Smart auto-tagging: suggest folder groups for unfiled channels (3.4)
New autotag.rs classifies each unfiled channel from already-scanned metadata — source platform + median video duration + upload cadence — into a suggested folder group (Music / Shorts / Long-form & Podcasts / Streams & VODs), with a confidence and a human-readable reason. Mid-length YouTube is left unsuggested rather than guessed at; channels already in a folder are skipped. Pure arithmetic over the in-memory library, computed on demand (no background job). Surfaced in both Maintenance views: - web: GET /api/autotag/suggest + POST /api/autotag/apply (create/reuse the named folder and assign), rendered with per-channel checkboxes and an "Apply -> group" button that re-analyzes after applying. - desktop: a section listing each group with a "Move all -> group" button. Apply mirrors post_assign_folder: DB write + in-memory library update + ETag bump. Reuses an existing folder of the same name (case-insensitive). Unit tests cover the classifier; verified the apply/revert round-trip live. ROADMAP 3.4 marked DONE. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
aed577ea2f
commit
ec8cf6f934
7 changed files with 658 additions and 4 deletions
81
src/web.rs
81
src/web.rs
|
|
@ -2112,6 +2112,85 @@ async fn get_maintenance_scan(State(state): State<Arc<WebState>>) -> impl IntoRe
|
|||
Json(report)
|
||||
}
|
||||
|
||||
/// `GET /api/autotag/suggest` — heuristic folder-grouping suggestions for
|
||||
/// unfiled channels (see [`crate::autotag`]). Pure arithmetic over the
|
||||
/// in-memory library, so it's computed on demand rather than as a job.
|
||||
async fn get_autotag_suggest(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||
let lib = state.library.lock_recover();
|
||||
Json(crate::autotag::suggest(&lib))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AutotagChannel {
|
||||
platform: String,
|
||||
handle: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AutotagApplyBody {
|
||||
/// Target folder name; created if it doesn't already exist.
|
||||
group: String,
|
||||
channels: Vec<AutotagChannel>,
|
||||
}
|
||||
|
||||
/// `POST /api/autotag/apply` — accept a suggested group: create the folder
|
||||
/// (or reuse an existing one with the same name) and move the given channels
|
||||
/// into it. Mirrors the move onto the in-memory library + bumps the ETag, the
|
||||
/// same way `post_assign_folder` does for a single channel.
|
||||
async fn post_autotag_apply(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<AutotagApplyBody>,
|
||||
) -> impl IntoResponse {
|
||||
let name = body.group.trim();
|
||||
if name.is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, "empty group name").into_response();
|
||||
}
|
||||
// Reuse a folder of the same name (case-insensitive) if one exists,
|
||||
// otherwise create it.
|
||||
let existing = match state.db.list_folders() {
|
||||
Ok(folders) => folders
|
||||
.iter()
|
||||
.find(|f| f.name.eq_ignore_ascii_case(name))
|
||||
.map(|f| f.id),
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("db: {e}")).into_response(),
|
||||
};
|
||||
let folder_id = match existing {
|
||||
Some(id) => id,
|
||||
None => match state.db.create_folder(name) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("create folder: {e}"))
|
||||
.into_response()
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let mut applied = 0usize;
|
||||
for c in &body.channels {
|
||||
if state
|
||||
.db
|
||||
.set_channel_folder(&c.platform, &c.handle, Some(folder_id))
|
||||
.is_ok()
|
||||
{
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut lib = state.library.lock_recover();
|
||||
for ch in lib.iter_mut() {
|
||||
if body
|
||||
.channels
|
||||
.iter()
|
||||
.any(|c| ch.platform.dir_name() == c.platform && ch.name == c.handle)
|
||||
{
|
||||
ch.folder_id = Some(folder_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
bump_library_version(&state);
|
||||
Json(serde_json::json!({ "folder_id": folder_id, "applied": applied })).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RemoveRequest {
|
||||
paths: Vec<PathBuf>,
|
||||
|
|
@ -3088,6 +3167,8 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
|||
.route("/api/plex/generate", post(post_plex_generate))
|
||||
.route("/api/maintenance/scan", get(get_maintenance_scan))
|
||||
.route("/api/maintenance/remove", post(post_maintenance_remove))
|
||||
.route("/api/autotag/suggest", get(get_autotag_suggest))
|
||||
.route("/api/autotag/apply", post(post_autotag_apply))
|
||||
.route("/api/maintenance/dedup/scan", post(post_dedup_scan))
|
||||
.route("/api/maintenance/dedup/status", get(get_dedup_status))
|
||||
.route("/api/maintenance/repair/:id", post(post_maintenance_repair))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue