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:
cwilvx
2025-05-25 20:35:54 +03:00
parent 76fc97e088
commit 86fabcd5e3
171 changed files with 658 additions and 627 deletions
+4
View File
@@ -0,0 +1,4 @@
"""
This module contains classes and methods for working with
data loaded in memory.
"""
+127
View File
@@ -0,0 +1,127 @@
import random
from typing import Iterable
from swingmusic.lib.tagger import create_albums
from swingmusic.models import Album, Track
from swingmusic.store.artists import ArtistStore
from swingmusic.utils.auth import get_current_userid
from swingmusic.utils.customlist import CustomList
from ..utils.hashing import create_hash
from .tracks import TrackStore
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)
+177
View File
@@ -0,0 +1,177 @@
import json
from typing import Iterable
from swingmusic.lib.tagger import create_artists
from swingmusic.models import Artist
from swingmusic.utils.auth import get_current_userid
from swingmusic.utils.customlist import CustomList
from .tracks import TrackStore
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] = []):
"""
Loads all artists from the database into the store.
"""
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)
+116
View File
@@ -0,0 +1,116 @@
from sortedcontainers import SortedSet
from concurrent.futures import ThreadPoolExecutor
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 700ms for 10 folders. By using this store, we are able to reduce that to less than 10ms.
"""
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]):
for filepath in filepaths:
trackhash = cls.map.get(filepath)
if trackhash:
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.
"""
results: list[dict[str, int | str]] = []
with ThreadPoolExecutor() as executor:
res = executor.map(countFilepathsInDir, ((path, FolderStore.filepaths) for path in paths))
results = [
{"path": path, "trackcount": count} for path, count in zip(paths, res)
]
return results
def getIndexOfFirstMatch(strings: list[str], prefix: str):
"""
Find the index of the first path that starts with the given path.
Uses a binary search algorithm to find the index.
"""
left = 0
right = len(strings) - 1
while left <= right:
mid = (left + right) // 2
if strings[mid].startswith(prefix):
if mid == 0 or not strings[mid - 1].startswith(prefix):
return mid
right = mid - 1
elif strings[mid] < prefix:
left = mid + 1
else:
right = mid - 1
return -1
def countFilepathsInDir(_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 checks each path after that to see if it starts with the given directory path.
"""
dirpath, filepaths = _map
index = getIndexOfFirstMatch(filepaths, dirpath)
if index == -1:
return 0
paths: list[str] = []
for path in filepaths[index:]:
if path.startswith(dirpath):
paths.append(path)
else:
break
return len(paths)
+102
View File
@@ -0,0 +1,102 @@
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.
"""
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.keys()
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
+118
View File
@@ -0,0 +1,118 @@
from abc import ABC
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
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 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),
}
+333
View File
@@ -0,0 +1,333 @@
# from tqdm import tqdm
import itertools
import json
from typing import Callable, Iterable
from swingmusic.db.libdata import TrackTable
from swingmusic.models import Track
from swingmusic.utils.auth import get_current_userid
from swingmusic.utils.remove_duplicates import remove_duplicates
TRACKS_LOAD_KEY = ""
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 classproperty(property):
"""
A class property decorator.
"""
def __get__(self, owner_self, owner_cls):
if self.fget:
return self.fget(owner_cls)
class TrackStore:
# {'trackhash': Track[]}
trackhashmap: dict[str, TrackGroup] = dict()
@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 = dict()
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.
"""
hash_set = set(trackhashes)
tracks: list[Track] = []
for trackhash in hash_set:
group = cls.trackhashmap.get(trackhash, None)
if group:
track = group.get_best()
tracks.append(track)
# sort the tracks in the order of the given trackhashes
if type(trackhashes) == 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.
"""
predicate = lambda artisthashes, artisthash: 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.
"""
predicate: Callable[[str, str], bool] = (
lambda track_folder, path: 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)