diff --git a/TODO.md b/TODO.md index e1ed9eee..dc6d5216 100644 --- a/TODO.md +++ b/TODO.md @@ -44,4 +44,6 @@ - Move plugins to a config file - What about our migrations? - Add userid to queries -- Remove duplicates on artist page (test with Hanson) \ No newline at end of file +- Remove duplicates on artist page (test with Hanson) +- Test foreign keys on delete +- Map scrobble info on app start diff --git a/app/api/__init__.py b/app/api/__init__.py index b9eacf49..73a0c663 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -26,7 +26,7 @@ from app.api import ( settings, lyrics, plugins, - logger, + scrobble, home, getall, auth, @@ -116,7 +116,7 @@ def create_api(): app.register_api(lyrics_plugin.api) # Logger - app.register_api(logger.api) + app.register_api(scrobble.api) # Home app.register_api(home.api) diff --git a/app/api/getall/__init__.py b/app/api/getall/__init__.py index f1fe2a35..5489803a 100644 --- a/app/api/getall/__init__.py +++ b/app/api/getall/__init__.py @@ -18,6 +18,7 @@ from app.utils.dates import ( create_new_date, date_string_to_time_passed, seconds_to_time_string, + timestamp_to_time_passed, ) bp_tag = Tag(name="Get all", description="List all items") @@ -57,6 +58,13 @@ def get_all_items(path: GetAllItemsPath, query: GetAllItemsQuery): Get all items Used to show all albums or artists in the library + + Sort keys: + - + Both albums and artists: `duration`, `created_date`, `playcount`, `playduration`, `lastplayed`, `trackcount` + + Albums only: `title`, `albumartists`, `date` + Artists only: `name`, `albumcount` """ is_albums = path.itemtype == "albums" is_artists = path.itemtype == "artists" @@ -76,6 +84,9 @@ def get_all_items(path: GetAllItemsPath, query: GetAllItemsQuery): sort_is_count = sort == "trackcount" sort_is_duration = sort == "duration" sort_is_create_date = sort == "created_date" + sort_is_playcount = sort == "playcount" + sort_is_playduration = sort == "playduration" + sort_is_lastplayed = sort == "lastplayed" sort_is_date = is_albums and sort == "date" sort_is_artist = is_albums and sort == "albumartists" @@ -94,7 +105,6 @@ def get_all_items(path: GetAllItemsPath, query: GetAllItemsQuery): for item in items: item_dict = serialize_album(item) if is_albums else serialize_artist(item) - print(item_dict) if sort_is_date: item_dict["help_text"] = item.date @@ -122,6 +132,20 @@ def get_all_items(path: GetAllItemsPath, query: GetAllItemsQuery): f"{format_number(item.albumcount)} album{'' if item.albumcount == 1 else 's'}" ) + if sort_is_playcount: + item_dict["help_text"] = ( + f"{format_number(item.playcount)} play{'' if item.playcount == 1 else 's'}" + ) + + if sort_is_lastplayed: + if item.playduration == 0: + item_dict["help_text"] = "Never played" + else: + item_dict["help_text"] = timestamp_to_time_passed(item.lastplayed) + + if sort_is_playduration: + item_dict["help_text"] = seconds_to_time_string(item.playduration) + album_list.append(item_dict) return {"items": album_list, "total": total} diff --git a/app/api/logger/__init__.py b/app/api/scrobble/__init__.py similarity index 64% rename from app/api/logger/__init__.py rename to app/api/scrobble/__init__.py index 40fd4db7..19137a0b 100644 --- a/app/api/logger/__init__.py +++ b/app/api/scrobble/__init__.py @@ -3,7 +3,8 @@ from flask_openapi3 import APIBlueprint from pydantic import Field from app.api.apischemas import TrackHashSchema -from app.db.sqlite.logger.tracks import SQLiteTrackLogger as db +from app.db.libdata import AlbumTable, ArtistTable, TrackTable +from app.db.userdata import ScrobbleTable from app.settings import Defaults bp_tag = Tag(name="Logger", description="Log item plays") @@ -26,19 +27,20 @@ def log_track(body: LogTrackBody): """ Log a track play to the database. """ - trackhash = body.trackhash timestamp = body.timestamp duration = body.duration - source = body.source if not timestamp or duration < 5: return {"msg": "Invalid entry."}, 400 - last_row = db.insert_track( - trackhash=trackhash, - timestamp=timestamp, - duration=duration, - source=source, - ) + track = TrackTable.get_track_by_trackhash(body.trackhash) - return {"total entries": last_row} + if track is None: + return {"msg": "Track not found."}, 404 + + ScrobbleTable.add(dict(body)) + TrackTable.increment_playcount(body.trackhash, duration, timestamp) + AlbumTable.increment_playcount(track.albumhash, duration, timestamp) + ArtistTable.increment_playcount(track.artisthashes, duration, timestamp) + + return {"msg": "recorded"}, 201 diff --git a/app/db/__init__.py b/app/db/__init__.py index ebc6d776..bd857356 100644 --- a/app/db/__init__.py +++ b/app/db/__init__.py @@ -48,7 +48,7 @@ class DbManager: if self.commit: self.conn.commit() - self.conn.close() + # self.conn.close() class Base(MappedAsDataclass, DeclarativeBase): diff --git a/app/db/libdata.py b/app/db/libdata.py index 9d3ebbd6..9748fe3f 100644 --- a/app/db/libdata.py +++ b/app/db/libdata.py @@ -73,6 +73,21 @@ class Base(MasterBase, DeclarativeBase): conn.execute(stmt) + @classmethod + def increment_scrobblecount( + cls, table: Any, field: Any, hash: str, duration: int, timestamp: int + ): + cls.execute( + update(table) + .where(field == hash) + .values( + playcount=table.playcount + 1, + playduration=table.playduration + duration, + lastplayed=timestamp, + ), + commit=True, + ) + class TrackTable(Base): __tablename__ = "track" @@ -99,8 +114,12 @@ class TrackTable(Base): track: Mapped[int] = mapped_column(Integer()) trackhash: Mapped[str] = mapped_column(String(), index=True) is_favorite: Mapped[Optional[bool]] = mapped_column(Boolean()) - playcount: Mapped[int] = mapped_column(Integer()) - extra: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON()) + lastplayed: Mapped[int] = mapped_column(Integer(), default=0) + playcount: Mapped[int] = mapped_column(Integer(), default=0) + playduration: Mapped[int] = mapped_column(Integer(), default=0) + extra: Mapped[Optional[dict[str, Any]]] = mapped_column( + JSON(), default_factory=dict + ) @classmethod def get_all(cls): @@ -180,6 +199,12 @@ class TrackTable(Base): with DbManager(commit=True) as conn: conn.execute(delete(TrackTable).where(TrackTable.filepath.in_(filepaths))) + @classmethod + def increment_playcount(cls, trackhash: str, duration: int, timestamp: int): + cls.increment_scrobblecount( + TrackTable, TrackTable.trackhash, trackhash, duration, timestamp + ) + class AlbumTable(Base): __tablename__ = "album" @@ -199,7 +224,12 @@ class AlbumTable(Base): title: Mapped[str] = mapped_column(String()) trackcount: Mapped[int] = mapped_column(Integer()) is_favorite: Mapped[Optional[bool]] = mapped_column(Boolean()) - extra: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON()) + lastplayed: Mapped[int] = mapped_column(Integer(), default=0) + playcount: Mapped[int] = mapped_column(Integer(), default=0) + playduration: Mapped[int] = mapped_column(Integer(), default=0) + extra: Mapped[Optional[dict[str, Any]]] = mapped_column( + JSON(), default_factory=dict + ) @classmethod def get_all(cls): @@ -257,6 +287,12 @@ class AlbumTable(Base): ) return albums_to_dataclasses(result.all()) + @classmethod + def increment_playcount(cls, albumhash: str, duration: int, timestamp: int): + return cls.increment_scrobblecount( + AlbumTable, AlbumTable.albumhash, albumhash, duration, timestamp + ) + class ArtistTable(Base): __tablename__ = "artist" @@ -272,7 +308,12 @@ class ArtistTable(Base): name: Mapped[str] = mapped_column(String(), index=True) trackcount: Mapped[int] = mapped_column(Integer()) is_favorite: Mapped[Optional[bool]] = mapped_column(Boolean()) - extra: Mapped[Optional[dict[str, Any]]] = mapped_column(JSON()) + lastplayed: Mapped[int] = mapped_column(Integer(), default=0) + playcount: Mapped[int] = mapped_column(Integer(), default=0) + playduration: Mapped[int] = mapped_column(Integer(), default=0) + extra: Mapped[Optional[dict[str, Any]]] = mapped_column( + JSON(), default_factory=dict + ) @classmethod def get_all(cls): @@ -310,3 +351,18 @@ class ArtistTable(Base): .limit(limit) ) return artists_to_dataclasses(result.fetchall()) + + @classmethod + def increment_playcount( + cls, artisthashes: list[str], duration: int, timestamp: int + ): + cls.execute( + update(cls) + .where(ArtistTable.artisthash.in_(artisthashes)) + .values( + playcount=ArtistTable.playcount + 1, + playduration=ArtistTable.playduration + duration, + lastplayed=timestamp, + ), + commit=True, + ) diff --git a/app/db/userdata.py b/app/db/userdata.py index ce2b6708..b57cd73f 100644 --- a/app/db/userdata.py +++ b/app/db/userdata.py @@ -31,7 +31,7 @@ from app.db.utils import ( ) from app.db import Base, DbManager -from app.utils.auth import hash_password +from app.utils.auth import get_current_userid, hash_password class UserTable(Base): @@ -160,7 +160,7 @@ class FavoritesTable(Base): type: Mapped[str] = mapped_column(String(), index=True) timestamp: Mapped[int] = mapped_column(Integer(), index=True) userid: Mapped[int] = mapped_column( - Integer(), ForeignKey("user.id"), default=1, index=True + Integer(), ForeignKey("user.id", ondelete="cascade"), default=1, index=True ) extra: Mapped[dict[str, Any]] = mapped_column( JSON(), nullable=True, default_factory=dict @@ -175,7 +175,7 @@ class FavoritesTable(Base): @classmethod def insert_item(cls, item: dict[str, Any]): item["timestamp"] = int(datetime.datetime.now().timestamp()) - item["userid"] = current_user["id"] + item["userid"] = get_current_userid() with DbManager(commit=True) as conn: conn.execute(insert(cls).values(item)) @@ -199,7 +199,7 @@ class FavoritesTable(Base): result = cls.execute( select(table) .select_from(join(table, cls, field == cls.hash)) - .where(and_(cls.type == type, cls.userid == current_user["id"])) + .where(and_(cls.type == type, cls.userid == get_current_userid())) .offset(start) # INFO: If start is 0, fetch all so we can get the total count .limit(limit if start != 0 else None) @@ -238,3 +238,24 @@ class FavoritesTable(Base): ArtistTable, ArtistTable.artisthash, "artist", start, limit ) return artists_to_dataclasses(result), total + + +class ScrobbleTable(Base): + __tablename__ = "scrobble" + + id: Mapped[int] = mapped_column(primary_key=True) + trackhash: Mapped[str] = mapped_column(String(), index=True) + duration: Mapped[int] = mapped_column(Integer()) + timestamp: Mapped[int] = mapped_column(Integer()) + source: Mapped[str] = mapped_column(String()) + userid: Mapped[int] = mapped_column( + Integer(), ForeignKey("user.id", ondelete="cascade"), index=True + ) + extra: Mapped[dict[str, Any]] = mapped_column( + JSON(), nullable=True, default_factory=dict + ) + + @classmethod + def add(cls, item: dict[str, Any]): + item["userid"] = get_current_userid() + return cls.insert_one(item) diff --git a/app/lib/tagger.py b/app/lib/tagger.py index d8a679a6..2bb7f660 100644 --- a/app/lib/tagger.py +++ b/app/lib/tagger.py @@ -156,32 +156,32 @@ class IndexAlbums: "albumhash": track.albumhash, "base_title": None, "color": None, - "created_date": None, - "date": None, + "created_date": track.last_mod, + "date": track.date, "duration": track.duration, "genres": [*track.genres] if track.genres else [], "og_title": track.og_album, + "lastplayed": track.lastplayed, + "playcount": track.playcount, + "playduration": track.playduration, "title": track.album, "trackcount": 1, - "dates": [track.date], - "created_dates": [track.last_mod], } else: album = albums[track.albumhash] album["trackcount"] += 1 + album["playcount"] += track.playcount + album["playduration"] += track.playduration + album["lastplayed"] = max(album["lastplayed"], track.lastplayed) album["duration"] += track.duration - album["dates"].append(track.date) - album["created_dates"].append(track.last_mod) + album["date"] = min(album["date"], track.date) + album["created_date"] = min(album["created_date"], track.last_mod) if track.genres: album["genres"].extend(track.genres) for album in albums.values(): - album["date"] = min(album["dates"]) - album["created_date"] = min(album["created_dates"]) - genres = [] - for genre in album["genres"]: if genre not in genres: genres.append(genre) @@ -190,8 +190,6 @@ class IndexAlbums: album["base_title"], _ = get_base_album_title(album["og_title"]) del genres - del album["dates"] - del album["created_dates"] AlbumTable.remove_all() AlbumTable.insert_many(list(albums.values())) @@ -219,23 +217,28 @@ class IndexArtists: "albumcount": None, "albums": {track.albumhash}, "artisthash": thisartist["artisthash"], - "created_dates": [track.last_mod], - "dates": [track.date], - "date": None, + "created_date": track.last_mod, + "date": track.date, "duration": track.duration, "genres": track.genres if track.genres else [], "name": None, "names": {thisartist["name"]}, + "lastplayed": track.lastplayed, + "playcount": track.playcount, + "playduration": track.playduration, "trackcount": None, "tracks": {track.trackhash}, } else: artist = artists[thisartist["artisthash"]] artist["duration"] += track.duration + artist["playcount"] += track.playcount + artist["playduration"] += track.playduration artist["albums"].add(track.albumhash) artist["tracks"].add(track.trackhash) - artist["dates"].append(track.date) - artist["created_dates"].append(track.last_mod) + artist["date"] = min(artist["date"], track.date) + artist["lastplayed"] = max(artist["lastplayed"], track.lastplayed) + artist["created_date"] = min(artist["created_date"], track.last_mod) artist["names"].add(thisartist["name"]) if track.genres: @@ -244,8 +247,6 @@ class IndexArtists: for artist in artists.values(): artist["albumcount"] = len(artist["albums"]) artist["trackcount"] = len(artist["tracks"]) - artist["date"] = min(artist["dates"]) - artist["created_date"] = min(artist["created_dates"]) genres = [] @@ -260,8 +261,6 @@ class IndexArtists: del artist["names"] del artist["tracks"] del artist["albums"] - del artist["dates"] - del artist["created_dates"] # INFO: Delete local variables del genres diff --git a/app/models/album.py b/app/models/album.py index aa843e43..dfcb7137 100644 --- a/app/models/album.py +++ b/app/models/album.py @@ -29,6 +29,9 @@ class Album: title: str trackcount: int is_favorite: bool + lastplayed: int + playcount: int + playduration: int extra: dict type: str = "album" diff --git a/app/models/artist.py b/app/models/artist.py index 1ef9aa26..6f8d8002 100644 --- a/app/models/artist.py +++ b/app/models/artist.py @@ -48,6 +48,9 @@ class Artist: name: str trackcount: int is_favorite: bool + lastplayed: int + playcount: int + playduration: int extra: dict image: str = "" diff --git a/app/models/track.py b/app/models/track.py index 140df3cd..86dc2da1 100644 --- a/app/models/track.py +++ b/app/models/track.py @@ -29,6 +29,9 @@ class Track: track: int trackhash: str extra: dict + lastplayed: int + playcount: int + playduration: int is_favorite: bool = False _pos: int = 0 diff --git a/app/serializers/album.py b/app/serializers/album.py index ed3aebbf..2a559866 100644 --- a/app/serializers/album.py +++ b/app/serializers/album.py @@ -27,6 +27,7 @@ def serialize_for_card(album: Album): "og_title", "base_title", "genres", + "playcount" } return album_serializer(album, props_to_remove) diff --git a/app/serializers/artist.py b/app/serializers/artist.py index 82802586..633b0789 100644 --- a/app/serializers/artist.py +++ b/app/serializers/artist.py @@ -14,6 +14,7 @@ def serialize_for_card(artist: Artist): "trackcount", "duration", "albumcount", + "playcount", } for key in props_to_remove: diff --git a/app/serializers/track.py b/app/serializers/track.py index 062b5bd4..10426fbb 100644 --- a/app/serializers/track.py +++ b/app/serializers/track.py @@ -20,6 +20,7 @@ def serialize_track(track: Track, to_remove: set = {}, remove_disc=True) -> dict "artist_hashes", "created_date", "fav_userids", + "playcount", }.union(to_remove) if not remove_disc: diff --git a/app/utils/dates.py b/app/utils/dates.py index ebca97f1..82172946 100644 --- a/app/utils/dates.py +++ b/app/utils/dates.py @@ -63,4 +63,4 @@ def seconds_to_time_string(seconds): if minutes > 0: return f"{minutes} minute{'s' if minutes > 1 else ''}" - return f"{remaining_seconds} second{'s' if remaining_seconds > 1 else ''}" + return f"{remaining_seconds} second{'' if remaining_seconds == 1 else 's'}"