Skip to content

Frontend Reliability Polish — API-correctness, SDK single-flight, F5-survival#149

Open
wowa1990 wants to merge 12 commits into
splitti:mainfrom
wowa1990:pr-frontend-reliability-polish
Open

Frontend Reliability Polish — API-correctness, SDK single-flight, F5-survival#149
wowa1990 wants to merge 12 commits into
splitti:mainfrom
wowa1990:pr-frontend-reliability-polish

Conversation

@wowa1990

Copy link
Copy Markdown

Was es macht

  • Spotify-Service API-Correctness-Patches (MED-7/8/9/14): URLs, pagination, error-wrapping
  • RSS-Resume-Enrichment (MED-10/11)
  • Spotify-Player single-flight SDK-Load (MED-12, verhindert doppelte SDK-Initialisierung)
  • home.page distinctUntilChanged (MED-13)
  • medialist überlebt F5 und App-Resume (MED-18/21)
  • wifi.page Keyboard-Cleanup + Race-Window (MED-19 / LOW-9/10)
  • player.service shareReplay refCount (MED-20)
  • artwork / rssfeed / settings / swiper Polish (LOW-5..8)
  • spotify.php OAuth-Re-Auth flag-flip
  • Throttle activity + drop BehaviorSubject replay
  • parsers.js, log.service, logout.php Cleanup
  • Frontend-Reliability-Cluster B10/B11/B12/B15

Architektur (kurz)

Keine API-Änderungen, kein Migration-Layer. Patches sind individuell klein und thematisch nicht in den anderen PRs reingerutscht — bewusst als „Sammlung restlicher Polish-Patches" gebündelt damit du sie en bloc sichten kannst, statt als Einzel-PRs.

Test in Codespaces / lokal

  1. Branch, Setup
  2. Konkrete Tests:
    • F5 mitten in einer Spotify-Album-Liste → Liste kommt zurück, Scroll-Position weg (akzeptierter Trade-off), keine Endlos-Spinner
    • Wifi-Setup: SSID + Passwort eingeben → erwartet keine Race auf „Verbindung herstellen", Keyboard schließt sich
    • Player-Service-Subscribers prüfen (z.B. Status-Bar + Player-Page parallel offen) → nur ein WebSocket / Polling-Stream pro Service
    • OAuth abgelaufen lassen → erwartet sauberer Re-Auth-Flow, kein White-Screen

Hinweis: App-Resume-Test (Android/iOS PWA) nur auf echtem Device möglich.

Offene Punkte

  • Ein paar Patches (activity throttle, BehaviorSubject ohne replay) ändern subtil das Timing — wenn dir das zu „untestbar" ist, gerne explizit ausnehmen.

wowa1990 added 12 commits May 16, 2026 16:12
Four narrow bugs in the Spotify API client layer that all surfaced as
"this album sometimes shows weird data."

MED-14 (pagination skips items on short first page). fetchAllPaginated-
Results computed every subsequent offset as `page * pageSize`, which
assumes the first page came back full. When the first response was
short — Spotify can server-side-filter for market / availability, or
some endpoints just return fewer-than-pageSize for the last available
page — the second page started at pageSize instead of
firstPageItems.length. Every item between firstPageItems.length and
pageSize was silently skipped. Anchor offsets to the actual length of
the first page; if the first page was full this still yields pageSize,
2*pageSize, … as before.

MED-9 (episode.show is an object, not an array). getMediaByEpisode
read `episode.show?.[0]?.name` and got undefined for every episode.
Per Spotify's /episodes/{id} response shape, `show` is a single
object (`{ name, … }`), not an array. Resume entries for podcasts
always rendered "Unknown Show" because of this typo. One-character
fix: `episode.show?.name`.

MED-8 (getPlaylistInfo crashed on null tracks). Spotify keeps the
slot in tracks.items when a track is removed from the playlist or is
unavailable in the user's market; `item.track` is null in that slot.
The previous code mapped over items without filtering and threw a
TypeError on `item.track.id`. The whole getPlaylistInfo observable
errored out and the caller's playlist-load failed entirely. Filter
null-track slots before mapping; the totals already exclude them.

MED-7 (mediaInfoCache shape inconsistency between hit and miss).
The cache-hit branch returned `this.mediaInfoCache` (which has
currentId and mediaType set), but the cache-miss branch returned the
raw mediaInfo (without those fields). Callers that checked
`result.mediaType` saw different shapes depending on whether the entry
was cached. Return the just-written cache object so hit and miss
agree.

All four are pure observation/logic fixes — no API contract changes,
no new feature flags, type-checks pass.
…h RSS resume (MED-10/11)

Two complementary fixes to the resume-list rendering path.

MED-11: getMediaByID, getAudiobookByID, getMediaByEpisode and
getMediaByPlaylistID all returned `EMPTY` from their catchError
branches when the Spotify API call failed. The downstream forkJoin /
combineLatest then silently dropped the slot, so an item that errored
out (network blip, region lock, removed from catalogue) just
disappeared from the user's resume list with no indication anything
went wrong. The user assumes the box "lost" their progress.

Add `unavailable?: boolean` to the Media interface and a
`placeholderMedia()` helper on SpotifyService that constructs a
minimal stand-in carrying the identifying field (id / audiobookid /
showid / playlistid), category + index for list position, any resume
timestamps already known, and a default placeholder cover. Each of
the four catchError branches now returns this placeholder via
`of(...)` instead of EMPTY. The slot stays in the list; templates can
later add an "unavailable" badge based on the flag without rendering
having to change today (the placeholder cover + "Nicht verfügbar"
title produces a reasonable fallback even with current templates).

MED-10: media.service.ts iif() chain gated RSS-feed enrichment on
`!isResumeEntry(item)`. RSS resume entries skipped the rssFeedService
fetch and rendered with whatever stale title/cover/episode-list was
saved at last play. Episodes that had moved or been removed simply
showed wrong data. Drop the gate so RSS resume items also pull fresh
feed data; overwriteArtist() on the resulting observable preserves
the user's chosen artist label.

Type-checks pass; no API contract changes.
…2 / LOW-4)

Three small but cumulative leaks in the Spotify SDK plumbing.

MED-12 (loadSDKScript ghost timeout). The 15-second SDK-load timeout
captured `this` and ran via setTimeout but its handle was never
stored, so a successful load (resolve fired before 15s) still left
the timer ticking until the deadline. Worse, the timeout's
`if (sdkState === 'loading')` guard prevented a double-reject but
not the timer itself — every SDK load created a closure that survived
for 15s. Capture the handle and clear it in the resolve / reject /
error paths via a `settle()` wrapper so the cleanup runs exactly
once.

LOW-4a (concurrent loadSDKScript). Two callers racing to initialise
the SDK each registered their own onSpotifyWebPlaybackSDKReady, so
the second registration overwrote the first. The first promise
never resolved. Add `sdkLoadInflight` as a single-flight gate: a
second call returns the same in-flight promise, and the gate clears
on settlement so a follow-up load (after a network blip) can start
fresh.

LOW-4b (waitForConnection ghost timeout). Same pattern — the
fallback setTimeout's handle wasn't stored, so connections that
arrived 1ms before the timeout still kept the closure alive until
deadline. Same fix: store the handle, clear on success path,
finish() guards against double-resolve.

No type-checks broken; no API surface changes (the public
isConnected$ / playerState$ / sdkState etc. behaviour is preserved).
…ch storm (MED-13)

The artists observable was driven by combineLatest of [category,
isOnline]. combineLatest emits whenever EITHER input changes, so a
short Wi-Fi blip (online → offline → online → offline → online over
a few seconds — common on a Pi with marginal signal) triggered four
fetchArtistData calls in quick succession. /api/data + the Spotify
endpoints all got hit in parallel, the swiper reset four times, and
the home page jittered.

isOnline was being mapped away (`map(([cat, _]) => cat)`) and only
used as a re-fetch trigger. Add distinctUntilChanged after the map so
we collapse identical-category emissions; we now fetch only when the
user actually switches tabs. The auto-refresh on going-back-online
is lost, but fetchArtistData reads the server-side cache anyway and
the user can tap the same category tab to force a refresh.

Type-checks pass.
…ows/RSS (MED-18 / MED-21)

Two bugs in the medialist page that hit different user paths.

MED-21 (crash on F5 / app-resume / kiosk-restart). The constructor
read `this.router.currentNavigation()?.extras.state?.artist`, which
returns null on any non-router navigation — browser refresh, kiosk
restart preserving the URL, Capacitor app-resume. The signal then got
set to undefined, the template's `{{artist().name}}` threw an NPE,
and the page rendered as a white screen until the user navigated
away and back.

Fall back to `history.state` (the browser persists this across
reloads), and if NEITHER source has the artist, redirect to /home
instead of trying to render an empty page. Also tighten the template
to `{{artist()?.name}}` so the brief tick before redirectNavigateByUrl
completes can't crash either.

MED-18 (aPartOfAllMin/Max off-by-one for shows / RSS). Eltern enter
"Episodes 5-10" via the admin UI; for audiobooks (alphabetical sort,
filesystem returns alpha order) this happened to work because slice-
post-sort with offsetByOne=true produced indices 4-9 of the alpha
array. But for shows / RSS the array was sorted ReleaseDateDescending
FIRST, then sliced with offsetByOne=false — so picking 5-10 returned
indices 5-10 of the descending array, i.e. the 6th-through-11th
newest episodes, not Episode 5 through Episode 10.

Restructure to slice on the API's native order (chronological for RSS
shows, alpha for filesystem audiobooks), THEN sort the slice for
display. Same semantics for both, no offsetByOne flag, and the
1-indexed-to-0-indexed conversion is documented in-line.

Type-checks pass.
…ation (MED-19 / LOW-9 / LOW-10)

Three small fixes to the same page that all manifest under fast-paced
admin use.

MED-19 (simple-keyboard listener leak). simple-keyboard attaches several
DOM listeners (mousedown, touchstart, etc.) when constructed. The
component never called .destroy(), so every mount/unmount cycle of the
wifi page added another set of listeners and kept the keyboard instance
+ closures reachable. Three or four enter/leave cycles in admin use was
enough to be visible in DevTools heap snapshots. Implement OnDestroy
and call this.keyboard?.destroy() in ngOnDestroy.

LOW-9 (init-race). focusChanged / inputChanged / validate / submit /
clearInput all dereferenced this.keyboard directly. The keyboard is
constructed in ngAfterViewInit, so any input event that fires BEFORE
view-init (rare, but possible if a child input auto-focuses or the
user navigates very quickly) hit a null reference and crashed the
handler. Optional-chain every keyboard access — null-keyboard now just
short-circuits to the default value.

LOW-10 (submit without re-validation). submit() was wired up to the
form button, which Ionic only enables when this.valid is true. But
this.valid was last computed when validate() last ran, which could
have been before the user typed more. Add a fresh validate() call at
the top of submit() and bail if !this.valid. Belt and braces with
add_wifi.sh's HIGH-12 SSID validation, but UX-wise the user gets
immediate "no" instead of "POST succeeded but nothing happened."

Type-checks pass.
playerService.getConfig() was cached via the legacy
publishReplay(1) + refCount() combo, which keeps the source
observable alive only while at least one subscriber is connected.
mupihat-icon components mount and unmount as the user navigates
between admin tabs — each remount briefly drops the subscriber count
to zero, which terminates the source observable. The next mount then
re-fires the underlying http.get to /api/sonos.

With three mupihat-icon instances spread across toolbar / footer /
medialist views, every navigation re-fetched the config three times.

Switch to shareReplay({ bufferSize: 1, refCount: false }) so the
cached value survives the zero-subscriber gap between unmount and
remount. The config only changes via setting_update.sh (which
restarts pm2 anyway), so the lifetime cache is fine.

Type-checks pass.
…/7/8)

Four small cleanups, none individually load-bearing but they accumulate.

LOW-5 (artwork.service Observable never completes). getArtwork /
getArtistArtwork built `new Observable(observer => observer.next(url))`
without ever calling observer.complete(). Subscribers stayed open
forever and only got cleaned up when the component was destroyed.
With one swiper showing 30 cards on a long-running kiosk, that's 30
subscriptions per page open. Switch to `of(value)` which is
synchronous, single-emit, and completes properly.

LOW-7 (rssfeed.service log spam + uncaught error). The map() body
ran `console.log(item)` once per RSS item — every feed load dumped
hundreds of lines of raw RSS into chrome_debug.log, which we ship via
debug.php for support tickets. Drop the log. Also add a catchError
that returns an empty Media[] so a feed-fetch failure (network blip,
malformed XML) doesn't error the whole observable and crash the
medialist page.

LOW-6 (settings.page no error handler on shutdown/reboot POST).
`this.http.post('/api/shutdown', {}).subscribe()` swallowed any
network / 500 error silently. The Ionic alert dismissed and the user
got zero feedback that the action didn't fire. Add an error logger
so the failure at least lands in chrome_debug.log for diagnosis.

LOW-8 (swiper stale index after data change). cachedSwiperPosition
was captured on ionViewWillLeave and restored unconditionally on
re-enter via slideTo(cached). If the data set shrank while the page
was hidden (audiobook removed, category switched, etc.), slideTo
landed on an out-of-range index. Clamp to `Math.min(cached, len-1)`
so we restore to the last valid slide rather than nothing.

Type-checks pass.
Re-running the Connect-Spotify OAuth flow set accessToken and
refreshToken in mupiboxconfig.json but left spotify.active untouched.
If an admin had previously set active=false (e.g. as a debugging
workaround when the previous tokens were broken), the new tokens
silently don't take effect — the frontend continues to hide Spotify
content, the admin assumes the re-auth also failed, and ends up with
a "Spotify is broken even after I just re-authorised" mystery.

Set active=true unconditionally would re-enable Spotify even if the
OAuth call returned an error blob (no access_token in the response),
which would resurrect the SDK-init-loop-stuck-state from the previous
broken-token regime. Gate the flip on both tokens being non-empty so
a failed OAuth leaves active alone.
…haviorSubject replay (LOW-4 A23/A24)

Two leftover sub-bugs from LOW-4 that the Phase 4 commit explicitly
deferred — both small and unrelated except for living in the spotify-
player adjacent stack.

A23 (display-manager.service activity tracking).
  - The pipe between activityDebouncer and the resetIdleTimer call
    was empty, despite a `// Debounce activity events` comment that
    suggested otherwise. Every mousemove and touchmove tick fired a
    fresh resetIdleTimer call. The work itself was cheap (one
    Date.now() write) but woke the JS event loop hundreds of times
    a second on a busy touchscreen drag. Add throttleTime(500ms);
    a 1-minute idle threshold has plenty of headroom for half-second
    granularity.
  - ngOnDestroy was dead code. The service is providedIn: 'root',
    so Angular keeps it alive for the entire app lifetime —
    ngOnDestroy never fires. Removed the cleanup block and noted
    the lifetime assumption in a comment so the next person
    touching this file doesn't mistake it for a missing teardown.
    The `document` listeners stay attached forever, which is fine
    since `document` lives just as long.

A24 (external-playback-navigator BehaviorSubject replay).
  - trackChangeDetected$ was a BehaviorSubject<Track|null>, which
    replays the last value to every new subscriber. external-
    playback-navigator subscribes once on construction and would
    receive the LAST detected track immediately on any HMR reload
    or service re-mount — and then auto-navigate to /player even
    though the user had since walked away to another page.
    Switch to plain Subject so subscribers only see events that
    fire AFTER they subscribed. Verified all three consumers
    (external-playback-navigator, media.service, spotify.service
    re-export) only .next()/.subscribe() — no .value reads — so
    the swap doesn't break anything.

Type-checks pass.
…14 / R3-B-4)

Three small defensive cleanups, none functional changes in the
common path but each one removes a sharp edge a future bug could
land on.

B7 — parsers.js (mplayer slave-protocol response parsing).
  - parseString did `str.slice(1, -1)` blindly. Quotes around
    mplayer's get_meta_* responses are CONVENTIONAL but not
    guaranteed (different mplayer builds emit different framing,
    edge cases like empty-string answers); a non-quoted response
    would silently lose its first and last char. Check for the
    "…" wrapper and only strip when it's present.
  - parseStringList split mplayer's metadata response on `,` —
    which goes wrong as soon as a field VALUE contains a comma
    (album titles like "Foo, Vol. 2" or composer names like
    "Bach, Johann Sebastian"). Re-join all fragments between two
    known meta-prop markers as the value, so commas inside values
    are preserved instead of treated as delimiters.
  - parseFlag now also accepts non-string input safely (mplayer
    glitches sometimes hand us numbers or null).

B14 — log.service.ts HTTP-logging recursion risk.
  - sendBufferedLogs already used console.warn directly (correct).
    Added a method-level comment explaining why this MUST stay
    that way: this.warn → bufferLogEntry → queues a HTTP POST →
    on failure the catchError would re-enter sendBufferedLogs →
    infinite loop. The property `console.warn` (NOT `this.warn`)
    annotation hangs on the two existing call sites.

R3-B-4 — logout.php session cookie.
  - session_destroy() alone clears server-side session storage
    but leaves the PHPSESSID cookie alive on the client. Next
    request the browser sends the same id, PHP rehydrates an
    empty session under it, and other tabs still believe they
    are authenticated. Best practice: also expire the cookie via
    setcookie(name, '', past-time, …) with the original cookie
    parameters, and clear $_SESSION first so destroy doesn't
    leave any data even briefly.

Type-checks pass.
- B10 spotify.service: 15s timeout() on 8 metadata HTTP calls so a
  hung Spotify backend doesn't block the current$ polling loop or
  album/show/audiobook lookups indefinitely (catchError handles the
  resulting TimeoutError).
- B11 media.service: catchError on current$/local$/albumStop$/mupihat$
  HTTP polling streams — a single 5xx no longer tears down the
  shareReplay-backed observable and silently kills polling.
- B12 add.page: m3uStreamfetcher save() now awaits the resolved URL
  inside the loading-indicator's then() (race fixed; save() was
  previously firing before the stream URL was resolved).
- B15 network.service: isOnline() seeded with startWith(false) so
  toSignal()-based consumers don't hang at initialValue forever
  during the cold-boot 300ms-to-5s window before /api/network's
  first response.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant