Auflösungen heissen jetzt, wie Menschen sie nennen

Auf der Detailseite stand „1080" — die Zeichenkette des Servers in
Grossbuchstaben. Weder eine Zahl mit Bedeutung noch ein Name, den jemand
benutzt. Jetzt steht dort „Full HD", „4K", „HD", „SD".

Die Einstufung geht neu über die **Breite**, nicht über die Höhe. Ein
Kinofilm in 2,39:1 misst 1920 × 804; nach der Höhe war das bisher „720p",
und zwar ausgerechnet bei den grossen Filmen. Die Höhe bleibt als Notnagel
für Server, die keine Breite melden, und der Name des Servers als letzter.

Nebenbei fällt damit der 4K-Filter bei Jellyfin nicht mehr durch, wenn ein
breitwandiges 4K nur 1608 Zeilen hat, und die doppelte Einstufung in
PlexMedia verschwindet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-28 06:33:26 +02:00
co-authored by Claude Opus 5
parent e61cb0403d
commit f47c6385e3
8 changed files with 334 additions and 98 deletions
+64 -60
View File
@@ -5,10 +5,6 @@
"comment" : "A separator between different parts of the episode metadata.", "comment" : "A separator between different parts of the episode metadata.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"%@ GB" : {
"comment" : "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
"isCommentAutoGenerated" : true
},
"%@ → %@" : { "%@ → %@" : {
"comment" : "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.", "comment" : "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -21,8 +17,8 @@
} }
} }
}, },
"1080p" : { "%@ GB" : {
"comment" : "A label for a 1080p video.", "comment" : "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"4K" : { "4K" : {
@@ -33,8 +29,8 @@
"comment" : "A label for a 720p video.", "comment" : "A label for a 720p video.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"SD" : { "1080p" : {
"comment" : "A label for a 720p movie.", "comment" : "A label for a 1080p video.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"account.hint" : { "account.hint" : {
@@ -2313,6 +2309,10 @@
} }
} }
}, },
"library.allLoaded %@" : {
"comment" : "A footer in the library view that shows the number of items in the library.",
"isCommentAutoGenerated" : true
},
"library.allLoaded %lld" : { "library.allLoaded %lld" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
@@ -2922,23 +2922,6 @@
} }
} }
}, },
"liveTV.recordSeries" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Serie aufnehmen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Record series"
}
}
}
},
"liveTV.recording.inProgress" : { "liveTV.recording.inProgress" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
@@ -3007,6 +2990,23 @@
} }
} }
}, },
"liveTV.recordSeries" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Serie aufnehmen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Record series"
}
}
}
},
"liveTV.runningNow" : { "liveTV.runningNow" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -3611,23 +3611,6 @@
} }
} }
}, },
"player.subtitleTracks" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Untertitel"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Subtitles"
}
}
}
},
"player.subtitlesOff" : { "player.subtitlesOff" : {
"comment" : "Eintrag, der die Untertitel abschaltet", "comment" : "Eintrag, der die Untertitel abschaltet",
"extractionState" : "stale", "extractionState" : "stale",
@@ -3646,6 +3629,23 @@
} }
} }
}, },
"player.subtitleTracks" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Untertitel"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Subtitles"
}
}
}
},
"player.tracks" : { "player.tracks" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -3730,23 +3730,6 @@
} }
} }
}, },
"quality.autoShort" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Auto"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Auto"
}
}
}
},
"quality.automatic" : { "quality.automatic" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -3764,6 +3747,23 @@
} }
} }
}, },
"quality.autoShort" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Auto"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Auto"
}
}
}
},
"quality.current" : { "quality.current" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -4190,6 +4190,10 @@
} }
} }
}, },
"SD" : {
"comment" : "A label for a 720p movie.",
"isCommentAutoGenerated" : true
},
"search.hint" : { "search.hint" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -6442,4 +6446,4 @@
} }
}, },
"version" : "1.1" "version" : "1.1"
} }
+2 -2
View File
@@ -174,7 +174,7 @@ struct PlayableDetailView: View {
if let year = item.year { pieces.append(String(year)) } if let year = item.year { pieces.append(String(year)) }
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) } if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
if let rating = item.contentRating { pieces.append(rating) } if let rating = item.contentRating { pieces.append(rating) }
if let resolution = currentMedia?.videoResolution { pieces.append(resolution.uppercased()) } if let resolution = currentMedia?.resolutionLabel { pieces.append(resolution) }
return pieces return pieces
} }
@@ -183,7 +183,7 @@ struct PlayableDetailView: View {
if let year = item.year { pieces.append(String(year)) } if let year = item.year { pieces.append(String(year)) }
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) } if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
if let rating = item.contentRating { pieces.append(rating) } if let rating = item.contentRating { pieces.append(rating) }
if let resolution = currentMedia?.videoResolution { pieces.append(resolution.uppercased()) } if let resolution = currentMedia?.resolutionLabel { pieces.append(resolution) }
return pieces.isEmpty ? nil : pieces.joined(separator: " · ") return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
} }
+2 -2
View File
@@ -50,8 +50,8 @@ struct LibraryFilterSheet: View {
Picker("filter.resolution", selection: $draft.resolution) { Picker("filter.resolution", selection: $draft.resolution) {
Text("filter.any").tag(String?.none) Text("filter.any").tag(String?.none)
Text("4K").tag(String?.some("4k")) Text("4K").tag(String?.some("4k"))
Text("1080p").tag(String?.some("1080")) Text("Full HD").tag(String?.some("1080"))
Text("720p").tag(String?.some("720")) Text("HD").tag(String?.some("720"))
Text("SD").tag(String?.some("sd")) Text("SD").tag(String?.some("sd"))
} }
} }
@@ -194,7 +194,10 @@ public func isInLocalNetwork() async -> Bool {
var total = page.totalRecordCount ?? mapped.count var total = page.totalRecordCount ?? mapped.count
if query.resolution?.lowercased() == "4k" { if query.resolution?.lowercased() == "4k" {
mapped = mapped.filter { ($0.preferredVersion?.pixelHeight ?? 0) >= 1_600 } // Nach der Stufe, nicht nach der Zeilenzahl: ein breitwandiges
// 4K misst 3840 × 1608 und fiele durch jede Höhenschwelle,
// die 2160 erwartet.
mapped = mapped.filter { ($0.preferredVersion?.resolution ?? .sd) >= .uhd4K }
// Die Gesamtzahl stimmt dann nicht mehr — lieber die gefilterte // Die Gesamtzahl stimmt dann nicht mehr — lieber die gefilterte
// Menge als eine Zahl, die zum Nachladen auffordert, obwohl nichts // Menge als eine Zahl, die zum Nachladen auffordert, obwohl nichts
// mehr kommt. // mehr kommt.
@@ -51,32 +51,22 @@ public struct MediaVersion: Sendable, Equatable, Identifiable {
/// Danach richtet sich, welche Qualitätsstufen überhaupt Sinn ergeben — /// Danach richtet sich, welche Qualitätsstufen überhaupt Sinn ergeben —
/// eine 720p-Datei auf 1080p hochzurechnen bringt kein Bild dazu. /// eine 720p-Datei auf 1080p hochzurechnen bringt kein Bild dazu.
public var pixelHeight: Int? { public var pixelHeight: Int? {
if let height { return height } if let height, height > 0 { return height }
switch videoResolution?.lowercased() { return resolution?.nominalHeight
case "4k": return 2_160
case "1080": return 1_080
case "720": return 720
case "480": return 480
case "sd": return 480
default: return nil
}
} }
/// Die Auflösung als Wort — „4K", „1080p". /// Die Stufe, in der diese Fassung vorliegt.
/// public var resolution: VideoResolution? {
/// Aus der Bildhöhe, nicht aus dem Namen des Servers: Plex sagt „4k", VideoResolution(width: width, height: height, serverLabel: videoResolution)
/// Jellyfin gibt Zahlen. Gemeint ist dasselbe.
public var resolutionLabel: String? {
guard let pixelHeight else { return videoResolution?.uppercased() }
switch pixelHeight {
case 1_600...: return "4K"
case 1_000..<1_600: return "1080p"
case 700..<1_000: return "720p"
case 1..<700: return "SD"
default: return nil
}
} }
/// Die Auflösung als Wort — „4K", „Full HD".
///
/// Nicht die Zeichenkette des Servers: Plex sagt „4k" und „1080",
/// Jellyfin gibt Zahlen. Gemeint ist dasselbe, und keines davon ist ein
/// Name, den jemand benutzt.
public var resolutionLabel: String? { resolution?.name }
/// Eine kurze Beschreibung für die Versionsauswahl, etwa /// Eine kurze Beschreibung für die Versionsauswahl, etwa
/// „4K · HEVC · 38 Mbit/s". /// „4K · HEVC · 38 Mbit/s".
public var qualityLabel: String { public var qualityLabel: String {
@@ -0,0 +1,135 @@
import Foundation
/// Die Auflösung als Stufe, nicht als Zahl.
///
/// **Warum es das gibt.** Bisher stand auf der Detailseite schlicht `1080` —
/// die Zeichenkette, die Plex liefert, in Großbuchstaben. Weder eine Zahl mit
/// Bedeutung noch ein Name, den jemand benutzt.
///
/// **Warum die Breite zählt und nicht die Höhe.** Ein Kinofilm liegt in 2,39:1
/// vor und misst dann 1920 × 804. Nach der Höhe wäre das „720p"; nach der
/// Breite ist es, was es ist. Genau so sahen die Fassungen aus, bei denen die
/// alte Einstufung danebenlag — und zwar ausgerechnet bei den großen Filmen.
/// Die Höhe bleibt als Notnagel für Server, die keine Breite melden.
public enum VideoResolution: Sendable, Equatable, Comparable, CaseIterable {
case sd
case hd
case fullHD
case qhd
case uhd4K
case uhd8K
/// Wie der Name in der Oberfläche steht.
///
/// Nicht übersetzt: „Full HD" und „4K" heißen in jeder Sprache so, und
/// eine Übersetzungstabelle für vier feste Wörter wäre eine Fehlerquelle
/// ohne Nutzen.
public var name: String {
switch self {
case .sd: "SD"
case .hd: "HD"
case .fullHD: "Full HD"
case .qhd: "QHD"
case .uhd4K: "4K"
case .uhd8K: "8K"
}
}
/// Die technische Schreibweise — für Stufen, die man wählt, statt für
/// Material, das man beschreibt.
public var technicalName: String {
switch self {
case .sd: "480p"
case .hd: "720p"
case .fullHD: "1080p"
case .qhd: "1440p"
case .uhd4K: "2160p"
case .uhd8K: "4320p"
}
}
/// Die Bildhöhe, die diese Stufe üblicherweise hat.
///
/// Für Entscheidungen, die eine Zahl brauchen — etwa welche
/// Download-Stufen bei diesem Material überhaupt Sinn ergeben.
public var nominalHeight: Int {
switch self {
case .sd: 480
case .hd: 720
case .fullHD: 1_080
case .qhd: 1_440
case .uhd4K: 2_160
case .uhd8K: 4_320
}
}
/// Stuft eine Fassung ein.
///
/// In dieser Reihenfolge: Breite, dann Höhe, dann der Name des Servers.
/// Gibt `nil` zurück, wenn nichts davon etwas hergibt — dann ist eine
/// erfundene Stufe schlechter als gar keine Angabe.
public init?(width: Int?, height: Int?, serverLabel: String? = nil) {
if let width, width > 0 {
self = Self.fromWidth(width)
} else if let height, height > 0 {
self = Self.fromHeight(height)
} else if let stufe = Self.fromServerLabel(serverLabel) {
self = stufe
} else {
return nil
}
}
/// Der verlässliche Weg. Die Breite ist vom Bildformat unabhängig.
private static func fromWidth(_ width: Int) -> VideoResolution {
switch width {
case 7_000...: .uhd8K
case 3_000..<7_000: .uhd4K // 3840 und 4096
case 2_000..<3_000: .qhd // 2560
case 1_700..<2_000: .fullHD // 1920
case 1_100..<1_700: .hd // 1280
default: .sd
}
}
/// Der Notnagel.
///
/// Aus der Höhe allein ist ein breitwandiges 1080p (804 Zeilen) nicht von
/// einem vollformatigen 720p zu unterscheiden. Das ist keine Nachlässigkeit
/// in den Grenzen, sondern liegt an der Angabe: die Information fehlt
/// schlicht. Deshalb kommt die Breite zuerst.
private static func fromHeight(_ height: Int) -> VideoResolution {
switch height {
case 2_800...: .uhd8K
case 1_500..<2_800: .uhd4K
case 1_250..<1_500: .qhd
case 850..<1_250: .fullHD
case 650..<850: .hd
default: .sd
}
}
/// Was Plex hinschreibt: „4k", „1080", „sd".
private static func fromServerLabel(_ raw: String?) -> VideoResolution? {
guard let raw = raw?.lowercased().trimmingCharacters(in: .whitespaces),
!raw.isEmpty
else { return nil }
switch raw {
case "8k", "4320", "4320p": return .uhd8K
case "4k", "uhd", "2160", "2160p": return .uhd4K
case "qhd", "1440", "1440p": return .qhd
case "fhd", "1080", "1080p": return .fullHD
case "hd", "720", "720p": return .hd
case "sd", "576", "576p", "480", "480p", "360", "360p", "240", "240p": return .sd
default: break
}
// „1080i" und Ähnliches: die Ziffern vorne deuten, den Rest verwerfen.
let ziffern = raw.prefix { $0.isNumber }
guard let zahl = Int(ziffern), zahl > 0 else { return nil }
// Über tausend ist es eine Breite, darunter eine Höhe — andersherum
// gibt es keine sinnvolle Lesart.
return zahl >= 2_000 ? fromWidth(zahl) : fromHeight(zahl)
}
}
@@ -1,4 +1,5 @@
import Foundation import Foundation
import MediaKit
/// Eine Datei auf der Platte des Servers. /// Eine Datei auf der Platte des Servers.
/// ///
@@ -144,23 +145,19 @@ public struct PlexMedia: Sendable, Equatable, Codable, Identifiable {
} }
} }
/// Die Auflösung in der Schreibweise, die Menschen erwarten. /// Die Stufe, in der diese Fassung vorliegt.
public var resolutionLabel: String? { public var resolution: VideoResolution? {
guard let pixelHeight else { return videoResolution?.uppercased() } VideoResolution(width: width, height: height, serverLabel: videoResolution)
switch pixelHeight {
case 1_600...: return "4K"
case 1_000..<1_600: return "1080p"
case 700..<1_000: return "720p"
case 1..<700: return "SD"
default: return nil
}
} }
/// Die Auflösung in der Schreibweise, die Menschen erwarten.
public var resolutionLabel: String? { resolution?.name }
/// Eine kurze Beschreibung für die Versionsauswahl auf der Detailseite, /// Eine kurze Beschreibung für die Versionsauswahl auf der Detailseite,
/// etwa „4K · HEVC · 38,2 Mbit/s". /// etwa „4K · HEVC · 38,2 Mbit/s".
public var qualityLabel: String { public var qualityLabel: String {
var pieces: [String] = [] var pieces: [String] = []
if let videoResolution { pieces.append(videoResolution.uppercased()) } if let label = resolutionLabel { pieces.append(label) }
if let videoCodec { pieces.append(videoCodec.uppercased()) } if let videoCodec { pieces.append(videoCodec.uppercased()) }
if let bitrate { pieces.append("\(bitrate / 1000) Mbit/s") } if let bitrate { pieces.append("\(bitrate / 1000) Mbit/s") }
return pieces.joined(separator: " · ") return pieces.joined(separator: " · ")
@@ -0,0 +1,107 @@
import Testing
@testable import MediaKit
@Suite("Auflösung einstufen")
struct VideoResolutionTests {
// MARK: - Nach der Breite
@Test("Die üblichen Breiten")
func commonWidths() {
#expect(VideoResolution(width: 7_680, height: 4_320) == .uhd8K)
#expect(VideoResolution(width: 3_840, height: 2_160) == .uhd4K)
#expect(VideoResolution(width: 4_096, height: 2_160) == .uhd4K)
#expect(VideoResolution(width: 2_560, height: 1_440) == .qhd)
#expect(VideoResolution(width: 1_920, height: 1_080) == .fullHD)
#expect(VideoResolution(width: 1_280, height: 720) == .hd)
#expect(VideoResolution(width: 720, height: 576) == .sd)
}
/// Der Fall, wegen dem die Breite zuerst kommt: ein Kinofilm in 2,39:1 hat
/// 804 Zeilen. Nach der Höhe wäre das „HD" — und das stand bisher an
/// ausgerechnet den größten Filmen.
@Test("Breitwand bleibt Full HD, nicht HD")
func letterboxedStaysFullHD() {
#expect(VideoResolution(width: 1_920, height: 804) == .fullHD)
#expect(VideoResolution(width: 1_920, height: 800) == .fullHD)
}
@Test("Breitwandiges 4K bleibt 4K")
func letterboxed4K() {
#expect(VideoResolution(width: 3_840, height: 1_608) == .uhd4K)
#expect(VideoResolution(width: 3_840, height: 1_392) == .uhd4K)
}
// MARK: - Nach der Höhe, wenn es die Breite nicht gibt
@Test("Ohne Breite entscheidet die Höhe")
func fallsBackToHeight() {
#expect(VideoResolution(width: nil, height: 2_160) == .uhd4K)
#expect(VideoResolution(width: nil, height: 1_080) == .fullHD)
#expect(VideoResolution(width: nil, height: 720) == .hd)
#expect(VideoResolution(width: nil, height: 480) == .sd)
#expect(VideoResolution(width: 0, height: 1_080) == .fullHD)
}
// MARK: - Nach dem Namen des Servers
@Test("Was Plex hinschreibt")
func plexLabels() {
#expect(VideoResolution(width: nil, height: nil, serverLabel: "4k") == .uhd4K)
#expect(VideoResolution(width: nil, height: nil, serverLabel: "1080") == .fullHD)
#expect(VideoResolution(width: nil, height: nil, serverLabel: "720") == .hd)
#expect(VideoResolution(width: nil, height: nil, serverLabel: "sd") == .sd)
#expect(VideoResolution(width: nil, height: nil, serverLabel: "576") == .sd)
#expect(VideoResolution(width: nil, height: nil, serverLabel: "4K") == .uhd4K)
}
@Test("Zeilensprung wird als Zahl gelesen")
func interlacedLabel() {
#expect(VideoResolution(width: nil, height: nil, serverLabel: "1080i") == .fullHD)
}
@Test("Ohne jede Angabe keine erfundene Stufe")
func nothingIsNothing() {
#expect(VideoResolution(width: nil, height: nil) == nil)
#expect(VideoResolution(width: nil, height: nil, serverLabel: "") == nil)
#expect(VideoResolution(width: nil, height: nil, serverLabel: "irgendwas") == nil)
}
// MARK: - Die Namen
@Test("Die Namen, die dastehen sollen")
func names() {
#expect(VideoResolution.uhd4K.name == "4K")
#expect(VideoResolution.fullHD.name == "Full HD")
#expect(VideoResolution.hd.name == "HD")
#expect(VideoResolution.sd.name == "SD")
#expect(VideoResolution.fullHD.technicalName == "1080p")
}
/// Gebraucht für „größer als 4K" in Filtern.
@Test("Die Stufen lassen sich vergleichen")
func ordered() {
#expect(VideoResolution.sd < .hd)
#expect(VideoResolution.fullHD < .uhd4K)
#expect(VideoResolution.uhd8K > .uhd4K)
}
// MARK: - An der Fassung
@Test("Die Fassung sagt Full HD statt 1080")
func versionSpeaksPlainly() {
let fassung = MediaVersion(
id: "1", videoResolution: "1080", width: 1_920, height: 804
)
#expect(fassung.resolutionLabel == "Full HD")
#expect(fassung.qualityLabel.hasPrefix("Full HD"))
}
/// Ohne Zahlen bleibt die Angabe des Servers die einzige Quelle — sie darf
/// dann nicht verlorengehen.
@Test("Auch ohne Maße kommt ein Name heraus")
func labelWithoutNumbers() {
#expect(MediaVersion(id: "1", videoResolution: "4k").resolutionLabel == "4K")
#expect(MediaVersion(id: "1", videoResolution: "4k").pixelHeight == 2_160)
}
}