Merge branch 'NuvioMedia:cmp-rewrite' into cmp-rewrite

This commit is contained in:
D4rk56 2026-04-30 21:18:49 +02:00 committed by GitHub
commit bb6ead8cba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 959 additions and 173 deletions

View file

@ -272,7 +272,7 @@ kotlin {
afterEvaluate {
dependencies {
add("fullImplementation", libs.quickjs.kt)
add("fullImplementation", files("libs/quickjs-kt-android-1.0.5-nuvio.aar"))
add("fullImplementation", libs.ksoup)
}
}

Binary file not shown.

View file

@ -110,29 +110,38 @@
<string name="collections_editor_tmdb_production_mode">Production</string>
<string name="collections_editor_tmdb_network_mode">Network</string>
<string name="collections_editor_tmdb_collection_mode">Collection</string>
<string name="collections_editor_tmdb_person_mode">Person</string>
<string name="collections_editor_tmdb_director_mode">Director</string>
<string name="collections_editor_tmdb_custom_mode">Custom</string>
<string name="collections_editor_tmdb_help_presets">Pick a ready-made source. You can edit or remove it after adding.</string>
<string name="collections_editor_tmdb_help_list">Paste a public TMDB list URL or only the number from the URL.</string>
<string name="collections_editor_tmdb_help_production">Search by studio name, or paste a TMDB company ID/URL and add it directly.</string>
<string name="collections_editor_tmdb_help_network">Enter a network ID. Common networks are available in Presets and quick filters.</string>
<string name="collections_editor_tmdb_help_collection">Search a movie collection name or paste the collection ID from TMDB.</string>
<string name="collections_editor_tmdb_help_person">Enter a TMDB person ID or URL to build a row from cast credits.</string>
<string name="collections_editor_tmdb_help_director">Enter a TMDB person ID or URL to build a row from director credits.</string>
<string name="collections_editor_tmdb_help_discover">Build a live TMDB row using optional filters. Leave fields empty when you do not need that filter.</string>
<string name="collections_editor_tmdb_public_list">Public TMDB list</string>
<string name="collections_editor_tmdb_network_id">Network ID</string>
<string name="collections_editor_tmdb_collection_id">Collection ID</string>
<string name="collections_editor_tmdb_person_id">Person ID</string>
<string name="collections_editor_tmdb_company_search">Production company name, ID, or URL</string>
<string name="collections_editor_tmdb_id_or_url">TMDB ID or URL</string>
<string name="collections_editor_tmdb_list_placeholder">https://www.themoviedb.org/list/8504994 or 8504994</string>
<string name="collections_editor_tmdb_network_placeholder">213 for Netflix, 49 for HBO, 2739 for Disney+</string>
<string name="collections_editor_tmdb_collection_placeholder">10 for Star Wars Collection</string>
<string name="collections_editor_tmdb_company_placeholder">Marvel Studios, 420, or company URL</string>
<string name="collections_editor_tmdb_person_placeholder">31 for Tom Hanks, or person URL</string>
<string name="collections_editor_tmdb_search_helper">Examples: Marvel Studios, 420, or https://www.themoviedb.org/company/420.</string>
<string name="collections_editor_tmdb_collection_helper">Example: Star Wars Collection, Harry Potter Collection, or a collection URL.</string>
<string name="collections_editor_tmdb_network_helper">Example IDs: Netflix 213, HBO 49, Disney+ 2739.</string>
<string name="collections_editor_tmdb_list_helper">Example: https://www.themoviedb.org/list/8504994 or 8504994.</string>
<string name="collections_editor_tmdb_person_helper">Example: https://www.themoviedb.org/person/31-tom-hanks or 31.</string>
<string name="collections_editor_tmdb_display_title">Display title</string>
<string name="collections_editor_tmdb_title_helper">Shown as the row/tab name. If blank, Nuvio creates one from the source.</string>
<string name="collections_editor_tmdb_title_placeholder">Marvel Movies, Netflix Originals, Pixar</string>
<string name="collections_editor_tmdb_person_title_placeholder">Tom Hanks Movies, Favorite Actors</string>
<string name="collections_editor_tmdb_director_title_placeholder">Christopher Nolan Movies, Favorite Directors</string>
<string name="collections_editor_tmdb_discover_title_placeholder">Best Action Movies, Korean Dramas, 2024 Animation</string>
<string name="collections_editor_tmdb_search_results">Search Results</string>
<string name="collections_editor_tmdb_collection">TMDB Collection</string>
@ -212,6 +221,7 @@
<string name="collections_editor_tmdb_network_disney_plus">Disney+</string>
<string name="collections_editor_tmdb_network_prime_video">Prime Video</string>
<string name="collections_editor_tmdb_network_hulu">Hulu</string>
<string name="collections_editor_tmdb_sort_original">Original</string>
<string name="collections_editor_tmdb_sort_popular">Popular</string>
<string name="collections_editor_tmdb_sort_top_rated">Top Rated</string>
<string name="collections_editor_tmdb_sort_recent">Recent</string>
@ -219,6 +229,8 @@
<string name="collections_editor_tmdb_subtitle_movie_collection">TMDB Movie Collection</string>
<string name="collections_editor_tmdb_subtitle_production">Production</string>
<string name="collections_editor_tmdb_subtitle_network">Network</string>
<string name="collections_editor_tmdb_subtitle_person">Person</string>
<string name="collections_editor_tmdb_subtitle_director">Director</string>
<string name="collections_editor_tmdb_subtitle_discover">TMDB Discover</string>
<string name="collections_empty_subtitle">Create one to organize your catalogs.</string>
<string name="collections_empty_title">No collections yet</string>

View file

@ -0,0 +1,23 @@
package com.nuvio.app.core.ui
internal data class DuplicateSafeLazyEntry<T>(
val value: T,
val lazyKey: Any,
)
internal fun <T> List<T>.withDuplicateSafeLazyKeys(key: (T) -> Any): List<DuplicateSafeLazyEntry<T>> {
val keyCounts = groupingBy(key).eachCount()
val occurrences = mutableMapOf<Any, Int>()
return map { entry ->
val baseKey = key(entry)
val lazyKey = if (keyCounts[baseKey] == 1) {
baseKey
} else {
val occurrence = occurrences.getOrElse(baseKey) { 0 }
occurrences[baseKey] = occurrence + 1
"$baseKey#$occurrence"
}
DuplicateSafeLazyEntry(value = entry, lazyKey = lazyKey)
}
}

View file

@ -82,10 +82,10 @@ fun <T> NuvioShelfSection(
) {
if (key != null) {
items(
items = entries,
key = key,
) { entry ->
itemContent(entry)
items = entries.withDuplicateSafeLazyKeys(key),
key = { entry -> entry.lazyKey },
) { keyedEntry ->
itemContent(keyedEntry.value)
}
} else {
items(entries) { entry ->

View file

@ -50,6 +50,7 @@ import com.nuvio.app.core.ui.NuvioBackButton
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
import com.nuvio.app.core.ui.posterCardClickable
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.home.stableKey
@ -175,9 +176,10 @@ fun CatalogScreen(
}
} else {
items(
items = uiState.items,
key = { item -> item.stableKey() },
) { item ->
items = uiState.items.withDuplicateSafeLazyKeys { item -> item.stableKey() },
key = { item -> item.lazyKey },
) { keyedItem ->
val item = keyedItem.value
CatalogPosterTile(
item = item,
cornerRadiusDp = posterCardStyle.cornerRadiusDp,

View file

@ -0,0 +1,43 @@
package com.nuvio.app.features.collection
import com.nuvio.app.features.addons.AddonCatalog
import com.nuvio.app.features.addons.ManagedAddon
internal data class ResolvedCollectionCatalog(
val addon: ManagedAddon,
val catalog: AddonCatalog,
)
internal fun List<ManagedAddon>.findCollectionCatalog(
source: CollectionCatalogSource,
): ResolvedCollectionCatalog? {
val declaredAddon = firstOrNull { it.manifest?.id == source.addonId }
val declaredCatalog = declaredAddon?.manifest?.catalogs?.findSourceCatalog(source)
if (declaredAddon != null && declaredCatalog != null) {
return ResolvedCollectionCatalog(addon = declaredAddon, catalog = declaredCatalog)
}
return firstNotNullOfOrNull { addon ->
val catalog = addon.manifest?.catalogs?.find {
it.id == source.catalogId && it.type == source.type
} ?: return@firstNotNullOfOrNull null
ResolvedCollectionCatalog(addon = addon, catalog = catalog)
}
}
internal fun List<AvailableCatalog>.findAvailableCatalog(
source: CollectionCatalogSource,
): AvailableCatalog? {
val declaredCatalogs = filter { it.addonId == source.addonId }
return declaredCatalogs.findSourceCatalog(source)
?: firstOrNull { it.catalogId == source.catalogId && it.type == source.type }
}
private fun List<AddonCatalog>.findSourceCatalog(source: CollectionCatalogSource): AddonCatalog? =
find { it.id == source.catalogId && it.type == source.type }
?: find { it.id == source.catalogId.substringBefore(",") && it.type == source.type }
private fun List<AvailableCatalog>.findSourceCatalog(source: CollectionCatalogSource): AvailableCatalog? =
find { it.catalogId == source.catalogId && it.type == source.type }
?: find { it.catalogId == source.catalogId.substringBefore(",") && it.type == source.type }

View file

@ -46,6 +46,8 @@ enum class TmdbBuilderMode {
PRODUCTION,
NETWORK,
COLLECTION,
PERSON,
DIRECTOR,
DISCOVER,
}
@ -340,9 +342,15 @@ object CollectionEditorRepository {
} else {
_uiState.value.tmdbMediaType
}
val sortBy = when (mode) {
TmdbBuilderMode.LIST,
TmdbBuilderMode.COLLECTION -> TmdbCollectionSort.ORIGINAL.value
else -> TmdbCollectionSort.POPULAR_DESC.value
}
_uiState.value = _uiState.value.copy(
tmdbBuilderMode = mode,
tmdbMediaType = mediaType,
tmdbSortBy = sortBy,
tmdbMediaBoth = if (
mode == TmdbBuilderMode.NETWORK ||
mode == TmdbBuilderMode.LIST ||
@ -459,6 +467,8 @@ object CollectionEditorRepository {
TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION
TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY
TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK
TmdbBuilderMode.PERSON -> TmdbCollectionSourceType.PERSON
TmdbBuilderMode.DIRECTOR -> TmdbCollectionSourceType.DIRECTOR
TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER
}
val id = TmdbCollectionSourceResolver.parseTmdbId(state.tmdbInput)
@ -473,6 +483,8 @@ object CollectionEditorRepository {
TmdbCollectionSourceType.COLLECTION -> "TMDB Collection ${id ?: ""}".trim()
TmdbCollectionSourceType.COMPANY -> "TMDB Production ${id ?: ""}".trim()
TmdbCollectionSourceType.NETWORK -> "TMDB Network ${id ?: ""}".trim()
TmdbCollectionSourceType.PERSON -> "TMDB Person ${id ?: ""}".trim()
TmdbCollectionSourceType.DIRECTOR -> "TMDB Director ${id ?: ""}".trim()
TmdbCollectionSourceType.DISCOVER -> "TMDB Discover"
}
}
@ -561,6 +573,8 @@ private val coverMetadataSourceTypes = setOf(
TmdbCollectionSourceType.COLLECTION,
TmdbCollectionSourceType.COMPANY,
TmdbCollectionSourceType.NETWORK,
TmdbCollectionSourceType.PERSON,
TmdbCollectionSourceType.DIRECTOR,
)
private fun CollectionCatalogSource.toCollectionSource(): CollectionSource =
@ -591,6 +605,8 @@ private fun selectedMediaTypes(
): List<TmdbCollectionMediaType> =
when (sourceType) {
TmdbCollectionSourceType.COMPANY,
TmdbCollectionSourceType.PERSON,
TmdbCollectionSourceType.DIRECTOR,
TmdbCollectionSourceType.DISCOVER -> if (state.tmdbMediaBoth) {
listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV)
} else {

View file

@ -111,9 +111,7 @@ fun CollectionEditorScreen(
val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) }
val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource()
val genrePickerCatalog = genrePickerCatalogSource?.let { source ->
state.availableCatalogs.find {
it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId
}
state.availableCatalogs.findAvailableCatalog(source)
}
FolderEditorPage(
@ -757,11 +755,7 @@ private fun FolderEditorPage(
} else if (addonSource != null) {
FolderCatalogSourceCard(
source = addonSource,
matchingCatalog = state.availableCatalogs.find {
it.addonId == addonSource.addonId &&
it.type == addonSource.type &&
it.catalogId == addonSource.catalogId
},
matchingCatalog = state.availableCatalogs.findAvailableCatalog(addonSource),
onRemove = { CollectionEditorRepository.removeCatalogSource(index) },
onOpenGenrePicker = { CollectionEditorRepository.showGenrePicker(index) },
)
@ -897,13 +891,19 @@ private fun TmdbSourcePickerScreen(
TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION
TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY
TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK
TmdbBuilderMode.PERSON -> TmdbCollectionSourceType.PERSON
TmdbBuilderMode.DIRECTOR -> TmdbCollectionSourceType.DIRECTOR
TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER
}
val requiresId = sourceType != TmdbCollectionSourceType.DISCOVER
val showMediaControls = state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION ||
state.tmdbBuilderMode == TmdbBuilderMode.PERSON ||
state.tmdbBuilderMode == TmdbBuilderMode.DIRECTOR ||
state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER
val showSortControls = state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION ||
state.tmdbBuilderMode == TmdbBuilderMode.NETWORK ||
state.tmdbBuilderMode == TmdbBuilderMode.PERSON ||
state.tmdbBuilderMode == TmdbBuilderMode.DIRECTOR ||
state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER
val showFilterControls = state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER
@ -1892,6 +1892,8 @@ private fun tmdbBuilderModeLabel(mode: TmdbBuilderMode): String =
TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_production_mode)
TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_mode)
TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_mode)
TmdbBuilderMode.PERSON -> stringResource(Res.string.collections_editor_tmdb_person_mode)
TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_director_mode)
TmdbBuilderMode.DISCOVER -> stringResource(Res.string.collections_editor_tmdb_custom_mode)
}
@ -1903,6 +1905,8 @@ private fun tmdbModeHelpText(mode: TmdbBuilderMode): String =
TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_help_production)
TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_help_network)
TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_help_collection)
TmdbBuilderMode.PERSON -> stringResource(Res.string.collections_editor_tmdb_help_person)
TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_help_director)
TmdbBuilderMode.DISCOVER -> stringResource(Res.string.collections_editor_tmdb_help_discover)
}
@ -1913,6 +1917,8 @@ private fun tmdbInputLabel(mode: TmdbBuilderMode): String =
TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_id)
TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_id)
TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_company_search)
TmdbBuilderMode.PERSON,
TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_person_id)
else -> stringResource(Res.string.collections_editor_tmdb_id_or_url)
}
@ -1923,6 +1929,8 @@ private fun tmdbInputPlaceholder(mode: TmdbBuilderMode): String =
TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_placeholder)
TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_placeholder)
TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_company_placeholder)
TmdbBuilderMode.PERSON,
TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_person_placeholder)
else -> stringResource(Res.string.collections_editor_tmdb_id_or_url)
}
@ -1933,6 +1941,8 @@ private fun tmdbInputHelper(mode: TmdbBuilderMode): String =
TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_helper)
TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_helper)
TmdbBuilderMode.LIST -> stringResource(Res.string.collections_editor_tmdb_list_helper)
TmdbBuilderMode.PERSON,
TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_person_helper)
else -> ""
}
@ -1940,12 +1950,15 @@ private fun tmdbInputHelper(mode: TmdbBuilderMode): String =
private fun tmdbTitlePlaceholder(mode: TmdbBuilderMode): String =
when (mode) {
TmdbBuilderMode.DISCOVER -> stringResource(Res.string.collections_editor_tmdb_discover_title_placeholder)
TmdbBuilderMode.PERSON -> stringResource(Res.string.collections_editor_tmdb_person_title_placeholder)
TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_director_title_placeholder)
else -> stringResource(Res.string.collections_editor_tmdb_title_placeholder)
}
@Composable
private fun tmdbSortLabel(sort: TmdbCollectionSort): String =
when (sort) {
TmdbCollectionSort.ORIGINAL -> stringResource(Res.string.collections_editor_tmdb_sort_original)
TmdbCollectionSort.POPULAR_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_popular)
TmdbCollectionSort.VOTE_AVERAGE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_top_rated)
TmdbCollectionSort.RELEASE_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent)
@ -1979,6 +1992,16 @@ private fun tmdbSourceSubtitle(source: CollectionSource): String {
stringResource(Res.string.collections_editor_tmdb_series),
sort,
).joinToString("")
TmdbCollectionSourceType.PERSON -> listOf(
stringResource(Res.string.collections_editor_tmdb_subtitle_person),
media,
sort,
).joinToString("")
TmdbCollectionSourceType.DIRECTOR -> listOf(
stringResource(Res.string.collections_editor_tmdb_subtitle_director),
media,
sort,
).joinToString("")
TmdbCollectionSourceType.DISCOVER -> listOf(
stringResource(Res.string.collections_editor_tmdb_subtitle_discover),
media,

View file

@ -69,6 +69,8 @@ enum class TmdbCollectionSourceType {
COMPANY,
NETWORK,
DISCOVER,
PERSON,
DIRECTOR,
}
@Serializable
@ -86,6 +88,7 @@ enum class TmdbCollectionMediaType(val value: String) {
}
enum class TmdbCollectionSort(val value: String) {
ORIGINAL("original"),
POPULAR_DESC("popularity.desc"),
VOTE_AVERAGE_DESC("vote_average.desc"),
RELEASE_DATE_DESC("primary_release_date.desc"),

View file

@ -140,16 +140,19 @@ object FolderDetailRepository {
source = source,
type = type,
catalogId = tmdbCatalogId(source),
supportsPagination = source.tmdbSourceType != TmdbCollectionSourceType.COLLECTION.name,
supportsPagination = source.tmdbSourceType !in setOf(
TmdbCollectionSourceType.COLLECTION.name,
TmdbCollectionSourceType.PERSON.name,
TmdbCollectionSourceType.DIRECTOR.name,
),
isLoading = true,
),
)
} else {
val catalogSource = source.addonCatalogSource() ?: return@forEach
val addon = addons.find { it.manifest?.id == catalogSource.addonId }
val catalog = addon?.manifest?.catalogs?.find {
it.id == catalogSource.catalogId && it.type == catalogSource.type
}
val resolvedCatalog = addons.findCollectionCatalog(catalogSource)
val addon = resolvedCatalog?.addon
val catalog = resolvedCatalog?.catalog
val label = catalog?.name ?: catalogSource.catalogId
val typeLabel = localizedMediaTypeLabel(catalogSource.type)
val genreSuffix = if (catalogSource.genre != null) " · ${catalogSource.genre}" else ""
@ -184,8 +187,8 @@ object FolderDetailRepository {
sources.forEachIndexed { sourceIndex, source ->
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
val catalogSource = source.addonCatalogSource()
val addon = catalogSource?.let { value -> addons.find { it.manifest?.id == value.addonId } }
if (!source.isTmdb && addon == null) {
val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) }
if (!source.isTmdb && resolvedCatalog == null) {
updateTab(tabIndex) {
it.copy(
isLoading = false,

View file

@ -54,6 +54,7 @@ import com.nuvio.app.core.ui.NuvioPosterCard
import com.nuvio.app.core.ui.NuvioPosterShape
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape
@ -275,9 +276,10 @@ private fun TabbedGridContent(
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
items(
items = selectedTab.items,
key = { item -> item.stableKey() },
) { item ->
items = selectedTab.items.withDuplicateSafeLazyKeys { item -> item.stableKey() },
key = { item -> item.lazyKey },
) { keyedItem ->
val item = keyedItem.value
NuvioPosterCard(
title = item.name,
imageUrl = item.poster,
@ -326,9 +328,10 @@ private fun RowsContent(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(
items = sections,
key = { it.key },
) { section ->
items = sections.withDuplicateSafeLazyKeys { it.key },
key = { it.lazyKey },
) { keyedSection ->
val section = keyedSection.value
HomeCatalogRowSection(
section = section,
entries = section.items.take(18),

View file

@ -29,6 +29,8 @@ object TmdbCollectionSourceResolver {
when (sourceType) {
TmdbCollectionSourceType.LIST -> resolveList(source, apiKey, language, page)
TmdbCollectionSourceType.COLLECTION -> resolveCollection(source, apiKey, language)
TmdbCollectionSourceType.PERSON,
TmdbCollectionSourceType.DIRECTOR -> resolvePersonCredits(source, apiKey, language)
TmdbCollectionSourceType.COMPANY,
TmdbCollectionSourceType.NETWORK,
TmdbCollectionSourceType.DISCOVER -> resolveDiscover(source, apiKey, language, page)
@ -85,6 +87,19 @@ object TmdbCollectionSourceResolver {
)
}
TmdbCollectionSourceType.PERSON,
TmdbCollectionSourceType.DIRECTOR -> {
val body = fetch<TmdbPersonResponse>(
endpoint = "person/$id",
apiKey = apiKey,
query = mapOf("language" to language),
) ?: error("TMDB person not found")
TmdbSourceImportMetadata(
title = body.name?.takeIf { it.isNotBlank() },
coverImageUrl = imageUrl(body.profilePath, "w500"),
)
}
TmdbCollectionSourceType.DISCOVER -> TmdbSourceImportMetadata(title = "TMDB Discover")
}
}
@ -153,7 +168,7 @@ object TmdbCollectionSourceResolver {
fun parseTmdbId(input: String): Int? {
val trimmed = input.trim()
trimmed.toIntOrNull()?.let { return it }
return Regex("""(?:list|collection|company|network)/(\d+)""")
return Regex("""(?:list|collection|company|network|person)/(\d+)""")
.find(trimmed)
?.groupValues
?.getOrNull(1)
@ -193,6 +208,7 @@ object TmdbCollectionSourceResolver {
) ?: error("TMDB list not found")
val items = body.items.orEmpty()
.mapNotNull { it.toPreview() }
.sortedFor(source.sortBy)
.distinctBy { "${it.type}:${it.id}" }
return CatalogPage(
items = items,
@ -213,12 +229,35 @@ object TmdbCollectionSourceResolver {
query = mapOf("language" to language),
) ?: error("TMDB collection not found")
val items = body.parts.orEmpty()
.sortedBy { it.releaseDate ?: "9999" }
.mapNotNull { it.toPreview(TmdbCollectionMediaType.MOVIE) }
.sortedFor(source.sortBy)
.distinctBy { it.id }
return CatalogPage(items = items, rawItemCount = items.size, nextSkip = null)
}
private suspend fun resolvePersonCredits(
source: CollectionSource,
apiKey: String,
language: String,
): CatalogPage {
val id = source.tmdbId ?: error("Missing TMDB person ID")
val mediaType = source.tmdbMediaType()
val body = fetch<TmdbPersonCreditsResponse>(
endpoint = "person/$id/combined_credits",
apiKey = apiKey,
query = mapOf("language" to language),
) ?: error("TMDB person credits not found")
val items = when (source.tmdbType()) {
TmdbCollectionSourceType.DIRECTOR -> body.crew.orEmpty()
.filter { it.job.equals("Director", ignoreCase = true) }
.mapNotNull { it.toPreview(mediaType) }
else -> body.cast.orEmpty().mapNotNull { it.toPreview(mediaType) }
}
.distinctBy { "${it.type}:${it.id}" }
.sortedFor(source.sortBy)
return CatalogPage(items = items, rawItemCount = items.size, nextSkip = null)
}
private suspend fun resolveDiscover(
source: CollectionSource,
apiKey: String,
@ -312,6 +351,21 @@ object TmdbCollectionSourceResolver {
}.getOrNull()
}
private fun List<MetaPreview>.sortedFor(sortBy: String?): List<MetaPreview> =
when (sortBy) {
TmdbCollectionSort.ORIGINAL.value -> this
TmdbCollectionSort.VOTE_AVERAGE_DESC.value -> sortedWith(
compareByDescending<MetaPreview> { it.imdbRating?.toDoubleOrNull() ?: -1.0 }
.thenByDescending { it.rawReleaseDate ?: it.releaseInfo.orEmpty() },
)
TmdbCollectionSort.RELEASE_DATE_DESC.value,
TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> sortedByDescending { it.rawReleaseDate ?: it.releaseInfo.orEmpty() }
TmdbCollectionSort.POPULAR_DESC.value,
null,
"" -> this
else -> this
}
private fun TmdbListItem.toPreview(): MetaPreview? {
val media = mediaType?.lowercase()
val contentType = if (media == "tv") TmdbCollectionMediaType.TV else TmdbCollectionMediaType.MOVIE
@ -362,6 +416,62 @@ object TmdbCollectionSourceResolver {
)
}
private fun TmdbPersonCreditCast.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? {
if (!matchesMediaType(mediaType, this.mediaType)) return null
val title = title?.takeIf { it.isNotBlank() }
?: name?.takeIf { it.isNotBlank() }
?: originalTitle?.takeIf { it.isNotBlank() }
?: originalName?.takeIf { it.isNotBlank() }
?: return null
return MetaPreview(
id = "tmdb:$id",
type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie",
name = title,
poster = imageUrl(posterPath, "w500") ?: imageUrl(backdropPath, "w780"),
banner = imageUrl(backdropPath, "w1280"),
posterShape = PosterShape.Poster,
description = overview?.takeIf { it.isNotBlank() },
releaseInfo = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> releaseDate?.take(4)
TmdbCollectionMediaType.TV -> firstAirDate?.take(4)
},
rawReleaseDate = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> releaseDate
TmdbCollectionMediaType.TV -> firstAirDate
},
popularity = popularity,
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
)
}
private fun TmdbPersonCreditCrew.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? {
if (!matchesMediaType(mediaType, this.mediaType)) return null
val title = title?.takeIf { it.isNotBlank() }
?: name?.takeIf { it.isNotBlank() }
?: originalTitle?.takeIf { it.isNotBlank() }
?: originalName?.takeIf { it.isNotBlank() }
?: return null
return MetaPreview(
id = "tmdb:$id",
type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie",
name = title,
poster = imageUrl(posterPath, "w500") ?: imageUrl(backdropPath, "w780"),
banner = imageUrl(backdropPath, "w1280"),
posterShape = PosterShape.Poster,
description = overview?.takeIf { it.isNotBlank() },
releaseInfo = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> releaseDate?.take(4)
TmdbCollectionMediaType.TV -> firstAirDate?.take(4)
},
rawReleaseDate = when (mediaType) {
TmdbCollectionMediaType.MOVIE -> releaseDate
TmdbCollectionMediaType.TV -> firstAirDate
},
popularity = popularity,
imdbRating = voteAverage?.let { ((it * 10).roundToInt() / 10.0).toString() },
)
}
private fun CollectionSource.tmdbType(): TmdbCollectionSourceType =
tmdbSourceType
?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() }
@ -370,6 +480,12 @@ object TmdbCollectionSourceResolver {
private fun CollectionSource.tmdbMediaType(): TmdbCollectionMediaType =
TmdbCollectionMediaType.fromString(mediaType)
private fun matchesMediaType(expected: TmdbCollectionMediaType, actual: String?): Boolean =
when (expected) {
TmdbCollectionMediaType.MOVIE -> actual == "movie"
TmdbCollectionMediaType.TV -> actual == "tv"
}
private fun company(title: String, id: Int) = CollectionSource(
provider = "tmdb",
tmdbSourceType = TmdbCollectionSourceType.COMPANY.name,
@ -391,6 +507,7 @@ object TmdbCollectionSourceResolver {
private fun movieSort(sortBy: String?): String =
when (sortBy) {
TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> TmdbCollectionSort.RELEASE_DATE_DESC.value
TmdbCollectionSort.ORIGINAL.value -> TmdbCollectionSort.POPULAR_DESC.value
null, "" -> TmdbCollectionSort.POPULAR_DESC.value
else -> sortBy
}
@ -398,6 +515,7 @@ object TmdbCollectionSourceResolver {
private fun tvSort(sortBy: String?): String =
when (sortBy) {
TmdbCollectionSort.RELEASE_DATE_DESC.value -> TmdbCollectionSort.FIRST_AIR_DATE_DESC.value
TmdbCollectionSort.ORIGINAL.value -> TmdbCollectionSort.POPULAR_DESC.value
null, "" -> TmdbCollectionSort.POPULAR_DESC.value
else -> sortBy
}
@ -449,6 +567,12 @@ private data class TmdbNetworkResponse(
@SerialName("logo_path") val logoPath: String? = null,
)
@Serializable
private data class TmdbPersonResponse(
val name: String? = null,
@SerialName("profile_path") val profilePath: String? = null,
)
@Serializable
data class TmdbCompanySearchResult(
val id: Int,
@ -496,6 +620,47 @@ private data class TmdbGenreItem(
val name: String,
)
@Serializable
private data class TmdbPersonCreditsResponse(
val cast: List<TmdbPersonCreditCast>? = null,
val crew: List<TmdbPersonCreditCrew>? = null,
)
@Serializable
private data class TmdbPersonCreditCast(
val id: Int,
@SerialName("media_type") val mediaType: String? = null,
val title: String? = null,
val name: String? = null,
@SerialName("original_title") val originalTitle: String? = null,
@SerialName("original_name") val originalName: String? = null,
val overview: String? = null,
@SerialName("poster_path") val posterPath: String? = null,
@SerialName("backdrop_path") val backdropPath: String? = null,
@SerialName("release_date") val releaseDate: String? = null,
@SerialName("first_air_date") val firstAirDate: String? = null,
@SerialName("vote_average") val voteAverage: Double? = null,
val popularity: Double? = null,
)
@Serializable
private data class TmdbPersonCreditCrew(
val id: Int,
@SerialName("media_type") val mediaType: String? = null,
val title: String? = null,
val name: String? = null,
@SerialName("original_title") val originalTitle: String? = null,
@SerialName("original_name") val originalName: String? = null,
val overview: String? = null,
@SerialName("poster_path") val posterPath: String? = null,
@SerialName("backdrop_path") val backdropPath: String? = null,
@SerialName("release_date") val releaseDate: String? = null,
@SerialName("first_air_date") val firstAirDate: String? = null,
val job: String? = null,
@SerialName("vote_average") val voteAverage: Double? = null,
val popularity: Double? = null,
)
@Serializable
private data class TmdbListItem(
val id: Int,

View file

@ -8,6 +8,7 @@ import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.mdblist.MdbListMetadataService
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
import com.nuvio.app.features.tmdb.TmdbMetadataService
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@ -101,17 +102,22 @@ object MetaDetailsRepository {
_uiState.value = MetaDetailsUiState(isLoading = true)
scope.launch {
val manifests = AddonRepository.uiState.value.addons
.mapNotNull { it.manifest }
.filter { manifest ->
manifest.resources.any { resource ->
resource.name == "meta" &&
resource.types.contains(type) &&
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) })
}
}
val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type)
val manifests = findMetaManifests(type = type, id = metaLookupId)
if (manifests.isEmpty()) {
val tmdbMeta = tryFetchTmdbFallbackMeta(type = type, id = id)
if (tmdbMeta != null) {
publishLoadedMeta(
requestKey = requestKey,
meta = tmdbMeta,
fallbackItemId = id,
mdbListSettings = mdbListSettings,
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
)
return@launch
}
log.w { "No addon provides meta for type=$type id=$id" }
_uiState.value = MetaDetailsUiState(
errorMessage = getString(Res.string.details_no_addon_meta),
@ -122,42 +128,32 @@ object MetaDetailsRepository {
for (manifest in manifests) {
val result = withContext(Dispatchers.Default) {
tryFetchMeta(manifest, type, id, includeMdbList = false)
tryFetchMeta(manifest, type, metaLookupId, includeMdbList = false)
}
if (result != null) {
var cachedEntry = CachedMetaEntry(baseMeta = result)
cachedMetaByRequestKey[requestKey] = cachedEntry
if (!shouldFetchMdbListOnMetaScreen(result, id, mdbListSettings)) {
_uiState.value = MetaDetailsUiState(meta = result)
activeRequestKey = requestKey
return@launch
}
_uiState.value = MetaDetailsUiState(
isLoading = true,
publishLoadedMeta(
requestKey = requestKey,
meta = result,
)
val enrichedMeta = withContext(Dispatchers.Default) {
enrichForMetaScreen(
requestKey = requestKey,
meta = result,
fallbackItemId = id,
settings = mdbListSettings,
settingsFingerprint = metaScreenSettingsFingerprint,
)
}
cachedEntry = cachedEntry.copy(
metaScreenMeta = enrichedMeta,
fallbackItemId = metaLookupId,
mdbListSettings = mdbListSettings,
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
)
cachedMetaByRequestKey[requestKey] = cachedEntry
_uiState.value = MetaDetailsUiState(meta = enrichedMeta)
activeRequestKey = requestKey
return@launch
}
}
val tmdbMeta = tryFetchTmdbFallbackMeta(type = type, id = id)
if (tmdbMeta != null) {
publishLoadedMeta(
requestKey = requestKey,
meta = tmdbMeta,
fallbackItemId = id,
mdbListSettings = mdbListSettings,
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
)
return@launch
}
_uiState.value = MetaDetailsUiState(
errorMessage = getString(Res.string.details_load_failed_all_addons),
)
@ -187,19 +183,12 @@ object MetaDetailsRepository {
val requestKey = "$type:$id"
cachedMetaByRequestKey[requestKey]?.let { return it.baseMeta }
val manifests = AddonRepository.uiState.value.addons
.mapNotNull { it.manifest }
.filter { manifest ->
manifest.resources.any { resource ->
resource.name == "meta" &&
resource.types.contains(type) &&
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) })
}
}
val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type)
val manifests = findMetaManifests(type = type, id = metaLookupId)
for (manifest in manifests) {
val result = withTimeoutOrNull(FETCH_TIMEOUT_MS) {
tryFetchMeta(manifest, type, id, includeMdbList = false)
tryFetchMeta(manifest, type, metaLookupId, includeMdbList = false)
}
if (result != null) {
cachedMetaByRequestKey[requestKey] = CachedMetaEntry(baseMeta = result)
@ -207,7 +196,9 @@ object MetaDetailsRepository {
}
}
return null
return tryFetchTmdbFallbackMeta(type = type, id = id)?.also { result ->
cachedMetaByRequestKey[requestKey] = CachedMetaEntry(baseMeta = result)
}
}
private const val FETCH_TIMEOUT_MS = 5_000L
@ -265,6 +256,78 @@ object MetaDetailsRepository {
}
}
private fun findMetaManifests(type: String, id: String): List<AddonManifest> =
AddonRepository.uiState.value.addons
.mapNotNull { it.manifest }
.filter { manifest ->
manifest.resources.any { resource ->
resource.name == "meta" &&
resource.types.contains(type) &&
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) })
}
}
private suspend fun resolveMetaLookupId(itemId: String, itemType: String): String {
val tmdbId = itemId
.takeIf { it.startsWith("tmdb:", ignoreCase = true) }
?.substringAfter(':')
?.substringBefore(':')
?.toIntOrNull()
?: return itemId
return withTimeoutOrNull(FETCH_TIMEOUT_MS) {
TmdbService.tmdbToImdb(tmdbId = tmdbId, mediaType = itemType)
}
?.takeIf { it.isNotBlank() }
?: itemId
}
private suspend fun tryFetchTmdbFallbackMeta(type: String, id: String): MetaDetails? =
withTimeoutOrNull(TMDB_ENRICH_TIMEOUT_MS) {
TmdbMetadataService.fetchStandaloneMeta(
type = type,
id = id,
settings = TmdbSettingsRepository.snapshot(),
)
}
private suspend fun publishLoadedMeta(
requestKey: String,
meta: MetaDetails,
fallbackItemId: String,
mdbListSettings: com.nuvio.app.features.mdblist.MdbListSettings,
metaScreenSettingsFingerprint: String,
) {
val cachedEntry = CachedMetaEntry(baseMeta = meta)
cachedMetaByRequestKey[requestKey] = cachedEntry
if (!shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, mdbListSettings)) {
_uiState.value = MetaDetailsUiState(meta = meta)
activeRequestKey = requestKey
return
}
_uiState.value = MetaDetailsUiState(
isLoading = true,
meta = meta,
)
val enrichedMeta = withContext(Dispatchers.Default) {
enrichForMetaScreen(
requestKey = requestKey,
meta = meta,
fallbackItemId = fallbackItemId,
settings = mdbListSettings,
settingsFingerprint = metaScreenSettingsFingerprint,
)
}
cachedMetaByRequestKey[requestKey] = cachedEntry.copy(
metaScreenMeta = enrichedMeta,
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
)
_uiState.value = MetaDetailsUiState(meta = enrichedMeta)
activeRequestKey = requestKey
}
private suspend fun enrichForMetaScreen(
requestKey: String,
meta: MetaDetails,

View file

@ -38,6 +38,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
import com.nuvio.app.features.trakt.TraktCommentReview
import kotlinx.coroutines.flow.distinctUntilChanged
import nuvio.composeapp.generated.resources.*
@ -122,7 +123,11 @@ fun DetailCommentsSection(
state = listState,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(comments, key = { it.id }) { review ->
items(
items = comments.withDuplicateSafeLazyKeys { it.id },
key = { it.lazyKey },
) { keyedReview ->
val review = keyedReview.value
CommentCard(
review = review,
onClick = { onCommentClick(review) },

View file

@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@ -582,7 +583,10 @@ private fun EpisodeHorizontalRow(
contentPadding = PaddingValues(horizontal = rowMetrics.rowHorizontalPadding, vertical = rowMetrics.rowVerticalPadding),
horizontalArrangement = Arrangement.spacedBy(rowMetrics.itemSpacing),
) {
items(episodes, key = { it.id }) { episode ->
itemsIndexed(
items = episodes,
key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" },
) { _, episode ->
val episodeVideoId = buildPlaybackVideoId(
parentMetaId = parentMetaId,
seasonNumber = episode.season,

View file

@ -13,7 +13,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
@ -158,10 +158,10 @@ fun DetailTrailersSection(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(sizing.cardSpacing),
) {
items(
itemsIndexed(
items = selectedTrailers,
key = { trailer -> "${trailer.type}-${trailer.id}-${trailer.seasonNumber ?: 0}" },
) { trailer ->
key = { index, trailer -> "${trailer.type}-${trailer.id}-${trailer.seasonNumber ?: 0}#$index" },
) { _, trailer ->
TrailerCard(
trailer = trailer,
cardWidth = sizing.cardWidth,

View file

@ -291,7 +291,10 @@ private fun EpisodesListSubView(
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp),
) {
items(seasonEpisodes, key = { "${it.season}:${it.episode}:${it.id}" }) { episode ->
itemsIndexed(
items = seasonEpisodes,
key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" },
) { _, episode ->
val isCurrent = episode.season == currentSeason && episode.episode == currentEpisode
EpisodeRow(
episode = episode,

View file

@ -44,6 +44,7 @@ import com.nuvio.app.core.ui.NuvioInputField
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.components.HomeCatalogRowSection
@ -303,9 +304,10 @@ fun SearchScreen(
else -> {
items(
items = uiState.sections,
key = { section -> section.key },
) { section ->
items = uiState.sections.withDuplicateSafeLazyKeys { section -> section.key },
key = { section -> section.lazyKey },
) { keyedSection ->
val section = keyedSection.value
HomeCatalogRowSection(
section = section,
modifier = Modifier.padding(bottom = 12.dp),

View file

@ -638,6 +638,69 @@ object TmdbMetadataService {
)
}
suspend fun fetchStandaloneMeta(
type: String,
id: String,
settings: TmdbSettings,
): MetaDetails? {
if (!settings.hasApiKey) return null
val tmdbId = id
.takeIf { it.startsWith("tmdb:", ignoreCase = true) }
?.substringAfter(':')
?.substringBefore(':')
?.toIntOrNull()
?: return null
val tmdbType = normalizeMetaType(type)
val enrichment = fetchEnrichment(
tmdbId = tmdbId.toString(),
mediaType = tmdbType,
language = settings.language,
settings = settings,
) ?: return null
return buildStandaloneMeta(
type = type,
id = id,
tmdbId = tmdbId,
enrichment = enrichment,
)
}
internal fun buildStandaloneMeta(
type: String,
id: String,
tmdbId: Int,
enrichment: TmdbEnrichment,
): MetaDetails =
MetaDetails(
id = id,
type = type,
name = enrichment.localizedTitle ?: "TMDB $tmdbId",
poster = enrichment.poster,
background = enrichment.backdrop,
logo = enrichment.logo,
description = enrichment.description,
releaseInfo = enrichment.releaseInfo,
lastAirDate = enrichment.lastAirDate,
status = enrichment.status,
imdbRating = enrichment.rating?.formatRating(),
ageRating = enrichment.ageRating,
runtime = enrichment.runtimeMinutes?.formatRuntime(),
genres = enrichment.genres,
director = enrichment.director,
writer = enrichment.writer,
cast = enrichment.people,
productionCompanies = enrichment.productionCompanies,
networks = enrichment.networks,
country = enrichment.countries.takeIf { it.isNotEmpty() }?.joinToString(", "),
language = enrichment.language,
moreLikeThis = enrichment.moreLikeThis,
collectionName = enrichment.collectionName,
collectionItems = enrichment.collectionItems,
trailers = enrichment.trailers,
)
internal fun applyEnrichment(
meta: MetaDetails,
enrichment: TmdbEnrichment?,

View file

@ -8,6 +8,47 @@ import kotlin.test.Test
import kotlin.test.assertEquals
class TmdbMetadataServiceTest {
@Test
fun `buildStandaloneMeta maps tmdb enrichment without addon meta`() {
val enrichment = TmdbEnrichment(
localizedTitle = "TMDB Movie",
description = "TMDB description",
genres = listOf("Adventure"),
backdrop = "backdrop",
logo = "logo",
poster = "poster",
people = listOf(MetaPerson(name = "Cast Member", role = "Hero")),
director = listOf("Director"),
writer = listOf("Writer"),
releaseInfo = "2026-01-01",
rating = 8.4,
runtimeMinutes = 105,
ageRating = "PG-13",
status = "Released",
countries = listOf("US", "GB"),
language = "en",
productionCompanies = listOf(MetaCompany(name = "Studio")),
networks = emptyList(),
)
val result = TmdbMetadataService.buildStandaloneMeta(
type = "movie",
id = "tmdb:123",
tmdbId = 123,
enrichment = enrichment,
)
assertEquals("tmdb:123", result.id)
assertEquals("movie", result.type)
assertEquals("TMDB Movie", result.name)
assertEquals("TMDB description", result.description)
assertEquals("8.4", result.imdbRating)
assertEquals("105m", result.runtime)
assertEquals("US, GB", result.country)
assertEquals(listOf("Cast Member"), result.cast.map { it.name })
assertEquals(listOf("Studio"), result.productionCompanies.map { it.name })
}
@Test
fun `applyEnrichment replaces enabled metadata groups`() {
val base = MetaDetails(

View file

@ -1,39 +1,52 @@
package com.nuvio.app.features.downloads
import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.isSuccess
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.readAvailable
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.convert
import kotlinx.cinterop.usePinned
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import platform.Foundation.NSError
import platform.Foundation.NSDate
import platform.Foundation.NSFileManager
import platform.Foundation.NSHTTPURLResponse
import platform.Foundation.NSHomeDirectory
import platform.Foundation.NSMutableURLRequest
import platform.Foundation.NSOperationQueue
import platform.Foundation.NSURL
import platform.posix.fclose
import platform.Foundation.NSURLRequestReloadIgnoringLocalCacheData
import platform.Foundation.NSURLResponse
import platform.Foundation.NSURLSession
import platform.Foundation.NSURLSessionConfiguration
import platform.Foundation.NSURLSessionDownloadDelegateProtocol
import platform.Foundation.NSURLSessionDownloadTask
import platform.Foundation.NSURLSessionTask
import platform.Foundation.setHTTPMethod
import platform.Foundation.setValue
import platform.Foundation.timeIntervalSince1970
import platform.darwin.NSObject
import platform.posix.fopen
import platform.posix.fclose
import platform.posix.fread
import platform.posix.fwrite
private val downloadHttpClient = HttpClient(Darwin) {
install(HttpTimeout) {
requestTimeoutMillis = 60_000
connectTimeoutMillis = 60_000
socketTimeoutMillis = 60_000
}
expectSuccess = false
private const val DOWNLOAD_REQUEST_TIMEOUT_SECONDS = 60.0
private const val DOWNLOAD_RESOURCE_TIMEOUT_SECONDS = 24.0 * 60.0 * 60.0
private const val PROGRESS_MIN_INTERVAL_SECONDS = 0.5
private const val PROGRESS_MIN_BYTE_DELTA = 512L * 1024L
private val backgroundSessionCompletionHandlers = mutableMapOf<String, () -> Unit>()
fun handleDownloadsBackgroundEvents(
identifier: String,
completionHandler: () -> Unit,
) {
backgroundSessionCompletionHandlers[identifier] = completionHandler
}
@OptIn(ExperimentalForeignApi::class)
@ -46,6 +59,7 @@ internal actual object DownloadsPlatformDownloader {
): DownloadsTaskHandle {
val job = SupervisorJob()
val scope = CoroutineScope(job + Dispatchers.Default)
val handle = IosDownloadsTaskHandle(job)
scope.launch {
val downloadsDirectory = downloadsDirectoryPath()
@ -55,55 +69,42 @@ internal actual object DownloadsPlatformDownloader {
try {
var resumeFromBytes = fileSizeOrNull(tempPath)?.coerceAtLeast(0L) ?: 0L
suspend fun performRequest(rangeStart: Long?) = downloadHttpClient.get(request.sourceUrl) {
request.sourceHeaders.forEach { (key, value) ->
header(key, value)
}
if (rangeStart != null && rangeStart > 0L) {
header("Range", "bytes=$rangeStart-")
}
}
var attemptedRangeRequest = resumeFromBytes > 0L
var response = performRequest(if (attemptedRangeRequest) resumeFromBytes else null)
var result = performDownloadRequest(
request = request,
rangeStart = if (attemptedRangeRequest) resumeFromBytes else null,
resumeFromBytes = resumeFromBytes,
tempPath = tempPath,
handle = handle,
onProgress = onProgress,
)
if (attemptedRangeRequest && response.status.value == 416) {
if (attemptedRangeRequest && result.statusCode == 416) {
removePathIfExists(tempPath)
resumeFromBytes = 0L
attemptedRangeRequest = false
response = performRequest(null)
result = performDownloadRequest(
request = request,
rangeStart = null,
resumeFromBytes = 0L,
tempPath = tempPath,
handle = handle,
onProgress = onProgress,
)
}
if (!response.status.isSuccess()) {
error("Request failed with HTTP ${response.status.value}")
}
val isPartialResume = attemptedRangeRequest && response.status.value == 206 && resumeFromBytes > 0L
val appendToTemp = isPartialResume
val startingBytes = if (appendToTemp) resumeFromBytes else 0L
if (!appendToTemp) {
removePathIfExists(tempPath)
if (result.statusCode !in 200..299) {
error("Request failed with HTTP ${result.statusCode}")
}
val isPartialResume = attemptedRangeRequest && result.statusCode == 206 && resumeFromBytes > 0L
val startingBytes = if (isPartialResume) resumeFromBytes else 0L
val totalBytes = resolveTotalBytes(
startingBytes = startingBytes,
isPartialResume = isPartialResume,
contentRangeHeader = response.headers["Content-Range"],
contentLength = response.headers["Content-Length"]?.toLongOrNull()?.takeIf { it > 0L },
contentRangeHeader = result.contentRange,
contentLength = result.contentLength,
)
val channel = response.bodyAsChannel()
val wrote = writeChannelToFile(
channel = channel,
path = tempPath,
append = appendToTemp,
initialDownloadedBytes = startingBytes,
totalBytes = totalBytes,
onProgress = onProgress,
)
if (!wrote) {
error("Failed to write download file")
}
removePathIfExists(destinationPath)
val moved = NSFileManager.defaultManager.moveItemAtPath(
@ -118,12 +119,14 @@ internal actual object DownloadsPlatformDownloader {
val localFileUri = NSURL.fileURLWithPath(destinationPath).absoluteString ?: "file://$destinationPath"
val finalSize = fileSizeOrNull(destinationPath)
onSuccess(localFileUri, totalBytes ?: finalSize)
} catch (_: CancellationException) {
handle.cancelNativeTask()
} catch (error: Throwable) {
onFailure(error.message ?: "Download failed")
}
}
return IosDownloadsTaskHandle(job)
return handle
}
actual fun removeFile(localFileUri: String?): Boolean {
@ -141,9 +144,172 @@ internal actual object DownloadsPlatformDownloader {
private class IosDownloadsTaskHandle(
private val job: Job,
) : DownloadsTaskHandle {
private var task: NSURLSessionDownloadTask? = null
private var session: NSURLSession? = null
fun attach(task: NSURLSessionDownloadTask, session: NSURLSession) {
this.task = task
this.session = session
}
override fun cancel() {
cancelNativeTask()
job.cancel()
}
fun cancelNativeTask() {
task?.cancel()
session?.invalidateAndCancel()
task = null
session = null
}
}
private data class IosDownloadResult(
val statusCode: Int,
val contentRange: String?,
val contentLength: Long?,
)
@OptIn(ExperimentalForeignApi::class)
private class IosDownloadDelegate(
private val attemptedRangeRequest: Boolean,
private val resumeFromBytes: Long,
private val tempPath: String,
private val onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
) : NSObject(), NSURLSessionDownloadDelegateProtocol {
private val completion = CompletableDeferred<IosDownloadResult>()
private var result: IosDownloadResult? = null
private var fileError: Throwable? = null
private var lastProgressBytes = -1L
private var lastProgressTimestampSeconds = 0.0
suspend fun awaitCompletion(): IosDownloadResult = completion.await()
override fun URLSession(
session: NSURLSession,
downloadTask: NSURLSessionDownloadTask,
didFinishDownloadingToURL: NSURL,
) {
val httpResponse = downloadTask.response as? NSHTTPURLResponse
val statusCode = httpResponse?.statusCode?.toInt() ?: 200
result = IosDownloadResult(
statusCode = statusCode,
contentRange = httpResponse?.valueForHTTPHeaderField("Content-Range"),
contentLength = httpResponse
?.valueForHTTPHeaderField("Content-Length")
?.toLongOrNull()
?.takeIf { it > 0L },
)
if (statusCode !in 200..299) return
val sourcePath = didFinishDownloadingToURL.path
if (sourcePath.isNullOrBlank()) {
fileError = IllegalStateException("Downloaded file was not available")
return
}
val isPartialResume = attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L
val stored = if (isPartialResume) {
appendFile(sourcePath, tempPath)
} else {
removePathIfExists(tempPath) &&
NSFileManager.defaultManager.moveItemAtPath(
srcPath = sourcePath,
toPath = tempPath,
error = null,
)
}
if (!stored) {
fileError = IllegalStateException("Failed to store download file")
}
}
override fun URLSession(
session: NSURLSession,
downloadTask: NSURLSessionDownloadTask,
didWriteData: Long,
totalBytesWritten: Long,
totalBytesExpectedToWrite: Long,
) {
val statusCode = (downloadTask.response as? NSHTTPURLResponse)?.statusCode?.toInt()
val startingBytes = if (attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L) {
resumeFromBytes
} else {
0L
}
val expectedTotal = totalBytesExpectedToWrite
.takeIf { it > 0L }
?.let { startingBytes + it }
reportProgress(
downloadedBytes = startingBytes + totalBytesWritten.coerceAtLeast(0L),
totalBytes = expectedTotal,
)
}
override fun URLSession(
session: NSURLSession,
task: NSURLSessionTask,
didCompleteWithError: NSError?,
) {
if (didCompleteWithError != null) {
completion.completeExceptionally(
IllegalStateException(didCompleteWithError.localizedDescription),
)
return
}
val error = fileError
if (error != null) {
completion.completeExceptionally(error)
return
}
completion.complete(result ?: task.response.toDownloadResult())
}
override fun URLSessionDidFinishEventsForBackgroundURLSession(session: NSURLSession) {
val identifier = session.configuration.identifier ?: return
backgroundSessionCompletionHandlers.remove(identifier)?.invoke()
}
private fun reportProgress(
downloadedBytes: Long,
totalBytes: Long?,
) {
val normalizedDownloadedBytes = downloadedBytes.coerceAtLeast(0L)
val now = NSDate().timeIntervalSince1970
val byteDelta = normalizedDownloadedBytes - lastProgressBytes
val timeDelta = now - lastProgressTimestampSeconds
val reachedEnd = totalBytes != null && normalizedDownloadedBytes >= totalBytes
if (
lastProgressBytes >= 0L &&
!reachedEnd &&
byteDelta < PROGRESS_MIN_BYTE_DELTA &&
timeDelta < PROGRESS_MIN_INTERVAL_SECONDS
) {
return
}
lastProgressBytes = normalizedDownloadedBytes
lastProgressTimestampSeconds = now
onProgress(normalizedDownloadedBytes, totalBytes)
}
}
private fun NSURLResponse?.toDownloadResult(): IosDownloadResult {
val httpResponse = this as? NSHTTPURLResponse
return IosDownloadResult(
statusCode = httpResponse?.statusCode?.toInt() ?: 200,
contentRange = httpResponse?.valueForHTTPHeaderField("Content-Range"),
contentLength = httpResponse
?.valueForHTTPHeaderField("Content-Length")
?.toLongOrNull()
?.takeIf { it > 0L },
)
}
@OptIn(ExperimentalForeignApi::class)
@ -166,45 +332,98 @@ private fun removePathIfExists(path: String): Boolean {
}
@OptIn(ExperimentalForeignApi::class)
private suspend fun writeChannelToFile(
channel: ByteReadChannel,
path: String,
append: Boolean,
initialDownloadedBytes: Long,
totalBytes: Long?,
private suspend fun performDownloadRequest(
request: DownloadPlatformRequest,
rangeStart: Long?,
resumeFromBytes: Long,
tempPath: String,
handle: IosDownloadsTaskHandle,
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
): Boolean {
val file = fopen(path, if (append) "ab" else "wb") ?: return false
): IosDownloadResult {
val url = NSURL(string = request.sourceUrl)
val nativeRequest = NSMutableURLRequest(
uRL = url,
cachePolicy = NSURLRequestReloadIgnoringLocalCacheData,
timeoutInterval = DOWNLOAD_REQUEST_TIMEOUT_SECONDS,
)
nativeRequest.setHTTPMethod("GET")
nativeRequest.setAllowsCellularAccess(true)
nativeRequest.setAllowsExpensiveNetworkAccess(true)
nativeRequest.setAllowsConstrainedNetworkAccess(true)
request.sourceHeaders.forEach { (key, value) ->
nativeRequest.setValue(value, forHTTPHeaderField = key)
}
if (rangeStart != null && rangeStart > 0L) {
nativeRequest.setValue("bytes=$rangeStart-", forHTTPHeaderField = "Range")
}
val delegate = IosDownloadDelegate(
attemptedRangeRequest = rangeStart != null && rangeStart > 0L,
resumeFromBytes = resumeFromBytes,
tempPath = tempPath,
onProgress = onProgress,
)
val configuration = NSURLSessionConfiguration.defaultSessionConfiguration().apply {
timeoutIntervalForRequest = DOWNLOAD_REQUEST_TIMEOUT_SECONDS
timeoutIntervalForResource = DOWNLOAD_RESOURCE_TIMEOUT_SECONDS
waitsForConnectivity = true
allowsCellularAccess = true
allowsExpensiveNetworkAccess = true
allowsConstrainedNetworkAccess = true
}
val session = NSURLSession.sessionWithConfiguration(
configuration = configuration,
delegate = delegate,
delegateQueue = NSOperationQueue(),
)
val task = session.downloadTaskWithRequest(nativeRequest)
handle.attach(task, session)
onProgress(resumeFromBytes.coerceAtLeast(0L), null)
task.resume()
return try {
delegate.awaitCompletion()
} finally {
session.finishTasksAndInvalidate()
}
}
@OptIn(ExperimentalForeignApi::class)
private fun appendFile(sourcePath: String, destinationPath: String): Boolean {
val source = fopen(sourcePath, "rb") ?: return false
val destination = fopen(destinationPath, "ab") ?: run {
fclose(source)
return false
}
val buffer = ByteArray(16 * 1024)
var downloadedBytes = initialDownloadedBytes
onProgress(downloadedBytes, totalBytes)
return try {
while (true) {
kotlinx.coroutines.currentCoroutineContext().ensureActive()
val read = channel.readAvailable(buffer, 0, buffer.size)
if (read < 0) break
if (read == 0) continue
val read = buffer.usePinned { pinned ->
fread(
pinned.addressOf(0),
1.convert(),
buffer.size.convert(),
source,
).toInt()
}
if (read <= 0) break
val wroteChunk = buffer.usePinned { pinned ->
val written = fwrite(
val wrote = buffer.usePinned { pinned ->
fwrite(
pinned.addressOf(0),
1.convert(),
read.convert(),
file,
)
written.toInt() == read
destination,
).toInt()
}
if (!wroteChunk) {
return false
}
downloadedBytes += read.toLong()
onProgress(downloadedBytes, totalBytes)
if (wrote != read) return false
}
true
} finally {
fclose(file)
fclose(source)
fclose(destination)
}
}

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=42
MARKETING_VERSION=0.1.10
CURRENT_PROJECT_VERSION=48
MARKETING_VERSION=0.1.0

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E1B229BC363ABB711AF255E3"
BuildableName = "Nuvio.app"
BlueprintName = "iosApp"
ReferencedContainer = "container:iosApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E1B229BC363ABB711AF255E3"
BuildableName = "Nuvio.app"
BlueprintName = "iosApp"
ReferencedContainer = "container:iosApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E1B229BC363ABB711AF255E3"
BuildableName = "Nuvio.app"
BlueprintName = "iosApp"
ReferencedContainer = "container:iosApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -17,6 +17,11 @@
</array>
</dict>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>

View file

@ -23,6 +23,17 @@ final class OrientationLockAppDelegate: NSObject, UIApplicationDelegate, UNUserN
OrientationLockCoordinator.shared.supportedOrientations
}
func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
DownloadsPlatformDownloader_iosKt.handleDownloadsBackgroundEvents(
identifier: identifier,
completionHandler: completionHandler
)
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,

@ -1 +0,0 @@
Subproject commit df33966d7fbc6eb14e43fb1892e062417d76e7f5

@ -1 +0,0 @@
Subproject commit 8a8ddddf430555878273da13006fc57e182b0c0c

1
vendor/quickjs-kt vendored Submodule

@ -0,0 +1 @@
Subproject commit 57ce096200ac36bceb4e1ee5b6ec411b12357eb8