Furumi init

This commit is contained in:
ab
2026-05-23 13:08:09 +03:00
parent b8afaa1864
commit 8912c51165
42 changed files with 14279 additions and 54 deletions
+525 -8
View File
@@ -4,6 +4,7 @@ use std::sync::Arc;
use cot::db::Database;
use cot::db::migrations::SyncDynMigration;
use cot::json::Json;
use cot::request::extractors::{Path, RequestForm, UrlQuery};
use cot::response::IntoResponse;
use cot::router::method::get;
@@ -15,8 +16,14 @@ use serde::Deserialize;
use crate::auth;
use crate::config::AppConfig;
use crate::i18n::I18n;
use crate::scheduler::{JobRegistry, SchedulerHandle};
use crate::user::User;
use views::{OidcSettingsForm, SetupForm, UserForm};
use views::{ArtistForm, CronForm, OidcSettingsForm, ReleaseForm, SetImageBody, SetupForm, UploadImageBody, UserForm};
#[derive(Debug, Deserialize)]
struct ReviewsQuery {
status: Option<String>,
}
/// Build-time metadata baked in by `build.rs` and Cargo env vars.
#[derive(Debug)]
@@ -42,11 +49,17 @@ pub static BUILD_INFO: BuildInfo = BuildInfo {
pub struct AdminApp {
config: Arc<AppConfig>,
registry: Arc<JobRegistry>,
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
}
impl AdminApp {
pub fn new(config: Arc<AppConfig>) -> Self {
Self { config }
pub fn new(
config: Arc<AppConfig>,
registry: Arc<JobRegistry>,
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
) -> Self {
Self { config, registry, scheduler_handle }
}
}
@@ -60,12 +73,32 @@ struct PathId {
id: i64,
}
#[derive(Debug, Deserialize)]
struct PathName {
name: String,
}
#[derive(Debug, Deserialize)]
struct PathNameRunId {
name: String,
run_id: i64,
}
#[derive(Debug, Deserialize)]
struct ReleasesQuery {
artist_id: Option<i64>,
}
impl App for AdminApp {
fn name(&self) -> &'static str {
"admin"
}
fn router(&self) -> Router {
// Create a shared sqlx pool for admin routes that need it
let pool_config = Arc::clone(&self.config);
let pool: Arc<tokio::sync::OnceCell<sqlx::PgPool>> = Arc::new(tokio::sync::OnceCell::new());
Router::with_urls([
// -- Setup (first-run, no auth required) --------------------------
Route::with_handler_and_name(
@@ -95,15 +128,15 @@ impl App for AdminApp {
Route::with_handler_and_name(
"/",
|session: Session, db: Database, i18n: I18n| async move {
// First-run redirect
let count = User::count_all(&db).await.unwrap_or(0);
if count == 0 {
return Ok(auth::redirect("/admin/setup"));
}
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
let admin =
match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::admin_index(admin, i18n).await?.into_response()
},
"admin_index",
@@ -167,6 +200,27 @@ impl App for AdminApp {
}),
"admin_settings",
),
// -- Settings probe (HTMX fragment) -----------------------------------
Route::with_handler_and_name(
"/settings/probe",
{
let config = Arc::clone(&self.config);
move |session: Session, db: Database, i18n: I18n| {
let config = Arc::clone(&config);
async move {
let admin =
match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::settings_probe_handler(admin, i18n, &config, &db)
.await?
.into_response()
}
}
},
"admin_settings_probe",
),
// -- Users --------------------------------------------------------
Route::with_handler_and_name(
"/users",
@@ -238,6 +292,463 @@ impl App for AdminApp {
),
"admin_users_delete",
),
// -- Artists ------------------------------------------------------
Route::with_handler_and_name(
"/artists",
|session: Session, db: Database, i18n: I18n| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_list(admin, i18n, &db).await?.into_response()
},
"admin_artists",
),
Route::with_handler_and_name(
"/artists/new",
get(|session: Session, db: Database, i18n: I18n| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_new(admin, i18n).await?.into_response()
})
.post(
|session: Session, db: Database, form: RequestForm<ArtistForm>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_create(admin, &db, form).await
},
),
"admin_artists_new",
),
Route::with_handler_and_name(
"/artists/{id}/edit",
get(
|session: Session, db: Database, i18n: I18n,
path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_edit(admin, i18n, &db, path.0.id)
.await?
.into_response()
},
)
.post(
|session: Session, db: Database, path: Path<PathId>,
form: RequestForm<ArtistForm>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_update(admin, &db, path.0.id, form).await
},
),
"admin_artists_edit",
),
Route::with_handler_and_name(
"/artists/{id}/delete",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_delete(admin, &db, path.0.id).await
},
),
"admin_artists_delete",
),
Route::with_handler_and_name(
"/artists/{id}/available-covers",
get(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_available_covers(admin, &db, path.0.id).await
},
),
"admin_artists_available_covers",
),
Route::with_handler_and_name(
"/artists/{id}/set-image",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>,
json: Json<SetImageBody>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_set_image(admin, &db, path.0.id, json.0).await
},
),
"admin_artists_set_image",
),
Route::with_handler_and_name(
"/artists/{id}/upload-image",
cot::router::method::post({
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database, path: Path<PathId>,
json: Json<UploadImageBody>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
let pg_pool = pool.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(3)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
}).await;
let (live_config, _) = AppConfig::load_with_db(&db).await;
views::artists_upload_image(admin, &db, pg_pool, &live_config, path.0.id, json.0).await
}
}
}),
"admin_artists_upload_image",
),
// -- Releases -----------------------------------------------------
Route::with_handler_and_name(
"/releases",
|session: Session, db: Database, i18n: I18n,
query: UrlQuery<ReleasesQuery>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::releases_list(admin, i18n, &db, query.0.artist_id)
.await?
.into_response()
},
"admin_releases",
),
Route::with_handler_and_name(
"/releases/new",
get(|session: Session, db: Database, i18n: I18n| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::releases_new(admin, i18n, &db).await?.into_response()
})
.post(
|session: Session, db: Database,
form: RequestForm<ReleaseForm>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::releases_create(admin, &db, form).await
},
),
"admin_releases_new",
),
Route::with_handler_and_name(
"/releases/{id}/edit",
get(
|session: Session, db: Database, i18n: I18n,
path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::releases_edit(admin, i18n, &db, path.0.id)
.await?
.into_response()
},
)
.post(
|session: Session, db: Database, path: Path<PathId>,
form: RequestForm<ReleaseForm>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::releases_update(admin, &db, path.0.id, form).await
},
),
"admin_releases_edit",
),
Route::with_handler_and_name(
"/releases/{id}/delete",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::releases_delete(admin, &db, path.0.id).await
},
),
"admin_releases_delete",
),
// -- Media Files --------------------------------------------------
Route::with_handler_and_name(
"/media-files",
|session: Session, db: Database, i18n: I18n| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::media_files_list(admin, i18n, &db).await?.into_response()
},
"admin_media_files",
),
Route::with_handler_and_name(
"/media-files/{id}/delete",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::media_files_delete(admin, &db, path.0.id).await
},
),
"admin_media_files_delete",
),
// -- Jobs ---------------------------------------------------------
Route::with_handler_and_name(
"/jobs",
{
let registry = Arc::clone(&self.registry);
move |session: Session, db: Database, i18n: I18n| {
let registry = Arc::clone(&registry);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::jobs_list(admin, i18n, &db, &registry).await?.into_response()
}
}
},
"admin_jobs",
),
Route::with_handler_and_name(
"/jobs/{name}/run",
cot::router::method::post({
let handle = Arc::clone(&self.scheduler_handle);
move |session: Session, db: Database, path: Path<PathName>| {
let handle = Arc::clone(&handle);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::job_run_now(admin, &handle, &path.0.name).await
}
}
}),
"admin_job_run",
),
Route::with_handler_and_name(
"/jobs/{name}/toggle",
cot::router::method::post({
let handle = Arc::clone(&self.scheduler_handle);
move |session: Session, db: Database, path: Path<PathName>| {
let handle = Arc::clone(&handle);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::job_toggle_enabled(admin, &db, &handle, &path.0.name).await
}
}
}),
"admin_job_toggle",
),
Route::with_handler_and_name(
"/jobs/{name}/cron",
cot::router::method::post({
let handle = Arc::clone(&self.scheduler_handle);
move |session: Session, db: Database, path: Path<PathName>,
form: RequestForm<CronForm>| {
let handle = Arc::clone(&handle);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::job_update_cron(admin, &db, &handle, &path.0.name, form).await
}
}
}),
"admin_job_cron",
),
Route::with_handler_and_name(
"/jobs/{name}/runs/{run_id}",
{
move |session: Session, db: Database, i18n: I18n,
path: Path<PathNameRunId>| {
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::job_run_detail(admin, i18n, &db, &path.0.name, path.0.run_id)
.await?
.into_response()
}
}
},
"admin_job_run_detail",
),
Route::with_handler_and_name(
"/jobs/{name}",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database, i18n: I18n,
path: Path<PathName>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
let pg_pool = pool.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(3)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
}).await;
views::job_detail(admin, i18n, &db, pg_pool, &path.0.name)
.await?
.into_response()
}
}
},
"admin_job_detail",
),
// -- Reviews: clear -----------------------------------------------
Route::with_handler_and_name(
"/reviews/clear",
cot::router::method::post(
|session: Session, db: Database,
query: UrlQuery<ReviewsQuery>| async move {
let admin =
match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::reviews_clear(admin, &db, query.0.status.as_deref()).await
},
),
"admin_reviews_clear",
),
// -- Reviews ------------------------------------------------------
Route::with_handler_and_name(
"/reviews",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database, i18n: I18n,
query: UrlQuery<ReviewsQuery>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
let pg_pool = pool.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(3)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
}).await;
views::reviews_list(admin, i18n, &db, pg_pool, query.0.status.as_deref())
.await?
.into_response()
}
}
},
"admin_reviews",
),
Route::with_handler_and_name(
"/reviews/{id}",
|session: Session, db: Database, i18n: I18n,
path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::review_detail(admin, i18n, &db, path.0.id)
.await?
.into_response()
},
"admin_review_detail",
),
Route::with_handler_and_name(
"/reviews/{id}/approve",
cot::router::method::post({
let config = Arc::clone(&self.config);
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database, path: Path<PathId>| {
let config = Arc::clone(&config);
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
let pg_pool = pool.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(3)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
}).await;
views::review_approve(admin, &config, &db, pg_pool, path.0.id).await
}
}
}),
"admin_review_approve",
),
Route::with_handler_and_name(
"/reviews/{id}/reject",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::review_reject(admin, &db, path.0.id).await
},
),
"admin_review_reject",
),
Route::with_handler_and_name(
"/reviews/{id}/requeue",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::review_requeue(admin, &db, path.0.id).await
},
),
"admin_review_requeue",
),
])
}
@@ -247,6 +758,12 @@ impl App for AdminApp {
all.extend(cot::db::migrations::wrap_migrations(
crate::user::db_migrations::MIGRATIONS,
));
all.extend(cot::db::migrations::wrap_migrations(
crate::music::db_migrations::MIGRATIONS,
));
all.extend(cot::db::migrations::wrap_migrations(
crate::scheduler::db_migrations::MIGRATIONS,
));
all
}
}
+1141 -11
View File
File diff suppressed because it is too large Load Diff
+408
View File
@@ -0,0 +1,408 @@
//! Cover art extraction and management.
//!
//! Sources (in priority order):
//! 1. Standalone image files in the album folder (cover.jpg, folder.jpg, etc.)
//! 2. Embedded cover art in audio file metadata (ID3 APIC, Vorbis METADATA_BLOCK_PICTURE, etc.)
//!
//! The first usable image found is saved as a MediaFile with file_type="cover_art"
//! and linked to the Release via cover_file_id.
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
/// Image data extracted from an audio file or found on disk.
#[derive(Debug)]
pub struct CoverImage {
pub data: Vec<u8>,
pub mime_type: String,
/// Where this image came from (for logging).
pub source: CoverSource,
}
#[derive(Debug)]
pub enum CoverSource {
/// A standalone image file in the folder.
FolderFile(PathBuf),
/// Embedded in an audio file's metadata.
Embedded(PathBuf),
}
/// Well-known cover art filenames, in priority order.
/// Case-insensitive matching is used.
const COVER_FILENAMES: &[&str] = &[
"cover",
"folder",
"front",
"album",
"albumart",
"albumartsmall",
"thumb",
"artwork",
];
const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "webp", "bmp", "gif"];
fn is_image_file(name: &str) -> bool {
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
IMAGE_EXTENSIONS.contains(&ext.as_str())
}
fn mime_for_image(path: &Path) -> String {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
"jpg" | "jpeg" => "image/jpeg".to_string(),
"png" => "image/png".to_string(),
"webp" => "image/webp".to_string(),
"gif" => "image/gif".to_string(),
"bmp" => "image/bmp".to_string(),
_ => "application/octet-stream".to_string(),
}
}
/// Scan a folder for image files that look like cover art.
///
/// Returns image file paths sorted by priority:
/// - Files with well-known names (cover.jpg, front.png, etc.) first
/// - Then any other image files
pub fn find_folder_images(folder: &Path) -> Vec<PathBuf> {
let entries = match std::fs::read_dir(folder) {
Ok(rd) => rd,
Err(_) => return Vec::new(),
};
let mut images: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name().to_string_lossy().into_owned();
!name.starts_with('.') && is_image_file(&name)
})
.map(|e| e.path())
.collect();
// Sort: well-known names first (by priority index), then alphabetically
images.sort_by(|a, b| {
let pri_a = cover_name_priority(a);
let pri_b = cover_name_priority(b);
pri_a.cmp(&pri_b).then_with(|| a.cmp(b))
});
images
}
/// Return a priority index for a filename (lower = higher priority).
/// Well-known cover filenames get indices 0..N, unknown ones get usize::MAX.
fn cover_name_priority(path: &Path) -> usize {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_lowercase();
for (i, &known) in COVER_FILENAMES.iter().enumerate() {
if stem == known {
return i;
}
}
usize::MAX
}
/// Try to find the best cover image for a folder of audio files.
///
/// Strategy:
/// 1. Look for standalone image files in the folder (prioritized by filename).
/// 2. Try to extract embedded cover art from each audio file.
///
/// Returns the first usable image found, or None.
pub async fn find_best_cover(
folder: &Path,
audio_files: &[PathBuf],
) -> Option<CoverImage> {
// Strategy 1: folder images
let folder_images = find_folder_images(folder);
for img_path in &folder_images {
match tokio::fs::read(img_path).await {
Ok(data) if !data.is_empty() => {
let mime = mime_for_image(img_path);
return Some(CoverImage {
data,
mime_type: mime,
source: CoverSource::FolderFile(img_path.clone()),
});
}
_ => continue,
}
}
// Strategy 2: embedded cover art from audio files
for audio_path in audio_files {
let path = audio_path.to_path_buf();
let result = tokio::task::spawn_blocking(move || extract_embedded_cover(&path)).await;
if let Ok(Some(cover)) = result {
return Some(cover);
}
}
None
}
/// Extract embedded cover art from an audio file.
///
/// Tries Symphonia first (works for FLAC, OGG, etc.), then falls back to
/// id3 crate for MP3 files.
///
/// Must be called from a blocking context.
fn extract_embedded_cover(path: &Path) -> Option<CoverImage> {
// Try Symphonia visuals first
if let Some(cover) = extract_cover_symphonia(path) {
return Some(cover);
}
// Fallback: id3 for MP3
let is_mp3 = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("mp3"))
.unwrap_or(false);
if is_mp3 {
return extract_cover_id3(path);
}
None
}
fn extract_cover_symphonia(path: &Path) -> Option<CoverImage> {
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
let file = std::fs::File::open(path).ok()?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let mut probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions {
enable_gapless: false,
..Default::default()
},
&MetadataOptions::default(),
)
.ok()?;
// Check side-data metadata (ID3 before format)
if let Some(rev) = probed.metadata.get().as_ref().and_then(|m| m.current()) {
for visual in rev.visuals() {
if !visual.data.is_empty() {
let mime = if visual.media_type.is_empty() {
guess_image_mime(&visual.data)
} else {
visual.media_type.to_string()
};
return Some(CoverImage {
data: visual.data.to_vec(),
mime_type: mime,
source: CoverSource::Embedded(path.to_path_buf()),
});
}
}
}
// Check format-level metadata
if let Some(rev) = probed.format.metadata().current() {
for visual in rev.visuals() {
if !visual.data.is_empty() {
let mime = if visual.media_type.is_empty() {
guess_image_mime(&visual.data)
} else {
visual.media_type.to_string()
};
return Some(CoverImage {
data: visual.data.to_vec(),
mime_type: mime,
source: CoverSource::Embedded(path.to_path_buf()),
});
}
}
}
None
}
fn extract_cover_id3(path: &Path) -> Option<CoverImage> {
let tag = id3::Tag::read_from_path(path).ok()?;
// Prefer front cover (picture type 3), then any picture
let mut best: Option<&id3::frame::Picture> = None;
for pic in tag.pictures() {
if pic.picture_type == id3::frame::PictureType::CoverFront {
best = Some(pic);
break;
}
if best.is_none() {
best = Some(pic);
}
}
let pic = best?;
if pic.data.is_empty() {
return None;
}
let mime = if pic.mime_type.is_empty() || pic.mime_type == "image/" {
guess_image_mime(&pic.data)
} else {
pic.mime_type.clone()
};
Some(CoverImage {
data: pic.data.clone(),
mime_type: mime,
source: CoverSource::Embedded(path.to_path_buf()),
})
}
/// Guess MIME type from image magic bytes.
fn guess_image_mime(data: &[u8]) -> String {
if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
"image/jpeg".to_string()
} else if data.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
"image/png".to_string()
} else if data.starts_with(b"RIFF") && data.len() > 12 && &data[8..12] == b"WEBP" {
"image/webp".to_string()
} else if data.starts_with(b"GIF8") {
"image/gif".to_string()
} else if data.starts_with(&[0x42, 0x4D]) {
"image/bmp".to_string()
} else {
"image/jpeg".to_string() // default assumption
}
}
/// Compute SHA-256 hash of image data.
pub fn hash_image(data: &[u8]) -> String {
let digest = Sha256::digest(data);
format!("{:x}", digest)
}
/// Extension for a MIME type.
pub fn extension_for_mime(mime: &str) -> &str {
match mime {
"image/jpeg" => "jpg",
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
"image/bmp" => "bmp",
_ => "jpg",
}
}
/// Save cover image data to the storage directory and create a MediaFile record.
///
/// Returns the MediaFile ID on success.
pub async fn save_cover_to_storage(
db: &cot::db::Database,
pool: &sqlx::PgPool,
storage_dir: &str,
artist_name: &str,
release_title: &str,
cover: &CoverImage,
) -> anyhow::Result<i64> {
let hash = hash_image(&cover.data);
// Check if we already have this exact image in the DB
let existing: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM furumusic__media_file WHERE sha256_hash = $1 AND file_type = 'cover_art' LIMIT 1",
)
.bind(&hash)
.fetch_optional(pool)
.await?;
if let Some((id,)) = existing {
return Ok(id);
}
let ext = extension_for_mime(&cover.mime_type);
let filename = format!("cover.{ext}");
let artist_dir = sanitize_dir_name(artist_name);
let album_dir = sanitize_dir_name(release_title);
let dest_dir = Path::new(storage_dir).join(&artist_dir).join(&album_dir);
tokio::fs::create_dir_all(&dest_dir).await?;
let dest_path = dest_dir.join(&filename);
// Write image data
tokio::fs::write(&dest_path, &cover.data).await?;
let relative_path = dest_path.to_string_lossy().to_string();
let file_size = cover.data.len() as i64;
let media_file = crate::music::MediaFile::create(
db,
"cover_art",
&relative_path,
&filename,
&cover.mime_type,
file_size,
&hash,
None,
None,
None,
None,
)
.await
.map_err(|e| anyhow::anyhow!("failed to create cover MediaFile: {e}"))?;
tracing::info!(
media_file_id = media_file.id_val(),
hash = %hash,
mime = %cover.mime_type,
size = file_size,
"Saved cover art"
);
Ok(media_file.id_val())
}
/// Set the cover_file_id on a release (if not already set).
pub async fn assign_cover_to_release(
pool: &sqlx::PgPool,
release_id: i64,
cover_file_id: i64,
) -> anyhow::Result<()> {
sqlx::query(
"UPDATE furumusic__release SET cover_file_id = $1, updated_at = $3 WHERE id = $2 AND cover_file_id IS NULL",
)
.bind(cover_file_id)
.bind(release_id)
.bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
.execute(pool)
.await?;
Ok(())
}
fn sanitize_dir_name(name: &str) -> String {
name.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
_ => c,
})
.collect::<String>()
.trim()
.trim_matches('.')
.to_owned()
}
+65
View File
@@ -0,0 +1,65 @@
use serde::{Deserialize, Serialize};
/// Raw metadata extracted from audio file tags.
#[derive(Debug, Default)]
pub struct RawMetadata {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub track_number: Option<u32>,
pub year: Option<u32>,
pub genre: Option<String>,
pub duration_secs: Option<f64>,
}
/// Hints parsed from the file path (directory structure + filename).
#[derive(Debug, Default)]
pub struct PathHints {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub year: Option<i32>,
pub track_number: Option<i32>,
}
/// Normalized metadata returned by the LLM.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct NormalizedFields {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub year: Option<i32>,
pub track_number: Option<i32>,
pub genre: Option<String>,
#[serde(default)]
pub featured_artists: Vec<String>,
pub release_type: Option<String>,
pub confidence: Option<f64>,
pub notes: Option<String>,
}
/// A similar artist found via pg_trgm fuzzy search.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SimilarArtist {
pub id: i64,
pub name: String,
pub similarity: f32,
}
/// A similar release found via pg_trgm fuzzy search.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SimilarRelease {
pub id: i64,
pub title: String,
pub year: Option<i32>,
pub similarity: f32,
}
/// Context about other files in the same folder (for the LLM).
pub struct FolderContext {
pub folder_path: String,
pub folder_files: Vec<String>,
pub track_count: usize,
}
+169
View File
@@ -0,0 +1,169 @@
use std::path::Path;
use symphonia::core::{
codecs::CODEC_TYPE_NULL,
formats::FormatOptions,
io::MediaSourceStream,
meta::{MetadataOptions, StandardTagKey},
probe::Hint,
};
use super::dto::RawMetadata;
/// Extract metadata from an audio file.
///
/// For MP3, falls back to the `id3` crate when Symphonia cannot probe the file
/// (e.g. ID3 tag with large embedded cover art exceeds Symphonia's probe limit).
///
/// Must be called from a blocking context (`spawn_blocking`).
pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
match extract_via_symphonia(path) {
Ok(meta) => Ok(meta),
Err(e) => {
let is_mp3 = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("mp3"))
.unwrap_or(false);
if is_mp3 {
tracing::debug!(error = %e, "Symphonia failed on MP3, falling back to id3 crate");
extract_mp3_via_id3(path)
} else {
Err(e)
}
}
}
}
fn extract_via_symphonia(path: &Path) -> anyhow::Result<RawMetadata> {
let file = std::fs::File::open(path)?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let mut probed = symphonia::default::get_probe().format(
&hint,
mss,
&FormatOptions {
enable_gapless: false,
..Default::default()
},
&MetadataOptions::default(),
)?;
let mut meta = RawMetadata::default();
// Check metadata side-data (e.g. ID3 tags probed before format)
if let Some(rev) = probed.metadata.get().as_ref().and_then(|m| m.current()) {
extract_tags(rev.tags(), &mut meta);
}
// Also check format-embedded metadata
if let Some(rev) = probed.format.metadata().current() {
if meta.title.is_none() {
extract_tags(rev.tags(), &mut meta);
}
}
// Duration
meta.duration_secs = probed
.format
.tracks()
.iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.and_then(|t| {
let n_frames = t.codec_params.n_frames?;
let tb = t.codec_params.time_base?;
Some(n_frames as f64 * tb.numer as f64 / tb.denom as f64)
});
Ok(meta)
}
/// Read MP3 tags via the `id3` crate. Duration is not available this way.
fn extract_mp3_via_id3(path: &Path) -> anyhow::Result<RawMetadata> {
use id3::TagLike;
let tag =
id3::Tag::read_from_path(path).map_err(|e| anyhow::anyhow!("id3 read failed: {}", e))?;
let mut meta = RawMetadata::default();
meta.title = tag.title().map(|s| fix_encoding(s.to_owned()));
meta.artist = tag.artist().map(|s| fix_encoding(s.to_owned()));
meta.album = tag.album().map(|s| fix_encoding(s.to_owned()));
meta.year = tag.year().and_then(|y| u32::try_from(y).ok());
meta.track_number = tag.track();
meta.genre = tag.genre().map(|s: &str| fix_encoding(s.to_owned()));
Ok(meta)
}
fn extract_tags(tags: &[symphonia::core::meta::Tag], meta: &mut RawMetadata) {
for tag in tags {
let value = fix_encoding(tag.value.to_string());
if let Some(key) = tag.std_key {
match key {
StandardTagKey::TrackTitle => {
if meta.title.is_none() {
meta.title = Some(value);
}
}
StandardTagKey::Artist | StandardTagKey::Performer => {
if meta.artist.is_none() {
meta.artist = Some(value);
}
}
StandardTagKey::Album => {
if meta.album.is_none() {
meta.album = Some(value);
}
}
StandardTagKey::TrackNumber => {
if meta.track_number.is_none() {
meta.track_number = value.parse().ok();
}
}
StandardTagKey::Date | StandardTagKey::OriginalDate => {
if meta.year.is_none() {
meta.year = value[..4.min(value.len())].parse().ok();
}
}
StandardTagKey::Genre => {
if meta.genre.is_none() {
meta.genre = Some(value);
}
}
_ => {}
}
}
}
}
/// Heuristic to fix mojibake (CP1251 bytes interpreted as Latin-1/Windows-1252).
fn fix_encoding(s: String) -> String {
let bytes: Vec<u8> = s
.chars()
.map(|c| c as u32)
.filter(|&c| c <= 255)
.map(|c| c as u8)
.collect();
if bytes.len() != s.chars().count() {
return s;
}
let has_mojibake = bytes.iter().any(|&b| b >= 0xC0);
if !has_mojibake {
return s;
}
let (decoded, _, errors) = encoding_rs::WINDOWS_1251.decode(&bytes);
if errors {
return s;
}
decoded.into_owned()
}
+156
View File
@@ -0,0 +1,156 @@
pub mod cover_art;
pub mod dto;
pub mod metadata;
pub mod mover;
pub mod normalize;
pub mod path_hints;
pub mod rag;
use serde::Deserialize;
// ---------------------------------------------------------------------------
// LLM health probe — called from the admin settings page
// ---------------------------------------------------------------------------
/// Result of probing the LLM API.
#[derive(Debug, Default)]
pub struct AgentProbeResult {
pub ok: bool,
pub model_intro: String,
pub model_name: String,
pub prompt_tokens: Option<u32>,
pub completion_tokens: Option<u32>,
pub tokens_per_sec: Option<f64>,
pub latency_ms: u64,
pub error: String,
}
/// Send a lightweight "introduce yourself" prompt to the LLM and return the
/// response together with timing / usage statistics when available.
pub async fn probe_llm(
llm_url: &str,
llm_model: &str,
llm_auth: &str,
) -> AgentProbeResult {
let start = std::time::Instant::now();
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(e) => {
return AgentProbeResult {
error: format!("failed to create HTTP client: {e}"),
..Default::default()
};
}
};
let body = serde_json::json!({
"model": llm_model,
"messages": [
{
"role": "user",
"content": "Introduce yourself briefly: what model are you, who made you? Reply in 12 sentences."
}
],
"stream": false,
"temperature": 0.3,
"max_tokens": 256
});
let url = format!("{}/v1/chat/completions", llm_url.trim_end_matches('/'));
let mut req = client.post(&url).json(&body);
if !llm_auth.is_empty() {
req = req.header("Authorization", llm_auth);
}
let resp = match req.send().await {
Ok(r) => r,
Err(e) => {
return AgentProbeResult {
latency_ms: start.elapsed().as_millis() as u64,
error: format!("connection failed: {e}"),
..Default::default()
};
}
};
let elapsed = start.elapsed();
let latency_ms = elapsed.as_millis() as u64;
if !resp.status().is_success() {
let status = resp.status();
let body_text = resp.text().await.unwrap_or_default();
return AgentProbeResult {
latency_ms,
error: format!("HTTP {status}: {}", &body_text[..body_text.len().min(300)]),
..Default::default()
};
}
#[derive(Deserialize)]
struct ProbeResponse {
choices: Option<Vec<ProbeChoice>>,
model: Option<String>,
usage: Option<ProbeUsage>,
}
#[derive(Deserialize)]
struct ProbeChoice {
message: Option<ProbeMessage>,
}
#[derive(Deserialize)]
struct ProbeMessage {
content: Option<String>,
}
#[derive(Deserialize)]
struct ProbeUsage {
prompt_tokens: Option<u32>,
completion_tokens: Option<u32>,
}
let raw: ProbeResponse = match resp.json().await {
Ok(r) => r,
Err(e) => {
return AgentProbeResult {
latency_ms,
error: format!("failed to parse response: {e}"),
..Default::default()
};
}
};
let model_intro = raw
.choices
.as_ref()
.and_then(|c| c.first())
.and_then(|c| c.message.as_ref())
.and_then(|m| m.content.clone())
.unwrap_or_default();
let model_name = raw.model.unwrap_or_default();
let prompt_tokens = raw.usage.as_ref().and_then(|u| u.prompt_tokens);
let completion_tokens = raw.usage.as_ref().and_then(|u| u.completion_tokens);
// Compute tokens/sec from completion tokens and wall time
let tokens_per_sec = completion_tokens.map(|ct| {
if elapsed.as_secs_f64() > 0.0 {
ct as f64 / elapsed.as_secs_f64()
} else {
0.0
}
});
AgentProbeResult {
ok: true,
model_intro,
model_name,
prompt_tokens,
completion_tokens,
tokens_per_sec,
latency_ms,
error: String::new(),
}
}
+66
View File
@@ -0,0 +1,66 @@
use std::path::{Path, PathBuf};
pub enum MoveOutcome {
/// File was moved/renamed to destination.
Moved(PathBuf),
/// Destination already existed; inbox duplicate was removed.
Merged(PathBuf),
}
/// Move a file from inbox to the permanent storage directory.
///
/// Creates the directory structure: `storage_dir/artist/album/filename`
///
/// If `rename` fails (cross-device), falls back to copy + remove.
/// If the destination already exists the inbox copy is removed and
/// `MoveOutcome::Merged` is returned.
pub async fn move_to_storage(
storage_dir: &Path,
artist: &str,
album: &str,
filename: &str,
source: &Path,
) -> anyhow::Result<MoveOutcome> {
let artist_dir = sanitize_dir_name(artist);
let album_dir = sanitize_dir_name(album);
let dest_dir = storage_dir.join(&artist_dir).join(&album_dir);
tokio::fs::create_dir_all(&dest_dir).await?;
let dest = dest_dir.join(filename);
// File already at destination — remove the inbox duplicate
if dest.exists() {
if source.exists() {
tokio::fs::remove_file(source).await?;
tracing::info!(from = ?source, to = ?dest, "merged duplicate into existing storage file");
}
return Ok(MoveOutcome::Merged(dest));
}
// Try atomic rename first (same filesystem)
match tokio::fs::rename(source, &dest).await {
Ok(()) => {}
Err(_) => {
// Cross-device: copy then remove
tokio::fs::copy(source, &dest).await?;
tokio::fs::remove_file(source).await?;
}
}
tracing::info!(from = ?source, to = ?dest, "moved file to storage");
Ok(MoveOutcome::Moved(dest))
}
/// Remove characters that are unsafe for directory names.
fn sanitize_dir_name(name: &str) -> String {
name.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
_ => c,
})
.collect::<String>()
.trim()
.trim_matches('.')
.to_owned()
}
+483
View File
@@ -0,0 +1,483 @@
use serde::{Deserialize, Serialize};
use super::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata, SimilarArtist, SimilarRelease};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/// A single message in the chat history.
#[derive(Clone, Serialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
#[derive(Serialize)]
struct ChatRequest {
model: String,
messages: Vec<ChatMessage>,
response_format: ChatResponseFormat,
stream: bool,
temperature: f64,
}
#[derive(Serialize)]
struct ChatResponseFormat {
#[serde(rename = "type")]
kind: String,
}
#[derive(Deserialize)]
struct ChatResponse {
model: Option<String>,
choices: Vec<ChatChoice>,
usage: Option<ChatUsage>,
}
#[derive(Deserialize)]
struct ChatChoice {
message: ChatResponseMessage,
}
#[derive(Deserialize)]
struct ChatResponseMessage {
content: String,
}
#[derive(Deserialize, Default)]
struct ChatUsage {
prompt_tokens: Option<u32>,
completion_tokens: Option<u32>,
}
async fn call_llm_chat(
base_url: &str,
model: &str,
messages: &[ChatMessage],
auth: Option<&str>,
) -> anyhow::Result<(String, String, ChatUsage)> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(600))
.build()?;
let request = ChatRequest {
model: model.to_owned(),
messages: messages.to_vec(),
response_format: ChatResponseFormat {
kind: "json_object".to_owned(),
},
stream: false,
temperature: 0.1,
};
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
tracing::info!(
%url,
model,
message_count = messages.len(),
"Calling LLM API (chat mode)..."
);
let start = std::time::Instant::now();
let mut req = client.post(&url).json(&request);
if let Some(auth_header) = auth {
req = req.header("Authorization", auth_header);
}
let resp = req.send().await?;
let elapsed = start.elapsed();
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
tracing::error!(%status, body = &body[..body.len().min(500)], "LLM API error");
anyhow::bail!("LLM returned {}: {}", status, body);
}
let chat_resp: ChatResponse = resp.json().await?;
let resp_model = chat_resp.model.unwrap_or_else(|| model.to_owned());
let usage = chat_resp.usage.unwrap_or_default();
let content = chat_resp
.choices
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("LLM returned empty choices"))?
.message
.content;
tracing::info!(
elapsed_ms = elapsed.as_millis() as u64,
response_len = content.len(),
prompt_tokens = usage.prompt_tokens.unwrap_or(0),
completion_tokens = usage.completion_tokens.unwrap_or(0),
model = %resp_model,
"LLM response received"
);
tracing::debug!(raw_response = %content, "LLM raw output");
Ok((content, resp_model, usage))
}
// ---------------------------------------------------------------------------
// Batch normalize — process multiple files in one LLM call
// ---------------------------------------------------------------------------
/// Input for one file in a batch normalize call.
pub struct BatchFileInput {
pub filename: String,
pub raw: RawMetadata,
pub hints: PathHints,
}
/// Result of a batch normalize call.
pub struct BatchNormalizeResult {
/// (filename, normalized_fields) pairs.
pub results: Vec<(String, NormalizedFields)>,
pub model: String,
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub duration_ms: u64,
}
/// Estimate the token count for a batch of files.
/// Uses the rough heuristic of 1 token per 4 characters.
fn estimate_batch_tokens(
system_prompt: &str,
files: &[BatchFileInput],
similar_artists: &[SimilarArtist],
similar_releases: &[SimilarRelease],
folder_ctx: Option<&FolderContext>,
) -> u64 {
let system_tokens = system_prompt.len() as u64 / 4;
// Shared context (RAG + folder) — sent once
let mut shared_chars: u64 = 0;
for a in similar_artists {
shared_chars += 40 + a.name.len() as u64;
}
for r in similar_releases {
shared_chars += 50 + r.title.len() as u64;
}
if let Some(ctx) = folder_ctx {
shared_chars += 60 + ctx.folder_path.len() as u64;
for f in &ctx.folder_files {
shared_chars += 4 + f.len() as u64;
}
}
let shared_tokens = shared_chars / 4;
// Per-file: metadata input + expected response
let mut per_file_tokens: u64 = 0;
for f in files {
let mut chars: u64 = 40 + f.filename.len() as u64; // header
if let Some(v) = &f.raw.title { chars += 10 + v.len() as u64; }
if let Some(v) = &f.raw.artist { chars += 12 + v.len() as u64; }
if let Some(v) = &f.raw.album { chars += 12 + v.len() as u64; }
if f.raw.year.is_some() { chars += 12; }
if f.raw.track_number.is_some() { chars += 18; }
if let Some(v) = &f.raw.genre { chars += 10 + v.len() as u64; }
// hints
if let Some(v) = &f.hints.artist { chars += 16 + v.len() as u64; }
if let Some(v) = &f.hints.album { chars += 16 + v.len() as u64; }
if let Some(v) = &f.hints.title { chars += 15 + v.len() as u64; }
if f.hints.year.is_some() { chars += 14; }
if f.hints.track_number.is_some() { chars += 20; }
per_file_tokens += chars / 4;
// Expected response per file (~150 tokens)
per_file_tokens += 150;
}
system_tokens + shared_tokens + per_file_tokens
}
/// Build the user message for a batch of files.
fn build_batch_user_message(
files: &[BatchFileInput],
similar_artists: &[SimilarArtist],
similar_releases: &[SimilarRelease],
folder_ctx: Option<&FolderContext>,
) -> String {
let mut msg = String::with_capacity(4096);
// Shared context first
if let Some(ctx) = folder_ctx {
msg.push_str("## Folder context\n");
msg.push_str(&format!("Folder path: \"{}\"\n", ctx.folder_path));
msg.push_str(&format!("Total files in folder: {}\n\n", ctx.track_count));
}
if !similar_artists.is_empty() {
msg.push_str("## Existing artists in database\n");
for a in similar_artists {
msg.push_str(&format!("- \"{}\" (similarity: {:.2})\n", a.name, a.similarity));
}
msg.push('\n');
}
if !similar_releases.is_empty() {
msg.push_str("## Existing releases in database\n");
for r in similar_releases {
let year_str = r.year.map(|y| format!(", year: {y}")).unwrap_or_default();
msg.push_str(&format!("- \"{}\" (similarity: {:.2}{})\n", r.title, r.similarity, year_str));
}
msg.push('\n');
}
// Per-file metadata
msg.push_str(&format!("## Files to process ({})\n\n", files.len()));
for f in files {
msg.push_str(&format!("### {}\n", f.filename));
if let Some(v) = &f.raw.title { msg.push_str(&format!("Title: \"{v}\"\n")); }
if let Some(v) = &f.raw.artist { msg.push_str(&format!("Artist: \"{v}\"\n")); }
if let Some(v) = &f.raw.album { msg.push_str(&format!("Release: \"{v}\"\n")); }
if let Some(v) = f.raw.year { msg.push_str(&format!("Year: {v}\n")); }
if let Some(v) = f.raw.track_number { msg.push_str(&format!("Track: {v}\n")); }
if let Some(v) = &f.raw.genre { msg.push_str(&format!("Genre: \"{v}\"\n")); }
// Path hints (only if different from tag metadata)
let has_hints = f.hints.artist.is_some()
|| f.hints.album.is_some()
|| f.hints.title.is_some()
|| f.hints.year.is_some()
|| f.hints.track_number.is_some();
if has_hints {
if let Some(v) = &f.hints.artist { msg.push_str(&format!("Path artist: \"{v}\"\n")); }
if let Some(v) = &f.hints.album { msg.push_str(&format!("Path release: \"{v}\"\n")); }
if let Some(v) = &f.hints.title { msg.push_str(&format!("Path title: \"{v}\"\n")); }
if let Some(v) = f.hints.year { msg.push_str(&format!("Path year: {v}\n")); }
if let Some(v) = f.hints.track_number { msg.push_str(&format!("Path track: {v}\n")); }
}
msg.push('\n');
}
msg
}
/// Normalize a batch of files in one LLM call.
/// If the batch is too large for the context window, it is automatically
/// split in half and each half is processed recursively.
pub async fn normalize_batch(
llm_url: &str,
llm_model: &str,
llm_auth: &str,
system_prompt: &str,
context_limit: u64,
files: Vec<BatchFileInput>,
similar_artists: &[SimilarArtist],
similar_releases: &[SimilarRelease],
folder_ctx: Option<&FolderContext>,
) -> anyhow::Result<BatchNormalizeResult> {
// Estimate tokens
let estimated = estimate_batch_tokens(
system_prompt, &files, similar_artists, similar_releases, folder_ctx,
);
// If over 80% of context limit and more than 1 file, split
let limit_80 = context_limit * 80 / 100;
if estimated > limit_80 && files.len() > 1 {
tracing::info!(
estimated_tokens = estimated,
context_limit,
file_count = files.len(),
"Batch too large, splitting in half"
);
let mid = files.len() / 2;
let mut files_vec = files;
let right = files_vec.split_off(mid);
let left = files_vec;
let left_result = Box::pin(normalize_batch(
llm_url, llm_model, llm_auth, system_prompt, context_limit,
left, similar_artists, similar_releases, folder_ctx,
)).await?;
let right_result = Box::pin(normalize_batch(
llm_url, llm_model, llm_auth, system_prompt, context_limit,
right, similar_artists, similar_releases, folder_ctx,
)).await?;
// Merge results
let mut results = left_result.results;
results.extend(right_result.results);
return Ok(BatchNormalizeResult {
results,
model: left_result.model,
prompt_tokens: left_result.prompt_tokens + right_result.prompt_tokens,
completion_tokens: left_result.completion_tokens + right_result.completion_tokens,
duration_ms: left_result.duration_ms + right_result.duration_ms,
});
}
// Build and send
let user_message = build_batch_user_message(
&files, similar_artists, similar_releases, folder_ctx,
);
let messages = vec![
ChatMessage { role: "system".into(), content: system_prompt.to_owned() },
ChatMessage { role: "user".into(), content: user_message },
];
let start = std::time::Instant::now();
let call_result = call_llm_chat(
llm_url, llm_model, &messages,
if llm_auth.is_empty() { None } else { Some(llm_auth) },
).await;
let duration_ms = start.elapsed().as_millis() as u64;
// If LLM error and batch > 1, try splitting (handles context overflow errors)
let (response_text, resp_model, usage) = match call_result {
Ok(r) => r,
Err(e) if files.len() > 1 => {
let err_str = e.to_string().to_lowercase();
let is_context_error = err_str.contains("context")
|| err_str.contains("too long")
|| err_str.contains("maximum")
|| err_str.contains("length")
|| err_str.contains("token");
if is_context_error {
tracing::warn!(
file_count = files.len(),
"LLM error suggests context overflow, splitting batch: {e}"
);
let mid = files.len() / 2;
let mut files_vec = files;
let right = files_vec.split_off(mid);
let left = files_vec;
let left_result = Box::pin(normalize_batch(
llm_url, llm_model, llm_auth, system_prompt, context_limit,
left, similar_artists, similar_releases, folder_ctx,
)).await?;
let right_result = Box::pin(normalize_batch(
llm_url, llm_model, llm_auth, system_prompt, context_limit,
right, similar_artists, similar_releases, folder_ctx,
)).await?;
let mut results = left_result.results;
results.extend(right_result.results);
return Ok(BatchNormalizeResult {
results,
model: left_result.model,
prompt_tokens: left_result.prompt_tokens + right_result.prompt_tokens,
completion_tokens: left_result.completion_tokens + right_result.completion_tokens,
duration_ms: left_result.duration_ms + right_result.duration_ms,
});
}
return Err(e);
}
Err(e) => return Err(e),
};
let prompt_tokens = usage.prompt_tokens.unwrap_or(0) as u64;
let completion_tokens = usage.completion_tokens.unwrap_or(0) as u64;
// Parse batch response
let results = parse_batch_response(&response_text, &files)?;
Ok(BatchNormalizeResult {
results,
model: resp_model,
prompt_tokens,
completion_tokens,
duration_ms,
})
}
/// Parse a batch JSON array response from the LLM.
/// Returns (filename, NormalizedFields) pairs.
/// Handles: clean JSON array, markdown-fenced JSON, and wrapped `{"results": [...]}`.
fn parse_batch_response(
response: &str,
files: &[BatchFileInput],
) -> anyhow::Result<Vec<(String, NormalizedFields)>> {
let cleaned = response.trim();
// Strip markdown code fences if present
let json_str = if cleaned.starts_with("```") {
let start = cleaned.find('[')
.or_else(|| cleaned.find('{'))
.unwrap_or(0);
let end_bracket = cleaned.rfind(']').map(|i| i + 1);
let end_brace = cleaned.rfind('}').map(|i| i + 1);
let end = end_bracket.or(end_brace).unwrap_or(cleaned.len());
&cleaned[start..end]
} else {
cleaned
};
#[derive(Deserialize)]
struct BatchLlmOutput {
filename: Option<String>,
artist: Option<String>,
album: Option<String>,
title: Option<String>,
year: Option<i32>,
track_number: Option<i32>,
genre: Option<String>,
#[serde(default)]
featured_artists: Vec<String>,
release_type: Option<String>,
confidence: Option<f64>,
notes: Option<String>,
}
// Try parsing as array first, then as {"results": [...]} wrapper
let items: Vec<BatchLlmOutput> = if json_str.starts_with('[') {
serde_json::from_str(json_str)
} else {
// Try as wrapper object with a "results" or "files" key
#[derive(Deserialize)]
struct Wrapper {
#[serde(alias = "files")]
results: Vec<BatchLlmOutput>,
}
serde_json::from_str::<Wrapper>(json_str).map(|w| w.results)
}
.map_err(|e| {
anyhow::anyhow!(
"Failed to parse batch LLM response: {} — raw: {}",
e,
&response[..response.len().min(500)]
)
})?;
// Build a map of filename → NormalizedFields
let mut results = Vec::with_capacity(files.len());
let mut matched = std::collections::HashSet::new();
for item in &items {
let filename = match &item.filename {
Some(f) => f.clone(),
None => continue,
};
let fields = NormalizedFields {
title: item.title.clone(),
artist: item.artist.clone(),
album: item.album.clone(),
year: item.year,
track_number: item.track_number,
genre: item.genre.clone(),
featured_artists: item.featured_artists.clone(),
release_type: item.release_type.clone(),
confidence: item.confidence,
notes: item.notes.clone(),
};
matched.insert(filename.clone());
results.push((filename, fields));
}
// Warn about files the LLM missed
for f in files {
if !matched.contains(&f.filename) {
tracing::warn!(
filename = %f.filename,
"LLM batch response missing result for file"
);
}
}
Ok(results)
}
+197
View File
@@ -0,0 +1,197 @@
use std::path::Path;
use super::dto::PathHints;
/// Parse metadata hints from the file path relative to the inbox directory.
///
/// Recognized patterns:
/// Artist/Album/01 - Title.ext
/// Artist/Album (Year)/01 - Title.ext
/// Artist/(Year) Album/01 - Title.ext
/// Artist/Album [Year]/01 - Title.ext
/// 01 - Title.ext (flat, no artist/album)
pub fn parse(relative_path: &Path) -> PathHints {
let components: Vec<&str> = relative_path
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
let mut hints = PathHints::default();
let filename = components.last().copied().unwrap_or("");
let stem = Path::new(filename)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
// Parse track number and title from filename
parse_filename(stem, &mut hints);
match components.len() {
// Artist/Album/file.ext
3.. => {
hints.artist = Some(components[0].to_owned());
let album_raw = components[1];
let (album, year) = parse_album_with_year(album_raw);
hints.album = Some(album);
if year.is_some() {
hints.year = year;
}
}
// Album/file.ext (or Artist/file.ext — ambiguous, treat as album)
2 => {
let dir = components[0];
let (name, year) = parse_album_with_year(dir);
hints.album = Some(name);
if year.is_some() {
hints.year = year;
}
}
// Just file.ext
_ => {}
}
hints
}
/// Try to extract track number and title from a filename stem.
///
/// Patterns: "01 - Title", "01. Title", "1 Title", "Title"
fn parse_filename(stem: &str, hints: &mut PathHints) {
let trimmed = stem.trim();
// Try "NN - Title" or "NN. Title"
if let Some(rest) = try_strip_track_prefix(trimmed) {
let (num_str, title) = rest;
if let Ok(num) = num_str.parse::<i32>() {
hints.track_number = Some(num);
if !title.is_empty() {
hints.title = Some(title.to_owned());
}
return;
}
}
// No track number found, use full stem as title
if !trimmed.is_empty() {
hints.title = Some(trimmed.to_owned());
}
}
/// Try to parse "NN - Rest" or "NN. Rest" from a string.
/// Returns (number_str, rest) if successful.
fn try_strip_track_prefix(s: &str) -> Option<(&str, &str)> {
let digit_end = s.find(|c: char| !c.is_ascii_digit())?;
if digit_end == 0 {
return None;
}
let num_str = &s[..digit_end];
let rest = s[digit_end..].trim_start();
let title = if let Some(stripped) = rest.strip_prefix("- ") {
stripped.trim()
} else if let Some(stripped) = rest.strip_prefix(". ") {
stripped.trim()
} else if let Some(stripped) = rest.strip_prefix('.') {
stripped.trim()
} else {
rest
};
Some((num_str, title))
}
/// Extract album name and optional year from directory name.
///
/// Patterns: "Album (2001)", "(2001) Album", "Album [2001]", "Album"
fn parse_album_with_year(dir: &str) -> (String, Option<i32>) {
// Try "Album (YYYY)" or "Album [YYYY]"
for (open, close) in [('(', ')'), ('[', ']')] {
if let Some(start) = dir.rfind(open) {
if let Some(end) = dir[start..].find(close) {
let inside = &dir[start + 1..start + end];
if let Ok(year) = inside.trim().parse::<i32>() {
if (1900..=2100).contains(&year) {
let album = format!(
"{}{}",
&dir[..start].trim(),
&dir[start + end + 1..].trim()
);
let album = album.trim().to_owned();
return (album, Some(year));
}
}
}
}
}
// Try "(YYYY) Album"
if dir.starts_with('(') {
if let Some(end) = dir.find(')') {
let inside = &dir[1..end];
if let Ok(year) = inside.trim().parse::<i32>() {
if (1900..=2100).contains(&year) {
let album = dir[end + 1..].trim().to_owned();
return (album, Some(year));
}
}
}
}
(dir.to_owned(), None)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_artist_album_track() {
let p = PathBuf::from("Pink Floyd/Wish You Were Here (1975)/03 - Have a Cigar.flac");
let h = parse(&p);
assert_eq!(h.artist.as_deref(), Some("Pink Floyd"));
assert_eq!(h.album.as_deref(), Some("Wish You Were Here"));
assert_eq!(h.year, Some(1975));
assert_eq!(h.track_number, Some(3));
assert_eq!(h.title.as_deref(), Some("Have a Cigar"));
}
#[test]
fn test_year_prefix() {
let p = PathBuf::from("Artist/(2020) Album Name/01. Song.flac");
let h = parse(&p);
assert_eq!(h.artist.as_deref(), Some("Artist"));
assert_eq!(h.album.as_deref(), Some("Album Name"));
assert_eq!(h.year, Some(2020));
assert_eq!(h.track_number, Some(1));
assert_eq!(h.title.as_deref(), Some("Song"));
}
#[test]
fn test_flat_file() {
let p = PathBuf::from("05 - Something.mp3");
let h = parse(&p);
assert_eq!(h.artist, None);
assert_eq!(h.album, None);
assert_eq!(h.track_number, Some(5));
assert_eq!(h.title.as_deref(), Some("Something"));
}
#[test]
fn test_no_track_number() {
let p = PathBuf::from("Artist/Album/Song Name.flac");
let h = parse(&p);
assert_eq!(h.track_number, None);
assert_eq!(h.title.as_deref(), Some("Song Name"));
}
#[test]
fn test_square_bracket_year() {
let p = PathBuf::from("Band/Album [1999]/track.flac");
let h = parse(&p);
assert_eq!(h.album.as_deref(), Some("Album"));
assert_eq!(h.year, Some(1999));
}
}
+97
View File
@@ -0,0 +1,97 @@
use sqlx::PgPool;
use super::dto::{SimilarArtist, SimilarRelease};
/// Find artists with similar names using pg_trgm.
/// Short names (<3 chars) fall back to ILIKE prefix match.
pub async fn find_similar_artists(
pool: &PgPool,
name: &str,
limit: i32,
) -> anyhow::Result<Vec<SimilarArtist>> {
if name.chars().count() < 3 {
let rows: Vec<(i64, String, f32)> = sqlx::query_as(
"SELECT id, name, 1.0::real AS similarity FROM furumusic__artist \
WHERE name_sort ILIKE $1 || '%' ORDER BY name LIMIT $2",
)
.bind(name.to_lowercase())
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(id, name, similarity)| SimilarArtist {
id,
name,
similarity,
})
.collect())
} else {
let rows: Vec<(i64, String, f32)> = sqlx::query_as(
r#"SELECT id, name, MAX(sim) AS similarity FROM (
SELECT id, name, similarity(name_sort, $1) AS sim
FROM furumusic__artist WHERE name_sort % $1
UNION ALL
SELECT id, name, 0.01::real AS sim
FROM furumusic__artist WHERE name_sort ILIKE '%' || $1 || '%'
) sub GROUP BY id, name ORDER BY similarity DESC LIMIT $2"#,
)
.bind(name.to_lowercase())
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(id, name, similarity)| SimilarArtist {
id,
name,
similarity,
})
.collect())
}
}
/// Find releases with similar titles using pg_trgm.
pub async fn find_similar_releases(
pool: &PgPool,
title: &str,
limit: i32,
) -> anyhow::Result<Vec<SimilarRelease>> {
let rows: Vec<(i64, String, Option<i32>, f32)> = sqlx::query_as(
"SELECT id, title, year, similarity(title_sort, $1) AS similarity \
FROM furumusic__release WHERE title_sort % $1 \
ORDER BY similarity DESC LIMIT $2",
)
.bind(title.to_lowercase())
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(id, title, year, similarity)| SimilarRelease {
id,
title,
year,
similarity,
})
.collect())
}
/// Check if a file with the given SHA-256 hash is actively used in the library.
/// Returns true only if a media_file with this hash exists AND at least one
/// track references it via audio_file_id. Orphaned media_files (no track)
/// are ignored so that re-discovery is possible after the user deletes
/// artists/releases/tracks.
pub async fn file_hash_exists(pool: &PgPool, sha256: &str) -> anyhow::Result<bool> {
let row: (bool,) = sqlx::query_as(
"SELECT EXISTS(\
SELECT 1 FROM furumusic__media_file mf \
JOIN furumusic__track t ON t.audio_file_id = mf.id \
WHERE mf.sha256_hash = $1\
)",
)
.bind(sha256)
.fetch_one(pool)
.await?;
Ok(row.0)
}
+63
View File
@@ -128,6 +128,15 @@ pub struct ConfigSources {
pub oidc_button_text: ConfigSource,
pub oidc_admin_groups: ConfigSource,
pub swagger_enabled: ConfigSource,
pub agent_enabled: ConfigSource,
pub agent_inbox_dir: ConfigSource,
pub agent_storage_dir: ConfigSource,
pub agent_llm_url: ConfigSource,
pub agent_llm_model: ConfigSource,
pub agent_llm_auth: ConfigSource,
pub agent_confidence_threshold: ConfigSource,
pub agent_context_limit: ConfigSource,
pub agent_concurrency: ConfigSource,
}
impl Default for ConfigSources {
@@ -143,6 +152,15 @@ impl Default for ConfigSources {
oidc_button_text: ConfigSource::Default,
oidc_admin_groups: ConfigSource::Default,
swagger_enabled: ConfigSource::Default,
agent_enabled: ConfigSource::Default,
agent_inbox_dir: ConfigSource::Default,
agent_storage_dir: ConfigSource::Default,
agent_llm_url: ConfigSource::Default,
agent_llm_model: ConfigSource::Default,
agent_llm_auth: ConfigSource::Default,
agent_confidence_threshold: ConfigSource::Default,
agent_context_limit: ConfigSource::Default,
agent_concurrency: ConfigSource::Default,
}
}
}
@@ -227,6 +245,24 @@ pub struct AppConfig {
pub oidc_admin_groups: String,
/// Whether the Swagger UI is served at /swagger/.
pub swagger_enabled: bool,
/// Whether the AI agent background loop is enabled.
pub agent_enabled: bool,
/// Directory to scan for incoming audio files.
pub agent_inbox_dir: String,
/// Directory for organized permanent storage.
pub agent_storage_dir: String,
/// LLM API URL (OpenAI-compatible).
pub agent_llm_url: String,
/// LLM model name.
pub agent_llm_model: String,
/// LLM Authorization header value (e.g. "Bearer sk-...").
pub agent_llm_auth: String,
/// Confidence threshold for auto-approval (0.01.0).
pub agent_confidence_threshold: f64,
/// LLM context window size in tokens. Chat history resets when approaching this limit.
pub agent_context_limit: u64,
/// Number of files to process in parallel via the LLM.
pub agent_concurrency: u64,
}
impl Default for AppConfig {
@@ -242,6 +278,15 @@ impl Default for AppConfig {
oidc_button_text: "Sign in with SSO".into(),
oidc_admin_groups: String::new(),
swagger_enabled: false,
agent_enabled: false,
agent_inbox_dir: String::new(),
agent_storage_dir: String::new(),
agent_llm_url: "http://localhost:8080".into(),
agent_llm_model: "default".into(),
agent_llm_auth: String::new(),
agent_confidence_threshold: 0.85,
agent_context_limit: 8192,
agent_concurrency: 2,
}
}
}
@@ -258,6 +303,15 @@ impl_env_overrides!(
oidc_button_text,
oidc_admin_groups,
swagger_enabled,
agent_enabled,
agent_inbox_dir,
agent_storage_dir,
agent_llm_url,
agent_llm_model,
agent_llm_auth,
agent_confidence_threshold,
agent_context_limit,
agent_concurrency,
);
impl AppConfig {
@@ -324,6 +378,15 @@ impl AppConfig {
apply_db_field!(oidc_button_text);
apply_db_field!(oidc_admin_groups);
apply_db_field!(swagger_enabled);
apply_db_field!(agent_enabled);
apply_db_field!(agent_inbox_dir);
apply_db_field!(agent_storage_dir);
apply_db_field!(agent_llm_url);
apply_db_field!(agent_llm_model);
apply_db_field!(agent_llm_auth);
apply_db_field!(agent_confidence_threshold);
apply_db_field!(agent_context_limit);
apply_db_field!(agent_concurrency);
}
}
+2
View File
@@ -40,6 +40,7 @@ impl Lang {
macro_rules! translations {
( $( $key:ident : $en:expr , $ru:expr );* $(;)? ) => {
#[derive(Debug)]
#[allow(dead_code)]
pub struct Translations {
pub lang: $crate::i18n::Lang,
$( pub $key: &'static str, )*
@@ -149,6 +150,7 @@ fn resolve_lang(headers: &cot::http::HeaderMap) -> Lang {
// I18n extractor
// ---------------------------------------------------------------------------
#[allow(dead_code)]
pub struct I18n {
pub lang: Lang,
pub t: &'static Translations,
+149
View File
@@ -97,4 +97,153 @@ translations! {
// OIDC login errors
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
login_sso_disabled: "SSO login is not configured." , "Вход через SSO не настроен.";
// Artist management
nav_artists: "Artists" , "Артисты";
artists_heading: "Artists" , "Артисты";
artists_add: "Add artist" , "Добавить артиста";
artists_name: "Name" , "Имя";
artists_hidden: "Hidden" , "Скрыт";
artists_actions: "Actions" , "Действия";
artists_edit: "Edit" , "Редактировать";
artists_delete: "Delete" , "Удалить";
artists_delete_confirm: "Are you sure?" , "Вы уверены?";
artists_new_heading: "New artist" , "Новый артист";
artists_edit_heading: "Edit artist" , "Редактирование артиста";
artists_empty: "No artists yet." , "Артистов пока нет.";
artists_releases: "Releases" , "Релизы";
artists_tracks: "Tracks" , "Треки";
artists_view_releases: "View releases" , "Показать релизы";
artists_image: "Artist Image" , "Изображение артиста";
artists_no_image: "No image set." , "Изображение не задано.";
artists_upload_image: "Upload custom image" , "Загрузить изображение";
artists_upload: "Upload" , "Загрузить";
artists_pick_cover: "Or pick from album covers" , "Или выберите обложку альбома";
artists_no_covers: "No album covers available." , "Обложки альбомов недоступны.";
artists_remove_image: "Remove image" , "Удалить изображение";
// Release management
nav_releases: "Releases" , "Релизы";
releases_heading: "Releases" , "Релизы";
releases_add: "Add release" , "Добавить релиз";
releases_title: "Title" , "Название";
releases_type: "Type" , "Тип";
releases_year: "Year" , "Год";
releases_artist: "Artist" , "Артист";
releases_artists: "Artists" , "Артисты";
releases_hidden: "Hidden" , "Скрыт";
releases_actions: "Actions" , "Действия";
releases_edit: "Edit" , "Редактировать";
releases_delete: "Delete" , "Удалить";
releases_delete_confirm: "Are you sure?" , "Вы уверены?";
releases_new_heading: "New release" , "Новый релиз";
releases_edit_heading: "Edit release" , "Редактирование релиза";
releases_empty: "No releases yet." , "Релизов пока нет.";
releases_no_artist: "— no artist —" , "— без артиста —";
releases_select_artist: "Select artist..." , "Выберите артиста...";
releases_filter_all: "All artists" , "Все артисты";
releases_filter_label: "Filter by artist" , "Фильтр по артисту";
// Media files
nav_media_files: "Media Files" , "Медиафайлы";
media_files_heading: "Media Files" , "Медиафайлы";
media_files_empty: "No media files found." , "Медиафайлы не найдены.";
media_files_filename: "Filename" , "Файл";
media_files_type: "Type" , "Тип";
media_files_format: "Format" , "Формат";
media_files_size: "Size" , "Размер";
media_files_path: "Path" , "Путь";
media_files_hash: "SHA-256" , "SHA-256";
media_files_created: "Created" , "Создан";
media_files_track: "Track" , "Трек";
media_files_orphan: "Orphan" , "Без трека";
media_files_actions: "Actions" , "Действия";
media_files_delete: "Delete" , "Удалить";
media_files_delete_confirm: "Delete this media file?" , "Удалить этот медиафайл?";
// Job management
nav_jobs: "Jobs" , "Задания";
nav_reviews: "Reviews" , "Проверки";
jobs_heading: "Scheduled Jobs" , "Запланированные задания";
jobs_name: "Name" , "Имя";
jobs_description: "Description" , "Описание";
jobs_cron: "Cron" , "Cron";
jobs_enabled: "Enabled" , "Включено";
jobs_last_run: "Last run" , "Последний запуск";
jobs_next_run: "Next run" , "Следующий запуск";
jobs_actions: "Actions" , "Действия";
jobs_run_now: "Run now" , "Запустить";
jobs_enable: "Enable" , "Включить";
jobs_disable: "Disable" , "Выключить";
jobs_run_history: "Run history" , "История запусков";
jobs_run_status: "Status" , "Статус";
jobs_run_started: "Started" , "Начало";
jobs_run_duration: "Duration" , "Длительность";
jobs_run_trigger: "Trigger" , "Триггер";
jobs_run_log: "Log" , "Лог";
jobs_run_error: "Error" , "Ошибка";
jobs_cron_help: "7-field cron: sec min hour day month weekday year" , "7-полевой cron: сек мин час день месяц день_недели год";
jobs_cron_update: "Update cron" , "Обновить cron";
jobs_back_to_list: "Back to jobs" , "Назад к заданиям";
jobs_run_detail: "Run detail" , "Детали запуска";
jobs_back_to_job: "Back to job" , "Назад к заданию";
// Review management
reviews_heading: "Pending Reviews" , "Ожидающие проверки";
reviews_empty: "No reviews." , "Проверок нет.";
reviews_status: "Status" , "Статус";
reviews_type: "Type" , "Тип";
reviews_input_path: "Input" , "Файл";
reviews_confidence: "Confidence" , "Уверенность";
reviews_approve: "Approve" , "Подтвердить";
reviews_reject: "Reject" , "Отклонить";
reviews_context: "Context" , "Контекст";
reviews_result: "Result" , "Результат";
reviews_created: "Created" , "Создано";
reviews_view: "View" , "Открыть";
reviews_clear_all: "Clear all" , "Очистить все";
reviews_clear_filtered: "Clear shown" , "Очистить показанные";
reviews_clear_confirm: "Are you sure? This will delete the selected reviews." , "Вы уверены? Выбранные проверки будут удалены.";
reviews_back_to_list: "Back to reviews" , "Назад к проверкам";
reviews_filter_all: "All" , "Все";
reviews_filter_pending: "Pending" , "Ожидают";
reviews_filter_approved: "Approved" , "Подтверждённые";
reviews_filter_rejected: "Rejected" , "Отклонённые";
reviews_filter_queued: "Queued" , "В очереди";
reviews_filter_processing: "Processing" , "В обработке";
reviews_filter_auto_approved: "Auto-approved" , "Авто-подтверждённые";
reviews_filter_failed: "Failed" , "Ошибочные";
reviews_error: "Error" , "Ошибка";
reviews_requeue: "Re-queue" , "В очередь";
reviews_requeue_confirm: "Re-queue this item for processing?" , "Поставить в очередь на повторную обработку?";
// Processing stats
settings_agent_concurrency: "Concurrency" , "Параллелизм";
reviews_model: "Model" , "Модель";
reviews_llm_duration: "LLM time" , "Время LLM";
reviews_tokens: "Tokens (in/out)" , "Токены (вх/вых)";
// Agent settings
settings_agent: "Agent" , "Агент";
settings_agent_help: "AI music processing agent configuration. Enable and configure the background agent that automatically processes audio files." , "Настройки AI-агента обработки музыки. Включите и настройте фоновый агент, который автоматически обрабатывает аудиофайлы.";
settings_agent_enabled: "Agent enabled" , "Агент включён";
settings_agent_inbox: "Inbox directory" , "Папка входящих";
settings_agent_storage: "Storage directory" , "Папка хранилища";
settings_agent_llm_url: "LLM API URL" , "URL API LLM";
settings_agent_llm_model: "LLM model" , "Модель LLM";
settings_agent_threshold: "Confidence threshold" , "Порог уверенности";
settings_agent_context: "Context limit (tokens)" , "Лимит контекста (токены)";
settings_agent_llm_auth: "LLM auth header" , "Заголовок авторизации LLM";
settings_agent_status: "Agent Status" , "Статус агента";
settings_agent_status_disabled: "Agent is disabled." , "Агент отключён.";
settings_agent_status_no_url: "LLM URL is not configured." , "URL LLM не настроен.";
settings_agent_status_ok: "LLM connection OK" , "Подключение к LLM OK";
settings_agent_status_error: "LLM connection error" , "Ошибка подключения к LLM";
settings_agent_model_name: "Model" , "Модель";
settings_agent_latency: "Latency" , "Задержка";
settings_agent_prompt_tokens: "Prompt tokens" , "Токенов на промпт";
settings_agent_completion_tokens: "Completion tokens" , "Токенов на ответ";
settings_agent_tokens_per_sec: "Tokens/sec" , "Токенов/сек";
settings_agent_status_loading: "Checking connection" , "Проверка подключения";
}
+58
View File
@@ -0,0 +1,58 @@
use crate::scheduler::{Job, JobContext, JobLog};
/// Periodic job that auto-assigns artist images from their release covers.
///
/// For every artist that has no `image_file_id`, picks the cover of the most
/// recent release (by year) that has one. Runs after the cover backfill job
/// so freshly-extracted covers are available.
pub struct ArtistImageBackfillJob;
#[async_trait::async_trait]
impl Job for ArtistImageBackfillJob {
fn name(&self) -> &'static str {
"artist_image_backfill"
}
fn description(&self) -> &'static str {
"Auto-assign artist images from release covers"
}
fn default_cron(&self) -> &'static str {
// 03:15 daily — after cover_backfill at 03:00
"0 15 3 * * *"
}
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
let result = sqlx::query(
"UPDATE furumusic__artist a \
SET image_file_id = ( \
SELECT r.cover_file_id \
FROM furumusic__release_artist ra \
JOIN furumusic__release r ON r.id = ra.release_id \
WHERE ra.artist_id = a.id \
AND r.cover_file_id IS NOT NULL \
ORDER BY r.year DESC NULLS LAST \
LIMIT 1 \
), \
updated_at = $1 \
WHERE a.image_file_id IS NULL \
AND EXISTS ( \
SELECT 1 FROM furumusic__release_artist ra2 \
JOIN furumusic__release r2 ON r2.id = ra2.release_id \
WHERE ra2.artist_id = a.id AND r2.cover_file_id IS NOT NULL \
)",
)
.bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
.execute(&ctx.pool)
.await?;
let count = result.rows_affected();
if count > 0 {
log.info(&format!("Assigned images to {count} artists from release covers"));
} else {
log.info("All artists already have images (or no covers available)");
}
Ok(())
}
}
+69
View File
@@ -0,0 +1,69 @@
use crate::scheduler::{Job, JobContext, JobLog};
/// Fallback job that assigns artist images from track cover art.
///
/// The primary `artist_image_backfill` job uses release covers. This job
/// runs afterwards and covers the case where the release itself has no
/// cover but individual tracks do (e.g. when cover art is embedded in the
/// audio file and extracted per-track rather than per-release).
///
/// For every artist that *still* has no `image_file_id` after the release-
/// based backfill, picks the `cover_file_id` of the most recent track
/// (by year, then track id) that has one.
pub struct ArtistTrackImageBackfillJob;
#[async_trait::async_trait]
impl Job for ArtistTrackImageBackfillJob {
fn name(&self) -> &'static str {
"artist_track_image_backfill"
}
fn description(&self) -> &'static str {
"Auto-assign artist images from track covers (fallback)"
}
fn default_cron(&self) -> &'static str {
// 03:30 daily — after artist_image_backfill at 03:15
"0 30 3 * * *"
}
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
let result = sqlx::query(
"UPDATE furumusic__artist a \
SET image_file_id = ( \
SELECT t.cover_file_id \
FROM furumusic__track_artist ta \
JOIN furumusic__track t ON t.id = ta.track_id \
WHERE ta.artist_id = a.id \
AND t.cover_file_id IS NOT NULL \
AND t.is_hidden = false \
ORDER BY t.year DESC NULLS LAST, t.id DESC \
LIMIT 1 \
), \
updated_at = $1 \
WHERE a.image_file_id IS NULL \
AND a.is_hidden = false \
AND EXISTS ( \
SELECT 1 FROM furumusic__track_artist ta2 \
JOIN furumusic__track t2 ON t2.id = ta2.track_id \
WHERE ta2.artist_id = a.id \
AND t2.cover_file_id IS NOT NULL \
AND t2.is_hidden = false \
)",
)
.bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
.execute(&ctx.pool)
.await?;
let count = result.rows_affected();
if count > 0 {
log.info(&format!(
"Assigned images to {count} artists from track covers"
));
} else {
log.info("All artists already have images (or no track covers available)");
}
Ok(())
}
}
+172
View File
@@ -0,0 +1,172 @@
use std::path::{Path, PathBuf};
use crate::agent::cover_art;
use crate::scheduler::{Job, JobContext, JobLog};
/// One-shot / periodic job that finds releases without cover art and attempts
/// to extract or discover covers from their audio files in storage.
pub struct CoverBackfillJob;
#[async_trait::async_trait]
impl Job for CoverBackfillJob {
fn name(&self) -> &'static str {
"cover_backfill"
}
fn description(&self) -> &'static str {
"Backfill cover art for releases missing covers"
}
fn default_cron(&self) -> &'static str {
// Once a day at 03:00
"0 0 3 * * *"
}
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
let storage_dir = &ctx.config.agent_storage_dir;
if storage_dir.is_empty() {
log.warn("agent_storage_dir is not configured, skipping cover backfill");
return Ok(());
}
// Find all releases without a cover
let rows: Vec<(i64, String)> = sqlx::query_as(
"SELECT r.id, r.title \
FROM furumusic__release r \
WHERE r.cover_file_id IS NULL \
ORDER BY r.id",
)
.fetch_all(&ctx.pool)
.await?;
if rows.is_empty() {
log.info("All releases already have cover art, nothing to backfill");
return Ok(());
}
log.info(&format!(
"Found {} releases without cover art, starting backfill...",
rows.len()
));
let mut assigned = 0u32;
let mut failed = 0u32;
let mut skipped_no_audio = 0u32;
let mut skipped_no_cover = 0u32;
let total = rows.len();
for (i, (release_id, release_title)) in rows.iter().enumerate() {
log.info(&format!(
"[{}/{}] Processing release {release_id} \"{release_title}\"...",
i + 1,
total,
));
// Find audio files belonging to this release via tracks → media_file
let audio_paths: Vec<(String,)> = sqlx::query_as(
"SELECT mf.file_path \
FROM furumusic__track t \
JOIN furumusic__media_file mf ON mf.id = t.audio_file_id \
WHERE t.release_id = $1 AND mf.file_type = 'audio'",
)
.bind(release_id)
.fetch_all(&ctx.pool)
.await
.unwrap_or_default();
if audio_paths.is_empty() {
log.warn(&format!(
"Release {release_id} \"{release_title}\": no audio files found, skipping"
));
skipped_no_audio += 1;
continue;
}
// Determine the folder from the first audio file's path
let first_path = Path::new(&audio_paths[0].0);
let folder = first_path.parent().unwrap_or(Path::new("."));
// Collect all audio file paths as PathBuf
let audio_files: Vec<PathBuf> = audio_paths
.iter()
.map(|(p,)| PathBuf::from(p))
.collect();
// Try to find cover art
let cover = match cover_art::find_best_cover(folder, &audio_files).await {
Some(c) => c,
None => {
log.info(&format!(
"Release {release_id} \"{release_title}\": no cover image found in {} audio files, skipping",
audio_files.len(),
));
skipped_no_cover += 1;
continue;
}
};
let source_desc = match &cover.source {
cover_art::CoverSource::FolderFile(p) => format!("folder: {}", p.display()),
cover_art::CoverSource::Embedded(p) => format!("embedded: {}", p.display()),
};
// Look up artist name for storage path
let artist_name: String = sqlx::query_scalar(
"SELECT a.name FROM furumusic__artist a \
JOIN furumusic__release_artist ra ON ra.artist_id = a.id \
WHERE ra.release_id = $1 \
ORDER BY ra.position LIMIT 1",
)
.bind(release_id)
.fetch_optional(&ctx.pool)
.await
.ok()
.flatten()
.unwrap_or_else(|| "Unknown Artist".to_string());
match cover_art::save_cover_to_storage(
&ctx.db,
&ctx.pool,
storage_dir,
&artist_name,
release_title,
&cover,
)
.await
{
Ok(cover_file_id) => {
if let Err(e) = cover_art::assign_cover_to_release(
&ctx.pool,
*release_id,
cover_file_id,
)
.await
{
log.warn(&format!(
"Release {release_id} \"{release_title}\": saved cover but failed to assign: {e}"
));
failed += 1;
} else {
log.info(&format!(
"Release {release_id} \"{release_title}\": assigned cover from {source_desc}"
));
assigned += 1;
}
}
Err(e) => {
log.warn(&format!(
"Release {release_id} \"{release_title}\": failed to save cover: {e}"
));
failed += 1;
}
}
}
log.info(&format!(
"Cover backfill complete: {assigned} assigned, {failed} failed, \
{skipped_no_audio} skipped (no audio), {skipped_no_cover} skipped (no cover found)"
));
Ok(())
}
}
+240
View File
@@ -0,0 +1,240 @@
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use sha2::{Digest, Sha256};
use crate::scheduler::{Job, JobContext, JobLog, PendingReview};
/// Guard to prevent overlapping inbox_discover runs.
static DISCOVER_RUNNING: AtomicBool = AtomicBool::new(false);
const AUDIO_EXTENSIONS: &[&str] = &[
"mp3", "flac", "ogg", "opus", "aac", "m4a", "wav", "ape", "wv", "wma", "tta", "aiff", "aif",
];
pub struct InboxDiscoverJob;
#[async_trait::async_trait]
impl Job for InboxDiscoverJob {
fn name(&self) -> &'static str {
"inbox_discover"
}
fn description(&self) -> &'static str {
"Scan inbox for new audio files and queue them for processing"
}
fn default_cron(&self) -> &'static str {
"0 */5 * * * *"
}
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
// Prevent overlapping discover runs
if DISCOVER_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
log.info("Another inbox_discover is already running, skipping");
return Ok(());
}
struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
DISCOVER_RUNNING.store(false, Ordering::SeqCst);
}
}
let _guard = Guard;
let config = &ctx.config;
if config.agent_inbox_dir.is_empty() {
log.info("No inbox directory configured, skipping");
return Ok(());
}
let inbox = Path::new(&config.agent_inbox_dir);
if !inbox.exists() {
log.warn(&format!("Inbox path does not exist: {}", inbox.display()));
return Ok(());
}
let mut audio_files = Vec::new();
collect_audio_files(inbox, &mut audio_files).await?;
log.info(&format!("Found {} audio files in inbox", audio_files.len()));
if audio_files.is_empty() {
return Ok(());
}
let groups = group_by_folder(&audio_files);
log.info(&format!("Grouped into {} folder batches", groups.len()));
let mut discovered = 0u64;
let mut skipped_hash = 0u64;
let mut skipped_existing = 0u64;
for (_folder, files) in &groups {
for file_path in files {
let input_path_str = file_path.to_string_lossy().to_string();
// Skip if a PendingReview already exists for this path
match PendingReview::exists_for_path(&ctx.db, &input_path_str).await {
Ok(true) => {
skipped_existing += 1;
continue;
}
Ok(false) => {}
Err(e) => {
log.warn(&format!("Error checking existing review for {}: {e}", input_path_str));
continue;
}
}
// Compute SHA-256 hash
let path_clone = file_path.to_path_buf();
let (hash, file_size) = match tokio::task::spawn_blocking(move || -> anyhow::Result<(String, i64)> {
let data = std::fs::read(&path_clone)?;
let digest = Sha256::digest(&data);
let hash = format!("{:x}", digest);
let size = data.len() as i64;
Ok((hash, size))
})
.await?
{
Ok(v) => v,
Err(e) => {
log.warn(&format!("Failed to hash {}: {e}", file_path.display()));
continue;
}
};
// Skip if hash already in media_files
if crate::agent::rag::file_hash_exists(&ctx.pool, &hash).await.unwrap_or(false) {
skipped_hash += 1;
continue;
}
// Extract raw metadata
let path_for_meta = file_path.to_path_buf();
let raw_meta = match tokio::task::spawn_blocking(move || {
crate::agent::metadata::extract(&path_for_meta)
})
.await?
{
Ok(m) => m,
Err(e) => {
log.warn(&format!("Failed to extract metadata from {}: {e}", file_path.display()));
continue;
}
};
// Parse path hints
let relative = file_path.strip_prefix(inbox).unwrap_or(file_path);
let hints = crate::agent::path_hints::parse(relative);
// Build context JSON
let context = serde_json::json!({
"sha256": hash,
"file_size": file_size,
"raw_title": raw_meta.title,
"raw_artist": raw_meta.artist,
"raw_album": raw_meta.album,
"raw_track_number": raw_meta.track_number,
"raw_year": raw_meta.year,
"raw_genre": raw_meta.genre,
"duration_secs": raw_meta.duration_secs,
"path_title": hints.title,
"path_artist": hints.artist,
"path_album": hints.album,
"path_year": hints.year,
"path_track_number": hints.track_number,
});
let context_str = serde_json::to_string(&context).unwrap_or_default();
// Create PendingReview with status "queued"
PendingReview::create_queued(
&ctx.db,
ctx.run_id,
"new_file",
Some(&input_path_str),
Some(&context_str),
)
.await
.map_err(|e| anyhow::anyhow!("failed to create queued review: {e}"))?;
discovered += 1;
}
}
log.info(&format!(
"Discovered {} new files, skipped {} (hash known), skipped {} (already queued)",
discovered, skipped_hash, skipped_existing
));
// Trigger inbox_process in background if new files were discovered
// and no orchestrator is already running
if discovered > 0 {
if crate::jobs::inbox_process::is_orchestrator_running() {
log.info("New files discovered but inbox_process already running, it will pick them up");
} else {
log.info("Spawning inbox_process in background...");
let config = ctx.config.clone();
let db = ctx.db.clone();
let pool = ctx.pool.clone();
let registry = ctx.registry.clone();
tokio::spawn(async move {
if let Err(e) = crate::scheduler::trigger_job_now(
&config, &db, &pool, &registry, "inbox_process",
)
.await
{
tracing::error!("Background inbox_process trigger failed: {e}");
}
});
}
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// Helpers (moved from inbox_scan.rs)
// ---------------------------------------------------------------------------
pub fn group_by_folder(files: &[PathBuf]) -> Vec<(PathBuf, Vec<PathBuf>)> {
use std::collections::HashMap;
let mut map: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
for f in files {
let folder = f.parent().unwrap_or(f).to_path_buf();
map.entry(folder).or_default().push(f.clone());
}
let mut groups: Vec<(PathBuf, Vec<PathBuf>)> = map.into_iter().collect();
groups.sort_by(|a, b| a.0.cmp(&b.0));
for (_, files) in &mut groups {
files.sort();
}
groups
}
pub async fn collect_audio_files(
dir: &Path,
audio: &mut Vec<PathBuf>,
) -> anyhow::Result<()> {
let mut entries = tokio::fs::read_dir(dir).await?;
while let Some(entry) = entries.next_entry().await? {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
let ft = entry.file_type().await?;
if ft.is_dir() {
Box::pin(collect_audio_files(&entry.path(), audio)).await?;
} else if ft.is_file() && is_audio_file(&name) {
audio.push(entry.path());
}
}
Ok(())
}
pub fn is_audio_file(name: &str) -> bool {
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
AUDIO_EXTENSIONS.contains(&ext.as_str())
}
+957
View File
@@ -0,0 +1,957 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use cot::db::{Database, Model};
/// Guard to prevent multiple inbox_process orchestrators from running simultaneously.
static ORCHESTRATOR_RUNNING: AtomicBool = AtomicBool::new(false);
/// Well-known advisory lock ID for the inbox_process orchestrator.
/// PostgreSQL advisory locks use a 64-bit key; this is an arbitrary unique value.
const ORCHESTRATOR_ADVISORY_LOCK_ID: i64 = 0x4655_5255_4D55_5349; // "FURUMUSI" in hex
/// Check if an orchestrator is currently running (used by inbox_discover to avoid redundant triggers).
pub fn is_orchestrator_running() -> bool {
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
}
/// Try to acquire the PostgreSQL advisory lock for the orchestrator.
/// Returns true if the lock was acquired (no other orchestrator is running).
async fn try_acquire_orchestrator_lock(pool: &sqlx::PgPool) -> bool {
match sqlx::query_scalar::<_, bool>(
"SELECT pg_try_advisory_lock($1)"
)
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
.fetch_one(pool)
.await
{
Ok(acquired) => acquired,
Err(e) => {
tracing::error!("Failed to acquire advisory lock: {e}");
false
}
}
}
/// Release the PostgreSQL advisory lock for the orchestrator.
async fn release_orchestrator_lock(pool: &sqlx::PgPool) {
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
.execute(pool)
.await;
}
use crate::config::AppConfig;
use crate::music::{
Artist, MediaFile, Release, ReleaseArtist, Track, TrackArtist,
};
use crate::scheduler::{Job, JobContext, JobLog, JobRun, PendingReview, ProcessingStats};
use crate::agent::dto::{FolderContext, NormalizedFields, RawMetadata, PathHints};
use crate::agent::normalize::BatchFileInput;
use crate::agent::mover;
const AUDIO_EXTENSIONS: &[&str] = &[
"mp3", "flac", "ogg", "opus", "aac", "m4a", "wav", "ape", "wv", "wma", "tta", "aiff", "aif",
];
// ---------------------------------------------------------------------------
// InboxProcessJob — orchestrator that runs until ALL queued files are done
// ---------------------------------------------------------------------------
pub struct InboxProcessJob;
#[async_trait::async_trait]
impl Job for InboxProcessJob {
fn name(&self) -> &'static str {
"inbox_process"
}
fn description(&self) -> &'static str {
"Orchestrator: process queued files in folder batches"
}
fn default_cron(&self) -> &'static str {
"30 */5 * * * *"
}
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
// --- Guard 1: AtomicBool (fast in-process check) ---
let prev = ORCHESTRATOR_RUNNING.load(Ordering::SeqCst);
tracing::info!(
previous_value = prev,
"inbox_process: checking ORCHESTRATOR_RUNNING AtomicBool"
);
if ORCHESTRATOR_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
log.info("Another inbox_process orchestrator is already running (AtomicBool), skipping");
return Ok(());
}
struct AtomicGuard;
impl Drop for AtomicGuard {
fn drop(&mut self) {
tracing::info!("inbox_process: releasing ORCHESTRATOR_RUNNING AtomicBool");
ORCHESTRATOR_RUNNING.store(false, Ordering::SeqCst);
}
}
let _atomic_guard = AtomicGuard;
// --- Guard 2: PostgreSQL advisory lock (cross-process/binary safe) ---
if !try_acquire_orchestrator_lock(&ctx.pool).await {
log.info("Another inbox_process orchestrator holds the advisory lock, skipping");
return Ok(());
}
tracing::info!("inbox_process: advisory lock acquired");
let pool_for_unlock = ctx.pool.clone();
struct AdvisoryGuard {
pool: sqlx::PgPool,
}
impl Drop for AdvisoryGuard {
fn drop(&mut self) {
let pool = self.pool.clone();
tokio::spawn(async move {
release_orchestrator_lock(&pool).await;
tracing::info!("inbox_process: advisory lock released");
});
}
}
let _advisory_guard = AdvisoryGuard { pool: pool_for_unlock };
let config = Arc::clone(&ctx.config);
let mut total_ok = 0u64;
let mut total_fail = 0u64;
// Outer loop: re-check for newly queued files after each round
loop {
let queued = PendingReview::list_queued(&ctx.db)
.await
.map_err(|e| anyhow::anyhow!("failed to list queued reviews: {e}"))?;
if queued.is_empty() {
if total_ok == 0 && total_fail == 0 {
log.info("No queued files to process");
} else {
log.info("No more queued files, finishing");
}
break;
}
// Group queued reviews by parent folder
let groups = group_reviews_by_folder(&queued, &config.agent_inbox_dir);
log.info(&format!(
"{} queued file(s) in {} folder batch(es)",
queued.len(),
groups.len(),
));
for (folder_rel, reviews) in groups {
let file_count = reviews.len();
log.info(&format!(
"Folder batch: \"{}\" ({} files)",
folder_rel, file_count,
));
let (ok, fail) = process_folder_batch(
&ctx.db, &config, &ctx.pool, &folder_rel, reviews, log,
).await;
total_ok += ok;
total_fail += fail;
log.info(&format!(
"Folder done: {ok} ok, {fail} err. Total so far: {total_ok} ok, {total_fail} err"
));
}
}
// Cleanup empty dirs
let inbox_path = Path::new(&config.agent_inbox_dir);
if total_ok > 0 && !config.agent_inbox_dir.is_empty() {
cleanup_empty_dirs(inbox_path).await;
}
log.info(&format!(
"Orchestrator finished: {total_ok} succeeded, {total_fail} failed"
));
Ok(())
}
}
// ---------------------------------------------------------------------------
// FileProcessJob — registered for admin UI visibility (no cron, never auto-triggered)
// ---------------------------------------------------------------------------
pub struct FileProcessJob;
#[async_trait::async_trait]
impl Job for FileProcessJob {
fn name(&self) -> &'static str {
"file_process"
}
fn description(&self) -> &'static str {
"Process audio files through LLM (spawned by orchestrator)"
}
fn default_cron(&self) -> &'static str {
"" // no cron — only spawned by the orchestrator
}
async fn run(&self, _ctx: &JobContext, _log: &mut JobLog) -> anyhow::Result<()> {
Ok(())
}
}
// ---------------------------------------------------------------------------
// Prepared file — metadata extracted, ready for LLM
// ---------------------------------------------------------------------------
struct PreparedFile {
review: PendingReview,
filename: String,
raw_meta: RawMetadata,
hints: PathHints,
context: serde_json::Value,
}
// ---------------------------------------------------------------------------
// Group reviews by parent folder
// ---------------------------------------------------------------------------
fn group_reviews_by_folder(
reviews: &[PendingReview],
inbox_dir: &str,
) -> Vec<(String, Vec<PendingReview>)> {
let inbox = Path::new(inbox_dir);
let mut map: HashMap<String, Vec<PendingReview>> = HashMap::new();
for r in reviews {
let path = Path::new(r.input_path_str());
let folder = path.parent().unwrap_or(path);
let rel = folder.strip_prefix(inbox).unwrap_or(folder);
let key = rel.to_string_lossy().to_string();
map.entry(key).or_default().push(r.clone());
}
let mut groups: Vec<(String, Vec<PendingReview>)> = map.into_iter().collect();
groups.sort_by(|a, b| a.0.cmp(&b.0));
// Sort files within each group by path
for (_, reviews) in &mut groups {
reviews.sort_by(|a, b| a.input_path_str().cmp(b.input_path_str()));
}
groups
}
// ---------------------------------------------------------------------------
// Process one folder batch
// ---------------------------------------------------------------------------
async fn process_folder_batch(
db: &Database,
config: &AppConfig,
pool: &sqlx::PgPool,
folder_rel: &str,
reviews: Vec<PendingReview>,
orch_log: &mut JobLog,
) -> (u64, u64) {
let inbox_path = Path::new(&config.agent_inbox_dir);
let file_count = reviews.len();
// Create a single JobRun for the folder batch
let trigger_label = if folder_rel.is_empty() {
format!("batch({})", file_count)
} else {
let short = truncate_path(folder_rel, 20);
format!("{short}({})", file_count)
};
let mut run = match JobRun::create_running(db, "file_process", &trigger_label).await {
Ok(r) => r,
Err(e) => {
orch_log.error(&format!("Failed to create batch JobRun: {e}"));
return (0, file_count as u64);
}
};
let batch_start = std::time::Instant::now();
let mut log = JobLog::with_live_flush(pool.clone(), run.id_val());
log.info(&format!(
"Folder batch: \"{folder_rel}\"{file_count} file(s)"
));
// Phase 1: Prepare all files (extract metadata, parse hints)
log.info("Phase 1: extracting metadata...");
let mut prepared: Vec<PreparedFile> = Vec::with_capacity(file_count);
let mut failed_reviews: Vec<PendingReview> = Vec::new();
for mut review in reviews {
let input_path_str = review.input_path_str().to_owned();
let file_path = Path::new(&input_path_str);
let filename = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_owned();
// Set status → processing
let _ = review.set_processing(db).await;
// Parse context_json
let context: serde_json::Value = review
.context_json
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
// Extract metadata (with 60s timeout)
let path_for_meta = file_path.to_path_buf();
let meta_future = tokio::task::spawn_blocking(move || {
crate::agent::metadata::extract(&path_for_meta)
});
let raw_meta = match tokio::time::timeout(
std::time::Duration::from_secs(60),
meta_future,
).await {
Ok(Ok(Ok(m))) => m,
Ok(Ok(Err(e))) => {
let msg = format!("{filename}: metadata error: {e}");
log.error(&msg);
let _ = review.set_failed(db, &msg).await;
failed_reviews.push(review);
continue;
}
Ok(Err(e)) => {
let msg = format!("{filename}: metadata panic: {e}");
log.error(&msg);
let _ = review.set_failed(db, &msg).await;
failed_reviews.push(review);
continue;
}
Err(_) => {
let msg = format!("{filename}: metadata timeout (60s)");
log.error(&msg);
let _ = review.set_failed(db, &msg).await;
failed_reviews.push(review);
continue;
}
};
// Parse path hints
let relative = file_path.strip_prefix(inbox_path).unwrap_or(file_path);
let hints = crate::agent::path_hints::parse(relative);
prepared.push(PreparedFile {
review,
filename,
raw_meta,
hints,
context,
});
}
log.info(&format!(
"Phase 1 done: {} prepared, {} failed metadata",
prepared.len(),
failed_reviews.len(),
));
if prepared.is_empty() {
let duration_ms = batch_start.elapsed().as_millis() as i64;
let _ = run.set_completed(db, duration_ms, &log.output()).await;
return (0, failed_reviews.len() as u64);
}
// Phase 2: RAG lookup (collect unique artist/album queries from all files)
log.info("Phase 2: RAG lookup...");
let mut artist_queries: Vec<String> = Vec::new();
let mut album_queries: Vec<String> = Vec::new();
for p in &prepared {
let artist_q = p.raw_meta.artist.as_deref()
.or(p.hints.artist.as_deref())
.unwrap_or("")
.to_owned();
if !artist_q.is_empty() && !artist_queries.contains(&artist_q) {
artist_queries.push(artist_q);
}
let album_q = p.raw_meta.album.as_deref()
.or(p.hints.album.as_deref())
.unwrap_or("")
.to_owned();
if !album_q.is_empty() && !album_queries.contains(&album_q) {
album_queries.push(album_q);
}
}
// Lookup all unique artist queries
let mut all_similar_artists = Vec::new();
for q in &artist_queries {
match tokio::time::timeout(
std::time::Duration::from_secs(30),
crate::agent::rag::find_similar_artists(pool, q, 5),
).await {
Ok(Ok(results)) => {
for a in results {
if !all_similar_artists.iter().any(|x: &crate::agent::dto::SimilarArtist| x.id == a.id) {
all_similar_artists.push(a);
}
}
}
Ok(Err(e)) => log.warn(&format!("RAG artist lookup failed for \"{q}\": {e}")),
Err(_) => log.warn(&format!("RAG artist lookup timed out for \"{q}\"")),
}
}
let mut all_similar_releases = Vec::new();
for q in &album_queries {
match tokio::time::timeout(
std::time::Duration::from_secs(30),
crate::agent::rag::find_similar_releases(pool, q, 5),
).await {
Ok(Ok(results)) => {
for r in results {
if !all_similar_releases.iter().any(|x: &crate::agent::dto::SimilarRelease| x.id == r.id) {
all_similar_releases.push(r);
}
}
}
Ok(Err(e)) => log.warn(&format!("RAG release lookup failed for \"{q}\": {e}")),
Err(_) => log.warn(&format!("RAG release lookup timed out for \"{q}\"")),
}
}
log.info(&format!(
"Phase 2 done: {} similar artists, {} similar releases",
all_similar_artists.len(),
all_similar_releases.len(),
));
// Phase 3: Build folder context and call batch LLM
log.info("Phase 3: calling LLM (batch)...");
// Build folder context from the first file's folder
let folder_ctx = {
let first_path = Path::new(prepared[0].review.input_path_str());
let folder = first_path.parent().unwrap_or(first_path);
let mut folder_files: Vec<String> = std::fs::read_dir(folder)
.ok()
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
if AUDIO_EXTENSIONS.contains(&ext.as_str()) {
Some(name)
} else {
None
}
})
.collect()
})
.unwrap_or_default();
folder_files.sort();
let track_count = folder_files.len();
FolderContext {
folder_path: folder_rel.to_owned(),
folder_files,
track_count,
}
};
// Build batch input
let batch_files: Vec<BatchFileInput> = prepared.iter().map(|p| {
BatchFileInput {
filename: p.filename.clone(),
raw: RawMetadata {
title: p.raw_meta.title.clone(),
artist: p.raw_meta.artist.clone(),
album: p.raw_meta.album.clone(),
track_number: p.raw_meta.track_number,
year: p.raw_meta.year,
genre: p.raw_meta.genre.clone(),
duration_secs: p.raw_meta.duration_secs,
},
hints: PathHints {
title: p.hints.title.clone(),
artist: p.hints.artist.clone(),
album: p.hints.album.clone(),
year: p.hints.year,
track_number: p.hints.track_number,
},
}
}).collect();
let system_prompt = include_str!("../../prompts/normalize_batch.txt");
let context_limit = config.agent_context_limit;
let llm_result = crate::agent::normalize::normalize_batch(
&config.agent_llm_url,
&config.agent_llm_model,
&config.agent_llm_auth,
system_prompt,
context_limit,
batch_files,
&all_similar_artists,
&all_similar_releases,
Some(&folder_ctx),
).await;
let batch_result = match llm_result {
Ok(r) => r,
Err(e) => {
let err_msg = format!("Batch LLM call failed: {e}");
log.error(&err_msg);
// Mark all files as failed
for mut p in prepared {
let _ = p.review.set_failed(db, &err_msg).await;
}
let total_fail_count = failed_reviews.len() as u64 + file_count as u64;
let duration_ms = batch_start.elapsed().as_millis() as i64;
let _ = run.set_failed(db, duration_ms, &log.output(), &err_msg).await;
return (0, total_fail_count);
}
};
log.info(&format!(
"Phase 3 done: LLM returned {} results in {}ms (model={}, tokens={}/{})",
batch_result.results.len(),
batch_result.duration_ms,
batch_result.model,
batch_result.prompt_tokens,
batch_result.completion_tokens,
));
// Phase 4: Match results to files and finalize
log.info("Phase 4: finalizing...");
// Build lookup map: filename → NormalizedFields
let result_map: HashMap<String, NormalizedFields> = batch_result.results
.into_iter()
.collect();
let llm_model = &batch_result.model;
let prompt_per_file = batch_result.prompt_tokens / prepared.len().max(1) as u64;
let completion_per_file = batch_result.completion_tokens / prepared.len().max(1) as u64;
let duration_per_file = batch_result.duration_ms as i64 / prepared.len().max(1) as i64;
let mut ok_count = 0u64;
let mut fail_count = failed_reviews.len() as u64;
for mut p in prepared {
let filename = &p.filename;
let normalized = match result_map.get(filename) {
Some(n) => n,
None => {
let msg = format!("LLM returned no result for \"{filename}\"");
log.error(&msg);
let _ = p.review.set_failed(db, &msg).await;
fail_count += 1;
continue;
}
};
// Record processing stats
let _ = ProcessingStats::create(
db,
p.review.id_val(),
llm_model,
duration_per_file,
prompt_per_file as i64,
completion_per_file as i64,
).await;
let result_json = serde_json::to_string(normalized).unwrap_or_default();
let confidence = normalized.confidence.unwrap_or(0.0);
let feat = if normalized.featured_artists.is_empty() {
String::new()
} else {
format!(" feat=[{}]", normalized.featured_artists.join(", "))
};
log.info(&format!(
"{filename}: artist={} | album={} | title={} | track={} | year={} | conf={}{}",
normalized.artist.as_deref().unwrap_or("-"),
normalized.album.as_deref().unwrap_or("-"),
normalized.title.as_deref().unwrap_or("-"),
normalized.track_number.map_or("-".into(), |n| n.to_string()),
normalized.year.map_or("-".into(), |y| y.to_string()),
confidence,
feat,
));
p.review.result_json = Some(result_json);
let _ = p.review.save(db).await;
let input_path_str = p.review.input_path_str().to_owned();
if confidence >= config.agent_confidence_threshold {
match finalize_approved(
db, pool, config, &input_path_str, normalized, &p.context,
&config.agent_storage_dir, Some(llm_model),
).await {
Ok(()) => {
let _ = p.review.set_auto_approved(db).await;
ok_count += 1;
}
Err(e) => {
let msg = format!("{filename}: finalize failed: {e}");
log.error(&msg);
let _ = p.review.set_failed(db, &msg).await;
fail_count += 1;
}
}
} else {
p.review.status = cot::db::LimitedString::new("pending").unwrap();
p.review.updated_at = cot::db::LimitedString::new(
&chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
).unwrap();
let _ = p.review.save(db).await;
log.info(&format!(
"{filename}: manual review (confidence {confidence} < {})",
config.agent_confidence_threshold,
));
ok_count += 1; // Not a failure, just needs review
}
}
let duration_ms = batch_start.elapsed().as_millis() as i64;
if fail_count == 0 {
let _ = run.set_completed(db, duration_ms, &log.output()).await;
} else {
let msg = format!("{fail_count} file(s) failed");
let _ = run.set_failed(db, duration_ms, &log.output(), &msg).await;
}
(ok_count, fail_count)
}
// ---------------------------------------------------------------------------
// Finalization (called on approve or auto-approve)
// ---------------------------------------------------------------------------
pub async fn finalize_approved(
db: &cot::db::Database,
pool: &sqlx::PgPool,
_config: &crate::config::AppConfig,
input_path_str: &str,
normalized: &NormalizedFields,
context: &serde_json::Value,
storage_dir_str: &str,
model_name: Option<&str>,
) -> anyhow::Result<()> {
let artist_name = normalized.artist.as_deref().unwrap_or("Unknown Artist");
let release_title = normalized.album.as_deref().unwrap_or("Unknown Release");
let track_title = normalized.title.as_deref().unwrap_or("Unknown Title");
let release_type = normalized.release_type.as_deref().unwrap_or("album");
let year = normalized.year;
let track_number = normalized.track_number;
let artist = find_or_create_artist(db, artist_name, model_name).await?;
let release = find_or_create_release(db, release_title, release_type, year, model_name).await?;
// Link ReleaseArtist
let existing_links = ReleaseArtist::find_by_release(db, release.id_val())
.await
.unwrap_or_default();
let already_linked = existing_links
.iter()
.any(|l| l.artist_id() == artist.id_val());
if !already_linked {
let position = existing_links.len() as i32;
let mut link = ReleaseArtist {
id: cot::db::Auto::auto(),
release_id: release.id_val(),
artist_id: artist.id_val(),
position,
};
link.insert(db)
.await
.map_err(|e| anyhow::anyhow!("failed to link release-artist: {e}"))?;
}
let sha256 = context
.get("sha256")
.and_then(|v| v.as_str())
.unwrap_or("");
let file_size = context
.get("file_size")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let duration_secs = context
.get("duration_secs")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let source_path = Path::new(input_path_str);
let original_filename = source_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let ext = source_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("flac");
let mime_type = match ext.to_lowercase().as_str() {
"mp3" => "audio/mpeg",
"flac" => "audio/flac",
"ogg" | "opus" => "audio/ogg",
"aac" | "m4a" => "audio/mp4",
"wav" => "audio/wav",
"aiff" | "aif" => "audio/aiff",
_ => "application/octet-stream",
};
let track_num = track_number.unwrap_or(0);
let dest_filename = if track_num > 0 {
format!(
"{:02} - {}.{}",
track_num,
sanitize_filename(track_title),
ext
)
} else {
format!("{}.{}", sanitize_filename(track_title), ext)
};
let storage_dir = Path::new(storage_dir_str);
let storage_path = if source_path.exists() {
match mover::move_to_storage(
storage_dir,
artist_name,
release_title,
&dest_filename,
source_path,
)
.await?
{
mover::MoveOutcome::Moved(p) => p.to_string_lossy().to_string(),
mover::MoveOutcome::Merged(p) => p.to_string_lossy().to_string(),
}
} else {
storage_dir
.join(sanitize_filename(artist_name))
.join(sanitize_filename(release_title))
.join(&dest_filename)
.to_string_lossy()
.to_string()
};
let media_file = MediaFile::create(
db,
"audio",
&storage_path,
original_filename,
mime_type,
file_size,
sha256,
Some(ext),
None,
None,
None,
)
.await
.map_err(|e| anyhow::anyhow!("failed to create media file: {e}"))?;
let track = Track::create(
db,
track_title,
release.id_val(),
track_number,
None,
duration_secs,
media_file.id_val(),
year,
model_name,
)
.await
.map_err(|e| anyhow::anyhow!("failed to create track: {e}"))?;
TrackArtist::create(db, track.id_val(), artist.id_val(), "main", 0)
.await
.map_err(|e| anyhow::anyhow!("failed to link track-artist: {e}"))?;
for (i, feat_name) in normalized.featured_artists.iter().enumerate() {
let feat_artist = find_or_create_artist(db, feat_name, model_name).await?;
let _ = TrackArtist::create(
db,
track.id_val(),
feat_artist.id_val(),
"featuring",
(i + 1) as i32,
)
.await;
}
// Cover art: if the release has no cover yet, try to find one
if release.cover_file_id.is_none() {
let source_folder = Path::new(input_path_str)
.parent()
.unwrap_or(Path::new("."));
// Collect audio files in the same folder to try embedded extraction
let audio_files_in_folder: Vec<std::path::PathBuf> = std::fs::read_dir(source_folder)
.ok()
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name().to_string_lossy().into_owned();
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
AUDIO_EXTENSIONS.contains(&ext.as_str())
})
.map(|e| e.path())
.collect()
})
.unwrap_or_default();
match crate::agent::cover_art::find_best_cover(source_folder, &audio_files_in_folder).await
{
Some(cover) => {
let source_desc = match &cover.source {
crate::agent::cover_art::CoverSource::FolderFile(p) => {
format!("folder file: {}", p.display())
}
crate::agent::cover_art::CoverSource::Embedded(p) => {
format!("embedded in: {}", p.display())
}
};
match crate::agent::cover_art::save_cover_to_storage(
db,
pool,
storage_dir_str,
artist_name,
release_title,
&cover,
)
.await
{
Ok(cover_file_id) => {
let _ = crate::agent::cover_art::assign_cover_to_release(
pool,
release.id_val(),
cover_file_id,
)
.await;
tracing::info!(
release_id = release.id_val(),
cover_file_id,
source = %source_desc,
"Assigned cover art to release"
);
}
Err(e) => {
tracing::warn!(
release_id = release.id_val(),
error = %e,
"Failed to save cover art"
);
}
}
}
None => {
tracing::debug!(
release_id = release.id_val(),
"No cover art found for release"
);
}
}
}
tracing::info!(
track_id = track.id_val(),
artist = artist_name,
release = release_title,
title = track_title,
"Track finalized"
);
Ok(())
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async fn find_or_create_artist(
db: &cot::db::Database,
name: &str,
model_name: Option<&str>,
) -> anyhow::Result<Artist> {
let name_sort = name.trim().to_lowercase();
let all = Artist::list_all(db).await.unwrap_or_default();
for a in &all {
if a.name_sort.as_str() == name_sort {
return Ok(a.clone());
}
}
Artist::create(db, name, model_name)
.await
.map_err(|e| anyhow::anyhow!("failed to create artist: {e}"))
}
async fn find_or_create_release(
db: &cot::db::Database,
title: &str,
release_type: &str,
year: Option<i32>,
model_name: Option<&str>,
) -> anyhow::Result<Release> {
let title_sort = title.trim().to_lowercase();
let all = Release::list_all(db).await.unwrap_or_default();
for r in &all {
if r.title_sort.as_str() == title_sort && r.release_type.as_str() == release_type {
return Ok(r.clone());
}
}
Release::create(db, title, release_type, year, model_name)
.await
.map_err(|e| anyhow::anyhow!("failed to create release: {e}"))
}
async fn cleanup_empty_dirs(dir: &Path) -> bool {
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(e) => e,
Err(_) => return false,
};
let mut is_empty = true;
while let Ok(Some(entry)) = entries.next_entry().await {
let ft = match entry.file_type().await {
Ok(ft) => ft,
Err(_) => {
is_empty = false;
continue;
}
};
if ft.is_dir() {
let child_empty = Box::pin(cleanup_empty_dirs(&entry.path())).await;
if child_empty {
let _ = tokio::fs::remove_dir(&entry.path()).await;
} else {
is_empty = false;
}
} else {
is_empty = false;
}
}
is_empty
}
fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ => c,
})
.collect::<String>()
.trim()
.to_owned()
}
fn truncate_path(path: &str, max_len: usize) -> String {
if path.len() <= max_len {
path.to_owned()
} else {
format!("...{}", &path[path.len() - (max_len - 3)..])
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod artist_image_backfill;
pub mod artist_track_image_backfill;
pub mod cover_backfill;
pub mod inbox_discover;
pub mod inbox_process;
+72 -23
View File
@@ -1,9 +1,14 @@
mod admin;
mod agent;
mod api;
mod auth;
mod config;
mod i18n;
mod jobs;
mod music;
mod oidc;
mod player;
mod scheduler;
mod user;
use std::sync::Arc;
@@ -12,8 +17,8 @@ use cot::auth::PasswordVerificationResult;
use cot::cli::CliMetadata;
use cot::common_types::Password;
use cot::config::{
DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig,
SessionStoreTypeConfig,
DatabaseConfig, MiddlewareConfig, ProjectConfig, SameSite, SessionMiddlewareConfig,
SessionStoreConfig, SessionStoreTypeConfig,
};
use cot::db::Database;
use cot::form::{Form, FormResult};
@@ -31,8 +36,24 @@ use serde::Deserialize;
use crate::config::AppConfig;
use crate::i18n::{I18n, Translations};
use crate::scheduler::{JobRegistry, SchedulerHandle};
use crate::user::User;
// ---------------------------------------------------------------------------
// Build the job registry
// ---------------------------------------------------------------------------
fn build_registry() -> Arc<JobRegistry> {
let mut registry = JobRegistry::new();
registry.register(jobs::inbox_discover::InboxDiscoverJob);
registry.register(jobs::inbox_process::InboxProcessJob);
registry.register(jobs::inbox_process::FileProcessJob);
registry.register(jobs::cover_backfill::CoverBackfillJob);
registry.register(jobs::artist_image_backfill::ArtistImageBackfillJob);
registry.register(jobs::artist_track_image_backfill::ArtistTrackImageBackfillJob);
Arc::new(registry)
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
@@ -42,23 +63,12 @@ async fn index(
db: Database,
i18n: I18n,
) -> cot::Result<cot::response::Response> {
let user = match auth::get_session_user(&session, &db).await {
let _user = match auth::get_session_user(&session, &db).await {
Some(u) => u,
None => return Ok(auth::redirect("/login")),
};
let role_label = match user.role {
auth::Role::Admin => format!(
r#"{} | <a href="/admin/">{}</a>"#,
user.role.code(),
i18n.t.nav_admin
),
_ => user.role.code().to_owned(),
};
Html::new(format!(
"<h1>{}</h1><p>{}</p><p>{}: {}</p>",
i18n.t.index_heading, i18n.t.index_status, user.name, role_label
))
.into_response()
let template = player::PlayerPageTemplate { t: i18n.t };
Html::new(template.render()?).into_response()
}
#[derive(Deserialize)]
@@ -154,7 +164,12 @@ impl App for FuruApp {
get(|| async { Ok::<_, cot::Error>(auth::redirect("/swagger/")) }),
"swagger_redirect",
),
Route::with_handler_and_name("/", index, "index"),
Route::with_handler_and_name("/",
|session: Session, db: Database, i18n: I18n| async move {
index(session, db, i18n).await
},
"index",
),
Route::with_handler_and_name(
"/login",
get({
@@ -236,6 +251,8 @@ impl App for FuruApp {
struct FuruProject {
app_config: Arc<AppConfig>,
registry: Arc<JobRegistry>,
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
}
impl Project for FuruProject {
@@ -289,6 +306,8 @@ impl Project for FuruProject {
MiddlewareConfig::builder()
.session(
SessionMiddlewareConfig::builder()
.secure(false)
.same_site(SameSite::Lax)
.store(
SessionStoreConfig::builder()
.store_type(SessionStoreTypeConfig::Database)
@@ -310,14 +329,30 @@ impl Project for FuruProject {
) -> cot::project::RootHandler {
handler
.middleware(StaticFilesMiddleware::from_context(context))
.middleware(
SessionMiddleware::from_context(context)
.same_site(cot::config::SameSite::Lax),
)
.middleware(SessionMiddleware::from_context(context))
.build()
}
fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) {
// Spawn the scheduler in background — it runs independently of HTTP
// requests. The OnceCell ensures it starts exactly once.
let sched_cell = Arc::clone(&self.scheduler_handle);
let sched_config = Arc::clone(&self.app_config);
let sched_registry = Arc::clone(&self.registry);
tokio::spawn(async move {
let _ = sched_cell
.get_or_init(|| async {
match scheduler::start_scheduler(&sched_config, sched_registry).await {
Ok(handle) => handle,
Err(e) => {
tracing::error!("Failed to start scheduler: {e:#}");
panic!("scheduler failed to start: {e}");
}
}
})
.await;
});
apps.register(cot::session::db::SessionApp::new());
apps.register_with_views(
FuruApp {
@@ -326,10 +361,18 @@ impl Project for FuruProject {
"",
);
apps.register_with_views(
admin::AdminApp::new(Arc::clone(&self.app_config)),
admin::AdminApp::new(
Arc::clone(&self.app_config),
Arc::clone(&self.registry),
Arc::clone(&self.scheduler_handle),
),
"/admin",
);
apps.register_with_views(api::ApiApp, "/api");
apps.register_with_views(
player::PlayerApp::new(Arc::clone(&self.app_config)),
"/api/player",
);
if self.app_config.swagger_enabled {
apps.register_with_views(
cot::openapi::swagger_ui::SwaggerUi::new(),
@@ -362,5 +405,11 @@ fn main() -> impl Project {
tracing::info!("loaded config: {:?}", app_config);
FuruProject { app_config }
let registry = build_registry();
FuruProject {
app_config,
registry,
scheduler_handle: Arc::new(tokio::sync::OnceCell::new()),
}
}
+1443
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -190,6 +190,11 @@ pub async fn oidc_start_handler(
let redirect_url = RedirectUrl::new(redirect_uri_str.clone())
.map_err(|e| cot::Error::internal(format!("bad redirect URI: {e}")))?;
let client = client.set_redirect_uri(redirect_url);
tracing::info!(
redirect_uri = %redirect_uri_str,
oidc_issuer = %config.oidc_issuer,
"OIDC start: building authorization request",
);
// Build PKCE challenge.
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
@@ -206,6 +211,7 @@ pub async fn oidc_start_handler(
.add_scope(Scope::new("profile".to_string()))
.set_pkce_challenge(pkce_challenge)
.url();
tracing::info!(auth_url = %auth_url, "OIDC start: redirecting to provider");
// Store OIDC flow state in the session.
session
+2440
View File
File diff suppressed because it is too large Load Diff
+1321
View File
File diff suppressed because it is too large Load Diff