Move server code to this repo (#95)

move server code to this repo
This commit is contained in:
Mungai Njoroge
2023-01-13 20:01:52 +03:00
committed by GitHub
parent dd257e919d
commit 198957bcae
318 changed files with 6259 additions and 16797 deletions
+3
View File
@@ -0,0 +1,3 @@
"""
This module contains all the data processing and non-API libraries
"""
+3
View File
@@ -0,0 +1,3 @@
"""
Contains methods relating to albums.
"""
+130
View File
@@ -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)
+94
View File
@@ -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.
+47
View File
@@ -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
+115
View File
@@ -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
+100
View File
@@ -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]
+125
View File
@@ -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]
+159
View File
@@ -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
+19
View File
@@ -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)
+172
View File
@@ -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()