From d6399d2d161df83cd4b91e01ef4915f4bb508117 Mon Sep 17 00:00:00 2001 From: OzGav Date: Sat, 7 Feb 2026 09:39:36 +1100 Subject: [PATCH] Fix local file system audiobook edge cases (#3080) --- .../providers/filesystem_local/__init__.py | 125 +++++++++++++----- 1 file changed, 90 insertions(+), 35 deletions(-) diff --git a/music_assistant/providers/filesystem_local/__init__.py b/music_assistant/providers/filesystem_local/__init__.py index b1105848..d5d93e99 100644 --- a/music_assistant/providers/filesystem_local/__init__.py +++ b/music_assistant/providers/filesystem_local/__init__.py @@ -1156,15 +1156,30 @@ class LocalFileSystemProvider(MusicProvider): return artist async def _parse_audiobook(self, file_item: FileSystemItem, tags: AudioTags) -> Audiobook: - """Parse full Audiobook details from file tags.""" - # an audiobook can either be a single file with chapters embedded in the file - # or a folder with multiple files (each file being a chapter) - # we only scrape all tags from the first file in the folder - if tags.track and tags.track > 1: - raise IsChapterFile - # in case of a multi-file audiobook, the title is the chapter name - # and the album is the actual audiobook name - # so we prefer the album name as the audiobook name + """Parse Audiobook details from file tags. + + Audiobooks can be single files with embedded chapters or multiple files per folder. + Only the first file (by track number or alphabetically) is processed as the audiobook. + """ + # Skip files that aren't the first chapter + track_tag = tags.tags.get("track") + if track_tag: + track_num = try_parse_int(str(track_tag).split("/")[0], None) + if track_num and track_num > 1: + raise IsChapterFile + else: + # No track tag - only process the first file alphabetically + abs_path = self.get_absolute_path(file_item.parent_path) + for item in await asyncio.to_thread( + sorted_scandir, self.base_path, abs_path, sort=True + ): + if item.is_dir or item.ext not in AUDIOBOOK_EXTENSIONS: + continue + if item.absolute_path != file_item.absolute_path: + raise IsChapterFile + break + + # For multi-file audiobooks, album tag is the book name, title is the chapter name if tags.album: book_name = tags.album sort_name = tags.album_sort @@ -1795,12 +1810,51 @@ class LocalFileSystemProvider(MusicProvider): async def _get_chapters_for_audiobook( self, audiobook_file_item: FileSystemItem, tags: AudioTags ) -> tuple[int, list[MediaItemChapter]]: - """Return the chapters for an audiobook.""" + """Return chapters for an audiobook. + + Chapter sources in order of preference: + 1. Multiple files with track tags - sorted by track number + 2. Single file with embedded chapters - use embedded chapter markers + 3. Multiple files without track tags - sorted alphabetically (fallback) + """ chapters: list[MediaItemChapter] = [] all_chapter_files: list[tuple[str, float]] = [] total_duration = 0.0 - if tags.chapters: - # The chapters are embedded in the file tags + + # Scan folder for chapter files, separating tagged from untagged + chapter_file_tags: list[AudioTags] = [] + untagged_file_tags: list[AudioTags] = [] + abs_path = self.get_absolute_path(audiobook_file_item.parent_path) + for item in await asyncio.to_thread(sorted_scandir, self.base_path, abs_path, sort=True): + if "." not in item.relative_path or item.is_dir: + continue + if item.ext not in AUDIOBOOK_EXTENSIONS: + continue + item_tags = await async_parse_tags(item.absolute_path, item.file_size) + if not (tags.album == item_tags.album or (item_tags.tags.get("title") is None)): + continue + if item_tags.tags.get("track") is None: + untagged_file_tags.append(item_tags) + else: + chapter_file_tags.append(item_tags) + + # Determine chapter source + use_embedded = False + use_alphabetical = False + + if len(chapter_file_tags) > 1: + chapter_file_tags.sort(key=lambda x: (x.disc or 0, x.track or 0)) + elif len(chapter_file_tags) <= 1 and tags.chapters: + use_embedded = True + elif len(untagged_file_tags) > 1: + use_alphabetical = True + chapter_file_tags = untagged_file_tags + self.logger.info( + "Audiobook files have no track tags, using alphabetical order: %s", + tags.album, + ) + + if use_embedded: chapters = [ MediaItemChapter( position=chapter.chapter_id, @@ -1811,31 +1865,24 @@ class LocalFileSystemProvider(MusicProvider): for chapter in tags.chapters ] total_duration = try_parse_int(tags.duration) or 0 + self.logger.log( + VERBOSE_LOG_LEVEL, + "Audiobook '%s': %d embedded chapters, duration=%d", + tags.album, + len(chapters), + int(total_duration), + ) else: - # there could be multiple files for this audiobook in the same folder, - # where each file is a portion/chapter of the audiobook - # try to gather the chapters by traversing files in the same folder - chapter_file_tags: list[AudioTags] = [] - abs_path = self.get_absolute_path(audiobook_file_item.parent_path) - for item in await asyncio.to_thread( - sorted_scandir, self.base_path, abs_path, sort=True - ): - if "." not in item.relative_path or item.is_dir: - continue - if item.ext not in AUDIOBOOK_EXTENSIONS: - continue - item_tags = await async_parse_tags(item.absolute_path, item.file_size) - if not (tags.album == item_tags.album or (item_tags.tags.get("title") is None)): - continue - if item_tags.track is None: + for position, chapter_tags in enumerate(chapter_file_tags, start=1): + if chapter_tags.duration is None: + self.logger.warning( + "Chapter file has no duration, skipping: %s", + chapter_tags.filename, + ) continue - chapter_file_tags.append(item_tags) - chapter_file_tags.sort(key=lambda x: (x.disc or 0, x.track or 0)) - for chapter_tags in chapter_file_tags: - assert chapter_tags.duration is not None chapters.append( MediaItemChapter( - position=chapter_tags.track or 0, + position=position if use_alphabetical else (chapter_tags.track or position), name=chapter_tags.title, start=total_duration, end=total_duration + chapter_tags.duration, @@ -1848,9 +1895,17 @@ class LocalFileSystemProvider(MusicProvider): ) ) total_duration += chapter_tags.duration + sort_method = "alphabetical" if use_alphabetical else "track" + self.logger.log( + VERBOSE_LOG_LEVEL, + "Audiobook '%s': %d files (%s order), duration=%d", + tags.album, + len(chapters), + sort_method, + int(total_duration), + ) - # store chapter files in cache - # for easy access from streamdetails + # Cache chapter files for streaming await self.cache.set( key=audiobook_file_item.relative_path, data=all_chapter_files, -- 2.34.1