mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
fix: ajdust tmdb metadata handling for collections
This commit is contained in:
parent
d37af8e4a9
commit
2c1993c996
4 changed files with 294 additions and 47 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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?,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
<?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">
|
||||||
|
<Testables>
|
||||||
|
</Testables>
|
||||||
|
</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>
|
||||||
Loading…
Reference in a new issue