mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
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.
134 lines
4.8 KiB
Swift
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")!)
|
|
}
|