merge the big one!

The big one
This commit is contained in:
Mungai Njoroge
2024-08-31 12:15:48 +03:00
committed by GitHub
98 changed files with 4854 additions and 4045 deletions
+27 -1
View File
@@ -1,14 +1,40 @@
# What's New?
<!-- TODO: ELABORATE -->
- Auth
- New artists/albums Sort by: last played, no. of streams, total stream duration
- Option to show now playing track info on tab title. Go to Settings > Appearance to enable
- You can select which disc to play in an album
- Internal Backup and restore
## Improvements
- The context menu now doesn't take forever to open up
- Merged "Save as Playlist" with "Add to Playlist" > "New Playlist"
## Bug fixes
- Add to queue adding to last index -1
-
## Development
- Rewritten the whole DB layer to move stores from memory to the database.
## THE BIG ONE API CHANGES
- genre is no longer a string, but a struct:
```ts
interface Genre {
name: str;
genrehash: str;
}
```
- Pairing via QR Code has been split into 2 endpoint:
1. `/getpaircode`
2. `/pair`
-
+38 -3
View File
@@ -1,6 +1,13 @@
# TODO
- Migrations:
1. Move userdata to new hashing algorithm
1. Move userdata to new hashing algorithm
- favorites ✅
- playlists
- scrobble
- images
- remove image colors
- Package jsoni and publish on PyPi
- Rewrite stores to use dictionaries instead of list pools
@@ -8,12 +15,40 @@
- Disable the watchdog by default, and mark it as experimental
- rename userid to server id in config file
- Look into seeding jwts using user password + server id
- Recreate album hash if featured artists are discover
- Implement checking if is clean install and skip migrations!
<!-- CHECKPOINT -->
<!-- ALBUM PAGE! -->
# DONE
- Support auth headers
- Add recently played playlist
- Move user track logs to user zero
- Move future logs to appropriate user id
- Store (and read) from the correct user account:
1. Playlists
2. Favorites
1. Playlists
2. Favorites
# THE BIG ONE
- Watchdog
- Periodic scans
- What about our migrations?
- Test foreign keys on delete
- Normalize playlists table:
- New table to hold playlist entries
- Normalize similar artists:
- New table to hold similar artist entries
- Create 2 way relationships, such that if an artist A is similar to another B with a certain weight,
then artist B is similar to A with the same weight, unless overwritten.
- Clean up tempfiles after transcoding
- Double sort artist tracks for consistency (alphabetically then by other field. eg. playcount)
# Bug fixes
- Duplicates on search
- Audio stops on ending
- Show users on account settings when logged in as admin and show users on login is disabled.
- Save both filepath and trackhash in favorites and playlists
+11 -10
View File
@@ -11,9 +11,9 @@ from flask_openapi3 import OpenAPI
from flask_jwt_extended import JWTManager
from app.config import UserConfig
from app.db.userdata import UserTable
from app.settings import Info as AppInfo
from .plugins import lyrics as lyrics_plugin
from app.db.sqlite.auth import SQLiteAuthMethods as authdb
from app.api import (
album,
artist,
@@ -26,11 +26,12 @@ from app.api import (
settings,
lyrics,
plugins,
logger,
scrobble,
home,
getall,
auth,
stream,
backup_and_restore
)
# TODO: Move this description to a separate file
@@ -64,7 +65,7 @@ def create_api():
app = OpenAPI(__name__, info=api_info, doc_prefix="/docs")
# JWT CONFIGS
app.config["JWT_SECRET_KEY"] = UserConfig().userId
app.config["JWT_SECRET_KEY"] = UserConfig().serverId
app.config["JWT_TOKEN_LOCATION"] = ["cookies", "headers"]
app.config["JWT_COOKIE_CSRF_PROTECT"] = False
app.config["JWT_SESSION_COOKIE"] = False
@@ -76,6 +77,7 @@ def create_api():
CORS(app, origins="*", supports_credentials=True)
# RESPONSE COMPRESSION
# Only compress JSON responses
Compress(app)
app.config["COMPRESS_MIMETYPES"] = [
"application/json",
@@ -84,16 +86,14 @@ def create_api():
# JWT
jwt = JWTManager(app)
# @jwt.user_identity_loader
# def user_identity_lookup(user):
# return user
@jwt.user_lookup_loader
def user_lookup_callback(_jwt_header, jwt_data):
identity = jwt_data["sub"]
userid = identity["id"]
user = authdb.get_user_by_id(userid)
return user.todict()
user = UserTable.get_by_id(userid)
if user:
return user.todict()
# Register all the API blueprints
with app.app_context():
@@ -108,13 +108,14 @@ def create_api():
app.register_api(settings.api)
app.register_api(colors.api)
app.register_api(lyrics.api)
app.register_api(backup_and_restore.api)
# Plugins
app.register_api(plugins.api)
app.register_api(lyrics_plugin.api)
# Logger
app.register_api(logger.api)
app.register_api(scrobble.api)
# Home
app.register_api(home.api)
+80 -97
View File
@@ -2,28 +2,26 @@
Contains all the album routes.
"""
from dataclasses import asdict
import random
from flask_jwt_extended import current_user
from pydantic import Field
from pydantic import BaseModel, Field
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from app.api.apischemas import AlbumHashSchema, AlbumLimitSchema, ArtistHashSchema
from app.config import UserConfig
from app.db.userdata import SimilarArtistTable
from app.models.album import Album
from app.settings import Defaults
from app.models import FavType, Track
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
from app.store.tracks import TrackStore
from app.utils.hashing import create_hash
from app.lib.albumslib import sort_by_track_no
from app.serializers.album import serialize_for_card
from app.serializers.track import serialize_track
from app.db.sqlite.albumcolors import SQLiteAlbumMethods as adb
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.db.sqlite.lastfm.similar_artists import SQLiteLastFMSimilarArtists as lastfmdb
from app.serializers.album import serialize_for_card_many
from app.serializers.track import serialize_tracks
get_albums_by_albumartist = adb.get_albums_by_albumartist
check_is_fav = favdb.check_is_favorite
bp_tag = Tag(name="Album", description="Single album")
api = APIBlueprint("album", __name__, url_prefix="/album", abp_tags=[bp_tag])
@@ -38,46 +36,37 @@ def get_album_tracks_and_info(body: AlbumHashSchema):
Returns album info and tracks for the given albumhash.
"""
albumhash = body.albumhash
albumentry = AlbumStore.albummap.get(albumhash)
error_msg = {"error": "Album not created yet."}
album = AlbumStore.get_album_by_hash(albumhash)
if albumentry is None:
return {"error": "Album not found"}, 404
if album is None:
return error_msg, 404
tracks = TrackStore.get_tracks_by_albumhash(albumhash)
if tracks is None:
return error_msg, 404
if len(tracks) == 0:
return error_msg, 404
def get_album_genres(tracks: list[Track]):
genres = set()
for track in tracks:
if track.genre is not None:
genres.update(track.genre)
return list(genres)
album.genres = get_album_genres(tracks)
album.count = len(tracks)
album.get_date_from_tracks(tracks)
album = albumentry.album
tracks = TrackStore.get_tracks_by_trackhashes(albumentry.trackhashes)
album.trackcount = len(tracks)
album.duration = sum(t.duration for t in tracks)
album.check_type(
tracks=tracks, singleTrackAsSingle=UserConfig().showAlbumsAsSingles
)
album.check_is_single(tracks)
if not album.is_single:
album.check_type()
album.is_favorite = check_is_fav(albumhash, FavType.album)
track_total = sum({int(t.extra.get("track_total", 1) or 1) for t in tracks})
avg_bitrate = sum(t.bitrate for t in tracks) // (len(tracks) or 1)
return {
"tracks": [serialize_track(t, remove_disc=False) for t in tracks],
"info": album,
"info": {
**asdict(album),
"is_favorite": album.is_favorite,
},
"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": avg_bitrate,
},
"copyright": tracks[0].copyright,
"tracks": serialize_tracks(tracks, remove_disc=False),
}
@@ -89,16 +78,15 @@ def get_album_tracks(path: AlbumHashSchema):
Returns all the tracks in the given album, sorted by disc and track number.
NOTE: No album info is returned.
"""
tracks = TrackStore.get_tracks_by_albumhash(path.albumhash)
tracks = AlbumStore.get_album_tracks(path.albumhash)
tracks = sort_by_track_no(tracks)
return tracks
return serialize_tracks(tracks)
class GetMoreFromArtistsBody(AlbumLimitSchema):
albumartists: str = Field(
albumartists: list = Field(
description="The artist hashes to get more albums from",
example=Defaults.API_ARTISTHASH,
)
base_title: str = Field(
@@ -119,38 +107,43 @@ def get_more_from_artist(body: GetMoreFromArtistsBody):
limit = body.limit
base_title = body.base_title
albumartists: list[str] = albumartists.split(",")
all_albums: dict[str, list[Album]] = {}
albums = [
{
"artisthash": a,
"albums": AlbumStore.get_albums_by_albumartist(
a, limit, exclude=base_title
),
}
for a in albumartists
]
for artisthash in albumartists:
all_albums[artisthash] = AlbumStore.get_albums_by_artisthash(artisthash)
albums = [
{
"artisthash": a["artisthash"],
"albums": [serialize_for_card(a_) for a_ in (a["albums"])],
}
for a in albums
if len(a["albums"]) > 0
]
seen_hashes = set()
return albums
for artisthash, albums in all_albums.items():
albums = [
a
for a in albums
# INFO: filter out albums added to other artists
if a.albumhash not in seen_hashes and artisthash in a.artisthashes
# INFO: filter out albums with the same base title
and create_hash(a.base_title) != create_hash(base_title)
]
all_albums[artisthash] = serialize_for_card_many(
[a for a in albums if create_hash(a.base_title) != create_hash(base_title)][
:limit
]
)
# INFO: record albums added to other artists
seen_hashes.update([a.albumhash for a in albums][:limit])
return all_albums
class GetAlbumVersionsBody(ArtistHashSchema):
class GetAlbumVersionsBody(BaseModel):
og_album_title: str = Field(
description="The original album title (album.og_title)",
example=Defaults.API_ALBUMNAME,
)
base_title: str = Field(
description="The base title of the album to exclude from the results.",
example=Defaults.API_ALBUMNAME,
albumhash: str = Field(
description="The album hash of the album to exclude from the results.",
example=Defaults.API_ALBUMHASH,
)
@@ -161,24 +154,24 @@ def get_album_versions(body: GetAlbumVersionsBody):
Returns other versions of the given album.
"""
og_album_title = body.og_album_title
base_title = body.base_title
artisthash = body.artisthash
albumhash = body.albumhash
album = AlbumStore.albummap.get(albumhash)
if not album:
return []
artisthash = album.album.artisthashes[0]
albums = AlbumStore.get_albums_by_artisthash(artisthash)
basetitle = album.basetitle
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)
if a.og_title != album.album.og_title
if a.base_title == basetitle
and artisthash in {a["artisthash"] for a in a.albumartists}
]
for a in albums:
tracks = TrackStore.get_tracks_by_albumhash(a.albumhash)
a.get_date_from_tracks(tracks)
return albums
return serialize_for_card_many(albums)
class GetSimilarAlbumsQuery(ArtistHashSchema, AlbumLimitSchema):
@@ -195,24 +188,14 @@ def get_similar_albums(query: GetSimilarAlbumsQuery):
artisthash = query.artisthash
limit = query.limit
similar_artists = lastfmdb.get_similar_artists_for(artisthash)
similar_artists = SimilarArtistTable.get_by_hash(artisthash)
if similar_artists is None:
return {"albums": []}
return []
artisthashes = similar_artists.get_artist_hash_set()
artists = ArtistStore.get_artists_by_hashes(artisthashes)
albums = AlbumStore.get_albums_by_artisthashes([a.artisthash for a in artists])
sample = random.sample(albums, min(len(albums), limit))
if len(artisthashes) == 0:
return {"albums": []}
albums = [AlbumStore.get_albums_by_artisthash(a) for a in artisthashes]
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]]
return serialize_for_card_many(sample[:limit])
+83 -114
View File
@@ -5,17 +5,25 @@ Contains all the artist(s) routes.
import math
import random
from datetime import datetime
from itertools import groupby
from flask_jwt_extended import current_user
from flask_openapi3 import APIBlueprint, Tag
from pydantic import Field
from app.api.apischemas import AlbumLimitSchema, ArtistHashSchema, ArtistLimitSchema, TrackLimitSchema
from app.api.apischemas import (
AlbumLimitSchema,
ArtistHashSchema,
ArtistLimitSchema,
TrackLimitSchema,
)
from app.config import UserConfig
from app.db.userdata import SimilarArtistTable
from app.lib.sortlib import sort_tracks
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.db.sqlite.lastfm.similar_artists import SQLiteLastFMSimilarArtists as fmdb
from app.models import Album, FavType
from app.serializers.album import serialize_for_card_many
from app.serializers.artist import serialize_for_cards, serialize_for_card
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
@@ -34,35 +42,21 @@ def get_artist(path: ArtistHashSchema, query: TrackLimitSchema):
artisthash = path.artisthash
limit = query.limit
artist = ArtistStore.get_artist_by_hash(artisthash)
entry = ArtistStore.artistmap.get(artisthash)
if artist is None:
if entry is None:
return {"error": "Artist not found"}, 404
tracks = TrackStore.get_tracks_by_artisthash(artisthash)
tracks = TrackStore.get_tracks_by_trackhashes(entry.trackhashes)
tracks = sort_tracks(tracks, key="playcount", reverse=True)
tcount = len(tracks)
acount = AlbumStore.count_albums_by_artisthash(artisthash)
if acount == 0 and tcount < 10:
artist = entry.artist
if artist.albumcount == 0 and tcount < 10:
limit = tcount
artist.set_trackcount(tcount)
artist.set_albumcount(acount)
artist.set_duration(sum(t.duration for t in tracks))
artist.is_favorite = favdb.check_is_favorite(artisthash, FavType.artist)
genres = set()
for t in tracks:
if t.genre is not None:
genres = genres.union(t.genre)
genres = list(genres)
try:
min_stamp = min(t.date for t in tracks)
year = datetime.fromtimestamp(min_stamp).year
year = datetime.fromtimestamp(artist.date).year
except ValueError:
year = 0
@@ -73,12 +67,18 @@ def get_artist(path: ArtistHashSchema, query: TrackLimitSchema):
decade = str(decade)[2:] + "s"
if decade:
genres.insert(0, decade)
artist.genres.insert(0, {"name": decade, "genrehash": decade})
return {
"artist": artist,
"artist": {
**serialize_for_card(artist),
"duration": sum(t.duration for t in tracks) if tracks else 0,
"trackcount": tcount,
"albumcount": artist.albumcount,
"genres": artist.genres,
"is_favorite": artist.is_favorite,
},
"tracks": serialize_tracks(tracks[:limit]),
"genres": genres,
}
@@ -98,83 +98,61 @@ def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery):
limit = query.limit
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)
entry = ArtistStore.artistmap.get(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.check_is_single(album_tracks)
all_albums = sorted(all_albums, key=lambda a: str(a.date), reverse=True)
singles = [a for a in all_albums if a.is_single]
eps = [a for a in all_albums if a.is_EP]
def remove_EPs_and_singles(albums_: list[Album]):
albums_ = [a for a in albums_ if not a.is_single]
albums_ = [a for a in albums_ if not a.is_EP]
return albums_
albums = filter(lambda a: artisthash in a.albumartists_hashes, 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:
if entry is None:
return {"error": "Artist not found"}, 404
albums = AlbumStore.get_albums_by_hashes(entry.albumhashes)
tracks = TrackStore.get_tracks_by_trackhashes(entry.trackhashes)
missing_albumhashes = {
t.albumhash for t in tracks if t.albumhash not in {a.albumhash for a in albums}
}
albums.extend(AlbumStore.get_albums_by_hashes(missing_albumhashes))
albumdict = {a.albumhash: a for a in albums}
config = UserConfig()
albumgroups = groupby(tracks, key=lambda t: t.albumhash)
for albumhash, tracks in albumgroups:
album = albumdict.get(albumhash)
if album:
album.check_type(list(tracks), config.showAlbumsAsSingles)
albums = [a for a in albumdict.values()]
all_albums = sorted(albums, key=lambda a: a.date, reverse=True)
res = {
"albums": [],
"appearances": [],
"compilations": [],
"singles_and_eps": [],
}
for album in all_albums:
if album.type == "single" or album.type == "ep":
res["singles_and_eps"].append(album)
elif album.type == "compilation":
res["compilations"].append(album)
elif (
album.albumhash in missing_albumhashes
or artisthash not in album.artisthashes
):
res["appearances"].append(album)
else:
res["albums"].append(album)
if return_all:
limit = len(all_albums)
singles_and_eps = singles + eps
# loop through the res dict and serialize the albums
for key, value in res.items():
res[key] = serialize_for_card_many(value[:limit])
return {
"artistname": artist.name,
"albums": serialize_for_card_many(albums[:limit]),
"singles_and_eps": serialize_for_card_many(singles_and_eps[:limit]),
"appearances": serialize_for_card_many(appearances[:limit]),
"compilations": serialize_for_card_many(compilations[:limit]),
}
res["artistname"] = entry.artist.name
return res
@api.get("/<artisthash>/tracks")
@@ -184,8 +162,8 @@ def get_all_artist_tracks(path: ArtistHashSchema):
Returns all artists by a given artist.
"""
tracks = TrackStore.get_tracks_by_artisthash(path.artisthash)
tracks = ArtistStore.get_artist_tracks(path.artisthash)
tracks = sort_tracks(tracks, key="playcount", reverse=True)
return serialize_tracks(tracks)
@@ -195,23 +173,14 @@ def get_similar_artists(path: ArtistHashSchema, query: ArtistLimitSchema):
Get similar artists.
"""
limit = query.limit
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)
result = SimilarArtistTable.get_by_hash(path.artisthash)
if result is None:
return {"artists": []}
return []
similar = ArtistStore.get_artists_by_hashes(result.get_artist_hash_set())
if len(similar) > limit:
similar = random.sample(similar, limit)
similar = random.sample(similar, min(limit, len(similar)))
return similar[:limit]
# TODO: Rewrite this file using generators where possible
return serialize_for_cards(similar[:limit])
+51 -44
View File
@@ -14,7 +14,7 @@ from pydantic import BaseModel, Field
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
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.config import UserConfig
@@ -65,7 +65,7 @@ def login(body: LoginBody):
Authenticate using username and password
"""
user = authdb.get_user_by_username(body.username)
user = UserTable.get_by_username(body.username)
if user is None:
return {"msg": "User not found"}, 404
@@ -87,42 +87,41 @@ def login(body: LoginBody):
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):
code: str = Field("", description="The code")
@api.get("/pair")
@jwt_required(optional=True)
def pair_device(query: PairDeviceQuery):
def pair_with_code(query: PairDeviceQuery):
"""
Pair the Swing Music mobile app with this server
Send a code to get an access token. Send an authenticated request without the code to generate a new token.
Get an access token by sending a pair code. NOTE: A code can only be used once!
"""
# INFO: if user is already logged in, create a new pair code
if current_user:
token = create_new_token(get_jwt_identity())
key = token["accesstoken"][-6:]
global pair_token
token = pair_token.get(query.code, None)
global pair_token
pair_token = {
key: token,
}
if token:
pair_token = {}
return token
return {"code": key}
# 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
return {"msg": "Invalid code"}, 400
@api.post("/refresh")
@@ -133,6 +132,8 @@ def refresh():
>>> Headers:
>>> Authorization: Bearer <refresh_token>
Won't work with cookies!!!
"""
user = get_jwt_identity()
return create_new_token(user)
@@ -153,7 +154,6 @@ def update_profile(body: UpdateProfileBody):
"""
user = {
"id": body.id,
"email": body.email,
"username": body.username,
"password": body.password,
"roles": body.roles,
@@ -172,7 +172,8 @@ def update_profile(body: UpdateProfileBody):
if "admin" not in current_user["roles"]:
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:
# check if we're removing the last admin
admins = [user for user in all_users if "admin" in user.roles]
@@ -195,7 +196,9 @@ def update_profile(body: UpdateProfileBody):
clean_user = {k: v for k, v in user.items() if v}
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:
return {"msg": "Username already exists"}, 400
@@ -216,11 +219,18 @@ def create_user(body: UpdateProfileBody):
}
# 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
userid = authdb.insert_user(user)
return authdb.get_user_by_id(userid).todict()
UserTable.insert_one(user)
user = UserTable.get_by_username(user["username"])
if user:
return user.todict()
return {
"msg": "Failed to create user",
}, 500
@api.post("/profile/guest/create")
@@ -230,14 +240,14 @@ def create_guest_user():
Create a guest user
"""
# check if guest user already exists
guest_user = authdb.get_user_by_username("guest")
guest_user = UserTable.get_by_username("guest")
if guest_user:
return {
"msg": "Guest user already exists",
}, 400
userid = authdb.insert_guest_user()
userid = UserTable.insert_guest_user()
if userid:
return {
@@ -264,12 +274,12 @@ def delete_user(body: DeleteUseBody):
return {"msg": "Sorry! you cannot delete yourselfu"}, 400
# 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]
if len(admins) == 1 and admins[0].username == body.username:
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"}
@@ -296,8 +306,6 @@ def get_all_users(query: GetAllUsersQuery):
Get all users (if you're an admin, you will also receive accounts settings)
"""
config = UserConfig()
# config.enableGuest = True
# config.usersOnLogin = True
settings = {
"enableGuest": False,
"usersOnLogin": config.usersOnLogin,
@@ -308,8 +316,7 @@ def get_all_users(query: GetAllUsersQuery):
"users": [],
}
users = authdb.get_all_users()
users = UserTable.get_all()
is_admin = current_user and "admin" in current_user["roles"]
settings["enableGuest"] = [
user for user in users if user.username == "guest"
@@ -355,8 +362,8 @@ def get_all_users(query: GetAllUsersQuery):
if query.simplified:
res["users"] = [user.todict_simplified() for user in users]
res["users"] = [user.todict() for user in users]
else:
res["users"] = [user.todict() for user in users]
return res
+78
View File
@@ -0,0 +1,78 @@
from dataclasses import asdict
import json
from pathlib import Path
import shutil
from time import time
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from app.api.auth import admin_required
from app.db.userdata import FavoritesTable, PlaylistTable, ScrobbleTable
from app.settings import Paths
bp_tag = Tag(name="Backup and Restore", description="Backup and Restore")
api = APIBlueprint("backup_and_restore", __name__, url_prefix="/", abp_tags=[bp_tag])
@api.post("/backup")
@admin_required()
def backup():
"""
Create a backup file of your favorites, playlists and scrobble data.
"""
backup_dir = Path(Paths.get_app_dir()) / "backup"
backup_dir.mkdir(parents=True, exist_ok=True)
backup_name = f"backup.{int(time())}"
backup_file = backup_dir / f"{backup_name}.json"
# INFO: Image folder for playlist images
img_folder = backup_dir / "images" / backup_name
img_folder.mkdir(parents=True, exist_ok=True)
favorites = FavoritesTable.get_all()
favorites = [asdict(entry) for entry in favorites]
scrobbles = ScrobbleTable.get_all(start=0)
scrobbles = [asdict(entry) for entry in scrobbles]
# SECTION: Playlists
playlists = PlaylistTable.get_all()
playlist_dicts = []
for entry in playlists:
playlist = asdict(entry)
for key in ["_last_updated", "has_image", "images", "duration", "count"]:
del playlist[key]
playlist_dicts.append(playlist)
# copy images
if playlist["thumb"]:
img_path = Path(Paths.get_playlist_img_path()) / playlist["thumb"]
shutil.copy(img_path, img_folder / playlist["thumb"])
# !SECTION
data = {
"favorites": favorites,
"scrobbles": scrobbles,
"playlists": playlist_dicts,
}
with open(backup_file, "w") as f:
json.dump(data, f, indent=4)
return {
"msg": "Backup created",
"data_path": str(backup_file),
"images_path": str(img_folder),
}, 200
@api.post("/restore")
@admin_required()
def restore():
"""
Restore your favorites, playlists and scrobble data from a backup file.
"""
return {"msg": "Restore"}
+102 -96
View File
@@ -1,23 +1,27 @@
from typing import List, TypeVar
from flask_jwt_extended import current_user
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
from app.api.apischemas import GenericLimitSchema
from app.db.libdata import TrackTable
from app.db.userdata import FavoritesTable
from app.lib.extras import get_extra_info
from app.models import FavType
from app.settings import Defaults
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.artist import serialize_for_card as serialize_artist, serialize_for_cards
from app.serializers.album import serialize_for_card, serialize_for_card_many
from app.store.albums import AlbumStore
from app.store.tracks import TrackStore
from app.store.artists import ArtistStore
from app.store.tracks import TrackStore
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.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")
api = APIBlueprint("favorites", __name__, url_prefix="/favorites", abp_tags=[bp_tag])
@@ -40,18 +44,44 @@ class FavoritesAddBody(BaseModel):
type: str = Field(description="The type of the item", example=FavType.album)
def toggle_fav(type: str, hash: str):
"""
Toggles a favorite item.
"""
if type == FavType.track:
entry = TrackStore.trackhashmap.get(hash)
if entry is not None:
entry.toggle_favorite_user()
elif type == FavType.album:
entry = AlbumStore.albummap.get(hash)
if entry is not None:
entry.toggle_favorite_user()
elif type == FavType.artist:
entry = ArtistStore.artistmap.get(hash)
if entry is not None:
entry.toggle_favorite_user()
return {"msg": "Added to favorites"}
@api.post("/add")
def add_favorite(body: FavoritesAddBody):
def toggle_favorite(body: FavoritesAddBody):
"""
Adds a favorite to the database.
"""
itemhash = body.hash
itemtype = body.type
extra = get_extra_info(body.hash, body.type)
favdb.insert_one_favorite(itemtype, itemhash)
try:
FavoritesTable.insert_item(
{"hash": body.hash, "type": body.type, "extra": extra}
)
except:
return {"msg": "Failed! An error occured"}, 500
if itemtype == FavType.track:
TrackStore.make_track_fav(itemhash)
toggle_fav(body.type, body.hash)
return {"msg": "Added to favorites"}
@@ -61,80 +91,65 @@ def remove_favorite(body: FavoritesAddBody):
"""
Removes a favorite from the database.
"""
itemhash = body.hash
itemtype = body.type
try:
FavoritesTable.remove_item({"hash": body.hash, "type": body.type})
except:
return {"msg": "Failed! An error occured"}, 500
favdb.delete_favorite(itemtype, itemhash)
if itemtype == FavType.track:
TrackStore.remove_track_from_fav(itemhash)
toggle_fav(body.type, body.hash)
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")
def get_favorite_albums(query: GenericLimitSchema):
def get_favorite_albums(query: GetAllOfTypeQuery):
"""
Get favorite albums
"""
limit = query.limit
albums = favdb.get_fav_albums()
albumhashes = [a[1] for a in albums]
albumhashes.reverse()
fav_albums, total = FavoritesTable.get_fav_albums(query.start, query.limit)
fav_albums.reverse()
src_albums = sorted(AlbumStore.albums, key=lambda x: x.albumhash)
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])}
albums = AlbumStore.get_albums_by_hashes(a.hash for a in fav_albums)
return {"albums": serialize_for_card_many(albums), "total": total}
@api.get("/tracks")
def get_favorite_tracks(query: GenericLimitSchema):
def get_favorite_tracks(query: GetAllOfTypeQuery):
"""
Get favorite tracks
"""
limit = query.limit
userid = current_user['id']
tracks, total = FavoritesTable.get_fav_tracks(query.start, query.limit)
tracks.reverse()
tracks = TrackStore.get_tracks_by_trackhashes([t.hash for t in tracks])
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])}
return {"tracks": serialize_tracks(tracks), "total": total}
@api.get("/artists")
def get_favorite_artists(query: GenericLimitSchema):
def get_favorite_artists(query: GetAllOfTypeQuery):
"""
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()
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]}
artists = ArtistStore.get_artists_by_hashes(a.hash for a in artists)
return {"artists": [serialize_artist(a) for a in artists], "total": total}
class GetAllFavoritesQuery(BaseModel):
@@ -173,27 +188,29 @@ def get_all_favorites(query: GetAllFavoritesQuery):
# largest is x2 to accound for broken hashes if any
largest = max(track_limit, album_limit, artist_limit)
favs = favdb.get_all()
favs = FavoritesTable.get_all()
favs.reverse()
tracks = []
albums = []
artists = []
track_master_hash = set(t.trackhash for t in TrackStore.tracks)
album_master_hash = set(a.albumhash for a in AlbumStore.albums)
artist_master_hash = set(a.artisthash for a in ArtistStore.artists)
track_master_hash = TrackStore.trackhashmap.keys()
album_master_hash = AlbumStore.albummap.keys()
artist_master_hash = ArtistStore.artistmap.keys()
# INFO: Filter out invalid hashes (file not found or tags edited)
for fav in favs:
# INFO: hash is [1], type is [2], timestamp is [3]
hash = fav[1]
if fav[2] == FavType.track:
hash = fav.hash
type = fav.type
if type == FavType.track:
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
if fav[2] == FavType.album:
if type == FavType.album:
albums.append(hash) if hash in album_master_hash else None
count = {
@@ -202,35 +219,25 @@ def get_all_favorites(query: GetAllFavoritesQuery):
"artists": len(artists),
}
src_tracks = sorted(TrackStore.tracks, key=lambda x: x.trackhash)
src_albums = sorted(AlbumStore.albums, key=lambda x: x.albumhash)
src_artists = sorted(ArtistStore.artists, key=lambda x: x.artisthash)
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)
tracks = TrackStore.get_tracks_by_trackhashes(tracks[:track_limit])
albums = AlbumStore.get_albums_by_hashes(albums[:album_limit])
artists = ArtistStore.get_artists_by_hashes(artists[:artist_limit])
recents = []
# first_n = favs
for fav in favs:
# INFO: hash is [1], type is [2], timestamp is [3]
if len(recents) >= largest:
break
if fav[2] == FavType.album:
album = next((a for a in albums if a.albumhash == fav[1]), None)
if fav.type == FavType.album:
album = next((a for a in albums if a.albumhash == fav.hash), None)
if album is None:
continue
album = serialize_for_card(album)
album["help_text"] = "album"
album["time"] = timestamp_to_time_passed(fav[3])
album["time"] = timestamp_to_time_passed(fav.timestamp)
recents.append(
{
@@ -239,15 +246,15 @@ def get_all_favorites(query: GetAllFavoritesQuery):
}
)
if fav[2] == FavType.artist:
artist = next((a for a in artists if a.artisthash == fav[1]), None)
if fav.type == FavType.artist:
artist = next((a for a in artists if a.artisthash == fav.hash), None)
if artist is None:
continue
artist = serialize_artist(artist)
artist["help_text"] = "artist"
artist["time"] = timestamp_to_time_passed(fav[3])
artist["time"] = timestamp_to_time_passed(fav.timestamp)
recents.append(
{
@@ -256,15 +263,15 @@ def get_all_favorites(query: GetAllFavoritesQuery):
}
)
if fav[2] == FavType.track:
track = next((t for t in tracks if t.trackhash == fav[1]), None)
if fav.type == FavType.track:
track = next((t for t in tracks if t.trackhash == fav.hash), None)
if track is None:
continue
track = serialize_track(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})
@@ -284,6 +291,5 @@ def check_favorite(query: FavoritesAddBody):
"""
itemhash = query.hash
itemtype = query.type
exists = favdb.check_is_favorite(itemhash, itemtype)
return {"is_favorite": exists}
return {"is_favorite": FavoritesTable.check_exists(itemhash, itemtype)}
+55 -17
View File
@@ -12,10 +12,10 @@ from flask_openapi3 import APIBlueprint
from showinfm import show_in_file_manager
from app import settings
from app.db.sqlite.settings import SettingsSQLMethods as db
from app.lib.folderslib import GetFilesAndDirs, get_folders
from app.config import UserConfig
from app.db.libdata import TrackTable
from app.lib.folderslib import get_files_and_dirs, get_folders
from app.serializers.track import serialize_track
from app.store.tracks import TrackStore as store
from app.utils.wintools import is_windows, win_replace_slash
tag = Tag(name="Folders", description="Get folders and tracks in a directory")
@@ -23,9 +23,46 @@ api = APIBlueprint("folder", __name__, url_prefix="/folder", abp_tags=[tag])
class FolderTree(BaseModel):
folder: str = Field(
"$home", example="$home", description="The folder to things from"
folder: str = Field("$home", description="The folder to things from")
sorttracksby: str = Field(
"default",
description="""The field to sort tracks by. Options: [
"default",
"album",
"albumartists",
"artists",
"bitrate",
"date",
"disc",
"duration",
"lastmod",
"lastplayed",
"playduration",
"playcount",
"title",
]""",
)
tracksort_reverse: bool = Field(
False,
description="Whether to reverse the sort order of the tracks",
)
sortfoldersby: str = Field(
"lastmod",
description="""The field to sort folders by.
Options: [
"default",
"name",
"lastmod",
"trackcount",
]
""",
)
foldersort_reverse: bool = Field(
False,
description="Whether to reverse the sort order of the folders",
)
start: int = Field(0, description="The start index")
limit: int = Field(50, description="The max number of items to return")
tracks_only: bool = Field(False, description="Whether to only get tracks")
@@ -39,8 +76,8 @@ def get_folder_tree(body: FolderTree):
req_dir = body.folder
tracks_only = body.tracks_only
root_dirs = db.get_root_dirs()
root_dirs.sort()
config = UserConfig()
root_dirs = config.rootDirs
try:
if req_dir == "$home" and root_dirs[0] == "$home":
@@ -66,17 +103,19 @@ def get_folder_tree(body: FolderTree):
else:
req_dir = "/" + req_dir if not req_dir.startswith("/") else req_dir
res = GetFilesAndDirs(req_dir, tracks_only=tracks_only)()
res["folders"] = sorted(res["folders"], key=lambda i: i.name)
res = get_files_and_dirs(
req_dir,
start=body.start,
limit=body.limit,
tracks_only=tracks_only,
tracksortby=body.sorttracksby,
foldersortby=body.sortfoldersby,
tracksort_reverse=body.tracksort_reverse,
foldersort_reverse=body.foldersort_reverse,
)
return res
# return {
# "path": req_dir,
# "tracks": tracks,
# "folders": sorted(folders, key=lambda i: i.name),
# }
def get_all_drives(is_win: bool = False):
"""
@@ -181,8 +220,7 @@ def get_tracks_in_path(query: GetTracksInPathQuery):
Used when adding tracks to the queue.
"""
tracks = store.get_tracks_in_path(query.path)
tracks = sorted(tracks, key=lambda i: i.last_mod)
tracks = TrackTable.get_tracks_in_path(query.path)
tracks = (serialize_track(t) for t in tracks if Path(t.filepath).exists())
return {
+42 -12
View File
@@ -1,5 +1,3 @@
from flask import Blueprint
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
@@ -16,6 +14,7 @@ from app.utils.dates import (
create_new_date,
date_string_to_time_passed,
seconds_to_time_string,
timestamp_to_time_passed,
)
bp_tag = Tag(name="Get all", description="List all items")
@@ -55,23 +54,35 @@ def get_all_items(path: GetAllItemsPath, query: GetAllItemsQuery):
Get all items
Used to show all albums or artists in the library
Sort keys:
-
Both albums and artists: `duration`, `created_date`, `playcount`, `playduration`, `lastplayed`, `trackcount`
Albums only: `title`, `albumartists`, `date`
Artists only: `name`, `albumcount`
"""
is_albums = path.itemtype == "albums"
is_artists = path.itemtype == "artists"
items = AlbumStore.albums
if is_albums:
items = AlbumStore.get_flat_list()
elif is_artists:
items = ArtistStore.get_flat_list()
if is_artists:
items = ArtistStore.artists
total = len(items)
start = query.start
limit = query.limit
sort = query.sortby
reverse = query.reverse == "1"
sort_is_count = sort == "count"
sort_is_count = sort == "trackcount"
sort_is_duration = sort == "duration"
sort_is_create_date = sort == "created_date"
sort_is_playcount = sort == "playcount"
sort_is_playduration = sort == "playduration"
sort_is_lastplayed = sort == "lastplayed"
sort_is_date = is_albums and sort == "date"
sort_is_artist = is_albums and sort == "albumartists"
@@ -80,19 +91,24 @@ def get_all_items(path: GetAllItemsPath, query: GetAllItemsQuery):
sort_is_artist_albumcount = is_artists and sort == "albumcount"
lambda_sort = lambda x: getattr(x, sort)
lambda_sort_casefold = lambda x: getattr(x, sort).casefold()
if sort_is_artist:
lambda_sort = lambda x: getattr(x, sort)[0].name
lambda_sort = lambda x: getattr(x, sort)[0]["name"].casefold()
try:
sorted_items = sorted(items, key=lambda_sort_casefold, reverse=reverse)
except AttributeError:
sorted_items = sorted(items, key=lambda_sort, reverse=reverse)
sorted_items = sorted(items, key=lambda_sort, reverse=reverse)
items = sorted_items[start : start + limit]
album_list = []
for item in items:
item_dict = serialize_album(item) if is_albums else serialize_artist(item)
if sort_is_date:
item_dict["help_text"] = item.date
item_dict["help_text"] = datetime.fromtimestamp(item.date).year
if sort_is_create_date:
date = create_new_date(datetime.fromtimestamp(item.created_date))
@@ -101,7 +117,7 @@ def get_all_items(path: GetAllItemsPath, query: GetAllItemsQuery):
if sort_is_count:
item_dict["help_text"] = (
f"{format_number(item.count)} track{'' if item.count == 1 else 's'}"
f"{format_number(item.trackcount)} track{'' if item.trackcount == 1 else 's'}"
)
if sort_is_duration:
@@ -117,6 +133,20 @@ def get_all_items(path: GetAllItemsPath, query: GetAllItemsQuery):
f"{format_number(item.albumcount)} album{'' if item.albumcount == 1 else 's'}"
)
if sort_is_playcount:
item_dict["help_text"] = (
f"{format_number(item.playcount)} play{'' if item.playcount == 1 else 's'}"
)
if sort_is_lastplayed:
if item.playduration == 0:
item_dict["help_text"] = "Never played"
else:
item_dict["help_text"] = timestamp_to_time_passed(item.lastplayed)
if sort_is_playduration:
item_dict["help_text"] = seconds_to_time_string(item.playduration)
album_list.append(item_dict)
return {"items": album_list, "total": len(sorted_items)}
return {"items": album_list, "total": total}
-44
View File
@@ -1,44 +0,0 @@
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import Field
from app.api.apischemas import TrackHashSchema
from app.db.sqlite.logger.tracks import SQLiteTrackLogger as db
from app.settings import Defaults
bp_tag = Tag(name="Logger", description="Log item plays")
api = APIBlueprint("logger", __name__, url_prefix="/logger", abp_tags=[bp_tag])
class LogTrackBody(TrackHashSchema):
timestamp: int = Field(description="The timestamp of the track", example=1622217600)
duration: int = Field(
description="The duration of the track in seconds", example=300
)
source: str = Field(
description="The play source of the track",
example=f"al:{Defaults.API_ALBUMHASH}",
)
@api.post("/track/log")
def log_track(body: LogTrackBody):
"""
Log a track play to the database.
"""
trackhash = body.trackhash
timestamp = body.timestamp
duration = body.duration
source = body.source
if not timestamp or duration < 5:
return {"msg": "Invalid entry."}, 400
last_row = db.insert_track(
trackhash=trackhash,
timestamp=timestamp,
duration=duration,
source=source,
)
return {"total entries": last_row}
+101 -104
View File
@@ -12,21 +12,82 @@ from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint, FileStorage
from app import models
from app.db.sqlite.playlists import SQLitePlaylistMethods
from app.api.apischemas import GenericLimitSchema
from app.db.userdata import PlaylistTable
from app.lib import playlistlib
from app.lib.albumslib import sort_by_track_no
from app.lib.home.recentlyadded import get_recently_added_playlist
from app.lib.home.recentlyplayed import get_recently_played_playlist
from app.lib.sortlib import sort_tracks
from app.models.playlist import Playlist
from app.serializers.playlist import serialize_for_card
from app.serializers.track import serialize_tracks
from app.store.tracks import TrackStore
from app.utils.dates import create_new_date, date_string_to_time_passed
from app.utils.remove_duplicates import remove_duplicates
from app.settings import Paths
tag = Tag(name="Playlists", description="Get and manage playlists")
api = APIBlueprint("playlists", __name__, url_prefix="/playlists", abp_tags=[tag])
PL = SQLitePlaylistMethods
def insert_playlist(name: str, image: str = None):
playlist = {
"image": image,
"last_updated": create_new_date(),
"name": name,
"trackhashes": [],
"settings": {
"has_gif": False,
"banner_pos": 50,
"square_img": True if image else False,
"pinned": False,
},
}
rowid = PlaylistTable.add_one(playlist)
if rowid:
playlist["id"] = rowid
return Playlist(**playlist)
return None
def get_path_trackhashes(path: str):
"""
Returns a list of trackhashes in a folder.
"""
tracks = TrackStore.get_tracks_in_path(path)
return [t.trackhash for t in tracks]
def get_album_trackhashes(albumhash: str):
"""
Returns a list of trackhashes in an album.
"""
tracks = TrackStore.get_tracks_by_albumhash(albumhash)
tracks = sort_by_track_no(tracks)
return [t.trackhash for t in tracks]
def get_artist_trackhashes(artisthash: str):
"""
Returns a list of trackhashes for an artist.
"""
tracks = TrackStore.get_tracks_by_artisthash(artisthash)
tracks = sort_tracks(tracks, key="playcount", reverse=True)
return [t.trackhash for t in tracks]
def format_custom_playlist(playlist: models.Playlist, tracks: list[models.Track]):
playlist.duration = sum(t.duration for t in tracks)
playlist.count = len(tracks)
return {
"info": serialize_for_card(playlist),
"tracks": serialize_tracks(tracks),
}
class SendAllPlaylistsQuery(BaseModel):
@@ -38,15 +99,13 @@ def send_all_playlists(query: SendAllPlaylistsQuery):
"""
Gets all the playlists.
"""
playlists = PL.get_all_playlists()
playlists = list(playlists)
playlists = PlaylistTable.get_all()
for playlist in playlists:
if not query.no_images:
if not playlist.has_image:
playlist.images = playlistlib.get_first_4_images(
trackhashes=playlist.trackhashes
)
playlist.images = [img["image"] for img in playlist.images]
playlist.clear_lists()
@@ -58,25 +117,6 @@ def send_all_playlists(query: SendAllPlaylistsQuery):
return {"data": playlists}
def insert_playlist(name: str, image: str = None):
playlist = {
"image": image,
"last_updated": create_new_date(),
"name": name,
"trackhashes": json.dumps([]),
"settings": json.dumps(
{
"has_gif": False,
"banner_pos": 50,
"square_img": True if image else False,
"pinned": False,
}
),
}
return PL.insert_one_playlist(playlist)
class CreatePlaylistBody(BaseModel):
name: str = Field(..., description="The name of the playlist")
@@ -88,9 +128,9 @@ def create_playlist(body: CreatePlaylistBody):
Creates a new playlist. Accepts POST method with a JSON body.
"""
existing_playlist_count = PL.count_playlist_by_name(body.name)
exists = PlaylistTable.check_exists_by_name(body.name)
if existing_playlist_count > 0:
if exists:
return {"error": "Playlist already exists"}, 409
playlist = insert_playlist(body.name)
@@ -101,36 +141,9 @@ def create_playlist(body: CreatePlaylistBody):
return {"playlist": playlist}, 201
def get_path_trackhashes(path: str):
"""
Returns a list of trackhashes in a folder.
"""
tracks = TrackStore.get_tracks_in_path(path)
tracks = sorted(tracks, key=lambda t: t.last_mod)
return [t.trackhash for t in tracks]
def get_album_trackhashes(albumhash: str):
"""
Returns a list of trackhashes in an album.
"""
tracks = TrackStore.get_tracks_by_albumhash(albumhash)
tracks = sort_by_track_no(tracks)
return [t["trackhash"] for t in tracks]
def get_artist_trackhashes(artisthash: str):
"""
Returns a list of trackhashes for an artist.
"""
tracks = TrackStore.get_tracks_by_artisthash(artisthash)
return [t.trackhash for t in tracks]
class PlaylistIDPath(BaseModel):
# INFO: playlistid string examples: "recentlyadded"
playlistid: int | str = Field(..., description="The ID of the playlist")
playlistid: str = Field(..., description="The ID of the playlist")
class AddItemToPlaylistBody(BaseModel):
@@ -164,28 +177,13 @@ def add_item_to_playlist(path: PlaylistIDPath, body: AddItemToPlaylistBody):
else:
trackhashes = []
insert_count = PL.add_tracks_to_playlist(int(playlist_id), trackhashes)
if insert_count == 0:
return {"error": "Item already exists in playlist"}, 409
PlaylistTable.append_to_playlist(int(playlist_id), trackhashes)
return {"msg": "Done"}, 200
class GetPlaylistQuery(BaseModel):
class GetPlaylistQuery(GenericLimitSchema):
no_tracks: bool = Field(False, description="Whether to include tracks")
def format_custom_playlist(playlist: models.Playlist, tracks: list[models.Track]):
duration = sum(t.duration for t in tracks)
playlist.set_duration(duration)
playlist = serialize_for_card(playlist)
return {
"info": playlist,
"tracks": tracks,
}
start: int = Field(0, description="The start index of the tracks")
@api.get("/<playlistid>")
@@ -203,32 +201,38 @@ def get_playlist(path: PlaylistIDPath, query: GetPlaylistQuery):
is_custom = playlistid in {p["name"] for p in custom_playlists}
if is_custom:
if query.start != 0:
return {
"tracks": [],
}
handler = next(
p["handler"] for p in custom_playlists if p["name"] == playlistid
)
playlist, tracks = handler()
return format_custom_playlist(playlist, tracks)
playlist = PL.get_playlist_by_id(int(playlistid))
playlist = PlaylistTable.get_by_id(int(playlistid))
if playlist is None:
return {"msg": "Playlist not found"}, 404
tracks = TrackStore.get_tracks_by_trackhashes(list(playlist.trackhashes))
if query.limit == -1:
query.limit = len(playlist.trackhashes) - 1
tracks = remove_duplicates(tracks)
tracks = TrackStore.get_tracks_by_trackhashes(
playlist.trackhashes[query.start : query.start + query.limit]
)
duration = sum(t.duration for t in tracks)
playlist.last_updated = date_string_to_time_passed(playlist.last_updated)
playlist.set_duration(duration)
playlist.set_count(len(tracks))
if not playlist.has_image:
playlist.images = playlistlib.get_first_4_images(tracks)
playlist._last_updated = date_string_to_time_passed(playlist.last_updated)
playlist.duration = duration
playlist.images = playlistlib.get_first_4_images(tracks)
playlist.clear_lists()
return {"info": playlist, "tracks": tracks if not no_tracks else []}
return {
"info": playlist,
"tracks": serialize_tracks(tracks) if not no_tracks else [],
}
class UpdatePlaylistForm(BaseModel):
@@ -247,7 +251,7 @@ def update_playlist_info(path: PlaylistIDPath, form: UpdatePlaylistForm):
Update playlist
"""
playlistid = path.playlistid
db_playlist = PL.get_playlist_by_id(playlistid)
db_playlist = PlaylistTable.get_by_id(playlistid)
if db_playlist is None:
return {"error": "Playlist not found"}, 404
@@ -266,7 +270,6 @@ def update_playlist_info(path: PlaylistIDPath, form: UpdatePlaylistForm):
"last_updated": create_new_date(),
"name": str(form.name).strip(),
"settings": settings,
"trackhashes": json.dumps([]),
}
if image:
@@ -286,7 +289,7 @@ def update_playlist_info(path: PlaylistIDPath, form: UpdatePlaylistForm):
p_tuple = (*playlist.values(),)
PL.update_playlist(playlistid, playlist)
PlaylistTable.update_one(playlistid, playlist)
playlist = models.Playlist(*p_tuple)
playlist.last_updated = date_string_to_time_passed(playlist.last_updated)
@@ -301,7 +304,7 @@ def pin_unpin_playlist(path: PlaylistIDPath):
"""
Pin playlist.
"""
playlist = PL.get_playlist_by_id(path.playlistid)
playlist = PlaylistTable.get_by_id(path.playlistid)
if playlist is None:
return {"error": "Playlist not found"}, 404
@@ -313,8 +316,7 @@ def pin_unpin_playlist(path: PlaylistIDPath):
except KeyError:
settings["pinned"] = True
PL.update_settings(path.playlistid, settings)
PlaylistTable.update_settings(path.playlistid, settings)
return {"msg": "Done"}, 200
@@ -323,12 +325,12 @@ def remove_playlist_image(path: PlaylistIDPath):
"""
Clear playlist image.
"""
playlist = PL.get_playlist_by_id(path.playlistid)
playlist = PlaylistTable.get_by_id(path.playlistid)
if playlist is None:
return {"error": "Playlist not found"}, 404
PL.remove_banner(path.playlistid)
PlaylistTable.remove_image(path.playlistid)
playlist.image = None
playlist.thumb = None
@@ -346,8 +348,7 @@ def remove_playlist(path: PlaylistIDPath):
"""
Delete playlist
"""
PL.delete_playlist(path.playlistid)
PlaylistTable.remove_one(path.playlistid)
return {"msg": "Done"}, 200
@@ -368,15 +369,11 @@ def remove_tracks_from_playlist(
# index: int;
# }
PL.remove_tracks_from_playlist(path.playlistid, body.tracks)
PlaylistTable.remove_from_playlist(path.playlistid, body.tracks)
return {"msg": "Done"}, 200
def playlist_name_exists(name: str) -> bool:
return PL.count_playlist_by_name(name) > 0
class SavePlaylistAsItemBody(BaseModel):
itemtype: str = Field(..., description="The type of item", example="tracks")
playlist_name: str = Field(..., description="The name of the playlist")
@@ -394,7 +391,7 @@ def save_item_as_playlist(body: SavePlaylistAsItemBody):
playlist_name = body.playlist_name
itemhash = body.itemhash
if playlist_name_exists(playlist_name):
if PlaylistTable.check_exists_by_name(playlist_name):
return {"error": "Playlist already exists"}, 409
if itemtype == "tracks":
@@ -437,8 +434,8 @@ def save_item_as_playlist(body: SavePlaylistAsItemBody):
img, str(playlist.id), "image/webp", filename=filename
)
PL.add_tracks_to_playlist(playlist.id, trackhashes)
playlist.set_count(len(trackhashes))
PlaylistTable.append_to_playlist(playlist.id, trackhashes)
playlist.count = len(trackhashes)
images = playlistlib.get_first_4_images(trackhashes=trackhashes)
playlist.images = [img["image"] for img in images]
+5 -10
View File
@@ -1,10 +1,8 @@
from flask import Blueprint, request
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
from app.api.auth import admin_required
from app.db.sqlite.plugins import PluginsMethods
from app.db.userdata import PluginTable
bp_tag = Tag(name="Plugins", description="Manage plugins")
api = APIBlueprint("plugins", __name__, url_prefix="/plugins", abp_tags=[bp_tag])
@@ -15,8 +13,7 @@ def get_all_plugins():
"""
List all plugins
"""
plugins = PluginsMethods.get_all_plugins()
plugins = PluginTable.get_all()
return {"plugins": plugins}
@@ -37,9 +34,7 @@ def activate_deactivate_plugin(body: PluginActivateBody):
Activate/Deactivate plugin
"""
name = body.plugin
active = 1 if body.active else 0
PluginsMethods.plugin_set_active(name, active)
PluginTable.activate(name, body.active)
return {"message": "OK"}, 200
@@ -62,7 +57,7 @@ def update_plugin_settings(body: PluginSettingsBody):
if not plugin or not settings:
return {"error": "Missing plugin or settings"}, 400
PluginsMethods.update_plugin_settings(plugin_name=plugin, settings=settings)
plugin = PluginsMethods.get_plugin_by_name(plugin)
PluginTable.update_settings(plugin, settings)
plugin = PluginTable.get_by_name(plugin)
return {"status": "success", "settings": plugin.settings}
+64
View File
@@ -0,0 +1,64 @@
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import Field
from app.api.apischemas import TrackHashSchema
from app.db.userdata import ScrobbleTable
from app.lib.extras import get_extra_info
from app.settings import Defaults
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
from app.store.tracks import TrackStore
bp_tag = Tag(name="Logger", description="Log item plays")
api = APIBlueprint("logger", __name__, url_prefix="/logger", abp_tags=[bp_tag])
class LogTrackBody(TrackHashSchema):
timestamp: int = Field(description="The timestamp of the track", example=1622217600)
duration: int = Field(
description="The duration of the track in seconds", example=300
)
source: str = Field(
description="The play source of the track",
example=f"al:{Defaults.API_ALBUMHASH}",
)
@api.post("/track/log")
def log_track(body: LogTrackBody):
"""
Log a track play to the database.
"""
timestamp = body.timestamp
duration = body.duration
if not timestamp or duration < 5:
return {"msg": "Invalid entry."}, 400
trackentry = TrackStore.trackhashmap.get(body.trackhash)
if trackentry is None:
return {"msg": "Track not found."}, 404
scrobble_data = dict(body)
scrobble_data["extra"] = get_extra_info(body.trackhash, "track")
ScrobbleTable.add(scrobble_data)
# Update play data on the in-memory stores
track = trackentry.tracks[0]
album = AlbumStore.albummap.get(track.albumhash)
if album:
album.increment_playcount(duration, timestamp)
for hash in track.artisthashes:
artist = ArtistStore.artistmap.get(hash)
if artist:
artist.increment_playcount(duration, timestamp)
track = TrackStore.trackhashmap.get(body.trackhash)
if track:
track.increment_playcount(duration, timestamp)
return {"msg": "recorded"}, 201
+26 -54
View File
@@ -2,17 +2,18 @@
Contains all the search routes.
"""
from flask import request
from unidecode import unidecode
from pydantic import BaseModel, Field
from pydantic import Field
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from app import models
from app.api.apischemas import GenericLimitSchema
from app.lib import searchlib
from app.settings import Defaults
from app.store.tracks import TrackStore
tag = Tag(name="Search", description="Search for tracks, albums and artists")
api = APIBlueprint("search", __name__, url_prefix="/search", abp_tags=[tag])
@@ -20,30 +21,18 @@ SEARCH_COUNT = 30
"""The max amount of items to return per request"""
def query_in_quotes(query: str) -> bool:
"""
Returns True if the query is in quotes
"""
try:
return query.startswith('"') and query.endswith('"')
except AttributeError:
return False
class Search:
def __init__(self, query: str) -> None:
self.tracks: list[models.Track] = []
self.query = unidecode(query)
def search_tracks(self, in_quotes=False):
def search_tracks(self):
"""
Calls :class:`SearchTracks` which returns the tracks that fuzzily match
the search terms. Then adds them to the `SearchResults` store.
"""
self.tracks = TrackStore.tracks
return searchlib.TopResults().search(
self.query, tracks_only=True, in_quotes=in_quotes
)
self.tracks = TrackStore.get_flat_list()
return searchlib.TopResults().search(self.query, tracks_only=True)
def search_artists(self):
"""Calls :class:`SearchArtists` which returns the artists that fuzzily match
@@ -51,25 +40,23 @@ class Search:
"""
return searchlib.SearchArtists(self.query)()
def search_albums(self, in_quotes=False):
def search_albums(self):
"""Calls :class:`SearchAlbums` which returns the albums that fuzzily match
the search term. Then adds them to the `SearchResults` store.
"""
return searchlib.TopResults().search(
self.query, albums_only=True, in_quotes=in_quotes
)
return searchlib.TopResults().search(self.query, albums_only=True)
def get_top_results(
self,
limit: int,
in_quotes=False,
):
finder = searchlib.TopResults()
return finder.search(self.query, in_quotes=in_quotes, limit=limit)
return finder.search(self.query, limit=limit)
class SearchQuery(BaseModel):
class SearchQuery(GenericLimitSchema):
q: str = Field(description="The search query", example=Defaults.API_ARTISTNAME)
start: int = Field(description="The index to start from", default=0, example=0)
@api.get("/tracks")
@@ -77,11 +64,7 @@ def search_tracks(query: SearchQuery):
"""
Search tracks
"""
query = query.q
in_quotes = query_in_quotes(query)
tracks = Search(query).search_tracks(in_quotes)
tracks = Search(query.q).search_tracks()
return {
"tracks": tracks[:SEARCH_COUNT],
@@ -90,15 +73,13 @@ def search_tracks(query: SearchQuery):
@api.get("/albums")
def search_albums(query: SearchQuery):
def search_albums(
query: SearchQuery,
):
"""
Search albums.
"""
query = query.q
in_quotes = query_in_quotes(query)
albums = Search(query).search_albums(in_quotes)
albums = Search(query.q).search_albums()
return {
"albums": albums[:SEARCH_COUNT],
@@ -111,13 +92,10 @@ def search_artists(query: SearchQuery):
"""
Search artists.
"""
query = query.q
if not query:
if not query.q:
return {"error": "No query provided"}, 400
artists = Search(query).search_artists()
artists = Search(query.q).search_artists()
return {
"artists": artists[:SEARCH_COUNT],
@@ -138,20 +116,15 @@ def get_top_results(query: TopResultsQuery):
Returns the top results for the given query.
"""
limit = query.limit
query = query.q
in_quotes = query_in_quotes(query)
if not query:
if not query.q:
return {"error": "No query provided"}, 400
return Search(query).get_top_results(in_quotes=in_quotes, limit=limit)
return Search(query.q).get_top_results(limit=query.limit)
class SearchLoadMoreQuery(SearchQuery):
type: str = Field(description="The type of search", example="tracks")
index: int = Field(description="The index to start from", default=0)
start: int = Field(description="The index to start from", default=0)
@api.get("/loadmore")
@@ -163,27 +136,26 @@ def search_load_more(query: SearchLoadMoreQuery):
NOTE: You must first initiate a search using the `/search` endpoint.
"""
query = query.q
q = query.q
item_type = query.type
index = query.index
in_quotes = query_in_quotes(query)
index = query.start
if item_type == "tracks":
t = Search(query).search_tracks(in_quotes)
t = Search(q).search_tracks()
return {
"tracks": t[index : index + SEARCH_COUNT],
"more": len(t) > index + SEARCH_COUNT,
}
elif item_type == "albums":
a = Search(query).search_albums(in_quotes)
a = Search(q).search_albums()
return {
"albums": a[index : index + SEARCH_COUNT],
"more": len(a) > index + SEARCH_COUNT,
}
elif item_type == "artists":
a = Search(query).search_artists()
a = Search(q).search_artists()
return {
"artists": a[index : index + SEARCH_COUNT],
"more": len(a) > index + SEARCH_COUNT,
+39 -172
View File
@@ -1,22 +1,13 @@
from dataclasses import asdict
from typing import Any
from flask import request
from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint
from pydantic import BaseModel, Field
from app.api.auth import admin_required
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.lib import populate
from app.lib.watchdogg import Watcher as WatchDog
from app.logger import log
from app.settings import Info, Paths, SessionVarKeys, set_flag
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
from app.store.tracks import TrackStore
from app.utils.generators import get_random_str
from app.utils.threading import background
from app.db.userdata import PluginTable
from app.lib.index import index_everything
from app.settings import Info
from app.config import UserConfig
bp_tag = Tag(name="Settings", description="Customize stuff")
@@ -29,63 +20,6 @@ def get_child_dirs(parent: str, children: list[str]):
return [_dir for _dir in children if _dir.startswith(parent) and _dir != parent]
def reload_everything(instance_key: str):
"""
Reloads all stores using the current database items
"""
try:
TrackStore.load_all_tracks(instance_key)
except Exception as e:
log.error(e)
try:
AlbumStore.load_albums(instance_key=instance_key)
except Exception as e:
log.error(e)
try:
ArtistStore.load_artists(instance_key)
except Exception as e:
log.error(e)
@background
def rebuild_store(db_dirs: list[str]):
"""
Restarts watchdog and rebuilds the music library.
"""
instance_key = get_random_str()
log.info("Rebuilding library...")
trackdb.remove_tracks_not_in_folders(db_dirs)
reload_everything(instance_key)
try:
populate.Populate(instance_key=instance_key)
except populate.PopulateCancelledError as e:
print(e)
reload_everything(instance_key)
return
WatchDog().restart()
log.info("Rebuilding library... ✅")
# I freaking don't know what this function does anymore
def finalize(new_: list[str], removed_: list[str], db_dirs_: list[str]):
"""
Params:
new_: will be added to the database
removed_: will be removed from the database
db_dirs_: will be used to remove tracks that
are outside these directories from the database and store.
"""
sdb.remove_root_dirs(removed_)
sdb.add_root_dirs(new_)
rebuild_store(db_dirs_)
class AddRootDirsBody(BaseModel):
new_dirs: list[str] = Field(
description="The new directories to add",
@@ -106,7 +40,8 @@ def add_root_dirs(body: AddRootDirsBody):
new_dirs = body.new_dirs
removed_dirs = body.removed
db_dirs = sdb.get_root_dirs()
config = UserConfig()
db_dirs = config.rootDirs
home = "$home"
db_home = any([d == home for d in db_dirs]) # if $home is in db
@@ -114,13 +49,16 @@ def add_root_dirs(body: AddRootDirsBody):
# handle $home case
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:
sdb.remove_root_dirs(db_dirs)
config.rootDirs = []
if incoming_home:
finalize([home], [], [Paths.USER_HOME_DIR])
config.rootDirs = [home]
index_everything()
return {"root_dirs": [home]}
# ---
@@ -136,11 +74,10 @@ def add_root_dirs(body: AddRootDirsBody):
pass
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)
return {"root_dirs": db_dirs}
index_everything()
return {"root_dirs": config.rootDirs}
@api.get("/get-root-dirs")
@@ -148,21 +85,7 @@ def get_root_dirs():
"""
Get root directories
"""
dirs = sdb.get_root_dirs()
return {"dirs": dirs}
# maps settings to their parser flags
mapp = {
"artist_separators": SessionVarKeys.ARTIST_SEPARATORS,
"extract_feat": SessionVarKeys.EXTRACT_FEAT,
"remove_prod": SessionVarKeys.REMOVE_PROD,
"clean_album_title": SessionVarKeys.CLEAN_ALBUM_TITLE,
"remove_remaster": SessionVarKeys.REMOVE_REMASTER_FROM_TRACK,
"merge_albums": SessionVarKeys.MERGE_ALBUM_VERSIONS,
"show_albums_as_singles": SessionVarKeys.SHOW_ALBUMS_AS_SINGLES,
}
return {"dirs": UserConfig().rootDirs}
@api.get("")
@@ -170,40 +93,12 @@ def 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()
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
def reload_all_for_set_setting():
reload_everything(get_random_str())
return config
class SetSettingBody(BaseModel):
@@ -217,57 +112,12 @@ class SetSettingBody(BaseModel):
)
@api.post("/set")
@admin_required()
def set_setting(body: SetSettingBody):
"""
Set a setting.
"""
key = body.key
value = body.value
if key is None or value is None or key == "root_dirs":
return {"msg": "Invalid arguments!"}, 400
root_dir = sdb.get_root_dirs()
if not root_dir:
return {"msg": "No root directories set!"}, 400
if key not in mapp:
return {"msg": "Invalid key!"}, 400
sdb.set_setting(key, value)
flag = mapp[key]
if key == "artist_separators":
value = str(value).split(",")
value = set(value)
set_flag(flag, value)
reload_all_for_set_setting()
# if value is a set, convert it to a string
# (artist_separators)
if type(value) == set:
value = ",".join(value)
return {"result": value}
@background
def run_populate():
populate.Populate(instance_key=get_random_str())
@api.get("/trigger-scan")
def trigger_scan():
"""
Triggers scan for new music
"""
run_populate()
index_everything()
return {"msg": "Scan triggered!"}
@@ -289,8 +139,25 @@ def update_config(body: UpdateConfigBody):
Update the config file
"""
config = UserConfig()
if body.key == "artistSeparators":
body.value = body.value.split(",")
setattr(config, body.key, body.value)
# INFO: Rebuild stores when these settings are updated
reset_stores_lists = {
"artistSeparators",
"artistSplitIgnoreList",
"removeProdBy",
"removeRemasterInfo",
"mergeAlbums",
"cleanAlbumTitle",
"showAlbumsAsSingles",
}
if body.key in reset_stores_lists:
index_everything()
return {
"msg": "Config updated!",
}
+190 -59
View File
@@ -3,12 +3,16 @@ Contains all the track routes.
"""
import os
import tempfile
import time
from typing import Literal
from flask import send_file, request, Response
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from app.api.apischemas import TrackHashSchema
from app.lib.trackslib import get_silence_paddings
from app.lib.transcoder import start_transcoding
from app.store.tracks import TrackStore
from app.utils.files import guess_mime_type
@@ -17,10 +21,36 @@ bp_tag = Tag(name="File", description="Audio files")
api = APIBlueprint("track", __name__, url_prefix="/file", abp_tags=[bp_tag])
class TransCodeStore:
map: dict[str, str] = {}
@classmethod
def add_file(cls, trackhash: str, filepath: str):
cls.map[trackhash] = filepath
@classmethod
def remove_file(cls, trackhash: str):
del cls.map[trackhash]
@classmethod
def find(cls, trackhash: str):
return cls.map.get(trackhash)
class SendTrackFileQuery(BaseModel):
filepath: str = Field(
description="The filepath to play (if available)", default=None
)
quality: Literal["original", "1411", "800", "600", "320", "256", "128", "96"] = (
Field(
"320",
description="The quality of the audio file. Options: original, 1411, 1024, 512, 320, 256, 128, 96",
)
)
container: Literal["mp3", "aac", "flac", "webm", "ogg"] = Field(
"flac",
description="The container format of the audio file. Options: mp3, aac, flac, webm, ogg",
)
@api.get("/<trackhash>/legacy")
@@ -29,41 +59,34 @@ def send_track_file_legacy(path: TrackHashSchema, query: SendTrackFileQuery):
Get a playable audio file without Range support
Returns a playable audio file that corresponds to the given filepath. Falls back to track hash if filepath is not found.
NOTE: Does not support range requests or transcoding.
"""
trackhash = path.trackhash
filepath = query.filepath
msg = {"msg": "File Not Found"}
def get_mime(filename: str) -> str:
ext = filename.rsplit(".", maxsplit=1)[-1]
return f"audio/{ext}"
track = None
tracks = TrackStore.get_tracks_by_filepaths([filepath])
# If filepath is provide, try to send that
if filepath is not None:
try:
track = TrackStore.get_tracks_by_filepaths([filepath])[0]
except IndexError:
track = None
if len(tracks) > 0 and os.path.exists(filepath):
track = tracks[0]
else:
res = TrackStore.trackhashmap.get(trackhash)
track_exists = track is not None and os.path.exists(track.filepath)
# When finding by trackhash, sort by bitrate
# and get the first track that exists
if res is not None:
tracks = sorted(res.tracks, key=lambda x: x.bitrate, reverse=True)
if track_exists:
audio_type = get_mime(filepath)
return send_file(filepath, mimetype=audio_type, conditional=True)
for t in tracks:
if os.path.exists(t.filepath):
track = t
break
# Else, find file by trackhash
tracks = TrackStore.get_tracks_by_trackhashes([trackhash])
for track in tracks:
if track is None:
return msg, 404
audio_type = get_mime(track.filepath)
try:
return send_file(track.filepath, mimetype=audio_type, conditional=True)
except (FileNotFoundError, OSError) as e:
return msg, 404
if track is not None:
audio_type = guess_mime_type(filepath)
return send_file(filepath, mimetype=audio_type, conditional=True)
return msg, 404
@@ -74,48 +97,125 @@ def send_track_file(path: TrackHashSchema, query: SendTrackFileQuery):
Get a playable audio file with Range headers support
Returns a playable audio file that corresponds to the given filepath. Falls back to track hash if filepath is not found.
Transcoding can be done by sending the quality and container query parameters.
**NOTES:**
- Transcoded streams report incorrect duration during playback (idk why! FFMPEG gurus we need your help here).
- The quality parameter is the desired bitrate in kbps.
- The mp3 container is the best container for upto 320kbps (and has better duration reporting). The flac container allows for higher bitrates but it produces dramatically larger files (when transcoding from lossy formats).
- You can get the transcoded bitrate by checking the X-Transcoded-Bitrate header on the first request's response.
"""
trackhash = path.trackhash
filepath = query.filepath
msg = {"msg": "File Not Found"}
# If filepath is provided, try to send that
if filepath is not None:
try:
track = TrackStore.get_tracks_by_filepaths([filepath])[0]
except IndexError:
track = None
track = None
tracks = TrackStore.get_tracks_by_filepaths([filepath])
track_exists = track is not None and os.path.exists(track.filepath)
if len(tracks) > 0 and os.path.exists(filepath):
track = tracks[0]
else:
res = TrackStore.trackhashmap.get(trackhash)
if track_exists:
audio_type = guess_mime_type(filepath)
return send_file_as_chunks(track.filepath, audio_type)
# When finding by trackhash, sort by bitrate
# and get the first track that exists
if res is not None:
tracks = sorted(res.tracks, key=lambda x: x.bitrate, reverse=True)
# Else, find file by trackhash
tracks = TrackStore.get_tracks_by_trackhashes([trackhash])
for t in tracks:
if os.path.exists(t.filepath):
track = t
break
for track in tracks:
if track is None:
return msg, 404
if track is not None:
if query.quality == "original":
return send_file_as_chunks(track.filepath)
audio_type = guess_mime_type(track.filepath)
# prevent requesting over transcoding
max_bitrate = track.bitrate
requested_bitrate = int(query.quality)
try:
return send_file_as_chunks(track.filepath, audio_type)
except (FileNotFoundError, OSError) as e:
return msg, 404
if query.container != "flac":
# drop to 320 for non-flac containers
requested_bitrate = min(320, requested_bitrate)
return msg, 404
quality = f"{min(max_bitrate, requested_bitrate)}k"
return transcode_and_stream(trackhash, track.filepath, quality, query.container)
return {"msg": "File Not Found"}, 404
def send_file_as_chunks(filepath: str, audio_type: str) -> Response:
def transcode_and_stream(trackhash: str, filepath: str, bitrate: str, container: str):
"""
Initiates transcoding and returns the first chunk of the transcoded file.
The other chunks are streamed on subsequent requests and are rerouted to `send_file_as_chunks`.
"""
temp_file = TransCodeStore.find(trackhash)
if temp_file is not None:
return send_file_as_chunks(temp_file)
format_params = {
"mp3": ["-c:a", "libmp3lame"],
"aac": ["-c:a", "aac"],
"webm": ["-c:a", "libopus"],
"ogg": ["-c:a", "libvorbis"],
"flac": ["-c:a", "flac"],
"wav": ["-c:a", "pcm_s16le"],
}
# Create a temporary file
format = f".{container}" if container in format_params.keys() else ".flac"
container_args = (
format_params[container]
if container in format_params.keys()
else format_params["flac"]
)
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=format)
temp_filename = temp_file.name
temp_file.close()
TransCodeStore.add_file(trackhash, temp_filename)
start_transcoding(filepath, temp_filename, bitrate, container_args)
chunk_size = 1024 * 512 # 0.5MB
file_size = os.path.getsize(filepath)
def generate():
# Poll for the output file
while (
not os.path.exists(temp_filename)
or os.path.getsize(temp_filename) < chunk_size
):
print(f"Waiting for transcoding to complete... filename: {temp_filename}")
time.sleep(0.1) # Wait for 100ms before checking again
with open(temp_filename, "rb") as file:
file.seek(0)
return file.read(chunk_size)
audio_type = guess_mime_type(temp_filename)
response = Response(
generate(),
206,
mimetype=audio_type,
content_type=audio_type,
direct_passthrough=True,
)
response.headers.add("Content-Range", f"bytes {0}-{chunk_size}/{file_size}")
response.headers.add("Accept-Ranges", "bytes")
response.headers.add("X-Transcoded-Bitrate", bitrate)
return response
def send_file_as_chunks(filepath: str) -> Response:
"""
Returns a Response object that streams the file in chunks.
"""
# NOTE: +1 makes sure the last byte is included in the range.
# NOTE: -1 is used to convert the end index to a 0-based index.
chunk_size = 1024 * 360 # 360 KB
chunk_size = 1024 * 512 # 0.5MB
# Get file size
file_size = os.path.getsize(filepath)
@@ -141,25 +241,56 @@ def send_file_as_chunks(filepath: str, audio_type: str) -> Response:
file.seek(start)
remaining_bytes = end - start + 1
while remaining_bytes > 0:
# Read the chunk size or all the remaining bytes
retry_count = 0
max_retries = 10 # 5 * 100ms = 500ms total wait time
while remaining_bytes > 0 or retry_count < max_retries:
if retry_count == max_retries:
print("💚 sending final chunk! ...")
pos = file.tell()
chunk = file.read(os.path.getsize(filepath) - pos)
return chunk, pos, True
if remaining_bytes < chunk_size:
time.sleep(0.25)
retry_count += 1
remaining_bytes = os.path.getsize(filepath) - file.tell()
continue
chunk = file.read(min(chunk_size, remaining_bytes))
yield chunk
if chunk:
remaining_bytes -= len(chunk)
return chunk, file.tell(), False
else:
# If no data is read, wait for 100ms before retrying
time.sleep(0.25)
retry_count += 1
# Update the remaining bytes
remaining_bytes -= len(chunk)
# update remaining bytes
remaining_bytes = os.path.getsize(filepath) - file.tell()
print(f"▶ Remaining bytes: {remaining_bytes}")
return None, 0, True
data, position, is_final = generate_chunks()
audio_type = guess_mime_type(filepath)
response = Response(
generate_chunks(),
206, # Partial Content status code
response=data,
status=206, # Partial Content status code
mimetype=audio_type,
content_type=audio_type,
direct_passthrough=True,
)
response.headers.add("Content-Range", f"bytes {start}-{end}/{file_size}")
response.headers.add("Accept-Ranges", "bytes")
response.headers.add("Content-Length", str(end - start + 1))
bytes_to_add = chunk_size if not is_final else 0
response.headers.add(
"Content-Range",
f"bytes {start}-{position}/{os.path.getsize(filepath) + bytes_to_add}",
)
response.headers.add("Accept-Ranges", "bytes")
return response
+9 -7
View File
@@ -9,13 +9,15 @@ import sys
import PyInstaller.__main__ as bundler
from app import settings
from app.config import UserConfig
from app.db.userdata import UserTable
from app.logger import log
from app.print_help import HELP_MESSAGE
from app.setup.sqlite import setup_sqlite
from app.utils.auth import hash_password
from app.utils.paths import getFlaskOpenApiPath
from app.utils.xdg_utils import get_xdg_config_dir
from app.utils.wintools import is_windows
from app.db.sqlite.auth import SQLiteAuthMethods as authdb
ALLARGS = settings.ALLARGS
ARGS = sys.argv[1:]
@@ -160,7 +162,7 @@ class ProcessArgs:
@staticmethod
def handle_periodic_scan():
if any((a in ARGS for a in ALLARGS.no_periodic_scan)):
settings.SessionVars.DO_PERIODIC_SCANS = False
UserConfig().enablePeriodicScans = False
@staticmethod
def handle_periodic_scan_interval():
@@ -182,10 +184,10 @@ class ProcessArgs:
sys.exit(0)
if psi < 0:
print("WADAFUCK ARE YOU TRYING?")
print("WHAT ARE YOU TRYING?")
sys.exit(0)
settings.SessionVars.PERIODIC_SCAN_INTERVAL = psi
UserConfig().scanInterval = psi
@staticmethod
def handle_help():
@@ -207,6 +209,7 @@ class ProcessArgs:
if ALLARGS.pswd in ARGS:
print("SWING MUSIC v2.0.0 ")
print("PASSWORD RECOVERY \n")
setup_sqlite()
username: str = ""
password: str = ""
@@ -219,7 +222,7 @@ class ProcessArgs:
sys.exit(0)
username = username.strip()
user = authdb.get_user_by_username(username)
user = UserTable.get_by_username(username)
if not user:
print(f"User {username} not found")
@@ -232,7 +235,6 @@ class ProcessArgs:
print("\nOperation cancelled! Exiting ...")
sys.exit(0)
password = hash_password(password)
user = authdb.update_user({"id": user.id, "password": password})
UserTable.update_one({"id": user.id, "password": hash_password(password)})
sys.exit(0)
+20 -3
View File
@@ -6,6 +6,7 @@ from .settings import Paths
# TODO: Publish this on PyPi
@dataclass
class UserConfig:
_config_path: str = ""
@@ -14,13 +15,21 @@ class UserConfig:
# auth stuff
# NOTE: Don't expose the userId via the API
userId: str = ""
serverId: str = ""
usersOnLogin: bool = True
# lists
rootDirs: list[str] = field(default_factory=list)
excludeDirs: list[str] = field(default_factory=list)
artistSeparators: set[str] = field(default_factory=list)
artistSeparators: set[str] = field(default_factory=lambda: {";", "/"})
artistSplitIgnoreList: set[str] = field(
default_factory=lambda: {
"AC/DC",
"Bob marley & the wailers",
"Crosby, Stills, Nash & Young",
}
)
genreSeparators: set[str] = field(default_factory=lambda: {"/", ";", "&"})
# tracks
extractFeaturedArtists: bool = True
@@ -32,6 +41,14 @@ class UserConfig:
cleanAlbumTitle: bool = True
showAlbumsAsSingles: bool = False
# misc
enablePeriodicScans: bool = False
scanInterval: int = 10
enableWatchdog: bool = False
# plugins
enablePlugins: bool = True
def __post_init__(self):
"""
Loads the config file and sets the values to this instance
@@ -79,7 +96,7 @@ class UserConfig:
settings = {k: v for k, v in settings.items() if not k.startswith("_")}
with open(self._config_path, "w") as f:
json.dump(settings, f, indent=4)
json.dump(settings, f, indent=4, default=list)
def __setattr__(self, key: str, value: Any) -> None:
"""
+61
View File
@@ -0,0 +1,61 @@
from typing import Any
from sqlalchemy import (
delete,
func,
insert,
select,
)
from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
from app.db.engine import DbEngine
class Base(MappedAsDataclass, DeclarativeBase):
"""
Base class for all database models.
It has methods common to all tables. eg. `insert_one`, `insert_many`, `remove_all`, `remove_one`, `all`, `count`.
"""
@classmethod
def execute(cls, stmt: Any, commit: bool = False):
with DbEngine.manager(commit=commit) as session:
return session.execute(stmt)
@classmethod
def insert_many(cls, items: list[dict[str, Any]]):
"""
Inserts multiple items into the database.
"""
return cls.execute(insert(cls).values(items), commit=True)
@classmethod
def insert_one(cls, item: dict[str, Any]):
"""
Inserts a single item into the database.
"""
return cls.insert_many([item])
@classmethod
def remove_all(cls):
return cls.execute(delete(cls), commit=True)
@classmethod
def remove_one(cls, id: int):
return cls.execute(delete(cls).where(cls.id == id), commit=True)
@classmethod
def all(cls):
return cls.execute(select(cls))
@classmethod
def count(cls):
return cls.execute(select(func.count()).select_from(cls)).scalar()
def create_all_tables():
"""
Creates all the tables that build on the Base class.
"""
Base().metadata.create_all(DbEngine.engine)
+45
View File
@@ -0,0 +1,45 @@
from contextlib import contextmanager
import gc
from sqlalchemy import Engine, event
@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA cache_size=10000")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA temp_store=MEMORY")
cursor.execute("PRAGMA mmap_size=30000000000")
cursor.close()
class DbEngine:
"""
The database engine instance.
"""
engine: Engine
@classmethod
@contextmanager
def manager(cls, commit: bool = False):
"""
This context manager manages access to the database.
When the context manager is entered, it returns a session object that can be used to execute SQL statements.
If the `commit` parameter is set to `True`, the context manager will commit the transaction when it exits.
"""
conn = cls.engine.connect()
try:
yield conn.execution_options(preserve_rowcount=True)
if commit:
conn.commit()
except Exception as e:
conn.rollback()
raise e
finally:
conn.close()
+429
View File
@@ -0,0 +1,429 @@
from app.db import (
Base as MasterBase,
)
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.engine import DbEngine
from sqlalchemy import JSON, Integer, String, 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.
NOTE: We need this function because the MasterBase does not collect
the tables defined here (as they are grand-children of the MasterBase)
"""
Base.metadata.create_all(DbEngine.engine)
class Base(MasterBase, DeclarativeBase):
@classmethod
def get_all_hashes(cls, create_date: int | None = None):
with DbEngine.manager() as conn:
if create_date:
if cls.__tablename__ == "track":
stmt = select(TrackTable.trackhash).where(
cls.last_mod < create_date
)
elif cls.__tablename__ == "album":
stmt = select(AlbumTable.albumhash).where(
cls.created_date < create_date
)
elif cls.__tablename__ == "artist":
stmt = select(ArtistTable.artisthash).where(
cls.created_date < create_date
)
else:
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 DbEngine.manager(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)
@classmethod
def increment_scrobblecount(
cls, table: Any, field: Any, hash: str, duration: int, timestamp: int
):
cls.execute(
update(table)
.where(field == hash)
.values(
playcount=table.playcount + 1,
playduration=table.playduration + duration,
lastplayed=timestamp,
),
commit=True,
)
class TrackTable(Base):
__tablename__ = "track"
id: Mapped[int] = mapped_column(init=False, primary_key=True)
album: Mapped[str] = mapped_column(String())
albumartists: Mapped[str] = mapped_column(String())
albumhash: Mapped[str] = mapped_column(String(), index=True)
# artisthashes: Mapped[list[str]] = mapped_column(JSON(), index=True)
artists: Mapped[str] = mapped_column(String())
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(), index=True, unique=True)
folder: Mapped[str] = mapped_column(String(), index=True)
# genrehashes: Mapped[list[str]] = mapped_column(JSON(), index=True)
genres: Mapped[Optional[str]] = mapped_column(String())
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)
lastplayed: Mapped[int] = mapped_column(Integer(), default=0)
playcount: Mapped[int] = mapped_column(Integer(), default=0)
playduration: Mapped[int] = mapped_column(Integer(), default=0)
extra: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSON(), default_factory=dict
)
@classmethod
def get_all(cls):
with DbEngine.manager() as conn:
result = conn.execute(select(cls))
return tracks_to_dataclasses(result.fetchall())
@classmethod
def get_tracks_by_filepaths(cls, filepaths: list[str]):
with DbEngine.manager() as conn:
result = conn.execute(
select(TrackTable)
.where(TrackTable.filepath.in_(filepaths))
.order_by(TrackTable.last_mod)
)
return tracks_to_dataclasses(result.fetchall())
@classmethod
def get_tracks_by_albumhash(cls, albumhash: str):
with DbEngine.manager() 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 DbEngine.manager() as conn:
if filepath:
result = conn.execute(
select(TrackTable)
.where(
(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 DbEngine.manager() 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 DbEngine.manager() 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 DbEngine.manager() as conn:
result = conn.execute(
select(TrackTable)
.where(TrackTable.trackhash.in_(hashes))
.group_by(TrackTable.trackhash)
.limit(limit)
)
tracks = tracks_to_dataclasses(result.fetchall())
# order the tracks in the same order as the hashes
if type(hashes) == list:
return sorted(tracks, key=lambda x: hashes.index(x.trackhash))
return tracks
@classmethod
def get_recently_added(cls, start: int, limit: int):
with DbEngine.manager() as conn:
result = conn.execute(
select(TrackTable)
.order_by(TrackTable.last_mod.desc())
.offset(start)
.limit(limit)
)
return tracks_to_dataclasses(result.fetchall())
@classmethod
def get_recently_played(cls, limit: int):
result = cls.execute(
select(cls)
.group_by(cls.trackhash)
.order_by(cls.lastplayed.desc())
.limit(limit)
)
return tracks_to_dataclasses(result.fetchall())
@classmethod
def remove_tracks_by_filepaths(cls, filepaths: set[str]):
with DbEngine.manager(commit=True) as conn:
conn.execute(delete(TrackTable).where(TrackTable.filepath.in_(filepaths)))
@classmethod
def increment_playcount(cls, trackhash: str, duration: int, timestamp: int):
cls.increment_scrobblecount(
TrackTable, TrackTable.trackhash, trackhash, duration, timestamp
)
# @classmethod
# def update_artist_separators(cls, separators: set[str]):
# tracks = cls.get_all()
# with DbEngine.manager(commit=True) as conn:
# for track in tracks:
# track.split_artists(separators)
# conn.execute(
# update(cls)
# .where(cls.trackhash == track.trackhash)
# .values(artists=track.artists, artisthashes=track.artisthashes)
# )
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())
lastplayed: Mapped[int] = mapped_column(Integer(), default=0)
playcount: Mapped[int] = mapped_column(Integer(), default=0)
playduration: Mapped[int] = mapped_column(Integer(), default=0)
extra: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSON(), default_factory=dict
)
@classmethod
def get_all(cls):
with DbEngine.manager() 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 DbEngine.manager() 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 DbEngine.manager() as conn:
result = conn.execute(
select(AlbumTable).where(AlbumTable.albumhash.in_(hashes)).limit(limit)
)
albums = albums_to_dataclasses(result.fetchall())
# order the albums in the same order as the hashes
if type(hashes) == list:
return sorted(albums, key=lambda x: hashes.index(x.albumhash))
return albums
@classmethod
def get_albums_by_artisthashes(cls, artisthashes: list[str]):
with DbEngine.manager() as conn:
albums: dict[str, list[AlbumModel]] = {}
for artist in artisthashes:
result = conn.execute(
select(AlbumTable).where(AlbumTable.artisthashes.contains(artist))
)
albums[artist] = albums_to_dataclasses(result.fetchall())
return albums
@classmethod
def get_albums_by_base_title(cls, base_title: str):
with DbEngine.manager() 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 DbEngine.manager() as conn:
result = conn.execute(
select(AlbumTable).where(AlbumTable.artisthashes.contains(artisthash))
)
return albums_to_dataclasses(result.all())
@classmethod
def increment_playcount(cls, albumhash: str, duration: int, timestamp: int):
return cls.increment_scrobblecount(
AlbumTable, AlbumTable.albumhash, albumhash, duration, timestamp
)
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())
lastplayed: Mapped[int] = mapped_column(Integer(), default=0)
playcount: Mapped[int] = mapped_column(Integer(), default=0)
playduration: Mapped[int] = mapped_column(Integer(), default=0)
extra: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSON(), default_factory=dict
)
@classmethod
def get_all(cls):
with DbEngine.manager() 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 DbEngine.manager() 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 DbEngine.manager() 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 DbEngine.manager() as conn:
result = conn.execute(
select(ArtistTable)
.where(ArtistTable.artisthash.in_(hashes))
.limit(limit)
)
return artists_to_dataclasses(result.fetchall())
@classmethod
def increment_playcount(
cls, artisthashes: list[str], duration: int, timestamp: int
):
cls.execute(
update(cls)
.where(ArtistTable.artisthash.in_(artisthashes))
.values(
playcount=ArtistTable.playcount + 1,
playduration=ArtistTable.playduration + duration,
lastplayed=timestamp,
),
commit=True,
)
+35
View File
@@ -0,0 +1,35 @@
from app.db import Base
from sqlalchemy import Integer, insert, select, update
from sqlalchemy.orm import Mapped, mapped_column
from app.db.engine import DbEngine
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 DbEngine.manager(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 DbEngine.manager() as conn:
result = conn.execute(select(cls.version).where(cls.id == 1))
result = result.fetchone()
if result:
return result[0]
return -1
-18
View File
@@ -1,21 +1,3 @@
"""
This module contains the functions to interact with the SQLite database.
"""
import sqlite3
from sqlite3 import Connection as SqlConn
def create_connection(db_file: str) -> SqlConn:
"""
Creates a connection to the specified database.
"""
conn = sqlite3.connect(db_file)
return conn
def create_tables(conn: SqlConn, sql_query: str):
"""
Executes the specifiend SQL file to create database tables.
"""
conn.executescript(sql_query)
-66
View File
@@ -1,66 +0,0 @@
from sqlite3 import Cursor
from .utils import SQLiteManager, tuples_to_albums
class SQLiteAlbumMethods:
@classmethod
def insert_one_album(cls, cur: Cursor, albumhash: str, colors: str):
"""
Inserts one album into the database
"""
sql = """INSERT OR REPLACE INTO albums(
albumhash,
colors
) VALUES(?,?)
"""
cur.execute(sql, (albumhash, colors))
lastrowid = cur.lastrowid
return lastrowid
@classmethod
def get_all_albums(cls):
with SQLiteManager() as cur:
cur.execute("SELECT * FROM albums")
albums = cur.fetchall()
cur.close()
if albums is not None:
return albums
return []
@staticmethod
def get_albums_by_albumartist(albumartist: str):
with SQLiteManager() as cur:
cur.execute("SELECT * FROM albums WHERE albumartist=?", (albumartist,))
albums = cur.fetchall()
cur.close()
if albums is not None:
return tuples_to_albums(albums)
return []
@staticmethod
def exists(albumhash: str, cur: Cursor = None):
"""
Checks if an album exists in the database.
"""
sql = "SELECT COUNT(1) FROM albums WHERE albumhash = ?"
def _exists(cur: Cursor):
cur.execute(sql, (albumhash,))
count = cur.fetchone()[0]
return count != 0
if cur:
return _exists(cur)
with SQLiteManager() as cur:
return _exists(cur)
-64
View File
@@ -1,64 +0,0 @@
"""
Contains methods for reading and writing to the sqlite artists database.
"""
import json
from sqlite3 import Cursor
from .utils import SQLiteManager
class SQLiteArtistMethods:
@staticmethod
def insert_one_artist(cur: Cursor, artisthash: str, colors: list[str]):
"""
Inserts a single artist into the database.
"""
sql = """INSERT OR REPLACE INTO artists(
artisthash,
colors
) VALUES(?,?)
"""
colors = json.dumps(colors)
cur.execute(sql, (artisthash, colors))
@staticmethod
def get_all_artists(cur_: Cursor = None):
"""
Get all artists from the database and return a generator of Artist objects
"""
sql = """SELECT * FROM artists"""
if not cur_:
with SQLiteManager() as cur:
cur.execute(sql)
for artist in cur.fetchall():
yield artist
cur.close()
else:
cur_.execute(sql)
for artist in cur_.fetchall():
yield artist
@staticmethod
def exists(artisthash: str, cur: Cursor = None):
"""
Checks if an artist exists in the database.
"""
sql = "SELECT COUNT(1) FROM artists WHERE artisthash = ?"
def _exists(cur: Cursor):
cur.execute(sql, (artisthash,))
count = cur.fetchone()[0]
return count != 0
if cur:
return _exists(cur)
with SQLiteManager() as cur:
return _exists(cur)
-146
View File
@@ -1,146 +0,0 @@
import json
from app.models.user import User
from app.utils.auth import hash_password
from app.db.sqlite.utils import SQLiteManager
class SQLiteAuthMethods:
"""
Methods for authenticating users.
"""
@staticmethod
def insert_user(user: dict[str, str]):
"""
Insert a user into the database.
:param user: A dict with the username, password and roles.
"""
sql = """INSERT INTO users(
username,
password,
roles
) VALUES(:username, :password, :roles)
"""
user_tuple = tuple(user.values())
with SQLiteManager(userdata_db=True) as cur:
cur = cur.execute(sql, user_tuple)
userid = cur.lastrowid
return userid
# if userid:
# # sleep
# user = SQLiteAuthMethods.get_user_by_id(userid).todict_simplified()
# cur.close()
# return user
raise Exception(f"Failed to insert user: {user}")
@staticmethod
def insert_default_user():
"""
Inserts the default admin user.
"""
user = {
"username": "admin",
"password": hash_password("admin"),
"roles": json.dumps(["admin"]),
}
return SQLiteAuthMethods.insert_user(user)
@staticmethod
def insert_guest_user():
"""
Inserts the default guest user.
"""
user = {
"username": "guest",
"password": hash_password("guest"),
"roles": json.dumps(["guest"]),
}
return SQLiteAuthMethods.insert_user(user)
@staticmethod
def update_user(user: dict[str, str]):
"""
Update a user in the database.
:param user: A dict with the user id and the fields to update. Ommited fields will not be updated.
"""
# get all user dict keys
keys = list(user.keys())
sql = f"""UPDATE users SET
{', '.join([f"{key} = :{key}" for key in keys if key != 'id'])}
WHERE id = :id
"""
print(sql, user)
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, user)
cur.close()
return SQLiteAuthMethods.get_user_by_id(user["id"]).todict()
@staticmethod
def get_all_users():
"""
Check if there are any users in the database.
"""
sql = "SELECT * FROM users"
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql)
data = cur.fetchall()
cur.close()
return [User(*user) for user in data]
@staticmethod
def get_user_by_username(username: str):
"""
Get a user by username.
"""
sql = "SELECT * FROM users WHERE username = ?"
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (username,))
data = cur.fetchone()
cur.close()
if data is not None:
return User(*data)
return None
@staticmethod
def get_user_by_id(userid: int):
"""
Get a user by id.
"""
sql = "SELECT * FROM users WHERE id = ?"
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (userid,))
data = cur.fetchone()
cur.close()
if data is not None:
return User(*data)
return None
@staticmethod
def delete_user_by_username(username: str):
"""
Delete a user by username.
"""
sql = "DELETE FROM users WHERE id = ?"
print("deleting user: ", username)
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (3,))
cur.close()
-121
View File
@@ -1,121 +0,0 @@
from datetime import datetime
from flask_jwt_extended import current_user
from app.models import FavType
from .utils import SQLiteManager
class SQLiteFavoriteMethods:
"""THis class contains methods for interacting with the favorites table."""
@classmethod
def check_is_favorite(cls, itemhash: str, fav_type: str):
"""
Checks if an item is favorited.
"""
userid = current_user["id"]
sql = """SELECT * FROM favorites WHERE hash = ? AND type = ? AND userid = ?"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (itemhash, fav_type, userid))
item = cur.fetchone()
cur.close()
return item is not None
@classmethod
def insert_one_favorite(cls, fav_type: str, fav_hash: str):
"""
Inserts a single favorite into the database.
"""
# try to find the favorite in the database, if it exists, don't insert it
if cls.check_is_favorite(fav_hash, fav_type):
return
sql = """INSERT INTO favorites(type, hash, timestamp, userid) VALUES(?,?,?,?)"""
current_timestamp = int(datetime.now().timestamp())
with SQLiteManager(userdata_db=True) as cur:
userid = current_user["id"]
cur.execute(sql, (fav_type, fav_hash, current_timestamp, userid))
cur.close()
@classmethod
def get_all(cls) -> list[tuple]:
"""
Returns a list of all favorites.
"""
sql = """SELECT * FROM favorites WHERE userid = ?"""
with SQLiteManager(userdata_db=True) as cur:
userid = current_user["id"]
cur.execute(sql, (userid,))
favs = cur.fetchall()
cur.close()
return [fav for fav in favs if fav[1] != ""]
@classmethod
def get_favorites(cls, fav_type: str, userid: int = None) -> list[tuple]:
"""
Returns a list of favorite tracks.
If userid is None, all favorites are returned.
"""
sql = """SELECT * FROM favorites WHERE type = ?"""
params = (fav_type,)
if not userid:
sql += " AND userid = ?"
params = (fav_type, userid)
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, params)
all_favs = cur.fetchall()
cur.close()
return all_favs
@classmethod
def get_fav_tracks(cls, userid: int = None) -> list[tuple]:
"""
Returns a list of favorite tracks.
"""
return cls.get_favorites(FavType.track, userid)
@classmethod
def get_fav_albums(cls) -> list[tuple]:
"""
Returns a list of favorite albums.
"""
userid = current_user["id"]
return cls.get_favorites(FavType.album, userid)
@classmethod
def get_fav_artists(cls) -> list[tuple]:
"""
Returns a list of favorite artists.
"""
userid = current_user["id"]
return cls.get_favorites(FavType.artist, userid)
@classmethod
def delete_favorite(cls, fav_type: str, fav_hash: str):
"""
Deletes a favorite from the database.
"""
sql = """DELETE FROM favorites WHERE hash = ? AND type = ? AND userid = ?"""
with SQLiteManager(userdata_db=True) as cur:
userid = current_user["id"]
cur.execute(sql, (fav_hash, fav_type, userid))
cur.close()
@classmethod
def get_track_count(cls) -> int:
"""
Returns the number of favorite tracks.
"""
sql = """SELECT COUNT(*) FROM favorites WHERE type = ? AND userid = ?"""
with SQLiteManager(userdata_db=True) as cur:
userid = current_user["id"]
cur.execute(sql, (FavType.track, userid))
count = cur.fetchone()[0]
cur.close()
return count
-62
View File
@@ -1,62 +0,0 @@
from app.models.lastfm import SimilarArtist
from ..utils import SQLiteManager
class SQLiteLastFMSimilarArtists:
"""
This class contains methods for interacting with the lastfm_similar_artists table.
"""
@classmethod
def insert_one(cls, artist: SimilarArtist):
"""
Inserts a single artist into the database.
"""
sql = """INSERT OR REPLACE INTO lastfm_similar_artists(artisthash, similar_artists) VALUES(?,?)"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (artist.artisthash, artist.similar_artist_hashes))
cur.close()
@classmethod
def get_similar_artists_for(cls, artisthash: str):
"""
Returns a list of similar artists.
"""
sql = """SELECT * FROM lastfm_similar_artists WHERE artisthash = ?"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (artisthash,))
similar_artists = cur.fetchone()
cur.close()
if similar_artists is None:
return None
return SimilarArtist(artisthash, similar_artists[2])
@classmethod
def get_all(cls):
"""
Returns a list of all similar artists.
"""
sql = """SELECT * FROM lastfm_similar_artists"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql)
similar_artists = cur.fetchall()
cur.close()
for a in similar_artists:
yield SimilarArtist(a[1], a[2])
@classmethod
def exists(cls, artisthash: str):
"""
Checks if an artist exists in the database by counting the number of rows
"""
sql = """SELECT COUNT(*) FROM lastfm_similar_artists WHERE artisthash = ?"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (artisthash,))
count = cur.fetchone()[0]
cur.close()
return count > 0
-56
View File
@@ -1,56 +0,0 @@
from flask_jwt_extended import current_user
from app.db.sqlite.utils import SQLiteManager
from app.models.logger import TrackLog as TrackLog
class SQLiteTrackLogger:
@classmethod
def insert_track(cls, trackhash: str, duration: int, source: str, timestamp: int):
"""
Inserts a track play record into the database
"""
with SQLiteManager(userdata_db=True) as cur:
sql = """INSERT OR REPLACE INTO track_logger(
trackhash,
duration,
timestamp,
source,
userid
) VALUES(?,?,?,?,?)
"""
cur.execute(
sql, (trackhash, duration, timestamp, source, current_user["id"])
)
lastrowid = cur.lastrowid
return lastrowid
@classmethod
def get_all(cls):
"""
Returns all track play records from the database
"""
with SQLiteManager(userdata_db=True) as cur:
sql = f"""SELECT * FROM track_logger WHERE userid = {current_user['id']} ORDER BY timestamp DESC"""
cur.execute(sql)
rows = cur.fetchall()
return rows
@classmethod
def get_recently_played(cls, start: int = 0, limit: int = 100):
"""
Returns a list of recently played tracks
"""
with SQLiteManager(userdata_db=True) as cur:
sql = f"""SELECT * FROM track_logger WHERE userid = {current_user['id']} ORDER BY timestamp DESC LIMIT ?,?"""
cur.execute(sql, (start, limit))
rows = cur.fetchall()
return [TrackLog(*row) for row in rows]
-202
View File
@@ -1,202 +0,0 @@
import json
from collections import OrderedDict
from flask_jwt_extended import current_user
from app.db.sqlite.utils import SQLiteManager, tuple_to_playlist, tuples_to_playlists
from app.utils.dates import create_new_date
class SQLitePlaylistMethods:
"""
This class contains methods for interacting with the playlists table.
"""
@staticmethod
def update_last_updated(playlist_id: int):
"""Updates the last updated date of a playlist."""
sql = """UPDATE playlists SET last_updated = ? WHERE id = ?"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (create_new_date(), playlist_id))
@staticmethod
def insert_one_playlist(playlist: dict):
# banner_pos,
# has_gif,
sql = """INSERT INTO playlists(
image,
last_updated,
name,
settings,
trackhashes,
userid
) VALUES(:image, :last_updated, :name, :settings, :trackhashes, :userid)
"""
playlist["userid"] = current_user["id"]
playlist = OrderedDict(sorted(playlist.items()))
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, playlist)
pid = cur.lastrowid
cur.close()
p_tuple = (pid, *playlist.values())
return tuple_to_playlist(p_tuple)
@staticmethod
def count_playlist_by_name(name: str):
sql = f"SELECT COUNT(*) FROM playlists WHERE name = ? and userid = {current_user['id']}"
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (name,))
data = cur.fetchone()
cur.close()
return int(data[0])
@staticmethod
def get_all_playlists():
with SQLiteManager(userdata_db=True) as cur:
cur.execute(f"SELECT * FROM playlists WHERE userid = {current_user['id']}")
playlists = cur.fetchall()
cur.close()
if playlists is not None:
return tuples_to_playlists(playlists)
return []
@staticmethod
def get_playlist_by_id(playlist_id: int):
sql = f"SELECT * FROM playlists WHERE id = ? and userid = {current_user['id']}"
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (playlist_id,))
data = cur.fetchone()
cur.close()
if data is not None:
return tuple_to_playlist(data)
return None
# FIXME: Extract the "add_track_to_playlist" method to use it for both the artisthash and trackhash lists.
@classmethod
def add_item_to_json_list(cls, playlist_id: int, field: str, items: set[str]):
"""
Adds a string item to a json dumped list using a playlist id and field name.
Takes the playlist ID, a field name, an item to add to the field.
"""
sql = f"SELECT {field} FROM playlists WHERE id = ? and userid = {current_user['id']}"
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (playlist_id,))
data = cur.fetchone()
if data is not None:
db_items: list[str] = json.loads(data[0])
# Remove duplicates, without changing the order.
for item in items:
if item in db_items:
items.remove(item)
db_items.extend(items)
sql = f"UPDATE playlists SET {field} = ? WHERE id = ?"
cur.execute(sql, (json.dumps(db_items), playlist_id))
return len(items)
cls.update_last_updated(playlist_id)
@classmethod
def add_tracks_to_playlist(cls, playlist_id: int, trackhashes: list[str]):
"""
Adds trackhashes to a playlist
"""
return cls.add_item_to_json_list(playlist_id, "trackhashes", trackhashes)
@classmethod
def update_playlist(cls, playlist_id: int, playlist: dict):
sql = f"""UPDATE playlists SET
image = ?,
last_updated = ?,
name = ?,
settings = ?
WHERE id = ? and userid = {current_user['id']}
"""
del playlist["id"]
del playlist["trackhashes"]
playlist["settings"] = json.dumps(playlist["settings"])
playlist = OrderedDict(sorted(playlist.items()))
params = (*playlist.values(), playlist_id)
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, params)
cls.update_last_updated(playlist_id)
@classmethod
def update_settings(cls, playlist_id: int, settings: dict):
sql = f"""UPDATE playlists SET settings = ? WHERE id = ? and userid = {current_user['id']}"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (json.dumps(settings), playlist_id))
cls.update_last_updated(playlist_id)
@staticmethod
def delete_playlist(pid: str):
sql = f"DELETE FROM playlists WHERE id = ? and userid = {current_user['id']}"
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (pid,))
@staticmethod
def remove_banner(playlistid: int):
sql = f"""UPDATE playlists SET image = NULL WHERE id = ? and userid = {current_user['id']}"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (playlistid,))
@classmethod
def remove_tracks_from_playlist(cls, playlistid: int, tracks: list[dict[str, int]]):
"""
Removes tracks from a playlist by trackhash and position.
"""
sql = """UPDATE playlists SET trackhashes = ? WHERE id = ?"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(
f"SELECT trackhashes FROM playlists WHERE id = ? and userid = {current_user['id']}",
(playlistid,),
)
data = cur.fetchone()
if data is None:
return
trackhashes: list[str] = json.loads(data[0])
for track in tracks:
# {
# trackhash: str;
# index: int;
# }
index = trackhashes.index(track["trackhash"])
if index == track["index"]:
trackhashes.remove(track["trackhash"])
cur.execute(sql, (json.dumps(trackhashes), playlistid))
cls.update_last_updated(playlistid)
-93
View File
@@ -1,93 +0,0 @@
import json
from app.models.plugins import Plugin
from ..utils import SQLiteManager
def plugin_tuple_to_obj(plugin_tuple: tuple) -> Plugin:
return Plugin(
name=plugin_tuple[1],
description=plugin_tuple[2],
active=bool(plugin_tuple[3]),
settings=json.loads(plugin_tuple[4]),
)
class PluginsMethods:
@classmethod
def insert_plugin(cls, plugin: Plugin):
"""
Inserts one plugin into the database
"""
sql = """INSERT OR IGNORE INTO plugins(
name,
description,
active,
settings
) VALUES(?,?,?,?)
"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(
sql,
(
plugin.name,
plugin.description,
int(plugin.active),
json.dumps(plugin.settings),
),
)
lastrowid = cur.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
def get_all_plugins(cls):
with SQLiteManager(userdata_db=True) as cur:
cur.execute("SELECT * FROM plugins")
plugins = cur.fetchall()
cur.close()
if plugins is not None:
return [plugin_tuple_to_obj(plugin) for plugin in plugins]
return []
@classmethod
def plugin_set_active(cls, name: str, active: int):
with SQLiteManager(userdata_db=True) as cur:
cur.execute("UPDATE plugins SET active=? WHERE name=?", (active, name))
cur.close()
@classmethod
def update_plugin_settings(cls, plugin_name: str, settings: dict):
with SQLiteManager(userdata_db=True) as cur:
cur.execute(
"UPDATE plugins SET settings=? WHERE name=?",
(json.dumps(settings), plugin_name),
)
cur.close()
@classmethod
def get_plugin_by_name(cls, name: str):
with SQLiteManager(userdata_db=True) as cur:
cur.execute("SELECT * FROM plugins WHERE name=?", (name,))
plugin = cur.fetchone()
cur.close()
if plugin is not None:
return plugin_tuple_to_obj(plugin)
return None
-130
View File
@@ -1,130 +0,0 @@
"""
This file contains the SQL queries to create the database tables.
"""
CREATE_USERDATA_TABLES = """
CREATE TABLE IF NOT EXISTS playlists (
id integer PRIMARY KEY,
image text,
last_updated text not null,
name text not null,
settings text,
trackhashes text,
userid integer not null,
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 (
id integer PRIMARY KEY,
root_dirs text NOT NULL,
exclude_dirs text,
artist_separators text NOT NULL default '/,;',
extract_feat integer NOT NULL DEFAULT 1,
remove_prod integer NOT NULL DEFAULT 1,
clean_album_title integer NOT NULL DEFAULT 1,
remove_remaster integer NOT NULL DEFAULT 1,
merge_albums integer NOT NULL DEFAULT 0,
show_albums_as_singles integer NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS lastfm_similar_artists (
id integer PRIMARY KEY,
artisthash text NOT NULL,
similar_artists text NOT NULL,
UNIQUE (artisthash)
);
CREATE TABLE IF NOT EXISTS plugins (
id integer PRIMARY KEY,
name text NOT NULL UNIQUE,
description text NOT NULL,
active integer NOT NULL DEFAULT 0,
settings text
);
CREATE TABLE IF NOT EXISTS track_logger (
id integer PRIMARY KEY,
trackhash text NOT NULL,
duration integer NOT NULL,
timestamp integer NOT NULL,
source text,
userid integer NOT NULL DEFAULT 1,
constraint fk_users foreign key (userid) references users(id) on delete cascade
);
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY,
username text NOT NULL UNIQUE,
firstname text,
lastname text,
password text NOT NULL,
email text,
image text,
roles text NOT NULL DEFAULT '["user"]'
)
"""
CREATE_APPDB_TABLES = """
CREATE TABLE IF NOT EXISTS tracks (
id integer PRIMARY KEY,
album text NOT NULL,
albumartist text NOT NULL,
albumhash text NOT NULL,
artist text NOT NULL,
bitrate integer NOT NULL,
copyright text,
date integer NOT NULL,
disc integer NOT NULL,
duration integer NOT NULL,
filepath text NOT NULL,
folder text NOT NULL,
genre text,
title text NOT NULL,
track integer NOT NULL,
trackhash text NOT NULL,
last_mod float NOT NULL,
UNIQUE (filepath)
);
CREATE TABLE IF NOT EXISTS albums (
id integer PRIMARY KEY,
albumhash text NOT NULL,
colors text NOT NULL,
UNIQUE (albumhash)
);
CREATE TABLE IF NOT EXISTS artists (
id integer PRIMARY KEY,
artisthash text NOT NULL,
colors text,
bio text,
UNIQUE (artisthash)
);
CREATE TABLE IF NOT EXISTS folders (
id integer PRIMARY KEY,
path text NOT NULL,
trackcount integer NOT NULL
);
"""
# changed from migrations to dbmigrations in v1.3.0
# to avoid conflicts with the previous migrations.
CREATE_MIGRATIONS_TABLE = """
CREATE TABLE IF NOT EXISTS dbmigrations (
id integer PRIMARY KEY,
version integer NOT NULL DEFAULT 0
);
INSERT INTO dbmigrations (version)
SELECT -1
WHERE NOT EXISTS (SELECT 1 FROM dbmigrations);
"""
-150
View File
@@ -1,150 +0,0 @@
from pprint import pprint
from typing import Any
from app.db.sqlite.utils import SQLiteManager
from app.settings import SessionVars
from app.utils.wintools import win_replace_slash
class SettingsSQLMethods:
"""
Methods for interacting with the settings table.
"""
@staticmethod
def get_all_settings():
"""
Gets all settings from the database.
"""
sql = "SELECT * FROM settings WHERE id = 1"
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql)
settings = cur.fetchone()
cur.close()
# if root_dirs not set
if settings is None:
return []
# omit id, root_dirs, and exclude_dirs
return settings[3:]
@staticmethod
def get_root_dirs() -> list[str]:
"""
Gets custom root directories from the database.
"""
sql = "SELECT root_dirs FROM settings"
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql)
dirs = cur.fetchall()
cur.close()
dirs = [_dir[0] for _dir in dirs]
return [win_replace_slash(d) for d in dirs]
@staticmethod
def add_root_dirs(dirs: list[str]):
"""
Add custom root directories to the database.
"""
sql = "INSERT INTO settings (root_dirs) VALUES (?)"
existing_dirs = SettingsSQLMethods.get_root_dirs()
dirs = [_dir for _dir in dirs if _dir not in existing_dirs]
if len(dirs) == 0:
return
with SQLiteManager(userdata_db=True) as cur:
for _dir in dirs:
cur.execute(sql, (_dir,))
@staticmethod
def remove_root_dirs(dirs: list[str]):
"""
Remove custom root directories from the database.
"""
sql = "DELETE FROM settings WHERE root_dirs = ?"
with SQLiteManager(userdata_db=True) as cur:
for _dir in dirs:
cur.execute(sql, (_dir,))
# Not currently used anywhere, to be used later
@staticmethod
def add_excluded_dirs(dirs: list[str]):
"""
Add custom exclude directories to the database.
"""
sql = "INSERT INTO settings (exclude_dirs) VALUES (?)"
with SQLiteManager(userdata_db=True) as cur:
cur.executemany(sql, dirs)
@staticmethod
def remove_excluded_dirs(dirs: list[str]):
"""
Remove custom exclude directories from the database.
"""
sql = "DELETE FROM settings WHERE exclude_dirs = ?"
with SQLiteManager(userdata_db=True) as cur:
cur.executemany(sql, dirs)
@staticmethod
def get_excluded_dirs() -> list[str]:
"""
Gets custom exclude directories from the database.
"""
sql = "SELECT exclude_dirs FROM settings"
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql)
dirs = cur.fetchall()
return [_dir[0] for _dir in dirs]
@staticmethod
def get_settings() -> dict[str, Any]:
pass
@staticmethod
def set_setting(key: str, value: Any):
sql = f"UPDATE settings SET {key} = :value WHERE id = 1"
if type(value) == bool:
value = str(int(value))
with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, {"value": value})
def load_settings():
s = SettingsSQLMethods.get_all_settings()
try:
db_separators: str = s[0]
db_separators = db_separators.replace(" ", "")
separators = db_separators.split(",")
separators = set(separators)
except IndexError:
separators = {";", "/"}
SessionVars.ARTIST_SEPARATORS = separators
# boolean settings
SessionVars.EXTRACT_FEAT = bool(s[1])
SessionVars.REMOVE_PROD = bool(s[2])
SessionVars.CLEAN_ALBUM_TITLE = bool(s[3])
SessionVars.REMOVE_REMASTER_FROM_TRACK = bool(s[4])
SessionVars.MERGE_ALBUM_VERSIONS = bool(s[5])
SessionVars.SHOW_ALBUMS_AS_SINGLES = bool(s[6])
-121
View File
@@ -1,121 +0,0 @@
"""
Contains the SQLiteTrackMethods class which contains methods for
interacting with the tracks table.
"""
from collections import OrderedDict
from sqlite3 import Cursor
from app.db.sqlite.utils import tuple_to_track, tuples_to_tracks
from .utils import SQLiteManager
from app.utils.unicode import handle_unicode
class SQLiteTrackMethods:
"""
This class contains all methods for interacting with the tracks table.
"""
@classmethod
def insert_one_track(cls, track: dict, cur: Cursor):
"""
Inserts a single track into the database.
"""
sql = """INSERT OR REPLACE INTO tracks(
album,
albumartist,
albumhash,
artist,
bitrate,
copyright,
date,
disc,
duration,
filepath,
folder,
genre,
last_mod,
title,
track,
trackhash
) VALUES(:album, :albumartist, :albumhash, :artist, :bitrate, :copyright,
:date, :disc, :duration, :filepath, :folder, :genre, :last_mod, :title, :track, :trackhash)
"""
track = OrderedDict(sorted(track.items()))
track["artist"] = track["artists"]
track["albumartist"] = track["albumartists"]
del track["artists"]
del track["albumartists"]
try:
cur.execute(sql, track)
except UnicodeEncodeError:
# for each of the values in the track, call handle_unicode on it
for key, value in track.items():
track[key] = handle_unicode(value)
cur.execute(sql, track)
@classmethod
def insert_many_tracks(cls, tracks: list[dict]):
"""
Inserts a list of tracks into the database.
"""
with SQLiteManager() as cur:
for track in tracks:
cls.insert_one_track(track, cur)
@staticmethod
def get_all_tracks():
"""
Get all tracks from the database and return a generator of Track objects
or an empty list.
"""
with SQLiteManager() as cur:
cur.execute("SELECT * FROM tracks")
rows = cur.fetchall()
if rows is not None:
return tuples_to_tracks(rows)
return []
@staticmethod
def get_track_by_trackhash(trackhash: str):
"""
Gets a track using its trackhash. Returns a Track object or None.
"""
with SQLiteManager() as cur:
cur.execute("SELECT * FROM tracks WHERE trackhash=?", (trackhash,))
row = cur.fetchone()
if row is not None:
return tuple_to_track(row)
return None
@staticmethod
def remove_tracks_by_filepaths(filepaths: str | set[str]):
"""
Removes a track or tracks from the database using their filepaths.
"""
if isinstance(filepaths, str):
filepaths = {filepaths}
with SQLiteManager() as cur:
for filepath in filepaths:
cur.execute("DELETE FROM tracks WHERE filepath=?", (filepath,))
@staticmethod
def remove_tracks_not_in_folders(folders: set[str]):
sql = "DELETE FROM tracks WHERE folder NOT IN ({})".format(
",".join("?" * len(folders))
)
with SQLiteManager() as cur:
cur.execute(sql, tuple(folders))
+2 -2
View File
@@ -90,10 +90,10 @@ class SQLiteManager:
if self.test_db_path:
db_path = self.test_db_path
else:
db_path = settings.Db.get_app_db_path()
db_path = settings.DbPaths.get_app_db_path()
if self.userdata_db:
db_path = settings.Db.get_userdata_db_path()
db_path = settings.DbPaths.get_userdata_db_path()
self.conn = sqlite3.connect(
db_path,
+427
View File
@@ -0,0 +1,427 @@
import datetime
from typing import Any, Literal
from sqlalchemy import (
JSON,
Boolean,
ForeignKey,
Integer,
String,
and_,
delete,
insert,
select,
update,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.engine import DbEngine
from app.db.utils import (
favorites_to_dataclass,
playlist_to_dataclass,
playlists_to_dataclasses,
plugin_to_dataclass,
plugin_to_dataclasses,
similar_artist_to_dataclass,
similar_artists_to_dataclass,
tracklog_to_dataclasses,
user_to_dataclass,
user_to_dataclasses,
)
from app.db import Base
from app.utils.auth import get_current_userid, 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 DbEngine.manager() 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 DbEngine.manager() 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 DbEngine.manager(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())
@classmethod
def activate(cls, name: str, value: bool):
return cls.execute(
update(cls).where(cls.name == name).values(active=value), commit=True
)
@classmethod
def get_by_name(cls, name: str):
result = cls.execute(select(cls).where(cls.name == name))
return plugin_to_dataclass(result.fetchone())
@classmethod
def update_settings(cls, name: str, settings: dict[str, Any]):
return cls.execute(
update(cls).where(cls.name == name).values(settings=settings), commit=True
)
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 DbEngine.manager() 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 DbEngine.manager() 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 DbEngine.manager() 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(), unique=True)
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", ondelete="cascade"), default=1, index=True
)
extra: Mapped[dict[str, Any]] = mapped_column(
JSON(), nullable=True, default_factory=dict
)
@classmethod
def get_all(cls):
with DbEngine.manager() 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"] = get_current_userid()
with DbEngine.manager(commit=True) as conn:
conn.execute(insert(cls).values(item))
@classmethod
def remove_item(cls, item: dict[str, Any]):
with DbEngine.manager(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, type: str, start: int, limit: int):
result = cls.execute(
select(cls)
# .select_from(join(table, cls, field == cls.hash))
.where(and_(cls.type == type, cls.userid == get_current_userid())).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:
# if limit == -1, return all
if limit == -1:
limit = len(res)
return res[:limit], len(res)
return res, -1
@classmethod
def get_fav_tracks(cls, start: int, limit: int):
result, total = cls.get_all_of_type("track", start, limit)
return favorites_to_dataclass(result), total
@classmethod
def get_fav_albums(cls, start: int, limit: int):
result, total = cls.get_all_of_type("album", start, limit)
return favorites_to_dataclass(result), total
@classmethod
def get_fav_artists(cls, start: int, limit: int):
result, total = cls.get_all_of_type("artist", start, limit)
return favorites_to_dataclass(result), total
class ScrobbleTable(Base):
__tablename__ = "scrobble"
id: Mapped[int] = mapped_column(primary_key=True)
trackhash: Mapped[str] = mapped_column(String(), index=True)
duration: Mapped[int] = mapped_column(Integer())
timestamp: Mapped[int] = mapped_column(Integer())
source: Mapped[str] = mapped_column(String())
userid: Mapped[int] = mapped_column(
Integer(), ForeignKey("user.id", ondelete="cascade"), index=True
)
extra: Mapped[dict[str, Any]] = mapped_column(
JSON(), nullable=True, default_factory=dict
)
@classmethod
def add(cls, item: dict[str, Any]):
item["userid"] = get_current_userid()
return cls.insert_one(item)
@classmethod
def get_all(cls, start: int, limit: int | None = None):
result = cls.execute(
select(cls)
.where(cls.userid == get_current_userid())
.order_by(cls.timestamp.desc())
.offset(start)
.limit(limit)
)
return tracklog_to_dataclasses(result.fetchall())
class PlaylistTable(Base):
__tablename__ = "playlist"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(), index=True)
last_updated: Mapped[int] = mapped_column(Integer())
image: Mapped[str] = mapped_column(String(), nullable=True)
userid: Mapped[int] = mapped_column(
Integer(), ForeignKey("user.id", ondelete="cascade")
)
settings: Mapped[dict[str, Any]] = mapped_column(JSON())
trackhashes: Mapped[list[str]] = mapped_column(JSON(), default_factory=list)
extra: Mapped[dict[str, Any]] = mapped_column(
JSON(), nullable=True, default_factory=dict
)
@classmethod
def get_all(cls):
result = cls.all()
return playlists_to_dataclasses(result)
@classmethod
def add_one(cls, playlist: dict[str, Any]):
playlist["userid"] = get_current_userid()
result = cls.insert_one(playlist)
return result.lastrowid
@classmethod
def check_exists_by_name(cls, name: str):
result = cls.execute(
select(cls).where((cls.name == name) & (cls.userid == get_current_userid()))
)
return result.fetchone() is not None
@classmethod
def append_to_playlist(cls, id: int, trackhashes: list[str]):
dbtrackhashes = cls.get_trackhashes(id)
if not dbtrackhashes:
dbtrackhashes = []
return cls.execute(
update(cls)
.where((cls.id == id) & (cls.userid == get_current_userid()))
.values(trackhashes=dbtrackhashes + trackhashes),
commit=True,
)
@classmethod
def get_trackhashes(cls, id: int):
result = cls.execute(
select(cls.trackhashes).where(
(cls.id == id) & (cls.userid == get_current_userid())
)
)
result = result.fetchone()
if result:
return result[0]
@classmethod
def remove_from_playlist(cls, id: int, trackhashes: list[dict[str, Any]]):
# INFO: Get db trackhashes
dbtrackhashes = cls.get_trackhashes(id)
if dbtrackhashes:
for item in trackhashes:
if dbtrackhashes.index(item["trackhash"]) == item["index"]:
dbtrackhashes.remove(item["trackhash"])
return cls.execute(
update(cls)
.where((cls.id == id) & (cls.userid == get_current_userid()))
.values(trackhashes=dbtrackhashes),
commit=True,
)
@classmethod
def get_by_id(cls, id: int):
result = cls.execute(
select(cls).where((cls.id == id) & (cls.userid == get_current_userid()))
)
result = result.fetchone()
if result:
return playlist_to_dataclass(result)
@classmethod
def update_one(cls, id: int, playlist: dict[str, Any]):
return cls.execute(
update(cls)
.where((cls.id == id) & (cls.userid == get_current_userid()))
.values(playlist),
commit=True,
)
@classmethod
def update_settings(cls, id: int, settings: dict[str, Any]):
return cls.execute(
update(cls)
.where((cls.id == id) & (cls.userid == get_current_userid()))
.values(settings=settings),
commit=True,
)
@classmethod
def remove_image(cls, id: int):
return cls.execute(
update(cls)
.where((cls.id == id) & (cls.userid == get_current_userid()))
.values(image=None),
commit=True,
)
class LibDataTable(Base):
__tablename__ = "artistdata"
id: Mapped[int] = mapped_column(primary_key=True)
itemhash: Mapped[str] = mapped_column(String(), unique=True, index=True)
itemtype: Mapped[str] = mapped_column(String())
color: Mapped[str] = mapped_column(String(), nullable=True)
bio: Mapped[str] = mapped_column(String(), nullable=True)
info: Mapped[dict[str, Any]] = mapped_column(JSON(), nullable=True)
extra: Mapped[dict[str, Any]] = mapped_column(
JSON(), nullable=True, default_factory=dict
)
@classmethod
def update_one(cls, hash: str, data: dict[str, Any]):
return cls.execute(
update(cls).where(cls.itemhash == hash).values(data), commit=True
)
@classmethod
def find_one(cls, hash: str, type: Literal["album", "artist"]):
result = cls.execute(
select(cls).where((cls.itemhash == hash) & (cls.itemtype == type))
)
return result.fetchone()
@classmethod
def get_all_colors(cls, type: str) -> list[dict[str, str]]:
result = cls.execute(
select(cls.itemhash, cls.color).where(cls.itemtype == type)
)
return [{"itemhash": r[0], "color": r[1]} for r in result.fetchall()]
+96
View File
@@ -0,0 +1,96 @@
from typing import Any
from app.config import UserConfig
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.logger import TrackLog
from app.models.playlist import Playlist
from app.models.plugins import Plugin
from app.models.user import User
def track_to_dataclass(track: Any, config: UserConfig):
return TrackModel(**track._asdict(), config=config)
def tracks_to_dataclasses(tracks: Any):
return [track_to_dataclass(track, UserConfig()) 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]
def tracklog_to_dataclass(entry: Any):
entry_dict = entry._asdict()
return TrackLog(**entry_dict)
def tracklog_to_dataclasses(entries: Any):
return [tracklog_to_dataclass(entry) for entry in entries]
def playlist_to_dataclass(entry: Any):
entry_dict = entry._asdict()
return Playlist(**entry_dict)
def playlists_to_dataclasses(entries: Any):
return [playlist_to_dataclass(entry) for entry in entries]
+6 -56
View File
@@ -2,73 +2,23 @@
Contains methods relating to albums.
"""
from dataclasses import asdict
from typing import Any
from itertools import groupby
from app.models.track import Track
from app.store.albums import AlbumStore
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]):
"""
Removes duplicate tracks when merging versions of the same album.
"""
# TODO!
pass
def sort_by_track_no(tracks: list[Track]) -> list[dict[str, Any]]:
tracks = [asdict(t) for t in tracks]
def sort_by_track_no(tracks: list[Track]):
for t in tracks:
track = str(t["track"]).zfill(3)
t["_pos"] = int(f"{t['disc']}{track}")
track = str(t.track).zfill(3)
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
+13 -137
View File
@@ -1,5 +1,3 @@
from collections import namedtuple
from itertools import groupby
import os
import urllib
from concurrent.futures import ThreadPoolExecutor
@@ -12,9 +10,10 @@ from requests.exceptions import ConnectionError as RequestConnectionError
from requests.exceptions import ReadTimeout
from app import settings
from app.models import Album, Artist, Track
from app.store import artists as artist_store
from app.store.tracks import TrackStore
from app.db.libdata import ArtistTable
# from app.store import artists as artist_store
# from app.store.tracks import TrackStore
from app.utils.hashing import create_hash
from app.utils.progressbar import tqdm
@@ -107,22 +106,15 @@ class CheckArtistImages:
# read all files in the artist image folder
path = settings.Paths.get_sm_artist_img_path()
processed = "".join(os.listdir(path)).replace("webp", "")
# filter out artists that already have an image
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)
processed = [path.replace(".webp", "") for path in os.listdir(path)]
unprocessed = ArtistTable.get_artisthashes_not_in(processed)
key_artist_map = ((instance_key, artist) for artist in unprocessed)
with ThreadPoolExecutor(max_workers=14) as executor:
res = list(
tqdm(
executor.map(self.download_image, key_artist_map),
total=len(artists),
total=len(unprocessed),
desc="Downloading missing artist images",
)
)
@@ -130,7 +122,7 @@ class CheckArtistImages:
list(res)
@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.
@@ -142,130 +134,14 @@ class CheckArtistImages:
return
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():
return
url = get_artist_image_link(artist.name)
url = get_artist_image_link(artist["name"])
if url is not None:
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. """
# last_fm_url = "http://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key={}&artist={}&album={
# }&format=json".format( settings.Paths.LAST_FM_API_KEY, albumartist, title )
# try:
# response = requests.get(last_fm_url)
# data = response.json()
# except:
# return None
# try:
# bio = data["album"]["wiki"]["summary"].split('<a href="https://www.last.fm/')[0]
# except KeyError:
# bio = None
# return bio
# class FetchAlbumBio:
# """
# Returns the album bio for a given album.
# """
# def __init__(self, title: str, albumartist: str):
# self.title = title
# self.albumartist = albumartist
# def __call__(self):
# 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
return DownloadImage(url, name=f"{artist['artisthash']}.webp")
+52 -52
View File
@@ -2,20 +2,17 @@
Contains everything that deals with image color extraction.
"""
import json
from pathlib import Path
import colorgram
from app import settings
from app.db.sqlite.albumcolors import SQLiteAlbumMethods as aldb
from app.db.sqlite.artistcolors import SQLiteArtistMethods as adb
from app.db.sqlite.utils import SQLiteManager
from app.store.artists import ArtistStore
from app.store.albums import AlbumStore
from app.db.userdata import LibDataTable
from app.logger import log
from app.lib.errors import PopulateCancelledError
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
from app.utils.progressbar import tqdm
PROCESS_ALBUM_COLORS_KEY = ""
@@ -61,38 +58,36 @@ class ProcessAlbumColors:
global PROCESS_ALBUM_COLORS_KEY
PROCESS_ALBUM_COLORS_KEY = instance_key
albums = [
a
for a in AlbumStore.albums
if a is not None and a.colors is not None and len(a.colors) == 0
]
albums = [a for a in AlbumStore.get_flat_list() if not a.color]
with SQLiteManager() as cur:
try:
for album in tqdm(albums, desc="Processing missing album colors"):
if PROCESS_ALBUM_COLORS_KEY != instance_key:
raise PopulateCancelledError(
"A newer 'ProcessAlbumColors' instance is running. Stopping this one."
)
for album in tqdm(albums, desc="Processing missing album colors"):
albumhash = album.albumhash
if PROCESS_ALBUM_COLORS_KEY != instance_key:
raise PopulateCancelledError(
"A newer 'ProcessAlbumColors' instance is running. Stopping this one."
)
# TODO: Stop hitting the database for every album.
# Instead, fetch all the data from the database and
# check from memory.
albumrecord = LibDataTable.find_one(albumhash, type="album")
if albumrecord is not None and albumrecord.color is not None:
continue
exists = aldb.exists(album.albumhash, cur=cur)
if exists:
continue
colors = process_color(albumhash)
colors = process_color(album.albumhash)
if colors is None:
continue
if colors is None:
continue
album = AlbumStore.albummap.get(albumhash)
album.set_colors(colors)
color_str = json.dumps(colors)
aldb.insert_one_album(cur, album.albumhash, color_str)
finally:
cur.close()
if album:
album.set_color(colors[0])
# INFO: Write to the database.
if albumrecord is None:
LibDataTable.insert_one(
{"itemhash": albumhash, "color": colors[0], "itemtype": "album"}
)
else:
LibDataTable.update_one(albumhash, {"color": colors[0]})
class ProcessArtistColors:
@@ -101,32 +96,37 @@ class ProcessArtistColors:
"""
def __init__(self, instance_key: str) -> None:
all_artists = [a for a in ArtistStore.artists if len(a.colors) == 0]
all_artists = [a for a in ArtistStore.get_flat_list() if not a.color]
global PROCESS_ARTIST_COLORS_KEY
PROCESS_ARTIST_COLORS_KEY = instance_key
with SQLiteManager() as cur:
try:
for artist in tqdm(
all_artists, desc="Processing missing artist colors"
):
if PROCESS_ARTIST_COLORS_KEY != instance_key:
raise PopulateCancelledError(
"A newer 'ProcessArtistColors' instance is running. Stopping this one."
)
for artist in tqdm(all_artists, desc="Processing missing artist colors"):
artisthash = artist.artisthash
if PROCESS_ARTIST_COLORS_KEY != instance_key:
raise PopulateCancelledError(
"A newer 'ProcessArtistColors' instance is running. Stopping this one."
)
exists = adb.exists(artist.artisthash, cur=cur)
record = LibDataTable.find_one(artisthash, "artist")
if exists:
continue
if (record is not None) and (record.color is not None):
continue
colors = process_color(artist.artisthash, is_album=False)
colors = process_color(artisthash, is_album=False)
if colors is None:
continue
if colors is None:
continue
artist.set_colors(colors)
adb.insert_one_artist(cur, artist.artisthash, colors)
finally:
cur.close()
artist = ArtistStore.artistmap.get(artisthash)
if artist:
artist.set_color(colors[0])
# INFO: Write to the database.
if record is None:
LibDataTable.insert_one(
{"itemhash": artisthash, "color": colors[0], "itemtype": "artist"}
)
else:
LibDataTable.update_one(artisthash, {"color": colors[0]})
+37
View File
@@ -0,0 +1,37 @@
from typing import Any
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
from app.store.tracks import TrackStore
def get_extra_info(hash: str, type: str):
"""
Generates extra info for a track, album or artist, which will be stored
in the database (in favorites, playlists and scrobble data) for backup and restore.
The extra info contains all the fields needed to reconstruct the itemhash. The track contains an additional filepath field which can be used to locate the file when restoring.
"""
extra: dict[str, Any] = {}
if type == "track":
trackentry = TrackStore.trackhashmap.get(hash)
if trackentry is not None:
track = trackentry.get_best()
extra["filepath"] = track.filepath
extra["title"] = track.title
extra["artists"] = [a["name"] for a in track.artists]
extra["album"] = track.albumhash
elif type == "album":
album = AlbumStore.get_album_by_hash(hash)
if album is not None:
extra["albumartists"] = [a["name"] for a in album.albumartists]
extra["title"] = album.title
elif type == "artist":
artist = ArtistStore.get_artist_by_hash(hash)
if artist is not None:
extra["name"] = artist.name
return extra
+93 -100
View File
@@ -1,16 +1,18 @@
import os
from pathlib import Path
from app.lib.sortlib import sort_folders, sort_tracks
from app.logger import log
from app.models import Folder, Track
from app.models import Folder
from app.serializers.track import serialize_tracks
from app.settings import SUPPORTED_FILES
from app.store.folder import FolderStore
from app.utils.wintools import win_replace_slash
from app.store.tracks import TrackStore
# 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) -> Folder:
"""
Creates a folder object from a path.
"""
@@ -21,7 +23,6 @@ def create_folder(path: str, trackcount=0, foldercount=0) -> Folder:
path=win_replace_slash(str(folder)) + "/",
is_sym=folder.is_symlink(),
trackcount=trackcount,
foldercount=foldercount,
)
@@ -37,117 +38,109 @@ def get_first_child_from_path(root: str, maybe_child: str):
return os.path.join(root, first)
def get_folders(paths: list[str]):
"""
Filters out folders that don't have any tracks and
returns a list of folder objects.
"""
count_dict = {
"tracks": {path: 0 for path in paths},
# folders are immediate children of the root folder
"folders": {path: set() for path in paths},
}
for track in TrackStore.tracks:
for path in paths:
# a child path should be longer than the root path
if len(track.folder) >= len(path) and track.folder.startswith(path):
count_dict["tracks"][path] += 1
# counting subfolders
p = get_first_child_from_path(path, track.folder)
if p:
count_dict["folders"][path].add(p)
folders = [
{
"path": path,
"trackcount": count_dict["tracks"][path],
"foldercount": len(count_dict["folders"][path]),
}
for path in paths
]
folders = FolderStore.count_tracks_containing_paths(paths)
return [
create_folder(f["path"], f["trackcount"], f["foldercount"])
create_folder(f["path"], f["trackcount"])
for f in folders
if f["trackcount"] > 0
]
class GetFilesAndDirs:
def get_files_and_dirs(
path: str,
start: int,
limit: int,
tracksortby: str,
foldersortby: str,
tracksort_reverse: bool,
foldersort_reverse: bool,
tracks_only: bool = False,
skip_empty_folders=True,
):
"""
Get files and folders from a directory.
Given a path, returns a list of tracks and folders in that immediate path.
Can recursively call itself to skip through empty folders.
"""
def __init__(self, path: str, tracks_only=False) -> None:
self.path = path
self.tracks_only = tracks_only
def get_files_and_dirs(self, path: str, skip_empty_folders=True):
"""
Given a path, returns a list of tracks and folders in that immediate path.
Can recursively call itself to skip through empty folders.
"""
try:
entries = os.scandir(path)
except FileNotFoundError:
return {
"path": path,
"tracks": [],
"folders": [],
}
dirs, files = [], []
for entry in entries:
ext = os.path.splitext(entry.name)[1].lower()
if entry.is_dir() and not entry.name.startswith("."):
dir = win_replace_slash(entry.path)
# add a trailing slash to the folder path
# to avoid matching a folder starting with the same name as the root path
# eg. .../Music and .../Music VideosI
dirs.append(os.path.join(dir, ""))
elif entry.is_file() and ext in SUPPORTED_FILES:
files.append(win_replace_slash(entry.path))
files_ = []
for file in files:
try:
files_.append(
{
"path": file,
"time": os.path.getmtime(file),
}
)
except OSError as e:
log.error(e)
files_.sort(key=lambda f: f["time"])
files = [f["path"] for f in files_]
tracks = TrackStore.get_tracks_by_filepaths(files)
folders = []
if not self.tracks_only:
folders = get_folders(dirs)
if skip_empty_folders and len(folders) == 1 and len(tracks) == 0:
# INFO: When we only have one folder and no tracks,
# skip through empty folders.
# Call recursively with the first folder in the list.
return self.get_files_and_dirs(folders[0].path)
try:
entries = os.scandir(path)
except FileNotFoundError:
return {
"path": path,
"tracks": serialize_tracks(tracks),
"folders": folders,
"tracks": [],
"folders": [],
}
def __call__(self):
return self.get_files_and_dirs(self.path)
dirs, files = [], []
for entry in entries:
ext = os.path.splitext(entry.name)[1].lower()
if entry.is_dir() and not entry.name.startswith("."):
dir = win_replace_slash(entry.path)
# add a trailing slash to the folder path
# to avoid matching a folder starting with the same name as the root path
# eg. .../Music and .../Music VideosI
dirs.append(os.path.join(dir, ""))
elif entry.is_file() and ext in SUPPORTED_FILES:
files.append(win_replace_slash(entry.path))
files_ = []
for file in files:
try:
files_.append(
{
"path": file,
"time": os.path.getmtime(file),
}
)
except OSError as e:
log.error(e)
files_.sort(key=lambda f: f["time"])
files = [f["path"] for f in files_]
tracks = []
if files:
if limit == -1:
limit = len(files)
tracks = list(FolderStore.get_tracks_by_filepaths(files))
tracks = sort_tracks(tracks, tracksortby, tracksort_reverse)
tracks = tracks[start : start + limit]
folders = []
if not tracks_only:
folders = get_folders(dirs)
folders = sort_folders(folders, foldersortby, foldersort_reverse)
if skip_empty_folders and len(folders) == 1 and len(tracks) == 0:
# INFO: When we only have one folder and no tracks,
# skip through empty folders.
# Call recursively with the first folder in the list.
return get_files_and_dirs(
folders[0].path,
start=start,
limit=limit,
tracksortby=tracksortby,
foldersortby=foldersortby,
tracksort_reverse=tracksort_reverse,
foldersort_reverse=foldersort_reverse,
tracks_only=tracks_only,
skip_empty_folders=True,
)
return {
"path": path,
"tracks": serialize_tracks(tracks),
"folders": folders,
"total": len(files),
}
+84 -60
View File
@@ -1,8 +1,10 @@
from datetime import datetime
from time import time
from app.lib.playlistlib import get_first_4_images
from app.models.playlist import Playlist
from app.models.track import Track
from app.store.tracks import TrackStore
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
@@ -13,7 +15,12 @@ from app.serializers.artist import serialize_for_card
from itertools import groupby
from app.utils.dates import create_new_date, date_string_to_time_passed, timestamp_to_time_passed
from app.utils import flatten
from app.utils.dates import (
create_new_date,
date_string_to_time_passed,
timestamp_to_time_passed,
)
older_albums = set()
older_artists = set()
@@ -36,7 +43,7 @@ def check_is_album_folder(tracks: list[Track]):
def check_is_artist_folder(tracks: list[Track]):
# INFO: flatten artist hashes using "-" as a separator
artisthashes = "-".join(t.artist_hashes for t in tracks).split("-")
artisthashes = flatten([t.artisthashes for t in tracks])
return calc_based_on_percent(artisthashes, len(tracks))
@@ -48,27 +55,22 @@ def check_is_track_folder(tracks: list[Track]):
return [create_track(t) for t in tracks]
def check_is_new_artist(artisthash: str, timestamp: int):
"""
Checks if an artist already exists in the library.
"""
tracks = filter(
lambda t: t.last_mod < timestamp and artisthash in t.artist_hashes,
TrackStore.tracks,
)
return next(tracks, None) is None
# def check_is_new_artist(hashes: set[str], artisthash: str, timestamp: int):
# """
# Checks if an artist already exists in the library.
# """
# return artisthash not in hashes
def check_is_new_album(albumhash: str, timestamp: int):
"""
Checks if an album already exists in the library.
"""
tracks = filter(
lambda t: t.last_mod < timestamp and t.albumhash == albumhash, TrackStore.tracks
)
# def check_is_new_album(albumhash: str, timestamp: int):
# """
# Checks if an album already exists in the library.
# """
# tracks = filter(
# lambda t: t.last_mod < timestamp and t.albumhash == albumhash, TrackStore.tracks
# )
return next(tracks, None) is None
# return next(tracks, None) is None
def create_track(t: Track):
@@ -85,15 +87,17 @@ def create_track(t: Track):
# INFO: Keys: folder, tracks, time (timestamp)
group_type = dict[str, list[Track], float]
# group_type = dict[str, str | list[Track] | float]
def check_folder_type(group_: group_type) -> str:
def check_folder_type(group_: dict):
# check if all tracks in group have the same albumhash
# if so, return "album"
key = group_["folder"]
tracks = group_["tracks"]
time = group_["time"]
key: str = group_["folder"]
tracks: list[Track] = group_["tracks"]
time: float = group_["time"]
existing_artist_hashes: set[str] = set(ArtistStore.artistmap.keys())
existing_album_hashes: set[str] = set(AlbumStore.albummap.keys())
if len(tracks) == 1:
entry = create_track(tracks[0])
@@ -102,13 +106,14 @@ def check_folder_type(group_: group_type) -> str:
is_album, albumhash, _ = check_is_album_folder(tracks)
if is_album:
album = AlbumStore.get_album_by_hash(albumhash)
# album = AlbumTable.get_album_by_albumhash(albumhash)
entry = AlbumStore.albummap.get(albumhash)
if album is None:
if entry is None:
return None
album = album_serializer(
album,
entry.album,
to_remove={
"genres",
"og_title",
@@ -120,7 +125,7 @@ def check_folder_type(group_: group_type) -> str:
},
)
album["help_text"] = (
"NEW ALBUM" if check_is_new_album(albumhash, time) else "NEW TRACKS"
"NEW ALBUM" if albumhash in existing_album_hashes else "NEW TRACKS"
)
album["time"] = timestamp_to_time_passed(time)
@@ -131,15 +136,15 @@ def check_folder_type(group_: group_type) -> str:
is_artist, artisthash, trackcount = check_is_artist_folder(tracks)
if is_artist:
artist = ArtistStore.get_artist_by_hash(artisthash)
entry = ArtistStore.artistmap.get(artisthash)
if artist is None:
if entry is None:
return None
artist = serialize_for_card(artist)
artist = serialize_for_card(entry.artist)
artist["trackcount"] = trackcount
artist["help_text"] = (
"NEW ARTIST" if check_is_new_artist(artisthash, time) else "NEW MUSIC"
"NEW ARTIST" if artisthash not in existing_artist_hashes else "NEW MUSIC"
)
artist["time"] = timestamp_to_time_passed(time)
@@ -165,40 +170,60 @@ def check_folder_type(group_: group_type) -> str:
)
def group_track_by_folders(tracks: Track):
def group_track_by_folders(tracks: list[Track], groups: dict[str, list[Track]]):
"""
Groups tracks by folder and returns a list of groups sorted by last modified date.
Uses generator expressions to avoid creating intermediate lists.
"""
# INFO: sort tracks by folder name, then group by folder name
tracks = sorted(tracks, key=lambda t: t.folder)
groups = groupby(tracks, lambda t: t.folder)
thisgroup = groupby(tracks, lambda t: t.folder)
# INFO: sort tracks by last modified date in descending order to get the most recent last modified date
groups = (
(folder, sorted(tracks, key=lambda t: t.last_mod, reverse=True))
for folder, tracks in groups
)
for folder, thistracks in thisgroup:
groups.setdefault(folder, []).extend(thistracks)
# INFO: Return a generator of the groups
groups = (
{"folder": folder, "tracks": list(tracks), "time": tracks[0].last_mod}
for folder, tracks in groups
)
# sort groups by last modified date
return sorted(groups, key=lambda group: group["time"], reverse=True)
return groups
def get_recently_added_items(limit: int = 7):
tracks = sorted(TrackStore.tracks, key=lambda t: t.created_date)
groups = group_track_by_folders(tracks)
# tracks = sorted(TrackStore.tracks, key=lambda t: t.created_date)
now = time()
tracks = get_recently_added_tracks(start=0, limit=None)
then = time()
print(f"Time taken to get tracks: {then - now}")
groups = group_track_by_folders(tracks, {})
# print(groups)
# last_trackcount: int = len(tracks)
# while len(groups.keys()) < limit and last_trackcount > 0:
# distracks = get_recently_added_tracks(start=len(tracks), limit=100)
# last_trackcount = len(distracks)
# tracks.extend(distracks)
# groups = group_track_by_folders(tracks, groups)
grouplist = []
# INFO: sort tracks by last modified date in descending order to get the most recent last modified date
for folder, trackgroup in groups.items():
trackgroup.sort(key=lambda t: t.last_mod, reverse=True)
grouplist.append(
{
"folder": folder,
"len": len(trackgroup),
"tracks": trackgroup,
"time": trackgroup[0].last_mod,
}
)
# sort groups by last modified date
grouplist = sorted(grouplist, key=lambda group: group["time"], reverse=True)
recent_items = []
for group in groups:
for group in grouplist:
item = check_folder_type(group)
if item not in recent_items:
@@ -217,7 +242,6 @@ def get_recently_added_items(limit: int = 7):
return recent_items
def get_recently_added_playlist(limit: int = 100):
playlist = Playlist(
id="recentlyadded",
@@ -232,18 +256,18 @@ def get_recently_added_playlist(limit: int = 100):
try:
# Create date to show as last updated
date = datetime.fromtimestamp(tracks[0].created_date)
date = datetime.fromtimestamp(tracks[0].last_mod)
except IndexError:
return playlist, []
playlist.last_updated = date_string_to_time_passed(create_new_date(date))
playlist._last_updated = date_string_to_time_passed(create_new_date(date))
images = get_first_4_images(tracks=tracks)
playlist.images = images
playlist.set_count(len(tracks))
playlist.duration = sum(t.duration for t in tracks)
playlist.count = len(tracks)
return playlist, tracks
def get_recently_added_tracks(limit: int):
tracks = sorted(TrackStore.tracks, key=lambda t: t.created_date, reverse=True)
return tracks[:limit]
def get_recently_added_tracks(start: int = 0, limit: int | None = 100):
return TrackStore.get_recently_added(start, limit)
+24 -31
View File
@@ -1,16 +1,17 @@
from datetime import datetime
import os
from app.models.logger import TrackLog
from app.db.sqlite.logger.tracks import SQLiteTrackLogger as db
from app.db.sqlite.playlists import SQLitePlaylistMethods as pdb
from app.db.sqlite.favorite import SQLiteFavoriteMethods as fdb
from app.db.userdata import FavoritesTable, PlaylistTable, ScrobbleTable
from app.models.playlist import Playlist
from app.serializers.track import serialize_track
from app.serializers.album import album_serializer
from app.lib.playlistlib import get_first_4_images
from app.utils.dates import create_new_date, date_string_to_time_passed, timestamp_to_time_passed
from app.store.folder import FolderStore
from app.utils.dates import (
create_new_date,
date_string_to_time_passed,
timestamp_to_time_passed,
)
from app.serializers.artist import serialize_for_card
from app.serializers.playlist import serialize_for_card as serialize_playlist
from app.lib.home.recentlyadded import get_recently_added_playlist
@@ -20,10 +21,9 @@ from app.store.tracks import TrackStore
from app.store.artists import ArtistStore
def get_recently_played(limit=7):
# TODO: Paginate this
entries = db.get_all()
entries = ScrobbleTable.get_all(0, 200)
items = []
added = set()
@@ -36,8 +36,6 @@ def get_recently_played(limit=7):
if len(items) >= limit:
break
entry = TrackLog(*entry)
if entry.source in added:
continue
@@ -107,13 +105,14 @@ def get_recently_played(limit=7):
# print(folder)
# folder = os.path.join("/", folder, "")
# print(folder)
count = len([t for t in TrackStore.tracks if t.folder == folder])
# count = len([t for t in TrackStore.tracks if t.folder == folder])
count = FolderStore.count_tracks_containing_paths([folder])
items.append(
{
"type": "folder",
"item": {
"path": folder,
"count": count,
"count": count[0]["trackcount"],
"help_text": "folder",
"time": timestamp_to_time_passed(entry.timestamp),
},
@@ -127,7 +126,9 @@ def get_recently_played(limit=7):
if is_custom:
playlist, _ = next(
i["handler"]() for i in custom_playlists if i["name"] == entry.type_src
i["handler"]()
for i in custom_playlists
if i["name"] == entry.type_src
)
playlist.images = [i["image"] for i in playlist.images]
@@ -146,7 +147,7 @@ def get_recently_played(limit=7):
)
continue
playlist = pdb.get_playlist_by_id(entry.type_src)
playlist = PlaylistTable.get_by_id(entry.type_src)
if playlist is None:
continue
@@ -175,19 +176,19 @@ def get_recently_played(limit=7):
"type": "favorite_tracks",
"item": {
"help_text": "playlist",
"count": fdb.get_track_count(),
"count": FavoritesTable.count(),
"time": timestamp_to_time_passed(entry.timestamp),
},
}
)
continue
try:
track = TrackStore.get_tracks_by_trackhashes([entry.trackhash])[0]
except IndexError:
t = TrackStore.trackhashmap.get(entry.trackhash)
if t is None:
continue
track = serialize_track(track)
track = serialize_track(t.get_best())
track["help_text"] = "track"
track["time"] = timestamp_to_time_passed(entry.timestamp)
@@ -201,12 +202,6 @@ def get_recently_played(limit=7):
return items
def get_recently_played_tracks(limit: int):
records = db.get_recently_played(start=0, limit=limit)
last_updated = records[0].timestamp
tracks = TrackStore.get_tracks_by_trackhashes([r.trackhash for r in records])
return tracks, last_updated
def get_recently_played_playlist(limit: int = 100):
playlist = Playlist(
id="recentlyplayed",
@@ -217,13 +212,11 @@ def get_recently_played_playlist(limit: int = 100):
trackhashes=[],
)
tracks, timestamp = get_recently_played_tracks(limit)
date = datetime.fromtimestamp(timestamp)
playlist.last_updated = date_string_to_time_passed(create_new_date(date))
tracks = TrackStore.get_recently_played(limit)
date = datetime.fromtimestamp(tracks[0].lastplayed)
playlist._last_updated = date_string_to_time_passed(create_new_date(date))
images = get_first_4_images(tracks=tracks)
playlist.images = images
playlist.set_count(len(tracks))
return playlist, tracks
return playlist, tracks
+41
View File
@@ -0,0 +1,41 @@
import gc
from time import time
from app.lib.mapstuff import (
map_album_colors,
map_artist_colors,
map_favorites,
map_scrobble_data,
)
from app.lib.populate import CordinateMedia
from app.lib.tagger import IndexTracks
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
from app.store.folder import FolderStore
from app.store.tracks import TrackStore
from app.utils.threading import background
class IndexEverything:
def __init__(self) -> None:
IndexTracks(instance_key=time())
key = str(time())
TrackStore.load_all_tracks(key)
AlbumStore.load_albums(key)
ArtistStore.load_artists(key)
FolderStore.load_filepaths()
# map colors
map_album_colors()
map_artist_colors()
map_scrobble_data()
map_favorites()
CordinateMedia(instance_key=str(time()))
gc.collect()
@background
def index_everything():
return IndexEverything()
+88
View File
@@ -0,0 +1,88 @@
from app.db.userdata import LibDataTable, FavoritesTable, ScrobbleTable
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
from app.store.tracks import TrackStore
from typing import Any
def map_scrobble_data():
"""
Maps scrobble data to the in-memory stores.
The scrobble data is loaded from the database and grouped by trackhash.
The album and artist scrobble data (for those tracks) are then incremented based on the data.
"""
records = ScrobbleTable.get_all(0, None)
# group records by trackhash
grouped: dict[str, dict[str, Any]] = {}
for record in records:
# aggregate playcount, playduration and lastplayed
item = grouped.setdefault(record.trackhash, {})
item["playcount"] = item.get("playcount", 0) + 1
item["playduration"] = item.get("playduration", 0) + record.duration
item["lastplayed"] = max(item.get("lastplayed", 0), record.timestamp)
# increment playcount, playduration and lastplayed for albums and artists
for trackhash, data in grouped.items():
track = TrackStore.trackhashmap.get(trackhash)
if track is None:
continue
track.increment_playcount(data["playduration"], data["lastplayed"])
album = AlbumStore.albummap.get(track.tracks[0].albumhash)
if album:
album.increment_playcount(data["playduration"], data["lastplayed"])
for artisthash in track.tracks[0].artisthashes:
artist = ArtistStore.artistmap.get(artisthash)
if artist:
artist.increment_playcount(data["playduration"], data["lastplayed"])
def map_favorites():
"""
Maps favorites data to the in-memory stores.
"""
favorites = FavoritesTable.get_all()
for entry in favorites:
if entry.type == "album":
album = AlbumStore.albummap.get(entry.hash)
if album:
album.toggle_favorite_user(entry.userid)
elif entry.type == "artist":
artist = ArtistStore.artistmap.get(entry.hash)
if artist:
artist.toggle_favorite_user(entry.userid)
elif entry.type == "track":
track = TrackStore.trackhashmap.get(entry.hash)
if track:
track.toggle_favorite_user(entry.userid)
def map_artist_colors():
colors = LibDataTable.get_all_colors(type="artist")
for color in colors:
artist = ArtistStore.artistmap.get(color["itemhash"])
if artist:
artist.set_color(color["color"])
def map_album_colors():
colors = LibDataTable.get_all_colors(type="album")
for color in colors:
album = AlbumStore.albummap.get(color["itemhash"])
if album:
album.set_color(color["color"])
+8 -1
View File
@@ -10,6 +10,7 @@ from typing import Any
from PIL import Image, ImageSequence
from app import settings
from app.db.libdata import TrackTable
from app.models.track import Track
from app.store.albums import AlbumStore
from app.store.tracks import TrackStore
@@ -104,6 +105,12 @@ def duplicate_images(images: list):
def get_first_4_images(
tracks: list[Track] = [], trackhashes: list[str] = []
) -> list[dict["str", str]]:
"""
Returns images of the first 4 albums that appear in the track list.
When tracks are not passed, trackhashes need to be passed.
Tracks are then resolved from the store.
"""
if len(trackhashes) > 0:
tracks = TrackStore.get_tracks_by_trackhashes(trackhashes)
@@ -120,7 +127,7 @@ def get_first_4_images(
images = [
{
"image": album.image,
"color": "".join(album.colors),
"color": album.color,
}
for album in albums
]
+31 -181
View File
@@ -1,87 +1,39 @@
from dataclasses import asdict
import os
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from typing import Generator
from requests import ConnectionError as RequestConnectionError
from requests import ReadTimeout
from app import settings
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.tracks import SQLiteTrackMethods
from app.lib.albumslib import validate_albums
from app.lib.artistlib import CheckArtistImages
from app.lib.colorlib import ProcessAlbumColors, ProcessArtistColors
from app.lib.errors import PopulateCancelledError
from app.lib.taglib import extract_thumb, get_tags
from app.lib.trackslib import validate_tracks
from app.lib.taglib import extract_thumb
from app.logger import log
from app.models import Album, Artist, Track
from app.models import Album, Artist
from app.models.lastfm import SimilarArtist
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.network import has_connection
from app.utils.progressbar import tqdm
get_all_tracks = SQLiteTrackMethods.get_all_tracks
insert_many_tracks = SQLiteTrackMethods.insert_many_tracks
remove_tracks_by_filepaths = SQLiteTrackMethods.remove_tracks_by_filepaths
from app.db.userdata import SimilarArtistTable
POPULATE_KEY = ""
class Populate:
class CordinateMedia:
"""
Populates the database with all songs in the music directory
checks if the song is in the database, if not, it adds it
also checks if the album art exists in the image path, if not tries to extract it.
Cordinates the extracting of thumbnails
"""
def __init__(self, instance_key: str) -> None:
def __init__(self, instance_key: str):
global POPULATE_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:
ProcessTrackThumbnails(instance_key)
ProcessAlbumColors(instance_key)
@@ -92,10 +44,6 @@ class Populate:
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():
tried_to_download_new_images = True
try:
@@ -120,98 +68,6 @@ class Populate:
log.warn(e)
return
@staticmethod
def remove_modified(tracks: Generator[Track, None, None]):
"""
Removes tracks from the database that have been modified
since they were added to the database.
"""
unmodified_paths = set()
modified_tracks: list[Track] = []
modified_paths = set()
for track in tracks:
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
TrackStore.remove_track_obj(track)
remove_tracks_by_filepaths(track.filepath)
modified_paths.add(track.filepath)
modified_tracks.append(track)
TrackStore.remove_tracks_by_filepaths(modified_paths)
remove_tracks_by_filepaths(modified_paths)
return unmodified_paths, modified_tracks
@staticmethod
def tag_untagged(untagged: set[str], key: str):
log.info("Found %s new tracks", len(untagged))
tagged_tracks: deque[dict] = deque()
tagged_count = 0
favs = favdb.get_fav_tracks()
records = dict()
for fav in favs:
r = records.setdefault(fav[1], set())
r.add(fav[4])
for file in tqdm(untagged, desc="Reading files"):
if POPULATE_KEY != key:
log.warning("'Populate.tag_untagged': Populate key changed")
return
tags = get_tags(file)
if tags is not None:
tagged_tracks.append(tags)
track = Track(**tags)
track.fav_userids = list(records.get(track.trackhash, set()))
TrackStore.add_track(track)
if not AlbumStore.album_exists(track.albumhash):
AlbumStore.add_album(AlbumStore.create_album(track))
for artist in track.artists:
if not ArtistStore.artist_exists(artist.artisthash):
ArtistStore.add_artist(Artist(artist.name))
for artist in track.albumartists:
if not ArtistStore.artist_exists(artist.artisthash):
ArtistStore.add_artist(Artist(artist.name))
tagged_count += 1
else:
log.warning("Could not read file: %s", file)
if len(tagged_tracks) > 0:
log.info("Adding %s tracks to database", len(tagged_tracks))
insert_many_tracks(tagged_tracks)
log.info("Added %s/%s tracks", tagged_count, len(untagged))
@staticmethod
def extract_thumb_with_overwrite(tracks: list[Track]):
"""
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.image, overwrite=True)
except FileNotFoundError:
continue
def get_image(_map: tuple[str, Album]):
"""
@@ -227,28 +83,19 @@ def get_image(_map: tuple[str, Album]):
if POPULATE_KEY != instance_key:
raise PopulateCancelledError("'ProcessTrackThumbnails': Populate key changed")
matching_tracks = filter(
lambda t: t.albumhash == album.albumhash, TrackStore.tracks
)
matching_tracks = AlbumStore.get_album_tracks(album.albumhash)
try:
track = next(matching_tracks)
extracted = extract_thumb(track.filepath, track.image)
while not extracted:
try:
track = next(matching_tracks)
extracted = extract_thumb(track.filepath, track.image)
except StopIteration:
break
return
except StopIteration:
pass
for track in matching_tracks:
if extract_thumb(track.filepath, track.image):
break
_cpu_count = os.cpu_count()
CPU_COUNT = _cpu_count // 2 if _cpu_count > 2 else _cpu_count
def get_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:
@@ -268,14 +115,14 @@ class ProcessTrackThumbnails:
# filter out albums that already have thumbnails
albums = filter(
lambda album: album.albumhash not in processed, AlbumStore.albums
lambda album: album.albumhash not in processed, AlbumStore.get_flat_list()
)
albums = list(albums)
# process the rest
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(
tqdm(
executor.map(get_image, key_album_map),
@@ -300,16 +147,17 @@ def save_similar_artists(_map: tuple[str, Artist]):
"'FetchSimilarArtistsLastFM': Populate key changed"
)
if lastfmdb.exists(artist.artisthash):
if SimilarArtistTable.exists(artist.artisthash):
return
artist_hashes = fetch_similar_artists(artist.name)
artist_ = SimilarArtist(artist.artisthash, "~".join(artist_hashes))
artists = fetch_similar_artists(artist.name)
if len(artist_.similar_artist_hashes) == 0:
# INFO: Nones mean there was a connection error
if artists is None:
return
lastfmdb.insert_one(artist_)
artist_ = SimilarArtist(artist.artisthash, artists)
SimilarArtistTable.insert_one(asdict(artist_))
class FetchSimilarArtistsLastFM:
@@ -319,17 +167,19 @@ class FetchSimilarArtistsLastFM:
def __init__(self, instance_key: str) -> None:
# read all artists from db
processed = lastfmdb.get_all()
processed = SimilarArtistTable.get_all()
processed = ".".join(a.artisthash for a in processed)
# 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, ArtistStore.get_flat_list()
)
artists = list(artists)
# process the rest
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:
print("Processing similar artists")
results = list(
+24 -34
View File
@@ -8,16 +8,22 @@ from rapidfuzz import process, utils
from unidecode import unidecode
from app import models
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.config import UserConfig
# from app.db.libdata import AlbumTable, ArtistTable, TrackTable
# from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.models.enums import FavType
from app.models.track import Track
from app.serializers.album import serialize_for_card as serialize_album
from app.serializers.album import serialize_for_card_many as serialize_albums
from app.serializers.artist import serialize_for_cards
from app.serializers.track import serialize_track, serialize_tracks
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
from app.store.tracks import TrackStore
from app.utils.remove_duplicates import remove_duplicates
# ratio = fuzz.ratio
@@ -49,7 +55,7 @@ class Limit:
class SearchTracks:
def __init__(self, query: str) -> None:
self.query = query
self.tracks = TrackStore.tracks
self.tracks = TrackStore.get_flat_list()
def __call__(self) -> List[models.Track]:
"""
@@ -72,7 +78,7 @@ class SearchTracks:
class SearchArtists:
def __init__(self, query: str) -> None:
self.query = query
self.artists = ArtistStore.artists
self.artists = ArtistStore.get_flat_list()
def __call__(self):
"""
@@ -94,7 +100,7 @@ class SearchArtists:
class SearchAlbums:
def __init__(self, query: str) -> None:
self.query = query
self.albums = AlbumStore.albums
self.albums = AlbumStore.get_flat_list()
def __call__(self) -> List[models.Album]:
"""
@@ -137,7 +143,7 @@ _S2 = TypeVar("_S2")
_ResultType = int | float
def get_titles(items: _type):
def get_titles(items: list[_type]):
for item in items:
if isinstance(item, models.Track):
text = item.og_title
@@ -161,9 +167,9 @@ class TopResults:
def collect_all():
all_items: list[_type] = []
all_items.extend(ArtistStore.artists)
all_items.extend(TrackStore.tracks)
all_items.extend(AlbumStore.albums)
all_items.extend(ArtistStore.get_flat_list())
all_items.extend(TrackStore.get_flat_list())
all_items.extend(TrackStore.get_flat_list())
return all_items, get_titles(all_items)
@@ -189,19 +195,13 @@ class TopResults:
tracks = TrackStore.get_tracks_by_albumhash(item.albumhash)
tracks = remove_duplicates(tracks)
item.get_date_from_tracks(tracks)
try:
item.duration = sum((t.duration for t in tracks))
except AttributeError:
item.duration = 0
item.check_is_single(tracks)
if not item.is_single:
item.check_type()
item.is_favorite = favdb.check_is_favorite(
item.albumhash, fav_type=FavType.album
item.check_type(
tracks, singleTrackAsSingle=UserConfig().showAlbumsAsSingles
)
return {"type": "album", "item": item}
@@ -210,16 +210,13 @@ class TopResults:
track_count = 0
duration = 0
for track in TrackStore.get_tracks_by_artisthash(item.artisthash):
tracks = TrackStore.get_tracks_by_artisthash(item.artisthash)
tracks = remove_duplicates(tracks)
for track in tracks:
track_count += 1
duration += track.duration
album_count = AlbumStore.count_albums_by_artisthash(item.artisthash)
item.set_trackcount(track_count)
item.set_albumcount(album_count)
item.set_duration(duration)
return {"type": "artist", "item": item}
@staticmethod
@@ -242,6 +239,7 @@ class TopResults:
tracks.extend(t)
if item["type"] == "artist":
# t = TrackStore.get_tracks_by_artisthash(item["item"].artisthash)
t = TrackStore.get_tracks_by_artisthash(item["item"].artisthash)
# if there are less than the limit, get more tracks
@@ -263,6 +261,7 @@ class TopResults:
return SearchAlbums(query)()[:limit]
if item["type"] == "artist":
# albums = AlbumStore.get_albums_by_artisthash(item["item"].artisthash)
albums = AlbumStore.get_albums_by_artisthash(item["item"].artisthash)
# if there are less than the limit, get more albums
@@ -279,7 +278,6 @@ class TopResults:
limit: int = None,
albums_only=False,
tracks_only=False,
in_quotes=False,
):
items, titles = TopResults.collect_all()
results = TopResults.get_results(titles, query)
@@ -307,21 +305,13 @@ class TopResults:
result = TopResults.map_with_type(result)
if in_quotes:
top_tracks = SearchTracks(query)()[:tracks_limit]
else:
top_tracks = TopResults.get_track_items(result, query, limit=tracks_limit)
top_tracks = TopResults.get_track_items(result, query, limit=tracks_limit)
top_tracks = serialize_tracks(top_tracks)
if tracks_only:
return top_tracks
if in_quotes:
albums = SearchAlbums(query)()[:albums_limit]
else:
albums = TopResults.get_album_items(result, query, limit=albums_limit)
albums = TopResults.get_album_items(result, query, limit=albums_limit)
albums = serialize_albums(albums)
if albums_only:
+50
View File
@@ -0,0 +1,50 @@
from itertools import groupby
import os
from pprint import pprint
from app.lib.albumslib import sort_by_track_no
from app.models.folder import Folder
from app.models.track import Track
from app.utils import flatten
def sort_tracks(tracks: list[Track], key: str, reverse: bool = False):
"""
Sorts a list of tracks by a key.
"""
if key == "default":
return tracks
sortfunc = lambda x: getattr(x, key)
if key == "artists" or key == "albumartists":
sortfunc = lambda x: getattr(x, key)[0]["name"]
if key == "disc":
# INFO: Group tracks into albums, then sort them by disc number.
tracks = sorted(tracks, key=lambda x: x.album)
groups = groupby(tracks, lambda x: x.albumhash)
return flatten([sort_by_track_no(list(g)) for k, g in groups])
# INFO: sort tracks by title for a fallback value
tracks = sorted(tracks, key=lambda t: t.title)
if key == "title" and not reverse:
return tracks
return sorted(tracks, key=sortfunc, reverse=reverse)
def sort_folders(folders: list[Folder], key: str, reverse: bool = False):
"""
Sorts a list of folders by a key.
"""
if key == "default":
return folders
sortfunc = lambda x: getattr(x, key)
if key == "lastmod":
sortfunc = lambda x: os.path.getmtime(x.path)
return sorted(folders, key=sortfunc, reverse=reverse)
+313
View File
@@ -0,0 +1,313 @@
import os
from app import settings
from app.config import UserConfig
from app.db.libdata import TrackTable
from app.lib.taglib import extract_thumb, get_tags
from app.models.album import Album
from app.models.artist import Artist
from app.models.track import Track
from app.store.folder import FolderStore
from app.store.tracks import TrackStore
from app.utils import flatten
from app.utils.filesystem import run_fast_scandir
from app.utils.parsers import get_base_album_title
from app.utils.progressbar import tqdm
from app.logger import log
from app.utils.remove_duplicates import remove_duplicates
POPULATE_KEY: float = 0
class IndexTracks:
def __init__(self, instance_key: float) -> None:
"""
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 = 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()
for _dir in dirs_to_scan:
files = files.union(run_fast_scandir(_dir, full=True)[1])
unmodified, modified_tracks = self.filter_modded()
untagged = files - unmodified
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"):
if POPULATE_KEY != key:
log.warning("'Populate.tag_untagged': Populate key changed")
return
tags = get_tags(file, config=config)
if tags is not None:
TrackTable.insert_one(tags)
FolderStore.filepaths.add(tags["filepath"])
del tags
print(f"{len(files)} new files indexed")
print("Done")
def create_albums(_trackhashes: list[str] = []) -> list[tuple[Album, set[str]]]:
"""
Creates album objects using the indexed tracks. Takes in an optional
list of trackhashes to create the albums from. If no list is provided,
all tracks are used.
The trackhashes are passed when creating albums from the watchdogg module.
Returns a list of tuples containing the album and the trackhashes in the album.
ie:
>>> list[tuple[Album, set[str]]]
"""
albums = dict()
if _trackhashes:
all_tracks: list[Track] = TrackStore.get_tracks_by_trackhashes(_trackhashes)
else:
all_tracks: list[Track] = TrackStore.get_flat_list()
all_tracks = remove_duplicates(all_tracks)
for track in all_tracks:
if track.albumhash not in albums:
albums[track.albumhash] = {
"albumartists": track.albumartists,
"artisthashes": [a["artisthash"] for a in track.albumartists],
"albumhash": track.albumhash,
"base_title": None,
"color": None,
"created_date": track.last_mod,
"date": track.date,
"duration": track.duration,
"genres": [*track.genres] if track.genres else [],
"og_title": track.og_album,
"lastplayed": track.lastplayed,
"playcount": track.playcount,
"playduration": track.playduration,
"title": track.album,
"tracks": {track.trackhash},
"extra": {},
}
else:
album = albums[track.albumhash]
album["tracks"].add(track.trackhash)
album["playcount"] += track.playcount
album["playduration"] += track.playduration
album["lastplayed"] = max(album["lastplayed"], track.lastplayed)
album["duration"] += track.duration
album["date"] = min(album["date"], track.date)
album["created_date"] = min(album["created_date"], track.last_mod)
if track.genres:
album["genres"].extend(track.genres)
for album in albums.values():
genres = []
for genre in album["genres"]:
if genre not in genres:
genres.append(genre)
album["genres"] = genres
album["genrehashes"] = " ".join([g["genrehash"] for g in genres])
album["base_title"], _ = get_base_album_title(album["og_title"])
del genres
trackhashes = album.pop("tracks")
album["trackcount"] = len(trackhashes)
albums[album["albumhash"]] = (Album(**album), trackhashes)
return list(albums.values())
def create_artists(
artisthashes: list[str] = [],
) -> list[tuple[Artist, set[str], set[str]]]:
"""
Creates artist objects using the indexed tracks. Takes in an optional
list of artisthashes to create the artists from. If no list is provided,
all tracks are used.
Returns a list of tuples containing the artist, the trackhashes for the artist
and the albumhashes for the artist.
ie:
>>> list[tuple[Artist, set[str], set[str]]]
"""
if artisthashes:
all_tracks: list[Track] = flatten(
[TrackStore.get_tracks_by_artisthash(hash) for hash in artisthashes]
)
else:
all_tracks: list[Track] = TrackStore.get_flat_list()
all_tracks = remove_duplicates(all_tracks)
artists = dict()
for track in all_tracks:
this_artists = track.artists
for a in track.albumartists:
if a not in this_artists:
a["in_track"] = False
this_artists.append(a)
for thisartist in this_artists:
if thisartist["artisthash"] not in artists:
artists[thisartist["artisthash"]] = {
"albumcount": None,
"albums": {track.albumhash},
"artisthash": thisartist["artisthash"],
"created_date": track.last_mod,
"date": track.date,
"duration": track.duration,
"genres": track.genres if track.genres else [],
"name": None,
"names": {thisartist["name"]},
"lastplayed": track.lastplayed,
"playcount": track.playcount,
"playduration": track.playduration,
"trackcount": None,
"tracks": (
{track.trackhash} if thisartist.get("in_track", True) else set()
),
"extra": {},
}
else:
artist: dict = artists[thisartist["artisthash"]]
artist["duration"] += track.duration
artist["playcount"] += track.playcount
artist["playduration"] += track.playduration
artist["albums"].add(track.albumhash)
artist["date"] = min(artist["date"], track.date)
artist["lastplayed"] = max(artist["lastplayed"], track.lastplayed)
artist["created_date"] = min(artist["created_date"], track.last_mod)
artist["names"].add(thisartist["name"])
artist.setdefault("albums", set())
if thisartist.get("in_track", True):
artist["tracks"].add(track.trackhash)
if track.genres:
artist["genres"].extend(track.genres)
for artist in artists.values():
artist["albumcount"] = len(artist["albums"])
artist["trackcount"] = len(artist["tracks"])
genres = []
for genre in artist["genres"]:
if genre not in genres:
genres.append(genre)
artist["genres"] = genres
artist["genrehashes"] = " ".join([g["genrehash"] for g in genres])
artist["name"] = sorted(artist["names"])[0]
# INFO: Delete temporary keys
del artist["names"]
tracks = artist.pop("tracks")
albums = artist.pop("albums")
# INFO: Delete local variables
del genres
artists[artist["artisthash"]] = (Artist(**artist), tracks, albums)
return list(artists.values())
+152 -18
View File
@@ -1,13 +1,18 @@
from dataclasses import dataclass
import math
import os
from io import BytesIO
from pathlib import Path
from pprint import pprint
import re
import sys
from typing import Any
import pendulum
from PIL import Image, UnidentifiedImageError
from tinytag import TinyTag
from app.config import UserConfig
from app.settings import Defaults, Paths
from app.utils.hashing import create_hash
from app.utils.parsers import split_artists
@@ -77,7 +82,7 @@ def extract_thumb(filepath: str, webp_path: str, overwrite=False) -> bool:
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.
"""
@@ -99,12 +104,13 @@ def clean_filename(filename: str):
class ParseData:
artist: str
title: str
config: UserConfig
def __post_init__(self):
self.artist = split_artists(self.artist)
self.artist = split_artists(self.artist, self.config)
def extract_artist_title(filename: str):
def extract_artist_title(filename: str, config: UserConfig):
path = Path(filename).with_suffix("")
path = clean_filename(str(path))
@@ -112,22 +118,30 @@ def extract_artist_title(filename: str):
split_result = [x.strip() for x in split_result]
if len(split_result) == 1:
return ParseData("", split_result[0])
return ParseData(
"",
split_result[0],
config,
)
if len(split_result) > 2:
try:
int(split_result[0])
return ParseData(split_result[1], " - ".join(split_result[2:]))
return ParseData(
split_result[1],
" - ".join(split_result[2:]),
config,
)
except ValueError:
pass
artist = split_result[0]
title = split_result[1]
return ParseData(artist, title)
return ParseData(artist, title, config)
def get_tags(filepath: str):
def get_tags(filepath: str, config: UserConfig):
"""
Returns the tags for a given audio file.
"""
@@ -141,7 +155,7 @@ def get_tags(filepath: str):
return None
try:
tags = TinyTag.get(filepath)
tags: Any = TinyTag.get(filepath)
except: # noqa: E722
return None
@@ -160,17 +174,20 @@ def get_tags(filepath: str):
for tag in to_filename:
p = getattr(tags, tag)
if p == "" or p is None:
parse_data = extract_artist_title(filename)
title = parse_data.title
parse_data = extract_artist_title(filename, config)
title = parse_data.title.replace("_", " ")
setattr(tags, tag, title)
# tags.title = tags.title.replace("_", " ")
# tags.album = tags.album.replace("_", " ")
parse = ["artist", "albumartist"]
for tag in parse:
p = getattr(tags, tag)
if p == "" or p is None:
if not parse_data:
parse_data = extract_artist_title(filename)
parse_data = extract_artist_title(filename, config)
artist = parse_data.artist
@@ -190,7 +207,7 @@ def get_tags(filepath: str):
to_round = ["bitrate", "duration"]
for prop in to_round:
try:
setattr(tags, prop, round(getattr(tags, prop)))
setattr(tags, prop, math.floor(getattr(tags, prop)))
except TypeError:
setattr(tags, prop, 0)
@@ -206,9 +223,7 @@ def get_tags(filepath: str):
except KeyError:
tags.copyright = None
tags.albumhash = create_hash(tags.album, tags.albumartist)
tags.trackhash = create_hash(tags.artist, tags.album, tags.title)
tags.image = f"{tags.albumhash}.webp"
# tags.image = f"{tags.albumhash}.webp"
tags.folder = win_replace_slash(os.path.dirname(filepath))
tags.date = parse_date(tags.year) or int(last_mod)
@@ -218,9 +233,128 @@ def get_tags(filepath: str):
tags.artists = tags.artist
tags.albumartists = tags.albumartist
# split_artist = split_artists(tags.artist, separators=config.artistSeparators)
# split_albumartists = split_artists(tags.albumartist, separators=config.artistSeparators)
# new_title = tags.title
# TODO: Figure out which is the best spot to create these hashes
# create albumhash using og_album
tags.albumhash = create_hash(tags.album or "", tags.albumartist)
# extract featured artists
# if config.extractFeaturedArtists:
# feat, new_title = parse_feat_from_title(
# tags.title, separators=config.artistSeparators
# )
# 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)
# if no albumartist, assign to the first artist
if not tags.albumartist:
tags.albumartist = split_artists(tags.artist, config)[:1]
# create json objects for artists and albumartists
# tags.artists = [
# {
# "artisthash": create_hash(a, decode=True),
# "name": a,
# }
# for a in split_artist
# ]
# tags.albumartists = [
# {
# "artisthash": create_hash(a, decode=True),
# "name": a,
# }
# for a in split_albumartists
# ]
# tags.artisthashes = list(
# {a["artisthash"] for a in tags.artists}
# )
# remove prod by
# if config.removeProdBy:
# new_title = remove_prod(new_title)
# if track is a single, ie.
# if og_title == album, rename album to new_title
# if tags.title == tags.album:
# tags.album = new_title
# remove remaster from track title
# if config.removeRemasterInfo:
# new_title = clean_title(new_title)
# save final title
# tags.og_title = tags.title
# tags.title = new_title
# tags.og_album = tags.album
# clean album title
# if config.cleanAlbumTitle:
# tags.album, _ = get_base_title_and_versions(tags.album, get_versions=False)
# merge album versions
# if config.mergeAlbums:
# tags.albumhash = create_hash(
# tags.album, *(a["name"] for a in tags.albumartists)
# )
# process genres
# if tags.genre:
# src_genres: str = tags.genre
# src_genres = src_genres.lower()
# # separators = {"/", ";", "&"}
# separators = set(config.genreSeparators)
# contains_rnb = "r&b" in src_genres
# contains_rock = "rock & roll" in src_genres
# if contains_rnb:
# src_genres = src_genres.replace("r&b", "RnB")
# if contains_rock:
# src_genres = src_genres.replace("rock & roll", "rock")
# for s in separators:
# src_genres = src_genres.replace(s, ",")
# genres_list: list[str] = src_genres.split(",")
# tags.genres = [
# {"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 = []
tags.genres = tags.genre
# sub underscore with space
tags.title = tags.title.replace("_", " ")
tags.album = tags.album.replace("_", " ")
# tags.title = tags.title.replace("_", " ")
# tags.album = tags.album.replace("_", " ")
tags.trackhash = create_hash(tags.artists, tags.album, tags.title)
more_extra = {
"audio_offset": tags.audio_offset,
"bitdepth": tags.bitdepth,
"composer": tags.composer,
"channels": tags.channels,
"comment": tags.comment,
"disc_total": tags.disc_total,
"filesize": tags.filesize,
"samplerate": tags.samplerate,
"track_total": tags.track_total,
"hashinfo": {
"algo": "sha1",
"format": "[:5]+[-5:]", # first 5 + last 5 chars
},
}
tags.extra = {**tags.extra, **more_extra}
tags = tags.__dict__
@@ -236,13 +370,13 @@ def get_tags(filepath: str):
"comment",
"composer",
"disc_total",
"extra",
"samplerate",
"track_total",
"year",
"bitdepth",
"artist",
"albumartist",
"genre",
]
for tag in to_delete:
-14
View File
@@ -6,23 +6,9 @@ import os
from app.lib.pydub.pydub import AudioSegment
from app.lib.pydub.pydub.silence import detect_leading_silence, detect_silence
from app.db.sqlite.tracks import SQLiteTrackMethods as trackdb
from app.store.tracks import TrackStore
from app.utils.progressbar import tqdm
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):
"""
Returns the leading silence of a track.
+78
View File
@@ -0,0 +1,78 @@
from app.utils.threading import background
import subprocess
@background
def start_transcoding(
input_path: str, output_path: str, bitrate: str, container_args: list[str], compression_level: int = 12
):
"""
Starts a background transcoding process for an audio file.
This function uses FFmpeg to transcode an audio file from one format to another,
with specified bitrate and container format. It runs as a background task.
Args:
input_path (str): The path to the input audio file.
output_path (str): The path where the transcoded file will be saved.
bitrate (str): The desired bitrate for the output file (e.g., "128k").
container_args (list[str]): FFmpeg arguments specific to the output container format.
compression_level (int): Compression level (0-9, default: 6).
Returns:
None
Note:
This function is decorated with @background, which means it runs asynchronously.
The actual transcoding process is handled by FFmpeg in a subprocess.
The function will print status messages about the transcoding process.
"""
# Base command
command = [
"ffmpeg",
"-i",
input_path,
"-map_metadata", "0", # Add this line to copy metadata
"-b:a",
bitrate,
"-vn",
"-compression_level",
str(compression_level),
# REVIEW: Idk what any flag below this point does!
"-movflags",
"faststart+frag_keyframe+empty_moov",
"-write_xing",
"0",
"-fflags",
"+bitexact",
]
# Add format-specific parameters
command.extend(container_args)
# Add output path and overwrite flag
command.extend([output_path, "-y"])
process = subprocess.Popen(
command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
print(f"Started transcoding process with PID: {process.pid}")
try:
# Wait for the process to complete
process.wait()
print(f"Transcoding process (PID: {process.pid}) completed")
except KeyboardInterrupt:
print(f"Transcoding interrupted. Terminating process (PID: {process.pid})")
finally:
# Ensure the process is terminated
try:
process.terminate()
process.wait(timeout=5) # Wait up to 5 seconds for graceful termination
except subprocess.TimeoutExpired:
print(
f"Process (PID: {process.pid}) did not terminate gracefully. Killing..."
)
process.kill()
+66 -37
View File
@@ -8,19 +8,20 @@ import sqlite3
import time
from watchdog.events import PatternMatchingEventHandler
from watchdog.observers.api import BaseObserverSubclassCallable
from watchdog.observers import Observer
from app import settings
from app.db.sqlite.albumcolors import SQLiteAlbumMethods as aldb
from app.db.sqlite.settings import SettingsSQLMethods as sdb
from app.db.sqlite.tracks import SQLiteManager
from app.db.sqlite.tracks import SQLiteTrackMethods as db
from app.config import UserConfig
from app.db.libdata import TrackTable
from app.db.userdata import LibDataTable
from app.lib.colorlib import process_color
from app.lib.tagger import create_albums, create_artists
from app.lib.taglib import extract_thumb, get_tags
from app.logger import log
from app.models import Artist, Track
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
from app.store.artists import ArtistMapEntry, ArtistStore
from app.store.tracks import TrackStore
@@ -29,7 +30,7 @@ class Watcher:
Contains the methods for initializing and starting watchdog.
"""
observers: list[Observer] = []
observers: list[BaseObserverSubclassCallable] = []
def __init__(self):
self.observer = Observer()
@@ -43,7 +44,8 @@ class Watcher:
while trials < 10:
try:
dirs = sdb.get_root_dirs()
# dirs = sdb.get_root_dirs()
dirs = UserConfig().rootDirs
dirs = [rf"{d}" for d in dirs]
dir_map = [
@@ -127,10 +129,10 @@ class Watcher:
self.run()
def handle_colors(cur: sqlite3.Cursor, albumhash: str):
exists = aldb.exists(albumhash, cur)
def handle_color(albumhash: str):
entry = LibDataTable.find_one(albumhash, "album")
if exists:
if entry and entry.color:
return
colors = process_color(albumhash, is_album=True)
@@ -138,7 +140,12 @@ def handle_colors(cur: sqlite3.Cursor, albumhash: str):
if colors is None:
return
aldb.insert_one_album(cur=cur, albumhash=albumhash, colors=json.dumps(colors))
if entry is None:
LibDataTable.insert_one(
{"itemhash": albumhash, "color": colors[0], "itemtype": "album"}
)
else:
LibDataTable.update_one(albumhash, {"color": colors[0]})
return colors
@@ -152,38 +159,43 @@ def add_track(filepath: str) -> None:
TrackStore.remove_track_by_filepath(filepath)
tags = get_tags(filepath)
config = UserConfig()
tags = get_tags(filepath, config)
# if the track is somehow invalid, return
if tags is None or tags["bitrate"] == 0 or tags["duration"] == 0:
return
colors = None
with SQLiteManager() as cur:
db.insert_one_track(tags, cur)
extracted = extract_thumb(filepath, tags["albumhash"] + ".webp")
if not extracted:
return
colors = handle_colors(cur, tags["albumhash"])
TrackTable.insert_one(tags)
extract_thumb(filepath, tags["albumhash"] + ".webp", overwrite=True)
colors = handle_color(tags["albumhash"])
track = Track(**tags)
TrackStore.add_track(track)
if not AlbumStore.album_exists(track.albumhash):
album = AlbumStore.create_album(track)
album.set_colors(colors)
AlbumStore.add_album(album)
# SECTION: Index album
albumentry = AlbumStore.albummap.get(track.albumhash)
artists: list[Artist] = track.artists + track.albumartists # type: ignore
if albumentry is None:
album, trackhashes = create_albums([track.trackhash])[0]
AlbumStore.index_new_album(album, trackhashes)
else:
trackhash_exists = track.trackhash in albumentry.trackhashes
if not trackhash_exists:
albumentry.trackhashes.add(track.trackhash)
albumentry.album.trackcount += 1
albumentry.set_color(colors[0]) if colors else None
# SECTION: Index artist
artists = create_artists(track.artisthashes)
for artist in artists:
if not ArtistStore.artist_exists(artist.artisthash):
ArtistStore.add_artist(Artist(artist.name))
extract_thumb(filepath, track.image, overwrite=True)
ArtistStore.artistmap[artist[0].artisthash] = ArtistMapEntry(
artist=artist[0],
albumhashes=artist[1],
trackhashes=artist[2],
)
def remove_track(filepath: str) -> None:
@@ -287,16 +299,33 @@ class Handler(PatternMatchingEventHandler):
NOT FIRED IN WINDOWS
"""
try:
self.files_to_process.remove(event.src_path)
if os.path.getsize(event.src_path) > 0:
# Get initial file size
initial_size = os.path.getsize(event.src_path)
# Wait for 10 seconds
time.sleep(10)
# Check if file size has changed
current_size = os.path.getsize(event.src_path)
if current_size > 0 and current_size == initial_size:
path = self.get_abs_path(event.src_path)
add_track(path)
# Remove from processing list only after successful processing
self.files_to_process.remove(event.src_path)
else:
# File is still being modified or has been deleted
log.info(
f"File {event.src_path} is still being modified. Skipping processing for now."
)
except FileNotFoundError:
# file was closed and deleted.
pass
log.info(f"File {event.src_path} was closed and deleted before processing.")
except ValueError:
# file was removed from the list by another event handler.
pass
# file was already removed from the list by another event handler.
log.info(
f"File {event.src_path} was already removed from the processing list."
)
def on_modified(self, event):
# this event handler is triggered twice on windows
@@ -317,7 +346,7 @@ class Handler(PatternMatchingEventHandler):
if current_size == previous_size:
# Wait for a short duration to ensure the file write operation is complete
time.sleep(5)
time.sleep(10)
# Check the file size again
try:
+22 -18
View File
@@ -6,9 +6,9 @@ Reads and applies the latest database migrations.
import inspect
from types import ModuleType
from app.db.sqlite.migrations import MigrationManager
from app.logger import log
from app.migrations import v1_3_0, v1_4_9
# from app.db.sqlite.migrations import MigrationManager
from app.db.metadata import MigrationTable
from app.migrations.base import Migration
@@ -38,28 +38,32 @@ def apply_migrations():
migrations past that index are applied and the new length
is stored as the new migration index.
"""
modules = [v1_3_0, v1_4_9]
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]
to_apply: list[Migration] = []
# if index is from old release,
# get migrations from the "migrations" list
if index < 3:
_migrations = migrations[index:]
to_apply = [migration for sublist in _migrations for migration in sublist]
else:
to_apply = all_migrations[index:]
# if index < 3:
# _migrations = migrations[index:]
# to_apply = [migration for sublist in _migrations for migration in sublist]
# else:
# to_apply = all_migrations[index:]
for migration in to_apply:
try:
migration.migrate()
log.info("Applied migration: %s", migration.__name__)
except Exception as e:
log.error("Failed to run migration: %s", migration.__name__)
log.error(e)
# for migration in to_apply:
# # try:
# migration.migrate()
# log.info("Applied migration: %s", migration.__name__)
# except Exception as e:
# log.error("Failed to run migration: %s", migration.__name__)
# log.error(e)
MigrationManager.set_index(len(all_migrations))
# sys.exit(0)
# MigrationManager.set_index(len(all_migrations))
MigrationTable.set_version(len(all_migrations))
-306
View File
@@ -1,306 +0,0 @@
import json
import os
import shutil
import time
from collections import OrderedDict
from sqlite3 import OperationalError
from typing import Generator
from app.db.sqlite.utils import SQLiteManager
from app.migrations.base import Migration
from app.settings import Paths
from app.utils.decorators import coroutine
from app.utils.hashing import create_hash
# playlists table
# ---------------
# 0: id
# 1: banner_pos
# 2: has_gif
# 3: image
# 4: last_updated
# 5: name
# 6: trackhashes
class m1_RemoveSmallThumbnailFolder(Migration):
"""
Removes the small thumbnail folder.
Because we are added a new folder "original" in the same directory, and the small thumbs folder is used to check if an album's thumbnail is already extracted.
So we need to remove it, to force the app to extract thumbnails for all albums.
"""
@staticmethod
def migrate():
thumbs_sm_path = Paths.get_sm_thumb_path()
thumbs_lg_path = Paths.get_lg_thumb_path()
for path in [thumbs_sm_path, thumbs_lg_path]:
if os.path.exists(path):
shutil.rmtree(path)
for path in [thumbs_sm_path, thumbs_lg_path]:
os.makedirs(path, exist_ok=True)
class m2_RemovePlaylistArtistHashes(Migration):
"""
removes the artisthashes column from the playlists table.
"""
@staticmethod
def migrate():
# remove artisthashes column
sql = "ALTER TABLE playlists DROP COLUMN artisthashes"
with SQLiteManager(userdata_db=True) as cur:
try:
cur.execute(sql)
except OperationalError:
pass
cur.close()
class m3_AddSettingsToPlaylistTable(Migration):
"""
adds the settings column and removes the banner_pos and has_gif columns
to the playlists table.
"""
@staticmethod
def migrate():
select_playlists_sql = "SELECT * FROM playlists"
with SQLiteManager(userdata_db=True) as cur:
create_playlist_table_sql = """CREATE TABLE IF NOT EXISTS playlists (
id integer PRIMARY KEY,
image text,
last_updated text not null,
name text not null,
settings text,
trackhashes text
);"""
insert_playlist_sql = """INSERT INTO playlists(
image,
last_updated,
name,
settings,
trackhashes
) VALUES(:image, :last_updated, :name, :settings, :trackhashes)
"""
cur.execute(select_playlists_sql)
# load all playlists
playlists = cur.fetchall()
# drop old playlists table
cur.execute("DROP TABLE playlists")
# create new playlists table
cur.execute(create_playlist_table_sql)
def transform_playlists(pipeline: Generator, playlists: tuple):
for playlist in playlists:
# create dict that matches the new schema
p = {
"id": playlist[0],
"name": playlist[5],
"image": playlist[3],
"trackhashes": playlist[6],
"last_updated": playlist[4],
"settings": json.dumps(
{
"has_gif": False,
"banner_pos": playlist[1],
"square_img": False,
"pinned": False,
}
),
}
pipeline.send(p)
@coroutine
def insert_playlist():
while True:
playlist = yield
p = OrderedDict(sorted(playlist.items()))
cur.execute(insert_playlist_sql, p)
# insert playlists using a coroutine
# (my first coroutine)
pipeline = insert_playlist()
transform_playlists(pipeline, playlists)
pipeline.close()
cur.close()
class m4_AddLastUpdatedToTrackTable(Migration):
"""
adds the last modified column to the tracks table.
"""
@staticmethod
def migrate():
# add last_mod column and default to current timestamp
timestamp = time.time()
sql = f"ALTER TABLE tracks ADD COLUMN last_mod text not null DEFAULT '{timestamp}'"
with SQLiteManager() as cur:
try:
cur.execute(sql)
except OperationalError:
pass
cur.close()
class m5_MovePlaylistsAndFavoritesTo10BitHashes(Migration):
"""
moves the playlists and favorites to 10 bit hashes.
"""
@staticmethod
def migrate():
def get_track_data_by_hash(trackhash: str, tracks: list[tuple]) -> tuple:
for track in tracks:
# trackhash is the 15th bit hash
if track[15] == trackhash:
# return artist, album, title
return track[4], track[1], track[13]
def get_track_by_albumhash(albumhash: str, tracks: list[tuple]) -> tuple:
for track in tracks:
# albumhash is the 3rd bit hash
if track[3] == albumhash:
# return album, albumartist
return track[1], track[2]
_base = "SELECT * FROM"
fetch_playlists_sql = f"{_base} playlists"
fetch_tracks_sql = f"{_base} tracks"
update_playlist_hashes_sql = (
"UPDATE playlists SET trackhashes = :trackhashes WHERE id = :id"
)
fetch_favorites_sql = f"{_base} favorites"
update_fav_sql = "UPDATE favorites SET hash = :hash WHERE id = :id"
remove_fav_sql = "DELETE FROM favorites WHERE id = :id"
db_tracks = []
# read tracks from db
with SQLiteManager() as cur:
cur.execute(fetch_tracks_sql)
db_tracks.extend(cur.fetchall())
cur.close()
# update playlists
with SQLiteManager(userdata_db=True) as cur:
cur.execute(fetch_playlists_sql)
playlists = cur.fetchall()
# for each playlist
for p in playlists:
pid = p[0]
# load trackhashes
trackhashes: list[str] = json.loads(p[5])
for index, t in enumerate(trackhashes):
(artist, album, title) = get_track_data_by_hash(t, db_tracks)
# create new hash
new_hash = create_hash(artist, album, title, decode=True, limit=10)
trackhashes[index] = new_hash
# convert to string
trackhashes = json.dumps(trackhashes)
# save to db
cur.execute(
update_playlist_hashes_sql, {"trackhashes": trackhashes, "id": pid}
)
cur.close()
# update favorites
with SQLiteManager(userdata_db=True) as cur:
cur.execute(fetch_favorites_sql)
favorites = cur.fetchall()
# for each favorite
for f in favorites:
fid = f[0]
fhash: str = f[1]
ftype: str = f[2] # "track" || "album"
if ftype == "album":
(album, albumartist) = get_track_by_albumhash(fhash, db_tracks)
# create new hash
new_hash = create_hash(album, albumartist, decode=True, limit=10)
# save to db
cur.execute(update_fav_sql, {"hash": new_hash, "id": fid})
continue
if ftype == "track":
(artist, album, title) = get_track_data_by_hash(fhash, db_tracks)
# create new hash
new_hash = create_hash(artist, album, title, decode=True, limit=10)
# save to db
cur.execute(update_fav_sql, {"hash": new_hash, "id": fid})
continue
# remove favorites that are not track or album. ie. artists
cur.execute(remove_fav_sql, {"id": fid})
cur.close()
class m6_RemoveAllTracks(Migration):
"""
removes all tracks from the tracks table.
"""
@staticmethod
def migrate():
sql = "DELETE FROM tracks"
with SQLiteManager() as cur:
cur.execute(sql)
cur.close()
class m7_UpdateAppSettingsTable(Migration):
@staticmethod
def migrate():
drop_table_sql = "DROP TABLE settings"
create_table_sql = """
CREATE TABLE IF NOT EXISTS settings (
id integer PRIMARY KEY,
root_dirs text NOT NULL,
exclude_dirs text,
artist_separators text NOT NULL default '/,;',
extract_feat integer NOT NULL DEFAULT 1,
remove_prod integer NOT NULL DEFAULT 1,
clean_album_title integer NOT NULL DEFAULT 1,
remove_remaster integer NOT NULL DEFAULT 1,
merge_albums integer NOT NULL DEFAULT 0,
show_albums_as_singles integer NOT NULL DEFAULT 0
);
"""
with SQLiteManager(userdata_db=True) as cur:
cur.execute(drop_table_sql)
cur.execute(create_table_sql)
-177
View File
@@ -1,177 +0,0 @@
import os
import shutil
from app.db.sqlite.utils import SQLiteManager
from app.migrations.base import Migration
from app.settings import Paths
class _1AddTimestampToFavoritesTable(Migration):
"""
Adds a timestamp column to the favorites table.
"""
@staticmethod
def migrate():
# INFO: add timestamp column with automatic current timestamp
sql = f"ALTER TABLE favorites ADD COLUMN IF NOT EXISTS timestamp INTEGER NOT NULL DEFAULT 0"
# INFO: execute the sql
with SQLiteManager(userdata_db=True) as cur:
try:
# INFO: Add the timestamp column to the favorites table
cur.execute(sql)
# INFO: Set all the timestamps to the current time
cur.execute("UPDATE favorites SET timestamp = strftime('%s', 'now')")
except Exception as e:
# INFO: timestamp column already exists
pass
finally:
cur.close()
class _4MoveHashesToSha1(Migration):
"""
Moves the 10 bit item hashes from sha256 to sha1 which is
faster and more lenient on less powerful devices.
Thanks to [@tcsenpai](https:github.com/tcsenpai) for the contribution.
"""
enabled: bool = False
pass
# INFO: Apparentlly, every single table is affected by this migration.
# NOTE: Use generators to avoid memory issues.
class _2DeleteOriginalThumbnails(Migration):
"""
Original thumbnails are too large and are not needed.
"""
# TODO: Implement this migration
@staticmethod
def migrate():
imgpath = Paths.get_thumbs_path()
og_imgpath = os.path.join(imgpath, "original")
if os.path.exists(og_imgpath):
shutil.rmtree(og_imgpath)
class _3MoveScrobbleToUserId1(Migration):
"""
Updates all track logs from user id = 0 to user id = 1
"""
@staticmethod
def migrate():
sql = """
UPDATE track_logger SET userid = 1 WHERE userid = 0;
ALTER TABLE track_logger RENAME TO _track_logger;
CREATE TABLE IF NOT EXISTS track_logger (
id integer PRIMARY KEY,
trackhash text NOT NULL,
duration integer NOT NULL,
timestamp integer NOT NULL,
source text,
userid integer NOT NULL DEFAULT 1,
constraint fk_users foreign key (userid) references users(id) on delete cascade
);
INSERT INTO track_logger SELECT * FROM _track_logger;
DROP TABLE _track_logger;
"""
# INFO: Move the scrobble table to the user id 1
with SQLiteManager(userdata_db=True) as cur:
cur.executescript(sql)
cur.close()
class _4AddUserIdToFavoritesTable(Migration):
"""
Adds a userid column to the favorites table.
"""
@staticmethod
def migrate():
# check if userid column exists
exists_sql = (
"select count(*) from pragma_table_info('favorites') where name = 'userid'"
)
sql = """
ALTER TABLE favorites ADD userid INTEGER NOT NULL DEFAULT 1;
ALTER TABLE favorites RENAME TO _favorites;
CREATE TABLE IF NOT EXISTS favorites (
id integer PRIMARY KEY,
hash text not null,
type text not null,
timestamp integer not null default 0,
userid integer not null,
constraint fk_users foreign key (userid) references users(id) on delete cascade
);
INSERT INTO favorites SELECT * FROM _favorites;
DROP TABLE _favorites;
"""
with SQLiteManager(userdata_db=True) as cur:
data = cur.execute(exists_sql)
data = data.fetchone()
if data[0] == 1:
return # INFO: column already exists
cur.executescript(sql)
class _5AddUserIdToPlaylistsTable(Migration):
"""
Adds a userid column to the playlists table.
"""
@staticmethod
def migrate():
# check if userid column exists
exists_sql = (
"select count(*) from pragma_table_info('playlists') where name = 'userid'"
)
# Add the userid column to the playlists table
# Rename the old table to _playlists
# Create a new playlists table with the userid column
# Then, copy the data from the old table to the new table
# Finally, drop the old table
sql = """
ALTER TABLE playlists ADD userid INTEGER NOT NULL DEFAULT 1;
ALTER TABLE playlists RENAME TO _playlists;
CREATE TABLE IF NOT EXISTS playlists (
id integer PRIMARY KEY,
image text,
last_updated text not null,
name text not null,
settings text,
trackhashes text,
userid integer not null,
constraint fk_users foreign key (userid) references users(id) on delete cascade
);
INSERT INTO playlists SELECT * FROM _playlists;
DROP TABLE _playlists;
"""
with SQLiteManager(userdata_db=True) as cur:
# INFO: Check if the column already exists
data = cur.execute(exists_sql)
data = data.fetchone()
# INFO: If the column already exists, return
if data[0] == 1:
return # INFO: column already exists
# INFO: Execute the sql
cur.executescript(sql)
+74 -109
View File
@@ -1,13 +1,10 @@
import dataclasses
import datetime
from dataclasses import dataclass
from app.settings import SessionVarKeys, get_flag
from ..utils.hashing import create_hash
from ..utils.parsers import get_base_title_and_versions, parse_feat_from_title
from .artist import Artist
from .track import Track
from ..utils.hashing import create_hash
from app.utils.auth import get_current_userid
from ..utils.parsers import get_base_title_and_versions
@dataclass(slots=True)
@@ -16,94 +13,87 @@ class Album:
Creates an album object
"""
albumartists: list[dict[str, str]]
albumhash: str
artisthashes: list[str]
base_title: str
color: str
created_date: int
date: int
duration: int
genres: list[dict[str, str]]
genrehashes: list[str]
og_title: str
title: str
albumartists: list[Artist]
trackcount: int
lastplayed: int
playcount: int
playduration: int
extra: dict
albumartists_hashes: str = ""
id: int = -1
type: str = "album"
image: str = ""
count: int = 0
duration: int = 0
colors: list[str] = dataclasses.field(default_factory=list)
date: str = ""
created_date: int = 0
og_title: str = ""
base_title: str = ""
is_soundtrack: bool = False
is_compilation: bool = False
is_single: bool = False
is_EP: bool = False
is_favorite: bool = False
is_live: bool = False
genres: list[str] = dataclasses.field(default_factory=list)
versions: list[str] = dataclasses.field(default_factory=list)
fav_userids: list[int] = dataclasses.field(default_factory=list)
@property
def is_favorite(self):
return get_current_userid() in self.fav_userids
def toggle_favorite_user(self, userid: int):
"""
Adds or removes the given user from the list of users
who have favorited the album.
"""
if userid in self.fav_userids:
self.fav_userids.remove(userid)
else:
self.fav_userids.append(userid)
def __post_init__(self):
self.title = self.title.strip()
self.og_title = self.title
self.image = self.albumhash + ".webp"
self.populate_versions()
# Fetch album artists from title
if get_flag(SessionVarKeys.EXTRACT_FEAT):
featured, self.title = parse_feat_from_title(self.title)
def populate_versions(self):
_, self.versions = get_base_title_and_versions(self.og_title, get_versions=True)
if len(featured) > 0:
original_lower = "-".join([a.name.lower() for a in self.albumartists])
self.albumartists.extend(
[Artist(a) for a in featured if a.lower() not in original_lower]
)
if "super_deluxe" in self.versions:
self.versions.remove("deluxe")
from ..store.tracks import TrackStore
# at this point, we should know the type of album
if "original" in self.versions and self.type == "soundtrack":
self.versions.remove("original")
TrackStore.append_track_artists(self.albumhash, featured, self.title)
self.versions = [v.replace("_", " ") for v in self.versions]
# Handle album version data
if get_flag(SessionVarKeys.CLEAN_ALBUM_TITLE):
get_versions = not get_flag(SessionVarKeys.MERGE_ALBUM_VERSIONS)
self.title, self.versions = get_base_title_and_versions(
self.title, get_versions=get_versions
)
self.base_title = self.title
if "super_deluxe" in self.versions:
self.versions.remove("deluxe")
if "original" in self.versions and self.check_is_soundtrack():
self.versions.remove("original")
self.versions = [v.replace("_", " ") for v in self.versions]
else:
self.base_title = get_base_title_and_versions(
self.title, get_versions=False
)[0]
self.albumartists_hashes = "-".join(a.artisthash for a in self.albumartists)
def set_colors(self, colors: list[str]):
self.colors = colors
def check_type(self):
def check_type(self, tracks: list[Track], singleTrackAsSingle: bool):
"""
Runs all the checks to determine the type of album.
"""
self.is_soundtrack = self.check_is_soundtrack()
if self.is_soundtrack:
if self.is_single(tracks, singleTrackAsSingle):
self.type = "single"
return
self.is_live = self.check_is_live_album()
if self.is_live:
if self.is_soundtrack():
self.type = "soundtrack"
return
self.is_compilation = self.check_is_compilation()
if self.is_compilation:
if self.is_live_album():
self.type = "live album"
return
self.is_EP = self.check_is_ep()
if self.is_compilation():
self.type = "compilation"
return
def check_is_soundtrack(self) -> bool:
if self.is_ep():
self.type = "ep"
return
self.type = "album"
def is_soundtrack(self) -> bool:
"""
Checks if the album is a soundtrack.
"""
@@ -114,11 +104,11 @@ class Album:
return False
def check_is_compilation(self) -> bool:
def is_compilation(self) -> bool:
"""
Checks if the album is a compilation.
"""
artists = [a.name for a in self.albumartists]
artists = [a["name"] for a in self.albumartists]
artists = "".join(artists).lower()
if "various artists" in artists:
@@ -137,7 +127,7 @@ class Album:
"biggest hits",
"the hits",
"the ultimate",
"compilation"
"compilation",
}
for substring in substrings:
@@ -146,7 +136,7 @@ class Album:
return False
def check_is_live_album(self):
def is_live_album(self):
"""
Checks if the album is a live album.
"""
@@ -157,7 +147,7 @@ class Album:
return False
def check_is_ep(self) -> bool:
def is_ep(self) -> bool:
"""
Checks if the album is an EP.
"""
@@ -165,22 +155,22 @@ class Album:
# TODO: check against number of tracks
def check_is_single(self, tracks: list[Track]):
def is_single(self, tracks: list[Track], singleTrackAsSingle: bool):
"""
Checks if the album is a single.
"""
keywords = ["single version", "- single"]
show_albums_as_singles = get_flag(SessionVarKeys.SHOW_ALBUMS_AS_SINGLES)
# show_albums_as_singles = get_flag(SessionVarKeys.SHOW_ALBUMS_AS_SINGLES)
for keyword in keywords:
if keyword in self.og_title.lower():
self.is_single = True
return
return True
if show_albums_as_singles and len(tracks) == 1:
self.is_single = True
return
# REVIEW: Reading from the config file in a for loop will be slow
# TODO: Find a
if singleTrackAsSingle and len(tracks) == 1:
return True
if (
len(tracks) == 1
@@ -192,29 +182,4 @@ class Album:
# and tracks[0].disc == 1
# TODO: Review -> Are the above commented checks necessary?
):
self.is_single = True
def get_date_from_tracks(self, tracks: list[Track]):
"""
Gets the date of the album its tracks.
Args:
tracks (list[Track]): The tracks of the album.
"""
if self.date:
return
dates = (int(t.date) for t in tracks if t.date)
try:
self.date = datetime.datetime.fromtimestamp(min(dates)).year
except:
self.date = datetime.datetime.now().year
def set_count(self, count: int):
self.count = count
def set_duration(self, duration: int):
self.duration = duration
def set_created_date(self, created_date: int):
self.created_date = created_date
return True
+43 -24
View File
@@ -1,6 +1,7 @@
import dataclasses
from dataclasses import dataclass
from app.utils.auth import get_current_userid
from app.utils.hashing import create_hash
@@ -23,35 +24,53 @@ class ArtistMinimal:
if self.artisthash == "5a37d5315e":
self.name = "Juice WRLD"
def to_json(self):
return {
"name": self.name,
"artisthash": self.artisthash,
}
@dataclass(slots=True)
class Artist(ArtistMinimal):
class Artist:
"""
Artist class
"""
name: str = ""
trackcount: int = 0
albumcount: int = 0
duration: int = 0
colors: list[str] = dataclasses.field(default_factory=list)
is_favorite: bool = False
created_date: float = 0.0
name: str
albumcount: int
artisthash: str
created_date: int
date: int
duration: int
genres: list[dict[str, str]]
genrehashes: list[str]
name: str
trackcount: int
lastplayed: int
playcount: int
playduration: int
extra: dict
id: int = -1
image: str = ""
color: str = ""
fav_userids: list[int] = dataclasses.field(default_factory=list)
@property
def is_favorite(self):
return get_current_userid() in self.fav_userids
def toggle_favorite_user(self, userid: int):
"""
Adds or removes the given user from the list of users
who have favorited this artist.
"""
if userid in self.fav_userids:
self.fav_userids.remove(userid)
else:
self.fav_userids.append(userid)
def __post_init__(self):
super(Artist, self).__init__(self.name)
def set_trackcount(self, count: int):
self.trackcount = count
def set_albumcount(self, count: int):
self.albumcount = count
def set_duration(self, duration: int):
self.duration = duration
def set_colors(self, colors: list[str]):
self.colors = colors
def set_created_date(self, created_date: float):
self.created_date = created_date
self.image = self.artisthash + ".webp"
+11
View File
@@ -0,0 +1,11 @@
from dataclasses import dataclass
from typing import Any, Literal
@dataclass
class Favorite:
hash: str
type: Literal["album", "track", "artist"]
timestamp: int
userid: int
extra: dict[str, Any]
-1
View File
@@ -7,4 +7,3 @@ class Folder:
path: str
is_sym: bool = False
trackcount: int = 0
foldercount: int = 0
+17 -2
View File
@@ -1,13 +1,28 @@
from dataclasses import dataclass
from typing import Any
@dataclass
class SimilarArtistEntry:
artisthash: str
name: str
weight: float
scrobbles: int
listeners: int
@dataclass
class SimilarArtist:
artisthash: str
similar_artist_hashes: str
similar_artists: list[SimilarArtistEntry]
def get_artist_hash_set(self) -> set[str]:
"""
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)
+2 -1
View File
@@ -1,5 +1,5 @@
from dataclasses import dataclass
from typing import Literal
from typing import Any, Literal
@dataclass
@@ -14,6 +14,7 @@ class TrackLog:
timestamp: int
source: str
userid: int
extra: dict[str, Any]
type = "track"
type_src = None
+12 -15
View File
@@ -1,35 +1,38 @@
import dataclasses
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from app import settings
from app.utils.auth import get_current_userid
@dataclass(slots=True)
class Playlist:
"""Creates playlist objects"""
id: int
id: int | str
image: str | None
last_updated: str
name: str
settings: str | dict
trackhashes: str | list[str]
settings: dict
trackhashes: list[str] = dataclasses.field(default_factory=list)
extra: dict[str, Any] = dataclasses.field(default_factory=dict)
thumb: str | None = ""
_last_updated: str = ""
userid: int | None = None
thumb: str = ""
count: int = 0
duration: int = 0
has_image: bool = False
images: list[str] = dataclasses.field(default_factory=list)
images: list[dict[str, str]] = dataclasses.field(default_factory=list)
pinned: bool = False
def __post_init__(self):
self.trackhashes = json.loads(str(self.trackhashes))
self.count = len(self.trackhashes)
if isinstance(self.settings, str):
self.settings = dict(json.loads(self.settings))
if self.userid is None:
self.userid = get_current_userid()
self.pinned = self.settings.get("pinned", False)
self.has_image = (
@@ -42,12 +45,6 @@ class Playlist:
self.image = "None"
self.thumb = "None"
def set_duration(self, duration: int):
self.duration = duration
def set_count(self, count: int):
self.count = count
def clear_lists(self):
"""
Removes data from lists to make it lighter for sending
+1 -1
View File
@@ -4,7 +4,7 @@ from dataclasses import dataclass
@dataclass
class Plugin:
name: str
description: str
active: bool
settings: dict
extra: dict
+150 -129
View File
@@ -1,11 +1,7 @@
from dataclasses import dataclass, field
import os
from pathlib import Path
from flask_jwt_extended import current_user
from app.settings import SessionVarKeys, get_flag
from app.config import UserConfig
from app.utils.auth import get_current_userid
from app.utils.hashing import create_hash
from app.utils.parsers import (
clean_title,
@@ -15,8 +11,6 @@ from app.utils.parsers import (
split_artists,
)
from .artist import ArtistMinimal
@dataclass(slots=True)
class Track:
@@ -24,10 +18,11 @@ class Track:
Track class
"""
id: int
album: str
albumartists: str | list[ArtistMinimal]
albumartists: list[dict[str, str]]
albumhash: str
artists: str | list[ArtistMinimal]
artists: list[dict[str, str]]
bitrate: int
copyright: str
date: int
@@ -35,152 +30,178 @@ class Track:
duration: int
filepath: str
folder: str
genre: str | list[str]
genres: str | list[dict[str, str]]
last_mod: int
title: str
track: int
trackhash: str
last_mod: str | int
extra: dict
lastplayed: int
playcount: int
playduration: int
config: UserConfig
og_album: str = ""
og_title: str = ""
artisthashes: list[str] = field(default_factory=list)
genrehashes: list[str] = field(default_factory=list)
_pos: int = 0
_ati: str = ""
image: str = ""
artist_hashes: str = ""
fav_userids: list = field(default_factory=list)
"""
A string of user ids separated by commas.
"""
# is_favorite: bool = False
fav_userids: list[int] = field(default_factory=list)
@property
def is_favorite(self):
return current_user['id'] in self.fav_userids
return get_current_userid() in self.fav_userids
# temporary attributes
_pos: int = 0 # for sorting tracks by disc and track number
_ati: str = (
"" # (album track identifier) for removing duplicates when merging album versions
)
def toggle_favorite_user(self, userid: int):
"""
Toggles the favorite status of the track for a given user.
og_title: str = ""
og_album: str = ""
created_date: float = 0.0
def set_created_date(self):
try:
self.created_date = Path(self.filepath).stat().st_ctime
except FileNotFoundError:
pass
Args:
userid (int): The ID of the user toggling the favorite status.
"""
if userid in self.fav_userids:
self.fav_userids.remove(userid)
else:
self.fav_userids.append(userid)
def __post_init__(self):
"""
Performs post-initialization processing on the track object.
This includes setting original values, processing artists and genres,
and removing duplicate artists.
"""
self.og_title = self.title
self.og_album = self.album
self.last_mod = int(self.last_mod)
self.date = int(self.date)
# add a trailing slash to the folder path
# to avoid matching a folder starting with the same name as the root path
# eg. .../Music and .../Music Videos
self.folder = os.path.join(self.folder, "")
if self.artists is not None:
artists = split_artists(self.artists)
new_title = self.title
if get_flag(SessionVarKeys.EXTRACT_FEAT):
featured, new_title = parse_feat_from_title(self.title)
original_lower = "-".join([create_hash(a) for a in artists])
artists.extend(
a for a in featured if create_hash(a) not in original_lower
)
self.artist_hashes = "-".join(create_hash(a, decode=True) for a in artists)
self.artists = [ArtistMinimal(a) for a in artists]
albumartists = split_artists(self.albumartists)
if not albumartists:
self.albumartists = self.artists[:1]
else:
self.albumartists = [ArtistMinimal(a) for a in albumartists]
if get_flag(SessionVarKeys.REMOVE_PROD):
new_title = remove_prod(new_title)
# if track is a single
if self.og_title == self.album:
self.rename_album(new_title)
if get_flag(SessionVarKeys.REMOVE_REMASTER_FROM_TRACK):
new_title = clean_title(new_title)
self.title = new_title
if get_flag(SessionVarKeys.CLEAN_ALBUM_TITLE):
self.album, _ = get_base_title_and_versions(
self.album, get_versions=False
)
if get_flag(SessionVarKeys.MERGE_ALBUM_VERSIONS):
self.recreate_albumhash()
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),
}
if self.genre is not None and self.genre != "":
self.genre = self.genre.lower()
separators = {"/", ";", "&"}
self.split_artists()
self.map_with_config()
self.process_genres()
contains_rnb = "r&b" in self.genre
contains_rock = "rock & roll" in self.genre
# Remove duplicates from artists and albumartists
seen_artists = set()
self.artists = [
d
for d in self.artists
if tuple(d.items()) not in seen_artists
and not seen_artists.add(tuple(d.items()))
]
seen_albumartists = set()
self.albumartists = [
d
for d in self.albumartists
if tuple(d.items()) not in seen_albumartists
and not seen_albumartists.add(tuple(d.items()))
]
self.recreate_trackhash()
self.config = None
def split_artists(self):
"""
Splits the artists and albumartists based on the given separators,
and updates the artisthashes.
"""
def split(artists: str):
return [
{"name": a, "artisthash": create_hash(a, decode=True)}
for a in split_artists(artists, config=self.config)
]
self.artists = split(self.artists)
self.albumartists = split(self.albumartists)
self.artisthashes = [a["artisthash"] for a in self.artists]
def map_with_config(self):
"""
Applies various transformations to the track's title and album
based on the user's configuration settings.
"""
new_title = self.title
# Extract featured artists
if self.config.extractFeaturedArtists:
feat, new_title = parse_feat_from_title(self.title, self.config)
feat = [
{"name": f, "artisthash": create_hash(f, decode=True)} for f in feat
]
feat = [f for f in feat if f["artisthash"] not in self.artisthashes]
self.artists.extend(feat)
self.artisthashes.extend([f["artisthash"] for f in feat])
# Update album title for singles
# ie. album: "Title (feat. Artist)"
# title: "Title (feat. Artist)"
# becomes: album: "Title", title: "Title"
if self.og_album == self.og_title:
self.album = new_title
# Clean track title
if self.config.removeProdBy:
new_title = remove_prod(new_title)
# if self.title == new_title:
# self.album = new_title
if self.config.removeRemasterInfo:
new_title = clean_title(new_title)
self.title = new_title
# Clean album title
if self.config.cleanAlbumTitle:
self.album, _ = get_base_title_and_versions(self.album, get_versions=False)
if self.config.mergeAlbums:
self.albumhash = create_hash(
self.album, *(a["name"] for a in self.albumartists)
)
def process_genres(self):
"""
Processes and standardizes the genre information for the track.
"""
if self.genres:
src_genres: str = self.genres
src_genres = src_genres.lower()
# separators = {"/", ";", "&"}
separators = set(self.config.genreSeparators)
contains_rnb = "r&b" in src_genres
contains_rock = "rock & roll" in src_genres
if contains_rnb:
self.genre = self.genre.replace("r&b", "RnB")
src_genres = src_genres.replace("r&b", "RnB")
if contains_rock:
self.genre = self.genre.replace("rock & roll", "rock")
src_genres = src_genres.replace("rock & roll", "rock")
for s in separators:
self.genre: str = self.genre.replace(s, ",")
src_genres = src_genres.replace(s, ",")
self.genre = self.genre.split(",")
self.genre = [g.strip() for g in self.genre]
genres_list: list[str] = src_genres.split(",")
self.genres = [
{"name": g.strip(), "genrehash": create_hash(g.strip())}
for g in genres_list
]
self.genrehashes = [g["genrehash"] for g in self.genres]
self.recreate_hash()
self.set_created_date()
def recreate_hash(self):
def recreate_trackhash(self):
"""
Recreates a track hash if the track title was altered
to prevent duplicate tracks having different hashes.
Recreates the trackhash based on the current title, album, and artist information.
"""
if self.og_title == self.title and self.og_album == self.album:
return
self.trackhash = create_hash(
", ".join(a.name for a in self.artists), self.og_album, self.title
self.title, self.album, *(artist["name"] for artist in self.artists)
)
def recreate_artists_hash(self):
"""
Recreates a track's artist hashes if the artist list was altered
"""
self.artist_hashes = "-".join(a.artisthash for a in self.artists)
def recreate_albumhash(self):
"""
Recreates an albumhash of a track to merge all versions of an album.
"""
albumartists = (a.name for a in self.albumartists)
self.albumhash = create_hash(self.album, *albumartists)
def rename_album(self, new_album: str):
"""
Renames an album
"""
self.album = new_album
def add_artists(self, artists: list[str], new_album_title: str):
for artist in artists:
if create_hash(artist, decode=True) not in self.artist_hashes:
self.artists.append(ArtistMinimal(artist))
self.recreate_artists_hash()
self.rename_album(new_album_title)
+5 -9
View File
@@ -5,19 +5,15 @@ import json
@dataclass(slots=True)
class User:
id: int
username: str
firstname: str
lastname: str
password: str
email: str
image: str
password: str
username: str
roles: list[str]
extra: dict[str, str] = field(default_factory=dict)
# NOTE: roles: ['admin', 'user', 'curator']
roles: list[str] = field(default_factory=lambda: ["user"])
def __post_init__(self):
self.roles = json.loads(self.roles)
def todict(self):
this_dict = asdict(self)
del this_dict["password"]
@@ -28,5 +24,5 @@ class User:
return {
"id": self.id,
"username": self.username,
"firstname": self.firstname,
"firstname": self.extra["firstname"] if self.extra else "",
}
+21 -24
View File
@@ -1,38 +1,35 @@
"""
This module contains functions for the server
"""
import time
from app.lib.populate import Populate, PopulateCancelledError
from app.settings import SessionVarKeys, get_flag, get_scan_sleep_time
from app.config import UserConfig
from app.lib.populate import PopulateCancelledError
from app.utils.generators import get_random_str
from app.utils.threading import background
from app.logger import log
@background
def run_periodic_scans():
"""
Runs periodic scans.
Periodic scans are checks that run every few minutes
in the background to do stuff like:
- checking for new music
- delete deleted entries
- downloading artist images, and other data.
"""
# ValidateAlbumThumbs()
# ValidatePlaylistThumbs()
# @background
# def run_periodic_scans():
# """
# Runs periodic scans.
run_periodic_scan = True
# Periodic scans are checks that run every few minutes
# in the background to do stuff like:
# - checking for new music
# - delete deleted entries
# - downloading artist images, and other data.
# """
# # ValidateAlbumThumbs()
# # ValidatePlaylistThumbs()
while run_periodic_scan:
run_periodic_scan = get_flag(SessionVarKeys.DO_PERIODIC_SCANS)
# while UserConfig().enablePeriodicScans:
try:
Populate(instance_key=get_random_str())
except PopulateCancelledError:
log.error("'run_periodic_scans': Periodic scan cancelled.")
pass
# try:
# except PopulateCancelledError:
# log.error("'run_periodic_scans': Periodic scan cancelled.")
# pass
sleep_time = get_scan_sleep_time()
time.sleep(sleep_time)
# time.sleep(UserConfig().scanInterval)
+3 -5
View File
@@ -7,7 +7,7 @@ from typing import List, Optional
import requests
from unidecode import unidecode
from app.db.sqlite.plugins import PluginsMethods
from app.db.userdata import PluginTable
from app.plugins import Plugin, plugin_method
from app.settings import Paths
@@ -190,15 +190,13 @@ class LyricsProvider(LRCProvider):
class Lyrics(Plugin):
def __init__(self) -> None:
plugin = PluginsMethods.get_plugin_by_name("lyrics_finder")
plugin = PluginTable.get_by_name("lyrics_finder")
if not plugin:
return
name = plugin.name
description = plugin.description
super().__init__(name, description)
super().__init__(name, "Musixmatch lyrics finder")
self.provider = LyricsProvider()
+15 -2
View File
@@ -1,5 +1,18 @@
from app.db.sqlite.plugins import PluginsMethods
from app.db.userdata import PluginTable
from sqlalchemy.exc import IntegrityError
def register_plugins():
PluginsMethods.insert_lyrics_plugin()
try:
PluginTable.insert_one(
{
"name": "lyrics_finder",
"active": False,
"settings": {"auto_download": False},
"extra": {
"description": "Find lyrics from the internet",
},
}
)
except IntegrityError:
pass
+14 -3
View File
@@ -7,6 +7,7 @@ import urllib.parse
import requests
from requests import ConnectionError, HTTPError, ReadTimeout
from app.models.lastfm import SimilarArtistEntry
from app.utils.hashing import create_hash
@@ -20,7 +21,7 @@ def fetch_similar_artists(name: str):
response = requests.get(url, timeout=10)
response.raise_for_status()
except (ConnectionError, ReadTimeout, HTTPError):
return []
return None
data = response.json()
@@ -29,5 +30,15 @@ def fetch_similar_artists(name: str):
except KeyError:
return []
for artist in artists:
yield create_hash(artist["name"])
return [
SimilarArtistEntry(
**{
"artisthash": create_hash(artist["name"]),
"name": artist["name"],
"weight": artist["weight"],
"listeners": int(artist["listeners"]),
"scrobbles": int(artist["scrobbles"]),
}
)
for artist in artists
]
+11
View File
@@ -23,10 +23,21 @@ def serialize_for_card(album: Album):
props_to_remove = {
"duration",
"count",
"artisthashes",
"albumartists_hashes",
"created_date",
"og_title",
"base_title",
"genres",
"playcount",
"trackcount",
"type",
"playduration",
"genrehashes",
"fav_userids",
"extra",
"id",
"lastplayed",
}
return album_serializer(album, props_to_remove)
+10
View File
@@ -14,6 +14,16 @@ def serialize_for_card(artist: Artist):
"trackcount",
"duration",
"albumcount",
"playcount",
"playduration",
"playcount",
"lastplayed",
"id",
"genres",
"genrehashes",
"extra",
"created_date",
"date",
}
for key in props_to_remove:
+8 -2
View File
@@ -3,7 +3,7 @@ from dataclasses import asdict
from app.models.track import Track
def serialize_track(track: Track, to_remove: set = {}, remove_disc=True) -> dict:
def serialize_track(track: Track, to_remove: set = set(), remove_disc=True) -> dict:
album_dict = asdict(track)
# is_favorite @property is not included in asdict
album_dict["is_favorite"] = track.is_favorite
@@ -20,6 +20,12 @@ def serialize_track(track: Track, to_remove: set = {}, remove_disc=True) -> dict
"artist_hashes",
"created_date",
"fav_userids",
"playcount",
"genrehashes",
"id",
"lastplayed",
"playduration",
"genres",
}.union(to_remove)
if not remove_disc:
@@ -41,6 +47,6 @@ def serialize_track(track: Track, to_remove: set = {}, remove_disc=True) -> dict
def serialize_tracks(
tracks: list[Track], _remove: set = {}, remove_disc=True
tracks: list[Track], _remove: set = set(), remove_disc=True
) -> list[dict]:
return [serialize_track(t, _remove, remove_disc) for t in tracks]
+2 -28
View File
@@ -5,7 +5,6 @@ Contains default configs
import os
import subprocess
import sys
from typing import Any
from app import configs
@@ -126,7 +125,7 @@ class Defaults:
SM_ARTIST_IMG_SIZE = 128
MD_ARTIST_IMG_SIZE = 256
HASH_LENGTH = 10
HASH_LENGTH = 16
API_ALBUMHASH = "bfe300e966"
API_ARTISTHASH = "cae59f1fc5"
API_TRACKHASH = "0853280a12"
@@ -141,7 +140,7 @@ SUPPORTED_FILES = tuple(f".{file}" for file in FILES)
# ===== SQLite =====
class Db:
class DbPaths:
APP_DB_NAME = "swing.db"
USER_DATA_DB_NAME = "userdata.db"
@@ -231,31 +230,6 @@ class SessionVars:
SHOW_ALBUMS_AS_SINGLES = False
# TODO: Find a way to eliminate this class without breaking typings
class SessionVarKeys:
EXTRACT_FEAT = "EXTRACT_FEAT"
REMOVE_PROD = "REMOVE_PROD"
CLEAN_ALBUM_TITLE = "CLEAN_ALBUM_TITLE"
REMOVE_REMASTER_FROM_TRACK = "REMOVE_REMASTER_FROM_TRACK"
DO_PERIODIC_SCANS = "DO_PERIODIC_SCANS"
PERIODIC_SCAN_INTERVAL = "PERIODIC_SCAN_INTERVAL"
MERGE_ALBUM_VERSIONS = "MERGE_ALBUM_VERSIONS"
ARTIST_SEPARATORS = "ARTIST_SEPARATORS"
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:
"""
Terminal colors
+20 -14
View File
@@ -2,12 +2,19 @@
Prepares the server for use.
"""
from time import time
import uuid
from app.db.sqlite.settings import load_settings
from app.lib.mapstuff import (
map_album_colors,
map_artist_colors,
map_favorites,
map_scrobble_data,
)
from app.setup.files import create_config_dir
from app.setup.sqlite import run_migrations, setup_sqlite
from app.store.albums import AlbumStore
from app.store.artists import ArtistStore
from app.store.folder import FolderStore
from app.store.tracks import TrackStore
from app.utils.generators import get_random_str
from app.config import UserConfig
@@ -23,26 +30,25 @@ def run_setup():
config = UserConfig()
config.setup_config_file()
if not config.userId:
config.userId = str(uuid.uuid4())
if not config.serverId:
config.serverId = str(uuid.uuid4())
setup_sqlite()
run_migrations()
try:
load_settings()
except IndexError:
# settings table is empty
pass
def load_into_mem():
"""
Load all tracks, albums, and artists into memory.
"""
instance_key = get_random_str()
# INFO: Load all tracks, albums, and artists data into memory
key = str(time())
TrackStore.load_all_tracks(get_random_str())
AlbumStore.load_albums(key)
ArtistStore.load_artists(key)
FolderStore.load_filepaths()
# INFO: Load all tracks, albums, and artists into memory
TrackStore.load_all_tracks(instance_key)
AlbumStore.load_albums(instance_key)
ArtistStore.load_artists(instance_key)
map_scrobble_data()
map_favorites()
map_artist_colors()
map_album_colors()
+17 -16
View File
@@ -3,10 +3,14 @@ Module to setup Sqlite databases and tables.
Applies migrations.
"""
from app.db.sqlite import create_connection, create_tables, queries
from app.db.sqlite.auth import SQLiteAuthMethods as authdb
from sqlalchemy import create_engine
from app.db.userdata import UserTable
from app.migrations import apply_migrations
from app.settings import Db
from app.settings import DbPaths
from app.db.engine import DbEngine
from app.db import create_all_tables
from app.db.libdata import create_all as create_user_tables
def run_migrations():
@@ -20,18 +24,15 @@ def setup_sqlite():
"""
Create Sqlite databases and tables.
"""
# if os.path.exists(DB_PATH):
# os.remove(DB_PATH)
DbEngine.engine = create_engine(
f"sqlite+pysqlite:///{DbPaths.get_app_db_path()}",
echo=False,
max_overflow=20,
pool_size=10,
)
app_db_conn = create_connection(Db.get_app_db_path())
user_db_conn = create_connection(Db.get_userdata_db_path())
create_all_tables()
create_user_tables()
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()
if not UserTable.get_all():
UserTable.insert_default_user()
+106 -79
View File
@@ -1,9 +1,14 @@
from itertools import groupby
import json
from pprint import pprint
import random
from typing import Iterable
from app.db.sqlite.albumcolors import SQLiteAlbumMethods as aldb
from app.lib.tagger import create_albums
from app.models import Album, Track
from app.store.artists import ArtistStore
from app.utils import flatten
from app.utils.auth import get_current_userid
from app.utils.customlist import CustomList
from app.utils.remove_duplicates import remove_duplicates
@@ -14,19 +19,44 @@ from app.utils.progressbar import tqdm
ALBUM_LOAD_KEY = ""
class AlbumStore:
albums: list[Album] = CustomList()
class AlbumMapEntry:
def __init__(self, album: Album, trackhashes: set[str]) -> None:
self.album = album
self.trackhashes = trackhashes
@staticmethod
def create_album(track: Track):
"""
Creates album object from a track
"""
return Album(
albumhash=track.albumhash,
albumartists=track.albumartists, # type: ignore
title=track.og_album,
)
@property
def basetitle(self):
return self.album.base_title
def increment_playcount(self, duration: int, timestamp: int):
self.album.lastplayed = timestamp
self.album.playduration += duration
self.album.playcount += 1
def toggle_favorite_user(self, userid: int | None = None):
if userid is None:
userid = get_current_userid()
self.album.toggle_favorite_user(userid)
def set_color(self, color: str):
self.album.color = color
class AlbumStore:
# albums: list[Album] = CustomList()
albummap: dict[str, AlbumMapEntry] = {}
# @staticmethod
# def create_album(track: Track):
# """
# Creates album object from a track
# """
# return Album(
# albumhash=track.albumhash,
# albumartists=track.albumartists, # type: ignore
# title=track.og_album,
# )
@classmethod
def load_albums(cls, instance_key: str):
@@ -36,46 +66,27 @@ class AlbumStore:
global ALBUM_LOAD_KEY
ALBUM_LOAD_KEY = instance_key
cls.albums = CustomList()
print("Loading albums... ", end="")
tracks = remove_duplicates(TrackStore.tracks)
tracks = sorted(tracks, key=lambda t: t.albumhash)
grouped = groupby(tracks, lambda t: t.albumhash)
for albumhash, tracks in grouped:
tracks = list(tracks)
sample = tracks[0]
if sample is None:
continue
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(sample)
album.get_date_from_tracks(tracks)
album.set_count(count)
album.set_duration(duration)
album.set_created_date(created_date)
cls.albums.append(album)
db_albums: list[tuple] = aldb.get_all_albums()
for album in db_albums:
albumhash = album[1]
colors = json.loads(album[2])
for _al in cls.albums:
if _al.albumhash == albumhash:
_al.set_colors(colors)
break
cls.albummap = {
album.albumhash: AlbumMapEntry(album=album, trackhashes=trackhashes)
for album, trackhashes in create_albums()
}
print("Done!")
@classmethod
def index_new_album(cls, album: Album, trackhashes: set[str]):
cls.albummap[album.albumhash] = AlbumMapEntry(
album=album, trackhashes=trackhashes
)
@classmethod
def get_flat_list(cls):
"""
Returns a flat list of all albums.
"""
return [a.album for a in cls.albummap.values()]
@classmethod
def add_album(cls, album: Album):
"""
@@ -98,9 +109,7 @@ class AlbumStore:
Returns N albums by the given albumartist, excluding the specified album.
"""
albums = [
album for album in cls.albums if artisthash in album.albumartists_hashes
]
albums = [album for album in cls.albums if artisthash in album.artisthashes]
albums = [
album
@@ -119,32 +128,16 @@ class AlbumStore:
"""
Returns an album by its hash.
"""
for album in cls.albums:
if album.albumhash == albumhash:
return album
return None
entry = cls.albummap.get(albumhash)
if entry is not None:
return entry.album
@classmethod
def get_albums_by_hashes(cls, albumhashes: list[str]) -> list[Album]:
def get_albums_by_hashes(cls, albumhashes: Iterable[str]) -> list[Album]:
"""
Returns albums by their hashes.
"""
albums_str = "-".join(albumhashes)
albums = [a for a in cls.albums if a.albumhash in albums_str]
# sort albums by the order of the hashes
albums.sort(key=lambda x: albumhashes.index(x.albumhash))
return albums
@classmethod
def get_albums_by_artisthash(cls, artisthash: str) -> list[Album]:
"""
Returns all albums by the given artist.
"""
return [
album for album in cls.albums if artisthash in album.albumartists_hashes
]
return [cls.albummap[albumhash].album for albumhash in albumhashes]
@classmethod
def count_albums_by_artisthash(cls, artisthash: str):
@@ -154,12 +147,12 @@ class AlbumStore:
master_string = "-".join(a.albumartists_hashes for a in cls.albums)
return master_string.count(artisthash)
@classmethod
def album_exists(cls, albumhash: str) -> bool:
"""
Checks if an album exists.
"""
return albumhash in "-".join([a.albumhash for a in cls.albums])
# @classmethod
# def album_exists(cls, albumhash: str) -> bool:
# """
# Checks if an album exists.
# """
# return albumhash in "-".join([a.albumhash for a in cls.albums])
@classmethod
def remove_album(cls, album: Album):
@@ -174,3 +167,37 @@ class AlbumStore:
Removes an album from the store.
"""
cls.albums = CustomList(a for a in cls.albums if a.albumhash != albumhash)
@classmethod
def get_albums_by_artisthash(cls, hash: str):
"""
Returns all albums by the given artist hash.
"""
artist = ArtistStore.artistmap.get(hash)
if not artist:
return []
return [cls.albummap[albumhash].album for albumhash in artist.albumhashes]
@classmethod
def get_albums_by_artisthashes(cls, hashes: Iterable[str]):
"""
Returns all albums by the given artist hashes.
"""
albums = []
for hash in hashes:
albums.extend(cls.get_albums_by_artisthash(hash))
return albums
@classmethod
def get_album_tracks(cls, albumhash: str) -> list[Track]:
"""
Returns all tracks for the given album hash.
"""
album = cls.albummap.get(albumhash)
if not album:
return []
return TrackStore.get_tracks_by_trackhashes(album.trackhashes)
+119 -71
View File
@@ -1,23 +1,43 @@
import json
from typing import Iterable
from app.db.sqlite.artistcolors import SQLiteArtistMethods as ardb
from app.lib.artistlib import get_all_artists
from app.lib.tagger import create_artists
from app.models import Artist
from app.utils.bisection import use_bisection
from app.utils.auth import get_current_userid
from app.utils.customlist import CustomList
from app.utils.progressbar import tqdm
from .albums import AlbumStore
from .tracks import TrackStore
ARTIST_LOAD_KEY = ""
class ArtistMapEntry:
def __init__(
self, artist: Artist, albumhashes: set[str], trackhashes: set[str]
) -> None:
self.artist = artist
self.albumhashes: set[str] = albumhashes
self.trackhashes: set[str] = trackhashes
def increment_playcount(self, duration: int, timestamp: int):
self.artist.lastplayed = timestamp
self.artist.playduration += duration
self.artist.playcount += 1
def toggle_favorite_user(self, userid: int | None = None):
if userid is None:
userid = get_current_userid()
self.artist.toggle_favorite_user(userid)
def set_color(self, color: str):
self.artist.color = color
class ArtistStore:
artists: list[Artist] = CustomList()
artistmap: dict[str, ArtistMapEntry] = {}
@classmethod
def load_artists(cls, instance_key: str):
def load_artists(cls, instance_key: str, _trackhashes: list[str] = []):
"""
Loads all artists from the database into the store.
"""
@@ -25,92 +45,120 @@ class ArtistStore:
ARTIST_LOAD_KEY = instance_key
print("Loading artists... ", end="")
cls.artists.clear()
cls.artistmap.clear()
cls.artistmap = {
artist.artisthash: ArtistMapEntry(
artist=artist, albumhashes=albumhashes, trackhashes=trackhashes
)
for artist, trackhashes, albumhashes in create_artists(_trackhashes)
}
# for track in TrackStore.get_flat_list():
# if instance_key != ARTIST_LOAD_KEY:
# return
# for hash in track.artisthashes:
# cls.artistmap[hash].trackhashes.add(track.trackhash)
# cls.artistmap[hash].albumhashes.add(track.albumhash)
cls.artists.extend(get_all_artists(TrackStore.tracks, AlbumStore.albums))
print("Done!")
for artist in ardb.get_all_artists():
if instance_key != ARTIST_LOAD_KEY:
return
# for artist in ardb.get_all_artists():
# if instance_key != ARTIST_LOAD_KEY:
# return
cls.map_artist_color(artist)
# cls.map_artist_color(artist)
@classmethod
def map_artist_color(cls, artist_tuple: tuple):
def get_flat_list(cls):
"""
Maps a color to the corresponding artist.
Returns a flat list of all artists.
"""
return [a.artist for a in cls.artistmap.values()]
artisthash = artist_tuple[1]
color = json.loads(artist_tuple[2])
# @classmethod
# def map_artist_color(cls, artist_tuple: tuple):
# """
# Maps a color to the corresponding artist.
# """
for artist in cls.artists:
if artist.artisthash == artisthash:
artist.set_colors(color)
break
# artisthash = artist_tuple[1]
# color = json.loads(artist_tuple[2])
# for artist in cls.artists:
# if artist.artisthash == artisthash:
# artist.set_colors(color)
# break
# @classmethod
# def add_artist(cls, artist: Artist):
# """
# Adds an artist to the store.
# """
# cls.artists.append(artist)
# @classmethod
# def add_artists(cls, artists: list[Artist]):
# """
# Adds multiple artists to the store.
# """
# for artist in artists:
# if artist not in cls.artists:
# cls.artists.append(artist)
@classmethod
def add_artist(cls, artist: Artist):
"""
Adds an artist to the store.
"""
cls.artists.append(artist)
@classmethod
def add_artists(cls, artists: list[Artist]):
"""
Adds multiple artists to the store.
"""
for artist in artists:
if artist not in cls.artists:
cls.artists.append(artist)
@classmethod
def get_artist_by_hash(cls, artisthash: str) -> Artist:
def get_artist_by_hash(cls, artisthash: str):
"""
Returns an artist by its hash.P
"""
artists = sorted(cls.artists, key=lambda x: x.artisthash)
try:
artist = use_bisection(artists, "artisthash", [artisthash])[0]
return artist
except IndexError:
return None
entry = cls.artistmap.get(artisthash, None)
if entry is not None:
return entry.artist
@classmethod
def get_artists_by_hashes(cls, artisthashes: list[str]) -> list[Artist]:
def get_artists_by_hashes(cls, artisthashes: Iterable[str]):
"""
Returns artists by their hashes.
"""
artists = sorted(cls.artists, key=lambda x: x.artisthash)
artists = use_bisection(artists, "artisthash", artisthashes)
artists = [cls.get_artist_by_hash(hash) for hash in artisthashes]
return [a for a in artists if a is not None]
@classmethod
def artist_exists(cls, artisthash: str) -> bool:
"""
Checks if an artist exists.
"""
return artisthash in "-".join([a.artisthash for a in cls.artists])
# @classmethod
# def artist_exists(cls, artisthash: str) -> bool:
# """
# Checks if an artist exists.
# """
# return artisthash in "-".join([a.artisthash for a in cls.artists])
# @classmethod
# def artist_has_tracks(cls, artisthash: str) -> bool:
# """
# Checks if an artist has tracks.
# """
# artists: set[str] = set()
# for track in TrackStore.tracks:
# artists.update(track.artist_hashes)
# album_artists: list[str] = [a.artisthash for a in track.albumartists]
# artists.update(album_artists)
# master_hash = "-".join(artists)
# return artisthash in master_hash
# @classmethod
# def remove_artist_by_hash(cls, artisthash: str):
# """
# Removes an artist from the store.
# """
# cls.artists = CustomList(a for a in cls.artists if a.artisthash != artisthash)
@classmethod
def artist_has_tracks(cls, artisthash: str) -> bool:
def get_artist_tracks(cls, artisthash: str):
"""
Checks if an artist has tracks.
Returns all tracks by the given artist hash.
"""
artists: set[str] = set()
entry = cls.artistmap.get(artisthash)
if entry is not None:
return TrackStore.get_tracks_by_trackhashes(entry.trackhashes)
for track in TrackStore.tracks:
artists.update(track.artist_hashes)
album_artists: list[str] = [a.artisthash for a in track.albumartists]
artists.update(album_artists)
master_hash = "-".join(artists)
return artisthash in master_hash
@classmethod
def remove_artist_by_hash(cls, artisthash: str):
"""
Removes an artist from the store.
"""
cls.artists = CustomList(a for a in cls.artists if a.artisthash != artisthash)
return []
+115
View File
@@ -0,0 +1,115 @@
from sortedcontainers import SortedSet
from concurrent.futures import ThreadPoolExecutor
from app.db.libdata import TrackTable
from app.store.tracks import TrackStore
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()
map: dict[str, str] = {}
"""
The map above is a dictionary that maps the folder path to the track hash, which can be used to fetch the track from the track store (a dict of track hashes to track objects).
"""
@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)
cls.map[track.filepath] = track.trackhash
@classmethod
def get_tracks_by_filepaths(cls, filepaths: list[str]):
for filepath in filepaths:
trackhash = cls.map.get(filepath)
if trackhash:
trackgroup = TrackStore.trackhashmap.get(trackhash)
if trackgroup is None:
continue
for track in trackgroup.tracks:
if track.filepath == filepath:
yield track
@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)
+213 -85
View File
@@ -1,18 +1,93 @@
# from tqdm import tqdm
from flask_jwt_extended import current_user
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.db.sqlite.tracks import SQLiteTrackMethods as trackdb
import itertools
from typing import Callable, Iterable
from app.db.libdata import TrackTable
from app.models import Track
from app.utils.bisection import use_bisection
from app.utils.customlist import CustomList
from app.utils.auth import get_current_userid
from app.utils.remove_duplicates import remove_duplicates
TRACKS_LOAD_KEY = ""
class TrackGroup:
"""
Tracks grouped under the same trackhash.
"""
def __init__(self, tracks: list[Track]):
self.tracks = tracks
def append(self, track: Track):
"""
Adds a track to the group.
"""
self.tracks.append(track)
def remove(self, track: Track):
"""
Removes a track from the group.
"""
self.tracks.remove(track)
def increment_playcount(self, duration: int, timestamp: int):
"""
Increments the playcount of all tracks in the group.
"""
for track in self.tracks:
track.playcount += 1
track.lastplayed = timestamp
track.playduration += duration
def toggle_favorite_user(self, userid: int | None = None):
"""
Adds or removes a user from the list of users who have favorited the track.
"""
if userid is None:
userid = get_current_userid()
for track in self.tracks:
track.toggle_favorite_user(userid)
def get_best(self):
"""
Returns the track with higest bitrate.
"""
return max(self.tracks, key=lambda x: x.bitrate)
def __len__(self):
return len(self.tracks)
class classproperty(property):
"""
A class property decorator.
"""
def __get__(self, owner_self, owner_cls):
if self.fget:
return self.fget(owner_cls)
class TrackStore:
tracks: list[Track] = CustomList()
# {'trackhash': Track[]}
trackhashmap: dict[str, TrackGroup] = dict()
@classproperty
def tracks(cls) -> list[Track]:
return cls.get_flat_list()
@classmethod
def get_flat_list(cls):
"""
Returns a flat list of all tracks.
"""
return list(
itertools.chain.from_iterable(
[group.tracks for group in cls.trackhashmap.values()]
)
)
@classmethod
def load_all_tracks(cls, instance_key: str):
@@ -24,32 +99,31 @@ class TrackStore:
global TRACKS_LOAD_KEY
TRACKS_LOAD_KEY = instance_key
cls.tracks = CustomList(trackdb.get_all_tracks())
cls.trackhashmap = dict()
tracks = TrackTable.get_all()
favs = favdb.get_fav_tracks()
records = dict()
for fav in favs:
r = records.setdefault(fav[1], set())
r.add(fav[4])
for track in cls.tracks:
# INFO: Load all tracks into the dict store
for track in tracks:
if instance_key != TRACKS_LOAD_KEY:
return
userids = records.get(track.trackhash, set())
track.fav_userids = list(userids)
print("Done!")
exists = cls.trackhashmap.get(track.trackhash, None)
if not exists:
cls.trackhashmap[track.trackhash] = TrackGroup([track])
else:
cls.trackhashmap[track.trackhash].append(track)
@classmethod
def add_track(cls, track: Track):
"""
Adds a single track to the store.
"""
group = cls.trackhashmap.get(track.trackhash, None)
cls.tracks.append(track)
if group:
return group.append(track)
cls.trackhashmap[track.trackhash] = TrackGroup([track])
@classmethod
def add_tracks(cls, tracks: list[Track]):
@@ -57,17 +131,21 @@ class TrackStore:
Adds multiple tracks to the store.
"""
cls.tracks.extend(tracks)
for track in tracks:
cls.add_track(track)
@classmethod
def remove_track_obj(cls, track: Track):
def remove_track(cls, track: Track):
"""
Removes a single track from the store.
"""
try:
cls.tracks.remove(track)
except ValueError:
pass
group = cls.trackhashmap.get(track.trackhash, None)
if group:
group.remove(track)
if len(group) == 0:
del cls.trackhashmap[track.trackhash]
@classmethod
def remove_track_by_filepath(cls, filepath: str):
@@ -75,10 +153,7 @@ class TrackStore:
Removes a track from the store by its filepath.
"""
for track in cls.tracks:
if track.filepath == filepath:
cls.remove_track_obj(track)
break
return cls.remove_tracks_by_filepaths({filepath})
@classmethod
def remove_tracks_by_filepaths(cls, filepaths: set[str]):
@@ -86,70 +161,54 @@ class TrackStore:
Removes multiple tracks from the store by their filepaths.
"""
for track in cls.tracks:
if track.filepath in filepaths:
cls.remove_track_obj(track)
filecount = len(filepaths)
for trackhash in cls.trackhashmap:
group = cls.trackhashmap[trackhash]
for track in group.tracks:
if track.filepath in filepaths:
group.remove(track)
if len(group) == 0:
del cls.trackhashmap[trackhash]
filecount -= 1
if filecount == 0:
break
@classmethod
def count_tracks_by_trackhash(cls, trackhash: str) -> int:
"""
Counts the number of tracks with a specific trackhash.
"""
return sum(1 for track in cls.tracks if track.trackhash == trackhash)
@classmethod
def make_track_fav(cls, trackhash: str):
"""
Adds a track to the favorites.
"""
for track in cls.tracks:
if track.trackhash == trackhash:
if current_user["id"] not in track.fav_userids:
track.fav_userids.append(current_user["id"])
@classmethod
def remove_track_from_fav(cls, trackhash: str):
"""
Removes a track from the favorites.
"""
for track in cls.tracks:
if track.trackhash == trackhash:
if current_user["id"] in track.fav_userids:
track.fav_userids.remove(current_user["id"])
@classmethod
def append_track_artists(
cls, albumhash: str, artists: list[str], new_album_title: str
):
tracks = cls.get_tracks_by_albumhash(albumhash)
for track in tracks:
track.add_artists(artists, new_album_title)
return len(cls.trackhashmap.get(trackhash, []))
# ================================================
# ================== GETTERS =====================
# ================================================
@classmethod
def get_tracks_by_trackhashes(cls, trackhashes: list[str]) -> list[Track]:
def get_tracks_by_trackhashes(cls, trackhashes: Iterable[str]) -> list[Track]:
"""
Returns a list of tracks by their hashes.
"""
hash_set = set(trackhashes)
set_len = len(hash_set)
tracks = []
for track in cls.tracks:
if track.trackhash in hash_set:
tracks: list[Track] = []
for trackhash in hash_set:
group = cls.trackhashmap.get(trackhash, None)
if group:
track = group.get_best()
tracks.append(track)
if len(tracks) == set_len:
break
# sort the tracks in the order of the given trackhashes
tracks.sort(key=lambda t: trackhashes.index(t.trackhash))
if type(trackhashes) == list:
tracks.sort(key=lambda t: trackhashes.index(t.trackhash))
return tracks
@classmethod
@@ -157,31 +216,100 @@ class TrackStore:
"""
Returns all tracks matching the given paths.
"""
tracks = sorted(cls.tracks, key=lambda x: x.filepath)
tracks = use_bisection(tracks, "filepath", paths)
return [track for track in tracks if track is not None]
# tracks = sorted(cls.trackhashmap, key=lambda x: x.filepath)
# tracks = use_bisection(tracks, "filepath", paths)
# return [track for track in tracks if track is not None]
# return cls.find_tracks_by(key="filepath", value=paths)
tracks: list[Track] = []
for trackhash in cls.trackhashmap:
group = cls.trackhashmap.get(trackhash)
if not group:
continue
for track in group.tracks:
if track.filepath in paths:
tracks.append(track)
return tracks
@classmethod
def find_tracks_by(
cls,
key: str,
value: str,
predicate: Callable = lambda prop_value, value: prop_value == value,
including_duplicates: bool = False,
):
"""
Find all tracks by a specific key.
"""
tracks: list[Track] = []
for trackhash in cls.trackhashmap:
group = cls.trackhashmap.get(trackhash, None)
if not group:
continue
for track in group.tracks:
prop_value = getattr(track, key)
if predicate(prop_value, value):
tracks.append(track)
if including_duplicates:
return tracks
return remove_duplicates(tracks)
@classmethod
def get_tracks_by_albumhash(cls, album_hash: str) -> list[Track]:
"""
Returns all tracks matching the given album hash.
"""
tracks = [t for t in cls.tracks if t.albumhash == album_hash]
return remove_duplicates(tracks, is_album_tracks=True)
return cls.find_tracks_by(key="albumhash", value=album_hash)
@classmethod
def get_tracks_by_artisthash(cls, artisthash: str):
"""
Returns all tracks matching the given artist. Duplicate tracks are removed.
"""
tracks = [t for t in cls.tracks if artisthash in t.artist_hashes]
tracks = remove_duplicates(tracks)
tracks.sort(key=lambda x: x.last_mod)
return tracks
predicate = lambda artisthashes, artisthash: artisthash in artisthashes
return cls.find_tracks_by(
key="artisthashes", value=artisthash, predicate=predicate
)
@classmethod
def get_tracks_in_path(cls, path: str):
"""
Returns all tracks in the given path.
"""
return (t for t in cls.tracks if t.folder.startswith(path))
predicate: Callable[[str, str], bool] = (
lambda track_folder, path: track_folder.startswith(path)
)
return cls.find_tracks_by(
key="folder",
value=path,
predicate=predicate,
including_duplicates=True,
)
@classmethod
def get_recently_added(cls, start: int, limit: int | None):
"""
Returns the most recently added tracks.
"""
tracks = cls.get_flat_list()
if limit is None:
return sorted(tracks, key=lambda x: x.last_mod, reverse=True)[start:]
return sorted(tracks, key=lambda x: x.last_mod, reverse=True)[start:limit]
@classmethod
def get_recently_played(cls, limit: int):
tracks = cls.get_flat_list()
return sorted(tracks, key=lambda x: x.lastplayed, reverse=True)[:limit]
+10
View File
@@ -1,4 +1,7 @@
import locale
from typing import Iterable, TypeVar
T = TypeVar("T")
# Set to user's default locale:
locale.setlocale(locale.LC_ALL, "")
@@ -9,3 +12,10 @@ locale.setlocale(locale.LC_ALL, "")
def format_number(number: float) -> str:
return locale.format_string("%d", number, grouping=True)
def flatten(list_: Iterable[list[T]]) -> list[T]:
"""
Flattens a list of lists into a single list.
"""
return [item for sublist in list_ for item in sublist]
+14 -2
View File
@@ -1,6 +1,8 @@
import hmac
import hashlib
from flask_jwt_extended import current_user
from app.config import UserConfig
@@ -12,9 +14,8 @@ def hash_password(password: str) -> str:
:return: The hashed password.
"""
return hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), UserConfig().userId.encode("utf-8"), 100000
"sha256", password.encode("utf-8"), UserConfig().serverId.encode("utf-8"), 100000
).hex()
@@ -29,3 +30,14 @@ def check_password(password: str, hashed: str) -> bool:
"""
return hmac.compare_digest(hash_password(password), hashed)
def get_current_userid() -> int:
"""
Get the current session user.
"""
try:
return current_user["id"]
except RuntimeError:
# Catch this error raised during migration execution
return 1
+2 -2
View File
@@ -26,7 +26,7 @@ def create_new_date(date: datetime = None) -> str:
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.
"""
@@ -63,4 +63,4 @@ def seconds_to_time_string(seconds):
if minutes > 0:
return f"{minutes} minute{'s' if minutes > 1 else ''}"
return f"{remaining_seconds} second{'s' if remaining_seconds > 1 else ''}"
return f"{remaining_seconds} second{'' if remaining_seconds == 1 else 's'}"
+9 -6
View File
@@ -1,4 +1,5 @@
import hashlib
import xxhash
from unidecode import unidecode
@@ -32,10 +33,12 @@ def create_hash(*args: str, decode=False, limit=10) -> str:
str_ = unidecode(str_)
str_ = str_.encode("utf-8")
str_ = hashlib.sha1(str_).hexdigest()
return xxhash.xxh3_64(str_).hexdigest()
# str_ = hashlib.sha1(str_).hexdigest()
return (
str_[: limit // 2] + str_[-limit // 2 :]
if limit % 2 == 0
else str_[: limit // 2] + str_[-limit // 2 - 1 :]
)
# INFO: Return first 5 + last 5 characters
# return (
# str_[: limit // 2] + str_[-limit // 2 :]
# if limit % 2 == 0
# else str_[: limit // 2] + str_[-limit // 2 - 1 :]
# )
+44 -11
View File
@@ -1,21 +1,54 @@
import re
from app.config import UserConfig
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, config: UserConfig):
"""
Splits a string of artists into a list of artists.
Splits a string of artists into a list of artists, preserving those in ignoreList.
Case-insensitive matching is used for the ignoreList.
"""
separators: set = get_flag(SessionVarKeys.ARTIST_SEPARATORS)
for sep in separators:
src = src.replace(sep, ",")
result = []
current = ""
i = 0
artists = src.split(",")
artists = [a.strip() for a in artists]
while i < len(src):
# Check if any ignored artist starts at this position (case-insensitive)
ignored_match = next(
(
src[i : i + len(ignored)]
for ignored in config.artistSplitIgnoreList
if src.lower().startswith(ignored.lower(), i)
),
None,
)
return [a for a in artists if a]
if ignored_match:
# If we have accumulated any current string, add it to result
if current.strip():
result.extend([a.strip() for a in current.split(",") if a.strip()])
current = ""
# Add the ignored artist to the result (preserving original case)
result.append(ignored_match)
# Move past the ignored artist
i += len(ignored_match)
elif src[i] in config.artistSeparators:
# If we encounter a separator, process the current string
if current.strip():
result.extend([a.strip() for a in current.split(",") if a.strip()])
current = ""
i += 1
else:
# If it's not an ignored artist or a separator, add to current
current += src[i]
i += 1
# Process any remaining current string
if current.strip():
result.extend([a.strip() for a in current.split(",") if a.strip()])
return result
def remove_prod(title: str) -> str:
@@ -38,7 +71,7 @@ def remove_prod(title: str) -> str:
return title.strip()
def parse_feat_from_title(title: str) -> tuple[list[str], str]:
def parse_feat_from_title(title: str, config: UserConfig) -> tuple[list[str], str]:
"""
Extracts featured artists from a song title using regex.
"""
@@ -56,7 +89,7 @@ def parse_feat_from_title(title: str) -> tuple[list[str], str]:
return [], title
artists = match.group(1)
artists = split_artists(artists)
artists = split_artists(artists, config)
# remove "feat" group from title
new_title = re.sub(regex, "", title, flags=re.IGNORECASE)
+74
View File
@@ -0,0 +1,74 @@
from sqlalchemy import create_engine, text, Table, Column, Integer, String, MetaData, select
from sqlalchemy.orm import DeclarativeBase
from typing import List, Optional
from sqlalchemy.orm import Mapped, mapped_column, relationship
fullpath = "/home/cwilvx/temp/swingmusic/swing.db"
engine = create_engine(f"sqlite+pysqlite:///{fullpath}", echo=True)
class Base(DeclarativeBase):
pass
class Tracks(Base):
__tablename__ = "tracks"
id: Mapped[int] = mapped_column(primary_key=True)
album: Mapped[str] = mapped_column(String())
albumartist: Mapped[str] = mapped_column(String())
copyright: Mapped[Optional[str]]
def __repr__(self):
return f"<Tracks(album={self.album}, albumartist={self.albumartist})>"
stmt = select(Tracks.album, Tracks.copyright).where(Tracks.album == "RAVAGE")
print(stmt)
with engine.connect() as conn:
result = conn.execute(stmt)
for row in result:
print(row)
# Base.metadata.create_all(engine)
# metadata = MetaData()
# track_table = Table(
# "tracks",
# metadata,
# Column("id", Integer, primary_key=True, autoincrement=True),
# Column("album", String),
# Column("albumartist", String),
# Column("albumhash", String),
# Column("artist", String),
# Column("bitrate", Integer),
# Column("copyright", String),
# Column("date", Integer),
# Column("disc", Integer),
# Column("duration", Integer),
# Column("filepath", String),
# Column("folder", String),
# Column("genre", String),
# Column("title", String),
# Column("track", Integer),
# Column("trackhash", String),
# Column("last_mod", Integer),
# )
# metadata.create_all(engine)
# with engine.connect() as conn:
# result = conn.execute(
# text("SELECT * FROM tracks where trackhash = :trackhash"),
# {"trackhash": "93acbea22b"},
# )
# # print(result.all())
# for r in result.mappings():
# print(r["trackhash"])
+3
View File
@@ -0,0 +1,3 @@
## Streaming
## Transcoding
+21 -10
View File
@@ -21,8 +21,7 @@ import setproctitle
from app.api import create_api
from app.arg_handler import ProcessArgs
from app.lib.watchdogg import Watcher as WatchDog
from app.periodic_scan import run_periodic_scans
from app.lib.index import IndexEverything
from app.plugins.register import register_plugins
from app.settings import FLASKVARS, TCOLOR, Info
from app.setup import load_into_mem, run_setup
@@ -31,8 +30,15 @@ from app.utils.filesystem import get_home_res_path
from app.utils.paths import getClientFilesExtensions
from app.utils.threading import background
mimetypes.add_type("text/css", ".css")
# Load mimetypes for the web client's static files
# Loading mimetypes should happen automatically but
# sometimes the mimetypes are not loaded correctly
# eg. when the Registry is messed up on Windows.
# See the following issues:
# https://github.com/swingmx/swingmusic/issues/137
mimetypes.add_type("text/css", ".css")
mimetypes.add_type("text/javascript", ".js")
mimetypes.add_type("text/plain", ".txt")
mimetypes.add_type("text/html", ".html")
@@ -44,15 +50,18 @@ mimetypes.add_type("image/gif", ".gif")
mimetypes.add_type("font/woff", ".woff")
mimetypes.add_type("application/manifest+json", ".webmanifest")
werkzeug = logging.getLogger("werkzeug")
werkzeug.setLevel(logging.ERROR)
# logging.disable(logging.CRITICAL)
# werkzeug = logging.getLogger("werkzeug")
# werkzeug.setLevel(logging.ERROR)
# # logging.basicConfig()
# logging.getLogger("sqlalchemy.engine").setLevel(logging.ERROR)
# Background tasks
# @background
# def bg_run_setup():
# pass
# run_periodic_scans()
@background
def bg_run_setup():
IndexEverything()
# @background
@@ -63,7 +72,7 @@ werkzeug.setLevel(logging.ERROR)
@background
def run_swingmusic():
log_startup_info()
# bg_run_setup()
bg_run_setup()
register_plugins()
# start_watchdog()
@@ -160,6 +169,8 @@ def serve_client_files(path: str):
gzipped_path = path + ".gz"
user_agent = request.headers.get("User-Agent")
# INFO: Safari doesn't support gzip encoding
# See issue: https://github.com/swingmx/swingmusic/issues/155
is_safari = user_agent.find("Safari") >= 0 and user_agent.find("Chrome") < 0
if is_safari:
Generated
+247 -1
View File
@@ -491,6 +491,23 @@ files = [
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "ffmpeg-python"
version = "0.2.0"
description = "Python bindings for FFmpeg - with complex filtering support"
optional = false
python-versions = "*"
files = [
{file = "ffmpeg-python-0.2.0.tar.gz", hash = "sha256:65225db34627c578ef0e11c8b1eb528bb35e024752f6f10b78c011f6f64c4127"},
{file = "ffmpeg_python-0.2.0-py3-none-any.whl", hash = "sha256:ac441a0404e053f8b6a1113a77c0f452f1cfc62f6344a769475ffdc0f56c23c5"},
]
[package.dependencies]
future = "*"
[package.extras]
dev = ["Sphinx (==2.1.0)", "future (==0.17.1)", "numpy (==1.16.4)", "pytest (==4.6.1)", "pytest-mock (==1.10.4)", "tox (==3.12.1)"]
[[package]]
name = "flask"
version = "2.3.3"
@@ -597,6 +614,17 @@ dotenv = ["python-dotenv"]
email = ["email-validator"]
yaml = ["pyyaml"]
[[package]]
name = "future"
version = "1.0.0"
description = "Clean single-source support for Python 3 and 2"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
{file = "future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216"},
{file = "future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05"},
]
[[package]]
name = "gevent"
version = "23.9.1"
@@ -1120,6 +1148,20 @@ files = [
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
]
[[package]]
name = "memory-profiler"
version = "0.61.0"
description = "A module for monitoring memory usage of a python program"
optional = false
python-versions = ">=3.5"
files = [
{file = "memory_profiler-0.61.0-py3-none-any.whl", hash = "sha256:400348e61031e3942ad4d4109d18753b2fb08c2f6fb8290671c5513a34182d84"},
{file = "memory_profiler-0.61.0.tar.gz", hash = "sha256:4e5b73d7864a1d1292fb76a03e82a3e78ef934d06828a698d9dada76da2067b0"},
]
[package.dependencies]
psutil = "*"
[[package]]
name = "msgpack"
version = "1.0.7"
@@ -2164,6 +2206,93 @@ files = [
{file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
]
[[package]]
name = "sqlalchemy"
version = "2.0.31"
description = "Database Abstraction Library"
optional = false
python-versions = ">=3.7"
files = [
{file = "SQLAlchemy-2.0.31-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f2a213c1b699d3f5768a7272de720387ae0122f1becf0901ed6eaa1abd1baf6c"},
{file = "SQLAlchemy-2.0.31-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9fea3d0884e82d1e33226935dac990b967bef21315cbcc894605db3441347443"},
{file = "SQLAlchemy-2.0.31-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ad7f221d8a69d32d197e5968d798217a4feebe30144986af71ada8c548e9fa"},
{file = "SQLAlchemy-2.0.31-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f2bee229715b6366f86a95d497c347c22ddffa2c7c96143b59a2aa5cc9eebbc"},
{file = "SQLAlchemy-2.0.31-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cd5b94d4819c0c89280b7c6109c7b788a576084bf0a480ae17c227b0bc41e109"},
{file = "SQLAlchemy-2.0.31-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:750900a471d39a7eeba57580b11983030517a1f512c2cb287d5ad0fcf3aebd58"},
{file = "SQLAlchemy-2.0.31-cp310-cp310-win32.whl", hash = "sha256:7bd112be780928c7f493c1a192cd8c5fc2a2a7b52b790bc5a84203fb4381c6be"},
{file = "SQLAlchemy-2.0.31-cp310-cp310-win_amd64.whl", hash = "sha256:5a48ac4d359f058474fadc2115f78a5cdac9988d4f99eae44917f36aa1476327"},
{file = "SQLAlchemy-2.0.31-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f68470edd70c3ac3b6cd5c2a22a8daf18415203ca1b036aaeb9b0fb6f54e8298"},
{file = "SQLAlchemy-2.0.31-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e2c38c2a4c5c634fe6c3c58a789712719fa1bf9b9d6ff5ebfce9a9e5b89c1ca"},
{file = "SQLAlchemy-2.0.31-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd15026f77420eb2b324dcb93551ad9c5f22fab2c150c286ef1dc1160f110203"},
{file = "SQLAlchemy-2.0.31-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2196208432deebdfe3b22185d46b08f00ac9d7b01284e168c212919891289396"},
{file = "SQLAlchemy-2.0.31-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:352b2770097f41bff6029b280c0e03b217c2dcaddc40726f8f53ed58d8a85da4"},
{file = "SQLAlchemy-2.0.31-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:56d51ae825d20d604583f82c9527d285e9e6d14f9a5516463d9705dab20c3740"},
{file = "SQLAlchemy-2.0.31-cp311-cp311-win32.whl", hash = "sha256:6e2622844551945db81c26a02f27d94145b561f9d4b0c39ce7bfd2fda5776dac"},
{file = "SQLAlchemy-2.0.31-cp311-cp311-win_amd64.whl", hash = "sha256:ccaf1b0c90435b6e430f5dd30a5aede4764942a695552eb3a4ab74ed63c5b8d3"},
{file = "SQLAlchemy-2.0.31-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3b74570d99126992d4b0f91fb87c586a574a5872651185de8297c6f90055ae42"},
{file = "SQLAlchemy-2.0.31-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f77c4f042ad493cb8595e2f503c7a4fe44cd7bd59c7582fd6d78d7e7b8ec52c"},
{file = "SQLAlchemy-2.0.31-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd1591329333daf94467e699e11015d9c944f44c94d2091f4ac493ced0119449"},
{file = "SQLAlchemy-2.0.31-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74afabeeff415e35525bf7a4ecdab015f00e06456166a2eba7590e49f8db940e"},
{file = "SQLAlchemy-2.0.31-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b9c01990d9015df2c6f818aa8f4297d42ee71c9502026bb074e713d496e26b67"},
{file = "SQLAlchemy-2.0.31-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:66f63278db425838b3c2b1c596654b31939427016ba030e951b292e32b99553e"},
{file = "SQLAlchemy-2.0.31-cp312-cp312-win32.whl", hash = "sha256:0b0f658414ee4e4b8cbcd4a9bb0fd743c5eeb81fc858ca517217a8013d282c96"},
{file = "SQLAlchemy-2.0.31-cp312-cp312-win_amd64.whl", hash = "sha256:fa4b1af3e619b5b0b435e333f3967612db06351217c58bfb50cee5f003db2a5a"},
{file = "SQLAlchemy-2.0.31-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f43e93057cf52a227eda401251c72b6fbe4756f35fa6bfebb5d73b86881e59b0"},
{file = "SQLAlchemy-2.0.31-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d337bf94052856d1b330d5fcad44582a30c532a2463776e1651bd3294ee7e58b"},
{file = "SQLAlchemy-2.0.31-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c06fb43a51ccdff3b4006aafee9fcf15f63f23c580675f7734245ceb6b6a9e05"},
{file = "SQLAlchemy-2.0.31-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:b6e22630e89f0e8c12332b2b4c282cb01cf4da0d26795b7eae16702a608e7ca1"},
{file = "SQLAlchemy-2.0.31-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:79a40771363c5e9f3a77f0e28b3302801db08040928146e6808b5b7a40749c88"},
{file = "SQLAlchemy-2.0.31-cp37-cp37m-win32.whl", hash = "sha256:501ff052229cb79dd4c49c402f6cb03b5a40ae4771efc8bb2bfac9f6c3d3508f"},
{file = "SQLAlchemy-2.0.31-cp37-cp37m-win_amd64.whl", hash = "sha256:597fec37c382a5442ffd471f66ce12d07d91b281fd474289356b1a0041bdf31d"},
{file = "SQLAlchemy-2.0.31-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:dc6d69f8829712a4fd799d2ac8d79bdeff651c2301b081fd5d3fe697bd5b4ab9"},
{file = "SQLAlchemy-2.0.31-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:23b9fbb2f5dd9e630db70fbe47d963c7779e9c81830869bd7d137c2dc1ad05fb"},
{file = "SQLAlchemy-2.0.31-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a21c97efcbb9f255d5c12a96ae14da873233597dfd00a3a0c4ce5b3e5e79704"},
{file = "SQLAlchemy-2.0.31-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26a6a9837589c42b16693cf7bf836f5d42218f44d198f9343dd71d3164ceeeac"},
{file = "SQLAlchemy-2.0.31-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc251477eae03c20fae8db9c1c23ea2ebc47331bcd73927cdcaecd02af98d3c3"},
{file = "SQLAlchemy-2.0.31-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:2fd17e3bb8058359fa61248c52c7b09a97cf3c820e54207a50af529876451808"},
{file = "SQLAlchemy-2.0.31-cp38-cp38-win32.whl", hash = "sha256:c76c81c52e1e08f12f4b6a07af2b96b9b15ea67ccdd40ae17019f1c373faa227"},
{file = "SQLAlchemy-2.0.31-cp38-cp38-win_amd64.whl", hash = "sha256:4b600e9a212ed59355813becbcf282cfda5c93678e15c25a0ef896b354423238"},
{file = "SQLAlchemy-2.0.31-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b6cf796d9fcc9b37011d3f9936189b3c8074a02a4ed0c0fbbc126772c31a6d4"},
{file = "SQLAlchemy-2.0.31-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:78fe11dbe37d92667c2c6e74379f75746dc947ee505555a0197cfba9a6d4f1a4"},
{file = "SQLAlchemy-2.0.31-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fc47dc6185a83c8100b37acda27658fe4dbd33b7d5e7324111f6521008ab4fe"},
{file = "SQLAlchemy-2.0.31-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a41514c1a779e2aa9a19f67aaadeb5cbddf0b2b508843fcd7bafdf4c6864005"},
{file = "SQLAlchemy-2.0.31-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:afb6dde6c11ea4525318e279cd93c8734b795ac8bb5dda0eedd9ebaca7fa23f1"},
{file = "SQLAlchemy-2.0.31-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3f9faef422cfbb8fd53716cd14ba95e2ef655400235c3dfad1b5f467ba179c8c"},
{file = "SQLAlchemy-2.0.31-cp39-cp39-win32.whl", hash = "sha256:fc6b14e8602f59c6ba893980bea96571dd0ed83d8ebb9c4479d9ed5425d562e9"},
{file = "SQLAlchemy-2.0.31-cp39-cp39-win_amd64.whl", hash = "sha256:3cb8a66b167b033ec72c3812ffc8441d4e9f5f78f5e31e54dcd4c90a4ca5bebc"},
{file = "SQLAlchemy-2.0.31-py3-none-any.whl", hash = "sha256:69f3e3c08867a8e4856e92d7afb618b95cdee18e0bc1647b77599722c9a28911"},
{file = "SQLAlchemy-2.0.31.tar.gz", hash = "sha256:b607489dd4a54de56984a0c7656247504bd5523d9d0ba799aef59d4add009484"},
]
[package.dependencies]
greenlet = {version = "!=0.4.17", markers = "python_version < \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"}
typing-extensions = ">=4.6.0"
[package.extras]
aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"]
aioodbc = ["aioodbc", "greenlet (!=0.4.17)"]
aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"]
asyncio = ["greenlet (!=0.4.17)"]
asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"]
mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"]
mssql = ["pyodbc"]
mssql-pymssql = ["pymssql"]
mssql-pyodbc = ["pyodbc"]
mypy = ["mypy (>=0.910)"]
mysql = ["mysqlclient (>=1.4.0)"]
mysql-connector = ["mysql-connector-python"]
oracle = ["cx_oracle (>=8)"]
oracle-oracledb = ["oracledb (>=1.0.1)"]
postgresql = ["psycopg2 (>=2.7)"]
postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"]
postgresql-pg8000 = ["pg8000 (>=1.29.1)"]
postgresql-psycopg = ["psycopg (>=3.0.7)"]
postgresql-psycopg2binary = ["psycopg2-binary"]
postgresql-psycopg2cffi = ["psycopg2cffi"]
postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"]
pymysql = ["pymysql"]
sqlcipher = ["sqlcipher3_binary"]
[[package]]
name = "tabulate"
version = "0.9.0"
@@ -2441,6 +2570,123 @@ files = [
{file = "wrapt-1.15.0.tar.gz", hash = "sha256:d06730c6aed78cee4126234cf2d071e01b44b915e725a6cb439a879ec9754a3a"},
]
[[package]]
name = "xxhash"
version = "3.4.1"
description = "Python binding for xxHash"
optional = false
python-versions = ">=3.7"
files = [
{file = "xxhash-3.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:91dbfa55346ad3e18e738742236554531a621042e419b70ad8f3c1d9c7a16e7f"},
{file = "xxhash-3.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:665a65c2a48a72068fcc4d21721510df5f51f1142541c890491afc80451636d2"},
{file = "xxhash-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb11628470a6004dc71a09fe90c2f459ff03d611376c1debeec2d648f44cb693"},
{file = "xxhash-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bef2a7dc7b4f4beb45a1edbba9b9194c60a43a89598a87f1a0226d183764189"},
{file = "xxhash-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c0f7b2d547d72c7eda7aa817acf8791f0146b12b9eba1d4432c531fb0352228"},
{file = "xxhash-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00f2fdef6b41c9db3d2fc0e7f94cb3db86693e5c45d6de09625caad9a469635b"},
{file = "xxhash-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23cfd9ca09acaf07a43e5a695143d9a21bf00f5b49b15c07d5388cadf1f9ce11"},
{file = "xxhash-3.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6a9ff50a3cf88355ca4731682c168049af1ca222d1d2925ef7119c1a78e95b3b"},
{file = "xxhash-3.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:f1d7c69a1e9ca5faa75546fdd267f214f63f52f12692f9b3a2f6467c9e67d5e7"},
{file = "xxhash-3.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:672b273040d5d5a6864a36287f3514efcd1d4b1b6a7480f294c4b1d1ee1b8de0"},
{file = "xxhash-3.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4178f78d70e88f1c4a89ff1ffe9f43147185930bb962ee3979dba15f2b1cc799"},
{file = "xxhash-3.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9804b9eb254d4b8cc83ab5a2002128f7d631dd427aa873c8727dba7f1f0d1c2b"},
{file = "xxhash-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c09c49473212d9c87261d22c74370457cfff5db2ddfc7fd1e35c80c31a8c14ce"},
{file = "xxhash-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:ebbb1616435b4a194ce3466d7247df23499475c7ed4eb2681a1fa42ff766aff6"},
{file = "xxhash-3.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:25dc66be3db54f8a2d136f695b00cfe88018e59ccff0f3b8f545869f376a8a46"},
{file = "xxhash-3.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:58c49083801885273e262c0f5bbeac23e520564b8357fbb18fb94ff09d3d3ea5"},
{file = "xxhash-3.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b526015a973bfbe81e804a586b703f163861da36d186627e27524f5427b0d520"},
{file = "xxhash-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36ad4457644c91a966f6fe137d7467636bdc51a6ce10a1d04f365c70d6a16d7e"},
{file = "xxhash-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:248d3e83d119770f96003271fe41e049dd4ae52da2feb8f832b7a20e791d2920"},
{file = "xxhash-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2070b6d5bbef5ee031666cf21d4953c16e92c2f8a24a94b5c240f8995ba3b1d0"},
{file = "xxhash-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2746035f518f0410915e247877f7df43ef3372bf36cfa52cc4bc33e85242641"},
{file = "xxhash-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a8ba6181514681c2591840d5632fcf7356ab287d4aff1c8dea20f3c78097088"},
{file = "xxhash-3.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0aac5010869240e95f740de43cd6a05eae180c59edd182ad93bf12ee289484fa"},
{file = "xxhash-3.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4cb11d8debab1626181633d184b2372aaa09825bde709bf927704ed72765bed1"},
{file = "xxhash-3.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:b29728cff2c12f3d9f1d940528ee83918d803c0567866e062683f300d1d2eff3"},
{file = "xxhash-3.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:a15cbf3a9c40672523bdb6ea97ff74b443406ba0ab9bca10ceccd9546414bd84"},
{file = "xxhash-3.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6e66df260fed01ed8ea790c2913271641c58481e807790d9fca8bfd5a3c13844"},
{file = "xxhash-3.4.1-cp311-cp311-win32.whl", hash = "sha256:e867f68a8f381ea12858e6d67378c05359d3a53a888913b5f7d35fbf68939d5f"},
{file = "xxhash-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:200a5a3ad9c7c0c02ed1484a1d838b63edcf92ff538770ea07456a3732c577f4"},
{file = "xxhash-3.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:1d03f1c0d16d24ea032e99f61c552cb2b77d502e545187338bea461fde253583"},
{file = "xxhash-3.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c4bbba9b182697a52bc0c9f8ec0ba1acb914b4937cd4a877ad78a3b3eeabefb3"},
{file = "xxhash-3.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9fd28a9da300e64e434cfc96567a8387d9a96e824a9be1452a1e7248b7763b78"},
{file = "xxhash-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6066d88c9329ab230e18998daec53d819daeee99d003955c8db6fc4971b45ca3"},
{file = "xxhash-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93805bc3233ad89abf51772f2ed3355097a5dc74e6080de19706fc447da99cd3"},
{file = "xxhash-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64da57d5ed586ebb2ecdde1e997fa37c27fe32fe61a656b77fabbc58e6fbff6e"},
{file = "xxhash-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a97322e9a7440bf3c9805cbaac090358b43f650516486746f7fa482672593df"},
{file = "xxhash-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bbe750d512982ee7d831838a5dee9e9848f3fb440e4734cca3f298228cc957a6"},
{file = "xxhash-3.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fd79d4087727daf4d5b8afe594b37d611ab95dc8e29fe1a7517320794837eb7d"},
{file = "xxhash-3.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:743612da4071ff9aa4d055f3f111ae5247342931dedb955268954ef7201a71ff"},
{file = "xxhash-3.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:b41edaf05734092f24f48c0958b3c6cbaaa5b7e024880692078c6b1f8247e2fc"},
{file = "xxhash-3.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:a90356ead70d715fe64c30cd0969072de1860e56b78adf7c69d954b43e29d9fa"},
{file = "xxhash-3.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ac56eebb364e44c85e1d9e9cc5f6031d78a34f0092fea7fc80478139369a8b4a"},
{file = "xxhash-3.4.1-cp312-cp312-win32.whl", hash = "sha256:911035345932a153c427107397c1518f8ce456f93c618dd1c5b54ebb22e73747"},
{file = "xxhash-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:f31ce76489f8601cc7b8713201ce94b4bd7b7ce90ba3353dccce7e9e1fee71fa"},
{file = "xxhash-3.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:b5beb1c6a72fdc7584102f42c4d9df232ee018ddf806e8c90906547dfb43b2da"},
{file = "xxhash-3.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6d42b24d1496deb05dee5a24ed510b16de1d6c866c626c2beb11aebf3be278b9"},
{file = "xxhash-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b685fab18876b14a8f94813fa2ca80cfb5ab6a85d31d5539b7cd749ce9e3624"},
{file = "xxhash-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:419ffe34c17ae2df019a4685e8d3934d46b2e0bbe46221ab40b7e04ed9f11137"},
{file = "xxhash-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e041ce5714f95251a88670c114b748bca3bf80cc72400e9f23e6d0d59cf2681"},
{file = "xxhash-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc860d887c5cb2f524899fb8338e1bb3d5789f75fac179101920d9afddef284b"},
{file = "xxhash-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:312eba88ffe0a05e332e3a6f9788b73883752be63f8588a6dc1261a3eaaaf2b2"},
{file = "xxhash-3.4.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:e01226b6b6a1ffe4e6bd6d08cfcb3ca708b16f02eb06dd44f3c6e53285f03e4f"},
{file = "xxhash-3.4.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9f3025a0d5d8cf406a9313cd0d5789c77433ba2004b1c75439b67678e5136537"},
{file = "xxhash-3.4.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:6d3472fd4afef2a567d5f14411d94060099901cd8ce9788b22b8c6f13c606a93"},
{file = "xxhash-3.4.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:43984c0a92f06cac434ad181f329a1445017c33807b7ae4f033878d860a4b0f2"},
{file = "xxhash-3.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a55e0506fdb09640a82ec4f44171273eeabf6f371a4ec605633adb2837b5d9d5"},
{file = "xxhash-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:faec30437919555b039a8bdbaba49c013043e8f76c999670aef146d33e05b3a0"},
{file = "xxhash-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c9e1b646af61f1fc7083bb7b40536be944f1ac67ef5e360bca2d73430186971a"},
{file = "xxhash-3.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:961d948b7b1c1b6c08484bbce3d489cdf153e4122c3dfb07c2039621243d8795"},
{file = "xxhash-3.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:719a378930504ab159f7b8e20fa2aa1896cde050011af838af7e7e3518dd82de"},
{file = "xxhash-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74fb5cb9406ccd7c4dd917f16630d2e5e8cbbb02fc2fca4e559b2a47a64f4940"},
{file = "xxhash-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5dab508ac39e0ab988039bc7f962c6ad021acd81fd29145962b068df4148c476"},
{file = "xxhash-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c59f3e46e7daf4c589e8e853d700ef6607afa037bfad32c390175da28127e8c"},
{file = "xxhash-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cc07256eff0795e0f642df74ad096f8c5d23fe66bc138b83970b50fc7f7f6c5"},
{file = "xxhash-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9f749999ed80f3955a4af0eb18bb43993f04939350b07b8dd2f44edc98ffee9"},
{file = "xxhash-3.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7688d7c02149a90a3d46d55b341ab7ad1b4a3f767be2357e211b4e893efbaaf6"},
{file = "xxhash-3.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a8b4977963926f60b0d4f830941c864bed16aa151206c01ad5c531636da5708e"},
{file = "xxhash-3.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:8106d88da330f6535a58a8195aa463ef5281a9aa23b04af1848ff715c4398fb4"},
{file = "xxhash-3.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4c76a77dbd169450b61c06fd2d5d436189fc8ab7c1571d39265d4822da16df22"},
{file = "xxhash-3.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:11f11357c86d83e53719c592021fd524efa9cf024dc7cb1dfb57bbbd0d8713f2"},
{file = "xxhash-3.4.1-cp38-cp38-win32.whl", hash = "sha256:0c786a6cd74e8765c6809892a0d45886e7c3dc54de4985b4a5eb8b630f3b8e3b"},
{file = "xxhash-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:aabf37fb8fa27430d50507deeab2ee7b1bcce89910dd10657c38e71fee835594"},
{file = "xxhash-3.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6127813abc1477f3a83529b6bbcfeddc23162cece76fa69aee8f6a8a97720562"},
{file = "xxhash-3.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef2e194262f5db16075caea7b3f7f49392242c688412f386d3c7b07c7733a70a"},
{file = "xxhash-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71be94265b6c6590f0018bbf73759d21a41c6bda20409782d8117e76cd0dfa8b"},
{file = "xxhash-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10e0a619cdd1c0980e25eb04e30fe96cf8f4324758fa497080af9c21a6de573f"},
{file = "xxhash-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa122124d2e3bd36581dd78c0efa5f429f5220313479fb1072858188bc2d5ff1"},
{file = "xxhash-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17032f5a4fea0a074717fe33477cb5ee723a5f428de7563e75af64bfc1b1e10"},
{file = "xxhash-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca7783b20e3e4f3f52f093538895863f21d18598f9a48211ad757680c3bd006f"},
{file = "xxhash-3.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d77d09a1113899fad5f354a1eb4f0a9afcf58cefff51082c8ad643ff890e30cf"},
{file = "xxhash-3.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:21287bcdd299fdc3328cc0fbbdeaa46838a1c05391264e51ddb38a3f5b09611f"},
{file = "xxhash-3.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:dfd7a6cc483e20b4ad90224aeb589e64ec0f31e5610ab9957ff4314270b2bf31"},
{file = "xxhash-3.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:543c7fcbc02bbb4840ea9915134e14dc3dc15cbd5a30873a7a5bf66039db97ec"},
{file = "xxhash-3.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fe0a98d990e433013f41827b62be9ab43e3cf18e08b1483fcc343bda0d691182"},
{file = "xxhash-3.4.1-cp39-cp39-win32.whl", hash = "sha256:b9097af00ebf429cc7c0e7d2fdf28384e4e2e91008130ccda8d5ae653db71e54"},
{file = "xxhash-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:d699b921af0dcde50ab18be76c0d832f803034d80470703700cb7df0fbec2832"},
{file = "xxhash-3.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:2be491723405e15cc099ade1280133ccfbf6322d2ef568494fb7d07d280e7eee"},
{file = "xxhash-3.4.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:431625fad7ab5649368c4849d2b49a83dc711b1f20e1f7f04955aab86cd307bc"},
{file = "xxhash-3.4.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc6dbd5fc3c9886a9e041848508b7fb65fd82f94cc793253990f81617b61fe49"},
{file = "xxhash-3.4.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3ff8dbd0ec97aec842476cb8ccc3e17dd288cd6ce3c8ef38bff83d6eb927817"},
{file = "xxhash-3.4.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef73a53fe90558a4096e3256752268a8bdc0322f4692ed928b6cd7ce06ad4fe3"},
{file = "xxhash-3.4.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:450401f42bbd274b519d3d8dcf3c57166913381a3d2664d6609004685039f9d3"},
{file = "xxhash-3.4.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a162840cf4de8a7cd8720ff3b4417fbc10001eefdd2d21541a8226bb5556e3bb"},
{file = "xxhash-3.4.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b736a2a2728ba45017cb67785e03125a79d246462dfa892d023b827007412c52"},
{file = "xxhash-3.4.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d0ae4c2e7698adef58710d6e7a32ff518b66b98854b1c68e70eee504ad061d8"},
{file = "xxhash-3.4.1-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6322c4291c3ff174dcd104fae41500e75dad12be6f3085d119c2c8a80956c51"},
{file = "xxhash-3.4.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:dd59ed668801c3fae282f8f4edadf6dc7784db6d18139b584b6d9677ddde1b6b"},
{file = "xxhash-3.4.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:92693c487e39523a80474b0394645b393f0ae781d8db3474ccdcead0559ccf45"},
{file = "xxhash-3.4.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4603a0f642a1e8d7f3ba5c4c25509aca6a9c1cc16f85091004a7028607ead663"},
{file = "xxhash-3.4.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fa45e8cbfbadb40a920fe9ca40c34b393e0b067082d94006f7f64e70c7490a6"},
{file = "xxhash-3.4.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:595b252943b3552de491ff51e5bb79660f84f033977f88f6ca1605846637b7c6"},
{file = "xxhash-3.4.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:562d8b8f783c6af969806aaacf95b6c7b776929ae26c0cd941d54644ea7ef51e"},
{file = "xxhash-3.4.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:41ddeae47cf2828335d8d991f2d2b03b0bdc89289dc64349d712ff8ce59d0647"},
{file = "xxhash-3.4.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c44d584afdf3c4dbb3277e32321d1a7b01d6071c1992524b6543025fb8f4206f"},
{file = "xxhash-3.4.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7bddb3a5b86213cc3f2c61500c16945a1b80ecd572f3078ddbbe68f9dabdfb"},
{file = "xxhash-3.4.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9ecb6c987b62437c2f99c01e97caf8d25660bf541fe79a481d05732e5236719c"},
{file = "xxhash-3.4.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:696b4e18b7023527d5c50ed0626ac0520edac45a50ec7cf3fc265cd08b1f4c03"},
{file = "xxhash-3.4.1.tar.gz", hash = "sha256:0379d6cf1ff987cd421609a264ce025e74f346e3e145dd106c0cc2e3ec3f99a9"},
]
[[package]]
name = "zope-event"
version = "5.0"
@@ -2515,4 +2761,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<3.12"
content-hash = "5722346cbfc224340877e337d4cee2c8e8a3a7ea68f9a64d9c5806e0ebcf919a"
content-hash = "43972b6ffadd14e5047f067a0258f2428ebe351df8bd032dc0bf05df379678a6"
+6
View File
@@ -3,6 +3,7 @@ name = "Swing music player"
version = "0.1.0"
description = ""
authors = ["geoffrey45 <geoffreymungai45@gmail.com>"]
package-mode = false
[tool.poetry.dependencies]
python = ">=3.10,<3.12"
@@ -26,6 +27,11 @@ watchdog = "^4.0.0"
pendulum = "^3.0.0"
flask-openapi3 = "^3.0.2"
flask-jwt-extended = "^4.6.0"
sqlalchemy = "^2.0.31"
memory-profiler = "^0.61.0"
sortedcontainers = "^2.4.0"
xxhash = "^3.4.1"
ffmpeg-python = "^0.2.0"
[tool.poetry.dev-dependencies]
pylint = "^2.15.5"
-59
View File
@@ -1,59 +0,0 @@
import json
import sqlite3
import os
from app.db.sqlite.artistcolors import SQLiteArtistMethods
from app.db.sqlite.queries import CREATE_APPDB_TABLES
from app.db.sqlite.utils import SQLiteManager
db_path = "test.db"
def test_sqlite_manager():
with SQLiteManager(test_db_path=db_path) as cur:
for query in CREATE_APPDB_TABLES.split(";"):
cur.execute(query)
cur.execute(
"INSERT INTO tracks (album, albumartist, albumhash, artist, bitrate, copyright, date, disc, duration, filepath, folder, genre, last_mod, title, track, trackhash) VALUES ('Dummy Album', 'Dummy Album Artist', 'dummyalbumhash', 'Dummy Artist', 320, 'Dummy Copyright', 1630454400, 1, 180, '/path/to/dummy/file.mp3', '/path/to/dummy/folder', 'Dummy Genre', 1630454400.5, 'Dummy Title', 1, 'dummytrackhash');"
)
cur.execute("SELECT * FROM tracks")
result = cur.fetchone()
assert result[7] == 1630454400
# Test using a connection
with SQLiteManager(conn=sqlite3.connect(db_path)) as cur:
cur.execute("SELECT * FROM tracks")
result = cur.fetchone()
assert result[7] == 1630454400
def test_insert_one_artist():
color1 = "rgb(0, 0, 0)"
color2 = "rgb(255, 255, 255)"
with SQLiteManager(test_db_path=db_path) as cur:
SQLiteArtistMethods.insert_one_artist(cur, "artisthash1", [color1, color2])
cur.execute("SELECT * FROM artists WHERE artisthash=?", ("artisthash1",))
result = cur.fetchone()
assert result[1:] == ("artisthash1", json.dumps([color1, color2]), None)
def test_get_all_artists():
with SQLiteManager(test_db_path=db_path) as cur:
artists = SQLiteArtistMethods.get_all_artists(cur)
# assert that that the generator is not empty and that for each tuple has 4 elements
try:
while True:
artist = next(artists)
assert len(artist) == 4
except StopIteration:
pass
def test_remove_test_db():
os.remove(db_path)
+137
View File
@@ -0,0 +1,137 @@
import unittest
def split_artists(src: str, separators: set[str], ignoreList: set[str] = set()):
"""
Splits a string of artists into a list of artists, preserving those in ignoreList.
Case-insensitive matching is used for the ignoreList.
"""
result = []
current = ""
i = 0
# Convert ignoreList to lowercase for case-insensitive matching
ignore_lower = {artist.lower() for artist in ignoreList}
while i < len(src):
# Check if any ignored artist starts at this position (case-insensitive)
ignored_match = next(
(
src[i:i+len(ignored)]
for ignored in ignoreList
if src.lower().startswith(ignored.lower(), i)
),
None
)
if ignored_match:
# If we have accumulated any current string, add it to result
if current.strip():
result.extend([a.strip() for a in current.split(',') if a.strip()])
current = ""
# Add the ignored artist to the result (preserving original case)
result.append(ignored_match)
# Move past the ignored artist
i += len(ignored_match)
elif src[i] in separators:
# If we encounter a separator, process the current string
if current.strip():
result.extend([a.strip() for a in current.split(',') if a.strip()])
current = ""
i += 1
else:
# If it's not an ignored artist or a separator, add to current
current += src[i]
i += 1
# Process any remaining current string
if current.strip():
result.extend([a.strip() for a in current.split(',') if a.strip()])
return result
class TestSplitArtists(unittest.TestCase):
def test_basic_splitting(self):
self.assertEqual(
split_artists("Beatles, Queen; Rolling Stones", {";"}),
["Beatles", "Queen", "Rolling Stones"],
)
def test_multiple_separators(self):
self.assertEqual(
split_artists("Beatles; Queen & Rolling Stones | ABBA", {";", "&", "|"}),
["Beatles", "Queen", "Rolling Stones", "ABBA"],
)
def test_ignore_list(self):
self.assertEqual(
split_artists(
"Beatles; Earth, Wind & Fire; Queen", {";", "&"}, {"Earth, Wind & Fire"}
),
["Beatles", "Earth, Wind & Fire", "Queen"],
)
def test_empty_string(self):
self.assertEqual(split_artists("", {";"}), [])
def test_only_separators(self):
self.assertEqual(split_artists(";;;", {";"}), [])
def test_extra_spaces(self):
self.assertEqual(
split_artists(" Beatles ; Queen ", {";"}), ["Beatles", "Queen"]
)
def test_comma_splitting(self):
self.assertEqual(
split_artists("Beatles, Queen; Rolling Stones, ABBA", {";"}),
["Beatles", "Queen", "Rolling Stones", "ABBA"],
)
def test_ignore_list_with_comma(self):
self.assertEqual(
split_artists(
"Beatles; Earth, Wind & Fire, Queen", {";"}, {"Earth, Wind & Fire"}
),
["Beatles", "Earth, Wind & Fire", "Queen"],
)
def test_ignore_list_with_separator(self):
self.assertEqual(
split_artists("Beatles; AC/DC", {"/", ";"}, {"AC/DC"}), ["Beatles", "AC/DC"]
)
def test_ignore_list_at_start(self):
self.assertEqual(
split_artists("AC/DC; Beatles", {"/", ";"}, {"AC/DC"}), ["AC/DC", "Beatles"]
)
def test_ignore_list_at_end(self):
self.assertEqual(
split_artists("Beatles; AC/DC", {"/", ";"}, {"AC/DC"}), ["Beatles", "AC/DC"]
)
def test_multiple_ignored_artists(self):
self.assertEqual(
split_artists(
"Beatles; AC/DC; Guns N' Roses; Queen",
{"/", ";", "'"},
{"AC/DC", "Guns N' Roses"},
),
["Beatles", "AC/DC", "Guns N' Roses", "Queen"],
)
def test_bob_marley(self):
self.assertEqual(
split_artists(
"Bob marley & The wailers; Beatles",
{";", "&"},
{"Bob marley & the wailers"},
),
["Bob marley & The wailers", "Beatles"],
)
if __name__ == "__main__":
unittest.main()
-34
View File
@@ -1,34 +0,0 @@
# from hypothesis import given
from app.utils.parsers import parse_feat_from_title
def test_extract_featured_artists_from_title():
test_titles = [
"Own it (Featuring Ed Sheeran & Stormzy)",
"Own it (Featuring Ed Sheeran and Stormzy)",
"Autograph (On my line)(Feat. Lil Peep)(Deluxe)",
"Why so sad? (with Juice Wrld, Lil Peep)",
"Why so sad? (with Juice Wrld/Lil Peep)",
"Simmer (with Burna Boy)",
"Simmer (without Burna Boy)",
]
results = [
["Ed Sheeran", "Stormzy"],
["Ed Sheeran", "Stormzy"],
["Lil Peep"],
["Juice Wrld", "Lil Peep"],
["Juice Wrld", "Lil Peep"],
["Burna Boy"],
[],
]
for title, expected in zip(test_titles, results):
assert parse_feat_from_title(title)[0] == expected
# === HYPOTHESIS GHOSTWRITER TESTS ===
# @given(__dir=st.text(), full=st.booleans())
# def test_fuzz_run_fast_scandir(__dir: str, full) -> None:
# app.utils.run_fast_scandir(_dir=__dir, full=full)