mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 05:02:53 +00:00
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.
This commit is contained in:
315
Yattee/Services/Player/DirectMediaProbe.swift
Normal file
315
Yattee/Services/Player/DirectMediaProbe.swift
Normal file
@@ -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[..<eq].trimmingCharacters(in: .whitespaces).lowercased()
|
||||
var raw = trimmed[trimmed.index(after: eq)...].trimmingCharacters(in: .whitespaces)
|
||||
if key == "filename*" {
|
||||
// RFC 5987: charset'lang'percent-encoded
|
||||
if let lastQuote = raw.lastIndex(of: "'") {
|
||||
raw = String(raw[raw.index(after: lastQuote)...])
|
||||
}
|
||||
raw = raw.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||
if let decoded = raw.removingPercentEncoding, !decoded.isEmpty {
|
||||
return (decoded as NSString).lastPathComponent
|
||||
}
|
||||
} else if key == "filename" {
|
||||
raw = raw.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||
if !raw.isEmpty { plain = (raw as NSString).lastPathComponent }
|
||||
}
|
||||
}
|
||||
return plain
|
||||
}
|
||||
|
||||
// MARK: - Payload sniffing
|
||||
|
||||
/// Recognises common container signatures in the first bytes of a file.
|
||||
static func sniffMimeType(_ data: Data) -> 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..<offset + signature.count]) == signature
|
||||
}
|
||||
func ascii(_ s: String) -> [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..<min(12, bytes.count)], encoding: .isoLatin1) ?? ""
|
||||
if brand.hasPrefix("M4A") { return "audio/mp4" }
|
||||
if brand.hasPrefix("qt") { return "video/quicktime" }
|
||||
if brand.hasPrefix("3g") { return "video/3gpp" }
|
||||
return "video/mp4"
|
||||
}
|
||||
if starts(with: ascii("OggS")) { return "application/ogg" }
|
||||
if starts(with: ascii("fLaC")) { return "audio/flac" }
|
||||
if starts(with: ascii("ID3")) { return "audio/mpeg" }
|
||||
if bytes[0] == 0xFF, bytes[1] & 0xE6 == 0xE2 || bytes[1] & 0xF6 == 0xF2 { return "audio/mpeg" } // MPEG audio frame sync
|
||||
if starts(with: ascii("RIFF")) {
|
||||
if starts(with: ascii("AVI "), at: 8) { return "video/x-msvideo" }
|
||||
if starts(with: ascii("WAVE"), at: 8) { return "audio/wav" }
|
||||
return nil
|
||||
}
|
||||
if starts(with: ascii("FLV")) { return "video/x-flv" }
|
||||
if starts(with: [0x30, 0x26, 0xB2, 0x75]) { return "video/x-ms-wmv" } // ASF
|
||||
if starts(with: [0x00, 0x00, 0x01, 0xBA]) { return "video/mpeg" } // MPEG-PS
|
||||
if starts(with: [0x00, 0x00, 0x01, 0xB3]) { return "video/mpeg" } // MPEG-1/2 video ES
|
||||
if starts(with: ascii(".RMF")) { return "application/vnd.rn-realmedia" }
|
||||
// MPEG-TS: sync byte 0x47 repeating every 188 bytes.
|
||||
if bytes[0] == 0x47, bytes.count > 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("<MPD") { return "application/dash+xml" }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -2181,6 +2181,16 @@ final class PlayerService {
|
||||
let stream = try await createStreamForMediaSource(video: video, url: originalURL, extractor: extractor)
|
||||
return (video, [stream], [], [])
|
||||
}
|
||||
|
||||
// Direct media (a file mpv plays as-is): rebuild the stream from the
|
||||
// URL. A probe fills in the format/size for opaque CDN links; if it
|
||||
// fails (offline, expired token) still hand mpv the URL and let it
|
||||
// report the real error.
|
||||
if extractor == DirectMediaHelper.provider {
|
||||
let probe = await DirectMediaProbe.probe(originalURL)
|
||||
let stream = DirectMediaHelper.createStream(from: originalURL, probe: probe)
|
||||
return (video, [stream], [], [])
|
||||
}
|
||||
}
|
||||
|
||||
guard let instance = try await findInstance(for: video) else {
|
||||
|
||||
@@ -58,8 +58,12 @@ enum DirectMediaHelper {
|
||||
// MARK: - Video/Stream Creation
|
||||
|
||||
/// Creates a Video model from a direct media URL.
|
||||
static func createVideo(from url: URL) -> 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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
120
YatteeTests/DirectMediaProbeTests.swift
Normal file
120
YatteeTests/DirectMediaProbeTests.swift
Normal file
@@ -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("<!DOCTYPE html><html>".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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user