mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
ani.zip sent each series' TVDB clear logo and every episode's running time from the start, and neither was kept. Enrichment now stores the logo in title_metadata.logo_url (kept across a refresh of the same entry, like the fanart) and episode runtimes in episode_metadata.runtime, TVDB's first and AniDB's length after it. AniList's duration lands in TitleDetails::runtime; TMDB episode runtimes are kept too. Schema v13 adds the two columns and marks AniList titles un-enriched without deleting anything. TitleDetails::revision lets details written before runtime existed refetch by id while the old ones stay on screen, instead of blanking them in the migration as v10 did. Clear logos are padded to a square, so each client crops them to their visible pixels before drawing. The logo replaces the name on the library's banner and the title page (not on a light theme's title page, where lettering made for dark art can vanish). Episode rows show how long they run, in place of the file size on Android and iOS; a film's runtime joins its line of facts.
449 lines
18 KiB
Swift
449 lines
18 KiB
Swift
import CryptoKit
|
|
import ImageIO
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
/// One file to pull Proton's own thumbnail from when no provider poster exists.
|
|
///
|
|
/// Proton renders a thumbnail per file, so any episode of a show is a frame of
|
|
/// the right show — the desktop client picks the first for the same reason.
|
|
struct ThumbnailSource: Hashable, Sendable {
|
|
let shareId: String
|
|
let volumeId: String
|
|
let linkId: String
|
|
|
|
var key: String { "\(shareId)/\(linkId)" }
|
|
}
|
|
|
|
extension TitleRecord {
|
|
/// What the first episode of a title offers as a poster. Metadata lookups
|
|
/// are off by default, so for a freshly crawled library this is the only
|
|
/// artwork there is.
|
|
var thumbnailSource: ThumbnailSource? {
|
|
seasons.lazy.compactMap(\.episodes.first).first.map(\.thumbnailSource)
|
|
}
|
|
}
|
|
|
|
extension EpisodeRecord {
|
|
var thumbnailSource: ThumbnailSource { ThumbnailSource(shareId: shareId, volumeId: volumeId, linkId: linkId) }
|
|
}
|
|
|
|
/// HTTPS-only artwork, cropped to whatever frame its parent gives it.
|
|
///
|
|
/// `fallback` is used only when there is no provider artwork or it fails to
|
|
/// load, which — with metadata off — is the common case rather than the rare
|
|
/// one. Until either answers, the box pulses as a skeleton; when neither has
|
|
/// anything, it shows `ArtworkPlaceholder` rather than an empty card — without
|
|
/// initials when `labelled` is false, for art with the name already over it.
|
|
struct RemoteArtwork: View {
|
|
let url: String?
|
|
let description: String
|
|
var fallback: ThumbnailSource?
|
|
var labelled = true
|
|
@State private var load: ArtworkLoad
|
|
|
|
init(_ url: String?, _ description: String, fallback: ThumbnailSource? = nil, labelled: Bool = true) {
|
|
self.url = url
|
|
self.description = description
|
|
self.fallback = fallback
|
|
self.labelled = labelled
|
|
// A poster already decoded draws in the first frame, so scrolling back
|
|
// over the grid does not flash a skeleton per tile.
|
|
_load = State(initialValue: ArtworkLoader.shared.cached(url: url, fallback: fallback).map(ArtworkLoad.loaded) ?? .pending)
|
|
}
|
|
|
|
var body: some View {
|
|
Color.clear
|
|
.overlay {
|
|
switch load {
|
|
case .pending:
|
|
Skeleton()
|
|
case .missing:
|
|
ArtworkPlaceholder(name: description, labelled: labelled)
|
|
case let .loaded(image):
|
|
Image(uiImage: image).resizable().scaledToFill()
|
|
}
|
|
}
|
|
.clipped()
|
|
.accessibilityElement()
|
|
.accessibilityLabel(description)
|
|
.task(id: Request(url: url, fallback: fallback)) {
|
|
if case .loaded = load, ArtworkLoader.shared.cached(url: url, fallback: fallback) != nil { return }
|
|
let loaded = await ArtworkLoader.shared.artwork(url: url, fallback: fallback)
|
|
if Task.isCancelled { return }
|
|
load = loaded.map(ArtworkLoad.loaded) ?? .missing
|
|
}
|
|
}
|
|
|
|
private struct Request: Hashable {
|
|
let url: String?
|
|
let fallback: ThumbnailSource?
|
|
}
|
|
}
|
|
|
|
/// A title's logo — its name as lettering — in place of `fallback`, once it
|
|
/// has loaded. `fallback` stands until then, and for good when there is none,
|
|
/// so a title without a logo looks as it always did and one with a logo only
|
|
/// gains.
|
|
///
|
|
/// Trimmed to what is not transparent before it is drawn: TVDB pads every
|
|
/// clear logo to a square, and the padding would otherwise decide its size
|
|
/// and place.
|
|
///
|
|
/// `maxWidth` and `maxHeight` bound the logo only; the fallback takes the room
|
|
/// it always did.
|
|
struct RemoteLogo<Fallback: View>: View {
|
|
let url: String?
|
|
let description: String
|
|
let maxWidth: CGFloat
|
|
let maxHeight: CGFloat
|
|
let fallback: () -> Fallback
|
|
@State private var logo: UIImage?
|
|
|
|
init(
|
|
_ url: String?,
|
|
_ description: String,
|
|
maxWidth: CGFloat,
|
|
maxHeight: CGFloat,
|
|
@ViewBuilder fallback: @escaping () -> Fallback
|
|
) {
|
|
self.url = url
|
|
self.description = description
|
|
self.maxWidth = maxWidth
|
|
self.maxHeight = maxHeight
|
|
self.fallback = fallback
|
|
_logo = State(initialValue: ArtworkLoader.shared.cachedLogo(url))
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let logo {
|
|
Image(uiImage: logo)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .bottomLeading)
|
|
.accessibilityLabel(description)
|
|
} else {
|
|
fallback()
|
|
}
|
|
}
|
|
.task(id: url) {
|
|
if logo != nil { return }
|
|
let loaded = await ArtworkLoader.shared.logo(url)
|
|
if Task.isCancelled { return }
|
|
logo = loaded
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Inclusive pixel bounds, top-left origin.
|
|
struct PixelBounds: Equatable {
|
|
let left: Int
|
|
let top: Int
|
|
let right: Int
|
|
let bottom: Int
|
|
}
|
|
|
|
/// The bounds of the pixels in a row-major `alpha` plane of `width` that are
|
|
/// not (nearly) transparent; `nil` when none are. Below the threshold is
|
|
/// anti-aliasing haze, not lettering.
|
|
func opaqueBounds(alpha: [UInt8], width: Int) -> PixelBounds? {
|
|
var left = Int.max, top = Int.max, right = -1, bottom = -1
|
|
for (index, value) in alpha.enumerated() where value > 8 {
|
|
let x = index % width, y = index / width
|
|
left = min(left, x)
|
|
right = max(right, x)
|
|
top = min(top, y)
|
|
bottom = max(bottom, y)
|
|
}
|
|
return right < 0 ? nil : PixelBounds(left: left, top: top, right: right, bottom: bottom)
|
|
}
|
|
|
|
/// `image` cropped to `opaqueBounds`, or itself when there is nothing to crop.
|
|
func trimTransparent(_ image: UIImage) -> UIImage {
|
|
guard let cgImage = image.cgImage else { return image }
|
|
let width = cgImage.width, height = cgImage.height
|
|
var pixels = [UInt8](repeating: 0, count: width * height * 4)
|
|
let drawn = pixels.withUnsafeMutableBytes { buffer -> Bool in
|
|
guard let context = CGContext(
|
|
data: buffer.baseAddress,
|
|
width: width,
|
|
height: height,
|
|
bitsPerComponent: 8,
|
|
bytesPerRow: width * 4,
|
|
space: CGColorSpaceCreateDeviceRGB(),
|
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
|
) else { return false }
|
|
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
|
|
return true
|
|
}
|
|
guard drawn else { return image }
|
|
let alpha = stride(from: 3, to: pixels.count, by: 4).map { pixels[$0] }
|
|
guard let bounds = opaqueBounds(alpha: alpha, width: width),
|
|
bounds != PixelBounds(left: 0, top: 0, right: width - 1, bottom: height - 1)
|
|
else { return image }
|
|
let rect = CGRect(
|
|
x: bounds.left,
|
|
y: bounds.top,
|
|
width: bounds.right - bounds.left + 1,
|
|
height: bounds.bottom - bounds.top + 1
|
|
)
|
|
guard let cropped = cgImage.cropping(to: rect) else { return image }
|
|
return UIImage(cgImage: cropped, scale: image.scale, orientation: image.imageOrientation)
|
|
}
|
|
|
|
private enum ArtworkLoad {
|
|
case pending
|
|
case missing
|
|
case loaded(UIImage)
|
|
}
|
|
|
|
/// Something being loaded: the surface tint, breathing.
|
|
///
|
|
/// A spinner per tile is a grid of spinners; a shape where the poster will be
|
|
/// says what is coming and where, and settles the layout before it arrives.
|
|
struct Skeleton: View {
|
|
@Environment(\.scheme) private var scheme
|
|
@State private var dim = false
|
|
|
|
var body: some View {
|
|
scheme.surfaceVariant
|
|
.opacity(dim ? 0.55 : 1)
|
|
.onAppear {
|
|
withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: true)) { dim = true }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Art for a title that has none: the accent as a gradient, and its initials.
|
|
///
|
|
/// Each name sets where on the accent ramp its gradient starts, so neighbours
|
|
/// differ while every one stays in the theme's colours — and since the hash is
|
|
/// Java's, a title gets the same colour here as on Android.
|
|
struct ArtworkPlaceholder: View {
|
|
let name: String
|
|
var labelled = true
|
|
@Environment(\.scheme) private var scheme
|
|
|
|
var body: some View {
|
|
let start = mix(scheme.primary, scheme.secondary, Double(javaHash(name).modulo(5)) / 4)
|
|
GeometryReader { geometry in
|
|
LinearGradient(colors: [start, mix(start, .black, 0.55)], startPoint: .topLeading, endPoint: .bottomTrailing)
|
|
.overlay {
|
|
if labelled {
|
|
Text(initials(name))
|
|
.font(.inter(size: min(geometry.size.width, geometry.size.height) * 0.3, semibold: true))
|
|
.foregroundStyle(Color.white.opacity(0.9))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// "Oshi no Ko" is "OK", "Akira" is "A": the first letters of up to two
|
|
/// capitalised words, so particles and articles in a romanised name do not
|
|
/// take a slot. A name with no capitals uses its first two words.
|
|
func initials(_ name: String) -> String {
|
|
let words = name
|
|
.split(whereSeparator: { " -_.:".contains($0) })
|
|
.filter { $0.first.map { $0.isLetter || $0.isNumber } ?? false }
|
|
let capitalised = words.filter { $0.first!.isUppercase || $0.first!.isNumber }
|
|
return (capitalised.isEmpty ? words : capitalised).prefix(2).map { $0.first!.uppercased() }.joined()
|
|
}
|
|
|
|
/// `String.hashCode()` from the JVM, over UTF-16 code units.
|
|
func javaHash(_ text: String) -> Int32 {
|
|
text.utf16.reduce(Int32(0)) { hash, unit in hash &* 31 &+ Int32(unit) }
|
|
}
|
|
|
|
private extension Int32 {
|
|
/// Kotlin's `mod`: never negative.
|
|
func modulo(_ divisor: Int32) -> Int32 {
|
|
let remainder = self % divisor
|
|
return remainder < 0 ? remainder + divisor : remainder
|
|
}
|
|
}
|
|
|
|
/// The HTTPS-only fetch behind every poster, its disk cache under
|
|
/// `Caches/metadata-art`, and Proton's own thumbnails as the fallback.
|
|
final class ArtworkLoader: @unchecked Sendable {
|
|
static let shared = ArtworkLoader()
|
|
|
|
/// Decoded posters, so scrolling the grid re-reads neither disk nor
|
|
/// decoder. Keyed by artwork URL or by `share/link` for a Proton
|
|
/// thumbnail; the two cannot collide, since one is always an `https:` URL.
|
|
private let decoded: NSCache<NSString, UIImage> = {
|
|
let cache = NSCache<NSString, UIImage>()
|
|
cache.totalCostLimit = 32 * 1024 * 1024
|
|
return cache
|
|
}()
|
|
|
|
/// Files known to have no Proton thumbnail, so the answer is paid for once.
|
|
/// Deliberately not persisted: a re-upload can add one.
|
|
private var missing = Set<String>()
|
|
private let lock = NSLock()
|
|
|
|
private let session: URLSession = {
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.timeoutIntervalForRequest = 10
|
|
configuration.urlCache = nil
|
|
return URLSession(configuration: configuration)
|
|
}()
|
|
|
|
private static let maxBytes = 12 * 1024 * 1024
|
|
private static let maxCacheBytes = 48 * 1024 * 1024
|
|
/// Longest edge worth decoding: a poster on a phone, or lock-screen art.
|
|
private static let maxEdge = 1024
|
|
|
|
func cached(url: String?, fallback: ThumbnailSource?) -> UIImage? {
|
|
if let url, let image = decoded.object(forKey: url as NSString) { return image }
|
|
if let key = fallback?.key { return decoded.object(forKey: key as NSString) }
|
|
return nil
|
|
}
|
|
|
|
func artwork(url: String?, fallback: ThumbnailSource?) async -> UIImage? {
|
|
if let image = await remote(url) { return image }
|
|
return await thumbnail(fallback)
|
|
}
|
|
|
|
func cachedLogo(_ url: String?) -> UIImage? {
|
|
guard let url else { return nil }
|
|
return decoded.object(forKey: "logo:\(url)" as NSString)
|
|
}
|
|
|
|
/// A title's logo, trimmed to its lettering — see `trimTransparent`.
|
|
func logo(_ url: String?) async -> UIImage? {
|
|
guard let url else { return nil }
|
|
if let image = cachedLogo(url) { return image }
|
|
guard let image = await remote(url) else { return nil }
|
|
let trimmed = trimTransparent(image)
|
|
decoded.setObject(trimmed, forKey: "logo:\(url)" as NSString, cost: trimmed.cost)
|
|
return trimmed
|
|
}
|
|
|
|
/// Provider artwork, from the disk cache or the network.
|
|
func remote(_ requested: String?) async -> UIImage? {
|
|
guard let requested else { return nil }
|
|
if let image = decoded.object(forKey: requested as NSString) { return image }
|
|
guard let url = URL(string: requested), url.scheme?.lowercased() == "https" else { return nil }
|
|
let directory = Self.directory
|
|
let digest = SHA256.hash(data: Data(requested.utf8)).map { String(format: "%02x", $0) }.joined()
|
|
let cachedFile = directory.appendingPathComponent("\(digest).img")
|
|
let files = FileManager.default
|
|
let fresh = !files.fileExists(atPath: cachedFile.path)
|
|
let bytes: Data
|
|
if fresh {
|
|
guard let downloaded = await download(url) else { return nil }
|
|
bytes = downloaded
|
|
} else {
|
|
guard let read = try? Data(contentsOf: cachedFile) else { return nil }
|
|
bytes = read
|
|
}
|
|
guard let image = Self.decodeSampled(bytes) else { return nil }
|
|
if fresh {
|
|
// Written only once it has decoded: caching the bytes first is what
|
|
// turns one hostile poster into a failure on every launch.
|
|
let temporary = directory.appendingPathComponent("\(digest).part")
|
|
if (try? bytes.write(to: temporary)) != nil {
|
|
try? files.moveItem(at: temporary, to: cachedFile)
|
|
Self.prune(directory)
|
|
}
|
|
} else {
|
|
try? files.setAttributes([.modificationDate: Date()], ofItemAtPath: cachedFile.path)
|
|
}
|
|
decoded.setObject(image, forKey: requested as NSString, cost: image.cost)
|
|
return image
|
|
}
|
|
|
|
/// Proton's own thumbnail for one file, decoded. A failed call is not
|
|
/// remembered — an unreachable share is transient — but a file with no
|
|
/// thumbnail is, for the session.
|
|
func thumbnail(_ source: ThumbnailSource?) async -> UIImage? {
|
|
guard let source else { return nil }
|
|
let key = source.key
|
|
if let image = decoded.object(forKey: key as NSString) { return image }
|
|
if lock.withLock({ missing.contains(key) }) { return nil }
|
|
let fetched: Data?
|
|
do {
|
|
fetched = try await NativeRuntime.engine().thumbnail(shareId: source.shareId, volumeId: source.volumeId, linkId: source.linkId)
|
|
} catch {
|
|
return nil
|
|
}
|
|
guard let bytes = fetched, let image = Self.decodeSampled(bytes) else {
|
|
lock.withLock { _ = missing.insert(key) }
|
|
return nil
|
|
}
|
|
decoded.setObject(image, forKey: key as NSString, cost: image.cost)
|
|
return image
|
|
}
|
|
|
|
private func download(_ url: URL) async -> Data? {
|
|
guard let (stream, response) = try? await session.bytes(from: url),
|
|
let http = response as? HTTPURLResponse,
|
|
(200 ... 299).contains(http.statusCode),
|
|
// A redirect must not leave HTTPS.
|
|
http.url?.scheme?.lowercased() == "https"
|
|
else { return nil }
|
|
// Refused before reading when the server says up front it is too big.
|
|
if response.expectedContentLength > Int64(Self.maxBytes) { return nil }
|
|
// An array sized once, not a `Data` grown a byte at a time: on an old
|
|
// iPad, a screen of posters read that way was seconds of CPU.
|
|
var bytes = [UInt8]()
|
|
bytes.reserveCapacity(Int(min(max(response.expectedContentLength, 0), Int64(Self.maxBytes))))
|
|
do {
|
|
for try await byte in stream {
|
|
bytes.append(byte)
|
|
if bytes.count > Self.maxBytes { return nil }
|
|
}
|
|
} catch {
|
|
return nil
|
|
}
|
|
return Data(bytes)
|
|
}
|
|
|
|
/// Decode within a bound the display can actually use: the compressed size
|
|
/// says nothing about what an image costs decoded.
|
|
private static func decodeSampled(_ bytes: Data) -> UIImage? {
|
|
guard let source = CGImageSourceCreateWithData(bytes as CFData, nil) else { return nil }
|
|
let options: [CFString: Any] = [
|
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
|
kCGImageSourceShouldCacheImmediately: true,
|
|
kCGImageSourceThumbnailMaxPixelSize: maxEdge,
|
|
]
|
|
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil }
|
|
return UIImage(cgImage: image)
|
|
}
|
|
|
|
private static var directory: URL {
|
|
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
|
let directory = caches.appendingPathComponent("metadata-art", isDirectory: true)
|
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
return directory
|
|
}
|
|
|
|
/// Hold the cache directory under `maxCacheBytes`, oldest first.
|
|
private static func prune(_ directory: URL) {
|
|
let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey]
|
|
guard let files = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: keys) else { return }
|
|
let entries = files.compactMap { file -> (URL, Date, Int)? in
|
|
guard let values = try? file.resourceValues(forKeys: Set(keys)) else { return nil }
|
|
return (file, values.contentModificationDate ?? .distantPast, values.fileSize ?? 0)
|
|
}.sorted { $0.1 < $1.1 }
|
|
var total = entries.reduce(0) { $0 + $1.2 }
|
|
for (file, _, size) in entries {
|
|
if total <= maxCacheBytes { return }
|
|
total -= size
|
|
try? FileManager.default.removeItem(at: file)
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension UIImage {
|
|
var cost: Int {
|
|
guard let image = cgImage else { return 0 }
|
|
return image.bytesPerRow * image.height
|
|
}
|
|
}
|