mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-04 12:33:03 +00:00
modularize src
+ merge main.py and manage.py + move start logic to swingmusic/__main__.py + add a run.py on the project root
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
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,160 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
import requests
|
||||
from typing import Any
|
||||
from hashlib import md5
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from swingmusic.config import UserConfig
|
||||
from swingmusic.models.track import Track
|
||||
from swingmusic.settings import Paths
|
||||
from swingmusic.utils.auth import get_current_userid
|
||||
from swingmusic.utils.threading import background
|
||||
from swingmusic.plugins import Plugin, plugin_method
|
||||
|
||||
from swingmusic.logger import log
|
||||
|
||||
|
||||
class LastFmPlugin(Plugin):
|
||||
"""
|
||||
Last.fm scrobbler plugin.
|
||||
"""
|
||||
|
||||
UPLOADING_DUMPS = False
|
||||
|
||||
def __init__(self):
|
||||
self.config = UserConfig()
|
||||
super().__init__("lastfm", "Last.fm scrobbler")
|
||||
self.set_active(
|
||||
bool(
|
||||
self.config.lastfmApiKey
|
||||
and self.config.lastfmApiSecret
|
||||
and self.config.lastfmSessionKeys.get(str(get_current_userid()))
|
||||
)
|
||||
)
|
||||
|
||||
def get_api_signature(self, data: dict[str, Any]) -> str:
|
||||
params = {k: v for k, v in 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(get_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
|
||||
self.config.lastfmSessionKeys.pop(str(get_current_userid()))
|
||||
self.config.lastfmSessionKeys = self.config.lastfmSessionKeys
|
||||
return False
|
||||
|
||||
if res_json.get("scrobbles", {}).get("@attr", {}).get("accepted") == 1:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# SECTION: Persistence
|
||||
def dump_scrobble(self, data: dict[str, Any]):
|
||||
"""
|
||||
Dumps the scrobble data to a file in the lastfm plugin directory.
|
||||
"""
|
||||
dump_dir = Path(Paths.get_plugins_path(), "lastfm")
|
||||
if not dump_dir.exists():
|
||||
dump_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
path = dump_dir / f"{int(time.time())}.json"
|
||||
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
def upload_dumps(self):
|
||||
"""
|
||||
Uploads the scrobble dumps to the lastfm api.
|
||||
"""
|
||||
if self.UPLOADING_DUMPS:
|
||||
return
|
||||
|
||||
self.UPLOADING_DUMPS = True
|
||||
dump_dir = Path(Paths.get_plugins_path(), "lastfm")
|
||||
|
||||
if not dump_dir.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
for file in dump_dir.iterdir():
|
||||
with open(file, "r") as f:
|
||||
data = json.load(f)
|
||||
success = self.post_scrobble_data(data)
|
||||
|
||||
if success:
|
||||
file.unlink()
|
||||
finally:
|
||||
self.UPLOADING_DUMPS = False
|
||||
@@ -0,0 +1,225 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
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 all of the 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/109.0.0.0 Safari/537.36"
|
||||
}
|
||||
)
|
||||
|
||||
def get_lrc_by_id(self, track_id: str) -> Optional[str]:
|
||||
"""
|
||||
Returns the synced lyrics of the song in lrc.
|
||||
|
||||
### Arguments
|
||||
- track_id: The ID of the track defined in the provider database. e.g. Spotify/Deezer track ID
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_lrc(self, search_term: str) -> Optional[str]:
|
||||
"""
|
||||
Returns the synced lyrics of the song in lrc.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class LyricsProvider(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:
|
||||
return None
|
||||
|
||||
if response is not None and response.ok:
|
||||
return response
|
||||
|
||||
return None
|
||||
|
||||
def _get_token(self):
|
||||
# Check if token is cached and not expired
|
||||
plugin_path = Paths.get_lyrics_plugins_path()
|
||||
token_path = os.path.join(plugin_path, "token.json")
|
||||
|
||||
current_time = int(time.time())
|
||||
|
||||
if os.path.exists(token_path):
|
||||
with open(token_path, "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
|
||||
|
||||
# Token not cached or expired, fetch a new token
|
||||
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 # 10 minutes expiration
|
||||
|
||||
# Cache the new token
|
||||
self.token = new_token
|
||||
token_data = {"token": new_token, "expiration_time": expiration_time}
|
||||
|
||||
os.makedirs(plugin_path, exist_ok=True)
|
||||
with open(token_path, "w", encoding="utf-8") as token_file:
|
||||
json.dump(token_data, token_file)
|
||||
|
||||
def get_lrc_by_id(self, track_id: str) -> Optional[str]:
|
||||
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):
|
||||
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:
|
||||
# if the artist name contains non-ascii characters, try to decode it
|
||||
decoded = unidecode(artist)
|
||||
|
||||
# if the decoded artist name is the same as the original, return an empty list
|
||||
if decoded == artist:
|
||||
return []
|
||||
|
||||
# if the decoded artist name is different, retry!
|
||||
return self.get_lrc(title, decoded)
|
||||
|
||||
return [
|
||||
{
|
||||
"track_id": 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"],
|
||||
}
|
||||
for t in tracks
|
||||
]
|
||||
|
||||
|
||||
class Lyrics(Plugin):
|
||||
def __init__(self) -> None:
|
||||
plugin = PluginTable.get_by_name("lyrics_finder")
|
||||
|
||||
if not plugin:
|
||||
return
|
||||
|
||||
name = plugin.name
|
||||
super().__init__(name, "Musixmatch lyrics finder")
|
||||
|
||||
self.provider = LyricsProvider()
|
||||
|
||||
if plugin:
|
||||
self.set_active(bool(int(plugin.active)))
|
||||
|
||||
@plugin_method
|
||||
def search_lyrics_by_title_and_artist(self, title: str, artist: str):
|
||||
return self.provider.get_lrc(title, artist)
|
||||
|
||||
@plugin_method
|
||||
def download_lyrics(self, trackid: str, path: str):
|
||||
lrc = self.provider.get_lrc_by_id(trackid)
|
||||
is_valid = lrc is not None and lrc.replace("\n", "").strip() != ""
|
||||
|
||||
if not is_valid:
|
||||
return None
|
||||
|
||||
path = Path(path).with_suffix(".lrc")
|
||||
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(lrc)
|
||||
return lrc
|
||||
except:
|
||||
return lrc
|
||||
@@ -0,0 +1,607 @@
|
||||
from gettext import ngettext
|
||||
from io import BytesIO
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
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 as e:
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
# FIXME: Make sure that different artists don't generate the same mix
|
||||
|
||||
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:
|
||||
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], 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
|
||||
sourcehash = create_hash(
|
||||
*sorted(trackhashes, key=lambda x: trackhashes.index(x))
|
||||
)
|
||||
|
||||
db_mix = MixTable.get_by_sourcehash(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(set(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,
|
||||
"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.get_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.get_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):
|
||||
"""
|
||||
Creates a mix from the locally available lastfm similar artists data.
|
||||
|
||||
The resulting mix is definitely expected to be of low quality.
|
||||
|
||||
TODO: Maybe implement this!
|
||||
"""
|
||||
pass
|
||||
|
||||
@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]]] = {}
|
||||
|
||||
for mix in mixes:
|
||||
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,
|
||||
)
|
||||
|
||||
artisthash = mixes[0].extra["artisthash"]
|
||||
because_you_listened_to_artist = {
|
||||
"title": "Because you listened to "
|
||||
+ ArtistStore.artistmap[artisthash].artist.name,
|
||||
"items": albums[artisthash][:15],
|
||||
}
|
||||
|
||||
# Flatten list of artists and remove duplicates by artisthash
|
||||
all_artists = []
|
||||
seen = set()
|
||||
|
||||
# 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[artisthash][:15],
|
||||
}
|
||||
|
||||
return because_you_listened_to_artist, artists_you_might_like
|
||||
@@ -0,0 +1,18 @@
|
||||
from swingmusic.db.userdata import PluginTable
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
|
||||
def register_plugins():
|
||||
try:
|
||||
PluginTable.insert_one(
|
||||
{
|
||||
"name": "lyrics_finder",
|
||||
"active": False,
|
||||
"settings": {"auto_download": False},
|
||||
"extra": {
|
||||
"description": "Find lyrics from the internet",
|
||||
},
|
||||
}
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
Reference in New Issue
Block a user