mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
Merge branch 'NuvioMedia:cmp-rewrite' into cmp-rewrite
This commit is contained in:
commit
bb6ead8cba
30 changed files with 959 additions and 173 deletions
|
|
@ -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.
BIN
composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar
Normal file
BIN
composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar
Normal file
Binary file not shown.
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
CURRENT_PROJECT_VERSION=42
|
||||
MARKETING_VERSION=0.1.10
|
||||
CURRENT_PROJECT_VERSION=48
|
||||
MARKETING_VERSION=0.1.0
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -17,6 +17,11 @@
|
|||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
|
|
|||
|
|
@ -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
mediamp
1
mediamp
|
|
@ -1 +0,0 @@
|
|||
Subproject commit df33966d7fbc6eb14e43fb1892e062417d76e7f5
|
||||
1
vendor/mpv-kt-upstream
vendored
1
vendor/mpv-kt-upstream
vendored
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 8a8ddddf430555878273da13006fc57e182b0c0c
|
||||
1
vendor/quickjs-kt
vendored
Submodule
1
vendor/quickjs-kt
vendored
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 57ce096200ac36bceb4e1ee5b6ec411b12357eb8
|
||||
Loading…
Reference in a new issue