Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ Default local workflow is CLI-first: use the commands above for day-to-day verif

> ⚠️ **SwiftFormat `--self insert` rule**: The project uses `--self insert` in `.swiftformat`. In static methods, call other static methods with `Self.methodName()` (not bare `methodName()`); in instance methods, use `self.property` explicitly.

## Localization

> 🌐 **`Sources/Kaset/Resources/Localizable.xcstrings` is the localization source of truth.** Packaged builds compile the catalog directly, but SwiftPM/Xcode runtime builds use the checked-in `Sources/Kaset/Resources/*.lproj/Localizable.strings` mirrors.

- When adding localization keys or changing translations, update the catalog first and update the corresponding checked-in `.lproj/Localizable.strings` files in the same change. Never update only one side.
- Run `swift test --skip KasetUITests --filter LocalizationCatalogParityTests` after localization changes.
- When adding a locale, also register its `.lproj` in `Package.swift`, add the `SettingsManager.ContentLanguage` case, and extend localization tests.

## Debugging & Measurement

> 🔬 **Measure before you fix — never guess at runtime behavior.** For any bug about *timing, lifecycle, or "why didn't this run/load/update"* (SwiftUI `.task`/state churn, cold-launch ordering, perceived latency), instrument the real code path and observe before changing anything. Reasoning about SwiftUI lifecycle or async ordering from the source alone is unreliable; a 10-line timestamped trace settles in one launch what hours of hypothesizing cannot. Add the trace → reproduce → read the evidence → fix the thing the data points at → re-measure to confirm → remove the instrumentation.
Expand Down
8 changes: 4 additions & 4 deletions Sources/Kaset/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -101724,7 +101724,7 @@
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Abbonati a %@"
"value" : "Iscriviti a %@"
}
},
"ja" : {
Expand Down Expand Up @@ -101909,7 +101909,7 @@
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Abbonati ai podcast su YouTube Music per vederli qui."
"value" : "Iscriviti ai podcast su YouTube Music per vederli qui."
}
},
"ja" : {
Expand Down Expand Up @@ -102279,7 +102279,7 @@
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Errore nell'abbonamento"
"value" : "Errore di iscrizione"
}
},
"ja" : {
Expand Down Expand Up @@ -102464,7 +102464,7 @@
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Abbonamenti"
"value" : "Iscrizioni"
}
},
"ja" : {
Expand Down
8 changes: 4 additions & 4 deletions Sources/Kaset/Resources/it.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -549,11 +549,11 @@
"Style" = "Stile";
"Submit" = "Invia";
"Subscribe" = "Iscriviti";
"Subscribe %@" = "Abbonati a %@";
"Subscribe to podcasts on YouTube Music to see them here." = "Abbonati ai podcast su YouTube Music per vederli qui.";
"Subscribe %@" = "Iscriviti a %@";
"Subscribe to podcasts on YouTube Music to see them here." = "Iscriviti ai podcast su YouTube Music per vederli qui.";
"Subscribed" = "Iscritto";
"Subscription Error" = "Errore nell'abbonamento";
"Subscriptions" = "Abbonamenti";
"Subscription Error" = "Errore di iscrizione";
"Subscriptions" = "Iscrizioni";
"Suggested" = "Suggerito";
"Suggested Removals" = "Rimozioni suggerite";
"Suggestions per insertion" = "Suggerimenti per inserimento";
Expand Down
35 changes: 25 additions & 10 deletions Sources/Kaset/Services/API/Parsers/YouTube/YouTubeItemParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,7 @@ enum YouTubeItemParser {
return nil
}

let sources = (
(lockup["thumbnailViewModel"] as? [String: Any])?["image"] as? [String: Any]
)?["sources"] as? [[String: Any]]
let sources = self.imageSources(fromThumbnailViewModel: lockup["thumbnailViewModel"])

return YouTubeVideo(
videoId: videoId,
Expand Down Expand Up @@ -355,16 +353,33 @@ enum YouTubeItemParser {
/// wrap it in `collectionThumbnailViewModel.primaryThumbnail` (the stacked
/// poster), so fall back to that path.
static func thumbnailURL(fromLockup lockup: [String: Any]) -> URL? {
let contentImage = lockup["contentImage"] as? [String: Any]
let thumbnailViewModel = contentImage?["thumbnailViewModel"] as? [String: Any]
?? ((contentImage?["collectionThumbnailViewModel"] as? [String: Any])?["primaryThumbnail"]
as? [String: Any])?["thumbnailViewModel"] as? [String: Any]
let sources = (thumbnailViewModel?["image"] as? [String: Any])?["sources"]
as? [[String: Any]]
guard let sources else { return nil }
guard let contentImage = lockup["contentImage"] as? [String: Any] else { return nil }

if let sources = self.imageSources(fromThumbnailViewModel: contentImage["thumbnailViewModel"]) {
return self.bestSourceURL(from: sources)
}

// Playlist and album lockups stack their poster behind a collection wrapper.
let collection = contentImage["collectionThumbnailViewModel"] as? [String: Any]
guard let sources = self.imageSources(
fromThumbnailViewModel: collection?["primaryThumbnail"]
) else {
return nil
}
return self.bestSourceURL(from: sources)
}

/// Extracts `image.sources` from a `thumbnailViewModel`, tolerating the
/// singly-nested form InnerTube uses for Shorts and collection lockups.
static func imageSources(fromThumbnailViewModel value: Any?) -> [[String: Any]]? {
guard let container = value as? [String: Any] else { return nil }
if let sources = (container["image"] as? [String: Any])?["sources"] as? [[String: Any]] {
return sources
}
let nested = container["thumbnailViewModel"] as? [String: Any]
return (nested?["image"] as? [String: Any])?["sources"] as? [[String: Any]]
}

/// Picks the largest entry from an array of `{url, width, height}` sources,
/// normalizing protocol-relative URLs.
static func bestSourceURL(from sources: [[String: Any]]) -> URL? {
Expand Down
33 changes: 33 additions & 0 deletions Sources/Kaset/Services/Player/WebPlaybackDocumentGeneration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,14 @@ struct WebPlaybackDocumentGeneration: Equatable {
return host == playbackHost.lowercased() || Self.trustedRedirectHosts.contains(host)
}

// Google's "unusual traffic" challenge (`www.google.com/sorry/…`) is
// deliberately NOT trusted here. Allowing the URL alone does not make it
// reachable: the challenge is commonly served as HTTP 429, which
// `acceptsMainFrameResponse` rejects before commit, and clearing it posts
// `g-recaptcha-response` as a form body that `requestByBindingGeneration`
// cannot carry across a generation rebind. Supporting it needs both of
// those handled together.

static func isTrustedIntermediaryURL(_ url: URL?) -> Bool {
guard let url,
url.scheme?.lowercased() == "https",
Expand Down Expand Up @@ -384,6 +392,31 @@ struct WebPlaybackDocumentGeneration: Equatable {
&& Self.isAllowedPlaybackNavigationURL(currentURL, playbackHost: playbackHost)
}

/// A form submission from a committed trusted intermediary must keep the
/// original WebKit navigation so its HTTP body is preserved. GET
/// continuations can be rebound with a generation token, but WebKit omits
/// form bodies from the navigation-policy request used to reconstruct them.
static func shouldAllowTrustedIntermediaryFormSubmission(
_ request: URLRequest,
currentURL: URL?,
generation: UInt64,
playbackHost: String,
committedIntermediaryGeneration: UInt64?
) -> Bool {
guard request.httpMethod?.uppercased() == "POST",
committedIntermediaryGeneration == generation,
self.isTrustedIntermediaryURL(currentURL),
self.isTrustedIntermediaryURL(request.url),
Self.generation(from: request.url) == nil
else { return false }

let mainDocumentGeneration = Self.generation(from: request.mainDocumentURL)
guard mainDocumentGeneration == nil || mainDocumentGeneration == generation else {
return false
}
return Self.isAllowedPlaybackNavigationURL(request.url, playbackHost: playbackHost)
}

static func requestByBindingGeneration(
_ request: URLRequest,
generation: UInt64
Expand Down
13 changes: 9 additions & 4 deletions Sources/Kaset/Services/Player/YouTubePlayerService+Seeking.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,15 @@ extension YouTubePlayerService {
guard time.isFinite else { return }
self.invalidateExplicitStartTargetForUserSeek()
let target = self.clampedSeekTarget(time)
// A live stream never "ends" when you catch up to the edge — seeking to
// the end of the DVR window just parks at the live head, so don't treat
// it as a completed watch (which would pause the stream).
if !self.isLive, self.duration > 0, target >= self.duration - Self.seekToEndThreshold {
// A live stream's duration is its DVR window, not a finish line, so
// seeking to the edge must not count as watching to the end. Runtime
// bridge state covers URL launches and SPA drift whose feed model did
// not carry liveness.
if self.currentVideo?.isLive != true,
!self.currentMediaIsLive,
self.duration > 0,
target >= self.duration - Self.seekToEndThreshold
{
self.handleManualSeekToEnd()
return
}
Expand Down
Loading
Loading