Files
musicseerr/frontend/src/lib/components/TrackRow.svelte
T
shaunrd0 23c9125ad8
Backend CI / Lint (push) Waiting to run
Backend CI / Tests (push) Waiting to run
Personal fork of habirabbu/musicseerr — multi-instance + inline downloads + lidarr-request
Squashes 26 incremental fork commits (Apr–May 2026) onto upstream main as a single
diff for cleaner cross-fork comparison. Original history preserved on the
pre-squash-backup tag locally.

Feature additions
─────────────────

• Inline single-track download via yt-dlp-worker proxy
  New routes: POST /api/v1/track-download/search (source: youtube | spotify),
  POST /api/v1/track-download, GET /api/v1/track-download/{id}. Frontend
  TrackDownloadButton in album track list AND popular-songs row, with a per-button
  source picker. Per-user rate limits live in the worker's SQLite store. On
  completion the backend fires Lidarr RefreshArtist + Plex library refresh +
  cache invalidation, and the popular-songs list auto-refreshes.

• Per-instance library pinning via MUSICSEERR_LIBRARY env
  Backend stamps the library label server-side (music / music-personal /
  music-shared); clients cannot override. Drives an instance-segregated
  deployment of three musicseerr containers sharing one source tree.

• Lidarr-request flow (single-track requests via Lidarr indexers)
  New routes: POST /api/v1/lidarr-request, GET /api/v1/lidarr-request/status.
  Per-album asyncio.Lock keyed on album_mbid so rapid-clicks on the same album
  serialize correctly. Cross-release track matcher with foreignTrackId →
  foreignRecordingId → position+disc → exact-title → substring fallback chain,
  evaluated per release (recording UUIDs frequently differ between album,
  single, and deluxe edition releases of the same song). Flips
  artist.monitored = True on request so Lidarr's WantedAlbums query reaches
  the track. Full Lidarr-chain gate (artist AND album AND track) for the
  status endpoint to avoid false-positive REQUESTED display. Persistent UI
  state so button icons survive refresh and cross-album navigation.

• Privacy: show_now_playing toggle in Settings → Home
  Default off. Plex /status/sessions returns active audio sessions across the
  whole server with no library-section filter, so a shared instance leaks
  every household member's listening activity. The merged store still emits
  the user's local MusicSeerr playback bar; only server-derived sessions
  (Plex / Jellyfin / Navidrome) are gated.

• Per-button visibility prefs for the track-row action cluster
  Settings → Preferences → Download Options / Playback Buttons. Per-context
  (popular_songs / album_page) force-off flags layered on top of the existing
  source-availability gate.

• UX: wrap action cluster on mobile, hide LidarrRequestButton in tight
  layouts, cross-album status-leak fix in AlbumTrackList ($effect keyed on
  album.musicbrainz_id to rebuild lookup; map keyed by
  "{albumMbid}:{position}:{disc}").

Test coverage
─────────────

Backend pytest: full suite green (2031/2031 as of squash). New: schema-default
tests for HomeSettings, lidarr_request_service cross-release matcher
regression test, singleton-registry expected-count bump to 59. Frontend
vitest: SettingsHome.svelte.spec covers new toggle, nowPlayingSessions
.svelte.spec covers the privacy gate (no fetch when off; fetches when on).
2026-05-29 23:55:54 +00:00

220 lines
6.7 KiB
Svelte

<script lang="ts">
import { albumHref } from '$lib/utils/entityRoutes';
import { Play, Disc3 } from 'lucide-svelte';
import type { TopSong, ResolvedTrack, TrackButtonVisibility } from '$lib/types';
import AlbumImage from './AlbumImage.svelte';
import LastFmPlaceholder from './LastFmPlaceholder.svelte';
import TrackPreviewButton from './TrackPreviewButton.svelte';
import TrackDownloadButton from './TrackDownloadButton.svelte';
import LidarrRequestButton from './LidarrRequestButton.svelte';
import { preferencesStore } from '$lib/stores/preferences';
import type { LidarrButtonStatus } from '$lib/api/lidarrRequest';
interface Props {
song: TopSong;
position: number;
source?: string;
showPreview?: boolean;
ytConfigured?: boolean;
initialCached?: boolean | null;
resolvedTrack?: ResolvedTrack | null;
lidarrStatus?: LidarrButtonStatus;
onPlay?: () => void;
}
let {
song,
position,
source = '',
showPreview = false,
ytConfigured = false,
initialCached = null,
resolvedTrack = null,
lidarrStatus = 'none',
onPlay
}: Props = $props();
let hasAlbum = $derived(!!song.release_group_mbid);
let isLastfmNoAlbum = $derived(!hasAlbum && source === 'lastfm');
let canPlay = $derived(!!resolvedTrack?.source);
let previewEnabled = $derived(showPreview && ytConfigured && !canPlay);
// Worker requires a non-empty album for the file path; bucket release-less
// tracks under "Singles" so the download still lands somewhere sensible.
let downloadAlbum = $derived(song.release_name || 'Singles');
// Subscribe to the user's per-context button-visibility preferences.
// `popular_songs` is the relevant slot for TrackRow (used by the
// Popular Songs panel on artist pages). Defaults all-true so first
// paint matches pre-fork behavior; the server response replaces this
// on load.
let buttonVisibility = $state<TrackButtonVisibility>({
lidarr_request: true,
track_download: true,
preview: true,
yt_play: true,
jellyfin: true,
local_files: true,
navidrome: true,
plex: true
});
preferencesStore.subscribe((prefs) => {
buttonVisibility = prefs.download_options.popular_songs;
});
</script>
{#if hasAlbum}
<div class="flex items-center gap-3 p-2 rounded-lg hover:bg-base-200 transition-colors group">
{#if canPlay}
<button
onclick={onPlay}
class="w-6 shrink-0 flex items-center justify-center cursor-pointer text-primary"
aria-label="Play {song.title} (in library)"
title="In library — click to play"
>
<Play class="w-4 h-4 mx-auto fill-current" />
</button>
{:else if previewEnabled}
<span class="w-6 shrink-0 flex items-center justify-center">
<span class="group-hover:hidden">{position}</span>
<span class="hidden group-hover:block">
<TrackPreviewButton
artist={song.artist_name}
track={song.title}
{ytConfigured}
{initialCached}
size="sm"
albumId={song.release_group_mbid ?? ''}
/>
</span>
</span>
{:else}
<a
href={albumHref(song.release_group_mbid ?? '')}
class="w-6 shrink-0 flex items-center justify-center"
>
<span class="group-hover:hidden text-sm text-base-content/50">{position}</span>
<span class="hidden group-hover:block">
<Play class="w-4 h-4 mx-auto fill-current" />
</span>
</a>
{/if}
<a
href={albumHref(song.release_group_mbid ?? '')}
class="flex items-center gap-3 flex-1 min-w-0 cursor-pointer"
>
<div class="w-12 h-12 shrink-0">
<AlbumImage
mbid={song.release_group_mbid ?? ''}
alt={song.release_name || ''}
size="full"
className="w-12 h-12 rounded"
/>
</div>
<div class="flex-1 min-w-0 grid grid-cols-2 items-center gap-4">
<p class="font-medium text-sm truncate min-w-0">{song.title}</p>
<p class="text-xs text-base-content/60 truncate min-w-0 text-right">
{song.release_name || ''}
</p>
</div>
</a>
<!--
Action cluster — LidarrRequestButton + TrackDownloadButton.
ALWAYS visible at full opacity, no hover dependency. Hover-to-reveal
UX confused users on mobile (no hover state at all) and on desktop
(per-user preference 2026-05-29). The "already in library" affordance
lives in the button title attribute instead.
-->
<div
class="shrink-0 flex items-center gap-1"
title={canPlay ? 'Already in library — download again only if you need a fresh copy' : ''}
>
{#if buttonVisibility.lidarr_request && song.recording_mbid && song.release_group_mbid}
<LidarrRequestButton
albumMbid={song.release_group_mbid}
trackMbid={song.recording_mbid}
trackTitle={song.title}
trackPosition={song.track_number ?? null}
discNumber={song.disc_number ?? null}
initialStatus={lidarrStatus}
size="sm"
/>
{/if}
{#if buttonVisibility.track_download}
<TrackDownloadButton
artist={song.artist_name}
album={downloadAlbum}
trackTitle={song.title}
trackPosition={song.track_number ?? null}
discNumber={song.disc_number ?? null}
size="sm"
/>
{/if}
</div>
</div>
{:else}
<div
class="flex items-center gap-3 p-2 rounded-lg transition-colors group {isLastfmNoAlbum
? 'opacity-75'
: ''}"
>
{#if canPlay}
<button
onclick={onPlay}
class="w-6 shrink-0 flex items-center justify-center cursor-pointer text-primary"
aria-label="Play {song.title} (in library)"
title="In library — click to play"
>
<Play class="w-4 h-4 mx-auto fill-current" />
</button>
{:else if previewEnabled}
<span class="w-6 shrink-0 flex items-center justify-center">
<span class="group-hover:hidden">{position}</span>
<span class="hidden group-hover:block">
<TrackPreviewButton
artist={song.artist_name}
track={song.title}
{ytConfigured}
{initialCached}
size="sm"
/>
</span>
</span>
{:else}
<span class="w-6 text-center text-sm text-base-content/50">{position}</span>
{/if}
{#if isLastfmNoAlbum}
<LastFmPlaceholder />
{:else}
<div class="w-12 h-12 shrink-0 bg-base-200 rounded flex items-center justify-center">
<Disc3 class="w-6 h-6 text-base-content/20" />
</div>
{/if}
<div class="flex-1 min-w-0 grid grid-cols-2 items-center gap-4">
<p class="font-medium text-sm truncate min-w-0">{song.title}</p>
<p class="text-xs text-base-content/40 truncate min-w-0 text-right italic"></p>
</div>
<!-- Always visible at full opacity, matching the canonical cluster above. -->
<div
class="shrink-0"
title={canPlay ? 'Already in library — download again only if you need a fresh copy' : ''}
>
{#if buttonVisibility.track_download}
<TrackDownloadButton
artist={song.artist_name}
album={downloadAlbum}
trackTitle={song.title}
trackPosition={song.track_number ?? null}
discNumber={song.disc_number ?? null}
size="sm"
/>
{/if}
</div>
</div>
{/if}