1、增加剧集详情页演员表,资源库信息,推荐,媒体信息,接入tmdb
2、优化剧集详情页性能,UI,和数据请求逻辑
This commit is contained in:
1 parent
ca5ab982da
commit
eaf5e432ff
9 files changed
+1611
-251
No files matched your search
@@ -1,8 +1,16 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application") version "9.0.1"
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
val localProperties = Properties().apply {
|
||||
val file = rootProject.file("local.properties")
|
||||
if (file.exists()) file.inputStream().use(::load)
|
||||
}
|
||||
val tmdbAccessToken = localProperties.getProperty("TMDB_ACCESS_TOKEN", "")
|
||||
|
||||
android {
|
||||
namespace = "com.example.myapplication"
|
||||
compileSdk = 35
|
||||
@@ -17,6 +25,7 @@ android {
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
buildConfigField("String", "TMDB_ACCESS_TOKEN", "\"${tmdbAccessToken.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -66,7 +66,11 @@ data class EmbyItem(
|
||||
@SerializedName("Taglines") val _taglines: List<String>? = emptyList(),
|
||||
@SerializedName("People") val _people: List<EmbyPerson>? = emptyList(),
|
||||
@SerializedName("Studios") val _studios: List<EmbyNamedReference>? = emptyList(),
|
||||
@SerializedName("ProviderIds") val _providerIds: Map<String, String>? = emptyMap(),
|
||||
@SerializedName("MediaStreams") val _mediaStreams: List<EmbyMediaStream>? = emptyList(),
|
||||
@SerializedName("MediaSources") val _mediaSources: List<EmbyMediaSource>? = emptyList(),
|
||||
@SerializedName("Size") val size: Long? = null,
|
||||
@SerializedName("Bitrate") val bitrate: Int? = null,
|
||||
@SerializedName("Chapters") val _chapters: List<EmbyChapter>? = emptyList(),
|
||||
@SerializedName("UserData") val userData: EmbyUserData? = null,
|
||||
) {
|
||||
@@ -75,7 +79,9 @@ data class EmbyItem(
|
||||
val taglines: List<String> get() = _taglines.orEmpty()
|
||||
val people: List<EmbyPerson> get() = _people.orEmpty()
|
||||
val studios: List<EmbyNamedReference> get() = _studios.orEmpty()
|
||||
val providerIds: Map<String, String> get() = _providerIds.orEmpty()
|
||||
val mediaStreams: List<EmbyMediaStream> get() = _mediaStreams.orEmpty()
|
||||
val mediaSources: List<EmbyMediaSource> get() = _mediaSources.orEmpty()
|
||||
val chapters: List<EmbyChapter> get() = _chapters.orEmpty()
|
||||
val playbackPositionMs: Long
|
||||
get() = (userData?.playbackPositionTicks ?: 0L).coerceAtLeast(0L) / 10_000L
|
||||
@@ -107,6 +113,7 @@ data class EmbyPerson(
|
||||
@SerializedName("Id") val id: String = "",
|
||||
@SerializedName("Name") val name: String = "",
|
||||
@SerializedName("Role") val role: String? = null,
|
||||
@SerializedName("Type") val type: String? = null,
|
||||
@SerializedName("PrimaryImageTag") val primaryImageTag: String? = null,
|
||||
)
|
||||
|
||||
@@ -118,11 +125,33 @@ data class EmbyNamedReference(
|
||||
|
||||
data class EmbyMediaStream(
|
||||
@SerializedName("DisplayTitle") val displayTitle: String? = null,
|
||||
@SerializedName("DisplayLanguage") val displayLanguage: String? = null,
|
||||
@SerializedName("Title") val title: String? = null,
|
||||
@SerializedName("Type") val type: String? = null,
|
||||
@SerializedName("Codec") val codec: String? = null,
|
||||
@SerializedName("Language") val language: String? = null,
|
||||
@SerializedName("Height") val height: Int? = null,
|
||||
@SerializedName("Width") val width: Int? = null,
|
||||
@SerializedName("BitRate") val bitRate: Int? = null,
|
||||
@SerializedName("SampleRate") val sampleRate: Int? = null,
|
||||
@SerializedName("Channels") val channels: Int? = null,
|
||||
@SerializedName("ChannelLayout") val channelLayout: String? = null,
|
||||
@SerializedName("RealFrameRate") val realFrameRate: Float? = null,
|
||||
@SerializedName("AverageFrameRate") val averageFrameRate: Float? = null,
|
||||
@SerializedName("VideoRange") val videoRange: String? = null,
|
||||
@SerializedName("VideoRangeType") val videoRangeType: String? = null,
|
||||
@SerializedName("IsInterlaced") val isInterlaced: Boolean = false,
|
||||
@SerializedName("ColorPrimaries") val colorPrimaries: String? = null,
|
||||
@SerializedName("ColorSpace") val colorSpace: String? = null,
|
||||
@SerializedName("ColorTransfer") val colorTransfer: String? = null,
|
||||
@SerializedName("Profile") val profile: String? = null,
|
||||
@SerializedName("Level") val level: Double? = null,
|
||||
@SerializedName("AspectRatio") val aspectRatio: String? = null,
|
||||
@SerializedName("BitDepth") val bitDepth: Int? = null,
|
||||
@SerializedName("PixelFormat") val pixelFormat: String? = null,
|
||||
@SerializedName("IsExternal") val isExternal: Boolean = false,
|
||||
@SerializedName("IsDefault") val isDefault: Boolean = false,
|
||||
@SerializedName("IsForced") val isForced: Boolean = false,
|
||||
)
|
||||
|
||||
data class EmbyChapter(
|
||||
@@ -150,4 +179,7 @@ data class EmbyMediaSource(
|
||||
@SerializedName("SupportsTranscoding") val supportsTranscoding: Boolean = false,
|
||||
@SerializedName("TranscodingUrl") val transcodingUrl: String? = null,
|
||||
@SerializedName("DirectStreamUrl") val directStreamUrl: String? = null,
|
||||
@SerializedName("Size") val size: Long? = null,
|
||||
@SerializedName("Bitrate") val bitrate: Int? = null,
|
||||
@SerializedName("MediaStreams") val mediaStreams: List<EmbyMediaStream>? = null,
|
||||
)
|
||||
@@ -304,7 +304,7 @@ object EmbyRepository {
|
||||
.appendQueryParameter("IncludeItemTypes", "Movie,Series")
|
||||
.appendQueryParameter("Limit", limit.toString())
|
||||
.appendQueryParameter("SortBy", "SortName")
|
||||
.appendQueryParameter("Fields", "PrimaryImageAspectRatio,Overview,Genres,ProductionYear,UserData")
|
||||
.appendQueryParameter("Fields", "PrimaryImageAspectRatio,Overview,Genres,ProductionYear,ProviderIds,UserData")
|
||||
.appendQueryParameter("EnableImages", "true")
|
||||
.appendQueryParameter("EnableUserData", "true")
|
||||
.appendQueryParameter("EnableImageTypes", "Primary,Backdrop,Thumb,Logo")
|
||||
@@ -331,7 +331,7 @@ object EmbyRepository {
|
||||
suspend fun getItem(server: EmbyServer, itemId: String): EmbyItem? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = Uri.parse("${server.fullUrl}/emby/Users/${server.userId}/Items/$itemId").buildUpon()
|
||||
.appendQueryParameter("Fields", "Overview,Genres,People,Studios,MediaStreams,Chapters,UserData")
|
||||
.appendQueryParameter("Fields", "Overview,Genres,People,Studios,ProviderIds,MediaSources,MediaStreams,Size,Bitrate,Chapters,UserData")
|
||||
.appendQueryParameter("EnableImages", "true")
|
||||
.appendQueryParameter("EnableUserData", "true")
|
||||
.appendQueryParameter("EnableImageTypes", "Primary,Backdrop,Thumb,Logo")
|
||||
@@ -399,54 +399,120 @@ object EmbyRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get episodes for one season.
|
||||
*/
|
||||
suspend fun getEpisodes(server: EmbyServer, seriesId: String, seasonId: String): List<EmbyItem> = withContext(Dispatchers.IO) {
|
||||
val dedicated = try {
|
||||
val url = "${server.fullUrl}/emby/Shows/$seriesId/Episodes" +
|
||||
"?userId=${server.userId}&seasonId=$seasonId&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,ImageTags,BackdropImageTags,UserData" +
|
||||
"&EnableImages=true&EnableImageTypes=Primary,Thumb,Backdrop&EnableUserData=true"
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("X-Emby-Token", server.apiKey)
|
||||
.get()
|
||||
.build()
|
||||
/** Get one page of episodes so the UI can render the first cards before the full season arrives. */
|
||||
suspend fun getEpisodePage(
|
||||
server: EmbyServer,
|
||||
seriesId: String,
|
||||
seasonId: String,
|
||||
startIndex: Int = 0,
|
||||
limit: Int? = null,
|
||||
): EmbyLibraryPage = withContext(Dispatchers.IO) {
|
||||
fun Uri.Builder.withPaging(): Uri.Builder = apply {
|
||||
appendQueryParameter("StartIndex", startIndex.coerceAtLeast(0).toString())
|
||||
limit?.takeIf { it > 0 }?.let { appendQueryParameter("Limit", it.toString()) }
|
||||
}
|
||||
|
||||
val dedicated = try {
|
||||
val url = Uri.parse("${server.fullUrl}/emby/Shows/$seriesId/Episodes").buildUpon()
|
||||
.appendQueryParameter("userId", server.userId)
|
||||
.appendQueryParameter("seasonId", seasonId)
|
||||
.appendQueryParameter("Fields", "PrimaryImageAspectRatio,Overview,ProductionYear,ProviderIds,ImageTags,BackdropImageTags,UserData")
|
||||
.appendQueryParameter("EnableImages", "true")
|
||||
.appendQueryParameter("EnableImageTypes", "Primary,Thumb,Backdrop")
|
||||
.appendQueryParameter("EnableUserData", "true")
|
||||
.withPaging()
|
||||
.build()
|
||||
val request = Request.Builder().url(url.toString()).header("X-Emby-Token", server.apiKey).get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) emptyList()
|
||||
if (!response.isSuccessful) EmbyLibraryPage()
|
||||
else {
|
||||
val body = response.body?.string().orEmpty()
|
||||
gson.fromJson(body, EmbyItemsResponse::class.java)?.items.orEmpty()
|
||||
val parsed = gson.fromJson(response.body?.string().orEmpty(), EmbyItemsResponse::class.java)
|
||||
EmbyLibraryPage(parsed?.items.orEmpty(), parsed?.totalRecordCount ?: 0)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
EmbyLibraryPage()
|
||||
}
|
||||
if (dedicated.isNotEmpty()) return@withContext dedicated
|
||||
if (dedicated.items.isNotEmpty()) return@withContext dedicated
|
||||
|
||||
return@withContext try {
|
||||
val url = "${server.fullUrl}/emby/Users/${server.userId}/Items" +
|
||||
"?ParentId=$seasonId&IncludeItemTypes=Episode&Recursive=true&SortBy=ParentIndexNumber,IndexNumber&SortOrder=Ascending" +
|
||||
"&Fields=PrimaryImageAspectRatio,Overview,ProductionYear,ImageTags,BackdropImageTags,UserData" +
|
||||
"&EnableImages=true&EnableImageTypes=Primary,Thumb,Backdrop&EnableUserData=true"
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("X-Emby-Token", server.apiKey)
|
||||
.get()
|
||||
val url = Uri.parse("${server.fullUrl}/emby/Users/${server.userId}/Items").buildUpon()
|
||||
.appendQueryParameter("ParentId", seasonId)
|
||||
.appendQueryParameter("IncludeItemTypes", "Episode")
|
||||
.appendQueryParameter("Recursive", "true")
|
||||
.appendQueryParameter("SortBy", "ParentIndexNumber,IndexNumber")
|
||||
.appendQueryParameter("SortOrder", "Ascending")
|
||||
.appendQueryParameter("Fields", "PrimaryImageAspectRatio,Overview,ProductionYear,ProviderIds,ImageTags,BackdropImageTags,UserData")
|
||||
.appendQueryParameter("EnableImages", "true")
|
||||
.appendQueryParameter("EnableImageTypes", "Primary,Thumb,Backdrop")
|
||||
.appendQueryParameter("EnableUserData", "true")
|
||||
.withPaging()
|
||||
.build()
|
||||
|
||||
val request = Request.Builder().url(url.toString()).header("X-Emby-Token", server.apiKey).get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) emptyList()
|
||||
if (!response.isSuccessful) EmbyLibraryPage()
|
||||
else {
|
||||
val body = response.body?.string().orEmpty()
|
||||
gson.fromJson(body, EmbyItemsResponse::class.java)?.items.orEmpty()
|
||||
val parsed = gson.fromJson(response.body?.string().orEmpty(), EmbyItemsResponse::class.java)
|
||||
EmbyLibraryPage(parsed?.items.orEmpty(), parsed?.totalRecordCount ?: 0)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
EmbyLibraryPage()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getEpisodes(server: EmbyServer, seriesId: String, seasonId: String): List<EmbyItem> =
|
||||
getEpisodePage(server, seriesId, seasonId).items
|
||||
|
||||
/** Returns the resume/next-up episode without waiting for seasons and episode lists. */
|
||||
suspend fun getSeriesPlayTarget(server: EmbyServer, seriesId: String): EmbyItem? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = Uri.parse("${server.fullUrl}/emby/Shows/NextUp").buildUpon()
|
||||
.appendQueryParameter("UserId", server.userId)
|
||||
.appendQueryParameter("SeriesId", seriesId)
|
||||
.appendQueryParameter("Limit", "1")
|
||||
.appendQueryParameter("Fields", "Overview,ParentId,UserData")
|
||||
.appendQueryParameter("EnableUserData", "true")
|
||||
.appendQueryParameter("EnableImages", "true")
|
||||
.appendQueryParameter("EnableImageTypes", "Primary,Thumb,Backdrop")
|
||||
.build()
|
||||
val request = Request.Builder().url(url.toString()).header("X-Emby-Token", server.apiKey).get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) null
|
||||
else gson.fromJson(response.body?.string().orEmpty(), EmbyItemsResponse::class.java)?.items?.firstOrNull()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Finds the same playable movie or episode on another server. */
|
||||
suspend fun findMatchingPlayableItem(
|
||||
server: EmbyServer,
|
||||
sourceSeries: EmbyItem?,
|
||||
sourceSeason: EmbyItem?,
|
||||
sourceItem: EmbyItem,
|
||||
): EmbyItem? {
|
||||
if (sourceSeries == null) {
|
||||
val match = selectBestMediaMatch(
|
||||
source = sourceItem,
|
||||
candidates = searchItems(server, sourceItem.name, limit = 12),
|
||||
) ?: return null
|
||||
return getItem(server, match.id)
|
||||
}
|
||||
|
||||
val matchingSeries = selectBestMediaMatch(
|
||||
source = sourceSeries,
|
||||
candidates = searchItems(server, sourceSeries.name, limit = 12),
|
||||
) ?: return null
|
||||
val matchingSeason = selectIndexedMatch(sourceSeason, getSeasons(server, matchingSeries.id))
|
||||
?: return null
|
||||
val matchingEpisode = selectIndexedMatch(
|
||||
sourceItem,
|
||||
getEpisodes(server, matchingSeries.id, matchingSeason.id),
|
||||
) ?: return null
|
||||
return getItem(server, matchingEpisode.id)
|
||||
}
|
||||
|
||||
suspend fun resolvePlaybackSession(server: EmbyServer, item: EmbyItem): EmbyPlaybackSession? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -756,3 +822,37 @@ object EmbyRepository {
|
||||
|
||||
private const val PLAYBACK_REPORT_INTERVAL_MS = 10_000L
|
||||
}
|
||||
|
||||
internal fun selectBestMediaMatch(source: EmbyItem, candidates: List<EmbyItem>): EmbyItem? {
|
||||
val sameType = candidates.filter { source.type == null || it.type == source.type }
|
||||
val pool = sameType.ifEmpty { candidates }
|
||||
if (pool.isEmpty()) return null
|
||||
|
||||
val sourceProviders = source.providerIds.entries.associate { it.key.lowercase() to it.value.lowercase() }
|
||||
if (sourceProviders.isNotEmpty()) {
|
||||
pool.firstOrNull { candidate ->
|
||||
candidate.providerIds.any { (key, value) ->
|
||||
sourceProviders[key.lowercase()] == value.lowercase()
|
||||
}
|
||||
}?.let { return it }
|
||||
}
|
||||
|
||||
val normalizedName = normalizeMediaName(source.name)
|
||||
return pool.firstOrNull {
|
||||
normalizeMediaName(it.name) == normalizedName &&
|
||||
(source.productionYear == null || it.productionYear == null || source.productionYear == it.productionYear)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun selectIndexedMatch(source: EmbyItem?, candidates: List<EmbyItem>): EmbyItem? {
|
||||
if (source == null) return candidates.firstOrNull()
|
||||
source.indexNumber?.let { index ->
|
||||
candidates.firstOrNull { it.indexNumber == index }?.let { return it }
|
||||
}
|
||||
val normalizedName = normalizeMediaName(source.name)
|
||||
return candidates.firstOrNull { normalizeMediaName(it.name) == normalizedName }
|
||||
}
|
||||
|
||||
private fun normalizeMediaName(value: String): String = value
|
||||
.lowercase()
|
||||
.replace(Regex("[^\\p{L}\\p{N}]"), "")
|
||||
@@ -0,0 +1,192 @@
|
||||
package com.example.myapplication.data
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.example.myapplication.BuildConfig
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class TmdbCastMember(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val originalName: String,
|
||||
val character: String,
|
||||
val profileUrl: String?,
|
||||
)
|
||||
|
||||
data class TmdbRecommendation(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val originalName: String,
|
||||
val posterUrl: String?,
|
||||
val firstAirYear: String,
|
||||
val rating: Double,
|
||||
)
|
||||
|
||||
data class TmdbSeriesExtras(
|
||||
val cast: List<TmdbCastMember> = emptyList(),
|
||||
val recommendations: List<TmdbRecommendation> = emptyList(),
|
||||
)
|
||||
|
||||
private data class TmdbSearchResponse(
|
||||
@SerializedName("results") val results: List<TmdbSearchItem> = emptyList(),
|
||||
)
|
||||
|
||||
private data class TmdbSearchItem(
|
||||
@SerializedName("id") val id: Int = 0,
|
||||
)
|
||||
|
||||
private data class TmdbCreditsResponse(
|
||||
@SerializedName("cast") val cast: List<TmdbCastItem> = emptyList(),
|
||||
)
|
||||
|
||||
private data class TmdbCastItem(
|
||||
@SerializedName("id") val id: Int = 0,
|
||||
@SerializedName("name") val name: String = "",
|
||||
@SerializedName("original_name") val originalName: String = "",
|
||||
@SerializedName("profile_path") val profilePath: String? = null,
|
||||
@SerializedName("roles") val roles: List<TmdbCastRole> = emptyList(),
|
||||
@SerializedName("order") val order: Int = Int.MAX_VALUE,
|
||||
)
|
||||
|
||||
private data class TmdbCastRole(
|
||||
@SerializedName("character") val character: String = "",
|
||||
@SerializedName("episode_count") val episodeCount: Int = 0,
|
||||
)
|
||||
|
||||
private data class TmdbRecommendationsResponse(
|
||||
@SerializedName("results") val results: List<TmdbRecommendationItem> = emptyList(),
|
||||
)
|
||||
|
||||
private data class TmdbRecommendationItem(
|
||||
@SerializedName("id") val id: Int = 0,
|
||||
@SerializedName("name") val name: String = "",
|
||||
@SerializedName("original_name") val originalName: String = "",
|
||||
@SerializedName("poster_path") val posterPath: String? = null,
|
||||
@SerializedName("first_air_date") val firstAirDate: String = "",
|
||||
@SerializedName("vote_average") val voteAverage: Double = 0.0,
|
||||
)
|
||||
|
||||
object TmdbRepository {
|
||||
|
||||
private const val API_BASE = "https://api.tmdb.org/3"
|
||||
private const val IMAGE_BASE = "https://image.tmdb.org/t/p/w342"
|
||||
private val gson = Gson()
|
||||
private val extrasCache = ConcurrentHashMap<Int, TmdbSeriesExtras>()
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(6, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val isConfigured: Boolean get() = BuildConfig.TMDB_ACCESS_TOKEN.isNotBlank()
|
||||
|
||||
suspend fun getSeriesExtras(
|
||||
tmdbId: String?,
|
||||
title: String,
|
||||
year: Int?,
|
||||
): TmdbSeriesExtras = withContext(Dispatchers.IO) {
|
||||
if (!isConfigured) return@withContext TmdbSeriesExtras()
|
||||
val resolvedId = tmdbId?.toIntOrNull() ?: searchSeriesId(title, year) ?: return@withContext TmdbSeriesExtras()
|
||||
extrasCache[resolvedId]?.let { return@withContext it }
|
||||
coroutineScope {
|
||||
val cast = async { getAggregateCredits(resolvedId) }
|
||||
val recommendations = async { getRecommendations(resolvedId) }
|
||||
val result = TmdbSeriesExtras(cast.await(), recommendations.await())
|
||||
if (result.cast.isNotEmpty() || result.recommendations.isNotEmpty()) {
|
||||
extrasCache[resolvedId] = result
|
||||
}
|
||||
Log.d("TmdbRepository", "Loaded ${result.cast.size} cast and ${result.recommendations.size} recommendations")
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
private fun searchSeriesId(title: String, year: Int?): Int? {
|
||||
if (title.isBlank()) return null
|
||||
return performSeriesSearch(title, year?.toString())
|
||||
?: year?.let { performSeriesSearch(title, null) }
|
||||
}
|
||||
|
||||
private fun performSeriesSearch(title: String, year: String?): Int? {
|
||||
val url = Uri.parse("$API_BASE/search/tv").buildUpon()
|
||||
.appendQueryParameter("query", title)
|
||||
.appendQueryParameter("language", language())
|
||||
.appendQueryParameter("include_adult", "false")
|
||||
.apply { year?.let { appendQueryParameter("first_air_date_year", it) } }
|
||||
.build()
|
||||
return get(url.toString(), TmdbSearchResponse::class.java)?.results?.firstOrNull()?.id
|
||||
}
|
||||
|
||||
private fun getAggregateCredits(seriesId: Int): List<TmdbCastMember> {
|
||||
val url = Uri.parse("$API_BASE/tv/$seriesId/aggregate_credits").buildUpon()
|
||||
.appendQueryParameter("language", language())
|
||||
.build()
|
||||
return get(url.toString(), TmdbCreditsResponse::class.java)?.cast.orEmpty()
|
||||
.sortedWith(compareBy<TmdbCastItem> { it.order }.thenByDescending { item -> item.roles.maxOfOrNull { it.episodeCount } ?: 0 })
|
||||
.filter { it.name.isNotBlank() }
|
||||
.take(24)
|
||||
.map { item ->
|
||||
TmdbCastMember(
|
||||
id = item.id,
|
||||
name = item.name,
|
||||
originalName = item.originalName,
|
||||
character = item.roles.maxByOrNull { it.episodeCount }?.character.orEmpty(),
|
||||
profileUrl = item.profilePath?.let { IMAGE_BASE + it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRecommendations(seriesId: Int): List<TmdbRecommendation> {
|
||||
val url = Uri.parse("$API_BASE/tv/$seriesId/recommendations").buildUpon()
|
||||
.appendQueryParameter("language", language())
|
||||
.appendQueryParameter("page", "1")
|
||||
.build()
|
||||
return get(url.toString(), TmdbRecommendationsResponse::class.java)?.results.orEmpty()
|
||||
.filter { it.name.isNotBlank() }
|
||||
.take(20)
|
||||
.map { item ->
|
||||
TmdbRecommendation(
|
||||
id = item.id,
|
||||
name = item.name,
|
||||
originalName = item.originalName,
|
||||
posterUrl = item.posterPath?.let { IMAGE_BASE + it },
|
||||
firstAirYear = item.firstAirDate.take(4),
|
||||
rating = item.voteAverage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> get(url: String, type: Class<T>): T? {
|
||||
return try {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Authorization", "Bearer ${BuildConfig.TMDB_ACCESS_TOKEN}")
|
||||
.header("Accept", "application/json")
|
||||
.get()
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
Log.w("TmdbRepository", "TMDB request failed with HTTP ${response.code}")
|
||||
null
|
||||
}
|
||||
else gson.fromJson(response.body?.string().orEmpty(), type)
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
Log.w("TmdbRepository", "TMDB request failed", error)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun language(): String = when (Locale.getDefault().language) {
|
||||
"zh" -> "zh-CN"
|
||||
else -> "en-US"
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,7 @@ fun AppNavHost(
|
||||
) { entry ->
|
||||
MediaDetailDestination(
|
||||
server = servers.firstOrNull { it.id == entry.arguments?.getString("serverId") },
|
||||
servers = servers,
|
||||
itemId = entry.arguments?.getString("itemId").orEmpty(),
|
||||
onClose = { navController.popBackStack() },
|
||||
onPlay = { itemId, seriesId, seasonId ->
|
||||
@@ -240,6 +241,7 @@ fun AppNavHost(
|
||||
@Composable
|
||||
private fun MediaDetailDestination(
|
||||
server: EmbyServer?,
|
||||
servers: List<EmbyServer>,
|
||||
itemId: String,
|
||||
onClose: () -> Unit,
|
||||
onPlay: (String, String?, String?) -> Unit,
|
||||
@@ -264,7 +266,7 @@ private fun MediaDetailDestination(
|
||||
}
|
||||
|
||||
when {
|
||||
item != null -> MediaDetailScreen(item = item!!, server = server, onClose = onClose, onPlay = onPlay)
|
||||
item != null -> MediaDetailScreen(item = item!!, server = server, servers = servers, onClose = onClose, onPlay = onPlay)
|
||||
loading -> DestinationLoading()
|
||||
else -> DestinationMessage(stringResource(Res.string.detail_load_failed), background = Color.Transparent)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -120,6 +120,7 @@
|
||||
<string name="detail_overview">Synopsis</string>
|
||||
<string name="detail_episodes">Episodes</string>
|
||||
<string name="detail_cast">Cast & Crew</string>
|
||||
<string name="detail_no_cast">No cast information available</string>
|
||||
<string name="detail_information">Details</string>
|
||||
<string name="detail_type">Type</string>
|
||||
<string name="detail_original_title">Original title</string>
|
||||
@@ -139,6 +140,63 @@
|
||||
<string name="detail_runtime_minutes">%1$d min</string>
|
||||
<string name="detail_runtime_hours">%1$d hr</string>
|
||||
<string name="detail_runtime_hours_minutes">%1$d hr %2$d min</string>
|
||||
<string name="detail_resources">Resources</string>
|
||||
<string name="detail_best_resource">Best</string>
|
||||
<string name="detail_loading_resources">Matching server resources...</string>
|
||||
<string name="detail_no_resources">No matching resources</string>
|
||||
<string name="detail_file_size">Video size</string>
|
||||
<string name="detail_bitrate">Video bitrate</string>
|
||||
<string name="detail_track_summary">%1$d audio · %2$d subtitles</string>
|
||||
<string name="detail_recommendations">Recommendations</string>
|
||||
<string name="detail_no_recommendations">No recommendations available</string>
|
||||
<string name="detail_media_information">Media information</string>
|
||||
<string name="detail_loading_media">Loading media information...</string>
|
||||
<string name="detail_no_media_info">No media information</string>
|
||||
<string name="detail_video_stream">Video %1$d</string>
|
||||
<string name="detail_audio_stream">Audio %1$d</string>
|
||||
<string name="detail_subtitle_stream">Subtitle %1$d</string>
|
||||
<string name="detail_video">Video</string>
|
||||
<string name="detail_audio">Audio</string>
|
||||
<string name="detail_subtitle">Subtitle</string>
|
||||
<string name="detail_codec_value">Codec · %1$s</string>
|
||||
<string name="detail_resolution_value">Resolution · %1$d×%2$d</string>
|
||||
<string name="detail_bitrate_value">Bitrate · %1$s</string>
|
||||
<string name="detail_dynamic_range_value">Dynamic range · %1$s</string>
|
||||
<string name="detail_frame_rate_value">Frame rate · %1$s</string>
|
||||
<string name="detail_pixel_format_value">Pixel format · %1$s</string>
|
||||
<string name="detail_sample_rate_value">Sample rate · %1$s</string>
|
||||
<string name="detail_channels_value">Channels · %1$s</string>
|
||||
<string name="detail_language_value">Language · %1$s</string>
|
||||
<string name="detail_subtitle_flags">Default · %1$s Forced · %2$s</string>
|
||||
<string name="detail_yes">Yes</string>
|
||||
<string name="detail_no">No</string>
|
||||
<string name="detail_locating_progress">Locating playback progress...</string>
|
||||
<string name="detail_loading_seasons">Loading seasons...</string>
|
||||
<string name="detail_loading_first_episodes">Loading the first episodes...</string>
|
||||
<string name="detail_loading_more_episodes">Loading more</string>
|
||||
<string name="detail_no_episodes">No episodes in this season</string>
|
||||
<string name="detail_display_title">Display title</string>
|
||||
<string name="detail_display_language">Display language</string>
|
||||
<string name="detail_language">Language</string>
|
||||
<string name="detail_codec">Codec</string>
|
||||
<string name="detail_resolution">Resolution</string>
|
||||
<string name="detail_frame_rate">Frame rate</string>
|
||||
<string name="detail_stream_bitrate">Bitrate</string>
|
||||
<string name="detail_dynamic_range">Dynamic range</string>
|
||||
<string name="detail_interlaced">Interlaced</string>
|
||||
<string name="detail_color_primaries">Color primaries</string>
|
||||
<string name="detail_color_space">Color space</string>
|
||||
<string name="detail_profile">Profile</string>
|
||||
<string name="detail_level">Level</string>
|
||||
<string name="detail_aspect_ratio">Aspect ratio</string>
|
||||
<string name="detail_bit_depth">Bit depth</string>
|
||||
<string name="detail_pixel_format">Pixel format</string>
|
||||
<string name="detail_layout">Layout</string>
|
||||
<string name="detail_channel_count">Channels</string>
|
||||
<string name="detail_sample_rate">Sample rate</string>
|
||||
<string name="detail_external">External</string>
|
||||
<string name="detail_default">Default</string>
|
||||
<string name="detail_forced">Forced</string>
|
||||
<string name="player_preparing">Preparing playback...</string>
|
||||
<string name="player_failed">Playback failed</string>
|
||||
<string name="player_subtitle">Subtitles</string>
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
<string name="detail_overview">简介</string>
|
||||
<string name="detail_episodes">剧集</string>
|
||||
<string name="detail_cast">演职人员</string>
|
||||
<string name="detail_no_cast">暂未获取到演员信息</string>
|
||||
<string name="detail_information">详细信息</string>
|
||||
<string name="detail_type">类型</string>
|
||||
<string name="detail_original_title">原名</string>
|
||||
@@ -139,6 +140,63 @@
|
||||
<string name="detail_runtime_minutes">%1$d 分钟</string>
|
||||
<string name="detail_runtime_hours">%1$d 小时</string>
|
||||
<string name="detail_runtime_hours_minutes">%1$d 小时 %2$d 分钟</string>
|
||||
<string name="detail_resources">资源列表</string>
|
||||
<string name="detail_best_resource">最佳</string>
|
||||
<string name="detail_loading_resources">正在匹配服务器资源...</string>
|
||||
<string name="detail_no_resources">未匹配到可用资源</string>
|
||||
<string name="detail_file_size">视频大小</string>
|
||||
<string name="detail_bitrate">视频码率</string>
|
||||
<string name="detail_track_summary">%1$d 个音频 · %2$d 个字幕</string>
|
||||
<string name="detail_recommendations">推荐作品</string>
|
||||
<string name="detail_no_recommendations">暂未获取到推荐作品</string>
|
||||
<string name="detail_media_information">媒体信息</string>
|
||||
<string name="detail_loading_media">正在读取媒体信息...</string>
|
||||
<string name="detail_no_media_info">暂无媒体信息</string>
|
||||
<string name="detail_video_stream">视频 %1$d</string>
|
||||
<string name="detail_audio_stream">音频 %1$d</string>
|
||||
<string name="detail_subtitle_stream">字幕 %1$d</string>
|
||||
<string name="detail_video">视频</string>
|
||||
<string name="detail_audio">音频</string>
|
||||
<string name="detail_subtitle">字幕</string>
|
||||
<string name="detail_codec_value">编码 · %1$s</string>
|
||||
<string name="detail_resolution_value">分辨率 · %1$d×%2$d</string>
|
||||
<string name="detail_bitrate_value">码率 · %1$s</string>
|
||||
<string name="detail_dynamic_range_value">动态范围 · %1$s</string>
|
||||
<string name="detail_frame_rate_value">帧率 · %1$s</string>
|
||||
<string name="detail_pixel_format_value">像素格式 · %1$s</string>
|
||||
<string name="detail_sample_rate_value">采样率 · %1$s</string>
|
||||
<string name="detail_channels_value">声道 · %1$s</string>
|
||||
<string name="detail_language_value">语言 · %1$s</string>
|
||||
<string name="detail_subtitle_flags">默认 · %1$s 强制 · %2$s</string>
|
||||
<string name="detail_yes">是</string>
|
||||
<string name="detail_no">否</string>
|
||||
<string name="detail_locating_progress">正在定位播放进度...</string>
|
||||
<string name="detail_loading_seasons">正在获取季度...</string>
|
||||
<string name="detail_loading_first_episodes">正在加载首批分集...</string>
|
||||
<string name="detail_loading_more_episodes">继续加载</string>
|
||||
<string name="detail_no_episodes">本季暂无可用分集</string>
|
||||
<string name="detail_display_title">显示标题</string>
|
||||
<string name="detail_display_language">显示语言</string>
|
||||
<string name="detail_language">语言</string>
|
||||
<string name="detail_codec">编码</string>
|
||||
<string name="detail_resolution">分辨率</string>
|
||||
<string name="detail_frame_rate">帧率</string>
|
||||
<string name="detail_stream_bitrate">比特率</string>
|
||||
<string name="detail_dynamic_range">动态范围</string>
|
||||
<string name="detail_interlaced">隔行扫描</string>
|
||||
<string name="detail_color_primaries">色彩原色</string>
|
||||
<string name="detail_color_space">色彩空间</string>
|
||||
<string name="detail_profile">配置</string>
|
||||
<string name="detail_level">等级</string>
|
||||
<string name="detail_aspect_ratio">长宽比</string>
|
||||
<string name="detail_bit_depth">位深</string>
|
||||
<string name="detail_pixel_format">像素格式</string>
|
||||
<string name="detail_layout">布局</string>
|
||||
<string name="detail_channel_count">声道</string>
|
||||
<string name="detail_sample_rate">采样率</string>
|
||||
<string name="detail_external">外部</string>
|
||||
<string name="detail_default">默认</string>
|
||||
<string name="detail_forced">强制</string>
|
||||
<string name="player_preparing">正在准备播放...</string>
|
||||
<string name="player_failed">播放失败</string>
|
||||
<string name="player_subtitle">字幕</string>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.example.myapplication.screens
|
||||
|
||||
import com.example.myapplication.data.EmbyItem
|
||||
import com.example.myapplication.data.EmbyMediaStream
|
||||
import com.example.myapplication.data.EmbyMediaSource
|
||||
import com.example.myapplication.data.selectBestMediaMatch
|
||||
import com.example.myapplication.data.selectIndexedMatch
|
||||
import com.google.gson.Gson
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class MediaDetailDataTest {
|
||||
|
||||
@Test
|
||||
fun `provider id takes priority when matching resources`() {
|
||||
val source = EmbyItem(
|
||||
id = "source",
|
||||
name = "Different localized title",
|
||||
type = "Series",
|
||||
_providerIds = mapOf("Tmdb" to "123"),
|
||||
)
|
||||
val expected = EmbyItem(
|
||||
id = "match",
|
||||
name = "完全不同的标题",
|
||||
type = "Series",
|
||||
_providerIds = mapOf("TMDB" to "123"),
|
||||
)
|
||||
|
||||
assertEquals(expected, selectBestMediaMatch(source, listOf(expected)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `title and year are used when provider id is absent`() {
|
||||
val source = EmbyItem(id = "source", name = "The Example!", type = "Movie", productionYear = 2025)
|
||||
val wrongYear = EmbyItem(id = "old", name = "The Example", type = "Movie", productionYear = 2020)
|
||||
val expected = EmbyItem(id = "match", name = "The-Example", type = "Movie", productionYear = 2025)
|
||||
|
||||
assertEquals(expected, selectBestMediaMatch(source, listOf(wrongYear, expected)))
|
||||
assertNull(selectBestMediaMatch(source, listOf(wrongYear)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `season and episode matching uses index first`() {
|
||||
val source = EmbyItem(id = "source", name = "第二集", indexNumber = 2)
|
||||
val expected = EmbyItem(id = "match", name = "Episode Two", indexNumber = 2)
|
||||
|
||||
assertEquals(expected, selectIndexedMatch(source, listOf(expected)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resource values use readable units`() {
|
||||
assertEquals("1.50 GB", formatFileSize(1_610_612_736L))
|
||||
assertEquals("8.0 Mbps", formatBitrate(8_000_000))
|
||||
assertEquals("—", formatBitrate(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resource badge counts video versions only`() {
|
||||
val video = EmbyMediaStream(type = "Video")
|
||||
val audio = EmbyMediaStream(type = "Audio")
|
||||
val item = EmbyItem(
|
||||
id = "episode",
|
||||
name = "Episode",
|
||||
_mediaSources = listOf(
|
||||
EmbyMediaSource(id = "version-1", mediaStreams = listOf(video, audio)),
|
||||
EmbyMediaSource(id = "version-2", mediaStreams = listOf(video)),
|
||||
EmbyMediaSource(id = "audio-only", mediaStreams = listOf(audio)),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(2, resourceVersionCount(item))
|
||||
assertEquals(1, resourceVersionCount(EmbyItem(id = "single", name = "Single", _mediaStreams = listOf(video))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `media stream fields used by detail cards are parsed`() {
|
||||
val stream = Gson().fromJson(
|
||||
"""{"Type":"Video","DisplayTitle":"4K H264","Profile":"High","Level":51,"BitDepth":8,"IsInterlaced":false,"ColorPrimaries":"bt709","ColorSpace":"bt709"}""",
|
||||
EmbyMediaStream::class.java,
|
||||
)
|
||||
|
||||
assertEquals("4K H264", stream.displayTitle)
|
||||
assertEquals("High", stream.profile)
|
||||
assertEquals(51.0, stream.level!!, 0.0)
|
||||
assertEquals(8, stream.bitDepth)
|
||||
assertEquals("bt709", stream.colorPrimaries)
|
||||
assertEquals("bt709", stream.colorSpace)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user