summaryrefslogtreecommitdiff
path: root/net/discovery
diff options
context:
space:
mode:
authorByteHamster <info@bytehamster.com>2022-04-25 23:10:18 +0200
committerByteHamster <info@bytehamster.com>2022-04-26 18:09:25 +0200
commit30be4628ae1cd07fe9d9ed584eb865f874869085 (patch)
treec09f4cf0187dfc82cda5f284eaea1ca9df51d6cd /net/discovery
parent20363ee41c814b14b16999505fa850a0943346dd (diff)
downloadAntennaPod-30be4628ae1cd07fe9d9ed584eb865f874869085.zip
Move feed discovery backends to their own module
Diffstat (limited to 'net/discovery')
-rw-r--r--net/discovery/README.md3
-rw-r--r--net/discovery/build.gradle37
-rw-r--r--net/discovery/src/main/AndroidManifest.xml1
-rw-r--r--net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/CombinedSearcher.java118
-rw-r--r--net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/FyydPodcastSearcher.java53
-rw-r--r--net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/GpodnetPodcastSearcher.java52
-rw-r--r--net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/ItunesPodcastSearcher.java116
-rw-r--r--net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/ItunesTopListLoader.java102
-rw-r--r--net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/PodcastIndexPodcastSearcher.java126
-rw-r--r--net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/PodcastSearchResult.java110
-rw-r--r--net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/PodcastSearcher.java14
-rw-r--r--net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/PodcastSearcherRegistry.java55
12 files changed, 787 insertions, 0 deletions
diff --git a/net/discovery/README.md b/net/discovery/README.md
new file mode 100644
index 000000000..53bc7e87c
--- /dev/null
+++ b/net/discovery/README.md
@@ -0,0 +1,3 @@
+# :net:discovery
+
+This module contains the podcast search/discovery APIs.
diff --git a/net/discovery/build.gradle b/net/discovery/build.gradle
new file mode 100644
index 000000000..4bebbd04d
--- /dev/null
+++ b/net/discovery/build.gradle
@@ -0,0 +1,37 @@
+plugins {
+ id("com.android.library")
+}
+apply from: "../../common.gradle"
+apply from: "../../playFlavor.gradle"
+
+android {
+ defaultConfig {
+ if (project.hasProperty("podcastindexApiKey")) {
+ buildConfigField "String", "PODCASTINDEX_API_KEY", '"' + podcastindexApiKey + '"'
+ buildConfigField "String", "PODCASTINDEX_API_SECRET", '"' + podcastindexApiSecret + '"'
+ } else {
+ buildConfigField "String", "PODCASTINDEX_API_KEY", '"XTMMQGA2YZ4WJUBYY4HK"'
+ buildConfigField "String", "PODCASTINDEX_API_SECRET", '"XAaAhk4^2YBsTE33vdbwbZNj82ZRLABDDqFdKe7x"'
+ }
+ }
+
+ lintOptions {
+ disable 'InvalidPeriodicWorkRequestInterval', 'MissingPermission',
+ 'GradleCompatible', 'AppCompatResource', 'QueryPermissionsNeeded'
+ }
+}
+
+dependencies {
+ implementation project(':core')
+ implementation project(':model')
+ implementation project(':net:sync:gpoddernet')
+ implementation project(':net:sync:model')
+
+ annotationProcessor "androidx.annotation:annotation:$annotationVersion"
+
+ implementation 'com.github.mfietz:fyydlin:v0.5.0'
+
+ implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion"
+ implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion"
+ implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
+}
diff --git a/net/discovery/src/main/AndroidManifest.xml b/net/discovery/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..3e42a802a
--- /dev/null
+++ b/net/discovery/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+<manifest package="de.danoeh.antennapod.net.discovery" />
diff --git a/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/CombinedSearcher.java b/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/CombinedSearcher.java
new file mode 100644
index 000000000..6cbf8eb2e
--- /dev/null
+++ b/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/CombinedSearcher.java
@@ -0,0 +1,118 @@
+package de.danoeh.antennapod.net.discovery;
+
+import android.text.TextUtils;
+import android.util.Log;
+import io.reactivex.Single;
+import io.reactivex.SingleOnSubscribe;
+import io.reactivex.android.schedulers.AndroidSchedulers;
+import io.reactivex.disposables.Disposable;
+import io.reactivex.schedulers.Schedulers;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+
+public class CombinedSearcher implements PodcastSearcher {
+ private static final String TAG = "CombinedSearcher";
+
+ public CombinedSearcher() {
+ }
+
+ public Single<List<PodcastSearchResult>> search(String query) {
+ ArrayList<Disposable> disposables = new ArrayList<>();
+ List<List<PodcastSearchResult>> singleResults = new ArrayList<>(
+ Collections.nCopies(PodcastSearcherRegistry.getSearchProviders().size(), null));
+ CountDownLatch latch = new CountDownLatch(PodcastSearcherRegistry.getSearchProviders().size());
+ for (int i = 0; i < PodcastSearcherRegistry.getSearchProviders().size(); i++) {
+ PodcastSearcherRegistry.SearcherInfo searchProviderInfo
+ = PodcastSearcherRegistry.getSearchProviders().get(i);
+ PodcastSearcher searcher = searchProviderInfo.searcher;
+ if (searchProviderInfo.weight <= 0.00001f || searcher.getClass() == CombinedSearcher.class) {
+ latch.countDown();
+ continue;
+ }
+ final int index = i;
+ disposables.add(searcher.search(query).subscribe(e -> {
+ singleResults.set(index, e);
+ latch.countDown();
+ }, throwable -> {
+ Log.d(TAG, Log.getStackTraceString(throwable));
+ latch.countDown();
+ }
+ ));
+ }
+
+ return Single.create((SingleOnSubscribe<List<PodcastSearchResult>>) subscriber -> {
+ latch.await();
+ List<PodcastSearchResult> results = weightSearchResults(singleResults);
+ subscriber.onSuccess(results);
+ })
+ .doOnDispose(() -> {
+ for (Disposable disposable : disposables) {
+ if (disposable != null) {
+ disposable.dispose();
+ }
+ }
+ })
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread());
+ }
+
+ private List<PodcastSearchResult> weightSearchResults(List<List<PodcastSearchResult>> singleResults) {
+ HashMap<String, Float> resultRanking = new HashMap<>();
+ HashMap<String, PodcastSearchResult> urlToResult = new HashMap<>();
+ for (int i = 0; i < singleResults.size(); i++) {
+ float providerPriority = PodcastSearcherRegistry.getSearchProviders().get(i).weight;
+ List<PodcastSearchResult> providerResults = singleResults.get(i);
+ if (providerResults == null) {
+ continue;
+ }
+ for (int position = 0; position < providerResults.size(); position++) {
+ PodcastSearchResult result = providerResults.get(position);
+ urlToResult.put(result.feedUrl, result);
+
+ float ranking = 0;
+ if (resultRanking.containsKey(result.feedUrl)) {
+ ranking = resultRanking.get(result.feedUrl);
+ }
+ ranking += 1.f / (position + 1.f);
+ resultRanking.put(result.feedUrl, ranking * providerPriority);
+ }
+ }
+ List<Map.Entry<String, Float>> sortedResults = new ArrayList<>(resultRanking.entrySet());
+ Collections.sort(sortedResults, (o1, o2) -> Double.compare(o2.getValue(), o1.getValue()));
+
+ List<PodcastSearchResult> results = new ArrayList<>();
+ for (Map.Entry<String, Float> res : sortedResults) {
+ results.add(urlToResult.get(res.getKey()));
+ }
+ return results;
+ }
+
+ @Override
+ public Single<String> lookupUrl(String url) {
+ return PodcastSearcherRegistry.lookupUrl(url);
+ }
+
+ @Override
+ public boolean urlNeedsLookup(String url) {
+ return PodcastSearcherRegistry.urlNeedsLookup(url);
+ }
+
+ @Override
+ public String getName() {
+ ArrayList<String> names = new ArrayList<>();
+ for (int i = 0; i < PodcastSearcherRegistry.getSearchProviders().size(); i++) {
+ PodcastSearcherRegistry.SearcherInfo searchProviderInfo
+ = PodcastSearcherRegistry.getSearchProviders().get(i);
+ PodcastSearcher searcher = searchProviderInfo.searcher;
+ if (searchProviderInfo.weight > 0.00001f && searcher.getClass() != CombinedSearcher.class) {
+ names.add(searcher.getName());
+ }
+ }
+ return TextUtils.join(", ", names);
+ }
+}
diff --git a/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/FyydPodcastSearcher.java b/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/FyydPodcastSearcher.java
new file mode 100644
index 000000000..d4674c79d
--- /dev/null
+++ b/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/FyydPodcastSearcher.java
@@ -0,0 +1,53 @@
+package de.danoeh.antennapod.net.discovery;
+
+import de.danoeh.antennapod.core.service.download.AntennapodHttpClient;
+import de.mfietz.fyydlin.FyydClient;
+import de.mfietz.fyydlin.FyydResponse;
+import de.mfietz.fyydlin.SearchHit;
+import io.reactivex.Single;
+import io.reactivex.SingleOnSubscribe;
+import io.reactivex.android.schedulers.AndroidSchedulers;
+import io.reactivex.schedulers.Schedulers;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class FyydPodcastSearcher implements PodcastSearcher {
+ private final FyydClient client = new FyydClient(AntennapodHttpClient.getHttpClient());
+
+ public Single<List<PodcastSearchResult>> search(String query) {
+ return Single.create((SingleOnSubscribe<List<PodcastSearchResult>>) subscriber -> {
+ FyydResponse response = client.searchPodcasts(query, 10)
+ .subscribeOn(Schedulers.io())
+ .blockingGet();
+
+ ArrayList<PodcastSearchResult> searchResults = new ArrayList<>();
+
+ if (!response.getData().isEmpty()) {
+ for (SearchHit searchHit : response.getData()) {
+ PodcastSearchResult podcast = PodcastSearchResult.fromFyyd(searchHit);
+ searchResults.add(podcast);
+ }
+ }
+
+ subscriber.onSuccess(searchResults);
+ })
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread());
+ }
+
+ @Override
+ public Single<String> lookupUrl(String url) {
+ return Single.just(url);
+ }
+
+ @Override
+ public boolean urlNeedsLookup(String url) {
+ return false;
+ }
+
+ @Override
+ public String getName() {
+ return "Fyyd";
+ }
+}
diff --git a/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/GpodnetPodcastSearcher.java b/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/GpodnetPodcastSearcher.java
new file mode 100644
index 000000000..222c415ab
--- /dev/null
+++ b/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/GpodnetPodcastSearcher.java
@@ -0,0 +1,52 @@
+package de.danoeh.antennapod.net.discovery;
+
+import de.danoeh.antennapod.core.sync.SynchronizationCredentials;
+import de.danoeh.antennapod.core.service.download.AntennapodHttpClient;
+import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService;
+import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetServiceException;
+import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetPodcast;
+import io.reactivex.Single;
+import io.reactivex.SingleOnSubscribe;
+import io.reactivex.android.schedulers.AndroidSchedulers;
+import io.reactivex.schedulers.Schedulers;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class GpodnetPodcastSearcher implements PodcastSearcher {
+ public Single<List<PodcastSearchResult>> search(String query) {
+ return Single.create((SingleOnSubscribe<List<PodcastSearchResult>>) subscriber -> {
+ try {
+ GpodnetService service = new GpodnetService(AntennapodHttpClient.getHttpClient(),
+ SynchronizationCredentials.getHosturl(), SynchronizationCredentials.getDeviceID(),
+ SynchronizationCredentials.getUsername(), SynchronizationCredentials.getPassword());
+ List<GpodnetPodcast> gpodnetPodcasts = service.searchPodcasts(query, 0);
+ List<PodcastSearchResult> results = new ArrayList<>();
+ for (GpodnetPodcast podcast : gpodnetPodcasts) {
+ results.add(PodcastSearchResult.fromGpodder(podcast));
+ }
+ subscriber.onSuccess(results);
+ } catch (GpodnetServiceException e) {
+ e.printStackTrace();
+ subscriber.onError(e);
+ }
+ })
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread());
+ }
+
+ @Override
+ public Single<String> lookupUrl(String url) {
+ return Single.just(url);
+ }
+
+ @Override
+ public boolean urlNeedsLookup(String url) {
+ return false;
+ }
+
+ @Override
+ public String getName() {
+ return "Gpodder.net";
+ }
+}
diff --git a/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/ItunesPodcastSearcher.java b/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/ItunesPodcastSearcher.java
new file mode 100644
index 000000000..b2ac1766c
--- /dev/null
+++ b/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/ItunesPodcastSearcher.java
@@ -0,0 +1,116 @@
+package de.danoeh.antennapod.net.discovery;
+
+import de.danoeh.antennapod.core.feed.FeedUrlNotFoundException;
+import de.danoeh.antennapod.core.service.download.AntennapodHttpClient;
+import io.reactivex.Single;
+import io.reactivex.SingleOnSubscribe;
+import io.reactivex.android.schedulers.AndroidSchedulers;
+import io.reactivex.schedulers.Schedulers;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class ItunesPodcastSearcher implements PodcastSearcher {
+ private static final String ITUNES_API_URL = "https://itunes.apple.com/search?media=podcast&term=%s";
+ private static final String PATTERN_BY_ID = ".*/podcasts\\.apple\\.com/.*/podcast/.*/id(\\d+).*";
+
+ public ItunesPodcastSearcher() {
+ }
+
+ @Override
+ public Single<List<PodcastSearchResult>> search(String query) {
+ return Single.create((SingleOnSubscribe<List<PodcastSearchResult>>) subscriber -> {
+ String encodedQuery;
+ try {
+ encodedQuery = URLEncoder.encode(query, "UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ // this won't ever be thrown
+ encodedQuery = query;
+ }
+
+ String formattedUrl = String.format(ITUNES_API_URL, encodedQuery);
+
+ OkHttpClient client = AntennapodHttpClient.getHttpClient();
+ Request.Builder httpReq = new Request.Builder()
+ .url(formattedUrl);
+ List<PodcastSearchResult> podcasts = new ArrayList<>();
+ try {
+ Response response = client.newCall(httpReq.build()).execute();
+
+ if (response.isSuccessful()) {
+ String resultString = response.body().string();
+ JSONObject result = new JSONObject(resultString);
+ JSONArray j = result.getJSONArray("results");
+
+ for (int i = 0; i < j.length(); i++) {
+ JSONObject podcastJson = j.getJSONObject(i);
+ PodcastSearchResult podcast = PodcastSearchResult.fromItunes(podcastJson);
+ if (podcast.feedUrl != null) {
+ podcasts.add(podcast);
+ }
+ }
+ } else {
+ subscriber.onError(new IOException(response.toString()));
+ }
+ } catch (IOException | JSONException e) {
+ subscriber.onError(e);
+ }
+ subscriber.onSuccess(podcasts);
+ })
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread());
+ }
+
+ @Override
+ public Single<String> lookupUrl(String url) {
+ Pattern pattern = Pattern.compile(PATTERN_BY_ID);
+ Matcher matcher = pattern.matcher(url);
+ final String lookupUrl = matcher.find() ? ("https://itunes.apple.com/lookup?id=" + matcher.group(1)) : url;
+ return Single.create(emitter -> {
+ OkHttpClient client = AntennapodHttpClient.getHttpClient();
+ Request.Builder httpReq = new Request.Builder().url(lookupUrl);
+ try {
+ Response response = client.newCall(httpReq.build()).execute();
+ if (response.isSuccessful()) {
+ String resultString = response.body().string();
+ JSONObject result = new JSONObject(resultString);
+ JSONObject results = result.getJSONArray("results").getJSONObject(0);
+ String feedUrlName = "feedUrl";
+ if (!results.has(feedUrlName)) {
+ String artistName = results.getString("artistName");
+ String trackName = results.getString("trackName");
+ emitter.onError(new FeedUrlNotFoundException(artistName, trackName));
+ return;
+ }
+ String feedUrl = results.getString(feedUrlName);
+ emitter.onSuccess(feedUrl);
+ } else {
+ emitter.onError(new IOException(response.toString()));
+ }
+ } catch (IOException | JSONException e) {
+ emitter.onError(e);
+ }
+ });
+ }
+
+ @Override
+ public boolean urlNeedsLookup(String url) {
+ return url.contains("itunes.apple.com") || url.matches(PATTERN_BY_ID);
+ }
+
+ @Override
+ public String getName() {
+ return "iTunes";
+ }
+}
diff --git a/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/ItunesTopListLoader.java b/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/ItunesTopListLoader.java
new file mode 100644
index 000000000..827a3202f
--- /dev/null
+++ b/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/ItunesTopListLoader.java
@@ -0,0 +1,102 @@
+package de.danoeh.antennapod.net.discovery;
+
+import android.content.Context;
+import android.util.Log;
+
+import de.danoeh.antennapod.core.service.download.AntennapodHttpClient;
+import io.reactivex.Single;
+import io.reactivex.SingleOnSubscribe;
+import io.reactivex.android.schedulers.AndroidSchedulers;
+import io.reactivex.schedulers.Schedulers;
+import okhttp3.CacheControl;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+public class ItunesTopListLoader {
+ private static final String TAG = "ITunesTopListLoader";
+ private final Context context;
+ public static final String PREF_KEY_COUNTRY_CODE = "country_code";
+ public static final String PREFS = "CountryRegionPrefs";
+ public static final String DISCOVER_HIDE_FAKE_COUNTRY_CODE = "00";
+ public static final String COUNTRY_CODE_UNSET = "99";
+
+ public ItunesTopListLoader(Context context) {
+ this.context = context;
+ }
+
+ public Single<List<PodcastSearchResult>> loadToplist(String country, int limit) {
+ return Single.create((SingleOnSubscribe<List<PodcastSearchResult>>) emitter -> {
+ OkHttpClient client = AntennapodHttpClient.getHttpClient();
+ String feedString;
+ String loadCountry = country;
+ if (COUNTRY_CODE_UNSET.equals(country)) {
+ loadCountry = Locale.getDefault().getCountry();
+ }
+ try {
+ feedString = getTopListFeed(client, loadCountry, limit);
+ } catch (IOException e) {
+ if (COUNTRY_CODE_UNSET.equals(country)) {
+ feedString = getTopListFeed(client, "US", limit);
+ } else {
+ emitter.onError(e);
+ return;
+ }
+ }
+
+ List<PodcastSearchResult> podcasts = parseFeed(feedString);
+ emitter.onSuccess(podcasts);
+ })
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread());
+ }
+
+ private String getTopListFeed(OkHttpClient client, String country, int limit) throws IOException {
+ String url = "https://itunes.apple.com/%s/rss/toppodcasts/limit=" + limit + "/explicit=true/json";
+ Log.d(TAG, "Feed URL " + String.format(url, country));
+ Request.Builder httpReq = new Request.Builder()
+ .cacheControl(new CacheControl.Builder().maxStale(1, TimeUnit.DAYS).build())
+ .url(String.format(url, country));
+
+ try (Response response = client.newCall(httpReq.build()).execute()) {
+ if (response.isSuccessful()) {
+ return response.body().string();
+ }
+ if (response.code() == 400) {
+ throw new IOException("iTunes does not have data for the selected country.");
+ }
+ String prefix = context.getString(R.string.error_msg_prefix);
+ throw new IOException(prefix + response);
+ }
+ }
+
+ private List<PodcastSearchResult> parseFeed(String jsonString) throws JSONException {
+ JSONObject result = new JSONObject(jsonString);
+ JSONObject feed;
+ JSONArray entries;
+ try {
+ feed = result.getJSONObject("feed");
+ entries = feed.getJSONArray("entry");
+ } catch (JSONException e) {
+ return new ArrayList<>();
+ }
+
+ List<PodcastSearchResult> results = new ArrayList<>();
+ for (int i = 0; i < entries.length(); i++) {
+ JSONObject json = entries.getJSONObject(i);
+ results.add(PodcastSearchResult.fromItunesToplist(json));
+ }
+
+ return results;
+ }
+
+}
diff --git a/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/PodcastIndexPodcastSearcher.java b/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/PodcastIndexPodcastSearcher.java
new file mode 100644
index 000000000..4645aaf62
--- /dev/null
+++ b/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/PodcastIndexPodcastSearcher.java
@@ -0,0 +1,126 @@
+package de.danoeh.antennapod.net.discovery;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.TimeZone;
+
+import de.danoeh.antennapod.net.discovery.BuildConfig;
+import de.danoeh.antennapod.core.ClientConfig;
+import de.danoeh.antennapod.core.service.download.AntennapodHttpClient;
+import io.reactivex.Single;
+import io.reactivex.SingleOnSubscribe;
+import io.reactivex.android.schedulers.AndroidSchedulers;
+import io.reactivex.schedulers.Schedulers;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class PodcastIndexPodcastSearcher implements PodcastSearcher {
+ private static final String PODCASTINDEX_API_URL = "https://api.podcastindex.org/api/1.0/search/byterm?q=%s";
+
+ public PodcastIndexPodcastSearcher() {
+ }
+
+ @Override
+ public Single<List<PodcastSearchResult>> search(String query) {
+ return Single.create((SingleOnSubscribe<List<PodcastSearchResult>>) subscriber -> {
+
+ Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
+ calendar.clear();
+ Date now = new Date();
+ calendar.setTime(now);
+ long secondsSinceEpoch = calendar.getTimeInMillis() / 1000L;
+ String apiHeaderTime = String.valueOf(secondsSinceEpoch);
+ String data4Hash = BuildConfig.PODCASTINDEX_API_KEY + BuildConfig.PODCASTINDEX_API_SECRET + apiHeaderTime;
+ String hashString = sha1(data4Hash);
+
+ String encodedQuery;
+ try {
+ encodedQuery = URLEncoder.encode(query, "UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ // this won't ever be thrown
+ encodedQuery = query;
+ }
+
+ String formattedUrl = String.format(PODCASTINDEX_API_URL, encodedQuery);
+
+ OkHttpClient client = AntennapodHttpClient.getHttpClient();
+ Request.Builder httpReq = new Request.Builder()
+ .addHeader("X-Auth-Date", apiHeaderTime)
+ .addHeader("X-Auth-Key", BuildConfig.PODCASTINDEX_API_KEY)
+ .addHeader("Authorization", hashString)
+ .addHeader("User-Agent", ClientConfig.USER_AGENT)
+ .url(formattedUrl);
+ List<PodcastSearchResult> podcasts = new ArrayList<>();
+ try {
+ Response response = client.newCall(httpReq.build()).execute();
+
+ if (response.isSuccessful()) {
+ String resultString = response.body().string();
+ JSONObject result = new JSONObject(resultString);
+ JSONArray j = result.getJSONArray("feeds");
+
+ for (int i = 0; i < j.length(); i++) {
+ JSONObject podcastJson = j.getJSONObject(i);
+ PodcastSearchResult podcast = PodcastSearchResult.fromPodcastIndex(podcastJson);
+ if (podcast.feedUrl != null) {
+ podcasts.add(podcast);
+ }
+ }
+ } else {
+ subscriber.onError(new IOException(response.toString()));
+ }
+ } catch (IOException | JSONException e) {
+ subscriber.onError(e);
+ }
+ subscriber.onSuccess(podcasts);
+ })
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread());
+ }
+
+ @Override
+ public Single<String> lookupUrl(String url) {
+ return Single.just(url);
+ }
+
+ @Override
+ public boolean urlNeedsLookup(String url) {
+ return false;
+ }
+
+ @Override
+ public String getName() {
+ return "Podcastindex.org";
+ }
+
+ private static String sha1(String clearString) {
+ try {
+ MessageDigest messageDigest = MessageDigest.getInstance("SHA-1");
+ messageDigest.update(clearString.getBytes("UTF-8"));
+ return toHex(messageDigest.digest());
+ } catch (Exception ignored) {
+ ignored.printStackTrace();
+ return null;
+ }
+ }
+
+ private static String toHex(byte[] bytes) {
+ StringBuilder buffer = new StringBuilder();
+ for (byte b : bytes) {
+ buffer.append(String.format(Locale.getDefault(), "%02x", b));
+ }
+ return buffer.toString();
+ }
+}
diff --git a/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/PodcastSearchResult.java b/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/PodcastSearchResult.java
new file mode 100644
index 000000000..b3f352334
--- /dev/null
+++ b/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/PodcastSearchResult.java
@@ -0,0 +1,110 @@
+package de.danoeh.antennapod.net.discovery;
+
+import androidx.annotation.Nullable;
+import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetPodcast;
+import de.mfietz.fyydlin.SearchHit;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+public class PodcastSearchResult {
+
+ /**
+ * The name of the podcast
+ */
+ public final String title;
+
+ /**
+ * URL of the podcast image
+ */
+ @Nullable
+ public final String imageUrl;
+ /**
+ * URL of the podcast feed
+ */
+ @Nullable
+ public final String feedUrl;
+
+ /**
+ * artistName of the podcast feed
+ */
+ @Nullable
+ public final String author;
+
+
+ private PodcastSearchResult(String title, @Nullable String imageUrl, @Nullable String feedUrl, @Nullable String author) {
+ this.title = title;
+ this.imageUrl = imageUrl;
+ this.feedUrl = feedUrl;
+ this.author = author;
+ }
+
+ public static PodcastSearchResult dummy() {
+ return new PodcastSearchResult("", "", "", "");
+ }
+
+ /**
+ * Constructs a Podcast instance from a iTunes search result
+ *
+ * @param json object holding the podcast information
+ * @throws JSONException
+ */
+ public static PodcastSearchResult fromItunes(JSONObject json) {
+ String title = json.optString("collectionName", "");
+ String imageUrl = json.optString("artworkUrl100", null);
+ String feedUrl = json.optString("feedUrl", null);
+ String author = json.optString("artistName", null);
+ return new PodcastSearchResult(title, imageUrl, feedUrl, author);
+ }
+
+ /**
+ * Constructs a Podcast instance from iTunes toplist entry
+ *
+ * @param json object holding the podcast information
+ * @throws JSONException
+ */
+ public static PodcastSearchResult fromItunesToplist(JSONObject json) throws JSONException {
+ String title = json.getJSONObject("title").getString("label");
+ String imageUrl = null;
+ JSONArray images = json.getJSONArray("im:image");
+ for(int i=0; imageUrl == null && i < images.length(); i++) {
+ JSONObject image = images.getJSONObject(i);
+ String height = image.getJSONObject("attributes").getString("height");
+ if(Integer.parseInt(height) >= 100) {
+ imageUrl = image.getString("label");
+ }
+ }
+ String feedUrl = "https://itunes.apple.com/lookup?id=" +
+ json.getJSONObject("id").getJSONObject("attributes").getString("im:id");
+
+ String author = null;
+ try {
+ author = json.getJSONObject("im:artist").getString("label");
+ } catch (Exception e) {
+ // Some feeds have empty artist
+ }
+ return new PodcastSearchResult(title, imageUrl, feedUrl, author);
+ }
+
+ public static PodcastSearchResult fromFyyd(SearchHit searchHit) {
+ return new PodcastSearchResult(searchHit.getTitle(),
+ searchHit.getThumbImageURL(),
+ searchHit.getXmlUrl(),
+ searchHit.getAuthor());
+ }
+
+ public static PodcastSearchResult fromGpodder(GpodnetPodcast searchHit) {
+ return new PodcastSearchResult(searchHit.getTitle(),
+ searchHit.getLogoUrl(),
+ searchHit.getUrl(),
+ searchHit.getAuthor());
+ }
+
+ public static PodcastSearchResult fromPodcastIndex(JSONObject json) {
+ String title = json.optString("title", "");
+ String imageUrl = json.optString("image", null);
+ String feedUrl = json.optString("url", null);
+ String author = json.optString("author", null);
+ return new PodcastSearchResult(title, imageUrl, feedUrl, author);
+ }
+}
diff --git a/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/PodcastSearcher.java b/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/PodcastSearcher.java
new file mode 100644
index 000000000..76edbf843
--- /dev/null
+++ b/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/PodcastSearcher.java
@@ -0,0 +1,14 @@
+package de.danoeh.antennapod.net.discovery;
+
+import io.reactivex.Single;
+import java.util.List;
+
+public interface PodcastSearcher {
+ Single<List<PodcastSearchResult>> search(String query);
+
+ Single<String> lookupUrl(String resultUrl);
+
+ boolean urlNeedsLookup(String resultUrl);
+
+ String getName();
+}
diff --git a/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/PodcastSearcherRegistry.java b/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/PodcastSearcherRegistry.java
new file mode 100644
index 000000000..c7892bd09
--- /dev/null
+++ b/net/discovery/src/main/java/de/danoeh/antennapod/net/discovery/PodcastSearcherRegistry.java
@@ -0,0 +1,55 @@
+package de.danoeh.antennapod.net.discovery;
+
+import io.reactivex.Single;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class PodcastSearcherRegistry {
+ private static List<SearcherInfo> searchProviders;
+
+ private PodcastSearcherRegistry() {
+ }
+
+ public static synchronized List<SearcherInfo> getSearchProviders() {
+ if (searchProviders == null) {
+ searchProviders = new ArrayList<>();
+ searchProviders.add(new SearcherInfo(new CombinedSearcher(), 1.0f));
+ searchProviders.add(new SearcherInfo(new GpodnetPodcastSearcher(), 0.0f));
+ searchProviders.add(new SearcherInfo(new FyydPodcastSearcher(), 1.0f));
+ searchProviders.add(new SearcherInfo(new ItunesPodcastSearcher(), 1.0f));
+ searchProviders.add(new SearcherInfo(new PodcastIndexPodcastSearcher(), 1.0f));
+ }
+ return searchProviders;
+ }
+
+ public static Single<String> lookupUrl(String url) {
+ for (PodcastSearcherRegistry.SearcherInfo searchProviderInfo : getSearchProviders()) {
+ if (searchProviderInfo.searcher.getClass() != CombinedSearcher.class
+ && searchProviderInfo.searcher.urlNeedsLookup(url)) {
+ return searchProviderInfo.searcher.lookupUrl(url);
+ }
+ }
+ return Single.just(url);
+ }
+
+ public static boolean urlNeedsLookup(String url) {
+ for (PodcastSearcherRegistry.SearcherInfo searchProviderInfo : getSearchProviders()) {
+ if (searchProviderInfo.searcher.getClass() != CombinedSearcher.class
+ && searchProviderInfo.searcher.urlNeedsLookup(url)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public static class SearcherInfo {
+ public final PodcastSearcher searcher;
+ public final float weight;
+
+ public SearcherInfo(PodcastSearcher searcher, float weight) {
+ this.searcher = searcher;
+ this.weight = weight;
+ }
+ }
+}