Files
yattee/Yattee/Views/Video/ExternalVideoView.swift
Arkadiusz Fal 6efd60a18e 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.
2026-09-21 20:03:06 +02:00

134 lines
4.8 KiB
Swift

//
// ExternalVideoView.swift
// Yattee
//
// Loading view for extracting and playing external site videos via Yattee Server.
//
import SwiftUI
/// View for extracting and playing videos from external sites (non-YouTube/PeerTube).
/// Uses Yattee Server's yt-dlp integration to extract video information.
struct ExternalVideoView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Environment(\.dismiss) private var dismiss
let url: URL
@State private var isLoading = true
@State private var errorMessage: String?
@State private var shouldDismissWhenPlayerExpands = false
var body: some View {
Group {
if isLoading {
LoadingView(
message: String(localized: "externalVideo.extracting"),
subtext: url.host ?? url.absoluteString
)
} else if let error = errorMessage {
ErrorStateView(
title: String(localized: "externalVideo.couldNotExtract"),
message: error,
onRetry: { await extractAndPlay() },
onDismiss: { dismiss() }
)
}
// On success, view dismisses after player expands
}
.task {
await extractAndPlay()
}
.onChange(of: appEnvironment?.navigationCoordinator.isPlayerExpanded) { _, isExpanded in
// Dismiss this view after the player has expanded
if isExpanded == true && shouldDismissWhenPlayerExpands {
dismiss()
}
}
}
// MARK: - Extraction
private func extractAndPlay() async {
guard let appEnvironment else {
errorMessage = String(localized: "externalVideo.appNotReady")
isLoading = false
return
}
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")
isLoading = false
return
}
do {
let (video, _, _) = try await appEnvironment.contentService.extractURL(url, instance: instance)
// Play the video - view will dismiss when player expands
await MainActor.run {
// Set flag to dismiss when player expands
shouldDismissWhenPlayerExpands = true
// Don't pass a specific stream - let the player's selectStreamAndBackend
// choose the best video+audio combination. Using streams.first would
// incorrectly select audio-only streams for sites like Bilibili.
appEnvironment.playerService.openVideo(video)
}
} catch let error as APIError {
isLoading = false
switch error {
case .httpError(let statusCode, let message):
if statusCode == 422 {
errorMessage = message ?? String(localized: "externalVideo.error.unsupported")
} else if statusCode == 400 {
errorMessage = message ?? String(localized: "externalVideo.error.invalidUrl")
} else {
errorMessage = message ?? String(localized: "externalVideo.error.server \(statusCode)")
}
case .decodingError:
errorMessage = String(localized: "externalVideo.error.parsing")
case .noConnection:
errorMessage = String(localized: "externalVideo.error.noConnection")
case .timeout:
errorMessage = String(localized: "externalVideo.error.timeout")
default:
errorMessage = error.localizedDescription
}
} catch {
isLoading = false
errorMessage = error.localizedDescription
}
}
}
// MARK: - InstancesManager Extension
extension InstancesManager {
/// Returns the first enabled Yattee Server instance, if any.
var yatteeServerInstance: Instance? {
instances.first { $0.type == .yatteeServer && $0.isEnabled }
}
}
#Preview {
ExternalVideoView(url: URL(string: "https://vimeo.com/123456")!)
}