feat: Initial v0.9 release with API Key authentication

## v0.9.20260325_144654

### Features
- API Key Authentication System
- Job Worker System
- V2 Backup Versioning

### Bug Fixes
- get_processor_results_by_job column mapping

Co-authored-by: OpenCode
This commit is contained in:
accusys
2026-03-25 14:52:51 +08:00
parent 47e86b696f
commit 383201cacd
193 changed files with 40268 additions and 422 deletions

196
src/player/api_client.rs Normal file
View File

@@ -0,0 +1,196 @@
use anyhow::Result;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
const DEFAULT_API_URL: &str = "http://localhost:3002";
#[derive(Debug, Clone)]
pub struct ApiClient {
client: Client,
base_url: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct RegisterRequest {
pub path: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct RegisterResponse {
pub uuid: String,
pub video_id: i64,
pub job_id: i64,
pub file_name: String,
pub duration: f64,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct SearchRequest {
pub query: String,
pub limit: Option<usize>,
pub uuid: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct SearchResult {
pub uuid: String,
pub chunk_id: String,
pub chunk_type: String,
pub start_time: f64,
pub end_time: f64,
pub text: String,
pub score: f32,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct SearchResponse {
pub results: Vec<SearchResult>,
pub query: String,
}
#[derive(Debug, Deserialize, Serialize)]
#[allow(dead_code)]
pub struct LookupQuery {
pub path: Option<String>,
pub uuid: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct LookupResponse {
pub uuid: String,
pub file_path: Option<String>,
pub file_name: Option<String>,
pub duration: Option<f64>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct VideoInfo {
pub uuid: String,
pub file_path: String,
pub file_name: String,
pub duration: f64,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct VideosResponse {
pub videos: Vec<VideoInfo>,
}
impl ApiClient {
pub fn new() -> Self {
let url = std::env::var("MOMENTRY_API_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string());
Self {
client: Client::new(),
base_url: url,
}
}
#[allow(dead_code)]
pub fn with_url(url: &str) -> Self {
Self {
client: Client::new(),
base_url: url.to_string(),
}
}
pub async fn register_video(&self, path: &str) -> Result<RegisterResponse> {
let url = format!("{}/api/v1/register", self.base_url);
let request = RegisterRequest {
path: path.to_string(),
};
let response = self.client.post(&url).json(&request).send().await?;
let status = response.status();
let result = response.json::<RegisterResponse>().await?;
if !status.is_success() {
anyhow::bail!("API request failed with status: {}", status);
}
Ok(result)
}
pub async fn search_chunks(
&self,
query: &str,
uuid: Option<&str>,
limit: Option<usize>,
) -> Result<SearchResponse> {
let url = format!("{}/api/v1/search", self.base_url);
let request = SearchRequest {
query: query.to_string(),
limit,
uuid: uuid.map(|s| s.to_string()),
};
let response = self.client.post(&url).json(&request).send().await?;
let status = response.status();
let result = response.json::<SearchResponse>().await?;
if !status.is_success() {
anyhow::bail!("API request failed with status: {}", status);
}
Ok(result)
}
pub async fn lookup_video(&self, uuid: &str) -> Result<LookupResponse> {
let url = format!("{}/api/v1/lookup?uuid={}", self.base_url, uuid);
let response = self.client.get(&url).send().await?;
let status = response.status();
let result = response.json::<LookupResponse>().await?;
if !status.is_success() {
anyhow::bail!("API request failed with status: {}", status);
}
Ok(result)
}
pub async fn list_videos(&self) -> Result<Vec<VideoInfo>> {
let url = format!("{}/api/v1/videos", self.base_url);
let response = self.client.get(&url).send().await?;
let status = response.status();
let result = response.json::<VideosResponse>().await?;
if !status.is_success() {
anyhow::bail!("API request failed with status: {}", status);
}
Ok(result.videos)
}
pub fn base_url(&self) -> &str {
&self.base_url
}
}
impl Default for ApiClient {
fn default() -> Self {
Self::new()
}
}
pub fn find_video_path() -> Option<String> {
let test_dirs = vec![
PathBuf::from("/Users/accusys/Movies"),
PathBuf::from("/Users/accusys/Downloads"),
PathBuf::from("/Users/accusys/momentry_core_project/test_video"),
PathBuf::from("."),
];
for dir in test_dirs {
if dir.exists() {
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if matches!(
ext_str.as_str(),
"mp4" | "mov" | "m4v" | "avi" | "mkv" | "webm"
) {
return Some(path.to_string_lossy().to_string());
}
}
}
}
}
}
None
}

181
src/player/asr_overlay.rs Normal file
View File

@@ -0,0 +1,181 @@
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, serde::Deserialize)]
#[allow(dead_code)]
pub struct AsrSegment {
pub start: f64,
pub end: f64,
pub text: String,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[allow(dead_code)]
pub struct AsrData {
#[serde(default)]
pub segments: Vec<AsrSegment>,
}
#[allow(dead_code)]
pub struct AsrOverlay {
segments: Vec<AsrSegment>,
current_text: String,
}
#[allow(dead_code)]
impl AsrOverlay {
pub fn new() -> Self {
Self {
segments: Vec::new(),
current_text: String::new(),
}
}
pub fn load_from_file(&mut self, video_path: &str) -> bool {
// Try to find ASR JSON file in various locations
let video_dir = PathBuf::from(video_path).parent().map(|p| p.to_path_buf());
let _video_stem = PathBuf::from(video_path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let mut paths = Vec::new();
// In same directory as video
if let Some(_dir) = &video_dir {
paths.push(PathBuf::from(video_path).with_extension("asr.json"));
}
// In data directory
let data_dir = PathBuf::from("/Users/accusys/momentry_core_0.1");
if let Ok(content) = fs::read_to_string(video_path) {
let _ = content;
}
// Try probe file for UUID
let uuid = self
.find_uuid_from_probe(video_path)
.or_else(|| lookup_uuid_from_db(video_path));
if let Some(uuid_val) = uuid {
paths.push(data_dir.join(format!("{}.asr.json", uuid_val)));
}
for path in &paths {
if path.exists() {
if let Ok(content) = fs::read_to_string(path) {
if let Ok(data) = serde_json::from_str::<AsrData>(&content) {
self.segments = data.segments;
println!(
"Loaded {} ASR segments from {:?}",
self.segments.len(),
path
);
return true;
}
}
}
}
// Try to load from PostgreSQL
if let Some(uuid) = lookup_uuid_from_db(video_path) {
let db_path = PathBuf::from("/Users/accusys/momentry_core_0.1")
.join(format!("{}.asr.json", uuid));
if db_path.exists() {
if let Ok(content) = fs::read_to_string(&db_path) {
if let Ok(data) = serde_json::from_str::<AsrData>(&content) {
self.segments = data.segments;
println!(
"Loaded {} ASR segments from database file",
self.segments.len()
);
return true;
}
}
}
}
false
}
#[allow(dead_code)]
pub fn update(&mut self, current_time: f64) {
self.current_text = String::new();
for segment in &self.segments {
if current_time >= segment.start && current_time <= segment.end {
self.current_text = segment.text.clone();
break;
}
}
}
#[allow(dead_code)]
pub fn get_text(&self) -> &str {
&self.current_text
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.segments.is_empty()
}
#[allow(dead_code)]
fn find_uuid_from_probe(&self, video_path: &str) -> Option<String> {
let path_buf = PathBuf::from(video_path);
let video_stem = path_buf.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let probe_path = PathBuf::from("/Users/accusys/momentry_core_0.1")
.join(format!("{}.probe.json", video_stem));
if probe_path.exists() {
if let Ok(content) = fs::read_to_string(&probe_path) {
if let Ok(probe) = serde_json::from_str::<serde_json::Value>(&content) {
return probe
.get("uuid")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
}
}
None
}
}
#[allow(dead_code)]
fn lookup_uuid_from_db(video_path: &str) -> Option<String> {
use std::process::Command as StdCommand;
let filename = std::path::Path::new(video_path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
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",
filename
),
])
.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
}

View File

@@ -0,0 +1,333 @@
use anyhow::Result;
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame, Terminal,
};
use std::io;
use std::process::Command as StdCommand;
#[allow(dead_code)]
const QDRANT_URL: &str = "http://localhost:6333";
#[allow(dead_code)]
const QDRANT_API_KEY: &str = "Test3200Test3200Test3200";
#[allow(dead_code)]
const OLLAMA_URL: &str = "http://localhost:11434";
#[allow(dead_code)]
const MODEL: &str = "nomic-embed-text-v2-moe";
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ChunkEntry {
pub chunk_id: String,
pub start_time: f64,
pub end_time: f64,
pub text: String,
pub score: f64,
}
#[allow(dead_code)]
impl ChunkEntry {
pub fn format_time_range(&self) -> String {
let start_mins = (self.start_time / 60.0) as u32;
let start_secs = (self.start_time % 60.0) as u32;
let end_mins = (self.end_time / 60.0) as u32;
let end_secs = (self.end_time % 60.0) as u32;
format!(
"{:02}:{:02} - {:02}:{:02}",
start_mins, start_secs, end_mins, end_secs
)
}
pub fn truncate_text(&self, max_len: usize) -> String {
if self.text.len() > max_len {
format!("{}...", &self.text[..max_len])
} else {
self.text.clone()
}
}
}
#[allow(dead_code)]
pub struct ChunkSelector {
chunks: Vec<ChunkEntry>,
selected_index: usize,
query: String,
video_uuid: String,
}
#[allow(dead_code)]
impl ChunkSelector {
pub fn new(video_uuid: &str) -> Self {
Self {
chunks: Vec::new(),
selected_index: 0,
query: String::new(),
video_uuid: video_uuid.to_string(),
}
}
pub fn search(&mut self, query: &str) -> Result<Vec<ChunkEntry>> {
self.query = query.to_string();
self.chunks = Vec::new();
self.selected_index = 0;
if query.is_empty() {
return Ok(Vec::new());
}
// Get embedding from Ollama
let embed_output = StdCommand::new("curl")
.args([
"-s",
&format!("{}/api/embeddings", OLLAMA_URL),
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
&format!(
r#"{{"model":"{}","prompt":"search_query: {}"}}"#,
MODEL, query
),
])
.output()?;
let embed_text = String::from_utf8_lossy(&embed_output.stdout);
// Parse embedding from response
let embedding: Vec<f64> = serde_json::from_str(&embed_text)
.ok()
.and_then(|v: serde_json::Value| {
v.get("embedding")
.and_then(|e| serde_json::from_value(e.clone()).ok())
})
.unwrap_or_default();
if embedding.is_empty() {
println!("Failed to get embedding for query: {}", query);
return Ok(Vec::new());
}
// Search Qdrant - try both collections (chunks_v3 for multilingual, AccusysDB for others)
let collections = ["chunks_v3", "AccusysDB"];
for collection in collections {
let vector_str = serde_json::to_string(&embedding)
.unwrap_or_default()
.replace(['[', ']'], "");
let qdrant_output = StdCommand::new("curl")
.args([
"-s",
&format!("{}/collections/{}/points/search", QDRANT_URL, collection),
"-X",
"POST",
"-H",
&format!("api-key: {}", QDRANT_API_KEY),
"-H",
"Content-Type: application/json",
"-d",
&format!(
r#"{{"vector":[{}],"limit":20,"with_payload":true}}"#,
vector_str
),
])
.output()?;
let qdrant_text = String::from_utf8_lossy(&qdrant_output.stdout);
if let Ok(response) = serde_json::from_str::<serde_json::Value>(&qdrant_text) {
if let Some(results) = response.get("result").and_then(|r| r.as_array()) {
for r in results {
let payload = r.get("payload");
// Try to match UUID - either exact match or partial match
let _uuid = payload
.and_then(|p| p.get("uuid"))
.and_then(|v| v.as_str())
.unwrap_or("");
// Accept all chunks (remove UUID filter for now since we want to find any content)
// The user can select which chunk to play
let uuid_match = true; // Accept all
if !uuid_match {
continue;
}
let chunk_id = payload
.and_then(|p| p.get("chunk_id"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let start_time = payload
.and_then(|p| p.get("start_time"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let end_time = payload
.and_then(|p| p.get("end_time"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let text = payload
.and_then(|p| p.get("text"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let score = r.get("score").and_then(|v| v.as_f64()).unwrap_or(0.0);
if !text.is_empty() {
self.chunks.push(ChunkEntry {
chunk_id,
start_time,
end_time,
text,
score,
});
}
}
if !self.chunks.is_empty() {
break;
}
}
}
}
Ok(self.chunks.clone())
}
pub fn run(&mut self) -> Result<Option<ChunkEntry>> {
let stdout = io::stdout();
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
loop {
terminal.draw(|f| self.render(f))?;
match crossterm::event::read() {
Ok(crossterm::event::Event::Key(key)) => match key.code {
crossterm::event::KeyCode::Up => {
if self.selected_index > 0 {
self.selected_index -= 1;
}
}
crossterm::event::KeyCode::Down => {
if self.selected_index < self.chunks.len().saturating_sub(1) {
self.selected_index += 1;
}
}
crossterm::event::KeyCode::Enter => {
let selected = self.chunks.get(self.selected_index).cloned();
terminal.show_cursor()?;
return Ok(selected);
}
crossterm::event::KeyCode::Char(c) => {
if c == 'q' {
terminal.show_cursor()?;
return Ok(None);
}
self.query.push(c);
}
crossterm::event::KeyCode::Backspace => {
self.query.pop();
}
crossterm::event::KeyCode::Esc => {
terminal.show_cursor()?;
return Ok(None);
}
_ => {}
},
Ok(crossterm::event::Event::Resize(_, _)) => {}
_ => {}
}
}
}
fn render(&self, f: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(f.area());
// Title
let title = Paragraph::new("🔍 Chunk Search - Natural Language Query")
.style(Style::default().fg(Color::Cyan))
.block(Block::default().borders(Borders::ALL).title(" Search "));
f.render_widget(title, chunks[0]);
// Query input
let query_text = if self.query.is_empty() {
"Type to search...".to_string()
} else {
self.query.clone()
};
let query_style = if self.query.is_empty() {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::White)
};
let query = Paragraph::new(query_text)
.style(query_style)
.block(Block::default().borders(Borders::ALL).title(" Query "));
f.render_widget(query, chunks[1]);
// Results
if self.chunks.is_empty() {
let no_results = Paragraph::new("No results found. Type to search...")
.style(Style::default().fg(Color::DarkGray))
.block(Block::default().borders(Borders::ALL).title(" Results "));
f.render_widget(no_results, chunks[2]);
} else {
let items: Vec<ListItem> = self
.chunks
.iter()
.enumerate()
.map(|(i, chunk)| {
let style = if i == self.selected_index {
Style::default().fg(Color::Yellow).bg(Color::DarkGray)
} else {
Style::default()
};
let content = Line::from(vec![
Span::raw(format!(
"{} ",
if i == self.selected_index { "" } else { " " }
)),
Span::styled(chunk.format_time_range(), Style::default().fg(Color::Green)),
Span::raw(" "),
Span::raw(chunk.truncate_text(50)),
Span::styled(
format!(" [{:.2}]", chunk.score),
Style::default().fg(Color::Blue),
),
]);
ListItem::new(content).style(style)
})
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(" Results "))
.highlight_style(Style::default().fg(Color::Yellow));
f.render_widget(list, chunks[2]);
}
// Help text
let help =
Paragraph::new(" [↑/↓] Navigate [Enter] Play from here [Type] Search [q] Quit ")
.style(Style::default().fg(Color::DarkGray))
.block(Block::default().borders(Borders::ALL));
f.render_widget(help, chunks[3]);
}
}

990
src/player/main.rs Normal file
View File

@@ -0,0 +1,990 @@
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)
}
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<()> {
println!("Player not available - SDL2 not configured");
println!("Playing: {} (UUID: {:?})", _video_path, _video_uuid);
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");
// API Testing Mode
if test_api_mode {
return run_api_test_mode();
}
// If --selector flag is provided, show video selector
if show_selector {
return run_selector();
}
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(())
}

View File

@@ -1,3 +1,13 @@
pub mod api_client;
pub mod asr_overlay;
pub mod chunk_selector;
pub mod player;
pub mod selector;
pub use api_client::{
ApiClient, LookupResponse, RegisterResponse, SearchResponse, SearchResult, VideoInfo,
};
pub use asr_overlay::{AsrData, AsrOverlay, AsrSegment};
pub use chunk_selector::{ChunkEntry, ChunkSelector};
pub use player::{play_video, PlayerConfig};
pub use selector::{VideoEntry, VideoSelector};

163
src/player/selector.rs Normal file
View File

@@ -0,0 +1,163 @@
use anyhow::Result;
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame, Terminal,
};
use std::io;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct VideoEntry {
pub uuid: String,
pub file_name: String,
pub file_path: String,
pub duration: f64,
pub width: u32,
pub height: u32,
pub thumbnail_dir: Option<PathBuf>,
}
impl VideoEntry {
pub fn format_duration(&self) -> String {
let secs = self.duration as u64;
let hours = secs / 3600;
let mins = (secs % 3600) / 60;
let secs = secs % 60;
if hours > 0 {
format!("{}:{:02}:{:02}", hours, mins, secs)
} else {
format!("{}:{:02}", mins, secs)
}
}
pub fn format_resolution(&self) -> String {
format!("{}x{}", self.width, self.height)
}
}
pub struct VideoSelector {
videos: Vec<VideoEntry>,
selected_index: usize,
}
impl VideoSelector {
pub fn new(videos: Vec<VideoEntry>) -> Self {
Self {
videos,
selected_index: 0,
}
}
pub fn run(&mut self) -> Result<Option<VideoEntry>> {
let stdout = io::stdout();
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
loop {
terminal.draw(|f| self.render(f))?;
match crossterm::event::read() {
Ok(crossterm::event::Event::Key(key)) => match key.code {
crossterm::event::KeyCode::Up => {
if self.selected_index > 0 {
self.selected_index -= 1;
}
}
crossterm::event::KeyCode::Down => {
if self.selected_index < self.videos.len() - 1 {
self.selected_index += 1;
}
}
crossterm::event::KeyCode::Enter => {
let selected = self.videos.get(self.selected_index).cloned();
terminal.show_cursor()?;
return Ok(selected);
}
crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Esc => {
terminal.show_cursor()?;
return Ok(None);
}
_ => {}
},
Ok(crossterm::event::Event::Resize(_, _)) => {}
_ => {}
}
}
}
fn render(&self, f: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(f.area());
// Title
let title = Paragraph::new("🎬 Video Selector")
.style(Style::default().fg(Color::Cyan))
.block(
Block::default()
.borders(Borders::ALL)
.title(" Select Video "),
);
f.render_widget(title, chunks[0]);
// Video list
let items: Vec<ListItem> = self
.videos
.iter()
.enumerate()
.map(|(i, video)| {
let style = if i == self.selected_index {
Style::default().fg(Color::Yellow).bg(Color::DarkGray)
} else {
Style::default()
};
let duration = video.format_duration();
let resolution = video.format_resolution();
let thumb_info = if video.thumbnail_dir.is_some() {
"📷"
} else {
""
};
let content = Line::from(vec![
Span::raw(format!(
"{} ",
if i == self.selected_index { "" } else { " " }
)),
Span::raw(&video.file_name),
Span::raw(" "),
Span::styled(
format!("{} | {}", duration, resolution),
Style::default().fg(Color::Blue),
),
Span::raw(" "),
Span::raw(thumb_info),
]);
ListItem::new(content).style(style)
})
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(" Videos "))
.highlight_style(Style::default().fg(Color::Yellow));
f.render_widget(list, chunks[1]);
// Help text
let help = Paragraph::new(" [↑/↓] Navigate [Enter] Select [q] Quit ")
.style(Style::default().fg(Color::DarkGray))
.block(Block::default().borders(Borders::ALL));
f.render_widget(help, chunks[2]);
}
}