feat(plex): checkpoint plex lan integration and quality sync

cx
This commit is contained in:
vicrodh committed 2026-02-09 01:28:11 -06:00
1 parent d2531ea700
commit 228563454b
28 files changed
+2596 -107

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "qbz-nix",
"version": "1.1.8-rc-5",
"version": "1.1.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "qbz-nix",
"version": "1.1.8-rc-5",
"version": "1.1.9",
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",
+1
View File
@@ -936,6 +936,7 @@ async fn play_album(
album_id: Some(album.id.clone()),
artist_id: t.performer.as_ref().map(|p| p.id),
streamable: t.streamable,
source: Some("qobuz".to_string()),
}
}).collect()
} else {
@@ -37,6 +37,7 @@ pub fn set_playback_context(
let content_source = match source.as_str() {
"qobuz" => ContentSource::Qobuz,
"local" => ContentSource::Local,
"plex" => ContentSource::Plex,
_ => return Err(format!("Invalid source: {}", source)),
};
+1
View File
@@ -327,6 +327,7 @@ fn track_to_queue_track(track: &Track) -> QueueTrack {
album_id,
artist_id,
streamable: track.streamable,
source: Some("qobuz".to_string()),
}
}
+17
View File
@@ -26,6 +26,7 @@ pub mod musicbrainz;
pub mod network;
pub mod offline;
pub mod playback_context;
pub mod plex;
pub mod player;
pub mod playlist_import;
pub mod queue;
@@ -672,6 +673,22 @@ pub fn run() {
cast::dlna::commands::dlna_stop,
cast::dlna::commands::dlna_seek,
cast::dlna::commands::dlna_set_volume,
// Plex LAN-only POC commands
plex::plex_ping,
plex::plex_get_music_sections,
plex::plex_get_section_tracks,
plex::plex_play_track,
plex::plex_auth_pin_start,
plex::plex_auth_pin_check,
plex::plex_open_auth_url,
plex::plex_cache_get_sections,
plex::plex_cache_save_sections,
plex::plex_cache_get_tracks,
plex::plex_cache_save_tracks,
plex::plex_cache_get_albums,
plex::plex_cache_get_album_tracks,
plex::plex_cache_search_tracks,
plex::plex_cache_clear,
// AirPlay casting commands - DISABLED until RAOP implementation is complete
// See docs/AIRPLAY_IMPLEMENTATION_STATUS.md for details
// cast::airplay::commands::airplay_start_discovery,
+1
View File
@@ -32,6 +32,7 @@ pub enum ContextType {
pub enum ContentSource {
Qobuz,
Local,
Plex,
}
/// Playback context - the semantic origin of playback
File diff suppressed because it is too large. Load diff
+3
View File
@@ -33,6 +33,9 @@ pub struct QueueTrack {
/// Whether the track is streamable on Qobuz (false = removed/unavailable)
#[serde(default = "default_streamable")]
pub streamable: bool,
/// Optional origin source (e.g. "qobuz", "local", "plex")
#[serde(default)]
pub source: Option<String>,
}
fn default_streamable() -> bool {
+25 -4
View File
@@ -27,6 +27,8 @@ pub struct PersistedQueueTrack {
pub artist_id: Option<u64>,
#[serde(default = "default_streamable")]
pub streamable: bool,
#[serde(default)]
pub source: Option<String>,
}
/// Represents the full persisted session state
@@ -110,7 +112,8 @@ impl SessionStore {
artwork_url TEXT,
hires INTEGER NOT NULL DEFAULT 0,
bit_depth INTEGER,
sample_rate REAL
sample_rate REAL,
source TEXT
);
-- Insert default row if not exists
@@ -158,6 +161,22 @@ impl SessionStore {
);
}
let has_source: bool = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('queue_tracks') WHERE name = 'source'",
[],
|row| row.get(0),
)
.unwrap_or(0) > 0;
if !has_source {
let _ = conn.execute_batch(
"
ALTER TABLE queue_tracks ADD COLUMN source TEXT;
"
);
}
Ok(Self { conn })
}
@@ -181,8 +200,8 @@ impl SessionStore {
// Insert queue tracks
for (pos, track) in session.queue_tracks.iter().enumerate() {
if let Err(e) = self.conn.execute(
"INSERT INTO queue_tracks (position, track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate, is_local, album_id, artist_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
"INSERT INTO queue_tracks (position, track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate, is_local, album_id, artist_id, source)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![
pos as i64,
track.id as i64,
@@ -197,6 +216,7 @@ impl SessionStore {
track.is_local as i64,
track.album_id,
track.artist_id.map(|v| v as i64),
track.source,
],
) {
let _ = self.conn.execute("ROLLBACK", []);
@@ -258,7 +278,7 @@ impl SessionStore {
// Load queue tracks
let mut stmt = self.conn
.prepare("SELECT track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate, is_local, album_id, artist_id FROM queue_tracks ORDER BY position")
.prepare("SELECT track_id, title, artist, album, duration_secs, artwork_url, hires, bit_depth, sample_rate, is_local, album_id, artist_id, source FROM queue_tracks ORDER BY position")
.map_err(|e| format!("Failed to prepare queue query: {}", e))?;
let tracks: Vec<PersistedQueueTrack> = stmt
@@ -277,6 +297,7 @@ impl SessionStore {
album_id: row.get(10)?,
artist_id: row.get::<_, Option<i64>>(11)?.map(|v| v as u64),
streamable: true, // Default to true for persisted tracks
source: row.get(12)?,
})
})
.map_err(|e| format!("Failed to query queue tracks: {}", e))?
+19 -5
View File
@@ -35,8 +35,8 @@
onOpenContainingFolder?: () => void;
onReDownloadAlbum?: () => void;
downloadStateVersion?: number;
/** Source badge for local library: 'user' for local files, 'qobuz_download' for Qobuz offline */
sourceBadge?: 'user' | 'qobuz_download';
/** Source badge for local library: 'user' | 'qobuz_download' | 'plex' */
sourceBadge?: 'user' | 'qobuz_download' | 'plex';
}
let {
@@ -202,7 +202,7 @@
<!-- Image overlays placeholder when loaded -->
{#if !imageError && artwork}
<img src={artwork} alt={title} loading="lazy" decoding="async" onerror={handleImageError} />
<img class="artwork-image" src={artwork} alt={title} loading="lazy" decoding="async" onerror={handleImageError} />
{/if}
<!-- Action Overlay -->
@@ -262,9 +262,15 @@
<!-- Source Badge (Local Library only) -->
{#if sourceBadge}
<div class="source-badge" class:local-badge={sourceBadge === 'user'} title={sourceBadge === 'user' ? 'Local file' : 'Qobuz offline'}>
<div
class="source-badge"
class:local-badge={sourceBadge === 'user'}
title={sourceBadge === 'user' ? 'Local file' : sourceBadge === 'plex' ? 'Plex library' : 'Qobuz offline'}
>
{#if sourceBadge === 'user'}
<HardDrive size={14} />
{:else if sourceBadge === 'plex'}
<img src="/plex-logo.svg" alt="Plex" class="qobuz-badge-icon plex-logo-icon" />
{:else}
<img src="/qobuz-logo-filled.svg" alt="Qobuz" class="qobuz-badge-icon" />
{/if}
@@ -310,7 +316,7 @@
overflow: hidden;
}
.artwork-container img {
.artwork-container .artwork-image {
position: absolute;
inset: 0;
width: 100%;
@@ -365,6 +371,7 @@
width: 24px;
height: 24px;
color: var(--text-secondary);
border-radius: 4px;
}
.source-badge.local-badge {
@@ -378,6 +385,13 @@
height: 24px;
}
.source-badge .plex-logo-icon {
width: 18px;
height: 18px;
object-fit: contain;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.45));
}
.action-overlay {
position: absolute;
inset: -1px;
@@ -17,7 +17,7 @@
bit_depth?: number;
sample_rate: number;
directory_path: string;
source?: string; // 'user' for local files, 'qobuz_download' for offline cached
source?: string; // 'user' | 'qobuz_download' | 'plex'
}
interface AlbumGroup {
@@ -324,7 +324,7 @@
onPlayNext={() => onAlbumQueueNext(album)}
onPlayLater={() => onAlbumQueueLater(album)}
onclick={() => onAlbumClick(album)}
sourceBadge={showSourceBadge ? (album.source === 'qobuz_download' ? 'qobuz_download' : 'user') : undefined}
sourceBadge={showSourceBadge ? (album.source === 'plex' ? 'plex' : (album.source === 'qobuz_download' ? 'qobuz_download' : 'user')) : undefined}
/>
{/each}
</div>
+8 -1
View File
@@ -912,7 +912,14 @@
<div class="header-actions">
<GenreFilterButton onFilterChange={handleGenreFilterChange} />
<button class="settings-btn" onclick={() => isSettingsModalOpen = true} title={$t('home.customizeHome')}>
<img src="/home-gear.svg" alt="Settings" class="settings-icon" />
<img
src="/home-gear.svg"
alt="Settings"
class="settings-icon"
width="22"
height="22"
style="width:22px;height:22px;filter:invert(1) opacity(0.8);"
/>
</button>
</div>
</div>
+365 -55
View File
@@ -72,6 +72,7 @@
artwork_path?: string;
last_modified: number;
indexed_at: number;
source?: string; // 'user' | 'qobuz_download' | 'plex'
}
interface LocalAlbum {
@@ -88,7 +89,56 @@
bit_depth?: number;
sample_rate: number;
directory_path: string;
source?: string; // 'user' for local files, 'qobuz_download' for offline cached
source?: string; // 'user' | 'qobuz_download' | 'plex'
}
interface PlexCachedAlbum {
id: string;
title: string;
artist: string;
artworkPath?: string;
trackCount: number;
totalDurationSecs: number;
format: string;
bitDepth?: number;
sampleRate: number;
source: string;
}
interface PlexCachedTrack {
id: number;
ratingKey: string;
title: string;
artist: string;
album: string;
durationSecs: number;
format: string;
bitDepth?: number;
sampleRate: number;
artworkPath?: string;
source: string;
albumKey: string;
}
interface PlexMusicSection {
key: string;
title: string;
}
interface PlexTrack {
ratingKey: string;
title: string;
artist?: string;
album?: string;
durationMs?: number;
artworkPath?: string;
partKey?: string;
container?: string;
codec?: string;
channels?: number;
bitrateKbps?: number;
samplingRateHz?: number;
bitDepth?: number;
}
interface LocalArtist {
@@ -158,6 +208,8 @@
isPlaybackActive?: boolean;
}
const PLEX_METADATA_WRITE_KEY = 'qbz-plex-poc-metadata-write-enabled';
let {
onAlbumClick,
onQobuzArtistClick,
@@ -319,7 +371,7 @@
if (sourceFiltersActive) {
const source = album.source ?? 'user';
if (filterLocalFiles && source === 'user') passesSource = true;
if (filterLocalFiles && (source === 'user' || source === 'plex')) passesSource = true;
if (filterOfflineCache && source === 'qobuz_download') passesSource = true;
}
@@ -832,6 +884,9 @@
let loading = $state(false);
let scanning = $state(false);
let error = $state<string | null>(null);
let plexRepairAttempted = $state(false);
let plexRepairInProgress = $state(false);
let plexRepairQueued = $state(false);
let fetchingArtwork = $state(false);
let updatingArtwork = $state(false);
let hasDiscogsCredentials = $state(false);
@@ -971,19 +1026,21 @@
// If not found in loaded albums, we need to fetch album list first
if (!album) {
const allAlbums = await invoke<LocalAlbum[]>('library_get_albums', {
includeHidden: false,
excludeNetworkFolders: shouldExcludeNetworkFolders()
});
const [localAlbums, plexAlbumsRaw] = await Promise.all([
invoke<LocalAlbum[]>('library_get_albums', {
includeHidden: false,
excludeNetworkFolders: shouldExcludeNetworkFolders()
}),
invoke<PlexCachedAlbum[]>('plex_cache_get_albums').catch(() => [])
]);
const allAlbums = [...localAlbums, ...plexAlbumsRaw.map(mapPlexAlbum)];
albums = allAlbums;
album = allAlbums.find(a => a.id === albumId);
}
if (album) {
selectedAlbum = album;
albumTracks = await invoke<LocalTrack[]>('library_get_album_tracks', {
albumGroupKey: album.id
});
albumTracks = await fetchAlbumTracks(album);
}
} catch (err) {
console.error('Failed to load album:', err);
@@ -1035,33 +1092,219 @@
}
}
async function loadLibraryData() {
console.log('[LocalLibrary] loadLibraryData START, isOffline:', isOffline);
loading = true;
error = null;
function buildPlexArtworkUrl(path: string): string {
const baseUrl = getUserItem('qbz-plex-poc-base-url') || '';
const token = getUserItem('qbz-plex-poc-token') || '';
if (!baseUrl || !token) return path;
const base = baseUrl.replace(/\/+$/, '');
const separator = path.includes('?') ? '&' : '?';
return `${base}${path}${separator}X-Plex-Token=${encodeURIComponent(token)}`;
}
function normalizePlexAlbumTitle(artist: string, album: string): string {
const artistName = artist.trim();
const albumTitle = album.trim();
if (!artistName || !albumTitle) return albumTitle;
for (const sep of [' - ', ' — ', ' – ', ': ']) {
const prefix = `${artistName}${sep}`;
if (albumTitle.startsWith(prefix)) {
return albumTitle.slice(prefix.length).trim();
}
}
return albumTitle;
}
function isLikelyLegacyPlexCache(plexAlbums: PlexCachedAlbum[]): boolean {
if (plexAlbums.length < 20) return false;
const suspicious = plexAlbums.filter(album =>
album.title.trim().toLowerCase() === album.artist.trim().toLowerCase()
).length;
return suspicious >= 20 || suspicious / plexAlbums.length >= 0.2;
}
async function repairLegacyPlexCache(): Promise<boolean> {
if (plexRepairInProgress) return false;
const baseUrl = getUserItem('qbz-plex-poc-base-url') || '';
const token = getUserItem('qbz-plex-poc-token') || '';
if (!baseUrl || !token) return false;
plexRepairInProgress = true;
try {
const startedAt = performance.now();
const sections = await invoke<PlexMusicSection[]>('plex_get_music_sections', {
baseUrl,
token
});
await invoke('plex_cache_save_sections', { serverId: null, sections });
const sectionMetrics = await Promise.all(
sections.map(async (section) => {
const sectionStart = performance.now();
const sectionTracks = await invoke<PlexTrack[]>('plex_get_section_tracks', {
baseUrl,
token,
sectionKey: section.key
});
await invoke('plex_cache_save_tracks', {
serverId: null,
sectionKey: section.key,
tracks: sectionTracks
});
return {
key: section.key,
tracks: sectionTracks.length,
ms: performance.now() - sectionStart
};
})
);
const totalTracks = sectionMetrics.reduce((sum, item) => sum + item.tracks, 0);
const elapsedMs = performance.now() - startedAt;
console.log('[PlexBench] cache repair completed', {
sections: sections.length,
totalTracks,
elapsedMs: Number(elapsedMs.toFixed(1)),
slowestSections: sectionMetrics
.slice()
.sort((a, b) => b.ms - a.ms)
.slice(0, 3)
.map((item) => ({ key: item.key, tracks: item.tracks, ms: Number(item.ms.toFixed(1)) }))
});
return true;
} catch (err) {
console.error('[LocalLibrary] Failed to auto-repair Plex cache:', err);
return false;
} finally {
plexRepairInProgress = false;
}
}
async function queuePlexRepairInBackground() {
if (plexRepairQueued || plexRepairInProgress) return;
plexRepairQueued = true;
try {
const repaired = await repairLegacyPlexCache();
if (repaired) {
await loadLibraryData({ background: true });
}
} finally {
plexRepairQueued = false;
}
}
function mapPlexAlbum(plexAlbum: PlexCachedAlbum): LocalAlbum {
const artist = plexAlbum.artist?.trim() || 'Unknown Artist';
const title = normalizePlexAlbumTitle(artist, plexAlbum.title || 'Unknown Album');
return {
id: plexAlbum.id,
title: title || 'Unknown Album',
artist,
artwork_path: plexAlbum.artworkPath,
track_count: plexAlbum.trackCount,
total_duration_secs: plexAlbum.totalDurationSecs,
format: plexAlbum.format,
bit_depth: plexAlbum.bitDepth,
sample_rate: plexAlbum.sampleRate,
directory_path: '',
source: 'plex'
};
}
function mapPlexTrack(plexTrack: PlexCachedTrack): LocalTrack {
const artist = plexTrack.artist?.trim() || 'Unknown Artist';
const album = normalizePlexAlbumTitle(artist, plexTrack.album || 'Unknown Album');
return {
id: plexTrack.id,
file_path: plexTrack.ratingKey,
title: plexTrack.title,
artist,
album: album || 'Unknown Album',
album_artist: artist,
album_group_key: plexTrack.albumKey,
album_group_title: album || 'Unknown Album',
track_number: undefined,
disc_number: undefined,
duration_secs: plexTrack.durationSecs,
format: plexTrack.format,
bit_depth: plexTrack.bitDepth,
sample_rate: plexTrack.sampleRate,
channels: 2,
file_size_bytes: 0,
artwork_path: plexTrack.artworkPath,
last_modified: 0,
indexed_at: 0,
source: 'plex'
};
}
async function loadLibraryData(options: { background?: boolean } = {}) {
const background = options.background === true;
const startedAt = performance.now();
console.log('[LocalLibrary] loadLibraryData START, isOffline:', isOffline, 'background:', background);
if (!background) {
loading = true;
error = null;
}
try {
const excludeNetwork = shouldExcludeNetworkFolders();
console.log('[LocalLibrary] Calling library_get_albums with excludeNetwork:', excludeNetwork);
const albumsResult = await invoke<LocalAlbum[]>('library_get_albums', {
includeHidden: false,
excludeNetworkFolders: excludeNetwork
});
console.log('[LocalLibrary] Received albums:', albumsResult.length);
console.log('[LocalLibrary] Calling library_get_stats');
const statsResult = await invoke<LibraryStats>('library_get_stats');
const fetchStart = performance.now();
const [albumsResult, statsResult, plexAlbumsRaw] = await Promise.all([
invoke<LocalAlbum[]>('library_get_albums', {
includeHidden: false,
excludeNetworkFolders: excludeNetwork
}),
invoke<LibraryStats>('library_get_stats'),
invoke<PlexCachedAlbum[]>('plex_cache_get_albums').catch(() => [])
]);
const fetchMs = performance.now() - fetchStart;
let workingPlexAlbumsRaw = plexAlbumsRaw;
const shouldAttemptRepair =
!plexRepairAttempted &&
(isLikelyLegacyPlexCache(workingPlexAlbumsRaw) || workingPlexAlbumsRaw.length <= 1);
if (shouldAttemptRepair) {
plexRepairAttempted = true;
void queuePlexRepairInBackground();
}
const mapStart = performance.now();
const plexAlbums = workingPlexAlbumsRaw.map(mapPlexAlbum);
const mapMs = performance.now() - mapStart;
console.log('[LocalLibrary] Received albums:', albumsResult.length, 'plex albums:', plexAlbums.length);
console.log('[LocalLibrary] Received stats:', statsResult);
albums = albumsResult;
stats = statsResult;
console.log('[LocalLibrary] loadLibraryData COMPLETE');
const plexTrackCount = plexAlbums.reduce((sum, album) => sum + album.track_count, 0);
const plexDurationSecs = plexAlbums.reduce((sum, album) => sum + album.total_duration_secs, 0);
albums = [...albumsResult, ...plexAlbums];
stats = {
...statsResult,
track_count: statsResult.track_count + plexTrackCount,
album_count: statsResult.album_count + plexAlbums.length,
total_duration_secs: statsResult.total_duration_secs + plexDurationSecs
};
console.log('[PlexBench] loadLibraryData', {
background,
localAlbums: albumsResult.length,
plexAlbums: plexAlbums.length,
fetchMs: Number(fetchMs.toFixed(1)),
mapMs: Number(mapMs.toFixed(1)),
totalMs: Number((performance.now() - startedAt).toFixed(1)),
repairQueued: shouldAttemptRepair
});
} catch (err) {
console.error('[LocalLibrary] Failed to load library:', err);
error = String(err);
if (!background) {
error = String(err);
}
} finally {
console.log('[LocalLibrary] Setting loading = false');
loading = false;
if (!background) {
console.log('[LocalLibrary] Setting loading = false');
loading = false;
}
}
}
@@ -1122,11 +1365,44 @@
console.log('[LocalLibrary] loadArtists START');
loading = true;
try {
console.log('[LocalLibrary] Calling library_get_artists');
artists = await invoke<LocalArtist[]>('library_get_artists', {
excludeNetworkFolders: shouldExcludeNetworkFolders()
});
console.log('[LocalLibrary] Received artists:', artists.length);
console.log('[LocalLibrary] Calling library_get_artists + plex_cache_get_albums');
const [localArtists, plexAlbumsRaw] = await Promise.all([
invoke<LocalArtist[]>('library_get_artists', {
excludeNetworkFolders: shouldExcludeNetworkFolders()
}),
invoke<PlexCachedAlbum[]>('plex_cache_get_albums').catch(() => [])
]);
const plexArtistMap = new Map<string, { albumCount: number; trackCount: number }>();
for (const rawAlbum of plexAlbumsRaw) {
const name = (rawAlbum.artist || '').trim();
if (!name) continue;
const current = plexArtistMap.get(name) ?? { albumCount: 0, trackCount: 0 };
current.albumCount += 1;
current.trackCount += rawAlbum.trackCount ?? 0;
plexArtistMap.set(name, current);
}
const mergedArtistMap = new Map<string, LocalArtist>();
for (const localArtist of localArtists) {
mergedArtistMap.set(localArtist.name, { ...localArtist });
}
for (const [name, metrics] of plexArtistMap) {
const existing = mergedArtistMap.get(name);
if (existing) {
existing.album_count += metrics.albumCount;
existing.track_count += metrics.trackCount;
} else {
mergedArtistMap.set(name, {
name,
album_count: metrics.albumCount,
track_count: metrics.trackCount
});
}
}
artists = Array.from(mergedArtistMap.values()).sort((a, b) => a.name.localeCompare(b.name));
console.log('[LocalLibrary] Received artists:', artists.length, 'local:', localArtists.length, 'plex:', plexArtistMap.size);
// Load cached artist images from database
await loadCachedArtistImages();
// Fetch missing images in background if enabled
@@ -1147,13 +1423,19 @@
console.log('[LocalLibrary] loadTracks START, query:', query);
loading = true;
try {
console.log('[LocalLibrary] Calling library_search');
tracks = await invoke<LocalTrack[]>('library_search', {
query,
limit: 0, // 0 = no limit, virtualization handles any list size
excludeNetworkFolders: shouldExcludeNetworkFolders()
});
console.log('[LocalLibrary] Received tracks:', tracks.length);
console.log('[LocalLibrary] Calling library_search + plex_cache_search_tracks');
const [localTracks, plexTracksRaw] = await Promise.all([
invoke<LocalTrack[]>('library_search', {
query,
limit: 0, // 0 = no limit, virtualization handles any list size
excludeNetworkFolders: shouldExcludeNetworkFolders()
}),
invoke<PlexCachedTrack[]>('plex_cache_search_tracks', {
query
}).catch(() => [])
]);
tracks = [...localTracks, ...plexTracksRaw.map(mapPlexTrack)];
console.log('[LocalLibrary] Received tracks:', tracks.length, 'local:', localTracks.length, 'plex:', plexTracksRaw.length);
} catch (err) {
console.error('[LocalLibrary] Failed to load tracks:', err);
error = String(err);
@@ -1505,9 +1787,7 @@
// Also load album data immediately for responsive UI
selectedAlbum = album;
try {
albumTracks = await invoke<LocalTrack[]>('library_get_album_tracks', {
albumGroupKey: album.id
});
albumTracks = await fetchAlbumTracks(album);
} catch (err) {
console.error('Failed to load album tracks:', err);
}
@@ -1541,6 +1821,7 @@
async function handleTrackPlay(track: LocalTrack) {
try {
const trackSource = track.source === 'plex' ? 'plex' : 'local';
if (selectedAlbum && albumTracks.length > 0) {
const trackIndex = albumTracks.findIndex(t => t.id === track.id);
const trackIds = albumTracks.map(t => t.id);
@@ -1550,7 +1831,7 @@
'local_library',
selectedAlbum.id,
selectedAlbum.title,
'local',
selectedAlbum.source === 'plex' ? 'plex' : 'local',
trackIds,
trackIndex >= 0 ? trackIndex : 0
);
@@ -1558,24 +1839,30 @@
await setQueueForAlbumTracks(albumTracks, trackIndex >= 0 ? trackIndex : 0);
} else if (activeTab === 'tracks' && tracks.length > 0) {
const orderedTracks = getDisplayedTrackOrder();
const trackIndex = orderedTracks.findIndex(t => t.id === track.id);
const trackIds = orderedTracks.map(t => t.id);
const sameSourceTracks = orderedTracks.filter(item => (item.source === 'plex') === (trackSource === 'plex'));
const trackIndex = sameSourceTracks.findIndex(item => item.id === track.id);
const trackIds = sameSourceTracks.map(item => item.id);
// Set playback context for Local Library tracks view
await setPlaybackContext(
'local_library',
'local-tracks',
trackSource === 'plex' ? 'plex-tracks' : 'local-tracks',
'Local Tracks',
'local',
trackSource,
trackIds,
trackIndex >= 0 ? trackIndex : 0
);
await setQueueForLocalTracks(orderedTracks, trackIndex >= 0 ? trackIndex : 0);
await setQueueForLocalTracks(sameSourceTracks, trackIndex >= 0 ? trackIndex : 0);
}
await invoke('library_play_track', { trackId: track.id });
onTrackPlay?.(track);
if (onTrackPlay) {
await Promise.resolve(onTrackPlay(track));
} else if (trackSource === 'local') {
await invoke('library_play_track', { trackId: track.id });
} else {
throw new Error('Plex playback handler not available');
}
} catch (err) {
console.error('Failed to play track:', err);
alert(`Failed to play: ${err}`);
@@ -1616,6 +1903,13 @@
async function fetchAlbumTracks(album: LocalAlbum): Promise<LocalTrack[]> {
try {
if (album.source === 'plex') {
const plexTracks = await invoke<PlexCachedTrack[]>('plex_cache_get_album_tracks', {
albumKey: album.id
});
return plexTracks.map(mapPlexTrack);
}
return await invoke<LocalTrack[]>('library_get_album_tracks', {
albumGroupKey: album.id
});
@@ -1629,6 +1923,7 @@
console.log('[LocalLibrary Queue] Setting queue with', tracks.length, 'tracks, startIndex:', startIndex);
const queueTracks = tracks.map(t => ({
source: t.source === 'plex' ? 'plex' : 'local',
id: t.id,
title: t.title,
artist: t.artist,
@@ -1638,7 +1933,7 @@
hires: (t.bit_depth && t.bit_depth > 16) || t.sample_rate > 44100,
bit_depth: t.bit_depth ?? null,
sample_rate: t.sample_rate ?? null,
is_local: true,
is_local: t.source !== 'plex',
album_id: null, // Local tracks don't have Qobuz IDs
artist_id: null,
}));
@@ -1647,7 +1942,7 @@
console.log('[LocalLibrary Queue] Track IDs:', queueTracks.map(t => t.id));
await invoke('set_queue', { tracks: queueTracks, startIndex });
onSetLocalQueue?.(tracks.map(t => t.id));
onSetLocalQueue?.(tracks.filter(t => t.source !== 'plex').map(t => t.id));
console.log('[LocalLibrary Queue] Queue set successfully');
}
@@ -1724,6 +2019,10 @@
function openAlbumEditModal() {
if (!selectedAlbum) return;
if (selectedAlbum.source === 'plex' && getUserItem(PLEX_METADATA_WRITE_KEY) !== 'true') {
showToast($t('settings.integrations.plexWriteDisabledNotice'), 'info');
return;
}
editingAlbumHidden = false;
albumMetadataRefreshed = false;
discogsImageOptions = [];
@@ -1959,6 +2258,8 @@
function getArtworkUrl(path?: string): string {
if (!path) return '';
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/library/')) return buildPlexArtworkUrl(path);
// For grid/list views, prefer thumbnails
const cachedThumb = thumbnailUrlCache.get(path);
@@ -1991,6 +2292,8 @@
// For album detail view, always use full resolution
function getFullArtworkUrl(path?: string): string {
if (!path) return '';
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/library/')) return buildPlexArtworkUrl(path);
const cached = artworkUrlCache.get(path);
if (cached) return cached;
const url = convertFileSrc(path);
@@ -2590,7 +2893,14 @@
<ArrowLeft size={16} />
<span>{$t('library.backToLibrary')}</span>
</button>
<button class="edit-btn" onclick={openAlbumEditModal} title={$t('actions.edit')}>
<button
class="edit-btn"
onclick={openAlbumEditModal}
title={selectedAlbum?.source === 'plex' && getUserItem(PLEX_METADATA_WRITE_KEY) !== 'true'
? $t('settings.integrations.plexWriteDisabledNotice')
: $t('actions.edit')}
disabled={selectedAlbum?.source === 'plex' && getUserItem(PLEX_METADATA_WRITE_KEY) !== 'true'}
>
<Edit3 size={16} />
</button>
</div>
@@ -2675,7 +2985,7 @@
onPlayNow: () => handleTrackPlay(track),
onPlayNext: onTrackPlayNext ? () => onTrackPlayNext(track) : undefined,
onPlayLater: onTrackPlayLater ? () => onTrackPlayLater(track) : undefined,
onAddToPlaylist: onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined
onAddToPlaylist: onTrackAddToPlaylist && track.source !== 'plex' ? () => onTrackAddToPlaylist(track.id) : undefined
}}
/>
{/each}
@@ -3364,7 +3674,7 @@
onPlayNext={() => handleAlbumQueueNextFromGrid(album)}
onPlayLater={() => handleAlbumQueueLaterFromGrid(album)}
onclick={() => handleAlbumClick(album)}
sourceBadge={album.source === 'qobuz_download' ? 'qobuz_download' : 'user'}
sourceBadge={album.source === 'plex' ? 'plex' : (album.source === 'qobuz_download' ? 'qobuz_download' : 'user')}
/>
{/each}
</div>
+575 -1
View File
@@ -133,6 +133,42 @@
url: string;
}
interface PlexServerInfo {
friendlyName?: string | null;
version?: string | null;
machineIdentifier?: string | null;
}
interface PlexMusicSection {
key: string;
title: string;
}
interface PlexPinStartResult {
pinId: number;
code: string;
authUrl: string;
expiresIn?: number | null;
}
interface PlexPinCheckResult {
authorized: boolean;
expired: boolean;
authToken?: string | null;
expiresIn?: number | null;
}
interface PlexTrack {
ratingKey: string;
title: string;
artist?: string | null;
album?: string | null;
durationMs?: number | null;
artworkPath?: string | null;
bitDepth?: number | null;
samplingRateHz?: number | null;
}
let {
onBack,
onLogout,
@@ -694,6 +730,29 @@
let showRemoteControlGuide = $state(false);
let remoteControlCollapsed = $state(true);
// Plex LAN POC state
let plexBaseUrl = $state(getUserItem('qbz-plex-poc-base-url') || 'http://127.0.0.1:32400');
let plexToken = $state(getUserItem('qbz-plex-poc-token') || '');
let plexMetadataWriteEnabled = $state(getUserItem('qbz-plex-poc-metadata-write-enabled') === 'true');
let plexSections = $state<PlexMusicSection[]>([]);
let plexTracks = $state<PlexTrack[]>([]);
let plexSelectedSectionKey = $state('');
let plexStatusKey = $state('settings.integrations.plexStatusIdle');
let plexStatusValues = $state<Record<string, string | number>>({});
let plexBusy = $state(false);
let plexLastError = $state('');
let plexAuthBusy = $state(false);
let plexAuthPinId = $state<number | null>(null);
let plexAuthCode = $state('');
let plexAuthUrl = $state('');
let plexAuthClientId = $state(getUserItem('qbz-plex-poc-client-id') || '');
let plexAuthPollTimer: ReturnType<typeof setInterval> | null = null;
const PLEX_CACHE_SELECTED_SECTION_KEY = 'qbz-plex-poc-selected-section';
const PLEX_CACHE_SERVER_ID_KEY = 'qbz-plex-poc-machine-id';
const PLEX_CLIENT_ID_KEY = 'qbz-plex-poc-client-id';
const PLEX_METADATA_WRITE_KEY = 'qbz-plex-poc-metadata-write-enabled';
// Load saved settings on mount
onMount(() => {
// Load theme
@@ -772,6 +831,10 @@
// Load remote control status
loadRemoteControlStatus();
// Warm-start Plex panel from local cache and refresh in background
void loadPlexCachedState();
void refreshPlexInBackground();
// Load notification preferences
loadToastsPreference();
toastsEnabled = getToastsEnabled();
@@ -839,6 +902,9 @@
settingsViewEl?.addEventListener('scroll', handleScroll);
return () => {
if (plexAuthPollTimer) {
clearInterval(plexAuthPollTimer);
}
unsubscribeOffline();
unsubscribeZoom();
unsubscribeTitleBar();
@@ -1050,6 +1116,336 @@
}
}
function persistPlexConfig() {
setUserItem('qbz-plex-poc-base-url', plexBaseUrl.trim());
setUserItem('qbz-plex-poc-token', plexToken.trim());
}
function handlePlexMetadataWriteToggle(enabled: boolean) {
plexMetadataWriteEnabled = enabled;
setUserItem(PLEX_METADATA_WRITE_KEY, enabled ? 'true' : 'false');
}
function ensurePlexClientId(): string {
if (plexAuthClientId) return plexAuthClientId;
const generated = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
? `qbz-${crypto.randomUUID()}`
: `qbz-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
plexAuthClientId = generated;
setUserItem(PLEX_CLIENT_ID_KEY, generated);
return generated;
}
async function handlePlexConnectEasy() {
if (plexAuthBusy) return;
plexAuthBusy = true;
plexLastError = '';
try {
const clientIdentifier = ensurePlexClientId();
const pin = await invoke<PlexPinStartResult>('plex_auth_pin_start', {
clientIdentifier
});
plexAuthPinId = pin.pinId;
plexAuthCode = pin.code;
plexAuthUrl = pin.authUrl;
plexStatusKey = 'settings.integrations.plexStatusAuthPending';
plexStatusValues = { code: pin.code };
if (plexAuthPollTimer) {
clearInterval(plexAuthPollTimer);
}
plexAuthPollTimer = setInterval(async () => {
if (!plexAuthPinId) return;
try {
const check = await invoke<PlexPinCheckResult>('plex_auth_pin_check', {
clientIdentifier,
pinId: plexAuthPinId,
code: plexAuthCode || null
});
if (check.authorized && check.authToken) {
plexToken = check.authToken;
persistPlexConfig();
plexStatusKey = 'settings.integrations.plexStatusAuthConnected';
plexStatusValues = {};
if (plexAuthPollTimer) {
clearInterval(plexAuthPollTimer);
plexAuthPollTimer = null;
}
plexAuthPinId = null;
plexAuthCode = '';
void handlePlexPing();
} else if (check.expired) {
plexStatusKey = 'settings.integrations.plexStatusAuthExpired';
plexStatusValues = {};
if (plexAuthPollTimer) {
clearInterval(plexAuthPollTimer);
plexAuthPollTimer = null;
}
plexAuthPinId = null;
plexAuthCode = '';
}
} catch (error) {
console.warn('Plex auth polling failed:', error);
}
}, 2500);
} catch (error) {
console.error('Failed starting Plex easy connect:', error);
setPlexError(error);
} finally {
plexAuthBusy = false;
}
}
function handleOpenPlexAuthUrl() {
if (!plexAuthUrl) return;
invoke('plex_open_auth_url', { url: plexAuthUrl }).catch((error) => {
console.error('Failed opening Plex auth URL:', error);
setPlexError(error);
});
}
async function handleCopyPlexCode() {
if (!plexAuthCode) return;
try {
await copyToClipboard(plexAuthCode);
showToast(get(t)('settings.integrations.plexCodeCopied'), 'success');
} catch (error) {
console.error('Failed copying Plex code:', error);
showToast(get(t)('settings.integrations.plexCodeCopyFailed'), 'error');
}
}
async function handlePlexDisconnect() {
const confirmed = await ask(get(t)('settings.integrations.plexDisconnectConfirmDesc'), {
title: get(t)('settings.integrations.plexDisconnectConfirmTitle'),
kind: 'warning',
okLabel: get(t)('settings.integrations.plexDisconnectConfirmOk'),
cancelLabel: get(t)('actions.cancel')
});
if (!confirmed) return;
if (plexAuthPollTimer) {
clearInterval(plexAuthPollTimer);
plexAuthPollTimer = null;
}
plexAuthPinId = null;
plexAuthCode = '';
plexAuthUrl = '';
plexAuthBusy = false;
plexToken = '';
plexSections = [];
plexTracks = [];
plexSelectedSectionKey = '';
plexStatusKey = 'settings.integrations.plexStatusDisconnected';
plexStatusValues = {};
plexLastError = '';
removeUserItem('qbz-plex-poc-token');
removeUserItem(PLEX_CACHE_SELECTED_SECTION_KEY);
removeUserItem(PLEX_CACHE_SERVER_ID_KEY);
try {
await invoke('plex_cache_clear');
} catch (error) {
console.warn('Failed clearing Plex cache:', error);
}
}
async function handlePlexClearCache() {
const confirmed = await ask(get(t)('settings.integrations.plexClearCacheConfirmDesc'), {
title: get(t)('settings.integrations.plexClearCacheConfirmTitle'),
kind: 'warning',
okLabel: get(t)('settings.integrations.plexClearCacheConfirmOk'),
cancelLabel: get(t)('actions.cancel')
});
if (!confirmed) return;
try {
await invoke('plex_cache_clear');
plexSections = [];
plexTracks = [];
plexSelectedSectionKey = '';
plexStatusKey = 'settings.integrations.plexStatusCacheCleared';
plexStatusValues = {};
plexLastError = '';
removeUserItem(PLEX_CACHE_SELECTED_SECTION_KEY);
removeUserItem(PLEX_CACHE_SERVER_ID_KEY);
} catch (error) {
console.error('Failed clearing Plex cache:', error);
setPlexError(error);
}
}
async function loadPlexCachedState() {
try {
const cachedSections = await invoke<PlexMusicSection[]>('plex_cache_get_sections');
if (Array.isArray(cachedSections) && cachedSections.length > 0) {
plexSections = cachedSections;
}
const cachedSelectedSection = getUserItem(PLEX_CACHE_SELECTED_SECTION_KEY);
if (cachedSelectedSection) {
plexSelectedSectionKey = cachedSelectedSection;
}
const cachedTracks = await invoke<PlexTrack[]>('plex_cache_get_tracks', {
sectionKey: plexSelectedSectionKey || null
});
if (Array.isArray(cachedTracks) && cachedTracks.length > 0) {
plexTracks = cachedTracks;
plexStatusKey = 'settings.integrations.plexStatusCacheLoaded';
plexStatusValues = { count: cachedTracks.length };
}
} catch (error) {
console.warn('Failed to load Plex cached state:', error);
}
}
async function refreshPlexInBackground() {
if (!plexBaseUrl.trim() || !plexToken.trim()) return;
try {
const info = await invoke<PlexServerInfo>('plex_ping', {
baseUrl: plexBaseUrl.trim(),
token: plexToken.trim()
});
if (info.machineIdentifier) {
setUserItem(PLEX_CACHE_SERVER_ID_KEY, info.machineIdentifier);
}
const sections = await invoke<PlexMusicSection[]>('plex_get_music_sections', {
baseUrl: plexBaseUrl.trim(),
token: plexToken.trim()
});
plexSections = sections;
await invoke<number>('plex_cache_save_sections', {
serverId: info.machineIdentifier ?? null,
sections
});
if (sections.length > 0 && !sections.some((section) => section.key === plexSelectedSectionKey)) {
plexSelectedSectionKey = sections[0].key;
}
if (plexSelectedSectionKey) {
setUserItem(PLEX_CACHE_SELECTED_SECTION_KEY, plexSelectedSectionKey);
}
if (!plexSelectedSectionKey) return;
const tracks = await invoke<PlexTrack[]>('plex_get_section_tracks', {
baseUrl: plexBaseUrl.trim(),
token: plexToken.trim(),
sectionKey: plexSelectedSectionKey
});
plexTracks = tracks;
await invoke<number>('plex_cache_save_tracks', {
serverId: info.machineIdentifier ?? (getUserItem(PLEX_CACHE_SERVER_ID_KEY) || null),
sectionKey: plexSelectedSectionKey,
tracks
});
plexStatusKey = 'settings.integrations.plexStatusTracksLoaded';
plexStatusValues = { count: tracks.length };
} catch (error) {
console.warn('Plex background refresh failed:', error);
// Keep cached data visible; only surface error if nothing cached is available.
if (plexTracks.length === 0) {
setPlexError(error);
}
}
}
function setPlexError(error: unknown) {
const message = String(error);
plexLastError = message;
plexStatusKey = 'settings.integrations.plexStatusError';
plexStatusValues = { error: message };
}
async function handlePlexPing() {
if (!plexBaseUrl.trim() || !plexToken.trim()) return;
plexBusy = true;
plexLastError = '';
persistPlexConfig();
try {
const info = await invoke<PlexServerInfo>('plex_ping', {
baseUrl: plexBaseUrl.trim(),
token: plexToken.trim()
});
if (info.machineIdentifier) {
setUserItem(PLEX_CACHE_SERVER_ID_KEY, info.machineIdentifier);
}
plexStatusKey = 'settings.integrations.plexStatusConnected';
plexStatusValues = {
server: info.friendlyName || info.machineIdentifier || 'Plex',
version: info.version || '?'
};
} catch (error) {
console.error('Failed Plex ping:', error);
setPlexError(error);
} finally {
plexBusy = false;
}
}
async function handlePlexLoadSections() {
if (!plexBaseUrl.trim() || !plexToken.trim()) return;
plexBusy = true;
plexLastError = '';
persistPlexConfig();
try {
const sections = await invoke<PlexMusicSection[]>('plex_get_music_sections', {
baseUrl: plexBaseUrl.trim(),
token: plexToken.trim()
});
plexSections = sections;
await invoke<number>('plex_cache_save_sections', {
serverId: getUserItem(PLEX_CACHE_SERVER_ID_KEY) || null,
sections
});
if (sections.length > 0 && !sections.some((section) => section.key === plexSelectedSectionKey)) {
plexSelectedSectionKey = sections[0].key;
}
if (plexSelectedSectionKey) {
setUserItem(PLEX_CACHE_SELECTED_SECTION_KEY, plexSelectedSectionKey);
}
plexStatusKey = 'settings.integrations.plexStatusSectionsLoaded';
plexStatusValues = { count: sections.length };
} catch (error) {
console.error('Failed loading Plex sections:', error);
setPlexError(error);
} finally {
plexBusy = false;
}
}
async function handlePlexLoadTracks() {
if (!plexBaseUrl.trim() || !plexToken.trim() || !plexSelectedSectionKey) return;
plexBusy = true;
plexLastError = '';
persistPlexConfig();
try {
const tracks = await invoke<PlexTrack[]>('plex_get_section_tracks', {
baseUrl: plexBaseUrl.trim(),
token: plexToken.trim(),
sectionKey: plexSelectedSectionKey
});
plexTracks = tracks;
await invoke<number>('plex_cache_save_tracks', {
serverId: getUserItem(PLEX_CACHE_SERVER_ID_KEY) || null,
sectionKey: plexSelectedSectionKey,
tracks
});
plexStatusKey = 'settings.integrations.plexStatusTracksLoaded';
plexStatusValues = { count: tracks.length };
} catch (error) {
console.error('Failed loading Plex tracks:', error);
setPlexError(error);
} finally {
plexBusy = false;
}
}
async function loadRemoteControlStatus() {
try {
const status = await invoke<RemoteControlStatus>('remote_control_get_status');
@@ -2851,7 +3247,7 @@
{/if}
<!-- MusicBrainz Integration -->
<div class="setting-row last">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.integrations.musicbrainz')}</span>
<small class="setting-note">
@@ -2860,6 +3256,117 @@
</div>
<Toggle enabled={musicbrainzEnabled} onchange={handleMusicBrainzChange} />
</div>
<div class="lastfm-config plex-poc-panel">
<div class="setting-with-description">
<span class="setting-label">{$t('settings.integrations.plexPoc')}</span>
<span class="setting-description">{$t('settings.integrations.plexPocDesc')}</span>
</div>
<small class="setting-note">{$t('settings.integrations.plexLibraryIntegrationNote')}</small>
<small class="setting-note">{$t('settings.integrations.plexLocalOnlyDirectPlayNote')}</small>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.integrations.plexMetadataWrite')}</span>
<small class="setting-note">{$t('settings.integrations.plexMetadataWriteDesc')}</small>
</div>
<Toggle enabled={plexMetadataWriteEnabled} onchange={handlePlexMetadataWriteToggle} />
</div>
<div class="plex-connect-wizard">
<div class="plex-wizard-header">
<span class="setting-label">{$t('settings.integrations.plexEasyConnectTitle')}</span>
<small class="setting-note">{$t('settings.integrations.plexEasyConnectDesc')}</small>
</div>
<div class="plex-actions">
<button class="connect-btn" onclick={handlePlexConnectEasy} disabled={plexBusy || plexAuthBusy}>
{plexAuthBusy ? $t('actions.loading') : $t('settings.integrations.plexActionEasyConnect')}
</button>
{#if plexAuthUrl}
<button
class="connect-btn"
onclick={handleOpenPlexAuthUrl}
disabled={plexAuthBusy}
>
{$t('settings.integrations.plexActionOpenAuth')}
</button>
{/if}
<button class="connect-btn danger-btn" onclick={handlePlexDisconnect} disabled={plexBusy || plexAuthBusy}>
{$t('settings.integrations.plexActionDisconnect')}
</button>
<button class="connect-btn danger-btn" onclick={handlePlexClearCache} disabled={plexBusy || plexAuthBusy}>
{$t('settings.integrations.plexActionClearCache')}
</button>
</div>
{#if plexAuthCode}
<div class="plex-code-card">
<small class="setting-note">{$t('settings.integrations.plexCodeLabel')}</small>
<div class="plex-code-value">{plexAuthCode}</div>
<div class="plex-actions">
<button class="connect-btn" onclick={handleCopyPlexCode}>
{$t('settings.integrations.plexActionCopyCode')}
</button>
</div>
</div>
{/if}
</div>
<div class="config-field">
<label for="plex-base-url">{$t('settings.integrations.plexBaseUrl')}</label>
<input
id="plex-base-url"
type="text"
bind:value={plexBaseUrl}
placeholder={$t('settings.integrations.plexBaseUrlPlaceholder')}
/>
</div>
<div class="config-field">
<label for="plex-token">{$t('settings.integrations.plexToken')}</label>
<input
id="plex-token"
type="password"
bind:value={plexToken}
placeholder={$t('settings.integrations.plexTokenPlaceholder')}
/>
</div>
<div class="plex-actions">
<button class="connect-btn" onclick={handlePlexPing} disabled={plexBusy || !plexBaseUrl.trim() || !plexToken.trim()}>
{plexBusy ? $t('actions.loading') : $t('settings.integrations.plexActionPing')}
</button>
<button class="connect-btn" onclick={handlePlexLoadSections} disabled={plexBusy || !plexBaseUrl.trim() || !plexToken.trim()}>
{plexBusy ? $t('actions.loading') : $t('settings.integrations.plexActionLoadSections')}
</button>
</div>
<div class="setting-row plex-inline-row">
<span class="setting-label">{$t('settings.integrations.plexMusicSection')}</span>
<div class="plex-section-controls">
<select
class="remote-control-input plex-section-select"
bind:value={plexSelectedSectionKey}
disabled={plexSections.length === 0 || plexBusy}
>
<option value="" disabled selected={plexSelectedSectionKey === ''}>
{$t('settings.integrations.plexSelectSection')}
</option>
{#each plexSections as plexSection}
<option value={plexSection.key}>{plexSection.title}</option>
{/each}
</select>
<button class="connect-btn" onclick={handlePlexLoadTracks} disabled={plexBusy || !plexSelectedSectionKey}>
{plexBusy ? $t('actions.loading') : $t('settings.integrations.plexActionLoadTracks')}
</button>
</div>
</div>
<small class="setting-note">{$t(plexStatusKey, { values: plexStatusValues })}</small>
{#if plexLastError}
<small class="setting-note plex-error-note">{plexLastError}</small>
{/if}
</div>
</section>
<!-- Updates Section -->
@@ -4390,6 +4897,73 @@ flatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz</pre>
word-break: break-all;
}
.plex-poc-panel {
margin-top: 10px;
display: grid;
gap: 10px;
}
.plex-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.plex-inline-row {
margin-top: 2px;
margin-bottom: 2px;
padding-top: 0;
padding-bottom: 0;
}
.plex-connect-wizard {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid var(--border-subtle);
border-radius: 10px;
background: var(--bg-secondary);
}
.plex-wizard-header {
display: grid;
gap: 4px;
}
.plex-code-card {
display: grid;
gap: 10px;
padding: 14px;
border-radius: 10px;
border: 1px solid var(--accent-primary);
background: color-mix(in srgb, var(--accent-primary) 10%, var(--bg-tertiary));
}
.plex-code-value {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 32px;
line-height: 1;
letter-spacing: 0.16em;
font-weight: 700;
color: var(--text-primary);
}
.plex-section-controls {
display: flex;
align-items: center;
gap: 8px;
}
.plex-section-select {
min-width: 240px;
width: 240px;
}
.plex-error-note {
color: #fca5a5;
word-break: break-word;
}
</style>
<MigrationModal
+29 -1
View File
@@ -643,6 +643,33 @@
"musicbrainzDesc": "Beziehungsdaten zwischen Künstlern und erweiterte Metadaten von MusicBrainz aktivieren.",
"musicbrainzCache": "MusicBrainz-Cache",
"listenbrainz": "ListenBrainz",
"plexActionEasyConnect": "Mit Plex verbinden",
"plexActionOpenAuth": "Plex-Anmeldung öffnen",
"plexActionDisconnect": "Plex trennen",
"plexActionClearCache": "Lokalen Plex-Cache leeren",
"plexActionCopyCode": "Code kopieren",
"plexEasyConnectTitle": "Einfache Verbindung",
"plexEasyConnectDesc": "Öffne plex.tv/link im Browser, gib den hier angezeigten Code ein, autorisiere QBZ und kehre zurück.",
"plexCodeLabel": "Mit diesem Code autorisieren",
"plexAuthCode": "Plex-Code: {code}",
"plexCodeCopied": "Plex-Code kopiert.",
"plexCodeCopyFailed": "Plex-Code konnte nicht kopiert werden.",
"plexStatusAuthPending": "Plex-Anmeldung gestartet. Schließe die Autorisierung mit dem Code {code} ab.",
"plexStatusAuthConnected": "Plex-Token erfolgreich empfangen.",
"plexStatusAuthExpired": "Der Plex-Anmeldecode ist abgelaufen. Starte die Verbindung erneut.",
"plexStatusDisconnected": "Plex-Verbindung und zwischengespeicherte Metadaten wurden entfernt.",
"plexStatusCacheCleared": "Lokaler Plex-Cache wurde geleert.",
"plexDisconnectConfirmTitle": "Plex trennen?",
"plexDisconnectConfirmDesc": "Dadurch wird das Plex-Token entfernt und der Plex-Metadaten-Cache geleert.",
"plexDisconnectConfirmOk": "Trennen",
"plexMetadataWrite": "Plex-Metadaten schreiben (experimentell)",
"plexMetadataWriteDesc": "Standardmäßig deaktiviert. Lass es aus, um nur lokale Metadatenbearbeitung in QBZ zu nutzen und keine Änderungen nach Plex zurückzuschreiben.",
"plexWriteDisabledNotice": "Plex-Metadaten-Schreiben ist deaktiviert. Aktiviere es in den Einstellungen, wenn du Plex-Album-Metadaten bearbeiten möchtest.",
"plexClearCacheConfirmTitle": "Lokalen Plex-Cache leeren?",
"plexClearCacheConfirmDesc": "Dadurch werden lokal in QBZ gespeicherte Plex-Bereiche/Titel entfernt. Dein Plex-Token bleibt verbunden.",
"plexClearCacheConfirmOk": "Cache leeren",
"plexLibraryIntegrationNote": "Plex-Musik wird in die Local Library integriert (gleicher Queue-/Player-Flow, keine separate Registerkarte).",
"plexLocalOnlyDirectPlayNote": "Nur Plex-Bibliotheken mit lokalen Dateien werden unterstützt, um Direct Play zu garantieren.",
"remoteControl": "Fernsteuerung",
"remoteControlDesc": "Steuere QBZ von einem anderen Gerät im selben Netzwerk.",
"remoteControlEnable": "Fernsteuerung aktivieren",
@@ -665,7 +692,8 @@
"remoteControlRegenerateConfirm": "Neu erzeugen",
"remoteControlQrHelp": "Diesen QR-Code mit QBZ-Control scannen, um zu koppeln.",
"remoteControlUrlLabel": "Lokale URL",
"remoteControlQrAlt": "QR-Code zur Kopplung"
"remoteControlQrAlt": "QR-Code zur Kopplung",
"plexStatusCacheLoaded": "{count} zwischengespeicherte Titel geladen. Aktualisierung von Plex im Hintergrund."
},
"storage": {
"title": "Speicher",
+52 -1
View File
@@ -685,7 +685,58 @@
"remoteControlRegenerateConfirm": "Regenerate",
"remoteControlQrHelp": "Scan this QR code with QBZ-Control to pair.",
"remoteControlUrlLabel": "Local URL",
"remoteControlQrAlt": "Pairing QR code"
"remoteControlQrAlt": "Pairing QR code",
"plexPoc": "Plex LAN POC",
"plexPocDesc": "Experimental local-only integration. Uses direct media parts from Plex without replacing QBZ playback.",
"plexLibraryIntegrationNote": "Plex music is integrated into Local Library (same queue/player flow, no separate tab).",
"plexLocalOnlyDirectPlayNote": "Only Plex libraries pointing to local files are supported to guarantee direct play.",
"plexMetadataWrite": "Plex metadata write (experimental)",
"plexMetadataWriteDesc": "Disabled by default. Keep off to use local-only metadata edits in QBZ and avoid writing changes back to Plex.",
"plexWriteDisabledNotice": "Plex metadata write is disabled. Enable it in Settings if you want to edit Plex album metadata.",
"plexBaseUrl": "Plex Base URL",
"plexBaseUrlPlaceholder": "http://127.0.0.1:32400",
"plexToken": "Plex Token",
"plexTokenPlaceholder": "Paste your X-Plex-Token",
"plexActionPing": "Ping Plex",
"plexActionEasyConnect": "Connect with Plex",
"plexActionOpenAuth": "Open Plex Sign-In",
"plexActionDisconnect": "Disconnect Plex",
"plexActionClearCache": "Clear Local Plex Cache",
"plexActionCopyCode": "Copy Code",
"plexActionLoadSections": "Load Music Sections",
"plexActionLoadTracks": "Load Tracks",
"plexActionPlay": "Play",
"plexActionPlaying": "Playing...",
"plexEasyConnectTitle": "Easy Connection",
"plexEasyConnectDesc": "Open plex.tv/link in your browser, enter the code shown here, approve QBZ, then return.",
"plexCodeLabel": "Authorize with this code",
"plexAuthCode": "Plex code: {code}",
"plexCodeCopied": "Plex code copied.",
"plexCodeCopyFailed": "Could not copy Plex code.",
"plexMusicSection": "Music Section",
"plexSelectSection": "Select a section",
"plexStatusIdle": "Ready for Plex LAN checks.",
"plexStatusAuthPending": "Plex sign-in started. Complete authorization with code {code}.",
"plexStatusAuthConnected": "Plex token received successfully.",
"plexStatusAuthExpired": "Plex sign-in code expired. Start the connection again.",
"plexStatusDisconnected": "Plex connection and cached metadata were cleared.",
"plexStatusCacheCleared": "Local Plex cache was cleared.",
"plexStatusConnected": "Connected to {server} (v{version}).",
"plexStatusSectionsLoaded": "Loaded {count} music sections.",
"plexStatusTracksLoaded": "Loaded {count} tracks.",
"plexStatusCacheLoaded": "Loaded {count} cached tracks. Refreshing from Plex in background.",
"plexStatusPlaying": "Playing: {title}",
"plexStatusPlayingDirect": "Playing direct from Plex: {title} ({rate} Hz / {bits}-bit, {contentType}).",
"plexStatusPlayingFallback": "Playing {title}, but direct play is not confirmed (part: {partKey}).",
"plexStatusError": "Plex error: {error}",
"plexDisconnectConfirmTitle": "Disconnect Plex?",
"plexDisconnectConfirmDesc": "This removes the Plex token and clears cached Plex metadata.",
"plexDisconnectConfirmOk": "Disconnect",
"plexClearCacheConfirmTitle": "Clear local Plex cache?",
"plexClearCacheConfirmDesc": "This removes locally cached Plex sections/tracks in QBZ. Your Plex token remains connected.",
"plexClearCacheConfirmOk": "Clear cache",
"plexNoTracksLoaded": "No tracks loaded yet.",
"plexTrackMeta": "{artist} - {album} - {rate} Hz / {bits}-bit"
},
"storage": {
"title": "Storage",
+52 -1
View File
@@ -685,7 +685,58 @@
"remoteControlRegenerateConfirm": "Regenerar",
"remoteControlQrHelp": "Escanea este código QR con QBZ-Control para emparejar.",
"remoteControlUrlLabel": "URL local",
"remoteControlQrAlt": "Código QR de emparejamiento"
"remoteControlQrAlt": "Código QR de emparejamiento",
"plexPoc": "Plex LAN POC",
"plexPocDesc": "Integración experimental solo local. Usa partes directas de medios desde Plex sin reemplazar la reproducción de QBZ.",
"plexLibraryIntegrationNote": "La música de Plex se integra en Local Library (misma cola/reproductor, sin pestaña separada).",
"plexLocalOnlyDirectPlayNote": "Solo se soportan bibliotecas de Plex que apunten a archivos locales para garantizar direct play.",
"plexMetadataWrite": "Escritura de metadatos en Plex (experimental)",
"plexMetadataWriteDesc": "Desactivado por defecto. Déjalo apagado para usar edición local de metadatos en QBZ y evitar escribir cambios en Plex.",
"plexWriteDisabledNotice": "La escritura de metadatos en Plex está desactivada. Actívala en Settings si quieres editar metadatos de álbumes Plex.",
"plexBaseUrl": "URL base de Plex",
"plexBaseUrlPlaceholder": "http://127.0.0.1:32400",
"plexToken": "Token de Plex",
"plexTokenPlaceholder": "Pega tu X-Plex-Token",
"plexActionPing": "Probar Plex",
"plexActionEasyConnect": "Conectar con Plex",
"plexActionOpenAuth": "Abrir inicio de sesión de Plex",
"plexActionDisconnect": "Desconectar Plex",
"plexActionClearCache": "Limpiar caché local de Plex",
"plexActionCopyCode": "Copiar código",
"plexActionLoadSections": "Cargar secciones de música",
"plexActionLoadTracks": "Cargar pistas",
"plexActionPlay": "Reproducir",
"plexActionPlaying": "Reproduciendo...",
"plexEasyConnectTitle": "Conexión rápida",
"plexEasyConnectDesc": "Abre plex.tv/link en tu navegador, introduce el código mostrado aquí, autoriza QBZ y vuelve.",
"plexCodeLabel": "Autoriza con este código",
"plexAuthCode": "Código de Plex: {code}",
"plexCodeCopied": "Código de Plex copiado.",
"plexCodeCopyFailed": "No se pudo copiar el código de Plex.",
"plexMusicSection": "Sección de música",
"plexSelectSection": "Selecciona una sección",
"plexStatusIdle": "Listo para pruebas LAN con Plex.",
"plexStatusAuthPending": "Inicio de sesión de Plex iniciado. Completa la autorización con el código {code}.",
"plexStatusAuthConnected": "Token de Plex recibido correctamente.",
"plexStatusAuthExpired": "El código de inicio de Plex expiró. Inicia la conexión nuevamente.",
"plexStatusDisconnected": "Se limpiaron la conexión y la caché de metadatos de Plex.",
"plexStatusCacheCleared": "Se limpió la caché local de Plex.",
"plexStatusConnected": "Conectado a {server} (v{version}).",
"plexStatusSectionsLoaded": "Se cargaron {count} secciones de música.",
"plexStatusTracksLoaded": "Se cargaron {count} pistas.",
"plexStatusCacheLoaded": "Se cargaron {count} pistas en caché. Actualizando desde Plex en segundo plano.",
"plexStatusPlaying": "Reproduciendo: {title}",
"plexStatusPlayingDirect": "Reproducción directa desde Plex: {title} ({rate} Hz / {bits}-bit, {contentType}).",
"plexStatusPlayingFallback": "Reproduciendo {title}, pero no se confirmó reproducción directa (part: {partKey}).",
"plexStatusError": "Error de Plex: {error}",
"plexDisconnectConfirmTitle": "¿Desconectar Plex?",
"plexDisconnectConfirmDesc": "Esto elimina el token de Plex y limpia la caché de metadatos de Plex.",
"plexDisconnectConfirmOk": "Desconectar",
"plexClearCacheConfirmTitle": "¿Limpiar caché local de Plex?",
"plexClearCacheConfirmDesc": "Esto elimina las secciones/pistas de Plex almacenadas localmente en QBZ. Tu token de Plex seguirá conectado.",
"plexClearCacheConfirmOk": "Limpiar caché",
"plexNoTracksLoaded": "Aún no se cargaron pistas.",
"plexTrackMeta": "{artist} - {album} - {rate} Hz / {bits}-bit"
},
"storage": {
"title": "Almacenamiento",
+29 -1
View File
@@ -643,6 +643,33 @@
"musicbrainzDesc": "Activer les relations d'artistes et les métadonnées améliorées depuis MusicBrainz.",
"musicbrainzCache": "Cache MusicBrainz",
"listenbrainz": "ListenBrainz",
"plexActionEasyConnect": "Se connecter avec Plex",
"plexActionOpenAuth": "Ouvrir la connexion Plex",
"plexActionDisconnect": "Déconnecter Plex",
"plexActionClearCache": "Effacer le cache Plex local",
"plexActionCopyCode": "Copier le code",
"plexEasyConnectTitle": "Connexion simplifiée",
"plexEasyConnectDesc": "Ouvrez plex.tv/link dans votre navigateur, saisissez le code affiché ici, autorisez QBZ puis revenez.",
"plexCodeLabel": "Autorisez avec ce code",
"plexAuthCode": "Code Plex : {code}",
"plexCodeCopied": "Code Plex copié.",
"plexCodeCopyFailed": "Impossible de copier le code Plex.",
"plexStatusAuthPending": "Connexion Plex démarrée. Terminez l'autorisation avec le code {code}.",
"plexStatusAuthConnected": "Jeton Plex reçu avec succès.",
"plexStatusAuthExpired": "Le code de connexion Plex a expiré. Relancez la connexion.",
"plexStatusDisconnected": "La connexion Plex et le cache de métadonnées ont été effacés.",
"plexStatusCacheCleared": "Le cache Plex local a été effacé.",
"plexDisconnectConfirmTitle": "Déconnecter Plex ?",
"plexDisconnectConfirmDesc": "Cela supprime le jeton Plex et efface le cache de métadonnées Plex.",
"plexDisconnectConfirmOk": "Déconnecter",
"plexMetadataWrite": "Écriture des métadonnées Plex (expérimental)",
"plexMetadataWriteDesc": "Désactivé par défaut. Laissez cette option désactivée pour utiliser uniquement l'édition locale des métadonnées dans QBZ et éviter de renvoyer des changements vers Plex.",
"plexWriteDisabledNotice": "L'écriture des métadonnées Plex est désactivée. Activez-la dans les paramètres si vous voulez modifier les métadonnées des albums Plex.",
"plexClearCacheConfirmTitle": "Effacer le cache Plex local ?",
"plexClearCacheConfirmDesc": "Cela supprime les sections/pistes Plex mises en cache localement dans QBZ. Votre jeton Plex reste connecté.",
"plexClearCacheConfirmOk": "Effacer le cache",
"plexLibraryIntegrationNote": "La musique Plex est intégrée dans la Local Library (même flux file d'attente/lecteur, sans onglet séparé).",
"plexLocalOnlyDirectPlayNote": "Seules les bibliothèques Plex pointant vers des fichiers locaux sont prises en charge pour garantir le direct play.",
"remoteControl": "Contrôle à distance",
"remoteControlDesc": "Contrôlez QBZ depuis un autre appareil sur le même réseau.",
"remoteControlEnable": "Activer le contrôle à distance",
@@ -665,7 +692,8 @@
"remoteControlRegenerateConfirm": "Régénérer",
"remoteControlQrHelp": "Scannez ce code QR avec QBZ-Control pour appairer.",
"remoteControlUrlLabel": "URL locale",
"remoteControlQrAlt": "Code QR d'appairage"
"remoteControlQrAlt": "Code QR d'appairage",
"plexStatusCacheLoaded": "{count} pistes en cache chargées. Actualisation depuis Plex en arrière-plan."
},
"storage": {
"title": "Stockage",
+50 -6
View File
@@ -55,6 +55,12 @@ interface PlayTrackResult {
format_id: number | null;
}
/** Result from plex_play_track command */
interface PlexPlayTrackResult {
sampling_rate_hz?: number | null;
bit_depth?: number | null;
}
/**
* Convert Qobuz format_id to format string for QualityBadge
* 5=MP3, 6=FLAC 16-bit (CD), 7=FLAC 24-bit (Hi-Res), 27=FLAC 24-bit Hi-Res+
@@ -72,6 +78,7 @@ function formatIdToString(formatId: number | null): string | undefined {
export interface PlayTrackOptions {
isLocal?: boolean;
source?: 'qobuz' | 'local' | 'plex';
showLoadingToast?: boolean;
showSuccessToast?: boolean;
/** When true, skip stop_playback and play_track — backend already has audio playing via gapless */
@@ -110,6 +117,7 @@ export async function playTrack(
): Promise<boolean> {
const {
isLocal = false,
source = isLocal ? 'local' : 'qobuz',
showLoadingToast = true,
showSuccessToast = true,
gaplessTransition = false
@@ -154,7 +162,27 @@ export async function playTrack(
});
} else {
// Use appropriate local playback command
if (isLocal) {
if (source === 'plex') {
const plexBaseUrl = getUserItem('qbz-plex-poc-base-url') || '';
const plexToken = getUserItem('qbz-plex-poc-token') || '';
if (!plexBaseUrl || !plexToken) {
throw new Error('Missing Plex base URL or token');
}
const result = await invoke<PlexPlayTrackResult>('plex_play_track', {
baseUrl: plexBaseUrl,
token: plexToken,
ratingKey: String(track.id)
});
// Plex metadata endpoint provides stream audio details even when queue list did not.
if (result.sampling_rate_hz && result.sampling_rate_hz > 0) {
track.samplingRate = result.sampling_rate_hz / 1000;
}
if (result.bit_depth && result.bit_depth > 0) {
track.bitDepth = result.bit_depth;
}
setCurrentTrack(track);
} else if (isLocal) {
await invoke('library_play_track', { trackId: track.id });
} else {
const result = await invoke<PlayTrackResult>('play_track', {
@@ -181,7 +209,7 @@ export async function playTrack(
}
// Log play event for recommendations (fire-and-forget)
if (!isLocal) {
if (!isLocal && source !== 'plex') {
void logRecoEvent({
eventType: 'play',
itemType: 'track',
@@ -224,7 +252,7 @@ export async function playTrack(
);
// Check favorite status (only for Qobuz tracks)
if (!isLocal) {
if (!isLocal && source !== 'plex') {
const isFav = await checkTrackFavorite(track.id);
setIsFavorite(isFav);
} else {
@@ -252,18 +280,34 @@ export async function playTrack(
console.log('Auto-skipping to next track:', next.title);
// Small delay to let the toast be visible
setTimeout(() => {
const nextSource = (next.source === 'plex' || next.source === 'local' || next.source === 'qobuz')
? next.source
: (next.is_local ? 'local' : 'qobuz');
const nextSamplingRate = next.sample_rate == null
? undefined
: (next.is_local || nextSource === 'plex')
? next.sample_rate / 1000
: next.sample_rate;
playTrack({
id: next.id,
title: next.title,
artist: next.artist,
album: next.album,
duration: next.duration_secs,
artwork: next.artwork_url || undefined,
artwork: next.artwork_url || '',
quality: next.hires ? 'Hi-Res' : 'CD Quality',
albumId: next.album_id || undefined,
artistId: next.artist_id || undefined,
bitDepth: next.bit_depth || undefined,
samplingRate: next.sample_rate || undefined
}, { showLoadingToast: true, showSuccessToast: true });
samplingRate: nextSamplingRate,
source: nextSource,
isLocal: next.is_local
}, {
isLocal: next.is_local ?? false,
source: nextSource,
showLoadingToast: true,
showSuccessToast: true
});
}, 500);
} else {
setIsPlaying(false);
+17 -8
View File
@@ -46,7 +46,8 @@ export function buildQueueTrackFromQobuz(track: QobuzTrack): BackendQueueTrack {
is_local: false,
album_id: track.album?.id || null,
artist_id: track.performer?.id ?? null,
streamable: track.streamable ?? true
streamable: track.streamable ?? true,
source: 'qobuz'
};
}
@@ -76,7 +77,8 @@ export function buildQueueTrackFromAlbumTrack(
is_local: false,
album_id: track.albumId || albumId || null,
artist_id: track.artistId ?? artistId ?? null,
streamable: track.streamable ?? true
streamable: track.streamable ?? true,
source: 'qobuz'
};
}
@@ -99,14 +101,18 @@ export function buildQueueTrackFromPlaylistTrack(track: PlaylistTrack): BackendQ
is_local: false,
album_id: track.albumId || null,
artist_id: track.artistId ?? null,
streamable: track.streamable ?? true
streamable: track.streamable ?? true,
source: 'qobuz'
};
}
export function buildQueueTrackFromLocalTrack(track: LocalLibraryTrack): BackendQueueTrack {
const artwork = track.artwork_path ? convertFileSrc(track.artwork_path) : null;
const artwork = track.artwork_path
? (/^https?:\/\//i.test(track.artwork_path) ? track.artwork_path : convertFileSrc(track.artwork_path))
: null;
// Local tracks are hi-res if bit_depth > 16 or sample_rate > 44100
const isHires = Boolean((track.bit_depth && track.bit_depth > 16) || (track.sample_rate && track.sample_rate > 44100));
const isPlexTrack = track.source === 'plex';
return {
id: track.id,
title: track.title,
@@ -117,10 +123,11 @@ export function buildQueueTrackFromLocalTrack(track: LocalLibraryTrack): Backend
hires: isHires,
bit_depth: track.bit_depth ?? null,
sample_rate: track.sample_rate ?? null,
is_local: true,
is_local: !isPlexTrack,
album_id: null, // Local tracks don't have Qobuz album IDs
artist_id: null, // Local tracks don't have Qobuz artist IDs
streamable: true // Local tracks are always playable
streamable: true, // Local tracks are always playable
source: isPlexTrack ? 'plex' : 'local'
};
}
@@ -163,11 +170,13 @@ export async function queuePlaylistTrackLater(track: PlaylistTrack): Promise<voi
}
export async function queueLocalTrackNext(track: LocalLibraryTrack): Promise<void> {
await queueTrackNext(buildQueueTrackFromLocalTrack(track), true);
const isPlexTrack = track.source === 'plex';
await queueTrackNext(buildQueueTrackFromLocalTrack(track), !isPlexTrack);
}
export async function queueLocalTrackLater(track: LocalLibraryTrack): Promise<void> {
await queueTrackLater(buildQueueTrackFromLocalTrack(track), true);
const isPlexTrack = track.source === 'plex';
await queueTrackLater(buildQueueTrackFromLocalTrack(track), !isPlexTrack);
}
// ============ Favorites ============
+1 -1
View File
@@ -10,7 +10,7 @@ import { invoke } from '@tauri-apps/api/core';
// ============ Types ============
export type ContextType = 'album' | 'playlist' | 'artist_top' | 'home_list' | 'favorites' | 'local_library' | 'radio';
export type ContentSource = 'qobuz' | 'local';
export type ContentSource = 'qobuz' | 'local' | 'plex';
export interface PlaybackContext {
type: ContextType;
+40 -10
View File
@@ -49,6 +49,7 @@ export interface PlayingTrack {
samplingRate?: number;
format?: string;
isLocal?: boolean;
source?: string;
// Optional IDs for recommendation tracking
albumId?: string;
artistId?: number;
@@ -93,10 +94,16 @@ interface QueueTrack {
bit_depth: number | null;
sample_rate: number | null;
is_local?: boolean;
source?: string;
album_id?: string | null;
artist_id?: number | null;
}
interface PlexPlayTrackResult {
sampling_rate_hz?: number | null;
bit_depth?: number | null;
}
// ============ State ============
/**
@@ -334,11 +341,27 @@ export async function togglePlay(): Promise<void> {
const savedPosition = pendingSessionRestore.position;
pendingSessionRestore = null; // Clear before loading
// Check if this is a local track (negative ID) or Qobuz track
if (currentTrack.id < 0) {
// Local track - use library_play_track with positive ID
// Restore source-specific playback
if (currentTrack.source === 'plex') {
const plexBaseUrl = getUserItem('qbz-plex-poc-base-url') || '';
const plexToken = getUserItem('qbz-plex-poc-token') || '';
if (!plexBaseUrl || !plexToken) {
throw new Error('Missing Plex configuration for session restore');
}
const result = await invoke<PlexPlayTrackResult>('plex_play_track', {
baseUrl: plexBaseUrl,
token: plexToken,
ratingKey: String(currentTrack.id)
});
if (result.sampling_rate_hz && result.sampling_rate_hz > 0) {
currentTrack.samplingRate = result.sampling_rate_hz / 1000;
}
if (result.bit_depth && result.bit_depth > 0) {
currentTrack.bitDepth = result.bit_depth;
}
} else if (currentTrack.isLocal || currentTrack.id < 0) {
// Local filesystem track
const localTrackId = Math.abs(currentTrack.id);
console.log('[Player] Restoring local track:', localTrackId);
await invoke('library_play_track', { trackId: localTrackId });
} else {
// Qobuz track - use play_track with duration for seekbar
@@ -539,6 +562,12 @@ async function handlePlaybackEvent(event: PlaybackEvent): Promise<void> {
try {
const queueTrack = await invoke<QueueTrack | null>('get_current_queue_track');
if (queueTrack && queueTrack.id === event.track_id) {
const rawRate = queueTrack.sample_rate ?? undefined;
const normalizedRate = rawRate == null
? undefined
: (queueTrack.is_local || queueTrack.source === 'plex')
? rawRate / 1000
: rawRate;
currentTrack = {
id: queueTrack.id,
title: queueTrack.title,
@@ -547,11 +576,12 @@ async function handlePlaybackEvent(event: PlaybackEvent): Promise<void> {
artwork: queueTrack.artwork_url || '',
duration: queueTrack.duration_secs,
quality: queueTrack.hires ? 'Hi-Res' : 'CD Quality',
bitDepth: queueTrack.bit_depth,
samplingRate: queueTrack.sample_rate,
bitDepth: queueTrack.bit_depth ?? undefined,
samplingRate: normalizedRate,
isLocal: queueTrack.is_local,
albumId: queueTrack.album_id,
artistId: queueTrack.artist_id
source: queueTrack.source ?? undefined,
albumId: queueTrack.album_id ?? undefined,
artistId: queueTrack.artist_id ?? undefined
};
duration = queueTrack.duration_secs;
// Update playback state from event
@@ -587,8 +617,8 @@ async function handlePlaybackEvent(event: PlaybackEvent): Promise<void> {
persistVolume(volume);
}
// Update track with actual stream quality (issue #34)
// The backend now sends the real sample rate and bit depth from the decoded stream
// Update track with actual stream quality (issue #34).
// For Plex this is needed because cached metadata can be stale/inaccurate.
if (event.sample_rate && event.sample_rate > 0) {
// Convert Hz to kHz for display (44100 -> 44.1)
currentTrack.samplingRate = event.sample_rate / 1000;
+2
View File
@@ -33,6 +33,8 @@ export interface BackendQueueTrack {
artist_id?: number | null;
/** Whether the track is streamable on Qobuz (false = removed/unavailable) */
streamable?: boolean;
/** Track source: qobuz | local | plex */
source?: string;
}
interface BackendQueueState {
+1
View File
@@ -322,6 +322,7 @@ export interface LocalLibraryTrack {
bit_depth?: number;
sample_rate: number;
artwork_path?: string;
source?: string;
}
// ============ External API Types ============
+23 -5
View File
@@ -1473,7 +1473,8 @@
// Helper to play a track from the queue (with offline skip support)
async function playQueueTrack(track: BackendQueueTrack, skippedIds = new Set<number>(), gaplessTransition = false) {
const isLocal = isLocalTrack(track.id);
const source = track.source ?? (track.is_local ? 'local' : 'qobuz');
const isLocal = source !== 'qobuz' || isLocalTrack(track.id);
// In offline mode, check if track is available
if (offlineStatus.isOffline && !isLocal) {
@@ -1524,9 +1525,10 @@
// Only convert Hz to kHz for local tracks. Qobuz tracks are already in kHz.
samplingRate: isLocal && track.sample_rate ? track.sample_rate / 1000 : track.sample_rate ?? undefined,
isLocal,
source,
albumId: track.album_id ?? undefined,
artistId: track.artist_id ?? undefined
}, { isLocal, showLoadingToast: false, gaplessTransition });
}, { isLocal, source: source as 'qobuz' | 'local' | 'plex', showLoadingToast: false, gaplessTransition });
}
// Play a specific track from the queue panel
@@ -2093,7 +2095,22 @@
// DO NOT clear context - LocalLibraryView already sets it correctly
// await clearPlaybackContext();
const artwork = track.artwork_path ? convertFileSrc(track.artwork_path) : '';
const source = track.source === 'plex' ? 'plex' : 'local';
const plexBaseUrl = getUserItem('qbz-plex-poc-base-url') || '';
const plexToken = getUserItem('qbz-plex-poc-token') || '';
const resolveArtwork = (path?: string): string => {
if (!path) return '';
if (/^https?:\/\//i.test(path)) return path;
if (source === 'plex' && path.startsWith('/library/') && plexBaseUrl && plexToken) {
const base = plexBaseUrl.replace(/\/+$/, '');
const sep = path.includes('?') ? '&' : '?';
return `${base}${path}${sep}X-Plex-Token=${encodeURIComponent(plexToken)}`;
}
return convertFileSrc(path);
};
const artwork = track.artwork_path
? resolveArtwork(track.artwork_path)
: '';
const quality = track.bit_depth && track.sample_rate
? (track.bit_depth >= 24 || track.sample_rate > 48000
? `${track.bit_depth}bit/${track.sample_rate / 1000}kHz`
@@ -2111,8 +2128,9 @@
bitDepth: track.bit_depth,
samplingRate: track.sample_rate ? track.sample_rate / 1000 : undefined, // Convert Hz to kHz (44100 → 44.1) - NO ROUNDING
format: track.format,
isLocal: true
}, { isLocal: true });
isLocal: source !== 'plex',
source
}, { isLocal: source !== 'plex', source });
}
// Handle setting queue from local library (tracks need different playback command)
+2 -3
View File
@@ -1,8 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M19 21H5a1 1 0 0 1-1-1v-9H1l10.327-9.388a1 1 0 0 1 1.346 0L23 11h-3v9a1 1 0 0 1-1 1zM6 19h12V9.157l-6-5.454-6 5.454V19zm2.591-5.191a3.508 3.508 0 0 1 0-1.622l-.991-.572 1-1.732.991.573a3.495 3.495 0 0 1 1.404-.812V8.5h2v1.144c.532.159 1.01.44 1.404.812l.991-.573 1 1.731-.991.573a3.508 3.508 0 0 1 0 1.622l.991.572-1 1.731-.991-.572a3.495 3.495 0 0 1-1.404.811v1.145h-2V16.35a3.495 3.495 0 0 1-1.404-.811l-.991.572-1-1.73.991-.573zm3.404.688a1.5 1.5 0 1 0 0-2.998 1.5 1.5 0 0 0 0 2.998z"/>
</g>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 787 B

After

Width:  |  Height:  |  Size: 677 B

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg xmlns="http://www.w3.org/2000/svg"
aria-label="Plex" role="img"
viewBox="0 0 512 512"><rect
width="512" height="512"
rx="15%"
fill="#282a2d"/><path d="m256 70h-108l108 186-108 186h108l108-186z" fill="#e5a00d"/></svg>

After

Width:  |  Height:  |  Size: 340 B

+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#000000" stroke-linejoin="round" stroke-width="12"><path d="M22 25.5h48L116 94l-46 68.5H22L68.5 94Zm109.8 56L108 46l14-20.5h48zm-.3 23.5c10.979 17.625 25.52 38.875 38.5 49.5-11.149 13.635-34.323 32.278-62.5-14z"/></svg>

After

Width:  |  Height:  |  Size: 450 B