-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathLibreSpot.ps1
More file actions
5022 lines (4718 loc) · 331 KB
/
LibreSpot.ps1
File metadata and controls
5022 lines (4718 loc) · 331 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# LibreSpot - Comprehensive SpotX + Spicetify Installer
# Easy Mode | Custom Mode | Maintenance Mode
#
# All-in-one installer for SpotX ad-blocking/patching and Spicetify
# themes, extensions, and Marketplace with full GUI configuration.
#
# Credits:
# SpotX - github.com/SpotX-Official/SpotX
# Spicetify - github.com/spicetify
# Marketplace - github.com/spicetify/marketplace
# Themes - github.com/spicetify/spicetify-themes
# ohitstom - github.com/ohitstom/spicetify-extensions
# =============================================================================
# 1. INITIAL SETUP
# =============================================================================
Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase, System.Windows.Forms, System.IO.Compression.FileSystem
Add-Type @'
using System;
using System.Runtime.InteropServices;
public class Win32 {
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool FlashWindowEx(ref FLASHWINFO pwfi);
public const int SW_HIDE = 0;
public const int SW_MINIMIZE = 6;
[StructLayout(LayoutKind.Sequential)]
public struct FLASHWINFO {
public uint cbSize;
public IntPtr hwnd;
public uint dwFlags;
public uint uCount;
public uint dwTimeout;
}
public const uint FLASHW_ALL = 3;
public const uint FLASHW_TIMERNOFG = 12;
public static void FlashTaskbar(IntPtr hwnd) {
FLASHWINFO fw = new FLASHWINFO();
fw.cbSize = (uint)Marshal.SizeOf(typeof(FLASHWINFO));
fw.hwnd = hwnd;
fw.dwFlags = FLASHW_ALL | FLASHW_TIMERNOFG;
fw.uCount = 5;
fw.dwTimeout = 0;
FlashWindowEx(ref fw);
}
}
'@ -ErrorAction SilentlyContinue
$ErrorActionPreference = 'Stop'
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
} catch {}
$global:VERSION = '3.6.0'
# CLI argument detection. Supports `irm URL | iex -clean` (PowerShell passes
# trailing args to `iex` as $args inside the invoked script) and also
# `powershell.exe -File LibreSpot.ps1 -clean` via the same $args.
#
# Recognized flags:
# -clean Pre-tick Easy mode + CleanInstall for a one-shot rebuild.
# -watch Headless auto-reapply check. No UI. Scheduled task uses this.
# -installwatcher Register the scheduled task that calls `-watch` and exit.
# -uninstallwatcher Remove that scheduled task and exit.
$script:CliClean = $false
$script:CliWatch = $false
$script:CliInstallWatcher = $false
$script:CliUninstallWatcher = $false
try {
if ($args -and $args.Count -gt 0) {
foreach ($a in $args) {
switch -Regex ([string]$a) {
'^-{1,2}clean$' { $script:CliClean = $true }
'^-{1,2}watch$' { $script:CliWatch = $true }
'^-{1,2}installwatcher$' { $script:CliInstallWatcher = $true }
'^-{1,2}uninstallwatcher$' { $script:CliUninstallWatcher = $true }
}
}
}
} catch {}
# --- Pinned dependency versions with SHA256 verification ---
# Update these when new versions are tested. Use Maintenance > Check for Updates.
$global:PinnedReleases = @{
SpotX = @{
Version = '2.0'
Commit = '0abf98a36be501740d774a56d54d5f7fbbafc35c'
Url = 'https://raw.githubusercontent.com/SpotX-Official/SpotX/0abf98a36be501740d774a56d54d5f7fbbafc35c/run.ps1'
SHA256 = '38d4205a2afc2050781bbfe28c6713edd6b0aef2c084304b58d92308b081f569'
}
SpicetifyCLI = @{
Version = '2.43.1'
SHA256 = @{
x64 = 'c9b5e677d5b3046d14da09a3f713bd7b864b67b0c4c4b7ea2ab53c261e63b491'
arm64 = '4cc793a947678ededaa244899c216d60230f535cb8ccaadf683e99c4ae741e13'
}
}
Marketplace = @{
Version = '1.0.8'
Url = 'https://github.com/spicetify/marketplace/releases/download/v1.0.8/marketplace.zip'
SHA256 = 'ba20cd30896605ec60c272905004673b995162d2c8ca085351971e409cf80ec7'
}
Themes = @{
Commit = '9af41cf91af6f6093c0e060d57264f08f6bb161c'
SHA256 = 'fd55e443e88302dfd45e201f35ec67db5f51c4346b58fab5da90faf7b1a66f28'
}
}
# Computed URLs (derived from pinned versions, do not edit directly)
$global:URL_SPOTX = $global:PinnedReleases.SpotX.Url
$global:URL_MARKETPLACE = $global:PinnedReleases.Marketplace.Url
$global:URL_THEMES_REPO = "https://github.com/spicetify/spicetify-themes/archive/$($global:PinnedReleases.Themes.Commit).zip"
$global:URL_SPICETIFY_FMT = 'https://github.com/spicetify/cli/releases/download/v{0}/spicetify-{0}-windows-{1}.zip'
$global:TEMP_DIR = $env:TEMP
$global:SPOTIFY_EXE_PATH = "$env:APPDATA\Spotify\Spotify.exe"
$global:SPICETIFY_DIR = "$env:LOCALAPPDATA\spicetify"
$global:SPICETIFY_CONFIG_DIR = "$env:APPDATA\spicetify"
$global:BACKUP_ROOT = "$env:USERPROFILE\LibreSpot_Backups"
$global:CONFIG_DIR = "$env:APPDATA\LibreSpot"
$global:CONFIG_PATH = "$env:APPDATA\LibreSpot\config.json"
$global:LOG_PATH = "$env:APPDATA\LibreSpot\install.log"
$global:WATCHER_STATE_PATH = "$env:APPDATA\LibreSpot\watcher-state.json"
$global:WATCHER_LOG_PATH = "$env:APPDATA\LibreSpot\watcher.log"
$global:WATCHER_TASK_NAME = 'LibreSpot\ReapplyWatcher'
$global:BrushGreen = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#FF22c55e"))
$global:BrushRed = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#FFef4444"))
$global:BrushMuted = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#FFa1a1aa"))
$global:BrushError = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString("#FFf87171"))
foreach ($b in @($global:BrushGreen,$global:BrushRed,$global:BrushMuted,$global:BrushError)) { $b.Freeze() }
$script:openRunspaces = [System.Collections.Generic.List[object]]::new()
$script:BrushConverter = [System.Windows.Media.BrushConverter]::new()
$script:EntryInvocation = $MyInvocation
$script:EntryCommandPath = $PSCommandPath
# PS2EXE leaves $PSScriptRoot empty. Compute a reliable script root for both
# .ps1 (where $PSScriptRoot works) and .exe (where we use the process path).
$script:ScriptRoot = if (-not [string]::IsNullOrWhiteSpace($PSScriptRoot)) {
$PSScriptRoot
} elseif (-not [string]::IsNullOrWhiteSpace($PSCommandPath)) {
Split-Path -Parent $PSCommandPath
} else {
try { Split-Path -Parent ([System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName) } catch { $PWD.Path }
}
$script:ConfigLoadWarning = $null
# =============================================================================
# 1b. AUTO-REAPPLY WATCHER (Track 4.2)
# =============================================================================
# Headless mode invoked by a scheduled task that detects Spotify.exe version
# bumps and re-runs the saved SpotX config. No UI is loaded in this path —
# anything requiring $window, $ui, or a WPF dispatcher is off-limits.
function Write-WatcherLog {
param([string]$Message, [string]$Level = 'INFO')
try {
if (-not (Test-Path -LiteralPath $global:CONFIG_DIR)) {
New-Item -ItemType Directory -Path $global:CONFIG_DIR -Force | Out-Null
}
$line = "[{0}] [{1}] {2}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::AppendAllText($global:WATCHER_LOG_PATH, $line + [Environment]::NewLine, $utf8NoBom)
# Trim the watcher log when it exceeds ~1 MB so an unattended machine
# can't fill the disk with 15-minute polling entries.
if ((Get-Item -LiteralPath $global:WATCHER_LOG_PATH).Length -gt 1048576) {
$keep = Get-Content -LiteralPath $global:WATCHER_LOG_PATH -Tail 500
[System.IO.File]::WriteAllLines($global:WATCHER_LOG_PATH, $keep, $utf8NoBom)
}
} catch {}
}
function Get-WatcherState {
if (-not (Test-Path -LiteralPath $global:WATCHER_STATE_PATH)) {
return @{ LastKnownVersion = $null; LastRunAt = $null; LastOutcome = $null }
}
try {
$raw = Get-Content -LiteralPath $global:WATCHER_STATE_PATH -Raw -ErrorAction Stop | ConvertFrom-Json
return @{
LastKnownVersion = [string]$raw.LastKnownVersion
LastRunAt = [string]$raw.LastRunAt
LastOutcome = [string]$raw.LastOutcome
}
} catch {
return @{ LastKnownVersion = $null; LastRunAt = $null; LastOutcome = $null }
}
}
function Set-WatcherState {
param([hashtable]$State)
try {
if (-not (Test-Path -LiteralPath $global:CONFIG_DIR)) {
New-Item -ItemType Directory -Path $global:CONFIG_DIR -Force | Out-Null
}
# Use [UTF8Encoding]($false) to avoid the BOM that PS 5.1's
# `-Encoding UTF8` produces, which can trip up ConvertFrom-Json.
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$json = $State | ConvertTo-Json -Compress
[System.IO.File]::WriteAllText($global:WATCHER_STATE_PATH, $json, $utf8NoBom)
} catch {
Write-WatcherLog "State save failed: $($_.Exception.Message)" -Level 'WARN'
}
}
function Get-InstalledSpotifyVersion {
if (-not (Test-Path -LiteralPath $global:SPOTIFY_EXE_PATH)) { return $null }
try { return (Get-Item -LiteralPath $global:SPOTIFY_EXE_PATH).VersionInfo.FileVersion }
catch { return $null }
}
function Test-SpotifyRunning {
try { return [bool](Get-Process -Name 'Spotify' -ErrorAction SilentlyContinue) }
catch { return $false }
}
function Get-WatcherLaunchCommand {
# Returns a [string[]]{ FileName, ArgumentList... } suitable for schtasks.exe's
# /TR value. Prefers the compiled LibreSpot.exe when the user launched from it;
# falls back to powershell.exe + -File when launched from the raw .ps1. Returns
# $null when neither path is usable (e.g. `irm | iex`) so the caller can surface
# a helpful error instead of registering a broken task.
$entry = [string]$script:EntryCommandPath
if ([string]::IsNullOrWhiteSpace($entry)) { return $null }
if (-not (Test-Path -LiteralPath $entry)) { return $null }
$ext = [System.IO.Path]::GetExtension($entry).ToLowerInvariant()
if ($ext -eq '.exe') {
return @{ Command = "`"$entry`" -Watch"; Entry = $entry }
}
if ($ext -eq '.ps1') {
$ps = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
if (-not (Test-Path -LiteralPath $ps)) { $ps = 'powershell.exe' }
return @{ Command = "`"$ps`" -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$entry`" -Watch"; Entry = $entry }
}
return $null
}
function Test-AutoReapplyTaskRegistered {
try {
$out = & schtasks.exe /Query /TN $global:WATCHER_TASK_NAME 2>$null
return ($LASTEXITCODE -eq 0) -and ($out -and $out.Length -gt 0)
} catch { return $false }
}
function Register-AutoReapplyTask {
# Creates a per-user scheduled task that fires at logon, then again every
# 30 minutes, invoking LibreSpot in -Watch mode. Returns $true on success.
$launch = Get-WatcherLaunchCommand
if (-not $launch) {
Write-WatcherLog 'Register: no usable LibreSpot entry path (iex launch?). Watcher not registered.' -Level 'ERROR'
return $false
}
# Unregister first so we don't get "task already exists" failures when the
# user toggles the setting. schtasks /Create /F also overwrites, but the
# explicit delete keeps the semantics obvious.
try { Unregister-AutoReapplyTask | Out-Null } catch {}
# Build an inline XML task definition. schtasks.exe's flag syntax can't
# express "logon trigger + repetition every 30 minutes for 1 day" cleanly,
# but the XML schema can. Repetition Duration=PT0S means "forever" per
# MS-TSCH 2.3.5.2; Interval=PT30M is every 30 minutes.
$escapedCommand = [System.Security.SecurityElement]::Escape($launch.Command)
# Use the current user's SID for domain-joined machines where bare USERNAME
# may not resolve. Fall back to USERDOMAIN\USERNAME, then bare USERNAME.
$userId = $null
try {
$currentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$userId = $currentIdentity.User.Value # SID string, e.g. S-1-5-21-...
} catch {}
if ([string]::IsNullOrWhiteSpace($userId)) {
$userId = if ($env:USERDOMAIN -and $env:USERDOMAIN -ne $env:COMPUTERNAME) {
"$env:USERDOMAIN\$env:USERNAME"
} else { $env:USERNAME }
}
$userId = [System.Security.SecurityElement]::Escape($userId)
$xml = @"
<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Author>LibreSpot</Author>
<Description>LibreSpot reapplies SpotX automatically when Spotify updates itself. Toggle from Maintenance inside the app.</Description>
<URI>\LibreSpot\ReapplyWatcher</URI>
</RegistrationInfo>
<Triggers>
<LogonTrigger>
<Enabled>true</Enabled>
<Delay>PT2M</Delay>
<Repetition>
<Interval>PT30M</Interval>
<Duration>PT0S</Duration>
<StopAtDurationEnd>false</StopAtDurationEnd>
</Repetition>
</LogonTrigger>
</Triggers>
<Principals>
<Principal id="Author">
<UserId>$userId</UserId>
<LogonType>InteractiveToken</LogonType>
<RunLevel>LeastPrivilege</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<IdleSettings>
<StopOnIdleEnd>false</StopOnIdleEnd>
<RestartOnIdle>false</RestartOnIdle>
</IdleSettings>
<AllowStartOnDemand>true</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<RunOnlyIfIdle>false</RunOnlyIfIdle>
<WakeToRun>false</WakeToRun>
<ExecutionTimeLimit>PT30M</ExecutionTimeLimit>
<Priority>7</Priority>
</Settings>
<Actions Context="Author">
<Exec>
<Command>$escapedCommand</Command>
</Exec>
</Actions>
</Task>
"@
$xmlPath = Join-Path $global:CONFIG_DIR "watcher-task.xml"
try {
if (-not (Test-Path -LiteralPath $global:CONFIG_DIR)) {
New-Item -ItemType Directory -Path $global:CONFIG_DIR -Force | Out-Null
}
# schtasks /Create /XML requires UTF-16 LE with BOM to match the XML header.
[System.IO.File]::WriteAllText($xmlPath, $xml, [System.Text.Encoding]::Unicode)
$output = & schtasks.exe /Create /TN $global:WATCHER_TASK_NAME /XML $xmlPath /F 2>&1
$ok = ($LASTEXITCODE -eq 0)
if ($ok) {
Write-WatcherLog "Register: scheduled task created for $($launch.Entry)"
} else {
Write-WatcherLog "Register failed (exit $LASTEXITCODE): $($output -join ' ')" -Level 'ERROR'
}
return $ok
} catch {
Write-WatcherLog "Register exception: $($_.Exception.Message)" -Level 'ERROR'
return $false
} finally {
try { if (Test-Path -LiteralPath $xmlPath) { Remove-Item -LiteralPath $xmlPath -Force -ErrorAction SilentlyContinue } } catch {}
}
}
function Unregister-AutoReapplyTask {
try {
$null = & schtasks.exe /Delete /TN $global:WATCHER_TASK_NAME /F 2>&1
if ($LASTEXITCODE -eq 0) {
Write-WatcherLog "Unregister: scheduled task removed"
return $true
}
return $false
} catch { return $false }
}
function Invoke-HeadlessReapply {
# Minimal reapply pipeline — runs SpotX synchronously with the saved config
# and reapplies Spicetify if the CLI is present. Intentionally does NOT use
# any UI / runspace plumbing. Caller runs on the main thread from -Watch.
param([hashtable]$Config)
if (-not $Config) { throw 'Invoke-HeadlessReapply: missing config' }
$tempDir = Join-Path $global:TEMP_DIR ("LibreSpot_Watcher_" + [guid]::NewGuid().ToString('N').Substring(0,8))
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
try {
$spotxRun = Join-Path $tempDir 'spotx_run.ps1'
# Download + hash-verify SpotX. We DON'T fall back to BITS here because
# the watcher runs unattended and we'd rather silently skip than use a
# different download backend than the user-triggered install path.
Write-WatcherLog "Downloading SpotX run.ps1"
Invoke-WebRequest -Uri $global:URL_SPOTX -OutFile $spotxRun -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop
$actualHash = (Get-FileHash -LiteralPath $spotxRun -Algorithm SHA256).Hash.ToLowerInvariant()
$expectedHash = [string]$global:PinnedReleases.SpotX.SHA256
if ($actualHash -ne $expectedHash.ToLowerInvariant()) {
throw "SpotX hash mismatch. Expected $expectedHash, got $actualHash. Refusing to run."
}
$spotxArgs = Build-SpotXParams -Config $Config
Write-WatcherLog "Invoking SpotX with: $spotxArgs"
# Use powershell.exe isolation so SpotX can't leak runtime state into our
# own script scope. Exit code is the only signal we care about.
$psExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
if (-not (Test-Path -LiteralPath $psExe)) { $psExe = 'powershell.exe' }
$pinfo = New-Object System.Diagnostics.ProcessStartInfo
$pinfo.FileName = $psExe
$pinfo.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$spotxRun`" $spotxArgs"
$pinfo.RedirectStandardOutput = $true
$pinfo.RedirectStandardError = $true
$pinfo.UseShellExecute = $false
$pinfo.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($pinfo)
# Drain stdout/stderr asynchronously to prevent buffer deadlock.
# If SpotX writes more than the OS pipe buffer (~4KB) the process
# hangs forever waiting for the buffer to be read.
$stdoutTask = $proc.StandardOutput.ReadToEndAsync()
$stderrTask = $proc.StandardError.ReadToEndAsync()
if (-not $proc.WaitForExit(20 * 60 * 1000)) {
try { $proc.Kill() } catch {}
throw "SpotX timed out after 20 minutes."
}
$proc.WaitForExit() # Ensure async streams are fully flushed
if ($proc.ExitCode -ne 0) {
$stderrText = if ($stderrTask.IsCompleted) { $stderrTask.Result } else { '(not available)' }
throw "SpotX exited with code $($proc.ExitCode). Stderr: $stderrText"
}
Write-WatcherLog "SpotX completed successfully" -Level 'SUCCESS'
# Reapply Spicetify when it's installed. Missing CLI is fine — it just
# means the user only patches with SpotX and that part is already done.
$spicetifyExe = Join-Path $global:SPICETIFY_DIR 'spicetify.exe'
if (Test-Path -LiteralPath $spicetifyExe) {
try {
& $spicetifyExe 'backup' 'apply' 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-WatcherLog "Spicetify reapplied" -Level 'SUCCESS'
} else {
Write-WatcherLog "Spicetify apply exited $LASTEXITCODE" -Level 'WARN'
}
} catch {
Write-WatcherLog "Spicetify apply failed: $($_.Exception.Message)" -Level 'WARN'
}
}
} finally {
try { Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue } catch {}
}
}
function Invoke-AutoReapplyWatcher {
# -Watch entry point. Returns an exit code to satisfy schtasks reporting.
Write-WatcherLog "--- Watcher tick ---"
$currentVersion = Get-InstalledSpotifyVersion
if (-not $currentVersion) {
Write-WatcherLog "Spotify not installed - skipping."
return 0
}
$state = Get-WatcherState
# First-ever run: record the version and do nothing. Reapplying on the
# first tick would clobber a freshly-installed unconfigured Spotify.
if (-not $state.LastKnownVersion) {
Set-WatcherState -State @{ LastKnownVersion = $currentVersion; LastRunAt = (Get-Date -Format 'o'); LastOutcome = 'Initialized' }
Write-WatcherLog "Initialized last-known version to $currentVersion (no reapply this tick)"
return 0
}
if ($currentVersion -eq $state.LastKnownVersion) {
Write-WatcherLog "Spotify still at $currentVersion - nothing to do"
Set-WatcherState -State @{ LastKnownVersion = $currentVersion; LastRunAt = (Get-Date -Format 'o'); LastOutcome = 'UpToDate' }
return 0
}
Write-WatcherLog "Spotify version bump: $($state.LastKnownVersion) -> $currentVersion" -Level 'STEP'
if (Test-SpotifyRunning) {
Write-WatcherLog "Spotify is running - deferring reapply to next tick"
Set-WatcherState -State @{ LastKnownVersion = $state.LastKnownVersion; LastRunAt = (Get-Date -Format 'o'); LastOutcome = 'DeferredSpotifyRunning' }
return 0
}
$saved = $null
try { $saved = Load-LibreSpotConfig } catch { Write-WatcherLog "Config load failed: $($_.Exception.Message)" -Level 'ERROR' }
if (-not $saved) {
Write-WatcherLog "No saved LibreSpot config - cannot reapply automatically" -Level 'WARN'
Set-WatcherState -State @{ LastKnownVersion = $currentVersion; LastRunAt = (Get-Date -Format 'o'); LastOutcome = 'NoConfig' }
return 0
}
$saved = Normalize-LibreSpotConfig -Config $saved
try {
Invoke-HeadlessReapply -Config $saved
Set-WatcherState -State @{ LastKnownVersion = $currentVersion; LastRunAt = (Get-Date -Format 'o'); LastOutcome = 'Reapplied' }
return 0
} catch {
Write-WatcherLog "Reapply failed: $($_.Exception.Message)" -Level 'ERROR'
# Keep LastKnownVersion unchanged so we'll retry next tick.
Set-WatcherState -State @{ LastKnownVersion = $state.LastKnownVersion; LastRunAt = (Get-Date -Format 'o'); LastOutcome = "Error: $($_.Exception.Message)" }
return 1
}
}
# -InstallWatcher / -UninstallWatcher don't depend on Build-SpotXParams or the
# config pipeline, so they can exit immediately. The -Watch entry point runs
# AFTER Build-SpotXParams is defined (search for "CliWatch" later in this file).
if ($script:CliInstallWatcher) {
Write-WatcherLog "CLI: -installwatcher"
$ok = Register-AutoReapplyTask
if ($ok) {
Write-Host "LibreSpot auto-reapply watcher registered."
exit 0
}
Write-Warning "LibreSpot watcher registration failed; see $($global:WATCHER_LOG_PATH)."
exit 1
}
if ($script:CliUninstallWatcher) {
Write-WatcherLog "CLI: -uninstallwatcher"
$ok = Unregister-AutoReapplyTask
if ($ok) { Write-Host "LibreSpot auto-reapply watcher removed." } else { Write-Host "LibreSpot watcher was not registered." }
exit 0
}
# =============================================================================
# 2. ADMIN CHECK
# =============================================================================
function Get-SelfElevationLaunchTarget {
$pathCandidates = @(
$script:EntryCommandPath,
$script:EntryInvocation.MyCommand.Path,
$script:EntryInvocation.ScriptName
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
foreach ($candidate in $pathCandidates) {
try {
if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue }
$resolvedPath = (Resolve-Path -LiteralPath $candidate).Path
$extension = [System.IO.Path]::GetExtension($resolvedPath)
if ($extension -ieq '.ps1') { return @{ Kind = 'Script'; Path = $resolvedPath } }
if ($extension -ieq '.exe') { return @{ Kind = 'Exe'; Path = $resolvedPath } }
} catch {}
}
try {
$processPath = [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName
$processName = [System.IO.Path]::GetFileName($processPath)
if (
-not [string]::IsNullOrWhiteSpace($processPath) -and
(Test-Path -LiteralPath $processPath -PathType Leaf) -and
[System.IO.Path]::GetExtension($processPath) -ieq '.exe' -and
$processName -notin @('powershell.exe', 'pwsh.exe', 'powershell_ise.exe')
) {
return @{ Kind = 'Exe'; Path = (Resolve-Path -LiteralPath $processPath).Path }
}
} catch {}
$inlineSource = $null
try {
if ($script:EntryInvocation.MyCommand.ScriptBlock) {
$inlineSource = $script:EntryInvocation.MyCommand.ScriptBlock.ToString()
}
} catch {}
if ([string]::IsNullOrWhiteSpace($inlineSource)) {
try {
$definition = [string]$script:EntryInvocation.MyCommand.Definition
if (
-not [string]::IsNullOrWhiteSpace($definition) -and
-not (Test-Path -LiteralPath $definition -PathType Leaf)
) {
$inlineSource = $definition
}
} catch {}
}
if (-not [string]::IsNullOrWhiteSpace($inlineSource)) {
try {
$bootstrapDir = Join-Path $env:TEMP 'LibreSpot'
if (-not (Test-Path -LiteralPath $bootstrapDir)) {
New-Item -Path $bootstrapDir -ItemType Directory -Force | Out-Null
}
$bootstrapPath = Join-Path $bootstrapDir 'LibreSpot-elevated.ps1'
Set-Content -Path $bootstrapPath -Value $inlineSource -Encoding UTF8 -Force
return @{ Kind = 'Script'; Path = $bootstrapPath; IsTemp = $true }
} catch {}
}
return $null
}
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
$launchTarget = Get-SelfElevationLaunchTarget
if ($launchTarget) {
try {
$workingDir = Split-Path -Path $launchTarget.Path -Parent
if ($launchTarget.Kind -eq 'Exe') {
Start-Process -FilePath $launchTarget.Path -Verb RunAs -WorkingDirectory $workingDir
} else {
Start-Process -FilePath 'powershell.exe' -ArgumentList @(
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-File', $launchTarget.Path
) -Verb RunAs -WorkingDirectory $workingDir
}
} catch {
[System.Windows.MessageBox]::Show(
"LibreSpot needs administrator permission to modify Spotify.`n`nApprove the Windows prompt to continue. If it was dismissed, just launch LibreSpot again.",
'LibreSpot',
[System.Windows.MessageBoxButton]::OK,
[System.Windows.MessageBoxImage]::Warning
) | Out-Null
}
} else {
[System.Windows.MessageBox]::Show(
"LibreSpot could not determine a reusable launch path for self-elevation.`n`nRun the saved LibreSpot.ps1 file or download the latest release and try again.",
'LibreSpot',
[System.Windows.MessageBoxButton]::OK,
[System.Windows.MessageBoxImage]::Warning
) | Out-Null
}
Exit
}
# =============================================================================
# 3. DATA
# =============================================================================
$global:THEMES_RAW_BASE = "https://raw.githubusercontent.com/spicetify/spicetify-themes/$($global:PinnedReleases.Themes.Commit)"
$global:ThemeData = [ordered]@{
"(None - Marketplace Only)" = @{ Schemes = @("Default"); Preview = @{} }
"Sleek" = @{ Schemes = @("Wealthy","Cherry","Coral","Deep","Greener","Deeper","Psycho","UltraBlack","Nord","Futura","Elementary","BladeRunner","Dracula","VantaBlack","RosePine","Eldritch","Catppuccin","AyuDark","TokyoNight")
Preview = @{ _default="Sleek/catppuccin.png"; "BladeRunner"="Sleek/bladerunner.png"; "AyuDark"="Sleek/ayudark.png"; "Catppuccin"="Sleek/catppuccin.png" } }
"Dribbblish" = @{ Schemes = @("base","white","dark","dracula","nord-light","nord-dark","purple","samurai","beach-sunset","gruvbox","gruvbox-material-dark","rosepine","lunar","catppuccin-latte","catppuccin-frappe","catppuccin-macchiato","catppuccin-mocha","tokyo-night","kanagawa")
Preview = @{ _default="Dribbblish/base.png"; "base"="Dribbblish/base.png"; "beach-sunset"="Dribbblish/beach-sunset.png"; "catppuccin-frappe"="Dribbblish/catppuccin-frappe.png" } }
"Ziro" = @{ Schemes = @("blue-dark","blue-light","gray-dark","gray-light","green-dark","green-light","orange-dark","orange-light","purple-dark","purple-light","red-dark","red-light","rose-pine","rose-pine-moon","rose-pine-dawn","tokyo-night")
Preview = @{ _default="Ziro/screenshots/rose-pine.jpg"; "rose-pine"="Ziro/screenshots/rose-pine.jpg"; "rose-pine-moon"="Ziro/screenshots/rose-pine-moon.jpg"; "rose-pine-dawn"="Ziro/screenshots/rose-pine-dawn.jpg" } }
"text" = @{ Schemes = @("Spotify","Spicetify","CatppuccinMocha","CatppuccinMacchiato","CatppuccinLatte","Dracula","Gruvbox","Kanagawa","Nord","Rigel","RosePine","RosePineMoon","RosePineDawn","Solarized","TokyoNight","TokyoNightStorm","ForestGreen","EverforestDarkHard","EverforestDarkMedium","EverforestDarkSoft")
Preview = @{ _default="text/screenshots/Spotify.png" } }
"StarryNight" = @{ Schemes = @("Base","Cotton-candy","Forest","Galaxy","Orange","Sky","Sunrise")
Preview = @{ _default="StarryNight/images/base.png"; "Base"="StarryNight/images/base.png"; "Cotton-candy"="StarryNight/images/cotton-candy.png"; "Forest"="StarryNight/images/forest.png"; "Galaxy"="StarryNight/images/galaxy.png"; "Orange"="StarryNight/images/orange.png" } }
"Turntable" = @{ Schemes = @("turntable"); Preview = @{ _default="Turntable/screenshots/turntable.png" } }
"Blackout" = @{ Schemes = @("def"); Preview = @{ _default="Blackout/images/home.png" } }
"Blossom" = @{ Schemes = @("dark"); Preview = @{ _default="Blossom/images/home.png" } }
"BurntSienna" = @{ Schemes = @("Base"); Preview = @{ _default="BurntSienna/screenshot.png" } }
"Default" = @{ Schemes = @("Ocean"); Preview = @{ _default="Default/ocean.png" } }
"Dreary" = @{ Schemes = @("Psycho","Deeper","BIB","Mono","Golden","Graytone-Blue")
Preview = @{ _default="Dreary/deeper.png"; "Deeper"="Dreary/deeper.png"; "BIB"="Dreary/bib.png"; "Golden"="Dreary/golden.png" } }
"Flow" = @{ Schemes = @("Pink","Green","Silver","Violet","Ocean")
Preview = @{ _default="Flow/screenshots/ocean.png"; "Pink"="Flow/screenshots/pink.png"; "Silver"="Flow/screenshots/silver.png"; "Violet"="Flow/screenshots/violet.png"; "Ocean"="Flow/screenshots/ocean.png" } }
"Matte" = @{ Schemes = @("matte","periwinkle","periwinkle-dark","porcelain","rose-pine-moon","gray-dark1","gray-dark2","gray-dark3","gray","gray-light")
Preview = @{ _default="Matte/screenshots/ylx-gray-dark1.png" } }
"Nightlight" = @{ Schemes = @("Nightlight Colors"); Preview = @{ _default="Nightlight/screenshots/nightlight.png" } }
"Onepunch" = @{ Schemes = @("dark","light","legacy"); Preview = @{ _default="Onepunch/screenshots/dark_home.png" } }
"SharkBlue" = @{ Schemes = @("Base"); Preview = @{ _default="SharkBlue/screenshot.png" } }
# --- Community themes (downloaded from individual GitHub repos) ---
"Catppuccin" = @{ Schemes = @("mocha","macchiato","frappe","latte")
Preview = @{ _default="https://raw.githubusercontent.com/catppuccin/spicetify/main/assets/mocha.webp"; "latte"="https://raw.githubusercontent.com/catppuccin/spicetify/main/assets/latte.webp"; "frappe"="https://raw.githubusercontent.com/catppuccin/spicetify/main/assets/frappe.webp"; "macchiato"="https://raw.githubusercontent.com/catppuccin/spicetify/main/assets/macchiato.webp"; "mocha"="https://raw.githubusercontent.com/catppuccin/spicetify/main/assets/mocha.webp" } }
"Comfy" = @{ Schemes = @("Comfy","Mono","Chromatic")
Preview = @{ _default="https://raw.githubusercontent.com/Comfy-Themes/Spicetify/main/screenshots/Comfy.png"; "Mono"="https://raw.githubusercontent.com/Comfy-Themes/Spicetify/main/screenshots/Mono.png"; "Chromatic"="https://raw.githubusercontent.com/Comfy-Themes/Spicetify/main/screenshots/Chromatic.png" } }
"Bloom" = @{ Schemes = @("dark","light","darkMono","darkGreen","coffee","comfy","violet")
Preview = @{ _default="https://raw.githubusercontent.com/nimsandu/spicetify-bloom/main/screenshots/dark.png"; "light"="https://raw.githubusercontent.com/nimsandu/spicetify-bloom/main/screenshots/light.png" } }
"Lucid" = @{ Schemes = @("dark","light","dark-green","coffee","comfy","dark-fluent","greenland","biscuit","macos","rosepine","dracula","dracula-pro")
Preview = @{ _default="https://raw.githubusercontent.com/sanoojes/Spicetify-Lucid/main/screenshots/dark.webp"; "light"="https://raw.githubusercontent.com/sanoojes/Spicetify-Lucid/main/screenshots/light.webp" } }
"Hazy" = @{ Schemes = @("dark","light")
Preview = @{ _default="https://raw.githubusercontent.com/Astromations/Hazy/main/screenshots/dark.png" } }
}
# Community themes are hosted in individual GitHub repos, not the official
# spicetify-themes collection. Each entry maps a theme name (matching a key
# in $ThemeData above) to its repo owner/name and the subfolder inside the
# archive that contains color.ini + user.css. Module-InstallThemes checks
# this table to decide whether to pull from the official themes archive or
# from the community repo.
$global:CommunityThemeRepos = @{
"Catppuccin" = @{ Owner="catppuccin"; Repo="spicetify"; Branch="main"; ThemeFolder="." }
"Comfy" = @{ Owner="Comfy-Themes"; Repo="Spicetify"; Branch="main"; ThemeFolder="." }
"Bloom" = @{ Owner="nimsandu"; Repo="spicetify-bloom"; Branch="main"; ThemeFolder="." }
"Lucid" = @{ Owner="sanoojes"; Repo="Spicetify-Lucid"; Branch="main"; ThemeFolder="." }
"Hazy" = @{ Owner="Astromations"; Repo="Hazy"; Branch="main"; ThemeFolder="." }
}
# Themes that require inject_theme_js = 1 (they ship a theme.js file).
$global:ThemesNeedingJS = @("Dribbblish","StarryNight","Turntable","Catppuccin","Comfy","Bloom","Lucid","Hazy")
$global:BuiltInExtensions = [ordered]@{
"fullAppDisplay.js" = "Full-screen album art display with blur effect and playback controls"
"shuffle+.js" = "True shuffle using Fisher-Yates algorithm instead of Spotify weighted shuffle"
"trashbin.js" = "Automatically skip songs and artists you have marked as unwanted"
"keyboardShortcut.js" = "Vim-style keyboard navigation bindings for power users"
"bookmark.js" = "Save and instantly recall pages, tracks, albums, and timestamps"
"loopyLoop.js" = "Set A-B loop points on any track for practice or replay"
"popupLyrics.js" = "Display synchronized lyrics in a separate resizable window"
"autoSkipVideo.js" = "Automatically skip canvas videos and region-locked content"
"autoSkipExplicit.js" = "Automatically skip tracks marked as explicit"
"webnowplaying.js" = "Expose now-playing data for Rainmeter widgets and desktop integrations"
}
# Community extensions are downloaded from GitHub repos to the Spicetify
# Extensions folder before being registered. Each entry maps a filename to
# its description and a raw download URL. These are NOT bundled with the
# Spicetify CLI — LibreSpot fetches them on demand.
$global:CommunityExtensions = [ordered]@{
"hidePodcasts.js" = @{
Description = "Remove podcast, episode, and audiobook UI elements from the Spotify interface"
Url = "https://raw.githubusercontent.com/theRealPadster/spicetify-hide-podcasts/main/dist/hidePodcasts.js"
Source = "theRealPadster/spicetify-hide-podcasts"
}
"beautifulLyrics.js" = @{
Description = "Immersive synced lyrics with dynamic backgrounds, romanization, and blur effects"
Url = "https://raw.githubusercontent.com/surfbryce/beautiful-lyrics/main/dist/beautifulLyrics.js"
Source = "surfbryce/beautiful-lyrics"
}
"playlistIcons.js" = @{
Description = "Add custom icons and folder images to your playlists and library"
Url = "https://raw.githubusercontent.com/jeroentvb/spicetify-playlist-icons/main/dist/playlistIcons.js"
Source = "jeroentvb/spicetify-playlist-icons"
}
"songStats.js" = @{
Description = "Show play count, popularity score, and release date next to each track"
Url = "https://raw.githubusercontent.com/Shinyhero36/spicetify-song-stats/main/dist/songStats.js"
Source = "Shinyhero36/spicetify-song-stats"
}
"volumePercentage.js" = @{
Description = "Display the exact volume percentage next to the volume slider"
Url = "https://raw.githubusercontent.com/daksh2k/spicetify-stuff/main/Extensions/volumePercentage.js"
Source = "daksh2k/spicetify-stuff"
}
}
$global:EasyDefaults = @{
SpotX_NewTheme=$true; SpotX_PodcastsOff=$true; SpotX_BlockUpdate=$true; SpotX_AdSectionsOff=$true
SpotX_Premium=$false; SpotX_LyricsEnabled=$true; SpotX_LyricsTheme="spotify"
SpotX_TopSearch=$false; SpotX_RightSidebarOff=$false; SpotX_RightSidebarClr=$false
SpotX_CanvasHomeOff=$false; SpotX_HomeSubOff=$false; SpotX_DisableStartup=$true; SpotX_NoShortcut=$false; SpotX_CacheLimit=0
SpotX_Plus=$false; SpotX_NewFullscreen=$false; SpotX_FunnyProgress=$false; SpotX_ExpSpotify=$false; SpotX_LyricsBlock=$false
SpotX_SendVersionOff=$true; SpotX_StartSpoti=$false
SpotX_DevTools=$false; SpotX_Mirror=$false; SpotX_DownloadMethod=""; SpotX_ConfirmUninstall=$false
SpotX_SpotifyVersionId="auto"
Spicetify_Theme="(None - Marketplace Only)"; Spicetify_Scheme="Default"; Spicetify_Marketplace=$true
Spicetify_Extensions=@("fullAppDisplay.js","shuffle+.js","trashbin.js")
CleanInstall=$true; LaunchAfter=$true
# Auto-reapply after Spotify updates (Track 4.2). Off by default — we won't
# register a scheduled task until the user explicitly opts in from Maintenance.
AutoReapply_Enabled=$false
}
$global:SpotXLyricsThemes = @(
'spotify','blueberry','blue','discord','forest','fresh','github','lavender',
'orange','pumpkin','purple','red','strawberry','turquoise','yellow','oceano',
'royal','krux','pinkle','zing','radium','sandbar','postlight','relish',
'drot','default','spotify#2'
)
# Curated manifest of Spotify client versions SpotX currently knows how to
# patch cleanly. `Version = ''` means "let SpotX pick the default". Keep this
# list tight — every entry is an explicit compatibility promise.
$global:SpotifyVersionManifest = @(
@{ Id='auto'; Label='Auto (use SpotX default)'; Version=''; Notes='Recommended. Lets SpotX pick the most compatible build.' }
@{ Id='1.2.86.502'; Label='1.2.86.502 (current pinned)'; Version='1.2.86.502.g8cd7fb22'; Notes='Best match for our pinned SpotX commit.' }
@{ Id='1.2.85.519'; Label='1.2.85.519 (previous stable)'; Version='1.2.85.519.g7c42e2e8'; Notes='Last Windows release before Canvas-home changes.' }
@{ Id='1.2.53.440.x86'; Label='1.2.53.440 (x86 / 32-bit only)'; Version='1.2.53.440.g7b2f582a'; Notes='For 32-bit Windows. Do not pick on x64.' }
@{ Id='1.2.5.1006.win7'; Label='1.2.5.1006 (Windows 7 / 8.1)'; Version='1.2.5.1006.g22820f93'; Notes='Last build supported on legacy Windows.' }
)
$global:SpotifyVersionIds = @($global:SpotifyVersionManifest | ForEach-Object { $_.Id })
# =============================================================================
# 4. SETTINGS PERSISTENCE
# =============================================================================
function ConvertTo-PlainHashtable {
param([object]$InputObject)
$result = @{}
if ($null -eq $InputObject) { return $result }
if ($InputObject -is [hashtable]) {
foreach ($key in $InputObject.Keys) { $result[[string]$key] = $InputObject[$key] }
return $result
}
foreach ($property in $InputObject.PSObject.Properties) {
if ($property.Value -is [System.Collections.IEnumerable] -and $property.Value -isnot [string]) {
$result[$property.Name] = @($property.Value)
} else {
$result[$property.Name] = $property.Value
}
}
return $result
}
function ConvertTo-ConfigBoolean {
param([object]$Value, [bool]$Default = $false)
if ($null -eq $Value) { return $Default }
if ($Value -is [bool]) { return [bool]$Value }
if ($Value -is [int] -or $Value -is [long]) { return ([int64]$Value -ne 0) }
$text = ([string]$Value).Trim().ToLowerInvariant()
if ([string]::IsNullOrWhiteSpace($text)) { return $Default }
switch -Regex ($text) {
'^(1|true|yes|on)$' { return $true }
'^(0|false|no|off)$' { return $false }
default { return $Default }
}
}
function ConvertTo-ConfigInt {
param(
[object]$Value,
[int]$Default = 0,
[int]$Minimum = [int]::MinValue,
[int]$Maximum = [int]::MaxValue
)
$parsed = 0
if ($null -eq $Value -or -not [int]::TryParse([string]$Value, [ref]$parsed)) {
$parsed = $Default
}
if ($parsed -lt $Minimum) { $parsed = $Minimum }
if ($parsed -gt $Maximum) { $parsed = $Maximum }
return $parsed
}
function Normalize-LibreSpotConfig {
param([hashtable]$Config)
$normalized = @{ Mode = 'Easy' }
foreach ($key in $global:EasyDefaults.Keys) {
$defaultValue = $global:EasyDefaults[$key]
if ($defaultValue -is [System.Collections.IEnumerable] -and $defaultValue -isnot [string]) {
$normalized[$key] = @($defaultValue)
} else {
$normalized[$key] = $defaultValue
}
}
if ($Config -and $Config.ContainsKey('Mode')) {
$mode = [string]$Config.Mode
if ($mode -in @('Easy', 'Custom')) { $normalized.Mode = $mode }
}
$booleanKeys = @(
'CleanInstall','LaunchAfter',
'SpotX_NewTheme','SpotX_PodcastsOff','SpotX_BlockUpdate','SpotX_AdSectionsOff',
'SpotX_Premium','SpotX_LyricsEnabled','SpotX_TopSearch','SpotX_RightSidebarOff',
'SpotX_RightSidebarClr','SpotX_CanvasHomeOff','SpotX_HomeSubOff',
'SpotX_DisableStartup','SpotX_NoShortcut','SpotX_OldLyrics','SpotX_HideColIconOff',
'SpotX_Plus','SpotX_NewFullscreen','SpotX_FunnyProgress','SpotX_ExpSpotify','SpotX_LyricsBlock',
'SpotX_SendVersionOff','SpotX_StartSpoti','SpotX_DevTools','SpotX_Mirror','SpotX_ConfirmUninstall',
'Spicetify_Marketplace','AutoReapply_Enabled'
)
foreach ($key in $booleanKeys) {
if ($Config -and $Config.ContainsKey($key)) {
$normalized[$key] = ConvertTo-ConfigBoolean -Value $Config[$key] -Default ([bool]$normalized[$key])
}
}
if ($Config -and $Config.ContainsKey('SpotX_CacheLimit')) {
$normalized.SpotX_CacheLimit = ConvertTo-ConfigInt -Value $Config.SpotX_CacheLimit -Default ([int]$normalized.SpotX_CacheLimit) -Minimum 0 -Maximum 50000
}
$dm = if ($Config -and $Config.ContainsKey('SpotX_DownloadMethod')) { [string]$Config.SpotX_DownloadMethod } else { [string]$normalized.SpotX_DownloadMethod }
$dm = $dm.Trim().ToLowerInvariant()
if ($dm -notin @('','curl','webclient')) { $dm = '' }
$normalized.SpotX_DownloadMethod = $dm
$svid = if ($Config -and $Config.ContainsKey('SpotX_SpotifyVersionId')) { [string]$Config.SpotX_SpotifyVersionId } else { [string]$normalized.SpotX_SpotifyVersionId }
if ([string]::IsNullOrWhiteSpace($svid) -or $svid -notin $global:SpotifyVersionIds) { $svid = 'auto' }
$normalized.SpotX_SpotifyVersionId = $svid
$lyricsTheme = if ($Config -and $Config.ContainsKey('SpotX_LyricsTheme')) { [string]$Config.SpotX_LyricsTheme } else { [string]$normalized.SpotX_LyricsTheme }
if ([string]::IsNullOrWhiteSpace($lyricsTheme) -or $lyricsTheme -notin $global:SpotXLyricsThemes) {
$lyricsTheme = [string]$global:EasyDefaults.SpotX_LyricsTheme
}
$normalized.SpotX_LyricsTheme = $lyricsTheme
$themeName = if ($Config -and $Config.ContainsKey('Spicetify_Theme')) { [string]$Config.Spicetify_Theme } else { [string]$normalized.Spicetify_Theme }
if ([string]::IsNullOrWhiteSpace($themeName) -or -not $global:ThemeData.Contains($themeName)) {
$themeName = [string]$global:EasyDefaults.Spicetify_Theme
}
$normalized.Spicetify_Theme = $themeName
$availableSchemes = @()
if ($global:ThemeData.Contains($themeName)) {
$availableSchemes = @($global:ThemeData[$themeName].Schemes)
}
$defaultScheme = if ($availableSchemes -contains [string]$global:EasyDefaults.Spicetify_Scheme) {
[string]$global:EasyDefaults.Spicetify_Scheme
} elseif ($availableSchemes.Count -gt 0) {
[string]$availableSchemes[0]
} else {
'Default'
}
$schemeName = if ($Config -and $Config.ContainsKey('Spicetify_Scheme')) { [string]$Config.Spicetify_Scheme } else { $defaultScheme }
if ([string]::IsNullOrWhiteSpace($schemeName) -or $schemeName -notin $availableSchemes) {
$schemeName = $defaultScheme
}
$normalized.Spicetify_Scheme = $schemeName
$extensions = [System.Collections.Generic.List[string]]::new()
$rawExtensions = @()
if ($Config -and $Config.ContainsKey('Spicetify_Extensions')) {
if ($Config.Spicetify_Extensions -is [string]) {
$rawExtensions = @([string]$Config.Spicetify_Extensions)
} elseif ($Config.Spicetify_Extensions -is [System.Collections.IEnumerable]) {
$rawExtensions = @($Config.Spicetify_Extensions)
}
}
foreach ($extension in $rawExtensions) {
$name = [string]$extension
if ([string]::IsNullOrWhiteSpace($name)) { continue }
if (-not $global:BuiltInExtensions.Contains($name) -and -not $global:CommunityExtensions.Contains($name)) { continue }
if (-not $extensions.Contains($name)) { $extensions.Add($name) }
}
$normalized.Spicetify_Extensions = @($extensions)
if ($normalized.SpotX_RightSidebarOff) {
$normalized.SpotX_RightSidebarClr = $false
}
if (-not $normalized.SpotX_LyricsEnabled) {
$normalized.SpotX_OldLyrics = $false
$normalized.SpotX_LyricsBlock = $false
} elseif ($normalized.SpotX_LyricsBlock) {
$normalized.SpotX_OldLyrics = $false
}
if ($Config -and -not $Config.ContainsKey('Mode')) {
foreach ($key in $global:EasyDefaults.Keys) {
$defaultValue = $global:EasyDefaults[$key]
$currentValue = $normalized[$key]
$isEnumerableDefault = ($defaultValue -is [System.Collections.IEnumerable] -and $defaultValue -isnot [string])
if ($isEnumerableDefault) {
if ((@($currentValue) -join '|') -ne (@($defaultValue) -join '|')) {
$normalized.Mode = 'Custom'
break
}
continue
}
if ([string]$currentValue -ne [string]$defaultValue) {
$normalized.Mode = 'Custom'
break
}
}
}
return $normalized
}
function Move-ConfigFileToQuarantine {
param([string]$Reason)
try {
if (-not (Test-Path -LiteralPath $global:CONFIG_DIR)) {
New-Item -Path $global:CONFIG_DIR -ItemType Directory -Force | Out-Null
}
if (Test-Path -LiteralPath $global:CONFIG_PATH) {
$stamp = Get-Date -Format 'yyyyMMdd-HHmmssfff'
$quarantinePath = $null
for ($attempt = 0; $attempt -lt 10; $attempt++) {
$suffix = if ($attempt -eq 0) { '' } else { "-$attempt" }
$candidateName = "config.corrupt.$stamp$suffix.json"
$candidatePath = Join-Path $global:CONFIG_DIR $candidateName
if (-not (Test-Path -LiteralPath $candidatePath)) {
$quarantinePath = $candidatePath
break
}
}
if (-not $quarantinePath) {
$quarantinePath = Join-Path $global:CONFIG_DIR ("config.corrupt.{0}.json" -f [Guid]::NewGuid().ToString('N'))
}
Move-Item -LiteralPath $global:CONFIG_PATH -Destination $quarantinePath -ErrorAction Stop
$quarantineName = Split-Path -Path $quarantinePath -Leaf
$script:ConfigLoadWarning = "LibreSpot reset the saved settings because the config file could not be read safely. The previous file was moved to $quarantineName."
} else {
$script:ConfigLoadWarning = 'LibreSpot reset the saved settings because the config file could not be read safely.'
}
} catch {
$script:ConfigLoadWarning = 'LibreSpot reset the saved settings because the config file could not be read safely, but it could not move the original file aside automatically.'
}
try {
if ($Reason) { Write-Log "Config reset: $Reason" -Level 'WARN' }
} catch {}
}
function Save-LibreSpotConfig { param([hashtable]$Config)
$tempPath = $null
$backupPath = $null
try {
if (-not (Test-Path -LiteralPath $global:CONFIG_DIR)) { New-Item -Path $global:CONFIG_DIR -ItemType Directory -Force | Out-Null }
$tempPath = Join-Path $global:CONFIG_DIR ("config.{0}.tmp" -f [Guid]::NewGuid().ToString('N'))
$backupPath = Join-Path $global:CONFIG_DIR ("config.{0}.bak" -f [Guid]::NewGuid().ToString('N'))
$normalizedConfig = Normalize-LibreSpotConfig -Config $Config
$json = [ordered]@{}
foreach ($key in $normalizedConfig.Keys) { $json[$key] = $normalizedConfig[$key] }
$utf8 = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($tempPath, ($json | ConvertTo-Json -Depth 4), $utf8)
if (Test-Path -LiteralPath $global:CONFIG_PATH) {
try {
[System.IO.File]::Replace($tempPath, $global:CONFIG_PATH, $backupPath, $true)
Remove-Item -LiteralPath $backupPath -Force -ErrorAction SilentlyContinue
} catch {
# Replace() can fail on some filesystems; fall back to atomic Move
Remove-Item -LiteralPath $global:CONFIG_PATH -Force -ErrorAction Stop
[System.IO.File]::Move($tempPath, $global:CONFIG_PATH)
}
} else {
[System.IO.File]::Move($tempPath, $global:CONFIG_PATH)
}
return $true
} catch {
try { Write-Log "Config save failed: $($_.Exception.Message)" -Level 'WARN' } catch {}
if ($tempPath) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue }