merge refactors pr #364 from @michilyy

* Save to DB only unique trackhashes

* Add check if track already exists in playlist

* replace all paths with `pathlib.Path`

* `architecture.md`:
* add config folder layout

`config.py`:
* fix bug where `pathlib.Path` cannot be serialized

`files.py`:
* remove unused imports
* update path concatenation to `pathlib.Path`
* add config-folder creation

`imgserver.py`:
* fix serialisation bug

`playlistlib.py`:
* update path concatenation to `pathlib.Path`

* update all `settings.Paths` usages to new singleton `Paths` class.

* update all usages of `settings.Paths`

* `files.py`:
* rework assets copy function.
* remove unused loop and unused `shutil.copy2` function

`settings.py`
* fix recursion exception in `Paths`

* `settings.py`:
* remove Singleton and `@property` todos from `Paths`

* `__init__.py`:
* remove now unused function `create_config_dir()`

`setup.files`:
* remove because merged into `settings.Paths()`
  for more central and clear flow how the base path gets decided

`settings.py`:
* add `copy_assets` function

`start_swingmusic.py`:
* add configurable settings.Paths class

`__main__.py`:
* update click to used correct default path

* remove wrong commited egg files

* remove change in the wrong branch

* add forgotten `property` decorator
update `get_files_and_dirs` to use pathlib where possible

`config.py`:
* update type annotation

`folders.py`:
* convert `pathlib` to posix path where needed for sub-functions

`folderlib.py`:
* rework `get_files_and_dirs` to use `pathlib` where possible

`settings.py`:
* add forgotten `@property`

`start_swingmusic.py`:
* remove second `log_startup_info()`

* `artistlib.py`:
* fix calling property
`tagger.py`:
* fix comparing elements in `pathlib.Path`

* add support for repeating lyrics.

* rework lyrics api and lib

* update most path functions.
add type-hint pathlib where needed

* for serialization paths are converted to posix path

* use `open` instead of `os.open`
update `metaclass` with constant

* fix initial config exception if empty file existed

* update `userConfig` with `InitVar` to be excluded from `asdict`

* remove `is_windows_slash()`
rework path function to use pathlib

* convert `pathlib.Path` to `str` for serialization

* fixing bug with str + pathlib

* `__main__.py`:
* update click to use package version
* remove now unused function `print_version`

`filesystem.py`:
* rework `CWD` to use importlib

`pyproject.toml`:
* disable namespace for `importlib.resources` to work correctly

* update `lyrics.py`:
* remove unused functions
* simplify functions

* fix bug where assets get created on root

* remove unused code

* update lyrics for clearer structure.

* add support for unsynced lyrics

* fix wrong return type in unsynced lyrics

* update `/check` to use `send_lyrics`

* prefer tags to duplicates

* `lyrics.py`:
* add docs to a function group

* `logger.py`:
* add logging config dict.
* combine Logging into one file
* add socket logger
* add debug mode to logger
* add JSONL formater

* `logger.py`:
* update config to directly use the formater.
resolves circular import exception

`__main__.py`:
* add logger setup to main

`start_swingmusic.py`:
* add debug option to cli

* `lyrics.py`:
* add offset support

* add `setuptools-scm` to get version from git

* add support for docker build with scm

* add support for docker build with scm
need someone who can test the changes workflow

* update all usage of `version.txt` to `metadata.version()`

* 2x update all usage of `version.txt` to `metadata.version()`

* update to no local_scheme version

* provide fix for #331.
convert `sql.Row` and `TrackTable` to dict before converting to dataclass.

* fix `__main__.py`:
* wrong import and uncommited changes
* add debug and base_path parameter

* fix logger pathlib

* add client build workflow

* set name

* split client from build

* try fixing builds

* try another fix

* try also another fix

* try again something new

* try again something new

* change runner

* fix failed run because of malformed runner

* add wheel builds

* remove systems from pure python build

* add isolated pyinstaller build

* artifacts with names

* wrong wheel path

* try fetch-depth for tag fetch

* disable fail-fast.
add wheel installation

* add install system packages

* add debug

* fix wheel install
fix pyinstaller spec file

* try fix for pyinstaller

* try another fix

* build on release

* add concrete release types

* only run on released or pre-released

* try release upload

* reformat upload

* fix needs tag

* identifiable pyinstaller builds

* compress client folder before uploading

* update to src build

* remove no more needed aarch64 build script
rename pyinstaller assets to lowercase

* remove unneeded code

* fix: save to DB only unique track hashes

* replace click with argparse

* set concrete types in argparse

* replace manuall path usages with pathlib

* remove unused `configs.py` file

* reformat `start_swingmusic.py`

* fix empty set startup exception

* optimizing static files serve function

* fixing bug in optimisation of static files serve function

* fix folder view bug

* colorlib.py:
* fix wrong type exception
* remove singe use Index_everything class
* update logging of populate.py

* cleanup files

* fix settings.py Paths copy function.
Created folder on file.

* add exist check to folder

* remove unused `INFO` class

* fix multiprocessing bug on windows

* potential icon fix for pyinstaller
fix multiple logging bug

* fix argparse config path bug
add jobs file

* cleanup code fragments
fix logging issue
add notes to function

* note that concurrent creates own sys.modules

* refactor some lyrics plugin condition
remove unused import from hashing

* refactor taglib.py

* update import statements to be static

* playlistlib.py:
* refactoring and more doc strings
populate.py:
* add poc bugfix
settings.py:
* add typehint

* possible bugfix for multitreading globals

* folder.py:
* add check if provided path is absolute
populate.py:
* add bug note
settings.py:
* add possible error from Singleton implementation
start_swingmusic.py:
* correct spelling
* pass resolved path to Paths
tagger.py:
* add logging

* trying out fixes for multithreading

* only upload results not metadata

* fix build action again

* folder.py:
* strictly use pathlib where possible

folderlib.py:
* add missing docstring to function, who really need it.

track.py:
* refactor some code

folder.py:
* refactor some more code

* Merge DBPath class and Paths class.
Update all usages of DBPath

folderslib.py:
* fix bug with logging

taglib.py:
* add missing docstring

settings.py:
* merge classes
* refactor

* network.py:
* add more docstring

config.py:
* update pathlib usage

tools.py:
* refactor
* add docstrings

* colorlib.py:
* add docstring

Refactor App builder into grouped config settings.

* update assets access for migration

* Update FUNDING.yml

* Update FUNDING.yml

* upgrade tinytag in requirements.txt

* update readme

* update license

* update readme

* Update README.md

* Update README.md

* cleanup requirements.txt
remove unused import in audio_segment.py
add entrypoint.sh for appimage support
update pyproject.toml for optional dependencies
add appimage to github workflow

* fix invalid workflow file

* AppImage build needs more research.
Commenting for now

* testing a new build workflow

* add libev installation

* update workflow to new optional dependencies

* trying again another fix

* finally fix all optional deps installation correctly

* remove AppImage poc

* albumslib.py:
* add docstring

folder.py:
* add unix path fix

update logger name to `__name__`

* update build with docker
update Dockerfile with git
fix typo in lyrics.py
add dynamic deps back

* add log for static folder

* add missing import

* add some more todos

* add support for AppImages even when it's not perfect.

* quick bugfix for wrong appimage config path

* fix uploading not finding AppImages builds aka wrong pattern

* optimise docker build by using artifacts.
Add client path option.
change docstring to sphinx format

* add todos

* Now support AppImages for real:
manually build AppImage as we are building a complex project.

* fix missing dep in AppImage build

* add full AppImage metadata

* add missing image file.

* only update swingmusic appimage not tool

* add todo and fix AppImage build again.

* Try fixing some path mixup in AppImage build

* add debug tag to action

* correct path to appimage folder

* do not download tool before checkout

* Another fix for path in appimage build

* extend config files with more information

* default client dir is now inside the config dir.
TODOs updated.

* default client dir is now inside the config dir.
TODOs updated.
Add priority todos.

* Auto download client when client not found.
Respects user provided dir.

* rename `requests` submodule to `request`

* poc for arm AppImage builds

* try out another fix

* fix typo in build.yml

* add missing arch tag

* fix uploading double names

* unique naming

* enable fallback version for project.

* do not download client into readonly dir.

* fix relative client download path. Client was resolved into parent of config.

* remove client backup path as client is now downloadable

* `Paths` checks if config folder exists and creates it if necessary.
logger no more creates the config folder.
`app_builder.py`: static route no more with '/client'

* path are only created in MainProcess.
fix gz file not found.

* move assets into src and update usages accordingly

* remove solved todos

* Only upload artefacts if not draft/master aka only on tag

* wrong type in assets copy

* update log with correct priority

* add debug statements and logging to Paths

* remove debugging statement

* remove double version tag from docker build

* fork save release protection

* fix typo

* add fallback client dir for static builds.

* update argparse to new param

* add missing import pathlib

* add sparse checkout as we do not need everything downloaded

* add assets copy check

* init logger bevor Paths

* remove unused import

* check if logdir exists and create if not

* only add exec info to file

* remove exception log from cli

* move logging into main.
Allows tools support again.

* UserConfig now correctly uses _finished key.
Bug where _finished was never written

* double save serverId.
update root_dir to trow no exception on init.
remove debug param

* clean up TODOs

---------

Co-authored-by: skilletfun <skilletfun.laptew.sergey@yandex.ru>
Co-authored-by: Mungai Njoroge <geoffreymungai45@gmail.com>
This commit is contained in:
michily
2025-08-28 09:28:11 +00:00
committed by GitHub
parent b4b0a6e11f
commit e770606567
197 changed files with 2961 additions and 2150 deletions
+30
View File
@@ -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
+162
View File
@@ -0,0 +1,162 @@
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
try:
self.config.lastfmSessionKeys.pop(str(get_current_userid()))
except KeyError:
pass
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 = 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.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
+224
View File
@@ -0,0 +1,224 @@
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().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
# 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}
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) -> 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)
if lrc is None:
return None
elif len(lrc.replace("\n", "").strip()) < 1: #check if empty
return None
path = Path(path).with_suffix(".lrc")
if not path.exists():
path.touch()
path.write_text(lrc)
return lrc
+622
View File
@@ -0,0 +1,622 @@
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().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):
"""
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]]] = {}
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
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[pivot_artist.artisthash][:15],
}
return because_you_listened_to_artist, artists_you_might_like
+18
View File
@@ -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