From 8f882659c264d1554b89a295392fdeb8f7767852 Mon Sep 17 00:00:00 2001 From: Cliff Hill Date: Tue, 23 Apr 2024 13:33:49 -0400 Subject: [PATCH] xing things. Signed-off-by: Cliff Hill --- backend/src/playlist/models.py | 156 +++++++++++++++++++++++++++----- backend/src/playlist/sql.py | 35 +++++++ backend/src/playlist/updater.py | 31 ++++++- 3 files changed, 198 insertions(+), 24 deletions(-) diff --git a/backend/src/playlist/models.py b/backend/src/playlist/models.py index cf1cd52..396bcdb 100644 --- a/backend/src/playlist/models.py +++ b/backend/src/playlist/models.py @@ -1,14 +1,18 @@ """All database models are defined here.""" +import enum from datetime import datetime +from sqlalchemy import Boolean from sqlalchemy import Column from sqlalchemy import DateTime +from sqlalchemy import Enum from sqlalchemy import Float from sqlalchemy import ForeignKey from sqlalchemy import Integer from sqlalchemy import String from sqlalchemy import Table +from sqlalchemy import UniqueConstraint from sqlalchemy import func from sqlalchemy import text from sqlalchemy.orm import Mapped @@ -17,21 +21,12 @@ from sqlalchemy.orm import registry from sqlalchemy.orm import relationship -# Registry and Base initialization using SQLAlchemy's new ORM 2.0 style +# Registry initialization using SQLAlchemy's new ORM 2.0 style mapper_registry = registry() -Base = mapper_registry.generate_base() - -# Association tables for many-to-many relationships -playlist_track_association = Table( - "playlist_track_association", - Base.metadata, - Column("playlist_id", ForeignKey("playlists.id"), primary_key=True), - Column("track_id", ForeignKey("tracks.id"), primary_key=True), -) holiday_track_association = Table( "holiday_track_association", - Base.metadata, + mapper_registry.metadata, Column("holiday_id", ForeignKey("holidays.id"), primary_key=True), Column("track_id", ForeignKey("tracks.id"), primary_key=True), ) @@ -73,8 +68,71 @@ class Playlist: name: Mapped[str] = mapped_column(String(255), nullable=False) owner_id: Mapped[int] = mapped_column(ForeignKey("users.id")) owner: Mapped[User] = relationship("User", back_populates="playlist") - tracks: Mapped[list["Track"]] = relationship( - "Track", secondary=playlist_track_association, back_populates="playlists" + playlist_tracks: Mapped[list["PlaylistTrack"]] = relationship( + "PlaylistTrack", back_populates="playlist" + ) + + +@mapper_registry.mapped_as_dataclass +class PlaylistTrack: + """Represents an entry in a playlist that associates a track with additional context. + + Each playlist track is tied to exactly one track and optionally linked to a category, + a holiday, or a podcast episode. The playlist track can also categorize the track + as part of a specific sublist based on how the track was added to the playlist. + + Attributes: + id (int): The primary key for the playlist track. + track_id (int): The foreign key linked to the track's primary key. + category_id (int, optional): The foreign key linked to the category's primary key, nullable. + holiday_id (int, optional): The foreign key linked to the holiday's primary key, nullable. + episode_id (int, optional): The foreign key linked to the podcast episode's primary + key, nullable. + favorite (bool, optional): Flag to indicate if the track is marked as a favorite, nullable. + sublist (SublistType, optional): The type of sublist the track belongs to, nullable. + + Relationships: + track (Track): The track associated with this playlist track. + category (Category, optional): The category associated with this playlist track, nullable. + holiday (Holiday, optional): The holiday associated with this playlist track, nullable. + episode (Episode, optional): The podcast episode associated with this playlist + track, nullable. + + Constraints: + At least one of `category_id`, `holiday_id`, or `episode_id` must be non-null. + Fields `favorite` and `sublist` must be null if `episode_id` is not null. + """ + + __tablename__ = "playlist_tracks" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id"), nullable=False) + track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id"), nullable=False) + category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"), nullable=True) + holiday_id: Mapped[int] = mapped_column(ForeignKey("holidays.id"), nullable=True) + episode_id: Mapped[int] = mapped_column(ForeignKey("episodes.id"), nullable=True) + favorite: Mapped[bool] = mapped_column(Boolean, nullable=True) + sublist: Mapped[str] = mapped_column(Enum(enum.Sublist), nullable=True) + + playlist: Mapped["Playlist"] = relationship("Playlist") + track: Mapped["Track"] = relationship("Track") + category: Mapped["Category"] = relationship("Category") + holiday: Mapped["Holiday"] = relationship("Holiday") + episode: Mapped["Episode"] = relationship("Episode") + + __table_args__ = ( + # Check constraint to ensure one of the category, holiday, or episode must be not null + { + "sqlite_with_rowid": False, + "postgresql_where": ( + ( + category_id.is_not(None) + | holiday_id.is_not(None) + | episode_id.is_not(None) + ) + & (episode_id.is_not(None) & favorite.is_(None) & sublist.is_(None)) + ), + } ) @@ -111,26 +169,60 @@ class Track: rating: Mapped[float] = mapped_column(Float) genre_id: Mapped[int] = mapped_column(ForeignKey("genres.id")) genre: Mapped["Genre"] = relationship("Genre", back_populates="tracks") - playlists: Mapped[list["Playlist"]] = relationship( - "Playlist", secondary=playlist_track_association, back_populates="tracks" + category_id: Mapped[int] = mapped_column(ForeignKey("categories.id")) + category: Mapped["Category"] = relationship("Category", back_populates="tracks") + playlist_tracks: Mapped[list["PlaylistTrack"]] = relationship( + "PlaylistTrack", back_populates="track" ) @mapper_registry.mapped_as_dataclass class Genre: - """Represents a musical genre. + """Represents a music genre. + + Each genre can be associated with multiple tracks and belongs to exactly one category. Attributes: - id (int): Unique identifier for the genre. - name (str): Name of the genre. - tracks (list[Track]): List of tracks belonging to this genre. + id (int): Primary key. + name (str): Name of the genre, which is unique within the same category. + category_id (int): Foreign key linking to the category the genre belongs to. + category (Category): The category this genre is part of. + tracks (list[Track]): List of tracks associated with this genre. """ __tablename__ = "genres" - id: Mapped[int] = mapped_column(primary_key=True) + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + category_id: Mapped[int] = mapped_column(ForeignKey("categories.id")) + category: Mapped["Category"] = relationship("Category", back_populates="genres") + tracks: Mapped[list["Track"]] = relationship("Track", back_populates="genre") + + __table_args__ = ( + UniqueConstraint("name", "category_id", name="uix_genre_name_category_id"), + ) + + +@mapper_registry.mapped_as_dataclass +class Category: + """Represents a category of music that can encompass multiple genres. + + Each category can include various genres, providing a way to classify tracks into different + musical styles or themes. + + Attributes: + id (int): Primary key. + name (str): Unique name of the category. + genres (list[Genre]): List of genres associated with this category. + """ + + __tablename__ = "categories" + __sa_dataclass_metadata_key__ = "sa" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) - tracks: Mapped[list[Track]] = relationship("Track", back_populates="genre") + genres: Mapped[list["Genre"]] = relationship("Genre", back_populates="category") + tracks: Mapped[list["Track"]] = relationship("Track", back_populates="category") @mapper_registry.mapped_as_dataclass @@ -218,8 +310,21 @@ class Statistics: max_playtime (Mapped[float]): The maximum playtime after accounting for podcasts, in seconds. max_tracks_per_day (Mapped[int]): The maximum number of tracks that can be played in a day. active_holiday_count (Mapped[int]): The number of active holidays affecting today's playlist. + category_count (Mapped[int]): The number of categories affecting today's playlist. max_holiday_tracks (Mapped[int]): The maximum number of tracks that can be allocated to holidays. max_regular_tracks (Mapped[int]): The maximum number of regular tracks per day. + max_regular_favorite_tracks (Mapped[int]): The maximum number of favorite tracks per day. + max_holiday_favorite_tracks (Mapped[int]): The maximum number of favorite tracks that can be + allocated to holidays. + max_regular_general_tracks (Mapped[int]): The maximum number of general tracks per day. + max_holiday_general_tracks (Mapped[int]): The maximum number of general tracks that can be + allocated to holidays. + max_regular_favorite_base (Mapped[int]): The maximum number of favorite tracks in one sublist. + max_regular_general_base (Mapped[int]): The maximum number of general tracks in one sublist. + max_holiday_favorite_base (Mapped[int]): The maximum number of favorite tracks in one sublist + for holidays. + max_holiday_general_base (Mapped[int]): The maximum number of general tracks in one sublist for + holidays. track_update_start_timestamp (Mapped[DateTime]): The timestamp when track updates begin. track_update_end_timestamp (Mapped[DateTime]): The timestamp when track updates end. """ @@ -232,8 +337,17 @@ class Statistics: max_playtime: Mapped[float] = mapped_column(Float) max_tracks_per_day: Mapped[int] = mapped_column(Integer) active_holiday_count: Mapped[int] = mapped_column(Integer) + category_count: Mapped[int] = mapped_column(Integer) max_holiday_tracks: Mapped[int] = mapped_column(Integer) max_regular_tracks: Mapped[int] = mapped_column(Integer) + max_regular_favorite_tracks: Mapped[int] = mapped_column(Integer) + max_regular_general_tracks: Mapped[int] = mapped_column(Integer) + max_holiday_favorite_tracks: Mapped[int] = mapped_column(Integer) + max_holiday_general_tracks: Mapped[int] = mapped_column(Integer) + max_regular_favorite_base: Mapped[int] = mapped_column(Integer) + max_regular_general_base: Mapped[int] = mapped_column(Integer) + max_holiday_favorite_base: Mapped[int] = mapped_column(Integer) + max_holiday_general_base: Mapped[int] = mapped_column(Integer) track_update_start_timestamp: Mapped[DateTime] = mapped_column( DateTime, server_default=text("CURRENT_TIMESTAMP") ) diff --git a/backend/src/playlist/sql.py b/backend/src/playlist/sql.py index 28b3245..30c2645 100644 --- a/backend/src/playlist/sql.py +++ b/backend/src/playlist/sql.py @@ -244,10 +244,19 @@ async def update_statistics( new_average_duration: float, new_podcast_length: float, new_max_playtime: float, + new_category_count: int, new_active_holidays: int, new_max_tracks: int, new_max_regular: int, new_max_holiday: int, + new_max_regular_favorite: int, + new_max_regular_general: int, + new_max_holiday_favorite: int, + new_max_holiday_general: int, + new_max_regular_favorite_base: int, + new_max_regular_general_base: int, + new_max_holiday_favorite_base: int, + new_max_holiday_general_base: int, ) -> None: """Update the statistics model. @@ -256,10 +265,19 @@ async def update_statistics( of all track durations as a float. new_podcast_length (float): The new total podcast length of all current episodes as a float. new_max_playtime (float): The new max playtime as a float. + new_category_count (int): The new count of categories as an int. new_active_holidays (int): The new active holidays as an int. new_max_tracks (int): The new max tracks per day as an int. new_max_regular (int): The new max regular tracks as an int. new_max_holiday (int): The new max holiday tracks as an int. + new_max_regular_favorite (int): The new max regular favorite tracks as an int. + new_max_regular_general (int): The new max regular general tracks as an int. + new_max_holiday_favorite (int): The new max holiday favorite tracks as an int. + new_max_holiday_general (int): The new max holiday general tracks as an int. + new_max_regular_favorite_base (int): The new max regular favorite base unit as an int. + new_max_regular_general_base (int): The new max regular general base unit as an int. + new_max_holiday_favorite_base (int): The new max holiday favorite base unit as an int. + new_max_holiday_general_base (int): The new max holiday general base unit as an int. """ async with async_session_maker() as session: try: @@ -272,10 +290,19 @@ async def update_statistics( stats.average_track_length = new_average_duration stats.total_podcast_length = new_podcast_length stats.max_playtime = new_max_playtime + stats.category_count = new_category_count stats.active_holiday_count = new_active_holidays stats.max_tracks_per_day = new_max_tracks stats.max_regular_tracks = new_max_regular stats.max_holiday_tracks = new_max_holiday + stats.max_regular_favorite_tracks = new_max_regular_favorite + stats.max_regular_general_tracks = new_max_regular_general + stats.max_holiday_favorite_tracks = new_max_holiday_favorite + stats.max_holiday_general_tracks = new_max_holiday_general + stats.max_regular_favorite_base = new_max_regular_favorite_base + stats.max_regular_general_base = new_max_regular_general_base + stats.max_holiday_favorite_base = new_max_holiday_favorite_base + stats.max_holiday_general_base = new_max_holiday_general_base await session.commit() else: # If no statistics entry exists, create one @@ -287,6 +314,14 @@ async def update_statistics( max_tracks_per_day=new_max_tracks, max_regular_tracks=new_max_regular, max_holiday_tracks=new_max_holiday, + max_regular_favorite_tracks=new_max_regular_favorite, + max_regular_general_tracks=new_max_regular_general, + max_holiday_favorite_tracks=new_max_holiday_favorite, + max_holiday_general_tracks=new_max_holiday_general, + max_regular_favorite_base=new_max_regular_favorite_base, + max_regular_general_base=new_max_regular_general_base, + max_holiday_favorite_base=new_max_holiday_favorite_base, + max_holiday_general_base=new_max_holiday_general_base, ) session.add(new_stats) await session.commit() diff --git a/backend/src/playlist/updater.py b/backend/src/playlist/updater.py index 832b71a..b6f92b9 100644 --- a/backend/src/playlist/updater.py +++ b/backend/src/playlist/updater.py @@ -26,6 +26,7 @@ async def process_tracks(server_url: str, token: str) -> None: async for track_data in plex.fetch_tracks_from_plex(server_url, token): await sql.insert_or_update_track(track_data, found_genres) + # TODO: Exclude podcast episodes from the list of durations. if track_data["duration"] > 0: # Ensure only positive durations are added durations.append(track_data["duration"]) @@ -56,25 +57,49 @@ async def calculate_statistics(durations: list[float]) -> None: max_playtime = env.MAX_PLAYTIME - total_podcast_length max_tracks_per_day = int(max_playtime / average_duration) + # TODO: Calculate the number of categories in the database.abs + category_count = 1 + # TODO: Calculate the amount of the playlist that will be for active holidays. active_holidays = 0 ratio_denom = active_holidays * 2 + 1 + max_regular_percentage = (active_holidays + 1) / ratio_denom / category_count max_holiday_percentage = 1 / ratio_denom if active_holidays else 0 - max_regular_percentage = (active_holidays + 1) / ratio_denom - max_holiday_tracks = int(max_tracks_per_day * max_holiday_percentage) max_regular_tracks = int(max_tracks_per_day * max_regular_percentage) + max_holiday_tracks = int(max_tracks_per_day * max_holiday_percentage) + + max_regular_favorite_tracks = int(max_regular_tracks / 3) + max_regular_general_tracks = int(2 * max_regular_tracks / 3) + + max_holiday_favorite_tracks = int(max_holiday_tracks / 3) + max_holiday_general_tracks = int(2 * max_holiday_tracks / 3) + + max_regular_favorite_base = int(max_regular_favorite_tracks / 6) + max_regular_general_base = int(max_regular_general_tracks / 6) + + max_holiday_favorite_base = int(max_holiday_favorite_tracks / 6) + max_holiday_general_base = int(max_holiday_general_tracks / 6) await sql.update_statistics( average_duration, total_podcast_length, max_playtime, + category_count, active_holidays, max_tracks_per_day, max_regular_tracks, max_holiday_tracks, + max_regular_favorite_tracks, + max_regular_general_tracks, + max_regular_favorite_base, + max_regular_general_base, + max_holiday_favorite_tracks, + max_holiday_general_tracks, + max_holiday_favorite_base, + max_holiday_general_base, ) else: - logger.warning("No valid track durations retrieved.") + logger.warning("No valid track durations retrieved, statistics not updated.")