diff --git a/Flaro/Resources/Localizable.xcstrings b/Flaro/Resources/Localizable.xcstrings index 9994e0e..1fcdbaa 100644 --- a/Flaro/Resources/Localizable.xcstrings +++ b/Flaro/Resources/Localizable.xcstrings @@ -1886,6 +1886,22 @@ } } }, + "filter.downloadedOnly" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur Heruntergeladene" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloaded only" + } + } + } + }, "filter.filters" : { "extractionState" : "stale", "localizations" : { @@ -2130,6 +2146,22 @@ } } }, + "home.downloaded" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heruntergeladen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloaded" + } + } + } + }, "home.empty.description" : { "localizations" : { "de" : { diff --git a/Flaro/Session/DownloadService.swift b/Flaro/Session/DownloadService.swift index 5270cdf..f09b745 100644 --- a/Flaro/Session/DownloadService.swift +++ b/Flaro/Session/DownloadService.swift @@ -196,9 +196,42 @@ struct DownloadService { quality: DownloadQuality = .original, audioStream: MediaStream? = nil ) throws(DownloadRefusal) -> DownloadItem { - try coordinator.enqueue( + let eintrag = try coordinator.enqueue( try plan(for: item, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream) ) + plakatHolen(for: item) + return eintrag + } + + /// Holt das Plakat mit herunter. + /// + /// **Ohne Verbindung gibt es keine Adresse mehr, von der eines käme.** + /// Ein Download ohne Bild ist offline eine graue Fläche mit einem Titel + /// darunter — und genau dann, wenn man etwas zum Ansehen sucht, ist das + /// Bild die halbe Auskunft. Ein paar Kilobyte je Eintrag. + /// + /// Nebenher und ohne Fehlerweg: scheitert es, fehlt ein Bild. Den + /// Download deshalb scheitern zu lassen wäre die schlechtere Hälfte. + func plakatHolen(for item: MediaItem) { + guard let adresse = backend?.rawImageURL(path: item.thumb) else { return } + Task { await coordinator.fetchArtwork(for: item.id, from: adresse) } + } + + /// Holt Plakate nach, die früheren Downloads fehlen. + /// + /// **Weil bis vor Kurzem gar keines mitkam.** Der Dateiname dafür stand + /// im Modell, gesetzt hat ihn nie jemand — alles, was vorher + /// heruntergeladen wurde, hat kein Bild und stünde in der neuen Reihe + /// als graue Fläche. + /// + /// Einmal je Serverwechsel, nur für die Einträge ohne Bild; danach + /// findet die Schleife nichts mehr. + func plakateNachholen() async { + guard let backend else { return } + for eintrag in coordinator.items where eintrag.artworkFileName == nil { + guard let titel = try? await backend.item(id: eintrag.ratingKey) else { continue } + plakatHolen(for: titel) + } } /// Ob der Server beim Herunterladen alle Spuren mitgibt. @@ -294,6 +327,7 @@ struct DownloadService { _ = try coordinator.enqueue( try plan(for: detailed, quality: quality, audioStream: ton) ) + plakatHolen(for: detailed) accepted += 1 } catch DownloadRefusal.noDownloadableFile { // Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie diff --git a/Flaro/Session/Heruntergeladenes.swift b/Flaro/Session/Heruntergeladenes.swift new file mode 100644 index 0000000..ca02e2d --- /dev/null +++ b/Flaro/Session/Heruntergeladenes.swift @@ -0,0 +1,71 @@ +import FlaroDownloads +import Foundation +import MediaKit + +/// Was auf dem Gerät liegt, als gewöhnliche Titel. +/// +/// **Damit ein Download aussieht wie alles andere.** Bisher waren Downloads +/// eine eigene Welt: eine eigene Liste, eine eigene Zeile, ein eigener +/// Bereich. Wer etwas ansehen wollte, musste erst wissen, dass er dorthin +/// wechseln muss. Als `MediaItem` fügt sich ein Download in jede Reihe und +/// jedes Raster ein, das es ohnehin gibt — Kachel, Fortschrittsbalken, +/// Kontextmenü inbegriffen. +/// +/// **Und es ist die Grundlage fürs Offline-Arbeiten.** Ohne Server gibt es +/// keine andere Quelle für einen Titel; alles, was dann noch dasteht, kommt +/// von hier. +@MainActor +enum Heruntergeladenes { + /// Die fertigen Downloads, neueste zuerst. + /// + /// **Nur fertige.** Ein halb geladener Film ist nichts zum Ansehen; er + /// steht im Downloads-Bereich mit seinem Balken, und dort gehört er hin. + static func fertige(in coordinator: DownloadCoordinator) -> [DownloadItem] { + coordinator.items + .filter(\.isComplete) + .sorted { ($0.completedAt ?? $0.addedAt) > ($1.completedAt ?? $1.addedAt) } + } + + /// Ein Download als Titel. + /// + /// **Das Bild kommt von der Platte, nicht vom Server.** `thumb` ist sonst + /// ein Pfad, den erst ein Server zu einer Adresse macht; hier steht + /// stattdessen die fertige lokale Adresse. Ansichten, die `rawImageURL` + /// darauf anwenden, müssen das wissen — siehe `bildAdresse`. + static func titel(_ eintrag: DownloadItem, in coordinator: DownloadCoordinator) -> MediaItem { + MediaItem( + id: eintrag.ratingKey, + kind: eintrag.seriesKey == nil ? .movie : .episode, + title: eintrag.title, + duration: eintrag.durationMilliseconds, + thumb: coordinator.artworkURL(forRatingKey: eintrag.ratingKey)?.absoluteString, + index: eintrag.episodeIndex, + parentIndex: eintrag.seasonIndex, + parentTitle: eintrag.seriesTitle, + seriesTitle: eintrag.seriesTitle, + seriesID: eintrag.seriesKey + ) + } + + /// Die Adresse eines Bildes, das schon eine ist. + /// + /// **Ein Umweg, der nötig ist.** Die Kacheln bauen ihre Bildadresse über + /// den Server (`rawImageURL`), weil `thumb` dort ein Pfad ist. Bei einem + /// Download steht darin bereits eine `file:`-Adresse — durch denselben + /// Bau geschickt käme Unsinn heraus. + static func bildAdresse(_ titel: MediaItem) -> URL? { + guard let roh = titel.thumb, roh.hasPrefix("file:") else { return nil } + return URL(string: roh) + } + + /// Ob dieser Titel auf dem Gerät liegt. + /// + /// **Eine Serie gilt als da, wenn Folgen von ihr da sind.** Die Serie + /// selbst wird nie heruntergeladen — ihre Folgen schon, und wer die + /// Kachel sieht, will wissen, ob er etwas davon ansehen kann. + static func liegtBereit(_ titelID: String, in coordinator: DownloadCoordinator) -> Bool { + coordinator.items.contains { + $0.isComplete && ($0.ratingKey == titelID || $0.seriesKey == titelID) + } + } +} diff --git a/Flaro/Session/HomeFeed.swift b/Flaro/Session/HomeFeed.swift index 4f08b9f..50f51ab 100644 --- a/Flaro/Session/HomeFeed.swift +++ b/Flaro/Session/HomeFeed.swift @@ -1,3 +1,4 @@ +import Foundation import MediaKit import Observation @@ -38,10 +39,31 @@ final class HomeFeed { #endif } + /// Die Kennung der Downloadreihe. + /// + /// Fest und nicht vom Server: nur so lässt sie sich wie jede andere + /// Reihe verschieben oder ausblenden. + static let downloadsRow = "flaro.downloads" + /// Die Reihen in der eingestellten Form. - func rows(for layout: HomeLayoutSettings) -> [MediaRowGroup] { + /// + /// `downloaded` kommt von aussen, weil `HomeFeed` nichts von Downloads + /// weiss und auch nichts davon wissen muss — es ist eine Reihe wie jede + /// andere, nur mit einer anderen Quelle. + func rows(for layout: HomeLayoutSettings, downloaded: [MediaItem] = []) -> [MediaRowGroup] { guard case .loaded(let alle) = state else { return [] } - return layout.apply(to: prepared(alle, layout: layout)) + var reihen = prepared(alle, layout: layout) + if !downloaded.isEmpty { + reihen.insert( + MediaRowGroup( + id: Self.downloadsRow, + title: String(localized: "home.downloaded"), + items: downloaded + ), + at: 0 + ) + } + return layout.apply(to: reihen) } // MARK: - Ordnen diff --git a/Flaro/Views/Components/QuickPlayTile.swift b/Flaro/Views/Components/QuickPlayTile.swift index d5a05fe..523f2aa 100644 --- a/Flaro/Views/Components/QuickPlayTile.swift +++ b/Flaro/Views/Components/QuickPlayTile.swift @@ -89,6 +89,11 @@ struct QuickPlayTile: View { .overlay(alignment: .topTrailing) { if isPlayable, isMac { playButton } } + // **Links, weil rechts schon etwas sitzt** — am Mac der Abspielknopf + // beim Überfahren, unten der Fortschrittsbalken. + .overlay(alignment: .topLeading) { + if Heruntergeladenes.liegtBereit(item.id, in: downloads) { downloadMarke } + } .onContinuousHover { phase in // Zur Laufzeit, nicht beim Übersetzen: „für iPad angepasst" ist // der iOS-Bau und läuft trotzdem auf dem Mac. @@ -247,6 +252,20 @@ struct QuickPlayTile: View { .accessibilityHidden(true) } + /// Das Zeichen für „liegt auf diesem Gerät". + /// + /// **Damit man es sieht, ohne danach zu suchen.** Bisher stand ein + /// Download nur im Downloads-Bereich; in der Mediathek sah eine + /// heruntergeladene Folge aus wie jede andere, und wer wissen wollte, + /// was im Flugzeug läuft, musste zwei Listen vergleichen. + private var downloadMarke: some View { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white, Color.black.opacity(0.55)) + .padding(FlaroSpacing.snug) + .accessibilityLabel(Text("downloads.available")) + } + /// Ob es etwas herunterzuladen gibt — und ob es nicht schon da ist. /// /// Sammlungen, Serien und Staffeln fallen heraus: dort lädt man nicht diff --git a/Flaro/Views/Home/HomeView.swift b/Flaro/Views/Home/HomeView.swift index cfe0ac9..c4ba677 100644 --- a/Flaro/Views/Home/HomeView.swift +++ b/Flaro/Views/Home/HomeView.swift @@ -1,5 +1,6 @@ import SwiftUI import FlaroDesign +import FlaroDownloads import MediaKit /// Die Startseite: Aufmacher oben, darunter die Reihen vom Server. @@ -11,8 +12,20 @@ import MediaKit struct HomeView: View { @Environment(FlaroSession.self) private var session @Environment(HomeLayoutSettings.self) private var layout + @Environment(DownloadCoordinator.self) private var downloads @State private var feed = HomeFeed() + /// Was auf dem Gerät liegt, als Reihe ganz oben. + /// + /// Wird bei jeder Änderung an den Downloads neu berechnet, weil + /// `DownloadCoordinator` beobachtet wird — eine fertige Folge steht + /// ohne Neuladen der Startseite da. + private var heruntergeladen: [MediaItem] { + Heruntergeladenes.fertige(in: downloads).map { + Heruntergeladenes.titel($0, in: downloads) + } + } + var body: some View { Group { switch feed.state { @@ -29,7 +42,7 @@ struct HomeView: View { } case .loaded: - let hubs = feed.rows(for: layout) + let hubs = feed.rows(for: layout, downloaded: heruntergeladen) if hubs.isEmpty { ContentUnavailableView { Label("home.empty.title", systemImage: "film.stack") @@ -59,7 +72,14 @@ struct HomeView: View { // daneben, und genau diese Kante sah man. Auf den geschobenen // Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg. .toolbar(.hidden, for: .navigationBar) - .task(id: session.serverName) { await feed.load(from: session.backend) } + .task(id: session.serverName) { + await feed.load(from: session.backend) + // Siehe `DownloadService.plakateNachholen`: ältere Downloads + // haben kein Bild, und hier ist die Stelle, an der man es merken + // würde. + await DownloadService(backend: session.backend, coordinator: downloads) + .plakateNachholen() + } .refreshable { await feed.load(from: session.backend) } // Was eine Kachel am Server ändert, ändert auch, was hier steht: // eine abgehakte Folge gehört nicht mehr in „Weiterschauen". diff --git a/Flaro/Views/Home/MediaRow.swift b/Flaro/Views/Home/MediaRow.swift index 59de4f8..bb7b436 100644 --- a/Flaro/Views/Home/MediaRow.swift +++ b/Flaro/Views/Home/MediaRow.swift @@ -59,7 +59,7 @@ struct MediaRow: View { LandscapeCard( title: item.rowTitle, subtitle: item.cardSubtitle, - artworkURL: session.backend?.artworkURL( + artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL( path: item.rowArtworkPath, width: 440, height: 248 @@ -71,7 +71,7 @@ struct MediaRow: View { PosterCard( title: item.rowTitle, subtitle: item.cardSubtitle, - artworkURL: session.backend?.artworkURL( + artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL( path: item.posterArtworkPath, width: 300, height: 450 diff --git a/Flaro/Views/Library/LibraryFilterSheet.swift b/Flaro/Views/Library/LibraryFilterSheet.swift index 62ce1bf..1ddaf3d 100644 --- a/Flaro/Views/Library/LibraryFilterSheet.swift +++ b/Flaro/Views/Library/LibraryFilterSheet.swift @@ -5,6 +5,9 @@ import MediaKit /// Filter und Sortierung einer Mediathek. struct LibraryFilterSheet: View { @Binding var query: MediaQuery + /// Steht getrennt von `query`, weil der Server ihn nicht auswerten kann — + /// siehe `LibraryView.nurHeruntergeladene`. + @Binding var nurHeruntergeladene: Bool /// Die Mediathek selbst, nicht nur ihre Art. /// /// Vorher stand hier die Art, und die Genres wurden über *irgendeine* @@ -23,11 +26,18 @@ struct LibraryFilterSheet: View { /// Sonst löste jeder Tipp im Sheet sofort eine neue Serverabfrage aus — /// bei drei Änderungen hintereinander drei verworfene Ladevorgänge. @State private var draft: MediaQuery + @State private var entwurfHeruntergeladene: Bool - init(query: Binding, library: MediaLibrary) { + init( + query: Binding, + nurHeruntergeladene: Binding, + library: MediaLibrary + ) { self._query = query + self._nurHeruntergeladene = nurHeruntergeladene self.library = library self._draft = State(initialValue: query.wrappedValue) + self._entwurfHeruntergeladene = State(initialValue: nurHeruntergeladene.wrappedValue) } var body: some View { @@ -45,6 +55,7 @@ struct LibraryFilterSheet: View { Section("filter.filters") { Toggle("filter.unwatchedOnly", isOn: $draft.unwatchedOnly) + Toggle("filter.downloadedOnly", isOn: $entwurfHeruntergeladene) Picker("filter.genre", selection: $draft.genre) { Text("filter.any").tag(String?.none) @@ -80,8 +91,9 @@ struct LibraryFilterSheet: View { Section { Button("filter.reset", role: .destructive) { draft = MediaQuery(sort: draft.sort) + entwurfHeruntergeladene = false } - .disabled(!draft.isFiltering) + .disabled(!draft.isFiltering && !entwurfHeruntergeladene) } } // Ausdrücklich, nicht nur geerbt. @@ -102,6 +114,7 @@ struct LibraryFilterSheet: View { } ToolbarItem(placement: .confirmationAction) { Button("action.apply") { + nurHeruntergeladene = entwurfHeruntergeladene query = draft dismiss() } diff --git a/Flaro/Views/Library/LibraryView.swift b/Flaro/Views/Library/LibraryView.swift index 0af0893..2c4ea88 100644 --- a/Flaro/Views/Library/LibraryView.swift +++ b/Flaro/Views/Library/LibraryView.swift @@ -1,5 +1,6 @@ import SwiftUI import FlaroDesign +import FlaroDownloads import MediaKit /// Eine Mediathek zum Durchblättern. @@ -10,6 +11,7 @@ struct LibraryView: View { /// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl /// zwischen Raster und Liste. @Environment(HomeLayoutSettings.self) private var sizing + @Environment(DownloadCoordinator.self) private var downloads @AppStorage("flaro.library.layout") private var layout: LibraryLayout = .grid @Environment(\.horizontalSizeClass) private var sizeClass @@ -25,6 +27,14 @@ struct LibraryView: View { @State private var loadMoreFailed = false @State private var isLoadingMore = false @State private var showsFilters = false + /// Ob nur gezeigt wird, was auf diesem Gerät liegt. + /// + /// **Kein Teil von `MediaQuery`**, und das ist der Kern der Sache: die + /// Abfrage wertet der Server aus, und der weiss nicht, was auf diesem + /// Gerät liegt. Steht der Filter an, wird gar nicht erst gefragt — die + /// Liste kommt aus den Downloads. Das ist genau, vollständig, und + /// funktioniert ohne Verbindung. + @State private var nurHeruntergeladene = false var body: some View { Group { @@ -48,8 +58,9 @@ struct LibraryView: View { } description: { Text("library.empty.description") } actions: { - if query.isFiltering { + if query.isFiltering || nurHeruntergeladene { Button("library.clearFilters") { + nurHeruntergeladene = false query = MediaQuery(sort: query.sort) } } @@ -64,7 +75,11 @@ struct LibraryView: View { .flaroInlineTitle() .toolbar { toolbar } .sheet(isPresented: $showsFilters) { - LibraryFilterSheet(query: $query, library: library) + LibraryFilterSheet( + query: $query, + nurHeruntergeladene: $nurHeruntergeladene, + library: library + ) } .task(id: library.id) { // Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält @@ -78,6 +93,11 @@ struct LibraryView: View { // Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei // verschiedenen Abfragen vermischen. .onChange(of: query) { _, _ in Task { await reload() } } + .onChange(of: nurHeruntergeladene) { _, _ in + items = [] + page = nil + Task { await reload() } + } .refreshable { await reload() } // Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen. .environment(\.flaroItemChanged, { await reload() }) @@ -105,7 +125,8 @@ struct LibraryView: View { PosterCard( title: item.title, subtitle: item.cardSubtitle, - artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450), + artworkURL: Heruntergeladenes.bildAdresse(item) + ?? session.backend?.artworkURL(path: item.thumb, width: 300, height: 450), progress: item.resumeProgress ) } @@ -232,7 +253,7 @@ struct LibraryView: View { Button { showsFilters = true } label: { - Image(systemName: query.isFiltering + Image(systemName: query.isFiltering || nurHeruntergeladene ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") } @@ -276,6 +297,16 @@ struct LibraryView: View { /// stehen. Eine Liste von gestern ist im Zug brauchbarer als ein /// Fehlerbildschirm. private func reload() async { + // **Vor dem Server, nicht danach.** Was auf dem Gerät liegt, weiss + // nur das Gerät — und es weiss es auch im Flugmodus. + if nurHeruntergeladene { + items = geraeteliste + page = MediaPage(items: items, totalCount: items.count) + loadMoreFailed = false + state = .loaded(()) + return + } + guard let server = session.backend else { return } let schlüssel = query.cacheKey let kennung = server.identity @@ -319,6 +350,27 @@ struct LibraryView: View { } } + /// Die Downloads, die in diese Mediathek gehören. + /// + /// **Zugeordnet über die Art der Mediathek**, weil ein Download seine + /// Mediathek nicht kennt: er führt einen Serienschlüssel oder keinen, + /// und mehr braucht es nicht. Eine Folge gehört in eine Serien-, + /// ein Film in eine Filmmediathek. + /// + /// shortcut: zwei Filmmediatheken bekämen beide dieselbe Liste — erst + /// wenn `DownloadItem` die Mediathek mitführt, lässt sich das trennen. + private var geraeteliste: [MediaItem] { + Heruntergeladenes.fertige(in: downloads) + .filter { eintrag in + switch library.kind { + case .movie: eintrag.seriesKey == nil + case .show: eintrag.seriesKey != nil + default: true + } + } + .map { Heruntergeladenes.titel($0, in: downloads) } + } + /// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert. private func loadMoreIfNeeded(reaching item: MediaItem) async { guard @@ -385,7 +437,8 @@ private struct LibraryListRow: View { var body: some View { HStack(spacing: FlaroSpacing.regular) { FlaroArtwork( - url: session.backend?.artworkURL(path: item.thumb, width: 120, height: 180), + url: Heruntergeladenes.bildAdresse(item) + ?? session.backend?.artworkURL(path: item.thumb, width: 120, height: 180), aspectRatio: PosterCard.aspectRatio ) .frame(width: 44) diff --git a/Packages/FlaroKit/Sources/FlaroDownloads/Engine/DownloadCoordinator.swift b/Packages/FlaroKit/Sources/FlaroDownloads/Engine/DownloadCoordinator.swift index 40e1fd1..bfd5256 100644 --- a/Packages/FlaroKit/Sources/FlaroDownloads/Engine/DownloadCoordinator.swift +++ b/Packages/FlaroKit/Sources/FlaroDownloads/Engine/DownloadCoordinator.swift @@ -422,6 +422,40 @@ public final class DownloadCoordinator { return fileStore.url(forFileName: fileName) } + /// Das lokale Plakat eines Downloads — `nil`, wenn keines geholt wurde. + /// + /// **Weil eine Reihe ohne Bilder keine Reihe ist.** Ohne Verbindung gibt + /// es keine Adresse, von der ein Plakat nachzuladen wäre; was nicht beim + /// Herunterladen mitkam, bleibt eine graue Fläche. + public func artworkURL(forRatingKey key: String) -> URL? { + guard let name = items.first(where: { $0.ratingKey == key })?.artworkFileName, + fileStore.exists(fileName: name) + else { return nil } + return fileStore.url(forFileName: name) + } + + /// Holt das Plakat zu einem Eintrag nach und legt es ab. + /// + /// **Beim Anlegen, nicht erst beim Fertigwerden.** Die Liste zeigt den + /// Eintrag, sobald er in der Warteschlange steht; bis dahin wäre sonst + /// nichts zu sehen. Es sind ein paar Kilobyte. + public func fetchArtwork(for ratingKey: String, from url: URL) async { + guard let item = items.first(where: { $0.ratingKey == ratingKey }), + item.artworkFileName == nil + else { return } + + guard let (daten, antwort) = try? await URLSession.shared.data(from: url), + (antwort as? HTTPURLResponse).map({ (200..<300).contains($0.statusCode) }) ?? true, + !daten.isEmpty + else { return } + + let name = fileStore.artworkFileName(forRatingKey: ratingKey) + guard fileStore.store(daten, as: name) != nil else { return } + + item.artworkFileName = name + save() + } + public func offlineMedia(forRatingKey key: String) -> OfflineMedia? { guard let data = items.first(where: { $0.ratingKey == key })?.offlineMediaData else { return nil } return try? JSONDecoder().decode(OfflineMedia.self, from: data) diff --git a/Packages/FlaroKit/Sources/FlaroDownloads/Engine/DownloadFileStore.swift b/Packages/FlaroKit/Sources/FlaroDownloads/Engine/DownloadFileStore.swift index 2ba1861..94a0343 100644 --- a/Packages/FlaroKit/Sources/FlaroDownloads/Engine/DownloadFileStore.swift +++ b/Packages/FlaroKit/Sources/FlaroDownloads/Engine/DownloadFileStore.swift @@ -58,6 +58,29 @@ public struct DownloadFileStore: Sendable { return "\(key).\(ext)" } + /// Der Name des Plakats zu einem Titel. + /// + /// **Eine eigene Datei, kein Anhängsel am Film.** Sie ist ein paar + /// Kilobyte gross und wird gebraucht, solange der Eintrag steht — auch + /// während der Film noch lädt und noch gar keine Datei hat. + public func artworkFileName(forRatingKey key: String) -> String { "\(key).bild" } + + /// Legt ein geholtes Plakat ab. Schlägt es fehl, gibt es eben keines. + /// + /// **Wirft nicht.** Ein Download ohne Bild ist ein Download; ein + /// Download, der am Bild scheitert, wäre keiner. + @discardableResult + public func store(_ data: Data, as name: String) -> URL? { + let ziel = url(forFileName: name) + do { + try createRootIfNeeded() + try data.write(to: ziel, options: .atomic) + return ziel + } catch { + return nil + } + } + /// Verschiebt die fertige Datei aus dem temporären Ordner an ihren Platz. /// /// Verschieben, nicht kopieren: iOS löscht die temporäre Datei, sobald der