first commit

This commit is contained in:
Tomas Dvorak
2026-04-13 17:46:58 +02:00
commit 6e8fedf534
234 changed files with 53808 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
"""
This module contains classes and methods for working with
data loaded in memory.
"""
+122
View File
@@ -0,0 +1,122 @@
from collections.abc import Iterable
from swingmusic.lib.tagger import create_albums
from swingmusic.models import Album, Track
from swingmusic.store.artists import ArtistStore
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.auth import get_current_userid
ALBUM_LOAD_KEY = ""
class AlbumMapEntry:
def __init__(self, album: Album, trackhashes: set[str]) -> None:
self.album = album
self.trackhashes = trackhashes
@property
def basetitle(self):
return self.album.base_title
def increment_playcount(self, duration: int, timestamp: int, playcount: int = 1):
self.album.lastplayed = timestamp
self.album.playduration += duration
self.album.playcount += playcount
def toggle_favorite_user(self, userid: int | None = None):
if userid is None:
userid = get_current_userid()
self.album.toggle_favorite_user(userid)
def set_color(self, color: str):
self.album.color = color
class AlbumStore:
albummap: dict[str, AlbumMapEntry] = {}
@classmethod
def load_albums(cls, instance_key: str):
"""
Loads all albums from the database into the store.
"""
global ALBUM_LOAD_KEY
ALBUM_LOAD_KEY = instance_key
print("Loading albums... ", end="")
cls.albummap = {
album.albumhash: AlbumMapEntry(album=album, trackhashes=trackhashes)
for album, trackhashes in create_albums()
}
print("Done!")
@classmethod
def index_new_album(cls, album: Album, trackhashes: set[str]):
cls.albummap[album.albumhash] = AlbumMapEntry(
album=album, trackhashes=trackhashes
)
@classmethod
def get_flat_list(cls):
"""
Returns a flat list of all albums.
"""
return [a.album for a in cls.albummap.values()]
@classmethod
def get_album_by_hash(cls, albumhash: str) -> Album | None:
"""
Returns an album by its hash.
"""
entry = cls.albummap.get(albumhash)
if entry is not None:
return entry.album
@classmethod
def get_albums_by_hashes(cls, albumhashes: Iterable[str]) -> list[Album]:
"""
Returns albums by their hashes.
"""
albums = []
for albumhash in albumhashes:
entry = cls.albummap.get(albumhash)
if entry is not None:
albums.append(entry.album)
return albums
@classmethod
def get_albums_by_artisthash(cls, hash: str):
"""
Returns all albums by the given artist hash.
"""
artist = ArtistStore.artistmap.get(hash)
if not artist:
return []
return [cls.albummap[albumhash].album for albumhash in artist.albumhashes]
@classmethod
def get_albums_by_artisthashes(cls, hashes: Iterable[str]):
"""
Returns all albums by the given artist hashes.
"""
albums = []
for hash in hashes:
albums.extend(cls.get_albums_by_artisthash(hash))
return albums
@classmethod
def get_album_tracks(cls, albumhash: str) -> list[Track]:
"""
Returns all tracks for the given album hash.
"""
album = cls.albummap.get(albumhash)
if not album:
return []
return TrackStore.get_tracks_by_trackhashes(album.trackhashes)
+178
View File
@@ -0,0 +1,178 @@
import json
from collections.abc import Iterable
from swingmusic.lib.tagger import create_artists
from swingmusic.models import Artist
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.auth import get_current_userid
ARTIST_LOAD_KEY = ""
class ArtistMapEntry:
def __init__(
self, artist: Artist, albumhashes: set[str], trackhashes: set[str]
) -> None:
self.artist = artist
self.albumhashes: set[str] = albumhashes
self.trackhashes: set[str] = trackhashes
def increment_playcount(self, duration: int, timestamp: int, playcount: int = 1):
self.artist.lastplayed = timestamp
self.artist.playduration += duration
self.artist.playcount += playcount
def toggle_favorite_user(self, userid: int | None = None):
if userid is None:
userid = get_current_userid()
self.artist.toggle_favorite_user(userid)
def set_color(self, color: str):
self.artist.color = color
class ArtistStore:
artistmap: dict[str, ArtistMapEntry] = {}
@classmethod
def load_artists(cls, instance_key: str, _trackhashes: list[str] = None):
"""
Loads all artists from the database into the store.
"""
if _trackhashes is None:
_trackhashes = []
global ARTIST_LOAD_KEY
ARTIST_LOAD_KEY = instance_key
print("Loading artists... ", end="")
cls.artistmap.clear()
cls.artistmap = {
artist.artisthash: ArtistMapEntry(
artist=artist, albumhashes=albumhashes, trackhashes=trackhashes
)
for artist, trackhashes, albumhashes in create_artists(_trackhashes)
}
# for track in TrackStore.get_flat_list():
# if instance_key != ARTIST_LOAD_KEY:
# return
# for hash in track.artisthashes:
# cls.artistmap[hash].trackhashes.add(track.trackhash)
# cls.artistmap[hash].albumhashes.add(track.albumhash)
print("Done!")
# for artist in ardb.get_all_artists():
# if instance_key != ARTIST_LOAD_KEY:
# return
# cls.map_artist_color(artist)
@classmethod
def get_flat_list(cls):
"""
Returns a flat list of all artists.
"""
return [a.artist for a in cls.artistmap.values()]
# @classmethod
# def map_artist_color(cls, artist_tuple: tuple):
# """
# Maps a color to the corresponding artist.
# """
# artisthash = artist_tuple[1]
# color = json.loads(artist_tuple[2])
# for artist in cls.artists:
# if artist.artisthash == artisthash:
# artist.set_colors(color)
# break
# @classmethod
# def add_artist(cls, artist: Artist):
# """
# Adds an artist to the store.
# """
# cls.artists.append(artist)
# @classmethod
# def add_artists(cls, artists: list[Artist]):
# """
# Adds multiple artists to the store.
# """
# for artist in artists:
# if artist not in cls.artists:
# cls.artists.append(artist)
@classmethod
def get_artist_by_hash(cls, artisthash: str):
"""
Returns an artist by its hash.P
"""
entry = cls.artistmap.get(artisthash, None)
if entry is not None:
return entry.artist
@classmethod
def get_artists_by_hashes(cls, artisthashes: Iterable[str]):
"""
Returns artists by their hashes.
"""
artists = [cls.get_artist_by_hash(hash) for hash in artisthashes]
return [a for a in artists if a is not None]
# @classmethod
# def artist_exists(cls, artisthash: str) -> bool:
# """
# Checks if an artist exists.
# """
# return artisthash in "-".join([a.artisthash for a in cls.artists])
# @classmethod
# def artist_has_tracks(cls, artisthash: str) -> bool:
# """
# Checks if an artist has tracks.
# """
# artists: set[str] = set()
# for track in TrackStore.tracks:
# artists.update(track.artist_hashes)
# album_artists: list[str] = [a.artisthash for a in track.albumartists]
# artists.update(album_artists)
# master_hash = "-".join(artists)
# return artisthash in master_hash
# @classmethod
# def remove_artist_by_hash(cls, artisthash: str):
# """
# Removes an artist from the store.
# """
# cls.artists = CustomList(a for a in cls.artists if a.artisthash != artisthash)
@classmethod
def get_artist_tracks(cls, artisthash: str):
"""
Returns all tracks by the given artist hash.
"""
entry = cls.artistmap.get(artisthash)
if entry is not None:
return TrackStore.get_tracks_by_trackhashes(entry.trackhashes)
return []
@classmethod
def export(cls):
path = "artists.json"
with open(path, "w") as f:
data = [
{
"name": a.name,
}
for a in cls.get_flat_list()
]
json.dump(data, f)
+129
View File
@@ -0,0 +1,129 @@
import pathlib
from concurrent.futures import ThreadPoolExecutor
from sortedcontainers import SortedSet
from swingmusic.db.libdata import TrackTable
from swingmusic.store.tracks import TrackStore
class FolderStore:
"""
The Folder store is used to hold all the indexed tracks filepaths in memory
for fast count operations when browsing the folder page.
Counting from the database is super slow,
even with a small number of folders to get the count for Up to 700 ms for 10 folders.
By using this store, we are able to reduce that to less than 10 ms.
"""
filepaths: SortedSet = SortedSet()
map: dict[str, str] = {}
"""
The map above is a dictionary that maps the folder path to the track hash, which can be used to fetch the track from the track store (a dict of track hashes to track objects).
"""
@classmethod
def load_filepaths(cls):
"""
Load all the filepaths from the database into memory.
This is needed to speed up the process of counting the number of tracks in the folder page.
"""
cls.filepaths.clear()
tracks = TrackTable.get_all()
for track in tracks:
cls.filepaths.add(track.filepath)
cls.map[track.filepath] = track.trackhash
@classmethod
def get_tracks_by_filepaths(cls, filepaths: list[str]):
"""
Generator which tries to match TrackStore with track hash
"""
for filepath in filepaths:
filepath = pathlib.Path(filepath).as_posix()
if filepath in cls.map:
trackhash = cls.map[filepath]
trackgroup = TrackStore.trackhashmap.get(trackhash)
if trackgroup is None:
continue
for track in trackgroup.tracks:
if track.filepath == filepath:
yield track
@classmethod
def count_tracks_containing_paths(cls, paths: list[str]):
"""
Count the number of tracks in each directory.
Uses a ThreadPoolExecutor to count the number of tracks
in each directory for fast execution time.
"""
with ThreadPoolExecutor() as executor:
res = executor.map(
count_filepaths_in_dir,
((path, FolderStore.filepaths) for path in paths),
)
results = [
{"path": path, "trackcount": count}
for path, count in zip(paths, res, strict=False)
]
return results
def get_index_of_first_match(paths: list[str], prefix: str) -> int:
"""
Find index of first match.
Uses binary search to speed up the search process.
:params paths: List of string to march.
:params prefix: Prefix to match against with `startswith`.
:returns: -1 if no element found, 0 if everything matches, else result > 0
"""
left = 0
right = len(paths) - 1
while left <= right:
mid = (left + right) // 2
if paths[mid].startswith(prefix):
if mid == 0 or not paths[mid - 1].startswith(prefix):
return mid
right = mid - 1
elif paths[mid] < prefix:
left = mid + 1
else:
right = mid - 1
return -1
def count_filepaths_in_dir(_map: tuple[str, SortedSet]):
"""
Counts the number of filepaths that start with the given directory path.
Gets the index of the first path that starts with the given directory path,
then check each path after that to see if it starts with the given directory path.
"""
dirpath, filepaths = _map
index = get_index_of_first_match(filepaths, dirpath)
count = 0
for path in filepaths[index:]:
if path.startswith(dirpath):
count += 1
else:
break
return count
+103
View File
@@ -0,0 +1,103 @@
from typing import Any
from swingmusic.db.userdata import CollectionTable
from swingmusic.lib.pagelib import recover_page_items
from swingmusic.store.homepageentries import (
BecauseYouListenedToArtistHomepageEntry,
GenericRecoverableEntry,
HomepageEntry,
MixHomepageEntry,
RecentlyAddedHomepageEntry,
RecentlyPlayedHomepageEntry,
)
from swingmusic.utils.auth import get_current_userid
class HomepageStore:
"""
Stores the homepage items.
"""
# INFO: map of entry names to entry objects
entries: dict[str, HomepageEntry] = {
"recently_played": RecentlyPlayedHomepageEntry(
title="Recently played",
),
"artist_mixes": MixHomepageEntry(
title="Artist mixes for you",
description="Based on artists you have been listening to",
),
"custom_mixes": MixHomepageEntry(
title="Mixes for you",
description="Because artist mixes alone aren't enough",
),
"top_streamed_weekly_artists": GenericRecoverableEntry(
title="Top artists this week",
description="Your most played artists since Monday",
),
"top_streamed_monthly_artists": GenericRecoverableEntry(
title="Top artists this month",
description="Your most played artists since the start of the month",
),
"because_you_listened_to_artist": BecauseYouListenedToArtistHomepageEntry(
title="",
description="Artists similar to the artist you listened to",
),
"artists_you_might_like": BecauseYouListenedToArtistHomepageEntry(
title="Artists you might like",
description="Artists similar to the artists you have listened to",
),
"recently_added": RecentlyAddedHomepageEntry(
title="Recently added",
description="New music added to your library",
),
}
@classmethod
def set_mixes(cls, items: list[Any], entrykey: str, userid: int | None = None):
idmap = {item.id: item for item in items}
cls.entries[entrykey].items[userid or get_current_userid()] = idmap
@classmethod
def get_mix(cls, mixkey: str, mixid: str):
mix = cls.entries[mixkey].items.get(get_current_userid(), {}).get(mixid)
return mix.to_full_dict() if mix else None
@classmethod
def get_homepage_items(cls, limit: int):
# return a dict of entry name to entry items
pages = CollectionTable.get_all()
pagedata = []
for page in pages:
pagedata.append(
{
page["id"]: {
"id": page["id"],
"title": page["name"],
"description": page["extra"]["description"],
"items": recover_page_items(page["items"], for_homepage=True),
"url": f"collections/{page['id']}",
}
}
)
homedata = [
{entry: cls.entries[entry].get_items(get_current_userid(), limit)}
for entry in cls.entries
if len(cls.entries[entry].items)
]
recently_added = homedata.pop()
return homedata + pagedata + [recently_added]
@classmethod
def find_mix(cls, mixid: str):
mixentries = ["artist_mixes", "custom_mixes"]
for entry in mixentries:
mix = cls.entries[entry].items.get(get_current_userid(), {}).get(mixid)
if mix:
return mix
return None
+125
View File
@@ -0,0 +1,125 @@
from abc import ABC, abstractmethod
from typing import Any
from swingmusic.lib.home.recover_items import recover_items
from swingmusic.models.mix import Mix
class HomepageEntry(ABC):
"""
Base class for all homepage entries.
items is a dict of userid to a dict of stuff.
"""
title: str
description: str
items: dict[int, Any]
def __init__(self, title: str, description: str):
self.title = title
self.description = description
@abstractmethod
def get_items(self, userid: int, limit: int | None = None):
"""
Return usable items for the homepage.
"""
...
class MixHomepageEntry(HomepageEntry):
"""
A homepage entry for mixes.
self.items is a dict of userid to a dict of mixid to mix.
"""
items: dict[int, dict[str, Mix]]
def __init__(self, title: str, description: str):
super().__init__(title, description)
self.items = {}
def get_items(self, userid: int, limit: int | None = None):
items = []
for mix in self.items.get(userid, {}).values():
if limit and len(items) >= limit:
break
items.append(
{
"type": "mix",
"item": mix.to_dict(),
}
)
return {
"title": self.title,
"description": self.description,
"items": items,
}
class RecentlyPlayedHomepageEntry(HomepageEntry):
"""
A homepage entry for recently played.
"""
items: dict[int, list[dict[str, Any]]]
def __init__(self, title: str, description: str = ""):
super().__init__(title, description)
self.items = {}
def add_new_user(self, userid: int):
"""
Add a new user to the homepage entry.
"""
self.items[userid] = []
def get_items(self, userid: int, limit: int | None = None):
items = self.items.get(userid, [])[:limit]
return {
"title": self.title,
"description": self.description,
"items": recover_items(items),
}
class RecentlyAddedHomepageEntry(RecentlyPlayedHomepageEntry):
"""
A homepage entry for recently added.
"""
def get_items(self, userid: int, limit: int | None = None):
return super().get_items(0, limit)
class GenericRecoverableEntry(RecentlyPlayedHomepageEntry):
"""
A homepage entry for top streamed.
"""
# NOTE: This extends RecentlyPlayedHomepageEntry because
# the shape of the data is the same.
pass
class BecauseYouListenedToArtistHomepageEntry(RecentlyPlayedHomepageEntry):
"""
A homepage entry for because you listened to artist.
"""
# SHAPE: {userid: {title: str, items: list[RecoverableItem]}}
items: dict[int, dict[str, Any]]
def get_items(self, userid: int, limit: int | None = None):
title = self.items.get(userid, {}).get("title")
items = self.items.get(userid, {}).get("items", [])[:limit]
return {
"title": title,
"items": recover_items(items),
}
+363
View File
@@ -0,0 +1,363 @@
import contextlib
import itertools
import json
import logging
from collections.abc import Callable, Iterable
from swingmusic.db.dragonfly_extended_client import get_track_cache_service
from swingmusic.db.libdata import TrackTable
from swingmusic.models import Track
from swingmusic.utils import classproperty
from swingmusic.utils.auth import get_current_userid
from swingmusic.utils.remove_duplicates import remove_duplicates
TRACKS_LOAD_KEY = ""
logger = logging.getLogger(__name__)
class TrackGroup:
"""
Tracks grouped under the same trackhash.
"""
def __init__(self, tracks: list[Track]):
self.tracks = tracks
def append(self, track: Track):
"""
Adds a track to the group.
"""
self.tracks.append(track)
def remove(self, track: Track):
"""
Removes a track from the group.
"""
self.tracks.remove(track)
def increment_playcount(self, duration: int, timestamp: int, playcount: int = 1):
"""
Increments the playcount of all tracks in the group.
"""
for track in self.tracks:
track.playcount += playcount
track.lastplayed = timestamp
track.playduration += duration
def toggle_favorite_user(self, userid: int | None = None):
"""
Adds or removes a user from the list of users who have favorited the track.
"""
if userid is None:
userid = get_current_userid()
for track in self.tracks:
track.toggle_favorite_user(userid)
def get_best(self):
"""
Returns the track with higest bitrate.
"""
return max(self.tracks, key=lambda x: x.bitrate)
def __len__(self):
return len(self.tracks)
class TrackStore:
# {'trackhash': Track[]}
trackhashmap: dict[str, TrackGroup] = {}
@classproperty
def tracks(cls) -> list[Track]:
return cls.get_flat_list()
@classmethod
def get_flat_list(cls):
"""
Returns a flat list of all tracks.
"""
return list(
itertools.chain.from_iterable(
[group.tracks for group in cls.trackhashmap.values()]
)
)
@classmethod
def load_all_tracks(cls, instance_key: str):
"""
Loads all tracks from the database into the store.
"""
print("Loading tracks... ", end="")
global TRACKS_LOAD_KEY
TRACKS_LOAD_KEY = instance_key
cls.trackhashmap = {}
tracks = TrackTable.get_all()
# INFO: Load all tracks into the dict store
for track in tracks:
if instance_key != TRACKS_LOAD_KEY:
return
exists = cls.trackhashmap.get(track.trackhash, None)
if not exists:
cls.trackhashmap[track.trackhash] = TrackGroup([track])
else:
cls.trackhashmap[track.trackhash].append(track)
print("Done!")
@classmethod
def add_track(cls, track: Track):
"""
Adds a single track to the store.
"""
group = cls.trackhashmap.get(track.trackhash, None)
if group:
return group.append(track)
cls.trackhashmap[track.trackhash] = TrackGroup([track])
@classmethod
def add_tracks(cls, tracks: list[Track]):
"""
Adds multiple tracks to the store.
"""
for track in tracks:
cls.add_track(track)
@classmethod
def remove_track(cls, track: Track):
"""
Removes a single track from the store.
"""
group = cls.trackhashmap.get(track.trackhash, None)
if group:
group.remove(track)
if len(group) == 0:
del cls.trackhashmap[track.trackhash]
@classmethod
def remove_track_by_filepath(cls, filepath: str):
"""
Removes a track from the store by its filepath.
"""
return cls.remove_tracks_by_filepaths({filepath})
@classmethod
def remove_tracks_by_filepaths(cls, filepaths: set[str]):
"""
Removes multiple tracks from the store by their filepaths.
"""
filecount = len(filepaths)
for trackhash in cls.trackhashmap:
group = cls.trackhashmap[trackhash]
for track in group.tracks:
if track.filepath in filepaths:
group.remove(track)
if len(group) == 0:
del cls.trackhashmap[trackhash]
filecount -= 1
if filecount == 0:
break
@classmethod
def count_tracks_by_trackhash(cls, trackhash: str) -> int:
"""
Counts the number of tracks with a specific trackhash.
"""
return len(cls.trackhashmap.get(trackhash, []))
# ================================================
# ================== GETTERS =====================
# ================================================
@classmethod
def get_tracks_by_trackhashes(cls, trackhashes: Iterable[str]) -> list[Track]:
"""
Returns a list of tracks by their hashes.
Uses DragonflyDB cache for faster lookups when available.
"""
hash_set = set(trackhashes)
tracks: list[Track] = []
uncached_hashes: list[str] = []
# Try DragonflyDB cache first
track_cache = get_track_cache_service()
if track_cache.cache.client.is_available():
# Try batch get from cache
for trackhash in hash_set:
cached = track_cache.get_track(trackhash)
if cached:
# Reconstruct Track from cached data
track = (
Track.from_dict(cached) if hasattr(Track, "from_dict") else None
)
if track:
tracks.append(track)
else:
uncached_hashes.append(trackhash)
else:
uncached_hashes.append(trackhash)
else:
uncached_hashes = list(hash_set)
# Fetch uncached tracks from in-memory store
for trackhash in uncached_hashes:
group = cls.trackhashmap.get(trackhash, None)
if group:
track = group.get_best()
tracks.append(track)
# Cache the track for future lookups
if track_cache.cache.client.is_available():
with contextlib.suppress(Exception):
track_cache.set_track(
trackhash,
track.to_dict()
if hasattr(track, "to_dict")
else track.__dict__,
ttl_hours=24,
)
# sort the tracks in the order of the given trackhashes
if type(trackhashes) is list:
tracks.sort(key=lambda t: trackhashes.index(t.trackhash))
return tracks
@classmethod
def get_tracks_by_filepaths(cls, paths: list[str]) -> list[Track]:
"""
Returns all tracks matching the given paths.
"""
# tracks = sorted(cls.trackhashmap, key=lambda x: x.filepath)
# tracks = use_bisection(tracks, "filepath", paths)
# return [track for track in tracks if track is not None]
# return cls.find_tracks_by(key="filepath", value=paths)
tracks: list[Track] = []
for trackhash in cls.trackhashmap:
group = cls.trackhashmap.get(trackhash)
if not group:
continue
for track in group.tracks:
if track.filepath in paths:
tracks.append(track)
return tracks
@classmethod
def find_tracks_by(
cls,
key: str,
value: str,
predicate: Callable = lambda prop_value, value: prop_value == value,
including_duplicates: bool = False,
):
"""
Find all tracks by a specific key.
"""
tracks: list[Track] = []
for trackhash in cls.trackhashmap:
group = cls.trackhashmap.get(trackhash, None)
if not group:
continue
for track in group.tracks:
prop_value = getattr(track, key)
if predicate(prop_value, value):
tracks.append(track)
if including_duplicates:
return tracks
return remove_duplicates(tracks)
@classmethod
def get_tracks_by_albumhash(cls, album_hash: str) -> list[Track]:
"""
Returns all tracks matching the given album hash.
"""
return cls.find_tracks_by(key="albumhash", value=album_hash)
@classmethod
def get_tracks_by_artisthash(cls, artisthash: str):
"""
Returns all tracks matching the given artist. Duplicate tracks are removed.
"""
def predicate(artisthashes, artisthash):
return artisthash in artisthashes
return cls.find_tracks_by(
key="artisthashes", value=artisthash, predicate=predicate
)
@classmethod
def get_tracks_in_path(cls, path: str):
"""
Returns all tracks in the given path.
"""
def predicate(track_folder: str, path: str) -> bool:
return track_folder.startswith(path)
return cls.find_tracks_by(
key="folder",
value=path,
predicate=predicate,
including_duplicates=True,
)
@classmethod
def get_recently_added(cls, start: int, limit: int | None):
"""
Returns the most recently added tracks.
"""
tracks = cls.get_flat_list()
if limit is None:
return sorted(tracks, key=lambda x: x.last_mod, reverse=True)[start:]
return sorted(tracks, key=lambda x: x.last_mod, reverse=True)[start:limit]
@classmethod
def get_recently_played(cls, limit: int):
tracks = cls.get_flat_list()
return sorted(tracks, key=lambda x: x.lastplayed, reverse=True)[:limit]
@classmethod
def export(cls):
path = "tracks.json"
with open(path, "w") as f:
data = [
{
"title": t.title,
"album": t.album,
"artists": [a["name"] for a in t.artists],
}
for t in cls.get_flat_list()
]
json.dump(data, f)