Skip to content
Merged
Show file tree
Hide file tree
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 Mar 4, 2026
5140403
Added missing methods and commands
albaintor Mar 8, 2026
6d0a3a5
Fixed search media response type
albaintor Mar 11, 2026
e55a4b4
Refactoring and added typed definitions
albaintor Mar 19, 2026
69ab522
Finalized & tested updated API with browsing/search support
albaintor Mar 19, 2026
0665b31
Removed forced media_type to MediaContentType as it can be custom
albaintor Mar 19, 2026
8f9f553
Fixed bug
albaintor Mar 19, 2026
7c0e585
Fixed bug
albaintor Mar 19, 2026
0081b3d
Nailed bug finally
albaintor Mar 19, 2026
4c16fbf
Fixes reported by Jack
albaintor Mar 19, 2026
d11cfea
Fixes following Markus review
albaintor Mar 20, 2026
d413f81
Fixed warning
albaintor Mar 22, 2026
55a3d5e
Linting
albaintor Mar 22, 2026
a544238
Add documentation
zehnm Mar 23, 2026
d27d462
Fix BrowseMediaItem
zehnm Mar 23, 2026
7fdc544
Refactor media classes
zehnm Mar 24, 2026
e1b8fad
Use MediaClass and MediaContentType in BrowseMediaItem
zehnm Mar 24, 2026
792f11e
Refactor Paging and Pagination classes
zehnm Mar 24, 2026
3553d3e
Merge branch 'main' into fork/albaintor/media_browsing
zehnm Mar 24, 2026
ee0e117
Separate SearchOptions from BrowseOptions
zehnm Mar 25, 2026
d2c1146
Add documentation to media-player classes
zehnm Mar 25, 2026
99046c3
Fix `SearchMediaFilter` to support custom media classes
zehnm Mar 25, 2026
0ce29c7
Improve error handling in browse and search
zehnm Mar 25, 2026
70f18fe
Add text field validation in BrowseMediaItem
zehnm Mar 25, 2026
6615bce
fixup! Add text field validation in BrowseMediaItem
zehnm Mar 25, 2026
1b867d8
PR feedback: add exports and validate paging limit
zehnm Mar 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

_Changes in the next release_

### Added
- Media browsing and searching features to media-player entity.

### Breaking Changes
- Renamed `MediaType` to `MediaContentType` and changed enums to lowercase. See media-player entity documentation for more information.
- Changed `str, Enum` to new Python 3.11 `StrEnum` class.

---

## v0.5.2 - 2026-01-30
Expand Down
159 changes: 159 additions & 0 deletions tests/test_media_player.py
Copy link
Copy Markdown
Contributor

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.

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()
131 changes: 131 additions & 0 deletions tests/test_paging.py
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()
17 changes: 16 additions & 1 deletion ucapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
DriverSetupRequest,
Events,
IntegrationSetupError,
Paging,
Pagination,
RequestUserConfirmation,
RequestUserInput,
SetupAction,
Expand All @@ -42,7 +44,20 @@
from .climate import Climate # noqa: F401
from .cover import Cover # noqa: F401
from .light import Light # noqa: F401
from .media_player import MediaPlayer # noqa: F401
from .media_player import ( # noqa: F401
BrowseMediaItem,
BrowseOptions,
BrowseResults,
MediaClass,
MediaContentType,
MediaPlayAction,
MediaPlayer,
RepeatMode,
SearchMediaFilter,
SearchMediaItem,
SearchOptions,
SearchResults,
)
from .remote import Remote # noqa: F401
from .select import Select # noqa: F401
from .sensor import Sensor # noqa: F401
Expand Down
Loading