Repository: PaulWoitaschek/Voice (language: kotlin)

Issue:
newest version has killed chapters for my entire library
### Overview

User for ~3 years, since the new version the app hasn't been identifying chapters for any of my m4b files, this includes files that previously worked fine on older versions, I've replicated the error on 2x pixel phones both running Graphene OS.  This has only been present since the new version that changes how chapters are parsed.

### How to reproduce

Open the app
Tap any previously added book or add a new one
Playback starts
No chapter select dropdown present, starts playing back as a single hours-long audio file

### Version

8.4.2 - f droid main repository

### Android Version

Graphene OS 2025051900

### Device Model

Pixel 8a; Pixel 7


Discussion hints:
Can confirm that rolling back to 8.3.2 resolves the issue completely
Thanks! Could you send me one file to my mail which previously had chapters but now doesn't?
I'm pinning this issue and will stop the rollout until this is fixed.
If someone could send files which reproduce this issue, that would be really helpful!
I face the same issue with Matroska Containers and can confirm that it is fixed when rolling back to 8.3.2. In addition, the cover art is also not parsed on the latest version, but on 8.3.2 (I don’t know if this is related).
I’ll send you an e-mail with a test file you can use for reproducing this.
On my end, I needed to clear the app data when updating from 8.3.2 to 8.5.0 to reproduce the issue, as it otherwise still used the cached data from the successful metadata import on 8.3.2.
Thank you. I could reproduce it with the MKA chapters. If somebody has other media, especially M4A and m4b files with non-recognized chapters, please send them to me. 
It wouldnt let me upload a m4b so i threw .txt on the end of it. The chapters can be detected in vlc but not voice.

[Itsa_test.m4b.txt](https://github.com/user-attachments/files/20557496/Itsa_test.m4b.txt)
And here is my function for creating the audiobook in case its helpful. (that produced that m4b)

```python
def create_audiobook_m4b(chapters_audio_data, metadata, cover_image_path=None, output_path="audiobook.m4b", chapter_pause_duration_s=1.0, bitrate="128k"):
    """
    Creates an M4B audiobook file with chapters, metadata, and optional cover image,
    mimicking epub2tts.py's ffmpeg strategy for better chapter compatibility.
    chapters_audio_data is a list of tuples: (title, samplerate, numpy_audio_array)
    """
    with tempfile.TemporaryDirectory() as temp_dir_name:
        temp_dir = Path(temp_dir_name)

        # Write chapter audio files (as WAV) to temporary directory
        chapter_raw_audio_files = []
        # Ensure consistent samplerate for concatenation, resample if necessary.
        # For simplicity, this example assumes all generated audio has the same samplerate (model.sr).
        # If not, resampling would be needed here before writing to file or during ffmpeg concat.
        target_sr = None
        for i, (title, sr, audio_array) in enumerate(chapters_audio_data):
            if audio_array.size == 0:
                print(f"Skipping empty audio for chapter: {title}")
                continue
            if target_sr is None:
                target_sr = sr
            elif target_sr != sr:
                # This part would require resampling. For now, we assume consistency.
                print(f"Warning: Samplerate mismatch for chapter {title}. Expected {target_sr}, got {sr}. Playback issues may occur.")

            chapter_file = temp_dir / f"chapter_{i:03d}.wav"
            sf.write(chapter_file, audio_array, sr)
            chapter_raw_audio_files.append(chapter_file)

        if not chapter_raw_audio_files:
            raise ValueError("No valid audio chapters to process for the audiobook.")

        # Create concat list file including silences
        concat_list_file = temp_dir / "concat_list.txt"
        with open(concat_list_file, 'w', encoding='utf-8') as f:
            for i, chap_file in enumerate(chapter_raw_audio_files):
                # ffmpeg needs relative paths from the perspective of the concat file, or absolute paths.
                # Using resolve() for absolute paths is safer.
                f.write(f"file '{chap_file.resolve()}'\n")
                if i < len(chapter_raw_audio_files) - 1 and chapter_pause_duration_s > 0:
                    silence_file = temp_dir / f"silence_{i:03d}.wav"
                    # Ensure target_sr is defined; it should be from the first valid chapter.
                    pause_samples = int(target_sr * chapter_pause_duration_s)
                    silent_audio = np.zeros(pause_samples, dtype=np.float32)
                    sf.write(silence_file, silent_audio, target_sr)
                    f.write(f"file '{silence_file.resolve()}'\n")

        # --- First FFmpeg Pass: Create intermediate M4A audio file ---
        intermediate_m4a_file = temp_dir / "intermediate_audio.m4a"
        concat_cmd = [
            'ffmpeg', '-y',
            '-f', 'concat', '-safe', '0', '-i', str(concat_list_file),
            '-codec:a', 'aac',  # Standard AAC codec
            '-b:a', bitrate,    # Use the provided bitrate
            '-f', 'ipod',       # CRUCIAL: Specify ipod container format for M4A
            str(intermediate_m4a_file)
        ]
        print(f"Running FFmpeg (Pass 1 - Audio Concat): {' '.join(concat_cmd)}")
        try:
            process_result = subprocess.run(concat_cmd, check=True, capture_output=True, text=True)
            if process_result.stderr:
                print("FFMPEG (Pass 1) stderr:", process_result.stderr)
        except subprocess.CalledProcessError as e:
            print("Error during FFmpeg Pass 1 (Audio Concat):")
            print("Command:", ' '.join(e.cmd))
            print("Return code:", e.returncode)
            print("Stdout:", e.stdout)
            print("Stderr:", e.stderr)
            raise

        # --- Create FFMETADATAFILE ---
        ffmetadata_file_path = temp_dir / "FFMETADATAFILE.txt" # Exact name as in epub2tts for consistency
        with open(ffmetadata_file_path, 'w', encoding='utf-8') as f:
            f.write(';FFMETADATA1\n')
            # Write main metadata (use uppercase for common tags like epub2tts)
            # Prefer 'title' from metadata, fallback to 'album' or a default.
            album_title = metadata.get('title', metadata.get('album', 'Untitled Audiobook'))
            f.write(f"TITLE={metadata.get('title', 'Untitled Audiobook')}\n") # Specific Title tag
            f.write(f"ALBUM={album_title}\n") # Album tag
            f.write(f"ARTIST={metadata.get('author', 'Unknown Author')}\n") # Artist tag
            if metadata.get('genre'):
                f.write(f"GENRE={metadata.get('genre')}\n")
            if metadata.get('year'):
                f.write(f"DATE={metadata.get('year')}\n") # DATE is more standard than YEAR for ffmpeg metadata
            # epub2tts uses DESCRIPTION, your script had 'comment'
            description_text = metadata.get('comment', 'Made with Chatterbox TTS Pro')
            if 'narrator' in metadata:
                 description_text += f" Narrated by {metadata['narrator']}."
            f.write(f"DESCRIPTION={description_text}\n")


            # Write chapter metadata
            current_time_ms = 0
            # Re-calculate durations based on the actual files that went into concat,
            # as ffmpeg will use these to build the timeline.
            # This means we need to sum durations of chapter files and silence files.
            # Note: get_duration would be a helper function if we didn't have the audio arrays directly.
            # Since we have chapters_audio_data, we can use that.
            for i, (title, sr, audio_array) in enumerate(chapters_audio_data):
                if audio_array.size == 0:
                    continue

                duration_ms = int((len(audio_array) / sr) * 1000)
                end_time_ms = current_time_ms + duration_ms

                f.write('\n[CHAPTER]\n')
                f.write('TIMEBASE=1/1000\n') # Millisecond timebase
                f.write(f'START={current_time_ms}\n')
                f.write(f'END={end_time_ms}\n')
                f.write(f'title={title if title else f"Chapter {i+1}"}\n')

                current_time_ms = end_time_ms
                if i < len(chapters_audio_data) - 1: # Add pause duration if not the last chapter
                    current_time_ms += int(chapter_pause_duration_s * 1000)

        # --- Second FFmpeg Pass: Combine M4A with Metadata and Cover ---
        final_ffmpeg_cmd = [
            'ffmpeg', '-y',
            '-i', str(intermediate_m4a_file),   # Input 0: intermediate M4A audio
            '-i', str(ffmetadata_file_path),    # Input 1: FFMETADATAFILE
            '-map_metadata', '1',                # Map metadata from input 1 (FFMETADATAFILE)
            '-codec', 'copy',                    # Copy audio and video (if cover added) streams
            '-f', 'ipod',                        # CRUCIAL: Specify ipod container for final M4B
        ]

        # Add cover image if available, mapping it as the third input
        if cover_image_path and Path(cover_image_path).exists():
            final_ffmpeg_cmd.extend([
                '-i', str(Path(cover_image_path).resolve()), # Input 2: Cover image
                '-map', '0:a',                   # Map audio from input 0
                '-map', '2:v',                   # Map video (cover) from input 2
                '-disposition:v', 'attached_pic', # Mark as cover art
                 # Set common cover image metadata (optional, but good practice)
                '-metadata:s:v', 'title="Album cover"',
                '-metadata:s:v', 'comment="Cover (front)"'
            ])
        else:
            # If no cover, just map the audio from input 0
            final_ffmpeg_cmd.extend(['-map', '0:a'])


        final_ffmpeg_cmd.append(str(Path(output_path).resolve()))

        print(f"Running FFmpeg (Pass 2 - Final M4B): {' '.join(final_ffmpeg_cmd)}")
        try:
            process_result = subprocess.run(final_ffmpeg_cmd, check=True, capture_output=True, text=True)
            if process_result.stderr:
                print("FFMPEG (Pass 2) stderr:", process_result.stderr)
        except subprocess.CalledProcessError as e:
            print("Error during FFmpeg Pass 2 (Final M4B creation):")
            print("Command:", ' '.join(e.cmd))
            print("Return code:", e.returncode)
            print("Stdout:", e.stdout)
            print("Stderr:", e.stderr)
            raise

    return str(Path(output_path).resolve())
```
@Lastofthefirst 

Which version of voice did you test this with?
8.4.3
@Lastofthefirst 
Can you confirm that:
- if you add the file as a single-file
  - it shows no chapters
- if you add it as part of a folder
  - it shows chapters

Pull request: fix: Parse mpeg4 chapter tracks correctly
Fixes #2840

Test command: ./gradlew :scanner:testDebugUnitTest --tests "voice.app.scanner.MediaAnalyzerTest"

Fix patch:
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterCueProcessor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterCueProcessor.kt
deleted file mode 100644
index 3f7b2f9997..0000000000
--- a/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterCueProcessor.kt
+++ /dev/null
@@ -1,73 +0,0 @@
-package voice.app.scanner.mp4
-
-import androidx.media3.common.DataReader
-import androidx.media3.common.Format
-import androidx.media3.common.util.ParsableByteArray
-import androidx.media3.extractor.TrackOutput
-import androidx.media3.extractor.text.CueDecoder
-import voice.data.MarkData
-import java.io.ByteArrayOutputStream
-import java.util.concurrent.TimeUnit
-
-internal class ChapterCueProcessor(private val outputChapters: MutableList<MarkData>) : TrackOutput {
-
-  private val sampleDataBuffer = ByteArrayOutputStream()
-
-  override fun format(format: Format) {
-    // Not needed for chapter extraction
-  }
-
-  override fun sampleData(
-    input: DataReader,
-    length: Int,
-    allowEndOfInput: Boolean,
-    sampleDataPart: Int,
-  ): Int {
-    if (length == 0) return 0
-
-    val buffer = ByteArray(length)
-    val bytesRead = input.read(buffer, 0, length)
-
-    if (bytesRead > 0) {
-      sampleDataBuffer.write(buffer, 0, bytesRead)
-    }
-
-    return bytesRead
-  }
-
-  override fun sampleData(
-    data: ParsableByteArray,
-    length: Int,
-    sampleDataPart: Int,
-  ) {
-    if (length == 0) return
-
-    val currentPosition = data.position
-    sampleDataBuffer.write(data.data, currentPosition, length)
-    data.skipBytes(length)
-  }
-
-  override fun sampleMetadata(
-    timeUs: Long,
-    flags: Int,
-    size: Int,
-    offset: Int,
-    cryptoData: TrackOutput.CryptoData?,
-  ) {
-    val sampleBytes = sampleDataBuffer.toByteArray()
-    val effectiveOffset = sampleBytes.size - offset - size
-
-    val cuesWithTiming = CueDecoder().decode(timeUs, sampleBytes, effectiveOffset, size)
-
-    val cue = cuesWithTiming.cues.firstOrNull()
-    if (cue != null) {
-      val chapterMark = MarkData(
-        startMs = TimeUnit.MICROSECONDS.toMillis(timeUs),
-        name = cue.text?.toString() ?: "",
-      )
-      outputChapters.add(chapterMark)
-    }
-
-    sampleDataBuffer.reset()
-  }
-}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterTrackExtractor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterTrackExtractor.kt
deleted file mode 100644
index 2abe1961d6..0000000000
--- a/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterTrackExtractor.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-package voice.app.scanner.mp4
-
-import android.content.Context
-import android.net.Uri
-import androidx.media3.common.C
-import androidx.media3.datasource.DataSource
-import androidx.media3.datasource.DataSpec
-import androidx.media3.datasource.DefaultDataSource
-import androidx.media3.extractor.DefaultExtractorInput
-import androidx.media3.extractor.Extractor
-import androidx.media3.extractor.PositionHolder
-import androidx.media3.extractor.mp4.Mp4Extractor
-import androidx.media3.extractor.text.DefaultSubtitleParserFactory
-import voice.data.MarkData
-import voice.logging.core.Logger
-import java.io.IOException
-import javax.inject.Inject
-
-class ChapterTrackExtractor @Inject constructor(private val context: Context) {
-
-  fun extractFromTrackId(
-    uri: Uri,
-    trackId: Int,
-  ): List<MarkData> {
-    val chapters = mutableListOf<MarkData>()
-    val dataSource = DefaultDataSource.Factory(context).createDataSource()
-
-    try {
-      dataSource.open(DataSpec(uri))
-      processTrack(dataSource, uri, trackId, chapters)
-    } catch (e: IOException) {
-      Logger.e(e, "IO error during chapter track extraction")
-    } finally {
-      try {
-        dataSource.close()
-      } catch (e: IOException) {
-        Logger.w(e, "Error closing data source")
-      }
-    }
-
-    return chapters.sorted()
-  }
-
-  private fun processTrack(
-    dataSource: DataSource,
-    uri: Uri,
-    trackId: Int,
-    outputChapters: MutableList<MarkData>,
-  ) {
-    var extractorInput = DefaultExtractorInput(dataSource, 0, C.LENGTH_UNSET.toLong())
-    val positionHolder = PositionHolder()
-
-    val extractor = Mp4Extractor(DefaultSubtitleParserFactory())
-    val extractorOutput = ChapterTrackOutput(trackId, outputChapters)
-    extractor.init(extractorOutput)
-
-    while (true) {
-      val result = extractor.read(extractorInput, positionHolder)
-      when (result) {
-        Extractor.RESULT_CONTINUE -> {
-          // Continue reading
-        }
-        Extractor.RESULT_SEEK -> {
-          dataSource.close()
-          var length = dataSource.open(
-            DataSpec.Builder()
-              .setUri(uri)
-              .setPosition(positionHolder.position)
-              .build(),
-          )
-
-          if (length != C.LENGTH_UNSET.toLong()) {
-            length += positionHolder.position
-          }
-
-          Logger.d("Seeking to position ${positionHolder.position}")
-          extractorInput = DefaultExtractorInput(dataSource, positionHolder.position, length)
-        }
-        Extractor.RESULT_END_OF_INPUT -> break
-      }
-    }
-  }
-}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterTrackOutput.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterTrackOutput.kt
deleted file mode 100644
index 10d74401e7..0000000000
--- a/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterTrackOutput.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package voice.app.scanner.mp4
-
-import androidx.media3.common.C
-import androidx.media3.extractor.DiscardingTrackOutput
-import androidx.media3.extractor.ExtractorOutput
-import androidx.media3.extractor.SeekMap
-import androidx.media3.extractor.TrackOutput
-import voice.data.MarkData
-
-internal class ChapterTrackOutput(
-  private val targetTrackId: Int,
-  private val outputChapters: MutableList<MarkData>,
-) : ExtractorOutput {
-
-  private var chapterCueOutput: ChapterCueProcessor? = null
-
-  override fun track(
-    id: Int,
-    type: Int,
-  ): TrackOutput {
-    if (type == C.TRACK_TYPE_TEXT && id == targetTrackId) {
-      val processor = ChapterCueProcessor(outputChapters)
-      if (chapterCueOutput == null) {
-        chapterCueOutput = processor
-      }
-      return processor
-    }
-
-    return DiscardingTrackOutput()
-  }
-
-  override fun endTracks() {}
-  override fun seekMap(seekMap: SeekMap) {}
-}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterTrackProcessor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterTrackProcessor.kt
new file mode 100644
index 0000000000..26b1094cfc
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/ChapterTrackProcessor.kt
@@ -0,0 +1,109 @@
+package voice.app.scanner.mp4
+
+import android.net.Uri
+import androidx.media3.common.util.ParsableByteArray
+import androidx.media3.datasource.DataSource
+import androidx.media3.datasource.DataSpec
+import voice.data.MarkData
+import voice.logging.core.Logger
+import javax.inject.Inject
+
+class ChapterTrackProcessor
+@Inject constructor() {
+
+  operator fun invoke(
+    uri: Uri,
+    dataSource: DataSource,
+    trackId: Int,
+    output: Mp4ChpaterExtractorOutput,
+  ): List<MarkData> {
+    val chunkOffsets = output.chunkOffsets.getOrNull(trackId - 1)
+    if (chunkOffsets == null) {
+      Logger.w("No chunk offsets found for track ID $trackId")
+      return emptyList()
+    }
+    val timeScale = output.timeScales.getOrNull(trackId - 1)
+    if (timeScale == null) {
+      Logger.w("No time scale found for track ID $trackId")
+      return emptyList()
+    }
+    val durations = output.durations.getOrNull(trackId - 1)
+    if (durations == null) {
+      Logger.w("No durations found for track ID $trackId")
+      return emptyList()
+    }
+    val stscEntries = output.stscEntries.getOrNull(trackId - 1)
+    if (stscEntries == null) {
+      Logger.w("No stsc entries found for track ID $trackId")
+      return emptyList()
+    }
+
+    val numberOfChaptersToProcess = chunkOffsets.size
+
+    val names = chunkOffsets.map { offset ->
+      dataSource.close()
+      dataSource.open(
+        DataSpec.Builder()
+          .setUri(uri)
+          .setPosition(offset)
+          .build(),
+      )
+      val buffer = ParsableByteArray()
+      buffer.reset(2)
+      dataSource.read(buffer.data, 0, 2)
+      val textLength = buffer.readShort().toInt()
+      buffer.reset(textLength)
+      dataSource.read(buffer.data, 0, textLength)
+      buffer.readString(textLength)
+    }
+
+    if (names.size != numberOfChaptersToProcess) {
+      Logger.w("Mismatch in names size and chunk offsets size for track ID $trackId")
+      return emptyList()
+    }
+
+    var position = 0L
+    var sampleIndex = 0
+
+    return (0 until numberOfChaptersToProcess)
+      .map { chunkIndex ->
+        val chapterName = names[chunkIndex]
+
+        val samplesInThisChunk = getSamplesPerChunk(chunkIndex, stscEntries)
+
+        var chunkDuration = 0L
+
+        repeat(samplesInThisChunk) {
+          if (sampleIndex < durations.size) {
+            chunkDuration += durations[sampleIndex]
+            sampleIndex++
+          } else {
+            Logger.w("Not enough sample durations for chunk ${chunkIndex + 1}")
+          }
+        }
+
+        MarkData(
+          startMs = position * 1000 / timeScale,
+          name = chapterName,
+        ).also {
+          position += chunkDuration
+        }
+      }
+      .sorted()
+  }
+
+  private fun getSamplesPerChunk(
+    chunkIndex: Int,
+    stscEntries: List<StscEntry>,
+  ): Int {
+    stscEntries.forEachIndexed { index, entry ->
+      val nextEntry = stscEntries.getOrNull(index + 1)
+      if (chunkIndex + 1 >= entry.firstChunk) {
+        if (nextEntry == null || chunkIndex + 1 < nextEntry.firstChunk) {
+          return entry.samplesPerChunk
+        }
+      }
+    }
+    return 1
+  }
+}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4BoxParser.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4BoxParser.kt
new file mode 100644
index 0000000000..567a419f59
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4BoxParser.kt
@@ -0,0 +1,129 @@
+package voice.app.scanner.mp4
+
+import androidx.media3.common.util.ParsableByteArray
+import androidx.media3.container.Mp4Box
+import androidx.media3.extractor.ExtractorInput
+import voice.app.scanner.mp4.visitor.ChapVisitor
+import voice.app.scanner.mp4.visitor.ChplVisitor
+import voice.app.scanner.mp4.visitor.MdhdVisitor
+import voice.app.scanner.mp4.visitor.StcoVisitor
+import voice.app.scanner.mp4.visitor.StscVisitor
+import voice.app.scanner.mp4.visitor.SttsVisitor
+import voice.logging.core.Logger
+import javax.inject.Inject
+
+class Mp4BoxParser
+@Inject constructor(
+  stscVisitor: StscVisitor,
+  mdhdVisitor: MdhdVisitor,
+  sttsVisitor: SttsVisitor,
+  stcoVisitor: StcoVisitor,
+  chplVisitor: ChplVisitor,
+  chapVisitor: ChapVisitor,
+) {
+
+  private val visitors = listOf(
+    stscVisitor,
+    mdhdVisitor,
+    sttsVisitor,
+    stcoVisitor,
+    chplVisitor,
+    chapVisitor,
+  )
+  private val visitorByPath = visitors.associateBy { it.path }
+
+  operator fun invoke(input: ExtractorInput): Mp4ChpaterExtractorOutput {
+    val scratch = ParsableByteArray(Mp4Box.LONG_HEADER_SIZE)
+    val parseOutput = Mp4ChpaterExtractorOutput()
+    parseBoxes(
+      input = input,
+      path = emptyList(),
+      parentEnd = Long.MAX_VALUE,
+      scratch = scratch,
+      parseOutput = parseOutput,
+    )
+    return parseOutput
+  }
+
+  private fun parseBoxes(
+    input: ExtractorInput,
+    path: List<String>,
+    parentEnd: Long,
+    scratch: ParsableByteArray,
+    parseOutput: Mp4ChpaterExtractorOutput,
+  ) {
+    while (input.position < parentEnd) {
+      scratch.reset(Mp4Box.HEADER_SIZE)
+      if (!input.readFully(scratch.data, 0, Mp4Box.HEADER_SIZE, true)) {
+        return
+      }
+
+      var atomSize = scratch.readUnsignedInt()
+      val atomType = scratch.readString(4)
+      var headerSize = Mp4Box.HEADER_SIZE
+
+      if (atomSize == 1L) {
+        input.readFully(
+          scratch.data,
+          Mp4Box.HEADER_SIZE,
+          Mp4Box.LONG_HEADER_SIZE - Mp4Box.HEADER_SIZE,
+        )
+        scratch.setPosition(Mp4Box.HEADER_SIZE)
+        atomSize = scratch.readUnsignedLongToLong()
+        headerSize = Mp4Box.LONG_HEADER_SIZE
+      }
+
+      val payloadSize = (atomSize - headerSize).toInt()
+      val payloadEnd = input.position + payloadSize
+      val currentPath = path + atomType
+      Logger.d("Current path: $currentPath, atomType: $atomType")
+
+      val visitor = visitorByPath[currentPath]
+
+      when {
+        visitor != null -> {
+          Logger.v("Found ${visitor.path.last()}!")
+          scratch.reset(payloadSize)
+          if (!input.readFully(scratch.data, 0, payloadSize, true)) {
+            return
+          }
+          visitor.visit(scratch, parseOutput)
+
+          if (parseOutput.chplChapters.isNotEmpty()) {
+            return
+          }
+        }
+
+        visitors.any { it.path.startsWith(currentPath) } -> {
+          parseBoxes(
+            input = input,
+            path = currentPath,
+            parentEnd = payloadEnd,
+            scratch = scratch,
+            parseOutput = parseOutput,
+          )
+
+          if (parseOutput.chplChapters.isNotEmpty()) {
+            return
+          }
+        }
+
+        else -> {
+          if (!input.skipFully(payloadSize, true)) {
+            return
+          }
+        }
+      }
+
+      if (input.position < payloadEnd) {
+        if (!input.skipFully((payloadEnd - input.position).toInt(), true)) {
+          return
+        }
+      }
+    }
+  }
+
+  private fun List<String>.startsWith(other: List<String>): Boolean {
+    return take(other.size) == other
+  }
+}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4ChapterExtractor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4ChapterExtractor.kt
index af29b04932..d3dc193b63 100644
--- a/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4ChapterExtractor.kt
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4ChapterExtractor.kt
@@ -3,12 +3,9 @@ package voice.app.scanner.mp4
 import android.content.Context
 import android.net.Uri
 import androidx.media3.common.C
-import androidx.media3.common.util.ParsableByteArray
-import androidx.media3.container.Mp4Box
 import androidx.media3.datasource.DataSpec
 import androidx.media3.datasource.DefaultDataSource
 import androidx.media3.extractor.DefaultExtractorInput
-import androidx.media3.extractor.ExtractorInput
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 import voice.data.MarkData
@@ -16,21 +13,11 @@ import voice.logging.core.Logger
 import java.io.IOException
 import javax.inject.Inject
 
-/**
- * Extracts chapter information from MP4 files using two methods:
- *
- * 1. Primary method: Search for 'chpl' atom in MP4 structure, which directly contains chapter data
- * 2. Fallback method: If 'chap' atom is found, extract chapter info from the referenced text track
- *
- * The extraction process:
- * - Opens MP4 file and traverses its box structure (moov → udta/trak → chpl/tref/chap)
- * - If 'chpl' atom is found, parses timestamps and titles directly
- * - If 'chap' atom is found, uses ChapterTrackExtractor to process the referenced text track
- * - Returns chapters as MarkData objects with timestamps and names
- */
-class Mp4ChapterExtractor @Inject constructor(
+class Mp4ChapterExtractor
+@Inject constructor(
   private val context: Context,
-  private val chapterTrackExtractor: ChapterTrackExtractor,
+  private val boxParser: Mp4BoxParser,
+  private val chapterTrackProcessor: ChapterTrackProcessor,
 ) {
 
   suspend fun extractChapters(uri: Uri): List<MarkData> = withContext(Dispatchers.IO) {
@@ -38,167 +25,34 @@ class Mp4ChapterExtractor @Inject constructor(
 
     try {
       dataSource.open(DataSpec(uri))
-
       val input = DefaultExtractorInput(dataSource, 0, C.LENGTH_UNSET.toLong())
-      val result = parseTopLevelBoxes(input)
-
-      when (result) {
-        is ChapterParseResult.ChplChapters -> result.chapters
-        is ChapterParseResult.ChapterTrackId -> {
-          chapterTrackExtractor.extractFromTrackId(uri, result.trackId)
+      val topLevelResult = boxParser(input)
+      val trackId = topLevelResult.chapterTrackId
+      when {
+        topLevelResult.chplChapters.isNotEmpty() -> {
+          topLevelResult.chplChapters
         }
-
-        null -> emptyList()
+        trackId != null -> {
+          chapterTrackProcessor(uri, dataSource, trackId, topLevelResult)
+        }
+        else -> emptyList()
       }
     } catch (e: IOException) {
       Logger.w(e, "Failed to open MP4 file for chapter extraction")
       emptyList()
-    } finally {
-      try {
-        dataSource.close()
-      } catch (e: IOException) {
-        Logger.w(e, "Error closing data source")
-      }
-    }
-  }
-
-  private fun parseTopLevelBoxes(input: ExtractorInput): ChapterParseResult? {
-    val scratch = ParsableByteArray(Mp4Box.LONG_HEADER_SIZE)
-    return try {
-      parseBoxes(input = input, depth = 0, parentEnd = Long.MAX_VALUE, scratch = scratch)
-    } catch (e: IOException) {
-      Logger.w(e, "Failed to parse MP4 boxes")
-      null
     } catch (e: IllegalStateException) {
       Logger.w(e, "Invalid MP4 structure")
-      null
+      emptyList()
     } catch (e: ArrayIndexOutOfBoundsException) {
       Logger.w(e, "Undeclared")
       // https://github.com/androidx/media/issues/2467
-      null
-    }
-  }
-
-  private fun parseBoxes(
-    input: ExtractorInput,
-    depth: Int,
-    parentEnd: Long,
-    scratch: ParsableByteArray,
-  ): ChapterParseResult? {
-    var chapterTrackId: ChapterParseResult.ChapterTrackId? = null
-
-    while (input.position < parentEnd) {
-      scratch.reset(Mp4Box.HEADER_SIZE)
-      if (!input.readFully(scratch.data, 0, Mp4Box.HEADER_SIZE, true)) {
-        return chapterTrackId
-      }
-
-      scratch.setPosition(0)
-      var atomSize = scratch.readUnsignedInt()
-      val atomType = scratch.readString(4)
-      var headerSize = Mp4Box.HEADER_SIZE
-
-      if (atomSize == 1L) {
-        input.readFully(
-          scratch.data,
-          Mp4Box.HEADER_SIZE,
-          Mp4Box.LONG_HEADER_SIZE - Mp4Box.HEADER_SIZE,
-        )
-        scratch.setPosition(Mp4Box.HEADER_SIZE)
-        atomSize = scratch.readUnsignedLongToLong()
-        headerSize = Mp4Box.LONG_HEADER_SIZE
-      }
-
-      val payloadSize = (atomSize - headerSize).toInt()
-      val payloadEnd = input.position + payloadSize
-
-      when {
-        (depth == 0 && atomType == "moov") ||
-          (depth == 1 && atomType in listOf("udta", "trak")) ||
-          (depth == 2 && atomType == "tref") -> {
-          val result = parseBoxes(
-            input = input,
-            depth = depth + 1,
-            parentEnd = payloadEnd,
-            scratch = scratch,
-          )
-
-          if (result is ChapterParseResult.ChplChapters) {
-            return result
-          }
-
-          if (result is ChapterParseResult.ChapterTrackId) {
-            chapterTrackId = result
-          }
-        }
-
-        depth == 2 && atomType == "chpl" -> {
-          val buffer = ParsableByteArray(payloadSize)
-          if (!input.readFully(buffer.data, 0, payloadSize, true)) {
-            return chapterTrackId
-          }
-
-          val chapters = parseChplAtom(buffer)
-          if (chapters.isNotEmpty()) {
-            return ChapterParseResult.ChplChapters(chapters)
-          }
-        }
-
-        atomType == "chap" -> {
-          val buffer = ParsableByteArray(payloadSize)
-          if (!input.readFully(buffer.data, 0, payloadSize, true)) {
-            return chapterTrackId
-          }
-
-          val trackId = buffer.readUnsignedIntToInt()
-          chapterTrackId = ChapterParseResult.ChapterTrackId(trackId)
-        }
-
-        else -> {
-          if (!input.skipFully(payloadSize, true)) {
-            return chapterTrackId
-          }
-        }
-      }
-
-      if (input.position < payloadEnd) {
-        if (!input.skipFully((payloadEnd - input.position).toInt(), true)) {
-          return chapterTrackId
-        }
-      }
-    }
-
-    return chapterTrackId
-  }
-
-  private fun parseChplAtom(data: ParsableByteArray): List<MarkData> {
-    data.setPosition(0)
-    val version = data.readUnsignedByte()
-    data.skipBytes(3) // flags
-    data.skipBytes(1) // reserved
-    val chapterCount = data.readUnsignedIntToInt()
-
-    val chapters = mutableListOf<MarkData>()
-    repeat(chapterCount) {
-      val timestamp = if (version == 0) {
-        data.readUnsignedInt()
-      } else {
-        data.readUnsignedLongToLong()
+      emptyList()
+    } finally {
+      try {
+        dataSource.close()
+      } catch (e: IOException) {
+        Logger.w(e, "Error closing data source")
       }
-
-      val titleLength = data.readUnsignedByte()
-      val title = data.readString(titleLength)
-
-      // Convert from 100ns units to milliseconds
-      val startTimeMs = timestamp / 10_000
-      chapters.add(MarkData(startMs = startTimeMs, name = title))
     }
-
-    return chapters
-  }
-
-  private sealed class ChapterParseResult {
-    data class ChplChapters(val chapters: List<MarkData>) : ChapterParseResult()
-    data class ChapterTrackId(val trackId: Int) : ChapterParseResult()
   }
 }
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4ChpaterExtractorOutput.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4ChpaterExtractorOutput.kt
new file mode 100644
index 0000000000..1f02702df2
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/Mp4ChpaterExtractorOutput.kt
@@ -0,0 +1,17 @@
+package voice.app.scanner.mp4
+
+import voice.data.MarkData
+
+data class Mp4ChpaterExtractorOutput(
+  val chunkOffsets: MutableList<List<Long>> = mutableListOf(),
+  val durations: MutableList<List<Long>> = mutableListOf(),
+  val stscEntries: MutableList<List<StscEntry>> = mutableListOf(),
+  val timeScales: MutableList<Long> = mutableListOf(),
+  var chplChapters: List<MarkData> = emptyList(),
+  var chapterTrackId: Int? = null,
+)
+
+data class StscEntry(
+  val firstChunk: Long,
+  val samplesPerChunk: Int,
+)
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/AtomVisitor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/AtomVisitor.kt
new file mode 100644
index 0000000000..6fa6a857af
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/AtomVisitor.kt
@@ -0,0 +1,13 @@
+package voice.app.scanner.mp4.visitor
+
+import androidx.media3.common.util.ParsableByteArray
+import voice.app.scanner.mp4.Mp4ChpaterExtractorOutput
+
+interface AtomVisitor {
+  val path: List<String>
+
+  fun visit(
+    buffer: ParsableByteArray,
+    parseOutput: Mp4ChpaterExtractorOutput,
+  )
+}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/ChapVisitor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/ChapVisitor.kt
new file mode 100644
index 0000000000..e4f908d172
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/ChapVisitor.kt
@@ -0,0 +1,18 @@
+package voice.app.scanner.mp4.visitor
+
+import androidx.media3.common.util.ParsableByteArray
+import voice.app.scanner.mp4.Mp4ChpaterExtractorOutput
+import javax.inject.Inject
+
+class ChapVisitor @Inject constructor() : AtomVisitor {
+
+  override val path: List<String> = listOf("moov", "trak", "tref", "chap")
+
+  override fun visit(
+    buffer: ParsableByteArray,
+    parseOutput: Mp4ChpaterExtractorOutput,
+  ) {
+    val trackId = buffer.readUnsignedIntToInt()
+    parseOutput.chapterTrackId = trackId
+  }
+}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/ChplVisitor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/ChplVisitor.kt
new file mode 100644
index 0000000000..857a4756c3
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/ChplVisitor.kt
@@ -0,0 +1,49 @@
+package voice.app.scanner.mp4.visitor
+
+import androidx.media3.common.util.ParsableByteArray
+import voice.app.scanner.mp4.Mp4ChpaterExtractorOutput
+import voice.data.MarkData
+import voice.logging.core.Logger
+import javax.inject.Inject
+
+class ChplVisitor @Inject constructor() : AtomVisitor {
+
+  override val path: List<String> = listOf("moov", "udta", "chpl")
+
+  override fun visit(
+    buffer: ParsableByteArray,
+    parseOutput: Mp4ChpaterExtractorOutput,
+  ) {
+    buffer.setPosition(0)
+    val version = buffer.readUnsignedByte()
+    buffer.skipBytes(3) // flags
+
+    if (version != 0 && version != 1) {
+      Logger.w("Unexpected version $version in chpl atom, expected 0 or 1")
+      return
+    }
+
+    if (version == 1) {
+      buffer.skipBytes(4)
+    }
+
+    val chapterCount = buffer.readUnsignedByte()
+
+    val chapters = (0 until chapterCount).map {
+      val timestamp = if (version == 0) {
+        buffer.readUnsignedInt()
+      } else {
+        buffer.readUnsignedLongToLong()
+      }
+
+      val titleLength = buffer.readUnsignedByte()
+      val title = buffer.readString(titleLength)
+
+      // Convert from 100ns units to milliseconds (10,000 units per ms)
+      val startTimeMs = timestamp / 10_000
+      MarkData(startMs = startTimeMs, name = title)
+    }
+
+    parseOutput.chplChapters = chapters
+  }
+}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/MdhdVisitor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/MdhdVisitor.kt
new file mode 100644
index 0000000000..89800751ca
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/MdhdVisitor.kt
@@ -0,0 +1,30 @@
+package voice.app.scanner.mp4.visitor
+
+import androidx.media3.common.util.ParsableByteArray
+import voice.app.scanner.mp4.Mp4ChpaterExtractorOutput
+import voice.logging.core.Logger
+import javax.inject.Inject
+
+// https://developer.apple.com/documentation/quicktime-file-format/media_header_atom
+class MdhdVisitor @Inject constructor() : AtomVisitor {
+
+  override val path: List<String> = listOf("moov", "trak", "mdia", "mdhd")
+
+  override fun visit(
+    buffer: ParsableByteArray,
+    parseOutput: Mp4ChpaterExtractorOutput,
+  ) {
+    val version = buffer.readUnsignedByte()
+    if (version != 0 && version != 1) {
+      Logger.w("Unexpected version $version in mdhd atom, expected 0 or 1")
+    } else {
+      val flagsSize = 3
+      val creationTimeSize = if (version == 0) 4 else 8
+      val modificationTimeSize = if (version == 0) 4 else 8
+      buffer.skipBytes(flagsSize + creationTimeSize + modificationTimeSize)
+      val timescale = buffer.readUnsignedInt()
+      Logger.v("Timescale: $timescale")
+      parseOutput.timeScales += timescale
+    }
+  }
+}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/StcoVisitor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/StcoVisitor.kt
new file mode 100644
index 0000000000..f5ad6a9d7c
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/StcoVisitor.kt
@@ -0,0 +1,28 @@
+package voice.app.scanner.mp4.visitor
+
+import androidx.media3.common.util.ParsableByteArray
+import voice.app.scanner.mp4.Mp4ChpaterExtractorOutput
+import voice.logging.core.Logger
+import javax.inject.Inject
+
+// https://developer.apple.com/documentation/quicktime-file-format/chunk_offset_atom
+class StcoVisitor @Inject constructor() : AtomVisitor {
+
+  override val path: List<String> = listOf("moov", "trak", "mdia", "minf", "stbl", "stco")
+
+  override fun visit(
+    buffer: ParsableByteArray,
+    parseOutput: Mp4ChpaterExtractorOutput,
+  ) {
+    val version = buffer.readUnsignedByte()
+    if (version != 0) {
+      Logger.w("Unexpected version $version in stco atom, expected 0")
+    } else {
+      buffer.skipBytes(3) // flags
+      val numberOfEntries = buffer.readUnsignedIntToInt()
+      Logger.v("Number of entries in stco: $numberOfEntries")
+      val chunkOffsets = (0 until numberOfEntries).map { buffer.readUnsignedInt() }
+      parseOutput.chunkOffsets.add(chunkOffsets)
+    }
+  }
+}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/StscVisitor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/StscVisitor.kt
new file mode 100644
index 0000000000..7cf10b9a32
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/StscVisitor.kt
@@ -0,0 +1,37 @@
+package voice.app.scanner.mp4.visitor
+
+import androidx.media3.common.util.ParsableByteArray
+import voice.app.scanner.mp4.Mp4ChpaterExtractorOutput
+import voice.app.scanner.mp4.StscEntry
+import voice.logging.core.Logger
+import javax.inject.Inject
+
+// https://developer.apple.com/documentation/quicktime-file-format/sample-to-chunk_atom/
+class StscVisitor @Inject constructor() : AtomVisitor {
+
+  override val path: List<String> = listOf("moov", "trak", "mdia", "minf", "stbl", "stsc")
+
+  override fun visit(
+    buffer: ParsableByteArray,
+    parseOutput: Mp4ChpaterExtractorOutput,
+  ) {
+    val version = buffer.readUnsignedByte()
+    if (version != 0) {
+      Logger.w("Unexpected version $version in stsc atom, expected 0")
+    } else {
+      buffer.skipBytes(3) // flags
+      val numberOfEntries = buffer.readUnsignedIntToInt()
+      Logger.v("Number of entries in stsc: $numberOfEntries")
+      val stscEntriesForTrack = (0 until numberOfEntries).map {
+        val firstChunk = buffer.readUnsignedInt()
+        val samplesPerChunk = buffer.readUnsignedIntToInt()
+        buffer.skipBytes(4) // skip sample description index
+        StscEntry(
+          firstChunk = firstChunk,
+          samplesPerChunk = samplesPerChunk,
+        )
+      }
+      parseOutput.stscEntries.add(stscEntriesForTrack)
+    }
+  }
+}
diff --git a/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/SttsVisitor.kt b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/SttsVisitor.kt
new file mode 100644
index 0000000000..d69d087504
--- /dev/null
+++ b/scanner/src/main/kotlin/voice/app/scanner/mp4/visitor/SttsVisitor.kt
@@ -0,0 +1,35 @@
+package voice.app.scanner.mp4.visitor
+
+import androidx.media3.common.util.ParsableByteArray
+import voice.app.scanner.mp4.Mp4ChpaterExtractorOutput
+import voice.logging.core.Logger
+import javax.inject.Inject
+
+// https://developer.apple.com/documentation/quicktime-file-format/time-to-sample_atom
+class SttsVisitor @Inject constructor() : AtomVisitor {
+
+  override val path: List<String> = listOf("moov", "trak", "mdia", "minf", "stbl", "stts")
+
+  override fun visit(
+    buffer: ParsableByteArray,
+    parseOutput: Mp4ChpaterExtractorOutput,
+  ) {
+    val version = buffer.readUnsignedByte()
+    if (version != 0) {
+      Logger.w("Unexpected version $version in stts atom, expected 0")
+    } else {
+      buffer.skipBytes(3) // flags
+      val numberOfEntriesInSttsTable = buffer.readUnsignedIntToInt()
+      Logger.v("Number of entries in stts: $numberOfEntriesInSttsTable")
+      val individualSampleDurations = mutableListOf<Long>()
+      repeat(numberOfEntriesInSttsTable) {
+        val count = buffer.readUnsignedInt().toInt()
+        val delta = buffer.readUnsignedInt()
+        repeat(count) {
+          individualSampleDurations.add(delta)
+        }
+      }
+      parseOutput.durations.add(individualSampleDurations)
+    }
+  }
+}


Test patch:
diff --git a/scanner/src/test/kotlin/voice/app/scanner/MediaAnalyzerTest.kt b/scanner/src/test/kotlin/voice/app/scanner/MediaAnalyzerTest.kt
index 10a6c3a3fc..1ef17b4b49 100644
--- a/scanner/src/test/kotlin/voice/app/scanner/MediaAnalyzerTest.kt
+++ b/scanner/src/test/kotlin/voice/app/scanner/MediaAnalyzerTest.kt
@@ -4,6 +4,7 @@ import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import io.kotest.assertions.assertSoftly
 import io.kotest.matchers.collections.shouldContainExactly
+import io.kotest.matchers.collections.shouldHaveElementAt
 import io.kotest.matchers.longs.shouldBeWithinPercentageOf
 import io.kotest.matchers.nulls.shouldNotBeNull
 import io.kotest.matchers.shouldBe
@@ -14,8 +15,15 @@ import org.junit.Test
 import org.junit.rules.TemporaryFolder
 import org.junit.runner.RunWith
 import org.robolectric.annotation.Config
-import voice.app.scanner.mp4.ChapterTrackExtractor
+import voice.app.scanner.mp4.ChapterTrackProcessor
+import voice.app.scanner.mp4.Mp4BoxParser
 import voice.app.scanner.mp4.Mp4ChapterExtractor
+import voice.app.scanner.mp4.visitor.ChapVisitor
+import voice.app.scanner.mp4.visitor.ChplVisitor
+import voice.app.scanner.mp4.visitor.MdhdVisitor
+import voice.app.scanner.mp4.visitor.StcoVisitor
+import voice.app.scanner.mp4.visitor.StscVisitor
+import voice.app.scanner.mp4.visitor.SttsVisitor
 import voice.data.MarkData
 import voice.documentfile.FileBasedDocumentFile
 import voice.logging.core.LogWriter
@@ -34,7 +42,15 @@ internal class MediaAnalyzerTest {
     context = ApplicationProvider.getApplicationContext(),
     mp4ChapterExtractor = Mp4ChapterExtractor(
       context = ApplicationProvider.getApplicationContext(),
-      chapterTrackExtractor = ChapterTrackExtractor(context = ApplicationProvider.getApplicationContext()),
+      boxParser = Mp4BoxParser(
+        stscVisitor = StscVisitor(),
+        mdhdVisitor = MdhdVisitor(),
+        sttsVisitor = SttsVisitor(),
+        stcoVisitor = StcoVisitor(),
+        chplVisitor = ChplVisitor(),
+        chapVisitor = ChapVisitor(),
+      ),
+      chapterTrackProcessor = ChapterTrackProcessor(),
     ),
   )
   private val auphonicChapters = listOf(
@@ -85,6 +101,15 @@ internal class MediaAnalyzerTest {
     }
   }
 
+  @Test
+  fun chapterTrackId() {
+    val chapters = parse("chapter_track_id.m4b")
+      .shouldNotBeNull()
+      .chapters
+    chapters.shouldHaveElementAt(0, MarkData(0, "Opening Credits"))
+    chapters.shouldHaveElementAt(107, MarkData(103121056, "Closing Credits"))
+  }
+
   @Test
   fun opus() {
     val metadata = parse("auphonic_chapters_demo.opus")
@@ -112,7 +137,7 @@ internal class MediaAnalyzerTest {
       metadata.album shouldBe "Auphonic Examples"
       metadata.chapters shouldContainExactly auphonicChapters.filter {
         // for some reason only this one is missing in the test files
-        it.name != "Sound analysis"
+        it.name != "Creating a new production"
       }
     }
   }
@@ -143,7 +168,15 @@ internal class MediaAnalyzerTest {
             message: String,
             throwable: Throwable?,
           ) {
-            println("$severity: $message, $throwable")
+            println(
+              buildString {
+                append("${severity.name}: ")
+                append(message)
+                if (throwable != null) {
+                  append(", $throwable")
+                }
+              },
+            )
           }
         },
       )
diff --git a/scanner/src/test/resources/chapter_track_id.m4b b/scanner/src/test/resources/chapter_track_id.m4b
new file mode 100644
index 0000000000..a04cb04fc5
Binary files /dev/null and b/scanner/src/test/resources/chapter_track_id.m4b differ