start: rewrite the database layer using a freaking ORM

+ start ditching in-mem stores
+ move main db table to a new name
+ experiments!
This commit is contained in:
cwilvx
2024-06-24 00:26:47 +03:00
parent c3472a865a
commit c42ec4dcde
27 changed files with 1399 additions and 397 deletions
+13
View File
@@ -12,3 +12,16 @@
- -
## Development ## Development
## THE BIG ONE API CHANGES
- genre is no longer a string, but a struct:
```ts
interface Genre {
name: str;
genrehash: str;
}
```
+7
View File
@@ -1,6 +1,11 @@
# TODO # TODO
- Migrations: - 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 - Package jsoni and publish on PyPi
- Rewrite stores to use dictionaries instead of list pools - Rewrite stores to use dictionaries instead of list pools
@@ -8,6 +13,8 @@
- Disable the watchdog by default, and mark it as experimental - Disable the watchdog by default, and mark it as experimental
- rename userid to server id in config file - rename userid to server id in config file
- Look into seeding jwts using user password + server id - 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!
# DONE # DONE
- Support auth headers - Support auth headers
+50 -67
View File
@@ -2,6 +2,7 @@
Contains all the album routes. Contains all the album routes.
""" """
from itertools import groupby
import random import random
from flask_jwt_extended import current_user from flask_jwt_extended import current_user
@@ -10,13 +11,15 @@ from flask_openapi3 import Tag
from flask_openapi3 import APIBlueprint from flask_openapi3 import APIBlueprint
from app.api.apischemas import AlbumHashSchema, AlbumLimitSchema, ArtistHashSchema from app.api.apischemas import AlbumHashSchema, AlbumLimitSchema, ArtistHashSchema
from app.config import UserConfig
from app.db import AlbumTable as AlbumDb, TrackTable as TrackDb
from app.settings import Defaults from app.settings import Defaults
from app.models import FavType, Track from app.models import FavType, Track
from app.store.albums import AlbumStore from app.store.albums import AlbumStore
from app.store.tracks import TrackStore from app.store.tracks import TrackStore
from app.utils.hashing import create_hash from app.utils.hashing import create_hash
from app.lib.albumslib import sort_by_track_no from app.lib.albumslib import sort_by_track_no
from app.serializers.album import serialize_for_card from app.serializers.album import serialize_for_card, serialize_for_card_many
from app.serializers.track import serialize_track from app.serializers.track import serialize_track
from app.db.sqlite.albumcolors import SQLiteAlbumMethods as adb from app.db.sqlite.albumcolors import SQLiteAlbumMethods as adb
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
@@ -38,47 +41,20 @@ def get_album_tracks_and_info(body: AlbumHashSchema):
Returns album info and tracks for the given albumhash. Returns album info and tracks for the given albumhash.
""" """
albumhash = body.albumhash albumhash = body.albumhash
album = AlbumDb.get_album_by_albumhash(albumhash)
error_msg = {"error": "Album not created yet."}
album = AlbumStore.get_album_by_hash(albumhash)
if album is None: if album is None:
return error_msg, 404 return {"error": "Album not found"}, 404
tracks = TrackStore.get_tracks_by_albumhash(albumhash) tracks = TrackDb.get_tracks_by_albumhash(albumhash)
album.trackcount = len(tracks)
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.duration = sum(t.duration for t in tracks) album.duration = sum(t.duration for t in tracks)
album.type = album.check_type(
tracks=tracks, singleTrackAsSingle=UserConfig().showAlbumsAsSingles
)
album.populate_versions()
album.check_is_single(tracks) return {"info": album, "tracks": tracks}
if not album.is_single:
album.check_type()
album.is_favorite = check_is_fav(albumhash, FavType.album)
return {
"tracks": [serialize_track(t, remove_disc=False) for t in tracks],
"info": album,
}
@api.get("/<albumhash>/tracks") @api.get("/<albumhash>/tracks")
@@ -89,16 +65,16 @@ def get_album_tracks(path: AlbumHashSchema):
Returns all the tracks in the given album, sorted by disc and track number. Returns all the tracks in the given album, sorted by disc and track number.
NOTE: No album info is returned. NOTE: No album info is returned.
""" """
tracks = TrackStore.get_tracks_by_albumhash(path.albumhash) tracks = TrackDb.get_tracks_by_albumhash(path.albumhash)
tracks = sort_by_track_no(tracks) tracks = sort_by_track_no(tracks)
return tracks return tracks
class GetMoreFromArtistsBody(AlbumLimitSchema): class GetMoreFromArtistsBody(AlbumLimitSchema):
albumartists: str = Field( albumartists: list = Field(
description="The artist hashes to get more albums from", description="The artist hashes to get more albums from",
example=Defaults.API_ARTISTHASH, example='[{"name": "Khalid", "artisthash": "94ca2dba1c"}]',
) )
base_title: str = Field( base_title: str = Field(
@@ -119,29 +95,25 @@ def get_more_from_artist(body: GetMoreFromArtistsBody):
limit = body.limit limit = body.limit
base_title = body.base_title base_title = body.base_title
albumartists: list[str] = albumartists.split(",") all_albums = AlbumDb.get_albums_by_artisthashes(albumartists)
albums = [ # filter out albums with the same base title
{ all_albums = filter(
"artisthash": a, lambda a: create_hash(a.base_title) != create_hash(base_title), all_albums
"albums": AlbumStore.get_albums_by_albumartist( )
a, limit, exclude=base_title all_albums = list(all_albums)
),
} if not len(all_albums):
for a in albumartists return []
# group by first albumartist's artisthash
groups = groupby(all_albums, lambda a: a.albumartists[0]["artisthash"])
return [
{"artisthash": g[0], "albums": serialize_for_card_many(list(g[1])[:limit])}
for g in groups
] ]
albums = [
{
"artisthash": a["artisthash"],
"albums": [serialize_for_card(a_) for a_ in (a["albums"])],
}
for a in albums
if len(a["albums"]) > 0
]
return albums
class GetAlbumVersionsBody(ArtistHashSchema): class GetAlbumVersionsBody(ArtistHashSchema):
og_album_title: str = Field( og_album_title: str = Field(
@@ -165,18 +137,29 @@ def get_album_versions(body: GetAlbumVersionsBody):
base_title = body.base_title base_title = body.base_title
artisthash = body.artisthash artisthash = body.artisthash
albums = AlbumStore.get_albums_by_artisthash(artisthash) albums = AlbumDb.get_albums_by_base_title(base_title)
print(albums)
albums = [ albums = [
a a
for a in albums for a in albums
if create_hash(a.base_title) == create_hash(base_title) if a.og_title != og_album_title
and create_hash(og_album_title) != create_hash(a.og_title) and artisthash in {a["artisthash"] for a in a.albumartists}
] ]
for a in albums: print(albums)
tracks = TrackStore.get_tracks_by_albumhash(a.albumhash)
a.get_date_from_tracks(tracks) # albums = AlbumStore.get_albums_by_artisthash(artisthash)
# albums = [
# a
# for a in albums
# if create_hash(a.base_title) == create_hash(base_title)
# and create_hash(og_album_title) != create_hash(a.og_title)
# ]
# for a in albums:
# tracks = TrackStore.get_tracks_by_albumhash(a.albumhash)
# a.get_date_from_tracks(tracks)
return albums return albums
+1 -1
View File
@@ -133,7 +133,7 @@ def get_artist_albums(path: ArtistHashSchema, query: GetArtistAlbumsQuery):
AlbumStore.remove_album_by_hash(a.albumhash) AlbumStore.remove_album_by_hash(a.albumhash)
continue continue
a.check_is_single(album_tracks) a.is_single(album_tracks)
all_albums = sorted(all_albums, key=lambda a: str(a.date), reverse=True) all_albums = sorted(all_albums, key=lambda a: str(a.date), reverse=True)
+2
View File
@@ -66,7 +66,9 @@ def get_folder_tree(body: FolderTree):
else: else:
req_dir = "/" + req_dir if not req_dir.startswith("/") else req_dir req_dir = "/" + req_dir if not req_dir.startswith("/") else req_dir
print('stuff!')
res = GetFilesAndDirs(req_dir, tracks_only=tracks_only)() res = GetFilesAndDirs(req_dir, tracks_only=tracks_only)()
print(res['folders'])
res["folders"] = sorted(res["folders"], key=lambda i: i.name) res["folders"] = sorted(res["folders"], key=lambda i: i.name)
return res return res
+10 -7
View File
@@ -6,6 +6,7 @@ from pydantic import BaseModel, Field
from datetime import datetime from datetime import datetime
from app.api.apischemas import GenericLimitSchema from app.api.apischemas import GenericLimitSchema
from app.db import AlbumTable, ArtistTable
from app.store.albums import AlbumStore from app.store.albums import AlbumStore
from app.store.artists import ArtistStore from app.store.artists import ArtistStore
@@ -59,17 +60,19 @@ def get_all_items(path: GetAllItemsPath, query: GetAllItemsQuery):
is_albums = path.itemtype == "albums" is_albums = path.itemtype == "albums"
is_artists = path.itemtype == "artists" is_artists = path.itemtype == "artists"
items = AlbumStore.albums if is_albums:
items = AlbumTable.get_all(query.start, query.limit)
elif is_artists:
items = ArtistTable.get_all(query.start, query.limit)
if is_artists: print(items)
items = ArtistStore.artists
start = query.start start = query.start
limit = query.limit limit = query.limit
sort = query.sortby sort = query.sortby
reverse = query.reverse == "1" reverse = query.reverse == "1"
sort_is_count = sort == "count" sort_is_count = sort == "trackcount"
sort_is_duration = sort == "duration" sort_is_duration = sort == "duration"
sort_is_create_date = sort == "created_date" sort_is_create_date = sort == "created_date"
@@ -81,7 +84,7 @@ def get_all_items(path: GetAllItemsPath, query: GetAllItemsQuery):
lambda_sort = lambda x: getattr(x, sort) lambda_sort = lambda x: getattr(x, sort)
if sort_is_artist: if sort_is_artist:
lambda_sort = lambda x: getattr(x, sort)[0].name lambda_sort = lambda x: getattr(x, sort)[0]["name"]
sorted_items = sorted(items, key=lambda_sort, reverse=reverse) sorted_items = sorted(items, key=lambda_sort, reverse=reverse)
items = sorted_items[start : start + limit] items = sorted_items[start : start + limit]
@@ -101,7 +104,7 @@ def get_all_items(path: GetAllItemsPath, query: GetAllItemsQuery):
if sort_is_count: if sort_is_count:
item_dict["help_text"] = ( 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: if sort_is_duration:
@@ -114,7 +117,7 @@ def get_all_items(path: GetAllItemsPath, query: GetAllItemsQuery):
if sort_is_artist_albumcount: if sort_is_artist_albumcount:
item_dict["help_text"] = ( item_dict["help_text"] = (
f"{format_number(item.albumcount)} album{'' if item.albumcount == 1 else 's'}" f"{format_number(item['albumcount'])} album{'' if item['albumcount'] == 1 else 's'}"
) )
album_list.append(item_dict) album_list.append(item_dict)
+1
View File
@@ -21,6 +21,7 @@ class UserConfig:
rootDirs: list[str] = field(default_factory=list) rootDirs: list[str] = field(default_factory=list)
excludeDirs: list[str] = field(default_factory=list) excludeDirs: list[str] = field(default_factory=list)
artistSeparators: set[str] = field(default_factory=list) artistSeparators: set[str] = field(default_factory=list)
genreSeparators: set[str] = field(default_factory=lambda: {"/", ";", "&"})
# tracks # tracks
extractFeaturedArtists: bool = True extractFeaturedArtists: bool = True
+232
View File
@@ -0,0 +1,232 @@
import json
from pprint import pprint
from typing import Any, Optional
from sqlalchemy import (
JSON,
Boolean,
Integer,
Row,
String,
Tuple,
create_engine,
insert,
select,
)
from sqlalchemy.orm import (
Mapped,
mapped_column,
DeclarativeBase,
MappedAsDataclass,
sessionmaker,
)
from app.models import Track as TrackModel
from app.models import Album as AlbumModel
from app.utils.remove_duplicates import remove_duplicates
fullpath = "/home/cwilvx/temp/swingmusic/swing.db"
engine = create_engine(f"sqlite+pysqlite:///{fullpath}", echo=False)
def todict(track: Any):
return track._asdict()
def todicts(tracks: list[Any]):
return [todict(track) for track in tracks]
class DbManager:
def __init__(self):
self.engine = create_engine(f"sqlite+pysqlite:///{fullpath}", echo=True)
self.conn = self.engine.connect()
def __enter__(self):
return self.conn.execution_options(preserve_rowcount=True)
def __exit__(self, exc_type, exc_val, exc_tb):
self.conn.commit()
self.conn.close()
class Base(MappedAsDataclass, DeclarativeBase):
@classmethod
def insert_many(cls, items: list[dict[str, Any]]):
"""
Inserts multiple items into the database.
"""
with DbManager() as conn:
conn.execute(insert(cls).values(items))
@classmethod
def insert_one(cls, item: dict[str, Any]):
"""
Inserts a single item into the database.
"""
return cls.insert_many([item])
@classmethod
def get_all(cls):
"""
Returns all the items from the database.
"""
with DbManager() as conn:
result = conn.execute(select(cls))
return result.fetchall()
class ArtistTable(Base):
__tablename__ = "artist"
id: Mapped[int] = mapped_column(primary_key=True)
albumcount: Mapped[int] = mapped_column(Integer())
artisthash: Mapped[str] = mapped_column(String(), unique=True, index=True)
created_date: Mapped[int] = mapped_column(Integer())
date: Mapped[int] = mapped_column(Integer())
duration: Mapped[int] = mapped_column(Integer())
genres: Mapped[str] = mapped_column(JSON())
name: Mapped[str] = mapped_column(String(), index=True)
trackcount: Mapped[int] = mapped_column(Integer())
is_favorite: Mapped[Optional[bool]] = mapped_column(Boolean())
@classmethod
def get_all(cls, start: int, limit: int):
with DbManager() as conn:
result = conn.execute(select(cls).offset(start).limit(limit))
return albums_to_dataclasses(result.fetchall())
class AlbumTable(Base):
__tablename__ = "album"
id: Mapped[int] = mapped_column(primary_key=True)
albumartists: Mapped[list[dict[str, str]]] = mapped_column(JSON(), index=True)
albumhash: Mapped[str] = mapped_column(String(), unique=True, index=True)
base_title: Mapped[str] = mapped_column(String())
color: Mapped[Optional[str]] = mapped_column(String())
created_date: Mapped[int] = mapped_column(Integer())
date: Mapped[int] = mapped_column(Integer())
duration: Mapped[int] = mapped_column(Integer())
genres: Mapped[str] = mapped_column(JSON())
og_title: Mapped[str] = mapped_column(String())
title: Mapped[str] = mapped_column(String())
trackcount: Mapped[int] = mapped_column(Integer())
@classmethod
def get_album_by_albumhash(cls, hash: str):
with DbManager() as conn:
result = conn.execute(
select(AlbumTable).where(AlbumTable.albumhash == hash)
)
album = result.fetchone()
if album:
return album_to_dataclass(album)
@classmethod
def get_all(cls, start: int, limit: int):
with DbManager() as conn:
result = conn.execute(select(AlbumTable).offset(start).limit(limit))
return albums_to_dataclasses(result.fetchall())
@classmethod
def get_albums_by_artisthashes(cls, artisthashes: list[dict[str, str]]):
with DbManager() as conn:
albums: list[AlbumModel] = []
for artist in artisthashes:
result = conn.execute(
# NOTE: The artist dict keys need to in the same order they appear in the db for this to work!
select(AlbumTable).where(AlbumTable.albumartists.contains(artist))
)
albums.extend(albums_to_dataclasses(result.fetchall()))
print(albums)
return albums
@classmethod
def get_albums_by_base_title(cls, base_title: str):
with DbManager() as conn:
result = conn.execute(
select(AlbumTable).where(AlbumTable.base_title == base_title)
)
return albums_to_dataclasses(result.fetchall())
class TrackTable(Base):
__tablename__ = "track"
id: Mapped[int] = mapped_column(init=False, primary_key=True)
album: Mapped[str] = mapped_column(String())
albumartists: Mapped[list[dict[str, str]]] = mapped_column(JSON())
albumhash: Mapped[str] = mapped_column(String(), index=True)
artists: Mapped[list[dict[str, str]]] = mapped_column(JSON(), index=True)
bitrate: Mapped[int] = mapped_column(Integer())
copyright: Mapped[Optional[str]] = mapped_column(String())
date: Mapped[int] = mapped_column(Integer())
disc: Mapped[int] = mapped_column(Integer())
duration: Mapped[int] = mapped_column(Integer())
filepath: Mapped[str] = mapped_column(String(), unique=True)
folder: Mapped[str] = mapped_column(String(), index=True)
genre: Mapped[Optional[list[dict[str, str]]]] = mapped_column(JSON())
last_mod: Mapped[float] = mapped_column(Integer())
og_album: Mapped[str] = mapped_column(String())
og_title: Mapped[str] = mapped_column(String())
title: Mapped[str] = mapped_column(String())
track: Mapped[int] = mapped_column(Integer())
trackhash: Mapped[str] = mapped_column(String(), index=True)
@classmethod
def get_tracks_by_filepaths(cls, filepaths: list[str]):
print(filepaths[0])
with DbManager() as conn:
result = conn.execute(
select(TrackTable).where(TrackTable.filepath.in_(filepaths))
)
return [dict(r) for r in result.mappings().fetchall()]
@classmethod
def count_tracks_containing_paths(cls, paths: list[str]):
results: list[dict[str, int | str]] = []
with DbManager() as conn:
for path in paths:
result = conn.execute(
select(TrackTable).where(TrackTable.filepath.contains(path))
)
results.append({"path": path, "trackcount": result.all().__len__()})
return results
@classmethod
def get_tracks_by_albumhash(cls, albumhash: str):
with DbManager() as conn:
result = conn.execute(
select(TrackTable).where(TrackTable.albumhash == albumhash)
)
tracks = tracks_to_dataclasses(result.fetchall())
return remove_duplicates(tracks, is_album_tracks=True)
# SECTION: HELPER FUNCTIONS
def album_to_dataclass(album: Row[AlbumTable]):
return AlbumModel(**album._asdict())
def albums_to_dataclasses(albums: list[Row[AlbumTable]]):
return [album_to_dataclass(album) for album in albums]
def track_to_dataclass(track: Row[TrackTable]):
return TrackModel(**track._asdict())
def tracks_to_dataclasses(tracks: list[Row[TrackTable]]):
return [track_to_dataclass(track) for track in tracks]
Base().metadata.create_all(engine)
-2
View File
@@ -75,7 +75,6 @@ class SQLiteAuthMethods:
{', '.join([f"{key} = :{key}" for key in keys if key != 'id'])} {', '.join([f"{key} = :{key}" for key in keys if key != 'id'])}
WHERE id = :id WHERE id = :id
""" """
print(sql, user)
with SQLiteManager(userdata_db=True) as cur: with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, user) cur.execute(sql, user)
@@ -140,7 +139,6 @@ class SQLiteAuthMethods:
Delete a user by username. Delete a user by username.
""" """
sql = "DELETE FROM users WHERE id = ?" sql = "DELETE FROM users WHERE id = ?"
print("deleting user: ", username)
with SQLiteManager(userdata_db=True) as cur: with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (3,)) cur.execute(sql, (3,))
cur.close() cur.close()
+5 -2
View File
@@ -1,6 +1,7 @@
from flask_jwt_extended import current_user from flask_jwt_extended import current_user
from app.db.sqlite.utils import SQLiteManager from app.db.sqlite.utils import SQLiteManager
from app.models.logger import TrackLog as TrackLog from app.models.logger import TrackLog as TrackLog
from app.utils.auth import get_current_userid
class SQLiteTrackLogger: class SQLiteTrackLogger:
@@ -10,6 +11,7 @@ class SQLiteTrackLogger:
Inserts a track play record into the database Inserts a track play record into the database
""" """
userid = get_current_userid()
with SQLiteManager(userdata_db=True) as cur: with SQLiteManager(userdata_db=True) as cur:
sql = """INSERT OR REPLACE INTO track_logger( sql = """INSERT OR REPLACE INTO track_logger(
trackhash, trackhash,
@@ -21,7 +23,7 @@ class SQLiteTrackLogger:
""" """
cur.execute( cur.execute(
sql, (trackhash, duration, timestamp, source, current_user["id"]) sql, (trackhash, duration, timestamp, source, userid)
) )
lastrowid = cur.lastrowid lastrowid = cur.lastrowid
@@ -34,7 +36,8 @@ class SQLiteTrackLogger:
""" """
with SQLiteManager(userdata_db=True) as cur: with SQLiteManager(userdata_db=True) as cur:
sql = f"""SELECT * FROM track_logger WHERE userid = {current_user['id']} ORDER BY timestamp DESC""" userid = get_current_userid()
sql = f"""SELECT * FROM track_logger WHERE userid = {userid} ORDER BY timestamp DESC"""
cur.execute(sql) cur.execute(sql)
rows = cur.fetchall() rows = cur.fetchall()
+31 -5
View File
@@ -60,7 +60,15 @@ class SQLitePlaylistMethods:
@staticmethod @staticmethod
def get_all_playlists(): def get_all_playlists():
with SQLiteManager(userdata_db=True) as cur: with SQLiteManager(userdata_db=True) as cur:
cur.execute(f"SELECT * FROM playlists WHERE userid = {current_user['id']}") userid = 1
try:
userid = current_user["id"]
except RuntimeError:
# Catch this error raised during migration execution
pass
cur.execute(f"SELECT * FROM playlists WHERE userid = {userid}")
playlists = cur.fetchall() playlists = cur.fetchall()
cur.close() cur.close()
@@ -92,7 +100,15 @@ class SQLitePlaylistMethods:
Adds a string item to a json dumped list using a playlist id and field name. 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. 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']}" userid = 1
try:
userid = current_user["id"]
except RuntimeError:
# Catch this error raised during migration execution
pass
sql = f"SELECT {field} FROM playlists WHERE id = ? and userid = {userid}"
with SQLiteManager(userdata_db=True) as cur: with SQLiteManager(userdata_db=True) as cur:
cur.execute(sql, (playlist_id,)) cur.execute(sql, (playlist_id,))
@@ -173,10 +189,17 @@ class SQLitePlaylistMethods:
""" """
sql = """UPDATE playlists SET trackhashes = ? WHERE id = ?""" sql = """UPDATE playlists SET trackhashes = ? WHERE id = ?"""
userid = 1
try:
userid = current_user["id"]
except RuntimeError:
# Catch this error raised during migration execution
pass
with SQLiteManager(userdata_db=True) as cur: with SQLiteManager(userdata_db=True) as cur:
cur.execute( cur.execute(
f"SELECT trackhashes FROM playlists WHERE id = ? and userid = {current_user['id']}", f"SELECT trackhashes FROM playlists WHERE id = ? and userid = {userid}",
(playlistid,), (playlistid,),
) )
data = cur.fetchone() data = cur.fetchone()
@@ -185,17 +208,20 @@ class SQLitePlaylistMethods:
return return
trackhashes: list[str] = json.loads(data[0]) trackhashes: list[str] = json.loads(data[0])
to_remove = []
for track in tracks: for track in tracks:
# { # {
# trackhash: str; # trackhash: str;
# index: int; # index: int;
# } # }
index = trackhashes.index(track["trackhash"]) index = trackhashes.index(track["trackhash"])
if index == track["index"]: if index == track["index"]:
trackhashes.remove(track["trackhash"]) to_remove.append(track["trackhash"])
for trackhash in to_remove:
trackhashes.remove(trackhash)
cur.execute(sql, (json.dumps(trackhashes), playlistid)) cur.execute(sql, (json.dumps(trackhashes), playlistid))
+14
View File
@@ -98,6 +98,20 @@ class SQLiteTrackMethods:
return tuple_to_track(row) return tuple_to_track(row)
return None return None
@staticmethod
def get_track_by_albumhash(albumhash: str):
"""
Gets a track using its albumhash. Returns a Track object or None.
"""
with SQLiteManager() as cur:
cur.execute("SELECT * FROM tracks WHERE albumhash=?", (albumhash,))
row = cur.fetchone()
if row is not None:
return tuple_to_track(row)
return None
@staticmethod @staticmethod
def remove_tracks_by_filepaths(filepaths: str | set[str]): def remove_tracks_by_filepaths(filepaths: str | set[str]):
+45 -30
View File
@@ -8,6 +8,7 @@ from app.settings import SUPPORTED_FILES
from app.utils.wintools import win_replace_slash from app.utils.wintools import win_replace_slash
from app.store.tracks import TrackStore from app.store.tracks import TrackStore
from app.db import TrackTable as TrackDB
def create_folder(path: str, trackcount=0, foldercount=0) -> Folder: def create_folder(path: str, trackcount=0, foldercount=0) -> Folder:
@@ -37,44 +38,52 @@ def get_first_child_from_path(root: str, maybe_child: str):
return os.path.join(root, first) return os.path.join(root, first)
def get_folders(paths: list[str]): def get_folders(paths: list[str]):
""" """
Filters out folders that don't have any tracks and Filters out folders that don't have any tracks and
returns a list of folder objects. returns a list of folder objects.
""" """
count_dict = { folders = TrackDB.count_tracks_containing_paths(paths)
"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
]
return [ return [
create_folder(f["path"], f["trackcount"], f["foldercount"]) create_folder(f["path"], f["trackcount"], foldercount=0)
for f in folders for f in folders
if f["trackcount"] > 0 if f["trackcount"] > 0
] ]
# 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
# ]
# return [
# create_folder(f["path"], f["trackcount"], f["foldercount"])
# for f in folders
# if f["trackcount"] > 0
# ]
class GetFilesAndDirs: class GetFilesAndDirs:
@@ -131,7 +140,13 @@ class GetFilesAndDirs:
files_.sort(key=lambda f: f["time"]) files_.sort(key=lambda f: f["time"])
files = [f["path"] for f in files_] files = [f["path"] for f in files_]
tracks = TrackStore.get_tracks_by_filepaths(files) tracks = []
if files:
tracks = TrackDB.get_tracks_by_filepaths(files)
print("printing files")
print(tracks)
# tracks = TrackStore.get_tracks_by_filepaths(files)
folders = [] folders = []
if not self.tracks_only: if not self.tracks_only:
@@ -145,7 +160,7 @@ class GetFilesAndDirs:
return { return {
"path": path, "path": path,
"tracks": serialize_tracks(tracks), "tracks": tracks,
"folders": folders, "folders": folders,
} }
+36 -34
View File
@@ -7,6 +7,7 @@ from requests import ConnectionError as RequestConnectionError
from requests import ReadTimeout from requests import ReadTimeout
from app import settings from app import settings
from app.db import TrackTable
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
from app.db.sqlite.lastfm.similar_artists import SQLiteLastFMSimilarArtists as lastfmdb from app.db.sqlite.lastfm.similar_artists import SQLiteLastFMSimilarArtists as lastfmdb
from app.db.sqlite.settings import SettingsSQLMethods as sdb from app.db.sqlite.settings import SettingsSQLMethods as sdb
@@ -121,14 +122,14 @@ class Populate:
return return
@staticmethod @staticmethod
def remove_modified(tracks: Generator[Track, None, None]): def remove_modified(tracks: Generator[TrackTable, None, None]):
""" """
Removes tracks from the database that have been modified Removes tracks from the database that have been modified
since they were added to the database. since they were added to the database.
""" """
unmodified_paths = set() unmodified_paths = set()
modified_tracks: list[Track] = [] modified_tracks: list[TrackTable] = []
modified_paths = set() modified_paths = set()
for track in tracks: for track in tracks:
@@ -151,18 +152,6 @@ class Populate:
@staticmethod @staticmethod
def tag_untagged(untagged: set[str], key: str): 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"): for file in tqdm(untagged, desc="Reading files"):
if POPULATE_KEY != key: if POPULATE_KEY != key:
log.warning("'Populate.tag_untagged': Populate key changed") log.warning("'Populate.tag_untagged': Populate key changed")
@@ -171,36 +160,49 @@ class Populate:
tags = get_tags(file) tags = get_tags(file)
if tags is not None: if tags is not None:
tagged_tracks.append(tags) TrackTable.insert_one(tags)
track = Track(**tags)
track.fav_userids = list(records.get(track.trackhash, set())) # log.info("Found %s new tracks", len(untagged))
# # tagged_tracks: deque[dict] = deque()
# # tagged_count = 0
TrackStore.add_track(track) # favs = favdb.get_fav_tracks()
# records = dict()
if not AlbumStore.album_exists(track.albumhash): # for fav in favs:
AlbumStore.add_album(AlbumStore.create_album(track)) # r = records.setdefault(fav[1], set())
# r.add(fav[4])
for artist in track.artists: # tagged_tracks.append(tags)
if not ArtistStore.artist_exists(artist.artisthash): # track = Track(**tags)
ArtistStore.add_artist(Artist(artist.name))
for artist in track.albumartists: # track.fav_userids = list(records.get(track.trackhash, set()))
if not ArtistStore.artist_exists(artist.artisthash):
ArtistStore.add_artist(Artist(artist.name))
tagged_count += 1 # TrackStore.add_track(track)
else:
log.warning("Could not read file: %s", file)
if len(tagged_tracks) > 0: # if not AlbumStore.album_exists(track.albumhash):
log.info("Adding %s tracks to database", len(tagged_tracks)) # AlbumStore.add_album(AlbumStore.create_album(track))
insert_many_tracks(tagged_tracks)
log.info("Added %s/%s tracks", tagged_count, len(untagged)) # 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 @staticmethod
def extract_thumb_with_overwrite(tracks: list[Track]): def extract_thumb_with_overwrite(tracks: list[TrackTable]):
""" """
Extracts the thumbnail from a list of filepaths, Extracts the thumbnail from a list of filepaths,
overwriting the existing thumbnail if it exists, overwriting the existing thumbnail if it exists,
+1 -1
View File
@@ -195,7 +195,7 @@ class TopResults:
except AttributeError: except AttributeError:
item.duration = 0 item.duration = 0
item.check_is_single(tracks) item.is_single(tracks)
if not item.is_single: if not item.is_single:
item.check_type() item.check_type()
+154
View File
@@ -0,0 +1,154 @@
from pprint import pprint
from app.db import AlbumTable, ArtistTable, TrackTable
from app.lib.taglib import get_tags
from app.utils.filesystem import run_fast_scandir
from app.utils.parsers import get_base_album_title
from app.utils.progressbar import tqdm
class IndexTracks:
def __init__(self) -> None:
dirs_to_scan = ["/home/cwilvx/Music"]
files = set()
for _dir in dirs_to_scan:
files = files.union(run_fast_scandir(_dir, full=True)[1])
self.tag_untagged(files)
# unmodified, modified_tracks = self.remove_modified(tracks)
# untagged = files - unmodified
def tag_untagged(self, files: set[str]):
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)
if tags is not None:
TrackTable.insert_one(tags)
class IndexAlbums:
def __init__(self) -> None:
albums = dict()
all_tracks: list[TrackTable] = TrackTable.get_all()
for track in all_tracks:
if track.albumhash not in albums:
albums[track.albumhash] = {
"albumartists": track.albumartists,
"albumhash": track.albumhash,
"base_title": None,
"color": None,
"created_date": None,
"date": None,
"duration": track.duration,
"genres": [*track.genre] if track.genre else [],
"og_title": track.og_album,
"title": track.album,
"trackcount": 1,
"dates": [track.date],
"created_dates": [track.last_mod],
}
else:
album = albums[track.albumhash]
album["trackcount"] += 1
album["duration"] += track.duration
album["dates"].append(track.date)
album["created_dates"].append(track.last_mod)
if track.genre:
album["genres"].append(track.genre)
for album in albums.values():
album["date"] = min(album["dates"])
album["created_date"] = min(album["created_dates"])
genres = []
for genre in album["genres"]:
if genre not in genres:
genres.append(genre)
album["genres"] = genres
album["base_title"], _ = get_base_album_title(album["og_title"])
del album["dates"]
del album["created_dates"]
pprint(albums)
AlbumTable.insert_many(list(albums.values()))
class IndexArtists:
def __init__(self) -> None:
all_tracks: list[TrackTable] = TrackTable.get_all()
artists = dict()
for track in all_tracks:
this_artists = track.artists
for a in track.albumartists:
if a not in this_artists:
this_artists.append(a)
for artist in this_artists:
if artist["artisthash"] not in artists:
artists[artist["artisthash"]] = {
"albumcount": None,
"albums": {track.albumhash},
"artisthash": artist["artisthash"],
"created_dates": [track.last_mod],
"dates": [track.date],
"date": None,
"duration": track.duration,
"genres": [*track.genre] if track.genre else [],
"name": artist["name"],
"trackcount": None,
"tracks": {track.trackhash},
}
else:
artist = artists[artist["artisthash"]]
artist["duration"] += track.duration
artist["albums"].add(track.albumhash)
artist["tracks"].add(track.trackhash)
artist["dates"].append(track.date)
artist["created_dates"].append(track.last_mod)
if track.genre:
artist["genres"].append(track.genre)
for artist in artists.values():
artist["albumcount"] = len(artist["albums"])
artist["trackcount"] = len(artist["tracks"])
artist["date"] = min(artist["dates"])
artist["created_date"] = min(artist["created_dates"])
genres = []
for genre in artist["genres"]:
if genre not in genres:
genres.append(genre)
artist["genres"] = genres
del artist["tracks"]
del artist["albums"]
del artist["dates"]
del artist["created_dates"]
pprint(artists)
ArtistTable.insert_many(list(artists.values()))
class IndexEverything:
def __init__(self) -> None:
# IndexTracks()
# IndexAlbums()
# IndexArtists()
pass
+100 -4
View File
@@ -8,9 +8,16 @@ import pendulum
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from tinytag import TinyTag from tinytag import TinyTag
from app.config import UserConfig
from app.settings import Defaults, Paths from app.settings import Defaults, Paths
from app.utils.hashing import create_hash from app.utils.hashing import create_hash
from app.utils.parsers import split_artists from app.utils.parsers import (
clean_title,
get_base_title_and_versions,
parse_feat_from_title,
remove_prod,
split_artists,
)
from app.utils.wintools import win_replace_slash from app.utils.wintools import win_replace_slash
@@ -206,9 +213,7 @@ def get_tags(filepath: str):
except KeyError: except KeyError:
tags.copyright = None tags.copyright = None
tags.albumhash = create_hash(tags.album, tags.albumartist) # tags.image = f"{tags.albumhash}.webp"
tags.trackhash = create_hash(tags.artist, tags.album, tags.title)
tags.image = f"{tags.albumhash}.webp"
tags.folder = win_replace_slash(os.path.dirname(filepath)) tags.folder = win_replace_slash(os.path.dirname(filepath))
tags.date = parse_date(tags.year) or int(last_mod) tags.date = parse_date(tags.year) or int(last_mod)
@@ -218,9 +223,100 @@ def get_tags(filepath: str):
tags.artists = tags.artist tags.artists = tags.artist
tags.albumartists = tags.albumartist tags.albumartists = tags.albumartist
split_artist = split_artists(tags.artist)
split_albumartists = split_artists(tags.albumartist)
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)
config = UserConfig()
# extract featured artists
if config.extractFeaturedArtists:
feat, new_title = parse_feat_from_title(tags.title)
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_artist[: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
]
# 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:
tags.genre = tags.genre.lower()
# separators = {"/", ";", "&"}
separators = set(config.genreSeparators)
contains_rnb = "r&b" in tags.genre
contains_rock = "rock & roll" in tags.genre
if contains_rnb:
tags.genre = tags.genre.replace("r&b", "RnB")
if contains_rock:
tags.genre = tags.genre.replace("rock & roll", "rock")
for s in separators:
tags.genre = tags.genre.replace(s, ",")
tags.genre = tags.genre.split(",")
tags.genre = [
{"name": g.strip(), "genrehash": create_hash(g.strip())} for g in tags.genre
]
# sub underscore with space # sub underscore with space
tags.title = tags.title.replace("_", " ") tags.title = tags.title.replace("_", " ")
tags.album = tags.album.replace("_", " ") tags.album = tags.album.replace("_", " ")
tags.trackhash = create_hash(
*[a["name"] for a in tags.artists], tags.album, tags.title
)
tags = tags.__dict__ tags = tags.__dict__
+6 -4
View File
@@ -5,6 +5,7 @@ Reads and applies the latest database migrations.
""" """
import inspect import inspect
import sys
from types import ModuleType from types import ModuleType
from app.db.sqlite.migrations import MigrationManager from app.db.sqlite.migrations import MigrationManager
from app.logger import log from app.logger import log
@@ -55,11 +56,12 @@ def apply_migrations():
to_apply = all_migrations[index:] to_apply = all_migrations[index:]
for migration in to_apply: for migration in to_apply:
try: # try:
migration.migrate() migration.migrate()
log.info("Applied migration: %s", migration.__name__) log.info("Applied migration: %s", migration.__name__)
except Exception as e: # except Exception as e:
log.error("Failed to run migration: %s", migration.__name__) # log.error("Failed to run migration: %s", migration.__name__)
log.error(e) # log.error(e)
# sys.exit(0)
MigrationManager.set_index(len(all_migrations)) MigrationManager.set_index(len(all_migrations))
+253 -25
View File
@@ -1,9 +1,90 @@
import os import os
import shutil import shutil
import sqlite3
from time import time
from app.db.sqlite.utils import SQLiteManager from app.db.sqlite.utils import SQLiteManager
from app.migrations.base import Migration from app.migrations.base import Migration
from app.settings import Paths from app.settings import Paths
import hashlib
from unidecode import unidecode
from app.db.sqlite.tracks import SQLiteTrackMethods as tdb
from app.db.sqlite.playlists import SQLitePlaylistMethods as pdb
from app.db.sqlite.logger.tracks import SQLiteTrackLogger as ldb
from app.utils.hashing import create_hash
def create_sha256_hash(*args: str, decode=False, limit=10) -> str:
"""
This function creates a case-insensitive, non-alphanumeric chars ignoring hash from the given arguments.
Example use case:
- Creating computable IDs for duplicate artists. eg. Juice WRLD and Juice Wrld should have the same ID.
:param args: The arguments to hash.
:param decode: Whether to decode the arguments before hashing.
:param limit: The number of characters to return.
:return: The hash.
"""
def remove_non_alnum(token: str) -> str:
token = token.lower().strip().replace(" ", "")
t = "".join(t for t in token if t.isalnum())
if t == "":
return token
return t
str_ = "".join(remove_non_alnum(t) for t in args)
if decode:
str_ = unidecode(str_)
str_ = str_.encode("utf-8")
str_ = hashlib.sha256(str_).hexdigest()
return str_[-limit:]
def create_sha1_hash(*args: str, decode=False, limit=10) -> str:
"""
This function creates a case-insensitive, non-alphanumeric chars ignoring hash from the given arguments.
Example use case:
- Creating computable IDs for duplicate artists. eg. Juice WRLD and Juice Wrld should have the same ID.
:param args: The arguments to hash.
:param decode: Whether to decode the arguments before hashing.
:param limit: The number of characters to return.
:return: The hash.
"""
def remove_non_alnum(token: str) -> str:
token = token.lower().strip().replace(" ", "")
t = "".join(t for t in token if t.isalnum())
if t == "":
return token
return t
str_ = "".join(remove_non_alnum(t) for t in args)
if decode:
str_ = unidecode(str_)
str_ = str_.encode("utf-8")
str_ = hashlib.sha1(str_).hexdigest()
return (
str_[: limit // 2] + str_[-limit // 2 :]
if limit % 2 == 0
else str_[: limit // 2] + str_[-limit // 2 - 1 :]
)
class _1AddTimestampToFavoritesTable(Migration): class _1AddTimestampToFavoritesTable(Migration):
""" """
@@ -13,37 +94,23 @@ class _1AddTimestampToFavoritesTable(Migration):
@staticmethod @staticmethod
def migrate(): def migrate():
# INFO: add timestamp column with automatic current timestamp # INFO: add timestamp column with automatic current timestamp
sql = f"ALTER TABLE favorites ADD COLUMN IF NOT EXISTS timestamp INTEGER NOT NULL DEFAULT 0" sql = f"ALTER TABLE favorites ADD COLUMN timestamp INTEGER NOT NULL DEFAULT 0"
# INFO: execute the sql # INFO: execute the sql
with SQLiteManager(userdata_db=True) as cur: with SQLiteManager(userdata_db=True) as cur:
try: table_exists = cur.execute(
# INFO: Add the timestamp column to the favorites table "select count(*) from pragma_table_info('favorites') where name = 'timestamp'"
cur.execute(sql) )
# INFO: Set all the timestamps to the current time table_exists = table_exists.fetchone()
cur.execute("UPDATE favorites SET timestamp = strftime('%s', 'now')")
except Exception as e:
# INFO: timestamp column already exists
pass
finally:
cur.close()
if table_exists[0] == 1:
return
class _4MoveHashesToSha1(Migration): # INFO: Add the timestamp column to the favorites table
""" timestamp = int(time())
Moves the 10 bit item hashes from sha256 to sha1 which is cur.execute(sql)
faster and more lenient on less powerful devices. cur.execute(f"UPDATE favorites SET timestamp = {timestamp}")
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): class _2DeleteOriginalThumbnails(Migration):
@@ -175,3 +242,164 @@ class _5AddUserIdToPlaylistsTable(Migration):
# INFO: Execute the sql # INFO: Execute the sql
cur.executescript(sql) cur.executescript(sql)
class _6MoveHashesToSha1(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.
@classmethod
def port_track(cls, trackhash: str):
# get the track with the track hash
track = tdb.get_track_by_trackhash(trackhash)
if track is None:
return
title = track.og_title
if track.trackhash != trackhash:
# raise ValueError("Track hash mismatch")
print("Track hash mismatch")
title = track.title
else:
print("Porting track: ", track.title)
# return the new hash
finalhash = create_sha1_hash(
", ".join(a.name for a in track.artists),
track.og_album,
title,
)
if finalhash != create_hash(
", ".join(a.name for a in track.artists), track.og_album, title
):
raise ValueError("Hash mismatch")
@classmethod
def port_album(cls, albumhash: str):
# get the first track with the album hash
track = tdb.get_track_by_albumhash(albumhash)
if track is None:
return
# return the new hash
return create_sha1_hash(
track.og_album,
", ".join(a.name for a in track.albumartists),
)
@classmethod
def port_artist(cls, artisthash: str):
# find all tracks with the artist hash
tracks = [t for t in cls.tracks if artisthash in t.artist_hashes]
if len(tracks) == 0:
return
# find the artist name
artist = [
a.name
for a in tracks[0].artists
if create_sha256_hash(a.name, decode=True) == artisthash
][0]
# return the new hash
return create_sha1_hash(artist, decode=True)
@classmethod
def migrate_favorites(cls):
with SQLiteManager(userdata_db=True) as cur:
# read all favorites
data = cur.execute("SELECT * FROM favorites")
data = data.fetchall()
for track in cls.tracks:
track.artist_hashes = "-".join(
[create_sha256_hash(a.name, decode=True) for a in track.artists]
)
for entry in data:
# hash is the 2nd column in the table
hash = entry[1]
# entry type is the 3rd column in the table
if entry[2] == "track":
newhash = cls.port_track(hash)
if newhash:
cur.execute(
f"UPDATE favorites SET hash = '{newhash}' WHERE hash = '{hash}' AND type = 'track'"
)
elif entry[2] == "album":
newhash = cls.port_album(hash)
if newhash:
cur.execute(
f"UPDATE favorites SET hash = '{newhash}' WHERE hash = '{hash}' AND type = 'album'"
)
elif entry[2] == "artist":
newhash = cls.port_artist(hash)
if newhash:
cur.execute(
f"UPDATE favorites SET hash = '{newhash}' WHERE hash = '{hash}' AND type = 'artist'"
)
@classmethod
def migrate_playlists(cls):
playlists = pdb.get_all_playlists()
for playlist in playlists:
# remove previous hashes
to_remove = [
{"trackhash": trackhash, "index": index}
for index, trackhash in enumerate(playlist.trackhashes)
]
pdb.remove_tracks_from_playlist(playlist.id, to_remove)
# add new hashes
newhashes = [
cls.port_track(trackhash) for trackhash in playlist.trackhashes
]
newhashes = [h for h in newhashes if h is not None]
pdb.add_tracks_to_playlist(playlist.id, newhashes)
print("Ported playlist: ", playlist.name)
print("Total tracks: ", len(newhashes))
@classmethod
def migrate_scrobble(cls):
# read all logs
logs = ldb.get_all()
with SQLiteManager(userdata_db=True) as cur:
# for each log, port the hash
for log in logs:
newhash = cls.port_track(log[1])
if newhash:
cur.execute(
f"UPDATE track_logger SET trackhash = '{newhash}' WHERE trackhash = '{log[1]}'"
)
@classmethod
def migrate(cls):
cls.tracks = list(tdb.get_all_tracks())
cls.migrate_favorites()
# cls.migrate_playlists()
# cls.migrate_scrobble()
+113 -95
View File
@@ -2,6 +2,7 @@ import dataclasses
import datetime import datetime
from dataclasses import dataclass from dataclasses import dataclass
from app.config import UserConfig
from app.settings import SessionVarKeys, get_flag from app.settings import SessionVarKeys, get_flag
from ..utils.hashing import create_hash from ..utils.hashing import create_hash
@@ -16,94 +17,111 @@ class Album:
Creates an album object Creates an album object
""" """
id: int
albumartists: list[dict[str, str]]
albumhash: str albumhash: str
base_title: str
color: str
created_date: int
date: int
duration: int
genres: list[dict[str, str]]
og_title: str
title: str title: str
albumartists: list[Artist] trackcount: int
albumartists_hashes: str = "" 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) versions: list[str] = dataclasses.field(default_factory=list)
def __post_init__(self): def __post_init__(self):
self.title = self.title.strip() self.date = datetime.datetime.fromtimestamp(self.date).year
self.og_title = self.title
self.image = self.albumhash + ".webp"
# Fetch album artists from title # albumhash: str
if get_flag(SessionVarKeys.EXTRACT_FEAT): # title: str
featured, self.title = parse_feat_from_title(self.title) # albumartists: list[Artist]
if len(featured) > 0: # albumartists_hashes: str = ""
original_lower = "-".join([a.name.lower() for a in self.albumartists]) # image: str = ""
self.albumartists.extend( # count: int = 0
[Artist(a) for a in featured if a.lower() not in original_lower] # duration: int = 0
) # colors: list[str] = dataclasses.field(default_factory=list)
# date: str = ""
from ..store.tracks import TrackStore # 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
TrackStore.append_track_artists(self.albumhash, featured, self.title) # genres: list[str] = dataclasses.field(default_factory=list)
# Handle album version data # def __post_init__(self):
if get_flag(SessionVarKeys.CLEAN_ALBUM_TITLE): # self.title = self.title.strip()
get_versions = not get_flag(SessionVarKeys.MERGE_ALBUM_VERSIONS) # self.og_title = self.title
# self.image = self.albumhash + ".webp"
self.title, self.versions = get_base_title_and_versions( # # Fetch album artists from title
self.title, get_versions=get_versions # if get_flag(SessionVarKeys.EXTRACT_FEAT):
) # featured, self.title = parse_feat_from_title(self.title)
self.base_title = self.title
if "super_deluxe" in self.versions: # if len(featured) > 0:
self.versions.remove("deluxe") # 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 "original" in self.versions and self.check_is_soundtrack(): # from ..store.tracks import TrackStore
self.versions.remove("original")
self.versions = [v.replace("_", " ") for v in self.versions] # TrackStore.append_track_artists(self.albumhash, featured, self.title)
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) # # Handle album version data
# else:
# self.base_title = get_base_title_and_versions(
# self.title, get_versions=False
# )[0]
def set_colors(self, colors: list[str]): # self.albumartists_hashes = "-".join(a.artisthash for a in self.albumartists)
self.colors = colors
def check_type(self): # # def set_colors(self, colors: list[str]):
# # self.colors = colors
def populate_versions(self):
_, self.versions = get_base_title_and_versions(self.og_title, get_versions=True)
if "super_deluxe" in self.versions:
self.versions.remove("deluxe")
# at this point, we should know the type of album
if "original" in self.versions and self.type == "soundtrack":
self.versions.remove("original")
self.versions = [v.replace("_", " ") for v in self.versions]
def check_type(self, tracks: list[Track], singleTrackAsSingle: bool):
""" """
Runs all the checks to determine the type of album. Runs all the checks to determine the type of album.
""" """
self.is_soundtrack = self.check_is_soundtrack() if self.is_single(tracks, singleTrackAsSingle):
if self.is_soundtrack: return "single"
return
self.is_live = self.check_is_live_album() if self.is_soundtrack():
if self.is_live: return "soundtrack"
return
self.is_compilation = self.check_is_compilation() if self.is_live_album():
if self.is_compilation: return "live album"
return
self.is_EP = self.check_is_ep() if self.is_compilation():
return "compilation"
def check_is_soundtrack(self) -> bool: if self.is_ep():
return "ep"
return "album"
def is_soundtrack(self) -> bool:
""" """
Checks if the album is a soundtrack. Checks if the album is a soundtrack.
""" """
@@ -114,11 +132,11 @@ class Album:
return False return False
def check_is_compilation(self) -> bool: def is_compilation(self) -> bool:
""" """
Checks if the album is a compilation. 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() artists = "".join(artists).lower()
if "various artists" in artists: if "various artists" in artists:
@@ -137,7 +155,7 @@ class Album:
"biggest hits", "biggest hits",
"the hits", "the hits",
"the ultimate", "the ultimate",
"compilation" "compilation",
} }
for substring in substrings: for substring in substrings:
@@ -146,7 +164,7 @@ class Album:
return False return False
def check_is_live_album(self): def is_live_album(self):
""" """
Checks if the album is a live album. Checks if the album is a live album.
""" """
@@ -157,7 +175,7 @@ class Album:
return False return False
def check_is_ep(self) -> bool: def is_ep(self) -> bool:
""" """
Checks if the album is an EP. Checks if the album is an EP.
""" """
@@ -165,22 +183,22 @@ class Album:
# TODO: check against number of tracks # 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. Checks if the album is a single.
""" """
keywords = ["single version", "- 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: for keyword in keywords:
if keyword in self.og_title.lower(): if keyword in self.og_title.lower():
self.is_single = True return True
return
if show_albums_as_singles and len(tracks) == 1: # REVIEW: Reading from the config file in a for loop will be slow
self.is_single = True # TODO: Find a
return if singleTrackAsSingle and len(tracks) == 1:
return True
if ( if (
len(tracks) == 1 len(tracks) == 1
@@ -192,29 +210,29 @@ class Album:
# and tracks[0].disc == 1 # and tracks[0].disc == 1
# TODO: Review -> Are the above commented checks necessary? # TODO: Review -> Are the above commented checks necessary?
): ):
self.is_single = True return True
def get_date_from_tracks(self, tracks: list[Track]): # def get_date_from_tracks(self, tracks: list[Track]):
""" # """
Gets the date of the album its tracks. # Gets the date of the album its tracks.
Args: # Args:
tracks (list[Track]): The tracks of the album. # tracks (list[Track]): The tracks of the album.
""" # """
if self.date: # if self.date:
return # return
dates = (int(t.date) for t in tracks if t.date) # dates = (int(t.date) for t in tracks if t.date)
try: # try:
self.date = datetime.datetime.fromtimestamp(min(dates)).year # self.date = datetime.datetime.fromtimestamp(min(dates)).year
except: # except:
self.date = datetime.datetime.now().year # self.date = datetime.datetime.now().year
def set_count(self, count: int): # def set_count(self, count: int):
self.count = count # self.count = count
def set_duration(self, duration: int): # def set_duration(self, duration: int):
self.duration = duration # self.duration = duration
def set_created_date(self, created_date: int): # def set_created_date(self, created_date: int):
self.created_date = created_date # self.created_date = created_date
+6
View File
@@ -23,6 +23,12 @@ class ArtistMinimal:
if self.artisthash == "5a37d5315e": if self.artisthash == "5a37d5315e":
self.name = "Juice WRLD" self.name = "Juice WRLD"
def to_json(self):
return {
"name": self.name,
"artisthash": self.artisthash,
}
@dataclass(slots=True) @dataclass(slots=True)
class Artist(ArtistMinimal): class Artist(ArtistMinimal):
+137 -115
View File
@@ -4,7 +4,6 @@ from pathlib import Path
from flask_jwt_extended import current_user from flask_jwt_extended import current_user
from app.settings import SessionVarKeys, get_flag from app.settings import SessionVarKeys, get_flag
from app.utils.hashing import create_hash from app.utils.hashing import create_hash
from app.utils.parsers import ( from app.utils.parsers import (
@@ -24,10 +23,11 @@ class Track:
Track class Track class
""" """
id: int
album: str album: str
albumartists: str | list[ArtistMinimal] albumartists: list[dict[str, str]]
albumhash: str albumhash: str
artists: str | list[ArtistMinimal] artists: str
bitrate: int bitrate: int
copyright: str copyright: str
date: int date: int
@@ -35,152 +35,174 @@ class Track:
duration: int duration: int
filepath: str filepath: str
folder: str folder: str
genre: str | list[str] genre: list[dict[str, str]]
last_mod: int
og_album: str
og_title: str
title: str title: str
track: int track: int
trackhash: str trackhash: str
last_mod: str | int
image: str = "" _pos: int = 0
artist_hashes: str = "" _ati: str = ""
fav_userids: list = field(default_factory=list) # album: str
""" # albumartists: str | list[ArtistMinimal]
A string of user ids separated by commas. # albumhash: str
""" # artists: str | list[ArtistMinimal]
# is_favorite: bool = False # bitrate: int
# copyright: str
# date: int
# disc: int
# duration: int
# filepath: str
# folder: str
# genre: str | list[str]
# title: str
# track: int
# trackhash: str
# last_mod: str | int
@property # image: str = ""
def is_favorite(self): # artist_hashes: str = ""
return current_user['id'] in self.fav_userids
# temporary attributes # fav_userids: list = field(default_factory=list)
_pos: int = 0 # for sorting tracks by disc and track number # """
_ati: str = ( # A string of user ids separated by commas.
"" # (album track identifier) for removing duplicates when merging album versions # """
) # # is_favorite: bool = False
og_title: str = "" # @property
og_album: str = "" # def is_favorite(self):
created_date: float = 0.0 # return current_user["id"] in self.fav_userids
def set_created_date(self): # # temporary attributes
try: # _pos: int = 0 # for sorting tracks by disc and track number
self.created_date = Path(self.filepath).stat().st_ctime # _ati: str = (
except FileNotFoundError: # "" # (album track identifier) for removing duplicates when merging album versions
pass # )
def __post_init__(self): # og_title: str = ""
self.og_title = self.title # og_album: str = ""
self.og_album = self.album # created_date: float = 0.0
self.last_mod = int(self.last_mod)
self.date = int(self.date)
# add a trailing slash to the folder path # def set_created_date(self):
# to avoid matching a folder starting with the same name as the root path # try:
# eg. .../Music and .../Music Videos # self.created_date = Path(self.filepath).stat().st_ctime
self.folder = os.path.join(self.folder, "") # except FileNotFoundError:
# pass
if self.artists is not None: # def __post_init__(self):
artists = split_artists(self.artists) # self.og_title = self.title
new_title = self.title # self.og_album = self.album
# self.last_mod = int(self.last_mod)
# self.date = int(self.date)
if get_flag(SessionVarKeys.EXTRACT_FEAT): # # add a trailing slash to the folder path
featured, new_title = parse_feat_from_title(self.title) # # to avoid matching a folder starting with the same name as the root path
original_lower = "-".join([create_hash(a) for a in artists]) # # eg. .../Music and .../Music Videos
artists.extend( # self.folder = os.path.join(self.folder, "")
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) # if self.artists is not None:
self.artists = [ArtistMinimal(a) for a in artists] # artists = split_artists(self.artists)
# new_title = self.title
albumartists = split_artists(self.albumartists) # 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
# )
if not albumartists: # self.artist_hashes = "-".join(create_hash(a, decode=True) for a in artists)
self.albumartists = self.artists[:1] # self.artists = [ArtistMinimal(a) for a in artists]
else:
self.albumartists = [ArtistMinimal(a) for a in albumartists]
if get_flag(SessionVarKeys.REMOVE_PROD): # albumartists = split_artists(self.albumartists)
new_title = remove_prod(new_title)
# if track is a single # if not albumartists:
if self.og_title == self.album: # self.albumartists = self.artists[:1]
self.rename_album(new_title) # else:
# self.albumartists = [ArtistMinimal(a) for a in albumartists]
if get_flag(SessionVarKeys.REMOVE_REMASTER_FROM_TRACK): # if get_flag(SessionVarKeys.REMOVE_PROD):
new_title = clean_title(new_title) # new_title = remove_prod(new_title)
self.title = new_title # if track is a single
# if self.og_title == self.album:
# self.rename_album(new_title)
if get_flag(SessionVarKeys.CLEAN_ALBUM_TITLE): # if get_flag(SessionVarKeys.REMOVE_REMASTER_FROM_TRACK):
self.album, _ = get_base_title_and_versions( # new_title = clean_title(new_title)
self.album, get_versions=False
)
if get_flag(SessionVarKeys.MERGE_ALBUM_VERSIONS): # self.title = new_title
self.recreate_albumhash()
self.image = self.albumhash + ".webp" # if get_flag(SessionVarKeys.CLEAN_ALBUM_TITLE):
# self.album, _ = get_base_title_and_versions(
# self.album, get_versions=False
# )
if self.genre is not None and self.genre != "": # if get_flag(SessionVarKeys.MERGE_ALBUM_VERSIONS):
self.genre = self.genre.lower() # self.recreate_albumhash()
separators = {"/", ";", "&"}
contains_rnb = "r&b" in self.genre # self.image = self.albumhash + ".webp"
contains_rock = "rock & roll" in self.genre
if contains_rnb: # if self.genre is not None and self.genre != "":
self.genre = self.genre.replace("r&b", "RnB") # self.genre = self.genre.lower()
# separators = {"/", ";", "&"}
if contains_rock: # contains_rnb = "r&b" in self.genre
self.genre = self.genre.replace("rock & roll", "rock") # contains_rock = "rock & roll" in self.genre
for s in separators: # if contains_rnb:
self.genre: str = self.genre.replace(s, ",") # self.genre = self.genre.replace("r&b", "RnB")
self.genre = self.genre.split(",") # if contains_rock:
self.genre = [g.strip() for g in self.genre] # self.genre = self.genre.replace("rock & roll", "rock")
self.recreate_hash() # for s in separators:
self.set_created_date() # self.genre: str = self.genre.replace(s, ",")
def recreate_hash(self): # self.genre = self.genre.split(",")
""" # self.genre = [g.strip() for g in self.genre]
Recreates a track hash if the track title was altered
to prevent duplicate tracks having different hashes.
"""
if self.og_title == self.title and self.og_album == self.album:
return
self.trackhash = create_hash( # self.recreate_hash()
", ".join(a.name for a in self.artists), self.og_album, self.title # self.set_created_date()
)
def recreate_artists_hash(self): # def recreate_hash(self):
""" # """
Recreates a track's artist hashes if the artist list was altered # Recreates a track hash if the track title was altered
""" # to prevent duplicate tracks having different hashes.
self.artist_hashes = "-".join(a.artisthash for a in self.artists) # """
# if self.og_title == self.title and self.og_album == self.album:
# return
def recreate_albumhash(self): # self.trackhash = create_hash(
""" # ", ".join(a.name for a in self.artists), self.og_album, self.title
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): # def recreate_artists_hash(self):
""" # """
Renames an album # Recreates a track's artist hashes if the artist list was altered
""" # """
self.album = new_album # self.artist_hashes = "-".join(a.artisthash for a in self.artists)
def add_artists(self, artists: list[str], new_album_title: str): # def recreate_albumhash(self):
for artist in artists: # """
if create_hash(artist, decode=True) not in self.artist_hashes: # Recreates an albumhash of a track to merge all versions of an album.
self.artists.append(ArtistMinimal(artist)) # """
# albumartists = (a.name for a in self.albumartists)
# self.albumhash = create_hash(self.album, *albumartists)
self.recreate_artists_hash() # def rename_album(self, new_album: str):
self.rename_album(new_album_title) # """
# 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)
+13
View File
@@ -1,6 +1,8 @@
import hmac import hmac
import hashlib import hashlib
from flask_jwt_extended import current_user
from app.config import UserConfig from app.config import UserConfig
@@ -29,3 +31,14 @@ def check_password(password: str, hashed: str) -> bool:
""" """
return hmac.compare_digest(hash_password(password), hashed) 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
+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"])
+6 -4
View File
@@ -21,6 +21,7 @@ import setproctitle
from app.api import create_api from app.api import create_api
from app.arg_handler import ProcessArgs from app.arg_handler import ProcessArgs
from app.lib.tagger import IndexEverything
from app.lib.watchdogg import Watcher as WatchDog from app.lib.watchdogg import Watcher as WatchDog
from app.periodic_scan import run_periodic_scans from app.periodic_scan import run_periodic_scans
from app.plugins.register import register_plugins from app.plugins.register import register_plugins
@@ -49,10 +50,11 @@ werkzeug.setLevel(logging.ERROR)
# Background tasks # Background tasks
# @background @background
# def bg_run_setup(): def bg_run_setup():
# pass pass
# run_periodic_scans() # run_periodic_scans()
IndexEverything()
# @background # @background
@@ -63,7 +65,7 @@ werkzeug.setLevel(logging.ERROR)
@background @background
def run_swingmusic(): def run_swingmusic():
log_startup_info() log_startup_info()
# bg_run_setup() bg_run_setup()
register_plugins() register_plugins()
# start_watchdog() # start_watchdog()
Generated
+88 -1
View File
@@ -2164,6 +2164,93 @@ files = [
{file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, {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]] [[package]]
name = "tabulate" name = "tabulate"
version = "0.9.0" version = "0.9.0"
@@ -2515,4 +2602,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.10,<3.12" python-versions = ">=3.10,<3.12"
content-hash = "5722346cbfc224340877e337d4cee2c8e8a3a7ea68f9a64d9c5806e0ebcf919a" content-hash = "333baa055ac4a32ed914fb46025a48559575806dafba7db5aac97a3878ade23c"
+1
View File
@@ -26,6 +26,7 @@ watchdog = "^4.0.0"
pendulum = "^3.0.0" pendulum = "^3.0.0"
flask-openapi3 = "^3.0.2" flask-openapi3 = "^3.0.2"
flask-jwt-extended = "^4.6.0" flask-jwt-extended = "^4.6.0"
sqlalchemy = "^2.0.31"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pylint = "^2.15.5" pylint = "^2.15.5"