Fix local file system audiobook edge cases (#3080)
authorOzGav <gavnosp@hotmail.com>
Fri, 6 Feb 2026 22:39:36 +0000 (09:39 +1100)
committerGitHub <noreply@github.com>
Fri, 6 Feb 2026 22:39:36 +0000 (23:39 +0100)
music_assistant/providers/filesystem_local/__init__.py

index b11058485c64bcf0723ea6190d8ec84fe1f4bdae..d5d93e991a6078def1527380aa0a303cfb88b60e 100644 (file)
@@ -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,