From f6373292aa0980709332db80e247fc40bb461456 Mon Sep 17 00:00:00 2001 From: cwilvx Date: Mon, 28 Oct 2024 16:42:51 +0300 Subject: [PATCH] document and add image to mix --- app/api/plugins/mixes.py | 4 +-- app/crons/mixes.py | 2 +- app/models/mix.py | 1 + app/plugins/mixes.py | 57 +++++++++++++++++++++++++++++++++++----- app/utils/mixes.py | 56 +++++++++++++++++++++++++-------------- 5 files changed, 91 insertions(+), 29 deletions(-) diff --git a/app/api/plugins/mixes.py b/app/api/plugins/mixes.py index 3f292ca9..a4048a77 100644 --- a/app/api/plugins/mixes.py +++ b/app/api/plugins/mixes.py @@ -31,7 +31,7 @@ def get_track_mix(): @api.post("/artist") def get_artist_mix(): mixes = MixesPlugin() - return mixes.get_artists() + return mixes.create_artist_mixes() # tracks = mixes.get_artist_mix("09306be8039b98ad") # return { @@ -54,7 +54,7 @@ def get_mix(query: MixQuery): case "a": mixtype = "artist_mixes" case _: - raise ValueError(f"Invalid mix ID: {query.mixid}") + return {"msg": "Invalid mix ID"}, 400 mix = HomepageStore.get_mix(mixtype, query.mixid[1:]) if mix: diff --git a/app/crons/mixes.py b/app/crons/mixes.py index 0aea5869..aee30a6a 100644 --- a/app/crons/mixes.py +++ b/app/crons/mixes.py @@ -16,6 +16,6 @@ class Mixes(CronJob): """ print("⭐⭐⭐⭐ Mixes cron job running") mixes = MixesPlugin() - artist_mixes = mixes.get_artists() + artist_mixes = mixes.create_artist_mixes() HomepageStore.set_artist_mixes(artist_mixes) diff --git a/app/models/mix.py b/app/models/mix.py index b5e807ef..9af84e7a 100644 --- a/app/models/mix.py +++ b/app/models/mix.py @@ -13,6 +13,7 @@ class Mix: title: str description: str tracks: list[str] + image: dict timestamp: int = field(default_factory=lambda: int(time.time())) extra: dict = field(default_factory=dict) diff --git a/app/plugins/mixes.py b/app/plugins/mixes.py index 4024414b..8aaafad5 100644 --- a/app/plugins/mixes.py +++ b/app/plugins/mixes.py @@ -32,7 +32,19 @@ class MixesPlugin(Plugin): @plugin_method def get_track_mix(self, tracks: list[Track], with_help: bool = False): - # query = f"{track.title} - {','.join(a['name'] for a in track.artists)}" + """ + Given a list of tracks, creates a mix by fetching data from the + Swing Music Cloud recommendation server. + + The server returns a list of weak trackhashes. We use these to fetch + the matching track data from our library database. Found tracks are + then balanced and returned as the final mix tracklist. + + :param with_help: Whether to include the help flag in the query. + The flag tells the server to find more data using other tracks from the same album. + + + """ queries = [ { "query": f"{track.title} - {','.join(a['name'] for a in track.artists)}", @@ -60,15 +72,23 @@ class MixesPlugin(Plugin): for track in trackmatches: grouped.setdefault(track.weakhash, []).append(track) - trackmatches = [max(group, key=lambda x: x.bitrate) for group in grouped.values()] + trackmatches = [ + max(group, key=lambda x: x.bitrate) for group in grouped.values() + ] # sort by trackhash order trackmatches = sorted(trackmatches, key=lambda x: trackhashes.index(x.weakhash)) + + # try to balance the mix trackmatches = balance_mix(trackmatches) return trackmatches @plugin_method def get_artist_mix(self, artisthash: str): + """ + Given an artisthash, creates an artist mix using the + self.MAX_TRACKS_TO_FETCH most listened to tracks. + """ artist = ArtistStore.artistmap[artisthash] tracks = TrackStore.get_tracks_by_trackhashes(artist.trackhashes) @@ -76,7 +96,7 @@ class MixesPlugin(Plugin): return self.get_track_mix(tracks[: self.MAX_TRACKS_TO_FETCH]) @plugin_method - def get_artists(self, limit: int = 10): + def create_artist_mixes(self, limit: int = 10): mixes: list[Mix] = [] indexed = set() @@ -130,6 +150,10 @@ class MixesPlugin(Plugin): return mixes def get_mix_description(self, tracks: list[Track], artishash: str): + """ + Constructs a description for a mix by putting together the first n=4 + artists in the mix tracklist. + """ first_4_artists = [] indexed = set() @@ -151,13 +175,22 @@ class MixesPlugin(Plugin): return f"Featuring {tracks[0].artists[0]['name']}" def create_artist_mix(self, artist: dict[str, str]): + """ + Given an artist dict, creates an artist mix. + """ mix_tracks = self.get_artist_mix(artist["artisthash"]) if len(mix_tracks) < self.MIN_TRACK_MIX_LENGTH: return None + _artist = ArtistStore.get_artist_by_hash(artist["artisthash"]) + + if not _artist: + return None + return Mix( - id=artist["artisthash"], + # the a prefix indicates that this is an artist mix + id=f"a{artist['artisthash']}", title=artist["artist"], description=self.get_mix_description(mix_tracks, artist["artisthash"]), tracks=[t.trackhash for t in mix_tracks], @@ -165,10 +198,19 @@ class MixesPlugin(Plugin): "type": "artist", "artisthash": artist["artisthash"], }, + image={ + "image": _artist.image, + "color": _artist.color, + }, ) - - def fallback_create_artist_mix(self, artist: dict[str, str], similar_artists: list[str], trackhashes: set[str], limit: int): + def fallback_create_artist_mix( + self, + artist: dict[str, str], + similar_artists: list[str], + trackhashes: set[str], + limit: int, + ): """ Creates an artist mix by selecting random tracks from similar artists. @@ -200,4 +242,5 @@ class MixesPlugin(Plugin): # Since the artisthashes are ordered by similarity score, we iterate from the start # and go forward collecting tracks that aren't in the mix yet. - # \ No newline at end of file + # + \ No newline at end of file diff --git a/app/utils/mixes.py b/app/utils/mixes.py index 9688aef4..352c038f 100644 --- a/app/utils/mixes.py +++ b/app/utils/mixes.py @@ -3,20 +3,31 @@ from typing import List, Dict, Tuple from collections import Counter -def violates_gap_rule(balanced_mix: Dict[int, Track], position: int, track: Track, gap: int = 3) -> bool: +def violates_gap_rule( + balanced_mix: Dict[int, Track], position: int, track: Track, gap: int = 3 +) -> bool: """ Check if placing the track at the given position violates the gap rule. + + The gap rule is violated if the track has an artist in common with any + track within the gap range (default = 3). """ - track_artists = set(artist['artisthash'] for artist in track.artists) - + track_artists = set(artist["artisthash"] for artist in track.artists) + for i in range(max(0, position - gap), position): if i in balanced_mix: - existing_artists = set(artist['artisthash'] for artist in balanced_mix[i].artists) + existing_artists = set( + artist["artisthash"] for artist in balanced_mix[i].artists + ) if track_artists.intersection(existing_artists): return True + return False -def find_next_position(balanced_mix: Dict[int, Track], start: int, track: Track, total_tracks: int) -> int: + +def find_next_position( + balanced_mix: Dict[int, Track], start: int, track: Track, total_tracks: int +) -> int: """ Find the next available position for the track, starting from 'start' and wrapping around. """ @@ -28,59 +39,66 @@ def find_next_position(balanced_mix: Dict[int, Track], start: int, track: Track, return i return start # If no better position is found, return the original position + def is_tracklist_balanced(tracks: List[Track], gap: int = 3) -> Tuple[bool, bool]: """ Checks if a tracklist is balanced or can be balanced. - + Args: - tracks: List of Track objects - gap: Minimum number of tracks between songs by the same artist (default 3) - + Returns: - A tuple (can_be_balanced, is_currently_balanced) """ total_tracks = len(tracks) - + # Count tracks per artist (considering only the first artist) - artist_counts = Counter(track.artists[0]['artisthash'] for track in tracks) - + artist_counts = Counter(track.artists[0]["artisthash"] for track in tracks) + # Calculate the maximum number of tracks an artist can have in a balanced list max_tracks_per_artist = (total_tracks + gap) // (gap + 1) - + # Check if it's mathematically possible to balance the tracklist - can_be_balanced = all(count <= max_tracks_per_artist for count in artist_counts.values()) + can_be_balanced = all( + count <= max_tracks_per_artist for count in artist_counts.values() + ) if not can_be_balanced: return False, False - + # Check if the current arrangement is balanced is_currently_balanced = True artist_last_positions = {} - + for i, track in enumerate(tracks): - artist = track.artists[0]['artisthash'] + artist = track.artists[0]["artisthash"] if artist in artist_last_positions: if i - artist_last_positions[artist] <= gap: is_currently_balanced = False break artist_last_positions[artist] = i - + return can_be_balanced, is_currently_balanced + def balance_mix(tracks: List[Track]) -> List[Track]: """ Balances the mix by ensuring that the tracks in a mix are distributed evenly. Preserves the overall rating order of tracks while minimizing disruption. + + Tracks that need to be moved are moved down the tracklist until they no longer + violate the gap rule. """ can_be_balanced, is_balanced = is_tracklist_balanced(tracks) - + if not can_be_balanced: print("Warning: This tracklist cannot be perfectly balanced.") # Proceed with best-effort balancing - + if is_balanced: return tracks # Already balanced, no need to modify - + balanced_mix: Dict[int, Track] = {} total_tracks = len(tracks)