mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-05 04:53:01 +00:00
Merge branch 'custom-root-dirs'
This commit is contained in:
@@ -16,7 +16,6 @@ __pycache__
|
|||||||
.hypothesis
|
.hypothesis
|
||||||
sqllib.py
|
sqllib.py
|
||||||
encoderx.py
|
encoderx.py
|
||||||
tests
|
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
|
|
||||||
# pyinstaller files
|
# pyinstaller files
|
||||||
|
|||||||
-44
@@ -1,44 +0,0 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
|
||||||
|
|
||||||
|
|
||||||
block_cipher = None
|
|
||||||
|
|
||||||
|
|
||||||
a = Analysis(
|
|
||||||
['manage.py'],
|
|
||||||
pathex=[],
|
|
||||||
binaries=[],
|
|
||||||
datas=[('assets', 'assets'), ('client', 'client'), ('pyinstaller.config.ini', '.')],
|
|
||||||
hiddenimports=[],
|
|
||||||
hookspath=[],
|
|
||||||
hooksconfig={},
|
|
||||||
runtime_hooks=[],
|
|
||||||
excludes=[],
|
|
||||||
win_no_prefer_redirects=False,
|
|
||||||
win_private_assemblies=False,
|
|
||||||
cipher=block_cipher,
|
|
||||||
noarchive=False,
|
|
||||||
)
|
|
||||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
|
||||||
|
|
||||||
exe = EXE(
|
|
||||||
pyz,
|
|
||||||
a.scripts,
|
|
||||||
a.binaries,
|
|
||||||
a.zipfiles,
|
|
||||||
a.datas,
|
|
||||||
[],
|
|
||||||
name='swing',
|
|
||||||
debug=False,
|
|
||||||
bootloader_ignore_signals=False,
|
|
||||||
strip=False,
|
|
||||||
upx=True,
|
|
||||||
upx_exclude=[],
|
|
||||||
runtime_tmpdir=None,
|
|
||||||
console=True,
|
|
||||||
disable_windowed_traceback=False,
|
|
||||||
argv_emulation=False,
|
|
||||||
target_arch=None,
|
|
||||||
codesign_identity=None,
|
|
||||||
entitlements_file=None,
|
|
||||||
)
|
|
||||||
+11
-10
@@ -5,8 +5,8 @@ This module combines all API blueprints into a single Flask app instance.
|
|||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
|
||||||
from app.api import album, artist, favorites, folder, playlist, search, track
|
from app.api import (album, artist, favorites, folder, imgserver, playlist,
|
||||||
from app.imgserver import imgbp as imgserver
|
search, settings, track)
|
||||||
|
|
||||||
|
|
||||||
def create_api():
|
def create_api():
|
||||||
@@ -18,13 +18,14 @@ def create_api():
|
|||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
|
|
||||||
app.register_blueprint(album.albumbp)
|
app.register_blueprint(album.api)
|
||||||
app.register_blueprint(artist.artistbp)
|
app.register_blueprint(artist.api)
|
||||||
app.register_blueprint(track.trackbp)
|
app.register_blueprint(track.api)
|
||||||
app.register_blueprint(search.searchbp)
|
app.register_blueprint(search.api)
|
||||||
app.register_blueprint(folder.folderbp)
|
app.register_blueprint(folder.api)
|
||||||
app.register_blueprint(playlist.playlistbp)
|
app.register_blueprint(playlist.api)
|
||||||
app.register_blueprint(favorites.favbp)
|
app.register_blueprint(favorites.api)
|
||||||
app.register_blueprint(imgserver)
|
app.register_blueprint(imgserver.api)
|
||||||
|
app.register_blueprint(settings.api)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
+8
-17
@@ -12,15 +12,14 @@ from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
|
|||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models import FavType, Track
|
from app.models import FavType, Track
|
||||||
|
|
||||||
|
|
||||||
get_album_by_id = adb.get_album_by_id
|
get_album_by_id = adb.get_album_by_id
|
||||||
get_albums_by_albumartist = adb.get_albums_by_albumartist
|
get_albums_by_albumartist = adb.get_albums_by_albumartist
|
||||||
check_is_fav = favdb.check_is_favorite
|
check_is_fav = favdb.check_is_favorite
|
||||||
|
|
||||||
albumbp = Blueprint("album", __name__, url_prefix="")
|
api = Blueprint("album", __name__, url_prefix="")
|
||||||
|
|
||||||
|
|
||||||
@albumbp.route("/album", methods=["POST"])
|
@api.route("/album", methods=["POST"])
|
||||||
def get_album():
|
def get_album():
|
||||||
"""Returns all the tracks in the given album."""
|
"""Returns all the tracks in the given album."""
|
||||||
|
|
||||||
@@ -62,23 +61,16 @@ def get_album():
|
|||||||
tracks = utils.remove_duplicates(tracks)
|
tracks = utils.remove_duplicates(tracks)
|
||||||
|
|
||||||
album.count = len(tracks)
|
album.count = len(tracks)
|
||||||
|
album.get_date_from_tracks(tracks)
|
||||||
for track in tracks:
|
|
||||||
if track.date != "Unknown":
|
|
||||||
album.date = track.date
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
album.duration = sum((t.duration for t in tracks))
|
album.duration = sum((t.duration for t in tracks))
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
album.duration = 0
|
album.duration = 0
|
||||||
|
|
||||||
if (
|
album.check_is_single(tracks)
|
||||||
album.count == 1
|
|
||||||
and tracks[0].title == album.title
|
if album.is_single:
|
||||||
# and tracks[0].track == 1
|
|
||||||
# and tracks[0].disc == 1
|
|
||||||
):
|
|
||||||
album.is_single = True
|
album.is_single = True
|
||||||
else:
|
else:
|
||||||
album.check_type()
|
album.check_type()
|
||||||
@@ -88,7 +80,7 @@ def get_album():
|
|||||||
return {"tracks": tracks, "info": album}
|
return {"tracks": tracks, "info": album}
|
||||||
|
|
||||||
|
|
||||||
@albumbp.route("/album/<albumhash>/tracks", methods=["GET"])
|
@api.route("/album/<albumhash>/tracks", methods=["GET"])
|
||||||
def get_album_tracks(albumhash: str):
|
def get_album_tracks(albumhash: str):
|
||||||
"""
|
"""
|
||||||
Returns all the tracks in the given album.
|
Returns all the tracks in the given album.
|
||||||
@@ -105,7 +97,7 @@ def get_album_tracks(albumhash: str):
|
|||||||
return {"tracks": tracks}
|
return {"tracks": tracks}
|
||||||
|
|
||||||
|
|
||||||
@albumbp.route("/album/from-artist", methods=["POST"])
|
@api.route("/album/from-artist", methods=["POST"])
|
||||||
def get_artist_albums():
|
def get_artist_albums():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
|
||||||
@@ -130,7 +122,6 @@ def get_artist_albums():
|
|||||||
|
|
||||||
return {"data": albums}
|
return {"data": albums}
|
||||||
|
|
||||||
|
|
||||||
# @album_bp.route("/album/bio", methods=["POST"])
|
# @album_bp.route("/album/bio", methods=["POST"])
|
||||||
# def get_album_bio():
|
# def get_album_bio():
|
||||||
# """Returns the album bio for the given album."""
|
# """Returns the album bio for the given album."""
|
||||||
|
|||||||
+14
-10
@@ -5,12 +5,12 @@ from collections import deque
|
|||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
|
|
||||||
|
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models import Album, FavType, Track
|
from app.models import Album, FavType, Track
|
||||||
from app.utils import remove_duplicates
|
from app.utils import remove_duplicates
|
||||||
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
|
|
||||||
|
|
||||||
artistbp = Blueprint("artist", __name__, url_prefix="/")
|
api = Blueprint("artist", __name__, url_prefix="/")
|
||||||
|
|
||||||
|
|
||||||
class CacheEntry:
|
class CacheEntry:
|
||||||
@@ -39,7 +39,7 @@ class ArtistsCache:
|
|||||||
Holds artist page cache.
|
Holds artist page cache.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
artists: deque[CacheEntry] = deque(maxlen=6)
|
artists: deque[CacheEntry] = deque(maxlen=1)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_albums_by_artisthash(cls, artisthash: str):
|
def get_albums_by_artisthash(cls, artisthash: str):
|
||||||
@@ -48,9 +48,9 @@ class ArtistsCache:
|
|||||||
"""
|
"""
|
||||||
for (index, albums) in enumerate(cls.artists):
|
for (index, albums) in enumerate(cls.artists):
|
||||||
if albums.artisthash == artisthash:
|
if albums.artisthash == artisthash:
|
||||||
return (albums.albums, index)
|
return albums.albums, index
|
||||||
|
|
||||||
return ([], -1)
|
return [], -1
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def albums_cached(cls, artisthash: str) -> bool:
|
def albums_cached(cls, artisthash: str) -> bool:
|
||||||
@@ -131,6 +131,7 @@ class ArtistsCache:
|
|||||||
album_tracks = Store.get_tracks_by_albumhash(album.albumhash)
|
album_tracks = Store.get_tracks_by_albumhash(album.albumhash)
|
||||||
album_tracks = remove_duplicates(album_tracks)
|
album_tracks = remove_duplicates(album_tracks)
|
||||||
|
|
||||||
|
album.get_date_from_tracks(album_tracks)
|
||||||
album.check_is_single(album_tracks)
|
album.check_is_single(album_tracks)
|
||||||
|
|
||||||
entry.type_checked = True
|
entry.type_checked = True
|
||||||
@@ -156,7 +157,7 @@ def add_albums_to_cache(artisthash: str):
|
|||||||
# =======================================================
|
# =======================================================
|
||||||
|
|
||||||
|
|
||||||
@artistbp.route("/artist/<artisthash>", methods=["GET"])
|
@api.route("/artist/<artisthash>", methods=["GET"])
|
||||||
def get_artist(artisthash: str):
|
def get_artist(artisthash: str):
|
||||||
"""
|
"""
|
||||||
Get artist data.
|
Get artist data.
|
||||||
@@ -203,7 +204,7 @@ def get_artist(artisthash: str):
|
|||||||
return {"artist": artist, "tracks": tracks[:limit]}
|
return {"artist": artist, "tracks": tracks[:limit]}
|
||||||
|
|
||||||
|
|
||||||
@artistbp.route("/artist/<artisthash>/albums", methods=["GET"])
|
@api.route("/artist/<artisthash>/albums", methods=["GET"])
|
||||||
def get_artist_albums(artisthash: str):
|
def get_artist_albums(artisthash: str):
|
||||||
limit = request.args.get("limit")
|
limit = request.args.get("limit")
|
||||||
|
|
||||||
@@ -214,7 +215,6 @@ def get_artist_albums(artisthash: str):
|
|||||||
|
|
||||||
limit = int(limit)
|
limit = int(limit)
|
||||||
|
|
||||||
all_albums = []
|
|
||||||
is_cached = ArtistsCache.albums_cached(artisthash)
|
is_cached = ArtistsCache.albums_cached(artisthash)
|
||||||
|
|
||||||
if not is_cached:
|
if not is_cached:
|
||||||
@@ -242,6 +242,10 @@ def get_artist_albums(artisthash: str):
|
|||||||
albums = list(albums)
|
albums = list(albums)
|
||||||
albums = remove_EPs_and_singles(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.albumartisthash, all_albums)
|
appearances = filter(lambda a: artisthash not in a.albumartisthash, all_albums)
|
||||||
appearances = list(appearances)
|
appearances = list(appearances)
|
||||||
|
|
||||||
@@ -258,10 +262,11 @@ def get_artist_albums(artisthash: str):
|
|||||||
"singles": singles[:limit],
|
"singles": singles[:limit],
|
||||||
"eps": eps[:limit],
|
"eps": eps[:limit],
|
||||||
"appearances": appearances[:limit],
|
"appearances": appearances[:limit],
|
||||||
|
"compilations": compilations[:limit]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@artistbp.route("/artist/<artisthash>/tracks", methods=["GET"])
|
@api.route("/artist/<artisthash>/tracks", methods=["GET"])
|
||||||
def get_artist_tracks(artisthash: str):
|
def get_artist_tracks(artisthash: str):
|
||||||
"""
|
"""
|
||||||
Returns all artists by a given artist.
|
Returns all artists by a given artist.
|
||||||
@@ -275,7 +280,6 @@ def get_artist_tracks(artisthash: str):
|
|||||||
|
|
||||||
# return {"albums": albums[:limit]}
|
# return {"albums": albums[:limit]}
|
||||||
|
|
||||||
|
|
||||||
# @artist_bp.route("/artist/<artist>")
|
# @artist_bp.route("/artist/<artist>")
|
||||||
# @cache.cached()
|
# @cache.cached()
|
||||||
# def get_artist_data(artist: str):
|
# def get_artist_data(artist: str):
|
||||||
|
|||||||
+18
-14
@@ -1,17 +1,18 @@
|
|||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
|
|
||||||
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
|
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models import FavType
|
from app.models import FavType
|
||||||
from app.utils import UseBisection
|
from app.utils import UseBisection
|
||||||
|
|
||||||
favbp = Blueprint("favorite", __name__, url_prefix="/")
|
api = Blueprint("favorite", __name__, url_prefix="/")
|
||||||
|
|
||||||
|
|
||||||
def remove_none(items: list):
|
def remove_none(items: list):
|
||||||
return [i for i in items if i is not None]
|
return [i for i in items if i is not None]
|
||||||
|
|
||||||
|
|
||||||
@favbp.route("/favorite/add", methods=["POST"])
|
@api.route("/favorite/add", methods=["POST"])
|
||||||
def add_favorite():
|
def add_favorite():
|
||||||
"""
|
"""
|
||||||
Adds a favorite to the database.
|
Adds a favorite to the database.
|
||||||
@@ -32,7 +33,7 @@ def add_favorite():
|
|||||||
return {"msg": "Added to favorites"}
|
return {"msg": "Added to favorites"}
|
||||||
|
|
||||||
|
|
||||||
@favbp.route("/favorite/remove", methods=["POST"])
|
@api.route("/favorite/remove", methods=["POST"])
|
||||||
def remove_favorite():
|
def remove_favorite():
|
||||||
"""
|
"""
|
||||||
Removes a favorite from the database.
|
Removes a favorite from the database.
|
||||||
@@ -53,7 +54,7 @@ def remove_favorite():
|
|||||||
return {"msg": "Removed from favorites"}
|
return {"msg": "Removed from favorites"}
|
||||||
|
|
||||||
|
|
||||||
@favbp.route("/albums/favorite")
|
@api.route("/albums/favorite")
|
||||||
def get_favorite_albums():
|
def get_favorite_albums():
|
||||||
limit = request.args.get("limit")
|
limit = request.args.get("limit")
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ def get_favorite_albums():
|
|||||||
return {"albums": fav_albums[:limit]}
|
return {"albums": fav_albums[:limit]}
|
||||||
|
|
||||||
|
|
||||||
@favbp.route("/tracks/favorite")
|
@api.route("/tracks/favorite")
|
||||||
def get_favorite_tracks():
|
def get_favorite_tracks():
|
||||||
limit = request.args.get("limit")
|
limit = request.args.get("limit")
|
||||||
|
|
||||||
@@ -100,7 +101,7 @@ def get_favorite_tracks():
|
|||||||
return {"tracks": tracks[:limit]}
|
return {"tracks": tracks[:limit]}
|
||||||
|
|
||||||
|
|
||||||
@favbp.route("/artists/favorite")
|
@api.route("/artists/favorite")
|
||||||
def get_favorite_artists():
|
def get_favorite_artists():
|
||||||
limit = request.args.get("limit")
|
limit = request.args.get("limit")
|
||||||
|
|
||||||
@@ -124,7 +125,7 @@ def get_favorite_artists():
|
|||||||
return {"artists": artists[:limit]}
|
return {"artists": artists[:limit]}
|
||||||
|
|
||||||
|
|
||||||
@favbp.route("/favorites")
|
@api.route("/favorites")
|
||||||
def get_all_favorites():
|
def get_all_favorites():
|
||||||
"""
|
"""
|
||||||
Returns all the favorites in the database.
|
Returns all the favorites in the database.
|
||||||
@@ -149,6 +150,8 @@ def get_all_favorites():
|
|||||||
favs = favdb.get_all()
|
favs = favdb.get_all()
|
||||||
favs.reverse()
|
favs.reverse()
|
||||||
|
|
||||||
|
favs = [fav for fav in favs if fav[1] != ""]
|
||||||
|
|
||||||
tracks = []
|
tracks = []
|
||||||
albums = []
|
albums = []
|
||||||
artists = []
|
artists = []
|
||||||
@@ -161,21 +164,22 @@ def get_all_favorites():
|
|||||||
):
|
):
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if not len(tracks) >= track_limit:
|
||||||
if fav[2] == FavType.track:
|
if fav[2] == FavType.track:
|
||||||
tracks.append(fav[1])
|
tracks.append(fav[1])
|
||||||
elif fav[2] == FavType.album:
|
|
||||||
|
if not len(albums) >= album_limit:
|
||||||
|
if fav[2] == FavType.album:
|
||||||
albums.append(fav[1])
|
albums.append(fav[1])
|
||||||
elif fav[2] == FavType.artist:
|
|
||||||
|
if not len(artists) >= artist_limit:
|
||||||
|
if fav[2] == FavType.artist:
|
||||||
artists.append(fav[1])
|
artists.append(fav[1])
|
||||||
|
|
||||||
src_tracks = sorted(Store.tracks, key=lambda x: x.trackhash)
|
src_tracks = sorted(Store.tracks, key=lambda x: x.trackhash)
|
||||||
src_albums = sorted(Store.albums, key=lambda x: x.albumhash)
|
src_albums = sorted(Store.albums, key=lambda x: x.albumhash)
|
||||||
src_artists = sorted(Store.artists, key=lambda x: x.artisthash)
|
src_artists = sorted(Store.artists, key=lambda x: x.artisthash)
|
||||||
|
|
||||||
tracks = tracks[:track_limit]
|
|
||||||
albums = albums[:album_limit]
|
|
||||||
artists = artists[:artist_limit]
|
|
||||||
|
|
||||||
tracks = UseBisection(src_tracks, "trackhash", tracks)()
|
tracks = UseBisection(src_tracks, "trackhash", tracks)()
|
||||||
albums = UseBisection(src_albums, "albumhash", albums)()
|
albums = UseBisection(src_albums, "albumhash", albums)()
|
||||||
artists = UseBisection(src_artists, "artisthash", artists)()
|
artists = UseBisection(src_artists, "artisthash", artists)()
|
||||||
@@ -191,7 +195,7 @@ def get_all_favorites():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@favbp.route("/favorites/check")
|
@api.route("/favorites/check")
|
||||||
def check_favorite():
|
def check_favorite():
|
||||||
"""
|
"""
|
||||||
Checks if a favorite exists in the database.
|
Checks if a favorite exists in the database.
|
||||||
|
|||||||
+100
-5
@@ -1,28 +1,66 @@
|
|||||||
"""
|
"""
|
||||||
Contains all the folder routes.
|
Contains all the folder routes.
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
|
|
||||||
from app import settings
|
from app import settings
|
||||||
from app.lib.folderslib import GetFilesAndDirs
|
from app.lib.folderslib import GetFilesAndDirs
|
||||||
|
from app.db.sqlite.settings import SettingsSQLMethods as db
|
||||||
|
from app.models import Folder
|
||||||
|
from app.utils import create_folder_hash, is_windows, win_replace_slash
|
||||||
|
|
||||||
folderbp = Blueprint("folder", __name__, url_prefix="/")
|
api = Blueprint("folder", __name__, url_prefix="/")
|
||||||
|
|
||||||
|
|
||||||
@folderbp.route("/folder", methods=["POST"])
|
@api.route("/folder", methods=["POST"])
|
||||||
def get_folder_tree():
|
def get_folder_tree():
|
||||||
"""
|
"""
|
||||||
Returns a list of all the folders and tracks in the given folder.
|
Returns a list of all the folders and tracks in the given folder.
|
||||||
"""
|
"""
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
req_dir = "$home"
|
||||||
|
|
||||||
if data is not None:
|
if data is not None:
|
||||||
|
try:
|
||||||
req_dir: str = data["folder"]
|
req_dir: str = data["folder"]
|
||||||
else:
|
except KeyError:
|
||||||
req_dir = settings.HOME_DIR
|
req_dir = "$home"
|
||||||
|
|
||||||
|
root_dirs = db.get_root_dirs()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if req_dir == "$home" and root_dirs[0] == "$home":
|
||||||
|
req_dir = settings.USER_HOME_DIR
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
|
||||||
if req_dir == "$home":
|
if req_dir == "$home":
|
||||||
req_dir = settings.HOME_DIR
|
folders = [Path(f) for f in root_dirs]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"folders": [
|
||||||
|
Folder(
|
||||||
|
name=f.name if f.name != "" else str(f).replace("\\", "/"),
|
||||||
|
path=win_replace_slash(str(f)),
|
||||||
|
has_tracks=True,
|
||||||
|
is_sym=f.is_symlink(),
|
||||||
|
path_hash=create_folder_hash(*f.parts[1:]),
|
||||||
|
)
|
||||||
|
for f in folders
|
||||||
|
],
|
||||||
|
"tracks": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_windows():
|
||||||
|
# Trailing slash needed when drive letters are passed,
|
||||||
|
# Remember, the trailing slash is removed in the client.
|
||||||
|
req_dir = req_dir + "/"
|
||||||
|
else:
|
||||||
|
req_dir = "/" + req_dir + "/" if not req_dir.startswith("/") else req_dir + "/"
|
||||||
|
|
||||||
tracks, folders = GetFilesAndDirs(req_dir)()
|
tracks, folders = GetFilesAndDirs(req_dir)()
|
||||||
|
|
||||||
@@ -30,3 +68,60 @@ def get_folder_tree():
|
|||||||
"tracks": tracks,
|
"tracks": tracks,
|
||||||
"folders": sorted(folders, key=lambda i: i.name),
|
"folders": sorted(folders, key=lambda i: i.name),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_drives(is_win: bool = False):
|
||||||
|
"""
|
||||||
|
Returns a list of all the drives on a Windows machine.
|
||||||
|
"""
|
||||||
|
drives = psutil.disk_partitions(all=False)
|
||||||
|
drives = [d.mountpoint for d in drives]
|
||||||
|
|
||||||
|
if is_win:
|
||||||
|
drives = [win_replace_slash(d) for d in drives]
|
||||||
|
else:
|
||||||
|
remove = ["/boot", "/boot/efi", "/tmp"]
|
||||||
|
drives = [d for d in drives if d not in remove]
|
||||||
|
|
||||||
|
return drives
|
||||||
|
|
||||||
|
|
||||||
|
@api.route("/folder/dir-browser", methods=["POST"])
|
||||||
|
def list_folders():
|
||||||
|
"""
|
||||||
|
Returns a list of all the folders in the given folder.
|
||||||
|
"""
|
||||||
|
data = request.get_json()
|
||||||
|
is_win = is_windows()
|
||||||
|
|
||||||
|
try:
|
||||||
|
req_dir: str = data["folder"]
|
||||||
|
except KeyError:
|
||||||
|
req_dir = "$root"
|
||||||
|
|
||||||
|
if req_dir == "$root":
|
||||||
|
# req_dir = settings.USER_HOME_DIR
|
||||||
|
# if is_win:
|
||||||
|
return {
|
||||||
|
"folders": [{"name": d, "path": d} for d in get_all_drives(is_win=is_win)]
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_win:
|
||||||
|
req_dir = req_dir + "/"
|
||||||
|
else:
|
||||||
|
req_dir = "/" + req_dir + "/"
|
||||||
|
req_dir = str(Path(req_dir).resolve())
|
||||||
|
|
||||||
|
try:
|
||||||
|
entries = os.scandir(req_dir)
|
||||||
|
except PermissionError:
|
||||||
|
return {"folders": []}
|
||||||
|
|
||||||
|
dirs = [e.name for e in entries if e.is_dir() and not e.name.startswith(".")]
|
||||||
|
dirs = [
|
||||||
|
{"name": d, "path": win_replace_slash(os.path.join(req_dir, d))} for d in dirs
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"folders": sorted(dirs, key=lambda i: i["name"]),
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Blueprint, request, send_from_directory
|
from flask import Blueprint, send_from_directory
|
||||||
|
|
||||||
imgbp = Blueprint("imgserver", __name__, url_prefix="/img")
|
from app.settings import APP_DIR
|
||||||
|
|
||||||
|
api = Blueprint("imgserver", __name__, url_prefix="/img")
|
||||||
SUPPORTED_IMAGES = (".jpg", ".png", ".webp", ".jpeg")
|
SUPPORTED_IMAGES = (".jpg", ".png", ".webp", ".jpeg")
|
||||||
|
|
||||||
HOME = os.path.expanduser("~")
|
APP_DIR = Path(APP_DIR)
|
||||||
|
|
||||||
APP_DIR = Path(HOME) / ".swing"
|
|
||||||
IMG_PATH = APP_DIR / "images"
|
IMG_PATH = APP_DIR / "images"
|
||||||
ASSETS_PATH = APP_DIR / "assets"
|
ASSETS_PATH = APP_DIR / "assets"
|
||||||
|
|
||||||
@@ -23,7 +22,7 @@ ARTIST_SM_PATH = ARTIST_PATH / "small"
|
|||||||
PLAYLIST_PATH = IMG_PATH / "playlists"
|
PLAYLIST_PATH = IMG_PATH / "playlists"
|
||||||
|
|
||||||
|
|
||||||
@imgbp.route("/")
|
@api.route("/")
|
||||||
def hello():
|
def hello():
|
||||||
return "<h1>Image Server</h1>"
|
return "<h1>Image Server</h1>"
|
||||||
|
|
||||||
@@ -37,7 +36,7 @@ def send_fallback_img(filename: str = "default.webp"):
|
|||||||
return send_from_directory(ASSETS_PATH, filename)
|
return send_from_directory(ASSETS_PATH, filename)
|
||||||
|
|
||||||
|
|
||||||
@imgbp.route("/t/<imgpath>")
|
@api.route("/t/<imgpath>")
|
||||||
def send_lg_thumbnail(imgpath: str):
|
def send_lg_thumbnail(imgpath: str):
|
||||||
fpath = LG_THUMB_PATH / imgpath
|
fpath = LG_THUMB_PATH / imgpath
|
||||||
|
|
||||||
@@ -47,7 +46,7 @@ def send_lg_thumbnail(imgpath: str):
|
|||||||
return send_fallback_img()
|
return send_fallback_img()
|
||||||
|
|
||||||
|
|
||||||
@imgbp.route("/t/s/<imgpath>")
|
@api.route("/t/s/<imgpath>")
|
||||||
def send_sm_thumbnail(imgpath: str):
|
def send_sm_thumbnail(imgpath: str):
|
||||||
fpath = SM_THUMB_PATH / imgpath
|
fpath = SM_THUMB_PATH / imgpath
|
||||||
|
|
||||||
@@ -57,7 +56,7 @@ def send_sm_thumbnail(imgpath: str):
|
|||||||
return send_fallback_img()
|
return send_fallback_img()
|
||||||
|
|
||||||
|
|
||||||
@imgbp.route("/a/<imgpath>")
|
@api.route("/a/<imgpath>")
|
||||||
def send_lg_artist_image(imgpath: str):
|
def send_lg_artist_image(imgpath: str):
|
||||||
fpath = ARTIST_LG_PATH / imgpath
|
fpath = ARTIST_LG_PATH / imgpath
|
||||||
|
|
||||||
@@ -67,7 +66,7 @@ def send_lg_artist_image(imgpath: str):
|
|||||||
return send_fallback_img("artist.webp")
|
return send_fallback_img("artist.webp")
|
||||||
|
|
||||||
|
|
||||||
@imgbp.route("/a/s/<imgpath>")
|
@api.route("/a/s/<imgpath>")
|
||||||
def send_sm_artist_image(imgpath: str):
|
def send_sm_artist_image(imgpath: str):
|
||||||
fpath = ARTIST_SM_PATH / imgpath
|
fpath = ARTIST_SM_PATH / imgpath
|
||||||
|
|
||||||
@@ -77,7 +76,7 @@ def send_sm_artist_image(imgpath: str):
|
|||||||
return send_fallback_img("artist.webp")
|
return send_fallback_img("artist.webp")
|
||||||
|
|
||||||
|
|
||||||
@imgbp.route("/p/<imgpath>")
|
@api.route("/p/<imgpath>")
|
||||||
def send_playlist_image(imgpath: str):
|
def send_playlist_image(imgpath: str):
|
||||||
fpath = PLAYLIST_PATH / imgpath
|
fpath = PLAYLIST_PATH / imgpath
|
||||||
|
|
||||||
+9
-10
@@ -13,7 +13,7 @@ from app.db.store import Store
|
|||||||
from app.lib import playlistlib
|
from app.lib import playlistlib
|
||||||
from app.utils import create_new_date, remove_duplicates
|
from app.utils import create_new_date, remove_duplicates
|
||||||
|
|
||||||
playlistbp = Blueprint("playlist", __name__, url_prefix="/")
|
api = Blueprint("playlist", __name__, url_prefix="/")
|
||||||
|
|
||||||
PL = SQLitePlaylistMethods
|
PL = SQLitePlaylistMethods
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ delete_playlist = PL.delete_playlist
|
|||||||
# get_tracks_by_trackhashes = SQLiteTrackMethods.get_tracks_by_trackhashes
|
# get_tracks_by_trackhashes = SQLiteTrackMethods.get_tracks_by_trackhashes
|
||||||
|
|
||||||
|
|
||||||
@playlistbp.route("/playlists", methods=["GET"])
|
@api.route("/playlists", methods=["GET"])
|
||||||
def send_all_playlists():
|
def send_all_playlists():
|
||||||
"""
|
"""
|
||||||
Gets all the playlists.
|
Gets all the playlists.
|
||||||
@@ -46,7 +46,7 @@ def send_all_playlists():
|
|||||||
return {"data": playlists}
|
return {"data": playlists}
|
||||||
|
|
||||||
|
|
||||||
@playlistbp.route("/playlist/new", methods=["POST"])
|
@api.route("/playlist/new", methods=["POST"])
|
||||||
def create_playlist():
|
def create_playlist():
|
||||||
"""
|
"""
|
||||||
Creates a new playlist. Accepts POST method with a JSON body.
|
Creates a new playlist. Accepts POST method with a JSON body.
|
||||||
@@ -79,7 +79,7 @@ def create_playlist():
|
|||||||
return {"playlist": playlist}, 201
|
return {"playlist": playlist}, 201
|
||||||
|
|
||||||
|
|
||||||
@playlistbp.route("/playlist/<playlist_id>/add", methods=["POST"])
|
@api.route("/playlist/<playlist_id>/add", methods=["POST"])
|
||||||
def add_track_to_playlist(playlist_id: str):
|
def add_track_to_playlist(playlist_id: str):
|
||||||
"""
|
"""
|
||||||
Takes a playlist ID and a track hash, and adds the track to the playlist
|
Takes a playlist ID and a track hash, and adds the track to the playlist
|
||||||
@@ -97,12 +97,12 @@ def add_track_to_playlist(playlist_id: str):
|
|||||||
return {"error": "Track already exists in playlist"}, 409
|
return {"error": "Track already exists in playlist"}, 409
|
||||||
|
|
||||||
add_artist_to_playlist(int(playlist_id), trackhash)
|
add_artist_to_playlist(int(playlist_id), trackhash)
|
||||||
PL.update_last_updated(playlist_id)
|
PL.update_last_updated(int(playlist_id))
|
||||||
|
|
||||||
return {"msg": "Done"}, 200
|
return {"msg": "Done"}, 200
|
||||||
|
|
||||||
|
|
||||||
@playlistbp.route("/playlist/<playlistid>")
|
@api.route("/playlist/<playlistid>")
|
||||||
def get_playlist(playlistid: str):
|
def get_playlist(playlistid: str):
|
||||||
"""
|
"""
|
||||||
Gets a playlist by id, and if it exists, it gets all the tracks in the playlist and returns them.
|
Gets a playlist by id, and if it exists, it gets all the tracks in the playlist and returns them.
|
||||||
@@ -123,7 +123,7 @@ def get_playlist(playlistid: str):
|
|||||||
return {"info": playlist, "tracks": tracks}
|
return {"info": playlist, "tracks": tracks}
|
||||||
|
|
||||||
|
|
||||||
@playlistbp.route("/playlist/<playlistid>/update", methods=["PUT"])
|
@api.route("/playlist/<playlistid>/update", methods=["PUT"])
|
||||||
def update_playlist_info(playlistid: str):
|
def update_playlist_info(playlistid: str):
|
||||||
if playlistid is None:
|
if playlistid is None:
|
||||||
return {"error": "Playlist ID not provided"}, 400
|
return {"error": "Playlist ID not provided"}, 400
|
||||||
@@ -166,7 +166,6 @@ def update_playlist_info(playlistid: str):
|
|||||||
return {"error": "Failed: Invalid image"}, 400
|
return {"error": "Failed: Invalid image"}, 400
|
||||||
|
|
||||||
p_tuple = (*playlist.values(),)
|
p_tuple = (*playlist.values(),)
|
||||||
print("banner pos:", playlist["banner_pos"])
|
|
||||||
|
|
||||||
update_playlist(int(playlistid), playlist)
|
update_playlist(int(playlistid), playlist)
|
||||||
|
|
||||||
@@ -188,7 +187,7 @@ def update_playlist_info(playlistid: str):
|
|||||||
# return {"data": artists}
|
# return {"data": artists}
|
||||||
|
|
||||||
|
|
||||||
@playlistbp.route("/playlist/delete", methods=["POST"])
|
@api.route("/playlist/delete", methods=["POST"])
|
||||||
def remove_playlist():
|
def remove_playlist():
|
||||||
"""
|
"""
|
||||||
Deletes a playlist by ID.
|
Deletes a playlist by ID.
|
||||||
@@ -209,7 +208,7 @@ def remove_playlist():
|
|||||||
return {"msg": "Done"}, 200
|
return {"msg": "Done"}, 200
|
||||||
|
|
||||||
|
|
||||||
@playlistbp.route("/playlist/<pid>/set-image-pos", methods=["POST"])
|
@api.route("/playlist/<pid>/set-image-pos", methods=["POST"])
|
||||||
def update_image_position(pid: int):
|
def update_image_position(pid: int):
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
message = {"msg": "No data provided"}
|
message = {"msg": "No data provided"}
|
||||||
|
|||||||
+9
-9
@@ -8,7 +8,7 @@ from app import models, utils
|
|||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.lib import searchlib
|
from app.lib import searchlib
|
||||||
|
|
||||||
searchbp = Blueprint("search", __name__, url_prefix="/")
|
api = Blueprint("search", __name__, url_prefix="/")
|
||||||
|
|
||||||
|
|
||||||
SEARCH_COUNT = 12
|
SEARCH_COUNT = 12
|
||||||
@@ -95,7 +95,7 @@ class DoSearch:
|
|||||||
# self.search_playlists()
|
# self.search_playlists()
|
||||||
|
|
||||||
|
|
||||||
@searchbp.route("/search/tracks", methods=["GET"])
|
@api.route("/search/tracks", methods=["GET"])
|
||||||
def search_tracks():
|
def search_tracks():
|
||||||
"""
|
"""
|
||||||
Searches for tracks that match the search query.
|
Searches for tracks that match the search query.
|
||||||
@@ -113,7 +113,7 @@ def search_tracks():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@searchbp.route("/search/albums", methods=["GET"])
|
@api.route("/search/albums", methods=["GET"])
|
||||||
def search_albums():
|
def search_albums():
|
||||||
"""
|
"""
|
||||||
Searches for albums.
|
Searches for albums.
|
||||||
@@ -131,7 +131,7 @@ def search_albums():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@searchbp.route("/search/artists", methods=["GET"])
|
@api.route("/search/artists", methods=["GET"])
|
||||||
def search_artists():
|
def search_artists():
|
||||||
"""
|
"""
|
||||||
Searches for artists.
|
Searches for artists.
|
||||||
@@ -167,7 +167,7 @@ def search_artists():
|
|||||||
# }
|
# }
|
||||||
|
|
||||||
|
|
||||||
@searchbp.route("/search/top", methods=["GET"])
|
@api.route("/search/top", methods=["GET"])
|
||||||
def get_top_results():
|
def get_top_results():
|
||||||
"""
|
"""
|
||||||
Returns the top results for the search query.
|
Returns the top results for the search query.
|
||||||
@@ -188,7 +188,7 @@ def get_top_results():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@searchbp.route("/search/loadmore")
|
@api.route("/search/loadmore")
|
||||||
def search_load_more():
|
def search_load_more():
|
||||||
"""
|
"""
|
||||||
Returns more songs, albums or artists from a search query.
|
Returns more songs, albums or artists from a search query.
|
||||||
@@ -199,20 +199,20 @@ def search_load_more():
|
|||||||
if s_type == "tracks":
|
if s_type == "tracks":
|
||||||
t = SearchResults.tracks
|
t = SearchResults.tracks
|
||||||
return {
|
return {
|
||||||
"tracks": t[index : index + SEARCH_COUNT],
|
"tracks": t[index: index + SEARCH_COUNT],
|
||||||
"more": len(t) > index + SEARCH_COUNT,
|
"more": len(t) > index + SEARCH_COUNT,
|
||||||
}
|
}
|
||||||
|
|
||||||
elif s_type == "albums":
|
elif s_type == "albums":
|
||||||
a = SearchResults.albums
|
a = SearchResults.albums
|
||||||
return {
|
return {
|
||||||
"albums": a[index : index + SEARCH_COUNT],
|
"albums": a[index: index + SEARCH_COUNT],
|
||||||
"more": len(a) > index + SEARCH_COUNT,
|
"more": len(a) > index + SEARCH_COUNT,
|
||||||
}
|
}
|
||||||
|
|
||||||
elif s_type == "artists":
|
elif s_type == "artists":
|
||||||
a = SearchResults.artists
|
a = SearchResults.artists
|
||||||
return {
|
return {
|
||||||
"artists": a[index : index + SEARCH_COUNT],
|
"artists": a[index: index + SEARCH_COUNT],
|
||||||
"more": len(a) > index + SEARCH_COUNT,
|
"more": len(a) > index + SEARCH_COUNT,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
from flask import Blueprint, request
|
||||||
|
from app import settings
|
||||||
|
|
||||||
|
from app.logger import log
|
||||||
|
from app.lib import populate
|
||||||
|
from app.db.store import Store
|
||||||
|
from app.utils import background, get_random_str
|
||||||
|
from app.lib.watchdogg import Watcher as WatchDog
|
||||||
|
from app.db.sqlite.settings import SettingsSQLMethods as sdb
|
||||||
|
|
||||||
|
api = Blueprint("settings", __name__, url_prefix="/")
|
||||||
|
|
||||||
|
|
||||||
|
def get_child_dirs(parent: str, children: list[str]):
|
||||||
|
"""Returns child directories in a list, given a parent directory"""
|
||||||
|
|
||||||
|
return [_dir for _dir in children if _dir.startswith(parent) and _dir != parent]
|
||||||
|
|
||||||
|
|
||||||
|
def reload_everything():
|
||||||
|
"""
|
||||||
|
Reloads all stores using the current database items
|
||||||
|
"""
|
||||||
|
Store.load_all_tracks()
|
||||||
|
Store.process_folders()
|
||||||
|
Store.load_albums()
|
||||||
|
Store.load_artists()
|
||||||
|
|
||||||
|
|
||||||
|
@background
|
||||||
|
def rebuild_store(db_dirs: list[str]):
|
||||||
|
"""
|
||||||
|
Restarts the watchdog and rebuilds the music library.
|
||||||
|
"""
|
||||||
|
log.info("Rebuilding library...")
|
||||||
|
Store.remove_tracks_by_dir_except(db_dirs)
|
||||||
|
reload_everything()
|
||||||
|
|
||||||
|
key = get_random_str()
|
||||||
|
try:
|
||||||
|
populate.Populate(key=key)
|
||||||
|
except populate.PopulateCancelledError:
|
||||||
|
reload_everything()
|
||||||
|
return
|
||||||
|
|
||||||
|
WatchDog().restart()
|
||||||
|
|
||||||
|
log.info("Rebuilding library... ✅")
|
||||||
|
|
||||||
|
|
||||||
|
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_)
|
||||||
|
|
||||||
|
|
||||||
|
@api.route("/settings/add-root-dirs", methods=["POST"])
|
||||||
|
def add_root_dirs():
|
||||||
|
"""
|
||||||
|
Add custom root directories to the database.
|
||||||
|
"""
|
||||||
|
msg = {"msg": "Failed! No directories were given."}
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
return msg, 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_dirs: list[str] = data["new_dirs"]
|
||||||
|
removed_dirs: list[str] = data["removed"]
|
||||||
|
except KeyError:
|
||||||
|
return msg, 400
|
||||||
|
|
||||||
|
db_dirs = sdb.get_root_dirs()
|
||||||
|
_h = "$home"
|
||||||
|
|
||||||
|
db_home = any([d == _h for d in db_dirs]) # if $home is in db
|
||||||
|
incoming_home = any([d == _h for d in new_dirs]) # if $home is in incoming
|
||||||
|
|
||||||
|
# handle $home case
|
||||||
|
if db_home and incoming_home:
|
||||||
|
return {"msg": "Not changed!"}
|
||||||
|
|
||||||
|
if db_home or incoming_home:
|
||||||
|
sdb.remove_root_dirs(db_dirs)
|
||||||
|
|
||||||
|
if incoming_home:
|
||||||
|
finalize([_h], [], [settings.USER_HOME_DIR])
|
||||||
|
return {"root_dirs": [_h]}
|
||||||
|
|
||||||
|
# ---
|
||||||
|
|
||||||
|
for _dir in new_dirs:
|
||||||
|
children = get_child_dirs(_dir, db_dirs)
|
||||||
|
removed_dirs.extend(children)
|
||||||
|
|
||||||
|
for _dir in removed_dirs:
|
||||||
|
try:
|
||||||
|
db_dirs.remove(_dir)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
db_dirs.extend(new_dirs)
|
||||||
|
db_dirs = [dir_ for dir_ in db_dirs if dir_ != _h]
|
||||||
|
|
||||||
|
finalize(new_dirs, removed_dirs, db_dirs)
|
||||||
|
|
||||||
|
return {"root_dirs": db_dirs}
|
||||||
|
|
||||||
|
|
||||||
|
@api.route("/settings/get-root-dirs", methods=["GET"])
|
||||||
|
def get_root_dirs():
|
||||||
|
"""
|
||||||
|
Get custom root directories from the database.
|
||||||
|
"""
|
||||||
|
dirs = sdb.get_root_dirs()
|
||||||
|
|
||||||
|
return {"dirs": dirs}
|
||||||
+3
-2
@@ -2,12 +2,13 @@
|
|||||||
Contains all the track routes.
|
Contains all the track routes.
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint, send_file
|
from flask import Blueprint, send_file
|
||||||
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
|
||||||
trackbp = Blueprint("track", __name__, url_prefix="/")
|
api = Blueprint("track", __name__, url_prefix="/")
|
||||||
|
|
||||||
|
|
||||||
@trackbp.route("/file/<trackhash>")
|
@api.route("/file/<trackhash>")
|
||||||
def send_track_file(trackhash: str):
|
def send_track_file(trackhash: str):
|
||||||
"""
|
"""
|
||||||
Returns an audio file that matches the passed id to the client.
|
Returns an audio file that matches the passed id to the client.
|
||||||
|
|||||||
+15
-11
@@ -5,11 +5,26 @@ from .utils import SQLiteManager
|
|||||||
class SQLiteFavoriteMethods:
|
class SQLiteFavoriteMethods:
|
||||||
"""THis class contains methods for interacting with the favorites table."""
|
"""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.
|
||||||
|
"""
|
||||||
|
sql = """SELECT * FROM favorites WHERE hash = ? AND type = ?"""
|
||||||
|
with SQLiteManager(userdata_db=True) as cur:
|
||||||
|
cur.execute(sql, (itemhash, fav_type))
|
||||||
|
items = cur.fetchall()
|
||||||
|
return len(items) > 0
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def insert_one_favorite(cls, fav_type: str, fav_hash: str):
|
def insert_one_favorite(cls, fav_type: str, fav_hash: str):
|
||||||
"""
|
"""
|
||||||
Inserts a single favorite into the database.
|
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) VALUES(?,?)"""
|
sql = """INSERT INTO favorites(type, hash) VALUES(?,?)"""
|
||||||
with SQLiteManager(userdata_db=True) as cur:
|
with SQLiteManager(userdata_db=True) as cur:
|
||||||
cur.execute(sql, (fav_type, fav_hash))
|
cur.execute(sql, (fav_type, fav_hash))
|
||||||
@@ -64,14 +79,3 @@ class SQLiteFavoriteMethods:
|
|||||||
|
|
||||||
with SQLiteManager(userdata_db=True) as cur:
|
with SQLiteManager(userdata_db=True) as cur:
|
||||||
cur.execute(sql, (fav_hash, fav_type))
|
cur.execute(sql, (fav_hash, fav_type))
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def check_is_favorite(cls, itemhash: str, fav_type: str):
|
|
||||||
"""
|
|
||||||
Checks if an item is favorited.
|
|
||||||
"""
|
|
||||||
sql = """SELECT * FROM favorites WHERE hash = ? AND type = ?"""
|
|
||||||
with SQLiteManager(userdata_db=True) as cur:
|
|
||||||
cur.execute(sql, (itemhash, fav_type))
|
|
||||||
items = cur.fetchall()
|
|
||||||
return len(items) > 0
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""
|
||||||
|
Reads and saves the latest database migrations version.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
from app.db.sqlite.utils import SQLiteManager
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationManager:
|
||||||
|
all_get_sql = "SELECT * FROM migrations"
|
||||||
|
pre_init_set_sql = "UPDATE migrations SET pre_init_version = ? WHERE id = 1"
|
||||||
|
post_init_set_sql = "UPDATE migrations SET post_init_version = ? WHERE id = 1"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_preinit_version(cls) -> int:
|
||||||
|
"""
|
||||||
|
Returns the latest userdata pre-init database version.
|
||||||
|
"""
|
||||||
|
with SQLiteManager() as cur:
|
||||||
|
cur.execute(cls.all_get_sql)
|
||||||
|
return int(cur.fetchone()[1])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_maindb_postinit_version(cls) -> int:
|
||||||
|
"""
|
||||||
|
Returns the latest maindb post-init database version.
|
||||||
|
"""
|
||||||
|
with SQLiteManager() as cur:
|
||||||
|
cur.execute(cls.all_get_sql)
|
||||||
|
return int(cur.fetchone()[2])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_userdatadb_postinit_version(cls) -> int:
|
||||||
|
"""
|
||||||
|
Returns the latest userdata post-init database version.
|
||||||
|
"""
|
||||||
|
with SQLiteManager(userdata_db=True) as cur:
|
||||||
|
cur.execute(cls.all_get_sql)
|
||||||
|
return cur.fetchone()[2]
|
||||||
|
|
||||||
|
# 👇 Setters 👇
|
||||||
|
@classmethod
|
||||||
|
def set_preinit_version(cls, version: int):
|
||||||
|
"""
|
||||||
|
Sets the userdata pre-init database version.
|
||||||
|
"""
|
||||||
|
with SQLiteManager() as cur:
|
||||||
|
cur.execute(cls.pre_init_set_sql, (version,))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set_maindb_postinit_version(cls, version: int):
|
||||||
|
"""
|
||||||
|
Sets the maindb post-init database version.
|
||||||
|
"""
|
||||||
|
with SQLiteManager() as cur:
|
||||||
|
cur.execute(cls.post_init_set_sql, (version,))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set_userdatadb_postinit_version(cls, version: int):
|
||||||
|
"""
|
||||||
|
Sets the userdata post-init database version.
|
||||||
|
"""
|
||||||
|
with SQLiteManager(userdata_db=True) as cur:
|
||||||
|
cur.execute(cls.post_init_set_sql, (version,))
|
||||||
@@ -90,23 +90,9 @@ class SQLitePlaylistMethods:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def add_item_to_json_list(playlist_id: int, field: str, items: list[str]):
|
def add_item_to_json_list(playlist_id: int, field: str, items: list[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, and an error to raise if the item is already in the field.
|
Adds a string item to a json dumped list using a playlist id and field name.
|
||||||
|
Takes the playlist ID, a field name,
|
||||||
Parameters
|
an item to add to the field, and an error to raise if the item is already in the field.
|
||||||
----------
|
|
||||||
playlist_id : int
|
|
||||||
The ID of the playlist to add the item to.
|
|
||||||
field : str
|
|
||||||
The field in the database that you want to add the item to.
|
|
||||||
item : str
|
|
||||||
The item to add to the list.
|
|
||||||
error : Exception
|
|
||||||
The error to raise if the item is already in the list.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
A list of strings.
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
sql = f"SELECT {field} FROM playlists WHERE id = ?"
|
sql = f"SELECT {field} FROM playlists WHERE id = ?"
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ CREATE TABLE IF NOT EXISTS favorites (
|
|||||||
hash text not null,
|
hash text not null,
|
||||||
type text not null
|
type text not null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
id integer PRIMARY KEY,
|
||||||
|
root_dirs text NOT NULL,
|
||||||
|
exclude_dirs text
|
||||||
|
)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CREATE_APPDB_TABLES = """
|
CREATE_APPDB_TABLES = """
|
||||||
@@ -63,3 +69,15 @@ CREATE TABLE IF NOT EXISTS folders (
|
|||||||
trackcount integer NOT NULL
|
trackcount integer NOT NULL
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
CREATE_MIGRATIONS_TABLE = """
|
||||||
|
CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
id integer PRIMARY KEY,
|
||||||
|
pre_init_version integer NOT NULL DEFAULT 0,
|
||||||
|
post_init_version integer NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO migrations (pre_init_version, post_init_version)
|
||||||
|
SELECT 0, 0
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM migrations);
|
||||||
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
from app.db.sqlite.utils import SQLiteManager
|
||||||
|
from app.utils import win_replace_slash
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsSQLMethods:
|
||||||
|
"""
|
||||||
|
Methods for interacting with the settings table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@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()
|
||||||
|
|
||||||
|
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,))
|
||||||
|
|
||||||
|
@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]
|
||||||
+7
-11
@@ -61,6 +61,8 @@ class SQLiteTrackMethods:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# TODO: rewrite the above code using an ordered dict and destructuring
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def insert_many_tracks(cls, tracks: list[dict]):
|
def insert_many_tracks(cls, tracks: list[dict]):
|
||||||
"""
|
"""
|
||||||
@@ -128,15 +130,9 @@ class SQLiteTrackMethods:
|
|||||||
cur.execute("DELETE FROM tracks WHERE filepath=?", (filepath,))
|
cur.execute("DELETE FROM tracks WHERE filepath=?", (filepath,))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def track_exists(filepath: str):
|
def remove_tracks_by_folders(folders: set[str]):
|
||||||
"""
|
sql = "DELETE FROM tracks WHERE folder = ?"
|
||||||
Checks if a track exists in the database using its filepath.
|
|
||||||
"""
|
|
||||||
with SQLiteManager() as cur:
|
with SQLiteManager() as cur:
|
||||||
cur.execute("SELECT * FROM tracks WHERE filepath=?", (filepath,))
|
for folder in folders:
|
||||||
row = cur.fetchone()
|
cur.execute(sql, (folder,))
|
||||||
|
|
||||||
if row is not None:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|||||||
+16
-1
@@ -4,6 +4,7 @@ Helper functions for use with the SQLite database.
|
|||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from sqlite3 import Connection, Cursor
|
from sqlite3 import Connection, Cursor
|
||||||
|
import time
|
||||||
|
|
||||||
from app.models import Album, Playlist, Track
|
from app.models import Album, Playlist, Track
|
||||||
from app.settings import APP_DB_PATH, USERDATA_DB_PATH
|
from app.settings import APP_DB_PATH, USERDATA_DB_PATH
|
||||||
@@ -82,12 +83,26 @@ class SQLiteManager:
|
|||||||
if self.userdata_db:
|
if self.userdata_db:
|
||||||
db_path = USERDATA_DB_PATH
|
db_path = USERDATA_DB_PATH
|
||||||
|
|
||||||
self.conn = sqlite3.connect(db_path)
|
self.conn = sqlite3.connect(
|
||||||
|
db_path,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
return self.conn.cursor()
|
return self.conn.cursor()
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_value, exc_traceback):
|
def __exit__(self, exc_type, exc_value, exc_traceback):
|
||||||
if self.conn:
|
if self.conn:
|
||||||
|
trial_count = 0
|
||||||
|
|
||||||
|
while trial_count < 10:
|
||||||
|
try:
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
if self.CLOSE_CONN:
|
if self.CLOSE_CONN:
|
||||||
self.conn.close()
|
self.conn.close()
|
||||||
|
|
||||||
|
return
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
trial_count += 1
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
self.conn.close()
|
||||||
|
|||||||
+32
-7
@@ -17,6 +17,7 @@ from app.utils import (
|
|||||||
create_folder_hash,
|
create_folder_hash,
|
||||||
get_all_artists,
|
get_all_artists,
|
||||||
remove_duplicates,
|
remove_duplicates,
|
||||||
|
win_replace_slash,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ class Store:
|
|||||||
cls.tracks = list(tdb.get_all_tracks())
|
cls.tracks = list(tdb.get_all_tracks())
|
||||||
|
|
||||||
fav_hashes = favdb.get_fav_tracks()
|
fav_hashes = favdb.get_fav_tracks()
|
||||||
fav_hashes = [t[1] for t in fav_hashes]
|
fav_hashes = " ".join([t[1] for t in fav_hashes])
|
||||||
|
|
||||||
for track in tqdm(cls.tracks, desc="Loading tracks"):
|
for track in tqdm(cls.tracks, desc="Loading tracks"):
|
||||||
if track.trackhash in fav_hashes:
|
if track.trackhash in fav_hashes:
|
||||||
@@ -88,6 +89,17 @@ class Store:
|
|||||||
cls.tracks.remove(track)
|
cls.tracks.remove(track)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def remove_tracks_by_dir_except(cls, dirs: list[str]):
|
||||||
|
"""Removes all tracks not in the root directories."""
|
||||||
|
to_remove = set()
|
||||||
|
|
||||||
|
for track in cls.tracks:
|
||||||
|
if not track.folder.startswith(tuple(dirs)):
|
||||||
|
to_remove.add(track.folder)
|
||||||
|
|
||||||
|
tdb.remove_tracks_by_folders(to_remove)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def count_tracks_by_hash(cls, trackhash: str) -> int:
|
def count_tracks_by_hash(cls, trackhash: str) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -163,7 +175,7 @@ class Store:
|
|||||||
|
|
||||||
return Folder(
|
return Folder(
|
||||||
name=folder.name,
|
name=folder.name,
|
||||||
path=str(folder),
|
path=win_replace_slash(str(folder)),
|
||||||
is_sym=folder.is_symlink(),
|
is_sym=folder.is_symlink(),
|
||||||
has_tracks=True,
|
has_tracks=True,
|
||||||
path_hash=create_folder_hash(*folder.parts[1:]),
|
path_hash=create_folder_hash(*folder.parts[1:]),
|
||||||
@@ -197,6 +209,8 @@ class Store:
|
|||||||
"""
|
"""
|
||||||
Creates a list of folders from the tracks in the store.
|
Creates a list of folders from the tracks in the store.
|
||||||
"""
|
"""
|
||||||
|
cls.folders.clear()
|
||||||
|
|
||||||
all_folders = [track.folder for track in cls.tracks]
|
all_folders = [track.folder for track in cls.tracks]
|
||||||
all_folders = set(all_folders)
|
all_folders = set(all_folders)
|
||||||
|
|
||||||
@@ -205,9 +219,18 @@ class Store:
|
|||||||
]
|
]
|
||||||
|
|
||||||
all_folders = [Path(f) for f in all_folders]
|
all_folders = [Path(f) for f in all_folders]
|
||||||
all_folders = [f for f in all_folders if f.exists()]
|
# all_folders = [f for f in all_folders if f.exists()]
|
||||||
|
|
||||||
for path in tqdm(all_folders, desc="Processing folders"):
|
valid_folders = []
|
||||||
|
|
||||||
|
for folder in all_folders:
|
||||||
|
try:
|
||||||
|
if folder.exists():
|
||||||
|
valid_folders.append(folder)
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for path in tqdm(valid_folders, desc="Processing folders"):
|
||||||
folder = cls.create_folder(str(path))
|
folder = cls.create_folder(str(path))
|
||||||
|
|
||||||
cls.folders.append(folder)
|
cls.folders.append(folder)
|
||||||
@@ -277,6 +300,8 @@ class Store:
|
|||||||
Loads all albums from the database into the store.
|
Loads all albums from the database into the store.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
cls.albums = []
|
||||||
|
|
||||||
albumhashes = set(t.albumhash for t in cls.tracks)
|
albumhashes = set(t.albumhash for t in cls.tracks)
|
||||||
|
|
||||||
for albumhash in tqdm(albumhashes, desc="Loading albums"):
|
for albumhash in tqdm(albumhashes, desc="Loading albums"):
|
||||||
@@ -291,9 +316,9 @@ class Store:
|
|||||||
albumhash = album[1]
|
albumhash = album[1]
|
||||||
colors = json.loads(album[2])
|
colors = json.loads(album[2])
|
||||||
|
|
||||||
for al in cls.albums:
|
for _al in cls.albums:
|
||||||
if al.albumhash == albumhash:
|
if _al.albumhash == albumhash:
|
||||||
al.set_colors(colors)
|
_al.set_colors(colors)
|
||||||
break
|
break
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
+6
-7
@@ -7,8 +7,8 @@ from requests import ReadTimeout
|
|||||||
|
|
||||||
from app import utils
|
from app import utils
|
||||||
from app.lib.artistlib import CheckArtistImages
|
from app.lib.artistlib import CheckArtistImages
|
||||||
from app.lib.colorlib import ProcessAlbumColors, ProcessArtistColors
|
from app.lib.colorlib import ProcessArtistColors
|
||||||
from app.lib.populate import Populate, ProcessTrackThumbnails
|
from app.lib.populate import Populate, PopulateCancelledError
|
||||||
from app.lib.trackslib import validate_tracks
|
from app.lib.trackslib import validate_tracks
|
||||||
from app.logger import log
|
from app.logger import log
|
||||||
|
|
||||||
@@ -23,11 +23,10 @@ def run_periodic_checks():
|
|||||||
validate_tracks()
|
validate_tracks()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
try:
|
||||||
Populate()
|
Populate(key=utils.get_random_str())
|
||||||
ProcessTrackThumbnails()
|
except PopulateCancelledError:
|
||||||
ProcessAlbumColors()
|
pass
|
||||||
ProcessArtistColors()
|
|
||||||
|
|
||||||
if utils.Ping()():
|
if utils.Ping()():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ class CheckArtistImages:
|
|||||||
"""
|
"""
|
||||||
Checks if an artist image exists and downloads it if not.
|
Checks if an artist image exists and downloads it if not.
|
||||||
|
|
||||||
:param artistname: The artist name
|
:param artist: The artist name
|
||||||
"""
|
"""
|
||||||
img_path = Path(settings.ARTIST_IMG_SM_PATH) / f"{artist.artisthash}.webp"
|
img_path = Path(settings.ARTIST_IMG_SM_PATH) / f"{artist.artisthash}.webp"
|
||||||
|
|
||||||
|
|||||||
+12
-9
@@ -38,10 +38,10 @@ class ProcessAlbumColors:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
albums = [a for a in Store.albums if len(a.colors) == 0]
|
||||||
|
|
||||||
with SQLiteManager() as cur:
|
with SQLiteManager() as cur:
|
||||||
for album in tqdm(Store.albums, desc="Processing album colors"):
|
for album in tqdm(albums, desc="Processing missing album colors"):
|
||||||
if len(album.colors) == 0:
|
|
||||||
colors = self.process_color(album)
|
colors = self.process_color(album)
|
||||||
|
|
||||||
if colors is None:
|
if colors is None:
|
||||||
@@ -69,13 +69,9 @@ class ProcessArtistColors:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
all_artists = Store.artists
|
all_artists = [a for a in Store.artists if len(a.colors) == 0]
|
||||||
|
|
||||||
if all_artists is None:
|
for artist in tqdm(all_artists, desc="Processing missing artist colors"):
|
||||||
return
|
|
||||||
|
|
||||||
for artist in tqdm(all_artists, desc="Processing artist colors"):
|
|
||||||
if len(artist.colors) == 0:
|
|
||||||
self.process_color(artist)
|
self.process_color(artist)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -91,4 +87,11 @@ class ProcessArtistColors:
|
|||||||
adb.insert_one_artist(artisthash=artist.artisthash, colors=colors)
|
adb.insert_one_artist(artisthash=artist.artisthash, colors=colors)
|
||||||
Store.map_artist_color((0, artist.artisthash, json.dumps(colors)))
|
Store.map_artist_color((0, artist.artisthash, json.dumps(colors)))
|
||||||
|
|
||||||
# TODO: Load album and artist colors into the store.
|
# TODO: If item color is in db, get it, assign it to the item and continue.
|
||||||
|
# - Format all colors in the format: rgb(123, 123, 123)
|
||||||
|
# - Each digit should be 3 digits long.
|
||||||
|
# - Format all db colors into a master string of the format "-itemhash:colorhash-"
|
||||||
|
# - Find the item hash using index() and get the color using the index + number, where number
|
||||||
|
# is the length of the rgb string + 1
|
||||||
|
# - Assign the color to the item and continue.
|
||||||
|
# - If the color is not in the db, extract it and add it to the db.
|
||||||
|
|||||||
+20
-8
@@ -1,10 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
import pathlib
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models import Folder, Track
|
from app.models import Folder, Track
|
||||||
from app.settings import SUPPORTED_FILES
|
from app.settings import SUPPORTED_FILES
|
||||||
|
from app.logger import log
|
||||||
|
from app.utils import win_replace_slash
|
||||||
|
|
||||||
|
|
||||||
class GetFilesAndDirs:
|
class GetFilesAndDirs:
|
||||||
@@ -19,7 +20,7 @@ class GetFilesAndDirs:
|
|||||||
try:
|
try:
|
||||||
entries = os.scandir(self.path)
|
entries = os.scandir(self.path)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return ([], [])
|
return [], []
|
||||||
|
|
||||||
dirs, files = [], []
|
dirs, files = [], []
|
||||||
|
|
||||||
@@ -27,14 +28,25 @@ class GetFilesAndDirs:
|
|||||||
ext = os.path.splitext(entry.name)[1].lower()
|
ext = os.path.splitext(entry.name)[1].lower()
|
||||||
|
|
||||||
if entry.is_dir() and not entry.name.startswith("."):
|
if entry.is_dir() and not entry.name.startswith("."):
|
||||||
dirs.append(entry.path)
|
dirs.append(win_replace_slash(entry.path))
|
||||||
elif entry.is_file() and ext in SUPPORTED_FILES:
|
elif entry.is_file() and ext in SUPPORTED_FILES:
|
||||||
files.append(entry.path)
|
files.append(win_replace_slash(entry.path))
|
||||||
|
|
||||||
# sort files by modified time
|
files_ = []
|
||||||
files.sort(
|
|
||||||
key=lambda f: os.path.getmtime(f) # pylint: disable=unnecessary-lambda
|
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 = Store.get_tracks_by_filepaths(files)
|
tracks = Store.get_tracks_by_filepaths(files)
|
||||||
|
|
||||||
@@ -44,4 +56,4 @@ class GetFilesAndDirs:
|
|||||||
|
|
||||||
folders = filter(lambda f: f.has_tracks, folders)
|
folders = filter(lambda f: f.has_tracks, folders)
|
||||||
|
|
||||||
return (tracks, folders) # type: ignore
|
return tracks, folders # type: ignore
|
||||||
|
|||||||
+48
-5
@@ -3,7 +3,10 @@ from tqdm import tqdm
|
|||||||
|
|
||||||
from app import settings
|
from app import settings
|
||||||
from app.db.sqlite.tracks import SQLiteTrackMethods
|
from app.db.sqlite.tracks import SQLiteTrackMethods
|
||||||
|
from app.db.sqlite.settings import SettingsSQLMethods as sdb
|
||||||
|
from app.db.sqlite.favorite import SQLiteFavoriteMethods as favdb
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
from app.lib.colorlib import ProcessAlbumColors, ProcessArtistColors
|
||||||
|
|
||||||
from app.lib.taglib import extract_thumb, get_tags
|
from app.lib.taglib import extract_thumb, get_tags
|
||||||
from app.logger import log
|
from app.logger import log
|
||||||
@@ -13,6 +16,12 @@ from app.utils import run_fast_scandir
|
|||||||
get_all_tracks = SQLiteTrackMethods.get_all_tracks
|
get_all_tracks = SQLiteTrackMethods.get_all_tracks
|
||||||
insert_many_tracks = SQLiteTrackMethods.insert_many_tracks
|
insert_many_tracks = SQLiteTrackMethods.insert_many_tracks
|
||||||
|
|
||||||
|
POPULATE_KEY = ""
|
||||||
|
|
||||||
|
|
||||||
|
class PopulateCancelledError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Populate:
|
class Populate:
|
||||||
"""
|
"""
|
||||||
@@ -22,12 +31,34 @@ class Populate:
|
|||||||
also checks if the album art exists in the image path, if not tries to extract it.
|
also checks if the album art exists in the image path, if not tries to extract it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, key: str) -> None:
|
||||||
|
global POPULATE_KEY
|
||||||
|
POPULATE_KEY = key
|
||||||
|
|
||||||
tracks = get_all_tracks()
|
tracks = get_all_tracks()
|
||||||
tracks = list(tracks)
|
tracks = list(tracks)
|
||||||
|
|
||||||
files = run_fast_scandir(settings.HOME_DIR, full=True)[1]
|
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.USER_HOME_DIR]
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
files = []
|
||||||
|
|
||||||
|
for _dir in dirs_to_scan:
|
||||||
|
files.extend(run_fast_scandir(_dir, full=True)[1])
|
||||||
|
|
||||||
untagged = self.filter_untagged(tracks, files)
|
untagged = self.filter_untagged(tracks, files)
|
||||||
|
|
||||||
@@ -35,7 +66,12 @@ class Populate:
|
|||||||
log.info("All clear, no unread files.")
|
log.info("All clear, no unread files.")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.tag_untagged(untagged)
|
self.tag_untagged(untagged, key)
|
||||||
|
|
||||||
|
ProcessTrackThumbnails()
|
||||||
|
ProcessAlbumColors()
|
||||||
|
ProcessArtistColors()
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def filter_untagged(tracks: list[Track], files: list[str]):
|
def filter_untagged(tracks: list[Track], files: list[str]):
|
||||||
@@ -43,17 +79,24 @@ class Populate:
|
|||||||
return set(files) - set(tagged_files)
|
return set(files) - set(tagged_files)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def tag_untagged(untagged: set[str]):
|
def tag_untagged(untagged: set[str], key: str):
|
||||||
log.info("Found %s new tracks", len(untagged))
|
log.info("Found %s new tracks", len(untagged))
|
||||||
tagged_tracks: list[dict] = []
|
tagged_tracks: list[dict] = []
|
||||||
tagged_count = 0
|
tagged_count = 0
|
||||||
|
|
||||||
|
fav_tracks = favdb.get_fav_tracks()
|
||||||
|
fav_tracks = "-".join([t[1] for t in fav_tracks])
|
||||||
|
|
||||||
for file in tqdm(untagged, desc="Reading files"):
|
for file in tqdm(untagged, desc="Reading files"):
|
||||||
|
if POPULATE_KEY != key:
|
||||||
|
raise PopulateCancelledError("Populate key changed")
|
||||||
|
|
||||||
tags = get_tags(file)
|
tags = get_tags(file)
|
||||||
|
|
||||||
if tags is not None:
|
if tags is not None:
|
||||||
tagged_tracks.append(tags)
|
tagged_tracks.append(tags)
|
||||||
track = Track(**tags)
|
track = Track(**tags)
|
||||||
|
track.is_favorite = track.trackhash in fav_tracks
|
||||||
|
|
||||||
Store.add_track(track)
|
Store.add_track(track)
|
||||||
Store.add_folder(track.folder)
|
Store.add_folder(track.folder)
|
||||||
@@ -97,4 +140,4 @@ class ProcessTrackThumbnails:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
results = [r for r in results]
|
list(results)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class SearchTracks:
|
|||||||
Gets all songs with a given title.
|
Gets all songs with a given title.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
tracks = [track.title for track in self.tracks]
|
tracks = [track.og_title for track in self.tracks]
|
||||||
results = process.extract(
|
results = process.extract(
|
||||||
self.query,
|
self.query,
|
||||||
tracks,
|
tracks,
|
||||||
|
|||||||
+28
-9
@@ -1,13 +1,17 @@
|
|||||||
import os
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import os
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from tinytag import TinyTag
|
|
||||||
from PIL import Image, UnidentifiedImageError
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
from tinytag import TinyTag
|
||||||
|
|
||||||
from app import settings
|
from app import settings
|
||||||
from app.utils import create_hash
|
from app.utils import (
|
||||||
|
create_hash,
|
||||||
|
parse_artist_from_filename,
|
||||||
|
parse_title_from_filename,
|
||||||
|
win_replace_slash,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_album_art(filepath: str):
|
def parse_album_art(filepath: str):
|
||||||
@@ -81,7 +85,7 @@ def get_tags(filepath: str):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
tags = TinyTag.get(filepath)
|
tags = TinyTag.get(filepath)
|
||||||
except: # pylint: disable=bare-except
|
except: # noqa: E722
|
||||||
return None
|
return None
|
||||||
|
|
||||||
no_albumartist: bool = (tags.albumartist == "") or (tags.albumartist is None)
|
no_albumartist: bool = (tags.albumartist == "") or (tags.albumartist is None)
|
||||||
@@ -97,9 +101,24 @@ def get_tags(filepath: str):
|
|||||||
for tag in to_filename:
|
for tag in to_filename:
|
||||||
p = getattr(tags, tag)
|
p = getattr(tags, tag)
|
||||||
if p == "" or p is None:
|
if p == "" or p is None:
|
||||||
setattr(tags, tag, filename)
|
maybe = parse_title_from_filename(filename)
|
||||||
|
setattr(tags, tag, maybe)
|
||||||
|
|
||||||
to_check = ["album", "artist", "year", "albumartist"]
|
parse = ["artist", "albumartist"]
|
||||||
|
for tag in parse:
|
||||||
|
p = getattr(tags, tag)
|
||||||
|
|
||||||
|
if p == "" or p is None:
|
||||||
|
maybe = parse_artist_from_filename(filename)
|
||||||
|
|
||||||
|
if maybe:
|
||||||
|
setattr(tags, tag, ", ".join(maybe))
|
||||||
|
else:
|
||||||
|
setattr(tags, tag, "Unknown")
|
||||||
|
|
||||||
|
# TODO: Move parsing title, album and artist to startup. (Maybe!)
|
||||||
|
|
||||||
|
to_check = ["album", "year", "albumartist"]
|
||||||
for prop in to_check:
|
for prop in to_check:
|
||||||
p = getattr(tags, prop)
|
p = getattr(tags, prop)
|
||||||
if (p is None) or (p == ""):
|
if (p is None) or (p == ""):
|
||||||
@@ -127,10 +146,10 @@ def get_tags(filepath: str):
|
|||||||
tags.albumhash = create_hash(tags.album, tags.albumartist)
|
tags.albumhash = create_hash(tags.album, tags.albumartist)
|
||||||
tags.trackhash = create_hash(tags.artist, tags.album, tags.title)
|
tags.trackhash = create_hash(tags.artist, tags.album, tags.title)
|
||||||
tags.image = f"{tags.albumhash}.webp"
|
tags.image = f"{tags.albumhash}.webp"
|
||||||
tags.folder = os.path.dirname(filepath)
|
tags.folder = win_replace_slash(os.path.dirname(filepath))
|
||||||
|
|
||||||
tags.date = extract_date(tags.year)
|
tags.date = extract_date(tags.year)
|
||||||
tags.filepath = filepath
|
tags.filepath = win_replace_slash(filepath)
|
||||||
tags.filetype = filetype
|
tags.filetype = filetype
|
||||||
|
|
||||||
tags = tags.__dict__
|
tags = tags.__dict__
|
||||||
|
|||||||
+135
-23
@@ -2,17 +2,22 @@
|
|||||||
This library contains the classes and functions related to the watchdog file watcher.
|
This library contains the classes and functions related to the watchdog file watcher.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from watchdog.events import PatternMatchingEventHandler
|
from watchdog.events import PatternMatchingEventHandler
|
||||||
from watchdog.observers import Observer
|
from watchdog.observers import Observer
|
||||||
|
|
||||||
from app.db.sqlite.tracks import SQLiteManager
|
|
||||||
from app.db.sqlite.tracks import SQLiteTrackMethods as db
|
from app.logger import log
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.lib.taglib import get_tags
|
from app.lib.taglib import get_tags
|
||||||
from app.logger import log
|
|
||||||
from app.models import Artist, Track
|
from app.models import Artist, Track
|
||||||
|
from app import settings
|
||||||
|
|
||||||
|
from app.db.sqlite.tracks import SQLiteManager
|
||||||
|
from app.db.sqlite.tracks import SQLiteTrackMethods as db
|
||||||
|
from app.db.sqlite.settings import SettingsSQLMethods as sdb
|
||||||
|
|
||||||
|
|
||||||
class Watcher:
|
class Watcher:
|
||||||
@@ -20,38 +25,99 @@ class Watcher:
|
|||||||
Contains the methods for initializing and starting watchdog.
|
Contains the methods for initializing and starting watchdog.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
home_dir = os.path.expanduser("~")
|
|
||||||
dirs = [home_dir]
|
|
||||||
observers: list[Observer] = []
|
observers: list[Observer] = []
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.observer = Observer()
|
self.observer = Observer()
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
event_handler = Handler()
|
"""
|
||||||
|
Starts watchers for each dir in root_dirs
|
||||||
|
"""
|
||||||
|
|
||||||
for dir_ in self.dirs:
|
trials = 0
|
||||||
|
|
||||||
|
while trials < 10:
|
||||||
|
try:
|
||||||
|
dirs = sdb.get_root_dirs()
|
||||||
|
dirs = [rf"{d}" for d in dirs]
|
||||||
|
|
||||||
|
dir_map = [
|
||||||
|
{"original": d, "realpath": os.path.realpath(d)} for d in dirs
|
||||||
|
]
|
||||||
|
break
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
trials += 1
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
log.error(
|
||||||
|
"WatchDogError: Failed to start Watchdog. Waiting for database timed out!"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(dirs) == 0:
|
||||||
|
log.warning(
|
||||||
|
"WatchDogInfo: No root directories configured. Watchdog not started."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
dir_map = [d for d in dir_map if d["realpath"] != d["original"]]
|
||||||
|
|
||||||
|
# if len(dirs) > 0 and dirs[0] == "$home":
|
||||||
|
# dirs = [settings.USER_HOME_DIR]
|
||||||
|
|
||||||
|
if any([d == "$home" for d in dirs]):
|
||||||
|
dirs = [settings.USER_HOME_DIR]
|
||||||
|
|
||||||
|
event_handler = Handler(root_dirs=dirs, dir_map=dir_map)
|
||||||
|
|
||||||
|
for _dir in dirs:
|
||||||
|
exists = os.path.exists(_dir)
|
||||||
|
|
||||||
|
if not exists:
|
||||||
|
log.error("WatchdogError: Directory not found: %s", _dir)
|
||||||
|
|
||||||
|
for _dir in dirs:
|
||||||
self.observer.schedule(
|
self.observer.schedule(
|
||||||
event_handler, os.path.realpath(dir_), recursive=True
|
event_handler, os.path.realpath(_dir), recursive=True
|
||||||
)
|
)
|
||||||
self.observers.append(self.observer)
|
self.observers.append(self.observer)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.observer.start()
|
self.observer.start()
|
||||||
except OSError:
|
log.info("Started watchdog")
|
||||||
log.error("Could not start watchdog.")
|
except (FileNotFoundError, PermissionError):
|
||||||
|
log.error(
|
||||||
|
"WatchdogError: Failed to start watchdog, root directories could not be resolved."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
self.stop_all()
|
||||||
|
|
||||||
|
for obsv in self.observers:
|
||||||
|
obsv.join()
|
||||||
|
|
||||||
|
def stop_all(self):
|
||||||
|
"""
|
||||||
|
Unschedules and stops all existing watchers.
|
||||||
|
"""
|
||||||
|
log.info("Stopping all watchdog observers")
|
||||||
for obsv in self.observers:
|
for obsv in self.observers:
|
||||||
obsv.unschedule_all()
|
obsv.unschedule_all()
|
||||||
obsv.stop()
|
obsv.stop()
|
||||||
|
|
||||||
for obsv in self.observers:
|
def restart(self):
|
||||||
obsv.join()
|
"""
|
||||||
|
Stops all existing watchers, refetches root_dirs from the db
|
||||||
|
and restarts the watchers.
|
||||||
|
"""
|
||||||
|
log.info("🔃 Restarting watchdog")
|
||||||
|
self.stop_all()
|
||||||
|
self.run()
|
||||||
|
|
||||||
|
|
||||||
def add_track(filepath: str) -> None:
|
def add_track(filepath: str) -> None:
|
||||||
@@ -118,28 +184,46 @@ def remove_track(filepath: str) -> None:
|
|||||||
|
|
||||||
class Handler(PatternMatchingEventHandler):
|
class Handler(PatternMatchingEventHandler):
|
||||||
files_to_process = []
|
files_to_process = []
|
||||||
|
files_to_process_windows = []
|
||||||
|
|
||||||
|
root_dirs = []
|
||||||
|
dir_map = []
|
||||||
|
|
||||||
|
def __init__(self, root_dirs: list[str], dir_map: dict[str:str]):
|
||||||
|
self.root_dirs = root_dirs
|
||||||
|
self.dir_map = dir_map
|
||||||
|
patterns = [f"*{f}" for f in settings.SUPPORTED_FILES]
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
log.info("✅ started watchdog")
|
|
||||||
PatternMatchingEventHandler.__init__(
|
PatternMatchingEventHandler.__init__(
|
||||||
self,
|
self,
|
||||||
patterns=["*.flac", "*.mp3"],
|
patterns=patterns,
|
||||||
ignore_directories=True,
|
ignore_directories=True,
|
||||||
case_sensitive=False,
|
case_sensitive=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_abs_path(self, path: str):
|
||||||
|
"""
|
||||||
|
Convert a realpath to a path relative to the matching root directory.
|
||||||
|
"""
|
||||||
|
for d in self.dir_map:
|
||||||
|
if d["realpath"] in path:
|
||||||
|
return path.replace(d["realpath"], d["original"])
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
def on_created(self, event):
|
def on_created(self, event):
|
||||||
"""
|
"""
|
||||||
Fired when a supported file is created.
|
Fired when a supported file is created.
|
||||||
"""
|
"""
|
||||||
self.files_to_process.append(event.src_path)
|
self.files_to_process.append(event.src_path)
|
||||||
|
self.files_to_process_windows.append(event.src_path)
|
||||||
|
|
||||||
def on_deleted(self, event):
|
def on_deleted(self, event):
|
||||||
"""
|
"""
|
||||||
Fired when a delete event occurs on a supported file.
|
Fired when a delete event occurs on a supported file.
|
||||||
"""
|
"""
|
||||||
|
path = self.get_abs_path(event.src_path)
|
||||||
remove_track(event.src_path)
|
remove_track(path)
|
||||||
|
|
||||||
def on_moved(self, event):
|
def on_moved(self, event):
|
||||||
"""
|
"""
|
||||||
@@ -148,25 +232,53 @@ class Handler(PatternMatchingEventHandler):
|
|||||||
trash = "share/Trash"
|
trash = "share/Trash"
|
||||||
|
|
||||||
if trash in event.dest_path:
|
if trash in event.dest_path:
|
||||||
remove_track(event.src_path)
|
path = self.get_abs_path(event.src_path)
|
||||||
|
remove_track(path)
|
||||||
|
|
||||||
elif trash in event.src_path:
|
elif trash in event.src_path:
|
||||||
add_track(event.dest_path)
|
path = self.get_abs_path(event.dest_path)
|
||||||
|
add_track(path)
|
||||||
|
|
||||||
elif trash not in event.dest_path and trash not in event.src_path:
|
elif trash not in event.dest_path and trash not in event.src_path:
|
||||||
add_track(event.dest_path)
|
dest_path = self.get_abs_path(event.dest_path)
|
||||||
remove_track(event.src_path)
|
src_path = self.get_abs_path(event.src_path)
|
||||||
|
|
||||||
|
add_track(dest_path)
|
||||||
|
remove_track(src_path)
|
||||||
|
|
||||||
def on_closed(self, event):
|
def on_closed(self, event):
|
||||||
"""
|
"""
|
||||||
Fired when a created file is closed.
|
Fired when a created file is closed.
|
||||||
|
NOT FIRED IN WINDOWS
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
self.files_to_process.remove(event.src_path)
|
self.files_to_process.remove(event.src_path)
|
||||||
if os.path.getsize(event.src_path) > 0:
|
if os.path.getsize(event.src_path) > 0:
|
||||||
add_track(event.src_path)
|
path = self.get_abs_path(event.src_path)
|
||||||
|
add_track(path)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def on_modified(self, event):
|
||||||
|
# this event handler is triggered twice on windows
|
||||||
|
# for copy events. We need to test how this behaves in
|
||||||
|
# Linux.
|
||||||
|
|
||||||
# watcher = Watcher()
|
if event.src_path not in self.files_to_process_windows:
|
||||||
|
return
|
||||||
|
|
||||||
|
file_size = -1
|
||||||
|
|
||||||
|
while file_size != os.path.getsize(event.src_path):
|
||||||
|
file_size = os.path.getsize(event.src_path)
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.rename(event.src_path, event.src_path)
|
||||||
|
path = self.get_abs_path(event.src_path)
|
||||||
|
remove_track(path)
|
||||||
|
add_track(path)
|
||||||
|
self.files_to_process_windows.remove(event.src_path)
|
||||||
|
except OSError:
|
||||||
|
# File is locked, skipping
|
||||||
|
pass
|
||||||
|
|||||||
+3
-4
@@ -10,15 +10,15 @@ class CustomFormatter(logging.Formatter):
|
|||||||
Custom log formatter
|
Custom log formatter
|
||||||
"""
|
"""
|
||||||
|
|
||||||
grey = "\x1b[38;20m"
|
grey = "\033[92m"
|
||||||
yellow = "\x1b[33;20m"
|
yellow = "\x1b[33;20m"
|
||||||
red = "\x1b[31;20m"
|
red = "\033[41m"
|
||||||
bold_red = "\x1b[31;1m"
|
bold_red = "\x1b[31;1m"
|
||||||
reset = "\x1b[0m"
|
reset = "\x1b[0m"
|
||||||
# format = (
|
# format = (
|
||||||
# "%(asctime)s - %(name)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)"
|
# "%(asctime)s - %(name)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)"
|
||||||
# )
|
# )
|
||||||
format_ = "[%(asctime)s]@%(name)s • %(message)s"
|
format_ = "%(message)s"
|
||||||
|
|
||||||
FORMATS = {
|
FORMATS = {
|
||||||
logging.DEBUG: grey + format_ + reset,
|
logging.DEBUG: grey + format_ + reset,
|
||||||
@@ -45,5 +45,4 @@ handler.setLevel(logging.DEBUG)
|
|||||||
handler.setFormatter(CustomFormatter())
|
handler.setFormatter(CustomFormatter())
|
||||||
log.addHandler(handler)
|
log.addHandler(handler)
|
||||||
|
|
||||||
|
|
||||||
# copied from: https://stackoverflow.com/a/56944256:
|
# copied from: https://stackoverflow.com/a/56944256:
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
Migrations module.
|
||||||
|
|
||||||
|
Reads and applies the latest database migrations.
|
||||||
|
|
||||||
|
PLEASE NOTE: OLDER MIGRATIONS CAN NEVER BE DELETED.
|
||||||
|
ONLY MODIFY OLD MIGRATIONS FOR BUG FIXES OR ENHANCEMENTS ONLY
|
||||||
|
[TRY NOT TO MODIFY BEHAVIOR, UNLESS YOU KNOW WHAT YOU'RE DOING].
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
from app.db.sqlite.migrations import MigrationManager
|
||||||
|
from app.logger import log
|
||||||
|
|
||||||
|
from .main import main_db_migrations
|
||||||
|
from .userdata import userdata_db_migrations
|
||||||
|
|
||||||
|
|
||||||
|
def apply_migrations():
|
||||||
|
"""
|
||||||
|
Applies the latest database migrations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
userdb_version = MigrationManager.get_userdatadb_postinit_version()
|
||||||
|
maindb_version = MigrationManager.get_maindb_postinit_version()
|
||||||
|
|
||||||
|
for migration in main_db_migrations:
|
||||||
|
if migration.version > maindb_version:
|
||||||
|
log.info("Running new MAIN-DB post-init migration: %s", migration.name)
|
||||||
|
migration.migrate()
|
||||||
|
|
||||||
|
for migration in userdata_db_migrations:
|
||||||
|
if migration.version > userdb_version:
|
||||||
|
log.info("Running new USERDATA-DB post-init migration: %s", migration.name)
|
||||||
|
migration.migrate()
|
||||||
|
|
||||||
|
|
||||||
|
def set_postinit_migration_versions():
|
||||||
|
"""
|
||||||
|
Sets the post-init migration versions.
|
||||||
|
"""
|
||||||
|
# TODO: Don't forget to remove the zeros below when you add a valid migration 👇.
|
||||||
|
MigrationManager.set_maindb_postinit_version(0)
|
||||||
|
MigrationManager.set_userdatadb_postinit_version(0)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
Pre-init migrations are executed before the database is created.
|
||||||
|
Useful when you need to move files or folders before the database is created.
|
||||||
|
|
||||||
|
PLEASE NOTE: OLDER MIGRATIONS CAN NEVER BE DELETED.
|
||||||
|
ONLY MODIFY OLD MIGRATIONS FOR BUG FIXES OR ENHANCEMENTS ONLY.
|
||||||
|
[TRY NOT TO MODIFY BEHAVIOR, UNLESS YOU KNOW WHAT YOU'RE DOING].
|
||||||
|
"""
|
||||||
|
from sqlite3 import OperationalError
|
||||||
|
|
||||||
|
from app.db.sqlite.migrations import MigrationManager
|
||||||
|
from app.logger import log
|
||||||
|
|
||||||
|
from .move_to_xdg_folder import MoveToXdgFolder
|
||||||
|
|
||||||
|
all_preinits = [MoveToXdgFolder]
|
||||||
|
|
||||||
|
|
||||||
|
def run_preinit_migrations():
|
||||||
|
"""
|
||||||
|
Runs all pre-init migrations.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
userdb_version = MigrationManager.get_preinit_version()
|
||||||
|
except (OperationalError):
|
||||||
|
userdb_version = 0
|
||||||
|
|
||||||
|
for migration in all_preinits:
|
||||||
|
if migration.version > userdb_version:
|
||||||
|
log.warn("Running new pre-init migration: %s", migration.name)
|
||||||
|
migration.migrate()
|
||||||
|
|
||||||
|
|
||||||
|
def set_preinit_migration_versions():
|
||||||
|
"""
|
||||||
|
Sets the migration versions.
|
||||||
|
"""
|
||||||
|
MigrationManager.set_preinit_version(all_preinits[-1].version)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""
|
||||||
|
This migration handles moving the config folder to the XDG standard location.
|
||||||
|
It also handles moving the userdata and the downloaded artist images to the new location.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from app.settings import APP_DIR, USER_HOME_DIR
|
||||||
|
from app.logger import log
|
||||||
|
|
||||||
|
|
||||||
|
class MoveToXdgFolder:
|
||||||
|
version = 1
|
||||||
|
name = "MoveToXdgFolder"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def migrate():
|
||||||
|
old_config_dir = os.path.join(USER_HOME_DIR, ".swing")
|
||||||
|
new_config_dir = APP_DIR
|
||||||
|
|
||||||
|
if not os.path.exists(old_config_dir):
|
||||||
|
log.info("No old config folder found. Skipping migration.")
|
||||||
|
return
|
||||||
|
|
||||||
|
log.info("Found old config folder: %s", old_config_dir)
|
||||||
|
old_imgs_dir = os.path.join(old_config_dir, "images")
|
||||||
|
|
||||||
|
# move images to new location
|
||||||
|
if os.path.exists(old_imgs_dir):
|
||||||
|
shutil.copytree(
|
||||||
|
old_imgs_dir,
|
||||||
|
os.path.join(new_config_dir, "images"),
|
||||||
|
copy_function=shutil.copy2,
|
||||||
|
dirs_exist_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
log.warn("Moved artist images to: %s", new_config_dir)
|
||||||
|
|
||||||
|
# move userdata.db to new location
|
||||||
|
userdata_db = os.path.join(old_config_dir, "userdata.db")
|
||||||
|
if os.path.exists(userdata_db):
|
||||||
|
shutil.copy2(userdata_db, new_config_dir)
|
||||||
|
|
||||||
|
log.warn("Moved userdata.db to: %s", new_config_dir)
|
||||||
|
log.warn("Migration complete. ✅")
|
||||||
|
|
||||||
|
# swing.db is not moved because the new code fixes bugs which require
|
||||||
|
# the whole database to be recreated anyway. (ie. the bug which caused duplicate album and artist color entries)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
Migrations for the main database.
|
||||||
|
|
||||||
|
PLEASE NOTE: OLDER MIGRATIONS CAN NEVER BE DELETED.
|
||||||
|
ONLY MODIFY OLD MIGRATIONS FOR BUG FIXES OR ENHANCEMENTS ONLY
|
||||||
|
[TRY NOT TO MODIFY BEHAVIOR, UNLESS YOU KNOW WHAT YOU'RE DOING].
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
main_db_migrations = []
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
Migrations for the userdata database.
|
||||||
|
|
||||||
|
PLEASE NOTE: OLDER MIGRATIONS CAN NEVER BE DELETED.
|
||||||
|
ONLY MODIFY OLD MIGRATIONS FOR BUG FIXES OR ENHANCEMENTS ONLY
|
||||||
|
[TRY NOT TO MODIFY BEHAVIOR, UNLESS YOU KNOW WHAT YOU'RE DOING].
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
userdata_db_migrations = []
|
||||||
+60
-12
@@ -5,7 +5,7 @@ import dataclasses
|
|||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from app import utils
|
from app import utils, settings
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -55,23 +55,37 @@ class Track:
|
|||||||
image: str = ""
|
image: str = ""
|
||||||
artist_hashes: list[str] = dataclasses.field(default_factory=list)
|
artist_hashes: list[str] = dataclasses.field(default_factory=list)
|
||||||
is_favorite: bool = False
|
is_favorite: bool = False
|
||||||
|
og_title: str = ""
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
|
self.og_title = self.title
|
||||||
if self.artist is not None:
|
if self.artist is not None:
|
||||||
artist_str = str(self.artist).split(", ")
|
artists = utils.split_artists(self.artist)
|
||||||
self.artist_hashes = [utils.create_hash(a, decode=True) for a in artist_str]
|
|
||||||
|
|
||||||
self.artist = [Artist(a) for a in artist_str]
|
if settings.EXTRACT_FEAT:
|
||||||
|
featured, new_title = utils.parse_feat_from_title(self.title)
|
||||||
|
original_lower = "-".join([a.lower() for a in artists])
|
||||||
|
artists.extend([a for a in featured if a.lower() not in original_lower])
|
||||||
|
|
||||||
albumartists = str(self.albumartist).split(", ")
|
self.title = new_title
|
||||||
|
|
||||||
|
if self.og_title == self.album:
|
||||||
|
self.album = new_title
|
||||||
|
|
||||||
|
self.artist_hashes = [utils.create_hash(a, decode=True) for a in artists]
|
||||||
|
|
||||||
|
self.artist = [Artist(a) for a in artists]
|
||||||
|
|
||||||
|
albumartists = utils.split_artists(self.albumartist)
|
||||||
self.albumartist = [Artist(a) for a in albumartists]
|
self.albumartist = [Artist(a) for a in albumartists]
|
||||||
|
|
||||||
self.filetype = self.filepath.rsplit(".", maxsplit=1)[-1]
|
self.filetype = self.filepath.rsplit(".", maxsplit=1)[-1]
|
||||||
self.image = self.albumhash + ".webp"
|
self.image = self.albumhash + ".webp"
|
||||||
|
|
||||||
if self.genre is not None:
|
if self.genre is not None:
|
||||||
self.genre = str(self.genre).replace("/", ", ")
|
self.genre = str(self.genre).replace("/", ",").replace(";", ",")
|
||||||
self.genre = str(self.genre).lower().split(", ")
|
self.genre = str(self.genre).lower().split(",")
|
||||||
|
self.genre = [g.strip() for g in self.genre]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -96,6 +110,7 @@ class Album:
|
|||||||
is_single: bool = False
|
is_single: bool = False
|
||||||
is_EP: bool = False
|
is_EP: bool = False
|
||||||
is_favorite: bool = False
|
is_favorite: bool = False
|
||||||
|
is_live: bool = False
|
||||||
genres: list[str] = dataclasses.field(default_factory=list)
|
genres: list[str] = dataclasses.field(default_factory=list)
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
@@ -113,11 +128,15 @@ class Album:
|
|||||||
if self.is_soundtrack:
|
if self.is_soundtrack:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self.is_live = self.check_is_live_album()
|
||||||
|
if self.is_live:
|
||||||
|
return
|
||||||
|
|
||||||
self.is_compilation = self.check_is_compilation()
|
self.is_compilation = self.check_is_compilation()
|
||||||
if self.is_compilation:
|
if self.is_compilation:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.is_EP = self.check_is_EP()
|
self.is_EP = self.check_is_ep()
|
||||||
|
|
||||||
def check_is_soundtrack(self) -> bool:
|
def check_is_soundtrack(self) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -137,9 +156,30 @@ class Album:
|
|||||||
artists = [a.name for a in self.albumartists] # type: ignore
|
artists = [a.name for a in self.albumartists] # type: ignore
|
||||||
artists = "".join(artists).lower()
|
artists = "".join(artists).lower()
|
||||||
|
|
||||||
return "various artists" in artists
|
if "various artists" in artists:
|
||||||
|
return True
|
||||||
|
|
||||||
def check_is_EP(self) -> bool:
|
substrings = ["the essential", "best of", "greatest hits", "#1 hits", "number ones", "super hits",
|
||||||
|
"ultimate collection"]
|
||||||
|
|
||||||
|
for substring in substrings:
|
||||||
|
if substring in self.title.lower():
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_is_live_album(self):
|
||||||
|
"""
|
||||||
|
Checks if the album is a live album.
|
||||||
|
"""
|
||||||
|
keywords = ["live from", "live at", "live in"]
|
||||||
|
for keyword in keywords:
|
||||||
|
if keyword in self.title.lower():
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_is_ep(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Checks if the album is an EP.
|
Checks if the album is an EP.
|
||||||
"""
|
"""
|
||||||
@@ -152,11 +192,19 @@ class Album:
|
|||||||
if (
|
if (
|
||||||
len(tracks) == 1
|
len(tracks) == 1
|
||||||
and tracks[0].title == self.title
|
and tracks[0].title == self.title
|
||||||
and tracks[0].track == 1
|
|
||||||
and tracks[0].disc == 1
|
# and tracks[0].track == 1
|
||||||
|
# and tracks[0].disc == 1
|
||||||
|
# Todo: Are the above commented checks necessary?
|
||||||
):
|
):
|
||||||
self.is_single = True
|
self.is_single = True
|
||||||
|
|
||||||
|
def get_date_from_tracks(self, tracks: list[Track]):
|
||||||
|
for track in tracks:
|
||||||
|
if track.date != "Unknown":
|
||||||
|
self.date = track.date
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Playlist:
|
class Playlist:
|
||||||
|
|||||||
+41
-32
@@ -1,16 +1,42 @@
|
|||||||
"""
|
"""
|
||||||
Contains default configs
|
Contains default configs
|
||||||
"""
|
"""
|
||||||
import multiprocessing
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
APP_VERSION = "Swing v0.0.1.alpha"
|
|
||||||
|
# ------- HELPER METHODS --------
|
||||||
|
def get_xdg_config_dir():
|
||||||
|
"""
|
||||||
|
Returns the XDG_CONFIG_HOME environment variable if it exists, otherwise
|
||||||
|
returns the default config directory. If none of those exist, returns the
|
||||||
|
user's home directory.
|
||||||
|
"""
|
||||||
|
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
|
||||||
|
|
||||||
|
if xdg_config_home:
|
||||||
|
return xdg_config_home
|
||||||
|
|
||||||
|
try:
|
||||||
|
alt_dir = os.path.join(os.environ.get("HOME"), ".config")
|
||||||
|
|
||||||
|
if os.path.exists(alt_dir):
|
||||||
|
return alt_dir
|
||||||
|
except TypeError:
|
||||||
|
return os.path.expanduser("~")
|
||||||
|
|
||||||
|
|
||||||
|
# ------- HELPER METHODS --------
|
||||||
|
|
||||||
|
|
||||||
|
APP_VERSION = "v.1.1.0.beta"
|
||||||
|
|
||||||
# paths
|
# paths
|
||||||
CONFIG_FOLDER = ".swing"
|
XDG_CONFIG_DIR = get_xdg_config_dir()
|
||||||
HOME_DIR = os.path.expanduser("~")
|
USER_HOME_DIR = os.path.expanduser("~")
|
||||||
|
|
||||||
APP_DIR = os.path.join(HOME_DIR, CONFIG_FOLDER)
|
CONFIG_FOLDER = "swingmusic" if XDG_CONFIG_DIR != USER_HOME_DIR else ".swingmusic"
|
||||||
|
|
||||||
|
APP_DIR = os.path.join(XDG_CONFIG_DIR, CONFIG_FOLDER)
|
||||||
IMG_PATH = os.path.join(APP_DIR, "images")
|
IMG_PATH = os.path.join(APP_DIR, "images")
|
||||||
|
|
||||||
ARTIST_IMG_PATH = os.path.join(IMG_PATH, "artists")
|
ARTIST_IMG_PATH = os.path.join(IMG_PATH, "artists")
|
||||||
@@ -20,7 +46,7 @@ ARTIST_IMG_LG_PATH = os.path.join(ARTIST_IMG_PATH, "large")
|
|||||||
THUMBS_PATH = os.path.join(IMG_PATH, "thumbnails")
|
THUMBS_PATH = os.path.join(IMG_PATH, "thumbnails")
|
||||||
SM_THUMB_PATH = os.path.join(THUMBS_PATH, "small")
|
SM_THUMB_PATH = os.path.join(THUMBS_PATH, "small")
|
||||||
LG_THUMBS_PATH = os.path.join(THUMBS_PATH, "large")
|
LG_THUMBS_PATH = os.path.join(THUMBS_PATH, "large")
|
||||||
|
MUSIC_DIR = os.path.join(USER_HOME_DIR, "Music")
|
||||||
|
|
||||||
# TEST_DIR = "/home/cwilvx/Downloads/Telegram Desktop"
|
# TEST_DIR = "/home/cwilvx/Downloads/Telegram Desktop"
|
||||||
# TEST_DIR = "/mnt/dfc48e0f-103b-426e-9bf9-f25d3743bc96/Music/Chill/Wolftyla Radio"
|
# TEST_DIR = "/mnt/dfc48e0f-103b-426e-9bf9-f25d3743bc96/Music/Chill/Wolftyla Radio"
|
||||||
@@ -34,11 +60,6 @@ IMG_PLAYLIST_URI = IMG_BASE_URI + "playlists/"
|
|||||||
|
|
||||||
# defaults
|
# defaults
|
||||||
DEFAULT_ARTIST_IMG = IMG_ARTIST_URI + "0.webp"
|
DEFAULT_ARTIST_IMG = IMG_ARTIST_URI + "0.webp"
|
||||||
|
|
||||||
LAST_FM_API_KEY = "762db7a44a9e6fb5585661f5f2bdf23a"
|
|
||||||
|
|
||||||
CPU_COUNT = multiprocessing.cpu_count()
|
|
||||||
|
|
||||||
THUMB_SIZE = 400
|
THUMB_SIZE = 400
|
||||||
SM_THUMB_SIZE = 64
|
SM_THUMB_SIZE = 64
|
||||||
SM_ARTIST_IMG_SIZE = 64
|
SM_ARTIST_IMG_SIZE = 64
|
||||||
@@ -46,34 +67,15 @@ SM_ARTIST_IMG_SIZE = 64
|
|||||||
The size of extracted images in pixels
|
The size of extracted images in pixels
|
||||||
"""
|
"""
|
||||||
|
|
||||||
LOGGER_ENABLE: bool = True
|
FILES = ["flac", "mp3", "wav", "m4a", "ogg", "wma", "opus", "alac", "aiff"]
|
||||||
|
|
||||||
FILES = ["flac", "mp3", "wav", "m4a"]
|
|
||||||
SUPPORTED_FILES = tuple(f".{file}" for file in FILES)
|
SUPPORTED_FILES = tuple(f".{file}" for file in FILES)
|
||||||
|
|
||||||
SUPPORTED_IMAGES = (".jpg", ".png", ".webp", ".jpeg")
|
|
||||||
|
|
||||||
SUPPORTED_DIR_IMAGES = [
|
|
||||||
"folder",
|
|
||||||
"cover",
|
|
||||||
"album",
|
|
||||||
"front",
|
|
||||||
]
|
|
||||||
|
|
||||||
# ===== DB =========
|
|
||||||
USE_MONGO = False
|
|
||||||
|
|
||||||
|
|
||||||
# ===== SQLite =====
|
# ===== SQLite =====
|
||||||
APP_DB_NAME = "swing.db"
|
APP_DB_NAME = "swing.db"
|
||||||
USER_DATA_DB_NAME = "userdata.db"
|
USER_DATA_DB_NAME = "userdata.db"
|
||||||
APP_DB_PATH = os.path.join(APP_DIR, APP_DB_NAME)
|
APP_DB_PATH = os.path.join(APP_DIR, APP_DB_NAME)
|
||||||
USERDATA_DB_PATH = os.path.join(APP_DIR, USER_DATA_DB_NAME)
|
USERDATA_DB_PATH = os.path.join(APP_DIR, USER_DATA_DB_NAME)
|
||||||
|
|
||||||
|
|
||||||
# ===== Store =====
|
|
||||||
USE_STORE = True
|
|
||||||
|
|
||||||
HELP_MESSAGE = """
|
HELP_MESSAGE = """
|
||||||
Usage: swingmusic [options]
|
Usage: swingmusic [options]
|
||||||
|
|
||||||
@@ -81,10 +83,17 @@ Options:
|
|||||||
--build: Build the application
|
--build: Build the application
|
||||||
--host: Set the host
|
--host: Set the host
|
||||||
--port: Set the port
|
--port: Set the port
|
||||||
|
--no-feat: Do not extract featured artists from the song title
|
||||||
--help, -h: Show this help message
|
--help, -h: Show this help message
|
||||||
--version, -v: Show the version
|
--version, -v: Show the version
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
EXTRACT_FEAT = True
|
||||||
|
"""
|
||||||
|
Whether to extract the featured artists from the song title.
|
||||||
|
Changed using the `--no-feat` flag
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class TCOLOR:
|
class TCOLOR:
|
||||||
"""
|
"""
|
||||||
@@ -95,7 +104,7 @@ class TCOLOR:
|
|||||||
OKBLUE = "\033[94m"
|
OKBLUE = "\033[94m"
|
||||||
OKCYAN = "\033[96m"
|
OKCYAN = "\033[96m"
|
||||||
OKGREEN = "\033[92m"
|
OKGREEN = "\033[92m"
|
||||||
WARNING = "\033[93m"
|
YELLOW = "\033[93m"
|
||||||
FAIL = "\033[91m"
|
FAIL = "\033[91m"
|
||||||
ENDC = "\033[0m"
|
ENDC = "\033[0m"
|
||||||
BOLD = "\033[1m"
|
BOLD = "\033[1m"
|
||||||
|
|||||||
+15
-7
@@ -3,21 +3,25 @@ Contains the functions to prepare the server for use.
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import time
|
||||||
from configparser import ConfigParser
|
from configparser import ConfigParser
|
||||||
|
|
||||||
from app import settings
|
from app import settings
|
||||||
from app.db.sqlite import create_connection, create_tables, queries
|
from app.db.sqlite import create_connection, create_tables, queries
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
from app.migrations import apply_migrations, set_postinit_migration_versions
|
||||||
|
from app.migrations._preinit import (
|
||||||
|
run_preinit_migrations,
|
||||||
|
set_preinit_migration_versions,
|
||||||
|
)
|
||||||
from app.settings import APP_DB_PATH, USERDATA_DB_PATH
|
from app.settings import APP_DB_PATH, USERDATA_DB_PATH
|
||||||
from app.utils import get_home_res_path
|
from app.utils import get_home_res_path
|
||||||
|
|
||||||
|
|
||||||
config = ConfigParser()
|
config = ConfigParser()
|
||||||
|
|
||||||
config_path = get_home_res_path("pyinstaller.config.ini")
|
config_path = get_home_res_path("pyinstaller.config.ini")
|
||||||
config.read(config_path)
|
config.read(config_path)
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
IS_BUILD = config["DEFAULT"]["BUILD"] == "True"
|
IS_BUILD = config["DEFAULT"]["BUILD"] == "True"
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@@ -64,10 +68,6 @@ def create_config_dir() -> None:
|
|||||||
"""
|
"""
|
||||||
Creates the config directory if it doesn't exist.
|
Creates the config directory if it doesn't exist.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
home_dir = os.path.expanduser("~")
|
|
||||||
config_folder = os.path.join(home_dir, settings.CONFIG_FOLDER)
|
|
||||||
|
|
||||||
thumb_path = os.path.join("images", "thumbnails")
|
thumb_path = os.path.join("images", "thumbnails")
|
||||||
small_thumb_path = os.path.join(thumb_path, "small")
|
small_thumb_path = os.path.join(thumb_path, "small")
|
||||||
large_thumb_path = os.path.join(thumb_path, "large")
|
large_thumb_path = os.path.join(thumb_path, "large")
|
||||||
@@ -91,7 +91,7 @@ def create_config_dir() -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
for _dir in dirs:
|
for _dir in dirs:
|
||||||
path = os.path.join(config_folder, _dir)
|
path = os.path.join(settings.APP_DIR, _dir)
|
||||||
exists = os.path.exists(path)
|
exists = os.path.exists(path)
|
||||||
|
|
||||||
if not exists:
|
if not exists:
|
||||||
@@ -107,6 +107,7 @@ def setup_sqlite():
|
|||||||
"""
|
"""
|
||||||
# if os.path.exists(DB_PATH):
|
# if os.path.exists(DB_PATH):
|
||||||
# os.remove(DB_PATH)
|
# os.remove(DB_PATH)
|
||||||
|
run_preinit_migrations()
|
||||||
|
|
||||||
app_db_conn = create_connection(APP_DB_PATH)
|
app_db_conn = create_connection(APP_DB_PATH)
|
||||||
playlist_db_conn = create_connection(USERDATA_DB_PATH)
|
playlist_db_conn = create_connection(USERDATA_DB_PATH)
|
||||||
@@ -114,9 +115,16 @@ def setup_sqlite():
|
|||||||
create_tables(app_db_conn, queries.CREATE_APPDB_TABLES)
|
create_tables(app_db_conn, queries.CREATE_APPDB_TABLES)
|
||||||
create_tables(playlist_db_conn, queries.CREATE_USERDATA_TABLES)
|
create_tables(playlist_db_conn, queries.CREATE_USERDATA_TABLES)
|
||||||
|
|
||||||
|
create_tables(app_db_conn, queries.CREATE_MIGRATIONS_TABLE)
|
||||||
|
create_tables(playlist_db_conn, queries.CREATE_MIGRATIONS_TABLE)
|
||||||
|
|
||||||
app_db_conn.close()
|
app_db_conn.close()
|
||||||
playlist_db_conn.close()
|
playlist_db_conn.close()
|
||||||
|
|
||||||
|
apply_migrations()
|
||||||
|
set_preinit_migration_versions()
|
||||||
|
set_postinit_migration_versions()
|
||||||
|
|
||||||
Store.load_all_tracks()
|
Store.load_all_tracks()
|
||||||
Store.process_folders()
|
Store.process_folders()
|
||||||
Store.load_albums()
|
Store.load_albums()
|
||||||
|
|||||||
+134
-17
@@ -1,13 +1,19 @@
|
|||||||
"""
|
"""
|
||||||
This module contains mini functions for the server.
|
This module contains mini functions for the server.
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from pathlib import Path
|
import os
|
||||||
|
import platform
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import socket as Socket
|
||||||
|
import string
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from unidecode import unidecode
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from unidecode import unidecode
|
||||||
|
|
||||||
from app import models
|
from app import models
|
||||||
from app.settings import SUPPORTED_FILES
|
from app.settings import SUPPORTED_FILES
|
||||||
@@ -27,27 +33,34 @@ def background(func):
|
|||||||
return background_func
|
return background_func
|
||||||
|
|
||||||
|
|
||||||
def run_fast_scandir(__dir: str, full=False) -> tuple[list[str], list[str]]:
|
def run_fast_scandir(_dir: str, full=False) -> tuple[list[str], list[str]]:
|
||||||
"""
|
"""
|
||||||
Scans a directory for files with a specific extension. Returns a list of files and folders in the directory.
|
Scans a directory for files with a specific extension.
|
||||||
|
Returns a list of files and folders in the directory.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
if _dir == "":
|
||||||
|
return [], []
|
||||||
|
|
||||||
subfolders = []
|
subfolders = []
|
||||||
files = []
|
files = []
|
||||||
|
|
||||||
for f in os.scandir(__dir):
|
try:
|
||||||
if f.is_dir() and not f.name.startswith("."):
|
for _file in os.scandir(_dir):
|
||||||
subfolders.append(f.path)
|
if _file.is_dir() and not _file.name.startswith("."):
|
||||||
if f.is_file():
|
subfolders.append(_file.path)
|
||||||
ext = os.path.splitext(f.name)[1].lower()
|
if _file.is_file():
|
||||||
|
ext = os.path.splitext(_file.name)[1].lower()
|
||||||
if ext in SUPPORTED_FILES:
|
if ext in SUPPORTED_FILES:
|
||||||
files.append(f.path)
|
files.append(win_replace_slash(_file.path))
|
||||||
|
|
||||||
if full or len(files) == 0:
|
if full or len(files) == 0:
|
||||||
for _dir in list(subfolders):
|
for _dir in list(subfolders):
|
||||||
sf, f = run_fast_scandir(_dir, full=True)
|
sub_dirs, _file = run_fast_scandir(_dir, full=True)
|
||||||
subfolders.extend(sf)
|
subfolders.extend(sub_dirs)
|
||||||
files.extend(f)
|
files.extend(_file)
|
||||||
|
except (OSError, PermissionError, FileNotFoundError, ValueError):
|
||||||
|
return [], []
|
||||||
|
|
||||||
return subfolders, files
|
return subfolders, files
|
||||||
|
|
||||||
@@ -169,13 +182,11 @@ def get_artists_from_tracks(tracks: list[models.Track]) -> set[str]:
|
|||||||
def get_albumartists(albums: list[models.Album]) -> set[str]:
|
def get_albumartists(albums: list[models.Album]) -> set[str]:
|
||||||
artists = set()
|
artists = set()
|
||||||
|
|
||||||
# master_artist_list = [a.albumartists for a in albums]
|
|
||||||
for album in albums:
|
for album in albums:
|
||||||
albumartists = [a.name for a in album.albumartists] # type: ignore
|
albumartists = [a.name for a in album.albumartists] # type: ignore
|
||||||
|
|
||||||
artists.update(albumartists)
|
artists.update(albumartists)
|
||||||
|
|
||||||
# return [models.Artist(a) for a in artists]
|
|
||||||
return artists
|
return artists
|
||||||
|
|
||||||
|
|
||||||
@@ -221,6 +232,112 @@ def bisection_search_string(strings: list[str], target: str) -> str | None:
|
|||||||
|
|
||||||
def get_home_res_path(filename: str):
|
def get_home_res_path(filename: str):
|
||||||
"""
|
"""
|
||||||
Returns a path to resources in the home directory of this project. Used to resolve resources in builds.
|
Returns a path to resources in the home directory of this project.
|
||||||
|
Used to resolve resources in builds.
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
return (CWD / ".." / filename).resolve()
|
return (CWD / ".." / filename).resolve()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_ip():
|
||||||
|
"""
|
||||||
|
Returns the IP address of this device.
|
||||||
|
"""
|
||||||
|
soc = Socket.socket(Socket.AF_INET, Socket.SOCK_DGRAM)
|
||||||
|
soc.connect(("8.8.8.8", 80))
|
||||||
|
ip_address = str(soc.getsockname()[0])
|
||||||
|
soc.close()
|
||||||
|
|
||||||
|
return ip_address
|
||||||
|
|
||||||
|
|
||||||
|
def is_windows():
|
||||||
|
"""
|
||||||
|
Returns True if the OS is Windows.
|
||||||
|
"""
|
||||||
|
return platform.system() == "Windows"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_feat_from_title(title: str) -> tuple[list[str], str]:
|
||||||
|
"""
|
||||||
|
Extracts featured artists from a song title using regex.
|
||||||
|
"""
|
||||||
|
regex = r"\((?:feat|ft|featuring|with)\.?\s+(.+?)\)"
|
||||||
|
# regex for square brackets 👇
|
||||||
|
sqr_regex = r"\[(?:feat|ft|featuring|with)\.?\s+(.+?)\]"
|
||||||
|
|
||||||
|
match = re.search(regex, title, re.IGNORECASE)
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
match = re.search(sqr_regex, title, re.IGNORECASE)
|
||||||
|
regex = sqr_regex
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
return [], title
|
||||||
|
|
||||||
|
artists = match.group(1)
|
||||||
|
artists = split_artists(artists, with_and=True)
|
||||||
|
|
||||||
|
# remove "feat" group from title
|
||||||
|
new_title = re.sub(regex, "", title, flags=re.IGNORECASE)
|
||||||
|
return artists, new_title
|
||||||
|
|
||||||
|
|
||||||
|
def get_random_str(length=5):
|
||||||
|
"""
|
||||||
|
Generates a random string of length `length`.
|
||||||
|
"""
|
||||||
|
return "".join(random.choices(string.ascii_letters + string.digits, k=length))
|
||||||
|
|
||||||
|
|
||||||
|
def win_replace_slash(path: str):
|
||||||
|
if is_windows():
|
||||||
|
return path.replace("\\", "/").replace("//", "/")
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def split_artists(src: str, with_and: bool = False):
|
||||||
|
exp = r"\s*(?:and|&|,|;)\s*" if with_and else r"\s*[,;]\s*"
|
||||||
|
|
||||||
|
artists = re.split(exp, src)
|
||||||
|
return [a.strip() for a in artists]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_artist_from_filename(title: str):
|
||||||
|
"""
|
||||||
|
Extracts artist names from a song title using regex.
|
||||||
|
"""
|
||||||
|
|
||||||
|
regex = r"^(.+?)\s*[-–—]\s*(?:.+?)$"
|
||||||
|
match = re.search(regex, title, re.IGNORECASE)
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
return []
|
||||||
|
|
||||||
|
artists = match.group(1)
|
||||||
|
artists = split_artists(artists)
|
||||||
|
return artists
|
||||||
|
|
||||||
|
|
||||||
|
def parse_title_from_filename(title: str):
|
||||||
|
"""
|
||||||
|
Extracts track title from a song title using regex.
|
||||||
|
"""
|
||||||
|
|
||||||
|
regex = r"^(?:.+?)\s*[-–—]\s*(.+?)$"
|
||||||
|
match = re.search(regex, title, re.IGNORECASE)
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
return title
|
||||||
|
|
||||||
|
res = match.group(1)
|
||||||
|
# remove text in brackets starting with "official" case insensitive
|
||||||
|
res = re.sub(r"\s*\([^)]*official[^)]*\)", "", res, flags=re.IGNORECASE)
|
||||||
|
return res.strip()
|
||||||
|
|
||||||
|
# for title in sample_titles:
|
||||||
|
# print(parse_artist_from_filename(title))
|
||||||
|
# print(parse_title_from_filename(title))
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ from configparser import ConfigParser
|
|||||||
|
|
||||||
import PyInstaller.__main__ as bundler
|
import PyInstaller.__main__ as bundler
|
||||||
|
|
||||||
|
from app import settings
|
||||||
from app.api import create_api
|
from app.api import create_api
|
||||||
from app.functions import run_periodic_checks
|
from app.functions import run_periodic_checks
|
||||||
from app.lib.watchdogg import Watcher as WatchDog
|
from app.lib.watchdogg import Watcher as WatchDog
|
||||||
from app.settings import APP_VERSION, HELP_MESSAGE, TCOLOR
|
from app.settings import APP_VERSION, HELP_MESSAGE, TCOLOR
|
||||||
from app.setup import run_setup
|
from app.setup import run_setup
|
||||||
from app.utils import background, get_home_res_path
|
from app.utils import background, get_home_res_path, get_ip, is_windows
|
||||||
|
|
||||||
werkzeug = logging.getLogger("werkzeug")
|
werkzeug = logging.getLogger("werkzeug")
|
||||||
werkzeug.setLevel(logging.ERROR)
|
werkzeug.setLevel(logging.ERROR)
|
||||||
@@ -42,7 +43,7 @@ def serve_client_files(path):
|
|||||||
@app.route("/")
|
@app.route("/")
|
||||||
def serve_client():
|
def serve_client():
|
||||||
"""
|
"""
|
||||||
Serves the index.html file at client/index.html.
|
Serves the index.html file at `client/index.html`.
|
||||||
"""
|
"""
|
||||||
return app.send_static_file("index.html")
|
return app.send_static_file("index.html")
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ class ArgsEnum:
|
|||||||
build = "--build"
|
build = "--build"
|
||||||
port = "--port"
|
port = "--port"
|
||||||
host = "--host"
|
host = "--host"
|
||||||
|
no_feat = "--no-feat"
|
||||||
help = ["--help", "-h"]
|
help = ["--help", "-h"]
|
||||||
version = ["--version", "-v"]
|
version = ["--version", "-v"]
|
||||||
|
|
||||||
@@ -67,6 +69,7 @@ class HandleArgs:
|
|||||||
self.handle_build()
|
self.handle_build()
|
||||||
self.handle_host()
|
self.handle_host()
|
||||||
self.handle_port()
|
self.handle_port()
|
||||||
|
self.handle_no_feat()
|
||||||
self.handle_help()
|
self.handle_help()
|
||||||
self.handle_version()
|
self.handle_version()
|
||||||
|
|
||||||
@@ -80,6 +83,8 @@ class HandleArgs:
|
|||||||
config["DEFAULT"]["BUILD"] = "True"
|
config["DEFAULT"]["BUILD"] = "True"
|
||||||
config.write(file)
|
config.write(file)
|
||||||
|
|
||||||
|
_s = ";" if is_windows() else ":"
|
||||||
|
|
||||||
bundler.run(
|
bundler.run(
|
||||||
[
|
[
|
||||||
"manage.py",
|
"manage.py",
|
||||||
@@ -87,9 +92,9 @@ class HandleArgs:
|
|||||||
"--name",
|
"--name",
|
||||||
"swingmusic",
|
"swingmusic",
|
||||||
"--clean",
|
"--clean",
|
||||||
"--add-data=assets:assets",
|
f"--add-data=assets{_s}assets",
|
||||||
"--add-data=client:client",
|
f"--add-data=client{_s}client",
|
||||||
"--add-data=pyinstaller.config.ini:.",
|
f"--add-data=pyinstaller.config.ini{_s}.",
|
||||||
"-y",
|
"-y",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -129,6 +134,11 @@ class HandleArgs:
|
|||||||
|
|
||||||
Variables.FLASK_HOST = host # type: ignore
|
Variables.FLASK_HOST = host # type: ignore
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def handle_no_feat():
|
||||||
|
if ArgsEnum.no_feat in ARGS:
|
||||||
|
settings.EXTRACT_FEAT = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def handle_help():
|
def handle_help():
|
||||||
if any((a in ARGS for a in ArgsEnum.help)):
|
if any((a in ARGS for a in ArgsEnum.help)):
|
||||||
@@ -153,31 +163,54 @@ def start_watchdog():
|
|||||||
WatchDog().run()
|
WatchDog().run()
|
||||||
|
|
||||||
|
|
||||||
def log_info():
|
def log_startup_info():
|
||||||
lines = " -------------------------------------"
|
lines = "------------------------------"
|
||||||
|
# clears terminal 👇
|
||||||
os.system("cls" if os.name == "nt" else "echo -e \\\\033c")
|
os.system("cls" if os.name == "nt" else "echo -e \\\\033c")
|
||||||
|
|
||||||
print(lines)
|
print(lines)
|
||||||
print(f" {TCOLOR.HEADER}{APP_VERSION} {TCOLOR.ENDC}")
|
print(f"{TCOLOR.HEADER}SwingMusic {APP_VERSION} {TCOLOR.ENDC}")
|
||||||
|
|
||||||
|
adresses = [Variables.FLASK_HOST]
|
||||||
|
|
||||||
|
if Variables.FLASK_HOST == "0.0.0.0":
|
||||||
|
adresses = ["localhost", get_ip()]
|
||||||
|
|
||||||
|
print("Started app on:")
|
||||||
|
for address in adresses:
|
||||||
|
# noinspection HttpUrlsUsage
|
||||||
print(
|
print(
|
||||||
f" Started app on: {TCOLOR.OKGREEN}http://{Variables.FLASK_HOST}:{Variables.FLASK_PORT}{TCOLOR.ENDC}"
|
f"➤ {TCOLOR.OKGREEN}http://{address}:{Variables.FLASK_PORT}{TCOLOR.ENDC}"
|
||||||
)
|
)
|
||||||
|
|
||||||
print(lines)
|
print(lines)
|
||||||
print("\n")
|
print("\n")
|
||||||
|
|
||||||
|
if not settings.EXTRACT_FEAT:
|
||||||
|
print(
|
||||||
|
f"{TCOLOR.OKBLUE}Extracting featured artists from track titles: {TCOLOR.FAIL}DISABLED!{TCOLOR.ENDC}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"{TCOLOR.OKBLUE}App data folder: {settings.APP_DIR}{TCOLOR.OKGREEN}{TCOLOR.ENDC}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
HandleArgs()
|
HandleArgs()
|
||||||
log_info()
|
log_startup_info()
|
||||||
run_bg_checks()
|
run_bg_checks()
|
||||||
start_watchdog()
|
start_watchdog()
|
||||||
|
|
||||||
app.run(
|
app.run(
|
||||||
debug=True,
|
debug=False,
|
||||||
threaded=True,
|
threaded=True,
|
||||||
host=Variables.FLASK_HOST,
|
host=Variables.FLASK_HOST,
|
||||||
port=Variables.FLASK_PORT,
|
port=Variables.FLASK_PORT,
|
||||||
use_reloader=False,
|
use_reloader=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: Find out how to print in color: red for errors, etc.
|
|
||||||
# TODO: Find a way to verify the host string
|
# TODO: Find a way to verify the host string
|
||||||
# TODO: Organize code in this file: move args to new file, etc.
|
# TODO: Organize code in this file: move args to new file, etc.
|
||||||
-44
@@ -1,44 +0,0 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
|
||||||
|
|
||||||
|
|
||||||
block_cipher = None
|
|
||||||
|
|
||||||
|
|
||||||
a = Analysis(
|
|
||||||
['manage.py'],
|
|
||||||
pathex=[],
|
|
||||||
binaries=[],
|
|
||||||
datas=[],
|
|
||||||
hiddenimports=[],
|
|
||||||
hookspath=[],
|
|
||||||
hooksconfig={},
|
|
||||||
runtime_hooks=[],
|
|
||||||
excludes=[],
|
|
||||||
win_no_prefer_redirects=False,
|
|
||||||
win_private_assemblies=False,
|
|
||||||
cipher=block_cipher,
|
|
||||||
noarchive=False,
|
|
||||||
)
|
|
||||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
|
||||||
|
|
||||||
exe = EXE(
|
|
||||||
pyz,
|
|
||||||
a.scripts,
|
|
||||||
a.binaries,
|
|
||||||
a.zipfiles,
|
|
||||||
a.datas,
|
|
||||||
[],
|
|
||||||
name='manage',
|
|
||||||
debug=False,
|
|
||||||
bootloader_ignore_signals=False,
|
|
||||||
strip=False,
|
|
||||||
upx=True,
|
|
||||||
upx_exclude=[],
|
|
||||||
runtime_tmpdir=None,
|
|
||||||
console=True,
|
|
||||||
disable_windowed_traceback=False,
|
|
||||||
argv_emulation=False,
|
|
||||||
target_arch=None,
|
|
||||||
codesign_identity=None,
|
|
||||||
entitlements_file=None,
|
|
||||||
)
|
|
||||||
Generated
+781
-625
File diff suppressed because it is too large
Load Diff
+12
-7
@@ -5,25 +5,30 @@ description = ""
|
|||||||
authors = ["geoffrey45 <geoffreymungai45@gmail.com>"]
|
authors = ["geoffrey45 <geoffreymungai45@gmail.com>"]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = ">=3.10"
|
python = ">=3.10,<3.12"
|
||||||
Flask = "^2.0.2"
|
Flask = "^2.0.2"
|
||||||
Flask-Cors = "^3.0.10"
|
Flask-Cors = "^3.0.10"
|
||||||
requests = "^2.27.1"
|
requests = "^2.27.1"
|
||||||
watchdog = "^2.2.0"
|
watchdog = "^2.2.1"
|
||||||
gunicorn = "^20.1.0"
|
gunicorn = "^20.1.0"
|
||||||
Pillow = "^9.0.1"
|
Pillow = "^9.0.1"
|
||||||
"colorgram.py" = "^1.2.0"
|
"colorgram.py" = "^1.2.0"
|
||||||
tqdm = "^4.64.0"
|
tqdm = "^4.64.0"
|
||||||
rapidfuzz = "^2.13.7"
|
rapidfuzz = "^2.13.7"
|
||||||
tinytag = "^1.8.1"
|
tinytag = "^1.8.1"
|
||||||
hypothesis = "^6.56.3"
|
|
||||||
pytest = "^7.1.3"
|
|
||||||
pylint = "^2.15.5"
|
|
||||||
Unidecode = "^1.3.6"
|
Unidecode = "^1.3.6"
|
||||||
pyinstaller = "^5.7.0"
|
psutil = "^5.9.4"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
black = {version = "^22.6.0", allow-prereleases = true}
|
pylint = "^2.15.5"
|
||||||
|
pytest = "^7.1.3"
|
||||||
|
hypothesis = "^6.56.3"
|
||||||
|
pyinstaller = "^5.7.0"
|
||||||
|
|
||||||
|
[tool.poetry.dev-dependencies.black]
|
||||||
|
version = "^22.6.0"
|
||||||
|
allow-prereleases = true
|
||||||
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
black @ file:///home/cwilvx/.cache/pypoetry/artifacts/6a/ca/67/2501f462728be2eb38d33f074ba5e8c08d49867e154b321b3f0b41db86/black-22.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
|
|
||||||
cachelib @ file:///home/cwilvx/.cache/pypoetry/artifacts/cd/93/0e/3cc9b898ce11816a06c6fdc2c82b4f32443ad17db9ac94b2b74380ebdf/cachelib-0.7.0-py3-none-any.whl
|
|
||||||
certifi @ file:///home/cwilvx/.cache/pypoetry/artifacts/8c/ef/5f/67cf35ca016dcd84174e483e20fc3cfcd8bf39b6852e96236e852f31ba/certifi-2022.5.18.1-py3-none-any.whl
|
|
||||||
charset-normalizer @ file:///home/cwilvx/.cache/pypoetry/artifacts/d5/27/31/db4fb74906e3a7f55f720e0079ac1850dd86e30651cdfa5e1f04c53cfa/charset_normalizer-2.0.12-py3-none-any.whl
|
|
||||||
click @ file:///home/cwilvx/.cache/pypoetry/artifacts/63/f3/4c/2270b95f4d37b9ea73cd401abe68b6e9ede30380533cd4e7118a8e3aa3/click-8.1.3-py3-none-any.whl
|
|
||||||
colorgram.py @ file:///home/cwilvx/.cache/pypoetry/artifacts/b9/4f/19/0bfe8f89dd3c5df77fd3399df1820ed195abdb2f850e3d64336a672d1b/colorgram.py-1.2.0-py2.py3-none-any.whl
|
|
||||||
Flask @ file:///home/cwilvx/.cache/pypoetry/artifacts/61/b9/1a/04191a9edc7415cae23e0e84b682bd895d55cc79f68018278adbca71c8/Flask-2.1.2-py3-none-any.whl
|
|
||||||
Flask-Caching @ file:///home/cwilvx/.cache/pypoetry/artifacts/e9/38/2f/8faf7982cf117a9058f8e8c2140c686f929bf8911986c0ab697cae8448/Flask_Caching-1.11.1-py3-none-any.whl
|
|
||||||
Flask-Cors @ file:///home/cwilvx/.cache/pypoetry/artifacts/b7/c4/f4/3606582505f2ade21c9f72607db37c2bd347d83951df4749019c3d39f8/Flask_Cors-3.0.10-py2.py3-none-any.whl
|
|
||||||
gunicorn @ file:///home/cwilvx/.cache/pypoetry/artifacts/9f/68/9f/f1166be9473b4fe2cc59c98fac616db1f94b18662b9055d1ac940374e3/gunicorn-20.1.0-py3-none-any.whl
|
|
||||||
idna @ file:///home/cwilvx/.cache/pypoetry/artifacts/90/36/8c/81eabf6ac88608721ab27f439c9a6b9a8e6a21cc58c59ebb1a42720199/idna-3.3-py3-none-any.whl
|
|
||||||
itsdangerous @ file:///home/cwilvx/.cache/pypoetry/artifacts/2e/15/8d/e1a5243416994d875e03f548c0c5af64a08970297056408d4e67e6bc28/itsdangerous-2.1.2-py3-none-any.whl
|
|
||||||
jarowinkler @ file:///home/cwilvx/.cache/pypoetry/artifacts/37/93/e8/2c0fb4589d71bd0c06ac156569ba434fb917ce65e3fc77353dc1960e7a/jarowinkler-1.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
|
|
||||||
Jinja2 @ file:///home/cwilvx/.cache/pypoetry/artifacts/49/36/ae/943f6cd852641f7249acddef711eb97d0c9ed91d7f435c798b6d7041ca/Jinja2-3.1.2-py3-none-any.whl
|
|
||||||
MarkupSafe @ file:///home/cwilvx/.cache/pypoetry/artifacts/dd/cc/d7/91f68383c04a15a87f0a2b31599de891c89b1d15e309273f759daf132c/MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
|
|
||||||
mutagen @ file:///home/cwilvx/.cache/pypoetry/artifacts/b4/fa/ad/d30a69658cc841ca38b77185eed0d97982259ce1cf1da32af87b376d4e/mutagen-1.45.1-py3-none-any.whl
|
|
||||||
mypy-extensions @ file:///home/cwilvx/.cache/pypoetry/artifacts/2f/c6/09/3e1afdcb75322c65b786e63cd7e879b6be3db36dea78ca376db5483ae4/mypy_extensions-0.4.3-py2.py3-none-any.whl
|
|
||||||
pathspec @ file:///home/cwilvx/.cache/pypoetry/artifacts/48/a0/9f/f5128d9e11d591bca7a942dd80ec44f9b2de8294775c68e0b99beeef93/pathspec-0.9.0-py2.py3-none-any.whl
|
|
||||||
Pillow @ file:///home/cwilvx/.cache/pypoetry/artifacts/95/cd/1a/99053885d95d74defc6d40d0bd7518f83fd74b133dfd762dfb523db565/Pillow-9.2.0-cp310-cp310-manylinux_2_28_x86_64.whl
|
|
||||||
platformdirs @ file:///home/cwilvx/.cache/pypoetry/artifacts/ea/8e/52/e5ac2f14474cef8f0fd44b4aa7d6968bfa89442d1b88ab567c446eae70/platformdirs-2.5.2-py3-none-any.whl
|
|
||||||
progress @ file:///home/cwilvx/.cache/pypoetry/artifacts/79/c2/d7/2a7bb2708100a9ccc186a9d3b9376c85fb53798080a3fe7480454fb17b/progress-1.6.tar.gz
|
|
||||||
pymongo @ file:///home/cwilvx/.cache/pypoetry/artifacts/64/8f/c6/6c691a87845035107c96bbd25b59f19d5cc716c2d46cbbdeb4ec149795/pymongo-4.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
|
|
||||||
rapidfuzz @ file:///home/cwilvx/.cache/pypoetry/artifacts/9d/5e/96/b127feb34cd55e8eedc2ba19c53199ebceb9252389ec7a95cd4eb6e154/rapidfuzz-2.0.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
|
|
||||||
requests @ file:///home/cwilvx/.cache/pypoetry/artifacts/d2/b2/c6/a04ce59140c6739203837d8dd0f518e29051b7ab61d2f34d4fd4241d30/requests-2.27.1-py2.py3-none-any.whl
|
|
||||||
six @ file:///home/cwilvx/.cache/pypoetry/artifacts/89/b2/f8/fd92b6d5daa0f8889429b2fc67ec21eedc5cae5d531ee2853828ced6c7/six-1.16.0-py2.py3-none-any.whl
|
|
||||||
tomli @ file:///home/cwilvx/.cache/pypoetry/artifacts/62/12/b6/6db9ebb9c8e1a6c5aa8a92ae73098d8119816b5e8507490916621bc305/tomli-2.0.1-py3-none-any.whl
|
|
||||||
tqdm @ file:///home/cwilvx/.cache/pypoetry/artifacts/9c/70/8c/d9fd60c1049cc4dba00815d66d598e9d5f265d4d59489e074827e331a9/tqdm-4.64.0-py2.py3-none-any.whl
|
|
||||||
urllib3 @ file:///home/cwilvx/.cache/pypoetry/artifacts/88/1c/d5/a55ed0245e5d7cd3a9f40dd75733644cbf7b7d94a6c521eb6c027a326c/urllib3-1.26.9-py2.py3-none-any.whl
|
|
||||||
watchdog @ file:///home/cwilvx/.cache/pypoetry/artifacts/0f/af/c3/b6575b0b5cab70c439d50980bd9673762b57878772f930d2d908ca83fc/watchdog-2.1.8-py3-none-manylinux2014_x86_64.whl
|
|
||||||
Werkzeug @ file:///home/cwilvx/.cache/pypoetry/artifacts/34/38/89/78911cfcd7dec75796d6056c730d94f730967bfe4fb4c5192b8d0d81ec/Werkzeug-2.1.2-py3-none-any.whl
|
|
||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
# Fixes !
|
|
||||||
|
|
||||||
- [ ] Click on artist image to go to artist page ⚠
|
|
||||||
- [ ] Play next song if current song can't be loaded ⚠
|
|
||||||
<!-- -->
|
|
||||||
- [ ] Removing song duplicates from queries
|
|
||||||
- [ ] Add support for WAV files
|
|
||||||
- [ ] Compress thumbnails
|
|
||||||
|
|
||||||
# Features +
|
|
||||||
|
|
||||||
## Needed features
|
|
||||||
|
|
||||||
- [ ] Adding songs to queue
|
|
||||||
<!-- -->
|
|
||||||
- [ ] Add keyboard shortcuts
|
|
||||||
- [ ] Adjust volume
|
|
||||||
- [ ] Add listening statistics for all songs
|
|
||||||
- [ ] Extract color from artist image [for use with artist card gradient]
|
|
||||||
- [ ] Adding songs to favorites
|
|
||||||
- [ ] Playing song radio
|
|
||||||
|
|
||||||
## Future features
|
|
||||||
|
|
||||||
- [ ] Toggle shuffle
|
|
||||||
- [ ] Toggle repeat
|
|
||||||
- [ ] Suggest similar artists
|
|
||||||
- [ ] Getting artist info
|
|
||||||
- [ ] Create a Python script to build, bundle and serve the app
|
|
||||||
- [ ] Getting extra song info (probably from genius)
|
|
||||||
- [ ] Getting lyrics
|
|
||||||
- [ ] Sorting songs
|
|
||||||
- [ ] Suggest undiscorvered artists, albums and songs
|
|
||||||
- [ ] Remember last played song
|
|
||||||
- [ ] Add next and previous song transition and progress bar reset animations
|
|
||||||
- [ ] Add playlist to folder
|
|
||||||
- [ ] Add functionality to 'Listen now' button
|
|
||||||
- [ ] Paginated requests for songs
|
|
||||||
- [ ] Package app as installable PWA
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
#!/bin/zsh
|
|
||||||
|
|
||||||
gpath=$(poetry run which gunicorn)
|
|
||||||
# pytest=$(poetry run which pytest)
|
|
||||||
|
|
||||||
# $pytest # -q
|
|
||||||
|
|
||||||
while getopts ':s' opt; do
|
|
||||||
case $opt in
|
|
||||||
s)
|
|
||||||
echo "Starting image server"
|
|
||||||
cd "./app"
|
|
||||||
"$gpath" -b 0.0.0.0:1971 -w 1 --threads=1 "imgserver:app" &
|
|
||||||
cd ../
|
|
||||||
echo "Done ✅"
|
|
||||||
;;
|
|
||||||
\?)
|
|
||||||
echo "Invalid option: -$OPTARG" >&2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
|
|
||||||
echo "Starting swing"
|
|
||||||
"$gpath" -b 0.0.0.0:1970 --threads=2 "manage:create_api()"
|
|
||||||
-44
@@ -1,44 +0,0 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
|
||||||
|
|
||||||
|
|
||||||
block_cipher = None
|
|
||||||
|
|
||||||
|
|
||||||
a = Analysis(
|
|
||||||
['manage.py'],
|
|
||||||
pathex=[],
|
|
||||||
binaries=[],
|
|
||||||
datas=[('assets', 'assets'), ('client', 'client'), ('pyinstaller.config.ini', '.')],
|
|
||||||
hiddenimports=[],
|
|
||||||
hookspath=[],
|
|
||||||
hooksconfig={},
|
|
||||||
runtime_hooks=[],
|
|
||||||
excludes=[],
|
|
||||||
win_no_prefer_redirects=False,
|
|
||||||
win_private_assemblies=False,
|
|
||||||
cipher=block_cipher,
|
|
||||||
noarchive=False,
|
|
||||||
)
|
|
||||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
|
||||||
|
|
||||||
exe = EXE(
|
|
||||||
pyz,
|
|
||||||
a.scripts,
|
|
||||||
a.binaries,
|
|
||||||
a.zipfiles,
|
|
||||||
a.datas,
|
|
||||||
[],
|
|
||||||
name='swing',
|
|
||||||
debug=False,
|
|
||||||
bootloader_ignore_signals=False,
|
|
||||||
strip=False,
|
|
||||||
upx=True,
|
|
||||||
upx_exclude=[],
|
|
||||||
runtime_tmpdir=None,
|
|
||||||
console=True,
|
|
||||||
disable_windowed_traceback=False,
|
|
||||||
argv_emulation=False,
|
|
||||||
target_arch=None,
|
|
||||||
codesign_identity=None,
|
|
||||||
entitlements_file=None,
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from hypothesis import given
|
||||||
|
from app.utils 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) == 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)
|
||||||
Reference in New Issue
Block a user