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
26 changes: 23 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,22 @@ Rules:

- **Sources.** `ExtensionProviderRegistry` turns installed catalog entries into providers.
`StreamingRepositoryImpl` resolves them in parallel, ranks with `ServerRanker`, and runs
`fallback` providers only when direct ones return nothing (or on "Find more" / failover).
`fallback` providers only when direct ones return nothing, or have found nothing after 8 s (or on
"Find more" / failover). Each provider gets 25 s, enforced by awaiting rather than by cancelling
(OkHttp's blocking `execute()` ignores coroutine cancellation); the IMDb id lookup gets 8 s.
Cancelled resolutions record nothing; five failures in a row skip a provider for two minutes.
Users rank installed sources (Marketplace > Installed, `ExtensionStateStore.sourceOrder`); a
provider's priority is its position. Search mode (`SourceSearchMode` in `AppSettings`): all at
once (rank by quality, order breaks ties) or in order (one at a time, stop at the first with
streams; ranking puts the order before quality; "Find more" searches everything).
Engines: `vidking-direct` (`VidkingDirectApi`, encrypted payload, prefers the master playlist so
quality switches in-player), `web-embed` and `vidking-webview` (`WebEmbedResolver`: first a native
pass through `HosterExtractors` (Filemoon, StreamWish/VidHide, Voe, Mp4Upload, Vidmoly, ok.ru) when
the embed is or frames a known hoster, else a hidden WebView that records media requests).
Anime engines (`data/streaming/anime`): `anikoto`, `reanime`, `animepahe`, `fouranimo`, `animegg`.
`stremio` (`StremioAddonProvider`): any Stremio add-on by base URL; direct-`url` streams only
(no torrents/debrid). A pasted Stremio `manifest.json` becomes a one-add-on repository
(`ExtensionIndexParser`), installed right away when it can play.
`VideoServer` can carry a MIME hint (HLS for URLs without `.m3u8`) and the source's own intro/outro
times, which the player prefers over AniSkip. `AnimeEpisodeMapper`
maps TMDB season/episode to an AniList episode (ani.zip + AniList GraphQL, both keyless);
Expand All @@ -94,11 +104,21 @@ Rules:
and `SubSourceRepository` (keyless, mirrors subsource.net's own API: IMDb search -> list ->
download token -> zip), plus any the stream carries. `SubtitleFetcher` downloads and unwraps
them (gzip, zip, charset) for both the player and the cast proxy; promo cues are stripped.
`SavedSubtitleRepository` keeps subtitles on the device (`filesDir/subtitles/<download id>/`, files
plus `index.json`), saved from Downloads (any language, or an imported file). Downloads also
record the stream's own subtitles there (`stream.json`) so the sheet can offer them; older
downloads re-resolve the stream sources to find them. `DownloadSubtitleSaver` saves every
subtitle in the languages chosen in Settings (default English) when a download starts: the
stream's own, plus OpenSubtitles/SubSource unless turned off. The player lists
them first for that title/episode, online or offline; `SubtitleFetcher` reads `file:` URLs only
from that folder because the cast proxy fetches whatever URL it is given. Deleting a download
deletes its saved subtitles.
- **Network.** `AppDns` (DNS-over-HTTPS, default AdGuard, chosen in Settings) backs every OkHttp
client and Coil's image loader (`OpenStreamApp`), because some ISPs block TMDB at the DNS level.
Media3 playback/downloads and WebView sources still use the system resolver.
- **Settings.** `AppSettingsStore` (SharedPreferences) holds theme, dynamic color, DNS and
Wi-Fi-only downloads; `MainActivity` applies the theme.
- **Settings.** `AppSettingsStore` (SharedPreferences) holds theme, dynamic color, DNS,
Wi-Fi-only downloads and the subtitle languages saved with downloads; `MainActivity` applies the
theme.
- **Skip intro.** `SkipTimesRepository`: `AnimeEpisodeMapper` gives the MAL id and the episode within
that entry (AniList title search only as a fallback), AniSkip v2 gives the intro/recap/credits
times (anime only), and the player picks the submission timed on the closest file length. All
Expand Down
105 changes: 105 additions & 0 deletions app/src/main/assets/extensions/official-repo.json
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,111 @@
"priority": 13
}
},
{
"id": "hdhub",
"name": "HdHub",
"description": "Stremio add-on with many direct releases per title, up to 4K, including Hindi and dual-audio files. Large files: best on a fast connection.",
"version": "1.0.7",
"versionCode": 1,
"apiVersion": 1,
"authors": ["Stremio add-on"],
"language": "Multi",
"iconUrl": "https://hdhub.thevolecitor.qzz.io/logo.png",
"tags": ["movies", "series", "stremio", "4k"],
"status": 3,
"updatedAt": "2026-10-02",
"homepage": "https://hdhub.thevolecitor.qzz.io",
"installedByDefault": false,
"engine": {
"type": "stremio",
"endpoint": "https://hdhub.thevolecitor.qzz.io/eyJ0b3Jib3giOiJ1bnNldCIsInF1YWxpdGllcyI6IjIxNjBwLDEwODBwLDcyMHAiLCJzb3J0IjoiZGVzYyJ9",
"priority": 14
}
},
{
"id": "yastream",
"name": "yastream",
"description": "Stremio add-on for Asian dramas, series and movies from several providers.",
"version": "0.11.1",
"versionCode": 1,
"apiVersion": 1,
"authors": ["Stremio add-on"],
"language": "Multi",
"iconUrl": "https://yastream.tamthai.de/img/yas.png",
"tags": ["series", "movies", "stremio", "asian-drama"],
"status": 3,
"updatedAt": "2026-10-02",
"homepage": "https://yastream.tamthai.de",
"installedByDefault": false,
"engine": {
"type": "stremio",
"endpoint": "https://yastream.tamthai.de",
"priority": 15
}
},
{
"id": "zeus",
"name": "Zeus",
"description": "Stremio add-on with Portuguese (Brazil) dubbed and subtitled movies, series and anime.",
"version": "0.39.4",
"versionCode": 1,
"apiVersion": 1,
"authors": ["Stremio add-on"],
"language": "Portuguese",
"iconUrl": "https://i.imgur.com/7Jr9ng6.jpg",
"tags": ["movies", "series", "stremio", "portuguese"],
"status": 3,
"updatedAt": "2026-10-02",
"homepage": "https://398fe185fed6-zeus.baby-beamup.club",
"installedByDefault": false,
"engine": {
"type": "stremio",
"endpoint": "https://398fe185fed6-zeus.baby-beamup.club/v1-p1kv-q3j",
"priority": 16
}
},
{
"id": "froststream",
"name": "FrostStream",
"description": "Stremio add-on with Portuguese (Brazil) dubbed and subtitled movies and series.",
"version": "2.2.7",
"versionCode": 1,
"apiVersion": 1,
"authors": ["Stremio add-on"],
"language": "Portuguese",
"iconUrl": "https://i.imgur.com/yuG0ZIU.jpeg",
"tags": ["movies", "series", "stremio", "portuguese"],
"status": 3,
"updatedAt": "2026-10-02",
"homepage": "https://froststream.cloutteam.com",
"installedByDefault": false,
"engine": {
"type": "stremio",
"endpoint": "https://froststream.cloutteam.com",
"priority": 17
}
},
{
"id": "fenixflix",
"name": "FenixFlix",
"description": "Stremio add-on with Portuguese (Brazil) dubbed and subtitled series, novelas and movies.",
"version": "1.1.9",
"versionCode": 1,
"apiVersion": 1,
"authors": ["Stremio add-on"],
"language": "Portuguese",
"iconUrl": "https://fenixflix.fenixhub.online/logo.png",
"tags": ["series", "movies", "stremio", "portuguese"],
"status": 3,
"updatedAt": "2026-10-02",
"homepage": "https://fenixflix.fenixhub.online",
"installedByDefault": false,
"engine": {
"type": "stremio",
"endpoint": "https://fenixflix.fenixhub.online",
"priority": 18
}
},
{
"id": "jett",
"name": "Jett",
Expand Down
16 changes: 13 additions & 3 deletions app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.ivor.openstream.data.settings.AppSettings
import com.ivor.openstream.data.settings.AppSettingsStore
import com.ivor.openstream.data.settings.DnsProvider
import com.ivor.openstream.data.settings.PipAction
import com.ivor.openstream.data.settings.SourceSearchMode
import com.ivor.openstream.data.settings.ThemeMode
import kotlinx.coroutines.flow.first
import kotlinx.serialization.Serializable
Expand Down Expand Up @@ -266,7 +267,10 @@ private data class BackupSettings(
val autoPlayNext: Boolean? = null,
val downloadMaxHeight: Int? = null,
val pipLeftAction: String? = null,
val pipRightAction: String? = null
val pipRightAction: String? = null,
val subtitleDownloadLanguages: List<String>? = null,
val subtitleDownloadFromSites: Boolean? = null,
val sourceSearchMode: String? = null
) {
fun applyTo(current: AppSettings) = current.copy(
themeMode = ThemeMode.entries.firstOrNull { it.name == themeMode } ?: current.themeMode,
Expand All @@ -278,7 +282,10 @@ private data class BackupSettings(
autoPlayNext = autoPlayNext ?: current.autoPlayNext,
downloadMaxHeight = downloadMaxHeight?.takeIf { it in AppSettings.DOWNLOAD_HEIGHTS } ?: current.downloadMaxHeight,
pipLeftAction = PipAction.entries.firstOrNull { it.name == pipLeftAction } ?: current.pipLeftAction,
pipRightAction = PipAction.entries.firstOrNull { it.name == pipRightAction } ?: current.pipRightAction
pipRightAction = PipAction.entries.firstOrNull { it.name == pipRightAction } ?: current.pipRightAction,
subtitleDownloadLanguages = subtitleDownloadLanguages?.filter { it.isNotBlank() }?.distinct() ?: current.subtitleDownloadLanguages,
subtitleDownloadFromSites = subtitleDownloadFromSites ?: current.subtitleDownloadFromSites,
sourceSearchMode = SourceSearchMode.entries.firstOrNull { it.name == sourceSearchMode } ?: current.sourceSearchMode
)
}

Expand All @@ -292,5 +299,8 @@ private fun AppSettings.toBackup() = BackupSettings(
autoPlayNext = autoPlayNext,
downloadMaxHeight = downloadMaxHeight,
pipLeftAction = pipLeftAction.name,
pipRightAction = pipRightAction.name
pipRightAction = pipRightAction.name,
subtitleDownloadLanguages = subtitleDownloadLanguages,
subtitleDownloadFromSites = subtitleDownloadFromSites,
sourceSearchMode = sourceSearchMode.name
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import com.ivor.openstream.domain.model.ExtensionStatus
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeParseException
Expand All @@ -26,9 +28,13 @@ class ExtensionIndexParser @Inject constructor() {
isLenient = true
}

/** Parses a repository document: an object, or a bare array of entries. */
fun parseRepo(raw: String): ExtensionRepoDto {
/**
* Parses a repository document: an object, a bare array of entries, or a Stremio add-on
* manifest ([sourceUrl] is where it was fetched, which is how the add-on is addressed).
*/
fun parseRepo(raw: String, sourceUrl: String? = null): ExtensionRepoDto {
val root = json.parseToJsonElement(raw)
if (root is JsonObject && isStremioManifest(root)) return stremioRepo(root, sourceUrl)
return when (root) {
is JsonArray -> ExtensionRepoDto(extensions = decodeEntries(root))
is JsonObject -> {
Expand Down Expand Up @@ -105,13 +111,86 @@ class ExtensionIndexParser @Inject constructor() {
}
}

private companion object {
/** After the built-in direct routes, before WebView fallbacks. */
const val STREMIO_PRIORITY = 40
}

private fun decodeEntries(array: JsonArray?): List<ExtensionEntryDto> =
array.orEmpty().mapNotNull { element ->
runCatching {
json.decodeFromJsonElement(ExtensionEntryDto.serializer(), element)
}.getOrNull()
}

private fun isStremioManifest(root: JsonObject): Boolean =
"resources" in root && "id" in root && "extensions" !in root && "extensionLists" !in root

/**
* A Stremio add-on as a one-entry repository, so pasting its link in "Add repository" is all
* it takes. Installed straight away when it can play here: it serves streams and doesn't
* need configuring first (a configured add-on's link already carries its settings).
*/
private fun stremioRepo(manifest: JsonObject, sourceUrl: String?): ExtensionRepoDto {
val addonId = manifest.text("id")?.takeIf { it.isNotBlank() }
?: throw IllegalArgumentException("Stremio manifest has no id")
val name = manifest.text("name")?.trim().orEmpty().ifEmpty { addonId }
val baseUrl = sourceUrl?.substringBefore('?')?.removeSuffix("/manifest.json")?.trimEnd('/').orEmpty()
val resources = (manifest["resources"] as? JsonArray).orEmpty().mapNotNull { resource ->
when (resource) {
is JsonPrimitive -> resource.contentOrNull
is JsonObject -> resource.text("name")
else -> null
}
}
val hints = manifest["behaviorHints"] as? JsonObject
val needsConfiguring = hints?.text("configurationRequired") == "true"
val servesStreams = "stream" in resources
val playable = servesStreams && !needsConfiguring && baseUrl.isNotEmpty()
val types = (manifest["types"] as? JsonArray).orEmpty().mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
val note = when {
!servesStreams -> "This add-on has no streams (only catalogs, metadata or subtitles), so it has nothing to play here."
needsConfiguring -> "Configure this add-on on its own page first, then add the link it gives you."
else -> null
}
val logo = manifest.text("logo")?.takeIf { it.startsWith("https://") }
val entry = ExtensionEntryDto(
id = addonId.lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-').ifEmpty { "addon" },
name = name,
description = listOfNotNull(manifest.text("description")?.trim()?.takeIf { it.isNotEmpty() }, note)
.joinToString("\n\n"),
version = manifest.text("version")?.trim().orEmpty().ifEmpty { "1.0.0" },
versionCode = semverCode(manifest.text("version")),
authors = listOfNotNull(baseUrl.substringAfter("://").substringBefore('/').takeIf { it.isNotEmpty() }),
iconUrl = logo,
tags = (listOf("stremio") + types.map { if (it == "movie") "movies" else it }).distinct(),
status = if (playable) 3 else 0,
homepage = baseUrl.takeIf { it.isNotEmpty() && hints?.text("configurable") == "true" }?.let { "$it/configure" },
installedByDefault = playable,
engine = ExtensionEngineDto(
type = if (playable) "stremio" else "unsupported",
endpoint = baseUrl,
priority = STREMIO_PRIORITY
)
)
return ExtensionRepoDto(
name = "$name (Stremio)",
description = "Stremio add-on",
iconUrl = logo,
website = baseUrl.takeIf { it.isNotEmpty() },
extensions = listOf(entry)
)
}

/** "1.4.12" -> 1004012, so a newer add-on version shows as an update. */
private fun semverCode(version: String?): Int {
val parts = version.orEmpty().split('.', '-', '+').take(3).map { it.toIntOrNull()?.coerceIn(0, 999) ?: 0 }
return (parts.getOrElse(0) { 0 } * 1_000_000 + parts.getOrElse(1) { 0 } * 1_000 + parts.getOrElse(2) { 0 })
.coerceAtLeast(1)
}

private fun JsonObject.text(key: String): String? = (get(key) as? JsonPrimitive)?.contentOrNull

/** Entries are decoded one by one, so keep them out of the strict repository decode. */
private fun stripEntries(root: JsonObject): JsonObject =
JsonObject(root.filterKeys { it != "extensions" })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class ExtensionRepoClient @Inject constructor(
) {

suspend fun fetch(url: String): CachedRepoSnapshot = withContext(Dispatchers.IO) {
val repo = parser.parseRepo(get(url))
val repo = parser.parseRepo(get(url), sourceUrl = url)
val linked = repo.extensionLists
.mapNotNull { RepoUrlNormalizer.normalize(it) }
.flatMap { listUrl ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ class ExtensionRepositoryImpl @Inject constructor(
rebuildManifests()
toRepo(repoId, snapshot)
}
// A pasted Stremio add-on is ready to use without a separate install step.
seedDefaultInstalls()
publish(isSyncing = false)
repo
}
Expand Down Expand Up @@ -190,11 +192,18 @@ class ExtensionRepositoryImpl @Inject constructor(
return updates.size
}

/** Already in source order: [publish] sorts the catalog that way. */
override fun activeExtensions(): List<MarketplaceExtension> {
ensureLoaded()
return _catalog.value.extensions
.filter { it.isActive }
.sortedBy { it.manifest.engine.priority }
return _catalog.value.extensions.filter { it.isActive }
}

override fun setSourceOrder(keys: List<String>) {
ensureLoaded()
// Keep ranks for sources not in this list (uninstalled ones the user may reinstall).
val rest = store.sourceOrder().filterNot { it in keys }
store.setSourceOrder(keys + rest)
publish(isSyncing = _catalog.value.isSyncing)
}

override fun recordOutcome(key: String, success: Boolean) {
Expand Down Expand Up @@ -284,7 +293,14 @@ class ExtensionRepositoryImpl @Inject constructor(
private fun publish(isSyncing: Boolean) {
val installs = store.installs()
val usage = store.usage()
val extensions = manifests.map { manifest ->
val order = store.sourceOrder().withIndex().associate { (index, key) -> key to index }
// The user's ranking first; the rest by the catalog's priority, as before ranking existed.
val ranked = manifests.sortedWith(
compareBy<ExtensionManifest> { order[it.key] ?: Int.MAX_VALUE }
.thenBy { it.engine.priority }
.thenBy { it.name.lowercase() }
)
val extensions = ranked.map { manifest ->
val record = installs[manifest.key]
val stats = usage[manifest.key]
MarketplaceExtension(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ class ExtensionStateStore @Inject constructor(
write(KEY_REPOS, repoSerializer, customRepos().filterNot { it.id == repoId })
}

/** Extension keys in the order the user ranked them; anything missing ranks after these. */
fun sourceOrder(): List<String> = read(KEY_ORDER, seededSerializer, emptyList())

fun setSourceOrder(keys: List<String>) {
write(KEY_ORDER, seededSerializer, keys.distinct())
}

fun lastSyncedAt(): Long = preferences.getLong(KEY_LAST_SYNC, 0L)

fun setLastSyncedAt(timestamp: Long) {
Expand Down Expand Up @@ -148,6 +155,7 @@ class ExtensionStateStore @Inject constructor(
const val KEY_REPOS = "marketplace.repos"
const val KEY_LAST_SYNC = "marketplace.last_sync"
const val KEY_SEEDED = "marketplace.seeded"
const val KEY_ORDER = "marketplace.source_order"
const val MAX_SAMPLES = 40
}
}
Loading
Loading