From c98eb2f41da398c59721d8f079103aea7c9e71b5 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 30 Sep 2026 15:49:21 -0700 Subject: [PATCH] Rebrand as Playlink with a new sunset theme - Name: Playlink in the UI, page title, footer, messages and README (file names like playlink.db and .playlink-trash are unchanged). - Theme: plum-black surfaces with a coral-to-pink accent, a soft glow behind the page, gradient primary buttons, toggles and progress bars, accent focus rings and slim scrollbars. - New logo (beamed notes on the brand gradient) and a Play/link wordmark. - Inter variable font, bundled so it works under the strict CSP. Co-Authored-By: Claude Opus 5.5 --- README.md | 20 ++++----- internal/backup/backup.go | 10 ++--- internal/engine/ops.go | 2 +- internal/engine/scan.go | 2 +- web/index.html | 3 +- web/package-lock.json | 10 +++++ web/package.json | 1 + web/public/favicon.svg | 2 +- web/src/App.tsx | 13 +++--- web/src/index.css | 85 +++++++++++++++++++++++++++++++++---- web/src/pages/Activity.tsx | 2 +- web/src/pages/Backups.tsx | 16 +++---- web/src/pages/Build.tsx | 6 +-- web/src/pages/Bulk.tsx | 6 +-- web/src/pages/Cleanup.tsx | 4 +- web/src/pages/Dashboard.tsx | 2 +- web/src/pages/Login.tsx | 11 ++--- web/src/pages/PairForm.tsx | 2 +- web/src/pages/PairPage.tsx | 6 +-- web/src/pages/Renames.tsx | 4 +- web/src/pages/Review.tsx | 6 +-- web/src/pages/Settings.tsx | 4 +- web/src/ui.tsx | 24 ++++++++--- 23 files changed, 164 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 7b36429..06c3258 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# playlink +# Playlink -Deduplicate music libraries with hardlinks. playlink finds songs that exist in both a **master** library (e.g. your full `Albums`) and a +Deduplicate music libraries with hardlinks. Playlink finds songs that exist in both a **master** library (e.g. your full `Albums`) and a **slave** library (e.g. a curated `Singles` library) and replaces the slave copy with a hardlink to the master copy. Each song then takes up disk space only once, while both libraries keep looking exactly the same to Plex, Navidrome, Jellyfin and other media servers. @@ -13,7 +13,7 @@ Files are matched by their **path relative to each library root**. Any folder la `Genre/Artist/Title.ext`, flat, …) as long as both libraries use the same one, so that a song sits at the same relative path on both sides. The fallbacks below catch small differences, and a scan warns when the two layouts don't line up. -| Situation | What playlink does | +| Situation | What Playlink does | |---|---| | Same relative path, **byte-identical** | Status `identical`. Link with one click, in bulk, or automatically after each scan. | | Same path, **different bytes** (re-encode, different tags) | Status `different`. Manual review with a codec/bitrate/bit-depth/duration comparison. | @@ -51,7 +51,7 @@ Safeguards: codec-weighted bitrate (Opus and AAC count for more per kbps than MP3). You then have three choices: - **Upgrade / link to master copy.** The default. When the master copy is better, the match is flagged as an **upgrade** and listed - under the *Upgrades* filter. The master is kept because it belongs to a complete album. If the master is *lower* quality, playlink + under the *Upgrades* filter. The master is kept because it belongs to a complete album. If the master is *lower* quality, Playlink warns you and asks for explicit confirmation. - **Use slave copy as master.** The override. The slave's better file replaces the master file, the old master file is quarantined, and both libraries share the better file. @@ -76,14 +76,14 @@ Each row has the match status and type, a recommendation, both paths, where link bit depth and duration for both sides, the quality verdict and any notes. The file opens cleanly in Excel, Numbers and LibreOffice (UTF-8, and cells a spreadsheet would treat as formulas are escaped). -**Folders without music** (the *Cleanup* tab). Choose the slave or master library and **Find folders**: playlink lists the top-most +**Folders without music** (the *Cleanup* tab). Choose the slave or master library and **Find folders**: Playlink lists the top-most folders with no music file anywhere inside them (music = the pair's file extensions), such as album folders left empty after linking or ones holding only a cover or playlist, with what they still contain. Empty folders are pre-selected, folders that still hold files are not. **Remove** asks for confirmation (and an extra acknowledgement when the folders still hold files), re-checks each folder for music right before touching it, and moves it into the quarantine instead of deleting it: it can be restored from History until the retention period purges it. The library root, the quarantine and hidden folders are never listed, and dry run applies. -**Backups** (Settings → Backups), similar to Sonarr. A backup is a zip of playlink's database (settings, library pairs, scan results and +**Backups** (Settings → Backups), similar to Sonarr. A backup is a zip of Playlink's database (settings, library pairs, scan results and history, not music files) taken safely while the app runs. Choose a folder (default `/config/backups`), a schedule (off, every 1, 3, 7 or 14 days) and a retention period for scheduled backups; **Back up now** makes a manual one (kept until deleted). Backups can be downloaded, deleted, or restored, also from an uploaded zip. A restore is validated first (damaged backups and ones from a newer version @@ -92,12 +92,12 @@ policy brings it back automatically. Optionally, **back up before every action i before scans change files), never one per file; if that backup fails the action does not run, and the newest 10 are kept. **Build mode** covers the "start fresh" workflow. Point the slave at an empty folder, browse the master library, tick the -folders or songs you want, and playlink hardlinks them in at the same relative path. **Remove** deletes slave files, but only ones that are hardlinks of the +folders or songs you want, and Playlink hardlinks them in at the same relative path. **Remove** deletes slave files, but only ones that are hardlinks of the master file. ## Safety model -playlink is built so that no user data is ever lost, even if it crashes or loses power mid-operation: +Playlink is built so that no user data is ever lost, even if it crashes or loses power mid-operation: - **Dry run is on by default.** Every action is recorded in Activity as *planned* until you switch to live mode. - **Files are re-verified before any change.** Both files must still be plain regular files with the same inode, size and mtime that @@ -159,7 +159,7 @@ Run a scan, check the Review tab, and turn off dry run once the planned actions - **Both libraries must be visible through one bind mount.** Docker bind mounts are separate mount points, and `link(2)` fails across them (`EXDEV`) even when they are on the same disk. Mount a common parent folder, as shown above. -- **Both libraries must be on the same filesystem.** playlink checks this when you save a pair and before every operation. +- **Both libraries must be on the same filesystem.** Playlink checks this when you save a pair and before every operation. - **Unraid:** hardlinks through `/mnt/user` require *Settings → Global Share Settings → Tunable (support Hard Links)* to be *Yes* (the default). If the share uses a cache pool, make sure your mover preserves hardlinks. Every link is verified by inode, and a failed link aborts safely with the original untouched. @@ -176,7 +176,7 @@ Run a scan, check the Review tab, and turn off dry run once the planned actions | `PLAYLINK_LOG_LEVEL` | `info` | `debug` for verbose logs. | **Hide quarantine from Plex** (Settings → Media servers) writes a `.plexignore` into each pair's quarantine folder so Plex does not -pick up quarantined songs; new quarantine folders get it automatically. Turning it off removes the file again, but only if playlink +pick up quarantined songs; new quarantine folders get it automatically. Turning it off removes the file again, but only if Playlink created it. Per-pair settings (in the UI) are file extensions, auto-link, approximate matching and its threshold, scan interval, quarantine folder, diff --git a/internal/backup/backup.go b/internal/backup/backup.go index 1da3674..14cf951 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -41,7 +41,7 @@ const ( var nameRe = regexp.MustCompile(`^playlink_backup_(manual|scheduled|action)_\d{4}\.\d{2}\.\d{2}_\d{2}\.\d{2}\.\d{2}(_\d+)?\.zip$`) -var ErrBadName = errors.New("not a playlink backup file name") +var ErrBadName = errors.New("not a Playlink backup file name") // Settings stored in the settings table. const ( @@ -341,7 +341,7 @@ func (m *Manager) Stage(ctx context.Context, zipPath string, maxSchema int) erro } } if dbf == nil { - return errors.New("the zip does not contain a playlink database") + return errors.New("the zip does not contain a Playlink database") } tmp := filepath.Join(m.dataDir, stagedName+".tmp") _ = os.Remove(tmp) @@ -395,14 +395,14 @@ func validate(ctx context.Context, path string, maxSchema int) error { } var schema int if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(v), 0) FROM schema_version`).Scan(&schema); err != nil { - return errors.New("the backup is not a playlink database") + return errors.New("the backup is not a Playlink database") } if schema < 1 || schema > maxSchema { - return fmt.Errorf("the backup is from a newer version of playlink (schema %d, this version supports up to %d)", schema, maxSchema) + return fmt.Errorf("the backup is from a newer version of Playlink (schema %d, this version supports up to %d)", schema, maxSchema) } var n int if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('pairs', 'users', 'operations')`).Scan(&n); err != nil || n != 3 { - return errors.New("the backup is not a playlink database") + return errors.New("the backup is not a Playlink database") } return nil } diff --git a/internal/engine/ops.go b/internal/engine/ops.go index fda0083..cc76f4e 100644 --- a/internal/engine/ops.go +++ b/internal/engine/ops.go @@ -626,7 +626,7 @@ func (e *Engine) Undo(ctx context.Context, opID int64) error { default: // History can hold operations of features that were removed (tag // edits, file moves); undoing them as a link would be wrong. - return fmt.Errorf("%q operations can no longer be undone from playlink; their originals stay in the quarantine folder", o.Action) + return fmt.Errorf("%q operations can no longer be undone from Playlink; their originals stay in the quarantine folder", o.Action) } l, err := e.loadPair(ctx, o.PairID) if err != nil { diff --git a/internal/engine/scan.go b/internal/engine/scan.go index 4a98a3b..2c6ec15 100644 --- a/internal/engine/scan.go +++ b/internal/engine/scan.go @@ -345,7 +345,7 @@ func (e *Engine) scan(ctx context.Context, j *Job, pairID int64, trigger string) } if ms.symlinks+ss.symlinks > 0 { warns = append(warns, store.Warning{Title: fmt.Sprintf("Ignored %d symlinks", ms.symlinks+ss.symlinks), - Detail: "playlink never follows or replaces symlinks."}) + Detail: "Playlink never follows or replaces symlinks."}) } if e.prober.Bin == "" { warns = append(warns, store.Warning{Title: "ffprobe was not found", diff --git a/web/index.html b/web/index.html index db89679..237a62d 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,8 @@ - playlink + Playlink +
diff --git a/web/package-lock.json b/web/package-lock.json index c6a3dcb..82f0f22 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "playlink-web", "version": "0.1.0", "dependencies": { + "@fontsource-variable/inter": "^5.3.0", "@tanstack/react-query": "^5.103.2", "lucide-react": "^1.47.0", "react": "^19.3.0", @@ -23,6 +24,15 @@ "vite": "^8.3.0" } }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/web/package.json b/web/package.json index 5214171..d2d9f51 100644 --- a/web/package.json +++ b/web/package.json @@ -9,6 +9,7 @@ "typecheck": "tsc -b" }, "dependencies": { + "@fontsource-variable/inter": "^5.3.0", "@tanstack/react-query": "^5.103.2", "lucide-react": "^1.47.0", "react": "^19.3.0", diff --git a/web/public/favicon.svg b/web/public/favicon.svg index 37a07a5..10eee94 100644 --- a/web/public/favicon.svg +++ b/web/public/favicon.svg @@ -1 +1 @@ - + diff --git a/web/src/App.tsx b/web/src/App.tsx index f63c761..47fae50 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,7 +7,7 @@ import { Dashboard } from "./pages/Dashboard"; import { PairPage } from "./pages/PairPage"; import { ActivityPage } from "./pages/Activity"; import { SettingsPage } from "./pages/Settings"; -import { Spinner, cx } from "./ui"; +import { Spinner, cx, Brand } from "./ui"; import { useToast } from "./toast"; export function useRoute() { @@ -74,9 +74,8 @@ function Shell({ username }: { username: string }) {
- - - playlink + +
)}
{page}
-
playlink {info.data?.version}
+
Playlink {info.data?.version}
); } diff --git a/web/src/index.css b/web/src/index.css index 37dd171..534d446 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,14 +1,81 @@ @import "tailwindcss"; +@import "@fontsource-variable/inter"; +/* Playlink "sunset" theme: plum-black surfaces with a coral-to-pink accent. */ @theme { - --color-bg: #0b0c10; - --color-panel: #13151b; - --color-panel-2: #1a1d25; - --color-line: #262a35; - --color-muted: #8a91a3; - --color-accent: #8b5cf6; + --font-sans: "Inter Variable", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + + --color-bg: #0d0b12; + --color-panel: #15121c; + --color-panel-2: #1d1926; + --color-line: #2b2637; + --color-muted: #9690a8; + + --color-accent: #ff7a59; /* coral */ + --color-accent-strong: #ff9275; /* hover */ + --color-accent-2: #ff4f9a; /* pink, the other end of the brand gradient */ + --color-on-accent: #1f0c06; /* text on coral: dark reads better than white */ } -html, body, #root { height: 100%; } -body { background: var(--color-bg); color: #e6e8ee; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; } -code, .mono { font-family: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace; } +html, +body, +#root { + height: 100%; +} + +body { + background-color: var(--color-bg); + /* A soft sunset glow behind the page. */ + background-image: + radial-gradient(60rem 28rem at 12% -8rem, rgb(255 122 89 / 0.09), transparent 70%), + radial-gradient(50rem 26rem at 95% -10rem, rgb(255 79 154 / 0.08), transparent 70%); + background-attachment: fixed; + background-repeat: no-repeat; + color: #ece9f3; + font-family: var(--font-sans); + font-feature-settings: "cv11", "ss01"; /* Inter: single-storey a, open digits */ + -webkit-font-smoothing: antialiased; +} + +code, +.mono { + font-family: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace; +} + +::selection { + background: rgb(255 122 89 / 0.35); +} + +/* The brand gradient, for the wordmark and small highlights. */ +.brand-gradient { + background-image: linear-gradient(100deg, var(--color-accent), var(--color-accent-2)); +} +.brand-text { + background-image: linear-gradient(100deg, var(--color-accent), var(--color-accent-2)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +:focus-visible { + outline: 2px solid rgb(255 122 89 / 0.7); + outline-offset: 2px; +} + +input[type="checkbox"], +input[type="radio"] { + accent-color: var(--color-accent); +} + +/* Slim scrollbars that match the panels. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--color-line) transparent; +} + +/* Fields show their own accent ring when focused. */ +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: none; +} diff --git a/web/src/pages/Activity.tsx b/web/src/pages/Activity.tsx index f6ae22a..eaa2692 100644 --- a/web/src/pages/Activity.tsx +++ b/web/src/pages/Activity.tsx @@ -12,7 +12,7 @@ export function ActivityPage() {

Activity

-

Every change playlink makes is recorded here and can be undone.

+

Every change Playlink makes is recorded here and can be undone.

{(["all", "quarantine"] as const).map((v) => ( diff --git a/web/src/pages/Backups.tsx b/web/src/pages/Backups.tsx index 6c2eec1..b8aec79 100644 --- a/web/src/pages/Backups.tsx +++ b/web/src/pages/Backups.tsx @@ -5,7 +5,7 @@ import { api, type Backup, type BackupConfig } from "../api"; import { Alert, Badge, Button, Card, Field, Modal, Spinner, Toggle, formatAgo, formatBytes, inputCls } from "../ui"; import { useToast } from "../toast"; -const typeTone = { manual: "violet", scheduled: "blue", action: "gray" } as const; +const typeTone = { manual: "accent", scheduled: "blue", action: "gray" } as const; const typeLabel = { manual: "manual", scheduled: "scheduled", action: "before action" } as const; export function BackupsCard() { @@ -75,7 +75,7 @@ export function BackupsCard() { Backups

- Backups hold playlink's database: settings, library pairs, scan results and history. They do not contain music files; file + Backups hold Playlink's database: settings, library pairs, scan results and history. They do not contain music files; file changes are undone from History.

@@ -175,15 +175,15 @@ export function BackupsCard() { setRestore(null)} title="Restore this backup?">

- playlink's database is replaced with {restore?.name ?? restore?.file?.name}: settings, library + Playlink's database is replaced with {restore?.name ?? restore?.file?.name}: settings, library pairs, scan results and history go back to how they were then.

    -
  • The backup is checked first; a damaged backup or one from a newer playlink version is refused.
  • +
  • The backup is checked first; a damaged backup or one from a newer Playlink version is refused.
  • The current database is kept next to it (playlink.db.pre-restore-…), so this can be reversed by hand.
  • Music files are not changed. History entries made after the backup are no longer listed, so undo them first if needed.
  • - playlink restarts to finish. In Docker it comes back by itself; if you run it directly, start it again. + Playlink restarts to finish. In Docker it comes back by itself; if you run it directly, start it again.
@@ -214,7 +214,7 @@ export function BackupsCard() { ); } -/** Waits for playlink to come back after a restore, then reloads the page. */ +/** Waits for Playlink to come back after a restore, then reloads the page. */ function Restarting() { const [slow, setSlow] = useState(false); useEffect(() => { @@ -235,12 +235,12 @@ function Restarting() { return ( {}} title="Restoring backup…">
- playlink is restarting with the restored database. This page reloads when it is back. + Playlink is restarting with the restored database. This page reloads when it is back.
{slow && (
- playlink hasn't come back yet. If you run it without Docker (or without a restart policy), start it again; the restore is applied when + Playlink hasn't come back yet. If you run it without Docker (or without a restart policy), start it again; the restore is applied when it starts.
diff --git a/web/src/pages/Build.tsx b/web/src/pages/Build.tsx index 4d651f5..171c23b 100644 --- a/web/src/pages/Build.tsx +++ b/web/src/pages/Build.tsx @@ -39,7 +39,7 @@ export function Build({ pair }: { pair: Pair }) { return (
- Build the slave library from the master. Browse the master library, pick folders or songs, and playlink hardlinks them into the slave + Build the slave library from the master. Browse the master library, pick folders or songs, and Playlink hardlinks them into the slave library at the same relative path. No extra space is used. Existing slave files are never overwritten, and Remove only deletes slave files that are hardlinks of the master file, so the master copy is always kept. @@ -81,7 +81,7 @@ export function Build({ pair }: { pair: Pair }) {