From: Marcel van der Veldt Date: Tue, 11 Feb 2025 14:57:16 +0000 (+0100) Subject: Chore: Improve syncing of resume/progress info X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=95f816322e8cf33787643110efeb355d5bd364fc;p=music-assistant-server.git Chore: Improve syncing of resume/progress info --- diff --git a/music_assistant/controllers/media/audiobooks.py b/music_assistant/controllers/media/audiobooks.py index f59edc15..b7fee93a 100644 --- a/music_assistant/controllers/media/audiobooks.py +++ b/music_assistant/controllers/media/audiobooks.py @@ -8,13 +8,14 @@ from music_assistant_models.enums import MediaType, ProviderFeature from music_assistant_models.errors import InvalidDataError from music_assistant_models.media_items import Artist, Audiobook, UniqueList -from music_assistant.constants import DB_TABLE_AUDIOBOOKS +from music_assistant.constants import DB_TABLE_AUDIOBOOKS, DB_TABLE_PLAYLOG from music_assistant.controllers.media.base import MediaControllerBase from music_assistant.helpers.compare import ( compare_audiobook, compare_media_item, loose_compare_strings, ) +from music_assistant.helpers.datetime import utc_timestamp from music_assistant.helpers.json import serialize_to_json if TYPE_CHECKING: @@ -45,8 +46,13 @@ class AudiobooksController(MediaControllerBase[Audiobook, Audiobook]): 'audio_format', json(provider_mappings.audio_format), 'url', provider_mappings.url, 'details', provider_mappings.details - )) FROM provider_mappings WHERE provider_mappings.item_id = audiobooks.item_id AND media_type = 'audiobook') AS provider_mappings - FROM audiobooks""" # noqa: E501 + )) FROM provider_mappings WHERE provider_mappings.item_id = audiobooks.item_id AND media_type = 'audiobook') AS provider_mappings, + playlog.fully_played AS fully_played, + playlog.seconds_played AS seconds_played, + playlog.seconds_played * 1000 as resume_position_ms + FROM audiobooks + LEFT JOIN playlog ON playlog.item_id = audiobooks.item_id AND playlog.media_type = 'audiobook' + """ # noqa: E501 # register (extra) api handlers api_base = self.api_base self.mass.register_api_command(f"music/{api_base}/audiobook_versions", self.versions) @@ -139,6 +145,7 @@ class AudiobooksController(MediaControllerBase[Audiobook, Audiobook]): # update/set provider_mappings table await self._set_provider_mappings(db_id, item.provider_mappings) self.logger.debug("added %s to database (id: %s)", item.name, db_id) + await self._set_playlog(db_id, item) return db_id async def _update_library_item( @@ -180,6 +187,7 @@ class AudiobooksController(MediaControllerBase[Audiobook, Audiobook]): # update/set provider_mappings table await self._set_provider_mappings(db_id, provider_mappings, overwrite) self.logger.debug("updated %s in database: (id %s)", update.name, db_id) + await self._set_playlog(db_id, update) async def radio_mode_base_tracks( self, @@ -250,3 +258,53 @@ class AudiobooksController(MediaControllerBase[Audiobook, Audiobook]): db_audiobook.name, provider.name, ) + + async def _set_playlog(self, db_id: int, media_item: Audiobook) -> None: + """Update/set the playlog table for the given audiobook db item_id.""" + # cleanup provider specific entries for this item + # we always prefer the library playlog entry + for prov_mapping in media_item.provider_mappings: + if not (provider := self.mass.get_provider(prov_mapping.provider_instance)): + continue + await self.mass.music.database.delete( + DB_TABLE_PLAYLOG, + { + "media_type": self.media_type.value, + "item_id": prov_mapping.item_id, + "provider": provider.lookup_key, + }, + ) + if media_item.fully_played is None and media_item.resume_position_ms is None: + return + cur_entry = await self.mass.music.database.get_row( + DB_TABLE_PLAYLOG, + { + "media_type": self.media_type.value, + "item_id": db_id, + "provider": "library", + }, + ) + seconds_played = int(media_item.resume_position_ms or 0 / 1000) + # abort if nothing changed + if ( + cur_entry + and cur_entry["fully_played"] == media_item.fully_played + and abs((cur_entry["seconds_played"] or 0) - seconds_played) > 2 + ): + return + await self.mass.music.database.insert( + DB_TABLE_PLAYLOG, + { + "item_id": db_id, + "provider": "library", + "media_type": media_item.media_type.value, + "name": media_item.name, + "image": serialize_to_json(media_item.image.to_dict()) + if media_item.image + else None, + "fully_played": media_item.fully_played, + "seconds_played": seconds_played, + "timestamp": utc_timestamp(), + }, + allow_replace=True, + ) diff --git a/music_assistant/controllers/media/podcasts.py b/music_assistant/controllers/media/podcasts.py index 460141eb..18c8c066 100644 --- a/music_assistant/controllers/media/podcasts.py +++ b/music_assistant/controllers/media/podcasts.py @@ -6,7 +6,7 @@ import asyncio from typing import TYPE_CHECKING, Any from music_assistant_models.enums import MediaType, ProviderFeature -from music_assistant_models.errors import InvalidDataError +from music_assistant_models.errors import InvalidDataError, MediaNotFoundError from music_assistant_models.media_items import Artist, Podcast, PodcastEpisode, UniqueList from music_assistant.constants import DB_TABLE_PLAYLOG, DB_TABLE_PODCASTS @@ -102,9 +102,10 @@ class PodcastsController(MediaControllerBase[Podcast, Podcast]): ) -> UniqueList[PodcastEpisode]: """Return podcast episodes for the given provider podcast id.""" # always check if we have a library item for this podcast - if library_podcast := await self.get_library_item_by_prov_id( - item_id, provider_instance_id_or_domain - ): + if provider_instance_id_or_domain == "library": + library_podcast = await self.get_library_item(item_id) + if not library_podcast: + raise MediaNotFoundError(f"Podcast {item_id} not found in library") for provider_mapping in library_podcast.provider_mappings: item_id = provider_mapping.item_id provider_instance_id_or_domain = provider_mapping.provider_instance @@ -222,8 +223,10 @@ class PodcastsController(MediaControllerBase[Podcast, Podcast]): async def set_resume_position(episode: PodcastEpisode) -> None: if episode.fully_played is not None or episode.resume_position_ms: + # provider supports resume info, we can skip return - # TODO: inject resume position info here for providers that do not natively provide it + # for providers that do not natively support providing resume info, + # we fallback to the playlog db table resume_info_db_row = await self.mass.music.database.get_row( DB_TABLE_PLAYLOG, { diff --git a/music_assistant/controllers/music.py b/music_assistant/controllers/music.py index fb3c3576..a7e82e2b 100644 --- a/music_assistant/controllers/music.py +++ b/music_assistant/controllers/music.py @@ -70,6 +70,7 @@ from .media.tracks import TracksController if TYPE_CHECKING: from music_assistant_models.config_entries import CoreConfig + from music_assistant_models.media_items import Audiobook, PodcastEpisode from music_assistant.models.music_provider import MusicProvider @@ -465,7 +466,35 @@ class MusicController(CoreController): media_types_str = "(" + ",".join(f'"{x}"' for x in media_types) + ")" query = ( f"SELECT * FROM {DB_TABLE_PLAYLOG} " - f"WHERE media_type in {media_types_str} ORDER BY timestamp DESC" + f"WHERE media_type in {media_types_str} AND fully_played = 1 " + "ORDER BY timestamp DESC" + ) + db_rows = await self.mass.music.database.get_rows_from_query(query, limit=limit) + result: list[ItemMapping] = [] + available_providers = ("library", *get_global_cache_value("unique_providers", [])) + for db_row in db_rows: + result.append( + ItemMapping.from_dict( + { + "item_id": db_row["item_id"], + "provider": db_row["provider"], + "media_type": db_row["media_type"], + "name": db_row["name"], + "image": json_loads(db_row["image"]) if db_row["image"] else None, + "available": db_row["provider"] in available_providers, + } + ) + ) + return result + + @api_command("music/in_progress_items") + async def in_progress_items(self, limit: int = 10) -> list[ItemMapping]: + """Return a list of the Audiobooks and PodcastEpisodes that are in progress.""" + query = ( + f"SELECT * FROM {DB_TABLE_PLAYLOG} " + f"WHERE media_type in ('audiobook', 'podcast_episode') AND fully_played = 0 " + "AND seconds_played > 0 " + "ORDER BY timestamp DESC" ) db_rows = await self.mass.music.database.get_rows_from_query(query, limit=limit) result: list[ItemMapping] = [] @@ -861,9 +890,47 @@ class MusicController(CoreController): ctrl = self.get_controller(media_item.media_type) db_item = await ctrl.get_library_item_by_prov_id(media_item.item_id, media_item.provider) if db_item: - await self.database.execute(f"UPDATE {ctrl.db_table} SET play_count = play_count - 1") + await self.database.update( + f"UPDATE {ctrl.db_table} SET play_count = play_count - 1, " + f"last_played = 0 WHERE item_id = {db_item.item_id}" + ) await self.database.commit() + async def get_resume_position(self, media_item: Audiobook | PodcastEpisode) -> tuple[bool, int]: + """ + Get progress (resume point) details for the given audiobook or episode. + + This is a separate call to ensure the resume position is always up-to-date + and because many providers have this info present on a dedicated endpoint. + + Will be called right before playback starts to ensure the resume position is correct. + + Returns a boolean with the fully_played status + and an integer with the resume position in ms. + """ + for prov_mapping in media_item.provider_mappings: + if not (music_prov := self.mass.get_provider(prov_mapping.provider_instance)): + continue + with suppress(NotImplementedError): + return await music_prov.get_resume_position( + prov_mapping.item_id, media_item.media_type + ) + # no provider info found, fallback to library playlog + if db_entry := await self.mass.music.database.get_row( + DB_TABLE_PLAYLOG, + { + "media_type": media_item.media_type.value, + "item_id": media_item.item_id, + "provider": media_item.provider, + }, + ): + resume_position_ms = ( + db_entry["seconds_played"] * 1000 if db_entry["seconds_played"] else 0 + ) + return (db_entry["fully_played"], resume_position_ms) + + return (False, 0) + def get_controller( self, media_type: MediaType ) -> ( diff --git a/music_assistant/controllers/player_queues.py b/music_assistant/controllers/player_queues.py index c6206eb7..6dd05d72 100644 --- a/music_assistant/controllers/player_queues.py +++ b/music_assistant/controllers/player_queues.py @@ -17,7 +17,7 @@ import asyncio import random import time from types import NoneType -from typing import TYPE_CHECKING, Any, TypedDict +from typing import TYPE_CHECKING, Any, TypedDict, cast from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType from music_assistant_models.enums import ( @@ -51,12 +51,7 @@ from music_assistant_models.player import PlayerMedia from music_assistant_models.player_queue import PlayerQueue from music_assistant_models.queue_item import QueueItem -from music_assistant.constants import ( - CONF_CROSSFADE, - CONF_FLOW_MODE, - DB_TABLE_PLAYLOG, - MASS_LOGO_ONLINE, -) +from music_assistant.constants import CONF_CROSSFADE, CONF_FLOW_MODE, MASS_LOGO_ONLINE from music_assistant.helpers.api import api_command from music_assistant.helpers.audio import get_stream_details, get_stream_dsp_details from music_assistant.helpers.throttle_retry import BYPASS_THROTTLER @@ -1427,31 +1422,8 @@ class PlayerQueuesController(CoreController): raise InvalidDataError( f"Unable to resolve chapter to play for Audiobook {audio_book.name}" ) - # prefer the resume point from the provider's item - for prov_mapping in audio_book.provider_mappings: - if not (provider := self.mass.get_provider(prov_mapping.provider_instance)): - continue - if provider_item := await provider.get_audiobook(prov_mapping.item_id): - if provider_item.fully_played: - return 0 - if provider_item.resume_position_ms is not None: - return provider_item.resume_position_ms - # fallback to the resume point from the playlog (if available) - resume_info_db_row = await self.mass.music.database.get_row( - DB_TABLE_PLAYLOG, - { - "item_id": audio_book.item_id, - "provider": audio_book.provider, - "media_type": MediaType.AUDIOBOOK, - }, - ) - if resume_info_db_row is not None: - if resume_info_db_row["fully_played"]: - return 0 - if resume_info_db_row["seconds_played"]: - return int(resume_info_db_row["seconds_played"] * 1000) - - return 0 + full_played, resume_position_ms = await self.mass.music.get_resume_position(audio_book) + return 0 if full_played else resume_position_ms async def get_next_podcast_episodes( self, podcast: Podcast | None, episode: PodcastEpisode | str | None @@ -1460,7 +1432,17 @@ class PlayerQueuesController(CoreController): if podcast is None and isinstance(episode, str | NoneType): raise InvalidDataError("Either podcast or episode must be provided") if podcast is None: - podcast = episode.podcast + # single podcast episode requested + self.logger.debug( + "Fetching resume point to play for Podcast episode %s", + episode.name, + ) + episode = cast(PodcastEpisode, episode) + fully_played, resume_position_ms = await self.mass.music.get_resume_position(episode) + episode.fully_played = fully_played + episode.resume_position_ms = 0 if fully_played else resume_position_ms + return [episode] + # podcast with optional start episode requested self.logger.debug( "Fetching episode(s) and resume point to play for Podcast %s", podcast.name, @@ -1470,12 +1452,28 @@ class PlayerQueuesController(CoreController): # so we need to find the index of the episode in the list if isinstance(episode, PodcastEpisode): episode = next((x for x in all_episodes if x.uri == episode.uri), None) + # ensure we have accurate resume info + fully_played, resume_position_ms = await self.mass.music.get_resume_position(episode) + episode.resume_position_ms = 0 if fully_played else resume_position_ms elif isinstance(episode, str): episode = next((x for x in all_episodes if episode in (x.uri, x.item_id)), None) + # ensure we have accurate resume info + fully_played, resume_position_ms = await self.mass.music.get_resume_position(episode) + episode.resume_position_ms = 0 if fully_played else resume_position_ms else: # get first episode that is not fully played - episode = next((x for x in all_episodes if not x.fully_played), None) - if episode is None: + for episode in all_episodes: + if episode.fully_played: + continue + # ensure we have accurate resume info + fully_played, resume_position_ms = await self.mass.music.get_resume_position( + episode + ) + if fully_played: + continue + episode.resume_position_ms = resume_position_ms + break + else: # no episodes found that are not fully played, so we start at the beginning episode = next((x for x in all_episodes), None) if episode is None: @@ -1571,12 +1569,14 @@ class PlayerQueuesController(CoreController): self.mass.create_task(self.mass.music.mark_item_played(media_item)) return await self.get_album_tracks(media_item, start_item) if media_item.media_type == MediaType.AUDIOBOOK: - if resume_point := await self.get_audiobook_resume_point(media_item, start_item): - media_item.resume_position_ms = resume_point + # ensure we grab the correct/latest resume point info + media_item.resume_position_ms = await self.get_audiobook_resume_point( + media_item, start_item + ) return [media_item] if media_item.media_type == MediaType.PODCAST: self.mass.create_task(self.mass.music.mark_item_played(media_item)) - return await self.get_next_podcast_episodes(media_item, start_item or media_item) + return await self.get_next_podcast_episodes(media_item, start_item) if media_item.media_type == MediaType.PODCAST_EPISODE: return await self.get_next_podcast_episodes(None, media_item) # all other: single track or radio item diff --git a/music_assistant/models/music_provider.py b/music_assistant/models/music_provider.py index a56f3126..8fca1d1f 100644 --- a/music_assistant/models/music_provider.py +++ b/music_assistant/models/music_provider.py @@ -310,6 +310,21 @@ class MusicProvider(Provider): if ProviderFeature.SIMILAR_TRACKS in self.supported_features: raise NotImplementedError + async def get_resume_position(self, item_id: str, media_type: MediaType) -> tuple[bool, int]: + """ + Get progress (resume point) details for the given Audiobook or Podcast episode. + + This is a separate call from the regular get_item call to ensure the resume position + is always up-to-date and because a lot providers have this info present on a dedicated + endpoint. + + Will be called right before playback starts to ensure the resume position is correct. + + Returns a boolean with the fully_played status + and an integer with the resume position in ms. + """ + raise NotImplementedError + async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: """Get streamdetails for a track/radio/chapter/episode.""" raise NotImplementedError @@ -631,27 +646,20 @@ class MusicProvider(Provider): library_item = await controller.update_item_in_library( library_item.item_id, prov_item ) + # check if resume_position_ms or fully_played changed (audiobook only) + resume_pos_prov = getattr(prov_item, "resume_position_ms", None) + fully_played_prov = getattr(prov_item, "fully_played", None) if ( - getattr(library_item, "resume_position_ms", None) - != (resume_pos_prov := getattr(prov_item, "resume_position_ms", None)) - and resume_pos_prov is not None - ): - # resume_position_ms changed (audiobook only) - library_item.resume_position_ms = resume_pos_prov - library_item = await controller.update_item_in_library( - library_item.item_id, prov_item - ) - if ( - getattr(library_item, "fully_played", None) - != (fully_played_prov := getattr(prov_item, "fully_played", None)) + resume_pos_prov is not None and fully_played_prov is not None + and ( + getattr(library_item, "resume_position_ms", None) != resume_pos_prov + or getattr(library_item, "fully_played", None) != fully_played_prov + ) ): - # fully_played changed (audiobook only) - library_item.fully_played = fully_played_prov library_item = await controller.update_item_in_library( library_item.item_id, prov_item ) - cur_db_ids.add(library_item.item_id) await asyncio.sleep(0) # yield to eventloop except MusicAssistantError as err: self.logger.warning( diff --git a/music_assistant/providers/_template_music_provider/__init__.py b/music_assistant/providers/_template_music_provider/__init__.py index ccdf7dd0..9f6fdfc3 100644 --- a/music_assistant/providers/_template_music_provider/__init__.py +++ b/music_assistant/providers/_template_music_provider/__init__.py @@ -354,7 +354,7 @@ class MyDemoMusicprovider(MusicProvider): ) -> None: """Remove track(s) from playlist.""" # Remove track(s) from a playlist. - # This is only called if the provider supports the EDPLAYLIST_TRACKS_EDITIT feature. + # This is only called if the provider supports the PLAYLIST_TRACKS_EDIT feature. async def create_playlist(self, name: str) -> Playlist: # type: ignore[empty-body] """Create a new playlist on provider with given name.""" @@ -368,6 +368,22 @@ class MyDemoMusicprovider(MusicProvider): # Get a list of similar tracks based on the provided track. # This is only called if the provider supports the SIMILAR_TRACKS feature. + async def get_resume_position(self, item_id: str, media_type: MediaType) -> tuple[bool, int]: # type: ignore[empty-body] + """ + Get progress (resume point) details for the given Audiobook or Podcast episode. + + This is a separate call from the regular get_item call to ensure the resume position + is always up-to-date and because a lot providers have this info present on a dedicated + endpoint. + + Will be called right before playback starts to ensure the resume position is correct. + + Returns a boolean with the fully_played status + and an integer with the resume position in ms. + """ + # optional function to get the resume position of a audiobook or podcast episode + # only implement this if your provider supports providing this information + async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: """Get streamdetails for a track/radio.""" # Get stream details for a track or radio.