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:
parent
2696a51cc8
commit
865ad87b22
4 changed files with 300 additions and 5 deletions
36
src/web.rs
36
src/web.rs
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue