diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/LenientStringListAdapter.kt b/app/src/main/kotlin/com/arflix/tv/data/api/LenientStringListAdapter.kt new file mode 100644 index 000000000..0f3857466 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/api/LenientStringListAdapter.kt @@ -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?>() { + override fun read(reader: JsonReader): List? = when (reader.peek()) { + JsonToken.NULL -> { reader.nextNull(); null } + JsonToken.BEGIN_ARRAY -> { + val values = mutableListOf() + 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?) { + if (value == null) { + writer.nullValue() + return + } + writer.beginArray() + value.forEach { writer.value(it) } + writer.endArray() + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/StreamApi.kt b/app/src/main/kotlin/com/arflix/tv/data/api/StreamApi.kt index ea679c3c2..aad2af117 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/api/StreamApi.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/api/StreamApi.kt @@ -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 @@ -201,7 +202,7 @@ data class StremioMetaPreview( val background: String? = null, val logo: String? = null, val description: String? = null, - val genres: List? = null, + @JsonAdapter(LenientStringListAdapter::class) val genres: List? = null, val released: String? = null, val releaseInfo: String? = null, val year: String? = null, diff --git a/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt b/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt index 47c80dab1..795c39e0a 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt @@ -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() diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/AddonCatalogUrls.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/AddonCatalogUrls.kt index ac5719ae0..6030c736c 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/AddonCatalogUrls.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/AddonCatalogUrls.kt @@ -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, @@ -10,13 +18,13 @@ internal fun buildCatalogRequestUrls( queryBase: String?, genre: String? = null ): List { - 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") } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/AddonMetaOwnership.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/AddonMetaOwnership.kt new file mode 100644 index 000000000..8c18878e3 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/AddonMetaOwnership.kt @@ -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, + 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 = (manifest.resources as List?).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?, 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 } +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/AddonNativeCatalog.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/AddonNativeCatalog.kt index b8571527e..be7336ff2 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/AddonNativeCatalog.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/AddonNativeCatalog.kt @@ -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, @@ -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) @@ -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 } @@ -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, @@ -163,7 +206,8 @@ internal class AddonNativeCatalog( image = poster.orEmpty(), backdrop = background, addonNativeId = metaId, - addonNativeAddonId = addonId + addonNativeAddonId = addonId, + addonNativeType = type ) private fun ensureRegistryLoaded() { diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/AddonRuntime.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/AddonRuntime.kt index 56356d947..da4c7035c 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/AddonRuntime.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/AddonRuntime.kt @@ -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( @@ -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 { diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt index 494336822..b7da6112c 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt @@ -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 } } diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/ContinueWatchingMerge.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/ContinueWatchingMerge.kt index 8f59433f1..944a5cf26 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/ContinueWatchingMerge.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/ContinueWatchingMerge.kt @@ -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 }, diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt index b0b7e94c2..dfecc7e71 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt @@ -18,6 +18,7 @@ import com.arflix.tv.data.api.TmdbWatchProviderRegion import com.arflix.tv.data.api.TraktApi import com.arflix.tv.data.api.TraktPublicListItem import com.arflix.tv.data.api.StremioMetaPreview +import com.arflix.tv.data.model.Addon import com.arflix.tv.data.model.CastMember import com.arflix.tv.data.model.CatalogConfig import com.arflix.tv.data.model.CatalogKind @@ -43,6 +44,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -478,6 +480,12 @@ class MediaRepository @Inject constructor( /** True for a native addon item (see [AddonNativeCatalog]). */ fun isAddonNative(mediaId: Int): Boolean = mediaId < 0 && addonNative.isNative(mediaId) + /** A native addon item's own type, for stream lookup; null for TMDB items. */ + fun addonNativeType(mediaId: Int): String? = if (mediaId < 0) addonNative.addonType(mediaId) else null + + /** An addon's live channel, which must be played as live and kept out of Continue Watching. */ + fun isAddonNativeLiveChannel(mediaId: Int): Boolean = mediaId < 0 && addonNative.isLiveChannel(mediaId) + // Catalog rows show native items as registered cards: loading full addon metadata per // card would cost one request per item, and the details screen loads it anyway. private suspend fun catalogItemFor(type: MediaType, mediaId: Int): MediaItem? = when { @@ -1943,15 +1951,25 @@ class MediaRepository @Inject constructor( val nativeRefs = mutableListOf>() val nativeMetaIds = HashSet() val nativeItems = mutableListOf() + // Read once per page: each read parses every installed manifest, and per item that + // starved the heap on a 500-item catalog (ANR). + val installed = installedAddonsSnapshot() + val catalogAddon = installed.firstOrNull { it.id == descriptor.addonId } metas.forEach { meta -> if (parseTmdbRefFromAddonMeta(meta, typeHint) != null || extractImdbId(meta) != null) return@forEach val metaId = meta.id?.trim().orEmpty() - val mediaType = typeHint ?: addonCatalogTypeToMediaType(meta.type) ?: return@forEach - val requestType = if (mediaType == MediaType.TV) "series" else "movie" - if (metaId.isBlank() || !addonServesOwnMeta(descriptor.addonId, requestType, metaId)) { - return@forEach - } - val item = addonNative.register(descriptor.addonId, meta, mediaType) ?: return@forEach + // The addon's own type ("Podcasts", "tv", ...) is what its /meta answers to. + val addonType = meta.type?.trim()?.takeIf { it.isNotBlank() } ?: descriptor.catalogType + val mediaType = nativeAddonMediaType(addonType, typeHint) + if (metaId.isBlank()) return@forEach + // A metadata addon (AIOMetadata) can re-list another addon's catalog; that addon + // owns the ids and serves their details and streams. + val owner = if (catalogAddon != null && addonServesOwnMeta(catalogAddon, addonType, metaId)) { + descriptor.addonId + } else { + findAddonServingOwnMeta(installed, addonType, metaId, excludeAddonId = descriptor.addonId) + } ?: return@forEach + val item = addonNative.register(owner, meta, mediaType, addonType) ?: return@forEach if (nativeMetaIds.add(metaId)) { nativeItems += item nativeRefs += mediaType to item.id @@ -1990,15 +2008,21 @@ class MediaRepository @Inject constructor( if (extractImdbId(meta) != null) return@forEach val title = meta.name?.trim().orEmpty() if (title.isBlank()) return@forEach - val metaHint = typeHint ?: addonCatalogTypeToMediaType(meta.type) - val titleKey = "${metaHint?.name ?: "ANY"}|${title.lowercase(Locale.US)}" + // A title alone, with no movie/series type to narrow it, matches unrelated TMDB titles. + val metaHint = typeHint ?: addonCatalogTypeToMediaType(meta.type) ?: return@forEach + val titleKey = "${metaHint.name}|${title.lowercase(Locale.US)}" if (seenTitle.add(titleKey)) { titleCandidates += title to metaHint } } val metaSemaphore = Semaphore(2) - val resolvedFromMeta = unresolvedMetaCandidates.take(8).map { unresolved -> + // Don't ask an addon for metadata its manifest says it can't serve (a metadata addon for + // ids it re-lists from another addon): each miss is several requests, and a page of them + // gets us rate limited. + val resolvedFromMeta = unresolvedMetaCandidates + .filter { addonMayServeMeta(catalogAddon, it.id) } + .take(8).map { unresolved -> async { metaSemaphore.withPermit { resolveAddonMetaToTmdbRef( @@ -2030,13 +2054,13 @@ class MediaRepository @Inject constructor( (nativeRefs + directRefs + resolvedFromMeta + resolvedImdbRefs + resolvedTitleRefs).distinct() } - private suspend fun addonServesOwnMeta(addonId: String, type: String, metaId: String): Boolean = try { - streamRepository.addonServesOwnMeta(addonId, type, metaId) + private suspend fun installedAddonsSnapshot(): List = try { + streamRepository.installedAddons.first() } catch (e: CancellationException) { throw e } catch (e: Exception) { // Unknown means "match it to TMDB as before", never a failed row. - false + emptyList() } private suspend fun resolveAddonMetaToTmdbRef( @@ -2183,17 +2207,29 @@ class MediaRepository @Inject constructor( return try { URLDecoder.decode(value, "UTF-8") } catch (_: Exception) { value } } + /** Known types are canonicalised; any other addon type keeps its spelling (see CatalogRepository). */ 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 } } + /** + * How a native item is shown. Stremio's "tv" type is a live channel: one stream, no + * episodes, so it opens like a movie and plays straight away. Other unknown types + * (podcasts, debrid libraries) are containers of videos, which the series page shows. + */ + private fun nativeAddonMediaType(rawType: String, typeHint: MediaType?): MediaType { + if (rawType.equals("tv", ignoreCase = true)) return MediaType.MOVIE + return typeHint ?: addonCatalogTypeToMediaType(rawType) ?: MediaType.TV + } + private fun addonCatalogTypeToMediaType(rawType: String?): MediaType? { return when (normalizeAddonCatalogType(rawType)) { "movie" -> MediaType.MOVIE diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index 368ce9479..76e87ba7b 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -385,7 +385,8 @@ class StreamRepository @Inject constructor( addon = addon, imdbId = request.imdbId, title = request.title, - year = request.year + year = request.year, + nativeType = request.nativeType ) }, episodeResolver = { addon, request -> @@ -400,7 +401,8 @@ class StreamRepository @Inject constructor( originalLanguage = request.originalLanguage, title = request.title, animeQueryOverride = request.animeQueryOverride, - airDate = request.airDate + airDate = request.airDate, + nativeType = request.nativeType ) } ) @@ -1050,8 +1052,16 @@ class StreamRepository @Inject constructor( } } - return addons.firstOrNull { it.id == preferred && matches(it) }?.id + // Collections name the addon by its manifest id ("aio-metadata"); installed ids carry a + // per-URL suffix (see buildAddonInstanceId). + fun isPreferred(addon: Addon): Boolean = + preferred != null && (addon.id == preferred || addon.manifest?.id == preferred) + + return addons.firstOrNull { isPreferred(it) && matches(it) }?.id ?: addons.firstOrNull { matches(it) }?.id + // Like Nuvio, ask the named addon anyway: AIOMetadata serves catalogs (its + // streaming services) that a user's manifest doesn't list. + ?: addons.firstOrNull { isPreferred(it) }?.id } private fun buildAddonInstanceId(manifestId: String, url: String): String { @@ -1482,8 +1492,24 @@ class StreamRepository @Inject constructor( val (baseUrl, queryParams) = getAddonBaseUrl(addonUrl) val queryBase = queryParams?.takeIf { it.isNotBlank() } - val typeCandidates = catalogTypeAliases(catalogType) + // Only fall back to alias types the addon declares for this catalog: some addons answer + // any /catalog/tv/ request with their live channel list, which would fill an empty + // series page with unrelated channels. + val declaredSpellings = addon.manifest?.catalogs.orEmpty() + .filter { it.id == catalogId } + .mapNotNull { (it.type as String?)?.trim() } + val declaredTypes = declaredSpellings.map { it.lowercase(Locale.US) }.toSet() + // Addons route by the exact spelling ("Live Docs"); collections may lower-case it. + val requestType = declaredSpellings.firstOrNull { it.equals(catalogType.trim(), ignoreCase = true) } + ?: catalogType + val typeCandidates = catalogTypeAliases(requestType).filter { candidate -> + declaredTypes.isEmpty() || candidate.equals(catalogType, ignoreCase = true) || + candidate.lowercase(Locale.US) in declaredTypes + } var firstSuccessful: StremioCatalogResponse? = null + // A rate-limited addon fails every request for a while; more requests only extend it. + // Failing (not returning empty) keeps callers from caching the page as empty. + if (AddonRateLimitTracker.isCoolingDown(addonId)) throw java.io.IOException("Addon is rate limited") for (typeCandidate in typeCandidates) { val urls = buildCatalogRequestUrls( @@ -1499,6 +1525,10 @@ class StreamRepository @Inject constructor( streamApi.getAddonCatalog(url) } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e + if (AddonRateLimitTracker.isRateLimitError(e)) { + AddonRateLimitTracker.recordRateLimit(addonId) + throw java.io.IOException("Addon is rate limited", e) + } continue } if (firstSuccessful == null) { @@ -1587,18 +1617,23 @@ class StreamRepository @Inject constructor( val cacheKey = "$addonId|$addonUrl|$mediaType|$mediaId" addonMetaCache[cacheKey]?.takeIf { System.currentTimeMillis() - it.createdAtMs < 30 * 60_000L } ?.let { return@withContext it.meta } + if (AddonRateLimitTracker.isCoolingDown(addonId)) return@withContext null val (baseUrl, queryParams) = getAddonBaseUrl(addonUrl) val typeCandidates = catalogTypeAliases(mediaType) - val encodedId = URLEncoder.encode(mediaId, "UTF-8") + val encodedId = encodePathSegment(mediaId) for (typeCandidate in typeCandidates) { - val encodedType = URLEncoder.encode(typeCandidate, "UTF-8") + val encodedType = encodePathSegment(typeCandidate) val query = queryParams?.takeIf { it.isNotBlank() }?.let { "?$it" }.orEmpty() val url = "$baseUrl/meta/$encodedType/$encodedId.json$query" val meta = try { streamApi.getAddonMeta(url).meta } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e + if (AddonRateLimitTracker.isRateLimitError(e)) { + AddonRateLimitTracker.recordRateLimit(addonId) + return@withContext null + } null } if (meta != null) { @@ -1610,27 +1645,40 @@ class StreamRepository @Inject constructor( null } + /** [addonServesOwnMeta] for one installed addon; see AddonMetaOwnership.kt. */ + suspend fun addonServesOwnMeta(addonId: String, type: String, contentId: String): Boolean { + val addon = installedAddons.first().firstOrNull { it.id == addonId } ?: return false + return addonServesOwnMeta(addon, type, contentId) + } + /** - * True when [addonId] 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. + * The types [addonId] declares under which it serves its own metadata for [contentId]. Used + * for watch-history entries saved before the item's own type was recorded with them. */ - suspend fun addonServesOwnMeta(addonId: String, 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 - val manifest = installedAddons.first().firstOrNull { it.id == addonId && it.isEnabled }?.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) - } - } + suspend fun ownMetaTypes(addonId: String, contentId: String): List { + val addon = installedAddons.first().firstOrNull { it.id == addonId } ?: return emptyList() + val manifest = addon.manifest + // Gson leaves absent manifest fields null despite the Kotlin types. + val declared: List = manifest?.types.orEmpty() + manifest?.catalogs.orEmpty().map { it.type } + return declared.mapNotNull { it?.trim() } + .filter { it.isNotBlank() } + .distinctBy { it.lowercase(Locale.US) } + .filter { addonServesOwnMeta(addon, it, contentId) } + } + + /** + * Types a native episode's show may live under at [addon]: "series" first, then the + * addon's own non-movie types ("Podcasts", "other"), which strict addons route /meta and + * /stream by. + */ + private fun nativeContainerTypes(addon: Addon): List { + val manifest = addon.manifest + // Gson leaves absent manifest fields null despite the Kotlin types. + val declared: List = manifest?.types.orEmpty() + manifest?.catalogs.orEmpty().map { it.type } + return (listOf("series") + declared) + .mapNotNull { it?.trim() } + .filter { it.isNotBlank() && !it.equals("movie", ignoreCase = true) } + .distinctBy { it.lowercase(Locale.US) } } private fun catalogTypeAliases(rawType: String): List { @@ -1738,18 +1786,6 @@ class StreamRepository @Inject constructor( } } - private fun supportsResourceType(resourceTypes: List?, 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 } - } - private fun idMatchesAnyPrefix(id: String, prefixes: List?): Boolean { if (prefixes.isNullOrEmpty()) return true return prefixes.any { prefix -> @@ -1817,10 +1853,12 @@ class StreamRepository @Inject constructor( tmdbId: Int?, isAnime: Boolean, genreIds: List, - originalLanguage: String? + originalLanguage: String?, + // A native item's own type ("Podcasts"): an addon declaring only that type still qualifies. + nativeType: String? = null ): List { val seriesAddons = buildEpisodeAddonLookupIds(imdbId, tmdbId) - .flatMap { id -> getStreamAddons(addons, "series", id) } + .flatMap { id -> getStreamAddons(addons, nativeType ?: "series", id) } .distinctBy { it.id } val hasNativeAnimeAddon = addons.any(::shouldPreferNativeAnimeIds) val shouldIncludeAnimeAddons = isAnime || @@ -2013,7 +2051,9 @@ class StreamRepository @Inject constructor( addon: Addon, imdbId: String, title: String = "", - year: Int? = null + year: Int? = null, + // A native item's own type ("tv" for a live channel): its addon may declare no movie type. + nativeType: String? = null ): List { val startedAt = System.currentTimeMillis() val addonTimeoutMs = when { @@ -2038,10 +2078,15 @@ class StreamRepository @Inject constructor( return@withTimeout streams } val (baseUrl, queryParams) = getAddonBaseUrl(addon.url ?: return@withTimeout emptyList()) + val path = if (nativeType != null) { + "${encodePathSegment(nativeType)}/${encodePathSegment(imdbId)}" + } else { + "movie/$imdbId" + } val url = if (queryParams != null) { - "$baseUrl/stream/movie/$imdbId.json?$queryParams" + "$baseUrl/stream/$path.json?$queryParams" } else { - "$baseUrl/stream/movie/$imdbId.json" + "$baseUrl/stream/$path.json" } Log.d( TAG, @@ -2109,7 +2154,8 @@ class StreamRepository @Inject constructor( originalLanguage: String? = null, title: String = "", animeQueryOverride: String? = null, - airDate: String? = null + airDate: String? = null, + nativeType: String? = null ): List { val startedAt = System.currentTimeMillis() val nativeAnimeAddonHint = shouldPreferNativeAnimeIds(addon) @@ -2170,9 +2216,18 @@ class StreamRepository @Inject constructor( animeQueryOverride ?: resolveAnimeQuery(animeLookupTimeoutMs) } else null - val isNative = addonServesOwnMeta(addon.id, "series", imdbId) + val nativeTypes = (listOfNotNull(nativeType) + nativeContainerTypes(addon)) + .distinctBy { it.lowercase(Locale.US) } + .filter { addonServesOwnMeta(addon, it, imdbId) } + val isNative = nativeTypes.isNotEmpty() + // The type the addon answered /meta with; strict addons route /stream by it too. + var nativeType: String? = null val seriesId = if (isNative) { - val meta = withTimeoutOrNull(5_000L) { getAddonMeta(addon.id, "series", imdbId) } + val meta = withTimeoutOrNull(5_000L) { + nativeTypes.firstNotNullOfOrNull { type -> + getAddonMeta(addon.id, type, imdbId)?.also { nativeType = type } + } + } nativeEpisodeStreamId(meta, season, episode) ?: return@withTimeout emptyList() } else { "$imdbId:$season:$episode" @@ -2186,11 +2241,12 @@ class StreamRepository @Inject constructor( val useKitsuFallback = resolveAsAnime && supportsKitsu && animeQuery != null && animeQuery != seriesId val preferNativeAnimeIds = useKitsuFallback && nativeAnimeAddon fun streamUrl(type: String, contentId: String): String { - val encodedId = if (isNative) URLEncoder.encode(contentId, "UTF-8") else contentId + val encodedId = if (isNative) encodePathSegment(contentId) else contentId + val encodedType = encodePathSegment(type) return if (queryParams != null) { - "$baseUrl/stream/$type/$encodedId.json?$queryParams" + "$baseUrl/stream/$encodedType/$encodedId.json?$queryParams" } else { - "$baseUrl/stream/$type/$encodedId.json" + "$baseUrl/stream/$encodedType/$encodedId.json" } } @@ -2199,6 +2255,7 @@ class StreamRepository @Inject constructor( // episodes like TMDB S4E29 to Kitsu E7 and surface the wrong debrid files. fun streamRequestTypes(contentId: String, preferAnimePath: Boolean): List { val types = mutableListOf() + nativeType?.let { types += it } if (preferAnimePath && addonSupportsStreamRequest(addon, "anime", contentId)) { types += "anime" } @@ -2465,12 +2522,13 @@ class StreamRepository @Inject constructor( imdbId: String, title: String = "", year: Int? = null, - forceRefresh: Boolean = false + forceRefresh: Boolean = false, + nativeType: String? = null ): StreamResult = withContext(Dispatchers.IO) { ensureAddonHealthLoaded() val subtitles = mutableListOf() val allAddons = installedAddonsForSourceResolution() - val streamAddons = getStreamAddons(allAddons, "movie", imdbId) + val streamAddons = getStreamAddons(allAddons, nativeType ?: "movie", imdbId) val cacheKey = streamCacheKey( profileId = profileManager.getProfileIdSync(), type = "movie", @@ -2498,7 +2556,7 @@ class StreamRepository @Inject constructor( } val prioritizedAddons = prioritizeStreamingAddons(streamAddons) - val movieRequest = MovieRuntimeRequest(imdbId = imdbId, title = title, year = year) + val movieRequest = MovieRuntimeRequest(imdbId = imdbId, title = title, year = year, nativeType = nativeType) val streams = addonRuntimeAggregator.resolveMovieStreams( stremioAddons = prioritizedAddons, request = movieRequest @@ -2530,14 +2588,15 @@ class StreamRepository @Inject constructor( title: String = "", year: Int? = null, forceRefresh: Boolean = false, - sequential: Boolean = false + sequential: Boolean = false, + nativeType: String? = null ): Flow = callbackFlow { // Retained so cancelling the collector (back-nav, superseded prefetch) // also stops the scrape instead of leaking it in repositoryScope. val workerJob = repositoryScope.launch { ensureAddonHealthLoaded() val allAddons = installedAddonsForSourceResolution() - val streamAddons = getStreamAddons(allAddons, "movie", imdbId) + val streamAddons = getStreamAddons(allAddons, nativeType ?: "movie", imdbId) val profileId = profileManager.getProfileIdSync() val baseCacheKey = streamCacheKey( profileId = profileId, @@ -2683,7 +2742,7 @@ class StreamRepository @Inject constructor( for (addon in prioritizedAddons) { val addonStreams = try { withTimeoutOrNull(3_500L) { - fetchMovieStreamsFromAddon(addon, imdbId) + fetchMovieStreamsFromAddon(addon, imdbId, nativeType = nativeType) } ?: emptyList() } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e @@ -2746,7 +2805,7 @@ class StreamRepository @Inject constructor( prioritizedAddons.forEach { addon -> launch { val addonStreams = try { - fetchMovieStreamsFromAddon(addon, imdbId) + fetchMovieStreamsFromAddon(addon, imdbId, nativeType = nativeType) } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e @@ -2836,12 +2895,13 @@ class StreamRepository @Inject constructor( originalLanguage: String? = null, animeQueryOverride: String? = null, airDate: String? = null, - timeoutMs: Long = 3_500L + timeoutMs: Long = 3_500L, + nativeType: String? = null ): List = withContext(Dispatchers.IO) { withTimeoutOrNull(timeoutMs) { try { if (mediaType == MediaType.MOVIE) { - fetchMovieStreamsFromAddon(addon = addon, imdbId = imdbId, title = title, year = year) + fetchMovieStreamsFromAddon(addon = addon, imdbId = imdbId, title = title, year = year, nativeType = nativeType) } else { fetchEpisodeStreamsFromAddon( addon = addon, @@ -2854,7 +2914,8 @@ class StreamRepository @Inject constructor( originalLanguage = originalLanguage, title = title, animeQueryOverride = animeQueryOverride, - airDate = airDate + airDate = airDate, + nativeType = nativeType ) } } catch (e: Exception) { @@ -3141,7 +3202,9 @@ class StreamRepository @Inject constructor( title: String = "", forceRefresh: Boolean = false, animeQueryOverride: String? = null, - airDate: String? = null + airDate: String? = null, + // A native addon item's own type ("Podcasts"), for addon selection and /meta, /stream. + nativeType: String? = null ): StreamResult = withContext(Dispatchers.IO) { // Native addon items carry a negative stand-in id, which no addon knows as TMDB. val tmdbId = tmdbId?.takeIf { it > 0 } @@ -3155,7 +3218,8 @@ class StreamRepository @Inject constructor( tmdbId = tmdbId, isAnime = isAnime, genreIds = genreIds, - originalLanguage = originalLanguage + originalLanguage = originalLanguage, + nativeType = nativeType ) val cacheKey = streamCacheKey( profileId = profileManager.getProfileIdSync(), @@ -3190,7 +3254,8 @@ class StreamRepository @Inject constructor( originalLanguage = originalLanguage, title = title, airDate = airDate, - animeQueryOverride = animeQueryOverride + animeQueryOverride = animeQueryOverride, + nativeType = nativeType ) val streams = addonRuntimeAggregator.resolveEpisodeStreams( stremioAddons = prioritizedAddons, @@ -3220,7 +3285,9 @@ class StreamRepository @Inject constructor( forceRefresh: Boolean = false, animeQueryOverride: String? = null, airDate: String? = null, - sequential: Boolean = false + sequential: Boolean = false, + // A native addon item's own type ("Podcasts"), for addon selection and /meta, /stream. + nativeType: String? = null ): Flow = callbackFlow { // Native addon items carry a negative stand-in id, which no addon knows as TMDB. val tmdbId = tmdbId?.takeIf { it > 0 } @@ -3236,7 +3303,8 @@ class StreamRepository @Inject constructor( tmdbId = tmdbId, isAnime = isAnime, genreIds = genreIds, - originalLanguage = originalLanguage + originalLanguage = originalLanguage, + nativeType = nativeType ) val baseCacheKey = streamCacheKey( profileId = profileManager.getProfileIdSync(), @@ -3372,7 +3440,8 @@ class StreamRepository @Inject constructor( originalLanguage = originalLanguage, title = title, animeQueryOverride = animeQueryOverride, - airDate = airDate + airDate = airDate, + nativeType = nativeType ) } ?: emptyList() } catch (e: Exception) { @@ -3454,7 +3523,8 @@ class StreamRepository @Inject constructor( originalLanguage = originalLanguage, title = title, animeQueryOverride = animeQueryOverride, - airDate = airDate + airDate = airDate, + nativeType = nativeType ) } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt index a02d69284..514917902 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt @@ -2747,6 +2747,7 @@ class TraktRepository @Inject constructor( emitUpdate: Boolean = true, addonNativeId: String? = null, addonNativeAddonId: String? = null, + addonNativeType: String? = null, ) { ensureProfileCacheScope() if (SportsAddonCapabilities.isLiveStreamOrSportsItem( @@ -2792,6 +2793,7 @@ class TraktRepository @Inject constructor( isUpNext = isUpNext, addonNativeId = addonNativeId, addonNativeAddonId = addonNativeAddonId, + addonNativeType = addonNativeType, updatedAtMs = System.currentTimeMillis() ) @@ -5121,7 +5123,9 @@ data class ContinueWatchingItem( val totalEpisodes: Int = 0, val watchedEpisodes: Int = 0, val addonNativeId: String? = null, - val addonNativeAddonId: String? = null + val addonNativeAddonId: String? = null, + // Portable, so a device restoring this entry asks the addon with its own type. + val addonNativeType: String? = null ) { fun toMediaItem(context: Context? = null): MediaItem { val effectiveDurationSeconds = durationSeconds.takeIf { it > 0L } ?: parseRuntimeLabelSeconds(duration) @@ -5211,7 +5215,8 @@ data class ContinueWatchingItem( timeRemainingLabel = timeRemainingLabel, showPlaybackProgress = showPlaybackProgress, addonNativeId = addonNativeId, - addonNativeAddonId = addonNativeAddonId + addonNativeAddonId = addonNativeAddonId, + addonNativeType = addonNativeType ) } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/collections/CollectionDetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/collections/CollectionDetailsScreen.kt index d45c450f5..55b6bd0cd 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/collections/CollectionDetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/collections/CollectionDetailsScreen.kt @@ -623,9 +623,14 @@ class CollectionDetailsViewModel @Inject constructor( if (mediaType == "all" || mediaType == "any" || mediaType == "both" || mediaType == "mixed") { return true } + val isMovie = mediaType == "movie" || mediaType == "film" + val isSeries = mediaType == "series" || mediaType == "tv" || mediaType == "show" || mediaType == "anime" + // Addon-defined types ("Live Docs", "Podcasts") can hold either; the loader keeps + // only the items whose own type matches the tab. + if (!isMovie && !isSeries) return true return when (tab) { - CollectionTab.MOVIES -> mediaType == "movie" || mediaType == "film" - CollectionTab.SERIES -> mediaType == "series" || mediaType == "tv" || mediaType == "show" || mediaType == "anime" + CollectionTab.MOVIES -> isMovie + CollectionTab.SERIES -> isSeries } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt index 6c679de5a..686a85ef4 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt @@ -1889,7 +1889,8 @@ class DetailsViewModel @Inject constructor( streamRepository.resolveMovieStreamsProgressive( imdbId = imdbId, title = _uiState.value.item?.title.orEmpty(), - year = _uiState.value.item?.year?.toIntOrNull() + year = _uiState.value.item?.year?.toIntOrNull(), + nativeType = mediaRepository.addonNativeType(currentMediaId) ).collect { progressive -> prewarmVisibleStreams( sortPlayableStreamsFirst( @@ -1923,7 +1924,8 @@ class DetailsViewModel @Inject constructor( genreIds = _uiState.value.item?.genreIds ?: emptyList(), originalLanguage = _uiState.value.item?.originalLanguage, title = _uiState.value.item?.title ?: "", - airDate = prefetchAirDate + airDate = prefetchAirDate, + nativeType = mediaRepository.addonNativeType(currentMediaId) ).collect { progressive -> prewarmVisibleStreams( sortPlayableStreamsFirst( @@ -2311,7 +2313,8 @@ class DetailsViewModel @Inject constructor( streamRepository.resolveMovieStreamsProgressive( imdbId = effectiveStreamId, title = item?.title.orEmpty(), - year = item?.year?.toIntOrNull() + year = item?.year?.toIntOrNull(), + nativeType = mediaRepository.addonNativeType(currentMediaId) ).collect { progressive -> if (!isCurrentRequest()) return@collect val existingVod = _uiState.value.streams.filter(::isSupplementalStream) @@ -2380,7 +2383,8 @@ class DetailsViewModel @Inject constructor( originalLanguage = originalLanguage, title = item?.title ?: "", animeQueryOverride = animeQueryOverride, - airDate = episodeAirDate + airDate = episodeAirDate, + nativeType = mediaRepository.addonNativeType(currentMediaId) ).collect { progressive -> if (!isCurrentRequest()) return@collect val existingVod = _uiState.value.streams.filter(::isSupplementalStream) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index 0078d6927..0871ddbfe 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -900,6 +900,8 @@ class HomeViewModel @Inject constructor( id = item.id, streamAddonId = item.streamAddonId, title = item.title, + // Also drops live channels saved before they were played as live. + isLiveStream = mediaRepository.isAddonNativeLiveChannel(item.id), addons = installedAddons, isAddonNative = !item.addonNativeId.isNullOrBlank() ) @@ -3056,28 +3058,16 @@ class HomeViewModel @Inject constructor( // Resolve in savedCatalogs order — this is the user's configured // catalog ordering from Settings > Catalogs. - // Addon-provided service-branded catalogs (Netflix, Disney+, - // Hulu etc. rows contributed by aio-metadata / org.kris / - // local scraper addons) are suppressed here - we already surface - // those services via the collection-tile Services row, so - // having a second identically-named catalog row below it - // was duplicative per user feedback. Preinstalled catalogs - // (Trending, Just Added, Top 10, etc.) are never skipped. - val serviceTitleBlocklist = setOf( - "netflix", "prime video", "prime", "apple tv+", "apple tv plus", - "apple tv", "disney+", "disney plus", "paramount+", "paramount plus", - "hbo max", "max", "hulu", "shudder", "jiohotstar", "sonyliv", - "sky", "crunchyroll", "peacock" - ) + // Addon catalogs named after a service (Netflix, Disney+ from + // AIOMetadata) are shown like any other row, as in other Stremio + // clients: the user enabled them in their addon, and a duplicate of + // the built-in Services row can be removed in Settings > Catalogs. val resolved = savedCatalogs.mapNotNull { cfg -> if (isCollectionRailConfig(cfg) || isCollectionTileConfig(cfg)) { return@mapNotNull null } val cat = allById[cfg.id] if (cat == null || cat.items.isEmpty()) return@mapNotNull null - if (cfg.sourceType == CatalogSourceType.ADDON && - cat.title.trim().lowercase(Locale.US) in serviceTitleBlocklist - ) return@mapNotNull null cat.withTop10CapIfNeeded() }.toMutableList() resolved diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index d61721039..33b14ae35 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt @@ -731,7 +731,9 @@ class PlayerViewModel @Inject constructor( currentPreferredAddonId = preferredAddonId?.trim()?.takeIf { it.isNotBlank() } currentPreferredSourceName = preferredSourceName?.trim()?.takeIf { it.isNotBlank() } currentPreferredBingeGroup = preferredBingeGroup?.trim()?.takeIf { it.isNotBlank() } - currentIsLiveStreamPlayback = isLiveStreamPlayback + // An addon's live channel opens like a movie but plays live: no progress, scrobbles or + // Continue Watching entry. + currentIsLiveStreamPlayback = isLiveStreamPlayback || mediaRepository.isAddonNativeLiveChannel(mediaId) autoPlayMinimumQuality = 0 autoPlayLimits = com.arflix.tv.data.model.AutoplayLimits() playbackSessionStartTime = System.currentTimeMillis() @@ -1333,7 +1335,8 @@ class PlayerViewModel @Inject constructor( originalLanguage = currentOriginalLanguage, animeQueryOverride = animeQueryOverride, airDate = currentAirDate, - timeoutMs = 3_500L + timeoutMs = 3_500L, + nativeType = mediaRepository.addonNativeType(mediaId) ) } else emptyList() } @@ -1478,7 +1481,8 @@ class PlayerViewModel @Inject constructor( imdbId = effectiveStreamId, title = currentItemTitle, year = null, - forceRefresh = forceRefresh + forceRefresh = forceRefresh, + nativeType = mediaRepository.addonNativeType(mediaId) ) } else { streamRepository.resolveEpisodeStreamsProgressive( @@ -1492,7 +1496,8 @@ class PlayerViewModel @Inject constructor( title = currentItemTitle, forceRefresh = forceRefresh, animeQueryOverride = animeQueryOverride, - airDate = currentAirDate + airDate = currentAirDate, + nativeType = mediaRepository.addonNativeType(mediaId) ) } @@ -7713,6 +7718,7 @@ class PlayerViewModel @Inject constructor( emitUpdate = aired, addonNativeId = mediaRepository.getCachedItem(MediaType.TV, currentMediaId)?.addonNativeId, addonNativeAddonId = mediaRepository.getCachedItem(MediaType.TV, currentMediaId)?.addonNativeAddonId, + addonNativeType = mediaRepository.addonNativeType(currentMediaId), ) } @@ -7961,7 +7967,8 @@ class PlayerViewModel @Inject constructor( streamAddonId = streamAddonId, streamTitle = streamTitle, addonNativeId = mediaRepository.getCachedItem(currentMediaType, currentMediaId)?.addonNativeId, - addonNativeAddonId = mediaRepository.getCachedItem(currentMediaType, currentMediaId)?.addonNativeAddonId + addonNativeAddonId = mediaRepository.getCachedItem(currentMediaType, currentMediaId)?.addonNativeAddonId, + addonNativeType = mediaRepository.addonNativeType(currentMediaId) ) // Push local CW to cloud so other devices see mid-playback progress. @@ -8318,7 +8325,8 @@ class PlayerViewModel @Inject constructor( streamRepository.resolveMovieStreams( imdbId = imdbId, title = currentItemTitle, - year = null + year = null, + nativeType = mediaRepository.addonNativeType(mediaId) ) } else { streamRepository.resolveEpisodeStreams( @@ -8330,7 +8338,8 @@ class PlayerViewModel @Inject constructor( genreIds = currentGenreIds, originalLanguage = currentOriginalLanguage, title = currentItemTitle, - animeQueryOverride = currentAnimeQueryOverride + animeQueryOverride = currentAnimeQueryOverride, + nativeType = mediaRepository.addonNativeType(mediaId) ) } diff --git a/app/src/test/kotlin/com/arflix/tv/data/api/LenientStringListAdapterTest.kt b/app/src/test/kotlin/com/arflix/tv/data/api/LenientStringListAdapterTest.kt new file mode 100644 index 000000000..481784698 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/api/LenientStringListAdapterTest.kt @@ -0,0 +1,19 @@ +package com.arflix.tv.data.api + +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class LenientStringListAdapterTest { + private val gson = Gson() + + @Test fun catalogWithBareStringGenresStillParses() { + val json = """{"metas":[{"id":"a","genres":["Drama","Comedy"]},{"id":"b","genres":"Documentary"},{"id":"c","genres":null},{"id":"d"}]}""" + val metas = gson.fromJson(json, StremioCatalogResponse::class.java).metas!! + assertEquals(listOf("Drama", "Comedy"), metas[0].genres) + assertEquals(listOf("Documentary"), metas[1].genres) + assertNull(metas[2].genres) + assertNull(metas[3].genres) + } +} diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/AddonMetaOwnershipTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/AddonMetaOwnershipTest.kt new file mode 100644 index 000000000..6854ec289 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/AddonMetaOwnershipTest.kt @@ -0,0 +1,47 @@ +package com.arflix.tv.data.repository + +import com.arflix.tv.data.model.Addon +import com.arflix.tv.data.model.AddonManifest +import com.arflix.tv.data.model.AddonResource +import com.arflix.tv.data.model.AddonType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AddonMetaOwnershipTest { + private fun addon(id: String, prefixes: List?, resources: List = listOf("catalog", "meta")) = Addon( + id = id, name = id, version = "1", description = "", isInstalled = true, type = AddonType.CUSTOM, + manifest = AddonManifest(id = id, name = id, version = "1", + resources = resources.map { AddonResource(name = it) }, idPrefixes = prefixes) + ) + + // A metadata addon re-listing a broadcaster's catalog: it doesn't own the broadcaster's ids. + private val metadata = addon("aio-metadata_1", listOf("tmdb:", "tt", "mal:")) + private val broadcaster = addon("example.channels_2", listOf("chx_", "tmdb:", "tt"), + listOf("catalog", "meta", "stream")) + + @Test fun reListedIdsBelongToTheAddonThatServesTheirMeta() { + val addons = listOf(metadata, broadcaster) + assertFalse(addonServesOwnMeta(metadata, "series", "chx_show_1")) + assertEquals(broadcaster.id, findAddonServingOwnMeta(addons, "series", "chx_show_1", metadata.id)) + // TMDB/IMDb ids stay on the TMDB path, whoever lists them. + assertNull(findAddonServingOwnMeta(addons, "series", "tt123", metadata.id)) + } + + @Test fun ownerIsNotFoundWhenItsAddonIsMissingOrDisabled() { + assertNull(findAddonServingOwnMeta(listOf(metadata), "series", "chx_show_1", metadata.id)) + assertNull(findAddonServingOwnMeta(listOf(metadata, broadcaster.copy(isEnabled = false)), + "series", "chx_show_1", metadata.id)) + } + + @Test fun metaIsNotRequestedFromAnAddonWhosePrefixesExcludeTheId() { + assertFalse(addonMayServeMeta(metadata, "chx_show_1")) + assertTrue(addonMayServeMeta(metadata, "tt123")) + assertFalse(addonMayServeMeta(addon("catalog-only", null, listOf("catalog")), "x:1")) + // Unknown manifests and prefix-less meta keep the old behaviour. + assertTrue(addonMayServeMeta(null, "chx_1")) + assertTrue(addonMayServeMeta(addon("open", null), "anything:1")) + } +} diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/AddonNativeCatalogTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/AddonNativeCatalogTest.kt index 873167e58..6d396dc8b 100644 --- a/app/src/test/kotlin/com/arflix/tv/data/repository/AddonNativeCatalogTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/AddonNativeCatalogTest.kt @@ -17,8 +17,46 @@ import org.junit.rules.TemporaryFolder class AddonNativeCatalogTest { @get:Rule val folder = TemporaryFolder() - private fun context(): Context = mockk { - every { filesDir } returns folder.root + // A second device: same cloud history, empty native registry. + @get:Rule val freshDevice = TemporaryFolder() + private fun context(dir: java.io.File = folder.root): Context = mockk { + every { filesDir } returns dir + } + + private fun savedPodcastEpisode(type: String?): ContinueWatchingItem { + val id = AddonNativeCatalog(context(), mockk(relaxed = true)).stableId("provider", "provider:pod") + return ContinueWatchingItem(id = id, title = "Pod", mediaType = MediaType.TV, progress = 20, + resumePositionSeconds = 300, season = 1, episode = 2, addonNativeId = "provider:pod", + addonNativeAddonId = "provider", addonNativeType = type) + } + + @Test fun freshDeviceRestoreKeepsTheAddonsOwnType() = runBlocking { + val history = decodeContinueWatchingCache(Gson().toJson(listOf(savedPodcastEpisode("Podcasts"))), Gson()) + val streams = mockk(relaxed = true) + coEvery { streams.getAddonMeta(any(), any(), any()) } returns null + val device = AddonNativeCatalog(context(freshDevice.root), streams) + val card = history.single().toMediaItem() + device.restore(card) + device.details(MediaType.TV, card.id) + assertEquals("Podcasts", device.addonType(card.id)) + io.mockk.coVerify { streams.getAddonMeta("provider", "Podcasts", "provider:pod") } + io.mockk.coVerify(exactly = 0) { streams.getAddonMeta(any(), "series", any()) } + } + + @Test fun historySavedWithoutTheTypeResolvesItFromTheOwningAddon() = runBlocking { + val streams = mockk(relaxed = true) + coEvery { streams.getAddonMeta("provider", "series", "provider:pod") } returns null + coEvery { streams.ownMetaTypes("provider", "provider:pod") } returns listOf("Podcasts") + coEvery { streams.getAddonMeta("provider", "Podcasts", "provider:pod") } returns + StremioMetaPreview(id = "provider:pod", name = "Pod", videos = listOf( + StremioMetaVideo(id = "provider:pod:1:2", season = 1, episode = 2))) + val device = AddonNativeCatalog(context(freshDevice.root), streams) + val card = savedPodcastEpisode(type = null).toMediaItem() + device.restore(card) + assertEquals(1, device.episodes(card.id, 1).size) + // Remembered, so stream lookup and later opens use it directly. + assertEquals("Podcasts", device.addonType(card.id)) + assertEquals("Podcasts", AddonNativeCatalog(context(freshDevice.root), streams).addonType(card.id)) } @Test fun episodeLookupKeepsOpaqueIdsAndSeasonCoordinates() { @@ -50,6 +88,30 @@ class AddonNativeCatalogTest { assertEquals("provider", restarted.card(MediaType.TV, card.id)?.addonNativeAddonId) } + @Test fun customAddonTypeIsUsedForMetaAndSurvivesRestore() = runBlocking { + val streams = mockk(relaxed = true) + coEvery { streams.getAddonMeta(any(), any(), any()) } returns null + val catalog = AddonNativeCatalog(context(), streams) + val card = catalog.register("provider", StremioMetaPreview(id = "provider:pod", name = "Pod"), + MediaType.TV, addonType = "Podcasts")!! + catalog.restore(card) + catalog.details(MediaType.TV, card.id) + io.mockk.coVerify { streams.getAddonMeta("provider", "Podcasts", "provider:pod") } + } + + @Test fun onlyTvTypedItemsAreLiveChannels() { + val catalog = AddonNativeCatalog(context(), mockk(relaxed = true)) + val live = catalog.register("provider", StremioMetaPreview(id = "provider:ch1", name = "Ch"), + MediaType.MOVIE, addonType = "tv")!! + val show = catalog.register("provider", StremioMetaPreview(id = "provider:show", name = "Show"), MediaType.TV)!! + val pod = catalog.register("provider", StremioMetaPreview(id = "provider:pod", name = "Pod"), + MediaType.TV, addonType = "Podcasts")!! + assertTrue(catalog.isLiveChannel(live.id)) + assertFalse(catalog.isLiveChannel(show.id)) + assertFalse(catalog.isLiveChannel(pod.id)) + assertFalse(catalog.isLiveChannel(12345)) + } + @Test fun connectedTrackersDoNotHideLocalNativePlaybackOrUpNext() { val native = ContinueWatchingItem(id = -123, title = "Native", mediaType = MediaType.TV, progress = 20, resumePositionSeconds = 300, addonNativeId = "provider:show", diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/CustomCollectionsTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/CustomCollectionsTest.kt index 838905fe6..eb8f82676 100644 --- a/app/src/test/kotlin/com/arflix/tv/data/repository/CustomCollectionsTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/CustomCollectionsTest.kt @@ -127,6 +127,12 @@ class CustomCollectionsTest { buildCatalogRequestUrls("https://addon", "movie", "top", 20, null, source.addonGenre)) } + @Test + fun `addon-defined catalog type with a space is percent-encoded in the path`() { + assertEquals(listOf("https://addon/catalog/Live%20Docs/kids.json"), + buildCatalogRequestUrls("https://addon", "Live Docs", "kids", 0, null)) + } + @Test fun `person and director sources use credits rather than unsupported TV discovery filters`() { val document = """{"title":"People","folders":[{"title":"Director","sources":[{"provider":"tmdb","tmdbSourceType":"DIRECTOR","tmdbId":123,"mediaType":"TV"}]}]}""" diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/NativeAddonTypeStreamTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/NativeAddonTypeStreamTest.kt new file mode 100644 index 000000000..d69af1734 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/NativeAddonTypeStreamTest.kt @@ -0,0 +1,111 @@ +package com.arflix.tv.data.repository + +import android.app.Application +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import com.arflix.tv.data.api.* +import com.arflix.tv.data.model.* +import io.mockk.* +import kotlinx.coroutines.* +import com.arflix.tv.util.settingsDataStore +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.ConscryptMode + +/** Native items of addons that declare only their own types ("tv", "Podcasts"), no movie/series. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class, manifest = Config.NONE) +@ConscryptMode(ConscryptMode.Mode.OFF) +class NativeAddonTypeStreamTest { + // Robolectric gives every test a new data dir, but the app's DataStores are process-wide; + // an in-memory store keeps each test independent (as in StreamRepositoryAddonMutationTest). + @Before fun inMemoryDataStores() { + val state = MutableStateFlow(emptyPreferences()) + val store = object : DataStore { + override val data = state + override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences = + transform(state.value).also { state.value = it } + } + mockkStatic("com.arflix.tv.data.repository.StreamRepositoryKt") + mockkStatic("com.arflix.tv.util.DataStoresKt") + every { any().streamDataStore } returns store + every { any().settingsDataStore } returns store + } + + @After fun restoreDataStores() { + unmockkStatic("com.arflix.tv.data.repository.StreamRepositoryKt") + unmockkStatic("com.arflix.tv.util.DataStoresKt") + } + + private fun repository(api: StreamApi, addon: Addon): StreamRepository { + val integrations = mockk(relaxed = true) + coEvery { integrations.isIntegrationEnabled(any()) } returns true + coEvery { integrations.enabledProviderIds(StreamIntegrationType.STREMIO_ADDONS, any()) } returns setOf("stremio:${addon.id}") + coEvery { integrations.getUnifiedSourceOrderedIdsSync() } returns emptyList() + return StreamRepository( + RuntimeEnvironment.getApplication(), api, + mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), + mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), + integrations + ).also { runBlocking { it.replaceSharedAddonsFromCloud(listOf(addon)) } } + } + + private fun strictAddon(type: String) = Addon( + "example.channels", "Channels", "1", "", true, type = AddonType.COMMUNITY, + url = "https://ch.test/manifest.json", + manifest = AddonManifest( + "example.channels", "Channels", "1", types = listOf(type), + resources = listOf( + AddonResource("meta", listOf(type), listOf("chx_")), + AddonResource("stream", listOf(type), listOf("chx_")) + ) + ) + ) + + @Test fun liveChannelIsAskedByItsOwnTypeFromAnAddonWithoutAMovieType() = runBlocking { + val api = mockk() + val repository = repository(api, strictAddon("tv")) + coEvery { api.getAddonStreams(any()) } returns + StremioStreamResponse(listOf(StremioStream(url = "https://ch.test/live.m3u8"))) + + val result = repository.resolveMovieStreams("chx_live_1", forceRefresh = true, nativeType = "tv") + + assertFalse(result.streams.isEmpty()) + coVerify(exactly = 1) { api.getAddonStreams("https://ch.test/stream/tv/chx_live_1.json") } + coVerify(exactly = 0) { api.getAddonStreams(match { it.contains("/stream/movie/") }) } + } + + @Test fun customTypeEpisodeIsFoundAndAskedByItsOwnType() = runBlocking { + val api = mockk() + val repository = repository(api, strictAddon("Podcasts")) + coEvery { api.getAddonMeta(any()) } returns StremioMetaResponse(StremioMetaPreview( + id = "chx_pod_1", videos = listOf(StremioMetaVideo(id = "chx_pod_1:1:1", season = 1, episode = 1)))) + coEvery { api.getAddonStreams(any()) } returns + StremioStreamResponse(listOf(StremioStream(url = "https://ch.test/ep1.mp3"))) + + val result = repository.resolveEpisodeStreams("chx_pod_1", 1, 1, forceRefresh = true, nativeType = "Podcasts") + + assertFalse(result.streams.isEmpty()) + coVerify { api.getAddonMeta("https://ch.test/meta/Podcasts/chx_pod_1.json") } + coVerify(exactly = 1) { api.getAddonStreams("https://ch.test/stream/Podcasts/chx_pod_1%3A1%3A1.json") } + } + + @Test fun metaUrlsEncodeSpacesInTheTypeLikeCatalogUrls() = runBlocking { + val api = mockk() + val repository = repository(api, strictAddon("Live Docs")) + coEvery { api.getAddonMeta(any()) } returns StremioMetaResponse(StremioMetaPreview(id = "chx_doc_1", name = "Doc")) + + assertNotNull(repository.getAddonMeta("example.channels", "Live Docs", "chx_doc_1")) + + coVerify(exactly = 1) { api.getAddonMeta("https://ch.test/meta/Live%20Docs/chx_doc_1.json") } + } +} diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/details/DetailsStreamCompletionTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/details/DetailsStreamCompletionTest.kt index b48061ccb..33bdd9be9 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/details/DetailsStreamCompletionTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/details/DetailsStreamCompletionTest.kt @@ -62,7 +62,7 @@ class DetailsStreamCompletionTest { gates[2].await() emptyList() } - every { streams.resolveMovieStreamsProgressive(any(), any(), any(), any(), any()) } returns flow { + every { streams.resolveMovieStreamsProgressive(any(), any(), any(), any(), any(), any()) } returns flow { gates[0].await() emit(ProgressiveStreamResult(emptyList(), completedAddons = 0, totalAddons = 0, isFinal = true)) } diff --git a/web/components/player/PlayerOverlay.tsx b/web/components/player/PlayerOverlay.tsx index bb78df714..60300a9b0 100644 --- a/web/components/player/PlayerOverlay.tsx +++ b/web/components/player/PlayerOverlay.tsx @@ -43,6 +43,7 @@ import { bufferedAhead, bufferedEndAt, classifyMediaError, + isAudioOnlySource, monitorPlaybackStall, monitorVideoFrames, playbackFailureKind, @@ -521,7 +522,7 @@ function VideoPlayer({ // Recover only when the decoder delivers no video frames. Pixel brightness // cannot distinguish unsupported video from a legitimate dark scene. useEffect(() => { - if (!booted || liveTv) return undefined; + if (!booted || liveTv || isAudioOnlySource(stream.url)) return undefined; const video = videoRef.current; if (!video) return undefined; return monitorVideoFrames(video, () => { diff --git a/web/lib/addonNative.ts b/web/lib/addonNative.ts index 36139140d..e7316259f 100644 --- a/web/lib/addonNative.ts +++ b/web/lib/addonNative.ts @@ -14,7 +14,9 @@ import type { EpisodeInfo, InstalledAddon, MediaItem, MediaType } from "./types" * watch-history entry written by either app points at the same show. */ -type Entry = { addonId: string; manifestUrl: string; metaId: string; type: "movie" | "series"; title: string; poster?: string }; +// `type` is how the item is shown; `addonType` is the addon's own type ("Podcasts", "tv") that +// its /meta and /stream answer to, when it differs. +type Entry = { addonId: string; manifestUrl: string; metaId: string; type: "movie" | "series"; addonType?: string; title: string; poster?: string }; export type AddonMetaPreview = { id?: string; name?: string; title?: string; poster?: string; background?: string; description?: string }; @@ -79,10 +81,13 @@ function persistSoon() { * True when [addon] serves its own metadata for [contentId]: its manifest offers a `meta` * resource whose id prefixes cover that id. */ -export function addonServesOwnMeta(addon: InstalledAddon, type: "movie" | "series", contentId: string) { +export function addonServesOwnMeta(addon: InstalledAddon, type: string, contentId: string) { const id = contentId.trim().toLowerCase(); if (!id || addon.enabled === false || NON_NATIVE_ID_FAMILIES.some((family) => id.startsWith(family))) return false; - const aliases = type === "movie" ? ["movie", "film"] : ["series", "tv", "show"]; + const lower = type.trim().toLowerCase(); + const aliases = lower === "movie" || lower === "film" ? ["movie", "film"] + : lower === "series" || lower === "tv" || lower === "show" ? ["series", "tv", "show"] + : [lower]; return (addon.resources ?? []).some((resource) => { const name = typeof resource === "string" ? resource : resource.name; if (name?.toLowerCase() !== "meta") return false; @@ -94,6 +99,15 @@ export function addonServesOwnMeta(addon: InstalledAddon, type: "movie" | "serie }); } +/** + * 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. + */ +export function findAddonServingOwnMeta(addons: InstalledAddon[], type: string, contentId: string, excludeAddonId?: string) { + return addons.find((addon) => addon.id !== excludeAddonId && addonServesOwnMeta(addon, type, contentId)) ?? null; +} + function cardFor(id: number, entry: Entry): MediaItem { return { id, @@ -111,7 +125,7 @@ function cardFor(id: number, entry: Entry): MediaItem { } /** Builds (and remembers) the card for a catalog entry, or null when it has no id or title. */ -export function registerNativeItem(addon: InstalledAddon, meta: AddonMetaPreview, mediaType: MediaType): MediaItem | null { +export function registerNativeItem(addon: InstalledAddon, meta: AddonMetaPreview, mediaType: MediaType, addonType?: string): MediaItem | null { const metaId = meta.id?.trim(); const title = (meta.name ?? meta.title)?.trim(); if (!metaId || !title) return null; @@ -121,6 +135,7 @@ export function registerNativeItem(addon: InstalledAddon, meta: AddonMetaPreview manifestUrl: addon.manifestUrl, metaId, type: mediaType === "tv" ? "series" : "movie", + ...(addonType && addonType !== (mediaType === "tv" ? "series" : "movie") ? { addonType } : {}), title, ...(meta.poster ? { poster: meta.poster } : {}) }; @@ -144,7 +159,7 @@ function manifestBase(manifestUrl: string) { async function fetchMeta(entry: Entry) { const { base, query } = manifestBase(entry.manifestUrl); - const url = `${base}/meta/${entry.type}/${encodeURIComponent(entry.metaId)}.json${query ? `?${query}` : ""}`; + const url = `${base}/meta/${encodeURIComponent(entry.addonType || entry.type)}/${encodeURIComponent(entry.metaId)}.json${query ? `?${query}` : ""}`; const payload = await jsonRequest<{ meta?: AddonMeta }>(url) .catch(() => jsonRequest<{ meta?: AddonMeta }>(proxiedUrl(url))); return payload?.meta ?? null; @@ -219,7 +234,8 @@ export async function getNativeSeasonEpisodes(id: number, season: number): Promi export async function nativeStreamTarget(item: MediaItem, season?: number, episode?: number) { const entry = entries().get(item.id); if (!entry) return null; - const target = { metaId: entry.metaId, type: entry.type }; + // requestType: what the owning addon's /stream answers to ("tv" for a live channel). + const target = { metaId: entry.metaId, type: entry.type, requestType: entry.addonType || entry.type }; if (entry.type === "movie") return { ...target, ids: [entry.metaId] }; if (!season || !episode) return { ...target, ids: [] as string[] }; const meta = await loadMeta(item.id, entry); diff --git a/web/lib/addons.ts b/web/lib/addons.ts index 8f092b9cd..1d78e4670 100644 --- a/web/lib/addons.ts +++ b/web/lib/addons.ts @@ -264,8 +264,8 @@ export async function getStreamsProgressive( const native = await nativeStreamTarget(item, season, episode); if (native) { if (!native.ids.length) return []; - const owners = addons.filter((addon) => addonServesOwnMeta(addon, native.type, native.metaId)); - return getBrowserStreamsProgressive(owners, item, season, episode, onUpdate, native.ids); + const owners = addons.filter((addon) => addonServesOwnMeta(addon, native.requestType, native.metaId)); + return getBrowserStreamsProgressive(owners, item, season, episode, onUpdate, native.ids, native.requestType); } if (hasResolverConfig()) { const browserPromise = getBrowserStreamsProgressive(addons, item, season, episode, update) @@ -294,7 +294,9 @@ async function getBrowserStreamsProgressive( season?: number, episode?: number, onUpdate?: (streams: StreamSource[], batch: StreamSource[]) => void, - nativeIds?: string[] + nativeIds?: string[], + // A native item's own type ("tv", "Podcasts"); strict addons route /stream by it. + nativeType?: string ) { const type = item.mediaType === "tv" ? "series" : "movie"; const ids = nativeIds ?? streamIds(item, season, episode); @@ -322,7 +324,7 @@ async function getBrowserStreamsProgressive( .catch(() => [] as SubtitleTrack[]); await Promise.all(enabled.map(async (addon) => { - const batch = await queryAddonStreams(addon, type, ids); + const batch = await queryAddonStreams(addon, type, ids, nativeType); if (!batch.length) return; const withSubtitles = batch.map((stream) => attachSubtitles(stream, sharedSubtitles)); aggregate = sortStreams(dedupeStreams([...aggregate, ...withSubtitles]) @@ -334,12 +336,12 @@ async function getBrowserStreamsProgressive( return aggregate; } -async function queryAddonStreams(addon: InstalledAddon, type: "movie" | "series", ids: string[]) { +async function queryAddonStreams(addon: InstalledAddon, type: "movie" | "series", ids: string[], preferredType?: string) { const { base, query } = addonBaseUrl(manifestUrlFor(addon)); for (const id of ids) { if (!addonSupportsId(addon, id)) continue; - for (const requestType of streamRequestTypes(addon, type)) { - const url = `${base}/stream/${requestType}/${encodeURIComponent(id)}.json${query ? `?${query}` : ""}`; + for (const requestType of streamRequestTypes(addon, type, preferredType)) { + const url = `${base}/stream/${encodeURIComponent(requestType)}/${encodeURIComponent(id)}.json${query ? `?${query}` : ""}`; try { const payload = await addonJsonRequest<{ streams?: RawStream[] }>(url); const streams = (payload.streams ?? []).map((stream) => normalizeStream(stream, addon)) @@ -431,9 +433,11 @@ function streamIds(item: MediaItem, season?: number, episode?: number) { return Array.from(new Set(ids)); } -function streamRequestTypes(addon: InstalledAddon, requestedType: "movie" | "series") { +function streamRequestTypes(addon: InstalledAddon, requestedType: "movie" | "series", preferredType?: string) { const aliases = requestedType === "series" ? ["series", "tv", "show"] : ["movie", "film"]; - return aliases.filter((type) => addonSupportsType(addon, type)); + // A native item is asked for by its own type first: a live channel ("tv") is shown as a + // movie, but its addon may declare no movie type at all. + return [...new Set([...(preferredType ? [preferredType] : []), ...aliases.filter((type) => addonSupportsType(addon, type))])]; } function addonSupportsType(addon: InstalledAddon, requestedType: string) { diff --git a/web/lib/playerRecovery.ts b/web/lib/playerRecovery.ts index dbbbc9fbe..6767de635 100644 --- a/web/lib/playerRecovery.ts +++ b/web/lib/playerRecovery.ts @@ -170,6 +170,19 @@ export function isStalled(opts: { } /** Detect audio advancing without video, including streams with known dimensions. */ +/** + * True for an audio file (a podcast episode's .mp3): it never presents video frames, so the + * frame watchdog would report a playing episode as an undecodable video. + */ +export function isAudioOnlySource(url: string | undefined | null) { + if (!url) return false; + try { + return /\.(mp3|m4a|aac|oga|ogg|opus|wav|flac)$/i.test(new URL(url, "http://local").pathname); + } catch { + return false; + } +} + export function monitorVideoFrames(video: HTMLVideoElement, onMissing: () => void): () => void { let stopped = false; let frame: number | undefined; diff --git a/web/lib/tmdb.ts b/web/lib/tmdb.ts index c4f511ce9..8820dac62 100644 --- a/web/lib/tmdb.ts +++ b/web/lib/tmdb.ts @@ -1,4 +1,4 @@ -import { addonServesOwnMeta, getNativeDetails, getNativeSeasonEpisodes, isAddonNative, registerNativeItem } from "./addonNative"; +import { addonServesOwnMeta, findAddonServingOwnMeta, getNativeDetails, getNativeSeasonEpisodes, isAddonNative, registerNativeItem } from "./addonNative"; import { config } from "./config"; import { apiProxiedUrl, jsonRequest, proxiedUrl } from "./http"; import { fetchAniZipMappingByTmdbId } from "./metadata/anizip"; @@ -527,8 +527,7 @@ export async function loadCollectionSource( }, language).then((items) => items.map((item) => mapTmdbItem(item, mediaType === "tv" ? "tv" : "movie"))); } if (kind === "ADDON_CATALOG") { - const installed = addons.find(a => a.enabled !== false && a.id === source.addonId) ?? - addons.find(a => a.enabled !== false && a.catalogs?.some(c => c.id === source.addonCatalogId && c.type === source.addonCatalogType)); + const installed = findCollectionAddon(addons, source); if (!installed) throw new Error("The add-on for this collection is not installed or enabled."); return loadAddonCatalog({ id: `collection-addon-${source.addonId}-${source.addonCatalogId}`, @@ -554,6 +553,22 @@ export async function loadCollectionSource( return []; } +/** + * The installed addon a collection's addon source loads from. Collections name the addon by + * its manifest id ("aio-metadata"); Android-installed entries carry a per-URL suffix. Like + * Nuvio, the named addon is asked even for a catalog its manifest doesn't list (AIOMetadata + * serves its streaming catalogs that way). + */ +function findCollectionAddon(addons: InstalledAddon[], source: CollectionSourceConfig) { + const enabled = addons.filter(a => a.enabled !== false); + const wanted = String(source.addonId ?? "").trim(); + const isNamed = (a: InstalledAddon) => Boolean(wanted) && + (a.id === wanted || (a as { manifest?: { id?: string } }).manifest?.id === wanted); + const declares = (a: InstalledAddon) => (a.catalogs ?? []).some(c => c.id === source.addonCatalogId && + String(c.type).toLowerCase() === String(source.addonCatalogType ?? "").toLowerCase()); + return enabled.find(a => isNamed(a) && declares(a)) ?? enabled.find(declares) ?? enabled.find(isNamed); +} + function sourceMediaType(source: CollectionSourceConfig): MediaType | "all" { const raw = String(source.mediaType ?? "").trim().toLowerCase(); if (raw === "tv" || raw === "series") return "tv"; @@ -673,24 +688,39 @@ async function loadAddonCatalog(catalog: CatalogConfig, addons: InstalledAddon[] candidate.manifestUrl === catalog.sourceUrl ); const manifestUrl = addon?.manifestUrl || catalog.sourceUrl; - const catalogType = catalog.addonCatalogType || (catalog.mediaType === "tv" ? "series" : catalog.mediaType === "movie" ? "movie" : "movie"); + const requestedType = catalog.addonCatalogType || (catalog.mediaType === "tv" ? "series" : catalog.mediaType === "movie" ? "movie" : "movie"); const catalogId = catalog.addonCatalogId || catalog.sourceRef || catalog.id; + // Addons route by the manifest's exact spelling ("Live Docs"); collections may lower-case it. + const catalogType = (addon?.catalogs ?? []).find(c => c.id === catalogId && + String(c.type).toLowerCase() === requestedType.toLowerCase())?.type || requestedType; if (!manifestUrl || !catalogId) return []; const base = manifestUrl.replace(/\/manifest\.json$/, "").replace(/\/+$/, ""); const extra = catalog.addonGenre ? `/genre=${encodeURIComponent(catalog.addonGenre)}` : ""; const url = proxiedUrl(`${base}/catalog/${encodeURIComponent(catalogType)}/${encodeURIComponent(catalogId)}${extra}.json`); const payload = await jsonRequest<{ metas?: StremioMeta[] }>(url); const metas = payload.metas ?? []; - const hydrated = await Promise.all(metas.map((meta) => hydrateAddonMeta(meta, catalog.mediaType, language, addon).catch(() => null))); + const hydrated = await Promise.all(metas.map((meta) => hydrateAddonMeta(meta, catalog.mediaType, language, addon, addons, catalogType).catch(() => null))); return hydrated.filter((item): item is MediaItem => Boolean(item)); } -async function hydrateAddonMeta(meta: StremioMeta, preferred: CatalogConfig["mediaType"], language: string, addon?: InstalledAddon): Promise { +async function hydrateAddonMeta( + meta: StremioMeta, + preferred: CatalogConfig["mediaType"], + language: string, + addon?: InstalledAddon, + addons: InstalledAddon[] = [], + catalogType = "" +): Promise { const tmdbId = numberValue(meta.tmdb_id); const mediaType: MediaType = String(meta.type ?? preferred ?? "").toLowerCase().includes("series") || preferred === "tv" ? "tv" : "movie"; - // Items the addon describes itself open from its own /meta instead of a TMDB guess. - if (addon && !tmdbId && !meta.imdb_id && meta.id && addonServesOwnMeta(addon, mediaType === "tv" ? "series" : "movie", meta.id)) { - const native = registerNativeItem(addon, meta, mediaType); + // Items the addon describes itself open from its own /meta instead of a TMDB guess. A + // metadata addon (AIOMetadata) may re-list another addon's catalog; that addon owns the ids. + if (!tmdbId && !meta.imdb_id && meta.id) { + const addonType = String(meta.type ?? "").trim() || catalogType || (mediaType === "tv" ? "series" : "movie"); + const owner = addon && addonServesOwnMeta(addon, addonType, meta.id) + ? addon + : findAddonServingOwnMeta(addons, addonType, meta.id, addon?.id); + const native = owner ? registerNativeItem(owner, meta, nativeMediaType(addonType), addonType) : null; if (native) return native; } if (tmdbId) { @@ -717,6 +747,16 @@ async function hydrateAddonMeta(meta: StremioMeta, preferred: CatalogConfig["med }; } +/** + * How a native item is shown. Stremio's "tv" type is a live channel: one stream, no episodes, + * so it opens like a movie and plays straight away. Other addon-defined types (podcasts, + * debrid libraries) are containers of videos, which the series page shows. + */ +function nativeMediaType(addonType: string): MediaType { + const type = addonType.trim().toLowerCase(); + return type === "tv" || type === "movie" || type === "film" ? "movie" : "tv"; +} + function stableStringId(value: string) { let hash = 0; for (let index = 0; index < value.length; index += 1) { diff --git a/web/tests/addon-native.test.cjs b/web/tests/addon-native.test.cjs index 8e02a8cc9..8160abf03 100644 --- a/web/tests/addon-native.test.cjs +++ b/web/tests/addon-native.test.cjs @@ -121,3 +121,75 @@ test('native episodes request streams from the owning addon by the video id', as assert.equal(calls.some(url => url.includes('opensubtitles')), false); assert.deepEqual(plain(await addons.getStreamsProgressive([owner], card, 9, 9)), []); }); + +// A broadcaster addon with addon-defined types and no movie type (live channels are "tv"). +const broadcasterAddon = { + id: 'example.channels', name: 'Channels', version: '1.0.0', manifestUrl: 'https://ch.example.test/manifest.json', + types: ['series', 'tv', 'Podcasts'], idPrefixes: ['chx_', 'tt'], + catalogs: [{ type: 'Podcasts', id: 'pods', name: 'Pods' }, { type: 'tv', id: 'live', name: 'Live' }], + resources: ['catalog', 'meta', 'stream'] +}; +// A metadata addon re-listing the broadcaster's catalog under its own type spelling. +const metadataAddon = { + id: 'aio-metadata_0123456789ab', name: 'Meta', version: '1.0.0', manifestUrl: 'https://meta.example.test/u/manifest.json', + manifest: { id: 'aio-metadata' }, types: ['movie', 'series'], idPrefixes: ['tmdb:', 'tt'], + catalogs: [{ type: 'VOD Example', id: 'relisted.kids', name: 'Kids' }], resources: ['catalog', 'meta'] +}; + +function catalogApi(respond) { + const calls = []; + const http = { proxiedUrl: url => url, apiProxiedUrl: url => url, jsonRequest: async url => { calls.push(url); return respond(url) ?? {}; } }; + const api = load('lib/tmdb.ts', { + './config': { config: {} }, './storage': storage(), './http': http, + './metadata/anizip': {}, './metadata/dispatcher': {}, './mediaImages': { tmdbImageUrl: () => '' } + }, { window: { location: { origin: 'https://web.invalid' } } }); + return { api, calls }; +} + +test('a re-listed catalog item belongs to the installed addon that serves its meta', async () => { + const { addons } = fixture(); + const meta = addons.normalizeAddon(metadataAddon); + const owner = addons.normalizeAddon(broadcasterAddon); + const { api, calls } = catalogApi(url => url.includes('/catalog/') ? { metas: [{ id: 'chx_kids_1', type: 'series', name: 'Kids show' }] } : null); + // The collection names the addon by manifest id and lower-cases the type. + const items = await api.loadCollectionSource({ kind: 'ADDON_CATALOG', mediaType: 'tv', addonId: 'aio-metadata', + addonCatalogType: 'vod example', addonCatalogId: 'relisted.kids' }, 'he', [meta, owner]); + assert.equal(items.length, 1); + assert.ok(items[0].id < 0); + assert.equal(items[0].addonNativeAddonId, 'example.channels'); + assert.deepEqual(plain(calls), ['https://meta.example.test/u/catalog/VOD%20Example/relisted.kids.json']); +}); + +test('the named collection addon is asked for a catalog its manifest does not list', async () => { + const { addons } = fixture(); + const { api, calls } = catalogApi(() => ({ metas: [] })); + await api.loadCollectionSource({ kind: 'ADDON_CATALOG', mediaType: 'movie', addonId: 'aio-metadata', + addonCatalogType: 'movie', addonCatalogId: 'streaming.nfx' }, 'he', [addons.normalizeAddon(metadataAddon)]); + assert.deepEqual(plain(calls), ['https://meta.example.test/u/catalog/movie/streaming.nfx.json']); +}); + +test('podcasts keep their own type and live channels play by the "tv" type', async () => { + const calls = []; + const http = { proxiedUrl: url => url, jsonRequest: async url => { + calls.push(url); + if (url.includes('/meta/Podcasts/')) return { meta: { id: 'chx_pod_1', name: 'Pod', videos: [{ id: 'chx_pod_1:1:1', season: 1, episode: 1 }] } }; + if (url.includes('/stream/')) return { streams: [{ name: 'Live', url: 'https://ch.example.test/live.m3u8' }] }; + return {}; + } }; + const store = storage(); + const native = load('lib/addonNative.ts', { './http': http, './storage': store }); + const addons = load('lib/addons.ts', { + './http': http, './addonNative': native, './config': { hasResolverConfig: () => false }, + './resolver': { getResolverStreamsProgressive: async () => [] }, './storage': store, + './streamCompatibility': { isBrowserPlayableStream: () => true, isIosPlayableStream: () => true }, + './addonStreamInfo': { isInformationalAddonStream: () => false } + }); + const owner = addons.normalizeAddon(broadcasterAddon); + const pod = native.registerNativeItem(owner, { id: 'chx_pod_1', name: 'Pod' }, 'tv', 'Podcasts'); + assert.equal((await native.getNativeSeasonEpisodes(pod.id, 1)).length, 1); + assert.ok(calls.some(url => url.endsWith('/meta/Podcasts/chx_pod_1.json'))); + const live = native.registerNativeItem(owner, { id: 'chx_live_1', name: 'Live' }, 'movie', 'tv'); + assert.equal(live.mediaType, 'movie'); + assert.equal((await addons.getStreamsProgressive([owner], live)).length, 1); + assert.ok(calls.some(url => url.endsWith('/stream/tv/chx_live_1.json')), 'a live channel is asked for as "tv"'); +}); diff --git a/web/tests/browser-playback-integration.test.cjs b/web/tests/browser-playback-integration.test.cjs index 5e5c0fc52..ed81e9757 100644 --- a/web/tests/browser-playback-integration.test.cjs +++ b/web/tests/browser-playback-integration.test.cjs @@ -1017,6 +1017,20 @@ test('Dolby Vision conversion keeps the manually selected file and propagates fa assert.equal(requested, provider); }); +test('an audio file is never sent to the missing-video recovery', () => { + let monitored = false; + const effect = extracted('components/player/PlayerOverlay.tsx', (node, source) => + ts.isCallExpression(node) && node.expression.getText(source) === 'useEffect' + && ts.isArrowFunction(node.arguments[0]) && node.arguments[0].getText(source).includes('monitorVideoFrames(') + ? node.arguments[0] : undefined, { + booted: true, liveTv: false, videoRef: { current: {} }, stream: { ...file(), url: 'https://cdn.test/episode.mp3' }, + isAudioOnlySource: (url) => url.endsWith('.mp3'), + monitorVideoFrames: () => { monitored = true; return () => {}; } + }); + assert.equal(effect(), undefined); + assert.equal(monitored, false); +}); + for (const converted of [false, true]) test(`missing video ${converted ? 'after conversion stops with an error' : 'requests conversion of the same file'}`, () => { const stream = { ...file(), transcoded: converted }; const video = { pause: () => { paused = true; } }; @@ -1028,7 +1042,7 @@ for (const converted of [false, true]) test(`missing video ${converted ? 'after ts.isCallExpression(node) && node.expression.getText(source) === 'useEffect' && ts.isArrowFunction(node.arguments[0]) && node.arguments[0].getText(source).includes('monitorVideoFrames(') ? node.arguments[0] : undefined, { - booted: true, liveTv: false, videoRef: { current: video }, stream, + booted: true, liveTv: false, videoRef: { current: video }, stream, isAudioOnlySource: () => false, monitorVideoFrames: (_, callback) => { missing = callback; return () => {}; }, canProviderTranscode: () => true, recordBrowserPlaybackFailure: (...args) => failures.push(args), diff --git a/web/tests/playback-reliability.test.cjs b/web/tests/playback-reliability.test.cjs index f0d6d5e9e..8f307e529 100644 --- a/web/tests/playback-reliability.test.cjs +++ b/web/tests/playback-reliability.test.cjs @@ -119,3 +119,12 @@ test('failure diagnostics are fixed categories even when exception text contains assert.equal(playbackFailureKind({ message: 'No compatible decoder' }), 'format'); assert.equal(playbackFailureKind(), 'unknown'); }); + +test('audio files are not judged by the video-frame watchdog', () => { + const { isAudioOnlySource } = load('lib/playerRecovery.ts'); + assert.equal(isAudioOnlySource('https://traffic.omny.fm/d/clips/a/b/audio.mp3'), true); + assert.equal(isAudioOnlySource('https://cdn.example/ep.M4A?token=1'), true); + assert.equal(isAudioOnlySource('https://cdn.example/master.m3u8'), false); + assert.equal(isAudioOnlySource('https://cdn.example/movie.mkv'), false); + assert.equal(isAudioOnlySource(undefined), false); +});