Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for parsing Nero M4A chapters #7159

Merged
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

public class Chapter {
private long id;
/** Defines starting point in milliseconds. */
/** The start time of the chapter in milliseconds */
private long start;
private String title;
private String link;
Expand Down Expand Up @@ -66,7 +66,7 @@ public void setChapterId(String chapterId) {

@Override
public String toString() {
return "ID3Chapter [title=" + getTitle() + ", start=" + getStart() + ", url=" + getLink() + "]";
return "Chapter [title=" + getTitle() + ", start=" + getStart() + ", url=" + getLink() + "]";
}

public long getId() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package de.danoeh.antennapod.parser.media.m4a;

import android.util.Log;

import org.apache.commons.io.IOUtils;

import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

import de.danoeh.antennapod.model.feed.Chapter;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;

public class M4AChapterReader {
private static final String TAG = "M4AChapterReader";
private final List<Chapter> chapters = new ArrayList<>();
private final InputStream inputStream;
private static final int FTYP_CODE = 0x66747970; // "ftyp"

public M4AChapterReader(InputStream input) {
inputStream = input;
}

/**
* Read the input stream populating the chapters list
*/
public void readInputStream() {
try {
isM4A(inputStream);
int dataSize = this.findAtom("moov.udta.chpl");
if (dataSize == -1) {
Log.d(TAG, "Nero Chapter Atom not found");
} else {
Log.d(TAG, "Nero Chapter Atom found. Data Size: " + dataSize);
this.parseNeroChapterAtom(dataSize);
}
} catch (Exception e) {
Log.d(TAG, "ERROR: " + e.getMessage());
}
}

/**
* Find the atom with the given name in the M4A file
*
* @param name the name of the atom to find, separated by dots
* @return the size of the atom (minus the 8-byte header) if found
* @throws IOException if an I/O error occurs or the atom is not found
*/
public int findAtom(String name) throws IOException {
// Split the name into parts encoded as UTF-8
String[] parts = name.split("\\.");
int partIndex = 0;
// Initialize remaining size to track the current part's size and check if it is exceeded
int remainingSize = -1;

// Read the M4A file atom by atom
ByteBuffer buffer = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN);
while (true) {
// Read the atom header
IOUtils.readFully(inputStream, buffer.array());
// Get the size of the current atom
int chunkSize = buffer.getInt();
int dataSize = chunkSize - 8;

// Get the atom type
String atomType = StandardCharsets.UTF_8.decode(buffer).toString();

// Reset the buffer for reading the atom data
buffer.clear();

// Check if the current atom matches the current part of the name
if (atomType.equals(parts[partIndex])) {
if (partIndex == parts.length - 1) {
// If the current atom is the last part of the name return its size
return dataSize;
} else {
// Else move to the next part of the name
partIndex++;
// Update the remaining size
remainingSize = dataSize;
}
} else {
// Do not check the remaining size of top-level atoms
if (partIndex > 0) {
// Update the remaining size
remainingSize -= dataSize;
// If the remaining size is exhausted, throw an exception
if (remainingSize <= 0) {
throw new IOException("Part size exceeded for part \"" + parts[partIndex - 1]
+ "\" while searching atom. Remaining Size: " + remainingSize);
}
}
// Skip the rest of the atom
IOUtils.skipFully(inputStream, dataSize);
}
}
}

/**
* Parse the Nero Chapter Atom in the M4A file
* Assumes that the current position is at the start of the Nero Chapter Atom
*
* @param chunkSize the size of the Nero Chapter Atom
* @throws IOException if an I/O error occurs
* @see <a href="https://github.com/Zeugma440/atldotnet/wiki/Focus-on-Chapter-metadata#nero-chapters">Nero Chapter</a>
*/
private void parseNeroChapterAtom(long chunkSize) throws IOException {
// Read the Nero Chapter Atom data into a buffer
ByteBuffer byteBuffer = ByteBuffer.allocate((int) chunkSize).order(ByteOrder.BIG_ENDIAN);
IOUtils.readFully(inputStream, byteBuffer.array());
// Skip the 5-byte header
// Nero Chapter Atom consists of a 5-byte header followed by chapter data
// The first 4 bytes are the version and flags, the 5th byte is reserved
byteBuffer.position(5);
// Get the chapter count
int chapterCount = byteBuffer.getInt();
Log.d(TAG, "Nero Chapter Count: " + chapterCount);

// Parse each chapter
for (int i = 0; i < chapterCount; i++) {
long startTime = byteBuffer.getLong();
int chapterNameSize = byteBuffer.get();
byte[] chapterNameBytes = new byte[chapterNameSize];
byteBuffer.get(chapterNameBytes, 0, chapterNameSize);
String chapterName = new String(chapterNameBytes, StandardCharsets.UTF_8);

Chapter chapter = new Chapter();
chapter.setStart(startTime / 10000);
chapter.setTitle(chapterName);
chapter.setChapterId(String.valueOf(i + 1));
chapters.add(chapter);

Log.d(TAG, "Nero Chapter " + (i + 1) + ": " + chapter);
}
}

public List<Chapter> getChapters() {
return chapters;
}

/**
* Assert that the input stream is an M4A file by checking the signature
*
* @param inputStream the input stream to check
* @throws IOException if an I/O error occurs
*/
public static void isM4A(InputStream inputStream) throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN);
IOUtils.readFully(inputStream, byteBuffer.array());

int ftypSize = byteBuffer.getInt();
if (byteBuffer.getInt() != FTYP_CODE) {
throw new IOException("Not an M4A file");
}
IOUtils.skipFully(inputStream, ftypSize - 8);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package de.danoeh.antennapod.parser.media.m4a;

import de.danoeh.antennapod.model.feed.Chapter;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;

import static org.junit.Assert.assertEquals;

@RunWith(RobolectricTestRunner.class)
public class M4AChapterReaderTest {

@Test
public void testFiles() throws IOException {
testFile();
}

public void testFile() throws IOException {
InputStream inputStream = getClass().getClassLoader()
.getResource("nero-chapters.m4a").openStream();
M4AChapterReader reader = new M4AChapterReader(inputStream);
reader.readInputStream();
List<Chapter> chapters = reader.getChapters();

assertEquals(4, chapters.size());

assertEquals(0, chapters.get(0).getStart());
assertEquals(3000, chapters.get(1).getStart());
assertEquals(6000, chapters.get(2).getStart());
assertEquals(9000, chapters.get(3).getStart());

assertEquals("Chapter 1 - ❤️😊", chapters.get(0).getTitle());
assertEquals("Chapter 2 - ßöÄ", chapters.get(1).getTitle());
assertEquals("Chapter 3 - 爱", chapters.get(2).getTitle());
assertEquals("Chapter 4", chapters.get(3).getTitle());
}
}
Binary file added parser/media/src/test/resources/nero-chapters.m4a
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import de.danoeh.antennapod.model.playback.Playable;
import de.danoeh.antennapod.parser.media.vorbis.VorbisCommentChapterReader;
import de.danoeh.antennapod.parser.media.vorbis.VorbisCommentReaderException;
import de.danoeh.antennapod.parser.media.m4a.M4AChapterReader;
import okhttp3.CacheControl;
import okhttp3.Request;
import okhttp3.Response;
Expand Down Expand Up @@ -106,6 +107,19 @@ public static List<Chapter> loadChaptersFromMediaFile(Playable playable, Context
} catch (IOException | VorbisCommentReaderException e) {
Log.e(TAG, "Unable to load vorbis chapters: " + e.getMessage());
}

try (CountingInputStream in = openStream(playable, context)) {
List<Chapter> chapters = readM4AChaptersFromInputStream(in);
if (!chapters.isEmpty()) {
Log.i(TAG, "Chapters loaded");
return chapters;
}
} catch (InterruptedIOException e) {
throw e;
} catch (IOException e) {
Log.e(TAG, "Unable to open stream " + e.getMessage());
}

return null;
}

Expand Down Expand Up @@ -195,6 +209,22 @@ private static List<Chapter> readOggChaptersFromInputStream(InputStream input) t
return Collections.emptyList();
}

@NonNull
private static List<Chapter> readM4AChaptersFromInputStream(InputStream input) {
M4AChapterReader reader = new M4AChapterReader(new BufferedInputStream(input));
reader.readInputStream();
List<Chapter> chapters = reader.getChapters();
if (chapters == null) {
return Collections.emptyList();
}
Collections.sort(chapters, new ChapterStartTimeComparator());
enumerateEmptyChapterTitles(chapters);
if (chaptersValid(chapters)) {
return chapters;
}
return Collections.emptyList();
}

/**
* Makes sure that chapter does a title and an item attribute.
*/
Expand Down