diff options
Diffstat (limited to 'app/src/main/java/de/danoeh/antennapod/discovery')
7 files changed, 447 insertions, 0 deletions
diff --git a/app/src/main/java/de/danoeh/antennapod/discovery/CombinedSearcher.java b/app/src/main/java/de/danoeh/antennapod/discovery/CombinedSearcher.java new file mode 100644 index 000000000..18d65a03c --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/discovery/CombinedSearcher.java @@ -0,0 +1,96 @@ +package de.danoeh.antennapod.discovery; + +import android.content.Context; +import android.util.Log; +import android.util.Pair; +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"; + + private final List<Pair<PodcastSearcher, Float>> searchProviders = new ArrayList<>(); + + public CombinedSearcher(Context context) { + addProvider(new FyydPodcastSearcher(), 1.f); + addProvider(new ItunesPodcastSearcher(context), 1.f); + addProvider(new GpodnetPodcastSearcher(), 0.6f); + } + + private void addProvider(PodcastSearcher provider, float priority) { + searchProviders.add(new Pair<>(provider, priority)); + } + + public Single<List<PodcastSearchResult>> search(String query) { + ArrayList<Disposable> disposables = new ArrayList<>(); + List<List<PodcastSearchResult>> singleResults = new ArrayList<>(Collections.nCopies(searchProviders.size(), null)); + CountDownLatch latch = new CountDownLatch(searchProviders.size()); + for (int i = 0; i < searchProviders.size(); i++) { + Pair<PodcastSearcher, Float> searchProviderInfo = searchProviders.get(i); + PodcastSearcher searcher = searchProviderInfo.first; + 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) { + 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 = searchProviders.get(i).second; + 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; + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/discovery/FyydPodcastSearcher.java b/app/src/main/java/de/danoeh/antennapod/discovery/FyydPodcastSearcher.java new file mode 100644 index 000000000..529a9e3d5 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/discovery/FyydPodcastSearcher.java @@ -0,0 +1,38 @@ +package de.danoeh.antennapod.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()); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/discovery/GpodnetPodcastSearcher.java b/app/src/main/java/de/danoeh/antennapod/discovery/GpodnetPodcastSearcher.java new file mode 100644 index 000000000..6e5debb38 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/discovery/GpodnetPodcastSearcher.java @@ -0,0 +1,38 @@ +package de.danoeh.antennapod.discovery; + +import de.danoeh.antennapod.core.gpoddernet.GpodnetService; +import de.danoeh.antennapod.core.gpoddernet.GpodnetServiceException; +import de.danoeh.antennapod.core.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 -> { + GpodnetService service = null; + try { + service = new GpodnetService(); + 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); + } finally { + if (service != null) { + service.shutdown(); + } + } + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/discovery/ItunesPodcastSearcher.java b/app/src/main/java/de/danoeh/antennapod/discovery/ItunesPodcastSearcher.java new file mode 100644 index 000000000..a91aae1a8 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/discovery/ItunesPodcastSearcher.java @@ -0,0 +1,74 @@ +package de.danoeh.antennapod.discovery; + +import android.content.Context; +import de.danoeh.antennapod.R; +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; +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; + +public class ItunesPodcastSearcher implements PodcastSearcher { + private static final String ITUNES_API_URL = "https://itunes.apple.com/search?media=podcast&term=%s"; + private final Context context; + + public ItunesPodcastSearcher(Context context) { + this.context = context; + } + + 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) + .header("User-Agent", ClientConfig.USER_AGENT); + 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); + podcasts.add(podcast); + } + } else { + String prefix = context.getString(R.string.error_msg_prefix); + subscriber.onError(new IOException(prefix + response)); + } + } catch (IOException | JSONException e) { + subscriber.onError(e); + } + subscriber.onSuccess(podcasts); + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/discovery/ItunesTopListLoader.java b/app/src/main/java/de/danoeh/antennapod/discovery/ItunesTopListLoader.java new file mode 100644 index 000000000..bc9133258 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/discovery/ItunesTopListLoader.java @@ -0,0 +1,110 @@ +package de.danoeh.antennapod.discovery; + +import android.content.Context; +import de.danoeh.antennapod.R; +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; +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; + +public class ItunesTopListLoader { + private final Context context; + + public ItunesTopListLoader(Context context) { + this.context = context; + } + + public Single<List<PodcastSearchResult>> loadToplist(int limit) { + return Single.create((SingleOnSubscribe<List<PodcastSearchResult>>) emitter -> { + String lang = Locale.getDefault().getLanguage(); + OkHttpClient client = AntennapodHttpClient.getHttpClient(); + String feedString; + try { + try { + feedString = getTopListFeed(client, lang, limit); + } catch (IOException e) { + feedString = getTopListFeed(client, "us", limit); + } + List<PodcastSearchResult> podcasts = parseFeed(feedString); + emitter.onSuccess(podcasts); + } catch (IOException | JSONException e) { + emitter.onError(e); + } + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()); + } + + public Single<String> getFeedUrl(PodcastSearchResult podcast) { + if (!podcast.feedUrl.contains("itunes.apple.com")) { + return Single.just(podcast.feedUrl) + .observeOn(AndroidSchedulers.mainThread()); + } + return Single.create((SingleOnSubscribe<String>) emitter -> { + OkHttpClient client = AntennapodHttpClient.getHttpClient(); + Request.Builder httpReq = new Request.Builder() + .url(podcast.feedUrl) + .header("User-Agent", ClientConfig.USER_AGENT); + 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 feedUrl = results.getString("feedUrl"); + emitter.onSuccess(feedUrl); + } else { + String prefix = context.getString(R.string.error_msg_prefix); + emitter.onError(new IOException(prefix + response)); + } + } catch (IOException | JSONException e) { + emitter.onError(e); + } + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()); + } + + private String getTopListFeed(OkHttpClient client, String language, int limit) throws IOException { + String url = "https://itunes.apple.com/%s/rss/toppodcasts/limit="+limit+"/explicit=true/json"; + Request.Builder httpReq = new Request.Builder() + .header("User-Agent", ClientConfig.USER_AGENT) + .url(String.format(url, language)); + + try (Response response = client.newCall(httpReq.build()).execute()) { + if (response.isSuccessful()) { + return response.body().string(); + } + 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 = result.getJSONObject("feed"); + JSONArray entries = feed.getJSONArray("entry"); + + 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/app/src/main/java/de/danoeh/antennapod/discovery/PodcastSearchResult.java b/app/src/main/java/de/danoeh/antennapod/discovery/PodcastSearchResult.java new file mode 100644 index 000000000..ca9ed83d7 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/discovery/PodcastSearchResult.java @@ -0,0 +1,81 @@ +package de.danoeh.antennapod.discovery; + +import android.support.annotation.Nullable; +import de.danoeh.antennapod.core.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; + + + private PodcastSearchResult(String title, @Nullable String imageUrl, @Nullable String feedUrl) { + this.title = title; + this.imageUrl = imageUrl; + this.feedUrl = feedUrl; + } + + 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); + return new PodcastSearchResult(title, imageUrl, feedUrl); + } + + /** + * 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"); + return new PodcastSearchResult(title, imageUrl, feedUrl); + } + + public static PodcastSearchResult fromFyyd(SearchHit searchHit) { + return new PodcastSearchResult(searchHit.getTitle(), searchHit.getThumbImageURL(), searchHit.getXmlUrl()); + } + + public static PodcastSearchResult fromGpodder(GpodnetPodcast searchHit) { + return new PodcastSearchResult(searchHit.getTitle(), searchHit.getLogoUrl(), searchHit.getUrl()); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/discovery/PodcastSearcher.java b/app/src/main/java/de/danoeh/antennapod/discovery/PodcastSearcher.java new file mode 100644 index 000000000..b19d7d695 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/discovery/PodcastSearcher.java @@ -0,0 +1,10 @@ +package de.danoeh.antennapod.discovery; + +import io.reactivex.Single; +import io.reactivex.disposables.Disposable; +import io.reactivex.functions.Consumer; +import java.util.List; + +public interface PodcastSearcher { + Single<List<PodcastSearchResult>> search(String query); +} |