Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
820 changes: 605 additions & 215 deletions src/backend-api/src/server.ts

Large diffs are not rendered by default.

24 changes: 23 additions & 1 deletion src/backend-api/src/services/spotify-api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,28 @@ export class SpotifyApiService {
}
}

// B6: hard upper bound on a single Spotify SDK call. The SDK's
// underlying fetch has no built-in timeout, and a TCP-level stall
// (no FIN, no RST, just silence from the upstream) would leave this
// promise pending forever. The pendingRequests entry in queueRequest
// never settles, so every subsequent same-key request also hangs —
// and the queue stops processing because isProcessingQueue stays
// true. 20s is generous: the slowest legitimate response we see is
// ~3-4s for an audiobook with hundreds of chapters.
private static readonly SPOTIFY_REQUEST_TIMEOUT_MS = 20000

private async withTimeout<T>(operation: () => Promise<T>, ms: number): Promise<T> {
let timer: NodeJS.Timeout | undefined
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`Spotify request timed out after ${ms}ms`)), ms)
})
try {
return await Promise.race([operation(), timeoutPromise])
} finally {
if (timer) clearTimeout(timer)
}
}

private async rateLimitedRequest<T>(operation: () => Promise<T>): Promise<T> {
// Implement simple rate limiting
const now = Date.now()
Expand All @@ -153,7 +175,7 @@ export class SpotifyApiService {

try {
this.lastRequestTime = Date.now()
return await operation()
return await this.withTimeout(operation, SpotifyApiService.SPOTIFY_REQUEST_TIMEOUT_MS)
} catch (error: any) {
if (error.statusCode === 429) {
// Rate limited - wait and retry
Expand Down
5 changes: 5 additions & 0 deletions src/backend-api/src/services/spotify-media-info.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,16 @@ export class SpotifyMediaInfo {
console.debug(`${logPrefix} Fetching playlist data from Spotify Embed: ${playlistId}`)

const embedUrl = `https://open.spotify.com/embed/playlist/${playlistId}`
// B6: AbortSignal.timeout() ensures a stalled embed-page fetch
// can't hang the foreground request indefinitely (no built-in
// fetch timeout in Node). 20s matches the SDK timeout in
// SpotifyApiService.
const response = await fetch(embedUrl, {
headers: {
'user-agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36',
},
signal: AbortSignal.timeout(20000),
})

if (!response.ok) {
Expand Down
50 changes: 50 additions & 0 deletions src/backend-player/src/spotify-control.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,56 @@ player.on('track-change', () => {
cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_Local.py')
})

player.on('playlist-finish', () => {
// Library album finished naturally — drop its resume entry so the user
// isn't offered "weiterhören" at the very end next time. Spotify and RSS
// are skipped: Spotify gives no clean end-of-album signal via the
// mplayer wrapper anyway, and RSS isn't tracked with enough metadata in
// currentMeta to build a composite key.
deleteResumeForFinishedLibraryAlbum()
})

// POSTs a minimal Media-shape body to /api/deleteresume so the backend-api
// removes the matching resume.json entry by composite key. Best-effort: a
// failure here just leaves a stale resume entry, no playback impact.
function deleteResumeForFinishedLibraryAlbum() {
if (currentMeta.currentPlayer !== 'mplayer') return
if (currentMeta.currentType !== 'local') return
const rawPath = currentMeta.path
if (!rawPath) return
const parts = String(rawPath).split('/')
if (parts.length < 3) return
const body = JSON.stringify({
type: 'library',
artist: decodeURIComponent(parts[1]),
title: decodeURIComponent(parts[2]),
})
const req = http.request(
{
host: '127.0.0.1',
port: 8200,
path: '/api/deleteresume',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
},
(response) => {
response.resume() // drain
log.debug(
`${nowDate.toLocaleString()}: [Spotify Control] deleteresume status=${response.statusCode} for ${rawPath}`,
)
},
)
req.on('error', (err) => {
log.debug(`${nowDate.toLocaleString()}: [Spotify Control] deleteresume failed: ${err.message}`)
})
req.write(body)
req.end()
}


setInterval(() => {
const cmdVolume = "/usr/bin/amixer sget Master | grep 'Right:'"
const exec = require('node:child_process').exec
Expand Down
112 changes: 80 additions & 32 deletions src/frontend-box/src/app/media.service.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { firstValueFrom, from, iif, interval, Observable, of, Subject } from 'rxjs'
import { map, mergeAll, mergeMap, shareReplay, switchMap, toArray } from 'rxjs/operators'
import { catchError, map, mergeAll, mergeMap, shareReplay, switchMap, toArray } from 'rxjs/operators'
import { environment } from '../environments/environment'
import type { AlbumStop } from './albumstop'
import type { Artist } from './artist'
import type { CurrentMPlayer } from './current.mplayer'
import type { CurrentSpotify } from './current.spotify'
import type { CategoryType, Media, MediaInfoCache } from './media'
import { isResumeEntry, type CategoryType, type Media, type MediaInfoCache } from './media'
import { Mupihat } from './mupihat'
import type { Network } from './network'
import { NetworkService } from './network.service'
Expand Down Expand Up @@ -158,25 +158,52 @@ export class MediaService {
}),
shareReplay({ bufferSize: 1, refCount: true }),
)
: // Remote: HTTP polling
: // Remote: HTTP polling.
// B11: a single HTTP failure (network blip, backend restart)
// would error the source observable, and shareReplay would
// forever replay that error to subscribers — UI stops getting
// state updates until the page is reloaded. Wrap the inner
// get in catchError(of({})) so transient failures show as
// "no current state" without tearing down the polling stream.
interval(10000).pipe(
switchMap(
(): Observable<CurrentSpotify> => this.http.get<CurrentSpotify>(`${this.getPlayerBackendUrl()}/state`),
(): Observable<CurrentSpotify> =>
this.http
.get<CurrentSpotify>(`${this.getPlayerBackendUrl()}/state`)
.pipe(catchError(() => of({} as CurrentSpotify))),
),
shareReplay({ bufferSize: 1, refCount: true }),
)
// Same B11 pattern for local$ / albumStop$ / mupihat$ — all polling
// streams that should swallow transient errors instead of becoming
// permanently broken.
this.local$ = interval(1000).pipe(
switchMap((): Observable<CurrentMPlayer> => this.http.get<CurrentMPlayer>(`${this.getPlayerBackendUrl()}/local`)),
switchMap(
(): Observable<CurrentMPlayer> =>
this.http
.get<CurrentMPlayer>(`${this.getPlayerBackendUrl()}/local`)
.pipe(catchError(() => of({} as CurrentMPlayer))),
),
shareReplay({ bufferSize: 1, refCount: true }),
)

this.albumStop$ = interval(1000).pipe(
switchMap((): Observable<AlbumStop> => this.http.get<AlbumStop>(`${this.getApiBackendUrl()}/albumstop`)),
switchMap(
(): Observable<AlbumStop> =>
this.http
.get<AlbumStop>(`${this.getApiBackendUrl()}/albumstop`)
.pipe(catchError(() => of({} as AlbumStop))),
),
shareReplay({ bufferSize: 1, refCount: false }),
)
// Every 2 seconds should be enough for timely charging update.
this.mupihat$ = interval(2000).pipe(
switchMap((): Observable<Mupihat> => this.http.get<Mupihat>(`${this.getApiBackendUrl()}/mupihat`)),
switchMap(
(): Observable<Mupihat> =>
this.http
.get<Mupihat>(`${this.getApiBackendUrl()}/mupihat`)
.pipe(catchError(() => of({} as Mupihat))),
),
shareReplay({ bufferSize: 1, refCount: false }),
)

Expand Down Expand Up @@ -283,18 +310,6 @@ export class MediaService {
})
}

editRawResumeAtIndex(index: number, data: Media) {
const url = `${this.getApiBackendUrl()}/editresume`
const body = {
index,
data,
}

this.http.post(url, body, { responseType: 'text' }).subscribe((response) => {
this.response = response
})
}

addRawResume(media: Media) {
const url = `${this.getApiBackendUrl()}/addresume`

Expand Down Expand Up @@ -411,9 +426,18 @@ export class MediaService {

public fetchActiveResumeData(): Observable<Media[]> {
// Category is irrelevant if 'resume' is set to true.
// Sort by lastPlayedAt DESC so "most recently played" is at position 1.
// Previously the page used a blind `.reverse()` of the array, which
// matches the file insertion order — but addresume updates existing
// entries in place (preserving their position) so a freshly-played
// album never moved to the top until it was a *new* entry. Items
// without a timestamp (legacy entries pre-migration) sort to 0 and
// land at the bottom; the backend back-fills synthetic stamps
// preserving original order on the next addresume so this is
// self-healing.
return this.updateMedia(`${this.getApiBackendUrl()}/activeresume`, true, 'resume').pipe(
map((media: Media[]) => {
return media.reverse()
return [...media].sort((a, b) => (b.lastPlayedAt ?? 0) - (a.lastPlayedAt ?? 0))
}),
)
}
Expand All @@ -424,18 +448,25 @@ export class MediaService {

// Get the media data for the current category from the server
private updateMedia(url: string, resume: boolean, category: CategoryType): Observable<Media[]> {
// Custom rxjs pipe to override artist.
// Custom rxjs pipe applied to every iif-branch's service-call output.
// Carries the original item's user-relevant fields onto the Media that
// the spotify/rss/library service builds out of upstream API data:
// - artist: optional user-defined override
// - lastPlayedAt: ResumePage sorts DESC by this; spotify.service's
// getMediaByID etc. don't accept it as a param, so without this carry
// the field gets dropped on every resume entry that goes through a
// service call. fetchActiveResumeData's sort then sees zeros and the
// user's most-recently-played item ends up at a random swiper position.
// - isResume: marks resume entries; same loss-on-service-call risk.
const overwriteArtist =
(item: Media) =>
(source$: Observable<Media[]>): Observable<Media[]> => {
return source$.pipe(
// If the user entered an user-defined artist name in addition to a query,
// overwrite orignal artist from spotify.
map((items) => {
if (item.artist?.length > 0) {
for (const currentItem of items) {
currentItem.artist = item.artist
}
for (const currentItem of items) {
if (item.artist?.length > 0) currentItem.artist = item.artist
if (typeof item.lastPlayedAt === 'number') currentItem.lastPlayedAt = item.lastPlayedAt
if (item.isResume === true) currentItem.isResume = true
}
return items
}),
Expand Down Expand Up @@ -472,13 +503,13 @@ export class MediaService {
.pipe(overwriteArtist(item)),
iif(
// Get media by show
() => !!(item.showid && item.showid.length > 0 && item.category !== 'resume'),
() => !!(item.showid && item.showid.length > 0 && !isResumeEntry(item)),
this.spotifyService
.getMediaByShowID(item.showid, item.category, item.index, item)
.pipe(overwriteArtist(item)),
iif(
// Get media by show supporting resume
() => !!(item.showid && item.showid.length > 0 && item.category === 'resume'),
() => !!(item.showid && item.showid.length > 0 && isResumeEntry(item)),
this.spotifyService
.getMediaByEpisode(
item.showid,
Expand Down Expand Up @@ -513,8 +544,19 @@ export class MediaService {
overwriteArtist(item),
),
iif(
// Get media by rss feed
() => !!(item.type === 'rss' && item.id.length > 0 && item.category !== 'resume'),
// Get media by rss feed.
// MED-10 attempted to enrich RSS resume entries with
// fresh feed data, but the `id` of a RSS resume entry
// is the *episode's MP3 URL*, not the channel feed
// URL — the enrichment fetch streamed the MP3 audio
// (multi-MB) into the rss-parser path before MED-2's
// size-cap aborted with 413. Six RSS resume entries
// = ~24 s freeze on the resume page. Reinstate the
// resume-skip gate: every persisted field needed for
// the resume tile (title, cover, artistcover, release
// date, duration, progress) is already on disk; no
// network round-trip needed for resume rendering.
() => !!(item.type === 'rss' && item.id.length > 0 && !isResumeEntry(item)),
this.rssFeedService
.getRssFeed(item.id, item.category, item.index, item)
.pipe(overwriteArtist(item)),
Expand Down Expand Up @@ -699,7 +741,13 @@ export class MediaService {
mediaType,
}

return mediaInfo
// MED-7: cache-hit branch (line 645+) returns this.mediaInfoCache
// which has currentId + mediaType, but the previous miss-branch
// returned the raw mediaInfo without those fields. Callers that
// checked `result.mediaType` saw different shapes depending on
// whether the entry was already cached. Return the cache object
// we just wrote so the shape is consistent across hits and misses.
return this.mediaInfoCache
}
} catch (error) {
console.warn('Failed to get media info for URI:', contextUri, error)
Expand Down
26 changes: 26 additions & 0 deletions src/frontend-box/src/app/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export interface Media {
cover?: string
type: string
category: CategoryType
// Marks this Media as a resume entry. New code uses this flag exclusively;
// the historical convention of overwriting `category` with the literal
// 'resume' is still recognised on read for entries written by older
// versions, but no longer produced.
isResume?: boolean
artistcover?: string
shuffle?: boolean
aPartOfAll?: boolean
Expand All @@ -36,8 +41,29 @@ export interface Media {
resumelocalcurrentTracknr?: number
resumelocalprogressTime?: number
resumerssprogressTime?: number
// Marks an item whose Spotify metadata fetch failed (network blip,
// region lock, removed from catalogue, etc.). Set by spotify.service's
// catchError fallbacks so the item still occupies its slot in the list
// instead of silently vanishing — callers / templates can render it
// greyed-out or with an "unavailable" badge later.
unavailable?: boolean
// Set by /api/addresume to Date.now() on every save. Frontend sorts the
// resume page by this DESC so "most recently played" lands at position 1
// even when the entry was already in the file (addresume's update-in-
// place pattern leaves the array index untouched). Optional because
// pre-existing entries written before this field was introduced will be
// back-filled lazily by the backend with synthetic stamps preserving
// file order.
lastPlayedAt?: number
}

// Reads as "is this Media a resume entry?" — true for entries written by the
// new isResume-flag path AND for legacy entries where category was overwritten
// with 'resume'. Use everywhere instead of bare category comparisons so the
// same filter works through the migration window.
export const isResumeEntry = (m: Pick<Media, 'isResume' | 'category'> | null | undefined): boolean =>
!!m && (m.isResume === true || m.category === 'resume')

// Cache interface for storing album/playlist/show/audiobook information
export interface MediaInfoCache {
total_tracks?: number
Expand Down
18 changes: 16 additions & 2 deletions src/frontend-box/src/app/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Media } from './media'

export type ExtraDataMedia = Pick<
Media,
'artistcover' | 'shuffle' | 'aPartOfAll' | 'aPartOfAllMin' | 'aPartOfAllMax' | 'sorting'
'artistcover' | 'shuffle' | 'aPartOfAll' | 'aPartOfAllMin' | 'aPartOfAllMax' | 'sorting' | 'lastPlayedAt'
>

export namespace Utils {
Expand All @@ -13,7 +13,21 @@ export namespace Utils {
* @param target - The target to which the values of the properties will be copied.
*/
export const copyExtraMediaData = (source: ExtraDataMedia, target: Media): void => {
const keys = ['artistcover', 'shuffle', 'aPartOfAll', 'aPartOfAllMin', 'aPartOfAllMax', 'sorting']
// lastPlayedAt MUST be in this list: media.service.updateMedia replaces
// every resume entry with a Spotify/RSS-derived Media. If lastPlayedAt
// doesn't survive the round-trip, fetchActiveResumeData's DESC sort
// sees only zeros and the resume page falls back to mergeMap-completion
// order — which makes the most-recently-played item appear at a random
// position (typically the right end of the swiper).
const keys = [
'artistcover',
'shuffle',
'aPartOfAll',
'aPartOfAllMin',
'aPartOfAllMax',
'sorting',
'lastPlayedAt',
]
for (const key of keys) {
if (source[key] != null) {
target[key] = source[key]
Expand Down