Compare commits
13
Commits
v0.8.2
..
ae26d5356a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae26d5356a | ||
|
|
b025ed05f2 | ||
|
|
2de91ae76c | ||
|
|
35157a6c59 | ||
|
|
3b55810a0e | ||
|
|
bf72f9869a | ||
|
|
4567c63797 | ||
|
|
46a5219f8e | ||
|
|
1518d92ef4 | ||
|
|
662cb3cd85 | ||
|
|
d54d8cc7c4 | ||
|
|
4c82a0a025 | ||
|
|
51d914777a |
@@ -16,6 +16,12 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Run Tests
|
name: Run Tests
|
||||||
|
# A release push triggers build-release.yml on the tag, which runs this exact
|
||||||
|
# test suite itself — and on a single-slot runner the two ~1h workflows would
|
||||||
|
# otherwise serialize/contend. Skip the duplicate for chore(release) commits.
|
||||||
|
# (head_commit is absent on pull_request/workflow_dispatch; startsWith(null,…)
|
||||||
|
# is false there, so those events still run.)
|
||||||
|
if: "!startsWith(github.event.head_commit.message, 'chore(release)')"
|
||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# ⚠️ Key must NOT collide with the test job's `cargo-host` key: the test
|
||||||
|
# job runs first and saves debug/clippy artifacts under its key, and
|
||||||
|
# actions/cache skips saving on an exact-key hit — so a shared key meant
|
||||||
|
# this job's *release* artifacts were never cached and every Linux release
|
||||||
|
# build compiled cold (~31min vs ~9min for the correctly-keyed Windows job).
|
||||||
- name: Cache Rust dependencies
|
- name: Cache Rust dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
@@ -95,9 +100,9 @@ jobs:
|
|||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
~/.cargo/git
|
~/.cargo/git
|
||||||
src-tauri/target
|
src-tauri/target
|
||||||
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
|
key: ${{ runner.os }}-cargo-linux-release-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-host-
|
${{ runner.os }}-cargo-linux-release-
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
- name: Cache Node dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
@@ -220,9 +225,13 @@ jobs:
|
|||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
~/.cargo/git
|
~/.cargo/git
|
||||||
src-tauri/target
|
src-tauri/target
|
||||||
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
# `-release` suffix keeps this distinct from build-and-test.yml's
|
||||||
|
# android-check key, whose `cargo check` artifacts would otherwise
|
||||||
|
# claim the key first and block this job's release cache from ever
|
||||||
|
# being saved (same collision as the Linux job above).
|
||||||
|
key: ${{ runner.os }}-cargo-android-release-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-android-
|
${{ runner.os }}-cargo-android-release-
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
- name: Cache Node dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ canonical, maintained source; this file only summarizes. See
|
|||||||
| [09-security.md](docs/architecture/09-security.md) | Token storage, secure storage, network security |
|
| [09-security.md](docs/architecture/09-security.md) | Token storage, secure storage, network security |
|
||||||
|
|
||||||
Release process lives in [docs/release-checklist.md](docs/release-checklist.md)
|
Release process lives in [docs/release-checklist.md](docs/release-checklist.md)
|
||||||
and [docs/build-release.md](docs/build-release.md).
|
and [docs/build/build-release.md](docs/build/build-release.md).
|
||||||
|
|
||||||
### Core principles (from the architecture docs)
|
### Core principles (from the architecture docs)
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ For the full set of build, test, and Android helper scripts, see
|
|||||||
|-------|----------|
|
|-------|----------|
|
||||||
| Architecture overview & subsystem docs | [docs/architecture/](docs/architecture/) |
|
| Architecture overview & subsystem docs | [docs/architecture/](docs/architecture/) |
|
||||||
| Requirements, traceability & technical debt | [docs/requirements.md](docs/requirements.md) |
|
| Requirements, traceability & technical debt | [docs/requirements.md](docs/requirements.md) |
|
||||||
| Build & release process | [docs/build-release.md](docs/build-release.md) |
|
| Build & release process | [docs/build/build-release.md](docs/build/build-release.md) |
|
||||||
| Docker builds | [docs/build/docker.md](docs/build/docker.md) |
|
| Docker builds | [docs/build/docker.md](docs/build/docker.md) |
|
||||||
| Traceability tooling & CI | [docs/traceability.md](docs/traceability.md), [docs/traceability-ci.md](docs/traceability-ci.md) |
|
| Traceability tooling & CI | [docs/traceability.md](docs/traceability.md), [docs/traceability-ci.md](docs/traceability-ci.md) |
|
||||||
| Release checklist | [docs/release-checklist.md](docs/release-checklist.md) |
|
| Release checklist | [docs/release-checklist.md](docs/release-checklist.md) |
|
||||||
|
|||||||
+47
-2
@@ -22,15 +22,60 @@
|
|||||||
- [Database Design](architecture/08-database-design.md)
|
- [Database Design](architecture/08-database-design.md)
|
||||||
- [Security](architecture/09-security.md)
|
- [Security](architecture/09-security.md)
|
||||||
|
|
||||||
# UX & Specs
|
# UX
|
||||||
|
|
||||||
- [UX Flows](ux-flows.md)
|
- [UX Flows](ux-flows.md)
|
||||||
|
|
||||||
|
# Specs — Writing One
|
||||||
|
|
||||||
|
- [Spec Template](specs/SPEC-TEMPLATE.md)
|
||||||
|
- [Spec Review Checklist](specs/SPEC-REVIEW-CHECKLIST.md)
|
||||||
|
|
||||||
|
# Specs — Playback & Player
|
||||||
|
|
||||||
|
- [Playback Backend Unification](specs/playback-backend-unification.md)
|
||||||
|
- [Player Facade Enforcement](specs/player-facade-enforcement.md)
|
||||||
|
- [Playback Documentation Corrections](specs/playback-docs-corrections.md)
|
||||||
- [Video Background Audio](specs/video-background-audio.md)
|
- [Video Background Audio](specs/video-background-audio.md)
|
||||||
|
- [Android Native Video Spike](specs/android-native-video-spike.md)
|
||||||
|
- [Android Audio Settings Parity](specs/android-audio-settings-parity.md)
|
||||||
|
- [Audio Equalizer](specs/audio-equalizer.md)
|
||||||
|
- [Windows Native Audio Backend](specs/windows-native-audio-backend.md)
|
||||||
|
- [libmpv2 Migration](specs/libmpv2-migration.md)
|
||||||
|
- [Streaming Bitrate Cap](specs/streaming-bitrate-cap.md)
|
||||||
|
- [Read-Through Media Cache](specs/read-through-media-cache.md)
|
||||||
|
|
||||||
|
# Specs — Library & Browsing
|
||||||
|
|
||||||
|
- [Scoped Search](specs/scoped-search.md)
|
||||||
|
- [Scoped Search Boundary](specs/scoped-search-boundary.md)
|
||||||
|
- [Scoped Search Boundary — Implementation](specs/scoped-search-boundary-implementation.md)
|
||||||
|
- [Locally-Indexed Search](specs/catalog-index-search.md)
|
||||||
|
- [Favourites Browsing](specs/favorites-browsing.md)
|
||||||
|
- [Library Mosaic](specs/library-mosaic.md)
|
||||||
|
- [Series Current-Episode Navigation](specs/series-current-episode-navigation.md)
|
||||||
|
- [Account Menu](specs/account-menu.md)
|
||||||
|
- [Frontend Domain Model](specs/frontend-domain-model.md)
|
||||||
|
|
||||||
|
# Specs — Downloads & Offline
|
||||||
|
|
||||||
|
- [Downloads as an Offline Library](specs/downloads-as-offline-library.md)
|
||||||
|
- [Offline Downloaded-Only Filter](specs/offline-downloaded-only-filter.md)
|
||||||
|
|
||||||
|
# Specs — Tooling & Build
|
||||||
|
|
||||||
|
- [Traceability Gate Repair](specs/traceability-gate-repair.md)
|
||||||
|
- [Boundary Tripwire Hardening](specs/boundary-tripwire-hardening.md)
|
||||||
|
- [Requirement-Coverage Script Removal](specs/req-coverage-script-removal.md)
|
||||||
|
- [Build Provenance](specs/build-provenance.md)
|
||||||
|
|
||||||
# Build & Release
|
# Build & Release
|
||||||
|
|
||||||
- [Build & Release](build-release.md)
|
- [Build & Release](build/build-release.md)
|
||||||
- [Release Checklist](release-checklist.md)
|
- [Release Checklist](release-checklist.md)
|
||||||
|
- [Desktop Packaging](build/build-desktop-packages.md)
|
||||||
|
- [Windows Build](build/build-windows.md)
|
||||||
|
- [Defect Windows](defect-windows.md)
|
||||||
- [Docker](build/docker.md)
|
- [Docker](build/docker.md)
|
||||||
- [Builder Image](build/build-builder-image.md)
|
- [Builder Image](build/build-builder-image.md)
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ run in Docker so no host toolchain setup is required. Outputs land in `./dist`.
|
|||||||
## One builder image (shared with CI)
|
## One builder image (shared with CI)
|
||||||
|
|
||||||
The deb/rpm and Windows-cross flows build on the **unified registry builder**
|
The deb/rpm and Windows-cross flows build on the **unified registry builder**
|
||||||
([../Dockerfile.builder](../Dockerfile.builder) →
|
([../Dockerfile.builder](../../Dockerfile.builder) →
|
||||||
`gitea.tourolle.paris/dtourolle/jellytau-builder`), the same image CI uses. It
|
`gitea.tourolle.paris/dtourolle/jellytau-builder`), the same image CI uses. It
|
||||||
carries every packaging tool: Android SDK/NDK, `rpm`/`file` (Linux bundler),
|
carries every packaging tool: Android SDK/NDK, `rpm`/`file` (Linux bundler),
|
||||||
`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` rust target
|
`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` rust target
|
||||||
(Windows). There is **one** dependency source of truth — no per-stage tool
|
(Windows). There is **one** dependency source of truth — no per-stage tool
|
||||||
installs.
|
installs.
|
||||||
|
|
||||||
The desktop stages in [../Dockerfile](../Dockerfile) are thin `FROM
|
The desktop stages in [../Dockerfile](../../Dockerfile) are thin `FROM
|
||||||
${BUILDER_IMAGE}` environments; the actual build runs at container-run time on
|
${BUILDER_IMAGE}` environments; the actual build runs at container-run time on
|
||||||
your bind-mounted source (like the `dev` service), so source edits need no image
|
your bind-mounted source (like the `dev` service), so source edits need no image
|
||||||
rebuild.
|
rebuild.
|
||||||
@@ -28,7 +28,7 @@ docker build -f Dockerfile.builder -t jellytau-builder:latest .
|
|||||||
BUILDER_IMAGE=jellytau-builder:latest bun run docker:build:windows
|
BUILDER_IMAGE=jellytau-builder:latest bun run docker:build:windows
|
||||||
```
|
```
|
||||||
|
|
||||||
Arch uses a separate `archlinux` image ([../Dockerfile.arch](../Dockerfile.arch))
|
Arch uses a separate `archlinux` image ([../Dockerfile.arch](../../Dockerfile.arch))
|
||||||
because `makepkg` is Arch-specific — it is not part of the unified builder.
|
because `makepkg` is Arch-specific — it is not part of the unified builder.
|
||||||
|
|
||||||
| Target | Format | Docker command | Functional? |
|
| Target | Format | Docker command | Functional? |
|
||||||
@@ -40,7 +40,7 @@ because `makepkg` is Arch-specific — it is not part of the unified builder.
|
|||||||
## Linux: deb + rpm
|
## Linux: deb + rpm
|
||||||
|
|
||||||
Tauri's bundler produces these natively. The build runs on the existing Ubuntu
|
Tauri's bundler produces these natively. The build runs on the existing Ubuntu
|
||||||
builder image ([../Dockerfile](../Dockerfile), `desktop-linux-build` stage):
|
builder image ([../Dockerfile](../../Dockerfile), `desktop-linux-build` stage):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run docker:build:linux # deb + rpm -> ./dist
|
bun run docker:build:linux # deb + rpm -> ./dist
|
||||||
@@ -58,8 +58,8 @@ transcoded video). The deb/rpm declare these.
|
|||||||
|
|
||||||
**Tauri has no `pacman` bundle target** (as of tauri-cli 2.9.x — valid targets
|
**Tauri has no `pacman` bundle target** (as of tauri-cli 2.9.x — valid targets
|
||||||
are deb/rpm/appimage/msi/nsis/app/dmg). So we ship a hand-written PKGBUILD in
|
are deb/rpm/appimage/msi/nsis/app/dmg). So we ship a hand-written PKGBUILD in
|
||||||
[../packaging/arch/PKGBUILD](../packaging/arch/PKGBUILD) and build it with
|
[../packaging/arch/PKGBUILD](../../packaging/arch/PKGBUILD) and build it with
|
||||||
`makepkg` on an Arch base image ([../Dockerfile.arch](../Dockerfile.arch)):
|
`makepkg` on an Arch base image ([../Dockerfile.arch](../../Dockerfile.arch)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run docker:build:arch # .pkg.tar.zst -> ./dist
|
bun run docker:build:arch # .pkg.tar.zst -> ./dist
|
||||||
+2
-2
@@ -294,8 +294,8 @@ bun run tauri build # Local build test
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
1. Update [CHANGELOG.md](../CHANGELOG.md) with changes
|
1. Update [CHANGELOG.md](../../CHANGELOG.md) with changes
|
||||||
2. Update [README.md](../README.md) with new features
|
2. Update [README.md](../../README.md) with new features
|
||||||
3. Document breaking changes
|
3. Document breaking changes
|
||||||
4. Add migration guide if needed
|
4. Add migration guide if needed
|
||||||
|
|
||||||
+3
-3
@@ -12,10 +12,10 @@ job / SMTC lockscreen), but it runs and plays media.
|
|||||||
h264 fine. No Windows-specific code.
|
h264 fine. No Windows-specific code.
|
||||||
- **Audio-only (music)** — the native audio backends are libmpv (Linux) and
|
- **Audio-only (music)** — the native audio backends are libmpv (Linux) and
|
||||||
ExoPlayer (Android); neither exists on Windows. Instead
|
ExoPlayer (Android); neither exists on Windows. Instead
|
||||||
`create_player_backend()` in [../src-tauri/src/lib.rs](../src-tauri/src/lib.rs)
|
`create_player_backend()` in [../src-tauri/src/lib.rs](../../src-tauri/src/lib.rs)
|
||||||
uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream
|
uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream
|
||||||
URL to a webview `<audio>` element (see
|
URL to a webview `<audio>` element (see
|
||||||
[../src/lib/services/webviewAudio.ts](../src/lib/services/webviewAudio.ts)),
|
[../src/lib/services/webviewAudio.ts](../../src/lib/services/webviewAudio.ts)),
|
||||||
which reports state back through the same `player_report_*` round-trip the video
|
which reports state back through the same `player_report_*` round-trip the video
|
||||||
path uses. Pure Rust + Tauri events.
|
path uses. Pure Rust + Tauri events.
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ Tauri CLI bundle the **NSIS installer from a Linux host**.
|
|||||||
> `--runner cargo-xwin --target x86_64-pc-windows-msvc` is what flips it into
|
> `--runner cargo-xwin --target x86_64-pc-windows-msvc` is what flips it into
|
||||||
> Windows mode and enables the `nsis`/`msi` bundlers on Linux.
|
> Windows mode and enables the `nsis`/`msi` bundlers on Linux.
|
||||||
|
|
||||||
The builder image ([../Dockerfile.builder](../Dockerfile.builder)) bakes in the
|
The builder image ([../Dockerfile.builder](../../Dockerfile.builder)) bakes in the
|
||||||
whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`,
|
whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`,
|
||||||
`llvm`, and `nsis`.
|
`llvm`, and `nsis`.
|
||||||
|
|
||||||
@@ -1,532 +0,0 @@
|
|||||||
# JellyTau Codebase Audit
|
|
||||||
|
|
||||||
**Date:** 2026-08-16 · **Version:** v0.6.0 · **Commit:** `be907b49` (master)
|
|
||||||
|
|
||||||
A review of the Rust/Svelte/Android codebase against its own requirements matrix
|
|
||||||
and against current Android and Tauri v2 platform practice. Every finding was
|
|
||||||
verified by running the project's own tooling or reading the code it points at —
|
|
||||||
nothing here is inferred from documentation alone.
|
|
||||||
|
|
||||||
**Scale:** 55,835 LOC Rust · 50,490 LOC TS/Svelte · 530 requirements · 824 traces
|
|
||||||
|
|
||||||
| Severity | Count |
|
|
||||||
|----------|-------|
|
|
||||||
| High | 5 |
|
|
||||||
| Medium | 9 |
|
|
||||||
| Low | 6 |
|
|
||||||
| Tests passing | 1,719 |
|
|
||||||
| Untraced requirements | 86 |
|
|
||||||
| Traceability coverage | 86% (285/330) |
|
|
||||||
|
|
||||||
> **Revisions, 2026-08-16.** Three rankings changed after device testing and
|
|
||||||
> platform research, all documented in place:
|
|
||||||
> - **B1 High → Low.** The predicted impact was refuted on a physical Android 16
|
|
||||||
> device. The residual risk turned out to be a different, narrower one.
|
|
||||||
> - **B7 Low → Medium, re-framed.** The original reading of predictive back was
|
|
||||||
> backwards: at targetSdk 36 it is already enabled, not merely un-opted-into.
|
|
||||||
> - **B8 added (Medium).** Android 16 Local Network Protections versus a
|
|
||||||
> LAN-hosted Jellyfin server.
|
|
||||||
> - **D3 Medium → Low.** The "820 unwraps" figure was a measurement error; the
|
|
||||||
> real number is 19, and none are in command handlers.
|
|
||||||
> - **B1's stated mechanism was wrong** even though its conclusion held. FGS
|
|
||||||
> notifications are *not* exempt from `POST_NOTIFICATIONS`; media-session
|
|
||||||
> notifications are. See B1 — the distinction changes what the fix should be.
|
|
||||||
>
|
|
||||||
> Original ranking was 6 High / 8 Medium / 5 Low.
|
|
||||||
|
|
||||||
**Verified by running:** `bun run check` · `bun run test` · `cargo test` ·
|
|
||||||
`cargo clippy --all-targets` · `bun run check:boundary` · `bun run traces:json`
|
|
||||||
|
|
||||||
**Device-verified (2026-08-16):** B1 and B2 were checked against a physical HONOR
|
|
||||||
ROD2-W09 running Android 16 (SDK 36) with the shipped app installed. B2 was
|
|
||||||
confirmed; B1 was refuted and downgraded.
|
|
||||||
|
|
||||||
**Not covered:** the e2e suite (`test:e2e` is not wired into CI and was not run),
|
|
||||||
Windows and Arch packaging paths, and the docs-site build. B3, C1 and C2 still
|
|
||||||
need a device/desktop playback pass.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. Requirements versus code
|
|
||||||
|
|
||||||
The traceability matrix is the project's own claim about what is built. Of 530
|
|
||||||
defined requirement IDs, 86 carry no `TRACES:` tag anywhere in the tree. Most of
|
|
||||||
those gaps are documentation debt rather than missing features — which is
|
|
||||||
precisely the problem, because it makes the matrix unreliable as evidence.
|
|
||||||
|
|
||||||
### A1 · High · Twelve requirements are marked "Done" but have zero traces
|
|
||||||
|
|
||||||
`UR-006` (lockscreen/BLE control), `UR-037` (video library presentation),
|
|
||||||
`IR-006` (Android MediaSession), `IR-008` (audio focus), `IR-022` (person/cast
|
|
||||||
API), `IR-024` (home-screen API) and six Jellyfin API requirements (`JA-006`,
|
|
||||||
`JA-009`, `JA-013`, `JA-014`, `JA-015`, `JA-018`) all claim completion with
|
|
||||||
nothing pointing at an implementation.
|
|
||||||
|
|
||||||
These features demonstrably work — lockscreen control, Next Up, favourites are
|
|
||||||
all shipped. The code is there; the tags are not. That means the matrix currently
|
|
||||||
over-reports on exactly the requirements a reviewer would most want to verify,
|
|
||||||
and a regression in any of them would leave no trace to follow.
|
|
||||||
|
|
||||||
**Fix:** Tag the existing implementations. Highest value per keystroke in the
|
|
||||||
whole audit: six of the twelve are single Jellyfin API call sites.
|
|
||||||
|
|
||||||
### A2 · Medium · Requirement statuses contradict each other across layers
|
|
||||||
|
|
||||||
`UR-020` (subtitle selection) and `UR-021` (audio track selection) are marked
|
|
||||||
*Done*, while the integration requirements they decompose into — `IR-018` and
|
|
||||||
`IR-019`, both libmpv-specific — are still *Planned*. Similarly `IR-005` (MPRIS)
|
|
||||||
sits at *Planned* under a *Done* `UR-006`.
|
|
||||||
|
|
||||||
The likely truth is that these user requirements were satisfied through a
|
|
||||||
different path than the one originally specified (HTML5 `<video>` and ExoPlayer
|
|
||||||
rather than libmpv), and the IRs were never re-scoped. Left as-is, the matrix
|
|
||||||
reads as though shipped features depend on unbuilt integrations.
|
|
||||||
|
|
||||||
**Fix:** Re-scope or retire the stale IRs so each Done UR rests on Done IRs.
|
|
||||||
|
|
||||||
### A3 · Medium · The traceability gate is set far below actual coverage
|
|
||||||
|
|
||||||
`traceability-check.yml` fails only below 50%. Real coverage is well above that,
|
|
||||||
so the gate cannot catch a coverage regression until roughly half the matrix has
|
|
||||||
rotted. A gate that can only fire after a catastrophe is not protecting anything.
|
|
||||||
|
|
||||||
**Measured coverage: 86% (285/330)** — UR 71/75, IR 19/32, DR 166/187, JA 29/36.
|
|
||||||
IR is by far the weakest dimension, which corroborates A1.
|
|
||||||
|
|
||||||
**Fix applied:** `MIN_THRESHOLD` ratcheted 50 → 82, with the ratchet policy
|
|
||||||
written into the workflow (only goes up; never lowered to make a red build pass).
|
|
||||||
The same figure is mirrored as `MIN_COVERAGE_PERCENT` in
|
|
||||||
`scripts/extract-traces.ts` so local `traces:coverage` gates on the same bar, and
|
|
||||||
a test parses the workflow YAML and fails if the two drift apart.
|
|
||||||
|
|
||||||
### A4 · Low · Two traced IDs do not exist in the requirements document
|
|
||||||
|
|
||||||
`DR-189` and `UT-188` are referenced by `TRACES:` comments but are defined
|
|
||||||
nowhere in `docs/requirements.md`. The extraction tool accepts them silently, so
|
|
||||||
typos and renames pass unnoticed.
|
|
||||||
|
|
||||||
**Fix:** Add a dangling-ID check to the extractor and fail CI on it — cheap, and
|
|
||||||
it keeps the matrix honest in both directions.
|
|
||||||
|
|
||||||
### A5 · Not a gap · The remaining untraced requirements are legitimately unbuilt
|
|
||||||
|
|
||||||
`UR-016`, `UR-022` and `UR-070` are Planned or Proposed, and `UR-031`
|
|
||||||
(crossfade) is explicitly blocked by `DR-034`. Their absence from the trace graph
|
|
||||||
is correct and needs no action — noted so it does not get swept into the fix list.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. Android platform practice
|
|
||||||
|
|
||||||
The app targets SDK 36 with a minSdk of 24. Several manifest and WebView settings
|
|
||||||
still reflect an earlier target level.
|
|
||||||
|
|
||||||
### B1 · Low · `POST_NOTIFICATIONS` is declared but never requested at runtime
|
|
||||||
|
|
||||||
*Downgraded from High. The original ranking was refuted by device testing — the
|
|
||||||
evidence is below, and it is the reason this finding is now near-trivial.*
|
|
||||||
|
|
||||||
The permission appears in the manifest, but there is no `requestPermissions` call
|
|
||||||
anywhere in the Kotlin, Rust or TypeScript sources, and
|
|
||||||
`JellyTauPlaybackService.startForeground()` runs with no `checkSelfPermission`
|
|
||||||
guard. On Android 13+ notification permission defaults to denied.
|
|
||||||
|
|
||||||
This was ranked High on the theory that it would suppress the media notification
|
|
||||||
and with it the lockscreen transport controls (`UR-006`). Testing on an HONOR
|
|
||||||
ROD2-W09 running **Android 16 (SDK 36)**, with the shipped app installed and
|
|
||||||
playing, shows otherwise. The permission is genuinely denied:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST_NOTIFICATIONS: granted=false, flags=[USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
|
|
||||||
appops POST_NOTIFICATION: ignore
|
|
||||||
```
|
|
||||||
|
|
||||||
and the notification is nonetheless live and complete:
|
|
||||||
|
|
||||||
```
|
|
||||||
ServiceRecord{... com.dtourolle.jellytau/.player.JellyTauPlaybackService}
|
|
||||||
isForeground=true foregroundId=1 types=0x00000002
|
|
||||||
foregroundNoti=Notification(flags=NO_CLEAR|FOREGROUND_SERVICE
|
|
||||||
category=transport actions=3 vis=PUBLIC)
|
|
||||||
```
|
|
||||||
|
|
||||||
**`UR-006` is not at risk.** But the *reason* is not the one this audit first
|
|
||||||
gave, and the correction is load-bearing rather than pedantic.
|
|
||||||
|
|
||||||
The first explanation here was "foreground-service notifications are exempt." That
|
|
||||||
is wrong. Android's own wording is that the permission covers "non-exempt
|
|
||||||
(**including Foreground Services (FGS)**) notifications", and that users who deny
|
|
||||||
it see FGS notices "in the Task Manager but [not] in the notification drawer" — an
|
|
||||||
FGS notification is explicitly *not* exempt. What is exempt is **media-session**
|
|
||||||
notifications. The platform predicate is `Notification.isMediaNotification()`,
|
|
||||||
requiring `MediaStyle`/`DecoratedMediaCustomViewStyle` **and** a non-null
|
|
||||||
`EXTRA_MEDIA_SESSION`; it is byte-identical across API 33–36, and
|
|
||||||
`NotificationManagerService` has no FGS clause in either enforcement site.
|
|
||||||
|
|
||||||
Why the difference matters: under the FGS theory, anything the service posts is
|
|
||||||
safe, and the code needs no care. Under the correct one, the exemption is earned
|
|
||||||
per-notification by the token — so losing the token loses not just the shade entry
|
|
||||||
but the lockscreen controls entirely, since SystemUI's media carousel
|
|
||||||
(`MediaDataProcessor.onNotificationAdded`) gates on the *same* predicate. A
|
|
||||||
token-less notification never even reaches the notification listener.
|
|
||||||
|
|
||||||
**The real risk here is not the permission — it is how narrowly the exemption is
|
|
||||||
earned.** AOSP's `Notification.isMediaNotification()` grants it only when the
|
|
||||||
style is `MediaStyle`/`DecoratedMediaCustomViewStyle` **and**
|
|
||||||
`Notification.EXTRA_MEDIA_SESSION` holds a non-null *platform* session token. If
|
|
||||||
either is missing while the permission is denied, the notification is **silently
|
|
||||||
suppressed** — no exception, no log.
|
|
||||||
|
|
||||||
JellyTau earns it at two sites, both of which hang it on a null-safe call:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
androidx.media.app.NotificationCompat.MediaStyle()
|
|
||||||
.setMediaSession(mediaSessionCompat?.sessionToken) // :273 and :466
|
|
||||||
```
|
|
||||||
|
|
||||||
Ordering currently saves it — `mediaSessionCompat` is assigned in `onCreate`
|
|
||||||
(:195) and `createBasicNotification()` is only reached from `onStartCommand`
|
|
||||||
(:251) — and the device test confirms it works. But it is one reordering away
|
|
||||||
from breaking invisibly, and only for users who denied the permission, which is
|
|
||||||
a population most developers never test as.
|
|
||||||
|
|
||||||
**Fix:** Keep the permission declared — download-service FGS notifications are
|
|
||||||
*not* covered by the media exemption, and this app has a downloads feature that
|
|
||||||
may want them. Comment both `setMediaSession` sites to record what earns the
|
|
||||||
exemption, and log loudly if the token is ever null at build time, converting a
|
|
||||||
silent failure into a diagnosable one.
|
|
||||||
|
|
||||||
**Location:** `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt:251`, `:273`, `:466`
|
|
||||||
|
|
||||||
### B2 · High · Cloud backup is on by default, and it will break credential restore
|
|
||||||
|
|
||||||
The manifest sets neither `android:allowBackup="false"` nor a
|
|
||||||
`dataExtractionRules`/`fullBackupContent` file, so Android's default applies: the
|
|
||||||
app's data directory is backed up to the user's Google account. That ships the
|
|
||||||
SQLite catalogue — library metadata and watch history — off the device.
|
|
||||||
|
|
||||||
The credential path makes it worse rather than better. `SecureStorage.kt`
|
|
||||||
encrypts with AES/GCM under an Android Keystore key, and Keystore keys are never
|
|
||||||
backed up. A user restoring onto a new phone therefore gets the ciphertext
|
|
||||||
without the key: undecryptable credentials and a silent authentication failure,
|
|
||||||
with no code path that recognises the situation.
|
|
||||||
|
|
||||||
**Fix applied.** `allowBackup="false"`. Extraction rules that merely excluded the
|
|
||||||
DB and credential prefs would have left nothing worth backing up: the SQLite
|
|
||||||
catalogue is a rebuildable mirror of the server and watch state lives server-side,
|
|
||||||
so there is no user-authored data to preserve.
|
|
||||||
|
|
||||||
**A gap this audit missed:** on API 31+, `allowBackup="false"` disables *cloud*
|
|
||||||
backup but **not device-to-device transfer**, which reproduces the identical
|
|
||||||
failure — the prefs travel, the Keystore key does not. A
|
|
||||||
`data_extraction_rules.xml` excluding all five domains from both `<cloud-backup>`
|
|
||||||
and `<device-transfer>` was added to close it.
|
|
||||||
|
|
||||||
**A real bug found while fixing this:** the Rust encrypted-file fallback in
|
|
||||||
`credentials.rs` propagated a decrypt failure as `CredentialError::Encryption`,
|
|
||||||
which `storage_get_access_token` turned into a hard `Err` — so an undecryptable
|
|
||||||
blob was an error state, not a logout. It now logs and returns an empty map, so
|
|
||||||
the caller sees `NotFound` → `Ok(None)` → login screen, and the next sign-in
|
|
||||||
self-heals the file. `SecureStorage.getCredential` on the Kotlin side already
|
|
||||||
returned null, but could not distinguish "nothing stored" from "unreadable" and
|
|
||||||
left the dead blob in prefs forever; it now separates the cases and discards it.
|
|
||||||
Three tests written and watched fail first, per the red→green rule.
|
|
||||||
|
|
||||||
### B3 · High · `MIXED_CONTENT_ALWAYS_ALLOW` undoes the network security config
|
|
||||||
|
|
||||||
`network_security_config.xml` is careful and well-argued: cleartext blocked
|
|
||||||
everywhere, exempted only for `127.0.0.1` so the local media server can serve
|
|
||||||
downloads. Its own comment warns "this must not become a blanket cleartext
|
|
||||||
opt-in."
|
|
||||||
|
|
||||||
But `MainActivity.kt` sets `mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW`, which
|
|
||||||
permits the WebView to load http subresources into an https page from any origin.
|
|
||||||
Alongside it, `allowFileAccess = true` and `allowContentAccess = true` are both
|
|
||||||
broader than anything the app needs, since Tauri serves the UI from its own scheme
|
|
||||||
and media comes from the token-guarded loopback server. These read as leftovers
|
|
||||||
from before the media server existed.
|
|
||||||
|
|
||||||
**Fix:** Drop to `MIXED_CONTENT_COMPATIBILITY_MODE` and set both file and content
|
|
||||||
access to false, then verify offline video still plays.
|
|
||||||
|
|
||||||
**Location:** `src-tauri/android/src/main/java/com/dtourolle/jellytau/MainActivity.kt:504-507`
|
|
||||||
|
|
||||||
### B4 · Medium · Android TV is half-declared
|
|
||||||
|
|
||||||
The manifest advertises `LEANBACK_LAUNCHER` and a non-required leanback feature,
|
|
||||||
but omits `<uses-feature android:name="android.hardware.touchscreen"
|
|
||||||
android:required="false"/>` and an `android:banner`. That combination fails Play's
|
|
||||||
TV validation, and on a real TV the app would launch into a UI with no D-pad focus
|
|
||||||
model behind it.
|
|
||||||
|
|
||||||
**Fix:** Either commit to TV — add the feature declaration, a banner, and a focus
|
|
||||||
pass — or remove the leanback category until you do.
|
|
||||||
|
|
||||||
### B5 · Medium · `jvmTarget` is pinned to 1.8 under compileSdk 36
|
|
||||||
|
|
||||||
The Kotlin target has not moved with the SDK. AGP 8 warns on it, and it locks the
|
|
||||||
Kotlin sources out of APIs and desugaring behaviour that everything else in the
|
|
||||||
toolchain assumes.
|
|
||||||
|
|
||||||
**Fix:** Move `jvmTarget` and the Java source/target compatibility to 17.
|
|
||||||
|
|
||||||
### B6 · Low · Media3 is several minor versions behind
|
|
||||||
|
|
||||||
`androidx.media3` is pinned at 1.5.0 across exoplayer, hls, session and common.
|
|
||||||
Given how much of this app's hard-won behaviour lives in ExoPlayer edge cases —
|
|
||||||
truncated progressive streams, background audio handoff, HLS resume — staying
|
|
||||||
current on its bug-fix releases has unusually high value here.
|
|
||||||
|
|
||||||
**Fix:** Schedule a Media3 bump with a device pass over the playback regression list.
|
|
||||||
|
|
||||||
### B7 · Medium · Predictive back is already on, not merely un-opted-into
|
|
||||||
|
|
||||||
*Upgraded from Low, and re-framed — the original framing was backwards.*
|
|
||||||
|
|
||||||
The audit first read the absent `enableOnBackInvokedCallback` as the app
|
|
||||||
*forgoing* the Android 13+ back-gesture preview. That is not what the flag means
|
|
||||||
at this target level. Predictive back is enabled by default for apps targeting
|
|
||||||
recent SDKs, and Android 16's own behaviour-change list carries "Migration or
|
|
||||||
opt-out required for predictive back" — with the opt-out being removed. Targeting
|
|
||||||
36, JellyTau is already getting predictive back; it simply hasn't been checked
|
|
||||||
against it.
|
|
||||||
|
|
||||||
That matters more than a missing opt-in would, because the app does not use
|
|
||||||
ordinary Android back. It runs a WebView with its own history model —
|
|
||||||
`src/lib/utils/navigation.ts` tracks a depth counter, applies a popstate delta,
|
|
||||||
and falls back to a path when `history.back()` would trap the user, with
|
|
||||||
`scrollRestore.ts` keying off the same popstate events. That is exactly the kind
|
|
||||||
of custom back handling predictive back is most likely to disagree with.
|
|
||||||
|
|
||||||
**Fix:** This is a device test, not a code change — exercise the back gesture
|
|
||||||
(including the drag-and-release preview and the cancel) from a library page, a
|
|
||||||
detail page, the player, and the settings screen, and watch for the depth counter
|
|
||||||
desynchronising. Only change code if it misbehaves.
|
|
||||||
|
|
||||||
Separately and unrelatedly: `JellyTauPlaybackService` is `exported="true"` with a
|
|
||||||
`MediaSessionService` intent filter — conventional for Media3, but it means any
|
|
||||||
app on the device can attempt to bind and drive playback. Confirm the session's
|
|
||||||
`onConnect` callback rejects unknown packages.
|
|
||||||
|
|
||||||
### B8 · Medium (forward-looking) · Android 16 Local Network Protections vs a LAN Jellyfin server
|
|
||||||
|
|
||||||
*New finding, surfaced while researching B1.*
|
|
||||||
|
|
||||||
Android 16's behaviour-change list includes **Local Network Permission**. JellyTau's
|
|
||||||
entire purpose is reaching a Jellyfin server that, for most users, sits on the
|
|
||||||
local network — so a permission gate on local-network access is a direct threat to
|
|
||||||
the app's core function, not a peripheral concern.
|
|
||||||
|
|
||||||
Stated carefully, because the timing matters: in Android 16 this is **opt-in for
|
|
||||||
testing**, not enforced by default, with enforcement signalled for a future
|
|
||||||
release. Nothing is broken today, and the device test will not surface it. But
|
|
||||||
this is the rare platform change that could stop the app working at all, and it
|
|
||||||
is much cheaper to handle before it is mandatory.
|
|
||||||
|
|
||||||
**Fix:** Investigate what the permission will require, then test the app against
|
|
||||||
it with the opt-in flag enabled on the Android 16 device already to hand. Track it
|
|
||||||
as a release-blocking item for whichever Android version enforces it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. Tauri v2 configuration
|
|
||||||
|
|
||||||
The capability model here is genuinely well done — see section E. The gaps are in
|
|
||||||
the two settings that govern what a compromised web layer could reach.
|
|
||||||
|
|
||||||
### C1 · High · `"csp": null` contradicts the project's own security convention
|
|
||||||
|
|
||||||
`CLAUDE.md` lists "keep the CSP restrictive in `tauri.conf.json`" as a standing
|
|
||||||
rule; the config disables CSP entirely. With it off, any script that reaches the
|
|
||||||
web layer inherits the full IPC surface.
|
|
||||||
|
|
||||||
The realistic exposure today is low, and worth stating plainly rather than
|
|
||||||
inflating: the frontend has a single `{@html}` — an app-owned icon in
|
|
||||||
`GenericGenreBrowser.svelte`, not server data — and no `innerHTML`, `eval` or
|
|
||||||
`new Function` outside tests. So this is a missing defence rather than an open
|
|
||||||
hole. But it is the defence that stops the next careless interpolation of a
|
|
||||||
Jellyfin-supplied string from becoming a full compromise.
|
|
||||||
|
|
||||||
**Fix:** Set a CSP permitting `'self'`, `asset.localhost`, `http://127.0.0.1:*`
|
|
||||||
for media, and the configured Jellyfin origin for images. Expect one or two
|
|
||||||
iterations against HLS playback.
|
|
||||||
|
|
||||||
### C2 · Medium · The asset protocol scope is wider than what it serves
|
|
||||||
|
|
||||||
`assetProtocol.scope` is `$APPDATA/**`, which covers the whole app data directory
|
|
||||||
— the SQLite database and the credential store included — while the protocol only
|
|
||||||
needs to reach cached thumbnails and downloaded media.
|
|
||||||
|
|
||||||
Since `DR-137` introduced the token-guarded loopback media server, the asset
|
|
||||||
protocol's remaining job may be thumbnails alone, which would make the narrowing
|
|
||||||
nearly free.
|
|
||||||
|
|
||||||
**Fix applied:** scoped to `$APPDATA/thumbnails/**`. Confirmed on device that
|
|
||||||
`jellytau.db` (8 MB catalogue) and `shared_prefs` sit in the `$APPDATA` root and
|
|
||||||
are now outside the grant.
|
|
||||||
|
|
||||||
**But device testing found the finding was aimed at the wrong thing.** The asset
|
|
||||||
protocol is not narrowly used — it is **entirely unused at runtime**:
|
|
||||||
|
|
||||||
- `getCachedImageUrl` in `imageCache.ts` has **no production callers**. Its only
|
|
||||||
references are its own test file. `convertFileSrc`'s sole production mention
|
|
||||||
sits inside that uncalled function, so it never executes.
|
|
||||||
- The real path is `MediaCard` → `CachedImage` → `commands.imageGetUrl()`, which
|
|
||||||
returns **base64 from Rust**. Every image in the app is a `data:` URI delivered
|
|
||||||
over IPC.
|
|
||||||
- Confirmed on device: zero `asset.localhost` requests across a full session of
|
|
||||||
browsing home, the library list and a poster grid; the thumbnail cache stayed
|
|
||||||
at 12 files and never grew, because nothing calls `thumbnailSave` either.
|
|
||||||
|
|
||||||
Two consequences worth acting on, neither yet done:
|
|
||||||
|
|
||||||
1. **The `protocol-asset` Cargo feature and the whole `assetProtocol` config
|
|
||||||
block can likely be removed**, which retires the attack surface rather than
|
|
||||||
shrinking it. `imageCache.ts` is dead code and can go with it.
|
|
||||||
2. **`img-src` in the new CSP can be much tighter.** It currently grants
|
|
||||||
`http: https:` on the reasoning that thumbnails are fetched direct-from-server
|
|
||||||
on a cache miss — but they are not; they arrive as data URIs. With no
|
|
||||||
webview-side server image loads anywhere in `src/`, `img-src 'self' data:
|
|
||||||
blob:` should suffice. That is a real tightening the CSP work left on the
|
|
||||||
table because it reasoned from the dead code path.
|
|
||||||
|
|
||||||
Both need their own device pass, since a wrong `img-src` blanks every image.
|
|
||||||
|
|
||||||
### C3 · Low · Shipped desktop bundles have no update path
|
|
||||||
|
|
||||||
The bundle targets deb, rpm and nsis, but `tauri-plugin-updater` is not among the
|
|
||||||
dependencies. Every desktop user upgrades by manually fetching a new package,
|
|
||||||
which in practice means a long tail of installs pinned to whatever version they
|
|
||||||
first downloaded.
|
|
||||||
|
|
||||||
**Fix:** Add the updater plugin with a signed release manifest, or document the
|
|
||||||
manual upgrade path in the README so the omission is at least deliberate.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. CI and code health
|
|
||||||
|
|
||||||
Local discipline in this project is strong and well documented. CI enforces only
|
|
||||||
part of it, which means the discipline holds exactly as long as every contributor
|
|
||||||
remembers it.
|
|
||||||
|
|
||||||
### D1 · High · CI runs neither `cargo clippy` nor `cargo fmt --check`
|
|
||||||
|
|
||||||
`CLAUDE.md` requires both before committing. Neither appears anywhere in
|
|
||||||
`.gitea/workflows/`. The build-and-test job runs the boundary check, the frontend
|
|
||||||
tests, the Rust tests and an Android `cargo check` — a good set, with the two lint
|
|
||||||
gates missing.
|
|
||||||
|
|
||||||
Clippy currently reports 51 warnings across the lib and its tests, including
|
|
||||||
unused imports and a redundant import that a gate would have stopped at the door.
|
|
||||||
|
|
||||||
**Fix:** Add both to the test job. Start with `-D warnings` on new code only if
|
|
||||||
clearing the existing 51 is too large a first step.
|
|
||||||
|
|
||||||
### D2 · Medium · A flaky test will intermittently redden CI
|
|
||||||
|
|
||||||
`offlineCatalog.test.ts` — "pushes include=true while the server is reachable"
|
|
||||||
(`UT-068`) — timed out at the 5 s limit during a full-suite run, then passed twice
|
|
||||||
in isolation taking 1.13 s and 0.61 s.
|
|
||||||
|
|
||||||
**Root cause (corrected):** this audit originally attributed it to a real
|
|
||||||
wall-clock timer. It isn't. The cost is the **first dynamic
|
|
||||||
`import("./offlineCatalog")`**, which pays to transform the service and its whole
|
|
||||||
dependency graph (~1072 ms cold) inside a test body, charged against vitest's 5 s
|
|
||||||
default. Later re-imports after `vi.resetModules()` cost ~30 ms. Under full-suite
|
|
||||||
contention the cold transform alone crosses the limit.
|
|
||||||
|
|
||||||
**Fix applied:** warm the import once at collection time with a top-level
|
|
||||||
`await import(...)`, so no test is timing the compiler. Slowest test 1072 ms →
|
|
||||||
129 ms; file total 1170 ms → 238 ms. Timeout deliberately left at the default.
|
|
||||||
A latent cross-test leak was also fixed alongside it — the store shim's
|
|
||||||
subscribers were never cleared, so every module instance discarded by
|
|
||||||
`resetModules()` kept pushing its own visibility value.
|
|
||||||
|
|
||||||
**Location:** `src/lib/services/offlineCatalog.test.ts:58`
|
|
||||||
|
|
||||||
### D3 · Low · ~~820~~ **19** production `unwrap()`/`expect()` calls
|
|
||||||
|
|
||||||
*Downgraded from Medium. This audit substantially overstated the problem, and the
|
|
||||||
correction is worth recording because the measurement error is instructive.*
|
|
||||||
|
|
||||||
The original 820 figure came from grepping for `unwrap()`/`expect()` and filtering
|
|
||||||
lines containing "test". That does not exclude test *modules* — it only excludes
|
|
||||||
lines with "test" in them. Scripting the actual `#[cfg(test)]` boundaries gives
|
|
||||||
**19 real production sites**, not 820. `player/mod.rs`'s 154 hits, for instance,
|
|
||||||
are *all* past its `#[cfg(test)]` at line 2183, as are the bulk of
|
|
||||||
`repository/offline.rs`, `storage/mod.rs` and `commands/download/mod.rs`.
|
|
||||||
|
|
||||||
**More importantly: zero bare unwraps exist in any `#[tauri::command]` handler.**
|
|
||||||
The specific risk this finding was built around — a panic inside a command killing
|
|
||||||
the task and stranding shared player state — is already absent.
|
|
||||||
|
|
||||||
The same correction applies to the lock half: all 33 raw `.lock().unwrap()` hits
|
|
||||||
were in test modules (three weren't even code, but prose in `utils/lock.rs`'s doc
|
|
||||||
comment). Production was already fully on `lock_safe()`/`read_safe()`/
|
|
||||||
`write_safe()`. Converting them was consistency work, not a bug fix.
|
|
||||||
|
|
||||||
**What is genuinely worth doing** is a three-site cluster, all the same pattern —
|
|
||||||
`Runtime::new().unwrap()` in threads owning playback-critical state:
|
|
||||||
|
|
||||||
| | Site | Consequence of a panic |
|
|
||||||
|---|------|------------------------|
|
|
||||||
| 1 | `session_poller/mod.rs:102` | Poller thread dies silently; it drives remote-mode state *and* offline→online recovery, so the app strands offline with nothing surfaced |
|
|
||||||
| 2 | `player/mpv_backend.rs:424` | Position reporting stops mid-playback; the scrubber freezes while audio keeps going |
|
|
||||||
| 3 | `player/android/mod.rs:761` | Same pattern across a JNI boundary; progress reporting dies and no resume points are written |
|
|
||||||
|
|
||||||
**Fix:** One shared helper returning `Option<Runtime>` and logging on failure
|
|
||||||
retires all three. The remaining 16 are startup `expect()`s and two provably
|
|
||||||
infallible calls.
|
|
||||||
|
|
||||||
### D4 · Low · Five files carry a disproportionate share of the complexity
|
|
||||||
|
|
||||||
`player/mod.rs` (4,726 lines), `repository/offline.rs` (4,696),
|
|
||||||
`repository/online.rs` (3,702), `commands/player/mod.rs` (3,299) and
|
|
||||||
`commands/download/mod.rs` (3,226), plus `VideoPlayer.svelte` (2,778) on the
|
|
||||||
frontend.
|
|
||||||
|
|
||||||
These are the same files the changelog keeps returning to for deadlocks and
|
|
||||||
playback regressions. Not a defect in itself, and not worth a speculative
|
|
||||||
refactor — but the next time one of them needs substantial work, splitting it is
|
|
||||||
likely cheaper than continuing to grow it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. Verified sound
|
|
||||||
|
|
||||||
Things this audit specifically went looking for and found in good order —
|
|
||||||
including one that looked alarming from the warning output and turned out to be
|
|
||||||
fine.
|
|
||||||
|
|
||||||
| Area | Finding |
|
|
||||||
|------|---------|
|
|
||||||
| **The 9 "MutexGuard across await" warnings are test-only** | All nine sit in `#[tokio::test]` functions holding a serialization lock, not in the production async paths that `CLAUDE.md`'s deadlock gotcha warns about. |
|
|
||||||
| **The local media server is exemplary** | Loopback-only bind, a 32-hex-char per-session token, lexical `..` folding rather than `canonicalize`, and a test asserting reads stay inside the data directory. |
|
|
||||||
| **Tauri capabilities are minimal** | Three permissions total — `core:default`, `opener:default`, `core:path:default`. No blanket grants, no `withGlobalTauri`. |
|
|
||||||
| **SQL is parameterised** | Two `format!`-built statements in the whole Rust tree, neither interpolating caller-controlled input into a query. |
|
|
||||||
| **R8 keep rules are correct and explained** | JNI-loaded player and security classes, the JavascriptInterface bridges and Media3 are all kept, each with a comment naming the crash it prevents. |
|
|
||||||
| **Type and boundary gates are green** | `svelte-check`: 0 errors, 0 warnings. `check:boundary` passes with three reviewed allowlist entries. 698 Rust tests and 1,021 frontend tests pass. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F. Suggested order
|
|
||||||
|
|
||||||
Sequenced so the cheap gates land before the work they would have caught. B2
|
|
||||||
leads because it is the finding a user is most likely to actually feel.
|
|
||||||
|
|
||||||
*(B1 originally led this list. It was demoted to row 10 after device testing —
|
|
||||||
see B1. This is a good advertisement for testing a finding before scheduling
|
|
||||||
work against it.)*
|
|
||||||
|
|
||||||
| # | Finding | What it buys | Effort |
|
|
||||||
|---|---------|--------------|--------|
|
|
||||||
| 1 | B2 | Catalogue and credentials stop leaving the device; restore stops failing silently | S — **confirmed on device**: `ALLOW_BACKUP` set, Google transport active |
|
|
||||||
| 3 | D1 | Lint discipline becomes enforced rather than remembered | S |
|
|
||||||
| 4 | B3 | The network security config actually holds | S — needs an offline-playback check |
|
|
||||||
| 5 | A1 | The matrix stops over-reporting on twelve shipped requirements | M — mostly mechanical |
|
|
||||||
| 6 | D2 | CI stops flaking | S |
|
|
||||||
| 7 | C1 · C2 | The web layer stops being one interpolation away from full IPC | M — iterate against HLS |
|
|
||||||
| 8 | A2 · A3 · A4 | The matrix becomes self-consistent and defended by a real gate | M |
|
|
||||||
| 9 | B4 · B5 · B6 · B7 | Platform hygiene brought level with the SDK target | M |
|
|
||||||
| 10 | B1 · D3 · C3 · D4 | Long-tail robustness; opportunistic rather than scheduled | L |
|
|
||||||
@@ -255,9 +255,8 @@ First build takes longer (cache warming). Subsequent releases are faster due to
|
|||||||
**Android:** 8.0+
|
**Android:** 8.0+
|
||||||
|
|
||||||
### 🔗 Links
|
### 🔗 Links
|
||||||
- [Changelog](../../CHANGELOG.md)
|
- [Changelog](https://gitea.tourolle.paris/dtourolle/jellytau/src/branch/master/CHANGELOG.md)
|
||||||
- [Issues](../../issues)
|
- [Issues](https://gitea.tourolle.paris/dtourolle/jellytau/issues)
|
||||||
- [Discussion](../../discussions)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
Built with Tauri, SvelteKit, and Rust 🦀
|
Built with Tauri, SvelteKit, and Rust 🦀
|
||||||
|
|||||||
+39
-12
@@ -85,6 +85,7 @@ For a narrative overview of the system design, see
|
|||||||
| UR-073 | Watched state is something the viewer can **set**, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachable | Medium | Done |
|
| UR-073 | Watched state is something the viewer can **set**, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachable | Medium | Done |
|
||||||
| UR-072 | Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before it | Medium | Done |
|
| UR-072 | Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before it | Medium | Done |
|
||||||
| UR-075 | Artwork is shown at the shape it was made in. Where a screen presents a set of things side by side — the libraries on the library page and on home — they are laid out as a mosaic: rows of a common height in which each tile is as wide as its own picture, rather than a grid that crops every cover to one box. Favourites are reachable per category from that same mosaic, beside the library they belong to, not only as one undifferentiated list | Medium | Done |
|
| UR-075 | Artwork is shown at the shape it was made in. Where a screen presents a set of things side by side — the libraries on the library page and on home — they are laid out as a mosaic: rows of a common height in which each tile is as wide as its own picture, rather than a grid that crops every cover to one box. Favourites are reachable per category from that same mosaic, beside the library they belong to, not only as one undifferentiated list | Medium | Done |
|
||||||
|
| UR-076 | Music browsing shows only what the listener considers music. A Jellyfin server commonly keeps podcasts, audiobooks, sound effects or sample packs in their own folders inside a music library; those folders can be **excluded by choice**, once, and every music surface — library grids, artist and album listings, genre rows, search and the home screen — then agrees on what is in scope. The choice is by folder, not by a name the app happens to recognise, so a folder called anything at all can be excluded and an item is never dropped because its title matched a word | Medium | Proposed |
|
||||||
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
|
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -392,6 +393,12 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-137 | Local media is served to the player over a loopback HTTP server, not the asset protocol. Tauri's `asset` protocol answers a request carrying no `Range` header by reading the whole file into memory, and only advertises `Accept-Ranges: bytes` from *inside* its range branch — so the first request never learns ranges exist and a multi-gigabyte body is attempted instead. Chromium abandoned it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as "downloaded video does not play offline". Real HTTP on `127.0.0.1` is chosen over a custom URI scheme deliberately: range support becomes a property of the transport rather than depending on whether a platform's webview forwards `Range` to a custom scheme. No response ever exceeds a 4 MiB chunk and bodies stream from the file handle, so memory is bounded regardless of file size. Because **loopback is shared between apps on Android**, the server binds `127.0.0.1` only and every URL carries a random per-session token; paths are additionally confined to the app data directory, so a leaked URL cannot read outside it. This is stage 1 of making the server the single media origin — remote passthrough and download-while-watching are deliberately out of scope here | Playback | UR-071 | Done |
|
| DR-137 | Local media is served to the player over a loopback HTTP server, not the asset protocol. Tauri's `asset` protocol answers a request carrying no `Range` header by reading the whole file into memory, and only advertises `Accept-Ranges: bytes` from *inside* its range branch — so the first request never learns ranges exist and a multi-gigabyte body is attempted instead. Chromium abandoned it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as "downloaded video does not play offline". Real HTTP on `127.0.0.1` is chosen over a custom URI scheme deliberately: range support becomes a property of the transport rather than depending on whether a platform's webview forwards `Range` to a custom scheme. No response ever exceeds a 4 MiB chunk and bodies stream from the file handle, so memory is bounded regardless of file size. Because **loopback is shared between apps on Android**, the server binds `127.0.0.1` only and every URL carries a random per-session token; paths are additionally confined to the app data directory, so a leaked URL cannot read outside it. This is stage 1 of making the server the single media origin — remote passthrough and download-while-watching are deliberately out of scope here | Playback | UR-071 | Done |
|
||||||
| DR-138 | Loopback is exempted from Android's cleartext ban, and nothing else is. Release builds set `usesCleartextTraffic="false"`, so the webview's request to the local media server (DR-137) was rejected by network security policy before any I/O — `<video>` failed in the same millisecond as `loadstart`, with `NETWORK_NO_SOURCE` and no server-side log at all, which is why it looked identical to a missing file. A `network-security-config` resource permits cleartext for `127.0.0.1` only and keeps `base-config cleartextTrafficPermitted="false"`, so a remote server must still be HTTPS; this is deliberately not a blanket opt-in. The manifest attribute is ignored once the config is present, so the config is the single authority. `sync-android-sources.sh` also had to learn to copy `res/xml`, which it skipped — the manifest references the resource, so a missed copy fails the resource link rather than degrading quietly | Security | UR-071 | Done |
|
| DR-138 | Loopback is exempted from Android's cleartext ban, and nothing else is. Release builds set `usesCleartextTraffic="false"`, so the webview's request to the local media server (DR-137) was rejected by network security policy before any I/O — `<video>` failed in the same millisecond as `loadstart`, with `NETWORK_NO_SOURCE` and no server-side log at all, which is why it looked identical to a missing file. A `network-security-config` resource permits cleartext for `127.0.0.1` only and keeps `base-config cleartextTrafficPermitted="false"`, so a remote server must still be HTTPS; this is deliberately not a blanket opt-in. The manifest attribute is ignored once the config is present, so the config is the single authority. `sync-android-sources.sh` also had to learn to copy `res/xml`, which it skipped — the manifest references the resource, so a missed copy fails the resource link rather than degrading quietly | Security | UR-071 | Done |
|
||||||
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
|
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
|
||||||
|
| DR-204 | A leveled logging facade for the frontend, replacing raw `console.*` calls. One module owns the log sinks, so a level (error/warn/info/debug) decides at run time what is emitted rather than every call site deciding permanently at authoring time: a release build stays quiet, a developer chasing a playback bug turns the player's debug output on without editing and rebuilding, and nothing that reaches the console is written by a `console.log` nobody can find again. Scoped loggers carry the subsystem in the message, so a filtered console is usable while a player, a download worker and a store are all talking | Tooling | - | Proposed |
|
||||||
|
| DR-205 | ESLint + Prettier run as a gate over the frontend, so lint and formatting are decided once by configuration rather than per reviewer. Formatting is not a matter of opinion at review time, and the classes of bug a linter sees (unused bindings, floating promises, accidental globals) should never reach a human reviewer at all. Wired as an npm script so the same command runs locally and in CI, matching how `check:boundary` and the traceability gate already work | Tooling | - | Proposed |
|
||||||
|
| DR-206 | The Rust toolchain is pinned in-repo (`rust-toolchain.toml`) and the pin is what both a developer's machine and CI use. Without it, `cargo fmt --check` and `cargo clippy` are run by whatever version each host happens to have, so a formatting or lint result differs between a laptop and the builder image and CI fails on a diff that was clean locally — the failure mode is a red build nobody can reproduce. The builder image carries the pinned toolchain, so pinning is a *declaration*, not a CI-time install (see the no-toolchain-installs rule) | Tooling | - | Proposed |
|
||||||
|
| DR-207 | A pre-commit hook runs the "Before Committing" gates — frontend checks and tests, `cargo fmt`, clippy, the boundary tripwire and the traceability checks — so the gates are enforced at the commit rather than discovered in CI. The gates already exist and are already documented; what is missing is that nothing runs them, which makes compliance a matter of memory. The hook is the mechanism that makes the documented list actually binding | Tooling | - | Proposed |
|
||||||
|
| DR-208 | Documentation link integrity is checked mechanically (`scripts/check-doc-links.sh`): every relative markdown link in every tracked `.md` must resolve to a file that exists on disk. This is a real defect class, not hygiene — the generated traceability matrix shipped ~2,800 dead file links because it was written to `docs/` while its hrefs were repo-root-relative, and nothing noticed for months because no check existed and nobody clicks 2,800 links. The check validates *paths*, deliberately not anchors or external URLs: anchor resolution needs a markdown renderer's slug rules and network checks make the gate flaky, so both are out of scope and stated as such in the script | Tooling | - | Done |
|
||||||
|
| DR-209 | Library folders are excluded from music browsing **server-side, by folder id**, replacing a hardcoded frontend filter that dropped anything whose name contained "Podcasts". The name filter was wrong in three separate ways: it encoded a domain classification in the presentation layer, it matched on a title rather than on what an item *is* (so an album legitimately called "Podcasts" vanished while a podcast folder named anything else did not), and it applied only where someone had remembered to call it, so the same library was in scope on one screen and out of scope on the next. Excluded folder ids are stored as user configuration and applied by the repository layer to every music query — libraries, artists, albums, genres, search and the home rows — so scope is decided in one place and is the same everywhere | Repository | UR-076 | Proposed |
|
||||||
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
|
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -476,6 +483,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-073 | - | DR-158 |
|
| UR-073 | - | DR-158 |
|
||||||
| UR-074 | - | DR-162, DR-177, DR-181 |
|
| UR-074 | - | DR-162, DR-177, DR-181 |
|
||||||
| UR-075 | - | DR-174, DR-175 |
|
| UR-075 | - | DR-174, DR-175 |
|
||||||
|
| UR-076 | - | DR-209 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -678,6 +686,9 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-197 | Skipping forward near the end clamps to the duration rather than running past it into an EOF-driven advance | DR-201 | Done |
|
| UT-197 | Skipping forward near the end clamps to the duration rather than running past it into an EOF-driven advance | DR-201 | Done |
|
||||||
| UT-198 | An unknown duration still scrubs and still refuses to go negative | DR-201 | Done |
|
| UT-198 | An unknown duration still scrubs and still refuses to go negative | DR-201 | Done |
|
||||||
| UT-199 | The screen-wake decision: video playing holds the display, pausing releases it, audio playing never holds it, a webview element going inactive releases even without a pause report, either renderer alone is enough to hold, and teardown drops both | DR-202 | Done |
|
| UT-199 | The screen-wake decision: video playing holds the display, pausing releases it, audio playing never holds it, a webview element going inactive releases even without a pause report, either renderer alone is enough to hold, and teardown drops both | DR-202 | Done |
|
||||||
|
| UT-201 | The logging facade gates by level: a message below the active level is not emitted at all, one at or above it reaches the sink, changing the level at run time changes what passes without touching the call sites, and a scoped logger tags its output with the subsystem | DR-204 | Proposed |
|
||||||
|
| UT-202 | Generated traceability-matrix file links resolve from `docs/`: an emitted href, resolved against the directory `traceability.md` is written to, points at a file that exists on disk; the visible link text stays repo-root-relative; the `#Lnn` anchor survives; and a bare repo-root href — the regression that made every link 404 as `docs/<path>` — is rejected | DR-093 | Done |
|
||||||
|
| UT-203 | Library folder exclusion filters by id, not by name: an excluded folder's items are absent from a music query, an item whose *title* merely contains an excluded folder's name is kept, and clearing the exclusion restores the items | DR-209 | Proposed |
|
||||||
| UT-200 | The stream a player could only restart is refused its retry: the handoff transcode answers yes to `player_retry_restarts_stream` while music, video and a downloaded episode answer no, and the Kotlin decision starts permissive, flips on a non-resumable load, and is restored by the next ordinary one | DR-203 | Done |
|
| UT-200 | The stream a player could only restart is refused its retry: the handoff transcode answers yes to `player_retry_restarts_stream` while music, video and a downloaded episode answer no, and the Kotlin decision starts permissive, flips on a non-resumable load, and is restored by the next ordinary one | DR-203 | Done |
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
@@ -704,12 +715,28 @@ Internal architecture, components, and application logic.
|
|||||||
|
|
||||||
## 5. Technical Debt
|
## 5. Technical Debt
|
||||||
|
|
||||||
### Open items from the codebase audit (2026-08-16)
|
### Open items carried over from the v0.6.0 codebase audit
|
||||||
|
|
||||||
Findings from [codebase-audit.md](codebase-audit.md) that were **not** addressed
|
The 2026-08-16 audit (v0.6.0, commit `be907b49`) was a point-in-time snapshot
|
||||||
in v0.8.0, plus items the device-verification pass turned up. Ordered by what
|
with no status markers, and by v0.8.2 most of it had been either fixed or
|
||||||
would hurt most if left. The audit doc carries the full reasoning and evidence
|
overtaken. It was **retired** rather than left to rot into a document that
|
||||||
for each.
|
half-describes the code: what survived it is the table below, which is now the
|
||||||
|
record. Each row is self-contained — the audit is not needed to act on it.
|
||||||
|
|
||||||
|
What was dropped as demonstrably closed, so it is not re-raised: the CSP and
|
||||||
|
asset-protocol scope findings (now DR-198), cloud backup and credential restore,
|
||||||
|
the WebView mixed-content override (DR-199), `POST_NOTIFICATIONS` and the
|
||||||
|
media-session exemption (DR-200), the `jvmTarget` 1.8 pin (now 17), the
|
||||||
|
half-declared Android TV leanback category (removed), the untraced-but-Done
|
||||||
|
requirements and the contradictory UR/IR statuses (re-scoped in §2.1), the 50%
|
||||||
|
traceability gate (ratcheted, and gated on a live denominator by DR-093), the
|
||||||
|
flaky `offlineCatalog` test, the clippy warning backlog (cleared, and `cargo
|
||||||
|
fmt --check` plus clippy now run in CI), and the "820 production `unwrap()`s"
|
||||||
|
figure — a measurement error that counted test modules, corrected in the audit
|
||||||
|
itself to ~19 and standing at 27 today, none of them in a command handler. The
|
||||||
|
three `Runtime::new().unwrap()` sites that genuinely matter survive as row 5.
|
||||||
|
|
||||||
|
Ordered by what would hurt most if left.
|
||||||
|
|
||||||
> **Closed 2026-08-17:** the R8-minified release APK was validated on device.
|
> **Closed 2026-08-17:** the R8-minified release APK was validated on device.
|
||||||
> That was the last item gating confidence in the v0.8.0 release itself; R8
|
> That was the last item gating confidence in the v0.8.0 release itself; R8
|
||||||
@@ -719,17 +746,17 @@ for each.
|
|||||||
|
|
||||||
| # | Item | Why it matters | Size |
|
| # | Item | Why it matters | Size |
|
||||||
|---|------|----------------|------|
|
|---|------|----------------|------|
|
||||||
| 1 | **Android 16 Local Network Protections** (audit B8) | The rare platform change that could stop the app working at all: JellyTau's core function is reaching a Jellyfin server that, for most users, is on the LAN. Opt-in for testing in Android 16, enforcement signalled for a later release — so nothing is broken today and no device test will surface it. Far cheaper to handle before it is mandatory. An Android 16 device is already to hand to test the opt-in flag against | M |
|
| 1 | **Android 16 Local Network Protections** | The rare platform change that could stop the app working at all: JellyTau's core function is reaching a Jellyfin server that, for most users, is on the LAN. Opt-in for testing in Android 16, enforcement signalled for a later release — so nothing is broken today and no device test will surface it. Far cheaper to handle before it is mandatory. An Android 16 device is already to hand to test the opt-in flag against | M |
|
||||||
| 2 | **The traceability matrix cannot see Kotlin** | `scripts/extract-traces.ts` walks only `src`, `src-tauri/src` and `scripts`, so every `TRACES:` comment in `src-tauri/android/**` is invisible — pre-existing ones included. A whole platform is unmeasured, which is plausibly why the Android IRs sat untagged for so long, and it means the 90% coverage figure is computed over a codebase that excludes the Android tree | S |
|
| 2 | **The traceability matrix cannot see Kotlin** | `scripts/extract-traces.ts` walks only `src`, `src-tauri/src` and `scripts`, so every `TRACES:` comment in `src-tauri/android/**` is invisible — pre-existing ones included. A whole platform is unmeasured, which is plausibly why the Android IRs sat untagged for so long, and it means the 90% coverage figure is computed over a codebase that excludes the Android tree | S |
|
||||||
| 3 | **Delete the asset protocol outright** | It is not narrowly used, it is **unused**. `getCachedImageUrl` has no production callers (only its own test file), so `convertFileSrc` never executes; images arrive as base64 `data:` URIs from `image_get_url`. Confirmed on device: zero `asset.localhost` requests across a full browsing session. Dropping `protocol-asset` and the `assetProtocol` block retires the surface instead of shrinking it, and `imageCache.ts` goes with it | S |
|
| 3 | **Delete the asset protocol outright** | It is not narrowly used, it is **unused**. `getCachedImageUrl` has no production callers (only its own test file), so `convertFileSrc` never executes; images arrive as base64 `data:` URIs from `image_get_url`. Confirmed on device: zero `asset.localhost` requests across a full browsing session. Dropping `protocol-asset` and the `assetProtocol` block retires the surface instead of shrinking it, and `imageCache.ts` goes with it | S |
|
||||||
| 4 | **Tighten `img-src`** | The v0.8.0 CSP grants `img-src … http: https:` on the premise that thumbnails are fetched direct-from-server by the webview. They are not (see #3). With no webview-side server image loads anywhere in `src/`, `'self' data: blob:` should suffice. Needs its own device pass — a wrong `img-src` blanks every image, silently | S |
|
| 4 | **Tighten `img-src`** | The v0.8.0 CSP grants `img-src … http: https:` on the premise that thumbnails are fetched direct-from-server by the webview. They are not (see #3). With no webview-side server image loads anywhere in `src/`, `'self' data: blob:` should suffice. Needs its own device pass — a wrong `img-src` blanks every image, silently | S |
|
||||||
| 5 | **Three `Runtime::new().unwrap()` in playback-critical threads** (audit D3) | `session_poller/mod.rs:102`, `player/mpv_backend.rs:424`, `player/android/mod.rs:761`. A panic strands the app offline with nothing surfaced, freezes the scrubber mid-playback, or kills progress reporting across a JNI boundary. One shared helper returning `Option<Runtime>` and logging on failure retires all three. (The wider "820 unwraps" figure was a measurement error — the real count is 19, and none are in command handlers) | S |
|
| 5 | **Three `Runtime::new().unwrap()` in playback-critical threads** | `session_poller/mod.rs:102`, `player/mpv_backend.rs:424`, `player/android/mod.rs:761`. A panic strands the app offline with nothing surfaced, freezes the scrubber mid-playback, or kills progress reporting across a JNI boundary. One shared helper returning `Option<Runtime>` and logging on failure retires all three. (The wider "820 unwraps" figure was a measurement error — the real count is 19, and none are in command handlers) | S |
|
||||||
| 6 | **Confirm the playback service rejects unknown callers** (audit B7, second half) | `JellyTauPlaybackService` is `exported="true"` with a `MediaSessionService` intent filter — conventional for Media3, but it means any app on the device can attempt to bind and drive playback. The session's `onConnect` should reject unknown packages. (The predictive-back half of B7 was verified working on device and needs nothing) | S |
|
| 6 | **Confirm the playback service rejects unknown callers** | `JellyTauPlaybackService` is `exported="true"` with a `MediaSessionService` intent filter — conventional for Media3, but it means any app on the device can attempt to bind and drive playback. The session's `onConnect` should reject unknown packages. (Predictive back, raised alongside this, was verified working on device and needs nothing) | S |
|
||||||
| 7 | **Media3 is several minor versions behind** (audit B6) | Pinned at 1.5.0 across exoplayer/hls/session/common. Much of this app's hard-won behaviour lives in ExoPlayer edge cases — truncated progressive streams, background-audio handoff, HLS resume — so its bug-fix releases have unusually high value here. Schedule with a device pass over the playback regression list | M |
|
| 7 | **Media3 is several minor versions behind** | Pinned at 1.5.0 across exoplayer/hls/session/common. Much of this app's hard-won behaviour lives in ExoPlayer edge cases — truncated progressive streams, background-audio handoff, HLS resume — so its bug-fix releases have unusually high value here. Schedule with a device pass over the playback regression list | M |
|
||||||
| 8 | **Shipped desktop bundles have no update path** (audit C3) | deb/rpm/nsis are built but `tauri-plugin-updater` is absent, so every desktop user upgrades by manually fetching a package — in practice a long tail of installs pinned to whatever they first downloaded. Add the updater with a signed manifest, or document the manual path so the omission is deliberate | M |
|
| 8 | **Shipped desktop bundles have no update path** | deb/rpm/nsis are built but `tauri-plugin-updater` is absent, so every desktop user upgrades by manually fetching a package — in practice a long tail of installs pinned to whatever they first downloaded. Add the updater with a signed manifest, or document the manual path so the omission is deliberate | M |
|
||||||
| 9 | **`DR-042` overstates what ships** | It promises "poster cards, year, **and rating badges**", but `MediaCard.svelte` renders only `productionYear`; `CommunityRating`/`OfficialRating` appear solely as sort keys, never as a badge. Either build the badge or correct the requirement text — a requirement that describes unbuilt behaviour is worse than an untraced one | S |
|
| 9 | **`DR-042` overstates what ships** | It promises "poster cards, year, **and rating badges**", but `MediaCard.svelte` renders only `productionYear`; `CommunityRating`/`OfficialRating` appear solely as sort keys, never as a badge. Either build the badge or correct the requirement text — a requirement that describes unbuilt behaviour is worse than an untraced one | S |
|
||||||
| 10 | **Stray duplicate `JellyTauPlayer.kt`** | A copy exists at `src-tauri/android/app/src/main/java/.../player/JellyTauPlayer.kt`, outside the canonical `src-tauri/android/src` tree that `sync-android-sources.sh` reads. Two files with one name in a tree with a strict canonical-source rule is a trap for the next edit | S |
|
| 10 | **Stray duplicate `JellyTauPlayer.kt`** | A copy exists at `src-tauri/android/app/src/main/java/.../player/JellyTauPlayer.kt`, outside the canonical `src-tauri/android/src` tree that `sync-android-sources.sh` reads. Two files with one name in a tree with a strict canonical-source rule is a trap for the next edit | S |
|
||||||
| 11 | **Five files carry a disproportionate share of complexity** (audit D4) | `player/mod.rs` (4.7k lines), `repository/offline.rs` (4.7k), `repository/online.rs` (3.7k), `commands/player/mod.rs` (3.3k), `commands/download/mod.rs` (3.2k), plus `VideoPlayer.svelte` (2.8k). The same files the changelog keeps returning to for deadlocks and playback regressions. Not worth a speculative refactor — but the next time one needs substantial work, splitting it is likely cheaper than growing it | L |
|
| 11 | **Six modules carry a disproportionate share of the complexity** | `src-tauri/src/player/mod.rs` (4,732 lines), `src-tauri/src/repository/offline.rs` (4,705), `src-tauri/src/repository/online.rs` (3,760), `src-tauri/src/commands/player/mod.rs` (3,327), `src-tauri/src/commands/download/mod.rs` (3,238) and `src/lib/components/player/VideoPlayer.svelte` (2,786) — all still growing. The cost is not the line count itself, it is that **these are the same modules `CLAUDE.md`'s Gotchas section keeps having to warn about**: the deadlock rule about locking in event callbacks, the `AutoplayDecision` scrutinee, the "no lifecycle calls after an `await` in `onMount`" rule, the HLS `master.m3u8` rule, the download concurrency cap. A file that needs a standing warning in the project's onboarding document is a file whose invariants are no longer local to it, and every such warning is a rule a newcomer has to be *told* rather than one the structure enforces. **Recorded, not scheduled** — a speculative refactor of six files this size buys nothing on its own. The trigger is the next time one of them needs substantial work: splitting it then is likely cheaper than growing it, and each rule that moves from Gotchas into a module boundary is one fewer thing to remember | L |
|
||||||
|
|
||||||
|
|
||||||
### Linux Keyring Integration Workaround
|
### Linux Keyring Integration Workaround
|
||||||
@@ -843,7 +870,7 @@ deprecated in current Media3.)
|
|||||||
**Affected Files**:
|
**Affected Files**:
|
||||||
- [src/lib/components/player/AudioPlayer.svelte](../src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
|
- [src/lib/components/player/AudioPlayer.svelte](../src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
|
||||||
- [src/lib/components/player/MiniPlayer.svelte](../src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers
|
- [src/lib/components/player/MiniPlayer.svelte](../src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers
|
||||||
- [src/lib/services/playbackControl.ts](../src/lib/services/playbackControl.ts) - Position conversion
|
- [src/lib/utils/playbackUnits.ts](../src/lib/utils/playbackUnits.ts) - Position conversion (the shared helper the "Future Fix" below called for; `playbackControl.ts`, previously listed here, has since been removed)
|
||||||
- [src/lib/stores/playbackMode.ts](../src/lib/stores/playbackMode.ts) - Position conversion
|
- [src/lib/stores/playbackMode.ts](../src/lib/stores/playbackMode.ts) - Position conversion
|
||||||
- [src/lib/services/playbackReporting.ts](../src/lib/services/playbackReporting.ts) - Position conversion
|
- [src/lib/services/playbackReporting.ts](../src/lib/services/playbackReporting.ts) - Position conversion
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ Copy the boxes into the review comment (or the PR) and tick them.
|
|||||||
- [ ] Linked to existing URs, or new URs/DRs are allocated in
|
- [ ] Linked to existing URs, or new URs/DRs are allocated in
|
||||||
[requirements.md](../requirements.md).
|
[requirements.md](../requirements.md).
|
||||||
- [ ] Requirement-implementing code will carry `// TRACES:` comments (CLAUDE.md).
|
- [ ] Requirement-implementing code will carry `// TRACES:` comments (CLAUDE.md).
|
||||||
- [ ] Traceability coverage stays ≥ 50% (the CI gate).
|
- [ ] Traceability coverage stays ≥ 88% (the CI gate — a ratchet, so check
|
||||||
|
`bun run traces:coverage` rather than trusting this number).
|
||||||
|
|
||||||
## Conflicts & hygiene
|
## Conflicts & hygiene
|
||||||
|
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ Frontend (`bun run test`):
|
|||||||
|------|--------|
|
|------|--------|
|
||||||
| UT-105 | `favorites` store override precedence: store value beats `userData.isFavorite` beats `false` |
|
| UT-105 | `favorites` store override precedence: store value beats `userData.isFavorite` beats `false` |
|
||||||
| UT-106 | Un-hearting removes the item from a favourites list view (pure logic extracted to a `.ts` module, per the TrackList/episodeStrip pattern) |
|
| UT-106 | Un-hearting removes the item from a favourites list view (pure logic extracted to a `.ts` module, per the TrackList/episodeStrip pattern) |
|
||||||
| IT-0xx | `repositoryGetFavorites` param naming — add to [tauriIntegration.test.ts](../../src/lib/utils/tauriIntegration.test.ts): camelCase top-level params, scope serialised as `"movies"` etc. |
|
| IT-0xx | `repositoryGetFavorites` param naming — add to the IPC param-naming suite under `src/lib/utils/` (`tauriIntegration.test.ts` no longer exists — see the current camelCase guards in `src/lib/stores/`): camelCase top-level params, scope serialised as `"movies"` etc. |
|
||||||
|
|
||||||
Any component logic worth testing gets extracted into a plain `.ts` module first
|
Any component logic worth testing gets extracted into a plain `.ts` module first
|
||||||
(`favoritesView.ts`), rather than tested through the component.
|
(`favoritesView.ts`), rather than tested through the component.
|
||||||
|
|||||||
+14
-13
@@ -15,7 +15,7 @@ The CI/CD pipeline automatically validates that code changes are properly traced
|
|||||||
Traceability validation lives in `.gitea/workflows/traceability-check.yml`:
|
Traceability validation lives in `.gitea/workflows/traceability-check.yml`:
|
||||||
|
|
||||||
- ✅ Automatic trace extraction
|
- ✅ Automatic trace extraction
|
||||||
- ✅ Coverage validation against minimum threshold (82%, ratcheted)
|
- ✅ Coverage validation against minimum threshold (88%, ratcheted)
|
||||||
- ✅ Modified file checking
|
- ✅ Modified file checking
|
||||||
- ✅ Artifact preservation
|
- ✅ Artifact preservation
|
||||||
- ✅ Summary reports
|
- ✅ Summary reports
|
||||||
@@ -43,7 +43,7 @@ Extracts all TRACES comments from:
|
|||||||
|
|
||||||
### 2. Coverage Thresholds
|
### 2. Coverage Thresholds
|
||||||
The workflow checks:
|
The workflow checks:
|
||||||
- **Minimum overall coverage:** 82% (`MIN_THRESHOLD`)
|
- **Minimum overall coverage:** 88% (`MIN_THRESHOLD`)
|
||||||
|
|
||||||
Denominators are **derived from `docs/requirements.md` at run time** — they are
|
Denominators are **derived from `docs/requirements.md` at run time** — they are
|
||||||
never hardcoded here or in the workflow. Run `bun run traces:coverage` for the
|
never hardcoded here or in the workflow. Run `bun run traces:coverage` for the
|
||||||
@@ -67,9 +67,10 @@ or if it computes above 100%, which can only mean the gate is miscounting.
|
|||||||
#### Ratchet policy
|
#### Ratchet policy
|
||||||
|
|
||||||
`MIN_THRESHOLD` **only ever goes up.** It is deliberately set a few points below
|
`MIN_THRESHOLD` **only ever goes up.** It is deliberately set a few points below
|
||||||
the coverage actually achieved (82 against a real 86%), so a genuine regression
|
the coverage actually achieved (88 against a real ~90%), so a genuine regression
|
||||||
trips it. It previously sat at 50 while true coverage was 86%: nearly half the
|
trips it. It previously sat at 50 while true coverage was 86%: nearly half the
|
||||||
matrix could have rotted before CI objected.
|
matrix could have rotted before CI objected. It was ratcheted 50 → 82 when that
|
||||||
|
was found, and 82 → 88 once coverage had held above 88% for several releases.
|
||||||
|
|
||||||
When coverage rises durably, raise the threshold to just under the new figure.
|
When coverage rises durably, raise the threshold to just under the new figure.
|
||||||
**Never lower it to make a red build pass** — add the missing TRACES comments
|
**Never lower it to make a red build pass** — add the missing TRACES comments
|
||||||
@@ -151,13 +152,13 @@ TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
|
|||||||
|
|
||||||
### On Push to Main Branch
|
### On Push to Main Branch
|
||||||
1. ✅ Extracts all traces from code
|
1. ✅ Extracts all traces from code
|
||||||
2. ✅ Validates coverage is >= 82%
|
2. ✅ Validates coverage is >= 88%
|
||||||
3. ✅ Generates full traceability report
|
3. ✅ Generates full traceability report
|
||||||
4. ✅ Saves report as artifact
|
4. ✅ Saves report as artifact
|
||||||
|
|
||||||
### On Pull Request
|
### On Pull Request
|
||||||
1. ✅ Extracts all traces
|
1. ✅ Extracts all traces
|
||||||
2. ✅ Validates coverage >= 82%
|
2. ✅ Validates coverage >= 88%
|
||||||
3. ✅ Checks modified files for TRACES
|
3. ✅ Checks modified files for TRACES
|
||||||
4. ✅ Warns if new code lacks TRACES
|
4. ✅ Warns if new code lacks TRACES
|
||||||
5. ✅ Suggests proper format
|
5. ✅ Suggests proper format
|
||||||
@@ -165,7 +166,7 @@ TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
|
|||||||
|
|
||||||
### Failure Scenarios
|
### Failure Scenarios
|
||||||
The workflow **fails** (blocks merge) if:
|
The workflow **fails** (blocks merge) if:
|
||||||
- Coverage drops below 82%
|
- Coverage drops below 88%
|
||||||
- A `TRACES:` comment names an ID `docs/requirements.md` does not define
|
- A `TRACES:` comment names an ID `docs/requirements.md` does not define
|
||||||
- JSON extraction fails
|
- JSON extraction fails
|
||||||
- Invalid trace format
|
- Invalid trace format
|
||||||
@@ -203,12 +204,12 @@ below threshold. Numbers are deliberately not pinned here; the previous snapshot
|
|||||||
in this section (51%, 56/114) was stale by roughly 100 requirements and was what
|
in this section (51%, 56/114) was stale by roughly 100 requirements and was what
|
||||||
made the broken CI arithmetic look plausible for so long.
|
made the broken CI arithmetic look plausible for so long.
|
||||||
|
|
||||||
As of July 2026 overall coverage is ~86% (182/212).
|
As of August 2026 overall coverage is ~90%.
|
||||||
|
|
||||||
### Targets
|
### Targets
|
||||||
- **Short term** (Sprint): Maintain ≥82% overall (the current ratchet)
|
- **Short term** (Sprint): Maintain ≥88% overall (the current ratchet)
|
||||||
- **Medium term** (Month): Reach 70% overall coverage
|
- **Medium term** (Month): Hold above 90% and ratchet the gate to match
|
||||||
- **Long term** (Release): Reach 90% coverage with focus on:
|
- **Long term** (Release): Reach 95% coverage with focus on:
|
||||||
- IR requirements (API clients)
|
- IR requirements (API clients)
|
||||||
- JA requirements (Jellyfin API endpoints)
|
- JA requirements (Jellyfin API endpoints)
|
||||||
- Remaining UR/DR requirements
|
- Remaining UR/DR requirements
|
||||||
@@ -241,14 +242,14 @@ When submitting a pull request:
|
|||||||
|
|
||||||
- [ ] All new code has TRACES comments linking to requirements
|
- [ ] All new code has TRACES comments linking to requirements
|
||||||
- [ ] TRACES format is correct: `// TRACES: UR-001 | DR-002`
|
- [ ] TRACES format is correct: `// TRACES: UR-001 | DR-002`
|
||||||
- [ ] Workflow passes (coverage ≥ 82%)
|
- [ ] Workflow passes (coverage ≥ 88%)
|
||||||
- [ ] No coverage regressions
|
- [ ] No coverage regressions
|
||||||
- [ ] Artifact traceability report was generated
|
- [ ] Artifact traceability report was generated
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### "Coverage below minimum threshold"
|
### "Coverage below minimum threshold"
|
||||||
**Problem:** Workflow fails with coverage < 82%
|
**Problem:** Workflow fails with coverage < 88%
|
||||||
|
|
||||||
**Solution:**
|
**Solution:**
|
||||||
1. Run `bun run traces:json` locally
|
1. Run `bun run traces:json` locally
|
||||||
|
|||||||
+7016
-6999
File diff suppressed because it is too large
Load Diff
+10
-10
@@ -52,10 +52,10 @@ fn test_queue_next() {
|
|||||||
|
|
||||||
## Where to Find Requirements
|
## Where to Find Requirements
|
||||||
|
|
||||||
1. **User Requirements (UR):** [README.md](README.md#1-user-requirements)
|
1. **User Requirements (UR):** [requirements.md](requirements.md#1-user-requirements)
|
||||||
2. **Integration Requirements (IR):** [README.md](README.md#21-integration-requirements)
|
2. **Integration Requirements (IR):** [requirements.md](requirements.md#21-integration-requirements)
|
||||||
3. **Development Requirements (DR):** [README.md](README.md#23-development-requirements)
|
3. **Development Requirements (DR):** [requirements.md](requirements.md#23-development-requirements)
|
||||||
4. **Jellyfin API (JA):** [README.md](README.md#22-jellyfin-api-requirements)
|
4. **Jellyfin API (JA):** [requirements.md](requirements.md#22-jellyfin-api-requirements)
|
||||||
|
|
||||||
## How to Add TRACES
|
## How to Add TRACES
|
||||||
|
|
||||||
@@ -139,13 +139,13 @@ bun run traces:json | jq '.requirements."UR-005"'
|
|||||||
## CI/CD Validation
|
## CI/CD Validation
|
||||||
|
|
||||||
The workflow automatically checks:
|
The workflow automatically checks:
|
||||||
- ✅ Coverage stays >= 82% (a ratchet — raise it, never lower it)
|
- ✅ Coverage stays >= 88% (a ratchet — raise it, never lower it)
|
||||||
- ✅ Every traced ID is defined in `docs/requirements.md`
|
- ✅ Every traced ID is defined in `docs/requirements.md`
|
||||||
- ✅ New files have TRACES
|
- ✅ New files have TRACES
|
||||||
- ✅ JSON format is valid
|
- ✅ JSON format is valid
|
||||||
- ✅ Reports are generated
|
- ✅ Reports are generated
|
||||||
|
|
||||||
See [traceability-ci.md](docs/traceability-ci.md) for details.
|
See [traceability-ci.md](traceability-ci.md) for details.
|
||||||
|
|
||||||
## Tips & Tricks
|
## Tips & Tricks
|
||||||
|
|
||||||
@@ -199,10 +199,10 @@ A: Yes! TRACES show your implementation plan.
|
|||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
- [Full Traceability Matrix](docs/traceability.md)
|
- [Full Traceability Matrix](traceability.md)
|
||||||
- [CI/CD Pipeline Guide](docs/traceability-ci.md)
|
- [CI/CD Pipeline Guide](traceability-ci.md)
|
||||||
- [Requirements Specification](README.md)
|
- [Requirements Specification](requirements.md)
|
||||||
- [Extraction Script](scripts/README.md#extract-tracests)
|
- [Extraction Script](../scripts/README.md#extract-tracests)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
# it can bundle the NSIS installer from a Linux host.
|
# it can bundle the NSIS installer from a Linux host.
|
||||||
#
|
#
|
||||||
# Playback on Windows: video renders via WebView2 and audio via the webview
|
# Playback on Windows: video renders via WebView2 and audio via the webview
|
||||||
# <audio> backend (WebviewAudioBackend) — see docs/build-windows.md.
|
# <audio> backend (WebviewAudioBackend) — see docs/build/build-windows.md.
|
||||||
#
|
#
|
||||||
# Requirements (present in the Docker windows-cross target / unified builder):
|
# Requirements (present in the Docker windows-cross target / unified builder):
|
||||||
# - rustup target x86_64-pc-windows-msvc
|
# - rustup target x86_64-pc-windows-msvc
|
||||||
|
|||||||
Executable
+176
@@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Documentation link integrity: every relative markdown link must point at a
|
||||||
|
# file that exists.
|
||||||
|
#
|
||||||
|
# Implements DR-208 (see docs/requirements.md).
|
||||||
|
#
|
||||||
|
# Why this exists: docs/traceability.md is generated into docs/ while its file
|
||||||
|
# links were emitted repo-root-relative, so all ~2,800 of them resolved to
|
||||||
|
# docs/src-tauri/… and 404'd — in the Gitea repo browser and on the published
|
||||||
|
# mdBook site alike. Nobody clicks 2,800 links, so it went unnoticed for months.
|
||||||
|
# Several hand-written docs had the same defect at smaller scale: links to files
|
||||||
|
# that had been deleted, and links written as if the doc lived at the repo root.
|
||||||
|
# A link that does not resolve is a documentation defect of the same kind as a
|
||||||
|
# compile error, and a grep is enough to catch the whole class.
|
||||||
|
#
|
||||||
|
# What it checks: for every tracked `.md` file, every inline markdown link
|
||||||
|
# `[text](target)` whose target is a *path* — the target is resolved relative to
|
||||||
|
# the directory of the file containing it, and must exist on disk.
|
||||||
|
#
|
||||||
|
# ⚠️ It validates PATHS, NOT ANCHORS. A green run does not mean the links land
|
||||||
|
# where the text claims.
|
||||||
|
#
|
||||||
|
# 🔴 What it deliberately CANNOT see (do not read a green run as proof):
|
||||||
|
# - **Anchor fragments.** `foo.md#some-heading` is checked only as `foo.md`.
|
||||||
|
# Resolving the fragment needs a markdown renderer's heading-slug rules
|
||||||
|
# (which differ between Gitea, GitHub and mdBook), so a link to a heading
|
||||||
|
# that was renamed still passes here. That is a deliberate scope cut, not an
|
||||||
|
# oversight.
|
||||||
|
# - **External URLs.** http(s):// and mailto: are skipped. Checking them means
|
||||||
|
# network I/O in a gate, which makes the gate flaky and slow; link rot in an
|
||||||
|
# external URL is also not something a commit can break.
|
||||||
|
# - **Reference-style links** (`[text][ref]` with a separate `[ref]: target`
|
||||||
|
# definition) and bare autolinks. This project writes inline links; add the
|
||||||
|
# pattern here if that changes.
|
||||||
|
# - **Links inside fenced code blocks**, which are intentionally skipped —
|
||||||
|
# a template being *shown* to the reader (e.g. the release-notes template in
|
||||||
|
# docs/release-checklist.md) is sample text, not a live link, and its targets
|
||||||
|
# are resolved wherever it is eventually pasted, not from the docs tree.
|
||||||
|
# - **A link that resolves to the wrong existing file.** Existence is not
|
||||||
|
# correctness.
|
||||||
|
#
|
||||||
|
# Usage: bash scripts/check-doc-links.sh
|
||||||
|
# Exits non-zero, listing file:line and the unresolved target, on any failure.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
# Generated, vendored or build-output trees. Their markdown is not authored here
|
||||||
|
# and their link targets are not ours to fix.
|
||||||
|
EXCLUDES=(
|
||||||
|
"./node_modules/*"
|
||||||
|
"./.svelte-kit/*"
|
||||||
|
"./build/*"
|
||||||
|
"./dist/*"
|
||||||
|
"./src-tauri/gen/*"
|
||||||
|
"./src-tauri/target/*"
|
||||||
|
"./.git/*"
|
||||||
|
# Agent/dev scratch worktrees (.claude/worktrees is itself git-ignored). These
|
||||||
|
# are full checkouts of the repo, so without this the checker walks every
|
||||||
|
# in-flight branch and reports its links as if they were ours.
|
||||||
|
"./.claude/*"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Targets that do not exist in the repo *by design* because the publish-docs job
|
||||||
|
# writes them into docs/ at build time (see .gitea/workflows/publish-docs.yml).
|
||||||
|
# Keep this list to genuinely generated pages — anything else here is a broken
|
||||||
|
# link being hidden.
|
||||||
|
GENERATED_TARGETS=(
|
||||||
|
"./docs/README.md" # the site's landing page, written by publish-docs
|
||||||
|
"./docs/api-redirect.md" # the rustdoc redirect stub, likewise
|
||||||
|
)
|
||||||
|
|
||||||
|
is_generated() {
|
||||||
|
local candidate="$1"
|
||||||
|
for generated in "${GENERATED_TARGETS[@]}"; do
|
||||||
|
[[ "$candidate" == "$generated" ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "🔎 Checking relative markdown links resolve to files on disk…"
|
||||||
|
|
||||||
|
# Build the find(1) prune expression from EXCLUDES.
|
||||||
|
find_args=(. )
|
||||||
|
for pattern in "${EXCLUDES[@]}"; do
|
||||||
|
find_args+=(-path "$pattern" -prune -o)
|
||||||
|
done
|
||||||
|
find_args+=(-name "*.md" -type f -print)
|
||||||
|
|
||||||
|
mapfile -t md_files < <(find "${find_args[@]}" | sort)
|
||||||
|
|
||||||
|
echo " ${#md_files[@]} markdown files"
|
||||||
|
|
||||||
|
broken=""
|
||||||
|
checked=0
|
||||||
|
|
||||||
|
for md in "${md_files[@]}"; do
|
||||||
|
dir="$(dirname "$md")"
|
||||||
|
|
||||||
|
# One documented exception: docs-site/SUMMARY.md is mdBook's table of
|
||||||
|
# contents, and the publish-docs job copies it *into* docs/ before rendering
|
||||||
|
# (book.toml sets src = "../docs"). Its links are therefore written relative
|
||||||
|
# to docs/, not to the directory the file is stored in. Resolving it from
|
||||||
|
# docs/ is what actually validates it — and it is the check that catches a
|
||||||
|
# SUMMARY entry pointing at a page that does not exist, which mdBook itself
|
||||||
|
# only warns about.
|
||||||
|
if [[ "$md" == "./docs-site/SUMMARY.md" ]]; then
|
||||||
|
dir="./docs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Strip fenced code blocks (``` and ~~~) before extracting links, so sample
|
||||||
|
# markdown shown to the reader is not checked as if it were a live link.
|
||||||
|
# Line numbers are preserved by blanking the lines rather than deleting them.
|
||||||
|
#
|
||||||
|
# Then emit "lineno<TAB>target" for each inline link on each surviving line.
|
||||||
|
while IFS=$'\t' read -r lineno target; do
|
||||||
|
[[ -z "${target:-}" ]] && continue
|
||||||
|
|
||||||
|
# Skip external schemes and pure-anchor links.
|
||||||
|
case "$target" in
|
||||||
|
http://*|https://*|mailto:*|ftp://*|"#"*|"") continue ;;
|
||||||
|
# A protocol-relative or scheme-ish target we do not resolve.
|
||||||
|
//*) continue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Drop any anchor fragment and query string — we check the path only.
|
||||||
|
path="${target%%#*}"
|
||||||
|
path="${path%%\?*}"
|
||||||
|
[[ -z "$path" ]] && continue
|
||||||
|
|
||||||
|
# Percent-decode: SvelteKit route directories are literally named `[id]`,
|
||||||
|
# which docs link as `%5Bid%5D`, and spaces appear as `%20`.
|
||||||
|
if [[ "$path" == *%* ]]; then
|
||||||
|
path="$(printf '%b' "${path//%/\\x}")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
checked=$((checked + 1))
|
||||||
|
|
||||||
|
if is_generated "$dir/$path"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -e "$dir/$path" ]]; then
|
||||||
|
broken+="${md}:${lineno} -> ${target}"$'\n'
|
||||||
|
fi
|
||||||
|
done < <(
|
||||||
|
awk '
|
||||||
|
/^[[:space:]]*(```|~~~)/ { fence = !fence; print ""; next }
|
||||||
|
fence { print ""; next }
|
||||||
|
{ print }
|
||||||
|
' "$md" |
|
||||||
|
grep -noE '\]\([^)[:space:]]+' |
|
||||||
|
sed -E 's/^([0-9]+):\]\(/\1\t/'
|
||||||
|
)
|
||||||
|
done
|
||||||
|
|
||||||
|
echo " $checked relative links checked"
|
||||||
|
|
||||||
|
if [[ -n "$broken" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ Broken documentation links — these targets do not exist on disk:"
|
||||||
|
echo ""
|
||||||
|
echo "$broken" | sed 's/^/ /'
|
||||||
|
echo " Each link is resolved relative to the directory of the file it is in."
|
||||||
|
echo " The usual causes:"
|
||||||
|
echo " • the target file was moved or deleted — update or drop the link;"
|
||||||
|
echo " • the link was written as if the doc lived at the repo root — a doc"
|
||||||
|
echo " in docs/ needs '../' to reach src/, scripts/ or CHANGELOG.md;"
|
||||||
|
echo " • a generated doc emits repo-root-relative hrefs — fix the"
|
||||||
|
echo " generator, not the output (see scripts/extract-traces.ts)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ All relative documentation links resolve."
|
||||||
|
echo " (Reminder: paths only — anchors and external URLs are NOT checked.)"
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
*
|
*
|
||||||
* @req-test: UT-089 - Requirement definitions parsed from requirements.md
|
* @req-test: UT-089 - Requirement definitions parsed from requirements.md
|
||||||
* @req-test: UT-090 - Coverage is the intersection of traced and defined IDs
|
* @req-test: UT-090 - Coverage is the intersection of traced and defined IDs
|
||||||
|
* @req-test: UT-202 - Generated matrix links resolve from docs/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
@@ -20,7 +21,10 @@ import {
|
|||||||
countDefinedRequirements,
|
countDefinedRequirements,
|
||||||
computeCoverage,
|
computeCoverage,
|
||||||
findDanglingIds,
|
findDanglingIds,
|
||||||
|
formatMatrixFileLink,
|
||||||
|
generateMarkdown,
|
||||||
MIN_COVERAGE_PERCENT,
|
MIN_COVERAGE_PERCENT,
|
||||||
|
type TracesData,
|
||||||
} from "./extract-traces";
|
} from "./extract-traces";
|
||||||
|
|
||||||
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
|
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
|
||||||
@@ -250,6 +254,73 @@ describe("computeCoverage", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("generated matrix file links", () => {
|
||||||
|
// Regression: the generator emitted the repo-root-relative path as the href
|
||||||
|
// (`](src-tauri/src/…)`), but writes its output to docs/traceability.md — so
|
||||||
|
// every one of the ~2,800 links resolved to docs/src-tauri/… and 404'd, in
|
||||||
|
// the repo browser and on the published mdBook site. The markdown generator
|
||||||
|
// had no test at all, which is why it survived. UT-202.
|
||||||
|
//
|
||||||
|
// @req-test: UT-202
|
||||||
|
|
||||||
|
/** A minimal TracesData whose single entry points at a file that really exists. */
|
||||||
|
function fixture(file: string, line = 12): TracesData {
|
||||||
|
return {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
totalFiles: 1,
|
||||||
|
totalTraces: 1,
|
||||||
|
requirements: {
|
||||||
|
"DR-093": [{ file, line, context: "export function x() {}" }],
|
||||||
|
},
|
||||||
|
byType: { UR: [], IR: [], DR: ["DR-093"], JA: [] },
|
||||||
|
} as TracesData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull the href out of the first `- **File:** [`x`](href)` line. */
|
||||||
|
function firstHref(md: string): string {
|
||||||
|
const m = md.match(/^- \*\*File:\*\* \[`[^`]+`\]\(([^)]+)\)/m);
|
||||||
|
expect(m).not.toBeNull();
|
||||||
|
return m![1];
|
||||||
|
}
|
||||||
|
|
||||||
|
it("emits an href that resolves, from docs/, to a file that exists", () => {
|
||||||
|
// Use a real repo file so "exists on disk" is a genuine assertion.
|
||||||
|
const target = "scripts/extract-traces.ts";
|
||||||
|
const md = generateMarkdown(fixture(target));
|
||||||
|
|
||||||
|
const href = firstHref(md);
|
||||||
|
const [relPath] = href.split("#");
|
||||||
|
|
||||||
|
// traceability.md is written to docs/, so links resolve from there.
|
||||||
|
const resolved = path.resolve(HERE, "../docs", relPath);
|
||||||
|
expect(fs.existsSync(resolved)).toBe(true);
|
||||||
|
expect(resolved).toBe(path.resolve(HERE, "..", target));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the repo-root-relative path as the visible link text", () => {
|
||||||
|
// The text is what a developer copies into an editor or a grep; only the
|
||||||
|
// href is rewritten for the docs/ location.
|
||||||
|
const md = generateMarkdown(fixture("src-tauri/src/lib.rs"));
|
||||||
|
expect(md).toContain("[`src-tauri/src/lib.rs`]");
|
||||||
|
expect(md).not.toContain("[`../src-tauri/src/lib.rs`]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the #Lnn line anchor on the href", () => {
|
||||||
|
const link = formatMatrixFileLink("scripts/extract-traces.ts", 427);
|
||||||
|
expect(link).toBe(
|
||||||
|
"[`scripts/extract-traces.ts`](../scripts/extract-traces.ts#L427)"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not produce a bare repo-root href, which resolves to docs/<path>", () => {
|
||||||
|
const md = generateMarkdown(fixture("scripts/extract-traces.ts"));
|
||||||
|
const href = firstHref(md);
|
||||||
|
expect(href.startsWith("../")).toBe(true);
|
||||||
|
// The pre-fix output — the exact shape that produced docs/scripts/….
|
||||||
|
expect(href.startsWith("scripts/")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("live requirements.md", () => {
|
describe("live requirements.md", () => {
|
||||||
it("parses the real file to the counts the CI gate must use", () => {
|
it("parses the real file to the counts the CI gate must use", () => {
|
||||||
// Guards the specific regression: CI hardcoded UR/39, IR/24, DR/48, JA/3
|
// Guards the specific regression: CI hardcoded UR/39, IR/24, DR/48, JA/3
|
||||||
@@ -262,7 +333,7 @@ describe("live requirements.md", () => {
|
|||||||
);
|
);
|
||||||
const defined = countDefinedRequirements(md);
|
const defined = countDefinedRequirements(md);
|
||||||
|
|
||||||
expect(defined.UR).toBe(75);
|
expect(defined.UR).toBe(76);
|
||||||
expect(defined.IR).toBe(32);
|
expect(defined.IR).toBe(32);
|
||||||
// 192 = 187 + four requirements added independently on four audit branches,
|
// 192 = 187 + four requirements added independently on four audit branches,
|
||||||
// plus DR-201 (lockscreen skip resolution). Originally 191 = 187 + four
|
// plus DR-201 (lockscreen skip resolution). Originally 191 = 187 + four
|
||||||
@@ -272,9 +343,13 @@ describe("live requirements.md", () => {
|
|||||||
// merge, where it collided). Each branch bumped for its own — merged,
|
// merge, where it collided). Each branch bumped for its own — merged,
|
||||||
// they sum. Resolve this by summing, never by taking one side. 193 adds
|
// they sum. Resolve this by summing, never by taking one side. 193 adds
|
||||||
// DR-202 (video keeps the display awake), 194 DR-203 (the handoff
|
// DR-202 (video keeps the display awake), 194 DR-203 (the handoff
|
||||||
// transcode refusing the player's own load-error retry).
|
// transcode refusing the player's own load-error retry). 200 adds the
|
||||||
expect(defined.DR).toBe(194);
|
// six tooling/quality requirements DR-204..DR-209 (logging facade, lint
|
||||||
|
// gate, pinned toolchain, pre-commit hook, doc-link check, server-side
|
||||||
|
// library folder exclusion); UR rises to 76 with UR-076, which DR-209
|
||||||
|
// serves.
|
||||||
|
expect(defined.DR).toBe(200);
|
||||||
expect(defined.JA).toBe(36);
|
expect(defined.JA).toBe(36);
|
||||||
expect(defined.total).toBe(337);
|
expect(defined.total).toBe(344);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ interface RequirementMapping {
|
|||||||
[reqId: string]: TraceEntry[];
|
[reqId: string]: TraceEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TracesData {
|
export interface TracesData {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
totalFiles: number;
|
totalFiles: number;
|
||||||
totalTraces: number;
|
totalTraces: number;
|
||||||
@@ -366,7 +366,34 @@ export function readDefinedRequirements(): DefinedRequirements {
|
|||||||
return countDefinedRequirements(fs.readFileSync(reqPath, "utf-8"));
|
return countDefinedRequirements(fs.readFileSync(reqPath, "utf-8"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateMarkdown(data: TracesData): string {
|
/**
|
||||||
|
* Path prefix that turns a repo-root-relative file path into a link target that
|
||||||
|
* resolves from `docs/traceability.md`, where this markdown is written.
|
||||||
|
*
|
||||||
|
* The generated matrix lives one directory below the repo root, so a bare
|
||||||
|
* `src-tauri/src/player/mod.rs` href resolves to `docs/src-tauri/…` and 404s —
|
||||||
|
* in the repo browser and on the published mdBook site alike. Every file link
|
||||||
|
* in the matrix was dead for this reason. The *display text* stays
|
||||||
|
* repo-root-relative (that is the path a developer types and greps for); only
|
||||||
|
* the href is rewritten.
|
||||||
|
*
|
||||||
|
* TRACES: | DR-093 | UT-202
|
||||||
|
*/
|
||||||
|
export const MATRIX_LINK_PREFIX = "../";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the ``[`path`](href#Lnn)`` link used for one trace entry in the matrix.
|
||||||
|
*
|
||||||
|
* Exported so extract-traces.test.ts can resolve a generated href against
|
||||||
|
* `docs/` and assert the target exists on disk.
|
||||||
|
*
|
||||||
|
* TRACES: | DR-093 | UT-202
|
||||||
|
*/
|
||||||
|
export function formatMatrixFileLink(file: string, line: number): string {
|
||||||
|
return `[\`${file}\`](${MATRIX_LINK_PREFIX}${file}#L${line})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateMarkdown(data: TracesData): string {
|
||||||
let md = `# Code Traceability Matrix
|
let md = `# Code Traceability Matrix
|
||||||
|
|
||||||
**Generated:** ${new Date(data.timestamp).toLocaleString()}
|
**Generated:** ${new Date(data.timestamp).toLocaleString()}
|
||||||
@@ -424,7 +451,7 @@ ${data.byType.JA.join(", ")}
|
|||||||
md += `**Locations:** ${entries.length} file(s)\n\n`;
|
md += `**Locations:** ${entries.length} file(s)\n\n`;
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
md += `- **File:** [\`${entry.file}\`](${entry.file}#L${entry.line})\n`;
|
md += `- **File:** ${formatMatrixFileLink(entry.file, entry.line)}\n`;
|
||||||
md += ` - **Line:** ${entry.line}\n`;
|
md += ` - **Line:** ${entry.line}\n`;
|
||||||
const contextPreview = entry.context.substring(0, 70);
|
const contextPreview = entry.context.substring(0, 70);
|
||||||
md += ` - **Context:** \`${contextPreview}${entry.context.length > 70 ? "..." : ""}\`\n`;
|
md += ` - **Context:** \`${contextPreview}${entry.context.length > 70 ? "..." : ""}\`\n`;
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import type {
|
|||||||
PlaylistEntry,
|
PlaylistEntry,
|
||||||
PlaylistCreatedResult,
|
PlaylistCreatedResult,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("RepositoryClient");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository client - thin wrapper over Rust HybridRepository
|
* Repository client - thin wrapper over Rust HybridRepository
|
||||||
@@ -39,14 +42,14 @@ export class RepositoryClient {
|
|||||||
accessToken: string,
|
accessToken: string,
|
||||||
serverId: string
|
serverId: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
console.log("[RepositoryClient] Creating Rust repository...");
|
log.debug("Creating Rust repository...");
|
||||||
this.handle = await commands.repositoryCreate(serverUrl, userId, accessToken, serverId);
|
this.handle = await commands.repositoryCreate(serverUrl, userId, accessToken, serverId);
|
||||||
|
|
||||||
// Store for URL construction
|
// Store for URL construction
|
||||||
this._serverUrl = serverUrl;
|
this._serverUrl = serverUrl;
|
||||||
this._accessToken = accessToken;
|
this._accessToken = accessToken;
|
||||||
|
|
||||||
console.log("[RepositoryClient] Repository created with handle:", this.handle);
|
log.debug("Repository created with handle:", this.handle);
|
||||||
return this.handle;
|
return this.handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
import { haptics } from "$lib/utils/haptics";
|
import { haptics } from "$lib/utils/haptics";
|
||||||
import { toast } from "$lib/stores/toast";
|
import { toast } from "$lib/stores/toast";
|
||||||
import { favoriteOverrides } from "$lib/stores/favorites";
|
import { favoriteOverrides } from "$lib/stores/favorites";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("FavoriteButton");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -78,7 +81,7 @@
|
|||||||
isAnimating = false;
|
isAnimating = false;
|
||||||
}, 600);
|
}, 600);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to toggle favorite:", error);
|
log.error("Failed to toggle favorite:", error);
|
||||||
toast.show("Failed to update favorites", "error");
|
toast.show("Failed to update favorites", "error");
|
||||||
isAnimating = false;
|
isAnimating = false;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
|
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("DownloadItem");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
download: DownloadInfo;
|
download: DownloadInfo;
|
||||||
@@ -69,7 +72,7 @@
|
|||||||
// Refresh to update UI
|
// Refresh to update UI
|
||||||
await downloads.refresh(download.userId);
|
await downloads.refresh(download.userId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to pause download:", error);
|
log.error("Failed to pause download:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +82,7 @@
|
|||||||
// Refresh to update UI
|
// Refresh to update UI
|
||||||
await downloads.refresh(download.userId);
|
await downloads.refresh(download.userId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to resume download:", error);
|
log.error("Failed to resume download:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +92,7 @@
|
|||||||
// Refresh to update UI
|
// Refresh to update UI
|
||||||
await downloads.refresh(download.userId);
|
await downloads.refresh(download.userId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to cancel download:", error);
|
log.error("Failed to cancel download:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +102,7 @@
|
|||||||
// Refresh to update UI
|
// Refresh to update UI
|
||||||
await downloads.refresh(download.userId);
|
await downloads.refresh(download.userId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete download:", error);
|
log.error("Failed to delete download:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
import { downloads } from "$lib/stores/downloads";
|
import { downloads } from "$lib/stores/downloads";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("AlbumDownloadButton");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
albumId: string;
|
albumId: string;
|
||||||
@@ -60,7 +63,7 @@
|
|||||||
try {
|
try {
|
||||||
const userId = $auth.user?.id;
|
const userId = $auth.user?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
console.error("No user ID found");
|
log.error("No user ID found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +98,7 @@
|
|||||||
await downloads.refresh(userId);
|
await downloads.refresh(userId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Album download operation failed:", error);
|
log.error("Album download operation failed:", error);
|
||||||
} finally {
|
} finally {
|
||||||
isProcessing = false;
|
isProcessing = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||||
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("ArtistDetailView");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
artist: MediaItem;
|
artist: MediaItem;
|
||||||
@@ -47,7 +50,7 @@
|
|||||||
});
|
});
|
||||||
albums = albumsResult.items.filter(item => item.kind === "album");
|
albums = albumsResult.items.filter(item => item.kind === "album");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to load albums:", e);
|
log.warn("Failed to load albums:", e);
|
||||||
} finally {
|
} finally {
|
||||||
albumsLoading = false;
|
albumsLoading = false;
|
||||||
}
|
}
|
||||||
@@ -62,7 +65,7 @@
|
|||||||
});
|
});
|
||||||
topTracks = tracksResult.items.filter(item => item.kind === "track");
|
topTracks = tracksResult.items.filter(item => item.kind === "track");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to load tracks:", e);
|
log.warn("Failed to load tracks:", e);
|
||||||
} finally {
|
} finally {
|
||||||
tracksLoading = false;
|
tracksLoading = false;
|
||||||
}
|
}
|
||||||
@@ -82,14 +85,14 @@
|
|||||||
.slice(0, 6);
|
.slice(0, 6);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to load related artists:", e);
|
log.warn("Failed to load related artists:", e);
|
||||||
} finally {
|
} finally {
|
||||||
artistsLoading = false;
|
artistsLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
singlesLoading = false;
|
singlesLoading = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error loading artist content:", e);
|
log.error("Error loading artist content:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { isServerReachable } from "$lib/stores/connectivity";
|
import { isServerReachable } from "$lib/stores/connectivity";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("ClearHistoryButton");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Series or season id to clear. */
|
/** Series or season id to clear. */
|
||||||
@@ -51,7 +54,7 @@
|
|||||||
await auth.getRepository().clearWatchHistory(itemId);
|
await auth.getRepository().clearWatchHistory(itemId);
|
||||||
onCleared?.();
|
onCleared?.();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to clear watch history:", e);
|
log.error("Failed to clear watch history:", e);
|
||||||
alert(
|
alert(
|
||||||
`Could not clear watch history: ${e instanceof Error ? e.message : String(e)}`
|
`Could not clear watch history: ${e instanceof Error ? e.message : String(e)}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
import DownloadButtonCore from "./DownloadButtonCore.svelte";
|
import DownloadButtonCore from "./DownloadButtonCore.svelte";
|
||||||
import type { DownloadState } from "./DownloadButtonCore.svelte";
|
import type { DownloadState } from "./DownloadButtonCore.svelte";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("DownloadButton");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single audio track download button
|
* Single audio track download button
|
||||||
@@ -39,7 +42,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function handleClick() {
|
async function handleClick() {
|
||||||
console.log("🖱️ Download button clicked! Current status:", status);
|
log.debug("🖱️ Download button clicked! Current status:", status);
|
||||||
if (isProcessing) return;
|
if (isProcessing) return;
|
||||||
|
|
||||||
isProcessing = true;
|
isProcessing = true;
|
||||||
@@ -63,25 +66,25 @@
|
|||||||
// Start download
|
// Start download
|
||||||
const userId = $auth.user?.id;
|
const userId = $auth.user?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
console.error("No user ID found");
|
log.error("No user ID found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
|
|
||||||
console.log("🎯 Starting download for item:", itemId);
|
log.debug("🎯 Starting download for item:", itemId);
|
||||||
|
|
||||||
// Get stream URL
|
// Get stream URL
|
||||||
const streamUrl = await repo.getAudioStreamUrl(itemId);
|
const streamUrl = await repo.getAudioStreamUrl(itemId);
|
||||||
console.log(" Stream URL obtained:", streamUrl?.substring(0, 50) + "...");
|
log.debug(" Stream URL obtained:", streamUrl?.substring(0, 50) + "...");
|
||||||
if (!streamUrl) {
|
if (!streamUrl) {
|
||||||
throw new Error("Failed to get stream URL");
|
throw new Error("Failed to get stream URL");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get target directory
|
// Get target directory
|
||||||
const targetDir = await commands.storageGetPath();
|
const targetDir = await commands.storageGetPath();
|
||||||
console.log(" Target directory:", targetDir);
|
log.debug(" Target directory:", targetDir);
|
||||||
|
|
||||||
// Queue and start download in single atomic operation
|
// Queue and start download in single atomic operation
|
||||||
const downloadId = await commands.downloadItemAndStart({
|
const downloadId = await commands.downloadItemAndStart({
|
||||||
@@ -93,16 +96,16 @@
|
|||||||
artistName: artistName || null,
|
artistName: artistName || null,
|
||||||
albumName: albumName || null,
|
albumName: albumName || null,
|
||||||
});
|
});
|
||||||
console.log(" Download queued and started with ID:", downloadId);
|
log.debug(" Download queued and started with ID:", downloadId);
|
||||||
|
|
||||||
// Refresh downloads list
|
// Refresh downloads list
|
||||||
await downloads.refresh(userId);
|
await downloads.refresh(userId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("❌ Failed to start download:", e);
|
log.error("❌ Failed to start download:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Download operation failed:", error);
|
log.error("Download operation failed:", error);
|
||||||
} finally {
|
} finally {
|
||||||
isProcessing = false;
|
isProcessing = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
||||||
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
|
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
|
||||||
import type { Genre, MediaItem, ItemType } from "$lib/api/types";
|
import type { Genre, MediaItem, ItemType } from "$lib/api/types";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("GenericGenreBrowser");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic genre browser supporting Movies, Music Albums, and TV Series
|
* Generic genre browser supporting Movies, Music Albums, and TV Series
|
||||||
@@ -92,7 +95,7 @@
|
|||||||
genres = result.sort((a, b) => a.name.localeCompare(b.name));
|
genres = result.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
applyFilter();
|
applyFilter();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load genres:", e);
|
log.error("Failed to load genres:", e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -115,7 +118,7 @@
|
|||||||
});
|
});
|
||||||
genreItems = result.items;
|
genreItems = result.items;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load genre items:", e);
|
log.error("Failed to load genre items:", e);
|
||||||
} finally {
|
} finally {
|
||||||
loadingItems = false;
|
loadingItems = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
import TrackList from "./TrackList.svelte";
|
import TrackList from "./TrackList.svelte";
|
||||||
import AlphabetScrollBar from "./AlphabetScrollBar.svelte";
|
import AlphabetScrollBar from "./AlphabetScrollBar.svelte";
|
||||||
import { excludePodcasts } from "$lib/utils/podcastFilter";
|
import { excludePodcasts } from "$lib/utils/podcastFilter";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("GenericMediaListPage");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic media list page supporting Albums, Artists, Playlists, and Tracks
|
* Generic media list page supporting Albums, Artists, Playlists, and Tracks
|
||||||
@@ -154,7 +157,7 @@
|
|||||||
items = excludePodcasts(result.items);
|
items = excludePodcasts(result.items);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed to load ${config.itemType}:`, e);
|
log.error(`Failed to load ${config.itemType}:`, e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||||
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("MediaCard");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: MediaItem | Library;
|
item: MediaItem | Library;
|
||||||
@@ -171,7 +174,7 @@
|
|||||||
media.albumName ?? undefined
|
media.albumName ?? undefined
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[MediaCard] Failed to queue download:", err);
|
log.error("Failed to queue download:", err);
|
||||||
queueError = "Failed to queue";
|
queueError = "Failed to queue";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
import LibraryGrid from "./LibraryGrid.svelte";
|
import LibraryGrid from "./LibraryGrid.svelte";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("PersonDetailView");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
person: MediaItem;
|
person: MediaItem;
|
||||||
@@ -34,7 +37,7 @@
|
|||||||
movies = result.items.filter(item => item.kind === "movie");
|
movies = result.items.filter(item => item.kind === "movie");
|
||||||
series = result.items.filter(item => item.kind === "series");
|
series = result.items.filter(item => item.kind === "series");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load filmography:", e);
|
log.error("Failed to load filmography:", e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||||
import { favoriteOverrides } from "$lib/stores/favorites";
|
import { favoriteOverrides } from "$lib/stores/favorites";
|
||||||
import { formatDuration } from "$lib/utils/duration";
|
import { formatDuration } from "$lib/utils/duration";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("PlaylistDetail");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
playlist: MediaItem;
|
playlist: MediaItem;
|
||||||
@@ -40,7 +43,7 @@
|
|||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
entries = await repo.getPlaylistItems(playlist.id);
|
entries = await repo.getPlaylistItems(playlist.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[PlaylistDetail] Failed to load items:", e);
|
log.error("Failed to load items:", e);
|
||||||
toast.error("Failed to load playlist items");
|
toast.error("Failed to load playlist items");
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -62,7 +65,7 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[PlaylistDetail] Failed to play all:", e);
|
log.error("Failed to play all:", e);
|
||||||
toast.error("Failed to play playlist");
|
toast.error("Failed to play playlist");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,7 +85,7 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[PlaylistDetail] Failed to shuffle play:", e);
|
log.error("Failed to shuffle play:", e);
|
||||||
toast.error("Failed to shuffle playlist");
|
toast.error("Failed to shuffle playlist");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,7 +103,7 @@
|
|||||||
playlist.name = trimmed;
|
playlist.name = trimmed;
|
||||||
toast.success("Playlist renamed");
|
toast.success("Playlist renamed");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[PlaylistDetail] Failed to rename:", e);
|
log.error("Failed to rename:", e);
|
||||||
toast.error("Failed to rename playlist");
|
toast.error("Failed to rename playlist");
|
||||||
editName = playlist.name;
|
editName = playlist.name;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -115,7 +118,7 @@
|
|||||||
toast.success("Playlist deleted");
|
toast.success("Playlist deleted");
|
||||||
goto("/library");
|
goto("/library");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[PlaylistDetail] Failed to delete:", e);
|
log.error("Failed to delete:", e);
|
||||||
toast.error("Failed to delete playlist");
|
toast.error("Failed to delete playlist");
|
||||||
} finally {
|
} finally {
|
||||||
showDeleteConfirm = false;
|
showDeleteConfirm = false;
|
||||||
@@ -129,7 +132,7 @@
|
|||||||
entries = entries.filter(e => e.playlistItemId !== entry.playlistItemId);
|
entries = entries.filter(e => e.playlistItemId !== entry.playlistItemId);
|
||||||
toast.success("Track removed");
|
toast.success("Track removed");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[PlaylistDetail] Failed to remove track:", e);
|
log.error("Failed to remove track:", e);
|
||||||
toast.error("Failed to remove track");
|
toast.error("Failed to remove track");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import type { MediaItem, MediaKind, Person } from "$lib/api/types";
|
import type { MediaItem, MediaKind, Person } from "$lib/api/types";
|
||||||
import MediaCard from "./MediaCard.svelte";
|
import MediaCard from "./MediaCard.svelte";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("RelatedItemsSection");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentItemId: string;
|
currentItemId: string;
|
||||||
@@ -57,7 +60,7 @@
|
|||||||
return; // Success - return early
|
return; // Success - return early
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to load similar items from API:", e);
|
log.warn("Failed to load similar items from API:", e);
|
||||||
// Fall through to genre-based loading
|
// Fall through to genre-based loading
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,7 +81,7 @@
|
|||||||
|
|
||||||
items = result.items.filter(item => item.id !== currentItemId);
|
items = result.items.filter(item => item.id !== currentItemId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to load related items by genre:", e);
|
log.warn("Failed to load related items by genre:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +97,7 @@
|
|||||||
const artistAlbums = result.items.filter(item => item.id !== currentItemId);
|
const artistAlbums = result.items.filter(item => item.id !== currentItemId);
|
||||||
items = [...items, ...artistAlbums];
|
items = [...items, ...artistAlbums];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to load albums by artist:", e);
|
log.warn("Failed to load albums by artist:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +109,7 @@
|
|||||||
relatedItems = uniqueItems;
|
relatedItems = uniqueItems;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : "Failed to load related items";
|
error = e instanceof Error ? e.message : "Failed to load related items";
|
||||||
console.error("Error loading related items:", e);
|
log.error("Error loading related items:", e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
|
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("SeasonDownloadButton");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
seasonId: string;
|
seasonId: string;
|
||||||
@@ -46,11 +49,11 @@
|
|||||||
try {
|
try {
|
||||||
const userId = $auth.user?.id;
|
const userId = $auth.user?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
console.error("No user ID found");
|
log.error("No user ID found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📺 Starting season download for:", seasonName, "quality:", quality);
|
log.debug("📺 Starting season download for:", seasonName, "quality:", quality);
|
||||||
|
|
||||||
// Get target directory
|
// Get target directory
|
||||||
const targetDir = await commands.storageGetPath();
|
const targetDir = await commands.storageGetPath();
|
||||||
@@ -67,7 +70,7 @@
|
|||||||
quality
|
quality
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`✅ Queued ${downloadIds.length} episodes for download`);
|
log.debug(`✅ Queued ${downloadIds.length} episodes for download`);
|
||||||
|
|
||||||
// Pin the season item
|
// Pin the season item
|
||||||
await downloads.pinItem(seasonId);
|
await downloads.pinItem(seasonId);
|
||||||
@@ -77,9 +80,9 @@
|
|||||||
// rest as slots free up.
|
// rest as slots free up.
|
||||||
const handle = auth.getRepository().getHandle();
|
const handle = auth.getRepository().getHandle();
|
||||||
await commands.enqueueVideoDownloads(handle, downloadIds, targetDir);
|
await commands.enqueueVideoDownloads(handle, downloadIds, targetDir);
|
||||||
console.log(" Episodes enqueued; backend pump will start them");
|
log.debug(" Episodes enqueued; backend pump will start them");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start season download:", error);
|
log.error("Failed to start season download:", error);
|
||||||
} finally {
|
} finally {
|
||||||
isProcessing = false;
|
isProcessing = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
|
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("SeriesDownloadButton");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
seriesId: string;
|
seriesId: string;
|
||||||
@@ -40,11 +43,11 @@
|
|||||||
try {
|
try {
|
||||||
const userId = $auth.user?.id;
|
const userId = $auth.user?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
console.error("No user ID found");
|
log.error("No user ID found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📺 Starting series download for:", seriesName, "quality:", quality);
|
log.debug("📺 Starting series download for:", seriesName, "quality:", quality);
|
||||||
|
|
||||||
// Get target directory
|
// Get target directory
|
||||||
const targetDir = await commands.storageGetPath();
|
const targetDir = await commands.storageGetPath();
|
||||||
@@ -59,7 +62,7 @@
|
|||||||
quality
|
quality
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(` Queued ${downloadIds.length} episodes for download`);
|
log.debug(` Queued ${downloadIds.length} episodes for download`);
|
||||||
|
|
||||||
// Pin the series item
|
// Pin the series item
|
||||||
await downloads.pinItem(seriesId);
|
await downloads.pinItem(seriesId);
|
||||||
@@ -69,9 +72,9 @@
|
|||||||
// rest as slots free up.
|
// rest as slots free up.
|
||||||
const handle = auth.getRepository().getHandle();
|
const handle = auth.getRepository().getHandle();
|
||||||
await commands.enqueueVideoDownloads(handle, downloadIds, targetDir);
|
await commands.enqueueVideoDownloads(handle, downloadIds, targetDir);
|
||||||
console.log(" Episodes enqueued; backend pump will start them");
|
log.debug(" Episodes enqueued; backend pump will start them");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start series download:", error);
|
log.error("Failed to start series download:", error);
|
||||||
} finally {
|
} finally {
|
||||||
isProcessing = false;
|
isProcessing = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
import AddToPlaylistModal from "$lib/components/playlist/AddToPlaylistModal.svelte";
|
import AddToPlaylistModal from "$lib/components/playlist/AddToPlaylistModal.svelte";
|
||||||
import { calculateMenuPosition, type MenuPosition } from "$lib/utils/menuPosition";
|
import { calculateMenuPosition, type MenuPosition } from "$lib/utils/menuPosition";
|
||||||
import { formatDuration } from "$lib/utils/duration";
|
import { formatDuration } from "$lib/utils/duration";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("TrackList");
|
||||||
|
|
||||||
/** Queue context for remote transfer - what type of queue is this? */
|
/** Queue context for remote transfer - what type of queue is this? */
|
||||||
export type QueueContext =
|
export type QueueContext =
|
||||||
@@ -55,7 +58,7 @@
|
|||||||
|
|
||||||
// If this is an album, use the backend album command (more efficient)
|
// If this is an album, use the backend album command (more efficient)
|
||||||
if (context && context.type === "album") {
|
if (context && context.type === "album") {
|
||||||
console.log(`[TrackList] Playing track: "${track.name}" (ID: ${track.id}, index in list: ${index})`);
|
log.debug(`Playing track: "${track.name}" (ID: ${track.id}, index in list: ${index})`);
|
||||||
await playerController.playAlbumTrack({
|
await playerController.playAlbumTrack({
|
||||||
albumId: context.albumId,
|
albumId: context.albumId,
|
||||||
albumName: context.albumName,
|
albumName: context.albumName,
|
||||||
@@ -91,7 +94,7 @@
|
|||||||
// Queue will auto-update from Rust backend event
|
// Queue will auto-update from Rust backend event
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const errorMessage = e instanceof Error ? e.message : 'Unknown error';
|
const errorMessage = e instanceof Error ? e.message : 'Unknown error';
|
||||||
console.error("Failed to play track:", errorMessage);
|
log.error("Failed to play track:", errorMessage);
|
||||||
toast.error(`Failed to play track: ${errorMessage}`, 5000);
|
toast.error(`Failed to play track: ${errorMessage}`, 5000);
|
||||||
} finally {
|
} finally {
|
||||||
isPlayingTrack = null;
|
isPlayingTrack = null;
|
||||||
@@ -145,9 +148,9 @@
|
|||||||
try {
|
try {
|
||||||
// Queue store now handles everything in Rust - just pass the track
|
// Queue store now handles everything in Rust - just pass the track
|
||||||
await queue.addToQueue(track, position);
|
await queue.addToQueue(track, position);
|
||||||
console.log(`Added "${track.name}" to queue (${position})`);
|
log.debug(`Added "${track.name}" to queue (${position})`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to add to queue:", e);
|
log.error("Failed to add to queue:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
|
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("VideoDownloadButton");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -57,17 +60,17 @@
|
|||||||
try {
|
try {
|
||||||
const userId = $auth.user?.id;
|
const userId = $auth.user?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
console.error("No user ID found");
|
log.error("No user ID found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
|
|
||||||
console.log("🎬 Starting video download for item:", itemId, "quality:", quality);
|
log.debug("🎬 Starting video download for item:", itemId, "quality:", quality);
|
||||||
|
|
||||||
// Get stream URL based on quality
|
// Get stream URL based on quality
|
||||||
const streamUrl = await repo.getVideoDownloadUrl(itemId, quality);
|
const streamUrl = await repo.getVideoDownloadUrl(itemId, quality);
|
||||||
console.log(" Stream URL obtained");
|
log.debug(" Stream URL obtained");
|
||||||
|
|
||||||
// Get target directory
|
// Get target directory
|
||||||
const targetDir = await commands.storageGetPath();
|
const targetDir = await commands.storageGetPath();
|
||||||
@@ -85,7 +88,7 @@
|
|||||||
filePath = `videos/${safeName}.mp4`;
|
filePath = `videos/${safeName}.mp4`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(" File path:", filePath);
|
log.debug(" File path:", filePath);
|
||||||
|
|
||||||
// Queue download with video metadata
|
// Queue download with video metadata
|
||||||
const downloadId = await downloads.downloadVideo(
|
const downloadId = await downloads.downloadVideo(
|
||||||
@@ -101,16 +104,16 @@
|
|||||||
episodeNumber,
|
episodeNumber,
|
||||||
seasonNumber
|
seasonNumber
|
||||||
);
|
);
|
||||||
console.log(" Download queued with ID:", downloadId);
|
log.debug(" Download queued with ID:", downloadId);
|
||||||
|
|
||||||
// Pin the item metadata
|
// Pin the item metadata
|
||||||
await downloads.pinItem(itemId);
|
await downloads.pinItem(itemId);
|
||||||
|
|
||||||
// Actually start the download
|
// Actually start the download
|
||||||
await commands.startDownload(downloadId, streamUrl, targetDir);
|
await commands.startDownload(downloadId, streamUrl, targetDir);
|
||||||
console.log(" Download started");
|
log.debug(" Download started");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start video download:", error);
|
log.error("Failed to start video download:", error);
|
||||||
} finally {
|
} finally {
|
||||||
isProcessing = false;
|
isProcessing = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { syncService } from "$lib/services/syncService";
|
import { syncService } from "$lib/services/syncService";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("WatchedToggleButton");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Episode, season or series id. */
|
/** Episode, season or series id. */
|
||||||
@@ -81,7 +84,7 @@
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Put the button back where it was — the change did not happen.
|
// Put the button back where it was — the change did not happen.
|
||||||
optimistic = null;
|
optimistic = null;
|
||||||
console.error("Failed to change watched state:", e);
|
log.error("Failed to change watched state:", e);
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
import VolumeControl from "./VolumeControl.svelte";
|
import VolumeControl from "./VolumeControl.svelte";
|
||||||
import CachedImage from "../common/CachedImage.svelte";
|
import CachedImage from "../common/CachedImage.svelte";
|
||||||
import { currentQueueItem } from "$lib/stores/queue";
|
import { currentQueueItem } from "$lib/stores/queue";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("AudioPlayer");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
media: MediaItem | null;
|
media: MediaItem | null;
|
||||||
@@ -130,7 +133,7 @@
|
|||||||
queue.skipTo(index);
|
queue.skipTo(index);
|
||||||
await playerController.skipTo(index);
|
await playerController.skipTo(index);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to skip to queue item:", e);
|
log.error("Failed to skip to queue item:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -38,6 +38,9 @@
|
|||||||
import CastButton from "$lib/components/sessions/CastButton.svelte";
|
import CastButton from "$lib/components/sessions/CastButton.svelte";
|
||||||
import VolumeControl from "./VolumeControl.svelte";
|
import VolumeControl from "./VolumeControl.svelte";
|
||||||
import CachedImage from "../common/CachedImage.svelte";
|
import CachedImage from "../common/CachedImage.svelte";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("MiniPlayer");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
media: MediaItem | null;
|
media: MediaItem | null;
|
||||||
@@ -159,7 +162,7 @@
|
|||||||
await playerController.seek(newPosition);
|
await playerController.seek(newPosition);
|
||||||
haptics.tap();
|
haptics.tap();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to seek:", err);
|
log.error("Failed to seek:", err);
|
||||||
toast.show("Failed to seek", "error");
|
toast.show("Failed to seek", "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,7 +233,7 @@
|
|||||||
// Vertical swipe
|
// Vertical swipe
|
||||||
if (Math.abs(diffY) > swipeThreshold && diffY > 0) {
|
if (Math.abs(diffY) > swipeThreshold && diffY > 0) {
|
||||||
// Swiped up - Open full player
|
// Swiped up - Open full player
|
||||||
console.log("[MiniPlayer] Swipe-up detected, expanding player");
|
log.debug("Swipe-up detected, expanding player");
|
||||||
haptics.tap();
|
haptics.tap();
|
||||||
onExpand?.();
|
onExpand?.();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { queue } from "$lib/stores/queue";
|
import { queue } from "$lib/stores/queue";
|
||||||
import CachedImage from "../common/CachedImage.svelte";
|
import CachedImage from "../common/CachedImage.svelte";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("QueueView");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: MediaItem[];
|
items: MediaItem[];
|
||||||
@@ -82,7 +85,7 @@
|
|||||||
// Sync with backend
|
// Sync with backend
|
||||||
await playerController.moveInQueue(fromIndex, toIndex);
|
await playerController.moveInQueue(fromIndex, toIndex);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to move queue item:", e);
|
log.error("Failed to move queue item:", e);
|
||||||
// The store already updated optimistically, refresh if needed
|
// The store already updated optimistically, refresh if needed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,7 +112,7 @@
|
|||||||
queue.removeFromQueue(index);
|
queue.removeFromQueue(index);
|
||||||
await playerController.removeFromQueue(index);
|
await playerController.removeFromQueue(index);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to remove from queue:", err);
|
log.error("Failed to remove from queue:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -75,6 +75,9 @@
|
|||||||
planHandoffReturn,
|
planHandoffReturn,
|
||||||
type BackgroundAudioState,
|
type BackgroundAudioState,
|
||||||
} from "./backgroundAudioHandoff";
|
} from "./backgroundAudioHandoff";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("VideoPlayer");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
media: MediaItem | null;
|
media: MediaItem | null;
|
||||||
@@ -287,11 +290,11 @@
|
|||||||
// TRACES: UR-021 | IR-016, JA-009 | DR-024
|
// TRACES: UR-021 | IR-016, JA-009 | DR-024
|
||||||
const audioTracks = $derived(() => {
|
const audioTracks = $derived(() => {
|
||||||
if (!media || !media.mediaStreams) {
|
if (!media || !media.mediaStreams) {
|
||||||
console.log("[VideoPlayer] No media or mediaStreams available");
|
log.debug("No media or mediaStreams available");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const tracks = media.mediaStreams.filter(stream => stream.kind === "audio");
|
const tracks = media.mediaStreams.filter(stream => stream.kind === "audio");
|
||||||
console.log("[VideoPlayer] Found audio tracks:", tracks.length, tracks);
|
log.debug("Found audio tracks:", tracks.length, tracks);
|
||||||
return tracks;
|
return tracks;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -304,7 +307,7 @@
|
|||||||
if (preference.audioTrackDisplayTitle) {
|
if (preference.audioTrackDisplayTitle) {
|
||||||
const match = tracks.find(t => t.displayTitle === preference.audioTrackDisplayTitle);
|
const match = tracks.find(t => t.displayTitle === preference.audioTrackDisplayTitle);
|
||||||
if (match) {
|
if (match) {
|
||||||
console.log("[VideoPlayer] Matched audio track by display title:", match.displayTitle);
|
log.debug("Matched audio track by display title:", match.displayTitle);
|
||||||
return match.index;
|
return match.index;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,14 +316,14 @@
|
|||||||
if (preference.audioTrackLanguage) {
|
if (preference.audioTrackLanguage) {
|
||||||
const match = tracks.find(t => t.language === preference.audioTrackLanguage);
|
const match = tracks.find(t => t.language === preference.audioTrackLanguage);
|
||||||
if (match) {
|
if (match) {
|
||||||
console.log("[VideoPlayer] Matched audio track by language:", match.language);
|
log.debug("Matched audio track by language:", match.language);
|
||||||
return match.index;
|
return match.index;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to default track
|
// Fall back to default track
|
||||||
const defaultTrack = tracks.find(t => t.isDefault) || tracks[0];
|
const defaultTrack = tracks.find(t => t.isDefault) || tracks[0];
|
||||||
console.log("[VideoPlayer] Using default/first audio track:", defaultTrack.displayTitle || defaultTrack.language);
|
log.debug("Using default/first audio track:", defaultTrack.displayTitle || defaultTrack.language);
|
||||||
return defaultTrack.index;
|
return defaultTrack.index;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,15 +338,15 @@
|
|||||||
const preference = await commands.storageGetSeriesAudioPreference(userId, media.seriesId);
|
const preference = await commands.storageGetSeriesAudioPreference(userId, media.seriesId);
|
||||||
|
|
||||||
if (preference) {
|
if (preference) {
|
||||||
console.log("[VideoPlayer] Loaded series audio preference:", preference);
|
log.debug("Loaded series audio preference:", preference);
|
||||||
const matchedIndex = findBestAudioTrack(preference);
|
const matchedIndex = findBestAudioTrack(preference);
|
||||||
if (matchedIndex !== null) {
|
if (matchedIndex !== null) {
|
||||||
selectedAudioTrackIndex = matchedIndex;
|
selectedAudioTrackIndex = matchedIndex;
|
||||||
console.log("[VideoPlayer] Applied series audio preference, track index:", matchedIndex);
|
log.debug("Applied series audio preference, track index:", matchedIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[VideoPlayer] Failed to load series audio preference:", err);
|
log.warn("Failed to load series audio preference:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,11 +358,11 @@
|
|||||||
// TRACES: UR-020 | DR-176 | UT-168
|
// TRACES: UR-020 | DR-176 | UT-168
|
||||||
const subtitleTracks = $derived(() => {
|
const subtitleTracks = $derived(() => {
|
||||||
if (!media || !media.mediaStreams) {
|
if (!media || !media.mediaStreams) {
|
||||||
console.log("[VideoPlayer] No media or mediaStreams available for subtitles");
|
log.debug("No media or mediaStreams available for subtitles");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const tracks = subtitleStreamsOf(media.mediaStreams);
|
const tracks = subtitleStreamsOf(media.mediaStreams);
|
||||||
console.log("[VideoPlayer] Found subtitle tracks:", tracks.length, tracks);
|
log.debug("Found subtitle tracks:", tracks.length, tracks);
|
||||||
return tracks;
|
return tracks;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -547,7 +550,7 @@
|
|||||||
if (isHlsStream && Hls.isSupported()) {
|
if (isHlsStream && Hls.isSupported()) {
|
||||||
// Clean up existing HLS instance if any - CRITICAL for preventing dual audio
|
// Clean up existing HLS instance if any - CRITICAL for preventing dual audio
|
||||||
if (hls) {
|
if (hls) {
|
||||||
console.log('[VideoPlayer] Cleaning up existing HLS instance');
|
log.debug('Cleaning up existing HLS instance');
|
||||||
// Detach from media element first to stop all audio/video
|
// Detach from media element first to stop all audio/video
|
||||||
hls.detachMedia();
|
hls.detachMedia();
|
||||||
// Stop loading and flush buffers
|
// Stop loading and flush buffers
|
||||||
@@ -571,7 +574,7 @@
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!videoElement) return;
|
if (!videoElement) return;
|
||||||
|
|
||||||
console.log('[VideoPlayer] Creating new HLS instance for:', currentStreamUrl);
|
log.debug('Creating new HLS instance for:', currentStreamUrl);
|
||||||
|
|
||||||
// Create new HLS instance
|
// Create new HLS instance
|
||||||
hls = new Hls({
|
hls = new Hls({
|
||||||
@@ -599,14 +602,14 @@
|
|||||||
|
|
||||||
// Listen for media attached event
|
// Listen for media attached event
|
||||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||||
console.log('[VideoPlayer] HLS.js attached to video element');
|
log.debug('HLS.js attached to video element');
|
||||||
// Load the HLS stream
|
// Load the HLS stream
|
||||||
hls!.loadSource(currentStreamUrl);
|
hls!.loadSource(currentStreamUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for manifest parsed event
|
// Listen for manifest parsed event
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
console.log('[VideoPlayer] HLS manifest parsed, ready to play');
|
log.debug('HLS manifest parsed, ready to play');
|
||||||
});
|
});
|
||||||
|
|
||||||
// On the Android WebView the element's own `canplay` may not fire for
|
// On the Android WebView the element's own `canplay` may not fire for
|
||||||
@@ -623,7 +626,7 @@
|
|||||||
if (canplayFallbackTimeout) clearTimeout(canplayFallbackTimeout);
|
if (canplayFallbackTimeout) clearTimeout(canplayFallbackTimeout);
|
||||||
canplayFallbackTimeout = setTimeout(() => {
|
canplayFallbackTimeout = setTimeout(() => {
|
||||||
if (!isMediaReady && videoElement && videoElement.readyState >= 2) {
|
if (!isMediaReady && videoElement && videoElement.readyState >= 2) {
|
||||||
console.warn('[VideoPlayer] HLS canplay fallback - revealing video (readyState:', videoElement.readyState, ')');
|
log.warn('HLS canplay fallback - revealing video (readyState:', videoElement.readyState, ')');
|
||||||
markMediaReady();
|
markMediaReady();
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
@@ -633,7 +636,7 @@
|
|||||||
|
|
||||||
// Handle errors
|
// Handle errors
|
||||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||||
console.error('[VideoPlayer] HLS error:', data);
|
log.error('HLS error:', data);
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
// Is this the stream ending or the stream breaking? Jellyfin's
|
// Is this the stream ending or the stream breaking? Jellyfin's
|
||||||
// transcoded HLS doesn't always emit #EXT-X-ENDLIST, so both arrive
|
// transcoded HLS doesn't always emit #EXT-X-ENDLIST, so both arrive
|
||||||
@@ -650,25 +653,25 @@
|
|||||||
attempts: hlsFatalRecoveryAttempts,
|
attempts: hlsFatalRecoveryAttempts,
|
||||||
})) {
|
})) {
|
||||||
case 'ended':
|
case 'ended':
|
||||||
console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended');
|
log.debug('Fatal network error near end of stream - treating as ended');
|
||||||
notifyEnded();
|
notifyEnded();
|
||||||
break;
|
break;
|
||||||
case 'retry':
|
case 'retry':
|
||||||
console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
|
log.error('Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
|
||||||
hls!.startLoad();
|
hls!.startLoad();
|
||||||
break;
|
break;
|
||||||
case 'giveUp':
|
case 'giveUp':
|
||||||
console.error('[VideoPlayer] Fatal network error, max recovery attempts reached');
|
log.error('Fatal network error, max recovery attempts reached');
|
||||||
hls!.destroy();
|
hls!.destroy();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||||
console.error('[VideoPlayer] Fatal media error, trying to recover');
|
log.error('Fatal media error, trying to recover');
|
||||||
hls!.recoverMediaError();
|
hls!.recoverMediaError();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.error('[VideoPlayer] Unrecoverable HLS error');
|
log.error('Unrecoverable HLS error');
|
||||||
hls!.destroy();
|
hls!.destroy();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -678,7 +681,7 @@
|
|||||||
|
|
||||||
// Cleanup on effect re-run
|
// Cleanup on effect re-run
|
||||||
return () => {
|
return () => {
|
||||||
console.log('[VideoPlayer] Effect cleanup: destroying HLS instance');
|
log.debug('Effect cleanup: destroying HLS instance');
|
||||||
if (hls) {
|
if (hls) {
|
||||||
hls.detachMedia();
|
hls.detachMedia();
|
||||||
hls.stopLoad();
|
hls.stopLoad();
|
||||||
@@ -691,11 +694,11 @@
|
|||||||
};
|
};
|
||||||
} else if (isHlsStream && videoElement.canPlayType('application/vnd.apple.mpegurl')) {
|
} else if (isHlsStream && videoElement.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
// Native HLS support (Safari)
|
// Native HLS support (Safari)
|
||||||
console.log('[VideoPlayer] Using native HLS support');
|
log.debug('Using native HLS support');
|
||||||
videoElement.src = currentStreamUrl;
|
videoElement.src = currentStreamUrl;
|
||||||
} else {
|
} else {
|
||||||
// Not an HLS stream, use regular video element
|
// Not an HLS stream, use regular video element
|
||||||
console.log('[VideoPlayer] Using regular video element for non-HLS stream');
|
log.debug('Using regular video element for non-HLS stream');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -704,24 +707,24 @@
|
|||||||
if (videoElement) {
|
if (videoElement) {
|
||||||
videoElement.muted = false;
|
videoElement.muted = false;
|
||||||
videoElement.volume = 1.0;
|
videoElement.volume = 1.0;
|
||||||
console.log("[VideoPlayer] Video element configured: muted=", videoElement.muted, "volume=", videoElement.volume);
|
log.debug("Video element configured: muted=", videoElement.muted, "volume=", videoElement.volume);
|
||||||
|
|
||||||
// DIAGNOSTIC: Check if video has audio tracks
|
// DIAGNOSTIC: Check if video has audio tracks
|
||||||
if ((videoElement as any).audioTracks) {
|
if ((videoElement as any).audioTracks) {
|
||||||
console.log("[VideoPlayer] Audio tracks count:", (videoElement as any).audioTracks.length);
|
log.debug("Audio tracks count:", (videoElement as any).audioTracks.length);
|
||||||
|
|
||||||
// Set initial audio track (prefer default track)
|
// Set initial audio track (prefer default track)
|
||||||
if (selectedAudioTrackIndex === null && audioTracks().length > 0) {
|
if (selectedAudioTrackIndex === null && audioTracks().length > 0) {
|
||||||
const defaultTrack = audioTracks().find(t => t.isDefault);
|
const defaultTrack = audioTracks().find(t => t.isDefault);
|
||||||
selectedAudioTrackIndex = defaultTrack ? defaultTrack.index : audioTracks()[0].index;
|
selectedAudioTrackIndex = defaultTrack ? defaultTrack.index : audioTracks()[0].index;
|
||||||
console.log("[VideoPlayer] Selected default audio track:", selectedAudioTrackIndex);
|
log.debug("Selected default audio track:", selectedAudioTrackIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ((videoElement as any).mozHasAudio !== undefined) {
|
if ((videoElement as any).mozHasAudio !== undefined) {
|
||||||
console.log("[VideoPlayer] mozHasAudio:", (videoElement as any).mozHasAudio);
|
log.debug("mozHasAudio:", (videoElement as any).mozHasAudio);
|
||||||
}
|
}
|
||||||
if ((videoElement as any).webkitAudioDecodedByteCount !== undefined) {
|
if ((videoElement as any).webkitAudioDecodedByteCount !== undefined) {
|
||||||
console.log("[VideoPlayer] webkitAudioDecodedByteCount:", (videoElement as any).webkitAudioDecodedByteCount);
|
log.debug("webkitAudioDecodedByteCount:", (videoElement as any).webkitAudioDecodedByteCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -749,7 +752,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
console.log("[VideoPlayer] Initial position changed, seeking to:", pos);
|
log.debug("Initial position changed, seeking to:", pos);
|
||||||
lastAppliedInitialPosition = pos;
|
lastAppliedInitialPosition = pos;
|
||||||
if (videoElement) {
|
if (videoElement) {
|
||||||
videoElement.currentTime = pos;
|
videoElement.currentTime = pos;
|
||||||
@@ -775,7 +778,7 @@
|
|||||||
selectedQuality = settings.streamingQuality ?? "original";
|
selectedQuality = settings.streamingQuality ?? "original";
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.warn("[VideoPlayer] Failed to load streaming qualities:", err);
|
log.warn("Failed to load streaming qualities:", err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -808,8 +811,8 @@
|
|||||||
// Initialize player via Rust - Rust will decide which backend to use based on platform
|
// Initialize player via Rust - Rust will decide which backend to use based on platform
|
||||||
if (media && currentStreamUrl) {
|
if (media && currentStreamUrl) {
|
||||||
try {
|
try {
|
||||||
console.log("[VideoPlayer] Initializing player for:", media.name);
|
log.debug("Initializing player for:", media.name);
|
||||||
console.log("[VideoPlayer] Stream URL:", currentStreamUrl);
|
log.debug("Stream URL:", currentStreamUrl);
|
||||||
|
|
||||||
// Resolve subtitle URLs for the native (ExoPlayer) path. These must be
|
// Resolve subtitle URLs for the native (ExoPlayer) path. These must be
|
||||||
// in hand *before* the play request: ExoPlayer sideloads subtitles as
|
// in hand *before* the play request: ExoPlayer sideloads subtitles as
|
||||||
@@ -827,7 +830,7 @@
|
|||||||
sentSubtitleTracks = mediaSourceId
|
sentSubtitleTracks = mediaSourceId
|
||||||
? await resolveSubtitleTracks(media.mediaStreams, (index) => getSubtitleUrl(index))
|
? await resolveSubtitleTracks(media.mediaStreams, (index) => getSubtitleUrl(index))
|
||||||
: [];
|
: [];
|
||||||
console.log(`[VideoPlayer] Sending ${sentSubtitleTracks.length} subtitle tracks to the backend`);
|
log.debug(`Sending ${sentSubtitleTracks.length} subtitle tracks to the backend`);
|
||||||
|
|
||||||
// Call Rust backend to start playback
|
// Call Rust backend to start playback
|
||||||
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
|
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
|
||||||
@@ -847,7 +850,7 @@
|
|||||||
// Rust tells us which backend it's using
|
// Rust tells us which backend it's using
|
||||||
useHtml5Element = response.useHtml5Element;
|
useHtml5Element = response.useHtml5Element;
|
||||||
backendChosen = true;
|
backendChosen = true;
|
||||||
console.log(`[VideoPlayer] Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);
|
log.debug(`Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);
|
||||||
|
|
||||||
// Rust reported a native backend (Android/ExoPlayer). Honour it only if
|
// Rust reported a native backend (Android/ExoPlayer). Honour it only if
|
||||||
// the user opted into the experimental native path; otherwise fall back
|
// the user opted into the experimental native path; otherwise fall back
|
||||||
@@ -858,13 +861,13 @@
|
|||||||
// just started, or ExoPlayer and the <video> element both decode the
|
// just started, or ExoPlayer and the <video> element both decode the
|
||||||
// same stream and the audio doubles.
|
// same stream and the audio doubles.
|
||||||
if (!useHtml5Element && !$experimentalNativeVideo) {
|
if (!useHtml5Element && !$experimentalNativeVideo) {
|
||||||
console.log("[VideoPlayer] Native backend available but experimentalNativeVideo is off - using HTML5");
|
log.debug("Native backend available but experimentalNativeVideo is off - using HTML5");
|
||||||
useHtml5Element = true;
|
useHtml5Element = true;
|
||||||
try {
|
try {
|
||||||
await commands.playerStop();
|
await commands.playerStop();
|
||||||
didStopBackendEarly = true;
|
didStopBackendEarly = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[VideoPlayer] Failed to stop native backend:", err);
|
log.warn("Failed to stop native backend:", err);
|
||||||
}
|
}
|
||||||
} else if (!useHtml5Element) {
|
} else if (!useHtml5Element) {
|
||||||
// Native path: clear the opaque layers between the viewport and the
|
// Native path: clear the opaque layers between the viewport and the
|
||||||
@@ -872,7 +875,7 @@
|
|||||||
// Paired with disableNativeVideoCompositing() in the teardown path —
|
// Paired with disableNativeVideoCompositing() in the teardown path —
|
||||||
// leaving this on renders the rest of the app over a transparent
|
// leaving this on renders the rest of the app over a transparent
|
||||||
// window.
|
// window.
|
||||||
console.log("[VideoPlayer] Using native ExoPlayer video surface");
|
log.debug("Using native ExoPlayer video surface");
|
||||||
enableNativeVideoCompositing();
|
enableNativeVideoCompositing();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -880,14 +883,14 @@
|
|||||||
// For transcoded content, we need to keep the backend running to handle seeking/audio track switching
|
// For transcoded content, we need to keep the backend running to handle seeking/audio track switching
|
||||||
if (useHtml5Element && !needsTranscoding && !didStopBackendEarly) {
|
if (useHtml5Element && !needsTranscoding && !didStopBackendEarly) {
|
||||||
try {
|
try {
|
||||||
console.log("[VideoPlayer] Using HTML5 for direct stream - stopping backend player to prevent dual audio");
|
log.debug("Using HTML5 for direct stream - stopping backend player to prevent dual audio");
|
||||||
await commands.playerStop();
|
await commands.playerStop();
|
||||||
didStopBackendEarly = true; // Track that we stopped the backend
|
didStopBackendEarly = true; // Track that we stopped the backend
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[VideoPlayer] Failed to stop backend player:", err);
|
log.warn("Failed to stop backend player:", err);
|
||||||
}
|
}
|
||||||
} else if (useHtml5Element && needsTranscoding) {
|
} else if (useHtml5Element && needsTranscoding) {
|
||||||
console.log("[VideoPlayer] Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions");
|
log.debug("Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions");
|
||||||
// Backend is kept running but should not play audio since HTML5 element handles playback
|
// Backend is kept running but should not play audio since HTML5 element handles playback
|
||||||
didStartNativePlayback = true; // Track that we need to stop backend on unmount
|
didStartNativePlayback = true; // Track that we need to stop backend on unmount
|
||||||
}
|
}
|
||||||
@@ -972,12 +975,12 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[VideoPlayer] Failed to initialize player:", err);
|
log.error("Failed to initialize player:", err);
|
||||||
if (backendChosen) {
|
if (backendChosen) {
|
||||||
// The backend already accepted the item; a later error (e.g. event
|
// The backend already accepted the item; a later error (e.g. event
|
||||||
// subscription) must not silently switch the seek/controls path to
|
// subscription) must not silently switch the seek/controls path to
|
||||||
// HTML5 while the native backend keeps playing.
|
// HTML5 while the native backend keeps playing.
|
||||||
console.warn("[VideoPlayer] Backend already initialized - keeping native mode despite error");
|
log.warn("Backend already initialized - keeping native mode despite error");
|
||||||
} else {
|
} else {
|
||||||
// Fallback to HTML5 on error
|
// Fallback to HTML5 on error
|
||||||
useHtml5Element = true;
|
useHtml5Element = true;
|
||||||
@@ -1042,8 +1045,8 @@
|
|||||||
// Flattened to a single string on purpose: the Android WebView console
|
// Flattened to a single string on purpose: the Android WebView console
|
||||||
// bridge stringifies objects as "[object Object]" in logcat, which made
|
// bridge stringifies objects as "[object Object]" in logcat, which made
|
||||||
// this whole payload useless when diagnosing over adb.
|
// this whole payload useless when diagnosing over adb.
|
||||||
console.log(
|
log.debug(
|
||||||
`[VideoPlayer Debug] t=${videoElement.currentTime.toFixed(2)}` +
|
`Debug t=${videoElement.currentTime.toFixed(2)}` +
|
||||||
` display=${currentTime.toFixed(2)}` +
|
` display=${currentTime.toFixed(2)}` +
|
||||||
` readyState=${videoElement.readyState}` +
|
` readyState=${videoElement.readyState}` +
|
||||||
` networkState=${videoElement.networkState}` +
|
` networkState=${videoElement.networkState}` +
|
||||||
@@ -1111,7 +1114,7 @@
|
|||||||
|
|
||||||
// Clean up HLS.js instance - prevent dual audio on unmount
|
// Clean up HLS.js instance - prevent dual audio on unmount
|
||||||
if (hls) {
|
if (hls) {
|
||||||
console.log("[VideoPlayer] Destroying HLS.js instance on unmount");
|
log.debug("Destroying HLS.js instance on unmount");
|
||||||
hls.detachMedia(); // Detach from video element first
|
hls.detachMedia(); // Detach from video element first
|
||||||
hls.stopLoad(); // Stop loading and flush buffers
|
hls.stopLoad(); // Stop loading and flush buffers
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
@@ -1129,10 +1132,10 @@
|
|||||||
// Skip if we already stopped the backend early (non-transcoded + HTML5)
|
// Skip if we already stopped the backend early (non-transcoded + HTML5)
|
||||||
if (didStartNativePlayback && !didStopBackendEarly) {
|
if (didStartNativePlayback && !didStopBackendEarly) {
|
||||||
try {
|
try {
|
||||||
console.log("[VideoPlayer] Stopping backend player on component unmount");
|
log.debug("Stopping backend player on component unmount");
|
||||||
await commands.playerStop();
|
await commands.playerStop();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[VideoPlayer] Failed to stop backend player:", err);
|
log.error("Failed to stop backend player:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1187,20 +1190,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleLoadedMetadata() {
|
function handleLoadedMetadata() {
|
||||||
console.log("[VideoPlayer] loadedmetadata event");
|
log.debug("loadedmetadata event");
|
||||||
// Intrinsic dimensions are known now, which is what PiP sizes its window
|
// Intrinsic dimensions are known now, which is what PiP sizes its window
|
||||||
// from — before this they are 0 and the ratio would be rejected. (DR-160)
|
// from — before this they are 0 and the ratio would be rejected. (DR-160)
|
||||||
reportPipVideoState();
|
reportPipVideoState();
|
||||||
console.log("[VideoPlayer] Video element duration:", videoElement?.duration);
|
log.debug("Video element duration:", videoElement?.duration);
|
||||||
console.log("[VideoPlayer] Media item runTimeTicks:", media?.runTimeTicks);
|
log.debug("Media item runTimeTicks:", media?.runTimeTicks);
|
||||||
console.log("[VideoPlayer] Needs transcoding:", needsTranscoding);
|
log.debug("Needs transcoding:", needsTranscoding);
|
||||||
|
|
||||||
// For direct streams without runTimeTicks, use video element's duration
|
// For direct streams without runTimeTicks, use video element's duration
|
||||||
if (videoElement && videoElement.duration && !isNaN(videoElement.duration) && videoElement.duration !== Infinity) {
|
if (videoElement && videoElement.duration && !isNaN(videoElement.duration) && videoElement.duration !== Infinity) {
|
||||||
const newDuration = videoElement.duration;
|
const newDuration = videoElement.duration;
|
||||||
console.log("[VideoPlayer] Setting videoDuration to:", newDuration);
|
log.debug("Setting videoDuration to:", newDuration);
|
||||||
videoDuration = newDuration;
|
videoDuration = newDuration;
|
||||||
console.log("[VideoPlayer] videoDuration state is now:", videoDuration);
|
log.debug("videoDuration state is now:", videoDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tell the Rust controller the media is loaded and its duration (mirrors the
|
// Tell the Rust controller the media is loaded and its duration (mirrors the
|
||||||
@@ -1209,8 +1212,8 @@
|
|||||||
|
|
||||||
// Use setTimeout to log the derived value after reactive updates
|
// Use setTimeout to log the derived value after reactive updates
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log("[VideoPlayer] Derived duration value:", duration);
|
log.debug("Derived duration value:", duration);
|
||||||
console.log("[VideoPlayer] Duration source:", media?.runTimeTicks ? "runTimeTicks" : "video element");
|
log.debug("Duration source:", media?.runTimeTicks ? "runTimeTicks" : "video element");
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1251,15 +1254,15 @@
|
|||||||
el.volume = 1.0;
|
el.volume = 1.0;
|
||||||
if (shouldPlay) await el.play();
|
if (shouldPlay) await el.play();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[VideoPlayer] Failed to resume after background audio:", err);
|
log.error("Failed to resume after background audio:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (el.readyState >= 1 /* HAVE_METADATA */) {
|
if (el.readyState >= 1 /* HAVE_METADATA */) {
|
||||||
console.log("[VideoPlayer] Applying foreground seek to:", (seekOffset + seekTo).toFixed(1));
|
log.debug("Applying foreground seek to:", (seekOffset + seekTo).toFixed(1));
|
||||||
await doSeek();
|
await doSeek();
|
||||||
} else {
|
} else {
|
||||||
console.log("[VideoPlayer] Deferring foreground seek until loadedmetadata:", (seekOffset + seekTo).toFixed(1));
|
log.debug("Deferring foreground seek until loadedmetadata:", (seekOffset + seekTo).toFixed(1));
|
||||||
el.addEventListener("loadedmetadata", () => { void doSeek(); }, { once: true });
|
el.addEventListener("loadedmetadata", () => { void doSeek(); }, { once: true });
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -1267,7 +1270,7 @@
|
|||||||
|
|
||||||
function markMediaReady() {
|
function markMediaReady() {
|
||||||
if (isMediaReady) return;
|
if (isMediaReady) return;
|
||||||
console.log("[VideoPlayer] Marking media ready");
|
log.debug("Marking media ready");
|
||||||
isMediaReady = true;
|
isMediaReady = true;
|
||||||
// A handoff return can be revealed here (not via canplay) — apply its seek.
|
// A handoff return can be revealed here (not via canplay) — apply its seek.
|
||||||
void applyPendingForegroundSeek();
|
void applyPendingForegroundSeek();
|
||||||
@@ -1275,14 +1278,14 @@
|
|||||||
|
|
||||||
async function handleCanPlay() {
|
async function handleCanPlay() {
|
||||||
// Media is ready to play - transition from Loading to Playing state (DR-001)
|
// Media is ready to play - transition from Loading to Playing state (DR-001)
|
||||||
console.log("[VideoPlayer] canplay event fired - media is ready");
|
log.debug("canplay event fired - media is ready");
|
||||||
isMediaReady = true;
|
isMediaReady = true;
|
||||||
|
|
||||||
// Ensure video is unmuted and at max volume (critical for Android)
|
// Ensure video is unmuted and at max volume (critical for Android)
|
||||||
if (videoElement) {
|
if (videoElement) {
|
||||||
videoElement.muted = false;
|
videoElement.muted = false;
|
||||||
videoElement.volume = 1.0;
|
videoElement.volume = 1.0;
|
||||||
console.log("[VideoPlayer] Video unmuted on canplay, volume: 1.0");
|
log.debug("Video unmuted on canplay, volume: 1.0");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returning from background audio: resume the <video> at the position native
|
// Returning from background audio: resume the <video> at the position native
|
||||||
@@ -1294,7 +1297,7 @@
|
|||||||
|
|
||||||
// Seek to initial position if resuming playback
|
// Seek to initial position if resuming playback
|
||||||
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
|
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
|
||||||
console.log("[VideoPlayer] Seeking to initial position:", initialPosition);
|
log.debug("Seeking to initial position:", initialPosition);
|
||||||
hasPerformedInitialSeek = true;
|
hasPerformedInitialSeek = true;
|
||||||
lastAppliedInitialPosition = initialPosition; // mark this value as applied so the change-effect ignores it
|
lastAppliedInitialPosition = initialPosition; // mark this value as applied so the change-effect ignores it
|
||||||
|
|
||||||
@@ -1325,7 +1328,7 @@
|
|||||||
await videoElement.play();
|
await videoElement.play();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[VideoPlayer] Failed to seek to initial position:", err);
|
log.error("Failed to seek to initial position:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1335,7 +1338,7 @@
|
|||||||
const error = video.error;
|
const error = video.error;
|
||||||
|
|
||||||
// Log comprehensive error details
|
// Log comprehensive error details
|
||||||
console.error("[VideoPlayer] Video error event:", {
|
log.error("Video error event:", {
|
||||||
code: error?.code,
|
code: error?.code,
|
||||||
message: error?.message,
|
message: error?.message,
|
||||||
networkState: video.networkState,
|
networkState: video.networkState,
|
||||||
@@ -1354,28 +1357,28 @@
|
|||||||
|
|
||||||
const errorCode = error?.code || 0;
|
const errorCode = error?.code || 0;
|
||||||
const msg = errorMessages[errorCode] || `Unknown error (code ${errorCode})`;
|
const msg = errorMessages[errorCode] || `Unknown error (code ${errorCode})`;
|
||||||
console.error("[VideoPlayer] Error interpretation:", msg);
|
log.error("Error interpretation:", msg);
|
||||||
|
|
||||||
// Log additional debugging info
|
// Log additional debugging info
|
||||||
console.error("[VideoPlayer] Stream URL:", currentStreamUrl);
|
log.error("Stream URL:", currentStreamUrl);
|
||||||
console.error("[VideoPlayer] Needs transcoding:", needsTranscoding);
|
log.error("Needs transcoding:", needsTranscoding);
|
||||||
|
|
||||||
// Network state meanings: 0=EMPTY, 1=IDLE, 2=LOADING, 3=NO_SOURCE
|
// Network state meanings: 0=EMPTY, 1=IDLE, 2=LOADING, 3=NO_SOURCE
|
||||||
const networkStates = ["NETWORK_EMPTY", "NETWORK_IDLE", "NETWORK_LOADING", "NETWORK_NO_SOURCE"];
|
const networkStates = ["NETWORK_EMPTY", "NETWORK_IDLE", "NETWORK_LOADING", "NETWORK_NO_SOURCE"];
|
||||||
console.error("[VideoPlayer] Network state:", networkStates[video.networkState] || video.networkState);
|
log.error("Network state:", networkStates[video.networkState] || video.networkState);
|
||||||
|
|
||||||
// Ready state meanings: 0=NOTHING, 1=METADATA, 2=CURRENT_DATA, 3=FUTURE_DATA, 4=ENOUGH_DATA
|
// Ready state meanings: 0=NOTHING, 1=METADATA, 2=CURRENT_DATA, 3=FUTURE_DATA, 4=ENOUGH_DATA
|
||||||
const readyStates = ["HAVE_NOTHING", "HAVE_METADATA", "HAVE_CURRENT_DATA", "HAVE_FUTURE_DATA", "HAVE_ENOUGH_DATA"];
|
const readyStates = ["HAVE_NOTHING", "HAVE_METADATA", "HAVE_CURRENT_DATA", "HAVE_FUTURE_DATA", "HAVE_ENOUGH_DATA"];
|
||||||
console.error("[VideoPlayer] Ready state:", readyStates[video.readyState] || video.readyState);
|
log.error("Ready state:", readyStates[video.readyState] || video.readyState);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWaiting() {
|
function handleWaiting() {
|
||||||
console.log("[VideoPlayer] waiting event - buffering");
|
log.debug("waiting event - buffering");
|
||||||
isBuffering = true;
|
isBuffering = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePlaying() {
|
function handlePlaying() {
|
||||||
console.log("[VideoPlayer] playing event - playback resumed");
|
log.debug("playing event - playback resumed");
|
||||||
isBuffering = false;
|
isBuffering = false;
|
||||||
// Safety net: if we reached `playing` we are definitely renderable, even if
|
// Safety net: if we reached `playing` we are definitely renderable, even if
|
||||||
// `canplay`/hls FRAG_BUFFERED were missed on this WebView.
|
// `canplay`/hls FRAG_BUFFERED were missed on this WebView.
|
||||||
@@ -1383,9 +1386,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleLoadStart() {
|
function handleLoadStart() {
|
||||||
console.log("[VideoPlayer] loadstart event - starting to load:", currentStreamUrl);
|
log.debug("loadstart event - starting to load:", currentStreamUrl);
|
||||||
console.log("[VideoPlayer] Video element readyState:", videoElement?.readyState);
|
log.debug("Video element readyState:", videoElement?.readyState);
|
||||||
console.log("[VideoPlayer] Video element networkState:", videoElement?.networkState);
|
log.debug("Video element networkState:", videoElement?.networkState);
|
||||||
|
|
||||||
// Clear any existing fallback timeout
|
// Clear any existing fallback timeout
|
||||||
if (canplayFallbackTimeout) {
|
if (canplayFallbackTimeout) {
|
||||||
@@ -1395,12 +1398,12 @@
|
|||||||
// Set up a fallback timeout in case canplay event never fires
|
// Set up a fallback timeout in case canplay event never fires
|
||||||
canplayFallbackTimeout = setTimeout(() => {
|
canplayFallbackTimeout = setTimeout(() => {
|
||||||
if (!isMediaReady && videoElement) {
|
if (!isMediaReady && videoElement) {
|
||||||
console.warn("[VideoPlayer] canplay event did not fire within 5 seconds");
|
log.warn("canplay event did not fire within 5 seconds");
|
||||||
console.log("[VideoPlayer] Fallback check - readyState:", videoElement.readyState, "networkState:", videoElement.networkState);
|
log.debug("Fallback check - readyState:", videoElement.readyState, "networkState:", videoElement.networkState);
|
||||||
|
|
||||||
// Check if video is actually ready despite event not firing
|
// Check if video is actually ready despite event not firing
|
||||||
if (videoElement.readyState >= 3) { // HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
|
if (videoElement.readyState >= 3) { // HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
|
||||||
console.log("[VideoPlayer] Video appears ready (readyState >= 3), forcing media ready state");
|
log.debug("Video appears ready (readyState >= 3), forcing media ready state");
|
||||||
markMediaReady();
|
markMediaReady();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1430,7 +1433,7 @@
|
|||||||
jrayActors = actors;
|
jrayActors = actors;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[VideoPlayer] JRay lookup failed:", err);
|
log.warn("JRay lookup failed:", err);
|
||||||
if (token === jrayRequestId) jrayActors = [];
|
if (token === jrayRequestId) jrayActors = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1508,8 +1511,8 @@
|
|||||||
// reason. Log the element state so an unexplained pause/resume loop can be
|
// reason. Log the element state so an unexplained pause/resume loop can be
|
||||||
// attributed from an adb capture instead of guessed at.
|
// attributed from an adb capture instead of guessed at.
|
||||||
const el = videoElement;
|
const el = videoElement;
|
||||||
console.log(
|
log.debug(
|
||||||
`[VideoPlayer] pause event — t=${el ? el.currentTime.toFixed(2) : "?"}` +
|
`pause event — t=${el ? el.currentTime.toFixed(2) : "?"}` +
|
||||||
` readyState=${el?.readyState}` +
|
` readyState=${el?.readyState}` +
|
||||||
` networkState=${el?.networkState}` +
|
` networkState=${el?.networkState}` +
|
||||||
` seeking=${el?.seeking}` +
|
` seeking=${el?.seeking}` +
|
||||||
@@ -1553,7 +1556,7 @@
|
|||||||
try {
|
try {
|
||||||
await playerController.toggle();
|
await playerController.toggle();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[VideoPlayer] Failed to toggle playback:", err);
|
log.error("Failed to toggle playback:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1595,7 +1598,7 @@
|
|||||||
isDraggingSeekBar = false;
|
isDraggingSeekBar = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("[VideoPlayer] Seeking to:", targetTime.toFixed(2));
|
log.debug("Seeking to:", targetTime.toFixed(2));
|
||||||
|
|
||||||
// Optimistic display; the primitive updates currentTime/seekOffset as it
|
// Optimistic display; the primitive updates currentTime/seekOffset as it
|
||||||
// completes (reloadSource drives the stream URL via the adapter bridge).
|
// completes (reloadSource drives the stream URL via the adapter bridge).
|
||||||
@@ -1617,9 +1620,9 @@
|
|||||||
startTimeUpdates();
|
startTimeUpdates();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[VideoPlayer] Seek completed at:", currentTime.toFixed(2), "offset:", seekOffset);
|
log.debug("Seek completed at:", currentTime.toFixed(2), "offset:", seekOffset);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[VideoPlayer] Seek failed:", err);
|
log.error("Seek failed:", err);
|
||||||
} finally {
|
} finally {
|
||||||
isSeeking = false;
|
isSeeking = false;
|
||||||
isDraggingSeekBar = false;
|
isDraggingSeekBar = false;
|
||||||
@@ -1654,12 +1657,12 @@
|
|||||||
|
|
||||||
function toggleBackgroundAudio() {
|
function toggleBackgroundAudio() {
|
||||||
backgroundAudioOn = !backgroundAudioOn;
|
backgroundAudioOn = !backgroundAudioOn;
|
||||||
console.log("[VideoPlayer] Background-audio toggle ->", backgroundAudioOn);
|
log.debug("Background-audio toggle ->", backgroundAudioOn);
|
||||||
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
|
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
|
||||||
// so exactly one background behavior is active.
|
// so exactly one background behavior is active.
|
||||||
const armed = setBackgroundAudioEnabled(backgroundAudioOn);
|
const armed = setBackgroundAudioEnabled(backgroundAudioOn);
|
||||||
if (!armed) {
|
if (!armed) {
|
||||||
console.warn("[VideoPlayer] Background audio NOT armed natively (no bridge)");
|
log.warn("Background audio NOT armed natively (no bridge)");
|
||||||
}
|
}
|
||||||
setAutoEnterEnabled(!backgroundAudioOn);
|
setAutoEnterEnabled(!backgroundAudioOn);
|
||||||
}
|
}
|
||||||
@@ -1675,7 +1678,7 @@
|
|||||||
// if the element is mid-teardown — which shipped audio starting from 0:00.
|
// if the element is mid-teardown — which shipped audio starting from 0:00.
|
||||||
const pos = computeHandoffPosition(currentTime, 0);
|
const pos = computeHandoffPosition(currentTime, 0);
|
||||||
const wasPlaying = isPlaying;
|
const wasPlaying = isPlaying;
|
||||||
console.log("[VideoPlayer] Background-audio handoff at position:", pos.toFixed(1));
|
log.debug("Background-audio handoff at position:", pos.toFixed(1));
|
||||||
handoffState = { active: true, wasPlaying };
|
handoffState = { active: true, wasPlaying };
|
||||||
try {
|
try {
|
||||||
if (!media) return;
|
if (!media) return;
|
||||||
@@ -1716,7 +1719,7 @@
|
|||||||
videoElement.load();
|
videoElement.load();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[VideoPlayer] Background-audio handoff failed:", err);
|
log.error("Background-audio handoff failed:", err);
|
||||||
handoffState = { ...initialHandoffState };
|
handoffState = { ...initialHandoffState };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1734,7 +1737,7 @@
|
|||||||
try {
|
try {
|
||||||
// Absolute position the native audio reached (base offset applied in Rust).
|
// Absolute position the native audio reached (base offset applied in Rust).
|
||||||
const pos = await commands.playerExitBackgroundAudio();
|
const pos = await commands.playerExitBackgroundAudio();
|
||||||
console.log("[VideoPlayer] Returning from background audio at:", pos.toFixed(1));
|
log.debug("Returning from background audio at:", pos.toFixed(1));
|
||||||
|
|
||||||
isMediaReady = false;
|
isMediaReady = false;
|
||||||
// The foreground seek below (pendingForegroundSeek/handleCanPlay) OWNS the
|
// The foreground seek below (pendingForegroundSeek/handleCanPlay) OWNS the
|
||||||
@@ -1837,7 +1840,7 @@
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
currentStreamUrl = targetUrl;
|
currentStreamUrl = targetUrl;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[VideoPlayer] Background-audio return failed:", err);
|
log.error("Background-audio return failed:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1856,7 +1859,7 @@
|
|||||||
// WebKitGTK rejects when the gesture isn't recognised as user-activated;
|
// WebKitGTK rejects when the gesture isn't recognised as user-activated;
|
||||||
// the immersive call below is what matters on Android, so don't let a
|
// the immersive call below is what matters on Android, so don't let a
|
||||||
// rejection here abort it.
|
// rejection here abort it.
|
||||||
console.warn("[VideoPlayer] requestFullscreen rejected:", err);
|
log.warn("requestFullscreen rejected:", err);
|
||||||
});
|
});
|
||||||
enterImmersive();
|
enterImmersive();
|
||||||
isFullscreen = true;
|
isFullscreen = true;
|
||||||
@@ -1906,7 +1909,7 @@
|
|||||||
});
|
});
|
||||||
pendingSeekTarget = newTime;
|
pendingSeekTarget = newTime;
|
||||||
|
|
||||||
console.log("[VideoPlayer] Relative seek:", {
|
log.debug("Relative seek:", {
|
||||||
offset: `${seconds > 0 ? "+" : ""}${seconds}s`,
|
offset: `${seconds > 0 ? "+" : ""}${seconds}s`,
|
||||||
from: currentTime.toFixed(2),
|
from: currentTime.toFixed(2),
|
||||||
to: newTime.toFixed(2),
|
to: newTime.toFixed(2),
|
||||||
@@ -2092,7 +2095,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectAudioTrack(streamIndex: number, arrayIndex: number) {
|
async function selectAudioTrack(streamIndex: number, arrayIndex: number) {
|
||||||
console.log("[VideoPlayer] Selecting audio track - streamIndex:", streamIndex, "arrayIndex:", arrayIndex);
|
log.debug("Selecting audio track - streamIndex:", streamIndex, "arrayIndex:", arrayIndex);
|
||||||
const previousTrackIndex = selectedAudioTrackIndex;
|
const previousTrackIndex = selectedAudioTrackIndex;
|
||||||
selectedAudioTrackIndex = streamIndex;
|
selectedAudioTrackIndex = streamIndex;
|
||||||
showAudioTrackMenu = false;
|
showAudioTrackMenu = false;
|
||||||
@@ -2113,7 +2116,7 @@
|
|||||||
startTimeUpdates();
|
startTimeUpdates();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[VideoPlayer] Successfully changed audio track");
|
log.debug("Successfully changed audio track");
|
||||||
|
|
||||||
// Save series audio preference for future episodes
|
// Save series audio preference for future episodes
|
||||||
if (media && media.seriesId) {
|
if (media && media.seriesId) {
|
||||||
@@ -2132,14 +2135,14 @@
|
|||||||
selectedTrack.language || null,
|
selectedTrack.language || null,
|
||||||
streamIndex
|
streamIndex
|
||||||
);
|
);
|
||||||
console.log("[VideoPlayer] Saved series audio preference:", selectedTrack.displayTitle || selectedTrack.language);
|
log.debug("Saved series audio preference:", selectedTrack.displayTitle || selectedTrack.language);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[VideoPlayer] Failed to save series audio preference:", err);
|
log.warn("Failed to save series audio preference:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[VideoPlayer] Failed to change audio track:", err);
|
log.error("Failed to change audio track:", err);
|
||||||
// Revert to previous track on error
|
// Revert to previous track on error
|
||||||
selectedAudioTrackIndex = previousTrackIndex;
|
selectedAudioTrackIndex = previousTrackIndex;
|
||||||
}
|
}
|
||||||
@@ -2177,9 +2180,9 @@
|
|||||||
if (videoElement && !videoElement.paused) {
|
if (videoElement && !videoElement.paused) {
|
||||||
startTimeUpdates();
|
startTimeUpdates();
|
||||||
}
|
}
|
||||||
console.log("[VideoPlayer] Streaming quality changed:", quality);
|
log.debug("Streaming quality changed:", quality);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[VideoPlayer] Failed to change streaming quality:", err);
|
log.error("Failed to change streaming quality:", err);
|
||||||
selectedQuality = previous;
|
selectedQuality = previous;
|
||||||
} finally {
|
} finally {
|
||||||
changingQuality = false;
|
changingQuality = false;
|
||||||
@@ -2210,7 +2213,7 @@
|
|||||||
const trackStreamIndex = parseInt(track.getAttribute("data-stream-index") || "-1");
|
const trackStreamIndex = parseInt(track.getAttribute("data-stream-index") || "-1");
|
||||||
if (trackStreamIndex === streamIndex && track.track) {
|
if (trackStreamIndex === streamIndex && track.track) {
|
||||||
track.track.mode = "showing";
|
track.track.mode = "showing";
|
||||||
console.log("[VideoPlayer] Enabled subtitle track:", streamIndex);
|
log.debug("Enabled subtitle track:", streamIndex);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2230,7 +2233,7 @@
|
|||||||
* TRACES: UR-020 | DR-023, IR-016 | UT-147
|
* TRACES: UR-020 | DR-023, IR-016 | UT-147
|
||||||
*/
|
*/
|
||||||
async function selectSubtitle(streamIndex: number | null) {
|
async function selectSubtitle(streamIndex: number | null) {
|
||||||
console.log("[VideoPlayer] Selecting subtitle - streamIndex:", streamIndex);
|
log.debug("Selecting subtitle - streamIndex:", streamIndex);
|
||||||
selectedSubtitleIndex = streamIndex;
|
selectedSubtitleIndex = streamIndex;
|
||||||
showSubtitleMenu = false;
|
showSubtitleMenu = false;
|
||||||
|
|
||||||
@@ -2242,9 +2245,9 @@
|
|||||||
try {
|
try {
|
||||||
const indexToUse = nativeSubtitleArrayIndex(sentSubtitleTracks, streamIndex);
|
const indexToUse = nativeSubtitleArrayIndex(sentSubtitleTracks, streamIndex);
|
||||||
await commands.playerSetSubtitleTrack(indexToUse);
|
await commands.playerSetSubtitleTrack(indexToUse);
|
||||||
console.log("[VideoPlayer] Native backend subtitle track changed - streamIndex:", streamIndex, "position:", indexToUse);
|
log.debug("Native backend subtitle track changed - streamIndex:", streamIndex, "position:", indexToUse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[VideoPlayer] Failed to set subtitle track:", error);
|
log.error("Failed to set subtitle track:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
import { toast } from "$lib/stores/toast";
|
import { toast } from "$lib/stores/toast";
|
||||||
import CreatePlaylistModal from "./CreatePlaylistModal.svelte";
|
import CreatePlaylistModal from "./CreatePlaylistModal.svelte";
|
||||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("AddToPlaylist");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
@@ -39,7 +42,7 @@
|
|||||||
playlists = result.items;
|
playlists = result.items;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[AddToPlaylist] Failed to load playlists:", e);
|
log.error("Failed to load playlists:", e);
|
||||||
toast.error("Failed to load playlists");
|
toast.error("Failed to load playlists");
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -54,7 +57,7 @@
|
|||||||
toast.success(`Added to "${playlist.name}"`);
|
toast.success(`Added to "${playlist.name}"`);
|
||||||
onClose?.();
|
onClose?.();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[AddToPlaylist] Failed to add:", e);
|
log.error("Failed to add:", e);
|
||||||
toast.error("Failed to add to playlist");
|
toast.error("Failed to add to playlist");
|
||||||
} finally {
|
} finally {
|
||||||
adding = null;
|
adding = null;
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { toast } from "$lib/stores/toast";
|
import { toast } from "$lib/stores/toast";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("CreatePlaylist");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
@@ -27,7 +30,7 @@
|
|||||||
onClose?.();
|
onClose?.();
|
||||||
goto(`/library/${result.id}`);
|
goto(`/library/${result.id}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[CreatePlaylist] Failed:", e);
|
log.error("Failed:", e);
|
||||||
toast.error("Failed to create playlist");
|
toast.error("Failed to create playlist");
|
||||||
} finally {
|
} finally {
|
||||||
creating = false;
|
creating = false;
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
import { playbackPosition } from "$lib/stores/player";
|
import { playbackPosition } from "$lib/stores/player";
|
||||||
import { lmsSync, isLmsSession, macForSession } from "$lib/stores/lmsSync";
|
import { lmsSync, isLmsSession, macForSession } from "$lib/stores/lmsSync";
|
||||||
import type { Session } from "$lib/api/types";
|
import type { Session } from "$lib/api/types";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("SessionPicker");
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
@@ -41,7 +44,7 @@
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to select session:", error);
|
log.error("Failed to select session:", error);
|
||||||
// Error is already stored in playbackMode store
|
// Error is already stored in playbackMode store
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +70,7 @@
|
|||||||
await lmsSync.fuseZone(masterMac, zoneMac);
|
await lmsSync.fuseZone(masterMac, zoneMac);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to toggle LMS zone:", error);
|
log.error("Failed to toggle LMS zone:", error);
|
||||||
// Error is surfaced via the lmsSync store
|
// Error is surfaced via the lmsSync store
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,7 +82,7 @@
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to transfer to local:", error);
|
log.error("Failed to transfer to local:", error);
|
||||||
// Error is already stored in playbackMode store
|
// Error is already stored in playbackMode store
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,7 +94,7 @@
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to disconnect:", error);
|
log.error("Failed to disconnect:", error);
|
||||||
// Error is already stored in playbackMode store
|
// Error is already stored in playbackMode store
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
|
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("Html5PlayerAdapter");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Narrow seam the owning component provides so the adapter can execute the
|
* Narrow seam the owning component provides so the adapter can execute the
|
||||||
@@ -110,7 +113,7 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
|||||||
// play promise while the element keeps trying. Surfacing it would report
|
// play promise while the element keeps trying. Surfacing it would report
|
||||||
// an error roughly once a second for the duration of the stall.
|
// an error roughly once a second for the duration of the stall.
|
||||||
if (isPlayInterruptedError(err)) {
|
if (isPlayInterruptedError(err)) {
|
||||||
console.debug("[Html5PlayerAdapter] play() interrupted by pause (stall recovery)");
|
log.debug("play() interrupted by pause (stall recovery)");
|
||||||
} else {
|
} else {
|
||||||
this.host.onError(`play() failed: ${err}`);
|
this.host.onError(`play() failed: ${err}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
|
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
import type { AdapterHost } from "./types";
|
import type { AdapterHost } from "./types";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("rustReportHost");
|
||||||
|
|
||||||
const POSITION_REPORT_INTERVAL_MS = 250;
|
const POSITION_REPORT_INTERVAL_MS = 250;
|
||||||
|
|
||||||
@@ -37,7 +40,7 @@ export async function reportState(
|
|||||||
try {
|
try {
|
||||||
await commands.playerReportState(state, mediaId);
|
await commands.playerReportState(state, mediaId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[rustReportHost] Failed to report state:", err);
|
log.warn("Failed to report state:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +57,7 @@ export async function reportPosition(
|
|||||||
try {
|
try {
|
||||||
await commands.playerReportPosition(position, Number.isFinite(duration) ? duration : 0);
|
await commands.playerReportPosition(position, Number.isFinite(duration) ? duration : 0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[rustReportHost] Failed to report position:", err);
|
log.warn("Failed to report position:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +65,7 @@ export async function reportMediaLoaded(duration: number): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await commands.playerReportMediaLoaded(Number.isFinite(duration) ? duration : 0);
|
await commands.playerReportMediaLoaded(Number.isFinite(duration) ? duration : 0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[rustReportHost] Failed to report media loaded:", err);
|
log.warn("Failed to report media loaded:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +88,7 @@ export function createRustReportHost(
|
|||||||
onPosition: (position, duration) => void reportPosition(position, duration),
|
onPosition: (position, duration) => void reportPosition(position, duration),
|
||||||
onMediaLoaded: (duration) => void reportMediaLoaded(duration),
|
onMediaLoaded: (duration) => void reportMediaLoaded(duration),
|
||||||
onEnded: view.onEnded ?? (() => {}),
|
onEnded: view.onEnded ?? (() => {}),
|
||||||
onError: view.onError ?? ((message) => console.warn("[rustReportHost] adapter error:", message)),
|
onError: view.onError ?? ((message) => log.warn("adapter error:", message)),
|
||||||
onStreamUrlChanged: view.onStreamUrlChanged ?? (() => {}),
|
onStreamUrlChanged: view.onStreamUrlChanged ?? (() => {}),
|
||||||
onBuffering: view.onBuffering ?? (() => {}),
|
onBuffering: view.onBuffering ?? (() => {}),
|
||||||
onReady: view.onReady ?? (() => {}),
|
onReady: view.onReady ?? (() => {}),
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("deviceId");
|
||||||
|
|
||||||
let cachedDeviceId: string | null = null;
|
let cachedDeviceId: string | null = null;
|
||||||
|
|
||||||
@@ -34,7 +37,7 @@ export async function getDeviceId(): Promise<string> {
|
|||||||
cachedDeviceId = deviceId;
|
cachedDeviceId = deviceId;
|
||||||
return deviceId;
|
return deviceId;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[deviceId] Failed to get device ID from backend:", e);
|
log.error("Failed to get device ID from backend:", e);
|
||||||
throw new Error("Failed to initialize device ID: " + String(e));
|
throw new Error("Failed to initialize device ID: " + String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { commands } from "$lib/api/bindings";
|
|||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { isConnected } from "$lib/stores/connectivity";
|
import { isConnected } from "$lib/stores/connectivity";
|
||||||
import { setFavorite } from "$lib/stores/favorites";
|
import { setFavorite } from "$lib/stores/favorites";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("Favorites");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle the favorite status of an item.
|
* Toggle the favorite status of an item.
|
||||||
@@ -59,7 +62,7 @@ export async function toggleFavorite(
|
|||||||
// 3. Mark as synced
|
// 3. Mark as synced
|
||||||
await commands.storageMarkSynced(userId, itemId);
|
await commands.storageMarkSynced(userId, itemId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to sync favorite to server:", error);
|
log.error("Failed to sync favorite to server:", error);
|
||||||
// Favorite is stored locally and will be synced later
|
// Favorite is stored locally and will be synced later
|
||||||
// via sync queue (when implemented)
|
// via sync queue (when implemented)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
|
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("ImageCache");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Statistics about the thumbnail cache
|
* Statistics about the thumbnail cache
|
||||||
@@ -48,7 +51,7 @@ export async function getCachedImageUrl(
|
|||||||
return convertFileSrc(cachedPath);
|
return convertFileSrc(cachedPath);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.debug("Failed to check thumbnail cache:", e);
|
log.debug("Failed to check thumbnail cache:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build server URL
|
// Build server URL
|
||||||
@@ -63,7 +66,7 @@ export async function getCachedImageUrl(
|
|||||||
// Trigger background caching (fire and forget)
|
// Trigger background caching (fire and forget)
|
||||||
commands.thumbnailSave(itemId, imageType, tag, serverImageUrl).catch((e) => {
|
commands.thumbnailSave(itemId, imageType, tag, serverImageUrl).catch((e) => {
|
||||||
// Silently fail - caching is best-effort
|
// Silently fail - caching is best-effort
|
||||||
console.debug("Background thumbnail cache failed:", e);
|
log.debug("Background thumbnail cache failed:", e);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return server URL for immediate display
|
// Return server URL for immediate display
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
|
|
||||||
import { commands } from '$lib/api/bindings';
|
import { commands } from '$lib/api/bindings';
|
||||||
import type { NetworkType } from '$lib/api/bindings';
|
import type { NetworkType } from '$lib/api/bindings';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('NetworkType');
|
||||||
|
|
||||||
/** The Android bridge, present only in the Android WebView. */
|
/** The Android bridge, present only in the Android WebView. */
|
||||||
interface AndroidNetworkTypeBridge {
|
interface AndroidNetworkTypeBridge {
|
||||||
@@ -62,7 +65,7 @@ export async function reportNetworkState(): Promise<void> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Never let network reporting break the UI — the gate fails closed on
|
// Never let network reporting break the UI — the gate fails closed on
|
||||||
// the Rust side, so a missed report at worst delays a queued download.
|
// the Rust side, so a missed report at worst delays a queued download.
|
||||||
console.warn('[NetworkType] Failed to report network state:', error);
|
log.warn('Failed to report network state:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +106,7 @@ export async function areDownloadsAllowed(): Promise<boolean> {
|
|||||||
try {
|
try {
|
||||||
return await commands.getDownloadsAllowed();
|
return await commands.getDownloadsAllowed();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[NetworkType] Failed to query download gate:', error);
|
log.warn('Failed to query download gate:', error);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import { goto } from "$app/navigation";
|
|||||||
import { cancelAutoplayCountdown } from "$lib/api/autoplay";
|
import { cancelAutoplayCountdown } from "$lib/api/autoplay";
|
||||||
import { nextEpisode } from "$lib/stores/nextEpisode";
|
import { nextEpisode } from "$lib/stores/nextEpisode";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("NextEpisode");
|
||||||
|
|
||||||
/** Guard against double-navigation */
|
/** Guard against double-navigation */
|
||||||
let isNavigating = false;
|
let isNavigating = false;
|
||||||
@@ -46,11 +49,11 @@ export async function cancelAutoPlay() {
|
|||||||
*/
|
*/
|
||||||
function navigateToEpisode(episode: MediaItem) {
|
function navigateToEpisode(episode: MediaItem) {
|
||||||
if (isNavigating) {
|
if (isNavigating) {
|
||||||
console.warn("[NextEpisode] Already navigating, skipping duplicate navigation to", episode.id);
|
log.warn("Already navigating, skipping duplicate navigation to", episode.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isNavigating = true;
|
isNavigating = true;
|
||||||
console.log("[NextEpisode] Navigating to next episode:", episode.id, episode.name);
|
log.debug("Navigating to next episode:", episode.id, episode.name);
|
||||||
nextEpisode.hidePopup();
|
nextEpisode.hidePopup();
|
||||||
goto(`/player/${episode.id}?restart=true`, { replaceState: true }).finally(() => {
|
goto(`/player/${episode.id}?restart=true`, { replaceState: true }).finally(() => {
|
||||||
isNavigating = false;
|
isNavigating = false;
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import { writable, type Writable } from "svelte/store";
|
|||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { isConnected } from "$lib/stores/connectivity";
|
import { isConnected } from "$lib/stores/connectivity";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("OfflineCatalog");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When true (and offline), library grids reveal greyed-out versions of media
|
* When true (and offline), library grids reveal greyed-out versions of media
|
||||||
@@ -58,7 +61,7 @@ async function pushCatalogVisibility(connected: boolean, showCatalog: boolean):
|
|||||||
try {
|
try {
|
||||||
await commands.setShowServerCatalog(include);
|
await commands.setShowServerCatalog(include);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[OfflineCatalog] Failed to set catalog visibility:", err);
|
log.warn("Failed to set catalog visibility:", err);
|
||||||
// The backend is still on the old gate, so forget that we sent this —
|
// The backend is still on the old gate, so forget that we sent this —
|
||||||
// otherwise the next identical transition is skipped as a no-op and the
|
// otherwise the next identical transition is skipped as a no-op and the
|
||||||
// frontend and backend disagree about the filter for the rest of the
|
// frontend and backend disagree about the filter for the rest of the
|
||||||
@@ -115,12 +118,12 @@ export async function syncCatalog(): Promise<void> {
|
|||||||
syncInProgress = true;
|
syncInProgress = true;
|
||||||
try {
|
try {
|
||||||
const result = await commands.syncFullCatalog(handle);
|
const result = await commands.syncFullCatalog(handle);
|
||||||
console.info(
|
log.info(
|
||||||
`[OfflineCatalog] Synced ${result.itemsCached} items (${result.librariesFailed} libraries failed)`
|
`Synced ${result.itemsCached} items (${result.librariesFailed} libraries failed)`
|
||||||
);
|
);
|
||||||
await refreshSyncStatus();
|
await refreshSyncStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[OfflineCatalog] Full catalog sync failed:", err);
|
log.warn("Full catalog sync failed:", err);
|
||||||
} finally {
|
} finally {
|
||||||
syncInProgress = false;
|
syncInProgress = false;
|
||||||
}
|
}
|
||||||
@@ -136,12 +139,12 @@ export async function resumeQueued(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const result = await commands.resumeQueuedDownloads(handle);
|
const result = await commands.resumeQueuedDownloads(handle);
|
||||||
if (result.resolved > 0 || result.failed > 0) {
|
if (result.resolved > 0 || result.failed > 0) {
|
||||||
console.info(
|
log.info(
|
||||||
`[OfflineCatalog] Resumed queued downloads: ${result.resolved} resolved, ${result.failed} failed`
|
`Resumed queued downloads: ${result.resolved} resolved, ${result.failed} failed`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[OfflineCatalog] Failed to resume queued downloads:", err);
|
log.warn("Failed to resume queued downloads:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +154,7 @@ export async function refreshSyncStatus(): Promise<void> {
|
|||||||
const status = await commands.catalogSyncStatus();
|
const status = await commands.catalogSyncStatus();
|
||||||
lastCatalogSync.set(status.lastSyncedAt ?? null);
|
lastCatalogSync.set(status.lastSyncedAt ?? null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.debug("[OfflineCatalog] Failed to fetch sync status:", err);
|
log.debug("Failed to fetch sync status:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("capabilities");
|
||||||
|
|
||||||
export interface PlaybackCapabilities {
|
export interface PlaybackCapabilities {
|
||||||
/** Audio renders through a webview `<audio>` element, not a native backend. */
|
/** Audio renders through a webview `<audio>` element, not a native backend. */
|
||||||
@@ -52,7 +55,7 @@ export async function getPlaybackCapabilities(): Promise<PlaybackCapabilities> {
|
|||||||
};
|
};
|
||||||
return cached;
|
return cached;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[capabilities] player_get_capabilities failed:", err);
|
log.warn("player_get_capabilities failed:", err);
|
||||||
// Do NOT cache the fallback — a later call should get the real answer.
|
// Do NOT cache the fallback — a later call should get the real answer.
|
||||||
return FALLBACK;
|
return FALLBACK;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
|
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("PlaybackReporting");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record the start of playback **locally**, with the context it started from.
|
* Record the start of playback **locally**, with the context it started from.
|
||||||
@@ -32,8 +35,8 @@ export async function reportPlaybackStart(
|
|||||||
const positionMs = Math.floor(positionSeconds * 1000);
|
const positionMs = Math.floor(positionSeconds * 1000);
|
||||||
const userId = auth.getUserId();
|
const userId = auth.getUserId();
|
||||||
|
|
||||||
console.log(
|
log.debug(
|
||||||
"[PlaybackReporting] reportPlaybackStart - itemId:",
|
"reportPlaybackStart - itemId:",
|
||||||
itemId,
|
itemId,
|
||||||
"positionSeconds:",
|
"positionSeconds:",
|
||||||
positionSeconds,
|
positionSeconds,
|
||||||
@@ -47,7 +50,7 @@ export async function reportPlaybackStart(
|
|||||||
try {
|
try {
|
||||||
await commands.storageUpdatePlaybackContext(userId, itemId, positionMs, contextType, contextId);
|
await commands.storageUpdatePlaybackContext(userId, itemId, positionMs, contextType, contextId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[PlaybackReporting] Failed to update playback context:", e);
|
log.error("Failed to update playback context:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,7 +79,7 @@ export async function reportPlaybackProgress(
|
|||||||
|
|
||||||
// Reduce logging for frequent progress updates
|
// Reduce logging for frequent progress updates
|
||||||
if (Math.floor(positionSeconds) % 30 === 0) {
|
if (Math.floor(positionSeconds) % 30 === 0) {
|
||||||
console.log("[PlaybackReporting] reportPlaybackProgress - itemId:", itemId, "position:", positionSeconds);
|
log.debug("reportPlaybackProgress - itemId:", itemId, "position:", positionSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update local DB only (progress updates are frequent, don't report to server)
|
// Update local DB only (progress updates are frequent, don't report to server)
|
||||||
@@ -84,7 +87,7 @@ export async function reportPlaybackProgress(
|
|||||||
try {
|
try {
|
||||||
await commands.storageUpdatePlaybackProgress(userId, itemId, positionMs);
|
await commands.storageUpdatePlaybackProgress(userId, itemId, positionMs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[PlaybackReporting] Failed to update local progress:", e);
|
log.error("Failed to update local progress:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,14 +104,14 @@ export async function reportPlaybackStopped(itemId: string, positionSeconds: num
|
|||||||
const positionMs = Math.floor(positionSeconds * 1000);
|
const positionMs = Math.floor(positionSeconds * 1000);
|
||||||
const userId = auth.getUserId();
|
const userId = auth.getUserId();
|
||||||
|
|
||||||
console.log("[PlaybackReporting] reportPlaybackStopped - itemId:", itemId, "positionSeconds:", positionSeconds);
|
log.debug("reportPlaybackStopped - itemId:", itemId, "positionSeconds:", positionSeconds);
|
||||||
|
|
||||||
// Update local DB first (always works, even offline)
|
// Update local DB first (always works, even offline)
|
||||||
if (userId) {
|
if (userId) {
|
||||||
try {
|
try {
|
||||||
await commands.storageUpdatePlaybackProgress(userId, itemId, positionMs);
|
await commands.storageUpdatePlaybackProgress(userId, itemId, positionMs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[PlaybackReporting] Failed to update local progress:", e);
|
log.error("Failed to update local progress:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +123,7 @@ export async function reportPlaybackStopped(itemId: string, positionSeconds: num
|
|||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
await repo.reportPlaybackStopped(itemId, positionMs);
|
await repo.reportPlaybackStopped(itemId, positionMs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[PlaybackReporting] Stop-report did not reach the server; queued for sync:", e);
|
log.warn("Stop-report did not reach the server; queued for sync:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,14 +136,14 @@ export async function reportPlaybackStopped(itemId: string, positionSeconds: num
|
|||||||
export async function markAsPlayed(itemId: string): Promise<void> {
|
export async function markAsPlayed(itemId: string): Promise<void> {
|
||||||
const userId = auth.getUserId();
|
const userId = auth.getUserId();
|
||||||
|
|
||||||
console.log("[PlaybackReporting] markAsPlayed - itemId:", itemId);
|
log.debug("markAsPlayed - itemId:", itemId);
|
||||||
|
|
||||||
// Update local DB first
|
// Update local DB first
|
||||||
if (userId) {
|
if (userId) {
|
||||||
try {
|
try {
|
||||||
await commands.storageMarkPlayed(userId, itemId);
|
await commands.storageMarkPlayed(userId, itemId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[PlaybackReporting] Failed to mark as played in local DB:", e);
|
log.error("Failed to mark as played in local DB:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +156,6 @@ export async function markAsPlayed(itemId: string): Promise<void> {
|
|||||||
await repo.reportPlaybackStopped(itemId, item.durationMs);
|
await repo.reportPlaybackStopped(itemId, item.durationMs);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[PlaybackReporting] Failed to report as played:", e);
|
log.error("Failed to report as played:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import { preloadUpcomingTracks } from "$lib/services/preload";
|
|||||||
import { playerController } from "$lib/player";
|
import { playerController } from "$lib/player";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("playerEvents");
|
||||||
|
|
||||||
// PlayerStatusEvent and SleepTimerMode are generated by tauri-specta and
|
// PlayerStatusEvent and SleepTimerMode are generated by tauri-specta and
|
||||||
// imported from $lib/api/bindings — they are the authoritative shapes emitted
|
// imported from $lib/api/bindings — they are the authoritative shapes emitted
|
||||||
@@ -34,7 +37,7 @@ let isInitialized = false;
|
|||||||
*/
|
*/
|
||||||
export async function initPlayerEvents(): Promise<void> {
|
export async function initPlayerEvents(): Promise<void> {
|
||||||
if (isInitialized) {
|
if (isInitialized) {
|
||||||
console.warn("Player events already initialized");
|
log.warn("Player events already initialized");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,9 +46,9 @@ export async function initPlayerEvents(): Promise<void> {
|
|||||||
handlePlayerEvent(event.payload);
|
handlePlayerEvent(event.payload);
|
||||||
});
|
});
|
||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
console.log("Player event listener initialized");
|
log.debug("Player event listener initialized");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to initialize player events:", e);
|
log.error("Failed to initialize player events:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +101,7 @@ function handlePlayerEvent(event: PlayerStatusEvent): void {
|
|||||||
|
|
||||||
case "buffering":
|
case "buffering":
|
||||||
// Could show buffering indicator in UI
|
// Could show buffering indicator in UI
|
||||||
console.debug(`Buffering: ${event.percent}%`);
|
log.debug(`Buffering: ${event.percent}%`);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
@@ -196,7 +199,7 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
|||||||
// When local playback starts, ensure mode is set to local
|
// When local playback starts, ensure mode is set to local
|
||||||
const mode = get(playbackMode);
|
const mode = get(playbackMode);
|
||||||
if (mode.mode !== "local") {
|
if (mode.mode !== "local") {
|
||||||
console.log("Setting playback mode to local");
|
log.debug("Setting playback mode to local");
|
||||||
playbackMode.setMode("local");
|
playbackMode.setMode("local");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +218,7 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
|||||||
// Trigger preloading of upcoming tracks in the background
|
// Trigger preloading of upcoming tracks in the background
|
||||||
preloadUpcomingTracks().catch((e) => {
|
preloadUpcomingTracks().catch((e) => {
|
||||||
// Preload failures are non-critical, already logged in the service
|
// Preload failures are non-critical, already logged in the service
|
||||||
console.debug("[playerEvents] Preload failed (non-critical):", e);
|
log.debug("Preload failed (non-critical):", e);
|
||||||
});
|
});
|
||||||
} else if (state === "paused" && currentItem) {
|
} else if (state === "paused" && currentItem) {
|
||||||
// Keep current position and duration from store. The same track is
|
// Keep current position and duration from store. The same track is
|
||||||
@@ -240,7 +243,7 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
|||||||
// When local playback stops, revert to idle mode
|
// When local playback stops, revert to idle mode
|
||||||
const currentMode = get(playbackMode);
|
const currentMode = get(playbackMode);
|
||||||
if (currentMode.mode === "local") {
|
if (currentMode.mode === "local") {
|
||||||
console.log("Setting playback mode to idle");
|
log.debug("Setting playback mode to idle");
|
||||||
playbackMode.setMode("idle");
|
playbackMode.setMode("idle");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +257,7 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
|||||||
function handleMediaLoaded(duration: number): void {
|
function handleMediaLoaded(duration: number): void {
|
||||||
// Media is now loaded and ready
|
// Media is now loaded and ready
|
||||||
// The state_changed event will handle setting the playing state
|
// The state_changed event will handle setting the playing state
|
||||||
console.debug(`Media loaded, duration: ${duration}s`);
|
log.debug(`Media loaded, duration: ${duration}s`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -268,7 +271,7 @@ async function handlePlaybackEnded(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await commands.playerOnPlaybackEnded(null, null);
|
await commands.playerOnPlaybackEnded(null, null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[playerEvents] Failed to handle playback ended:", e);
|
log.error("Failed to handle playback ended:", e);
|
||||||
// Fallback: set idle state on error
|
// Fallback: set idle state on error
|
||||||
player.setIdle();
|
player.setIdle();
|
||||||
}
|
}
|
||||||
@@ -287,18 +290,18 @@ async function handlePlaybackEnded(): Promise<void> {
|
|||||||
* TRACES: UR-004, UR-040 | DR-130
|
* TRACES: UR-004, UR-040 | DR-130
|
||||||
*/
|
*/
|
||||||
async function handleError(message: string, recoverable: boolean): Promise<void> {
|
async function handleError(message: string, recoverable: boolean): Promise<void> {
|
||||||
console.error(`Playback error (recoverable: ${recoverable}): ${message}`);
|
log.error(`Playback error (recoverable: ${recoverable}): ${message}`);
|
||||||
|
|
||||||
if (recoverable) {
|
if (recoverable) {
|
||||||
try {
|
try {
|
||||||
if (await commands.playerRecoverStream()) {
|
if (await commands.playerRecoverStream()) {
|
||||||
console.log("Stream re-opened after a recoverable error - not stopping");
|
log.debug("Stream re-opened after a recoverable error - not stopping");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fall through to the normal stop: a failed recovery attempt is still an
|
// Fall through to the normal stop: a failed recovery attempt is still an
|
||||||
// error, and leaving the player running would strand it mid-failure.
|
// error, and leaving the player running would strand it mid-failure.
|
||||||
console.error("Stream recovery attempt failed:", e);
|
log.error("Stream recovery attempt failed:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,9 +311,9 @@ async function handleError(message: string, recoverable: boolean): Promise<void>
|
|||||||
// This also reports playback stopped to Jellyfin server
|
// This also reports playback stopped to Jellyfin server
|
||||||
try {
|
try {
|
||||||
await commands.playerStop();
|
await commands.playerStop();
|
||||||
console.log("Backend player stopped after error");
|
log.debug("Backend player stopped after error");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to stop player after error:", e);
|
log.error("Failed to stop player after error:", e);
|
||||||
// Continue with state cleanup even if stop fails
|
// Continue with state cleanup even if stop fails
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,7 +362,7 @@ function handleControlCommand(action: string, position: number | null): void {
|
|||||||
void adapter.pause();
|
void adapter.pause();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn("[playerEvents] Unknown control command:", action);
|
log.warn("Unknown control command:", action);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
import { commands } from '$lib/api/bindings';
|
import { commands } from '$lib/api/bindings';
|
||||||
import type { CacheConfig } from '$lib/api/bindings';
|
import type { CacheConfig } from '$lib/api/bindings';
|
||||||
import { auth } from '$lib/stores/auth';
|
import { auth } from '$lib/stores/auth';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('Preload');
|
||||||
|
|
||||||
interface PreloadOptions {
|
interface PreloadOptions {
|
||||||
/** Enable debug logging */
|
/** Enable debug logging */
|
||||||
@@ -28,17 +31,17 @@ export async function preloadUpcomingTracks(options: PreloadOptions = {}): Promi
|
|||||||
const userId = overrideUserId || auth.getUserId();
|
const userId = overrideUserId || auth.getUserId();
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
if (debug) console.log('[Preload] No active user session, skipping preload');
|
if (debug) log.debug('No active user session, skipping preload');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (debug) console.log('[Preload] Triggering preload for user:', userId);
|
if (debug) log.debug('Triggering preload for user:', userId);
|
||||||
|
|
||||||
// downloadBasePath is currently unused in the backend
|
// downloadBasePath is currently unused in the backend
|
||||||
const result = await commands.playerPreloadUpcoming(userId, '/downloads');
|
const result = await commands.playerPreloadUpcoming(userId, '/downloads');
|
||||||
|
|
||||||
if (debug) {
|
if (debug) {
|
||||||
console.log('[Preload] Result:', {
|
log.debug('Result:', {
|
||||||
queued: result.queuedCount,
|
queued: result.queuedCount,
|
||||||
alreadyDownloaded: result.alreadyDownloaded,
|
alreadyDownloaded: result.alreadyDownloaded,
|
||||||
skipped: result.skipped
|
skipped: result.skipped
|
||||||
@@ -47,12 +50,12 @@ export async function preloadUpcomingTracks(options: PreloadOptions = {}): Promi
|
|||||||
|
|
||||||
// Log meaningful results
|
// Log meaningful results
|
||||||
if (result.queuedCount > 0) {
|
if (result.queuedCount > 0) {
|
||||||
console.log(`[Preload] Queued ${result.queuedCount} track(s) for background download`);
|
log.debug(`Queued ${result.queuedCount} track(s) for background download`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Fail silently - preloading is a background optimization
|
// Fail silently - preloading is a background optimization
|
||||||
// Don't interrupt the user's playback experience
|
// Don't interrupt the user's playback experience
|
||||||
console.warn('[Preload] Failed to preload upcoming tracks:', error);
|
log.warn('Failed to preload upcoming tracks:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import { auth } from "$lib/stores/auth";
|
|||||||
// hand-written mirror — the mirror had already drifted (it predates `itemName`),
|
// hand-written mirror — the mirror had already drifted (it predates `itemName`),
|
||||||
// and a drifted duplicate is how a field silently stops reaching the UI.
|
// and a drifted duplicate is how a field silently stops reaching the UI.
|
||||||
import type { SyncQueueItem } from "$lib/api/bindings";
|
import type { SyncQueueItem } from "$lib/api/bindings";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("SyncService");
|
||||||
export type { SyncQueueItem };
|
export type { SyncQueueItem };
|
||||||
|
|
||||||
export type SyncOperation =
|
export type SyncOperation =
|
||||||
@@ -42,14 +45,14 @@ class SyncService {
|
|||||||
* Start the sync service (lifecycle managed by Rust backend)
|
* Start the sync service (lifecycle managed by Rust backend)
|
||||||
*/
|
*/
|
||||||
start(): void {
|
start(): void {
|
||||||
console.log("[SyncService] Started");
|
log.debug("Started");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop the sync service (lifecycle managed by Rust backend)
|
* Stop the sync service (lifecycle managed by Rust backend)
|
||||||
*/
|
*/
|
||||||
stop(): void {
|
stop(): void {
|
||||||
console.log("[SyncService] Stopped");
|
log.debug("Stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,7 +77,7 @@ class SyncService {
|
|||||||
payload ? JSON.stringify(payload) : null
|
payload ? JSON.stringify(payload) : null
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`[SyncService] Queued ${operation} for item ${itemId}, id: ${id}`);
|
log.debug(`Queued ${operation} for item ${itemId}, id: ${id}`);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +158,7 @@ class SyncService {
|
|||||||
*/
|
*/
|
||||||
async cleanup(daysOld: number = 7): Promise<number> {
|
async cleanup(daysOld: number = 7): Promise<number> {
|
||||||
const deleted = await commands.syncCleanupCompleted(daysOld);
|
const deleted = await commands.syncCleanupCompleted(daysOld);
|
||||||
console.log(`[SyncService] Cleaned up ${deleted} old completed operations`);
|
log.debug(`Cleaned up ${deleted} old completed operations`);
|
||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +197,7 @@ class SyncService {
|
|||||||
const userId = auth.getUserId();
|
const userId = auth.getUserId();
|
||||||
if (userId) {
|
if (userId) {
|
||||||
await commands.syncClearUser(userId);
|
await commands.syncClearUser(userId);
|
||||||
console.log("[SyncService] Cleared sync queue for user");
|
log.debug("Cleared sync queue for user");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-39
@@ -13,6 +13,9 @@ import type { User, AuthResult } from "$lib/api/types";
|
|||||||
import type { Session, AuthServerInfo as ServerInfo } from "$lib/api/bindings";
|
import type { Session, AuthServerInfo as ServerInfo } from "$lib/api/bindings";
|
||||||
import { connectivity } from "./connectivity";
|
import { connectivity } from "./connectivity";
|
||||||
import { getDeviceId, clearCache as clearDeviceIdCache } from "$lib/services/deviceId";
|
import { getDeviceId, clearCache as clearDeviceIdCache } from "$lib/services/deviceId";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("Auth");
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
@@ -70,7 +73,7 @@ function createAuthStore() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
unlistenSessionVerified = await listen<{ user: User }>("auth:session-verified", (event) => {
|
unlistenSessionVerified = await listen<{ user: User }>("auth:session-verified", (event) => {
|
||||||
console.log("[Auth] Session verified:", event.payload.user.name);
|
log.debug("Session verified:", event.payload.user.name);
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
sessionVerified: true,
|
sessionVerified: true,
|
||||||
@@ -80,12 +83,12 @@ function createAuthStore() {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Auth] Failed to listen to session-verified event:", e);
|
log.error("Failed to listen to session-verified event:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
unlistenNeedsReauth = await listen<{ reason: string }>("auth:needs-reauth", (event) => {
|
unlistenNeedsReauth = await listen<{ reason: string }>("auth:needs-reauth", (event) => {
|
||||||
console.log("[Auth] Session needs re-authentication:", event.payload.reason);
|
log.debug("Session needs re-authentication:", event.payload.reason);
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
sessionVerified: false,
|
sessionVerified: false,
|
||||||
@@ -95,17 +98,17 @@ function createAuthStore() {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Auth] Failed to listen to needs-reauth event:", e);
|
log.error("Failed to listen to needs-reauth event:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
unlistenNetworkError = await listen<{ message: string }>("auth:network-error", (event) => {
|
unlistenNetworkError = await listen<{ message: string }>("auth:network-error", (event) => {
|
||||||
console.log("[Auth] Network error during verification:", event.payload.message);
|
log.debug("Network error during verification:", event.payload.message);
|
||||||
// Network errors don't trigger re-auth - just log them
|
// Network errors don't trigger re-auth - just log them
|
||||||
update((s) => ({ ...s, isVerifying: false }));
|
update((s) => ({ ...s, isVerifying: false }));
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Auth] Failed to listen to network-error event:", e);
|
log.error("Failed to listen to network-error event:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +147,7 @@ function createAuthStore() {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const securityStatus = await commands.storageGetSecurityStatus();
|
const securityStatus = await commands.storageGetSecurityStatus();
|
||||||
console.log("[Auth] Security status:", securityStatus);
|
log.debug("Security status:", securityStatus);
|
||||||
if (!securityStatus.usingKeyring) {
|
if (!securityStatus.usingKeyring) {
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
@@ -153,17 +156,17 @@ function createAuthStore() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Auth] Failed to get security status:", error);
|
log.warn("Failed to get security status:", error);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Initialize auth manager and get session
|
// Initialize auth manager and get session
|
||||||
console.log("[Auth] Initializing auth manager...");
|
log.debug("Initializing auth manager...");
|
||||||
const session = await commands.authInitialize();
|
const session = await commands.authInitialize();
|
||||||
console.log("[Auth] Session retrieval result:", session ? "Session found" : "No session found");
|
log.debug("Session retrieval result:", session ? "Session found" : "No session found");
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
console.log("[Auth] Restoring session for user:", session.username, "on server:", session.serverUrl);
|
log.debug("Restoring session for user:", session.username, "on server:", session.serverUrl);
|
||||||
|
|
||||||
// Create RepositoryClient for cache-first access. This IS required before
|
// Create RepositoryClient for cache-first access. This IS required before
|
||||||
// we mark authenticated — the first screen (library overview) reads
|
// we mark authenticated — the first screen (library overview) reads
|
||||||
@@ -184,9 +187,9 @@ function createAuthStore() {
|
|||||||
session.userId,
|
session.userId,
|
||||||
deviceId
|
deviceId
|
||||||
);
|
);
|
||||||
console.log("[Auth] Rust player configured for automatic playback reporting");
|
log.debug("Rust player configured for automatic playback reporting");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Auth] Failed to configure Rust player:", error);
|
log.error("Failed to configure Rust player:", error);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -205,7 +208,7 @@ function createAuthStore() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Start connectivity monitoring early to avoid appearing offline on startup
|
// Start connectivity monitoring early to avoid appearing offline on startup
|
||||||
console.log("[Auth] Starting early connectivity monitoring...");
|
log.debug("Starting early connectivity monitoring...");
|
||||||
connectivity.startMonitoring(session.serverUrl, {
|
connectivity.startMonitoring(session.serverUrl, {
|
||||||
onServerReconnected: () => {
|
onServerReconnected: () => {
|
||||||
// Retry session verification when server becomes reachable
|
// Retry session verification when server becomes reachable
|
||||||
@@ -214,10 +217,10 @@ function createAuthStore() {
|
|||||||
// Lazy import to avoid an auth <-> offlineCatalog import cycle.
|
// Lazy import to avoid an auth <-> offlineCatalog import cycle.
|
||||||
import("$lib/services/offlineCatalog")
|
import("$lib/services/offlineCatalog")
|
||||||
.then((m) => m.onReconnected())
|
.then((m) => m.onReconnected())
|
||||||
.catch((err) => console.warn("[Auth] Catalog reconnect failed:", err));
|
.catch((err) => log.warn("Catalog reconnect failed:", err));
|
||||||
},
|
},
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error("[Auth] Failed to start connectivity monitoring:", error);
|
log.error("Failed to start connectivity monitoring:", error);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start background session verification — fire-and-forget. This is
|
// Start background session verification — fire-and-forget. This is
|
||||||
@@ -228,14 +231,14 @@ function createAuthStore() {
|
|||||||
try {
|
try {
|
||||||
const verifyDeviceId = await getDeviceId();
|
const verifyDeviceId = await getDeviceId();
|
||||||
await commands.authStartVerification(verifyDeviceId);
|
await commands.authStartVerification(verifyDeviceId);
|
||||||
console.log("[Auth] Background verification started");
|
log.debug("Background verification started");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Auth] Failed to start verification:", error);
|
log.error("Failed to start verification:", error);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
} else {
|
} else {
|
||||||
// No stored session
|
// No stored session
|
||||||
console.log("[Auth] No active session found");
|
log.debug("No active session found");
|
||||||
set({
|
set({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -250,7 +253,7 @@ function createAuthStore() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Auth] Failed to initialize:", error);
|
log.error("Failed to initialize:", error);
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -269,15 +272,15 @@ function createAuthStore() {
|
|||||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("[Auth] Connecting to server:", serverUrl);
|
log.debug("Connecting to server:", serverUrl);
|
||||||
const serverInfo = await commands.authConnectToServer(serverUrl);
|
const serverInfo = await commands.authConnectToServer(serverUrl);
|
||||||
console.log("[Auth] Connected to server:", serverInfo.name, serverInfo.version);
|
log.debug("Connected to server:", serverInfo.name, serverInfo.version);
|
||||||
console.log("[Auth] Normalized URL:", serverInfo.normalizedUrl);
|
log.debug("Normalized URL:", serverInfo.normalizedUrl);
|
||||||
|
|
||||||
update((s) => ({ ...s, isLoading: false }));
|
update((s) => ({ ...s, isLoading: false }));
|
||||||
return serverInfo;
|
return serverInfo;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Auth] Failed to connect to server:", error);
|
log.error("Failed to connect to server:", error);
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -297,11 +300,11 @@ function createAuthStore() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const deviceId = await getDeviceId();
|
const deviceId = await getDeviceId();
|
||||||
console.log("[Auth] Logging in as:", username);
|
log.debug("Logging in as:", username);
|
||||||
|
|
||||||
const authResult = await commands.authLogin(serverUrl, username, password, deviceId);
|
const authResult = await commands.authLogin(serverUrl, username, password, deviceId);
|
||||||
|
|
||||||
console.log("[Auth] Login successful:", authResult.user);
|
log.debug("Login successful:", authResult.user);
|
||||||
|
|
||||||
// Save to storage
|
// Save to storage
|
||||||
await commands.storageSaveServer(authResult.serverId, serverName, serverUrl, null);
|
await commands.storageSaveServer(authResult.serverId, serverName, serverUrl, null);
|
||||||
@@ -340,9 +343,9 @@ function createAuthStore() {
|
|||||||
authResult.user.id,
|
authResult.user.id,
|
||||||
playerDeviceId
|
playerDeviceId
|
||||||
);
|
);
|
||||||
console.log("[Auth] Rust player configured for playback reporting");
|
log.debug("Rust player configured for playback reporting");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Auth] Failed to configure Rust player:", error);
|
log.error("Failed to configure Rust player:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
@@ -364,12 +367,12 @@ function createAuthStore() {
|
|||||||
const verifyDeviceId = await getDeviceId();
|
const verifyDeviceId = await getDeviceId();
|
||||||
await commands.authStartVerification(verifyDeviceId);
|
await commands.authStartVerification(verifyDeviceId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Auth] Failed to start verification:", error);
|
log.error("Failed to start verification:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return authResult;
|
return authResult;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Auth] Login failed:", error);
|
log.error("Login failed:", error);
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
update((s) => ({ ...s, isLoading: false, error: errorMessage }));
|
update((s) => ({ ...s, isLoading: false, error: errorMessage }));
|
||||||
throw error;
|
throw error;
|
||||||
@@ -384,11 +387,11 @@ function createAuthStore() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const deviceId = await getDeviceId();
|
const deviceId = await getDeviceId();
|
||||||
console.log("[Auth] Re-authenticating...");
|
log.debug("Re-authenticating...");
|
||||||
|
|
||||||
const authResult = await commands.authReauthenticate(password, deviceId);
|
const authResult = await commands.authReauthenticate(password, deviceId);
|
||||||
|
|
||||||
console.log("[Auth] Re-authentication successful");
|
log.debug("Re-authentication successful");
|
||||||
|
|
||||||
// Update storage
|
// Update storage
|
||||||
await commands.storageSaveUser(
|
await commands.storageSaveUser(
|
||||||
@@ -417,7 +420,7 @@ function createAuthStore() {
|
|||||||
playerDeviceId
|
playerDeviceId
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Auth] Failed to reconfigure player:", error);
|
log.error("Failed to reconfigure player:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
@@ -432,7 +435,7 @@ function createAuthStore() {
|
|||||||
|
|
||||||
return authResult;
|
return authResult;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Auth] Re-authentication failed:", error);
|
log.error("Re-authentication failed:", error);
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
update((s) => ({ ...s, isLoading: false, error: errorMessage }));
|
update((s) => ({ ...s, isLoading: false, error: errorMessage }));
|
||||||
throw error;
|
throw error;
|
||||||
@@ -456,7 +459,7 @@ function createAuthStore() {
|
|||||||
try {
|
try {
|
||||||
await commands.playerDisableJellyfin();
|
await commands.playerDisableJellyfin();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Auth] Failed to disable player reporting:", error);
|
log.error("Failed to disable player reporting:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear repository
|
// Clear repository
|
||||||
@@ -481,7 +484,7 @@ function createAuthStore() {
|
|||||||
// Clear device ID cache on logout
|
// Clear device ID cache on logout
|
||||||
clearDeviceIdCache();
|
clearDeviceIdCache();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Auth] Logout error (continuing anyway):", error);
|
log.error("Logout error (continuing anyway):", error);
|
||||||
set(initialState);
|
set(initialState);
|
||||||
clearDeviceIdCache();
|
clearDeviceIdCache();
|
||||||
}
|
}
|
||||||
@@ -501,7 +504,7 @@ function createAuthStore() {
|
|||||||
try {
|
try {
|
||||||
return await commands.authGetSession();
|
return await commands.authGetSession();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Auth] Failed to get current session:", error);
|
log.error("Failed to get current session:", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -536,10 +539,10 @@ function createAuthStore() {
|
|||||||
async function retryVerification() {
|
async function retryVerification() {
|
||||||
try {
|
try {
|
||||||
const deviceId = await getDeviceId();
|
const deviceId = await getDeviceId();
|
||||||
console.log("[Auth] Retrying session verification after reconnection");
|
log.debug("Retrying session verification after reconnection");
|
||||||
await commands.authStartVerification(deviceId);
|
await commands.authStartVerification(deviceId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Auth] Failed to retry verification:", error);
|
log.error("Failed to retry verification:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import { writable, derived } from "svelte/store";
|
|||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("ConnectivityStore");
|
||||||
|
|
||||||
export interface ConnectivityState {
|
export interface ConnectivityState {
|
||||||
/** Browser's navigator.onLine status */
|
/** Browser's navigator.onLine status */
|
||||||
@@ -76,7 +79,7 @@ function createConnectivityStore() {
|
|||||||
update((s) => ({ ...s, isOnline: true }));
|
update((s) => ({ ...s, isOnline: true }));
|
||||||
// Device regained network — ask the backend to re-verify the server now.
|
// Device regained network — ask the backend to re-verify the server now.
|
||||||
checkServerReachable().catch((err) => {
|
checkServerReachable().catch((err) => {
|
||||||
console.debug("[ConnectivityStore] Recheck after 'online' failed:", err);
|
log.debug("Recheck after 'online' failed:", err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,7 +88,7 @@ function createConnectivityStore() {
|
|||||||
// decide whether the server is actually reachable.
|
// decide whether the server is actually reachable.
|
||||||
update((s) => ({ ...s, isOnline: false }));
|
update((s) => ({ ...s, isOnline: false }));
|
||||||
checkServerReachable().catch((err) => {
|
checkServerReachable().catch((err) => {
|
||||||
console.debug("[ConnectivityStore] Recheck after 'offline' failed:", err);
|
log.debug("Recheck after 'offline' failed:", err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -132,7 +135,7 @@ function createConnectivityStore() {
|
|||||||
|
|
||||||
return isReachable;
|
return isReachable;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ConnectivityStore] Failed to check server:", error);
|
log.error("Failed to check server:", error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,7 +148,7 @@ function createConnectivityStore() {
|
|||||||
isMonitoring = true;
|
isMonitoring = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("[ConnectivityStore] Starting monitoring for:", url);
|
log.debug("Starting monitoring for:", url);
|
||||||
|
|
||||||
// Set the server URL
|
// Set the server URL
|
||||||
await commands.connectivitySetServerUrl(url);
|
await commands.connectivitySetServerUrl(url);
|
||||||
@@ -163,10 +166,10 @@ function createConnectivityStore() {
|
|||||||
isChecking: status.isChecking,
|
isChecking: status.isChecking,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log("[ConnectivityStore] Started monitoring. Initial status:",
|
log.debug("Started monitoring. Initial status:",
|
||||||
status.isServerReachable ? "ONLINE" : "OFFLINE");
|
status.isServerReachable ? "ONLINE" : "OFFLINE");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ConnectivityStore] Failed to start monitoring:", error);
|
log.error("Failed to start monitoring:", error);
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
isServerReachable: false,
|
isServerReachable: false,
|
||||||
@@ -185,9 +188,9 @@ function createConnectivityStore() {
|
|||||||
await commands.connectivityStopMonitoring();
|
await commands.connectivityStopMonitoring();
|
||||||
isMonitoring = false;
|
isMonitoring = false;
|
||||||
eventHandlers = {};
|
eventHandlers = {};
|
||||||
console.log("[ConnectivityStore] Stopped monitoring");
|
log.debug("Stopped monitoring");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ConnectivityStore] Failed to stop monitoring:", error);
|
log.error("Failed to stop monitoring:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +201,7 @@ function createConnectivityStore() {
|
|||||||
try {
|
try {
|
||||||
await commands.connectivitySetServerUrl(url);
|
await commands.connectivitySetServerUrl(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ConnectivityStore] Failed to set server URL:", error);
|
log.error("Failed to set server URL:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+45
-42
@@ -3,6 +3,9 @@
|
|||||||
import { writable, derived, get } from 'svelte/store';
|
import { writable, derived, get } from 'svelte/store';
|
||||||
import { commands } from '$lib/api/bindings';
|
import { commands } from '$lib/api/bindings';
|
||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('Downloads');
|
||||||
|
|
||||||
// Event listener state
|
// Event listener state
|
||||||
let unlistenFn: UnlistenFn | null = null;
|
let unlistenFn: UnlistenFn | null = null;
|
||||||
@@ -103,7 +106,7 @@ function createDownloadsStore() {
|
|||||||
async function refreshDownloads(userId: string, statusFilter?: string[]): Promise<void> {
|
async function refreshDownloads(userId: string, statusFilter?: string[]): Promise<void> {
|
||||||
// If a refresh is already in progress, queue this request instead
|
// If a refresh is already in progress, queue this request instead
|
||||||
if (refreshInProgress) {
|
if (refreshInProgress) {
|
||||||
console.debug('🔄 Refresh already in progress, queuing request for user:', userId);
|
log.debug('🔄 Refresh already in progress, queuing request for user:', userId);
|
||||||
pendingRefreshRequest = { userId, statusFilter };
|
pendingRefreshRequest = { userId, statusFilter };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -111,13 +114,13 @@ function createDownloadsStore() {
|
|||||||
refreshInProgress = true;
|
refreshInProgress = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('🔄 Refreshing downloads for user:', userId);
|
log.debug('🔄 Refreshing downloads for user:', userId);
|
||||||
const response = (await commands.getDownloads(
|
const response = (await commands.getDownloads(
|
||||||
userId,
|
userId,
|
||||||
statusFilter ?? null
|
statusFilter ?? null
|
||||||
)) as unknown as { downloads: DownloadInfo[]; stats: DownloadStats };
|
)) as unknown as { downloads: DownloadInfo[]; stats: DownloadStats };
|
||||||
console.log(' Got', response.downloads.length, 'downloads from backend');
|
log.debug(' Got', response.downloads.length, 'downloads from backend');
|
||||||
console.log(' Stats:', response.stats);
|
log.debug(' Stats:', response.stats);
|
||||||
|
|
||||||
update((state) => {
|
update((state) => {
|
||||||
const downloadsMap: Record<number, DownloadInfo> = {};
|
const downloadsMap: Record<number, DownloadInfo> = {};
|
||||||
@@ -133,7 +136,7 @@ function createDownloadsStore() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh downloads:', error);
|
log.error('Failed to refresh downloads:', error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
refreshInProgress = false;
|
refreshInProgress = false;
|
||||||
@@ -164,7 +167,7 @@ function createDownloadsStore() {
|
|||||||
albumName?: string
|
albumName?: string
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
try {
|
try {
|
||||||
console.log('📥 downloadItem called:', { itemId, userId, filePath, itemName, artistName, albumName });
|
log.debug('📥 downloadItem called:', { itemId, userId, filePath, itemName, artistName, albumName });
|
||||||
const downloadId = await commands.downloadItem({
|
const downloadId = await commands.downloadItem({
|
||||||
itemId,
|
itemId,
|
||||||
userId,
|
userId,
|
||||||
@@ -176,16 +179,16 @@ function createDownloadsStore() {
|
|||||||
albumName: albumName ?? null,
|
albumName: albumName ?? null,
|
||||||
expectedSize: null
|
expectedSize: null
|
||||||
});
|
});
|
||||||
console.log(' Got download ID from backend:', downloadId);
|
log.debug(' Got download ID from backend:', downloadId);
|
||||||
|
|
||||||
// Fetch download info and add to store
|
// Fetch download info and add to store
|
||||||
console.log(' Refreshing downloads...');
|
log.debug(' Refreshing downloads...');
|
||||||
await refreshDownloads(userId);
|
await refreshDownloads(userId);
|
||||||
console.log(' Refresh complete. Store state:', get({ subscribe }));
|
log.debug(' Refresh complete. Store state:', get({ subscribe }));
|
||||||
|
|
||||||
return downloadId;
|
return downloadId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to queue download:', error);
|
log.error('Failed to queue download:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -206,16 +209,16 @@ function createDownloadsStore() {
|
|||||||
basePath: string
|
basePath: string
|
||||||
): Promise<number[]> {
|
): Promise<number[]> {
|
||||||
try {
|
try {
|
||||||
console.log('📥 downloadAlbum called:', { albumId, userId, basePath });
|
log.debug('📥 downloadAlbum called:', { albumId, userId, basePath });
|
||||||
const downloadIds = await commands.downloadAlbum(handle, albumId, userId, basePath);
|
const downloadIds = await commands.downloadAlbum(handle, albumId, userId, basePath);
|
||||||
console.log(' Got download IDs from backend:', downloadIds);
|
log.debug(' Got download IDs from backend:', downloadIds);
|
||||||
|
|
||||||
// Refresh downloads
|
// Refresh downloads
|
||||||
await refreshDownloads(userId);
|
await refreshDownloads(userId);
|
||||||
|
|
||||||
return downloadIds;
|
return downloadIds;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to queue album download:', error);
|
log.error('Failed to queue album download:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -237,7 +240,7 @@ function createDownloadsStore() {
|
|||||||
seasonNumber?: number
|
seasonNumber?: number
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
try {
|
try {
|
||||||
console.log('🎬 downloadVideo called:', {
|
log.debug('🎬 downloadVideo called:', {
|
||||||
itemId,
|
itemId,
|
||||||
userId,
|
userId,
|
||||||
filePath,
|
filePath,
|
||||||
@@ -258,14 +261,14 @@ function createDownloadsStore() {
|
|||||||
episodeNumber: episodeNumber ?? null,
|
episodeNumber: episodeNumber ?? null,
|
||||||
seasonNumber: seasonNumber ?? null
|
seasonNumber: seasonNumber ?? null
|
||||||
});
|
});
|
||||||
console.log(' Got download ID from backend:', downloadId);
|
log.debug(' Got download ID from backend:', downloadId);
|
||||||
|
|
||||||
// Refresh downloads
|
// Refresh downloads
|
||||||
await refreshDownloads(userId);
|
await refreshDownloads(userId);
|
||||||
|
|
||||||
return downloadId;
|
return downloadId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to queue video download:', error);
|
log.error('Failed to queue video download:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -281,7 +284,7 @@ function createDownloadsStore() {
|
|||||||
qualityPreset?: string
|
qualityPreset?: string
|
||||||
): Promise<number[]> {
|
): Promise<number[]> {
|
||||||
try {
|
try {
|
||||||
console.log('📺 downloadSeries called:', {
|
log.debug('📺 downloadSeries called:', {
|
||||||
seriesId,
|
seriesId,
|
||||||
seriesName,
|
seriesName,
|
||||||
userId,
|
userId,
|
||||||
@@ -295,14 +298,14 @@ function createDownloadsStore() {
|
|||||||
basePath,
|
basePath,
|
||||||
qualityPreset ?? null
|
qualityPreset ?? null
|
||||||
);
|
);
|
||||||
console.log(' Queued', downloadIds.length, 'episodes for download');
|
log.debug(' Queued', downloadIds.length, 'episodes for download');
|
||||||
|
|
||||||
// Refresh downloads
|
// Refresh downloads
|
||||||
await refreshDownloads(userId);
|
await refreshDownloads(userId);
|
||||||
|
|
||||||
return downloadIds;
|
return downloadIds;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to queue series download:', error);
|
log.error('Failed to queue series download:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -320,7 +323,7 @@ function createDownloadsStore() {
|
|||||||
qualityPreset?: string
|
qualityPreset?: string
|
||||||
): Promise<number[]> {
|
): Promise<number[]> {
|
||||||
try {
|
try {
|
||||||
console.log('📺 downloadSeason called:', {
|
log.debug('📺 downloadSeason called:', {
|
||||||
seasonId,
|
seasonId,
|
||||||
seriesName,
|
seriesName,
|
||||||
seasonName,
|
seasonName,
|
||||||
@@ -336,14 +339,14 @@ function createDownloadsStore() {
|
|||||||
basePath,
|
basePath,
|
||||||
qualityPreset ?? null
|
qualityPreset ?? null
|
||||||
);
|
);
|
||||||
console.log(' Queued', downloadIds.length, 'episodes for download');
|
log.debug(' Queued', downloadIds.length, 'episodes for download');
|
||||||
|
|
||||||
// Refresh downloads
|
// Refresh downloads
|
||||||
await refreshDownloads(userId);
|
await refreshDownloads(userId);
|
||||||
|
|
||||||
return downloadIds;
|
return downloadIds;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to queue season download:', error);
|
log.error('Failed to queue season download:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -355,7 +358,7 @@ function createDownloadsStore() {
|
|||||||
try {
|
try {
|
||||||
await commands.pinItem(itemId);
|
await commands.pinItem(itemId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to pin item:', error);
|
log.error('Failed to pin item:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -367,7 +370,7 @@ function createDownloadsStore() {
|
|||||||
try {
|
try {
|
||||||
await commands.unpinItem(itemId);
|
await commands.unpinItem(itemId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to unpin item:', error);
|
log.error('Failed to unpin item:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -379,7 +382,7 @@ function createDownloadsStore() {
|
|||||||
try {
|
try {
|
||||||
return await commands.isItemPinned(itemId);
|
return await commands.isItemPinned(itemId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to check pin status:', error);
|
log.error('Failed to check pin status:', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -391,7 +394,7 @@ function createDownloadsStore() {
|
|||||||
try {
|
try {
|
||||||
await commands.pauseDownload(downloadId);
|
await commands.pauseDownload(downloadId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to pause download:', error);
|
log.error('Failed to pause download:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -403,7 +406,7 @@ function createDownloadsStore() {
|
|||||||
try {
|
try {
|
||||||
await commands.resumeDownload(downloadId);
|
await commands.resumeDownload(downloadId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to resume download:', error);
|
log.error('Failed to resume download:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -415,7 +418,7 @@ function createDownloadsStore() {
|
|||||||
try {
|
try {
|
||||||
await commands.cancelDownload(downloadId);
|
await commands.cancelDownload(downloadId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to cancel download:', error);
|
log.error('Failed to cancel download:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -431,7 +434,7 @@ function createDownloadsStore() {
|
|||||||
return { ...state, downloads: remaining };
|
return { ...state, downloads: remaining };
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete download:', error);
|
log.error('Failed to delete download:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -448,14 +451,14 @@ function createDownloadsStore() {
|
|||||||
update((state) => {
|
update((state) => {
|
||||||
const download = state.downloads[downloadId];
|
const download = state.downloads[downloadId];
|
||||||
if (!download) {
|
if (!download) {
|
||||||
console.log(' Download not in store:', downloadId);
|
log.debug(' Download not in store:', downloadId);
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedDownload = { ...download, ...updates };
|
const updatedDownload = { ...download, ...updates };
|
||||||
const newDownloads = { ...state.downloads, [downloadId]: updatedDownload };
|
const newDownloads = { ...state.downloads, [downloadId]: updatedDownload };
|
||||||
|
|
||||||
console.log(' Store updated for download', downloadId, ':', updates);
|
log.debug(' Store updated for download', downloadId, ':', updates);
|
||||||
// No count calculation - stats remain as-is until next refresh
|
// No count calculation - stats remain as-is until next refresh
|
||||||
return {
|
return {
|
||||||
downloads: newDownloads,
|
downloads: newDownloads,
|
||||||
@@ -515,30 +518,30 @@ export const audioDownloads = derived(downloads, ($d) =>
|
|||||||
*/
|
*/
|
||||||
export async function initDownloadEvents(): Promise<void> {
|
export async function initDownloadEvents(): Promise<void> {
|
||||||
if (isEventsInitialized) {
|
if (isEventsInitialized) {
|
||||||
console.warn('Download events already initialized');
|
log.warn('Download events already initialized');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('🎧 Setting up download event listener...');
|
log.debug('🎧 Setting up download event listener...');
|
||||||
unlistenFn = await listen<DownloadEvent>('download-event', (event) => {
|
unlistenFn = await listen<DownloadEvent>('download-event', (event) => {
|
||||||
const payload = event.payload;
|
const payload = event.payload;
|
||||||
console.log('📬 Received download event:', payload.type, 'for download:', payload.downloadId);
|
log.debug('📬 Received download event:', payload.type, 'for download:', payload.downloadId);
|
||||||
console.log(' Full event payload:', JSON.stringify(payload));
|
log.debug(' Full event payload:', JSON.stringify(payload));
|
||||||
|
|
||||||
// Update the store based on event type
|
// Update the store based on event type
|
||||||
downloads.subscribe((state) => {
|
downloads.subscribe((state) => {
|
||||||
const download = state.downloads[payload.downloadId];
|
const download = state.downloads[payload.downloadId];
|
||||||
console.log(' Current download state:', download ? download.status : 'NOT IN STORE');
|
log.debug(' Current download state:', download ? download.status : 'NOT IN STORE');
|
||||||
})(); // Immediately unsubscribe after reading
|
})(); // Immediately unsubscribe after reading
|
||||||
|
|
||||||
handleDownloadEvent(payload);
|
handleDownloadEvent(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
isEventsInitialized = true;
|
isEventsInitialized = true;
|
||||||
console.log('✅ Download event listener registered successfully');
|
log.debug('✅ Download event listener registered successfully');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('❌ Failed to register download event listener:', err);
|
log.error('❌ Failed to register download event listener:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,7 +602,7 @@ function handleDownloadEvent(payload: DownloadEvent): void {
|
|||||||
payload.downloadId,
|
payload.downloadId,
|
||||||
payload.totalBytes || download.fileSize || download.bytesDownloaded,
|
payload.totalBytes || download.fileSize || download.bytesDownloaded,
|
||||||
payload.filePath || download.filePath
|
payload.filePath || download.filePath
|
||||||
).catch((err) => console.error('Failed to persist download completion:', err));
|
).catch((err) => log.error('Failed to persist download completion:', err));
|
||||||
|
|
||||||
updateDownloadInStore(payload.downloadId, {
|
updateDownloadInStore(payload.downloadId, {
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
@@ -616,7 +619,7 @@ function handleDownloadEvent(payload: DownloadEvent): void {
|
|||||||
commands.markDownloadFailed(
|
commands.markDownloadFailed(
|
||||||
payload.downloadId,
|
payload.downloadId,
|
||||||
payload.error || 'Unknown error'
|
payload.error || 'Unknown error'
|
||||||
).catch((err) => console.error('Failed to persist download failure:', err));
|
).catch((err) => log.error('Failed to persist download failure:', err));
|
||||||
|
|
||||||
updateDownloadInStore(payload.downloadId, {
|
updateDownloadInStore(payload.downloadId, {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
@@ -654,7 +657,7 @@ function handleDownloadEvent(payload: DownloadEvent): void {
|
|||||||
* Helper to update a download in the store.
|
* Helper to update a download in the store.
|
||||||
*/
|
*/
|
||||||
function updateDownloadInStore(downloadId: number, updates: Partial<DownloadInfo>): void {
|
function updateDownloadInStore(downloadId: number, updates: Partial<DownloadInfo>): void {
|
||||||
console.log(' updateDownloadInStore:', downloadId, updates);
|
log.debug(' updateDownloadInStore:', downloadId, updates);
|
||||||
downloads.updateDownload(downloadId, updates);
|
downloads.updateDownload(downloadId, updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,6 +665,6 @@ function updateDownloadInStore(downloadId: number, updates: Partial<DownloadInfo
|
|||||||
* Helper to remove a download from the store.
|
* Helper to remove a download from the store.
|
||||||
*/
|
*/
|
||||||
function removeDownloadFromStore(downloadId: number): void {
|
function removeDownloadFromStore(downloadId: number): void {
|
||||||
console.log(' removeDownloadFromStore:', downloadId);
|
log.debug(' removeDownloadFromStore:', downloadId);
|
||||||
downloads.removeDownload(downloadId);
|
downloads.removeDownload(downloadId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
filterSupersededResumeItems,
|
filterSupersededResumeItems,
|
||||||
filterInProgressNextUpItems,
|
filterInProgressNextUpItems,
|
||||||
} from "./continueWatchingFilter";
|
} from "./continueWatchingFilter";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("HomeStore");
|
||||||
|
|
||||||
interface HomeState {
|
interface HomeState {
|
||||||
heroItems: MediaItem[];
|
heroItems: MediaItem[];
|
||||||
@@ -103,7 +106,7 @@ function createHomeStore() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to load home sections";
|
const message = error instanceof Error ? error.message : "Failed to load home sections";
|
||||||
update(s => ({ ...s, isLoading: false, error: message }));
|
update(s => ({ ...s, isLoading: false, error: message }));
|
||||||
console.error("Failed to load home sections:", error);
|
log.error("Failed to load home sections:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-10
@@ -7,6 +7,9 @@ import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
|
|||||||
import type { SearchOptions } from "$lib/api/bindings";
|
import type { SearchOptions } from "$lib/api/bindings";
|
||||||
import type { SearchScope } from "$lib/utils/searchScope";
|
import type { SearchScope } from "$lib/utils/searchScope";
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("LibraryStore");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload of the backend `search-event` (mirrors Rust `SearchUpdateEvent`).
|
* Payload of the backend `search-event` (mirrors Rust `SearchUpdateEvent`).
|
||||||
@@ -84,16 +87,16 @@ function createLibraryStore() {
|
|||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
|
|
||||||
console.log("📚 [LibraryStore] Loading libraries...");
|
log.debug("📚 Loading libraries...");
|
||||||
|
|
||||||
const libraries = await repo.getLibraries();
|
const libraries = await repo.getLibraries();
|
||||||
|
|
||||||
const loadTime = Math.round(performance.now() - startTime);
|
const loadTime = Math.round(performance.now() - startTime);
|
||||||
|
|
||||||
if (loadTime < 100) {
|
if (loadTime < 100) {
|
||||||
console.log(`🚀 [LibraryStore] CACHE HIT! Loaded ${libraries.length} libraries in ${loadTime}ms (instant)`);
|
log.debug(`🚀 CACHE HIT! Loaded ${libraries.length} libraries in ${loadTime}ms (instant)`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`⏳ [LibraryStore] Loaded ${libraries.length} libraries in ${loadTime}ms (from server)`);
|
log.debug(`⏳ Loaded ${libraries.length} libraries in ${loadTime}ms (from server)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
@@ -120,7 +123,7 @@ function createLibraryStore() {
|
|||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
|
|
||||||
console.log(`📚 [LibraryStore] Loading items for parent: ${parentId.substring(0, 8)}...`);
|
log.debug(`📚 Loading items for parent: ${parentId.substring(0, 8)}...`);
|
||||||
|
|
||||||
const result = await repo.getItems(parentId, {
|
const result = await repo.getItems(parentId, {
|
||||||
startIndex: options.startIndex ?? 0,
|
startIndex: options.startIndex ?? 0,
|
||||||
@@ -134,9 +137,9 @@ function createLibraryStore() {
|
|||||||
const loadTime = Math.round(performance.now() - startTime);
|
const loadTime = Math.round(performance.now() - startTime);
|
||||||
|
|
||||||
if (loadTime < 100) {
|
if (loadTime < 100) {
|
||||||
console.log(`🚀 [LibraryStore] CACHE HIT! Loaded ${result.items.length} items in ${loadTime}ms (instant)`);
|
log.debug(`🚀 CACHE HIT! Loaded ${result.items.length} items in ${loadTime}ms (instant)`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`⏳ [LibraryStore] Loaded ${result.items.length} items in ${loadTime}ms (from server)`);
|
log.debug(`⏳ Loaded ${result.items.length} items in ${loadTime}ms (from server)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
@@ -202,11 +205,11 @@ function createLibraryStore() {
|
|||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
const item = await repo.getItem(itemId);
|
const item = await repo.getItem(itemId);
|
||||||
|
|
||||||
console.log(`[LibraryStore] loadItem(${itemId}): ${item.name} (${item.kind})`);
|
log.debug(`loadItem(${itemId}): ${item.name} (${item.kind})`);
|
||||||
console.log(`[LibraryStore] - Has people? ${item.people ? `YES (${item.people.length})` : 'NO'}`);
|
log.debug(`- Has people? ${item.people ? `YES (${item.people.length})` : 'NO'}`);
|
||||||
if (item.people && item.people.length > 0) {
|
if (item.people && item.people.length > 0) {
|
||||||
item.people.forEach((p, i) => {
|
item.people.forEach((p, i) => {
|
||||||
console.log(`[LibraryStore] [${i}] ${p.name} (type: "${p.type}", id: "${p.id}")`);
|
log.debug(` [${i}] ${p.name} (type: "${p.type}", id: "${p.id}")`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +322,7 @@ function createLibraryStore() {
|
|||||||
update((s) => ({ ...s, genres }));
|
update((s) => ({ ...s, genres }));
|
||||||
return genres;
|
return genres;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load genres:", error);
|
log.error("Failed to load genres:", error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import { writable, get } from "svelte/store";
|
|||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
import type { Session } from "$lib/api/types";
|
import type { Session } from "$lib/api/types";
|
||||||
import type { LmsSyncGroup } from "$lib/api/bindings";
|
import type { LmsSyncGroup } from "$lib/api/bindings";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("LmsSync");
|
||||||
|
|
||||||
const LMS_DEVICE_PREFIX = "lms-";
|
const LMS_DEVICE_PREFIX = "lms-";
|
||||||
|
|
||||||
@@ -51,7 +54,7 @@ function createLmsSyncStore() {
|
|||||||
update((s) => ({ ...s, groups, error: null }));
|
update((s) => ({ ...s, groups, error: null }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// The plugin may not be installed; treat as "no groups" rather than fatal.
|
// The plugin may not be installed; treat as "no groups" rather than fatal.
|
||||||
console.warn("[LmsSync] Failed to load sync groups:", error);
|
log.warn("Failed to load sync groups:", error);
|
||||||
update((s) => ({ ...s, groups: [] }));
|
update((s) => ({ ...s, groups: [] }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { writable, derived } from "svelte/store";
|
|||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
import { buildHeroMix } from "$lib/utils/heroMix";
|
import { buildHeroMix } from "$lib/utils/heroMix";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("MoviesStore");
|
||||||
|
|
||||||
/** A single "by genre" row: the genre name plus the movies in it. */
|
/** A single "by genre" row: the genre name plus the movies in it. */
|
||||||
export interface GenreRow {
|
export interface GenreRow {
|
||||||
@@ -91,7 +94,7 @@ function createMoviesStore() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to load movie sections";
|
const message = error instanceof Error ? error.message : "Failed to load movie sections";
|
||||||
update(s => ({ ...s, isLoading: false, error: message }));
|
update(s => ({ ...s, isLoading: false, error: message }));
|
||||||
console.error("Failed to load movie sections:", error);
|
log.error("Failed to load movie sections:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +123,7 @@ function createMoviesStore() {
|
|||||||
});
|
});
|
||||||
return { id: genre.id, name: genre.name, items: result.items };
|
return { id: genre.id, name: genre.name, items: result.items };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`Failed to load genre row "${genre.name}":`, e);
|
log.warn(`Failed to load genre row "${genre.name}":`, e);
|
||||||
return { id: genre.id, name: genre.name, items: [] };
|
return { id: genre.id, name: genre.name, items: [] };
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -133,7 +136,7 @@ function createMoviesStore() {
|
|||||||
|
|
||||||
update(s => ({ ...s, genreRows }));
|
update(s => ({ ...s, genreRows }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to load movie genre rows:", e);
|
log.warn("Failed to load movie genre rows:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { auth } from "./auth";
|
|||||||
import { excludePodcasts } from "$lib/utils/podcastFilter";
|
import { excludePodcasts } from "$lib/utils/podcastFilter";
|
||||||
import { selectDiverseGenres, sampleAcross } from "$lib/utils/genreDiversity";
|
import { selectDiverseGenres, sampleAcross } from "$lib/utils/genreDiversity";
|
||||||
import { buildHeroMix } from "$lib/utils/heroMix";
|
import { buildHeroMix } from "$lib/utils/heroMix";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("MusicStore");
|
||||||
|
|
||||||
/** A single "by genre" row: the genre name plus the albums in it. */
|
/** A single "by genre" row: the genre name plus the albums in it. */
|
||||||
export interface GenreRow {
|
export interface GenreRow {
|
||||||
@@ -124,7 +127,7 @@ function createMusicStore() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to load music sections";
|
const message = error instanceof Error ? error.message : "Failed to load music sections";
|
||||||
update(s => ({ ...s, isLoading: false, error: message }));
|
update(s => ({ ...s, isLoading: false, error: message }));
|
||||||
console.error("Failed to load music sections:", error);
|
log.error("Failed to load music sections:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +146,7 @@ function createMusicStore() {
|
|||||||
// HACK: drop the "Podcasts" folder that lives in the music library.
|
// HACK: drop the "Podcasts" folder that lives in the music library.
|
||||||
return { id: genre.id, name: genre.name, items: excludePodcasts(result.items) };
|
return { id: genre.id, name: genre.name, items: excludePodcasts(result.items) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`Failed to load genre row "${genre.name}":`, e);
|
log.warn(`Failed to load genre row "${genre.name}":`, e);
|
||||||
return { id: genre.id, name: genre.name, items: [] };
|
return { id: genre.id, name: genre.name, items: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,7 +208,7 @@ function createMusicStore() {
|
|||||||
|
|
||||||
update(s => ({ ...s, genreRows }));
|
update(s => ({ ...s, genreRows }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to load music genre rows:", e);
|
log.warn("Failed to load music genre rows:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import { commands, events } from "$lib/api/bindings";
|
|||||||
import { sessions, selectedSession } from "./sessions";
|
import { sessions, selectedSession } from "./sessions";
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
import { ticksToSeconds } from "$lib/utils/playbackUnits";
|
import { ticksToSeconds } from "$lib/utils/playbackUnits";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("PlaybackMode");
|
||||||
|
|
||||||
export type PlaybackMode = "local" | "remote" | "idle";
|
export type PlaybackMode = "local" | "remote" | "idle";
|
||||||
|
|
||||||
@@ -59,7 +62,7 @@ function createPlaybackModeStore() {
|
|||||||
// authoritative mode.
|
// authoritative mode.
|
||||||
sessions.selectSession(remoteSessionId);
|
sessions.selectSession(remoteSessionId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to get playback mode:", error);
|
log.error("Failed to get playback mode:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +84,7 @@ function createPlaybackModeStore() {
|
|||||||
sessionId: string | null | undefined,
|
sessionId: string | null | undefined,
|
||||||
currentPosition?: number,
|
currentPosition?: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log("[PlaybackMode] Transferring to remote session:", sessionId);
|
log.debug("Transferring to remote session:", sessionId);
|
||||||
update((s) => ({ ...s, isTransferring: true, transferError: null }));
|
update((s) => ({ ...s, isTransferring: true, transferError: null }));
|
||||||
|
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
@@ -104,12 +107,12 @@ function createPlaybackModeStore() {
|
|||||||
|
|
||||||
// Rust handles everything - just wait for it to complete
|
// Rust handles everything - just wait for it to complete
|
||||||
// It includes its own 5-second timeout for track loading
|
// It includes its own 5-second timeout for track loading
|
||||||
console.log("[PlaybackMode] About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId, "position:", positionOverride);
|
log.debug("About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId, "position:", positionOverride);
|
||||||
await commands.playbackModeTransferToRemote(sessionId ?? "", positionOverride);
|
await commands.playbackModeTransferToRemote(sessionId ?? "", positionOverride);
|
||||||
console.log("[PlaybackMode] Invoke completed successfully");
|
log.debug("Invoke completed successfully");
|
||||||
|
|
||||||
if (aborted) {
|
if (aborted) {
|
||||||
console.log("[PlaybackMode] Transfer was cancelled");
|
log.debug("Transfer was cancelled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,10 +125,10 @@ function createPlaybackModeStore() {
|
|||||||
isTransferring: false,
|
isTransferring: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log("[PlaybackMode] Successfully transferred to remote");
|
log.debug("Successfully transferred to remote");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (aborted) {
|
if (aborted) {
|
||||||
console.log("[PlaybackMode] Transfer was cancelled");
|
log.debug("Transfer was cancelled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +138,7 @@ function createPlaybackModeStore() {
|
|||||||
isTransferring: false,
|
isTransferring: false,
|
||||||
transferError: message,
|
transferError: message,
|
||||||
}));
|
}));
|
||||||
console.error("Transfer to remote failed:", error);
|
log.error("Transfer to remote failed:", error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
currentTransferAbort = null;
|
currentTransferAbort = null;
|
||||||
@@ -154,7 +157,7 @@ function createPlaybackModeStore() {
|
|||||||
* Will be fully migrated to Rust after Phase 3.
|
* Will be fully migrated to Rust after Phase 3.
|
||||||
*/
|
*/
|
||||||
async function transferToLocal(): Promise<void> {
|
async function transferToLocal(): Promise<void> {
|
||||||
console.log("[PlaybackMode] Transferring to local");
|
log.debug("Transferring to local");
|
||||||
update((s) => ({ ...s, isTransferring: true, transferError: null }));
|
update((s) => ({ ...s, isTransferring: true, transferError: null }));
|
||||||
|
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
@@ -195,7 +198,7 @@ function createPlaybackModeStore() {
|
|||||||
const itemId = (nowPlaying as any).id || (nowPlaying as any).Id;
|
const itemId = (nowPlaying as any).id || (nowPlaying as any).Id;
|
||||||
const itemName = (nowPlaying as any).name || (nowPlaying as any).Name;
|
const itemName = (nowPlaying as any).name || (nowPlaying as any).Name;
|
||||||
|
|
||||||
console.log("[PlaybackMode] Current remote item:", itemName, "position:", positionSeconds, "id:", itemId);
|
log.debug("Current remote item:", itemName, "position:", positionSeconds, "id:", itemId);
|
||||||
|
|
||||||
if (!itemId) {
|
if (!itemId) {
|
||||||
throw new Error("Cannot transfer: remote item has no ID");
|
throw new Error("Cannot transfer: remote item has no ID");
|
||||||
@@ -246,10 +249,10 @@ function createPlaybackModeStore() {
|
|||||||
isTransferring: false,
|
isTransferring: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log("[PlaybackMode] Successfully transferred to local");
|
log.debug("Successfully transferred to local");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (aborted) {
|
if (aborted) {
|
||||||
console.log("[PlaybackMode] Transfer was cancelled");
|
log.debug("Transfer was cancelled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +262,7 @@ function createPlaybackModeStore() {
|
|||||||
isTransferring: false,
|
isTransferring: false,
|
||||||
transferError: message,
|
transferError: message,
|
||||||
}));
|
}));
|
||||||
console.error("Transfer to local failed:", error);
|
log.error("Transfer to local failed:", error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
// Always lower the Rust transferring flag so it can't stick on if any step
|
// Always lower the Rust transferring flag so it can't stick on if any step
|
||||||
@@ -268,7 +271,7 @@ function createPlaybackModeStore() {
|
|||||||
try {
|
try {
|
||||||
await commands.playbackModeSetTransferring(false);
|
await commands.playbackModeSetTransferring(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[PlaybackMode] Failed to clear transferring flag:", e);
|
log.warn("Failed to clear transferring flag:", e);
|
||||||
}
|
}
|
||||||
currentTransferAbort = null;
|
currentTransferAbort = null;
|
||||||
// Reconcile to the authoritative Rust mode in case a step above threw and
|
// Reconcile to the authoritative Rust mode in case a step above threw and
|
||||||
@@ -294,9 +297,9 @@ function createPlaybackModeStore() {
|
|||||||
if (event.payload.type === "remote_disconnect_requested") {
|
if (event.payload.type === "remote_disconnect_requested") {
|
||||||
const currentState = get({ subscribe });
|
const currentState = get({ subscribe });
|
||||||
if (currentState.mode === "remote") {
|
if (currentState.mode === "remote") {
|
||||||
console.log("[PlaybackMode] Lockscreen requested disconnect; transferring to local");
|
log.debug("Lockscreen requested disconnect; transferring to local");
|
||||||
transferToLocal().catch((e) =>
|
transferToLocal().catch((e) =>
|
||||||
console.error("[PlaybackMode] Lockscreen-triggered transfer failed:", e),
|
log.error("Lockscreen-triggered transfer failed:", e),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -331,7 +334,7 @@ function createPlaybackModeStore() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[PlaybackMode] Backend mode changed →", mode, remoteSessionId);
|
log.debug("Backend mode changed →", mode, remoteSessionId);
|
||||||
update((s) => ({ ...s, mode, remoteSessionId }));
|
update((s) => ({ ...s, mode, remoteSessionId }));
|
||||||
// Keep the selected session in step so the merged UI stores follow, but
|
// Keep the selected session in step so the merged UI stores follow, but
|
||||||
// only touch the selection when it actually differs — re-selecting the
|
// only touch the selection when it actually differs — re-selecting the
|
||||||
@@ -352,10 +355,10 @@ function createPlaybackModeStore() {
|
|||||||
if (currentState.mode === "remote" && currentState.remoteSessionId && !currentState.isTransferring) {
|
if (currentState.mode === "remote" && currentState.remoteSessionId && !currentState.isTransferring) {
|
||||||
if (!session || session.id !== currentState.remoteSessionId || !session.supportsMediaControl) {
|
if (!session || session.id !== currentState.remoteSessionId || !session.supportsMediaControl) {
|
||||||
consecutiveMisses++;
|
consecutiveMisses++;
|
||||||
console.warn(`[PlaybackMode] Remote session miss ${consecutiveMisses}/${DISCONNECT_THRESHOLD}`);
|
log.warn(`Remote session miss ${consecutiveMisses}/${DISCONNECT_THRESHOLD}`);
|
||||||
|
|
||||||
if (consecutiveMisses >= DISCONNECT_THRESHOLD) {
|
if (consecutiveMisses >= DISCONNECT_THRESHOLD) {
|
||||||
console.warn("[PlaybackMode] Remote session lost after sustained disconnection");
|
log.warn("Remote session lost after sustained disconnection");
|
||||||
consecutiveMisses = 0;
|
consecutiveMisses = 0;
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
@@ -367,7 +370,7 @@ function createPlaybackModeStore() {
|
|||||||
} else {
|
} else {
|
||||||
// Session is healthy, reset counter
|
// Session is healthy, reset counter
|
||||||
if (consecutiveMisses > 0) {
|
if (consecutiveMisses > 0) {
|
||||||
console.log("[PlaybackMode] Remote session recovered after", consecutiveMisses, "misses");
|
log.debug("Remote session recovered after", consecutiveMisses, "misses");
|
||||||
}
|
}
|
||||||
consecutiveMisses = 0;
|
consecutiveMisses = 0;
|
||||||
}
|
}
|
||||||
@@ -389,7 +392,7 @@ function createPlaybackModeStore() {
|
|||||||
*/
|
*/
|
||||||
function cancelTransfer(): void {
|
function cancelTransfer(): void {
|
||||||
if (currentTransferAbort) {
|
if (currentTransferAbort) {
|
||||||
console.log("[PlaybackMode] Cancelling transfer");
|
log.debug("Cancelling transfer");
|
||||||
currentTransferAbort();
|
currentTransferAbort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,11 +402,11 @@ function createPlaybackModeStore() {
|
|||||||
* This stops controlling the remote device and returns to idle/local state
|
* This stops controlling the remote device and returns to idle/local state
|
||||||
*/
|
*/
|
||||||
async function disconnect(): Promise<void> {
|
async function disconnect(): Promise<void> {
|
||||||
console.log("[PlaybackMode] Disconnecting from remote session");
|
log.debug("Disconnecting from remote session");
|
||||||
|
|
||||||
const currentState = get({ subscribe });
|
const currentState = get({ subscribe });
|
||||||
if (currentState.mode !== "remote") {
|
if (currentState.mode !== "remote") {
|
||||||
console.log("[PlaybackMode] Not in remote mode, nothing to disconnect");
|
log.debug("Not in remote mode, nothing to disconnect");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,10 +423,10 @@ function createPlaybackModeStore() {
|
|||||||
transferError: null,
|
transferError: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log("[PlaybackMode] Successfully disconnected");
|
log.debug("Successfully disconnected");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to disconnect";
|
const message = error instanceof Error ? error.message : "Failed to disconnect";
|
||||||
console.error("[PlaybackMode] Disconnect failed:", error);
|
log.error("Disconnect failed:", error);
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
transferError: message,
|
transferError: message,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { writable, derived, get } from "svelte/store";
|
|||||||
import { commands, events } from "$lib/api/bindings";
|
import { commands, events } from "$lib/api/bindings";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("Queue");
|
||||||
|
|
||||||
export type RepeatMode = "off" | "all" | "one";
|
export type RepeatMode = "off" | "all" | "one";
|
||||||
|
|
||||||
@@ -72,7 +75,7 @@ function createQueueStore() {
|
|||||||
async function syncFromRust(): Promise<void> {
|
async function syncFromRust(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const rustQueue = (await commands.playerGetQueue()) as unknown as QueueChangedEvent;
|
const rustQueue = (await commands.playerGetQueue()) as unknown as QueueChangedEvent;
|
||||||
console.log("[Queue] Synced from Rust - items:", rustQueue.items.length);
|
log.debug("Synced from Rust - items:", rustQueue.items.length);
|
||||||
set({
|
set({
|
||||||
items: rustQueue.items,
|
items: rustQueue.items,
|
||||||
currentIndex: rustQueue.currentIndex,
|
currentIndex: rustQueue.currentIndex,
|
||||||
@@ -82,7 +85,7 @@ function createQueueStore() {
|
|||||||
hasPrevious: rustQueue.hasPrevious,
|
hasPrevious: rustQueue.hasPrevious,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Queue] Failed to sync from Rust:", error);
|
log.error("Failed to sync from Rust:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-23
@@ -4,6 +4,9 @@
|
|||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import { commands, events } from "$lib/api/bindings";
|
import { commands, events } from "$lib/api/bindings";
|
||||||
import type { Session } from "$lib/api/types";
|
import type { Session } from "$lib/api/types";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("Sessions");
|
||||||
|
|
||||||
interface SessionsState {
|
interface SessionsState {
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
@@ -28,9 +31,9 @@ function createSessionsStore() {
|
|||||||
events.playerStatusEvent.listen((event) => {
|
events.playerStatusEvent.listen((event) => {
|
||||||
if (event.payload.type === "sessions_updated") {
|
if (event.payload.type === "sessions_updated") {
|
||||||
const sessions = event.payload.sessions as unknown as Session[];
|
const sessions = event.payload.sessions as unknown as Session[];
|
||||||
console.log(`[Sessions] Received ${sessions.length} sessions from backend`);
|
log.debug(`Received ${sessions.length} sessions from backend`);
|
||||||
sessions.forEach((s, i) => {
|
sessions.forEach((s, i) => {
|
||||||
console.log(`[Sessions] Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`);
|
log.debug(`Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`);
|
||||||
});
|
});
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
@@ -50,9 +53,9 @@ function createSessionsStore() {
|
|||||||
|
|
||||||
const sessions = await commands.sessionsPollNow();
|
const sessions = await commands.sessionsPollNow();
|
||||||
|
|
||||||
console.log(`[Sessions] Manual refresh returned ${sessions.length} sessions`);
|
log.debug(`Manual refresh returned ${sessions.length} sessions`);
|
||||||
sessions.forEach((s, i) => {
|
sessions.forEach((s, i) => {
|
||||||
console.log(`[Sessions] Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`);
|
log.debug(`Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
@@ -69,7 +72,7 @@ function createSessionsStore() {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: message,
|
error: message,
|
||||||
}));
|
}));
|
||||||
console.error("Failed to fetch sessions:", error);
|
log.error("Failed to fetch sessions:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +93,7 @@ function createSessionsStore() {
|
|||||||
// Refresh after command to get updated state
|
// Refresh after command to get updated state
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send play/pause command:", error);
|
log.error("Failed to send play/pause command:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +106,7 @@ function createSessionsStore() {
|
|||||||
await commands.remoteSendCommand(sessionId ?? "", "Stop");
|
await commands.remoteSendCommand(sessionId ?? "", "Stop");
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send stop command:", error);
|
log.error("Failed to send stop command:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,7 +119,7 @@ function createSessionsStore() {
|
|||||||
await commands.remoteSendCommand(sessionId ?? "", "NextTrack");
|
await commands.remoteSendCommand(sessionId ?? "", "NextTrack");
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send next track command:", error);
|
log.error("Failed to send next track command:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,7 +132,7 @@ function createSessionsStore() {
|
|||||||
await commands.remoteSendCommand(sessionId ?? "", "PreviousTrack");
|
await commands.remoteSendCommand(sessionId ?? "", "PreviousTrack");
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send previous track command:", error);
|
log.error("Failed to send previous track command:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,7 +145,7 @@ function createSessionsStore() {
|
|||||||
await commands.remoteSessionSeek(sessionId ?? "", positionTicks);
|
await commands.remoteSessionSeek(sessionId ?? "", positionTicks);
|
||||||
// Don't refresh immediately for seek to avoid UI lag
|
// Don't refresh immediately for seek to avoid UI lag
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send seek command:", error);
|
log.error("Failed to send seek command:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,7 +158,7 @@ function createSessionsStore() {
|
|||||||
await commands.remoteSessionSetVolume(sessionId ?? "", volume);
|
await commands.remoteSessionSetVolume(sessionId ?? "", volume);
|
||||||
// Don't refresh immediately for volume to avoid UI lag
|
// Don't refresh immediately for volume to avoid UI lag
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send volume command:", error);
|
log.error("Failed to send volume command:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,7 +171,7 @@ function createSessionsStore() {
|
|||||||
await commands.remoteSendCommand(sessionId ?? "", "ToggleMute");
|
await commands.remoteSendCommand(sessionId ?? "", "ToggleMute");
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to toggle mute:", error);
|
log.error("Failed to toggle mute:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,20 +184,20 @@ function createSessionsStore() {
|
|||||||
itemIds: string[],
|
itemIds: string[],
|
||||||
startIndex = 0
|
startIndex = 0
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log("[SESSIONS] ========== playOnSession called ==========");
|
log.debug("========== playOnSession called ==========");
|
||||||
console.log("[SESSIONS] sessionId:", sessionId);
|
log.debug("sessionId:", sessionId);
|
||||||
console.log("[SESSIONS] itemIds array:", itemIds);
|
log.debug("itemIds array:", itemIds);
|
||||||
console.log("[SESSIONS] itemIds.length:", itemIds.length);
|
log.debug("itemIds.length:", itemIds.length);
|
||||||
console.log("[SESSIONS] itemIds JSON:", JSON.stringify(itemIds));
|
log.debug("itemIds JSON:", JSON.stringify(itemIds));
|
||||||
console.log("[SESSIONS] startIndex:", startIndex);
|
log.debug("startIndex:", startIndex);
|
||||||
console.log("[SESSIONS] About to call commands.remotePlayOnSession");
|
log.debug("About to call commands.remotePlayOnSession");
|
||||||
try {
|
try {
|
||||||
// Use Rust player's Jellyfin client for remote playback
|
// Use Rust player's Jellyfin client for remote playback
|
||||||
const result = await commands.remotePlayOnSession(sessionId ?? "", itemIds, startIndex);
|
const result = await commands.remotePlayOnSession(sessionId ?? "", itemIds, startIndex);
|
||||||
console.log("[SESSIONS] invoke succeeded, result:", result);
|
log.debug("invoke succeeded, result:", result);
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[SESSIONS] Failed to play on session:", error);
|
log.error("Failed to play on session:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,10 +245,10 @@ export const controllableSessions = derived(
|
|||||||
sessions,
|
sessions,
|
||||||
($sessions) => {
|
($sessions) => {
|
||||||
const controllable = $sessions.sessions.filter((s) => s.supportsRemoteControl);
|
const controllable = $sessions.sessions.filter((s) => s.supportsRemoteControl);
|
||||||
console.log(`[Sessions] Filtering ${$sessions.sessions.length} total sessions, ${controllable.length} are controllable`);
|
log.debug(`Filtering ${$sessions.sessions.length} total sessions, ${controllable.length} are controllable`);
|
||||||
$sessions.sessions.forEach((s, i) => {
|
$sessions.sessions.forEach((s, i) => {
|
||||||
const status = s.supportsRemoteControl ? "✓ CONTROLLABLE" : "✗ NOT CONTROLLABLE";
|
const status = s.supportsRemoteControl ? "✓ CONTROLLABLE" : "✗ NOT CONTROLLABLE";
|
||||||
console.log(`[Sessions] ${status}: ${s.deviceName} (id=${s.id}, supportsRemoteControl=${s.supportsRemoteControl})`);
|
log.debug(` ${status}: ${s.deviceName} (id=${s.id}, supportsRemoteControl=${s.supportsRemoteControl})`);
|
||||||
});
|
});
|
||||||
return controllable;
|
return controllable;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
filterSupersededResumeItems,
|
filterSupersededResumeItems,
|
||||||
filterInProgressNextUpItems,
|
filterInProgressNextUpItems,
|
||||||
} from "./continueWatchingFilter";
|
} from "./continueWatchingFilter";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("TvStore");
|
||||||
|
|
||||||
/** A single "by genre" row: the genre name plus the series in it. */
|
/** A single "by genre" row: the genre name plus the series in it. */
|
||||||
export interface GenreRow {
|
export interface GenreRow {
|
||||||
@@ -114,7 +117,7 @@ function createTvStore() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to load TV sections";
|
const message = error instanceof Error ? error.message : "Failed to load TV sections";
|
||||||
update(s => ({ ...s, isLoading: false, error: message }));
|
update(s => ({ ...s, isLoading: false, error: message }));
|
||||||
console.error("Failed to load TV sections:", error);
|
log.error("Failed to load TV sections:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +146,7 @@ function createTvStore() {
|
|||||||
});
|
});
|
||||||
return { id: genre.id, name: genre.name, items: result.items };
|
return { id: genre.id, name: genre.name, items: result.items };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`Failed to load genre row "${genre.name}":`, e);
|
log.warn(`Failed to load genre row "${genre.name}":`, e);
|
||||||
return { id: genre.id, name: genre.name, items: [] };
|
return { id: genre.id, name: genre.name, items: [] };
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -156,7 +159,7 @@ function createTvStore() {
|
|||||||
|
|
||||||
update(s => ({ ...s, genreRows }));
|
update(s => ({ ...s, genreRows }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to load TV genre rows:", e);
|
log.warn("Failed to load TV genre rows:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
* Unsupported (no-op) on every non-Android platform.
|
* Unsupported (no-op) on every non-Android platform.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("BgAudio");
|
||||||
|
|
||||||
interface AndroidBackgroundAudioBridge {
|
interface AndroidBackgroundAudioBridge {
|
||||||
setEnabled(enabled: boolean): void;
|
setEnabled(enabled: boolean): void;
|
||||||
}
|
}
|
||||||
@@ -44,15 +48,15 @@ export function setBackgroundAudioEnabled(enabled: boolean): boolean {
|
|||||||
// before/without the bridge existing. Silently no-oping here leaves the UI
|
// before/without the bridge existing. Silently no-oping here leaves the UI
|
||||||
// showing "armed" while native never learns — and the handoff then never
|
// showing "armed" while native never learns — and the handoff then never
|
||||||
// fires on lock. Report it so callers can retry.
|
// fires on lock. Report it so callers can retry.
|
||||||
console.warn("[BgAudio] setEnabled: bridge missing, native NOT armed");
|
log.warn("setEnabled: bridge missing, native NOT armed");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
b.setEnabled(enabled);
|
b.setEnabled(enabled);
|
||||||
console.log("[BgAudio] setEnabled ->", enabled);
|
log.debug("setEnabled ->", enabled);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[BgAudio] Failed to set enabled:", err);
|
log.warn("Failed to set enabled:", err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
* Provides tactile feedback for user actions
|
* Provides tactile feedback for user actions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("Haptics");
|
||||||
|
|
||||||
type HapticStyle = "light" | "medium" | "heavy" | "success" | "warning" | "error";
|
type HapticStyle = "light" | "medium" | "heavy" | "success" | "warning" | "error";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,7 +32,7 @@ export function haptic(style: HapticStyle = "medium") {
|
|||||||
navigator.vibrate(patterns[style]);
|
navigator.vibrate(patterns[style]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Silently fail if vibration is not supported or blocked
|
// Silently fail if vibration is not supported or blocked
|
||||||
console.debug("Haptic feedback not available:", error);
|
log.debug("Haptic feedback not available:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
* already does the right thing and these calls are no-ops.
|
* already does the right thing and these calls are no-ops.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("Immersive");
|
||||||
|
|
||||||
interface AndroidImmersiveBridge {
|
interface AndroidImmersiveBridge {
|
||||||
enter(): void;
|
enter(): void;
|
||||||
exit(): void;
|
exit(): void;
|
||||||
@@ -38,7 +42,7 @@ export function isImmersiveSupported(): boolean {
|
|||||||
try {
|
try {
|
||||||
return bridge()?.isSupported() ?? false;
|
return bridge()?.isSupported() ?? false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[Immersive] isSupported check failed:", err);
|
log.warn("isSupported check failed:", err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,7 +52,7 @@ export function enterImmersive(): void {
|
|||||||
try {
|
try {
|
||||||
bridge()?.enter();
|
bridge()?.enter();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Immersive] Failed to hide the system bars:", err);
|
log.error("Failed to hide the system bars:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +67,6 @@ export function exitImmersive(): void {
|
|||||||
try {
|
try {
|
||||||
bridge()?.exit();
|
bridge()?.exit();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Immersive] Failed to restore the system bars:", err);
|
log.error("Failed to restore the system bars:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
LOG_LEVEL_STORAGE_KEY,
|
||||||
|
createLogger,
|
||||||
|
defaultLogLevel,
|
||||||
|
getLogLevel,
|
||||||
|
isLevelEnabled,
|
||||||
|
parseLogLevel,
|
||||||
|
readStoredLogLevel,
|
||||||
|
resetLogLevel,
|
||||||
|
setLogLevel,
|
||||||
|
type LogLevel,
|
||||||
|
} from "./logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frontend leveled logging facade.
|
||||||
|
*
|
||||||
|
* TRACES: | DR-204 | UT-201
|
||||||
|
*
|
||||||
|
* The frontend used to ship 468 ungated `console.*` calls to end users — no
|
||||||
|
* levels, no gate, no way to turn them off. These tests pin the three
|
||||||
|
* properties that make the replacement safe to rely on:
|
||||||
|
*
|
||||||
|
* 1. **Gating is by severity, and errors/warnings are never gated away.** A
|
||||||
|
* production build suppresses chatter, but a user-visible failure must still
|
||||||
|
* reach the console or a bug report has nothing in it.
|
||||||
|
* 2. **The scope is what replaces the hand-written `"[Scope] …"` prefixes**, so
|
||||||
|
* it has to land in the message rather than beside it, and it must not
|
||||||
|
* mangle the remaining arguments.
|
||||||
|
* 3. **Reading the `localStorage` override can never throw.** `localStorage` is
|
||||||
|
* absent under SSR and *throws on access* in a webview with storage
|
||||||
|
* disabled; logging must not be what takes the app down.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Spies for all four backing console methods. */
|
||||||
|
function spyConsole() {
|
||||||
|
return {
|
||||||
|
log: vi.spyOn(console, "log").mockImplementation(() => {}),
|
||||||
|
info: vi.spyOn(console, "info").mockImplementation(() => {}),
|
||||||
|
warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
|
||||||
|
error: vi.spyOn(console, "error").mockImplementation(() => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Swap `globalThis.localStorage` for the duration of a test. */
|
||||||
|
function stubStorage(value: unknown) {
|
||||||
|
const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||||
|
Object.defineProperty(globalThis, "localStorage", {
|
||||||
|
value,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
if (original) Object.defineProperty(globalThis, "localStorage", original);
|
||||||
|
else delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parseLogLevel", () => {
|
||||||
|
it("accepts every level name", () => {
|
||||||
|
for (const level of ["debug", "info", "warn", "error"] as LogLevel[]) {
|
||||||
|
expect(parseLogLevel(level)).toBe(level);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case- and whitespace-insensitive, because a human types the override", () => {
|
||||||
|
expect(parseLogLevel(" DEBUG ")).toBe("debug");
|
||||||
|
expect(parseLogLevel("Warn")).toBe("warn");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects anything that is not a level", () => {
|
||||||
|
expect(parseLogLevel("trace")).toBeNull();
|
||||||
|
expect(parseLogLevel("")).toBeNull();
|
||||||
|
expect(parseLogLevel(null)).toBeNull();
|
||||||
|
expect(parseLogLevel(undefined)).toBeNull();
|
||||||
|
expect(parseLogLevel(3)).toBeNull();
|
||||||
|
expect(parseLogLevel({ level: "debug" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("level gating", () => {
|
||||||
|
let restoreLevel: LogLevel;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
restoreLevel = getLogLevel();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setLogLevel(restoreLevel);
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits everything at debug", () => {
|
||||||
|
setLogLevel("debug");
|
||||||
|
const spies = spyConsole();
|
||||||
|
const log = createLogger("Test");
|
||||||
|
|
||||||
|
log.debug("d");
|
||||||
|
log.info("i");
|
||||||
|
log.warn("w");
|
||||||
|
log.error("e");
|
||||||
|
|
||||||
|
expect(spies.log).toHaveBeenCalledTimes(1);
|
||||||
|
expect(spies.info).toHaveBeenCalledTimes(1);
|
||||||
|
expect(spies.warn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(spies.error).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses debug and info at warn — the production default", () => {
|
||||||
|
setLogLevel("warn");
|
||||||
|
const spies = spyConsole();
|
||||||
|
const log = createLogger("Test");
|
||||||
|
|
||||||
|
log.debug("d");
|
||||||
|
log.info("i");
|
||||||
|
log.warn("w");
|
||||||
|
log.error("e");
|
||||||
|
|
||||||
|
expect(spies.log).not.toHaveBeenCalled();
|
||||||
|
expect(spies.info).not.toHaveBeenCalled();
|
||||||
|
expect(spies.warn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(spies.error).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still emits errors at the most restrictive level", () => {
|
||||||
|
// A silent failure is worse to support than a noisy console: there is no
|
||||||
|
// level at which `error` is dropped.
|
||||||
|
setLogLevel("error");
|
||||||
|
const spies = spyConsole();
|
||||||
|
const log = createLogger("Test");
|
||||||
|
|
||||||
|
log.debug("d");
|
||||||
|
log.info("i");
|
||||||
|
log.warn("w");
|
||||||
|
log.error("boom");
|
||||||
|
|
||||||
|
expect(spies.log).not.toHaveBeenCalled();
|
||||||
|
expect(spies.info).not.toHaveBeenCalled();
|
||||||
|
expect(spies.warn).not.toHaveBeenCalled();
|
||||||
|
expect(spies.error).toHaveBeenCalledWith("[Test] boom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports which levels are enabled", () => {
|
||||||
|
setLogLevel("warn");
|
||||||
|
expect(isLevelEnabled("debug")).toBe(false);
|
||||||
|
expect(isLevelEnabled("info")).toBe(false);
|
||||||
|
expect(isLevelEnabled("warn")).toBe(true);
|
||||||
|
expect(isLevelEnabled("error")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps debug to console.log, not console.debug", () => {
|
||||||
|
// console.debug lands in the browser's hidden "Verbose" bucket, which would
|
||||||
|
// make dev logging invisible in exactly the builds that want it.
|
||||||
|
setLogLevel("debug");
|
||||||
|
const spies = spyConsole();
|
||||||
|
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||||
|
|
||||||
|
createLogger("Test").debug("hello");
|
||||||
|
|
||||||
|
expect(debugSpy).not.toHaveBeenCalled();
|
||||||
|
expect(spies.log).toHaveBeenCalledWith("[Test] hello");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scope prefixing", () => {
|
||||||
|
let restoreLevel: LogLevel;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
restoreLevel = getLogLevel();
|
||||||
|
setLogLevel("debug");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setLogLevel(restoreLevel);
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds the scope into a leading string message", () => {
|
||||||
|
const spies = spyConsole();
|
||||||
|
createLogger("VideoPlayer").warn("seek failed");
|
||||||
|
|
||||||
|
expect(spies.warn).toHaveBeenCalledWith("[VideoPlayer] seek failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes trailing arguments through untouched, by reference", () => {
|
||||||
|
const spies = spyConsole();
|
||||||
|
const payload = { itemId: "abc", nested: { position: 12 } };
|
||||||
|
const err = new Error("nope");
|
||||||
|
|
||||||
|
createLogger("Queue").error("failed to advance:", payload, err, 42);
|
||||||
|
|
||||||
|
expect(spies.error).toHaveBeenCalledWith("[Queue] failed to advance:", payload, err, 42);
|
||||||
|
// Same object, not a copy — devtools inspection depends on this.
|
||||||
|
expect(spies.error.mock.calls[0][1]).toBe(payload);
|
||||||
|
expect(spies.error.mock.calls[0][2]).toBe(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prepends the scope as its own argument when the first argument is not a string", () => {
|
||||||
|
const spies = spyConsole();
|
||||||
|
const err = new Error("boom");
|
||||||
|
|
||||||
|
createLogger("Auth").error(err);
|
||||||
|
|
||||||
|
expect(spies.error).toHaveBeenCalledWith("[Auth]", err);
|
||||||
|
expect(spies.error.mock.calls[0][1]).toBe(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a call with no arguments at all", () => {
|
||||||
|
const spies = spyConsole();
|
||||||
|
createLogger("Auth").debug();
|
||||||
|
|
||||||
|
expect(spies.log).toHaveBeenCalledWith("[Auth]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps separate scopes independent", () => {
|
||||||
|
const spies = spyConsole();
|
||||||
|
createLogger("NextEpisode").info("advancing");
|
||||||
|
createLogger("PlayerPage").info("advancing");
|
||||||
|
|
||||||
|
expect(spies.info).toHaveBeenNthCalledWith(1, "[NextEpisode] advancing");
|
||||||
|
expect(spies.info).toHaveBeenNthCalledWith(2, "[PlayerPage] advancing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves console methods at call time so spies and overrides are honoured", () => {
|
||||||
|
// A cached `console.warn` reference would bypass a devtools override or a
|
||||||
|
// later-installed spy — and every existing test that asserts on log output.
|
||||||
|
const log = createLogger("Late");
|
||||||
|
const late = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
log.warn("after the fact");
|
||||||
|
|
||||||
|
expect(late).toHaveBeenCalledWith("[Late] after the fact");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("localStorage override", () => {
|
||||||
|
let restoreStorage: () => void = () => {};
|
||||||
|
let restoreLevel: LogLevel;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
restoreLevel = getLogLevel();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreStorage();
|
||||||
|
restoreStorage = () => {};
|
||||||
|
setLogLevel(restoreLevel);
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the level a user set to gather logs for a bug report", () => {
|
||||||
|
restoreStorage = stubStorage({ getItem: vi.fn(() => "debug") });
|
||||||
|
|
||||||
|
expect(readStoredLogLevel()).toBe("debug");
|
||||||
|
expect(resetLogLevel()).toBe("debug");
|
||||||
|
expect(isLevelEnabled("debug")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("looks the level up under jellytau:logLevel", () => {
|
||||||
|
const getItem = vi.fn(() => "error");
|
||||||
|
restoreStorage = stubStorage({ getItem });
|
||||||
|
|
||||||
|
readStoredLogLevel();
|
||||||
|
|
||||||
|
expect(getItem).toHaveBeenCalledWith(LOG_LEVEL_STORAGE_KEY);
|
||||||
|
expect(LOG_LEVEL_STORAGE_KEY).toBe("jellytau:logLevel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the build default for a missing or bogus value", () => {
|
||||||
|
restoreStorage = stubStorage({ getItem: vi.fn(() => null) });
|
||||||
|
expect(readStoredLogLevel()).toBeNull();
|
||||||
|
expect(resetLogLevel()).toBe(defaultLogLevel());
|
||||||
|
|
||||||
|
restoreStorage();
|
||||||
|
restoreStorage = stubStorage({ getItem: vi.fn(() => "extremely-verbose") });
|
||||||
|
expect(readStoredLogLevel()).toBeNull();
|
||||||
|
expect(resetLogLevel()).toBe(defaultLogLevel());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives localStorage being absent (SSR)", () => {
|
||||||
|
restoreStorage = stubStorage(undefined);
|
||||||
|
|
||||||
|
expect(() => readStoredLogLevel()).not.toThrow();
|
||||||
|
expect(readStoredLogLevel()).toBeNull();
|
||||||
|
expect(resetLogLevel()).toBe(defaultLogLevel());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives localStorage throwing on access (storage disabled)", () => {
|
||||||
|
restoreStorage = stubStorage({
|
||||||
|
getItem: () => {
|
||||||
|
throw new DOMException("The operation is insecure.", "SecurityError");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => readStoredLogLevel()).not.toThrow();
|
||||||
|
expect(readStoredLogLevel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives localStorage being a stale object with no getItem", () => {
|
||||||
|
restoreStorage = stubStorage({});
|
||||||
|
|
||||||
|
expect(() => readStoredLogLevel()).not.toThrow();
|
||||||
|
expect(readStoredLogLevel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a broken localStorage break logging itself", () => {
|
||||||
|
restoreStorage = stubStorage({
|
||||||
|
getItem: () => {
|
||||||
|
throw new Error("nope");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
resetLogLevel();
|
||||||
|
const spies = spyConsole();
|
||||||
|
|
||||||
|
expect(() => createLogger("Boot").error("still reaches the console")).not.toThrow();
|
||||||
|
expect(spies.error).toHaveBeenCalledWith("[Boot] still reaches the console");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Frontend leveled logging facade.
|
||||||
|
*
|
||||||
|
* TRACES: | DR-204
|
||||||
|
*
|
||||||
|
* ## Why this exists
|
||||||
|
*
|
||||||
|
* The Rust half of the app is disciplined about logging: the `log` crate behind
|
||||||
|
* `env_logger`, `LevelFilter::Info` by default, `RUST_LOG` to turn the volume up
|
||||||
|
* without a rebuild (see `src-tauri/src/lib.rs`). The frontend had nothing —
|
||||||
|
* every `console.log` written during development shipped to end users and ran on
|
||||||
|
* every device, forever.
|
||||||
|
*
|
||||||
|
* This module is the frontend's `log` crate: four levels, a compile-environment
|
||||||
|
* default, and a runtime override that is the moral equivalent of `RUST_LOG`.
|
||||||
|
*
|
||||||
|
* ## Levels
|
||||||
|
*
|
||||||
|
* `debug < info < warn < error`. A message is emitted when its level is at or
|
||||||
|
* above the active level.
|
||||||
|
*
|
||||||
|
* - **debug** — the default for anything chatty: per-tick state, cache hits,
|
||||||
|
* "entered this branch". Dev only.
|
||||||
|
* - **info** — lifecycle/state events worth having in a user's console when
|
||||||
|
* they are diagnosing something: sign-in, playback start, mode transfer.
|
||||||
|
* - **warn** — recovered-from problems. Always emitted.
|
||||||
|
* - **error** — failures the user may notice. Always emitted.
|
||||||
|
*
|
||||||
|
* ## Defaults
|
||||||
|
*
|
||||||
|
* Dev builds (`import.meta.env.DEV`) default to `debug`; production builds
|
||||||
|
* default to `warn`. Production deliberately keeps **warn and error** — this is
|
||||||
|
* a user-facing media client talking to a server that may or may not be there,
|
||||||
|
* and a silent failure is far worse to support than a noisy console. Only the
|
||||||
|
* chatter (`debug`/`info`) is suppressed.
|
||||||
|
*
|
||||||
|
* ## Runtime override (the `RUST_LOG` equivalent)
|
||||||
|
*
|
||||||
|
* A user filing a bug can turn verbose logging on in a shipped build without a
|
||||||
|
* rebuild, from the webview console:
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* localStorage.setItem("jellytau:logLevel", "debug"); // then reload
|
||||||
|
* localStorage.removeItem("jellytau:logLevel"); // back to the default
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* The key is read **once at module init** (so the level cannot change halfway
|
||||||
|
* through a session and confuse a bug report) and every read is guarded — SSR
|
||||||
|
* has no `localStorage`, and a webview with storage disabled *throws* on access
|
||||||
|
* rather than returning `null`. Either way we fall back to the build default.
|
||||||
|
*
|
||||||
|
* ## Scopes
|
||||||
|
*
|
||||||
|
* `createLogger("VideoPlayer")` replaces the hand-rolled `"[VideoPlayer] …"`
|
||||||
|
* prefixes that used to be typed into every call site.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const log = createLogger("VideoPlayer");
|
||||||
|
* log.debug("seeking to", position, { mode });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* ## Pass-through, not a wrapper
|
||||||
|
*
|
||||||
|
* When a level is enabled the call goes straight to `console.*` with the
|
||||||
|
* arguments **untouched** — no stringification, no JSON, no cloning — so object
|
||||||
|
* references stay live and expandable in devtools. The scope is folded into the
|
||||||
|
* leading string argument when there is one (keeping `console` grouping and
|
||||||
|
* substitution behaviour intact), and passed as its own leading argument
|
||||||
|
* otherwise. `console` is looked up at call time so `vi.spyOn(console, …)` and
|
||||||
|
* devtools console overrides still see everything.
|
||||||
|
*
|
||||||
|
* `debug` maps to `console.log` rather than `console.debug` on purpose:
|
||||||
|
* `console.debug` lands in the browser's "Verbose" bucket, which is hidden by
|
||||||
|
* default in both Chrome DevTools and the WebKit inspector, so mapping there
|
||||||
|
* would make dev logging invisible in exactly the builds that want it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Severity ordering. Higher wins. */
|
||||||
|
const LEVEL_RANK = {
|
||||||
|
debug: 10,
|
||||||
|
info: 20,
|
||||||
|
warn: 30,
|
||||||
|
error: 40,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** A log level, in the same vocabulary as the Rust `log` crate. */
|
||||||
|
export type LogLevel = keyof typeof LEVEL_RANK;
|
||||||
|
|
||||||
|
/** The `localStorage` key that overrides the build-default level. */
|
||||||
|
export const LOG_LEVEL_STORAGE_KEY = "jellytau:logLevel";
|
||||||
|
|
||||||
|
/** Which `console` method backs each level. See the module header for `debug`. */
|
||||||
|
const CONSOLE_METHOD: Record<LogLevel, "log" | "info" | "warn" | "error"> = {
|
||||||
|
debug: "log",
|
||||||
|
info: "info",
|
||||||
|
warn: "warn",
|
||||||
|
error: "error",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A scoped logger. One method per level, all variadic like `console.*`. */
|
||||||
|
export interface Logger {
|
||||||
|
debug(...args: unknown[]): void;
|
||||||
|
info(...args: unknown[]): void;
|
||||||
|
warn(...args: unknown[]): void;
|
||||||
|
error(...args: unknown[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce arbitrary input to a `LogLevel`, or `null` when it is not one.
|
||||||
|
* Case- and whitespace-insensitive, because this parses human-typed input.
|
||||||
|
*/
|
||||||
|
export function parseLogLevel(raw: unknown): LogLevel | null {
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const normalised = raw.trim().toLowerCase();
|
||||||
|
return normalised in LEVEL_RANK ? (normalised as LogLevel) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The level a build defaults to with no override present. */
|
||||||
|
export function defaultLogLevel(): LogLevel {
|
||||||
|
return import.meta.env?.DEV ? "debug" : "warn";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the override from `localStorage`, or `null` when there is none.
|
||||||
|
*
|
||||||
|
* Never throws. `localStorage` is absent under SSR and *throws on access* in a
|
||||||
|
* webview with storage disabled or a blocked third-party context — logging must
|
||||||
|
* not be the thing that takes the app down.
|
||||||
|
*/
|
||||||
|
export function readStoredLogLevel(): LogLevel | null {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage === "undefined" || localStorage === null) return null;
|
||||||
|
return parseLogLevel(localStorage.getItem(LOG_LEVEL_STORAGE_KEY));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeLevel: LogLevel = readStoredLogLevel() ?? defaultLogLevel();
|
||||||
|
|
||||||
|
/** The level currently in force. */
|
||||||
|
export function getLogLevel(): LogLevel {
|
||||||
|
return activeLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the active level for the rest of the session.
|
||||||
|
*
|
||||||
|
* Does **not** persist — write {@link LOG_LEVEL_STORAGE_KEY} for that. Mainly
|
||||||
|
* here for tests and for a future settings toggle.
|
||||||
|
*/
|
||||||
|
export function setLogLevel(level: LogLevel): void {
|
||||||
|
activeLevel = level;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read the override and reapply the build default. Called once implicitly at
|
||||||
|
* module init; exposed so tests can exercise the override without a fresh
|
||||||
|
* module registry.
|
||||||
|
*/
|
||||||
|
export function resetLogLevel(): LogLevel {
|
||||||
|
activeLevel = readStoredLogLevel() ?? defaultLogLevel();
|
||||||
|
return activeLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Would a message at `level` be emitted right now? */
|
||||||
|
export function isLevelEnabled(level: LogLevel): boolean {
|
||||||
|
return LEVEL_RANK[level] >= LEVEL_RANK[activeLevel];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a logger tagged with `scope`.
|
||||||
|
*
|
||||||
|
* The scope replaces the `"[Scope] …"` prefixes that used to be hand-written
|
||||||
|
* into each call, so call sites pass the message alone.
|
||||||
|
*/
|
||||||
|
export function createLogger(scope: string): Logger {
|
||||||
|
const tag = `[${scope}]`;
|
||||||
|
|
||||||
|
const emit = (level: LogLevel, args: unknown[]): void => {
|
||||||
|
if (!isLevelEnabled(level)) return;
|
||||||
|
|
||||||
|
// Look `console` up at call time: test spies and devtools overrides replace
|
||||||
|
// the method on the object, and a cached reference would bypass them.
|
||||||
|
const method = CONSOLE_METHOD[level];
|
||||||
|
|
||||||
|
// Fold the tag into a leading string so format specifiers (`%s`, `%o`) and
|
||||||
|
// multi-line messages still read as one message. Non-string leading args
|
||||||
|
// (an Error, an object) are left strictly alone.
|
||||||
|
if (typeof args[0] === "string") {
|
||||||
|
console[method](`${tag} ${args[0]}`, ...args.slice(1));
|
||||||
|
} else {
|
||||||
|
console[method](tag, ...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
debug: (...args: unknown[]) => emit("debug", args),
|
||||||
|
info: (...args: unknown[]) => emit("info", args),
|
||||||
|
warn: (...args: unknown[]) => emit("warn", args),
|
||||||
|
error: (...args: unknown[]) => emit("error", args),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@
|
|||||||
* so there is no HTML5 fallback to reach for.
|
* so there is no HTML5 fallback to reach for.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("PiP");
|
||||||
|
|
||||||
interface AndroidPictureInPictureBridge {
|
interface AndroidPictureInPictureBridge {
|
||||||
enterPip(): void;
|
enterPip(): void;
|
||||||
isSupported(): boolean;
|
isSupported(): boolean;
|
||||||
@@ -41,7 +45,7 @@ export function isPipSupported(): boolean {
|
|||||||
try {
|
try {
|
||||||
return bridge()?.isSupported() ?? false;
|
return bridge()?.isSupported() ?? false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[PiP] isSupported check failed:", err);
|
log.warn("isSupported check failed:", err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,7 +58,7 @@ export function canEnterPip(): boolean {
|
|||||||
try {
|
try {
|
||||||
return bridge()?.canEnterPip() ?? false;
|
return bridge()?.canEnterPip() ?? false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[PiP] canEnterPip check failed:", err);
|
log.warn("canEnterPip check failed:", err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,7 +68,7 @@ export function enterPip(): void {
|
|||||||
try {
|
try {
|
||||||
bridge()?.enterPip();
|
bridge()?.enterPip();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[PiP] Failed to enter picture-in-picture:", err);
|
log.error("Failed to enter picture-in-picture:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +86,7 @@ export function setAutoEnterEnabled(enabled: boolean): void {
|
|||||||
try {
|
try {
|
||||||
bridge()?.setAutoEnterEnabled(enabled);
|
bridge()?.setAutoEnterEnabled(enabled);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[PiP] Failed to set auto-enter:", err);
|
log.warn("Failed to set auto-enter:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +119,6 @@ export function setHtml5VideoState(
|
|||||||
try {
|
try {
|
||||||
bridge()?.setHtml5VideoState(active, Math.round(width), Math.round(height), playing);
|
bridge()?.setHtml5VideoState(active, Math.round(width), Math.round(height), playing);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[PiP] Failed to report HTML5 video state:", err);
|
log.warn("Failed to report HTML5 video state:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@
|
|||||||
* document exists, and a page load wipes any inline style native had set.
|
* document exists, and a page load wipes any inline style native had set.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("SafeArea");
|
||||||
|
|
||||||
/** Window insets in CSS pixels, one per edge. */
|
/** Window insets in CSS pixels, one per edge. */
|
||||||
export interface SafeAreaInsets {
|
export interface SafeAreaInsets {
|
||||||
top: number;
|
top: number;
|
||||||
@@ -135,7 +139,7 @@ export function readNativeInsets(): SafeAreaInsets | null {
|
|||||||
try {
|
try {
|
||||||
return parseNativeInsets(bridge.get());
|
return parseNativeInsets(bridge.get());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[SafeArea] AndroidInsets bridge unusable:", err);
|
log.warn("AndroidInsets bridge unusable:", err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { nativeVideoActive } from "$lib/stores/nativeVideo";
|
import { nativeVideoActive } from "$lib/stores/nativeVideo";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("videoSurface");
|
||||||
|
|
||||||
interface AndroidVideoSurfaceBridge {
|
interface AndroidVideoSurfaceBridge {
|
||||||
setTransparent(transparent: boolean): void;
|
setTransparent(transparent: boolean): void;
|
||||||
@@ -50,7 +53,7 @@ export function isNativeSurfaceBridgeAvailable(): boolean {
|
|||||||
try {
|
try {
|
||||||
return bridge()?.isSupported() ?? false;
|
return bridge()?.isSupported() ?? false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[videoSurface] isSupported check failed:", err);
|
log.warn("isSupported check failed:", err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,17 +76,17 @@ export function enableNativeVideoCompositing(): void {
|
|||||||
// correctly behind a WebView that never stopped painting its own opaque
|
// correctly behind a WebView that never stopped painting its own opaque
|
||||||
// background. That ambiguity is what DR-172 was left holding. MainActivity's
|
// background. That ambiguity is what DR-172 was left holding. MainActivity's
|
||||||
// console bridge forwards this to logcat under the JellyTauWeb tag.
|
// console bridge forwards this to logcat under the JellyTauWeb tag.
|
||||||
console.error(
|
log.error(
|
||||||
"[videoSurface] AndroidVideoSurface bridge is MISSING - the webview will " +
|
"AndroidVideoSurface bridge is MISSING - the webview will " +
|
||||||
"stay opaque and native video will play as audio with no picture"
|
"stay opaque and native video will play as audio with no picture"
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
androidVideoSurface.setTransparent(true);
|
androidVideoSurface.setTransparent(true);
|
||||||
console.log("[videoSurface] compositing enabled (setTransparent(true) sent)");
|
log.debug("compositing enabled (setTransparent(true) sent)");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[videoSurface] setTransparent(true) failed:", err);
|
log.warn("setTransparent(true) failed:", err);
|
||||||
nativeVideoActive.set(false);
|
nativeVideoActive.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +96,7 @@ export function disableNativeVideoCompositing(): void {
|
|||||||
try {
|
try {
|
||||||
bridge()?.setTransparent(false);
|
bridge()?.setTransparent(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[videoSurface] setTransparent(false) failed:", err);
|
log.warn("setTransparent(false) failed:", err);
|
||||||
}
|
}
|
||||||
// Always clear the page layer, even if the bridge call failed, so the app is
|
// Always clear the page layer, even if the bridge call failed, so the app is
|
||||||
// never left rendering over a transparent window.
|
// never left rendering over a transparent window.
|
||||||
|
|||||||
@@ -34,6 +34,9 @@
|
|||||||
import { useScrollRestore } from "$lib/utils/scrollContainer";
|
import { useScrollRestore } from "$lib/utils/scrollContainer";
|
||||||
import { startNetworkReporting } from "$lib/services/networkType";
|
import { startNetworkReporting } from "$lib/services/networkType";
|
||||||
import { initSafeArea } from "$lib/utils/safeArea";
|
import { initSafeArea } from "$lib/utils/safeArea";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("Layout");
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -105,7 +108,7 @@
|
|||||||
const platformName = platform();
|
const platformName = platform();
|
||||||
isAndroid.set(platformName === "android");
|
isAndroid.set(platformName === "android");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Platform detection failed:", err);
|
log.error("Platform detection failed:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prime the safe-area custom properties from the native WindowInsets bridge
|
// Prime the safe-area custom properties from the native WindowInsets bridge
|
||||||
@@ -154,7 +157,7 @@
|
|||||||
const userId = get(auth).user?.id;
|
const userId = get(auth).user?.id;
|
||||||
if (userId) {
|
if (userId) {
|
||||||
downloads.refresh(userId).catch((err) =>
|
downloads.refresh(userId).catch((err) =>
|
||||||
console.error("Initial downloads refresh failed:", err)
|
log.error("Initial downloads refresh failed:", err)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +171,7 @@
|
|||||||
// without spending their retry budget (DR-131).
|
// without spending their retry budget (DR-131).
|
||||||
if (get(auth).user?.id) {
|
if (get(auth).user?.id) {
|
||||||
commands.syncProcessPending().catch((err) =>
|
commands.syncProcessPending().catch((err) =>
|
||||||
console.debug("[Layout] Startup sync drain skipped:", err)
|
log.debug("Startup sync drain skipped:", err)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +209,7 @@
|
|||||||
if (session?.serverUrl) {
|
if (session?.serverUrl) {
|
||||||
connectivity.forceCheck().catch((error) => {
|
connectivity.forceCheck().catch((error) => {
|
||||||
// If check fails, monitoring might not be started yet, so start it
|
// If check fails, monitoring might not be started yet, so start it
|
||||||
console.debug("[Layout] Queue status check failed, starting monitoring:", error);
|
log.debug("Queue status check failed, starting monitoring:", error);
|
||||||
connectivity.startMonitoring(session.serverUrl, {
|
connectivity.startMonitoring(session.serverUrl, {
|
||||||
onServerReconnected: () => {
|
onServerReconnected: () => {
|
||||||
// Retry session verification when server becomes reachable
|
// Retry session verification when server becomes reachable
|
||||||
@@ -215,7 +218,7 @@
|
|||||||
void onCatalogReconnected();
|
void onCatalogReconnected();
|
||||||
},
|
},
|
||||||
}).catch((monitorError) => {
|
}).catch((monitorError) => {
|
||||||
console.error("[Layout] Failed to start connectivity monitoring:", monitorError);
|
log.error("Failed to start connectivity monitoring:", monitorError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -242,7 +245,7 @@
|
|||||||
.then((unlisten) => {
|
.then((unlisten) => {
|
||||||
unlistenDrain = unlisten;
|
unlistenDrain = unlisten;
|
||||||
})
|
})
|
||||||
.catch((err) => console.debug("[Layout] sync-queue-changed listen failed:", err));
|
.catch((err) => log.debug("sync-queue-changed listen failed:", err));
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
import { assumedLibraryRatio } from "$lib/components/library/libraryMosaic";
|
import { assumedLibraryRatio } from "$lib/components/library/libraryMosaic";
|
||||||
import { useScrollRestore } from "$lib/utils/scrollContainer";
|
import { useScrollRestore } from "$lib/utils/scrollContainer";
|
||||||
import type { MediaItem, Library } from "$lib/api/types";
|
import type { MediaItem, Library } from "$lib/api/types";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("HomePage");
|
||||||
|
|
||||||
// Home scrolls in its own box rather than the shell's, and is destroyed on
|
// Home scrolls in its own box rather than the shell's, and is destroyed on
|
||||||
// every navigation away — so its offsets live in the module-level memory,
|
// every navigation away — so its offsets live in the module-level memory,
|
||||||
@@ -40,7 +43,7 @@
|
|||||||
const platformName = await platform();
|
const platformName = await platform();
|
||||||
isAndroid = platformName === "android";
|
isAndroid = platformName === "android";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Platform detection failed:", err);
|
log.error("Platform detection failed:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($isAuthenticated) {
|
if ($isAuthenticated) {
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import DownloadItem from "$lib/components/downloads/DownloadItem.svelte";
|
import DownloadItem from "$lib/components/downloads/DownloadItem.svelte";
|
||||||
import DownloadedBrowse from "$lib/components/downloads/DownloadedBrowse.svelte";
|
import DownloadedBrowse from "$lib/components/downloads/DownloadedBrowse.svelte";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("DownloadsPage");
|
||||||
|
|
||||||
type ViewType = "downloaded" | "transfers";
|
type ViewType = "downloaded" | "transfers";
|
||||||
let view = $state<ViewType>("downloaded");
|
let view = $state<ViewType>("downloaded");
|
||||||
@@ -42,7 +45,7 @@
|
|||||||
await downloads.refresh(userId);
|
await downloads.refresh(userId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load downloads:", error);
|
log.error("Failed to load downloads:", error);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -60,7 +63,7 @@
|
|||||||
try {
|
try {
|
||||||
await downloads.pause(download.id);
|
await downloads.pause(download.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to pause download ${download.id}:`, error);
|
log.error(`Failed to pause download ${download.id}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,7 +77,7 @@
|
|||||||
try {
|
try {
|
||||||
await downloads.resume(download.id);
|
await downloads.resume(download.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to resume download ${download.id}:`, error);
|
log.error(`Failed to resume download ${download.id}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,9 @@
|
|||||||
initialExpandedSeasons,
|
initialExpandedSeasons,
|
||||||
type SeasonData,
|
type SeasonData,
|
||||||
} from "$lib/components/library/seriesNavigation";
|
} from "$lib/components/library/seriesNavigation";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("LibraryDetail");
|
||||||
|
|
||||||
let item = $state<MediaItem | null>(null);
|
let item = $state<MediaItem | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -136,11 +139,11 @@
|
|||||||
}
|
}
|
||||||
// Series-less episode: rendered by the Focus View below, series and all.
|
// Series-less episode: rendered by the Focus View below, series and all.
|
||||||
}
|
}
|
||||||
console.log(`[LibraryDetail] ✓ Loaded item: ${item?.name} (${item?.kind})`);
|
log.debug(`✓ Loaded item: ${item?.name} (${item?.kind})`);
|
||||||
console.log(`[LibraryDetail] - Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`);
|
log.debug(`- Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`);
|
||||||
if (item?.people) {
|
if (item?.people) {
|
||||||
item.people.forEach((p, i) => {
|
item.people.forEach((p, i) => {
|
||||||
console.log(`[LibraryDetail] [${i}] ${p.name} (${p.type})`);
|
log.debug(` [${i}] ${p.name} (${p.type})`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +157,7 @@
|
|||||||
const musicLibrary = $libraries.find(lib => lib.collectionType === "music");
|
const musicLibrary = $libraries.find(lib => lib.collectionType === "music");
|
||||||
if (musicLibrary) {
|
if (musicLibrary) {
|
||||||
library.setCurrentLibrary(musicLibrary);
|
library.setCurrentLibrary(musicLibrary);
|
||||||
console.log("[LibraryDetail] Set current library to music library for music item");
|
log.debug("Set current library to music library for music item");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,20 +166,20 @@
|
|||||||
// Ensure cast/crew data is loaded for Movies, Series, and Episodes
|
// Ensure cast/crew data is loaded for Movies, Series, and Episodes
|
||||||
// Some APIs/caches may not include people data on first load
|
// Some APIs/caches may not include people data on first load
|
||||||
if ((item?.kind === "movie" || item?.kind === "series" || item?.kind === "episode") && (!item.people || item.people.length === 0)) {
|
if ((item?.kind === "movie" || item?.kind === "series" || item?.kind === "episode") && (!item.people || item.people.length === 0)) {
|
||||||
console.log(`[LibraryDetail] ⚠ People data missing, reloading ${item?.kind}...`);
|
log.debug(`⚠ People data missing, reloading ${item?.kind}...`);
|
||||||
try {
|
try {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
const fullItem = await repo.getItem(itemId);
|
const fullItem = await repo.getItem(itemId);
|
||||||
console.log(`[LibraryDetail] - Reloaded has people? ${fullItem.people ? `YES (${fullItem.people.length})` : 'NO'}`);
|
log.debug(`- Reloaded has people? ${fullItem.people ? `YES (${fullItem.people.length})` : 'NO'}`);
|
||||||
if (fullItem.people && fullItem.people.length > 0) {
|
if (fullItem.people && fullItem.people.length > 0) {
|
||||||
item = fullItem;
|
item = fullItem;
|
||||||
console.log(`[LibraryDetail] ✓ Updated item with ${fullItem.people.length} people`);
|
log.debug(`✓ Updated item with ${fullItem.people.length} people`);
|
||||||
fullItem.people.forEach((p, i) => {
|
fullItem.people.forEach((p, i) => {
|
||||||
console.log(`[LibraryDetail] [${i}] ${p.name} (${p.type})`);
|
log.debug(` [${i}] ${p.name} (${p.type})`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`Could not reload ${item?.kind} with full cast data:`, e);
|
log.warn(`Could not reload ${item?.kind} with full cast data:`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +197,7 @@
|
|||||||
repo.getSeriesEpisodes(itemId),
|
repo.getSeriesEpisodes(itemId),
|
||||||
// Best-effort: a series still renders if the anchor cannot be resolved.
|
// Best-effort: a series still renders if the anchor cannot be resolved.
|
||||||
repo.getSeriesCurrentEpisode(itemId).catch((e) => {
|
repo.getSeriesCurrentEpisode(itemId).catch((e) => {
|
||||||
console.warn("Could not resolve the current episode:", e);
|
log.warn("Could not resolve the current episode:", e);
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -220,7 +223,7 @@
|
|||||||
directFetchedEpisode = await repo.getItem(episodeIdParam);
|
directFetchedEpisode = await repo.getItem(episodeIdParam);
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort: the list entry still renders a usable hero.
|
// Best-effort: the list entry still renders a usable hero.
|
||||||
console.warn("Could not fetch focused episode directly:", episodeIdParam);
|
log.warn("Could not fetch focused episode directly:", episodeIdParam);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,7 +325,7 @@
|
|||||||
shuffle: false,
|
shuffle: false,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to play album:", e);
|
log.error("Failed to play album:", e);
|
||||||
alert(`Failed to play album: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
alert(`Failed to play album: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
} else if ($libraryItems.length > 0) {
|
} else if ($libraryItems.length > 0) {
|
||||||
@@ -346,7 +349,7 @@
|
|||||||
shuffle: true,
|
shuffle: true,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to shuffle play album:", e);
|
log.error("Failed to shuffle play album:", e);
|
||||||
alert(`Failed to shuffle play: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
alert(`Failed to shuffle play: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
} else if (item?.kind === "series" && allEpisodes.length > 0) {
|
} else if (item?.kind === "series" && allEpisodes.length > 0) {
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
favoritesRouteUrl,
|
favoritesRouteUrl,
|
||||||
emptyStateMessage,
|
emptyStateMessage,
|
||||||
} from "$lib/utils/favoritesView";
|
} from "$lib/utils/favoritesView";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("FavoritesPage");
|
||||||
|
|
||||||
const scope = $derived(resolveFavoritesScope($page.url.searchParams.get("scope")));
|
const scope = $derived(resolveFavoritesScope($page.url.searchParams.get("scope")));
|
||||||
|
|
||||||
@@ -49,7 +52,7 @@
|
|||||||
const result = await repo.getFavorites(currentScope, { limit: 500 });
|
const result = await repo.getFavorites(currentScope, { limit: 500 });
|
||||||
items = result.items;
|
items = result.items;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load favorites:", error);
|
log.error("Failed to load favorites:", error);
|
||||||
loadError = "Could not load your favourites.";
|
loadError = "Could not load your favourites.";
|
||||||
items = [];
|
items = [];
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -24,6 +24,11 @@
|
|||||||
import { reportSkippedEpisode, shouldSuppressStopReport } from "$lib/services/skipReporting";
|
import { reportSkippedEpisode, shouldSuppressStopReport } from "$lib/services/skipReporting";
|
||||||
import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
|
import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
|
||||||
import * as html5Adapter from "$lib/player/html5Adapter";
|
import * as html5Adapter from "$lib/player/html5Adapter";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("PlayerPage");
|
||||||
|
const nextEpisodeLog = createLogger("NextEpisode");
|
||||||
|
const autoPlayLog = createLogger("AutoPlay");
|
||||||
|
|
||||||
const itemId = $derived($page.params.id);
|
const itemId = $derived($page.params.id);
|
||||||
const queueParam = $derived($page.url.searchParams.get("queue"));
|
const queueParam = $derived($page.url.searchParams.get("queue"));
|
||||||
@@ -95,7 +100,7 @@
|
|||||||
const id = itemId;
|
const id = itemId;
|
||||||
const restart = restartParam;
|
const restart = restartParam;
|
||||||
if (id && id !== loadedItemId) {
|
if (id && id !== loadedItemId) {
|
||||||
console.log("[AutoPlay] $effect triggered: loading new item", id, "(was:", loadedItemId, ") restart:", restart);
|
autoPlayLog.debug("$effect triggered: loading new item", id, "(was:", loadedItemId, ") restart:", restart);
|
||||||
// restart=true (advancing to next episode) forces start-from-beginning,
|
// restart=true (advancing to next episode) forces start-from-beginning,
|
||||||
// bypassing the resume-progress check.
|
// bypassing the resume-progress check.
|
||||||
loadAndPlay(id, restart ? 0 : undefined, restart);
|
loadAndPlay(id, restart ? 0 : undefined, restart);
|
||||||
@@ -128,16 +133,16 @@
|
|||||||
let retrievedProgressSeconds: number | null = null;
|
let retrievedProgressSeconds: number | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("loadAndPlay: Loading item", id);
|
log.debug("loadAndPlay: Loading item", id);
|
||||||
// Load item details
|
// Load item details
|
||||||
const item = await library.loadItem(id);
|
const item = await library.loadItem(id);
|
||||||
console.log("loadAndPlay: Loaded item", item.name, "kind:", item.kind);
|
log.debug("loadAndPlay: Loaded item", item.name, "kind:", item.kind);
|
||||||
currentMedia = item;
|
currentMedia = item;
|
||||||
|
|
||||||
// Check if this is a non-playable collection type that should be viewed in library instead
|
// Check if this is a non-playable collection type that should be viewed in library instead
|
||||||
const collectionKinds: MediaKind[] = ["album", "artist", "series", "season", "folder", "playlist", "channel"];
|
const collectionKinds: MediaKind[] = ["album", "artist", "series", "season", "folder", "playlist", "channel"];
|
||||||
if (item.kind && collectionKinds.includes(item.kind)) {
|
if (item.kind && collectionKinds.includes(item.kind)) {
|
||||||
console.log("loadAndPlay: Redirecting collection type to library:", item.kind);
|
log.debug("loadAndPlay: Redirecting collection type to library:", item.kind);
|
||||||
goto(`/library/${id}`);
|
goto(`/library/${id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -162,7 +167,7 @@
|
|||||||
forceRestart,
|
forceRestart,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
console.log("loadAndPlay: Track already playing, showing UI without restarting");
|
log.debug("loadAndPlay: Track already playing, showing UI without restarting");
|
||||||
isPlaying = true;
|
isPlaying = true;
|
||||||
loading = false;
|
loading = false;
|
||||||
// hasNext/hasPrevious come from the event-driven queue store.
|
// hasNext/hasPrevious come from the event-driven queue store.
|
||||||
@@ -175,7 +180,7 @@
|
|||||||
try {
|
try {
|
||||||
await commands.playerStop();
|
await commands.playerStop();
|
||||||
queue.clear();
|
queue.clear();
|
||||||
console.log("loadAndPlay: Stopped audio backend for video playback");
|
log.debug("loadAndPlay: Stopped audio backend for video playback");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore - player may not have been playing
|
// Ignore - player may not have been playing
|
||||||
}
|
}
|
||||||
@@ -185,43 +190,43 @@
|
|||||||
// When forceRestart is set (advancing to a next episode) we always start
|
// When forceRestart is set (advancing to a next episode) we always start
|
||||||
// from the beginning, skipping the resume check and resume dialog.
|
// from the beginning, skipping the resume check and resume dialog.
|
||||||
const userId = auth.getUserId();
|
const userId = auth.getUserId();
|
||||||
console.log("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition, "forceRestart:", forceRestart);
|
log.debug("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition, "forceRestart:", forceRestart);
|
||||||
|
|
||||||
// Live streams have no fixed position - never resume.
|
// Live streams have no fixed position - never resume.
|
||||||
if (!startPosition && !forceRestart && userId && !isLive) {
|
if (!startPosition && !forceRestart && userId && !isLive) {
|
||||||
try {
|
try {
|
||||||
const progress = await commands.storageGetPlaybackProgress(userId, id);
|
const progress = await commands.storageGetPlaybackProgress(userId, id);
|
||||||
console.log("Resume check - retrieved progress:", progress);
|
log.debug("Resume check - retrieved progress:", progress);
|
||||||
|
|
||||||
if (progress && progress.positionMs > 0 && item.durationMs) {
|
if (progress && progress.positionMs > 0 && item.durationMs) {
|
||||||
const positionSeconds = progress.positionMs / 1000;
|
const positionSeconds = progress.positionMs / 1000;
|
||||||
const totalSeconds = item.durationMs / 1000;
|
const totalSeconds = item.durationMs / 1000;
|
||||||
const progressPercent = (positionSeconds / totalSeconds) * 100;
|
const progressPercent = (positionSeconds / totalSeconds) * 100;
|
||||||
|
|
||||||
console.log("Resume check - positionSeconds:", positionSeconds, "totalSeconds:", totalSeconds, "progressPercent:", progressPercent);
|
log.debug("Resume check - positionSeconds:", positionSeconds, "totalSeconds:", totalSeconds, "progressPercent:", progressPercent);
|
||||||
|
|
||||||
// Store for later use regardless of whether dialog is shown
|
// Store for later use regardless of whether dialog is shown
|
||||||
retrievedProgressSeconds = positionSeconds;
|
retrievedProgressSeconds = positionSeconds;
|
||||||
|
|
||||||
// Show resume dialog if watched > 30 seconds and < 90% complete
|
// Show resume dialog if watched > 30 seconds and < 90% complete
|
||||||
if (positionSeconds > 30 && progressPercent < 90) {
|
if (positionSeconds > 30 && progressPercent < 90) {
|
||||||
console.log("Resume check - SHOWING RESUME DIALOG");
|
log.debug("Resume check - SHOWING RESUME DIALOG");
|
||||||
savedProgress = { positionSeconds, progressPercent };
|
savedProgress = { positionSeconds, progressPercent };
|
||||||
showResumeDialog = true;
|
showResumeDialog = true;
|
||||||
loading = false;
|
loading = false;
|
||||||
return; // Wait for user decision
|
return; // Wait for user decision
|
||||||
} else {
|
} else {
|
||||||
console.log("Resume check - NOT showing dialog. Position > 30?", positionSeconds > 30, "Progress < 90?", progressPercent < 90);
|
log.debug("Resume check - NOT showing dialog. Position > 30?", positionSeconds > 30, "Progress < 90?", progressPercent < 90);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("Resume check - No valid progress found. Has progress?", !!progress, "Has position?", progress?.positionMs, "Has runtime?", !!item.durationMs);
|
log.debug("Resume check - No valid progress found. Has progress?", !!progress, "Has position?", progress?.positionMs, "Has runtime?", !!item.durationMs);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to check saved progress:", e);
|
log.error("Failed to check saved progress:", e);
|
||||||
// Continue with normal playback
|
// Continue with normal playback
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("Resume check - Skipped. Reason:", !userId ? "No userId" : "Has startPosition");
|
log.debug("Resume check - Skipped. Reason:", !userId ? "No userId" : "Has startPosition");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this item is downloaded locally
|
// Check if this item is downloaded locally
|
||||||
@@ -232,7 +237,7 @@
|
|||||||
|
|
||||||
if (localDownload) {
|
if (localDownload) {
|
||||||
// Use local file for playback
|
// Use local file for playback
|
||||||
console.log("loadAndPlay: Found local download, using offline playback:", localDownload.filePath);
|
log.debug("loadAndPlay: Found local download, using offline playback:", localDownload.filePath);
|
||||||
isOfflinePlayback = true;
|
isOfflinePlayback = true;
|
||||||
|
|
||||||
// Get the storage path and resolve the file's location. A completed
|
// Get the storage path and resolve the file's location. A completed
|
||||||
@@ -241,7 +246,7 @@
|
|||||||
// TRACES: UR-071 | DR-133
|
// TRACES: UR-071 | DR-133
|
||||||
const storagePath = await commands.storageGetPath();
|
const storagePath = await commands.storageGetPath();
|
||||||
const fullPath = downloadedFilePath(storagePath, localDownload.filePath);
|
const fullPath = downloadedFilePath(storagePath, localDownload.filePath);
|
||||||
console.log("loadAndPlay: Full local path:", fullPath);
|
log.debug("loadAndPlay: Full local path:", fullPath);
|
||||||
|
|
||||||
// Serve the file over the loopback media server rather than the asset
|
// Serve the file over the loopback media server rather than the asset
|
||||||
// protocol: the asset protocol answers a range-less request with the
|
// protocol: the asset protocol answers a range-less request with the
|
||||||
@@ -249,7 +254,7 @@
|
|||||||
// the URL (it holds the port and the per-session token).
|
// the URL (it holds the port and the per-session token).
|
||||||
// TRACES: UR-071 | DR-137
|
// TRACES: UR-071 | DR-137
|
||||||
const localUrl = await commands.mediaLocalUrl(fullPath);
|
const localUrl = await commands.mediaLocalUrl(fullPath);
|
||||||
console.log("loadAndPlay: Local media URL resolved");
|
log.debug("loadAndPlay: Local media URL resolved");
|
||||||
|
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
// Local video files don't need transcoding and support native seeking
|
// Local video files don't need transcoding and support native seeking
|
||||||
@@ -260,7 +265,7 @@
|
|||||||
videoInitialPosition = effectivePosition;
|
videoInitialPosition = effectivePosition;
|
||||||
} else {
|
} else {
|
||||||
// Local audio playback via MPV backend
|
// Local audio playback via MPV backend
|
||||||
console.log("loadAndPlay: Using MPV backend for offline audio");
|
log.debug("loadAndPlay: Using MPV backend for offline audio");
|
||||||
// Use player_play_tracks - backend fetches all metadata from single ID
|
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
const repositoryHandle = repo.getHandle();
|
const repositoryHandle = repo.getHandle();
|
||||||
@@ -286,9 +291,9 @@
|
|||||||
if (isLive) {
|
if (isLive) {
|
||||||
// Live TV channels must be "opened" before streaming; the server returns
|
// Live TV channels must be "opened" before streaming; the server returns
|
||||||
// a ready-to-play HLS transcoding URL. No resume, no seek, no progress.
|
// a ready-to-play HLS transcoding URL. No resume, no seek, no progress.
|
||||||
console.log("loadAndPlay: Opening live stream for channel:", id);
|
log.debug("loadAndPlay: Opening live stream for channel:", id);
|
||||||
const liveInfo = await repo.openLiveStream(id);
|
const liveInfo = await repo.openLiveStream(id);
|
||||||
console.log("loadAndPlay: Live stream URL:", liveInfo.streamUrl);
|
log.debug("loadAndPlay: Live stream URL:", liveInfo.streamUrl);
|
||||||
mediaSourceId = liveInfo.mediaSourceId;
|
mediaSourceId = liveInfo.mediaSourceId;
|
||||||
streamUrl = liveInfo.streamUrl;
|
streamUrl = liveInfo.streamUrl;
|
||||||
videoNeedsTranscoding = true;
|
videoNeedsTranscoding = true;
|
||||||
@@ -298,13 +303,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("loadAndPlay: Getting playback info");
|
log.debug("loadAndPlay: Getting playback info");
|
||||||
const playbackInfo = await repo.getPlaybackInfo(id);
|
const playbackInfo = await repo.getPlaybackInfo(id);
|
||||||
console.log("loadAndPlay: Got playback info, mediaSourceId:", playbackInfo.mediaSourceId);
|
log.debug("loadAndPlay: Got playback info, mediaSourceId:", playbackInfo.mediaSourceId);
|
||||||
|
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
// Playback API now detects HEVC/10-bit and returns transcoded URL when needed
|
// Playback API now detects HEVC/10-bit and returns transcoded URL when needed
|
||||||
console.log("loadAndPlay: Using video stream, directPlay:", playbackInfo.directPlay, "needsTranscoding:", playbackInfo.needsTranscoding);
|
log.debug("loadAndPlay: Using video stream, directPlay:", playbackInfo.directPlay, "needsTranscoding:", playbackInfo.needsTranscoding);
|
||||||
mediaSourceId = playbackInfo.mediaSourceId;
|
mediaSourceId = playbackInfo.mediaSourceId;
|
||||||
|
|
||||||
// Prefer a completed download over streaming. Audio has done this
|
// Prefer a completed download over streaming. Audio has done this
|
||||||
@@ -328,7 +333,7 @@
|
|||||||
|
|
||||||
streamUrl = source.url;
|
streamUrl = source.url;
|
||||||
videoNeedsTranscoding = source.needsTranscoding;
|
videoNeedsTranscoding = source.needsTranscoding;
|
||||||
console.log(
|
log.debug(
|
||||||
source.isLocal
|
source.isLocal
|
||||||
? "loadAndPlay: Playing downloaded file from disk"
|
? "loadAndPlay: Playing downloaded file from disk"
|
||||||
: `loadAndPlay: Using stream URL: ${streamUrl}`
|
: `loadAndPlay: Using stream URL: ${streamUrl}`
|
||||||
@@ -347,17 +352,17 @@
|
|||||||
const effectivePosition = startPosition ?? retrievedProgressSeconds ?? 0;
|
const effectivePosition = startPosition ?? retrievedProgressSeconds ?? 0;
|
||||||
videoInitialPosition = effectivePosition > 0 ? effectivePosition : 0;
|
videoInitialPosition = effectivePosition > 0 ? effectivePosition : 0;
|
||||||
if (videoInitialPosition > 0) {
|
if (videoInitialPosition > 0) {
|
||||||
console.log("loadAndPlay: Will seek to position after load:", videoInitialPosition);
|
log.debug("loadAndPlay: Will seek to position after load:", videoInitialPosition);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For audio, use MPV backend
|
// For audio, use MPV backend
|
||||||
console.log("loadAndPlay: Using MPV backend for audio");
|
log.debug("loadAndPlay: Using MPV backend for audio");
|
||||||
|
|
||||||
// Check if we have a queue parameter (e.g., queue=parent:albumId)
|
// Check if we have a queue parameter (e.g., queue=parent:albumId)
|
||||||
const queueParamValue = queueParam;
|
const queueParamValue = queueParam;
|
||||||
if (queueParamValue?.startsWith("parent:")) {
|
if (queueParamValue?.startsWith("parent:")) {
|
||||||
const parentId = queueParamValue.substring(7); // Remove "parent:" prefix
|
const parentId = queueParamValue.substring(7); // Remove "parent:" prefix
|
||||||
console.log("loadAndPlay: Loading queue from parent:", parentId);
|
log.debug("loadAndPlay: Loading queue from parent:", parentId);
|
||||||
|
|
||||||
// Fetch all tracks from the parent (album/playlist)
|
// Fetch all tracks from the parent (album/playlist)
|
||||||
const result = await repo.getItems(parentId, {
|
const result = await repo.getItems(parentId, {
|
||||||
@@ -372,16 +377,16 @@
|
|||||||
const startIndex = audioTracks.findIndex(t => t.id === id);
|
const startIndex = audioTracks.findIndex(t => t.id === id);
|
||||||
const actualStartIndex = startIndex >= 0 ? startIndex : 0;
|
const actualStartIndex = startIndex >= 0 ? startIndex : 0;
|
||||||
|
|
||||||
console.log("loadAndPlay: Building queue with", audioTracks.length, "tracks, startIndex:", actualStartIndex);
|
log.debug("loadAndPlay: Building queue with", audioTracks.length, "tracks, startIndex:", actualStartIndex);
|
||||||
|
|
||||||
// Build queue items with stream URLs
|
// Build queue items with stream URLs
|
||||||
// Add error handling and logging for each track
|
// Add error handling and logging for each track
|
||||||
const queueItems = await Promise.all(audioTracks.map(async (t, idx) => {
|
const queueItems = await Promise.all(audioTracks.map(async (t, idx) => {
|
||||||
try {
|
try {
|
||||||
console.log(`loadAndPlay: Fetching stream URL for track ${idx + 1}/${audioTracks.length}: ${t.name}`);
|
log.debug(`loadAndPlay: Fetching stream URL for track ${idx + 1}/${audioTracks.length}: ${t.name}`);
|
||||||
const trackStreamUrl = await repo.getAudioStreamUrl(t.id);
|
const trackStreamUrl = await repo.getAudioStreamUrl(t.id);
|
||||||
if (!trackStreamUrl) {
|
if (!trackStreamUrl) {
|
||||||
console.error(`loadAndPlay: Empty stream URL for track: ${t.name}`);
|
log.error(`loadAndPlay: Empty stream URL for track: ${t.name}`);
|
||||||
throw new Error(`Failed to get stream URL for ${t.name}`);
|
throw new Error(`Failed to get stream URL for ${t.name}`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -398,7 +403,7 @@
|
|||||||
jellyfinItemId: t.id,
|
jellyfinItemId: t.id,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`loadAndPlay: Failed to build queue item for track ${t.name}:`, e);
|
log.error(`loadAndPlay: Failed to build queue item for track ${t.name}:`, e);
|
||||||
throw e; // Re-throw to fail fast and show error to user
|
throw e; // Re-throw to fail fast and show error to user
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -411,10 +416,10 @@
|
|||||||
} as unknown as PlayQueueRequest);
|
} as unknown as PlayQueueRequest);
|
||||||
|
|
||||||
// Queue will auto-update from Rust backend event
|
// Queue will auto-update from Rust backend event
|
||||||
console.log("loadAndPlay: Successfully set up queue with", audioTracks.length, "tracks");
|
log.debug("loadAndPlay: Successfully set up queue with", audioTracks.length, "tracks");
|
||||||
} else {
|
} else {
|
||||||
// Fallback to single item playback
|
// Fallback to single item playback
|
||||||
console.log("loadAndPlay: No audio tracks found in parent, falling back to single item");
|
log.debug("loadAndPlay: No audio tracks found in parent, falling back to single item");
|
||||||
// Use player_play_tracks - backend fetches all metadata from single ID
|
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
const repositoryHandle = repo.getHandle();
|
const repositoryHandle = repo.getHandle();
|
||||||
@@ -430,7 +435,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Queue will auto-update from Rust backend event
|
// Queue will auto-update from Rust backend event
|
||||||
console.log("loadAndPlay: Set queue with single item:", item.name);
|
log.debug("loadAndPlay: Set queue with single item:", item.name);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No queue parameter - single item playback
|
// No queue parameter - single item playback
|
||||||
@@ -449,7 +454,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Queue will auto-update from Rust backend event
|
// Queue will auto-update from Rust backend event
|
||||||
console.log("loadAndPlay: Set queue with single item:", item.name);
|
log.debug("loadAndPlay: Set queue with single item:", item.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seek to start position if provided
|
// Seek to start position if provided
|
||||||
@@ -463,14 +468,14 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
|
|
||||||
// Fetch next episode for video episodes (for skip button)
|
// Fetch next episode for video episodes (for skip button)
|
||||||
console.log("[NextEpisode] Post-load check: isVideo=", isVideo, "currentMedia=", currentMedia?.kind, currentMedia?.name);
|
nextEpisodeLog.debug("Post-load check: isVideo=", isVideo, "currentMedia=", currentMedia?.kind, currentMedia?.name);
|
||||||
if (isVideo && currentMedia) {
|
if (isVideo && currentMedia) {
|
||||||
fetchNextEpisode(currentMedia);
|
fetchNextEpisode(currentMedia);
|
||||||
} else {
|
} else {
|
||||||
console.log("[NextEpisode] Skipped fetchNextEpisode - isVideo:", isVideo, "currentMedia:", !!currentMedia);
|
nextEpisodeLog.debug("Skipped fetchNextEpisode - isVideo:", isVideo, "currentMedia:", !!currentMedia);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("loadAndPlay error:", e);
|
log.error("loadAndPlay error:", e);
|
||||||
// Show detailed error including the full error object
|
// Show detailed error including the full error object
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
error = `${e.name}: ${e.message}`;
|
error = `${e.name}: ${e.message}`;
|
||||||
@@ -599,21 +604,21 @@
|
|||||||
// and check for next episodes. HTML5 video plays independently of the Rust
|
// and check for next episodes. HTML5 video plays independently of the Rust
|
||||||
// backend queue, so the backend needs these to know what just finished.
|
// backend queue, so the backend needs these to know what just finished.
|
||||||
const mediaId = currentMedia?.id ?? null;
|
const mediaId = currentMedia?.id ?? null;
|
||||||
console.log("[AutoPlay] Video ended. currentMedia:", mediaId, currentMedia?.name, "itemId (URL):", itemId);
|
autoPlayLog.debug("Video ended. currentMedia:", mediaId, currentMedia?.name, "itemId (URL):", itemId);
|
||||||
try {
|
try {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
const repoHandle = repo.getHandle();
|
const repoHandle = repo.getHandle();
|
||||||
await commands.playerOnPlaybackEnded(mediaId, repoHandle);
|
await commands.playerOnPlaybackEnded(mediaId, repoHandle);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[AutoPlay] Failed to handle playback ended:", e);
|
autoPlayLog.error("Failed to handle playback ended:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchNextEpisode(media: MediaItem) {
|
async function fetchNextEpisode(media: MediaItem) {
|
||||||
nextEpisode = null;
|
nextEpisode = null;
|
||||||
console.log("[NextEpisode] fetchNextEpisode called:", { kind: media.kind, seriesId: media.seriesId, seasonId: media.seasonId, indexNumber: media.indexNumber, id: media.id, name: media.name });
|
nextEpisodeLog.debug("fetchNextEpisode called:", { kind: media.kind, seriesId: media.seriesId, seasonId: media.seasonId, indexNumber: media.indexNumber, id: media.id, name: media.name });
|
||||||
if (media.kind !== "episode" || !media.seasonId || media.indexNumber == null) {
|
if (media.kind !== "episode" || !media.seasonId || media.indexNumber == null) {
|
||||||
console.log("[NextEpisode] Skipping - not an episode or missing seasonId/indexNumber");
|
nextEpisodeLog.debug("Skipping - not an episode or missing seasonId/indexNumber");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -621,18 +626,18 @@
|
|||||||
// Fetch all episodes in the season sorted by episode number
|
// Fetch all episodes in the season sorted by episode number
|
||||||
const result = await repo.getItems(media.seasonId, { sortBy: "IndexNumber", sortOrder: "Ascending", limit: 500 });
|
const result = await repo.getItems(media.seasonId, { sortBy: "IndexNumber", sortOrder: "Ascending", limit: 500 });
|
||||||
const episodes = result.items.filter(e => e.kind === "episode");
|
const episodes = result.items.filter(e => e.kind === "episode");
|
||||||
console.log("[NextEpisode] Season has", episodes.length, "episodes, current index:", media.indexNumber);
|
nextEpisodeLog.debug("Season has", episodes.length, "episodes, current index:", media.indexNumber);
|
||||||
|
|
||||||
// Find the episode after the current one by index number
|
// Find the episode after the current one by index number
|
||||||
const currentIdx = episodes.findIndex(e => e.id === media.id);
|
const currentIdx = episodes.findIndex(e => e.id === media.id);
|
||||||
if (currentIdx >= 0 && currentIdx < episodes.length - 1) {
|
if (currentIdx >= 0 && currentIdx < episodes.length - 1) {
|
||||||
nextEpisode = episodes[currentIdx + 1];
|
nextEpisode = episodes[currentIdx + 1];
|
||||||
console.log("[NextEpisode] Set nextEpisode:", nextEpisode.name, "index:", nextEpisode.indexNumber);
|
nextEpisodeLog.debug("Set nextEpisode:", nextEpisode.name, "index:", nextEpisode.indexNumber);
|
||||||
} else {
|
} else {
|
||||||
console.log("[NextEpisode] No next episode in season (current position:", currentIdx, "of", episodes.length, ")");
|
nextEpisodeLog.debug("No next episode in season (current position:", currentIdx, "of", episodes.length, ")");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[NextEpisode] Failed to fetch next episode:", e);
|
nextEpisodeLog.error("Failed to fetch next episode:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
} from "$lib/services/networkType";
|
} from "$lib/services/networkType";
|
||||||
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
|
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
|
||||||
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
|
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
|
||||||
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
|
const log = createLogger("SettingsPage");
|
||||||
|
|
||||||
const episodeLimitOptions = [
|
const episodeLimitOptions = [
|
||||||
{ value: 0, label: "Unlimited" },
|
{ value: 0, label: "Unlimited" },
|
||||||
@@ -153,7 +156,7 @@
|
|||||||
// Load cache stats in parallel but don't block on it
|
// Load cache stats in parallel but don't block on it
|
||||||
loadCacheStats();
|
loadCacheStats();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load settings:", e);
|
log.error("Failed to load settings:", e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -164,7 +167,7 @@
|
|||||||
cacheLoading = true;
|
cacheLoading = true;
|
||||||
cacheStats = await getCacheStats();
|
cacheStats = await getCacheStats();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load cache stats:", e);
|
log.error("Failed to load cache stats:", e);
|
||||||
} finally {
|
} finally {
|
||||||
cacheLoading = false;
|
cacheLoading = false;
|
||||||
}
|
}
|
||||||
@@ -176,7 +179,7 @@
|
|||||||
// Reload stats to reflect new limit
|
// Reload stats to reflect new limit
|
||||||
await loadCacheStats();
|
await loadCacheStats();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to set cache limit:", e);
|
log.error("Failed to set cache limit:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +189,7 @@
|
|||||||
await clearCache();
|
await clearCache();
|
||||||
await loadCacheStats();
|
await loadCacheStats();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to clear cache:", e);
|
log.error("Failed to clear cache:", e);
|
||||||
} finally {
|
} finally {
|
||||||
clearingCache = false;
|
clearingCache = false;
|
||||||
}
|
}
|
||||||
@@ -214,7 +217,7 @@
|
|||||||
try {
|
try {
|
||||||
await commands.playerSetAudioSettings(settings);
|
await commands.playerSetAudioSettings(settings);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to save audio settings:", e);
|
log.error("Failed to save audio settings:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +225,7 @@
|
|||||||
try {
|
try {
|
||||||
await commands.playerSetVideoSettings(videoSettings);
|
await commands.playerSetVideoSettings(videoSettings);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to save video settings:", e);
|
log.error("Failed to save video settings:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +237,7 @@
|
|||||||
// rather than at the next network change.
|
// rather than at the next network change.
|
||||||
await reportNetworkState();
|
await reportNetworkState();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to save download settings:", e);
|
log.error("Failed to save download settings:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user