docs(traces): tag the twelve "Done but untraced" requirements, and stop the matrix over-reporting
Twelve requirements were marked Done in docs/requirements.md with zero TRACES anywhere in the tree. The features work — the tags were simply never written — so the matrix over-reported on exactly the requirements a reviewer would most want to verify. Each is now tagged at the code that actually implements it: - JA-006 / JA-009 / JA-013 / JA-014 / JA-015 / JA-018 and IR-022 / IR-024 at their Jellyfin call sites in repository/online.rs (search, get_item's MediaStreams/People fields, Items/Resume, Shows/NextUp, FavoriteItems DELETE, get_person/get_items_by_person), plus the commands that expose them. - UR-006 / IR-006 across the lockscreen spine: JellyTauPlaybackService (the MediaSessionCompat owner), the nativeOnMediaCommand JNI intake, and LockscreenMetadata / update_lockscreen_metadata. - IR-008 at both audio-focus mechanisms — ExoPlayer-managed for audio, the manual AudioFocusRequest listener for video — and at the media-type string that chooses between them. - UR-037 (with DR-042, also untraced) on the video-library poster grid: LibraryGrid, MediaCard, and the tv/movies routes. Resolve contradictory statuses across layers, evidence first: - IR-018/IR-019 were Planned under Done URs because they were scoped to libmpv. MpvBackend is the audio-only backend and overrides neither set_subtitle_track nor set_audio_track — the trait's not_implemented() default still stands — so UR-020/UR-021 are met by ExoPlayer and by the HTML5 <video> path instead. Both IRs are re-scoped to those backends and marked Done; IT-008/IT-009 and the stale @req-planned markers in backend.rs follow. - IR-005 (MPRIS) stays Planned: there is no MPRIS/D-Bus code or dependency in the project and update_lockscreen_metadata is a no-op off Android. UR-006 is corrected to Done (Android) rather than the IR being marked Done. - A note under the IR table records where a UR is met by a different mechanism than its IR anticipated. Define the two dangling IDs the source already referenced: DR-189 (the control bar never auto-hid on a touchscreen, because its timer was armed only from onmousemove) and UT-188 (its rule test). The live-denominator assertion in extract-traces.test.ts moves 187/330 to 188/331 accordingly. Traced requirements 444 to 459; IR coverage 19/32 to 25/32.
This commit is contained in:
@@ -1478,6 +1478,16 @@ pub async fn player_seek_video(
|
||||
|
||||
/// Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
|
||||
/// Note: Frontend should handle saving series preferences after this command succeeds
|
||||
///
|
||||
/// The split is the requirement: an HTML5 `<video>` element cannot be told to
|
||||
/// change audio track, so the stream is re-opened at the chosen
|
||||
/// `AudioStreamIndex` and the frontend seeks the reloaded element back to
|
||||
/// `position`; a native backend (ExoPlayer) switches in place by track-group
|
||||
/// index. libmpv implements neither — it is the audio-only backend here and
|
||||
/// leaves `PlayerBackend::set_audio_track` at its `not_implemented()` default,
|
||||
/// which is why IR-019 is met by these two paths rather than by MPV.
|
||||
///
|
||||
/// TRACES: UR-021 | IR-019, DR-024
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_switch_audio_track(
|
||||
@@ -1650,6 +1660,9 @@ pub async fn player_set_stream_quality(
|
||||
Ok(StreamQualityResponse::Native { position })
|
||||
}
|
||||
|
||||
/// Set the active audio track on a native backend directly.
|
||||
///
|
||||
/// TRACES: UR-021 | IR-019, DR-024
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_set_audio_track(
|
||||
@@ -1663,6 +1676,14 @@ pub async fn player_set_audio_track(
|
||||
Ok(get_player_status(&controller))
|
||||
}
|
||||
|
||||
/// Set (or clear, with `None`) the active subtitle track on a native backend.
|
||||
///
|
||||
/// On Android this indexes ExoPlayer's *text track groups* — i.e. the position
|
||||
/// of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
|
||||
/// index. The HTML5 path never reaches here; it toggles its own `<track>`
|
||||
/// children. libmpv implements neither, leaving the trait default in place.
|
||||
///
|
||||
/// TRACES: UR-020 | IR-018, DR-023
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_set_subtitle_track(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Tauri commands for repository access
|
||||
//! Uses handle-based system: UUID -> Arc<HybridRepository>
|
||||
//!
|
||||
//! TRACES: UR-007, UR-035, UR-036 | JA-004, JA-005, JA-029, JA-030, JA-031
|
||||
//! TRACES: UR-007, UR-008, UR-023, UR-034, UR-035, UR-036 | IR-022, IR-024, JA-004, JA-005, JA-006, JA-029, JA-030, JA-031
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use std::collections::HashMap;
|
||||
@@ -294,7 +294,13 @@ pub async fn repository_get_latest_items(
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get resume items (continue watching/listening)
|
||||
/// Get resume items (continue watching/listening).
|
||||
///
|
||||
/// The home screen's Continue Watching row and every library's "pick up where
|
||||
/// you left off" hero come through here; each item carries its own resume
|
||||
/// position in `UserData`.
|
||||
///
|
||||
/// TRACES: UR-019, UR-023, UR-034 | IR-024, JA-013, JA-015 | DR-026, DR-038
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_resume_items(
|
||||
@@ -318,7 +324,9 @@ pub async fn repository_get_resume_items(
|
||||
})
|
||||
}
|
||||
|
||||
/// Get next up episodes
|
||||
/// Get next up episodes.
|
||||
///
|
||||
/// TRACES: UR-023, UR-034 | IR-024, JA-014 | DR-026
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_next_up_episodes(
|
||||
|
||||
@@ -420,7 +420,18 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
None => JValue::Object(&null_obj),
|
||||
};
|
||||
|
||||
// Determine media type string for JNI
|
||||
// Determine media type string for JNI.
|
||||
//
|
||||
// This is not cosmetic: the string decides *which audio-focus mechanism*
|
||||
// runs on the Kotlin side. `JellyTauPlayer.load()` re-applies
|
||||
// `setAudioAttributes(attrs, handleAudioFocus = mediaType == AUDIO)`, so
|
||||
// "audio" leaves focus to ExoPlayer (request on play, duck on transient
|
||||
// loss, pause on a call) while "video" switches it to the manual
|
||||
// `AudioFocusRequest` path, which needs delayed-focus handling. Either
|
||||
// way the resulting pause comes back through `nativeOnStateChanged`, so
|
||||
// the Rust controller — not the focus listener — stays authoritative.
|
||||
//
|
||||
// TRACES: UR-004, UR-006 | IR-008
|
||||
let media_type_str = match media.media_type {
|
||||
MediaType::Video => "video",
|
||||
MediaType::Audio => "audio",
|
||||
@@ -1096,6 +1107,15 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
///
|
||||
/// Commands from lockscreen controls, notification buttons, and Bluetooth
|
||||
/// devices are routed through here to the Rust PlayerController.
|
||||
///
|
||||
/// This is the inbound half of UR-006: `MediaSessionCompat` is flagged
|
||||
/// `FLAG_HANDLES_MEDIA_BUTTONS`, so an AVRCP play/pause/skip from a headset
|
||||
/// arrives at the service's transport callback and lands here as a command
|
||||
/// string. The player stays authoritative — the session is a consumer that
|
||||
/// *requests*, and the resulting state comes back out through
|
||||
/// [`update_lockscreen_metadata`].
|
||||
///
|
||||
/// TRACES: UR-006 | IR-006
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnMediaCommand(
|
||||
mut env: JNIEnv,
|
||||
@@ -1396,6 +1416,8 @@ use crate::player::LockscreenMetadata;
|
||||
/// running (in remote mode it is started via [`enable_remote_volume`]); if it
|
||||
/// isn't, this is a no-op rather than an error so it can be called freely on
|
||||
/// every poll tick.
|
||||
///
|
||||
/// TRACES: UR-006 | IR-006
|
||||
pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), String> {
|
||||
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
|
||||
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -98,9 +98,12 @@ pub trait PlayerBackend: Send + Sync {
|
||||
|
||||
/// Set the active audio track by stream index
|
||||
///
|
||||
/// @req-planned: UR-021 - Select audio track for video content
|
||||
/// @req-planned: IR-019 - libmpv audio track selection
|
||||
/// @req-planned: DR-024 - Audio track selection UI in video player
|
||||
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
|
||||
/// does **not** override it — MPV is the audio-only backend here, so it keeps
|
||||
/// this `not_implemented()` default and the Linux video path switches track by
|
||||
/// re-opening the stream instead (`player_switch_audio_track`).
|
||||
///
|
||||
/// TRACES: UR-021 | IR-019, DR-024
|
||||
fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError> {
|
||||
// Default implementation does nothing - override in platform-specific backends
|
||||
Err(PlayerError::not_implemented())
|
||||
@@ -108,9 +111,12 @@ pub trait PlayerBackend: Send + Sync {
|
||||
|
||||
/// Set the active subtitle track by stream index (None to disable subtitles)
|
||||
///
|
||||
/// @req-planned: UR-020 - Select subtitles for video content
|
||||
/// @req-planned: IR-018 - libmpv subtitle rendering and selection
|
||||
/// @req-planned: DR-023 - Subtitle selection UI in video player
|
||||
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
|
||||
/// does **not** override it, so it keeps this `not_implemented()` default;
|
||||
/// the Linux video path renders subtitles as `<track>` children of the
|
||||
/// WebKitGTK HTML5 `<video>` element and never calls this.
|
||||
///
|
||||
/// TRACES: UR-020 | IR-018, DR-023
|
||||
fn set_subtitle_track(&mut self, _stream_index: Option<i32>) -> Result<(), PlayerError> {
|
||||
// Default implementation does nothing - override in platform-specific backends
|
||||
Err(PlayerError::not_implemented())
|
||||
|
||||
@@ -140,6 +140,8 @@ const RESUME_BACKOFF_STEP_SECS: u64 = 2;
|
||||
/// the local ExoPlayer is idle and so can't supply now-playing info. The session
|
||||
/// poller fills this in from the remote Jellyfin session and pushes it to the
|
||||
/// notification so the lockscreen stays in sync while casting.
|
||||
///
|
||||
/// TRACES: UR-006 | IR-006
|
||||
#[derive(Debug, Clone)]
|
||||
// Fields are read only by the Android MediaSession bridge; on other platforms
|
||||
// `update_lockscreen_metadata` is a no-op, so they're constructed but unread.
|
||||
@@ -157,6 +159,11 @@ pub struct LockscreenMetadata {
|
||||
|
||||
/// Push now-playing metadata to the Android lockscreen. No-op off Android, so the
|
||||
/// session poller can call it unconditionally and stay platform-agnostic.
|
||||
///
|
||||
/// No-op on Linux specifically because there is no MPRIS/D-Bus publisher — see
|
||||
/// IR-005, which is still Planned.
|
||||
///
|
||||
/// TRACES: UR-006 | IR-006
|
||||
pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), String> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
|
||||
@@ -760,7 +760,11 @@ struct JellyfinItem {
|
||||
/// `UserData` in the `Fields=` list so the shape is explicit rather than
|
||||
/// dependent on the server's default field set.
|
||||
///
|
||||
/// TRACES: UR-069 | DR-113, JA-034 | UT-099
|
||||
/// `PlaybackPositionTicks` is the server's resume position for the item, and the
|
||||
/// only place it is published — Jellyfin has no per-item "resume position"
|
||||
/// endpoint, so reading `UserData` *is* how a resume point is obtained.
|
||||
///
|
||||
/// TRACES: UR-019, UR-069 | DR-113, JA-013, JA-034 | UT-099
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct JellyfinUserData {
|
||||
@@ -854,6 +858,8 @@ fn build_get_items_endpoint(
|
||||
///
|
||||
/// Pulled out of `get_latest_items` so the query can be asserted without an
|
||||
/// HTTP server, matching `build_favorites_endpoint`.
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016
|
||||
fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
|
||||
format!(
|
||||
"/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
@@ -875,7 +881,7 @@ fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usi
|
||||
/// Pulled out of `get_next_up_episodes` so the query can be asserted without an
|
||||
/// HTTP server, matching `build_favorites_endpoint`.
|
||||
///
|
||||
/// TRACES: UR-059 | DR-197, JA-036 | UT-190, UT-191
|
||||
/// TRACES: UR-023, UR-059 | DR-197, JA-014, JA-036 | UT-190, UT-191
|
||||
fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
|
||||
let mut endpoint = format!(
|
||||
"/Shows/NextUp?UserId={}&Limit={}&EnableResumable=false&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
@@ -1129,6 +1135,17 @@ impl MediaRepository for OnlineRepository {
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch one item with every field the detail and player screens need.
|
||||
///
|
||||
/// The `Fields=` list is the load-bearing part: Jellyfin omits these unless
|
||||
/// they are named. `MediaStreams` is what makes the item's **audio and
|
||||
/// subtitle tracks** knowable at all — there is no separate "tracks"
|
||||
/// endpoint, so this single call is how the player learns which audio tracks
|
||||
/// an item offers (`to_media_item` maps them, and the player's selector
|
||||
/// filters them by `kind`). `People` is likewise how **cast and crew** are
|
||||
/// obtained.
|
||||
///
|
||||
/// TRACES: UR-021, UR-035 | IR-016, IR-022, JA-005, JA-009
|
||||
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, item_id);
|
||||
|
||||
@@ -1152,6 +1169,14 @@ impl MediaRepository for OnlineRepository {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Continue Watching: the items this user has started and not finished.
|
||||
///
|
||||
/// `/Users/{uid}/Items/Resume` is the server-side answer to both "what goes
|
||||
/// in the Continue Watching row" and "where was this left off" — each item
|
||||
/// carries its own `UserData.PlaybackPositionTicks`, which is why `UserData`
|
||||
/// is named in `Fields=` rather than left to the server's default field set.
|
||||
///
|
||||
/// TRACES: UR-019, UR-023 | IR-024, JA-013, JA-015
|
||||
async fn get_resume_items(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
@@ -1175,6 +1200,10 @@ impl MediaRepository for OnlineRepository {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// "Next Up": the episode that follows the ones this user has finished,
|
||||
/// per series — the Shows-scoped counterpart to Continue Watching.
|
||||
///
|
||||
/// TRACES: UR-023, UR-059 | IR-024, JA-014
|
||||
async fn get_next_up_episodes(
|
||||
&self,
|
||||
series_id: Option<&str>,
|
||||
@@ -1348,6 +1377,11 @@ impl MediaRepository for OnlineRepository {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Continue Watching, narrowed to movies — the home screen's movie row and
|
||||
/// the movie library's own hero both want the unfinished films without the
|
||||
/// episodes mixed in.
|
||||
///
|
||||
/// TRACES: UR-019, UR-034 | IR-024, JA-013, JA-015
|
||||
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let limit_str = limit.unwrap_or(16);
|
||||
let endpoint = format!(
|
||||
@@ -1423,6 +1457,14 @@ impl MediaRepository for OnlineRepository {
|
||||
Ok(genres)
|
||||
}
|
||||
|
||||
/// Search every library the user can see.
|
||||
///
|
||||
/// `Recursive=true` with no `ParentId` is what makes this cross-library
|
||||
/// rather than folder-scoped; a caller narrowing the search passes the item
|
||||
/// types through `SearchOptions` (already expanded from an opaque
|
||||
/// `SearchScope` on this side of the boundary).
|
||||
///
|
||||
/// TRACES: UR-008 | IR-010, JA-006
|
||||
async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
@@ -2195,6 +2237,12 @@ impl MediaRepository for OnlineRepository {
|
||||
})
|
||||
}
|
||||
|
||||
/// Un-favourite an item: the same `/Users/{uid}/FavoriteItems/{id}` resource
|
||||
/// as [`Self::mark_favorite`], removed rather than posted. Written out by
|
||||
/// hand rather than through `post_json` because it is the one favourite call
|
||||
/// that needs `DELETE`.
|
||||
///
|
||||
/// TRACES: UR-017 | JA-018, DR-021
|
||||
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
@@ -2316,12 +2364,22 @@ impl MediaRepository for OnlineRepository {
|
||||
result
|
||||
}
|
||||
|
||||
/// A single Person item (actor, director, …) by id.
|
||||
///
|
||||
/// Jellyfin models people as ordinary items, so this is the plain item
|
||||
/// endpoint rather than anything under `/Persons`; the cast entries returned
|
||||
/// on an item's `People` field carry the ids this is called with.
|
||||
///
|
||||
/// TRACES: UR-035, UR-036 | IR-022, JA-030
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id);
|
||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||
Ok(item.to_media_item(self.user_id.clone()))
|
||||
}
|
||||
|
||||
/// A person's filmography — every item they are credited on.
|
||||
///
|
||||
/// TRACES: UR-036 | IR-022, JA-031
|
||||
async fn get_items_by_person(
|
||||
&self,
|
||||
person_id: &str,
|
||||
|
||||
Reference in New Issue
Block a user