mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
feat: implement native system tray, background notifications, and download resumability via tauri-plugin-store
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
with open("src-tauri/src/lib.rs", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
content = content.replace('println!("fetch_media_metadata called for: {}", url);', 'println!("fetch_media_metadata called for: {}", url);')
|
||||
content = content.replace('let err = String::from_utf8_lossy(&output.stderr);', 'let err = String::from_utf8_lossy(&output.stderr); println!("YTDLP ERROR: {}", err);')
|
||||
content = content.replace('let text = String::from_utf8_lossy(&output.stdout).to_string();', 'let text = String::from_utf8_lossy(&output.stdout).to_string(); println!("YTDLP SUCCESS: {}", text.chars().take(200).collect::<String>());')
|
||||
|
||||
with open("src-tauri/src/lib.rs", "w") as f:
|
||||
f.write(content)
|
||||
@@ -0,0 +1,8 @@
|
||||
const fs = require('fs');
|
||||
let content = fs.readFileSync('src/App.tsx', 'utf8');
|
||||
const idx1 = content.indexOf('import { invoke }');
|
||||
const idx2 = content.indexOf('export default function App() {', idx1);
|
||||
if (idx1 !== -1 && idx2 !== -1) {
|
||||
// wait I injected it into App function body, wait where did I inject it?
|
||||
}
|
||||
// I will just git checkout src/App.tsx
|
||||
@@ -0,0 +1,9 @@
|
||||
import re
|
||||
with open("src-tauri/src/lib.rs", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
content = content.replace('let text = String::from_utf8_lossy(&output.stdout).to_string();\n Ok(text)', 'let text = String::from_utf8_lossy(&output.stdout).to_string();\n std::fs::write("/tmp/firelink_out.txt", &text).unwrap();\n Ok(text)')
|
||||
content = content.replace('let err = String::from_utf8_lossy(&output.stderr);\n Err(format!("yt-dlp error: {}", err))', 'let err = String::from_utf8_lossy(&output.stderr);\n std::fs::write("/tmp/firelink_err.txt", &err).unwrap();\n Err(format!("yt-dlp error: {}", err))')
|
||||
|
||||
with open("src-tauri/src/lib.rs", "w") as f:
|
||||
f.write(content)
|
||||
Generated
+10
@@ -14,6 +14,7 @@
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-store": "^2.4.3",
|
||||
"lucide-react": "^1.17.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
@@ -941,6 +942,15 @@
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-store": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.3.tgz",
|
||||
"integrity": "sha512-9LWPj9yMphRi9czEtUv87XHbl1b6xgd9EXpPrUnq6nG7+nbtoF84d4Kwz9xhAv/Hf30sr58pq7EOlyI936y8qw==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-store": "^2.4.3",
|
||||
"lucide-react": "^1.17.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
with open("src-tauri/src/lib.rs", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
content = content.replace('.arg("--js-runtimes").arg(format!("deno:{},node", deno_path.display()));', '.arg("--js-runtimes").arg("deno,node");')
|
||||
|
||||
with open("src-tauri/src/lib.rs", "w") as f:
|
||||
f.write(content)
|
||||
@@ -0,0 +1,30 @@
|
||||
with open("src-tauri/src/lib.rs", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
setup_code = """
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
println!("TESTING FETCH MEDIA METADATA NOW!");
|
||||
let res = fetch_media_metadata(
|
||||
handle,
|
||||
"https://www.youtube.com/watch?v=a769AIuHOdE".to_string(),
|
||||
None, None, None
|
||||
).await;
|
||||
match res {
|
||||
Ok(out) => println!("TEST_SUCCESS: {}", out.chars().take(200).collect::<String>()),
|
||||
Err(e) => println!("TEST_ERROR: {}", e)
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
"""
|
||||
|
||||
if ".setup(|app|" not in content:
|
||||
content = content.replace('.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {', setup_code + ' .plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {')
|
||||
with open("src-tauri/src/lib.rs", "w") as f:
|
||||
f.write(content)
|
||||
print("Injected setup code.")
|
||||
else:
|
||||
print("Setup code already exists?")
|
||||
Generated
+17
-82
@@ -1185,18 +1185,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
@@ -1242,7 +1230,6 @@ dependencies = [
|
||||
"objc",
|
||||
"regex 1.12.4",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1258,6 +1245,7 @@ dependencies = [
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-store",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
@@ -1779,32 +1767,11 @@ dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -2425,17 +2392,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.38.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -3638,31 +3594,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
@@ -4206,18 +4137,6 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"js-sys",
|
||||
"rsqlite-vfs",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -4711,6 +4630,22 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-store"
|
||||
version = "2.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c72dda16786eb4a3f903e43a17b64d8d78dc0f00fe2aa4b757c28f617a8630b"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.2"
|
||||
|
||||
@@ -51,9 +51,9 @@ keepawake = "0.6.0"
|
||||
system_shutdown = "4.1.0"
|
||||
tokio-tungstenite = "0.29.0"
|
||||
futures-util = { version = "0.3.32", features = ["sink"] }
|
||||
rusqlite = { version = "0.40.1", features = ["bundled"] }
|
||||
chrono = "0.4.38"
|
||||
url = "2"
|
||||
tauri-plugin-store = "2.4.3"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cocoa = "0.25"
|
||||
objc = "0.2.7"
|
||||
|
||||
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,112 +0,0 @@
|
||||
use rusqlite::{Connection, Result, params};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
pub struct DbState {
|
||||
pub conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
pub fn init_db(app_handle: &tauri::AppHandle) -> Result<Connection> {
|
||||
let app_dir = app_handle.path().app_data_dir().expect("Cannot get app data dir");
|
||||
if !app_dir.exists() {
|
||||
let _ = std::fs::create_dir_all(&app_dir);
|
||||
}
|
||||
let db_path = app_dir.join("firelink.sqlite");
|
||||
let conn = Connection::open(db_path)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS downloads (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
queue_id TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
data TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS queues (
|
||||
id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
// Downloads CRUD
|
||||
pub fn insert_download(conn: &Connection, id: &str, status: &str, queue_id: &str, data: &str) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO downloads (id, status, queue_id, data) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![id, status, queue_id, data],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_download(conn: &Connection, id: &str) -> Result<()> {
|
||||
conn.execute("DELETE FROM downloads WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_all_downloads(conn: &Connection) -> Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare("SELECT data FROM downloads")?;
|
||||
let iter = stmt.query_map([], |row| row.get(0))?;
|
||||
let mut res = Vec::new();
|
||||
for data in iter {
|
||||
res.push(data?);
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
// Settings CRUD
|
||||
pub fn get_settings(conn: &Connection) -> Result<Option<String>> {
|
||||
let mut stmt = conn.prepare("SELECT data FROM settings WHERE id = 1")?;
|
||||
let mut iter = stmt.query_map([], |row| row.get(0))?;
|
||||
if let Some(row) = iter.next() {
|
||||
Ok(Some(row?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_settings(conn: &Connection, data: &str) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (id, data) VALUES (1, ?1)",
|
||||
params![data],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Queues CRUD
|
||||
pub fn insert_queue(conn: &Connection, id: &str, data: &str) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO queues (id, data) VALUES (?1, ?2)",
|
||||
params![id, data],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_queue(conn: &Connection, id: &str) -> Result<()> {
|
||||
conn.execute("DELETE FROM queues WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_all_queues(conn: &Connection) -> Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare("SELECT data FROM queues")?;
|
||||
let iter = stmt.query_map([], |row| row.get(0))?;
|
||||
let mut res = Vec::new();
|
||||
for data in iter {
|
||||
res.push(data?);
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
+55
-61
@@ -601,6 +601,8 @@ async fn start_download(
|
||||
options.insert("max-connection-per-server".to_string(), serde_json::json!(conn.to_string()));
|
||||
options.insert("max-tries".to_string(), serde_json::json!(mt.to_string()));
|
||||
|
||||
options.insert("continue".to_string(), serde_json::json!("true"));
|
||||
|
||||
if let Some(speed) = &speed_limit {
|
||||
options.insert("max-download-limit".to_string(), serde_json::json!(speed));
|
||||
}
|
||||
@@ -780,9 +782,10 @@ pub(crate) async fn start_media_download_internal(
|
||||
.arg("--retries").arg("3")
|
||||
.arg("--extractor-retries").arg("3")
|
||||
.arg("--downloader").arg("aria2c")
|
||||
.arg("--downloader-args").arg("aria2c:-x 16 -s 16 -k 1M")
|
||||
.arg("--downloader-args").arg("aria2c:-c -x 16 -s 16 -k 1M")
|
||||
.arg("--concurrent-fragments").arg("4")
|
||||
.arg("--no-warnings")
|
||||
.arg("--continue")
|
||||
.arg("--compat-options").arg("no-youtube-unavailable-videos")
|
||||
.arg("-o").arg(out_path.to_string_lossy().to_string());
|
||||
|
||||
@@ -941,6 +944,8 @@ pub(crate) async fn start_media_download_internal(
|
||||
println!("child exit status: {:?}", payload.code);
|
||||
if payload.code == Some(0) {
|
||||
let _ = app_handle.emit("download-complete", id.to_string());
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
let _ = app_handle.notification().builder().title("Download Complete").body(&safe_filename).show();
|
||||
} else {
|
||||
let _ = app_handle.emit("download-failed", id.to_string());
|
||||
}
|
||||
@@ -1362,6 +1367,49 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.manage(Aria2DaemonGuard(std::sync::Mutex::new(None)))
|
||||
.setup(move |app| {
|
||||
use tauri::tray::{TrayIconBuilder, MouseButton, MouseButtonState, TrayIconEvent};
|
||||
use tauri::menu::{Menu, MenuItem};
|
||||
|
||||
let show_i = MenuItem::with_id(app, "show", "Show Firelink", true, None::<&str>).unwrap();
|
||||
let pause_all_i = MenuItem::with_id(app, "pause_all", "Pause All", true, None::<&str>).unwrap();
|
||||
let resume_all_i = MenuItem::with_id(app, "resume_all", "Resume All", true, None::<&str>).unwrap();
|
||||
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>).unwrap();
|
||||
let menu = Menu::with_items(app, &[&show_i, &pause_all_i, &resume_all_i, &quit_i]).unwrap();
|
||||
|
||||
let tray_icon = tauri::image::Image::from_bytes(include_bytes!("../icons/trayTemplate.png")).unwrap();
|
||||
let _tray = TrayIconBuilder::with_id("main_startup")
|
||||
.icon(tray_icon)
|
||||
.icon_as_template(true)
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"show" => { restore_main_window(app); }
|
||||
"pause_all" => {
|
||||
use tauri::Emitter;
|
||||
let _ = app.emit("tray-action", "pause-all");
|
||||
}
|
||||
"resume_all" => {
|
||||
use tauri::Emitter;
|
||||
let _ = app.emit("tray-action", "resume-all");
|
||||
}
|
||||
"quit" => { app.exit(0); }
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if matches!(
|
||||
event,
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
}
|
||||
) {
|
||||
restore_main_window(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)
|
||||
.unwrap();
|
||||
|
||||
let aria2_gids = Arc::new(RwLock::new(std::collections::HashMap::new()));
|
||||
let aria2_gids_clone1 = aria2_gids.clone();
|
||||
let aria2_gids_clone2 = aria2_gids.clone();
|
||||
@@ -1385,9 +1433,6 @@ pub fn run() {
|
||||
Ok(None) => {}
|
||||
Err(error) => eprintln!("Failed to read startup deep link: {error}"),
|
||||
}
|
||||
let db_conn = crate::db::init_db(app.handle()).expect("Failed to init db");
|
||||
app.manage(crate::db::DbState { conn: tokio::sync::Mutex::new(db_conn) });
|
||||
|
||||
crate::scheduler::spawn_scheduler(app.handle().clone());
|
||||
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
@@ -1446,7 +1491,9 @@ pub fn run() {
|
||||
use tauri::Emitter;
|
||||
match method {
|
||||
"aria2.onDownloadComplete" => {
|
||||
let _ = app_handle_ws.emit("download-complete", id);
|
||||
let _ = app_handle_ws.emit("download-complete", id.clone());
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
let _ = app_handle_ws.notification().builder().title("Download Complete").body(&format!("File downloaded successfully")).show();
|
||||
}
|
||||
"aria2.onDownloadError" => {
|
||||
let _ = app_handle_ws.emit("download-failed", id);
|
||||
@@ -1529,6 +1576,7 @@ pub fn run() {
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
@@ -1536,10 +1584,8 @@ pub fn run() {
|
||||
.on_window_event(|window, event| {
|
||||
if window.label() == "main" {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
if window.app_handle().tray_by_id("main").is_some() {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1552,62 +1598,10 @@ pub fn run() {
|
||||
check_file_exists, delete_file, toggle_tray_icon, set_extension_pairing_token,
|
||||
set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download,
|
||||
parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, parity::get_supported_media_domains,
|
||||
db_save_settings, db_load_settings, db_get_all_downloads, db_save_download, db_delete_download, db_get_all_queues, db_save_queue, db_delete_queue,
|
||||
parity::create_category_directories
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
mod extension_server;
|
||||
mod db;
|
||||
mod scheduler;
|
||||
|
||||
#[tauri::command]
|
||||
async fn db_save_settings(state: tauri::State<'_, crate::db::DbState>, data: String) -> Result<(), String> {
|
||||
let conn = state.conn.lock().await;
|
||||
crate::db::save_settings(&conn, &data).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn db_load_settings(state: tauri::State<'_, crate::db::DbState>) -> Result<Option<String>, String> {
|
||||
let conn = state.conn.lock().await;
|
||||
crate::db::get_settings(&conn).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn db_get_all_downloads(state: tauri::State<'_, crate::db::DbState>) -> Result<Vec<String>, String> {
|
||||
let conn = state.conn.lock().await;
|
||||
crate::db::get_all_downloads(&conn).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn db_save_download(state: tauri::State<'_, crate::db::DbState>, id: String, status: crate::ipc::DownloadStatus, queue_id: String, data: String) -> Result<(), String> {
|
||||
let conn = state.conn.lock().await;
|
||||
crate::db::insert_download(&conn, &id, status.as_str(), &queue_id, &data)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn db_delete_download(state: tauri::State<'_, crate::db::DbState>, id: String) -> Result<(), String> {
|
||||
let conn = state.conn.lock().await;
|
||||
crate::db::delete_download(&conn, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn db_get_all_queues(state: tauri::State<'_, crate::db::DbState>) -> Result<Vec<String>, String> {
|
||||
let conn = state.conn.lock().await;
|
||||
crate::db::get_all_queues(&conn).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn db_save_queue(state: tauri::State<'_, crate::db::DbState>, id: String, data: String) -> Result<(), String> {
|
||||
let conn = state.conn.lock().await;
|
||||
crate::db::insert_queue(&conn, &id, &data).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn db_delete_queue(state: tauri::State<'_, crate::db::DbState>, id: String) -> Result<(), String> {
|
||||
let conn = state.conn.lock().await;
|
||||
crate::db::delete_queue(&conn, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,13 @@ pub fn spawn_scheduler(app_handle: tauri::AppHandle) {
|
||||
interval.tick().await;
|
||||
|
||||
let settings_opt = {
|
||||
let state = app_handle.state::<crate::db::DbState>();
|
||||
let conn = state.conn.lock().await;
|
||||
crate::db::get_settings(&conn).unwrap_or(None)
|
||||
use tauri_plugin_store::StoreExt;
|
||||
let store = app_handle.store("store.bin");
|
||||
if let Ok(store) = store {
|
||||
store.get("settings").and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(settings_str) = settings_opt {
|
||||
@@ -45,9 +49,11 @@ pub fn spawn_scheduler(app_handle: tauri::AppHandle) {
|
||||
let _ = app_handle.emit("schedule-trigger", "start");
|
||||
|
||||
if let Ok(updated) = serde_json::to_string(&settings) {
|
||||
let state = app_handle.state::<crate::db::DbState>();
|
||||
let conn = state.conn.lock().await;
|
||||
let _ = crate::db::save_settings(&conn, &updated);
|
||||
use tauri_plugin_store::StoreExt;
|
||||
if let Ok(store) = app_handle.store("store.bin") {
|
||||
store.set("settings", serde_json::json!(updated));
|
||||
let _ = store.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,9 +64,11 @@ pub fn spawn_scheduler(app_handle: tauri::AppHandle) {
|
||||
let _ = app_handle.emit("schedule-trigger", "stop");
|
||||
|
||||
if let Ok(updated) = serde_json::to_string(&settings) {
|
||||
let state = app_handle.state::<crate::db::DbState>();
|
||||
let conn = state.conn.lock().await;
|
||||
let _ = crate::db::save_settings(&conn, &updated);
|
||||
use tauri_plugin_store::StoreExt;
|
||||
if let Ok(store) = app_handle.store("store.bin") {
|
||||
store.set("settings", serde_json::json!(updated));
|
||||
let _ = store.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export const useDownloadProgressStore = create<DownloadProgressState>((set) => (
|
||||
}));
|
||||
|
||||
let unlistenProgress: UnlistenFn | null = null;
|
||||
let unlistenTray: UnlistenFn | null = null;
|
||||
|
||||
export async function initDownloadListener() {
|
||||
if (unlistenProgress) return;
|
||||
@@ -38,4 +39,17 @@ export async function initDownloadListener() {
|
||||
mainStore.updateDownload(payload.id, { size: payload.size });
|
||||
}
|
||||
});
|
||||
|
||||
if (!unlistenTray) {
|
||||
unlistenTray = await listen<string>('tray-action', (event) => {
|
||||
const mainStore = useDownloadStore.getState();
|
||||
if (event.payload === 'pause-all') {
|
||||
const uniqueQueues = Array.from(new Set(mainStore.downloads.map(d => d.queueId)));
|
||||
uniqueQueues.forEach(qid => mainStore.pauseQueue(qid));
|
||||
} else if (event.payload === 'resume-all') {
|
||||
const uniqueQueues = Array.from(new Set(mainStore.downloads.map(d => d.queueId)));
|
||||
uniqueQueues.forEach(qid => mainStore.startQueue(qid));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
import { LazyStore } from '@tauri-apps/plugin-store';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
|
||||
export const tauriStore = new LazyStore('store.bin');
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
||||
@@ -138,13 +141,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
|
||||
addDownload: (item) => {
|
||||
set((state) => ({ downloads: [...state.downloads, item] }));
|
||||
const toSave = { ...item };
|
||||
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
|
||||
invoke('db_save_download', { id: item.id, status: item.status, queueId: item.queueId, data: JSON.stringify(toSave) }).catch(console.error);
|
||||
set((state) => ({ downloads: [...state.downloads, item] }));
|
||||
get().processQueue();
|
||||
},
|
||||
updateDownload: (id, updates) => {
|
||||
let updatedItem: DownloadItem | null = null;
|
||||
set((state) => ({
|
||||
downloads: state.downloads.map(d => {
|
||||
if (d.id === id) {
|
||||
@@ -153,19 +153,12 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
...updates,
|
||||
fraction: updates.fraction !== undefined ? updates.fraction : d.fraction
|
||||
};
|
||||
updatedItem = updated;
|
||||
return updated;
|
||||
}
|
||||
return d;
|
||||
})
|
||||
}));
|
||||
|
||||
if (updatedItem && Object.keys(updates).some(k => !['fraction', 'speed', 'eta'].includes(k))) {
|
||||
const toSave = { ...(updatedItem as DownloadItem) };
|
||||
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
|
||||
invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: toSave.queueId, data: JSON.stringify(toSave) }).catch(console.error);
|
||||
}
|
||||
|
||||
// If status changed to something that frees up a slot, process queue
|
||||
if (updates.status && ['completed', 'failed', 'paused'].includes(updates.status)) {
|
||||
get().processQueue();
|
||||
@@ -194,12 +187,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
set((state) => ({
|
||||
downloads: state.downloads.filter(d => d.id !== id)
|
||||
}));
|
||||
invoke('db_delete_download', { id }).catch(console.error);
|
||||
get().processQueue();
|
||||
syncSystemIntegrations();
|
||||
},
|
||||
redownload: (id) => {
|
||||
let updatedItem: DownloadItem | null = null;
|
||||
let wasDownloading = false;
|
||||
set((state) => ({
|
||||
downloads: state.downloads.map(d => {
|
||||
@@ -208,7 +199,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
wasDownloading = true;
|
||||
}
|
||||
const updated: DownloadItem = { ...d, status: 'queued', _dispatched: false, fraction: 0, speed: '-', eta: '-' };
|
||||
updatedItem = updated;
|
||||
return updated;
|
||||
}
|
||||
return d;
|
||||
@@ -217,11 +207,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
if (wasDownloading) {
|
||||
invoke('pause_download', { id }).catch(console.error);
|
||||
}
|
||||
if (updatedItem) {
|
||||
const toSave = { ...(updatedItem as DownloadItem) };
|
||||
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
|
||||
invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: toSave.queueId, data: JSON.stringify(toSave) }).catch(console.error);
|
||||
}
|
||||
get().processQueue();
|
||||
},
|
||||
startQueue: async (queueId) => {
|
||||
@@ -238,13 +223,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
: item
|
||||
)
|
||||
}));
|
||||
|
||||
const downloadsToSave = get().downloads.filter(item => runnableIds.includes(item.id));
|
||||
downloadsToSave.forEach(d => {
|
||||
const toSave = { ...d };
|
||||
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
|
||||
invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: toSave.queueId, data: JSON.stringify(toSave) }).catch(console.error);
|
||||
});
|
||||
|
||||
await get().processQueue();
|
||||
return runnableIds.length;
|
||||
@@ -263,13 +241,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
: item
|
||||
)
|
||||
}));
|
||||
|
||||
const downloadsToSave = get().downloads.filter(item => activeIds.includes(item.id));
|
||||
downloadsToSave.forEach(d => {
|
||||
const toSave = { ...d };
|
||||
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
|
||||
invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: toSave.queueId, data: JSON.stringify(toSave) }).catch(console.error);
|
||||
});
|
||||
|
||||
await Promise.all(activeIds.map(id => invoke('pause_download', { id }).catch(() => {})));
|
||||
syncSystemIntegrations();
|
||||
@@ -281,51 +252,32 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
set((state) => ({
|
||||
queues: [...state.queues, q]
|
||||
}));
|
||||
invoke('db_save_queue', { id, data: JSON.stringify(q) }).catch(console.error);
|
||||
},
|
||||
renameQueue: (id, name) => {
|
||||
let updatedQ: Queue | null = null;
|
||||
set((state) => ({
|
||||
queues: state.queues.map(q => {
|
||||
if (q.id === id) {
|
||||
const newQ = { ...q, name };
|
||||
updatedQ = newQ;
|
||||
return newQ;
|
||||
}
|
||||
return q;
|
||||
})
|
||||
}));
|
||||
if (updatedQ) {
|
||||
invoke('db_save_queue', { id, data: JSON.stringify(updatedQ) }).catch(console.error);
|
||||
}
|
||||
},
|
||||
removeQueue: (id) => {
|
||||
if (id === MAIN_QUEUE_ID) return;
|
||||
|
||||
const affectedDownloads = get().downloads.filter(d => d.queueId === id);
|
||||
|
||||
set((state) => ({
|
||||
queues: state.queues.filter(q => q.id !== id),
|
||||
downloads: state.downloads.map(d =>
|
||||
d.queueId === id ? { ...d, queueId: MAIN_QUEUE_ID } : d
|
||||
)
|
||||
}));
|
||||
invoke('db_delete_queue', { id }).catch(console.error);
|
||||
|
||||
// Also we need to save the updated downloads to DB
|
||||
affectedDownloads.forEach(d => {
|
||||
const toSave = { ...d, queueId: MAIN_QUEUE_ID };
|
||||
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
|
||||
invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: MAIN_QUEUE_ID, data: JSON.stringify(toSave) }).catch(console.error);
|
||||
});
|
||||
},
|
||||
initDB: async () => {
|
||||
try {
|
||||
const queuesStr = await invoke('db_get_all_queues');
|
||||
const queues = queuesStr.map(q => JSON.parse(q));
|
||||
|
||||
const downloadsStr = await invoke('db_get_all_downloads');
|
||||
const downloads = downloadsStr.map(d => JSON.parse(d));
|
||||
const queues = await tauriStore.get<Queue[]>('queues') || [];
|
||||
const downloads = await tauriStore.get<DownloadItem[]>('download_queue') || [];
|
||||
|
||||
set(state => ({
|
||||
queues: queues.length > 0 ? queues : state.queues,
|
||||
@@ -460,3 +412,30 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
let lastSavedDownloads = '';
|
||||
|
||||
useDownloadStore.subscribe(async (state, prevState) => {
|
||||
if (state.queues !== prevState.queues) {
|
||||
await tauriStore.set('queues', state.queues);
|
||||
await tauriStore.save();
|
||||
}
|
||||
|
||||
if (state.downloads !== prevState.downloads) {
|
||||
const staticDownloads = state.downloads.map(d => {
|
||||
const copy = { ...d };
|
||||
delete copy.fraction;
|
||||
delete copy.speed;
|
||||
delete copy.eta;
|
||||
delete copy._dispatched;
|
||||
return copy;
|
||||
});
|
||||
|
||||
const currentSerialized = JSON.stringify(staticDownloads);
|
||||
if (currentSerialized !== lastSavedDownloads) {
|
||||
lastSavedDownloads = currentSerialized;
|
||||
await tauriStore.set('download_queue', staticDownloads);
|
||||
await tauriStore.save();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,12 +13,14 @@ import type { SettingsTab } from '../bindings/SettingsTab';
|
||||
import type { SiteLogin } from '../bindings/SiteLogin';
|
||||
import type { Theme } from '../bindings/Theme';
|
||||
|
||||
import { tauriStore } from './useDownloadStore';
|
||||
|
||||
const tauriStorage: StateStorage = {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
if (name === 'firelink-settings') {
|
||||
try {
|
||||
const data = await invoke('db_load_settings');
|
||||
return data;
|
||||
const data = await tauriStore.get<string>('settings');
|
||||
return data || null;
|
||||
} catch (e) {
|
||||
console.error("Failed to load settings from DB", e);
|
||||
return null;
|
||||
@@ -29,7 +31,8 @@ const tauriStorage: StateStorage = {
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
if (name === 'firelink-settings') {
|
||||
try {
|
||||
await invoke('db_save_settings', { data: value });
|
||||
await tauriStore.set('settings', value);
|
||||
await tauriStore.save();
|
||||
} catch (e) {
|
||||
console.error("Failed to save settings to DB", e);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
|
||||
with open("src-tauri/src/main.rs", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
test_call = """
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
println!("Testing fetch_media_metadata...");
|
||||
let res = firelink::fetch_media_metadata(
|
||||
handle,
|
||||
"https://www.youtube.com/watch?v=a769AIuHOdE".to_string(),
|
||||
None, None, None
|
||||
).await;
|
||||
match res {
|
||||
Ok(out) => println!("TEST SUCCESS: {}", out.chars().take(200).collect::<String>()),
|
||||
Err(e) => println!("TEST ERROR: {}", e)
|
||||
}
|
||||
});
|
||||
"""
|
||||
|
||||
if "Testing fetch_media_metadata" not in content:
|
||||
content = content.replace('.setup(|app| {', test_call)
|
||||
with open("src-tauri/src/main.rs", "w") as f:
|
||||
f.write(content)
|
||||
print("Added test call to main.rs")
|
||||
@@ -0,0 +1,25 @@
|
||||
const fs = require('fs');
|
||||
const appTsxPath = 'src/App.tsx';
|
||||
let appTsx = fs.readFileSync(appTsxPath, 'utf8');
|
||||
|
||||
const injection = `
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
useEffect(() => {
|
||||
invoke('fetch_media_metadata', {
|
||||
url: "https://www.youtube.com/watch?v=a769AIuHOdE",
|
||||
cookieBrowser: null,
|
||||
username: null,
|
||||
password: null
|
||||
}).then(res => {
|
||||
invoke('perform_system_action', { action: "TEST_SUCCESS: " + res.substring(0, 50) });
|
||||
}).catch(err => {
|
||||
invoke('perform_system_action', { action: "TEST_ERROR: " + String(err) });
|
||||
});
|
||||
}, []);
|
||||
`;
|
||||
|
||||
// Insert after imports
|
||||
if (!appTsx.includes('TEST_ERROR')) {
|
||||
appTsx = appTsx.replace('export default function App() {', "export default function App() {\n" + injection);
|
||||
fs.writeFileSync(appTsxPath, appTsx);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
const fs = require('fs');
|
||||
const jsonStr = fs.readFileSync('test_27.json', 'utf8');
|
||||
const data = JSON.parse(jsonStr);
|
||||
|
||||
const isVideo = (f) => f.vcodec && f.vcodec !== 'none';
|
||||
const matchesHeight = (f, h) => f.height === h || (f.height >= h - 10 && f.height <= h + 10);
|
||||
const rawFormats = data.formats.filter((format) => Boolean(format) && typeof format === 'object');
|
||||
|
||||
const standardResolutions = [
|
||||
{ h: 2160, name: "4K" },
|
||||
{ h: 1440, name: "1440p" },
|
||||
{ h: 1080, name: "1080p" },
|
||||
{ h: 720, name: "720p" },
|
||||
{ h: 480, name: "480p" },
|
||||
{ h: 360, name: "360p" }
|
||||
];
|
||||
|
||||
const availableResolutions = standardResolutions.filter(res =>
|
||||
rawFormats.some(f => isVideo(f) && matchesHeight(f, res.h))
|
||||
);
|
||||
|
||||
console.log("availableResolutions:", availableResolutions.map(r => r.name));
|
||||
|
||||
const videoQualities = [{ h: null, name: "Best" }, ...availableResolutions];
|
||||
const videoContainers = [
|
||||
{ ext: "mp4", name: "MP4" },
|
||||
{ ext: "mkv", name: "MKV" },
|
||||
{ ext: "webm", name: "WebM" }
|
||||
];
|
||||
|
||||
const hasVideoFormat = (formats, height, ext) => {
|
||||
if (!height) return formats.some(f => isVideo(f) && (f.ext === ext || (ext === 'mkv')));
|
||||
return formats.some(f => isVideo(f) && matchesHeight(f, height) && (f.ext === ext || (ext === 'mkv')));
|
||||
};
|
||||
|
||||
const options = [];
|
||||
for (const q of videoQualities) {
|
||||
for (const c of videoContainers) {
|
||||
if (!hasVideoFormat(rawFormats, q.h, c.ext)) continue;
|
||||
options.push({ name: `${q.name} ${c.name}` });
|
||||
}
|
||||
}
|
||||
console.log("Options count:", options.length);
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
let ytdlp_path = "/Users/nima/Documents/Code/Firelink/src-tauri/binaries/yt-dlp";
|
||||
let deno_path = "/Users/nima/Documents/Code/Firelink/src-tauri/binaries/deno";
|
||||
|
||||
let mut cmd = Command::new(ytdlp_path);
|
||||
cmd.arg("-J")
|
||||
.arg("--no-warnings")
|
||||
.arg("--no-playlist")
|
||||
.arg("--no-check-formats")
|
||||
.arg("--socket-timeout").arg("20")
|
||||
.arg("--retries").arg("3")
|
||||
.arg("--extractor-retries").arg("3")
|
||||
.arg("--compat-options").arg("no-youtube-unavailable-videos")
|
||||
.arg("--js-runtimes").arg(format!("deno:{},node", deno_path))
|
||||
.arg("--")
|
||||
.arg("https://www.youtube.com/watch?v=a769AIuHOdE");
|
||||
|
||||
println!("Running: {:?}", cmd);
|
||||
let output = cmd.output().unwrap();
|
||||
println!("Status: {}", output.status);
|
||||
println!("Stdout: {}", String::from_utf8_lossy(&output.stdout).chars().take(200).collect::<String>());
|
||||
println!("Stderr: {}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
Reference in New Issue
Block a user