diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 44b952d..06090d7 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -150,6 +150,7 @@ async fn main() -> anyhow::Result<()> { .route("/metadata/batch/:id/report", get(metadata_batch::get_batch_report)) .route("/metadata/batch/:id/results", get(metadata_batch::get_batch_results)) .route("/metadata/refresh", axum::routing::post(metadata_refresh::start_refresh)) + .route("/metadata/refresh-link/:id", axum::routing::post(metadata_refresh::refresh_single_link)) .route("/metadata/refresh/:id/report", get(metadata_refresh::get_refresh_report)) .route("/reading-status/match", axum::routing::post(reading_status_match::start_match)) .route("/reading-status/match/:id/report", get(reading_status_match::get_match_report)) diff --git a/apps/api/src/metadata_refresh.rs b/apps/api/src/metadata_refresh.rs index 7797a6e..8377db4 100644 --- a/apps/api/src/metadata_refresh.rs +++ b/apps/api/src/metadata_refresh.rs @@ -41,7 +41,7 @@ struct BookDiff { /// Per-series change report #[derive(Serialize, Clone)] -struct SeriesRefreshResult { +pub(crate) struct SeriesRefreshResult { series_name: String, provider: String, status: String, // "updated", "unchanged", "error" @@ -299,6 +299,45 @@ pub async fn get_refresh_report( })) } +// --------------------------------------------------------------------------- +// POST /metadata/refresh-link/:id — Refresh a single metadata link +// --------------------------------------------------------------------------- + +/// Refresh a single approved metadata link by its ID. +pub async fn refresh_single_link( + State(state): State, + AxumPath(link_id): AxumPath, +) -> Result, ApiError> { + let row = sqlx::query( + "SELECT library_id, series_name, provider, external_id, status \ + FROM external_metadata_links WHERE id = $1", + ) + .bind(link_id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| ApiError::not_found("metadata link not found"))?; + + let status: String = row.get("status"); + if status != "approved" { + return Err(ApiError::bad_request("only approved links can be refreshed")); + } + + let library_id: Uuid = row.get("library_id"); + let series_name: String = row.get("series_name"); + let provider: String = row.get("provider"); + let external_id: String = row.get("external_id"); + + match refresh_link(&state.pool, link_id, library_id, &series_name, &provider, &external_id).await { + Ok(result) => { + Ok(Json(serde_json::json!({ + "ok": true, + "status": result.status, + }))) + } + Err(e) => Err(ApiError::internal(format!("refresh failed: {e}"))), + } +} + // --------------------------------------------------------------------------- // Background processing // --------------------------------------------------------------------------- @@ -437,7 +476,7 @@ pub(crate) async fn process_metadata_refresh( } /// Refresh a single approved metadata link: re-fetch from provider, compare, sync, return diff -async fn refresh_link( +pub(crate) async fn refresh_link( pool: &PgPool, link_id: Uuid, library_id: Uuid, diff --git a/apps/api/src/torrent_import.rs b/apps/api/src/torrent_import.rs index a47d64a..4905b37 100644 --- a/apps/api/src/torrent_import.rs +++ b/apps/api/src/torrent_import.rs @@ -6,7 +6,7 @@ use std::time::Duration; use tracing::{info, trace, warn}; use uuid::Uuid; -use crate::{error::ApiError, prowlarr::extract_volumes_from_title_pub, qbittorrent::{load_qbittorrent_config, qbittorrent_login}, state::AppState}; +use crate::{error::ApiError, metadata_refresh, prowlarr::extract_volumes_from_title_pub, qbittorrent::{load_qbittorrent_config, qbittorrent_login}, state::AppState}; // ─── Types ────────────────────────────────────────────────────────────────── @@ -412,20 +412,46 @@ async fn process_torrent_import(pool: PgPool, torrent_id: Uuid) -> anyhow::Resul .await?; // Queue a scan job so the indexer picks up the new files - let job_id = Uuid::new_v4(); + let scan_job_id = Uuid::new_v4(); sqlx::query( "INSERT INTO index_jobs (id, library_id, type, status) VALUES ($1, $2, 'scan', 'pending')", ) - .bind(job_id) + .bind(scan_job_id) .bind(library_id) .execute(&pool) .await?; + // Refresh metadata for this series if it has an approved metadata link + let link_row = sqlx::query( + "SELECT id, provider, external_id FROM external_metadata_links \ + WHERE library_id = $1 AND LOWER(series_name) = LOWER($2) AND status = 'approved' LIMIT 1", + ) + .bind(library_id) + .bind(&series_name) + .fetch_optional(&pool) + .await?; + + if let Some(link) = link_row { + let link_id: Uuid = link.get("id"); + let provider: String = link.get("provider"); + let external_id: String = link.get("external_id"); + let pool2 = pool.clone(); + let sn = series_name.clone(); + tokio::spawn(async move { + let result = metadata_refresh::refresh_link(&pool2, link_id, library_id, &sn, &provider, &external_id).await; + if let Err(e) = result { + warn!("[IMPORT] Metadata refresh for '{}' failed: {}", sn, e); + } else { + info!("[IMPORT] Metadata refresh for '{}' done", sn); + } + }); + } + info!( "Torrent import {} done: {} files imported, scan job {} queued", torrent_id, imported.len(), - job_id + scan_job_id ); } Err(e) => { diff --git a/apps/backoffice/app/api/metadata/refresh-link/[id]/route.ts b/apps/backoffice/app/api/metadata/refresh-link/[id]/route.ts new file mode 100644 index 0000000..dc86331 --- /dev/null +++ b/apps/backoffice/app/api/metadata/refresh-link/[id]/route.ts @@ -0,0 +1,13 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiFetch } from "@/lib/api"; + +export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const data = await apiFetch(`/metadata/refresh-link/${id}`, { method: "POST" }); + return NextResponse.json(data); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to refresh metadata"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/backoffice/app/components/MetadataSearchModal.tsx b/apps/backoffice/app/components/MetadataSearchModal.tsx index 559962e..c8e0ede 100644 --- a/apps/backoffice/app/components/MetadataSearchModal.tsx +++ b/apps/backoffice/app/components/MetadataSearchModal.tsx @@ -55,6 +55,8 @@ export function MetadataSearchModal({ const [missing, setMissing] = useState(initialMissing); const [showMissingList, setShowMissingList] = useState(false); const [syncReport, setSyncReport] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [refreshDone, setRefreshDone] = useState(false); // Provider selector: empty string = library default const [searchProvider, setSearchProvider] = useState(""); @@ -655,6 +657,37 @@ export function MetadataSearchModal({ > {t("metadata.searchAgain")} +