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