Files
plezy/tvos/TopShelfExtension/TopShelfProvider.swift
edde746 84bc4b2618 fix(i18n): translate the strings that still reached the UI in English
Non-English users saw English text in a dozen places and blank labels in
sixteen more.

The English came from sites that produce their copy away from the widget
that renders it, which is what the structural hardcoded-string check
cannot see: picture-in-picture refused with a raw literal instead of the
pipErrors.notSupported key that already existed; the two Jellyfin/Emby
auth throws missing display: rendered their developer message on the
add-server form; ServerParsingException.toString() fed its English into
the localized "Failed to load servers" wrapper; Watch Together
interpolated the whole PeerError, so a failed create read "Failed to
create session: PeerError(PeerErrorType.timeout): Timed out creating
session" and join printed its prefix twice; the hub and playlist
continuation footers rendered exception.toString(); shader rows showed an
English title over an already translated subtitle; the player queue fell
back to the raw Dart enum name; a Plex home user with no title showed
"Unknown"; a failed player start showed "Exception: Failed to initialize
player"; and the tvOS top-shelf header was hardcoded in an extension that
has no Flutter engine.

The blanks came from three recent features that added English keys
without translations. clean_translations.py filled all 21 siblings with
empty strings, so the Android TV resolution switch, every Jellyfin/Emby
recording-rule field, the demuxer row, the Companion Remote address
caption and the Seerr blocklist pill rendered nothing at all.

Two fixes are structural rather than key swaps. ContinuationStatusSliver
now takes an errorContext and calls a new non-logging
localizedLoadErrorText, so no future throw can leak through it. lib/mpv
stays free of user-facing copy: it raises a PlayerInitializationException
sentinel and a PlayerError.playerInitFailed cause tag that the player
screen resolves to localized text. The tvOS section title travels in the
shelf payload, additively, so an older cache still renders.
2026-08-24 05:23:05 +02:00

262 lines
11 KiB
Swift

import CryptoKit
import Foundation
import TVServices
private enum TopShelfShared {
static let schemaVersion = 3
static let appGroupIdentifier = "group.com.edde746.plezy"
static let cacheDataKey = "PlezySystemShelfCacheData"
static let artworkDirectoryName = "SystemShelfArtwork"
/// English fallback for sources persisted before the localized title existed.
static let fallbackSectionTitle = "Continue Watching"
static var sharedDefaults: UserDefaults? { UserDefaults(suiteName: appGroupIdentifier) }
static var artworkRoot: URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)?
.appendingPathComponent(artworkDirectoryName, isDirectory: true)
}
}
private struct TopShelfCachePayload: Decodable {
struct Section: Decodable {
let id: String
let title: String
let items: [Item]
}
struct Item: Decodable {
let contentId: String
let title: String
let episodeTitle: String?
let description: String?
let artworkKey: String?
let posterUrl: String?
let type: String?
let duration: Double?
let lastPlaybackPosition: Double?
let seasonNumber: Int?
let episodeNumber: Int?
private enum CodingKeys: String, CodingKey {
case contentId
case title
case episodeTitle
case description
case artworkKey
case posterUrl
case type
case duration
case lastPlaybackPosition
case seasonNumber
case episodeNumber
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
contentId = try container.decode(String.self, forKey: .contentId)
title = try container.decode(String.self, forKey: .title)
episodeTitle = try container.decodeIfPresent(String.self, forKey: .episodeTitle)
description = try container.decodeIfPresent(String.self, forKey: .description)
artworkKey = try container.decodeIfPresent(String.self, forKey: .artworkKey)
posterUrl = try container.decodeIfPresent(String.self, forKey: .posterUrl)
type = try container.decodeIfPresent(String.self, forKey: .type)
duration = container.decodeFlexibleDoubleIfPresent(.duration)
lastPlaybackPosition = container.decodeFlexibleDoubleIfPresent(.lastPlaybackPosition)
seasonNumber = container.decodeFlexibleIntIfPresent(.seasonNumber)
episodeNumber = container.decodeFlexibleIntIfPresent(.episodeNumber)
}
init(fetched: ShelfFetchedItem) {
contentId = fetched.contentId
title = fetched.title
episodeTitle = fetched.episodeTitle
description = nil
artworkKey = nil
posterUrl = fetched.posterUrl
type = fetched.type
duration = fetched.durationMilliseconds
lastPlaybackPosition = fetched.lastPlaybackPositionMilliseconds
seasonNumber = fetched.seasonNumber
episodeNumber = fetched.episodeNumber
}
}
let schemaVersion: Int
let ownerId: String
let sections: [Section]
}
private extension KeyedDecodingContainer {
func decodeFlexibleDoubleIfPresent(_ key: Key) -> Double? {
if let value = try? decodeIfPresent(Double.self, forKey: key) { return value }
if let value = try? decodeIfPresent(Int.self, forKey: key) { return Double(value) }
return nil
}
func decodeFlexibleIntIfPresent(_ key: Key) -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) { return value }
if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) }
return nil
}
}
final class TopShelfProvider: TVTopShelfContentProvider {
override func loadTopShelfContent() async -> (any TVTopShelfContent)? {
// Prefer a live fetch when the app has published server sources; any
// failure falls back to replaying the last committed cache unchanged.
if let sources = ShelfSourceStore.load(),
let items = await ShelfFetcher.fetchContinueWatching(sources: sources)
{
let sectionTitle =
sources.sectionTitle.flatMap { $0.isEmpty ? nil : $0 } ?? TopShelfShared.fallbackSectionTitle
persistLiveSnapshot(items, ownerId: sources.ownerId, sectionTitle: sectionTitle)
return buildLiveContent(items, ownerId: sources.ownerId, sectionTitle: sectionTitle)
}
return buildContent()
}
private func buildLiveContent(
_ items: [ShelfFetchedItem],
ownerId: String,
sectionTitle: String
) -> TVTopShelfContent? {
let sectionItems = items.compactMap {
makeTopShelfItem(TopShelfCachePayload.Item(fetched: $0), ownerId: ownerId)
}
guard !sectionItems.isEmpty else { return nil }
let collection = TVTopShelfItemCollection(items: sectionItems)
collection.title = sectionTitle
return TVTopShelfSectionedContent(sections: [collection])
}
/// Rewrites the shared cache with the live result so offline replay stays
/// fresh. Token-bearing poster URLs stay inside the app group container.
private func persistLiveSnapshot(_ items: [ShelfFetchedItem], ownerId: String, sectionTitle: String) {
guard let defaults = TopShelfShared.sharedDefaults else { return }
let itemDicts = items.map { item -> [String: Any] in
var dict: [String: Any] = ["contentId": item.contentId, "title": item.title]
if let episodeTitle = item.episodeTitle { dict["episodeTitle"] = episodeTitle }
if let type = item.type { dict["type"] = type }
if let duration = item.durationMilliseconds { dict["duration"] = duration }
if let position = item.lastPlaybackPositionMilliseconds {
dict["lastPlaybackPosition"] = position
}
if let seasonNumber = item.seasonNumber { dict["seasonNumber"] = seasonNumber }
if let episodeNumber = item.episodeNumber { dict["episodeNumber"] = episodeNumber }
if let posterUrl = item.posterUrl { dict["posterUrl"] = posterUrl }
if item.recency > 0 { dict["lastEngagementTime"] = item.recency }
return dict
}
let payload: [String: Any] = [
"schemaVersion": TopShelfShared.schemaVersion,
"ownerId": ownerId,
"updatedAt": Date().timeIntervalSince1970,
"sections": [["id": "continue_watching", "title": sectionTitle, "items": itemDicts]],
]
guard JSONSerialization.isValidJSONObject(payload),
let data = try? JSONSerialization.data(withJSONObject: payload)
else { return }
defaults.set(data, forKey: TopShelfShared.cacheDataKey)
defaults.synchronize()
}
private func buildContent() -> TVTopShelfContent? {
guard let defaults = TopShelfShared.sharedDefaults,
let data = defaults.data(forKey: TopShelfShared.cacheDataKey)
else { return nil }
guard let payload = try? JSONDecoder().decode(TopShelfCachePayload.self, from: data),
payload.schemaVersion == TopShelfShared.schemaVersion,
!payload.ownerId.isEmpty
else { return nil }
let sections = payload.sections.compactMap { section -> TVTopShelfItemCollection<TVTopShelfSectionedItem>? in
let items = section.items.compactMap { makeTopShelfItem($0, ownerId: payload.ownerId) }
guard !items.isEmpty else { return nil }
let collection = TVTopShelfItemCollection(items: items)
collection.title = section.title
return collection
}
guard !sections.isEmpty else { return nil }
return TVTopShelfSectionedContent(sections: sections)
}
private func makeTopShelfItem(_ cacheItem: TopShelfCachePayload.Item, ownerId: String) -> TVTopShelfSectionedItem? {
guard !cacheItem.contentId.isEmpty else { return nil }
let item = TVTopShelfSectionedItem(identifier: cacheItem.contentId)
item.title = displayTitle(for: cacheItem)
item.imageShape = .poster
if let duration = cacheItem.duration, duration > 0,
let position = cacheItem.lastPlaybackPosition, position > 0
{
item.playbackProgress = min(max(position / duration, 0), 1)
}
if let url = deepLinkURL(contentId: cacheItem.contentId) {
let action = TVTopShelfAction(url: url)
item.displayAction = action
item.playAction = action
}
if let remoteURL = remoteArtworkURL(cacheItem.posterUrl) {
item.setImageURL(remoteURL, for: .screenScale1x)
item.setImageURL(remoteURL, for: .screenScale2x)
} else if let key = cacheItem.artworkKey, let localURL = localArtworkURL(ownerId: ownerId, key: key) {
item.setImageURL(localURL, for: .screenScale1x)
item.setImageURL(localURL, for: .screenScale2x)
}
return item
}
private func remoteArtworkURL(_ posterUrl: String?) -> URL? {
guard let posterUrl, let url = URL(string: posterUrl),
["http", "https"].contains(url.scheme?.lowercased() ?? "")
else { return nil }
return url
}
private func localArtworkURL(ownerId: String, key: String) -> URL? {
guard key.range(of: "^[a-f0-9]{32}\\.art$", options: .regularExpression) != nil,
let root = TopShelfShared.artworkRoot
else { return nil }
let ownerHash = SHA256.hash(data: Data(ownerId.utf8)).map { String(format: "%02x", $0) }.joined()
let canonicalRoot = root.standardizedFileURL.resolvingSymlinksInPath()
let ownerDirectory = canonicalRoot.appendingPathComponent(ownerHash, isDirectory: true)
.standardizedFileURL.resolvingSymlinksInPath()
guard ownerDirectory.deletingLastPathComponent() == canonicalRoot else { return nil }
let candidate = ownerDirectory.appendingPathComponent(key, isDirectory: false)
.standardizedFileURL.resolvingSymlinksInPath()
guard candidate.deletingLastPathComponent() == ownerDirectory else { return nil }
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory), !isDirectory.boolValue
else {
return nil
}
return candidate
}
private func displayTitle(for item: TopShelfCachePayload.Item) -> String {
guard let episodeTitle = item.episodeTitle, !episodeTitle.isEmpty else { return item.title }
let episodePrefix: String? = {
if let seasonNumber = item.seasonNumber, let episodeNumber = item.episodeNumber {
return "S\(seasonNumber) E\(episodeNumber)"
}
if let episodeNumber = item.episodeNumber { return "E\(episodeNumber)" }
return nil
}()
// S/E leads (official Plex app format) so it is readable immediately
// instead of after the focused-item marquee scrolls a long series name;
// the poster artwork already identifies the series. Without numbers the
// series name is the only usable context, so it stays.
if let episodePrefix { return "\(episodePrefix) - \(episodeTitle)" }
return "\(item.title) - \(episodeTitle)"
}
private func deepLinkURL(contentId: String) -> URL? {
var components = URLComponents()
components.scheme = "plezy"
components.host = "play"
components.queryItems = [URLQueryItem(name: "content_id", value: contentId)]
return components.url
}
}