diff --git a/package.json b/package.json index abe0176e..1397fad3 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "register-service-worker": "^1.7.1", "sass": "^1.49.0", "sass-loader": "^10", + "vite-svg-loader": "^3.4.0", "vue": "^3.0.0", "vue-debounce": "^3.0.2", "vue-router": "^4.0.0-0", @@ -28,7 +29,8 @@ "@vue/compiler-sfc": "^3.0.0", "eslint": "^8.7.0", "eslint-plugin-vue": "^8.3.0", - "vite": "^2.5.4" + "vite": "^2.5.4", + "vue-svg-loader": "^0.16.0" }, "packageManager": "yarn@3.1.1" } diff --git a/server/app/api/__init__.py b/server/app/api/__init__.py index b9b916d2..5a128083 100644 --- a/server/app/api/__init__.py +++ b/server/app/api/__init__.py @@ -3,25 +3,9 @@ This module contains all the Flask Blueprints and API routes. It also contains a that are used through-out the app. It handles the initialization of the watchdog, checking and creating config dirs and starting the re-indexing process using a background thread. """ -from typing import List -from typing import Set - from app import functions from app import helpers -from app import instances -from app import models from app import prep -from app.lib import albumslib -from app.lib import folderslib -from app.lib import playlistlib - -DB_TRACKS = instances.tracks_instance.get_all_tracks() -VALID_FOLDERS: Set[str] = set() - -ALBUMS: List[models.Album] = [] -TRACKS: List[models.Track] = [] -PLAYLISTS: List[models.Playlist] = [] -FOLDERS: List[models.Folder] = List @helpers.background @@ -31,10 +15,7 @@ def initialize() -> None: """ functions.start_watchdog() prep.create_config_dir() - albumslib.create_everything() - folderslib.run_scandir() - playlistlib.create_all_playlists() - functions.reindex_tracks() + functions.run_checks() initialize() diff --git a/server/app/api/album.py b/server/app/api/album.py index fbdf73d4..2f22f702 100644 --- a/server/app/api/album.py +++ b/server/app/api/album.py @@ -1,16 +1,17 @@ """ Contains all the album routes. """ +from pprint import pprint from typing import List from app import api from app import helpers +from app import instances from app import models +from app.functions import FetchAlbumBio from app.lib import albumslib -from app.lib import trackslib from flask import Blueprint from flask import request -from app.functions import FetchAlbumBio album_bp = Blueprint("album", __name__, url_prefix="") @@ -35,42 +36,57 @@ def get_albums(): return {"albums": albums} -@album_bp.route("/album/tracks", methods=["POST"]) +@album_bp.route("/album", methods=["POST"]) def get_album(): """Returns all the tracks in the given album.""" data = request.get_json() + albumhash = data["hash"] + error_msg = {"error": "Album not created yet."} - album = data["album"] - artist = data["artist"] + tracks = instances.tracks_instance.find_tracks_by_hash(albumhash) - songs = trackslib.get_album_tracks(album, artist) - albumhash = helpers.create_album_hash(album, artist) - index = albumslib.find_album(api.ALBUMS, albumhash) - album: models.Album = api.ALBUMS[index] + if len(tracks) == 0: + return error_msg, 204 - album.count = len(songs) - album.duration = albumslib.get_album_duration(songs) + tracks = [models.Track(t) for t in tracks] + tracks = helpers.RemoveDuplicates(tracks)() - if ( - album.count == 1 - and songs[0].title == album.title - and songs[0].tracknumber == 1 - and songs[0].disknumber == 1 - ): + album = instances.album_instance.find_album_by_hash(albumhash) + + if not album: + return error_msg, 204 + + album = models.Album(album) + + album.count = len(tracks) + try: + album.duration = sum([t.length for t in tracks]) + except AttributeError: + album.duration = 0 + + if (album.count == 1 and tracks[0].title == album.title + and tracks[0].tracknumber == 1 and tracks[0].disknumber == 1): album.is_single = True - return {"songs": songs, "info": album} + return {"tracks": tracks, "info": album} @album_bp.route("/album/bio", methods=["POST"]) def get_album_bio(): """Returns the album bio for the given album.""" data = request.get_json() - fetch_bio = FetchAlbumBio(data["album"], data["albumartist"]) - bio = fetch_bio() + album_hash = data["hash"] + err_msg = {"bio": "No bio found"} + + album = instances.album_instance.find_album_by_hash(album_hash) + + if album is None: + return err_msg, 404 + + bio = FetchAlbumBio(album["title"], album["artist"])() if bio is None: - return {"bio": "No bio found."}, 404 + return err_msg, 404 return {"bio": bio} @@ -80,19 +96,16 @@ def get_albumartists(): """Returns a list of artists featured in a given album.""" data = request.get_json() - album = data["album"] - artist = data["artist"] + albumhash = data["hash"] - tracks: List[models.Track] = [] - - for track in api.TRACKS: - if track.album == album and track.albumartist == artist: - tracks.append(track) + tracks = instances.tracks_instance.find_tracks_by_hash(albumhash) + tracks = [models.Track(t) for t in tracks] artists = [] for track in tracks: for artist in track.artists: + artist = artist.lower() if artist not in artists: artists.append(artist) @@ -100,7 +113,7 @@ def get_albumartists(): for artist in artists: artist_obj = { "name": artist, - "image": helpers.check_artist_image(artist), + "image": helpers.create_safe_name(artist) + ".webp", } final_artists.append(artist_obj) diff --git a/server/app/api/artist.py b/server/app/api/artist.py index 0958197a..08f60aba 100644 --- a/server/app/api/artist.py +++ b/server/app/api/artist.py @@ -10,49 +10,48 @@ from flask import Blueprint artist_bp = Blueprint("artist", __name__, url_prefix="/") +# @artist_bp.route("/artist/") +# @cache.cached() +# def get_artist_data(artist: str): +# """Returns the artist's data, tracks and albums""" +# artist = urllib.parse.unquote(artist) +# artist_obj = instances.artist_instance.get_artists_by_name(artist) -@artist_bp.route("/artist/") -@cache.cached() -def get_artist_data(artist: str): - """Returns the artist's data, tracks and albums""" - artist = urllib.parse.unquote(artist) - artist_obj = instances.artist_instance.get_artists_by_name(artist) +# def get_artist_tracks(): +# songs = instances.tracks_instance.find_songs_by_artist(artist) - def get_artist_tracks(): - songs = instances.tracks_instance.find_songs_by_artist(artist) +# return songs - return songs +# artist_songs = get_artist_tracks() +# songs = helpers.remove_duplicates(artist_songs) - artist_songs = get_artist_tracks() - songs = helpers.remove_duplicates(artist_songs) +# def get_artist_albums(): +# artist_albums = [] +# albums_with_count = [] - def get_artist_albums(): - artist_albums = [] - albums_with_count = [] +# albums = instances.tracks_instance.find_songs_by_albumartist(artist) - albums = instances.tracks_instance.find_songs_by_albumartist(artist) +# for song in albums: +# if song["album"] not in artist_albums: +# artist_albums.append(song["album"]) - for song in albums: - if song["album"] not in artist_albums: - artist_albums.append(song["album"]) +# for album in artist_albums: +# count = 0 +# length = 0 - for album in artist_albums: - count = 0 - length = 0 +# for song in artist_songs: +# if song["album"] == album: +# count = count + 1 +# length = length + song["length"] - for song in artist_songs: - if song["album"] == album: - count = count + 1 - length = length + song["length"] +# album_ = {"title": album, "count": count, "length": length} - album_ = {"title": album, "count": count, "length": length} +# albums_with_count.append(album_) - albums_with_count.append(album_) +# return albums_with_count - return albums_with_count - - return { - "artist": artist_obj, - "songs": songs, - "albums": get_artist_albums() - } +# return { +# "artist": artist_obj, +# "songs": songs, +# "albums": get_artist_albums() +# } diff --git a/server/app/api/playlist.py b/server/app/api/playlist.py index d8730a02..c26eb0df 100644 --- a/server/app/api/playlist.py +++ b/server/app/api/playlist.py @@ -8,12 +8,13 @@ from app import exceptions from app import instances from app import models from app import serializer +from app.helpers import create_new_date +from app.helpers import Get +from app.helpers import UseBisection from app.lib import playlistlib from flask import Blueprint from flask import request -from app.helpers import UseBisection, create_new_date - playlist_bp = Blueprint("playlist", __name__, url_prefix="/") PlaylistExists = exceptions.PlaylistExists @@ -22,8 +23,13 @@ TrackExistsInPlaylist = exceptions.TrackExistsInPlaylist @playlist_bp.route("/playlists", methods=["GET"]) def get_all_playlists(): + """Returns all the playlists.""" + dbplaylists = instances.playlist_instance.get_all_playlists() + dbplaylists = [models.Playlist(p) for p in dbplaylists] + playlists = [ - serializer.Playlist(p, construct_last_updated=False) for p in api.PLAYLISTS + serializer.Playlist(p, construct_last_updated=False) + for p in dbplaylists ] playlists.sort( key=lambda p: datetime.strptime(p.lastUpdated, "%Y-%m-%d %H:%M:%S"), @@ -36,7 +42,7 @@ def get_all_playlists(): def create_playlist(): data = request.get_json() - playlist = { + data = { "name": data["name"], "description": "", "pre_tracks": [], @@ -45,21 +51,16 @@ def create_playlist(): "thumb": "", } - try: - for pl in api.PLAYLISTS: - if pl.name == playlist["name"]: - raise PlaylistExists("Playlist already exists.") + dbp = instances.playlist_instance.get_playlist_by_name(data["name"]) - except PlaylistExists as e: - return {"error": str(e)}, 409 + if dbp is not None: + return {"message": "Playlist already exists."}, 409 - upsert_id = instances.playlist_instance.insert_playlist(playlist) + upsert_id = instances.playlist_instance.insert_playlist(data) p = instances.playlist_instance.get_playlist_by_id(upsert_id) - pp = models.Playlist(p) + playlist = models.Playlist(p) - api.PLAYLISTS.append(pp) - - return {"playlist": pp}, 201 + return {"playlist": playlist}, 201 @playlist_bp.route("/playlist//add", methods=["POST"]) @@ -70,22 +71,27 @@ def add_track_to_playlist(playlist_id: str): try: playlistlib.add_track(playlist_id, trackid) - except TrackExistsInPlaylist as e: + except TrackExistsInPlaylist: return {"error": "Track already exists in playlist"}, 409 return {"msg": "I think It's done"}, 200 @playlist_bp.route("/playlist/") -def get_single_p_info(playlistid: str): - p = UseBisection(api.PLAYLISTS, "playlistid", [playlistid])() - playlist: models.Playlist = p[0] +def get_playlist(playlistid: str): + p = instances.playlist_instance.get_playlist_by_id(playlistid) + if p is None: + return {"info": {}, "tracks": []} - if playlist is not None: - tracks = playlist.get_tracks() - return {"info": serializer.Playlist(playlist), "tracks": tracks} + playlist = models.Playlist(p) - return {"info": {}, "tracks": []} + tracks = playlistlib.create_playlist_tracks(playlist.pretracks) + + duration = sum([t.length for t in tracks]) + playlist = serializer.Playlist(playlist) + playlist.duration = duration + + return {"info": playlist, "tracks": tracks} @playlist_bp.route("/playlist//update", methods=["PUT"]) @@ -105,25 +111,27 @@ def update_playlist(playlistid: str): "thumb": None, } - p = UseBisection(api.PLAYLISTS, "playlistid", [playlistid])() + playlists = Get.get_all_playlists() + + p = UseBisection(playlists, "playlistid", [playlistid])() p: models.Playlist = p[0] if playlist is not None: - if image: - image_, thumb_ = playlistlib.save_p_image(image, playlistid) - playlist["image"] = image_ - playlist["thumb"] = thumb_ + if image: + image_, thumb_ = playlistlib.save_p_image(image, playlistid) + playlist["image"] = image_ + playlist["thumb"] = thumb_ - else: - playlist["image"] = p.image.split("/")[-1] - playlist["thumb"] = p.thumb.split("/")[-1] + else: + playlist["image"] = p.image.split("/")[-1] + playlist["thumb"] = p.thumb.split("/")[-1] - p.update_playlist(playlist) - instances.playlist_instance.update_playlist(playlistid, playlist) + p.update_playlist(playlist) + instances.playlist_instance.update_playlist(playlistid, playlist) - return { - "data": serializer.Playlist(p), - } + return { + "data": serializer.Playlist(p), + } return {"msg": "Something shady happened"}, 500 diff --git a/server/app/api/search.py b/server/app/api/search.py index 52a54ca6..b745a166 100644 --- a/server/app/api/search.py +++ b/server/app/api/search.py @@ -1,7 +1,12 @@ """ Contains all the search routes. """ +from pprint import pprint +from typing import List + from app import helpers +from app import models +from app import serializer from app.lib import searchlib from flask import Blueprint from flask import request @@ -15,22 +20,94 @@ SEARCH_RESULTS = { } +class SearchResults: + """ + Holds all the search results. + """ + + query: str = "" + tracks: list[models.Track] = [] + albums: list[models.Album] = [] + playlists: list[models.Playlist] = [] + artists: list[models.Artist] = [] + + +class DoSearch: + """Class containing the methods that perform searching.""" + + def __init__(self, query: str) -> None: + """ + :param :str:`query`: the search query. + """ + self.query = query + SearchResults.query = query + + def search_tracks(self): + """Calls :class:`SearchTracks` which returns the tracks that fuzzily match + the search terms. Then adds them to the `SearchResults` store. + """ + self.tracks = helpers.Get.get_all_tracks() + tracks = searchlib.SearchTracks(self.tracks, self.query)() + tracks = helpers.RemoveDuplicates(tracks)() + SearchResults.tracks = tracks + + return tracks + + def search_artists(self): + """Calls :class:`SearchArtists` which returns the artists that fuzzily match + the search term. Then adds them to the `SearchResults` store. + """ + self.artists = helpers.Get.get_all_artists() + artists = searchlib.SearchArtists(self.artists, self.query)() + SearchResults.artists = artists + + return artists + + def search_albums(self): + """Calls :class:`SearchAlbums` which returns the albums that fuzzily match + the search term. Then adds them to the `SearchResults` store. + """ + albums = helpers.Get.get_all_albums() + albums = searchlib.SearchAlbums(albums, 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 = helpers.Get.get_all_playlists() + playlists = [serializer.Playlist(playlist) for playlist in playlists] + + playlists = searchlib.SearchPlaylists(playlists, self.query)() + SearchResults.playlists = playlists + + return playlists + + def search_all(self): + """Calls all the search methods.""" + self.search_tracks() + self.search_albums() + self.search_artists() + self.search_playlists() + + @search_bp.route("/search/tracks", methods=["GET"]) def search_tracks(): """ - Searches for tracks. + Searches for tracks that match the search query. """ query = request.args.get("q") if not query: return {"error": "No query provided"}, 400 - results = searchlib.SearchTracks(query)() - SEARCH_RESULTS["tracks"] = results + tracks = DoSearch(query).search_tracks() return { - "tracks": results[:5], - "more": len(results) > 5, + "tracks": tracks[:6], + "more": len(tracks) > 6, }, 200 @@ -44,12 +121,11 @@ def search_albums(): if not query: return {"error": "No query provided"}, 400 - results = searchlib.SearchAlbums(query)() - SEARCH_RESULTS["albums"] = results + tracks = DoSearch(query).search_albums() return { - "albums": results[:6], - "more": len(results) > 6, + "albums": tracks[:6], + "more": len(tracks) > 6, }, 200 @@ -63,51 +139,50 @@ def search_artists(): if not query: return {"error": "No query provided"}, 400 - results = searchlib.SearchArtists(query)() - SEARCH_RESULTS["artists"] = results + artists = DoSearch(query).search_artists() return { - "artists": results[:6], - "more": len(results) > 6, + "artists": artists[:6], + "more": len(artists) > 6, }, 200 -@search_bp.route("/search") -def search(): +@search_bp.route("/search/playlists", methods=["GET"]) +def search_playlists(): """ - Returns a list of songs, albums and artists that match the search query. + Searches for playlists. """ - query = request.args.get("q") or "Mexican girl" - albums = searchlib.SearchAlbums(query)() - artists_dicts = searchlib.SearchArtists(query)() + query = request.args.get("q") + if not query: + return {"error": "No query provided"}, 400 - tracks = searchlib.SearchTracks(query)() - top_artist = artists_dicts[0]["name"] - - _tracks = searchlib.GetTopArtistTracks(top_artist)() - tracks = [*tracks, *[t for t in _tracks if t not in tracks]] - - SEARCH_RESULTS.clear() - SEARCH_RESULTS["tracks"] = tracks - SEARCH_RESULTS["albums"] = albums - SEARCH_RESULTS["artists"] = artists_dicts + playlists = DoSearch(query).search_playlists() return { - "data": [ - { - "tracks": tracks[:5], - "more": len(tracks) > 5 - }, - { - "albums": albums[:6], - "more": len(albums) > 6 - }, - { - "artists": artists_dicts[:6], - "more": len(artists_dicts) > 6 - }, - ] + "playlists": playlists[:6], + "more": len(playlists) > 6, + }, 200 + + +@search_bp.route("/search/top", methods=["GET"]) +def get_top_results(): + """ + Returns the top results for the search query. + """ + + query = request.args.get("q") + if not query: + return {"error": "No query provided"}, 400 + + DoSearch(query).search_all() + + max = 2 + return { + "tracks": SearchResults.tracks[:max], + "albums": SearchResults.albums[:max], + "artists": SearchResults.artists[:max], + "playlists": SearchResults.playlists[:max], } @@ -120,19 +195,22 @@ def search_load_more(): index = int(request.args.get("index")) if type == "tracks": + t = SearchResults.tracks return { - "tracks": SEARCH_RESULTS["tracks"][index:index + 5], - "more": len(SEARCH_RESULTS["tracks"]) > index + 5, + "tracks": t[index:index + 5], + "more": len(t) > index + 5, } elif type == "albums": + a = SearchResults.albums return { - "albums": SEARCH_RESULTS["albums"][index:index + 6], - "more": len(SEARCH_RESULTS["albums"]) > index + 6, + "albums": a[index:index + 6], + "more": len(a) > index + 6, } elif type == "artists": + a = SearchResults.artists return { - "artists": SEARCH_RESULTS["artists"][index:index + 6], - "more": len(SEARCH_RESULTS["artists"]) > index + 6, + "artists": a[index:index + 6], + "more": len(a) > index + 6, } diff --git a/server/app/api/track.py b/server/app/api/track.py index 64bf7453..924f6aa1 100644 --- a/server/app/api/track.py +++ b/server/app/api/track.py @@ -3,6 +3,7 @@ Contains all the track routes. """ from app import api from app import instances +from app import models from flask import Blueprint from flask import send_file @@ -14,21 +15,19 @@ def send_track_file(trackid): """ Returns an audio file that matches the passed id to the client. """ + track = instances.tracks_instance.get_track_by_id(trackid) + msg = {"msg": "File Not Found"} + + if track is None: + return msg, 404 + + track = models.Track(track) + type = track.filepath.split(".")[-1] + try: - files = [] - for f in api.DB_TRACKS: - try: - if f["_id"]["$oid"] == trackid: - files.append(f["filepath"]) - except KeyError: - # Bug: some albums are not found although they exist in `api.ALBUMS`. It has something to do with the bisection method used or sorting. Not sure yet. - pass - - filepath = files[0] - except IndexError: - return "File not found", 404 - - return send_file(filepath, mimetype="audio/mp3") + return send_file(track.filepath, mimetype=f"audio/{type}") + except FileNotFoundError: + return msg, 404 @track_bp.route("/sample") @@ -37,6 +36,5 @@ def get_sample_track(): Returns a sample track object. """ - return instances.tracks_instance.get_song_by_album( - "Legends Never Die", "Juice WRLD" - ) + return instances.tracks_instance.get_song_by_album("Legends Never Die", + "Juice WRLD") diff --git a/server/app/db/__init__.py b/server/app/db/__init__.py index e7157d74..79a76d25 100644 --- a/server/app/db/__init__.py +++ b/server/app/db/__init__.py @@ -200,3 +200,15 @@ class TrackMethods: Removes a track from the database. Returns a boolean indicating success or failure of the operation. """ pass + + def find_tracks_by_hash(): + """ + Returns all the tracks matching the passed hash. + """ + pass + + def get_dir_t_count(): + """ + Returns a list of all the tracks matching the path in the query params. + """ + pass diff --git a/server/app/db/mongodb/albums.py b/server/app/db/mongodb/albums.py index 27b7feb5..7e220760 100644 --- a/server/app/db/mongodb/albums.py +++ b/server/app/db/mongodb/albums.py @@ -2,7 +2,8 @@ This file contains the Album class for interacting with album documents in MongoDB. """ -from app import db +from typing import List + from app.db.mongodb import convert_many from app.db.mongodb import convert_one from app.db.mongodb import MongoAlbums @@ -31,6 +32,13 @@ class Albums(MongoAlbums): upsert=True, ).upserted_id + def insert_many(self, albums: Album): + albums = [a.__dict__ for a in albums] + """ + Inserts multiple albums into the database. + """ + return self.collection.insert_many(albums) + def get_all_albums(self) -> list: """ Returns all the albums in the database. @@ -52,9 +60,20 @@ class Albums(MongoAlbums): album = self.collection.find_one({"album": name, "artist": artist}) return convert_one(album) - def get_album_by_artist(self, name: str) -> dict: + def find_album_by_hash(self, hash: str) -> dict: """ - Returns a single album matching the artist in the query params. + Returns a single album matching the hash in the query params. """ - album = self.collection.find_one({"albumartist": name}) + album = self.collection.find_one({"hash": hash}) return convert_one(album) + + def set_album_colors(self, colors: List[str], hash: str) -> None: + """ + Sets the colors for an album. + """ + self.collection.update_one( + {"hash": hash}, + {"$set": { + "colors": colors + }}, + ) diff --git a/server/app/db/mongodb/playlists.py b/server/app/db/mongodb/playlists.py index d6df804b..78941f2c 100644 --- a/server/app/db/mongodb/playlists.py +++ b/server/app/db/mongodb/playlists.py @@ -1,10 +1,10 @@ """ This file contains the Playlists class for interacting with the playlist documents in MongoDB. """ +from app import helpers from app.db.mongodb import convert_many from app.db.mongodb import convert_one from app.db.mongodb import MongoPlaylists -from app.helpers import create_new_date from bson import ObjectId @@ -41,25 +41,32 @@ class Playlists(MongoPlaylists): playlist = self.collection.find_one({"_id": ObjectId(id)}) return convert_one(playlist) + def set_last_updated(self, playlistid: str) -> None: + """ + Sets the lastUpdated field to the current date. + """ + date = helpers.create_new_date() + + return self.collection.update_one( + {"_id": ObjectId(playlistid)}, + {"$set": { + "lastUpdated": date + }}, + ) + def add_track_to_playlist(self, playlistid: str, track: dict) -> None: """ Adds a track to a playlist. """ - date = create_new_date() - - return self.collection.update_one( + self.collection.update_one( { "_id": ObjectId(playlistid), }, - { - "$push": { - "pre_tracks": track - }, - "$set": { - "lastUpdated": date - } - }, + {"$push": { + "pre_tracks": track + }}, ) + self.set_last_updated(playlistid) def get_playlist_by_name(self, name: str) -> dict: """ diff --git a/server/app/db/mongodb/tracks.py b/server/app/db/mongodb/tracks.py index 6fa72c95..9504b757 100644 --- a/server/app/db/mongodb/tracks.py +++ b/server/app/db/mongodb/tracks.py @@ -1,6 +1,8 @@ """ This file contains the AllSongs class for interacting with track documents in MongoDB. """ +import re + import pymongo from app.db.mongodb import convert_many from app.db.mongodb import convert_one @@ -20,9 +22,18 @@ class Tracks(MongoTracks): """ Inserts a new track object into the database. """ - return self.collection.update_one( - {"filepath": song_obj["filepath"]}, {"$set": song_obj}, upsert=True - ).upserted_id + return self.collection.update_one({ + "filepath": song_obj["filepath"] + }, { + "$set": song_obj + }, + upsert=True).upserted_id + + def insert_many(self, songs: list): + """ + Inserts multiple songs into the database. + """ + return self.collection.insert_many(songs) def get_all_tracks(self) -> list: """ @@ -30,11 +41,11 @@ class Tracks(MongoTracks): """ return convert_many(self.collection.find()) - def get_song_by_id(self, file_id: str) -> dict: + def get_track_by_id(self, id: str) -> dict: """ Returns a track object by its mongodb id. """ - song = self.collection.find_one({"_id": ObjectId(file_id)}) + song = self.collection.find_one({"_id": ObjectId(id)}) return convert_one(song) def get_song_by_album(self, name: str, artist: str) -> dict: @@ -48,21 +59,33 @@ class Tracks(MongoTracks): """ Returns all the songs matching the albums in the query params (using regex). """ - songs = self.collection.find({"album": {"$regex": query, "$options": "i"}}) + songs = self.collection.find( + {"album": { + "$regex": query, + "$options": "i" + }}) return convert_many(songs) def search_songs_by_artist(self, query: str) -> list: """ Returns all the songs matching the artists in the query params. """ - songs = self.collection.find({"artists": {"$regex": query, "$options": "i"}}) + songs = self.collection.find( + {"artists": { + "$regex": query, + "$options": "i" + }}) return convert_many(songs) def find_song_by_title(self, query: str) -> list: """ Finds all the tracks matching the title in the query params. """ - song = self.collection.find({"title": {"$regex": query, "$options": "i"}}) + song = self.collection.find( + {"title": { + "$regex": query, + "$options": "i" + }}) return convert_many(song) def find_songs_by_album(self, name: str, artist: str) -> list: @@ -76,16 +99,33 @@ class Tracks(MongoTracks): """ Returns a sorted list of all the tracks exactly matching the folder in the query params """ - songs = self.collection.find({"folder": query}).sort("title", pymongo.ASCENDING) + songs = self.collection.find({ + "folder": query + }).sort("title", pymongo.ASCENDING) + return convert_many(songs) + + def find_songs_by_filenames(self, filenames: list) -> list: + """ + Returns a list of all the tracks matching the filenames in the query params. + """ + songs = self.collection.find({"filepath": {"$in": filenames}}) return convert_many(songs) def find_songs_by_folder_og(self, query: str) -> list: """ - Returns an unsorted list of all the tracks exactly matching the folder in the query params + Returns an unsorted list of all the track matching the folder in the query params """ songs = self.collection.find({"folder": query}) return convert_many(songs) + def get_dir_t_count(self, path: str) -> int: + """ + Returns a list of all the tracks matching the path in the query params. + """ + regex = re.compile(r"^.*" + re.escape(path) + r".*$") + + return self.collection.count_documents({"filepath": {"$regex": regex}}) + def find_songs_by_artist(self, query: str) -> list: """ Returns a list of all the tracks exactly matching the artists in the query params. @@ -98,8 +138,10 @@ class Tracks(MongoTracks): Returns a list of all the tracks containing the albumartist in the query params. """ songs = self.collection.find( - {"albumartist": {"$regex": query, "$options": "i"}} - ) + {"albumartist": { + "$regex": query, + "$options": "i" + }}) return convert_many(songs) def get_song_by_path(self, path: str) -> dict: @@ -128,3 +170,22 @@ class Tracks(MongoTracks): return True except: return False + + def find_tracks_by_hash(self, hash: str) -> list: + """ + Returns a list of all the tracks matching the hash in the query params. + """ + songs = self.collection.find({"albumhash": hash}) + return convert_many(songs) + + def find_track_by_title_artists_album(self, title: str, artist: str, + album: str) -> dict: + """ + Returns a single track matching the title, artist, and album in the query params. + """ + song = self.collection.find_one({ + "title": title, + "artists": artist, + "album": album + }) + return convert_one(song) diff --git a/server/app/functions.py b/server/app/functions.py index 31345332..b9293de3 100644 --- a/server/app/functions.py +++ b/server/app/functions.py @@ -3,31 +3,49 @@ This module contains functions for the server """ import os import time +from concurrent.futures import ThreadPoolExecutor from io import BytesIO import requests -from app import api from app import helpers from app import settings +from app.lib import trackslib from app.lib import watchdoge +from app.lib.albumslib import ValidateAlbumThumbs +from app.lib.colorlib import ProcessAlbumColors +from app.lib.playlistlib import ValidatePlaylistThumbs +from app.lib.populate import CreateAlbums from app.lib.populate import Populate +from app.logger import get_logger from PIL import Image -from concurrent.futures import ThreadPoolExecutor -from app.lib.trackslib import create_all_tracks +log = get_logger() @helpers.background -def reindex_tracks(): +def run_checks(): """ Checks for new songs every 5 minutes. """ + ValidateAlbumThumbs() while True: - populate() - CheckArtistImages()() + trackslib.validate_tracks() - time.sleep(60) + Populate() + CreateAlbums() + + if helpers.Ping()(): + CheckArtistImages()() + + @helpers.background + def process_album_colors(): + ProcessAlbumColors() + + ValidatePlaylistThumbs() + process_album_colors() + + time.sleep(300) @helpers.background @@ -38,15 +56,6 @@ def start_watchdog(): watchdoge.watch.run() -def populate(): - pop = Populate() - pop.run() - - tracks = create_all_tracks() - api.TRACKS.clear() - api.TRACKS.extend(tracks) - - class getArtistImage: """ Returns an artist image url. @@ -70,6 +79,7 @@ class getArtistImage: class useImageDownloader: + def __init__(self, url: str, dest: str) -> None: self.url = url self.dest = dest @@ -79,15 +89,18 @@ class useImageDownloader: img = Image.open(BytesIO(requests.get(self.url).content)) img.save(self.dest, format="webp") img.close() + return "fetched image" except requests.exceptions.ConnectionError: - print("🔴🔴🔴🔴🔴🔴🔴") time.sleep(5) + return "connection error" class CheckArtistImages: + def __init__(self): self.artists: list[str] = [] print("Checking for artist images") + log.info("Checking artist images") @staticmethod def check_if_exists(img_path: str): @@ -100,18 +113,6 @@ class CheckArtistImages: else: return False - def gather_artists(self): - """ - Loops through all the tracks and gathers all the artists. - """ - - for song in api.DB_TRACKS: - this_artists: list = song["artists"].split(", ") - - for artist in this_artists: - if artist not in self.artists: - self.artists.append(artist) - @classmethod def download_image(cls, artistname: str): """ @@ -120,28 +121,25 @@ class CheckArtistImages: :param artistname: The artist name """ - img_path = ( - helpers.app_dir - + "/images/artists/" - + artistname.replace("/", "::") - + ".webp" - ) + img_path = (settings.APP_DIR + "/images/artists/" + + helpers.create_safe_name(artistname) + ".webp") if cls.check_if_exists(img_path): - return + return "exists" url = getArtistImage(artistname)() if url is None: - return + return "url is none" - useImageDownloader(url, img_path)() + return useImageDownloader(url, img_path)() def __call__(self): - self.gather_artists() + self.artists = helpers.Get.get_all_artists() with ThreadPoolExecutor() as pool: - pool.map(self.download_image, self.artists) + iter = pool.map(self.download_image, self.artists) + [i for i in iter] print("Done fetching images") @@ -151,8 +149,7 @@ def fetch_album_bio(title: str, albumartist: str) -> str | None: Returns the album bio for a given album. """ last_fm_url = "http://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key={}&artist={}&album={}&format=json".format( - settings.LAST_FM_API_KEY, albumartist, title - ) + settings.LAST_FM_API_KEY, albumartist, title) try: response = requests.get(last_fm_url) @@ -161,7 +158,8 @@ def fetch_album_bio(title: str, albumartist: str) -> str | None: return None try: - bio = data["album"]["wiki"]["summary"].split(' Dict[List[str], List[str]]: return subfolders, files -def remove_duplicates(tracklist: List[models.Track]) -> List[models.Track]: - """ - Removes duplicates from a list. Returns a list without duplicates. - """ +class RemoveDuplicates: - song_num = 0 + def __init__(self, tracklist: List[models.Track]) -> None: + self.tracklist = tracklist - while song_num < len(tracklist) - 1: - for index, song in enumerate(tracklist): - if ( - tracklist[song_num].title == song.title - and tracklist[song_num].album == song.album - and tracklist[song_num].artists == song.artists - and index != song_num - ): - tracklist.remove(song) + def __call__(self) -> List[models.Track]: + uniq_hashes = set(t.uniq_hash for t in self.tracklist) + tracks = UseBisection(self.tracklist, "uniq_hash", uniq_hashes)() - song_num += 1 - - return tracklist - - -# def save_image(url: str, path: str) -> None: -# """ -# Saves an image from an url to a path. -# """ - -# response = requests.get(url) -# img = Image.open(BytesIO(response.content)) -# img.save(path, "JPEG") + return tracks def is_valid_file(filename: str) -> bool: @@ -94,31 +73,13 @@ def is_valid_file(filename: str) -> bool: return False -def use_memoji(): - """ - Returns a path to a random memoji image. - """ - path = str(random.randint(0, 20)) + ".svg" - return "defaults/" + path - - -def check_artist_image(image: str) -> str: - """ - Checks if the artist image is valid. - """ - img_name = image.replace("/", "::") + ".webp" - - if not os.path.exists(os.path.join(app_dir, "images", "artists", img_name)): - return use_memoji() - else: - return img_name - - def create_album_hash(title: str, artist: str) -> str: """ Creates a simple hash for an album """ - return (title + artist).replace(" ", "").lower() + lower = (title + artist).replace(" ", "").lower() + hash = "".join([i for i in lower if i.isalnum()]) + return hash def create_new_date(): @@ -131,33 +92,33 @@ def create_safe_name(name: str) -> str: """ Creates a url-safe name from a name. """ - return "".join([i for i in name if i not in '/\\:*?"<>|']) + return "".join([i for i in name if i.isalnum()]) class UseBisection: """ - Uses bisection to find a list of items in another list. + Uses bisection to find a list of items in another list. - returns a list of found items with `None` items being not found + returns a list of found items with `None` items being not found items. """ - def __init__(self, source: List, search_from: str, queries: List[str]) -> None: - self.list = source - self.queries = queries - self.search_from = search_from - self.list.sort(key=lambda x: getattr(x, search_from)) + def __init__(self, source: List, search_from: str, + queries: List[str]) -> None: + self.source_list = source + self.queries_list = queries + self.attr = search_from + self.source_list.sort(key=lambda x: getattr(x, search_from)) def find(self, query: str): left = 0 - right = len(self.list) - 1 + right = len(self.source_list) - 1 while left <= right: mid = (left + right) // 2 - - if self.list[mid].__getattribute__(self.search_from) == query: - return self.list[mid] - elif self.list[mid].__getattribute__(self.search_from) > query: + if self.source_list[mid].__getattribute__(self.attr) == query: + return self.source_list[mid] + elif self.source_list[mid].__getattribute__(self.attr) > query: right = mid - 1 else: left = mid + 1 @@ -165,4 +126,57 @@ class UseBisection: return None def __call__(self) -> List: - return [self.find(query) for query in self.queries] + if len(self.source_list) == 0: + print("🚀🚀🚀🚀🚀🚀🚀") + return [None] + + return [self.find(query) for query in self.queries_list] + + +class Get: + + @staticmethod + def get_all_tracks() -> List[models.Track]: + """ + Returns all tracks + """ + t = instances.tracks_instance.get_all_tracks() + return [models.Track(t) for t in t] + + def get_all_albums() -> List[models.Album]: + """ + Returns all albums + """ + a = instances.album_instance.get_all_albums() + return [models.Album(a) for a in a] + + @classmethod + def get_all_artists(cls) -> Set[str]: + tracks = cls.get_all_tracks() + artists: Set[str] = set() + + for track in tracks: + for artist in track.artists: + artists.add(artist.lower()) + + return artists + + @staticmethod + def get_all_playlists() -> List[models.Playlist]: + """ + Returns all playlists + """ + p = instances.playlist_instance.get_all_playlists() + return [models.Playlist(p) for p in p] + + +class Ping: + """Checks if there is a connection to the internet by pinging google.com""" + + @staticmethod + def __call__() -> bool: + try: + requests.get("https://google.com", timeout=10) + return True + except (requests.exceptions.ConnectionError, requests.Timeout): + return False diff --git a/server/app/imgserver/__init__.py b/server/app/imgserver/__init__.py index cc5057b6..04a3b59e 100644 --- a/server/app/imgserver/__init__.py +++ b/server/app/imgserver/__init__.py @@ -12,11 +12,13 @@ def join(*args: Tuple[str]) -> str: HOME = path.expanduser("~") -ROOT_PATH = path.join(HOME, ".alice", "images") +APP_DIR = join(HOME, ".alice") +IMG_PATH = path.join(APP_DIR, "images") -THUMB_PATH = join(ROOT_PATH, "thumbnails") -ARTIST_PATH = join(ROOT_PATH, "artists") -PLAYLIST_PATH = join(ROOT_PATH, "playlists") +ASSETS_PATH = join(APP_DIR, "assets") +THUMB_PATH = join(IMG_PATH, "thumbnails") +ARTIST_PATH = join(IMG_PATH, "artists") +PLAYLIST_PATH = join(IMG_PATH, "playlists") @app.route("/") @@ -24,6 +26,16 @@ def hello(): return "Hello mf" +def send_fallback_img(): + img = join(ASSETS_PATH, "default.webp") + exists = path.exists(img) + + if not exists: + return "", 404 + + return send_from_directory(ASSETS_PATH, "default.webp") + + @app.route("/t/") def send_thumbnail(imgpath: str): fpath = join(THUMB_PATH, imgpath) @@ -32,7 +44,7 @@ def send_thumbnail(imgpath: str): if exists: return send_from_directory(THUMB_PATH, imgpath) - return {"msg": "Not found"}, 404 + return send_fallback_img() @app.route("/a/") @@ -43,7 +55,7 @@ def send_artist_image(imgpath: str): if exists: return send_from_directory(ARTIST_PATH, imgpath) - return {"msg": "Not found"}, 404 + return send_fallback_img() @app.route("/p/") @@ -54,11 +66,8 @@ def send_playlist_image(imgpath: str): if exists: return send_from_directory(PLAYLIST_PATH, imgpath) - return {"msg": "Not found"}, 404 + return send_fallback_img() -# TODO -# Return Fallback images instead of JSON - if __name__ == "__main__": app.run(threaded=True, port=9877) diff --git a/server/app/lib/albumslib.py b/server/app/lib/albumslib.py index 2bf6d929..eea60e4f 100644 --- a/server/app/lib/albumslib.py +++ b/server/app/lib/albumslib.py @@ -1,89 +1,93 @@ """ This library contains all the functions related to albums. """ +import os import random +from dataclasses import dataclass from typing import List -from app import api from app import helpers from app import instances from app import models from app.lib import taglib -from app.lib import trackslib +from app.logger import logg +from app.settings import THUMBS_PATH from tqdm import tqdm -def get_all_albums() -> List[models.Album]: +@dataclass +class Thumbnail: + filename: str + + +class RipAlbumImage: """ - Returns a list of album objects for all albums in the database. - """ - print("Getting all albums...") - - albums: List[models.Album] = [] - - db_albums = instances.album_instance.get_all_albums() - - for album in tqdm(db_albums, desc="Creating albums"): - aa = models.Album(album) - albums.append(aa) - - return albums - - -def create_everything() -> List[models.Track]: - """ - Creates album objects for all albums and returns - a list of track objects - """ - albums: list[models.Album] = get_all_albums() - - api.ALBUMS = albums - api.ALBUMS.sort(key=lambda x: x.hash) - - tracks = trackslib.create_all_tracks() - - api.TRACKS.clear() - api.TRACKS.extend(tracks) - api.TRACKS.sort(key=lambda x: x.title) - - -def find_album(albums: List[models.Album], hash: str) -> int | None: - """ - Finds an album by album title and artist. - - :param `albums`: List of album objects. - :param `hash`: Hash of album. - :return: Index of album in list. + Rips a thumbnail for the given album hash. """ - left = 0 - right = len(albums) - 1 + def __init__(self, hash: str) -> None: + tracks = instances.tracks_instance.find_tracks_by_hash(hash) + tracks = [models.Track(track) for track in tracks] - while left <= right: - mid = (left + right) // 2 + for track in tracks: + ripped = taglib.extract_thumb(track.filepath, hash + ".webp") - if albums[mid].hash == hash: - return mid - - if albums[mid].hash < hash: - left = mid + 1 - else: - right = mid - 1 - - return None + if ripped: + break -def get_album_duration(album: List[models.Track]) -> int: - """ - Gets the duration of an album. - """ +class ValidateAlbumThumbs: - album_duration = 0 + @staticmethod + def remove_obsolete(): + """ + Removes unreferenced thumbnails from the thumbnails folder. + """ + entries = os.scandir(THUMBS_PATH) + entries = [entry for entry in entries if entry.is_file()] - for track in album: - album_duration += track.length + albums = helpers.Get.get_all_albums() + thumbs = [Thumbnail(album.hash + ".webp") for album in albums] - return album_duration + for entry in tqdm(entries, desc="Validating thumbnails"): + e = helpers.UseBisection(thumbs, "filename", [entry.name])() + + if e is None: + os.remove(entry.path) + + @staticmethod + def find_lost_thumbnails(): + """ + Re-rip lost album thumbnails + """ + entries = os.scandir(THUMBS_PATH) + entries = [ + Thumbnail(entry.name) for entry in entries if entry.is_file() + ] + + albums = helpers.Get.get_all_albums() + thumbs = [(album.hash + ".webp") for album in albums] + + def rip_image(t_hash: str): + e = helpers.UseBisection(entries, "filename", [t_hash])()[0] + + if e is None: + hash = t_hash.replace(".webp", "") + RipAlbumImage(hash) + + logg.info("Ripping lost album thumbnails") + # with ThreadPoolExecutor() as pool: + # i = pool.map(rip_image, thumbs) + # [a for a in i] + # ⚠️ empty lists are sent to the useBisection function as the source list. + for thumb in thumbs: + rip_image(thumb) + + logg.info("Ripping lost album thumbnails ... ✅") + + def __init__(self) -> None: + self.remove_obsolete() + self.find_lost_thumbnails() def use_defaults() -> str: @@ -94,31 +98,19 @@ def use_defaults() -> str: return path -def gen_random_path() -> str: - """ - Generates a random image file path for an album image. - """ - choices = "abcdefghijklmnopqrstuvwxyz0123456789" - path = "".join(random.choice(choices) for i in range(20)) - path += ".webp" - - return path - - -def get_album_image(album: list) -> str: +def get_album_image(track: models.Track) -> str: """ Gets the image of an album. """ - for track in album: - img_p = gen_random_path() + img_p = track.albumhash + ".webp" - exists = taglib.extract_thumb(track["filepath"], webp_path=img_p) + success = taglib.extract_thumb(track.filepath, webp_path=img_p) - if exists: - return img_p + if success: + return img_p - return use_defaults() + return None class GetAlbumTracks: @@ -127,66 +119,38 @@ class GetAlbumTracks: and album artist. """ - def __init__(self, tracklist: list, albumhash: str) -> None: + def __init__(self, tracklist: List[models.Track], albumhash: str) -> None: self.hash = albumhash self.tracks = tracklist - self.tracks.sort(key=lambda x: x["albumhash"]) + self.tracks.sort(key=lambda x: x.albumhash) - def find_tracks(self): - tracks = [] - index = trackslib.find_track(self.tracks, self.hash) + def __call__(self): + tracks = helpers.UseBisection(self.tracks, "albumhash", [self.hash])() - while index is not None: - track = self.tracks[index] - tracks.append(track) - self.tracks.remove(track) - index = trackslib.find_track(self.tracks, self.hash) - - # self.tracks.extend(tracks) - # self.tracks.sort(key=lambda x: x["albumhash"]) return tracks -def get_album_tracks(album: str, artist: str) -> List: - return GetAlbumTracks(album, artist).find_tracks() +def get_album_tracks(tracklist: List[models.Track], hash: str) -> List: + return GetAlbumTracks(tracklist, hash)() -def create_album(track: dict, tracklist: list) -> dict: +def create_album(track: models.Track) -> dict: """ Generates and returns an album object from a track object. """ album = { - "title": track["album"], - "artist": track["albumartist"], + "title": track.album, + "artist": track.albumartist, + "hash": track.albumhash, } - albumhash = helpers.create_album_hash(album["title"], album["artist"]) - album_tracks = get_album_tracks(tracklist, albumhash) + album["date"] = track.date - if len(album_tracks) == 0: - return None + img_p = get_album_image(track) - album["date"] = album_tracks[0]["date"] - - album["image"] = get_album_image(album_tracks) - # album["image"] = "".join(x for x in albumhash if x not in "\/:*?<>|") + if img_p is not None: + album["image"] = img_p + return album + album["image"] = None return album - - -def search_albums_by_name(query: str) -> List[models.Album]: - """ - Searches albums by album name. - """ - title_albums: List[models.Album] = [] - artist_albums: List[models.Album] = [] - - for album in api.ALBUMS: - if query.lower() in album.title.lower(): - title_albums.append(album) - - for album in api.ALBUMS: - if query.lower() in album.artist.lower(): - artist_albums.append(album) - - return [*title_albums, *artist_albums] diff --git a/server/app/lib/colorlib.py b/server/app/lib/colorlib.py index 5882461f..5936759b 100644 --- a/server/app/lib/colorlib.py +++ b/server/app/lib/colorlib.py @@ -1,17 +1,17 @@ -from io import BytesIO - import colorgram -from app import api from app import instances -from app.lib.taglib import return_album_art -from PIL import Image -from progress.bar import Bar +from app import settings +from app.helpers import Get +from app.logger import get_logger +from app.models import Album + +log = get_logger() -def get_image_colors(image) -> list: +def get_image_colors(image: str) -> list: """Extracts 2 of the most dominant colors from an image.""" try: - colors = sorted(colorgram.extract(image, 2), key=lambda c: c.hsl.h) + colors = sorted(colorgram.extract(image, 4), key=lambda c: c.hsl.h) except OSError: return [] @@ -24,30 +24,26 @@ def get_image_colors(image) -> list: return formatted_colors -def save_track_colors(img, filepath) -> None: - """Saves the track colors to the database""" +class ProcessAlbumColors: - track_colors = get_image_colors(img) + def __init__(self) -> None: + log.info("Processing album colors") + all_albums = Get.get_all_albums() - tc_dict = { - "filepath": filepath, - "colors": track_colors, - } + all_albums = [a for a in all_albums if len(a.colors) == 0] - instances.track_color_instance.insert_track_color(tc_dict) + for a in all_albums: + self.process_color(a) + log.info("Processing album colors ... ✅") -def save_t_colors(): - _bar = Bar("Processing image colors", max=len(api.DB_TRACKS)) + @staticmethod + def process_color(album: Album): + img = settings.THUMBS_PATH + "/" + album.image - for track in api.DB_TRACKS: - filepath = track["filepath"] - album_art = return_album_art(filepath) + colors = get_image_colors(img) - if album_art is not None: - img = Image.open(BytesIO(album_art)) - save_track_colors(img, filepath) + if len(colors) > 0: + instances.album_instance.set_album_colors(colors, album.hash) - _bar.next() - - _bar.finish() + return colors diff --git a/server/app/lib/folderslib.py b/server/app/lib/folderslib.py index bc70f830..795b2ed0 100644 --- a/server/app/lib/folderslib.py +++ b/server/app/lib/folderslib.py @@ -1,15 +1,12 @@ +import time +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from os import scandir -from time import time -from typing import List -from typing import Set from typing import Tuple -from app import api -from app import helpers +from app import instances from app.models import Folder from app.models import Track -from tqdm import tqdm @dataclass @@ -18,20 +15,12 @@ class Dir: is_sym: bool -def get_valid_folders() -> None: - for track in api.TRACKS: - api.VALID_FOLDERS.add(track.folder) - - -def get_folder_track_count(foldername: str) -> int: +def get_folder_track_count(path: str) -> int: """ Returns the number of files associated with a folder. """ - count = 0 - for track in api.TRACKS: - if foldername in track.folder: - count += 1 - return count + tracks = instances.tracks_instance.get_dir_t_count(path) + return len(tracks) def create_folder(dir: Dir) -> Folder: @@ -40,57 +29,12 @@ def create_folder(dir: Dir) -> Folder: "name": dir.path.split("/")[-1], "path": dir.path, "is_sym": dir.is_sym, - "trackcount": get_folder_track_count(dir.path), + "trackcount": instances.tracks_instance.get_dir_t_count(dir.path), } return Folder(folder) -def create_all_folders() -> Set[Folder]: - folders: List[Folder] = [] - - for foldername in tqdm(api.VALID_FOLDERS, desc="Creating folders"): - folder = create_folder(foldername) - folders.append(folder) - - return folders - - -def get_subdirs(foldername: str) -> List[Folder]: - """ - Finds and Creates Folder objects for each sub-directory string in the foldername passed. - """ - subdirs = set() - - for folder in api.VALID_FOLDERS: - if foldername in folder: - str0 = folder.replace(foldername, "") - - try: - str1 = str0.split("/")[1] - except IndexError: - str1 = None - - if str1 is not None: - subdirs.add(foldername + "/" + str1) - return [create_folder(dir) for dir in subdirs] - - -@helpers.background -def run_scandir(): - """ - Initiates the creation of all folder objects for each folder with a track in it. - - Runs in a background thread after every 5 minutes. - It calls the - """ - get_valid_folders() - # folders_ = create_all_folders() - """Create all the folder objects before clearing api.FOLDERS""" - - # api.FOLDERS = folders_ - - class getFnF: """ Get files and folders from a directory. @@ -99,15 +43,6 @@ class getFnF: def __init__(self, path: str) -> None: self.path = path - @classmethod - def get_tracks(cls, files: List[str]) -> List[Track]: - """ - Returns a list of Track objects for each file in the given list. - """ - tracks = helpers.UseBisection(api.TRACKS, "filepath", files)() - tracks = filter(lambda t: t is not None, tracks) - return list(tracks) - def __call__(self) -> Tuple[Track, Folder]: try: all = scandir(self.path) @@ -125,9 +60,14 @@ class getFnF: dirs.append(Dir(**dir)) elif entry.is_file() and entry.name.endswith((".mp3", ".flac")): files.append(entry.path) - tracks = self.get_tracks(files) - folders = [create_folder(dir) for dir in dirs] + tracks = instances.tracks_instance.find_songs_by_filenames(files) + tracks = [Track(track) for track in tracks] + + with ThreadPoolExecutor() as pool: + iter = pool.map(create_folder, dirs) + folders = [i for i in iter if i is not None] + folders = filter(lambda f: f.trackcount > 0, folders) return tracks, folders diff --git a/server/app/lib/playlistlib.py b/server/app/lib/playlistlib.py index aabc4cf0..95fda111 100644 --- a/server/app/lib/playlistlib.py +++ b/server/app/lib/playlistlib.py @@ -5,63 +5,46 @@ import os import random import string from datetime import datetime +from typing import List -from tqdm import tqdm - -from app import api from app import exceptions from app import instances from app import models from app import settings +from app.helpers import Get from app.lib import trackslib +from app.logger import get_logger from PIL import Image from PIL import ImageSequence -from progress.bar import Bar from werkzeug import datastructures TrackExistsInPlaylist = exceptions.TrackExistsInPlaylist +logg = get_logger() + def add_track(playlistid: str, trackid: str): """ - Adds a track to a playlist in the api.PLAYLISTS dict and to the database. + Adds a track to a playlist to the database. """ - for playlist in api.PLAYLISTS: - if playlist.playlistid == playlistid: - tt = trackslib.get_track_by_id(trackid) + tt = instances.tracks_instance.get_track_by_id(trackid) - track = { - "title": tt.title, - "artists": tt.artists, - "album": tt.album, - } + if tt is None: + return - try: - playlist.add_track(track) - instances.playlist_instance.add_track_to_playlist( - playlistid, track) - return - except TrackExistsInPlaylist as error: - raise error + track = models.Track(tt) + playlist = instances.playlist_instance.get_playlist_by_id(playlistid) -def get_playlist_tracks(pid: str): - for p in api.PLAYLISTS: - if p.playlistid == pid: - return p.tracks + track = { + "title": track.title, + "artists": tt["artists"], + "album": track.album, + } + if track in playlist["pre_tracks"]: + raise TrackExistsInPlaylist - -def create_all_playlists(): - """ - Gets all playlists from the database. - """ - playlists = instances.playlist_instance.get_all_playlists() - - - for playlist in tqdm(playlists, desc="Creating playlists"): - api.PLAYLISTS.append(models.Playlist(playlist)) - - validate_images() + instances.playlist_instance.add_track_to_playlist(playlistid, track) def create_thumbnail(image: any, img_path: str) -> str: @@ -113,26 +96,47 @@ def save_p_image(file: datastructures.FileStorage, pid: str): return img_path, thumb_path -def validate_images(): +class ValidatePlaylistThumbs: """ Removes all unused images in the images/playlists folder. """ - images = [] - for playlist in api.PLAYLISTS: - if playlist.image: - img_path = playlist.image.split("/")[-1] - thumb_path = playlist.thumb.split("/")[-1] + def __init__(self) -> None: + images = [] + playlists = Get.get_all_playlists() - images.append(img_path) - images.append(thumb_path) + logg.info("Validating playlist thumbnails") + for playlist in playlists: + if playlist.image: + img_path = playlist.image.split("/")[-1] + thumb_path = playlist.thumb.split("/")[-1] - p_path = os.path.join(settings.APP_DIR, "images", "playlists") + images.append(img_path) + images.append(thumb_path) - for image in os.listdir(p_path): - if image not in images: - os.remove(os.path.join(p_path, image)) + p_path = os.path.join(settings.APP_DIR, "images", "playlists") + + for image in os.listdir(p_path): + if image not in images: + os.remove(os.path.join(p_path, image)) + + logg.info("Validating playlist thumbnails ... ✅") def create_new_date(): return datetime.now() + + +def create_playlist_tracks(playlist_tracks: List) -> List[models.Track]: + """ + Creates a list of model.Track objects from a list of playlist track dicts. + """ + tracks: List[models.Track] = [] + + for t in playlist_tracks: + track = trackslib.get_p_track(t) + + if track is not None: + tracks.append(models.Track(track)) + + return tracks diff --git a/server/app/lib/populate.py b/server/app/lib/populate.py index f2f7102f..c6ee9400 100644 --- a/server/app/lib/populate.py +++ b/server/app/lib/populate.py @@ -1,23 +1,18 @@ -import os import time from concurrent.futures import ThreadPoolExecutor -from copy import deepcopy -from multiprocessing import Pool -from os import path +from dataclasses import dataclass from typing import List -from app import api +from app import instances from app import settings from app.helpers import create_album_hash +from app.helpers import Get from app.helpers import run_fast_scandir -from app.instances import album_instance +from app.helpers import UseBisection from app.instances import tracks_instance -from app.lib import folderslib from app.lib.albumslib import create_album -from app.lib.albumslib import find_album from app.lib.taglib import get_tags -from app.lib.trackslib import find_track -from app.logger import Log +from app.logger import logg from app.models import Album from app.models import Track from tqdm import tqdm @@ -33,37 +28,14 @@ class Populate: """ def __init__(self) -> None: - self.files = [] self.db_tracks = [] self.tagged_tracks = [] - self.folders = set() - self.pre_albums = [] - self.albums: List[Album] = [] self.files = run_fast_scandir(settings.HOME_DIR, full=True)[1] self.db_tracks = tracks_instance.get_all_tracks() - self.tag_count = 0 - self.exist_count = 0 - def run(self): self.check_untagged() - self.get_all_tags() - - if len(self.tagged_tracks) == 0: - return - - self.tagged_tracks.sort(key=lambda x: x["albumhash"]) - self.tracks = deepcopy(self.tagged_tracks) - - self.pre_albums = self.create_pre_albums(self.tagged_tracks) - self.create_albums(self.pre_albums) - - self.albums.sort(key=lambda x: x.hash) - api.ALBUMS.sort(key=lambda x: x.hash) - - self.save_albums() - self.create_tracks() - # self.create_folders() + self.tag_untagged() def check_untagged(self): """ @@ -75,159 +47,111 @@ class Populate: if track["filepath"] in self.files: self.files.remove(track["filepath"]) - Log(f"Found {len(self.files)} untagged tracks") - - def process_tags(self, tags: dict): - for t in tags: - if t is None: - continue - - t["albumhash"] = create_album_hash(t["album"], t["albumartist"]) - self.tagged_tracks.append(t) - api.DB_TRACKS.append(t) - - self.folders.add(t["folder"]) - def get_tags(self, file: str): tags = get_tags(file) if tags is not None: - folder = tags["folder"] - self.folders.add(folder) - - tags["albumhash"] = create_album_hash(tags["album"], - tags["albumartist"]) + hash = create_album_hash(tags["album"], tags["albumartist"]) + tags["albumhash"] = hash self.tagged_tracks.append(tags) - api.DB_TRACKS.append(tags) - def get_all_tags(self): + def tag_untagged(self): """ Loops through all the untagged files and tags them. """ - s = time.time() - print(f"Started tagging files") + logg.info("Tagging untagged tracks...") with ThreadPoolExecutor() as executor: executor.map(self.get_tags, self.files) - # with Pool(maxtasksperchild=10, processes=10) as p: - # tags = p.map(get_tags, tqdm(self.files)) - # self.process_tags(tags) + if len(self.tagged_tracks) > 0: + tracks_instance.insert_many(self.tagged_tracks) - # for t in tqdm(self.files): - # self.get_tags(t) + logg.info(f"Tagged {len(self.tagged_tracks)} tracks.") - d = time.time() - s - Log(f"Tagged {len(self.tagged_tracks)} files in {d} seconds") + +@dataclass +class PreAlbum: + title: str + artist: str + hash: str + + +class CreateAlbums: + + def __init__(self) -> None: + self.db_tracks = Get.get_all_tracks() + self.db_albums = Get.get_all_albums() + + prealbums = self.create_pre_albums(self.db_tracks) + prealbums = self.filter_processed(self.db_albums, prealbums) + + albums = [] + + for album in tqdm(prealbums, desc="Creating albums"): + a = self.create_album(album) + if a is not None: + albums.append(a) + + # with ThreadPoolExecutor() as pool: + # iterator = pool.map(self.create_album, prealbums) + + # for i in iterator: + # if i is not None: + # albums.append(i) + + if len(albums) > 0: + instances.album_instance.insert_many(albums) @staticmethod - def create_pre_albums(tracks: List[dict]): - """ - Creates pre-albums for the all tagged tracks. - """ + def create_pre_albums(tracks: List[Track]) -> List[PreAlbum]: prealbums = [] - for track in tqdm(tracks, desc="Creating pre-albums"): - album = {"title": track["album"], "artist": track["albumartist"]} + for track in tqdm(tracks, desc="Creating prealbums"): + album = { + "title": track.album, + "artist": track.albumartist, + "hash": track.albumhash, + } + + album = PreAlbum(**album) if album not in prealbums: prealbums.append(album) - Log(f"Created {len(prealbums)} pre-albums") return prealbums - def create_album(self, album: dict): - albumhash = create_album_hash(album["title"], album["artist"]) - index = find_album(api.ALBUMS, albumhash) + @staticmethod + def filter_processed(albums: List[Album], + prealbums: List[PreAlbum]) -> List[dict]: + to_process = [] - if index is not None: - album = api.ALBUMS[index] - self.albums.append(album) + for p in tqdm(prealbums, desc="Filtering processed albums"): + album = UseBisection(albums, "hash", [p.hash])()[0] - self.exist_count += 1 - return + if album is None: + to_process.append(p) - self.albums.sort(key=lambda x: x.hash) - index = find_track(self.tagged_tracks, albumhash) + return to_process - if index is None: - return + def create_album(self, album: PreAlbum) -> Album: + hash = album.hash - track = self.tagged_tracks[index] + album = {"image": None} + iter = 0 - album = create_album(track, self.tagged_tracks) - - if album is None: - print("album is none") - return - - album = Album(album) - - api.ALBUMS.append(album) - self.albums.append(album) - - def create_albums(self, albums: List[dict]): - """ - Uses the pre-albums to create new albums and add them to the database. - """ - for album in tqdm(albums, desc="Building albums"): - self.create_album(album) - - Log(f"{self.exist_count} of {len(albums)} albums were already in the database" - ) - - def create_track(self, track: dict): - """ - Creates a single track object. - """ - - albumhash = track["albumhash"] - index = find_album(self.albums, albumhash) - - if index is None: - return + while album["image"] is None: + track = UseBisection(self.db_tracks, "albumhash", [hash])()[0] + if track is not None: + iter += 1 + album = create_album(track) + self.db_tracks.remove(track) + else: + album["image"] = hash + ".webp" try: - album: Album = self.albums[index] - except (TypeError): - """ - 😭😭😭 - """ - pass - - track["image"] = album.image - - upsert_id = tracks_instance.insert_song(track) - track["_id"] = {"$oid": str(upsert_id)} - - api.TRACKS.append(Track(track)) - - def create_tracks(self): - """ - Loops through all the tagged tracks creating complete track objects using the `models.Track` model. - """ - with ThreadPoolExecutor() as executor: - executor.map(self.create_track, self.tagged_tracks) - - Log(f"Added {len(self.tagged_tracks)} new tracks and {len(self.albums)} new albums" - ) - - def save_albums(self): - """ - Saves the albums to the database. - """ - - with ThreadPoolExecutor() as executor: - executor.map(album_instance.insert_album, self.albums) - - # def create_folders(self): - # """ - # Creates the folder objects for all the tracks. - # """ - # for folder in tqdm(self.folders, desc="Creating folders"): - # api.VALID_FOLDERS.add(folder) - - # fff = folderslib.create_folder(folder) - # api.FOLDERS.append(fff) - - # Log(f"Created {len(self.folders)} new folders") + album = Album(album) + return album + except KeyError: + print(f"📌 {iter}") + print(album) diff --git a/server/app/lib/searchlib.py b/server/app/lib/searchlib.py index 6b8ff223..adf77b1e 100644 --- a/server/app/lib/searchlib.py +++ b/server/app/lib/searchlib.py @@ -19,9 +19,10 @@ class Cutoff: Holds all the default cutoff values. """ - tracks: int = 70 - albums: int = 70 - artists: int = 70 + tracks: int = 80 + albums: int = 80 + artists: int = 80 + playlists: int = 80 class Limit: @@ -32,19 +33,21 @@ class Limit: tracks: int = 50 albums: int = 50 artists: int = 50 + playlists: int = 50 class SearchTracks: - def __init__(self, query) -> None: + def __init__(self, tracks: List[models.Track], query: str) -> None: self.query = query + self.tracks = tracks def __call__(self) -> List[models.Track]: """ Gets all songs with a given title. """ - tracks = [track.title for track in api.TRACKS] + tracks = [track.title for track in self.tracks] results = process.extract( self.query, tracks, @@ -53,39 +56,23 @@ class SearchTracks: limit=Limit.tracks, ) - return [api.TRACKS[i[2]] for i in results] + return [self.tracks[i[2]] for i in results] class SearchArtists: - def __init__(self, query) -> None: + def __init__(self, artists: set[str], query: str) -> None: self.query = query - - @staticmethod - def get_all_artist_names() -> List[str]: - """ - Gets all artist names. - """ - - artists = [track.artists for track in api.TRACKS] - - f_artists = set() - - for artist in artists: - for a in artist: - f_artists.add(a) - - return f_artists + self.artists = artists def __call__(self) -> list: """ Gets all artists with a given name. """ - artists = self.get_all_artist_names() results = process.extract( self.query, - artists, + self.artists, scorer=fuzz.WRatio, score_cutoff=Cutoff.artists, limit=Limit.artists, @@ -104,24 +91,17 @@ class SearchArtists: class SearchAlbums: - def __init__(self, query) -> None: + def __init__(self, albums: List[models.Album], query: str) -> None: self.query = query - - def get_albums_by_name(self) -> List[models.Album]: - """ - Gets all albums with a given title. - """ - - albums = [album.title for album in api.ALBUMS] - results = process.extract(self.query, albums) - return [api.ALBUMS[i[2]] for i in results] + self.albums = albums def __call__(self) -> List[models.Album]: """ Gets all albums with a given title. """ - albums = [a.title for a in api.ALBUMS] + albums = [a.title.lower() for a in self.albums] + results = process.extract( self.query, albums, @@ -130,7 +110,7 @@ class SearchAlbums: limit=Limit.albums, ) - return [api.ALBUMS[i[2]] for i in results] + return [self.albums[i[2]] for i in results] # get all artists that matched the query # for get all albums from the artists @@ -140,31 +120,20 @@ class SearchAlbums: # recheck next and previous artist on play next or add to playlist -class GetTopArtistTracks: +class SearchPlaylists: - def __init__(self, artist: str) -> None: - self.artist = artist + def __init__(self, playlists: List[models.Playlist], query: str) -> None: + self.playlists = playlists + self.query = query - def __call__(self) -> List[models.Track]: - """ - Gets all tracks from a given artist. - """ + def __call__(self) -> List[models.Playlist]: + playlists = [p.name for p in self.playlists] + results = process.extract( + self.query, + playlists, + scorer=fuzz.WRatio, + score_cutoff=Cutoff.playlists, + limit=Limit.playlists, + ) - return [track for track in api.TRACKS if self.artist in track.artists] - - -def get_search_albums(query: str) -> List[models.Album]: - """ - Gets all songs with a given album. - """ - return albumslib.search_albums_by_name(query) - - -def get_artists(artist: str) -> List[models.Track]: - """ - Gets all songs with a given artist. - """ - return [ - track for track in api.TRACKS - if artist.lower() in str(track.artists).lower() - ] + return [self.playlists[i[2]] for i in results] diff --git a/server/app/lib/taglib.py b/server/app/lib/taglib.py index 0106b2c3..4770035d 100644 --- a/server/app/lib/taglib.py +++ b/server/app/lib/taglib.py @@ -33,6 +33,7 @@ def extract_thumb(filepath: str, webp_path: str) -> bool: Extracts the thumbnail from an audio file. Returns the path to the thumbnail. """ img_path = os.path.join(settings.THUMBS_PATH, webp_path) + tsize = settings.THUMB_SIZE if os.path.exists(img_path): return True @@ -43,12 +44,12 @@ def extract_thumb(filepath: str, webp_path: str) -> bool: img = Image.open(BytesIO(album_art)) try: - small_img = img.resize((250, 250), Image.ANTIALIAS) + small_img = img.resize((tsize, tsize), Image.ANTIALIAS) small_img.save(img_path, format="webp") except OSError: try: png = img.convert("RGB") - small_img = png.resize((250, 250), Image.ANTIALIAS) + small_img = png.resize((tsize, tsize), Image.ANTIALIAS) small_img.save(webp_path, format="webp") except: return False @@ -135,8 +136,8 @@ def parse_track_number(tags): Parses the track number from an audio file. """ try: - track_number = tags["tracknumber"][0] - except (KeyError, IndexError): + track_number = int(tags["tracknumber"][0]) + except (KeyError, IndexError, ValueError): track_number = 1 return track_number @@ -147,8 +148,8 @@ def parse_disk_number(tags): Parses the disk number from an audio file. """ try: - disk_number = tags["disknumber"][0] - except (KeyError, IndexError): + disk_number = int(tags["disknumber"][0]) + except (KeyError, IndexError, ValueError): disk_number = 1 return disk_number diff --git a/server/app/lib/trackslib.py b/server/app/lib/trackslib.py index f580e6cf..61e39136 100644 --- a/server/app/lib/trackslib.py +++ b/server/app/lib/trackslib.py @@ -2,74 +2,24 @@ This library contains all the functions related to tracks. """ import os -from typing import List -from app import api from app import instances -from app import models -from app.helpers import remove_duplicates from tqdm import tqdm -def create_all_tracks() -> List[models.Track]: +def validate_tracks() -> None: """ Gets all songs under the ~/ directory. """ - tracks: list[models.Track] = [] + entries = instances.tracks_instance.get_all_tracks() - for track in tqdm(api.DB_TRACKS, desc="Creating tracks"): + for track in tqdm(entries, desc="Validating tracks"): try: os.chmod(track["filepath"], 0o755) except FileNotFoundError: instances.tracks_instance.remove_song_by_id(track["_id"]["$oid"]) - api.DB_TRACKS.remove(track) - - tracks.append(models.Track(track)) - - return tracks -def get_album_tracks(albumname, artist): - """Returns api tracks matching an album""" - _tracks: List[models.Track] = [] - - for track in api.TRACKS: - if track.album == albumname and track.albumartist == artist: - _tracks.append(track) - - return remove_duplicates(_tracks) - - -def get_track_by_id(trackid: str) -> models.Track: - """Returns api track matching an id""" - for track in api.TRACKS: - try: - if track.trackid == trackid: - return track - except AttributeError: - print("AttributeError") - print(track) - - -def find_track(tracks: list, hash: str) -> int or None: - """ - Finds an album by album title and artist. - """ - - left = 0 - right = len(tracks) - 1 - iter = 0 - - while left <= right: - iter += 1 - mid = (left + right) // 2 - - if tracks[mid]["albumhash"] == hash: - return mid - - if tracks[mid]["albumhash"] < hash: - left = mid + 1 - else: - right = mid - 1 - - return None +def get_p_track(ptrack): + return instances.tracks_instance.find_track_by_title_artists_album( + ptrack["title"], ptrack["artists"], ptrack["album"]) diff --git a/server/app/lib/watchdoge.py b/server/app/lib/watchdoge.py index d7496e2b..50731a6d 100644 --- a/server/app/lib/watchdoge.py +++ b/server/app/lib/watchdoge.py @@ -4,16 +4,15 @@ This library contains the classes and functions related to the watchdog file wat import os import time -from app import api from app import instances -from app import models -from app.helpers import UseBisection, create_album_hash -from app.lib.albumslib import create_album -from app.lib.albumslib import find_album +from app.helpers import create_album_hash from app.lib.taglib import get_tags +from app.logger import get_logger from watchdog.events import PatternMatchingEventHandler from watchdog.observers import Observer +log = get_logger() + class OnMyWatch: """ @@ -28,7 +27,12 @@ class OnMyWatch: def run(self): event_handler = Handler() self.observer.schedule(event_handler, self.directory, recursive=True) - self.observer.start() + + try: + self.observer.start() + except OSError: + log.error("Could not start watchdog.") + return try: while True: @@ -51,24 +55,7 @@ def add_track(filepath: str) -> None: if tags is not None: hash = create_album_hash(tags["album"], tags["albumartist"]) tags["albumhash"] = hash - api.DB_TRACKS.append(tags) - - albumindex = find_album(api.ALBUMS, hash) - - if albumindex is not None: - album = api.ALBUMS[albumindex] - else: - album_data = create_album(tags, api.DB_TRACKS) - album = models.Album(album_data) - - instances.album_instance.insert_album(album) - api.ALBUMS.append(album) - - tags["image"] = album.image - upsert_id = instances.tracks_instance.insert_song(tags) - tags["_id"] = {"$oid": str(upsert_id)} - - api.TRACKS.append(models.Track(tags)) + instances.tracks_instance.insert_song(tags) def remove_track(filepath: str) -> None: @@ -76,16 +63,7 @@ def remove_track(filepath: str) -> None: Removes a track from the music dict. """ - try: - trackid = instances.tracks_instance.get_song_by_path(filepath)["_id"]["$oid"] - except TypeError: - print(f"💙 Watchdog Error: Error removing track {filepath} TypeError") - return - - track = UseBisection(api.TRACKS, "trackid", [trackid])() - if track is not None: - api.TRACKS.remove(track[0]) - instances.tracks_instance.remove_song_by_id(trackid) + instances.tracks_instance.remove_song_by_filepath(filepath) class Handler(PatternMatchingEventHandler): @@ -148,7 +126,3 @@ class Handler(PatternMatchingEventHandler): watch = OnMyWatch() - -# TODO -# When removing a track, check if there are other tracks in the same album, -# if it was the last one, remove the album. \ No newline at end of file diff --git a/server/app/logger.py b/server/app/logger.py index ee844ccd..cd030110 100644 --- a/server/app/logger.py +++ b/server/app/logger.py @@ -1,8 +1,48 @@ -from app.settings import logger +import logging -class Log: +class CustomFormatter(logging.Formatter): - def __init__(self, msg): - if logger.enable: - print("\n🦋 " + msg + "\n") + grey = "\x1b[38;20m" + yellow = "\x1b[33;20m" + red = "\x1b[31;20m" + bold_red = "\x1b[31;1m" + reset = "\x1b[0m" + # format = ( + # "%(asctime)s - %(name)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)" + # ) + format = "[%(asctime)s] [%(levelname)s] [@%(name)s]ℹ️ %(message)s" + + FORMATS = { + logging.DEBUG: grey + format + reset, + logging.INFO: grey + format + reset, + logging.WARNING: yellow + format + reset, + logging.ERROR: red + format + reset, + logging.CRITICAL: bold_red + format + reset, + } + + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno) + formatter = logging.Formatter(log_fmt, "%H:%M:%S") + return formatter.format(record) + + +logg = logging.getLogger("ALICE_MUSIC_SERVER") +logg.setLevel(logging.DEBUG) + +# create console handler with a higher log level +ch = logging.StreamHandler() +ch.setLevel(logging.DEBUG) + +ch.setFormatter(CustomFormatter()) + +logg.addHandler(ch) + + +def get_logger(): + return logg + + +logg = get_logger() + +# copied from: https://stackoverflow.com/a/56944256: diff --git a/server/app/models.py b/server/app/models.py index f02e100d..974e4b36 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -1,11 +1,12 @@ """ Contains all the models for objects generation and typing. """ -from dataclasses import dataclass, field +import random +from dataclasses import dataclass +from dataclasses import field from typing import List -from app import api, helpers -from app.exceptions import TrackExistsInPlaylist +from app import helpers @dataclass(slots=True) @@ -16,7 +17,7 @@ class Track: trackid: str title: str - artists: list + artists: list[str] albumartist: str album: str folder: str @@ -24,18 +25,15 @@ class Track: length: int genre: str bitrate: int - image: str tracknumber: int disknumber: int albumhash: str + date: str + image: str + uniq_hash: str def __init__(self, tags): - try: - self.trackid = tags["_id"]["$oid"] - except KeyError: - print("No id") - print(tags) - + self.trackid = tags["_id"]["$oid"] self.title = tags["title"] self.artists = tags["artists"].split(", ") self.albumartist = tags["albumartist"] @@ -47,16 +45,17 @@ class Track: self.length = int(tags["length"]) self.disknumber = int(tags["disknumber"]) self.albumhash = tags["albumhash"] + self.date = tags["date"] + self.image = tags["albumhash"] + ".webp" + self.tracknumber = int(tags["tracknumber"]) - try: - self.image = tags["image"] - except KeyError: - print(tags) + self.uniq_hash = self.create_unique_hash("".join(self.artists), + self.album, self.title) - try: - self.tracknumber = int(tags["tracknumber"]) - except ValueError: - self.tracknumber = 1 + @staticmethod + def create_unique_hash(*args): + string = "".join(str(a) for a in args).replace(" ", "") + return "".join([i for i in string if i.isalnum()]).lower() @dataclass(slots=True) @@ -78,30 +77,32 @@ class Artist: @dataclass class Album: """ - Album class + Creates an album object """ title: str artist: str + hash: str date: int image: str - hash: str count: int = 0 duration: int = 0 is_soundtrack: bool = False is_compilation: bool = False is_single: bool = False + colors: List[str] = field(default_factory=list) def __init__(self, tags): self.title = tags["title"] self.artist = tags["artist"] self.date = tags["date"] self.image = tags["image"] + self.hash = tags["hash"] try: - self.hash = tags["albumhash"] + self.colors = tags["colors"] except KeyError: - self.hash = helpers.create_album_hash(self.title, self.artist) + self.colors = [] @property def is_soundtrack(self) -> bool: @@ -117,30 +118,6 @@ class Album: return self.artist.lower() == "various artists" -def get_p_track(ptrack): - for track in api.TRACKS: - if ( - track.title == ptrack["title"] - and track.artists == ptrack["artists"] - and ptrack["album"] == track.album - ): - return track - - -def create_playlist_tracks(playlist_tracks: List) -> List[Track]: - """ - Creates a list of model.Track objects from a list of playlist track dicts. - """ - tracks: List[Track] = [] - - for t in playlist_tracks: - track = get_p_track(t) - if track is not None: - tracks.append(track) - - return tracks - - @dataclass class Playlist: """Creates playlist objects""" @@ -148,7 +125,7 @@ class Playlist: playlistid: str name: str tracks: List[Track] - _pre_tracks: list = field(init=False, repr=False) + pretracks: list = field(init=False, repr=False) lastUpdated: int image: str thumb: str @@ -162,16 +139,10 @@ class Playlist: self.description = data["description"] self.image = self.create_img_link(data["image"]) self.thumb = self.create_img_link(data["thumb"]) - self._pre_tracks = data["pre_tracks"] + self.pretracks = data["pre_tracks"] self.tracks = [] self.lastUpdated = data["lastUpdated"] - self.count = len(self._pre_tracks) - - def get_tracks(self) -> List[Track]: - """ - Generates and returns Track objects from pre_tracks - """ - return create_playlist_tracks(self._pre_tracks) + self.count = len(self.pretracks) def create_img_link(self, image: str): if image: @@ -179,20 +150,6 @@ class Playlist: return "default.webp" - def update_count(self): - self.count = len(self._pre_tracks) - - def add_track(self, track): - if track not in self._pre_tracks: - self._pre_tracks.append(track) - self.update_count() - self.lastUpdated = helpers.create_new_date() - else: - raise TrackExistsInPlaylist("Track already exists in playlist") - - def update_desc(self, desc): - self.description = desc - def update_playlist(self, data: dict): self.name = data["name"] self.description = data["description"] diff --git a/server/app/prep.py b/server/app/prep.py index 55831a9b..2ddde835 100644 --- a/server/app/prep.py +++ b/server/app/prep.py @@ -2,10 +2,37 @@ Contains the functions to prepare the server for use. """ import os +import shutil from app import settings +class CopyFiles: + """Copies assets to the app directory.""" + + def __init__(self) -> None: + files = [{ + "src": "assets", + "dest": os.path.join(settings.APP_DIR, "assets"), + "is_dir": True, + }] + + for entry in files: + src = os.path.join(os.getcwd(), entry["src"]) + + if entry["is_dir"]: + shutil.copytree( + src, + entry["dest"], + ignore=shutil.ignore_patterns("*.pyc", ), + copy_function=shutil.copy2, + dirs_exist_ok=True, + ) + break + + shutil.copy2(src, entry["dest"]) + + def create_config_dir() -> None: """ Creates the config directory if it doesn't exist. @@ -29,3 +56,5 @@ def create_config_dir() -> None: if not exists: os.makedirs(path) os.chmod(path, 0o755) + + CopyFiles() diff --git a/server/app/serializer.py b/server/app/serializer.py index cfae04d5..cde4e22e 100644 --- a/server/app/serializer.py +++ b/server/app/serializer.py @@ -59,6 +59,7 @@ class Playlist: lastUpdated: int description: str count: int = 0 + duration: int = 0 def __init__(self, p: models.Playlist, @@ -72,7 +73,8 @@ class Playlist: self.count = p.count if construct_last_updated: - self.lastUpdated = self.l_updated(p.lastUpdated) + self.lastUpdated = self.get_l_updated(p.lastUpdated) - def l_updated(self, date: str) -> str: + @staticmethod + def get_l_updated(date: str) -> str: return date_string_to_time_passed(date) diff --git a/server/app/settings.py b/server/app/settings.py index 29029ae7..740f2c9a 100644 --- a/server/app/settings.py +++ b/server/app/settings.py @@ -1,18 +1,20 @@ """ Contains default configs """ -import os -from dataclasses import dataclass import multiprocessing - +import os # paths CONFIG_FOLDER = ".alice" HOME_DIR = os.path.expanduser("~") APP_DIR = os.path.join(HOME_DIR, CONFIG_FOLDER) -THUMBS_PATH = os.path.join(APP_DIR, "images", "thumbnails") -TEST_DIR = "/home/cwilvx/Music/Link to Music/Chill" -# URL +IMG_PATH = os.path.join(APP_DIR, "images") + +THUMBS_PATH = os.path.join(IMG_PATH, "thumbnails") +TEST_DIR = "/home/cwilvx/Music/Link to Music/Chill/Wolftyla Radio" +# HOME_DIR = TEST_DIR + +# URLS IMG_BASE_URI = "http://127.0.0.1:8900/images/" IMG_ARTIST_URI = IMG_BASE_URI + "artists/" IMG_THUMB_URI = IMG_BASE_URI + "thumbnails/" @@ -23,21 +25,11 @@ DEFAULT_ARTIST_IMG = IMG_ARTIST_URI + "0.webp" LAST_FM_API_KEY = "762db7a44a9e6fb5585661f5f2bdf23a" -P_COLORS = [ - "rgb(4, 40, 196)", - "rgb(196, 4, 68)", - "rgb(4, 99, 59)", - "rgb(161, 87, 1)", - "rgb(1, 161, 22)", - "rgb(116, 1, 161)", - "rgb(0, 0, 0)", - "rgb(95, 95, 95)", - "rgb(141, 132, 2)", - "rgb(141, 11, 2)", -] - CPU_COUNT = multiprocessing.cpu_count() -class logger: - enable = True +THUMB_SIZE: int = 400 +""" +The size of extracted in pixels +""" +LOGGER_ENABLE: bool = True diff --git a/server/assets/default.webp b/server/assets/default.webp new file mode 100644 index 00000000..8993fcf8 Binary files /dev/null and b/server/assets/default.webp differ diff --git a/server/setup/default-images/thumbnails/0.webp b/server/setup/default-images/thumbnails/0.webp deleted file mode 100644 index c0cc614a..00000000 Binary files a/server/setup/default-images/thumbnails/0.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/1.webp b/server/setup/default-images/thumbnails/1.webp deleted file mode 100644 index e2d20e3a..00000000 Binary files a/server/setup/default-images/thumbnails/1.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/10.webp b/server/setup/default-images/thumbnails/10.webp deleted file mode 100644 index 86f47262..00000000 Binary files a/server/setup/default-images/thumbnails/10.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/11.webp b/server/setup/default-images/thumbnails/11.webp deleted file mode 100644 index e287556e..00000000 Binary files a/server/setup/default-images/thumbnails/11.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/12.webp b/server/setup/default-images/thumbnails/12.webp deleted file mode 100644 index 340b5fd7..00000000 Binary files a/server/setup/default-images/thumbnails/12.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/13.webp b/server/setup/default-images/thumbnails/13.webp deleted file mode 100644 index 4bebe508..00000000 Binary files a/server/setup/default-images/thumbnails/13.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/14.webp b/server/setup/default-images/thumbnails/14.webp deleted file mode 100644 index 4f348c24..00000000 Binary files a/server/setup/default-images/thumbnails/14.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/15.webp b/server/setup/default-images/thumbnails/15.webp deleted file mode 100644 index 64f6df15..00000000 Binary files a/server/setup/default-images/thumbnails/15.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/16.webp b/server/setup/default-images/thumbnails/16.webp deleted file mode 100644 index 15c1797f..00000000 Binary files a/server/setup/default-images/thumbnails/16.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/17.webp b/server/setup/default-images/thumbnails/17.webp deleted file mode 100644 index d515661c..00000000 Binary files a/server/setup/default-images/thumbnails/17.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/18.webp b/server/setup/default-images/thumbnails/18.webp deleted file mode 100644 index 0ef1c032..00000000 Binary files a/server/setup/default-images/thumbnails/18.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/19.webp b/server/setup/default-images/thumbnails/19.webp deleted file mode 100644 index a3edbdd9..00000000 Binary files a/server/setup/default-images/thumbnails/19.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/2.webp b/server/setup/default-images/thumbnails/2.webp deleted file mode 100644 index 94e9e51c..00000000 Binary files a/server/setup/default-images/thumbnails/2.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/20.webp b/server/setup/default-images/thumbnails/20.webp deleted file mode 100644 index 2761f847..00000000 Binary files a/server/setup/default-images/thumbnails/20.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/3.webp b/server/setup/default-images/thumbnails/3.webp deleted file mode 100644 index 567b7828..00000000 Binary files a/server/setup/default-images/thumbnails/3.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/4.webp b/server/setup/default-images/thumbnails/4.webp deleted file mode 100644 index daeee325..00000000 Binary files a/server/setup/default-images/thumbnails/4.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/5.webp b/server/setup/default-images/thumbnails/5.webp deleted file mode 100644 index 36bb172c..00000000 Binary files a/server/setup/default-images/thumbnails/5.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/6.webp b/server/setup/default-images/thumbnails/6.webp deleted file mode 100644 index d1bb3042..00000000 Binary files a/server/setup/default-images/thumbnails/6.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/7.webp b/server/setup/default-images/thumbnails/7.webp deleted file mode 100644 index 8ffe570e..00000000 Binary files a/server/setup/default-images/thumbnails/7.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/8.webp b/server/setup/default-images/thumbnails/8.webp deleted file mode 100644 index 19b66c5b..00000000 Binary files a/server/setup/default-images/thumbnails/8.webp and /dev/null differ diff --git a/server/setup/default-images/thumbnails/9.webp b/server/setup/default-images/thumbnails/9.webp deleted file mode 100644 index 363a9ed3..00000000 Binary files a/server/setup/default-images/thumbnails/9.webp and /dev/null differ diff --git a/server/start.sh b/server/start.sh index 2bac5a6a..c6c0e46a 100755 --- a/server/start.sh +++ b/server/start.sh @@ -8,7 +8,7 @@ gpath=$(poetry run which gunicorn) while getopts ':s' opt; do case $opt in s) - echo "🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴🔴" + echo "Starting Alice server" cd "./app" "$gpath" -b 0.0.0.0:9877 -w 4 --threads=2 "imgserver:app" & cd ../ diff --git a/src/App.vue b/src/App.vue index 91ccae81..97012b67 100644 --- a/src/App.vue +++ b/src/App.vue @@ -36,6 +36,7 @@ import useQStore from "@/stores/queue"; import useShortcuts from "@/composables/useKeyboard"; import Logo from "@/components/Logo.vue"; import { useRouter } from "vue-router"; +import { onStartTyping } from "@vueuse/core"; const context_store = useContextStore(); const queue = useQStore(); @@ -53,6 +54,10 @@ app_dom.addEventListener("click", (e) => { useRouter().afterEach(() => { document.getElementById("acontent")?.scrollTo(0, 0); }); + +onStartTyping(() => { + document.getElementById("globalsearch").focus(); +}); \ No newline at end of file + diff --git a/src/components/AlbumView/Header.vue b/src/components/AlbumView/Header.vue index 22008810..8b3d0d93 100644 --- a/src/components/AlbumView/Header.vue +++ b/src/components/AlbumView/Header.vue @@ -1,16 +1,22 @@