From 27d92e6179f66c9670a4499ffd1085fa5a8aaabe Mon Sep 17 00:00:00 2001 From: Thales <> Date: Tue, 1 Sep 2026 08:15:20 +0100 Subject: [PATCH] fix(library): keep trashed songs out of the library, and give We Recommend a shape A trashed song came back on the next launch whenever a second job shared its source URL. addTrackToLibrary evicted the trashed track to make room for the sibling's import, which dropped the catalog entry but not 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. The trashed match is now left where the user put it: the new track is in no folder yet, so it reaches the library on its own, and evicting the old one was never what put it there. A Playwright spec seeds two jobs on one source URL and holds both halves, and seed.py grows a sibling job to make that possible. Three of the eight logos listed in the We Recommend dialog had no file behind them and 404'd for every user on every platform. They are bundled now, along with avatars for Beltr and Seratone, which had none at all. The dialog itself was one flat list of twelve entries with no order a reader could perceive. It is grouped into five categories, r/bass is added so the app matches the README, and the descriptions move out of hardcoded English into the i18n layer across all ten language tables. The README table gains the same categories so the two lists stop drifting apart. library.deleteFailed, English-only since #540, is filled in at the same time because the i18n audit cannot pass without it. --- README.md | 28 +- static/css/daw.css | 119 +++++--- static/img/friends/analog4lyfe.jpg | Bin 0 -> 2531 bytes static/img/friends/beltr.jpg | Bin 0 -> 1593 bytes static/img/friends/empress-effects.png | Bin 0 -> 6808 bytes static/img/friends/seratone.jpg | Bin 0 -> 2066 bytes static/img/friends/thomann.jpg | Bin 0 -> 2818 bytes static/js/catalog.js | 361 +++++++++++++++---------- static/js/i18n.js | 189 +++++++++++++ tests/e2e/helpers.mjs | 71 +++-- tests/e2e/seed.py | 65 +++-- tests/e2e/trash-persistence.spec.mjs | 105 +++++++ 12 files changed, 705 insertions(+), 233 deletions(-) create mode 100644 static/img/friends/analog4lyfe.jpg create mode 100644 static/img/friends/beltr.jpg create mode 100644 static/img/friends/empress-effects.png create mode 100644 static/img/friends/seratone.jpg create mode 100644 static/img/friends/thomann.jpg create mode 100644 tests/e2e/trash-persistence.spec.mjs 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 0000000000000000000000000000000000000000..05c6f2cddacd8b1814e261456f75760dea3a7c7b GIT binary patch literal 2531 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 E7cCR;_5c6? literal 0 HcmV?d00001 diff --git a/static/img/friends/beltr.jpg b/static/img/friends/beltr.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7a2d2a9e1ebee52d70266dad818bfd3869dc0bb3 GIT binary patch literal 1593 zcmYLI30#a@6#u^2npNM-)M!zej7O>QHO-X#QVA`SR&9n>Q?D#-T7|?EMLo+TA!2H( zF-j>dB)q&yq%8eX@oN)Nh(|)1_f2@tx##}xx!bw-f6rAuQ4RnQ-@(}d06_o%5e1ZQ z0Fg&TbbR#oi0GL4=4=>PX7B8Z!2l|Wn1@;nggV%}u7*Qk7!Fy+gUt|xxtm~)3PXfH zFwFc1!lAIasy~$T14GPVuF4l${6Mp1Fi+*1Z~BQ=u!YJ8lzo5*z~XQuJV_-KmCcz$ zK};R7ITUpWf~Zuag4F3Wx;jLqYiQ7^bOwV#XJ~3Nv=L>{Kr9wZR3qXE1Uv~(#FL0b z0)a>*6NreCDI_8ZDHJkE6@yGBQz(cLNkodOMc1Iy>8F*q04g330zx!s2%xASnhGjg z01p5_gDRP&WVQlPYG^DD0|6)y35q5VevN@tCJGIit;Epgd&U}uq|-GQz`;k(RXxCJ zaLnzzZce-(RQ3X7Gzg#oG_n=gDkw^<@P93BD1_p2AGnC0KE9aB>@GO`w3j_x>?5cz z4@fEPI2)K`{pHik*yWR}Dm{;Q3zo-^RNfAo`*r3(WntkOUY_F7)SHweo)3MS4v*by z6`-YESu?D_hVdZ-*^*Z??t6#ZK5mpuPBrBZXEw(0#`gz?+mngjmR?<4&^&G}g}bF^ z&NXe4w?-GEhhHMV`><}6$J8Rz$XR21gdP5xBbMbEBbQ}_5t9Er;jxNbj$SerxdtAg z1jM2yX-Ys?msZ`sb@Hvj$;q7lDpP;{@Oam*q!;Z)y%woSjQYCDXbY2y@pnDfs=sX@ z)>KTon`MzekvwdTPTiX;9!7$MHq80nh+w3(jDU{T;T5 zGkiSZvu<|b^_sL*)sN&W7LOUMtCkDoy?NRrl^6W&6xj@Na@y=FlxmeWYocl(;}8^; zMVkB2_2oC4AkXO1yd{;kMWyFw9Dg1kXN#JohxsLjl{~Gn+}86f{PVHcL7VZrEyJnJ z13`UHFIL`>r){}cJK7)q$Axu?z5CDVbw7LIy~J~>Y-@AMbca#Jj+YZh`!;`gbf$fm z{=4p>AVE%_-x=S#&vv$-e{s9+TD~M-e9_ufl9m#9KdN#vb~0y}sMbc)O9Ju*#z^ zV#HXdsQ+5dWuND54Z?+f7rvwZqG|fe$YmMl%f(3_PaH32AIe)`#IH4;d=y!=Jk&c$ zo5Wh(aj6-(@bBLNm3=H)DdQxr*Ko^}jq1OW+$-M2D;vlroNcp?!8uAr>qLt$?VIID zl_(Hxe!AR4#5Ef>Bww+Pz&`da@bOudo1zh%GkRy#%04T?04&z!u@j50*3)P@I=6$J zLd;AYZ~RUgNGq9eP%T|Fg|i~Fq0*%&+Y_I34kOHu8T|he&`lLG8OTOi8@8TBDX;fT{ZSEipAF zk_Pu>?h36y>a<7pSx&$TFwR4mCqWAf1sfGDqC2dun}(cR@VD?f1}Nyj{JpqLvmp_a S0CADzV&d(ge-KPl_WTEZyloNy literal 0 HcmV?d00001 diff --git a/static/img/friends/empress-effects.png b/static/img/friends/empress-effects.png new file mode 100644 index 0000000000000000000000000000000000000000..9cf74b44099e6fed789c8e4e07c7d43e34553969 GIT binary patch literal 6808 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 Wy1Xog<{%{VQuO1a5uDhS{(k`Jh%*uZ literal 0 HcmV?d00001 diff --git a/static/img/friends/thomann.jpg b/static/img/friends/thomann.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7fb1a8380dad4d3698f600cbce7970ec7cd41a41 GIT binary patch literal 2818 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 literal 0 HcmV?d00001 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); + }); +});