mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +00:00
Fix storyboard downloads with yattee-server direct YouTube URLs
yattee-server returns direct YouTube CDN URLs in the storyboard `url`
and `templateUrl` fields instead of an Invidious-style VTT proxy path.
Two resulting issues:
- `Storyboard.directSheetURL` was replacing the whole `M$M` token with
just the index, producing `.../0.jpg` (404) instead of `.../M0.jpg`.
Replace `M$M` with `M\(index)` to preserve the literal `M` prefix;
matching the full token also avoids clobbering `$M` sequences that
may appear in `sigh=rs$...` query params.
- The download code fetched `proxyUrl` as if it were a WebVTT file;
with yattee-server that downloads a JPEG that fails UTF-8 parsing.
Skip the VTT round-trip when `proxyUrl` obviously points at an image.
Also align the on-disk filename with the local-playback template
(`sb_M$M.jpg` → `sb_M{N}.jpg`) so offline seek-bar previews resolve,
and add [Storyboard] debug logs at each decision point so future
failures can be diagnosed without guessing.
This commit is contained in:
@@ -99,7 +99,12 @@ struct Storyboard: Hashable, Sendable, Codable {
|
|||||||
/// - Returns: Direct URL for the sprite sheet, or nil if invalid
|
/// - Returns: Direct URL for the sprite sheet, or nil if invalid
|
||||||
func directSheetURL(for index: Int) -> URL? {
|
func directSheetURL(for index: Int) -> URL? {
|
||||||
guard index >= 0, index < storyboardCount else { return nil }
|
guard index >= 0, index < storyboardCount else { return nil }
|
||||||
let urlString = templateUrl.replacingOccurrences(of: "M$M", with: "\(index)")
|
// YouTube storyboard filenames are `M{N}.jpg` and the templateUrl encodes the
|
||||||
|
// slot as `M$M`. The leading `M` is literal, so replace `M$M` with `M{index}`
|
||||||
|
// (not bare `\(index)`) — otherwise the file becomes `0.jpg` instead of `M0.jpg`
|
||||||
|
// and YouTube returns 404. Matching the full `M$M` token also avoids accidentally
|
||||||
|
// rewriting any `$M` that appears later in query params such as `sigh=rs$...`.
|
||||||
|
let urlString = templateUrl.replacingOccurrences(of: "M$M", with: "M\(index)")
|
||||||
return URL(string: urlString)
|
return URL(string: urlString)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ import Foundation
|
|||||||
extension DownloadManager {
|
extension DownloadManager {
|
||||||
// MARK: - Storyboard Download
|
// MARK: - Storyboard Download
|
||||||
|
|
||||||
|
/// Returns true if `proxyUrl` clearly points at a direct image (e.g. a YouTube CDN
|
||||||
|
/// `.jpg` URL returned by yattee-server) rather than a VTT proxy path.
|
||||||
|
/// In that case fetching it and trying to parse as WebVTT would be wasted bandwidth
|
||||||
|
/// and produces an empty URL list, so we skip straight to the templateUrl fallback.
|
||||||
|
static func proxyUrlLooksLikeImage(_ urlString: String) -> Bool {
|
||||||
|
// Strip the query string; common YouTube URLs carry huge `sqp` / `sigh` params.
|
||||||
|
let pathOnly = urlString.split(separator: "?", maxSplits: 1).first.map(String.init) ?? urlString
|
||||||
|
let lower = pathOnly.lowercased()
|
||||||
|
return lower.hasSuffix(".jpg")
|
||||||
|
|| lower.hasSuffix(".jpeg")
|
||||||
|
|| lower.hasSuffix(".png")
|
||||||
|
|| lower.hasSuffix(".webp")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Start downloading storyboard sprite sheets sequentially
|
/// Start downloading storyboard sprite sheets sequentially
|
||||||
func startStoryboardDownload(downloadID: UUID) {
|
func startStoryboardDownload(downloadID: UUID) {
|
||||||
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }),
|
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }),
|
||||||
@@ -43,10 +58,29 @@ extension DownloadManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// First, try to get VTT from proxy URL to extract actual image URLs
|
// Diagnostic: log the selected storyboard variant so we can tell
|
||||||
|
// which server shape we are dealing with (Invidious VTT vs yattee-server direct URLs).
|
||||||
|
let proxySample = storyboard.proxyUrl.map { String($0.prefix(120)) } ?? "<nil>"
|
||||||
|
let templateSample = String(storyboard.templateUrl.prefix(120))
|
||||||
|
LoggingService.shared.debug(
|
||||||
|
"[Storyboard] Starting download for \(videoID): \(storyboard.width)x\(storyboard.height), sheets=\(storyboard.storyboardCount)",
|
||||||
|
category: .downloads,
|
||||||
|
details: "proxyUrl=\(proxySample) templateUrl=\(templateSample)"
|
||||||
|
)
|
||||||
|
|
||||||
|
// First, try to get VTT from proxy URL to extract actual image URLs.
|
||||||
|
// Some backends (yattee-server after innertube switch) return a direct image
|
||||||
|
// URL in the `url` field instead of a VTT proxy path, so skip the VTT round-trip
|
||||||
|
// when the URL obviously points at an image resource.
|
||||||
var imageURLs: [URL] = []
|
var imageURLs: [URL] = []
|
||||||
|
|
||||||
if let proxyUrl = storyboard.proxyUrl {
|
if let proxyUrl = storyboard.proxyUrl {
|
||||||
|
if Self.proxyUrlLooksLikeImage(proxyUrl) {
|
||||||
|
LoggingService.shared.debug(
|
||||||
|
"[Storyboard] Skipping VTT fetch — proxyUrl looks like a direct image, using templateUrl fallback",
|
||||||
|
category: .downloads
|
||||||
|
)
|
||||||
|
} else {
|
||||||
// Construct absolute VTT URL
|
// Construct absolute VTT URL
|
||||||
let vttURL: URL?
|
let vttURL: URL?
|
||||||
if proxyUrl.hasPrefix("http://") || proxyUrl.hasPrefix("https://") {
|
if proxyUrl.hasPrefix("http://") || proxyUrl.hasPrefix("https://") {
|
||||||
@@ -65,31 +99,65 @@ extension DownloadManager {
|
|||||||
|
|
||||||
if let vttURL {
|
if let vttURL {
|
||||||
do {
|
do {
|
||||||
let (vttData, _) = try await URLSession.shared.data(from: vttURL)
|
let (vttData, response) = try await URLSession.shared.data(from: vttURL)
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||||
imageURLs = parseVTTForImageURLs(vttData, baseURL: vttURL)
|
imageURLs = parseVTTForImageURLs(vttData, baseURL: vttURL)
|
||||||
|
LoggingService.shared.debug(
|
||||||
|
"[Storyboard] VTT fetch OK (status=\(status), bytes=\(vttData.count)) parsed \(imageURLs.count) URLs",
|
||||||
|
category: .downloads
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
// VTT fetch failed, will fall back to direct URLs
|
LoggingService.shared.debug(
|
||||||
|
"[Storyboard] VTT fetch failed: \(error.localizedDescription) — will fall back to direct URLs",
|
||||||
|
category: .downloads
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LoggingService.shared.debug(
|
||||||
|
"[Storyboard] Could not construct VTT URL from proxyUrl, falling back to direct URLs",
|
||||||
|
category: .downloads
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If VTT parsing failed, fall back to direct URLs (may not work if blocked)
|
// If VTT parsing failed, fall back to direct URLs (may not work if blocked)
|
||||||
if imageURLs.isEmpty, storyboard.storyboardCount > 0 {
|
if imageURLs.isEmpty, storyboard.storyboardCount > 0 {
|
||||||
|
var nilCount = 0
|
||||||
for sheetIndex in 0..<storyboard.storyboardCount {
|
for sheetIndex in 0..<storyboard.storyboardCount {
|
||||||
if let url = storyboard.directSheetURL(for: sheetIndex) {
|
if let url = storyboard.directSheetURL(for: sheetIndex) {
|
||||||
imageURLs.append(url)
|
imageURLs.append(url)
|
||||||
|
} else {
|
||||||
|
nilCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let firstSample = imageURLs.first.map { String($0.absoluteString.prefix(160)) } ?? "<none>"
|
||||||
|
LoggingService.shared.debug(
|
||||||
|
"[Storyboard] directSheetURL fallback produced \(imageURLs.count)/\(storyboard.storyboardCount) URLs (nil: \(nilCount))",
|
||||||
|
category: .downloads,
|
||||||
|
details: "first=\(firstSample)"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalSheets = imageURLs.count
|
let totalSheets = imageURLs.count
|
||||||
var completedSheets = 0
|
var completedSheets = 0
|
||||||
|
|
||||||
|
if totalSheets == 0 {
|
||||||
|
LoggingService.shared.debug(
|
||||||
|
"[Storyboard] No sheet URLs to download after VTT + fallback — will mark as failed",
|
||||||
|
category: .downloads
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Download each sprite sheet sequentially
|
// Download each sprite sheet sequentially
|
||||||
for (sheetIndex, sheetURL) in imageURLs.enumerated() {
|
for (sheetIndex, sheetURL) in imageURLs.enumerated() {
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
let fileName = "sb_\(sheetIndex).jpg"
|
// Filename must match the `sb_M$M.jpg` template used by
|
||||||
|
// `Storyboard.localStoryboard(...)` after `M$M` → `M{index}` substitution
|
||||||
|
// in `Storyboard.directSheetURL(for:)`, otherwise local playback won't
|
||||||
|
// resolve the sheets.
|
||||||
|
let fileName = "sb_M\(sheetIndex).jpg"
|
||||||
let fileURL = storyboardDir.appendingPathComponent(fileName)
|
let fileURL = storyboardDir.appendingPathComponent(fileName)
|
||||||
|
|
||||||
// Skip if already downloaded
|
// Skip if already downloaded
|
||||||
@@ -104,6 +172,12 @@ extension DownloadManager {
|
|||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse,
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
httpResponse.statusCode == 200 else {
|
httpResponse.statusCode == 200 else {
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||||
|
LoggingService.shared.debug(
|
||||||
|
"[Storyboard] Sheet \(sheetIndex) skipped — HTTP \(status) (bytes=\(data.count))",
|
||||||
|
category: .downloads,
|
||||||
|
details: String(sheetURL.absoluteString.prefix(160))
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +185,10 @@ extension DownloadManager {
|
|||||||
|
|
||||||
// Verify it's actually an image
|
// Verify it's actually an image
|
||||||
guard contentType.contains("image") || data.count > 50000 else {
|
guard contentType.contains("image") || data.count > 50000 else {
|
||||||
|
LoggingService.shared.debug(
|
||||||
|
"[Storyboard] Sheet \(sheetIndex) skipped — unexpected content (type=\(contentType), bytes=\(data.count))",
|
||||||
|
category: .downloads
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,12 +197,20 @@ extension DownloadManager {
|
|||||||
updateStoryboardProgress(downloadID: downloadID, completed: completedSheets, total: totalSheets)
|
updateStoryboardProgress(downloadID: downloadID, completed: completedSheets, total: totalSheets)
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
// Continue with next sheet - non-fatal
|
LoggingService.shared.debug(
|
||||||
|
"[Storyboard] Sheet \(sheetIndex) errored: \(error.localizedDescription)",
|
||||||
|
category: .downloads,
|
||||||
|
details: String(sheetURL.absoluteString.prefix(160))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete storyboard phase
|
// Complete storyboard phase
|
||||||
let success = completedSheets > 0
|
let success = completedSheets > 0
|
||||||
|
LoggingService.shared.debug(
|
||||||
|
"[Storyboard] Download phase finished: \(completedSheets)/\(totalSheets) sheets succeeded",
|
||||||
|
category: .downloads
|
||||||
|
)
|
||||||
finalizeStoryboardDownload(
|
finalizeStoryboardDownload(
|
||||||
downloadID: downloadID,
|
downloadID: downloadID,
|
||||||
storyboardDirName: storyboardDirName,
|
storyboardDirName: storyboardDirName,
|
||||||
|
|||||||
Reference in New Issue
Block a user