"name": "Jellyfin Media Server Library",
"description": "Support for the Jellyfin streaming provider in Music Assistant.",
"codeowners": ["@lokiberra", "@Jc2k"],
- "requirements": ["aiojellyfin==0.7.0"],
+ "requirements": ["aiojellyfin==0.8.3"],
"documentation": "https://music-assistant.io/music-providers/jellyfin/",
"multi_instance": true
}
aiodns>=3.0.0
aiofiles==23.2.1
aiohttp==3.9.5
-aiojellyfin==0.7.0
+aiojellyfin==0.8.3
aiorun==2024.5.1
aioslimproto==3.0.1
aiosqlite==0.20.0
--- /dev/null
+"""Common test helpers for Music Assistant tests."""
+
+import asyncio
+import contextlib
+import pathlib
+from collections.abc import AsyncGenerator
+
+import aiofiles.os
+
+from music_assistant.common.models.enums import EventType
+from music_assistant.common.models.event import MassEvent
+from music_assistant.server.server import MusicAssistant
+
+
+def _get_fixture_folder(provider: str | None = None) -> pathlib.Path:
+ tests_base = pathlib.Path(__file__).parent
+ if provider:
+ return tests_base / "server" / "providers" / provider / "fixtures"
+ return tests_base / "fixtures"
+
+
+async def get_fixtures_dir(
+ subdir: str, provider: str | None = None
+) -> AsyncGenerator[tuple[str, bytes], None]:
+ """Yield the contents of every fixture in a fixtures folder."""
+ dir_path = _get_fixture_folder(provider) / subdir
+ for file in await aiofiles.os.listdir(dir_path):
+ async with aiofiles.open(dir_path / file, "rb") as fp:
+ yield (file, await fp.read())
+
+
+@contextlib.asynccontextmanager
+async def wait_for_sync_completion(mass: MusicAssistant) -> AsyncGenerator[None, None]:
+ """Wait for a sync to finish."""
+ flag = asyncio.Event()
+
+ def _event(event: MassEvent) -> None:
+ if not event.data:
+ flag.set()
+
+ release_cb = mass.subscribe(_event, EventType.SYNC_TASKS_UPDATED)
+
+ try:
+ yield
+ finally:
+ await flag.wait()
+ release_cb()
import pytest
from music_assistant.server.server import MusicAssistant
+from tests.common import wait_for_sync_completion
@pytest.fixture(name="caplog")
storage_path = tmp_path / "root"
storage_path.mkdir(parents=True)
- mass = MusicAssistant(storage_path)
- await mass.start()
+ logging.getLogger("aiosqlite").level = logging.INFO
+
+ mass = MusicAssistant(str(storage_path))
+
+ async with wait_for_sync_completion(mass):
+ await mass.start()
+
try:
yield mass
finally:
# serializer version: 1
+# name: test_parse_albums[infest]
+ dict({
+ 'album_type': 'unknown',
+ 'artists': list([
+ dict({
+ 'available': True,
+ 'external_ids': list([
+ ]),
+ 'image': None,
+ 'item_id': 'e439648e08ade14e27d5de48fa97c88e',
+ 'media_type': 'artist',
+ 'name': 'Papa Roach',
+ 'provider': 'xx-instance-id-xx',
+ 'sort_name': 'papa roach',
+ 'uri': 'xx-instance-id-xx://artist/e439648e08ade14e27d5de48fa97c88e',
+ 'version': '',
+ }),
+ ]),
+ 'external_ids': list([
+ list([
+ 'musicbrainz',
+ '0193355a-cdfb-3936-afd2-44d651eb006d',
+ ]),
+ ]),
+ 'favorite': False,
+ 'item_id': '70b7288088b42d318f75dbcc41fd0091',
+ 'media_type': 'album',
+ 'metadata': dict({
+ 'cache_checksum': None,
+ 'chapters': None,
+ 'copyright': None,
+ 'description': None,
+ 'explicit': None,
+ 'genres': None,
+ 'images': list([
+ dict({
+ 'path': 'http://localhost:1234/Items/70b7288088b42d318f75dbcc41fd0091/Images/Primary?api_key=ACCESS_TOKEN',
+ 'provider': 'xx-instance-id-xx',
+ 'remotely_accessible': False,
+ 'type': 'thumb',
+ }),
+ ]),
+ 'label': None,
+ 'last_refresh': None,
+ 'links': None,
+ 'lyrics': None,
+ 'mood': None,
+ 'performers': None,
+ 'popularity': None,
+ 'preview': None,
+ 'review': None,
+ 'style': None,
+ }),
+ 'name': 'Infest',
+ 'position': None,
+ 'provider': 'jellyfin',
+ 'provider_mappings': list([
+ dict({
+ 'audio_format': dict({
+ 'bit_depth': 16,
+ 'bit_rate': 320,
+ 'channels': 2,
+ 'content_type': '?',
+ 'output_format_str': '?',
+ 'sample_rate': 44100,
+ }),
+ 'available': True,
+ 'details': None,
+ 'item_id': '70b7288088b42d318f75dbcc41fd0091',
+ 'provider_domain': 'jellyfin',
+ 'provider_instance': 'xx-instance-id-xx',
+ 'url': None,
+ }),
+ ]),
+ 'sort_name': 'infest',
+ 'uri': 'jellyfin://album/70b7288088b42d318f75dbcc41fd0091',
+ 'version': '',
+ 'year': 2000,
+ })
+# ---
# name: test_parse_albums[this_is_christmas]
dict({
'album_type': 'unknown',
--- /dev/null
+{
+ "Name": "Infest",
+ "ServerId": "58f180d8d2b34927bcfd73eee400ffad",
+ "Id": "70b7288088b42d318f75dbcc41fd0091",
+ "Etag": "ecf97edd78eb2b76a30ae2adba6b66e5",
+ "DateCreated": "2023-12-11T12:10:40.7607527Z",
+ "CanDelete": false,
+ "CanDownload": false,
+ "SortName": "infest",
+ "PremiereDate": "2000-04-25T00:00:00.0000000Z",
+ "ExternalUrls": [
+ {
+ "Name": "MusicBrainz",
+ "Url": "https://musicbrainz.org/release/bf25b030-0cbb-495a-8d79-6c7fee20a089"
+ },
+ {
+ "Name": "MusicBrainz",
+ "Url": "https://musicbrainz.org/release-group/0193355a-cdfb-3936-afd2-44d651eb006d"
+ }
+ ],
+ "Path": "/media/music/Papa Roach/Infest",
+ "EnableMediaSourceDisplay": true,
+ "ChannelId": null,
+ "Taglines": [],
+ "Genres": [
+ "Alt Metal"
+ ],
+ "CumulativeRunTimeTicks": 27614273019,
+ "RunTimeTicks": 27614273019,
+ "PlayAccess": "Full",
+ "ProductionYear": 2000,
+ "RemoteTrailers": [],
+ "ProviderIds": {
+ "MusicBrainzAlbum": "bf25b030-0cbb-495a-8d79-6c7fee20a089",
+ "MusicBrainzReleaseGroup": "0193355a-cdfb-3936-afd2-44d651eb006d",
+ "MusicBrainzAlbumArtist": "c5eb9407-caeb-4303-b383-6929aa94021c"
+ },
+ "IsFolder": true,
+ "ParentId": "e439648e08ade14e27d5de48fa97c88e",
+ "Type": "MusicAlbum",
+ "People": [],
+ "Studios": [],
+ "GenreItems": [
+ {
+ "Name": "Alt Metal",
+ "Id": "7fae6ce8290515d5dfedc4e1894c1522"
+ }
+ ],
+ "ParentLogoItemId": "e439648e08ade14e27d5de48fa97c88e",
+ "ParentBackdropItemId": "e439648e08ade14e27d5de48fa97c88e",
+ "ParentBackdropImageTags": [
+ "c3d584db117d4c2bba5a975f391a965e"
+ ],
+ "LocalTrailerCount": 0,
+ "UserData": {
+ "PlaybackPositionTicks": 0,
+ "PlayCount": 0,
+ "IsFavorite": false,
+ "Played": false,
+ "Key": "MusicAlbum-MusicBrainzReleaseGroup-0193355a-cdfb-3936-afd2-44d651eb006d"
+ },
+ "RecursiveItemCount": 11,
+ "ChildCount": 11,
+ "SpecialFeatureCount": 0,
+ "DisplayPreferencesId": "f13d7f51d4f1f8b6fcd620855eb88c1e",
+ "Tags": [],
+ "PrimaryImageAspectRatio": 1,
+ "Artists": [
+ "Papa Roach"
+ ],
+ "ArtistItems": [
+ {
+ "Name": "Papa Roach",
+ "Id": "e439648e08ade14e27d5de48fa97c88e"
+ }
+ ],
+ "AlbumArtist": "Papa Roach",
+ "AlbumArtists": [
+ {
+ "Name": "Papa Roach",
+ "Id": "e439648e08ade14e27d5de48fa97c88e"
+ }
+ ],
+ "ImageTags": {
+ "Primary": "bcbe1ac159b0522743c9a0fe5401f948"
+ },
+ "BackdropImageTags": [],
+ "ParentLogoImageTag": "d58ea3bfadfb34e66033f55b8b2198c4",
+ "ImageBlurHashes": {
+ "Primary": {
+ "bcbe1ac159b0522743c9a0fe5401f948": "ecQb^8vf.S_2xY*0%hxDV[kXyYx^IUNGxt=ZsSNGV@njxuxuaKayS2"
+ },
+ "Logo": {
+ "d58ea3bfadfb34e66033f55b8b2198c4": "OQBftnWXD%WBNHoft7xaWBaej[fkoLay0Lax-:ofxZazRj"
+ },
+ "Backdrop": {
+ "c3d584db117d4c2bba5a975f391a965e": "W%F~5FodtRNGkCt6~Woet8Rkazs:-;j@ofoLWBkCxuWBays:axof"
+ }
+ },
+ "LocationType": "FileSystem",
+ "MediaType": "Unknown",
+ "LockedFields": [],
+ "LockData": false,
+ "NormalizationGain": -11
+}
\ No newline at end of file
--- /dev/null
+"""Tests for the Jellyfin provider."""
+
+from collections.abc import AsyncGenerator
+from unittest import mock
+
+import pytest
+from aiojellyfin.testing import FixtureBuilder
+
+from music_assistant.common.models.config_entries import ProviderConfig
+from music_assistant.server.server import MusicAssistant
+from tests.common import get_fixtures_dir, wait_for_sync_completion
+
+
+@pytest.fixture
+async def jellyfin_provider(mass: MusicAssistant) -> AsyncGenerator[ProviderConfig, None]:
+ """Configure an aiojellyfin test fixture, and add a provider to mass that uses it."""
+ f = FixtureBuilder()
+ async for _, artist in get_fixtures_dir("artists", "jellyfin"):
+ f.add_artist_bytes(artist)
+
+ async for _, album in get_fixtures_dir("albums", "jellyfin"):
+ f.add_album_bytes(album)
+
+ async for _, track in get_fixtures_dir("tracks", "jellyfin"):
+ f.add_track_bytes(track)
+
+ authenticate_by_name = f.to_authenticate_by_name()
+
+ with mock.patch(
+ "music_assistant.server.providers.jellyfin.authenticate_by_name", authenticate_by_name
+ ):
+ async with wait_for_sync_completion(mass):
+ config = await mass.config.save_provider_config(
+ "jellyfin",
+ {
+ "url": "http://localhost",
+ "username": "username",
+ "password": "password",
+ },
+ )
+
+ yield config
+
+
+@pytest.mark.usefixtures("jellyfin_provider")
+async def test_initial_sync(mass: MusicAssistant) -> None:
+ """Test that initial sync worked."""
+ artists = await mass.music.artists.library_items(search="Ash")
+ assert artists[0].name == "Ash"
+
+ albums = await mass.music.albums.library_items(search="christmas")
+ assert albums[0].name == "This Is Christmas"
+
+ tracks = await mass.music.tracks.library_items(search="where the bands are")
+ assert tracks[0].name == "Where the Bands Are (2018 Version)"