Skip to content

Commit aab6dbe

Browse files
feat(plex): add Plex config storage and Settings UI (TASK-342.3)
Add 5 Tauri commands (plex_config_set, plex_config_get, plex_config_clear, plex_server_ping, plex_list_libraries) backed by tauri-plugin-store. Token is masked on read; client_identifier persisted as UUIDv4 across reconnects. Wiremock integration tests cover ping success/401 and library filtering. Frontend: new Alpine settings store with plex_configured flag, Plex state and methods in settings-view.js, settings-plex.html partial (URL input, token input, discover libraries, connect/disconnect), wired into settings.html between Last.fm and Stats.
1 parent 19c4ff6 commit aab6dbe

14 files changed

Lines changed: 783 additions & 25 deletions

File tree

app/frontend/js/api/plex.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Plex API
3+
*
4+
* Server configuration, ping, and library discovery.
5+
*/
6+
7+
import { tauriInvoke } from './shared.js';
8+
9+
export const plex = {
10+
getConfig() {
11+
return tauriInvoke('plex_config_get');
12+
},
13+
14+
setConfig(url, token, libraries) {
15+
return tauriInvoke('plex_config_set', { url, token, libraries });
16+
},
17+
18+
clearConfig() {
19+
return tauriInvoke('plex_config_clear');
20+
},
21+
22+
ping(url, token) {
23+
return tauriInvoke('plex_server_ping', { url, token });
24+
},
25+
26+
listLibraries(url, token) {
27+
return tauriInvoke('plex_list_libraries', { url, token });
28+
},
29+
};

app/frontend/js/components/settings-view.js

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { audio } from '../api/audio.js';
22
import { lastfm } from '../api/lastfm.js';
3+
import { plex } from '../api/plex.js';
34
import { settings } from '../api/settings.js';
45
import { tauriConfirm, tauriInvoke } from '../api/shared.js';
56
import { modLabel, SHORTCUT_DEFINITIONS } from '../shortcuts.js';
@@ -22,6 +23,7 @@ export function createSettingsView(Alpine) {
2223
{ id: 'sorting', label: 'Sorting' },
2324
{ id: 'advanced', label: 'Advanced' },
2425
{ id: 'lastfm', label: 'Last.fm' },
26+
{ id: 'plex', label: 'Plex' },
2527
{ id: 'stats', label: 'Statistics' },
2628
],
2729

@@ -45,6 +47,19 @@ export function createSettingsView(Alpine) {
4547
isResettingLoved: false,
4648
},
4749

50+
plex: {
51+
url: '',
52+
token: '',
53+
serverName: null,
54+
machineId: null,
55+
version: null,
56+
libraries: [],
57+
selectedLibraries: [],
58+
isConnecting: false,
59+
isDiscovering: false,
60+
connected: false,
61+
},
62+
4863
reconcileScan: {
4964
isRunning: false,
5065
lastResult: null,
@@ -220,6 +235,7 @@ export function createSettingsView(Alpine) {
220235
});
221236
await this.loadWatchedFolders();
222237
await this.loadLastfmSettings();
238+
await this.loadPlexSettings();
223239
await this.loadNetworkCacheStatus();
224240
this.loadColumnSettings();
225241
this.deduplicateAcrossDirectories = window.settings.get(
@@ -856,6 +872,114 @@ export function createSettingsView(Alpine) {
856872
}
857873
},
858874

875+
// ============================================
876+
// Plex methods
877+
// ============================================
878+
879+
plexStatusColor() {
880+
return this.plex.connected ? 'bg-green-500' : 'bg-red-500';
881+
},
882+
883+
plexStatusText() {
884+
if (this.plex.connected) {
885+
return this.plex.serverName ? `Connected to ${this.plex.serverName}` : 'Connected';
886+
}
887+
return 'Not Connected';
888+
},
889+
890+
async loadPlexSettings() {
891+
if (!window.__TAURI__) return;
892+
893+
try {
894+
const config = await plex.getConfig();
895+
if (config?.status === 'configured') {
896+
this.plex.url = config.url;
897+
this.plex.token = config.token;
898+
this.plex.selectedLibraries = config.libraries ?? [];
899+
this.plex.connected = true;
900+
}
901+
} catch (error) {
902+
console.error('[settings] Failed to load Plex settings:', error);
903+
}
904+
},
905+
906+
async connectPlex() {
907+
if (!this.plex.url || !this.plex.token) {
908+
Alpine.store('ui').toast('Server URL and token are required', 'warning');
909+
return;
910+
}
911+
912+
this.plex.isConnecting = true;
913+
try {
914+
const info = await plex.ping(this.plex.url, this.plex.token);
915+
this.plex.serverName = info.server_name;
916+
this.plex.machineId = info.machine_id;
917+
this.plex.version = info.version;
918+
919+
await plex.setConfig(this.plex.url, this.plex.token, this.plex.selectedLibraries);
920+
this.plex.connected = true;
921+
Alpine.store('settings').plex_configured = true;
922+
Alpine.store('ui').toast(`Connected to ${info.server_name}`, 'success');
923+
} catch (error) {
924+
console.error('[settings] Failed to connect to Plex:', error);
925+
Alpine.store('ui').toast(`Failed to connect: ${error}`, 'error');
926+
} finally {
927+
this.plex.isConnecting = false;
928+
}
929+
},
930+
931+
async disconnectPlex() {
932+
try {
933+
await plex.clearConfig();
934+
this.plex.url = '';
935+
this.plex.token = '';
936+
this.plex.serverName = null;
937+
this.plex.machineId = null;
938+
this.plex.version = null;
939+
this.plex.libraries = [];
940+
this.plex.selectedLibraries = [];
941+
this.plex.connected = false;
942+
Alpine.store('settings').plex_configured = false;
943+
Alpine.store('ui').toast('Disconnected from Plex', 'success');
944+
} catch (error) {
945+
console.error('[settings] Failed to disconnect from Plex:', error);
946+
Alpine.store('ui').toast('Failed to disconnect from Plex', 'error');
947+
}
948+
},
949+
950+
async discoverPlexLibraries() {
951+
if (!this.plex.url || !this.plex.token) {
952+
Alpine.store('ui').toast('Server URL and token are required', 'warning');
953+
return;
954+
}
955+
956+
this.plex.isDiscovering = true;
957+
try {
958+
this.plex.libraries = await plex.listLibraries(this.plex.url, this.plex.token);
959+
if (this.plex.libraries.length === 0) {
960+
Alpine.store('ui').toast('No music libraries found on this server', 'info');
961+
}
962+
} catch (error) {
963+
console.error('[settings] Failed to discover Plex libraries:', error);
964+
Alpine.store('ui').toast(`Failed to discover libraries: ${error}`, 'error');
965+
} finally {
966+
this.plex.isDiscovering = false;
967+
}
968+
},
969+
970+
plexLibrarySelected(key) {
971+
return this.plex.selectedLibraries.includes(key);
972+
},
973+
974+
togglePlexLibrary(key) {
975+
const idx = this.plex.selectedLibraries.indexOf(key);
976+
if (idx === -1) {
977+
this.plex.selectedLibraries = [...this.plex.selectedLibraries, key];
978+
} else {
979+
this.plex.selectedLibraries = this.plex.selectedLibraries.filter((k) => k !== key);
980+
}
981+
},
982+
859983
// ============================================
860984
// Column Settings methods
861985
// ============================================

app/frontend/js/stores/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { createPlayerStore } from './player.js';
99
import { createQueueStore } from './queue.js';
1010
import { createLibraryStore } from './library.js';
11+
import { createSettingsStore } from './settings.js';
1112
import { createUIStore } from './ui.js';
1213
import { initEventListeners } from '../events.js';
1314

@@ -29,6 +30,9 @@ export function initStores(Alpine) {
2930
// Queue store (references player and library)
3031
createQueueStore(Alpine);
3132

33+
// Settings store (no store dependencies)
34+
createSettingsStore(Alpine);
35+
3236
console.log('[stores] All stores registered');
3337

3438
// Initialize Tauri event listeners after stores are ready

app/frontend/js/stores/settings.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Settings Store
3+
*
4+
* Global settings state shared across views, including integration flags
5+
* that need to be available before the settings view is opened.
6+
*/
7+
8+
import { plex } from '../api/plex.js';
9+
10+
export function createSettingsStore(Alpine) {
11+
Alpine.store('settings', {
12+
plex_configured: false,
13+
14+
init() {
15+
this._loadPlexConfigured();
16+
},
17+
18+
async _loadPlexConfigured() {
19+
try {
20+
const config = await plex.getConfig();
21+
this.plex_configured = config?.status === 'configured' && !!config.url;
22+
} catch {
23+
this.plex_configured = false;
24+
}
25+
},
26+
});
27+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<!-- Plex Section -->
2+
<div x-show="isSection('plex')" data-testid="settings-section-plex">
3+
<h3 class="text-xl font-semibold mb-4">Plex</h3>
4+
5+
<!-- Connection Status -->
6+
<div class="mb-8">
7+
<h4 class="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-3">Connection</h4>
8+
<div class="bg-muted/30 rounded-lg p-4">
9+
<div class="flex items-center justify-between mb-3">
10+
<div class="flex items-center gap-3">
11+
<div
12+
class="w-3 h-3 rounded-full"
13+
:class="plexStatusColor()"
14+
></div>
15+
<span class="text-sm font-medium" x-text="plexStatusText()"></span>
16+
</div>
17+
<div class="flex gap-2">
18+
<button
19+
x-show="!plex.connected"
20+
class="px-3 py-1.5 text-xs bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors disabled:opacity-50"
21+
:disabled="plex.isConnecting"
22+
@click="connectPlex()"
23+
data-testid="plex-connect"
24+
>
25+
<span x-text="plex.isConnecting ? 'Connecting...' : 'Connect'"></span>
26+
</button>
27+
<button
28+
x-show="plex.connected"
29+
class="px-3 py-1.5 text-xs bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors"
30+
@click="disconnectPlex()"
31+
data-testid="plex-disconnect"
32+
>
33+
Disconnect
34+
</button>
35+
</div>
36+
</div>
37+
38+
<!-- Server info (shown after successful ping) -->
39+
<div x-show="plex.connected && plex.machineId" class="mt-3 pt-3 border-t border-border/50 space-y-1">
40+
<div class="flex justify-between text-xs">
41+
<span class="text-muted-foreground">Server</span>
42+
<span x-text="plex.serverName || '—'" data-testid="plex-server-name"></span>
43+
</div>
44+
<div class="flex justify-between text-xs">
45+
<span class="text-muted-foreground">Machine ID</span>
46+
<span class="font-mono" x-text="plex.machineId || '—'" data-testid="plex-machine-id"></span>
47+
</div>
48+
<div class="flex justify-between text-xs">
49+
<span class="text-muted-foreground">Version</span>
50+
<span x-text="plex.version || '—'"></span>
51+
</div>
52+
</div>
53+
</div>
54+
</div>
55+
56+
<!-- Server Configuration -->
57+
<div class="mb-8">
58+
<h4 class="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-3">Server</h4>
59+
<div class="space-y-4">
60+
<div>
61+
<label class="block text-sm font-medium mb-1.5" for="plex-url">Server URL</label>
62+
<input
63+
id="plex-url"
64+
type="text"
65+
x-model="plex.url"
66+
placeholder="http://192.168.1.100:32400"
67+
class="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:ring-1 focus:ring-primary focus:border-primary"
68+
data-testid="plex-url-input"
69+
/>
70+
<p class="text-xs text-muted-foreground mt-1">URL of your Plex Media Server including port</p>
71+
</div>
72+
73+
<div>
74+
<label class="block text-sm font-medium mb-1.5" for="plex-token">Plex Token</label>
75+
<input
76+
id="plex-token"
77+
type="password"
78+
x-model="plex.token"
79+
placeholder="Your Plex authentication token"
80+
class="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:ring-1 focus:ring-primary focus:border-primary"
81+
data-testid="plex-token-input"
82+
/>
83+
<p class="text-xs text-muted-foreground mt-1">
84+
Find your token at plex.tv/web &rarr; Account &rarr; XML TV &rarr; URL parameters
85+
</p>
86+
</div>
87+
</div>
88+
</div>
89+
90+
<!-- Library Selection -->
91+
<div class="mb-8">
92+
<div class="flex items-center justify-between mb-3">
93+
<h4 class="text-sm font-medium text-muted-foreground uppercase tracking-wider">Music Libraries</h4>
94+
<button
95+
class="px-3 py-1.5 text-xs border border-border rounded-md hover:bg-muted/50 transition-colors disabled:opacity-50"
96+
:disabled="plex.isDiscovering || !plex.url || !plex.token"
97+
@click="discoverPlexLibraries()"
98+
data-testid="plex-discover-libraries"
99+
>
100+
<span x-text="plex.isDiscovering ? 'Discovering...' : 'Discover Libraries'"></span>
101+
</button>
102+
</div>
103+
104+
<div x-show="plex.libraries.length === 0" class="px-4 py-6 rounded-md border border-dashed border-border text-center">
105+
<p class="text-sm text-muted-foreground">Click "Discover Libraries" to load music libraries from your Plex server.</p>
106+
</div>
107+
108+
<div x-show="plex.libraries.length > 0" class="space-y-2">
109+
<template x-for="lib in plex.libraries" :key="lib.key">
110+
<label class="flex items-center gap-3 px-3 py-2 rounded-md border border-border hover:bg-muted/30 cursor-pointer">
111+
<input
112+
type="checkbox"
113+
:checked="plexLibrarySelected(lib.key)"
114+
@change="togglePlexLibrary(lib.key)"
115+
class="w-4 h-4 rounded border-border text-primary focus:ring-primary focus:ring-offset-0"
116+
/>
117+
<span class="text-sm" x-text="lib.title"></span>
118+
</label>
119+
</template>
120+
</div>
121+
</div>
122+
</div>

app/frontend/views/settings.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,8 @@ <h4 class="text-sm font-medium text-muted-foreground uppercase tracking-wider mb
331331

332332
{{> settings-lastfm}}
333333

334+
{{> settings-plex}}
335+
334336
{{> settings-stats}}
335337
</div>
336338
</div>

0 commit comments

Comments
 (0)