mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-31 04:58:11 +00:00
fix: package bundled media engines
This commit is contained in:
@@ -63,16 +63,18 @@ jobs:
|
|||||||
- name: Fetch media engines
|
- name: Fetch media engines
|
||||||
run: |
|
run: |
|
||||||
mkdir -p Sources/Firelink
|
mkdir -p Sources/Firelink
|
||||||
|
|
||||||
# Download latest yt-dlp for macOS
|
# Download latest yt-dlp for macOS
|
||||||
curl -sL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o Sources/Firelink/yt-dlp
|
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o Sources/Firelink/yt-dlp
|
||||||
chmod +x Sources/Firelink/yt-dlp
|
chmod +x Sources/Firelink/yt-dlp
|
||||||
|
|
||||||
# Download ffmpeg (Martin Riedl's static macOS ARM64 build)
|
# Download latest FFmpeg release build for macOS ARM64 from Martin Riedl's build server
|
||||||
curl -sL "https://ffmpeg.martin-riedl.de/download/macos/arm64/1779741465_9_1_1/ffmpeg.zip" -o ffmpeg.zip
|
curl -fsSL "https://ffmpeg.martin-riedl.de/redirect/latest/macos/arm64/release/ffmpeg.zip" -o ffmpeg.zip
|
||||||
unzip -q -o ffmpeg.zip -d Sources/Firelink
|
unzip -q -o ffmpeg.zip -d Sources/Firelink
|
||||||
rm ffmpeg.zip
|
rm ffmpeg.zip
|
||||||
chmod +x Sources/Firelink/ffmpeg
|
chmod +x Sources/Firelink/ffmpeg
|
||||||
|
test -x Sources/Firelink/yt-dlp
|
||||||
|
test -x Sources/Firelink/ffmpeg
|
||||||
|
|
||||||
- name: Build app bundle
|
- name: Build app bundle
|
||||||
env:
|
env:
|
||||||
@@ -106,7 +108,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
|
awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
|
||||||
|
|
||||||
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
|
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
|
||||||
gh release upload "$GITHUB_REF_NAME" dist/*.dmg --clobber
|
gh release upload "$GITHUB_REF_NAME" dist/*.dmg --clobber
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ SparklePrivateKey*
|
|||||||
private-key*
|
private-key*
|
||||||
private_key*
|
private_key*
|
||||||
yt-dlp
|
yt-dlp
|
||||||
|
ffmpeg
|
||||||
|
|||||||
+1
-1
Submodule Extensions/Firefox updated: c45809038e...2b24d69977
@@ -44,7 +44,7 @@
|
|||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- ⚡ **Multi-Segmented Engine:** Ultra-fast parallel downloading powered by `aria2c`.
|
- ⚡ **Multi-Segmented Engine:** Ultra-fast parallel downloading powered by `aria2c`.
|
||||||
- 🪄 **Media Downloader:** Instantly extract high-quality audio and video formats (4K, 1080p, MP3) from sites like YouTube and Twitter—backed securely by `yt-dlp` and `ffmpeg` via our Add-on Gatekeeper.
|
- 🪄 **Media Downloader:** Instantly extract high-quality audio and video formats (4K, 1080p, MP3) from sites like YouTube and Twitter, backed by bundled `yt-dlp` and `ffmpeg` engines.
|
||||||
- 🎨 **Premium Native UI:** Responsive, frosted-glass SwiftUI design tailor-made for Apple Silicon.
|
- 🎨 **Premium Native UI:** Responsive, frosted-glass SwiftUI design tailor-made for Apple Silicon.
|
||||||
- 🌐 **Seamless Integration:** Send links directly from your browser with the Firelink Companion extension.
|
- 🌐 **Seamless Integration:** Send links directly from your browser with the Firelink Companion extension.
|
||||||
- 🎯 **Visual Chunk Map:** Monitor active segment connections and download progress in real time.
|
- 🎯 **Visual Chunk Map:** Monitor active segment connections and download progress in real time.
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ cp "$ROOT_DIR/Resources/$ICON_NAME.icns" "$RESOURCES_DIR/$ICON_NAME.icns"
|
|||||||
cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png"
|
cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png"
|
||||||
cp "$ROOT_DIR/Resources/GitHubTemplate.png" "$RESOURCES_DIR/GitHubTemplate.png"
|
cp "$ROOT_DIR/Resources/GitHubTemplate.png" "$RESOURCES_DIR/GitHubTemplate.png"
|
||||||
|
|
||||||
|
for media_engine in yt-dlp ffmpeg; do
|
||||||
|
media_engine_path="$ROOT_DIR/Sources/Firelink/$media_engine"
|
||||||
|
if [[ -x "$media_engine_path" ]]; then
|
||||||
|
cp "$media_engine_path" "$RESOURCES_DIR/$media_engine"
|
||||||
|
chmod +x "$RESOURCES_DIR/$media_engine"
|
||||||
|
else
|
||||||
|
echo "WARNING: $media_engine not found or not executable at $media_engine_path"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
echo "Packaging Firefox extension..."
|
echo "Packaging Firefox extension..."
|
||||||
mkdir -p "$RESOURCES_DIR/FirefoxExtension"
|
mkdir -p "$RESOURCES_DIR/FirefoxExtension"
|
||||||
cp "$ROOT_DIR/Extensions/Firefox/background.js" "$RESOURCES_DIR/FirefoxExtension/background.js"
|
cp "$ROOT_DIR/Extensions/Firefox/background.js" "$RESOURCES_DIR/FirefoxExtension/background.js"
|
||||||
|
|||||||
@@ -208,26 +208,6 @@ final class DownloadController: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func addMediaDownload(_ item: DownloadItem, startImmediately: Bool) {
|
|
||||||
var item = item
|
|
||||||
item.fileName = FileClassifier.sanitizedFileName(item.fileName)
|
|
||||||
item.category = FileClassifier.category(forFileName: item.fileName)
|
|
||||||
item.speedLimitKiBPerSecond = normalizedSpeedLimit(item.speedLimitKiBPerSecond)
|
|
||||||
item.queueID = normalizedQueueID(item.queueID ?? DownloadQueue.mainQueueID)
|
|
||||||
|
|
||||||
if let password = item.credentials?.password, !password.isEmpty {
|
|
||||||
KeychainCredentialStore.setPassword(password, for: item.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloads.append(item)
|
|
||||||
engineMessage = "Added \(item.fileName) to \(item.category.rawValue)."
|
|
||||||
saveDownloads()
|
|
||||||
|
|
||||||
if startImmediately {
|
|
||||||
startQueue(queueID: item.queueID ?? DownloadQueue.mainQueueID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func startQueue(queueID: UUID? = nil) {
|
func startQueue(queueID: UUID? = nil) {
|
||||||
engineMessage = ""
|
engineMessage = ""
|
||||||
restrictQueueToAutoResume = false
|
restrictQueueToAutoResume = false
|
||||||
@@ -534,7 +514,7 @@ final class DownloadController: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
update(item.id) {
|
update(item.id) {
|
||||||
guard $0.status == .downloading else { return }
|
guard $0.status == .downloading else { return }
|
||||||
$0.message = "Checking media add-ons..."
|
$0.message = "Checking bundled media engines..."
|
||||||
}
|
}
|
||||||
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp, .ffmpeg])
|
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp, .ffmpeg])
|
||||||
guard let liveItem = activeDownloadItem(id: item.id) else { return }
|
guard let liveItem = activeDownloadItem(id: item.id) else { return }
|
||||||
|
|||||||
@@ -8,15 +8,11 @@ final class LocalExtensionServer: @unchecked Sendable {
|
|||||||
static let maxRequestBytes = 128 * 1024
|
static let maxRequestBytes = 128 * 1024
|
||||||
static let maxURLCount = 200
|
static let maxURLCount = 200
|
||||||
static let extensionRequestHeader = "x-firelink-extension"
|
static let extensionRequestHeader = "x-firelink-extension"
|
||||||
|
|
||||||
// IMPORTANT(Backward Compatibility):
|
// Firelink Companion 1.0.7+ sends this token. Keep accepted tokens here
|
||||||
// Extension updates on Mozilla/Chrome stores can take several days to be approved.
|
// when future store releases need a non-breaking local API transition.
|
||||||
// Therefore, we MUST NOT introduce breaking changes to the LocalExtensionServer API
|
|
||||||
// without maintaining backward compatibility for older extensions.
|
|
||||||
// If you need to update the API (e.g., changing the payload structure), add the new
|
|
||||||
// token here and handle both versions in `processRequest`.
|
|
||||||
static let supportedExtensionTokens = Set(["firelink-extension-v1"])
|
static let supportedExtensionTokens = Set(["firelink-extension-v1"])
|
||||||
|
|
||||||
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
|
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
|||||||
let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg)
|
let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg)
|
||||||
|
|
||||||
guard let ytDlpURL, FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
guard let ytDlpURL, FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
||||||
throw EngineError.missingEngine("yt-dlp is not installed. Please check Settings > Add-ons.")
|
throw EngineError.missingEngine("The bundled yt-dlp executable is missing. Reinstall Firelink or rebuild the app bundle.")
|
||||||
}
|
}
|
||||||
guard let ffmpegURL, FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
|
guard let ffmpegURL, FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
|
||||||
throw EngineError.missingEngine("ffmpeg is not installed. Please check Settings > Add-ons.")
|
throw EngineError.missingEngine("The bundled FFmpeg executable is missing. Reinstall Firelink or rebuild the app bundle.")
|
||||||
}
|
}
|
||||||
|
|
||||||
try FileManager.default.createDirectory(at: item.destinationDirectory, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: item.destinationDirectory, withIntermediateDirectories: true)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import Combine
|
|||||||
|
|
||||||
enum AddonState: Equatable, Sendable {
|
enum AddonState: Equatable, Sendable {
|
||||||
case notInstalled
|
case notInstalled
|
||||||
case downloading(progress: Double)
|
|
||||||
case installed(version: String)
|
case installed(version: String)
|
||||||
case failed(error: String)
|
case failed(error: String)
|
||||||
}
|
}
|
||||||
@@ -32,9 +31,11 @@ final class MediaEngineManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func binaryPath(for addon: AddonType) -> URL? {
|
func binaryPath(for addon: AddonType) -> URL? {
|
||||||
if let bundled = Bundle.main.url(forResource: addon.binaryName, withExtension: nil),
|
for bundle in [Bundle.main, Bundle.module] {
|
||||||
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
if let bundled = bundle.url(forResource: addon.binaryName, withExtension: nil),
|
||||||
return bundled
|
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
||||||
|
return bundled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -55,18 +56,18 @@ final class MediaEngineManager: ObservableObject {
|
|||||||
switch state(for: addon) {
|
switch state(for: addon) {
|
||||||
case .installed:
|
case .installed:
|
||||||
return false
|
return false
|
||||||
case .downloading, .notInstalled, .failed:
|
case .notInstalled, .failed:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard !missingAddons.isEmpty else { return }
|
guard !missingAddons.isEmpty else { return }
|
||||||
|
|
||||||
for missing in missingAddons {
|
for missing in missingAddons {
|
||||||
setState(for: missing, to: .failed(error: "Bundled executable missing"))
|
setState(for: missing, to: .failed(error: "Bundled executable missing"))
|
||||||
}
|
}
|
||||||
|
|
||||||
throw NSError(domain: "MediaEngineErrorDomain", code: 1, userInfo: [NSLocalizedDescriptionKey: "One or more required media engines are missing from the app bundle."])
|
throw NSError(domain: "MediaEngineErrorDomain", code: 1, userInfo: [NSLocalizedDescriptionKey: "One or more required media engines are missing from the app bundle. Reinstall Firelink or rebuild the app bundle."])
|
||||||
}
|
}
|
||||||
|
|
||||||
private func state(for addon: AddonType) -> AddonState {
|
private func state(for addon: AddonType) -> AddonState {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ enum MediaExtractionEngine {
|
|||||||
case .processFailed(let msg): return "Extraction failed: \(msg)"
|
case .processFailed(let msg): return "Extraction failed: \(msg)"
|
||||||
case .invalidOutput: return "Invalid output from media engine."
|
case .invalidOutput: return "Invalid output from media engine."
|
||||||
case .parsingFailed(let err): return "Failed to parse metadata: \(err.localizedDescription)"
|
case .parsingFailed(let err): return "Failed to parse metadata: \(err.localizedDescription)"
|
||||||
case .timedOut: return "Fetching metadata timed out. Try again, update yt-dlp, or change the selected browser cookie source."
|
case .timedOut: return "Fetching metadata timed out. Try again or change the selected browser cookie source."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,268 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct MediaInspectorInlineView: View {
|
|
||||||
let url: URL
|
|
||||||
let cookieSource: BrowserCookieSource
|
|
||||||
let credentials: DownloadCredentials?
|
|
||||||
let transferOptions: DownloadTransferOptions
|
|
||||||
let onCancel: () -> Void
|
|
||||||
let onDownload: (CleanFormatOption, MediaMetadata) -> Void
|
|
||||||
|
|
||||||
@ObservedObject private var engineManager = MediaEngineManager.shared
|
|
||||||
|
|
||||||
@State private var isLoading = true
|
|
||||||
@State private var statusText = "Checking Media Engine..."
|
|
||||||
@State private var metadata: MediaMetadata?
|
|
||||||
@State private var options: [CleanFormatOption] = []
|
|
||||||
@State private var errorMessage: String?
|
|
||||||
@State private var loadTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
enum MediaType: String, CaseIterable, Identifiable {
|
|
||||||
case video = "Video"
|
|
||||||
case audio = "Audio"
|
|
||||||
var id: String { rawValue }
|
|
||||||
}
|
|
||||||
|
|
||||||
@State private var selectedType: MediaType = .video
|
|
||||||
@State private var selectedVideoQuality: String = "Best"
|
|
||||||
@State private var selectedVideoFormat: String = "MP4"
|
|
||||||
@State private var selectedAudioFormat: String = "MP3"
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 16) {
|
|
||||||
if isLoading {
|
|
||||||
ProgressView()
|
|
||||||
.controlSize(.regular)
|
|
||||||
|
|
||||||
let ytState = engineManager.ytDlpState
|
|
||||||
let ffState = engineManager.ffmpegState
|
|
||||||
|
|
||||||
if case let .downloading(p) = ytState, p > 0 {
|
|
||||||
Text("Downloading yt-dlp: \(Int(p * 100))%")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else if case let .downloading(p) = ffState, p > 0 {
|
|
||||||
Text("Downloading ffmpeg: \(Int(p * 100))%")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(statusText)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
cookieStatusLabel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
} else if let errorMessage {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.foregroundStyle(.orange)
|
|
||||||
Text(errorMessage)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(2)
|
|
||||||
Spacer()
|
|
||||||
Button("Retry") {
|
|
||||||
loadMetadata()
|
|
||||||
}
|
|
||||||
} else if let metadata {
|
|
||||||
if let thumbnail = metadata.thumbnail {
|
|
||||||
AsyncImage(url: thumbnail) { image in
|
|
||||||
image
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: 80, height: 50)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
|
||||||
.shadow(color: .black.opacity(0.1), radius: 2, y: 1)
|
|
||||||
} placeholder: {
|
|
||||||
RoundedRectangle(cornerRadius: 6)
|
|
||||||
.fill(.quaternary)
|
|
||||||
.frame(width: 80, height: 50)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(metadata.title ?? "Unknown Title")
|
|
||||||
.font(.subheadline.weight(.medium))
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Picker("Type", selection: $selectedType) {
|
|
||||||
ForEach(availableTypes) { type in
|
|
||||||
Text(type.rawValue).tag(type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.frame(width: 80)
|
|
||||||
|
|
||||||
if selectedType == .video {
|
|
||||||
Picker("Quality", selection: $selectedVideoQuality) {
|
|
||||||
ForEach(availableVideoQualities, id: \.self) { q in
|
|
||||||
Text(q).tag(q)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.frame(width: 90)
|
|
||||||
|
|
||||||
Picker("Format", selection: $selectedVideoFormat) {
|
|
||||||
ForEach(availableVideoFormats, id: \.self) { f in
|
|
||||||
Text(f).tag(f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.frame(width: 80)
|
|
||||||
} else {
|
|
||||||
Picker("Format", selection: $selectedAudioFormat) {
|
|
||||||
ForEach(availableAudioFormats, id: \.self) { f in
|
|
||||||
Text(f).tag(f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.frame(width: 90)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let selected = resolveSelectedOption() {
|
|
||||||
Text(selected.detail)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: 16)
|
|
||||||
|
|
||||||
Button("Cancel") {
|
|
||||||
onCancel()
|
|
||||||
}
|
|
||||||
.keyboardShortcut(.cancelAction)
|
|
||||||
|
|
||||||
Button("Extract") {
|
|
||||||
if let selected = resolveSelectedOption() {
|
|
||||||
onDownload(selected, metadata)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(resolveSelectedOption() == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
||||||
.fill(.quaternary.opacity(0.35))
|
|
||||||
)
|
|
||||||
.onAppear {
|
|
||||||
loadMetadata()
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
loadTask?.cancel()
|
|
||||||
loadTask = nil
|
|
||||||
}
|
|
||||||
.onChange(of: url) { _, _ in loadMetadata() }
|
|
||||||
.onChange(of: cookieSource) { _, _ in loadMetadata() }
|
|
||||||
.onChange(of: credentials) { _, _ in loadMetadata() }
|
|
||||||
.onChange(of: transferOptions) { _, _ in loadMetadata() }
|
|
||||||
.onChange(of: selectedType) { _, _ in ensureValidSelection() }
|
|
||||||
.onChange(of: options) { _, _ in ensureValidSelection() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private var availableTypes: [MediaType] {
|
|
||||||
var types: [MediaType] = []
|
|
||||||
if options.contains(where: { !$0.isAudioOnly }) { types.append(.video) }
|
|
||||||
if options.contains(where: { $0.isAudioOnly }) { types.append(.audio) }
|
|
||||||
return types
|
|
||||||
}
|
|
||||||
|
|
||||||
private var availableVideoQualities: [String] {
|
|
||||||
let qualities = options.filter { !$0.isAudioOnly }.map { $0.name.components(separatedBy: " ").first ?? "" }
|
|
||||||
return NSOrderedSet(array: qualities).array as? [String] ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
private var availableVideoFormats: [String] {
|
|
||||||
let formats = options.filter { !$0.isAudioOnly }.map { $0.name.components(separatedBy: " ").last ?? "" }
|
|
||||||
return NSOrderedSet(array: formats).array as? [String] ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
private var availableAudioFormats: [String] {
|
|
||||||
let formats = options.filter { $0.isAudioOnly }.map { $0.name.replacingOccurrences(of: "Audio ", with: "") }
|
|
||||||
return NSOrderedSet(array: formats).array as? [String] ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
private func ensureValidSelection() {
|
|
||||||
if !availableTypes.contains(selectedType), let first = availableTypes.first {
|
|
||||||
selectedType = first
|
|
||||||
}
|
|
||||||
if selectedType == .video {
|
|
||||||
if !availableVideoQualities.contains(selectedVideoQuality), let first = availableVideoQualities.first {
|
|
||||||
selectedVideoQuality = first
|
|
||||||
}
|
|
||||||
if !availableVideoFormats.contains(selectedVideoFormat), let first = availableVideoFormats.first {
|
|
||||||
selectedVideoFormat = first
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if !availableAudioFormats.contains(selectedAudioFormat), let first = availableAudioFormats.first {
|
|
||||||
selectedAudioFormat = first
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolveSelectedOption() -> CleanFormatOption? {
|
|
||||||
if selectedType == .video {
|
|
||||||
return options.first { !$0.isAudioOnly && $0.name == "\(selectedVideoQuality) \(selectedVideoFormat)" }
|
|
||||||
} else {
|
|
||||||
return options.first { $0.isAudioOnly && $0.name == "Audio \(selectedAudioFormat)" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var cookieStatusLabel: some View {
|
|
||||||
if let browserName = cookieSource.ytDlpBrowserName {
|
|
||||||
Label("Using \(browserName.capitalized) cookies", systemImage: "checkmark.circle.fill")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.green)
|
|
||||||
} else {
|
|
||||||
Label("Browser cookies off", systemImage: "circle")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadMetadata() {
|
|
||||||
loadTask?.cancel()
|
|
||||||
isLoading = true
|
|
||||||
errorMessage = nil
|
|
||||||
metadata = nil
|
|
||||||
options = []
|
|
||||||
|
|
||||||
loadTask = Task {
|
|
||||||
do {
|
|
||||||
await MainActor.run { statusText = "Checking yt-dlp..." }
|
|
||||||
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp])
|
|
||||||
guard !Task.isCancelled else { return }
|
|
||||||
|
|
||||||
await MainActor.run { statusText = "Fetching Metadata..." }
|
|
||||||
let (fetchedMetadata, fetchedOptions) = try await MediaExtractionEngine.fetchMetadata(
|
|
||||||
for: url,
|
|
||||||
cookieSource: cookieSource,
|
|
||||||
credentials: credentials,
|
|
||||||
transferOptions: transferOptions
|
|
||||||
)
|
|
||||||
guard !Task.isCancelled else { return }
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
if fetchedOptions.isEmpty {
|
|
||||||
self.errorMessage = "No downloadable media formats were found."
|
|
||||||
} else {
|
|
||||||
self.metadata = fetchedMetadata
|
|
||||||
self.options = fetchedOptions
|
|
||||||
self.ensureValidSelection()
|
|
||||||
}
|
|
||||||
self.loadTask = nil
|
|
||||||
withAnimation { self.isLoading = false }
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await MainActor.run {
|
|
||||||
self.errorMessage = error.localizedDescription
|
|
||||||
self.loadTask = nil
|
|
||||||
withAnimation { self.isLoading = false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,7 @@ struct EngineSettingsPane: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
LabeledContent("Binary Path") {
|
LabeledContent("Binary Path") {
|
||||||
Text(executableURL?.path ?? "Not found")
|
Text(executableURL?.path ?? "Not found")
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
@@ -42,9 +42,9 @@ struct EngineSettingsPane: View {
|
|||||||
|
|
||||||
Section {
|
Section {
|
||||||
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState, path: engineManager.binaryPath(for: .ytDlp))
|
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState, path: engineManager.binaryPath(for: .ytDlp))
|
||||||
|
|
||||||
addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState, path: engineManager.binaryPath(for: .ffmpeg))
|
addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState, path: engineManager.binaryPath(for: .ffmpeg))
|
||||||
|
|
||||||
LabeledContent("Browser Cookies") {
|
LabeledContent("Browser Cookies") {
|
||||||
Picker("", selection: $settings.mediaCookieSource) {
|
Picker("", selection: $settings.mediaCookieSource) {
|
||||||
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
|
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
|
||||||
@@ -59,7 +59,7 @@ struct EngineSettingsPane: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Powers video and audio extraction from supported sites.")
|
Text("Powers video and audio extraction from supported sites.")
|
||||||
|
|
||||||
if settings.mediaCookieSource != .none {
|
if settings.mediaCookieSource != .none {
|
||||||
Text(settings.mediaCookieSource.statusDetail)
|
Text(settings.mediaCookieSource.statusDetail)
|
||||||
}
|
}
|
||||||
@@ -80,8 +80,6 @@ struct EngineSettingsPane: View {
|
|||||||
case .notInstalled:
|
case .notInstalled:
|
||||||
Text("Missing")
|
Text("Missing")
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
case .downloading:
|
|
||||||
Text("Unavailable")
|
|
||||||
case .installed(let version):
|
case .installed(let version):
|
||||||
Text(version)
|
Text(version)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -91,7 +89,7 @@ struct EngineSettingsPane: View {
|
|||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
.help(error)
|
.help(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(path?.path ?? "Not found")
|
Text(path?.path ?? "Not found")
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
|
|||||||
Reference in New Issue
Block a user