nixamp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/share.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The share link.
3
+ *
4
+ * `nixamp serve` listens on every interface so the phone in your pocket can
5
+ * reach it, and that is only reasonable because the address is not enough on
6
+ * its own: every request has to carry a key that is printed once, in the link.
7
+ * Someone else on the coffee shop wifi can find the port and gets nothing.
8
+ *
9
+ * The key travels in a cookie, set by opening the link. Nothing in the PWA had
10
+ * to change for that: a browser sends a same-origin cookie with every fetch,
11
+ * every EventSource and every `<audio src>` on its own.
12
+ */
13
+ import { randomBytes, timingSafeEqual } from "node:crypto";
14
+ import type { IncomingMessage } from "node:http";
15
+ import { networkInterfaces } from "node:os";
16
+
17
+ /** The cookie, and the query parameter that sets it. */
18
+ export const KEY_COOKIE = "nixamp_key";
19
+ export const KEY_QUERY = "k";
20
+ export const KEY_HEADER = "x-nixamp-key";
21
+
22
+ /**
23
+ * 128 bits, base64url. Long enough that guessing is not a strategy, short
24
+ * enough to read down a phone screen when someone types it by hand.
25
+ */
26
+ export function newKey(): string {
27
+ return randomBytes(16).toString("base64url");
28
+ }
29
+
30
+ /** Compare without leaking where two keys first differ. */
31
+ export function keysMatch(a: string, b: string): boolean {
32
+ const left = Buffer.from(a);
33
+ const right = Buffer.from(b);
34
+ // timingSafeEqual throws on a length mismatch, which is itself the answer.
35
+ return left.length === right.length && timingSafeEqual(left, right);
36
+ }
37
+
38
+ /** Every place a key is accepted from, in the order they are looked for. */
39
+ export function keyFrom(request: IncomingMessage, url: URL): string | null {
40
+ const query = url.searchParams.get(KEY_QUERY);
41
+ if (query) return query;
42
+
43
+ const header = request.headers[KEY_HEADER];
44
+ if (typeof header === "string" && header) return header;
45
+
46
+ for (const part of (request.headers.cookie ?? "").split(";")) {
47
+ const [name, ...rest] = part.trim().split("=");
48
+ if (name === KEY_COOKIE && rest.length > 0) return decodeURIComponent(rest.join("="));
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /** The Set-Cookie for a browser that just opened the link. */
54
+ export function keyCookie(key: string): string {
55
+ // HttpOnly because nothing in the page reads it: the browser attaches it to
56
+ // every same-origin request by itself. No Secure, because the whole point is
57
+ // a plain-http address on your own network.
58
+ return `${KEY_COOKIE}=${encodeURIComponent(key)}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`;
59
+ }
60
+
61
+ /**
62
+ * Interfaces that exist for containers and virtual machines. An address on one
63
+ * of these reaches a bridge, not the phone on the sofa, and listing six of them
64
+ * buries the one line someone actually needed.
65
+ */
66
+ const VIRTUAL = /^(docker|br-|veth|virbr|vmnet|vboxnet|lo)/;
67
+
68
+ /** Where an address actually goes, which is not always where you would like. */
69
+ export function classify(address: string): "private" | "cgnat" | "public" {
70
+ const [a, b] = address.split(".").map(Number) as [number, number];
71
+ if (a === 10) return "private";
72
+ if (a === 192 && b === 168) return "private";
73
+ if (a === 172 && b >= 16 && b <= 31) return "private";
74
+ if (a === 169 && b === 254) return "private";
75
+ // 100.64/10 is carrier-grade NAT, which in practice means Tailscale here.
76
+ if (a === 100 && b >= 64 && b <= 127) return "cgnat";
77
+ return "public";
78
+ }
79
+
80
+ /**
81
+ * The addresses another device could actually reach this machine on, nearest
82
+ * first. On a server the public one is the point: it is the address a phone
83
+ * somewhere else can open. It is labelled for what it is, because the key in
84
+ * the link is then the only thing between a stranger and the library.
85
+ */
86
+ export function reachableAddresses(host: string, port: number): { label: string; url: string }[] {
87
+ const link = (address: string): string => {
88
+ // A bare IPv6 address needs brackets before it is a URL.
89
+ const authority = address.includes(":") ? `[${address}]` : address;
90
+ return `http://${authority}:${port}`;
91
+ };
92
+
93
+ if (host !== "0.0.0.0" && host !== "::") return [{ label: "here", url: link(host) }];
94
+
95
+ const LABELS = { private: "on your network", cgnat: "on tailscale", public: "on the internet" } as const;
96
+ const found: { label: string; url: string; kind: keyof typeof LABELS }[] = [];
97
+ for (const [name, entries] of Object.entries(networkInterfaces())) {
98
+ if (VIRTUAL.test(name)) continue;
99
+ for (const entry of entries ?? []) {
100
+ // Link-local v6 needs a scope id to be usable, and nobody types those in.
101
+ if (entry.internal || entry.family !== "IPv4") continue;
102
+ const kind = classify(entry.address);
103
+ found.push({ label: LABELS[kind], url: link(entry.address), kind });
104
+ }
105
+ }
106
+ const order = { private: 0, cgnat: 1, public: 2 } as const;
107
+ found.sort((x, y) => order[x.kind] - order[y.kind]);
108
+ return [
109
+ { label: "here", url: `http://localhost:${port}` },
110
+ ...found.map(({ label, url }) => ({ label, url })),
111
+ ];
112
+ }
113
+
114
+ /** The full link, key and all. */
115
+ export function shareLink(base: string, key: string | null): string {
116
+ return key === null ? base : `${base}/s/${key}`;
117
+ }
118
+
119
+ /** How to run a command, so the tests never touch a real firewall. */
120
+ export interface Runner {
121
+ read(path: string): string | null;
122
+ run(command: string, args: string[]): { status: number | null; stdout: string };
123
+ }
124
+
125
+ /** Which firewall is in the way, if any. */
126
+ export type Firewall = "ufw" | "firewalld";
127
+
128
+ /**
129
+ * Whether a firewall is running that would keep the port closed to other
130
+ * devices. Listening on 0.0.0.0 proves the socket is open on this machine and
131
+ * nothing more, so this is the difference between "it works" and "it works
132
+ * here".
133
+ */
134
+ export function firewallInUse(io: Runner): Firewall | null {
135
+ if (process.platform !== "linux") return null;
136
+
137
+ // ufw keeps its state in a file, so asking needs no privileges.
138
+ const ufw = io.read("/etc/ufw/ufw.conf");
139
+ if (ufw && /^ENABLED=yes/im.test(ufw)) return "ufw";
140
+
141
+ const firewalld = io.run("systemctl", ["is-active", "firewalld"]);
142
+ if (firewalld.status === 0 && firewalld.stdout.trim() === "active") return "firewalld";
143
+ return null;
144
+ }
145
+
146
+ /** The commands that open and close a port, for each firewall we know. */
147
+ export function portCommands(firewall: Firewall, port: number): { open: string[]; close: string[] } {
148
+ return firewall === "ufw"
149
+ ? { open: ["ufw", "allow", `${port}/tcp`], close: ["ufw", "delete", "allow", `${port}/tcp`] }
150
+ : {
151
+ open: ["firewall-cmd", `--add-port=${port}/tcp`],
152
+ close: ["firewall-cmd", `--remove-port=${port}/tcp`],
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Root runs it directly; anyone else goes through sudo, and only when sudo
158
+ * will not stop to ask. A server that hangs on an invisible password prompt is
159
+ * worse than one that tells you the command to run yourself.
160
+ */
161
+ export function elevate(io: Runner, command: string[]): string[] | null {
162
+ const [head, ...rest] = command as [string, ...string[]];
163
+ if (typeof process.getuid === "function" && process.getuid() === 0) return [head, ...rest];
164
+ const canSudo = io.run("sudo", ["-n", "true"]);
165
+ return canSudo.status === 0 ? ["sudo", "-n", head, ...rest] : null;
166
+ }
package/src/sources.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Where a playlist comes from.
3
+ *
4
+ * A directory, a file, an .m3u, or a URL to any of those. ffmpeg reads a URL as
5
+ * happily as a path, so a remote track needs no special case once it is in the
6
+ * list; what needs care is telling the four apart, and telling an .m3u that
7
+ * lists tracks from an HLS playlist that *is* one track.
8
+ */
9
+
10
+ /** http and https only. ffmpeg speaks more, but these are what a link is. */
11
+ export function isRemote(source: string): boolean {
12
+ return /^https?:\/\//i.test(source);
13
+ }
14
+
15
+ export function isPlaylistFile(source: string): boolean {
16
+ const path = isRemote(source) ? new URL(source).pathname : source;
17
+ return /\.(m3u|m3u8|pls)$/i.test(path);
18
+ }
19
+
20
+ /**
21
+ * An HLS playlist describes one stream in segments; an .m3u describes a list of
22
+ * things to play. Both are "m3u8" on disk, and the tags are the only honest way
23
+ * to tell them apart. Expanding an HLS playlist into a track per segment would
24
+ * turn one song into four hundred.
25
+ */
26
+ export function isHls(text: string): boolean {
27
+ return /^#EXT-X-(?:STREAM-INF|TARGETDURATION|MEDIA-SEQUENCE|PLAYLIST-TYPE|ENDLIST)/im.test(text);
28
+ }
29
+
30
+ export interface Entry {
31
+ /** A path or a URL, whichever the playlist gave us. */
32
+ source: string;
33
+ title: string;
34
+ /** Seconds, from #EXTINF. Zero when it did not say, and for live. */
35
+ duration: number;
36
+ }
37
+
38
+ /** Resolve a playlist line against the playlist's own location. */
39
+ export function resolveEntry(base: string, entry: string): string {
40
+ if (isRemote(entry)) return entry;
41
+ if (isRemote(base)) return new URL(entry, base).toString();
42
+ if (entry.startsWith("/")) return entry;
43
+ const dir = base.slice(0, Math.max(0, base.lastIndexOf("/")));
44
+ return dir ? `${dir}/${entry}` : entry;
45
+ }
46
+
47
+ /**
48
+ * Parse an .m3u or .m3u8. `#EXTINF:<seconds>,<title>` decorates the line after
49
+ * it; everything else beginning with # is a comment or a tag we do not need.
50
+ */
51
+ export function parseM3u(text: string, base: string): Entry[] {
52
+ const out: Entry[] = [];
53
+ let duration = 0;
54
+ let title = "";
55
+
56
+ for (const raw of text.split(/\r?\n/)) {
57
+ const line = raw.trim();
58
+ if (line === "") continue;
59
+
60
+ if (line.startsWith("#")) {
61
+ const info = /^#EXTINF:\s*(-?[\d.]+)\s*(?:,(.*))?$/i.exec(line);
62
+ if (info) {
63
+ const seconds = Number(info[1]);
64
+ // -1 is the conventional "unknown", which is also what live means.
65
+ duration = Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
66
+ title = (info[2] ?? "").trim();
67
+ }
68
+ continue;
69
+ }
70
+
71
+ const source = resolveEntry(base, line);
72
+ out.push({ source, duration, title: title || nameOf(source) });
73
+ duration = 0;
74
+ title = "";
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** A .pls, which Shoutcast and Icecast hand out as often as an .m3u. */
80
+ export function parsePls(text: string, base: string): Entry[] {
81
+ const files = new Map<string, string>();
82
+ const titles = new Map<string, string>();
83
+ const lengths = new Map<string, number>();
84
+
85
+ for (const raw of text.split(/\r?\n/)) {
86
+ const line = raw.trim();
87
+ const match = /^(File|Title|Length)(\d+)\s*=\s*(.*)$/i.exec(line);
88
+ if (!match) continue;
89
+ const [, kind, index, value] = match as unknown as [string, string, string, string];
90
+ if (/^file$/i.test(kind)) files.set(index, value);
91
+ else if (/^title$/i.test(kind)) titles.set(index, value);
92
+ else lengths.set(index, Number(value));
93
+ }
94
+
95
+ return [...files.entries()]
96
+ .sort((a, b) => Number(a[0]) - Number(b[0]))
97
+ .map(([index, file]) => {
98
+ const source = resolveEntry(base, file);
99
+ const seconds = lengths.get(index) ?? 0;
100
+ return {
101
+ source,
102
+ title: titles.get(index)?.trim() || nameOf(source),
103
+ duration: Number.isFinite(seconds) && seconds > 0 ? seconds : 0,
104
+ };
105
+ });
106
+ }
107
+
108
+ /** The last useful part of a path or URL, for when nothing named the track. */
109
+ export function nameOf(source: string): string {
110
+ const remote = isRemote(source);
111
+ const path = remote ? new URL(source).pathname : source;
112
+ const last = path.split("/").filter(Boolean).pop() ?? source;
113
+ // Percent-decoding is a URL's business. A file on disk called
114
+ // `Some%20Song.mp3` is called exactly that, and renaming it in the display
115
+ // would be a lie about what is in the directory.
116
+ if (!remote) return last || source;
117
+ try {
118
+ return decodeURIComponent(last) || source;
119
+ } catch {
120
+ return last || source;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Formats a browser will play as-is. Anything else gets transcoded on the way
126
+ * out, which is the difference between a library that plays on a phone and one
127
+ * that plays on the machine it lives on.
128
+ */
129
+ const WEB_READY = new Set([".mp3", ".m4a", ".aac", ".ogg", ".oga", ".opus", ".webm", ".mp4", ".wav"]);
130
+
131
+ export function playsInBrowser(source: string): boolean {
132
+ if (isRemote(source)) return false;
133
+ const path = source.toLowerCase();
134
+ const dot = path.lastIndexOf(".");
135
+ return dot > 0 && WEB_READY.has(path.slice(dot));
136
+ }
@@ -0,0 +1,214 @@
1
+ <#
2
+ .SYNOPSIS
3
+ nixamp installer for Windows.
4
+
5
+ .DESCRIPTION
6
+ irm https://nixamp.com/install.ps1 | iex
7
+
8
+ Installs the app and the CLI under %LOCALAPPDATA%\nixamp. No administrator
9
+ rights, no registry, nothing outside your profile. The CLI runs on the Node
10
+ inside the app bundle, so no system Node is needed.
11
+
12
+ Updating is `nixamp update` and removing is `nixamp uninstall`, which runs a
13
+ script this installer leaves behind.
14
+
15
+ .PARAMETER CliOnly
16
+ Never install the app. The CLI then needs Node 24 or newer on PATH.
17
+
18
+ .PARAMETER Version
19
+ Install a specific release instead of the latest.
20
+
21
+ .PARAMETER Prefix
22
+ Install root. Defaults to $env:LOCALAPPDATA\nixamp.
23
+
24
+ .EXAMPLE
25
+ & ([scriptblock]::Create((irm https://nixamp.com/install.ps1))) -CliOnly
26
+ #>
27
+ [CmdletBinding()]
28
+ param(
29
+ [switch]$CliOnly,
30
+ [string]$Version = $env:NIXAMP_VERSION,
31
+ [string]$Prefix = $(if ($env:NIXAMP_PREFIX) { $env:NIXAMP_PREFIX } else { Join-Path $env:LOCALAPPDATA 'nixamp' })
32
+ )
33
+
34
+ $ErrorActionPreference = 'Stop'
35
+ $ProgressPreference = 'SilentlyContinue'
36
+
37
+ $Repo = 'profullstack/nixamp'
38
+ $Site = if ($env:NIXAMP_SITE) { $env:NIXAMP_SITE } else { 'https://nixamp.com' }
39
+
40
+ function Fail($message) {
41
+ Write-Error "nixamp: $message"
42
+ exit 1
43
+ }
44
+
45
+ # arm64 Windows exists and Electron ships for it, so do not assume x64.
46
+ $Arch = switch ($env:PROCESSOR_ARCHITECTURE) {
47
+ 'AMD64' { 'x64' }
48
+ 'ARM64' { 'arm64' }
49
+ 'x86' { Fail 'nixamp does not ship a 32-bit build.' }
50
+ default { 'x64' }
51
+ }
52
+
53
+ if (-not $Version) {
54
+ try {
55
+ $latest = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest"
56
+ $Version = $latest.tag_name -replace '^v', ''
57
+ } catch {
58
+ Fail "could not determine the latest version. Pass -Version, or see $Site"
59
+ }
60
+ }
61
+
62
+ $Base = if ($env:NIXAMP_RELEASE_BASE) {
63
+ $env:NIXAMP_RELEASE_BASE
64
+ } else {
65
+ "https://github.com/$Repo/releases/download/v$Version"
66
+ }
67
+
68
+ $Bin = Join-Path $Prefix 'bin'
69
+ $Share = Join-Path $Prefix 'share'
70
+
71
+ Write-Host "nixamp $Version"
72
+ Write-Host " platform: windows-$Arch"
73
+ Write-Host " desktop: $(if ($CliOnly) { 'no' } else { 'yes' })"
74
+ Write-Host " prefix: $Prefix"
75
+ Write-Host ""
76
+
77
+ $work = Join-Path ([System.IO.Path]::GetTempPath()) ("nixamp-" + [guid]::NewGuid().ToString('N'))
78
+ New-Item -ItemType Directory -Force -Path $work, $Bin, $Share | Out-Null
79
+
80
+ $paths = New-Object System.Collections.Generic.List[string]
81
+ $paths.Add((Join-Path $Bin 'nixamp.cmd'))
82
+ $paths.Add($Share)
83
+ $method = 'cli-tarball'
84
+ $appDir = Join-Path $Share 'app'
85
+ $cliDir = $null
86
+ $runtime = $null
87
+
88
+ # --- the app ------------------------------------------------------------------
89
+
90
+ if (-not $CliOnly) {
91
+ $asset = "nixamp-$Version-win-$Arch.zip"
92
+ Write-Host 'Downloading the app...'
93
+ try {
94
+ Invoke-WebRequest "$Base/$asset" -OutFile (Join-Path $work 'app.zip')
95
+ if (Test-Path $appDir) { Remove-Item -Recurse -Force $appDir }
96
+ New-Item -ItemType Directory -Force -Path $appDir | Out-Null
97
+ Expand-Archive -Path (Join-Path $work 'app.zip') -DestinationPath $appDir -Force
98
+ $method = 'windows-app'
99
+ $cliDir = Join-Path $appDir 'resources\cli'
100
+ $runtime = Join-Path $appDir 'nixamp.exe'
101
+
102
+ # A Start menu shortcut, which is what the NSIS installer would give you,
103
+ # done without touching anything outside the profile.
104
+ $menu = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\nixamp.lnk'
105
+ $shell = New-Object -ComObject WScript.Shell
106
+ $link = $shell.CreateShortcut($menu)
107
+ $link.TargetPath = $runtime
108
+ $link.WorkingDirectory = $appDir
109
+ $link.Description = "It really whips the terminal's ass."
110
+ $link.Save()
111
+ $paths.Add($menu)
112
+ } catch {
113
+ Write-Host " the app could not be installed ($($_.Exception.Message)); installing the CLI only."
114
+ $CliOnly = $true
115
+ $method = 'cli-tarball'
116
+ }
117
+ }
118
+
119
+ # --- the CLI ------------------------------------------------------------------
120
+
121
+ if ($method -eq 'cli-tarball') {
122
+ # Pure JavaScript, so one bundle runs everywhere a Node does.
123
+ $asset = "nixamp-cli-$Version.tar.gz"
124
+ Write-Host 'Downloading the CLI...'
125
+ $tarball = Join-Path $work 'cli.tar.gz'
126
+ try {
127
+ Invoke-WebRequest "$Base/$asset" -OutFile $tarball
128
+ } catch {
129
+ Fail "could not download $Base/$asset"
130
+ }
131
+
132
+ $cliDir = Join-Path $Share 'cli'
133
+ if (Test-Path $cliDir) { Remove-Item -Recurse -Force $cliDir }
134
+ New-Item -ItemType Directory -Force -Path $cliDir | Out-Null
135
+ # bsdtar has shipped in Windows since 1809 and reads gzip, so there is no
136
+ # third-party unpacker to ask for.
137
+ tar -xzf $tarball -C $cliDir --strip-components=1
138
+ if ($LASTEXITCODE -ne 0) { Fail 'could not unpack the CLI.' }
139
+
140
+ if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
141
+ Write-Host ' note: no app was installed, so the CLI needs Node 24 or newer. It was not found.'
142
+ }
143
+ }
144
+
145
+ # The shim, written here because only the installer knows which of the two
146
+ # runtimes this machine ended up with.
147
+ $entry = Join-Path $cliDir 'bin\nixamp.mjs'
148
+ $shim = Join-Path $Bin 'nixamp.cmd'
149
+ if ($runtime) {
150
+ @"
151
+ @echo off
152
+ rem nixamp. Runs on the Node inside the app, so no system Node is required.
153
+ rem Written by the installer; ``nixamp uninstall`` removes it.
154
+ set NIXAMP_HOME=$Share
155
+ set ELECTRON_RUN_AS_NODE=1
156
+ "$runtime" "$entry" %*
157
+ "@ | Set-Content -Path $shim -Encoding ASCII
158
+ } else {
159
+ @"
160
+ @echo off
161
+ rem nixamp. Written by the installer; ``nixamp uninstall`` removes it.
162
+ set NIXAMP_HOME=$Share
163
+ node "$entry" %*
164
+ "@ | Set-Content -Path $shim -Encoding ASCII
165
+ }
166
+
167
+ # --- what was installed, and how to remove it ---------------------------------
168
+
169
+ $manifest = [ordered]@{
170
+ version = $Version
171
+ method = $method
172
+ installer = "$Site/install.ps1"
173
+ installedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
174
+ prefix = $Prefix
175
+ desktop = ($method -eq 'windows-app')
176
+ paths = $paths.ToArray()
177
+ }
178
+ $manifest | ConvertTo-Json | Set-Content -Path (Join-Path $Share 'manifest.json') -Encoding UTF8
179
+
180
+ $removals = ($paths | ForEach-Object { "Remove-Item -Recurse -Force -ErrorAction SilentlyContinue '$_'" }) -join "`n"
181
+ @"
182
+ # Removes nixamp. Written by the installer, which knew exactly what it created.
183
+ # Your music is NOT touched.
184
+ $removals
185
+ Write-Host 'nixamp removed.'
186
+ "@ | Set-Content -Path (Join-Path $Share 'uninstall.ps1') -Encoding UTF8
187
+
188
+ Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
189
+
190
+ # --- PATH ---------------------------------------------------------------------
191
+
192
+ $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
193
+ if ($userPath -notlike "*$Bin*") {
194
+ [Environment]::SetEnvironmentVariable('Path', "$userPath;$Bin", 'User')
195
+ Write-Host ""
196
+ Write-Host "Added $Bin to your PATH. Open a new terminal for it to take effect."
197
+ }
198
+
199
+ Write-Host ""
200
+ Write-Host "Installed nixamp $Version"
201
+ Write-Host " $(if ($method -eq 'windows-app') { 'app and CLI' } else { 'CLI only' })"
202
+
203
+ # ffmpeg decodes every track. Saying so now beats a confusing failure on first
204
+ # use, when the playlist loads and nothing comes out of the speakers.
205
+ if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
206
+ Write-Host ""
207
+ Write-Host ' ffmpeg was not found, and nixamp decodes with ffmpeg.'
208
+ Write-Host ' winget install Gyan.FFmpeg'
209
+ }
210
+
211
+ Write-Host ""
212
+ Write-Host 'Try: nixamp %USERPROFILE%\Music'
213
+ Write-Host 'Update with `nixamp update`, remove with `nixamp uninstall`.'
214
+ Write-Host "Docs: $Site"
package/web/dist/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /* nixamp service worker — generated, do not edit */
2
- const CACHE = "nixamp-1788892198880";
2
+ const CACHE = "nixamp-1788894378334";
3
3
  const PRECACHE = [
4
4
  "/",
5
5
  "/apple-touch-icon.png",