6b7ce512eddbcc3f31e1e9778fd0fa32e702f47f
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6b7ce512ed |
fix(online): percent-encode query values and path ids
build_get_items_endpoint pasted ParentId, IncludeItemTypes, SortBy and SortOrder straight into the query string while the Genres parameter twenty lines below and the SearchTerm parameter both percent-encode theirs. Encode them the same way, per list element so the commas Jellyfin splits on survive. The per-call ids interpolated into request paths (item, person and playlist ids) get the same treatment; a Jellyfin GUID is unchanged by encoding, so this is consistency, not a behaviour change. self.user_id is left alone throughout, as it is at the endpoint builders already. TRACES: UR-007 | DR-212 | UT-206 |
||
|
|
d52470e0cd |
fix(offline): bind item-type filter as query parameters
get_items built its `AND i.item_type IN (…)` fragment by interpolating
each requested type into the SQL string, while `search`, `get_favorites`
and `prune_stale_catalog` in the same file bind the identical filter as
`?` placeholders. Follow the existing pattern so the listing query is
consistent with its neighbours.
The type values bind between the six parent-matching ids and the
favourites user id, matching where `{type_filter}` lands in the
statement.
TRACES: UR-065 | DR-212 | UT-206
|
||
|
|
ae26d5356a |
docs: fix remaining references to the moved build docs
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 27m6s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m26s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 3m6s
Updates the two referrers outside the docs tree that the move left behind, and excludes .claude/ from the link checker — the agent worktrees under it are full checkouts, so it was walking every in-flight branch and reporting their links as ours. |
||
|
|
b025ed05f2 |
docs: repair the traceability matrix, link integrity and site nav
Every file link in docs/traceability.md was broken — all 2,840. The generator emitted repo-root-relative hrefs from a file that lives in docs/, so the artefact the whole TRACES system exists to produce was unnavigable in the repo browser and on the published site alike. Fixed at the generator and covered by a regression test, since the markdown output had no test at all. Also: repairs the remaining broken relative links, adds scripts/check-doc-links.sh so this class of defect fails a build instead of rotting, publishes all 50 docs in the mdBook nav (was 21), moves the root-level build docs under docs/build/ for consistency, retires the stale v0.6.0 audit after confirming every still-open finding survives in the technical-debt table, and records the oversized-module debt. TRACES: | DR-208 | UT-202 |
||
|
|
2de91ae76c |
docs: move the root-level build docs under docs/build/
build-release.md, build-desktop-packages.md and build-windows.md sat at the docs/ root while docker.md and build-builder-image.md were already in docs/build/, so "where do build docs live" had two answers. They now have one. Referrers updated: README.md, docs-site/SUMMARY.md, and the ../ links inside the moved files themselves, which each gained a level of depth — Dockerfile, Dockerfile.arch, packaging/arch/PKGBUILD, CHANGELOG.md, README.md, src-tauri/src/lib.rs and src/lib/services/webviewAudio.ts. Every one of those was caught by check-doc-links.sh rather than by reading, which is the point of having it. Two referrers are left for their owners: CLAUDE.md line 173 and the comment at scripts/build-windows-cross.sh line 11. |
||
|
|
35157a6c59 |
refactor(logging): replace raw console calls with a leveled logger facade
484 ungated console.* calls across 63 frontend files shipped to end users — 248 console.log occurrences were verified present in the built bundle. The Rust half of the app has used the log crate with a LevelFilter and a RUST_LOG override since the beginning; the frontend had no equivalent. Adds src/lib/utils/logger.ts: four levels, scoped loggers replacing the hand-written "[Scope] " prefixes, debug in dev and warn in production, and a localStorage override so a user can turn verbose logging on in a shipped build to file a bug report. warn and error are never gated away. The sweep itself is mechanical — no control flow, error handling, or message semantics changed. TRACES: | DR-204 | UT-201 |
||
|
|
3b55810a0e |
docs: publish every spec and build doc in the mdBook nav
SUMMARY.md drives the mdBook build and mdBook renders only what SUMMARY references, so 31 of the 52 pages in docs/ were being written, reviewed and merged without ever appearing on the published site: 25 of the 26 specs (only video-background-audio was listed), plus build-desktop- packages, build-windows and defect-windows. The specs are grouped into themed sections — playback, library and browsing, downloads and offline, tooling and build — because a flat list of 28 is not navigable, with SPEC-TEMPLATE and SPEC-REVIEW-CHECKLIST kept together as the process docs someone reaches for before writing a spec. |
||
|
|
bf72f9869a |
build(scripts): add a documentation link integrity check (DR-208)
Walks every tracked .md file, resolves each relative inline link against the directory the file lives in, and fails with the file:line and the unresolved target if it is not on disk. Skips http(s)/mailto, pure anchors, and links inside fenced code blocks (a template being shown to the reader is sample text, not a live link). This is the check that would have caught the 2,793 dead links in the generated traceability matrix at the commit that introduced them, and the handful of hand-written ones repaired alongside it. Nobody clicks 2,800 links, which is why the defect survived for months. Two documented exceptions rather than silent ones: docs-site/SUMMARY.md is copied into docs/ by publish-docs before rendering, so its links are resolved from docs/ — which is what makes it catch a nav entry pointing at a page that does not exist; and docs/README.md and docs/api-redirect.md are generated by that same job and so are absent from the repo by design. The header states what it deliberately cannot see, in the house style of check-frontend-boundary.sh: it validates paths, not anchors. Resolving a fragment needs a renderer's heading-slug rules, which differ between Gitea, GitHub and mdBook, so a link to a renamed heading still passes. The package.json script and CI wiring are added separately. |
||
|
|
4567c63797 |
docs: raise the documented traceability gate to 88%
The spec review checklist still asked for >= 50%, the figure the gate sat at before it was found to be unreachable; traceability-ci.md carried 82% throughout. Both now read 88%, matching the ratchet, and the checklist points at `bun run traces:coverage` rather than inviting anyone to trust a number written in a document. Also refreshes the two stale coverage snapshots in traceability-ci.md (~86% from July 2026, and targets of 70% and 90% that the current 90% already passes) and records the 50 -> 82 -> 88 ratchet history. |
||
|
|
46a5219f8e |
docs: repair broken relative links
- traces-quick-ref.md: the four "where to find requirements" links pointed at README.md, but those anchors (#1-user-requirements and friends) live in requirements.md; the "See Also" links were written as if the file sat at the repo root (docs/traceability.md from inside docs/); and the extraction-script link needed ../ to reach scripts/README.md. - release-checklist.md: the release-notes template linked ../../CHANGELOG.md (one level too deep) and ../../issues + ../../discussions, which are GitHub relative-URL idioms. The canonical remote is Gitea, whose release bodies render the template outside any repo path, so these are now absolute gitea.tourolle.paris URLs. Gitea has no discussions, so that link is dropped rather than pointed somewhere it does not exist. - specs/favorites-browsing.md: linked the deleted src/lib/utils/tauriIntegration.test.ts. |
||
|
|
1518d92ef4 |
fix(traces): make generated matrix links resolve from docs/
docs/traceability.md emitted each trace's file link with the repo-root-relative path as the href, but the file is written to docs/ — so every one of the 2,793 links resolved to docs/src-tauri/... or docs/src/... and 404'd, in the Gitea repo browser and on the published mdBook site alike. The matrix is the artefact the whole TRACES system exists to produce, and it was unnavigable. The href now carries a ../ prefix; the visible link text stays repo-root-relative, since that is the path a developer greps for. This survived because the markdown generator had no test at all — the existing suite covers counting, coverage and dangling IDs only. UT-202 now generates a link for a file that really exists, resolves the href against docs/, and asserts the target is on disk; it fails against the old output. Watched red before the fix, per the red-green rule. The live-requirements counts move with the rows added in the previous commit: UR 75 -> 76, DR 194 -> 200, total 337 -> 344. |
||
|
|
662cb3cd85 |
docs(requirements): add new IDs, retire the v0.6.0 audit, record module size
Adds the requirement rows other work in flight needs so `traces:validate`
stays green: DR-204 (frontend logging facade), DR-205 (ESLint + Prettier
gate), DR-206 (pinned Rust toolchain), DR-207 (pre-commit hook), DR-208
(documentation link integrity), DR-209 (server-side library folder
exclusion) and UR-076, plus §4 test rows UT-201, UT-202 and UT-203.
Deletes docs/codebase-audit.md. It was a 2026-08-16 snapshot of v0.6.0 at
commit
|
||
|
|
d54d8cc7c4 |
refactor(logging): route frontend console calls through the logger
TRACES: | DR-204 484 ungated `console.*` calls across 63 non-test frontend files shipped to end users with no way to turn them off. Mechanical substitution, no control flow, error handling or message semantics changed: console.log / console.debug -> log.debug console.info -> log.info console.warn -> log.warn console.error -> log.error Hand-written `"[Scope] …"` prefixes are dropped where the logger's scope now carries them; scope names that already existed are preserved verbatim (`[Auth]`, `[VideoPlayer]`, `[PiP]`, …) and inferred from the filename where a file had none. `src/routes/player/[id]/+page.svelte` keeps its `NextEpisode` and `AutoPlay` sub-scopes as separate loggers rather than flattening them into the page scope. `grep -rn 'console\.' src/` now matches nothing outside the tests and the facade itself. |
||
|
|
4c82a0a025 |
feat(logging): add leveled logger facade
TRACES: | DR-204 | UT-201
The Rust half of the app logs through the `log` crate behind `env_logger`,
with `LevelFilter::Info` by default and `RUST_LOG` to turn the volume up
without a rebuild. The frontend had no equivalent at all: every
`console.log` written during development shipped to end users.
`createLogger(scope)` gives the frontend the same shape:
- four levels (debug/info/warn/error), gated by severity;
- verbose in dev, `warn` in production — warn and error are never gated
away, because a silent failure in a networked media client is worse to
support than a noisy console;
- `localStorage["jellytau:logLevel"]`, read once at init, as the
`RUST_LOG` equivalent so a user can gather verbose logs for a bug
report without a rebuild. Guarded for SSR and for webviews where
storage access throws;
- the scope replaces the hand-written `"[Scope] …"` prefixes;
- a thin pass-through: arguments reach `console.*` untouched and by
reference, and `console` is resolved at call time so devtools
overrides and test spies still see everything.
|
||
|
|
51d914777a |
ci: fix cache-key collisions and skip duplicate release-commit test run
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 28m26s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 2m41s
The test job and build-linux shared one cargo cache key; the test job's debug artifacts claimed it first and actions/cache skips saving on an exact-key hit, so Linux release builds compiled cold every time (~31min vs ~9min for the correctly-keyed Windows job). Same collision between android-check and build-android. Give the release jobs their own keys. Also skip build-and-test.yml for chore(release) commits: the tag push triggers build-release.yml on the same commit, which runs the identical test suite, and the two ~1h workflows contended for the single runner slot. |
||
|
|
61df2730bc |
chore(release): 0.8.2
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 24m47s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m40s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
Build & Release / Run Tests (push) Successful in 25m19s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 11m18s
Build & Release / Build Linux (push) Successful in 30m51s
Build & Release / Build Windows (push) Successful in 14m55s
Build & Release / Build Android (push) Successful in 32m54s
Build & Release / Create Release (push) Successful in 20s
|
||
|
|
c18d79c656 |
fix(android): stop background audio rewinding to where it started
A video handed off to background audio (UR-040) streams a live mp3 transcode
over plain HTTP. That response is chunked, so there is no Content-Length, and a
live encode carries no Xing header, so the extractor establishes no duration —
on device every position tick reads "<position> / 0.0".
ProgressiveMediaPeriod.configureRetry resumes a failed load in place only when
the content length is known or the seek map has a duration. With neither it
assumes the source is live, sets pendingDeferredRetry, and when the sample
queues next run dry resets them and re-requests the URL from offset 0. Our URL
carries StartTimeTicks = the handoff point, so "offset 0" is where audio-only
mode began: a transient load error armed a retry that fired minutes later, when
the buffer finally drained, and playback resumed at the handoff point and ran
on from there. A successful retry raises no error and ends nothing, so neither
arm of DR-129 was consulted and no discontinuity handler existed — the only
trace was a position that went backwards, which is why it read as random, and
why the two earlier fixes for the same symptom (DR-129's phantom end, DR-159's
relative-timeline leak) left it standing.
A retry that can only restart the stream is worth less than no retry at all.
player_retry_restarts_stream marks a Remote audio-only video item,
loadWithMetadata carries the answer to Kotlin, and the pure StreamRetryDecision
holds it for a DefaultLoadErrorHandlingPolicy that returns C.TIME_UNSET —
making onLoadError answer DONT_RETRY_FATAL before it reaches configureRetry.
The rewind becomes a recoverable error, which recoverable_error_resume already
answers by re-opening at the position playback reached, StartTimeTicks
rewritten so the selected audio track survives. Every other source keeps the
player's retry: a static file and an HLS playlist declare their timeline and
are resumed where the load stopped. onPositionDiscontinuity is added for its
log line alone, loud for DISCONTINUITY_REASON_INTERNAL, which is the rewind's
own signature.
Verified on device (FP5), same procedure both runs — handoff, 60s to fill the
buffer, a 45s radio outage:
before 13:54:52 BUFFERING, then "Media ready! Duration: -9.22e15"
(C.TIME_UNSET) and position 1165.4s -> 840.349s, exactly the handoff
base, 3.5 minutes after the outage with nothing logged between
after 14:05:08 "declining the player's retry", playback undisturbed off the
buffer for 69s (a fatal load error is only raised when the renderer
next needs data), then ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ->
re-opening at 785.6s -> READY, and no rewind in the following 7 min
Kotlin tests run with ./gradlew :app:testUniversalDebugUnitTest.
TRACES: UR-040, UR-004 | DR-203 | UT-200
|
||
|
|
69c2498cf7 |
docs(traceability): record DR-202 device verification
FP5, native ExoPlayer path: the hold follows IS PLAYING CHANGED within 17 ms, dumpsys shows fl=KEEP_SCREEN_ON on the window, and a pause/resume round-trip releases and re-takes it. The webview <video> path is still unverified. |
||
|
|
73dd0ef68b |
chore(release): 0.8.1
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 17m3s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Failing after 16m26s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
Patch: one Android fix — the display no longer sleeps mid-video. |
||
|
|
caebf2d139 |
fix(android): keep the display awake while video plays
Android counts its display timeout from the last user input, and watching
something is exactly the case where there is none — so the screen dimmed and
slept mid-playback unless the user kept tapping it.
Nothing held it. FLAG_KEEP_SCREEN_ON appeared nowhere in the app, and neither
renderer supplies a hold for free: ExoPlayer's setWakeMode is a CPU/wifi wake
lock that says nothing about the display, and it draws into the TextureView we
own (DR-192) rather than media3's PlayerView, which is the widget that would
otherwise set keepScreenOn itself; the webview <video> path is no better,
because the display wake lock Chrome takes for video lives in the browser layer
and not in an embedded WebView.
ScreenWakeManager toggles FLAG_KEEP_SCREEN_ON on the Activity window — window
scoped, so it stops applying the moment the app is not visible and cannot
outlive a crash the way an acquired PowerManager.WakeLock can, and it needs no
permission. The two rendering paths are independent holders OR-ed in the pure
ScreenWakeState: the native path follows onIsPlayingChanged plus surface
teardown, so the hold tracks what ExoPlayer reports rather than what the UI
intends, and the webview path reuses the setHtml5VideoState report the frontend
already sends for PiP. Audio is deliberately not a holder — screen-off music is
the point of that path.
Also the repo's first Kotlin JVM unit tests: ScreenWakeState is framework-free,
so the decision is testable off-device with
./gradlew :app:testUniversalDebugUnitTest
(note the variant — plain testDebugUnitTest is ambiguous here). sync-android
-sources.sh mirrors src/test into the gen tree alongside the main sources.
TRACES: UR-003, UR-004 | DR-202 | UT-199
|
||
|
|
d5d0e35bca |
docs(debt): close the R8 release-APK validation item
Validated on device. It was the last item gating confidence in v0.8.0 itself: R8 stripping JNI-loaded classes has broken release builds here before, and this release added a new Kotlin path the unminified debug pass did not exercise. Recorded as closed rather than deleted, so it is not re-raised. |
||
|
|
a1cb142df4 |
docs(debt): record the 12 open items from the codebase audit
Findings not addressed in v0.8.0, plus items the device-verification pass turned up, ordered by what would hurt most if left. Highest are the Android 16 Local Network Protections exposure (LAN Jellyfin access is the app's core function and enforcement is coming) and the traceability extractor's blindness to the Kotlin tree, which means the 90% figure excludes a whole platform. |
||
|
|
2c52077b1d |
chore(release): 0.8.0
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 25m14s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m47s
Traceability Validation / Check Requirement Traces (push) Successful in 36s
Build & Release / Run Tests (push) Successful in 26m3s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 11m2s
Build & Release / Build Linux (push) Successful in 32m23s
Build & Release / Build Windows (push) Successful in 14m59s
Build & Release / Build Android (push) Successful in 31m22s
Build & Release / Create Release (push) Successful in 31s
Minor rather than patch: three user-visible behaviour changes — cloud/D2D backup disabled, the Android TV launcher entry withdrawn, and lockscreen skip scrubbing rather than advancing during background audio. |
||
|
|
6dfc6b259a |
fix(player): lockscreen skip scrubs instead of advancing in background audio
onSkipToNext/onSkipToPrevious forwarded a bare next/previous to Rust, which always advanced the queue. Correct for music, wrong for a video whose audio is running through a background-audio handoff (UR-040): pressing skip to re-hear a line jumped to the next episode instead of scrubbing. resolve_skip_action in player/seek.rs maps the command to Advance or SeekTo, and is_background_audio_active() is the whole test — the handoff exists only for video, and an episode played through it reports MediaType::Audio, so media type cannot distinguish the case. Forward 30s, back 10s, both clamped to [0, duration] so a skip near either end cannot seek negative or read as EOF and advance. Routed through the same spawn-then-seek_absolute path as the scrubber, because a handoff seek re-opens the stream and must not run under the blocking lock (DR-159). Kotlin keeps sending the opaque command; it only gains FAST_FORWARD/ REWIND in the PlaybackStateCompat so the system stops drawing skip arrows for a control that scrubs. The remote-volume action block is deliberately untouched: the handoff never applies to cast sessions, where skip really does mean advance. Tests written first and watched fail (left: Advance, right: SeekTo). 706 Rust tests pass, clippy 0, coverage 90%. |
||
|
|
42e7d86ec4 |
docs(audit): record device-verification results and the asset-protocol finding
Device pass on HONOR ROD2-W09 (Android 16 / SDK 36) confirms B2, B4, B5, B7 and finds no CSP violations across a full browsing session. C2 was aimed at the wrong thing: the asset protocol is not narrowly used but entirely unused. getCachedImageUrl has no production callers, images arrive as base64 data URIs from Rust via imageGetUrl, and the device saw zero asset.localhost requests. Both protocol-asset and the CSP's img-src http:/https: grant can likely be dropped. |
||
|
|
4e451bb534 |
chore(bindings): regenerate specta output for the new TRACES doc comments
tauri-specta propagates Rust doc comments into bindings.ts as JSDoc, so adding TRACES comments to command functions changes generated output. Regeneration happens at build time, so this was left dirty by the branch that added them. Doc-comment-only: no signature or exported-symbol changes. Also records the audit corrections made during device verification (B1 mechanism, B7 re-framing, B8, D3 magnitude). |
||
|
|
889289286b |
merge: clear the clippy backlog and unify lock helpers (D1-warnings, D3)
51 clippy warnings -> 0, with 8 justified #[allow]s (IPC arity, specta wire types, and the 9 test-only await-holding-lock sites). 27 raw lock calls moved to the poison-tolerant helpers - all of them test code; production was already clean. Caught a non-neutral clippy --fix: removing the redundant 'use hostname;' in credentials.rs orphaned its #[cfg(target_os = "linux")] onto SERVICE_NAME, which would have cfg'd the constant out of every non-Linux build. Compiles clean on Linux, so only Windows/macOS CI would have caught it. |
||
|
|
8500da1a42 |
chore(rust): clear the clippy backlog and finish the poison-tolerant lock sweep
`cargo clippy --all-targets` went from 51 warnings (23 in the lib) to zero. Most were mechanical — needless borrows, `assert_eq!` against a bool literal, `vec!` where an array does, `or_insert_with(Vec::new)`, a loop index used only to index — and were applied with `clippy --fix`, then reviewed line by line. That review caught one auto-fix that was *not* semantically neutral: dropping the redundant `use hostname;` left its `#[cfg(target_os = "linux")]` orphaned directly above `SERVICE_NAME`, which would have silently cfg'd the constant out of every non-Linux build. Removed the stray attribute with the import. Where a lint asked for a risky change rather than a better one, it is suppressed with a comment saying why: - `too_many_arguments` on five `#[tauri::command]` handlers and `ThumbnailCache::save_thumbnail` — most of the arity is `State<'_, _>` injection, and a parameter struct would change the IPC contract and the generated TypeScript for no readability gain. - `large_enum_variant` on `PlayerStatusEvent` and `AutoplayDecision` — both are serde + specta wire types emitted a handful of times a second, never bulk allocated; boxing would have to stay invisible to the generated bindings while every match arm gained a deref. - `await_holding_lock` on the `hybrid`/`offline` test modules — the guard is a test-only serialisation lock for the process-global `INCLUDE_CATALOG_BROWSE` flag, and the await it spans *is* the critical section. Each `#[tokio::test]` gets its own single-threaded runtime, so this is not the production deadlock class the lint targets; restructuring would reintroduce the flag race. Real fixes elsewhere: `JellyfinItem::to_media_item` takes `self` by value, so it is now `into_media_item`; the five-tuple episode row in the download commands has a named `EpisodeRow` alias; the mpv `PropertyChange` arm matches `name: "pause"` instead of guarding on it. Also converted the last 27 raw `.lock().unwrap()` call sites to `lock_safe()`, completing the `MutexSafe`/`RwLockSafe` convention. All of them turned out to be in test modules — production code was already clean — so this is consistency rather than a fix. The two raw locks in `utils/lock.rs` stay raw on purpose: those tests deliberately poison a mutex to prove the helpers recover from it. Pure refactoring: all 698 tests still pass. |
||
|
|
88e15e3e12 |
merge: Android runtime security (B1, B3)
Correct the POST_NOTIFICATIONS mechanism: the lockscreen notification is exempt because of the MediaSession token, not because it belongs to a foreground service — FGS notifications are explicitly NOT exempt. So no permission prompt and no checkSelfPermission gate; instead both notification builders bind the token once and log loudly if it is ever null, turning a silent failure into a logcat line. Stop the webview undoing the network security config: mixedContentMode COMPATIBILITY, allowFileAccess/allowContentAccess false. Conflict resolution: this branch's DR-198 collided with the Tauri branch's, so it was renumbered DR-200 (3 TRACES in JellyTauPlaybackService.kt and the UR-006 matrix row updated). DR-199 was uncontested. Pinned counts summed to DR 191 / total 334; UR-071 takes both DR-198 and DR-199. |
||
|
|
c9f33ae6a4 |
merge: restrictive CSP and narrowed asset scope (C1, C2)
Set a CSP with script-src 'self' (Tauri nonces the one inline bootstrap script), object-src/frame-src 'none', and necessarily-permissive img/media/connect for the user-supplied Jellyfin origin. Narrow assetProtocol $APPDATA/** -> thumbnails/**, which is convertFileSrc's only remaining caller. Conflict resolution: scripts/extract-traces.test.ts pinned counts summed rather than side-picked — DR-189 and DR-198 were added independently on two branches, so DR 187 -> 189 and total 330 -> 332. docs/traceability.md regenerated. |
||
|
|
a93cee9241 |
merge: stop backing up credentials no key can ever open (B2, B4, B5)
allowBackup=false plus data_extraction_rules covering device-transfer, not just cloud-backup; treat an undecryptable credential blob as a logout rather than a hard error; drop the half-declared leanback/TV entries; jvmTarget 1.8 -> 17. |
||
|
|
4996727ca9 |
merge: enforce CI gates the contributor rules already required (D1, A3, A4, D2)
Add cargo fmt --check (strict) and cargo clippy (advisory) to CI, ratchet the traceability threshold 50 -> 82, add a dangling-ID gate, and fix the offlineCatalog flake (cold dynamic import, not a timer). |
||
|
|
e3cdb12967 |
merge: traceability matrix repair (A1, A2, A4)
Tag the twelve Done-but-untraced requirements, re-scope the stale libmpv IRs against the backends that actually deliver them, and define the two dangling IDs (DR-189, UT-188). Coverage 285/330 (86%) -> 301/331 (91%); IR 19/32 -> 25/32. |
||
|
|
2d21f092d5 |
fix(android): stop the webview undoing the network security config
MainActivity set mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW together with allowFileAccess/allowContentAccess = true, which is a blanket cleartext opt-in reached by hand — the exact thing network_security_config.xml exists to prevent and its own comment warns against. Nothing needed any of the three: - file:// is never loaded. Cached thumbnails go through convertFileSrc, which on Android resolves to http://asset.localhost/... and is answered by wry's request interceptor rather than the filesystem; downloaded media goes over the loopback HTTP server (DR-137), which exists precisely because the asset/file route cannot stream a large file. - content:// is never loaded. The manifest's FileProvider is for outbound share intents, not webview navigation. - Mixed content never arises. Tauri serves the UI from http://tauri.localhost (use_https_scheme defaults false and is not set), and both 127.0.0.1 and asset.localhost are loopback/.localhost origins Chromium treats as potentially trustworthy. A plain-HTTP remote server would be mixed content, but the network security config already rejects it first — so ALWAYS_ALLOW bought nothing. COMPATIBILITY_MODE rather than NEVER_ALLOW is a deliberate hedge: the platform default at targetSdk 21+ is NEVER_ALLOW, so this is still one step looser, and it keeps passive content working if the analysis missed a path. The two files now cross-reference each other so the pair cannot drift apart again. Also records why POST_NOTIFICATIONS is declared but never requested. An audit read the missing runtime request as a threat to the lockscreen controls; it is not. A foreground-service notification is explicitly NOT exempt, but a media-session one is, and the platform predicate (Notification.isMediaNotification) requires MediaStyle AND a non-null session token. Confirmed on device: appops POST_NOTIFICATION: ignore with the transport notification live. So no permission prompt is added and startForeground stays ungated — a guard there would trade a cosmetic problem for the "did not then call Service.startForeground()" kill. What is added is the guard matching the real precondition: both builders bind the token once and log an error if it is ever null, since SystemUI's media carousel is gated on the same predicate and a token-less notification loses the lockscreen controls entirely, silently. TRACES: UR-006, UR-071 | DR-198, DR-199 |
||
|
|
ebf9a99b80 |
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. |
||
|
|
38dd1129e5 |
feat(security): set a restrictive CSP and scope the asset protocol to thumbnails
`app.security.csp` was `null`, so the webview ran with no Content-Security-Policy
at all: any script that reached the web layer would have inherited the whole IPC
surface. There is no known injection path today (one app-owned `{@html}`, no
`innerHTML`/`eval`), so this is defence in depth rather than a fix for an open
hole.
`script-src 'self'` is the restrictive half — Tauri nonces SvelteKit's inline
bootstrap script at build time, so no `'unsafe-inline'` is needed — together with
`object-src`/`frame-src 'none'` and `base-uri 'self'`. `img-src`/`media-src`/
`connect-src` cannot be restrictive: the Jellyfin origin is typed in by the user
at run time and is routinely plain http on a LAN, so they allow `http:`/`https:`.
That is a wide grant for data, but it still bars `file:`/`filesystem:` and does
not touch script execution. A run-time policy naming the server exactly was
rejected: Tauri derives the header from immutable config when it serves the HTML,
so it would mean rebuilding config and reloading the webview on every server
change. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"`
attributes into markup; `worker-src`/`media-src` keep `blob:` for hls.js's
demuxer worker and its MSE object URL; `ipc:`/`http://ipc.localhost` keeps
`invoke` working. `devCsp` mirrors it with the eval/inline/websocket allowances
Vite's dev server needs.
The asset-protocol scope narrows from `$APPDATA/**` — the storage root holding
the SQLite database and the encrypted-token fallback file — to
`$APPDATA/thumbnails/**`. Since DR-137 moved downloaded media to the loopback
media server, `imageCache` is the only `convertFileSrc` caller left.
Needs manual verification on both platforms: thumbnails, online HLS video and
offline downloaded video cannot be exercised headlessly.
|
||
|
|
4c9361d020 |
fix(android): stop backing up credentials no key can ever open
The app's data dir was eligible for Google cloud backup: the manifest set neither allowBackup nor any extraction rules, so the SQLite catalogue (library metadata, watch history) and the jellytau_secure_prefs credential blob were shipped to the user's Google account. Restoring that is worse than not having it — SecureStorage encrypts under an Android Keystore key, and Keystore keys are never backed up, so a restored install gets ciphertext with nothing to open it and fails auth silently while looking signed in. Backup and device-to-device transfer are both turned off. allowBackup ="false" covers API 24-30 outright and kills cloud backup on 31+; it does NOT stop D2D there, so @xml/data_extraction_rules excludes every domain from both channels. Nothing is lost: the catalogue is a rebuildable mirror of the Jellyfin server, and watch state lives on the server. The credential-load path degrades instead of erroring, because a device can still arrive at undecryptable ciphertext (an older install's backup, a Keystore key invalidated by a lockscreen change). Both backends now distinguish "nothing stored" from "stored but unreadable" and answer the second as the first: CredentialStore::load_credentials_file logs and returns an empty map rather than CredentialError::Encryption — which storage_get_access_token was turning into a hard Err and storage_get_active_session into a warning — and SecureStorage.getCredential discards the dead blob so it cannot fail every subsequent read. The result is a login screen rather than a broken session, and the next successful sign-in rewrites the store. Also removes the half-declared Android TV support: the manifest offered LEANBACK_LAUNCHER and the leanback uses-feature with no D-pad focus model, no TV layouts, and neither of the two declarations Play's TV validation also requires (touchscreen required="false", android:banner). That fails review while advertising the app to TV launchers. All four go back together when a focus pass is actually done. And raises jvmTarget from 1.8 to 17 under compileSdk 36, with matching compileOptions — AGP 8.11 already requires a JDK 17 toolchain, so 1.8 was only capping emitted bytecode. Nothing else in the build assumed 1.8. TRACES: UR-012 | IR-014 |
||
|
|
b9dab56379 |
ci: enforce the checks the contributor rules already required
Four gates that were documented but unenforced, plus the flaky test that made a full-suite run untrustworthy. Rust lint/format: CLAUDE.md has required `cargo fmt` and `cargo clippy` before every commit for as long as the rule existed, yet neither ran anywhere in CI — the requirement rested on memory alone. Both now run in build-and-test.yml and build-release.yml. rustfmt and clippy are already baked into the builder image, so nothing is installed at job time. `cargo fmt --all -- --check` is strict immediately (the tree is clean). Clippy is advisory for now: ~51 pre-existing warnings mean `-D warnings` would fail on unrelated work, so the step carries a TODO to flip the flag once the backlog clears. A compile error still fails it, so it is not a no-op. Traceability threshold: MIN_THRESHOLD sat at 50 while real coverage was 86%, so nearly half the matrix could rot before the gate objected. Ratcheted to 82 with the policy written down — it only ever goes up, and is never lowered to make a red build pass. The same figure lives in MIN_COVERAGE_PERCENT so `traces:coverage` gates locally on the same bar, and a test fails if the two drift. Dangling IDs: a TRACES comment could name any well-formed ID and the extractor accepted it silently, so typos and renames that missed a call site passed unnoticed. `bun run traces:validate` cross-checks every traced ID against the table rows in requirements.md and fails with the referencing files listed. It spans UT/IT as well, which the coverage orphan list ignores by design. This currently reports DR-189 and UT-188, which are being defined separately. Flaky offlineCatalog test: the first dynamic import of the service paid ~1s to transform its dependency graph, charged to a test body against vitest's 5s default. Alone it passed; under suite-wide contention it timed out. The import is now warmed at collection time, so no test is timing the compiler — the timeout is deliberately unchanged. The store shim also drops subscribers from module instances discarded by resetModules, which previously leaked across tests. |
||
|
|
73641e192c |
chore(release): 0.7.0
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 21m50s
Traceability Validation / Check Requirement Traces (push) Successful in 44s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m50s
Build & Release / Run Tests (push) Successful in 18m46s
Build & Release / Build Linux (push) Successful in 30m52s
Build & Release / Build Windows (push) Successful in 15m13s
Build & Release / Build Android (push) Successful in 31m53s
Build & Release / Create Release (push) Successful in 12s
Version bumped across package.json, tauri.conf.json and Cargo.toml (+ lock), CHANGELOG entry written from the five commits in the range rather than from the trace extractor's output — VideoPlayer.svelte alone carries dozens of TRACES, so the generated draft named most of the app's requirements for a five-commit release. DR-188 is retargeted: it recorded the native-video default as waiting on the background-audio handoff, which is now fixed (DR-196), so it records the completed flip and the evidence for it instead. Minor, not patch: the rendering path changes underneath every Android user. |
||
|
|
be907b4945 |
fix(home): stop Next Up repeating Continue Watching
Jellyfin's /Shows/NextUp defaults EnableResumable=true, which returns a
partially-watched episode as its own series' next up — precisely the
episode /Items/Resume already returns. Home's "Next Episode" row and the
TV landing's Next Up row therefore duplicated Continue Watching card for
card.
build_next_up_endpoint now sends EnableResumable=false, and because
servers predating that parameter ignore it, filterInProgressNextUpItems
also drops any next-up entry whose id appears in the resume list. It is
the mirror of DR-089 and sits beside it: presentation-layer de-duplication
over two lists the frontend already holds. The resume filter still reads
its frontier from the unfiltered Next Up list, so pruning in-progress
entries cannot resurrect a stale resume card.
The code changes were swept into
|
||
|
|
3b9a8ad695 |
test(player): pin the native-video default and the opt-out that must survive it
The default has moved four times, so the risk is not which way it points but that a flip silently overrides people who chose. The previous reader was getItem(KEY) === "true", which conflates "never chose" with "chose off" — under it, flipping the default re-enables the native path for everyone who had deliberately turned it off. The three cases are pinned separately so that conflation cannot come back. |
||
|
|
ab95f5013d |
feat(player): make native Android video the default
The two defects that were holding the flip back are fixed and verified on a
device, which is the standard this default has been held to since DR-161 shipped
a verified sub-path over an unverified one:
- returning from background audio restarts the renderer that is actually on
screen, instead of only ever reloading the <video> element (DR-196)
- the letterbox bars are painted, instead of retaining whatever was last in
the framebuffer (DR-194)
Evidence: handoff to audio-only at 69:54 returning to video playing at 70:18,
and clean bars across playback, the control bar and a rotation round-trip.
An explicit stored choice still wins in both directions, so anyone who turned the
flag off keeps it off — hence the null check on the stored value rather than a
bare === "true", which would silently re-enable it for people who opted out.
The Settings copy no longer tells users to leave it off; it now describes the
toggle as the fallback to the built-in web player.
The flag keeps its "experimental" name because it remains a suppressor of Rust's
backend choice, never a promoter: turning it on cannot produce a native backend
where Rust says HTML5.
|
||
|
|
5e8efa252e |
fix(player): restart the native renderer when returning from background audio
With native video on, coming back from background audio left a black screen: a play overlay pinned at 0:00, a seek bar at zero, and a play button that did nothing. Nothing crashed — the process stayed up and the frontend kept logging — the transition was simply dropped. The two render paths resume by different means, and exitBackgroundAudioHandoff only ever performed one of them. The webview <video> reloads off its stream URL: an $effect watches it, reinitialises HLS or sets element.src, and canplay drives the seek and play. ExoPlayer owns no element and nothing watches the URL on its behalf — native playback is only ever started by an explicit player_play_item plus adapter load, which the component issues once, from onMount. So reassigning the URL restarted precisely nothing, and since player_exit_background_audio had already stopped the handoff's audio player, the backend came back holding no item at all. That is why the play button was inert: there was nothing loaded to play. The return now re-issues that pair on the native path, in the same order as the initial load, carrying the position the audio reached. Subtitle configurations are reused from the ones resolved at mount — ExoPlayer sideloads them as MediaItem.SubtitleConfigurations and cannot accept one after prepare(). Which path to take is decided by planHandoffReturn, a pure helper in backgroundAudioHandoff.ts, so the branch is unit-testable without mounting the player. It also folds in shouldResumeOnForeground, so a pause taken on the lockscreen during the handoff still wins over the snapshot captured on the way out. Verified on device (HONOR ROD2-W09, Android 16): handoff to audio-only at 69:54, return restored native video playing at 70:18. Previously the same sequence left the player idle and black. The requirements count pin in extract-traces.test.ts moves with the new DR-196. |
||
|
|
1285908733 |
fix(android): paint the letterbox bars, so stale pixels stop surviving in them
Native video left debris in the padding around the video: the "previous frame" flash on rotation, a ghost copy of the control bar stranded in the top bar, each new clock digit drawn over the one before it (35:42 with the 1 still showing through the 2), and the sleep/quality menus leaving their imprint after closing. One cause under all of it — nothing painted those bars. The window surface is opaque; the theme is not translucent and dumpsys window shows no translucency flag. For an opaque surface HWUI deliberately does NOT clear the damaged region before replaying a frame: it assumes the view hierarchy covers every pixel it owns. Here that hierarchy is window background → video TextureView → transparent WebView, and fitSurfaceToScreen sizes the TextureView to the letterboxed video rect. So the bars were the window background's alone to paint, and setTransparent(true) cleared it to TRANSPARENT — leaving them painted by nobody, with whatever was last in the framebuffer surviving there. The window background now stays opaque black while compositing. It cannot hide the video: the TextureView is drawn on top of it, and the WebView's own background is what lets the picture through. Three previous attempts missed because they aimed at the window's rotation animation and at TextureView frame-retention — two postOnAnimation hops, an onSurfaceTextureUpdated reveal, then ROTATION_ANIMATION_JUMPCUT with FLAG_FULLSCREEN to make it stick. The pixels were never the animation's, which is also why the artefact reproduces standing still, with no rotation involved. Those are removed. The alpha-hiding among them actively made things worse: it blanked the one view that reliably paints its own rect. FLAG_FULLSCREEN goes too — it fought edge-to-edge insets for no gain. Verified on device (HONOR ROD2-W09, Android 16): reproduced with native video on — ghost control bar in the top bar, doubled clock digit — then absent after the fix across playback, the control bar and a rotation round-trip. DR-194 is rewritten to record the real mechanism and marked Done. |
||
|
|
8e98e1c37a |
test(player): answer the commands the tap-surface tests actually render
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 22m57s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m50s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
Build & Release / Run Tests (push) Successful in 7m21s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 20m32s
Build & Release / Build Windows (push) Successful in 14m29s
Build & Release / Build Android (push) Successful in 31m5s
Build & Release / Create Release (push) Successful in 12s
VideoPlayer.tapSurface.test.ts deliberately does not mock $lib/api/bindings — it renders the real component against the real bindings, which bottom out in the globally mocked `invoke`. That mock resolves `undefined` for every command, so any command whose result is *rendered* blows up: the quality picker assigns the result straight to state and the template then reads `streamingQualities.length`, which throws on undefined. It threw asynchronously, outside any test, so the suite reported 4 unhandled errors while every test still passed — the state vitest warns "might cause false positive tests". Answering the two rendered commands removes them. Authored in the main checkout; brought in here and verified: 83 files, 1009 tests, and the unhandled-error count drops from 4 to 0. |
||
|
|
440d7a01a9 |
chore(release): 0.6.0
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 6m2s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 32s
Traceability Validation / Check Requirement Traces (push) Successful in 13s
Build & Release / Run Tests (push) Failing after 6m7s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
Android native video renders a picture, and its transport works. The path shipped once as audio with no picture and was reverted with the compositing named as the suspect. It was not the compositing: five independent defects sat between ExoPlayer and the screen, each able to produce that symptom on its own — the app shell painting over the surface through a CSS rule aimed at an attribute nothing set, a poster card with no way to lift on a path that renders no <video>, JS bridges racing the page load and losing permanently, a SurfaceView that was never detached, and a frontend that told Rust a webview element was playing when none existed, so every play/pause intent was aimed at something that was not there. Native video stays opt-in. Turning it on surfaced a further unverified path — the background-audio return is written only for the webview element — and rotation still needs device confirmation. Minor rather than patch: the player's touch behaviour changes for everyone (the control bar now auto-hides on touchscreens, and the system bars go away with the player), not only for those who opt into native video. |
||
|
|
dccb5f53dd |
fix(android): stop the rotation cross-fade replaying the old video frame
Rotating with native video on shows the previous frame flashing in what become
the letterbox bars. It reads as a TextureView artefact — the view retains its
last frame, so between the rotation and fitSurfaceToScreen() landing that frame
sits at the old size — and two fixes were built on that reading:
1. reveal after two postOnAnimation hops. An animation frame is not a video
frame; at 24fps the next decoded frame can be several vsyncs away.
2. reveal on onSurfaceTextureUpdated, i.e. when a real frame lands. This meant
owning the SurfaceTextureListener and handing ExoPlayer the Surface directly
instead of via setVideoTextureView, which installs its own and leaves us
blind to frame arrival.
Neither stopped the flash. The mechanism is the WINDOW's rotation animation:
Android cross-fades a screenshot of the old orientation, that screenshot holds
the old video frame at the old size, and nothing at the TextureView level can
reach it. The app cannot pre-empt the screenshot either — onConfigurationChanged
fires after it is taken.
So the animation itself has to go: ROTATION_ANIMATION_JUMPCUT. That was accepted
and silently ignored, and the platform said why out loud —
"VRI[MainActivity]: setLayoutParams: not fullscreen" — because the attribute is
honoured only for a fullscreen window. FLAG_FULLSCREEN is therefore set with it,
scoped to while native compositing is active so the rest of the app keeps its
normal animation. After the change that complaint is gone from logcat.
The frame-arrival reveal is kept: it replaces a fixed-timeout guess with a real
signal, and its timeout is required rather than defensive — a resize while paused
means no new frame is ever coming, and revealing a stale frame beats a
permanently black player.
NOT CONFIRMED FIXED on device. The forced-rotation harness
(settings put system user_rotation) proved unreliable here, and screenrecord
fixes its canvas at start, so a rotation inside a recording never changes frame
dimensions — which defeated two separate attempts to measure this. DR-194 is
recorded as "Needs device verification" rather than Done.
|
||
|
|
c142568230 |
fix(player): make transport reach the player that is actually rendering
Play/pause did nothing on the Android native video path — from the on-screen tap, from the control bar, and from a direct player_toggle invocation — while seek and skip kept working. That asymmetry was the whole clue: seek decides in player_seek_video, transport decides in toggle_playback. DR-195 is the cause. `html5_playing` is Rust's record of "a webview <video> is active and in this state", and toggle_playback/play/pause all route transport to that element whenever it is set. The player route mirrored element state into it UNCONDITIONALLY — from handleReportStart and, fatally, from handleReportProgress, which VideoPlayer calls on a 10-second interval. So on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. It also explains the flashing: the control bar and the JRay overlay both key off isPlaying, which was being contradicted on every tick. The mirror now lives in mirrorElementStateToRust() in VideoPlayer, gated on useHtml5Element — the only place that knows whether an element renders at all. The route cannot tell the paths apart, which is exactly how it came to lie. DR-193 hands transport authority back to the native backend when an item loads into it. Necessary but insufficient alone: the progress interval put the flag straight back, which is why the first device test after it still failed. DR-192 presents native video through a TextureView instead of a SurfaceView. A SurfaceView renders on its own layer outside the app window and punches a transparent region through it, and everything drawn above that hole — here, the entire Svelte UI — depends on that composition path. The overlay dropped its incremental damage: the DOM advanced (slider 476 -> 479 across three seconds) behind a screen showing neither, so the progress bar froze, controls would not fade and rotation lost the transport UI, while structural DOM changes got through, which is why the play overlay always appeared to work. It supersedes DR-191, which forced redraws in a loop and treated the symptom. DR-194 hides the video view across a resize and reveals it two frames later. A TextureView retains its last frame, so between a rotation and the re-fit landing that frame is stretched across the old rect and the previous frame flashes in what should be the letterbox bars. Verified on device (Honor ROD2-W09, Android 16) by driving ADB and reading the live DOM over the devtools socket: surface tap pauses (position frozen across 12 seconds, overlay raised, transport flipped) and resumes; the control bar does both. UT-189 drives the real 10-second interval under fake timers — an earlier version asserted on a freshly mounted player, passed with the guard deleted, and guarded nothing. Still open, and deliberately not claimed: DR-192's effect on the overlay repaint is unverified on device, DR-194's letterbox reset is untested, and the native default (DR-188) stays off pending DR-190, the background-audio return. |
||
|
|
95129d04a3 |
fix(player): make Android native video actually visible, and usable
DR-172 reverted native video to opt-in after it shipped as audio with no picture, naming the compositing as the suspect. The compositing was fine. Five separate defects sat between ExoPlayer and the screen, each able to produce that exact symptom on its own, and each invisible to the others. DR-185 — the app shell painted over the surface. app.css clears the page's opaque layers through three selectors, one of which targets `[data-app-shell]`, an attribute NO component has ever set, in any commit. The shell paints --color-background across the whole viewport and VideoPlayer stacks above it, so the WebView composited opaque no matter what else was cleared. Invisible three ways over: the CSS is valid, the selector is plausible, and a rule matching nothing looks exactly like a rule matching something already transparent. DR-182 — nothing could lift the poster card. Every markMediaReady() call site is an HTML5 <video> event, and the native branch renders no element, so the black title card covered the surface for the entire session. The first fix hooked `player://position-update` / `player://state-changed`; those channels are never emitted by the backend, so it passed a test that fired them by hand and did nothing on a device. Driven from the player store now, as the seek bar already was. DR-183 — the JS bridges raced the page load. Installed 500ms after onCreate by walking the view tree, while WebView binds injected objects at page-load time, and the identity guard then declined to re-inject forever. setTransparent(true) could never arrive. Installed from WryActivity.onWebViewCreate instead, which wry calls immediately before the first loadUrl. DR-184 — the SurfaceView was never detached. detachVideoSurface had no callers anywhere, mirroring the DR-151 defect: every native video left its surface parented to the content view and the next one stacked another beneath it. DR-191 — the overlay stopped repainting. Incremental damage (the clock's text, the control bar's opacity) never reached the screen while structural changes did, so the progress bar froze, the controls would not fade, and the play overlay appeared to work because it is added and removed from the DOM. Driven from the Activity via postInvalidateOnAnimation while compositing is on. Two UI defects only this path could reveal came with them: isPlaying froze at its initial value, leaving the play overlay dimming and covering the video (DR-186), and the control bar's auto-hide was armed solely by mousemove, which a touchscreen never fires (DR-189). Immersive mode now applies on entering the player rather than only via the fullscreen button (DR-187). Verified on a device (Honor ROD2-W09, Android 16): logcat carries `WebView transparent = true` and `Marking media ready` with video on screen — the pair DR-172 went looking for and could not find — and skip, seek, rotation and subtitle rendering were exercised by hand. The default stays OFF (DR-188). Turning it on surfaced a further unverified sub-path: returning from background audio is HTML5-only, so playback stays dead (DR-190, proposed). Shipping it would have repeated DR-161 exactly — a verified sub-path made default over an unverified one. |
||
|
|
f0f98feae8 |
fix(player): strip the burn-in the server puts back into its own transcode URL
The negotiation asks for no subtitle stream (DR-176), but when PlaybackInfo answers with a TranscodingUrl we played that URL verbatim — and the server built it from its own subtitle verdict. Jellyfin's StreamInfo.ToUrl appends SubtitleStreamIndex and SubtitleMethod whenever it picked a track, so the burn-in we had just declined came straight back through the URL, turning a remux into a full frame-by-frame re-encode. Live TV never declined it at all: open_live_stream sent no index, so the server applied the channel's default track, and broadcast subtitles are DVB bitmaps that NormalizeSubtitleEmbed converts to burn-in on sight. without_server_chosen_subtitle() drops SubtitleStreamIndex, SubtitleMethod, SubtitleCodec and alwaysBurnInSubtitleWhenTranscoding from any URL the server built — matched case-insensitively, as Jellyfin binds query keys — and re-appends the -1 sentinel, because an absent index is not "none", it is "you choose". Applied at both adoption points, plus the sentinel in the live-stream negotiation body and its fallback URL. |
||
|
|
d9e1e256e9 |
fix(auth): trim the username before authenticating
The login form guarded on `username.trim()` but sent the raw value, so a trailing space from a soft keyboard reached the server verbatim. Jellyfin reports that as an unknown user, which surfaces as a 401 indistinguishable from a wrong password — the user is certain of their credentials and the app insists otherwise. Normalising in AuthManager rather than the form keeps it on the path every caller uses, alongside normalize_url. Only surrounding whitespace is stripped; interior spaces are legal in Jellyfin usernames. |
||
|
|
42868fc2e6 |
feat(login): reveal-password toggle, and stop the keyboard editing credentials
Add an eye/eye-off button inside the password field so a typed password can be checked against what was intended — the difference between "wrong password" and "wrong keyboard" was previously invisible. `bind:value` is not allowed alongside a dynamic `type`, so the field is wired manually via value/oninput; unlike branching on two separate inputs, this keeps focus and caret position when the toggle is pressed. Both fields also get autocapitalize/autocorrect/spellcheck off and proper autocomplete hints. The Android soft keyboard was free to capitalise or autocorrect the username, which silently changes a credential the user believes they typed correctly. |
||
|
|
c0c6c5023e |
fix(player): resume a transcoded video by seeking, not by asking for a stream that starts mid-item
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m30s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
A resumed transcode played nothing at all: every segment came back 400, hls.js exhausted its retries and gave up, while the same episode from the beginning was fine. Jellyfin builds each segment URI by echoing the master playlist's query string into it, and its segment handler opens by rejecting any request carrying StartTimeTicks > 0 (ArgumentException → 400). So one resume position on the playlist is copied onto every hls1/main/N.ts and 400s all of them — the `> 0` being exactly why starting from the beginning survived. HLS does not need the parameter: a playlist spans the whole item and asking for segment N *is* the seek. It is removed from the URL builder entirely rather than conditionalised — the builder cannot know whether its response will be segmented — and the position becomes a seek issued once the player has loaded. The progressive /Audio/universal builder behind the background-audio handoff has no segments and keeps its StartTimeTicks, which is why audio-only handoffs resumed correctly and video ones did not. Completing that across the boundary, since the URL no longer starts where the caller asked: - reloadSource(url, position) now means "reload and resume AT this absolute position": it seeks the element once the source is playable and clears the transcode offset to zero. It previously set the offset to the position and seeked nothing, which was correct only while the URL itself began there — left in place it would have shown 20:00 on the scrubber while the opening titles played, with no seek ever happening. - The transcoded resume path in the player page collapses into the same "seek after load" branch direct streams already used. - VideoPlayer's background-audio return does the same: no base, seek to the absolute position. - The stale test asserting StartTimeTicks is present is rewritten to keep its other half (an HLS master playlist, never a progressive stream.mp4, carrying the chosen source and audio track). TRACES: UR-004, UR-005, UR-019, UR-021, UR-074 | DR-181 | UT-182, UT-183 |
||
|
|
521acc75fd |
build(android): add a side-by-side release build for validating R8
R8 has broken release APKs here before by stripping the JNI-loaded player and security classes, and the only way to reproduce that was to build with the real signing key and clobber the install you actually use. `./scripts/build-and-deploy.sh release --device --debug` now builds a fully minified release APK — exactly what ships — into the .debug applicationId slot, signed with the local debug keystore: release com.dtourolle.jellytau 0.5.5 release --debug com.dtourolle.jellytau.debug 0.5.5-debug-release debug com.dtourolle.jellytau.debug 0.5.5-debug It shares the applicationId *and* the signature with the plain debug build, so the two replace each other cleanly rather than colliding, and the versionName suffix says which is currently installed. No real key is needed, so the side-by-side path deliberately skips write-keystore-properties.sh. The flag reaches Gradle as JT_SIDE_BY_SIDE=1. CI never sets it, and the release manifest merges byte-identical without it — verified both ways through processUniversalReleaseMainManifest. deploy-android.sh and build-and-deploy.sh learned the flag too, since the APK path is unchanged but the package to launch is not. |
||
|
|
886cbcb29a |
docs(changelog): backfill every release, and date each fixed defect
CHANGELOG.md stopped at v0.5.0 and had gaps below it. Every tag from
v0.0.1 to v0.5.5 now has an entry, written from the commit bodies rather
than the subjects. Entries before v0.1.2 are shorter and marked as
reconstructed after the fact -- the commit messages of that era ("many
changes", "Playback fix") do not record causes.
docs/defect-windows.md is new: for each fixed defect, the releases it was
actually present in, with the evidence for the dating recorded per row so
a row can be disputed. Dated with `git log -S` on the defective token, not
by blaming the lines a fix removed -- that reliably lands on whatever last
touched the adjacent lines rather than on the defect's origin, and was
used only to shortlist.
Twelve defects date to the v0.0.1 proof of concept and shipped for seven
to eight weeks. They are not regressions but original assumptions nothing
exercised, four of them outright latent: the videoBitrate casing was
harmless until a quality picker existed to select against, and the
unconditional Range header was inert until that fix made transcoded
downloads actually transcode -- so DR-170's code dates to v0.0.1 while its
corruption window is the single release v0.5.1.
Three others are plumbing built and never connected: get_next_up_episodes
accepted a series_id with no caller until v0.3.0, the sync queue ran with
neither producer wired, and both watched-state backend halves sat unused.
No automated check sees these; the code is present, tested and reachable
in principle.
Also corrects the v0.5.5 entry.
|
||
|
|
2cc39cd7fd |
build(android): install the debug build alongside release as its own app
Testing a debug build meant uninstalling the real one first: same
applicationId signed with a different key is INSTALL_FAILED_UPDATE_
INCOMPATIBLE, so every experiment cost the app's settings, credentials
and offline cache.
The debug build type now carries applicationIdSuffix ".debug" and
versionNameSuffix "-debug", so it installs as com.dtourolle.jellytau.debug
("JellyTau Debug", 0.5.5-debug) with its own data directory — two
independent apps on one device.
Only the *application* id is suffixed. Kotlin classes stay in the
`namespace` package com.dtourolle.jellytau, so the JNI loadClass lookups
in player/android/mod.rs, the manifest <service> entry and the R8 keep
rules are untouched, and the FileProvider authority was already
${applicationId}-relative. Launcher names come from the appLabel /
activityLabel manifestPlaceholders rather than resValue, which would
collide with Tauri's generated strings.xml; release resolves them back to
@string/app_name and merges byte-identical.
deploy-android.sh reports the target package and explains an
UPDATE_INCOMPATIBLE failure instead of leaving it raw; logcat.sh takes a
debug|release argument (it was filtering on com.jellytau.app, a package
that has never existed) and attaches by pid when the app is running.
Verified: aapt2 badging on the built APK reports
com.dtourolle.jellytau.debug / 0.5.5-debug / "JellyTau Debug", and the
release manifest merge is unchanged.
|
||
|
|
e457a9884c | chore(release): 0.5.5 | ||
|
|
de1c13e72f |
fix(player,reporting): report real positions, and count an audio-only episode as watched
Returning to the foreground before the background-audio stream had started
playing handed the frontend 0.0s, so the video reloaded at StartTimeTicks=0 —
the episode restarted from the beginning — and the stop report that followed
wrote that zero to Jellyfin as the resume point. Caught on device: locked at
18.4s, unlocked 3.5s later with ExoPlayer still IDLE.
The base that turns a handoff's relative timeline into the episode's is applied
once at the native tick boundary (DR-159), so before the first tick nothing has
applied it. The same blind spot covers webview-rendered media, where nothing is
loaded into the native backend at all and its position is a permanent 0 — which
is why 14 of 14 stop reports in a 35-minute trace were zeroes, one landing 40s
after the frontend had correctly reported 15:22 for the same episode.
- absolute_position(): the maximum of the backend's reading, the last position
webview media reported, and the handoff base. Exact rather than heuristic —
at most one term is ever meaningful, and the base is a floor the stream
cannot physically be behind. duration() gains the same fallback.
- Withhold zero-position stop reports. A zero is never information, and
Jellyfin stores the reported position as the resume point, so sending one
only ever destroys a real one.
- Report progress from the controller's own position ticks, through the 30s
throttler it already shared with the native audio path.
/Sessions/Playing/Progress was previously requested zero times in 35 minutes.
- Report a finished audio-only episode stopped at its runtime before advancing,
so Jellyfin's 90% rule marks it played. Nothing else can: the webview is
suspended and its <video> was torn down at the handoff.
- Split the handoff by source — a downloaded file takes no base and a real
seek, a stream keeps its StartTimeTicks base and no seek — and stop routing a
downloaded handoff's absolute seek through the stream rebuild, which refuses
a non-remote source outright.
Reports go through a PlaybackReportSink, which also collapses three copies of
spawn-a-task-and-hope into one and is what let each of these be written as a
failing test first.
TRACES: UR-005, UR-025, UR-040, UR-071 | DR-178, DR-179, DR-180 |
UT-176, UT-177, UT-178, UT-179, UT-180, UT-181
|
||
|
|
5096c01960 |
fix(player): restore the subtitle sidecar work dropped by the previous commit
The previous commit was assembled from a tree read before
|
||
|
|
2d67b0e4f5 |
fix(player): give every transcode its own play session, and stop the one it replaces
Switching bitrate mid-film stalled playback. The server served the new playlist and then rejected its segments: 400 on hls1/main/0.ts, six times over 25 seconds, never recovering, while the UI logged "Streaming quality changed" as if nothing were wrong. Jellyfin keys a transcode job by device and play session. Every stream URL this app built carried the same hardcoded DeviceId and no PlaySessionId at all, so the second stream for an item was indistinguishable from the first and nothing ever stopped the old ffmpeg. Re-opening a stream is not rare — a quality switch, a transcoded seek and an audio-track switch all do it. Replayed against the server, a second stream opened for a live job's item alternates per attempt between serving bytes and 400ing, which is why it read as flaky rather than broken. begin_video_play_session mints a session id per open and reports the one it supersedes; the URL builder stops that job (DELETE /Videos/ActiveEncodings, un-retried — a slow stop must not delay playback) before returning. Putting it in the builder rather than in each caller covers every re-open path by construction. adopt_video_play_session takes ownership of the job the server starts itself when PlaybackInfo answers with a TranscodingUrl: without it the first switch on a stream has nothing to stop and collides with what is playing. Two client faults made the same incident worse and go with it: - The fatal-HLS-error handler added the transcode seek offset to a position that already included it. Past roughly the halfway mark of a film the doubled value cleared the "near end" threshold, so any transient network error was reported as end-of-stream and autoplay skipped to the next item — precisely when a quality switch had just made the offset large. The decision now lives in hlsRecovery.ts, against the absolute position. - The HTML5 reload primitive resolved on its own canplay timeout, so a reload the server never served reported success. The picker showed a quality that was not playing and the caller had nothing to revert. TRACES: UR-074, UR-004 | DR-177 | UT-173, UT-174, UT-175 |
||
|
|
13264e225b |
fix(player): never let the server burn a subtitle in, and never offer one we cannot draw
Reported as "subtitles are shown even when off", and no toggle in the app cleared them — because they were not the app's subtitles at all. The server was painting them into the video. `PlaybackInfo` omitted `SubtitleStreamIndex`, which does not mean "none": the server then honours the source's own default/forced flag. On the reported episode that default is a PGS track — a bitmap, which cannot go out as a sidecar — so the server fell back to `SubtitleMethod=Encode` and composited it onto every frame. Confirmed against the live server, which answered the same PlaybackInfo request two ways: with the index omitted it returned `SubtitleStreamIndex=2` + `SubtitleMethod=Encode` and a `SubtitleCodecNotSupported` transcode reason, and its ffmpeg command carried `[0:2]…[sub];[main][sub]overlay_qsv=…`; with `-1` it selected no subtitle stream at all. The cost landed on the video, not the subtitle: burn-in rules out remuxing, so a stream that only needed its audio transcoded was re-encoded frame by frame. Three parts: - The negotiation asks for `SubtitleStreamIndex=-1` and advertises every text format we can render (srt/subrip/ass/ssa/vtt) as `External`. - The stream URL says the same thing, because the negotiation is not what opens most streams: a quality switch, a transcoded seek and an audio-track switch each rebuild the URL on their own, and an omitted index there lets the server pick the default track back up out of whatever session state it still holds. - The picker offers only subtitles the app can actually draw. Each subtitle stream now crosses the boundary carrying `supports_external_delivery`, decided in Rust where the codec vocabulary belongs, and `None` for anything that is not a subtitle so a `false` cannot be misread as a verdict. `subtitleStreamsOf()` drops the rejected ones — and since that one function feeds the menu, the `<track>` children and the native play request alike, a bitmap track disappears from all three without its URL ever being fetched. Only an explicit "no" hides a track; a stream carrying no verdict behaves exactly as before. Nothing is lost by refusing burn-in: the app already fetches the text tracks and draws them itself (UR-020), so the server's composited copy was always redundant. Image-based tracks are consequently not offered, which is honest rather than a regression — the renderer cannot composite a bitmap, and the old behaviour paid for them by making the whole stream unwatchable. Tests were written first and observed failing: the Rust one would not compile against a field that did not exist, and the frontend one resolved a URL for the PGS track it was supposed to drop. Carries with it the in-flight per-stream `PlaySessionId` work in online.rs, whose hunks sit inside the same request builder and could not be separated from these. TRACES: UR-020, UR-004 | DR-176 | UT-168 |
||
|
|
041969f446 |
fix(player): stop the server burning subtitles into the picture
A transcoded episode stalled every few seconds and seeking took five to nine seconds to produce a frame. Neither was a seek bug: both seeks in the capture landed correctly. The stream itself could not keep up. The episode was HEVC video, E-AC-3 audio, and a PGSSUB subtitle track. Only the audio needed transcoding — the device profile supports HEVC and the server would have remuxed the video untouched. But the PlaybackInfo request omitted SubtitleStreamIndex, and omitting it does not mean "no subtitles": the server then honours the source's default/forced flag and picks a track itself. It picked the PGS one. PGS is a bitmap, and the profile advertised only srt/vtt as External, so it could not go out as a sidecar — leaving SubtitleMethod=Encode, burn-in. Burn-in is a video cost, not a subtitle cost. Compositing rules out remuxing, so the whole HEVC stream was re-encoded to h264 frame by frame. The server could not sustain that in real time: the buffer never grew past one segment and playback ran waiting -> HLS error -> canplay -> three seconds of picture, indefinitely, while each seek restarted the encoder from scratch. TranscodeReasons named it — SubtitleCodecNotSupported — but nothing in the log connected that to the stall, so the diagnostic now says which track it is declining and why. Ask for SubtitleStreamIndex=-1 explicitly, and advertise every text format we can render (srt/subrip/ass/ssa/vtt) as External so a subtitle can only ever arrive as a sidecar. Nothing is lost: the app already fetches subtitle tracks itself and draws them over the video (UR-020), so the server's composited copy was always redundant. Image-based tracks are consequently not offered, which is honest rather than a regression — the renderer cannot composite a bitmap, and the previous behaviour paid for them by making the stream unwatchable. The policy lives beside the other device-profile rules in Rust, where it is testable without a device. TRACES: UR-020, UR-004 | DR-176 | UT-168 |
||
|
|
1a9805f0f3 |
fix(downloads): queue the whole album, and make every queued track findable offline
An album download put a handful of its tracks on the device while the button reported the album as downloaded. Two independent gaps, one shared cause. - `download_album` read its track list from `items WHERE album_id = ?` — the local catalog cache. Jellyfin does not return `AlbumId` on every listing endpoint, so tracks cached from one of those sit in `items` with a NULL `album_id` and are invisible to that query. On the reported database three whole albums (18, 12 and 9 tracks) had it NULL on every track; a partially linked album queued only the linked subset. - The frontend then resolved one stream URL per track from its own list and paired it with the returned row ids by position. The ids came back in the backend's `index_number` order over a different set of rows, so a row could be handed another track's URL and any track past the end of the shorter list was never started. On Android that loop also stopped wherever the webview was suspended. - `album_id` is what `OfflineRepository::get_items` joins a track to its album on, so a track that did download stayed invisible under its album offline — the same missing link seen from the other side. The operation now belongs to Rust end to end: - `HybridRepository::get_album_tracks` asks the server what the album contains. Cache-first `get_items` is right for browsing and wrong for deciding what to download; it errors offline so the caller falls back to the ungated local catalog, keeping the queue-while-offline flow. - `queue_album_tracks` writes the album link onto every track it queues, and creates an `items` row for tracks the cache has never seen. - Stream URLs resolve here, through the existing reconnect resolver, now scoped to the rows just queued so one album cannot start every unrelated pending row. Only the album id crosses the IPC boundary. - `album_file_names` gives each track its own file. A title repeated inside one album (deluxe edition, two discs) mapped to one path, so those downloads overwrote each other. Re-tapping download on a broken album heals it: missing tracks are queued and the tracks already on disk get their link. `download_series`/`download_season` still derive their episode lists from the cache the same way and want the same treatment. DR-173, UT-170..172. Rust 673 tests, frontend 975 tests, svelte-check and check:boundary clean. Note: this tree is shared with a concurrent session. Only the files above are committed; docs/traceability.md is left to be regenerated once that work lands. |
||
|
|
82b6982d68 |
fix(player): use a speedometer icon for the streaming quality selector
The bitrate ceiling button reused a cloud-download glyph, which read as a download action rather than a bandwidth setting. |
||
|
|
3363ff7f08 |
Merge branch 'master' into worktree-mosaic-library
# Conflicts: # scripts/extract-traces.test.ts |
||
|
|
9858b7cb92 |
chore(release): 0.5.4
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 2m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m37s
Traceability Validation / Check Requirement Traces (push) Successful in 26s
Build & Release / Run Tests (push) Failing after 5m56s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
|
||
|
|
f46d7bf676 |
fix(player): make native Android video opt-in again — it shipped as audio with no picture
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 4m55s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Traceability Validation / Check Requirement Traces (push) Successful in 20s
DR-161 flipped experimentalNativeVideo on by default so picture-in-picture could shrink a real video surface. On a device that shipped sound with a blank screen. The decode path was never at fault. Logcat shows ExoPlayer running and feeding a live SurfaceView with an active BufferQueue. The compositing was: the SurfaceView sits behind the WebView, and the step that clears the opaque layers above it never took effect — `WebView transparent = false` is logged, `= true` never appears. The video was rendering correctly the whole time, behind an opaque page. This is precisely the defect the flag existed to contain; VideoPlayer.scrubRegression.test.ts had already recorded that "the native SurfaceView has never been visible through the webview". Enabling it by default shipped a verified decode path on top of an unverified display path. Reverting costs nothing that matters: PiP does not depend on it — DR-160 drives PiP from the WebView <video> — and working video outranks PiP showing a native surface. The flag stays in Settings, now described as incomplete rather than as a performance win, so anyone helping test it still can. Fixing the compositing is the prerequisite for trying this default again (DR-172). |
||
|
|
74bffea650 |
Merge branch 'fix/autoplay-issues'
Records ancestry only: all three of its changes are already on master, content-identical, having been applied by cherry-pick rather than merge — the reportMediaId snapshot in VideoPlayer, the `?restart=true` hand-off in nextEpisodeService, and the POSIX-sh rewrite of the traceability CI loop. The branch is 167 commits behind, so the files it touched conflicted with their own newer selves; every conflict resolved to master's version. The resulting tree is byte-identical to the pre-merge tree. |
||
|
|
7e1f0e0547 |
Merge branch 'master' into worktree-mosaic-library
# Conflicts: # docs/traceability.md |
||
|
|
99ceeadb83 |
docs: regenerate the traceability matrix
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m7s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m26s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Failing after 5m5s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
The generated matrix had drifted well behind the code — this pass picks up DR-171/UT-166 along with everything else that had accumulated since it was last run, which is why the diff is large for a mechanical regeneration. Coverage 87% (265/303), no orphaned IDs, comfortably above the workflow's 50% floor. No hand edits: `bun run traces:markdown` output as-is. |
||
|
|
e015c4c9b1 |
Merge branch 'master' into worktree-mosaic-library
Renumbers the mosaic's requirement IDs out of the way of the download work that landed on master in parallel: it had already claimed DR-163/DR-164 and UT-162, so the mosaic layout is now DR-172, the library favourites scope DR-173, and its composition test UT-167. Note for the download branch: its UT-162..UT-165 rows trace to DR-163..DR-166, none of which are defined in requirements.md — that branch defined DR-167..171 instead. Those references are orphaned and want a look; nothing here touches them. |
||
|
|
7387f35c7e |
docs(player): correct the stale "native video defaults to off" comments
DR-161 made `experimentalNativeVideo` default to on, but three comments still described the pre-flip world and one of them was load-bearing: - `nativeVideo.ts` labelled the store "Default off" directly above a `load()` that returns true when nothing is stored. - The two PiP comments explained themselves as "what makes PiP work in the shipping configuration", which stopped being true when Android started shrinking the real ExoPlayer surface. They still describe the Linux path and the flag-off case, so they say that instead. - `video_audio_codecs` justified its narrow codec list with "video does not play through ExoPlayer", which is no longer so on Android. The narrow list is still right, for a different reason now recorded: the flag is a user setting and a download outlives it, so only the intersection holds on both sides of the switch. DR-171 carries the same caveat. No behaviour change. |
||
|
|
0861523015 |
feat(library,home): lay libraries out as a mosaic, with favourites per category
The library overview and the home shortcut strip showed artwork of three different shapes — square music covers, 16:9 library backdrops, 2:3 posters — in grids that pick one box and crop everything to it. The home strip said so in a comment: it forced `aspect="video"` on music libraries so the row would line up, which lined it up by cutting the covers down. Both surfaces are now justified mosaics: rows share one height and each tile is as wide as its own artwork. `layoutMosaic` is a pure module — it packs tiles until the height needed to fill the container drops to the target, justifies the row by absorbing the rounding remainder into its widest tile, and deliberately leaves the last row unstretched so one leftover tile does not inflate into a banner. The component supplies only what the DOM knows: the measured container width, and the artwork's *decoded* aspect ratio (via a new `onNaturalSize` on CachedImage), committed in one debounced batch so the grid does not reshuffle once per image as artwork lands. Favourites gain a tile per category beside the library it belongs to, alongside the existing cross-library entry. Which collection type maps to which category is Jellyfin vocabulary, so it is derived in Rust — `SearchScope::for_collection_type`, stamped onto every `Library` by a new constructor and carried over as an optional `favoritesScope`. Deriving it in Svelte would have rebuilt the exact leak `SearchScope::item_types` was extracted to close. A category shows one tile however many libraries share it, and a library kind favourites do not carve up (Live TV, channels, books) gets none. Also corrects the requirements-count test, which the UR-074 commit left one behind. Spec: docs/specs/library-mosaic.md TRACES: UR-075, UR-067 | DR-163, DR-164 | UT-158..UT-162 |
||
|
|
ac4fccd499 |
fix(downloads,playback): re-encode undecodable audio and carry the media source through
Work from a parallel session in the same working tree, committed here so the branch is not left half-written. Attribution note: authored in a concurrent Claude session, not by the author of the preceding commit. - DR-171: a downloaded video keeps audio the device can actually decode. `original` quality asked for a straight copy, so an E-AC-3/AC-3/DTS/TrueHD track came down untouched and the webview had nothing to play it with. - `get_video_download_url` gains the media source, so the URL is built against the source actually chosen rather than the item's default. - Device profile and repository plumbing updated to match. Verified green as a whole: 656 Rust tests, 945 frontend tests, svelte-check clean. |
||
|
|
a5535f2941 |
fix(downloads): stop libraries mixing, make pause/resume real, reap partials, end bitrate corruption
Four defects behind "downloads still flaky", each with its own cause.
Libraries mixed their media (DR-167). Cached items carry no link back to their
library — library_id and parent_id are NULL on every row — so the library branch
of get_downloaded_items matched `EXISTS (SELECT 1 FROM libraries WHERE id = ?)`,
which asserts only that the library exists and never constrains the item to it.
Opening any downloaded library listed every downloaded top-level item on the
server: films under Music, albums under TV. The query deciding which libraries
appear already had the right rule, so the two disagreed about the same question;
that collection_type <-> item_type mapping is now one constant used by both.
Pause and resume did nothing (DR-168). pause_download wrote status = 'paused'
and stopped there — no cancellation existed anywhere in the download stack, so
the streaming task ran on and overwrote the row with completed/failed when it
finished. The row flicked to "paused" and undid itself. resume_download had the
mirror defect: it flipped the row to 'pending' without pumping, and the pump is
not a poller, so a resumed download sat until some unrelated event pumped the
queue. Adds a per-download stop flag the worker reads between chunks and on
retry, returning Stopped — not retryable, not recorded as a failure, and the
.part file is kept because that is what the resume continues from. Registering
returns a fresh flag so a resumed download does not inherit the pause that
stopped it. Cancel and clear_stale_downloads signal it too, so neither deletes a
file still being written.
Partial files were never reaped (DR-169). The worker named its sidecar with
with_extension("part"), which replaces: movie.mp4 became movie.part. Every
cleanup path deleted "{file_path}.part" — movie.mp4.part. They never matched, so
the partial of every cancelled or failed download stayed on disk forever,
invisible to disk-usage totals because no row pointed at it. One partial_path
helper now serves the writer and the cleaners.
Bitrate downloads corrupted themselves (DR-170). Only `original` asks for
Static=true; every other rung requests a transcode, which Jellyfin serves
chunked with no Content-Length and cannot byte-seek — it ignores Range and
answers 200 with the whole stream, not 206 with the tail. The worker sent the
header whenever a .part existed and appended the body regardless, so each retry
concatenated another full copy onto what was on disk. The file grew past its
real size and would not play, which is why bitrate downloads stayed broken after
the videoBitRate casing fix corrected the request. resume_offset now lets the
response decide: append only on 206, otherwise truncate and take it from the top.
docs/requirements.md also carries DR-171/UT-166, written by a parallel session
working in the same tree; its code lands separately.
|
||
|
|
d49d027020 |
docs(player): allocate UR-074/DR-162 for the streaming bitrate cap
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
The feature shipped tagged against DR-160, which a parallel session had claimed for picture-in-picture in the meantime. Renumbered to DR-162 across the Rust and frontend TRACES comments (the PiP tags in VideoPlayer.svelte, pictureInPicture.ts and nativeVideo.ts keep DR-160) and regenerated bindings.ts. Adds the requirement rows the tags point at: UR-074 for the user need, and DR-162 covering why the cap has to reach the PlaybackInfo negotiation and not only the transcode URL, why the ceiling is process-wide, and why the Settings default persists while the in-player override does not. Notes that this gives UR-070 its resume-at-the-same-point mechanism while the server-offered rendition list that requirement also asks for stays proposed. UT-156/157 record what the tests pin. docs/specs/streaming-bitrate-cap.md carries the layer assignment — the step definitions, the video/audio split, the resolution pairing and the reload decision are all Rust; the frontend holds a serde token and the labels it was handed. TRACES: UR-074 | DR-162 | UT-156, UT-157 |
||
|
|
9c352fdb77 | Merge branch 'fix/android-versioncode-floor' | ||
|
|
dda2ff86a3 |
feat(player): cap streaming bandwidth with a user-chosen bitrate ceiling
Video streams were opened at a fixed allowance nobody could change: MaxStreamingBitrate=20000000/VideoBitrate=18000000 on the HLS transcode URL, 20 Mbps in the PlaybackInfo negotiation, and a 999999999 device profile that let the server direct-play a source of any size. On a metered or slow connection there was no way to spend less. StreamingQuality is a ladder of bandwidth ceilings — Original, 20/10/8/ 4/2/1 Mbps and 720 kbps — where a step bundles the total ceiling, the audio share of it and the resolution that budget can carry. Those numbers are Jellyfin encoding vocabulary, so they live in Rust and the frontend only names a variant; labels and details come back over IPC from player_get_streaming_qualities, the same arrangement as the EQ presets. The cap has to reach the *negotiation*, not just the transcode URL: max_static_bitrate in the device profile is what makes the server refuse to direct-play a file fatter than the cap, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot. So it is applied at all four places that decide bandwidth — the HLS URL builder, PlaybackInfo, the Live TV stream, and the background-audio handoff (which takes the lower of the cap and its own 384 kbps). Video bitrate is the total minus the audio share so the two together honour the ceiling rather than overshooting it. The ceiling is process-wide rather than a repository field: it is a preference about this device's connection, must survive a repository rebuilt on re-login, and every URL builder plus the negotiation have to agree on it or the cap leaks. Same shape as INCLUDE_CATALOG_BROWSE. Two ways in. Settings holds the durable default, persisted to app_settings and restored at startup — unlike the rest of VideoSettings, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to see. The in-player menu is the "this film, this connection" override: a cap is a property of the stream the server is producing, so it cannot apply to one already in flight — player_set_stream_quality re-opens the stream at the new quality and resumes at the current position, reloading the native backend itself and handing HTML5 a URL for the same reloadSource primitive the audio-track switch uses. Tests pin the URL parameters at a capped and an uncapped step, the handoff taking the lower of the two, the ladder's internal consistency (video + audio == cap, resolution descending with bitrate) and the persisted token's round trip. The ceiling is process-wide, so the tests that depend on it serialise on a guard that restores the default. TRACES: UR-074 | DR-160 | UT-156, UT-157 |
||
|
|
8ad3dc5c4f |
fix(android): raise the versionCode floor so 0.5.x can install over v0.5.2
v0.5.2 shipped Android versionCode 5002, from an earlier `minor*1000` scheme.
The `minor*100` formula that replaced it yields only 1502 for that same version,
and 1503 for 0.5.3 — lower than what is already installed, so Android refuses
the update as a downgrade. Every 0.5.x release built from this script was
un-installable for anyone already on v0.5.2.
This is the exact failure the block was written to prevent; its floor simply
went stale. The floor tracked "codes below 1000 are already in the field", which
was true when written, but a 5002 build has shipped since — and the highest code
this formula has *produced* is not the same as the highest code in the field.
Widen the multipliers and raise the floor past 5002:
code = 10000 + major*1000000 + minor*1000 + patch
0.0.14 -> 10014 0.5.2 -> 15002 0.6.0 -> 16000
0.1.0 -> 11000 0.5.3 -> 15003 1.0.0 -> 1010000
Still strictly monotonic across the upgrade sequence. The guard test gains a
case pinning 0.5.3 above the 5002 in the field, so the floor is expressed as
"clears what shipped" rather than a literal that can silently go stale again.
|
||
|
|
9f5f57cba4 |
fix(ui,player): scroll restore, immersive fullscreen, watched toggle, handoff timeline, PiP
Batch of reported bugs and enhancements. UI - Pages no longer inherit the previous page's scroll position (DR-156, UR-072). The shell keeps its scrollers alive across navigation by design, so the element never remounts and its scrollTop survived the route change; SvelteKit restores window scroll, which this app never uses. ScrollMemory records the offset per route and per container: forward moves reset to the top, Back restores where the route was left. - Season header stacks on narrow screens, and the title span gets min-w-0 so it actually truncates instead of overflowing under the action buttons. - Favourites gets a labelled tile at the head of the library grid rather than only an unlabelled heart icon in the header. Playback - Full-screen video on Android hides the system bars (DR-157, UR-066). requestFullscreen() cannot touch the Activity window from inside a WebView, so the control did nothing visible while the bars stayed painted over the video. ImmersiveModeBridge hides them, restored on exit, Escape and teardown. - Background-audio handoff stops leaking its relative timeline (DR-159). background_audio_base was a display-only correction applied in two places while progress reports to Jellyfin, the frontend and media3's own seeks all worked in the relative timeline treating it as absolute — each crossing losing exactly `base` seconds. The conversion now happens once, in the position tick, and inbound seeks resolve through seek_absolute, which re-opens the stream at the requested position because the handoff transcode cannot seek. - Picture-in-picture works on the path that actually plays video (DR-160). canEnterPip demanded a native ExoPlayer surface, but that path is behind a flag defaulting to off, so PiP could never engage. It now accepts the WebView <video> too, keeping the WebView visible and routing play/pause to the element. - Native video is now the default so PiP has a real surface (DR-161). The scrub-regression tests pinned the flag-off path implicitly; they now mock it off explicitly. The native scrub/seek path is not covered by the suite and needs device verification. Watched state - Watched toggle on the episode row, season header, series and movie hero, and the Episode Focus View (DR-158, UR-073). Both backend halves already existed with no caller. storage_set_watched covers a container's episodes so the toggle is honest offline, and QueuedOp::MarkUnplayed gives the sync queue the missing direction. Release - Fix the Android versionCode floor (set-version.sh). v0.5.2 shipped code 5002 under an earlier minor*1000 scheme, but the current minor*100 formula yields 1502 for that version and 1503 for 0.5.3 — so every 0.5.x release built from it was an un-installable downgrade for anyone already on v0.5.2. Widened to 10000 + major*1000000 + minor*1000 + patch (0.5.3 -> 15003). - Bump to 0.5.3. |
||
|
|
50934e2ac6 |
ci(android): ship Gradle in the builder image instead of downloading it
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 10m7s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m28s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
Build & Release / Run Tests (push) Successful in 7m35s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 3m2s
Build & Release / Build Linux (push) Successful in 20m0s
Build & Release / Build Windows (push) Successful in 8m36s
Build & Release / Build Android (push) Successful in 30m30s
Build & Release / Create Release (push) Successful in 21s
The release APK job died at the Gradle wrapper step, after the 11-minute
Rust compile had already succeeded:
Downloading https://services.gradle.org/distributions/gradle-8.14.3-bin.zip
java.net.SocketException: Unexpected end of file from server
`tauri android init` regenerates gen/android with a wrapper pointing at
services.gradle.org, so every Android job re-downloaded ~130MB of Gradle at
build time. That is slow on a good day and a hard build failure when the CDN
drops the connection mid-transfer. It was also a standing violation of the
rule that every build tool must already live in the builder image.
Dockerfile.builder installs Gradle 8.14.3, keeping both the unpacked
distribution (on PATH) and the original zip under /opt/gradle/dist. A
`gradle --version` smoke-test fails the image build on a bad version rather
than letting CI discover it.
sync-android-sources.sh then repoints the regenerated wrapper at that local
zip, which is the established place for fixing up the generated project.
It parses the version the wrapper actually requests, so a future Tauri Gradle
bump logs "not in image, will download" instead of pointing at a missing
file. On dev machines /opt/gradle/dist does not exist and the properties file
is left untouched.
Verified by running the project's own wrapper jar inside a network namespace
with no connectivity: it resolved and unpacked the local zip to 100% and
proceeded into build-script evaluation.
Note: this is inert until the builder image is rebuilt and pushed
(scripts/build-builder-image.sh).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
8fbc080733 |
Merge branch 'fix/android-resume-position'
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 8m2s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m34s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 2m58s
Build & Release / Run Tests (push) Successful in 8m22s
Build & Release / Build Linux (push) Successful in 19m40s
Build & Release / Build Windows (push) Successful in 8m6s
Build & Release / Build Android (push) Failing after 13m33s
Build & Release / Create Release (push) Skipped
Resume-playback fixes across the three layers where the position was lost: - DR-150 path: the Android native (ExoPlayer) surface never applied the resume seek, so resume always played from the start on device. - DR-154: a stop-report the server could not be told about was logged and dropped, even though sync_queue and its drain were built and running. - DR-155: the server's watch position was never mirrored into the local user_data row the resume check reads, so resume never crossed devices. Also carries concurrent fixes merged in from parallel work: download bitrate, series resume ordering, Recently Added grouping, remote-session volume handoff, and home library card heights. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ba5fd55204 |
fix(sync): mirror the server's watch position so resume crosses devices (DR-155)
The resume check reads the local user_data row and nothing else, but mirror_user_data -- the only path by which server UserData lands in that table -- mirrored is_favorite alone, and returned early whenever that field was absent, which is exactly the shape of an ordinary watched episode. playback_position_ticks was therefore write-only from this device's perspective: watch 40 minutes in a browser, open JellyTau, and it resumed from whatever this device last saw, or offered no resume at all. Same user-visible symptom as the Android bug fixed earlier on this branch, from an unrelated cause -- which is why resume read as broadly flaky rather than as one defect. The mirror now carries the position alongside the favourite flag under the same pending_sync = 0 conflict rule, so a local position still waiting to be pushed is never pulled backwards by a server that has not yet heard where we got to. COALESCE(excluded.x, user_data.x) keeps the stored value for a field the server omitted rather than nulling it, and a row with neither field is still skipped rather than fabricated as zeroes. Mirroring alone was not sufficient. get_item -- the call the player route makes -- returned the cached copy on a hit and never consulted the server, so for an already-cached item the mirror never ran. It now refreshes in the background on a cache hit via race_with_refresh, the reusable form of what get_items already did inline. That asymmetry is why browsing a season picked up other devices' state while opening the episode directly did not. The refreshed value lands for the next read; the cache-first race still answers immediately. The DR/total counts in extract-traces.test.ts are updated for DR-154 and DR-155 -- that edit is the test's intended signal that the CI gate's denominator is live rather than frozen. Verified red->green in the jellytau-builder image: both new tests failed before the fix. Full Rust suite passes (634), cargo fmt clean, clippy adds no new warnings; frontend suite (933) and svelte-check clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fec4b7ae8c | Merge branch 'fix/download-bitrate-param' into HEAD | ||
|
|
2ca2174cea |
fix(player): return volume control to the local speaker when a remote session stops
Stopping a remote session left Android stuck on the remote volume slider with no way back to the device speaker. Two causes: 1. `player_stop`'s remote branch sent "Stop" to the session and returned without touching the playback mode, so the manager stayed in Remote. It now drops to Idle, mirroring what the local branch already does. 2. Volume routing was torn down at a single call site (`transfer_to_local_inner`), so every *other* exit from remote mode leaked the Android VolumeProviderCompat. Routing is now derived from the transition inside `set_mode`: entering remote attaches control, any exit from remote hands it back to the local media stream. This also covers the frontend `disconnect()` path (Remote -> Idle) and the local-playback-start paths (Remote -> Local). Adds a `RemoteVolumeControl` trait so the routing rule is unit-testable off-device — the real implementation is Android JNI. Tests cover remote->idle, remote->local, remote->remote (re-arms, never releases), and that local/idle transitions leave routing untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0ca2857c3a |
fix(catalog): show a new album once in Recently Added, not once per track
Recently Added listed every newly-added track individually, so importing a 14-track album filled the whole row with that one album and buried everything else. Both code paths that build the row had the same symptom from separate causes: - Online: Jellyfin's /Items/Latest defaults to GroupItems=false, returning each new leaf on its own. Send GroupItems=true so the server collapses children into the container that was added. - Offline: the downloaded-items CTE deliberately matches leaves *and* their container (right for browsing, wrong here), so a downloaded album returned the album plus each of its tracks. Drop a leaf only when its own container is in the same result. Items with no container (movies, standalone tracks) are unaffected in both paths. The online URL is extracted into build_latest_items_endpoint so it can be asserted without an HTTP server, matching build_favorites_endpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1f32e4040b |
Merge branch 'fix/home-library-card-heights' from origin
Local and remote had both advanced two commits from
|
||
|
|
e4632bb2b2 |
fix(sync): queue a watch position the server could not be told about (DR-154)
sync_queue and its drain (DR-131) were built, tested and running, but the
stop-report path never fed them, so closing a video while the server was
unreachable lost the resume point outright.
HybridRepository::report_playback_stopped is a bare pass-through to the
online repository ("Playback reporting goes directly to server"), and on
failure the error surfaced to a frontend catch whose own comment read
"Server error - could queue, but for now just log". Both producers that
would have queued it -- PlaybackReporter::queue_for_sync in Rust and
syncService.queuePlaybackProgress on the frontend -- have no callers on
the playback path. user_data.pending_sync was dutifully set to 1, but
nothing drains that flag for positions the way favourites do (DR-120).
The command layer now enqueues a report_playback_stopped row whenever the
push fails; the existing drain already parses and replays that operation.
The pending row for an item is superseded in place rather than appended
to: progress is reported every 10s, so a server that stays down would
otherwise add a row per tick, all obsoleted by the newest -- the
unbounded queue DR-131 exists to prevent. Only pending/failed rows are
superseded, since reviving an abandoned row restores that same growing
counter. Queueing is best-effort and never fails the command: the local
position is already saved, so a failed queue write must not be reported
as a lost position.
Verified red->green in the jellytau-builder image: the four new tests
failed to compile (enqueue_playback_stopped not found) before the fix.
Full Rust suite passes (627 tests), cargo fmt clean, clippy adds no new
warnings; frontend suite (933) and svelte-check also clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
2d50744320 |
fix(player): resume at saved position on the Android native path
The native (ExoPlayer) video path never applied the resume position, so "resume from where you left off" always played from the start on Android. Two layers each assumed the other did the seek: - The only code acting on `initialPosition` was handleCanPlay, an HTML5 <video> event handler. The native path has no <video> element, so `canplay` never fires and that seek never ran. - NativePlayerAdapter.load() had an initialPosition branch, but it only recorded the number, claiming "the native backend performs the actual seek internally". It does not: PlayItemRequest carries no start position, and loadWithMetadata -> prepare() always starts ExoPlayer at 0. - VideoPlayer never called adapter.load() at all, so even that branch was unreachable. The frontend therefore believed it had resumed (the seek bar showed the resume point) while ExoPlayer played from the beginning. NativePlayerAdapter.load() now issues the backend seek, excluding live streams (no resume point; seeking knocks the HLS window off its live edge). VideoPlayer calls it on the native branch and marks the initial seek as performed so the existing $effect does not fire a duplicate. The HTML5 path is untouched: seeking before metadata is clamped to 0, which is exactly what handleCanPlay waits for. Verified red->green: the new test failed with "Number of calls: 0" before the fix. Full frontend suite passes (933 tests); svelte-check and check:boundary are clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
adc460f35d |
fix(downloads): honor the selected bitrate (videoBitRate, capital R)
Downloading at a specific quality silently returned the full-size original. The download URL builder spelled the transcode params `videoBitrate`/`audioBitrate`, but Jellyfin binds `videoBitRate`/ `audioBitRate` — with a capital R. Query-key binding is case-insensitive, so this is not a casing preference: the lowercase-r form is a different token that fails to bind. The server discards it without error and then stream-copies the source, so picking "480p" produced an original-quality file with no failure surfaced anywhere. `maxHeight`/`videoCodec` were unaffected (case-insensitive binding covers them), which is why the height cap applied while the bitrate cap vanished. Also set `allowVideoStreamCopy=false` on the transcode presets to force a real re-encode. Video stream-copy is gated by `allowVideoStreamCopy`, not `enableAutoStreamCopy` — the latter governs audio only. `original` is unchanged: it stays a deliberate direct static copy, now pinned by a test. The pre-existing unit tests asserted the broken lowercase-r spellings, so they passed against broken code; corrected. Verified red -> green by extracting the pre-fix and post-fix builder bodies into an isolated harness: 15 assertion failures before, 0 after. TRACES: UR-071 | DR-123 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9d7cb085e9 |
fix(series): resume after the furthest-watched episode, not the first gap
`pick_current_episode` rung 3 returned the first unwatched episode in series order. A viewer who skipped the pilot but is three seasons deep was sent back to S1E1: the gap was a deliberate skip, not the place they stopped. This read as flaky rather than consistently wrong because rung 3 only fires when the server's Next Up (rung 2) yields nothing, and `resolve_current_episode` swallows that call's errors with `.unwrap_or_default()`. `HybridRepository::get_next_up_episodes` delegates unconditionally to the online repo, so any unreachable-server moment silently degraded to the empty vec — same series, same watch state, different answer depending on one request's outcome. Rung 3 now scans the ordered list from the end with `rposition(is_played)` and returns the episode after the furthest-watched one, falling back to the previous first-unwatched behaviour when nothing is watched or the series is finished. Season crossing comes free from the already-flat series ordering, and `season_rank` keeps specials last so a watched special cannot mark a show finished. Tests written first and confirmed failing (S1E1 where S3E4 was expected), covering the skipped-pilot case, rolling into the next season past a skipped episode, and the watched-special case. All 17 existing tests still pass. Note: cargo test could not run locally (javascriptcoregtk-4.1 / webkit2gtk-4.1 absent on this host). The pure policy half plus its verbatim test module were extracted into a standalone crate to get real red/green; the full crate suite still needs a run on a complete toolchain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
85bd227714 |
fix(home): uniform card heights in the Your Libraries row
MediaCard derives its artwork aspect ratio from the item, so a music library rendered aspect-square (144px tall at w-36) next to video libraries at aspect-video (81px), leaving the home row ragged. Add an optional `aspect` prop that overrides the derived ratio, and pass aspect="video" from the home Libraries strip. Unset, behaviour is unchanged, so the /library overview grid and the media carousels keep their per-type ratios. Artwork already uses object-cover, so square music art crops rather than distorts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3619f71aba |
build: make the git tag the single source of truth for the version (DR-153)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 6m55s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m21s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 7m36s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 2m57s
Build & Release / Build Linux (push) Successful in 20m4s
Build & Release / Build Windows (push) Successful in 8m42s
Build & Release / Build Android (push) Successful in 30m30s
Build & Release / Create Release (push) Successful in 17s
The version lived in four files — package.json, tauri.conf.json, Cargo.toml and
Cargo.lock — that had to be hand-edited in lockstep, and the release workflow
rewrote exactly one of them. A tagged build therefore produced an installer
named for the tag wrapped around package metadata naming the previous release,
and the Linux job, which had no version step at all, shipped whatever happened
to be committed.
scripts/set-version.sh now writes all four from one argument and is the only
thing that does. Every release job calls it with the tag, including the Linux
job that was missing one. The committed versions become a placeholder for dev
builds rather than something to maintain by hand.
The Android versionCode moves into the same script, unchanged in formula
(1000 + major*10000 + minor*100 + patch). It stays inline-documented because the
reasoning is not obvious: builds already in the field shipped code 1000, and
Android refuses an update whose code is lower than the installed one, so a
formula that can emit a smaller number for a newer release bricks updates
irreversibly. UT-150 asserts that property directly — monotonic across an
upgrade sequence, and always above the floor.
Two edge cases the previous inline version got wrong:
- A prerelease tag (v0.6.0-rc1) made $(( 0-rc1 )) abort the step under set -e.
The suffix is stripped before the arithmetic; the manifests keep it.
- CI passes "${GITHUB_REF#refs/tags/}" unconditionally, which on a branch build
is still a full ref. That reached the validator verbatim and would have failed
every untagged Android build; a non-tag ref now falls back to git describe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
8e081845d0 |
Merge pull request 'Feat/android native video' (#13) from feat/android-native-video into master
Reviewed-on: #13 |
||
|
|
5fa74d9e34 |
docs: renumber to DR-150/151/152 after rebase onto master
master landed DR-148 and DR-149 for unrelated audio-decode work (0.4.7/0.4.8)
while this branch was in flight, and both sides claimed the same two IDs. The
native-video requirements move to DR-150 (native rendering behind the flag),
DR-151 (the severed SurfaceView attach chain) and DR-152 (capabilities reported
by Rust). UT-090 was likewise already taken by the seek-bar test, so the adapter
selection test moves to UT-149 and is registered in the table.
The spec header also cited DR-023/DR-024, which are the subtitle and audio-track
selection UI requirements — unrelated to this work. Corrected, with a note so the
wrong IDs are not reintroduced from the draft.
extract-traces.test.ts asserts the live requirement counts on purpose, so adding
three DRs moves DR 144→147 and total 282→285.
Subtitles on the native path are not a regression from this branch: master's
|
||
|
|
c480276a97 |
docs(spec): native video confirmed working on device
The spike's central question — can a SurfaceView be composited behind a transparent Tauri WebView on Android — is answered yes, verified on a physical device. No upstream issue blocked it and none demonstrated it; this appears to be the first working instance. Marks DR-148 done behind the flag and records what is confirmed versus what is still open: playback and positioning are verified, but the individual native controls (seek, audio-track, subtitle), the mini-player transition, and the MediaCodec hardware-decode claim are not yet each measured. The mini-player transition is called out as the known gap, since it is the one case where the fullscreen assumption behind "no rect plumbing needed" does not hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ca490c34ec |
docs(traceability): regenerate matrix for the native-video requirements
DR-148/149/150 now resolve; coverage 86% (243/283), no orphans. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e144e62b31 |
feat(player): render Android video natively behind a transparent webview (DR-150, DR-151, DR-152)
Rust already reported `use_html5_element: false` on Android, but two frontend overrides threw that answer away, so ExoPlayer's video path had never actually run. Both are lifted behind an `experimentalNativeVideo` opt-in (default off). The flag is a suppressor, never a promoter: off forces HTML5 even where Rust says native, so an in-progress spike cannot ship as the default, but it can never select native where Rust reported HTML5 — Linux cannot composite behind WebKitGTK, and promoting there would be a black screen. Two blockers the spec did not anticipate, both in code assumed to be merely unreachable rather than broken: - `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was always null and `autoAttachSurface()` bailed. The SurfaceView was created and wired to ExoPlayer but never added to the view hierarchy — video would have decoded to a surface that was never on screen, whatever the webview did. This also revives PiP on the video path, which gated on the same flag. - `createAdapter()` was not the real gate; it is never called in production. The actual override was in VideoPlayer.svelte, which forced HTML5 and stopped the native backend `player_play_item` had just started. Both sites now route through `createAdapter()`. Compositing needs two independent opaque layers cleared, not one. Clearing only the page leaves the WebView widget opaque — audio over a black picture, exactly the symptom the old INTERIM comment described. `videoSurface.ts` toggles both: the widget background and window drawable from Kotlin, the page backgrounds via a `data-native-video` attribute keyed by app.css. Transparency lives in `tauri.android.conf.json` so Linux keeps an opaque window, and is scoped to the playback session so the launcher never shows through the rest of the app. Phase 3's rect plumbing turned out to be unnecessary: video is fullscreen on the player route, and `fitSurfaceToScreen()` already letterboxes and re-fits on rotation. The mini-player transition remains unverified on device. Also removes the `navigator.userAgent` sniffing in webviewAudio.ts, which was a second copy of the Rust cfg gate free to drift from it. `player_get_capabilities` now reports `usesWebviewAudio` and `supportsNativeVideo` from those same gates. Tests: adapter selection covers the full matrix, including the regression guard that the flag off beats Rust. Written first and confirmed failing (2 of 7) before the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
07d10dfed7 |
docs(traceability): land the DR-149 requirement rows and settle a UT collision
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 19m31s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m28s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 6m47s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m6s
Build & Release / Build Linux (push) Successful in 19m57s
Build & Release / Build Windows (push) Successful in 14m14s
Build & Release / Build Android (push) Successful in 30m26s
Build & Release / Create Release (push) Successful in 19s
The DR-149 row lost an index race with a parallel session's edit of the same file, so the previous commit carried the count assertion (DR 144, total 282) without the requirement it counts — a clean checkout of that commit failed `bun run test` against its own requirements.md. The parallel session also reached UT-143 and UT-147 for subtitle work, which collided with the UT-143 used for the client-side transcode tests. Those move to UT-148, in the table and in the device_profile TRACES comments, so no two requirements share an ID. |
||
|
|
acddcdd6fa |
fix(playback): force a transcode when the webview cannot decode the audio (DR-149, 0.4.8)
Advertising a webview-shaped profile (DR-148) was necessary but not sufficient. Probing the server directly showed Jellyfin 10.11.5 enforces a DirectPlayProfile's Container and VideoCodec — excluding either returns SupportsDirectPlay:false with TranscodeReasons=ContainerNotSupported / VideoCodecNotSupported — but ignores its AudioCodec entirely: an E-AC-3 track is still offered for direct play against a profile listing only aac,flac,mp3,opus,vorbis. Neither a VideoAudio CodecProfile forbidding the codec nor MaxAudioChannels:2 against a 6-channel track changes the answer, so no profile the client can send fixes this and the picture plays silent. The client therefore stops delegating a question it can answer itself. The negotiated source's audio is checked against what the webview decodes, and an undecodable track forces the existing h264/aac HLS transcode regardless of the server calling direct play fine; direct_play and needs_transcoding are corrected to match so the frontend and the reporting path agree with the URL actually used. The track judged is the one that would be served — the default, else the first — since a supported track further down is not the one that plays. A source with no audio, or a codec the server did not name, is left alone rather than transcoded on a guess. Test-first: the new tests failed against the old behaviour before the decision existed. Verified on a motorola edge 30 by the audio HAL, not by ear — the same E-AC-3 episode logged isMusicActive=true once and 58 ACDB-LOADER lines under this build, against 0 and 0 on 0.4.6, where an AAC file in the same session produced 16 and 116. No FATAL EXCEPTION, so R8 on the signed release build is unaffected. Also carries in-flight subtitle-track work authored in a parallel session (subtitleTracks, VideoPlayer, player/media, bindings) at the user's request, so the tag matches the APK verified on device. |