diff --git a/app/api/plugins/mixes.py b/app/api/plugins/mixes.py index a4048a77..e513ac6b 100644 --- a/app/api/plugins/mixes.py +++ b/app/api/plugins/mixes.py @@ -20,7 +20,7 @@ def get_track_mix(): """ mixes = MixesPlugin() track = TrackStore.trackhashmap["9eeee292264ad01b"].get_best() - tracks = mixes.get_track_mix(track) + tracks = mixes.get_track_mix([track]) return { "total": len(tracks), diff --git a/app/crons/mixes.py b/app/crons/mixes.py index aee30a6a..9f9f212a 100644 --- a/app/crons/mixes.py +++ b/app/crons/mixes.py @@ -7,6 +7,7 @@ class Mixes(CronJob): """ This cron job creates mixes displayed on the homepage. """ + def __init__(self): super().__init__("mixes", 5) @@ -16,6 +17,11 @@ class Mixes(CronJob): """ print("⭐⭐⭐⭐ Mixes cron job running") mixes = MixesPlugin() + + if not mixes.enabled: + return + artist_mixes = mixes.create_artist_mixes() - HomepageStore.set_artist_mixes(artist_mixes) + if artist_mixes: + HomepageStore.set_artist_mixes(artist_mixes) diff --git a/app/models/album.py b/app/models/album.py index 0dc4322f..afe4f80b 100644 --- a/app/models/album.py +++ b/app/models/album.py @@ -36,6 +36,7 @@ class Album: image: str = "" versions: list[str] = dataclasses.field(default_factory=list) fav_userids: list[int] = dataclasses.field(default_factory=list) + weakhash: str = "" @property def is_favorite(self): @@ -54,6 +55,9 @@ class Album: def __post_init__(self): self.image = self.albumhash + ".webp" self.populate_versions() + self.weakhash = create_hash( + self.og_title, ",".join(a["name"] for a in self.albumartists) + ) def populate_versions(self): _, self.versions = get_base_title_and_versions(self.og_title, get_versions=True) diff --git a/app/plugins/mixes.py b/app/plugins/mixes.py index 378fb0c2..03e37121 100644 --- a/app/plugins/mixes.py +++ b/app/plugins/mixes.py @@ -1,5 +1,8 @@ +import datetime import json +import random import string +import time import requests from urllib.parse import quote from PIL import Image @@ -11,9 +14,10 @@ from app.models.mix import Mix from app.models.track import Track from app.plugins import Plugin, plugin_method from app.settings import Paths +from app.store.albums import AlbumStore from app.store.artists import ArtistStore from app.store.tracks import TrackStore -from app.utils.dates import get_date_range +from app.utils.dates import get_date_range, get_duration_ago from app.utils.mixes import balance_mix from app.utils.remove_duplicates import remove_duplicates from app.utils.stats import get_artists_in_period @@ -31,7 +35,18 @@ class MixesPlugin(Plugin): def __init__(self): super().__init__("mixes", "Mixes") self.server = "https://smcloud.mungaist.com" - self.set_active(True) + + server_online = self.ping_server() + self.set_active(server_online) + + def ping_server(self): + try: + requests.get(self.server, timeout=10) + except requests.exceptions.ConnectionError: + print("Failed to connect to the recommendation server") + return False + + return True @plugin_method def get_track_mix(self, tracks: list[Track], with_help: bool = False): @@ -57,13 +72,20 @@ class MixesPlugin(Plugin): for track in tracks ] - response = requests.post( - f"{self.server}/radio", - json=queries, - ) - results = response.json() + try: + response = requests.post(f"{self.server}/radio", json=queries, timeout=10) + except requests.exceptions.ConnectionError: + print("Failed to connect to recommendation server") + return [] + + try: + results = response.json() + except json.JSONDecodeError: + print("Failed to decode JSON response from recommendation server") + return [] # artisthashes = results["artists"] + # albumhashes = results["albums"] trackhashes: list[str] = results["tracks"] trackmatches = TrackStore.get_flat_list() @@ -82,6 +104,16 @@ class MixesPlugin(Plugin): # sort by trackhash order trackmatches = sorted(trackmatches, key=lambda x: trackhashes.index(x.weakhash)) + if len(trackmatches) < self.TRACK_MIX_LENGTH: + filler_tracks = self.fallback_create_artist_mix( + artist=tracks[0].artists[0], + similar_artists=results["artists"], + similar_albums=results["albums"], + omit_trackhashes={t.weakhash for t in trackmatches}, + limit=self.TRACK_MIX_LENGTH - len(trackmatches), + ) + trackmatches.extend(filler_tracks) + # try to balance the mix trackmatches = balance_mix(trackmatches) return trackmatches @@ -104,9 +136,9 @@ class MixesPlugin(Plugin): indexed = set() today_start, today_end = get_date_range(duration="day") - last_2_days_start, last_2_days_end = get_date_range(duration="day", units_ago=2) - last_7_days_start, last_7_days_end = get_date_range(duration="week") - last_1_month_start, last_1_month_end = get_date_range(duration="month") + last_2_days_start = get_duration_ago("day", 2) + last_7_days_start = get_duration_ago("week") + last_1_month_start = get_duration_ago("month") artists = { "today": { @@ -116,17 +148,17 @@ class MixesPlugin(Plugin): }, "last_2_days": { "max": 2, - "artists": get_artists_in_period(last_2_days_start, last_2_days_end), + "artists": get_artists_in_period(last_2_days_start, time.time()), "created": 0, }, "last_7_days": { "max": 3, - "artists": get_artists_in_period(last_7_days_start, last_7_days_end), + "artists": get_artists_in_period(last_7_days_start, time.time()), "created": 0, }, "last_1_month": { "max": 2, - "artists": get_artists_in_period(last_1_month_start, last_1_month_end), + "artists": get_artists_in_period(last_1_month_start, time.time()), "created": 0, }, } @@ -219,7 +251,10 @@ class MixesPlugin(Plugin): ) def download_artist_image(self, artist: Artist): - res = requests.get(f"{self.server}/image?artist={artist.name}") + try: + res = requests.get(f"{self.server}/image?artist={artist.name}") + except requests.exceptions.ConnectionError: + return None if res.status_code == 200: # save to file @@ -247,8 +282,9 @@ class MixesPlugin(Plugin): def fallback_create_artist_mix( self, artist: dict[str, str], + similar_albums: list[str], similar_artists: list[str], - trackhashes: set[str], + omit_trackhashes: set[str], limit: int, ): """ @@ -264,16 +300,70 @@ class MixesPlugin(Plugin): :param trackhashes: A set of trackhashes to omit from the new tracklist. :param limit: The maximum number of tracks to select. """ - artists = similar_artists - if len(similar_artists) == 0: - local_similar_artists = SimilarArtistTable.get_by_hash(artist["artisthash"]) + mixtracks = [] + albummatches = ( + a + for a in AlbumStore.albummap.values() + if a.album.weakhash in similar_albums + ) - if local_similar_artists: - artists = [a.artisthash for a in local_similar_artists.similar_artists] + for match in albummatches: + if len(mixtracks) >= limit: + print(f"Filled up to {limit} tracks with album tracks") + return mixtracks - if len(artists) == 0: - return [] + albumtracks = [ + t + for t in TrackStore.get_tracks_by_trackhashes(match.trackhashes) + if t.weakhash not in omit_trackhashes + ] + + if len(albumtracks) == 0: + continue + + sample = random.sample(albumtracks, k=1) + mixtracks.extend(sample) + print( + f"Supplement: album track {sample[0].title} from ALBUM: {match.album.og_title}" + ) + + artistmatches = ( + a + for a in ArtistStore.artistmap.values() + if a.artist.artisthash in similar_artists + ) + + for match in artistmatches: + if len(mixtracks) >= limit: + print(f"Filled up to {limit} tracks with artist tracks") + return mixtracks + + artisttracks = [ + t + for t in TrackStore.get_tracks_by_trackhashes(match.trackhashes) + if t.weakhash not in omit_trackhashes + ] + + if len(artisttracks) == 0: + continue + + sample = random.sample(artisttracks, k=1) + mixtracks.extend(sample) + print( + f"Supplement: track {sample[0].title} from ARTIST: {match.artist.name}" + ) + + return mixtracks + + # if len(similar_artists) == 0: + # local_similar_artists = SimilarArtistTable.get_by_hash(artist["artisthash"]) + + # if local_similar_artists: + # artists = [a.artisthash for a in local_similar_artists.similar_artists] + + # if len(artists) == 0: + # return [] # CHECKPOINT: I'M TIRED AF AND I NEED TO SLEEP # The plan: diff --git a/app/serializers/album.py b/app/serializers/album.py index 35405b4b..4e4c5183 100644 --- a/app/serializers/album.py +++ b/app/serializers/album.py @@ -38,6 +38,7 @@ def serialize_for_card(album: Album): "extra", "id", "lastplayed", + "weakhash", } return album_serializer(album, props_to_remove) diff --git a/app/utils/dates.py b/app/utils/dates.py index 484a0c14..c7b5306f 100644 --- a/app/utils/dates.py +++ b/app/utils/dates.py @@ -71,10 +71,13 @@ def get_date_range(duration: str, units_ago: int = 0): Returns a tuple of dates representing the start and end of a given duration. """ date_range = None - seconds_ago = ( - pendulum.now() - pendulum.now().subtract().start_of(duration) - ).total_seconds() * units_ago - print("seconds_ago", duration, str(seconds_ago)) + seconds_ago = 0 + + if duration != "alltime": + seconds_ago = ( + pendulum.now() - pendulum.now().subtract().start_of(duration) + ).total_seconds() * units_ago + print("seconds_ago", duration, str(seconds_ago)) match duration: case "day" | "week" | "month" | "year": @@ -83,7 +86,9 @@ def get_date_range(duration: str, units_ago: int = 0): .subtract(seconds=seconds_ago) .start_of(duration) .timestamp(), - pendulum.now().end_of(duration).timestamp(), + pendulum.now() + # .end_of(duration) + .timestamp(), ) case "alltime": date_range = (0, pendulum.now().timestamp()) @@ -93,6 +98,40 @@ def get_date_range(duration: str, units_ago: int = 0): return (int(date_range[0]), int(date_range[1])) +def get_duration_ago(duration: str, units_ago: int = 1) -> int: + """ + Returns the start of the last duration. + """ + seconds_in_day = 24 * 60 * 60 + now = pendulum.now() + + match duration: + case "day": + return int( + now.subtract(seconds=seconds_in_day * units_ago).timestamp() + ) + case "week": + return int( + now + .subtract(seconds=seconds_in_day * 7 * units_ago) + .timestamp() + ) + case "month": + return int( + now + .subtract(seconds=seconds_in_day * 30 * units_ago) + .timestamp() + ) + case "year": + return int( + now + .subtract(seconds=seconds_in_day * 365 * units_ago) + .timestamp() + ) + case _: + raise ValueError(f"Invalid duration: {duration}") + + def get_duration_in_seconds(duration: str) -> int: """ Returns the number of seconds in a given duration. diff --git a/app/utils/stats.py b/app/utils/stats.py index 3c832506..86df3eb4 100644 --- a/app/utils/stats.py +++ b/app/utils/stats.py @@ -11,7 +11,7 @@ from app.store.tracks import TrackStore from app.utils.dates import seconds_to_time_string -def get_artists_in_period(start_time: int, end_time: int): +def get_artists_in_period(start_time: int | float, end_time: int | float): scrobbles = ScrobbleTable.get_all_in_period(start_time, end_time) artists: Any = defaultdict(lambda: {"playcount": 0, "playduration": 0}) diff --git a/manage.py b/manage.py index f222f326..afdb7a85 100644 --- a/manage.py +++ b/manage.py @@ -102,7 +102,7 @@ whitelisted_routes = { "/auth/refresh", "/docs", } -blacklist_extensions = {".webp"}.union(getClientFilesExtensions()) +blacklist_extensions = {".webp", ".jpg"}.union(getClientFilesExtensions()) def skipAuthAction():