Improve uri parsing (#302)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 11 May 2022 10:32:28 +0000 (12:32 +0200)
committerGitHub <noreply@github.com>
Wed, 11 May 2022 10:32:28 +0000 (12:32 +0200)
* move enums to separate file to prevent circular imports

* add pytest to CI

* Update uri.py

32 files changed:
.github/workflows/test.yml
music_assistant/constants.py
music_assistant/controllers/metadata/__init__.py
music_assistant/controllers/metadata/audiodb.py
music_assistant/controllers/metadata/fanarttv.py
music_assistant/controllers/metadata/musicbrainz.py
music_assistant/controllers/music/__init__.py
music_assistant/controllers/music/albums.py
music_assistant/controllers/music/artists.py
music_assistant/controllers/music/playlists.py
music_assistant/controllers/music/radio.py
music_assistant/controllers/music/tracks.py
music_assistant/controllers/players.py
music_assistant/controllers/stream.py
music_assistant/helpers/audio.py
music_assistant/helpers/cache.py
music_assistant/helpers/database.py
music_assistant/helpers/images.py
music_assistant/helpers/typing.py [deleted file]
music_assistant/helpers/uri.py [new file with mode: 0644]
music_assistant/mass.py
music_assistant/models/background_job.py [new file with mode: 0644]
music_assistant/models/enums.py [new file with mode: 0644]
music_assistant/models/event.py [new file with mode: 0644]
music_assistant/models/media_controller.py
music_assistant/models/media_items.py
music_assistant/models/player.py
music_assistant/models/player_queue.py
music_assistant/models/provider.py
music_assistant/providers/qobuz.py
tests/test_helpers.py [new file with mode: 0644]
tests/utils.py [deleted file]

index 73eaddc8c1dda72955695b3a785339ba73c1f167..4fc09282eaac3381ba1561d3dd23c5ba7511e6a2 100644 (file)
@@ -32,3 +32,5 @@ jobs:
       - name: Run pylint on changed files
         run: |
           pylint -rn -sn --rcfile=pylintrc --fail-on=I $(git ls-files '*.py')
+      - name: Run unit tests with pytest
+        run: pytest tests/
index 25506a42ab4548c0b65449042b963b9fdac7c1a1..0d036d30ce1022a642ffac37a70f4271e444b6ca 100755 (executable)
@@ -1,74 +1,5 @@
 """All constants for Music Assistant."""
 
-from dataclasses import dataclass
-from enum import Enum
-from time import time
-from typing import Any, Coroutine, Optional
-
-
-class EventType(Enum):
-    """Enum with possible Events."""
-
-    PLAYER_ADDED = "player added"
-    PLAYER_REMOVED = "player removed"
-    PLAYER_UPDATED = "player updated"
-    STREAM_STARTED = "streaming started"
-    STREAM_ENDED = "streaming ended"
-    MUSIC_SYNC_STATUS = "music sync status"
-    QUEUE_ADDED = "queue_added"
-    QUEUE_UPDATED = "queue updated"
-    QUEUE_ITEMS_UPDATED = "queue items updated"
-    QUEUE_TIME_UPDATED = "queue time updated"
-    SHUTDOWN = "application shutdown"
-    ARTIST_ADDED = "artist added"
-    ALBUM_ADDED = "album added"
-    TRACK_ADDED = "track added"
-    PLAYLIST_ADDED = "playlist added"
-    PLAYLIST_UPDATED = "playlist updated"
-    RADIO_ADDED = "radio added"
-    TASK_UPDATED = "task updated"
-    PROVIDER_REGISTERED = "provider registered"
-    BACKGROUND_JOB_UPDATED = "background_job_updated"
-
-
-@dataclass
-class MassEvent:
-    """Representation of an Event emitted in/by Music Assistant."""
-
-    type: EventType
-    object_id: Optional[str] = None  # player_id, queue_id or uri
-    data: Optional[Any] = None  # optional data (such as the object)
-
-
-class JobStatus(Enum):
-    """Enum with Job status."""
-
-    PENDING = "pending"
-    RUNNING = "running"
-    CANCELLED = "cancelled"
-    FINISHED = "success"
-    ERROR = "error"
-
-
-@dataclass
-class BackgroundJob:
-    """Description of a background job/task."""
-
-    id: str
-    coro: Coroutine
-    name: str
-    timestamp: float = time()
-    status: JobStatus = JobStatus.PENDING
-
-    def to_dict(self):
-        """Return serializable dict from object."""
-        return {
-            "id": self.id,
-            "name": self.name,
-            "timestamp": self.status.value,
-            "status": self.status.value,
-        }
-
 
 # player attributes
 ATTR_PLAYER_ID = "player_id"
index 072c1bee7aafda52cae03640914c879c47b4ca10..00a229f3184fb0890a593a9f4ebe0bb889a2e2dd 100755 (executable)
@@ -2,16 +2,18 @@
 from __future__ import annotations
 
 from time import time
-from typing import Optional
+from typing import TYPE_CHECKING, Optional
 
 from music_assistant.helpers.images import create_thumbnail
-from music_assistant.helpers.typing import MusicAssistant
 from music_assistant.models.media_items import Album, Artist, Playlist, Radio, Track
 
 from .audiodb import TheAudioDb
 from .fanarttv import FanartTv
 from .musicbrainz import MusicBrainz
 
+if TYPE_CHECKING:
+    from music_assistant.mass import MusicAssistant
+
 TABLE_THUMBS = "thumbnails"
 
 
index f1717884af5616ac6cc4e6288ba8c572e2da3d73..b033d6b40778e22db2e75d4f3782f29774fdea6c 100755 (executable)
@@ -2,7 +2,7 @@
 from __future__ import annotations
 
 from json.decoder import JSONDecodeError
-from typing import Any, Dict, Optional
+from typing import TYPE_CHECKING, Any, Dict, Optional
 
 import aiohttp
 from asyncio_throttle import Throttler
@@ -12,7 +12,6 @@ from music_assistant.helpers.app_vars import (  # pylint: disable=no-name-in-mod
 )
 from music_assistant.helpers.cache import use_cache
 from music_assistant.helpers.compare import compare_strings
-from music_assistant.helpers.typing import MusicAssistant
 from music_assistant.models.media_items import (
     Album,
     AlbumType,
@@ -25,6 +24,9 @@ from music_assistant.models.media_items import (
     Track,
 )
 
+if TYPE_CHECKING:
+    from music_assistant.mass import MusicAssistant
+
 IMG_MAPPING = {
     "strArtistThumb": ImageType.THUMB,
     "strArtistLogo": ImageType.LOGO,
index ab8b3603294a1654a14fe2b927afa3589090797d..bdee1cab08e4652b40835201ff28a6d64b4cca77 100755 (executable)
@@ -2,7 +2,7 @@
 from __future__ import annotations
 
 from json.decoder import JSONDecodeError
-from typing import Optional
+from typing import TYPE_CHECKING, Optional
 
 import aiohttp
 from asyncio_throttle import Throttler
@@ -11,7 +11,6 @@ from music_assistant.helpers.app_vars import (  # pylint: disable=no-name-in-mod
     app_var,
 )
 from music_assistant.helpers.cache import use_cache
-from music_assistant.helpers.typing import MusicAssistant
 from music_assistant.models.media_items import (
     Album,
     Artist,
@@ -20,8 +19,11 @@ from music_assistant.models.media_items import (
     MediaItemMetadata,
 )
 
+if TYPE_CHECKING:
+    from music_assistant.mass import MusicAssistant
+
 # TODO: add support for personal api keys ?
-# TODO: Add support for album artwork ?
+
 
 IMG_MAPPING = {
     "artistthumb": ImageType.THUMB,
index 3712798109ec7934592beb17099845d72f28bb99..319b8ac5e95a375eae2fddd431a0b5ae21b435cc 100644 (file)
@@ -3,13 +3,16 @@ from __future__ import annotations
 
 import re
 from json.decoder import JSONDecodeError
+from typing import TYPE_CHECKING
 
 import aiohttp
 from asyncio_throttle import Throttler
 
 from music_assistant.helpers.cache import use_cache
 from music_assistant.helpers.compare import compare_strings, get_compare_string
-from music_assistant.helpers.typing import MusicAssistant
+
+if TYPE_CHECKING:
+    from music_assistant.mass import MusicAssistant
 
 LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])'
 
index 74c0190fd49925419a99ca71e13e855a0d8e6a2d..d13323c61daec0116043920aff6b35a0ace1b888 100755 (executable)
@@ -3,11 +3,10 @@ from __future__ import annotations
 
 import asyncio
 import statistics
-from typing import Dict, List, Optional, Tuple
+from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
 
 from databases import Database as Db
 
-from music_assistant.constants import EventType, MassEvent
 from music_assistant.controllers.music.albums import AlbumsController
 from music_assistant.controllers.music.artists import ArtistsController
 from music_assistant.controllers.music.playlists import PlaylistController
@@ -19,21 +18,25 @@ from music_assistant.helpers.database import (
     TABLE_TRACK_LOUDNESS,
 )
 from music_assistant.helpers.datetime import utc_timestamp
-from music_assistant.helpers.typing import MusicAssistant
+from music_assistant.helpers.uri import parse_uri
 from music_assistant.helpers.util import run_periodic
+from music_assistant.models.enums import EventType, MediaType
 from music_assistant.models.errors import (
     AlreadyRegisteredError,
     MusicAssistantError,
     SetupFailedError,
 )
+from music_assistant.models.event import MassEvent
 from music_assistant.models.media_items import (
     MediaItem,
     MediaItemProviderId,
     MediaItemType,
-    MediaType,
 )
 from music_assistant.models.provider import MusicProvider
 
+if TYPE_CHECKING:
+    from music_assistant.mass import MusicAssistant
+
 
 class MusicController:
     """Several helpers around the musicproviders."""
@@ -147,19 +150,7 @@ class MusicController:
         self, uri: str, force_refresh: bool = False, lazy: bool = True
     ) -> MediaItemType:
         """Fetch MediaItem by uri."""
-        try:
-            if "://" in uri:
-                provider = uri.split("://")[0]
-                item_id = uri.split("/")[-1]
-                media_type = MediaType(uri.split("/")[-2])
-            elif "spotify" in uri:
-                # spotify new-style uri
-                provider, media_type, item_id = uri.split(":")
-                media_type = MediaType(media_type)
-        except (TypeError, AttributeError, ValueError) as err:
-            raise MusicAssistantError(
-                f"Not a valid Music Assistant uri: {uri}"
-            ) from err
+        media_type, provider, item_id = parse_uri(uri)
         return await self.get_item(
             item_id, provider, media_type, force_refresh=force_refresh, lazy=lazy
         )
index 827da2e7216c8b7e3b54c9752df2b34856d7d7c2..5f9cee5584f71895574f25c332a62f4eaca8e9f2 100644 (file)
@@ -4,11 +4,12 @@ from __future__ import annotations
 import asyncio
 from typing import List
 
-from music_assistant.constants import EventType, MassEvent
 from music_assistant.helpers.compare import compare_album, compare_strings
 from music_assistant.helpers.database import TABLE_ALBUMS
 from music_assistant.helpers.json import json_serializer
 from music_assistant.helpers.util import create_sort_name
+from music_assistant.models.enums import EventType
+from music_assistant.models.event import MassEvent
 from music_assistant.models.media_controller import MediaControllerBase
 from music_assistant.models.media_items import (
     Album,
index e1613bb67d37bc966ee87124eb46070e264d3cfb..ef9f5d7540d547ac871d3813eabe07c4af99652a 100644 (file)
@@ -4,7 +4,6 @@ import asyncio
 import itertools
 from typing import List
 
-from music_assistant.constants import EventType, MassEvent
 from music_assistant.helpers.compare import (
     compare_album,
     compare_strings,
@@ -13,6 +12,8 @@ from music_assistant.helpers.compare import (
 from music_assistant.helpers.database import TABLE_ARTISTS
 from music_assistant.helpers.json import json_serializer
 from music_assistant.helpers.util import create_sort_name
+from music_assistant.models.enums import EventType
+from music_assistant.models.event import MassEvent
 from music_assistant.models.media_controller import MediaControllerBase
 from music_assistant.models.media_items import (
     Album,
index cd8d62d3aef13696192e3267e034ebb817d5b4ed..3e91f4e3e28466cd29e59e0186b532f351f3ad11 100644 (file)
@@ -4,13 +4,14 @@ from __future__ import annotations
 from time import time
 from typing import List
 
-from music_assistant.constants import EventType, MassEvent
 from music_assistant.helpers.database import TABLE_PLAYLISTS
 from music_assistant.helpers.json import json_serializer
 from music_assistant.helpers.util import create_sort_name
+from music_assistant.models.enums import EventType, MediaType
 from music_assistant.models.errors import InvalidDataError, MediaNotFoundError
+from music_assistant.models.event import MassEvent
 from music_assistant.models.media_controller import MediaControllerBase
-from music_assistant.models.media_items import MediaType, Playlist, Track
+from music_assistant.models.media_items import Playlist, Track
 
 
 class PlaylistController(MediaControllerBase[Playlist]):
index 63ab65190abdec23f8f5257853453f1bc10805d6..4bcf14da939f58546abb0af14201cc121695e978 100644 (file)
@@ -3,12 +3,13 @@ from __future__ import annotations
 
 from time import time
 
-from music_assistant.constants import EventType, MassEvent
 from music_assistant.helpers.database import TABLE_RADIOS
 from music_assistant.helpers.json import json_serializer
 from music_assistant.helpers.util import create_sort_name
+from music_assistant.models.enums import EventType, MediaType
+from music_assistant.models.event import MassEvent
 from music_assistant.models.media_controller import MediaControllerBase
-from music_assistant.models.media_items import MediaType, Radio
+from music_assistant.models.media_items import Radio
 
 
 class RadioController(MediaControllerBase[Radio]):
index e06aae69834ccbc54a1c7f20ec04aa9e1770d928..7a02986b9a78e6dccc53c995577da9720efc5961 100644 (file)
@@ -4,7 +4,6 @@ from __future__ import annotations
 import asyncio
 from typing import List
 
-from music_assistant.constants import EventType, MassEvent
 from music_assistant.helpers.compare import (
     compare_artists,
     compare_strings,
@@ -13,8 +12,10 @@ from music_assistant.helpers.compare import (
 from music_assistant.helpers.database import TABLE_TRACKS
 from music_assistant.helpers.json import json_serializer
 from music_assistant.helpers.util import create_sort_name
+from music_assistant.models.enums import EventType, MediaType
+from music_assistant.models.event import MassEvent
 from music_assistant.models.media_controller import MediaControllerBase
-from music_assistant.models.media_items import ItemMapping, MediaType, Track
+from music_assistant.models.media_items import ItemMapping, Track
 
 
 class TracksController(MediaControllerBase[Track]):
index 53b83014ca57ed81889e9f589bf18cd87d841f98..7f124665c2965eccddf71794b07f968c22378a70 100755 (executable)
@@ -1,14 +1,17 @@
 """Logic to play music from MusicProviders to supported players."""
 from __future__ import annotations
 
-from typing import Dict, Tuple, Union
+from typing import TYPE_CHECKING, Dict, Tuple, Union
 
-from music_assistant.constants import EventType, MassEvent
-from music_assistant.helpers.typing import MusicAssistant
+from music_assistant.models.enums import EventType
 from music_assistant.models.errors import AlreadyRegisteredError
+from music_assistant.models.event import MassEvent
 from music_assistant.models.player import Player, PlayerGroup
 from music_assistant.models.player_queue import PlayerQueue
 
+if TYPE_CHECKING:
+    from music_assistant.mass import MusicAssistant
+
 PlayerType = Union[Player, PlayerGroup]
 
 DB_TABLE = "queue_settings"
index b38a0815be1460bb18d308cc6a57554e241fac22..830bc6d08271ddc03a04f281b23d63d50df820a0 100644 (file)
@@ -4,11 +4,10 @@ from __future__ import annotations
 import asyncio
 from asyncio import Task
 from time import time
-from typing import AsyncGenerator, Dict, Optional, Set
+from typing import TYPE_CHECKING, AsyncGenerator, Dict, Optional, Set
 
 from aiohttp import web
 
-from music_assistant.constants import EventType, MassEvent
 from music_assistant.helpers.audio import (
     check_audio_support,
     crossfade_pcm_parts,
@@ -19,11 +18,19 @@ from music_assistant.helpers.audio import (
     strip_silence,
 )
 from music_assistant.helpers.process import AsyncProcess
-from music_assistant.helpers.typing import MusicAssistant
 from music_assistant.helpers.util import get_ip, select_stream_port
+from music_assistant.models.enums import (
+    ContentType,
+    CrossFadeMode,
+    EventType,
+    MediaType,
+)
 from music_assistant.models.errors import MediaNotFoundError, MusicAssistantError
-from music_assistant.models.media_items import ContentType, MediaType
-from music_assistant.models.player_queue import CrossFadeMode, PlayerQueue, QueueItem
+from music_assistant.models.event import MassEvent
+from music_assistant.models.player_queue import PlayerQueue, QueueItem
+
+if TYPE_CHECKING:
+    from music_assistant.mass import MusicAssistant
 
 
 class StreamController:
index d4932a59314923b58a3ad77f58837a1e6c5e0c66..42d1eab9ed6bfa07b570668c0945441b579f14a1 100644 (file)
@@ -5,15 +5,15 @@ import asyncio
 import logging
 import struct
 from io import BytesIO
-from typing import AsyncGenerator, List, Optional, Tuple
+from typing import TYPE_CHECKING, AsyncGenerator, List, Optional, Tuple
 
 import aiofiles
 
-from music_assistant.constants import EventType, MassEvent
 from music_assistant.helpers.process import AsyncProcess, check_output
-from music_assistant.helpers.typing import MusicAssistant, QueueItem
 from music_assistant.helpers.util import create_tempfile
+from music_assistant.models.enums import EventType
 from music_assistant.models.errors import AudioError, MediaNotFoundError
+from music_assistant.models.event import MassEvent
 from music_assistant.models.media_items import (
     ContentType,
     MediaType,
@@ -21,6 +21,10 @@ from music_assistant.models.media_items import (
     StreamType,
 )
 
+if TYPE_CHECKING:
+    from music_assistant.mass import MusicAssistant
+    from music_assistant.models.player_queue import QueueItem
+
 LOGGER = logging.getLogger("audio")
 
 # pylint:disable=consider-using-f-string
@@ -175,7 +179,7 @@ async def analyze_audio(mass: MusicAssistant, streamdetails: StreamDetails) -> N
 
 
 async def get_stream_details(
-    mass: MusicAssistant, queue_item: QueueItem, queue_id: str = ""
+    mass: MusicAssistant, queue_item: "QueueItem", queue_id: str = ""
 ) -> StreamDetails:
     """
     Get streamdetails for the given QueueItem.
index 53284e21eec0aaad60b966d815f3746ceefcb2c1..522f7f1e58d138e25568a52ec2c18325b445dc70 100644 (file)
@@ -5,9 +5,12 @@ import asyncio
 import functools
 import json
 import time
+from typing import TYPE_CHECKING
 
 from music_assistant.helpers.database import TABLE_CACHE
-from music_assistant.helpers.typing import MusicAssistant
+
+if TYPE_CHECKING:
+    from music_assistant.mass import MusicAssistant
 
 
 class Cache:
index 1c730ab250809deea19757ed1ce705d8b2d2bb6f..798e1fe932be4e388d3fc998d68ef6b060ad5ba1 100755 (executable)
@@ -2,12 +2,13 @@
 from __future__ import annotations
 
 from contextlib import asynccontextmanager
-from typing import Any, Dict, List, Mapping, Optional
+from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional
 
 from databases import Database as Db
 from databases import DatabaseURL
 
-from music_assistant.helpers.typing import MusicAssistant
+if TYPE_CHECKING:
+    from music_assistant.mass import MusicAssistant
 
 # pylint: disable=invalid-name
 
index fb334e21b3804b8a4e85d08affa70675c60be419..8ab2ea99df2fb770f573e10f967902a7d0582422 100644 (file)
@@ -2,16 +2,15 @@
 from __future__ import annotations
 
 from io import BytesIO
+from typing import TYPE_CHECKING
 
 from PIL import Image
 
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.models.media_items import (
-    ImageType,
-    ItemMapping,
-    MediaItemType,
-    MediaType,
-)
+from music_assistant.models.enums import ImageType, MediaType
+from music_assistant.models.media_items import ItemMapping, MediaItemType
+
+if TYPE_CHECKING:
+    from music_assistant.mass import MusicAssistant
 
 
 async def create_thumbnail(mass: MusicAssistant, url, size: int = 150) -> bytes:
diff --git a/music_assistant/helpers/typing.py b/music_assistant/helpers/typing.py
deleted file mode 100644 (file)
index 578ca3d..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-"""Typing helper."""
-from __future__ import annotations
-
-from typing import TYPE_CHECKING, List, Optional
-
-# pylint: disable=invalid-name
-if TYPE_CHECKING:
-    from music_assistant.mass import (
-        EventCallBackType,
-        EventSubscriptionType,
-        MusicAssistant,
-    )
-    from music_assistant.models.media_items import MediaType
-    from music_assistant.models.player import Player
-    from music_assistant.models.player_queue import PlayerQueue, QueueItem
-
-else:
-    MusicAssistant = "MusicAssistant"
-    QueueItem = "QueueItem"
-    PlayerQueue = "PlayerQueue"
-    StreamDetails = "StreamDetails"
-    Player = "Player"
-    MediaType = "MediaType"
-    EventCallBackType = "EventCallBackType"
-    EventSubscriptionType = "EventSubscriptionType"
-
-
-QueueItems = List[QueueItem]
-Players = List[Player]
-
-OptionalInt = Optional[int]
-OptionalStr = Optional[str]
diff --git a/music_assistant/helpers/uri.py b/music_assistant/helpers/uri.py
new file mode 100644 (file)
index 0000000..92a3374
--- /dev/null
@@ -0,0 +1,43 @@
+"""Helpers for creating/parsing URI's."""
+
+from typing import Tuple
+
+from music_assistant.models.enums import MediaType
+from music_assistant.models.errors import MusicAssistantError
+
+
+def parse_uri(uri: str) -> Tuple[MediaType, str, str]:
+    """
+    Try to parse URI to Mass identifiers.
+
+    Returns Tuple: MediaType, provider, item_id
+    """
+    try:
+        if uri.startswith("https://open."):
+            # public share URL (e.g. Spotify or Qobuz, not sure about others)
+            # https://open.spotify.com/playlist/5lH9NjOeJvctAO92ZrKQNB?si=04a63c8234ac413e
+            provider = uri.split(".")[1]
+            media_type_str = uri.split("/")[3]
+            media_type = MediaType(media_type_str)
+            item_id = uri.split("/")[4].split("?")[0]
+        elif "://" in uri:
+            # music assistant-style uri
+            # provider://media_type/item_id
+            provider = uri.split("://")[0]
+            media_type_str = uri.split("/")[2]
+            media_type = MediaType(media_type_str)
+            item_id = uri.split(f"{media_type_str}/")[1]
+        elif ":" in uri:
+            # spotify new-style uri
+            provider, media_type_str, item_id = uri.split(":")
+            media_type = MediaType(media_type_str)
+        else:
+            raise KeyError
+    except (TypeError, AttributeError, ValueError, KeyError) as err:
+        raise MusicAssistantError(f"Not a valid Music Assistant uri: {uri}") from err
+    return (media_type, provider, item_id)
+
+
+def create_uri(media_type: MediaType, provider: str, item_id: str) -> str:
+    """Create Music Assistant URI from MediaItem values."""
+    return f"{provider}://{media_type.value}/{item_id}"
index 7dfe167361b46a32ca8763af4ae6119b7c8ae63e..6455c8d1c3e9ef5db83f47f65ad5b386418027ab 100644 (file)
@@ -15,13 +15,15 @@ from uuid import uuid4
 import aiohttp
 from databases import DatabaseURL
 
-from music_assistant.constants import BackgroundJob, EventType, JobStatus, MassEvent
 from music_assistant.controllers.metadata import MetaDataController
 from music_assistant.controllers.music import MusicController
 from music_assistant.controllers.players import PlayerController
 from music_assistant.controllers.stream import StreamController
 from music_assistant.helpers.cache import Cache
 from music_assistant.helpers.database import Database
+from music_assistant.models.background_job import BackgroundJob
+from music_assistant.models.enums import EventType, JobStatus
+from music_assistant.models.event import MassEvent
 
 EventCallBackType = Callable[[MassEvent], None]
 EventSubscriptionType = Tuple[
diff --git a/music_assistant/models/background_job.py b/music_assistant/models/background_job.py
new file mode 100644 (file)
index 0000000..bc61c33
--- /dev/null
@@ -0,0 +1,26 @@
+"""Model for a Background Job."""
+from dataclasses import dataclass
+from time import time
+from typing import Coroutine
+
+from music_assistant.models.enums import JobStatus
+
+
+@dataclass
+class BackgroundJob:
+    """Description of a background job/task."""
+
+    id: str
+    coro: Coroutine
+    name: str
+    timestamp: float = time()
+    status: JobStatus = JobStatus.PENDING
+
+    def to_dict(self):
+        """Return serializable dict from object."""
+        return {
+            "id": self.id,
+            "name": self.name,
+            "timestamp": self.status.value,
+            "status": self.status.value,
+        }
diff --git a/music_assistant/models/enums.py b/music_assistant/models/enums.py
new file mode 100644 (file)
index 0000000..5c03dcc
--- /dev/null
@@ -0,0 +1,208 @@
+"""All enums used by the Music Assistant models."""
+
+from enum import Enum, IntEnum
+
+
+class MediaType(Enum):
+    """Enum for MediaType."""
+
+    ARTIST = "artist"
+    ALBUM = "album"
+    TRACK = "track"
+    PLAYLIST = "playlist"
+    RADIO = "radio"
+    UNKNOWN = "unknown"
+
+
+class MediaQuality(IntEnum):
+    """Enum for Media Quality."""
+
+    UNKNOWN = 0
+    LOSSY_MP3 = 1
+    LOSSY_OGG = 2
+    LOSSY_AAC = 3
+    FLAC_LOSSLESS = 10  # 44.1/48khz 16 bits
+    FLAC_LOSSLESS_HI_RES_1 = 20  # 44.1/48khz 24 bits HI-RES
+    FLAC_LOSSLESS_HI_RES_2 = 21  # 88.2/96khz 24 bits HI-RES
+    FLAC_LOSSLESS_HI_RES_3 = 22  # 176/192khz 24 bits HI-RES
+    FLAC_LOSSLESS_HI_RES_4 = 23  # above 192khz 24 bits HI-RES
+
+
+class LinkType(Enum):
+    """Enum wth link types."""
+
+    WEBSITE = "website"
+    FACEBOOK = "facebook"
+    TWITTER = "twitter"
+    LASTFM = "lastfm"
+    YOUTUBE = "youtube"
+    INSTAGRAM = "instagram"
+    SNAPCHAT = "snapchat"
+    TIKTOK = "tiktok"
+    DISCOGS = "discogs"
+    WIKIPEDIA = "wikipedia"
+    ALLMUSIC = "allmusic"
+
+
+class ImageType(Enum):
+    """Enum wth image types."""
+
+    THUMB = "thumb"
+    WIDE_THUMB = "wide_thumb"
+    FANART = "fanart"
+    LOGO = "logo"
+    CLEARART = "clearart"
+    BANNER = "banner"
+    CUTOUT = "cutout"
+    BACK = "back"
+    CDART = "cdart"
+    EMBEDDED_THUMB = "embedded_thumb"
+    OTHER = "other"
+
+
+class AlbumType(Enum):
+    """Enum for Album type."""
+
+    ALBUM = "album"
+    SINGLE = "single"
+    COMPILATION = "compilation"
+    UNKNOWN = "unknown"
+
+
+class StreamType(Enum):
+    """Enum with stream types."""
+
+    EXECUTABLE = "executable"
+    URL = "url"
+    FILE = "file"
+    CACHE = "cache"
+
+
+class ContentType(Enum):
+    """Enum with audio content types supported by ffmpeg."""
+
+    OGG = "ogg"
+    FLAC = "flac"
+    MP3 = "mp3"
+    AAC = "aac"
+    MPEG = "mpeg"
+    WAV = "wav"
+    PCM_S16LE = "s16le"  # PCM signed 16-bit little-endian
+    PCM_S24LE = "s24le"  # PCM signed 24-bit little-endian
+    PCM_S32LE = "s32le"  # PCM signed 32-bit little-endian
+    PCM_F32LE = "f32le"  # PCM 32-bit floating-point little-endian
+    PCM_F64LE = "f64le"  # PCM 64-bit floating-point little-endian
+
+    @classmethod
+    def try_parse(
+        cls: "ContentType", string: str, fallback: str = "mp3"
+    ) -> "ContentType":
+        """Try to parse ContentType from (url)string."""
+        tempstr = string.lower()
+        if "." in tempstr:
+            tempstr = tempstr.split(".")[-1]
+        tempstr = tempstr.split("?")[0]
+        tempstr = tempstr.split("&")[0]
+        try:
+            return cls(tempstr)
+        except ValueError:
+            return cls(fallback)
+
+    def is_pcm(self):
+        """Return if contentype is PCM."""
+        return self.name.startswith("PCM")
+
+    def sox_supported(self):
+        """Return if ContentType is supported by SoX."""
+        return self not in [ContentType.AAC, ContentType.MPEG]
+
+    def sox_format(self):
+        """Convert the ContentType to SoX compatible format."""
+        if not self.sox_supported():
+            raise NotImplementedError
+        return self.value.replace("le", "")
+
+    @classmethod
+    def from_bit_depth(
+        cls, bit_depth: int, floating_point: bool = False
+    ) -> "ContentType":
+        """Return (PCM) Contenttype from PCM bit depth."""
+        if floating_point and bit_depth > 32:
+            return cls.PCM_F64LE
+        if floating_point:
+            return cls.PCM_F32LE
+        if bit_depth == 16:
+            return cls.PCM_S16LE
+        if bit_depth == 24:
+            return cls.PCM_S24LE
+        return cls.PCM_S32LE
+
+
+class QueueOption(Enum):
+    """Enum representation of the queue (play) options."""
+
+    PLAY = "play"
+    REPLACE = "replace"
+    NEXT = "next"
+    ADD = "add"
+
+
+class CrossFadeMode(Enum):
+    """Enum with crossfade modes."""
+
+    DISABLED = "disabled"  # no crossfading at all
+    STRICT = "strict"  # do not crossfade tracks of same album
+    SMART = "smart"  # crossfade if possible (do not crossfade different sample rates)
+    ALWAYS = "always"  # all tracks - resample to fixed sample rate
+
+
+class RepeatMode(Enum):
+    """Enum with repeat modes."""
+
+    OFF = "off"  # no repeat at all
+    ONE = "one"  # repeat one/single track
+    ALL = "all"  # repeat entire queue
+
+
+class PlayerState(Enum):
+    """Enum for the (playback)state of a player."""
+
+    IDLE = "idle"
+    PAUSED = "paused"
+    PLAYING = "playing"
+    OFF = "off"
+
+
+class EventType(Enum):
+    """Enum with possible Events."""
+
+    PLAYER_ADDED = "player added"
+    PLAYER_REMOVED = "player removed"
+    PLAYER_UPDATED = "player updated"
+    STREAM_STARTED = "streaming started"
+    STREAM_ENDED = "streaming ended"
+    MUSIC_SYNC_STATUS = "music sync status"
+    QUEUE_ADDED = "queue_added"
+    QUEUE_UPDATED = "queue updated"
+    QUEUE_ITEMS_UPDATED = "queue items updated"
+    QUEUE_TIME_UPDATED = "queue time updated"
+    SHUTDOWN = "application shutdown"
+    ARTIST_ADDED = "artist added"
+    ALBUM_ADDED = "album added"
+    TRACK_ADDED = "track added"
+    PLAYLIST_ADDED = "playlist added"
+    PLAYLIST_UPDATED = "playlist updated"
+    RADIO_ADDED = "radio added"
+    TASK_UPDATED = "task updated"
+    PROVIDER_REGISTERED = "provider registered"
+    BACKGROUND_JOB_UPDATED = "background_job_updated"
+
+
+class JobStatus(Enum):
+    """Enum with Job status."""
+
+    PENDING = "pending"
+    RUNNING = "running"
+    CANCELLED = "cancelled"
+    FINISHED = "success"
+    ERROR = "error"
diff --git a/music_assistant/models/event.py b/music_assistant/models/event.py
new file mode 100644 (file)
index 0000000..15a0a72
--- /dev/null
@@ -0,0 +1,15 @@
+"""Model for Music Assistant Event."""
+
+from dataclasses import dataclass
+from typing import Any, Optional
+
+from music_assistant.models.enums import EventType
+
+
+@dataclass
+class MassEvent:
+    """Representation of an Event emitted in/by Music Assistant."""
+
+    type: EventType
+    object_id: Optional[str] = None  # player_id, queue_id or uri
+    data: Optional[Any] = None  # optional data (such as the object)
index bc20ce479638500d53d78883f01143f064a8935a..0097c56e720fd9984812f8f647601d41714539c5 100644 (file)
@@ -3,14 +3,17 @@ from __future__ import annotations
 
 from abc import ABCMeta, abstractmethod
 from time import time
-from typing import Generic, List, Optional, Tuple, TypeVar
+from typing import TYPE_CHECKING, Generic, List, Optional, Tuple, TypeVar
 
 from databases import Database as Db
 
-from music_assistant.helpers.typing import MusicAssistant
 from music_assistant.models.errors import MediaNotFoundError, ProviderUnavailableError
 
-from .media_items import MediaItemType, MediaType
+from .enums import MediaType
+from .media_items import MediaItemType
+
+if TYPE_CHECKING:
+    from music_assistant.mass import MusicAssistant
 
 ItemCls = TypeVar("ItemCls", bound="MediaControllerBase")
 
index 58236035893022505e2e4bd45377bc4f0b743996..a3431396727c427ebbb508f29ca1f042d12364f7 100755 (executable)
@@ -2,44 +2,28 @@
 from __future__ import annotations
 
 from dataclasses import dataclass, field, fields
-from enum import Enum, IntEnum
 from typing import Any, Dict, List, Mapping, Optional, Set, Union
 
 from mashumaro import DataClassDictMixin
 
 from music_assistant.helpers.json import json
+from music_assistant.helpers.uri import create_uri
 from music_assistant.helpers.util import create_sort_name
+from music_assistant.models.enums import (
+    AlbumType,
+    ContentType,
+    ImageType,
+    LinkType,
+    MediaQuality,
+    MediaType,
+    StreamType,
+)
 
 MetadataTypes = Union[int, bool, str, List[str]]
 
 JSON_KEYS = ("artists", "artist", "album", "metadata", "provider_ids")
 
 
-class MediaType(Enum):
-    """Enum for MediaType."""
-
-    ARTIST = "artist"
-    ALBUM = "album"
-    TRACK = "track"
-    PLAYLIST = "playlist"
-    RADIO = "radio"
-    UNKNOWN = "unknown"
-
-
-class MediaQuality(IntEnum):
-    """Enum for Media Quality."""
-
-    UNKNOWN = 0
-    LOSSY_MP3 = 1
-    LOSSY_OGG = 2
-    LOSSY_AAC = 3
-    FLAC_LOSSLESS = 10  # 44.1/48khz 16 bits
-    FLAC_LOSSLESS_HI_RES_1 = 20  # 44.1/48khz 24 bits HI-RES
-    FLAC_LOSSLESS_HI_RES_2 = 21  # 88.2/96khz 24 bits HI-RES
-    FLAC_LOSSLESS_HI_RES_3 = 22  # 176/192khz 24 bits HI-RES
-    FLAC_LOSSLESS_HI_RES_4 = 23  # above 192khz 24 bits HI-RES
-
-
 @dataclass(frozen=True)
 class MediaItemProviderId(DataClassDictMixin):
     """Model for a MediaItem's provider id."""
@@ -56,22 +40,6 @@ class MediaItemProviderId(DataClassDictMixin):
         return hash((self.provider, self.item_id, self.quality))
 
 
-class LinkType(Enum):
-    """Enum wth link types."""
-
-    WEBSITE = "website"
-    FACEBOOK = "facebook"
-    TWITTER = "twitter"
-    LASTFM = "lastfm"
-    YOUTUBE = "youtube"
-    INSTAGRAM = "instagram"
-    SNAPCHAT = "snapchat"
-    TIKTOK = "tiktok"
-    DISCOGS = "discogs"
-    WIKIPEDIA = "wikipedia"
-    ALLMUSIC = "allmusic"
-
-
 @dataclass(frozen=True)
 class MediaItemLink(DataClassDictMixin):
     """Model for a link."""
@@ -84,22 +52,6 @@ class MediaItemLink(DataClassDictMixin):
         return hash((self.type.value))
 
 
-class ImageType(Enum):
-    """Enum wth image types."""
-
-    THUMB = "thumb"
-    WIDE_THUMB = "wide_thumb"
-    FANART = "fanart"
-    LOGO = "logo"
-    CLEARART = "clearart"
-    BANNER = "banner"
-    CUTOUT = "cutout"
-    BACK = "back"
-    CDART = "cdart"
-    EMBEDDED_THUMB = "embedded_thumb"
-    OTHER = "other"
-
-
 @dataclass(frozen=True)
 class MediaItemImage(DataClassDictMixin):
     """Model for a image."""
@@ -272,15 +224,6 @@ class Artist(MediaItem):
     musicbrainz_id: Optional[str] = None
 
 
-class AlbumType(Enum):
-    """Enum for Album type."""
-
-    ALBUM = "album"
-    SINGLE = "single"
-    COMPILATION = "compilation"
-    UNKNOWN = "unknown"
-
-
 @dataclass
 class Album(MediaItem):
     """Model for an album."""
@@ -344,83 +287,9 @@ class Radio(MediaItem):
         return val
 
 
-def create_uri(media_type: MediaType, provider_id: str, item_id: str):
-    """Create uri for mediaitem."""
-    return f"{provider_id}://{media_type.value}/{item_id}"
-
-
 MediaItemType = Union[Artist, Album, Track, Radio, Playlist]
 
 
-class StreamType(Enum):
-    """Enum with stream types."""
-
-    EXECUTABLE = "executable"
-    URL = "url"
-    FILE = "file"
-    CACHE = "cache"
-
-
-class ContentType(Enum):
-    """Enum with audio content types supported by ffmpeg."""
-
-    OGG = "ogg"
-    FLAC = "flac"
-    MP3 = "mp3"
-    AAC = "aac"
-    MPEG = "mpeg"
-    WAV = "wav"
-    PCM_S16LE = "s16le"  # PCM signed 16-bit little-endian
-    PCM_S24LE = "s24le"  # PCM signed 24-bit little-endian
-    PCM_S32LE = "s32le"  # PCM signed 32-bit little-endian
-    PCM_F32LE = "f32le"  # PCM 32-bit floating-point little-endian
-    PCM_F64LE = "f64le"  # PCM 64-bit floating-point little-endian
-
-    @classmethod
-    def try_parse(
-        cls: "ContentType", string: str, fallback: str = "mp3"
-    ) -> "ContentType":
-        """Try to parse ContentType from (url)string."""
-        tempstr = string.lower()
-        if "." in tempstr:
-            tempstr = tempstr.split(".")[-1]
-        tempstr = tempstr.split("?")[0]
-        tempstr = tempstr.split("&")[0]
-        try:
-            return cls(tempstr)
-        except ValueError:
-            return cls(fallback)
-
-    def is_pcm(self):
-        """Return if contentype is PCM."""
-        return self.name.startswith("PCM")
-
-    def sox_supported(self):
-        """Return if ContentType is supported by SoX."""
-        return self not in [ContentType.AAC, ContentType.MPEG]
-
-    def sox_format(self):
-        """Convert the ContentType to SoX compatible format."""
-        if not self.sox_supported():
-            raise NotImplementedError
-        return self.value.replace("le", "")
-
-    @classmethod
-    def from_bit_depth(
-        cls, bit_depth: int, floating_point: bool = False
-    ) -> "ContentType":
-        """Return (PCM) Contenttype from PCM bit depth."""
-        if floating_point and bit_depth > 32:
-            return cls.PCM_F64LE
-        if floating_point:
-            return cls.PCM_F32LE
-        if bit_depth == 16:
-            return cls.PCM_S16LE
-        if bit_depth == 24:
-            return cls.PCM_S24LE
-        return cls.PCM_S32LE
-
-
 @dataclass
 class StreamDetails(DataClassDictMixin):
     """Model for streamdetails."""
index 69bb20f44036de6de5b942005d78515539825eb2..3cf6b8405eca65b4b6c1aff62c28e0a9bbcc6659 100755 (executable)
@@ -4,17 +4,18 @@ from __future__ import annotations
 import asyncio
 from abc import ABC
 from dataclasses import dataclass
-from enum import Enum
 from typing import TYPE_CHECKING, Any, Dict, List, Tuple
 
 from mashumaro import DataClassDictMixin
 
-from music_assistant.constants import EventType, MassEvent
-from music_assistant.helpers.typing import MusicAssistant
 from music_assistant.helpers.util import get_changed_keys
+from music_assistant.models.enums import EventType, PlayerState
+from music_assistant.models.event import MassEvent
 from music_assistant.models.media_items import ContentType
 
 if TYPE_CHECKING:
+    from music_assistant.mass import MusicAssistant
+
     from .player_queue import PlayerQueue
 
 
@@ -36,15 +37,6 @@ DEFAULT_SUPPORTED_SAMPLE_RATES = (
 )
 
 
-class PlayerState(Enum):
-    """Enum for the (playback)state of a player."""
-
-    IDLE = "idle"
-    PAUSED = "paused"
-    PLAYING = "playing"
-    OFF = "off"
-
-
 @dataclass(frozen=True)
 class DeviceInfo(DataClassDictMixin):
     """Model for a player's deviceinfo."""
index 747a1c475288a339b26aa25c93912f6b079adece..3c57f017840a157e37d8c0389d411b2ea45ab590 100644 (file)
@@ -5,34 +5,28 @@ import asyncio
 import random
 from asyncio import Task, TimerHandle
 from dataclasses import dataclass
-from enum import Enum
-from typing import Any, Dict, List, Optional, Tuple, Union
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
 from uuid import uuid4
 
 from mashumaro import DataClassDictMixin
 
-from music_assistant.constants import EventType, MassEvent
 from music_assistant.helpers.audio import get_stream_details
-from music_assistant.helpers.typing import MusicAssistant
-from music_assistant.models.errors import MediaNotFoundError, QueueEmpty
-from music_assistant.models.media_items import (
+from music_assistant.models.enums import (
     ContentType,
+    CrossFadeMode,
+    EventType,
     MediaType,
-    Radio,
-    StreamDetails,
-    Track,
+    QueueOption,
+    RepeatMode,
 )
+from music_assistant.models.errors import MediaNotFoundError, QueueEmpty
+from music_assistant.models.event import MassEvent
+from music_assistant.models.media_items import Radio, StreamDetails, Track
 
 from .player import Player, PlayerGroup, PlayerState
 
-
-class QueueOption(Enum):
-    """Enum representation of the queue (play) options."""
-
-    PLAY = "play"
-    REPLACE = "replace"
-    NEXT = "next"
-    ADD = "add"
+if TYPE_CHECKING:
+    from music_assistant.mass import MusicAssistant
 
 
 @dataclass
@@ -83,23 +77,6 @@ class QueueItem(DataClassDictMixin):
         )
 
 
-class CrossFadeMode(Enum):
-    """Enum with crossfade modes."""
-
-    DISABLED = "disabled"  # no crossfading at all
-    STRICT = "strict"  # do not crossfade tracks of same album
-    SMART = "smart"  # crossfade if possible (do not crossfade different sample rates)
-    ALWAYS = "always"  # all tracks - resample to fixed sample rate
-
-
-class RepeatMode(Enum):
-    """Enum with repeat modes."""
-
-    OFF = "off"  # no repeat at all
-    ONE = "one"  # repeat one/single track
-    ALL = "all"  # repeat entire queue
-
-
 class QueueSettings:
     """Representation of (user adjustable) PlayerQueue settings/preferences."""
 
index 28ca87549eb075515e05c1ec7b29cf3089a0919a..e69bd921b9dbcc4970821597614b1ada36c093f0 100644 (file)
@@ -3,20 +3,22 @@ from __future__ import annotations
 
 from abc import abstractmethod
 from logging import Logger
-from typing import List, Optional
+from typing import TYPE_CHECKING, List, Optional
 
-from music_assistant.helpers.typing import MusicAssistant
+from music_assistant.models.enums import MediaType
 from music_assistant.models.media_items import (
     Album,
     Artist,
     MediaItemType,
-    MediaType,
     Playlist,
     Radio,
     Track,
 )
 from music_assistant.models.player_queue import StreamDetails
 
+if TYPE_CHECKING:
+    from music_assistant.mass import MusicAssistant
+
 
 class MusicProvider:
     """Model for a Music Provider."""
index caeedf46d4deecb271bb159273a0163d68d209ad..31fd02ae3e53d13da63baf77f3a69a6aa21267ba 100644 (file)
@@ -10,13 +10,14 @@ from typing import List, Optional
 import aiohttp
 from asyncio_throttle import Throttler
 
-from music_assistant.constants import EventType, MassEvent
 from music_assistant.helpers.app_vars import (  # pylint: disable=no-name-in-module
     app_var,
 )
 from music_assistant.helpers.cache import use_cache
 from music_assistant.helpers.util import parse_title_and_version, try_parse_int
+from music_assistant.models.enums import EventType
 from music_assistant.models.errors import LoginFailed
+from music_assistant.models.event import MassEvent
 from music_assistant.models.media_items import (
     Album,
     AlbumType,
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
new file mode 100644 (file)
index 0000000..b2b8542
--- /dev/null
@@ -0,0 +1,49 @@
+"""Tests for utility/helper functions."""
+
+from pytest import raises
+
+from music_assistant.helpers import uri, util
+from music_assistant.models import media_items
+from music_assistant.models.errors import MusicAssistantError
+
+
+def test_version_extract():
+    """Test the extraction of version from title."""
+
+    test_str = "Bam Bam (feat. Ed Sheeran) - Karaoke Version"
+    title, version = util.parse_title_and_version(test_str)
+    assert title == "Bam Bam"
+    assert version == "Karaoke Version"
+
+
+def test_uri_parsing():
+    """Test parsing of URI."""
+    # test regular uri
+    test_uri = "spotify://track/123456789"
+    media_type, provider, item_id = uri.parse_uri(test_uri)
+    assert media_type == media_items.MediaType.TRACK
+    assert provider == "spotify"
+    assert item_id == "123456789"
+    # test spotify uri
+    test_uri = "spotify:track:123456789"
+    media_type, provider, item_id = uri.parse_uri(test_uri)
+    assert media_type == media_items.MediaType.TRACK
+    assert provider == "spotify"
+    assert item_id == "123456789"
+    # test public play/open url
+    test_uri = (
+        "https://open.spotify.com/playlist/5lH9NjOeJvctAO92ZrKQNB?si=04a63c8234ac413e"
+    )
+    media_type, provider, item_id = uri.parse_uri(test_uri)
+    assert media_type == media_items.MediaType.PLAYLIST
+    assert provider == "spotify"
+    assert item_id == "5lH9NjOeJvctAO92ZrKQNB"
+    # test filename with slashes as item_id
+    test_uri = "filesystem://track/Artist/Album/Track.flac"
+    media_type, provider, item_id = uri.parse_uri(test_uri)
+    assert media_type == media_items.MediaType.TRACK
+    assert provider == "filesystem"
+    assert item_id == "Artist/Album/Track.flac"
+    # test invalid uri
+    with raises(MusicAssistantError):
+        uri.parse_uri("invalid://blah")
diff --git a/tests/utils.py b/tests/utils.py
deleted file mode 100644 (file)
index 6f21487..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-"""Tests for utility functions."""
-
-from music_assistant.helpers import util
-
-
-def test_version_extract():
-    """Test the extraction of version from title."""
-
-    test_str = "Bam Bam (feat. Ed Sheeran) - Karaoke Version"
-    title, version = util.parse_title_and_version(test_str)
-    assert title == "Bam Bam"
-    assert version == "Karaoke Version"