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:
Luna 2026-06-10 03:38:45 -07:00
parent aed577ea2f
commit ec8cf6f934
7 changed files with 658 additions and 4 deletions

View file

@ -229,6 +229,9 @@ pub struct App {
// Maintenance (library health) screen state. The flag is gone now —
// Screen::Maintenance + this report's presence drive the render.
health_report: Option<crate::maintenance::HealthReport>,
/// Auto-tag grouping suggestions, recomputed when the Maintenance screen
/// opens and after a group is applied. Empty when there's nothing to suggest.
autotag_suggestions: Vec<crate::autotag::GroupSuggestion>,
stats_report: Option<crate::stats::StatsReport>,
// Per-channel download-options dialog state
show_channel_options: bool,
@ -573,6 +576,7 @@ impl App {
backup_open_tx,
backup_open_rx,
health_report: None,
autotag_suggestions: Vec::new(),
stats_report: None,
show_channel_options: false,
channel_options_target: None,
@ -1446,6 +1450,7 @@ impl App {
} else {
self.health_report =
Some(crate::maintenance::scan(&self.library_root, &self.library));
self.autotag_suggestions = crate::autotag::suggest(&self.library);
self.current_screen = Screen::Maintenance;
}
}
@ -2098,6 +2103,8 @@ impl App {
let mut rescan_health = false;
let mut start_dedup = false;
let mut dedup_remove: Vec<PathBuf> = Vec::new();
let mut apply_autotag_group: Option<usize> = None;
let autotag_suggestions = self.autotag_suggestions.clone();
let dedup_groups = self.dedup_groups.clone();
let dedup_running = self.dedup_running;
let dedup_started = self.dedup_started;
@ -2234,9 +2241,39 @@ impl App {
});
}
}
ui.add_space(12.0);
ui.heading("🏷 Auto-tag suggestions");
ui.label(egui::RichText::new(
"Unfiled channels grouped by platform + typical video length. \
Apply a group to move those channels into a folder.").weak().small());
if autotag_suggestions.is_empty() {
ui.label(egui::RichText::new(
"No suggestions — every channel is already filed or too ambiguous to call.")
.weak());
}
for (gi, g) in autotag_suggestions.iter().enumerate() {
ui.group(|ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(
format!("📁 {} ({})", g.group, g.channels.len())).strong());
if ui.button(format!("Move all → {}", g.group)).clicked() {
apply_autotag_group = Some(gi);
}
});
for c in &g.channels {
ui.label(format!(" {} · {} · {}",
c.display_name, c.platform_label, c.reason));
}
});
}
});
});
if let Some(gi) = apply_autotag_group {
self.apply_autotag_group(gi);
}
// ── Apply collected actions ────────────────────────────────────────
let mut changed = false;
if !to_remove.is_empty() {
@ -2279,6 +2316,44 @@ impl App {
}
}
/// Apply one auto-tag suggestion: create (or reuse) a folder with the
/// suggested name and move all of that group's channels into it, mirroring
/// the DB write onto the in-memory library. Recomputes suggestions so the
/// now-filed channels drop out.
fn apply_autotag_group(&mut self, gi: usize) {
let Some(group) = self.autotag_suggestions.get(gi).cloned() else { return };
let existing = self.folders.iter()
.find(|f| f.name.eq_ignore_ascii_case(&group.group))
.map(|f| f.id);
let folder_id = match existing {
Some(id) => id,
None => match self.db.create_folder(&group.group) {
Ok(id) => {
self.folders = self.db.list_folders().unwrap_or_default();
id
}
Err(e) => {
self.status = format!("Create folder failed: {e}");
return;
}
},
};
let mut moved = 0;
for c in &group.channels {
if self.db.set_channel_folder(&c.platform, &c.handle, Some(folder_id)).is_ok() {
for ch in self.library.iter_mut() {
if ch.platform.dir_name() == c.platform && ch.name == c.handle {
ch.folder_id = Some(folder_id);
break;
}
}
moved += 1;
}
}
self.status = format!("Moved {moved} channel(s) → {}", group.group);
self.autotag_suggestions = crate::autotag::suggest(&self.library);
}
fn stats_screen(&mut self, ctx: &egui::Context) {
let report = match &self.stats_report {
Some(r) => r.clone(),