From 92ab575b150ab49ca85e0ac994558142e49c9e68 Mon Sep 17 00:00:00 2001 From: ByteHamster Date: Fri, 5 Apr 2024 19:20:27 +0200 Subject: Delete core module (#7060) --- ui/chapters/README.md | 3 + ui/chapters/build.gradle | 21 ++ .../antennapod/ui/chapters/ChapterMerger.java | 70 +++++++ .../antennapod/ui/chapters/ChapterUtils.java | 228 +++++++++++++++++++++ 4 files changed, 322 insertions(+) create mode 100644 ui/chapters/README.md create mode 100644 ui/chapters/build.gradle create mode 100644 ui/chapters/src/main/java/de/danoeh/antennapod/ui/chapters/ChapterMerger.java create mode 100644 ui/chapters/src/main/java/de/danoeh/antennapod/ui/chapters/ChapterUtils.java (limited to 'ui/chapters') diff --git a/ui/chapters/README.md b/ui/chapters/README.md new file mode 100644 index 000000000..68136b436 --- /dev/null +++ b/ui/chapters/README.md @@ -0,0 +1,3 @@ +# :ui:chapters + +This module provides chapter loading and merging for display, but not the actual UI to display them. diff --git a/ui/chapters/build.gradle b/ui/chapters/build.gradle new file mode 100644 index 000000000..a3cb1b677 --- /dev/null +++ b/ui/chapters/build.gradle @@ -0,0 +1,21 @@ +plugins { + id("com.android.library") +} +apply from: "../../common.gradle" +apply from: "../../playFlavor.gradle" + +android { + namespace "de.danoeh.antennapod.ui.chapters" +} + +dependencies { + implementation project(':model') + implementation project(':net:common') + implementation project(':parser:media') + implementation project(':parser:feed') + implementation project(':storage:database') + + annotationProcessor "androidx.annotation:annotation:$annotationVersion" + implementation "commons-io:commons-io:$commonsioVersion" + implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" +} diff --git a/ui/chapters/src/main/java/de/danoeh/antennapod/ui/chapters/ChapterMerger.java b/ui/chapters/src/main/java/de/danoeh/antennapod/ui/chapters/ChapterMerger.java new file mode 100644 index 000000000..7ec11b566 --- /dev/null +++ b/ui/chapters/src/main/java/de/danoeh/antennapod/ui/chapters/ChapterMerger.java @@ -0,0 +1,70 @@ +package de.danoeh.antennapod.ui.chapters; + +import android.text.TextUtils; +import android.util.Log; +import androidx.annotation.Nullable; +import de.danoeh.antennapod.model.feed.Chapter; + +import java.util.List; + +public class ChapterMerger { + private static final String TAG = "ChapterMerger"; + + private ChapterMerger() { + + } + + /** + * This method might modify the input data. + */ + @Nullable + public static List merge(@Nullable List chapters1, @Nullable List chapters2) { + Log.d(TAG, "Merging chapters"); + if (chapters1 == null) { + return chapters2; + } else if (chapters2 == null) { + return chapters1; + } else if (chapters2.size() > chapters1.size()) { + return chapters2; + } else if (chapters2.size() < chapters1.size()) { + return chapters1; + } else { + // Merge chapter lists of same length. Store in chapters2 array. + // In case the lists can not be merged, return chapters1 array. + for (int i = 0; i < chapters2.size(); i++) { + Chapter chapterTarget = chapters2.get(i); + Chapter chapterOther = chapters1.get(i); + + if (Math.abs(chapterTarget.getStart() - chapterOther.getStart()) > 1000) { + Log.e(TAG, "Chapter lists are too different. Cancelling merge."); + return score(chapters1) > score(chapters2) ? chapters1 : chapters2; + } + + if (TextUtils.isEmpty(chapterTarget.getImageUrl())) { + chapterTarget.setImageUrl(chapterOther.getImageUrl()); + } + if (TextUtils.isEmpty(chapterTarget.getLink())) { + chapterTarget.setLink(chapterOther.getLink()); + } + if (TextUtils.isEmpty(chapterTarget.getTitle())) { + chapterTarget.setTitle(chapterOther.getTitle()); + } + } + return chapters2; + } + } + + /** + * Tries to give a score that can determine which list of chapters a user might want to see. + */ + private static int score(List chapters) { + int score = 0; + for (Chapter chapter : chapters) { + score = score + + (TextUtils.isEmpty(chapter.getTitle()) ? 0 : 1) + + (TextUtils.isEmpty(chapter.getLink()) ? 0 : 1) + + (TextUtils.isEmpty(chapter.getImageUrl()) ? 0 : 1); + } + return score; + } +} diff --git a/ui/chapters/src/main/java/de/danoeh/antennapod/ui/chapters/ChapterUtils.java b/ui/chapters/src/main/java/de/danoeh/antennapod/ui/chapters/ChapterUtils.java new file mode 100644 index 000000000..5554890ed --- /dev/null +++ b/ui/chapters/src/main/java/de/danoeh/antennapod/ui/chapters/ChapterUtils.java @@ -0,0 +1,228 @@ +package de.danoeh.antennapod.ui.chapters; + +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; +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.FeedMedia; +import de.danoeh.antennapod.net.common.AntennapodHttpClient; +import de.danoeh.antennapod.storage.database.DBReader; +import de.danoeh.antennapod.parser.feed.PodcastIndexChapterParser; +import de.danoeh.antennapod.parser.media.id3.ChapterReader; +import de.danoeh.antennapod.parser.media.id3.ID3ReaderException; +import de.danoeh.antennapod.model.playback.Playable; +import de.danoeh.antennapod.parser.media.vorbis.VorbisCommentChapterReader; +import de.danoeh.antennapod.parser.media.vorbis.VorbisCommentReaderException; +import okhttp3.CacheControl; +import okhttp3.Request; +import okhttp3.Response; +import org.apache.commons.io.input.CountingInputStream; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * Utility class for getting chapter data from media files. + */ +public class ChapterUtils { + + private static final String TAG = "ChapterUtils"; + + private ChapterUtils() { + } + + public static void loadChapters(Playable playable, Context context, boolean forceRefresh) { + if (playable.getChapters() != null && !forceRefresh) { + // Already loaded + return; + } + + try { + List chaptersFromDatabase = null; + List chaptersFromPodcastIndex = null; + if (playable instanceof FeedMedia) { + FeedMedia feedMedia = (FeedMedia) playable; + if (feedMedia.getItem() == null) { + feedMedia.setItem(DBReader.getFeedItem(feedMedia.getItemId())); + } + if (feedMedia.getItem().hasChapters()) { + chaptersFromDatabase = DBReader.loadChaptersOfFeedItem(feedMedia.getItem()); + } + + if (!TextUtils.isEmpty(feedMedia.getItem().getPodcastIndexChapterUrl())) { + chaptersFromPodcastIndex = ChapterUtils.loadChaptersFromUrl( + feedMedia.getItem().getPodcastIndexChapterUrl(), forceRefresh); + } + + } + + List chaptersFromMediaFile = ChapterUtils.loadChaptersFromMediaFile(playable, context); + List chaptersMergePhase1 = ChapterMerger.merge(chaptersFromDatabase, chaptersFromMediaFile); + List chapters = ChapterMerger.merge(chaptersMergePhase1, chaptersFromPodcastIndex); + if (chapters == null) { + // Do not try loading again. There are no chapters or parsing failed. + playable.setChapters(Collections.emptyList()); + } else { + playable.setChapters(chapters); + } + } catch (InterruptedIOException e) { + Log.d(TAG, "Chapter loading interrupted"); + playable.setChapters(null); // Allow later retry + } + } + + public static List loadChaptersFromMediaFile(Playable playable, Context context) + throws InterruptedIOException { + try (CountingInputStream in = openStream(playable, context)) { + List chapters = readId3ChaptersFrom(in); + if (!chapters.isEmpty()) { + Log.i(TAG, "Chapters loaded"); + return chapters; + } + } catch (InterruptedIOException e) { + throw e; + } catch (IOException | ID3ReaderException e) { + Log.e(TAG, "Unable to load ID3 chapters: " + e.getMessage()); + } + + try (CountingInputStream in = openStream(playable, context)) { + List chapters = readOggChaptersFromInputStream(in); + if (!chapters.isEmpty()) { + Log.i(TAG, "Chapters loaded"); + return chapters; + } + } catch (InterruptedIOException e) { + throw e; + } catch (IOException | VorbisCommentReaderException e) { + Log.e(TAG, "Unable to load vorbis chapters: " + e.getMessage()); + } + return null; + } + + private static CountingInputStream openStream(Playable playable, Context context) throws IOException { + if (playable.localFileAvailable()) { + if (playable.getLocalFileUrl() == null) { + throw new IOException("No local url"); + } + File source = new File(playable.getLocalFileUrl()); + if (!source.exists()) { + throw new IOException("Local file does not exist"); + } + return new CountingInputStream(new BufferedInputStream(new FileInputStream(source))); + } else if (playable.getStreamUrl().startsWith(ContentResolver.SCHEME_CONTENT)) { + Uri uri = Uri.parse(playable.getStreamUrl()); + return new CountingInputStream(new BufferedInputStream(context.getContentResolver().openInputStream(uri))); + } else { + Request request = new Request.Builder().url(playable.getStreamUrl()).build(); + Response response = AntennapodHttpClient.getHttpClient().newCall(request).execute(); + if (response.body() == null) { + throw new IOException("Body is null"); + } + return new CountingInputStream(new BufferedInputStream(response.body().byteStream())); + } + } + + public static List loadChaptersFromUrl(String url, boolean forceRefresh) throws InterruptedIOException { + if (forceRefresh) { + return loadChaptersFromUrl(url, CacheControl.FORCE_NETWORK); + } + List cachedChapters = loadChaptersFromUrl(url, CacheControl.FORCE_CACHE); + if (cachedChapters == null || cachedChapters.size() <= 1) { + // Some publishers use one dummy chapter before actual chapters are available + return loadChaptersFromUrl(url, CacheControl.FORCE_NETWORK); + } + return cachedChapters; + } + + private static List loadChaptersFromUrl(String url, CacheControl cacheControl) + throws InterruptedIOException { + Response response = null; + try { + Request request = new Request.Builder().url(url).cacheControl(cacheControl).build(); + response = AntennapodHttpClient.getHttpClient().newCall(request).execute(); + if (response.isSuccessful() && response.body() != null) { + return PodcastIndexChapterParser.parse(response.body().string()); + } + } catch (InterruptedIOException e) { + throw e; + } catch (IOException e) { + e.printStackTrace(); + } finally { + if (response != null) { + response.close(); + } + } + return null; + } + + @NonNull + private static List readId3ChaptersFrom(CountingInputStream in) throws IOException, ID3ReaderException { + ChapterReader reader = new ChapterReader(in); + reader.readInputStream(); + List chapters = reader.getChapters(); + Collections.sort(chapters, new ChapterStartTimeComparator()); + enumerateEmptyChapterTitles(chapters); + if (!chaptersValid(chapters)) { + Log.e(TAG, "Chapter data was invalid"); + return Collections.emptyList(); + } + return chapters; + } + + @NonNull + private static List readOggChaptersFromInputStream(InputStream input) throws VorbisCommentReaderException { + VorbisCommentChapterReader reader = new VorbisCommentChapterReader(new BufferedInputStream(input)); + reader.readInputStream(); + List chapters = reader.getChapters(); + if (chapters == null) { + return Collections.emptyList(); + } + Collections.sort(chapters, new ChapterStartTimeComparator()); + enumerateEmptyChapterTitles(chapters); + if (chaptersValid(chapters)) { + return chapters; + } + return Collections.emptyList(); + } + + /** + * Makes sure that chapter does a title and an item attribute. + */ + private static void enumerateEmptyChapterTitles(List chapters) { + for (int i = 0; i < chapters.size(); i++) { + Chapter c = chapters.get(i); + if (c.getTitle() == null) { + c.setTitle(Integer.toString(i)); + } + } + } + + private static boolean chaptersValid(List chapters) { + if (chapters.isEmpty()) { + return false; + } + for (Chapter c : chapters) { + if (c.getStart() < 0) { + return false; + } + } + return true; + } + + public static class ChapterStartTimeComparator implements Comparator { + @Override + public int compare(Chapter lhs, Chapter rhs) { + return Long.compare(lhs.getStart(), rhs.getStart()); + } + } +} -- cgit v1.2.3