Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions music_assistant/controllers/media/audiobooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from music_assistant_models.enums import MediaType, ProviderFeature
from music_assistant_models.media_items import Audiobook, ProviderMapping, UniqueList
from music_assistant_models.media_items.helpers import AudiobookSeries

from music_assistant.constants import DB_TABLE_AUDIOBOOKS, DB_TABLE_PLAYLOG
from music_assistant.controllers.media.base import MediaControllerBase
Expand Down Expand Up @@ -63,6 +64,7 @@ def __init__(self, mass: MusicAssistant) -> None:
# register (extra) api handlers
api_base = self.api_base
self.mass.register_api_command(f"music/{api_base}/audiobook_versions", self.versions)
self.mass.register_api_command(f"music/{api_base}/get_series", self.series)

async def library_items(
self,
Expand All @@ -73,6 +75,7 @@ async def library_items(
order_by: str = "sort_name",
provider: str | list[str] | None = None,
genre: int | list[int] | None = None,
without_series: bool | None = None,
**kwargs: Any,
) -> list[Audiobook]:
"""Get in-database audiobooks.
Expand All @@ -84,12 +87,18 @@ async def library_items(
:param order_by: Order by field (e.g. 'sort_name', 'timestamp_added').
:param provider: Filter by provider instance ID (single string or list).
:param genre: Filter by genre id(s).
:param without_series: Do not return audiobooks which are part of a series
"""
extra_query_params: dict[str, Any] = {}
extra_query_parts: list[str] = []
extra_join_parts: list[str] = []
if session_user := get_current_user():
extra_join_parts = [f"AND playlog.userid = '{session_user.user_id}'"]
if without_series:
extra_query_parts = [
"WHERE (json_extract(audiobooks.metadata, '$.series') IS NULL "
"OR json_extract(audiobooks.metadata, '$.series') = '[]')",
]
result = await self.get_library_items_by_query(
favorite=favorite,
search=search,
Expand Down Expand Up @@ -367,3 +376,53 @@ async def _set_playlog(self, db_id: int, media_item: Audiobook) -> None:
},
allow_replace=True,
)

async def series(
self,
) -> list[AudiobookSeries]:
"""Get all available audiobook series.

:param limit: Maximum number of items to return.
:param offset: Number of items to skip.
"""
# key is the series' title
series_dict: dict[str, list[Audiobook]] = {}
audiobooks_with_series = await self.get_library_items_by_query(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think limit and offset are now applied on AudioBooks here rather than on the series itself?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, that's true. I removed it completely now, as I don't think that I can apply this offset by pure db methods? The default limit for items is 500, and having more than 500 series would be extraordinary I think. Not certain here.

extra_query_parts=[
"WHERE json_extract(audiobooks.metadata, '$.series') IS NOT NULL "
"AND json_extract(audiobooks.metadata, '$.series') != '[]'",
],
)
for audiobook in audiobooks_with_series:
if audiobook.metadata.series is None:
# this should never happen
continue
for series_info in audiobook.metadata.series:
audiobook_list = series_dict.get(series_info.title, [])
audiobook_list.append(audiobook)
series_dict[series_info.title] = audiobook_list

result: list[AudiobookSeries] = []
# Sort series, first by number then alphabetically
for series_title, audiobook_list in series_dict.items():
audiobooks_with_number: list[tuple[Audiobook, float]] = []
audiobooks_with_string: list[tuple[Audiobook, str]] = []
audiobooks_with_none: list[Audiobook] = []
for audiobook in audiobook_list:
assert audiobook.metadata.series is not None # for type checking
series_info = next(x for x in audiobook.metadata.series if x.title == series_title)
if series_info.sequence is None:
audiobooks_with_none.append(audiobook)
continue
try:
sort_by = float(series_info.sequence)
audiobooks_with_number.append((audiobook, sort_by))
except ValueError:
audiobooks_with_string.append((audiobook, str(series_info.sequence)))
final_list = [x[0] for x in sorted(audiobooks_with_number, key=lambda x: x[1])]
final_list.extend([x[0] for x in sorted(audiobooks_with_string, key=lambda x: x[1])])
final_list.extend(audiobooks_with_none)

result.append(AudiobookSeries(title=series_title, audiobooks=final_list))

return result
12 changes: 11 additions & 1 deletion music_assistant/providers/audiobookshelf/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
ItemMapping,
MediaItemChapter,
MediaItemImage,
MediaItemSeries,
ProviderMapping,
UniqueList,
)
Expand Down Expand Up @@ -221,7 +222,7 @@ def parse_podcast_episode(

def parse_audiobook(
*,
abs_audiobook: AbsLibraryItemExpandedBook | AbsLibraryItemMinifiedBook,
abs_audiobook: AbsLibraryItemExpandedBook,
instance_id: str,
domain: str,
token: str | None,
Expand Down Expand Up @@ -266,6 +267,15 @@ def parse_audiobook(
year=int(abs_audiobook.media.metadata.published_year), month=1, day=1
)

book_series: list[MediaItemSeries] = []
for abs_series_sequence in abs_audiobook.media.metadata.series:
book_series.append(
MediaItemSeries(title=abs_series_sequence.name, sequence=abs_series_sequence.sequence)
)

if book_series:
mass_audiobook.metadata.series = UniqueList(book_series)

if abs_audiobook.media.metadata.genres is not None:
mass_audiobook.metadata.genres = set(abs_audiobook.media.metadata.genres)

Expand Down
Loading