Per-channel / per-video notes (Tartube parity 1.5)

Free-text annotations on any channel or video, searchable from the
global filter box.

## Storage

New `notes` table keyed by (target_kind, target_id):
- target_kind: "channel" or "video"
- target_id: "platform/handle" for channels, video ID for videos
- empty body deletes the row, so the table only holds real notes

DB methods: get_note, set_note (upsert-or-delete), get_all_notes.

## API

- GET /api/notes → { "video:<id>": body, "channel:<plat>/<handle>": body }
  fetched once on page load, held in memory for indicators + search
- POST /api/notes/:kind/:id { body } → upsert/delete

## Web UI

- 📝 button on every video card; turns accent-colored when a note
  exists. Opens an edit-in-place modal.
- 📝 Add/Edit note… sub-action under each channel in the sidebar.
- Global search now also matches note bodies, so you can find a clip
  by what you wrote about it.

## Desktop UI

- Note field in the video detail panel (bottom dock). Loads lazily
  from the DB when the selection changes; persists on focus-loss.

## Restore

Backup import merges the notes table too (keep-later-timestamp,
same as watched/positions). Guarded with a table-exists check so
pre-notes backups still import cleanly. RestoreSummary gains
notes_added; both UIs show it.

4 new tests (2 notes DB behavior, restore merge already covered the
shape). 79 pass total.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-31 07:13:17 -07:00
parent 2696a51cc8
commit 865ad87b22
4 changed files with 300 additions and 5 deletions

View file

@ -101,6 +101,12 @@ pub struct App {
library: Vec<library::Channel>,
sidebar_view: SidebarView,
selected_video: Option<String>,
/// Edit buffer for the selected video's note, plus the id it belongs
/// to. When `note_target` != `selected_video` we (re)load the note
/// from the DB into `note_buffer`. Keeps the textarea editable
/// without a DB round-trip per keystroke.
note_buffer: String,
note_target: Option<String>,
search: String,
downloader: Downloader,
show_downloads: bool,
@ -351,6 +357,8 @@ impl App {
library,
sidebar_view: SidebarView::All,
selected_video: None,
note_buffer: String::new(),
note_target: None,
search: String::new(),
downloader: Downloader::new(channels_root, browser, max_concurrent, use_bundled_ytdlp, use_pot_provider),
show_downloads: false,
@ -2715,6 +2723,35 @@ impl App {
ui.separator();
// ── Note editor ──────────────────────────────────────
// Lazy-load the note from the DB the first time we render
// for a given video. Subsequent frames edit the in-memory
// buffer; we persist on focus-loss / explicit Save.
if self.note_target.as_deref() != Some(selected_id.as_str()) {
self.note_buffer = self.db
.get_note("video", &selected_id)
.ok()
.flatten()
.unwrap_or_default();
self.note_target = Some(selected_id.clone());
}
ui.horizontal(|ui| {
ui.label(egui::RichText::new("📝 Note").small().strong());
let resp = ui.add(
egui::TextEdit::singleline(&mut self.note_buffer)
.desired_width(f32::INFINITY)
.hint_text("Anything you want to remember — searchable"),
);
// Persist when the field loses focus (matches how the
// web UI saves on modal close) so we don't write per
// keystroke.
if resp.lost_focus() {
let _ = self.db.set_note("video", &selected_id, &self.note_buffer);
}
});
ui.separator();
let description = self.description(&video);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
@ -3322,9 +3359,9 @@ impl eframe::App for App {
// Rescan so channel rows pick up new folder assignments.
self.rescan();
self.status = format!(
"Imported: {}W · {}P · {}F · {}flags · {}folders · {}assigns",
"Imported: {}W · {}P · {}F · {}flags · {}folders · {}assigns · {}notes",
s.watched_added, s.positions_added, s.options_added,
s.flags_added, s.folders_added, s.assignments_added,
s.flags_added, s.folders_added, s.assignments_added, s.notes_added,
);
}
Err(e) => self.status = format!("Restore failed: {e}"),