first recommendation draft

This commit is contained in:
cwilvx
2024-10-25 23:26:08 +03:00
parent a26373669d
commit c4a73f0d63
15 changed files with 393 additions and 6 deletions
+2
View File
@@ -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)
+13
View File
@@ -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": {},
},
}
+63
View File
@@ -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
+12
View File
@@ -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()
+23
View File
@@ -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.
"""
...
+15
View File
@@ -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)
+37
View File
@@ -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
+2
View File
@@ -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 = {
+164
View File
@@ -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"],
},
)
+30
View File
@@ -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()
+10 -3
View File
@@ -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":
+4 -2
View File
@@ -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):
+2
View File
@@ -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()
Generated
+15 -1
View File
@@ -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"
+1
View File
@@ -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"