combine userdata and swing db into one

+ port populate to new db interface
+ add genrehashes and hash info to tracks
+ properly structure new db table files
+ move helpers to dedicated utils file
+ move settings from db to config file
+ move artists, albums, auth and favorites endpoint to new db interface
+ use folder store to index filepaths
+ paginate favorite pages
+ 56 moretiny changes 😅
This commit is contained in:
cwilvx
2024-06-30 15:06:33 +03:00
parent 1a66194c6c
commit 4a9f804e70
53 changed files with 1719 additions and 1353 deletions
+5
View File
@@ -25,3 +25,8 @@ interface Genre {
} }
``` ```
- Pairing via QR Code has been split into 2 endpoint:
1. `/getpaircode`
2. `/pair`
-
+26 -9
View File
@@ -1,11 +1,13 @@
# TODO # TODO
- Migrations: - Migrations:
1. Move userdata to new hashing algorithm
- favorites ✅ 1. Move userdata to new hashing algorithm
- playlists - favorites ✅
- scrobble - playlists
- images - scrobble
- remove image colors - images
- remove image colors
- Package jsoni and publish on PyPi - Package jsoni and publish on PyPi
- Rewrite stores to use dictionaries instead of list pools - Rewrite stores to use dictionaries instead of list pools
@@ -16,15 +18,30 @@
- Recreate album hash if featured artists are discover - Recreate album hash if featured artists are discover
- Implement checking if is clean install and skip migrations! - Implement checking if is clean install and skip migrations!
<!-- CHECKPOINT --> <!-- CHECKPOINT -->
<!-- ALBUM PAGE! --> <!-- ALBUM PAGE! -->
# DONE # DONE
- Support auth headers - Support auth headers
- Add recently played playlist - Add recently played playlist
- Move user track logs to user zero - Move user track logs to user zero
- Move future logs to appropriate user id - Move future logs to appropriate user id
- Store (and read) from the correct user account: - Store (and read) from the correct user account:
1. Playlists 1. Playlists
2. Favorites 2. Favorites
# THE BIG ONE
- Updating settings
- Cleaning out commented code
- Watchdog
- Periodic scans
- Remove legacy db methods
- Remove all stores
- Review: We don't need server side image colors
- Clean up main db and userdata modules
- Move plugins to a config file
- What about our migrations?
- Add userid to queries
- Remove duplicates on artist page (test with Hanson)
+5 -3
View File
@@ -11,9 +11,9 @@ from flask_openapi3 import OpenAPI
from flask_jwt_extended import JWTManager from flask_jwt_extended import JWTManager
from app.config import UserConfig from app.config import UserConfig
from app.db.userdata import UserTable
from app.settings import Info as AppInfo from app.settings import Info as AppInfo
from .plugins import lyrics as lyrics_plugin from .plugins import lyrics as lyrics_plugin
from app.db.sqlite.auth import SQLiteAuthMethods as authdb
from app.api import ( from app.api import (
album, album,
artist, artist,
@@ -92,8 +92,10 @@ def create_api():
def user_lookup_callback(_jwt_header, jwt_data): def user_lookup_callback(_jwt_header, jwt_data):
identity = jwt_data["sub"] identity = jwt_data["sub"]
userid = identity["id"] userid = identity["id"]
user = authdb.get_user_by_id(userid) user = UserTable.get_by_id(userid)
return user.todict()
if user:
return user.todict()
# Register all the API blueprints # Register all the API blueprints
with app.app_context(): with app.app_context():
+27 -40
View File
@@ -12,15 +12,14 @@ from flask_openapi3 import APIBlueprint
from app.api.apischemas import AlbumHashSchema, AlbumLimitSchema, ArtistHashSchema from app.api.apischemas import AlbumHashSchema, AlbumLimitSchema, ArtistHashSchema
from app.config import UserConfig from app.config import UserConfig
from app.db import AlbumTable as AlbumDb, TrackTable as TrackDb from app.db.libdata import ArtistTable
from app.db.libdata import AlbumTable as AlbumDb, TrackTable as TrackDb
from app.db.userdata import SimilarArtistTable
from app.settings import Defaults from app.settings import Defaults
from app.models import FavType, Track
from app.store.albums import AlbumStore
from app.store.tracks import TrackStore
from app.utils.hashing import create_hash from app.utils.hashing import create_hash
from app.lib.albumslib import sort_by_track_no from app.lib.albumslib import sort_by_track_no
from app.serializers.album import serialize_for_card, serialize_for_card_many from app.serializers.album import serialize_for_card, serialize_for_card_many
from app.serializers.track import serialize_track from app.serializers.track import serialize_track, serialize_tracks
from app.db.sqlite.albumcolors import SQLiteAlbumMethods as adb from app.db.sqlite.albumcolors import SQLiteAlbumMethods as adb
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.db.sqlite.lastfm.similar_artists import SQLiteLastFMSimilarArtists as lastfmdb from app.db.sqlite.lastfm.similar_artists import SQLiteLastFMSimilarArtists as lastfmdb
@@ -52,9 +51,22 @@ def get_album_tracks_and_info(body: AlbumHashSchema):
album.type = album.check_type( album.type = album.check_type(
tracks=tracks, singleTrackAsSingle=UserConfig().showAlbumsAsSingles tracks=tracks, singleTrackAsSingle=UserConfig().showAlbumsAsSingles
) )
album.populate_versions()
return {"info": album, "tracks": tracks} track_total = sum({int(t.extra.get("track_total", 1) or 1) for t in tracks})
return {
"info": album,
"extra": {
# INFO: track_total is the sum of a set of track_total values from each track
# ASSUMPTIONS
# 1. All the tracks have the correct track totals
# 2. Tracks with the same track total are from the same disc
"track_total": track_total,
"avg_bitrate": sum(t.bitrate for t in tracks) // len(tracks),
},
"copyright": tracks[0].copyright,
"tracks": serialize_tracks(tracks, remove_disc=False),
}
@api.get("/<albumhash>/tracks") @api.get("/<albumhash>/tracks")
@@ -68,7 +80,7 @@ def get_album_tracks(path: AlbumHashSchema):
tracks = TrackDb.get_tracks_by_albumhash(path.albumhash) tracks = TrackDb.get_tracks_by_albumhash(path.albumhash)
tracks = sort_by_track_no(tracks) tracks = sort_by_track_no(tracks)
return tracks return serialize_tracks(tracks)
class GetMoreFromArtistsBody(AlbumLimitSchema): class GetMoreFromArtistsBody(AlbumLimitSchema):
@@ -138,30 +150,14 @@ def get_album_versions(body: GetAlbumVersionsBody):
artisthash = body.artisthash artisthash = body.artisthash
albums = AlbumDb.get_albums_by_base_title(base_title) albums = AlbumDb.get_albums_by_base_title(base_title)
print(albums)
albums = [ albums = [
a a
for a in albums for a in albums
if a.og_title != og_album_title if a.og_title != og_album_title
and artisthash in {a["artisthash"] for a in a.albumartists} and artisthash in {a["artisthash"] for a in a.albumartists}
] ]
print(albums) print(albums)
return serialize_for_card_many(albums)
# albums = AlbumStore.get_albums_by_artisthash(artisthash)
# albums = [
# a
# for a in albums
# if create_hash(a.base_title) == create_hash(base_title)
# and create_hash(og_album_title) != create_hash(a.og_title)
# ]
# for a in albums:
# tracks = TrackStore.get_tracks_by_albumhash(a.albumhash)
# a.get_date_from_tracks(tracks)
return albums
class GetSimilarAlbumsQuery(ArtistHashSchema, AlbumLimitSchema): class GetSimilarAlbumsQuery(ArtistHashSchema, AlbumLimitSchema):
@@ -178,24 +174,15 @@ def get_similar_albums(query: GetSimilarAlbumsQuery):
artisthash = query.artisthash artisthash = query.artisthash
limit = query.limit limit = query.limit
similar_artists = lastfmdb.get_similar_artists_for(artisthash) similar_artists = SimilarArtistTable.get_by_hash(artisthash)
if similar_artists is None: if similar_artists is None:
return {"albums": []} return []
artisthashes = similar_artists.get_artist_hash_set() artisthashes = similar_artists.get_artist_hash_set()
artists = ArtistTable.get_artists_by_artisthashes(artisthashes)
if len(artisthashes) == 0: albums = AlbumDb.get_albums_by_artisthashes([a.artisthash for a in artists])
return {"albums": []} sample = random.sample(albums, min(len(albums), limit))
albums = [AlbumStore.get_albums_by_artisthash(a) for a in artisthashes] return serialize_for_card_many(sample[:limit])
albums = [a for sublist in albums for a in sublist]
albums = list({a.albumhash: a for a in albums}.values())
try:
albums = random.sample(albums, min(len(albums), limit))
except ValueError:
pass
return [serialize_for_card(a) for a in albums[:limit]]
+12 -91
View File
@@ -2,12 +2,11 @@
Contains all the artist(s) routes. Contains all the artist(s) routes.
""" """
from itertools import groupby
import math import math
import random import random
from datetime import datetime from datetime import datetime
from itertools import groupby
from flask_jwt_extended import current_user
from flask_openapi3 import APIBlueprint, Tag from flask_openapi3 import APIBlueprint, Tag
from pydantic import Field from pydantic import Field
from app.api.apischemas import ( from app.api.apischemas import (
@@ -18,18 +17,13 @@ from app.api.apischemas import (
) )
from app.config import UserConfig from app.config import UserConfig
from app.db import AlbumTable, ArtistTable, TrackTable from app.db.libdata import ArtistTable
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb from app.db.libdata import AlbumTable, TrackTable
from app.db.sqlite.lastfm.similar_artists import SQLiteLastFMSimilarArtists as fmdb from app.db.userdata import SimilarArtistTable
from app.models import Album, FavType
from app.serializers.album import serialize_for_card_many from app.serializers.album import serialize_for_card_many
from app.serializers.track import serialize_tracks from app.serializers.track import serialize_tracks
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
from app.store.tracks import TrackStore
bp_tag = Tag(name="Artist", description="Single artist") bp_tag = Tag(name="Artist", description="Single artist")
api = APIBlueprint("artist", __name__, url_prefix="/artist", abp_tags=[bp_tag]) api = APIBlueprint("artist", __name__, url_prefix="/artist", abp_tags=[bp_tag])
@@ -45,8 +39,6 @@ def get_artist(path: ArtistHashSchema, query: TrackLimitSchema):
limit = query.limit limit = query.limit
artist = ArtistTable.get_artist_by_hash(artisthash) artist = ArtistTable.get_artist_by_hash(artisthash)
print(artist)
if artist is None: if artist is None:
return {"error": "Artist not found"}, 404 return {"error": "Artist not found"}, 404
@@ -56,8 +48,6 @@ def get_artist(path: ArtistHashSchema, query: TrackLimitSchema):
if artist.albumcount == 0 and tcount < 10: if artist.albumcount == 0 and tcount < 10:
limit = tcount limit = tcount
# artist.is_favorite = favdb.check_is_favorite(artisthash, FavType.artist)
try: try:
year = datetime.fromtimestamp(artist.date).year year = datetime.fromtimestamp(artist.date).year
except ValueError: except ValueError:
@@ -106,7 +96,7 @@ def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery):
t.albumhash for t in tracks if t.albumhash not in {a.albumhash for a in albums} t.albumhash for t in tracks if t.albumhash not in {a.albumhash for a in albums}
} }
albums.extend(AlbumTable.get_albums_by_hash(missing_albumhashes)) albums.extend(AlbumTable.get_albums_by_albumhashes(missing_albumhashes))
albumdict = {a.albumhash: a for a in albums} albumdict = {a.albumhash: a for a in albums}
config = UserConfig() config = UserConfig()
@@ -117,43 +107,6 @@ def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery):
if album: if album:
album.check_type(list(tracks), config.showAlbumsAsSingles) album.check_type(list(tracks), config.showAlbumsAsSingles)
# all_albums = AlbumStore.get_albums_by_artisthash(artisthash)
# start: check for missing albums. ie. compilations and features
# all_tracks = TrackStore.get_tracks_by_artisthash(artisthash)
# track_albums = set(t.albumhash for t in all_tracks)
# missing_album_hashes = track_albums.difference(set(a.albumhash for a in all_albums))
# if len(missing_album_hashes) > 0:
# missing_albums = AlbumStore.get_albums_by_hashes(list(missing_album_hashes))
# all_albums.extend(missing_albums)
# end check
# def get_album_tracks(albumhash: str):
# tracks = [t for t in all_tracks if t.albumhash == albumhash]
# if len(tracks) > 0:
# return tracks
# return TrackStore.get_tracks_by_albumhash(albumhash)
# for a in all_albums:
# a.check_type()
# album_tracks = get_album_tracks(a.albumhash)
# if len(album_tracks) == 0:
# continue
# a.get_date_from_tracks(album_tracks)
# if a.date == 0:
# AlbumStore.remove_album_by_hash(a.albumhash)
# continue
# a.is_single(album_tracks)
albums = [a for a in albumdict.values()] albums = [a for a in albumdict.values()]
all_albums = sorted(albums, key=lambda a: str(a.date), reverse=True) all_albums = sorted(albums, key=lambda a: str(a.date), reverse=True)
@@ -174,29 +127,6 @@ def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery):
else: else:
res["albums"].append(album) res["albums"].append(album)
# def remove_EPs_and_singles(albums_: list[Album]):
# albums_ = [a for a in albums_ if not a.type == "single"]
# albums_ = [a for a in albums_ if not a.type == "ep"]
# return albums_
# albums = filter(lambda a: artisthash in missing_albumhashes, all_albums)
# albums = list(albums)
# albums = remove_EPs_and_singles(albums)
# compilations = [a for a in albums if a.is_compilation]
# for c in compilations:
# albums.remove(c)
# appearances = filter(lambda a: artisthash not in a.albumartists_hashes, all_albums)
# appearances = list(appearances)
# appearances = remove_EPs_and_singles(appearances)
# artist = ArtistStore.get_artist_by_hash(artisthash)
# if artist is None:
# return {"error": "Artist not found"}, 404
if return_all: if return_all:
limit = len(all_albums) limit = len(all_albums)
@@ -215,8 +145,8 @@ def get_all_artist_tracks(path: ArtistHashSchema):
Returns all artists by a given artist. Returns all artists by a given artist.
""" """
tracks = TrackStore.get_tracks_by_artisthash(path.artisthash) # tracks = TrackStore.get_tracks_by_artisthash(path.artisthash)
tracks = TrackTable.get_tracks_by_artisthash(path.artisthash)
return serialize_tracks(tracks) return serialize_tracks(tracks)
@@ -226,23 +156,14 @@ def get_similar_artists(path: ArtistHashSchema, query: ArtistLimitSchema):
Get similar artists. Get similar artists.
""" """
limit = query.limit limit = query.limit
result = SimilarArtistTable.get_by_hash(path.artisthash)
artist = ArtistStore.get_artist_by_hash(path.artisthash)
if artist is None:
return {"error": "Artist not found"}, 404
result = fmdb.get_similar_artists_for(artist.artisthash)
if result is None: if result is None:
return {"artists": []} return []
similar = ArtistStore.get_artists_by_hashes(result.get_artist_hash_set()) similar = ArtistTable.get_artists_by_artisthashes(result.get_artist_hash_set())
if len(similar) > limit: if len(similar) > limit:
similar = random.sample(similar, limit) similar = random.sample(similar, min(limit, len(similar)))
return similar[:limit] return similar[:limit]
# TODO: Rewrite this file using generators where possible
+53 -43
View File
@@ -14,7 +14,8 @@ from pydantic import BaseModel, Field
from flask_openapi3 import Tag from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint from flask_openapi3 import APIBlueprint
from app.db.sqlite.auth import SQLiteAuthMethods as authdb # from app.db.sqlite.auth import SQLiteAuthMethods as authdb
from app.db.userdata import UserTable
from app.utils.auth import check_password, hash_password from app.utils.auth import check_password, hash_password
from app.config import UserConfig from app.config import UserConfig
@@ -65,7 +66,7 @@ def login(body: LoginBody):
Authenticate using username and password Authenticate using username and password
""" """
user = authdb.get_user_by_username(body.username) user = UserTable.get_by_username(body.username)
if user is None: if user is None:
return {"msg": "User not found"}, 404 return {"msg": "User not found"}, 404
@@ -87,42 +88,41 @@ def login(body: LoginBody):
pair_token = dict() pair_token = dict()
@api.get("/getpaircode")
def get_pair():
"""
Get a new pair code to log in to thee Swing Music mobile app
"""
# INFO: if user is already logged in, create a new pair code
token = create_new_token(get_jwt_identity())
key = token["accesstoken"][-6:]
global pair_token
pair_token = {
key: token,
}
return {"code": key}
class PairDeviceQuery(BaseModel): class PairDeviceQuery(BaseModel):
code: str = Field("", description="The code") code: str = Field("", description="The code")
@api.get("/pair") @api.post("/pair")
@jwt_required(optional=True) @jwt_required(optional=True)
def pair_device(query: PairDeviceQuery): def pair_with_code(body: PairDeviceQuery):
""" """
Pair the Swing Music mobile app with this server Get an access token by sending a pair code. NOTE: A code can only be used once!
Send a code to get an access token. Send an authenticated request without the code to generate a new token.
""" """
# INFO: if user is already logged in, create a new pair code global pair_token
if current_user: token = pair_token.get(body.code, None)
token = create_new_token(get_jwt_identity())
key = token["accesstoken"][-6:]
global pair_token if token:
pair_token = { pair_token = {}
key: token, return token
}
return {"code": key} return {"msg": "Invalid code"}, 400
# INFO: if there's a pair code, return the token
if query.code:
token = pair_token.get(query.code, None)
if token:
# INFO: reset pair_token
pair_token = {}
return token
return {"msg": "Invalid code"}, 400
return {"msg": "No code provided"}, 400
@api.post("/refresh") @api.post("/refresh")
@@ -133,6 +133,8 @@ def refresh():
>>> Headers: >>> Headers:
>>> Authorization: Bearer <refresh_token> >>> Authorization: Bearer <refresh_token>
Won't work with cookies!!!
""" """
user = get_jwt_identity() user = get_jwt_identity()
return create_new_token(user) return create_new_token(user)
@@ -153,7 +155,6 @@ def update_profile(body: UpdateProfileBody):
""" """
user = { user = {
"id": body.id, "id": body.id,
"email": body.email,
"username": body.username, "username": body.username,
"password": body.password, "password": body.password,
"roles": body.roles, "roles": body.roles,
@@ -172,7 +173,8 @@ def update_profile(body: UpdateProfileBody):
if "admin" not in current_user["roles"]: if "admin" not in current_user["roles"]:
return {"msg": "Only admins can update roles"}, 403 return {"msg": "Only admins can update roles"}, 403
all_users = authdb.get_all_users() # all_users = authdb.get_all_users()
all_users = UserTable.get_all()
if "admin" not in body.roles: if "admin" not in body.roles:
# check if we're removing the last admin # check if we're removing the last admin
admins = [user for user in all_users if "admin" in user.roles] admins = [user for user in all_users if "admin" in user.roles]
@@ -195,7 +197,9 @@ def update_profile(body: UpdateProfileBody):
clean_user = {k: v for k, v in user.items() if v} clean_user = {k: v for k, v in user.items() if v}
try: try:
return authdb.update_user(clean_user) # return authdb.update_user(clean_user)
UserTable.update_one(clean_user)
return UserTable.get_by_id(user["id"]).todict()
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
return {"msg": "Username already exists"}, 400 return {"msg": "Username already exists"}, 400
@@ -216,11 +220,18 @@ def create_user(body: UpdateProfileBody):
} }
# check if user already exists # check if user already exists
if authdb.get_user_by_username(user["username"]): if UserTable.get_by_username(user["username"]):
return {"msg": "Username already exists"}, 400 return {"msg": "Username already exists"}, 400
userid = authdb.insert_user(user) UserTable.insert_one(user)
return authdb.get_user_by_id(userid).todict() user = UserTable.get_by_username(user["username"])
if user:
return user.todict()
return {
"msg": "Failed to create user",
}, 500
@api.post("/profile/guest/create") @api.post("/profile/guest/create")
@@ -230,14 +241,14 @@ def create_guest_user():
Create a guest user Create a guest user
""" """
# check if guest user already exists # check if guest user already exists
guest_user = authdb.get_user_by_username("guest") guest_user = UserTable.get_by_username("guest")
if guest_user: if guest_user:
return { return {
"msg": "Guest user already exists", "msg": "Guest user already exists",
}, 400 }, 400
userid = authdb.insert_guest_user() userid = UserTable.insert_guest_user()
if userid: if userid:
return { return {
@@ -264,12 +275,12 @@ def delete_user(body: DeleteUseBody):
return {"msg": "Sorry! you cannot delete yourselfu"}, 400 return {"msg": "Sorry! you cannot delete yourselfu"}, 400
# prevent deleting the only admin # prevent deleting the only admin
users = authdb.get_all_users() users = UserTable.get_all()
admins = [user for user in users if "admin" in user.roles] admins = [user for user in users if "admin" in user.roles]
if len(admins) == 1 and admins[0].username == body.username: if len(admins) == 1 and admins[0].username == body.username:
return {"msg": "Cannot delete the only admin"}, 400 return {"msg": "Cannot delete the only admin"}, 400
authdb.delete_user_by_username(body.username) UserTable.remove_by_username(body.username)
return {"msg": f"User {body.username} deleted"} return {"msg": f"User {body.username} deleted"}
@@ -308,8 +319,7 @@ def get_all_users(query: GetAllUsersQuery):
"users": [], "users": [],
} }
users = authdb.get_all_users() users = UserTable.get_all()
is_admin = current_user and "admin" in current_user["roles"] is_admin = current_user and "admin" in current_user["roles"]
settings["enableGuest"] = [ settings["enableGuest"] = [
user for user in users if user.username == "guest" user for user in users if user.username == "guest"
@@ -355,8 +365,8 @@ def get_all_users(query: GetAllUsersQuery):
if query.simplified: if query.simplified:
res["users"] = [user.todict_simplified() for user in users] res["users"] = [user.todict_simplified() for user in users]
else:
res["users"] = [user.todict() for user in users] res["users"] = [user.todict() for user in users]
return res return res
+73 -97
View File
@@ -6,18 +6,19 @@ from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.api.apischemas import GenericLimitSchema from app.api.apischemas import GenericLimitSchema
from app.db.libdata import ArtistTable
from app.db.libdata import AlbumTable, TrackTable
from app.db.userdata import FavoritesTable
from app.models import FavType from app.models import FavType
from app.settings import Defaults from app.settings import Defaults
from app.utils.bisection import use_bisection from app.utils.bisection import use_bisection
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.serializers.track import serialize_track, serialize_tracks from app.serializers.track import serialize_track, serialize_tracks
from app.serializers.artist import serialize_for_card as serialize_artist, serialize_for_cards from app.serializers.artist import (
from app.serializers.album import serialize_for_card, serialize_for_card_many serialize_for_card as serialize_artist,
serialize_for_cards,
from app.store.albums import AlbumStore )
from app.store.tracks import TrackStore
from app.store.artists import ArtistStore
from app.utils.dates import timestamp_to_time_passed from app.utils.dates import timestamp_to_time_passed
from app.serializers.album import serialize_for_card, serialize_for_card_many
bp_tag = Tag(name="Favorites", description="Your favorite items") bp_tag = Tag(name="Favorites", description="Your favorite items")
api = APIBlueprint("favorites", __name__, url_prefix="/favorites", abp_tags=[bp_tag]) api = APIBlueprint("favorites", __name__, url_prefix="/favorites", abp_tags=[bp_tag])
@@ -41,17 +42,18 @@ class FavoritesAddBody(BaseModel):
@api.post("/add") @api.post("/add")
def add_favorite(body: FavoritesAddBody): def toggle_favorite(body: FavoritesAddBody):
""" """
Adds a favorite to the database. Adds a favorite to the database.
""" """
itemhash = body.hash FavoritesTable.insert_item({"hash": body.hash, "type": body.type})
itemtype = body.type
favdb.insert_one_favorite(itemtype, itemhash) if body.type == FavType.track:
TrackTable.set_is_favorite(body.hash, True)
if itemtype == FavType.track: elif body.type == FavType.album:
TrackStore.make_track_fav(itemhash) AlbumTable.set_is_favorite(body.hash, True)
elif body.type == FavType.artist:
ArtistTable.set_is_favorite(body.hash, True)
return {"msg": "Added to favorites"} return {"msg": "Added to favorites"}
@@ -61,80 +63,62 @@ def remove_favorite(body: FavoritesAddBody):
""" """
Removes a favorite from the database. Removes a favorite from the database.
""" """
itemhash = body.hash FavoritesTable.remove_item({"hash": body.hash, "type": body.type})
itemtype = body.type
favdb.delete_favorite(itemtype, itemhash) if body.type == FavType.track:
TrackTable.set_is_favorite(body.hash, False)
if itemtype == FavType.track: elif body.type == FavType.album:
TrackStore.remove_track_from_fav(itemhash) AlbumTable.set_is_favorite(body.hash, False)
elif body.type == FavType.artist:
ArtistTable.set_is_favorite(body.hash, False)
return {"msg": "Removed from favorites"} return {"msg": "Removed from favorites"}
class GetAllOfTypeQuery(GenericLimitSchema):
"""
Extending this class will give you a model with the `limit` field
"""
start: int = Field(
description="Where to start from",
example=Defaults.API_CARD_LIMIT,
default=Defaults.API_CARD_LIMIT,
)
@api.get("/albums") @api.get("/albums")
def get_favorite_albums(query: GenericLimitSchema): def get_favorite_albums(query: GetAllOfTypeQuery):
""" """
Get favorite albums Get favorite albums
""" """
limit = query.limit fav_albums, total = FavoritesTable.get_fav_albums(query.start, query.limit)
albums = favdb.get_fav_albums() fav_albums.reverse()
albumhashes = [a[1] for a in albums]
albumhashes.reverse()
src_albums = sorted(AlbumStore.albums, key=lambda x: x.albumhash) return {"albums": serialize_for_card_many(fav_albums), "total": total}
fav_albums = use_bisection(src_albums, "albumhash", albumhashes)
fav_albums = remove_none(fav_albums)
if limit == 0:
limit = len(albums)
return {"albums": serialize_for_card_many(fav_albums[:limit])}
@api.get("/tracks") @api.get("/tracks")
def get_favorite_tracks(query: GenericLimitSchema): def get_favorite_tracks(query: GetAllOfTypeQuery):
""" """
Get favorite tracks Get favorite tracks
""" """
limit = query.limit tracks, total = FavoritesTable.get_fav_tracks(query.start, query.limit)
userid = current_user['id'] return {"tracks": serialize_tracks(tracks), "total": total}
tracks = favdb.get_fav_tracks(userid)
trackhashes = [t[1] for t in tracks]
trackhashes.reverse()
src_tracks = sorted(TrackStore.tracks, key=lambda x: x.trackhash)
tracks = use_bisection(src_tracks, "trackhash", trackhashes)
tracks = remove_none(tracks)
if limit == 0:
limit = len(tracks)
return {"tracks": serialize_tracks(tracks[:limit])}
@api.get("/artists") @api.get("/artists")
def get_favorite_artists(query: GenericLimitSchema): def get_favorite_artists(query: GetAllOfTypeQuery):
""" """
Get favorite artists Get favorite artists
""" """
limit = query.limit artists, total = FavoritesTable.get_fav_artists(
start=query.start,
limit=query.limit,
)
artists.reverse()
artists = favdb.get_fav_artists() return {"artists": [serialize_artist(a) for a in artists], "total": total}
artisthashes = [a[1] for a in artists]
artisthashes.reverse()
src_artists = sorted(ArtistStore.artists, key=lambda x: x.artisthash)
artists = use_bisection(src_artists, "artisthash", artisthashes)
artists = remove_none(artists)
if limit == 0:
limit = len(artists)
return {"artists": artists[:limit]}
class GetAllFavoritesQuery(BaseModel): class GetAllFavoritesQuery(BaseModel):
@@ -173,27 +157,29 @@ def get_all_favorites(query: GetAllFavoritesQuery):
# largest is x2 to accound for broken hashes if any # largest is x2 to accound for broken hashes if any
largest = max(track_limit, album_limit, artist_limit) largest = max(track_limit, album_limit, artist_limit)
favs = favdb.get_all() favs = FavoritesTable.get_all()
favs.reverse() favs.reverse()
tracks = [] tracks = []
albums = [] albums = []
artists = [] artists = []
track_master_hash = set(t.trackhash for t in TrackStore.tracks) track_master_hash = TrackTable.get_all_hashes()
album_master_hash = set(a.albumhash for a in AlbumStore.albums) album_master_hash = AlbumTable.get_all_hashes()
artist_master_hash = set(a.artisthash for a in ArtistStore.artists) artist_master_hash = ArtistTable.get_all_hashes()
# INFO: Filter out invalid hashes (file not found or tags edited)
for fav in favs: for fav in favs:
# INFO: hash is [1], type is [2], timestamp is [3] hash = fav.hash
hash = fav[1] type = fav.type
if fav[2] == FavType.track:
if type == FavType.track:
tracks.append(hash) if hash in track_master_hash else None tracks.append(hash) if hash in track_master_hash else None
if fav[2] == FavType.artist: if type == FavType.artist:
artists.append(hash) if hash in artist_master_hash else None artists.append(hash) if hash in artist_master_hash else None
if fav[2] == FavType.album: if type == FavType.album:
albums.append(hash) if hash in album_master_hash else None albums.append(hash) if hash in album_master_hash else None
count = { count = {
@@ -202,35 +188,26 @@ def get_all_favorites(query: GetAllFavoritesQuery):
"artists": len(artists), "artists": len(artists),
} }
src_tracks = sorted(TrackStore.tracks, key=lambda x: x.trackhash) tracks = TrackTable.get_tracks_by_trackhashes(tracks, limit=track_limit)
src_albums = sorted(AlbumStore.albums, key=lambda x: x.albumhash) albums = AlbumTable.get_albums_by_albumhashes(albums, limit=album_limit)
src_artists = sorted(ArtistStore.artists, key=lambda x: x.artisthash) artists = ArtistTable.get_artists_by_artisthashes(artists, limit=artist_limit)
tracks = use_bisection(src_tracks, "trackhash", tracks, limit=track_limit)
albums = use_bisection(src_albums, "albumhash", albums, limit=album_limit)
artists = use_bisection(src_artists, "artisthash", artists, limit=artist_limit)
tracks = remove_none(tracks)
albums = remove_none(albums)
artists = remove_none(artists)
recents = [] recents = []
# first_n = favs # first_n = favs
for fav in favs: for fav in favs:
# INFO: hash is [1], type is [2], timestamp is [3]
if len(recents) >= largest: if len(recents) >= largest:
break break
if fav[2] == FavType.album: if fav.type == FavType.album:
album = next((a for a in albums if a.albumhash == fav[1]), None) album = next((a for a in albums if a.albumhash == fav.hash), None)
if album is None: if album is None:
continue continue
album = serialize_for_card(album) album = serialize_for_card(album)
album["help_text"] = "album" album["help_text"] = "album"
album["time"] = timestamp_to_time_passed(fav[3]) album["time"] = timestamp_to_time_passed(fav.timestamp)
recents.append( recents.append(
{ {
@@ -239,15 +216,15 @@ def get_all_favorites(query: GetAllFavoritesQuery):
} }
) )
if fav[2] == FavType.artist: if fav.type == FavType.artist:
artist = next((a for a in artists if a.artisthash == fav[1]), None) artist = next((a for a in artists if a.artisthash == fav.hash), None)
if artist is None: if artist is None:
continue continue
artist = serialize_artist(artist) artist = serialize_artist(artist)
artist["help_text"] = "artist" artist["help_text"] = "artist"
artist["time"] = timestamp_to_time_passed(fav[3]) artist["time"] = timestamp_to_time_passed(fav.timestamp)
recents.append( recents.append(
{ {
@@ -256,15 +233,15 @@ def get_all_favorites(query: GetAllFavoritesQuery):
} }
) )
if fav[2] == FavType.track: if fav.type == FavType.track:
track = next((t for t in tracks if t.trackhash == fav[1]), None) track = next((t for t in tracks if t.trackhash == fav.hash), None)
if track is None: if track is None:
continue continue
track = serialize_track(track) track = serialize_track(track)
track["help_text"] = "track" track["help_text"] = "track"
track["time"] = timestamp_to_time_passed(fav[3]) track["time"] = timestamp_to_time_passed(fav.timestamp)
recents.append({"type": "track", "item": track}) recents.append({"type": "track", "item": track})
@@ -284,6 +261,5 @@ def check_favorite(query: FavoritesAddBody):
""" """
itemhash = query.hash itemhash = query.hash
itemtype = query.type itemtype = query.type
exists = favdb.check_is_favorite(itemhash, itemtype)
return {"is_favorite": exists} return {"is_favorite": FavoritesTable.check_exists(itemhash, itemtype)}
+5 -11
View File
@@ -10,11 +10,10 @@ from pydantic import BaseModel, Field
from flask_openapi3 import Tag from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint from flask_openapi3 import APIBlueprint
from showinfm import show_in_file_manager from showinfm import show_in_file_manager
from memory_profiler import profile
from app import settings from app import settings
from app.db import TrackTable from app.config import UserConfig
from app.db.sqlite.settings import SettingsSQLMethods as db from app.db.libdata import TrackTable
from app.lib.folderslib import GetFilesAndDirs, get_folders from app.lib.folderslib import GetFilesAndDirs, get_folders
from app.serializers.track import serialize_track from app.serializers.track import serialize_track
from app.utils.wintools import is_windows, win_replace_slash from app.utils.wintools import is_windows, win_replace_slash
@@ -40,8 +39,9 @@ def get_folder_tree(body: FolderTree):
req_dir = body.folder req_dir = body.folder
tracks_only = body.tracks_only tracks_only = body.tracks_only
root_dirs = db.get_root_dirs()
root_dirs.sort() config = UserConfig()
root_dirs = config.rootDirs
try: try:
if req_dir == "$home" and root_dirs[0] == "$home": if req_dir == "$home" and root_dirs[0] == "$home":
@@ -72,12 +72,6 @@ def get_folder_tree(body: FolderTree):
return res return res
# return {
# "path": req_dir,
# "tracks": tracks,
# "folders": sorted(folders, key=lambda i: i.name),
# }
def get_all_drives(is_win: bool = False): def get_all_drives(is_win: bool = False):
""" """
+5 -4
View File
@@ -6,7 +6,8 @@ from pydantic import BaseModel, Field
from datetime import datetime from datetime import datetime
from app.api.apischemas import GenericLimitSchema from app.api.apischemas import GenericLimitSchema
from app.db import AlbumTable, ArtistTable from app.db.libdata import ArtistTable
from app.db.libdata import AlbumTable
from app.store.albums import AlbumStore from app.store.albums import AlbumStore
from app.store.artists import ArtistStore from app.store.artists import ArtistStore
@@ -61,11 +62,11 @@ def get_all_items(path: GetAllItemsPath, query: GetAllItemsQuery):
is_artists = path.itemtype == "artists" is_artists = path.itemtype == "artists"
if is_albums: if is_albums:
items, total = AlbumTable.get_all(query.start, query.limit) items = AlbumTable.get_all()
elif is_artists: elif is_artists:
items, total = ArtistTable.get_all(query.start, query.limit) items = ArtistTable.get_all()
# print(items) total = len(items)
start = query.start start = query.start
limit = query.limit limit = query.limit
+51 -71
View File
@@ -1,3 +1,4 @@
from dataclasses import asdict
from typing import Any from typing import Any
from flask import request from flask import request
from flask_openapi3 import Tag from flask_openapi3 import Tag
@@ -6,12 +7,13 @@ from pydantic import BaseModel, Field
from app.api.auth import admin_required from app.api.auth import admin_required
from app.db.sqlite.plugins import PluginsMethods as pdb from app.db.sqlite.plugins import PluginsMethods as pdb
from app.db.sqlite.settings import SettingsSQLMethods as sdb
from app.db.sqlite.tracks import SQLiteTrackMethods as trackdb from app.db.sqlite.tracks import SQLiteTrackMethods as trackdb
from app.db.userdata import PluginTable
from app.lib import populate from app.lib import populate
from app.lib.tagger import index_everything
from app.lib.watchdogg import Watcher as WatchDog from app.lib.watchdogg import Watcher as WatchDog
from app.logger import log from app.logger import log
from app.settings import Info, Paths, SessionVarKeys, set_flag from app.settings import Info, Paths, SessionVarKeys
from app.store.albums import AlbumStore from app.store.albums import AlbumStore
from app.store.artists import ArtistStore from app.store.artists import ArtistStore
from app.store.tracks import TrackStore from app.store.tracks import TrackStore
@@ -48,42 +50,43 @@ def reload_everything(instance_key: str):
except Exception as e: except Exception as e:
log.error(e) log.error(e)
# CHECKPOINT: TEST SETTINGS API ENDPOINTS
@background # @background
def rebuild_store(db_dirs: list[str]): # def rebuild_store(db_dirs: list[str]):
""" # """
Restarts watchdog and rebuilds the music library. # Restarts watchdog and rebuilds the music library.
""" # """
instance_key = get_random_str() # instance_key = get_random_str()
log.info("Rebuilding library...") # log.info("Rebuilding library...")
trackdb.remove_tracks_not_in_folders(db_dirs) # trackdb.remove_tracks_not_in_folders(db_dirs)
reload_everything(instance_key) # reload_everything(instance_key)
try: # try:
populate.Populate(instance_key=instance_key) # populate.Populate(instance_key=instance_key)
except populate.PopulateCancelledError as e: # except populate.PopulateCancelledError as e:
print(e) # print(e)
reload_everything(instance_key) # reload_everything(instance_key)
return # return
WatchDog().restart() # WatchDog().restart()
log.info("Rebuilding library... ✅") # log.info("Rebuilding library... ✅")
# I freaking don't know what this function does anymore # # I freaking don't know what this function does anymore
def finalize(new_: list[str], removed_: list[str], db_dirs_: list[str]): # def finalize(new_: list[str], removed_: list[str], db_dirs_: list[str]):
""" # """
Params: # Params:
new_: will be added to the database # new_: will be added to the database
removed_: will be removed from the database # removed_: will be removed from the database
db_dirs_: will be used to remove tracks that # db_dirs_: will be used to remove tracks that
are outside these directories from the database and store. # are outside these directories from the database and store.
""" # """
sdb.remove_root_dirs(removed_) # sdb.remove_root_dirs(removed_)
sdb.add_root_dirs(new_) # sdb.add_root_dirs(new_)
rebuild_store(db_dirs_) # rebuild_store(db_dirs_)
class AddRootDirsBody(BaseModel): class AddRootDirsBody(BaseModel):
@@ -106,7 +109,8 @@ def add_root_dirs(body: AddRootDirsBody):
new_dirs = body.new_dirs new_dirs = body.new_dirs
removed_dirs = body.removed removed_dirs = body.removed
db_dirs = sdb.get_root_dirs() config = UserConfig()
db_dirs = config.rootDirs
home = "$home" home = "$home"
db_home = any([d == home for d in db_dirs]) # if $home is in db db_home = any([d == home for d in db_dirs]) # if $home is in db
@@ -114,13 +118,16 @@ def add_root_dirs(body: AddRootDirsBody):
# handle $home case # handle $home case
if db_home and incoming_home: if db_home and incoming_home:
return {"msg": "Not changed!"} return {"msg": "Not changed!"}, 304
# if $home is the current root dir or the incoming root dir
# is $home, remove all root dirs
if db_home or incoming_home: if db_home or incoming_home:
sdb.remove_root_dirs(db_dirs) config.rootDirs = []
if incoming_home: if incoming_home:
finalize([home], [], [Paths.USER_HOME_DIR]) config.rootDirs = [home]
index_everything()
return {"root_dirs": [home]} return {"root_dirs": [home]}
# --- # ---
@@ -136,11 +143,10 @@ def add_root_dirs(body: AddRootDirsBody):
pass pass
db_dirs.extend(new_dirs) db_dirs.extend(new_dirs)
db_dirs = [dir_ for dir_ in db_dirs if dir_ != home] config.rootDirs = [dir_ for dir_ in db_dirs if dir_ != home]
finalize(new_dirs, removed_dirs, db_dirs) index_everything()
return {"root_dirs": config.rootDirs}
return {"root_dirs": db_dirs}
@api.get("/get-root-dirs") @api.get("/get-root-dirs")
@@ -148,9 +154,7 @@ def get_root_dirs():
""" """
Get root directories Get root directories
""" """
dirs = sdb.get_root_dirs() return {"dirs": UserConfig().rootDirs}
return {"dirs": dirs}
# maps settings to their parser flags # maps settings to their parser flags
@@ -170,35 +174,12 @@ def get_all_settings():
""" """
Get all settings Get all settings
""" """
config = asdict(UserConfig())
plugins = PluginTable.get_all()
config["plugins"] = plugins
config["version"] = Info.SWINGMUSIC_APP_VERSION
settings = sdb.get_all_settings() return config
plugins = pdb.get_all_plugins()
key_list = list(mapp.keys())
s = {}
for key in key_list:
val_index = key_list.index(key)
try:
s[key] = settings[val_index]
if type(s[key]) == int:
s[key] = bool(s[key])
if type(s[key]) == str:
s[key] = str(s[key]).split(",")
except IndexError:
s[key] = None
root_dirs = sdb.get_root_dirs()
s["root_dirs"] = root_dirs
s["plugins"] = plugins
s["version"] = Info.SWINGMUSIC_APP_VERSION
return {
"settings": s,
}
@background @background
@@ -245,7 +226,6 @@ def set_setting(body: SetSettingBody):
value = str(value).split(",") value = str(value).split(",")
value = set(value) value = set(value)
set_flag(flag, value)
reload_all_for_set_setting() reload_all_for_set_setting()
# if value is a set, convert it to a string # if value is a set, convert it to a string
+1 -1
View File
@@ -11,7 +11,7 @@ from app.api.apischemas import TrackHashSchema
from app.lib.trackslib import get_silence_paddings from app.lib.trackslib import get_silence_paddings
# from app.store.tracks import TrackStore # from app.store.tracks import TrackStore
from app.db import TrackTable from app.db.libdata import TrackTable
from app.utils.files import guess_mime_type from app.utils.files import guess_mime_type
bp_tag = Tag(name="File", description="Audio files") bp_tag = Tag(name="File", description="Audio files")
+4 -3
View File
@@ -9,6 +9,7 @@ import sys
import PyInstaller.__main__ as bundler import PyInstaller.__main__ as bundler
from app import settings from app import settings
from app.config import UserConfig
from app.logger import log from app.logger import log
from app.print_help import HELP_MESSAGE from app.print_help import HELP_MESSAGE
from app.utils.auth import hash_password from app.utils.auth import hash_password
@@ -160,7 +161,7 @@ class ProcessArgs:
@staticmethod @staticmethod
def handle_periodic_scan(): def handle_periodic_scan():
if any((a in ARGS for a in ALLARGS.no_periodic_scan)): if any((a in ARGS for a in ALLARGS.no_periodic_scan)):
settings.SessionVars.DO_PERIODIC_SCANS = False UserConfig().enablePeriodicScans = False
@staticmethod @staticmethod
def handle_periodic_scan_interval(): def handle_periodic_scan_interval():
@@ -182,10 +183,10 @@ class ProcessArgs:
sys.exit(0) sys.exit(0)
if psi < 0: if psi < 0:
print("WADAFUCK ARE YOU TRYING?") print("WHAT ARE YOU TRYING?")
sys.exit(0) sys.exit(0)
settings.SessionVars.PERIODIC_SCAN_INTERVAL = psi UserConfig().scanInterval = psi
@staticmethod @staticmethod
def handle_help(): def handle_help():
+9 -1
View File
@@ -6,6 +6,7 @@ from .settings import Paths
# TODO: Publish this on PyPi # TODO: Publish this on PyPi
@dataclass @dataclass
class UserConfig: class UserConfig:
_config_path: str = "" _config_path: str = ""
@@ -20,7 +21,7 @@ class UserConfig:
# lists # lists
rootDirs: list[str] = field(default_factory=list) rootDirs: list[str] = field(default_factory=list)
excludeDirs: list[str] = field(default_factory=list) excludeDirs: list[str] = field(default_factory=list)
artistSeparators: set[str] = field(default_factory=set) artistSeparators: set[str] = field(default_factory=lambda: {";", "/"})
genreSeparators: set[str] = field(default_factory=lambda: {"/", ";", "&"}) genreSeparators: set[str] = field(default_factory=lambda: {"/", ";", "&"})
# tracks # tracks
@@ -33,6 +34,13 @@ class UserConfig:
cleanAlbumTitle: bool = True cleanAlbumTitle: bool = True
showAlbumsAsSingles: bool = False showAlbumsAsSingles: bool = False
# misc
enablePeriodicScans: bool = False
scanInterval: int = 60 * 10 # 10 minutes
# plugins
enablePlugins: bool = True
def __post_init__(self): def __post_init__(self):
""" """
Loads the config file and sets the values to this instance Loads the config file and sets the values to this instance
+32 -349
View File
@@ -1,36 +1,21 @@
from concurrent.futures import ThreadPoolExecutor from typing import Any
import json
import os
from pathlib import Path
from pprint import pprint
from typing import Any, Optional
from memory_profiler import profile
from sqlalchemy import ( from sqlalchemy import (
JSON,
Boolean,
Integer,
Row,
String,
Tuple,
and_,
create_engine, create_engine,
delete,
insert, insert,
select, select,
) )
from sqlalchemy.engine import Engine
from sqlalchemy import event
from sqlalchemy.orm import ( from sqlalchemy.orm import (
Mapped,
mapped_column,
DeclarativeBase, DeclarativeBase,
MappedAsDataclass, MappedAsDataclass,
sessionmaker,
) )
from app.models import Track as TrackModel # ============================================================
from app.models import Album as AlbumModel # TODO: Make sure the database is created before we run this.
from app.models import Artist as ArtistModel
from app.utils.remove_duplicates import remove_duplicates
fullpath = "/home/cwilvx/temp/swingmusic/swing.db" fullpath = "/home/cwilvx/temp/swingmusic/swing.db"
engine = create_engine( engine = create_engine(
f"sqlite+pysqlite:///{fullpath}", f"sqlite+pysqlite:///{fullpath}",
@@ -39,85 +24,46 @@ engine = create_engine(
pool_size=5, pool_size=5,
) )
if not os.path.exists(fullpath): # connection = engine.connect()
os.makedirs(Path(fullpath).parent)
connection = engine.connect()
all_filepaths = list()
def getIndexOfFirstMatch(strings: list[str], prefix: str): @event.listens_for(Engine, "connect")
""" def set_sqlite_pragma(dbapi_connection, connection_record):
Find the index of the first path that starts with the given path. cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
Uses a binary search algorithm to find the index. cursor.close()
"""
left = 0
right = len(strings) - 1
while left <= right:
mid = (left + right) // 2
if strings[mid].startswith(prefix):
if mid == 0 or not strings[mid - 1].startswith(prefix):
return mid
right = mid - 1
elif strings[mid] < prefix:
left = mid + 1
else:
right = mid - 1
return -1
def countFilepathsInDir(dirpath: str):
"""
Return all the filepaths in a directory.
"""
global all_filepaths
index = getIndexOfFirstMatch(all_filepaths, dirpath)
if index == -1:
return 0
paths: list[str] = []
for path in all_filepaths[index:]:
if path.startswith(dirpath):
paths.append(path)
else:
break
return len(paths)
class DbManager: class DbManager:
def __init__(self, commit: bool = False): def __init__(self, commit: bool = False):
self.commit = commit self.commit = commit
# self.engine = create_engine(f"sqlite+pysqlite:///{fullpath}", echo=True) self.engine = create_engine(f"sqlite+pysqlite:///{fullpath}", echo=True)
# self.conn = self.engine.connect() self.conn = self.engine.connect()
# pass
def __enter__(self): def __enter__(self):
# return self.conn.execution_options(preserve_rowcount=True) return self.conn.execution_options(preserve_rowcount=True)
return connection # return connection
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
if self.commit: if self.commit:
connection.commit() self.conn.commit()
# self.conn.close() self.conn.close()
class Base(MappedAsDataclass, DeclarativeBase): class Base(MappedAsDataclass, DeclarativeBase):
@classmethod
def execute(cls, stmt: Any, commit: bool = False):
with DbManager(commit=commit) as conn:
return conn.execute(stmt)
@classmethod @classmethod
def insert_many(cls, items: list[dict[str, Any]]): def insert_many(cls, items: list[dict[str, Any]]):
""" """
Inserts multiple items into the database. Inserts multiple items into the database.
""" """
with DbManager(commit=True) as conn: with DbManager(commit=True) as conn:
conn.execute(insert(cls).values(items)) return conn.execute(insert(cls).values(items))
@classmethod @classmethod
def insert_one(cls, item: dict[str, Any]): def insert_one(cls, item: dict[str, Any]):
@@ -127,277 +73,14 @@ class Base(MappedAsDataclass, DeclarativeBase):
return cls.insert_many([item]) return cls.insert_many([item])
@classmethod @classmethod
def get_all(cls): def remove_all(cls):
""" with DbManager(commit=True) as conn:
Returns all the items from the database. conn.execute(delete(cls))
"""
with DbManager() as conn:
result = conn.execute(select(cls))
return result.fetchall()
class ArtistTable(Base):
__tablename__ = "artist"
id: Mapped[int] = mapped_column(primary_key=True)
albumcount: Mapped[int] = mapped_column(Integer())
artisthash: Mapped[str] = mapped_column(String(), unique=True, index=True)
created_date: Mapped[int] = mapped_column(Integer())
date: Mapped[int] = mapped_column(Integer())
duration: Mapped[int] = mapped_column(Integer())
genres: Mapped[str] = mapped_column(JSON())
name: Mapped[str] = mapped_column(String(), index=True)
trackcount: Mapped[int] = mapped_column(Integer())
is_favorite: Mapped[Optional[bool]] = mapped_column(Boolean())
@classmethod @classmethod
def get_all(cls, start: int, limit: int): def all(cls):
with DbManager() as conn: return cls.execute(select(cls))
if start == 0:
result = conn.execute(select(cls))
else:
result = conn.execute(select(cls).offset(start).limit(limit))
all = result.fetchall()
return artists_to_dataclasses(all), len(all)
@classmethod
def get_artist_by_hash(cls, artisthash: str):
with DbManager() as conn:
result = conn.execute(
select(ArtistTable).where(ArtistTable.artisthash == artisthash)
)
return artist_to_dataclass(result.fetchone())
class AlbumTable(Base): def create_all():
__tablename__ = "album" Base().metadata.create_all(engine)
id: Mapped[int] = mapped_column(primary_key=True)
albumartists: Mapped[list[dict[str, str]]] = mapped_column(JSON(), index=True)
artisthashes: Mapped[list[str]] = mapped_column(JSON(), index=True)
albumhash: Mapped[str] = mapped_column(String(), unique=True, index=True)
base_title: Mapped[str] = mapped_column(String())
color: Mapped[Optional[str]] = mapped_column(String())
created_date: Mapped[int] = mapped_column(Integer())
date: Mapped[int] = mapped_column(Integer())
duration: Mapped[int] = mapped_column(Integer())
genres: Mapped[str] = mapped_column(JSON())
og_title: Mapped[str] = mapped_column(String())
title: Mapped[str] = mapped_column(String())
trackcount: Mapped[int] = mapped_column(Integer())
@classmethod
def get_album_by_albumhash(cls, hash: str):
with DbManager() as conn:
result = conn.execute(
select(AlbumTable).where(AlbumTable.albumhash == hash)
)
album = result.fetchone()
if album:
return album_to_dataclass(album)
@classmethod
def get_albums_by_hash(cls, hashes: set[str]):
with DbManager() as conn:
result = conn.execute(
select(AlbumTable).where(AlbumTable.albumhash.in_(hashes))
)
return albums_to_dataclasses(result.fetchall())
@classmethod
def get_all(cls, start: int, limit: int):
with DbManager() as conn:
if start == 0:
result = conn.execute(select(AlbumTable))
else:
result = conn.execute(select(AlbumTable).offset(start).limit(limit))
all = result.fetchall()
return albums_to_dataclasses(all)[:limit], len(all)
@classmethod
def get_albums_by_artisthashes(cls, artisthashes: list[dict[str, str]]):
with DbManager() as conn:
albums: list[AlbumModel] = []
for artist in artisthashes:
result = conn.execute(
# NOTE: The artist dict keys need to in the same order they appear in the db for this to work!
select(AlbumTable).where(AlbumTable.albumartists.contains(artist))
)
albums.extend(albums_to_dataclasses(result.fetchall()))
return albums
@classmethod
def get_albums_by_base_title(cls, base_title: str):
with DbManager() as conn:
result = conn.execute(
select(AlbumTable).where(AlbumTable.base_title == base_title)
)
return albums_to_dataclasses(result.fetchall())
@classmethod
def get_albums_by_artisthash(cls, artisthash: str):
with DbManager() as conn:
result = conn.execute(
select(AlbumTable).where(AlbumTable.artisthashes.contains(artisthash))
)
return albums_to_dataclasses(result.all())
class TrackTable(Base):
__tablename__ = "track"
id: Mapped[int] = mapped_column(init=False, primary_key=True)
album: Mapped[str] = mapped_column(String())
albumartists: Mapped[list[dict[str, str]]] = mapped_column(JSON())
albumhash: Mapped[str] = mapped_column(String(), index=True)
artisthashes: Mapped[list[str]] = mapped_column(JSON(), index=True)
artists: Mapped[list[dict[str, str]]] = mapped_column(JSON(), index=True)
bitrate: Mapped[int] = mapped_column(Integer())
copyright: Mapped[Optional[str]] = mapped_column(String())
date: Mapped[int] = mapped_column(Integer())
disc: Mapped[int] = mapped_column(Integer())
duration: Mapped[int] = mapped_column(Integer())
filepath: Mapped[str] = mapped_column(String(), index=True, unique=True)
folder: Mapped[str] = mapped_column(String(), index=True)
genre: Mapped[Optional[list[dict[str, str]]]] = mapped_column(JSON())
last_mod: Mapped[float] = mapped_column(Integer())
og_album: Mapped[str] = mapped_column(String())
og_title: Mapped[str] = mapped_column(String())
title: Mapped[str] = mapped_column(String())
track: Mapped[int] = mapped_column(Integer())
trackhash: Mapped[str] = mapped_column(String(), index=True)
extra: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON())
@classmethod
def get_tracks_by_filepaths(cls, filepaths: list[str]):
with DbManager() as conn:
result = conn.execute(
select(TrackTable).where(TrackTable.filepath.in_(filepaths))
)
return tracks_to_dataclasses(result.fetchall())
@classmethod
def count_tracks_containing_paths(cls, paths: list[str]):
results: list[dict[str, int | str]] = []
with ThreadPoolExecutor() as executor:
res = executor.map(countFilepathsInDir, paths)
results = [
{"path": path, "trackcount": count} for path, count in zip(paths, res)
]
return results
@classmethod
def get_tracks_by_albumhash(cls, albumhash: str):
with DbManager() as conn:
result = conn.execute(
select(TrackTable).where(TrackTable.albumhash == albumhash)
)
tracks = tracks_to_dataclasses(result.fetchall())
return remove_duplicates(tracks, is_album_tracks=True)
@classmethod
def get_track_by_trackhash(cls, hash: str, filepath: str = ""):
with DbManager() as conn:
if filepath:
result = conn.execute(
select(TrackTable)
.where(
and_(
TrackTable.trackhash == hash,
TrackTable.filepath == filepath,
)
)
.order_by(TrackTable.bitrate.desc())
)
else:
result = conn.execute(
select(TrackTable).where(TrackTable.trackhash == hash)
)
track = result.fetchone()
if track:
return track_to_dataclass(track)
@classmethod
def get_tracks_by_artisthash(cls, artisthash: str):
with DbManager() as conn:
result = conn.execute(
select(TrackTable).where(TrackTable.artists.contains(artisthash))
)
return tracks_to_dataclasses(result.fetchall())
@classmethod
def get_tracks_in_path(cls, path: str):
with DbManager() as conn:
result = conn.execute(
select(TrackTable)
.where(TrackTable.filepath.contains(path))
.order_by(TrackTable.last_mod)
)
return tracks_to_dataclasses(result.fetchall())
all_tracks = TrackTable.get_all()
for track in all_tracks:
all_filepaths.append(track.filepath)
all_filepaths.sort()
# print("files in path: ",getFilepathsInDir("/home/cwilvx/Music/").__len__())
# SECTION: Userdata database
class UserTable(Base):
__tablename__ = "user"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(), unique=True)
firstname: Mapped[Optional[str]] = mapped_column(String())
lastname: Mapped[Optional[str]] = mapped_column(String())
password: Mapped[str] = mapped_column(String())
email: Mapped[Optional[str]] = mapped_column(String())
image: Mapped[Optional[str]] = mapped_column(String())
roles: Mapped[list[str]] = mapped_column(JSON(), default_factory=lambda: ["user"])
extra: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSON(), default_factory=dict
)
# SECTION: HELPER FUNCTIONS
def artist_to_dataclass(artist: Any):
return ArtistModel(**artist._asdict())
def artists_to_dataclasses(artists: Any):
return [artist_to_dataclass(artist) for artist in artists]
def album_to_dataclass(album: Any):
return AlbumModel(**album._asdict())
def albums_to_dataclasses(albums: Any):
return [album_to_dataclass(album) for album in albums]
def track_to_dataclass(track: Any):
return TrackModel(**track._asdict())
def tracks_to_dataclasses(tracks: Any):
return [track_to_dataclass(track) for track in tracks]
Base().metadata.create_all(engine)
+312
View File
@@ -0,0 +1,312 @@
from app.db import (
Base as MasterBase,
DbManager,
)
from app.db.utils import (
album_to_dataclass,
albums_to_dataclasses,
artist_to_dataclass,
artists_to_dataclasses,
track_to_dataclass,
tracks_to_dataclasses,
)
from app.models import Album as AlbumModel
from app.utils.remove_duplicates import remove_duplicates
from app.db import engine
from sqlalchemy import JSON, Boolean, Integer, String, and_, delete, select, update
from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase
from typing import Any, Iterable, Optional
def create_all():
"""
Create all the tables defined in this file.
"""
Base.metadata.create_all(engine)
class Base(MasterBase, DeclarativeBase):
@classmethod
def get_all_hashes(cls):
with DbManager() as conn:
if cls.__tablename__ == "track":
stmt = select(TrackTable.trackhash)
elif cls.__tablename__ == "album":
stmt = select(AlbumTable.albumhash)
elif cls.__tablename__ == "artist":
stmt = select(ArtistTable.artisthash)
result = conn.execute(stmt)
return {row[0] for row in result.fetchall()}
@classmethod
def set_is_favorite(cls, hash: str, is_favorite: bool):
"""
Set the 'is_favorite' flag for a specific hash.
Args:
hash (str): The hash value.
is_favorite (bool): The value of the 'is_favorite' flag.
"""
with DbManager(commit=True) as conn:
if cls.__tablename__ == "track":
stmt = (
update(cls)
.where(TrackTable.trackhash == hash)
.values(is_favorite=is_favorite)
)
elif cls.__tablename__ == "album":
stmt = (
update(cls)
.where(AlbumTable.albumhash == hash)
.values(is_favorite=is_favorite)
)
elif cls.__tablename__ == "artist":
stmt = (
update(cls)
.where(ArtistTable.artisthash == hash)
.values(is_favorite=is_favorite)
)
conn.execute(stmt)
class TrackTable(Base):
__tablename__ = "track"
id: Mapped[int] = mapped_column(init=False, primary_key=True)
album: Mapped[str] = mapped_column(String())
albumartists: Mapped[list[dict[str, str]]] = mapped_column(JSON())
albumhash: Mapped[str] = mapped_column(String(), index=True)
artisthashes: Mapped[list[str]] = mapped_column(JSON(), index=True)
artists: Mapped[list[dict[str, str]]] = mapped_column(JSON(), index=True)
bitrate: Mapped[int] = mapped_column(Integer())
copyright: Mapped[Optional[str]] = mapped_column(String())
date: Mapped[int] = mapped_column(Integer(), nullable=True)
disc: Mapped[int] = mapped_column(Integer())
duration: Mapped[int] = mapped_column(Integer())
filepath: Mapped[str] = mapped_column(String(), unique=True)
folder: Mapped[str] = mapped_column(String(), index=True)
genrehashes: Mapped[list[str]] = mapped_column(JSON(), index=True)
genres: Mapped[Optional[list[dict[str, str]]]] = mapped_column(JSON())
last_mod: Mapped[float] = mapped_column(Integer())
og_album: Mapped[str] = mapped_column(String())
og_title: Mapped[str] = mapped_column(String())
title: Mapped[str] = mapped_column(String())
track: Mapped[int] = mapped_column(Integer())
trackhash: Mapped[str] = mapped_column(String(), index=True)
is_favorite: Mapped[Optional[bool]] = mapped_column(Boolean())
playcount: Mapped[int] = mapped_column(Integer())
extra: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON())
@classmethod
def get_all(cls):
with DbManager() as conn:
result = conn.execute(select(cls))
return tracks_to_dataclasses(result.fetchall())
@classmethod
def get_tracks_by_filepaths(cls, filepaths: list[str]):
with DbManager() as conn:
result = conn.execute(
select(TrackTable).where(TrackTable.filepath.in_(filepaths))
)
return tracks_to_dataclasses(result.fetchall())
@classmethod
def get_tracks_by_albumhash(cls, albumhash: str):
with DbManager() as conn:
result = conn.execute(
select(TrackTable).where(TrackTable.albumhash == albumhash)
)
tracks = tracks_to_dataclasses(result.fetchall())
return remove_duplicates(tracks, is_album_tracks=True)
@classmethod
def get_track_by_trackhash(cls, hash: str, filepath: str = ""):
with DbManager() as conn:
if filepath:
result = conn.execute(
select(TrackTable)
.where(
and_(
TrackTable.trackhash == hash,
TrackTable.filepath == filepath,
)
)
.order_by(TrackTable.bitrate.desc())
)
else:
result = conn.execute(
select(TrackTable).where(TrackTable.trackhash == hash)
)
track = result.fetchone()
if track:
return track_to_dataclass(track)
@classmethod
def get_tracks_by_artisthash(cls, artisthash: str):
with DbManager() as conn:
result = conn.execute(
select(TrackTable).where(TrackTable.artists.contains(artisthash))
)
return tracks_to_dataclasses(result.fetchall())
@classmethod
def get_tracks_in_path(cls, path: str):
with DbManager() as conn:
result = conn.execute(
select(TrackTable)
.where(TrackTable.filepath.contains(path))
.order_by(TrackTable.last_mod)
)
return tracks_to_dataclasses(result.fetchall())
@classmethod
def get_tracks_by_trackhashes(cls, hashes: Iterable[str], limit: int | None = None):
with DbManager() as conn:
result = conn.execute(
select(TrackTable).where(TrackTable.trackhash.in_(hashes)).limit(limit)
)
return tracks_to_dataclasses(result.fetchall())
@classmethod
def remove_tracks_by_filepaths(cls, filepaths: set[str]):
with DbManager(commit=True) as conn:
conn.execute(delete(TrackTable).where(TrackTable.filepath.in_(filepaths)))
class AlbumTable(Base):
__tablename__ = "album"
id: Mapped[int] = mapped_column(primary_key=True)
albumartists: Mapped[list[dict[str, str]]] = mapped_column(JSON(), index=True)
artisthashes: Mapped[list[str]] = mapped_column(JSON(), index=True)
albumhash: Mapped[str] = mapped_column(String(), unique=True, index=True)
base_title: Mapped[str] = mapped_column(String())
color: Mapped[Optional[str]] = mapped_column(String())
created_date: Mapped[int] = mapped_column(Integer())
date: Mapped[int] = mapped_column(Integer())
duration: Mapped[int] = mapped_column(Integer())
genrehashes: Mapped[list[str]] = mapped_column(JSON(), nullable=True, index=True)
genres: Mapped[str] = mapped_column(JSON())
og_title: Mapped[str] = mapped_column(String())
title: Mapped[str] = mapped_column(String())
trackcount: Mapped[int] = mapped_column(Integer())
is_favorite: Mapped[Optional[bool]] = mapped_column(Boolean())
extra: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON())
@classmethod
def get_all(cls):
with DbManager() as conn:
result = conn.execute(select(AlbumTable))
all = result.fetchall()
return albums_to_dataclasses(all)
@classmethod
def get_album_by_albumhash(cls, hash: str):
with DbManager() as conn:
result = conn.execute(
select(AlbumTable).where(AlbumTable.albumhash == hash)
)
album = result.fetchone()
if album:
return album_to_dataclass(album)
@classmethod
def get_albums_by_albumhashes(cls, hashes: Iterable[str], limit: int | None = None):
with DbManager() as conn:
result = conn.execute(
select(AlbumTable).where(AlbumTable.albumhash.in_(hashes)).limit(limit)
)
return albums_to_dataclasses(result.fetchall())
@classmethod
def get_albums_by_artisthashes(cls, artisthashes: list[str]):
with DbManager() as conn:
albums: list[AlbumModel] = []
for artist in artisthashes:
result = conn.execute(
# NOTE: The artist dict keys need to in the same order they appear in the db for this to work!
select(AlbumTable).where(AlbumTable.albumartists.contains(artist))
)
albums.extend(albums_to_dataclasses(result.fetchall()))
return albums
@classmethod
def get_albums_by_base_title(cls, base_title: str):
with DbManager() as conn:
result = conn.execute(
select(AlbumTable).where(AlbumTable.base_title == base_title)
)
return albums_to_dataclasses(result.fetchall())
@classmethod
def get_albums_by_artisthash(cls, artisthash: str):
with DbManager() as conn:
result = conn.execute(
select(AlbumTable).where(AlbumTable.artisthashes.contains(artisthash))
)
return albums_to_dataclasses(result.all())
class ArtistTable(Base):
__tablename__ = "artist"
id: Mapped[int] = mapped_column(primary_key=True)
albumcount: Mapped[int] = mapped_column(Integer())
artisthash: Mapped[str] = mapped_column(String(), unique=True, index=True)
created_date: Mapped[int] = mapped_column(Integer())
date: Mapped[int] = mapped_column(Integer())
duration: Mapped[int] = mapped_column(Integer())
genrehashes: Mapped[list[str]] = mapped_column(JSON(), nullable=True, index=True)
genres: Mapped[str] = mapped_column(JSON())
name: Mapped[str] = mapped_column(String(), index=True)
trackcount: Mapped[int] = mapped_column(Integer())
is_favorite: Mapped[Optional[bool]] = mapped_column(Boolean())
extra: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON())
@classmethod
def get_all(cls):
with DbManager() as conn:
result = conn.execute(select(cls))
all = result.fetchall()
return artists_to_dataclasses(all)
@classmethod
def get_artist_by_hash(cls, artisthash: str):
with DbManager() as conn:
result = conn.execute(
select(ArtistTable).where(ArtistTable.artisthash == artisthash)
)
return artist_to_dataclass(result.fetchone())
@classmethod
def get_artisthashes_not_in(cls, artisthashes: list[str]):
with DbManager() as conn:
result = conn.execute(
select(ArtistTable.artisthash, ArtistTable.name).where(
~ArtistTable.artisthash.in_(artisthashes)
)
)
return [{"artisthash": row[0], "name": row[1]} for row in result.fetchall()]
@classmethod
def get_artists_by_artisthashes(
cls, hashes: Iterable[str], limit: int | None = None
):
with DbManager() as conn:
result = conn.execute(
select(ArtistTable)
.where(ArtistTable.artisthash.in_(hashes))
.limit(limit)
)
return artists_to_dataclasses(result.fetchall())
+33
View File
@@ -0,0 +1,33 @@
from app.db import Base, DbManager
from sqlalchemy import Integer, insert, select, update
from sqlalchemy.orm import Mapped, mapped_column
class MigrationTable(Base):
__tablename__ = "dbmigration"
id: Mapped[int] = mapped_column(primary_key=True)
version: Mapped[int] = mapped_column(Integer())
@classmethod
def set_version(cls, version: int):
with DbManager(commit=True) as conn:
result = conn.execute(
update(cls).where(cls.id == 1).values(version=version)
)
if result.rowcount == 0:
conn.execute(insert(cls).values(id=1, version=version))
@classmethod
def get_version(cls):
with DbManager() as conn:
result = conn.execute(select(cls.version).where(cls.id == 1))
result = result.fetchone()
if result:
return result[0]
return -1
+1 -1
View File
@@ -16,7 +16,7 @@ class SQLiteLastFMSimilarArtists:
sql = """INSERT OR REPLACE INTO lastfm_similar_artists(artisthash, similar_artists) VALUES(?,?)""" sql = """INSERT OR REPLACE INTO lastfm_similar_artists(artisthash, similar_artists) VALUES(?,?)"""
with SQLiteManager(userdata_db=True) as cur: with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (artist.artisthash, artist.similar_artist_hashes)) cur.execute(sql, (artist.artisthash, artist.similar_artists))
cur.close() cur.close()
@classmethod @classmethod
-10
View File
@@ -8,7 +8,6 @@ from ..utils import SQLiteManager
def plugin_tuple_to_obj(plugin_tuple: tuple) -> Plugin: def plugin_tuple_to_obj(plugin_tuple: tuple) -> Plugin:
return Plugin( return Plugin(
name=plugin_tuple[1], name=plugin_tuple[1],
description=plugin_tuple[2],
active=bool(plugin_tuple[3]), active=bool(plugin_tuple[3]),
settings=json.loads(plugin_tuple[4]), settings=json.loads(plugin_tuple[4]),
) )
@@ -43,15 +42,6 @@ class PluginsMethods:
return lastrowid return lastrowid
@classmethod
def insert_lyrics_plugin(cls):
plugin = Plugin(
name="lyrics_finder",
description="Find lyrics from the internet",
active=False,
settings={"auto_download": False},
)
cls.insert_plugin(plugin)
@classmethod @classmethod
def get_all_plugins(cls): def get_all_plugins(cls):
-7
View File
@@ -14,13 +14,6 @@ CREATE TABLE IF NOT EXISTS playlists (
constraint fk_users foreign key (userid) references users(id) on delete cascade constraint fk_users foreign key (userid) references users(id) on delete cascade
); );
CREATE TABLE IF NOT EXISTS favorites (
id integer PRIMARY KEY,
hash text not null,
type text not null,
timestamp integer not null default 0
);
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
id integer PRIMARY KEY, id integer PRIMARY KEY,
root_dirs text NOT NULL, root_dirs text NOT NULL,
+109 -108
View File
@@ -1,150 +1,151 @@
from pprint import pprint from pprint import pprint
from typing import Any from typing import Any
from app.config import UserConfig
from app.db.sqlite.utils import SQLiteManager from app.db.sqlite.utils import SQLiteManager
from app.settings import SessionVars
from app.utils.wintools import win_replace_slash from app.utils.wintools import win_replace_slash
class SettingsSQLMethods: # class SettingsSQLMethods:
""" # """
Methods for interacting with the settings table. # Methods for interacting with the settings table.
""" # """
@staticmethod # @staticmethod
def get_all_settings(): # def get_all_settings():
""" # """
Gets all settings from the database. # Gets all settings from the database.
""" # """
sql = "SELECT * FROM settings WHERE id = 1" # sql = "SELECT * FROM settings WHERE id = 1"
with SQLiteManager(userdata_db=True) as cur: # with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql) # cur.execute(sql)
settings = cur.fetchone() # settings = cur.fetchone()
cur.close() # cur.close()
# if root_dirs not set # # if root_dirs not set
if settings is None: # if settings is None:
return [] # return []
# omit id, root_dirs, and exclude_dirs # # omit id, root_dirs, and exclude_dirs
return settings[3:] # return settings[3:]
@staticmethod # @staticmethod
def get_root_dirs() -> list[str]: # def get_root_dirs() -> list[str]:
""" # """
Gets custom root directories from the database. # Gets custom root directories from the database.
""" # """
sql = "SELECT root_dirs FROM settings" # sql = "SELECT root_dirs FROM settings"
with SQLiteManager(userdata_db=True) as cur: # with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql) # cur.execute(sql)
dirs = cur.fetchall() # dirs = cur.fetchall()
cur.close() # cur.close()
dirs = [_dir[0] for _dir in dirs] # dirs = [_dir[0] for _dir in dirs]
return [win_replace_slash(d) for d in dirs] # return [win_replace_slash(d) for d in dirs]
@staticmethod # @staticmethod
def add_root_dirs(dirs: list[str]): # def add_root_dirs(dirs: list[str]):
""" # """
Add custom root directories to the database. # Add custom root directories to the database.
""" # """
sql = "INSERT INTO settings (root_dirs) VALUES (?)" # sql = "INSERT INTO settings (root_dirs) VALUES (?)"
existing_dirs = SettingsSQLMethods.get_root_dirs() # existing_dirs = SettingsSQLMethods.get_root_dirs()
dirs = [_dir for _dir in dirs if _dir not in existing_dirs] # dirs = [_dir for _dir in dirs if _dir not in existing_dirs]
if len(dirs) == 0: # if len(dirs) == 0:
return # return
with SQLiteManager(userdata_db=True) as cur: # with SQLiteManager(userdata_db=True) as cur:
for _dir in dirs: # for _dir in dirs:
cur.execute(sql, (_dir,)) # cur.execute(sql, (_dir,))
@staticmethod # @staticmethod
def remove_root_dirs(dirs: list[str]): # def remove_root_dirs(dirs: list[str]):
""" # """
Remove custom root directories from the database. # Remove custom root directories from the database.
""" # """
sql = "DELETE FROM settings WHERE root_dirs = ?" # sql = "DELETE FROM settings WHERE root_dirs = ?"
with SQLiteManager(userdata_db=True) as cur: # with SQLiteManager(userdata_db=True) as cur:
for _dir in dirs: # for _dir in dirs:
cur.execute(sql, (_dir,)) # cur.execute(sql, (_dir,))
# Not currently used anywhere, to be used later # # Not currently used anywhere, to be used later
@staticmethod # @staticmethod
def add_excluded_dirs(dirs: list[str]): # def add_excluded_dirs(dirs: list[str]):
""" # """
Add custom exclude directories to the database. # Add custom exclude directories to the database.
""" # """
sql = "INSERT INTO settings (exclude_dirs) VALUES (?)" # sql = "INSERT INTO settings (exclude_dirs) VALUES (?)"
with SQLiteManager(userdata_db=True) as cur: # with SQLiteManager(userdata_db=True) as cur:
cur.executemany(sql, dirs) # cur.executemany(sql, dirs)
@staticmethod # @staticmethod
def remove_excluded_dirs(dirs: list[str]): # def remove_excluded_dirs(dirs: list[str]):
""" # """
Remove custom exclude directories from the database. # Remove custom exclude directories from the database.
""" # """
sql = "DELETE FROM settings WHERE exclude_dirs = ?" # sql = "DELETE FROM settings WHERE exclude_dirs = ?"
with SQLiteManager(userdata_db=True) as cur: # with SQLiteManager(userdata_db=True) as cur:
cur.executemany(sql, dirs) # cur.executemany(sql, dirs)
@staticmethod # @staticmethod
def get_excluded_dirs() -> list[str]: # def get_excluded_dirs() -> list[str]:
""" # """
Gets custom exclude directories from the database. # Gets custom exclude directories from the database.
""" # """
sql = "SELECT exclude_dirs FROM settings" # sql = "SELECT exclude_dirs FROM settings"
with SQLiteManager(userdata_db=True) as cur: # with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql) # cur.execute(sql)
dirs = cur.fetchall() # dirs = cur.fetchall()
return [_dir[0] for _dir in dirs] # return [_dir[0] for _dir in dirs]
@staticmethod # @staticmethod
def get_settings() -> dict[str, Any]: # def get_settings() -> dict[str, Any]:
pass # pass
@staticmethod # @staticmethod
def set_setting(key: str, value: Any): # def set_setting(key: str, value: Any):
sql = f"UPDATE settings SET {key} = :value WHERE id = 1" # sql = f"UPDATE settings SET {key} = :value WHERE id = 1"
if type(value) == bool: # if type(value) == bool:
value = str(int(value)) # value = str(int(value))
with SQLiteManager(userdata_db=True) as cur: # with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, {"value": value}) # cur.execute(sql, {"value": value})
def load_settings(): # def load_settings():
s = SettingsSQLMethods.get_all_settings() # # s = SettingsSQLMethods.get_all_settings()
# config = UserConfig()
try: # try:
db_separators: str = s[0] # db_separators: str = s[0]
db_separators = db_separators.replace(" ", "") # db_separators = db_separators.replace(" ", "")
separators = db_separators.split(",") # separators = db_separators.split(",")
separators = set(separators) # separators = set(separators)
except IndexError: # except IndexError:
separators = {";", "/"} # separators = {";", "/"}
SessionVars.ARTIST_SEPARATORS = separators # SessionVars.ARTIST_SEPARATORS = config.artistSeparators
# boolean settings # # boolean settings
SessionVars.EXTRACT_FEAT = bool(s[1]) # SessionVars.EXTRACT_FEAT = bool(s[1])
SessionVars.REMOVE_PROD = bool(s[2]) # SessionVars.REMOVE_PROD = bool(s[2])
SessionVars.CLEAN_ALBUM_TITLE = bool(s[3]) # SessionVars.CLEAN_ALBUM_TITLE = bool(s[3])
SessionVars.REMOVE_REMASTER_FROM_TRACK = bool(s[4]) # SessionVars.REMOVE_REMASTER_FROM_TRACK = bool(s[4])
SessionVars.MERGE_ALBUM_VERSIONS = bool(s[5]) # SessionVars.MERGE_ALBUM_VERSIONS = bool(s[5])
SessionVars.SHOW_ALBUMS_AS_SINGLES = bool(s[6]) # SessionVars.SHOW_ALBUMS_AS_SINGLES = bool(s[6])
+240
View File
@@ -0,0 +1,240 @@
import datetime
from shlex import join
from typing import Any
from flask_jwt_extended import current_user
from sqlalchemy import (
JSON,
Boolean,
ForeignKey,
Integer,
String,
and_,
delete,
insert,
select,
update,
join,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.utils import (
albums_to_dataclasses,
artists_to_dataclasses,
favorites_to_dataclass,
plugin_to_dataclasses,
similar_artist_to_dataclass,
similar_artists_to_dataclass,
tracks_to_dataclasses,
user_to_dataclass,
user_to_dataclasses,
)
from app.db import Base, DbManager
from app.utils.auth import hash_password
class UserTable(Base):
__tablename__ = "user"
id: Mapped[int] = mapped_column(primary_key=True)
image: Mapped[str] = mapped_column(String(), nullable=True)
password: Mapped[str] = mapped_column(String())
username: Mapped[str] = mapped_column(String(), index=True)
roles: Mapped[list[str]] = mapped_column(JSON(), default_factory=lambda: ["user"])
extra: Mapped[dict[str, Any]] = mapped_column(
JSON(), nullable=True, default_factory=dict
)
@classmethod
def get_all(cls):
result = cls.execute(select(cls))
return user_to_dataclasses(result.fetchall())
@classmethod
def insert_default_user(cls):
user = {
"username": "admin",
"password": hash_password("admin"),
"roles": ["admin"],
}
return cls.insert_one(user)
@classmethod
def insert_guest_user(cls):
user = {
"username": "guest",
"password": hash_password("guest"),
"roles": ["guest"],
}
return cls.insert_one(user)
@classmethod
def get_by_id(cls, id: int):
with DbManager() as conn:
result = conn.execute(select(cls).where(cls.id == id))
res = result.fetchone()
if res:
return user_to_dataclass(res)
@classmethod
def get_by_username(cls, username: str):
with DbManager() as conn:
result = conn.execute(select(cls).where(cls.username == username))
res = result.fetchone()
if res:
return user_to_dataclass(res)
@classmethod
def update_one(cls, user: dict[str, Any]):
with DbManager(commit=True) as conn:
conn.execute(update(cls).where(cls.id == user["id"]).values(user))
@classmethod
def remove_by_username(cls, username: str):
return cls.execute(delete(cls).where(cls.username == username), commit=True)
class PluginTable(Base):
__tablename__ = "plugin"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(), unique=True)
active: Mapped[bool] = mapped_column(Boolean())
settings: Mapped[dict[str, Any]] = mapped_column(JSON())
extra: Mapped[dict[str, Any]] = mapped_column(JSON(), nullable=True)
@classmethod
def get_all(cls):
return plugin_to_dataclasses(cls.all())
class SimilarArtistTable(Base):
__tablename__ = "notlastfm_similar_artists"
id: Mapped[int] = mapped_column(Integer(), primary_key=True)
artisthash: Mapped[str] = mapped_column(String(), index=True)
similar_artists: Mapped[dict[str, str]] = mapped_column(JSON())
@classmethod
def get_all(cls):
with DbManager() as conn:
result = conn.execute(select(cls))
return similar_artists_to_dataclass(result.fetchall())
@classmethod
def exists(cls, artisthash: str):
"""
Check whether an artisthash exists in the database.
"""
with DbManager() as conn:
result = conn.execute(
select(cls.artisthash).where(cls.artisthash == artisthash)
)
return result.fetchone() is not None
@classmethod
def get_by_hash(cls, artisthash: str):
"""
Get a single artist by hash.
"""
with DbManager() as conn:
result = conn.execute(select(cls).where(cls.artisthash == artisthash))
result = result.fetchone()
if result:
return similar_artist_to_dataclass(result)
class FavoritesTable(Base):
__tablename__ = "favorite"
id: Mapped[int] = mapped_column(primary_key=True)
hash: Mapped[str] = mapped_column(String())
type: Mapped[str] = mapped_column(String(), index=True)
timestamp: Mapped[int] = mapped_column(Integer(), index=True)
userid: Mapped[int] = mapped_column(
Integer(), ForeignKey("user.id"), default=1, index=True
)
extra: Mapped[dict[str, Any]] = mapped_column(
JSON(), nullable=True, default_factory=dict
)
@classmethod
def get_all(cls):
with DbManager() as conn:
result = conn.execute(select(cls))
return favorites_to_dataclass(result.fetchall())
@classmethod
def insert_item(cls, item: dict[str, Any]):
item["timestamp"] = int(datetime.datetime.now().timestamp())
item["userid"] = current_user["id"]
with DbManager(commit=True) as conn:
conn.execute(insert(cls).values(item))
@classmethod
def remove_item(cls, item: dict[str, Any]):
with DbManager(commit=True) as conn:
conn.execute(
delete(cls).where(
(cls.hash == item["hash"]) & (cls.type == item["type"])
)
)
@classmethod
def check_exists(cls, hash: str, type: str):
result = cls.execute(select(cls).where((cls.hash == hash) & (cls.type == type)))
return result.fetchone() is not None
@classmethod
def get_all_of_type(cls, table: Any, field: Any, type: str, start: int, limit: int):
result = cls.execute(
select(table)
.select_from(join(table, cls, field == cls.hash))
.where(and_(cls.type == type, cls.userid == current_user["id"]))
.offset(start)
# INFO: If start is 0, fetch all so we can get the total count
.limit(limit if start != 0 else None)
)
res = result.fetchall()
if start == 0:
return res[:limit], len(res)
return res, -1
@classmethod
def get_fav_tracks(cls, start: int, limit: int):
from .libdata import TrackTable
result, total = cls.get_all_of_type(
TrackTable, TrackTable.trackhash, "track", start, limit
)
return tracks_to_dataclasses(result), total
@classmethod
def get_fav_albums(cls, start: int, limit: int):
from .libdata import AlbumTable
result, total = cls.get_all_of_type(
AlbumTable, AlbumTable.albumhash, "album", start, limit
)
return albums_to_dataclasses(result), total
@classmethod
def get_fav_artists(cls, start: int, limit: int):
from .libdata import ArtistTable
result, total = cls.get_all_of_type(
ArtistTable, ArtistTable.artisthash, "artist", start, limit
)
return artists_to_dataclasses(result), total
+75
View File
@@ -0,0 +1,75 @@
from typing import Any
from app.models import Album as AlbumModel, Artist as ArtistModel, Track as TrackModel
from app.models.favorite import Favorite
from app.models.lastfm import SimilarArtist
from app.models.plugins import Plugin
from app.models.user import User
def track_to_dataclass(track: Any):
return TrackModel(**track._asdict())
def tracks_to_dataclasses(tracks: Any):
return [track_to_dataclass(track) for track in tracks]
def album_to_dataclass(album: Any):
return AlbumModel(**album._asdict())
def albums_to_dataclasses(albums: Any):
return [album_to_dataclass(album) for album in albums]
def artist_to_dataclass(artist: Any):
return ArtistModel(**artist._asdict())
def artists_to_dataclasses(artists: Any):
return [artist_to_dataclass(artist) for artist in artists]
# SECTION: User data helpers
def similar_artist_to_dataclass(entry: Any):
entry_dict = entry._asdict()
del entry_dict["id"]
return SimilarArtist(**entry_dict)
def similar_artists_to_dataclass(entries: Any):
return [similar_artist_to_dataclass(entry) for entry in entries]
def favorite_to_dataclass(entry: Any):
entry_dict = entry._asdict()
del entry_dict["id"]
return Favorite(**entry_dict)
def favorites_to_dataclass(entries: Any):
return [favorite_to_dataclass(entry) for entry in entries]
def user_to_dataclass(entry: Any):
entry_dict = entry._asdict()
return User(**entry_dict)
def user_to_dataclasses(entries: Any):
return [user_to_dataclass(entry) for entry in entries]
def plugin_to_dataclass(entry: Any):
entry_dict = entry._asdict()
del entry_dict["id"]
return Plugin(**entry_dict)
def plugin_to_dataclasses(entries: Any):
return [plugin_to_dataclass(entry) for entry in entries]
+6 -48
View File
@@ -12,63 +12,21 @@ from app.store.albums import AlbumStore
from app.store.tracks import TrackStore from app.store.tracks import TrackStore
def create_albums():
"""
Creates albums from the tracks in the store.
"""
# group all tracks by albumhash
tracks = TrackStore.tracks
tracks = sorted(tracks, key=lambda t: t.albumhash)
grouped = groupby(tracks, lambda t: t.albumhash)
# create albums from the groups
albums: list[Track] = []
for albumhash, tracks in grouped:
count = len(list(tracks))
duration = sum(t.duration for t in tracks)
created_date = min(t.created_date for t in tracks)
album = AlbumStore.create_album(list(tracks)[0])
album.set_count(count)
album.set_duration(duration)
album.set_created_date(created_date)
albums.append(album)
return albums
def validate_albums():
"""
Removes albums that have no tracks.
Probably albums that were added from incompletely written files.
"""
album_hashes = {t.albumhash for t in TrackStore.tracks}
albums = AlbumStore.albums
for album in albums:
if album.albumhash not in album_hashes:
AlbumStore.remove_album(album)
def remove_duplicate_on_merge_versions(tracks: list[Track]) -> list[Track]: def remove_duplicate_on_merge_versions(tracks: list[Track]) -> list[Track]:
""" """
Removes duplicate tracks when merging versions of the same album. Removes duplicate tracks when merging versions of the same album.
""" """
# TODO!
pass pass
def sort_by_track_no(tracks: list[Track]) -> list[dict[str, Any]]: def sort_by_track_no(tracks: list[Track]):
tracks = [asdict(t) for t in tracks] # tracks = [asdict(t) for t in tracks]
for t in tracks: for t in tracks:
track = str(t["track"]).zfill(3) track = str(t.track).zfill(3)
t["_pos"] = int(f"{t['disc']}{track}") t._pos = int(f"{t.disc}{track}")
tracks = sorted(tracks, key=lambda t: t["_pos"]) tracks = sorted(tracks, key=lambda t: t._pos)
return tracks return tracks
+13 -106
View File
@@ -1,5 +1,3 @@
from collections import namedtuple
from itertools import groupby
import os import os
import urllib import urllib
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@@ -12,9 +10,10 @@ from requests.exceptions import ConnectionError as RequestConnectionError
from requests.exceptions import ReadTimeout from requests.exceptions import ReadTimeout
from app import settings from app import settings
from app.models import Album, Artist, Track from app.db.libdata import ArtistTable
from app.store import artists as artist_store
from app.store.tracks import TrackStore # from app.store import artists as artist_store
# from app.store.tracks import TrackStore
from app.utils.hashing import create_hash from app.utils.hashing import create_hash
from app.utils.progressbar import tqdm from app.utils.progressbar import tqdm
@@ -107,22 +106,15 @@ class CheckArtistImages:
# read all files in the artist image folder # read all files in the artist image folder
path = settings.Paths.get_sm_artist_img_path() path = settings.Paths.get_sm_artist_img_path()
processed = "".join(os.listdir(path)).replace("webp", "") processed = [path.replace(".webp", "") for path in os.listdir(path)]
unprocessed = ArtistTable.get_artisthashes_not_in(processed)
# filter out artists that already have an image key_artist_map = ((instance_key, artist) for artist in unprocessed)
artists = filter(
lambda a: a.artisthash not in processed, artist_store.ArtistStore.artists
)
artists = list(artists)
# process the rest
key_artist_map = ((instance_key, artist) for artist in artists)
with ThreadPoolExecutor(max_workers=14) as executor: with ThreadPoolExecutor(max_workers=14) as executor:
res = list( res = list(
tqdm( tqdm(
executor.map(self.download_image, key_artist_map), executor.map(self.download_image, key_artist_map),
total=len(artists), total=len(unprocessed),
desc="Downloading missing artist images", desc="Downloading missing artist images",
) )
) )
@@ -130,7 +122,7 @@ class CheckArtistImages:
list(res) list(res)
@staticmethod @staticmethod
def download_image(_map: tuple[str, Artist]): def download_image(_map: tuple[str, dict[str, str]]):
""" """
Checks if an artist image exists and downloads it if not. Checks if an artist image exists and downloads it if not.
@@ -142,16 +134,17 @@ class CheckArtistImages:
return return
img_path = ( img_path = (
Path(settings.Paths.get_sm_artist_img_path()) / f"{artist.artisthash}.webp" Path(settings.Paths.get_sm_artist_img_path())
/ f"{artist['artisthash']}.webp"
) )
if img_path.exists(): if img_path.exists():
return return
url = get_artist_image_link(artist.name) url = get_artist_image_link(artist["name"])
if url is not None: if url is not None:
return DownloadImage(url, name=f"{artist.artisthash}.webp") return DownloadImage(url, name=f"{artist['artisthash']}.webp")
# def fetch_album_bio(title: str, albumartist: str) -> str | None: """ Returns the album bio for a given album. """ # def fetch_album_bio(title: str, albumartist: str) -> str | None: """ Returns the album bio for a given album. """
@@ -183,89 +176,3 @@ class CheckArtistImages:
# def __call__(self): # def __call__(self):
# return fetch_album_bio(self.title, self.albumartist) # return fetch_album_bio(self.title, self.albumartist)
def get_artists_from_tracks(tracks: list[Track]) -> set[str]:
"""
Extracts all artists from a list of tracks. Returns a list of Artists.
"""
artists = set()
master_artist_list = [[x.name for x in t.artists] for t in tracks]
artists = artists.union(*master_artist_list)
return artists
def get_albumartists(albums: list[Album]) -> set[str]:
artists = set()
for album in albums:
albumartists = [a.name for a in album.albumartists]
artists.update(albumartists)
return artists
def get_all_artists(tracks: list[Track], albums: list[Album]) -> list[Artist]:
TrackInfo = namedtuple(
"TrackInfo",
[
"artisthash",
"albumhash",
"trackhash",
"duration",
"artistname",
"created_date",
],
)
src_tracks = TrackStore.tracks
all_tracks: set[TrackInfo] = set()
for track in src_tracks:
artist_hashes = {(a.name, a.artisthash) for a in track.artists}.union(
(a.name, a.artisthash) for a in track.albumartists
)
for artist in artist_hashes:
track_info = TrackInfo(
artistname=artist[0],
artisthash=artist[1],
albumhash=track.albumhash,
trackhash=track.trackhash,
duration=track.duration,
created_date=track.created_date,
# work on created date
)
all_tracks.add(track_info)
all_tracks = sorted(all_tracks, key=lambda x: x.artisthash)
all_tracks = groupby(all_tracks, key=lambda x: x.artisthash)
artists = []
for artisthash, tracks in all_tracks:
tracks: list[TrackInfo] = list(tracks)
artistname = (
sorted({t.artistname for t in tracks})[0]
if len(tracks) > 1
else tracks[0].artistname
)
albumcount = len({t.albumhash for t in tracks})
duration = sum(t.duration for t in tracks)
created_date = min(t.created_date for t in tracks)
artist = Artist(name=artistname)
artist.set_trackcount(len(tracks))
artist.set_albumcount(albumcount)
artist.set_duration(duration)
artist.set_created_date(created_date)
artists.append(artist)
return artists
+33 -33
View File
@@ -52,47 +52,47 @@ def process_color(item_hash: str, is_album=True):
return get_image_colors(str(path)) return get_image_colors(str(path))
class ProcessAlbumColors: # class ProcessAlbumColors:
""" # """
Extracts the most dominant color from the album art and saves it to the database. # Extracts the most dominant color from the album art and saves it to the database.
""" # """
def __init__(self, instance_key: str) -> None: # def __init__(self, instance_key: str) -> None:
global PROCESS_ALBUM_COLORS_KEY # global PROCESS_ALBUM_COLORS_KEY
PROCESS_ALBUM_COLORS_KEY = instance_key # PROCESS_ALBUM_COLORS_KEY = instance_key
albums = [ # albums = [
a # a
for a in AlbumStore.albums # for a in AlbumStore.albums
if a is not None and a.colors is not None and len(a.colors) == 0 # if a is not None and a.colors is not None and len(a.colors) == 0
] # ]
with SQLiteManager() as cur: # with SQLiteManager() as cur:
try: # try:
for album in tqdm(albums, desc="Processing missing album colors"): # for album in tqdm(albums, desc="Processing missing album colors"):
if PROCESS_ALBUM_COLORS_KEY != instance_key: # if PROCESS_ALBUM_COLORS_KEY != instance_key:
raise PopulateCancelledError( # raise PopulateCancelledError(
"A newer 'ProcessAlbumColors' instance is running. Stopping this one." # "A newer 'ProcessAlbumColors' instance is running. Stopping this one."
) # )
# TODO: Stop hitting the database for every album. # # TODO: Stop hitting the database for every album.
# Instead, fetch all the data from the database and # # Instead, fetch all the data from the database and
# check from memory. # # check from memory.
exists = aldb.exists(album.albumhash, cur=cur) # exists = aldb.exists(album.albumhash, cur=cur)
if exists: # if exists:
continue # continue
colors = process_color(album.albumhash) # colors = process_color(album.albumhash)
if colors is None: # if colors is None:
continue # continue
album.set_colors(colors) # album.set_colors(colors)
color_str = json.dumps(colors) # color_str = json.dumps(colors)
aldb.insert_one_album(cur, album.albumhash, color_str) # aldb.insert_one_album(cur, album.albumhash, color_str)
finally: # finally:
cur.close() # cur.close()
class ProcessArtistColors: class ProcessArtistColors:
+3 -3
View File
@@ -5,9 +5,10 @@ from app.logger import log
from app.models import Folder from app.models import Folder
from app.serializers.track import serialize_tracks from app.serializers.track import serialize_tracks
from app.settings import SUPPORTED_FILES from app.settings import SUPPORTED_FILES
from app.store.folder import FolderStore
from app.utils.wintools import win_replace_slash from app.utils.wintools import win_replace_slash
from app.db import TrackTable as TrackDB from app.db.libdata import TrackTable as TrackDB
def create_folder(path: str, trackcount=0, foldercount=0) -> Folder: def create_folder(path: str, trackcount=0, foldercount=0) -> Folder:
@@ -43,8 +44,7 @@ def get_folders(paths: list[str]):
Filters out folders that don't have any tracks and Filters out folders that don't have any tracks and
returns a list of folder objects. returns a list of folder objects.
""" """
folders = TrackDB.count_tracks_containing_paths(paths) folders = FolderStore.count_tracks_containing_paths(paths)
return [ return [
create_folder(f["path"], f["trackcount"], foldercount=0) create_folder(f["path"], f["trackcount"], foldercount=0)
for f in folders for f in folders
+138 -138
View File
@@ -1,3 +1,4 @@
from dataclasses import asdict
import os import os
from collections import deque from collections import deque
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@@ -7,28 +8,26 @@ from requests import ConnectionError as RequestConnectionError
from requests import ReadTimeout from requests import ReadTimeout
from app import settings from app import settings
from app.db import TrackTable from app.db.libdata import ArtistTable
from app.db.libdata import AlbumTable, TrackTable
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.db.sqlite.lastfm.similar_artists import SQLiteLastFMSimilarArtists as lastfmdb
from app.db.sqlite.settings import SettingsSQLMethods as sdb # from app.db.sqlite.lastfm.similar_artists import SQLiteLastFMSimilarArtists as lastfmdb
from app.db.sqlite.tracks import SQLiteTrackMethods from app.db.sqlite.tracks import SQLiteTrackMethods
from app.lib.albumslib import validate_albums
from app.lib.artistlib import CheckArtistImages from app.lib.artistlib import CheckArtistImages
from app.lib.colorlib import ProcessAlbumColors, ProcessArtistColors from app.lib.colorlib import ProcessArtistColors
from app.lib.errors import PopulateCancelledError from app.lib.errors import PopulateCancelledError
from app.lib.taglib import extract_thumb, get_tags from app.lib.taglib import extract_thumb
from app.lib.trackslib import validate_tracks
from app.logger import log from app.logger import log
from app.models import Album, Artist, Track from app.models import Album, Artist, Track
from app.models.lastfm import SimilarArtist from app.models.lastfm import SimilarArtist
from app.requests.artists import fetch_similar_artists from app.requests.artists import fetch_similar_artists
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
from app.store.tracks import TrackStore
from app.utils.filesystem import run_fast_scandir from app.utils.filesystem import run_fast_scandir
from app.utils.network import has_connection from app.utils.network import has_connection
from app.utils.progressbar import tqdm from app.utils.progressbar import tqdm
from app.db.userdata import SimilarArtistTable
get_all_tracks = SQLiteTrackMethods.get_all_tracks get_all_tracks = SQLiteTrackMethods.get_all_tracks
insert_many_tracks = SQLiteTrackMethods.insert_many_tracks insert_many_tracks = SQLiteTrackMethods.insert_many_tracks
remove_tracks_by_filepaths = SQLiteTrackMethods.remove_tracks_by_filepaths remove_tracks_by_filepaths = SQLiteTrackMethods.remove_tracks_by_filepaths
@@ -44,50 +43,49 @@ class Populate:
also checks if the album art exists in the image path, if not tries to extract it. also checks if the album art exists in the image path, if not tries to extract it.
""" """
def __init__(self, instance_key: str) -> None: # def __init__(self, instance_key: str) -> None:
return # return
# if len(dirs_to_scan) == 0:
# log.warning(
# (
# "The root directory is not configured. "
# + "Open the app in your webbrowser to configure."
# )
# )
# return
# try:
# if dirs_to_scan[0] == "$home":
# dirs_to_scan = [settings.Paths.USER_HOME_DIR]
# except IndexError:
# pass
# files = set()
# for _dir in dirs_to_scan:
# files = files.union(run_fast_scandir(_dir, full=True)[1])
# unmodified, modified_tracks = self.remove_modified(tracks)
# untagged = files - unmodified
# if len(untagged) != 0:
# self.tag_untagged(untagged, instance_key)
# self.extract_thumb_with_overwrite(modified_tracks)
class CordinateMedia:
"""
Cordinates the extracting of thumbnails
"""
def __init__(self, instance_key: str):
global POPULATE_KEY global POPULATE_KEY
POPULATE_KEY = instance_key POPULATE_KEY = instance_key
validate_tracks()
validate_albums()
tracks = get_all_tracks()
dirs_to_scan = sdb.get_root_dirs()
if len(dirs_to_scan) == 0:
log.warning(
(
"The root directory is not configured. "
+ "Open the app in your webbrowser to configure."
)
)
return
try:
if dirs_to_scan[0] == "$home":
dirs_to_scan = [settings.Paths.USER_HOME_DIR]
except IndexError:
pass
files = set()
for _dir in dirs_to_scan:
files = files.union(run_fast_scandir(_dir, full=True)[1])
unmodified, modified_tracks = self.remove_modified(tracks)
untagged = files - unmodified
if len(untagged) != 0:
self.tag_untagged(untagged, instance_key)
self.extract_thumb_with_overwrite(modified_tracks)
try: try:
ProcessTrackThumbnails(instance_key) ProcessTrackThumbnails(instance_key)
ProcessAlbumColors(instance_key)
ProcessArtistColors(instance_key) ProcessArtistColors(instance_key)
except PopulateCancelledError as e: except PopulateCancelledError as e:
log.warn(e) log.warn(e)
@@ -95,10 +93,6 @@ class Populate:
tried_to_download_new_images = False tried_to_download_new_images = False
ArtistStore.load_artists(instance_key)
AlbumStore.load_albums(instance_key)
TrackStore.load_all_tracks(instance_key)
if has_connection(): if has_connection():
tried_to_download_new_images = True tried_to_download_new_images = True
try: try:
@@ -123,101 +117,101 @@ class Populate:
log.warn(e) log.warn(e)
return return
@staticmethod # @staticmethod
def remove_modified(tracks: Generator[TrackTable, None, None]): # def remove_modified(tracks: Generator[TrackTable, None, None]):
""" # """
Removes tracks from the database that have been modified # Removes tracks from the database that have been modified
since they were added to the database. # since they were added to the database.
""" # """
unmodified_paths = set() # unmodified_paths = set()
modified_tracks: list[TrackTable] = [] # modified_tracks: list[TrackTable] = []
modified_paths = set() # modified_paths = set()
for track in tracks: # for track in tracks:
try: # try:
if track.last_mod == round(os.path.getmtime(track.filepath)): # if track.last_mod == round(os.path.getmtime(track.filepath)):
unmodified_paths.add(track.filepath) # unmodified_paths.add(track.filepath)
continue # continue
except (FileNotFoundError, OSError) as e: # except (FileNotFoundError, OSError) as e:
log.warning(e) # REVIEW More informations = good # log.warning(e) # REVIEW More informations = good
TrackStore.remove_track_obj(track) # TrackStore.remove_track_obj(track)
remove_tracks_by_filepaths(track.filepath) # remove_tracks_by_filepaths(track.filepath)
modified_paths.add(track.filepath) # modified_paths.add(track.filepath)
modified_tracks.append(track) # modified_tracks.append(track)
TrackStore.remove_tracks_by_filepaths(modified_paths) # TrackStore.remove_tracks_by_filepaths(modified_paths)
remove_tracks_by_filepaths(modified_paths) # remove_tracks_by_filepaths(modified_paths)
return unmodified_paths, modified_tracks # return unmodified_paths, modified_tracks
@staticmethod # @staticmethod
def tag_untagged(untagged: set[str], key: str): # def tag_untagged(untagged: set[str], key: str):
pass # pass
# for file in tqdm(untagged, desc="Reading files"): # for file in tqdm(untagged, desc="Reading files"):
# if POPULATE_KEY != key: # if POPULATE_KEY != key:
# log.warning("'Populate.tag_untagged': Populate key changed") # log.warning("'Populate.tag_untagged': Populate key changed")
# return # return
# tags = get_tags(file) # tags = get_tags(file)
# if tags is not None: # if tags is not None:
# TrackTable.insert_one(tags) # TrackTable.insert_one(tags)
# ============================================= # =============================================
# log.info("Found %s new tracks", len(untagged)) # log.info("Found %s new tracks", len(untagged))
# # tagged_tracks: deque[dict] = deque() # # tagged_tracks: deque[dict] = deque()
# # tagged_count = 0 # # tagged_count = 0
# favs = favdb.get_fav_tracks() # favs = favdb.get_fav_tracks()
# records = dict() # records = dict()
# for fav in favs: # for fav in favs:
# r = records.setdefault(fav[1], set()) # r = records.setdefault(fav[1], set())
# r.add(fav[4]) # r.add(fav[4])
# tagged_tracks.append(tags) # tagged_tracks.append(tags)
# track = Track(**tags) # track = Track(**tags)
# track.fav_userids = list(records.get(track.trackhash, set())) # track.fav_userids = list(records.get(track.trackhash, set()))
# TrackStore.add_track(track) # TrackStore.add_track(track)
# if not AlbumStore.album_exists(track.albumhash): # if not AlbumStore.album_exists(track.albumhash):
# AlbumStore.add_album(AlbumStore.create_album(track)) # AlbumStore.add_album(AlbumStore.create_album(track))
# for artist in track.artists: # for artist in track.artists:
# if not ArtistStore.artist_exists(artist.artisthash): # if not ArtistStore.artist_exists(artist.artisthash):
# ArtistStore.add_artist(Artist(artist.name)) # ArtistStore.add_artist(Artist(artist.name))
# for artist in track.albumartists: # for artist in track.albumartists:
# if not ArtistStore.artist_exists(artist.artisthash): # if not ArtistStore.artist_exists(artist.artisthash):
# ArtistStore.add_artist(Artist(artist.name)) # ArtistStore.add_artist(Artist(artist.name))
# tagged_count += 1 # tagged_count += 1
# else: # else:
# log.warning("Could not read file: %s", file) # log.warning("Could not read file: %s", file)
# if len(tagged_tracks) > 0: # if len(tagged_tracks) > 0:
# log.info("Adding %s tracks to database", len(tagged_tracks)) # log.info("Adding %s tracks to database", len(tagged_tracks))
# insert_many_tracks(tagged_tracks) # insert_many_tracks(tagged_tracks)
# log.info("Added %s/%s tracks", tagged_count, len(untagged)) # log.info("Added %s/%s tracks", tagged_count, len(untagged))
@staticmethod # @staticmethod
def extract_thumb_with_overwrite(tracks: list[TrackTable]): # def extract_thumb_with_overwrite(tracks: list[TrackTable]):
""" # """
Extracts the thumbnail from a list of filepaths, # Extracts the thumbnail from a list of filepaths,
overwriting the existing thumbnail if it exists, # overwriting the existing thumbnail if it exists,
for modified files. # for modified files.
""" # """
for track in tracks: # for track in tracks:
try: # try:
extract_thumb(track.filepath, track.image, overwrite=True) # extract_thumb(track.filepath, track.image, overwrite=True)
except FileNotFoundError: # except FileNotFoundError:
continue # continue
def get_image(_map: tuple[str, Album]): def get_image(_map: tuple[str, Album]):
@@ -235,7 +229,8 @@ def get_image(_map: tuple[str, Album]):
raise PopulateCancelledError("'ProcessTrackThumbnails': Populate key changed") raise PopulateCancelledError("'ProcessTrackThumbnails': Populate key changed")
matching_tracks = filter( matching_tracks = filter(
lambda t: t.albumhash == album.albumhash, TrackStore.tracks lambda t: t.albumhash == album.albumhash,
TrackTable.get_tracks_by_albumhash(album.albumhash),
) )
try: try:
@@ -254,8 +249,12 @@ def get_image(_map: tuple[str, Album]):
pass pass
_cpu_count = os.cpu_count() def get_cpu_count():
CPU_COUNT = _cpu_count // 2 if _cpu_count > 2 else _cpu_count """
Returns the number of CPUs on the machine.
"""
cpu_count = os.cpu_count() or 0
return cpu_count // 2 if cpu_count > 2 else cpu_count
class ProcessTrackThumbnails: class ProcessTrackThumbnails:
@@ -275,14 +274,14 @@ class ProcessTrackThumbnails:
# filter out albums that already have thumbnails # filter out albums that already have thumbnails
albums = filter( albums = filter(
lambda album: album.albumhash not in processed, AlbumStore.albums lambda album: album.albumhash not in processed, AlbumTable.get_all()
) )
albums = list(albums) albums = list(albums)
# process the rest # process the rest
key_album_map = ((instance_key, album) for album in albums) key_album_map = ((instance_key, album) for album in albums)
with ThreadPoolExecutor(max_workers=CPU_COUNT) as executor: with ThreadPoolExecutor(max_workers=get_cpu_count()) as executor:
results = list( results = list(
tqdm( tqdm(
executor.map(get_image, key_album_map), executor.map(get_image, key_album_map),
@@ -307,16 +306,17 @@ def save_similar_artists(_map: tuple[str, Artist]):
"'FetchSimilarArtistsLastFM': Populate key changed" "'FetchSimilarArtistsLastFM': Populate key changed"
) )
if lastfmdb.exists(artist.artisthash): if SimilarArtistTable.exists(artist.artisthash):
return return
artist_hashes = fetch_similar_artists(artist.name) artists = fetch_similar_artists(artist.name)
artist_ = SimilarArtist(artist.artisthash, "~".join(artist_hashes))
if len(artist_.similar_artist_hashes) == 0: # INFO: Nones mean there was a connection error
if artists is None:
return return
lastfmdb.insert_one(artist_) artist_ = SimilarArtist(artist.artisthash, artists)
SimilarArtistTable.insert_one(asdict(artist_))
class FetchSimilarArtistsLastFM: class FetchSimilarArtistsLastFM:
@@ -326,17 +326,17 @@ class FetchSimilarArtistsLastFM:
def __init__(self, instance_key: str) -> None: def __init__(self, instance_key: str) -> None:
# read all artists from db # read all artists from db
processed = lastfmdb.get_all() processed = SimilarArtistTable.get_all()
processed = ".".join(a.artisthash for a in processed) processed = ".".join(a.artisthash for a in processed)
# filter out artists that already have similar artists # filter out artists that already have similar artists
artists = filter(lambda a: a.artisthash not in processed, ArtistStore.artists) artists = filter(lambda a: a.artisthash not in processed, ArtistTable.get_all())
artists = list(artists) artists = list(artists)
# process the rest # process the rest
key_artist_map = ((instance_key, artist) for artist in artists) key_artist_map = ((instance_key, artist) for artist in artists)
with ThreadPoolExecutor(max_workers=CPU_COUNT) as executor: with ThreadPoolExecutor(max_workers=get_cpu_count()) as executor:
try: try:
print("Processing similar artists") print("Processing similar artists")
results = list( results = list(
+163 -33
View File
@@ -1,55 +1,165 @@
import os
from pprint import pprint from pprint import pprint
from app.db import AlbumTable, ArtistTable, TrackTable from time import time
from app.lib.taglib import get_tags from typing import Generator
from app import settings
from app.config import UserConfig
from app.db.libdata import ArtistTable
from app.db.libdata import AlbumTable, TrackTable
from app.lib.populate import CordinateMedia
from app.lib.taglib import extract_thumb, get_tags
from app.models.track import Track
from app.store.folder import FolderStore
from app.utils.filesystem import run_fast_scandir from app.utils.filesystem import run_fast_scandir
from app.utils.parsers import get_base_album_title from app.utils.parsers import get_base_album_title
from app.utils.progressbar import tqdm from app.utils.progressbar import tqdm
from app.logger import log
from app.utils.threading import background
POPULATE_KEY: float = 0
class IndexTracks: class IndexTracks:
def __init__(self) -> None: def __init__(self, instance_key: float) -> None:
dirs_to_scan = ["/home/cwilvx/Music"] """
Indexes all tracks in the database.
An instance key is used to prevent multiple instances of the
same class from running at the same time.
"""
global POPULATE_KEY
POPULATE_KEY = instance_key
# dirs_to_scan = sdb.get_root_dirs()
dirs_to_scan = UserConfig().rootDirs
if len(dirs_to_scan) == 0:
log.warning(
(
"The root directory is not configured. "
+ "Open the app in your webbrowser to configure."
)
)
return
try:
if dirs_to_scan[0] == "$home":
dirs_to_scan = [settings.Paths.USER_HOME_DIR]
except IndexError:
pass
files = set() files = set()
for _dir in dirs_to_scan: for _dir in dirs_to_scan:
files = files.union(run_fast_scandir(_dir, full=True)[1]) files = files.union(run_fast_scandir(_dir, full=True)[1])
self.tag_untagged(files) unmodified, modified_tracks = self.filter_modded()
# unmodified, modified_tracks = self.remove_modified(tracks) untagged = files - unmodified
# untagged = files - unmodified
def tag_untagged(self, files: set[str]): self.tag_untagged(untagged, instance_key)
self.extract_thumb_with_overwrite(modified_tracks)
@staticmethod
def extract_thumb_with_overwrite(tracks: list[dict[str, str]]):
"""
Extracts the thumbnail from a list of filepaths,
overwriting the existing thumbnail if it exists,
for modified files.
"""
for track in tracks:
try:
extract_thumb(
track["filepath"], track["trackhash"] + ".webp", overwrite=True
)
except FileNotFoundError:
continue
@staticmethod
def filter_modded():
"""
Removes tracks from the database that have been modified
since they were indexed.
Returns a tuple of unmodified paths and modified tracks.
Unmodified paths are indexed and the modified tracks are
"""
unmodified_paths = set()
modified_tracks: list[dict[str, str]] = []
to_remove = set()
for track in TrackTable.get_all():
try:
if track.last_mod == round(os.path.getmtime(track.filepath)):
unmodified_paths.add(track.filepath)
continue
except (FileNotFoundError, OSError) as e:
log.warning(e) # REVIEW More informations = good
to_remove.add(track.filepath)
modified_tracks.append(
{
"filepath": track.filepath,
"trackhash": track.trackhash,
}
)
to_remove = to_remove.union(set(t["filepath"] for t in modified_tracks))
TrackTable.remove_tracks_by_filepaths(to_remove)
# REVIEW: Remove after testing!
track = TrackTable.get_tracks_by_filepaths(list(to_remove)[:1])
if track:
raise Exception("Track not removed")
# =============================================================
return unmodified_paths, modified_tracks
def get_untagged(self):
tracks = TrackTable.get_all()
def tag_untagged(self, files: set[str], key: float):
config = UserConfig()
for file in tqdm(files, desc="Reading files"): for file in tqdm(files, desc="Reading files"):
# if POPULATE_KEY != key: if POPULATE_KEY != key:
# log.warning("'Populate.tag_untagged': Populate key changed") log.warning("'Populate.tag_untagged': Populate key changed")
# return return
tags = get_tags(file) tags = get_tags(file, artist_separators=config.artistSeparators)
if tags is not None: if tags is not None:
TrackTable.insert_one(tags) TrackTable.insert_one(tags)
FolderStore.filepaths.add(tags["filepath"])
del tags del tags
print(f"{len(files)} new files indexed")
print("Done")
class IndexAlbums: class IndexAlbums:
def __init__(self) -> None: def __init__(self) -> None:
albums = dict() albums = dict()
all_tracks: list[Track] = TrackTable.get_all()
all_tracks: list[TrackTable] = TrackTable.get_all() if len(all_tracks) == 0:
return
for track in all_tracks: for track in all_tracks:
if track.albumhash not in albums: if track.albumhash not in albums:
albums[track.albumhash] = { albums[track.albumhash] = {
"albumartists": track.albumartists, "albumartists": track.albumartists,
"artisthashes": [a['artisthash'] for a in track.albumartists], "artisthashes": [a["artisthash"] for a in track.albumartists],
"albumhash": track.albumhash, "albumhash": track.albumhash,
"base_title": None, "base_title": None,
"color": None, "color": None,
"created_date": None, "created_date": None,
"date": None, "date": None,
"duration": track.duration, "duration": track.duration,
"genres": [*track.genre] if track.genre else [], "genres": [*track.genres] if track.genres else [],
"og_title": track.og_album, "og_title": track.og_album,
"title": track.album, "title": track.album,
"trackcount": 1, "trackcount": 1,
@@ -63,8 +173,8 @@ class IndexAlbums:
album["dates"].append(track.date) album["dates"].append(track.date)
album["created_dates"].append(track.last_mod) album["created_dates"].append(track.last_mod)
if track.genre: if track.genres:
album["genres"].extend(track.genre) album["genres"].extend(track.genres)
for album in albums.values(): for album in albums.values():
album["date"] = min(album["dates"]) album["date"] = min(album["dates"])
@@ -79,20 +189,23 @@ class IndexAlbums:
album["genres"] = genres album["genres"] = genres
album["base_title"], _ = get_base_album_title(album["og_title"]) album["base_title"], _ = get_base_album_title(album["og_title"])
del genres
del album["dates"] del album["dates"]
del album["created_dates"] del album["created_dates"]
pprint(albums) AlbumTable.remove_all()
AlbumTable.insert_many(list(albums.values())) AlbumTable.insert_many(list(albums.values()))
del albums del albums
class IndexArtists: class IndexArtists:
def __init__(self) -> None: def __init__(self) -> None:
all_tracks: list[TrackTable] = TrackTable.get_all() all_tracks: list[Track] = TrackTable.get_all()
artists = dict() artists = dict()
if len(all_tracks) == 0:
return
for track in all_tracks: for track in all_tracks:
this_artists = track.artists this_artists = track.artists
@@ -100,32 +213,33 @@ class IndexArtists:
if a not in this_artists: if a not in this_artists:
this_artists.append(a) this_artists.append(a)
for artist in this_artists: for thisartist in this_artists:
if artist["artisthash"] not in artists: if thisartist["artisthash"] not in artists:
artists[artist["artisthash"]] = { artists[thisartist["artisthash"]] = {
"albumcount": None, "albumcount": None,
"albums": {track.albumhash}, "albums": {track.albumhash},
"artisthash": artist["artisthash"], "artisthash": thisartist["artisthash"],
"created_dates": [track.last_mod], "created_dates": [track.last_mod],
"dates": [track.date], "dates": [track.date],
"date": None, "date": None,
"duration": track.duration, "duration": track.duration,
"genres": track.genre if track.genre else [], "genres": track.genres if track.genres else [],
"name": artist["name"], "name": None,
"names": {thisartist["name"]},
"trackcount": None, "trackcount": None,
"tracks": {track.trackhash}, "tracks": {track.trackhash},
} }
else: else:
artist = artists[artist["artisthash"]] artist = artists[thisartist["artisthash"]]
artist["duration"] += track.duration artist["duration"] += track.duration
artist["albums"].add(track.albumhash) artist["albums"].add(track.albumhash)
artist["tracks"].add(track.trackhash) artist["tracks"].add(track.trackhash)
artist["dates"].append(track.date) artist["dates"].append(track.date)
artist["created_dates"].append(track.last_mod) artist["created_dates"].append(track.last_mod)
artist["names"].add(thisartist["name"])
if track.genre: if track.genres:
artist["genres"].extend(track.genre) artist["genres"].extend(track.genres)
for artist in artists.values(): for artist in artists.values():
artist["albumcount"] = len(artist["albums"]) artist["albumcount"] = len(artist["albums"])
@@ -140,19 +254,35 @@ class IndexArtists:
genres.append(genre) genres.append(genre)
artist["genres"] = genres artist["genres"] = genres
artist["name"] = sorted(artist["names"])[0]
# INFO: Delete temporary keys
del artist["names"]
del artist["tracks"] del artist["tracks"]
del artist["albums"] del artist["albums"]
del artist["dates"] del artist["dates"]
del artist["created_dates"] del artist["created_dates"]
pprint(artists) # INFO: Delete local variables
del genres
ArtistTable.remove_all()
ArtistTable.insert_many(list(artists.values())) ArtistTable.insert_many(list(artists.values()))
del artists del artists
class IndexEverything: class IndexEverything:
def __init__(self) -> None: def __init__(self) -> None:
IndexTracks() IndexTracks(instance_key=time())
IndexAlbums() IndexAlbums()
IndexArtists() IndexArtists()
pass FolderStore.load_filepaths()
# pass
CordinateMedia(instance_key=str(time()))
@background
def index_everything():
return IndexEverything()
+42 -24
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from pprint import pprint from pprint import pprint
import re import re
import sys import sys
from typing import Any
import pendulum import pendulum
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
@@ -86,7 +87,7 @@ def extract_thumb(filepath: str, webp_path: str, overwrite=False) -> bool:
return False return False
def parse_date(date_str: str | None) -> int | None: def parse_date(date_str: str) -> int | None:
""" """
Extracts the date from a string and returns a timestamp. Extracts the date from a string and returns a timestamp.
""" """
@@ -108,12 +109,13 @@ def clean_filename(filename: str):
class ParseData: class ParseData:
artist: str artist: str
title: str title: str
artist_separators: set[str]
def __post_init__(self): def __post_init__(self):
self.artist = split_artists(self.artist) self.artist = split_artists(self.artist, self.artist_separators)
def extract_artist_title(filename: str): def extract_artist_title(filename: str, artist_separators: set[str]):
path = Path(filename).with_suffix("") path = Path(filename).with_suffix("")
path = clean_filename(str(path)) path = clean_filename(str(path))
@@ -121,22 +123,24 @@ def extract_artist_title(filename: str):
split_result = [x.strip() for x in split_result] split_result = [x.strip() for x in split_result]
if len(split_result) == 1: if len(split_result) == 1:
return ParseData("", split_result[0]) return ParseData("", split_result[0], artist_separators)
if len(split_result) > 2: if len(split_result) > 2:
try: try:
int(split_result[0]) int(split_result[0])
return ParseData(split_result[1], " - ".join(split_result[2:])) return ParseData(
split_result[1], " - ".join(split_result[2:]), artist_separators
)
except ValueError: except ValueError:
pass pass
artist = split_result[0] artist = split_result[0]
title = split_result[1] title = split_result[1]
return ParseData(artist, title) return ParseData(artist, title, artist_separators)
def get_tags(filepath: str): def get_tags(filepath: str, artist_separators: set[str]):
""" """
Returns the tags for a given audio file. Returns the tags for a given audio file.
""" """
@@ -150,7 +154,7 @@ def get_tags(filepath: str):
return None return None
try: try:
tags = TinyTag.get(filepath) tags: Any = TinyTag.get(filepath)
except: # noqa: E722 except: # noqa: E722
return None return None
@@ -169,7 +173,7 @@ def get_tags(filepath: str):
for tag in to_filename: for tag in to_filename:
p = getattr(tags, tag) p = getattr(tags, tag)
if p == "" or p is None: if p == "" or p is None:
parse_data = extract_artist_title(filename) parse_data = extract_artist_title(filename, artist_separators)
title = parse_data.title title = parse_data.title
setattr(tags, tag, title) setattr(tags, tag, title)
@@ -179,7 +183,7 @@ def get_tags(filepath: str):
if p == "" or p is None: if p == "" or p is None:
if not parse_data: if not parse_data:
parse_data = extract_artist_title(filename) parse_data = extract_artist_title(filename, artist_separators)
artist = parse_data.artist artist = parse_data.artist
@@ -225,8 +229,8 @@ def get_tags(filepath: str):
tags.artists = tags.artist tags.artists = tags.artist
tags.albumartists = tags.albumartist tags.albumartists = tags.albumartist
split_artist = split_artists(tags.artist) split_artist = split_artists(tags.artist, separators=artist_separators)
split_albumartists = split_artists(tags.albumartist) split_albumartists = split_artists(tags.albumartist, separators=artist_separators)
new_title = tags.title new_title = tags.title
# TODO: Figure out which is the best spot to create these hashes # TODO: Figure out which is the best spot to create these hashes
@@ -237,7 +241,9 @@ def get_tags(filepath: str):
# extract featured artists # extract featured artists
if config.extractFeaturedArtists: if config.extractFeaturedArtists:
feat, new_title = parse_feat_from_title(tags.title) feat, new_title = parse_feat_from_title(
tags.title, separators=artist_separators
)
original_lower = "-".join([create_hash(a) for a in split_artist]) original_lower = "-".join([create_hash(a) for a in split_artist])
split_artist.extend(a for a in feat if create_hash(a) not in original_lower) split_artist.extend(a for a in feat if create_hash(a) not in original_lower)
@@ -262,8 +268,9 @@ def get_tags(filepath: str):
for a in split_albumartists for a in split_albumartists
] ]
tags.artisthashes = list({a["artisthash"] for a in tags.artists + tags.albumartists}) tags.artisthashes = list(
{a["artisthash"] for a in tags.artists + tags.albumartists}
)
# remove prod by # remove prod by
if config.removeProdBy: if config.removeProdBy:
@@ -295,26 +302,32 @@ def get_tags(filepath: str):
# process genres # process genres
if tags.genre: if tags.genre:
tags.genre = tags.genre.lower() src_genres: str = tags.genre
src_genres = src_genres.lower()
# separators = {"/", ";", "&"} # separators = {"/", ";", "&"}
separators = set(config.genreSeparators) separators = set(config.genreSeparators)
contains_rnb = "r&b" in tags.genre contains_rnb = "r&b" in src_genres
contains_rock = "rock & roll" in tags.genre contains_rock = "rock & roll" in src_genres
if contains_rnb: if contains_rnb:
tags.genre = tags.genre.replace("r&b", "RnB") src_genres = src_genres.replace("r&b", "RnB")
if contains_rock: if contains_rock:
tags.genre = tags.genre.replace("rock & roll", "rock") src_genres = src_genres.replace("rock & roll", "rock")
for s in separators: for s in separators:
tags.genre = tags.genre.replace(s, ",") src_genres = src_genres.replace(s, ",")
tags.genre = tags.genre.split(",") genres_list: list[str] = src_genres.split(",")
tags.genre = [ tags.genres = [
{"name": g.strip(), "genrehash": create_hash(g.strip())} for g in tags.genre {"name": g.strip(), "genrehash": create_hash(g.strip())}
for g in genres_list
] ]
tags.genrehashes = [g["genrehash"] for g in tags.genres]
else:
tags.genres = []
tags.genrehashes = []
# sub underscore with space # sub underscore with space
tags.title = tags.title.replace("_", " ") tags.title = tags.title.replace("_", " ")
@@ -333,6 +346,10 @@ def get_tags(filepath: str):
"filesize": tags.filesize, "filesize": tags.filesize,
"samplerate": tags.samplerate, "samplerate": tags.samplerate,
"track_total": tags.track_total, "track_total": tags.track_total,
"hashinfo": {
"algo": "sha1",
"format": "[:5]+[-5:]", # first 5 + last 5 chars
},
} }
tags.extra = {**tags.extra, **more_extra} tags.extra = {**tags.extra, **more_extra}
@@ -357,6 +374,7 @@ def get_tags(filepath: str):
"bitdepth", "bitdepth",
"artist", "artist",
"albumartist", "albumartist",
"genre",
] ]
for tag in to_delete: for tag in to_delete:
-10
View File
@@ -13,16 +13,6 @@ from app.utils.progressbar import tqdm
from app.utils.threading import ThreadWithReturnValue from app.utils.threading import ThreadWithReturnValue
def validate_tracks() -> None:
"""
Removes track records whose files no longer exist.
"""
for track in tqdm(TrackStore.tracks, desc="Validating tracks"):
if not os.path.exists(track.filepath):
TrackStore.remove_track_obj(track)
trackdb.remove_tracks_by_filepaths(track.filepath)
def get_leading_silence_end(filepath: str): def get_leading_silence_end(filepath: str):
""" """
Returns the leading silence of a track. Returns the leading silence of a track.
+7 -3
View File
@@ -11,8 +11,10 @@ from watchdog.events import PatternMatchingEventHandler
from watchdog.observers import Observer from watchdog.observers import Observer
from app import settings from app import settings
from app.config import UserConfig
from app.db.sqlite.albumcolors import SQLiteAlbumMethods as aldb from app.db.sqlite.albumcolors import SQLiteAlbumMethods as aldb
from app.db.sqlite.settings import SettingsSQLMethods as sdb
# from app.db.sqlite.settings import SettingsSQLMethods as sdb
from app.db.sqlite.tracks import SQLiteManager from app.db.sqlite.tracks import SQLiteManager
from app.db.sqlite.tracks import SQLiteTrackMethods as db from app.db.sqlite.tracks import SQLiteTrackMethods as db
from app.lib.colorlib import process_color from app.lib.colorlib import process_color
@@ -43,7 +45,8 @@ class Watcher:
while trials < 10: while trials < 10:
try: try:
dirs = sdb.get_root_dirs() # dirs = sdb.get_root_dirs()
dirs = UserConfig().rootDirs
dirs = [rf"{d}" for d in dirs] dirs = [rf"{d}" for d in dirs]
dir_map = [ dir_map = [
@@ -152,7 +155,8 @@ def add_track(filepath: str) -> None:
TrackStore.remove_track_by_filepath(filepath) TrackStore.remove_track_by_filepath(filepath)
tags = get_tags(filepath) config = UserConfig()
tags = get_tags(filepath, artist_separators=config.artistSeparators)
# if the track is somehow invalid, return # if the track is somehow invalid, return
if tags is None or tags["bitrate"] == 0 or tags["duration"] == 0: if tags is None or tags["bitrate"] == 0 or tags["duration"] == 0:
+20 -15
View File
@@ -7,7 +7,9 @@ Reads and applies the latest database migrations.
import inspect import inspect
import sys import sys
from types import ModuleType from types import ModuleType
from app.db.sqlite.migrations import MigrationManager
# from app.db.sqlite.migrations import MigrationManager
from app.db.metadata import MigrationTable
from app.logger import log from app.logger import log
from app.migrations import v1_3_0, v1_4_9 from app.migrations import v1_3_0, v1_4_9
from app.migrations.base import Migration from app.migrations.base import Migration
@@ -42,26 +44,29 @@ def apply_migrations():
modules = [v1_3_0, v1_4_9] modules = [v1_3_0, v1_4_9]
migrations = [get_all_migrations(m) for m in modules] migrations = [get_all_migrations(m) for m in modules]
index = MigrationManager.get_index() # index = MigrationManager.get_index()
index = MigrationTable.get_version()
all_migrations = [migration for sublist in migrations for migration in sublist] all_migrations = [migration for sublist in migrations for migration in sublist]
to_apply: list[Migration] = [] to_apply: list[Migration] = []
# if index is from old release, # if index is from old release,
# get migrations from the "migrations" list # get migrations from the "migrations" list
if index < 3:
_migrations = migrations[index:] # if index < 3:
to_apply = [migration for sublist in _migrations for migration in sublist] # _migrations = migrations[index:]
else: # to_apply = [migration for sublist in _migrations for migration in sublist]
to_apply = all_migrations[index:] # else:
# to_apply = all_migrations[index:]
for migration in to_apply: # for migration in to_apply:
# try: # # try:
migration.migrate() # migration.migrate()
log.info("Applied migration: %s", migration.__name__) # log.info("Applied migration: %s", migration.__name__)
# except Exception as e: # except Exception as e:
# log.error("Failed to run migration: %s", migration.__name__) # log.error("Failed to run migration: %s", migration.__name__)
# log.error(e) # log.error(e)
# sys.exit(0) # sys.exit(0)
MigrationManager.set_index(len(all_migrations)) # MigrationManager.set_index(len(all_migrations))
MigrationTable.set_version(len(all_migrations))
+7 -4
View File
@@ -2,9 +2,6 @@ import dataclasses
import datetime import datetime
from dataclasses import dataclass from dataclasses import dataclass
from app.config import UserConfig
from app.settings import SessionVarKeys, get_flag
from ..utils.hashing import create_hash from ..utils.hashing import create_hash
from ..utils.parsers import get_base_title_and_versions, parse_feat_from_title from ..utils.parsers import get_base_title_and_versions, parse_feat_from_title
from .artist import Artist from .artist import Artist
@@ -27,15 +24,21 @@ class Album:
date: int date: int
duration: int duration: int
genres: list[dict[str, str]] genres: list[dict[str, str]]
genrehashes: list[str]
og_title: str og_title: str
title: str title: str
trackcount: int trackcount: int
is_favorite: bool
extra: dict
type: str = "album" type: str = "album"
image: str = ""
versions: list[str] = dataclasses.field(default_factory=list) versions: list[str] = dataclasses.field(default_factory=list)
def __post_init__(self): def __post_init__(self):
self.date = datetime.datetime.fromtimestamp(self.date).year # self.date = datetime.datetime.fromtimestamp(self.date).year
self.image = self.albumhash + ".webp"
self.populate_versions()
# albumhash: str # albumhash: str
# title: str # title: str
+7
View File
@@ -44,6 +44,13 @@ class Artist:
date: int date: int
duration: int duration: int
genres: list[dict[str, str]] genres: list[dict[str, str]]
genrehashes: list[str]
name: str name: str
trackcount: int trackcount: int
is_favorite: bool is_favorite: bool
extra: dict
image: str = ""
def __post_init__(self):
self.image = self.artisthash + ".webp"
+11
View File
@@ -0,0 +1,11 @@
from dataclasses import dataclass
from typing import Any, Literal
@dataclass
class Favorite:
hash: str
type: Literal["album", "track", "artist"]
timestamp: int
userid: int
extra: dict[str, Any]
+17 -2
View File
@@ -1,13 +1,28 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
@dataclass
class SimilarArtistEntry:
artisthash: str
name: str
weight: float
scrobbles: int
listeners: int
@dataclass @dataclass
class SimilarArtist: class SimilarArtist:
artisthash: str artisthash: str
similar_artist_hashes: str similar_artists: list[SimilarArtistEntry]
def get_artist_hash_set(self) -> set[str]: def get_artist_hash_set(self) -> set[str]:
""" """
Returns a set of similar artists. Returns a set of similar artists.
""" """
return set(self.similar_artist_hashes.split("~")) if not self.similar_artists:
return set()
# INFO:
return set(a['artisthash'] for a in self.similar_artists)
+1 -1
View File
@@ -4,7 +4,7 @@ from dataclasses import dataclass
@dataclass @dataclass
class Plugin: class Plugin:
name: str name: str
description: str
active: bool active: bool
settings: dict settings: dict
extra: dict
+12 -19
View File
@@ -1,20 +1,4 @@
from dataclasses import dataclass, field from dataclasses import dataclass
import os
from pathlib import Path
from flask_jwt_extended import current_user
from app.settings import SessionVarKeys, get_flag
from app.utils.hashing import create_hash
from app.utils.parsers import (
clean_title,
get_base_title_and_versions,
parse_feat_from_title,
remove_prod,
split_artists,
)
from .artist import ArtistMinimal
@dataclass(slots=True) @dataclass(slots=True)
@@ -28,7 +12,7 @@ class Track:
albumartists: list[dict[str, str]] albumartists: list[dict[str, str]]
albumhash: str albumhash: str
artisthashes: list[str] artisthashes: list[str]
artists: str artists: list[dict[str, str]]
bitrate: int bitrate: int
copyright: str copyright: str
date: int date: int
@@ -36,7 +20,8 @@ class Track:
duration: int duration: int
filepath: str filepath: str
folder: str folder: str
genre: list[dict[str, str]] genres: list[dict[str, str]]
genrehashes: list[str]
last_mod: int last_mod: int
og_album: str og_album: str
og_title: str og_title: str
@@ -48,7 +33,15 @@ class Track:
is_favorite: bool = False is_favorite: bool = False
_pos: int = 0 _pos: int = 0
_ati: str = "" _ati: str = ""
image: str = ""
def __post_init__(self):
self.image = self.albumhash + ".webp"
self.extra = {
"disc_total": self.extra.get("disc_total", 0),
"track_total": self.extra.get("track_total", 0),
"samplerate": self.extra.get("samplerate", -1),
}
# album: str # album: str
# albumartists: str | list[ArtistMinimal] # albumartists: str | list[ArtistMinimal]
+5 -9
View File
@@ -5,19 +5,15 @@ import json
@dataclass(slots=True) @dataclass(slots=True)
class User: class User:
id: int id: int
username: str
firstname: str
lastname: str
password: str
email: str
image: str image: str
password: str
username: str
roles: list[str]
extra: dict[str, str] = field(default_factory=dict)
# NOTE: roles: ['admin', 'user', 'curator'] # NOTE: roles: ['admin', 'user', 'curator']
roles: list[str] = field(default_factory=lambda: ["user"]) roles: list[str] = field(default_factory=lambda: ["user"])
def __post_init__(self):
self.roles = json.loads(self.roles)
def todict(self): def todict(self):
this_dict = asdict(self) this_dict = asdict(self)
del this_dict["password"] del this_dict["password"]
@@ -28,5 +24,5 @@ class User:
return { return {
"id": self.id, "id": self.id,
"username": self.username, "username": self.username,
"firstname": self.firstname, "firstname": self.extra["firstname"] if self.extra else "",
} }
+5 -7
View File
@@ -1,14 +1,16 @@
""" """
This module contains functions for the server This module contains functions for the server
""" """
import time import time
from app.config import UserConfig
from app.lib.populate import Populate, PopulateCancelledError from app.lib.populate import Populate, PopulateCancelledError
from app.settings import SessionVarKeys, get_flag, get_scan_sleep_time
from app.utils.generators import get_random_str from app.utils.generators import get_random_str
from app.utils.threading import background from app.utils.threading import background
from app.logger import log from app.logger import log
@background @background
def run_periodic_scans(): def run_periodic_scans():
""" """
@@ -23,10 +25,7 @@ def run_periodic_scans():
# ValidateAlbumThumbs() # ValidateAlbumThumbs()
# ValidatePlaylistThumbs() # ValidatePlaylistThumbs()
run_periodic_scan = True while UserConfig().enablePeriodicScans:
while run_periodic_scan:
run_periodic_scan = get_flag(SessionVarKeys.DO_PERIODIC_SCANS)
try: try:
Populate(instance_key=get_random_str()) Populate(instance_key=get_random_str())
@@ -34,5 +33,4 @@ def run_periodic_scans():
log.error("'run_periodic_scans': Periodic scan cancelled.") log.error("'run_periodic_scans': Periodic scan cancelled.")
pass pass
sleep_time = get_scan_sleep_time() time.sleep(UserConfig().scanInterval)
time.sleep(sleep_time)
+15 -2
View File
@@ -1,5 +1,18 @@
from app.db.sqlite.plugins import PluginsMethods from app.db.userdata import PluginTable
from sqlalchemy.exc import IntegrityError
def register_plugins(): def register_plugins():
PluginsMethods.insert_lyrics_plugin() try:
PluginTable.insert_one(
{
"name": "lyrics_finder",
"active": False,
"settings": {"auto_download": False},
"extra": {
"description": "Find lyrics from the internet",
},
}
)
except IntegrityError:
pass
+14 -3
View File
@@ -7,6 +7,7 @@ import urllib.parse
import requests import requests
from requests import ConnectionError, HTTPError, ReadTimeout from requests import ConnectionError, HTTPError, ReadTimeout
from app.models.lastfm import SimilarArtistEntry
from app.utils.hashing import create_hash from app.utils.hashing import create_hash
@@ -20,7 +21,7 @@ def fetch_similar_artists(name: str):
response = requests.get(url, timeout=10) response = requests.get(url, timeout=10)
response.raise_for_status() response.raise_for_status()
except (ConnectionError, ReadTimeout, HTTPError): except (ConnectionError, ReadTimeout, HTTPError):
return [] return None
data = response.json() data = response.json()
@@ -29,5 +30,15 @@ def fetch_similar_artists(name: str):
except KeyError: except KeyError:
return [] return []
for artist in artists: return [
yield create_hash(artist["name"]) SimilarArtistEntry(
**{
"artisthash": create_hash(artist["name"]),
"name": artist["name"],
"weight": artist["weight"],
"listeners": int(artist["listeners"]),
"scrobbles": int(artist["scrobbles"]),
}
)
for artist in artists
]
-11
View File
@@ -244,17 +244,6 @@ class SessionVarKeys:
SHOW_ALBUMS_AS_SINGLES = "SHOW_ALBUMS_AS_SINGLES" SHOW_ALBUMS_AS_SINGLES = "SHOW_ALBUMS_AS_SINGLES"
def get_flag(key: SessionVarKeys) -> bool:
return getattr(SessionVars, key)
def set_flag(key: SessionVarKeys, value: Any):
setattr(SessionVars, key, value)
def get_scan_sleep_time() -> int:
return SessionVars.PERIODIC_SCAN_INTERVAL
class TCOLOR: class TCOLOR:
""" """
+11 -10
View File
@@ -3,11 +3,11 @@ Prepares the server for use.
""" """
import uuid import uuid
from app.db.sqlite.settings import load_settings
from app.setup.files import create_config_dir from app.setup.files import create_config_dir
from app.setup.sqlite import run_migrations, setup_sqlite from app.setup.sqlite import run_migrations, setup_sqlite
from app.store.albums import AlbumStore from app.store.albums import AlbumStore
from app.store.artists import ArtistStore from app.store.artists import ArtistStore
from app.store.folder import FolderStore
from app.store.tracks import TrackStore from app.store.tracks import TrackStore
from app.utils.generators import get_random_str from app.utils.generators import get_random_str
from app.config import UserConfig from app.config import UserConfig
@@ -29,20 +29,21 @@ def run_setup():
setup_sqlite() setup_sqlite()
run_migrations() run_migrations()
try: # try:
load_settings() # load_settings()
except IndexError: # except IndexError:
# settings table is empty # # settings table is empty
pass # pass
def load_into_mem(): def load_into_mem():
""" """
Load all tracks, albums, and artists into memory. Load all tracks, albums, and artists into memory.
""" """
instance_key = get_random_str() # instance_key = get_random_str()
# INFO: Load all tracks, albums, and artists into memory # INFO: Load all tracks, albums, and artists into memory
TrackStore.load_all_tracks(instance_key) # TrackStore.load_all_tracks(instance_key)
AlbumStore.load_albums(instance_key) # AlbumStore.load_albums(instance_key)
ArtistStore.load_artists(instance_key) # ArtistStore.load_artists(instance_key)
FolderStore.load_filepaths()
+8 -14
View File
@@ -3,11 +3,15 @@ Module to setup Sqlite databases and tables.
Applies migrations. Applies migrations.
""" """
from app.db.userdata import UserTable
from app.db.sqlite import create_connection, create_tables, queries from app.db.sqlite import create_connection, create_tables, queries
from app.db.sqlite.auth import SQLiteAuthMethods as authdb from app.db.sqlite.auth import SQLiteAuthMethods as authdb
from app.migrations import apply_migrations from app.migrations import apply_migrations
from app.settings import Db from app.settings import Db
from app.db import create_all
from app.db.libdata import create_all as create_all_libdata
def run_migrations(): def run_migrations():
""" """
@@ -20,18 +24,8 @@ def setup_sqlite():
""" """
Create Sqlite databases and tables. Create Sqlite databases and tables.
""" """
# if os.path.exists(DB_PATH): create_all()
# os.remove(DB_PATH) create_all_libdata()
app_db_conn = create_connection(Db.get_app_db_path()) if not UserTable.get_all():
user_db_conn = create_connection(Db.get_userdata_db_path()) UserTable.insert_default_user()
create_tables(app_db_conn, queries.CREATE_APPDB_TABLES)
create_tables(user_db_conn, queries.CREATE_USERDATA_TABLES)
create_tables(app_db_conn, queries.CREATE_MIGRATIONS_TABLE)
if not authdb.get_all_users():
authdb.insert_default_user()
app_db_conn.close()
user_db_conn.close()
-1
View File
@@ -1,7 +1,6 @@
import json import json
from app.db.sqlite.artistcolors import SQLiteArtistMethods as ardb from app.db.sqlite.artistcolors import SQLiteArtistMethods as ardb
from app.lib.artistlib import get_all_artists
from app.models import Artist from app.models import Artist
from app.utils.bisection import use_bisection from app.utils.bisection import use_bisection
from app.utils.customlist import CustomList from app.utils.customlist import CustomList
+95
View File
@@ -0,0 +1,95 @@
from sortedcontainers import SortedSet
from concurrent.futures import ThreadPoolExecutor
from app.db.libdata import TrackTable
class FolderStore:
"""
The Folder store is used to hold all the indexed tracks filepaths in memory
for fast count operations when browsing the folder page.
Counting from the database is super slow, even with a small number of folders to get the count for. Up to 700ms for 10 folders. By using this store, we are able to reduce that to less than 10ms.
"""
filepaths: SortedSet = SortedSet()
@classmethod
def load_filepaths(cls):
"""
Load all the filepaths from the database into memory.
This is needed to speed up the process of counting the number of tracks in the folder page.
"""
cls.filepaths.clear()
tracks = TrackTable.get_all()
for track in tracks:
cls.filepaths.add(track.filepath)
@classmethod
def count_tracks_containing_paths(cls, paths: list[str]):
"""
Count the number of tracks in each directory.
Uses a ThreadPoolExecutor to count the number of tracks
in each directory for fast execution time.
"""
results: list[dict[str, int | str]] = []
with ThreadPoolExecutor() as executor:
res = executor.map(countFilepathsInDir, paths)
results = [
{"path": path, "trackcount": count} for path, count in zip(paths, res)
]
return results
def getIndexOfFirstMatch(strings: list[str], prefix: str):
"""
Find the index of the first path that starts with the given path.
Uses a binary search algorithm to find the index.
"""
left = 0
right = len(strings) - 1
while left <= right:
mid = (left + right) // 2
if strings[mid].startswith(prefix):
if mid == 0 or not strings[mid - 1].startswith(prefix):
return mid
right = mid - 1
elif strings[mid] < prefix:
left = mid + 1
else:
right = mid - 1
return -1
def countFilepathsInDir(dirpath: str):
"""
Counts the number of filepaths that start with the given directory path.
Gets the index of the first path that starts with the given directory path,
then checks each path after that to see if it starts with the given directory path.
"""
index = getIndexOfFirstMatch(FolderStore.filepaths, dirpath)
if index == -1:
return 0
paths: list[str] = []
for path in FolderStore.filepaths[index:]:
if path.startswith(dirpath):
paths.append(path)
else:
break
return len(paths)
+1 -1
View File
@@ -26,7 +26,7 @@ def create_new_date(date: datetime = None) -> str:
return date.strftime(_format) return date.strftime(_format)
def timestamp_to_time_passed(timestamp: str): def timestamp_to_time_passed(timestamp: str | int):
""" """
Converts a timestamp to time passed. e.g. 2 minutes ago, 1 hour ago, yesterday, 2 days ago, 2 weeks ago, etc. Converts a timestamp to time passed. e.g. 2 minutes ago, 1 hour ago, yesterday, 2 days ago, 2 weeks ago, etc.
""" """
+1
View File
@@ -34,6 +34,7 @@ def create_hash(*args: str, decode=False, limit=10) -> str:
str_ = str_.encode("utf-8") str_ = str_.encode("utf-8")
str_ = hashlib.sha1(str_).hexdigest() str_ = hashlib.sha1(str_).hexdigest()
# INFO: Return first 5 + last 5 characters
return ( return (
str_[: limit // 2] + str_[-limit // 2 :] str_[: limit // 2] + str_[-limit // 2 :]
if limit % 2 == 0 if limit % 2 == 0
+3 -5
View File
@@ -1,14 +1,12 @@
import re import re
from app.enums.album_versions import AlbumVersionEnum, get_all_keywords from app.enums.album_versions import AlbumVersionEnum, get_all_keywords
from app.settings import SessionVarKeys, get_flag
def split_artists(src: str): def split_artists(src: str, separators: set[str]):
""" """
Splits a string of artists into a list of artists. Splits a string of artists into a list of artists.
""" """
separators: set = get_flag(SessionVarKeys.ARTIST_SEPARATORS)
for sep in separators: for sep in separators:
src = src.replace(sep, ",") src = src.replace(sep, ",")
@@ -38,7 +36,7 @@ def remove_prod(title: str) -> str:
return title.strip() return title.strip()
def parse_feat_from_title(title: str) -> tuple[list[str], str]: def parse_feat_from_title(title: str, separators: set[str]) -> tuple[list[str], str]:
""" """
Extracts featured artists from a song title using regex. Extracts featured artists from a song title using regex.
""" """
@@ -56,7 +54,7 @@ def parse_feat_from_title(title: str) -> tuple[list[str], str]:
return [], title return [], title
artists = match.group(1) artists = match.group(1)
artists = split_artists(artists) artists = split_artists(artists, separators)
# remove "feat" group from title # remove "feat" group from title
new_title = re.sub(regex, "", title, flags=re.IGNORECASE) new_title = re.sub(regex, "", title, flags=re.IGNORECASE)
+1 -1
View File
@@ -45,7 +45,7 @@ mimetypes.add_type("image/gif", ".gif")
mimetypes.add_type("font/woff", ".woff") mimetypes.add_type("font/woff", ".woff")
mimetypes.add_type("application/manifest+json", ".webmanifest") mimetypes.add_type("application/manifest+json", ".webmanifest")
logging.disable(logging.CRITICAL) # logging.disable(logging.CRITICAL)
# werkzeug = logging.getLogger("werkzeug") # werkzeug = logging.getLogger("werkzeug")
# werkzeug.setLevel(logging.ERROR) # werkzeug.setLevel(logging.ERROR)
Generated
+1 -1
View File
@@ -2616,4 +2616,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.10,<3.12" python-versions = ">=3.10,<3.12"
content-hash = "9c7ba20671a6a3b59dbb120e3e56ded7e4dfcbf2de14418bdef41059233cdcb1" content-hash = "80cb2755efc6cec2cb20d50cb8927dee554991741283e70e7a2665e6253b895d"
+1
View File
@@ -28,6 +28,7 @@ flask-openapi3 = "^3.0.2"
flask-jwt-extended = "^4.6.0" flask-jwt-extended = "^4.6.0"
sqlalchemy = "^2.0.31" sqlalchemy = "^2.0.31"
memory-profiler = "^0.61.0" memory-profiler = "^0.61.0"
sortedcontainers = "^2.4.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pylint = "^2.15.5" pylint = "^2.15.5"