diff --git a/Sources/APIExplorer/AskVideoAudit.swift b/Sources/APIExplorer/AskVideoAudit.swift index 3af95a056..7a747c833 100644 --- a/Sources/APIExplorer/AskVideoAudit.swift +++ b/Sources/APIExplorer/AskVideoAudit.swift @@ -399,6 +399,10 @@ private func askParityContext( "osVersion": "10_15_7", "platform": "DESKTOP", "userAgent": askParityUserAgent, + // Minutes from UTC, UTC-behind negative — the same value YouTube's web client + // sends (its JS `-Date.getTimezoneOffset()`). Mirrors + // `InnerTubeSupport.utcOffsetMinutes(for:)`, inlined because APIExplorer cannot + // import the Kaset executable target. "utcOffsetMinutes": TimeZone.current.secondsFromGMT() / 60, ] if profile.usesVisitorData { diff --git a/Sources/Kaset/Services/API/InnerTubeSupport.swift b/Sources/Kaset/Services/API/InnerTubeSupport.swift index b8d8af883..30d9dbb27 100644 --- a/Sources/Kaset/Services/API/InnerTubeSupport.swift +++ b/Sources/Kaset/Services/API/InnerTubeSupport.swift @@ -19,4 +19,13 @@ enum InnerTubeSupport { .joined() return "\(timestamp)_\(hash)" } + + /// The `client.utcOffsetMinutes` value for an InnerTube request: minutes offset + /// from UTC, UTC-behind zones NEGATIVE (Los Angeles is `-420`). This matches what + /// YouTube's own web client sends — its JS computes `-Date.getTimezoneOffset()`, + /// and `TimeZone.secondsFromGMT()` already carries that opposite sign, so no + /// negation is needed here. + static func utcOffsetMinutes(for timeZone: TimeZone) -> Int { + timeZone.secondsFromGMT() / 60 + } } diff --git a/Sources/Kaset/Services/API/YTMusicClient.swift b/Sources/Kaset/Services/API/YTMusicClient.swift index 4c3a033c2..e9d5ad46c 100644 --- a/Sources/Kaset/Services/API/YTMusicClient.swift +++ b/Sources/Kaset/Services/API/YTMusicClient.swift @@ -1959,7 +1959,7 @@ final class YTMusicClient: YTMusicClientProtocol { "osVersion": "10_15_7", "platform": "DESKTOP", "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", - "utcOffsetMinutes": -TimeZone.current.secondsFromGMT() / 60, + "utcOffsetMinutes": InnerTubeSupport.utcOffsetMinutes(for: .current), ], "user": userDict, ] diff --git a/Sources/Kaset/Services/API/YouTubeClient.swift b/Sources/Kaset/Services/API/YouTubeClient.swift index 0d1764c13..a1fe779fe 100644 --- a/Sources/Kaset/Services/API/YouTubeClient.swift +++ b/Sources/Kaset/Services/API/YouTubeClient.swift @@ -689,7 +689,7 @@ final class YouTubeClient: YouTubeClientProtocol { // swiftlint:disable:this typ "osVersion": "10_15_7", "platform": "DESKTOP", "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", - "utcOffsetMinutes": TimeZone.current.secondsFromGMT() / 60, + "utcOffsetMinutes": InnerTubeSupport.utcOffsetMinutes(for: .current), ], "user": userDict, ] diff --git a/Tests/KasetTests/InnerTubeSupportTests.swift b/Tests/KasetTests/InnerTubeSupportTests.swift index dda7a184b..cbb404433 100644 --- a/Tests/KasetTests/InnerTubeSupportTests.swift +++ b/Tests/KasetTests/InnerTubeSupportTests.swift @@ -50,4 +50,42 @@ struct InnerTubeSupportTests { ) #expect(hash.hasPrefix("42_")) } + + @Test("utcOffsetMinutes is negative for a UTC-behind zone (matches YouTube web)") + func utcOffsetMinutesBehindUTC() throws { + // Fixed UTC-5 offset (DST-independent) so the assertion is stable on any + // run date; secondsFromGMT() == -18_000 → -18_000 / 60 == -300. + let tz = try #require(TimeZone(secondsFromGMT: -18000)) + #expect(InnerTubeSupport.utcOffsetMinutes(for: tz) == -300) + } + + @Test("utcOffsetMinutes is positive for a UTC-ahead zone (matches YouTube web)") + func utcOffsetMinutesAheadOfUTC() throws { + // Fixed UTC+1 offset (DST-independent); secondsFromGMT() == 3600 → 3600 / 60 == 60. + let tz = try #require(TimeZone(secondsFromGMT: 3600)) + #expect(InnerTubeSupport.utcOffsetMinutes(for: tz) == 60) + } + + @Test("utcOffsetMinutes is zero for UTC") + func utcOffsetMinutesUTC() throws { + let tz = try #require(TimeZone(identifier: "UTC")) + #expect(InnerTubeSupport.utcOffsetMinutes(for: tz) == 0) + } + + @Test("utcOffsetMinutes carries the fractional half-hour sign for India") + func utcOffsetMinutesHalfHour() throws { + let tz = try #require(TimeZone(identifier: "Asia/Kolkata")) // IST UTC+5:30 (no DST) → +19_800s + #expect(InnerTubeSupport.utcOffsetMinutes(for: tz) == 330) + } + + @Test("utcOffsetMinutes pins the Los Angeles datum YouTube's web client sends") + func utcOffsetMinutesLosAngelesReference() throws { + // YouTube's web client sends `-Date.getTimezoneOffset()` (YouTube.js, + // Session.ts), which equals `secondsFromGMT() / 60` in Foundation's opposite + // sign convention. Los Angeles on daylight time is UTC-7: -25_200s → -420, + // the datum in the review discussion. A fixed offset is used rather than the + // named zone so DST cannot move the assertion. + let tz = try #require(TimeZone(secondsFromGMT: -25200)) + #expect(InnerTubeSupport.utcOffsetMinutes(for: tz) == -420) + } }