Compare commits
2 Commits
playlist-1
...
chatgpt-as
| Author | SHA1 | Date | |
|---|---|---|---|
|
23a42c065d
|
|||
|
6be20af94f
|
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"_checkout": "2022.6.3",
|
||||
"_output_dir": "/config/workspace/DarkHelm.org/plex-playlist",
|
||||
"_output_dir": "/config/workspace/DarkHelm.org",
|
||||
"_repo_dir": "/config/.cookiecutters/cookiecutter-hypermodern-python",
|
||||
"_template": "gh:cjolowicz/cookiecutter-hypermodern-python",
|
||||
"author": "Cliff Hill",
|
||||
"copyright_year": "2023",
|
||||
"copyright_year": "2024",
|
||||
"development_status": "Development Status :: 1 - Planning",
|
||||
"email": "xlorep@darkhelm.org",
|
||||
"friendly_name": "Plex Playlist",
|
||||
"github_user": "xlorepdarkhelm",
|
||||
"license": "MIT",
|
||||
"package_name": "playlist",
|
||||
"package_name": "xdh.playlist",
|
||||
"project_name": "plex-playlist",
|
||||
"version": "0.0.0"
|
||||
}
|
||||
|
||||
5
.flake8
5
.flake8
@@ -1,9 +1,10 @@
|
||||
[flake8]
|
||||
select = B,B9,C,D,DAR,E,F,N,RST,S,W
|
||||
ignore = E203,E501,RST201,RST203,RST301,W503
|
||||
max-line-length = 100
|
||||
max-line-length = 80
|
||||
max-complexity = 10
|
||||
docstring-convention = google
|
||||
per-file-ignores = backend/tests/*:S101
|
||||
per-file-ignores = tests/*:S101
|
||||
exclude = noxfile.py
|
||||
rst-roles = class,const,func,meth,mod,ref
|
||||
rst-directives = deprecated
|
||||
|
||||
20
.github/docs/adrs/000-template.md
vendored
20
.github/docs/adrs/000-template.md
vendored
@@ -1,20 +0,0 @@
|
||||
# ADR N: brief decision title
|
||||
|
||||
Describe here the forces that influence the design decision, including technological, cost-related, and project local.
|
||||
|
||||
## Decision
|
||||
|
||||
Describe here our response to these forces, that is, the design decision that was made. State the decision in full sentences, with active voice ("We will...").
|
||||
|
||||
## Rationale
|
||||
|
||||
Describe here the rationale for the design decision. Also indicate the rationale for significant _rejected_ alternatives. This section may also indicate assumptions, constraints, requirements, and results of evaluations and experiments.
|
||||
|
||||
## Status
|
||||
|
||||
[Proposed | Accepted | Deprecated | Superseded]
|
||||
If deprecated, indicate why. If superseded, include a link to the new ADR.
|
||||
|
||||
## Consequences
|
||||
|
||||
Describe here the resulting context, after applying the decision. All consequences should be listed, not just the "positive" ones.
|
||||
10
.github/docs/diagrams/updater.mermaid
vendored
10
.github/docs/diagrams/updater.mermaid
vendored
@@ -1,10 +0,0 @@
|
||||
flowchart TD
|
||||
A[Start] --> B[[Update start timestamp & get last updated]]
|
||||
B --> C([Get track from plex])
|
||||
C --> |more tracks| D[[Remove recently played tracks from playlist]]
|
||||
D --> E[[Insert/update track into database]]
|
||||
E --> C
|
||||
C --> |no more tracks| F[[Clean up genres]]
|
||||
F --> G[[Calculate statistics]]
|
||||
G --> H[[Update end timestamp]]
|
||||
H --> I[End]
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright © 2023 Cliff Hill
|
||||
Copyright © 2024 Cliff Hill
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
```{include} ../../CODE_OF_CONDUCT.md
|
||||
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
"""Plex Playlist."""
|
||||
@@ -1,13 +0,0 @@
|
||||
"""Command-line interface."""
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.version_option()
|
||||
def main() -> None:
|
||||
"""Plex Playlist."""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(prog_name="plex-playlist") # pragma: no cover
|
||||
@@ -1,53 +0,0 @@
|
||||
"""All enums for the project are kept here."""
|
||||
|
||||
from enum import StrEnum
|
||||
from enum import auto
|
||||
from typing import Generator
|
||||
|
||||
|
||||
class Sublist(StrEnum):
|
||||
"""StrEnum to categorize tracks within a playlist based on various criteria.
|
||||
|
||||
Values are auto-generated.
|
||||
|
||||
Attributes:
|
||||
LEAST_RECENTLY_ADDED: Represents tracks that were added to the library the earliest.
|
||||
LEAST_RECENTLY_PLAYED: Represents tracks that were last played the longest time ago.
|
||||
LEAST_OFTEN_PLAYED: Represents tracks that have been played the least often.
|
||||
RANDOM: Represents tracks that are selected randomly.
|
||||
"""
|
||||
|
||||
LEAST_RECENTLY_ADDED = auto()
|
||||
LEAST_RECENTLY_PLAYED = auto()
|
||||
LEAST_OFTEN_PLAYED = auto()
|
||||
RANDOM = auto()
|
||||
|
||||
def standard(self) -> Generator["Sublist", None, None]:
|
||||
"""Generate the standard sublist types one at a time."""
|
||||
yield self.LEAST_RECENTLY_ADDED
|
||||
yield self.LEAST_RECENTLY_PLAYED
|
||||
yield self.LEAST_OFTEN_PLAYED
|
||||
|
||||
|
||||
class TimedEvent(StrEnum):
|
||||
"""Enumerates the start/end for timed processes like updater.
|
||||
|
||||
Attributes:
|
||||
START: Represents the start of the event.
|
||||
END: Represents the end of the event.
|
||||
"""
|
||||
|
||||
START = auto()
|
||||
END = auto()
|
||||
|
||||
|
||||
class Event(StrEnum):
|
||||
"""Enumerates the major backend events that are run.
|
||||
|
||||
Attributes:
|
||||
UPDATER: Represents the track updater process.
|
||||
GENERATOR: Represents the playlist generator process.
|
||||
"""
|
||||
|
||||
UPDATER: auto()
|
||||
GENERATOR: auto()
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Environment variables used throughout the application."""
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
load_dotenv() # Take environment variables from .env.
|
||||
|
||||
PLEX_SERVER_URL = os.getenv("PLEX_SERVER_URL", "http://default-plex-server-url")
|
||||
PLEX_TOKEN = os.getenv("PLEX_TOKEN", "default-plex-token")
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL", "postgres://user:password@localhost/database"
|
||||
) # Default fallback
|
||||
|
||||
MAX_TRACKS_QUERIED = int(
|
||||
os.getenv("MAX_TRACKS_QUERIED", 1000)
|
||||
) # Default to 1000 if not set
|
||||
|
||||
MAX_PLAYTIME = (
|
||||
int(os.getenv("MAX_PLAYTIME", 16)) * 60 * 60
|
||||
) # In seconds, but env var is in hours.
|
||||
|
||||
PODCAST_DAYS = os.getenv("PODCAST_DAYS")
|
||||
|
||||
SSH_USER = os.getenv("SSH_USER")
|
||||
SSH_SERVER = os.getenv("SSH_SERVER")
|
||||
SSH_PATH = os.getenv("SSH_PATH")
|
||||
SSH_KEY_PATH = os.getenv("SSH_KEY_PATH")
|
||||
@@ -1,405 +0,0 @@
|
||||
"""All database models are defined here."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from playlist import enums
|
||||
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.orm import Mapped
|
||||
from sqlalchemy.orm import mapped_column
|
||||
from sqlalchemy.orm import registry
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
|
||||
# Registry initialization using SQLAlchemy's new ORM 2.0 style
|
||||
mapper_registry = registry()
|
||||
|
||||
holiday_track_association = Table(
|
||||
"holiday_track_association",
|
||||
mapper_registry.metadata,
|
||||
Column("holiday_id", ForeignKey("holidays.id"), primary_key=True),
|
||||
Column("track_id", ForeignKey("tracks.id"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
@mapper_registry.mapped_as_dataclass
|
||||
class User:
|
||||
"""Represents a system user, capable of owning a single playlist.
|
||||
|
||||
Attributes:
|
||||
id (int): Unique identifier for the user.
|
||||
username (str): Unique username for the user.
|
||||
|
||||
Relationships:
|
||||
playlist (Playlist): The playlist owned by the user, one-to-one relationship.
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
playlist: Mapped["Playlist"] = relationship(
|
||||
"Playlist", back_populates="owner", uselist=False
|
||||
)
|
||||
|
||||
|
||||
@mapper_registry.mapped_as_dataclass
|
||||
class Playlist:
|
||||
"""Represents a playlist containing multiple unique tracks, owned by a user.
|
||||
|
||||
Attributes:
|
||||
id (int): Unique identifier for the playlist.
|
||||
name (str): Name of the playlist.
|
||||
owner (User): The user who owns the playlist, one-to-one relationship.
|
||||
|
||||
Relationships:
|
||||
tracks (list[Track]): List of tracks in the playlist, many-to-many relationship.
|
||||
"""
|
||||
|
||||
__tablename__ = "playlists"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
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")
|
||||
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(enums.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))
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@mapper_registry.mapped_as_dataclass
|
||||
class Track:
|
||||
"""Represents a musical track with comprehensive metadata.
|
||||
|
||||
Attributes:
|
||||
id (int): Unique identifier for the track.
|
||||
title (str): Title of the track.
|
||||
artist (str): Artist of the track.
|
||||
album (str): Album name.
|
||||
cover_image (str): URL or path to the album cover image.
|
||||
duration (int): Duration of the track in seconds.
|
||||
times_played (int): Number of times the track has been played.
|
||||
date_last_played (DateTime): Date the track was last played.
|
||||
date_added (DateTime): Date the track was added to the database.
|
||||
rating (float): Rating of the track on a 1-5 scale.
|
||||
genre_id (int): ForeignKey linking to the genre.
|
||||
category_id (int): Foreign key linking to the category.
|
||||
server_id (str): Identifier for the server where the track is hosted, indexed.
|
||||
|
||||
Relationships:
|
||||
genre (Genre): The genre for this track.
|
||||
playlist_tracks (list[PlaylistTrack]): List of playlist tracks for this track.
|
||||
category (Category): The category for this track.
|
||||
holidays (list[Holiday]): The list of holidays this track is assigned to.
|
||||
podcast_episode (Episode | None): The optional podcast episode this track is linked to.
|
||||
"""
|
||||
|
||||
__tablename__ = "tracks"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
artist: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
album: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
cover_image: Mapped[str] = mapped_column(String(255))
|
||||
duration: Mapped[int] = mapped_column(Integer)
|
||||
times_played: Mapped[int] = mapped_column(Integer, default=0)
|
||||
date_last_played: Mapped[DateTime] = mapped_column(DateTime)
|
||||
date_added: Mapped[DateTime] = mapped_column(DateTime, default=func.now())
|
||||
rating: Mapped[float] = mapped_column(Float)
|
||||
genre_id: Mapped[int] = mapped_column(ForeignKey("genres.id"))
|
||||
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"))
|
||||
server_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, index=True, unique=True
|
||||
)
|
||||
|
||||
genre: Mapped["Genre"] = relationship("Genre", back_populates="tracks")
|
||||
category: Mapped["Category"] = relationship("Category", back_populates="tracks")
|
||||
holidays: Mapped[list["Holiday"]] = relationship(
|
||||
"Holiday", secondary=holiday_track_association, back_populates="tracks"
|
||||
)
|
||||
playlist_tracks: Mapped[list["PlaylistTrack"]] = relationship(
|
||||
"PlaylistTrack", back_populates="track"
|
||||
)
|
||||
|
||||
|
||||
@mapper_registry.mapped_as_dataclass
|
||||
class Genre:
|
||||
"""Represents a music genre.
|
||||
|
||||
Each genre can be associated with multiple tracks and belongs to exactly one category.
|
||||
|
||||
Attributes:
|
||||
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.
|
||||
|
||||
Relationships:
|
||||
tracks (list[Track]): List of tracks associated with this genre.
|
||||
"""
|
||||
|
||||
__tablename__ = "genres"
|
||||
|
||||
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.
|
||||
|
||||
Relationships:
|
||||
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)
|
||||
genres: Mapped[list["Genre"]] = relationship("Genre", back_populates="category")
|
||||
tracks: Mapped[list["Track"]] = relationship("Track", back_populates="category")
|
||||
|
||||
|
||||
@mapper_registry.mapped_as_dataclass
|
||||
class Holiday:
|
||||
"""Represents a holiday, which can be linked to specific tracks for special occasions.
|
||||
|
||||
Attributes:
|
||||
id (Mapped[int]): The primary key.
|
||||
name (Mapped[str]): The name of the holiday.
|
||||
start_date (Mapped[DateTime]): The starting date of the holiday.
|
||||
end_date (Mapped[DateTime]): The ending date of the holiday.
|
||||
is_active (property): A calculated property that checks if the holiday is currently active.
|
||||
|
||||
Relationships:
|
||||
tracks (Mapped[list[Track]]): List of tracks associated with this holiday.
|
||||
"""
|
||||
|
||||
__tablename__ = "holidays"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
start_date: Mapped[DateTime] = mapped_column(DateTime, nullable=False)
|
||||
end_date: Mapped[DateTime] = mapped_column(DateTime, nullable=False)
|
||||
tracks: Mapped[list[Track]] = relationship(
|
||||
"Track", secondary=holiday_track_association, back_populates="holidays"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""Calculate whether the holiday is currently active based on the system's current date.
|
||||
|
||||
This does not use the database but computes based on the current datetime.
|
||||
"""
|
||||
return self.start_date <= datetime.now() <= self.end_date
|
||||
|
||||
|
||||
@mapper_registry.mapped_as_dataclass
|
||||
class Podcast:
|
||||
"""Represents a podcast, which consists of multiple episodes.
|
||||
|
||||
Attributes:
|
||||
id (Mapped[int]): The primary key.
|
||||
name (Mapped[str]): The name of the podcast.
|
||||
feed_url (Mapped[str]): The URL of the podcast's RSS feed.
|
||||
|
||||
Relationships:
|
||||
episodes (Mapped[list[Episode]]): List of episodes associated with this podcast.
|
||||
"""
|
||||
|
||||
__tablename__ = "podcasts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
feed_url: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
episodes: Mapped[list["Episode"]] = relationship(
|
||||
"Episode", back_populates="podcast"
|
||||
)
|
||||
|
||||
|
||||
@mapper_registry.mapped_as_dataclass
|
||||
class Episode:
|
||||
"""Represents an episode of a podcast.
|
||||
|
||||
Attributes:
|
||||
id (Mapped[int]): The primary key.
|
||||
name (Mapped[str]): The title of the episode.
|
||||
filename (Mapped[str]): The filename of the episode's media file.
|
||||
podcast_id (Mapped[int]): Foreign key linking to the associated podcast.
|
||||
server_id (Mapped[str | None]): Identifier for the server where the episode is hosted, indexed.
|
||||
date_published (Mapped[datetime]): The publication date of the episode in the podcast feed.
|
||||
track_id (Mapped[int | None]): Optional foreign key linking to the associated track.
|
||||
|
||||
Relationships:
|
||||
podcast (Mapped[Podcast]): The podcast this episode belongs to.
|
||||
track (Mapped[Track | None]): Optional relation to the track from the plex server.
|
||||
"""
|
||||
|
||||
__tablename__ = "episodes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
podcast_id: Mapped[int] = mapped_column(ForeignKey("podcasts.id"))
|
||||
server_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True, unique=True
|
||||
)
|
||||
date_published: Mapped[datetime] = mapped_column(datetime, nullable=False)
|
||||
track_id: Mapped[int | None] = mapped_column(ForeignKey("tracks.id"), nullable=True)
|
||||
|
||||
podcast: Mapped[Podcast] = relationship("Podcast", back_populates="episodes")
|
||||
track: Mapped[Track | None] = relationship(
|
||||
"Track", back_populates="podcast_episode"
|
||||
)
|
||||
|
||||
|
||||
@mapper_registry.mapped_as_dataclass
|
||||
class Statistics:
|
||||
"""Stores various computed statistics and operational metrics for the application.
|
||||
|
||||
Attributes:
|
||||
id (Mapped[int]): The primary key.
|
||||
average_track_length (Mapped[float]): The average length of tracks in seconds.
|
||||
total_podcast_length (Mapped[float]): The total length of all podcast episodes in seconds.
|
||||
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.
|
||||
start_update (Mapped[DateTime]): The timestamp when track updates begin.
|
||||
end_update (Mapped[DateTime]): The timestamp when track updates end.
|
||||
start_playlist_gen (Mapped[DateTime]): The timestamp when the playlist generation begins.
|
||||
end_playlist_gen (Mapped[DateTime]): The timestamp when the playlist generation ends.
|
||||
"""
|
||||
|
||||
__tablename__ = "statistics"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
average_track_length: Mapped[float] = mapped_column(Float)
|
||||
total_podcast_length: Mapped[float] = mapped_column(Float)
|
||||
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)
|
||||
start_update: Mapped[DateTime | None] = mapped_column(
|
||||
DateTime, nullable=True, server_default=None
|
||||
)
|
||||
end_update: Mapped[DateTime | None] = mapped_column(
|
||||
DateTime, nullable=True, server_default=None
|
||||
)
|
||||
start_playlist_gen: Mapped[DateTime | None] = mapped_column(
|
||||
DateTime, nullable=True, server_default=None
|
||||
)
|
||||
end_playlist_gen: Mapped[DateTime | None] = mapped_column(
|
||||
DateTime, nullable=True, server_default=None
|
||||
)
|
||||
@@ -1,204 +0,0 @@
|
||||
"""Contains the functions that are used to generate the playlist."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from aiologger import Logger
|
||||
from playlist import enums
|
||||
from playlist import sql
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
logger = Logger.with_default_handlers()
|
||||
|
||||
|
||||
async def generate_sublists(
|
||||
session: AsyncSession,
|
||||
playlist_entries: set[int],
|
||||
sublist_counts: dict[enums.Sublist, int],
|
||||
is_category: bool,
|
||||
type_id: int,
|
||||
is_favorite: bool,
|
||||
base_count: int,
|
||||
max_count: int,
|
||||
) -> None:
|
||||
"""Generate sublists for a given category and type (favorites or general).
|
||||
|
||||
Args:
|
||||
session (AsyncSession): The database session.
|
||||
playlist_entries (set[int]): The set of track IDs in the playlist.
|
||||
sublist_counts (dict[enums.Sublist, int]): The counts of existing tracks in the
|
||||
given sublist in the current playlist.
|
||||
is_category (bool): True if it is a category, false if it is a holiday.
|
||||
type_id (int): The category or holiday ID for which to generate playlists.
|
||||
is_favorite (bool): True for favorites only, False for general tracks.
|
||||
base_count (int): The base number of tracks to be included from regular sublists.
|
||||
max_count (int): The max number of tracks to be included from regular sublists.
|
||||
"""
|
||||
for sublist in enums.Sublist.standard():
|
||||
existing_count = sublist_counts.get(sublist, 0)
|
||||
allowed_count = base_count - existing_count
|
||||
tracks = await sql.fetch_tracks_for_sublist(
|
||||
session,
|
||||
is_category,
|
||||
type_id,
|
||||
is_favorite,
|
||||
sublist,
|
||||
allowed_count,
|
||||
playlist_entries,
|
||||
)
|
||||
for track in tracks:
|
||||
await sql.insert_into_playlist(
|
||||
session, is_category, type_id, track.id, sublist, is_favorite
|
||||
)
|
||||
playlist_entries.add(track.id)
|
||||
|
||||
# Calculate how many tracks to randomly add
|
||||
remaining_count = max_count - len(playlist_entries)
|
||||
if remaining_count > 0:
|
||||
await generate_random_sublist(
|
||||
session,
|
||||
playlist_entries,
|
||||
sublist_counts.get(enums.Sublist.RANDOM, 0),
|
||||
type_id,
|
||||
is_favorite,
|
||||
remaining_count,
|
||||
)
|
||||
|
||||
|
||||
async def generate_random_sublist(
|
||||
session: AsyncSession,
|
||||
playlist_entries: set[int],
|
||||
existing_count: int,
|
||||
is_category: bool,
|
||||
type_id: int,
|
||||
is_favorite: bool,
|
||||
count: int,
|
||||
) -> None:
|
||||
"""Generate random sublist for the remaining slots in the playlist.
|
||||
|
||||
Args:
|
||||
session (AsyncSession): The database session.
|
||||
playlist_entries (set[int]): The set of track IDs in the playlist.
|
||||
existing_count (int): The number of random tracks already existing in the playlist.
|
||||
is_category (bool): True if it is a category, false if it is a holiday.
|
||||
type_id (int): The category or holiday ID for which to generate the random sublist.
|
||||
is_favorite (bool): True for favorites only, False for general tracks.
|
||||
count (int): Number of random tracks to add.
|
||||
existing_entries (set): Set of track IDs already added to avoid duplicates.
|
||||
"""
|
||||
allowed_count = count - existing_count
|
||||
random_tracks = await sql.fetch_tracks_for_sublist(
|
||||
session,
|
||||
type_id,
|
||||
is_favorite,
|
||||
enums.Sublist.RANDOM,
|
||||
count,
|
||||
allowed_count,
|
||||
playlist_entries,
|
||||
)
|
||||
for track in random_tracks:
|
||||
await sql.insert_into_playlist(
|
||||
session, type_id, track.id, enums.Sublist.RANDOM, is_favorite
|
||||
)
|
||||
playlist_entries.add(track.id)
|
||||
if len(playlist_entries) >= count:
|
||||
break
|
||||
|
||||
|
||||
async def process_playlists(
|
||||
playlist_entries: set[int],
|
||||
playlist_counts: dict[tuple[bool, int, bool, enums.Sublist], int],
|
||||
is_category: bool,
|
||||
type_id: int,
|
||||
favorite_base: int,
|
||||
general_base: int,
|
||||
favorite_max: int,
|
||||
general_max: int,
|
||||
) -> None:
|
||||
"""Process playlists for both favorites and general tracks for a given category or holiday.
|
||||
|
||||
Args:
|
||||
playlist_entries (set[int]): The IDs of the tracks currently in the playlist.
|
||||
playlist_counts (dict[tuple[bool, int, bool, enums.Sublist], int]): Counts of the
|
||||
different sublist types within the playlist.
|
||||
is_category (bool): True if it is a category, false if it is a holiday.
|
||||
type_id (int): The category or holiday ID for which to generate the random sublist.
|
||||
favorite_base (int): The maximum number of tracks for a favorite standard sublist.
|
||||
general_base (int): The maximum number of tracks for a general standard sublist.
|
||||
favorite_max (int): The maximum number of tracks for favorite tracks.
|
||||
general_max (int): The maximum number of tracks for general tracks.
|
||||
"""
|
||||
async with sql.async_session_maker() as session:
|
||||
favorite_counts = {
|
||||
key[3]: count
|
||||
for key, count in playlist_counts.items()
|
||||
if key[0] is is_category and key[1] == type_id and key[2] is True
|
||||
}
|
||||
general_counts = {
|
||||
key[3]: count
|
||||
for key, count in playlist_counts.items()
|
||||
if key[0] is is_category and key[1] == type_id and key[2] is False
|
||||
}
|
||||
await generate_sublists(
|
||||
session,
|
||||
playlist_entries,
|
||||
favorite_counts,
|
||||
is_category,
|
||||
type_id,
|
||||
True,
|
||||
favorite_base,
|
||||
favorite_max,
|
||||
)
|
||||
await generate_sublists(
|
||||
session,
|
||||
playlist_entries,
|
||||
general_counts,
|
||||
is_category,
|
||||
type_id,
|
||||
False,
|
||||
general_base,
|
||||
general_max,
|
||||
)
|
||||
|
||||
|
||||
async def generate_playlist_for_all() -> None:
|
||||
"""Generate playlists for all categories & holidays."""
|
||||
(
|
||||
existing_track_ids,
|
||||
existing_playlist_counts,
|
||||
) = await sql.get_existing_playlist_track_info()
|
||||
category_ids = tuple(await sql.gen_all_category_ids())
|
||||
holiday_ids = tuple(sql.gen_active_holiday_ids())
|
||||
stats = await sql.get_statistics()
|
||||
category_favorite_base = stats.max_regular_favorite_base
|
||||
category_general_base = stats.max_regular_general_base
|
||||
category_favorite_max = stats.max_regular_favorite_tracks
|
||||
category_general_max = stats.max_regular_general_tracks
|
||||
holiday_favorite_base = stats.max_holiday_favorite_base
|
||||
holiday_general_base = stats.max_holiday_general_base
|
||||
holiday_favorite_max = stats.max_holiday_favorite_tracks
|
||||
holiday_general_max = stats.max_holiday_general_tracks
|
||||
tasks = [
|
||||
process_playlists(
|
||||
playlist_entries=existing_track_ids,
|
||||
playlist_counts=existing_playlist_counts,
|
||||
is_category=True,
|
||||
type_id=category_id,
|
||||
favorite_base=category_favorite_base,
|
||||
general_base=category_general_base,
|
||||
favorite_max=category_favorite_max,
|
||||
general_max=category_general_max,
|
||||
)
|
||||
for category_id in category_ids
|
||||
] + [
|
||||
process_playlists(
|
||||
is_category=False,
|
||||
type_id=holiday_id,
|
||||
favorite_base=holiday_favorite_base,
|
||||
general_base=holiday_general_base,
|
||||
favorite_max=holiday_favorite_max,
|
||||
general_max=holiday_general_max,
|
||||
)
|
||||
for holiday_id in holiday_ids
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Contains all plex code here."""
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import env # Make sure to import your environment configuration
|
||||
from aiologger import Logger
|
||||
from plexapi.server import PlexServer
|
||||
|
||||
|
||||
logger = Logger.with_default_handlers(name="plex_logger")
|
||||
executor = ThreadPoolExecutor()
|
||||
|
||||
|
||||
async def fetch_tracks_from_plex(
|
||||
server_url: str = env.PLEX_SERVER_URL, token: str = env.PLEX_TOKEN
|
||||
) -> AsyncGenerator[dict, None]:
|
||||
"""Asynchronously fetches tracks from a Plex server.
|
||||
|
||||
Uses plexapi, employing a ThreadPoolExecutor to handle blocking calls from plexapi.
|
||||
|
||||
Args:
|
||||
server_url (str): URL of the Plex server, defaults to environment variable.
|
||||
token (str): Authentication token for accessing the Plex server, defaults to environment variable.
|
||||
|
||||
Yields:
|
||||
AsyncGenerator[Dict, None]: An asynchronous generator yielding track data dictionaries.
|
||||
"""
|
||||
try:
|
||||
# Initialize Plex server connection using plexapi in a non-blocking way
|
||||
plex = await asyncio.get_event_loop().run_in_executor(
|
||||
executor, lambda: PlexServer(server_url, token)
|
||||
)
|
||||
|
||||
# Fetch all music sections asynchronously
|
||||
sections = await asyncio.get_event_loop().run_in_executor(
|
||||
executor, plex.library.sections
|
||||
)
|
||||
music_section = next(
|
||||
(section for section in sections if section.type == "artist"), None
|
||||
)
|
||||
|
||||
if not music_section:
|
||||
await logger.error("No music section found on the Plex server.")
|
||||
return
|
||||
|
||||
# Asynchronously fetch tracks by paging through the music section
|
||||
total_tracks = await asyncio.get_event_loop().run_in_executor(
|
||||
executor, music_section.totalSize
|
||||
)
|
||||
size = env.MAX_TRACKS_QUERIED # Maximum number of tracks to query at once
|
||||
|
||||
for offset in range(0, total_tracks, size):
|
||||
tracks = await asyncio.get_event_loop().run_in_executor(
|
||||
executor,
|
||||
lambda offset=offset, size=size: music_section.searchTracks(
|
||||
container_start=offset, container_size=size
|
||||
),
|
||||
)
|
||||
for track in tracks:
|
||||
track_data = {
|
||||
"title": track.title,
|
||||
"artist": (
|
||||
track.grandparentTitle
|
||||
if track.grandparentTitle
|
||||
else "Unknown Artist"
|
||||
),
|
||||
"album": (
|
||||
track.parentTitle if track.parentTitle else "Unknown Album"
|
||||
),
|
||||
"cover_image": track.thumbUrl if track.thumbUrl else "",
|
||||
"duration": track.duration,
|
||||
"times_played": getattr(track, "userRatingCount", 0),
|
||||
"date_last_played": (
|
||||
track.lastViewedAt.isoformat() if track.lastViewedAt else None
|
||||
),
|
||||
"date_added": track.addedAt.isoformat(),
|
||||
"rating": getattr(track, "userRating", None),
|
||||
"genre": (
|
||||
", ".join(genre.tag for genre in track.genres)
|
||||
if track.genres
|
||||
else "Unknown"
|
||||
),
|
||||
"server_id": track.ratingKey, # Using Plex track ratingKey as the server_id
|
||||
}
|
||||
yield track_data
|
||||
except Exception as e:
|
||||
await logger.error(f"Failed to fetch tracks from Plex: {e}")
|
||||
raise
|
||||
@@ -1,190 +0,0 @@
|
||||
"""Podcast processing/updating contained here."""
|
||||
|
||||
import asyncio
|
||||
import tempfile
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
import asyncpathlib
|
||||
import asyncssh
|
||||
import eyed3
|
||||
import feedparser
|
||||
from aiologger import Logger
|
||||
from env import PODCAST_DAYS
|
||||
from env import SSH_KEY_PATH
|
||||
from env import SSH_PATH
|
||||
from env import SSH_SERVER
|
||||
from env import SSH_USER
|
||||
from models import Episode
|
||||
from models import Podcast
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
|
||||
logger = Logger.with_default_handlers()
|
||||
|
||||
|
||||
async def fetch_and_process_podcasts(session: AsyncSession):
|
||||
"""Asynchronously fetches all podcasts from the database and processes each one.
|
||||
|
||||
Args:
|
||||
session (AsyncSession): The database session to use for querying.
|
||||
"""
|
||||
podcasts = await session.execute(select(Podcast))
|
||||
tasks = [process_podcast(podcast, session) for podcast in podcasts.scalars().all()]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
|
||||
async def process_podcast(podcast: Podcast, session: AsyncSession):
|
||||
"""Processes each podcast.
|
||||
|
||||
By parsing its feed, checking for new episodes, downloading,
|
||||
modifying, uploading, and saving them.
|
||||
|
||||
Args:
|
||||
podcast (Podcast): A podcast object containing metadata.
|
||||
session (AsyncSession): The database session to use for ORM operations.
|
||||
"""
|
||||
feed = feedparser.parse(podcast.url)
|
||||
threshold_date = datetime.now() - timedelta(days=PODCAST_DAYS)
|
||||
for entry in feed.entries:
|
||||
if datetime(*entry.published_parsed[:6]) > threshold_date:
|
||||
if not await episode_exists(session, entry.title, podcast.id):
|
||||
episode_path = await download_episode(entry)
|
||||
if episode_path:
|
||||
await modify_id3_tags(episode_path, entry.title, podcast.name)
|
||||
await upload_episode(episode_path, podcast.name)
|
||||
await save_episode(session, entry, podcast.id, episode_path)
|
||||
|
||||
|
||||
async def episode_exists(
|
||||
session: AsyncSession, episode_name: str, podcast_id: int
|
||||
) -> bool:
|
||||
"""Determines whether an episode already exists in the database.
|
||||
|
||||
Args:
|
||||
session (AsyncSession): The database session.
|
||||
episode_name (str): The name of the episode to check.
|
||||
podcast_id (int): The ID of the podcast to check against.
|
||||
|
||||
Returns:
|
||||
bool: True if the episode exists, False otherwise.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(Episode).where(
|
||||
Episode.name == episode_name, Episode.podcast_id == podcast_id
|
||||
)
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
async def download_episode(entry):
|
||||
"""Download an episode's audio file to a temporary location based on its feed entry.
|
||||
|
||||
Args:
|
||||
entry (FeedParserDict): A single episode entry from the parsed feed.
|
||||
|
||||
Returns:
|
||||
asyncpathlib.Path: Path to the downloaded file, or None if the download fails.
|
||||
"""
|
||||
temp_dir = tempfile.TemporaryDirectory()
|
||||
episode_path = (
|
||||
asyncpathlib.Path(temp_dir.name) / f"{entry.title.replace('/', '-')}.mp3"
|
||||
)
|
||||
try:
|
||||
audio_url = entry.enclosures[0].href
|
||||
content = await fetch_content(audio_url)
|
||||
await episode_path.write_bytes(content)
|
||||
return episode_path
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download episode {entry.title}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_content(url: str) -> bytes:
|
||||
"""Fetch content from a URL asynchronously.
|
||||
|
||||
Args:
|
||||
url (str): The URL from which to fetch the content.
|
||||
|
||||
Returns:
|
||||
bytes: The content fetched from the URL.
|
||||
"""
|
||||
import aiohttp
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
return await response.read()
|
||||
|
||||
|
||||
async def modify_id3_tags(file_path: asyncpathlib.Path, title: str, album: str):
|
||||
"""Modify the ID3 tags of a downloaded audio file asynchronously using eyed3.
|
||||
|
||||
Args:
|
||||
file_path (asyncpathlib.Path): The path to the audio file.
|
||||
title (str): The title to set in the ID3 tags.
|
||||
album (str): The album name to set in the ID3 tags.
|
||||
"""
|
||||
executor = ThreadPoolExecutor()
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(executor, lambda: _edit_tags(file_path, title, album))
|
||||
|
||||
|
||||
def _edit_tags(file_path, title, album):
|
||||
"""Helper function to modify ID3 tags using eyed3, called within a ThreadPoolExecutor.
|
||||
|
||||
Args:
|
||||
file_path (asyncpathlib.Path): The path to the file.
|
||||
title (str): The title to apply to the ID3 tags.
|
||||
album (str): The album name to apply to the ID3 tags.
|
||||
"""
|
||||
audiofile = eyed3.load(str(file_path))
|
||||
if audiofile.tag is None:
|
||||
audiofile.initTag()
|
||||
audiofile.tag.title = title
|
||||
audiofile.tag.album = album
|
||||
audiofile.tag.genre = "Podcast"
|
||||
audiofile.tag.save()
|
||||
|
||||
|
||||
async def upload_episode(file_path: asyncpathlib.Path, podcast_name: str):
|
||||
"""Asynchronously uploads an episode file to a remote server using SCP.
|
||||
|
||||
Args:
|
||||
file_path (asyncpathlib.Path): The path to the episode file.
|
||||
podcast_name (str): The name of the podcast, used to organize files on the server.
|
||||
"""
|
||||
remote_path = f"{SSH_PATH}/{podcast_name}/{file_path.name}"
|
||||
try:
|
||||
async with asyncssh.connect(
|
||||
host=SSH_SERVER, username=SSH_USER, client_keys=[SSH_KEY_PATH]
|
||||
) as conn:
|
||||
await conn.run(f"mkdir -p {SSH_PATH}/{podcast_name}", check=True)
|
||||
await asyncssh.scp(str(file_path), (conn, remote_path))
|
||||
logger.info(f"Successfully uploaded {file_path} to {remote_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error uploading file {file_path} to {remote_path}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def save_episode(
|
||||
session: AsyncSession, entry, podcast_id: int, episode_path: asyncpathlib.Path
|
||||
):
|
||||
"""Save the details of a new podcast episode to the database after it has been processed.
|
||||
|
||||
Args:
|
||||
session (AsyncSession): The database session.
|
||||
entry (FeedParserDict): The parsed feed entry.
|
||||
podcast_id (int): The ID of the podcast.
|
||||
episode_path (asyncpathlib.Path): The path to the downloaded and processed episode file.
|
||||
"""
|
||||
new_episode = Episode(
|
||||
podcast_id=podcast_id,
|
||||
name=entry.title,
|
||||
filename=str(episode_path),
|
||||
date_published=datetime(*entry.published_parsed[:6]),
|
||||
)
|
||||
session.add(new_episode)
|
||||
await session.commit()
|
||||
logger.info(f"New episode saved: {entry.title}")
|
||||
@@ -1,677 +0,0 @@
|
||||
"""Contains all SQL code here."""
|
||||
|
||||
import collections
|
||||
import contextlib
|
||||
import functools
|
||||
import inspect
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import Coroutine
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Protocol
|
||||
|
||||
import aiologger
|
||||
import sqlalchemy
|
||||
import sqlalchemy.orm
|
||||
from playlist import enums
|
||||
from playlist import env
|
||||
from playlist import models
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
|
||||
# Initialize asynchronous logger
|
||||
logger = aiologger.Logger.with_default_handlers(name=__name__)
|
||||
|
||||
# Create an asynchronous SQLAlchemy engine
|
||||
_engine = create_async_engine(env.DATABASE_URL, echo=True)
|
||||
|
||||
# Create a session maker for asynchronous sessions
|
||||
_async_session_maker = sqlalchemy.orm.sessionmaker(
|
||||
_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
|
||||
class Sessionizable(Protocol):
|
||||
"""Protocol defining the signature of a sessionizable function."""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
*args: tuple[Any, ...],
|
||||
session: AsyncSession | None = None,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> (AsyncGenerator, Coroutine):
|
||||
"""The signature for a sessionizable function.
|
||||
|
||||
Args:
|
||||
args: The positional arguments for the function.
|
||||
kwargs: The keyword arguments for the function (except for session).
|
||||
|
||||
Keyword Args:
|
||||
session: The database session. If set to None, this will be filled with a new session.
|
||||
|
||||
Returns:
|
||||
(AsyncGenerator, Coroutine): The async generator or
|
||||
coroutine that would be created by this function.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _db_logger(
|
||||
func_name: str, session: AsyncSession
|
||||
) -> AsyncGenerator[None, None]:
|
||||
"""Asynchronous context manager for logging database errors and handling rollbacks.
|
||||
|
||||
This context manager logs SQLAlchemy-specific exceptions and other exceptions that occur during
|
||||
the execution of a function. It also handles rolling back the session in case of an error.
|
||||
|
||||
Args:
|
||||
func_name (str): The name of the function being executed.
|
||||
session (AsyncSession): The database session used in the function.
|
||||
|
||||
Yields:
|
||||
None: This context manager does not yield any value.
|
||||
|
||||
Raises:
|
||||
SQLAlchemyError: Re-raises any SQLAlchemy-specific exceptions after logging and rollback.
|
||||
Exception: Re-raises any other exceptions after logging and rollback.
|
||||
"""
|
||||
try:
|
||||
yield
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
await logger.exception(f"Database error in {func_name}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
await logger.exception(f"Unexpected error in {func_name}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def _validate_signature(func: Sessionizable) -> None:
|
||||
"""Validate that the given function has a 'session' keyword argument.
|
||||
|
||||
This function checks if the 'session' parameter is present in the function's signature.
|
||||
If the 'session' parameter is missing, it raises an UnboundLocalError.
|
||||
|
||||
Args:
|
||||
func (Sessionizable): The function to validate.
|
||||
|
||||
Raises:
|
||||
UnboundLocalError: If the function does not have a 'session' parameter.
|
||||
"""
|
||||
sig = inspect.signature(func)
|
||||
try:
|
||||
sig.parameters["session"]
|
||||
|
||||
except KeyError as e:
|
||||
msg = f"{func.__name__} does not have a session parameter."
|
||||
raise UnboundLocalError(msg) from e
|
||||
|
||||
|
||||
def sessionize(func: Sessionizable) -> Sessionizable:
|
||||
"""Decorator that ensures a database session is available to an async function or generator.
|
||||
|
||||
This decorator automatically injects a `session` of type AsyncSession
|
||||
into the decorated function if one is not provided. If the `session` keyword argument is
|
||||
missing or set to None, a new session is created using the `_async_session_maker` and passed
|
||||
to the function. If a session is already provided when the function is called, it uses the
|
||||
existing session.
|
||||
|
||||
The decorator is intended for use with coroutine functions and async generator functions that
|
||||
are expected to interact with a database using SQLAlchemy's asynchronous session management.
|
||||
|
||||
Features:
|
||||
- Automatically manages database sessions to ensure efficient and correct usage of database
|
||||
connections.
|
||||
- Logs any SQLAlchemy-specific exceptions or other exceptions that occur during the execution
|
||||
of the decorated function, handling rollbacks if necessary.
|
||||
- Ensures that functions without a proper 'session' keyword argument are not decorated, raising
|
||||
an UnboundLocalError.
|
||||
|
||||
Args:
|
||||
func (Sessionizable): A coroutine function or an async generator function that accepts a
|
||||
'session' keyword argument.
|
||||
|
||||
Returns:
|
||||
Sessionizable: A wrapped version of the input function that manages a database session.
|
||||
|
||||
Raises:
|
||||
TypeError: If `func` is neither a coroutine function nor an async generator function.
|
||||
UnboundLocalError: If `func` lacks a 'session' keyword argument.
|
||||
|
||||
Example:
|
||||
@sessionize
|
||||
async def fetch_data(session: AsyncSession | None = None):
|
||||
# Function body using session
|
||||
...
|
||||
|
||||
Note:
|
||||
- The function must include a 'session' parameter in its signature, which can be None by
|
||||
default.
|
||||
- This decorator is only applicable to functions intended to perform database operations
|
||||
asynchronously.
|
||||
|
||||
See Also:
|
||||
- Sessionizable: The Protocol for sessionizable functions.
|
||||
- AsyncSession: SQLAlchemy class used for asynchronous session management.
|
||||
"""
|
||||
_validate_signature(func)
|
||||
|
||||
ret: Sessionizable
|
||||
match func:
|
||||
case func if inspect.iscoroutinefunction(func):
|
||||
|
||||
@functools.wraps(func)
|
||||
async def _coro_wrapper[
|
||||
**P
|
||||
](*args: P.args, **kwargs: P.kwargs) -> Coroutine:
|
||||
"""Wrap a sessionized coroutine function to inject the session if needed.
|
||||
|
||||
This wrapper function manages the database session for coroutine functions.
|
||||
"""
|
||||
if kwargs["session"] is None:
|
||||
async with (
|
||||
_async_session_maker() as kwargs["session"],
|
||||
_db_logger(func.__name__, kwargs["session"]),
|
||||
):
|
||||
return await func(*args, **kwargs)
|
||||
else:
|
||||
async with _db_logger(func.__name__, kwargs["session"]):
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
ret = _coro_wrapper
|
||||
|
||||
case func if inspect.isasyncgenfunction(func):
|
||||
|
||||
@functools.wraps(func)
|
||||
async def _asyncgen_wrapper[
|
||||
**P
|
||||
](*args: P.args, **kwargs: P.kwargs) -> AsyncGenerator:
|
||||
"""Wrap a sessionized async generator function to inject the session if needed.
|
||||
|
||||
This wrapper function manages the database session for async generator functions.
|
||||
"""
|
||||
if kwargs["session"] is None:
|
||||
async with (
|
||||
_async_session_maker() as kwargs["session"],
|
||||
_db_logger(func.__name__, kwargs["session"]),
|
||||
):
|
||||
async for element in func(*args, **kwargs):
|
||||
yield element
|
||||
else:
|
||||
async with _db_logger(func.__name__, kwargs["session"]):
|
||||
async for element in func(*args, **kwargs):
|
||||
yield element
|
||||
|
||||
ret = _asyncgen_wrapper
|
||||
|
||||
case _:
|
||||
msg = f"{func.__name__} is not an async generator or coroutine function."
|
||||
logger.error(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create all tables asynchronously."""
|
||||
try:
|
||||
async with _engine.begin() as conn:
|
||||
await conn.run_sync(models.mapper_registry.metadata.create_all)
|
||||
await logger.info("Database tables created.")
|
||||
except Exception as e:
|
||||
await logger.exception(f"Failed to create tables: {e}")
|
||||
|
||||
|
||||
async def drop_db() -> None:
|
||||
"""Drop all tables asynchronously for clean slate testing or teardown."""
|
||||
try:
|
||||
async with _engine.begin() as conn:
|
||||
await conn.run_sync(models.mapper_registry.metadata.drop_all)
|
||||
await logger.info("Database tables dropped.")
|
||||
except Exception as e:
|
||||
await logger.exception(f"Failed to drop tables: {e}")
|
||||
|
||||
|
||||
@sessionize
|
||||
async def insert_or_update_track(
|
||||
track_data: dict[str, Any],
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Insert a new track or update an existing one in the database asynchronously.
|
||||
|
||||
Args:
|
||||
track_data: The track data to write to the database.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
""" # noqa: D417
|
||||
async with session.begin():
|
||||
genre = await session.get(models.Genre, track_data.get("genre_id"))
|
||||
if not genre:
|
||||
genre = models.Genre(name=track_data["genre_name"])
|
||||
session.add(genre)
|
||||
await session.flush() # Ensures 'genre' is persisted and has an 'id'
|
||||
|
||||
track = await session.get(models.Track, track_data.get("id"))
|
||||
if track:
|
||||
for key, value in track_data.items():
|
||||
setattr(track, key, value)
|
||||
await logger.info(f"Updated track: {track.title}")
|
||||
else:
|
||||
track = models.Track(**track_data)
|
||||
session.add(track)
|
||||
await logger.info(f"Inserted new track: {track.title}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
@sessionize
|
||||
async def cleanup_unused_genres(*, session: AsyncSession | None = None) -> None:
|
||||
"""Remove genres that are no longer used by any tracks.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
"""
|
||||
async with session.begin():
|
||||
stmt = (
|
||||
sqlalchemy.select(models.Genre)
|
||||
.outerjoin(models.Track)
|
||||
.filter(models.Track.id is None)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
unused_genres = result.scalars().all()
|
||||
|
||||
for genre in unused_genres:
|
||||
await session.delete(genre)
|
||||
|
||||
await session.commit()
|
||||
await logger.info(f"Cleaned up {len(unused_genres)} unused genres.")
|
||||
|
||||
|
||||
@sessionize
|
||||
async def set_timestamp(
|
||||
event: enums.Event,
|
||||
operation: enums.TimedEvent,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> datetime:
|
||||
"""Mark the start or end timestamps in the statistics model.
|
||||
|
||||
Args:
|
||||
event: Identifies what event is being affected.
|
||||
operation: Identifies if it is the start or end of the event.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
|
||||
Returns:
|
||||
datetime: The datetime of the last update.
|
||||
"""
|
||||
async with session.begin():
|
||||
stats = await session.get(models.Statistics, 1)
|
||||
stats_obj = stats.scalars().first()
|
||||
|
||||
match [event, operation]:
|
||||
case [enums.Event.UPDATER, enums.EventTime.START]:
|
||||
stats_obj.start_update = datetime.now()
|
||||
log_message = "Updater start timestamp updated."
|
||||
case [enums.Event.UPDATER, enums.EventTime.END]:
|
||||
stats_obj.end_update = datetime.now()
|
||||
log_message = "Updater end timestamp updated."
|
||||
case [enums.Event.GENERATOR, enums.EventTime.START]:
|
||||
stats_obj.start_playlist_gen = datetime.now()
|
||||
log_message = "Generator start timestamp updated."
|
||||
case [enums.Event.GENERATOR, enums.EventTime.END]:
|
||||
stats_obj.end_playlist_gen = datetime.now()
|
||||
log_message = "Generator end timestamp updated."
|
||||
case _:
|
||||
raise ValueError(
|
||||
f"Invalid event {event} or operation {operation} specified."
|
||||
)
|
||||
|
||||
match event:
|
||||
case enums.Event.UPDATER:
|
||||
last_updated = stats_obj.end_update
|
||||
case enums.Event.GENERATOR:
|
||||
last_updated = stats_obj.end_playlist_gen
|
||||
case _:
|
||||
raise ValueError(f"Invalid event {event} specified.")
|
||||
|
||||
await session.commit()
|
||||
await logger.info(log_message)
|
||||
|
||||
return last_updated
|
||||
|
||||
|
||||
@sessionize
|
||||
async def remove_holiday_by_id(
|
||||
holiday_id: int, *, session: AsyncSession | None = None
|
||||
) -> None:
|
||||
"""Remove a holiday from the database by ID.
|
||||
|
||||
Captures and logs database-specific errors and general exceptions.
|
||||
|
||||
Args:
|
||||
holiday_id: The holiday ID to remove.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
""" # noqa: D417
|
||||
holiday = await session.get(models.Holiday, holiday_id)
|
||||
if holiday:
|
||||
await session.delete(holiday)
|
||||
await session.commit()
|
||||
else:
|
||||
await logger.warning(f"No holiday found with ID: {holiday_id}")
|
||||
|
||||
|
||||
@sessionize
|
||||
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,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Update the statistics model.
|
||||
|
||||
Args:
|
||||
new_average_duration: The new average duration of a track by geometric mean
|
||||
of all track durations as a float.
|
||||
new_podcast_length: The new total podcast length of all current episodes as a float.
|
||||
new_max_playtime: The new max playtime as a float.
|
||||
new_category_count: The new count of categories as an int.
|
||||
new_active_holidays: The new active holidays as an int.
|
||||
new_max_tracks: The new max tracks per day as an int.
|
||||
new_max_regular: The new max regular tracks as an int.
|
||||
new_max_holiday: The new max holiday tracks as an int.
|
||||
new_max_regular_favorite: The new max regular favorite tracks as an int.
|
||||
new_max_regular_general: The new max regular general tracks as an int.
|
||||
new_max_holiday_favorite: The new max holiday favorite tracks as an int.
|
||||
new_max_holiday_general: The new max holiday general tracks as an int.
|
||||
new_max_regular_favorite_base: The new max regular favorite base unit as an int.
|
||||
new_max_regular_general_base: The new max regular general base unit as an int.
|
||||
new_max_holiday_favorite_base: The new max holiday favorite base unit as an int.
|
||||
new_max_holiday_general_base: The new max holiday general base unit as an int.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
""" # noqa: D417
|
||||
async with session.begin():
|
||||
# Assuming there's only one statistics record, or you might need to handle this differently
|
||||
stats = await session.get(models.Statistics, 1)
|
||||
if stats:
|
||||
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
|
||||
new_stats = models.Statistics(
|
||||
average_track_length=new_average_duration,
|
||||
total_podcast_length=new_podcast_length,
|
||||
max_playtime=new_max_playtime,
|
||||
active_holiday_count=new_active_holidays,
|
||||
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()
|
||||
|
||||
|
||||
@sessionize
|
||||
async def fetch_tracks_for_sublist(
|
||||
is_category: bool,
|
||||
type_id: int,
|
||||
is_favorite: bool,
|
||||
sublist: enums.Sublist,
|
||||
limit: int,
|
||||
playlist_entries: set[int],
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> AsyncGenerator[models.Track, None]:
|
||||
"""Fetch tracks for a given sublist type within a category.
|
||||
|
||||
Args:
|
||||
is_category: True if a category, false if a holiday.
|
||||
type_id: Category or holiday ID for track filtering.
|
||||
is_favorite: Flag indicating if only favorite tracks should be fetched.
|
||||
sublist: Type of sublist to fetch.
|
||||
limit: Number of tracks to fetch.
|
||||
playlist_entries: The set of track IDs already in the playlist.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
|
||||
Returns:
|
||||
AsyncGenerator[models.Track, None]: Generates the tracks fitting the
|
||||
criteria.
|
||||
"""
|
||||
query = sqlalchemy.select(models.Track)
|
||||
if is_category:
|
||||
query = query.where(models.Track.category_id == type_id)
|
||||
else:
|
||||
query = query.join(models.Track.holidays).where(models.Holiday.id == type_id)
|
||||
|
||||
query = query.filter(models.Track.id.not_in(playlist_entries))
|
||||
|
||||
if is_favorite:
|
||||
query = query.filter(models.Track.rating == 5)
|
||||
|
||||
match sublist:
|
||||
case enums.Sublist.LEAST_RECENTLY_PLAYED:
|
||||
query = query.order_by(models.Track.last_played.asc())
|
||||
case enums.Sublist.LEAST_OFTEN_PLAYED:
|
||||
query = query.order_by(models.Track.play_count.asc())
|
||||
case enums.Sublist.LEAST_RECENTLY_ADDED:
|
||||
query = query.order_by(models.Track.date_added.asc())
|
||||
case enums.Sublist.RANDOM:
|
||||
query = query.order_by(sqlalchemy.func.random())
|
||||
case _:
|
||||
raise ValueError(f"Unknown sublist type: {sublist}")
|
||||
|
||||
query = query.limit(limit)
|
||||
result = await session.execute(query)
|
||||
for track in result.scalars():
|
||||
yield track
|
||||
|
||||
|
||||
@sessionize
|
||||
async def insert_into_playlist( # noqa: C901
|
||||
is_category: bool,
|
||||
type_id: int,
|
||||
track_id: int,
|
||||
sublist: enums.Sublist,
|
||||
is_favorite: bool,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Insert a track into the playlist.
|
||||
|
||||
Args:
|
||||
is_category: True is a category, false is a holiday.
|
||||
type_id: Category or holiday ID associated with the playlist.
|
||||
track_id: Track ID to be added to the playlist.
|
||||
sublist: Sublist type under which the track is added.
|
||||
is_favorite: Indicates if the track is added as a favorite.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
""" # noqa: D417
|
||||
if is_category:
|
||||
playlist_track = models.PlaylistTrack(
|
||||
category_id=type_id,
|
||||
track_id=track_id,
|
||||
sublist_type=sublist,
|
||||
is_favorite=is_favorite,
|
||||
)
|
||||
else:
|
||||
playlist_track = models.PlaylistTrack(
|
||||
holiday_id=type_id,
|
||||
track_id=track_id,
|
||||
sublist_type=sublist,
|
||||
is_favorite=is_favorite,
|
||||
)
|
||||
session.add(playlist_track)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@sessionize
|
||||
async def gen_all_category_ids(
|
||||
*, session: AsyncSession | None = None
|
||||
) -> AsyncGenerator[int, None, None]:
|
||||
"""Generate the current category ids from the database.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
|
||||
Yields:
|
||||
int: The category id.
|
||||
"""
|
||||
query = sqlalchemy.select(models.Category)
|
||||
result = session.execute(query)
|
||||
for row in result.scalars():
|
||||
yield row.id
|
||||
|
||||
|
||||
@sessionize
|
||||
async def gen_active_holiday_ids(
|
||||
*, session: AsyncSession | None = None
|
||||
) -> AsyncGenerator[int, None, None]:
|
||||
"""Generate the current active holiday ids from the database.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
|
||||
Yields:
|
||||
int: The holiday id.
|
||||
"""
|
||||
query = sqlalchemy.select(models.Holiday).where(models.Holiday.is_active is True)
|
||||
result = session.execute(query)
|
||||
for row in result.scalars():
|
||||
yield row.id
|
||||
|
||||
|
||||
@sessionize
|
||||
async def get_existing_playlist_track_info(
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> tuple[set[int], dict[tuple[bool, int, bool, enums.Sublist], int]]:
|
||||
"""Get the existing playlist track information.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
|
||||
Returns:
|
||||
tuple[set[int], dict[tuple[bool, int, bool, enums.Sublist], int]]: Two pieces:
|
||||
First is a set of the ids of all of the tracks in the playlist. Second is a
|
||||
dict with the key being the combination of is_category, type_id, is_favorite,
|
||||
and sublist; the value is the count of that unique key combination.
|
||||
"""
|
||||
# TODO: Set up user-specific query here.
|
||||
query = sqlalchemy.select(models.PlaylistTrack).where(
|
||||
models.PlaylistTrack.episode_id is None
|
||||
)
|
||||
result = session.execute(query)
|
||||
ids = set()
|
||||
data = []
|
||||
for row in result.scalars():
|
||||
if row.episode_id is not None:
|
||||
continue
|
||||
ids.append(row.track_id)
|
||||
is_category = row.category_id is not None
|
||||
type_id = row.category_id if is_category else row.holiday_id
|
||||
is_favorite = row.is_favorite
|
||||
sublist = row.sublist
|
||||
data.append((is_category, type_id, is_favorite, sublist))
|
||||
data_counts = collections.Counter(data)
|
||||
return ids, data_counts
|
||||
|
||||
|
||||
@sessionize
|
||||
async def get_statistics(*, session: AsyncSession | None = None) -> models.Statistics:
|
||||
"""Get the statistics object for the database.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
|
||||
Returns:
|
||||
models.Statistics: The statistics object used for playlist processing.
|
||||
"""
|
||||
stats = await session.get(models.Statistics, 1)
|
||||
stats_obj = stats.scalars().first()
|
||||
return stats_obj
|
||||
|
||||
|
||||
@sessionize
|
||||
async def remove_track_from_playlist(
|
||||
server_id: str, *, session: AsyncSession | None = None
|
||||
):
|
||||
"""Directly removes all entries from PlaylistTrack that are associated with a given server_id.
|
||||
|
||||
Args:
|
||||
server_id: The unique server ID of the track to remove from the playlist.
|
||||
|
||||
Keyword Args:
|
||||
session: The current database session.
|
||||
|
||||
Returns:
|
||||
int: Number of rows affected by the delete operation.
|
||||
"""
|
||||
# Delete directly using a join on Track where server_id matches
|
||||
delete_query = sqlalchemy.delete(models.PlaylistTrack).where(
|
||||
models.PlaylistTrack.track_id
|
||||
== sqlalchemy.select(models.Track.id)
|
||||
.where(models.Track.server_id == server_id)
|
||||
.scalar_subquery()
|
||||
)
|
||||
result = await session.execute(delete_query)
|
||||
await session.commit()
|
||||
|
||||
affected_rows = result.rowcount
|
||||
logger.info(
|
||||
f"Removed {affected_rows} entries from PlaylistTrack for server_id {server_id}."
|
||||
)
|
||||
return affected_rows
|
||||
@@ -1,127 +0,0 @@
|
||||
"""Handles updating the tracks stored in the database from plex."""
|
||||
|
||||
import enums
|
||||
import env
|
||||
import numpy as np
|
||||
import plex
|
||||
import sql
|
||||
from aiologger import Logger
|
||||
|
||||
|
||||
logger = Logger.with_default_handlers(name="updater_logger")
|
||||
|
||||
|
||||
async def process_tracks(server_url: str, token: str) -> None:
|
||||
"""Asynchronously processes track data fetched from a Plex server.
|
||||
|
||||
Updates tracks in the database, and handles genre cleanup after updates.
|
||||
|
||||
Args:
|
||||
server_url (str): The URL of the Plex server.
|
||||
token (str): The authentication token used for accessing the Plex server.
|
||||
"""
|
||||
found_genres = set()
|
||||
durations: list[float] = []
|
||||
try:
|
||||
with sql.async_session_maker() as session:
|
||||
last_updated = await sql.update_timestamp(
|
||||
session, enums.Event.UPDATER, enums.Event.START
|
||||
)
|
||||
|
||||
async for track_data in plex.fetch_tracks_from_plex(server_url, token):
|
||||
if track_data["date_last_played"] > last_updated:
|
||||
await sql.remove_track_from_playlist(
|
||||
session, track_data["server_id"]
|
||||
)
|
||||
logger.info(
|
||||
f"Track {track_data['server_id']} with title {track_data['title']}"
|
||||
f" by artist {track_data['artist']} removed from playlist because it"
|
||||
" was recently played."
|
||||
)
|
||||
await sql.insert_or_update_track(session, 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"])
|
||||
else:
|
||||
logger.warning(
|
||||
f"Track {track_data['server_id']} with title {track_data['title']}"
|
||||
f" by artist {track_data['artist']} has a duration of: {track_data['duration']}."
|
||||
)
|
||||
|
||||
await sql.cleanup_unused_genres(session, found_genres)
|
||||
|
||||
await calculate_statistics(session, durations)
|
||||
|
||||
await sql.update_timestamp(session, enums.Event.UPDATER, enums.Event.END)
|
||||
await logger.info("Track processing completed successfully.")
|
||||
except Exception as e:
|
||||
await logger.error(f"An error occurred during track processing: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def calculate_statistics(durations: list[float]) -> None:
|
||||
"""Calculate the statistics to be used for playlist processing.
|
||||
|
||||
Args:
|
||||
durations (list[float]): The list of durations of all tracks gathered during
|
||||
the track updating process.
|
||||
"""
|
||||
if durations:
|
||||
average_duration = np.exp(np.log(durations).mean())
|
||||
|
||||
# TODO: Get the total duration of all podcast episodes.
|
||||
total_podcast_length = 0
|
||||
|
||||
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_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,
|
||||
)
|
||||
|
||||
# TODO: Remove any tracks that exceed a sublist's limit for the playlist after these adjustments.
|
||||
else:
|
||||
logger.warning("No valid track durations retrieved, statistics not updated.")
|
||||
@@ -1 +0,0 @@
|
||||
"""Test suite for the playlist package."""
|
||||
3
docs/codeofconduct.md
Normal file
3
docs/codeofconduct.md
Normal file
@@ -0,0 +1,3 @@
|
||||
```{include} ../CODE_OF_CONDUCT.md
|
||||
|
||||
```
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Sphinx configuration."""
|
||||
|
||||
project = "Plex Playlist"
|
||||
author = "Cliff Hill"
|
||||
copyright = "2023, Cliff Hill"
|
||||
copyright = "2024, Cliff Hill"
|
||||
extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.napoleon",
|
||||
@@ -1,4 +1,4 @@
|
||||
```{include} ../../CONTRIBUTING.md
|
||||
```{include} ../CONTRIBUTING.md
|
||||
---
|
||||
end-before: <!-- github-only -->
|
||||
---
|
||||
@@ -1,4 +1,4 @@
|
||||
```{include} ../../README.md
|
||||
```{include} ../README.md
|
||||
---
|
||||
end-before: <!-- github-only -->
|
||||
---
|
||||
@@ -1,6 +1,6 @@
|
||||
# License
|
||||
|
||||
```{literalinclude} ../../LICENSE
|
||||
```{literalinclude} ../LICENSE
|
||||
---
|
||||
language: none
|
||||
---
|
||||
@@ -1,8 +1,8 @@
|
||||
# Reference
|
||||
|
||||
## playlist
|
||||
## xdh.playlist
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: playlist
|
||||
.. automodule:: xdh.playlist
|
||||
:members:
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
# Usage
|
||||
|
||||
```{eval-rst}
|
||||
.. click:: playlist.__main__:main
|
||||
.. click:: xdh.playlist.__main__:main
|
||||
:prog: plex-playlist
|
||||
:nested: full
|
||||
```
|
||||
22
noxfile.py
22
noxfile.py
@@ -1,4 +1,6 @@
|
||||
"""Nox sessions."""
|
||||
|
||||
# noqa
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
@@ -22,7 +24,7 @@ except ImportError:
|
||||
raise SystemExit(dedent(message)) from None
|
||||
|
||||
|
||||
package = "playlist"
|
||||
package = "xdh.playlist"
|
||||
python_versions = ["3.10", "3.9", "3.8", "3.7"]
|
||||
nox.needs_version = ">= 2021.6.6"
|
||||
nox.options.sessions = (
|
||||
@@ -149,7 +151,7 @@ def safety(session: Session) -> None:
|
||||
@session(python=python_versions)
|
||||
def mypy(session: Session) -> None:
|
||||
"""Type-check using mypy."""
|
||||
args = session.posargs or ["backend/src", "backend/tests", "backend/docs/conf.py"]
|
||||
args = session.posargs or ["src", "tests", "docs/conf.py"]
|
||||
session.install(".")
|
||||
session.install("mypy", "pytest")
|
||||
session.run("mypy", *args)
|
||||
@@ -163,15 +165,7 @@ def tests(session: Session) -> None:
|
||||
session.install(".")
|
||||
session.install("coverage[toml]", "pytest", "pygments")
|
||||
try:
|
||||
session.run(
|
||||
"coverage",
|
||||
"run",
|
||||
"--parallel",
|
||||
"-m",
|
||||
"pytest",
|
||||
"backend/tests",
|
||||
*session.posargs,
|
||||
)
|
||||
session.run("coverage", "run", "--parallel", "-m", "pytest", *session.posargs)
|
||||
finally:
|
||||
if session.interactive:
|
||||
session.notify("coverage", posargs=[])
|
||||
@@ -195,9 +189,7 @@ def typeguard(session: Session) -> None:
|
||||
"""Runtime type checking using Typeguard."""
|
||||
session.install(".")
|
||||
session.install("pytest", "typeguard", "pygments")
|
||||
session.run(
|
||||
"pytest", "backend/tests", f"--typeguard-packages={package}", *session.posargs
|
||||
)
|
||||
session.run("pytest", f"--typeguard-packages={package}", *session.posargs)
|
||||
|
||||
|
||||
@session(python=python_versions)
|
||||
@@ -212,7 +204,7 @@ def xdoctest(session: Session) -> None:
|
||||
|
||||
session.install(".")
|
||||
session.install("xdoctest[colors]")
|
||||
session.run("python", "-m", "xdoctest", "backend/src", *args)
|
||||
session.run("python", "-m", "xdoctest", *args)
|
||||
|
||||
|
||||
@session(name="docs-build", python=python_versions[0])
|
||||
|
||||
3194
poetry.lock
generated
3194
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ homepage = "https://github.com/xlorepdarkhelm/plex-playlist"
|
||||
repository = "https://github.com/xlorepdarkhelm/plex-playlist"
|
||||
documentation = "https://plex-playlist.readthedocs.io"
|
||||
packages = [
|
||||
{ include = "playlist", from = "backend/src" },
|
||||
{ include = "xdh", from = "src" },
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 1 - Planning",
|
||||
@@ -21,13 +21,8 @@ Changelog = "https://github.com/xlorepdarkhelm/plex-playlist/releases"
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12"
|
||||
click = ">=8.0.1"
|
||||
asyncpg = "^0.28.0"
|
||||
sqlalchemy = "^2.0.23"
|
||||
pydantic = "^2.5.0"
|
||||
alembic = "^1.12.1"
|
||||
python-dotenv = "^1.0.1"
|
||||
aiologger = "^0.7.0"
|
||||
numpy = "^1.26.4"
|
||||
plexapi = ">=4.7.2"
|
||||
python-dotenv = ">=0.19.2"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
Pygments = ">=2.10.0"
|
||||
@@ -46,6 +41,7 @@ pep8-naming = ">=0.12.1"
|
||||
pre-commit = ">=2.16.0"
|
||||
pre-commit-hooks = ">=4.1.0"
|
||||
pytest = ">=6.2.5"
|
||||
pytest-asyncio = ">=0.16.0"
|
||||
pyupgrade = ">=2.29.1"
|
||||
safety = ">=1.10.3"
|
||||
sphinx = ">=4.3.2"
|
||||
@@ -56,10 +52,10 @@ xdoctest = {extras = ["colors"], version = ">=0.15.10"}
|
||||
myst-parser = {version = ">=0.16.1"}
|
||||
|
||||
[tool.poetry.scripts]
|
||||
plex-playlist = "playlist.__main__:main"
|
||||
plex-playlist = "xdh.playlist.__main__:main"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
jupyter = "^1.0.0"
|
||||
ipython = "^8.27.0"
|
||||
|
||||
[tool.coverage.paths]
|
||||
source = ["src", "*/site-packages"]
|
||||
@@ -67,7 +63,7 @@ tests = ["tests", "*/tests"]
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = true
|
||||
source = ["playlist", "tests"]
|
||||
source = ["xdh.playlist", "tests"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
|
||||
24
src/xdh/playlist/__init__.py
Normal file
24
src/xdh/playlist/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Plex Playlist."""
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import dotenv_values
|
||||
|
||||
|
||||
class _Env:
|
||||
def __init__(self, filename: str = ".env") -> None:
|
||||
self.__env = dotenv_values(filename)
|
||||
|
||||
def __getattr__(self, name: str) -> str:
|
||||
try:
|
||||
return self.__env[name]
|
||||
except KeyError as e:
|
||||
try:
|
||||
return os.environ[name]
|
||||
except KeyError:
|
||||
raise AttributeError(
|
||||
f"{name} is not a defined environment variable."
|
||||
) from e
|
||||
|
||||
|
||||
env = _Env()
|
||||
25
src/xdh/playlist/__main__.py
Normal file
25
src/xdh/playlist/__main__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Command-line interface."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import click
|
||||
|
||||
from xdh.playlist.plex import PlexTrackFetcher
|
||||
|
||||
|
||||
@click.command()
|
||||
def fetch_tracks(batch_size: int):
|
||||
"""Fetch tracks from Plex server in batches."""
|
||||
click.echo(f"Fetching Plex tracks in batches of {batch_size}...")
|
||||
|
||||
plex_fetcher = PlexTrackFetcher(batch_size)
|
||||
|
||||
async def run():
|
||||
async for track in plex_fetcher.fetch_tracks():
|
||||
click.echo(track)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
fetch_tracks()
|
||||
62
src/xdh/playlist/plex.py
Normal file
62
src/xdh/playlist/plex.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Plex API calls."""
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from typing import Any
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from plexapi.server import PlexServer
|
||||
|
||||
from xdh.playlist import env
|
||||
|
||||
|
||||
class PlexTrackFetcher:
|
||||
"""Fetches tracks from Plex in batches asynchronously."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the track fetcher."""
|
||||
server = PlexServer(env.PLEX_URL, env.PLEX_TOKEN)
|
||||
self.music_library = server.library.section("Music")
|
||||
|
||||
async def fetch_tracks(self) -> AsyncGenerator[dict[str, Any], None]:
|
||||
"""Async generator that fetches and yields tracks from Plex in batches."""
|
||||
total_tracks = self.music_library.totalSize
|
||||
|
||||
with ProcessPoolExecutor() as executor:
|
||||
loop = asyncio.get_event_loop()
|
||||
futures = [
|
||||
loop.run_in_executor(executor, self._fetch_batch, i)
|
||||
for i in range(0, total_tracks, env.BATCH_SIZE)
|
||||
]
|
||||
for future in asyncio.as_completed(futures):
|
||||
result_batch = await future
|
||||
for track in result_batch:
|
||||
yield track
|
||||
|
||||
def _fetch_batch(self, start: int) -> list[dict[str, Any]]:
|
||||
"""Fetch a batch of tracks."""
|
||||
batch = self.music_library.searchTracks(
|
||||
max_results=env.BATCH_SIZE,
|
||||
container_start=start,
|
||||
container_size=env.BATCH_SIZE,
|
||||
)
|
||||
|
||||
result = [
|
||||
{
|
||||
"plex_id": track.ratingKey,
|
||||
"title": track.title,
|
||||
"album_name": track.album().title if track.album() else None,
|
||||
"artist": track.originalTitle,
|
||||
"album_artist": (
|
||||
track.album().artist().title if track.album() else None
|
||||
),
|
||||
"date_last_played": track.lastViewedAt,
|
||||
"date_added": track.addedAt,
|
||||
"play_count": track.viewCount,
|
||||
"track_number": track.index,
|
||||
"disc_number": track.disc,
|
||||
"rating": track.userRating,
|
||||
}
|
||||
for track in batch
|
||||
]
|
||||
return result
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Test suite for the xdh.playlist package."""
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Test cases for the __main__ module."""
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
from playlist import __main__
|
||||
|
||||
from xdh.playlist import __main__
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
Reference in New Issue
Block a user