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,4 @@
|
||||
"""
|
||||
This module contains classes and methods for working with
|
||||
data loaded in memory.
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user