feat(plex): checkpoint plex lan integration and quality sync
cx
This commit is contained in:
1 parent
d2531ea700
commit
228563454b
28 files changed
+2595
-106
No files matched your search
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
|
||||
@@ -327,6 +327,7 @@ fn track_to_queue_track(track: &Track) -> QueueTrack {
|
||||
album_id,
|
||||
artist_id,
|
||||
streamable: track.streamable,
|
||||
source: Some("qobuz".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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))?
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ============
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -322,6 +322,7 @@ export interface LocalLibraryTrack {
|
||||
bit_depth?: number;
|
||||
sample_rate: number;
|
||||
artwork_path?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
// ============ External API Types ============
|
||||
|
||||
+23
-5
@@ -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)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?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"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 787 B After Width: | Height: | Size: 677 B |
@@ -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 |
@@ -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 |
Reference in new issue
Block a user