From 6efd60a18e0d2f26508036cd3dfbb5c1e315c4eb Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 21 Sep 2026 20:03:06 +0200 Subject: [PATCH] Play direct media links without Yattee Server by probing the URL first Debrid and CDN download links (TorBox, Real-Debrid, presigned S3) hide the file behind an opaque path, so the extension-based direct media check missed them and every one was sent to Yattee Server for yt-dlp extraction, failing outright when no server is configured. Add DirectMediaProbe: a HEAD request classifies the response by Content-Type and Content-Disposition, and when the type is generic or HEAD is refused, a ranged GET sniffs the first bytes for container signatures (Matroska/WebM, ISO BMFF, Ogg, FLAC, MP3, RIFF, FLV, ASF, MPEG-PS/TS, HLS and DASH manifests). Anything playable goes straight to mpv with the server-reported filename as the title; everything else falls back to extraction as before. The probe runs ahead of the Yattee Server path in ExternalVideoView, both OpenLinkSheet flows and the deep-link download path, and PlayerService rebuilds direct_media streams itself so replays from history no longer depend on a server either. --- Yattee/Services/Player/DirectMediaProbe.swift | 315 ++++++++++++++++++ Yattee/Services/Player/PlayerService.swift | 10 + Yattee/Utilities/DirectMediaHelper.swift | 41 ++- Yattee/Views/Home/OpenLinkSheet.swift | 12 + Yattee/Views/Video/ExternalVideoView.swift | 12 + Yattee/YatteeApp.swift | 13 + YatteeTests/DirectMediaProbeTests.swift | 120 +++++++ 7 files changed, 515 insertions(+), 8 deletions(-) create mode 100644 Yattee/Services/Player/DirectMediaProbe.swift create mode 100644 YatteeTests/DirectMediaProbeTests.swift diff --git a/Yattee/Services/Player/DirectMediaProbe.swift b/Yattee/Services/Player/DirectMediaProbe.swift new file mode 100644 index 00000000..1305e7b7 --- /dev/null +++ b/Yattee/Services/Player/DirectMediaProbe.swift @@ -0,0 +1,315 @@ +// +// DirectMediaProbe.swift +// Yattee +// +// Decides whether an arbitrary http(s) URL serves a media file mpv can play +// as-is, so the app can skip Yattee Server extraction for debrid/CDN download +// links whose path is an opaque ID (TorBox, Real-Debrid, presigned S3, …). +// + +import Foundation + +/// What the probe learned about a URL that serves playable media. +struct DirectMediaProbeResult: Sendable, Equatable { + /// Filename reported by the server (Content-Disposition), if any. + let filename: String? + /// Media type the server reported, or the one sniffed from the payload. + let mimeType: String? + /// Content-Length, if the server sent one. + let contentLength: Int64? +} + +enum DirectMediaProbe { + /// How long a probe may take before falling back to extraction. + static let timeout: TimeInterval = 10 + /// How many leading bytes to sniff when the server's type is generic. + static let sniffLength = 512 + + // MARK: - Network + + /// Asks the server what it serves at `url` and returns a result when it is + /// something mpv can play directly. `nil` means "not obviously media", + /// e.g. an HTML page, and the caller should try Yattee Server instead. + static func probe(_ url: URL, session: URLSession = .shared) async -> DirectMediaProbeResult? { + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { + return nil + } + + var headResult: HeaderVerdict? + if let response = await head(url, session: session) { + headResult = classify(response) + switch headResult { + case .media(let result): + LoggingService.shared.debug( + "[DirectMediaProbe] HEAD says media: \(result.mimeType ?? "-") \(result.filename ?? "")", + category: .player + ) + return result + case .notMedia: + LoggingService.shared.debug("[DirectMediaProbe] HEAD says not media for \(url.host ?? "")", category: .player) + return nil + case .unknown, .none: + break + } + } + + // HEAD failed or the type was generic: look at the first bytes. + guard let sniffed = await sniff(url, session: session) else { + return nil + } + let headers = sniffed.headers + guard let mimeType = sniffMimeType(sniffed.data) ?? mediaTypeFromFilename(headers) else { + LoggingService.shared.debug("[DirectMediaProbe] payload not recognised for \(url.host ?? "")", category: .player) + return nil + } + LoggingService.shared.debug("[DirectMediaProbe] sniffed \(mimeType) for \(url.host ?? "")", category: .player) + return DirectMediaProbeResult( + filename: headers.filename, + mimeType: mimeType, + contentLength: headers.totalLength + ) + } + + private static func head(_ url: URL, session: URLSession) async -> HTTPURLResponse? { + var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout) + request.httpMethod = "HEAD" + applyUserAgent(&request) + do { + let (_, response) = try await session.data(for: request) + return response as? HTTPURLResponse + } catch { + LoggingService.shared.debug("[DirectMediaProbe] HEAD failed: \(error.localizedDescription)", category: .player) + return nil + } + } + + private static func sniff(_ url: URL, session: URLSession) async -> (headers: ResponseHeaders, data: Data)? { + var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout) + request.setValue("bytes=0-\(sniffLength - 1)", forHTTPHeaderField: "Range") + applyUserAgent(&request) + do { + let (bytes, response) = try await session.bytes(for: request) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + return nil + } + var data = Data() + data.reserveCapacity(sniffLength) + for try await byte in bytes { + data.append(byte) + if data.count >= sniffLength { break } + } + return (ResponseHeaders(http), data) + } catch { + LoggingService.shared.debug("[DirectMediaProbe] sniff failed: \(error.localizedDescription)", category: .player) + return nil + } + } + + private static func applyUserAgent(_ request: inout URLRequest) { + let settings = UserAgentConfiguration.shared.current() + if let userAgent = settings.userAgent, !userAgent.isEmpty { + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + } + } + + // MARK: - Header classification + + enum HeaderVerdict: Equatable { + case media(DirectMediaProbeResult) + case notMedia + /// Generic type (octet-stream, missing) — needs a payload sniff. + case unknown + } + + /// The headers that matter, pulled out of an `HTTPURLResponse`. + struct ResponseHeaders { + let statusCode: Int + let contentType: String? + let contentDisposition: String? + let contentLength: Int64? + let contentRange: String? + + init(_ response: HTTPURLResponse) { + statusCode = response.statusCode + contentType = response.value(forHTTPHeaderField: "Content-Type") + contentDisposition = response.value(forHTTPHeaderField: "Content-Disposition") + contentLength = response.value(forHTTPHeaderField: "Content-Length").flatMap { Int64($0) } + contentRange = response.value(forHTTPHeaderField: "Content-Range") + } + + init(statusCode: Int = 200, contentType: String? = nil, contentDisposition: String? = nil, + contentLength: Int64? = nil, contentRange: String? = nil) { + self.statusCode = statusCode + self.contentType = contentType + self.contentDisposition = contentDisposition + self.contentLength = contentLength + self.contentRange = contentRange + } + + /// Filename from Content-Disposition (`filename*=` wins over `filename=`). + var filename: String? { + DirectMediaProbe.filename(fromContentDisposition: contentDisposition) + } + + /// The full size: Content-Range's total for a 206, else Content-Length. + var totalLength: Int64? { + if let contentRange, + let slash = contentRange.lastIndex(of: "/"), + let total = Int64(contentRange[contentRange.index(after: slash)...].trimmingCharacters(in: .whitespaces)) { + return total + } + return contentLength + } + + /// The media type without parameters, lowercased. + var essenceType: String? { + guard let contentType else { return nil } + let essence = contentType.split(separator: ";", maxSplits: 1).first.map(String.init) ?? contentType + let trimmed = essence.trimmingCharacters(in: .whitespaces).lowercased() + return trimmed.isEmpty ? nil : trimmed + } + } + + static func classify(_ response: HTTPURLResponse) -> HeaderVerdict { + classify(ResponseHeaders(response)) + } + + static func classify(_ headers: ResponseHeaders) -> HeaderVerdict { + guard (200...299).contains(headers.statusCode) else { + // 403/405 on HEAD is common for CDNs that only serve GET; let the sniff decide. + return .unknown + } + + let type = headers.essenceType + let filename = headers.filename + + if let type, isPlayableMediaType(type) { + return .media(DirectMediaProbeResult(filename: filename, mimeType: type, contentLength: headers.totalLength)) + } + + // A generic type with a media filename attached is still a media file. + if let fromName = mediaTypeFromFilename(headers), type.map(isGenericType) ?? true { + return .media(DirectMediaProbeResult(filename: filename, mimeType: fromName, contentLength: headers.totalLength)) + } + + guard let type else { return .unknown } + if isGenericType(type) { return .unknown } + return .notMedia + } + + /// Types mpv plays straight from the network. + static func isPlayableMediaType(_ type: String) -> Bool { + if type.hasPrefix("video/") || type.hasPrefix("audio/") { return true } + return [ + "application/vnd.apple.mpegurl", + "application/x-mpegurl", + "application/dash+xml", + "application/mp4", + "application/x-matroska", + "application/ogg", + "application/x-flac", + "application/mpeg", + "application/vnd.rn-realmedia", + ].contains(type) + } + + /// Types that say nothing about the payload. + static func isGenericType(_ type: String) -> Bool { + [ + "application/octet-stream", + "application/x-download", + "application/force-download", + "application/download", + "binary/octet-stream", + "application/unknown", + ].contains(type) + } + + /// A media type derived from the Content-Disposition filename's extension. + static func mediaTypeFromFilename(_ headers: ResponseHeaders) -> String? { + guard let filename = headers.filename else { return nil } + let ext = (filename as NSString).pathExtension.lowercased() + guard DirectMediaHelper.allExtensions.contains(ext) else { return nil } + return DirectMediaHelper.mimeType(for: ext) ?? "video/\(ext)" + } + + /// Parses `filename*=UTF-8''…` or `filename="…"` out of a Content-Disposition value. + static func filename(fromContentDisposition value: String?) -> String? { + guard let value else { return nil } + var plain: String? + for part in value.split(separator: ";").dropFirst() { + let trimmed = part.trimmingCharacters(in: .whitespaces) + guard let eq = trimmed.firstIndex(of: "=") else { continue } + let key = trimmed[.. String? { + guard data.count >= 4 else { return nil } + let bytes = [UInt8](data.prefix(sniffLength)) + + func starts(with signature: [UInt8], at offset: Int = 0) -> Bool { + guard bytes.count >= offset + signature.count else { return false } + return Array(bytes[offset.. [UInt8] { Array(s.utf8) } + + // EBML: Matroska or WebM. The DocType string follows within the header. + if starts(with: [0x1A, 0x45, 0xDF, 0xA3]) { + if let text = String(bytes: bytes.prefix(64), encoding: .isoLatin1), text.contains("webm") { + return "video/webm" + } + return "video/x-matroska" + } + // ISO BMFF: mp4/mov/m4a/3gp — "ftyp" at offset 4. + if starts(with: ascii("ftyp"), at: 4) { + let brand = String(bytes: bytes[8.. 188, bytes[188] == 0x47 { return "video/mp2t" } + + // Text-based manifests. + if let text = String(bytes: bytes, encoding: .utf8) ?? String(bytes: bytes, encoding: .isoLatin1) { + let head = text.trimmingCharacters(in: .whitespacesAndNewlines) + if head.hasPrefix("#EXTM3U") { return "application/vnd.apple.mpegurl" } + if head.hasPrefix("<") && head.contains(" Video { - let filename = url.lastPathComponent + /// - Parameters: + /// - url: The media URL. + /// - probe: What `DirectMediaProbe` learned about the URL, when the path + /// itself carries no filename (debrid/CDN links with an opaque ID). + static func createVideo(from url: URL, probe: DirectMediaProbeResult? = nil) -> Video { + let filename = probe?.filename ?? url.lastPathComponent let title = (filename as NSString).deletingPathExtension let host = url.host ?? "Direct Media" @@ -77,17 +81,29 @@ enum DirectMediaHelper { viewCount: nil, likeCount: nil, thumbnails: [], - isLive: isStreamingURL(url), // Treat HLS/DASH as potentially live + isLive: isStreamingURL(url) || isStreamingType(probe?.mimeType), // Treat HLS/DASH as potentially live isUpcoming: false, scheduledStartTime: nil ) } /// Creates a Stream from a direct media URL. - static func createStream(from url: URL) -> Stream { - let pathExtension = url.pathExtension.lowercased() + /// - Parameters: + /// - url: The media URL. + /// - probe: What `DirectMediaProbe` learned about the URL; supplies the + /// format, MIME type and size when the path has no extension. + static func createStream(from url: URL, probe: DirectMediaProbeResult? = nil) -> Stream { + let probedExtension = probe?.filename.map { ($0 as NSString).pathExtension.lowercased() } ?? "" + let pathExtension = allExtensions.contains(url.pathExtension.lowercased()) + ? url.pathExtension.lowercased() + : (probedExtension.isEmpty ? url.pathExtension.lowercased() : probedExtension) + let probedType = probe?.mimeType let isAudio = isAudioURL(url) + || audioExtensions.contains(probedExtension) + || (probedType?.hasPrefix("audio/") ?? false) let isStreaming = isStreamingURL(url) + || streamingExtensions.contains(probedExtension) + || isStreamingType(probedType) return Stream( url: url, @@ -96,10 +112,10 @@ enum DirectMediaHelper { videoCodec: isAudio ? nil : "unknown", audioCodec: "unknown", bitrate: nil, - fileSize: nil, + fileSize: probe?.contentLength, isAudioOnly: isAudio, isLive: isStreaming, - mimeType: mimeType(for: pathExtension), + mimeType: mimeType(for: pathExtension) ?? probedType, audioLanguage: nil, audioTrackName: nil, isOriginalAudio: true, @@ -108,10 +124,19 @@ enum DirectMediaHelper { ) } + /// Whether a server-reported media type is an HLS or DASH manifest. + static func isStreamingType(_ mimeType: String?) -> Bool { + guard let mimeType else { return false } + return [ + "application/vnd.apple.mpegurl", "application/x-mpegurl", + "audio/mpegurl", "audio/x-mpegurl", "application/dash+xml" + ].contains(mimeType.lowercased()) + } + // MARK: - MIME Type Mapping /// Returns the MIME type for a file extension. - private static func mimeType(for extension: String) -> String? { + static func mimeType(for extension: String) -> String? { switch `extension` { case "mp4", "m4v": return "video/mp4" diff --git a/Yattee/Views/Home/OpenLinkSheet.swift b/Yattee/Views/Home/OpenLinkSheet.swift index 5f749cc6..f544b9af 100644 --- a/Yattee/Views/Home/OpenLinkSheet.swift +++ b/Yattee/Views/Home/OpenLinkSheet.swift @@ -754,6 +754,12 @@ struct OpenLinkFormView: View { return (video, [stream]) case .externalVideo, nil: + // A file mpv plays as-is needs no extraction + if let probe = await DirectMediaProbe.probe(url) { + let video = DirectMediaHelper.createVideo(from: url, probe: probe) + let stream = DirectMediaHelper.createStream(from: url, probe: probe) + return (video, [stream]) + } // External URL - use Yattee Server guard let instance = appEnvironment.instancesManager.yatteeServerInstance else { throw OpenLinkError.noYatteeServer @@ -896,6 +902,12 @@ struct OpenLinkFormView: View { return (video, [stream], [], []) case .externalVideo, nil: + // A file mpv plays as-is needs no extraction, no captions/storyboards + if let probe = await DirectMediaProbe.probe(url) { + let video = DirectMediaHelper.createVideo(from: url, probe: probe) + let stream = DirectMediaHelper.createStream(from: url, probe: probe) + return (video, [stream], [], []) + } // External URL - use Yattee Server (doesn't support storyboards) guard let instance = appEnvironment.instancesManager.yatteeServerInstance else { throw OpenLinkError.noYatteeServer diff --git a/Yattee/Views/Video/ExternalVideoView.swift b/Yattee/Views/Video/ExternalVideoView.swift index 6cc15d57..ab9d862d 100644 --- a/Yattee/Views/Video/ExternalVideoView.swift +++ b/Yattee/Views/Video/ExternalVideoView.swift @@ -59,6 +59,18 @@ struct ExternalVideoView: View { isLoading = true errorMessage = nil + // A file mpv can play as-is (debrid/CDN download links with an opaque + // path) needs no extraction and no Yattee Server. + if let probe = await DirectMediaProbe.probe(url) { + let video = DirectMediaHelper.createVideo(from: url, probe: probe) + let stream = DirectMediaHelper.createStream(from: url, probe: probe) + await MainActor.run { + shouldDismissWhenPlayerExpands = true + appEnvironment.playerService.openVideo(video, stream: stream) + } + return + } + // Find a Yattee Server instance guard let instance = appEnvironment.instancesManager.yatteeServerInstance else { errorMessage = String(localized: "externalVideo.noYatteeServer") diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index 9a91d2a0..1f6a68ba 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -755,6 +755,19 @@ struct YatteeApp: App { /// Download an external video from a deep link. private func downloadExternalVideoFromDeepLink(url: URL) async { + // A file mpv plays as-is can be downloaded straight from its URL + if let probe = await DirectMediaProbe.probe(url) { + let video = DirectMediaHelper.createVideo(from: url, probe: probe) + let stream = DirectMediaHelper.createStream(from: url, probe: probe) + await MainActor.run { + deepLinkVideo = video + deepLinkStreams = [stream] + deepLinkCaptions = [] + showingDeepLinkDownloadSheet = true + } + return + } + guard let instance = appEnvironment.instancesManager.yatteeServerInstance else { // No Yattee Server - fall back to open appEnvironment.navigationCoordinator.navigate(to: .externalVideo(url)) diff --git a/YatteeTests/DirectMediaProbeTests.swift b/YatteeTests/DirectMediaProbeTests.swift new file mode 100644 index 00000000..de0f9406 --- /dev/null +++ b/YatteeTests/DirectMediaProbeTests.swift @@ -0,0 +1,120 @@ +// +// DirectMediaProbeTests.swift +// YatteeTests +// + +import Testing +import Foundation +@testable import Yattee + +@Suite("DirectMediaProbe Tests") +struct DirectMediaProbeTests { + private typealias Headers = DirectMediaProbe.ResponseHeaders + + @Test("video/* Content-Type is media, filename comes from Content-Disposition") + func videoContentType() { + let headers = Headers( + contentType: "video/x-matroska", + contentDisposition: "attachment; filename=\"Ted Lasso S04E05 1080p.mkv\"", + contentLength: 3_847_537_440 + ) + let expected = DirectMediaProbeResult( + filename: "Ted Lasso S04E05 1080p.mkv", + mimeType: "video/x-matroska", + contentLength: 3_847_537_440 + ) + #expect(DirectMediaProbe.classify(headers) == .media(expected)) + } + + @Test("text/html is not media") + func htmlIsNotMedia() { + #expect(DirectMediaProbe.classify(Headers(contentType: "text/html; charset=utf-8")) == .notMedia) + } + + @Test("octet-stream without filename needs a sniff") + func octetStreamUnknown() { + #expect(DirectMediaProbe.classify(Headers(contentType: "application/octet-stream")) == .unknown) + #expect(DirectMediaProbe.classify(Headers(contentType: nil)) == .unknown) + #expect(DirectMediaProbe.classify(Headers(statusCode: 405)) == .unknown) + } + + @Test("octet-stream with a media filename is media") + func octetStreamWithFilename() { + let headers = Headers( + contentType: "application/octet-stream", + contentDisposition: "attachment; filename*=UTF-8''Episode%2001.mp4" + ) + guard case .media(let result) = DirectMediaProbe.classify(headers) else { + Issue.record("Expected media"); return + } + #expect(result.filename == "Episode 01.mp4") + #expect(result.mimeType == "video/mp4") + } + + @Test("HLS manifest types are media") + func hlsTypes() { + #expect(DirectMediaProbe.isPlayableMediaType("application/vnd.apple.mpegurl")) + #expect(DirectMediaProbe.isPlayableMediaType("application/dash+xml")) + #expect(!DirectMediaProbe.isPlayableMediaType("application/json")) + } + + @Test("Content-Disposition parsing prefers filename* and strips paths") + func contentDisposition() { + #expect(DirectMediaProbe.filename(fromContentDisposition: "inline; filename=\"a.mkv\"; filename*=UTF-8''b%20c.mkv") == "b c.mkv") + #expect(DirectMediaProbe.filename(fromContentDisposition: "attachment; filename=../../evil.mp4") == "evil.mp4") + #expect(DirectMediaProbe.filename(fromContentDisposition: "attachment") == nil) + #expect(DirectMediaProbe.filename(fromContentDisposition: nil) == nil) + } + + @Test("Content-Range total wins over Content-Length") + func contentRangeTotal() { + let headers = Headers(statusCode: 206, contentLength: 64, contentRange: "bytes 0-63/1000") + #expect(headers.totalLength == 1000) + } + + @Test("Magic bytes identify containers") + func magicBytes() { + let mkv = Data([0x1A, 0x45, 0xDF, 0xA3, 0xA3, 0x42, 0x86, 0x81]) + Data("matroska".utf8) + #expect(DirectMediaProbe.sniffMimeType(mkv) == "video/x-matroska") + let webm = Data([0x1A, 0x45, 0xDF, 0xA3, 0x9F, 0x42, 0x86, 0x81, 0x01, 0x42, 0x82, 0x84]) + Data("webm".utf8) + #expect(DirectMediaProbe.sniffMimeType(webm) == "video/webm") + let mp4 = Data([0, 0, 0, 0x18]) + Data("ftypisom".utf8) + Data(repeating: 0, count: 8) + #expect(DirectMediaProbe.sniffMimeType(mp4) == "video/mp4") + let m4a = Data([0, 0, 0, 0x18]) + Data("ftypM4A ".utf8) + #expect(DirectMediaProbe.sniffMimeType(m4a) == "audio/mp4") + #expect(DirectMediaProbe.sniffMimeType(Data("ID3\u{03}".utf8)) == "audio/mpeg") + #expect(DirectMediaProbe.sniffMimeType(Data("fLaC".utf8)) == "audio/flac") + #expect(DirectMediaProbe.sniffMimeType(Data("RIFF....AVI ".utf8)) == "video/x-msvideo") + #expect(DirectMediaProbe.sniffMimeType(Data("#EXTM3U\n#EXT-X-VERSION:3".utf8)) == "application/vnd.apple.mpegurl") + var ts = Data(repeating: 0, count: 200); ts[0] = 0x47; ts[188] = 0x47 + #expect(DirectMediaProbe.sniffMimeType(ts) == "video/mp2t") + #expect(DirectMediaProbe.sniffMimeType(Data("".utf8)) == nil) + #expect(DirectMediaProbe.sniffMimeType(Data("{\"a\":1}".utf8)) == nil) + } + + @Test("Probe result drives Video title and Stream metadata for opaque URLs") + func helperUsesProbe() { + let url = URL(string: "https://store-073.wnam.tb-cdn.io/dld/854b43c0-41bb?token=abc")! + let probe = DirectMediaProbeResult(filename: "Show S01E01.mkv", mimeType: "video/x-matroska", contentLength: 42) + let video = DirectMediaHelper.createVideo(from: url, probe: probe) + #expect(video.title == "Show S01E01") + #expect(!video.isLive) + let stream = DirectMediaHelper.createStream(from: url, probe: probe) + #expect(stream.format == "mkv") + #expect(stream.mimeType == "video/x-matroska") + #expect(stream.fileSize == 42) + #expect(!stream.isAudioOnly) + + let audio = DirectMediaHelper.createStream(from: url, probe: DirectMediaProbeResult(filename: nil, mimeType: "audio/flac", contentLength: nil)) + #expect(audio.isAudioOnly) + let hls = DirectMediaHelper.createVideo(from: url, probe: DirectMediaProbeResult(filename: nil, mimeType: "application/vnd.apple.mpegurl", contentLength: nil)) + #expect(hls.isLive) + } + + @Test("Without a probe the path extension still rules") + func helperWithoutProbe() { + let url = URL(string: "https://example.com/clip.mp4")! + #expect(DirectMediaHelper.createVideo(from: url).title == "clip") + #expect(DirectMediaHelper.createStream(from: url).format == "mp4") + } +}