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:
'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)
# 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(
# 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,
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,
+ )
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
) -> 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
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,
{
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
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] = []
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
) -> (
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 (
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
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
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,
# 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:
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
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
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(
) -> 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."""
# 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.