diff --git a/app/db/libdata.py b/app/db/libdata.py index caa0b3ab..ac89ebab 100644 --- a/app/db/libdata.py +++ b/app/db/libdata.py @@ -129,7 +129,7 @@ class TrackTable(Base): title: Mapped[str] = mapped_column(String()) track: Mapped[int] = mapped_column(Integer()) trackhash: Mapped[str] = mapped_column(String(), index=True) - is_favorite: Mapped[Optional[bool]] = mapped_column(Boolean()) + # is_favorite: Mapped[Optional[bool]] = mapped_column(Boolean()) lastplayed: Mapped[int] = mapped_column(Integer(), default=0) playcount: Mapped[int] = mapped_column(Integer(), default=0) playduration: Mapped[int] = mapped_column(Integer(), default=0) @@ -270,7 +270,7 @@ class AlbumTable(Base): og_title: Mapped[str] = mapped_column(String()) title: Mapped[str] = mapped_column(String()) trackcount: Mapped[int] = mapped_column(Integer()) - is_favorite: Mapped[Optional[bool]] = mapped_column(Boolean()) + # is_favorite: Mapped[Optional[bool]] = mapped_column(Boolean()) lastplayed: Mapped[int] = mapped_column(Integer(), default=0) playcount: Mapped[int] = mapped_column(Integer(), default=0) playduration: Mapped[int] = mapped_column(Integer(), default=0) @@ -360,7 +360,7 @@ class ArtistTable(Base): genres: Mapped[str] = mapped_column(JSON()) name: Mapped[str] = mapped_column(String(), index=True) trackcount: Mapped[int] = mapped_column(Integer()) - is_favorite: Mapped[Optional[bool]] = mapped_column(Boolean()) + # is_favorite: Mapped[Optional[bool]] = mapped_column(Boolean()) lastplayed: Mapped[int] = mapped_column(Integer(), default=0) playcount: Mapped[int] = mapped_column(Integer(), default=0) playduration: Mapped[int] = mapped_column(Integer(), default=0) diff --git a/app/lib/folderslib.py b/app/lib/folderslib.py index 85542e11..7ff5205a 100644 --- a/app/lib/folderslib.py +++ b/app/lib/folderslib.py @@ -108,7 +108,7 @@ class GetFilesAndDirs: tracks = [] if files: - tracks = TrackDB.get_tracks_by_filepaths(files) + tracks = list(FolderStore.get_tracks_by_filepaths(files)) folders = [] if not self.tracks_only: diff --git a/app/models/album.py b/app/models/album.py index fb3cda97..425c91e6 100644 --- a/app/models/album.py +++ b/app/models/album.py @@ -28,7 +28,7 @@ class Album: og_title: str title: str trackcount: int - is_favorite: bool + # is_favorite: bool lastplayed: int playcount: int playduration: int diff --git a/app/models/artist.py b/app/models/artist.py index 6f8d8002..ee706866 100644 --- a/app/models/artist.py +++ b/app/models/artist.py @@ -47,7 +47,7 @@ class Artist: genrehashes: list[str] name: str trackcount: int - is_favorite: bool + # is_favorite: bool lastplayed: int playcount: int playduration: int diff --git a/app/models/track.py b/app/models/track.py index 86dc2da1..fa6984e5 100644 --- a/app/models/track.py +++ b/app/models/track.py @@ -1,4 +1,6 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field + +from app.utils.auth import get_current_userid @dataclass(slots=True) @@ -33,10 +35,14 @@ class Track: playcount: int playduration: int - is_favorite: bool = False _pos: int = 0 _ati: str = "" image: str = "" + fav_userids: set = field(default_factory=set) + + @property + def is_favorite(self): + return get_current_userid() in self.fav_userids def __post_init__(self): self.image = self.albumhash + ".webp" @@ -66,16 +72,11 @@ class Track: # image: str = "" # artist_hashes: str = "" - # fav_userids: list = field(default_factory=list) # """ # A string of user ids separated by commas. # """ # # is_favorite: bool = False - # @property - # def is_favorite(self): - # return current_user["id"] in self.fav_userids - # # temporary attributes # _pos: int = 0 # for sorting tracks by disc and track number # _ati: str = ( diff --git a/app/setup/__init__.py b/app/setup/__init__.py index 60738d68..098afe02 100644 --- a/app/setup/__init__.py +++ b/app/setup/__init__.py @@ -46,4 +46,5 @@ def load_into_mem(): # TrackStore.load_all_tracks(instance_key) # AlbumStore.load_albums(instance_key) # ArtistStore.load_artists(instance_key) + TrackStore.load_all_tracks(get_random_str()) FolderStore.load_filepaths() \ No newline at end of file diff --git a/app/store/folder.py b/app/store/folder.py index 0bfe3ae3..10fd60fe 100644 --- a/app/store/folder.py +++ b/app/store/folder.py @@ -2,6 +2,7 @@ from sortedcontainers import SortedSet from concurrent.futures import ThreadPoolExecutor from app.db.libdata import TrackTable +from app.store.tracks import TrackStore class FolderStore: @@ -13,6 +14,10 @@ class FolderStore: """ 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): @@ -26,7 +31,18 @@ class FolderStore: 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: + track = TrackStore.trackhashmap.get(trackhash) + + if track: + yield [t for t in track.tracks if t.filepath == filepath][0] @classmethod def count_tracks_containing_paths(cls, paths: list[str]): diff --git a/app/store/tracks.py b/app/store/tracks.py index 21a65d66..a3b158aa 100644 --- a/app/store/tracks.py +++ b/app/store/tracks.py @@ -1,18 +1,99 @@ # from tqdm import tqdm +import itertools +import sys +from typing import Callable from flask_jwt_extended import current_user +from app.db.libdata import TrackTable from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb -from app.db.sqlite.tracks import SQLiteTrackMethods as trackdb + +# from app.db.sqlite.tracks import SQLiteTrackMethods as trackdb +from app.db.userdata import FavoritesTable from app.models import Track -from app.utils.bisection import use_bisection -from app.utils.customlist import CustomList from app.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 set_fav_userids(self, userids: set[int]): + """ + Sets the favorite userids. + """ + for track in self.tracks: + track.fav_userids = userids + + def get_best(self): + """ + Returns the track with higest bitrate. + """ + return max(self.tracks, key=lambda x: x.bitrate) + + def toggle_favorite(self, remove: bool = False): + """ + Adds a track to the favorites. + """ + + userids = set(self.tracks[0].fav_userids) + + if remove: + userids.remove(current_user["id"]) + else: + userids.add(current_user["id"]) + + for track in self.tracks: + track.fav_userids = userids + + def __len__(self): + return len(self.tracks) + + +class classproperty(property): + """ + A class property decorator. + """ + + def __get__(self, owner_self, owner_cls): + return self.fget(owner_cls) + + class TrackStore: - tracks: list[Track] = CustomList() + # {'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): @@ -24,32 +105,60 @@ class TrackStore: global TRACKS_LOAD_KEY TRACKS_LOAD_KEY = instance_key - cls.tracks = CustomList(trackdb.get_all_tracks()) + cls.trackhashmap = dict() + tracks = TrackTable.get_all() - favs = favdb.get_fav_tracks() - - records = dict() - - for fav in favs: - r = records.setdefault(fav[1], set()) - r.add(fav[4]) - - for track in cls.tracks: + # INFO: Load all tracks into the dict store + for track in tracks: if instance_key != TRACKS_LOAD_KEY: return - userids = records.get(track.trackhash, set()) - track.fav_userids = list(userids) + 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!") + # favs = favdb.get_fav_tracks() + favs = FavoritesTable.get_all() + records: dict[str, set[int]] = dict() + + # convert records: {trackhash: {userid, userid, ...}} + for fav in favs: + if fav.hash not in records: + # if trackhash not in dict, add it + # and set the value to a set containing the userid + records[fav.hash] = {fav.userid} + + # if trackhash is in dict, add the userid to the set + records[fav.hash].add(fav.userid) + + for record in records: + if instance_key != TRACKS_LOAD_KEY: + return + + group = cls.trackhashmap.get(record, None) + + if not group: + continue + + group.set_fav_userids(records.get(record, set())) + + # print("Done!") + # print(cls.trackhashmap.get("0d6b22c19c").tracks[0].fav_userids) + # sys.exit(0) @classmethod def add_track(cls, track: Track): """ Adds a single track to the store. """ + group = cls.trackhashmap.get(track.trackhash, None) - cls.tracks.append(track) + if group: + return group.append(track) + + cls.trackhashmap[track.trackhash] = TrackGroup([track]) @classmethod def add_tracks(cls, tracks: list[Track]): @@ -57,17 +166,21 @@ class TrackStore: Adds multiple tracks to the store. """ - cls.tracks.extend(tracks) + for track in tracks: + cls.add_track(track) @classmethod - def remove_track_obj(cls, track: Track): + def remove_track(cls, track: Track): """ Removes a single track from the store. """ - try: - cls.tracks.remove(track) - except ValueError: - pass + 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): @@ -75,10 +188,7 @@ class TrackStore: Removes a track from the store by its filepath. """ - for track in cls.tracks: - if track.filepath == filepath: - cls.remove_track_obj(track) - break + return cls.remove_tracks_by_filepaths({filepath}) @classmethod def remove_tracks_by_filepaths(cls, filepaths: set[str]): @@ -86,47 +196,47 @@ class TrackStore: Removes multiple tracks from the store by their filepaths. """ - for track in cls.tracks: - if track.filepath in filepaths: - cls.remove_track_obj(track) + 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 sum(1 for track in cls.tracks if track.trackhash == trackhash) + return len(cls.trackhashmap.get(trackhash, [])) @classmethod - def make_track_fav(cls, trackhash: str): + def toggle_favorite(cls, trackhash: str, remove: bool = False): """ Adds a track to the favorites. """ - for track in cls.tracks: - if track.trackhash == trackhash: - if current_user["id"] not in track.fav_userids: - track.fav_userids.append(current_user["id"]) + group = cls.trackhashmap.get(trackhash) + + if group: + group.toggle_favorite(remove=remove) @classmethod def remove_track_from_fav(cls, trackhash: str): """ Removes a track from the favorites. """ - - for track in cls.tracks: - if track.trackhash == trackhash: - if current_user["id"] in track.fav_userids: - track.fav_userids.remove(current_user["id"]) - - @classmethod - def append_track_artists( - cls, albumhash: str, artists: list[str], new_album_title: str - ): - tracks = cls.get_tracks_by_albumhash(albumhash) - - for track in tracks: - track.add_artists(artists, new_album_title) + return cls.toggle_favorite(trackhash, remove=True) # ================================================ # ================== GETTERS ===================== @@ -138,16 +248,16 @@ class TrackStore: Returns a list of tracks by their hashes. """ hash_set = set(trackhashes) - set_len = len(hash_set) - tracks = [] - for track in cls.tracks: - if track.trackhash in hash_set: + tracks: list[Track] = [] + + for trackhash in hash_set: + group = cls.trackhashmap.get(trackhash, None) + + if group: + track = group.get_best() tracks.append(track) - if len(tracks) == set_len: - break - # sort the tracks in the order of the given trackhashes tracks.sort(key=lambda t: trackhashes.index(t.trackhash)) return tracks @@ -156,32 +266,86 @@ class TrackStore: def get_tracks_by_filepaths(cls, paths: list[str]) -> list[Track]: """ Returns all tracks matching the given paths. + + ⛔⛔⛔⛔⛔⛔⛔⛔⛔⛔⛔⛔⛔⛔⛔⛔⛔⛔⛔⛔⛔⛔ """ - tracks = sorted(cls.tracks, key=lambda x: x.filepath) - tracks = use_bisection(tracks, "filepath", paths) - return [track for track in tracks if track is not None] + # 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. """ - tracks = [t for t in cls.tracks if t.albumhash == album_hash] - return remove_duplicates(tracks, is_album_tracks=True) + 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. """ - tracks = [t for t in cls.tracks if artisthash in t.artist_hashes] - tracks = remove_duplicates(tracks) - tracks.sort(key=lambda x: x.last_mod) - return tracks + predicate = lambda artisthashes, artisthash: artisthash in artisthashes + return cls.find_tracks_by( + key="artist_hashes", value=artisthash, predicate=predicate + ) @classmethod def get_tracks_in_path(cls, path: str): """ Returns all tracks in the given path. """ - return (t for t in cls.tracks if t.folder.startswith(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, + )