mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-05 04:53:01 +00:00
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
This module contains all the data processing and non-API libraries
|
||||
"""
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Contains methods relating to albums.
|
||||
"""
|
||||
@@ -0,0 +1,130 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
import requests
|
||||
import urllib
|
||||
|
||||
from tqdm import tqdm
|
||||
from requests.exceptions import ConnectionError as ReqConnError, ReadTimeout
|
||||
|
||||
from app import settings
|
||||
from app.models import Artist
|
||||
from app.db.store import Store
|
||||
from app.utils import create_hash
|
||||
|
||||
|
||||
def get_artist_image_link(artist: str):
|
||||
"""
|
||||
Returns an artist image url.
|
||||
"""
|
||||
|
||||
try:
|
||||
query = urllib.parse.quote(artist) # type: ignore
|
||||
|
||||
url = f"https://api.deezer.com/search/artist?q={query}"
|
||||
response = requests.get(url, timeout=30)
|
||||
data = response.json()
|
||||
|
||||
for res in data["data"]:
|
||||
res_hash = create_hash(res["name"], decode=True)
|
||||
artist_hash = create_hash(artist, decode=True)
|
||||
|
||||
if res_hash == artist_hash:
|
||||
return res["picture_big"]
|
||||
|
||||
return None
|
||||
except (ReqConnError, ReadTimeout, IndexError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
class DownloadImage:
|
||||
def __init__(self, url: str, name: str) -> None:
|
||||
sm_path = Path(settings.ARTIST_IMG_SM_PATH) / name
|
||||
lg_path = Path(settings.ARTIST_IMG_LG_PATH) / name
|
||||
|
||||
img = self.download(url)
|
||||
|
||||
if img is not None:
|
||||
self.save_img(img, sm_path, lg_path)
|
||||
|
||||
@staticmethod
|
||||
def download(url: str) -> Image.Image | None:
|
||||
"""
|
||||
Downloads the image from the url.
|
||||
"""
|
||||
return Image.open(BytesIO(requests.get(url, timeout=10).content))
|
||||
|
||||
@staticmethod
|
||||
def save_img(img: Image.Image, sm_path: Path, lg_path: Path):
|
||||
"""
|
||||
Saves the image to the destinations.
|
||||
"""
|
||||
img.save(lg_path, format="webp")
|
||||
|
||||
sm_size = settings.SM_ARTIST_IMG_SIZE
|
||||
img.resize((sm_size, sm_size), Image.ANTIALIAS).save(sm_path, format="webp")
|
||||
|
||||
|
||||
class CheckArtistImages:
|
||||
def __init__(self):
|
||||
with ThreadPoolExecutor() as pool:
|
||||
list(
|
||||
tqdm(
|
||||
pool.map(self.download_image, Store.artists),
|
||||
total=len(Store.artists),
|
||||
desc="Downloading artist images",
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def download_image(artist: Artist):
|
||||
"""
|
||||
Checks if an artist image exists and downloads it if not.
|
||||
|
||||
:param artistname: The artist name
|
||||
"""
|
||||
img_path = Path(settings.ARTIST_IMG_SM_PATH) / f"{artist.artisthash}.webp"
|
||||
|
||||
if img_path.exists():
|
||||
return
|
||||
|
||||
url = get_artist_image_link(artist.name)
|
||||
|
||||
if url is not None:
|
||||
return DownloadImage(url, name=f"{artist.artisthash}.webp")
|
||||
|
||||
|
||||
# def fetch_album_bio(title: str, albumartist: str) -> str | None:
|
||||
# """
|
||||
# Returns the album bio for a given album.
|
||||
# """
|
||||
# last_fm_url = "http://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key={}&artist={}&album={}&format=json".format(
|
||||
# settings.LAST_FM_API_KEY, albumartist, title
|
||||
# )
|
||||
|
||||
# try:
|
||||
# response = requests.get(last_fm_url)
|
||||
# data = response.json()
|
||||
# except:
|
||||
# return None
|
||||
|
||||
# try:
|
||||
# bio = data["album"]["wiki"]["summary"].split('<a href="https://www.last.fm/')[0]
|
||||
# except KeyError:
|
||||
# bio = None
|
||||
|
||||
# return bio
|
||||
|
||||
|
||||
# class FetchAlbumBio:
|
||||
# """
|
||||
# Returns the album bio for a given album.
|
||||
# """
|
||||
|
||||
# def __init__(self, title: str, albumartist: str):
|
||||
# self.title = title
|
||||
# self.albumartist = albumartist
|
||||
|
||||
# def __call__(self):
|
||||
# return fetch_album_bio(self.title, self.albumartist)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Contains everything that deals with image color extraction.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import colorgram
|
||||
from tqdm import tqdm
|
||||
|
||||
from app import settings
|
||||
from app.db.sqlite.albums import SQLiteAlbumMethods as db
|
||||
from app.db.sqlite.artists import SQLiteArtistMethods as adb
|
||||
from app.db.sqlite.utils import SQLiteManager
|
||||
from app.db.store import Store
|
||||
from app.models import Album, Artist
|
||||
|
||||
|
||||
def get_image_colors(image: str) -> list[str]:
|
||||
"""Extracts 2 of the most dominant colors from an image."""
|
||||
try:
|
||||
colors = sorted(colorgram.extract(image, 1), key=lambda c: c.hsl.h)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
formatted_colors = []
|
||||
|
||||
for color in colors:
|
||||
color = f"rgb({color.rgb.r}, {color.rgb.g}, {color.rgb.b})"
|
||||
formatted_colors.append(color)
|
||||
|
||||
return formatted_colors
|
||||
|
||||
|
||||
class ProcessAlbumColors:
|
||||
"""
|
||||
Extracts the most dominant color from the album art and saves it to the database.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
||||
with SQLiteManager() as cur:
|
||||
for album in tqdm(Store.albums, desc="Processing album colors"):
|
||||
if len(album.colors) == 0:
|
||||
colors = self.process_color(album)
|
||||
|
||||
if colors is None:
|
||||
continue
|
||||
|
||||
album.set_colors(colors)
|
||||
|
||||
color_str = json.dumps(colors)
|
||||
db.insert_one_album(cur, album.albumhash, color_str)
|
||||
|
||||
@staticmethod
|
||||
def process_color(album: Album):
|
||||
path = Path(settings.SM_THUMB_PATH) / album.image
|
||||
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
colors = get_image_colors(str(path))
|
||||
return colors
|
||||
|
||||
|
||||
class ProcessArtistColors:
|
||||
"""
|
||||
Extracts the most dominant color from the artist art and saves it to the database.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
all_artists = Store.artists
|
||||
|
||||
if all_artists is None:
|
||||
return
|
||||
|
||||
for artist in tqdm(all_artists, desc="Processing artist colors"):
|
||||
if len(artist.colors) == 0:
|
||||
self.process_color(artist)
|
||||
|
||||
@staticmethod
|
||||
def process_color(artist: Artist):
|
||||
path = Path(settings.ARTIST_IMG_SM_PATH) / artist.image
|
||||
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
colors = get_image_colors(str(path))
|
||||
|
||||
if len(colors) > 0:
|
||||
adb.insert_one_artist(artisthash=artist.artisthash, colors=colors)
|
||||
Store.map_artist_color((0, artist.artisthash, json.dumps(colors)))
|
||||
|
||||
# TODO: Load album and artist colors into the store.
|
||||
@@ -0,0 +1,47 @@
|
||||
import os
|
||||
import pathlib
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from app.db.store import Store
|
||||
from app.models import Folder, Track
|
||||
from app.settings import SUPPORTED_FILES
|
||||
|
||||
|
||||
class GetFilesAndDirs:
|
||||
"""
|
||||
Get files and folders from a directory.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = path
|
||||
|
||||
def __call__(self) -> tuple[list[Track], list[Folder]]:
|
||||
try:
|
||||
entries = os.scandir(self.path)
|
||||
except FileNotFoundError:
|
||||
return ([], [])
|
||||
|
||||
dirs, files = [], []
|
||||
|
||||
for entry in entries:
|
||||
ext = os.path.splitext(entry.name)[1].lower()
|
||||
|
||||
if entry.is_dir() and not entry.name.startswith("."):
|
||||
dirs.append(entry.path)
|
||||
elif entry.is_file() and ext in SUPPORTED_FILES:
|
||||
files.append(entry.path)
|
||||
|
||||
# sort files by modified time
|
||||
files.sort(
|
||||
key=lambda f: os.path.getmtime(f) # pylint: disable=unnecessary-lambda
|
||||
)
|
||||
|
||||
tracks = Store.get_tracks_by_filepaths(files)
|
||||
|
||||
with ThreadPoolExecutor() as pool:
|
||||
iterable = pool.map(Store.get_folder, dirs)
|
||||
folders = [i for i in iterable if i is not None]
|
||||
|
||||
folders = filter(lambda f: f.has_tracks, folders)
|
||||
|
||||
return (tracks, folders) # type: ignore
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
This library contains all the functions related to playlists.
|
||||
"""
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, ImageSequence
|
||||
|
||||
from app import settings
|
||||
from app.logger import log
|
||||
|
||||
|
||||
def create_thumbnail(image: Any, img_path: str) -> str:
|
||||
"""
|
||||
Creates a 250 x 250 thumbnail from a playlist image
|
||||
"""
|
||||
thumb_path = "thumb_" + img_path
|
||||
full_thumb_path = os.path.join(settings.APP_DIR, "images", "playlists", thumb_path)
|
||||
|
||||
aspect_ratio = image.width / image.height
|
||||
|
||||
new_w = round(250 * aspect_ratio)
|
||||
|
||||
thumb = image.resize((new_w, 250), Image.ANTIALIAS)
|
||||
thumb.save(full_thumb_path, "webp")
|
||||
|
||||
return thumb_path
|
||||
|
||||
|
||||
def create_gif_thumbnail(image: Any, img_path: str):
|
||||
"""
|
||||
Creates a 250 x 250 thumbnail from a playlist image
|
||||
"""
|
||||
thumb_path = "thumb_" + img_path
|
||||
full_thumb_path = os.path.join(settings.APP_DIR, "images", "playlists", thumb_path)
|
||||
|
||||
frames = []
|
||||
|
||||
for frame in ImageSequence.Iterator(image):
|
||||
aspect_ratio = frame.width / frame.height
|
||||
|
||||
new_w = round(250 * aspect_ratio)
|
||||
|
||||
thumb = frame.resize((new_w, 250), Image.ANTIALIAS)
|
||||
frames.append(thumb)
|
||||
|
||||
frames[0].save(full_thumb_path, save_all=True, append_images=frames[1:])
|
||||
|
||||
return thumb_path
|
||||
|
||||
|
||||
def save_p_image(file, pid: str):
|
||||
"""
|
||||
Saves the image of a playlist to the database.
|
||||
"""
|
||||
img = Image.open(file)
|
||||
|
||||
random_str = "".join(random.choices(string.ascii_letters + string.digits, k=5))
|
||||
|
||||
img_path = pid + str(random_str) + ".webp"
|
||||
|
||||
full_img_path = os.path.join(settings.APP_DIR, "images", "playlists", img_path)
|
||||
|
||||
if file.content_type == "image/gif":
|
||||
frames = []
|
||||
|
||||
for frame in ImageSequence.Iterator(img):
|
||||
frames.append(frame.copy())
|
||||
|
||||
frames[0].save(full_img_path, save_all=True, append_images=frames[1:])
|
||||
create_gif_thumbnail(img, img_path=img_path)
|
||||
|
||||
return img_path
|
||||
|
||||
img.save(full_img_path, "webp")
|
||||
create_thumbnail(img, img_path=img_path)
|
||||
|
||||
return img_path
|
||||
|
||||
|
||||
class ValidatePlaylistThumbs:
|
||||
"""
|
||||
Removes all unused images in the images/playlists folder.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
images = []
|
||||
playlists = Get.get_all_playlists()
|
||||
|
||||
log.info("Validating playlist thumbnails")
|
||||
for playlist in playlists:
|
||||
if playlist.image:
|
||||
img_path = playlist.image.split("/")[-1]
|
||||
thumb_path = playlist.thumb.split("/")[-1]
|
||||
|
||||
images.append(img_path)
|
||||
images.append(thumb_path)
|
||||
|
||||
p_path = os.path.join(settings.APP_DIR, "images", "playlists")
|
||||
|
||||
for image in os.listdir(p_path):
|
||||
if image not in images:
|
||||
os.remove(os.path.join(p_path, image))
|
||||
|
||||
log.info("Validating playlist thumbnails ... ✅")
|
||||
|
||||
|
||||
def create_new_date():
|
||||
return datetime.now()
|
||||
|
||||
|
||||
# TODO: Fix ValidatePlaylistThumbs
|
||||
@@ -0,0 +1,100 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from tqdm import tqdm
|
||||
|
||||
from app import settings
|
||||
from app.db.sqlite.tracks import SQLiteTrackMethods
|
||||
from app.db.store import Store
|
||||
|
||||
from app.lib.taglib import extract_thumb, get_tags
|
||||
from app.logger import log
|
||||
from app.models import Album, Artist, Track
|
||||
from app.utils import run_fast_scandir
|
||||
|
||||
get_all_tracks = SQLiteTrackMethods.get_all_tracks
|
||||
insert_many_tracks = SQLiteTrackMethods.insert_many_tracks
|
||||
|
||||
|
||||
class Populate:
|
||||
"""
|
||||
Populates the database with all songs in the music directory
|
||||
|
||||
checks if the song is in the database, if not, it adds it
|
||||
also checks if the album art exists in the image path, if not tries to extract it.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
||||
tracks = get_all_tracks()
|
||||
tracks = list(tracks)
|
||||
|
||||
files = run_fast_scandir(settings.HOME_DIR, full=True)[1]
|
||||
|
||||
untagged = self.filter_untagged(tracks, files)
|
||||
|
||||
if len(untagged) == 0:
|
||||
log.info("All clear, no unread files.")
|
||||
return
|
||||
|
||||
self.tag_untagged(untagged)
|
||||
|
||||
@staticmethod
|
||||
def filter_untagged(tracks: list[Track], files: list[str]):
|
||||
tagged_files = [t.filepath for t in tracks]
|
||||
return set(files) - set(tagged_files)
|
||||
|
||||
@staticmethod
|
||||
def tag_untagged(untagged: set[str]):
|
||||
log.info("Found %s new tracks", len(untagged))
|
||||
tagged_tracks: list[dict] = []
|
||||
tagged_count = 0
|
||||
|
||||
for file in tqdm(untagged, desc="Reading files"):
|
||||
tags = get_tags(file)
|
||||
|
||||
if tags is not None:
|
||||
tagged_tracks.append(tags)
|
||||
track = Track(**tags)
|
||||
|
||||
Store.add_track(track)
|
||||
Store.add_folder(track.folder)
|
||||
|
||||
if not Store.album_exists(track.albumhash):
|
||||
Store.add_album(Store.create_album(track))
|
||||
|
||||
for artist in track.artist:
|
||||
if not Store.artist_exists(artist.artisthash):
|
||||
Store.add_artist(Artist(artist.name))
|
||||
|
||||
for artist in track.albumartist:
|
||||
if not Store.artist_exists(artist.artisthash):
|
||||
Store.add_artist(Artist(artist.name))
|
||||
|
||||
tagged_count += 1
|
||||
else:
|
||||
log.warning("Could not read file: %s", file)
|
||||
|
||||
if len(tagged_tracks) > 0:
|
||||
insert_many_tracks(tagged_tracks)
|
||||
|
||||
log.info("Added %s/%s tracks", tagged_count, len(untagged))
|
||||
|
||||
|
||||
def get_image(album: Album):
|
||||
for track in Store.tracks:
|
||||
if track.albumhash == album.albumhash:
|
||||
extract_thumb(track.filepath, track.image)
|
||||
break
|
||||
|
||||
|
||||
class ProcessTrackThumbnails:
|
||||
def __init__(self) -> None:
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = list(
|
||||
tqdm(
|
||||
pool.map(get_image, Store.albums),
|
||||
total=len(Store.albums),
|
||||
desc="Extracting track images",
|
||||
)
|
||||
)
|
||||
|
||||
results = [r for r in results]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
This library contains all the functions related to the search functionality.
|
||||
"""
|
||||
from typing import List
|
||||
|
||||
from rapidfuzz import fuzz, process
|
||||
|
||||
from app import models
|
||||
|
||||
ratio = fuzz.ratio
|
||||
wratio = fuzz.WRatio
|
||||
|
||||
|
||||
class Cutoff:
|
||||
"""
|
||||
Holds all the default cutoff values.
|
||||
"""
|
||||
|
||||
tracks: int = 60
|
||||
albums: int = 60
|
||||
artists: int = 60
|
||||
playlists: int = 60
|
||||
|
||||
|
||||
class Limit:
|
||||
"""
|
||||
Holds all the default limit values.
|
||||
"""
|
||||
|
||||
tracks: int = 50
|
||||
albums: int = 50
|
||||
artists: int = 50
|
||||
playlists: int = 50
|
||||
|
||||
|
||||
class SearchTracks:
|
||||
def __init__(self, tracks: List[models.Track], query: str) -> None:
|
||||
self.query = query
|
||||
self.tracks = tracks
|
||||
|
||||
def __call__(self) -> List[models.Track]:
|
||||
"""
|
||||
Gets all songs with a given title.
|
||||
"""
|
||||
|
||||
tracks = [track.title for track in self.tracks]
|
||||
results = process.extract(
|
||||
self.query,
|
||||
tracks,
|
||||
scorer=fuzz.WRatio,
|
||||
score_cutoff=Cutoff.tracks,
|
||||
limit=Limit.tracks,
|
||||
)
|
||||
|
||||
return [self.tracks[i[2]] for i in results]
|
||||
|
||||
|
||||
class SearchArtists:
|
||||
def __init__(self, artists: list[str], query: str) -> None:
|
||||
self.query = query
|
||||
self.artists = artists
|
||||
|
||||
def __call__(self) -> list:
|
||||
"""
|
||||
Gets all artists with a given name.
|
||||
"""
|
||||
|
||||
results = process.extract(
|
||||
self.query,
|
||||
self.artists,
|
||||
scorer=fuzz.WRatio,
|
||||
score_cutoff=Cutoff.artists,
|
||||
limit=Limit.artists,
|
||||
)
|
||||
|
||||
artists = [a[0] for a in results]
|
||||
return [models.Artist(a) for a in artists]
|
||||
|
||||
|
||||
class SearchAlbums:
|
||||
def __init__(self, albums: List[models.Album], query: str) -> None:
|
||||
self.query = query
|
||||
self.albums = albums
|
||||
|
||||
def __call__(self) -> List[models.Album]:
|
||||
"""
|
||||
Gets all albums with a given title.
|
||||
"""
|
||||
|
||||
albums = [a.title.lower() for a in self.albums]
|
||||
|
||||
results = process.extract(
|
||||
self.query,
|
||||
albums,
|
||||
scorer=fuzz.WRatio,
|
||||
score_cutoff=Cutoff.albums,
|
||||
limit=Limit.albums,
|
||||
)
|
||||
|
||||
return [self.albums[i[2]] for i in results]
|
||||
|
||||
# get all artists that matched the query
|
||||
# for get all albums from the artists
|
||||
# get all albums that matched the query
|
||||
# return [**artist_albums **albums]
|
||||
|
||||
# recheck next and previous artist on play next or add to playlist
|
||||
|
||||
|
||||
class SearchPlaylists:
|
||||
def __init__(self, playlists: List[models.Playlist], query: str) -> None:
|
||||
self.playlists = playlists
|
||||
self.query = query
|
||||
|
||||
def __call__(self) -> List[models.Playlist]:
|
||||
playlists = [p.name for p in self.playlists]
|
||||
results = process.extract(
|
||||
self.query,
|
||||
playlists,
|
||||
scorer=fuzz.WRatio,
|
||||
score_cutoff=Cutoff.playlists,
|
||||
limit=Limit.playlists,
|
||||
)
|
||||
|
||||
return [self.playlists[i[2]] for i in results]
|
||||
@@ -0,0 +1,159 @@
|
||||
import os
|
||||
import datetime
|
||||
from io import BytesIO
|
||||
|
||||
from tinytag import TinyTag
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from app import settings
|
||||
from app.utils import create_hash
|
||||
|
||||
|
||||
|
||||
def parse_album_art(filepath: str):
|
||||
"""
|
||||
Returns the album art for a given audio file.
|
||||
"""
|
||||
|
||||
try:
|
||||
tags = TinyTag.get(filepath, image=True)
|
||||
return tags.get_image()
|
||||
except: # pylint: disable=bare-except
|
||||
return None
|
||||
|
||||
|
||||
def extract_thumb(filepath: str, webp_path: str) -> bool:
|
||||
"""
|
||||
Extracts the thumbnail from an audio file. Returns the path to the thumbnail.
|
||||
"""
|
||||
img_path = os.path.join(settings.LG_THUMBS_PATH, webp_path)
|
||||
sm_img_path = os.path.join(settings.SM_THUMB_PATH, webp_path)
|
||||
|
||||
tsize = settings.THUMB_SIZE
|
||||
sm_tsize = settings.SM_THUMB_SIZE
|
||||
|
||||
def save_image(img: Image.Image):
|
||||
img.resize((sm_tsize, sm_tsize), Image.ANTIALIAS).save(sm_img_path, "webp")
|
||||
img.resize((tsize, tsize), Image.ANTIALIAS).save(img_path, "webp")
|
||||
|
||||
if os.path.exists(img_path):
|
||||
img_size = os.path.getsize(img_path)
|
||||
|
||||
if img_size > 0:
|
||||
return True
|
||||
|
||||
album_art = parse_album_art(filepath)
|
||||
|
||||
if album_art is not None:
|
||||
try:
|
||||
img = Image.open(BytesIO(album_art))
|
||||
except (UnidentifiedImageError, OSError):
|
||||
return False
|
||||
|
||||
try:
|
||||
save_image(img)
|
||||
except OSError:
|
||||
try:
|
||||
png = img.convert("RGB")
|
||||
save_image(png)
|
||||
except: # pylint: disable=bare-except
|
||||
return False
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_date(date_str: str | None) -> int:
|
||||
current_year = datetime.date.today().today().year
|
||||
|
||||
if date_str is None:
|
||||
return current_year
|
||||
|
||||
try:
|
||||
return int(date_str.split("-")[0])
|
||||
except: # pylint: disable=bare-except
|
||||
return current_year
|
||||
|
||||
|
||||
def get_tags(filepath: str):
|
||||
filetype = filepath.split(".")[-1]
|
||||
filename = (filepath.split("/")[-1]).replace(f".{filetype}", "")
|
||||
|
||||
try:
|
||||
tags = TinyTag.get(filepath)
|
||||
except: # pylint: disable=bare-except
|
||||
return None
|
||||
|
||||
no_albumartist: bool = (tags.albumartist == "") or (tags.albumartist is None)
|
||||
no_artist: bool = (tags.artist == "") or (tags.artist is None)
|
||||
|
||||
if no_albumartist and not no_artist:
|
||||
tags.albumartist = tags.artist
|
||||
|
||||
if no_artist and not no_albumartist:
|
||||
tags.artist = tags.albumartist
|
||||
|
||||
to_filename = ["title", "album"]
|
||||
for tag in to_filename:
|
||||
p = getattr(tags, tag)
|
||||
if p == "" or p is None:
|
||||
setattr(tags, tag, filename)
|
||||
|
||||
to_check = ["album", "artist", "year", "albumartist"]
|
||||
for prop in to_check:
|
||||
p = getattr(tags, prop)
|
||||
if (p is None) or (p == ""):
|
||||
setattr(tags, prop, "Unknown")
|
||||
|
||||
to_round = ["bitrate", "duration"]
|
||||
for prop in to_round:
|
||||
try:
|
||||
setattr(tags, prop, round(getattr(tags, prop)))
|
||||
except TypeError:
|
||||
setattr(tags, prop, 0)
|
||||
|
||||
to_int = ["track", "disc"]
|
||||
for prop in to_int:
|
||||
try:
|
||||
setattr(tags, prop, int(getattr(tags, prop)))
|
||||
except (ValueError, TypeError):
|
||||
setattr(tags, prop, 1)
|
||||
|
||||
try:
|
||||
tags.copyright = tags.extra["copyright"]
|
||||
except KeyError:
|
||||
tags.copyright = None
|
||||
|
||||
tags.albumhash = create_hash(tags.album, tags.albumartist)
|
||||
tags.trackhash = create_hash(tags.artist, tags.album, tags.title)
|
||||
tags.image = f"{tags.albumhash}.webp"
|
||||
tags.folder = os.path.dirname(filepath)
|
||||
|
||||
tags.date = extract_date(tags.year)
|
||||
tags.filepath = filepath
|
||||
tags.filetype = filetype
|
||||
|
||||
tags = tags.__dict__
|
||||
|
||||
# delete all tag properties that start with _ (tinytag internals)
|
||||
for tag in list(tags):
|
||||
if tag.startswith("_"):
|
||||
del tags[tag]
|
||||
|
||||
to_delete = [
|
||||
"filesize",
|
||||
"audio_offset",
|
||||
"channels",
|
||||
"comment",
|
||||
"composer",
|
||||
"disc_total",
|
||||
"extra",
|
||||
"samplerate",
|
||||
"track_total",
|
||||
"year",
|
||||
]
|
||||
|
||||
for tag in to_delete:
|
||||
del tags[tag]
|
||||
|
||||
return tags
|
||||
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
This library contains all the functions related to tracks.
|
||||
"""
|
||||
import os
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from app.db.store import Store
|
||||
from app.db.sqlite.tracks import SQLiteTrackMethods as tdb
|
||||
|
||||
|
||||
def validate_tracks() -> None:
|
||||
"""
|
||||
Gets all songs under the ~/ directory.
|
||||
"""
|
||||
for track in tqdm(Store.tracks, desc="Removing deleted tracks"):
|
||||
if not os.path.exists(track.filepath):
|
||||
Store.tracks.remove(track)
|
||||
tdb.remove_track_by_filepath(track.filepath)
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
This library contains the classes and functions related to the watchdog file watcher.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
|
||||
from watchdog.events import PatternMatchingEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from app.db.sqlite.tracks import SQLiteManager
|
||||
from app.db.sqlite.tracks import SQLiteTrackMethods as db
|
||||
from app.db.store import Store
|
||||
from app.lib.taglib import get_tags
|
||||
from app.logger import log
|
||||
from app.models import Artist, Track
|
||||
|
||||
|
||||
class Watcher:
|
||||
"""
|
||||
Contains the methods for initializing and starting watchdog.
|
||||
"""
|
||||
|
||||
home_dir = os.path.expanduser("~")
|
||||
dirs = [home_dir]
|
||||
observers: list[Observer] = []
|
||||
|
||||
def __init__(self):
|
||||
self.observer = Observer()
|
||||
|
||||
def run(self):
|
||||
event_handler = Handler()
|
||||
|
||||
for dir_ in self.dirs:
|
||||
self.observer.schedule(
|
||||
event_handler, os.path.realpath(dir_), recursive=True
|
||||
)
|
||||
self.observers.append(self.observer)
|
||||
|
||||
try:
|
||||
self.observer.start()
|
||||
except OSError:
|
||||
log.error("Could not start watchdog.")
|
||||
return
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
for obsv in self.observers:
|
||||
obsv.unschedule_all()
|
||||
obsv.stop()
|
||||
|
||||
for obsv in self.observers:
|
||||
obsv.join()
|
||||
|
||||
|
||||
def add_track(filepath: str) -> None:
|
||||
"""
|
||||
Processes the audio tags for a given file ands add them to the database and store.
|
||||
|
||||
Then creates the folder, album and artist objects for the added track and adds them to the store.
|
||||
"""
|
||||
tags = get_tags(filepath)
|
||||
|
||||
if tags is None:
|
||||
return
|
||||
|
||||
with SQLiteManager() as cur:
|
||||
db.remove_track_by_filepath(tags["filepath"])
|
||||
db.insert_one_track(tags, cur)
|
||||
|
||||
track = Track(**tags)
|
||||
Store().add_track(track)
|
||||
|
||||
Store.add_folder(track.folder)
|
||||
|
||||
if not Store.album_exists(track.albumhash):
|
||||
album = Store.create_album(track)
|
||||
Store.add_album(album)
|
||||
|
||||
artists: list[Artist] = track.artist + track.albumartist # type: ignore
|
||||
|
||||
for artist in artists:
|
||||
if not Store.artist_exists(artist.artisthash):
|
||||
Store.add_artist(Artist(artist.name))
|
||||
|
||||
|
||||
def remove_track(filepath: str) -> None:
|
||||
"""
|
||||
Removes a track from the music dict.
|
||||
"""
|
||||
try:
|
||||
track = Store.get_tracks_by_filepaths([filepath])[0]
|
||||
except IndexError:
|
||||
return
|
||||
|
||||
db.remove_track_by_filepath(filepath)
|
||||
Store.remove_track_by_filepath(filepath)
|
||||
|
||||
empty_album = Store.count_tracks_by_hash(track.albumhash) > 0
|
||||
|
||||
if empty_album:
|
||||
Store.remove_album_by_hash(track.albumhash)
|
||||
|
||||
artists: list[Artist] = track.artist + track.albumartist # type: ignore
|
||||
|
||||
for artist in artists:
|
||||
empty_artist = not Store.artist_has_tracks(artist.artisthash)
|
||||
|
||||
if empty_artist:
|
||||
Store.remove_artist_by_hash(artist.artisthash)
|
||||
|
||||
empty_folder = Store.is_empty_folder(track.folder)
|
||||
|
||||
if empty_folder:
|
||||
Store.remove_folder(track.folder)
|
||||
|
||||
|
||||
class Handler(PatternMatchingEventHandler):
|
||||
files_to_process = []
|
||||
|
||||
def __init__(self):
|
||||
log.info("✅ started watchdog")
|
||||
PatternMatchingEventHandler.__init__(
|
||||
self,
|
||||
patterns=["*.flac", "*.mp3"],
|
||||
ignore_directories=True,
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
def on_created(self, event):
|
||||
"""
|
||||
Fired when a supported file is created.
|
||||
"""
|
||||
self.files_to_process.append(event.src_path)
|
||||
|
||||
def on_deleted(self, event):
|
||||
"""
|
||||
Fired when a delete event occurs on a supported file.
|
||||
"""
|
||||
|
||||
remove_track(event.src_path)
|
||||
|
||||
def on_moved(self, event):
|
||||
"""
|
||||
Fired when a move event occurs on a supported file.
|
||||
"""
|
||||
trash = "share/Trash"
|
||||
|
||||
if trash in event.dest_path:
|
||||
remove_track(event.src_path)
|
||||
|
||||
elif trash in event.src_path:
|
||||
add_track(event.dest_path)
|
||||
|
||||
elif trash not in event.dest_path and trash not in event.src_path:
|
||||
add_track(event.dest_path)
|
||||
remove_track(event.src_path)
|
||||
|
||||
def on_closed(self, event):
|
||||
"""
|
||||
Fired when a created file is closed.
|
||||
"""
|
||||
try:
|
||||
self.files_to_process.remove(event.src_path)
|
||||
if os.path.getsize(event.src_path) > 0:
|
||||
add_track(event.src_path)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
# watcher = Watcher()
|
||||
Reference in New Issue
Block a user