Merge pull request #70 from geoffrey45/remove-in-memory-implementations

- Remove global lists and read everything from the database.  
- Restyle album page  
- Fix watchdog 
- Fix album counter 
- Show playlist duration 
- and much more.
This commit is contained in:
Mungai Geoffrey
2022-07-07 09:00:42 +03:00
committed by GitHub
112 changed files with 2795 additions and 1841 deletions
+3 -1
View File
@@ -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"
}
+1 -20
View File
@@ -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()
+42 -29
View File
@@ -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)
+33 -34
View File
@@ -10,49 +10,48 @@ from flask import Blueprint
artist_bp = Blueprint("artist", __name__, url_prefix="/")
# @artist_bp.route("/artist/<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/<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()
# }
+44 -36
View File
@@ -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/<playlist_id>/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/<playlistid>")
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/<playlistid>/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
+127 -49
View File
@@ -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,
}
+15 -17
View File
@@ -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")
+12
View File
@@ -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
+23 -4
View File
@@ -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
}},
)
+19 -12
View File
@@ -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:
"""
+73 -12
View File
@@ -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)
+41 -43
View File
@@ -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('<a href="https://www.last.fm/')[0]
bio = data["album"]["wiki"]["summary"].split(
'<a href="https://www.last.fm/')[0]
except KeyError:
bio = None
+80 -66
View File
@@ -2,16 +2,15 @@
This module contains mini functions for the server.
"""
import os
import random
import threading
from datetime import datetime
from typing import Dict
from typing import List
from typing import Set
import requests
from app import instances
from app import models
from app import settings
app_dir = settings.APP_DIR
def background(func):
@@ -51,36 +50,16 @@ def run_fast_scandir(__dir: str, full=False) -> 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
+19 -10
View File
@@ -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/<imgpath>")
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/<imgpath>")
@@ -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/<imgpath>")
@@ -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)
+89 -125
View File
@@ -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]
+23 -27
View File
@@ -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
+14 -74
View File
@@ -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
+52 -48
View File
@@ -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
+81 -157
View File
@@ -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)
+31 -62
View File
@@ -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]
+7 -6
View File
@@ -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
+6 -56
View File
@@ -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"])
+12 -38
View File
@@ -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.
+45 -5
View File
@@ -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:
+27 -70
View File
@@ -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"]
+29
View File
@@ -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()
+4 -2
View File
@@ -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)
+13 -21
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+1 -1
View File
@@ -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 ../
+5
View File
@@ -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();
});
</script>
<style lang="scss">
+6 -1
View File
@@ -39,13 +39,18 @@ $teal: rgb(64, 200, 224);
$primary: $gray4;
$accent: $darkblue;
$accent: $red;
$secondary: $gray5;
$cta: $blue;
$danger: $red;
$track-hover: $gray4;
$context: $gray5;
// SVG COLORS
$default: $accent;
$track-btn-svg: $red;
$side-nav-svg: $red;
// media query mixins
@mixin phone-only {
@media (max-width: 599px) {
+5 -4
View File
@@ -72,8 +72,11 @@ a {
"l-sidebar content r-sidebar"
"l-sidebar content r-sidebar"
"l-sidebar content tabs";
width: 100vw;
width: 100%;
align-content: center;
max-width: 2720px;
height: 100vh;
margin: 0 auto;
}
.tabs {
@@ -87,8 +90,6 @@ a {
#gsearch-input {
grid-area: search-input;
// border-left: solid 1px $gray3;
// border-bottom: 1px solid $gray3;
}
.topnav {
@@ -131,7 +132,7 @@ a {
#acontent {
grid-area: content;
width: 100%;
max-width: 1504px;
max-width: 1955px;
padding: $small;
padding-left: 0;
overflow: auto;
+1 -1
View File
@@ -1,3 +1,3 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.9912 22.7422C18.9746 22.7422 23.0879 18.6289 23.0879 13.6543C23.0879 8.67969 18.9658 4.56641 13.9824 4.56641C9.00781 4.56641 4.90332 8.67969 4.90332 13.6543C4.90332 18.6289 9.0166 22.7422 13.9912 22.7422ZM13.9912 20.9316C9.95703 20.9316 6.73145 17.6885 6.73145 13.6543C6.73145 9.62012 9.95703 6.38574 13.9824 6.38574C18.0166 6.38574 21.2598 9.62012 21.2686 13.6543C21.2773 17.6885 18.0254 20.9316 13.9912 20.9316ZM14 17.0996C15.9072 17.0996 17.4453 15.5615 17.4453 13.6455C17.4453 11.7471 15.9072 10.2002 14 10.2002C12.084 10.2002 10.5459 11.7471 10.5459 13.6455C10.5459 15.5615 12.084 17.0996 14 17.0996Z" fill="#F2F2F2"/>
<path d="M13.9912 22.7422C18.9746 22.7422 23.0879 18.6289 23.0879 13.6543C23.0879 8.67969 18.9658 4.56641 13.9824 4.56641C9.00781 4.56641 4.90332 8.67969 4.90332 13.6543C4.90332 18.6289 9.0166 22.7422 13.9912 22.7422ZM13.9912 20.9316C9.95703 20.9316 6.73145 17.6885 6.73145 13.6543C6.73145 9.62012 9.95703 6.38574 13.9824 6.38574C18.0166 6.38574 21.2598 9.62012 21.2686 13.6543C21.2773 17.6885 18.0254 20.9316 13.9912 20.9316ZM14 17.0996C15.9072 17.0996 17.4453 15.5615 17.4453 13.6455C17.4453 11.7471 15.9072 10.2002 14 10.2002C12.084 10.2002 10.5459 11.7471 10.5459 13.6455C10.5459 15.5615 12.084 17.0996 14 17.0996Z" fill="#fff"/>
</svg>

Before

Width:  |  Height:  |  Size: 740 B

After

Width:  |  Height:  |  Size: 737 B

+12
View File
@@ -0,0 +1,12 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_308_586)">
<path d="M23.1318 14.1377C25.5928 14.1377 27.6582 12.0811 27.6582 9.61133C27.6582 7.12402 25.6104 5.08496 23.1318 5.08496C20.6533 5.08496 18.6055 7.13281 18.6055 9.61133C18.6055 12.0986 20.6533 14.1377 23.1318 14.1377ZM6.81055 21.7666H21.3916C23.0879 21.7666 24.0723 20.7822 24.0723 18.9014V15.3066C23.5977 15.4121 22.8857 15.4209 22.3232 15.3066V18.752C22.3232 19.5957 21.875 20.0176 21.0664 20.0176H6.9248C6.11621 20.0176 5.66797 19.5957 5.66797 18.7432V12.0723H17.9551C17.709 11.5977 17.5244 11.0791 17.4365 10.5342H5.66797V8.33691C5.66797 7.53711 6.11621 7.11523 6.89844 7.11523H8.56836C9.19238 7.11523 9.56152 7.26465 10.0625 7.66895L10.5547 8.08203C11.1699 8.57422 11.6445 8.75 12.5674 8.75H17.4453C17.5156 8.12598 17.6562 7.58105 17.9551 6.99219H13.0332C12.4004 6.99219 12.0225 6.85156 11.5303 6.44727L11.0381 6.04297C10.4141 5.5332 9.95703 5.36621 9.03418 5.36621H6.54688C4.89453 5.36621 3.91895 6.33301 3.91895 8.1875V18.9014C3.91895 20.791 4.91211 21.7666 6.81055 21.7666ZM20.8643 10.2178C20.5391 10.2178 20.2578 9.92773 20.2578 9.61133C20.2578 9.28613 20.5391 9.00488 20.8643 9.00488H25.3994C25.7334 9.00488 26.0059 9.28613 26.0059 9.61133C26.0059 9.92773 25.7334 10.2178 25.3994 10.2178H20.8643Z" fill="#fff"/>
<path d="M26.7314 6.32392C28.484 8.0765 28.4777 10.9912 26.7376 12.7314C24.9788 14.4902 22.0827 14.4902 20.3239 12.7314C18.5713 10.9788 18.5713 8.08271 20.3301 6.32392C22.0827 4.57135 24.9788 4.57135 26.7314 6.32392Z" fill="#fff"/>
<path d="M21.4986 10.6246C21.2438 10.8794 21.2438 11.2896 21.5048 11.5506C21.7721 11.8178 22.1885 11.8241 22.4433 11.5693L23.9783 10.0342L24.662 9.27599V9.95962L24.6247 11.0783C24.6185 11.2461 24.6806 11.4201 24.8111 11.5382C25.0535 11.7806 25.4326 11.7992 25.6688 11.5506C25.793 11.4139 25.849 11.2585 25.8366 11.0721L25.7371 8.01438C25.7309 7.77201 25.6812 7.62285 25.5569 7.49856C25.4388 7.38047 25.2834 7.33697 25.0535 7.33076L21.9834 7.21889C21.8031 7.21268 21.6478 7.26861 21.5173 7.39912C21.2749 7.6415 21.2749 8.02681 21.5297 8.25676C21.6478 8.37484 21.8218 8.43699 21.9834 8.43699L23.1083 8.40592L23.7919 8.39349L23.0337 9.08955L21.4986 10.6246Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_308_586">
<rect width="28" height="28" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+7 -8
View File
@@ -8,10 +8,10 @@
</div>
</template>
<script>
export default {
props: ['bio'],
};
<script setup lang="ts">
defineProps<{
bio: string;
}>();
</script>
<style lang="scss">
@@ -48,7 +48,6 @@ export default {
width: 10rem;
}
.rect {
width: 10rem;
height: 10rem;
@@ -59,10 +58,10 @@ export default {
left: 7rem;
transform: rotate(45deg) translate(-1rem, -9rem);
z-index: 1;
transition: all .5s ease-in-out;
transition: all 0.5s ease-in-out;
&:hover {
transition: all .5s ease-in-out;
transition: all 0.5s ease-in-out;
}
}
@@ -86,4 +85,4 @@ export default {
}
}
}
</style>
</style>
+140 -44
View File
@@ -1,16 +1,22 @@
<template>
<div class="album-h" ref="albumheaderthing">
<div class="a-header rounded">
<div
class="a-header rounded"
:style="{
backgroundImage: `linear-gradient(
37deg, ${props.album.colors[0]}, ${props.album.colors[3]}
)`,
}"
>
<div class="art">
<div
class="image shadow-lg rounded"
:style="{
backgroundImage: `url(&quot;${imguri + album.image}&quot;)`,
}"
<img
:src="imguri + album.image"
alt=""
v-motion-slide-from-left
></div>
class="rounded shadow-lg"
/>
</div>
<div class="info">
<div class="info" :class="{ nocontrast: isLight() }">
<div class="top" v-motion-slide-from-top>
<div class="h">
<span v-if="album.is_soundtrack">Soundtrack</span>
@@ -18,7 +24,9 @@
<span v-else-if="album.is_single">Single</span>
<span v-else>Album</span>
</div>
<div class="title ellip">{{ album.title }}</div>
<div class="title ellip">
{{ album.title }}
</div>
</div>
<div class="bottom">
<div class="stats">
@@ -26,7 +34,11 @@
{{ formatSeconds(album.duration, true) }} {{ album.date }}
{{ album.artist }}
</div>
<PlayBtnRect :source="playSources.album" :store="useAlbumStore" />
<PlayBtnRect
:source="playSources.album"
:store="useAlbumStore"
:background="getButtonColor()"
/>
</div>
</div>
</div>
@@ -44,31 +56,133 @@ import { paths } from "../../config";
import { AlbumInfo } from "../../interfaces";
import PlayBtnRect from "../shared/PlayBtnRect.vue";
defineProps<{
const props = defineProps<{
album: AlbumInfo;
}>();
const emit = defineEmits<{
(event: "resetBottomPadding"): void;
}>();
const albumheaderthing = ref<HTMLElement>(null);
const imguri = paths.images.thumb;
const nav = useNavStore();
useVisibility(albumheaderthing, nav.toggleShowPlay);
/**
* Calls the `toggleShowPlay` method which toggles the play button in the nav.
* Emits the `resetBottomPadding` event to reset the album page content bottom padding.
*
* @param {boolean} state the new visibility state of the album page header.
*/
function handleVisibilityState(state: boolean) {
if (state) {
emit("resetBottomPadding");
}
nav.toggleShowPlay(state);
}
useVisibility(albumheaderthing, handleVisibilityState);
/**
* Returns `true` if the rgb color passed is light.
*
* @param {string} rgb The color to check whether it's light or dark.
* @returns {boolean} true if color is light, false if color is dark.
*/
function isLight(rgb: string = props.album.colors[0]): boolean {
if (rgb == null || undefined) return false;
const [r, g, b] = rgb.match(/\d+/g)!.map(Number);
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
return brightness > 170;
}
interface BtnColor {
color: string;
isDark: boolean;
}
/**
* Returns the first contrasting color in the album colors.
*
* @param {string[]} colors The album colors to choose from.
* @returns {BtnColor} A color to use as the play button background
*/
function getButtonColor(colors: string[] = props.album.colors): BtnColor {
const base_color = colors[0];
if (colors.length === 0) return { color: "#fff", isDark: true };
for (let i = 0; i < colors.length; i++) {
if (theyContrast(base_color, colors[i])) {
return {
color: colors[i],
isDark: isLight(colors[i]),
};
}
}
return {
color: "#fff",
isDark: true,
};
}
/**
* Returns the luminance of a color.
* @param r The red value of the color.
* @param g The green value of the color.
* @param b The blue value of the color.
*/
function luminance(r: any, g: any, b: any) {
let a = [r, g, b].map(function (v) {
v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
}
/**
* Returns a contrast ratio of `color1`:`color2`
* @param {string} color1 The first color
* @param {string} color2 The second color
*/
function contrast(color1: number[], color2: number[]): number {
let lum1 = luminance(color1[0], color1[1], color1[2]);
let lum2 = luminance(color2[0], color2[1], color2[2]);
let brightest = Math.max(lum1, lum2);
let darkest = Math.min(lum1, lum2);
return (brightest + 0.05) / (darkest + 0.05);
}
/**
* Converts a rgb color string to an array of the form: `[r, g, b]`
* @param rgb The color to convert
* @returns {number[]} The array representation of the color
*/
function rgbToArray(rgb: string): number[] {
return rgb.match(/\d+/g)!.map(Number);
}
/**
* Returns true if the `color2` contrast with `color1`.
* @param color1 The first color
* @param color2 The second color
*/
function theyContrast(color1: string, color2: string) {
return contrast(rgbToArray(color1), rgbToArray(color2)) > 3;
}
</script>
<style lang="scss">
.album-h {
height: 16rem;
}
.a-header {
display: grid;
grid-template-columns: 15rem 1fr;
grid-template-columns: max-content 1fr;
gap: 1rem;
padding: 1rem;
height: 100%;
background-color: $black;
background-color: #000000;
background-image: linear-gradient(37deg, $black 20%, $gray, $black 90%);
.art {
@@ -78,12 +192,17 @@ useVisibility(albumheaderthing, nav.toggleShowPlay);
display: flex;
align-items: flex-end;
.image {
width: 14rem;
height: 14rem;
img {
width: 15rem;
height: 15rem;
transition: all 0.2s ease-in-out;
}
}
.nocontrast {
color: $black;
}
.info {
width: 100%;
display: flex;
@@ -91,20 +210,14 @@ useVisibility(albumheaderthing, nav.toggleShowPlay);
justify-content: flex-end;
.top {
.h {
color: #ffffffcb;
}
.title {
font-size: 2.5rem;
font-weight: 600;
color: white;
text-transform: capitalize;
}
.artist {
font-size: 1.15rem;
color: #ffffffe0;
}
}
@@ -121,23 +234,6 @@ useVisibility(albumheaderthing, nav.toggleShowPlay);
font-size: 0.8rem;
margin-bottom: 0.75rem;
}
.play {
height: 2.5rem;
width: 6rem;
display: flex;
align-items: center;
background: $blue;
padding: $small;
cursor: pointer;
.icon {
height: 1.5rem;
width: 1.5rem;
margin-right: $small;
background: url(../../assets/icons/play.svg) no-repeat center/cover;
}
}
}
}
}
+18 -21
View File
@@ -1,33 +1,36 @@
<template>
<router-link
:to="{ name: 'FolderView', params: { path: props.folder.path } }"
>
<router-link :to="{ name: 'FolderView', params: { path: folder.path } }">
<div class="f-item">
<div class="icon image"></div>
<div class="icon">
<FolderSvg v-if="!folder.is_sym" />
<SymLinkSvg v-if="folder.is_sym" />
</div>
<div class="info">
<div class="f-item-text ellip">{{ props.folder.name }}</div>
<div class="f-item-text ellip">{{ folder.name }}</div>
<div class="separator no-border"></div>
<div class="f-item-count">{{ props.folder.trackcount }} tracks</div>
<div class="f-item-count">{{ folder.trackcount }} tracks</div>
</div>
</div>
</router-link>
</template>
<script setup lang="ts">
const props = defineProps({
folder: {
type: Object,
required: true,
},
});
import { Folder } from "@/interfaces";
import FolderSvg from "../../assets/icons/folder.svg";
import SymLinkSvg from "../../assets/icons/symlink.svg";
defineProps<{
folder: Folder;
}>();
</script>
<style lang="scss">
.f-container .f-item {
height: 5rem;
display: flex;
display: grid;
grid-template-columns: max-content 1fr;
padding-right: 1rem;
align-items: center;
position: relative;
background-color: $gray4;
transition: all 0.2s ease;
border-radius: 0.75rem;
@@ -37,16 +40,10 @@ const props = defineProps({
}
.icon {
background-image: url(../../assets/icons/folder.svg);
width: 2rem;
height: 1.5rem;
margin-right: 1rem;
margin-left: 1rem;
margin: 0 0.75rem;
}
.info {
width: 100%;
.f-item-count {
font-size: 0.8rem;
color: rgba(219, 217, 217, 0.63);
+29 -54
View File
@@ -1,22 +1,15 @@
<template>
<div class="folder">
<div class="table rounded" v-if="tracks.length">
<div class="thead">
<div class="index"></div>
<div class="track-header">Track</div>
<div class="artists-header">Artist</div>
<div class="album-header">Album</div>
<div class="duration-header">Duration</div>
</div>
<div class="songlist">
<SongItem
v-for="(track, index) in tracks"
v-for="track in getTracks()"
:key="track.trackid"
:song="track"
:index="index + 1"
:index="track.index"
@updateQueue="updateQueue"
:isPlaying="queue.playing"
:isCurrent="queue.current.trackid == track.trackid"
:isCurrent="queue.currentid == track.trackid"
/>
</div>
</div>
@@ -43,6 +36,7 @@ const props = defineProps<{
path?: string;
pname?: string;
playlistid?: string;
on_album_page?: boolean;
}>();
let route = useRoute().name;
@@ -53,21 +47,43 @@ let route = useRoute().name;
* @param track Track object
*/
function updateQueue(track: Track) {
const index = props.tracks.findIndex(
(t: Track) => t.trackid === track.trackid
);
switch (route) {
case "FolderView":
queue.playFromFolder(props.path, props.tracks);
queue.play(track);
queue.play(index);
break;
case "AlbumView":
queue.playFromAlbum(track.album, track.albumartist, props.tracks);
queue.play(track);
queue.play(index);
break;
case "PlaylistView":
queue.playFromPlaylist(props.pname, props.playlistid, props.tracks);
queue.play(track);
queue.play(index);
break;
}
}
function getTracks() {
if (props.on_album_page) {
let tracks = props.tracks.map((track, index) => {
track.index = track.tracknumber;
return track;
});
return tracks;
}
let tracks = props.tracks.map((track, index) => {
track.index = index + 1;
return track;
});
return tracks;
}
</script>
<style lang="scss">
@@ -97,47 +113,6 @@ function updateQueue(track: Track) {
}
}
.thead {
display: grid;
grid-template-columns: 1.5rem 1.5fr 1fr 1.5fr 0.25fr;
height: 2.5rem;
align-items: center;
text-transform: uppercase;
font-weight: bold;
color: $gray1;
gap: $small;
@include tablet-landscape {
grid-template-columns: 1.5rem 1.5fr 1fr 1.5fr;
}
@include tablet-portrait {
grid-template-columns: 1.5rem 1.5fr 1fr;
}
@include phone-only {
display: none;
}
.duration-header {
@include tablet-landscape {
display: none;
}
width: 6rem;
}
.album-header {
@include tablet-portrait {
display: none;
}
}
&::-webkit-scrollbar {
display: none;
}
}
.songlist {
scrollbar-width: none;
&::-webkit-scrollbar {
+14 -50
View File
@@ -7,7 +7,7 @@
>
<div class="nav-button" id="home-button" v-motion-slide-from-left-100>
<div class="in">
<div class="nav-icon image" :id="`${menu.name}-icon`"></div>
<component :is="menu.icon"></component>
<span>{{ menu.name }}</span>
</div>
</div>
@@ -16,39 +16,22 @@
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import PlaylistSvg from "../../assets/icons/playlist.svg";
import FolderSvg from "../../assets/icons/folder.svg";
const menus = [
{
name: "home",
route_name: "Home",
},
{
name: "albums",
route_name: "AlbumsView",
},
{
name: "artists",
route_name: "ArtistsView",
},
{
name: "playlists",
route_name: "Playlists",
icon: PlaylistSvg,
},
{
name: "folders",
route_name: "FolderView",
params: { path: "$home" },
},
{
name: "tags",
},
{
name: "settings",
route_name: "SettingsView",
icon: FolderSvg,
},
];
</script>
<style lang="scss">
@@ -84,36 +67,17 @@ const menus = [
background-size: 1.5rem;
}
}
}
#home-icon {
background-image: url(../../assets/icons/home.svg);
}
svg {
width: 2rem;
height: 2rem;
margin: 0 $small 0 $small;
border-radius: $small;
}
#albums-icon {
background-image: url(../../assets/icons/album.svg);
}
#artists-icon {
background-image: url(../../assets/icons/artist.svg);
}
#playlists-icon {
background-image: url(../../assets/icons/playlist.svg);
}
#mixes-icon {
background-image: url(../../assets/icons/mix.svg);
}
#folders-icon {
background-image: url(../../assets/icons/folder.svg);
}
#settings-icon {
background-image: url(../../assets/icons/settings.svg);
}
#tags-icon {
background-image: url(../../assets/icons/tag.svg);
}
svg > path {
fill: $accent;
}
}
</style>
+13 -8
View File
@@ -2,14 +2,19 @@
<div class="info">
<div class="desc">
<div>
<div class="art">
<div
class="l-image image rounded"
:style="{
backgroundImage: `url(&quot;${imguri + track.image}&quot;)`,
}"
></div>
</div>
<router-link
:to="{
name: 'AlbumView',
params: {
hash: track.albumhash,
},
}"
>
<div class="art">
<img :src="imguri + track.image" alt="" class="l-image rounded" />
</div>
</router-link>
<div id="bitrate">
<span v-if="track.bitrate > 1500">MASTER</span>
<span v-else-if="track.bitrate > 330">FLAC</span>
+47 -15
View File
@@ -1,10 +1,16 @@
<template>
<div class="l_ rounded">
<div class="headin">Now Playing</div>
<div class="button menu image rounded"></div>
<div
class="button menu rounded"
@click="showContextMenu"
:class="{ context_on: context_on }"
>
<MenuSvg />
</div>
<div class="separator no-border"></div>
<div>
<SongCard :track="queue.current" />
<SongCard :track="queue.tracks[queue.current]" />
<Progress />
<HotKeys />
</div>
@@ -16,8 +22,38 @@ import SongCard from "./SongCard.vue";
import HotKeys from "./NP/HotKeys.vue";
import Progress from "./NP/Progress.vue";
import useQStore from "../../stores/queue";
import MenuSvg from "../../assets/icons/more.svg";
import trackContext from "@/contexts/track_context";
import useContextStore from "@/stores/context";
import useModalStore from "@/stores/modal";
import useQueueStore from "@/stores/queue";
import { ContextSrc } from "@/composables/enums";
import { ref } from "vue";
const queue = useQStore();
const contextStore = useContextStore();
const context_on = ref(false);
const showContextMenu = (e: Event) => {
e.preventDefault();
e.stopPropagation();
const menus = trackContext(
queue.tracks[queue.current],
useModalStore,
useQueueStore
);
contextStore.showContextMenu(e, menus, ContextSrc.Track);
context_on.value = true;
contextStore.$subscribe((mutation, state) => {
if (!state.visible) {
context_on.value = false;
}
});
};
</script>
<style lang="scss">
.l_ {
@@ -49,31 +85,26 @@ const queue = useQStore();
}
.button {
height: 2rem;
width: 2rem;
position: absolute;
background-size: 1.5rem;
top: $small;
cursor: pointer;
transition: all 200ms;
display: flex;
align-items: center;
padding: $smaller;
&:hover {
background-color: $gray2;
background-color: $accent;
}
}
.context_on {
background-color: $accent;
}
.menu {
right: $small;
background-image: url("../../assets/icons/right-arrow.svg");
transform: rotate(90deg);
&:hover {
transform: rotate(0deg);
}
}
br {
height: 0rem;
}
.art {
@@ -102,6 +133,7 @@ const queue = useQStore();
.title {
font-weight: 900;
word-break: break-all;
}
.artists {
+37 -61
View File
@@ -3,81 +3,66 @@
<div class="header">
<div class="headin">Featured Artists</div>
<div class="xcontrols">
<div class="prev" @click="scrollLeft"></div>
<div class="next" @click="scrollRight"></div>
<div class="prev icon" @click="scrollLeft()"><ArrowSvg /></div>
<div class="next icon" @click="scrollRight()"><ArrowSvg /></div>
</div>
</div>
<div class="separator no-border"></div>
<div class="artists" ref="artists_dom">
<ArtistCard
v-for="artist in artists"
:key="artist"
:key="artist.image"
:artist="artist"
:color="'ffffff00'"
/>
</div>
</div>
</template>
<script>
<script setup lang="ts">
import { ref } from "@vue/reactivity";
import ArtistCard from "@/components/shared/ArtistCard.vue";
import { computed, reactive } from "vue";
import { Artist } from "@/interfaces";
import ArrowSvg from "../../assets/icons/right-arrow.svg";
export default {
props: ["artists"],
components: {
ArtistCard,
},
setup() {
const artists_dom = ref(null);
defineProps<{
artists: Artist[];
}>();
const scrollLeft = () => {
const dom = artists_dom.value;
dom.scrollBy({
left: -700,
behavior: "smooth",
});
};
const artists_dom = ref(null);
const scrollRight = () => {
const dom = artists_dom.value;
dom.scrollBy({
left: 700,
behavior: "smooth",
});
};
const scrollLeft = () => {
const dom = artists_dom.value;
dom.scrollBy({
left: -700,
behavior: "smooth",
});
};
return {
artists_dom,
scrollLeft,
scrollRight,
};
},
const scrollRight = () => {
const dom = artists_dom.value;
dom.scrollBy({
left: 700,
behavior: "smooth",
});
};
</script>
<style lang="scss">
.f-artists {
height: 14.5em;
width: calc(100%);
padding: $small;
padding-bottom: 0;
width: 100%;
padding: 0 $small;
border-radius: $small;
user-select: none;
background: linear-gradient(0deg, transparent, $black);
position: relative;
background-color: #ffffff00;
.header {
display: flex;
height: 2.5rem;
align-items: center;
position: relative;
.headin {
font-size: 1.5rem;
font-weight: 900;
// border: solid;
margin-left: $small;
}
}
@@ -85,40 +70,31 @@ export default {
.f-artists .xcontrols {
z-index: 1;
width: 5rem;
height: 2rem;
position: absolute;
top: 0;
right: 0;
display: flex;
justify-content: space-between;
&:hover {
z-index: 1;
}
.next {
background: url(../../assets/icons/right-arrow.svg) no-repeat center;
}
gap: 1rem;
.prev {
background: url(../../assets/icons/right-arrow.svg) no-repeat center;
transform: rotate(180deg);
}
.next,
.prev {
width: 2em;
height: 2em;
.icon {
border-radius: $small;
cursor: pointer;
transition: all 0.5s ease;
background-color: rgb(51, 51, 51);
}
padding: $smaller;
.next:hover,
.prev:hover {
background-color: $blue;
transition: all 0.5s ease;
svg {
display: flex;
}
&:hover {
background-color: $accent;
transition: all 0.5s ease;
}
}
}
+12 -3
View File
@@ -12,7 +12,14 @@
<div class="carddd">
<div class="info">
<div class="btns">
<PlayBtnRect :source="playSources.playlist" :store="usePStore" />
<PlayBtnRect
:source="playSources.playlist"
:store="usePStore"
:background="{
color: '#fff',
isDark: true,
}"
/>
<Option @showDropdown="showDropdown" :src="context.src" />
</div>
<div class="duration">
@@ -20,7 +27,8 @@
<span v-else-if="props.info.count == 1"
>{{ props.info.count }} Track</span
>
<span v-else>{{ props.info.count }} Tracks</span> 3 Hours
<span v-else>{{ props.info.count }} Tracks</span>
{{ formatSeconds(props.info.duration, true) }}
</div>
<div class="desc">
{{ props.info.description }}
@@ -51,6 +59,7 @@ import useContextStore from "../../stores/context";
import useModalStore from "../../stores/modal";
import Option from "../shared/Option.vue";
import PlayBtnRect from "../shared/PlayBtnRect.vue";
import { formatSeconds } from "@/composables/perks";
const imguri = paths.images.playlist;
const context = useContextStore();
@@ -77,7 +86,7 @@ function showDropdown(e: any) {
.p-header {
display: grid;
grid-template-columns: 1fr;
height: 16rem;
height: 17rem;
position: relative;
border-radius: 0.75rem;
color: $white;
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<div class="r-home">
<UpNext :next="queue.next" :playNext="queue.playNext" />
<UpNext :next="queue.tracks[queue.next]" :playNext="queue.playNext" />
<Recommendations />
</div>
</template>
+2 -3
View File
@@ -8,9 +8,8 @@
<div class="r-search" v-show="tabs.current === tabs.tabs.search">
<Search />
</div>
<div class="r-queue" v-show="tabs.current === tabs.tabs.queue">
<UpNext />
<Queue />
</div>
</div>
</div>
@@ -19,7 +18,7 @@
<script setup lang="ts">
import Search from "./Search/Main.vue";
import UpNext from "./Queue.vue";
import Queue from "./Queue.vue";
import DashBoard from "./Home/Main.vue";
import useTabStore from "../../stores/tabs";
+4 -4
View File
@@ -1,7 +1,7 @@
<template>
<div class="up-next">
<div class="r-grid">
<UpNext :next="queue.next" :playNext="queue.playNext" />
<UpNext :next="queue.tracks[queue.next]" :playNext="queue.playNext" />
<div class="scrollable-r border rounded">
<div
class="inner"
@@ -9,11 +9,11 @@
@mouseleave="setMouseOver(false)"
>
<TrackItem
v-for="t in queue.tracks"
v-for="(t, index) in queue.tracks"
:key="t.trackid"
:track="t"
@playThis="queue.play(t)"
:isCurrent="t.trackid === queue.current.trackid"
@playThis="queue.play(index)"
:isCurrent="index === queue.current"
:isPlaying="queue.playing"
/>
</div>
@@ -19,7 +19,7 @@ import useSearchStore from "../../../stores/search";
const search = useSearchStore();
function loadMore() {
search.updateLoadCounter("albums", 6);
search.updateLoadCounter("albums");
search.loadAlbums(search.loadCounter.albums);
}
</script>
@@ -19,7 +19,7 @@ import useSearchStore from "../../../stores/search";
const search = useSearchStore();
function loadMore() {
search.updateLoadCounter("artists", 6);
search.updateLoadCounter("artists");
search.loadArtists(search.loadCounter.artists);
}
</script>
@@ -0,0 +1,45 @@
<template>
<div class="albums-results border">
<div class="grid">
<PCard
v-for="album in search.albums.value"
:key="`${album.artist}-${album.title}`"
:album="album"
/>
</div>
<LoadMore v-if="search.albums.more" @loadMore="loadMore()" />
</div>
</template>
<script setup lang="ts">
import PCard from "../../playlists/PlaylistCard.vue";
import LoadMore from "./LoadMore.vue";
import useSearchStore from "../../../stores/search";
const search = useSearchStore();
function loadMore() {
search.updateLoadCounter("albums");
search.loadAlbums(search.loadCounter.albums);
}
</script>
<style lang="scss">
.right-search .albums-results {
border-radius: 0.5rem;
margin-top: $small;
padding: $small;
overflow-x: hidden;
.result-item:hover {
background-color: $gray4;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
flex-wrap: wrap;
gap: 0.75rem;
}
}
</style>
@@ -2,13 +2,13 @@
<div id="tracks-results" v-if="search.tracks.value">
<TransitionGroup name="list">
<TrackItem
v-for="track in search.tracks.value"
v-for="(track, index) in search.tracks.value"
:key="track.trackid"
:track="track"
:isPlaying="queue.playing"
:isCurrent="queue.current.trackid == track.trackid"
:isCurrent="queue.currentid == track.trackid"
:isSearchTrack="true"
@PlayThis="updateQueue"
@PlayThis="updateQueue(index)"
/>
</TransitionGroup>
<LoadMore v-if="search.tracks.more" @loadMore="loadMore" />
@@ -26,13 +26,13 @@ const queue = useQStore();
const search = useSearchStore();
function loadMore() {
search.updateLoadCounter("tracks", 5);
search.updateLoadCounter("tracks");
search.loadTracks(search.loadCounter.tracks);
}
function updateQueue(track: Track) {
function updateQueue(index: number) {
queue.playFromSearch(search.query, search.tracks.value);
queue.play(track);
queue.play(index);
}
</script>
+2 -1
View File
@@ -3,7 +3,7 @@
<div id="ginner" tabindex="0">
<div class="icon image"></div>
<input
id="search"
id="globalsearch"
class="rounded"
v-model="search.query"
placeholder="Search your library"
@@ -16,6 +16,7 @@
</template>
<script setup lang="ts">
import { ref } from "vue";
import useSearchStore from "../../stores/search";
const search = useSearchStore();
+5 -8
View File
@@ -23,7 +23,7 @@
v-for="option in context.options"
:key="option.label"
:class="[{ critical: option.critical }, option.type]"
@click="option.action"
@click="option.action()"
>
<div class="icon image" :class="option.icon"></div>
<div class="label ellip">{{ option.label }}</div>
@@ -32,7 +32,7 @@
<div
class="context-item"
v-for="child in option.children"
:key="child"
:key="child.label"
:class="[{ critical: child.critical }, child.type]"
@click="child.action()"
>
@@ -57,7 +57,7 @@ const context = useContextStore();
top: 0;
left: 0;
width: 12rem;
z-index: 10;
z-index: 10000 !important;
transform: scale(0);
padding: $small;
@@ -68,12 +68,10 @@ const context = useContextStore();
.context-item {
width: 100%;
display: flex;
justify-content: flex-start;
align-items: center;
cursor: default;
padding: $small;
border-radius: $small;
color: rgb(255, 255, 255);
position: relative;
text-transform: capitalize;
@@ -141,7 +139,7 @@ const context = useContextStore();
.children {
transform: scale(1);
transition: transform 0.2s ease-in-out;
transition: transform 0.1s ease-in-out;
}
}
}
@@ -160,7 +158,6 @@ const context = useContextStore();
.context-menu-visible {
transform: scale(1);
transition: transform 0.2s ease-in-out;
}
.context-normalizedX {
@@ -177,7 +174,7 @@ const context = useContextStore();
.context-normalizedY {
.context-item > .children {
transform-origin: bottom right;
top: -.5rem;
top: -0.5rem;
}
}
+5 -5
View File
@@ -18,7 +18,7 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { useRoute } from "vue-router";
import { createNewPlaylist } from "../../composables/playlists";
import { createNewPlaylist } from "../../composables/pages/playlists";
import { Track } from "../../interfaces";
import { Notification, NotifType } from "../../stores/notification";
import usePlaylistStore from "@/stores/pages/playlists";
@@ -43,22 +43,22 @@ emit("title", "New Playlist");
/**
* Create a new playlist. If this modal is called with a track,
* add the track to the new playlist.
* @param e Event
* @param {Event} e
*/
function create(e: Event) {
e.preventDefault();
const name = (e.target as HTMLFormElement).elements["name"].value;
if (name.trim()) {
createNewPlaylist(name, props.track).then((status) => {
createNewPlaylist(name, props.track).then(({ success, playlist }) => {
emit("hideModal");
if (!status.success) return;
if (!success) return;
if (route.name !== "Playlists") return;
setTimeout(() => {
playlistStore.addPlaylist(status.playlist);
playlistStore.addPlaylist(playlist);
}, 600);
});
} else {
+12 -4
View File
@@ -41,7 +41,13 @@
/>
</div>
<div class="submit">
<input type="submit" id="updateplaylistsubmit" class="rounded" value="Update" @click="" />
<input
type="submit"
id="updateplaylistsubmit"
class="rounded"
value="Update"
@click=""
/>
</div>
</form>
</template>
@@ -49,7 +55,7 @@
<script setup lang="ts">
import usePStore from "@/stores/pages/playlist";
import { onMounted } from "vue";
import { updatePlaylist } from "../../composables/playlists";
import { updatePlaylist } from "../../composables/pages/playlists";
import { Playlist } from "../../interfaces";
const pStore = usePStore();
@@ -105,8 +111,10 @@ function update_playlist(e: Event) {
if (!clicked) {
clicked = true;
const elem = document.getElementById("updateplaylistsubmit") as HTMLFormElement
elem.value = "Updating"
const elem = document.getElementById(
"updateplaylistsubmit"
) as HTMLFormElement;
elem.value = "Updating";
} else {
return;
}
+1 -7
View File
@@ -4,9 +4,6 @@
:playlist="props.playlist"
class="p-card rounded"
>
<div class="drop">
<Option :color="'#48484a'" />
</div>
<div
class="image p-image rounded shadow-sm"
:style="{
@@ -28,12 +25,9 @@
<script setup lang="ts">
import { Playlist } from "../../interfaces";
import PlayBtn from "../shared/PlayBtn.vue";
import Option from "../shared/Option.vue";
import { paths } from "../../config";
const imguri = paths.images.playlist
const imguri = paths.images.playlist;
const props = defineProps<{
playlist: Playlist;
+1 -1
View File
@@ -2,7 +2,7 @@
<router-link
:to="{
name: 'AlbumView',
params: { album: album.title, artist: album.artist },
params: { hash: album.hash },
}"
class="result-item"
>
+4 -3
View File
@@ -38,11 +38,11 @@ defineProps<{
cursor: pointer;
.artist-image {
width: 7em;
height: 7em;
width: 8em;
height: 8em;
border-radius: 60%;
margin-bottom: $small;
background-size: 7rem 7rem;
background-size: 8rem 8rem;
background-position: center;
background-repeat: no-repeat;
transition: all 0.5s ease-in-out;
@@ -63,6 +63,7 @@ defineProps<{
font-size: 0.9rem;
font-weight: 510;
max-width: 7rem;
text-transform: capitalize;
}
}
+20 -3
View File
@@ -2,8 +2,12 @@
<div
class="playbtnrect rounded"
@click="usePlayFrom(source, useQStore, store)"
:style="{
backgroundColor: background.color,
}"
:class="{ playbtnrectdark: background.isDark }"
>
<div class="icon image"></div>
<playBtnSvg />
<div class="text">Play</div>
</div>
</template>
@@ -11,13 +15,18 @@
<script setup lang="ts">
import { playSources } from "@/composables/enums";
import usePlayFrom from "@/composables/usePlayFrom";
import useFStore from "@/stores/folder";
import useFStore from "@/stores/pages/folder";
import useAStore from "@/stores/pages/album";
import usePStore from "@/stores/pages/playlist";
import useQStore from "@/stores/queue";
import playBtnSvg from "@/assets/icons/play.svg";
defineProps<{
source: playSources;
background?: {
color: string;
isDark?: boolean;
};
store:
| typeof useQStore
| typeof useFStore
@@ -34,8 +43,8 @@ defineProps<{
height: 2.5rem;
padding-left: 0.75rem;
cursor: pointer;
background: linear-gradient(34deg, $accent, $red);
user-select: none;
color: $white;
transition: all 0.5s ease-in-out;
.icon {
@@ -50,4 +59,12 @@ defineProps<{
}
}
}
.playbtnrectdark {
color: $black !important;
svg > path {
fill: $accent !important;
}
}
</style>
+93 -45
View File
@@ -7,13 +7,12 @@
>
<div class="index">{{ props.index }}</div>
<div class="flex">
<div
class="album-art image rounded"
:style="{
backgroundImage: `url(&quot;${imguri + props.song.image}&quot;`,
}"
@click="emitUpdate(props.song)"
>
<div @click="emitUpdate(props.song)" class="thumbnail">
<img
:src="imguri + props.song.image"
alt=""
class="album-art image rounded"
/>
<div
class="now-playing-track image"
v-if="props.isPlaying && props.isCurrent"
@@ -38,41 +37,52 @@
</div>
</div>
<router-link
class="song-album"
class="song-album ellip"
:to="{
name: 'AlbumView',
params: {
album: props.song.album,
artist: props.song.albumartist,
hash: props.song.albumhash,
},
}"
>
<div class="album ellip">
{{ props.song.album }}
</div>
{{ props.song.album }}
</router-link>
<div class="song-duration">
{{ formatSeconds(props.song.length) }}
<div class="text">{{ formatSeconds(props.song.length) }}</div>
</div>
<div
class="options-icon circular"
:class="{ options_button_clicked }"
@click="
(e) => {
showContextMenu(e);
options_button_clicked = true;
}
"
>
<OptionSvg />
</div>
</div>
</template>
<script setup lang="ts">
import { putCommas, formatSeconds } from "../../composables/perks";
import useContextStore from "../../stores/context";
import useModalStore from "../../stores/modal";
import useQueueStore from "../../stores/queue";
import { ContextSrc } from "../../composables/enums";
import { putCommas, formatSeconds } from "@/composables/perks";
import useContextStore from "@/stores/context";
import useModalStore from "@/stores/modal";
import useQueueStore from "@/stores/queue";
import { ContextSrc } from "@/composables/enums";
import OptionSvg from "@/assets/icons/more.svg";
import { ref } from "vue";
import trackContext from "../../contexts/track_context";
import { Track } from "../../interfaces";
import { paths } from "../../config";
import trackContext from "@/contexts/track_context";
import { Track } from "@/interfaces";
import { paths } from "@/config";
const contextStore = useContextStore();
const context_on = ref(false);
const imguri = paths.images.thumb;
const options_button_clicked = ref(false);
const showContextMenu = (e: Event) => {
e.preventDefault();
@@ -86,6 +96,7 @@ const showContextMenu = (e: Event) => {
contextStore.$subscribe((mutation, state) => {
if (!state.visible) {
context_on.value = false;
options_button_clicked.value = false;
}
});
};
@@ -110,44 +121,42 @@ function emitUpdate(track: Track) {
.songlist-item {
display: grid;
align-items: center;
grid-template-columns: 1.5rem 1.5fr 1fr 1.5fr 0.25fr;
grid-template-columns: 1.5rem 1.5fr 1fr 1.5fr 2rem 2.5rem;
height: 3.75rem;
text-align: left;
gap: $small;
user-select: none;
-moz-user-select: none;
.context {
position: fixed;
top: 0;
left: 0;
height: 45px;
width: 45px;
background-color: red;
}
@include tablet-landscape {
grid-template-columns: 1.5rem 1.5fr 1fr 1.5fr;
grid-template-columns: 1.5rem 1.5fr 1fr 1fr 2.5rem;
}
@include tablet-portrait {
grid-template-columns: 1.5rem 1.5fr 1fr;
grid-template-columns: 1.5rem 1.5fr 1fr 2.5rem;
}
&:hover {
background-color: $gray4;
.options-icon {
opacity: 1 !important;
}
}
.song-duration {
@include tablet-landscape {
display: none;
display: none !important;
}
}
.song-album {
.album {
cursor: pointer;
max-width: max-content;
word-break: break-all;
text-transform: capitalize;
max-width: max-content;
cursor: pointer;
&:hover {
text-decoration: underline;
}
@include tablet-portrait {
@@ -156,6 +165,8 @@ function emitUpdate(track: Track) {
}
.song-artists {
word-break: break-all;
.artist {
cursor: pointer;
}
@@ -179,26 +190,63 @@ function emitUpdate(track: Track) {
.song-duration {
font-size: 0.9rem;
width: 5rem !important;
text-align: left;
}
.options-icon {
opacity: 0;
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
width: 2rem;
margin-right: 1rem;
svg {
transition: all 0.2s ease-in;
transform: rotate(90deg);
stroke: $track-btn-svg;
circle {
fill: $track-btn-svg;
}
}
&:hover {
background-color: $gray5;
}
}
.options_button_clicked {
background-color: $gray5;
opacity: 1;
}
.flex {
position: relative;
padding-left: 4rem;
align-items: center;
.thumbnail {
margin-right: $small;
display: flex;
}
.album-art {
position: absolute;
left: $small;
width: 3rem;
height: 3rem;
margin-right: 1rem;
display: grid;
place-items: center;
cursor: pointer;
}
.now-playing-track {
position: absolute;
left: $small;
top: $small;
}
.title {
cursor: pointer;
word-break: break-all;
}
}
+8 -2
View File
@@ -74,11 +74,11 @@ const showContextMenu = (e: Event) => {
};
const emit = defineEmits<{
(e: "PlayThis", track: Track): void;
(e: "PlayThis"): void;
}>();
const playThis = (track: Track) => {
emit("PlayThis", track);
emit("PlayThis");
};
</script>
@@ -122,7 +122,13 @@ const playThis = (track: Track) => {
margin: 0 0.5rem 0 0;
background-image: url(../../assets/images/null.webp);
}
.title {
word-break: break-all;
}
.artist {
word-break: break-all;
font-size: small;
color: rgba(255, 255, 255, 0.637);
}
-65
View File
@@ -1,65 +0,0 @@
import axios, { AxiosError } from "axios";
import state from "./state";
import { AlbumInfo, Track } from "../interfaces";
const getAlbumTracks = async (album: string, artist: string) => {
let data = {
info: <AlbumInfo>{},
tracks: <Track[]>[],
};
await axios
.post(state.settings.uri + "/album/tracks", {
album: album,
artist: artist,
})
.then((res) => {
data.info = res.data.info;
data.tracks = res.data.songs;
})
.catch((err: AxiosError) => {
console.error(err);
});
return data;
};
const getAlbumArtists = async (album:string, artist:string) => {
let artists = [];
await axios
.post(state.settings.uri + "/album/artists", {
album: album,
artist: artist,
})
.then((res) => {
artists = res.data.artists;
})
.catch((err: AxiosError) => {
console.error(err);
});
return artists;
};
const getAlbumBio = async (album: string, albumartist: string) => {
let bio = null;
await axios
.post(state.settings.uri + "/album/bio", {
album: album,
albumartist: albumartist,
})
.then((res) => {
bio = res.data.bio;
})
.catch((err: AxiosError) => {
if (err.response.status === 404) {
bio = null;
}
});
return bio;
};
export { getAlbumTracks, getAlbumArtists, getAlbumBio };
-22
View File
@@ -1,22 +0,0 @@
import axios from "axios";
import { Folder, Track } from "../interfaces";
import state from "./state";
export default async function (path: string) {
let tracks = Array<Track>();
let folders = Array<Folder>();
await axios
.post(`${state.settings.uri}/folder`, {
folder: path,
})
.then((res) => {
tracks = res.data.tracks;
folders = res.data.folders;
})
.catch((err) => {
console.error(err);
});
return { tracks, folders };
}
View File
@@ -1,6 +1,6 @@
import { getElem } from "./perks";
export default (mouseX, mouseY) => {
export default (mouseX: number, mouseY: number) => {
const scope = getElem("app", "id");
const contextMenu = getElem("context-menu", "class");
// ? compute what is the mouse position relative to the container element
+68
View File
@@ -0,0 +1,68 @@
import state from "../state";
import { AlbumInfo, Track } from "../../interfaces";
import useAxios from "../useAxios";
import { NotifType, useNotifStore } from "@/stores/notification";
const getAlbumData = async (hash: string, ToastStore: typeof useNotifStore) => {
const url = state.settings.uri + "/album";
interface AlbumData {
info: AlbumInfo;
tracks: Track[];
}
const { data, status } = await useAxios({
url,
props: {
hash: hash,
},
});
if (status == 204) {
ToastStore().showNotification("Album not created yet!", NotifType.Error);
return {
info: {
album: "",
artist: "",
colors: []
},
tracks: [],
};
}
return data as AlbumData;
};
const getAlbumArtists = async (hash: string) => {
const { data, error } = await useAxios({
url: state.settings.uri + "/album/artists",
props: {
hash: hash,
},
});
if (error) {
console.error(error);
}
return data.artists;
};
const getAlbumBio = async (hash: string) => {
const { data, status } = await useAxios({
url: state.settings.uri + "/album/bio",
props: {
hash: hash,
},
});
if (data) {
return data.bio;
}
if (status == 404) {
return null;
}
};
export { getAlbumData as getAlbumTracks, getAlbumArtists, getAlbumBio };
+30
View File
@@ -0,0 +1,30 @@
import { Folder, Track } from "@/interfaces";
import state from "../state";
import useAxios from "../useAxios";
export default async function (path: string) {
interface FolderData {
tracks: Track[];
folders: Folder[];
}
const { data, error } = await useAxios({
url: `${state.settings.uri}/folder`,
props: {
folder: path,
},
});
if (error) {
console.error(error);
}
if (data) {
return data as FolderData;
}
return <FolderData>{
tracks: [],
folders: [],
};
}
+129
View File
@@ -0,0 +1,129 @@
import { Playlist, Track } from "../../interfaces";
import { Notification, NotifType } from "../../stores/notification";
import state from "../state";
import useAxios from "../useAxios";
/**
* Creates a new playlist on the server.
* @param playlist_name The name of the playlist to create.
*/
async function createNewPlaylist(playlist_name: string, track?: Track) {
const { data, status } = await useAxios({
url: state.settings.uri + "/playlist/new",
props: {
name: playlist_name,
},
});
if (status == 201) {
new Notification("✅ Playlist created successfullly!");
if (track) {
setTimeout(() => {
addTrackToPlaylist(data.playlist, track);
}, 1000);
}
return {
success: true,
playlist: data.playlist as Playlist,
};
}
new Notification("That playlist already exists", NotifType.Error);
return {
success: false,
playlist: <Playlist>{},
};
}
/**
* Fetches all playlists from the server.
* @returns {Promise<Playlist[]>} A promise that resolves to an array of playlists.
*/
async function getAllPlaylists(): Promise<Playlist[]> {
const { data, error } = await useAxios({
url: state.settings.uri + "/playlists",
get: true,
});
if (error) console.error(error);
if (data) {
return data.data;
}
return [];
}
async function addTrackToPlaylist(playlist: Playlist, track: Track) {
const uri = `${state.settings.uri}/playlist/${playlist.playlistid}/add`;
const { status } = await useAxios({
url: uri,
props: {
track: track.trackid,
},
});
if (status == 409) {
new Notification("Track already exists in playlist", NotifType.Info);
return;
}
new Notification(track.title + " added to " + playlist.name);
}
async function getPlaylist(pid: string) {
const uri = state.settings.uri + "/playlist/" + pid;
interface PlaylistData {
info: Playlist;
tracks: Track[];
}
const { data, error } = await useAxios({
url: uri,
get: true,
});
if (error) {
new Notification("Something funny happened!", NotifType.Error);
}
if (data) {
return data as PlaylistData;
}
return null;
}
async function updatePlaylist(pid: string, playlist: FormData, pStore: any) {
const uri = state.settings.uri + "/playlist/" + pid + "/update";
const { data, error } = await useAxios({
url: uri,
put: true,
props: playlist,
headers: {
"Content-Type": "multipart/form-data",
},
});
if (error) {
new Notification("Something funny happened!", NotifType.Error);
}
if (data) {
pStore.updatePInfo(data.data);
new Notification("Playlist updated!");
}
}
export {
createNewPlaylist,
getAllPlaylists,
addTrackToPlaylist,
getPlaylist,
updatePlaylist,
};
+5 -2
View File
@@ -37,9 +37,12 @@ function getElem(id: string, type: string) {
}
}
/**
* Converts seconds into minutes and hours.
* @param seconds The seconds to convert
* @param long Whether to provide the time in the long format
*/
function formatSeconds(seconds: number, long?: boolean) {
// check if there are arguments
const date = new Date(seconds * 1000);
const hh = date.getUTCHours();
-145
View File
@@ -1,145 +0,0 @@
import axios from "axios";
import { Playlist, Track } from "../interfaces";
import { Notification, NotifType } from "../stores/notification";
import state from "./state";
/**
* Creates a new playlist on the server.
* @param playlist_name The name of the playlist to create.
*/
async function createNewPlaylist(playlist_name: string, track?: Track) {
let status = {
success: false,
playlist: <Playlist>{},
};
await axios
.post(state.settings.uri + "/playlist/new", {
name: playlist_name,
})
.then((res) => {
new Notification("✅ Playlist created successfullly!");
if (track) {
setTimeout(() => {
addTrackToPlaylist(res.data.playlist, track);
}, 1000);
}
status.success = true;
status.playlist = res.data.playlist;
})
.catch((err) => {
if (err.response.status == 409) {
new Notification(
"That playlist already exists",
NotifType.Error
);
}
});
return status;
}
/**
* Fetches all playlists from the server.
* @returns {Promise<Playlist[]>} A promise that resolves to an array of playlists.
*/
async function getAllPlaylists(): Promise<Playlist[]> {
let playlists = <Playlist[]>[];
const newLocal = `${state.settings.uri}/playlists`;
await axios
.get(newLocal)
.then((res) => {
playlists = res.data.data;
})
.catch((err) => {
console.error(err);
});
return playlists;
}
async function addTrackToPlaylist(playlist: Playlist, track: Track) {
const uri = `${state.settings.uri}/playlist/${playlist.playlistid}/add`;
await axios
.post(uri, { track: track.trackid })
.then(() => {
new Notification(track.title + " added to " + playlist.name);
})
.catch((error) => {
if (error.response.status == 409) {
new Notification("Track already exists in playlist", NotifType.Info);
}
});
}
async function getPTracks(playlistid: string) {
const uri = state.settings.uri + "/playlist/" + playlistid;
let tracks: Track[] = [];
await axios
.get(uri)
.then((res) => {
tracks = res.data.data;
})
.catch((err) => {
new Notification("Something funny happened!", NotifType.Error);
throw new Error(err);
});
return tracks;
}
async function getPlaylist(pid: string) {
const uri = state.settings.uri + "/playlist/" + pid;
let playlist = {
info: {},
tracks: <Track[]>[],
};
await axios
.get(uri)
.then((res) => {
playlist.info = res.data.info;
playlist.tracks = res.data.tracks;
})
.catch((err) => {
new Notification("Something funny happened!", NotifType.Error);
throw new Error(err);
});
return playlist;
}
async function updatePlaylist(pid: string, playlist: FormData, pStore: any) {
const uri = state.settings.uri + "/playlist/" + pid + "/update";
await axios
.put(uri, playlist, {
headers: {
"Content-Type": "multipart/form-data",
},
})
.then((res) => {
pStore.updatePInfo(res.data.data);
new Notification("Playlist updated!");
})
.catch((err) => {
new Notification("Something funny happened!", NotifType.Error);
throw new Error(err);
});
}
export {
createNewPlaylist,
getAllPlaylists,
addTrackToPlaylist,
getPTracks,
getPlaylist,
updatePlaylist,
};
+3 -23
View File
@@ -9,29 +9,6 @@ const uris = {
artists: `${base_url}/artists?q=`,
};
async function search(query: string) {
state.loading.value = true;
const url = base_url + encodeURIComponent(query.trim());
const res = await fetch(url);
if (!res.ok) {
const message = `An error has occured: ${res.status}`;
throw new Error(message);
}
const data = await res.json();
state.loading.value = false;
return {
tracks: data.data[0],
albums: data.data[1],
artists: data.data[2],
};
}
async function searchTracks(query: string) {
const url = uris.tracks + encodeURIComponent(query.trim());
@@ -104,3 +81,6 @@ export {
loadMoreAlbums,
loadMoreArtists,
};
// TODO:
// Rewrite this module using `useAxios` hook
-45
View File
@@ -1,57 +1,12 @@
import { ref } from "@vue/reactivity";
import { reactive } from "vue";
import * as i from "../interfaces";
const search_query = ref("");
const queue = ref(
Array<i.Track>({
title: "Nothing played yet",
artists: ["... blah blah blah"],
image: "http://127.0.0.1:8900/images/thumbnails/4.webp",
trackid: "",
})
);
const folder_song_list = ref([]);
const folder_list = ref([]);
const current = ref(<i.Track>{
title: "Nothing played yet",
artists: ["... blah blah blah"],
image: "http://127.0.0.1:8900/images/thumbnails/4.webp",
trackid: "",
});
const prev = ref(<i.Track>{
title: "Nothing played yet",
artists: ["... blah blah blah"],
image: "http://127.0.0.1:8900/images/thumbnails/4.webp",
trackid: "",
});
const album = reactive({
tracklist: Array<i.Track>(),
info: <i.AlbumInfo>{},
artists: Array<i.Artist>(),
bio: "",
});
const loading = ref(false);
const is_playing = ref(false);
const settings = reactive({
uri: "http://127.0.0.1:9876",
});
export default {
search_query,
queue,
folder_song_list,
folder_list,
current,
prev,
loading,
is_playing,
album,
settings,
};
+32
View File
@@ -0,0 +1,32 @@
import { FetchProps } from "../interfaces";
import axios, { AxiosError, AxiosResponse } from "axios";
export default async (args: FetchProps) => {
let data: any = null;
let error: string = null;
let status: number = null;
function getAxios() {
if (args.get) {
return axios.get(args.url, args.props);
}
if (args.put) {
return axios.put(args.url, args.props, args.headers);
}
return axios.post(args.url, args.props);
}
await getAxios()
.then((res: AxiosResponse) => {
data = res.data;
status = res.status;
})
.catch((err: AxiosError) => {
error = err.message as string;
status = err.response.status as number;
});
return { data, error, status };
};
+5 -5
View File
@@ -1,4 +1,4 @@
import useQStore from "@/stores/queue"
import useQStore from "@/stores/queue";
let key_down_fired = false;
@@ -9,12 +9,12 @@ function focusSearchBox() {
}
export default function (queue: typeof useQStore) {
const q = queue()
window.addEventListener("keydown", (e: any) => {
let target = e.target;
const q = queue();
window.addEventListener("keydown", (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
let ctrlKey = e.ctrlKey;
function FocusedOnInput(target: any) {
function FocusedOnInput(target: HTMLElement) {
return target.tagName === "INPUT" || target.tagName === "TEXTAREA";
}
+3 -3
View File
@@ -25,14 +25,14 @@ export default function play(
const f = store();
useQueue.playFromFolder(f.path, f.tracks);
useQueue.play(f.tracks[0]);
useQueue.play();
break;
case playSources.album:
store = store as typeof album;
const a = store();
useQueue.playFromAlbum(a.info.title, a.info.artist, a.tracks);
useQueue.play(store().tracks[0]);
useQueue.play();
break;
case playSources.playlist:
store = store as typeof playlist;
@@ -41,7 +41,7 @@ export default function play(
if (p.tracks.length === 0) return;
useQueue.playFromPlaylist(p.info.name, p.info.playlistid, p.tracks);
useQueue.play(store().tracks[0]);
useQueue.play();
break;
}
}
+7 -4
View File
@@ -1,7 +1,10 @@
import { Playlist, Track } from "../interfaces";
import Router from "../router";
import { Option } from "../interfaces";
import { getAllPlaylists, addTrackToPlaylist } from "../composables/playlists";
import {
getAllPlaylists,
addTrackToPlaylist,
} from "../composables/pages/playlists";
import useQueueStore from "../stores/queue";
import useModalStore from "../stores/modal";
@@ -15,7 +18,7 @@ import useModalStore from "../stores/modal";
export default async (
track: Track,
modalStore: typeof useModalStore,
QueueStore: typeof useQueueStore,
QueueStore: typeof useQueueStore
): Promise<Option[]> => {
const separator: Option = {
type: "separator",
@@ -79,7 +82,7 @@ export default async (
QueueStore().playTrackNext(track);
},
icon: "add_to_queue",
}
};
const go_to_folder: Option = {
label: "Go to Folder",
@@ -114,7 +117,7 @@ export default async (
action: () => {
Router.push({
name: "AlbumView",
params: { album: track.album, artist: track.albumartist },
params: { hash: track.albumhash },
});
},
icon: "album",
+27 -27
View File
@@ -1,11 +1,12 @@
import { FromOptions, NotifType } from "./composables/enums";
interface Track {
export interface Track {
trackid: string;
title: string;
album?: string;
artists: string[];
albumartist?: string;
albumhash?: string;
folder?: string;
filepath?: string;
length?: number;
@@ -14,16 +15,19 @@ interface Track {
image: string;
tracknumber?: number;
disknumber?: number;
index?: number;
}
interface Folder {
export interface Folder {
name: string;
path: string;
trackcount: number;
subdircount: number;
is_sym: boolean;
}
interface AlbumInfo {
export interface AlbumInfo {
albumid: string;
title: string;
artist: string;
count: number;
@@ -33,23 +37,25 @@ interface AlbumInfo {
is_compilation: boolean;
is_soundtrack: boolean;
is_single: boolean;
hash: string;
colors: string[];
}
interface Artist {
export interface Artist {
name: string;
image: string;
}
interface Option {
export interface Option {
type?: string;
label?: string;
action?: Function;
action?: () => void;
children?: Option[] | false;
icon?: string;
critical?: Boolean;
}
interface Playlist {
export interface Playlist {
playlistid: string;
name: string;
description?: string;
@@ -58,51 +64,45 @@ interface Playlist {
count?: number;
lastUpdated?: string;
thumb?: string;
duration?: number
}
interface Notif {
export interface Notif {
text: string;
type: NotifType;
}
interface fromFolder {
export interface fromFolder {
type: FromOptions;
path: string;
name: string;
}
interface fromAlbum {
export interface fromAlbum {
type: FromOptions;
name: string;
albumartist: string;
}
interface fromPlaylist {
export interface fromPlaylist {
type: FromOptions;
name: string;
playlistid: string;
}
interface fromSearch {
export interface fromSearch {
type: FromOptions;
query: string;
}
interface subPath {
export interface subPath {
name: string;
path: string;
active: boolean;
}
export {
Track,
Folder,
AlbumInfo,
Artist,
Option,
Playlist,
Notif,
fromFolder,
fromAlbum,
fromPlaylist,
fromSearch,
subPath,
};
export interface FetchProps {
url: string;
props?: {};
get?: boolean;
put?: boolean;
headers?: {};
}
+13 -19
View File
@@ -1,20 +1,17 @@
import { createRouter, createWebHashHistory } from "vue-router";
import Home from "@/views/Home.vue";
import FolderView from "@/views/FolderView.vue";
import PlaylistView from "@/views/PlaylistView.vue";
import Playlists from "@/views/Playlists.vue";
import state from "@/composables/state";
import useAStore from "@/stores/pages/album";
import useFStore from "@/stores/pages/folder";
import usePTrackStore from "@/stores/pages/playlist";
import usePStore from "@/stores/pages/playlists";
import AlbumsExplorer from "@/views/AlbumsExplorer.vue";
import AlbumView from "@/views/AlbumView.vue";
import ArtistsExplorer from "@/views/ArtistsExplorer.vue";
import FolderView from "@/views/FolderView.vue";
import Home from "@/views/Home.vue";
import Playlists from "@/views/Playlists.vue";
import PlaylistView from "@/views/PlaylistView.vue";
import SettingsView from "@/views/SettingsView.vue";
import usePStore from "@/stores/pages/playlists";
import usePTrackStore from "@/stores/pages/playlist";
import useFStore from "@/stores/pages/folder";
import useAStore from "@/stores/pages/album";
import state from "@/composables/state";
import { createRouter, createWebHashHistory } from "vue-router";
const routes = [
{
@@ -62,17 +59,14 @@ const routes = [
component: AlbumsExplorer,
},
{
path: "/albums/:album/:artist",
path: "/albums/:hash",
name: "AlbumView",
component: AlbumView,
beforeEnter: async (to) => {
state.loading.value = true;
await useAStore().fetchTracksAndArtists(
to.params.album,
to.params.artist
);
await useAStore().fetchTracksAndArtists(to.params.hash);
state.loading.value = false;
useAStore().fetchBio(to.params.album, to.params.artist);
useAStore().fetchBio(to.params.hash);
},
},
{
+20 -11
View File
@@ -1,10 +1,21 @@
import { defineStore } from "pinia";
import { useNotifStore } from "../notification";
import { Track, Artist, AlbumInfo } from "../../interfaces";
import {
getAlbumTracks,
getAlbumArtists,
getAlbumBio,
} from "../../composables/album";
} from "../../composables/pages/album";
function sortTracks(tracks: Track[]) {
return tracks.sort((a, b) => {
if (a.tracknumber && b.tracknumber) {
return a.tracknumber - b.tracknumber;
}
return 0;
});
}
export default defineStore("album", {
state: () => ({
@@ -17,25 +28,23 @@ export default defineStore("album", {
/**
* Fetches a single album information, artists and its tracks from the server
* using the title and album-artist of the album.
* @param title title of the album
* @param albumartist artist of the album
* @param hash title of the album
*/
async fetchTracksAndArtists(title: string, albumartist: string) {
const tracks = await getAlbumTracks(title, albumartist);
const artists = await getAlbumArtists(title, albumartist);
async fetchTracksAndArtists(hash: string) {
const tracks = await getAlbumTracks(hash, useNotifStore);
const artists = await getAlbumArtists(hash);
this.tracks = tracks.tracks;
this.tracks = sortTracks(tracks.tracks);
this.info = tracks.info;
this.artists = artists;
},
/**
* Fetches the album bio from the server
* @param title title of the album
* @param albumartist artist of the album
* @param {string} hash title of the album
*/
fetchBio(title: string, albumartist: string) {
fetchBio(hash: string) {
this.bio = null;
getAlbumBio(title, albumartist).then((bio) => {
getAlbumBio(hash).then((bio) => {
this.bio = bio;
});
},

Some files were not shown because too many files have changed in this diff Show More