From c4a73f0d63bf7088b267dc2674cfc4de224791cc Mon Sep 17 00:00:00 2001 From: cwilvx Date: Fri, 25 Oct 2024 23:26:08 +0300 Subject: [PATCH] first recommendation draft --- app/api/__init__.py | 2 + app/api/home/__init__.py | 13 ++++ app/api/plugins/mixes.py | 63 +++++++++++++++ app/crons/__init__.py | 12 +++ app/crons/cron.py | 23 ++++++ app/crons/mixes.py | 15 ++++ app/models/mix.py | 37 +++++++++ app/models/track.py | 2 + app/plugins/mixes.py | 164 +++++++++++++++++++++++++++++++++++++++ app/store/homepage.py | 30 +++++++ app/utils/dates.py | 13 +++- app/utils/stats.py | 6 +- manage.py | 2 + poetry.lock | 16 +++- pyproject.toml | 1 + 15 files changed, 393 insertions(+), 6 deletions(-) create mode 100644 app/api/plugins/mixes.py create mode 100644 app/crons/__init__.py create mode 100644 app/crons/cron.py create mode 100644 app/crons/mixes.py create mode 100644 app/models/mix.py create mode 100644 app/plugins/mixes.py create mode 100644 app/store/homepage.py diff --git a/app/api/__init__.py b/app/api/__init__.py index f0d474f0..671e9b15 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -14,6 +14,7 @@ from app.config import UserConfig from app.db.userdata import UserTable from app.settings import Info as AppInfo from .plugins import lyrics as lyrics_plugin +from .plugins import mixes as mixes_plugin from app.api import ( album, artist, @@ -113,6 +114,7 @@ def create_api(): # Plugins app.register_api(plugins.api) app.register_api(lyrics_plugin.api) + app.register_api(mixes_plugin.api) # Logger app.register_api(scrobble.api) diff --git a/app/api/home/__init__.py b/app/api/home/__init__.py index c06a37bb..9c50a5ec 100644 --- a/app/api/home/__init__.py +++ b/app/api/home/__init__.py @@ -5,6 +5,7 @@ from flask_openapi3 import APIBlueprint from app.api.apischemas import GenericLimitSchema from app.lib.home.recentlyadded import get_recently_added_items from app.lib.home.recentlyplayed import get_recently_played +from app.store.homepage import HomepageStore bp_tag = Tag(name="Home", description="Homepage items") api = APIBlueprint("home", __name__, url_prefix="/home", abp_tags=[bp_tag]) @@ -24,3 +25,15 @@ def get_recent_plays(query: GenericLimitSchema): Get recently played """ return {"items": get_recently_played(query.limit)} + + +@api.get("/") +def homepage_items(): + return { + "artist_mixes": { + "title": "Artist mixes for you", + "description": "Curated based on artists you have played in the past", + "items": HomepageStore.get_artist_mixes(), + "extra": {}, + }, + } diff --git a/app/api/plugins/mixes.py b/app/api/plugins/mixes.py new file mode 100644 index 00000000..3f292ca9 --- /dev/null +++ b/app/api/plugins/mixes.py @@ -0,0 +1,63 @@ +from flask_openapi3 import Tag +from flask_openapi3 import APIBlueprint +from pydantic import BaseModel, Field + +from app.plugins.mixes import MixesPlugin +from app.store.homepage import HomepageStore +from app.store.tracks import TrackStore + + +bp_tag = Tag(name="Mixes Plugin", description="Mixes plugin hehe") +api = APIBlueprint( + "mixesplugin", __name__, url_prefix="/plugins/mixes", abp_tags=[bp_tag] +) + + +@api.post("/track") +def get_track_mix(): + """ + Get a track mix + """ + mixes = MixesPlugin() + track = TrackStore.trackhashmap["9eeee292264ad01b"].get_best() + tracks = mixes.get_track_mix(track) + + return { + "total": len(tracks), + "tracks": tracks, + } + + +@api.post("/artist") +def get_artist_mix(): + mixes = MixesPlugin() + return mixes.get_artists() + # tracks = mixes.get_artist_mix("09306be8039b98ad") + + # return { + # "total": len(tracks), + # "tracks": tracks, + # } + + return "hi" + + +class MixQuery(BaseModel): + mixid: str = Field(description="The mix id") + + +@api.get("/") +def get_mix(query: MixQuery): + mixtype = "" + + match query.mixid[0]: + case "a": + mixtype = "artist_mixes" + case _: + raise ValueError(f"Invalid mix ID: {query.mixid}") + + mix = HomepageStore.get_mix(mixtype, query.mixid[1:]) + if mix: + return mix, 200 + + return {"msg": "Mix not found"}, 404 diff --git a/app/crons/__init__.py b/app/crons/__init__.py new file mode 100644 index 00000000..7357fbff --- /dev/null +++ b/app/crons/__init__.py @@ -0,0 +1,12 @@ +import time +import schedule + +from app.crons.mixes import Mixes +from app.utils.threading import background + + +@background +def start_cron_jobs(): + Mixes().run() + + # schedule.run_pending() diff --git a/app/crons/cron.py b/app/crons/cron.py new file mode 100644 index 00000000..939c94a7 --- /dev/null +++ b/app/crons/cron.py @@ -0,0 +1,23 @@ +import schedule + + +from abc import ABC, abstractmethod + + +class CronJob(ABC): + """ + A cron job that will be run on a regular interval. + """ + + def __init__(self, name: str, hours: int): + self.name = name + self.hours = hours + + schedule.every(self.hours).seconds.do(self.run) + + @abstractmethod + def run(self): + """ + The function that will be called by the cron job. + """ + ... diff --git a/app/crons/mixes.py b/app/crons/mixes.py new file mode 100644 index 00000000..44e62945 --- /dev/null +++ b/app/crons/mixes.py @@ -0,0 +1,15 @@ +from app.crons.cron import CronJob +from app.plugins.mixes import MixesPlugin +from app.store.homepage import HomepageStore + + +class Mixes(CronJob): + def __init__(self): + super().__init__("mixes", 5) + + def run(self): + print("⭐⭐⭐⭐ Mixes cron job running") + mixes = MixesPlugin() + artist_mixes = mixes.get_artists() + + HomepageStore.set_artist_mixes(artist_mixes) diff --git a/app/models/mix.py b/app/models/mix.py new file mode 100644 index 00000000..b5e807ef --- /dev/null +++ b/app/models/mix.py @@ -0,0 +1,37 @@ +from dataclasses import asdict, dataclass, field +import time + +from app.lib.playlistlib import get_first_4_images +from app.serializers.track import serialize_tracks +from app.store.tracks import TrackStore +from app.utils.dates import seconds_to_time_string + + +@dataclass +class Mix: + id: str + title: str + description: str + tracks: list[str] + + timestamp: int = field(default_factory=lambda: int(time.time())) + extra: dict = field(default_factory=dict) + saved: bool = False + + def to_full_dict(self): + tracks = TrackStore.get_tracks_by_trackhashes(self.tracks) + serialized_tracks = serialize_tracks(tracks) + + _dict = asdict(self) + _dict["tracks"] = serialized_tracks + _dict["images"] = get_first_4_images(tracks) + _dict["duration"] = seconds_to_time_string(sum(t.duration for t in tracks)) + _dict["trackcount"] = len(tracks) + + return _dict + + def to_dict(self): + item = self.to_full_dict() + del item["tracks"] + + return item diff --git a/app/models/track.py b/app/models/track.py index 8f764bf9..5598b9e4 100644 --- a/app/models/track.py +++ b/app/models/track.py @@ -45,6 +45,7 @@ class Track: og_title: str = "" artisthashes: list[str] = field(default_factory=list) genrehashes: list[str] = field(default_factory=list) + weakhash: str = "" _pos: int = 0 _ati: str = "" @@ -76,6 +77,7 @@ class Track: self.og_title = self.title self.og_album = self.album self.folder = self.folder + "/" + self.weakhash = create_hash(self.title, self.artists) self.image = self.albumhash + ".webp" self.extra = { diff --git a/app/plugins/mixes.py b/app/plugins/mixes.py new file mode 100644 index 00000000..54036fa4 --- /dev/null +++ b/app/plugins/mixes.py @@ -0,0 +1,164 @@ +import json +import string +import requests +from urllib.parse import quote + +from app.models.artist import Artist +from app.models.mix import Mix +from app.models.track import Track +from app.plugins import Plugin, plugin_method +from app.store.artists import ArtistStore +from app.store.tracks import TrackStore +from app.utils.dates import get_date_range +from app.utils.remove_duplicates import remove_duplicates +from app.utils.stats import get_artists_in_period + + +class MixesPlugin(Plugin): + MAX_TRACKS_TO_FETCH = 5 + TRACK_MIX_LENGTH = 50 + MIN_TRACK_MIX_LENGTH = 15 + + MIN_DAY_LISTEN_DURATION = 3 * 60 # 3 minutes + MIN_WEEK_LISTEN_DURATION = 10 * 60 # 10 minutes + MIN_MONTH_LISTEN_DURATION = 20 * 60 # 20 minutes + + def __init__(self): + super().__init__("mixes", "Mixes") + self.server = "https://smcloud.mungaist.com" + self.set_active(True) + + @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)}" + queries = [ + { + "query": f"{track.title} - {','.join(a['name'] for a in track.artists)}", + "album": track.og_album, + "with_help": with_help, + } + for track in tracks + ] + + response = requests.post( + f"{self.server}/radio", + json=queries, + ) + results = response.json() + + # artisthashes = results["artists"] + trackhashes: list[str] = results["tracks"] + + trackmatches = TrackStore.get_flat_list() + trackmatches = [t for t in trackmatches if t.weakhash in trackhashes] + + # filter out duplicates of the same weakhash + # group by weakhash and pick the one with the highest bitrate + grouped: dict[str, list[Track]] = {} + for track in trackmatches: + grouped.setdefault(track.weakhash, []).append(track) + + 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)) + return trackmatches + + @plugin_method + def get_artist_mix(self, artisthash: str): + artist = ArtistStore.artistmap[artisthash] + tracks = TrackStore.get_tracks_by_trackhashes(artist.trackhashes) + + tracks = sorted(tracks, key=lambda x: x.playduration, reverse=True) + return self.get_track_mix(tracks[: self.MAX_TRACKS_TO_FETCH]) + + @plugin_method + def get_artists(self, limit: int = 10): + mixes: list[Mix] = [] + 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") + + artists = { + "today": { + "max": 2, + "artists": get_artists_in_period(today_start, today_end), + "created": 0, + }, + "last_2_days": { + "max": 2, + "artists": get_artists_in_period(last_2_days_start, last_2_days_end), + "created": 0, + }, + "last_7_days": { + "max": 3, + "artists": get_artists_in_period(last_7_days_start, last_7_days_end), + "created": 0, + }, + "last_1_month": { + "max": 2, + "artists": get_artists_in_period(last_1_month_start, last_1_month_end), + "created": 0, + }, + } + + for i, period in enumerate(artists.values()): + # if previous period has less than its max + # add the difference to this period's limit + limit = period["max"] + + if i > 0: + previous_period = artists[list(artists.keys())[i - 1]] + if previous_period["created"] < previous_period["max"]: + limit += previous_period["max"] - previous_period["created"] + + for artist in period["artists"][:limit]: + mix = self.create_artist_mix(artist) + + if mix: + mixes.append(mix) + indexed.add(artist["artisthash"]) + period["created"] += 1 + + return mixes + + def get_mix_description(self, tracks: list[Track], artishash: str): + first_4_artists = [] + indexed = set() + + for track in tracks: + if len(first_4_artists) < 4: + if ( + track.artists[0]["artisthash"] != artishash + and track.artists[0]["artisthash"] not in indexed + ): + first_4_artists.append(track.artists[0]) + indexed.add(track.artists[0]["artisthash"]) + + if len(first_4_artists) == 4: + return f"Featuring {', '.join(a['name'] for a in first_4_artists)} and more" + + if len(first_4_artists) > 0: + return f"Featuring {', '.join(a['name'] for a in first_4_artists)}" + + return f"Featuring {tracks[0].artists[0]['name']}" + + def create_artist_mix(self, artist: dict[str, str]): + mix_tracks = self.get_artist_mix(artist["artisthash"]) + + if len(mix_tracks) < self.MIN_TRACK_MIX_LENGTH: + return None + + return Mix( + id=artist["artisthash"], + title=artist["artist"], + description=self.get_mix_description(mix_tracks, artist["artisthash"]), + tracks=[t.trackhash for t in mix_tracks], + extra={ + "type": "artist", + "artisthash": artist["artisthash"], + }, + ) diff --git a/app/store/homepage.py b/app/store/homepage.py new file mode 100644 index 00000000..5691fb62 --- /dev/null +++ b/app/store/homepage.py @@ -0,0 +1,30 @@ +from app.models.mix import Mix +from app.store.tracks import TrackStore +from app.utils.auth import get_current_userid + + +class HomepageStore: + entries = { + "artist_mixes": {}, + } + + @classmethod + def set_artist_mixes(cls, mixes: list[Mix], userid: int = 1): + idmap = {mix.id: mix for mix in mixes} + cls.entries["artist_mixes"][userid] = idmap + + @classmethod + def get_artist_mixes(cls): + return [ + { + "type": "mix", + "item": mix.to_dict(), + } + for mix in cls.entries["artist_mixes"] + .get(get_current_userid(), {}) + .values() + ] + + @classmethod + def get_mix(cls, mixtype: str, mixid: str): + return cls.entries[mixtype].get(get_current_userid(), {}).get(mixid).to_full_dict() diff --git a/app/utils/dates.py b/app/utils/dates.py index 9d5228dd..484a0c14 100644 --- a/app/utils/dates.py +++ b/app/utils/dates.py @@ -66,16 +66,23 @@ def seconds_to_time_string(seconds): return f"{remaining_seconds} sec" -def get_date_range(duration: str): +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)) match duration: - case "week" | "month" | "year": + case "day" | "week" | "month" | "year": date_range = ( - pendulum.now().subtract().start_of(duration).timestamp(), + pendulum.now() + .subtract(seconds=seconds_ago) + .start_of(duration) + .timestamp(), pendulum.now().end_of(duration).timestamp(), ) case "alltime": diff --git a/app/utils/stats.py b/app/utils/stats.py index 8d353b3a..3c832506 100644 --- a/app/utils/stats.py +++ b/app/utils/stats.py @@ -13,7 +13,7 @@ from app.utils.dates import seconds_to_time_string def get_artists_in_period(start_time: int, end_time: int): scrobbles = ScrobbleTable.get_all_in_period(start_time, end_time) - artists = defaultdict(lambda: {"playcount": 0, "playduration": 0}) + artists: Any = defaultdict(lambda: {"playcount": 0, "playduration": 0}) for scrobble in scrobbles: track = TrackStore.get_tracks_by_trackhashes([scrobble.trackhash]) @@ -24,11 +24,13 @@ def get_artists_in_period(start_time: int, end_time: int): for artist in track.artists: artisthash = artist["artisthash"] + artists[artisthash]["artist"] = artist["name"] artists[artisthash]["artisthash"] = artist["artisthash"] artists[artisthash]["playcount"] += 1 artists[artisthash]["playduration"] += scrobble.duration - return list(artists.values()) + artists = list(artists.values()) + return sorted(artists, key=lambda x: x["playduration"], reverse=True) def get_albums_in_period(start_time: int, end_time: int): diff --git a/manage.py b/manage.py index 20d70e69..c35fb1b6 100644 --- a/manage.py +++ b/manage.py @@ -21,6 +21,7 @@ import setproctitle from app.api import create_api from app.arg_handler import ProcessArgs +from app.crons import start_cron_jobs from app.lib.index import IndexEverything from app.plugins.register import register_plugins from app.settings import FLASKVARS, TCOLOR, Info @@ -74,6 +75,7 @@ def run_swingmusic(): log_startup_info() bg_run_setup() register_plugins() + start_cron_jobs() # start_watchdog() diff --git a/poetry.lock b/poetry.lock index 0af17fe4..4d60be39 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2052,6 +2052,20 @@ files = [ {file = "roundrobin-0.0.4.tar.gz", hash = "sha256:7e9d19a5bd6123d99993fb935fa86d25c88bb2096e493885f61737ed0f5e9abd"}, ] +[[package]] +name = "schedule" +version = "1.2.2" +description = "Job scheduling for humans." +optional = false +python-versions = ">=3.7" +files = [ + {file = "schedule-1.2.2-py3-none-any.whl", hash = "sha256:5bef4a2a0183abf44046ae0d164cadcac21b1db011bdd8102e4a0c1e91e06a7d"}, + {file = "schedule-1.2.2.tar.gz", hash = "sha256:15fe9c75fe5fd9b9627f3f19cc0ef1420508f9f9a46f45cd0769ef75ede5f0b7"}, +] + +[package.extras] +timezone = ["pytz"] + [[package]] name = "setproctitle" version = "1.3.3" @@ -2761,4 +2775,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.12" -content-hash = "43972b6ffadd14e5047f067a0258f2428ebe351df8bd032dc0bf05df379678a6" +content-hash = "85f8932739522e7b53b4fe5bbecc3c10a30bb690e25bf9404209c57ec71e88d3" diff --git a/pyproject.toml b/pyproject.toml index 87d5b809..d2f32de0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ memory-profiler = "^0.61.0" sortedcontainers = "^2.4.0" xxhash = "^3.4.1" ffmpeg-python = "^0.2.0" +schedule = "^1.2.2" [tool.poetry.dev-dependencies] pylint = "^2.15.5"