summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSimon Conrad <simon2004.sc@gmail.com>2024-05-05 10:05:26 +0200
committerGitHub <noreply@github.com>2024-05-05 10:05:26 +0200
commitba14510b80bc7340c68ae298c795ad62c35c86a0 (patch)
tree2825e0f2b8efbc5a737da3f08377421e8a77694d
parentcb1a03cd8d55efca8a815d9c55b6a9a6448c0267 (diff)
downloadAntennaPod-ba14510b80bc7340c68ae298c795ad62c35c86a0.zip
Add support for parsing Nero M4A chapters (#7159)
-rw-r--r--model/src/main/java/de/danoeh/antennapod/model/feed/Chapter.java4
-rw-r--r--parser/media/src/main/java/de/danoeh/antennapod/parser/media/m4a/M4AChapterReader.java161
-rw-r--r--parser/media/src/test/java/de/danoeh/antennapod/parser/media/m4a/M4AChapterReaderTest.java41
-rw-r--r--parser/media/src/test/resources/nero-chapters.m4abin0 -> 6909 bytes
-rw-r--r--ui/chapters/src/main/java/de/danoeh/antennapod/ui/chapters/ChapterUtils.java30
5 files changed, 234 insertions, 2 deletions
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<Chapter> 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 <a href="https://github.com/Zeugma440/atldotnet/wiki/Focus-on-Chapter-metadata#nero-chapters">Nero Chapter</a>
+ */
+ 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<Chapter> 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<Chapter> 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
--- /dev/null
+++ b/parser/media/src/test/resources/nero-chapters.m4a
Binary files 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<Chapter> 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<Chapter> readM4AChaptersFromInputStream(InputStream input) {
+ M4AChapterReader reader = new M4AChapterReader(new BufferedInputStream(input));
+ reader.readInputStream();
+ List<Chapter> 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.
*/