|
1 | 1 | // ==UserScript== |
2 | 2 | // @name ITAM Enhancer |
3 | 3 | // @namespace https://github.com/Skriptey/Userscripts |
4 | | -// @version 1.7.0 |
| 4 | +// @version 1.7.1 |
5 | 5 | // @description iTunes/Apple Music Enhancer — shows audio formats (album + per-track), barcodes (UPC) and per-track ISRCs with one-click copy and a MagicISRC link (resolved via a MusicBrainz barcode lookup), adds inline album-header buttons, a Harmony cross-service lookup, cover-art download (static + animated/motion artwork), synced/word-by-word lyrics download, and per-track ISWC lookup (MusicBrainz + credits.fm) with MusicBrainz seeding — on Apple Music (music.apple.com) and Apple Music Classical (classical.music.apple.com), with a per-track Work column for classical releases. |
6 | 6 | // @author Skriptey |
7 | 7 | // @license GPL-3.0-or-later |
|
325 | 325 | ); |
326 | 326 | } |
327 | 327 |
|
| 328 | + /** Show a status message. Renders an ON-PAGE toast (always visible) AND fires a |
| 329 | + * native notification. The on-page toast matters because GM_notification |
| 330 | + * silently no-ops when notifications aren't granted — which made downloads with |
| 331 | + * no obvious file (e.g. a single .lrc) look like they "did nothing". */ |
328 | 332 | function toast(text) { |
| 333 | + try { |
| 334 | + let host = document.getElementById('itam-toasts'); |
| 335 | + if (!host) { |
| 336 | + host = el('div', { id: 'itam-toasts', class: 'itam-toasts' }); |
| 337 | + (document.body || document.documentElement).appendChild(host); |
| 338 | + } |
| 339 | + const t = el('div', { class: 'itam-toast', text }); |
| 340 | + host.appendChild(t); |
| 341 | + requestAnimationFrame(() => t.classList.add('show')); |
| 342 | + setTimeout(() => { |
| 343 | + t.classList.remove('show'); |
| 344 | + setTimeout(() => t.remove(), 300); |
| 345 | + }, 4000); |
| 346 | + } catch { |
| 347 | + /* ignore */ |
| 348 | + } |
329 | 349 | try { |
330 | 350 | GM_notification({ title: 'ITAM Enhancer', text, silent: true, timeout: 2500 }); |
331 | 351 | } catch { |
|
821 | 841 | url: API_BASE + path, |
822 | 842 | headers, |
823 | 843 | onload: (res) => { |
824 | | - if (res.status < 200 || res.status >= 300) |
825 | | - return reject(new Error(`API HTTP ${res.status}`)); |
| 844 | + if (res.status < 200 || res.status >= 300) { |
| 845 | + const e = new Error(`API HTTP ${res.status}`); |
| 846 | + e.status = res.status; // let callers (e.g. lyrics) report the code |
| 847 | + return reject(e); |
| 848 | + } |
826 | 849 | try { |
827 | 850 | resolve(JSON.parse(res.responseText)); |
828 | 851 | } catch { |
|
1046 | 1069 | devToken, |
1047 | 1070 | userToken, |
1048 | 1071 | ); |
1049 | | - } catch { |
| 1072 | + } catch (err) { |
| 1073 | + // Log the status (NOT the lyrics) so a failing endpoint/auth is diagnosable. |
| 1074 | + console.warn(`[ITAM] lyrics ${kind} ${songId}: ${err.status || err.message}`); |
1050 | 1075 | return null; |
1051 | 1076 | } |
1052 | 1077 | const ttml = json && json.data && json.data[0] && json.data[0].attributes?.ttml; |
1053 | | - return typeof ttml === 'string' && ttml ? ttml : null; |
| 1078 | + if (typeof ttml === 'string' && ttml) return ttml; |
| 1079 | + console.warn(`[ITAM] lyrics ${kind} ${songId}: 200 but no ttml in response`); |
| 1080 | + return null; |
1054 | 1081 | } |
1055 | 1082 |
|
1056 | 1083 | /** Resolve one track's lyrics in the requested tier, AUTOMATICALLY FALLING BACK |
|
1145 | 1172 | if ((TIER_RANK[lyr.kind] || 0) < (TIER_RANK[tier] || 0)) fellBack.add(lyr.kind); |
1146 | 1173 | } |
1147 | 1174 | } |
1148 | | - if (!out.length) return toast('No downloadable lyrics found for this release'); |
| 1175 | + if (!out.length) return toast('No lyrics returned for this release (see console for status)'); |
1149 | 1176 | const note = fellBack.size ? ` (some fell back to ${[...fellBack].sort().join(' / ')})` : ''; |
1150 | 1177 |
|
1151 | 1178 | if (model.kind !== 'album' || out.length === 1) { |
|
1527 | 1554 | display:flex; align-items:flex-start; justify-content:center; padding:5vh 12px; } |
1528 | 1555 | /* Width grows with the viewport (up to a cap) and shrinks on narrow screens; |
1529 | 1556 | container-type lets the track table hide columns responsively (below). */ |
1530 | | - .itam-panel { width:min(1200px,94vw); max-height:90vh; overflow:auto; container-type:inline-size; |
| 1557 | + .itam-panel { width:min(1760px,94vw); max-height:90vh; overflow:auto; container-type:inline-size; |
1531 | 1558 | background:#1c1c1e; color:#f2f2f7; border:1px solid #3a3a3c; border-radius:14px; |
1532 | 1559 | padding:18px 20px; font:14px/1.5 system-ui,-apple-system,sans-serif; |
1533 | 1560 | box-shadow:0 18px 50px rgba(0,0,0,.6); } |
|
1555 | 1582 | .itam-mfit.off { opacity:.4; } |
1556 | 1583 | /* Hide the Composer column when the panel is too narrow to fit it. */ |
1557 | 1584 | @container (max-width: 620px) { .itam-col-composer { display:none; } } |
| 1585 | + /* On-page status toasts (visible even when GM_notification isn't granted). */ |
| 1586 | + .itam-toasts { position:fixed; left:50%; bottom:24px; transform:translateX(-50%); |
| 1587 | + z-index:2147483647; display:flex; flex-direction:column; gap:8px; align-items:center; |
| 1588 | + pointer-events:none; } |
| 1589 | + .itam-toast { background:#1c1c1e; color:#f2f2f7; border:1px solid #48484a; border-radius:10px; |
| 1590 | + padding:9px 14px; font:600 13px system-ui,sans-serif; box-shadow:0 8px 24px rgba(0,0,0,.5); |
| 1591 | + max-width:80vw; opacity:0; transform:translateY(8px); transition:opacity .25s, transform .25s; } |
| 1592 | + .itam-toast.show { opacity:1; transform:translateY(0); } |
1558 | 1593 | @media (prefers-color-scheme: light) { |
1559 | 1594 | .itam-panel { background:#fff; color:#1c1c1e; border-color:#d1d1d6; } |
1560 | 1595 | .itam-dd-menu { background:#fff; color:#1c1c1e; border-color:#d1d1d6; } |
|
2202 | 2237 | * maybeHeaderUI is serialised, so repeated attempts are cheap. */ |
2203 | 2238 | function placeHeaderUI() { |
2204 | 2239 | if (placeStop) placeStop(); // cancel a previous route's attempts |
2205 | | - const page = parsePage(); |
2206 | 2240 | const placed = () => |
2207 | 2241 | document.querySelector('.itam-inline-actions') || document.querySelector('.itam-badges'); |
2208 | | - const deadline = Date.now() + 20000; // overall cap (mostly waiting for the token) |
2209 | | - let settledAt = 0; // when the model first became available (cached) |
| 2242 | + const startedAt = Date.now(); |
| 2243 | + let everPlaced = false; |
2210 | 2244 | let debounce = null; |
2211 | 2245 | const attempt = () => { |
2212 | | - if (placed() || Date.now() > deadline) return stop(); |
2213 | | - // Once the model is cached the header renders quickly — so if nothing has |
2214 | | - // been placed ~3s after the data is ready, there is simply nothing to place |
2215 | | - // here (e.g. a logged-out song page); stop, rather than churn for 20s. |
2216 | | - if (page && entityCache.has(page.id)) { |
2217 | | - if (!settledAt) settledAt = Date.now(); |
2218 | | - else if (Date.now() - settledAt > 3000) return stop(); |
2219 | | - } |
| 2246 | + // maybeHeaderUI is idempotent — it (re-)injects only when the badges/actions |
| 2247 | + // are MISSING. So this both places them late AND re-places them if Apple |
| 2248 | + // Music's React re-render wipes them — the cause of the inconsistent |
| 2249 | + // appearance (the inline UI would vanish on a re-render and never return). |
2220 | 2250 | maybeHeaderUI(); |
| 2251 | + if (placed()) everPlaced = true; |
| 2252 | + // Give up only if nothing has EVER placed after 30s — i.e. there is |
| 2253 | + // genuinely nothing to place here (e.g. a logged-out song page). Once it has |
| 2254 | + // placed, keep watching for the route's lifetime so a later wipe is |
| 2255 | + // re-injected; the observer is torn down on the next route change (placeStop). |
| 2256 | + else if (!everPlaced && Date.now() - startedAt > 30000) stop(); |
2221 | 2257 | }; |
2222 | | - // Debounce the (very chatty) SPA mutations so attempt() runs at most ~4×/s. |
| 2258 | + // Debounce the (very chatty) SPA mutations so attempt() runs at most ~3×/s. |
2223 | 2259 | const schedule = () => { |
2224 | 2260 | if (debounce) return; |
2225 | 2261 | debounce = setTimeout(() => { |
2226 | 2262 | debounce = null; |
2227 | 2263 | attempt(); |
2228 | | - }, 250); |
| 2264 | + }, 350); |
2229 | 2265 | }; |
2230 | 2266 | const obs = new MutationObserver(schedule); |
2231 | 2267 | obs.observe(document.body, { childList: true, subtree: true }); |
2232 | | - const poll = setInterval(attempt, 700); |
| 2268 | + const poll = setInterval(attempt, 1200); |
2233 | 2269 | function stop() { |
2234 | 2270 | obs.disconnect(); |
2235 | 2271 | clearInterval(poll); |
|
0 commit comments