Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.arflix.tv.data.api

import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import com.google.gson.stream.JsonWriter

/**
* A string list that also accepts a bare string (or number), since some addons send
* `"genres": "Drama"` where the protocol says `["Drama"]`. Strict parsing fails the whole
* catalog response over one such item.
*/
internal class LenientStringListAdapter : TypeAdapter<List<String>?>() {
override fun read(reader: JsonReader): List<String>? = when (reader.peek()) {
JsonToken.NULL -> { reader.nextNull(); null }
JsonToken.BEGIN_ARRAY -> {
val values = mutableListOf<String>()
reader.beginArray()
while (reader.hasNext()) {
when (reader.peek()) {
JsonToken.STRING, JsonToken.NUMBER -> values += reader.nextString()
else -> reader.skipValue()
}
}
reader.endArray()
values
}
JsonToken.STRING, JsonToken.NUMBER -> listOf(reader.nextString()).filter { it.isNotBlank() }
else -> { reader.skipValue(); null }
}

override fun write(writer: JsonWriter, value: List<String>?) {
if (value == null) {
writer.nullValue()
return
}
writer.beginArray()
value.forEach { writer.value(it) }
writer.endArray()
}
}
3 changes: 2 additions & 1 deletion app/src/main/kotlin/com/arflix/tv/data/api/StreamApi.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.arflix.tv.data.api

import com.google.gson.JsonElement
import com.google.gson.annotations.JsonAdapter
import com.google.gson.annotations.SerializedName
import retrofit2.http.GET
import retrofit2.http.Url
Expand Down Expand Up @@ -201,7 +202,7 @@ data class StremioMetaPreview(
val background: String? = null,
val logo: String? = null,
val description: String? = null,
val genres: List<String>? = null,
@JsonAdapter(LenientStringListAdapter::class) val genres: List<String>? = null,
val released: String? = null,
val releaseInfo: String? = null,
val year: String? = null,
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/data/model/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ data class MediaItem(
// number; details, episodes and streams come from the addon by this id.
val addonNativeId: String? = null,
val addonNativeAddonId: String? = null,
// The addon's own type for that id ("Podcasts", "tv"); its /meta and /stream route by it.
val addonNativeType: String? = null,
) : Serializable {
val isAddonNative: Boolean get() = !addonNativeId.isNullOrBlank()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ package com.arflix.tv.data.repository

import java.net.URLEncoder

/**
* One addon URL path segment (a type, an id or an extra). Addon types and ids may hold spaces
* ("Live Docs"), `:` or non-ASCII text; URLEncoder's "+" for a space would reach the addon as a
* literal plus, so it is written as %20. Used for catalog, meta and stream URLs alike.
*/
internal fun encodePathSegment(value: String): String =
URLEncoder.encode(value, "UTF-8").replace("+", "%20")

internal fun buildCatalogRequestUrls(
baseUrl: String,
catalogType: String,
Expand All @@ -10,13 +18,13 @@ internal fun buildCatalogRequestUrls(
queryBase: String?,
genre: String? = null
): List<String> {
val type = URLEncoder.encode(catalogType, "UTF-8")
val id = URLEncoder.encode(catalogId, "UTF-8")
val type = encodePathSegment(catalogType)
val id = encodePathSegment(catalogId)
val base = "$baseUrl/catalog/$type/$id"
val query = queryBase?.takeIf { it.isNotBlank() }
val configSuffix = query?.let { "?$it" }.orEmpty()
if (!genre.isNullOrBlank()) {
val encodedGenre = URLEncoder.encode(genre, "UTF-8").replace("+", "%20")
val encodedGenre = encodePathSegment(genre)
val extras = listOfNotNull("genre=$encodedGenre", "skip=$skip".takeIf { skip > 0 }).joinToString("&")
return listOf("$base/$extras.json$configSuffix")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.arflix.tv.data.repository

import com.arflix.tv.data.model.Addon
import com.arflix.tv.data.model.AddonResource
import java.util.Locale

// Checks against an already-read addon list. Reading StreamRepository.installedAddons parses
// every installed manifest, so a catalog page must read it once, not once per item.

/**
* True when [addon] serves its own metadata for [contentId]: its manifest offers a `meta`
* resource whose id prefixes cover that id (a broadcaster VOD addon, say). Such items can be
* shown natively instead of being matched to TMDB, which would hide every title TMDB doesn't
* list. Ids TMDB lookups understand and the anime id families ARVIO maps through its own anime
* pipeline are left out, so those addons behave as before.
*/
internal fun addonServesOwnMeta(addon: Addon, type: String, contentId: String): Boolean {
val id = contentId.trim()
if (id.isBlank() || NON_NATIVE_META_ID_FAMILIES.any { id.startsWith(it, ignoreCase = true) }) return false
if (!addon.isEnabled) return false
val manifest = addon.manifest ?: return false
val normalizedType = type.trim().lowercase(Locale.US)
return manifest.resources.any { resource ->
if (!resource.name.equals("meta", ignoreCase = true)) return@any false
if (!supportsResourceType(resource.types, normalizedType)) return@any false
// An addon that names no prefix at all can't be said to own any id.
val prefixes = resource.idPrefixes ?: manifest.idPrefixes
prefixes.orEmpty().any { prefix ->
prefix.isNotBlank() && id.startsWith(prefix.trim(), ignoreCase = true)
}
}
}

/**
* The addon in [addons] (other than [excludeAddonId]) that serves its own metadata for
* [contentId]. Metadata addons such as AIOMetadata re-list another addon's catalog (a channel
* addon's own ids) without serving its metadata or streams; the owner does.
*/
internal fun findAddonServingOwnMeta(
addons: List<Addon>,
type: String,
contentId: String,
excludeAddonId: String? = null
): String? = addons
.filter { it.isInstalled && it.isEnabled && it.id != excludeAddonId }
.firstOrNull { addonServesOwnMeta(it, type, contentId) }
?.id

/**
* False when [addon]'s manifest shows it cannot answer /meta for [contentId]: no meta resource,
* or meta id prefixes that don't cover the id. Asking anyway costs a request per type alias per
* item, enough to get an addon's server to rate-limit us.
*/
internal fun addonMayServeMeta(addon: Addon?, contentId: String): Boolean {
val manifest = addon?.manifest ?: return true
// Gson leaves absent manifest fields null despite the Kotlin types.
val resources: List<AddonResource?> = (manifest.resources as List<AddonResource?>?).orEmpty()
if (resources.isEmpty()) return true
return resources.filterNotNull().filter { it.name.equals("meta", ignoreCase = true) }.any { resource ->
val prefixes = resource.idPrefixes ?: manifest.idPrefixes
prefixes.isNullOrEmpty() || prefixes.any { prefix ->
prefix.isNotBlank() && contentId.startsWith(prefix.trim(), ignoreCase = true)
}
}
}

internal fun supportsResourceType(resourceTypes: List<String>?, requestedType: String): Boolean {
if (resourceTypes.isNullOrEmpty()) return true
val normalized = resourceTypes.map { it.trim().lowercase(Locale.US) }
val aliases = when (requestedType) {
"series", "tv", "show" -> setOf("series", "tv", "show")
"anime" -> setOf("anime", "series", "tv", "show")
"movie", "film" -> setOf("movie", "film")
else -> setOf(requestedType)
}
return normalized.any { it in aliases }
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,25 @@ internal class AddonNativeCatalog(

@Volatile private var registryDirty = false

/** Builds (and remembers) the card for a catalog entry; call [flush] after a batch. */
fun register(addonId: String, preview: StremioMetaPreview, mediaType: MediaType): MediaItem? {
/**
* Builds (and remembers) the card for a catalog entry; call [flush] after a batch.
* [addonType] is the addon's own type for the item ("Podcasts", "tv"), which its /meta
* is requested with; without one, the type follows [mediaType].
*/
fun register(
addonId: String,
preview: StremioMetaPreview,
mediaType: MediaType,
addonType: String? = null
): MediaItem? {
val metaId = preview.id?.trim()?.takeIf { it.isNotBlank() } ?: return null
val title = preview.name?.trim()?.takeIf { it.isNotBlank() } ?: return null
val id = stableId(addonId, metaId)
val entry = Entry(
addonId = addonId,
metaId = metaId,
type = if (mediaType == MediaType.TV) "series" else "movie",
type = addonType?.trim()?.takeIf { it.isNotBlank() }
?: if (mediaType == MediaType.TV) "series" else "movie",
title = title,
poster = preview.poster,
background = preview.background,
Expand All @@ -80,6 +90,12 @@ internal class AddonNativeCatalog(

fun isNative(mediaId: Int): Boolean = mediaId < 0 && entry(mediaId) != null

/** The addon's own type for this item ("series", "Podcasts", "tv"), which its /meta and /stream answer to. */
fun addonType(mediaId: Int): String? = entry(mediaId)?.type

/** A live channel (Stremio's "tv" type): it plays live, so it has no progress to resume. */
fun isLiveChannel(mediaId: Int): Boolean = entry(mediaId)?.type.equals("tv", ignoreCase = true)

/** The catalog card, without touching the network (for catalog rows). */
fun card(mediaType: MediaType, mediaId: Int): MediaItem? = entry(mediaId)?.toMediaItem(mediaId, mediaType)

Expand All @@ -92,8 +108,12 @@ internal class AddonNativeCatalog(
val metaId = item.addonNativeId?.takeIf { it.isNotBlank() } ?: return
if (item.id != stableId(addonId, metaId)) return
ensureRegistryLoaded()
val restored = Entry(addonId, metaId, if (item.mediaType == MediaType.TV) "series" else "movie",
item.title, item.image, item.backdrop, item.overview)
// Prefer the type this device's catalog recorded, then the one the history entry carries.
// Older entries carry none; loadMeta then finds the addon's own type (see resolveMeta).
val type = registry[item.id]?.type
?: item.addonNativeType?.trim()?.takeIf { it.isNotBlank() }
?: if (item.mediaType == MediaType.TV) "series" else "movie"
val restored = Entry(addonId, metaId, type, item.title, item.image, item.backdrop, item.overview)
if (registry.put(item.id, restored) != restored) registryDirty = true
}

Expand Down Expand Up @@ -146,15 +166,38 @@ internal class AddonNativeCatalog(
private suspend fun loadMeta(mediaId: Int, entry: Entry): StremioMetaPreview? {
flush()
metas[mediaId]?.takeIf { System.currentTimeMillis() - it.loadedAtMs < META_TTL_MS }?.let { return it.meta }
val meta = runCatching {
streamRepository.getAddonMeta(entry.addonId, entry.type, entry.metaId)
}.onFailure { Log.w(TAG, "meta for ${entry.metaId} failed: ${it.message}") }.getOrNull()
val meta = resolveMeta(mediaId, entry)
if (meta != null) {
metas[mediaId] = CachedMeta(meta, System.currentTimeMillis())
}
return meta ?: metas[mediaId]?.meta
}

/**
* The addon's /meta for [entry]. When its recorded type gets no answer (a history entry
* restored without the addon's own type, e.g. a podcast saved as "series"), the other types
* the addon declares are tried, and the one that answers is remembered.
*/
private suspend fun resolveMeta(mediaId: Int, entry: Entry): StremioMetaPreview? {
suspend fun fetch(type: String): StremioMetaPreview? =
runCatching { streamRepository.getAddonMeta(entry.addonId, type, entry.metaId) }
.onFailure { Log.w(TAG, "meta for ${entry.metaId} failed: ${it.message}") }
.getOrNull()
fetch(entry.type)?.let { return it }
val others = runCatching { streamRepository.ownMetaTypes(entry.addonId, entry.metaId) }.getOrDefault(emptyList())
.filterNot { it.equals(entry.type, ignoreCase = true) }
for (type in others) {
val meta = fetch(type) ?: continue
if (registry[mediaId] == entry) {
registry[mediaId] = entry.copy(type = type)
registryDirty = true
flush()
}
return meta
}
return null
}

private fun Entry.toMediaItem(id: Int, mediaType: MediaType) = MediaItem(
id = id,
title = title,
Expand All @@ -163,7 +206,8 @@ internal class AddonNativeCatalog(
image = poster.orEmpty(),
backdrop = background,
addonNativeId = metaId,
addonNativeAddonId = addonId
addonNativeAddonId = addonId,
addonNativeType = type
)

private fun ensureRegistryLoaded() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import com.arflix.tv.data.model.StreamSource
data class MovieRuntimeRequest(
val imdbId: String,
val title: String,
val year: Int?
val year: Int?,
/** A native addon item's own type ("tv"); streams are requested with it instead of "movie". */
val nativeType: String? = null
)

data class EpisodeRuntimeRequest(
Expand All @@ -20,7 +22,9 @@ data class EpisodeRuntimeRequest(
val originalLanguage: String?,
val title: String,
val airDate: String?,
val animeQueryOverride: String? = null
val animeQueryOverride: String? = null,
/** A native addon item's own type ("Podcasts"); strict addons route /meta and /stream by it. */
val nativeType: String? = null
)

interface AddonRuntime {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -682,14 +682,18 @@ class CatalogRepository @Inject constructor(
)
}

// Addons may declare any catalog type (AIOMetadata's "anime"/"all"/custom display
// types, "Podcasts", debrid "other"). Unknown types keep their spelling: the addon
// routes /catalog/{type}/ by it, case included.
private fun normalizeAddonCatalogType(rawType: String?): String? {
return when (rawType?.trim()?.lowercase()) {
val trimmed = rawType?.trim()?.takeIf { it.isNotBlank() } ?: return null
return when (trimmed.lowercase()) {
"movie" -> "movie"
"series" -> "series"
"tv" -> "tv"
"show" -> "show"
"shows" -> "shows"
else -> null
else -> trimmed
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ internal object ContinueWatchingMerge {
streamTitle = preferred.streamTitle ?: fallback.streamTitle,
addonNativeId = preferred.addonNativeId ?: fallback.addonNativeId,
addonNativeAddonId = preferred.addonNativeAddonId ?: fallback.addonNativeAddonId,
addonNativeType = preferred.addonNativeType ?: fallback.addonNativeType,
year = preferred.year.ifBlank { fallback.year },
releaseDate = preferred.releaseDate.ifBlank { fallback.releaseDate },
overview = preferred.overview.ifBlank { fallback.overview },
Expand Down
Loading
Loading