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
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,
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,
)
)
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,