Files
momentry_core/src/player/main.rs
2026-04-23 16:46:02 +08:00

1739 lines
65 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use anyhow::Result;
use std::env;
use std::io::Write;
#[cfg(feature = "player")]
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
mod api_client;
mod asr_overlay;
mod chunk_selector;
mod selector;
use api_client::ApiClient;
use selector::{VideoEntry, VideoSelector};
#[allow(dead_code)]
const STATUS_BAR_HEIGHT: i32 = 50;
#[allow(dead_code)]
const FONT_SIZE: i32 = 20;
#[allow(dead_code)]
const ASR_OVERLAY_HEIGHT: i32 = 80;
#[derive(Debug, Clone, Copy)]
#[allow(dead_code)]
enum TerminalCommand {
Pause,
Sound,
SeekBackward,
SeekForward,
ToggleStatusBar,
ToggleAsr,
Download,
SyncIncrease,
SyncDecrease,
Quit,
}
#[derive(Debug, Clone, Copy)]
#[allow(dead_code)]
struct VideoInfo {
width: u32,
height: u32,
fps: f64,
total_frames: u64,
duration: f64,
}
#[allow(dead_code)]
struct PlayerState {
current_time: f64,
current_frame: u64,
is_paused: bool,
sound_on: bool,
quit: bool,
status_bar_visible: bool,
asr_overlay_visible: bool,
}
struct Config {
download_dir: PathBuf,
}
#[allow(dead_code)]
fn start_sound_process(stream_url: &str, start_time: f64) -> Option<Child> {
Command::new(if cfg!(target_os = "macos") {
"/opt/homebrew/bin/ffplay"
} else {
"ffplay"
})
.args([
"-nodisp",
"-autoexit",
"-ss",
&format!("{:.2}", start_time),
stream_url,
])
.spawn()
.ok()
}
#[allow(dead_code)]
struct FormatOption {
format_id: String,
resolution: String,
ext: String,
note: String,
filesize: String,
}
#[allow(dead_code)]
fn load_config() -> Config {
let config_path = PathBuf::from(env::var("HOME").unwrap_or_default())
.join(".config")
.join("video_player")
.join("config.toml");
if config_path.exists() {
if let Ok(content) = std::fs::read_to_string(&config_path) {
for line in content.lines() {
let line = line.trim();
if line.starts_with("download_dir") && line.contains('=') {
let value = line.split('=').nth(1).unwrap_or("").trim();
let path = value
.trim_matches('"')
.replace("~", &env::var("HOME").unwrap_or_default());
return Config {
download_dir: PathBuf::from(path),
};
}
}
}
}
Config {
download_dir: PathBuf::from(env::var("HOME").unwrap_or_default()).join("Downloads"),
}
}
#[allow(dead_code)]
fn list_available_formats(video_url: &str) -> Result<Vec<FormatOption>> {
println!("Fetching available formats...");
let output = Command::new("yt-dlp")
.args(["-F", "--no-download", video_url])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to list formats: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut formats = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty()
|| line.starts_with("ID")
|| line.starts_with("---")
|| line.contains("storyboard")
{
continue;
}
if let Some(first_part) = line.split('|').next() {
let parts: Vec<&str> = first_part.split_whitespace().collect();
if parts.len() >= 3 {
let format_id = parts[0].to_string();
let ext = parts[1].to_string();
let resolution = parts[2].to_string();
if ext == "mp4" || ext == "webm" || ext == "m4a" {
if !resolution.contains("x") {
continue;
}
let note = if line.contains("|") {
line.split('|')
.nth(1)
.map(|s| s.trim().to_string())
.unwrap_or_default()
} else {
String::new()
};
formats.push(FormatOption {
format_id,
resolution,
ext,
note,
filesize: String::new(),
});
}
}
}
}
formats.sort_by(|a, b| {
let a_h = a
.resolution
.split('x')
.nth(1)
.unwrap_or("0")
.parse::<u32>()
.unwrap_or(0);
let b_h = b
.resolution
.split('x')
.nth(1)
.unwrap_or("0")
.parse::<u32>()
.unwrap_or(0);
b_h.cmp(&a_h)
});
Ok(formats)
}
#[allow(dead_code)]
fn show_format_menu(formats: &[FormatOption], term_fd: libc::c_int) -> Option<usize> {
for (i, fmt) in formats.iter().enumerate().take(10) {
println!("[{}] {} {} ({})", i + 1, fmt.resolution, fmt.ext, fmt.note);
}
println!("----------------------------------------");
print!("Enter choice (default: 1): ");
if std::io::stdout().flush().is_err() {
return None;
}
unsafe {
let mut termios = std::mem::zeroed();
libc::tcgetattr(term_fd, &mut termios);
let mut normal = termios;
libc::cfmakeraw(&mut normal);
libc::tcsetattr(term_fd, libc::TCSANOW, &normal);
let mut input = String::new();
let result = std::io::stdin().read_line(&mut input);
libc::tcsetattr(term_fd, libc::TCSANOW, &termios);
if result.is_ok() {
let input = input.trim();
if input.is_empty() {
return Some(0);
}
if let Ok(choice) = input.parse::<usize>() {
if choice >= 1 && choice <= formats.len() {
return Some(choice - 1);
}
}
}
}
None
}
fn download_video(video_url: &str, format_id: &str, download_dir: &Path) -> Result<String> {
println!("Downloading video to {:?}...", download_dir);
std::fs::create_dir_all(download_dir)?;
let output = Command::new("yt-dlp")
.args([
"-f",
format_id,
"-o",
&format!("{}/%(title)s.%(ext)s", download_dir.display()),
video_url,
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Download failed: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.contains("Merging")
|| line.contains("Destination")
|| line.contains(".mp4")
|| line.contains(".webm")
{
if let Some(path) = line.split("Destination: ").nth(1) {
let path = path.trim();
if Path::new(path).exists() {
return Ok(path.to_string());
}
}
if line.contains(".mp4") || line.contains(".webm") {
let filename = line.split_whitespace().last().unwrap_or("");
let full_path = download_dir.join(filename);
if full_path.exists() {
return Ok(full_path.to_string_lossy().to_string());
}
}
}
}
for entry in (std::fs::read_dir(download_dir)?).flatten() {
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext == "mp4" || ext == "webm" || ext == "mkv" {
return Ok(path.to_string_lossy().to_string());
}
}
anyhow::bail!("Could not find downloaded file")
}
fn is_youtube_url(input: &str) -> bool {
input.starts_with("http://")
|| input.starts_with("https://")
|| input.contains("youtube.com")
|| input.contains("youtu.be")
}
#[allow(dead_code)]
fn get_youtube_stream_url(video_url: &str) -> Result<String> {
println!("Getting video stream from YouTube...");
let output = Command::new("yt-dlp")
.args(["-f", "best[ext=mp4][vcodec!=none]", "-g", video_url])
.output()?;
if !output.status.success() {
let output = Command::new("yt-dlp")
.args(["-f", "best", "-g", video_url])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("yt-dlp failed: {}", stderr);
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
if url.is_empty() {
anyhow::bail!("yt-dlp returned empty URL");
}
println!("Stream URL obtained");
return Ok(url);
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
if url.is_empty() {
anyhow::bail!("yt-dlp returned empty URL");
}
println!("Stream URL obtained");
Ok(url)
}
#[allow(dead_code)]
fn get_video_info(video_path: &str) -> Result<VideoInfo> {
let output = Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,r_frame_rate,nb_frames,duration",
"-of",
"json",
video_path,
])
.output();
match output {
Ok(output) if output.status.success() => {
let json_str = String::from_utf8_lossy(&output.stdout);
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_str) {
let stream = &json["streams"][0];
return Ok(VideoInfo {
width: stream["width"].as_u64().unwrap_or(1280) as u32,
height: stream["height"].as_u64().unwrap_or(720) as u32,
fps: parse_fps(stream["r_frame_rate"].as_str().unwrap_or("30/1")),
total_frames: stream["nb_frames"].as_u64().unwrap_or(0),
duration: stream["duration"]
.as_str()
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(0.0),
});
}
}
_ => {}
}
Ok(VideoInfo {
width: 1280,
height: 720,
fps: 30.0,
total_frames: 0,
duration: 0.0,
})
}
#[allow(dead_code)]
fn parse_fps(fps_str: &str) -> f64 {
let parts: Vec<&str> = fps_str.split('/').collect();
if parts.len() == 2 {
let num: f64 = parts[0].parse().unwrap_or(30.0);
let den: f64 = parts[1].parse().unwrap_or(1.0);
if den > 0.0 {
num / den
} else {
30.0
}
} else {
fps_str.parse().unwrap_or(30.0)
}
}
#[allow(dead_code)]
fn format_time(seconds: f64) -> String {
let hours = (seconds / 3600.0).floor() as u32;
let minutes = ((seconds % 3600.0) / 60.0).floor() as u32;
let secs = (seconds % 60.0).floor() as u32;
let millis = ((seconds % 1.0) * 100.0).floor() as u32;
format!("{:02}:{:02}:{:02}.{:02}", hours, minutes, secs, millis)
}
#[allow(dead_code)]
fn get_video_duration(video_path: &str) -> f64 {
let output = std::process::Command::new("ffprobe")
.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
video_path,
])
.output();
match output {
Ok(out) if out.status.success() => {
let duration_str = String::from_utf8_lossy(&out.stdout).trim().to_string();
duration_str.parse::<f64>().unwrap_or(0.0)
}
_ => 0.0,
}
}
fn lookup_video_uuid(video_path: &str) -> Option<String> {
use std::process::Command as StdCommand;
// Try to find UUID from database by matching file_path
let output = StdCommand::new("psql")
.args([
"-U",
"accusys",
"-h",
"localhost",
"-d",
"momentry",
"-t",
"-A",
"-c",
&format!(
"SELECT uuid FROM videos WHERE file_path LIKE '%{}%' LIMIT 1",
video_path.split('/').next_back().unwrap_or("")
),
])
.output()
.ok()?;
if output.status.success() {
let uuid = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !uuid.is_empty() {
return Some(uuid);
}
}
None
}
#[allow(dead_code)]
#[cfg(not(feature = "player"))]
fn draw_status_bar(
_video_info: &VideoInfo,
_state: &PlayerState,
_sync_delay_ms: u64,
) -> Result<String> {
Ok(format!(
"{:.2}s / {:.2}s",
_state.current_time, _video_info.duration
))
}
#[allow(dead_code)]
#[cfg(feature = "player")]
fn draw_status_bar(
_canvas: &mut (),
_font: &(),
_video_info: &VideoInfo,
_state: &PlayerState,
_sync_delay_ms: u64,
) -> Result<String> {
Ok(format!(
"{:.2}s / {:.2}s",
_state.current_time, _video_info.duration
))
}
#[allow(dead_code)]
fn start_ffmpeg(
stream_url: &str,
video_width: u32,
video_height: u32,
video_fps: f64,
seek_time: f64,
) -> Result<Child> {
let ffmpeg_path = if cfg!(target_os = "macos") {
"/opt/homebrew/bin/ffmpeg"
} else {
"ffmpeg"
};
let fps_str = format!("{:.2}", video_fps);
let mut cmd = Command::new(ffmpeg_path);
if seek_time > 0.0 {
cmd.args(["-ss", &format!("{:.2}", seek_time)]);
}
cmd.args([
"-i",
stream_url,
"-vf",
&format!(
"scale={}:{}:force_original_aspect_ratio=decrease",
video_width, video_height
),
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
"-r",
&fps_str,
"-",
])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to start ffmpeg: {}", e))
}
#[cfg(not(feature = "player"))]
fn run_player(_video_path: &str, _video_uuid: Option<String>) -> Result<()> {
println!("Player not available - SDL2 not configured");
println!("Playing: {} (UUID: {:?})", _video_path, _video_uuid);
println!("(This is a stub - full player requires SDL2)");
Ok(())
}
#[cfg(feature = "player")]
fn run_player(video_path: &str, video_uuid: Option<String>) -> Result<()> {
run_player_with_sdl2(video_path, video_uuid)
}
#[cfg(feature = "player")]
fn run_player_with_sdl2(video_path: &str, video_uuid: Option<String>) -> Result<()> {
use sdl2::event::Event;
use sdl2::keyboard::Keycode;
use sdl2::pixels::PixelFormatEnum;
use std::io::{BufReader, Read};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
println!("\n=== 🎬 SDL2 Video Player ===");
println!("File: {}", video_path);
println!("UUID: {:?}", video_uuid);
let sdl_context = sdl2::init().map_err(|e| anyhow::anyhow!("SDL init failed: {}", e))?;
let video_subsystem = sdl_context
.video()
.map_err(|e| anyhow::anyhow!("Video init failed: {}", e))?;
let width = 1280u32;
let height = 720u32;
let window = video_subsystem
.window("Momentry Player", width, height)
.position_centered()
.resizable()
.build()
.map_err(|e| anyhow::anyhow!("Window creation failed: {}", e))?;
let mut canvas = window
.into_canvas()
.build()
.map_err(|e| anyhow::anyhow!("Canvas creation failed: {}", e))?;
let texture_creator = canvas.texture_creator();
let mut texture = texture_creator
.create_texture_streaming(PixelFormatEnum::RGB24, width as u32, height as u32)
.map_err(|e| anyhow::anyhow!("Texture creation failed: {}", e))?;
let ffmpeg_path = if cfg!(target_os = "macos") {
"/opt/homebrew/bin/ffmpeg"
} else {
"ffmpeg"
};
let mut ffmpeg = std::process::Command::new(ffmpeg_path)
.args([
"-i",
video_path,
"-vf",
&format!(
"scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2",
width, height, width, height
),
"-pix_fmt",
"rgb24",
"-r",
"30",
"-f",
"rawvideo",
"-",
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to start ffmpeg: {}", e))?;
let stdout = ffmpeg
.stdout
.take()
.ok_or_else(|| anyhow::anyhow!("Failed to capture stdout"))?;
let mut reader = BufReader::new(stdout);
let frame_size = (width * height * 3) as usize;
let mut frame_buffer = vec![0u8; frame_size];
let playing = Arc::new(AtomicBool::new(true));
let playing_clone = playing.clone();
let mut event_pump = sdl_context
.event_pump()
.map_err(|e| anyhow::anyhow!("Event pump failed: {}", e))?;
let mut asr_overlay = asr_overlay::AsrOverlay::new();
let _ = asr_overlay.load_from_file(video_path);
println!("ASR Overlay initialized: {}", !asr_overlay.is_empty());
let video_duration = get_video_duration(video_path);
println!("Video duration: {:.1}s", video_duration);
let mut frame_count = 0u64;
let frame_duration = Duration::from_millis(33);
let mut paused = false;
let mut current_time = 0.0;
let mut seek_request: Option<f64> = None;
let fps = 30.0;
let mut asr_overlay_visible = false;
println!("Playing... (Press SPACE to pause, Q/ESC to quit, ←/→ to seek, A to toggle ASR, F for fullscreen)");
loop {
let frame_start = Instant::now();
// Handle seek by restarting ffmpeg
if let Some(seek_pos) = seek_request {
seek_request = None;
println!("\n⏩ Seeking to {:.1}s...", seek_pos);
// Kill old ffmpeg and restart with seek position
let _ = ffmpeg.kill();
ffmpeg = std::process::Command::new(ffmpeg_path)
.args([
"-ss", &format!("{:.2}", seek_pos),
"-i", video_path,
"-vf", &format!(
"scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2",
width, height, width, height
),
"-pix_fmt", "rgb24",
"-r", "30",
"-f", "rawvideo",
"-",
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to restart ffmpeg: {}", e))?;
let stdout = ffmpeg
.stdout
.take()
.ok_or_else(|| anyhow::anyhow!("Failed to capture stdout"))?;
reader = BufReader::new(stdout);
current_time = seek_pos;
println!("▶ Resumed at {:.1}s", current_time);
}
for event in event_pump.poll_iter() {
match event {
Event::Quit { .. } => {
println!("\n👋 Quitting player");
playing_clone.store(false, Ordering::SeqCst);
break;
}
Event::KeyDown { keycode, .. } => match keycode {
Some(Keycode::Q) | Some(Keycode::Escape) => {
println!("\n👋 Quitting player");
playing_clone.store(false, Ordering::SeqCst);
break;
}
Some(Keycode::Space) => {
paused = !paused;
println!("{}", if paused { "⏸ Paused" } else { "▶ Playing" });
}
Some(Keycode::Left) => {
let new_time = (current_time - 10.0).max(0.0);
seek_request = Some(new_time);
println!("⏪ Seek to {:.1}s", new_time);
}
Some(Keycode::Right) => {
let new_time = current_time + 10.0;
seek_request = Some(new_time);
println!("⏩ Seek to {:.1}s", new_time);
}
Some(Keycode::Up) => {
let new_time = (current_time - 60.0).max(0.0);
seek_request = Some(new_time);
println!("⏪ Seek to {:.1}s (1min)", new_time);
}
Some(Keycode::Down) => {
let new_time = current_time + 60.0;
seek_request = Some(new_time);
println!("⏩ Seek to {:.1}s (+1min)", new_time);
}
Some(Keycode::A) => {
// Toggle ASR Visibility
asr_overlay_visible = !asr_overlay_visible;
println!(
"{}",
if asr_overlay_visible {
"🔊 ASR ON"
} else {
"🔇 ASR OFF"
}
);
}
Some(Keycode::F) => {
println!("📺 Toggle fullscreen (not implemented in basic SDL2)");
}
_ => {}
},
_ => {}
}
}
if !playing_clone.load(Ordering::SeqCst) {
break;
}
if paused {
thread::sleep(Duration::from_millis(100));
continue;
}
// Update ASR text based on current time
if !asr_overlay.is_empty() {
asr_overlay.update(current_time);
}
match reader.read_exact(&mut frame_buffer) {
Ok(_) => {
texture
.update(None, &frame_buffer, (width * 3) as usize)
.map_err(|e| anyhow::anyhow!("Texture update failed: {}", e))?;
// Draw everything
canvas.clear();
canvas
.copy(&texture, None, None)
.map_err(|e| anyhow::anyhow!("Render failed: {}", e))?;
// Draw ASR Text if visible and available
if asr_overlay_visible && !asr_overlay.get_text().is_empty() {
// Placeholder: Cannot use TTF functions directly here without font context.
// For now, just printing to console to verify timing.
// In a real implementation, load font and draw text here.
println!("[ASR] {:.1}s: {}", current_time, asr_overlay.get_text());
}
// Draw progress bar at bottom - gray background, green progress
use sdl2::rect::Rect;
let progress = if video_duration > 0.0 {
(current_time / video_duration).min(1.0)
} else {
0.0
};
let bar_width = ((width as f64) * progress) as u32;
canvas.set_draw_color(sdl2::pixels::Color::RGB(50, 50, 50)); // Background
let _ = canvas.fill_rect(Rect::new(0, height as i32 - 15, width, 5));
if bar_width > 0 {
canvas.set_draw_color(sdl2::pixels::Color::RGB(0, 200, 0)); // Progress
let _ = canvas.fill_rect(Rect::new(0, height as i32 - 15, bar_width, 5));
}
// Reset draw color to black for next frame
canvas.set_draw_color(sdl2::pixels::Color::RGB(0, 0, 0));
canvas.present();
frame_count += 1;
current_time += 1.0 / fps;
let elapsed = frame_start.elapsed();
if elapsed < frame_duration {
thread::sleep(frame_duration - elapsed);
}
}
Err(_) => {
println!(
"\n📽️ End of video ({} frames, {:.1}s)",
frame_count, current_time
);
break;
}
}
}
let _ = ffmpeg.kill();
println!("✅ Playback finished (total: {:.1}s)", current_time);
Ok(())
}
fn run_local_mode(external_player: &str) -> Result<()> {
let args: Vec<String> = env::args().collect();
// Find video path - skip all flags and get the first non-flag argument after them
let video_path = args
.iter()
.skip(1) // Skip binary name
.skip_while(|a| a.starts_with('-')) // Skip flags
.next()
.cloned();
let video_path = match video_path {
Some(p) if !p.is_empty() => p,
_ => {
println!("Local Mode - Play local video files");
println!("=====================================\n");
print!("Enter video file path: ");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let path = input.trim().to_string();
if path.is_empty() {
anyhow::bail!("No video path provided");
}
path
}
};
if !Path::new(&video_path).exists() {
anyhow::bail!("File not found: {}", video_path);
}
println!("\nUsing external player: {}", external_player);
println!("Playing: {}", video_path);
match external_player {
"vlc" => {
std::process::Command::new("open")
.arg("-a")
.arg("VLC")
.arg(&video_path)
.spawn()?;
println!("✅ Opened with VLC");
}
"mpv" => {
std::process::Command::new("mpv").arg(&video_path).spawn()?;
println!("✅ Opened with mpv");
}
"ffplay" => {
std::process::Command::new("ffplay")
.arg("-autoexit")
.arg(&video_path)
.spawn()?;
println!("✅ Opened with ffplay");
}
"sdl2" => {
#[cfg(feature = "player")]
return run_player_with_sdl2(&video_path, None);
#[cfg(not(feature = "player"))]
{
println!("SDL2 player not enabled. Rebuild with --features player");
}
}
_ => {
std::process::Command::new(external_player)
.arg(&video_path)
.spawn()?;
println!("✅ Opened with {}", external_player);
}
}
Ok(())
}
fn run_online_mode() -> Result<()> {
println!("\n===========================================");
println!(" 🎬 Online Mode - Momentry");
println!("===========================================\n");
let client = ApiClient::new();
println!("Connected to API: {}", client.base_url());
let rt = tokio::runtime::Runtime::new()?;
loop {
println!("\n┌─────────────────────────────────────────┐");
println!("│ Online Mode Menu │");
println!("├─────────────────────────────────────────┤");
println!("│ [1] List Videos - 列出所有影片 │");
println!("│ [2] Search - RAG 搜尋影片內容 │");
println!("│ [3] Play - 播放影片 │");
println!("│ [4] Lookup - 查詢影片資訊 │");
println!("│ [q] Quit - 離開 │");
println!("└─────────────────────────────────────────┘");
print!("\n請選擇: ");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let choice = input.trim();
match choice {
"1" => {
println!("\n=== 📋 影片列表 ===");
match rt.block_on(client.list_videos()) {
Ok(videos) => {
if videos.is_empty() {
println!("沒有找到任何影片");
} else {
println!("\n{} 部影片:\n", videos.len());
for (i, v) in videos.iter().enumerate() {
let duration = format!(
"{}:{:02}",
(v.duration / 60.0) as u32,
(v.duration % 60.0) as u32
);
println!(
" [{}] {} | {} | {}x{} | {}",
i + 1,
v.file_name,
v.uuid.chars().take(8).collect::<String>(),
v.width,
v.height,
duration
);
}
}
}
Err(e) => println!("取得影片列表失敗: {}", e),
}
}
"2" => {
println!("\n=== 🔍 RAG 搜尋 ===");
print!("輸入搜尋關鍵字: ");
input.clear();
std::io::stdin().read_line(&mut input)?;
let query = input.trim().to_string();
if query.is_empty() {
println!("搜尋關鍵字不能為空");
continue;
}
print!("限定特定影片?(y/N): ");
input.clear();
std::io::stdin().read_line(&mut input)?;
let limit_uuid = if input.trim().to_lowercase() == "y" {
print!("輸入影片 UUID: ");
input.clear();
std::io::stdin().read_line(&mut input)?;
Some(input.trim().to_string())
} else {
None
};
println!("\n搜尋中...");
match rt.block_on(client.search_chunks(&query, limit_uuid.as_deref(), Some(10))) {
Ok(response) => {
if response.results.is_empty() {
println!("沒有找到結果");
continue;
}
println!("\n找到 {} 個結果:\n", response.results.len());
for (i, r) in response.results.iter().enumerate() {
let time_range = format!(
"{:02}:{:02} - {:02}:{:02}",
(r.start_time / 60.0) as u32,
(r.start_time % 60.0) as u32,
(r.end_time / 60.0) as u32,
(r.end_time % 60.0) as u32
);
let text_preview = if r.text.len() > 50 {
format!("{}...", &r.text[..50])
} else {
r.text.clone()
};
println!(
" [{}] {} | {} | {:.2} | {}",
i + 1,
time_range,
r.uuid.chars().take(8).collect::<String>(),
r.score,
text_preview
);
}
let mut current_player: Option<std::process::Child> = None;
loop {
if let Some(ref mut child) = current_player {
match child.try_wait() {
Ok(Some(_)) => {
println!("播放器已結束");
current_player = None;
}
Ok(None) => {
// 還在執行中
}
Err(e) => {
println!("檢查播放器狀態失敗:{}", e);
current_player = None;
}
}
}
print!(
"\n選擇播放 (1-{}) 或 q 離開 (kill player), L 重新顯示列表:",
response.results.len()
);
input.clear();
std::io::stdin().read_line(&mut input)?;
let selection = input.trim();
let selection_lower = selection.to_lowercase();
if selection_lower == "q" {
if let Some(ref mut child) = current_player {
let _ = child.kill();
let _ = child.wait();
println!("已終止播放器");
current_player = None;
}
break;
}
if selection_lower == "l" {
println!("\n搜尋結果:");
for (i, r) in response.results.iter().enumerate() {
let time_range = format!(
"{:02}:{:02} - {:02}:{:02}",
(r.start_time / 60.0) as u32,
(r.start_time % 60.0) as u32,
(r.end_time / 60.0) as u32,
(r.end_time % 60.0) as u32
);
let text_preview = if r.text.len() > 50 {
format!("{}...", &r.text[..50])
} else {
r.text.clone()
};
println!(
" [{}] {} | {} | {:.2} | {}",
i + 1,
time_range,
r.uuid.chars().take(8).collect::<String>(),
r.score,
text_preview
);
}
continue;
}
if let Ok(idx) = selection.parse::<usize>() {
if idx > 0 && idx <= response.results.len() {
let selected = &response.results[idx - 1];
println!("\n播放:{} - {}", selected.uuid, selected.text);
if let Some(ref mut child) = current_player {
let _ = child.kill();
let _ = child.wait();
println!("已終止前一個播放器");
}
match rt.block_on(client.lookup_video(&selected.uuid)) {
Ok(info) => {
if let Some(path) = &info.file_path {
if std::path::Path::new(path).exists() {
let start_sec =
(selected.start_time as f64) - 2.0;
let end_sec = (selected.end_time as f64) + 2.0;
println!(
"開啟:{} (從 {:.0}{:.0}A-B 循環)",
path, start_sec, end_sec
);
println!("提示mpv 視窗中按 c/C 切換循環q 離開Space 暫停");
current_player = Some(
std::process::Command::new("mpv")
.arg(format!(
"--start={:.2}",
start_sec.max(0.0)
))
.arg(format!(
"--ab-loop-a={:.2}",
start_sec.max(0.0)
))
.arg(format!("--ab-loop-b={:.2}", end_sec))
.arg("--input-commands=bind c ab-loop; bind C ab-loop")
.arg(path)
.spawn()?
);
} else {
println!("錯誤:檔案不存在:{}", path);
}
}
}
Err(e) => println!("查詢失敗:{}", e),
}
}
}
}
}
Err(e) => println!("搜尋失敗:{}", e),
}
}
"4" => {
println!("\n=== 🔎 查詢影片 ===");
print!("輸入影片 UUID (直接 Enter 從列表選擇): ");
input.clear();
std::io::stdin().read_line(&mut input)?;
let uuid = input.trim();
if uuid.is_empty() {
println!("載入影片列表...");
match rt.block_on(client.list_videos()) {
Ok(videos) => {
if videos.is_empty() {
println!("沒有影片");
continue;
}
println!("\n選擇影片:");
for (i, v) in videos.iter().enumerate() {
println!(" [{}] {} ({})", i + 1, v.file_name, v.uuid);
}
print!("\n選擇編號:");
input.clear();
std::io::stdin().read_line(&mut input)?;
if let Ok(idx) = input.trim().parse::<usize>() {
if idx > 0 && idx <= videos.len() {
let selected = &videos[idx - 1];
println!("\n查詢中...");
match rt.block_on(client.lookup_video(&selected.uuid)) {
Ok(info) => {
println!("\n✓ 找到影片:");
println!(" UUID: {}", info.uuid);
if let Some(path) = &info.file_path {
println!(" 路徑:{}", path);
}
if let Some(name) = &info.file_name {
println!(" 名稱:{}", name);
}
if let Some(dur) = info.duration {
println!(" 時長:{:.2}s", dur);
}
}
Err(e) => println!("查詢失敗:{}", e),
}
}
}
}
Err(e) => println!("取得影片列表失敗:{}", e),
}
} else {
println!("\n查詢中...");
match rt.block_on(client.lookup_video(uuid)) {
Ok(info) => {
println!("\n✓ 找到影片:");
println!(" UUID: {}", info.uuid);
if let Some(path) = &info.file_path {
println!(" 路徑:{}", path);
}
if let Some(name) = &info.file_name {
println!(" 名稱:{}", name);
}
if let Some(dur) = info.duration {
println!(" 時長:{:.2}s", dur);
}
}
Err(e) => println!("查詢失敗:{}", e),
}
}
}
"3" => {
println!("\n=== ▶ 播放影片 ===");
print!("輸入影片 UUID (直接 Enter 從列表選擇): ");
input.clear();
std::io::stdin().read_line(&mut input)?;
let uuid = input.trim();
if uuid.is_empty() {
println!("載入影片列表...");
match rt.block_on(client.list_videos()) {
Ok(videos) => {
if videos.is_empty() {
println!("沒有影片");
continue;
}
println!("\n選擇影片:");
for (i, v) in videos.iter().enumerate() {
println!(" [{}] {} ({})", i + 1, v.file_name, v.uuid);
}
print!("\n選擇編號:");
input.clear();
std::io::stdin().read_line(&mut input)?;
if let Ok(idx) = input.trim().parse::<usize>() {
if idx > 0 && idx <= videos.len() {
let selected = &videos[idx - 1];
println!("\n播放: {}", selected.file_path);
if std::path::Path::new(&selected.file_path).exists() {
std::process::Command::new("mpv")
.arg(&selected.file_path)
.spawn()?;
} else {
println!("錯誤:檔案不存在:{}", selected.file_path);
}
}
}
}
Err(e) => println!("取得影片列表失敗:{}", e),
}
} else {
match rt.block_on(client.lookup_video(uuid)) {
Ok(info) => {
if let Some(path) = &info.file_path {
println!("開啟: {}", path);
if std::path::Path::new(path).exists() {
std::process::Command::new("mpv").arg(path).spawn()?;
} else {
println!("錯誤:檔案不存在:{}", path);
}
}
}
Err(e) => println!("查詢失敗:{}", e),
}
}
}
"q" | "Q" => {
println!("\n👋 再見!");
break;
}
_ => {
println!("無效選項");
}
}
}
Ok(())
}
fn main() -> Result<()> {
env_logger::init();
let args: Vec<String> = env::args().collect();
let should_download = args.iter().any(|a| a == "-d" || a == "--download");
let show_selector = args.iter().any(|a| a == "-s" || a == "--selector");
let test_api_mode = args.iter().any(|a| a == "-t" || a == "--test-api");
let local_mode = args.iter().any(|a| a == "-l" || a == "--local");
let online_mode = args.iter().any(|a| a == "-o" || a == "--online");
// Get external player choice
let external_player = args
.iter()
.position(|a| a == "-p" || a == "--player")
.and_then(|i| args.get(i + 1))
.cloned()
.unwrap_or_else(|| "vlc".to_string());
// API Testing Mode
if test_api_mode {
return run_api_test_mode();
}
// If --selector flag is provided, show video selector (online mode)
if show_selector {
return run_selector();
}
// If --online or -o is provided, run online mode
if online_mode {
return run_online_mode();
}
// If --local or -l is provided, run local mode with external player
if local_mode {
return run_local_mode(&external_player);
}
let video_path = if args.len() < 2 || (should_download && args.len() < 3) {
println!("Video Player\n============\nEnter video path or YouTube URL:");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let trimmed = input.trim().to_string();
if trimmed.is_empty() {
anyhow::bail!("No input provided");
}
trimmed
} else {
let idx = if should_download { 2 } else { 1 };
args[idx].clone()
};
println!("Video Player\n============\nInput: {}", video_path);
let final_path = if should_download && is_youtube_url(&video_path) {
println!("Downloading with best quality...");
let config = load_config();
let format_id = "best[ext=mp4][vcodec!=none]";
match download_video(&video_path, format_id, &config.download_dir) {
Ok(local_path) => {
println!("Download complete: {}", local_path);
Some(local_path)
}
Err(e) => {
eprintln!("Download failed: {}, playing stream instead", e);
None
}
}
} else {
None
};
let play_path = final_path.unwrap_or(video_path);
if is_youtube_url(&play_path) {
println!("Source: YouTube");
} else if Path::new(&play_path).exists() {
println!("Source: Local file");
} else {
anyhow::bail!("File not found: {}", play_path);
}
let video_uuid = lookup_video_uuid(&play_path);
println!("Video UUID: {:?}", video_uuid);
run_player(&play_path, video_uuid)?;
Ok(())
}
fn run_selector() -> Result<()> {
use std::process::Command as StdCommand;
let _db_url = momentry_core::core::config::DATABASE_URL.as_str();
// Use psql to query videos
let output = StdCommand::new("psql")
.args(["-U", "accusys", "-h", "localhost", "-d", "momentry", "-t", "-A",
"-c", "SELECT uuid, file_name, file_path, duration, width, height FROM videos ORDER BY created_at DESC"])
.output();
let videos: Vec<VideoEntry> = match output {
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout);
stdout
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() >= 6 {
Some(VideoEntry {
uuid: parts[0].to_string(),
file_name: parts[1].to_string(),
file_path: parts[2].to_string(),
duration: parts[3].parse().unwrap_or(0.0),
width: parts[4].parse().unwrap_or(0),
height: parts[5].parse().unwrap_or(0),
thumbnail_dir: None,
})
} else {
None
}
})
.collect()
}
_ => {
// Fallback: scan directory
println!("Could not connect to database, scanning directory...");
let test_video_dir = PathBuf::from("/Users/accusys/momentry_core_project/test_video");
std::fs::read_dir(&test_video_dir)?
.filter_map(|e| e.ok())
.filter(|e| {
let path = e.path();
matches!(
path.extension().and_then(|s| s.to_str()),
Some("mp4") | Some("mov") | Some("m4v") | Some("avi")
)
})
.map(|e| {
let path = e.path();
VideoEntry {
uuid: format!("{:x}", md5::compute(path.to_string_lossy().as_bytes()))
[0..16]
.to_string(),
file_name: path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
file_path: path.to_string_lossy().to_string(),
duration: 0.0,
width: 0,
height: 0,
thumbnail_dir: None,
}
})
.collect()
}
};
if videos.is_empty() {
println!("No videos found. Register videos first with 'momentry register'");
return Ok(());
}
// Try interactive mode, fall back to list if not a terminal
if atty::is(atty::Stream::Stdout) {
println!("Found {} videos", videos.len());
let mut selector = VideoSelector::new(videos);
match selector.run() {
Ok(Some(video)) => {
println!("\nPlaying: {} ({})", video.file_name, video.uuid);
run_player(&video.file_path, Some(video.uuid))?;
}
Ok(None) => {
println!("\nNo video selected");
}
Err(e) => {
eprintln!("Selector error: {}", e);
}
}
} else {
// Non-interactive: show list
println!("\nAvailable videos:");
for (i, video) in videos.iter().enumerate() {
println!(
" {}) {} - {} ({})",
i + 1,
video.file_name,
video.format_duration(),
video.uuid
);
}
println!("\nRun with a video path to play, or use interactive mode in a terminal.");
}
Ok(())
}
fn run_api_test_mode() -> Result<()> {
println!("\n===========================================");
println!(" 🎬 API Testing GUI");
println!("===========================================\n");
let client = ApiClient::new();
println!("API Server: {}\n", client.base_url());
println!(
"Waiting for API server... (make sure 'cargo run --bin momentry -- server' is running)\n"
);
let rt = tokio::runtime::Runtime::new()?;
loop {
println!("\n┌─────────────────────────────────────────┐");
println!("│ Main Menu │");
println!("├─────────────────────────────────────────┤");
println!("│ [1] Search - 自然語言搜尋影片內容 │");
println!("│ [2] List - 列出所有影片 │");
println!("│ [3] Register - 註冊新影片 │");
println!("│ [4] Lookup - 查詢影片資訊 │");
println!("│ [5] Play - 播放影片 │");
println!("│ [q] Quit - 離開 │");
println!("└─────────────────────────────────────────┘");
print!("\n請選擇: ");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let choice = input.trim();
match choice {
"1" => {
println!("\n=== 🔍 自然語言搜尋 ===");
print!("輸入搜尋關鍵字: ");
input.clear();
std::io::stdin().read_line(&mut input)?;
let query = input.trim().to_string();
if query.is_empty() {
println!("搜尋關鍵字不能為空");
continue;
}
print!("是否限定特定影片?(y/N): ");
input.clear();
std::io::stdin().read_line(&mut input)?;
let limit_uuid = if input.trim().to_lowercase() == "y" {
print!("輸入影片 UUID: ");
input.clear();
std::io::stdin().read_line(&mut input)?;
Some(input.trim().to_string())
} else {
None
};
println!("\n搜尋中...");
match rt.block_on(client.search_chunks(&query, limit_uuid.as_deref(), Some(20))) {
Ok(response) => {
println!("\n找到 {} 個結果:\n", response.results.len());
for (i, r) in response.results.iter().enumerate() {
let time_range = format!(
"{:02}:{:02} - {:02}:{:02}",
(r.start_time / 60.0) as u32,
(r.start_time % 60.0) as u32,
(r.end_time / 60.0) as u32,
(r.end_time % 60.0) as u32
);
let text_preview = if r.text.len() > 60 {
format!("{}...", &r.text[..60])
} else {
r.text.clone()
};
println!(
" [{}] {} | {} | {:.2} | {}",
i + 1,
time_range,
r.uuid.chars().take(8).collect::<String>(),
r.score,
text_preview
);
}
if !response.results.is_empty() {
print!("\n選擇要播放的結果編號 (直接Enter跳過): ");
input.clear();
std::io::stdin().read_line(&mut input)?;
if let Ok(idx) = input.trim().parse::<usize>() {
if idx > 0 && idx <= response.results.len() {
let selected = &response.results[idx - 1];
println!("\n正在取得影片路徑...");
match rt.block_on(client.lookup_video(&selected.uuid)) {
Ok(info) => {
if let Some(path) = &info.file_path {
println!(
"播放: {} @ {:.2}s",
path, selected.start_time
);
let video_uuid = Some(selected.uuid.clone());
run_player(path, video_uuid)?;
} else {
println!("無法取得影片路徑");
}
}
Err(e) => println!("查詢失敗: {}", e),
}
}
}
}
}
Err(e) => println!("搜尋失敗: {}", e),
}
}
"2" => {
println!("\n=== 📋 影片列表 ===");
println!("載入中...");
match rt.block_on(client.list_videos()) {
Ok(videos) => {
if videos.is_empty() {
println!("沒有找到任何影片,請先註冊");
} else {
println!("\n{} 部影片:\n", videos.len());
for (i, v) in videos.iter().enumerate() {
let duration = format!(
"{}:{:02}",
(v.duration / 60.0) as u32,
(v.duration % 60.0) as u32
);
println!(
" [{}] {} | {} | {}x{} | {}",
i + 1,
v.file_name,
v.uuid.chars().take(8).collect::<String>(),
v.width,
v.height,
duration
);
}
}
}
Err(e) => println!("取得影片列表失敗: {}", e),
}
}
"3" => {
println!("\n=== 📝 註冊影片 ===");
print!("輸入影片檔案路徑 (直接Enter使用自動搜尋): ");
input.clear();
std::io::stdin().read_line(&mut input)?;
let path = input.trim();
let video_path = if path.is_empty() {
println!("自動搜尋影片...");
match api_client::find_video_path() {
Some(p) => {
println!("找到: {}", p);
p
}
None => {
println!("找不到影片檔案,請手動輸入路徑");
continue;
}
}
} else {
path.to_string()
};
if !std::path::Path::new(&video_path).exists() {
println!("檔案不存在: {}", video_path);
continue;
}
println!("\n註冊中...");
match rt.block_on(client.register_video(&video_path)) {
Ok(resp) => {
println!("\n✓ 註冊成功!");
println!(" UUID: {}", resp.uuid);
println!(" 名稱: {}", resp.file_name);
println!(" 時長: {:.2}s", resp.duration);
println!(" 解析度: {}x{}", resp.width, resp.height);
}
Err(e) => println!("註冊失敗: {}", e),
}
}
"4" => {
print!("\n=== 🔎 查詢影片 ===\n輸入影片 UUID: ");
input.clear();
std::io::stdin().read_line(&mut input)?;
let uuid = input.trim();
if uuid.is_empty() {
println!("UUID 不能為空");
continue;
}
println!("\n查詢中...");
match rt.block_on(client.lookup_video(uuid)) {
Ok(info) => {
println!("\n✓ 找到影片:");
println!(" UUID: {}", info.uuid);
if let Some(path) = &info.file_path {
println!(" 路徑: {}", path);
}
if let Some(name) = &info.file_name {
println!(" 名稱: {}", name);
}
if let Some(dur) = info.duration {
println!(" 時長: {:.2}s", dur);
}
}
Err(e) => println!("查詢失敗: {}", e),
}
}
"5" => {
println!("\n=== ▶ 播放影片 ===");
print!("輸入影片 UUID (直接Enter從列表選擇): ");
input.clear();
std::io::stdin().read_line(&mut input)?;
let uuid = input.trim();
let video_path = if uuid.is_empty() {
println!("載入影片列表...");
match rt.block_on(client.list_videos()) {
Ok(videos) => {
if videos.is_empty() {
println!("沒有影片");
continue;
}
let entries: Vec<VideoEntry> = videos
.into_iter()
.map(|v| VideoEntry {
uuid: v.uuid,
file_name: v.file_name,
file_path: v.file_path,
duration: v.duration,
width: v.width,
height: v.height,
thumbnail_dir: None,
})
.collect();
let mut selector = VideoSelector::new(entries);
match selector.run() {
Ok(Some(video)) => video.file_path,
Ok(None) => {
println!("取消選擇");
continue;
}
Err(e) => {
println!("選擇錯誤: {}", e);
continue;
}
}
}
Err(e) => {
println!("取得列表失敗: {}", e);
continue;
}
}
} else {
match rt.block_on(client.lookup_video(uuid)) {
Ok(info) => {
if let Some(path) = info.file_path {
path
} else {
println!("找不到影片路徑");
continue;
}
}
Err(e) => {
println!("查詢失敗: {}", e);
continue;
}
}
};
let video_uuid = if let Ok(info) = rt.block_on(client.lookup_video(&video_path)) {
Some(info.uuid)
} else {
None
};
println!("\n播放: {}", video_path);
run_player(&video_path, video_uuid)?;
}
"q" | "Q" => {
println!("\n再見!");
break;
}
_ => {
println!("無效選項,請重新選擇");
}
}
}
Ok(())
}