mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-03 20:13:03 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
class Plugin:
|
||||
"""
|
||||
Class that all plugins should inherit from
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, description: str) -> None:
|
||||
self.enabled = False
|
||||
self.name = name
|
||||
self.description = description
|
||||
|
||||
def set_active(self, state: bool):
|
||||
self.enabled = state
|
||||
|
||||
|
||||
def plugin_method(func):
|
||||
"""
|
||||
A decorator that prevents execution if the plugin is disabled.
|
||||
Should be used on all plugin methods
|
||||
"""
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
plugin: Plugin = args[0]
|
||||
|
||||
if plugin.enabled:
|
||||
return func(*args, **kwargs)
|
||||
else:
|
||||
return
|
||||
|
||||
return wrapper
|
||||
@@ -0,0 +1,157 @@
|
||||
import contextlib
|
||||
import json
|
||||
import time
|
||||
from hashlib import md5
|
||||
from typing import Any
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
import requests
|
||||
|
||||
from swingmusic.config import UserConfig
|
||||
from swingmusic.logger import log
|
||||
from swingmusic.models.track import Track
|
||||
from swingmusic.plugins import Plugin, plugin_method
|
||||
from swingmusic.settings import Paths
|
||||
from swingmusic.utils.threading import background
|
||||
|
||||
|
||||
class LastFmPlugin(Plugin):
|
||||
"""
|
||||
Last.fm scrobbler plugin.
|
||||
"""
|
||||
|
||||
UPLOADING_DUMPS = False
|
||||
|
||||
def __init__(self, current_userid: int):
|
||||
self.config = UserConfig()
|
||||
self.current_userid = current_userid
|
||||
super().__init__("lastfm", "Last.fm scrobbler")
|
||||
self.set_active(
|
||||
bool(
|
||||
self.config.lastfmApiKey
|
||||
and self.config.lastfmApiSecret
|
||||
and self.config.lastfmSessionKeys.get(str(self.current_userid))
|
||||
)
|
||||
)
|
||||
|
||||
def get_api_signature(self, data: dict[str, Any]) -> str:
|
||||
params = dict(data.items())
|
||||
|
||||
signature = "".join(f"{k}{v}" for k, v in sorted(params.items()))
|
||||
signature += self.config.lastfmApiSecret
|
||||
|
||||
return md5(signature.encode("utf-8")).hexdigest()
|
||||
|
||||
def post(self, data: dict[str, Any], useSessionKey: bool = True):
|
||||
url = "http://ws.audioscrobbler.com/2.0/?format=json"
|
||||
data["api_key"] = self.config.lastfmApiKey
|
||||
if useSessionKey:
|
||||
data["sk"] = self.config.lastfmSessionKeys.get(str(self.current_userid))
|
||||
|
||||
data["api_sig"] = self.get_api_signature(data)
|
||||
|
||||
final_url = (
|
||||
url + "&" + "&".join(f"{k}={quote_plus(str(v))}" for k, v in data.items())
|
||||
)
|
||||
|
||||
return requests.post(final_url)
|
||||
|
||||
def get_session_key(self, token: str):
|
||||
data = {
|
||||
"method": "auth.getSession",
|
||||
"token": token,
|
||||
}
|
||||
|
||||
try:
|
||||
res = self.post(data, useSessionKey=False)
|
||||
return res.json()["session"]["key"]
|
||||
except Exception as e:
|
||||
print("get_session_key error", e)
|
||||
return None
|
||||
|
||||
@plugin_method
|
||||
@background
|
||||
def scrobble(self, track: Track, timestamp: int):
|
||||
data = {
|
||||
"method": "track.scrobble",
|
||||
"artist": track.artists[0]["name"],
|
||||
"track": track.title,
|
||||
"timestamp": timestamp,
|
||||
"album": track.album,
|
||||
"albumArtist": track.albumartists[0]["name"],
|
||||
}
|
||||
|
||||
success = self.post_scrobble_data({**data})
|
||||
|
||||
if not success:
|
||||
self.dump_scrobble(data)
|
||||
else:
|
||||
self.upload_dumps()
|
||||
|
||||
return success
|
||||
|
||||
def post_scrobble_data(self, data: dict[str, Any]):
|
||||
"""
|
||||
Uploads the scrobble data and handles the
|
||||
response from the lastfm scrobble endpoint.
|
||||
"""
|
||||
try:
|
||||
res = self.post(data)
|
||||
except Exception as e:
|
||||
log.warn("scrobble response error" + str(e))
|
||||
return False
|
||||
|
||||
try:
|
||||
res_json: dict[str, Any] = res.json()
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
return False
|
||||
|
||||
if res_json.get("error"):
|
||||
log.error("LASTFM: scrobble error" + str(res_json))
|
||||
|
||||
if res_json["error"] == 9:
|
||||
log.error("LAST.FM: Invalid session key")
|
||||
# Invalid session key
|
||||
with contextlib.suppress(KeyError):
|
||||
self.config.lastfmSessionKeys.pop(str(self.current_userid))
|
||||
|
||||
self.config.lastfmSessionKeys = self.config.lastfmSessionKeys
|
||||
return False
|
||||
|
||||
return res_json.get("scrobbles", {}).get("@attr", {}).get("accepted") == 1
|
||||
|
||||
# SECTION: Persistence
|
||||
def dump_scrobble(self, data: dict[str, Any]):
|
||||
"""
|
||||
Dumps the scrobble data to a file in the lastfm plugin directory.
|
||||
"""
|
||||
dump_dir = Paths().plugins_path / "lastfm"
|
||||
if not dump_dir.exists():
|
||||
dump_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
path = dump_dir / f"{int(time.time())}.json"
|
||||
path.write_text(json.dumps(data))
|
||||
|
||||
def upload_dumps(self):
|
||||
"""
|
||||
Uploads the scrobble dumps to the lastfm api.
|
||||
"""
|
||||
if self.UPLOADING_DUMPS:
|
||||
return
|
||||
|
||||
self.UPLOADING_DUMPS = True
|
||||
dump_dir = Paths().plugins_path / "lastfm"
|
||||
|
||||
if not dump_dir.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
for file in dump_dir.iterdir():
|
||||
with open(file) as f:
|
||||
data = json.load(f)
|
||||
success = self.post_scrobble_data(data)
|
||||
|
||||
if success:
|
||||
file.unlink()
|
||||
finally:
|
||||
self.UPLOADING_DUMPS = False
|
||||
@@ -0,0 +1,383 @@
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from unidecode import unidecode
|
||||
|
||||
from swingmusic.db.userdata import PluginTable
|
||||
from swingmusic.plugins import Plugin, plugin_method
|
||||
from swingmusic.settings import Paths
|
||||
|
||||
|
||||
class LRCProvider:
|
||||
"""Base class for synced (LRC format) lyrics providers."""
|
||||
|
||||
session = requests.Session()
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.session.headers.update(
|
||||
{
|
||||
"user-agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/122.0.0.0 Safari/537.36"
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
def get_lrc_by_id(self, track_id: str) -> str | None:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_lrc(self, title: str, artist: str, album: str = "") -> list[dict]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class LRCLibProvider(LRCProvider):
|
||||
"""LRCLIB-first provider (SpotiFLAC-style exact->fallback search strategy)."""
|
||||
|
||||
ROOT_URL = "https://lrclib.net/api"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._by_id_cache: dict[str, str] = {}
|
||||
|
||||
def _get_json(self, endpoint: str, params: dict) -> dict | list | None:
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{self.ROOT_URL}/{endpoint}",
|
||||
params=params,
|
||||
timeout=10,
|
||||
)
|
||||
if not response.ok:
|
||||
return None
|
||||
return response.json()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _entry_to_lrc(self, entry: dict) -> str | None:
|
||||
synced = (entry.get("syncedLyrics") or "").strip()
|
||||
plain = (entry.get("plainLyrics") or "").strip()
|
||||
|
||||
if synced:
|
||||
return synced
|
||||
if plain:
|
||||
return plain
|
||||
return None
|
||||
|
||||
def _to_result(self, entry: dict) -> dict | None:
|
||||
lrc = self._entry_to_lrc(entry)
|
||||
if not lrc:
|
||||
return None
|
||||
|
||||
track_id = str(entry.get("id") or "")
|
||||
provider_track_id = f"lrclib:{track_id}" if track_id else f"lrclib:{hash(lrc)}"
|
||||
self._by_id_cache[provider_track_id] = lrc
|
||||
|
||||
return {
|
||||
"track_id": provider_track_id,
|
||||
"title": entry.get("trackName", ""),
|
||||
"artist": entry.get("artistName", ""),
|
||||
"album": entry.get("albumName", ""),
|
||||
"image": None,
|
||||
"provider": "lrclib",
|
||||
"lrc": lrc,
|
||||
}
|
||||
|
||||
def get_lrc_by_id(self, track_id: str) -> str | None:
|
||||
return self._by_id_cache.get(track_id)
|
||||
|
||||
def get_lrc(self, title: str, artist: str, album: str = "") -> list[dict]:
|
||||
if not title or not artist:
|
||||
return []
|
||||
|
||||
results: list[dict] = []
|
||||
|
||||
# 1) Exact lookup including album when available.
|
||||
if album:
|
||||
exact_with_album = self._get_json(
|
||||
"get",
|
||||
{
|
||||
"artist_name": artist,
|
||||
"track_name": title,
|
||||
"album_name": album,
|
||||
},
|
||||
)
|
||||
if isinstance(exact_with_album, dict):
|
||||
result = self._to_result(exact_with_album)
|
||||
if result:
|
||||
results.append(result)
|
||||
|
||||
# 2) Exact lookup without album.
|
||||
if not results:
|
||||
exact = self._get_json(
|
||||
"get",
|
||||
{
|
||||
"artist_name": artist,
|
||||
"track_name": title,
|
||||
},
|
||||
)
|
||||
if isinstance(exact, dict):
|
||||
result = self._to_result(exact)
|
||||
if result:
|
||||
results.append(result)
|
||||
|
||||
# 3) Search fallback.
|
||||
if not results:
|
||||
search_data = self._get_json(
|
||||
"search",
|
||||
{
|
||||
"artist_name": artist,
|
||||
"track_name": title,
|
||||
},
|
||||
)
|
||||
if isinstance(search_data, list):
|
||||
for entry in search_data:
|
||||
result = self._to_result(entry)
|
||||
if result:
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class MusixmatchProvider(LRCProvider):
|
||||
"""Musixmatch provider class."""
|
||||
|
||||
ROOT_URL = "https://apic-desktop.musixmatch.com/ws/1.1/"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.token = None
|
||||
self.session.headers.update(
|
||||
{
|
||||
"authority": "apic-desktop.musixmatch.com",
|
||||
"cookie": "AWSELBCORS=0; AWSELB=0",
|
||||
}
|
||||
)
|
||||
|
||||
def _get(self, action: str, query: list[tuple]):
|
||||
if action != "token.get" and self.token is None:
|
||||
self._get_token()
|
||||
|
||||
query.append(("app_id", "web-desktop-app-v1.0"))
|
||||
if self.token is not None:
|
||||
query.append(("usertoken", self.token))
|
||||
|
||||
t = str(int(time.time() * 1000))
|
||||
query.append(("t", t))
|
||||
|
||||
try:
|
||||
url = self.ROOT_URL + action
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
try:
|
||||
response = self.session.get(url, params=query, timeout=10)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if response is not None and response.ok:
|
||||
return response
|
||||
|
||||
return None
|
||||
|
||||
def _get_token(self):
|
||||
plugin_path = Paths().lyrics_plugins_path
|
||||
token_path = plugin_path / "token.json"
|
||||
|
||||
current_time = int(time.time())
|
||||
|
||||
if token_path.exists():
|
||||
with token_path.open(mode="r", encoding="utf-8") as token_file:
|
||||
cached_token_data: dict = json.load(token_file)
|
||||
|
||||
cached_token = cached_token_data.get("token")
|
||||
expiration_time = cached_token_data.get("expiration_time")
|
||||
|
||||
if cached_token and expiration_time and current_time < expiration_time:
|
||||
self.token = cached_token
|
||||
return
|
||||
|
||||
res = self._get("token.get", [("user_language", "en")])
|
||||
|
||||
if res is None:
|
||||
return
|
||||
|
||||
res = res.json()
|
||||
if res["message"]["header"]["status_code"] == 401:
|
||||
time.sleep(13)
|
||||
return self._get_token()
|
||||
|
||||
new_token = res["message"]["body"]["user_token"]
|
||||
expiration_time = current_time + 600
|
||||
|
||||
self.token = new_token
|
||||
token_data = {"token": new_token, "expiration_time": expiration_time}
|
||||
|
||||
plugin_path.mkdir(parents=True, exist_ok=True)
|
||||
with token_path.open("w", encoding="utf-8") as token_file:
|
||||
json.dump(token_data, token_file)
|
||||
|
||||
def get_lrc_by_id(self, track_id: str) -> str | None:
|
||||
res = self._get(
|
||||
"track.subtitle.get",
|
||||
[("track_id", track_id), ("subtitle_format", "lrc")],
|
||||
)
|
||||
|
||||
try:
|
||||
res = res.json()
|
||||
body = res["message"]["body"]
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
if not body:
|
||||
return None
|
||||
|
||||
return body["subtitle"]["subtitle_body"]
|
||||
|
||||
def get_lrc(self, title: str, artist: str, album: str = "") -> list[dict]:
|
||||
res = self._get(
|
||||
"track.search",
|
||||
[
|
||||
("q_track", title),
|
||||
("q_artist", artist),
|
||||
("page_size", "5"),
|
||||
("page", "1"),
|
||||
("f_has_lyrics", "1"),
|
||||
("s_track_rating", "desc"),
|
||||
("quorum_factor", "1.0"),
|
||||
],
|
||||
)
|
||||
|
||||
try:
|
||||
body = res.json()["message"]["body"]
|
||||
except AttributeError:
|
||||
return []
|
||||
|
||||
try:
|
||||
tracks = body["track_list"]
|
||||
except TypeError:
|
||||
return []
|
||||
|
||||
if not tracks:
|
||||
decoded = unidecode(artist)
|
||||
if decoded == artist:
|
||||
return []
|
||||
return self.get_lrc(title, decoded, album)
|
||||
|
||||
return [
|
||||
{
|
||||
"track_id": str(t["track"]["track_id"]),
|
||||
"title": t["track"]["track_name"],
|
||||
"artist": t["track"]["artist_name"],
|
||||
"album": t["track"]["album_name"],
|
||||
"image": t["track"]["album_coverart_100x100"],
|
||||
"provider": "musixmatch",
|
||||
}
|
||||
for t in tracks
|
||||
]
|
||||
|
||||
|
||||
class Lyrics(Plugin):
|
||||
def __init__(self) -> None:
|
||||
plugin = PluginTable.get_by_name("lyrics_finder")
|
||||
if not plugin:
|
||||
return
|
||||
|
||||
super().__init__(plugin.name, "Lyrics finder")
|
||||
|
||||
self.providers: list[LRCProvider] = [LRCLibProvider(), MusixmatchProvider()]
|
||||
self._search_cache: dict[str, str] = {}
|
||||
|
||||
self.set_active(bool(int(plugin.active)))
|
||||
|
||||
@staticmethod
|
||||
def _write_lrc(path: str, lrc: str) -> None:
|
||||
output = Path(path).with_suffix(".lrc")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(lrc, encoding="utf-8")
|
||||
|
||||
@plugin_method
|
||||
def search_lyrics_by_title_and_artist(self, title: str, artist: str):
|
||||
album = ""
|
||||
results: list[dict] = []
|
||||
seen = set()
|
||||
|
||||
for provider in self.providers:
|
||||
try:
|
||||
provider_results = provider.get_lrc(title, artist, album)
|
||||
except TypeError:
|
||||
provider_results = provider.get_lrc(title, artist) # type: ignore[misc]
|
||||
|
||||
for item in provider_results:
|
||||
if not item:
|
||||
continue
|
||||
|
||||
dedupe_key = (
|
||||
(item.get("title") or "").strip().lower(),
|
||||
(item.get("artist") or "").strip().lower(),
|
||||
(item.get("album") or "").strip().lower(),
|
||||
)
|
||||
if dedupe_key in seen:
|
||||
continue
|
||||
|
||||
seen.add(dedupe_key)
|
||||
|
||||
track_id = str(item.get("track_id", "")).strip()
|
||||
lrc = item.get("lrc")
|
||||
if track_id and lrc:
|
||||
self._search_cache[track_id] = lrc
|
||||
|
||||
results.append(item)
|
||||
|
||||
return results
|
||||
|
||||
@plugin_method
|
||||
def download_lyrics(self, trackid: str, path: str):
|
||||
lrc = self._search_cache.get(trackid)
|
||||
|
||||
if not lrc:
|
||||
for provider in self.providers:
|
||||
lrc = provider.get_lrc_by_id(trackid)
|
||||
if lrc:
|
||||
break
|
||||
|
||||
if lrc is None:
|
||||
return None
|
||||
if len(lrc.replace("\n", "").strip()) < 1:
|
||||
return None
|
||||
|
||||
self._write_lrc(path, lrc)
|
||||
return lrc
|
||||
|
||||
@plugin_method
|
||||
def download_lyrics_by_metadata(
|
||||
self,
|
||||
title: str,
|
||||
artist: str,
|
||||
path: str,
|
||||
album: str = "",
|
||||
):
|
||||
if not title or not artist:
|
||||
return None
|
||||
|
||||
for provider in self.providers:
|
||||
try:
|
||||
provider_results = provider.get_lrc(title, artist, album)
|
||||
except TypeError:
|
||||
provider_results = provider.get_lrc(title, artist) # type: ignore[misc]
|
||||
|
||||
for item in provider_results:
|
||||
lrc = item.get("lrc")
|
||||
if not lrc:
|
||||
track_id = str(item.get("track_id", ""))
|
||||
if track_id:
|
||||
lrc = provider.get_lrc_by_id(track_id)
|
||||
|
||||
if not lrc or len(lrc.replace("\n", "").strip()) < 1:
|
||||
continue
|
||||
|
||||
self._write_lrc(path, lrc)
|
||||
return lrc
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,698 @@
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
from gettext import ngettext
|
||||
from io import BytesIO
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from swingmusic.db.userdata import MixTable
|
||||
from swingmusic.models.artist import Artist
|
||||
from swingmusic.models.mix import Mix
|
||||
from swingmusic.models.track import Track
|
||||
from swingmusic.plugins import Plugin, plugin_method
|
||||
from swingmusic.settings import Paths
|
||||
from swingmusic.store.albums import AlbumStore
|
||||
from swingmusic.store.artists import ArtistStore
|
||||
from swingmusic.store.tracks import TrackStore
|
||||
from swingmusic.utils.dates import get_date_range, get_duration_ago
|
||||
from swingmusic.utils.hashing import create_hash
|
||||
from swingmusic.utils.mixes import balance_mix
|
||||
from swingmusic.utils.stats import get_artists_in_period
|
||||
|
||||
|
||||
class MixAlreadyExists(Exception):
|
||||
"""
|
||||
Raised when a mix with the same sourcehash already exists.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MixesPlugin(Plugin):
|
||||
MAX_TRACKS_TO_FETCH = 5
|
||||
MIN_TRACK_MIX_LENGTH = 15
|
||||
MIN_ARTISTS_PER_MIX = 4
|
||||
MIX_TRACKS_LENGTH = 40
|
||||
|
||||
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.server = "http://localhost:1956"
|
||||
|
||||
# server_online = self.ping_server()
|
||||
self.set_active(True)
|
||||
|
||||
def ping_server(self):
|
||||
max_retries = 3
|
||||
retry_delay = 2 # seconds
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
requests.get(self.server, timeout=10)
|
||||
return True
|
||||
except Exception:
|
||||
print(
|
||||
f"Failed to connect to the recommendation server (attempt {attempt + 1}/{max_retries})"
|
||||
)
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(retry_delay)
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
@plugin_method
|
||||
def get_track_mix_data(self, tracks: list[Track], with_help: bool = False):
|
||||
"""
|
||||
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 = [
|
||||
{
|
||||
"title": track.title,
|
||||
"artists": [a["name"] for a in track.artists],
|
||||
"album": track.og_album,
|
||||
"with_help": with_help,
|
||||
}
|
||||
for track in tracks
|
||||
]
|
||||
|
||||
try:
|
||||
response = requests.post(f"{self.server}/radio", json=queries, timeout=30)
|
||||
except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout):
|
||||
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 [], [], []
|
||||
|
||||
trackhashes: list[str] = results.get("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))
|
||||
|
||||
# if the mix is short, try to fill it up with tracks
|
||||
# from album and artist data from the cloud!
|
||||
|
||||
# Create as many filler tracks as possible
|
||||
# Then the mix length will be controlled in the Mix model
|
||||
# if len(trackmatches) < self.TRACK_MIX_LENGTH:
|
||||
if True:
|
||||
filler_tracks = self.fallback_create_artist_mix(
|
||||
similar_artists=results.get("artists", []),
|
||||
similar_albums=results.get("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, results.get("albums", []), results.get("artists", [])
|
||||
|
||||
# @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.
|
||||
|
||||
# Returns a tuple of the mix and the sourcehash.
|
||||
# """
|
||||
# artist = ArtistStore.artistmap[artisthash]
|
||||
# tracks = TrackStore.get_tracks_by_trackhashes(artist.trackhashes)
|
||||
|
||||
# tracks = sorted(tracks, key=lambda x: x.playduration, reverse=True)
|
||||
# sourcetracks = tracks[: self.MAX_TRACKS_TO_FETCH]
|
||||
# sourcehash = create_hash(*[t.trackhash for t in sourcetracks])
|
||||
|
||||
# if MixTable.get_by_sourcehash(sourcehash):
|
||||
# raise MixAlreadyExists()
|
||||
|
||||
# tracks, albums, artists = self.get_track_mix(tracks[: self.MAX_TRACKS_TO_FETCH])
|
||||
# return (tracks, albums, artists, sourcehash)
|
||||
|
||||
@plugin_method
|
||||
def create_artist_mixes(self, userid: int):
|
||||
"""
|
||||
Creates artist mixes for a given userid.
|
||||
"""
|
||||
mixes: list[Mix] = []
|
||||
indexed = set()
|
||||
|
||||
today_start, today_end = get_date_range(duration="day")
|
||||
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": {
|
||||
"max": 4,
|
||||
"artists": get_artists_in_period(today_start, today_end, userid),
|
||||
"created": 0,
|
||||
},
|
||||
"last_2_days": {
|
||||
"max": 3,
|
||||
"artists": get_artists_in_period(
|
||||
last_2_days_start, time.time(), userid
|
||||
),
|
||||
"created": 0,
|
||||
},
|
||||
"last_7_days": {
|
||||
"max": 4,
|
||||
"artists": get_artists_in_period(
|
||||
last_7_days_start, time.time(), userid
|
||||
),
|
||||
"created": 0,
|
||||
},
|
||||
"last_1_month": {
|
||||
"max": 4,
|
||||
"artists": get_artists_in_period(
|
||||
last_1_month_start, time.time(), userid
|
||||
),
|
||||
"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"]:
|
||||
if period["created"] >= limit:
|
||||
break
|
||||
|
||||
if artist["artisthash"] in indexed:
|
||||
continue
|
||||
|
||||
# INFO: track['tracks'] is a dict of trackhashes and their counts
|
||||
# get the trackhashes sorted by count
|
||||
trackhashes = sorted(
|
||||
artist["tracks"], key=lambda x: artist["tracks"][x], reverse=True
|
||||
)
|
||||
|
||||
mix = self.create_artist_mix(
|
||||
artist, trackhashes[: self.MAX_TRACKS_TO_FETCH], userid=userid
|
||||
)
|
||||
|
||||
if mix:
|
||||
mixes.append(mix)
|
||||
indexed.add(artist["artisthash"])
|
||||
period["created"] += 1
|
||||
|
||||
return mixes
|
||||
|
||||
@classmethod
|
||||
def get_mix_description(cls, 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()
|
||||
|
||||
for track in tracks:
|
||||
if len(first_4_artists) < 4 and (
|
||||
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], trackhashes: list[str], userid: int
|
||||
):
|
||||
"""
|
||||
Given an artist dict, creates an artist mix.
|
||||
"""
|
||||
_artist = ArtistStore.artistmap.get(artist["artisthash"])
|
||||
|
||||
if not _artist:
|
||||
return None
|
||||
|
||||
tracks = TrackStore.get_tracks_by_trackhashes(trackhashes)
|
||||
# tracks = sorted(tracks, key=lambda x: x.playduration, reverse=True)
|
||||
# sourcetracks = tracks[: self.MAX_TRACKS_TO_FETCH]
|
||||
|
||||
# INFO: Sort the trackhashes when creating the sourcehash
|
||||
ordered_source_trackhashes = sorted(
|
||||
trackhashes, key=lambda x: trackhashes.index(x)
|
||||
)
|
||||
legacy_sourcehash = create_hash(*ordered_source_trackhashes)
|
||||
|
||||
# Scope mix identity per user AND per artist to avoid collisions.
|
||||
# Including artisthash ensures different artists with similar top tracks
|
||||
# generate unique mixes instead of colliding.
|
||||
sourcehash = create_hash(
|
||||
f"user:{userid}",
|
||||
f"artist:{artist['artisthash']}",
|
||||
*ordered_source_trackhashes,
|
||||
)
|
||||
|
||||
db_mix = MixTable.get_by_sourcehash(sourcehash) or MixTable.get_by_sourcehash(
|
||||
legacy_sourcehash
|
||||
)
|
||||
if db_mix:
|
||||
return db_mix
|
||||
|
||||
mix_tracks, albums, artists = self.get_track_mix_data(tracks)
|
||||
|
||||
if len(mix_tracks) < self.MIN_TRACK_MIX_LENGTH:
|
||||
return None
|
||||
|
||||
# INFO: Dump mixes with no variety
|
||||
if len({t.artisthashes[0] for t in mix_tracks}) < self.MIN_ARTISTS_PER_MIX:
|
||||
return None
|
||||
|
||||
# try downloading artist image
|
||||
mix_image = {"image": _artist.artist.image, "color": _artist.artist.color}
|
||||
image = self.download_artist_image(_artist.artist)
|
||||
|
||||
if image:
|
||||
mix_image["image"] = image
|
||||
|
||||
mix = Mix(
|
||||
# the a prefix indicates that this is an artist mix
|
||||
id=f"a{userid}{artist['artisthash']}",
|
||||
title=artist["artist"] + " Radio",
|
||||
description=self.get_mix_description(mix_tracks, artist["artisthash"]),
|
||||
tracks=[t.trackhash for t in mix_tracks],
|
||||
sourcehash=sourcehash,
|
||||
userid=userid,
|
||||
extra={
|
||||
"type": "artist",
|
||||
"artisthash": artist["artisthash"],
|
||||
"sourcetracks": trackhashes,
|
||||
"legacy_sourcehash": legacy_sourcehash,
|
||||
"image": mix_image,
|
||||
# NOTE: Save the similar albums and artists
|
||||
# Related to the source tracks that were used to create the mix
|
||||
# Will be useful when generating other homepage entries
|
||||
"albums": albums,
|
||||
"artists": artists,
|
||||
},
|
||||
timestamp=int(time.time()),
|
||||
)
|
||||
|
||||
MixTable.insert_one(mix)
|
||||
return mix
|
||||
|
||||
def download_artist_image(self, artist: Artist):
|
||||
try:
|
||||
res = requests.get(
|
||||
f"{self.server}/mix/image?artist={quote(artist.name)}&type=Artist"
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
return None
|
||||
|
||||
if res.status_code == 200:
|
||||
filename = f"{artist.artisthash}_{int(time.time())}.webp"
|
||||
path = Paths().md_mixes_img_path / filename
|
||||
|
||||
image = Image.open(BytesIO(res.content))
|
||||
aspect_ratio = image.width / image.height
|
||||
|
||||
# resize to 512px
|
||||
md_width = 512
|
||||
md_height = int(md_width / aspect_ratio)
|
||||
|
||||
image = image.resize((md_width, md_height), Image.LANCZOS)
|
||||
image.save(path, "webp")
|
||||
|
||||
# resize to 256px
|
||||
sm_width = 256
|
||||
sm_height = int(sm_width / aspect_ratio)
|
||||
|
||||
image = image.resize((sm_width, sm_height), Image.LANCZOS)
|
||||
small_path = Paths().sm_mixes_img_path / filename
|
||||
image.save(small_path, "webp")
|
||||
|
||||
return filename
|
||||
|
||||
return None
|
||||
|
||||
def fallback_create_artist_mix(
|
||||
self,
|
||||
# artist: dict[str, str],
|
||||
similar_albums: list[str],
|
||||
similar_artists: list[str],
|
||||
omit_trackhashes: set[str],
|
||||
limit: int = 99,
|
||||
):
|
||||
"""
|
||||
Creates an artist mix by selecting random tracks from similar albums and artists.
|
||||
|
||||
This is used when:
|
||||
- The Swing Music recommendation server is down.
|
||||
- The artist has less than self.MIN_TRACK_MIX_LENGTH tracks from the cloud mix.
|
||||
- When we need to dilute the mix to balance the artist distribution.
|
||||
|
||||
:param similar_albums: A list of similar album weakhashes to select tracks from.
|
||||
:param similar_artists: A list of similar artist hashes to select tracks from.
|
||||
:param omit_trackhashes: A set of trackhashes to omit from the new tracklist.
|
||||
:param limit: The maximum number of tracks to select.
|
||||
"""
|
||||
|
||||
mixtracks = []
|
||||
albummatches = (
|
||||
a
|
||||
for a in AlbumStore.albummap.values()
|
||||
if a.album.weakhash in similar_albums
|
||||
)
|
||||
|
||||
for match in albummatches:
|
||||
if len(mixtracks) >= limit:
|
||||
return mixtracks
|
||||
|
||||
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)
|
||||
|
||||
artistmatches = (
|
||||
a
|
||||
for a in ArtistStore.artistmap.values()
|
||||
if a.artist.artisthash in similar_artists
|
||||
)
|
||||
|
||||
for match in artistmatches:
|
||||
if len(mixtracks) >= limit:
|
||||
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)
|
||||
|
||||
return mixtracks
|
||||
|
||||
def get_mix_from_lastfm_data(self, artisthash: str, limit: int = 40) -> list[Track]:
|
||||
"""
|
||||
Creates a mix from the locally available lastfm similar artists data.
|
||||
|
||||
The resulting mix is definitely expected to be of lower quality than
|
||||
the cloud-based recommendation, but provides a fallback when offline
|
||||
or when the recommendation server is unavailable.
|
||||
|
||||
:param artisthash: The hash of the artist to create a mix for
|
||||
:param limit: Maximum number of tracks to include in the mix
|
||||
:return: List of tracks for the mix
|
||||
"""
|
||||
from swingmusic.db.userdata import SimilarArtistTable
|
||||
|
||||
# Get the similar artists data from the database
|
||||
similar_data = SimilarArtistTable.get_by_artisthash(artisthash)
|
||||
|
||||
if not similar_data or not similar_data.get("similar_artists"):
|
||||
return []
|
||||
|
||||
# Get the source artist to include their tracks
|
||||
source_artist = ArtistStore.artistmap.get(artisthash)
|
||||
mixtracks = []
|
||||
|
||||
if source_artist:
|
||||
# Add some tracks from the source artist first
|
||||
source_tracks = TrackStore.get_tracks_by_trackhashes(
|
||||
source_artist.trackhashes[:5]
|
||||
)
|
||||
mixtracks.extend(source_tracks)
|
||||
|
||||
# Get similar artist hashes sorted by weight (similarity score)
|
||||
similar_entries = sorted(
|
||||
similar_data["similar_artists"],
|
||||
key=lambda x: x.get("weight", 0),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# Collect tracks from similar artists
|
||||
seen_trackhashes = {t.trackhash for t in mixtracks}
|
||||
omit_trackhashes = {t.weakhash for t in mixtracks}
|
||||
|
||||
for entry in similar_entries:
|
||||
if len(mixtracks) >= limit:
|
||||
break
|
||||
|
||||
similar_artisthash = entry.get("artisthash")
|
||||
if not similar_artisthash:
|
||||
continue
|
||||
|
||||
similar_artist = ArtistStore.artistmap.get(similar_artisthash)
|
||||
if not similar_artist:
|
||||
continue
|
||||
|
||||
# Get tracks from this similar artist
|
||||
artist_tracks = [
|
||||
t
|
||||
for t in TrackStore.get_tracks_by_trackhashes(
|
||||
similar_artist.trackhashes
|
||||
)
|
||||
if t.weakhash not in omit_trackhashes
|
||||
and t.trackhash not in seen_trackhashes
|
||||
]
|
||||
|
||||
if artist_tracks:
|
||||
# Add 1-3 random tracks from this artist
|
||||
num_tracks = min(len(artist_tracks), random.randint(1, 3))
|
||||
sample = random.sample(artist_tracks, k=num_tracks)
|
||||
mixtracks.extend(sample)
|
||||
seen_trackhashes.update(t.trackhash for t in sample)
|
||||
omit_trackhashes.update(t.weakhash for t in sample)
|
||||
|
||||
# Balance the mix to ensure variety
|
||||
if mixtracks:
|
||||
mixtracks = balance_mix(mixtracks)
|
||||
|
||||
return mixtracks
|
||||
|
||||
@classmethod
|
||||
def get_track_mix(cls, mix: Mix):
|
||||
"""
|
||||
Given a mix, returns the excess tracks as a custom mix.
|
||||
"""
|
||||
|
||||
# INFO: If the mix can't have more than 20 tracks, return None
|
||||
if len(mix.tracks) <= cls.MIX_TRACKS_LENGTH + 20:
|
||||
return None
|
||||
|
||||
og_track = TrackStore.trackhashmap.get(mix.tracks[0])
|
||||
|
||||
if not og_track:
|
||||
return None
|
||||
|
||||
og_track = og_track.get_best()
|
||||
tracks = [og_track] + TrackStore.get_tracks_by_trackhashes(
|
||||
mix.tracks[cls.MIX_TRACKS_LENGTH :]
|
||||
)
|
||||
|
||||
trackmix = Mix(
|
||||
id=f"t{mix.userid}{mix.extra['artisthash']}",
|
||||
title=og_track.title,
|
||||
description=cls.get_mix_description(tracks, mix.extra["artisthash"]),
|
||||
tracks=[t.trackhash for t in tracks],
|
||||
sourcehash=create_hash(*[t.trackhash for t in tracks]),
|
||||
userid=mix.userid,
|
||||
extra={
|
||||
"type": "track",
|
||||
"og_sourcehash": mix.sourcehash,
|
||||
"images": cls.get_custom_mix_images(tracks),
|
||||
"artists": None,
|
||||
"albums": None,
|
||||
},
|
||||
)
|
||||
trackmix.timestamp = mix.timestamp
|
||||
|
||||
# INFO: Write track mix save state
|
||||
if mix.extra.get("trackmix_saved"):
|
||||
trackmix.saved = True
|
||||
|
||||
return trackmix
|
||||
|
||||
@classmethod
|
||||
def get_custom_mix_images(cls, tracks: list[Track]):
|
||||
first_album = tracks[0].albumhash
|
||||
first_img = {
|
||||
"image": first_album + ".webp",
|
||||
"type": "album",
|
||||
"color": AlbumStore.albummap[first_album].album.color,
|
||||
}
|
||||
|
||||
seen = set()
|
||||
images = [first_img]
|
||||
|
||||
for track in tracks[1:]:
|
||||
artisthash = track.artists[0]["artisthash"]
|
||||
|
||||
if artisthash in seen:
|
||||
continue
|
||||
|
||||
artist = ArtistStore.artistmap.get(artisthash)
|
||||
|
||||
if not artist:
|
||||
continue
|
||||
|
||||
seen.add(artisthash)
|
||||
|
||||
image = {
|
||||
"image": artisthash + ".webp",
|
||||
"type": "artist",
|
||||
"color": artist.artist.color,
|
||||
}
|
||||
|
||||
images.append(image)
|
||||
|
||||
if len(images) == 3:
|
||||
break
|
||||
|
||||
return images
|
||||
|
||||
@staticmethod
|
||||
def get_because_items(mixes: list[Mix]):
|
||||
"""
|
||||
Given a list of mixes, returns a list of artists that are similar to the
|
||||
artists in the mixes.
|
||||
"""
|
||||
artists: dict[str, list[dict[str, str | int]]] = {}
|
||||
albums: dict[str, list[dict[str, str | int]]] = {}
|
||||
|
||||
pivot_artist = None
|
||||
pivot_artist_index = None
|
||||
|
||||
# Get pivot artist
|
||||
for index, mix in enumerate(mixes):
|
||||
artist = ArtistStore.artistmap.get(mix.extra["artisthash"])
|
||||
if not artist:
|
||||
continue
|
||||
|
||||
pivot_artist = artist.artist
|
||||
pivot_artist_index = index
|
||||
break
|
||||
|
||||
if not pivot_artist:
|
||||
return None, None
|
||||
|
||||
for mix in mixes[pivot_artist_index:]:
|
||||
mix_artisthash = mix.extra["artisthash"]
|
||||
artists.setdefault(mix_artisthash, [])
|
||||
albums.setdefault(mix_artisthash, [])
|
||||
|
||||
for artisthash in mix.extra["artists"]:
|
||||
artist = ArtistStore.artistmap.get(artisthash)
|
||||
|
||||
if not artist:
|
||||
continue
|
||||
|
||||
artists[mix_artisthash].append(
|
||||
{
|
||||
"type": "artist",
|
||||
"trackcount": artist.artist.trackcount,
|
||||
"hash": artisthash,
|
||||
"help_text": str(artist.artist.trackcount)
|
||||
+ ngettext(" track", " tracks", artist.artist.trackcount),
|
||||
}
|
||||
)
|
||||
|
||||
for albumhash in mix.extra["albums"]:
|
||||
album = AlbumStore.albummap.get(albumhash)
|
||||
|
||||
if not album:
|
||||
continue
|
||||
|
||||
albums[mix_artisthash].append(
|
||||
{
|
||||
"type": "album",
|
||||
"trackcount": album.album.trackcount,
|
||||
"hash": albumhash,
|
||||
"help_text": str(album.album.trackcount)
|
||||
+ ngettext(" track", " tracks", album.album.trackcount),
|
||||
}
|
||||
)
|
||||
|
||||
# INFO: Sort artists by trackcount
|
||||
artists[mix_artisthash] = sorted(
|
||||
artists[mix_artisthash],
|
||||
key=lambda x: x["trackcount"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# INFO: Sort albums by trackcount
|
||||
albums[mix_artisthash] = sorted(
|
||||
albums[mix_artisthash],
|
||||
key=lambda x: x["trackcount"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
because_you_listened_to_artist = {
|
||||
"title": "Because you listened to " + pivot_artist.name,
|
||||
"items": albums[pivot_artist.artisthash][:15],
|
||||
}
|
||||
|
||||
# Flatten list of artists and remove duplicates by artisthash
|
||||
|
||||
# for artist_list in artists.values():
|
||||
# for artist in artist_list:
|
||||
# if artist["hash"] not in seen:
|
||||
# all_artists.append(artist)
|
||||
# seen.add(artist["hash"])
|
||||
|
||||
artists_you_might_like = {
|
||||
"title": "Artists you might like",
|
||||
"items": artists[pivot_artist.artisthash][:15],
|
||||
}
|
||||
|
||||
return because_you_listened_to_artist, artists_you_might_like
|
||||
@@ -0,0 +1,23 @@
|
||||
import contextlib
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from swingmusic.db.userdata import PluginTable
|
||||
|
||||
|
||||
def register_plugins():
|
||||
with contextlib.suppress(IntegrityError):
|
||||
PluginTable.insert_one(
|
||||
{
|
||||
"name": "lyrics_finder",
|
||||
"active": True,
|
||||
"settings": {
|
||||
"auto_download": True,
|
||||
"overide_unsynced": True,
|
||||
"provider_order": ["lrclib", "musixmatch"],
|
||||
},
|
||||
"extra": {
|
||||
"description": "Find lyrics from the internet",
|
||||
},
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user