summaryrefslogtreecommitdiff
path: root/parser
diff options
context:
space:
mode:
authorByteHamster <info@bytehamster.com>2021-08-28 09:52:45 +0200
committerByteHamster <info@bytehamster.com>2021-08-28 10:59:26 +0200
commitca64739f363e585758b6ada6cc4e6c9d43191724 (patch)
tree3b5b6634792a3997d9302053e628ec8cda205ff5 /parser
parentddae5e2278fe6b6e950576cdc460ec7ffe761d5d (diff)
downloadantennapod-ca64739f363e585758b6ada6cc4e6c9d43191724.zip
Moved media file parser to its own module
Diffstat (limited to 'parser')
-rw-r--r--parser/media/README.md3
-rw-r--r--parser/media/build.gradle12
-rw-r--r--parser/media/src/main/AndroidManifest.xml1
-rw-r--r--parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/ChapterReader.java118
-rw-r--r--parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/ID3Chapter.java38
-rw-r--r--parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/ID3Reader.java213
-rw-r--r--parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/ID3ReaderException.java9
-rw-r--r--parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/model/FrameHeader.java18
-rw-r--r--parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/model/Header.java26
-rw-r--r--parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/model/TagHeader.java25
-rw-r--r--parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/OggInputStream.java81
-rw-r--r--parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentChapter.java88
-rw-r--r--parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentChapterReader.java99
-rw-r--r--parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentHeader.java27
-rw-r--r--parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentReader.java167
-rw-r--r--parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentReaderException.java21
-rw-r--r--parser/media/src/main/resources/auphonic.m4abin0 -> 114657 bytes
-rw-r--r--parser/media/src/main/resources/auphonic.mp3bin0 -> 143695 bytes
-rw-r--r--parser/media/src/main/resources/auphonic.oggbin0 -> 6565 bytes
-rw-r--r--parser/media/src/main/resources/auphonic.opusbin0 -> 4189 bytes
-rw-r--r--parser/media/src/main/resources/hindenburg-journalist-pro.m4abin0 -> 23315 bytes
-rw-r--r--parser/media/src/main/resources/hindenburg-journalist-pro.mp3bin0 -> 206098 bytes
-rw-r--r--parser/media/src/main/resources/mp3chaps-py.mp3bin0 -> 123247 bytes
-rw-r--r--parser/media/src/main/resources/ultraschall5.mp3bin0 -> 5903309 bytes
-rw-r--r--parser/media/src/test/java/de/danoeh/antennapod/parser/media/id3/ChapterReaderTest.java205
-rw-r--r--parser/media/src/test/java/de/danoeh/antennapod/parser/media/id3/Id3ReaderTest.java151
-rw-r--r--parser/media/src/test/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentChapterReaderTest.java44
27 files changed, 1346 insertions, 0 deletions
diff --git a/parser/media/README.md b/parser/media/README.md
new file mode 100644
index 000000000..a6efab20e
--- /dev/null
+++ b/parser/media/README.md
@@ -0,0 +1,3 @@
+# :parser:media
+
+This module provides the tag parser for media files. This includes id3 or ogg/vorbis.
diff --git a/parser/media/build.gradle b/parser/media/build.gradle
new file mode 100644
index 000000000..c6ae6964a
--- /dev/null
+++ b/parser/media/build.gradle
@@ -0,0 +1,12 @@
+apply plugin: "com.android.library"
+apply from: "../../common.gradle"
+
+dependencies {
+ implementation project(':model')
+
+ annotationProcessor "androidx.annotation:annotation:$annotationVersion"
+
+ implementation "commons-io:commons-io:$commonsioVersion"
+
+ testImplementation 'junit:junit:4.13'
+}
diff --git a/parser/media/src/main/AndroidManifest.xml b/parser/media/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..c440f1553
--- /dev/null
+++ b/parser/media/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+<manifest package="de.danoeh.antennapod.parser.media" />
diff --git a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/ChapterReader.java b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/ChapterReader.java
new file mode 100644
index 000000000..81bfa67b6
--- /dev/null
+++ b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/ChapterReader.java
@@ -0,0 +1,118 @@
+package de.danoeh.antennapod.parser.media.id3;
+
+import android.text.TextUtils;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import de.danoeh.antennapod.model.feed.Chapter;
+import de.danoeh.antennapod.model.feed.EmbeddedChapterImage;
+import de.danoeh.antennapod.parser.media.id3.model.FrameHeader;
+import org.apache.commons.io.input.CountingInputStream;
+
+import java.io.IOException;
+import java.net.URLDecoder;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Reads ID3 chapters.
+ * See https://id3.org/id3v2-chapters-1.0
+ */
+public class ChapterReader extends ID3Reader {
+ private static final String TAG = "ID3ChapterReader";
+
+ public static final String FRAME_ID_CHAPTER = "CHAP";
+ public static final String FRAME_ID_TITLE = "TIT2";
+ public static final String FRAME_ID_LINK = "WXXX";
+ public static final String FRAME_ID_PICTURE = "APIC";
+ public static final String MIME_IMAGE_URL = "-->";
+ public static final int IMAGE_TYPE_COVER = 3;
+
+ private final List<Chapter> chapters = new ArrayList<>();
+
+ public ChapterReader(CountingInputStream input) {
+ super(input);
+ }
+
+ @Override
+ protected void readFrame(@NonNull FrameHeader frameHeader) throws IOException, ID3ReaderException {
+ if (FRAME_ID_CHAPTER.equals(frameHeader.getId())) {
+ Log.d(TAG, "Handling frame: " + frameHeader.toString());
+ Chapter chapter = readChapter(frameHeader);
+ Log.d(TAG, "Chapter done: " + chapter);
+ chapters.add(chapter);
+ } else {
+ super.readFrame(frameHeader);
+ }
+ }
+
+ public Chapter readChapter(@NonNull FrameHeader frameHeader) throws IOException, ID3ReaderException {
+ int chapterStartedPosition = getPosition();
+ String elementId = readIsoStringNullTerminated(100);
+ long startTime = readInt();
+ skipBytes(12); // Ignore end time, start offset, end offset
+ ID3Chapter chapter = new ID3Chapter(elementId, startTime);
+
+ // Read sub-frames
+ while (getPosition() < chapterStartedPosition + frameHeader.getSize()) {
+ FrameHeader subFrameHeader = readFrameHeader();
+ readChapterSubFrame(subFrameHeader, chapter);
+ }
+ return chapter;
+ }
+
+ public void readChapterSubFrame(@NonNull FrameHeader frameHeader, @NonNull Chapter chapter)
+ throws IOException, ID3ReaderException {
+ Log.d(TAG, "Handling subframe: " + frameHeader.toString());
+ int frameStartPosition = getPosition();
+ switch (frameHeader.getId()) {
+ case FRAME_ID_TITLE:
+ chapter.setTitle(readEncodingAndString(frameHeader.getSize()));
+ Log.d(TAG, "Found title: " + chapter.getTitle());
+ break;
+ case FRAME_ID_LINK:
+ readEncodingAndString(frameHeader.getSize()); // skip description
+ String url = readIsoStringNullTerminated(frameStartPosition + frameHeader.getSize() - getPosition());
+ try {
+ String decodedLink = URLDecoder.decode(url, "ISO-8859-1");
+ chapter.setLink(decodedLink);
+ Log.d(TAG, "Found link: " + chapter.getLink());
+ } catch (IllegalArgumentException iae) {
+ Log.w(TAG, "Bad URL found in ID3 data");
+ }
+ break;
+ case FRAME_ID_PICTURE:
+ byte encoding = readByte();
+ String mime = readEncodedString(encoding, frameHeader.getSize());
+ byte type = readByte();
+ String description = readEncodedString(encoding, frameHeader.getSize());
+ Log.d(TAG, "Found apic: " + mime + "," + description);
+ if (MIME_IMAGE_URL.equals(mime)) {
+ String link = readIsoStringNullTerminated(frameHeader.getSize());
+ Log.d(TAG, "Link: " + link);
+ if (TextUtils.isEmpty(chapter.getImageUrl()) || type == IMAGE_TYPE_COVER) {
+ chapter.setImageUrl(link);
+ }
+ } else {
+ int alreadyConsumed = getPosition() - frameStartPosition;
+ int rawImageDataLength = frameHeader.getSize() - alreadyConsumed;
+ if (TextUtils.isEmpty(chapter.getImageUrl()) || type == IMAGE_TYPE_COVER) {
+ chapter.setImageUrl(EmbeddedChapterImage.makeUrl(getPosition(), rawImageDataLength));
+ }
+ }
+ break;
+ default:
+ Log.d(TAG, "Unknown chapter sub-frame.");
+ break;
+ }
+
+ // Skip garbage to fill frame completely
+ // This also asserts that we are not reading too many bytes from this frame.
+ int alreadyConsumed = getPosition() - frameStartPosition;
+ skipBytes(frameHeader.getSize() - alreadyConsumed);
+ }
+
+ public List<Chapter> getChapters() {
+ return chapters;
+ }
+
+}
diff --git a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/ID3Chapter.java b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/ID3Chapter.java
new file mode 100644
index 000000000..fc594ab5a
--- /dev/null
+++ b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/ID3Chapter.java
@@ -0,0 +1,38 @@
+package de.danoeh.antennapod.parser.media.id3;
+
+import de.danoeh.antennapod.model.feed.Chapter;
+
+public class ID3Chapter extends Chapter {
+ public static final int CHAPTERTYPE_ID3CHAPTER = 2;
+
+ /**
+ * Identifies the chapter in its ID3 tag. This attribute does not have to be
+ * store in the DB and is only used for parsing.
+ */
+ private String id3ID;
+
+ public ID3Chapter(String id3ID, long start) {
+ super(start);
+ this.id3ID = id3ID;
+ }
+
+ public ID3Chapter(long start, String title, String link, String imageUrl) {
+ super(start, title, link, imageUrl);
+ }
+
+ @Override
+ public String toString() {
+ return "ID3Chapter [id3ID=" + id3ID + ", title=" + getTitle() + ", start="
+ + getStart() + ", url=" + getLink() + "]";
+ }
+
+ @Override
+ public int getChapterType() {
+ return CHAPTERTYPE_ID3CHAPTER;
+ }
+
+ public String getId3ID() {
+ return id3ID;
+ }
+
+}
diff --git a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/ID3Reader.java b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/ID3Reader.java
new file mode 100644
index 000000000..baaff2752
--- /dev/null
+++ b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/ID3Reader.java
@@ -0,0 +1,213 @@
+package de.danoeh.antennapod.parser.media.id3;
+
+import android.util.Log;
+import androidx.annotation.NonNull;
+import de.danoeh.antennapod.parser.media.id3.model.FrameHeader;
+import de.danoeh.antennapod.parser.media.id3.model.TagHeader;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.input.CountingInputStream;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.Charset;
+import java.nio.charset.MalformedInputException;
+
+/**
+ * Reads the ID3 Tag of a given file.
+ * See https://id3.org/id3v2.3.0
+ */
+public class ID3Reader {
+ private static final String TAG = "ID3Reader";
+ private static final int FRAME_ID_LENGTH = 4;
+ public static final byte ENCODING_ISO = 0;
+ public static final byte ENCODING_UTF16_WITH_BOM = 1;
+ public static final byte ENCODING_UTF16_WITHOUT_BOM = 2;
+ public static final byte ENCODING_UTF8 = 3;
+
+ private TagHeader tagHeader;
+ private final CountingInputStream inputStream;
+
+ public ID3Reader(CountingInputStream input) {
+ inputStream = input;
+ }
+
+ public void readInputStream() throws IOException, ID3ReaderException {
+ tagHeader = readTagHeader();
+ int tagContentStartPosition = getPosition();
+ while (getPosition() < tagContentStartPosition + tagHeader.getSize()) {
+ FrameHeader frameHeader = readFrameHeader();
+ if (frameHeader.getId().charAt(0) < '0' || frameHeader.getId().charAt(0) > 'z') {
+ Log.d(TAG, "Stopping because of invalid frame: " + frameHeader.toString());
+ return;
+ }
+ readFrame(frameHeader);
+ }
+ }
+
+ protected void readFrame(@NonNull FrameHeader frameHeader) throws IOException, ID3ReaderException {
+ Log.d(TAG, "Skipping frame: " + frameHeader.toString());
+ skipBytes(frameHeader.getSize());
+ }
+
+ int getPosition() {
+ return inputStream.getCount();
+ }
+
+ /**
+ * Skip a certain number of bytes on the given input stream.
+ */
+ void skipBytes(int number) throws IOException, ID3ReaderException {
+ if (number < 0) {
+ throw new ID3ReaderException("Trying to read a negative number of bytes");
+ }
+ IOUtils.skipFully(inputStream, number);
+ }
+
+ byte readByte() throws IOException {
+ return (byte) inputStream.read();
+ }
+
+ short readShort() throws IOException {
+ char firstByte = (char) inputStream.read();
+ char secondByte = (char) inputStream.read();
+ return (short) ((firstByte << 8) | secondByte);
+ }
+
+ int readInt() throws IOException {
+ char firstByte = (char) inputStream.read();
+ char secondByte = (char) inputStream.read();
+ char thirdByte = (char) inputStream.read();
+ char fourthByte = (char) inputStream.read();
+ return (firstByte << 24) | (secondByte << 16) | (thirdByte << 8) | fourthByte;
+ }
+
+ void expectChar(char expected) throws ID3ReaderException, IOException {
+ char read = (char) inputStream.read();
+ if (read != expected) {
+ throw new ID3ReaderException("Expected " + expected + " and got " + read);
+ }
+ }
+
+ @NonNull
+ TagHeader readTagHeader() throws ID3ReaderException, IOException {
+ expectChar('I');
+ expectChar('D');
+ expectChar('3');
+ short version = readShort();
+ byte flags = readByte();
+ int size = unsynchsafe(readInt());
+ if ((flags & 0b01000000) != 0) {
+ int extendedHeaderSize = readInt();
+ skipBytes(extendedHeaderSize - 4);
+ }
+ return new TagHeader("ID3", size, version, flags);
+ }
+
+ @NonNull
+ FrameHeader readFrameHeader() throws IOException {
+ String id = readIsoStringFixed(FRAME_ID_LENGTH);
+ int size = readInt();
+ if (tagHeader != null && tagHeader.getVersion() >= 0x0400) {
+ size = unsynchsafe(size);
+ }
+ short flags = readShort();
+ return new FrameHeader(id, size, flags);
+ }
+
+ private int unsynchsafe(int in) {
+ int out = 0;
+ int mask = 0x7F000000;
+
+ while (mask != 0) {
+ out >>= 1;
+ out |= in & mask;
+ mask >>= 8;
+ }
+
+ return out;
+ }
+
+ /**
+ * Reads a null-terminated string with encoding.
+ */
+ protected String readEncodingAndString(int max) throws IOException {
+ byte encoding = readByte();
+ return readEncodedString(encoding, max - 1);
+ }
+
+ @SuppressWarnings("CharsetObjectCanBeUsed")
+ protected String readIsoStringFixed(int length) throws IOException {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ int bytesRead = 0;
+ while (bytesRead < length) {
+ bytes.write(readByte());
+ bytesRead++;
+ }
+ return Charset.forName("ISO-8859-1").newDecoder().decode(ByteBuffer.wrap(bytes.toByteArray())).toString();
+ }
+
+ protected String readIsoStringNullTerminated(int max) throws IOException {
+ return readEncodedString(ENCODING_ISO, max);
+ }
+
+ @SuppressWarnings("CharsetObjectCanBeUsed")
+ String readEncodedString(int encoding, int max) throws IOException {
+ if (encoding == ENCODING_UTF16_WITH_BOM || encoding == ENCODING_UTF16_WITHOUT_BOM) {
+ return readEncodedString2(Charset.forName("UTF-16"), max);
+ } else if (encoding == ENCODING_UTF8) {
+ return readEncodedString2(Charset.forName("UTF-8"), max);
+ } else {
+ return readEncodedString1(Charset.forName("ISO-8859-1"), max);
+ }
+ }
+
+ /**
+ * Reads chars where the encoding uses 1 char per symbol.
+ */
+ private String readEncodedString1(Charset charset, int max) throws IOException {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ int bytesRead = 0;
+ while (bytesRead < max) {
+ byte c = readByte();
+ bytesRead++;
+ if (c == 0) {
+ break;
+ }
+ bytes.write(c);
+ }
+ return charset.newDecoder().decode(ByteBuffer.wrap(bytes.toByteArray())).toString();
+ }
+
+ /**
+ * Reads chars where the encoding uses 2 chars per symbol.
+ */
+ private String readEncodedString2(Charset charset, int max) throws IOException {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ int bytesRead = 0;
+ boolean foundEnd = false;
+ while (bytesRead + 1 < max) {
+ byte c1 = readByte();
+ byte c2 = readByte();
+ if (c1 == 0 && c2 == 0) {
+ foundEnd = true;
+ break;
+ }
+ bytesRead += 2;
+ bytes.write(c1);
+ bytes.write(c2);
+ }
+ if (!foundEnd && bytesRead < max) {
+ // Last character
+ byte c = readByte();
+ if (c != 0) {
+ bytes.write(c);
+ }
+ }
+ try {
+ return charset.newDecoder().decode(ByteBuffer.wrap(bytes.toByteArray())).toString();
+ } catch (MalformedInputException e) {
+ return "";
+ }
+ }
+}
diff --git a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/ID3ReaderException.java b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/ID3ReaderException.java
new file mode 100644
index 000000000..679dc5d7c
--- /dev/null
+++ b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/ID3ReaderException.java
@@ -0,0 +1,9 @@
+package de.danoeh.antennapod.parser.media.id3;
+
+public class ID3ReaderException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ public ID3ReaderException(String message) {
+ super(message);
+ }
+}
diff --git a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/model/FrameHeader.java b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/model/FrameHeader.java
new file mode 100644
index 000000000..1a298a0df
--- /dev/null
+++ b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/model/FrameHeader.java
@@ -0,0 +1,18 @@
+package de.danoeh.antennapod.parser.media.id3.model;
+
+import androidx.annotation.NonNull;
+
+public class FrameHeader extends Header {
+ private final short flags;
+
+ public FrameHeader(String id, int size, short flags) {
+ super(id, size);
+ this.flags = flags;
+ }
+
+ @Override
+ @NonNull
+ public String toString() {
+ return String.format("FrameHeader [flags=%s, id=%s, size=%s]", Integer.toBinaryString(flags), id, size);
+ }
+}
diff --git a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/model/Header.java b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/model/Header.java
new file mode 100644
index 000000000..572bc4955
--- /dev/null
+++ b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/model/Header.java
@@ -0,0 +1,26 @@
+package de.danoeh.antennapod.parser.media.id3.model;
+
+public abstract class Header {
+
+ final String id;
+ final int size;
+
+ Header(String id, int size) {
+ super();
+ this.id = id;
+ this.size = size;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public int getSize() {
+ return size;
+ }
+
+ @Override
+ public String toString() {
+ return "Header [id=" + id + ", size=" + size + "]";
+ }
+}
diff --git a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/model/TagHeader.java b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/model/TagHeader.java
new file mode 100644
index 000000000..c82c8ad6b
--- /dev/null
+++ b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/id3/model/TagHeader.java
@@ -0,0 +1,25 @@
+package de.danoeh.antennapod.parser.media.id3.model;
+
+import androidx.annotation.NonNull;
+
+public class TagHeader extends Header {
+ private final short version;
+ private final byte flags;
+
+ public TagHeader(String id, int size, short version, byte flags) {
+ super(id, size);
+ this.version = version;
+ this.flags = flags;
+ }
+
+ @Override
+ @NonNull
+ public String toString() {
+ return "TagHeader [version=" + version + ", flags=" + flags + ", id="
+ + id + ", size=" + size + "]";
+ }
+
+ public short getVersion() {
+ return version;
+ }
+}
diff --git a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/OggInputStream.java b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/OggInputStream.java
new file mode 100644
index 000000000..ed495bcf3
--- /dev/null
+++ b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/OggInputStream.java
@@ -0,0 +1,81 @@
+package de.danoeh.antennapod.parser.media.vorbis;
+
+import org.apache.commons.io.IOUtils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+
+class OggInputStream extends InputStream {
+ private final InputStream input;
+
+ /** True if OggInputStream is currently inside an Ogg page. */
+ private boolean isInPage;
+ private long bytesLeft;
+
+ public OggInputStream(InputStream input) {
+ super();
+ isInPage = false;
+ this.input = input;
+ }
+
+ @Override
+ public int read() throws IOException {
+ if (!isInPage) {
+ readOggPage();
+ }
+
+ if (isInPage && bytesLeft > 0) {
+ int result = input.read();
+ bytesLeft -= 1;
+ if (bytesLeft == 0) {
+ isInPage = false;
+ }
+ return result;
+ }
+ return -1;
+ }
+
+ private void readOggPage() throws IOException {
+ // find OggS
+ int[] buffer = new int[4];
+ int c;
+ boolean isInOggS = false;
+ while ((c = input.read()) != -1) {
+ switch (c) {
+ case 'O':
+ isInOggS = true;
+ buffer[0] = c;
+ break;
+ case 'g':
+ if (buffer[1] != c) {
+ buffer[1] = c;
+ } else {
+ buffer[2] = c;
+ }
+ break;
+ case 'S':
+ buffer[3] = c;
+ break;
+ default:
+ if (isInOggS) {
+ Arrays.fill(buffer, 0);
+ isInOggS = false;
+ }
+ }
+ if (buffer[0] == 'O' && buffer[1] == 'g' && buffer[2] == 'g'
+ && buffer[3] == 'S') {
+ break;
+ }
+ }
+ // read segments
+ IOUtils.skipFully(input, 22);
+ bytesLeft = 0;
+ int numSegments = input.read();
+ for (int i = 0; i < numSegments; i++) {
+ bytesLeft += input.read();
+ }
+ isInPage = true;
+ }
+
+}
diff --git a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentChapter.java b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentChapter.java
new file mode 100644
index 000000000..88ee7fef9
--- /dev/null
+++ b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentChapter.java
@@ -0,0 +1,88 @@
+package de.danoeh.antennapod.parser.media.vorbis;
+
+import java.util.concurrent.TimeUnit;
+
+import de.danoeh.antennapod.model.feed.Chapter;
+
+public class VorbisCommentChapter extends Chapter {
+ public static final int CHAPTERTYPE_VORBISCOMMENT_CHAPTER = 3;
+
+ private static final int CHAPTERXXX_LENGTH = "chapterxxx".length();
+
+ private int vorbisCommentId;
+
+ public VorbisCommentChapter(int vorbisCommentId) {
+ this.vorbisCommentId = vorbisCommentId;
+ }
+
+ public VorbisCommentChapter(long start, String title, String link, String imageUrl) {
+ super(start, title, link, imageUrl);
+ }
+
+ @Override
+ public String toString() {
+ return "VorbisCommentChapter [id=" + getId() + ", title=" + getTitle()
+ + ", link=" + getLink() + ", start=" + getStart() + "]";
+ }
+
+ public static long getStartTimeFromValue(String value)
+ throws VorbisCommentReaderException {
+ String[] parts = value.split(":");
+ if (parts.length >= 3) {
+ try {
+ long hours = TimeUnit.MILLISECONDS.convert(
+ Long.parseLong(parts[0]), TimeUnit.HOURS);
+ long minutes = TimeUnit.MILLISECONDS.convert(
+ Long.parseLong(parts[1]), TimeUnit.MINUTES);
+ if (parts[2].contains("-->")) {
+ parts[2] = parts[2].substring(0, parts[2].indexOf("-->"));
+ }
+ long seconds = TimeUnit.MILLISECONDS.convert(
+ ((long) Float.parseFloat(parts[2])), TimeUnit.SECONDS);
+ return hours + minutes + seconds;
+ } catch (NumberFormatException e) {
+ throw new VorbisCommentReaderException(e);
+ }
+ } else {
+ throw new VorbisCommentReaderException("Invalid time string");
+ }
+ }
+
+ /**
+ * Return the id of a vorbiscomment chapter from a string like CHAPTERxxx*
+ *
+ * @return the id of the chapter key or -1 if the id couldn't be read.
+ * @throws VorbisCommentReaderException
+ * */
+ public static int getIDFromKey(String key) throws VorbisCommentReaderException {
+ if (key.length() >= CHAPTERXXX_LENGTH) { // >= CHAPTERxxx
+ try {
+ String strId = key.substring(8, 10);
+ return Integer.parseInt(strId);
+ } catch (NumberFormatException e) {
+ throw new VorbisCommentReaderException(e);
+ }
+ }
+ throw new VorbisCommentReaderException("key is too short (" + key + ")");
+ }
+
+ /**
+ * Get the string that comes after 'CHAPTERxxx', for example 'name' or
+ * 'url'.
+ */
+ public static String getAttributeTypeFromKey(String key) {
+ if (key.length() > CHAPTERXXX_LENGTH) {
+ return key.substring(CHAPTERXXX_LENGTH);
+ }
+ return null;
+ }
+
+ @Override
+ public int getChapterType() {
+ return CHAPTERTYPE_VORBISCOMMENT_CHAPTER;
+ }
+
+ public int getVorbisCommentId() {
+ return vorbisCommentId;
+ }
+}
diff --git a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentChapterReader.java b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentChapterReader.java
new file mode 100644
index 000000000..f833f683b
--- /dev/null
+++ b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentChapterReader.java
@@ -0,0 +1,99 @@
+package de.danoeh.antennapod.parser.media.vorbis;
+
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import de.danoeh.antennapod.model.feed.Chapter;
+import de.danoeh.antennapod.parser.media.BuildConfig;
+
+public class VorbisCommentChapterReader extends VorbisCommentReader {
+ private static final String TAG = "VorbisCommentChptrReadr";
+
+ private static final String CHAPTER_KEY = "chapter\\d\\d\\d.*";
+ private static final String CHAPTER_ATTRIBUTE_TITLE = "name";
+ private static final String CHAPTER_ATTRIBUTE_LINK = "url";
+
+ private List<Chapter> chapters;
+
+ public VorbisCommentChapterReader() {
+ }
+
+ @Override
+ public void onVorbisCommentFound() {
+ System.out.println("Vorbis comment found");
+ }
+
+ @Override
+ public void onVorbisCommentHeaderFound(VorbisCommentHeader header) {
+ chapters = new ArrayList<>();
+ System.out.println(header.toString());
+ }
+
+ @Override
+ public boolean onContentVectorKey(String content) {
+ return content.matches(CHAPTER_KEY);
+ }
+
+ @Override
+ public void onContentVectorValue(String key, String value) throws VorbisCommentReaderException {
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "Key: " + key + ", value: " + value);
+ }
+ String attribute = VorbisCommentChapter.getAttributeTypeFromKey(key);
+ int id = VorbisCommentChapter.getIDFromKey(key);
+ Chapter chapter = getChapterById(id);
+ if (attribute == null) {
+ if (getChapterById(id) == null) {
+ // new chapter
+ long start = VorbisCommentChapter.getStartTimeFromValue(value);
+ chapter = new VorbisCommentChapter(id);
+ chapter.setStart(start);
+ chapters.add(chapter);
+ } else {
+ throw new VorbisCommentReaderException("Found chapter with duplicate ID (" + key + ", " + value + ")");
+ }
+ } else if (attribute.equals(CHAPTER_ATTRIBUTE_TITLE)) {
+ if (chapter != null) {
+ chapter.setTitle(value);
+ }
+ } else if (attribute.equals(CHAPTER_ATTRIBUTE_LINK)) {
+ if (chapter != null) {
+ chapter.setLink(value);
+ }
+ }
+ }
+
+ @Override
+ public void onNoVorbisCommentFound() {
+ System.out.println("No vorbis comment found");
+ }
+
+ @Override
+ public void onEndOfComment() {
+ System.out.println("End of comment");
+ for (Chapter c : chapters) {
+ System.out.println(c.toString());
+ }
+ }
+
+ @Override
+ public void onError(VorbisCommentReaderException exception) {
+ exception.printStackTrace();
+ }
+
+ private Chapter getChapterById(long id) {
+ for (Chapter c : chapters) {
+ if (((VorbisCommentChapter) c).getVorbisCommentId() == id) {
+ return c;
+ }
+ }
+ return null;
+ }
+
+ public List<Chapter> getChapters() {
+ return chapters;
+ }
+
+}
diff --git a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentHeader.java b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentHeader.java
new file mode 100644
index 000000000..c7d39a490
--- /dev/null
+++ b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentHeader.java
@@ -0,0 +1,27 @@
+package de.danoeh.antennapod.parser.media.vorbis;
+
+class VorbisCommentHeader {
+ private final String vendorString;
+ private final long userCommentLength;
+
+ public VorbisCommentHeader(String vendorString, long userCommentLength) {
+ super();
+ this.vendorString = vendorString;
+ this.userCommentLength = userCommentLength;
+ }
+
+ @Override
+ public String toString() {
+ return "VorbisCommentHeader [vendorString=" + vendorString
+ + ", userCommentLength=" + userCommentLength + "]";
+ }
+
+ public String getVendorString() {
+ return vendorString;
+ }
+
+ public long getUserCommentLength() {
+ return userCommentLength;
+ }
+
+}
diff --git a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentReader.java b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentReader.java
new file mode 100644
index 000000000..319d3759c
--- /dev/null
+++ b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentReader.java
@@ -0,0 +1,167 @@
+package de.danoeh.antennapod.parser.media.vorbis;
+
+import androidx.annotation.NonNull;
+import org.apache.commons.io.EndianUtils;
+import org.apache.commons.io.IOUtils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.nio.ByteBuffer;
+import java.nio.charset.Charset;
+import java.util.Locale;
+
+public abstract class VorbisCommentReader {
+ /** Length of first page in an ogg file in bytes. */
+ private static final int FIRST_OGG_PAGE_LENGTH = 58;
+ private static final int FIRST_OPUS_PAGE_LENGTH = 47;
+ private static final int SECOND_PAGE_MAX_LENGTH = 64 * 1024 * 1024;
+ private static final int PACKET_TYPE_IDENTIFICATION = 1;
+ private static final int PACKET_TYPE_COMMENT = 3;
+
+ /** Called when Reader finds identification header. */
+ protected abstract void onVorbisCommentFound();
+
+ protected abstract void onVorbisCommentHeaderFound(VorbisCommentHeader header);
+
+ /**
+ * Is called every time the Reader finds a content vector. The handler
+ * should return true if it wants to handle the content vector.
+ */
+ protected abstract boolean onContentVectorKey(String content);
+
+ /**
+ * Is called if onContentVectorKey returned true for the key.
+ */
+ protected abstract void onContentVectorValue(String key, String value) throws VorbisCommentReaderException;
+
+ protected abstract void onNoVorbisCommentFound();
+
+ protected abstract void onEndOfComment();
+
+ protected abstract void onError(VorbisCommentReaderException exception);
+
+ public void readInputStream(InputStream input) throws VorbisCommentReaderException {
+ try {
+ // look for identification header
+ if (findIdentificationHeader(input)) {
+ onVorbisCommentFound();
+ input = new OggInputStream(input);
+ if (findCommentHeader(input)) {
+ VorbisCommentHeader commentHeader = readCommentHeader(input);
+ onVorbisCommentHeaderFound(commentHeader);
+ for (int i = 0; i < commentHeader.getUserCommentLength(); i++) {
+ readUserComment(input);
+ }
+ onEndOfComment();
+ } else {
+ onError(new VorbisCommentReaderException("No comment header found"));
+ }
+ } else {
+ onNoVorbisCommentFound();
+ }
+ } catch (IOException e) {
+ onError(new VorbisCommentReaderException(e));
+ }
+ }
+
+ private void readUserComment(InputStream input) throws VorbisCommentReaderException {
+ try {
+ long vectorLength = EndianUtils.readSwappedUnsignedInteger(input);
+ String key = readContentVectorKey(input, vectorLength).toLowerCase(Locale.US);
+ boolean readValue = onContentVectorKey(key);
+ if (readValue) {
+ String value = readUtf8String(input, (int) (vectorLength - key.length() - 1));
+ onContentVectorValue(key, value);
+ } else {
+ IOUtils.skipFully(input, vectorLength - key.length() - 1);
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ private String readUtf8String(InputStream input, long length) throws IOException {
+ byte[] buffer = new byte[(int) length];
+ IOUtils.readFully(input, buffer);
+ Charset charset = Charset.forName("UTF-8");
+ return charset.newDecoder().decode(ByteBuffer.wrap(buffer)).toString();
+ }
+
+ /**
+ * Looks for an identification header in the first page of the file. If an
+ * identification header is found, it will be skipped completely and the
+ * method will return true, otherwise false.
+ */
+ private boolean findIdentificationHeader(InputStream input) throws IOException {
+ byte[] buffer = new byte[FIRST_OPUS_PAGE_LENGTH];
+ IOUtils.readFully(input, buffer);
+ final byte[] oggIdentificationHeader = new byte[]{ PACKET_TYPE_IDENTIFICATION, 'v', 'o', 'r', 'b', 'i', 's' };
+ for (int i = 6; i < buffer.length; i++) {
+ if (bufferMatches(buffer, oggIdentificationHeader, i)) {
+ IOUtils.skip(input, FIRST_OGG_PAGE_LENGTH - FIRST_OPUS_PAGE_LENGTH);
+ return true;
+ } else if (bufferMatches(buffer, "OpusHead".getBytes(), i)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean findCommentHeader(InputStream input) throws IOException {
+ byte[] buffer = new byte[64]; // Enough space for some bytes. Used circularly.
+ final byte[] oggCommentHeader = new byte[]{ PACKET_TYPE_COMMENT, 'v', 'o', 'r', 'b', 'i', 's' };
+ for (int bytesRead = 0; bytesRead < SECOND_PAGE_MAX_LENGTH; bytesRead++) {
+ buffer[bytesRead % buffer.length] = (byte) input.read();
+ if (bufferMatches(buffer, oggCommentHeader, bytesRead)) {
+ return true;
+ } else if (bufferMatches(buffer, "OpusTags".getBytes(), bytesRead)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Reads backwards in haystack, starting at position. Checks if the bytes match needle.
+ * Uses haystack circularly, so when reading at (-1), it reads at (length - 1).
+ */
+ boolean bufferMatches(byte[] haystack, byte[] needle, int position) {
+ for (int i = 0; i < needle.length; i++) {
+ int posInHaystack = position - i;
+ while (posInHaystack < 0) {
+ posInHaystack += haystack.length;
+ }
+ posInHaystack = posInHaystack % haystack.length;
+ if (haystack[posInHaystack] != needle[needle.length - 1 - i]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @NonNull
+ private VorbisCommentHeader readCommentHeader(InputStream input) throws IOException, VorbisCommentReaderException {
+ try {
+ long vendorLength = EndianUtils.readSwappedUnsignedInteger(input);
+ String vendorName = readUtf8String(input, vendorLength);
+ long userCommentLength = EndianUtils.readSwappedUnsignedInteger(input);
+ return new VorbisCommentHeader(vendorName, userCommentLength);
+ } catch (UnsupportedEncodingException e) {
+ throw new VorbisCommentReaderException(e);
+ }
+ }
+
+ private String readContentVectorKey(InputStream input, long vectorLength) throws IOException {
+ StringBuilder builder = new StringBuilder();
+ for (int i = 0; i < vectorLength; i++) {
+ char c = (char) input.read();
+ if (c == '=') {
+ return builder.toString();
+ } else {
+ builder.append(c);
+ }
+ }
+ return null; // no key found
+ }
+}
diff --git a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentReaderException.java b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentReaderException.java
new file mode 100644
index 000000000..8de1b29c0
--- /dev/null
+++ b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentReaderException.java
@@ -0,0 +1,21 @@
+package de.danoeh.antennapod.parser.media.vorbis;
+
+public class VorbisCommentReaderException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ public VorbisCommentReaderException() {
+ super();
+ }
+
+ public VorbisCommentReaderException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public VorbisCommentReaderException(String message) {
+ super(message);
+ }
+
+ public VorbisCommentReaderException(Throwable message) {
+ super(message);
+ }
+}
diff --git a/parser/media/src/main/resources/auphonic.m4a b/parser/media/src/main/resources/auphonic.m4a
new file mode 100644
index 000000000..ca59a80f6
--- /dev/null
+++ b/parser/media/src/main/resources/auphonic.m4a
Binary files differ
diff --git a/parser/media/src/main/resources/auphonic.mp3 b/parser/media/src/main/resources/auphonic.mp3
new file mode 100644
index 000000000..ca2a7ed4f
--- /dev/null
+++ b/parser/media/src/main/resources/auphonic.mp3
Binary files differ
diff --git a/parser/media/src/main/resources/auphonic.ogg b/parser/media/src/main/resources/auphonic.ogg
new file mode 100644
index 000000000..de326517a
--- /dev/null
+++ b/parser/media/src/main/resources/auphonic.ogg
Binary files differ
diff --git a/parser/media/src/main/resources/auphonic.opus b/parser/media/src/main/resources/auphonic.opus
new file mode 100644
index 000000000..08538ecb7
--- /dev/null
+++ b/parser/media/src/main/resources/auphonic.opus
Binary files differ
diff --git a/parser/media/src/main/resources/hindenburg-journalist-pro.m4a b/parser/media/src/main/resources/hindenburg-journalist-pro.m4a
new file mode 100644
index 000000000..bd64dd9da
--- /dev/null
+++ b/parser/media/src/main/resources/hindenburg-journalist-pro.m4a
Binary files differ
diff --git a/parser/media/src/main/resources/hindenburg-journalist-pro.mp3 b/parser/media/src/main/resources/hindenburg-journalist-pro.mp3
new file mode 100644
index 000000000..d341b6045
--- /dev/null
+++ b/parser/media/src/main/resources/hindenburg-journalist-pro.mp3
Binary files differ
diff --git a/parser/media/src/main/resources/mp3chaps-py.mp3 b/parser/media/src/main/resources/mp3chaps-py.mp3
new file mode 100644
index 000000000..05d519fb0
--- /dev/null
+++ b/parser/media/src/main/resources/mp3chaps-py.mp3
Binary files differ
diff --git a/parser/media/src/main/resources/ultraschall5.mp3 b/parser/media/src/main/resources/ultraschall5.mp3
new file mode 100644
index 000000000..a73029a54
--- /dev/null
+++ b/parser/media/src/main/resources/ultraschall5.mp3
Binary files differ
diff --git a/parser/media/src/test/java/de/danoeh/antennapod/parser/media/id3/ChapterReaderTest.java b/parser/media/src/test/java/de/danoeh/antennapod/parser/media/id3/ChapterReaderTest.java
new file mode 100644
index 000000000..af535b12a
--- /dev/null
+++ b/parser/media/src/test/java/de/danoeh/antennapod/parser/media/id3/ChapterReaderTest.java
@@ -0,0 +1,205 @@
+package de.danoeh.antennapod.parser.media.id3;
+
+import de.danoeh.antennapod.model.feed.Chapter;
+import de.danoeh.antennapod.model.feed.EmbeddedChapterImage;
+import de.danoeh.antennapod.parser.media.id3.model.FrameHeader;
+import org.apache.commons.io.input.CountingInputStream;
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+
+public class ChapterReaderTest {
+ private static final byte CHAPTER_WITHOUT_SUBFRAME_START_TIME = 23;
+ private static final byte[] CHAPTER_WITHOUT_SUBFRAME = {
+ 'C', 'H', '1', 0, // String ID for mapping to CTOC
+ 0, 0, 0, CHAPTER_WITHOUT_SUBFRAME_START_TIME, // Start time
+ 0, 0, 0, 0, // End time
+ 0, 0, 0, 0, // Start offset
+ 0, 0, 0, 0 // End offset
+ };
+
+ @Test
+ public void testReadFullTagWithChapter() throws IOException, ID3ReaderException {
+ byte[] chapter = Id3ReaderTest.concat(
+ Id3ReaderTest.generateFrameHeader(ChapterReader.FRAME_ID_CHAPTER, CHAPTER_WITHOUT_SUBFRAME.length),
+ CHAPTER_WITHOUT_SUBFRAME);
+ byte[] data = Id3ReaderTest.concat(
+ Id3ReaderTest.generateId3Header(chapter.length),
+ chapter);
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ ChapterReader reader = new ChapterReader(inputStream);
+ reader.readInputStream();
+ assertEquals(1, reader.getChapters().size());
+ assertEquals(CHAPTER_WITHOUT_SUBFRAME_START_TIME, reader.getChapters().get(0).getStart());
+ }
+
+ @Test
+ public void testReadFullTagWithMultipleChapters() throws IOException, ID3ReaderException {
+ byte[] chapter = Id3ReaderTest.concat(
+ Id3ReaderTest.generateFrameHeader(ChapterReader.FRAME_ID_CHAPTER, CHAPTER_WITHOUT_SUBFRAME.length),
+ CHAPTER_WITHOUT_SUBFRAME);
+ byte[] data = Id3ReaderTest.concat(
+ Id3ReaderTest.generateId3Header(2 * chapter.length),
+ chapter,
+ chapter);
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ ChapterReader reader = new ChapterReader(inputStream);
+ reader.readInputStream();
+ assertEquals(2, reader.getChapters().size());
+ assertEquals(CHAPTER_WITHOUT_SUBFRAME_START_TIME, reader.getChapters().get(0).getStart());
+ assertEquals(CHAPTER_WITHOUT_SUBFRAME_START_TIME, reader.getChapters().get(1).getStart());
+ }
+
+ @Test
+ public void testReadChapterWithoutSubframes() throws IOException, ID3ReaderException {
+ FrameHeader header = new FrameHeader(ChapterReader.FRAME_ID_CHAPTER,
+ CHAPTER_WITHOUT_SUBFRAME.length, (short) 0);
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(CHAPTER_WITHOUT_SUBFRAME));
+ Chapter chapter = new ChapterReader(inputStream).readChapter(header);
+ assertEquals(CHAPTER_WITHOUT_SUBFRAME_START_TIME, chapter.getStart());
+ }
+
+ @Test
+ public void testReadChapterWithTitle() throws IOException, ID3ReaderException {
+ byte[] title = {
+ ID3Reader.ENCODING_ISO,
+ 'H', 'e', 'l', 'l', 'o', // Title
+ 0 // Null-terminated
+ };
+ byte[] chapterData = Id3ReaderTest.concat(
+ CHAPTER_WITHOUT_SUBFRAME,
+ Id3ReaderTest.generateFrameHeader(ChapterReader.FRAME_ID_TITLE, title.length),
+ title);
+ FrameHeader header = new FrameHeader(ChapterReader.FRAME_ID_CHAPTER, chapterData.length, (short) 0);
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(chapterData));
+ ChapterReader reader = new ChapterReader(inputStream);
+ Chapter chapter = reader.readChapter(header);
+ assertEquals(CHAPTER_WITHOUT_SUBFRAME_START_TIME, chapter.getStart());
+ assertEquals("Hello", chapter.getTitle());
+ }
+
+ @Test
+ public void testReadTitleWithGarbage() throws IOException, ID3ReaderException {
+ byte[] titleSubframeContent = {
+ ID3Reader.ENCODING_ISO,
+ 'A', // Title
+ 0, // Null-terminated
+ 42, 42, 42, 42 // Garbage, should be ignored
+ };
+ FrameHeader header = new FrameHeader(ChapterReader.FRAME_ID_TITLE, titleSubframeContent.length, (short) 0);
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(titleSubframeContent));
+ ChapterReader reader = new ChapterReader(inputStream);
+ Chapter chapter = new ID3Chapter("", 0);
+ reader.readChapterSubFrame(header, chapter);
+ assertEquals("A", chapter.getTitle());
+
+ // Should skip the garbage and point to the next frame
+ assertEquals(titleSubframeContent.length, reader.getPosition());
+ }
+
+ @Test
+ public void testRealFileUltraschall() throws IOException, ID3ReaderException {
+ CountingInputStream inputStream = new CountingInputStream(getClass().getClassLoader()
+ .getResource("ultraschall5.mp3").openStream());
+ ChapterReader reader = new ChapterReader(inputStream);
+ reader.readInputStream();
+ List<Chapter> chapters = reader.getChapters();
+
+ assertEquals(3, chapters.size());
+
+ assertEquals(0, chapters.get(0).getStart());
+ assertEquals(4004, chapters.get(1).getStart());
+ assertEquals(7999, chapters.get(2).getStart());
+
+ assertEquals("Marke 1", chapters.get(0).getTitle());
+ assertEquals("Marke 2", chapters.get(1).getTitle());
+ assertEquals("Marke 3", chapters.get(2).getTitle());
+
+ assertEquals("https://example.com", chapters.get(0).getLink());
+ assertEquals("https://example.com", chapters.get(1).getLink());
+ assertEquals("https://example.com", chapters.get(2).getLink());
+
+ assertEquals(EmbeddedChapterImage.makeUrl(16073, 2750569), chapters.get(0).getImageUrl());
+ assertEquals(EmbeddedChapterImage.makeUrl(2766765, 15740), chapters.get(1).getImageUrl());
+ assertEquals(EmbeddedChapterImage.makeUrl(2782628, 2750569), chapters.get(2).getImageUrl());
+ }
+
+ @Test
+ public void testRealFileAuphonic() throws IOException, ID3ReaderException {
+ CountingInputStream inputStream = new CountingInputStream(getClass().getClassLoader()
+ .getResource("auphonic.mp3").openStream());
+ ChapterReader reader = new ChapterReader(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());
+
+ assertEquals("https://example.com", chapters.get(0).getLink());
+ assertEquals("https://example.com", chapters.get(1).getLink());
+ assertEquals("https://example.com", chapters.get(2).getLink());
+ assertEquals("https://example.com", chapters.get(3).getLink());
+
+ assertEquals(EmbeddedChapterImage.makeUrl(765, 308), chapters.get(0).getImageUrl());
+ assertEquals(EmbeddedChapterImage.makeUrl(1271, 308), chapters.get(1).getImageUrl());
+ assertEquals(EmbeddedChapterImage.makeUrl(1771, 308), chapters.get(2).getImageUrl());
+ assertEquals(EmbeddedChapterImage.makeUrl(2259, 308), chapters.get(3).getImageUrl());
+ }
+
+ @Test
+ public void testRealFileHindenburgJournalistPro() throws IOException, ID3ReaderException {
+ CountingInputStream inputStream = new CountingInputStream(getClass().getClassLoader()
+ .getResource("hindenburg-journalist-pro.mp3").openStream());
+ ChapterReader reader = new ChapterReader(inputStream);
+ reader.readInputStream();
+ List<Chapter> chapters = reader.getChapters();
+
+ assertEquals(2, chapters.size());
+
+ assertEquals(0, chapters.get(0).getStart());
+ assertEquals(5006, chapters.get(1).getStart());
+
+ assertEquals("Chapter Marker 1", chapters.get(0).getTitle());
+ assertEquals("Chapter Marker 2", chapters.get(1).getTitle());
+
+ assertEquals("https://example.com/chapter1url", chapters.get(0).getLink());
+ assertEquals("https://example.com/chapter2url", chapters.get(1).getLink());
+
+ assertEquals(EmbeddedChapterImage.makeUrl(5330, 4015), chapters.get(0).getImageUrl());
+ assertEquals(EmbeddedChapterImage.makeUrl(9498, 4364), chapters.get(1).getImageUrl());
+ }
+
+ @Test
+ public void testRealFileMp3chapsPy() throws IOException, ID3ReaderException {
+ CountingInputStream inputStream = new CountingInputStream(getClass().getClassLoader()
+ .getResource("mp3chaps-py.mp3").openStream());
+ ChapterReader reader = new ChapterReader(inputStream);
+ reader.readInputStream();
+ List<Chapter> chapters = reader.getChapters();
+
+ assertEquals(4, chapters.size());
+
+ assertEquals(0, chapters.get(0).getStart());
+ assertEquals(7000, chapters.get(1).getStart());
+ assertEquals(9000, chapters.get(2).getStart());
+ assertEquals(11000, chapters.get(3).getStart());
+
+ assertEquals("Start", chapters.get(0).getTitle());
+ assertEquals("Chapter 1", chapters.get(1).getTitle());
+ assertEquals("Chapter 2", chapters.get(2).getTitle());
+ assertEquals("Chapter 3", chapters.get(3).getTitle());
+ }
+}
diff --git a/parser/media/src/test/java/de/danoeh/antennapod/parser/media/id3/Id3ReaderTest.java b/parser/media/src/test/java/de/danoeh/antennapod/parser/media/id3/Id3ReaderTest.java
new file mode 100644
index 000000000..4b0bb2113
--- /dev/null
+++ b/parser/media/src/test/java/de/danoeh/antennapod/parser/media/id3/Id3ReaderTest.java
@@ -0,0 +1,151 @@
+package de.danoeh.antennapod.parser.media.id3;
+
+import de.danoeh.antennapod.parser.media.id3.model.FrameHeader;
+import de.danoeh.antennapod.parser.media.id3.model.TagHeader;
+import org.apache.commons.io.input.CountingInputStream;
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class Id3ReaderTest {
+ @Test
+ public void testReadString() throws IOException {
+ byte[] data = {
+ ID3Reader.ENCODING_ISO,
+ 'T', 'e', 's', 't',
+ 0 // Null-terminated
+ };
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ String string = new ID3Reader(inputStream).readEncodingAndString(1000);
+ assertEquals("Test", string);
+ }
+
+ @Test
+ public void testReadMultipleStrings() throws IOException {
+ byte[] data = {
+ ID3Reader.ENCODING_ISO,
+ 'F', 'o', 'o',
+ 0, // Null-terminated
+ ID3Reader.ENCODING_ISO,
+ 'B', 'a', 'r',
+ 0 // Null-terminated
+ };
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ ID3Reader reader = new ID3Reader(inputStream);
+ assertEquals("Foo", reader.readEncodingAndString(1000));
+ assertEquals("Bar", reader.readEncodingAndString(1000));
+ }
+
+ @Test
+ public void testReadingLimit() throws IOException {
+ byte[] data = {
+ ID3Reader.ENCODING_ISO,
+ 'A', 'B', 'C', 'D'
+ };
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ ID3Reader reader = new ID3Reader(inputStream);
+ assertEquals("ABC", reader.readEncodingAndString(4)); // Includes encoding
+ assertEquals('D', reader.readByte());
+ }
+
+ @Test
+ public void testReadUtf16RespectsBom() throws IOException {
+ byte[] data = {
+ ID3Reader.ENCODING_UTF16_WITH_BOM,
+ (byte) 0xff, (byte) 0xfe, // BOM: Little-endian
+ 'A', 0, 'B', 0, 'C', 0,
+ 0, 0, // Null-terminated
+ ID3Reader.ENCODING_UTF16_WITH_BOM,
+ (byte) 0xfe, (byte) 0xff, // BOM: Big-endian
+ 0, 'D', 0, 'E', 0, 'F',
+ 0, 0, // Null-terminated
+ };
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ ID3Reader reader = new ID3Reader(inputStream);
+ assertEquals("ABC", reader.readEncodingAndString(1000));
+ assertEquals("DEF", reader.readEncodingAndString(1000));
+ }
+
+ @Test
+ public void testReadUtf16NullPrefix() throws IOException {
+ byte[] data = {
+ ID3Reader.ENCODING_UTF16_WITH_BOM,
+ (byte) 0xff, (byte) 0xfe, // BOM
+ 0x00, 0x01, // Latin Capital Letter A with macron (Ā)
+ 0, 0, // Null-terminated
+ };
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ String string = new ID3Reader(inputStream).readEncodingAndString(1000);
+ assertEquals("Ā", string);
+ }
+
+ @Test
+ public void testReadingLimitUtf16() throws IOException {
+ byte[] data = {
+ ID3Reader.ENCODING_UTF16_WITHOUT_BOM,
+ 'A', 0, 'B', 0, 'C', 0, 'D', 0
+ };
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ ID3Reader reader = new ID3Reader(inputStream);
+ reader.readEncodingAndString(6); // Includes encoding, produces broken string
+ assertTrue("Should respect limit even if it breaks a symbol", reader.getPosition() <= 6);
+ }
+
+ @Test
+ public void testReadTagHeader() throws IOException, ID3ReaderException {
+ byte[] data = generateId3Header(23);
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ TagHeader header = new ID3Reader(inputStream).readTagHeader();
+ assertEquals("ID3", header.getId());
+ assertEquals(42, header.getVersion());
+ assertEquals(23, header.getSize());
+ }
+
+ @Test
+ public void testReadFrameHeader() throws IOException {
+ byte[] data = generateFrameHeader("CHAP", 42);
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ FrameHeader header = new ID3Reader(inputStream).readFrameHeader();
+ assertEquals("CHAP", header.getId());
+ assertEquals(42, header.getSize());
+ }
+
+ public static byte[] generateFrameHeader(String id, int size) {
+ return concat(
+ id.getBytes(StandardCharsets.ISO_8859_1), // Frame ID
+ new byte[] {
+ (byte) (size >> 24), (byte) (size >> 16),
+ (byte) (size >> 8), (byte) (size), // Size
+ 0, 0 // Flags
+ });
+ }
+
+ static byte[] generateId3Header(int size) {
+ return new byte[] {
+ 'I', 'D', '3', // Identifier
+ 0, 42, // Version
+ 0, // Flags
+ (byte) (size >> 24), (byte) (size >> 16),
+ (byte) (size >> 8), (byte) (size), // Size
+ };
+ }
+
+ static byte[] concat(byte[]... arrays) {
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ try {
+ for (byte[] array : arrays) {
+ outputStream.write(array);
+ }
+ } catch (IOException e) {
+ fail(e.getMessage());
+ }
+ return outputStream.toByteArray();
+ }
+}
diff --git a/parser/media/src/test/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentChapterReaderTest.java b/parser/media/src/test/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentChapterReaderTest.java
new file mode 100644
index 000000000..b81ce4651
--- /dev/null
+++ b/parser/media/src/test/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentChapterReaderTest.java
@@ -0,0 +1,44 @@
+package de.danoeh.antennapod.parser.media.vorbis;
+
+import de.danoeh.antennapod.model.feed.Chapter;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+
+public class VorbisCommentChapterReaderTest {
+
+ @Test
+ public void testRealFilesAuphonic() throws IOException, VorbisCommentReaderException {
+ testRealFileAuphonic("auphonic.ogg");
+ testRealFileAuphonic("auphonic.opus");
+ }
+
+ public void testRealFileAuphonic(String filename) throws IOException, VorbisCommentReaderException {
+ InputStream inputStream = getClass().getClassLoader()
+ .getResource(filename).openStream();
+ VorbisCommentChapterReader reader = new VorbisCommentChapterReader();
+ reader.readInputStream(inputStream);
+ 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());
+
+ assertEquals("https://example.com", chapters.get(0).getLink());
+ assertEquals("https://example.com", chapters.get(1).getLink());
+ assertEquals("https://example.com", chapters.get(2).getLink());
+ assertEquals("https://example.com", chapters.get(3).getLink());
+ }
+}