diff options
Diffstat (limited to 'parser/media')
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 Binary files differnew file mode 100644 index 000000000..ca59a80f6 --- /dev/null +++ b/parser/media/src/main/resources/auphonic.m4a diff --git a/parser/media/src/main/resources/auphonic.mp3 b/parser/media/src/main/resources/auphonic.mp3 Binary files differnew file mode 100644 index 000000000..ca2a7ed4f --- /dev/null +++ b/parser/media/src/main/resources/auphonic.mp3 diff --git a/parser/media/src/main/resources/auphonic.ogg b/parser/media/src/main/resources/auphonic.ogg Binary files differnew file mode 100644 index 000000000..de326517a --- /dev/null +++ b/parser/media/src/main/resources/auphonic.ogg diff --git a/parser/media/src/main/resources/auphonic.opus b/parser/media/src/main/resources/auphonic.opus Binary files differnew file mode 100644 index 000000000..08538ecb7 --- /dev/null +++ b/parser/media/src/main/resources/auphonic.opus diff --git a/parser/media/src/main/resources/hindenburg-journalist-pro.m4a b/parser/media/src/main/resources/hindenburg-journalist-pro.m4a Binary files differnew file mode 100644 index 000000000..bd64dd9da --- /dev/null +++ b/parser/media/src/main/resources/hindenburg-journalist-pro.m4a diff --git a/parser/media/src/main/resources/hindenburg-journalist-pro.mp3 b/parser/media/src/main/resources/hindenburg-journalist-pro.mp3 Binary files differnew file mode 100644 index 000000000..d341b6045 --- /dev/null +++ b/parser/media/src/main/resources/hindenburg-journalist-pro.mp3 diff --git a/parser/media/src/main/resources/mp3chaps-py.mp3 b/parser/media/src/main/resources/mp3chaps-py.mp3 Binary files differnew file mode 100644 index 000000000..05d519fb0 --- /dev/null +++ b/parser/media/src/main/resources/mp3chaps-py.mp3 diff --git a/parser/media/src/main/resources/ultraschall5.mp3 b/parser/media/src/main/resources/ultraschall5.mp3 Binary files differnew file mode 100644 index 000000000..a73029a54 --- /dev/null +++ b/parser/media/src/main/resources/ultraschall5.mp3 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()); + } +} |