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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 103 additions & 4 deletions app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4925,15 +4925,24 @@ class IptvRepository @Inject constructor(
@SerializedName("container_extension") val containerExtension: String? = null,
@SerializedName(value = "imdb", alternate = ["imdb_id", "imdbid"]) val imdb: String? = null,
@SerializedName(value = "tmdb", alternate = ["tmdb_id", "tmdbid"]) val tmdb: String? = null,
@SerializedName("category_id") val categoryId: String? = null
@SerializedName("category_id") val categoryId: String? = null,
// Browse-only fields (IPTV VOD screen): artwork, rating, added timestamp.
@SerializedName("stream_icon") val streamIcon: String? = null,
val rating: String? = null,
val added: String? = null
)

private data class XtreamSeriesItem(
@SerializedName(value = "series_id", alternate = ["seriesid", "id"]) val seriesId: Int? = null,
val name: String? = null,
@SerializedName(value = "imdb", alternate = ["imdb_id", "imdbid"]) val imdb: String? = null,
@SerializedName(value = "tmdb", alternate = ["tmdb_id", "tmdbid"]) val tmdb: String? = null,
@SerializedName("category_id") val categoryId: String? = null
@SerializedName("category_id") val categoryId: String? = null,
// Browse-only fields (IPTV VOD screen).
val cover: String? = null,
val rating: String? = null,
@SerializedName(value = "releaseDate", alternate = ["release_date"]) val releaseDate: String? = null,
@SerializedName("last_modified") val lastModified: String? = null
)

private data class XtreamSeriesEpisode(
Expand Down Expand Up @@ -7600,10 +7609,10 @@ class IptvRepository @Inject constructor(
}

private fun vodDiskCacheFile(creds: XtreamCredentials): File =
File(xtreamDiskCacheDir(), "vod_${xtreamDiskCacheHash(creds)}.json")
File(xtreamDiskCacheDir(), "vod_v2_${xtreamDiskCacheHash(creds)}.json")

private fun seriesDiskCacheFile(creds: XtreamCredentials): File =
File(xtreamDiskCacheDir(), "series_${xtreamDiskCacheHash(creds)}.json")
File(xtreamDiskCacheDir(), "series_v2_${xtreamDiskCacheHash(creds)}.json")

private fun <T> readDiskCache(file: File, type: Type): XtreamDiskCache<T>? {
val parentExists = file.parentFile?.exists() == true
Expand Down Expand Up @@ -12069,6 +12078,96 @@ class IptvRepository @Inject constructor(
.mapNotNull { it.toInfoOrNull() }
}

// ── IPTV VOD browsing (the VOD tab next to TV) ──────────────────────────

data class IptvVodBrowseCategory(val id: String, val name: String)

data class IptvVodBrowseItem(
val key: String,
val isSeries: Boolean,
val name: String,
val posterUrl: String?,
val year: Int?,
val rating: String?,
val categoryId: String?,
val tmdbId: Int?,
/** Direct play URL (movies only; series resolve episodes through Details). */
val streamUrl: String?,
val addedAt: Long
)

data class IptvVodBrowseCatalog(
val categories: List<IptvVodBrowseCategory>,
val items: List<IptvVodBrowseItem>
)

/** True when at least one active Xtream playlist can provide movies or series. */
suspend fun hasVodBrowseSource(): Boolean {
val config = observeConfig().first()
return xtreamCredentialsForVodImport(config).isNotEmpty() ||
xtreamCredentialsForSeriesImport(config).isNotEmpty()
}

/**
* The provider's full movie or series catalogue for browsing, in the provider's
* own categories. Uses the same memory/disk caches as VOD source matching, so
* opening the screen after the first load is instant.
*/
suspend fun getVodBrowseCatalog(series: Boolean): IptvVodBrowseCatalog = withContext(Dispatchers.IO) {
val config = observeConfig().first()
if (series) {
val creds = xtreamCredentialsForSeriesImport(config).firstOrNull()
?: return@withContext IptvVodBrowseCatalog(emptyList(), emptyList())
val categories = loadXtreamSeriesCategoriesInternal(creds, allowNetwork = true)
.mapNotNull { it.toInfoOrNull() }
.map { IptvVodBrowseCategory(it.categoryId, it.categoryName) }
val items = loadXtreamSeriesList(creds).mapNotNull { item ->
val id = item.seriesId ?: return@mapNotNull null
val name = item.name?.trim().orEmpty().ifBlank { return@mapNotNull null }
IptvVodBrowseItem(
key = "series_$id",
isSeries = true,
name = name,
posterUrl = item.cover?.trim()?.takeIf { it.startsWith("http") },
year = parseBrowseYear(item.releaseDate) ?: parseBrowseYear(name),
rating = item.rating?.trim()?.takeIf { it.isNotEmpty() && it != "0" },
categoryId = item.categoryId,
tmdbId = normalizeTmdbId(item.tmdb)?.toIntOrNull(),
streamUrl = null,
addedAt = item.lastModified?.trim()?.toLongOrNull() ?: 0L
)
}
IptvVodBrowseCatalog(categories, items)
} else {
val creds = xtreamCredentialsForVodImport(config).firstOrNull()
?: return@withContext IptvVodBrowseCatalog(emptyList(), emptyList())
val categories = loadXtreamVodCategoriesInternal(creds, allowNetwork = true)
.mapNotNull { it.toInfoOrNull() }
.map { IptvVodBrowseCategory(it.categoryId, it.categoryName) }
val items = loadXtreamVodStreams(creds).mapNotNull { item ->
val id = item.streamId ?: return@mapNotNull null
val name = item.name?.trim().orEmpty().ifBlank { return@mapNotNull null }
val ext = item.containerExtension?.trim()?.ifBlank { null } ?: "mp4"
IptvVodBrowseItem(
key = "movie_$id",
isSeries = false,
name = name,
posterUrl = item.streamIcon?.trim()?.takeIf { it.startsWith("http") },
year = parseBrowseYear(item.year) ?: parseBrowseYear(name),
rating = item.rating?.trim()?.takeIf { it.isNotEmpty() && it != "0" },
categoryId = item.categoryId,
tmdbId = normalizeTmdbId(item.tmdb)?.toIntOrNull(),
streamUrl = "${creds.baseUrl}/movie/${creds.username}/${creds.password}/$id.$ext",
addedAt = item.added?.trim()?.toLongOrNull() ?: 0L
)
}
IptvVodBrowseCatalog(categories, items)
}
}

private fun parseBrowseYear(raw: String?): Int? =
raw?.let { Regex("(19|20)\\d{2}").find(it)?.value?.toIntOrNull() }

private fun XtreamVodCategoryWire.toInfoOrNull(): XtreamVodCategoryInfo? {
val id = categoryId?.trim().orEmpty()
val name = categoryName?.trim().orEmpty()
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import com.arflix.tv.ui.screens.settings.SettingsScreen
import com.arflix.tv.ui.screens.settings.telegram.TelegramSettingsScreen
import com.arflix.tv.ui.screens.tv.live.LiveTvScreen
import com.arflix.tv.ui.screens.watchlist.WatchlistScreen
import com.arflix.tv.data.model.IptvVodSourceIds
import com.arflix.tv.ui.screens.profile.ProfileSelectionScreen
import com.arflix.tv.util.LocalDeviceType

Expand Down Expand Up @@ -284,6 +285,18 @@ fun AppNavigation(
onNavigateToDetails = { mediaType, mediaId ->
navController.navigate(Screen.Details.createRoute(mediaType, mediaId))
},
// An IPTV movie TMDB does not know: play the provider's copy.
onPlayIptvVod = { mediaId, streamUrl, title ->
navController.navigate(
Screen.Player.createRoute(
mediaType = MediaType.MOVIE,
mediaId = mediaId,
streamUrl = streamUrl,
preferredAddonId = IptvVodSourceIds.XTREAM,
preferredSourceName = title
)
)
},
onNavigateToHome = { navigateHome() },
onNavigateToSearch = { navigateTopLevel(Screen.Search.route) },
onNavigateToTv = { navigateTopLevel(Screen.Tv.createRoute()) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7720,7 +7720,7 @@ class PlayerViewModel @Inject constructor(
isLiveStream = currentIsLiveStreamPlayback,
addons = currentInstalledAddons,
isAddonNative = mediaRepository.isAddonNative(currentMediaId)
)
) || currentMediaId <= 0 // IPTV VOD played without a TMDB match: no scrobble/history

// Scrobble start/pause/updates with debounce
if (!isLiveStreamOrSports && isPlaying && !lastIsPlaying) {
Expand Down
Loading
Loading