Files
youtube_summarizer/src-tauri/src/main.rs

1010 lines
31 KiB
Rust
Raw Normal View History

2026-03-15 14:51:29 +01:00
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::{
2026-05-04 10:13:03 +02:00
env,
fs::{self, OpenOptions},
io::{BufRead, BufReader, ErrorKind, Write},
2026-03-15 14:51:29 +01:00
path::{Path, PathBuf},
process::{Command, Stdio},
sync::{Arc, Mutex},
thread,
time::{Duration, SystemTime, UNIX_EPOCH},
2026-03-15 14:51:29 +01:00
};
2026-05-04 10:13:03 +02:00
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
2026-03-15 14:51:29 +01:00
use open::that;
use reqwest::blocking::Client;
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
2026-05-04 10:13:03 +02:00
use tauri::menu::{MenuBuilder, SubmenuBuilder};
use tauri::{path::BaseDirectory, AppHandle, Emitter, Manager, State, WebviewWindow};
2026-03-15 14:51:29 +01:00
const DEFAULT_MODEL: &str = "mistral:latest";
const OLLAMA_TAGS_URL: &str = "http://localhost:11434/api/tags";
const BACKEND_EXECUTABLE_NAME: &str = "yts-backend";
const DISCORD_MAX_MESSAGE_LENGTH: usize = 2000;
const DISCORD_MESSAGE_DELAY_MS: u64 = 1000;
2026-03-15 14:51:29 +01:00
const TARGET_TRIPLE: &str = env!("TAURI_BUILD_TARGET");
2026-05-04 10:13:03 +02:00
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
2026-03-15 14:51:29 +01:00
#[derive(Clone)]
enum BackendRuntime {
Bundled {
executable: PathBuf,
},
Python {
python: PathBuf,
script_dir: PathBuf,
},
}
#[derive(Clone)]
struct AppState {
app_dir: PathBuf,
media_dir: PathBuf,
db_path: PathBuf,
2026-05-04 10:13:03 +02:00
backend_log_path: PathBuf,
2026-03-15 14:51:29 +01:00
backend: BackendRuntime,
ffmpeg_path: Option<PathBuf>,
ffprobe_path: Option<PathBuf>,
whisper_cache_dir: PathBuf,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SummarizeVideoRequest {
url: String,
use_whisper: bool,
model: Option<String>,
2026-05-04 10:13:03 +02:00
master_prompt: Option<String>,
2026-03-15 14:51:29 +01:00
}
#[derive(Debug, Deserialize)]
struct DeleteSummaryRequest {
id: i64,
}
#[derive(Debug, Deserialize)]
2026-05-04 10:13:03 +02:00
#[serde(rename_all = "camelCase")]
2026-03-15 14:51:29 +01:00
struct TranslateSummaryRequest {
id: i64,
lang: String,
model: Option<String>,
2026-05-04 10:13:03 +02:00
prompt_template: Option<String>,
2026-03-15 14:51:29 +01:00
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SendSummaryToDiscordRequest {
id: i64,
webhook_url: String,
}
#[derive(Debug, Deserialize)]
2026-03-15 14:51:29 +01:00
struct BackendSummaryMeta {
timestamp: String,
video_id: String,
url: String,
video_name: String,
channel: Option<String>,
thumbnail: Option<String>,
audio: Option<String>,
transcript: Option<String>,
summary: String,
}
#[derive(Debug, Deserialize)]
struct OllamaTagsResponse {
models: Vec<OllamaModel>,
}
#[derive(Debug, Deserialize)]
struct OllamaModel {
name: String,
}
#[derive(Debug)]
struct StoredSummary {
id: i64,
timestamp: Option<String>,
video_id: Option<String>,
url: Option<String>,
video_name: Option<String>,
channel: Option<String>,
thumbnail: Option<String>,
audio: Option<String>,
transcript: Option<String>,
summary_en: Option<String>,
summary_de: Option<String>,
summary_jp: Option<String>,
}
#[derive(Debug, Serialize)]
struct SummaryEntry {
id: i64,
timestamp: Option<String>,
video_id: Option<String>,
url: Option<String>,
video_name: Option<String>,
channel: Option<String>,
thumbnail: Option<String>,
audio: Option<String>,
transcript: Option<String>,
summary_en: Option<String>,
summary_de: Option<String>,
summary_jp: Option<String>,
}
impl StoredSummary {
fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
Ok(Self {
id: row.get("id")?,
timestamp: row.get("timestamp")?,
video_id: row.get("video_id")?,
url: row.get("url")?,
video_name: row.get("video_name")?,
channel: row.get("channel")?,
thumbnail: row.get("thumbnail")?,
audio: row.get("audio")?,
transcript: row.get("transcript")?,
summary_en: row.get("summary_en")?,
summary_de: row.get("summary_de")?,
summary_jp: row.get("summary_jp")?,
})
}
fn into_entry(self, state: &AppState) -> SummaryEntry {
SummaryEntry {
id: self.id,
timestamp: self.timestamp,
video_id: self.video_id,
url: self.url,
video_name: self.video_name,
channel: self.channel,
thumbnail: absolute_media_path(state, self.thumbnail),
audio: absolute_media_path(state, self.audio),
transcript: absolute_media_path(state, self.transcript),
summary_en: self.summary_en,
summary_de: self.summary_de,
summary_jp: self.summary_jp,
}
}
}
fn absolute_media_path(state: &AppState, file_name: Option<String>) -> Option<String> {
file_name.map(|name| state.media_dir.join(name).to_string_lossy().into_owned())
}
fn normalize_model(model: Option<String>) -> String {
model
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_MODEL.to_string())
}
2026-05-04 10:13:03 +02:00
fn normalize_prompt_template(prompt: Option<String>) -> Option<String> {
prompt
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
2026-03-15 14:51:29 +01:00
fn now_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}
fn resolve_project_root() -> Result<PathBuf, String> {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.canonicalize()
.map_err(|err| format!("Failed to resolve project root: {err}"))
}
fn platform_executable_name(base_name: &str) -> String {
if cfg!(windows) {
format!("{base_name}.exe")
} else {
base_name.to_string()
}
}
fn resolve_resource_file(app: &AppHandle, relative_path: &Path) -> Option<PathBuf> {
let mut candidates = Vec::new();
2026-05-04 10:13:03 +02:00
if let Ok(resource_path) = app.path().resolve(relative_path, BaseDirectory::Resource) {
candidates.push(resource_path);
2026-03-15 14:51:29 +01:00
}
2026-05-04 10:13:03 +02:00
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
candidates.push(manifest_dir.join(relative_path));
candidates.push(manifest_dir.join("resources").join(relative_path));
if let Ok(project_root) = resolve_project_root() {
candidates.push(project_root.join(relative_path));
}
2026-03-15 14:51:29 +01:00
candidates.into_iter().find(|path| path.exists())
}
fn resolve_backend_binary(app: &AppHandle) -> Option<PathBuf> {
if let Ok(path) = env::var("YTS_BACKEND_BIN") {
let trimmed = path.trim();
if !trimmed.is_empty() {
return Some(PathBuf::from(trimmed));
}
}
let relative_path = Path::new("backend")
.join(TARGET_TRIPLE)
.join(platform_executable_name(BACKEND_EXECUTABLE_NAME));
resolve_resource_file(app, &relative_path)
}
fn resolve_script_dir(app: &AppHandle) -> Result<PathBuf, String> {
2026-05-04 10:13:03 +02:00
if let Some(resource_file) = resolve_resource_file(app, Path::new("backend_cli.py")) {
if let Some(parent) = resource_file.parent() {
return Ok(parent.to_path_buf());
2026-03-15 14:51:29 +01:00
}
}
let project_dir = resolve_project_root()?;
if project_dir.join("backend_cli.py").exists() {
return Ok(project_dir);
}
Err("Unable to locate bundled or development backend Python scripts.".to_string())
}
fn resolve_python_command(script_dir: &Path) -> Result<PathBuf, String> {
if let Ok(path) = env::var("YTS_PYTHON") {
let trimmed = path.trim();
if !trimmed.is_empty() {
return Ok(PathBuf::from(trimmed));
}
}
let mut candidates = Vec::new();
candidates.push(script_dir.join("venv").join("bin").join("python3"));
candidates.push(script_dir.join("venv").join("bin").join("python"));
candidates.push(script_dir.join("venv").join("Scripts").join("python.exe"));
candidates.push(PathBuf::from("python3"));
candidates.push(PathBuf::from("python"));
for candidate in candidates {
if Command::new(&candidate).arg("--version").output().is_ok() {
return Ok(candidate);
}
}
Err("Unable to find a usable Python interpreter. Set YTS_PYTHON to override.".to_string())
}
fn resolve_backend_runtime(app: &AppHandle) -> Result<BackendRuntime, String> {
if let Some(executable) = resolve_backend_binary(app) {
return Ok(BackendRuntime::Bundled { executable });
}
let script_dir = resolve_script_dir(app)?;
let python = resolve_python_command(&script_dir)?;
Ok(BackendRuntime::Python { python, script_dir })
}
fn resolve_optional_tool_path(app: &AppHandle, env_name: &str, tool_name: &str) -> Option<PathBuf> {
if let Ok(path) = env::var(env_name) {
let trimmed = path.trim();
if !trimmed.is_empty() {
return Some(PathBuf::from(trimmed));
}
}
let relative_path = Path::new("ffmpeg")
.join(TARGET_TRIPLE)
.join(platform_executable_name(tool_name));
resolve_resource_file(app, &relative_path)
}
fn resolve_whisper_cache_dir(app: &AppHandle) -> Result<PathBuf, String> {
let cache_root = app
.path()
.app_cache_dir()
.or_else(|_| app.path().app_local_data_dir())
.map_err(|err| format!("Failed to resolve application cache directory: {err}"))?;
let whisper_cache_dir = cache_root.join("whisper");
fs::create_dir_all(&whisper_cache_dir)
.map_err(|err| format!("Failed to create Whisper cache directory: {err}"))?;
Ok(whisper_cache_dir)
}
2026-05-04 10:13:03 +02:00
fn resolve_log_dir(app: &AppHandle) -> Result<PathBuf, String> {
let log_dir = app
.path()
.app_log_dir()
.or_else(|_| {
app.path()
.app_local_data_dir()
.map(|path| path.join("logs"))
})
.map_err(|err| format!("Failed to resolve application log directory: {err}"))?;
fs::create_dir_all(&log_dir)
.map_err(|err| format!("Failed to create application log directory: {err}"))?;
Ok(log_dir)
}
2026-03-15 14:51:29 +01:00
fn open_connection(state: &AppState) -> Result<Connection, String> {
Connection::open(&state.db_path).map_err(|err| format!("Failed to open SQLite database: {err}"))
}
fn init_db(state: &AppState) -> Result<(), String> {
let db = open_connection(state)?;
db.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT,
video_id TEXT,
url TEXT,
video_name TEXT,
channel TEXT,
thumbnail TEXT,
audio TEXT,
transcript TEXT,
summary_en TEXT,
summary_de TEXT,
summary_jp TEXT
);
"#,
)
.map_err(|err| format!("Failed to initialize SQLite schema: {err}"))?;
Ok(())
}
fn remove_named_media_file(media_dir: &Path, file_name: &str) {
let path = media_dir.join(file_name);
if let Err(err) = fs::remove_file(&path) {
if err.kind() != ErrorKind::NotFound {
eprintln!("Failed to remove {}: {}", path.display(), err);
}
}
}
fn cleanup_artifacts(state: &AppState, audio: Option<&str>, transcript: Option<&str>) {
if let Some(audio_file) = audio.filter(|value| !value.trim().is_empty()) {
remove_named_media_file(&state.media_dir, audio_file);
}
if let Some(transcript_file) = transcript.filter(|value| !value.trim().is_empty()) {
remove_named_media_file(&state.media_dir, transcript_file);
}
}
fn purge_existing_artifacts(state: &AppState) -> Result<(), String> {
let db = open_connection(state)?;
let mut stmt = db
2026-05-04 10:13:03 +02:00
.prepare(
"SELECT id, audio, transcript FROM summaries WHERE audio IS NOT NULL OR transcript IS NOT NULL",
)
.map_err(|err| format!("Failed to prepare artifact cleanup query: {err}"))?;
2026-03-15 14:51:29 +01:00
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, Option<String>>(2)?,
))
})
.map_err(|err| format!("Failed to load stored artifacts: {err}"))?;
let mut entries = Vec::new();
for row in rows {
entries.push(row.map_err(|err| format!("Failed to decode stored artifact row: {err}"))?);
}
drop(stmt);
for (id, audio, transcript) in entries {
cleanup_artifacts(state, audio.as_deref(), transcript.as_deref());
db.execute(
"UPDATE summaries SET audio = NULL, transcript = NULL WHERE id = ?",
[id],
)
.map_err(|err| format!("Failed to clear stored artifact references: {err}"))?;
}
Ok(())
}
2026-05-04 10:13:03 +02:00
fn write_startup_error_log(app: &AppHandle, message: &str) {
let mut candidates = Vec::new();
if let Ok(path) = app.path().app_log_dir() {
candidates.push(path);
}
if let Ok(path) = app.path().app_local_data_dir() {
candidates.push(path);
}
candidates.push(env::temp_dir().join("youtube-summarizer"));
for directory in candidates {
if fs::create_dir_all(&directory).is_ok() {
let log_path = directory.join("startup-error.log");
if fs::write(&log_path, message).is_ok() {
eprintln!("Startup failure written to {}", log_path.display());
return;
}
}
}
eprintln!("{message}");
}
2026-03-15 14:51:29 +01:00
fn ensure_app_state(app: &AppHandle) -> Result<AppState, String> {
let app_dir = app
.path()
.app_local_data_dir()
.map_err(|err| format!("Failed to resolve application data directory: {err}"))?;
let media_dir = app_dir.join("data");
fs::create_dir_all(&media_dir)
.map_err(|err| format!("Failed to create application data directory: {err}"))?;
let state = AppState {
backend: resolve_backend_runtime(app)?,
ffmpeg_path: resolve_optional_tool_path(app, "YTS_FFMPEG", "ffmpeg"),
ffprobe_path: resolve_optional_tool_path(app, "YTS_FFPROBE", "ffprobe"),
whisper_cache_dir: resolve_whisper_cache_dir(app)?,
2026-05-04 10:13:03 +02:00
backend_log_path: resolve_log_dir(app)?.join("backend.log"),
2026-03-15 14:51:29 +01:00
app_dir: app_dir.clone(),
media_dir,
db_path: app_dir.join("summaries.db"),
};
init_db(&state)?;
purge_existing_artifacts(&state)?;
Ok(state)
}
fn emit_progress(app: &AppHandle, window_label: &str, line: &str) {
let trimmed = line.trim();
if !trimmed.is_empty() {
let _ = app.emit_to(window_label, "summarize-progress", trimmed.to_string());
}
}
2026-05-04 10:13:03 +02:00
fn append_backend_log(log_path: &Path, line: &str) {
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_path) {
let _ = writeln!(file, "{line}");
}
}
fn backend_failure_message(stderr_output: &str, fallback: String) -> String {
for line in stderr_output.lines().rev() {
let trimmed = line.trim();
if let Some(message) = trimmed.strip_prefix("[error]") {
let message = message.trim();
if !message.is_empty() {
return message.to_string();
}
}
}
for line in stderr_output.lines().rev() {
let trimmed = line.trim();
if trimmed.is_empty()
|| trimmed.starts_with("WARNING:")
|| trimmed.starts_with("Traceback")
|| trimmed.starts_with("File ")
|| trimmed.starts_with("During handling")
{
continue;
}
if trimmed.starts_with("ERROR:")
|| trimmed.contains("RuntimeError:")
|| trimmed.contains("SystemExit:")
{
return trimmed.to_string();
}
}
fallback
}
2026-03-15 14:51:29 +01:00
fn apply_backend_env(command: &mut Command, state: &AppState) {
command.env("PYTHONUNBUFFERED", "1");
2026-05-04 10:13:03 +02:00
command.env("PYTHONIOENCODING", "utf-8");
2026-03-15 14:51:29 +01:00
command.env("YTS_WHISPER_CACHE_DIR", &state.whisper_cache_dir);
if let Some(ffmpeg_path) = &state.ffmpeg_path {
command.env("YTS_FFMPEG", ffmpeg_path);
}
if let Some(ffprobe_path) = &state.ffprobe_path {
command.env("YTS_FFPROBE", ffprobe_path);
}
}
fn build_backend_command(state: &AppState, args: &[String]) -> Command {
let mut command = match &state.backend {
BackendRuntime::Bundled { executable } => Command::new(executable),
BackendRuntime::Python { python, script_dir } => {
let mut command = Command::new(python);
command.arg(script_dir.join("backend_cli.py"));
command
}
};
command.args(args).current_dir(&state.media_dir);
apply_backend_env(&mut command, state);
2026-05-04 10:13:03 +02:00
#[cfg(target_os = "windows")]
command.creation_flags(CREATE_NO_WINDOW);
2026-03-15 14:51:29 +01:00
command
}
fn run_backend_json_command(
state: &AppState,
app: &AppHandle,
window_label: &str,
args: &[String],
) -> Result<BackendSummaryMeta, String> {
let output_path = state.app_dir.join(format!("tmp_{}.json", now_millis()));
let mut command_args = args.to_vec();
command_args.push("--output-json".to_string());
command_args.push(output_path.to_string_lossy().into_owned());
2026-05-04 10:13:03 +02:00
append_backend_log(
&state.backend_log_path,
&format!("=== summarize {} ===", command_args.join(" ")),
);
2026-03-15 14:51:29 +01:00
let mut child = build_backend_command(state, &command_args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
2026-05-04 10:13:03 +02:00
.map_err(|err| {
let message = format!("Failed to start bundled backend: {err}");
append_backend_log(&state.backend_log_path, &message);
message
})?;
2026-03-15 14:51:29 +01:00
let stdout = child
.stdout
.take()
.ok_or_else(|| "Backend stdout was not captured.".to_string())?;
let stderr = child
.stderr
.take()
.ok_or_else(|| "Backend stderr was not captured.".to_string())?;
let stderr_buffer = Arc::new(Mutex::new(String::new()));
2026-05-04 10:13:03 +02:00
let stdout_log_path = state.backend_log_path.clone();
let stderr_log_path = state.backend_log_path.clone();
2026-03-15 14:51:29 +01:00
let stdout_app = app.clone();
let stdout_label = window_label.to_string();
let stdout_handle = thread::spawn(move || {
for line in BufReader::new(stdout).lines() {
match line {
2026-05-04 10:13:03 +02:00
Ok(line) => {
append_backend_log(&stdout_log_path, &format!("[stdout] {line}"));
emit_progress(&stdout_app, &stdout_label, &line);
}
2026-03-15 14:51:29 +01:00
Err(_) => break,
}
}
});
let stderr_buffer_clone = Arc::clone(&stderr_buffer);
let stderr_handle = thread::spawn(move || {
for line in BufReader::new(stderr).lines() {
match line {
Ok(line) => {
2026-05-04 10:13:03 +02:00
append_backend_log(&stderr_log_path, &format!("[stderr] {line}"));
2026-03-15 14:51:29 +01:00
if let Ok(mut buffer) = stderr_buffer_clone.lock() {
buffer.push_str(&line);
buffer.push('\n');
}
}
Err(_) => break,
}
}
});
2026-05-04 10:13:03 +02:00
let status = child.wait().map_err(|err| {
let message = format!("Failed to wait for bundled backend: {err}");
append_backend_log(&state.backend_log_path, &message);
message
})?;
append_backend_log(
&state.backend_log_path,
&format!("Bundled backend exit status: {status}"),
);
2026-03-15 14:51:29 +01:00
let _ = stdout_handle.join();
let _ = stderr_handle.join();
if !status.success() {
let stderr_output = stderr_buffer
.lock()
.map(|buffer| buffer.trim().to_string())
.unwrap_or_else(|_| String::new());
2026-05-04 10:13:03 +02:00
let message = backend_failure_message(
&stderr_output,
format!("Bundled backend exited with status {status}."),
);
append_backend_log(
&state.backend_log_path,
&format!("Backend failure: {message}"),
);
2026-03-15 14:51:29 +01:00
let _ = fs::remove_file(&output_path);
return Err(message);
}
2026-05-04 10:13:03 +02:00
let raw_json = fs::read_to_string(&output_path).map_err(|err| {
let message = format!("Failed to read backend output JSON: {err}");
append_backend_log(&state.backend_log_path, &message);
message
})?;
2026-03-15 14:51:29 +01:00
let _ = fs::remove_file(&output_path);
2026-05-04 10:13:03 +02:00
serde_json::from_str(&raw_json).map_err(|err| {
let message = format!("Invalid backend output JSON: {err}");
append_backend_log(&state.backend_log_path, &message);
message
})
2026-03-15 14:51:29 +01:00
}
fn run_backend_text_command(state: &AppState, args: &[String]) -> Result<String, String> {
2026-05-04 10:13:03 +02:00
append_backend_log(
&state.backend_log_path,
&format!("=== translate {} ===", args.join(" ")),
);
let output = build_backend_command(state, args).output().map_err(|err| {
let message = format!("Failed to start translation backend: {err}");
append_backend_log(&state.backend_log_path, &message);
message
})?;
for line in String::from_utf8_lossy(&output.stdout).lines() {
append_backend_log(&state.backend_log_path, &format!("[stdout] {line}"));
}
for line in String::from_utf8_lossy(&output.stderr).lines() {
append_backend_log(&state.backend_log_path, &format!("[stderr] {line}"));
}
append_backend_log(
&state.backend_log_path,
&format!("Translation backend exit status: {}", output.status),
);
2026-03-15 14:51:29 +01:00
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2026-05-04 10:13:03 +02:00
let message = if stderr.is_empty() {
2026-03-15 14:51:29 +01:00
format!("Translation backend exited with status {}.", output.status)
} else {
stderr
2026-05-04 10:13:03 +02:00
};
append_backend_log(
&state.backend_log_path,
&format!("Translation failure: {message}"),
);
return Err(message);
2026-03-15 14:51:29 +01:00
}
let translation = String::from_utf8(output.stdout)
.map_err(|err| format!("Translation backend returned invalid UTF-8: {err}"))?
.trim()
.to_string();
if translation.is_empty() {
2026-05-04 10:13:03 +02:00
let message = "Translation backend returned an empty result.".to_string();
append_backend_log(&state.backend_log_path, &message);
return Err(message);
2026-03-15 14:51:29 +01:00
}
Ok(translation)
}
fn get_entry_by_id(state: &AppState, id: i64) -> Result<SummaryEntry, String> {
let db = open_connection(state)?;
let stored = db
.query_row(
"SELECT * FROM summaries WHERE id = ?",
[id],
StoredSummary::from_row,
)
.optional()
.map_err(|err| format!("Failed to query summary entry: {err}"))?
.ok_or_else(|| "Entry not found.".to_string())?;
Ok(stored.into_entry(state))
}
fn summarize_video_inner(
state: &AppState,
app: &AppHandle,
window_label: &str,
request: SummarizeVideoRequest,
) -> Result<SummaryEntry, String> {
2026-05-04 10:13:03 +02:00
let SummarizeVideoRequest {
url,
use_whisper,
model,
master_prompt,
} = request;
let model = normalize_model(model);
2026-03-15 14:51:29 +01:00
let mut args = vec![
"summarize".to_string(),
"--url".to_string(),
2026-05-04 10:13:03 +02:00
url,
2026-03-15 14:51:29 +01:00
"--model".to_string(),
model,
];
2026-05-04 10:13:03 +02:00
if !use_whisper {
2026-03-15 14:51:29 +01:00
args.push("--no-whisper".to_string());
}
2026-05-04 10:13:03 +02:00
let prompt_path = if let Some(prompt) = normalize_prompt_template(master_prompt) {
let path = state
.app_dir
.join(format!("tmp_prompt_{}.txt", now_millis()));
fs::write(&path, prompt)
.map_err(|err| format!("Failed to write temporary prompt file: {err}"))?;
args.push("--prompt-template-file".to_string());
args.push(path.to_string_lossy().into_owned());
Some(path)
} else {
None
};
let result = run_backend_json_command(state, app, window_label, &args);
if let Some(path) = prompt_path {
let _ = fs::remove_file(path);
}
let info = result?;
2026-03-15 14:51:29 +01:00
cleanup_artifacts(state, info.audio.as_deref(), info.transcript.as_deref());
let db = open_connection(state)?;
db.execute(
"INSERT INTO summaries (timestamp, video_id, url, video_name, channel, thumbnail, audio, transcript, summary_en, summary_de, summary_jp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
info.timestamp,
info.video_id,
info.url,
info.video_name,
info.channel,
info.thumbnail,
Option::<String>::None,
Option::<String>::None,
info.summary,
Option::<String>::None,
Option::<String>::None,
],
)
.map_err(|err| format!("Failed to save summary entry: {err}"))?;
get_entry_by_id(state, db.last_insert_rowid())
}
fn translate_summary_inner(
state: &AppState,
request: TranslateSummaryRequest,
) -> Result<SummaryEntry, String> {
2026-05-04 10:13:03 +02:00
let TranslateSummaryRequest {
id,
lang,
model,
prompt_template,
} = request;
2026-03-15 14:51:29 +01:00
let db = open_connection(state)?;
let summary_text = db
.query_row(
"SELECT summary_en FROM summaries WHERE id = ?",
2026-05-04 10:13:03 +02:00
[id],
2026-03-15 14:51:29 +01:00
|row| row.get::<_, Option<String>>(0),
)
.optional()
.map_err(|err| format!("Failed to load English summary for translation: {err}"))?
.flatten()
.ok_or_else(|| "No English summary found for translation.".to_string())?;
2026-05-04 10:13:03 +02:00
let tmp_summary_path = state
.app_dir
.join(format!("tmp_summary_{}_{}.txt", id, now_millis()));
2026-03-15 14:51:29 +01:00
fs::write(&tmp_summary_path, summary_text)
.map_err(|err| format!("Failed to write temporary summary file: {err}"))?;
2026-05-04 10:13:03 +02:00
let model = normalize_model(model);
let mut args = vec![
2026-03-15 14:51:29 +01:00
"translate".to_string(),
"--summary-file".to_string(),
tmp_summary_path.to_string_lossy().into_owned(),
"--lang".to_string(),
2026-05-04 10:13:03 +02:00
lang.clone(),
2026-03-15 14:51:29 +01:00
"--model".to_string(),
model,
];
2026-05-04 10:13:03 +02:00
let tmp_prompt_path = if let Some(prompt) = normalize_prompt_template(prompt_template) {
let path = state.app_dir.join(format!(
"tmp_translation_prompt_{}_{}.txt",
id,
now_millis()
));
if let Err(err) = fs::write(&path, prompt) {
let _ = fs::remove_file(&tmp_summary_path);
return Err(format!(
"Failed to write temporary translation prompt file: {err}"
));
}
args.push("--prompt-template-file".to_string());
args.push(path.to_string_lossy().into_owned());
Some(path)
} else {
None
};
2026-03-15 14:51:29 +01:00
let result = run_backend_text_command(state, &args);
let _ = fs::remove_file(&tmp_summary_path);
2026-05-04 10:13:03 +02:00
if let Some(path) = tmp_prompt_path {
let _ = fs::remove_file(path);
}
2026-03-15 14:51:29 +01:00
let translation = result?;
2026-05-04 10:13:03 +02:00
let column = match lang.as_str() {
2026-03-15 14:51:29 +01:00
"de" => "summary_de",
"jp" => "summary_jp",
_ => return Err("Unsupported language code.".to_string()),
};
db.execute(
&format!("UPDATE summaries SET {column} = ? WHERE id = ?"),
2026-05-04 10:13:03 +02:00
params![translation, id],
2026-03-15 14:51:29 +01:00
)
.map_err(|err| format!("Failed to save translated summary: {err}"))?;
2026-05-04 10:13:03 +02:00
get_entry_by_id(state, id)
2026-03-15 14:51:29 +01:00
}
#[tauri::command]
fn get_models() -> Result<Vec<String>, String> {
let payload = Client::new()
.get(OLLAMA_TAGS_URL)
.send()
.and_then(|response| response.error_for_status())
.map_err(|err| format!("Failed to query Ollama models: {err}"))?
.json::<OllamaTagsResponse>()
.map_err(|err| format!("Failed to parse Ollama model list: {err}"))?;
Ok(payload.models.into_iter().map(|model| model.name).collect())
}
#[tauri::command]
fn get_summaries(state: State<'_, AppState>) -> Result<Vec<SummaryEntry>, String> {
let db = open_connection(&state)?;
let mut stmt = db
.prepare("SELECT * FROM summaries ORDER BY id DESC")
.map_err(|err| format!("Failed to prepare summary query: {err}"))?;
let rows = stmt
.query_map([], StoredSummary::from_row)
.map_err(|err| format!("Failed to read summaries: {err}"))?;
let mut items = Vec::new();
for row in rows {
let entry = row
.map_err(|err| format!("Failed to decode summary row: {err}"))?
.into_entry(&state);
items.push(entry);
}
Ok(items)
}
#[tauri::command]
async fn summarize_video(
state: State<'_, AppState>,
window: WebviewWindow,
request: SummarizeVideoRequest,
) -> Result<SummaryEntry, String> {
let state = state.inner().clone();
let app = window.app_handle().clone();
let window_label = window.label().to_string();
tauri::async_runtime::spawn_blocking(move || {
summarize_video_inner(&state, &app, &window_label, request)
})
.await
.map_err(|err| format!("Summarize task failed: {err}"))?
}
#[tauri::command]
fn delete_summary(state: State<'_, AppState>, request: DeleteSummaryRequest) -> Result<(), String> {
let db = open_connection(&state)?;
db.execute("DELETE FROM summaries WHERE id = ?", [request.id])
.map_err(|err| format!("Failed to delete summary entry: {err}"))?;
Ok(())
}
#[tauri::command]
async fn translate_summary(
state: State<'_, AppState>,
request: TranslateSummaryRequest,
) -> Result<SummaryEntry, String> {
let state = state.inner().clone();
tauri::async_runtime::spawn_blocking(move || translate_summary_inner(&state, request))
.await
.map_err(|err| format!("Translate task failed: {err}"))?
}
#[tauri::command]
fn open_external(url: String) -> Result<(), String> {
that(url).map_err(|err| format!("Failed to open URL: {err}"))
}
#[tauri::command]
fn open_file(file_path: String) -> Result<(), String> {
let path = Path::new(&file_path);
if !path.exists() {
return Err("Requested file does not exist.".to_string());
}
that(path).map_err(|err| format!("Failed to open file: {err}"))
}
2026-05-04 10:13:03 +02:00
fn install_app_menu(app: &mut tauri::App) -> tauri::Result<()> {
let handle = app.handle();
let menu_builder = MenuBuilder::new(handle);
#[cfg(target_os = "macos")]
let menu_builder = {
let app_menu = SubmenuBuilder::new(handle, "YouTube Summarizer")
.hide()
.hide_others()
.show_all()
.separator()
.quit()
.build()?;
menu_builder.item(&app_menu)
};
let settings_menu = SubmenuBuilder::new(handle, "Settings")
.text("open_settings", "Settings...")
.build()?;
let edit_menu = SubmenuBuilder::new(handle, "Edit")
.undo()
.redo()
.separator()
.cut()
.copy()
.paste()
.select_all()
.build()?;
let menu = menu_builder.item(&settings_menu).item(&edit_menu).build()?;
app.set_menu(menu)?;
Ok(())
}
2026-03-15 14:51:29 +01:00
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
2026-05-04 10:13:03 +02:00
.on_menu_event(|app, event| {
if event.id() == "open_settings" {
let _ = app.emit_to("main", "open-settings", ());
}
})
2026-03-15 14:51:29 +01:00
.setup(|app| {
2026-05-04 10:13:03 +02:00
install_app_menu(app)?;
match ensure_app_state(app.handle()) {
Ok(state) => {
app.manage(state);
Ok(())
}
Err(err) => {
write_startup_error_log(app.handle(), &err);
Err(err.into())
}
}
2026-03-15 14:51:29 +01:00
})
.invoke_handler(tauri::generate_handler![
get_models,
get_summaries,
summarize_video,
delete_summary,
translate_summary,
open_external,
open_file
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}