diff --git a/app/api/playlist.py b/app/api/playlist.py index a1e032e..165c80a 100644 --- a/app/api/playlist.py +++ b/app/api/playlist.py @@ -20,16 +20,6 @@ api = Blueprint("playlist", __name__, url_prefix="/") PL = SQLitePlaylistMethods -insert_one_playlist = PL.insert_one_playlist -get_playlist_by_name = PL.get_playlist_by_name -count_playlist_by_name = PL.count_playlist_by_name -get_all_playlists = PL.get_all_playlists -get_playlist_by_id = PL.get_playlist_by_id -tracks_to_playlist = PL.add_tracks_to_playlist -update_playlist = PL.update_playlist -delete_playlist = PL.delete_playlist -remove_image = PL.remove_banner - def duplicate_images(images: list): if len(images) == 1: @@ -80,7 +70,7 @@ def send_all_playlists(): # get the no_images query param no_images = request.args.get("no_images", False) - playlists = get_all_playlists() + playlists = PL.get_all_playlists() playlists = list(playlists) for playlist in playlists: @@ -109,7 +99,7 @@ def insert_playlist(name: str): ), } - return insert_one_playlist(playlist) + return PL.insert_one_playlist(playlist) @api.route("/playlist/new", methods=["POST"]) @@ -122,7 +112,7 @@ def create_playlist(): if data is None: return {"error": "Playlist name not provided"}, 400 - existing_playlist_count = count_playlist_by_name(data["name"]) + existing_playlist_count = PL.count_playlist_by_name(data["name"]) if existing_playlist_count > 0: return {"error": "Playlist already exists"}, 409 @@ -161,7 +151,7 @@ def get_artist_trackhashes(artisthash: str): @api.route("/playlist//add", methods=["POST"]) -def add_track_to_playlist(playlist_id: str): +def add_item_to_playlist(playlist_id: str): """ Takes a playlist ID and a track hash, and adds the track to the playlist """ @@ -191,10 +181,10 @@ def add_track_to_playlist(playlist_id: str): else: trackhashes = [] - insert_count = tracks_to_playlist(int(playlist_id), trackhashes) + insert_count = PL.add_tracks_to_playlist(int(playlist_id), trackhashes) if insert_count == 0: - return {"error": "Track already exists in playlist"}, 409 + return {"error": "Item already exists in playlist"}, 409 PL.update_last_updated(int(playlist_id)) @@ -209,7 +199,7 @@ def get_playlist(playlistid: str): no_tracks = request.args.get("no_tracks", False) no_tracks = no_tracks == "true" - playlist = get_playlist_by_id(int(playlistid)) + playlist = PL.get_playlist_by_id(int(playlistid)) if playlist is None: return {"msg": "Playlist not found"}, 404 @@ -243,7 +233,7 @@ def update_playlist_info(playlistid: str): if playlistid is None: return {"error": "Playlist ID not provided"}, 400 - db_playlist = get_playlist_by_id(int(playlistid)) + db_playlist = PL.get_playlist_by_id(int(playlistid)) if db_playlist is None: return {"error": "Playlist not found"}, 404 @@ -279,7 +269,7 @@ def update_playlist_info(playlistid: str): p_tuple = (*playlist.values(),) - update_playlist(int(playlistid), playlist) + PL.update_playlist(int(playlistid), playlist) playlist = models.Playlist(*p_tuple) playlist.last_updated = date_string_to_time_passed(playlist.last_updated) @@ -295,12 +285,12 @@ def remove_playlist_image(playlistid: str): Removes the playlist image. """ pid = int(playlistid) - playlist = get_playlist_by_id(pid) + playlist = PL.get_playlist_by_id(pid) if playlist is None: return {"error": "Playlist not found"}, 404 - remove_image(pid) + PL.remove_image(pid) playlist.image = None playlist.thumb = None @@ -330,7 +320,7 @@ def remove_playlist(): except KeyError: return message, 400 - delete_playlist(pid) + PL.delete_playlist(pid) return {"msg": "Done"}, 200 @@ -373,7 +363,7 @@ def remove_tracks_from_playlist(pid: int): def playlist_exists(name: str) -> bool: - return count_playlist_by_name(name) > 0 + return PL.count_playlist_by_name(name) > 0 @api.route("/playlist/save-item", methods=["POST"]) @@ -402,7 +392,12 @@ def save_item_as_playlist(): except KeyError: itemhash = None - if itemtype == "folder": + if itemtype is None or playlist_name is None or itemhash is None: + return msg + + if itemtype == "track": + trackhashes = [itemhash] + elif itemtype == "folder": trackhashes = get_path_trackhashes(itemhash) elif itemtype == "album": trackhashes = get_album_trackhashes(itemhash) @@ -419,7 +414,7 @@ def save_item_as_playlist(): if playlist is None: return {"error": "Playlist could not be created"}, 500 - tracks_to_playlist(playlist.id, trackhashes) + PL.add_tracks_to_playlist(playlist.id, trackhashes) PL.update_last_updated(playlist.id) - return {"playlist_id": playlist.id}, 201 + return {"playlist": playlist}, 201 diff --git a/app/api/search.py b/app/api/search.py index a98cc4d..4d2aca3 100644 --- a/app/api/search.py +++ b/app/api/search.py @@ -2,13 +2,11 @@ Contains all the search routes. """ -from unidecode import unidecode from flask import Blueprint, request +from unidecode import unidecode from app import models from app.lib import searchlib - - from app.store.tracks import TrackStore api = Blueprint("search", __name__, url_prefix="/") @@ -17,68 +15,45 @@ SEARCH_COUNT = 12 """The max amount of items to return per request""" -class SearchResults: +def query_in_quotes(query: str) -> bool: """ - Holds all the search results. + Returns True if the query is in quotes """ - - query: str = "" - tracks: list[models.Track] = [] - albums: list[models.Album] = [] - playlists: list[models.Playlist] = [] - artists: list[models.Artist] = [] + return query.startswith('"') and query.endswith('"') class Search: def __init__(self, query: str) -> None: self.tracks: list[models.Track] = [] self.query = unidecode(query) - SearchResults.query = self.query - def search_tracks(self): + def search_tracks(self, in_quotes=False): """ Calls :class:`SearchTracks` which returns the tracks that fuzzily match the search terms. Then adds them to the `SearchResults` store. """ self.tracks = TrackStore.tracks - tracks = searchlib.SearchTracks(self.query)() - - SearchResults.tracks = tracks - return tracks + return searchlib.TopResults().search( + self.query, tracks_only=True, in_quotes=in_quotes + ) def search_artists(self): """Calls :class:`SearchArtists` which returns the artists that fuzzily match the search term. Then adds them to the `SearchResults` store. """ - artists = searchlib.SearchArtists(self.query)() - SearchResults.artists = artists - return artists + return searchlib.SearchArtists(self.query)() - def search_albums(self): + def search_albums(self, in_quotes=False): """Calls :class:`SearchAlbums` which returns the albums that fuzzily match the search term. Then adds them to the `SearchResults` store. """ - albums = searchlib.SearchAlbums(self.query)() - SearchResults.albums = albums - - return albums - - # def search_playlists(self): - # """Calls :class:`SearchPlaylists` which returns the playlists that fuzzily match - # the search term. Then adds them to the `SearchResults` store. - # """ - # playlists = utils.Get.get_all_playlists() - # playlists = [serializer.Playlist(playlist) for playlist in playlists] - - # playlists = searchlib.SearchPlaylists(playlists, self.query)() - # SearchResults.playlists = playlists - - # return playlists - - def get_top_results(self): - finder = searchlib.SearchAll() - return finder.search(self.query) + return searchlib.TopResults().search( + self.query, albums_only=True, in_quotes=in_quotes + ) + def get_top_results(self, in_quotes=False): + finder = searchlib.TopResults() + return finder.search(self.query, in_quotes=in_quotes) @api.route("/search/tracks", methods=["GET"]) @@ -88,10 +63,12 @@ def search_tracks(): """ query = request.args.get("q") + in_quotes = query_in_quotes(query) + if not query: return {"error": "No query provided"}, 400 - tracks = Search(query).search_tracks() + tracks = Search(query).search_tracks(in_quotes) return { "tracks": tracks[:SEARCH_COUNT], @@ -106,14 +83,16 @@ def search_albums(): """ query = request.args.get("q") + in_quotes = query_in_quotes(query) + if not query: return {"error": "No query provided"}, 400 - tracks = Search(query).search_albums() + albums = Search(query).search_albums(in_quotes) return { - "albums": tracks[:SEARCH_COUNT], - "more": len(tracks) > SEARCH_COUNT, + "albums": albums[:SEARCH_COUNT], + "more": len(albums) > SEARCH_COUNT, } @@ -124,6 +103,7 @@ def search_artists(): """ query = request.args.get("q") + if not query: return {"error": "No query provided"}, 400 @@ -135,24 +115,6 @@ def search_artists(): } -# @searchbp.route("/search/playlists", methods=["GET"]) -# def search_playlists(): -# """ -# Searches for playlists. -# """ - -# query = request.args.get("q") -# if not query: -# return {"error": "No query provided"}, 400 - -# playlists = DoSearch(query).search_playlists() - -# return { -# "playlists": playlists[:SEARCH_COUNT], -# "more": len(playlists) > SEARCH_COUNT, -# } - - @api.route("/search/top", methods=["GET"]) def get_top_results(): """ @@ -160,21 +122,12 @@ def get_top_results(): """ query = request.args.get("q") + in_quotes = query_in_quotes(query) + if not query: return {"error": "No query provided"}, 400 - results = Search(query).get_top_results() - - # max_results = 2 - # return { - # "tracks": SearchResults.tracks[:max_results], - # "albums": SearchResults.albums[:max_results], - # "artists": SearchResults.artists[:max_results], - # "playlists": SearchResults.playlists[:max_results], - # } - return { - "results": results - } + return Search(query).get_top_results(in_quotes=in_quotes) @api.route("/search/loadmore") @@ -182,28 +135,32 @@ def search_load_more(): """ Returns more songs, albums or artists from a search query. """ + query = request.args.get("q") + in_quotes = query_in_quotes(query) + s_type = request.args.get("type") index = int(request.args.get("index") or 0) if s_type == "tracks": - t = SearchResults.tracks + t = Search(query).search_tracks(in_quotes) return { - "tracks": t[index: index + SEARCH_COUNT], + "tracks": t[index : index + SEARCH_COUNT], "more": len(t) > index + SEARCH_COUNT, } elif s_type == "albums": - a = SearchResults.albums + a = Search(query).search_albums(in_quotes) return { - "albums": a[index: index + SEARCH_COUNT], + "albums": a[index : index + SEARCH_COUNT], "more": len(a) > index + SEARCH_COUNT, } elif s_type == "artists": - a = SearchResults.artists + a = Search(query).search_artists() return { - "artists": a[index: index + SEARCH_COUNT], + "artists": a[index : index + SEARCH_COUNT], "more": len(a) > index + SEARCH_COUNT, } -# TODO: Rewrite this file using generators where possible \ No newline at end of file + +# TODO: Rewrite this file using generators where possible diff --git a/app/lib/artistlib.py b/app/lib/artistlib.py index c7c0d5e..3686e32 100644 --- a/app/lib/artistlib.py +++ b/app/lib/artistlib.py @@ -77,7 +77,9 @@ class CheckArtistImages: with Pool(cpu_count()) as pool: res = list( tqdm( - pool.imap_unordered(self.download_image, artist_store.ArtistStore.artists), + pool.imap_unordered( + self.download_image, artist_store.ArtistStore.artists + ), total=len(artist_store.ArtistStore.artists), desc="Downloading missing artist images", ) @@ -164,10 +166,17 @@ def get_all_artists(tracks: list[Track], albums: list[Album]) -> list[Artist]: artist_from_albums = get_albumartists(albums=albums) artists = list(artists_from_tracks.union(artist_from_albums)) - artists = sorted(artists) + artists.sort() - lower_artists = set(a.lower().strip() for a in artists) - indices = [[ar.lower().strip() for ar in artists].index(a) for a in lower_artists] - artists = [artists[i] for i in indices] + # Remove duplicates + artists_dup_free = set() + artist_hashes = set() - return [Artist(a) for a in artists] + for artist in artists: + artist_hash = create_hash(artist, decode=True) + + if artist_hash not in artist_hashes: + artists_dup_free.add(artist) + artist_hashes.add(artist_hash) + + return [Artist(a) for a in artists_dup_free] diff --git a/app/lib/searchlib.py b/app/lib/searchlib.py index 9fc14d2..d079239 100644 --- a/app/lib/searchlib.py +++ b/app/lib/searchlib.py @@ -1,21 +1,23 @@ """ This library contains all the functions related to the search functionality. """ -from typing import List, Generator, TypeVar, Any -import itertools +from typing import Any, Generator, List, TypeVar -from rapidfuzz import fuzz, process +from rapidfuzz import process, utils from unidecode import unidecode from app import models -from app.utils.remove_duplicates import remove_duplicates - +from app.models.enums import FavType +from app.models.track import Track from app.store.albums import AlbumStore from app.store.artists import ArtistStore from app.store.tracks import TrackStore -ratio = fuzz.ratio -wratio = fuzz.WRatio +from app.utils.remove_duplicates import remove_duplicates +from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb + +# ratio = fuzz.ratio +# wratio = fuzz.WRatio class Cutoff: @@ -56,6 +58,7 @@ class SearchTracks: track_titles, score_cutoff=Cutoff.tracks, limit=Limit.tracks, + processor=utils.default_process, ) tracks = [self.tracks[i[2]] for i in results] @@ -67,7 +70,7 @@ class SearchArtists: self.query = query self.artists = ArtistStore.artists - def __call__(self) -> list: + def __call__(self): """ Gets all artists with a given name. """ @@ -78,6 +81,7 @@ class SearchArtists: artists, score_cutoff=Cutoff.artists, limit=Limit.artists, + processor=utils.default_process, ) return [self.artists[i[2]] for i in results] @@ -100,17 +104,11 @@ class SearchAlbums: albums, score_cutoff=Cutoff.albums, limit=Limit.albums, + processor=utils.default_process, ) return [self.albums[i[2]] for i in results] - # get all artists that matched the query - # for get all albums from the artists - # get all albums that matched the query - # return [**artist_albums **albums] - - # recheck next and previous artist on play next or add to playlist - class SearchPlaylists: def __init__(self, playlists: List[models.Playlist], query: str) -> None: @@ -124,12 +122,13 @@ class SearchPlaylists: playlists, score_cutoff=Cutoff.playlists, limit=Limit.playlists, + processor=utils.default_process, ) return [self.playlists[i[2]] for i in results] -_type = List[models.Track | models.Album | models.Artist] +_type = models.Track | models.Album | models.Artist _S2 = TypeVar("_S2") _ResultType = int | float @@ -148,7 +147,7 @@ def get_titles(items: _type): yield text -class SearchAll: +class TopResults: """ Joins all tracks, albums and artists then fuzzy searches them as a single unit. @@ -156,11 +155,11 @@ class SearchAll: @staticmethod def collect_all(): - all_items: _type = [] + all_items: list[_type] = [] + all_items.extend(ArtistStore.artists) all_items.extend(TrackStore.tracks) all_items.extend(AlbumStore.albums) - all_items.extend(ArtistStore.artists) return all_items, get_titles(all_items) @@ -169,44 +168,157 @@ class SearchAll: items = list(items) results = process.extract( - query=query, - choices=items, - score_cutoff=Cutoff.tracks, - limit=20 + query=query, choices=items, score_cutoff=Cutoff.tracks, limit=1 ) return results @staticmethod - def sort_results(items: _type): + def map_with_type(item: _type): """ - Separates results into differrent lists using itertools.groupby. + Map the results to their respective types. """ - mapped_items = [ - {"type": "track", "item": item} if isinstance(item, models.Track) else - {"type": "album", "item": item} if isinstance(item, models.Album) else - {"type": "artist", "item": item} if isinstance(item, models.Artist) else - {"type": "Unknown", "item": item} for item in items - ] + if isinstance(item, models.Track): + return {"type": "track", "item": item} - mapped_items.sort(key=lambda x: x["type"]) + if isinstance(item, models.Album): + tracks = TrackStore.get_tracks_by_albumhash(item.albumhash) + tracks = remove_duplicates(tracks) - groups = [ - list(group) for key, group in - itertools.groupby(mapped_items, lambda x: x["type"]) - ] + item.get_date_from_tracks(tracks) + try: + item.duration = sum((t.duration for t in tracks)) + except AttributeError: + item.duration = 0 - # merge items of a group into a dict that looks like: {"albums": [album1, ...]} - groups = [ - {f"{group[0]['type']}s": [i['item'] for i in group]} for group in groups - ] + item.check_is_single(tracks) - return groups + if not item.is_single: + item.check_type() + + item.is_favorite = favdb.check_is_favorite( + item.albumhash, fav_type=FavType.album + ) + + return {"type": "album", "item": item} + + if isinstance(item, models.Artist): + track_count = 0 + duration = 0 + + for track in TrackStore.get_tracks_by_artisthash(item.artisthash): + track_count += 1 + duration += track.duration + + album_count = AlbumStore.count_albums_by_artisthash(item.artisthash) + + item.set_trackcount(track_count) + item.set_albumcount(album_count) + item.set_duration(duration) + + return {"type": "artist", "item": item} @staticmethod - def search(query: str): - items, titles = SearchAll.collect_all() - results = SearchAll.get_results(titles, query) - results = [items[i[2]] for i in results] + def get_track_items(item: dict[str, _type], query: str, limit=5): + tracks: list[Track] = [] - return SearchAll.sort_results(results) + if item["type"] == "track": + tracks.extend(SearchTracks(query)()) + + if item["type"] == "album": + t = TrackStore.get_tracks_by_albumhash(item["item"].albumhash) + t.sort(key=lambda x: x.last_mod) + + # if there are less than the limit, get more tracks + if len(t) < limit: + remainder = limit - len(t) + more_tracks = SearchTracks(query)() + t.extend(more_tracks[:remainder]) + + tracks.extend(t) + + if item["type"] == "artist": + t = TrackStore.get_tracks_by_artisthash(item["item"].artisthash) + t.sort(key=lambda x: x.last_mod) + + # if there are less than the limit, get more tracks + if len(t) < limit: + remainder = limit - len(t) + more_tracks = SearchTracks(query)() + t.extend(more_tracks[:remainder]) + + tracks.extend(t) + + return tracks[:limit] + + @staticmethod + def get_album_items(item: dict[str, _type], query: str, limit=6): + if item["type"] == "track": + return SearchAlbums(query)()[:limit] + + if item["type"] == "album": + return SearchAlbums(query)()[:limit] + + if item["type"] == "artist": + albums = AlbumStore.get_albums_by_artisthash(item["item"].artisthash) + + # if there are less than the limit, get more albums + if len(albums) < limit: + remainder = limit - len(albums) + more_albums = SearchAlbums(query)() + albums.extend(more_albums[:remainder]) + + return albums[:limit] + + @staticmethod + def search(query: str, albums_only=False, tracks_only=False, in_quotes=False): + items, titles = TopResults.collect_all() + results = TopResults.get_results(titles, query) + + tracks_limit = Limit.tracks if tracks_only else 5 + albums_limit = Limit.albums if albums_only else 6 + artists_limit = 3 + + # map results to their respective items + try: + result = [items[i[2]] for i in results][0] + except IndexError: + if tracks_only: + return [] + + if albums_only: + return [] + + return { + "top_result": None, + "tracks": [], + "artists": [], + "albums": [], + } + + result = TopResults.map_with_type(result) + + if in_quotes: + top_tracks = SearchTracks(query)()[:tracks_limit] + else: + top_tracks = TopResults.get_track_items(result, query, limit=tracks_limit) + + if tracks_only: + return top_tracks + + if in_quotes: + albums = SearchAlbums(query)()[:albums_limit] + else: + albums = TopResults.get_album_items(result, query, limit=albums_limit) + + if albums_only: + return albums + + artists = SearchArtists(query)()[:artists_limit] + + return { + "top_result": result, + "tracks": top_tracks, + "artists": artists, + "albums": albums, + } diff --git a/app/lib/watchdogg.py b/app/lib/watchdogg.py index a6dce62..da03cbe 100644 --- a/app/lib/watchdogg.py +++ b/app/lib/watchdogg.py @@ -10,12 +10,10 @@ from watchdog.events import PatternMatchingEventHandler from watchdog.observers import Observer from app import settings - +from app.db.sqlite.albumcolors import SQLiteAlbumMethods as aldb from app.db.sqlite.settings import SettingsSQLMethods as sdb from app.db.sqlite.tracks import SQLiteManager from app.db.sqlite.tracks import SQLiteTrackMethods as db -from app.db.sqlite.albumcolors import SQLiteAlbumMethods as aldb - from app.lib.colorlib import process_color from app.lib.taglib import extract_thumb, get_tags from app.logger import log @@ -199,7 +197,7 @@ def remove_track(filepath: str) -> None: db.remove_tracks_by_filepaths(filepath) TrackStore.remove_track_by_filepath(filepath) - empty_album = TrackStore.count_tracks_by_hash(track.albumhash) > 0 + empty_album = TrackStore.count_tracks_by_trackhash(track.albumhash) > 0 if empty_album: AlbumStore.remove_album_by_hash(track.albumhash) diff --git a/app/store/tracks.py b/app/store/tracks.py index 1b303a3..218f38d 100644 --- a/app/store/tracks.py +++ b/app/store/tracks.py @@ -84,18 +84,11 @@ class TrackStore: tdb.remove_tracks_by_folders(to_remove) @classmethod - def count_tracks_by_hash(cls, trackhash: str) -> int: + def count_tracks_by_trackhash(cls, trackhash: str) -> int: """ - Counts the number of tracks with a specific hash. + Counts the number of tracks with a specific trackhash. """ - - count = 0 - - for track in cls.tracks: - if track.trackhash == trackhash: - count += 1 - - return count + return sum(1 for track in cls.tracks if track.trackhash == trackhash) @classmethod def make_track_fav(cls, trackhash: str):