diff --git a/README.md b/README.md
index 2bb45ed4..7ac4fc55 100644
--- a/README.md
+++ b/README.md
@@ -51,20 +51,20 @@ Drop in an MP3, WAV, FLAC, OGG/Opus, MP4, or M4A file, or paste a YouTube URL, a
StemDeck is free and **does not accept any money, sponsorship, or funding** from anyone listed below. I share these makers and artists and communities purely for the joy of pointing you toward wonderful people doing beautiful work. Go meet them ❤️
-| Name | What they do | Link |
-|---|---|---|
-| Analog4Lyfe | All-analog music gear, no digital shortcuts | [@analog4lyfe](https://www.instagram.com/analog4lyfe) |
-| r/bass | My beloved bass community on reddit | [r/Bass](https://www.reddit.com/r/Bass) |
-| Beltr | Turns the songs you already own into karaoke gold, right on your own machine, no subscription, no cloud, just you and the mic | [beltr.app](https://beltr.app/) |
-| Dlima Guitars | Custom guitars and basses, built one at a time | [@dlimaguitars](https://www.instagram.com/dlimaguitars) |
-| Empress Effects | Boutique effects pedals for tone chasers who don't settle | [empresseffects.com](https://empresseffects.com) |
-| Joao Gaspar | Producer and film scorer, also plays as a touring/session musician | [@jay_glaspar](https://www.instagram.com/jay_glaspar) |
-| Kris Luthier | Hand-repairs and restores instruments in Lisbon, one careful fix at a time | [@krisluthier](https://www.instagram.com/krisluthier) |
-| Lisbon Guitar Works | Guitars built by hand in Lisbon | [dlimaguitars.com](https://dlimaguitars.com) |
-| More Notes Less Talk | Instruments and gear with personality, recorded raw to tape. No hype, no gatekeeping. | [@morenoteslesstalk](https://www.youtube.com/@morenoteslesstalk) |
-| Seratone | Turns any TV into a studio-grade karaoke stage | [seratone.audio](https://seratone.audio/) |
-| slashCAM | German-language camera and video tech: hands-on tests, industry news, and the post-production details most reviews skip | [@slashcam.de](https://www.instagram.com/slashcam.de) |
-| Thomann | One of Europe's largest music gear retailers, practically everything a musician could need | [@thomann.music](https://www.instagram.com/thomann.music) |
+| Category | Name | What they do | Link |
+|---|---|---|---|
+| Artists & Creators | Joao Gaspar | Producer, film scorer, touring/session musician | [@jay_glaspar](https://www.instagram.com/jay_glaspar) |
+| Artists & Creators | More Notes Less Talk | Gear-focused creative project with a raw, tape-recorded identity | [@morenoteslesstalk](https://www.youtube.com/@morenoteslesstalk) |
+| Instrument Builders & Repair | Dlima Guitars | Custom guitars and basses | [@dlimaguitars](https://www.instagram.com/dlimaguitars) |
+| Instrument Builders & Repair | Lisbon Guitar Works | Handmade guitars in Lisbon | [dlimaguitars.com](https://dlimaguitars.com) |
+| Instrument Builders & Repair | Kris Luthier | Instrument repair and restoration | [@krisluthier](https://www.instagram.com/krisluthier) |
+| Music Gear | Analog4Lyfe | Analog gear specialist | [@analog4lyfe](https://www.instagram.com/analog4lyfe) |
+| Music Gear | Empress Effects | Boutique effects pedals | [empresseffects.com](https://empresseffects.com) |
+| Music Gear | Thomann | Large music-equipment retailer | [@thomann.music](https://www.instagram.com/thomann.music) |
+| Music & Karaoke Technology | Beltr | Local, subscription-free karaoke software | [beltr.app](https://beltr.app/) |
+| Music & Karaoke Technology | Seratone | TV-based karaoke system | [seratone.audio](https://seratone.audio/) |
+| Media & Community | slashCAM | Camera, video, and post-production media | [@slashcam.de](https://www.instagram.com/slashcam.de) |
+| Media & Community | r/bass | Bass-player community | [r/Bass](https://www.reddit.com/r/Bass) |
---
diff --git a/static/css/daw.css b/static/css/daw.css
index b25c9210..70d694e7 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -3373,58 +3373,81 @@ input, textarea { font-family: inherit; }
}
.lib-tag-count { color: var(--muted); font-size: 11px; }
-/* Supporters (partner tiles, shown in the TV-icon dialog) */
-/* Slightly wider card so 3 tiles breathe; grid spans the card. */
-.friends-card { width: min(360px, calc(100vw - 32px)); }
+/* Supporters ("We Recommend" dialog): category sections, each a small uppercase
+ accent label with a rule, over a responsive grid of partner cards. */
+/* Wider than the stock about-card so 3 cards per row still breathe. */
+.friends-card { width: min(420px, calc(100vw - 32px)); }
.friends-card .lib-friends-grid { width: 100%; margin-top: 4px; }
.lib-friends-grid {
- /* Masonry columns: each column stacks independently so a tall tile does not
- push others down. Tiles are round-robined into these columns in JS. */
- display: flex;
- align-items: flex-start;
- gap: 6px;
- padding: 2px 0 0;
+ /* Five sections is taller than the viewport on a short window, so the list
+ scrolls inside the card rather than pushing it off-screen. */
+ text-align: left;
+ padding: 2px 2px 0 0;
+ max-height: min(58vh, 520px);
+ overflow-y: auto;
}
-.lib-friends-col {
- flex: 1 1 0;
- min-width: 0;
+.lib-friends-cat {
display: flex;
- flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ margin: 14px 0 7px;
+ font-family: var(--font-mono);
+ font-size: 9.5px;
+ font-weight: 700;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+ color: var(--accent);
+}
+.lib-friends-cat:first-child { margin-top: 2px; }
+/* Thin rule filling the width left over by the label. */
+.lib-friends-cat::after {
+ content: "";
+ flex: 1 1 auto;
+ height: 1px;
+ background: var(--border);
+}
+.lib-friends-row {
+ display: grid;
+ /* ~3 per row in the card's default width, dropping to 2 as it narrows. */
+ grid-template-columns: repeat(auto-fit, minmax(108px, 1fr));
gap: 6px;
}
.lib-friend {
display: flex;
flex-direction: column;
align-items: center;
- gap: 6px;
+ gap: 5px;
min-width: 0;
- padding: 9px 5px 12px;
+ padding: 10px 6px 9px;
background: var(--panel);
border: 1px solid var(--border);
- border-radius: 8px;
+ border-radius: 10px;
color: var(--fg-2);
text-decoration: none;
text-align: center;
- /* deliberately-uneven "frames on a wall" tilt; per-tile angle set in JS */
- transform: rotate(var(--tilt, 0deg));
- transition: background var(--t-fast), color var(--t-fast),
- border-color var(--t-fast), transform var(--t-fast);
+ transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast);
}
.lib-friend:hover {
background: var(--panel-2);
color: var(--fg);
border-color: rgba(255,255,255,0.15);
- /* straighten the frame on hover */
- transform: rotate(0deg);
}
-/* Logos are transparent wordmarks; span the tile width, keep aspect, cap height. */
-.lib-friend-logo {
+/* Fixed-height slot so names line up across a row whichever of the three
+ shapes an entry ends up with: round avatar, wordmark, or monogram. */
+.lib-friend-media {
+ display: flex;
+ align-items: center;
+ justify-content: center;
width: 100%;
- height: auto;
- max-height: 30px;
+ height: 44px;
+}
+/* Wordmark logos are transparent and not square: letterbox them into the slot. */
+.lib-friend-logo {
+ max-width: 100%;
+ max-height: 40px;
object-fit: contain;
}
-/* Instagram profile photos render as round avatars */
+/* Profile photos are square: crop to a circle. */
.lib-friend-avatar {
width: 44px;
height: 44px;
@@ -3432,7 +3455,7 @@ input, textarea { font-family: inherit; }
object-fit: cover;
border: 1px solid var(--border);
}
-/* Monogram fallback when a tile has no image (or it fails to load): matches the
+/* Monogram fallback when a card has no image (or it fails to load): matches the
round avatar size, in the accent colour, so the grid stays on-brand. */
.lib-friend-monogram {
width: 44px;
@@ -3448,24 +3471,35 @@ input, textarea { font-family: inherit; }
background: var(--panel-3);
border: 1px solid var(--border);
}
-/* Small Instagram glyph under the text on tiles that link to Instagram */
-.lib-friend-ig {
+/* Small link glyph at the foot of the card. margin-top:auto pins it to the
+ bottom so it lines up across cards with different role lengths. */
+.lib-friend-ig,
+.lib-friend-link {
/* block + no-shrink avoids the WebKit baseline clip on small inline SVGs */
display: block;
flex: 0 0 auto;
- width: 14px;
- height: 14px;
- margin-top: 2px;
- fill: currentColor;
- opacity: 0.65;
+ width: 13px;
+ height: 13px;
+ margin-top: auto;
+ padding-top: 3px;
+ opacity: 0.6;
}
-.lib-friend:hover .lib-friend-ig {
- opacity: 1;
+.lib-friend-ig { fill: currentColor; }
+.lib-friend-link {
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
}
+.lib-friend:hover .lib-friend-ig,
+.lib-friend:hover .lib-friend-link { opacity: 1; }
.lib-friend-name {
width: 100%;
- font-size: 10.5px;
+ font-size: 11px;
+ font-weight: 600;
line-height: 1.2;
+ color: var(--fg);
/* full name across up to 2 lines, then ellipsis */
display: -webkit-box;
-webkit-line-clamp: 2;
@@ -3474,11 +3508,10 @@ input, textarea { font-family: inherit; }
}
.lib-friend-role {
width: 100%;
- margin-top: 2px;
- font-size: 9px;
- line-height: 1.2;
- color: var(--fg-3, var(--fg-2));
- /* show the full role; the tile grows to fit it */
+ font-size: 9.5px;
+ line-height: 1.3;
+ color: var(--muted);
+ /* show the full role; the card grows to fit it */
}
/* ── Clear bin bar (trash view only) ── */
diff --git a/static/img/friends/analog4lyfe.jpg b/static/img/friends/analog4lyfe.jpg
new file mode 100644
index 00000000..05c6f2cd
Binary files /dev/null and b/static/img/friends/analog4lyfe.jpg differ
diff --git a/static/img/friends/beltr.jpg b/static/img/friends/beltr.jpg
new file mode 100644
index 00000000..7a2d2a9e
Binary files /dev/null and b/static/img/friends/beltr.jpg differ
diff --git a/static/img/friends/empress-effects.png b/static/img/friends/empress-effects.png
new file mode 100644
index 00000000..9cf74b44
Binary files /dev/null and b/static/img/friends/empress-effects.png differ
diff --git a/static/img/friends/seratone.jpg b/static/img/friends/seratone.jpg
new file mode 100644
index 00000000..9bb77f7f
Binary files /dev/null and b/static/img/friends/seratone.jpg differ
diff --git a/static/img/friends/thomann.jpg b/static/img/friends/thomann.jpg
new file mode 100644
index 00000000..7fb1a838
Binary files /dev/null and b/static/img/friends/thomann.jpg differ
diff --git a/static/js/catalog.js b/static/js/catalog.js
index b3476c35..1ca361de 100644
--- a/static/js/catalog.js
+++ b/static/js/catalog.js
@@ -28,78 +28,123 @@ const STORAGE_KEY = "stemdeck.folders";
const STORAGE_VERSION = 2; // bump to wipe stale seeded data
const DELETED_JOBS_KEY = "stemdeck.deleted_jobs";
-// Curated "Our Friends" partners shown at the bottom of the library. Add an
-// entry here to feature another store/band/etc. Logos are bundled under
-// static/img/friends/ so they render offline. Links open externally via the
-// document-level a[target="_blank"] handler in main.js (Tauri open_url).
-const FRIENDS = [
- {
- name: "Analog4Lyfe",
- role: "All-analog music gear, no digital shortcuts",
- url: "https://www.instagram.com/analog4lyfe",
- logo: "/img/friends/analog4lyfe.jpg",
- avatar: true,
- },
- {
- name: "Beltr",
- role: "Turns the songs you already own into karaoke gold, right on your own machine, no subscription, no cloud, just you and the mic",
- url: "https://beltr.app/",
- },
- {
- name: "Dlima Guitars",
- role: "Custom guitars and basses, built one at a time",
- url: "https://www.instagram.com/dlimaguitars",
- logo: "/img/friends/dlima-guitars-ig.jpg",
- avatar: true,
- },
- {
- name: "Empress Effects",
- role: "Boutique effects pedals for tone chasers who don't settle",
- url: "https://empresseffects.com",
- logo: "/img/friends/empress-effects.png",
- },
- {
- name: "Joao Gaspar",
- role: "Producer and film scorer, also plays as a touring/session musician",
- url: "https://www.instagram.com/jay_glaspar",
- logo: "/img/friends/joao-gaspar.jpg",
- avatar: true,
- },
- {
- name: "Kris Luthier",
- role: "Hand-repairs and restores instruments in Lisbon, one careful fix at a time",
- url: "https://www.instagram.com/krisluthier",
- logo: "/img/friends/kris-luthier.jpg",
- avatar: true,
- },
+// Curated "We Recommend" partners shown in the library's supporters dialog,
+// grouped into categories that render in this order. Add an entry to a group's
+// `members` to feature another store/band/etc.
+//
+// Logos are bundled under static/img/friends/ so they render offline. An entry
+// with `avatar: true` has a square profile photo, cropped to a circle; without
+// it the image is treated as a transparent wordmark and letterboxed into the
+// same slot. An entry with no `logo` at all falls back to an initial badge.
+//
+// `labelKey` and `roleKey` are i18n keys rather than literals: the rendered
+// nodes carry them as data-i18n, so setLanguage()'s applyTranslations(document)
+// pass re-resolves the text without the dialog having to be rebuilt.
+//
+// Links open externally via the document-level a[target="_blank"] handler in
+// main.js (Tauri open_url on desktop).
+const FRIEND_GROUPS = [
{
- name: "Lisbon Guitar Works",
- role: "Guitars built by hand in Lisbon",
- url: "https://dlimaguitars.com",
- logo: "/img/friends/lisbon-guitar-works.webp",
+ labelKey: "friends.cat.artists",
+ members: [
+ {
+ name: "Joao Gaspar",
+ roleKey: "friends.role.joaoGaspar",
+ url: "https://www.instagram.com/jay_glaspar",
+ logo: "/img/friends/joao-gaspar.jpg",
+ avatar: true,
+ },
+ {
+ name: "More Notes Less Talk",
+ roleKey: "friends.role.moreNotesLessTalk",
+ url: "https://www.youtube.com/@morenoteslesstalk",
+ },
+ ],
},
{
- name: "More Notes Less Talk",
- role: "Instruments and gear with personality, recorded raw to tape. No hype, no gatekeeping.",
- url: "https://www.youtube.com/@morenoteslesstalk",
+ labelKey: "friends.cat.builders",
+ members: [
+ {
+ name: "Dlima Guitars",
+ roleKey: "friends.role.dlimaGuitars",
+ url: "https://www.instagram.com/dlimaguitars",
+ logo: "/img/friends/dlima-guitars-ig.jpg",
+ avatar: true,
+ },
+ {
+ name: "Lisbon Guitar Works",
+ roleKey: "friends.role.lisbonGuitarWorks",
+ url: "https://dlimaguitars.com",
+ logo: "/img/friends/lisbon-guitar-works.webp",
+ },
+ {
+ name: "Kris Luthier",
+ roleKey: "friends.role.krisLuthier",
+ url: "https://www.instagram.com/krisluthier",
+ logo: "/img/friends/kris-luthier.jpg",
+ avatar: true,
+ },
+ ],
},
{
- name: "Seratone",
- role: "Turns any TV into a studio-grade karaoke stage",
- url: "https://seratone.audio/",
+ labelKey: "friends.cat.gear",
+ members: [
+ {
+ name: "Analog4Lyfe",
+ roleKey: "friends.role.analog4lyfe",
+ url: "https://www.instagram.com/analog4lyfe",
+ logo: "/img/friends/analog4lyfe.jpg",
+ avatar: true,
+ },
+ {
+ name: "Empress Effects",
+ roleKey: "friends.role.empressEffects",
+ url: "https://empresseffects.com",
+ logo: "/img/friends/empress-effects.png",
+ },
+ {
+ name: "Thomann",
+ roleKey: "friends.role.thomann",
+ url: "https://www.instagram.com/thomann.music",
+ logo: "/img/friends/thomann.jpg",
+ avatar: true,
+ },
+ ],
},
{
- name: "slashCAM",
- role: "German-language camera and video tech: hands-on tests, industry news, and the post-production details most reviews skip",
- url: "https://www.instagram.com/slashcam.de",
- logo: "/img/friends/slashcam.webp",
+ labelKey: "friends.cat.karaoke",
+ members: [
+ {
+ name: "Beltr",
+ roleKey: "friends.role.beltr",
+ url: "https://beltr.app/",
+ logo: "/img/friends/beltr.jpg",
+ avatar: true,
+ },
+ {
+ name: "Seratone",
+ roleKey: "friends.role.seratone",
+ url: "https://seratone.audio/",
+ logo: "/img/friends/seratone.jpg",
+ avatar: true,
+ },
+ ],
},
{
- name: "Thomann",
- role: "One of Europe's largest music gear retailers, practically everything a musician could need",
- url: "https://www.instagram.com/thomann.music",
- logo: "/img/friends/thomann.jpg",
- avatar: true,
+ labelKey: "friends.cat.media",
+ members: [
+ {
+ name: "slashCAM",
+ roleKey: "friends.role.slashcam",
+ url: "https://www.instagram.com/slashcam.de",
+ logo: "/img/friends/slashcam.webp",
+ },
+ {
+ name: "r/bass",
+ roleKey: "friends.role.rbass",
+ url: "https://www.reddit.com/r/Bass/",
+ },
+ ],
},
];
@@ -107,6 +152,15 @@ const FRIENDS = [
const IG_ICON_PATH =
"M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z";
+// Generic link glyph (Feather "link"), shown on cards that do not link to
+// Instagram. Stroked rather than filled, so it needs its own CSS class.
+const LINK_ICON_PATHS = [
+ "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71",
+ "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71",
+];
+
+const SVGNS = "http://www.w3.org/2000/svg";
+
let folders = [];
let tracks = {};
let _deletedJobIds = new Set();
@@ -305,19 +359,23 @@ function saveState() {
export function addTrackToLibrary(track) {
// track: { id, title, channel, thumb, stems, status, sourceUrl }
const existingId = findTrackBySource(track.sourceUrl, track.id);
- if (existingId) {
- const trash = getTrashFolder();
- const inTrash = trash?.items.includes(existingId);
- if (inTrash) {
- // Old track was trashed — delete it silently so the new import lands
- // in the library instead of inheriting the trash placement.
- delete tracks[existingId];
- for (const f of folders) f.items = f.items.filter((id) => id !== existingId);
- } else {
- replaceTrackId(existingId, track.id);
- }
- // The old track is gone either way (deleted or replaced) — any failure
- // notification tied to it, whichever kind, is moot now (#401).
+ // A match already in the Trash is left exactly where it is. It is a distinct
+ // job with its own files on disk, and the user is the only one who decides
+ // when those go. The new track is in no folder yet, so the placement below
+ // still lands it in the library; evicting the old one was never what put it
+ // there.
+ //
+ // Evicting it did real damage: it dropped the catalog entry without deleting
+ // the job, so the directory and its registry record outlived their only
+ // reference. syncWithServer then found a job with no track, no trash entry
+ // and no tombstone, and re-adopted it into the library on the next launch.
+ // One extra job sharing the source URL was enough to reach this, so trashing
+ // a track and restarting brought it back.
+ if (existingId && !getTrashFolder()?.items.includes(existingId)) {
+ replaceTrackId(existingId, track.id);
+ // The old track is gone, so any failure notification tied to it is moot
+ // now (#401). A trashed match keeps the dismissal moveTrackToTrash already
+ // did, and nothing here revives it.
dismissFailuresByJobId(existingId);
}
const existing = tracks[track.id] || {};
@@ -2639,9 +2697,82 @@ function wireAboutDialog() {
});
}
+// The small glyph at the foot of a partner card: the Instagram mark for an
+// Instagram profile, a generic link glyph for anything else.
+function friendGlyph(url) {
+ const instagram = /instagram\.com/i.test(url || "");
+ const svg = document.createElementNS(SVGNS, "svg");
+ svg.setAttribute("class", instagram ? "lib-friend-ig" : "lib-friend-link");
+ svg.setAttribute("viewBox", "0 0 24 24");
+ svg.setAttribute("aria-hidden", "true");
+ for (const d of instagram ? [IG_ICON_PATH] : LINK_ICON_PATHS) {
+ const p = document.createElementNS(SVGNS, "path");
+ p.setAttribute("d", d);
+ svg.appendChild(p);
+ }
+ return svg;
+}
+
+// One partner card: image (or initial badge), name, role, link glyph.
+function friendCard(f) {
+ const a = document.createElement("a");
+ a.className = "lib-friend";
+ a.href = f.url;
+ a.target = "_blank";
+ a.rel = "noopener noreferrer";
+ a.title = f.name;
+
+ // A monogram badge (first initial) keeps the card on-brand when an entry has
+ // no image, or when its image fails to load (e.g. before the asset is added).
+ const makeMonogram = () => {
+ const m = document.createElement("span");
+ m.className = "lib-friend-monogram";
+ m.textContent = (f.name || "?").trim().charAt(0).toUpperCase();
+ m.setAttribute("aria-hidden", "true");
+ return m;
+ };
+
+ // Fixed-height slot so names line up across a row whichever of the three
+ // shapes (round avatar, wordmark, monogram) an entry ends up with.
+ const media = document.createElement("span");
+ media.className = "lib-friend-media";
+ if (f.logo) {
+ const img = document.createElement("img");
+ img.className = f.avatar ? "lib-friend-avatar" : "lib-friend-logo";
+ img.src = f.logo;
+ img.alt = f.name;
+ img.loading = "lazy";
+ img.addEventListener("error", () => img.replaceWith(makeMonogram()));
+ media.appendChild(img);
+ } else {
+ media.appendChild(makeMonogram());
+ }
+ a.appendChild(media);
+
+ // Partner names are proper nouns and stay as-is; only the role is translated.
+ const name = document.createElement("span");
+ name.className = "lib-friend-name";
+ name.textContent = f.name;
+ a.appendChild(name);
+
+ if (f.roleKey) {
+ const role = document.createElement("span");
+ role.className = "lib-friend-role";
+ // data-i18n lets applyTranslations() re-resolve this on a language switch;
+ // the textContent below is what shows until then.
+ role.setAttribute("data-i18n", f.roleKey);
+ role.textContent = i18nT(f.roleKey);
+ a.appendChild(role);
+ }
+
+ a.appendChild(friendGlyph(f.url));
+ return a;
+}
+
// Supporters dialog: a TV rail button opens a centered modal (like About) with
-// the partner tiles. Links open externally via the document-level
-// a[target="_blank"] handler in main.js (Tauri open_url on desktop).
+// the partner cards, grouped by category. Links open externally via the
+// document-level a[target="_blank"] handler in main.js (Tauri open_url on
+// desktop).
function wireSupportersDialog() {
const btn = document.getElementById("friendsBtn");
const dialog = document.getElementById("friendsDialog");
@@ -2651,69 +2782,17 @@ function wireSupportersDialog() {
if (grid && grid.dataset.ready !== "1") {
grid.dataset.ready = "1";
- // Masonry: round-robin tiles into fixed columns so a tall tile in one
- // column does not push the next row down. Small per-tile tilt gives the
- // deliberately-uneven "frames on a wall" look.
- const COLS = 3;
- const tilts = ["-2deg", "1.5deg", "-1deg", "2deg", "-1.5deg", "1deg"];
- const cols = [];
- for (let i = 0; i < COLS; i++) {
- const col = document.createElement("div");
- col.className = "lib-friends-col";
- cols.push(col);
- grid.appendChild(col);
- }
- FRIENDS.forEach((f, i) => {
- const a = document.createElement("a");
- a.className = "lib-friend";
- a.href = f.url;
- a.target = "_blank";
- a.rel = "noopener noreferrer";
- a.title = f.name;
- a.style.setProperty("--tilt", tilts[i % tilts.length]);
- // A monogram avatar (first initial) keeps the tile on-brand when an entry
- // has no image, or its image fails to load (e.g. before the asset is added).
- const makeMonogram = () => {
- const m = document.createElement("span");
- m.className = "lib-friend-monogram";
- m.textContent = (f.name || "?").trim().charAt(0).toUpperCase();
- m.setAttribute("aria-hidden", "true");
- return m;
- };
- if (f.logo) {
- const img = document.createElement("img");
- img.className = f.avatar ? "lib-friend-avatar" : "lib-friend-logo";
- img.src = f.logo;
- img.alt = f.name;
- img.loading = "lazy";
- img.addEventListener("error", () => img.replaceWith(makeMonogram()));
- a.appendChild(img);
- } else {
- a.appendChild(makeMonogram());
- }
- const name = document.createElement("span");
- name.className = "lib-friend-name";
- name.textContent = f.name;
- a.appendChild(name);
- if (f.role) {
- const role = document.createElement("span");
- role.className = "lib-friend-role";
- role.textContent = f.role;
- a.appendChild(role);
- }
- if (/instagram\.com/i.test(f.url || "")) {
- const SVGNS = "http://www.w3.org/2000/svg";
- const ig = document.createElementNS(SVGNS, "svg");
- ig.setAttribute("class", "lib-friend-ig");
- ig.setAttribute("viewBox", "0 0 24 24");
- ig.setAttribute("aria-hidden", "true");
- const p = document.createElementNS(SVGNS, "path");
- p.setAttribute("d", IG_ICON_PATH);
- ig.appendChild(p);
- a.appendChild(ig);
- }
- cols[i % COLS].appendChild(a);
- });
+ for (const group of FRIEND_GROUPS) {
+ const label = document.createElement("h3");
+ label.className = "lib-friends-cat";
+ label.setAttribute("data-i18n", group.labelKey);
+ label.textContent = i18nT(group.labelKey);
+ grid.appendChild(label);
+ const row = document.createElement("div");
+ row.className = "lib-friends-row";
+ for (const f of group.members) row.appendChild(friendCard(f));
+ grid.appendChild(row);
+ }
}
const open = () => dialog.classList.remove("hidden");
diff --git a/static/js/i18n.js b/static/js/i18n.js
index 4b55b361..02fac7fc 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -466,6 +466,23 @@ const en = {
"friends.title": "We Recommend",
"friends.tagline": "Wonderful people doing beautiful work. Go meet them ❤️",
"friends.closeAria": "Close friends dialog",
+ "friends.cat.artists": "Artists & Creators",
+ "friends.cat.builders": "Instrument Builders & Repair",
+ "friends.cat.gear": "Music Gear",
+ "friends.cat.karaoke": "Music & Karaoke Technology",
+ "friends.cat.media": "Media & Community",
+ "friends.role.joaoGaspar": "Producer, film scorer, touring/session musician",
+ "friends.role.moreNotesLessTalk": "Gear-focused creative project with a raw, tape-recorded identity",
+ "friends.role.dlimaGuitars": "Custom guitars and basses",
+ "friends.role.lisbonGuitarWorks": "Handmade guitars in Lisbon",
+ "friends.role.krisLuthier": "Instrument repair and restoration",
+ "friends.role.analog4lyfe": "Analog gear specialist",
+ "friends.role.empressEffects": "Boutique effects pedals",
+ "friends.role.thomann": "Large music-equipment retailer",
+ "friends.role.beltr": "Local, subscription-free karaoke software",
+ "friends.role.seratone": "TV-based karaoke system",
+ "friends.role.slashcam": "Camera, video, and post-production media",
+ "friends.role.rbass": "Bass-player community",
"release.title": "New release available",
"release.closeAria": "Close release dialog",
@@ -1030,6 +1047,23 @@ const pl = {
"friends.title": "Polecamy",
"friends.tagline": "Wspaniali ludzie tworzący piękne rzeczy. Poznaj ich ❤️",
"friends.closeAria": "Zamknij okno poleceń",
+ "friends.cat.artists": "Artyści i twórcy",
+ "friends.cat.builders": "Lutnicy i naprawa instrumentów",
+ "friends.cat.gear": "Sprzęt muzyczny",
+ "friends.cat.karaoke": "Technologie muzyczne i karaoke",
+ "friends.cat.media": "Media i społeczność",
+ "friends.role.joaoGaspar": "Producent, kompozytor muzyki filmowej, muzyk koncertowy i sesyjny",
+ "friends.role.moreNotesLessTalk": "Projekt twórczy skupiony na sprzęcie, o surowym brzmieniu nagranym na taśmę",
+ "friends.role.dlimaGuitars": "Gitary i basy na zamówienie",
+ "friends.role.lisbonGuitarWorks": "Gitary robione ręcznie w Lizbonie",
+ "friends.role.krisLuthier": "Naprawa i renowacja instrumentów",
+ "friends.role.analog4lyfe": "Specjalista od sprzętu analogowego",
+ "friends.role.empressEffects": "Butikowe efekty gitarowe",
+ "friends.role.thomann": "Duży sklep ze sprzętem muzycznym",
+ "friends.role.beltr": "Lokalne karaoke bez abonamentu",
+ "friends.role.seratone": "System karaoke na telewizor",
+ "friends.role.slashcam": "Media o kamerach, wideo i postprodukcji",
+ "friends.role.rbass": "Społeczność basistów",
"release.title": "Dostępna nowa wersja",
"release.closeAria": "Zamknij okno wydania",
@@ -1124,6 +1158,7 @@ const pl = {
"library.importedFile": "Zaimportowany plik",
"library.syncing": "Synchronizowanie…",
"library.syncFailed": "Synchronizacja nie powiodła się — sprawdź połączenie.",
+ "library.deleteFailed": "Nie udało się usunąć {count} utworów. Nie ma ich już w bibliotece, ale ich pliki mogą nadal być na dysku.",
"library.recent": "Ostatnie",
"library.stemCollections": "Kolekcje ścieżek",
"library.tags": "Tagi",
@@ -1584,6 +1619,23 @@ const ja = {
"friends.title": "おすすめ",
"friends.tagline": "美しい仕事をする素晴らしい人たち。ぜひ会いに行ってみてください ❤️",
"friends.closeAria": "おすすめダイアログを閉じる",
+ "friends.cat.artists": "アーティストとクリエイター",
+ "friends.cat.builders": "楽器製作とリペア",
+ "friends.cat.gear": "音楽機材",
+ "friends.cat.karaoke": "音楽とカラオケの技術",
+ "friends.cat.media": "メディアとコミュニティ",
+ "friends.role.joaoGaspar": "プロデューサー、映画音楽家、ツアー/セッションミュージシャン",
+ "friends.role.moreNotesLessTalk": "機材を軸に、テープ録音の生々しさを大切にするクリエイティブプロジェクト",
+ "friends.role.dlimaGuitars": "オーダーメイドのギターとベース",
+ "friends.role.lisbonGuitarWorks": "リスボンの手作りギター",
+ "friends.role.krisLuthier": "楽器のリペアとレストア",
+ "friends.role.analog4lyfe": "アナログ機材のスペシャリスト",
+ "friends.role.empressEffects": "ブティック系エフェクターペダル",
+ "friends.role.thomann": "大手の楽器機材販売店",
+ "friends.role.beltr": "サブスク不要、ローカルで動くカラオケソフト",
+ "friends.role.seratone": "テレビで使えるカラオケシステム",
+ "friends.role.slashcam": "カメラ、映像、ポストプロダクションのメディア",
+ "friends.role.rbass": "ベーシストのコミュニティ",
"release.title": "新しいリリースがあります",
"release.closeAria": "リリースダイアログを閉じる",
@@ -1672,6 +1724,7 @@ const ja = {
"library.importedFile": "インポートされたファイル",
"library.syncing": "同期中…",
"library.syncFailed": "同期に失敗しました — 接続を確認してください。",
+ "library.deleteFailed": "{count} 曲を削除できませんでした。ライブラリからは外れていますが、ファイルはディスクに残っている可能性があります。",
"library.recent": "最近",
"library.stemCollections": "パートコレクション",
"library.tags": "タグ",
@@ -2114,6 +2167,23 @@ const zhHans = {
"friends.title": "推荐",
"friends.tagline": "一群做着美好事情的了不起的人。去认识他们吧 ❤️",
"friends.closeAria": "关闭推荐对话框",
+ "friends.cat.artists": "艺术家与创作者",
+ "friends.cat.builders": "乐器制作与维修",
+ "friends.cat.gear": "音乐器材",
+ "friends.cat.karaoke": "音乐与卡拉OK技术",
+ "friends.cat.media": "媒体与社区",
+ "friends.role.joaoGaspar": "制作人、电影配乐师、巡演/录音乐手",
+ "friends.role.moreNotesLessTalk": "以器材为主的创作项目,保留磁带录音的粗粝质感",
+ "friends.role.dlimaGuitars": "定制吉他与贝斯",
+ "friends.role.lisbonGuitarWorks": "里斯本手工吉他",
+ "friends.role.krisLuthier": "乐器维修与修复",
+ "friends.role.analog4lyfe": "模拟器材专家",
+ "friends.role.empressEffects": "精品效果器",
+ "friends.role.thomann": "大型乐器器材零售商",
+ "friends.role.beltr": "本地运行、无需订阅的卡拉OK软件",
+ "friends.role.seratone": "基于电视的卡拉OK系统",
+ "friends.role.slashcam": "相机、视频与后期制作媒体",
+ "friends.role.rbass": "贝斯手社区",
"release.title": "有新版本可用",
"release.closeAria": "关闭版本对话框",
@@ -2202,6 +2272,7 @@ const zhHans = {
"library.importedFile": "已导入的文件",
"library.syncing": "正在同步…",
"library.syncFailed": "同步失败 — 请检查你的网络连接。",
+ "library.deleteFailed": "有 {count} 首曲目未能删除。它们已不在音乐库中,但文件可能仍在磁盘上。",
"library.recent": "最近",
"library.stemCollections": "音轨合集",
"library.tags": "标签",
@@ -2645,6 +2716,23 @@ const de = {
"friends.title": "Wir empfehlen",
"friends.tagline": "Wunderbare Menschen, die schöne Arbeit leisten. Lern sie kennen ❤️",
"friends.closeAria": "Empfehlungsdialog schließen",
+ "friends.cat.artists": "Künstler und Kreative",
+ "friends.cat.builders": "Instrumentenbau und Reparatur",
+ "friends.cat.gear": "Musik-Equipment",
+ "friends.cat.karaoke": "Musik- und Karaoke-Technik",
+ "friends.cat.media": "Medien und Community",
+ "friends.role.joaoGaspar": "Produzent, Filmkomponist, Tour- und Sessionmusiker",
+ "friends.role.moreNotesLessTalk": "Kreativprojekt rund um Equipment, roh auf Band aufgenommen",
+ "friends.role.dlimaGuitars": "Gitarren und Bässe nach Maß",
+ "friends.role.lisbonGuitarWorks": "Handgebaute Gitarren aus Lissabon",
+ "friends.role.krisLuthier": "Reparatur und Restaurierung von Instrumenten",
+ "friends.role.analog4lyfe": "Spezialist für analoges Equipment",
+ "friends.role.empressEffects": "Boutique-Effektpedale",
+ "friends.role.thomann": "Großer Händler für Musik-Equipment",
+ "friends.role.beltr": "Lokale Karaoke-Software ohne Abo",
+ "friends.role.seratone": "Karaoke-System für den Fernseher",
+ "friends.role.slashcam": "Medien zu Kamera, Video und Postproduktion",
+ "friends.role.rbass": "Community für Bassistinnen und Bassisten",
"release.title": "Neue Version verfügbar",
"release.closeAria": "Versionsdialog schließen",
@@ -2736,6 +2824,7 @@ const de = {
"library.importedFile": "Importierte Datei",
"library.syncing": "Wird synchronisiert…",
"library.syncFailed": "Synchronisierung fehlgeschlagen — Verbindung prüfen.",
+ "library.deleteFailed": "{count} Titel konnten nicht gelöscht werden. Sie bleiben aus deiner Bibliothek entfernt, ihre Dateien liegen aber möglicherweise noch auf der Festplatte.",
"library.recent": "Zuletzt verwendet",
"library.stemCollections": "Stem-Sammlungen",
"library.tags": "Tags",
@@ -3186,6 +3275,23 @@ const pt = {
"friends.title": "Recomendamos",
"friends.tagline": "Pessoas maravilhosas fazendo um trabalho lindo. Conheça-as ❤️",
"friends.closeAria": "Fechar diálogo de recomendações",
+ "friends.cat.artists": "Artistas e criadores",
+ "friends.cat.builders": "Luteria e reparos",
+ "friends.cat.gear": "Equipamentos musicais",
+ "friends.cat.karaoke": "Tecnologia musical e karaokê",
+ "friends.cat.media": "Mídia e comunidade",
+ "friends.role.joaoGaspar": "Produtor, compositor de trilhas, músico de turnê e de estúdio",
+ "friends.role.moreNotesLessTalk": "Projeto criativo focado em equipamentos, com identidade crua gravada em fita",
+ "friends.role.dlimaGuitars": "Guitarras e baixos sob medida",
+ "friends.role.lisbonGuitarWorks": "Guitarras feitas à mão em Lisboa",
+ "friends.role.krisLuthier": "Reparo e restauração de instrumentos",
+ "friends.role.analog4lyfe": "Especialista em equipamentos analógicos",
+ "friends.role.empressEffects": "Pedais de efeito boutique",
+ "friends.role.thomann": "Grande varejista de equipamentos musicais",
+ "friends.role.beltr": "Software de karaokê local, sem assinatura",
+ "friends.role.seratone": "Sistema de karaokê para TV",
+ "friends.role.slashcam": "Mídia sobre câmera, vídeo e pós-produção",
+ "friends.role.rbass": "Comunidade de baixistas",
"release.title": "Nova versão disponível",
"release.closeAria": "Fechar diálogo de versão",
@@ -3277,6 +3383,7 @@ const pt = {
"library.importedFile": "Arquivo importado",
"library.syncing": "Sincronizando…",
"library.syncFailed": "Falha na sincronização — verifique sua conexão.",
+ "library.deleteFailed": "Não foi possível excluir {count} faixa(s). Elas ficam fora da sua biblioteca, mas os arquivos podem continuar no disco.",
"library.recent": "Recentes",
"library.stemCollections": "Coleções de stems",
"library.tags": "Tags",
@@ -3728,6 +3835,23 @@ const id = {
"friends.title": "Rekomendasi",
"friends.tagline": "Orang-orang hebat yang melakukan pekerjaan indah. Temui mereka ❤️",
"friends.closeAria": "Tutup dialog rekomendasi",
+ "friends.cat.artists": "Artis dan Kreator",
+ "friends.cat.builders": "Pembuat dan Reparasi Instrumen",
+ "friends.cat.gear": "Perangkat Musik",
+ "friends.cat.karaoke": "Teknologi Musik dan Karaoke",
+ "friends.cat.media": "Media dan Komunitas",
+ "friends.role.joaoGaspar": "Produser, penata musik film, musisi tur dan sesi",
+ "friends.role.moreNotesLessTalk": "Proyek kreatif seputar perangkat, dengan karakter mentah rekaman pita",
+ "friends.role.dlimaGuitars": "Gitar dan bas custom",
+ "friends.role.lisbonGuitarWorks": "Gitar buatan tangan di Lisbon",
+ "friends.role.krisLuthier": "Reparasi dan restorasi instrumen",
+ "friends.role.analog4lyfe": "Spesialis perangkat analog",
+ "friends.role.empressEffects": "Pedal efek butik",
+ "friends.role.thomann": "Peritel besar perangkat musik",
+ "friends.role.beltr": "Perangkat lunak karaoke lokal tanpa langganan",
+ "friends.role.seratone": "Sistem karaoke berbasis TV",
+ "friends.role.slashcam": "Media seputar kamera, video, dan pascaproduksi",
+ "friends.role.rbass": "Komunitas pemain bas",
"release.title": "Versi baru tersedia",
"release.closeAria": "Tutup dialog versi",
@@ -3816,6 +3940,7 @@ const id = {
"library.importedFile": "File yang diimpor",
"library.syncing": "Menyinkronkan…",
"library.syncFailed": "Sinkronisasi gagal — periksa koneksi Anda.",
+ "library.deleteFailed": "{count} lagu tidak dapat dihapus. Lagu-lagu itu tetap di luar pustaka Anda, tetapi berkasnya mungkin masih ada di disk.",
"library.recent": "Terbaru",
"library.stemCollections": "Koleksi Stem",
"library.tags": "Tag",
@@ -4259,6 +4384,23 @@ const fr = {
"friends.title": "Nos recommandations",
"friends.tagline": "Des gens formidables qui font de belles choses. Allez les rencontrer ❤️",
"friends.closeAria": "Fermer la fenêtre des recommandations",
+ "friends.cat.artists": "Artistes et créateurs",
+ "friends.cat.builders": "Lutherie et réparation",
+ "friends.cat.gear": "Matériel de musique",
+ "friends.cat.karaoke": "Technologies musicales et karaoké",
+ "friends.cat.media": "Médias et communauté",
+ "friends.role.joaoGaspar": "Producteur, compositeur de musique de film, musicien de tournée et de studio",
+ "friends.role.moreNotesLessTalk": "Projet créatif axé sur le matériel, à l'identité brute enregistrée sur bande",
+ "friends.role.dlimaGuitars": "Guitares et basses sur mesure",
+ "friends.role.lisbonGuitarWorks": "Guitares faites main à Lisbonne",
+ "friends.role.krisLuthier": "Réparation et restauration d'instruments",
+ "friends.role.analog4lyfe": "Spécialiste du matériel analogique",
+ "friends.role.empressEffects": "Pédales d'effets boutique",
+ "friends.role.thomann": "Grand détaillant de matériel de musique",
+ "friends.role.beltr": "Logiciel de karaoké local, sans abonnement",
+ "friends.role.seratone": "Système de karaoké sur téléviseur",
+ "friends.role.slashcam": "Médias sur la caméra, la vidéo et la postproduction",
+ "friends.role.rbass": "Communauté de bassistes",
"release.title": "Nouvelle version disponible",
"release.closeAria": "Fermer la fenêtre de version",
@@ -4351,6 +4493,7 @@ const fr = {
"library.importedFile": "Fichier importé",
"library.syncing": "Synchronisation…",
"library.syncFailed": "Échec de la synchronisation — vérifiez votre connexion.",
+ "library.deleteFailed": "{count} morceau(x) n'ont pas pu être supprimés. Ils restent hors de votre bibliothèque, mais leurs fichiers peuvent encore se trouver sur le disque.",
"library.recent": "Récents",
"library.stemCollections": "Collections de pistes",
"library.tags": "Tags",
@@ -4593,6 +4736,7 @@ const ptPT = {
"settings.logs.backendAria": "Log do backend (só de leitura)",
"settings.logs.backend.desc": "A última hora de backend.log, saída bruta do processo Python incorporado, incluindo qualquer coisa que o tenha bloqueado antes de o log da aplicação conseguir registar. Apenas app desktop. Só de leitura.",
"library.syncFailed": "Falha na sincronização, verifique a sua ligação.",
+ "library.deleteFailed": "Não foi possível eliminar {count} faixa(s). Ficam fora da sua biblioteca, mas os ficheiros podem continuar no disco.",
"resetConfirm.failedConnection": "Falha ao repor, verifique a sua ligação.",
"search.failed": "A pesquisa falhou. Verifique a sua ligação.",
"settings.network.noConnection": "Nenhuma ligação de rede local detetada.",
@@ -4652,6 +4796,15 @@ const ptPT = {
"settings.stemsLocation.movedPersistFailed.one": "{count} item movido, mas o StemDeck não conseguiu guardar isto como o novo local (verifique se a pasta é gravável). Reiniciar agora reverteria para o local antigo. Tente definir novamente.",
"settings.stemsLocation.movedPersistFailed.other": "{count} itens movidos, mas o StemDeck não conseguiu guardar isto como o novo local (verifique se a pasta é gravável). Reiniciar agora reverteria para o local antigo. Tente definir novamente.",
"failure.hint": "Estes detalhes vão para o relatório. O título da faixa e o link de origem não são incluídos, adicione-os se ajudar. Clicar num botão abaixo copia isto para a área de transferência.",
+ "friends.cat.builders": "Luteria e reparações",
+ "friends.cat.karaoke": "Tecnologia musical e karaoke",
+ "friends.cat.media": "Media e comunidade",
+ "friends.role.joaoGaspar": "Produtor, compositor de bandas sonoras, músico de digressão e de estúdio",
+ "friends.role.krisLuthier": "Reparação e restauro de instrumentos",
+ "friends.role.thomann": "Grande retalhista de equipamento musical",
+ "friends.role.beltr": "Software de karaoke local, sem subscrição",
+ "friends.role.seratone": "Sistema de karaoke para TV",
+ "friends.role.slashcam": "Media sobre câmara, vídeo e pós-produção",
};
const es = {
@@ -4901,6 +5054,23 @@ const es = {
"friends.title": "Recomendamos",
"friends.tagline": "Gente maravillosa haciendo un trabajo precioso. Ve a saludarlos ❤️",
"friends.closeAria": "Cerrar el diálogo de recomendaciones",
+ "friends.cat.artists": "Artistas y creadores",
+ "friends.cat.builders": "Luthería y reparación",
+ "friends.cat.gear": "Equipo musical",
+ "friends.cat.karaoke": "Tecnología musical y karaoke",
+ "friends.cat.media": "Medios y comunidad",
+ "friends.role.joaoGaspar": "Productor, compositor de bandas sonoras, músico de gira y de sesión",
+ "friends.role.moreNotesLessTalk": "Proyecto creativo centrado en el equipo, con una identidad cruda grabada en cinta",
+ "friends.role.dlimaGuitars": "Guitarras y bajos a medida",
+ "friends.role.lisbonGuitarWorks": "Guitarras hechas a mano en Lisboa",
+ "friends.role.krisLuthier": "Reparación y restauración de instrumentos",
+ "friends.role.analog4lyfe": "Especialista en equipo analógico",
+ "friends.role.empressEffects": "Pedales de efectos boutique",
+ "friends.role.thomann": "Gran tienda de equipo musical",
+ "friends.role.beltr": "Software de karaoke local y sin suscripción",
+ "friends.role.seratone": "Sistema de karaoke para televisión",
+ "friends.role.slashcam": "Medios sobre cámara, vídeo y posproducción",
+ "friends.role.rbass": "Comunidad de bajistas",
"release.title": "Nueva versión disponible",
"release.closeAria": "Cerrar el diálogo de la versión",
@@ -4993,6 +5163,7 @@ const es = {
"library.importedFile": "Archivo importado",
"library.syncing": "Sincronizando…",
"library.syncFailed": "La sincronización falló — revisa tu conexión.",
+ "library.deleteFailed": "No se pudieron eliminar {count} pista(s). Se quedan fuera de tu biblioteca, pero sus archivos pueden seguir en el disco.",
"library.recent": "Recientes",
"library.stemCollections": "Colecciones de stems",
"library.tags": "Etiquetas",
@@ -5464,6 +5635,23 @@ const ko = {
"friends.title": "추천",
"friends.tagline": "좋은 일을 하는 멋진 사람들이에요. 한번 만나 보세요 ❤️",
"friends.closeAria": "추천 창 닫기",
+ "friends.cat.artists": "아티스트와 크리에이터",
+ "friends.cat.builders": "악기 제작과 수리",
+ "friends.cat.gear": "음악 장비",
+ "friends.cat.karaoke": "음악과 노래방 기술",
+ "friends.cat.media": "미디어와 커뮤니티",
+ "friends.role.joaoGaspar": "프로듀서, 영화 음악 작곡가, 투어 및 세션 뮤지션",
+ "friends.role.moreNotesLessTalk": "장비를 중심으로 테이프 녹음의 거친 질감을 살린 창작 프로젝트",
+ "friends.role.dlimaGuitars": "주문 제작 기타와 베이스",
+ "friends.role.lisbonGuitarWorks": "리스본에서 손으로 만드는 기타",
+ "friends.role.krisLuthier": "악기 수리와 복원",
+ "friends.role.analog4lyfe": "아날로그 장비 전문",
+ "friends.role.empressEffects": "부티크 이펙터 페달",
+ "friends.role.thomann": "대형 음악 장비 판매점",
+ "friends.role.beltr": "구독이 필요 없는 로컬 노래방 소프트웨어",
+ "friends.role.seratone": "TV 기반 노래방 시스템",
+ "friends.role.slashcam": "카메라, 영상, 후반 작업 미디어",
+ "friends.role.rbass": "베이시스트 커뮤니티",
"release.title": "새 버전이 나왔어요",
"release.closeAria": "릴리스 창 닫기",
@@ -5552,6 +5740,7 @@ const ko = {
"library.importedFile": "가져온 파일",
"library.syncing": "동기화 중…",
"library.syncFailed": "동기화하지 못했어요. 연결을 확인해 주세요.",
+ "library.deleteFailed": "{count}곡을 삭제하지 못했습니다. 라이브러리에서는 빠졌지만 파일은 디스크에 남아 있을 수 있습니다.",
"library.recent": "최근",
"library.stemCollections": "스템 모음",
"library.tags": "태그",
diff --git a/tests/e2e/helpers.mjs b/tests/e2e/helpers.mjs
index 421d1391..6ee6ac65 100644
--- a/tests/e2e/helpers.mjs
+++ b/tests/e2e/helpers.mjs
@@ -15,33 +15,68 @@
export const JOB_ID = "e2e0deadbeef";
export const TRACK_TITLE = "E2E Fixture Track";
+// The second job seed.py writes, a re-extraction of the same source. Its
+// source_url is identical to the fixture track's, which is what makes the
+// catalog's dedup-by-source branch reachable (#542).
+export const SIBLING_JOB_ID = "e2e0cafebabe";
+export const SIBLING_TITLE = "E2E Fixture Track (again)";
+export const SOURCE_URL = "local:e2e-fixture.wav";
+
const STORAGE_KEY = "stemdeck.folders";
const STORAGE_VERSION = 2;
-/** Put the fixture track in the library so the sidebar renders it. */
+/** The library store's shape for one finished track. */
+export function fixtureTrack(id, title) {
+ return {
+ id,
+ title,
+ status: "done",
+ stems: ["vocals", "drums", "bass", "other"],
+ sourceUrl: SOURCE_URL,
+ createdAt: 1700000000,
+ favorite: false,
+ };
+}
+
+/**
+ * Write the library store before any of the app's scripts run.
+ *
+ * Tests that care about which folder a track is in build the state themselves
+ * and call this. seedLibrary is the default arrangement on top of it.
+ */
+export async function seedCatalogState(page, { folders, tracks }) {
+ await page.addInitScript(
+ ([key, value]) => window.localStorage.setItem(key, JSON.stringify(value)),
+ [STORAGE_KEY, { v: STORAGE_VERSION, folders, tracks }],
+ );
+}
+
+/** Read the library store back, to assert on what was persisted. */
+export async function readCatalogState(page) {
+ return page.evaluate((key) => JSON.parse(window.localStorage.getItem(key) || "null"), STORAGE_KEY);
+}
+
+/**
+ * Put both fixture tracks in the library so the sidebar renders them.
+ *
+ * Both, not just one. syncWithServer imports any server job the store does not
+ * already know, and the sibling shares the fixture track's source_url, so
+ * leaving it out would send every page load in the suite through
+ * addTrackToLibrary's dedup branch. That branch renames the existing entry to
+ * the incoming job's id, and `.cat-item[data-id="e2e0deadbeef"]` -- which most
+ * specs here click -- would stop existing.
+ */
export async function seedLibrary(page) {
- const state = {
- v: STORAGE_VERSION,
+ await seedCatalogState(page, {
folders: [
- { id: "f-unsorted", name: "Unsorted", items: [JOB_ID], color: null },
+ { id: "f-unsorted", name: "Unsorted", items: [JOB_ID, SIBLING_JOB_ID], color: null },
{ id: "trash", name: "Trash", items: [], color: null },
],
tracks: {
- [JOB_ID]: {
- id: JOB_ID,
- title: TRACK_TITLE,
- status: "done",
- stems: ["vocals", "drums", "bass", "other"],
- sourceUrl: "local:e2e-fixture.wav",
- createdAt: 1700000000,
- favorite: false,
- },
+ [JOB_ID]: fixtureTrack(JOB_ID, TRACK_TITLE),
+ [SIBLING_JOB_ID]: fixtureTrack(SIBLING_JOB_ID, SIBLING_TITLE),
},
- };
- await page.addInitScript(
- ([key, value]) => window.localStorage.setItem(key, JSON.stringify(value)),
- [STORAGE_KEY, state],
- );
+ });
}
/**
diff --git a/tests/e2e/seed.py b/tests/e2e/seed.py
index 55c8f337..ec844e13 100644
--- a/tests/e2e/seed.py
+++ b/tests/e2e/seed.py
@@ -1,4 +1,4 @@
-"""Build a jobs directory containing one finished track, for the browser tests.
+"""Build a jobs directory containing two finished tracks, for the browser tests.
The point is that the tests talk to the real backend: real Range requests for
stems, the real registry, the real endpoints. Only the pipeline is skipped,
@@ -15,6 +15,21 @@
back to decoding every stem for its waveforms, and the click track never
appeared at all.
+There are two jobs, and they deliberately share one `source_url`. That is the
+whole shape of #542: the catalog deduplicates imports by source URL, so a second
+job for a URL the library already knows is what makes the dedup branch run at
+all. Processing the same link twice is ordinary user behaviour, and until #542
+it silently evicted a trashed track. A fixture with a single job cannot reach
+that code path.
+
+The sibling is shorter than the fixture track. Nothing needs six seconds of it,
+and the tone generator is a per-sample Python loop, so the extra seconds are
+paid on every single run of the suite.
+
+Both jobs are real and complete -- stems, peaks and beats -- so a later test can
+open either one. A half-built second job would look usable in the registry and
+fail only once someone clicked it.
+
Usage: python tests/e2e/seed.py
"""
@@ -29,10 +44,18 @@
JOB_ID = "e2e0deadbeef"
TITLE = "E2E Fixture Track"
+DURATION_SEC = 6
+
+# A second extraction of the same source. Same `source_url` as the fixture
+# track, which is the point: see the module docstring.
+SIBLING_JOB_ID = "e2e0cafebabe"
+SIBLING_TITLE = "E2E Fixture Track (again)"
+SIBLING_DURATION_SEC = 2
+
+SOURCE_URL = "local:e2e-fixture.wav"
STEMS = ["vocals", "drums", "bass", "other"]
SAMPLE_RATE = 44100
CHANNELS = 2
-DURATION_SEC = 6
def _wav_bytes(freq: float, seconds: int = DURATION_SEC) -> bytes:
@@ -65,13 +88,13 @@ def _peaks(points: int = 400) -> list[list[float]]:
return out
-def seed(jobs_dir: Path) -> str:
- job_dir = jobs_dir / JOB_ID
- stems_dir = job_dir / "stems"
+def _build_job(jobs_dir: Path, job_id: str, title: str, seconds: int) -> dict:
+ """Write one finished job's files and return its registry record."""
+ stems_dir = jobs_dir / job_id / "stems"
stems_dir.mkdir(parents=True, exist_ok=True)
for index, name in enumerate(STEMS):
- (stems_dir / f"{name}.wav").write_bytes(_wav_bytes(220.0 * (index + 1)))
+ (stems_dir / f"{name}.wav").write_bytes(_wav_bytes(220.0 * (index + 1), seconds))
(stems_dir / "peaks.json").write_text(
json.dumps({name: _peaks() for name in STEMS}), encoding="utf-8"
@@ -86,7 +109,7 @@ def seed(jobs_dir: Path) -> str:
# Shape matches what beatgrid.py emits, `bars` included: with no bar marks
# the accent mode falls back to "Auto (none found)" and the detected-meter
# path never runs.
- beats = [round(i * 0.5, 3) for i in range(DURATION_SEC * 2)]
+ beats = [round(i * 0.5, 3) for i in range(seconds * 2)]
(stems_dir / "beats.json").write_text(
json.dumps(
{
@@ -97,7 +120,7 @@ def seed(jobs_dir: Path) -> str:
# 2 s. Enough for "Auto (detected)" and a one-bar count-in.
"bars": [{"beat": 0, "beats_per_bar": 4}],
"bpm": 120.0,
- "duration": float(DURATION_SEC),
+ "duration": float(seconds),
"confidence": 95,
# The grid editor snaps dragged beats onto these.
"onsets": beats,
@@ -110,14 +133,14 @@ def seed(jobs_dir: Path) -> str:
# Field names are the dataclass's, not the API's: from_record filters on
# Job's own fields, so "stage" or "duration" would be silently dropped and
# the track would load without a duration.
- record = {
- "id": JOB_ID,
+ return {
+ "id": job_id,
"status": "done",
"progress": 1.0,
"stage_message": "Done",
- "title": TITLE,
- "duration_sec": float(DURATION_SEC),
- "source_url": "local:e2e-fixture.wav",
+ "title": title,
+ "duration_sec": float(seconds),
+ "source_url": SOURCE_URL,
# Now, not a fixed date. The hourly sweep deletes job directories older
# than JOB_TTL_SECONDS, and it runs at startup: a fixture with a
# hardcoded timestamp is reaped before the first test opens the page,
@@ -131,15 +154,23 @@ def seed(jobs_dir: Path) -> str:
# Same shape the pipeline writes (runner.py): entries, not bare names.
# A list of strings deserialises without error and then leaves the
# studio with nothing to play.
- "stems": [{"name": name, "url": f"/api/jobs/{JOB_ID}/stems/{name}.wav"} for name in STEMS],
+ "stems": [{"name": name, "url": f"/api/jobs/{job_id}/stems/{name}.wav"} for name in STEMS],
}
+
+
+def seed(jobs_dir: Path) -> list[str]:
+ records = [
+ _build_job(jobs_dir, JOB_ID, TITLE, DURATION_SEC),
+ _build_job(jobs_dir, SIBLING_JOB_ID, SIBLING_TITLE, SIBLING_DURATION_SEC),
+ ]
(jobs_dir / "registry.json").write_text(
- json.dumps({"version": 1, "jobs": [record]}, indent=2) + "\n", encoding="utf-8"
+ json.dumps({"version": 1, "jobs": records}, indent=2) + "\n", encoding="utf-8"
)
- return JOB_ID
+ return [record["id"] for record in records]
if __name__ == "__main__":
target = Path(sys.argv[1]).expanduser().resolve()
target.mkdir(parents=True, exist_ok=True)
- print(seed(target))
+ for job_id in seed(target):
+ print(job_id)
diff --git a/tests/e2e/trash-persistence.spec.mjs b/tests/e2e/trash-persistence.spec.mjs
new file mode 100644
index 00000000..9fbe7517
--- /dev/null
+++ b/tests/e2e/trash-persistence.spec.mjs
@@ -0,0 +1,105 @@
+// A trashed track stays trashed, even when another job shares its source URL
+// (#542).
+//
+// Worth driving in a real browser rather than unit-testing addTrackToLibrary,
+// because the damage is invisible in the call that does it. Evicting a trashed
+// entry leaves a job on the server with no track, no trash entry and no
+// tombstone. Nothing looks wrong that session. It is the *next* launch's
+// syncWithServer, finding an orphan, that produces the reported symptom: the
+// song is back in the library and the Trash is empty.
+//
+// The condition is two jobs sharing one source_url, which tests/e2e/seed.py now
+// builds. That is not an exotic state: processing the same link twice is enough.
+//
+// The trap in writing this is the barrier. Assertions about what did *not*
+// happen have to be made after the startup sync, or they pass on a page that
+// never got the chance to break anything.
+
+import { test, expect } from "@playwright/test";
+
+import {
+ JOB_ID,
+ SIBLING_JOB_ID,
+ TRACK_TITLE,
+ fixtureTrack,
+ readCatalogState,
+ seedCatalogState,
+ stubUpdateCheck,
+} from "./helpers.mjs";
+
+/**
+ * The state a user reaches by trashing one of two extractions of the same URL.
+ *
+ * The trashed job is in the catalog and in the Trash. The sibling job exists
+ * only on the server, so the startup sync will import it, and that import is
+ * what used to take the trashed track out with it.
+ */
+async function seedTrashedWithUnknownSibling(page) {
+ await seedCatalogState(page, {
+ folders: [
+ { id: "f-unsorted", name: "Unsorted", items: [], color: null },
+ { id: "trash", name: "Trash", items: [JOB_ID], color: null },
+ ],
+ tracks: { [JOB_ID]: fixtureTrack(JOB_ID, TRACK_TITLE) },
+ });
+ await stubUpdateCheck(page);
+}
+
+/**
+ * Wait until the startup sync has imported the sibling.
+ *
+ * The sibling exists only on the server, so its row appearing is proof that
+ * syncWithServer ran and called addTrackToLibrary. That call is the one that
+ * used to evict the trashed track, so by the time the row is on screen the
+ * damage, if any, has already been done. No assertion after this races it.
+ */
+async function waitForSiblingImport(page) {
+ await expect(page.locator(`.cat-item[data-id="${SIBLING_JOB_ID}"]`).first()).toBeVisible();
+}
+
+/** Switch to the Trash view, where the list is only the trashed tracks. */
+async function openTrash(page) {
+ await page.locator(".rail-trash").click();
+ await expect(page.locator("#catalogPanel")).toHaveClass(/trash-view/);
+}
+
+test.describe("a trashed track and a sibling job with the same source", () => {
+ test("the sibling is imported without evicting the trashed track", async ({ page }) => {
+ await seedTrashedWithUnknownSibling(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await waitForSiblingImport(page);
+
+ await openTrash(page);
+ await expect(page.locator(`#catalogList .cat-item[data-id="${JOB_ID}"]`)).toHaveCount(1);
+
+ // Persisted, not merely on screen. The next launch reads this back, and
+ // the trash entry is the only thing that stops syncWithServer re-adopting
+ // the job: there is no tombstone for a soft delete.
+ const state = await readCatalogState(page);
+ expect(state.folders.find((f) => f.id === "trash").items).toContain(JOB_ID);
+ expect(Object.keys(state.tracks)).toContain(JOB_ID);
+ });
+
+ test("the trashed track is still in the Trash after a restart", async ({ page }) => {
+ await seedTrashedWithUnknownSibling(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await waitForSiblingImport(page);
+
+ // The restart is the whole bug. Everything above can look correct and the
+ // song still comes back here.
+ const synced = page.waitForResponse(
+ (r) => new URL(r.url()).pathname === "/api/jobs" && r.request().method() === "GET",
+ );
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await synced;
+ // A settle, because what follows is an assertion that nothing appeared.
+ // waitForResponse returns when the bytes land; the re-adoption and the
+ // re-render happen after that, and toHaveCount(0) would happily pass in
+ // the gap.
+ await page.waitForTimeout(500);
+
+ await expect(page.locator(`.cat-item[data-id="${JOB_ID}"]`)).toHaveCount(0);
+ await openTrash(page);
+ await expect(page.locator(`#catalogList .cat-item[data-id="${JOB_ID}"]`)).toHaveCount(1);
+ });
+});