Skip to content

Commit

Permalink
Merge branch 'dev' into fix-support-links
Browse files Browse the repository at this point in the history
  • Loading branch information
marcelveldt authored Jan 3, 2025
2 parents 69ed88c + dee5cc8 commit e75d72a
Show file tree
Hide file tree
Showing 12 changed files with 396 additions and 208 deletions.
66 changes: 34 additions & 32 deletions music_assistant/controllers/media/audiobooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

from __future__ import annotations

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.media_items import Artist, Audiobook, Chapter, 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,
Expand Down Expand Up @@ -99,20 +100,15 @@ async def chapters(
provider_instance_id_or_domain: str,
) -> UniqueList[Chapter]:
"""Return audiobook chapters for the given provider audiobook id."""
# always check if we have a library item for this audiobook
library_audiobook = await self.get_library_item_by_prov_id(
if library_audiobook := await self.get_library_item_by_prov_id(
item_id, provider_instance_id_or_domain
)
if not library_audiobook:
return await self._get_provider_audiobook_chapters(
item_id, provider_instance_id_or_domain
)
# return items from first/only provider
for provider_mapping in library_audiobook.provider_mappings:
return await self._get_provider_audiobook_chapters(
provider_mapping.item_id, provider_mapping.provider_instance
)
return UniqueList()
):
# return items from first/only provider
for provider_mapping in library_audiobook.provider_mappings:
return await self._get_provider_audiobook_chapters(
provider_mapping.item_id, provider_mapping.provider_instance
)
return await self._get_provider_audiobook_chapters(item_id, provider_instance_id_or_domain)

async def versions(
self,
Expand Down Expand Up @@ -210,27 +206,33 @@ async def _get_provider_audiobook_chapters(
prov: MusicProvider = self.mass.get_provider(provider_instance_id_or_domain)
if prov is None:
return []
# prefer cache items (if any) - for streaming providers only
cache_base_key = prov.lookup_key
cache_key = f"audiobook.{item_id}"
if (
prov.is_streaming_provider
and (cache := await self.mass.cache.get(cache_key, base_key=cache_base_key)) is not None
):
return [Chapter.from_dict(x) for x in cache]
# no items in cache - get listing from provider
# grab the chapters from the provider
# note that we do not cache any of this because its
# always a rather small list and we want fresh resume info
items = await prov.get_audiobook_chapters(item_id)
# store (serializable items) in cache
if prov.is_streaming_provider:
self.mass.create_task(
self.mass.cache.set(
cache_key,
[x.to_dict() for x in items],
expiration=3600,
base_key=cache_base_key,
),

async def set_resume_position(chapter: Chapter) -> None:
if chapter.resume_position_ms is not None:
return
if chapter.fully_played is not None:
return
# TODO: inject resume position info here for providers that do not natively provide it
resume_info_db_row = await self.mass.music.database.get_row(
DB_TABLE_PLAYLOG,
{
"item_id": chapter.item_id,
"provider": prov.lookup_key,
"media_type": MediaType.CHAPTER,
},
)
if resume_info_db_row is None:
return
if resume_info_db_row["seconds_played"] is not None:
chapter.resume_position_ms = resume_info_db_row["seconds_played"] * 1000
if resume_info_db_row["fully_played"] is not None:
chapter.fully_played = resume_info_db_row["fully_played"]

await asyncio.gather(*[set_resume_position(chapter) for chapter in items])
return items

async def _get_provider_dynamic_base_tracks(
Expand Down
8 changes: 4 additions & 4 deletions music_assistant/controllers/media/playlists.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
ProviderUnavailableError,
UnsupportedFeaturedException,
)
from music_assistant_models.media_items import Playlist, PlaylistTrack, Track
from music_assistant_models.media_items import Playlist, Track

from music_assistant.constants import DB_TABLE_PLAYLISTS
from music_assistant.helpers.json import serialize_to_json
Expand Down Expand Up @@ -50,7 +50,7 @@ async def tracks(
item_id: str,
provider_instance_id_or_domain: str,
force_refresh: bool = False,
) -> AsyncGenerator[PlaylistTrack, None]:
) -> AsyncGenerator[Track, None]:
"""Return playlist tracks for the given provider playlist id."""
playlist = await self.get(
item_id,
Expand Down Expand Up @@ -337,7 +337,7 @@ async def _get_provider_playlist_tracks(
cache_checksum: Any = None,
page: int = 0,
force_refresh: bool = False,
) -> list[PlaylistTrack]:
) -> list[Track]:
"""Return playlist tracks for the given provider playlist id."""
assert provider_instance_id_or_domain != "library"
provider: MusicProvider = self.mass.get_provider(provider_instance_id_or_domain)
Expand All @@ -359,7 +359,7 @@ async def _get_provider_playlist_tracks(
)
is not None
):
return [PlaylistTrack.from_dict(x) for x in cache]
return [Track.from_dict(x) for x in cache]
# no items in cache (or force_refresh) - get listing from provider
items = await provider.get_playlist_tracks(item_id, page=page)
# store (serializable items) in cache
Expand Down
68 changes: 36 additions & 32 deletions music_assistant/controllers/media/podcasts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

from __future__ import annotations

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.media_items import Artist, Episode, Podcast, UniqueList

from music_assistant.constants import DB_TABLE_PODCASTS
from music_assistant.constants import DB_TABLE_PLAYLOG, DB_TABLE_PODCASTS
from music_assistant.controllers.media.base import MediaControllerBase
from music_assistant.helpers.compare import (
compare_media_item,
Expand Down Expand Up @@ -100,19 +101,15 @@ async def episodes(
) -> UniqueList[Episode]:
"""Return podcast episodes for the given provider podcast id."""
# always check if we have a library item for this podcast
library_podcast = await self.get_library_item_by_prov_id(
if library_podcast := await self.get_library_item_by_prov_id(
item_id, provider_instance_id_or_domain
)
if not library_podcast:
return await self._get_provider_podcast_episodes(
item_id, provider_instance_id_or_domain
)
# return items from first/only provider
for provider_mapping in library_podcast.provider_mappings:
return await self._get_provider_podcast_episodes(
provider_mapping.item_id, provider_mapping.provider_instance
)
return UniqueList()
):
# return items from first/only provider
for provider_mapping in library_podcast.provider_mappings:
return await self._get_provider_podcast_episodes(
provider_mapping.item_id, provider_mapping.provider_instance
)
return await self._get_provider_podcast_episodes(item_id, provider_instance_id_or_domain)

async def versions(
self,
Expand Down Expand Up @@ -202,27 +199,34 @@ async def _get_provider_podcast_episodes(
prov: MusicProvider = self.mass.get_provider(provider_instance_id_or_domain)
if prov is None:
return []
# prefer cache items (if any) - for streaming providers only
cache_base_key = prov.lookup_key
cache_key = f"podcast.{item_id}"
if (
prov.is_streaming_provider
and (cache := await self.mass.cache.get(cache_key, base_key=cache_base_key)) is not None
):
return [Episode.from_dict(x) for x in cache]
# no items in cache - get listing from provider
items = await prov.get_podcast_episodes(item_id)
# store (serializable items) in cache
if prov.is_streaming_provider:
self.mass.create_task(
self.mass.cache.set(
cache_key,
[x.to_dict() for x in items],
expiration=3600,
base_key=cache_base_key,
),
# grab the episodes from the provider
# note that we do not cache any of this because its
# always a rather small list and we want fresh resume info
items = await prov.get_audiobook_chapters(item_id)

async def set_resume_position(episode: Episode) -> None:
if episode.resume_position_ms is not None:
return
if episode.fully_played is not None:
return
# TODO: inject resume position info here for providers that do not natively provide it
resume_info_db_row = await self.mass.music.database.get_row(
DB_TABLE_PLAYLOG,
{
"item_id": episode.item_id,
"provider": prov.lookup_key,
"media_type": MediaType.CHAPTER,
},
)
if resume_info_db_row is None:
return
if resume_info_db_row["seconds_played"] is not None:
episode.resume_position_ms = resume_info_db_row["seconds_played"] * 1000
if resume_info_db_row["fully_played"] is not None:
episode.fully_played = resume_info_db_row["fully_played"]

await asyncio.gather(*[set_resume_position(chapter) for chapter in items])
return items
return items

async def _get_provider_dynamic_base_tracks(
Expand Down
59 changes: 56 additions & 3 deletions music_assistant/controllers/music.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
CONF_SYNC_INTERVAL = "sync_interval"
CONF_DELETED_PROVIDERS = "deleted_providers"
CONF_ADD_LIBRARY_ON_PLAY = "add_library_on_play"
DB_SCHEMA_VERSION: Final[int] = 10
DB_SCHEMA_VERSION: Final[int] = 11


class MusicController(CoreController):
Expand Down Expand Up @@ -748,8 +748,14 @@ async def get_loudness(

return None

@api_command("music/mark_played")
async def mark_item_played(
self, media_type: MediaType, item_id: str, provider_instance_id_or_domain: str
self,
media_type: MediaType,
item_id: str,
provider_instance_id_or_domain: str,
fully_played: bool | None = None,
seconds_played: int | None = None,
) -> None:
"""Mark item as played in playlog."""
timestamp = utc_timestamp()
Expand All @@ -776,6 +782,8 @@ async def mark_item_played(
"item_id": item_id,
"provider": prov_key,
"media_type": media_type.value,
"fully_played": fully_played,
"seconds_played": seconds_played,
"timestamp": timestamp,
},
allow_replace=True,
Expand All @@ -800,6 +808,33 @@ async def mark_item_played(
)
await self.database.commit()

@api_command("music/mark_unplayed")
async def mark_item_unplayed(
self, media_type: MediaType, item_id: str, provider_instance_id_or_domain: str
) -> None:
"""Mark item as unplayed in playlog."""
if provider_instance_id_or_domain == "library":
prov_key = "library"
elif prov := self.mass.get_provider(provider_instance_id_or_domain):
prov_key = prov.lookup_key
else:
prov_key = provider_instance_id_or_domain
# update generic playlog table
await self.database.delete(
DB_TABLE_PLAYLOG,
{
"item_id": item_id,
"provider": prov_key,
"media_type": media_type.value,
},
)
# also update playcount in library table
ctrl = self.get_controller(media_type)
db_item = await ctrl.get_library_item_by_prov_id(item_id, provider_instance_id_or_domain)
if db_item:
await self.database.execute(f"UPDATE {ctrl.db_table} SET play_count = play_count - 1")
await self.database.commit()

def get_controller(
self, media_type: MediaType
) -> (
Expand All @@ -824,8 +859,14 @@ def get_controller(
return self.playlists
if media_type == MediaType.AUDIOBOOK:
return self.audiobooks
if media_type == MediaType.CHAPTER:
return self.audiobooks
if media_type == MediaType.EPISODE:
return self.podcasts
if media_type == MediaType.PODCAST:
return self.podcasts
if media_type == MediaType.EPISODE:
return self.podcasts
return None

def get_unique_providers(self) -> set[str]:
Expand Down Expand Up @@ -1162,11 +1203,21 @@ async def __migrate_database(self, prev_version: int) -> None:
)
await self.database.execute("DROP TABLE IF EXISTS track_loudness")

if prev_version <= 9:
if prev_version <= 10:
# recreate db tables for audiobooks and podcasts due to some mistakes in early version
await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_AUDIOBOOKS}")
await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_PODCASTS}")
await self.__create_database_tables()
try:
await self.database.execute(
f"ALTER TABLE {DB_TABLE_PLAYLOG} ADD COLUMN fully_played BOOLEAN"
)
await self.database.execute(
f"ALTER TABLE {DB_TABLE_PLAYLOG} ADD COLUMN seconds_played INTEGER"
)
except Exception as err:
if "duplicate column" not in str(err):
raise

# save changes
await self.database.commit()
Expand Down Expand Up @@ -1197,6 +1248,8 @@ async def __create_database_tables(self) -> None:
[provider] TEXT NOT NULL,
[media_type] TEXT NOT NULL DEFAULT 'track',
[timestamp] INTEGER DEFAULT 0,
[fully_played] BOOLEAN,
[seconds_played] INTEGER,
UNIQUE(item_id, provider, media_type));"""
)
await self.database.execute(
Expand Down
Loading

0 comments on commit e75d72a

Please sign in to comment.