diff --git a/src/mopidy_smartplaylists/__init__.py b/src/mopidy_smartplaylists/__init__.py index 55d5cb6..ea15b07 100644 --- a/src/mopidy_smartplaylists/__init__.py +++ b/src/mopidy_smartplaylists/__init__.py @@ -25,6 +25,7 @@ class Extension(ext.Extension): schema["playlist_dir"] = config.String(optional=True) schema["max_tracks"] = config.Integer(optional=True, minimum=0) schema["max_per_album"] = config.Integer(optional=True, minimum=0) + schema["max_per_artist"] = config.Integer(optional=True, minimum=0) return schema def validate_environment(self) -> None: diff --git a/src/mopidy_smartplaylists/ext.conf b/src/mopidy_smartplaylists/ext.conf index 9ba175e..f42e9db 100644 --- a/src/mopidy_smartplaylists/ext.conf +++ b/src/mopidy_smartplaylists/ext.conf @@ -26,3 +26,5 @@ playlist_dir = max_tracks = 75 # Maximum tracks per album in a mix (0 = no limit) max_per_album = 2 +# Maximum tracks per artist in a mix (0 = no limit) +max_per_artist = 4 diff --git a/src/mopidy_smartplaylists/generators.py b/src/mopidy_smartplaylists/generators.py index 79cc601..cfa9d82 100644 --- a/src/mopidy_smartplaylists/generators.py +++ b/src/mopidy_smartplaylists/generators.py @@ -34,22 +34,38 @@ def _search( def _mix_tracks( - tracks: list[Track], max_tracks: int = 0, max_per_album: int = 0, + tracks: list[Track], max_tracks: int = 0, + max_per_album: int = 0, max_per_artist: int = 0, ) -> list[Track]: if not tracks: return [] - if max_per_album > 0: - random.shuffle(tracks) - seen: dict[str, int] = {} - result: list[Track] = [] - for t in tracks: - key = t.album.uri if t.album and t.album.uri else str(id(t)) - count = seen.get(key, 0) - if count >= max_per_album: + random.shuffle(tracks) + seen_album: dict[str, int] = {} + seen_artist: dict[str, int] = {} + result: list[Track] = [] + for t in tracks: + if max_per_album > 0: + album_key = t.album.uri if t.album and t.album.uri else str(id(t)) + if seen_album.get(album_key, 0) >= max_per_album: continue - seen[key] = count + 1 - result.append(t) - tracks = result + if max_per_artist > 0: + artists = t.artists or [] + artist_key = None + for a in artists: + if a.uri: + artist_key = a.uri + break + if not artist_key: + artist_name = (artists[0].name or "") if artists else "" + artist_key = artist_name or str(id(t)) + if seen_artist.get(artist_key, 0) >= max_per_artist: + continue + if max_per_album > 0: + seen_album[album_key] = seen_album.get(album_key, 0) + 1 + if max_per_artist > 0: + seen_artist[artist_key] = seen_artist.get(artist_key, 0) + 1 + result.append(t) + tracks = result if max_tracks > 0: tracks = tracks[:max_tracks] random.shuffle(tracks) @@ -58,7 +74,7 @@ def _mix_tracks( def build_decade_mix( core: CoreProxy, decade: str, uris: list[Uri] | None = None, - max_tracks: int = 0, max_per_album: int = 0, + max_tracks: int = 0, max_per_album: int = 0, max_per_artist: int = 0, ) -> list[Track]: query = cast("Query[SearchField]", {"date": [parse_decade(decade)]}) try: @@ -66,23 +82,23 @@ def build_decade_mix( except Exception: logger.exception("Decade search failed for %s", decade) return [] - tracks = _mix_tracks(_extract_tracks(result), max_tracks, max_per_album) + tracks = _mix_tracks(_extract_tracks(result), max_tracks, max_per_album, max_per_artist) logger.info("Found %d tracks for decade %s", len(tracks), decade) return tracks def build_genre_mix( core: CoreProxy, genre: str, uris: list[Uri] | None = None, - max_tracks: int = 0, max_per_album: int = 0, + max_tracks: int = 0, max_per_album: int = 0, max_per_artist: int = 0, ) -> list[Track]: - return _mix_tracks(_search(core, "genre", genre, uris=uris), max_tracks, max_per_album) + return _mix_tracks(_search(core, "genre", genre, uris=uris), max_tracks, max_per_album, max_per_artist) def build_artist_mix( core: CoreProxy, artist: str, uris: list[Uri] | None = None, - max_tracks: int = 0, max_per_album: int = 0, + max_tracks: int = 0, max_per_album: int = 0, max_per_artist: int = 0, ) -> list[Track]: - return _mix_tracks(_search(core, "artist", artist, uris=uris), max_tracks, max_per_album) + return _mix_tracks(_search(core, "artist", artist, uris=uris), max_tracks, max_per_album, max_per_artist) def build_album_mix(core: CoreProxy, album_uri: str) -> list[Track]: diff --git a/src/mopidy_smartplaylists/web.py b/src/mopidy_smartplaylists/web.py index 050cad8..1b91599 100644 --- a/src/mopidy_smartplaylists/web.py +++ b/src/mopidy_smartplaylists/web.py @@ -29,7 +29,7 @@ class DecadeMixHandler(tornado.web.RequestHandler): def initialize( self, core: CoreProxy, prefix: str, uris: list[Uri] | None = None, playlist_dir: str | None = None, - max_tracks: int = 0, max_per_album: int = 0, + max_tracks: int = 0, max_per_album: int = 0, max_per_artist: int = 0, ) -> None: self.core = core self.prefix = prefix @@ -37,6 +37,7 @@ class DecadeMixHandler(tornado.web.RequestHandler): self.playlist_dir = playlist_dir self.max_tracks = max_tracks self.max_per_album = max_per_album + self.max_per_artist = max_per_artist def post(self) -> None: data = json.loads(self.request.body) @@ -48,6 +49,7 @@ class DecadeMixHandler(tornado.web.RequestHandler): tracks = build_decade_mix( self.core, decade, uris=self.uris, max_tracks=self.max_tracks, max_per_album=self.max_per_album, + max_per_artist=self.max_per_artist, ) if not tracks: self.write({"playlist": None, "tracks": 0}) @@ -71,7 +73,7 @@ class GenreMixHandler(tornado.web.RequestHandler): def initialize( self, core: CoreProxy, prefix: str, uris: list[Uri] | None = None, playlist_dir: str | None = None, - max_tracks: int = 0, max_per_album: int = 0, + max_tracks: int = 0, max_per_album: int = 0, max_per_artist: int = 0, ) -> None: self.core = core self.prefix = prefix @@ -79,6 +81,7 @@ class GenreMixHandler(tornado.web.RequestHandler): self.playlist_dir = playlist_dir self.max_tracks = max_tracks self.max_per_album = max_per_album + self.max_per_artist = max_per_artist def post(self) -> None: data = json.loads(self.request.body) @@ -90,6 +93,7 @@ class GenreMixHandler(tornado.web.RequestHandler): tracks = build_genre_mix( self.core, genre, uris=self.uris, max_tracks=self.max_tracks, max_per_album=self.max_per_album, + max_per_artist=self.max_per_artist, ) if not tracks: self.write({"playlist": None, "tracks": 0}) @@ -113,7 +117,7 @@ class ArtistMixHandler(tornado.web.RequestHandler): def initialize( self, core: CoreProxy, prefix: str, uris: list[Uri] | None = None, playlist_dir: str | None = None, - max_tracks: int = 0, max_per_album: int = 0, + max_tracks: int = 0, max_per_album: int = 0, max_per_artist: int = 0, ) -> None: self.core = core self.prefix = prefix @@ -121,6 +125,7 @@ class ArtistMixHandler(tornado.web.RequestHandler): self.playlist_dir = playlist_dir self.max_tracks = max_tracks self.max_per_album = max_per_album + self.max_per_artist = max_per_artist def post(self) -> None: data = json.loads(self.request.body) @@ -132,6 +137,7 @@ class ArtistMixHandler(tornado.web.RequestHandler): tracks = build_artist_mix( self.core, artist, uris=self.uris, max_tracks=self.max_tracks, max_per_album=self.max_per_album, + max_per_artist=self.max_per_artist, ) if not tracks: self.write({"playlist": None, "tracks": 0}) @@ -323,17 +329,21 @@ def app_factory(config: Config, core: CoreProxy) -> list[tuple]: playlist_dir = _parse_playlist_dir(config) max_tracks = _parse_int(config, "max_tracks", 0) max_per_album = _parse_int(config, "max_per_album", 0) + max_per_artist = _parse_int(config, "max_per_artist", 0) return [ (r"/decade", DecadeMixHandler, {"core": core, "prefix": prefix, "uris": uris, "playlist_dir": playlist_dir, - "max_tracks": max_tracks, "max_per_album": max_per_album}), + "max_tracks": max_tracks, "max_per_album": max_per_album, + "max_per_artist": max_per_artist}), (r"/genre", GenreMixHandler, {"core": core, "prefix": prefix, "uris": uris, "playlist_dir": playlist_dir, - "max_tracks": max_tracks, "max_per_album": max_per_album}), + "max_tracks": max_tracks, "max_per_album": max_per_album, + "max_per_artist": max_per_artist}), (r"/artist", ArtistMixHandler, {"core": core, "prefix": prefix, "uris": uris, "playlist_dir": playlist_dir, - "max_tracks": max_tracks, "max_per_album": max_per_album}), + "max_tracks": max_tracks, "max_per_album": max_per_album, + "max_per_artist": max_per_artist}), (r"/album", AlbumMixHandler, {"core": core, "prefix": prefix, "playlist_dir": playlist_dir}), (r"/instant-mix", InstantMixHandler,