Fix version/subtitle labels and add OpenSubtitles search in the TV player.
Clean null metadata in stream labels, show readable subtitle track names, and let viewers pause to pick OpenSubtitles during playback. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2ce482370b
commit
4b3c594609
10 changed files with 562 additions and 57 deletions
|
|
@ -12,8 +12,8 @@ android {
|
|||
applicationId = "nl.vonas.mediacluster.tv"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 10
|
||||
versionName = "0.7.3"
|
||||
versionCode = 11
|
||||
versionName = "0.7.4"
|
||||
buildConfigField("String", "DEFAULT_API_BASE", "\"https://master.vonas.nl\"")
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
|
|
|
|||
|
|
@ -193,6 +193,60 @@ class ApiClient(
|
|||
Unit
|
||||
}
|
||||
|
||||
suspend fun searchSubtitles(mediaFileId: String, languages: String = "nl,en"): SubtitleSearchResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val id = java.net.URLEncoder.encode(mediaFileId, "UTF-8")
|
||||
val langs = java.net.URLEncoder.encode(languages, "UTF-8")
|
||||
val req = authed("$baseUrl/api/v1/client/subtitles/search?mediaFileId=$id&languages=$langs")
|
||||
.get()
|
||||
.build()
|
||||
parseObject(execute(req)) { o ->
|
||||
val arr = o.optJSONArray("results") ?: JSONArray()
|
||||
val results = buildList {
|
||||
for (i in 0 until arr.length()) {
|
||||
val r = arr.getJSONObject(i)
|
||||
add(
|
||||
SubtitleHit(
|
||||
fileId = r.optInt("fileId"),
|
||||
language = r.optString("language"),
|
||||
release = r.optString("release").ifBlank { r.optString("fileName") },
|
||||
fileName = r.optString("fileName"),
|
||||
downloadCount = r.optInt("downloadCount"),
|
||||
hearingImpaired = r.optBoolean("hearingImpaired"),
|
||||
source = r.optString("source").ifBlank { "org" },
|
||||
uploader = r.optString("uploader").takeIf { it.isNotBlank() && it != "null" },
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
SubtitleSearchResult(
|
||||
configured = o.optBoolean("configured", true),
|
||||
imdbId = o.optString("imdbId").takeIf { it.isNotBlank() && it != "null" },
|
||||
title = o.optString("title").takeIf { it.isNotBlank() && it != "null" },
|
||||
results = results,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchSubtitle(fileId: Int, source: String): FetchedSubtitle =
|
||||
withContext(Dispatchers.IO) {
|
||||
val body = JSONObject()
|
||||
.put("fileId", fileId)
|
||||
.put("source", source)
|
||||
.toString()
|
||||
.toRequestBody(jsonMedia)
|
||||
val req = authed("$baseUrl/api/v1/client/subtitles/fetch").post(body).build()
|
||||
parseObject(execute(req)) { o ->
|
||||
FetchedSubtitle(
|
||||
fileId = o.optInt("fileId", fileId),
|
||||
source = o.optString("source").ifBlank { source },
|
||||
fileName = o.optString("fileName").ifBlank { "subtitle.srt" },
|
||||
format = o.optString("format").ifBlank { "srt" },
|
||||
contentBase64 = o.getString("contentBase64"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun dismissContinue(progressId: String) = withContext(Dispatchers.IO) {
|
||||
val req = authed("$baseUrl/api/v1/client/progress/$progressId/dismiss")
|
||||
.post("{}".toRequestBody(jsonMedia))
|
||||
|
|
@ -284,6 +338,32 @@ data class PlayResult(
|
|||
val mediaFileId: String,
|
||||
)
|
||||
|
||||
data class SubtitleHit(
|
||||
val fileId: Int,
|
||||
val language: String,
|
||||
val release: String,
|
||||
val fileName: String,
|
||||
val downloadCount: Int,
|
||||
val hearingImpaired: Boolean,
|
||||
val source: String,
|
||||
val uploader: String? = null,
|
||||
)
|
||||
|
||||
data class SubtitleSearchResult(
|
||||
val configured: Boolean,
|
||||
val imdbId: String?,
|
||||
val title: String?,
|
||||
val results: List<SubtitleHit>,
|
||||
)
|
||||
|
||||
data class FetchedSubtitle(
|
||||
val fileId: Int,
|
||||
val source: String,
|
||||
val fileName: String,
|
||||
val format: String,
|
||||
val contentBase64: String,
|
||||
)
|
||||
|
||||
data class ShelfPref(
|
||||
val shelfId: String,
|
||||
val name: String,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import android.os.Handler
|
|||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.Tracks
|
||||
|
|
@ -119,21 +120,77 @@ fun ExoPlayer.listTracks(type: Int): List<PlayerTrack> {
|
|||
if (group.type != type) continue
|
||||
for (ti in 0 until group.length) {
|
||||
val format = group.getTrackFormat(ti)
|
||||
val mime = format.sampleMimeType ?: "?"
|
||||
val lang = format.language?.uppercase() ?: ""
|
||||
val ch = format.channelCount.takeIf { it > 0 }?.let { "${it}ch" } ?: ""
|
||||
val label = listOfNotNull(
|
||||
format.label?.takeIf { it.isNotBlank() },
|
||||
lang.takeIf { it.isNotBlank() },
|
||||
shortMime(mime),
|
||||
ch.takeIf { it.isNotBlank() },
|
||||
).joinToString(" · ").ifBlank { "Track ${ti + 1}" }
|
||||
val label = if (type == C.TRACK_TYPE_TEXT) {
|
||||
formatTextTrackLabel(format, ti)
|
||||
} else {
|
||||
formatAudioTrackLabel(format, ti)
|
||||
}
|
||||
out += PlayerTrack(gi, ti, label, type)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun formatAudioTrackLabel(format: androidx.media3.common.Format, index: Int): String {
|
||||
val mime = format.sampleMimeType ?: ""
|
||||
val lang = prettyLanguage(format.language)
|
||||
val ch = format.channelCount.takeIf { it > 0 }?.let { "${it}ch" }
|
||||
return listOfNotNull(
|
||||
format.label?.takeIf { it.isNotBlank() && !it.equals("und", true) },
|
||||
lang,
|
||||
shortMime(mime).takeIf { it.isNotBlank() },
|
||||
ch,
|
||||
).joinToString(" · ").ifBlank { "Audio ${index + 1}" }
|
||||
}
|
||||
|
||||
private fun formatTextTrackLabel(format: androidx.media3.common.Format, index: Int): String {
|
||||
val lang = prettyLanguage(format.language)
|
||||
val kind = when {
|
||||
format.sampleMimeType?.contains("vtt", true) == true -> "VTT"
|
||||
format.sampleMimeType?.contains("ssa", true) == true ||
|
||||
format.sampleMimeType?.contains("ass", true) == true -> "ASS"
|
||||
format.sampleMimeType?.contains("subrip", true) == true ||
|
||||
format.sampleMimeType?.contains("srt", true) == true -> "SRT"
|
||||
else -> null
|
||||
}
|
||||
val forced = if ((format.selectionFlags and C.SELECTION_FLAG_FORCED) != 0) "Forced" else null
|
||||
val label = format.label?.takeIf {
|
||||
it.isNotBlank() &&
|
||||
!it.equals("und", true) &&
|
||||
!it.equals("sub", true) &&
|
||||
!it.equals("subtitle", true)
|
||||
}
|
||||
return listOfNotNull(label, lang, kind, forced)
|
||||
.distinct()
|
||||
.joinToString(" · ")
|
||||
.ifBlank { "Ondertitel ${index + 1}" }
|
||||
}
|
||||
|
||||
private fun prettyLanguage(code: String?): String? {
|
||||
val c = code?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "und" && it != "null" } ?: return null
|
||||
return when (c) {
|
||||
"nl", "dut", "nld" -> "Nederlands"
|
||||
"en", "eng" -> "Engels"
|
||||
"de", "ger", "deu" -> "Duits"
|
||||
"fr", "fre", "fra" -> "Frans"
|
||||
"es", "spa" -> "Spaans"
|
||||
"it", "ita" -> "Italiaans"
|
||||
"pt", "por" -> "Portugees"
|
||||
"pl", "pol" -> "Pools"
|
||||
"ru", "rus" -> "Russisch"
|
||||
"ja", "jpn" -> "Japans"
|
||||
"ko", "kor" -> "Koreaans"
|
||||
"zh", "chi", "zho", "cmn" -> "Chinees"
|
||||
"ar", "ara" -> "Arabisch"
|
||||
"tr", "tur" -> "Turks"
|
||||
"sv", "swe" -> "Zweeds"
|
||||
"no", "nor", "nb", "nn" -> "Noors"
|
||||
"da", "dan" -> "Deens"
|
||||
"fi", "fin" -> "Fins"
|
||||
else -> c.uppercase()
|
||||
}
|
||||
}
|
||||
|
||||
/** Kies een bruikbaar audiospoor als er nog niets speelt (commentary overslaan). */
|
||||
fun ExoPlayer.ensureAudioSelected() {
|
||||
if (currentTracks.hasAudioSelected()) {
|
||||
|
|
@ -170,6 +227,34 @@ fun ExoPlayer.disableTrackType(type: Int) {
|
|||
.build()
|
||||
}
|
||||
|
||||
fun ExoPlayer.applySideloadedSubtitle(
|
||||
uri: android.net.Uri,
|
||||
mimeType: String,
|
||||
language: String,
|
||||
label: String,
|
||||
) {
|
||||
val current = currentMediaItem ?: return
|
||||
val pos = currentPosition.coerceAtLeast(0)
|
||||
val wasPlaying = playWhenReady
|
||||
val sub = MediaItem.SubtitleConfiguration.Builder(uri)
|
||||
.setMimeType(mimeType)
|
||||
.setLanguage(language)
|
||||
.setLabel(label)
|
||||
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
|
||||
.build()
|
||||
val next = current.buildUpon()
|
||||
.setSubtitleConfigurations(listOf(sub))
|
||||
.build()
|
||||
setMediaItem(next, /* startPositionMs = */ pos)
|
||||
trackSelectionParameters = trackSelectionParameters
|
||||
.buildUpon()
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||
.setPreferredTextLanguage(language)
|
||||
.build()
|
||||
prepare()
|
||||
playWhenReady = wasPlaying
|
||||
}
|
||||
|
||||
private fun shortMime(mime: String): String = when {
|
||||
MimeTypes.AUDIO_E_AC3 == mime || mime.contains("eac3") || mime.contains("ec-3") -> "E-AC3"
|
||||
MimeTypes.AUDIO_AC3 == mime || mime.contains("ac3") -> "AC3"
|
||||
|
|
@ -179,8 +264,7 @@ private fun shortMime(mime: String): String = when {
|
|||
MimeTypes.AUDIO_AAC == mime || mime.contains("mp4a") -> "AAC"
|
||||
mime.contains("vorbis") -> "Vorbis"
|
||||
mime.startsWith("audio/") -> mime.substringAfter("audio/").uppercase()
|
||||
mime.startsWith("application/") || mime.startsWith("text/") -> "SUB"
|
||||
else -> mime.substringAfterLast('/').uppercase()
|
||||
else -> ""
|
||||
}
|
||||
|
||||
fun Tracks.hasAudioSelected(): Boolean {
|
||||
|
|
|
|||
|
|
@ -717,13 +717,14 @@ private fun MovieDetailScreen(
|
|||
if (arr != null) {
|
||||
for (i in 0 until arr.length()) {
|
||||
val f = arr.getJSONObject(i)
|
||||
val label = buildList {
|
||||
f.optString("resolution").ifBlank { null }?.let(::add)
|
||||
f.optString("hdrType").ifBlank { null }?.let(::add)
|
||||
f.optString("videoCodec").ifBlank { null }?.let(::add)
|
||||
f.optString("audioCodec").ifBlank { null }?.let(::add)
|
||||
f.optString("releaseName").ifBlank { null }?.let { if (isEmpty()) add(it.take(42)) }
|
||||
f.optString("nodeName").ifBlank { null }?.let { if (isEmpty()) add(it) }
|
||||
val fromApi = f.optCleanString("label")
|
||||
val label = fromApi ?: buildList {
|
||||
f.optCleanString("resolution")?.let(::add)
|
||||
f.optCleanString("hdrType")?.let(::add)
|
||||
f.optCleanString("videoCodec")?.let(::add)
|
||||
f.optCleanString("audioCodec")?.let(::add)
|
||||
f.optCleanString("releaseName")?.let { if (isEmpty()) add(it.take(42)) }
|
||||
f.optCleanString("nodeName")?.let { if (isEmpty()) add(it) }
|
||||
}.joinToString(" · ").ifBlank { "Versie ${i + 1}" }
|
||||
list += f.getString("id") to label
|
||||
}
|
||||
|
|
@ -918,3 +919,13 @@ private fun SettingsScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Android JSONObject.optString returns the literal "null" for JSON null — filter that. */
|
||||
private fun org.json.JSONObject.optCleanString(key: String): String? {
|
||||
if (!has(key) || isNull(key)) return null
|
||||
val raw = optString(key).trim()
|
||||
if (raw.isEmpty() || raw.equals("null", ignoreCase = true) || raw.equals("undefined", ignoreCase = true)) {
|
||||
return null
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import androidx.compose.ui.unit.sp
|
|||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem as ExoMediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
|
|
@ -60,9 +61,12 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
|
|||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nl.vonas.mediacluster.tv.data.ApiClient
|
||||
import nl.vonas.mediacluster.tv.data.SubtitleHit
|
||||
import nl.vonas.mediacluster.tv.playback.PlayerTrack
|
||||
import nl.vonas.mediacluster.tv.playback.applySideloadedSubtitle
|
||||
import nl.vonas.mediacluster.tv.playback.buildTvPlayer
|
||||
import nl.vonas.mediacluster.tv.playback.disableTrackType
|
||||
import nl.vonas.mediacluster.tv.playback.ensureAudioSelected
|
||||
|
|
@ -70,6 +74,9 @@ import nl.vonas.mediacluster.tv.playback.listTracks
|
|||
import nl.vonas.mediacluster.tv.playback.selectTrack
|
||||
import nl.vonas.mediacluster.tv.ui.components.McButton
|
||||
import nl.vonas.mediacluster.tv.ui.theme.McColors
|
||||
import android.util.Base64
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -87,6 +94,7 @@ fun PlayerScreen(
|
|||
val player = remember { buildTvPlayer(context) }
|
||||
val controlsFocus = remember { FocusRequester() }
|
||||
val catcherFocus = remember { FocusRequester() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
|
|
@ -96,7 +104,11 @@ fun PlayerScreen(
|
|||
var durationMs by remember { mutableLongStateOf(0L) }
|
||||
var audioTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
|
||||
var textTracks by remember { mutableStateOf<List<PlayerTrack>>(emptyList()) }
|
||||
var menu by remember { mutableStateOf<String?>(null) } // audio | text
|
||||
var menu by remember { mutableStateOf<String?>(null) } // audio | text | os
|
||||
var osHits by remember { mutableStateOf<List<SubtitleHit>>(emptyList()) }
|
||||
var osLoading by remember { mutableStateOf(false) }
|
||||
var osError by remember { mutableStateOf<String?>(null) }
|
||||
var osBusy by remember { mutableStateOf(false) }
|
||||
|
||||
fun showControls() {
|
||||
controls = true
|
||||
|
|
@ -111,8 +123,76 @@ fun PlayerScreen(
|
|||
showControls()
|
||||
}
|
||||
|
||||
fun openOpenSubtitles() {
|
||||
player.pause()
|
||||
menu = "os"
|
||||
osError = null
|
||||
osHits = emptyList()
|
||||
osLoading = true
|
||||
showControls()
|
||||
scope.launch {
|
||||
try {
|
||||
val res = api.searchSubtitles(mediaFileId)
|
||||
if (!res.configured) {
|
||||
osError = "OpenSubtitles staat uit in de admin"
|
||||
} else if (res.results.isEmpty()) {
|
||||
osError = "Geen ondertitels gevonden"
|
||||
}
|
||||
osHits = res.results
|
||||
} catch (e: Exception) {
|
||||
osError = e.message ?: "Zoeken mislukt"
|
||||
} finally {
|
||||
osLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pickOpenSubtitle(hit: SubtitleHit) {
|
||||
if (osBusy) return
|
||||
osBusy = true
|
||||
osError = null
|
||||
scope.launch {
|
||||
try {
|
||||
val fetched = api.fetchSubtitle(hit.fileId, hit.source)
|
||||
val dir = File(context.cacheDir, "subs").also { it.mkdirs() }
|
||||
val ext = when (fetched.format.lowercase()) {
|
||||
"vtt" -> "vtt"
|
||||
"ass", "ssa" -> "ass"
|
||||
else -> "srt"
|
||||
}
|
||||
val file = File(dir, "os-${fetched.source}-${fetched.fileId}.$ext")
|
||||
file.writeBytes(Base64.decode(fetched.contentBase64, Base64.DEFAULT))
|
||||
val mime = when (ext) {
|
||||
"vtt" -> MimeTypes.TEXT_VTT
|
||||
"ass" -> MimeTypes.TEXT_SSA
|
||||
else -> MimeTypes.APPLICATION_SUBRIP
|
||||
}
|
||||
val lang = hit.language.ifBlank { "nl" }
|
||||
val label = buildList {
|
||||
add(langLabel(lang))
|
||||
hit.release.takeIf { it.isNotBlank() }?.let { add(it.take(40)) }
|
||||
if (hit.hearingImpaired) add("SDH")
|
||||
}.joinToString(" · ")
|
||||
player.applySideloadedSubtitle(
|
||||
uri = android.net.Uri.fromFile(file),
|
||||
mimeType = mime,
|
||||
language = lang,
|
||||
label = label,
|
||||
)
|
||||
menu = null
|
||||
player.play()
|
||||
showControls()
|
||||
} catch (e: Exception) {
|
||||
osError = e.message ?: "Download mislukt"
|
||||
} finally {
|
||||
osBusy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
when {
|
||||
menu == "os" -> menu = "text"
|
||||
menu != null -> menu = null
|
||||
controls -> onBack()
|
||||
else -> showControls()
|
||||
|
|
@ -427,6 +507,7 @@ fun PlayerScreen(
|
|||
|
||||
if (menu != null) {
|
||||
val isAudio = menu == "audio"
|
||||
val isOs = menu == "os"
|
||||
val tracks = if (isAudio) audioTracks else textTracks
|
||||
Box(
|
||||
Modifier
|
||||
|
|
@ -437,27 +518,67 @@ fun PlayerScreen(
|
|||
Column(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.width(400.dp)
|
||||
.width(420.dp)
|
||||
.background(Color(0xF20A0A0A))
|
||||
.padding(horizontal = 28.dp, vertical = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
if (isAudio) "Audio" else "Ondertiteling",
|
||||
when {
|
||||
isAudio -> "Audio"
|
||||
isOs -> "OpenSubtitles"
|
||||
else -> "Ondertiteling"
|
||||
},
|
||||
color = Color.White,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (isOs) {
|
||||
if (osLoading) {
|
||||
Text("Zoeken…", color = Color(0xFF8A8A8A), fontSize = 14.sp)
|
||||
}
|
||||
if (osError != null) {
|
||||
Text(osError!!, color = Color(0xFFFF6B6B), fontSize = 14.sp)
|
||||
}
|
||||
if (osBusy) {
|
||||
Text("Bezig met downloaden…", color = Color(0xFF8A8A8A), fontSize = 14.sp)
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(osHits, key = { "${it.source}-${it.fileId}" }) { hit ->
|
||||
val rowLabel = buildList {
|
||||
add(langLabel(hit.language))
|
||||
hit.release.takeIf { it.isNotBlank() }?.let { add(it.take(36)) }
|
||||
if (hit.hearingImpaired) add("SDH")
|
||||
add(hit.source.uppercase())
|
||||
if (hit.downloadCount > 0) add("${hit.downloadCount}×")
|
||||
}.joinToString(" · ")
|
||||
NetflixTrackRow(rowLabel, selected = false) {
|
||||
pickOpenSubtitle(hit)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
NetflixTextButton("Terug") { menu = "text" }
|
||||
} else {
|
||||
if (!isAudio) {
|
||||
NetflixTrackRow("Uit", selected = false) {
|
||||
player.disableTrackType(C.TRACK_TYPE_TEXT)
|
||||
menu = null
|
||||
}
|
||||
NetflixTrackRow("Zoeken op OpenSubtitles…", selected = false) {
|
||||
openOpenSubtitles()
|
||||
}
|
||||
if (tracks.isEmpty()) {
|
||||
}
|
||||
if (tracks.isEmpty() && isAudio) {
|
||||
Text("Geen sporen", color = Color(0xFF8A8A8A), fontSize = 14.sp)
|
||||
}
|
||||
if (tracks.isEmpty() && !isAudio) {
|
||||
Text("Geen ingebouwde ondertitels", color = Color(0xFF8A8A8A), fontSize = 14.sp)
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
|
|
@ -476,6 +597,19 @@ fun PlayerScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun langLabel(code: String): String {
|
||||
val c = code.trim().lowercase()
|
||||
return when (c) {
|
||||
"nl", "dut", "nld" -> "Nederlands"
|
||||
"en", "eng" -> "Engels"
|
||||
"de", "ger", "deu" -> "Duits"
|
||||
"fr", "fre", "fra" -> "Frans"
|
||||
"es", "spa" -> "Spaans"
|
||||
else -> c.uppercase().ifBlank { "ONB" }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -74,9 +74,9 @@ async function main() {
|
|||
await registerStremioRoutes(app, config);
|
||||
await registerAdminRoutes(app, config);
|
||||
await registerInstallRoutes(app, config);
|
||||
registerDownloadRoutes(app, config);
|
||||
registerSettingsRoutes(app, config);
|
||||
registerViewerRoutes(app, config);
|
||||
const downloads = registerDownloadRoutes(app, config);
|
||||
registerViewerRoutes(app, config, downloads);
|
||||
|
||||
app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
|
||||
void nodeConnectionManager.handleConnection(socket);
|
||||
|
|
|
|||
|
|
@ -86,6 +86,8 @@ export class DownloadService {
|
|||
releaseHint?: string;
|
||||
movieTitle?: string;
|
||||
movieYear?: number | null;
|
||||
/** Fallback voor OS.org (bijv. serie S01E02) als IMDb ontbreekt of episode-specifiek. */
|
||||
query?: string;
|
||||
}): Promise<{
|
||||
imdbId: string | null;
|
||||
results: SubtitleHit[];
|
||||
|
|
@ -106,9 +108,10 @@ export class DownloadService {
|
|||
}
|
||||
|
||||
const languages = opts.languages?.trim() || "nl";
|
||||
const query = opts.query?.trim() || undefined;
|
||||
|
||||
// Alleen IMDb — free-text query mengt verkeerde films door elkaar
|
||||
if (!imdbId) {
|
||||
// Zonder IMDb: alleen OS.org query (als aanwezig)
|
||||
if (!imdbId && !query) {
|
||||
return { imdbId: null, results: [], configured };
|
||||
}
|
||||
|
||||
|
|
@ -120,11 +123,15 @@ export class DownloadService {
|
|||
return [] as SubtitleHit[];
|
||||
});
|
||||
|
||||
if (comClient) {
|
||||
if (imdbId && comClient) {
|
||||
tasks.push(safe("com/imdb", comClient.search({ imdbId, languages })));
|
||||
}
|
||||
// OpenSubtitles.org = aparte catalogus (rest.opensubtitles.org)
|
||||
if (imdbId) {
|
||||
tasks.push(safe("org/imdb", orgClient.search({ imdbId, languages })));
|
||||
}
|
||||
if (query) {
|
||||
tasks.push(safe("org/query", orgClient.search({ query, languages })));
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return { imdbId, results: [], configured };
|
||||
|
|
@ -141,7 +148,7 @@ export class DownloadService {
|
|||
if (r.language === "nl" || r.language.startsWith("nl") || r.language === "dut") {
|
||||
score += 1_000_000;
|
||||
}
|
||||
if (r.source === "org") score += 8; // .org vaak rijker; niet onder .com laten verdwijnen
|
||||
if (r.source === "org") score += 8;
|
||||
if (r.source === "com") score += 5;
|
||||
if (hint) {
|
||||
const hay = `${r.release} ${r.fileName}`.toLowerCase();
|
||||
|
|
@ -158,6 +165,82 @@ export class DownloadService {
|
|||
return { imdbId, results: scored.slice(0, 40), configured };
|
||||
}
|
||||
|
||||
/** TV/client: resolve IMDb (+ episode query) from media file, then search OS.com/OS.org. */
|
||||
async searchSubtitlesForMediaFile(mediaFileId: string, languages = "nl,en") {
|
||||
const file = await prisma.mediaFile.findUnique({
|
||||
where: { id: mediaFileId },
|
||||
include: {
|
||||
movie: { select: { imdbId: true, tmdbId: true, title: true, year: true } },
|
||||
episode: {
|
||||
select: {
|
||||
imdbId: true,
|
||||
tmdbId: true,
|
||||
title: true,
|
||||
seasonNumber: true,
|
||||
episodeNumber: true,
|
||||
series: { select: { imdbId: true, tmdbId: true, title: true, year: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!file) throw new AppError("NOT_FOUND", "Mediabestand niet gevonden", 404);
|
||||
|
||||
const imdbId =
|
||||
file.movie?.imdbId ||
|
||||
file.episode?.imdbId ||
|
||||
file.episode?.series?.imdbId ||
|
||||
undefined;
|
||||
const tmdbId = file.movie?.tmdbId || file.episode?.tmdbId || file.episode?.series?.tmdbId || undefined;
|
||||
const movieTitle = file.movie?.title || file.episode?.series?.title || undefined;
|
||||
const movieYear = file.movie?.year ?? file.episode?.series?.year ?? null;
|
||||
|
||||
let query: string | undefined;
|
||||
if (file.episode && !file.episode.imdbId) {
|
||||
const s = String(file.episode.seasonNumber).padStart(2, "0");
|
||||
const e = String(file.episode.episodeNumber).padStart(2, "0");
|
||||
const name = file.episode.series.title;
|
||||
query = `${name} S${s}E${e}`;
|
||||
}
|
||||
|
||||
const base = await this.searchSubtitles({
|
||||
imdbId,
|
||||
tmdbId: tmdbId ?? undefined,
|
||||
languages,
|
||||
releaseHint: file.releaseName,
|
||||
movieTitle,
|
||||
movieYear,
|
||||
query,
|
||||
});
|
||||
|
||||
return {
|
||||
...base,
|
||||
title: movieTitle || null,
|
||||
seasonNumber: file.episode?.seasonNumber ?? null,
|
||||
episodeNumber: file.episode?.episodeNumber ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async downloadSubtitleForClient(fileId: number, source: SubtitleSource) {
|
||||
if (!Number.isFinite(fileId) || fileId <= 0) {
|
||||
throw new AppError("INVALID_REQUEST", "fileId ongeldig", 400);
|
||||
}
|
||||
const src: SubtitleSource = source === "com" ? "com" : "org";
|
||||
const file = await this.downloadSubtitleFile(fileId, src);
|
||||
const lower = file.fileName.toLowerCase();
|
||||
const format = lower.endsWith(".vtt")
|
||||
? "vtt"
|
||||
: lower.endsWith(".ass") || lower.endsWith(".ssa")
|
||||
? "ass"
|
||||
: "srt";
|
||||
return {
|
||||
fileId,
|
||||
source: src,
|
||||
fileName: file.fileName,
|
||||
format,
|
||||
contentBase64: file.content.toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
private async clientForNode(nodeId: string): Promise<{
|
||||
client: SynologyDownloadStation;
|
||||
config: Awaited<ReturnType<typeof prisma.synologyDsConfig.findUniqueOrThrow>>;
|
||||
|
|
|
|||
58
apps/master-api/src/viewer/media-label.ts
Normal file
58
apps/master-api/src/viewer/media-label.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/** Strip JSON/DB junk ("null", empty) from media metadata fields. */
|
||||
export function cleanMeta(value: string | null | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
const t = String(value).trim();
|
||||
if (!t) return null;
|
||||
if (/^(null|undefined|none|n\/a|unknown)$/i.test(t)) return null;
|
||||
return t;
|
||||
}
|
||||
|
||||
function prettyCodec(value: string): string {
|
||||
const v = value.trim();
|
||||
const lower = v.toLowerCase();
|
||||
if (/^h\.?264$/i.test(v) || lower === "avc" || lower === "avc1") return "H.264";
|
||||
if (/^h\.?265$/i.test(v) || lower === "hevc" || lower === "x265") return "HEVC";
|
||||
if (lower === "x264") return "x264";
|
||||
if (/atmos/i.test(v)) return v.replace(/atmos/gi, "Atmos");
|
||||
if (/truehd/i.test(v)) return v.replace(/truehd/gi, "TrueHD");
|
||||
if (/dts-?hd/i.test(v)) return "DTS-HD";
|
||||
if (lower === "eac3" || lower === "e-ac3" || lower === "ec-3") return "E-AC3";
|
||||
if (lower === "ac3" || lower === "ac-3") return "AC3";
|
||||
return v;
|
||||
}
|
||||
|
||||
export function formatMediaFileLabel(file: {
|
||||
resolution?: string | null;
|
||||
hdrType?: string | null;
|
||||
videoCodec?: string | null;
|
||||
audioCodec?: string | null;
|
||||
audioChannels?: string | null;
|
||||
releaseName?: string | null;
|
||||
nodeName?: string | null;
|
||||
index?: number;
|
||||
}): string {
|
||||
const bits: string[] = [];
|
||||
const resolution = cleanMeta(file.resolution);
|
||||
const hdr = cleanMeta(file.hdrType);
|
||||
const video = cleanMeta(file.videoCodec);
|
||||
const audio = cleanMeta(file.audioCodec);
|
||||
const channels = cleanMeta(file.audioChannels);
|
||||
|
||||
if (resolution) bits.push(resolution);
|
||||
if (hdr) bits.push(hdr.toUpperCase() === hdr ? hdr : hdr);
|
||||
if (video) bits.push(prettyCodec(video));
|
||||
if (audio) bits.push(prettyCodec(audio));
|
||||
if (channels && !bits.some((b) => b.toLowerCase().includes(channels.toLowerCase()))) {
|
||||
bits.push(channels);
|
||||
}
|
||||
|
||||
if (bits.length) return bits.join(" · ");
|
||||
|
||||
const release = cleanMeta(file.releaseName);
|
||||
if (release) return release.length > 48 ? `${release.slice(0, 45)}…` : release;
|
||||
|
||||
const node = cleanMeta(file.nodeName);
|
||||
if (node) return node;
|
||||
|
||||
return `Versie ${(file.index ?? 0) + 1}`;
|
||||
}
|
||||
|
|
@ -4,10 +4,16 @@ import { requireAdmin } from "../auth/routes";
|
|||
import { PlaybackService } from "../playback/service";
|
||||
import type { Config } from "../config";
|
||||
import { ViewerService } from "./service";
|
||||
import type { DownloadService } from "../downloads/service";
|
||||
import type { SubtitleSource } from "../opensubtitles/client";
|
||||
|
||||
export function registerViewerRoutes(app: FastifyInstance, config: Config) {
|
||||
export function registerViewerRoutes(
|
||||
app: FastifyInstance,
|
||||
config: Config,
|
||||
downloads?: DownloadService
|
||||
) {
|
||||
const playback = new PlaybackService(config);
|
||||
const viewers = new ViewerService(config, playback);
|
||||
const viewers = new ViewerService(config, playback, downloads);
|
||||
|
||||
// ——— Admin: kijkers ———
|
||||
app.get("/api/v1/admin/viewers", { preHandler: requireAdmin }, async () => {
|
||||
|
|
@ -202,4 +208,24 @@ export function registerViewerRoutes(app: FastifyInstance, config: Config) {
|
|||
}
|
||||
return { shelves: await viewers.setShelfPrefs(auth.viewerId, body.shelves) };
|
||||
});
|
||||
|
||||
app.get("/api/v1/client/subtitles/search", async (request) => {
|
||||
await viewers.authFromBearer(request.headers.authorization);
|
||||
const q = request.query as { mediaFileId?: string; languages?: string };
|
||||
if (!q.mediaFileId?.trim()) {
|
||||
throw new AppError("INVALID_REQUEST", "mediaFileId verplicht", 400);
|
||||
}
|
||||
return viewers.searchSubtitles(q.mediaFileId.trim(), q.languages || "nl,en");
|
||||
});
|
||||
|
||||
app.post("/api/v1/client/subtitles/fetch", async (request) => {
|
||||
await viewers.authFromBearer(request.headers.authorization);
|
||||
const body = request.body as { fileId?: number; source?: string };
|
||||
const fileId = typeof body.fileId === "number" ? body.fileId : parseInt(String(body.fileId || ""), 10);
|
||||
if (!Number.isFinite(fileId) || fileId <= 0) {
|
||||
throw new AppError("INVALID_REQUEST", "fileId verplicht", 400);
|
||||
}
|
||||
const source: SubtitleSource = body.source === "com" ? "com" : "org";
|
||||
return viewers.fetchSubtitle(fileId, source);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import { AppError } from "../security/errors";
|
|||
import { PlaybackService } from "../playback/service";
|
||||
import type { Config } from "../config";
|
||||
import type { FastifyRequest } from "fastify";
|
||||
import { cleanMeta, formatMediaFileLabel } from "./media-label";
|
||||
import type { DownloadService } from "../downloads/service";
|
||||
import type { SubtitleSource } from "../opensubtitles/client";
|
||||
|
||||
const CODE_TTL_MS = 10 * 60 * 1000;
|
||||
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
|
@ -41,7 +44,8 @@ export function hashLinkCode(raw: string): string {
|
|||
export class ViewerService {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly playback: PlaybackService
|
||||
private readonly playback: PlaybackService,
|
||||
private readonly downloads?: DownloadService
|
||||
) {}
|
||||
|
||||
async createViewer(input: {
|
||||
|
|
@ -1052,15 +1056,26 @@ export class ViewerService {
|
|||
backdropUrl: movie.backdropUrl,
|
||||
runtime: movie.runtime,
|
||||
genres: movie.genres,
|
||||
mediaFiles: movie.mediaFiles.map((f) => ({
|
||||
mediaFiles: movie.mediaFiles.map((f, index) => ({
|
||||
id: f.id,
|
||||
resolution: cleanMeta(f.resolution),
|
||||
hdrType: cleanMeta(f.hdrType),
|
||||
videoCodec: cleanMeta(f.videoCodec),
|
||||
audioCodec: cleanMeta(f.audioCodec),
|
||||
audioChannels: cleanMeta(f.audioChannels),
|
||||
releaseName: f.releaseName,
|
||||
nodeName: f.node.name,
|
||||
nodeOnline: f.node.status === "ONLINE",
|
||||
label: formatMediaFileLabel({
|
||||
resolution: f.resolution,
|
||||
hdrType: f.hdrType,
|
||||
videoCodec: f.videoCodec,
|
||||
audioCodec: f.audioCodec,
|
||||
audioChannels: f.audioChannels,
|
||||
releaseName: f.releaseName,
|
||||
nodeName: f.node.name,
|
||||
nodeOnline: f.node.status === "ONLINE",
|
||||
index,
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -1121,6 +1136,20 @@ export class ViewerService {
|
|||
};
|
||||
}
|
||||
|
||||
async searchSubtitles(mediaFileId: string, languages = "nl,en") {
|
||||
if (!this.downloads) {
|
||||
throw new AppError("NOT_CONFIGURED", "Ondertitelzoeken niet beschikbaar", 503);
|
||||
}
|
||||
return this.downloads.searchSubtitlesForMediaFile(mediaFileId, languages);
|
||||
}
|
||||
|
||||
async fetchSubtitle(fileId: number, source: SubtitleSource) {
|
||||
if (!this.downloads) {
|
||||
throw new AppError("NOT_CONFIGURED", "Ondertiteldownload niet beschikbaar", 503);
|
||||
}
|
||||
return this.downloads.downloadSubtitleForClient(fileId, source);
|
||||
}
|
||||
|
||||
private publicBase(): string {
|
||||
const base = (this.config.CLIENT_LINK_BASE_URL || this.config.PUBLIC_URL).replace(/\/+$/, "");
|
||||
return base;
|
||||
|
|
|
|||
Loading…
Reference in a new issue