preserve opensubtitles caption labels

This commit is contained in:
dirtydishes 2026-05-25 12:55:10 -04:00
parent 046444f9ad
commit ae996e7ffb
6 changed files with 628 additions and 5 deletions

View file

@ -33,3 +33,4 @@
{"id":"int-f9deecdb","kind":"field_change","created_at":"2026-05-25T16:18:29.458162Z","actor":"dirtydishes","issue_id":"dreamio-urs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by rejecting OpenSubtitles manifest.json_N identifiers as playable subtitle URLs, promoting file_id values to API download URLs, and adding parser coverage for the live log shape."}}
{"id":"int-569ee372","kind":"field_change","created_at":"2026-05-25T16:22:50.024736Z","actor":"dirtydishes","issue_id":"dreamio-433","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by tightening OpenSubtitles subtitle URL filtering in the web bridge and Swift parser, plus adding regression coverage for logged artwork and addon endpoint false positives."}}
{"id":"int-eca1f7f8","kind":"field_change","created_at":"2026-05-25T16:33:55.331041Z","actor":"dirtydishes","issue_id":"dreamio-9sp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Accepted Stremio subtitle download URLs in the bridge, parser, resolver, and regression tests."}}
{"id":"int-99b3cb8b","kind":"field_change","created_at":"2026-05-25T16:54:58.390731Z","actor":"dirtydishes","issue_id":"dreamio-2ju","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by preserving OpenSubtitles subtitle display metadata through VLC external track attachment and adding display-name tests."}}

View file

@ -23,6 +23,7 @@
{"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-2ju","title":"Show OpenSubtitles languages in caption tracks","description":"Preserve external subtitle metadata after VLC attaches OpenSubtitles tracks so the captions menu shows useful language labels instead of generic VLC track names.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:52:44Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:54:58Z","started_at":"2026-05-25T16:52:49Z","closed_at":"2026-05-25T16:54:58Z","close_reason":"Fixed by preserving OpenSubtitles subtitle display metadata through VLC external track attachment and adding display-name tests.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-h5n","title":"Throttle VLC subtitle reapply during buffering","description":"VLC subtitle auto-selection currently reapplies the same subtitle track on every buffering state notification, producing noisy logs and unnecessary repeated player writes. Limit state-driven reapply to meaningful selection recovery or state transitions while preserving delayed retries after initial auto-selection.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:06:48Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:09:02Z","started_at":"2026-05-25T15:06:55Z","closed_at":"2026-05-25T15:09:02Z","close_reason":"Limited VLC auto-subtitle reapply to real selection recovery while keeping bounded delayed startup confirmations.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-c1m","title":"Add captions selection proof logging","description":"Add DEBUG-only logs around the native captions menu and VLC subtitle selection path so subtitle tap actions prove whether the UI fires and whether VLC accepts the selected embedded track index.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:18:06Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:19:19Z","started_at":"2026-05-25T14:18:11Z","closed_at":"2026-05-25T14:19:19Z","close_reason":"Added DEBUG-only logs for captions menu actions and VLC subtitle selection results.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-e9p","title":"Add native subtitle pipeline proof logging","description":"Add DEBUG-only logs across the web bridge, native player, subtitle resolution, and VLC attachment points so the next Xcode run can identify where external subtitles disappear without changing playback behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:03:18Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:07:14Z","started_at":"2026-05-25T14:03:22Z","closed_at":"2026-05-25T14:07:14Z","close_reason":"Added DEBUG-only subtitle pipeline proof logging and documented validation.","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -40,6 +40,108 @@ struct SubtitleTrack: Equatable {
let name: String
}
enum SubtitleDisplayName {
private static let genericLabels = [
"external subtitle",
"subtitle",
"unknown"
]
private static let languageCodeAliases = [
"eng": "en",
"en": "en",
"spa": "es",
"es": "es",
"fre": "fr",
"fra": "fr",
"fr": "fr"
]
static func displayName(for candidate: SubtitleCandidate) -> String {
if let label = meaningfulDisplayText(candidate.label) {
return label
}
if let languageName = languageName(for: candidate.language) {
return languageName
}
return fallbackName(from: candidate)
}
static func name(forVLCTrackName trackName: String, preservedName: String?) -> String {
guard isGenericLabel(trackName), let preservedName else {
return trackName
}
return preservedName
}
static func isGenericLabel(_ value: String) -> Bool {
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else {
return true
}
let lowercased = normalized.lowercased()
if genericLabels.contains(lowercased) {
return true
}
if Int(normalized) != nil {
return true
}
if lowercased.range(of: #"^track\s*\d+$"#, options: .regularExpression) != nil {
return true
}
return false
}
private static func meaningfulDisplayText(_ value: String?) -> String? {
guard let value else {
return nil
}
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !isGenericLabel(trimmed) else {
return nil
}
guard trimmed.count <= 3 else {
return trimmed
}
return languageName(for: trimmed) ?? trimmed
}
private static func languageName(for value: String?) -> String? {
guard let value else {
return nil
}
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, Int(trimmed) == nil else {
return nil
}
if trimmed.count > 3 {
return trimmed.capitalized
}
let lowercased = trimmed.lowercased()
let languageCode = languageCodeAliases[lowercased] ?? lowercased
guard let name = Locale.current.localizedString(forLanguageCode: languageCode) else {
return nil
}
return name.capitalized
}
private static func fallbackName(from candidate: SubtitleCandidate) -> String {
let trimmedLabel = candidate.label.trimmingCharacters(in: .whitespacesAndNewlines)
if !isGenericLabel(trimmedLabel) {
return trimmedLabel
}
let fileName = candidate.url.deletingPathExtension().lastPathComponent
return fileName.isEmpty ? "External Subtitle" : fileName
}
}
#if DEBUG
enum SubtitleDebugFormatter {
static func candidateSummary(_ candidates: [SubtitleCandidate]) -> String {

View file

@ -28,6 +28,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
private var autoSelectedSubtitleTrackID: Int32?
private var externalSubtitleBaselineTrackIDs = Set<Int32>()
private var hasPendingExternalSubtitleSelection = false
private var pendingExternalSubtitleDisplayNames: [String] = []
private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]
override init() {
super.init()
@ -51,6 +53,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
autoSelectedSubtitleTrackID = nil
externalSubtitleBaselineTrackIDs.removeAll()
hasPendingExternalSubtitleSelection = false
pendingExternalSubtitleDisplayNames.removeAll()
externalSubtitleDisplayNamesByTrackID.removeAll()
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
.map { "\($0.key): \($0.value)" }
@ -199,10 +203,15 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
var subtitleTracks: [SubtitleTrack] {
#if canImport(MobileVLCKit)
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? []
return zip(indexes, names).map { index, name in
SubtitleTrack(id: index.int32Value, name: name)
reconcileExternalSubtitleDisplayNames()
return rawSubtitleTracks().map { track in
SubtitleTrack(
id: track.id,
name: SubtitleDisplayName.name(
forVLCTrackName: track.name,
preservedName: externalSubtitleDisplayNamesByTrackID[track.id]
)
)
}
#else
[]
@ -229,7 +238,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {
var attachedCount = 0
var duplicateCount = 0
let baselineTrackIDs = Set(subtitleTracks.filter { $0.id >= 0 }.map(\.id))
let baselineTrackIDs = Set(rawSubtitleTracks().filter { $0.id >= 0 }.map(\.id))
candidates.forEach { candidate in
guard !attachedSubtitleURLs.contains(candidate.url) else {
duplicateCount += 1
@ -238,6 +247,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
attachedSubtitleURLs.insert(candidate.url)
externalSubtitleBaselineTrackIDs.formUnion(baselineTrackIDs)
hasPendingExternalSubtitleSelection = true
pendingExternalSubtitleDisplayNames.append(SubtitleDisplayName.displayName(for: candidate))
mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)
attachedCount += 1
#if DEBUG
@ -268,6 +278,32 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
return attachedCount
}
private func rawSubtitleTracks() -> [SubtitleTrack] {
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? []
return zip(indexes, names).map { index, name in
SubtitleTrack(id: index.int32Value, name: name)
}
}
private func reconcileExternalSubtitleDisplayNames() {
guard !pendingExternalSubtitleDisplayNames.isEmpty else {
return
}
rawSubtitleTracks()
.filter { $0.id >= 0 }
.filter { !externalSubtitleBaselineTrackIDs.contains($0.id) }
.filter { externalSubtitleDisplayNamesByTrackID[$0.id] == nil }
.sorted { $0.id < $1.id }
.forEach { track in
guard !pendingExternalSubtitleDisplayNames.isEmpty else {
return
}
externalSubtitleDisplayNamesByTrackID[track.id] = pendingExternalSubtitleDisplayNames.removeFirst()
}
}
#if DEBUG
private func logSubtitleTracks(reason: String) {
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []

View file

@ -21,6 +21,8 @@ struct StreamResolverTests {
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
testSubtitleCandidateDeduplicationPreservesLabels()
testSubtitleCandidateDeduplicationUpgradesLabels()
testSubtitleDisplayNameNormalization()
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
testSubtitleOptionMappingIncludesNone()
print("StreamResolverTests passed")
}
@ -437,6 +439,56 @@ struct StreamResolverTests {
assertEqual(options.first?.id, -1)
}
private static func testSubtitleDisplayNameNormalization() {
assertEqual(
SubtitleDisplayName.displayName(for: SubtitleCandidate(
url: URL(string: "https://opensubtitles.example.test/download/subtitle.srt")!,
label: "Track 1",
language: "eng"
)),
"English"
)
assertEqual(
SubtitleDisplayName.displayName(for: SubtitleCandidate(
url: URL(string: "https://opensubtitles.example.test/download/subtitle.srt")!,
label: "Track 2",
language: "Spanish"
)),
"Spanish"
)
assertEqual(
SubtitleDisplayName.displayName(for: SubtitleCandidate(
url: URL(string: "https://opensubtitles.example.test/download/subtitle.srt")!,
label: "English SDH",
language: "eng"
)),
"English SDH"
)
assertEqual(
SubtitleDisplayName.displayName(for: SubtitleCandidate(
url: URL(string: "https://cdn.example.test/subtitles/movie.es.srt")!,
label: "External Subtitle",
language: nil
)),
"movie.es"
)
}
private static func testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks() {
let options = SubtitleOptionMapper.options(from: [
SubtitleTrack(
id: 3,
name: SubtitleDisplayName.name(forVLCTrackName: "Track 1", preservedName: "English")
),
SubtitleTrack(
id: 4,
name: SubtitleDisplayName.name(forVLCTrackName: "Commentary", preservedName: "Spanish")
)
])
assertEqual(options.map(\.name), ["None", "English", "Commentary"])
}
private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {
assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)
}

File diff suppressed because one or more lines are too long