Albums
Nine marks, an artist, a year, and the one song I never skip. That is most of an album note before the card gets hold of it. After, the same lines stand up as a record sleeve: the cover, the title, the favourite track pulled to the front like a single.
Nothing is fetched, nothing phoned out. Every word on it is mine, set the way you would want to hold a sleeve and read the back.
What the card adds is the arithmetic. It averages the nine marks, leans on the bonuses, and lands the record on a single number, so the verdict is sitting there before you have read a line of the review.
Custom Views Code

<div class="alb-card" data-name="{{file.name}}">
<div class="alb-stage">
<img class="alb-side alb-side-l" alt="" />
<div class="alb-cover-wrap">
<img class="alb-cover" id="bgImg" src="" alt="" />
<button class="alb-more" type="button">SHOW MORE</button>
</div>
<img class="alb-side alb-side-r" alt="" />
</div>
<div class="alb-head">
<div class="alb-accent"></div>
<h1 class="alb-title" data-raw="{{file.basename}}"></h1>
<p class="alb-sub"></p>
<div class="alb-scoreline">
<span class="alb-score"></span>
<span class="alb-verdict"></span>
</div>
</div>
<div class="alb-detail">
<div class="alb-dims"></div>
<div class="alb-picks"></div>
<blockquote class="alb-lyric" hidden></blockquote>
<p class="alb-meta"></p>
</div>
<div class="alb-notes"></div>
</div>
.obsidian-custom-view-editable .obsidian-custom-view-render,
.obsidian-custom-view-render { padding: 0 !important; --line-width: 100%; --max-width: none; }
.alb-card {
--alb-accent: var(--color-accent, #d8893f);
--alb-ink: #ece9e3;
container-type: inline-size;
container-name: alb;
position: relative;
width: 100%;
box-sizing: border-box;
padding: clamp(24px, 4vw, 56px) clamp(20px, 5vw, 80px) clamp(40px, 6vw, 80px);
color: var(--alb-ink);
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro', sans-serif;
background: radial-gradient(135% 95% at 50% 14%, #1b1b20 0%, #111114 55%, #07070a 100%);
overflow: hidden;
}
/* ── coverflow stage ── */
.alb-stage {
position: relative;
display: flex;
align-items: center;
justify-content: center;
perspective: 1700px;
min-height: clamp(260px, 44vh, 500px);
margin-bottom: clamp(18px, 3vw, 34px);
}
.alb-cover-wrap { position: relative; z-index: 3; flex: 0 0 auto; }
.alb-cover {
display: block;
width: clamp(190px, 28vw, 330px);
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 34px 80px rgba(0,0,0,.72), 0 4px 14px rgba(0,0,0,.5);
background: #0c0c0f;
}
.alb-side {
flex: 0 0 auto;
width: clamp(150px, 20vw, 250px);
aspect-ratio: 3 / 4;
object-fit: cover;
border-radius: 8px;
opacity: .92;
filter: brightness(.82) saturate(1);
box-shadow: 0 24px 56px rgba(0,0,0,.62);
z-index: 1;
}
.alb-side-l { transform: rotateY(30deg) scale(.9); margin-right: clamp(16px, 5vw, 80px); transform-origin: right center; }
.alb-side-r { transform: rotateY(-30deg) scale(.9); margin-left: clamp(16px, 5vw, 80px); transform-origin: left center; }
.alb-more {
position: absolute;
left: 50%; bottom: 14px;
transform: translateX(-50%);
font: 600 11px/1 -apple-system, sans-serif;
letter-spacing: .12em;
color: var(--alb-ink);
background: rgba(10,10,12,.62);
border: 1px solid rgba(255,255,255,.22);
backdrop-filter: blur(6px);
padding: 8px 16px;
border-radius: 999px;
cursor: pointer;
transition: background .15s ease;
}
.alb-more:hover { background: rgba(20,20,24,.85); }
/* ── head ── */
.alb-head { max-width: 760px; }
.alb-accent { width: 64px; height: 3px; background: var(--alb-accent); margin-bottom: 16px; border-radius: 2px; }
.alb-title { margin: 0; font-size: clamp(28px, 4vw, 50px); font-weight: 700; letter-spacing: -.02em; line-height: 1.05; }
.alb-sub { margin: 8px 0 0; font-size: clamp(13px, 1.2vw, 16px); color: #b7b3ab; letter-spacing: .02em; }
.alb-scoreline { display: flex; align-items: baseline; gap: 16px; margin-top: 16px; flex-wrap: wrap; }
.alb-score { font-size: clamp(40px, 6vw, 70px); font-weight: 800; line-height: 1; color: #fff; }
.alb-score[data-empty="1"] { font-size: clamp(20px,2.4vw,26px); font-weight: 600; color: #8d8980; }
.alb-verdict { font-size: clamp(13px, 1.2vw, 16px); color: #c9c5bd; max-width: 460px; line-height: 1.4; font-style: italic; }
/* ── detail / scorecard (always visible) ── */
.alb-detail { margin-top: clamp(26px, 4vw, 44px); display: flex; flex-direction: column; gap: clamp(22px,3vw,32px); }
.alb-dims { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px 40px; }
.alb-dim { display: grid; grid-template-columns: 92px 1fr 30px; align-items: center; gap: 12px; }
.alb-dim-k { font-size: 11px; letter-spacing: .07em; text-transform: uppercase; color: #97938b; white-space: nowrap; }
.alb-dim-bar { height: 6px; border-radius: 3px; background: rgba(255,255,255,.10); overflow: hidden; }
.alb-dim-bar > i { display: block; height: 100%; background: var(--alb-accent); border-radius: 3px; }
.alb-dim-v { font-size: 13px; font-weight: 600; color: var(--alb-ink); text-align: right; }
.alb-sec-title { font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: #8d8980; margin: 0 0 12px; display: flex; align-items: center; gap: 14px; }
.alb-sec-title::after { content: ""; flex: 1; height: 1px; background: rgba(255,255,255,.1); }
.alb-chips { display: flex; flex-wrap: wrap; gap: 8px; }
.alb-chip { font-size: 13px; color: #cfcbc3; border: 1px solid rgba(255,255,255,.14); border-radius: 999px; padding: 5px 12px; }
.alb-chip.is-fav { border-color: var(--alb-accent); color: #fff; }
.alb-chip.is-fav::before { content: "\2605 "; color: var(--alb-accent); }
.alb-lyric { margin: 0; border-inline-start: 3px solid var(--alb-accent); padding: 4px 0 4px 18px; color: #d8d4cc; font-style: italic; font-size: clamp(14px,1.3vw,17px); line-height: 1.55; }
.alb-lyric cite { display: block; margin-top: 8px; font-style: normal; font-size: 12px; letter-spacing: .04em; color: #8d8980; }
.alb-meta { margin: 0; font-size: 12.5px; color: #8d8980; letter-spacing: .02em; }
.alb-meta b { color: #b7b3ab; font-weight: 600; }
/* ── notes (rendered body, revealed by SHOW MORE) ── */
.alb-notes { display: none; margin-top: clamp(26px,4vw,40px); }
.alb-card.notes-open .alb-notes { display: block; }
.alb-notes-body { font-size: 15.5px; line-height: 1.7; color: #b7b3ab; }
.alb-notes-body > :first-child { margin-top: 0; }
.alb-notes-body h2 { font-size: 14px; letter-spacing: .06em; text-transform: uppercase; color: #cfcbc3; margin: 24px 0 8px; }
.alb-notes-body h3 { font-size: 14px; color: #cfcbc3; margin: 16px 0 6px; }
.alb-notes-body a { color: var(--alb-accent); text-decoration: none; }
/* ── narrow / mobile ── */
@container alb (max-width: 640px) {
.alb-side { display: none; }
.alb-cover { width: clamp(200px, 60vw, 300px); }
.alb-dims { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.alb-side { display: none; }
.alb-cover { width: clamp(200px, 60vw, 300px); }
.alb-dims { grid-template-columns: 1fr; }
}
const _c = this;
(async () => {
try {
const root = _c.querySelector(".alb-card");
if (!root) return;
const wantName = root.dataset.name || "";
let file = app.workspace.getActiveFile();
if ((!file && wantName) || (file && wantName && file.name !== wantName)) {
const byName = app.vault.getFiles().find(f => f.name === wantName);
if (byName) file = byName;
}
if (!file) return;
const fm = (app.metadataCache.getFileCache(file) || {}).frontmatter || {};
const clean = s => String(s == null ? "" : s).replace(/\[\[|\]\]/g, "").trim();
const list = v => Array.isArray(v) ? v : (v == null || v === "" ? [] : [v]);
// display label for a link: prefer the [[target|DISPLAY]] part, else strip a "Type - " prefix
const label = s => { let v = clean(s); v = v.includes("|") ? v.split("|").pop() : v.replace(/^[^|]+ - /, ""); return v.trim(); };
// --- cover (local [[png]] -> resource path, or remote URL) ---
let coverVal = fm.cover; if (Array.isArray(coverVal)) coverVal = coverVal[0];
let resolved = "", dest = null;
if (coverVal) {
const s = String(coverVal).trim();
if (/^https?:\/\//.test(s)) resolved = s;
else {
const m = s.match(/\[\[([^\]|#]+)/);
const linkpath = m ? m[1].trim() : s.replace(/^!?\[\[|\]\]$/g, "").trim();
dest = app.metadataCache.getFirstLinkpathDest(linkpath, file.path);
if (dest) resolved = app.vault.getResourcePath(dest);
}
if (resolved) { const c = root.querySelector(".alb-cover"); if (c) c.src = resolved; }
}
// --- artist images -> side panels ---
const imgs = list(fm.artist_images).map(String).filter(Boolean);
const sl = root.querySelector(".alb-side-l"), sr = root.querySelector(".alb-side-r");
if (sl) { if (imgs[0]) sl.src = imgs[0]; else sl.remove(); }
if (sr) { if (imgs[1]) sr.src = imgs[1]; else if (imgs[0]) sr.src = imgs[0]; else sr.remove(); }
// --- title / sub ---
const title = root.querySelector(".alb-title");
if (title) title.textContent = (title.getAttribute("data-raw") || "").replace(/^Albums?\s*-\s*/, "");
const artists = list(fm.artist).map(label).filter(Boolean);
const subBits = [];
if (artists.length) subBits.push(artists.join(", "));
if (fm.year) subBits.push(String(fm.year));
if (fm.number_of_tracks) subBits.push(fm.number_of_tracks + " tracks");
const sub = root.querySelector(".alb-sub"); if (sub) sub.textContent = subBits.join(" · ");
// --- Total Album Score (audiophage formula, verbatim) ---
const num = v => (typeof v === "number" && !isNaN(v)) ? v : 0;
const S = ["sound_quality","production_quality","album_tone","singing","lyrics","album_art","replayability","album_cohesiveness"];
const tracks = Array.isArray(fm.favorite_tracks) ? fm.favorite_tracks.length : 0;
const pct = fm.number_of_tracks ? tracks / fm.number_of_tracks * 10 : 0;
const fav = fm.favorite ? 10 : 0, rel = fm.relistened_14d ? 10 : 0, tt = fm.title_track_good ? 10 : 0;
const sum = S.reduce((a, k) => a + num(fm[k]), 0) + pct + num(fm.personal_score) + fav + rel + tt;
const filled = S.filter(k => num(fm[k])).length + (pct ? 1 : 0) + (num(fm.personal_score) ? 1 : 0) + (fav ? 1 : 0) + (rel ? 1 : 0) + (tt ? 1 : 0);
const total = filled ? Math.round(sum / filled * 10) : 0;
const B = [[100,'A "True 10" — flawless in every aspect.'],[95,'A "10" — an all-time great with only unoverlookable minor flaws.'],[90,'A potential 10 held back by some flaws, or a genre-defining record. An all-time great.'],[85,'A potential AOTY contender. Memorable and influential, very few flaws.'],[80,'A good album that stands out against most other music. Worth listening to.'],[75,'Above average songwriting, several elevating elements and 1 spectacular redeeming quality.'],[70,'An average album with some elevating element (vocals, instrumentation).'],[65,'Above average, with noticeable flaws or lack of originality.'],[60,'An OK album that offers some memorable or enjoyable elements.'],[55,'Above-par but fails on most aspects; generally disappointing.'],[50,'The midpoint — average, forgettable.'],[45,'Subpar, fails to deliver on most aspects.'],[40,'Poor, significant flaws, little to enjoy.'],[35,'Below average, noticeable flaws, lack of originality.'],[30,'Flat average, typical, forgettable.'],[25,'Disappointing, major issues outweigh positives.'],[20,'Largely unenjoyable, few redeeming qualities.'],[15,'Severely flawed, not worth experiencing.'],[10,'Among the worst in its class, nearly unbearable.'],[5,'Abysmal — a complete failure, no redeeming qualities.'],[0,'Not worth mentioning.']];
const scoreEl = root.querySelector(".alb-score"), verdictEl = root.querySelector(".alb-verdict");
if (filled) { scoreEl.textContent = String(total); verdictEl.textContent = (B.find(b => total >= b[0]) || B[B.length - 1])[1]; }
else { scoreEl.textContent = "Not yet scored"; scoreEl.setAttribute("data-empty", "1"); }
// --- dimension bars ---
const DIMS = [["sound_quality","Sound"],["production_quality","Production"],["album_tone","Tone"],["singing","Vocals"],["lyrics","Lyrics"],["album_art","Art"],["replayability","Replay"],["album_cohesiveness","Cohesion"],["personal_score","Personal"]];
const dimsEl = root.querySelector(".alb-dims");
if (dimsEl) DIMS.forEach(([k, label]) => {
const val = num(fm[k]); if (!val) return;
const row = document.createElement("div"); row.className = "alb-dim";
row.innerHTML = `<span class="alb-dim-k">${label}</span><span class="alb-dim-bar"><i style="width:${Math.min(100, val * 10)}%"></i></span><span class="alb-dim-v">${val}</span>`;
dimsEl.appendChild(row);
});
// --- favorite tracks ---
const ftracks = list(fm.favorite_tracks).map(clean).filter(Boolean);
const topTrack = clean(fm.favorite_track);
const picksEl = root.querySelector(".alb-picks");
if (picksEl && (ftracks.length || topTrack)) {
const ordered = []; if (topTrack) ordered.push(topTrack);
ftracks.forEach(t => { if (t !== topTrack) ordered.push(t); });
picksEl.innerHTML = `<p class="alb-sec-title">Favorite tracks</p><div class="alb-chips">${ordered.map((t, i) => `<span class="alb-chip${i === 0 && topTrack ? " is-fav" : ""}">${t}</span>`).join("")}</div>`;
} else if (picksEl) picksEl.remove();
// --- favorite lyric ---
const lyric = clean(fm.favorite_lyrics), lyricEl = root.querySelector(".alb-lyric");
if (lyricEl && lyric) {
const src = clean(fm.favorite_lyrics_track);
lyricEl.innerHTML = `“${lyric}”` + (src ? `<cite>— ${src}</cite>` : "");
lyricEl.removeAttribute("hidden");
} else if (lyricEl) lyricEl.remove();
// --- review metadata ---
const meta = [];
if (fm.date_reviewed) meta.push(`<b>Reviewed</b> ${String(fm.date_reviewed).slice(0,10)}`);
if (fm.gear) meta.push(clean(fm.gear));
if (fm.listening_source) meta.push(clean(fm.listening_source));
if (fm.collection_type) meta.push(clean(fm.collection_type));
const genres = list(fm.genre).map(clean).filter(Boolean);
if (genres.length) meta.push(genres.join(" · "));
const metaEl = root.querySelector(".alb-meta");
if (metaEl) { if (meta.length) metaEl.innerHTML = meta.join(" · "); else metaEl.remove(); }
// --- notes: render the body (minus frontmatter + score callout) ---
const more = root.querySelector(".alb-more");
const notesEl = root.querySelector(".alb-notes");
let hasNotes = false;
if (notesEl) {
try {
const obs = require("obsidian");
let body = await app.vault.read(file);
body = body.replace(/^---[\s\S]*?\n---\n?/, ""); // strip frontmatter
body = body.replace(/^\s*((?:>.*\n?)+)\n?/, m => /dataviewjs|\[!abstract\]/.test(m) ? "" : m); // strip score callout
body = body.trim();
const meaningful = body.replace(/^#{1,6}\s.*$/gm, "").replace(/\s+/g, ""); // real content beyond headings?
if (meaningful) {
notesEl.innerHTML = '<p class="alb-sec-title">Notes</p><div class="alb-notes-body markdown-rendered"></div>';
const target = notesEl.querySelector(".alb-notes-body");
const comp = new obs.Component();
if (obs.MarkdownRenderer && obs.MarkdownRenderer.render) await obs.MarkdownRenderer.render(app, body, target, file.path, comp);
else if (obs.MarkdownRenderer) await obs.MarkdownRenderer.renderMarkdown(body, target, file.path, comp);
hasNotes = true;
} else notesEl.remove();
} catch (e) { console.error("[Albums notes]", e); if (notesEl) notesEl.remove(); }
}
if (more) {
if (!hasNotes) more.remove();
else more.addEventListener("click", () => {
const open = root.classList.toggle("notes-open");
more.textContent = open ? "SHOW LESS" : "SHOW MORE";
});
}
// --- accent colour matched to the cover (bg stays neutral) ---
if (resolved) {
const toHsl = (r, g, b) => {
r/=255; g/=255; b/=255; const mx=Math.max(r,g,b), mn=Math.min(r,g,b); let h, s, l=(mx+mn)/2;
if (mx===mn){h=s=0;} else { const d=mx-mn; s=l>0.5?d/(2-mx-mn):d/(mx+mn);
switch(mx){case r:h=(g-b)/d+(g<b?6:0);break;case g:h=(b-r)/d+2;break;default:h=(r-g)/d+4;} h/=6; }
return [h*360, s, l];
};
const hslToRgb = (h, s, l) => { h/=360; let r,g,b;
if (s===0){r=g=b=l;} else { const hue=(p,q,t)=>{if(t<0)t+=1;if(t>1)t-=1;if(t<1/6)return p+(q-p)*6*t;if(t<1/2)return q;if(t<2/3)return p+(q-p)*(2/3-t)*6;return p;};
const q=l<0.5?l*(1+s):l+s-l*s, p=2*l-q; r=hue(p,q,h+1/3); g=hue(p,q,h); b=hue(p,q,h-1/3); }
return [Math.round(r*255),Math.round(g*255),Math.round(b*255)];
};
const hex = (r,g,b) => "#"+[r,g,b].map(v=>Math.max(0,Math.min(255,v)).toString(16).padStart(2,"0")).join("");
const sample = img => {
try {
const W=44,H=44, cv=document.createElement("canvas"); cv.width=W; cv.height=H;
const ctx=cv.getContext("2d"); ctx.drawImage(img,0,0,W,H);
const d=ctx.getImageData(0,0,W,H).data; let wR=0,wG=0,wB=0,ws=0;
for (let i=0;i<d.length;i+=4){ if(d[i+3]<128)continue; const r=d[i],g=d[i+1],b=d[i+2];
const mx=Math.max(r,g,b),mn=Math.min(r,g,b); const sc=mx===0?0:(mx-mn)/mx; const w=0.1+sc*sc;
wR+=r*w; wG+=g*w; wB+=b*w; ws+=w; }
if(!ws) return;
let [h,s,l]=toHsl(wR/ws,wG/ws,wB/ws);
if (s < 0.12) return; // near-greyscale cover → keep default accent
s=Math.min(Math.max(s,0.55),0.95); l=Math.min(Math.max(l,0.52),0.64);
const [r,g,b]=hslToRgb(h,s,l); root.style.setProperty("--alb-accent", hex(r,g,b));
} catch (e) { /* tainted canvas → keep default accent */ }
};
let src = resolved, isBlob = false, cors = false;
if (dest) { try { const buf = await app.vault.readBinary(dest); src = URL.createObjectURL(new Blob([buf])); isBlob = true; } catch (e) {} }
else cors = true;
const probe = new Image(); if (cors) probe.crossOrigin = "anonymous";
probe.onload = () => { sample(probe); if (isBlob) URL.revokeObjectURL(src); };
probe.onerror = () => { if (isBlob) URL.revokeObjectURL(src); };
probe.src = src;
}
} catch (e) { console.error("[Custom Views: Albums]", e); }
})();
Templater Template
<%*
/**
* MusicBrainz → Album Frontmatter (Obsidian Templater)
* No API key required. Searches MusicBrainz release-groups, pulls cover from
* Cover Art Archive, writes full frontmatter, renames the note.
* Rendered as a card by the Custom Views "Albums" view.
*/
const MB = "https://musicbrainz.org/ws/2";
const CAA = "https://coverartarchive.org";
const today = tp.date.now("YYYY-MM-DD");
const fileTitle = tp.file.title;
// 0) Importer handshake — stardust-importer queues {artist, album, year,
// trackCount, listenedAt} keyed by filename. When present we auto-select the
// best MusicBrainz match instead of prompting; on a weak match we still attach
// the best guess and flag the note for review (option b).
const __P = app.plugins.plugins["stardust-importer"]?.pending?.[fileTitle];
let query;
let chosen = null;
let needsReview = false;
if (__P) {
query = (__P.album || fileTitle).trim();
let searchResponse;
try {
searchResponse = await tp.web.request(
`${MB}/release-group?query=${encodeURIComponent(`"${query}" AND artist:"${__P.artist || ""}" AND primarytype:Album`)}&fmt=json&limit=15`
);
} catch (e) { tR += `MusicBrainz search error: ${e}`; return; }
const results = (searchResponse && searchResponse["release-groups"]) || [];
if (!results.length) { tR += `No MusicBrainz match for "${query}".`; return; }
// Auto-pick: prefer an artist-credit + exact-title match, then closest year.
const wantArtist = (__P.artist || "").toLowerCase().trim();
const wantYear = parseInt((__P.year || "").toString().slice(0, 4)) || null;
const scored = results.map(r => {
const a = ((r["artist-credit"] || []).map(c => c.name).join(" ") || "").toLowerCase();
const y = parseInt((r["first-release-date"] || "").slice(0, 4)) || null;
const titleExact = (r.title || "").toLowerCase().trim() === query.toLowerCase();
const artistMatch = !!wantArtist && a.includes(wantArtist);
const yearGap = (wantYear && y) ? Math.abs(wantYear - y) : 99;
let score = (titleExact ? 2 : 0) + (artistMatch ? 3 : 0) - Math.min(yearGap, 10) * 0.1;
return { r, score, artistMatch, titleExact };
}).sort((x, y) => y.score - x.score);
chosen = scored[0].r;
needsReview = !(scored[0].artistMatch && scored[0].titleExact);
} else {
// 1) Ask for album title -----------------------------------------------
query = await tp.system.prompt("Enter album title (leave empty for file name):");
if (!query || !query.trim()) query = fileTitle;
query = query.trim();
// 2) Search MusicBrainz release-groups (albums only) ------------------
let searchResponse;
try {
searchResponse = await tp.web.request(
`${MB}/release-group?query=${encodeURIComponent(query)}%20AND%20primarytype:Album&fmt=json&limit=15`
);
} catch (e) { tR += `MusicBrainz search error: ${e}`; return; }
const results = (searchResponse && searchResponse["release-groups"]) || [];
if (!results.length) { tR += `No results for "${query}".`; return; }
const display = results.map(r => {
const t = r.title || "Unknown";
const y = (r["first-release-date"] || "").slice(0, 4) || "????";
const a = (r["artist-credit"] && r["artist-credit"][0] && r["artist-credit"][0].name) || "?";
return `${t} (${y}) — ${a}`;
});
// 3) Pick -------------------------------------------------------------
chosen = await tp.system.suggester(display, results, true, "Pick an album");
if (!chosen) { tR += "Selection cancelled."; return; }
}
// 4) Extract -------------------------------------------------------------
const albumTitle = (chosen.title || fileTitle).trim();
const year = (chosen["first-release-date"] || "").slice(0, 4) || "";
const artists = (chosen["artist-credit"] || []).map(c => c.name).filter(Boolean);
const tagObjs = (chosen.tags || []).sort((a,b) => (b.count || 0) - (a.count || 0));
const genres = tagObjs.slice(0, 5).map(t => t.name).map(s => s.replace(/\b\w/g, c => c.toUpperCase()));
// Cover Art Archive serves the cover for the release-group via redirect;
// the URL works directly as an <img src>.
const cover = `${CAA}/release-group/${chosen.id}/front-500`;
// 5) Confirm cover exists (some release-groups have no front cover) -----
// Try a tiny HEAD-ish request; if 404 we drop the cover URL.
let coverOk = true;
try {
const r = await tp.web.request(`${CAA}/release-group/${chosen.id}`);
// If JSON of images returns no front, mark missing.
if (r && Array.isArray(r.images) && !r.images.some(i => i.front)) coverOk = false;
} catch (e) { coverOk = false; }
const coverURL = coverOk ? cover : "";
// 5b) Artist images (TheAudioDB, no key) for the coverflow side panels --
// Up to 2 landscape artist photos; the Albums card uses them as the tilted
// panels flanking the cover. Falls back to cover-only if none are found.
let artistImages = [];
const primaryArtist = artists[0] || "";
if (primaryArtist) {
try {
const adb = await tp.web.request(
`https://www.theaudiodb.com/api/v1/json/2/search.php?s=${encodeURIComponent(primaryArtist)}`
);
const a = (adb && adb.artists && adb.artists[0]) || null;
if (a) {
const pool = [a.strArtistFanart, a.strArtistFanart2, a.strArtistFanart3, a.strArtistWideThumb, a.strArtistThumb];
for (const u of pool) {
if (u && !artistImages.includes(u)) artistImages.push(u);
if (artistImages.length === 2) break;
}
}
} catch (e) { /* no artist images — card falls back to the cover only */ }
}
// Helper: link an existing artist note if present
async function noteExists(name) {
for (const folder of ["References", ""]) {
const path = folder ? `${folder}/${name}.md` : `${name}.md`;
try { if (await tp.file.exists(path)) return true; } catch (e) {}
}
return false;
}
async function linkOrName(names, alwaysLinkFirst = 2) {
const out = [];
for (let i = 0; i < names.length; i++) {
if (i < alwaysLinkFirst) out.push(`[[${names[i]}]]`);
else out.push((await noteExists(names[i])) ? `[[${names[i]}]]` : names[i]);
}
return out;
}
const artistLinks = await linkOrName(artists);
const genreLinks = await linkOrName(genres, 0); // genres always wiki-linked iff a note exists; else plain
// 6) Rename to "Albums - Title" ----------------------------------------
const colonFixed = albumTitle.replace(/:/g, " - ");
let sanitized = colonFixed.replace(/[\/#%&{}<>*?$!'"@+`|=]/g, "").trim();
if (!sanitized) sanitized = fileTitle;
const requiresAlias = sanitized !== albumTitle;
const newName = sanitized;
if (newName !== fileTitle) await tp.file.rename(newName);
// 7) Build YAML ---------------------------------------------------------
// Identity + classification fields come from MusicBrainz; the review/scoring
// fields below are written blank for you to fill in by hand. The computed
// fields (Total Album Score, Rating Description, Percentage of Album Liked,
// Core Fields, Bonus) are NOT stored here — they are derived live by formulas in
// Albums.base, the same way Notion recalculated its formula columns.
const L = [];
L.push("---");
L.push("connections:");
L.push("categories:");
L.push(' - "[[Albums]]"');
// target = filename-sanitized name so it matches the Musician note, display = raw name)
if (artists.length) {
L.push("artist:");
artists.forEach(a => {
const tgt = a.replace(/[\/#%&{}<>*?$!'"@+`|=]/g, "").trim();
const disp = a.replace(/[\[\]|]/g, "");
L.push(' - "[[' + tgt + '|' + disp + ']]"');
});
} else L.push("artist: []");
if (genreLinks.length) { L.push("genre:"); genreLinks.forEach(g => L.push(` - "${g}"`)); } else L.push("genre: []");
L.push(coverURL ? `cover: "${coverURL}"` : "cover: []");
if (artistImages.length) { L.push("artist_images:"); artistImages.forEach(u => L.push(` - ${u}`)); } else L.push("artist_images: []");
L.push(`year: ${year}`);
L.push(`created: ${(__P && __P.listenedAt) || today}`);
L.push(`mbid: ${chosen.id}`);
// Review meta -----------------------------------------------------------
L.push("status: Active");
L.push(`date_reviewed: ${(__P && __P.listenedAt) || today}`);
L.push("gear: ");
L.push("listening_source: ");
L.push("collection_type: ");
L.push("language: English");
L.push(`number_of_tracks: ${(__P && __P.trackCount) || ""}`);
// Scores — each 0–10 -----------------------------------------------------
L.push("sound_quality: ");
L.push("production_quality: ");
L.push("album_tone: ");
L.push("singing: ");
L.push("lyrics: ");
L.push("album_art: ");
L.push("replayability: ");
L.push("album_cohesiveness: ");
L.push("personal_score: ");
// Flags ------------------------------------------------------------------
L.push("favorite: false");
L.push("relistened_14d: false");
L.push("title_track_good: false");
if (__P && needsReview) L.push("needs_review: true");
// Favorites --------------------------------------------------------------
L.push("favorite_tracks: []");
L.push("favorite_lyrics_track: ");
L.push("favorite_lyrics: ");
L.push("---");
// Body scaffold ----------------------------------------------------------
// The dataviewjs block recomputes the Total Album Score live from this note's
// own scores — the same formula as Albums.base, shown on the page itself.
const body = [
"",
...(__P && needsReview
? ["> [!warning] Auto-matched album — verify this is the right release (artist / year / edition), then clear `needs_review`.", ""]
: []),
"> [!abstract] Score",
"> ```dataviewjs",
"> const p = dv.current();",
'> const n = v => (typeof v === "number" && !isNaN(v)) ? v : 0;',
'> const C = ["sound_quality","production_quality","album_tone","singing","lyrics","album_art","replayability","album_cohesiveness","personal_score"];',
"> const tracks = Array.isArray(p.favorite_tracks) ? p.favorite_tracks.length : 0;",
"> const pct = p.number_of_tracks ? tracks / p.number_of_tracks * 10 : 0;",
"> const sum = C.reduce((a,k)=>a+n(p[k]),0) + pct;",
"> const count = C.filter(k=>n(p[k])).length + (pct?1:0);",
"> const bonus = (p.favorite?3:0) + (p.relistened_14d?2:0) + (p.title_track_good?2:0);",
"> const total = count >= 6 ? Math.min(100, Math.round(sum/count*9 + bonus/7*10)) : null;",
"> const B = [[100,'A \"True 10\" — flawless in every aspect.'],[95,'A \"10\" — an all-time great with only unoverlookable minor flaws.'],[90,'A potential 10 held back by some flaws, or a genre-defining record. An all-time great.'],[85,'A potential AOTY contender. Memorable and influential, very few flaws.'],[80,'A good album that stands out against most other music. Worth listening to.'],[75,'Above average songwriting, several elevating elements and 1 spectacular redeeming quality.'],[70,'An average album with some elevating element (vocals, instrumentation).'],[65,'Above average, with noticeable flaws or lack of originality.'],[60,'An OK album that offers some memorable or enjoyable elements.'],[55,'Above-par but fails on most aspects; generally disappointing.'],[50,'The midpoint — average, forgettable.'],[45,'Subpar, fails to deliver on most aspects.'],[40,'Poor, significant flaws, little to enjoy.'],[35,'Below average, noticeable flaws, lack of originality.'],[30,'Flat average, typical, forgettable.'],[25,'Disappointing, major issues outweigh positives.'],[20,'Largely unenjoyable, few redeeming qualities.'],[15,'Severely flawed, not worth experiencing.'],[10,'Among the worst in its class, nearly unbearable.'],[5,'Abysmal — a complete failure, no redeeming qualities.'],[0,'Not worth mentioning.']];",
"> const verdict = total === null ? '' : (B.find(b => total >= b[0]) || B[B.length-1])[1];",
"> dv.paragraph(total === null ? `_Not yet scored — ${count}/6 core fields._` : `**${total}** — ${verdict}`);",
"> ```",
"",
"## Listening Tips",
"",
"## First Impressions",
"",
"## Second Listen",
"",
"## Artist Impressions",
"",
"## Album History",
"",
"## Track Breakdown",
"",
"## Album Details",
""
];
tR += L.join("\n") + "\n" + body.join("\n");
-%>
Field Assembly
Want a record sleeve like this of your own? Two plugins do the work.
- Custom Views: the renderer. Make a view, build its rule in the menu to match
categoriescontainsAlbums(the screenshot above), then paste the template, styling, and JS blocks into their fields. - Templater: the note-maker. Paste the Templater block into a template file and bind it to a hotkey or a folder template.
The template fills the note from a public database when you create it, no key needed. After that the card runs on what is in the note.
