diff --git a/src/mopidy_smartplaylists/__init__.py b/src/mopidy_smartplaylists/__init__.py index 2dd163d..e22fc6a 100644 --- a/src/mopidy_smartplaylists/__init__.py +++ b/src/mopidy_smartplaylists/__init__.py @@ -21,6 +21,7 @@ class Extension(ext.Extension): schema["artists"] = config.String(optional=True) schema["playlist_prefix"] = config.String(optional=True) schema["refresh_interval"] = config.Integer(optional=True, minimum=0) + schema["search_uris"] = config.List(optional=True) return schema def validate_environment(self) -> None: diff --git a/src/mopidy_smartplaylists/ext.conf b/src/mopidy_smartplaylists/ext.conf index 9ba82f5..21f4bc0 100644 --- a/src/mopidy_smartplaylists/ext.conf +++ b/src/mopidy_smartplaylists/ext.conf @@ -15,3 +15,7 @@ artists = playlist_prefix = [Smart] # Refresh interval in hours (0 = only on startup) refresh_interval = 0 +# Comma-separated list of URI schemes to restrict searches to. +# Example: local:, file: +# Leave empty to search all backends. +search_uris = diff --git a/src/mopidy_smartplaylists/generators.py b/src/mopidy_smartplaylists/generators.py index 7c69b82..9e3f20b 100644 --- a/src/mopidy_smartplaylists/generators.py +++ b/src/mopidy_smartplaylists/generators.py @@ -19,20 +19,24 @@ def parse_decade(decade_str: str) -> str: return decade_str -def _search(core: CoreProxy, field: str, value: str) -> list[Track]: +def _search( + core: CoreProxy, field: str, value: str, uris: list[Uri] | None = None, +) -> list[Track]: query = cast("Query[SearchField]", {field: [value]}) try: - result = core.library.search(query).get() + result = core.library.search(query, uris=uris).get() except Exception: logger.exception("Search failed for %s=%s", field, value) return [] return _extract_tracks(result) -def build_decade_mix(core: CoreProxy, decade: str) -> list[Track]: +def build_decade_mix( + core: CoreProxy, decade: str, uris: list[Uri] | None = None, +) -> list[Track]: query = cast("Query[SearchField]", {"date": [parse_decade(decade)]}) try: - result = core.library.search(query).get() + result = core.library.search(query, uris=uris).get() except Exception: logger.exception("Decade search failed for %s", decade) return [] @@ -41,12 +45,16 @@ def build_decade_mix(core: CoreProxy, decade: str) -> list[Track]: return tracks -def build_genre_mix(core: CoreProxy, genre: str) -> list[Track]: - return _search(core, "genre", genre) +def build_genre_mix( + core: CoreProxy, genre: str, uris: list[Uri] | None = None, +) -> list[Track]: + return _search(core, "genre", genre, uris=uris) -def build_artist_mix(core: CoreProxy, artist: str) -> list[Track]: - return _search(core, "artist", artist) +def build_artist_mix( + core: CoreProxy, artist: str, uris: list[Uri] | None = None, +) -> list[Track]: + return _search(core, "artist", artist, uris=uris) def build_album_mix(core: CoreProxy, album_uri: str) -> list[Track]: @@ -60,7 +68,9 @@ def build_album_mix(core: CoreProxy, album_uri: str) -> list[Track]: return flat -def build_instant_mix(core: CoreProxy, track_uri: str, limit: int = 50) -> list[Track]: +def build_instant_mix( + core: CoreProxy, track_uri: str, limit: int = 50, uris: list[Uri] | None = None, +) -> list[Track]: try: lookup_result = core.library.lookup(cast("list[Uri]", [track_uri])).get() except Exception: @@ -85,7 +95,7 @@ def build_instant_mix(core: CoreProxy, track_uri: str, limit: int = 50) -> list[ for genre in genres: g_query = cast("Query[SearchField]", {"genre": [genre]}) try: - genre_result = core.library.search(g_query).get() + genre_result = core.library.search(g_query, uris=uris).get() except Exception: logger.exception("Genre search failed for %s", genre) continue @@ -99,7 +109,7 @@ def build_instant_mix(core: CoreProxy, track_uri: str, limit: int = 50) -> list[ break a_query = cast("Query[SearchField]", {"artist": [artist]}) try: - artist_result = core.library.search(a_query).get() + artist_result = core.library.search(a_query, uris=uris).get() except Exception: logger.exception("Artist search failed for %s", artist) continue @@ -149,13 +159,17 @@ def save_smart_playlist( def refresh_smart_playlists(core: CoreProxy, config_dict: dict) -> None: prefix = config_dict.get("playlist_prefix", "[Smart]") + raw = config_dict.get("search_uris", "") + uris: list[Uri] | None = None + if raw: + uris = cast("list[Uri]", [u.strip() for u in raw.split(",") if u.strip()]) decades_raw = config_dict.get("decades", "") if decades_raw: decades = [d.strip() for d in decades_raw.split(",") if d.strip()] for decade in decades: try: - tracks = build_decade_mix(core, decade) + tracks = build_decade_mix(core, decade, uris=uris) except Exception: logger.exception("Failed to build decade mix for %s", decade) continue @@ -167,7 +181,7 @@ def refresh_smart_playlists(core: CoreProxy, config_dict: dict) -> None: genres = [d.strip() for d in genres_raw.split(",") if d.strip()] for genre in genres: try: - tracks = build_genre_mix(core, genre) + tracks = build_genre_mix(core, genre, uris=uris) except Exception: logger.exception("Failed to build genre mix for %s", genre) continue @@ -179,7 +193,7 @@ def refresh_smart_playlists(core: CoreProxy, config_dict: dict) -> None: artists = [a.strip() for a in artists_raw.split(",") if a.strip()] for artist in artists: try: - tracks = build_artist_mix(core, artist) + tracks = build_artist_mix(core, artist, uris=uris) except Exception: logger.exception("Failed to build artist mix for %s", artist) continue diff --git a/src/mopidy_smartplaylists/web.py b/src/mopidy_smartplaylists/web.py index 3374dd8..55aa228 100644 --- a/src/mopidy_smartplaylists/web.py +++ b/src/mopidy_smartplaylists/web.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, cast import tornado.web if TYPE_CHECKING: - from mopidy_smartplaylists.compat import Config, CoreProxy + from mopidy_smartplaylists.compat import Config, CoreProxy, Uri if TYPE_CHECKING: from mopidy.models import Playlist @@ -26,9 +26,12 @@ logger = logging.getLogger(__name__) class DecadeMixHandler(tornado.web.RequestHandler): - def initialize(self, core: CoreProxy, prefix: str) -> None: + def initialize( + self, core: CoreProxy, prefix: str, uris: list[Uri] | None = None, + ) -> None: self.core = core self.prefix = prefix + self.uris = uris def post(self) -> None: data = json.loads(self.request.body) @@ -37,7 +40,7 @@ class DecadeMixHandler(tornado.web.RequestHandler): self.set_status(400) self.write({"error": "Missing 'decade' in request body"}) return - tracks = build_decade_mix(self.core, decade) + tracks = build_decade_mix(self.core, decade, uris=self.uris) if not tracks: self.write({"playlist": None, "tracks": 0}) return @@ -54,9 +57,12 @@ class DecadeMixHandler(tornado.web.RequestHandler): class GenreMixHandler(tornado.web.RequestHandler): - def initialize(self, core: CoreProxy, prefix: str) -> None: + def initialize( + self, core: CoreProxy, prefix: str, uris: list[Uri] | None = None, + ) -> None: self.core = core self.prefix = prefix + self.uris = uris def post(self) -> None: data = json.loads(self.request.body) @@ -65,7 +71,7 @@ class GenreMixHandler(tornado.web.RequestHandler): self.set_status(400) self.write({"error": "Missing 'genre' in request body"}) return - tracks = build_genre_mix(self.core, genre) + tracks = build_genre_mix(self.core, genre, uris=self.uris) if not tracks: self.write({"playlist": None, "tracks": 0}) return @@ -82,9 +88,12 @@ class GenreMixHandler(tornado.web.RequestHandler): class ArtistMixHandler(tornado.web.RequestHandler): - def initialize(self, core: CoreProxy, prefix: str) -> None: + def initialize( + self, core: CoreProxy, prefix: str, uris: list[Uri] | None = None, + ) -> None: self.core = core self.prefix = prefix + self.uris = uris def post(self) -> None: data = json.loads(self.request.body) @@ -93,7 +102,7 @@ class ArtistMixHandler(tornado.web.RequestHandler): self.set_status(400) self.write({"error": "Missing 'artist' in request body"}) return - tracks = build_artist_mix(self.core, artist) + tracks = build_artist_mix(self.core, artist, uris=self.uris) if not tracks: self.write({"playlist": None, "tracks": 0}) return @@ -143,9 +152,12 @@ class AlbumMixHandler(tornado.web.RequestHandler): class InstantMixHandler(tornado.web.RequestHandler): - def initialize(self, core: CoreProxy, prefix: str) -> None: + def initialize( + self, core: CoreProxy, prefix: str, uris: list[Uri] | None = None, + ) -> None: self.core = core self.prefix = prefix + self.uris = uris def post(self) -> None: data = json.loads(self.request.body) @@ -155,12 +167,17 @@ class InstantMixHandler(tornado.web.RequestHandler): self.set_status(400) self.write({"error": "Missing 'uri' in request body"}) return - tracks = build_instant_mix(self.core, track_uri, limit) + tracks = build_instant_mix(self.core, track_uri, limit, uris=self.uris) if not tracks: self.write({"playlist": None, "tracks": 0}) return - lookup_result = self.core.library.lookup([track_uri]).get() + try: + lookup_result = self.core.library.lookup([track_uri]).get() + except Exception: + logger.exception("Track lookup failed for %s", track_uri) + self.write({"playlist": None, "tracks": 0}) + return seed_name = "Instant Mix" for uri_tracks in lookup_result.values(): for t in uri_tracks: @@ -204,7 +221,17 @@ class StatusHandler(tornado.web.RequestHandler): self.core = core def get(self) -> None: - result = self.core.playlists.as_list().get() + try: + result = self.core.playlists.as_list().get() + except Exception: + logger.exception("Failed to list playlists") + self.set_status(500) + self.write({ + "error": "Failed to list playlists", + "smart_playlists": [], + "count": 0, + }) + return result_typed = cast("list[Playlist]", result) smart = [p for p in result_typed if p.uri and "smartplaylists" in p.uri] self.write( @@ -222,15 +249,25 @@ class StatusHandler(tornado.web.RequestHandler): ) +def _parse_search_uris(config: Config) -> list[Uri] | None: + raw = config.get("smartplaylists", {}).get("search_uris", "") + if raw: + uris = cast("list[Uri]", [u.strip() for u in raw.split(",") if u.strip()]) + return uris or None + return None + + def app_factory(config: Config, core: CoreProxy) -> list[tuple]: prefix = config.get("smartplaylists", {}).get("playlist_prefix", "[Smart]") + uris = _parse_search_uris(config) return [ - (r"/decade", DecadeMixHandler, {"core": core, "prefix": prefix}), - (r"/genre", GenreMixHandler, {"core": core, "prefix": prefix}), - (r"/artist", ArtistMixHandler, {"core": core, "prefix": prefix}), + (r"/decade", DecadeMixHandler, {"core": core, "prefix": prefix, "uris": uris}), + (r"/genre", GenreMixHandler, {"core": core, "prefix": prefix, "uris": uris}), + (r"/artist", ArtistMixHandler, {"core": core, "prefix": prefix, "uris": uris}), (r"/album", AlbumMixHandler, {"core": core, "prefix": prefix}), - (r"/instant-mix", InstantMixHandler, {"core": core, "prefix": prefix}), + (r"/instant-mix", InstantMixHandler, + {"core": core, "prefix": prefix, "uris": uris}), (r"/refresh", RefreshHandler, {"core": core, "config": config}), (r"/status", StatusHandler, {"core": core}), ]