From 94051dd0f8a6342e4ee0cc157b7247f4202f4770 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Sun, 15 Mar 2026 14:51:29 +0100 Subject: [PATCH] initial commit --- README.md | 113 ++++ backend_cli.py | 93 ++++ icon.png | Bin 0 -> 19195 bytes requirements.txt | 5 + run.bat | 27 + run.sh | 25 + src-tauri/Cargo.toml | 18 + src-tauri/build.rs | 7 + src-tauri/capabilities/default.json | 6 + src-tauri/icons/icon.png | Bin 0 -> 1354 bytes src-tauri/resources/backend/.gitkeep | 1 + src-tauri/resources/ffmpeg/.gitkeep | 1 + src-tauri/src/main.rs | 755 +++++++++++++++++++++++++++ src-tauri/tauri.conf.json | 39 ++ tools/autofill_translations.py | 74 +++ tools/prepare_bundle.py | 161 ++++++ translate_summary.py | 80 +++ ui/index.html | 166 ++++++ ui/renderer.js | 578 ++++++++++++++++++++ youtube_summarizer.py | 693 ++++++++++++++++++++++++ 20 files changed, 2842 insertions(+) create mode 100644 README.md create mode 100644 backend_cli.py create mode 100644 icon.png create mode 100644 requirements.txt create mode 100755 run.bat create mode 100755 run.sh create mode 100644 src-tauri/Cargo.toml create mode 100644 src-tauri/build.rs create mode 100644 src-tauri/capabilities/default.json create mode 100644 src-tauri/icons/icon.png create mode 100644 src-tauri/resources/backend/.gitkeep create mode 100644 src-tauri/resources/ffmpeg/.gitkeep create mode 100644 src-tauri/src/main.rs create mode 100644 src-tauri/tauri.conf.json create mode 100644 tools/autofill_translations.py create mode 100644 tools/prepare_bundle.py create mode 100644 translate_summary.py create mode 100644 ui/index.html create mode 100644 ui/renderer.js create mode 100644 youtube_summarizer.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..2888a03 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# YouTube Summarizer + +This is a local-first desktop app for summarizing YouTube videos with Ollama. + +It uses: + +- Tauri for the desktop shell +- a bundled Python backend for transcript/audio processing in release builds +- Ollama on `localhost` for summarization and translation +- SQLite for local history + +## What It Does + +Given a YouTube URL, the app can: + +- fetch a transcript via the YouTube transcript API or via Whisper +- generate an English summary with a local Ollama model +- optionally translate that summary into German and Japanese +- store the results locally so they can be reopened later + +## Local-Only Behavior + +This repository is intentionally reset to a clean publishable state: + +- no Discord webhook integration +- no remote PHP/MySQL sync +- no bundled production data or pre-filled database +- runtime data is stored in the OS app data directory, not in the repo + +## End User Requirements + +If you ship a built installer, the user should only need: + +- Ollama installed locally +- the Ollama model they want to use pulled locally + +Notes: + +- The installer is designed to bundle the backend helper plus `ffmpeg` / `ffprobe`. +- Whisper model weights are not bundled; the selected Whisper model is downloaded on first use and then cached locally. + +## Developer Requirements + +For development in this repo you still need: + +- Python 3.8+ +- Rust/Cargo +- FFmpeg in `PATH` +- Ollama running locally on `http://localhost:11434` + +Python dependencies are listed in [requirements.txt](/Users/giers/youtube_summarizer/requirements.txt). + +## Run In Development + +macOS/Linux: + +```bash +./run.sh +``` + +Windows: + +```bat +run.bat +``` + +Or directly: + +```bash +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cargo run --manifest-path src-tauri/Cargo.toml +``` + +The app prefers a bundled backend executable when one is present under [src-tauri/resources/backend](/Users/giers/youtube_summarizer/src-tauri/resources/backend), and otherwise falls back to the local Python environment for development. + +## Build A Shippable Bundle + +1. Make sure the build machine has Python, Rust/Cargo, and `ffmpeg` / `ffprobe` available on `PATH`. +2. Run: + +```bash +python3 tools/prepare_bundle.py +``` + +3. Then build the installer: + +```bash +cargo tauri build +``` + +What `tools/prepare_bundle.py` does: + +- installs PyInstaller into the current Python environment +- builds a single-file backend executable from [backend_cli.py](/Users/giers/youtube_summarizer/backend_cli.py) +- copies that executable into [src-tauri/resources/backend](/Users/giers/youtube_summarizer/src-tauri/resources/backend) +- copies `ffmpeg` and `ffprobe` from the build machine into [src-tauri/resources/ffmpeg](/Users/giers/youtube_summarizer/src-tauri/resources/ffmpeg) + +Build once on each target OS you want to ship. For Windows 10, build on Windows. + +## Build On GitHub Actions + +A Windows build workflow is included at [.github/workflows/windows-installer.yml](/Users/giers/youtube_summarizer/.github/workflows/windows-installer.yml). + +It runs on `windows-latest`, installs `ffmpeg` and NSIS, prepares the bundled Python backend with [tools/prepare_bundle.py](/Users/giers/youtube_summarizer/tools/prepare_bundle.py), builds an NSIS installer, and uploads the result as a workflow artifact named `windows-installer`. + +## Notes + +- If Python is not on your `PATH` for development, set `YTS_PYTHON` to the interpreter you want the Tauri backend to use. +- If you want to test a prebuilt backend executable during development, set `YTS_BACKEND_BIN` to its full path. +- If `ffmpeg` or `ffprobe` are not on `PATH` during bundle prep, set `YTS_FFMPEG` and `YTS_FFPROBE` to their full paths before running [tools/prepare_bundle.py](/Users/giers/youtube_summarizer/tools/prepare_bundle.py). +- Generated thumbnails and the SQLite database are created on first run in the app's local data directory. diff --git a/backend_cli.py b/backend_cli.py new file mode 100644 index 0000000..7522f2e --- /dev/null +++ b/backend_cli.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Single CLI entrypoint for the bundled summarizer backend. + +This wrapper lets the Tauri app launch one helper executable in production +while still supporting direct Python execution during development. +""" + +import argparse +import json +import sys +from pathlib import Path + +from translate_summary import translate_summary_text +from youtube_summarizer import process_video + + +DEFAULT_MODEL = "mistral:latest" + + +def configure_stdio() -> None: + """Keep progress output line-buffered for the desktop app.""" + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(line_buffering=True) + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(line_buffering=True) + + +def summarize(args: argparse.Namespace) -> int: + meta = process_video( + args.url, + use_whisper=args.use_whisper, + model=args.model, + output_json=args.output_json, + ) + if not args.output_json: + print(json.dumps(meta, ensure_ascii=False), flush=True) + return 0 + + +def translate(args: argparse.Namespace) -> int: + summary_path = Path(args.summary_file) + summary_text = summary_path.read_text(encoding="utf-8").strip() + if not summary_text: + raise SystemExit("Empty summary text!") + + translation = translate_summary_text(summary_text, args.lang, args.model) + + if args.output_file: + Path(args.output_file).write_text(translation, encoding="utf-8") + else: + print(translation, flush=True) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Bundled backend for YouTube Summarizer") + subparsers = parser.add_subparsers(dest="command", required=True) + + summarize_parser = subparsers.add_parser("summarize", help="Summarize a YouTube video") + summarize_parser.add_argument("--url", required=True, help="YouTube video URL") + summarize_parser.add_argument("--model", default=DEFAULT_MODEL, help="Ollama model to use") + summarize_parser.add_argument( + "--no-whisper", + dest="use_whisper", + action="store_false", + help="Use transcript/subtitle workflows instead of Whisper", + ) + summarize_parser.add_argument( + "--output-json", + help="Write the result metadata to a JSON file instead of stdout", + ) + summarize_parser.set_defaults(use_whisper=True, handler=summarize) + + translate_parser = subparsers.add_parser("translate", help="Translate an English summary") + translate_parser.add_argument("--summary-file", required=True, help="Path to the English summary text") + translate_parser.add_argument("--lang", required=True, choices=["de", "jp"], help="Target language") + translate_parser.add_argument("--model", default=DEFAULT_MODEL, help="Ollama model to use") + translate_parser.add_argument("--output-file", help="Optional path to write the translated text") + translate_parser.set_defaults(handler=translate) + + return parser + + +def main() -> int: + configure_stdio() + parser = build_parser() + args = parser.parse_args() + return args.handler(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..efaa522df4fa4457867000024a911de931892598 GIT binary patch literal 19195 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 SVf_%uwQ$~&xtG3F"] +edition = "2021" + +[build-dependencies] +tauri-build = { version = "2.5.6", features = [] } + +[dependencies] +open = "5.3.3" +reqwest = { version = "0.12.24", default-features = false, features = ["blocking", "json", "rustls-tls"] } +rusqlite = { version = "0.37.0", features = ["bundled"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tauri = { version = "2.10.3", features = ["protocol-asset"] } +tauri-plugin-dialog = "2.6.0" diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..c53d3ba --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,7 @@ +fn main() { + println!( + "cargo:rustc-env=TAURI_BUILD_TARGET={}", + std::env::var("TARGET").expect("TARGET not set by cargo") + ); + tauri_build::build(); +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..b32a4d7 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,6 @@ +{ + "identifier": "default", + "description": "Default capability set for the main window.", + "windows": ["main"], + "permissions": ["core:default", "dialog:allow-confirm"] +} diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a0993731429af9e451f67948514dd242cdd1331f GIT binary patch literal 1354 zcmcJPe`phD7{|YNNiNqmE)mOiYh4moH-*7S8LVSOd)iu4f0SB^h#f3Z*bg?VcD9My z)>FgAoKh80r-&&IQAAxQE>I`l+I1BXCTjPGX_nT&=BlMv2PU+I?0HT$kiiB+?~m`j zJkRI*KJW9s@7?FMHH)X^7v%$(RvoCU0|XihkR^J(({$=Rz&5?QazTCM&S5?mK%>P5?O-R8|f4ZKXaJu8fL2=v;gWx z-GtIMhQ4c*87eTL)h2ZRAVXg>0PXH7uBx0lDgu3-+^?@bY*UN1k${;voe*OM z>gJf~xZOrqz|#CKe!!`Y8MG*EV|OXGKzWs#MenBf0o|8;5%4VwOZo(q*&HVid`sez zDxtzk>!J|Qw4PXRvY1kVjHLHK@&3iSIA3BM-sz$=pj+hxvWTeKpXIXbZF9| z1OKLUo{7nFYpICrBlw=sT*y^sr_~Mp%4-~)$y>11LcU8d7?sd~{H2{L$$uZ=7;Ev4xU#ahv*r z_OLcNkhW``-LK&e>ib7dy34V9U^yRSFnNnI-*?XeYGseUN#`*u@6IWM|@-za7#3 z(JCt1V+ng0-*;(Gzf&`AB>U2KHS(bG?zqM4k7NwCU2dSRWM}Fn(>5lt&8d&YS0L)y zo_Zb8S;kSD<`dh`!}X~?R)`)syGK+N@yc?@B}3sngybo=;B{W, + ffprobe_path: Option, + whisper_cache_dir: PathBuf, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SummarizeVideoRequest { + url: String, + use_whisper: bool, + model: Option, +} + +#[derive(Debug, Deserialize)] +struct DeleteSummaryRequest { + id: i64, +} + +#[derive(Debug, Deserialize)] +struct TranslateSummaryRequest { + id: i64, + lang: String, + model: Option, +} + +#[derive(Debug, Deserialize)] +struct BackendSummaryMeta { + timestamp: String, + video_id: String, + url: String, + video_name: String, + channel: Option, + thumbnail: Option, + audio: Option, + transcript: Option, + summary: String, +} + +#[derive(Debug, Deserialize)] +struct OllamaTagsResponse { + models: Vec, +} + +#[derive(Debug, Deserialize)] +struct OllamaModel { + name: String, +} + +#[derive(Debug)] +struct StoredSummary { + id: i64, + timestamp: Option, + video_id: Option, + url: Option, + video_name: Option, + channel: Option, + thumbnail: Option, + audio: Option, + transcript: Option, + summary_en: Option, + summary_de: Option, + summary_jp: Option, +} + +#[derive(Debug, Serialize)] +struct SummaryEntry { + id: i64, + timestamp: Option, + video_id: Option, + url: Option, + video_name: Option, + channel: Option, + thumbnail: Option, + audio: Option, + transcript: Option, + summary_en: Option, + summary_de: Option, + summary_jp: Option, +} + +impl StoredSummary { + fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + 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) -> Option { + file_name.map(|name| state.media_dir.join(name).to_string_lossy().into_owned()) +} + +fn normalize_model(model: Option) -> String { + model + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_MODEL.to_string()) +} + +fn now_millis() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +fn resolve_project_root() -> Result { + 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 { + let mut candidates = Vec::new(); + + if let Ok(resource_dir) = app.path().resource_dir() { + candidates.push(resource_dir.join(relative_path)); + } + + candidates.push( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join(relative_path), + ); + + candidates.into_iter().find(|path| path.exists()) +} + +fn resolve_backend_binary(app: &AppHandle) -> Option { + 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 { + if let Ok(resource_dir) = app.path().resource_dir() { + if resource_dir.join("backend_cli.py").exists() { + return Ok(resource_dir); + } + } + + 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 { + 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 { + 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 { + 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 { + 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) +} + +fn open_connection(state: &AppState) -> Result { + 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 + .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}"))?; + + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(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(()) +} + +fn ensure_app_state(app: &AppHandle) -> Result { + 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)?, + 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()); + } +} + +fn apply_backend_env(command: &mut Command, state: &AppState) { + command.env("PYTHONUNBUFFERED", "1"); + 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); + command +} + +fn run_backend_json_command( + state: &AppState, + app: &AppHandle, + window_label: &str, + args: &[String], +) -> Result { + 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()); + + let mut child = build_backend_command(state, &command_args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| format!("Failed to start bundled backend: {err}"))?; + + 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())); + + 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 { + Ok(line) => emit_progress(&stdout_app, &stdout_label, &line), + Err(_) => break, + } + } + }); + + let stderr_app = app.clone(); + let stderr_label = window_label.to_string(); + 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) => { + emit_progress(&stderr_app, &stderr_label, &line); + if let Ok(mut buffer) = stderr_buffer_clone.lock() { + buffer.push_str(&line); + buffer.push('\n'); + } + } + Err(_) => break, + } + } + }); + + let status = child + .wait() + .map_err(|err| format!("Failed to wait for bundled backend: {err}"))?; + + 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()); + let message = if stderr_output.is_empty() { + format!("Bundled backend exited with status {status}.") + } else { + stderr_output + }; + let _ = fs::remove_file(&output_path); + return Err(message); + } + + let raw_json = fs::read_to_string(&output_path) + .map_err(|err| format!("Failed to read backend output JSON: {err}"))?; + let _ = fs::remove_file(&output_path); + + serde_json::from_str(&raw_json).map_err(|err| format!("Invalid backend output JSON: {err}")) +} + +fn run_backend_text_command(state: &AppState, args: &[String]) -> Result { + let output = build_backend_command(state, args) + .output() + .map_err(|err| format!("Failed to start translation backend: {err}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + format!("Translation backend exited with status {}.", output.status) + } else { + stderr + }); + } + + 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() { + return Err("Translation backend returned an empty result.".to_string()); + } + + Ok(translation) +} + +fn get_entry_by_id(state: &AppState, id: i64) -> Result { + 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 { + let model = normalize_model(request.model); + let mut args = vec![ + "summarize".to_string(), + "--url".to_string(), + request.url, + "--model".to_string(), + model, + ]; + if !request.use_whisper { + args.push("--no-whisper".to_string()); + } + + let info = run_backend_json_command(state, app, window_label, &args)?; + 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::::None, + Option::::None, + info.summary, + Option::::None, + Option::::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 { + let db = open_connection(state)?; + let summary_text = db + .query_row( + "SELECT summary_en FROM summaries WHERE id = ?", + [request.id], + |row| row.get::<_, Option>(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())?; + + let tmp_summary_path = + state + .app_dir + .join(format!("tmp_summary_{}_{}.txt", request.id, now_millis())); + fs::write(&tmp_summary_path, summary_text) + .map_err(|err| format!("Failed to write temporary summary file: {err}"))?; + + let model = normalize_model(request.model); + let args = vec![ + "translate".to_string(), + "--summary-file".to_string(), + tmp_summary_path.to_string_lossy().into_owned(), + "--lang".to_string(), + request.lang.clone(), + "--model".to_string(), + model, + ]; + let result = run_backend_text_command(state, &args); + + let _ = fs::remove_file(&tmp_summary_path); + let translation = result?; + + let column = match request.lang.as_str() { + "de" => "summary_de", + "jp" => "summary_jp", + _ => return Err("Unsupported language code.".to_string()), + }; + + db.execute( + &format!("UPDATE summaries SET {column} = ? WHERE id = ?"), + params![translation, request.id], + ) + .map_err(|err| format!("Failed to save translated summary: {err}"))?; + + get_entry_by_id(state, request.id) +} + +#[tauri::command] +fn get_models() -> Result, 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::() + .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, 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 { + 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 { + 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}")) +} + +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .setup(|app| { + let state = ensure_app_state(app.handle())?; + app.manage(state); + Ok(()) + }) + .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"); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..9e8f15f --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "YouTube Summarizer", + "version": "1.0.0", + "identifier": "com.victorgiers.youtube-summarizer", + "build": { + "frontendDist": "../ui" + }, + "app": { + "withGlobalTauri": true, + "security": { + "assetProtocol": { + "enable": true, + "scope": ["$APPLOCALDATA/data/**"] + }, + "csp": null + }, + "windows": [ + { + "label": "main", + "title": "YouTube Summarizer", + "width": 1104, + "height": 800, + "resizable": true + } + ] + }, + "bundle": { + "active": true, + "resources": [ + "../backend_cli.py", + "../youtube_summarizer.py", + "../translate_summary.py", + "../requirements.txt", + "resources/backend", + "resources/ffmpeg" + ] + } +} diff --git a/tools/autofill_translations.py b/tools/autofill_translations.py new file mode 100644 index 0000000..9de497b --- /dev/null +++ b/tools/autofill_translations.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 + +import os +import sqlite3 +import subprocess +import sys + +DB_FILE = os.path.join(os.path.dirname(__file__), 'summaries.db') +TRANSLATE_SCRIPT = os.path.join(os.path.dirname(__file__), 'translate_summary.py') +MODEL = "mistral-small3.1:24b" + +def get_entries_needing_translation(conn): + cursor = conn.cursor() + cursor.execute( + "SELECT id, summary_en, summary_de, summary_jp FROM summaries" + ) + return [ + (row[0], row[1], row[2], row[3]) + for row in cursor.fetchall() + if row[1] and (not row[2] or not row[3]) # summary_en vorhanden, mind. eine Übersetzung fehlt + ] + +def translate(summary_text, lang): + # Schreibe summary_text temporär in Datei + import tempfile + with tempfile.NamedTemporaryFile('w+', delete=False, suffix='.txt', encoding='utf-8') as f: + f.write(summary_text) + tmp_summary_path = f.name + try: + # Führe das Übersetzungsskript aus + cmd = [ + sys.executable, # benutzt aktuelles Python + TRANSLATE_SCRIPT, + "--summary-file", tmp_summary_path, + "--lang", lang, + "--model", MODEL, + ] + print(f"[{lang}] Translating with: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + translation = result.stdout.strip() + return translation + finally: + os.remove(tmp_summary_path) + +def main(): + conn = sqlite3.connect(DB_FILE) + cursor = conn.cursor() + entries = get_entries_needing_translation(conn) + print(f"Found {len(entries)} entries needing translation.") + for entry_id, summary_en, summary_de, summary_jp in entries: + updated = False + if not summary_de: + print(f"Translating to DE for entry id {entry_id}…") + try: + translation = translate(summary_en, "de") + cursor.execute("UPDATE summaries SET summary_de = ? WHERE id = ?", (translation, entry_id)) + updated = True + except Exception as e: + print(f"Failed to translate DE for id {entry_id}: {e}") + if not summary_jp: + print(f"Translating to JP for entry id {entry_id}…") + try: + translation = translate(summary_en, "jp") + cursor.execute("UPDATE summaries SET summary_jp = ? WHERE id = ?", (translation, entry_id)) + updated = True + except Exception as e: + print(f"Failed to translate JP for id {entry_id}: {e}") + if updated: + conn.commit() + conn.close() + print("Done.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tools/prepare_bundle.py b/tools/prepare_bundle.py new file mode 100644 index 0000000..ef7a25a --- /dev/null +++ b/tools/prepare_bundle.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +""" +Prepare local bundle assets for a distributable Tauri build. + +This script: +1. installs PyInstaller into the current Python environment +2. builds the bundled backend helper as a single executable +3. copies ffmpeg / ffprobe from the local PATH into Tauri resources + +It targets the current host platform. Run it once per build machine before +`cargo tauri build`. +""" + +from __future__ import annotations + +import os +import shutil +import stat +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SRC_TAURI = ROOT / "src-tauri" +BACKEND_ROOT = SRC_TAURI / "resources" / "backend" +FFMPEG_ROOT = SRC_TAURI / "resources" / "ffmpeg" +BUILD_DIR = ROOT / "build" +DIST_DIR = BUILD_DIR / "pyinstaller-dist" +WORK_DIR = BUILD_DIR / "pyinstaller-work" +SPEC_DIR = BUILD_DIR / "pyinstaller-spec" +BACKEND_NAME = "yts-backend" + + +def run(cmd: list[str]) -> None: + subprocess.run(cmd, check=True, cwd=ROOT) + + +def detect_target_triple() -> str: + try: + output = subprocess.check_output(["rustc", "--print", "host-tuple"], text=True) + return output.strip() + except subprocess.CalledProcessError: + verbose = subprocess.check_output(["rustc", "-Vv"], text=True) + for line in verbose.splitlines(): + if line.startswith("host: "): + return line.split(": ", 1)[1].strip() + raise SystemExit("Unable to determine the Rust host target triple.") + + +def executable_suffix() -> str: + return ".exe" if os.name == "nt" else "" + + +def ensure_pyinstaller() -> None: + run([sys.executable, "-m", "pip", "install", "--upgrade", "pip"]) + run([sys.executable, "-m", "pip", "install", "-r", str(ROOT / "requirements.txt"), "pyinstaller"]) + + +def build_backend_binary() -> Path: + DIST_DIR.mkdir(parents=True, exist_ok=True) + WORK_DIR.mkdir(parents=True, exist_ok=True) + SPEC_DIR.mkdir(parents=True, exist_ok=True) + + cmd = [ + sys.executable, + "-m", + "PyInstaller", + "--noconfirm", + "--clean", + "--onefile", + "--name", + BACKEND_NAME, + "--distpath", + str(DIST_DIR), + "--workpath", + str(WORK_DIR), + "--specpath", + str(SPEC_DIR), + "--paths", + str(ROOT), + "--collect-submodules", + "whisper", + "--collect-submodules", + "yt_dlp", + "--collect-submodules", + "youtube_transcript_api", + "--collect-data", + "whisper", + "--collect-data", + "yt_dlp", + "--collect-data", + "webvtt", + "--collect-data", + "youtube_transcript_api", + str(ROOT / "backend_cli.py"), + ] + run(cmd) + binary = DIST_DIR / f"{BACKEND_NAME}{executable_suffix()}" + if not binary.exists(): + raise SystemExit(f"Expected backend binary was not produced: {binary}") + return binary + + +def install_sidecar(binary: Path, target_triple: str) -> Path: + target_dir = BACKEND_ROOT / target_triple + target_dir.mkdir(parents=True, exist_ok=True) + target = target_dir / f"{BACKEND_NAME}{executable_suffix()}" + shutil.copy2(binary, target) + if os.name != "nt": + target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return target + + +def resolve_tool_source(env_name: str, tool_name: str) -> Path: + override = os.environ.get(env_name, "").strip() + if override: + return Path(override).expanduser().resolve() + + source = shutil.which(tool_name) + if not source: + raise SystemExit( + f"Required build dependency not found: {tool_name}. " + f"Put it on PATH or set {env_name}." + ) + return Path(source).resolve() + + +def copy_tool_to_resources(env_name: str, tool_name: str, resource_dir: Path) -> Path: + source_path = resolve_tool_source(env_name, tool_name) + destination = resource_dir / source_path.name + shutil.copy2(source_path, destination) + if os.name != "nt": + destination.chmod(destination.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return destination + + +def install_ffmpeg_resources(target_triple: str) -> tuple[Path, Path]: + resource_dir = FFMPEG_ROOT / target_triple + resource_dir.mkdir(parents=True, exist_ok=True) + ffmpeg = copy_tool_to_resources("YTS_FFMPEG", "ffmpeg", resource_dir) + ffprobe = copy_tool_to_resources("YTS_FFPROBE", "ffprobe", resource_dir) + return ffmpeg, ffprobe + + +def main() -> int: + target_triple = detect_target_triple() + ensure_pyinstaller() + backend_binary = build_backend_binary() + sidecar = install_sidecar(backend_binary, target_triple) + ffmpeg, ffprobe = install_ffmpeg_resources(target_triple) + + print(f"Prepared backend sidecar: {sidecar}") + print(f"Prepared ffmpeg resource: {ffmpeg}") + print(f"Prepared ffprobe resource: {ffprobe}") + print("Next step: cargo tauri build") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/translate_summary.py b/translate_summary.py new file mode 100644 index 0000000..2d0204a --- /dev/null +++ b/translate_summary.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +translate_summary.py + +Usage: + python3 translate_summary.py --summary-file --lang [--model ] [--output-file ] + +Arguments: + --summary-file Path to the file containing the English summary text. + --lang Target language ('de' for German, 'jp' for Japanese). + --model (Optional) Ollama model name, defaults to mistral:latest. + --output-file (Optional) Where to write translated summary as plain text. + +Example: + python3 translate_summary.py --summary-file summary.txt --lang de --model mistral:latest +""" + +import sys +import argparse +import json +import requests + +LANG_MAP = { + "de": "German", + "jp": "Japanese" +} + +def translate_summary_text(summary_text, target_language, model="mistral:latest"): + if target_language not in LANG_MAP: + raise ValueError("Supported languages: de (German), jp (Japanese)") + prompt = ( + f"Translate the following summary into {LANG_MAP[target_language]}. Only output the translated summary, " + "no explanation or intro. If it's already in the target language, do nothing but repeat it.\n\n" + f"Summary:\n{summary_text}\n\nTranslation:" + ) + payload = { + "model": model, + "messages": [ + {"role": "system", "content": f"You are an expert translator proficient in {LANG_MAP[target_language]} and English."}, + {"role": "user", "content": prompt} + ], + "stream": False + } + resp = requests.post("http://localhost:11434/api/chat", json=payload) + resp.raise_for_status() + data = resp.json() + return data.get("message", {}).get("content", "").strip() + + +def translate_summary_file(summary_file, target_language, model="mistral:latest"): + with open(summary_file, "r", encoding="utf-8") as f: + summary_text = f.read().strip() + if not summary_text: + raise ValueError("Empty summary text!") + return translate_summary_text(summary_text, target_language, model) + +def main(): + parser = argparse.ArgumentParser(description="Translate summary using Ollama") + parser.add_argument("--summary-file", required=True, help="Path to file with English summary text") + parser.add_argument("--lang", required=True, choices=["de", "jp"], help="Target language: 'de' or 'jp'") + parser.add_argument("--model", default="mistral:latest", help="Ollama model to use") + parser.add_argument("--output-file", help="Output file for translated summary") + args = parser.parse_args() + + # Read summary + try: + translation = translate_summary_file(args.summary_file, args.lang, args.model) + except Exception as e: + print(f"Translation failed: {e}", file=sys.stderr) + sys.exit(2) + + # Output result + if args.output_file: + with open(args.output_file, "w", encoding="utf-8") as f: + f.write(translation) + else: + print(translation) + +if __name__ == "__main__": + main() diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..d9590b3 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,166 @@ + + + + + + YouTube Summaries + + + +
+
+ + +
+ + +
+ +
+ +
+ +
+ + + + diff --git a/ui/renderer.js b/ui/renderer.js new file mode 100644 index 0000000..c69cfd7 --- /dev/null +++ b/ui/renderer.js @@ -0,0 +1,578 @@ +const tauriApi = window.__TAURI__; +const invoke = tauriApi?.core?.invoke; +const listen = tauriApi?.event?.listen; +const convertFileSrc = tauriApi?.core?.convertFileSrc; +const confirmDialog = tauriApi?.dialog?.confirm; + +if (!invoke || !listen) { + throw new Error('Tauri runtime API is unavailable.'); +} + +function toWebviewFileUrl(filePath) { + if (!filePath) { + return filePath; + } + if (typeof convertFileSrc === 'function') { + return convertFileSrc(filePath); + } + return filePath; +} + +window.api = { + getModels: () => invoke('get_models'), + getSummaries: () => invoke('get_summaries'), + summarizeVideo: (url, useWhisper, model) => invoke('summarize_video', { + request: { + url, + useWhisper, + model: model || null + } + }), + openExternal: (url) => invoke('open_external', { url }), + openFile: (filePath) => invoke('open_file', { filePath }), + deleteSummary: (id) => invoke('delete_summary', { + request: { id } + }), + translateSummary: (id, lang, model) => invoke('translate_summary', { + request: { + id, + lang, + model: model || null + } + }), + onSummarizeProgress: (callback) => listen('summarize-progress', (event) => { + callback(String(event.payload || '')); + }) +}; + +window.addEventListener('DOMContentLoaded', async () => { + const form = document.getElementById('summarize-form'); + const urlInput = document.getElementById('url-input'); + const whisperCheckbox = document.getElementById('whisper-checkbox'); + const summariesContainer = document.getElementById('summaries-container'); + const loadingIndicator = document.getElementById('loading'); + const modelSelect = document.getElementById('model-select'); + const paginationTop = document.getElementById('pagination-top'); + const paginationBottom = document.getElementById('pagination-bottom'); + const summarizeButton = form.querySelector('button[type="submit"]'); + const autoTranslateCheckbox = document.getElementById('autotranslate-checkbox'); + + let fullSummaries = []; + let currentPage = 1; + const PAGE_SIZE = 20; + let isLoading = false; + let entryUiState = {}; + + function setLoadingMessage(message) { + if (!isLoading) { + return; + } + loadingIndicator.style.display = 'inline'; + loadingIndicator.textContent = message; + } + + whisperCheckbox.checked = localStorage.getItem('useWhisper') === '0' ? false : true; + autoTranslateCheckbox.checked = localStorage.getItem('autoTranslate') === '1' ? true : false; + + whisperCheckbox.addEventListener('change', () => { + localStorage.setItem('useWhisper', whisperCheckbox.checked ? '1' : '0'); + }); + autoTranslateCheckbox.addEventListener('change', () => { + localStorage.setItem('autoTranslate', autoTranslateCheckbox.checked ? '1' : '0'); + }); + + function renderSummaries(list) { + summariesContainer.innerHTML = ''; + const renderedIds = new Set(); + + list.forEach(item => { + renderedIds.add(item.id); + if (!entryUiState[item.id]) { + entryUiState[item.id] = { expanded: false, lang: 'en' }; + } + let { expanded, lang } = entryUiState[item.id]; + + const entry = document.createElement('div'); + entry.classList.add('entry'); + entry.style.overflow = 'hidden'; + + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.innerHTML = '×'; + deleteButton.classList.add('delete-entry-button'); + deleteButton.style.width = '24px'; + deleteButton.style.height = '24px'; + deleteButton.style.display = 'flex'; + deleteButton.style.alignItems = 'center'; + deleteButton.style.justifyContent = 'center'; + deleteButton.style.border = 'none'; + deleteButton.style.background = 'transparent'; + deleteButton.style.color = '#9f1239'; + deleteButton.style.fontSize = '22px'; + deleteButton.style.fontWeight = 'normal'; + deleteButton.style.cursor = 'pointer'; + deleteButton.style.padding = '0'; + deleteButton.style.lineHeight = '1'; + deleteButton.disabled = isLoading; + deleteButton.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (isLoading) { + return; + } + if (typeof confirmDialog !== 'function') { + alert('Delete confirmation is unavailable.'); + return; + } + confirmDialog('Are you sure you want to delete this entry?', { + title: 'Delete entry', + kind: 'warning' + }).then((confirmed) => { + if (!confirmed) { + return; + } + window.api.deleteSummary(item.id) + .then(() => { + delete entryUiState[item.id]; + return window.api.getSummaries().then(setSummaries); + }) + .catch(err => { + alert('Error deleting summary: ' + err.message); + }); + }); + }); + const left = document.createElement('div'); + left.classList.add('left'); + if (item.thumbnail) { + const img = document.createElement('img'); + img.src = toWebviewFileUrl(item.thumbnail); + img.alt = item.video_name; + img.classList.add('thumbnail'); + if (item.url) { + img.style.cursor = 'pointer'; + img.title = 'Open video'; + img.addEventListener('click', (e) => { + e.stopPropagation(); + window.api.openExternal(item.url); + }); + } + left.appendChild(img); + } + + const langSwitcher = document.createElement('span'); + langSwitcher.style.display = 'flex'; + langSwitcher.style.gap = '6px'; + langSwitcher.style.marginTop = '8px'; + langSwitcher.style.marginBottom = '2px'; + + const summaryFields = { + en: item.summary_en, + de: item.summary_de, + jp: item.summary_jp + }; + + ['en', 'de', 'jp'].forEach(thisLang => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.textContent = thisLang.toUpperCase(); + btn.style.fontSize = '12px'; + btn.style.padding = '2px 8px'; + btn.style.borderRadius = '5px'; + btn.style.border = '1px solid #eee'; + btn.style.background = (thisLang === lang) ? '#9f1239' : '#fff1f2'; + btn.style.color = (thisLang === lang) ? '#fff' : '#9f1239'; + btn.disabled = isLoading; + btn.addEventListener('click', () => { + lang = thisLang; + entryUiState[item.id].lang = lang; + renderSummaryContent(); + Array.from(langSwitcher.children).forEach((button, index) => { + const language = ['en', 'de', 'jp'][index]; + button.style.background = (language === lang) ? '#9f1239' : '#fff1f2'; + button.style.color = (language === lang) ? '#fff' : '#9f1239'; + }); + }); + langSwitcher.appendChild(btn); + }); + left.appendChild(langSwitcher); + + const middle = document.createElement('div'); + middle.classList.add('middle'); + const headline = document.createElement('div'); + headline.style.display = 'flex'; + headline.style.alignItems = 'center'; + headline.style.justifyContent = 'space-between'; + headline.style.gap = '12px'; + const headlineMain = document.createElement('div'); + headlineMain.style.display = 'flex'; + headlineMain.style.alignItems = 'center'; + headlineMain.style.minWidth = '0'; + const titleEl = document.createElement('strong'); + titleEl.style.display = 'block'; + titleEl.style.fontSize = '16px'; + titleEl.style.cursor = 'default'; + titleEl.style.marginLeft = '0'; + titleEl.textContent = item.video_name; + + const arrow = document.createElement('span'); + arrow.textContent = expanded ? '▼' : '▶'; + arrow.style.marginRight = '8px'; + arrow.style.marginLeft = '0'; + arrow.style.fontSize = '18px'; + arrow.style.userSelect = 'none'; + arrow.style.transition = 'transform 0.15s'; + + headlineMain.appendChild(arrow); + headlineMain.appendChild(titleEl); + headline.appendChild(headlineMain); + headline.appendChild(deleteButton); + + const channelEl = document.createElement('span'); + channelEl.style.fontSize = '14px'; + channelEl.style.opacity = '0.8'; + channelEl.style.marginBottom = '12px'; + channelEl.textContent = item.channel || ''; + channelEl.style.display = 'block'; + channelEl.style.marginTop = '2px'; + + middle.appendChild(headline); + middle.appendChild(channelEl); + + const summaryHTML = document.createElement('div'); + summaryHTML.classList.add('summary'); + summaryHTML.style.display = '-webkit-box'; + summaryHTML.style.webkitBoxOrient = 'vertical'; + summaryHTML.style.overflow = 'hidden'; + summaryHTML.style.transition = 'max-height 0.2s'; + + function renderSummaryContent() { + const text = summaryFields[lang]; + summaryHTML.innerHTML = ''; + if (text && text.trim()) { + summaryHTML.innerHTML = markdownToHTML(text); + } else { + const missingMsg = document.createElement('span'); + missingMsg.textContent = ( + lang === 'de' ? 'German not available. ' : + lang === 'jp' ? 'Japanese not available. ' : + 'Not available. ' + ); + summaryHTML.appendChild(missingMsg); + } + if (!expanded) { + summaryHTML.style.webkitLineClamp = '2'; + summaryHTML.style.maxHeight = '2.8em'; + } else { + summaryHTML.style.webkitLineClamp = ''; + summaryHTML.style.maxHeight = ''; + } + } + + middle.appendChild(summaryHTML); + + entry.appendChild(left); + entry.appendChild(middle); + + summariesContainer.appendChild(entry); + + function applyCollapsedStyle() { + if (!expanded) { + entry.classList.add('collapsed'); + arrow.textContent = '▶'; + } else { + entry.classList.remove('collapsed'); + arrow.textContent = '▼'; + } + renderSummaryContent(); + } + applyCollapsedStyle(); + + middle.addEventListener('click', () => { + if (!expanded) { + expanded = true; + entryUiState[item.id].expanded = true; + applyCollapsedStyle(); + } + }); + + headline.addEventListener('click', (e) => { + if (expanded) { + expanded = false; + entryUiState[item.id].expanded = false; + applyCollapsedStyle(); + e.stopPropagation(); + } + }); + }); + + Object.keys(entryUiState).forEach(id => { + if (!renderedIds.has(Number(id))) { + delete entryUiState[id]; + } + }); + + setActionLinksDisabled(isLoading); + } + + function markdownToHTML(text) { + text = text.replace(/<\/think(?:ing)?>[^\S\n]*\n+[^\S\n]*/gi, ''); + text = text.replace( + /(^|\n)\s*[\s\S]*?<\/think(?:ing)?>\s*(\n\s*\n)?/gi, + (_, lead) => (lead ? '\n' : '') + ); + + let tmp = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + tmp = tmp.replace( + /(^|\n)\s*[\s\S]*?<\/think(?:ing)?>\s*(?=\n|$)/gi, + (_, lead) => (lead ? '\n' : '') + ); + + const codeblocks = []; + const placeholder = idx => `@@CODEBLOCK${idx}@@`; + tmp = tmp.replace(/```([\s\S]*?)```/g, (_, code) => { + codeblocks.push(code); + return placeholder(codeblocks.length - 1); + }); + + let escaped = tmp + .replace(/&/g, '&') + .replace(//g, '>'); + + escaped = escaped + .replace(/^#### (.+)$/gm, '

$1

') + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

'); + + escaped = escaped.replace( + /(^|\n)([ \t]*\* .+(?:\n[ \t]*\* .+)*)/g, + (_, lead, listBlock) => { + const items = listBlock + .split(/\n/) + .map(line => line.replace(/^[ \t]*\*\s+/, '').trim()) + .map(item => `
  • ${item}
  • `) + .join(''); + return `${lead}
      ${items}
    `; + } + ); + + let html = escaped + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/(?$1') + .replace(/`(.+?)`/g, '$1'); + + html = html.replace(/@@CODEBLOCK(\d+)@@/g, (_, idx) => { + const code = codeblocks[Number(idx)]; + return `
    ${code}
    `; + }); + + html = html.replace(/\n*(.*?<\/h[1-3]>)\n*/g, '$1\n'); + html = html.replace(/\n/g, '
    '); + html = html + .replace(/
    \s*()/g, '$1') + .replace(/(<\/h[1-3]>)\s*
    /g, '$1'); + + return html; + } + + function setActionLinksDisabled(disabled) { + document.querySelectorAll('.delete-entry-button').forEach(button => { + if (disabled) { + button.disabled = true; + button.style.opacity = '0.5'; + } else { + button.disabled = false; + button.style.opacity = ''; + } + }); + document.querySelectorAll('.left button').forEach(btn => { + btn.disabled = disabled; + btn.style.opacity = disabled ? '0.5' : ''; + }); + } + + function updatePaginationControls() { + if (!fullSummaries || fullSummaries.length <= PAGE_SIZE) { + paginationTop.style.display = 'none'; + paginationBottom.style.display = 'none'; + return; + } + paginationTop.style.display = 'flex'; + paginationBottom.style.display = 'flex'; + const totalPages = Math.ceil(fullSummaries.length / PAGE_SIZE); + + const buildNav = (container) => { + container.innerHTML = ''; + + const prevBtn = document.createElement('button'); + prevBtn.textContent = '«'; + prevBtn.disabled = currentPage === 1; + prevBtn.addEventListener('click', () => { + if (currentPage > 1) { + showPage(currentPage - 1); + updatePaginationControls(); + } + }); + container.appendChild(prevBtn); + + for (let i = 1; i <= totalPages; i += 1) { + const btn = document.createElement('button'); + btn.textContent = i; + if (i === currentPage) { + btn.classList.add('active'); + } + btn.addEventListener('click', () => { + showPage(i); + updatePaginationControls(); + }); + container.appendChild(btn); + } + + const nextBtn = document.createElement('button'); + nextBtn.textContent = '»'; + nextBtn.disabled = currentPage === totalPages; + nextBtn.addEventListener('click', () => { + if (currentPage < totalPages) { + showPage(currentPage + 1); + updatePaginationControls(); + } + }); + container.appendChild(nextBtn); + }; + + buildNav(paginationTop); + buildNav(paginationBottom); + } + + function showPage(page) { + const totalPages = Math.ceil(fullSummaries.length / PAGE_SIZE); + currentPage = Math.max(1, Math.min(page, totalPages || 1)); + const start = (currentPage - 1) * PAGE_SIZE; + const end = start + PAGE_SIZE; + renderSummaries(fullSummaries.slice(start, end)); + } + + function setSummaries(list) { + fullSummaries = list || []; + const totalPages = Math.ceil(fullSummaries.length / PAGE_SIZE); + if (currentPage > totalPages) { + currentPage = Math.max(1, totalPages); + } + showPage(currentPage); + updatePaginationControls(); + } + + try { + const models = await window.api.getModels(); + modelSelect.innerHTML = ''; + const hasMistral = Array.isArray(models) && models.includes('mistral:latest'); + const placeholder = document.createElement('option'); + placeholder.disabled = true; + placeholder.value = ''; + placeholder.innerText = 'Select model'; + modelSelect.appendChild(placeholder); + if (Array.isArray(models)) { + models.forEach(name => { + const option = document.createElement('option'); + option.value = name; + option.innerText = name; + modelSelect.appendChild(option); + }); + } + const saved = localStorage.getItem('selectedModel'); + let toSelect = ''; + if (saved && models.includes(saved)) { + toSelect = saved; + } else if (hasMistral) { + toSelect = 'mistral:latest'; + } + if (toSelect) { + modelSelect.value = toSelect; + placeholder.selected = false; + } else { + placeholder.selected = true; + } + } catch (err) { + console.error('Error loading models:', err); + modelSelect.innerHTML = ''; + const placeholder = document.createElement('option'); + placeholder.disabled = true; + placeholder.selected = true; + placeholder.value = ''; + placeholder.innerText = 'Select model'; + modelSelect.appendChild(placeholder); + } + + modelSelect.addEventListener('change', () => { + localStorage.setItem('selectedModel', modelSelect.value); + }); + + window.api.getSummaries().then(setSummaries).catch(console.error); + + form.addEventListener('submit', (e) => { + e.preventDefault(); + const url = urlInput.value.trim(); + const useWhisper = whisperCheckbox.checked; + const autoTranslate = autoTranslateCheckbox.checked; + if (!url || isLoading) { + return; + } + + isLoading = true; + summarizeButton.disabled = true; + setLoadingMessage('Summarizing…'); + setActionLinksDisabled(true); + + const selectedModel = modelSelect.value; + window.api.summarizeVideo(url, useWhisper, selectedModel) + .then((newEntry) => { + if (!newEntry || !newEntry.id) { + return window.api.getSummaries().then(setSummaries); + } + + entryUiState[newEntry.id] = { expanded: true, lang: 'en' }; + + if (!autoTranslate) { + return window.api.getSummaries().then(setSummaries); + } + + let translationsOk = true; + setLoadingMessage('Translating to German (DE)…'); + return window.api.translateSummary(newEntry.id, 'de', selectedModel) + .then(() => { + setLoadingMessage('Translating to Japanese (JP)…'); + return window.api.translateSummary(newEntry.id, 'jp', selectedModel); + }) + .catch(err => { + translationsOk = false; + alert('Error translating summary: ' + err.message); + }) + .then(() => { + entryUiState[newEntry.id] = { + expanded: true, + lang: translationsOk ? 'jp' : 'en' + }; + return window.api.getSummaries().then(setSummaries); + }); + }) + .catch(err => { + alert('Error summarizing video: ' + err.message); + }) + .finally(() => { + loadingIndicator.style.display = 'none'; + loadingIndicator.textContent = 'Loading…'; + summarizeButton.disabled = false; + isLoading = false; + setActionLinksDisabled(false); + urlInput.value = ''; + }); + }); + + window.api.onSummarizeProgress(line => { + if (!isLoading || !line) { + return; + } + setLoadingMessage(line); + }); +}); diff --git a/youtube_summarizer.py b/youtube_summarizer.py new file mode 100644 index 0000000..0dec6f7 --- /dev/null +++ b/youtube_summarizer.py @@ -0,0 +1,693 @@ +#!/usr/bin/env python3 +""" +youtube_summarizer.py + +This script accepts a YouTube URL, retrieves a transcript either via the +YouTube API or via Whisper (depending on the flags), generates a concise +summary using Ollama and optionally writes a JSON descriptor containing +metadata about the processed video. The metadata includes the video +identifier, original URL, title, downloaded thumbnail filename, audio +filename, transcript filename and the summary text itself. The script +has been adapted from an earlier command‑line tool to better integrate +with a GUI. The summarizer now returns the summary text instead of +printing it directly and supports additional command line arguments for +JSON output. + +Usage: + python3 youtube_summarizer.py [--no-ai] [--output-json ] + +Options: + --no-ai Use the classic API/subtitle workflow instead of Whisper for + transcription (default uses Whisper). + --output-json Specify a file path where metadata about the processed video + will be written as JSON. If omitted the metadata is + printed to standard output in JSON format. + +This script relies on yt_dlp for fetching video metadata, requests for +thumbnail download and the whisper and youtube_transcript_api packages for +transcription. + +""" + +import sys +import os +import re +import time +import json +import glob +import subprocess +import multiprocessing +import requests +import yt_dlp +import webvtt +from datetime import datetime +from typing import List, Tuple, Optional +from xml.parsers.expat import ExpatError +from xml.etree.ElementTree import ParseError +from youtube_transcript_api import YouTubeTranscriptApi +from youtube_transcript_api._errors import TranscriptsDisabled, NoTranscriptFound + +try: + import whisper +except ImportError: + whisper = None # handle gracefully if whisper isn't installed + +# ----------------------- +# Konfiguration & Flags +# ----------------------- +DEBUG = False + +# Whisper‑Settings +NUM_SLICES = 8 +OVERLAP_SEC = 1 +MAX_OVERLAP_WORDS = 7 +WHISPER_MODEL = "small" # e.g. "small", "medium", "large-v3" … + + +def debug_print(*args, **kwargs): + """Print debug messages when DEBUG is enabled.""" + if DEBUG: + print("[DEBUG]", *args, **kwargs, file=sys.stderr) + + +def get_ffmpeg_binary() -> str: + """Return the ffmpeg executable path, preferring a bundled override.""" + value = os.environ.get("YTS_FFMPEG", "").strip() + return value or "ffmpeg" + + +def get_ffprobe_binary() -> str: + """Return the ffprobe executable path, preferring a bundled override.""" + value = os.environ.get("YTS_FFPROBE", "").strip() + return value or "ffprobe" + + +def get_whisper_download_root() -> Optional[str]: + """Return a stable Whisper cache directory when one is configured.""" + value = os.environ.get("YTS_WHISPER_CACHE_DIR", "").strip() + if not value: + return None + os.makedirs(value, exist_ok=True) + return value + + +# ----------------------- +# 1) Utilities +# ----------------------- + +def extract_video_id(url: str) -> Optional[str]: + """Extract the eleven character YouTube video ID from a URL.""" + debug_print(f"Extracting video ID from URL: {url}") + m = re.search(r'(?:v=|youtu\.be/)([0-9A-Za-z_-]{11})', url) + vid = m.group(1) if m else None + debug_print(f"Video ID: {vid}") + return vid + + +def get_transcript_api(video_id: str) -> str: + """ + Fetch transcript via YouTubeTranscriptApi, trying 'en', then 'de', then any available language. + """ + debug_print(f"Trying transcript API for {video_id}") + + # Try English first + try: + data = YouTubeTranscriptApi.get_transcript(video_id, languages=["en"]) + text = " ".join(item["text"] for item in data) + debug_print(f"Transcript fetched in EN, length {len(text)} chars") + return text + except (TranscriptsDisabled, NoTranscriptFound): + pass + + # Try German + try: + data = YouTubeTranscriptApi.get_transcript(video_id, languages=["de"]) + text = " ".join(item["text"] for item in data) + debug_print(f"Transcript fetched in DE, length {len(text)} chars") + return text + except (TranscriptsDisabled, NoTranscriptFound): + pass + + # Try any available language (prefer auto-generated if possible) + try: + tx_list = YouTubeTranscriptApi.list_transcripts(video_id) + # Try manually created first + for tr in tx_list: + try: + if not tr.is_generated: + data = tr.fetch() + text = " ".join(item["text"] for item in data) + debug_print(f"Transcript fetched: {tr.language_code} (manual)") + return text + except Exception: + continue + # Then fallback to auto-generated + for tr in tx_list: + try: + if tr.is_generated: + data = tr.fetch() + text = " ".join(item["text"] for item in data) + debug_print(f"Transcript fetched: {tr.language_code} (auto-generated)") + return text + except Exception: + continue + except Exception as e: + debug_print(f"list_transcripts failed: {e}") + + # Nothing found, fail with info + raise SystemExit( + "No transcript available in EN, DE or any other language via API. " + "Try 'Use Whisper' mode or wait if you hit a YouTube rate limit." + ) + + +def vtt_to_lines(path: str) -> List[str]: + """Convert a VTT file into deduplicated lines of text.""" + cues, last = [], None + for caption in webvtt.read(path): + cur = caption.text.replace("\n", " ").strip() + if not cur or cur == last: + continue + if last and cur.startswith(last): + cur = cur[len(last):].strip(" -") + cues.append(cur) + last = caption.text.replace("\n", " ").strip() + return cues + + +def remove_consecutive_line_duplicates(lines: List[str]) -> List[str]: + """Remove consecutive duplicate lines.""" + deduped, last = [], None + for l in lines: + if l != last: + deduped.append(l) + last = l + return deduped + + +def remove_phrase_duplicates_from_lines(lines: List[str]) -> List[str]: + """Remove duplicate phrases within lines (used for subtitle deduplication).""" + out, last = [], None + for l in lines: + if last and l.startswith(last): + trimmed = l[len(last):].strip() + if trimmed: + out.append(trimmed) + else: + out.append(l) + last = l + return out + + +def remove_empty_lines(lines: List[str]) -> List[str]: + """Remove empty lines.""" + return [l for l in lines if l.strip()] + + +def get_subtitles_via_yt_dlp(url: str) -> Optional[str]: + """Try to fetch subtitles via yt_dlp when API transcripts fail.""" + debug_print(f"Fetching metadata via yt‑dlp for URL: {url}") + opts = {'skip_download': True, 'quiet': True, 'ignoreerrors': True} + with yt_dlp.YoutubeDL(opts) as ydl: + info = ydl.extract_info(url, download=False) + available = list(info.get('subtitles', {})) + list(info.get('automatic_captions', {})) + debug_print(f"Available subtitle languages: {available}") + if not available: + return None + + priority = ['en', 'es', 'fr', 'de', 'zh', 'ja'] + langs = [l for l in priority if l in available] + [l for l in available if l not in priority] + + for lang in langs: + debug_print(f"Trying subtitle language {lang}") + dl_opts = { + 'skip_download': True, + 'writesubtitles': True, + 'writeautomaticsub': True, + 'subtitlesformat': 'vtt', + 'subtitlelangs': [lang], + 'outtmpl': "transcript.%(language)s.%(ext)s", + 'quiet': True, + } + with yt_dlp.YoutubeDL(dl_opts) as ydl: + ydl.download([url]) + + files = [f for f in os.listdir('.') if f.startswith('transcript') and f.endswith('.vtt')] + if not files: + continue + path = files[0] + try: + lines = vtt_to_lines(path) + lines = remove_consecutive_line_duplicates(lines) + lines = remove_phrase_duplicates_from_lines(lines) + lines = remove_empty_lines(lines) + text = "\n".join(lines) + debug_print(f"Subtitle text length: {len(text)}") + return text + except Exception as e: + debug_print(f"Subtitle parsing failed: {e}") + return None + + +# -------------------------- +# 2) Whisper‑based workflow +# -------------------------- + +def _cleanup_audio_artifacts(vid: str) -> None: + """Remove partial audio download artifacts for the given video id.""" + for path in glob.glob(f"audio_{vid}.*"): + # Keep any existing mp3; it may belong to a previous summary. + if path.endswith(".mp3"): + continue + try: + os.remove(path) + except OSError: + pass + + +def _download_audio_with_yt_dlp(url: str, vid: str, extractor_args: Optional[dict] = None) -> str: + """Download audio via yt_dlp and extract to wav.""" + audio_fn = f"audio_{vid}.wav" + opts = { + "format": "bestaudio/best", + "outtmpl": f"audio_{vid}.%(ext)s", + "quiet": True, + "noprogress": True, + "nopart": True, + "continuedl": False, + "overwrites": True, + "noplaylist": True, + "retries": 3, + "fragment_retries": 3, + "postprocessors": [{ + "key": "FFmpegExtractAudio", + "preferredcodec": "wav", + }], + } + if extractor_args: + opts["extractor_args"] = extractor_args + with yt_dlp.YoutubeDL(opts) as ydl: + ydl.download([url]) + if not os.path.exists(audio_fn): + raise RuntimeError("yt_dlp completed but wav file was not created") + return audio_fn + + +def download_video_audio(url: str, vid: str) -> str: + """Download the best available audio for a YouTube video.""" + print(f"📥 Downloading audio from {url} …") + + # Clean up any stale partials that can trigger HTTP 416 resume errors. + _cleanup_audio_artifacts(vid) + + attempts = [ + ("android player client", {"youtube": {"player_client": ["android"]}}), + ("default player client", None), + ] + + last_err = None + for label, extractor_args in attempts: + try: + debug_print(f"yt_dlp audio attempt: {label}") + audio_fn = _download_audio_with_yt_dlp(url, vid, extractor_args) + debug_print(f"Audio saved as {audio_fn}") + return audio_fn + except Exception as e: + last_err = e + debug_print(f"yt_dlp attempt failed ({label}): {e}") + _cleanup_audio_artifacts(vid) + + raise RuntimeError("Audio download failed after multiple attempts") from last_err + + +def get_audio_duration(path: str) -> float: + """Return the duration of an audio file using ffprobe.""" + res = subprocess.run([ + get_ffprobe_binary(), "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", path + ], capture_output=True, text=True) + return float(res.stdout.strip()) + + +def slice_audio(audio_path: str, vid: str) -> List[Tuple[str, float, float]]: + """Slice a long audio file into overlapping chunks for Whisper.""" + print("Slicing audio …") + duration = get_audio_duration(audio_path) + length = duration / NUM_SLICES + slices = [] + for i in range(NUM_SLICES): + start = max(0, i * length - (OVERLAP_SEC if i > 0 else 0)) + end = min(duration, (i + 1) * length + (OVERLAP_SEC if i < NUM_SLICES - 1 else 0)) + fn = f"audio_{vid}_slice_{i:02d}.wav" + subprocess.run([ + get_ffmpeg_binary(), "-y", "-hide_banner", "-loglevel", "error", + "-ss", str(start), "-to", str(end), + "-i", audio_path, "-acodec", "copy", fn + ], check=True) + debug_print(f" slice {i}: {start:.1f}s→{end:.1f}s ({fn})") + slices.append((fn, start, end)) + return slices + + +def transcribe_slice(args: Tuple[str, int, str, str]) -> str: + """Transcribe a single audio slice using Whisper and save to a text file.""" + slice_path, idx, model_name, vid = args + if whisper is None: + raise RuntimeError("Whisper package is required but not installed") + m = whisper.load_model(model_name, download_root=get_whisper_download_root()) + res = m.transcribe(slice_path, task="transcribe") + out = f"transcript_{vid}_slice_{idx:02d}.txt" + with open(out, "w", encoding="utf-8") as f: + f.write(res["text"]) + debug_print(f"Transcribed slice {idx} → {out}") + return out + + +def merge_transcripts(files: List[str]) -> str: + """Merge transcribed slices by eliminating overlapping words.""" + merged, prev = [], [] + for i, fn in enumerate(files): + words = open(fn, encoding="utf-8").read().split() + if i > 0: + p_tail = prev[-MAX_OVERLAP_WORDS:] + c_head = words[:MAX_OVERLAP_WORDS] + L = min(len(p_tail), len(c_head)) + best = 0 + for n in range(L, 4, -1): + if p_tail[-n:] == c_head[:n]: + best = n + break + if best: + debug_print(f" overlap {best} words between slices {i-1}↔{i}") + words = words[best:] + merged += words + prev = words + text = " ".join(merged) + debug_print(f"Merged transcript: {len(text)} chars, {len(merged)} words") + return text + + +def clean_temp(pattern: str) -> None: + """Remove temporary files matching the given glob pattern.""" + for f in glob.glob(pattern): + try: + os.remove(f) + except Exception: + pass + + +def whisper_transcript(url: str, vid: str) -> str: + """Run the Whisper pipeline and return the final transcript text.""" + audio = download_video_audio(url, vid) + slices = slice_audio(audio, vid) + print("✍️ Transcribing using Whisper...", flush=True) + args = [(p, i, WHISPER_MODEL, vid) for i, (p, _, _) in enumerate(slices)] + with multiprocessing.Pool(len(slices)) as pool: + t_files = pool.map(transcribe_slice, args) + text = merge_transcripts(t_files) + clean_temp(f"audio_{vid}_slice_*.wav") + clean_temp(f"transcript_{vid}_slice_*.txt") + # Leave the original audio file so it can be referenced by the GUI + return text + + +# ----------------------- +# Ollama‑Summarizer +# ----------------------- + +def summarize_with_ollama(title: str, transcript: str, model: str = "mistral:latest") -> str: + """ + Send video title and transcript text to Ollama and return the summary string. + """ + debug_print(f"Preparing summary with model {model}, transcript length={len(transcript)}") + prompt = ( + "You are an expert summarizer. Summarize the following video concisely:\n\n" + f"Title: {title}\n\n" + f"Transcript:\n{transcript}\n\n" + "Summary:" + ) + debug_print(prompt) + payload = { + "model": model, + "messages": [ + {"role": "system", "content": "You are an intelligent summarizer."}, + {"role": "user", "content": prompt} + ], + "stream": True + } + debug_print("Sending request to Ollama …") + resp = requests.post("http://localhost:11434/api/chat", json=payload, stream=True) + debug_print(f"Ollama status: {resp.status_code}") + summary = "" + for line in resp.iter_lines(decode_unicode=True): + if not line: + continue + try: + msg = json.loads(line).get("message", {}).get("content", "") + summary += msg + except Exception: + continue + debug_print(f"Summary generated, length={len(summary)}") + return summary + + +# ----------------------- +# Video metadata and thumbnail download +# ----------------------- + +def fetch_video_metadata(url: str) -> Tuple[str, str, str]: + """ + Fetch the title, thumbnail URL and video ID for a YouTube URL using yt_dlp. + Returns a tuple: (video_id, title, thumbnail_url) + """ + with yt_dlp.YoutubeDL({'quiet': True}) as ydl: + info = ydl.extract_info(url, download=False) + vid = info.get('id') + title = info.get('title', f"Video {vid}") + thumbnail_url = info.get('thumbnail') + return vid, title, thumbnail_url + + +def fetch_channel_name(url: str) -> Optional[str]: + """ + Retrieve the channel or uploader name for a YouTube video using yt_dlp. + Returns None if it cannot be determined. + """ + try: + with yt_dlp.YoutubeDL({'quiet': True}) as ydl: + info = ydl.extract_info(url, download=False) + # Try channel, uploader, then return None + return info.get('channel') or info.get('uploader') + except Exception as e: + debug_print(f"Failed to fetch channel name: {e}") + return None + + +def download_thumbnail(vid: str, thumbnail_url: str) -> Optional[str]: + """ + Download the thumbnail image given its URL and save it as thumb_.. + Returns the local filename or None if download fails. + """ + if not thumbnail_url: + return None + try: + response = requests.get(thumbnail_url, timeout=10) + response.raise_for_status() + # Determine extension from content type or URL + ext = None + if 'content-type' in response.headers: + ctype = response.headers['content-type'] + if 'jpeg' in ctype: + ext = 'jpg' + elif 'png' in ctype: + ext = 'png' + if ext is None: + ext = thumbnail_url.split('.')[-1].split('?')[0] + filename = f"thumb_{vid}.{ext}" + with open(filename, 'wb') as f: + f.write(response.content) + debug_print(f"Thumbnail downloaded as {filename}") + return filename + except Exception as e: + debug_print(f"Thumbnail download failed: {e}") + return None + + +# ----------------------- +# Main +# ----------------------- + +def process_video(url: str, use_whisper: bool, model: str = "mistral:latest", output_json: Optional[str] = None) -> dict: + """ + Core processing routine. Retrieves metadata, obtains transcript via the + selected workflow, generates a summary using Ollama and writes the + transcript, thumbnail and audio (converted to mp3) to disk. Returns a + dictionary containing metadata which may also be dumped to a JSON file if + output_json is provided. + + Parameters + ---------- + url : str + The YouTube video URL. + use_whisper : bool + If True, use the Whisper transcription workflow; if False, use the + classic API/subtitle workflow. + model : str, optional + The Ollama model name to use for summarization. Defaults to + "mistral:latest". + output_json : str or None, optional + If provided, path to a file where JSON metadata should be written. + + Returns + ------- + dict + A dictionary containing metadata about the processed video. + """ + vid, title, thumb_url = fetch_video_metadata(url) + if not vid: + raise SystemExit("Invalid YouTube URL.") + + # Fetch the channel/uploader name + channel_name = fetch_channel_name(url) + + # Fetch transcript + if use_whisper: + print("🤖 Using Whisper parallel transcription…") + transcript_text = whisper_transcript(url, vid) + if not transcript_text.strip(): + raise SystemExit("Whisper transcription failed or empty.") + else: + print("▶️ Using classic API/subtitle workflow…") + # Try API first + try: + transcript_text = get_transcript_api(vid) + except Exception: + print("API failed, falling back to subtitles…") + transcript_text = get_subtitles_via_yt_dlp(url) + if not transcript_text: + raise SystemExit("No transcript/subtitles available.") + + # Save transcript to file + transcript_filename = f"transcript_{vid}.txt" + with open(transcript_filename, 'w', encoding='utf-8') as f: + f.write(transcript_text) + debug_print(f"Transcript saved to {transcript_filename}") + + # Download thumbnail + thumbnail_filename = download_thumbnail(vid, thumb_url) + + # Determine audio filename if generated and convert to mp3 + audio_filename = None + if use_whisper: + wav_name = f"audio_{vid}.wav" + mp3_name = f"audio_{vid}.mp3" + # Convert to mp3 using ffmpeg if wav exists + if os.path.exists(wav_name): + try: + subprocess.run([ + get_ffmpeg_binary(), '-y', '-i', wav_name, + '-codec:a', 'libmp3lame', '-qscale:a', '2', + mp3_name + ], check=True) + os.remove(wav_name) + debug_print(f"Converted {wav_name} to {mp3_name} and removed wav") + audio_filename = mp3_name + except Exception as e: + debug_print(f"Failed to convert audio to mp3: {e}") + # fallback: keep wav + audio_filename = wav_name + else: + # If wav file doesn't exist yet (perhaps removed elsewhere), do not set audio + audio_filename = None + + # Generate summary + print("✍️ Generating summary with Ollama…", flush=True) + summary_text = summarize_with_ollama(title, transcript_text, model) + + # Create metadata dictionary + meta = { + 'timestamp': datetime.utcnow().isoformat() + 'Z', + 'video_id': vid, + 'url': url, + 'video_name': title, + 'channel': channel_name, + 'thumbnail': thumbnail_filename, + 'audio': audio_filename, + 'transcript': transcript_filename, + 'summary': summary_text + } + + # Write JSON output if requested + if output_json: + with open(output_json, 'w', encoding='utf-8') as f: + json.dump(meta, f, ensure_ascii=False, indent=2) + debug_print(f"Metadata written to {output_json}") + return meta + + +def rewrite_summary(title: str, transcript_file: str, model: str = "mistral:latest", output_json: Optional[str] = None) -> dict: + """ + Regenerate a summary from an existing transcript file using the specified model. + + Parameters + ---------- + transcript_file : str + Path to a text file containing the transcript. + model : str, optional + Name of the Ollama model to use for summarization. + output_json : str or None, optional + If provided, write the resulting summary dictionary to this file. + + Returns + ------- + dict + A dictionary containing just the summary. + """ + if not os.path.exists(transcript_file): + raise SystemExit(f"Transcript file not found: {transcript_file}") + with open(transcript_file, 'r', encoding='utf-8') as f: + transcript_text = f.read() + debug_print(f"Rewriting summary using model {model} for {transcript_file}") + summary_text = summarize_with_ollama(title, transcript_text, model) + meta = {'summary': summary_text} + if output_json: + with open(output_json, 'w', encoding='utf-8') as f: + json.dump(meta, f, ensure_ascii=False, indent=2) + debug_print(f"Summary written to {output_json}") + return meta + + +def main(): + import argparse + parser = argparse.ArgumentParser(description="YouTube → Transcript → Ollama Summary") + parser.add_argument('url', help="YouTube‑Video‑URL") + parser.add_argument('--no-ai', action='store_true', + help="Use classic API/subtitle workflow instead of Whisper") + parser.add_argument('--output-json', type=str, default=None, + help="Write metadata JSON to the specified file instead of STDOUT") + parser.add_argument('--model', type=str, default='mistral:latest', + help="Ollama model to use for summarization (default: mistral:latest)") + parser.add_argument('--transcript-file', type=str, default=None, + help="Path to an existing transcript file; when provided the script will skip transcription and only generate a summary.") + args = parser.parse_args() + + use_whisper = not args.no_ai + + try: + # If a transcript file is provided, skip the normal processing and only rewrite summary + if args.transcript_file: + vid, title, _ = fetch_video_metadata(args.url) + meta = rewrite_summary(title, args.transcript_file, args.model, args.output_json) + else: + meta = process_video(args.url, use_whisper, args.model, args.output_json) + # If no JSON output specified, print metadata as JSON to stdout + if not args.output_json: + print(json.dumps(meta, ensure_ascii=False, indent=2)) + except SystemExit as e: + # Provide a friendly exit message without a stacktrace + print(str(e)) + sys.exit(1) + + +if __name__ == '__main__': + main()