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:
196
src/player/api_client.rs
Normal file
196
src/player/api_client.rs
Normal 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
181
src/player/asr_overlay.rs
Normal 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
|
||||
}
|
||||
333
src/player/chunk_selector.rs
Normal file
333
src/player/chunk_selector.rs
Normal 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
990
src/player/main.rs
Normal 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(())
|
||||
}
|
||||
@@ -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
163
src/player/selector.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user