diff --git a/Yattee/Services/Downloads/Download.swift b/Yattee/Services/Downloads/Download.swift index 16aa2b1b..da0efcc3 100644 --- a/Yattee/Services/Downloads/Download.swift +++ b/Yattee/Services/Downloads/Download.swift @@ -298,9 +298,29 @@ struct Download: Identifiable, Codable, Sendable, Equatable { self.audioBitrate = audioBitrate } + /// Resolves the local thumbnail file URL, if the thumbnail has been + /// downloaded to disk. Remote `thumbnailURL`s for proxied sources + /// (Invidious / Piped / Yattee server) expire, so prefer the local copy + /// whenever it is available. + func localThumbnailURL(in downloadsDirectory: URL) -> URL? { + guard let localThumbnailPath else { return nil } + return downloadsDirectory.appendingPathComponent(localThumbnailPath) + } + /// Convert to a Video model for display purposes. - func toVideo() -> Video { - Video( + /// - Parameter downloadsDirectory: When provided and a local thumbnail + /// exists on disk, the resulting `Video` uses a `file://` URL pointing + /// at the local thumbnail instead of the (potentially expired) remote + /// `thumbnailURL` captured at download time. + func toVideo(downloadsDirectory: URL? = nil) -> Video { + let resolvedThumbnailURL: URL? = { + if let downloadsDirectory, let localURL = localThumbnailURL(in: downloadsDirectory) { + return localURL + } + return thumbnailURL + }() + + return Video( id: videoID, title: title, description: description, @@ -316,7 +336,7 @@ struct Download: Identifiable, Codable, Sendable, Equatable { publishedText: publishedText, viewCount: viewCount, likeCount: likeCount, - thumbnails: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [], + thumbnails: resolvedThumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [], isLive: false, isUpcoming: false, scheduledStartTime: nil diff --git a/Yattee/Services/ImageLoading/ImageLoadingService.swift b/Yattee/Services/ImageLoading/ImageLoadingService.swift index bf1f4b93..525baa05 100644 --- a/Yattee/Services/ImageLoading/ImageLoadingService.swift +++ b/Yattee/Services/ImageLoading/ImageLoadingService.swift @@ -63,6 +63,15 @@ final class ImageLoadingService: Sendable { ) } + /// Remove a specific URL from both the memory and disk image caches. + /// Use when a previously cached URL is known to return stale or broken + /// data (e.g. an expired proxied thumbnail URL). + func removeCachedImage(for url: URL) { + let request = ImageRequest(url: url) + ImagePipeline.shared.cache.removeCachedImage(for: request) + ImagePipeline.shared.cache.removeCachedData(for: request) + } + /// Clear all image caches (memory and disk). func clearCache() { // Clear memory cache diff --git a/Yattee/Views/Downloads/DownloadRowView.swift b/Yattee/Views/Downloads/DownloadRowView.swift index a67a067f..3f1a148c 100644 --- a/Yattee/Views/Downloads/DownloadRowView.swift +++ b/Yattee/Views/Downloads/DownloadRowView.swift @@ -32,7 +32,7 @@ struct DownloadRowView: View { @State private var hasLoadedWatchData = false private var video: Video { - download.toVideo() + download.toVideo(downloadsDirectory: appEnvironment?.downloadManager.downloadsDirectory()) } /// Watch progress for this video (0.0 to 1.0), or nil if not watched. diff --git a/Yattee/Views/Downloads/DownloadsStorageView.swift b/Yattee/Views/Downloads/DownloadsStorageView.swift index 09aa5fcc..2610e067 100644 --- a/Yattee/Views/Downloads/DownloadsStorageView.swift +++ b/Yattee/Views/Downloads/DownloadsStorageView.swift @@ -161,7 +161,7 @@ struct DownloadsStorageView: View { @ViewBuilder private func storageRow(_ download: Download, isLast: Bool) -> some View { - let video = download.toVideo() + let video = download.toVideo(downloadsDirectory: downloadManager?.downloadsDirectory()) let isWatched = dataManager?.watchEntry(for: download.videoID.videoID)?.isFinished ?? false VideoListRow( diff --git a/Yattee/Views/Downloads/DownloadsView.swift b/Yattee/Views/Downloads/DownloadsView.swift index b17ca693..e4b2b1e1 100644 --- a/Yattee/Views/Downloads/DownloadsView.swift +++ b/Yattee/Views/Downloads/DownloadsView.swift @@ -375,7 +375,8 @@ private struct CompletedDownloadsSectionContentView: View { private func groupedByChannelContent(_ downloads: [Download]) -> some View { let grouped = settings.groupedByChannel(downloads) let allDownloadsInOrder = grouped.flatMap { $0.downloads } - let videoList = allDownloadsInOrder.map { $0.toVideo() } + let downloadsDir = manager.downloadsDirectory() + let videoList = allDownloadsInOrder.map { $0.toVideo(downloadsDirectory: downloadsDir) } var runningIndex = 0 ForEach(Array(grouped.enumerated()), id: \.element.channelID) { groupIndex, group in @@ -420,7 +421,8 @@ private struct CompletedDownloadsSectionContentView: View { let sortedDownloads = settings.sorted(downloads) let groupedByLetter = groupDownloadsByFirstLetter(sortedDownloads, ascending: settings.sortDirection == .ascending) let allDownloadsInOrder = groupedByLetter.flatMap { $0.downloads } - let videoList = allDownloadsInOrder.map { $0.toVideo() } + let downloadsDir = manager.downloadsDirectory() + let videoList = allDownloadsInOrder.map { $0.toVideo(downloadsDirectory: downloadsDir) } var runningIndex = 0 ForEach(Array(groupedByLetter.enumerated()), id: \.element.letter) { groupIndex, group in @@ -463,7 +465,8 @@ private struct CompletedDownloadsSectionContentView: View { @ViewBuilder private func flatListContent(_ downloads: [Download]) -> some View { let sortedDownloads = settings.sorted(downloads) - let videoList = sortedDownloads.map { $0.toVideo() } + let downloadsDir = manager.downloadsDirectory() + let videoList = sortedDownloads.map { $0.toVideo(downloadsDirectory: downloadsDir) } // Flat list content wrapped in its own card VideoListContent(listStyle: listStyle) { @@ -530,7 +533,7 @@ private struct CompletedDownloadsSectionContentView: View { ) } .videoSwipeActions( - video: download.toVideo(), + video: download.toVideo(downloadsDirectory: manager.downloadsDirectory()), fixedActions: [ SwipeAction( symbolImage: "trash.fill", diff --git a/Yattee/Views/Home/HomeView.swift b/Yattee/Views/Home/HomeView.swift index f35cc06b..649bc2bb 100644 --- a/Yattee/Views/Home/HomeView.swift +++ b/Yattee/Views/Home/HomeView.swift @@ -1142,7 +1142,8 @@ struct HomeView: View { let limitedDownloads = Array(downloads.prefix(sectionItemsLimit)) // Use toVideo() instead of videoAndStream() to avoid O(n²) file I/O on main thread // Downloads are looked up by video.id at playback time in PlayerService.playPreferringDownloaded() - let videoList = limitedDownloads.map { $0.toVideo() } + let downloadsDir = downloadManager?.downloadsDirectory() + let videoList = limitedDownloads.map { $0.toVideo(downloadsDirectory: downloadsDir) } return VStack(alignment: .leading, spacing: 0) { sectionHeader(title: "home.recentDownloads.title") { diff --git a/Yattee/Views/Video/VideoInfoView.swift b/Yattee/Views/Video/VideoInfoView.swift index f3a35f9e..caca8421 100644 --- a/Yattee/Views/Video/VideoInfoView.swift +++ b/Yattee/Views/Video/VideoInfoView.swift @@ -855,9 +855,10 @@ struct VideoInfoView: View { if let videos = allVideos { ForEach(Array(videos.enumerated()), id: \.element.id) { index, video in let isCurrent = index == currentVideoIndex - // Use original video for thumbnail (stable), get author from detailed video for avatar - let authorSource = loadedVideoDetails[video.id.videoID] - videoCard(for: video, authorFrom: authorSource, isLoadingMore: isLoadingMoreVideos && isCurrent, showTitle: true, isCurrent: isCurrent) + // Prefer freshly loaded details for thumbnail (so expired proxy URLs get replaced) and avatar. + // Keep `video` as the card's stable identity so title/channel don't flicker. + let detailedVideo = loadedVideoDetails[video.id.videoID] + videoCard(for: video, thumbnailFrom: detailedVideo, authorFrom: detailedVideo, isLoadingMore: isLoadingMoreVideos && isCurrent, showTitle: true, isCurrent: isCurrent) .opacity(isCurrent ? 1.0 : peekOpacity) .containerRelativeFrame(.horizontal) .id(index) @@ -914,14 +915,16 @@ struct VideoInfoView: View { /// A single video card with thumbnail, title, and channel info /// - Parameters: - /// - video: The video to display (used for thumbnail - stable reference) + /// - video: The video to display (used for title/channel - stable reference across carousel scrolls) + /// - thumbnailFrom: Optional video to source the thumbnail URL from (use the freshly loaded details so expired proxy URLs get replaced) /// - authorFrom: Optional video to get author info from (for avatar URL from detailed video) /// - isLoadingMore: Whether to show loading overlay for continuation loading /// - showTitle: Whether to show the title and channel (animates in/out) /// - isCurrent: Whether this is the currently selected video (thumbnail tap plays video) - private func videoCard(for video: Video, authorFrom: Video? = nil, isLoadingMore: Bool, showTitle: Bool, isCurrent: Bool) -> some View { - let deArrowURL = appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: video) - let bestThumb = video.bestThumbnail + private func videoCard(for video: Video, thumbnailFrom: Video? = nil, authorFrom: Video? = nil, isLoadingMore: Bool, showTitle: Bool, isCurrent: Bool) -> some View { + let thumbnailSource = thumbnailFrom ?? video + let deArrowURL = appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: thumbnailSource) + let bestThumb = thumbnailSource.bestThumbnail let thumbnailURL = deArrowURL ?? bestThumb?.url return VStack(spacing: 12) { // Thumbnail with loading overlay @@ -2035,6 +2038,7 @@ struct VideoInfoView: View { do { // Use extractURL method - just use the video part let (fullVideo, _, _) = try await contentService.extractURL(originalURL, instance: instance) + invalidateStaleThumbnails(old: base, new: fullVideo) loadedVideoDetails[videoID] = fullVideo CachedChannelData.cacheAuthor(fullVideo.author) } catch { @@ -2059,6 +2063,7 @@ struct VideoInfoView: View { id: videoID, instance: instance ) + invalidateStaleThumbnails(old: base, new: fullVideo) loadedVideoDetails[videoID] = fullVideo CachedChannelData.cacheAuthor(fullVideo.author) } catch { @@ -2067,6 +2072,18 @@ struct VideoInfoView: View { isLoadingVideoDetails = false } + + /// Evicts image cache entries for thumbnails whose URLs changed between + /// the queued/list copy of the video and the freshly fetched details, so + /// other views holding the old URLs re-fetch instead of reusing broken + /// payloads from Nuke's cache. + @MainActor + private func invalidateStaleThumbnails(old: Video, new: Video) { + let newURLs = Set(new.thumbnails.map(\.url)) + for thumb in old.thumbnails where !newURLs.contains(thumb.url) { + ImageLoadingService.shared.removeCachedImage(for: thumb.url) + } + } /// Load initial video from API (for videoID init mode). private func loadInitialVideoIfNeeded() async {