From fd1029d3e0dfb08de8192abf35424c90becdf86a Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 31 Jul 2026 22:30:37 +0200 Subject: [PATCH] Fix missing thumbnails for videos saved to library Persistence models (local playlists, watch history, bookmarks, downloads) store only the best advertised thumbnail URL - usually maxresdefault.jpg, which doesn't exist for many older videos and 404s. Live API results survive this because views fall back through the full quality chain, but toVideo() rebuilt videos with that single dead URL, leaving placeholder covers. Reconstruct the quality fallback chain from the stored YouTube-style URL at read time (Thumbnail.fallbackChain), and rewrite playlist list covers to the always-available hqdefault variant (Thumbnail.reliableURL), since those views render a single URL without fallback. Also expand the fabricated maxres URL in PipedAPI into a full chain. --- Yattee/Data/Bookmark.swift | 2 +- Yattee/Data/LocalPlaylist.swift | 14 ++++-- Yattee/Data/WatchEntry.swift | 2 +- Yattee/Models/Video.swift | 49 +++++++++++++++++++ Yattee/Services/API/PipedAPI.swift | 2 +- Yattee/Services/Downloads/Download.swift | 2 +- .../Services/Downloads/DownloadManager.swift | 2 +- 7 files changed, 64 insertions(+), 9 deletions(-) diff --git a/Yattee/Data/Bookmark.swift b/Yattee/Data/Bookmark.swift index b032d970..9c8c65a0 100644 --- a/Yattee/Data/Bookmark.swift +++ b/Yattee/Data/Bookmark.swift @@ -217,7 +217,7 @@ final class Bookmark { publishedText: publishedText, viewCount: viewCount, likeCount: nil, - thumbnails: thumbnailURL.map { [Thumbnail(url: $0, width: nil, height: nil)] } ?? [], + thumbnails: Thumbnail.fallbackChain(for: thumbnailURL), isLive: isLive, isUpcoming: false, scheduledStartTime: nil diff --git a/Yattee/Data/LocalPlaylist.swift b/Yattee/Data/LocalPlaylist.swift index 3cb73119..e7864c93 100644 --- a/Yattee/Data/LocalPlaylist.swift +++ b/Yattee/Data/LocalPlaylist.swift @@ -75,10 +75,16 @@ final class LocalPlaylist { } /// The first video's thumbnail URL for display. + /// + /// Rewritten to the always-available `hqdefault` variant because the stored + /// URL is the best advertised quality (often `maxresdefault`), which 404s + /// for many older videos and would leave the cover blank. var thumbnailURL: URL? { - (items ?? []).sorted { $0.sortOrder < $1.sortOrder } - .first? - .thumbnailURL + Thumbnail.reliableURL( + for: (items ?? []).sorted { $0.sortOrder < $1.sortOrder } + .first? + .thumbnailURL + ) } /// Sorted items by order. @@ -249,7 +255,7 @@ extension LocalPlaylistItem { publishedText: nil, viewCount: nil, likeCount: nil, - thumbnails: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [], + thumbnails: Thumbnail.fallbackChain(for: thumbnailURL), isLive: isLive, isUpcoming: false, scheduledStartTime: nil diff --git a/Yattee/Data/WatchEntry.swift b/Yattee/Data/WatchEntry.swift index 2bd2e05b..f34eceeb 100644 --- a/Yattee/Data/WatchEntry.swift +++ b/Yattee/Data/WatchEntry.swift @@ -241,7 +241,7 @@ extension WatchEntry { publishedText: nil, viewCount: nil, likeCount: nil, - thumbnails: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [], + thumbnails: Thumbnail.fallbackChain(for: thumbnailURL), isLive: isLive, isUpcoming: false, scheduledStartTime: nil diff --git a/Yattee/Models/Video.swift b/Yattee/Models/Video.swift index e379e4bc..28718d29 100644 --- a/Yattee/Models/Video.swift +++ b/Yattee/Models/Video.swift @@ -311,6 +311,55 @@ struct Thumbnail: Codable, Hashable, Sendable { self.width = width self.height = height } + + /// Expands a single stored YouTube-style thumbnail URL (`/vi//.jpg`) + /// into a best-first fallback chain. Persistence models (playlists, watch history, + /// bookmarks) store only the best advertised URL — usually `maxresdefault.jpg`, + /// which doesn't exist for many older videos and 404s. Reconstructing the + /// lower-quality variants lets thumbnail views fall back the same way they do + /// for live API results. Non-matching URLs get a single-entry chain. + /// Rewrites a YouTube-style thumbnail URL (`/vi//.jpg`) to the + /// `hqdefault.jpg` variant, which exists for effectively every video (unlike + /// `maxresdefault`/`sddefault`, which 404 for many older uploads). Use where a + /// single URL is displayed without fallback, e.g. playlist covers. Non-matching + /// URLs are returned unchanged. + static func reliableURL(for url: URL?) -> URL? { + guard let url else { return nil } + let variants = ["maxresdefault.jpg", "sddefault.jpg", "hqdefault.jpg", "mqdefault.jpg", "default.jpg"] + let path = url.path + guard path.range(of: #"/vi/[^/]+/"#, options: .regularExpression) != nil, + let match = variants.first(where: { path.hasSuffix($0) }), + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url + } + components.path = String(path.dropLast(match.count)) + "hqdefault.jpg" + return components.url ?? url + } + + static func fallbackChain(for url: URL?) -> [Thumbnail] { + guard let url else { return [] } + let variants: [(suffix: String, quality: Quality)] = [ + ("maxresdefault.jpg", .maxres), + ("sddefault.jpg", .standard), + ("hqdefault.jpg", .high), + ("mqdefault.jpg", .medium), + ("default.jpg", .default), + ] + let path = url.path + guard path.range(of: #"/vi/[^/]+/"#, options: .regularExpression) != nil, + let current = variants.first(where: { path.hasSuffix($0.suffix) }), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [Thumbnail(url: url, quality: .medium)] + } + let basePath = String(path.dropLast(current.suffix.count)) + return variants.compactMap { variant in + guard variant.quality <= current.quality else { return nil } + var variantComponents = components + variantComponents.path = basePath + variant.suffix + guard let variantURL = variantComponents.url else { return nil } + return Thumbnail(url: variantURL, quality: variant.quality) + } + } } // MARK: - Preview Support diff --git a/Yattee/Services/API/PipedAPI.swift b/Yattee/Services/API/PipedAPI.swift index 06489b48..ef77f4b3 100644 --- a/Yattee/Services/API/PipedAPI.swift +++ b/Yattee/Services/API/PipedAPI.swift @@ -590,7 +590,7 @@ private struct PipedStreamResponse: Decodable, Sendable { let thumbnails: [Thumbnail] = { if !resolvedVideoId.isEmpty, let url = URL(string: "https://i.ytimg.com/vi/\(resolvedVideoId)/maxresdefault.jpg") { - return [Thumbnail(url: url, quality: .maxres)] + return Thumbnail.fallbackChain(for: url) } // Fallback to proxy URL if video ID not available if let proxyURL = thumbnailUrl.flatMap({ URL(string: $0) }) { diff --git a/Yattee/Services/Downloads/Download.swift b/Yattee/Services/Downloads/Download.swift index da0efcc3..7e2ffd7a 100644 --- a/Yattee/Services/Downloads/Download.swift +++ b/Yattee/Services/Downloads/Download.swift @@ -336,7 +336,7 @@ struct Download: Identifiable, Codable, Sendable, Equatable { publishedText: publishedText, viewCount: viewCount, likeCount: likeCount, - thumbnails: resolvedThumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [], + thumbnails: Thumbnail.fallbackChain(for: resolvedThumbnailURL), isLive: false, isUpcoming: false, scheduledStartTime: nil diff --git a/Yattee/Services/Downloads/DownloadManager.swift b/Yattee/Services/Downloads/DownloadManager.swift index fc406c7f..20eefc37 100644 --- a/Yattee/Services/Downloads/DownloadManager.swift +++ b/Yattee/Services/Downloads/DownloadManager.swift @@ -960,7 +960,7 @@ final class DownloadManager: NSObject { publishedText: download.publishedText, viewCount: download.viewCount, likeCount: download.likeCount, - thumbnails: download.thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [], + thumbnails: Thumbnail.fallbackChain(for: download.thumbnailURL), isLive: false, isUpcoming: false, scheduledStartTime: nil