-
Notifications
You must be signed in to change notification settings - Fork 7
Added media browsing request #50
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
55b4c45
Added media browsing request
albaintor 5140403
Added missing methods and commands
albaintor 6d0a3a5
Fixed search media response type
albaintor e55a4b4
Refactoring and added typed definitions
albaintor 69ab522
Finalized & tested updated API with browsing/search support
albaintor 0665b31
Removed forced media_type to MediaContentType as it can be custom
albaintor 8f9f553
Fixed bug
albaintor 7c0e585
Fixed bug
albaintor 0081b3d
Nailed bug finally
albaintor 4c16fbf
Fixes reported by Jack
albaintor d11cfea
Fixes following Markus review
albaintor d413f81
Fixed warning
albaintor 55a3d5e
Linting
albaintor a544238
Add documentation
zehnm d27d462
Fix BrowseMediaItem
zehnm 7fdc544
Refactor media classes
zehnm e1b8fad
Use MediaClass and MediaContentType in BrowseMediaItem
zehnm 792f11e
Refactor Paging and Pagination classes
zehnm 3553d3e
Merge branch 'main' into fork/albaintor/media_browsing
zehnm ee0e117
Separate SearchOptions from BrowseOptions
zehnm d2c1146
Add documentation to media-player classes
zehnm 99046c3
Fix `SearchMediaFilter` to support custom media classes
zehnm 0ce29c7
Improve error handling in browse and search
zehnm 70f18fe
Add text field validation in BrowseMediaItem
zehnm 6615bce
fixup! Add text field validation in BrowseMediaItem
zehnm 1b867d8
PR feedback: add exports and validate paging limit
zehnm File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| """ | ||
| Tests for media player entity. | ||
| """ | ||
|
|
||
| import unittest | ||
| from ucapi.media_player import SearchMediaFilter, MediaClass, BrowseMediaItem | ||
|
|
||
|
|
||
| class TestMediaPlayer(unittest.TestCase): | ||
| """Media player tests.""" | ||
|
|
||
| def test_search_media_filter_media_classes(self): | ||
| """Test SearchMediaFilter media_classes with standard and custom values.""" | ||
| # Test with standard MediaClass | ||
| smf = SearchMediaFilter(media_classes=[MediaClass.ALBUM, MediaClass.TRACK]) | ||
| self.assertEqual(smf.media_classes, [MediaClass.ALBUM, MediaClass.TRACK]) | ||
|
|
||
| # Test with strings that match MediaClass | ||
| smf = SearchMediaFilter(media_classes=["album", "track"]) | ||
| self.assertEqual(smf.media_classes, [MediaClass.ALBUM, MediaClass.TRACK]) | ||
|
|
||
| # Test with custom string values | ||
| try: | ||
| smf = SearchMediaFilter(media_classes=["custom_class", "another_one"]) | ||
| self.assertEqual(smf.media_classes, ["custom_class", "another_one"]) | ||
| except ValueError as e: | ||
| self.fail( | ||
| f"SearchMediaFilter raised ValueError for custom media classes: {e}" | ||
| ) | ||
|
|
||
| def test_search_media_filter_mixed_classes(self): | ||
| """Test SearchMediaFilter with a mix of MediaClass and custom strings.""" | ||
| try: | ||
| smf = SearchMediaFilter(media_classes=[MediaClass.ALBUM, "custom_class"]) | ||
| self.assertEqual(smf.media_classes, [MediaClass.ALBUM, "custom_class"]) | ||
| except ValueError as e: | ||
| self.fail( | ||
| f"SearchMediaFilter raised ValueError for mixed media classes: {e}" | ||
| ) | ||
|
|
||
| def test_search_media_filter_none(self): | ||
| """Test SearchMediaFilter with None media_classes.""" | ||
| smf = SearchMediaFilter(media_classes=None) | ||
| self.assertIsNone(smf.media_classes) | ||
|
|
||
| def test_search_media_filter_from_dict(self): | ||
| """Test SearchMediaFilter.from_dict with custom values.""" | ||
| data = { | ||
| "media_classes": ["album", "custom_class"], | ||
| "artist": "Some Artist", | ||
| "album": "Some Album", | ||
| } | ||
| try: | ||
| smf = SearchMediaFilter.from_dict(data) | ||
| self.assertEqual(smf.media_classes, [MediaClass.ALBUM, "custom_class"]) | ||
| self.assertEqual(smf.artist, "Some Artist") | ||
| self.assertEqual(smf.album, "Some Album") | ||
| except ValueError as e: | ||
| self.fail( | ||
| f"SearchMediaFilter.from_dict raised ValueError for custom media classes: {e}" | ||
| ) | ||
|
|
||
| def test_browse_media_item_validation_mandatory(self): | ||
| """Test BrowseMediaItem mandatory field validation.""" | ||
| # Valid mandatory fields | ||
| item = BrowseMediaItem(media_id="id1", title="Title") | ||
| self.assertEqual(item.media_id, "id1") | ||
| self.assertEqual(item.title, "Title") | ||
|
|
||
| # media_id empty | ||
| with self.assertRaisesRegex( | ||
| ValueError, "media_id must be at least 1 characters" | ||
| ): | ||
| BrowseMediaItem(media_id="", title="Title") | ||
|
|
||
| # media_id too long | ||
| with self.assertRaisesRegex( | ||
| ValueError, "media_id must be at most 255 characters" | ||
| ): | ||
| BrowseMediaItem(media_id="a" * 256, title="Title") | ||
|
|
||
| # media_id wrong type | ||
| with self.assertRaisesRegex(TypeError, "media_id must be str, got int"): | ||
| BrowseMediaItem(media_id=123, title="Title") | ||
|
|
||
| # title empty | ||
| with self.assertRaisesRegex(ValueError, "title must be at least 1 characters"): | ||
| BrowseMediaItem(media_id="id1", title="") | ||
|
|
||
| # title too long | ||
| with self.assertRaisesRegex(ValueError, "title must be at most 255 characters"): | ||
| BrowseMediaItem(media_id="id1", title="a" * 256) | ||
|
|
||
| def test_browse_media_item_validation_optional(self): | ||
| """Test BrowseMediaItem optional field validation.""" | ||
| # subtitle | ||
| with self.assertRaisesRegex( | ||
| ValueError, "subtitle must be at least 1 characters" | ||
| ): | ||
| BrowseMediaItem(media_id="id1", title="Title", subtitle="") | ||
| with self.assertRaisesRegex( | ||
| ValueError, "subtitle must be at most 255 characters" | ||
| ): | ||
| BrowseMediaItem(media_id="id1", title="Title", subtitle="a" * 256) | ||
|
|
||
| # artist | ||
| with self.assertRaisesRegex(ValueError, "artist must be at least 1 characters"): | ||
| BrowseMediaItem(media_id="id1", title="Title", artist="") | ||
| with self.assertRaisesRegex( | ||
| ValueError, "artist must be at most 255 characters" | ||
| ): | ||
| BrowseMediaItem(media_id="id1", title="Title", artist="a" * 256) | ||
|
|
||
| # album | ||
| with self.assertRaisesRegex(ValueError, "album must be at least 1 characters"): | ||
| BrowseMediaItem(media_id="id1", title="Title", album="") | ||
| with self.assertRaisesRegex(ValueError, "album must be at most 255 characters"): | ||
| BrowseMediaItem(media_id="id1", title="Title", album="a" * 256) | ||
|
|
||
| # media_class (only when it's a string) | ||
| # Note: media class is allowed to be empty! | ||
| BrowseMediaItem(media_id="id1", title="Title", media_class="") | ||
|
|
||
| with self.assertRaisesRegex( | ||
| ValueError, "media_class must be at most 255 characters" | ||
| ): | ||
| BrowseMediaItem(media_id="id1", title="Title", media_class="a" * 256) | ||
| # Verify it accepts MediaClass enum without error | ||
| BrowseMediaItem(media_id="id1", title="Title", media_class=MediaClass.ALBUM) | ||
|
|
||
| # media_type (only when it's a string) | ||
| # Note: media type is allowed to be empty! | ||
| BrowseMediaItem(media_id="id1", title="Title", media_type="") | ||
|
|
||
| with self.assertRaisesRegex( | ||
| ValueError, "media_type must be at most 255 characters" | ||
| ): | ||
| BrowseMediaItem(media_id="id1", title="Title", media_type="a" * 256) | ||
|
|
||
| def test_browse_media_item_validation_thumbnail(self): | ||
| """Test BrowseMediaItem thumbnail field validation.""" | ||
| # Valid length | ||
| BrowseMediaItem(media_id="id1", title="Title", thumbnail="a" * 32768) | ||
|
|
||
| # Too short (empty) | ||
| with self.assertRaisesRegex( | ||
| ValueError, "thumbnail must be at least 1 characters" | ||
| ): | ||
| BrowseMediaItem(media_id="id1", title="Title", thumbnail="") | ||
|
|
||
| # Too long | ||
| with self.assertRaisesRegex( | ||
| ValueError, "thumbnail must be at most 32768 characters" | ||
| ): | ||
| BrowseMediaItem(media_id="id1", title="Title", thumbnail="a" * 32769) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| """Unit tests for Paging and Pagination classes.""" | ||
|
|
||
| import json | ||
| import unittest | ||
| from dataclasses import asdict | ||
|
|
||
| from ucapi.api_definitions import Pagination, Paging | ||
|
|
||
|
|
||
| class TestPaging(unittest.TestCase): | ||
| """Test cases for the Paging class.""" | ||
|
|
||
| def test_paging_default(self): | ||
| """Test default Paging instantiation.""" | ||
| paging = Paging() | ||
| self.assertEqual(paging.page, 1) | ||
| self.assertEqual(paging.limit, 10) | ||
| self.assertEqual(paging.offset, 0) | ||
|
|
||
| def test_paging_custom(self): | ||
| """Test custom Paging instantiation.""" | ||
| paging = Paging(page=2, limit=20) | ||
| self.assertEqual(paging.page, 2) | ||
| self.assertEqual(paging.limit, 20) | ||
| self.assertEqual(paging.offset, 20) | ||
|
|
||
| def test_paging_invalid_page(self): | ||
| """Test validation for invalid page number.""" | ||
| with self.assertRaises(ValueError) as cm: | ||
| Paging(page=0) | ||
| self.assertIn("Invalid page: 0", str(cm.exception)) | ||
|
|
||
| with self.assertRaises(ValueError): | ||
| Paging(page=-1) | ||
|
|
||
| def test_paging_invalid_limit(self): | ||
| """Test validation for invalid limit.""" | ||
| with self.assertRaises(ValueError) as cm: | ||
| Paging(limit=0) | ||
| self.assertIn("Invalid limit: 0", str(cm.exception)) | ||
|
|
||
| with self.assertRaises(ValueError): | ||
| Paging(limit=-1) | ||
| with self.assertRaises(ValueError): | ||
| Paging(limit=10000) | ||
|
|
||
| def test_paging_from_dict(self): | ||
| """Test constructing Paging from a dictionary.""" | ||
| data = {"page": 3, "limit": 50} | ||
| paging = Paging.from_dict(data) | ||
| self.assertEqual(paging.page, 3) | ||
| self.assertEqual(paging.limit, 50) | ||
|
|
||
| def test_paging_from_dict_defaults(self): | ||
| """Test constructing Paging from an empty dictionary using defaults.""" | ||
| paging = Paging.from_dict({}) | ||
| self.assertEqual(paging.page, 1) | ||
| self.assertEqual(paging.limit, 10) | ||
|
|
||
| def test_paging_serialization(self): | ||
| """Test Paging JSON serialization.""" | ||
| paging = Paging(page=2, limit=25) | ||
| serialized = asdict(paging) | ||
| self.assertEqual(serialized, {"page": 2, "limit": 25}) | ||
|
|
||
| # Verify JSON round-trip | ||
| json_str = json.dumps(serialized) | ||
| self.assertEqual(json.loads(json_str), {"page": 2, "limit": 25}) | ||
|
|
||
|
|
||
| class TestPagination(unittest.TestCase): | ||
| """Test cases for the Pagination class.""" | ||
|
|
||
| def test_pagination_instantiation(self): | ||
| """Test Pagination instantiation with all fields.""" | ||
| pagination = Pagination(page=1, limit=10, count=100) | ||
| self.assertEqual(pagination.page, 1) | ||
| self.assertEqual(pagination.limit, 10) | ||
| self.assertEqual(pagination.count, 100) | ||
|
|
||
| def test_pagination_no_count(self): | ||
| """Test Pagination instantiation without count.""" | ||
| pagination = Pagination(page=2, limit=20) | ||
| self.assertEqual(pagination.page, 2) | ||
| self.assertEqual(pagination.limit, 20) | ||
| self.assertIsNone(pagination.count) | ||
|
|
||
| def test_pagination_invalid_page(self): | ||
| """Test validation for invalid page number.""" | ||
| with self.assertRaises(ValueError) as cm: | ||
| Pagination(page=0, limit=10) | ||
| self.assertIn("page must be >= 1", str(cm.exception)) | ||
|
|
||
| def test_pagination_invalid_limit(self): | ||
| """Test validation for invalid limit.""" | ||
| with self.assertRaises(ValueError) as cm: | ||
| Pagination(page=1, limit=-1) | ||
| self.assertIn("Invalid limit", str(cm.exception)) | ||
|
|
||
| def test_pagination_limit_out_of_range(self): | ||
| """Test validation for invalid limit.""" | ||
| with self.assertRaises(ValueError) as cm: | ||
| Pagination(page=1, limit=10000) | ||
| self.assertIn("Invalid limit", str(cm.exception)) | ||
|
|
||
| def test_pagination_invalid_count(self): | ||
| """Test validation for invalid count.""" | ||
| with self.assertRaises(ValueError) as cm: | ||
| Pagination(page=1, limit=10, count=-1) | ||
| self.assertIn("count cannot be negative", str(cm.exception)) | ||
|
|
||
| def test_pagination_serialization(self): | ||
| """Test Pagination JSON serialization.""" | ||
| pagination = Pagination(page=1, limit=10, count=100) | ||
| serialized = asdict(pagination) | ||
| self.assertEqual(serialized, {"page": 1, "limit": 10, "count": 100}) | ||
|
|
||
| def test_pagination_serialization_no_count(self): | ||
| """Test Pagination JSON serialization when count is None.""" | ||
| pagination = Pagination(page=2, limit=20) | ||
| json_data = asdict(pagination) | ||
|
|
||
| # Note: In JS/TS, undefined keys are omitted during JSON.stringify. | ||
| # In Python, None becomes `null` in JSON. | ||
| # This is not an issue: the Remote core service treats `null` as "not existing". | ||
| self.assertIn("count", json_data) | ||
| self.assertIsNone(json_data["count"]) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pytest is the testing framework preference now, but these tests will still run under pytest if we wanted to move in that direction and can be easily converted later. AI can do it in a heartbeat as well.