From 4c82a0a0252da29eb5154a2308b1417d004a3ce9 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 20 Aug 2026 19:29:48 +0200 Subject: [PATCH 1/2] feat(logging): add leveled logger facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TRACES: | DR-204 | UT-201 The Rust half of the app logs through the `log` crate behind `env_logger`, with `LevelFilter::Info` by default and `RUST_LOG` to turn the volume up without a rebuild. The frontend had no equivalent at all: every `console.log` written during development shipped to end users. `createLogger(scope)` gives the frontend the same shape: - four levels (debug/info/warn/error), gated by severity; - verbose in dev, `warn` in production — warn and error are never gated away, because a silent failure in a networked media client is worse to support than a noisy console; - `localStorage["jellytau:logLevel"]`, read once at init, as the `RUST_LOG` equivalent so a user can gather verbose logs for a bug report without a rebuild. Guarded for SSR and for webviews where storage access throws; - the scope replaces the hand-written `"[Scope] …"` prefixes; - a thin pass-through: arguments reach `console.*` untouched and by reference, and `console` is resolved at call time so devtools overrides and test spies still see everything. --- src/lib/utils/logger.test.ts | 318 +++++++++++++++++++++++++++++++++++ src/lib/utils/logger.ts | 203 ++++++++++++++++++++++ 2 files changed, 521 insertions(+) create mode 100644 src/lib/utils/logger.test.ts create mode 100644 src/lib/utils/logger.ts diff --git a/src/lib/utils/logger.test.ts b/src/lib/utils/logger.test.ts new file mode 100644 index 00000000..0ccd14ea --- /dev/null +++ b/src/lib/utils/logger.test.ts @@ -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"); + }); +}); diff --git a/src/lib/utils/logger.ts b/src/lib/utils/logger.ts new file mode 100644 index 00000000..e27a3dc4 --- /dev/null +++ b/src/lib/utils/logger.ts @@ -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 = { + 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), + }; +} From d54d8cc7c43024421645ca064a22be853ef2bb34 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 20 Aug 2026 19:29:59 +0200 Subject: [PATCH 2/2] refactor(logging): route frontend console calls through the logger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TRACES: | DR-204 484 ungated `console.*` calls across 63 non-test frontend files shipped to end users with no way to turn them off. Mechanical substitution, no control flow, error handling or message semantics changed: console.log / console.debug -> log.debug console.info -> log.info console.warn -> log.warn console.error -> log.error Hand-written `"[Scope] …"` prefixes are dropped where the logger's scope now carries them; scope names that already existed are preserved verbatim (`[Auth]`, `[VideoPlayer]`, `[PiP]`, …) and inferred from the filename where a file had none. `src/routes/player/[id]/+page.svelte` keeps its `NextEpisode` and `AutoPlay` sub-scopes as separate loggers rather than flattening them into the page scope. `grep -rn 'console\.' src/` now matches nothing outside the tests and the facade itself. --- src/lib/api/repository-client.ts | 7 +- src/lib/components/FavoriteButton.svelte | 5 +- .../components/downloads/DownloadItem.svelte | 11 +- .../library/AlbumDownloadButton.svelte | 7 +- .../library/ArtistDetailView.svelte | 11 +- .../library/ClearHistoryButton.svelte | 5 +- .../components/library/DownloadButton.svelte | 19 +- .../library/GenericGenreBrowser.svelte | 7 +- .../library/GenericMediaListPage.svelte | 5 +- src/lib/components/library/MediaCard.svelte | 5 +- .../library/PersonDetailView.svelte | 5 +- .../library/PlaylistDetailView.svelte | 15 +- .../library/RelatedItemsSection.svelte | 11 +- .../library/SeasonDownloadButton.svelte | 13 +- .../library/SeriesDownloadButton.svelte | 13 +- src/lib/components/library/TrackList.svelte | 11 +- .../library/VideoDownloadButton.svelte | 17 +- .../library/WatchedToggleButton.svelte | 5 +- src/lib/components/player/AudioPlayer.svelte | 5 +- src/lib/components/player/MiniPlayer.svelte | 7 +- src/lib/components/player/Queue.svelte | 7 +- src/lib/components/player/VideoPlayer.svelte | 211 +++++++++--------- .../playlist/AddToPlaylistModal.svelte | 7 +- .../playlist/CreatePlaylistModal.svelte | 5 +- .../sessions/SessionPickerModal.svelte | 11 +- src/lib/player/adapters/html5Adapter.ts | 5 +- src/lib/player/adapters/rustReportHost.ts | 11 +- src/lib/services/deviceId.ts | 5 +- src/lib/services/favorites.ts | 5 +- src/lib/services/imageCache.ts | 7 +- src/lib/services/networkType.ts | 7 +- src/lib/services/nextEpisodeService.ts | 7 +- src/lib/services/offlineCatalog.ts | 19 +- src/lib/services/playbackCapabilities.ts | 5 +- src/lib/services/playbackReporting.ts | 25 ++- src/lib/services/playerEvents.ts | 33 +-- src/lib/services/preload.ts | 13 +- src/lib/services/syncService.ts | 13 +- src/lib/stores/auth.ts | 81 +++---- src/lib/stores/connectivity.ts | 21 +- src/lib/stores/downloads.ts | 87 ++++---- src/lib/stores/home.ts | 5 +- src/lib/stores/library.ts | 23 +- src/lib/stores/lmsSync.ts | 5 +- src/lib/stores/movies.ts | 9 +- src/lib/stores/music.ts | 9 +- src/lib/stores/playbackMode.ts | 53 ++--- src/lib/stores/queue.ts | 7 +- src/lib/stores/sessions.ts | 49 ++-- src/lib/stores/tv.ts | 9 +- src/lib/utils/backgroundAudio.ts | 10 +- src/lib/utils/haptics.ts | 6 +- src/lib/utils/immersive.ts | 10 +- src/lib/utils/pictureInPicture.ts | 14 +- src/lib/utils/safeArea.ts | 6 +- src/lib/utils/videoSurface.ts | 15 +- src/routes/+layout.svelte | 15 +- src/routes/+page.svelte | 5 +- src/routes/downloads/+page.svelte | 9 +- src/routes/library/[id]/+page.svelte | 29 +-- src/routes/library/favorites/+page.svelte | 5 +- src/routes/player/[id]/+page.svelte | 97 ++++---- src/routes/settings/+page.svelte | 17 +- 63 files changed, 686 insertions(+), 490 deletions(-) diff --git a/src/lib/api/repository-client.ts b/src/lib/api/repository-client.ts index 2c7427d0..71d153a8 100644 --- a/src/lib/api/repository-client.ts +++ b/src/lib/api/repository-client.ts @@ -19,6 +19,9 @@ import type { PlaylistEntry, PlaylistCreatedResult, } from "./types"; +import { createLogger } from "$lib/utils/logger"; + +const log = createLogger("RepositoryClient"); /** * Repository client - thin wrapper over Rust HybridRepository @@ -39,14 +42,14 @@ export class RepositoryClient { accessToken: string, serverId: string ): Promise { - console.log("[RepositoryClient] Creating Rust repository..."); + log.debug("Creating Rust repository..."); this.handle = await commands.repositoryCreate(serverUrl, userId, accessToken, serverId); // Store for URL construction this._serverUrl = serverUrl; this._accessToken = accessToken; - console.log("[RepositoryClient] Repository created with handle:", this.handle); + log.debug("Repository created with handle:", this.handle); return this.handle; } diff --git a/src/lib/components/FavoriteButton.svelte b/src/lib/components/FavoriteButton.svelte index 5ecbe6fa..12f60bf5 100644 --- a/src/lib/components/FavoriteButton.svelte +++ b/src/lib/components/FavoriteButton.svelte @@ -4,6 +4,9 @@ import { haptics } from "$lib/utils/haptics"; import { toast } from "$lib/stores/toast"; import { favoriteOverrides } from "$lib/stores/favorites"; + import { createLogger } from "$lib/utils/logger"; + + const log = createLogger("FavoriteButton"); interface Props { itemId: string; @@ -78,7 +81,7 @@ isAnimating = false; }, 600); } catch (error) { - console.error("Failed to toggle favorite:", error); + log.error("Failed to toggle favorite:", error); toast.show("Failed to update favorites", "error"); isAnimating = false; } finally { diff --git a/src/lib/components/downloads/DownloadItem.svelte b/src/lib/components/downloads/DownloadItem.svelte index c0dd0513..06ec20eb 100644 --- a/src/lib/components/downloads/DownloadItem.svelte +++ b/src/lib/components/downloads/DownloadItem.svelte @@ -1,5 +1,8 @@ diff --git a/src/lib/components/library/AlbumDownloadButton.svelte b/src/lib/components/library/AlbumDownloadButton.svelte index 851e1ce6..32fe5666 100644 --- a/src/lib/components/library/AlbumDownloadButton.svelte +++ b/src/lib/components/library/AlbumDownloadButton.svelte @@ -2,6 +2,9 @@ import { downloads } from "$lib/stores/downloads"; import { auth } from "$lib/stores/auth"; import type { MediaItem } from "$lib/api/types"; + import { createLogger } from "$lib/utils/logger"; + + const log = createLogger("AlbumDownloadButton"); interface Props { albumId: string; @@ -60,7 +63,7 @@ try { const userId = $auth.user?.id; if (!userId) { - console.error("No user ID found"); + log.error("No user ID found"); return; } @@ -95,7 +98,7 @@ await downloads.refresh(userId); } } catch (error) { - console.error("Album download operation failed:", error); + log.error("Album download operation failed:", error); } finally { isProcessing = false; } diff --git a/src/lib/components/library/ArtistDetailView.svelte b/src/lib/components/library/ArtistDetailView.svelte index 41e08aa8..2af02d53 100644 --- a/src/lib/components/library/ArtistDetailView.svelte +++ b/src/lib/components/library/ArtistDetailView.svelte @@ -8,6 +8,9 @@ import CachedImage from "$lib/components/common/CachedImage.svelte"; import FavoriteButton from "$lib/components/FavoriteButton.svelte"; import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites"; + import { createLogger } from "$lib/utils/logger"; + + const log = createLogger("ArtistDetailView"); interface Props { artist: MediaItem; @@ -47,7 +50,7 @@ }); albums = albumsResult.items.filter(item => item.kind === "album"); } catch (e) { - console.warn("Failed to load albums:", e); + log.warn("Failed to load albums:", e); } finally { albumsLoading = false; } @@ -62,7 +65,7 @@ }); topTracks = tracksResult.items.filter(item => item.kind === "track"); } catch (e) { - console.warn("Failed to load tracks:", e); + log.warn("Failed to load tracks:", e); } finally { tracksLoading = false; } @@ -82,14 +85,14 @@ .slice(0, 6); } } catch (e) { - console.warn("Failed to load related artists:", e); + log.warn("Failed to load related artists:", e); } finally { artistsLoading = false; } singlesLoading = false; } catch (e) { - console.error("Error loading artist content:", e); + log.error("Error loading artist content:", e); } } diff --git a/src/lib/components/library/ClearHistoryButton.svelte b/src/lib/components/library/ClearHistoryButton.svelte index 47931605..90f2092b 100644 --- a/src/lib/components/library/ClearHistoryButton.svelte +++ b/src/lib/components/library/ClearHistoryButton.svelte @@ -11,6 +11,9 @@ diff --git a/src/lib/components/library/VideoDownloadButton.svelte b/src/lib/components/library/VideoDownloadButton.svelte index 4bcdb304..3d9677db 100644 --- a/src/lib/components/library/VideoDownloadButton.svelte +++ b/src/lib/components/library/VideoDownloadButton.svelte @@ -3,6 +3,9 @@ import { auth } from "$lib/stores/auth"; import { commands } from "$lib/api/bindings"; import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets"; + import { createLogger } from "$lib/utils/logger"; + + const log = createLogger("VideoDownloadButton"); interface Props { itemId: string; @@ -57,17 +60,17 @@ try { const userId = $auth.user?.id; if (!userId) { - console.error("No user ID found"); + log.error("No user ID found"); return; } 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 const streamUrl = await repo.getVideoDownloadUrl(itemId, quality); - console.log(" Stream URL obtained"); + log.debug(" Stream URL obtained"); // Get target directory const targetDir = await commands.storageGetPath(); @@ -85,7 +88,7 @@ filePath = `videos/${safeName}.mp4`; } - console.log(" File path:", filePath); + log.debug(" File path:", filePath); // Queue download with video metadata const downloadId = await downloads.downloadVideo( @@ -101,16 +104,16 @@ episodeNumber, seasonNumber ); - console.log(" Download queued with ID:", downloadId); + log.debug(" Download queued with ID:", downloadId); // Pin the item metadata await downloads.pinItem(itemId); // Actually start the download await commands.startDownload(downloadId, streamUrl, targetDir); - console.log(" Download started"); + log.debug(" Download started"); } catch (error) { - console.error("Failed to start video download:", error); + log.error("Failed to start video download:", error); } finally { isProcessing = false; } diff --git a/src/lib/components/library/WatchedToggleButton.svelte b/src/lib/components/library/WatchedToggleButton.svelte index 9102b6e0..2b36272d 100644 --- a/src/lib/components/library/WatchedToggleButton.svelte +++ b/src/lib/components/library/WatchedToggleButton.svelte @@ -14,6 +14,9 @@ --> diff --git a/src/lib/components/player/MiniPlayer.svelte b/src/lib/components/player/MiniPlayer.svelte index e0f8185d..2853cd9b 100644 --- a/src/lib/components/player/MiniPlayer.svelte +++ b/src/lib/components/player/MiniPlayer.svelte @@ -38,6 +38,9 @@ import CastButton from "$lib/components/sessions/CastButton.svelte"; import VolumeControl from "./VolumeControl.svelte"; import CachedImage from "../common/CachedImage.svelte"; + import { createLogger } from "$lib/utils/logger"; + + const log = createLogger("MiniPlayer"); interface Props { media: MediaItem | null; @@ -159,7 +162,7 @@ await playerController.seek(newPosition); haptics.tap(); } catch (err) { - console.error("Failed to seek:", err); + log.error("Failed to seek:", err); toast.show("Failed to seek", "error"); } } @@ -230,7 +233,7 @@ // Vertical swipe if (Math.abs(diffY) > swipeThreshold && diffY > 0) { // Swiped up - Open full player - console.log("[MiniPlayer] Swipe-up detected, expanding player"); + log.debug("Swipe-up detected, expanding player"); haptics.tap(); onExpand?.(); } diff --git a/src/lib/components/player/Queue.svelte b/src/lib/components/player/Queue.svelte index 942a4bff..9074202e 100644 --- a/src/lib/components/player/Queue.svelte +++ b/src/lib/components/player/Queue.svelte @@ -6,6 +6,9 @@ import { auth } from "$lib/stores/auth"; import { queue } from "$lib/stores/queue"; import CachedImage from "../common/CachedImage.svelte"; + import { createLogger } from "$lib/utils/logger"; + + const log = createLogger("QueueView"); interface Props { items: MediaItem[]; @@ -82,7 +85,7 @@ // Sync with backend await playerController.moveInQueue(fromIndex, toIndex); } 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 } } @@ -109,7 +112,7 @@ queue.removeFromQueue(index); await playerController.removeFromQueue(index); } catch (err) { - console.error("Failed to remove from queue:", err); + log.error("Failed to remove from queue:", err); } } diff --git a/src/lib/components/player/VideoPlayer.svelte b/src/lib/components/player/VideoPlayer.svelte index 738903a9..0da38583 100644 --- a/src/lib/components/player/VideoPlayer.svelte +++ b/src/lib/components/player/VideoPlayer.svelte @@ -75,6 +75,9 @@ planHandoffReturn, type BackgroundAudioState, } from "./backgroundAudioHandoff"; + import { createLogger } from "$lib/utils/logger"; + + const log = createLogger("VideoPlayer"); interface Props { media: MediaItem | null; @@ -287,11 +290,11 @@ // TRACES: UR-021 | IR-016, JA-009 | DR-024 const audioTracks = $derived(() => { if (!media || !media.mediaStreams) { - console.log("[VideoPlayer] No media or mediaStreams available"); + log.debug("No media or mediaStreams available"); return []; } 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; }); @@ -304,7 +307,7 @@ if (preference.audioTrackDisplayTitle) { const match = tracks.find(t => t.displayTitle === preference.audioTrackDisplayTitle); 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; } } @@ -313,14 +316,14 @@ if (preference.audioTrackLanguage) { const match = tracks.find(t => t.language === preference.audioTrackLanguage); if (match) { - console.log("[VideoPlayer] Matched audio track by language:", match.language); + log.debug("Matched audio track by language:", match.language); return match.index; } } // Fall back to default track 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; } @@ -335,15 +338,15 @@ const preference = await commands.storageGetSeriesAudioPreference(userId, media.seriesId); if (preference) { - console.log("[VideoPlayer] Loaded series audio preference:", preference); + log.debug("Loaded series audio preference:", preference); const matchedIndex = findBestAudioTrack(preference); if (matchedIndex !== null) { selectedAudioTrackIndex = matchedIndex; - console.log("[VideoPlayer] Applied series audio preference, track index:", matchedIndex); + log.debug("Applied series audio preference, track index:", matchedIndex); } } } 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 const subtitleTracks = $derived(() => { if (!media || !media.mediaStreams) { - console.log("[VideoPlayer] No media or mediaStreams available for subtitles"); + log.debug("No media or mediaStreams available for subtitles"); return []; } const tracks = subtitleStreamsOf(media.mediaStreams); - console.log("[VideoPlayer] Found subtitle tracks:", tracks.length, tracks); + log.debug("Found subtitle tracks:", tracks.length, tracks); return tracks; }); @@ -547,7 +550,7 @@ if (isHlsStream && Hls.isSupported()) { // Clean up existing HLS instance if any - CRITICAL for preventing dual audio 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 hls.detachMedia(); // Stop loading and flush buffers @@ -571,7 +574,7 @@ setTimeout(() => { if (!videoElement) return; - console.log('[VideoPlayer] Creating new HLS instance for:', currentStreamUrl); + log.debug('Creating new HLS instance for:', currentStreamUrl); // Create new HLS instance hls = new Hls({ @@ -599,14 +602,14 @@ // Listen for media attached event 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 hls!.loadSource(currentStreamUrl); }); // Listen for manifest parsed event 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 @@ -623,7 +626,7 @@ if (canplayFallbackTimeout) clearTimeout(canplayFallbackTimeout); canplayFallbackTimeout = setTimeout(() => { 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(); } }, 5000); @@ -633,7 +636,7 @@ // Handle errors hls.on(Hls.Events.ERROR, (event, data) => { - console.error('[VideoPlayer] HLS error:', data); + log.error('HLS error:', data); if (data.fatal) { // Is this the stream ending or the stream breaking? Jellyfin's // transcoded HLS doesn't always emit #EXT-X-ENDLIST, so both arrive @@ -650,25 +653,25 @@ attempts: hlsFatalRecoveryAttempts, })) { 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(); break; 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(); break; case 'giveUp': - console.error('[VideoPlayer] Fatal network error, max recovery attempts reached'); + log.error('Fatal network error, max recovery attempts reached'); hls!.destroy(); break; } break; case Hls.ErrorTypes.MEDIA_ERROR: - console.error('[VideoPlayer] Fatal media error, trying to recover'); + log.error('Fatal media error, trying to recover'); hls!.recoverMediaError(); break; default: - console.error('[VideoPlayer] Unrecoverable HLS error'); + log.error('Unrecoverable HLS error'); hls!.destroy(); break; } @@ -678,7 +681,7 @@ // Cleanup on effect re-run return () => { - console.log('[VideoPlayer] Effect cleanup: destroying HLS instance'); + log.debug('Effect cleanup: destroying HLS instance'); if (hls) { hls.detachMedia(); hls.stopLoad(); @@ -691,11 +694,11 @@ }; } else if (isHlsStream && videoElement.canPlayType('application/vnd.apple.mpegurl')) { // Native HLS support (Safari) - console.log('[VideoPlayer] Using native HLS support'); + log.debug('Using native HLS support'); videoElement.src = currentStreamUrl; } else { // 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) { videoElement.muted = false; 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 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) if (selectedAudioTrackIndex === null && audioTracks().length > 0) { const defaultTrack = audioTracks().find(t => t.isDefault); 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) { - console.log("[VideoPlayer] mozHasAudio:", (videoElement as any).mozHasAudio); + log.debug("mozHasAudio:", (videoElement as any).mozHasAudio); } 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; } untrack(() => { - console.log("[VideoPlayer] Initial position changed, seeking to:", pos); + log.debug("Initial position changed, seeking to:", pos); lastAppliedInitialPosition = pos; if (videoElement) { videoElement.currentTime = pos; @@ -775,7 +778,7 @@ selectedQuality = settings.streamingQuality ?? "original"; }) .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 if (media && currentStreamUrl) { try { - console.log("[VideoPlayer] Initializing player for:", media.name); - console.log("[VideoPlayer] Stream URL:", currentStreamUrl); + log.debug("Initializing player for:", media.name); + log.debug("Stream URL:", currentStreamUrl); // Resolve subtitle URLs for the native (ExoPlayer) path. These must be // in hand *before* the play request: ExoPlayer sideloads subtitles as @@ -827,7 +830,7 @@ sentSubtitleTracks = mediaSourceId ? 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 // 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 useHtml5Element = response.useHtml5Element; 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 // the user opted into the experimental native path; otherwise fall back @@ -858,13 +861,13 @@ // just started, or ExoPlayer and the