Browse Source

Initial commit

Colin Powell 3 weeks ago
commit
66e1de238f

+ 3 - 0
MANIFEST.in

@@ -0,0 +1,3 @@
+
+include README.md
+include mopidy_smartplaylists/ext.conf

+ 11 - 0
README.md

@@ -0,0 +1,11 @@
+# Mopidy SmartPlaylists (local)
+
+
+Generates dynamic playlists from local audio files by *genre* or *MusicBrainz artist MBID*.
+
+
+Install (editable for development):
+
+
+```bash
+pip install -e .

+ 15 - 0
mopidy_smartplaylists/backend.py

@@ -0,0 +1,15 @@
+import logging
+import mopidy.backend
+from .playlists import SmartPlaylistsProvider
+
+
+logger = logging.getLogger(__name__)
+
+
+class SmartPlaylistsBackend(mopidy.backend.Backend):
+    def __init__(self, config, audio):
+        super().__init__(audio=audio)
+        self.config = config["smartplaylists"]
+
+        # Initialize playlist provider
+        self.playlists = SmartPlaylistsProvider(backend=self, config=self.config)

+ 11 - 0
mopidy_smartplaylists/ext.conf

@@ -0,0 +1,11 @@
+[smartplaylists]
+# Root directory where music files are stored. Required.
+music_directory = /var/lib/mopidy/media
+# Comma-separated file extensions to scan
+scan_extensions = .mp3,.flac
+# Some example genres to expose as top-level playlists. You can still query any genre by URI.
+genres = rock,jazz,blues
+# Maximum number of tracks to return for a generated playlist
+max_tracks = 200
+# Whether to use case-insensitive matching for tags
+case_insensitive = true

+ 27 - 0
mopidy_smartplaylists/extension.py

@@ -0,0 +1,27 @@
+import pkg_resources
+from mopidy import ext, config
+
+
+class Extension(ext.Extension):
+    dist_name = "Mopidy-SmartPlaylists"
+    ext_name = "smartplaylists"
+    version = "0.1.0"
+
+
+def get_default_config(self):
+    return config.read(pkg_resources.resource_filename(__name__, "ext.conf"))
+
+
+def get_config_schema(self):
+    schema = super().get_config_schema()
+    schema["music_directory"] = config.Path()
+    schema["scan_extensions"] = config.String(optional=True)
+    schema["genres"] = config.String(optional=True)
+    schema["max_tracks"] = config.Integer(optional=True)
+    schema["case_insensitive"] = config.Boolean(optional=True)
+    return schema
+
+
+def setup(self, registry):
+    from .backend import SmartPlaylistsBackend
+    registry.add("backend", SmartPlaylistsBackend)

+ 1 - 0
mopidy_smartplaylists/init.py

@@ -0,0 +1 @@
+from .extension import Extension

+ 21 - 0
mopidy_smartplaylists/mb_client.py

@@ -0,0 +1,21 @@
+import musicbrainzngs
+
+musicbrainzngs.set_useragent("Mopidy-SmartPlaylists", "0.1")
+
+def get_similar_artists(mbid):
+    result = musicbrainzngs.get_artist_by_id(mbid, includes=["artist-rels"])
+    sims = result["artist"]["artist-relation-list"]
+
+    tracks = []
+    for rel in sims:
+        artist = rel["artist"]["name"]
+        # Here you'd search Spotify/local/etc for tracks
+        # You return tracks with proper mopidy URIs
+        # Example placeholder:
+        tracks.append(Track(uri=f"spotify:artist:{artist}", name=artist))
+
+    return tracks
+
+def get_genre_tracks(genre):
+    # Example: Browse MusicBrainz tags
+    return []

+ 36 - 0
mopidy_smartplaylists/playlists.py

@@ -0,0 +1,36 @@
+from mopidy.models import Playlist, Track
+from mopidy.backend import PlaylistProvider
+from .mb_client import get_similar_artists, get_genre_tracks
+
+class SmartPlaylistsProvider(PlaylistProvider):
+
+    def as_list(self):
+        playlists = []
+
+        # Genres
+        for genre in ["rock", "jazz", "hiphop", "blues"]:
+            playlists.append(
+                Playlist(uri=f"smart:genre:{genre}", name=f"Genre: {genre.title()}")
+            )
+
+        # Example artist radios
+        playlists.append(
+            Playlist(uri="smart:artist:radio:MBID123", name="Artist Radio: Example")
+        )
+
+        return playlists
+
+    def lookup(self, uri):
+        parts = uri.split(":")
+        typ = parts[1]
+
+        if typ == "genre":
+            genre = parts[2]
+            tracks = get_genre_tracks(genre)
+        elif typ == "artist" and parts[2] == "radio":
+            mbid = parts[3]
+            tracks = get_similar_artists(mbid)
+        else:
+            return None
+
+        return Playlist(uri=uri, name="Generated", tracks=tracks)

+ 1 - 0
mopidy_smartplaylists/scanner.py

@@ -0,0 +1 @@
+#!/usr/bin/env python3

+ 180 - 0
poetry.lock

@@ -0,0 +1,180 @@
+# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
+
+[[package]]
+name = "black"
+version = "25.11.0"
+description = "The uncompromising code formatter."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+    {file = "black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e"},
+    {file = "black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0"},
+    {file = "black-25.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0f7c461df55cf32929b002335883946a4893d759f2df343389c4396f3b6b37"},
+    {file = "black-25.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:f9786c24d8e9bd5f20dc7a7f0cdd742644656987f6ea6947629306f937726c03"},
+    {file = "black-25.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a"},
+    {file = "black-25.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170"},
+    {file = "black-25.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc"},
+    {file = "black-25.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e"},
+    {file = "black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac"},
+    {file = "black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96"},
+    {file = "black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd"},
+    {file = "black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409"},
+    {file = "black-25.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b"},
+    {file = "black-25.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd"},
+    {file = "black-25.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993"},
+    {file = "black-25.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c"},
+    {file = "black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170"},
+    {file = "black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545"},
+    {file = "black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda"},
+    {file = "black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664"},
+    {file = "black-25.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3bb5ce32daa9ff0605d73b6f19da0b0e6c1f8f2d75594db539fdfed722f2b06"},
+    {file = "black-25.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9815ccee1e55717fe9a4b924cae1646ef7f54e0f990da39a34fc7b264fcf80a2"},
+    {file = "black-25.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92285c37b93a1698dcbc34581867b480f1ba3a7b92acf1fe0467b04d7a4da0dc"},
+    {file = "black-25.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:43945853a31099c7c0ff8dface53b4de56c41294fa6783c0441a8b1d9bf668bc"},
+    {file = "black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b"},
+    {file = "black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08"},
+]
+
+[package.dependencies]
+click = ">=8.0.0"
+mypy-extensions = ">=0.4.3"
+packaging = ">=22.0"
+pathspec = ">=0.9.0"
+platformdirs = ">=2"
+pytokens = ">=0.3.0"
+
+[package.extras]
+colorama = ["colorama (>=0.4.3)"]
+d = ["aiohttp (>=3.10)"]
+jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
+uvloop = ["uvloop (>=0.15.2)"]
+
+[[package]]
+name = "click"
+version = "8.3.0"
+description = "Composable command line interface toolkit"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+    {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"},
+    {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+groups = ["dev"]
+markers = "platform_system == \"Windows\""
+files = [
+    {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+    {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "isort"
+version = "7.0.0"
+description = "A Python utility / library to sort Python imports."
+optional = false
+python-versions = ">=3.10.0"
+groups = ["dev"]
+files = [
+    {file = "isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1"},
+    {file = "isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187"},
+]
+
+[package.extras]
+colors = ["colorama"]
+plugins = ["setuptools"]
+
+[[package]]
+name = "musicbrainzngs"
+version = "0.7.1"
+description = "Python bindings for the MusicBrainz NGS and the Cover Art Archive webservices"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+groups = ["main"]
+files = [
+    {file = "musicbrainzngs-0.7.1-py2.py3-none-any.whl", hash = "sha256:e841a8f975104c0a72290b09f59326050194081a5ae62ee512f41915090e1a10"},
+    {file = "musicbrainzngs-0.7.1.tar.gz", hash = "sha256:ab1c0100fd0b305852e65f2ed4113c6de12e68afd55186987b8ed97e0f98e627"},
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+description = "Type system extensions for programs checked with the mypy type checker."
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+    {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
+    {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
+]
+
+[[package]]
+name = "packaging"
+version = "25.0"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+    {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
+    {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
+]
+
+[[package]]
+name = "pathspec"
+version = "0.12.1"
+description = "Utility library for gitignore style pattern matching of file paths."
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+    {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
+    {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.5.0"
+description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+    {file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"},
+    {file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"},
+]
+
+[package.extras]
+docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"]
+type = ["mypy (>=1.18.2)"]
+
+[[package]]
+name = "pytokens"
+version = "0.3.0"
+description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons."
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+    {file = "pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3"},
+    {file = "pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a"},
+]
+
+[package.extras]
+dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"]
+
+[metadata]
+lock-version = "2.1"
+python-versions = ">=3.11"
+content-hash = "a733d33ed3381bffe47a8910fb1d89c373ad400f8f67e969d1220315c31a8da5"

+ 23 - 0
pyproject.toml

@@ -0,0 +1,23 @@
+[project]
+name = "mopidy-smartplaylists"
+version = "0.1.0"
+description = "Generate smart playlists from genres or artists"
+authors = [
+    {name = "Colin Powell",email = "colin@unbl.ink"}
+]
+license = {text = "MIT"}
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+    "musicbrainzngs (>=0.7.1,<0.8.0)"
+]
+
+[tool.poetry]
+
+[tool.poetry.group.dev.dependencies]
+black = "^25.11.0"
+isort = "^7.0.0"
+
+[build-system]
+requires = ["poetry-core>=2.0.0,<3.0.0"]
+build-backend = "poetry.core.masonry.api"

+ 19 - 0
setup.cfg

@@ -0,0 +1,19 @@
+[metadata]
+name = mopidy-smartplaylists
+version = 0.1.0
+description = Mopidy extension: generate playlists from local files by genre or MusicBrainz artist MBID
+long_description = file: README.md
+long_description_content_type = text/markdown
+license = MIT
+
+
+[options]
+packages = find:
+install_requires =
+mutagen>=1.45
+mopidy>=4.0
+
+
+[options.entry_points]
+mopidy.ext =
+smartplaylists = mopidy_smartplaylists:Extension