From fe39cadfdcf991f75652d4313b1edf7c909ac79f Mon Sep 17 00:00:00 2001 From: cwilvx Date: Tue, 7 Jan 2025 23:13:19 +0300 Subject: [PATCH] feat: use thumbnails from folders + cache failed lastfm scrobbles + implement lastfm scrobble filter + change /home to /nothome --- app/api/home/__init__.py | 2 +- app/api/imgserver.py | 117 ++++++++++++++++++++++++++++++++--- app/api/scrobble/__init__.py | 20 ++++-- app/config.py | 1 + app/lib/tagger.py | 1 + app/models/album.py | 1 + app/models/track.py | 2 + app/settings.py | 4 ++ app/store/albums.py | 12 ---- app/utils/stats.py | 7 +-- 10 files changed, 136 insertions(+), 31 deletions(-) diff --git a/app/api/home/__init__.py b/app/api/home/__init__.py index 095c0f44..00748ab7 100644 --- a/app/api/home/__init__.py +++ b/app/api/home/__init__.py @@ -8,7 +8,7 @@ from app.lib.home.get_recently_played import get_recently_played from app.store.homepage import HomepageStore bp_tag = Tag(name="Home", description="Homepage items") -api = APIBlueprint("home", __name__, url_prefix="/home", abp_tags=[bp_tag]) +api = APIBlueprint("home", __name__, url_prefix="/nothome", abp_tags=[bp_tag]) @api.get("/recents/added") diff --git a/app/api/imgserver.py b/app/api/imgserver.py index a3aa8d48..f635f175 100644 --- a/app/api/imgserver.py +++ b/app/api/imgserver.py @@ -1,3 +1,4 @@ +from fileinput import filename from pathlib import Path from flask_openapi3 import Tag from flask_openapi3 import APIBlueprint @@ -5,6 +6,10 @@ from pydantic import BaseModel, Field from flask import send_from_directory from app.settings import Defaults, Paths +from app.store.albums import AlbumStore +from app.store.tracks import TrackStore +from app.utils.threading import background +from PIL import Image bp_tag = Tag( name="Images", description="Image filenames are constructured as '{itemhash}.webp'" @@ -12,6 +17,74 @@ bp_tag = Tag( api = APIBlueprint("imgserver", __name__, url_prefix="/img", abp_tags=[bp_tag]) +@background +def cache_thumbnails(filepath: Path, trackhash: str): + """ + Resizes the image and stores it in the cache directory. + """ + image = Image.open(filepath) + path = Path(Paths.get_image_cache_path()) + aspect_ratio = image.width / image.height + + sizes = { + "xsmall": 64, + "small": 96, + "medium": 256, + "large": 512, + } + + for size, width in sizes.items(): + width = min(width, image.width) + height = int(width / aspect_ratio) + + resized_path = path / size / (trackhash + ".webp") + resized_path.parent.mkdir(parents=True, exist_ok=True) + image.resize((width, height)).save(resized_path, format="webp") + + +def find_thumbnail(albumhash: str, pathhash: str): + # entry = TrackStore.trackhashmap.get(albumhash) + entry = AlbumStore.albummap.get(albumhash) + + if entry is None: + return None, None, "" + + track_file = None + + tracks = TrackStore.get_tracks_by_trackhashes(entry.trackhashes) + for track in tracks: + if track.pathhash == pathhash: + track_file = track + break + + if track_file is None: + return None, None, "" + + folder = Path(track_file.folder) + + # INFO: Check if the folder has image files + extensions = [".jpg", ".jpeg", ".png", ".webp"] + hierarchy = ["cover", "front", "back", "folder", "album", "artwork"] + + images: list[Path] = [] + for item in folder.iterdir(): + if item.suffix in extensions: + images.append(item) + + if len(images) == 0: + return None, None, "" + + # INFO: Check if the folder has image files in the hierarchy + for item in hierarchy: + for image in images: + if image.name.lower().startswith(item.lower()): + return image.parent, image.name, track_file.albumhash + + # INFO: If no image falls in the hierarchy, return the first image + first_image = images[0] + return first_image.parent, first_image.name, track_file.albumhash + + def send_fallback_img(filename: str = "default.webp"): """ Returns the fallback image from the assets folder. @@ -25,7 +98,9 @@ def send_fallback_img(filename: str = "default.webp"): return send_from_directory(folder, filename) -def send_file_or_fallback(folder: str, filename: str, fallback: str = "default.webp"): +def send_file_or_fallback( + folder: str, filename: str, fallback: str = "default.webp", pathhash: str = "" +): """ Returns the file from the folder or the fallback image. """ @@ -34,6 +109,22 @@ def send_file_or_fallback(folder: str, filename: str, fallback: str = "default.w if fpath.exists(): return send_from_directory(folder, filename) + if pathhash != "": + # INFO: Check if the image is in the cache + cache_path = Path(Paths.get_image_cache_path()) / fpath.parent.name / filename + if cache_path.exists(): + return send_from_directory(cache_path.parent, cache_path.name) + + # INFO: Find the thumbnail + parent, file, albumhash = find_thumbnail( + filename.replace(".webp", ""), pathhash + ) + + # INFO: Cache and send the thumbnail + if file is not None and parent is not None: + cache_thumbnails(parent / file, albumhash) + return send_from_directory(parent, file) + return send_fallback_img(fallback) @@ -44,6 +135,13 @@ class ImagePath(BaseModel): ) +class ImageQuery(BaseModel): + pathhash: str = Field( + description="The path hash used to find the thumbnail", + default="", + ) + + # @api.get("/t/o/") # def send_original_thumbnail(path: ImagePath): # """ @@ -60,39 +158,39 @@ class ImagePath(BaseModel): # TRACK THUMBNAILS @api.get("/thumbnail/") -def send_lg_thumbnail(path: ImagePath): +def send_lg_thumbnail(path: ImagePath, query: ImageQuery): """ Get large thumbnail (500 x 500) """ folder = Paths.get_lg_thumb_path() - return send_file_or_fallback(folder, path.imgpath) + return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash) @api.get("/thumbnail/xsmall/") -def send_xsm_thumbnail(path: ImagePath): +def send_xsm_thumbnail(path: ImagePath, query: ImageQuery): """ Get extra small thumbnail (64px) """ folder = Paths.get_xsm_thumb_path() - return send_file_or_fallback(folder, path.imgpath) + return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash) @api.get("/thumbnail/small/") -def send_sm_thumbnail(path: ImagePath): +def send_sm_thumbnail(path: ImagePath, query: ImageQuery): """ Get small thumbnail (96px) """ folder = Paths.get_sm_thumb_path() - return send_file_or_fallback(folder, path.imgpath) + return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash) @api.get("/thumbnail/medium/") -def send_md_thumbnail(path: ImagePath): +def send_md_thumbnail(path: ImagePath, query: ImageQuery): """ Get medium thumbnail (256px) """ folder = Paths.get_md_thumb_path() - return send_file_or_fallback(folder, path.imgpath) + return send_file_or_fallback(folder, path.imgpath, pathhash=query.pathhash) # ARTISTS @@ -141,6 +239,7 @@ def send_playlist_image(path: PlaylistImagePath): folder = Paths.get_playlist_img_path() return send_file_or_fallback(folder, path.imgpath, "playlist.svg") + # MIXES @api.get("/mix/medium/") def send_md_mix_image(path: ImagePath): diff --git a/app/api/scrobble/__init__.py b/app/api/scrobble/__init__.py index d0396465..b2d60ccf 100644 --- a/app/api/scrobble/__init__.py +++ b/app/api/scrobble/__init__.py @@ -94,13 +94,19 @@ def log_track(body: LogTrackBody): if artist: artist.increment_playcount(duration, timestamp) - track = TrackStore.trackhashmap.get(body.trackhash) - if track: - track.increment_playcount(duration, timestamp) + trackentry.increment_playcount(duration, timestamp) + track = trackentry.tracks[0] lastfm = LastFmPlugin() - if lastfm.enabled: + print(track.duration / 2, 240, body.duration, "\n") + + if ( + lastfm.enabled + and track.duration > 30 + and body.duration >= min(track.duration / 2, 240) + # SEE: https://www.last.fm/api/scrobbling#when-is-a-scrobble-a-scrobble + ): lastfm.scrobble(trackentry.tracks[0], timestamp) return {"msg": "recorded"}, 201 @@ -350,7 +356,11 @@ def get_stats(): if len(tracks) > 0 else "—" ), - tracks[0].image if len(tracks) > 0 else None, + ( + tracks[0].image + "?pathhash=" + tracks[0].pathhash + if len(tracks) > 0 + else None + ), ) fav_count = FavoritesTable.count_favs_in_period(start_time, end_time) diff --git a/app/config.py b/app/config.py index bdbcaed5..178a233f 100644 --- a/app/config.py +++ b/app/config.py @@ -29,6 +29,7 @@ class UserConfig: "Crosby, Stills, Nash & Young", "Smith & Thell", "Peter, Paul & Mary", + "Simon & Garfunkel", } ) genreSeparators: set[str] = field(default_factory=lambda: {"/", ";", "&"}) diff --git a/app/lib/tagger.py b/app/lib/tagger.py index 8999d2ba..7358cb92 100644 --- a/app/lib/tagger.py +++ b/app/lib/tagger.py @@ -179,6 +179,7 @@ def create_albums(_trackhashes: list[str] = []) -> list[tuple[Album, set[str]]]: "playduration": track.playduration, "title": track.album, "tracks": {track.trackhash}, + "pathhash": track.pathhash, "extra": {}, } else: diff --git a/app/models/album.py b/app/models/album.py index 34ecbac7..68147aa3 100644 --- a/app/models/album.py +++ b/app/models/album.py @@ -30,6 +30,7 @@ class Album: playcount: int playduration: int extra: dict + pathhash: str = "" id: int = -1 type: str = "album" diff --git a/app/models/track.py b/app/models/track.py index e0a17673..e706487c 100644 --- a/app/models/track.py +++ b/app/models/track.py @@ -52,6 +52,7 @@ class Track: image: str = "" explicit: bool = False fav_userids: list[int] = field(default_factory=list) + pathhash: str = "" @property def is_favorite(self): @@ -81,6 +82,7 @@ class Track: self.weakhash = create_hash(self.title, self.artists) explicit_tag = self.extra.get("explicit", ["0"]) self.explicit = int(explicit_tag[0]) == 1 + self.pathhash = create_hash(self.folder) self.image = self.albumhash + ".webp" self.extra = { diff --git a/app/settings.py b/app/settings.py index df06e3d9..e9de5d7b 100644 --- a/app/settings.py +++ b/app/settings.py @@ -123,6 +123,10 @@ class Paths: def get_sm_mixes_img_path(cls): return join(cls.get_mixes_img_path(), "small") + @classmethod + def get_image_cache_path(cls): + return join(cls.get_img_path(), "cache") + # defaults class Defaults: diff --git a/app/store/albums.py b/app/store/albums.py index ed1860f1..68f2ec2d 100644 --- a/app/store/albums.py +++ b/app/store/albums.py @@ -38,20 +38,8 @@ class AlbumMapEntry: class AlbumStore: - # albums: list[Album] = CustomList() albummap: dict[str, AlbumMapEntry] = {} - # @staticmethod - # def create_album(track: Track): - # """ - # Creates album object from a track - # """ - # return Album( - # albumhash=track.albumhash, - # albumartists=track.albumartists, # type: ignore - # title=track.og_album, - # ) - @classmethod def load_albums(cls, instance_key: str): """ diff --git a/app/utils/stats.py b/app/utils/stats.py index 50087001..689a4550 100644 --- a/app/utils/stats.py +++ b/app/utils/stats.py @@ -233,7 +233,7 @@ def get_track_group_stats(tracks: list[Track], is_album: bool = False): "toptrack", f"top track ({seconds_to_time_string(top_track.playduration)} listened)", f"{top_track.title}", - top_track.image, + top_track.image + "?pathhash=" + top_track.pathhash if top_track else None, ) if top_track else StatItem( @@ -251,7 +251,7 @@ def get_track_group_stats(tracks: list[Track], is_album: bool = False): "playcount": 0, "playduration": 0, "title": track.album, - "image": track.image, + "image": track.image + "?pathhash=" + track.pathhash if track.image else None, } albums_map[track.albumhash]["playcount"] += 1 @@ -268,8 +268,7 @@ def get_track_group_stats(tracks: list[Track], is_album: bool = False): "topalbum", f"top album ({seconds_to_time_string(top_album['playduration'])} listened)", f"{top_album['title']}", - top_album["image"], - ) + top_album["image"]) if top_album else StatItem( "topalbum",