diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/Chapter.java b/model/src/main/java/de/danoeh/antennapod/model/feed/Chapter.java index 3683a2a44..6c6647880 100644 --- a/model/src/main/java/de/danoeh/antennapod/model/feed/Chapter.java +++ b/model/src/main/java/de/danoeh/antennapod/model/feed/Chapter.java @@ -4,7 +4,7 @@ import java.util.List; public class Chapter { private long id; - /** Defines starting point in milliseconds. */ + /** The start time of the chapter in milliseconds */ private long start; private String title; private String link; @@ -66,7 +66,7 @@ public class Chapter { @Override public String toString() { - return "ID3Chapter [title=" + getTitle() + ", start=" + getStart() + ", url=" + getLink() + "]"; + return "Chapter [title=" + getTitle() + ", start=" + getStart() + ", url=" + getLink() + "]"; } public long getId() { diff --git a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/m4a/M4AChapterReader.java b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/m4a/M4AChapterReader.java new file mode 100644 index 000000000..85a163fa7 --- /dev/null +++ b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/m4a/M4AChapterReader.java @@ -0,0 +1,161 @@ +package de.danoeh.antennapod.parser.media.m4a; + +import android.util.Log; + +import org.apache.commons.io.IOUtils; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import de.danoeh.antennapod.model.feed.Chapter; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +public class M4AChapterReader { + private static final String TAG = "M4AChapterReader"; + private final List chapters = new ArrayList<>(); + private final InputStream inputStream; + private static final int FTYP_CODE = 0x66747970; // "ftyp" + + public M4AChapterReader(InputStream input) { + inputStream = input; + } + + /** + * Read the input stream populating the chapters list + */ + public void readInputStream() { + try { + isM4A(inputStream); + int dataSize = this.findAtom("moov.udta.chpl"); + if (dataSize == -1) { + Log.d(TAG, "Nero Chapter Atom not found"); + } else { + Log.d(TAG, "Nero Chapter Atom found. Data Size: " + dataSize); + this.parseNeroChapterAtom(dataSize); + } + } catch (Exception e) { + Log.d(TAG, "ERROR: " + e.getMessage()); + } + } + + /** + * Find the atom with the given name in the M4A file + * + * @param name the name of the atom to find, separated by dots + * @return the size of the atom (minus the 8-byte header) if found + * @throws IOException if an I/O error occurs or the atom is not found + */ + public int findAtom(String name) throws IOException { + // Split the name into parts encoded as UTF-8 + String[] parts = name.split("\\."); + int partIndex = 0; + // Initialize remaining size to track the current part's size and check if it is exceeded + int remainingSize = -1; + + // Read the M4A file atom by atom + ByteBuffer buffer = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN); + while (true) { + // Read the atom header + IOUtils.readFully(inputStream, buffer.array()); + // Get the size of the current atom + int chunkSize = buffer.getInt(); + int dataSize = chunkSize - 8; + + // Get the atom type + String atomType = StandardCharsets.UTF_8.decode(buffer).toString(); + + // Reset the buffer for reading the atom data + buffer.clear(); + + // Check if the current atom matches the current part of the name + if (atomType.equals(parts[partIndex])) { + if (partIndex == parts.length - 1) { + // If the current atom is the last part of the name return its size + return dataSize; + } else { + // Else move to the next part of the name + partIndex++; + // Update the remaining size + remainingSize = dataSize; + } + } else { + // Do not check the remaining size of top-level atoms + if (partIndex > 0) { + // Update the remaining size + remainingSize -= dataSize; + // If the remaining size is exhausted, throw an exception + if (remainingSize <= 0) { + throw new IOException("Part size exceeded for part \"" + parts[partIndex - 1] + + "\" while searching atom. Remaining Size: " + remainingSize); + } + } + // Skip the rest of the atom + IOUtils.skipFully(inputStream, dataSize); + } + } + } + + /** + * Parse the Nero Chapter Atom in the M4A file + * Assumes that the current position is at the start of the Nero Chapter Atom + * + * @param chunkSize the size of the Nero Chapter Atom + * @throws IOException if an I/O error occurs + * @see Nero Chapter + */ + private void parseNeroChapterAtom(long chunkSize) throws IOException { + // Read the Nero Chapter Atom data into a buffer + ByteBuffer byteBuffer = ByteBuffer.allocate((int) chunkSize).order(ByteOrder.BIG_ENDIAN); + IOUtils.readFully(inputStream, byteBuffer.array()); + // Skip the 5-byte header + // Nero Chapter Atom consists of a 5-byte header followed by chapter data + // The first 4 bytes are the version and flags, the 5th byte is reserved + byteBuffer.position(5); + // Get the chapter count + int chapterCount = byteBuffer.getInt(); + Log.d(TAG, "Nero Chapter Count: " + chapterCount); + + // Parse each chapter + for (int i = 0; i < chapterCount; i++) { + long startTime = byteBuffer.getLong(); + int chapterNameSize = byteBuffer.get(); + byte[] chapterNameBytes = new byte[chapterNameSize]; + byteBuffer.get(chapterNameBytes, 0, chapterNameSize); + String chapterName = new String(chapterNameBytes, StandardCharsets.UTF_8); + + Chapter chapter = new Chapter(); + chapter.setStart(startTime / 10000); + chapter.setTitle(chapterName); + chapter.setChapterId(String.valueOf(i + 1)); + chapters.add(chapter); + + Log.d(TAG, "Nero Chapter " + (i + 1) + ": " + chapter); + } + } + + public List getChapters() { + return chapters; + } + + /** + * Assert that the input stream is an M4A file by checking the signature + * + * @param inputStream the input stream to check + * @throws IOException if an I/O error occurs + */ + public static void isM4A(InputStream inputStream) throws IOException { + ByteBuffer byteBuffer = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN); + IOUtils.readFully(inputStream, byteBuffer.array()); + + int ftypSize = byteBuffer.getInt(); + if (byteBuffer.getInt() != FTYP_CODE) { + throw new IOException("Not an M4A file"); + } + IOUtils.skipFully(inputStream, ftypSize - 8); + } +} diff --git a/parser/media/src/test/java/de/danoeh/antennapod/parser/media/m4a/M4AChapterReaderTest.java b/parser/media/src/test/java/de/danoeh/antennapod/parser/media/m4a/M4AChapterReaderTest.java new file mode 100644 index 000000000..9111f9727 --- /dev/null +++ b/parser/media/src/test/java/de/danoeh/antennapod/parser/media/m4a/M4AChapterReaderTest.java @@ -0,0 +1,41 @@ +package de.danoeh.antennapod.parser.media.m4a; + +import de.danoeh.antennapod.model.feed.Chapter; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import static org.junit.Assert.assertEquals; + +@RunWith(RobolectricTestRunner.class) +public class M4AChapterReaderTest { + + @Test + public void testFiles() throws IOException { + testFile(); + } + + public void testFile() throws IOException { + InputStream inputStream = getClass().getClassLoader() + .getResource("nero-chapters.m4a").openStream(); + M4AChapterReader reader = new M4AChapterReader(inputStream); + reader.readInputStream(); + List chapters = reader.getChapters(); + + assertEquals(4, chapters.size()); + + assertEquals(0, chapters.get(0).getStart()); + assertEquals(3000, chapters.get(1).getStart()); + assertEquals(6000, chapters.get(2).getStart()); + assertEquals(9000, chapters.get(3).getStart()); + + assertEquals("Chapter 1 - ❤️😊", chapters.get(0).getTitle()); + assertEquals("Chapter 2 - ßöÄ", chapters.get(1).getTitle()); + assertEquals("Chapter 3 - 爱", chapters.get(2).getTitle()); + assertEquals("Chapter 4", chapters.get(3).getTitle()); + } +} diff --git a/parser/media/src/test/resources/nero-chapters.m4a b/parser/media/src/test/resources/nero-chapters.m4a new file mode 100644 index 000000000..99de2b661 Binary files /dev/null and b/parser/media/src/test/resources/nero-chapters.m4a differ diff --git a/ui/chapters/src/main/java/de/danoeh/antennapod/ui/chapters/ChapterUtils.java b/ui/chapters/src/main/java/de/danoeh/antennapod/ui/chapters/ChapterUtils.java index 5554890ed..59317473a 100644 --- a/ui/chapters/src/main/java/de/danoeh/antennapod/ui/chapters/ChapterUtils.java +++ b/ui/chapters/src/main/java/de/danoeh/antennapod/ui/chapters/ChapterUtils.java @@ -16,6 +16,7 @@ import de.danoeh.antennapod.parser.media.id3.ID3ReaderException; import de.danoeh.antennapod.model.playback.Playable; import de.danoeh.antennapod.parser.media.vorbis.VorbisCommentChapterReader; import de.danoeh.antennapod.parser.media.vorbis.VorbisCommentReaderException; +import de.danoeh.antennapod.parser.media.m4a.M4AChapterReader; import okhttp3.CacheControl; import okhttp3.Request; import okhttp3.Response; @@ -106,6 +107,19 @@ public class ChapterUtils { } catch (IOException | VorbisCommentReaderException e) { Log.e(TAG, "Unable to load vorbis chapters: " + e.getMessage()); } + + try (CountingInputStream in = openStream(playable, context)) { + List chapters = readM4AChaptersFromInputStream(in); + if (!chapters.isEmpty()) { + Log.i(TAG, "Chapters loaded"); + return chapters; + } + } catch (InterruptedIOException e) { + throw e; + } catch (IOException e) { + Log.e(TAG, "Unable to open stream " + e.getMessage()); + } + return null; } @@ -195,6 +209,22 @@ public class ChapterUtils { return Collections.emptyList(); } + @NonNull + private static List readM4AChaptersFromInputStream(InputStream input) { + M4AChapterReader reader = new M4AChapterReader(new BufferedInputStream(input)); + reader.readInputStream(); + List chapters = reader.getChapters(); + if (chapters == null) { + return Collections.emptyList(); + } + Collections.sort(chapters, new ChapterStartTimeComparator()); + enumerateEmptyChapterTitles(chapters); + if (chaptersValid(chapters)) { + return chapters; + } + return Collections.emptyList(); + } + /** * Makes sure that chapter does a title and an item attribute. */