diff --git a/CLAUDE.md b/CLAUDE.md
index 517282c..457609e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -41,8 +41,9 @@ Single activity (`MainActivity`), Navigation Compose, Hilt everywhere.
```
data/remote TMDB (TmdbApi), GitHub releases, DTOs
-data/local Room: watch later, downloads, watch progress, id mappings
-data/repository Repository implementations (anime, downloads, progress, OpenSubtitles)
+data/local Room: profiles, watch later, custom lists, downloads, watch progress, id mappings
+data/repository Repository implementations (anime, downloads, progress, lists, OpenSubtitles)
+data/cast Cast options, media item converter, LAN proxy the receiver streams through
data/streaming Source resolution, extension -> provider registry, id mapping
data/extensions Extension catalog: repos, cache, parser, ranking, bundled copy
data/service Media3 download service
@@ -55,7 +56,7 @@ Rules:
- Composables render state and forward intent. ViewModels own screen state as `StateFlow`.
Networking and persistence stay in `data/`.
- Routes and arguments live in `presentation/navigation/AppNavigation.kt`.
-- Room schema changes need a real `Migration` in `di/DatabaseModule.kt` (current version 6).
+- Room schema changes need a real `Migration` in `di/DatabaseModule.kt` (current version 8).
`fallbackToDestructiveMigration` is only a safety net; users' downloads and progress live there.
## How the main features work
@@ -64,9 +65,12 @@ Rules:
`StreamingRepositoryImpl` resolves them in parallel, ranks with `ServerRanker`, and runs
`fallback` providers only when direct ones return nothing (or on "Find more" / failover).
Engines: `vidking-direct` (`VidkingDirectApi`, encrypted payload, prefers the master playlist so
- quality switches in-player), `web-embed` and `vidking-webview` (`WebEmbedResolver`, hidden
- WebView that records media requests).
- Anime engines (`data/streaming/anime`): `anikoto`, `reanime`, `animepahe`. `AnimeEpisodeMapper`
+ 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`.
+ `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);
megaplay embeds decrypt with a fixed AES key; `ImagePrefixStrippingDataSource` strips the fake
PNG header some anime CDNs put before TS segments.
@@ -77,17 +81,37 @@ Rules:
to it and never releases it; leaving the player keeps playback going in `MiniPlayer`. The session
also records watch progress (`WatchProgressRepository`) and queues the next episode for
Continue Watching. Debug builds log player events under `EventLogger`.
+- **Casting.** `PlaybackSession` also owns a Media3 `CastPlayer` (Default Media Receiver, options in
+ `CastOptionsProvider`, initialised from `MainActivity`). `activePlayer` is the TV while casting;
+ connecting moves the item there at the phone's position, disconnecting brings it back paused.
+ Everything the receiver loads goes through `CastMediaProxy`, a small HTTP server on the phone:
+ stateless URLs carry the target and headers, HLS playlists are rewritten, segments are read through
+ the download cache and `ImagePrefixStrippingDataSource`, subtitles are served as WebVTT. The
+ phone must stay on the TV's network. `PlayerScreen` swaps `ExoPlayerView` for `CastPlaybackView`.
- **Player UI.** Controls in `PlayerControls`; settings and sources share `PlayerPanelHost`
(bottom sheet inline, in-player side panel in fullscreen so immersive mode survives).
-- **Subtitles.** `OpenSubtitlesRepository` (keyless legacy REST API) plus any the stream carries.
- Files are gzipped; the player decompresses and strips promo cues.
+- **Subtitles.** `CombinedSubtitleRepository`: `OpenSubtitlesRepository` (keyless legacy REST API)
+ 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.
- **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.
-- **Skip intro.** `SkipTimesRepository`: AniList GraphQL finds the MAL id, AniSkip v2 gives the
- intro/recap/credits times (anime only). Both are keyless public APIs with no stability promise.
+- **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
+ keyless public APIs with no stability promise.
+- **Profiles.** `profiles` table (seeded with profile 1); the active id is in `AppSettingsStore`.
+ Watch Later, progress, hidden titles and custom lists carry `profileId`; their DAOs take it and
+ the repositories follow the active id with `flatMapLatest`. Downloads are device-wide.
+ Switching profile stops playback. Kids profiles: `KidsContentFilter` adds TMDB certification
+ filters to discover calls and checks everything else against the US rating (G/PG,
+ TV-Y..TV-PG; unrated is hidden); Home hides Settings and leaving takes a hold on the avatar.
+ `include_adult=false` is added to every TMDB request.
+- **Lists.** Watch Later plus user lists (`CustomListRepository`, `custom_lists` tables); Details has
+ "Add to list" and "Mark all watched", the Saved tab shows the lists.
- **Backup / diagnostics.** `LibraryBackup` (JSON, merge on restore) and `Diagnostics` (crash files
in `filesDir/crashes`, recorder installed in `OpenStreamApp`) back the Settings entries.
- **Deep links.** `MainActivity` is `singleTask`; `DeepLinks` turns TMDB links (VIEW or shared text)
@@ -104,6 +128,8 @@ Rules:
- Several Vidking routes currently 404/500 for most titles; Yoru (`cdn`) is the reliable one.
- AniList (about 90 requests a minute) and AniSkip are unauthenticated and undocumented as a
contract; skip buttons simply don't appear when they fail.
+- Casting relies on the receiver being allowed to load `http://` media from the phone's LAN address
+ (the Default Media Receiver page is HTTPS). Networks with client isolation block it.
- Wyzie subtitles now require an API key and were removed. Don't add features that need users to
supply API keys.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e763521..9329c61 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -152,6 +152,7 @@ dependencies {
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.exoplayer.dash)
implementation(libs.androidx.media3.ui)
+ implementation(libs.androidx.media3.cast)
// Graphics Shapes
implementation(libs.androidx.graphics.shapes)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 481bb43..ad4c6fa 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -18,4 +18,6 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
-#-renamesourcefileattribute SourceFile
\ No newline at end of file
+#-renamesourcefileattribute SourceFile
+# Named only in the manifest (Cast framework options), so R8 can't see it's used.
+-keep class com.ivor.openstream.data.cast.CastOptionsProvider { *; }
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 91601bf..bf5f93c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -68,6 +68,11 @@
+
+
+
(null)
private var deepLinkRequest by mutableStateOf(null)
@@ -50,6 +54,7 @@ class MainActivity : ComponentActivity() {
)
}
+ playbackSession.initCast()
AppShortcut.publish(this)
// Only a fresh launch; a recreated activity already acted on its shortcut.
if (savedInstanceState == null) handleLaunchIntent(intent)
@@ -92,4 +97,16 @@ class MainActivity : ComponentActivity() {
Toast.makeText(this, "OpenStream opens TMDB movie and TV links", Toast.LENGTH_LONG).show()
}
}
+
+ /** While casting, the volume keys change the TV's volume rather than the phone's. */
+ override fun dispatchKeyEvent(event: android.view.KeyEvent): Boolean {
+ val up = when (event.keyCode) {
+ android.view.KeyEvent.KEYCODE_VOLUME_UP -> true
+ android.view.KeyEvent.KEYCODE_VOLUME_DOWN -> false
+ else -> return super.dispatchKeyEvent(event)
+ }
+ if (!playbackSession.castStatus.value.isCasting) return super.dispatchKeyEvent(event)
+ if (event.action == android.view.KeyEvent.ACTION_DOWN) playbackSession.adjustCastVolume(up)
+ return true
+ }
}
diff --git a/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt b/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt
index f808834..f72ae14 100644
--- a/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt
+++ b/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt
@@ -1,14 +1,19 @@
package com.ivor.openstream.data.backup
+import com.ivor.openstream.data.local.dao.CustomListDao
import com.ivor.openstream.data.local.dao.HiddenTitleDao
import com.ivor.openstream.data.local.dao.WatchLaterDao
import com.ivor.openstream.data.local.dao.WatchProgressDao
+import com.ivor.openstream.data.local.entity.CustomListEntity
+import com.ivor.openstream.data.local.entity.CustomListItemEntity
import com.ivor.openstream.data.local.entity.HiddenTitleEntity
import com.ivor.openstream.data.local.entity.WatchLaterEntity
import com.ivor.openstream.data.local.entity.WatchProgressEntity
+import com.ivor.openstream.data.repository.ProfileRepository
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.ThemeMode
import kotlinx.coroutines.flow.first
import kotlinx.serialization.Serializable
@@ -19,20 +24,32 @@ import javax.inject.Inject
import javax.inject.Singleton
/** What a restore added, for the confirmation message. */
-data class RestoreSummary(val watchLater: Int, val progress: Int, val hidden: Int, val settingsRestored: Boolean)
+data class RestoreSummary(
+ val watchLater: Int,
+ val progress: Int,
+ val hidden: Int,
+ val settingsRestored: Boolean,
+ val lists: Int = 0,
+ val profiles: Int = 0
+)
class BackupFormatException(message: String) : Exception(message)
/**
- * The user's library as one JSON file: Watch Later, watch progress (which is also History), hidden
- * titles and app settings. Downloads aren't included; they're video files tied to this device.
- * Restoring merges: nothing already on the device is deleted, and progress keeps the newer row.
+ * The user's library as one JSON file: every profile with its Watch Later, custom lists, watch
+ * progress (which is also History) and hidden titles, plus app settings. Downloads aren't included;
+ * they're video files tied to this device.
+ * Restoring merges: nothing already on the device is deleted, profiles and lists merge by name,
+ * and progress keeps the newer row. A version 1 backup (from before profiles) goes into the
+ * active profile.
*/
@Singleton
class LibraryBackup @Inject constructor(
private val watchLaterDao: WatchLaterDao,
private val watchProgressDao: WatchProgressDao,
private val hiddenTitleDao: HiddenTitleDao,
+ private val customListDao: CustomListDao,
+ private val profiles: ProfileRepository,
private val settingsStore: AppSettingsStore
) {
private val json = Json {
@@ -44,14 +61,34 @@ class LibraryBackup @Inject constructor(
suspend fun export(output: OutputStream) {
val backup = BackupFile(
exportedAt = System.currentTimeMillis(),
- watchLater = watchLaterDao.getAllWatchLaterItems().first().map { it.toBackup() },
- progress = watchProgressDao.observeAll().first().map { it.toBackup() },
- hidden = hiddenTitleDao.observeAll().first().map { it.toBackup() },
+ profiles = profiles.all().map { profile ->
+ BackupProfile(
+ name = profile.name,
+ avatar = profile.avatar,
+ isKids = profile.isKids,
+ library = libraryOf(profile.id)
+ )
+ },
settings = settingsStore.current.toBackup()
)
output.bufferedWriter().use { it.write(json.encodeToString(BackupFile.serializer(), backup)) }
}
+ private suspend fun libraryOf(profileId: Long) = BackupLibrary(
+ watchLater = watchLaterDao.getAllWatchLaterItems(profileId).first().map { it.toBackup() },
+ progress = watchProgressDao.observeAll(profileId).first().map { it.toBackup() },
+ hidden = hiddenTitleDao.observeAll(profileId).first().map { it.toBackup() },
+ lists = customListDao.allItems(profileId).groupBy { it.listId }.let { itemsByList ->
+ customListDao.allLists(profileId).map { list ->
+ BackupList(
+ name = list.name,
+ createdAt = list.createdAt,
+ items = itemsByList[list.id].orEmpty().map { it.toBackup() }
+ )
+ }
+ }
+ )
+
suspend fun restore(input: InputStream): RestoreSummary {
val text = input.bufferedReader().use { it.readText() }
val backup = runCatching { json.decodeFromString(BackupFile.serializer(), text) }
@@ -59,29 +96,58 @@ class LibraryBackup @Inject constructor(
if (backup.app != APP_ID) throw BackupFormatException("This isn't an OpenStream backup")
if (backup.version > VERSION) throw BackupFormatException("This backup is from a newer version of OpenStream")
- backup.watchLater.forEach { watchLaterDao.insertWatchLaterItem(it.toEntity()) }
+ var total = MergeCounts()
+ backup.profiles.forEach { profile ->
+ val profileId = profiles.findOrCreate(profile.name, profile.avatar, profile.isKids)
+ total += restoreLibrary(profile.library, profileId)
+ }
+ // Version 1 kept one library at the top level: it belongs to whoever is restoring.
+ val legacy = BackupLibrary(backup.watchLater, backup.progress, backup.hidden, backup.lists)
+ if (!legacy.isEmpty()) total += restoreLibrary(legacy, settingsStore.activeProfileId.value)
+ backup.settings?.let { saved -> settingsStore.update { saved.applyTo(it) } }
+
+ return RestoreSummary(
+ watchLater = total.watchLater,
+ progress = total.progress,
+ hidden = total.hidden,
+ settingsRestored = backup.settings != null,
+ lists = total.lists,
+ profiles = backup.profiles.size
+ )
+ }
+
+ private suspend fun restoreLibrary(library: BackupLibrary, profileId: Long): MergeCounts {
+ library.watchLater.forEach { watchLaterDao.insertWatchLaterItem(it.toEntity(profileId)) }
var progressAdded = 0
- backup.progress.forEach { row ->
- val existing = watchProgressDao.get(row.id)
+ library.progress.forEach { row ->
+ val existing = watchProgressDao.get(profileId, row.id)
if (existing == null || existing.updatedAt < row.updatedAt) {
- watchProgressDao.upsert(row.toEntity())
+ watchProgressDao.upsert(row.toEntity(profileId))
progressAdded++
}
}
- backup.hidden.forEach { hiddenTitleDao.insert(it.toEntity()) }
- backup.settings?.let { saved -> settingsStore.update { saved.applyTo(it) } }
+ library.hidden.forEach { hiddenTitleDao.insert(it.toEntity(profileId)) }
+ library.lists.forEach { list ->
+ val listId = customListDao.findByName(profileId, list.name)
+ ?: customListDao.insertList(CustomListEntity(name = list.name, createdAt = list.createdAt, profileId = profileId))
+ list.items.forEach { customListDao.addItem(it.toEntity(listId)) }
+ }
+ return MergeCounts(library.watchLater.size, progressAdded, library.hidden.size, library.lists.size)
+ }
- return RestoreSummary(
- watchLater = backup.watchLater.size,
- progress = progressAdded,
- hidden = backup.hidden.size,
- settingsRestored = backup.settings != null
+ private data class MergeCounts(val watchLater: Int = 0, val progress: Int = 0, val hidden: Int = 0, val lists: Int = 0) {
+ operator fun plus(other: MergeCounts) = MergeCounts(
+ watchLater + other.watchLater,
+ progress + other.progress,
+ hidden + other.hidden,
+ lists + other.lists
)
}
companion object {
const val APP_ID = "openstream"
- const val VERSION = 1
+ /** 2 added profiles; 1 had a single top-level library. */
+ const val VERSION = 2
const val MIME_TYPE = "application/json"
}
}
@@ -91,12 +157,54 @@ private data class BackupFile(
val app: String = LibraryBackup.APP_ID,
val version: Int = LibraryBackup.VERSION,
val exportedAt: Long = 0L,
+ val profiles: List = emptyList(),
+ // Version 1: one library, no profiles.
val watchLater: List = emptyList(),
val progress: List = emptyList(),
val hidden: List = emptyList(),
+ val lists: List = emptyList(),
val settings: BackupSettings? = null
)
+@Serializable
+private data class BackupProfile(
+ val name: String,
+ val avatar: String = ProfileRepository.DEFAULT_AVATAR,
+ val isKids: Boolean = false,
+ val library: BackupLibrary = BackupLibrary()
+)
+
+@Serializable
+private data class BackupLibrary(
+ val watchLater: List = emptyList(),
+ val progress: List = emptyList(),
+ val hidden: List = emptyList(),
+ val lists: List = emptyList()
+) {
+ fun isEmpty() = watchLater.isEmpty() && progress.isEmpty() && hidden.isEmpty() && lists.isEmpty()
+}
+
+@Serializable
+private data class BackupList(
+ val name: String,
+ val createdAt: Long = 0L,
+ val items: List = emptyList()
+)
+
+@Serializable
+private data class BackupListItem(
+ val tmdbId: Int,
+ val mediaType: String,
+ val title: String,
+ val posterPath: String? = null,
+ val voteAverage: Double = 0.0,
+ val addedAt: Long = 0L
+) {
+ fun toEntity(listId: Long) = CustomListItemEntity(listId, tmdbId, mediaType, title, posterPath, voteAverage, addedAt)
+}
+
+private fun CustomListItemEntity.toBackup() = BackupListItem(tmdbId, mediaType, title, posterPath, voteAverage, addedAt)
+
@Serializable
private data class BackupWatchLater(
val id: Int,
@@ -106,7 +214,7 @@ private data class BackupWatchLater(
val voteAverage: Double = 0.0,
val dateAdded: Long = 0L
) {
- fun toEntity() = WatchLaterEntity(id, title, posterPath, mediaType, voteAverage, dateAdded)
+ fun toEntity(profileId: Long) = WatchLaterEntity(id, title, posterPath, mediaType, voteAverage, dateAdded, profileId)
}
private fun WatchLaterEntity.toBackup() = BackupWatchLater(id, title, posterPath, mediaType, voteAverage, dateAdded)
@@ -128,9 +236,9 @@ private data class BackupProgress(
val completed: Boolean,
val updatedAt: Long
) {
- fun toEntity() = WatchProgressEntity(
+ fun toEntity(profileId: Long) = WatchProgressEntity(
id, tmdbId, mediaType, season, episode, title, episodeTitle, posterPath, backdropPath,
- stillPath, positionMs, durationMs, completed, updatedAt
+ stillPath, positionMs, durationMs, completed, updatedAt, profileId
)
}
@@ -141,7 +249,7 @@ private fun WatchProgressEntity.toBackup() = BackupProgress(
@Serializable
private data class BackupHidden(val tmdbId: Int, val mediaType: String, val title: String, val hiddenAt: Long = 0L) {
- fun toEntity() = HiddenTitleEntity(tmdbId, mediaType, title, hiddenAt)
+ fun toEntity(profileId: Long) = HiddenTitleEntity(tmdbId, mediaType, title, hiddenAt, profileId)
}
private fun HiddenTitleEntity.toBackup() = BackupHidden(tmdbId, mediaType, title, hiddenAt)
@@ -156,7 +264,9 @@ private data class BackupSettings(
val seekStepSeconds: Int? = null,
val defaultSpeed: Float? = null,
val autoPlayNext: Boolean? = null,
- val downloadMaxHeight: Int? = null
+ val downloadMaxHeight: Int? = null,
+ val pipLeftAction: String? = null,
+ val pipRightAction: String? = null
) {
fun applyTo(current: AppSettings) = current.copy(
themeMode = ThemeMode.entries.firstOrNull { it.name == themeMode } ?: current.themeMode,
@@ -166,7 +276,9 @@ private data class BackupSettings(
seekStepSeconds = seekStepSeconds?.takeIf { it in AppSettings.SEEK_STEPS } ?: current.seekStepSeconds,
defaultSpeed = defaultSpeed?.takeIf { it in AppSettings.DEFAULT_SPEEDS } ?: current.defaultSpeed,
autoPlayNext = autoPlayNext ?: current.autoPlayNext,
- downloadMaxHeight = downloadMaxHeight?.takeIf { it in AppSettings.DOWNLOAD_HEIGHTS } ?: current.downloadMaxHeight
+ 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
)
}
@@ -178,5 +290,7 @@ private fun AppSettings.toBackup() = BackupSettings(
seekStepSeconds = seekStepSeconds,
defaultSpeed = defaultSpeed,
autoPlayNext = autoPlayNext,
- downloadMaxHeight = downloadMaxHeight
+ downloadMaxHeight = downloadMaxHeight,
+ pipLeftAction = pipLeftAction.name,
+ pipRightAction = pipRightAction.name
)
diff --git a/app/src/main/java/com/ivor/openstream/data/cast/CastMediaItemConverter.kt b/app/src/main/java/com/ivor/openstream/data/cast/CastMediaItemConverter.kt
new file mode 100644
index 0000000..5aa833e
--- /dev/null
+++ b/app/src/main/java/com/ivor/openstream/data/cast/CastMediaItemConverter.kt
@@ -0,0 +1,162 @@
+package com.ivor.openstream.data.cast
+
+import android.net.Uri
+import android.os.Bundle
+import androidx.media3.cast.MediaItemConverter
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import com.google.android.gms.cast.MediaInfo
+import com.google.android.gms.cast.MediaQueueItem
+import com.google.android.gms.cast.MediaTrack
+import com.google.android.gms.common.images.WebImage
+import org.json.JSONArray
+import org.json.JSONObject
+import com.google.android.gms.cast.MediaMetadata as CastMetadata
+
+/**
+ * Turns the app's media items into what the Default Media Receiver loads, and back.
+ *
+ * Unlike Media3's default converter this sends subtitle configurations as WebVTT text tracks (the
+ * one marked [C.SELECTION_FLAG_DEFAULT] starts active) and keeps the item's extras, which carry what
+ * is playing, so a session the app rejoins after a restart still knows its title and episode.
+ */
+class CastMediaItemConverter : MediaItemConverter {
+
+ override fun toMediaQueueItem(mediaItem: MediaItem): MediaQueueItem {
+ val configuration = requireNotNull(mediaItem.localConfiguration) { "Cast items need a URI" }
+ val metadata = mediaItem.mediaMetadata
+ val isEpisode = metadata.extras?.getBoolean(EXTRA_IS_EPISODE) == true
+
+ val castMetadata = CastMetadata(if (isEpisode) CastMetadata.MEDIA_TYPE_TV_SHOW else CastMetadata.MEDIA_TYPE_MOVIE)
+ metadata.title?.let { castMetadata.putString(CastMetadata.KEY_TITLE, it.toString()) }
+ metadata.subtitle?.let { castMetadata.putString(CastMetadata.KEY_SUBTITLE, it.toString()) }
+ metadata.albumTitle?.let { castMetadata.putString(CastMetadata.KEY_SERIES_TITLE, it.toString()) }
+ metadata.artworkUri?.let { castMetadata.addImage(WebImage(it)) }
+
+ val tracks = configuration.subtitleConfigurations.mapIndexed { index, subtitle ->
+ MediaTrack.Builder(index + 1L, MediaTrack.TYPE_TEXT)
+ .setSubtype(MediaTrack.SUBTYPE_SUBTITLES)
+ .setContentId(subtitle.uri.toString())
+ .setContentType("text/vtt")
+ .setName(subtitle.label)
+ .setLanguage(subtitle.language ?: "und")
+ .build()
+ }
+ val activeTrackIds = configuration.subtitleConfigurations
+ .withIndex()
+ .filter { (_, subtitle) -> subtitle.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0 }
+ .map { (index, _) -> index + 1L }
+ .take(1)
+ .toLongArray()
+
+ val customData = JSONObject()
+ .put(KEY_MEDIA_ID, mediaItem.mediaId)
+ .put(KEY_URI, configuration.uri.toString())
+ .put(KEY_MIME, configuration.mimeType)
+ .put(KEY_TITLE, metadata.title?.toString())
+ .put(KEY_SUBTITLE, metadata.subtitle?.toString())
+ .put(KEY_ALBUM, metadata.albumTitle?.toString())
+ .put(KEY_ARTWORK, metadata.artworkUri?.toString())
+ .put(KEY_EXTRAS, metadata.extras?.let(::bundleToJson))
+ .put(KEY_TRACKS, JSONArray().apply {
+ configuration.subtitleConfigurations.forEach { subtitle ->
+ put(
+ JSONObject()
+ .put("uri", subtitle.uri.toString())
+ .put("label", subtitle.label)
+ .put("language", subtitle.language)
+ .put("id", subtitle.id)
+ )
+ }
+ })
+
+ val info = MediaInfo.Builder(configuration.uri.toString())
+ .setContentUrl(configuration.uri.toString())
+ .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
+ .setContentType(configuration.mimeType ?: "video/mp4")
+ .setMetadata(castMetadata)
+ .setMediaTracks(tracks)
+ .setCustomData(customData)
+ .apply {
+ metadata.extras?.getString(EXTRA_HLS_VIDEO_FORMAT)?.let { setHlsVideoSegmentFormat(it) }
+ }
+ .build()
+ return MediaQueueItem.Builder(info)
+ .setActiveTrackIds(activeTrackIds)
+ .setCustomData(customData)
+ .build()
+ }
+
+ override fun toMediaItem(mediaQueueItem: MediaQueueItem): MediaItem {
+ val info = mediaQueueItem.media
+ val data = info?.customData ?: mediaQueueItem.customData ?: JSONObject()
+ val uri = data.optStringOrNull(KEY_URI) ?: info?.contentUrl ?: info?.contentId ?: ""
+ val extras = data.optJSONObject(KEY_EXTRAS)?.let(::jsonToBundle)
+ val metadata = MediaMetadata.Builder()
+ .setTitle(data.optStringOrNull(KEY_TITLE) ?: info?.metadata?.getString(CastMetadata.KEY_TITLE))
+ .setSubtitle(data.optStringOrNull(KEY_SUBTITLE))
+ .setAlbumTitle(data.optStringOrNull(KEY_ALBUM))
+ .setArtworkUri(data.optStringOrNull(KEY_ARTWORK)?.let(Uri::parse))
+ .setExtras(extras)
+ .build()
+ val tracks = data.optJSONArray(KEY_TRACKS)
+ val subtitles = (0 until (tracks?.length() ?: 0)).mapNotNull { index ->
+ val track = tracks?.optJSONObject(index) ?: return@mapNotNull null
+ val trackUri = track.optStringOrNull("uri") ?: return@mapNotNull null
+ MediaItem.SubtitleConfiguration.Builder(Uri.parse(trackUri))
+ .setMimeType("text/vtt")
+ .setLabel(track.optStringOrNull("label"))
+ .setLanguage(track.optStringOrNull("language"))
+ .setId(track.optStringOrNull("id"))
+ .build()
+ }
+ return MediaItem.Builder()
+ .setMediaId(data.optStringOrNull(KEY_MEDIA_ID) ?: MediaItem.DEFAULT_MEDIA_ID)
+ .setUri(uri)
+ .setMimeType(data.optStringOrNull(KEY_MIME) ?: info?.contentType)
+ .setMediaMetadata(metadata)
+ .setSubtitleConfigurations(subtitles)
+ .build()
+ }
+
+ private fun bundleToJson(bundle: Bundle): JSONObject = JSONObject().apply {
+ bundle.keySet().forEach { key ->
+ when (val value = @Suppress("DEPRECATION") bundle.get(key)) {
+ is String, is Int, is Long, is Boolean, is Double -> put(key, value)
+ }
+ }
+ }
+
+ private fun jsonToBundle(json: JSONObject): Bundle = Bundle().apply {
+ json.keys().forEach { key ->
+ when (val value = json.get(key)) {
+ is String -> putString(key, value)
+ is Int -> putInt(key, value)
+ is Long -> putLong(key, value)
+ is Boolean -> putBoolean(key, value)
+ is Double -> putDouble(key, value)
+ }
+ }
+ }
+
+ private fun JSONObject.optStringOrNull(key: String): String? =
+ if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() }
+
+ companion object {
+ /** Extras key: true for a TV episode (the receiver shows the series title). */
+ const val EXTRA_IS_EPISODE = "openstream.is_episode"
+ /** Extras key: `fmp4` or `mpeg2_ts` for HLS streams, when known. */
+ const val EXTRA_HLS_VIDEO_FORMAT = "openstream.hls_video_format"
+
+ private const val KEY_MEDIA_ID = "mediaId"
+ private const val KEY_URI = "uri"
+ private const val KEY_MIME = "mimeType"
+ private const val KEY_TITLE = "title"
+ private const val KEY_SUBTITLE = "subtitle"
+ private const val KEY_ALBUM = "album"
+ private const val KEY_ARTWORK = "artwork"
+ private const val KEY_EXTRAS = "extras"
+ private const val KEY_TRACKS = "tracks"
+ }
+}
diff --git a/app/src/main/java/com/ivor/openstream/data/cast/CastMediaProxy.kt b/app/src/main/java/com/ivor/openstream/data/cast/CastMediaProxy.kt
new file mode 100644
index 0000000..30ad75b
--- /dev/null
+++ b/app/src/main/java/com/ivor/openstream/data/cast/CastMediaProxy.kt
@@ -0,0 +1,585 @@
+package com.ivor.openstream.data.cast
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.net.Uri
+import android.util.Base64
+import android.util.Log
+import androidx.annotation.OptIn
+import androidx.media3.common.C
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.DataSource
+import androidx.media3.datasource.DataSpec
+import androidx.media3.datasource.DefaultDataSource
+import androidx.media3.datasource.DefaultHttpDataSource
+import androidx.media3.datasource.HttpDataSource
+import androidx.media3.datasource.cache.Cache
+import androidx.media3.datasource.cache.CacheDataSource
+import androidx.media3.datasource.cache.ContentMetadata
+import com.ivor.openstream.data.streaming.BROWSER_USER_AGENT
+import com.ivor.openstream.data.streaming.ImagePrefixStrippingDataSource
+import com.ivor.openstream.data.subtitles.SubtitleFetcher
+import com.ivor.openstream.data.subtitles.isSubtitleAd
+import kotlinx.coroutines.runBlocking
+import com.ivor.openstream.data.subtitles.parseSubtitles
+import com.ivor.openstream.data.subtitles.toWebVtt
+import dagger.hilt.android.qualifiers.ApplicationContext
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import java.io.BufferedInputStream
+import java.io.ByteArrayOutputStream
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.net.Inet4Address
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.net.NetworkInterface
+import java.net.ServerSocket
+import java.net.Socket
+import java.net.SocketException
+import java.security.SecureRandom
+import java.util.Collections
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * A small HTTP server on the phone that a Cast receiver streams through.
+ *
+ * A Chromecast fetches media itself, so it can't send the Referer/Origin headers most sources need,
+ * it needs CORS headers most stream CDNs don't send, it can't read the fake PNG prefix some anime
+ * CDNs put before TS segments, and it only shows WebVTT captions. So while casting, every URL handed
+ * to the receiver points here: requests are replayed upstream with the stream's headers (through the
+ * download cache, so downloaded episodes cast too), HLS playlists are rewritten so their segments,
+ * keys and renditions come back through the proxy, and subtitles are converted to WebVTT.
+ *
+ * URLs are stateless (`//m//`) so they keep working across a
+ * restart of the proxy; the random token keeps the server from being an open relay on the LAN. The
+ * phone has to stay on the same network as the receiver while casting.
+ */
+@OptIn(UnstableApi::class)
+@Singleton
+class CastMediaProxy @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val cache: Cache,
+ private val preferences: SharedPreferences,
+ private val subtitleFetcher: SubtitleFetcher
+) {
+ private val executor: ExecutorService = Executors.newCachedThreadPool { runnable ->
+ Thread(runnable, "CastProxy").apply { isDaemon = true }
+ }
+
+ @Volatile
+ private var server: ServerSocket? = null
+
+ private val token: String by lazy {
+ preferences.getString(KEY_TOKEN, null) ?: ByteArray(12)
+ .also { SecureRandom().nextBytes(it) }
+ .joinToString("") { "%02x".format(it) }
+ .also { preferences.edit().putString(KEY_TOKEN, it).apply() }
+ }
+
+ private val dataSourceFactory: DataSource.Factory by lazy {
+ val http = DefaultHttpDataSource.Factory()
+ .setUserAgent(BROWSER_USER_AGENT)
+ .setAllowCrossProtocolRedirects(true)
+ .setConnectTimeoutMs(15_000)
+ .setReadTimeoutMs(20_000)
+ val cached = CacheDataSource.Factory()
+ .setCache(cache)
+ .setUpstreamDataSourceFactory(DefaultDataSource.Factory(context, http))
+ .setCacheWriteDataSinkFactory(null)
+ .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
+ ImagePrefixStrippingDataSource.Factory(cached)
+ }
+
+ private val subtitleCache: MutableMap = Collections.synchronizedMap(
+ object : LinkedHashMap(8, 0.75f, true) {
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = size > 8
+ }
+ )
+
+ /** Starts the server if needed; returns its port, or null when no socket could be opened. */
+ @Synchronized
+ fun start(): Int? {
+ server?.takeUnless { it.isClosed }?.let { return it.localPort }
+ val socket = runCatching { bind(preferences.getInt(KEY_PORT, DEFAULT_PORT)) }
+ .recoverCatching { bind(0) }
+ .onFailure { Log.e(TAG, "Could not open the cast proxy", it) }
+ .getOrNull() ?: return null
+ preferences.edit().putInt(KEY_PORT, socket.localPort).apply()
+ server = socket
+ executor.execute { acceptLoop(socket) }
+ Log.i(TAG, "Cast proxy listening on ${socket.localPort}")
+ return socket.localPort
+ }
+
+ @Synchronized
+ fun stop() {
+ server?.let { runCatching { it.close() } }
+ server = null
+ subtitleCache.clear()
+ }
+
+ /**
+ * The proxy URL a receiver at [receiver] should use for [target]. Null when the phone has no
+ * address on a local network or the server can't start.
+ */
+ fun mediaUrl(target: String, headers: Map, receiver: InetAddress?): String? {
+ val base = baseUrl(receiver) ?: return null
+ return "$base/m/${encode(target, headers)}/${fileName(target, "media")}"
+ }
+
+ /** Like [mediaUrl] for a subtitle file, served as WebVTT whatever its original format. */
+ fun subtitleUrl(target: String, headers: Map, receiver: InetAddress?): String? {
+ val base = baseUrl(receiver) ?: return null
+ return "$base/s/${encode(target, headers)}/subtitles.vtt"
+ }
+
+ /** What a receiver needs to be told up front about a stream: its type and HLS segment format. */
+ data class StreamProbe(val mimeType: String, val hlsVideoSegmentFormat: String?)
+
+ /**
+ * Looks at the start of [target] (and, for an HLS master playlist, its first variant) to tell
+ * HLS from progressive files and TS from fMP4 segments. Blocking; call off the main thread.
+ */
+ fun probe(target: String, headers: Map): StreamProbe {
+ val path = Uri.parse(target).path.orEmpty().lowercase()
+ if (path.endsWith(".mpd")) return StreamProbe("application/dash+xml", null)
+ val playlist = runCatching { readPlaylist(target, headers) }.getOrNull()
+ ?: return StreamProbe(if (path.endsWith(".m3u8")) HLS_MIME else guessContentType(target).takeUnless { it == "video/mp2t" } ?: "video/mp4", null)
+ val (text, base) = playlist
+ val mediaPlaylist = if (text.contains("#EXT-X-STREAM-INF")) {
+ val variant = text.lineSequence().map { it.trim() }
+ .dropWhile { !it.startsWith("#EXT-X-STREAM-INF") }
+ .drop(1)
+ .firstOrNull { it.isNotEmpty() && !it.startsWith("#") }
+ variant?.let { resolve(base, it) }?.let { runCatching { readPlaylist(it, headers) }.getOrNull()?.first }
+ } else {
+ text
+ }
+ val format = when {
+ mediaPlaylist == null -> null
+ mediaPlaylist.contains("#EXT-X-MAP") -> HLS_FMP4
+ else -> HLS_TS
+ }
+ return StreamProbe(HLS_MIME, format)
+ }
+
+ /** The playlist text and its address after redirects, or null when [target] isn't a playlist. */
+ private fun readPlaylist(target: String, headers: Map): Pair? {
+ val source = dataSourceFactory.createDataSource()
+ try {
+ source.open(DataSpec.Builder().setUri(Uri.parse(target)).setHttpRequestHeaders(headers).build())
+ val head = readUpTo(source, SNIFF_BYTES)
+ if (!head.isPlaylist()) return null
+ val body = head + readRest(source, MAX_PLAYLIST_BYTES)
+ return body.toString(Charsets.UTF_8) to (source.uri?.toString() ?: target)
+ } finally {
+ runCatching { source.close() }
+ }
+ }
+
+ private fun baseUrl(receiver: InetAddress?): String? {
+ val port = start() ?: return null
+ val host = lanAddressFor(receiver) ?: return null
+ return "http://$host:$port/$token"
+ }
+
+ private fun bind(port: Int): ServerSocket = ServerSocket().apply {
+ reuseAddress = true
+ bind(InetSocketAddress(port))
+ }
+
+ private fun acceptLoop(socket: ServerSocket) {
+ while (!socket.isClosed) {
+ val client = try {
+ socket.accept()
+ } catch (_: SocketException) {
+ break
+ } catch (error: IOException) {
+ Log.w(TAG, "Accept failed", error)
+ continue
+ }
+ executor.execute { handle(client) }
+ }
+ }
+
+ // region HTTP
+
+ private fun handle(socket: Socket) {
+ socket.use {
+ runCatching {
+ socket.soTimeout = 30_000
+ val input = BufferedInputStream(socket.getInputStream())
+ val output = socket.getOutputStream().buffered(64 * 1024)
+ val requestLine = readLine(input) ?: return
+ val requestHeaders = mutableMapOf()
+ while (true) {
+ val line = readLine(input) ?: break
+ if (line.isEmpty()) break
+ val colon = line.indexOf(':')
+ if (colon > 0) requestHeaders[line.substring(0, colon).trim().lowercase()] = line.substring(colon + 1).trim()
+ }
+ val parts = requestLine.split(' ')
+ if (parts.size < 2) return
+ val method = parts[0].uppercase()
+ if (method == "OPTIONS") {
+ writeHead(output, 204, "No Content", emptyMap(), 0L)
+ output.flush()
+ return
+ }
+ if (method != "GET" && method != "HEAD") {
+ writeError(output, 405, "Method Not Allowed")
+ return
+ }
+ val segments = parts[1].substringBefore('?').split('/').filter { it.isNotEmpty() }
+ if (segments.size < 3 || segments[0] != token) {
+ writeError(output, 403, "Forbidden")
+ return
+ }
+ val decoded = decode(segments[2]) ?: run {
+ writeError(output, 400, "Bad Request")
+ return
+ }
+ val host = requestHeaders["host"] ?: return
+ when (segments[1]) {
+ "m" -> serveMedia(decoded.first, decoded.second, requestHeaders["range"], method == "HEAD", host, output)
+ "s" -> serveSubtitle(decoded.first, decoded.second, method == "HEAD", output)
+ else -> writeError(output, 404, "Not Found")
+ }
+ output.flush()
+ }.onFailure { error ->
+ // Receivers drop connections all the time (seeks, quality switches); that is not news.
+ if (error !is SocketException) Log.w(TAG, "Request failed: ${error.message}")
+ }
+ }
+ }
+
+ private fun serveMedia(
+ target: String,
+ headers: Map,
+ rangeHeader: String?,
+ headOnly: Boolean,
+ host: String,
+ output: OutputStream
+ ) {
+ val range = parseRange(rangeHeader)
+ val start = range?.first ?: 0L
+ val length = range?.second?.let { end -> end - start + 1 } ?: C.LENGTH_UNSET.toLong()
+ val source = dataSourceFactory.createDataSource()
+ try {
+ val spec = DataSpec.Builder()
+ .setUri(Uri.parse(target))
+ .setHttpRequestHeaders(headers)
+ .setPosition(start)
+ .setLength(length)
+ .build()
+ val opened = try {
+ source.open(spec)
+ } catch (error: HttpDataSource.InvalidResponseCodeException) {
+ writeError(output, error.responseCode, error.responseMessage ?: "Upstream error")
+ return
+ } catch (_: FileNotFoundException) {
+ writeError(output, 404, "Not Found")
+ return
+ } catch (error: IOException) {
+ Log.w(TAG, "Upstream failed for ${Uri.parse(target).host}: ${error.message}")
+ writeError(output, 502, "Bad Gateway")
+ return
+ }
+
+ val upstreamHeaders = source.responseHeaders
+ val head = if (start == 0L) readUpTo(source, SNIFF_BYTES) else ByteArray(0)
+ if (head.isPlaylist()) {
+ val body = head + readRest(source, MAX_PLAYLIST_BYTES)
+ val base = source.uri?.toString() ?: target
+ val rewritten = rewritePlaylist(body.toString(Charsets.UTF_8), base, headers, host)
+ .toByteArray(Charsets.UTF_8)
+ writeHead(
+ output, 200, "OK",
+ mapOf("Content-Type" to "application/vnd.apple.mpegurl", "Cache-Control" to "no-cache"),
+ rewritten.size.toLong()
+ )
+ if (!headOnly) output.write(rewritten)
+ return
+ }
+
+ val contentType = upstreamHeaders.first("Content-Type")
+ ?.takeUnless { it.startsWith("image/", ignoreCase = true) || it.startsWith("text/plain", ignoreCase = true) }
+ ?: guessContentType(target)
+ val responseHeaders = mutableMapOf("Content-Type" to contentType, "Accept-Ranges" to "bytes")
+ if (range != null) {
+ val total = totalLength(upstreamHeaders, target, start, opened, range.second)
+ val end = when {
+ opened != C.LENGTH_UNSET.toLong() -> start + opened - 1
+ range.second != null -> range.second!!
+ total != null -> total - 1
+ else -> null
+ }
+ responseHeaders["Content-Range"] = "bytes $start-${end ?: ""}/${total ?: "*"}"
+ writeHead(output, 206, "Partial Content", responseHeaders, opened.takeIf { it != C.LENGTH_UNSET.toLong() })
+ } else {
+ writeHead(output, 200, "OK", responseHeaders, opened.takeIf { it != C.LENGTH_UNSET.toLong() })
+ }
+ if (headOnly) return
+ output.write(head)
+ val buffer = ByteArray(32 * 1024)
+ while (true) {
+ val read = source.read(buffer, 0, buffer.size)
+ if (read == C.RESULT_END_OF_INPUT) break
+ output.write(buffer, 0, read)
+ }
+ } finally {
+ runCatching { source.close() }
+ }
+ }
+
+ private fun serveSubtitle(target: String, headers: Map, headOnly: Boolean, output: OutputStream) {
+ val body = subtitleCache[target] ?: runCatching { fetchSubtitleAsVtt(target, headers) }
+ .onFailure { Log.w(TAG, "Subtitle conversion failed: ${it.message}") }
+ .getOrNull()
+ ?.also { subtitleCache[target] = it }
+ if (body == null) {
+ writeError(output, 502, "Bad Gateway")
+ return
+ }
+ writeHead(output, 200, "OK", mapOf("Content-Type" to "text/vtt; charset=utf-8"), body.size.toLong())
+ if (!headOnly) output.write(body)
+ }
+
+ private fun fetchSubtitleAsVtt(target: String, headers: Map): ByteArray {
+ val text = runBlocking { subtitleFetcher.fetchText(target, headers) }
+ val cues = parseSubtitles(text).filterNot { it.text.isSubtitleAd() }
+ if (cues.isEmpty()) throw IOException("No cues")
+ return cues.toWebVtt().toByteArray(Charsets.UTF_8)
+ }
+
+ private fun writeHead(
+ output: OutputStream,
+ code: Int,
+ reason: String,
+ headers: Map,
+ contentLength: Long?
+ ) {
+ val head = buildString {
+ append("HTTP/1.1 ").append(code).append(' ').append(reason).append("\r\n")
+ headers.forEach { (name, value) -> append(name).append(": ").append(value).append("\r\n") }
+ if (contentLength != null) append("Content-Length: ").append(contentLength).append("\r\n")
+ // Receivers are web pages: they need CORS, and Chrome's private network access checks.
+ append("Access-Control-Allow-Origin: *\r\n")
+ append("Access-Control-Allow-Methods: GET, HEAD, OPTIONS\r\n")
+ append("Access-Control-Allow-Headers: *\r\n")
+ append("Access-Control-Allow-Private-Network: true\r\n")
+ append("Access-Control-Expose-Headers: Content-Length, Content-Range, Accept-Ranges\r\n")
+ append("Connection: close\r\n\r\n")
+ }
+ output.write(head.toByteArray(Charsets.ISO_8859_1))
+ }
+
+ private fun writeError(output: OutputStream, code: Int, reason: String) {
+ writeHead(output, code, reason, mapOf("Content-Type" to "text/plain"), 0L)
+ output.flush()
+ }
+
+ // endregion
+
+ // region Playlists
+
+ /**
+ * Points every URI in an HLS playlist (variants, segments, keys, init sections, renditions)
+ * back at the proxy, resolved against the playlist's own address after redirects.
+ */
+ internal fun rewritePlaylist(text: String, base: String, headers: Map, host: String): String {
+ val prefix = "http://$host/$token"
+ fun proxify(reference: String): String {
+ val resolved = resolve(base, reference) ?: return reference
+ val scheme = Uri.parse(resolved).scheme?.lowercase()
+ if (scheme !in PROXIED_SCHEMES) return reference
+ return "$prefix/m/${encode(resolved, headers)}/${fileName(resolved, "media")}"
+ }
+ return text.lineSequence().joinToString("\n") { raw ->
+ val line = raw.trim()
+ when {
+ line.isEmpty() -> raw
+ line.startsWith("#") -> URI_ATTRIBUTE.replace(raw) { match ->
+ "URI=\"${proxify(match.groupValues[1])}\""
+ }
+ else -> proxify(line)
+ }
+ }
+ }
+
+ private fun resolve(base: String, reference: String): String? =
+ base.toHttpUrlOrNull()?.resolve(reference)?.toString()
+ ?: runCatching { java.net.URI(base).resolve(reference).toString() }.getOrNull()
+
+ private fun ByteArray.isPlaylist(): Boolean {
+ val text = toString(Charsets.UTF_8).trimStart('', ' ', '\r', '\n', '\t')
+ return text.startsWith("#EXTM3U")
+ }
+
+ // endregion
+
+ // region Helpers
+
+ private fun encode(target: String, headers: Map): String {
+ val payload = buildString {
+ append(target)
+ headers.forEach { (name, value) -> append('\n').append(name).append(": ").append(value) }
+ }
+ return Base64.encodeToString(payload.toByteArray(Charsets.UTF_8), BASE64_FLAGS)
+ }
+
+ private fun decode(segment: String): Pair>? = runCatching {
+ val lines = Base64.decode(segment, BASE64_FLAGS).toString(Charsets.UTF_8).split('\n')
+ val headers = lines.drop(1).mapNotNull { line ->
+ val colon = line.indexOf(": ")
+ if (colon <= 0) null else line.substring(0, colon) to line.substring(colon + 2)
+ }.toMap()
+ lines.first() to headers
+ }.getOrNull()?.takeIf { it.first.isNotBlank() }
+
+ private fun fileName(target: String, fallback: String): String =
+ Uri.parse(target).lastPathSegment
+ ?.replace(Regex("[^A-Za-z0-9._-]"), "_")
+ ?.take(64)
+ ?.takeIf { it.isNotBlank() }
+ ?: fallback
+
+ private fun parseRange(header: String?): Pair? {
+ val match = header?.let { RANGE.matchEntire(it.trim()) } ?: return null
+ val start = match.groupValues[1].toLongOrNull() ?: return null
+ val end = match.groupValues[2].toLongOrNull()
+ return start to end
+ }
+
+ private fun totalLength(
+ upstreamHeaders: Map>,
+ target: String,
+ start: Long,
+ opened: Long,
+ requestedEnd: Long?
+ ): Long? {
+ upstreamHeaders.first("Content-Range")?.substringAfterLast('/')?.toLongOrNull()?.let { return it }
+ ContentMetadata.getContentLength(cache.getContentMetadata(target)).takeIf { it > 0 }?.let { return it }
+ return if (requestedEnd == null && opened != C.LENGTH_UNSET.toLong()) start + opened else null
+ }
+
+ private fun Map>.first(name: String): String? =
+ entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value?.firstOrNull()
+
+ private fun guessContentType(target: String): String {
+ val path = Uri.parse(target).path.orEmpty().lowercase()
+ return when {
+ path.endsWith(".m3u8") -> "application/vnd.apple.mpegurl"
+ path.endsWith(".mp4") || path.endsWith(".m4s") || path.endsWith(".m4v") -> "video/mp4"
+ path.endsWith(".m4a") -> "audio/mp4"
+ path.endsWith(".aac") -> "audio/aac"
+ path.endsWith(".vtt") -> "text/vtt"
+ path.endsWith(".mpd") -> "application/dash+xml"
+ path.endsWith(".webm") -> "video/webm"
+ path.endsWith(".mkv") -> "video/x-matroska"
+ path.endsWith(".key") -> "application/octet-stream"
+ else -> "video/mp2t"
+ }
+ }
+
+ private fun readLine(input: InputStream): String? {
+ val bytes = ByteArrayOutputStream()
+ while (true) {
+ val next = input.read()
+ if (next == -1) return if (bytes.size() == 0) null else bytes.toString(Charsets.ISO_8859_1.name())
+ if (next == '\n'.code) break
+ if (next != '\r'.code) bytes.write(next)
+ if (bytes.size() > 16 * 1024) throw IOException("Header line too long")
+ }
+ return bytes.toString(Charsets.ISO_8859_1.name())
+ }
+
+ private fun readUpTo(source: DataSource, limit: Int): ByteArray {
+ val bytes = ByteArray(limit)
+ var filled = 0
+ while (filled < limit) {
+ val read = source.read(bytes, filled, limit - filled)
+ if (read == C.RESULT_END_OF_INPUT) break
+ filled += read
+ }
+ return if (filled == limit) bytes else bytes.copyOf(filled)
+ }
+
+ private fun readRest(source: DataSource, limit: Int): ByteArray {
+ val output = ByteArrayOutputStream()
+ val buffer = ByteArray(16 * 1024)
+ while (output.size() < limit) {
+ val read = source.read(buffer, 0, buffer.size)
+ if (read == C.RESULT_END_OF_INPUT) break
+ output.write(buffer, 0, read)
+ }
+ return output.toByteArray()
+ }
+
+ // endregion
+
+ companion object {
+ private const val TAG = "CastProxy"
+ private const val KEY_TOKEN = "cast_proxy_token"
+ private const val KEY_PORT = "cast_proxy_port"
+ private const val DEFAULT_PORT = 8765
+ private const val HLS_MIME = "application/x-mpegURL"
+ private const val HLS_FMP4 = "fmp4"
+ private const val HLS_TS = "mpeg2_ts"
+ private const val SNIFF_BYTES = 16
+ private const val MAX_PLAYLIST_BYTES = 16 * 1024 * 1024
+ private const val BASE64_FLAGS = Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
+ private val PROXIED_SCHEMES = setOf("http", "https", "content", "file")
+ private val URI_ATTRIBUTE = Regex("URI=\"([^\"]+)\"")
+ private val RANGE = Regex("bytes=(\\d+)-(\\d*)")
+
+ /**
+ * This phone's IPv4 address on the network the receiver is on: the interface that shares
+ * the receiver's subnet, else the first private address on Wi-Fi/Ethernet.
+ */
+ fun lanAddressFor(receiver: InetAddress?): String? {
+ val candidates = runCatching { NetworkInterface.getNetworkInterfaces()?.toList().orEmpty() }
+ .getOrDefault(emptyList())
+ .filter { runCatching { it.isUp && !it.isLoopback }.getOrDefault(false) }
+ .flatMap { network ->
+ network.interfaceAddresses
+ .filter { it.address is Inet4Address }
+ .map { network.name.orEmpty() to it }
+ }
+ val target = receiver as? Inet4Address
+ if (target != null) {
+ candidates.firstOrNull { (_, address) ->
+ sameSubnet(address.address, target, address.networkPrefixLength.toInt())
+ }?.let { return it.second.address.hostAddress }
+ }
+ return candidates
+ .filter { (_, address) -> address.address.isSiteLocalAddress }
+ .sortedBy { (name, _) ->
+ when {
+ name.startsWith("wlan") -> 0
+ name.startsWith("eth") || name.startsWith("ap") || name.startsWith("swlan") -> 1
+ else -> 2
+ }
+ }
+ .firstOrNull()?.second?.address?.hostAddress
+ }
+
+ private fun sameSubnet(local: InetAddress, remote: InetAddress, prefixLength: Int): Boolean {
+ if (prefixLength !in 1..32) return false
+ val a = local.address
+ val b = remote.address
+ if (a.size != 4 || b.size != 4) return false
+ val mask = if (prefixLength == 32) -1 else ((-1L shl (32 - prefixLength)) and 0xFFFFFFFFL).toInt()
+ val left = ((a[0].toInt() and 0xFF) shl 24) or ((a[1].toInt() and 0xFF) shl 16) or
+ ((a[2].toInt() and 0xFF) shl 8) or (a[3].toInt() and 0xFF)
+ val right = ((b[0].toInt() and 0xFF) shl 24) or ((b[1].toInt() and 0xFF) shl 16) or
+ ((b[2].toInt() and 0xFF) shl 8) or (b[3].toInt() and 0xFF)
+ return (left and mask) == (right and mask)
+ }
+ }
+}
diff --git a/app/src/main/java/com/ivor/openstream/data/cast/CastOptionsProvider.kt b/app/src/main/java/com/ivor/openstream/data/cast/CastOptionsProvider.kt
new file mode 100644
index 0000000..7de3b52
--- /dev/null
+++ b/app/src/main/java/com/ivor/openstream/data/cast/CastOptionsProvider.kt
@@ -0,0 +1,35 @@
+package com.ivor.openstream.data.cast
+
+import android.content.Context
+import com.google.android.gms.cast.CastMediaControlIntent
+import com.google.android.gms.cast.framework.CastOptions
+import com.google.android.gms.cast.framework.OptionsProvider
+import com.google.android.gms.cast.framework.SessionProvider
+import com.google.android.gms.cast.framework.media.CastMediaOptions
+import com.google.android.gms.cast.framework.media.NotificationOptions
+import com.ivor.openstream.MainActivity
+
+/**
+ * Read by the Cast framework through the manifest (kept in proguard-rules.pro). Uses Google's
+ * Default Media Receiver, so no receiver app has to be registered or hosted. The framework's media
+ * notification and lock-screen controls cover playback on the TV; tapping them opens the app.
+ */
+class CastOptionsProvider : OptionsProvider {
+ override fun getCastOptions(context: Context): CastOptions {
+ val notification = NotificationOptions.Builder()
+ .setTargetActivityClassName(MainActivity::class.java.name)
+ .build()
+ val media = CastMediaOptions.Builder()
+ .setNotificationOptions(notification)
+ .setMediaSessionEnabled(true)
+ .build()
+ return CastOptions.Builder()
+ .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
+ .setCastMediaOptions(media)
+ .setStopReceiverApplicationWhenEndingSession(true)
+ .setResumeSavedSession(true)
+ .build()
+ }
+
+ override fun getAdditionalSessionProviders(context: Context): List? = null
+}
diff --git a/app/src/main/java/com/ivor/openstream/data/local/AppDatabase.kt b/app/src/main/java/com/ivor/openstream/data/local/AppDatabase.kt
index ee8e8d8..9fa92fa 100644
--- a/app/src/main/java/com/ivor/openstream/data/local/AppDatabase.kt
+++ b/app/src/main/java/com/ivor/openstream/data/local/AppDatabase.kt
@@ -2,14 +2,19 @@ package com.ivor.openstream.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
+import com.ivor.openstream.data.local.dao.CustomListDao
import com.ivor.openstream.data.local.dao.DownloadDao
import com.ivor.openstream.data.local.dao.HiddenTitleDao
import com.ivor.openstream.data.local.dao.WatchLaterDao
import com.ivor.openstream.data.local.dao.IdMappingDao
+import com.ivor.openstream.data.local.dao.ProfileDao
import com.ivor.openstream.data.local.dao.WatchProgressDao
+import com.ivor.openstream.data.local.entity.CustomListEntity
+import com.ivor.openstream.data.local.entity.CustomListItemEntity
import com.ivor.openstream.data.local.entity.DownloadEntity
import com.ivor.openstream.data.local.entity.HiddenTitleEntity
import com.ivor.openstream.data.local.entity.IdMappingEntity
+import com.ivor.openstream.data.local.entity.ProfileEntity
import com.ivor.openstream.data.local.entity.WatchLaterEntity
import com.ivor.openstream.data.local.entity.WatchProgressEntity
@@ -19,9 +24,12 @@ import com.ivor.openstream.data.local.entity.WatchProgressEntity
DownloadEntity::class,
IdMappingEntity::class,
WatchProgressEntity::class,
- HiddenTitleEntity::class
+ HiddenTitleEntity::class,
+ CustomListEntity::class,
+ CustomListItemEntity::class,
+ ProfileEntity::class
],
- version = 6,
+ version = 8,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
@@ -30,4 +38,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun idMappingDao(): IdMappingDao
abstract fun watchProgressDao(): WatchProgressDao
abstract fun hiddenTitleDao(): HiddenTitleDao
+ abstract fun customListDao(): CustomListDao
+ abstract fun profileDao(): ProfileDao
}
diff --git a/app/src/main/java/com/ivor/openstream/data/local/dao/CustomListDao.kt b/app/src/main/java/com/ivor/openstream/data/local/dao/CustomListDao.kt
new file mode 100644
index 0000000..92f98a6
--- /dev/null
+++ b/app/src/main/java/com/ivor/openstream/data/local/dao/CustomListDao.kt
@@ -0,0 +1,96 @@
+package com.ivor.openstream.data.local.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import com.ivor.openstream.data.local.entity.CustomListEntity
+import com.ivor.openstream.data.local.entity.CustomListItemEntity
+import kotlinx.coroutines.flow.Flow
+
+/** A list with its size and the posters of its newest titles, for list cards. */
+data class CustomListSummary(
+ val id: Long,
+ val name: String,
+ val updatedAt: Long,
+ val itemCount: Int,
+ /** Up to four poster paths, newest first, joined with '|'. */
+ val posters: String?
+)
+
+@Dao
+interface CustomListDao {
+ @Query(
+ """
+ SELECT l.id, l.name, l.updatedAt,
+ (SELECT COUNT(*) FROM custom_list_items i WHERE i.listId = l.id) AS itemCount,
+ (SELECT GROUP_CONCAT(posterPath, '|') FROM (
+ SELECT posterPath FROM custom_list_items i2
+ WHERE i2.listId = l.id AND i2.posterPath IS NOT NULL
+ ORDER BY i2.addedAt DESC LIMIT 4
+ )) AS posters
+ FROM custom_lists l
+ WHERE l.profileId = :profileId
+ ORDER BY l.updatedAt DESC
+ """
+ )
+ fun observeSummaries(profileId: Long): Flow>
+
+ @Query("SELECT * FROM custom_lists WHERE id = :listId")
+ fun observeList(listId: Long): Flow
+
+ @Query("SELECT * FROM custom_lists WHERE profileId = :profileId ORDER BY updatedAt DESC")
+ suspend fun allLists(profileId: Long): List
+
+ @Query("SELECT * FROM custom_list_items WHERE listId = :listId ORDER BY addedAt DESC")
+ fun observeItems(listId: Long): Flow>
+
+ @Query(
+ "SELECT i.* FROM custom_list_items i JOIN custom_lists l ON l.id = i.listId WHERE l.profileId = :profileId"
+ )
+ suspend fun allItems(profileId: Long): List
+
+ /** Ids of the profile's lists that contain a title. */
+ @Query(
+ "SELECT i.listId FROM custom_list_items i JOIN custom_lists l ON l.id = i.listId " +
+ "WHERE l.profileId = :profileId AND i.mediaType = :mediaType AND i.tmdbId = :tmdbId"
+ )
+ fun observeListIdsFor(profileId: Long, mediaType: String, tmdbId: Int): Flow>
+
+ @Insert
+ suspend fun insertList(list: CustomListEntity): Long
+
+ @Query("UPDATE custom_lists SET name = :name, updatedAt = :now WHERE id = :listId")
+ suspend fun rename(listId: Long, name: String, now: Long = System.currentTimeMillis())
+
+ @Query("UPDATE custom_lists SET updatedAt = :now WHERE id = :listId")
+ suspend fun touch(listId: Long, now: Long = System.currentTimeMillis())
+
+ @Query("DELETE FROM custom_lists WHERE id = :listId")
+ suspend fun deleteListRow(listId: Long)
+
+ @Query("DELETE FROM custom_list_items WHERE listId = :listId")
+ suspend fun deleteItemsOf(listId: Long)
+
+ @Transaction
+ suspend fun deleteList(listId: Long) {
+ deleteItemsOf(listId)
+ deleteListRow(listId)
+ }
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertItem(item: CustomListItemEntity)
+
+ @Transaction
+ suspend fun addItem(item: CustomListItemEntity) {
+ insertItem(item)
+ touch(item.listId)
+ }
+
+ @Query("DELETE FROM custom_list_items WHERE listId = :listId AND mediaType = :mediaType AND tmdbId = :tmdbId")
+ suspend fun removeItem(listId: Long, mediaType: String, tmdbId: Int)
+
+ @Query("SELECT id FROM custom_lists WHERE profileId = :profileId AND name = :name COLLATE NOCASE LIMIT 1")
+ suspend fun findByName(profileId: Long, name: String): Long?
+}
diff --git a/app/src/main/java/com/ivor/openstream/data/local/dao/HiddenTitleDao.kt b/app/src/main/java/com/ivor/openstream/data/local/dao/HiddenTitleDao.kt
index 361bda6..376e5aa 100644
--- a/app/src/main/java/com/ivor/openstream/data/local/dao/HiddenTitleDao.kt
+++ b/app/src/main/java/com/ivor/openstream/data/local/dao/HiddenTitleDao.kt
@@ -7,17 +7,18 @@ import androidx.room.Query
import com.ivor.openstream.data.local.entity.HiddenTitleEntity
import kotlinx.coroutines.flow.Flow
+/** Every query is scoped to one profile. */
@Dao
interface HiddenTitleDao {
- @Query("SELECT * FROM hidden_titles ORDER BY hiddenAt DESC")
- fun observeAll(): Flow>
+ @Query("SELECT * FROM hidden_titles WHERE profileId = :profileId ORDER BY hiddenAt DESC")
+ fun observeAll(profileId: Long): Flow>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(title: HiddenTitleEntity)
- @Query("DELETE FROM hidden_titles WHERE mediaType = :mediaType AND tmdbId = :tmdbId")
- suspend fun delete(mediaType: String, tmdbId: Int)
+ @Query("DELETE FROM hidden_titles WHERE profileId = :profileId AND mediaType = :mediaType AND tmdbId = :tmdbId")
+ suspend fun delete(profileId: Long, mediaType: String, tmdbId: Int)
- @Query("DELETE FROM hidden_titles")
- suspend fun clear()
+ @Query("DELETE FROM hidden_titles WHERE profileId = :profileId")
+ suspend fun clear(profileId: Long)
}
diff --git a/app/src/main/java/com/ivor/openstream/data/local/dao/ProfileDao.kt b/app/src/main/java/com/ivor/openstream/data/local/dao/ProfileDao.kt
new file mode 100644
index 0000000..7457a73
--- /dev/null
+++ b/app/src/main/java/com/ivor/openstream/data/local/dao/ProfileDao.kt
@@ -0,0 +1,58 @@
+package com.ivor.openstream.data.local.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.Query
+import androidx.room.Transaction
+import com.ivor.openstream.data.local.entity.ProfileEntity
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface ProfileDao {
+ @Query("SELECT * FROM profiles ORDER BY createdAt, id")
+ fun observeAll(): Flow>
+
+ @Query("SELECT * FROM profiles ORDER BY createdAt, id")
+ suspend fun all(): List
+
+ @Query("SELECT * FROM profiles WHERE id = :id")
+ suspend fun get(id: Long): ProfileEntity?
+
+ @Query("SELECT id FROM profiles WHERE name = :name COLLATE NOCASE LIMIT 1")
+ suspend fun findByName(name: String): Long?
+
+ @Insert
+ suspend fun insert(profile: ProfileEntity): Long
+
+ @Query("UPDATE profiles SET name = :name, avatar = :avatar, isKids = :isKids WHERE id = :id")
+ suspend fun update(id: Long, name: String, avatar: String, isKids: Boolean)
+
+ @Query("DELETE FROM profiles WHERE id = :id")
+ suspend fun deleteRow(id: Long)
+
+ @Query("DELETE FROM watch_later WHERE profileId = :id")
+ suspend fun deleteWatchLater(id: Long)
+
+ @Query("DELETE FROM watch_progress WHERE profileId = :id")
+ suspend fun deleteProgress(id: Long)
+
+ @Query("DELETE FROM hidden_titles WHERE profileId = :id")
+ suspend fun deleteHidden(id: Long)
+
+ @Query("DELETE FROM custom_list_items WHERE listId IN (SELECT id FROM custom_lists WHERE profileId = :id)")
+ suspend fun deleteListItems(id: Long)
+
+ @Query("DELETE FROM custom_lists WHERE profileId = :id")
+ suspend fun deleteLists(id: Long)
+
+ /** Deletes a profile and everything that belonged to it (downloads are device-wide and stay). */
+ @Transaction
+ suspend fun deleteWithData(id: Long) {
+ deleteWatchLater(id)
+ deleteProgress(id)
+ deleteHidden(id)
+ deleteListItems(id)
+ deleteLists(id)
+ deleteRow(id)
+ }
+}
diff --git a/app/src/main/java/com/ivor/openstream/data/local/dao/WatchLaterDao.kt b/app/src/main/java/com/ivor/openstream/data/local/dao/WatchLaterDao.kt
index 01327b0..3db7eac 100644
--- a/app/src/main/java/com/ivor/openstream/data/local/dao/WatchLaterDao.kt
+++ b/app/src/main/java/com/ivor/openstream/data/local/dao/WatchLaterDao.kt
@@ -1,27 +1,24 @@
package com.ivor.openstream.data.local.dao
import androidx.room.Dao
-import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.ivor.openstream.data.local.entity.WatchLaterEntity
import kotlinx.coroutines.flow.Flow
+/** Every query is scoped to one profile. */
@Dao
interface WatchLaterDao {
- @Query("SELECT * FROM watch_later ORDER BY dateAdded DESC")
- fun getAllWatchLaterItems(): Flow>
+ @Query("SELECT * FROM watch_later WHERE profileId = :profileId ORDER BY dateAdded DESC")
+ fun getAllWatchLaterItems(profileId: Long): Flow>
- @Query("SELECT EXISTS(SELECT 1 FROM watch_later WHERE id = :id)")
- fun isWatchLater(id: Int): Flow
+ @Query("SELECT EXISTS(SELECT 1 FROM watch_later WHERE profileId = :profileId AND id = :id)")
+ fun isWatchLater(profileId: Long, id: Int): Flow
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertWatchLaterItem(item: WatchLaterEntity)
- @Delete
- suspend fun deleteWatchLaterItem(item: WatchLaterEntity)
-
- @Query("DELETE FROM watch_later WHERE id = :id")
- suspend fun deleteWatchLaterItemById(id: Int)
+ @Query("DELETE FROM watch_later WHERE profileId = :profileId AND id = :id")
+ suspend fun deleteWatchLaterItemById(profileId: Long, id: Int)
}
diff --git a/app/src/main/java/com/ivor/openstream/data/local/dao/WatchProgressDao.kt b/app/src/main/java/com/ivor/openstream/data/local/dao/WatchProgressDao.kt
index c7e6027..a2ab0b0 100644
--- a/app/src/main/java/com/ivor/openstream/data/local/dao/WatchProgressDao.kt
+++ b/app/src/main/java/com/ivor/openstream/data/local/dao/WatchProgressDao.kt
@@ -7,45 +7,53 @@ import androidx.room.Query
import com.ivor.openstream.data.local.entity.WatchProgressEntity
import kotlinx.coroutines.flow.Flow
+/** Every query is scoped to one profile. */
@Dao
interface WatchProgressDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(progress: WatchProgressEntity)
- @Query("SELECT * FROM watch_progress WHERE id = :id")
- suspend fun get(id: String): WatchProgressEntity?
+ @Query("SELECT * FROM watch_progress WHERE profileId = :profileId AND id = :id")
+ suspend fun get(profileId: Long, id: String): WatchProgressEntity?
/** The most recently touched unfinished entry of every title, newest first. */
@Query(
"""
SELECT * FROM watch_progress AS p
- WHERE p.completed = 0
+ WHERE p.profileId = :profileId
+ AND p.completed = 0
AND p.updatedAt = (
SELECT MAX(q.updatedAt) FROM watch_progress AS q
- WHERE q.tmdbId = p.tmdbId AND q.mediaType = p.mediaType
+ WHERE q.profileId = p.profileId AND q.tmdbId = p.tmdbId AND q.mediaType = p.mediaType
)
ORDER BY p.updatedAt DESC
LIMIT :limit
"""
)
- fun observeContinueWatching(limit: Int): Flow>
+ fun observeContinueWatching(profileId: Long, limit: Int): Flow>
- @Query("SELECT * FROM watch_progress ORDER BY updatedAt DESC")
- fun observeAll(): Flow>
+ @Query("SELECT * FROM watch_progress WHERE profileId = :profileId ORDER BY updatedAt DESC")
+ fun observeAll(profileId: Long): Flow>
- @Query("SELECT * FROM watch_progress WHERE mediaType = :mediaType AND tmdbId = :tmdbId ORDER BY updatedAt DESC")
- fun observeForTitle(mediaType: String, tmdbId: Int): Flow>
+ @Query(
+ "SELECT * FROM watch_progress WHERE profileId = :profileId AND mediaType = :mediaType " +
+ "AND tmdbId = :tmdbId ORDER BY updatedAt DESC"
+ )
+ fun observeForTitle(profileId: Long, mediaType: String, tmdbId: Int): Flow>
/** Drops in-progress rows but keeps finished ones, so watched marks survive. */
- @Query("DELETE FROM watch_progress WHERE mediaType = :mediaType AND tmdbId = :tmdbId AND completed = 0")
- suspend fun deleteUnfinished(mediaType: String, tmdbId: Int)
+ @Query(
+ "DELETE FROM watch_progress WHERE profileId = :profileId AND mediaType = :mediaType " +
+ "AND tmdbId = :tmdbId AND completed = 0"
+ )
+ suspend fun deleteUnfinished(profileId: Long, mediaType: String, tmdbId: Int)
- @Query("DELETE FROM watch_progress WHERE mediaType = :mediaType AND tmdbId = :tmdbId")
- suspend fun deleteForTitle(mediaType: String, tmdbId: Int)
+ @Query("DELETE FROM watch_progress WHERE profileId = :profileId AND mediaType = :mediaType AND tmdbId = :tmdbId")
+ suspend fun deleteForTitle(profileId: Long, mediaType: String, tmdbId: Int)
- @Query("DELETE FROM watch_progress WHERE id = :id")
- suspend fun delete(id: String)
+ @Query("DELETE FROM watch_progress WHERE profileId = :profileId AND id = :id")
+ suspend fun delete(profileId: Long, id: String)
- @Query("DELETE FROM watch_progress")
- suspend fun clear()
+ @Query("DELETE FROM watch_progress WHERE profileId = :profileId")
+ suspend fun clear(profileId: Long)
}
diff --git a/app/src/main/java/com/ivor/openstream/data/local/entity/CustomListEntity.kt b/app/src/main/java/com/ivor/openstream/data/local/entity/CustomListEntity.kt
new file mode 100644
index 0000000..bf6228e
--- /dev/null
+++ b/app/src/main/java/com/ivor/openstream/data/local/entity/CustomListEntity.kt
@@ -0,0 +1,31 @@
+package com.ivor.openstream.data.local.entity
+
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+/** A list a profile made (beyond Watch Later), e.g. "Weekend movies". Items follow via listId. */
+@Entity(tableName = "custom_lists", indices = [Index(value = ["profileId"])])
+data class CustomListEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val name: String,
+ val createdAt: Long = System.currentTimeMillis(),
+ val updatedAt: Long = System.currentTimeMillis(),
+ val profileId: Long = ProfileEntity.DEFAULT_ID
+)
+
+/** A title in a custom list. Deleting a list deletes its items (see CustomListDao.deleteList). */
+@Entity(
+ tableName = "custom_list_items",
+ primaryKeys = ["listId", "mediaType", "tmdbId"],
+ indices = [Index(value = ["mediaType", "tmdbId"])]
+)
+data class CustomListItemEntity(
+ val listId: Long,
+ val tmdbId: Int,
+ val mediaType: String,
+ val title: String,
+ val posterPath: String?,
+ val voteAverage: Double,
+ val addedAt: Long = System.currentTimeMillis()
+)
diff --git a/app/src/main/java/com/ivor/openstream/data/local/entity/HiddenTitleEntity.kt b/app/src/main/java/com/ivor/openstream/data/local/entity/HiddenTitleEntity.kt
index 30a3f14..781e09e 100644
--- a/app/src/main/java/com/ivor/openstream/data/local/entity/HiddenTitleEntity.kt
+++ b/app/src/main/java/com/ivor/openstream/data/local/entity/HiddenTitleEntity.kt
@@ -3,10 +3,11 @@ package com.ivor.openstream.data.local.entity
import androidx.room.Entity
/** A title the user marked "Not interested"; it's left out of Home rows. */
-@Entity(tableName = "hidden_titles", primaryKeys = ["mediaType", "tmdbId"])
+@Entity(tableName = "hidden_titles", primaryKeys = ["profileId", "mediaType", "tmdbId"])
data class HiddenTitleEntity(
val tmdbId: Int,
val mediaType: String,
val title: String,
- val hiddenAt: Long = System.currentTimeMillis()
+ val hiddenAt: Long = System.currentTimeMillis(),
+ val profileId: Long = ProfileEntity.DEFAULT_ID
)
diff --git a/app/src/main/java/com/ivor/openstream/data/local/entity/ProfileEntity.kt b/app/src/main/java/com/ivor/openstream/data/local/entity/ProfileEntity.kt
new file mode 100644
index 0000000..a498f6c
--- /dev/null
+++ b/app/src/main/java/com/ivor/openstream/data/local/entity/ProfileEntity.kt
@@ -0,0 +1,24 @@
+package com.ivor.openstream.data.local.entity
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+/**
+ * Someone who watches on this device. Watch Later, progress (History, Continue Watching), hidden
+ * titles and custom lists belong to a profile; downloads are shared by the whole device.
+ *
+ * [avatar] is a built-in avatar key (see `ProfileAvatar`).
+ */
+@Entity(tableName = "profiles")
+data class ProfileEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val name: String,
+ val avatar: String,
+ val isKids: Boolean,
+ val createdAt: Long = System.currentTimeMillis()
+) {
+ companion object {
+ /** The profile every row that predates profiles belongs to (seeded by the migration). */
+ const val DEFAULT_ID = 1L
+ }
+}
diff --git a/app/src/main/java/com/ivor/openstream/data/local/entity/WatchLaterEntity.kt b/app/src/main/java/com/ivor/openstream/data/local/entity/WatchLaterEntity.kt
index b5b863e..8b306b2 100644
--- a/app/src/main/java/com/ivor/openstream/data/local/entity/WatchLaterEntity.kt
+++ b/app/src/main/java/com/ivor/openstream/data/local/entity/WatchLaterEntity.kt
@@ -1,15 +1,15 @@
package com.ivor.openstream.data.local.entity
import androidx.room.Entity
-import androidx.room.PrimaryKey
-@Entity(tableName = "watch_later")
+/** A title a profile saved for later; [id] is the TMDB id. */
+@Entity(tableName = "watch_later", primaryKeys = ["profileId", "id"])
data class WatchLaterEntity(
- @PrimaryKey
val id: Int,
val title: String,
val posterPath: String?,
val mediaType: String,
val voteAverage: Double,
- val dateAdded: Long = System.currentTimeMillis()
+ val dateAdded: Long = System.currentTimeMillis(),
+ val profileId: Long = ProfileEntity.DEFAULT_ID
)
diff --git a/app/src/main/java/com/ivor/openstream/data/local/entity/WatchProgressEntity.kt b/app/src/main/java/com/ivor/openstream/data/local/entity/WatchProgressEntity.kt
index 8f74d5a..c54ca2a 100644
--- a/app/src/main/java/com/ivor/openstream/data/local/entity/WatchProgressEntity.kt
+++ b/app/src/main/java/com/ivor/openstream/data/local/entity/WatchProgressEntity.kt
@@ -2,7 +2,6 @@ package com.ivor.openstream.data.local.entity
import androidx.room.Entity
import androidx.room.Index
-import androidx.room.PrimaryKey
/**
* One row per episode (or movie) the user has started. Rows with `positionMs == 0` and
@@ -10,10 +9,10 @@ import androidx.room.PrimaryKey
*/
@Entity(
tableName = "watch_progress",
- indices = [Index(value = ["mediaType", "tmdbId"])]
+ primaryKeys = ["profileId", "id"],
+ indices = [Index(value = ["profileId", "mediaType", "tmdbId"])]
)
data class WatchProgressEntity(
- @PrimaryKey
val id: String,
val tmdbId: Int,
val mediaType: String,
@@ -27,7 +26,8 @@ data class WatchProgressEntity(
val positionMs: Long,
val durationMs: Long,
val completed: Boolean,
- val updatedAt: Long
+ val updatedAt: Long,
+ val profileId: Long = ProfileEntity.DEFAULT_ID
) {
companion object {
fun idFor(mediaType: String, tmdbId: Int, season: Int, episode: Int): String =
diff --git a/app/src/main/java/com/ivor/openstream/data/remote/TmdbApi.kt b/app/src/main/java/com/ivor/openstream/data/remote/TmdbApi.kt
index 767baf3..95ee8ed 100644
--- a/app/src/main/java/com/ivor/openstream/data/remote/TmdbApi.kt
+++ b/app/src/main/java/com/ivor/openstream/data/remote/TmdbApi.kt
@@ -7,12 +7,22 @@ import com.ivor.openstream.data.remote.model.TmdbResponse
import com.ivor.openstream.data.remote.model.KeywordDto
import com.ivor.openstream.data.remote.model.ExternalIdsDto
import com.ivor.openstream.data.remote.model.PersonDto
+import com.ivor.openstream.data.remote.model.ContentRatingsDto
+import com.ivor.openstream.data.remote.model.ReleaseDatesDto
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.QueryMap
interface TmdbApi {
+ /** Per-country certifications (US "PG", "R"...) for a movie. */
+ @GET("movie/{id}/release_dates")
+ suspend fun getMovieReleaseDates(@Path("id") id: Int): ReleaseDatesDto
+
+ /** Per-country TV ratings (US "TV-Y7", "TV-MA"...) for a show. */
+ @GET("tv/{id}/content_ratings")
+ suspend fun getTvContentRatings(@Path("id") id: Int): ContentRatingsDto
+
@GET("{media_type}/{id}/external_ids")
suspend fun getExternalIds(
@Path("media_type") mediaType: String,
diff --git a/app/src/main/java/com/ivor/openstream/data/remote/model/AnimeDto.kt b/app/src/main/java/com/ivor/openstream/data/remote/model/AnimeDto.kt
index 9c0691d..6a3cb2a 100644
--- a/app/src/main/java/com/ivor/openstream/data/remote/model/AnimeDto.kt
+++ b/app/src/main/java/com/ivor/openstream/data/remote/model/AnimeDto.kt
@@ -17,7 +17,8 @@ data class AnimeDto(
@SerialName("genre_ids") val genreIds: List? = null,
@SerialName("media_type") val mediaType: String? = "tv",
@SerialName("original_language") val originalLanguage: String? = null,
- @SerialName("popularity") val popularity: Double? = null
+ @SerialName("popularity") val popularity: Double? = null,
+ @SerialName("adult") val adult: Boolean? = null
) {
val name: String
get() = movieTitle ?: tvName ?: ""
diff --git a/app/src/main/java/com/ivor/openstream/data/remote/model/CertificationDto.kt b/app/src/main/java/com/ivor/openstream/data/remote/model/CertificationDto.kt
new file mode 100644
index 0000000..8e98d2c
--- /dev/null
+++ b/app/src/main/java/com/ivor/openstream/data/remote/model/CertificationDto.kt
@@ -0,0 +1,25 @@
+package com.ivor.openstream.data.remote.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ReleaseDatesDto(val results: List = emptyList())
+
+@Serializable
+data class CountryReleaseDates(
+ @SerialName("iso_3166_1") val country: String = "",
+ @SerialName("release_dates") val releaseDates: List = emptyList()
+)
+
+@Serializable
+data class ReleaseDateDto(val certification: String = "")
+
+@Serializable
+data class ContentRatingsDto(val results: List = emptyList())
+
+@Serializable
+data class ContentRatingDto(
+ @SerialName("iso_3166_1") val country: String = "",
+ val rating: String = ""
+)
diff --git a/app/src/main/java/com/ivor/openstream/data/repository/AnimeRepositoryImpl.kt b/app/src/main/java/com/ivor/openstream/data/repository/AnimeRepositoryImpl.kt
index 423f9ef..a876f84 100644
--- a/app/src/main/java/com/ivor/openstream/data/repository/AnimeRepositoryImpl.kt
+++ b/app/src/main/java/com/ivor/openstream/data/repository/AnimeRepositoryImpl.kt
@@ -18,7 +18,8 @@ import javax.inject.Inject
class AnimeRepositoryImpl @Inject constructor(
private val api: TmdbApi,
private val sharedPreferences: SharedPreferences,
- private val json: Json
+ private val json: Json,
+ private val kids: KidsContentFilter
) : AnimeRepository {
private val HISTORY_KEY = "watch_history_list"
@@ -40,24 +41,27 @@ class AnimeRepositoryImpl @Inject constructor(
}
override suspend fun getCatalog(catalog: AnimeCatalog, forceRefresh: Boolean): Result> {
- val cached = catalogCache[catalog] ?: readCachedCatalog(catalog)?.also { catalogCache[catalog] = it }
+ // Kids profiles get their own filtered copy of each list.
+ val key = catalogKey(catalog)
+ val cached = catalogCache[key] ?: readCachedCatalog(key)?.also { catalogCache[key] = it }
val isFresh = cached != null && System.currentTimeMillis() - cached.savedAt < CATALOG_TTL_MS
if (!forceRefresh && isFresh) return Result.success(cached!!.items)
return fetchCatalog(catalog)
.onSuccess { items ->
val entry = CachedCatalog(System.currentTimeMillis(), items)
- catalogCache[catalog] = entry
- sharedPreferences.edit().putString(catalogKey(catalog), json.encodeToString(entry)).apply()
+ catalogCache[key] = entry
+ sharedPreferences.edit().putString(key, json.encodeToString(entry)).apply()
}
// Offline or rate limited: an older list beats an empty Home.
.recoverCatching { error -> cached?.items ?: throw error }
}
- private fun readCachedCatalog(catalog: AnimeCatalog): CachedCatalog? = runCatching {
- sharedPreferences.getString(catalogKey(catalog), null)?.let { json.decodeFromString(it) }
+ private fun readCachedCatalog(key: String): CachedCatalog? = runCatching {
+ sharedPreferences.getString(key, null)?.let { json.decodeFromString(it) }
}.getOrNull()
- private fun catalogKey(catalog: AnimeCatalog) = "catalog_cache_${catalog.name}"
+ private fun catalogKey(catalog: AnimeCatalog) =
+ if (kids.isActive) "catalog_cache_kids_${catalog.name}" else "catalog_cache_${catalog.name}"
private suspend fun fetchCatalog(catalog: AnimeCatalog): Result> = runCatching {
val anime = mapOf(
@@ -65,19 +69,21 @@ class AnimeRepositoryImpl @Inject constructor(
"with_original_language" to "ja",
"include_adult" to "false"
)
+ val movieKids = kids.movieDiscoverParams()
+ val tvKids = kids.tvDiscoverParams()
val results = when (catalog) {
AnimeCatalog.TRENDING -> api.getTrendingAll("week").results
// The mixed feed also lists people.
.filter { it.mediaType == "movie" || it.mediaType == "tv" }
AnimeCatalog.NEW_EPISODES -> api.getOnTheAir().results
AnimeCatalog.POPULAR_MOVIES -> api.discoverMovieWith(
- mapOf("sort_by" to "popularity.desc", "vote_count.gte" to "300", "include_adult" to "false")
+ mapOf("sort_by" to "popularity.desc", "vote_count.gte" to "300", "include_adult" to "false") + movieKids
).results.map { it.copy(mediaType = "movie") }
AnimeCatalog.POPULAR_SERIES -> api.discoverTvWith(
- mapOf("sort_by" to "popularity.desc", "vote_count.gte" to "200", "include_adult" to "false")
- ).results
+ mapOf("sort_by" to "popularity.desc", "vote_count.gte" to "200", "include_adult" to "false") + tvKids
+ ).results.map { it.copy(mediaType = "tv") }
AnimeCatalog.TOP_RATED_MOVIES -> api.discoverMovieWith(
- mapOf("sort_by" to "vote_average.desc", "vote_count.gte" to "3000", "include_adult" to "false")
+ mapOf("sort_by" to "vote_average.desc", "vote_count.gte" to "3000", "include_adult" to "false") + movieKids
).results.map { it.copy(mediaType = "movie") }
AnimeCatalog.TRENDING_ANIME -> coroutineScope {
// TMDB's trending feed cannot be filtered server-side, so read a few pages and keep anime.
@@ -85,12 +91,17 @@ class AnimeRepositoryImpl @Inject constructor(
.awaitAll()
.flatten()
.filter { it.originalLanguage == "ja" && it.genreIds.orEmpty().contains(ANIMATION_GENRE) }
- }.ifEmpty { api.discoverTvWith(anime + ("sort_by" to "popularity.desc")).results }
+ }.ifEmpty { api.discoverTvWith(anime + ("sort_by" to "popularity.desc") + tvKids).results }
AnimeCatalog.ANIME_MOVIES -> api.discoverMovieWith(
- anime + mapOf("sort_by" to "popularity.desc", "vote_count.gte" to "100")
+ anime + mapOf("sort_by" to "popularity.desc", "vote_count.gte" to "100") + movieKids
).results.map { it.copy(mediaType = "movie") }
}
- results.filter { it.posterPath != null }.distinctBy { "${it.mediaType}:${it.id}" }
+ val moviesPreFiltered = catalog == AnimeCatalog.POPULAR_MOVIES || catalog == AnimeCatalog.TOP_RATED_MOVIES ||
+ catalog == AnimeCatalog.ANIME_MOVIES
+ kids.filter(
+ results.filter { it.posterPath != null }.distinctBy { "${it.mediaType}:${it.id}" },
+ moviesPreFiltered = moviesPreFiltered
+ )
}
override suspend fun discoverByGenre(genre: BrowseGenre, page: Int): Result> = runCatching {
@@ -99,19 +110,20 @@ class AnimeRepositoryImpl @Inject constructor(
if (genre.isAnime) mapOf("with_original_language" to "ja") else emptyMap()
val movies = genre.movieGenreId?.let { id ->
async {
- api.discoverMovieWith(common + mapOf("with_genres" to "$id", "vote_count.gte" to "50"))
+ api.discoverMovieWith(common + mapOf("with_genres" to "$id", "vote_count.gte" to "50") + kids.movieDiscoverParams())
.results.map { it.copy(mediaType = "movie") }
}
}
val series = genre.tvGenreId?.let { id ->
async {
- api.discoverTvWith(common + mapOf("with_genres" to "$id", "vote_count.gte" to "30"))
+ api.discoverTvWith(common + mapOf("with_genres" to "$id", "vote_count.gte" to "30") + kids.tvDiscoverParams())
.results.map { it.copy(mediaType = "tv") }
}
}
- (movies?.await().orEmpty() + series?.await().orEmpty())
+ val combined = (movies?.await().orEmpty() + series?.await().orEmpty())
.filter { it.posterPath != null }
.sortedByDescending { it.popularity ?: 0.0 }
+ kids.filter(combined, moviesPreFiltered = true)
}
}
@@ -135,11 +147,13 @@ class AnimeRepositoryImpl @Inject constructor(
}
}
- AnimeSearchResults.prepare(
- tvShows = tvShows,
- movies = movies,
- sortBy = sortBy,
- query = query
+ kids.filter(
+ AnimeSearchResults.prepare(
+ tvShows = tvShows,
+ movies = movies,
+ sortBy = sortBy,
+ query = query
+ )
)
}
@@ -186,7 +200,7 @@ class AnimeRepositoryImpl @Inject constructor(
sharedPreferences.edit().remove(HISTORY_KEY).apply()
}
- private val catalogCache = java.util.concurrent.ConcurrentHashMap()
+ private val catalogCache = java.util.concurrent.ConcurrentHashMap()
@kotlinx.serialization.Serializable
private data class CachedCatalog(val savedAt: Long, val items: List)
diff --git a/app/src/main/java/com/ivor/openstream/data/repository/CombinedSubtitleRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/CombinedSubtitleRepository.kt
new file mode 100644
index 0000000..af5b9ee
--- /dev/null
+++ b/app/src/main/java/com/ivor/openstream/data/repository/CombinedSubtitleRepository.kt
@@ -0,0 +1,29 @@
+package com.ivor.openstream.data.repository
+
+import com.ivor.openstream.data.remote.model.SubtitleDto
+import com.ivor.openstream.domain.model.MediaIdentity
+import com.ivor.openstream.domain.repository.SubtitleRepository
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Every keyless subtitle source, searched in parallel: OpenSubtitles first, then SubSource.
+ * Each result keeps its source name, which the player shows under the language.
+ */
+@Singleton
+class CombinedSubtitleRepository @Inject constructor(
+ private val openSubtitles: OpenSubtitlesRepository,
+ private val subSource: SubSourceRepository
+) : SubtitleRepository {
+ override suspend fun search(identity: MediaIdentity): List = coroutineScope {
+ val fromOpenSubtitles = async { runCatching { openSubtitles.search(identity) }.getOrDefault(emptyList()) }
+ val fromSubSource = async { subSource.search(identity) }
+ (fromOpenSubtitles.await() + fromSubSource.await())
+ .distinctBy { it.url }
+ // The same release uploaded to both sites: keep the first copy.
+ .distinctBy { listOf(it.language, it.release?.lowercase()?.trim(), it.isHearingImpaired).takeIf { _ -> it.release != null } ?: it.url }
+ .sortedBy { if (it.language == "en") 0 else 1 }
+ }
+}
diff --git a/app/src/main/java/com/ivor/openstream/data/repository/CustomListRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/CustomListRepository.kt
new file mode 100644
index 0000000..c5ca67a
--- /dev/null
+++ b/app/src/main/java/com/ivor/openstream/data/repository/CustomListRepository.kt
@@ -0,0 +1,47 @@
+package com.ivor.openstream.data.repository
+
+import com.ivor.openstream.data.local.dao.CustomListDao
+import com.ivor.openstream.data.local.dao.CustomListSummary
+import com.ivor.openstream.data.local.entity.CustomListEntity
+import com.ivor.openstream.data.local.entity.CustomListItemEntity
+import com.ivor.openstream.data.settings.AppSettingsStore
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** The active profile's own lists of titles, kept alongside (not instead of) Watch Later. */
+@OptIn(ExperimentalCoroutinesApi::class)
+@Singleton
+class CustomListRepository @Inject constructor(
+ private val dao: CustomListDao,
+ private val settings: AppSettingsStore
+) {
+ private val profileId: Long get() = settings.activeProfileId.value
+
+ fun summaries(): Flow> = settings.activeProfileId.flatMapLatest { dao.observeSummaries(it) }
+
+ fun list(listId: Long): Flow = dao.observeList(listId)
+
+ fun items(listId: Long): Flow> = dao.observeItems(listId)
+
+ /** Ids of the lists a title is in. */
+ fun listIdsFor(mediaType: String, tmdbId: Int): Flow> =
+ settings.activeProfileId.flatMapLatest { dao.observeListIdsFor(it, mediaType, tmdbId) }.map { it.toSet() }
+
+ /** Creates a list; a name that already exists (ignoring case) returns that list instead. */
+ suspend fun create(name: String): Long {
+ val trimmed = name.trim()
+ return dao.findByName(profileId, trimmed) ?: dao.insertList(CustomListEntity(name = trimmed, profileId = profileId))
+ }
+
+ suspend fun rename(listId: Long, name: String) = dao.rename(listId, name.trim())
+
+ suspend fun delete(listId: Long) = dao.deleteList(listId)
+
+ suspend fun add(item: CustomListItemEntity) = dao.addItem(item)
+
+ suspend fun remove(listId: Long, mediaType: String, tmdbId: Int) = dao.removeItem(listId, mediaType, tmdbId)
+}
diff --git a/app/src/main/java/com/ivor/openstream/data/repository/DownloadRepositoryImpl.kt b/app/src/main/java/com/ivor/openstream/data/repository/DownloadRepositoryImpl.kt
index ba1ceb7..99230e0 100644
--- a/app/src/main/java/com/ivor/openstream/data/repository/DownloadRepositoryImpl.kt
+++ b/app/src/main/java/com/ivor/openstream/data/repository/DownloadRepositoryImpl.kt
@@ -184,7 +184,7 @@ class DownloadRepositoryImpl @Inject constructor(
headerStore.register(server.url, server.headers)
val request = DownloadRequest.Builder(target.id, Uri.parse(streamUrl))
- .setMimeType(if (streamUrl.isHls()) MimeTypes.APPLICATION_M3U8 else null)
+ .setMimeType(if (server.isHls || streamUrl.isHls()) MimeTypes.APPLICATION_M3U8 else null)
.build()
dao.insertDownload(
@@ -216,7 +216,7 @@ class DownloadRepositoryImpl @Inject constructor(
* separate renditions (then keep the master).
*/
private fun singleRenditionUrl(server: VideoServer): String {
- if (!server.url.isHls()) return server.url
+ if (!server.isHls && !server.url.isHls()) return server.url
val body = runCatching {
client.newCall(
Request.Builder()
diff --git a/app/src/main/java/com/ivor/openstream/data/repository/HiddenTitlesRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/HiddenTitlesRepository.kt
index 54158fa..8811336 100644
--- a/app/src/main/java/com/ivor/openstream/data/repository/HiddenTitlesRepository.kt
+++ b/app/src/main/java/com/ivor/openstream/data/repository/HiddenTitlesRepository.kt
@@ -2,26 +2,34 @@ package com.ivor.openstream.data.repository
import com.ivor.openstream.data.local.dao.HiddenTitleDao
import com.ivor.openstream.data.local.entity.HiddenTitleEntity
+import com.ivor.openstream.data.settings.AppSettingsStore
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
-/** Titles the user said they're not interested in, keyed as "mediaType:tmdbId". */
+/** Titles the active profile said it's not interested in, keyed as "mediaType:tmdbId". */
+@OptIn(ExperimentalCoroutinesApi::class)
@Singleton
class HiddenTitlesRepository @Inject constructor(
- private val dao: HiddenTitleDao
+ private val dao: HiddenTitleDao,
+ private val settings: AppSettingsStore
) {
- val hiddenKeys: Flow> = dao.observeAll().map { rows -> rows.mapTo(HashSet()) { key(it.mediaType, it.tmdbId) } }
+ private val rows = settings.activeProfileId.flatMapLatest { dao.observeAll(it) }
- val count: Flow = dao.observeAll().map { it.size }
+ val hiddenKeys: Flow> = rows.map { list -> list.mapTo(HashSet()) { key(it.mediaType, it.tmdbId) } }
- suspend fun hide(mediaType: String, tmdbId: Int, title: String) =
- dao.insert(HiddenTitleEntity(tmdbId = tmdbId, mediaType = mediaType, title = title))
+ val count: Flow = rows.map { it.size }
- suspend fun unhide(mediaType: String, tmdbId: Int) = dao.delete(mediaType, tmdbId)
+ suspend fun hide(mediaType: String, tmdbId: Int, title: String) = dao.insert(
+ HiddenTitleEntity(tmdbId = tmdbId, mediaType = mediaType, title = title, profileId = settings.activeProfileId.value)
+ )
- suspend fun unhideAll() = dao.clear()
+ suspend fun unhide(mediaType: String, tmdbId: Int) = dao.delete(settings.activeProfileId.value, mediaType, tmdbId)
+
+ suspend fun unhideAll() = dao.clear(settings.activeProfileId.value)
companion object {
fun key(mediaType: String, tmdbId: Int) = "$mediaType:$tmdbId"
diff --git a/app/src/main/java/com/ivor/openstream/data/repository/KidsContentFilter.kt b/app/src/main/java/com/ivor/openstream/data/repository/KidsContentFilter.kt
new file mode 100644
index 0000000..cbe291e
--- /dev/null
+++ b/app/src/main/java/com/ivor/openstream/data/repository/KidsContentFilter.kt
@@ -0,0 +1,82 @@
+package com.ivor.openstream.data.repository
+
+import com.ivor.openstream.data.remote.TmdbApi
+import com.ivor.openstream.data.remote.model.AnimeDto
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * What a kids profile may see: US certification G/PG for movies, TV-Y/TV-Y7/TV-G/TV-PG for shows.
+ *
+ * Discover requests carry TMDB's `certification_country`/`certification.lte` filters. Lists TMDB
+ * can't filter (trending, airing, search) and shows are checked title by title against their US
+ * rating (`release_dates` / `content_ratings`); a title without a US rating is left out.
+ */
+@Singleton
+class KidsContentFilter @Inject constructor(
+ private val api: TmdbApi,
+ private val profiles: ProfileRepository
+) {
+ private val verdicts = ConcurrentHashMap()
+ private val permits = Semaphore(6)
+
+ val isActive: Boolean get() = profiles.isKidsActive
+
+ /** Extra query parameters for `discover/movie` while a kids profile is active. */
+ fun movieDiscoverParams(): Map =
+ if (isActive) mapOf("certification_country" to "US", "certification.lte" to "PG") else emptyMap()
+
+ /** Extra query parameters for `discover/tv` while a kids profile is active. */
+ fun tvDiscoverParams(): Map =
+ if (isActive) mapOf("certification_country" to "US", "certification.lte" to "TV-PG") else emptyMap()
+
+ /** The titles a kids profile may see, in order; everything when no kids profile is active. */
+ suspend fun filter(items: List, moviesPreFiltered: Boolean = false): List {
+ if (!isActive || items.isEmpty()) return items
+ return coroutineScope {
+ items.map { item ->
+ async {
+ val allowed = when {
+ item.adult == true -> false
+ item.isMovie && moviesPreFiltered -> true
+ else -> permits.withPermit { isAllowed(item) }
+ }
+ item.takeIf { allowed }
+ }
+ }.awaitAll().filterNotNull()
+ }
+ }
+
+ private suspend fun isAllowed(item: AnimeDto): Boolean {
+ val key = "${if (item.isMovie) "movie" else "tv"}:${item.id}"
+ verdicts[key]?.let { return it }
+ val verdict = runCatching {
+ if (item.isMovie) {
+ api.getMovieReleaseDates(item.id).results
+ .firstOrNull { it.country == "US" }
+ ?.releaseDates?.map { it.certification.trim() }?.filter { it.isNotEmpty() }
+ ?.let { certifications -> certifications.isNotEmpty() && certifications.all { it in MOVIE_RATINGS } }
+ ?: false
+ } else {
+ api.getTvContentRatings(item.id).results
+ .firstOrNull { it.country == "US" }
+ ?.rating?.trim()
+ ?.let { it in TV_RATINGS }
+ ?: false
+ }
+ }.getOrNull() ?: return false // Network trouble: hide it now, ask again next time.
+ verdicts[key] = verdict
+ return verdict
+ }
+
+ private companion object {
+ val MOVIE_RATINGS = setOf("G", "PG")
+ val TV_RATINGS = setOf("TV-Y", "TV-Y7", "TV-G", "TV-PG")
+ }
+}
diff --git a/app/src/main/java/com/ivor/openstream/data/repository/ProfileRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/ProfileRepository.kt
new file mode 100644
index 0000000..36cfda0
--- /dev/null
+++ b/app/src/main/java/com/ivor/openstream/data/repository/ProfileRepository.kt
@@ -0,0 +1,86 @@
+package com.ivor.openstream.data.repository
+
+import com.ivor.openstream.data.local.dao.ProfileDao
+import com.ivor.openstream.data.local.entity.ProfileEntity
+import com.ivor.openstream.data.settings.AppSettingsStore
+import com.ivor.openstream.domain.model.Profile
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Profiles and which one is active. The active id lives in [AppSettingsStore]; every library
+ * repository follows it. There is always at least one profile.
+ */
+@Singleton
+class ProfileRepository @Inject constructor(
+ private val dao: ProfileDao,
+ private val settings: AppSettingsStore
+) {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ val profiles: StateFlow?> = dao.observeAll()
+ .map { rows -> rows.map { it.toDomain() } }
+ .stateIn(scope, SharingStarted.Eagerly, null)
+
+ /** The active profile; null only until the table has loaded. */
+ val activeProfile: StateFlow = combine(profiles, settings.activeProfileId) { list, id ->
+ list?.let { all -> all.firstOrNull { it.id == id } ?: all.firstOrNull() }
+ }.stateIn(scope, SharingStarted.Eagerly, null)
+
+ val activeProfileId: StateFlow get() = settings.activeProfileId
+
+ val isKidsActive: Boolean get() = activeProfile.value?.isKids == true
+
+ init {
+ // A deleted or unknown active id falls back to the first profile; an empty table gets one.
+ scope.launch {
+ if (dao.all().isEmpty()) {
+ dao.insert(ProfileEntity(id = ProfileEntity.DEFAULT_ID, name = DEFAULT_NAME, avatar = DEFAULT_AVATAR, isKids = false))
+ }
+ val all = dao.all()
+ if (all.none { it.id == settings.activeProfileId.value }) all.firstOrNull()?.let { settings.setActiveProfile(it.id) }
+ }
+ }
+
+ fun switchTo(id: Long) = settings.setActiveProfile(id)
+
+ suspend fun create(name: String, avatar: String, isKids: Boolean): Long =
+ dao.insert(ProfileEntity(name = name.trim(), avatar = avatar, isKids = isKids))
+
+ suspend fun update(id: Long, name: String, avatar: String, isKids: Boolean) =
+ dao.update(id, name.trim(), avatar, isKids)
+
+ /**
+ * Deletes a profile and its library. The last profile can't be deleted; deleting the active
+ * one switches to the first remaining. Returns false when nothing was deleted.
+ */
+ suspend fun delete(id: Long): Boolean {
+ val all = dao.all()
+ if (all.size <= 1 || all.none { it.id == id }) return false
+ dao.deleteWithData(id)
+ if (settings.activeProfileId.value == id) all.first { it.id != id }.let { settings.setActiveProfile(it.id) }
+ return true
+ }
+
+ suspend fun all(): List = dao.all().map { it.toDomain() }
+
+ /** Finds a profile by name or makes it; used when restoring backups. */
+ suspend fun findOrCreate(name: String, avatar: String, isKids: Boolean): Long =
+ dao.findByName(name.trim()) ?: create(name, avatar, isKids)
+
+ private fun ProfileEntity.toDomain() = Profile(id, name, avatar, isKids)
+
+ companion object {
+ const val DEFAULT_NAME = "Main"
+ const val DEFAULT_AVATAR = "fox"
+ }
+}
diff --git a/app/src/main/java/com/ivor/openstream/data/repository/SkipTimesRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/SkipTimesRepository.kt
index 5c3b0d6..672c693 100644
--- a/app/src/main/java/com/ivor/openstream/data/repository/SkipTimesRepository.kt
+++ b/app/src/main/java/com/ivor/openstream/data/repository/SkipTimesRepository.kt
@@ -1,6 +1,11 @@
package com.ivor.openstream.data.repository
import android.util.Log
+import com.ivor.openstream.data.streaming.BROWSER_USER_AGENT
+import com.ivor.openstream.data.streaming.anime.AnimeEpisodeMapper
+import com.ivor.openstream.domain.model.MediaIdentity
+import com.ivor.openstream.domain.model.SkipSegment
+import com.ivor.openstream.domain.model.SkipType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
@@ -17,37 +22,51 @@ import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
-enum class SkipType(val label: String) {
- INTRO("Skip intro"),
- RECAP("Skip recap"),
- CREDITS("Skip credits")
-}
-
-/** A stretch of an episode the player offers to jump past. */
-data class SkipSegment(val type: SkipType, val startMs: Long, val endMs: Long)
-
/**
* Intro, recap and credits times for anime from AniSkip, a community database keyed by MyAnimeList
- * id. TMDB doesn't carry MAL ids, so the id comes from AniList's public search, matched on title and,
- * for later seasons, the year the season started airing.
+ * id and the episode number within that MAL entry.
+ *
+ * TMDB doesn't carry MAL ids, and TMDB seasons often don't line up with MAL entries (split cours,
+ * arc-based seasons, absolute numbering). So the lookup goes through [AnimeEpisodeMapper] first,
+ * the same TMDB -> AniList mapping the anime sources use (ani.zip + AniList), which also gives the
+ * episode number inside the entry. Only titles it can't map fall back to AniList's title search,
+ * matched on format and the year the season started airing.
*
- * Both are keyless public APIs whose behaviour isn't formally documented or guaranteed: AniList
- * GraphQL (graphql.anilist.co, rate limited to about 90 requests a minute) and AniSkip v2
+ * All of these are keyless public APIs whose behaviour isn't formally documented or guaranteed:
+ * api.ani.zip, AniList GraphQL (graphql.anilist.co, about 90 requests a minute) and AniSkip v2
* (api.aniskip.com). Any failure just means no skip button.
*/
@Singleton
class SkipTimesRepository @Inject constructor(
@Named("StreamingClient") private val client: OkHttpClient,
- private val json: Json
+ private val json: Json,
+ private val episodeMapper: AnimeEpisodeMapper
) {
private val malIds = ConcurrentHashMap()
- suspend fun segmentsFor(title: String, isMovie: Boolean, seasonYear: Int?, episode: Int): List =
+ /**
+ * @param identity the TMDB title, season and episode being played.
+ * @param seasonYear the year the TMDB season started airing, for the title-search fallback.
+ */
+ suspend fun segmentsFor(identity: MediaIdentity, seasonYear: Int?): List =
withContext(Dispatchers.IO) {
+ val isMovie = identity.tmdbType == "movie"
+ val mapped = runCatching { episodeMapper.map(identity) }
+ .onFailure { Log.w(TAG, "Episode mapping failed for ${identity.title}: ${it.message}") }
+ .getOrNull()
+ val fromMapping = mapped?.malId?.let { malId ->
+ runCatching { skipTimes(malId, if (isMovie) 1 else mapped.episode) }
+ .onFailure { Log.w(TAG, "AniSkip failed for MAL $malId: ${it.message}") }
+ .getOrNull()
+ }
+ if (!fromMapping.isNullOrEmpty()) return@withContext fromMapping
+ // Mapped to a MAL entry that simply has no times: a title search would only guess worse.
+ if (mapped?.malId != null && fromMapping != null) return@withContext emptyList()
+
runCatching {
- val malId = malIdFor(title, isMovie, seasonYear) ?: return@runCatching emptyList()
- skipTimes(malId, if (isMovie) 1 else episode)
- }.onFailure { Log.w(TAG, "No skip times for $title: ${it.message}") }
+ val malId = malIdFor(identity.title, isMovie, seasonYear) ?: return@runCatching emptyList()
+ skipTimes(malId, if (isMovie) 1 else identity.episode)
+ }.onFailure { Log.w(TAG, "No skip times for ${identity.title}: ${it.message}") }
.getOrDefault(emptyList())
}
@@ -63,13 +82,20 @@ class SkipTimesRepository @Inject constructor(
.url("https://graphql.anilist.co")
.post(body.toRequestBody("application/json".toMediaType()))
.header("Accept", "application/json")
+ .header("User-Agent", BROWSER_USER_AGENT)
.build()
val text = client.newCall(request).execute().use { if (it.isSuccessful) it.body?.string() else null } ?: return null
val media = json.decodeFromString(AniListResponse.serializer(), text).data?.page?.media.orEmpty()
.filter { it.idMal != null }
- val formats = if (isMovie) setOf("MOVIE") else setOf("TV", "TV_SHORT", "ONA")
- val candidates = media.filter { it.format in formats }.ifEmpty { media }
+ // Full series first: ONA shorts and specials often rank above the show itself
+ // ("Sousou no Frieren: ●● no Mahou" comes before "Sousou no Frieren").
+ val formatGroups = if (isMovie) listOf(setOf("MOVIE")) else listOf(setOf("TV"), setOf("TV_SHORT", "ONA"))
+ val candidates = formatGroups.asSequence()
+ .map { formats -> media.filter { it.format in formats } }
+ .firstOrNull { group -> group.isNotEmpty() && (seasonYear == null || group.any { it.startDate?.year == seasonYear }) }
+ ?: formatGroups.asSequence().map { formats -> media.filter { it.format in formats } }.firstOrNull { it.isNotEmpty() }
+ ?: return null
// Later seasons are separate AniList entries; the one that started the year the TMDB season did wins.
val chosen = seasonYear?.let { year -> candidates.firstOrNull { it.startDate?.year == year } }
?: candidates.firstOrNull()
@@ -79,9 +105,16 @@ class SkipTimesRepository @Inject constructor(
private fun skipTimes(malId: Int, episode: Int): List {
val url = "https://api.aniskip.com/v2/skip-times/$malId/$episode" +
- "?types[]=op&types[]=ed&types[]=recap&types[]=mixed-op&types[]=mixed-ed&episodeLength=0"
- val text = client.newCall(Request.Builder().url(url).build()).execute()
- .use { if (it.isSuccessful) it.body?.string() else null } ?: return emptyList()
+ "?types=op&types=ed&types=recap&types=mixed-op&types=mixed-ed&episodeLength=0"
+ val request = Request.Builder().url(url).header("User-Agent", BROWSER_USER_AGENT).build()
+ val text = client.newCall(request).execute().use { response ->
+ when {
+ response.isSuccessful -> response.body?.string()
+ // AniSkip answers 404 when an episode has no submissions.
+ response.code == 404 -> return emptyList()
+ else -> throw java.io.IOException("AniSkip returned HTTP ${response.code}")
+ }
+ } ?: return emptyList()
val response = json.decodeFromString(AniSkipResponse.serializer(), text)
if (!response.found) return emptyList()
return response.results.mapNotNull { result ->
@@ -92,8 +125,12 @@ class SkipTimesRepository @Inject constructor(
else -> return@mapNotNull null
}
val interval = result.interval ?: return@mapNotNull null
- SkipSegment(type, (interval.startTime * 1000).toLong(), (interval.endTime * 1000).toLong())
- .takeIf { it.endMs > it.startMs }
+ SkipSegment(
+ type = type,
+ startMs = (interval.startTime * 1000).toLong(),
+ endMs = (interval.endTime * 1000).toLong(),
+ episodeLengthMs = (result.episodeLength * 1000).toLong()
+ ).takeIf { it.endMs > it.startMs }
}.sortedBy { it.startMs }
}
@@ -124,7 +161,11 @@ private data class AniListDate(val year: Int? = null)
private data class AniSkipResponse(val found: Boolean = false, val results: List = emptyList())
@Serializable
-private data class AniSkipResult(val skipType: String? = null, val interval: AniSkipInterval? = null)
+private data class AniSkipResult(
+ val skipType: String? = null,
+ val interval: AniSkipInterval? = null,
+ val episodeLength: Double = 0.0
+)
@Serializable
private data class AniSkipInterval(val startTime: Double = 0.0, val endTime: Double = 0.0)
diff --git a/app/src/main/java/com/ivor/openstream/data/repository/SubSourceRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/SubSourceRepository.kt
new file mode 100644
index 0000000..0b8e9a5
--- /dev/null
+++ b/app/src/main/java/com/ivor/openstream/data/repository/SubSourceRepository.kt
@@ -0,0 +1,177 @@
+package com.ivor.openstream.data.repository
+
+import android.util.Log
+import com.ivor.openstream.data.remote.model.SubtitleDto
+import com.ivor.openstream.data.streaming.BROWSER_USER_AGENT
+import com.ivor.openstream.data.streaming.IdMappingService
+import com.ivor.openstream.domain.model.MediaIdentity
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.intOrNull
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.put
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.io.IOException
+import java.util.Locale
+import javax.inject.Inject
+import javax.inject.Named
+import javax.inject.Singleton
+
+/**
+ * Subtitles from SubSource (subsource.net), keyless. SubSource has no public API contract: this
+ * mirrors what its own website calls (api.subsource.net/v1), which can change without notice.
+ *
+ * Search by IMDb id -> the title's `link` (`/subtitles/` for movies, `/series/` for
+ * shows, where a season is `/subtitles//season-N`) -> the list, filtered by language. A
+ * subtitle's URL here is its detail endpoint; [resolveDownloadUrl] trades that for a short-lived
+ * download token when the file is actually needed, and the file comes back as a zip.
+ */
+@Singleton
+class SubSourceRepository @Inject constructor(
+ private val idMappingService: IdMappingService,
+ @Named("StreamingClient") private val client: OkHttpClient,
+ private val json: Json
+) {
+ suspend fun search(identity: MediaIdentity): List = withContext(Dispatchers.IO) {
+ runCatching { searchOrThrow(identity) }
+ .onFailure { Log.w(TAG, "SubSource search failed: ${it.message}") }
+ .getOrDefault(emptyList())
+ }
+
+ private suspend fun searchOrThrow(identity: MediaIdentity): List {
+ val imdbId = idMappingService.enrich(identity).imdbId?.takeIf { it.startsWith("tt") } ?: return emptyList()
+ val isMovie = identity.tmdbType == "movie"
+ val link = findLink(imdbId) ?: return emptyList()
+ val listPath = if (isMovie) {
+ link
+ } else {
+ link.replaceFirst("/series/", "/subtitles/").trimEnd('/') + "/season-${identity.season}"
+ }
+ val episodePattern = if (isMovie) null else episodeRegex(identity.season, identity.episode)
+
+ return coroutineScope {
+ languages().map { (name, code) ->
+ async { list(listPath, name).map { it to code } }
+ }.awaitAll().flatten()
+ }
+ .filter { (row, _) -> row.releaseType != "trailer" }
+ .filter { (row, _) -> episodePattern == null || episodePattern.containsMatchIn(row.releaseInfo) }
+ // Multi-episode packs ("S01E02-E05") hold several files; the wrong one could be picked.
+ .filter { (row, _) -> episodePattern == null || !EPISODE_RANGE.containsMatchIn(row.releaseInfo) }
+ .distinctBy { (row, _) -> row.id }
+ .groupBy { (row, _) -> row.language }
+ .flatMap { (_, sameLanguage) -> sameLanguage.take(MAX_PER_LANGUAGE) }
+ .map { (row, code) ->
+ SubtitleDto(
+ id = "ss_${row.id}",
+ url = "$API/subtitle/${row.link}",
+ display = languageLabel(row.language) + if (row.hearingImpaired) " (SDH)" else "",
+ language = code,
+ isHearingImpaired = row.hearingImpaired,
+ source = SOURCE_NAME,
+ release = row.releaseInfo
+ )
+ }
+ }
+
+ private fun findLink(imdbId: String): String? {
+ val body = buildJsonObject {
+ put("query", imdbId)
+ put("signal", "{}")
+ put("includeSeasons", false)
+ put("limit", 15)
+ }.toString()
+ val request = Request.Builder()
+ .url("$API/movie/search")
+ .header("User-Agent", BROWSER_USER_AGENT)
+ .post(body.toRequestBody("application/json".toMediaType()))
+ .build()
+ val text = client.newCall(request).execute().use { if (it.isSuccessful) it.body?.string() else null } ?: return null
+ return json.parseToJsonElement(text).jsonObject["results"]?.jsonArray
+ ?.firstOrNull()?.jsonObject?.get("link")?.jsonPrimitive?.contentOrNull
+ ?.takeIf { it.startsWith("/") }
+ }
+
+ private fun list(path: String, language: String): List {
+ val request = Request.Builder()
+ .url("$API$path?language=$language")
+ .header("User-Agent", BROWSER_USER_AGENT)
+ .build()
+ val text = client.newCall(request).execute().use { if (it.isSuccessful) it.body?.string() else null } ?: return emptyList()
+ return json.parseToJsonElement(text).jsonObject["subtitles"]?.jsonArray.orEmpty().mapNotNull { element ->
+ val row = element as? JsonObject ?: return@mapNotNull null
+ Row(
+ id = row["id"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null,
+ language = row["language"]?.jsonPrimitive?.contentOrNull.orEmpty(),
+ releaseType = row["release_type"]?.jsonPrimitive?.contentOrNull,
+ releaseInfo = row["release_info"]?.jsonPrimitive?.contentOrNull.orEmpty(),
+ hearingImpaired = row["hearing_impaired"]?.jsonPrimitive?.intOrNull == 1,
+ link = row["link"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
+ )
+ }.filter { it.language.equals(language, ignoreCase = true) }
+ }
+
+ /** English, plus the phone's language when it isn't English: SubSource names languages in English. */
+ private fun languages(): List> {
+ val device = Locale.getDefault()
+ val deviceName = device.getDisplayLanguage(Locale.ENGLISH).lowercase(Locale.ROOT)
+ return listOfNotNull(
+ "english" to "en",
+ (deviceName to device.language).takeIf { deviceName.isNotBlank() && device.language != "en" }
+ )
+ }
+
+ private fun languageLabel(name: String) =
+ name.replace('_', ' ').replaceFirstChar { it.titlecase(Locale.ENGLISH) }
+
+ private data class Row(
+ val id: Int,
+ val language: String,
+ val releaseType: String?,
+ val releaseInfo: String,
+ val hearingImpaired: Boolean,
+ val link: String
+ )
+
+ companion object {
+ const val SOURCE_NAME = "SubSource"
+ private const val TAG = "SubSource"
+ private const val API = "https://api.subsource.net/v1"
+ private const val MAX_PER_LANGUAGE = 4
+ private val EPISODE_RANGE = Regex("(?i)e\\d+\\s*-\\s*e?\\d+")
+
+ /** `S01E05`, `s1.e5`, `1x05` or a bare `E05`, not followed by another digit. */
+ internal fun episodeRegex(season: Int, episode: Int): Regex = Regex(
+ "(?i)(s0*$season[ ._-]*e0*$episode(?!\\d))|((?
+ if (!response.isSuccessful) throw IOException("SubSource returned HTTP ${response.code}")
+ response.body?.string().orEmpty()
+ }
+ val token = Json.parseToJsonElement(text).jsonObject["subtitle"]?.jsonObject
+ ?.get("download_token")?.jsonPrimitive?.contentOrNull
+ ?: throw IOException("SubSource gave no download token")
+ return "$API/subtitle/download/$token"
+ }
+ }
+}
diff --git a/app/src/main/java/com/ivor/openstream/data/repository/WatchLaterRepositoryImpl.kt b/app/src/main/java/com/ivor/openstream/data/repository/WatchLaterRepositoryImpl.kt
index 691112f..dfb8383 100644
--- a/app/src/main/java/com/ivor/openstream/data/repository/WatchLaterRepositoryImpl.kt
+++ b/app/src/main/java/com/ivor/openstream/data/repository/WatchLaterRepositoryImpl.kt
@@ -2,31 +2,37 @@ package com.ivor.openstream.data.repository
import com.ivor.openstream.data.local.dao.WatchLaterDao
import com.ivor.openstream.data.local.entity.WatchLaterEntity
+import com.ivor.openstream.data.settings.AppSettingsStore
import com.ivor.openstream.domain.repository.WatchLaterRepository
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flatMapLatest
import javax.inject.Inject
+/** Watch Later of the active profile; reads follow profile switches. */
+@OptIn(ExperimentalCoroutinesApi::class)
class WatchLaterRepositoryImpl @Inject constructor(
- private val dao: WatchLaterDao
+ private val dao: WatchLaterDao,
+ private val settings: AppSettingsStore
) : WatchLaterRepository {
- override fun getWatchLaterList(): Flow> {
- return dao.getAllWatchLaterItems()
- }
+ private val profileId: Long get() = settings.activeProfileId.value
- override fun isWatchLater(id: Int): Flow {
- return dao.isWatchLater(id)
- }
+ override fun getWatchLaterList(): Flow> =
+ settings.activeProfileId.flatMapLatest { dao.getAllWatchLaterItems(it) }
+
+ override fun isWatchLater(id: Int): Flow =
+ settings.activeProfileId.flatMapLatest { dao.isWatchLater(it, id) }
override suspend fun addToWatchLater(item: WatchLaterEntity) {
- dao.insertWatchLaterItem(item)
+ dao.insertWatchLaterItem(item.copy(profileId = profileId))
}
override suspend fun removeFromWatchLater(item: WatchLaterEntity) {
- dao.deleteWatchLaterItem(item)
+ dao.deleteWatchLaterItemById(item.profileId, item.id)
}
override suspend fun removeFromWatchLaterById(id: Int) {
- dao.deleteWatchLaterItemById(id)
+ dao.deleteWatchLaterItemById(profileId, id)
}
}
diff --git a/app/src/main/java/com/ivor/openstream/data/repository/WatchProgressRepositoryImpl.kt b/app/src/main/java/com/ivor/openstream/data/repository/WatchProgressRepositoryImpl.kt
index 3663b96..8d38e8b 100644
--- a/app/src/main/java/com/ivor/openstream/data/repository/WatchProgressRepositoryImpl.kt
+++ b/app/src/main/java/com/ivor/openstream/data/repository/WatchProgressRepositoryImpl.kt
@@ -3,7 +3,10 @@ package com.ivor.openstream.data.repository
import com.ivor.openstream.data.local.dao.WatchProgressDao
import com.ivor.openstream.data.local.entity.WatchProgressEntity
import com.ivor.openstream.domain.model.WatchProgress
+import com.ivor.openstream.data.settings.AppSettingsStore
import com.ivor.openstream.domain.repository.WatchProgressRepository
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -13,42 +16,52 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
+/** Progress of the active profile; reads follow profile switches, writes go to the active one. */
+@OptIn(ExperimentalCoroutinesApi::class)
@Singleton
class WatchProgressRepositoryImpl @Inject constructor(
- private val dao: WatchProgressDao
+ private val dao: WatchProgressDao,
+ private val settings: AppSettingsStore
) : WatchProgressRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val profileId: Long get() = settings.activeProfileId.value
+
override fun continueWatching(limit: Int): Flow> =
- dao.observeContinueWatching(limit).map { rows -> rows.map { it.toDomain() } }
+ settings.activeProfileId.flatMapLatest { dao.observeContinueWatching(it, limit) }
+ .map { rows -> rows.map { it.toDomain() } }
override fun allProgress(): Flow> =
- dao.observeAll().map { rows -> rows.map { it.toDomain() } }
+ settings.activeProfileId.flatMapLatest { dao.observeAll(it) }
+ .map { rows -> rows.map { it.toDomain() } }
override fun progressForTitle(mediaType: String, tmdbId: Int): Flow> =
- dao.observeForTitle(mediaType, tmdbId).map { rows -> rows.map { it.toDomain() } }
+ settings.activeProfileId.flatMapLatest { dao.observeForTitle(it, mediaType, tmdbId) }
+ .map { rows -> rows.map { it.toDomain() } }
override suspend fun get(mediaType: String, tmdbId: Int, season: Int, episode: Int): WatchProgress? =
- dao.get(WatchProgressEntity.idFor(mediaType, tmdbId, season, episode))?.toDomain()
+ dao.get(profileId, WatchProgressEntity.idFor(mediaType, tmdbId, season, episode))?.toDomain()
override fun record(progress: WatchProgress) {
- scope.launch { dao.upsert(progress.toEntity()) }
+ // The profile is read now, not when the write runs, so a switch can't redirect it.
+ val entity = progress.toEntity(profileId)
+ scope.launch { dao.upsert(entity) }
}
override suspend fun dismiss(mediaType: String, tmdbId: Int) {
- dao.deleteUnfinished(mediaType, tmdbId)
+ dao.deleteUnfinished(profileId, mediaType, tmdbId)
}
override suspend fun clearTitle(mediaType: String, tmdbId: Int) {
- dao.deleteForTitle(mediaType, tmdbId)
+ dao.deleteForTitle(profileId, mediaType, tmdbId)
}
override suspend fun clearEpisode(mediaType: String, tmdbId: Int, season: Int, episode: Int) {
- dao.delete(WatchProgressEntity.idFor(mediaType, tmdbId, season, episode))
+ dao.delete(profileId, WatchProgressEntity.idFor(mediaType, tmdbId, season, episode))
}
override suspend fun clearAll() {
- dao.clear()
+ dao.clear(profileId)
}
private fun WatchProgressEntity.toDomain() = WatchProgress(
@@ -67,7 +80,7 @@ class WatchProgressRepositoryImpl @Inject constructor(
updatedAt = updatedAt
)
- private fun WatchProgress.toEntity() = WatchProgressEntity(
+ private fun WatchProgress.toEntity(profileId: Long) = WatchProgressEntity(
id = WatchProgressEntity.idFor(mediaType, tmdbId, season, episode),
tmdbId = tmdbId,
mediaType = mediaType,
@@ -81,6 +94,7 @@ class WatchProgressRepositoryImpl @Inject constructor(
positionMs = positionMs,
durationMs = durationMs,
completed = completed,
- updatedAt = updatedAt
+ updatedAt = updatedAt,
+ profileId = profileId
)
}
diff --git a/app/src/main/java/com/ivor/openstream/data/settings/AppSettingsStore.kt b/app/src/main/java/com/ivor/openstream/data/settings/AppSettingsStore.kt
index 853194f..78e38dd 100644
--- a/app/src/main/java/com/ivor/openstream/data/settings/AppSettingsStore.kt
+++ b/app/src/main/java/com/ivor/openstream/data/settings/AppSettingsStore.kt
@@ -26,6 +26,14 @@ enum class DnsProvider(
GOOGLE("Google", "https://dns.google/dns-query", listOf("8.8.8.8", "8.8.4.4"))
}
+/** A button in the picture-in-picture window, beside play/pause. */
+enum class PipAction(val label: String) {
+ REWIND("Back"),
+ FORWARD("Forward"),
+ NEXT_EPISODE("Next"),
+ SKIP_INTRO("Skip intro")
+}
+
data class AppSettings(
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val dynamicColor: Boolean = true,
@@ -38,7 +46,10 @@ data class AppSettings(
/** Count down into the next episode when one ends. */
val autoPlayNext: Boolean = true,
/** Tallest rendition a download picks from a master playlist. */
- val downloadMaxHeight: Int = 1080
+ val downloadMaxHeight: Int = 1080,
+ /** Picture-in-picture buttons left and right of play/pause (Android shows three at most). */
+ val pipLeftAction: PipAction = PipAction.REWIND,
+ val pipRightAction: PipAction = PipAction.FORWARD
) {
companion object {
val SEEK_STEPS = listOf(5, 10, 15, 30)
@@ -57,6 +68,19 @@ class AppSettingsStore @Inject constructor(
val current: AppSettings get() = _settings.value
+ private val _activeProfileId = MutableStateFlow(prefs.getLong(KEY_ACTIVE_PROFILE, DEFAULT_PROFILE_ID))
+
+ /**
+ * The profile whose library, progress and lists the app shows. Not part of [AppSettings]
+ * because backups and restores shouldn't switch who's watching.
+ */
+ val activeProfileId: StateFlow = _activeProfileId.asStateFlow()
+
+ fun setActiveProfile(id: Long) {
+ _activeProfileId.value = id
+ prefs.edit().putLong(KEY_ACTIVE_PROFILE, id).apply()
+ }
+
fun update(transform: (AppSettings) -> AppSettings) {
val updated = transform(_settings.value)
_settings.value = updated
@@ -69,6 +93,8 @@ class AppSettingsStore @Inject constructor(
.putFloat(KEY_DEFAULT_SPEED, updated.defaultSpeed)
.putBoolean(KEY_AUTO_PLAY_NEXT, updated.autoPlayNext)
.putInt(KEY_DOWNLOAD_HEIGHT, updated.downloadMaxHeight)
+ .putString(KEY_PIP_LEFT, updated.pipLeftAction.name)
+ .putString(KEY_PIP_RIGHT, updated.pipRightAction.name)
.apply()
}
@@ -85,7 +111,9 @@ class AppSettingsStore @Inject constructor(
.takeIf { it in AppSettings.DEFAULT_SPEEDS } ?: defaults.defaultSpeed,
autoPlayNext = prefs.getBoolean(KEY_AUTO_PLAY_NEXT, defaults.autoPlayNext),
downloadMaxHeight = prefs.getInt(KEY_DOWNLOAD_HEIGHT, defaults.downloadMaxHeight)
- .takeIf { it in AppSettings.DOWNLOAD_HEIGHTS } ?: defaults.downloadMaxHeight
+ .takeIf { it in AppSettings.DOWNLOAD_HEIGHTS } ?: defaults.downloadMaxHeight,
+ pipLeftAction = enumOrDefault(prefs.getString(KEY_PIP_LEFT, null), defaults.pipLeftAction),
+ pipRightAction = enumOrDefault(prefs.getString(KEY_PIP_RIGHT, null), defaults.pipRightAction)
)
}
@@ -101,5 +129,10 @@ class AppSettingsStore @Inject constructor(
const val KEY_DEFAULT_SPEED = "app_default_speed"
const val KEY_AUTO_PLAY_NEXT = "app_auto_play_next"
const val KEY_DOWNLOAD_HEIGHT = "app_download_max_height"
+ const val KEY_ACTIVE_PROFILE = "app_active_profile_id"
+ const val KEY_PIP_LEFT = "app_pip_left_action"
+ const val KEY_PIP_RIGHT = "app_pip_right_action"
+ /** Matches ProfileEntity.DEFAULT_ID, the profile the Room migration seeds. */
+ const val DEFAULT_PROFILE_ID = 1L
}
}
diff --git a/app/src/main/java/com/ivor/openstream/data/streaming/ExtensionProviderRegistry.kt b/app/src/main/java/com/ivor/openstream/data/streaming/ExtensionProviderRegistry.kt
index 74692e1..3101d8d 100644
--- a/app/src/main/java/com/ivor/openstream/data/streaming/ExtensionProviderRegistry.kt
+++ b/app/src/main/java/com/ivor/openstream/data/streaming/ExtensionProviderRegistry.kt
@@ -3,6 +3,9 @@ package com.ivor.openstream.data.streaming
import com.ivor.openstream.data.streaming.anime.AnikotoProvider
import com.ivor.openstream.data.streaming.anime.AnimeEpisodeMapper
import com.ivor.openstream.data.streaming.anime.AnimePaheProvider
+import com.ivor.openstream.data.streaming.hosters.HosterExtractors
+import com.ivor.openstream.data.streaming.anime.AnimeGGProvider
+import com.ivor.openstream.data.streaming.anime.FourAnimoProvider
import com.ivor.openstream.data.streaming.anime.AnimeSiteSpec
import com.ivor.openstream.data.streaming.anime.CloudflareClearance
import com.ivor.openstream.data.streaming.anime.MegaplayExtractor
@@ -55,6 +58,7 @@ class ExtensionProviderRegistry @Inject constructor(
private val animeEpisodeMapper: AnimeEpisodeMapper,
private val megaplayExtractor: MegaplayExtractor,
private val cloudflareClearance: CloudflareClearance,
+ private val hosterExtractors: HosterExtractors,
@Named("StreamingClient") private val streamingClient: OkHttpClient,
private val json: Json
) {
@@ -111,7 +115,8 @@ class ExtensionProviderRegistry @Inject constructor(
mapper = animeEpisodeMapper,
client = streamingClient,
json = json,
- megaplay = megaplayExtractor
+ megaplay = megaplayExtractor,
+ hosters = hosterExtractors
)
ExtensionEngineType.REANIME -> ReAnimeProvider(
spec = animeSiteSpec(manifest),
@@ -125,6 +130,17 @@ class ExtensionProviderRegistry @Inject constructor(
json = json,
clearance = cloudflareClearance
)
+ ExtensionEngineType.FOURANIMO -> FourAnimoProvider(
+ spec = animeSiteSpec(manifest),
+ mapper = animeEpisodeMapper,
+ client = streamingClient,
+ json = json
+ )
+ ExtensionEngineType.ANIMEGG -> AnimeGGProvider(
+ spec = animeSiteSpec(manifest),
+ mapper = animeEpisodeMapper,
+ client = streamingClient
+ )
ExtensionEngineType.UNSUPPORTED -> return null
}
diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/session/ImagePrefixStrippingDataSource.kt b/app/src/main/java/com/ivor/openstream/data/streaming/ImagePrefixStrippingDataSource.kt
similarity index 98%
rename from app/src/main/java/com/ivor/openstream/presentation/player/session/ImagePrefixStrippingDataSource.kt
rename to app/src/main/java/com/ivor/openstream/data/streaming/ImagePrefixStrippingDataSource.kt
index cb35508..3aad8cf 100644
--- a/app/src/main/java/com/ivor/openstream/presentation/player/session/ImagePrefixStrippingDataSource.kt
+++ b/app/src/main/java/com/ivor/openstream/data/streaming/ImagePrefixStrippingDataSource.kt
@@ -1,4 +1,4 @@
-package com.ivor.openstream.presentation.player.session
+package com.ivor.openstream.data.streaming
import android.net.Uri
import androidx.media3.common.C
diff --git a/app/src/main/java/com/ivor/openstream/data/streaming/anime/AnimeSiteProviders.kt b/app/src/main/java/com/ivor/openstream/data/streaming/anime/AnimeSiteProviders.kt
index 4cec687..fbb3b24 100644
--- a/app/src/main/java/com/ivor/openstream/data/streaming/anime/AnimeSiteProviders.kt
+++ b/app/src/main/java/com/ivor/openstream/data/streaming/anime/AnimeSiteProviders.kt
@@ -1,6 +1,8 @@
package com.ivor.openstream.data.streaming.anime
import com.ivor.openstream.data.streaming.StreamProvider
+import com.ivor.openstream.data.streaming.hosters.HosterExtractors
+import com.ivor.openstream.domain.model.HLS_MIME_TYPE
import com.ivor.openstream.domain.model.MediaIdentity
import com.ivor.openstream.domain.model.StreamAudio
import com.ivor.openstream.domain.model.StreamQuality
@@ -72,7 +74,8 @@ class AnikotoProvider(
mapper: AnimeEpisodeMapper,
private val client: OkHttpClient,
private val json: Json,
- private val megaplay: MegaplayExtractor
+ private val megaplay: MegaplayExtractor,
+ private val hosters: HosterExtractors
) : AnimeSiteProvider(spec, mapper) {
private val showPages = ConcurrentHashMap()
private val episodeLists = ConcurrentHashMap>>()
@@ -101,11 +104,24 @@ class AnikotoProvider(
client.fetch("$base/ajax/server?get=$linkId", ajax)
).jsonObject["result"]?.jsonObject
val url = result?.get("url")?.jsonPrimitive?.contentOrNull.orEmpty()
- // Only megaplay embeds have an extractor; other hosts are skipped.
- if ("megaplay" !in url) {
- emptyList()
- } else {
- megaplay.extract(url, "$base/", id, spec.name, label, audio)
+ when {
+ "megaplay" in url -> megaplay.extract(url, "$base/", id, spec.name, label, audio)
+ // Common embed hosts (Filemoon, StreamWish, Voe...) have native extractors.
+ hosters.supports(url) -> hosters.extract(url, "$base/").map { stream ->
+ VideoServer(
+ id = "$id-${stream.url.hashCode()}",
+ providerId = id,
+ providerName = spec.name,
+ name = "$label · ${stream.host} · ${audio.label}",
+ url = stream.url,
+ quality = stream.quality,
+ audio = audio,
+ headers = stream.headers,
+ audioLanguage = if (audio == StreamAudio.DUB) "English" else "Japanese",
+ mimeType = if (stream.isHls) HLS_MIME_TYPE else null
+ )
+ }
+ else -> emptyList()
}
}.getOrDefault(emptyList())
}
diff --git a/app/src/main/java/com/ivor/openstream/data/streaming/anime/MoreAnimeProviders.kt b/app/src/main/java/com/ivor/openstream/data/streaming/anime/MoreAnimeProviders.kt
new file mode 100644
index 0000000..eb8ef70
--- /dev/null
+++ b/app/src/main/java/com/ivor/openstream/data/streaming/anime/MoreAnimeProviders.kt
@@ -0,0 +1,196 @@
+package com.ivor.openstream.data.streaming.anime
+
+import com.ivor.openstream.domain.model.HLS_MIME_TYPE
+import com.ivor.openstream.domain.model.SkipSegment
+import com.ivor.openstream.domain.model.SkipType
+import com.ivor.openstream.domain.model.StreamAudio
+import com.ivor.openstream.domain.model.StreamQuality
+import com.ivor.openstream.domain.model.StreamSubtitle
+import com.ivor.openstream.domain.model.VideoServer
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.doubleOrNull
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import okhttp3.OkHttpClient
+import java.util.Locale
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * FourAnimo (4animo.xyz): its player host (`endpoint`, cdn.4animo.xyz) embeds by AniList id and
+ * episode, for sub and dub. The embed page carries a one-time `getSources` token; the JSON it
+ * returns has the HLS playlist, caption tracks and intro/outro times.
+ *
+ * Undocumented behaviour relied on: tokens are bound to the client's IP (a phone keeps one; a
+ * connection from another IP gets "invalid token"), and playlists/segments want the player host
+ * as Referer. Streams are always HLS behind `/p?t=` URLs, so the MIME type is set explicitly.
+ */
+class FourAnimoProvider(
+ spec: AnimeSiteSpec,
+ mapper: AnimeEpisodeMapper,
+ private val client: OkHttpClient,
+ private val json: Json
+) : AnimeSiteProvider(spec, mapper) {
+
+ override suspend fun streams(ref: AnimeEpisodeRef): List = coroutineScope {
+ listOf(StreamAudio.SUB to "sub", StreamAudio.DUB to "dub").map { (audio, type) ->
+ async { runCatching { stream(ref, audio, type) }.getOrNull() }
+ }.awaitAll().filterNotNull()
+ }
+
+ private fun stream(ref: AnimeEpisodeRef, audio: StreamAudio, type: String): VideoServer? {
+ val embed = "$base/embed/ani/${ref.anilistId}/${ref.episode}/$type?k=1"
+ val page = client.fetch(embed, mapOf("Referer" to SITE))
+ val sourcesPath = Regex("""sourcesUrl\s*=\s*'([^']+)'""").find(page)?.groupValues?.get(1) ?: return null
+ val data = json.parseToJsonElement(client.fetch(absolute(sourcesPath), mapOf("Referer" to embed))).jsonObject
+ val file = (data["sources"] as? JsonArray)?.firstOrNull()?.jsonObject?.get("file")
+ ?.jsonPrimitive?.contentOrNull ?: return null
+ val headers = mapOf("Referer" to "$base/", "Origin" to base)
+ val url = absolute(file)
+ val playlist = runCatching { client.fetch(url, headers) }.getOrNull() ?: return null
+ if (!playlist.trimStart().startsWith("#EXTM3U")) return null
+
+ val subtitles = (data["tracks"] as? JsonArray).orEmpty().mapNotNull { element ->
+ val track = element as? JsonObject ?: return@mapNotNull null
+ if (track["kind"]?.jsonPrimitive?.contentOrNull !in setOf(null, "captions", "subtitles")) return@mapNotNull null
+ val label = track["label"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
+ StreamSubtitle(
+ url = absolute(track["file"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null),
+ label = label,
+ language = languageCode(label),
+ headers = headers
+ )
+ }
+ val server = data["server"]?.jsonPrimitive?.contentOrNull ?: "HD-1"
+ return VideoServer(
+ id = "$id-$type-${ref.anilistId}-${ref.episode}",
+ providerId = id,
+ providerName = spec.name,
+ name = "$server · ${audio.label}",
+ url = url,
+ quality = bestPlaylistQuality(playlist),
+ audio = audio,
+ headers = headers,
+ subtitles = subtitles,
+ audioLanguage = if (audio == StreamAudio.DUB) "English" else "Japanese",
+ mimeType = HLS_MIME_TYPE,
+ skipSegments = listOfNotNull(
+ segment(data["intro"], SkipType.INTRO),
+ segment(data["outro"], SkipType.CREDITS)
+ )
+ )
+ }
+
+ private fun segment(value: Any?, type: SkipType): SkipSegment? {
+ val range = value as? JsonObject ?: return null
+ val start = range["start"]?.jsonPrimitive?.doubleOrNull ?: return null
+ val end = range["end"]?.jsonPrimitive?.doubleOrNull ?: return null
+ return SkipSegment(type, (start * 1000).toLong(), (end * 1000).toLong()).takeIf { it.endMs > it.startMs }
+ }
+
+ private fun absolute(path: String) = if (path.startsWith("http")) path else "$base/${path.trimStart('/')}"
+
+ private companion object {
+ const val SITE = "https://4animo.xyz/"
+ }
+}
+
+/**
+ * AnimeGG (animegg.org): sub and dub as progressive MP4s. Search by title, confirm the series page,
+ * then the episode page (`/-episode-N`) embeds one player per version whose page lists a
+ * `/play//video.mp4` link per quality; those redirect to the vidcache CDN.
+ *
+ * Undocumented behaviour relied on: the play links and CDN want Referer and Origin set to the
+ * site plus desktop Chrome client hints, and the redirect target is on a non-standard port.
+ */
+class AnimeGGProvider(
+ spec: AnimeSiteSpec,
+ mapper: AnimeEpisodeMapper,
+ private val client: OkHttpClient
+) : AnimeSiteProvider(spec, mapper) {
+ private val slugs = ConcurrentHashMap()
+
+ private val playHeaders: Map
+ get() = mapOf(
+ "Referer" to "$base/",
+ "Origin" to base,
+ "sec-ch-ua" to "\"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\", \"Not-A.Brand\";v=\"99\"",
+ "sec-ch-ua-mobile" to "?0",
+ "sec-ch-ua-platform" to "\"Windows\""
+ )
+
+ override suspend fun streams(ref: AnimeEpisodeRef): List {
+ val slug = seriesSlug(ref) ?: return emptyList()
+ val episodeUrl = "$base/$slug-episode-${ref.episode}"
+ val page = client.fetch(episodeUrl, mapOf("Referer" to "$base/series/$slug"))
+ // Each tab: data-id (the embed) and data-version (subbed, dubbed, raw).
+ val tabs = Regex("""]*data-id='(\d+)'[^>]*data-version="(\w+)"""").findAll(page)
+ .map { it.groupValues[1] to it.groupValues[2] }
+ .distinct()
+ .toList()
+ return coroutineScope {
+ tabs.map { (embedId, version) ->
+ async { runCatching { embedStreams(embedId, version, episodeUrl) }.getOrDefault(emptyList()) }
+ }.awaitAll().flatten()
+ }
+ }
+
+ private fun embedStreams(embedId: String, version: String, episodeUrl: String): List {
+ val audio = when (version.lowercase(Locale.ROOT)) {
+ "dubbed" -> StreamAudio.DUB
+ "subbed" -> StreamAudio.SUB
+ else -> return emptyList() // Raw: no subtitles at all.
+ }
+ val embed = "$base/embed/$embedId"
+ val html = client.fetch(embed, mapOf("Referer" to episodeUrl))
+ val sources = Regex("""file:\s*"(/play/[^"]+)",\s*label:\s*"(\d+)p"""").findAll(html)
+ .map { it.groupValues[1] to it.groupValues[2].toInt() }
+ .sortedByDescending { it.second }
+ .toList()
+ return sources.map { (path, height) ->
+ VideoServer(
+ id = "$id-$embedId-$height",
+ providerId = id,
+ providerName = spec.name,
+ name = "Animegg ${height}p · ${audio.label}",
+ url = "$base$path",
+ quality = StreamQuality.parse("${height}p"),
+ audio = audio,
+ headers = playHeaders + ("Referer" to embed),
+ audioLanguage = if (audio == StreamAudio.DUB) "English" else "Japanese",
+ mimeType = "video/mp4"
+ )
+ }
+ }
+
+ /** The series slug, taken from search results and matched against the AniList titles. */
+ private fun seriesSlug(ref: AnimeEpisodeRef): String? {
+ slugs[ref.anilistId]?.let { return it }
+ for (title in ref.titles) {
+ val html = client.fetch("$base/search/?q=${title.urlEncoded()}", mapOf("Referer" to "$base/"))
+ val found = Regex("""href="/series/([a-z0-9-]+)"""").findAll(html).map { it.groupValues[1] }.distinct().toList()
+ val wanted = slugify(title)
+ val slug = found.firstOrNull { it == wanted } ?: found.firstOrNull() ?: continue
+ slugs[ref.anilistId] = slug
+ return slug
+ }
+ return null
+ }
+
+ private fun slugify(title: String) = title.lowercase(Locale.ROOT)
+ .replace(Regex("[^a-z0-9]+"), "-")
+ .trim('-')
+}
+
+/** ISO 639-1 code for an English language name such as "Portuguese (Brazil)"; null when unknown. */
+internal fun languageCode(label: String): String? {
+ val name = label.substringBefore('(').trim().lowercase(Locale.ROOT)
+ return Locale.getAvailableLocales()
+ .firstOrNull { it.getDisplayLanguage(Locale.ENGLISH).lowercase(Locale.ROOT) == name }
+ ?.language
+}
diff --git a/app/src/main/java/com/ivor/openstream/data/streaming/hosters/HosterExtractors.kt b/app/src/main/java/com/ivor/openstream/data/streaming/hosters/HosterExtractors.kt
new file mode 100644
index 0000000..6c9aa6f
--- /dev/null
+++ b/app/src/main/java/com/ivor/openstream/data/streaming/hosters/HosterExtractors.kt
@@ -0,0 +1,257 @@
+package com.ivor.openstream.data.streaming.hosters
+
+import android.util.Base64
+import android.util.Log
+import com.ivor.openstream.data.streaming.BROWSER_USER_AGENT
+import com.ivor.openstream.data.streaming.anime.PackerUnpacker
+import com.ivor.openstream.domain.model.StreamQuality
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.IOException
+import java.util.Locale
+import javax.inject.Inject
+import javax.inject.Named
+import javax.inject.Singleton
+
+/** A direct stream pulled out of a hoster's embed page. */
+data class HosterStream(
+ val url: String,
+ /** Headers the stream's CDN wants (usually the hoster as Referer). */
+ val headers: Map,
+ val isHls: Boolean,
+ val quality: StreamQuality,
+ /** Short hoster name for server labels, e.g. "Filemoon". */
+ val host: String
+)
+
+/**
+ * Turns common embed hosts into direct streams, so their embeds play without the hidden WebView:
+ * Filemoon, StreamWish/VidHide (same player family), Voe, Mp4Upload, Vidmoly and ok.ru.
+ *
+ * Written from how each page delivers its player config (p.a.c.k.e.r scripts, inline player
+ * setup, redirects, JSON options). All of it is undocumented and changes without notice; an
+ * extractor that stops matching just returns nothing and the caller falls back.
+ */
+@Singleton
+class HosterExtractors @Inject constructor(
+ @Named("StreamingClient") private val client: OkHttpClient,
+ private val json: Json
+) {
+ private enum class Host(val label: String, val domains: List) {
+ FILEMOON("Filemoon", listOf("filemoon", "moonplayer", "kerapoxy", "bf0skv", "fmoonembed", "moflix", "cinegrab")),
+ STREAMWISH(
+ "StreamWish",
+ listOf("streamwish", "wishembed", "swdyu", "wishfast", "sfastwish", "strwish", "playerwish", "hlswish", "awish", "dwish", "embedwish", "mwish", "flaswish", "obeywish", "cdnwish", "asnwish", "jodwish", "streamhg", "dhcplay", "hglink")
+ ),
+ VIDHIDE("VidHide", listOf("vidhide", "filelions", "alions", "dlions", "mlions", "vidhidepro", "vidhidevip", "vidhidefast", "ryderjet", "smoothpre", "movearnpre", "dinisglows", "mivalyo", "dintezuvio")),
+ VOE("Voe", listOf("voe.sx", "voe-", "voeun", "voeunblock", "tubelessceliolymph", "maxfinishseveral", "crystaltreatmenteast", "graceaddresscommunity", "christopheruntilpoint", "launchreliantcleaverriver", "jennifercertaindevelopment")),
+ MP4UPLOAD("Mp4Upload", listOf("mp4upload")),
+ VIDMOLY("Vidmoly", listOf("vidmoly")),
+ OKRU("ok.ru", listOf("ok.ru", "odnoklassniki"));
+
+ fun matches(host: String): Boolean = domains.any { host.contains(it) }
+ }
+
+ /** True when [url] is on a host this class can extract. */
+ fun supports(url: String): Boolean = hostOf(url) != null
+
+ /** Direct streams from a hoster embed; empty when the page didn't match (or isn't a hoster). */
+ suspend fun extract(url: String, referer: String? = null): List = withContext(Dispatchers.IO) {
+ val host = hostOf(url) ?: return@withContext emptyList()
+ runCatching {
+ when (host) {
+ Host.FILEMOON -> filemoon(url, referer)
+ Host.STREAMWISH, Host.VIDHIDE -> packedPlayer(url, referer, host)
+ Host.VOE -> voe(url, referer)
+ Host.MP4UPLOAD -> mp4upload(url, referer)
+ Host.VIDMOLY -> vidmoly(url, referer)
+ Host.OKRU -> okru(url)
+ }
+ }.onFailure { Log.w(TAG, "${host.label} extraction failed: ${it.message}") }
+ .getOrDefault(emptyList())
+ }
+
+ private fun hostOf(url: String): Host? {
+ val host = url.toHttpUrlOrNull()?.host?.lowercase(Locale.ROOT) ?: return null
+ return Host.entries.firstOrNull { it.matches(host) }
+ }
+
+ // region Hosts
+
+ /** Filemoon: the page wraps the player in an iframe; the player's packed script holds `file:`. */
+ private fun filemoon(url: String, referer: String?): List {
+ var pageUrl = url
+ var html = fetch(url, referer)
+ Regex("""