mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-03 20:13:02 +00:00
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:
@@ -25,3 +25,8 @@ interface Genre {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- Pairing via QR Code has been split into 2 endpoint:
|
||||||
|
1. `/getpaircode`
|
||||||
|
2. `/pair`
|
||||||
|
|
||||||
|
-
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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])
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 "",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user