commit a054575dea479eb46624d0ccf931c0539ab3748f Author: Colin Powell Date: Wed Jun 24 19:11:02 2026 -0400 Initial project skeleton for mopidy-smartplaylists diff --git a/.copier-answers.yml b/.copier-answers.yml new file mode 100644 index 0000000..725fcd1 --- /dev/null +++ b/.copier-answers.yml @@ -0,0 +1,8 @@ +_commit: v2.3.0 +_src_path: gh:mopidy/mopidy-ext-template +author_email: chris@code.unbl.ink +author_full_name: Chris Powell +dist_name: mopidy-smartplaylists +ext_name: smartplaylists +github_username: secstate +short_description: Mopidy extension for generating smart playlists based on track properties diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dc155b7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +jobs: + build: + name: Build + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: hynek/build-and-inspect-python-package@v2 + + main: + strategy: + fail-fast: false + matrix: + include: + - name: "pytest (3.13)" + python: "3.13" + tox: "3.13" + - name: "pytest (3.14)" + python: "3.14" + tox: "3.14" + coverage: true + - name: "pyright" + python: "3.14" + tox: "pyright" + - name: "ruff check" + python: "3.14" + tox: "ruff-check" + - name: "ruff format" + python: "3.14" + tox: "ruff-format" + + name: ${{ matrix.name }} + runs-on: ubuntu-24.04 + container: ghcr.io/mopidy/ci:latest + + steps: + - uses: actions/checkout@v6 + - name: Fix home dir permissions to enable pip caching + run: chown -R root /github/home + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + cache: pip + allow-prereleases: true + - run: python -m pip install tox + - run: python -m pip install mopidy + - run: python -m tox -e ${{ matrix.tox }} + if: ${{ ! matrix.coverage }} + - run: python -m tox -e ${{ matrix.tox }} -- --cov-report=xml + if: ${{ matrix.coverage }} + - uses: codecov/codecov-action@v6 + if: ${{ matrix.coverage }} + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b55d3ee --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,16 @@ +name: Release + +on: + release: + types: + - released + +jobs: + release: + runs-on: ubuntu-24.04 + permissions: + id-token: write + steps: + - uses: actions/checkout@v6 + - uses: hynek/build-and-inspect-python-package@v2 + - uses: pypa/gh-action-pypi-publish@v1.12.4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd2e3f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.egg-info/ +/*.lock +/.*_cache/ +/.coverage +/.tox/ +/.venv/ +/build/ +/dist/ +__pycache__/ diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..c3dd728 --- /dev/null +++ b/.mailmap @@ -0,0 +1 @@ +Chris Powell diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..51fca54 --- /dev/null +++ b/LICENSE @@ -0,0 +1,11 @@ +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..69151e7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,140 @@ +[project] +name = "mopidy-smartplaylists" +description = "Mopidy extension for generating smart playlists based on track properties" +readme = "README.md" +requires-python = ">= 3.13" +license = { text = "Apache-2.0" } +authors = [{ name = "Chris Powell", email = "chris@code.unbl.ink" }] +classifiers = [ + "Environment :: No Input/Output (Daemon)", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Topic :: Multimedia :: Sound/Audio :: Players", +] +dynamic = ["version"] +dependencies = [ + "mopidy >= 4.0.0", + "pykka >= 4.1", + "tornado >= 6.4", +] + +[project.urls] +Homepage = "https://github.com/secstate/mopidy-smartplaylists" + +[project.entry-points."mopidy.ext"] +smartplaylists = "mopidy_smartplaylists:Extension" + + +[build-system] +requires = ["setuptools >= 78", "setuptools-scm >= 8.2"] +build-backend = "setuptools.build_meta" + + +[dependency-groups] +dev = [ + "tox", + { include-group = "ruff" }, + { include-group = "tests" }, + { include-group = "typing" }, +] +ruff = ["ruff"] +tests = ["pytest", "pytest-cov"] +typing = ["pyright"] + + +[tool.coverage.paths] +source = ["src/", "*/site-packages/"] + +[tool.coverage.run] +source_pkgs = ["mopidy_smartplaylists"] + +[tool.coverage.report] +show_missing = true + + +[tool.pyright] +pythonVersion = "3.13" +typeCheckingMode = "standard" +reportMissingTypeStubs = false +reportPrivateImportUsage = false + + +[tool.pytest.ini_options] +filterwarnings = [ + "error:::mopidy_smartplaylists", +] + + +[tool.ruff] +target-version = "py313" + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "D", + "D203", + "D213", + "FIX002", + "G004", + "S101", + "TD002", + "TD003", + "COM812", + "ISC001", +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = [ + "ANN", + "ARG", + "D", + "FBT003", + "PLR2004", + "S101", + "SLF001", +] + + +[tool.setuptools.package-data] +"*" = ["*.conf"] + + +[tool.setuptools_scm] + + +[tool.tox] +env_list = [ + "3.13", + "3.14", + "pyright", + "ruff-check", + "ruff-format", +] + +[tool.tox.env_run_base] +package = "wheel" +wheel_build_env = ".pkg" +dependency_groups = ["tests"] +commands = [ + [ + "pytest", + "--cov", + "--basetemp={envtmpdir}", + { replace = "posargs", extend = true }, + ], +] + +[tool.tox.env.pyright] +dependency_groups = ["typing"] +commands = [["pyright", "{posargs:src}"]] + +[tool.tox.env.ruff-check] +skip_install = true +dependency_groups = ["ruff"] +commands = [["ruff", "check", "{posargs:.}"]] + +[tool.tox.env.ruff-format] +skip_install = true +dependency_groups = ["ruff"] +commands = [["ruff", "format", "--check", "--diff", "{posargs:.}"]] diff --git a/src/mopidy_smartplaylists/__init__.py b/src/mopidy_smartplaylists/__init__.py new file mode 100644 index 0000000..c72e826 --- /dev/null +++ b/src/mopidy_smartplaylists/__init__.py @@ -0,0 +1,40 @@ +import pathlib +from importlib.metadata import version + +from mopidy import config, ext +from mopidy.config import ConfigSchema + +__version__ = version("mopidy-smartplaylists") + + +class Extension(ext.Extension): + dist_name = "mopidy-smartplaylists" + ext_name = "smartplaylists" + version = __version__ + + def get_default_config(self): + return config.read(pathlib.Path(__file__).parent / "ext.conf") + + def get_config_schema(self): + schema = super().get_config_schema() + schema["decades"] = config.String(optional=True) + schema["genres"] = config.String(optional=True) + schema["artists"] = config.String(optional=True) + schema["playlist_prefix"] = config.String(optional=True) + schema["refresh_interval"] = config.Integer(optional=True, minimum=0) + return schema + + def validate_environment(self): + pass + + def setup(self, registry): + from mopidy_smartplaylists.web import app_factory + + registry.add("http:app", { + "name": self.ext_name, + "factory": app_factory, + }) + + from mopidy_smartplaylists.frontend import SmartPlaylistsFrontend + + registry.add("frontend", SmartPlaylistsFrontend) diff --git a/src/mopidy_smartplaylists/ext.conf b/src/mopidy_smartplaylists/ext.conf new file mode 100644 index 0000000..9ba82f5 --- /dev/null +++ b/src/mopidy_smartplaylists/ext.conf @@ -0,0 +1,17 @@ +[smartplaylists] +enabled = true +# Comma-separated list of decades to auto-generate playlists for on startup. +# Example: 1960,1970,1980,1990,2000,2010,2020 +# Leave empty to disable auto-generation of decade mixes. +decades = +# Comma-separated list of genres to auto-generate playlists for on startup. +# Example: Jazz,Classical,Rock,Electronic,Hip Hop +# Leave empty to disable auto-generation of genre mixes. +genres = +# Comma-separated list of artists to auto-generate playlists for on startup. +# Leave empty to disable auto-generation of artist mixes. +artists = +# Prefix used for all smart playlist names +playlist_prefix = [Smart] +# Refresh interval in hours (0 = only on startup) +refresh_interval = 0 diff --git a/src/mopidy_smartplaylists/frontend.py b/src/mopidy_smartplaylists/frontend.py new file mode 100644 index 0000000..bbaf0c7 --- /dev/null +++ b/src/mopidy_smartplaylists/frontend.py @@ -0,0 +1,35 @@ +import logging + +import pykka +from mopidy.config import Config +from mopidy.core import CoreListener, CoreProxy + +from mopidy_smartplaylists.generators import refresh_smart_playlists + +logger = logging.getLogger(__name__) + + +class SmartPlaylistsFrontend(pykka.ThreadingActor, CoreListener): + def __init__(self, config: Config, core: CoreProxy) -> None: + super().__init__() + self.config = config + self.core = core + + def on_start(self) -> None: + section = dict(self.config.get("smartplaylists", {})) + has_recipes = any(section.get(k) for k in ("decades", "genres", "artists")) + if has_recipes: + logger.info("Generating smart playlists from config recipes...") + refresh_smart_playlists(self.core, section) + else: + logger.debug("No smart playlist recipes configured") + + def on_stop(self) -> None: + pass + + def playlists_loaded(self) -> None: + section = dict(self.config.get("smartplaylists", {})) + interval = int(section.get("refresh_interval", 0)) + if interval > 0: + logger.info("Smart playlists loaded, running refresh...") + refresh_smart_playlists(self.core, section) diff --git a/src/mopidy_smartplaylists/generators.py b/src/mopidy_smartplaylists/generators.py new file mode 100644 index 0000000..896b3b2 --- /dev/null +++ b/src/mopidy_smartplaylists/generators.py @@ -0,0 +1,159 @@ +import logging +import re + +from mopidy.core import CoreProxy +from mopidy.models import Playlist, Track, TrackField + +logger = logging.getLogger(__name__) + + +def parse_decade(decade_str: str) -> str: + decade_str = decade_str.strip() + if len(decade_str) == 4: + return decade_str[:3] + "*" + if decade_str.endswith("s"): + return decade_str[:3] + "*" + return decade_str + + +def build_decade_mix(core: CoreProxy, decade: str) -> list[Track]: + query = {TrackField.DATE: [parse_decade(decade)]} + result = core.library.search(query).get() + tracks = _extract_tracks(result) + logger.info("Found %d tracks for decade %s", len(tracks), decade) + return tracks + + +def build_genre_mix(core: CoreProxy, genre: str) -> list[Track]: + query = {TrackField.GENRE: [genre]} + result = core.library.search(query).get() + tracks = _extract_tracks(result) + logger.info("Found %d tracks for genre %s", len(tracks), genre) + return tracks + + +def build_artist_mix(core: CoreProxy, artist: str) -> list[Track]: + query = {TrackField.ARTIST: [artist]} + result = core.library.search(query).get() + tracks = _extract_tracks(result) + logger.info("Found %d tracks for artist %s", len(tracks), artist) + return tracks + + +def build_album_mix(core: CoreProxy, album_uri: str) -> list[Track]: + tracks = core.library.lookup([album_uri]).get() + flat = _flatten_lookup(tracks) + logger.info("Found %d tracks for album %s", len(flat), album_uri) + return flat + + +def build_instant_mix(core: CoreProxy, track_uri: str, limit: int = 50) -> list[Track]: + lookup_result = core.library.lookup([track_uri]).get() + seed_tracks = _flatten_lookup(lookup_result) + if not seed_tracks: + logger.warning("No track found for URI: %s", track_uri) + return [] + + seed = seed_tracks[0] + genres: list[str] = [] + artists: list[str] = [] + + if seed.genre: + genres = [g.strip() for g in seed.genre.split("/") if g.strip()] + if seed.artists: + for artist_ref in seed.artists: + if artist_ref.name: + artists.append(artist_ref.name) + + similar: dict[str, Track] = {} + + for genre in genres: + query = {TrackField.GENRE: [genre]} + result = core.library.search(query).get() + for batch in result: + for t in batch.tracks: + if t.uri and t.uri != track_uri: + similar[t.uri] = t + + for artist in artists: + if len(similar) >= limit: + break + query = {TrackField.ARTIST: [artist]} + result = core.library.search(query).get() + for batch in result: + for t in batch.tracks: + if t.uri and t.uri != track_uri: + similar[t.uri] = t + + tracks = list(similar.values())[:limit] + logger.info("Instant mix: found %d tracks similar to %s", len(tracks), seed.name) + return tracks + + +def save_smart_playlist( + core: CoreProxy, + prefix: str, + name: str, + tracks: list[Track], +) -> Playlist | None: + playlist_name = f"{prefix} {name}" + uri = f"mopidy:smartplaylists:{_sanitize_name(name)}" + + existing = core.playlists.lookup(uri).get() + if existing: + core.playlists.delete(existing.uri).get() + + playlist = Playlist( + name=playlist_name, + uri=uri, + tracks=tracks, + ) + saved = core.playlists.save(playlist).get() + logger.info("Saved smart playlist: %s (%d tracks)", playlist_name, len(tracks)) + return saved + + +def refresh_smart_playlists(core: CoreProxy, config_dict: dict) -> None: + prefix = config_dict.get("playlist_prefix", "[Smart]") + + 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: + tracks = build_decade_mix(core, decade) + if tracks: + save_smart_playlist(core, prefix, f"{decade}s Mix", tracks) + + genres_raw = config_dict.get("genres", "") + if genres_raw: + genres = [g.strip() for g in genres_raw.split(",") if g.strip()] + for genre in genres: + tracks = build_genre_mix(core, genre) + if tracks: + save_smart_playlist(core, prefix, f"{genre} Mix", tracks) + + artists_raw = config_dict.get("artists", "") + if artists_raw: + artists = [a.strip() for a in artists_raw.split(",") if a.strip()] + for artist in artists: + tracks = build_artist_mix(core, artist) + if tracks: + save_smart_playlist(core, prefix, f"{artist} Mix", tracks) + + +def _extract_tracks(search_result) -> list[Track]: + tracks: list[Track] = [] + for batch in search_result: + tracks.extend(batch.tracks) + return tracks + + +def _flatten_lookup(lookup_result: dict[str, list[Track]]) -> list[Track]: + tracks: list[Track] = [] + for uri_tracks in lookup_result.values(): + tracks.extend(uri_tracks) + return tracks + + +def _sanitize_name(name: str) -> str: + return re.sub(r"[^a-zA-Z0-9_-]", "_", name).lower() diff --git a/src/mopidy_smartplaylists/web.py b/src/mopidy_smartplaylists/web.py new file mode 100644 index 0000000..80fe666 --- /dev/null +++ b/src/mopidy_smartplaylists/web.py @@ -0,0 +1,204 @@ +import json +import logging + +import tornado.web +from mopidy.config import Config +from mopidy.core import CoreProxy + +from mopidy_smartplaylists.generators import ( + build_album_mix, + build_artist_mix, + build_decade_mix, + build_genre_mix, + build_instant_mix, + refresh_smart_playlists, + save_smart_playlist, +) + +logger = logging.getLogger(__name__) + + +class DecadeMixHandler(tornado.web.RequestHandler): + def initialize(self, core: CoreProxy, prefix: str) -> None: + self.core = core + self.prefix = prefix + + def post(self) -> None: + data = json.loads(self.request.body) + decade = data.get("decade", "") + if not decade: + self.set_status(400) + self.write({"error": "Missing 'decade' in request body"}) + return + tracks = build_decade_mix(self.core, decade) + if not tracks: + self.write({"playlist": None, "tracks": 0}) + return + playlist = save_smart_playlist(self.core, self.prefix, f"{decade}s Mix", tracks) + self.write({ + "playlist": { + "name": playlist.name if playlist else None, + "uri": playlist.uri if playlist else None, + "tracks": len(tracks), + }, + }) + + +class GenreMixHandler(tornado.web.RequestHandler): + def initialize(self, core: CoreProxy, prefix: str) -> None: + self.core = core + self.prefix = prefix + + def post(self) -> None: + data = json.loads(self.request.body) + genre = data.get("genre", "") + if not genre: + self.set_status(400) + self.write({"error": "Missing 'genre' in request body"}) + return + tracks = build_genre_mix(self.core, genre) + if not tracks: + self.write({"playlist": None, "tracks": 0}) + return + playlist = save_smart_playlist(self.core, self.prefix, f"{genre} Mix", tracks) + self.write({ + "playlist": { + "name": playlist.name if playlist else None, + "uri": playlist.uri if playlist else None, + "tracks": len(tracks), + }, + }) + + +class ArtistMixHandler(tornado.web.RequestHandler): + def initialize(self, core: CoreProxy, prefix: str) -> None: + self.core = core + self.prefix = prefix + + def post(self) -> None: + data = json.loads(self.request.body) + artist = data.get("artist", "") + if not artist: + self.set_status(400) + self.write({"error": "Missing 'artist' in request body"}) + return + tracks = build_artist_mix(self.core, artist) + if not tracks: + self.write({"playlist": None, "tracks": 0}) + return + playlist = save_smart_playlist(self.core, self.prefix, f"{artist} Mix", tracks) + self.write({ + "playlist": { + "name": playlist.name if playlist else None, + "uri": playlist.uri if playlist else None, + "tracks": len(tracks), + }, + }) + + +class AlbumMixHandler(tornado.web.RequestHandler): + def initialize(self, core: CoreProxy, prefix: str) -> None: + self.core = core + self.prefix = prefix + + def post(self) -> None: + data = json.loads(self.request.body) + album_uri = data.get("uri", "") + if not album_uri: + self.set_status(400) + self.write({"error": "Missing 'uri' in request body"}) + return + tracks = build_album_mix(self.core, album_uri) + if not tracks: + self.write({"playlist": None, "tracks": 0}) + return + name = tracks[0].album.name if tracks[0].album and tracks[0].album.name else "Album" + playlist = save_smart_playlist(self.core, self.prefix, f"{name} Mix", tracks) + self.write({ + "playlist": { + "name": playlist.name if playlist else None, + "uri": playlist.uri if playlist else None, + "tracks": len(tracks), + }, + }) + + +class InstantMixHandler(tornado.web.RequestHandler): + def initialize(self, core: CoreProxy, prefix: str) -> None: + self.core = core + self.prefix = prefix + + def post(self) -> None: + data = json.loads(self.request.body) + track_uri = data.get("uri", "") + limit = data.get("limit", 50) + if not track_uri: + self.set_status(400) + self.write({"error": "Missing 'uri' in request body"}) + return + tracks = build_instant_mix(self.core, track_uri, limit) + if not tracks: + self.write({"playlist": None, "tracks": 0}) + return + + lookup_result = self.core.library.lookup([track_uri]).get() + seed_name = "Instant Mix" + for uri_tracks in lookup_result.values(): + for t in uri_tracks: + if t.name: + seed_name = t.name + break + + playlist = save_smart_playlist(self.core, self.prefix, f"Instant Mix: {seed_name}", tracks) + self.write({ + "playlist": { + "name": playlist.name if playlist else None, + "uri": playlist.uri if playlist else None, + "tracks": len(tracks), + }, + }) + + +class RefreshHandler(tornado.web.RequestHandler): + def initialize(self, core: CoreProxy, config: Config) -> None: + self.core = core + self.config = config + + def post(self) -> None: + section = self.config.get("smartplaylists", {}) + refresh_smart_playlists(self.core, section) + self.write({"status": "ok"}) + + +class StatusHandler(tornado.web.RequestHandler): + def initialize(self, core: CoreProxy) -> None: + self.core = core + + def get(self) -> None: + result = self.core.playlists.as_list().get() + smart = [p for p in result if p.uri and "smartplaylists" in p.uri] + self.write({ + "smart_playlists": [ + { + "name": p.name, + "uri": p.uri, + "tracks": len(p.tracks) if p.tracks else 0, + } + for p in smart + ], + "count": len(smart), + }) + + +def app_factory(config: Config, core: CoreProxy) -> list[tuple]: + prefix = config.get("smartplaylists", {}).get("playlist_prefix", "[Smart]") + + return [ + (r"/decade", DecadeMixHandler, {"core": core, "prefix": prefix}), + (r"/genre", GenreMixHandler, {"core": core, "prefix": prefix}), + (r"/artist", ArtistMixHandler, {"core": core, "prefix": prefix}), + (r"/album", AlbumMixHandler, {"core": core, "prefix": prefix}), + (r"/instant-mix", InstantMixHandler, {"core": core, "prefix": prefix}), + (r"/refresh", RefreshHandler, {"core": core, "config": config}), + (r"/status", StatusHandler, {"core": core}), + ] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_extension.py b/tests/test_extension.py new file mode 100644 index 0000000..6fdf906 --- /dev/null +++ b/tests/test_extension.py @@ -0,0 +1,48 @@ +from unittest import mock + +from mopidy_smartplaylists import Extension +from mopidy_smartplaylists import frontend as frontend_lib +from mopidy_smartplaylists import web as web_lib + + +def test_get_default_config(): + ext = Extension() + config = ext.get_default_config() + assert "[smartplaylists]" in config + assert "enabled = true" in config + assert "decades =" in config + assert "genres =" in config + assert "artists =" in config + assert "playlist_prefix = [Smart]" in config + assert "refresh_interval = 0" in config + + +def test_get_config_schema(): + ext = Extension() + schema = ext.get_config_schema() + assert "decades" in schema + assert "genres" in schema + assert "artists" in schema + assert "playlist_prefix" in schema + assert "refresh_interval" in schema + + +def test_setup_registers_http_app(): + ext = Extension() + registry = mock.Mock() + ext.setup(registry) + calls = [c.args for c in registry.add.call_args_list] + http_app_calls = [c for c in calls if c[0] == "http:app"] + assert len(http_app_calls) == 1 + assert http_app_calls[0][1]["name"] == "smartplaylists" + assert http_app_calls[0][1]["factory"] == web_lib.app_factory + + +def test_setup_registers_frontend(): + ext = Extension() + registry = mock.Mock() + ext.setup(registry) + calls = [c.args for c in registry.add.call_args_list] + frontend_calls = [c for c in calls if c[0] == "frontend"] + assert len(frontend_calls) == 1 + assert frontend_calls[0][1] == frontend_lib.SmartPlaylistsFrontend diff --git a/tests/test_frontend.py b/tests/test_frontend.py new file mode 100644 index 0000000..2003f2f --- /dev/null +++ b/tests/test_frontend.py @@ -0,0 +1,63 @@ +from unittest import mock + +from mopidy_smartplaylists.frontend import SmartPlaylistsFrontend + + +class TestSmartPlaylistsFrontend: + def test_on_start_with_recipes(self): + config = mock.Mock() + config.get.return_value = { + "decades": "1980,1990", + "genres": "", + "artists": "", + "playlist_prefix": "[Smart]", + "refresh_interval": "0", + } + core = mock.Mock() + + frontend = SmartPlaylistsFrontend(config, core) + frontend.on_start() + + core.library.search.assert_called() + + def test_on_start_without_recipes(self): + config = mock.Mock() + config.get.return_value = { + "decades": "", + "genres": "", + "artists": "", + "playlist_prefix": "[Smart]", + "refresh_interval": "0", + } + core = mock.Mock() + + frontend = SmartPlaylistsFrontend(config, core) + frontend.on_start() + + core.library.search.assert_not_called() + + def test_playlists_loaded_with_interval(self): + config = mock.Mock() + config.get.return_value = { + "playlist_prefix": "[Smart]", + "refresh_interval": "24", + } + core = mock.Mock() + + frontend = SmartPlaylistsFrontend(config, core) + frontend.playlists_loaded() + + core.library.search.assert_called() + + def test_playlists_loaded_without_interval(self): + config = mock.Mock() + config.get.return_value = { + "playlist_prefix": "[Smart]", + "refresh_interval": "0", + } + core = mock.Mock() + + frontend = SmartPlaylistsFrontend(config, core) + frontend.playlists_loaded() + + core.library.search.assert_not_called() diff --git a/tests/test_generators.py b/tests/test_generators.py new file mode 100644 index 0000000..b38e95e --- /dev/null +++ b/tests/test_generators.py @@ -0,0 +1,241 @@ +from unittest import mock + +import pytest +from mopidy.models import Artist, Playlist, SearchResult, Track + +from mopidy_smartplaylists.generators import ( + _extract_tracks, + _flatten_lookup, + _sanitize_name, + build_artist_mix, + build_decade_mix, + build_genre_mix, + build_instant_mix, + parse_decade, + refresh_smart_playlists, + save_smart_playlist, +) + + +class TestParseDecade: + def test_four_digit_year(self): + assert parse_decade("1980") == "198*" + + def test_with_suffix(self): + assert parse_decade("1980s") == "198*" + + def test_three_digit(self): + assert parse_decade("199") == "199*" + + def test_whitespace(self): + assert parse_decade(" 2000 ") == "200*" + + +class TestSanitizeName: + def test_lowercase(self): + assert _sanitize_name("Hello World") == "hello_world" + + def test_special_chars(self): + assert _sanitize_name("Jazz/Funk Mix!") == "jazz_funk_mix_" + + def test_already_sanitized(self): + assert _sanitize_name("hello_world") == "hello_world" + + +class TestExtractTracks: + def test_extracts_from_search_results(self): + tracks = [Track(uri="dummy:1", name="Track 1"), Track(uri="dummy:2", name="Track 2")] + results = [SearchResult(uri="dummy:search", tracks=tracks)] + assert _extract_tracks(results) == tracks + + def test_empty_results(self): + assert _extract_tracks([]) == [] + + +class TestFlattenLookup: + def test_flattens_dict_of_lists(self): + result = { + "dummy:1": [Track(uri="dummy:1", name="Track 1")], + "dummy:2": [Track(uri="dummy:2", name="Track 2")], + } + flat = _flatten_lookup(result) + assert len(flat) == 2 + + def test_empty_lookup(self): + assert _flatten_lookup({}) == [] + + +class TestBuildDecadeMix: + def test_searches_with_decade_query(self): + core = mock.Mock() + expected_tracks = [Track(uri="dummy:1", name="Track 1")] + core.library.search.return_value.get.return_value = [ + SearchResult(uri="dummy:search", tracks=expected_tracks) + ] + + result = build_decade_mix(core, "1980") + + core.library.search.assert_called_once() + query_arg = core.library.search.call_args[0][0] + assert "date" in query_arg + assert query_arg["date"] == ["198*"] + assert result == expected_tracks + + +class TestBuildGenreMix: + def test_searches_with_genre_query(self): + core = mock.Mock() + expected_tracks = [Track(uri="dummy:1", name="Jazz Track")] + core.library.search.return_value.get.return_value = [ + SearchResult(uri="dummy:search", tracks=expected_tracks) + ] + + result = build_genre_mix(core, "Jazz") + + core.library.search.assert_called_once() + query_arg = core.library.search.call_args[0][0] + assert query_arg["genre"] == ["Jazz"] + assert result == expected_tracks + + +class TestBuildArtistMix: + def test_searches_with_artist_query(self): + core = mock.Mock() + expected_tracks = [Track(uri="dummy:1", name="Artist Track")] + core.library.search.return_value.get.return_value = [ + SearchResult(uri="dummy:search", tracks=expected_tracks) + ] + + result = build_artist_mix(core, "Miles Davis") + + core.library.search.assert_called_once() + query_arg = core.library.search.call_args[0][0] + assert query_arg["artist"] == ["Miles Davis"] + assert result == expected_tracks + + +class TestBuildInstantMix: + def test_returns_similar_tracks_by_genre(self): + core = mock.Mock() + seed = Track( + uri="dummy:seed", + name="Seed Track", + genre="Jazz", + artists=[Artist(name="Miles Davis")], + ) + core.library.lookup.return_value.get.return_value = { + "dummy:seed": [seed], + } + + similar = [ + Track(uri="dummy:similar1", name="Similar 1", genre="Jazz"), + Track(uri="dummy:similar2", name="Similar 2", genre="Jazz"), + ] + core.library.search.return_value.get.return_value = [ + SearchResult(uri="dummy:search", tracks=similar), + ] + + result = build_instant_mix(core, "dummy:seed", limit=50) + + assert len(result) == 2 + assert result[0].uri == "dummy:similar1" + + def test_excludes_seed_track(self): + core = mock.Mock() + seed = Track(uri="dummy:seed", name="Seed Track", genre="Rock") + core.library.lookup.return_value.get.return_value = { + "dummy:seed": [seed], + } + same_as_seed = [Track(uri="dummy:seed", name="Seed Track", genre="Rock")] + core.library.search.return_value.get.return_value = [ + SearchResult(uri="dummy:search", tracks=same_as_seed), + ] + + result = build_instant_mix(core, "dummy:seed") + + assert len(result) == 0 + + def test_empty_lookup_returns_empty(self): + core = mock.Mock() + core.library.lookup.return_value.get.return_value = {} + result = build_instant_mix(core, "dummy:nonexistent") + assert result == [] + + +class TestSaveSmartPlaylist: + def test_creates_new_playlist(self): + core = mock.Mock() + core.playlists.lookup.return_value.get.return_value = None + saved = Playlist( + name="[Smart] Jazz Mix", + uri="mopidy:smartplaylists:jazz_mix", + tracks=[Track(uri="dummy:1", name="Track")], + ) + core.playlists.save.return_value.get.return_value = saved + + tracks = [Track(uri="dummy:1", name="Track")] + result = save_smart_playlist(core, "[Smart]", "Jazz Mix", tracks) + + assert result == saved + core.playlists.save.assert_called_once() + + def test_deletes_existing_before_save(self): + core = mock.Mock() + existing = Playlist( + name="[Smart] Old Jazz Mix", + uri="mopidy:smartplaylists:jazz_mix", + tracks=[], + ) + core.playlists.lookup.return_value.get.return_value = existing + saved = Playlist( + name="[Smart] Jazz Mix", + uri="mopidy:smartplaylists:jazz_mix", + tracks=[Track(uri="dummy:1", name="Track")], + ) + core.playlists.save.return_value.get.return_value = saved + + tracks = [Track(uri="dummy:1", name="Track")] + result = save_smart_playlist(core, "[Smart]", "Jazz Mix", tracks) + + assert result == saved + core.playlists.delete.assert_called_once_with(existing.uri) + + +class TestRefreshSmartPlaylists: + def test_skips_when_no_recipes(self): + core = mock.Mock() + config = {} + refresh_smart_playlists(core, config) + core.library.search.assert_not_called() + + def test_generates_decade_mixes(self): + core = mock.Mock() + config = {"decades": "1980,1990", "playlist_prefix": "[Smart]"} + core.library.search.return_value.get.return_value = [ + SearchResult(uri="dummy:search", tracks=[Track(uri="dummy:1", name="T")]) + ] + core.playlists.lookup.return_value.get.return_value = None + core.playlists.save.return_value.get.return_value = Playlist( + name="test", uri="dummy:playlist", tracks=[] + ) + + refresh_smart_playlists(core, config) + + assert core.library.search.call_count == 2 + assert core.playlists.save.call_count == 2 + + def test_generates_genre_mixes(self): + core = mock.Mock() + config = {"genres": "Jazz,Rock", "playlist_prefix": "[Smart]"} + core.library.search.return_value.get.return_value = [ + SearchResult(uri="dummy:search", tracks=[Track(uri="dummy:1", name="T")]) + ] + core.playlists.lookup.return_value.get.return_value = None + core.playlists.save.return_value.get.return_value = Playlist( + name="test", uri="dummy:playlist", tracks=[] + ) + + refresh_smart_playlists(core, config) + + assert core.library.search.call_count == 2 + assert core.playlists.save.call_count == 2 diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..81e969b --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,148 @@ +import json +from unittest import mock + +import pytest +from mopidy.models import Playlist, Track + +from mopidy_smartplaylists.web import ( + AlbumMixHandler, + ArtistMixHandler, + DecadeMixHandler, + GenreMixHandler, + InstantMixHandler, + RefreshHandler, + StatusHandler, + app_factory, +) + + +class TestAppFactory: + def test_returns_list_of_routes(self): + config = mock.Mock() + config.get.return_value = {"playlist_prefix": "[Smart]"} + core = mock.Mock() + routes = app_factory(config, core) + assert isinstance(routes, list) + assert len(routes) > 0 + for pattern, handler_cls, kwargs in routes: + assert isinstance(pattern, str) + assert issubclass(handler_cls, object) + assert "core" in kwargs + + +class TestDecadeMixHandler: + @pytest.fixture + def handler(self): + core = mock.Mock() + core.library.search.return_value.get.return_value = [ + mock.Mock(tracks=[Track(uri="dummy:1", name="T")]) + ] + core.playlists.lookup.return_value.get.return_value = None + core.playlists.save.return_value.get.return_value = Playlist( + name="[Smart] 1980s Mix", + uri="mopidy:smartplaylists:1980s_mix", + tracks=[Track(uri="dummy:1", name="T")], + ) + + h = DecadeMixHandler(mock.Mock(), mock.Mock(), core=core, prefix="[Smart]") + h.request = mock.Mock() + h.request.body = json.dumps({"decade": "1980"}).encode() + return h + + def test_returns_playlist_info(self, handler): + handler.write = mock.Mock() + handler.post() + data = handler.write.call_args[0][0] + assert data["playlist"]["name"] == "[Smart] 1980s Mix" + assert data["tracks"] == 1 + + def test_missing_decade_returns_400(self): + h = DecadeMixHandler(mock.Mock(), mock.Mock(), core=mock.Mock(), prefix="[Smart]") + h.request = mock.Mock() + h.request.body = json.dumps({}).encode() + h.set_status = mock.Mock() + h.write = mock.Mock() + h.post() + h.set_status.assert_called_once_with(400) + + +class TestGenreMixHandler: + def test_missing_genre_returns_400(self): + h = GenreMixHandler(mock.Mock(), mock.Mock(), core=mock.Mock(), prefix="[Smart]") + h.request = mock.Mock() + h.request.body = json.dumps({}).encode() + h.set_status = mock.Mock() + h.write = mock.Mock() + h.post() + h.set_status.assert_called_once_with(400) + + +class TestArtistMixHandler: + def test_missing_artist_returns_400(self): + h = ArtistMixHandler(mock.Mock(), mock.Mock(), core=mock.Mock(), prefix="[Smart]") + h.request = mock.Mock() + h.request.body = json.dumps({}).encode() + h.set_status = mock.Mock() + h.write = mock.Mock() + h.post() + h.set_status.assert_called_once_with(400) + + +class TestAlbumMixHandler: + def test_missing_uri_returns_400(self): + h = AlbumMixHandler(mock.Mock(), mock.Mock(), core=mock.Mock(), prefix="[Smart]") + h.request = mock.Mock() + h.request.body = json.dumps({}).encode() + h.set_status = mock.Mock() + h.write = mock.Mock() + h.post() + h.set_status.assert_called_once_with(400) + + +class TestInstantMixHandler: + def test_missing_uri_returns_400(self): + h = InstantMixHandler(mock.Mock(), mock.Mock(), core=mock.Mock(), prefix="[Smart]") + h.request = mock.Mock() + h.request.body = json.dumps({}).encode() + h.set_status = mock.Mock() + h.write = mock.Mock() + h.post() + h.set_status.assert_called_once_with(400) + + +class TestRefreshHandler: + def test_calls_refresh(self): + config = mock.Mock() + config.get.return_value = {"playlist_prefix": "[Smart]"} + core = mock.Mock() + + h = RefreshHandler(mock.Mock(), mock.Mock(), core=core, config=config) + h.request = mock.Mock() + h.request.body = b"{}" + h.write = mock.Mock() + h.post() + assert h.write.call_args[0][0] == {"status": "ok"} + + +class TestStatusHandler: + def test_returns_smart_playlists(self): + core = mock.Mock() + core.playlists.as_list.return_value.get.return_value = [ + Playlist( + name="[Smart] Jazz Mix", + uri="mopidy:smartplaylists:jazz_mix", + tracks=[Track(uri="dummy:1", name="T")], + ), + Playlist( + name="Normal Playlist", + uri="mopidy:playlist:normal", + tracks=[], + ), + ] + + h = StatusHandler(mock.Mock(), mock.Mock(), core=core) + h.write = mock.Mock() + h.get() + data = h.write.call_args[0][0] + assert data["count"] == 1 + assert data["smart_playlists"][0]["name"] == "[Smart] Jazz Mix"