import Foundation import CryptoKit struct SponsorSegment: Sendable { let start: Double let end: Double let videoDuration: Double static func visibleRanges(_ segments: [SponsorSegment], duration: Double) -> [ClosedRange] { guard duration.isFinite, duration > 0 else { return [] } let ranges = segments.compactMap { segment -> ClosedRange? in guard segment.start.isFinite, segment.end.isFinite, segment.start >= 0, segment.end > segment.start, segment.end <= duration, segment.videoDuration.isFinite, segment.videoDuration >= 0, segment.videoDuration == 0 || abs(segment.videoDuration - duration) < 1 else { return nil } return segment.start...segment.end }.sorted { $0.lowerBound < $1.lowerBound } var merged: [ClosedRange] = [] for range in ranges { if let previous = merged.last, range.lowerBound <= previous.upperBound { merged[merged.count - 1] = previous.lowerBound...max(previous.upperBound, range.upperBound) } else { merged.append(range) } } return merged } } private enum SponsorBlockError: LocalizedError { case invalidVideoID, invalidResponse, status(Int), tooLarge, malformedJSON var errorDescription: String? { switch self { case .invalidVideoID: return "SponsorBlock requires a canonical YouTube video ID." case .invalidResponse: return "SponsorBlock returned an invalid response." case .status(let status): return "SponsorBlock returned HTTP \(status)." case .tooLarge: return "SponsorBlock returned too much data." case .malformedJSON: return "SponsorBlock returned malformed segment data." } } } private final class SponsorBlockRedirectPolicy: NSObject, URLSessionTaskDelegate, Sendable { func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) } } enum SponsorBlockResolver { static let responseLimit = 2 * 1024 * 1024 private static func validID(_ id: String) -> Bool { id.utf8.count == 11 && id.utf8.allSatisfy { (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95 } } static func videoID(for url: URL) -> String? { let prefix = "https://www.youtube.com/watch?v=" let text = url.absoluteString guard text.hasPrefix(prefix) else { return nil } let id = String(text.dropFirst(prefix.count)) return validID(id) ? id : nil } static func request(videoID: String) throws -> URLRequest { guard validID(videoID) else { throw SponsorBlockError.invalidVideoID } let prefix = SHA256.hash(data: Data(videoID.utf8)).prefix(2).map { String(format: "%02x", $0) }.joined() let url = URL(string: "https://sponsor.ajay.app/api/skipSegments/\(prefix)?category=sponsor&actionType=skip&service=YouTube")! var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 10) request.httpMethod = "GET" request.httpShouldHandleCookies = false return request } static func resolve(videoID: String, configuration: URLSessionConfiguration? = nil) async throws -> [SponsorSegment] { let request = try request(videoID: videoID) let configuration = (configuration?.copy() as? URLSessionConfiguration) ?? .ephemeral configuration.httpCookieStorage = nil configuration.httpShouldSetCookies = false configuration.urlCredentialStorage = nil configuration.urlCache = nil configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData configuration.httpAdditionalHeaders = nil configuration.timeoutIntervalForRequest = 10 configuration.timeoutIntervalForResource = 15 let session = URLSession(configuration: configuration) defer { session.invalidateAndCancel() } try Task.checkCancellation() let (bytes, response) = try await session.bytes(for: request, delegate: SponsorBlockRedirectPolicy()) try Task.checkCancellation() guard let response = response as? HTTPURLResponse else { throw SponsorBlockError.invalidResponse } if response.statusCode == 404 { return [] } guard response.statusCode == 200 else { throw SponsorBlockError.status(response.statusCode) } guard response.expectedContentLength <= Int64(responseLimit) else { throw SponsorBlockError.tooLarge } var data = Data() data.reserveCapacity(min(responseLimit, max(0, Int(response.expectedContentLength)))) for try await byte in bytes { try Task.checkCancellation() guard data.count < responseLimit else { throw SponsorBlockError.tooLarge } data.append(byte) } try Task.checkCancellation() return try decode(data, videoID: videoID) } static func decode(_ data: Data, videoID: String) throws -> [SponsorSegment] { guard data.count <= responseLimit else { throw SponsorBlockError.tooLarge } let buckets: [Lossy] do { buckets = try JSONDecoder().decode([Lossy].self, from: data) } catch { throw SponsorBlockError.malformedJSON } return buckets.compactMap(\.value).filter { $0.videoID == videoID }.flatMap { bucket in bucket.segments.compactMap { row in guard let row = row.value, row.category == "sponsor", row.actionType == "skip", row.segment.count == 2, row.segment[0].isFinite, row.segment[1].isFinite, row.segment[0] >= 0, row.segment[1] > row.segment[0], row.videoDuration.isFinite, row.videoDuration >= 0 else { return nil } return SponsorSegment(start: row.segment[0], end: row.segment[1], videoDuration: row.videoDuration) } } } private struct Lossy: Decodable { let value: Value? init(from decoder: Decoder) throws { value = try? Value(from: decoder) } } private struct Bucket: Decodable { let videoID: String let segments: [Lossy] } private struct Row: Decodable { let category: String let actionType: String let segment: [Double] let videoDuration: Double } }