Updating stuff, making sure that the track updater also removes played

tracks from the playlist.

Signed-off-by: Cliff Hill <xlorep@darkhelm.org>
This commit is contained in:
2024-04-24 11:28:08 -04:00
parent c91e7339fa
commit 0a1b66f2d5
4 changed files with 271 additions and 186 deletions

View File

@@ -152,7 +152,12 @@ class Track:
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.
playlists (list[Playlist]): List of playlists containing this track, many-to-many relationship.
genre (Genre): The genre for this track.
playlist_tracks (list[PlaylistTrack]): List of playlist tracks for this track.
category_id (int): Foreign key linking to the category.
category (Category): The category for this track.
holidays (list[Holiday]): The list of holidays this track is assigned to.
server_id (str): Identifier for the server where the track is hosted, indexed.
"""
__tablename__ = "tracks"
@@ -168,8 +173,12 @@ class Track:
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"))
genre: Mapped["Genre"] = relationship("Genre", back_populates="tracks")
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"

View File

@@ -82,6 +82,7 @@ async def fetch_tracks_from_plex(
if track.genres
else "Unknown"
),
"server_id": track.ratingKey, # Using Plex track ratingKey as the server_id
}
yield track_data
except Exception as e:

View File

@@ -13,6 +13,7 @@ from sqlalchemy import func
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.future import delete
from sqlalchemy.future import select
from sqlalchemy.orm import registry
from sqlalchemy.orm import sessionmaker
@@ -49,99 +50,95 @@ async def drop_db() -> None:
await logger.error(f"Failed to drop tables: {e}")
async def insert_or_update_track(track_data: dict[str, Any]) -> None:
async def insert_or_update_track(
session: AsyncSession, track_data: dict[str, Any]
) -> None:
"""Insert a new track or update an existing one in the database asynchronously."""
async with async_session_maker() as session:
try:
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'
try:
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}")
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()
except SQLAlchemyError as e:
await logger.error(f"Database error in insert_or_update_track: {e}")
await session.rollback()
except Exception as e:
await logger.error(f"Unexpected error in insert_or_update_track: {e}")
await session.rollback()
await session.commit()
except SQLAlchemyError as e:
await logger.error(f"Database error in insert_or_update_track: {e}")
await session.rollback()
except Exception as e:
await logger.error(f"Unexpected error in insert_or_update_track: {e}")
await session.rollback()
async def cleanup_unused_genres() -> None:
async def cleanup_unused_genres(session: AsyncSession) -> None:
"""Remove genres that are no longer used by any tracks."""
async with async_session_maker() as session:
try:
async with session.begin():
stmt = (
select(models.Genre)
.outerjoin(models.Track)
.filter(models.Track.id is None)
)
result = await session.execute(stmt)
unused_genres = result.scalars().all()
try:
async with session.begin():
stmt = (
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)
for genre in unused_genres:
await session.delete(genre)
await session.commit()
await logger.info(f"Cleaned up {len(unused_genres)} unused genres.")
except SQLAlchemyError as e:
await logger.error(f"Database error in cleanup_unused_genres: {e}")
await session.rollback()
except Exception as e:
await logger.error(f"Unexpected error in cleanup_unused_genres: {e}")
await session.rollback()
await session.commit()
await logger.info(f"Cleaned up {len(unused_genres)} unused genres.")
except SQLAlchemyError as e:
await logger.error(f"Database error in cleanup_unused_genres: {e}")
await session.rollback()
except Exception as e:
await logger.error(f"Unexpected error in cleanup_unused_genres: {e}")
await session.rollback()
async def update_statistics_timestamps(operation: str) -> None:
async def update_statistics_timestamps(session: AsyncSession, operation: str) -> None:
"""Update the start or end timestamps in the statistics model based on the specified operation.
Args:
session (AsyncSession): The database session.
operation (str): A string to specify whether to update 'start' or 'end' timestamp.
"""
async with async_session_maker() as session:
try:
async with session.begin():
stats = await session.get(models.Statistics, 1)
stats_obj = stats.scalars().first()
try:
async with session.begin():
stats = await session.get(models.Statistics, 1)
stats_obj = stats.scalars().first()
if operation == "start":
stats_obj.track_update_start_timestamp = datetime.now()
stats_obj.track_update_end_timestamp = None
log_message = "Statistics start timestamp updated."
elif operation == "end":
stats_obj.track_update_end_timestamp = datetime.now()
log_message = "Statistics end timestamp updated."
else:
raise ValueError(
"Invalid operation specified for statistics timestamp update."
)
if operation == "start":
stats_obj.track_update_start_timestamp = datetime.now()
stats_obj.track_update_end_timestamp = None
log_message = "Statistics start timestamp updated."
elif operation == "end":
stats_obj.track_update_end_timestamp = datetime.now()
log_message = "Statistics end timestamp updated."
else:
raise ValueError(
"Invalid operation specified for statistics timestamp update."
)
await session.commit()
await logger.info(log_message)
await session.commit()
await logger.info(log_message)
except SQLAlchemyError as e:
await logger.error(
f"Database error during statistics timestamp update: {e}"
)
await session.rollback()
except Exception as e:
await logger.error(
f"Unexpected error during statistics timestamp update: {e}"
)
await session.rollback()
except SQLAlchemyError as e:
await logger.error(f"Database error during statistics timestamp update: {e}")
await session.rollback()
except Exception as e:
await logger.error(f"Unexpected error during statistics timestamp update: {e}")
await session.rollback()
async def add_new_holiday(
@@ -217,6 +214,7 @@ async def remove_holiday_by_id(session: AsyncSession, holiday_id: int) -> None:
async def update_statistics(
session: AsyncSession,
new_average_duration: float,
new_podcast_length: float,
new_max_playtime: float,
@@ -237,6 +235,7 @@ async def update_statistics(
"""Update the statistics model.
Args:
session (AsyncSession): The database session.
new_average_duration (float): The new average duration of a track by geometric mean
of all track durations as a float.
new_podcast_length (float): The new total podcast length of all current episodes as a float.
@@ -255,53 +254,52 @@ async def update_statistics(
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:
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()
except Exception as e:
print(f"Failed to update the statistics model: {e}")
await session.rollback()
try:
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()
except Exception as e:
print(f"Failed to update the statistics model: {e}")
await session.rollback()
async def fetch_tracks_for_sublist(
@@ -413,62 +411,122 @@ async def insert_into_playlist( # noqa: C901
await session.rollback()
raise
async def gen_all_category_ids() -> AsyncGenerator[int, None, None]:
"""Generate the current category ids from the database.
Yields:
int: The category id.
"""
async with async_session_maker() as session:
query = select(models.Category)
result = session.execute(query)
for row in result.scalars():
yield row.id
async def gen_all_category_ids(
session: AsyncSession,
) -> AsyncGenerator[int, None, None]:
"""Generate the current category ids from the database.
async def gen_active_holiday_ids() -> AsyncGenerator[int, None, None]:
"""Generate the current active holiday ids from the database.
Args:
session (AsyncSession): The database session.
Yields:
int: The holiday id.
"""
async with async_session_maker() as session:
query = select(models.Holiday).where(models.Holiday.is_active is True)
result = session.execute(query)
for row in result.scalars():
yield row.id
Yields:
int: The category id.
"""
query = select(models.Category)
result = session.execute(query)
for row in result.scalars():
yield row.id
async def get_existing_playlist_track_info() -> (
tuple[set[int], dict[tuple[bool, int, bool, enums.Sublist], int]]
):
"""Get the existing playlist track information.
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.
"""
async with async_session_maker() as session:
# TODO: Set up user-specific query here.
query = 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 = Counter(data)
return ids, data_counts
async def gen_active_holiday_ids(
session: AsyncSession,
) -> AsyncGenerator[int, None, None]:
"""Generate the current active holiday ids from the database.
async def get_statistics() -> models.Statistics:
stats = await session.get(models.Statistics, 1)
stats_obj = stats.scalars().first()
return stats_obj
Args:
session (AsyncSession): The database session.
Yields:
int: The holiday id.
"""
query = select(models.Holiday).where(models.Holiday.is_active is True)
result = session.execute(query)
for row in result.scalars():
yield row.id
async def get_existing_playlist_track_info(
session: AsyncSession,
) -> tuple[set[int], dict[tuple[bool, int, bool, enums.Sublist], int]]:
"""Get the existing playlist track information.
Args:
session (AsyncSession): 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 = 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 = Counter(data)
return ids, data_counts
async def get_statistics(session: AsyncSession) -> models.Statistics:
"""Get the statistics object for the database.
Args:
session (AsyncSession): 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
async def remove_track_from_playlist(session: AsyncSession, server_id: str):
"""Directly removes all entries from PlaylistTrack that are associated with a given server_id.
Args:
session (AsyncSession): The current database session.
server_id (str): The unique server ID of the track to remove from the playlist.
Returns:
int: Number of rows affected by the delete operation.
"""
try:
# Delete directly using a join on Track where server_id matches
delete_query = delete(models.PlaylistTrack).where(
models.PlaylistTrack.track_id
== 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
except SQLAlchemyError as e:
logger.error(
f"SQLAlchemy error occurred while removing tracks from playlist: {e}"
)
await session.rollback()
raise
except Exception as e:
logger.error(
f"Unexpected error occurred while removing tracks from playlist: {e}"
)
await session.rollback()
raise

View File

@@ -22,19 +22,36 @@ async def process_tracks(server_url: str, token: str) -> None:
found_genres = set()
durations: list[float] = []
try:
await sql.update_statistics_timestamps(start=True)
with sql.async_session_maker() as session:
last_updated = await sql.update_statistics_timestamps(session, start=True)
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"])
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(found_genres)
await sql.cleanup_unused_genres(session, found_genres)
await calculate_statistics(durations)
await calculate_statistics(session, durations)
await sql.update_statistics_timestamps(start=False)
await sql.update_statistics_timestamps(session, start=False)
await logger.info("Track processing completed successfully.")
except Exception as e:
await logger.error(f"An error occurred during track processing: {e}")