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

@ -1985,6 +1985,9 @@ async fn post_assign_folder(
#[derive(Deserialize)]
struct FlagToggleBody { value: bool }
#[derive(Deserialize)]
struct NoteBody { body: String }
/// `POST /api/videos/:id/flags/:flag` — set or clear a single per-video
/// flag (`bookmark` / `favourite` / `waiting` / `archive`). The watched
/// flag is handled by the separate `POST /api/watched/:id` endpoint for
@ -2018,6 +2021,37 @@ async fn post_video_flag(
(StatusCode::OK, "ok").into_response()
}
/// `GET /api/notes` — return every note as a map of
/// `"<kind>:<id>" → body`. The UI fetches this once on load and keeps it
/// in memory to render note indicators + include note bodies in the
/// global search. Small table, so a single bulk fetch is cheap.
async fn get_notes(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let map = state.db.get_all_notes().unwrap_or_default();
// Flatten the (kind, id) tuple key into "kind:id" for JSON object keys.
let flat: HashMap<String, String> = map
.into_iter()
.map(|((kind, id), body)| (format!("{kind}:{id}"), body))
.collect();
Json(flat).into_response()
}
/// `POST /api/notes/:kind/:id` — upsert a note. An empty body deletes it.
/// `kind` must be `channel` or `video`; `id` is `platform/handle` for
/// channels (URL-encoded `/` becomes `%2F`) or the bare video ID.
async fn post_note(
State(state): State<Arc<WebState>>,
Path((kind, id)): Path<(String, String)>,
Json(body): Json<NoteBody>,
) -> impl IntoResponse {
if kind != "channel" && kind != "video" {
return (StatusCode::BAD_REQUEST, "kind must be channel or video").into_response();
}
if let Err(e) = state.db.set_note(&kind, &id, &body.body) {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
}
(StatusCode::OK, "ok").into_response()
}
/// `GET /api/channels/:platform/:handle/options` — fetch the stored
/// download-option overrides for a single channel. Returns the default
/// (empty) options when nothing is stored.
@ -2365,6 +2399,8 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
.route("/api/download", post(post_download))
.route("/api/watched/:id", post(post_watched))
.route("/api/videos/:id/flags/:flag", post(post_video_flag))
.route("/api/notes", get(get_notes))
.route("/api/notes/:kind/:id", post(post_note))
.route("/api/folders", post(post_create_folder))
.route("/api/folders/:id/rename", post(post_rename_folder))
.route("/api/folders/:id/check", post(post_check_folder))