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 { afterEvaluate {
dependencies { dependencies {
add("fullImplementation", libs.quickjs.kt) add("fullImplementation", files("libs/quickjs-kt-android-1.0.5-nuvio.aar"))
add("fullImplementation", libs.ksoup) 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_production_mode">Production</string>
<string name="collections_editor_tmdb_network_mode">Network</string> <string name="collections_editor_tmdb_network_mode">Network</string>
<string name="collections_editor_tmdb_collection_mode">Collection</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_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_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_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_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_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_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_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_public_list">Public TMDB list</string>
<string name="collections_editor_tmdb_network_id">Network ID</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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_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_search_results">Search Results</string>
<string name="collections_editor_tmdb_collection">TMDB Collection</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_disney_plus">Disney+</string>
<string name="collections_editor_tmdb_network_prime_video">Prime Video</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_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_popular">Popular</string>
<string name="collections_editor_tmdb_sort_top_rated">Top Rated</string> <string name="collections_editor_tmdb_sort_top_rated">Top Rated</string>
<string name="collections_editor_tmdb_sort_recent">Recent</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_movie_collection">TMDB Movie Collection</string>
<string name="collections_editor_tmdb_subtitle_production">Production</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_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_editor_tmdb_subtitle_discover">TMDB Discover</string>
<string name="collections_empty_subtitle">Create one to organize your catalogs.</string> <string name="collections_empty_subtitle">Create one to organize your catalogs.</string>
<string name="collections_empty_title">No collections yet</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) { if (key != null) {
items( items(
items = entries, items = entries.withDuplicateSafeLazyKeys(key),
key = key, key = { entry -> entry.lazyKey },
) { entry -> ) { keyedEntry ->
itemContent(entry) itemContent(keyedEntry.value)
} }
} else { } else {
items(entries) { entry -> 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.rememberPosterCardStyleUiState
import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.posterCardClickable
import com.nuvio.app.core.ui.nuvioSafeBottomPadding 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.MetaPreview
import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.home.stableKey import com.nuvio.app.features.home.stableKey
@ -175,9 +176,10 @@ fun CatalogScreen(
} }
} else { } else {
items( items(
items = uiState.items, items = uiState.items.withDuplicateSafeLazyKeys { item -> item.stableKey() },
key = { item -> item.stableKey() }, key = { item -> item.lazyKey },
) { item -> ) { keyedItem ->
val item = keyedItem.value
CatalogPosterTile( CatalogPosterTile(
item = item, item = item,
cornerRadiusDp = posterCardStyle.cornerRadiusDp, 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, PRODUCTION,
NETWORK, NETWORK,
COLLECTION, COLLECTION,
PERSON,
DIRECTOR,
DISCOVER, DISCOVER,
} }
@ -340,9 +342,15 @@ object CollectionEditorRepository {
} else { } else {
_uiState.value.tmdbMediaType _uiState.value.tmdbMediaType
} }
val sortBy = when (mode) {
TmdbBuilderMode.LIST,
TmdbBuilderMode.COLLECTION -> TmdbCollectionSort.ORIGINAL.value
else -> TmdbCollectionSort.POPULAR_DESC.value
}
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
tmdbBuilderMode = mode, tmdbBuilderMode = mode,
tmdbMediaType = mediaType, tmdbMediaType = mediaType,
tmdbSortBy = sortBy,
tmdbMediaBoth = if ( tmdbMediaBoth = if (
mode == TmdbBuilderMode.NETWORK || mode == TmdbBuilderMode.NETWORK ||
mode == TmdbBuilderMode.LIST || mode == TmdbBuilderMode.LIST ||
@ -459,6 +467,8 @@ object CollectionEditorRepository {
TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION
TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY
TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK
TmdbBuilderMode.PERSON -> TmdbCollectionSourceType.PERSON
TmdbBuilderMode.DIRECTOR -> TmdbCollectionSourceType.DIRECTOR
TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER
} }
val id = TmdbCollectionSourceResolver.parseTmdbId(state.tmdbInput) val id = TmdbCollectionSourceResolver.parseTmdbId(state.tmdbInput)
@ -473,6 +483,8 @@ object CollectionEditorRepository {
TmdbCollectionSourceType.COLLECTION -> "TMDB Collection ${id ?: ""}".trim() TmdbCollectionSourceType.COLLECTION -> "TMDB Collection ${id ?: ""}".trim()
TmdbCollectionSourceType.COMPANY -> "TMDB Production ${id ?: ""}".trim() TmdbCollectionSourceType.COMPANY -> "TMDB Production ${id ?: ""}".trim()
TmdbCollectionSourceType.NETWORK -> "TMDB Network ${id ?: ""}".trim() TmdbCollectionSourceType.NETWORK -> "TMDB Network ${id ?: ""}".trim()
TmdbCollectionSourceType.PERSON -> "TMDB Person ${id ?: ""}".trim()
TmdbCollectionSourceType.DIRECTOR -> "TMDB Director ${id ?: ""}".trim()
TmdbCollectionSourceType.DISCOVER -> "TMDB Discover" TmdbCollectionSourceType.DISCOVER -> "TMDB Discover"
} }
} }
@ -561,6 +573,8 @@ private val coverMetadataSourceTypes = setOf(
TmdbCollectionSourceType.COLLECTION, TmdbCollectionSourceType.COLLECTION,
TmdbCollectionSourceType.COMPANY, TmdbCollectionSourceType.COMPANY,
TmdbCollectionSourceType.NETWORK, TmdbCollectionSourceType.NETWORK,
TmdbCollectionSourceType.PERSON,
TmdbCollectionSourceType.DIRECTOR,
) )
private fun CollectionCatalogSource.toCollectionSource(): CollectionSource = private fun CollectionCatalogSource.toCollectionSource(): CollectionSource =
@ -591,6 +605,8 @@ private fun selectedMediaTypes(
): List<TmdbCollectionMediaType> = ): List<TmdbCollectionMediaType> =
when (sourceType) { when (sourceType) {
TmdbCollectionSourceType.COMPANY, TmdbCollectionSourceType.COMPANY,
TmdbCollectionSourceType.PERSON,
TmdbCollectionSourceType.DIRECTOR,
TmdbCollectionSourceType.DISCOVER -> if (state.tmdbMediaBoth) { TmdbCollectionSourceType.DISCOVER -> if (state.tmdbMediaBoth) {
listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV) listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV)
} else { } else {

View file

@ -111,9 +111,7 @@ fun CollectionEditorScreen(
val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) } val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) }
val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource() val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource()
val genrePickerCatalog = genrePickerCatalogSource?.let { source -> val genrePickerCatalog = genrePickerCatalogSource?.let { source ->
state.availableCatalogs.find { state.availableCatalogs.findAvailableCatalog(source)
it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId
}
} }
FolderEditorPage( FolderEditorPage(
@ -757,11 +755,7 @@ private fun FolderEditorPage(
} else if (addonSource != null) { } else if (addonSource != null) {
FolderCatalogSourceCard( FolderCatalogSourceCard(
source = addonSource, source = addonSource,
matchingCatalog = state.availableCatalogs.find { matchingCatalog = state.availableCatalogs.findAvailableCatalog(addonSource),
it.addonId == addonSource.addonId &&
it.type == addonSource.type &&
it.catalogId == addonSource.catalogId
},
onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, onRemove = { CollectionEditorRepository.removeCatalogSource(index) },
onOpenGenrePicker = { CollectionEditorRepository.showGenrePicker(index) }, onOpenGenrePicker = { CollectionEditorRepository.showGenrePicker(index) },
) )
@ -897,13 +891,19 @@ private fun TmdbSourcePickerScreen(
TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION TmdbBuilderMode.COLLECTION -> TmdbCollectionSourceType.COLLECTION
TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY TmdbBuilderMode.PRODUCTION -> TmdbCollectionSourceType.COMPANY
TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK TmdbBuilderMode.NETWORK -> TmdbCollectionSourceType.NETWORK
TmdbBuilderMode.PERSON -> TmdbCollectionSourceType.PERSON
TmdbBuilderMode.DIRECTOR -> TmdbCollectionSourceType.DIRECTOR
TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER TmdbBuilderMode.DISCOVER -> TmdbCollectionSourceType.DISCOVER
} }
val requiresId = sourceType != TmdbCollectionSourceType.DISCOVER val requiresId = sourceType != TmdbCollectionSourceType.DISCOVER
val showMediaControls = state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION || val showMediaControls = state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION ||
state.tmdbBuilderMode == TmdbBuilderMode.PERSON ||
state.tmdbBuilderMode == TmdbBuilderMode.DIRECTOR ||
state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER
val showSortControls = state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION || val showSortControls = state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION ||
state.tmdbBuilderMode == TmdbBuilderMode.NETWORK || state.tmdbBuilderMode == TmdbBuilderMode.NETWORK ||
state.tmdbBuilderMode == TmdbBuilderMode.PERSON ||
state.tmdbBuilderMode == TmdbBuilderMode.DIRECTOR ||
state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER
val showFilterControls = 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.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_production_mode)
TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_mode) TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_mode)
TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_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) 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.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_help_production)
TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_help_network) TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_help_network)
TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_help_collection) 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) 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.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_id)
TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_id) TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_id)
TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_company_search) 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) 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.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_placeholder)
TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_placeholder) TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_placeholder)
TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_company_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) 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.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_helper)
TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_helper) TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_helper)
TmdbBuilderMode.LIST -> stringResource(Res.string.collections_editor_tmdb_list_helper) TmdbBuilderMode.LIST -> stringResource(Res.string.collections_editor_tmdb_list_helper)
TmdbBuilderMode.PERSON,
TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_person_helper)
else -> "" else -> ""
} }
@ -1940,12 +1950,15 @@ private fun tmdbInputHelper(mode: TmdbBuilderMode): String =
private fun tmdbTitlePlaceholder(mode: TmdbBuilderMode): String = private fun tmdbTitlePlaceholder(mode: TmdbBuilderMode): String =
when (mode) { when (mode) {
TmdbBuilderMode.DISCOVER -> stringResource(Res.string.collections_editor_tmdb_discover_title_placeholder) 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) else -> stringResource(Res.string.collections_editor_tmdb_title_placeholder)
} }
@Composable @Composable
private fun tmdbSortLabel(sort: TmdbCollectionSort): String = private fun tmdbSortLabel(sort: TmdbCollectionSort): String =
when (sort) { when (sort) {
TmdbCollectionSort.ORIGINAL -> stringResource(Res.string.collections_editor_tmdb_sort_original)
TmdbCollectionSort.POPULAR_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_popular) 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.VOTE_AVERAGE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_top_rated)
TmdbCollectionSort.RELEASE_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent) 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), stringResource(Res.string.collections_editor_tmdb_series),
sort, sort,
).joinToString("") ).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( TmdbCollectionSourceType.DISCOVER -> listOf(
stringResource(Res.string.collections_editor_tmdb_subtitle_discover), stringResource(Res.string.collections_editor_tmdb_subtitle_discover),
media, media,

View file

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

View file

@ -140,16 +140,19 @@ object FolderDetailRepository {
source = source, source = source,
type = type, type = type,
catalogId = tmdbCatalogId(source), 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, isLoading = true,
), ),
) )
} else { } else {
val catalogSource = source.addonCatalogSource() ?: return@forEach val catalogSource = source.addonCatalogSource() ?: return@forEach
val addon = addons.find { it.manifest?.id == catalogSource.addonId } val resolvedCatalog = addons.findCollectionCatalog(catalogSource)
val catalog = addon?.manifest?.catalogs?.find { val addon = resolvedCatalog?.addon
it.id == catalogSource.catalogId && it.type == catalogSource.type val catalog = resolvedCatalog?.catalog
}
val label = catalog?.name ?: catalogSource.catalogId val label = catalog?.name ?: catalogSource.catalogId
val typeLabel = localizedMediaTypeLabel(catalogSource.type) val typeLabel = localizedMediaTypeLabel(catalogSource.type)
val genreSuffix = if (catalogSource.genre != null) " · ${catalogSource.genre}" else "" val genreSuffix = if (catalogSource.genre != null) " · ${catalogSource.genre}" else ""
@ -184,8 +187,8 @@ object FolderDetailRepository {
sources.forEachIndexed { sourceIndex, source -> sources.forEachIndexed { sourceIndex, source ->
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
val catalogSource = source.addonCatalogSource() val catalogSource = source.addonCatalogSource()
val addon = catalogSource?.let { value -> addons.find { it.manifest?.id == value.addonId } } val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) }
if (!source.isTmdb && addon == null) { if (!source.isTmdb && resolvedCatalog == null) {
updateTab(tabIndex) { updateTab(tabIndex) {
it.copy( it.copy(
isLoading = false, 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.NuvioPosterShape
import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.nuvioSafeBottomPadding 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.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.PosterShape
@ -275,9 +276,10 @@ private fun TabbedGridContent(
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
items( items(
items = selectedTab.items, items = selectedTab.items.withDuplicateSafeLazyKeys { item -> item.stableKey() },
key = { item -> item.stableKey() }, key = { item -> item.lazyKey },
) { item -> ) { keyedItem ->
val item = keyedItem.value
NuvioPosterCard( NuvioPosterCard(
title = item.name, title = item.name,
imageUrl = item.poster, imageUrl = item.poster,
@ -326,9 +328,10 @@ private fun RowsContent(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
items( items(
items = sections, items = sections.withDuplicateSafeLazyKeys { it.key },
key = { it.key }, key = { it.lazyKey },
) { section -> ) { keyedSection ->
val section = keyedSection.value
HomeCatalogRowSection( HomeCatalogRowSection(
section = section, section = section,
entries = section.items.take(18), entries = section.items.take(18),

View file

@ -29,6 +29,8 @@ object TmdbCollectionSourceResolver {
when (sourceType) { when (sourceType) {
TmdbCollectionSourceType.LIST -> resolveList(source, apiKey, language, page) TmdbCollectionSourceType.LIST -> resolveList(source, apiKey, language, page)
TmdbCollectionSourceType.COLLECTION -> resolveCollection(source, apiKey, language) TmdbCollectionSourceType.COLLECTION -> resolveCollection(source, apiKey, language)
TmdbCollectionSourceType.PERSON,
TmdbCollectionSourceType.DIRECTOR -> resolvePersonCredits(source, apiKey, language)
TmdbCollectionSourceType.COMPANY, TmdbCollectionSourceType.COMPANY,
TmdbCollectionSourceType.NETWORK, TmdbCollectionSourceType.NETWORK,
TmdbCollectionSourceType.DISCOVER -> resolveDiscover(source, apiKey, language, page) 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") TmdbCollectionSourceType.DISCOVER -> TmdbSourceImportMetadata(title = "TMDB Discover")
} }
} }
@ -153,7 +168,7 @@ object TmdbCollectionSourceResolver {
fun parseTmdbId(input: String): Int? { fun parseTmdbId(input: String): Int? {
val trimmed = input.trim() val trimmed = input.trim()
trimmed.toIntOrNull()?.let { return it } trimmed.toIntOrNull()?.let { return it }
return Regex("""(?:list|collection|company|network)/(\d+)""") return Regex("""(?:list|collection|company|network|person)/(\d+)""")
.find(trimmed) .find(trimmed)
?.groupValues ?.groupValues
?.getOrNull(1) ?.getOrNull(1)
@ -193,6 +208,7 @@ object TmdbCollectionSourceResolver {
) ?: error("TMDB list not found") ) ?: error("TMDB list not found")
val items = body.items.orEmpty() val items = body.items.orEmpty()
.mapNotNull { it.toPreview() } .mapNotNull { it.toPreview() }
.sortedFor(source.sortBy)
.distinctBy { "${it.type}:${it.id}" } .distinctBy { "${it.type}:${it.id}" }
return CatalogPage( return CatalogPage(
items = items, items = items,
@ -213,12 +229,35 @@ object TmdbCollectionSourceResolver {
query = mapOf("language" to language), query = mapOf("language" to language),
) ?: error("TMDB collection not found") ) ?: error("TMDB collection not found")
val items = body.parts.orEmpty() val items = body.parts.orEmpty()
.sortedBy { it.releaseDate ?: "9999" }
.mapNotNull { it.toPreview(TmdbCollectionMediaType.MOVIE) } .mapNotNull { it.toPreview(TmdbCollectionMediaType.MOVIE) }
.sortedFor(source.sortBy)
.distinctBy { it.id } .distinctBy { it.id }
return CatalogPage(items = items, rawItemCount = items.size, nextSkip = null) 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( private suspend fun resolveDiscover(
source: CollectionSource, source: CollectionSource,
apiKey: String, apiKey: String,
@ -312,6 +351,21 @@ object TmdbCollectionSourceResolver {
}.getOrNull() }.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? { private fun TmdbListItem.toPreview(): MetaPreview? {
val media = mediaType?.lowercase() val media = mediaType?.lowercase()
val contentType = if (media == "tv") TmdbCollectionMediaType.TV else TmdbCollectionMediaType.MOVIE 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 = private fun CollectionSource.tmdbType(): TmdbCollectionSourceType =
tmdbSourceType tmdbSourceType
?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() } ?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() }
@ -370,6 +480,12 @@ object TmdbCollectionSourceResolver {
private fun CollectionSource.tmdbMediaType(): TmdbCollectionMediaType = private fun CollectionSource.tmdbMediaType(): TmdbCollectionMediaType =
TmdbCollectionMediaType.fromString(mediaType) 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( private fun company(title: String, id: Int) = CollectionSource(
provider = "tmdb", provider = "tmdb",
tmdbSourceType = TmdbCollectionSourceType.COMPANY.name, tmdbSourceType = TmdbCollectionSourceType.COMPANY.name,
@ -391,6 +507,7 @@ object TmdbCollectionSourceResolver {
private fun movieSort(sortBy: String?): String = private fun movieSort(sortBy: String?): String =
when (sortBy) { when (sortBy) {
TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> TmdbCollectionSort.RELEASE_DATE_DESC.value TmdbCollectionSort.FIRST_AIR_DATE_DESC.value -> TmdbCollectionSort.RELEASE_DATE_DESC.value
TmdbCollectionSort.ORIGINAL.value -> TmdbCollectionSort.POPULAR_DESC.value
null, "" -> TmdbCollectionSort.POPULAR_DESC.value null, "" -> TmdbCollectionSort.POPULAR_DESC.value
else -> sortBy else -> sortBy
} }
@ -398,6 +515,7 @@ object TmdbCollectionSourceResolver {
private fun tvSort(sortBy: String?): String = private fun tvSort(sortBy: String?): String =
when (sortBy) { when (sortBy) {
TmdbCollectionSort.RELEASE_DATE_DESC.value -> TmdbCollectionSort.FIRST_AIR_DATE_DESC.value TmdbCollectionSort.RELEASE_DATE_DESC.value -> TmdbCollectionSort.FIRST_AIR_DATE_DESC.value
TmdbCollectionSort.ORIGINAL.value -> TmdbCollectionSort.POPULAR_DESC.value
null, "" -> TmdbCollectionSort.POPULAR_DESC.value null, "" -> TmdbCollectionSort.POPULAR_DESC.value
else -> sortBy else -> sortBy
} }
@ -449,6 +567,12 @@ private data class TmdbNetworkResponse(
@SerialName("logo_path") val logoPath: String? = null, @SerialName("logo_path") val logoPath: String? = null,
) )
@Serializable
private data class TmdbPersonResponse(
val name: String? = null,
@SerialName("profile_path") val profilePath: String? = null,
)
@Serializable @Serializable
data class TmdbCompanySearchResult( data class TmdbCompanySearchResult(
val id: Int, val id: Int,
@ -496,6 +620,47 @@ private data class TmdbGenreItem(
val name: String, 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 @Serializable
private data class TmdbListItem( private data class TmdbListItem(
val id: Int, 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.MdbListMetadataService
import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.mdblist.MdbListSettingsRepository
import com.nuvio.app.features.tmdb.TmdbMetadataService import com.nuvio.app.features.tmdb.TmdbMetadataService
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -101,17 +102,22 @@ object MetaDetailsRepository {
_uiState.value = MetaDetailsUiState(isLoading = true) _uiState.value = MetaDetailsUiState(isLoading = true)
scope.launch { scope.launch {
val manifests = AddonRepository.uiState.value.addons val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type)
.mapNotNull { it.manifest } val manifests = findMetaManifests(type = type, id = metaLookupId)
.filter { manifest ->
manifest.resources.any { resource ->
resource.name == "meta" &&
resource.types.contains(type) &&
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) })
}
}
if (manifests.isEmpty()) { 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" } log.w { "No addon provides meta for type=$type id=$id" }
_uiState.value = MetaDetailsUiState( _uiState.value = MetaDetailsUiState(
errorMessage = getString(Res.string.details_no_addon_meta), errorMessage = getString(Res.string.details_no_addon_meta),
@ -122,42 +128,32 @@ object MetaDetailsRepository {
for (manifest in manifests) { for (manifest in manifests) {
val result = withContext(Dispatchers.Default) { val result = withContext(Dispatchers.Default) {
tryFetchMeta(manifest, type, id, includeMdbList = false) tryFetchMeta(manifest, type, metaLookupId, includeMdbList = false)
} }
if (result != null) { if (result != null) {
var cachedEntry = CachedMetaEntry(baseMeta = result) publishLoadedMeta(
cachedMetaByRequestKey[requestKey] = cachedEntry
if (!shouldFetchMdbListOnMetaScreen(result, id, mdbListSettings)) {
_uiState.value = MetaDetailsUiState(meta = result)
activeRequestKey = requestKey
return@launch
}
_uiState.value = MetaDetailsUiState(
isLoading = true,
meta = result,
)
val enrichedMeta = withContext(Dispatchers.Default) {
enrichForMetaScreen(
requestKey = requestKey, requestKey = requestKey,
meta = result, meta = result,
fallbackItemId = id, fallbackItemId = metaLookupId,
settings = mdbListSettings, mdbListSettings = mdbListSettings,
settingsFingerprint = metaScreenSettingsFingerprint,
)
}
cachedEntry = cachedEntry.copy(
metaScreenMeta = enrichedMeta,
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
) )
cachedMetaByRequestKey[requestKey] = cachedEntry
_uiState.value = MetaDetailsUiState(meta = enrichedMeta)
activeRequestKey = requestKey
return@launch 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( _uiState.value = MetaDetailsUiState(
errorMessage = getString(Res.string.details_load_failed_all_addons), errorMessage = getString(Res.string.details_load_failed_all_addons),
) )
@ -187,19 +183,12 @@ object MetaDetailsRepository {
val requestKey = "$type:$id" val requestKey = "$type:$id"
cachedMetaByRequestKey[requestKey]?.let { return it.baseMeta } cachedMetaByRequestKey[requestKey]?.let { return it.baseMeta }
val manifests = AddonRepository.uiState.value.addons val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type)
.mapNotNull { it.manifest } val manifests = findMetaManifests(type = type, id = metaLookupId)
.filter { manifest ->
manifest.resources.any { resource ->
resource.name == "meta" &&
resource.types.contains(type) &&
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { id.startsWith(it) })
}
}
for (manifest in manifests) { for (manifest in manifests) {
val result = withTimeoutOrNull(FETCH_TIMEOUT_MS) { val result = withTimeoutOrNull(FETCH_TIMEOUT_MS) {
tryFetchMeta(manifest, type, id, includeMdbList = false) tryFetchMeta(manifest, type, metaLookupId, includeMdbList = false)
} }
if (result != null) { if (result != null) {
cachedMetaByRequestKey[requestKey] = CachedMetaEntry(baseMeta = result) 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 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( private suspend fun enrichForMetaScreen(
requestKey: String, requestKey: String,
meta: MetaDetails, 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
import com.nuvio.app.features.trakt.TraktCommentReview import com.nuvio.app.features.trakt.TraktCommentReview
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.*
@ -122,7 +123,11 @@ fun DetailCommentsSection(
state = listState, state = listState,
horizontalArrangement = Arrangement.spacedBy(12.dp), 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( CommentCard(
review = review, review = review,
onClick = { onCommentClick(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.layout.width
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -582,7 +583,10 @@ private fun EpisodeHorizontalRow(
contentPadding = PaddingValues(horizontal = rowMetrics.rowHorizontalPadding, vertical = rowMetrics.rowVerticalPadding), contentPadding = PaddingValues(horizontal = rowMetrics.rowHorizontalPadding, vertical = rowMetrics.rowVerticalPadding),
horizontalArrangement = Arrangement.spacedBy(rowMetrics.itemSpacing), 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( val episodeVideoId = buildPlaybackVideoId(
parentMetaId = parentMetaId, parentMetaId = parentMetaId,
seasonNumber = episode.season, 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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
@ -158,10 +158,10 @@ fun DetailTrailersSection(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(sizing.cardSpacing), horizontalArrangement = Arrangement.spacedBy(sizing.cardSpacing),
) { ) {
items( itemsIndexed(
items = selectedTrailers, items = selectedTrailers,
key = { trailer -> "${trailer.type}-${trailer.id}-${trailer.seasonNumber ?: 0}" }, key = { index, trailer -> "${trailer.type}-${trailer.id}-${trailer.seasonNumber ?: 0}#$index" },
) { trailer -> ) { _, trailer ->
TrailerCard( TrailerCard(
trailer = trailer, trailer = trailer,
cardWidth = sizing.cardWidth, cardWidth = sizing.cardWidth,

View file

@ -291,7 +291,10 @@ private fun EpisodesListSubView(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.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 val isCurrent = episode.season == currentSeason && episode.episode == currentEpisode
EpisodeRow( EpisodeRow(
episode = episode, 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.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.core.ui.NuvioScreenHeader 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.addons.AddonRepository
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.home.components.HomeCatalogRowSection
@ -303,9 +304,10 @@ fun SearchScreen(
else -> { else -> {
items( items(
items = uiState.sections, items = uiState.sections.withDuplicateSafeLazyKeys { section -> section.key },
key = { section -> section.key }, key = { section -> section.lazyKey },
) { section -> ) { keyedSection ->
val section = keyedSection.value
HomeCatalogRowSection( HomeCatalogRowSection(
section = section, section = section,
modifier = Modifier.padding(bottom = 12.dp), 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( internal fun applyEnrichment(
meta: MetaDetails, meta: MetaDetails,
enrichment: TmdbEnrichment?, enrichment: TmdbEnrichment?,

View file

@ -8,6 +8,47 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class TmdbMetadataServiceTest { 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 @Test
fun `applyEnrichment replaces enabled metadata groups`() { fun `applyEnrichment replaces enabled metadata groups`() {
val base = MetaDetails( val base = MetaDetails(

View file

@ -1,39 +1,52 @@
package com.nuvio.app.features.downloads 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.ExperimentalForeignApi
import kotlinx.cinterop.addressOf import kotlinx.cinterop.addressOf
import kotlinx.cinterop.convert import kotlinx.cinterop.convert
import kotlinx.cinterop.usePinned import kotlinx.cinterop.usePinned
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import platform.Foundation.NSError
import platform.Foundation.NSDate
import platform.Foundation.NSFileManager import platform.Foundation.NSFileManager
import platform.Foundation.NSHTTPURLResponse
import platform.Foundation.NSHomeDirectory import platform.Foundation.NSHomeDirectory
import platform.Foundation.NSMutableURLRequest
import platform.Foundation.NSOperationQueue
import platform.Foundation.NSURL 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.fopen
import platform.posix.fclose
import platform.posix.fread
import platform.posix.fwrite import platform.posix.fwrite
private val downloadHttpClient = HttpClient(Darwin) { private const val DOWNLOAD_REQUEST_TIMEOUT_SECONDS = 60.0
install(HttpTimeout) { private const val DOWNLOAD_RESOURCE_TIMEOUT_SECONDS = 24.0 * 60.0 * 60.0
requestTimeoutMillis = 60_000 private const val PROGRESS_MIN_INTERVAL_SECONDS = 0.5
connectTimeoutMillis = 60_000 private const val PROGRESS_MIN_BYTE_DELTA = 512L * 1024L
socketTimeoutMillis = 60_000
} private val backgroundSessionCompletionHandlers = mutableMapOf<String, () -> Unit>()
expectSuccess = false
fun handleDownloadsBackgroundEvents(
identifier: String,
completionHandler: () -> Unit,
) {
backgroundSessionCompletionHandlers[identifier] = completionHandler
} }
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
@ -46,6 +59,7 @@ internal actual object DownloadsPlatformDownloader {
): DownloadsTaskHandle { ): DownloadsTaskHandle {
val job = SupervisorJob() val job = SupervisorJob()
val scope = CoroutineScope(job + Dispatchers.Default) val scope = CoroutineScope(job + Dispatchers.Default)
val handle = IosDownloadsTaskHandle(job)
scope.launch { scope.launch {
val downloadsDirectory = downloadsDirectoryPath() val downloadsDirectory = downloadsDirectoryPath()
@ -55,55 +69,42 @@ internal actual object DownloadsPlatformDownloader {
try { try {
var resumeFromBytes = fileSizeOrNull(tempPath)?.coerceAtLeast(0L) ?: 0L 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 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) removePathIfExists(tempPath)
resumeFromBytes = 0L resumeFromBytes = 0L
attemptedRangeRequest = false attemptedRangeRequest = false
response = performRequest(null) result = performDownloadRequest(
request = request,
rangeStart = null,
resumeFromBytes = 0L,
tempPath = tempPath,
handle = handle,
onProgress = onProgress,
)
} }
if (!response.status.isSuccess()) { if (result.statusCode !in 200..299) {
error("Request failed with HTTP ${response.status.value}") error("Request failed with HTTP ${result.statusCode}")
}
val isPartialResume = attemptedRangeRequest && response.status.value == 206 && resumeFromBytes > 0L
val appendToTemp = isPartialResume
val startingBytes = if (appendToTemp) resumeFromBytes else 0L
if (!appendToTemp) {
removePathIfExists(tempPath)
} }
val isPartialResume = attemptedRangeRequest && result.statusCode == 206 && resumeFromBytes > 0L
val startingBytes = if (isPartialResume) resumeFromBytes else 0L
val totalBytes = resolveTotalBytes( val totalBytes = resolveTotalBytes(
startingBytes = startingBytes, startingBytes = startingBytes,
isPartialResume = isPartialResume, isPartialResume = isPartialResume,
contentRangeHeader = response.headers["Content-Range"], contentRangeHeader = result.contentRange,
contentLength = response.headers["Content-Length"]?.toLongOrNull()?.takeIf { it > 0L }, 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) removePathIfExists(destinationPath)
val moved = NSFileManager.defaultManager.moveItemAtPath( val moved = NSFileManager.defaultManager.moveItemAtPath(
@ -118,12 +119,14 @@ internal actual object DownloadsPlatformDownloader {
val localFileUri = NSURL.fileURLWithPath(destinationPath).absoluteString ?: "file://$destinationPath" val localFileUri = NSURL.fileURLWithPath(destinationPath).absoluteString ?: "file://$destinationPath"
val finalSize = fileSizeOrNull(destinationPath) val finalSize = fileSizeOrNull(destinationPath)
onSuccess(localFileUri, totalBytes ?: finalSize) onSuccess(localFileUri, totalBytes ?: finalSize)
} catch (_: CancellationException) {
handle.cancelNativeTask()
} catch (error: Throwable) { } catch (error: Throwable) {
onFailure(error.message ?: "Download failed") onFailure(error.message ?: "Download failed")
} }
} }
return IosDownloadsTaskHandle(job) return handle
} }
actual fun removeFile(localFileUri: String?): Boolean { actual fun removeFile(localFileUri: String?): Boolean {
@ -141,9 +144,172 @@ internal actual object DownloadsPlatformDownloader {
private class IosDownloadsTaskHandle( private class IosDownloadsTaskHandle(
private val job: Job, private val job: Job,
) : DownloadsTaskHandle { ) : 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() { override fun cancel() {
cancelNativeTask()
job.cancel() 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) @OptIn(ExperimentalForeignApi::class)
@ -166,45 +332,98 @@ private fun removePathIfExists(path: String): Boolean {
} }
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
private suspend fun writeChannelToFile( private suspend fun performDownloadRequest(
channel: ByteReadChannel, request: DownloadPlatformRequest,
path: String, rangeStart: Long?,
append: Boolean, resumeFromBytes: Long,
initialDownloadedBytes: Long, tempPath: String,
totalBytes: Long?, handle: IosDownloadsTaskHandle,
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
): Boolean { ): IosDownloadResult {
val file = fopen(path, if (append) "ab" else "wb") ?: return false 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) val buffer = ByteArray(16 * 1024)
var downloadedBytes = initialDownloadedBytes
onProgress(downloadedBytes, totalBytes)
return try { return try {
while (true) { while (true) {
kotlinx.coroutines.currentCoroutineContext().ensureActive() val read = buffer.usePinned { pinned ->
val read = channel.readAvailable(buffer, 0, buffer.size) fread(
if (read < 0) break pinned.addressOf(0),
if (read == 0) continue 1.convert(),
buffer.size.convert(),
source,
).toInt()
}
if (read <= 0) break
val wroteChunk = buffer.usePinned { pinned -> val wrote = buffer.usePinned { pinned ->
val written = fwrite( fwrite(
pinned.addressOf(0), pinned.addressOf(0),
1.convert(), 1.convert(),
read.convert(), read.convert(),
file, destination,
) ).toInt()
written.toInt() == read
} }
if (!wroteChunk) { if (wrote != read) return false
return false
}
downloadedBytes += read.toLong()
onProgress(downloadedBytes, totalBytes)
} }
true true
} finally { } finally {
fclose(file) fclose(source)
fclose(destination)
} }
} }

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=42 CURRENT_PROJECT_VERSION=48
MARKETING_VERSION=0.1.10 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> </array>
</dict> </dict>
</array> </array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSSupportsLiveActivities</key> <key>NSSupportsLiveActivities</key>
<true/> <true/>
</dict> </dict>

View file

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