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); + }); +});