mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 10:25:02 +00:00
Refresh expired thumbnail URLs for downloads and video info
Proxied thumbnail URLs from Invidious/Piped/Yattee server expire over time. Two paths were left holding stale URLs: the Video Info carousel kept the original list copy even after fresh details arrived, and downloaded videos rendered from the remote URL snapshot taken at download time while the local thumbnail on disk was ignored. Evict stale URLs from the Nuke cache when fresh video details load, pass the fresh details through to the videoCard thumbnail, and resolve downloads' thumbnails from the local file when localThumbnailPath is set.
This commit is contained in:
@@ -298,9 +298,29 @@ struct Download: Identifiable, Codable, Sendable, Equatable {
|
|||||||
self.audioBitrate = audioBitrate
|
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.
|
/// Convert to a Video model for display purposes.
|
||||||
func toVideo() -> Video {
|
/// - Parameter downloadsDirectory: When provided and a local thumbnail
|
||||||
Video(
|
/// 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,
|
id: videoID,
|
||||||
title: title,
|
title: title,
|
||||||
description: description,
|
description: description,
|
||||||
@@ -316,7 +336,7 @@ struct Download: Identifiable, Codable, Sendable, Equatable {
|
|||||||
publishedText: publishedText,
|
publishedText: publishedText,
|
||||||
viewCount: viewCount,
|
viewCount: viewCount,
|
||||||
likeCount: likeCount,
|
likeCount: likeCount,
|
||||||
thumbnails: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
|
thumbnails: resolvedThumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
|
||||||
isLive: false,
|
isLive: false,
|
||||||
isUpcoming: false,
|
isUpcoming: false,
|
||||||
scheduledStartTime: nil
|
scheduledStartTime: nil
|
||||||
|
|||||||
@@ -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).
|
/// Clear all image caches (memory and disk).
|
||||||
func clearCache() {
|
func clearCache() {
|
||||||
// Clear memory cache
|
// Clear memory cache
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ struct DownloadRowView: View {
|
|||||||
@State private var hasLoadedWatchData = false
|
@State private var hasLoadedWatchData = false
|
||||||
|
|
||||||
private var video: Video {
|
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.
|
/// Watch progress for this video (0.0 to 1.0), or nil if not watched.
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ struct DownloadsStorageView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func storageRow(_ download: Download, isLast: Bool) -> some View {
|
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
|
let isWatched = dataManager?.watchEntry(for: download.videoID.videoID)?.isFinished ?? false
|
||||||
|
|
||||||
VideoListRow(
|
VideoListRow(
|
||||||
|
|||||||
@@ -375,7 +375,8 @@ private struct CompletedDownloadsSectionContentView: View {
|
|||||||
private func groupedByChannelContent(_ downloads: [Download]) -> some View {
|
private func groupedByChannelContent(_ downloads: [Download]) -> some View {
|
||||||
let grouped = settings.groupedByChannel(downloads)
|
let grouped = settings.groupedByChannel(downloads)
|
||||||
let allDownloadsInOrder = grouped.flatMap { $0.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
|
var runningIndex = 0
|
||||||
|
|
||||||
ForEach(Array(grouped.enumerated()), id: \.element.channelID) { groupIndex, group in
|
ForEach(Array(grouped.enumerated()), id: \.element.channelID) { groupIndex, group in
|
||||||
@@ -420,7 +421,8 @@ private struct CompletedDownloadsSectionContentView: View {
|
|||||||
let sortedDownloads = settings.sorted(downloads)
|
let sortedDownloads = settings.sorted(downloads)
|
||||||
let groupedByLetter = groupDownloadsByFirstLetter(sortedDownloads, ascending: settings.sortDirection == .ascending)
|
let groupedByLetter = groupDownloadsByFirstLetter(sortedDownloads, ascending: settings.sortDirection == .ascending)
|
||||||
let allDownloadsInOrder = groupedByLetter.flatMap { $0.downloads }
|
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
|
var runningIndex = 0
|
||||||
|
|
||||||
ForEach(Array(groupedByLetter.enumerated()), id: \.element.letter) { groupIndex, group in
|
ForEach(Array(groupedByLetter.enumerated()), id: \.element.letter) { groupIndex, group in
|
||||||
@@ -463,7 +465,8 @@ private struct CompletedDownloadsSectionContentView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func flatListContent(_ downloads: [Download]) -> some View {
|
private func flatListContent(_ downloads: [Download]) -> some View {
|
||||||
let sortedDownloads = settings.sorted(downloads)
|
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
|
// Flat list content wrapped in its own card
|
||||||
VideoListContent(listStyle: listStyle) {
|
VideoListContent(listStyle: listStyle) {
|
||||||
@@ -530,7 +533,7 @@ private struct CompletedDownloadsSectionContentView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.videoSwipeActions(
|
.videoSwipeActions(
|
||||||
video: download.toVideo(),
|
video: download.toVideo(downloadsDirectory: manager.downloadsDirectory()),
|
||||||
fixedActions: [
|
fixedActions: [
|
||||||
SwipeAction(
|
SwipeAction(
|
||||||
symbolImage: "trash.fill",
|
symbolImage: "trash.fill",
|
||||||
|
|||||||
@@ -1142,7 +1142,8 @@ struct HomeView: View {
|
|||||||
let limitedDownloads = Array(downloads.prefix(sectionItemsLimit))
|
let limitedDownloads = Array(downloads.prefix(sectionItemsLimit))
|
||||||
// Use toVideo() instead of videoAndStream() to avoid O(n²) file I/O on main thread
|
// 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()
|
// 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) {
|
return VStack(alignment: .leading, spacing: 0) {
|
||||||
sectionHeader(title: "home.recentDownloads.title") {
|
sectionHeader(title: "home.recentDownloads.title") {
|
||||||
|
|||||||
@@ -855,9 +855,10 @@ struct VideoInfoView: View {
|
|||||||
if let videos = allVideos {
|
if let videos = allVideos {
|
||||||
ForEach(Array(videos.enumerated()), id: \.element.id) { index, video in
|
ForEach(Array(videos.enumerated()), id: \.element.id) { index, video in
|
||||||
let isCurrent = index == currentVideoIndex
|
let isCurrent = index == currentVideoIndex
|
||||||
// Use original video for thumbnail (stable), get author from detailed video for avatar
|
// Prefer freshly loaded details for thumbnail (so expired proxy URLs get replaced) and avatar.
|
||||||
let authorSource = loadedVideoDetails[video.id.videoID]
|
// Keep `video` as the card's stable identity so title/channel don't flicker.
|
||||||
videoCard(for: video, authorFrom: authorSource, isLoadingMore: isLoadingMoreVideos && isCurrent, showTitle: true, isCurrent: isCurrent)
|
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)
|
.opacity(isCurrent ? 1.0 : peekOpacity)
|
||||||
.containerRelativeFrame(.horizontal)
|
.containerRelativeFrame(.horizontal)
|
||||||
.id(index)
|
.id(index)
|
||||||
@@ -914,14 +915,16 @@ struct VideoInfoView: View {
|
|||||||
|
|
||||||
/// A single video card with thumbnail, title, and channel info
|
/// A single video card with thumbnail, title, and channel info
|
||||||
/// - Parameters:
|
/// - 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)
|
/// - authorFrom: Optional video to get author info from (for avatar URL from detailed video)
|
||||||
/// - isLoadingMore: Whether to show loading overlay for continuation loading
|
/// - isLoadingMore: Whether to show loading overlay for continuation loading
|
||||||
/// - showTitle: Whether to show the title and channel (animates in/out)
|
/// - showTitle: Whether to show the title and channel (animates in/out)
|
||||||
/// - isCurrent: Whether this is the currently selected video (thumbnail tap plays video)
|
/// - 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 {
|
private func videoCard(for video: Video, thumbnailFrom: Video? = nil, authorFrom: Video? = nil, isLoadingMore: Bool, showTitle: Bool, isCurrent: Bool) -> some View {
|
||||||
let deArrowURL = appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: video)
|
let thumbnailSource = thumbnailFrom ?? video
|
||||||
let bestThumb = video.bestThumbnail
|
let deArrowURL = appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: thumbnailSource)
|
||||||
|
let bestThumb = thumbnailSource.bestThumbnail
|
||||||
let thumbnailURL = deArrowURL ?? bestThumb?.url
|
let thumbnailURL = deArrowURL ?? bestThumb?.url
|
||||||
return VStack(spacing: 12) {
|
return VStack(spacing: 12) {
|
||||||
// Thumbnail with loading overlay
|
// Thumbnail with loading overlay
|
||||||
@@ -2035,6 +2038,7 @@ struct VideoInfoView: View {
|
|||||||
do {
|
do {
|
||||||
// Use extractURL method - just use the video part
|
// Use extractURL method - just use the video part
|
||||||
let (fullVideo, _, _) = try await contentService.extractURL(originalURL, instance: instance)
|
let (fullVideo, _, _) = try await contentService.extractURL(originalURL, instance: instance)
|
||||||
|
invalidateStaleThumbnails(old: base, new: fullVideo)
|
||||||
loadedVideoDetails[videoID] = fullVideo
|
loadedVideoDetails[videoID] = fullVideo
|
||||||
CachedChannelData.cacheAuthor(fullVideo.author)
|
CachedChannelData.cacheAuthor(fullVideo.author)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -2059,6 +2063,7 @@ struct VideoInfoView: View {
|
|||||||
id: videoID,
|
id: videoID,
|
||||||
instance: instance
|
instance: instance
|
||||||
)
|
)
|
||||||
|
invalidateStaleThumbnails(old: base, new: fullVideo)
|
||||||
loadedVideoDetails[videoID] = fullVideo
|
loadedVideoDetails[videoID] = fullVideo
|
||||||
CachedChannelData.cacheAuthor(fullVideo.author)
|
CachedChannelData.cacheAuthor(fullVideo.author)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -2068,6 +2073,18 @@ struct VideoInfoView: View {
|
|||||||
isLoadingVideoDetails = false
|
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).
|
/// Load initial video from API (for videoID init mode).
|
||||||
private func loadInitialVideoIfNeeded() async {
|
private func loadInitialVideoIfNeeded() async {
|
||||||
guard case .videoID(let videoID) = initMode else { return }
|
guard case .videoID(let videoID) = initMode else { return }
|
||||||
|
|||||||
Reference in New Issue
Block a user