« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--package.json2
-rw-r--r--src/aggregate.js46
-rw-r--r--src/common-util/sort.js19
-rw-r--r--src/common-util/wiki-data.js30
-rw-r--r--src/content/dependencies/generateAlbumArtInfoBox.js39
-rw-r--r--src/content/dependencies/generateAlbumArtworkColumn.js38
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js7
-rw-r--r--src/content/dependencies/generateAlbumCoverArtwork.js100
-rw-r--r--src/content/dependencies/generateAlbumGalleryAlbumGrid.js90
-rw-r--r--src/content/dependencies/generateAlbumGalleryPage.js242
-rw-r--r--src/content/dependencies/generateAlbumGalleryTrackGrid.js122
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js16
-rw-r--r--src/content/dependencies/generateAlbumReferencedArtworksPage.js12
-rw-r--r--src/content/dependencies/generateAlbumReferencingArtworksPage.js12
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js23
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNavGroupPart.js2
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js2
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackSection.js114
-rw-r--r--src/content/dependencies/generateAlbumSocialEmbed.js5
-rw-r--r--src/content/dependencies/generateAlbumTrackList.js12
-rw-r--r--src/content/dependencies/generateArtTagAncestorDescendantMapList.js4
-rw-r--r--src/content/dependencies/generateArtTagGalleryPage.js71
-rw-r--r--src/content/dependencies/generateArtTagInfoPage.js8
-rw-r--r--src/content/dependencies/generateArtTagSidebar.js4
-rw-r--r--src/content/dependencies/generateArtistArtworkColumn.js13
-rw-r--r--src/content/dependencies/generateArtistCredit.js18
-rw-r--r--src/content/dependencies/generateArtistGalleryPage.js150
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js43
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js2
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js7
-rw-r--r--src/content/dependencies/generateCommentaryEntry.js8
-rw-r--r--src/content/dependencies/generateContributionTooltipChronologySection.js24
-rw-r--r--src/content/dependencies/generateCoverArtwork.js159
-rw-r--r--src/content/dependencies/generateCoverArtworkArtTagDetails.js8
-rw-r--r--src/content/dependencies/generateCoverArtworkArtistDetails.js6
-rw-r--r--src/content/dependencies/generateCoverArtworkOriginDetails.js98
-rw-r--r--src/content/dependencies/generateCoverArtworkReferenceDetails.js26
-rw-r--r--src/content/dependencies/generateCoverGrid.js11
-rw-r--r--src/content/dependencies/generateFlashActGalleryPage.js16
-rw-r--r--src/content/dependencies/generateFlashArtworkColumn.js11
-rw-r--r--src/content/dependencies/generateFlashCoverArtwork.js41
-rw-r--r--src/content/dependencies/generateFlashIndexPage.js17
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js8
-rw-r--r--src/content/dependencies/generateGroupGalleryPage.js33
-rw-r--r--src/content/dependencies/generateIntrapageDotSwitcher.js2
-rw-r--r--src/content/dependencies/generateLyricsEntry.js25
-rw-r--r--src/content/dependencies/generateLyricsSection.js81
-rw-r--r--src/content/dependencies/generatePageLayout.js86
-rw-r--r--src/content/dependencies/generateReferencedArtworksPage.js72
-rw-r--r--src/content/dependencies/generateReferencingArtworksPage.js72
-rw-r--r--src/content/dependencies/generateTrackArtworkColumn.js33
-rw-r--r--src/content/dependencies/generateTrackCoverArtwork.js143
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js34
-rw-r--r--src/content/dependencies/generateTrackNavLinks.js2
-rw-r--r--src/content/dependencies/generateTrackReferencedArtworksPage.js12
-rw-r--r--src/content/dependencies/generateTrackReferencingArtworksPage.js12
-rw-r--r--src/content/dependencies/generateTrackReleaseInfo.js16
-rw-r--r--src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js25
-rw-r--r--src/content/dependencies/generateWikiHomepageAlbumGridRow.js17
-rw-r--r--src/content/dependencies/image.js126
-rw-r--r--src/content/dependencies/linkAnythingMan.js3
-rw-r--r--src/content/dependencies/linkArtwork.js20
-rw-r--r--src/content/dependencies/linkReferencedArtworks.js24
-rw-r--r--src/content/dependencies/linkReferencingArtworks.js24
-rw-r--r--src/content/dependencies/listArtTagNetwork.js10
-rw-r--r--src/content/dependencies/listArtTagsByName.js4
-rw-r--r--src/content/dependencies/listArtTagsByUses.js4
-rw-r--r--src/content/dependencies/listArtistsByGroup.js23
-rw-r--r--src/content/dependencies/listArtistsByLatestContribution.js5
-rw-r--r--src/content/dependencies/listTracksWithLyrics.js2
-rw-r--r--src/content/dependencies/transformContent.js35
-rw-r--r--src/data/cacheable-object.js6
-rw-r--r--src/data/checks.js92
-rw-r--r--src/data/composite/data/withPropertyFromList.js22
-rw-r--r--src/data/composite/things/album/index.js1
-rw-r--r--src/data/composite/things/album/withHasCoverArt.js64
-rw-r--r--src/data/composite/things/artwork/index.js1
-rw-r--r--src/data/composite/things/artwork/withDate.js41
-rw-r--r--src/data/composite/things/commentary-entry/index.js1
-rw-r--r--src/data/composite/things/commentary-entry/withWebArchiveDate.js41
-rw-r--r--src/data/composite/things/contribution/thingPropertyMatches.js19
-rw-r--r--src/data/composite/things/contribution/thingReferenceTypeMatches.js29
-rw-r--r--src/data/composite/things/track-section/index.js2
-rw-r--r--src/data/composite/things/track-section/withContinueCountingFrom.js25
-rw-r--r--src/data/composite/things/track-section/withStartCountingFrom.js64
-rw-r--r--src/data/composite/things/track/index.js3
-rw-r--r--src/data/composite/things/track/withAlbum.js22
-rw-r--r--src/data/composite/things/track/withAlwaysReferenceByDirectory.js15
-rw-r--r--src/data/composite/things/track/withCoverArtistContribs.js73
-rw-r--r--src/data/composite/things/track/withHasUniqueCoverArt.js76
-rw-r--r--src/data/composite/things/track/withPropertyFromAlbum.js17
-rw-r--r--src/data/composite/things/track/withTrackArtDate.js24
-rw-r--r--src/data/composite/things/track/withTrackNumber.js50
-rw-r--r--src/data/composite/wiki-data/index.js2
-rw-r--r--src/data/composite/wiki-data/withConstitutedArtwork.js57
-rw-r--r--src/data/composite/wiki-data/withCoverArtDate.js23
-rw-r--r--src/data/composite/wiki-data/withParsedCommentaryEntries.js260
-rw-r--r--src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js18
-rw-r--r--src/data/composite/wiki-properties/annotatedReferenceList.js8
-rw-r--r--src/data/composite/wiki-properties/commentary.js30
-rw-r--r--src/data/composite/wiki-properties/commentatorArtists.js11
-rw-r--r--src/data/composite/wiki-properties/constitutibleArtwork.js68
-rw-r--r--src/data/composite/wiki-properties/constitutibleArtworkList.js70
-rw-r--r--src/data/composite/wiki-properties/directory.js1
-rw-r--r--src/data/composite/wiki-properties/index.js3
-rw-r--r--src/data/composite/wiki-properties/referencedArtworkList.js36
-rw-r--r--src/data/composite/wiki-properties/soupyReverse.js15
-rw-r--r--src/data/thing.js8
-rw-r--r--src/data/things/album.js347
-rw-r--r--src/data/things/art-tag.js23
-rw-r--r--src/data/things/artist.js39
-rw-r--r--src/data/things/artwork.js399
-rw-r--r--src/data/things/content.js122
-rw-r--r--src/data/things/flash.js73
-rw-r--r--src/data/things/group.js2
-rw-r--r--src/data/things/index.js4
-rw-r--r--src/data/things/track.js211
-rw-r--r--src/data/yaml.js309
-rw-r--r--src/find.js8
-rw-r--r--src/gen-thumbs.js42
-rw-r--r--src/page/album.js6
-rw-r--r--src/page/track.js6
-rw-r--r--src/reverse.js27
-rw-r--r--src/static/css/site.css224
-rw-r--r--src/static/js/client/additional-names-box.js4
-rw-r--r--src/static/js/client/css-compatibility-assistant.js26
-rw-r--r--src/static/js/client/hoverable-tooltip.js23
-rw-r--r--src/static/js/client/image-overlay.js5
-rw-r--r--src/static/js/client/sticky-heading.js15
-rw-r--r--src/static/js/rectangles.js42
-rw-r--r--src/strings-default.yaml49
-rwxr-xr-xsrc/upd8.js64
-rw-r--r--src/urls-default.yaml2
-rw-r--r--src/urls.js11
-rw-r--r--src/validators.js139
135 files changed, 4029 insertions, 2122 deletions
diff --git a/package.json b/package.json
index f92058d2..d19da806 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,8 @@
         "#composite/things/album": "./src/data/composite/things/album/index.js",
         "#composite/things/art-tag": "./src/data/composite/things/art-tag/index.js",
         "#composite/things/artist": "./src/data/composite/things/artist/index.js",
+        "#composite/things/artwork": "./src/data/composite/things/artwork/index.js",
+        "#composite/things/commentary-entry": "./src/data/composite/things/commentary-entry/index.js",
         "#composite/things/contribution": "./src/data/composite/things/contribution/index.js",
         "#composite/things/flash": "./src/data/composite/things/flash/index.js",
         "#composite/things/flash-act": "./src/data/composite/things/flash-act/index.js",
diff --git a/src/aggregate.js b/src/aggregate.js
index 92c66b73..cb806e89 100644
--- a/src/aggregate.js
+++ b/src/aggregate.js
@@ -440,7 +440,15 @@ export function showAggregate(topError, {
       }
     }
 
-    return determineCauseHelper(cause.cause);
+    if (cause.cause) {
+      return determineCauseHelper(cause.cause);
+    }
+
+    if (cause.errors) {
+      return determineErrorsHelper(cause);
+    }
+
+    return cause;
   };
 
   const determineCause = error =>
@@ -478,7 +486,7 @@ export function showAggregate(topError, {
       : error.errors?.flatMap(determineErrorsHelper) ?? null);
 
   const flattenErrorStructure = (error, level = 0) => {
-    const cause = determineCause(error);
+    const cause = determineCause(error); // may be an array!
     const errors = determineErrors(error);
 
     return {
@@ -493,7 +501,9 @@ export function showAggregate(topError, {
           : error.stack),
 
       cause:
-        (cause
+        (Array.isArray(cause)
+          ? cause.map(cause => flattenErrorStructure(cause, level + 1))
+       : cause
           ? flattenErrorStructure(cause, level + 1)
           : null),
 
@@ -528,15 +538,29 @@ export function showAggregate(topError, {
       unhelpfulTraceLines: ownUnhelpfulTraceLines,
     },
   }, index, apparentSiblings) => {
+    const causeSingle = Array.isArray(cause) ? null : cause;
+    const causeArray = Array.isArray(cause) ? cause : null;
+
     const subApparentSiblings =
-      (cause && errors
-        ? [cause, ...errors]
-     : cause
-        ? [cause]
+      (causeSingle && errors
+        ? [causeSingle, ...errors]
+     : causeSingle
+        ? [causeSingle]
+     : causeArray && errors
+        ? [...causeArray, ...errors]
+     : causeArray
+        ? causeArray
      : errors
         ? errors
         : []);
 
+    const presentedAsErrors =
+      (causeArray && errors
+        ? [...causeArray, ...errors]
+     : causeArray
+        ? causeArray
+        : errors);
+
     const anythingHasErrorsThisLayer =
       apparentSiblings.some(({errors}) => !empty(errors));
 
@@ -584,8 +608,8 @@ export function showAggregate(topError, {
     const bar1 = ' ';
 
     const causePart =
-      (cause
-        ? recursive(cause, 0, subApparentSiblings)
+      (causeSingle
+        ? recursive(causeSingle, 0, subApparentSiblings)
             .split('\n')
             .map((line, i) => i === 0 ? ` ${head1} ${line}` : ` ${bar1} ${line}`)
             .join('\n')
@@ -595,8 +619,8 @@ export function showAggregate(topError, {
     const bar2 = level % 2 === 0 ? '\u2502' : colors.dim('\u254e');
 
     const errorsPart =
-      (errors
-        ? errors
+      (presentedAsErrors
+        ? presentedAsErrors
             .map((error, index) => recursive(error, index + 1, subApparentSiblings))
             .flatMap(str => str.split('\n'))
             .map((line, i) => i === 0 ? ` ${head2} ${line}` : ` ${bar2} ${line}`)
diff --git a/src/common-util/sort.js b/src/common-util/sort.js
index fd382033..d93d94c1 100644
--- a/src/common-util/sort.js
+++ b/src/common-util/sort.js
@@ -389,6 +389,22 @@ export function sortAlbumsTracksChronologically(data, {
   return data;
 }
 
+export function sortArtworksChronologically(data, {
+  latestFirst = false,
+} = {}) {
+  // Artworks conveniently describe their things as artwork.thing, so they
+  // work in sortEntryThingPairs. (Yes, this is just assuming the artworks
+  // are only for albums and tracks... sorry... TODO...)
+  sortEntryThingPairs(data, things =>
+    sortAlbumsTracksChronologically(things, {latestFirst}));
+
+  // Artworks' own dates always matter before however the thing places itself,
+  // and accommodate per-thing properties like coverArtDate anyway.
+  sortByDate(data, {latestFirst});
+
+  return data;
+}
+
 export function sortFlashesChronologically(data, {
   latestFirst = false,
   getDate,
@@ -413,6 +429,7 @@ export function sortFlashesChronologically(data, {
 
 export function sortContributionsChronologically(data, sortThings, {
   latestFirst = false,
+  getThing = contrib => contrib.thing,
 } = {}) {
   // Contributions only have one date property (which is provided when
   // the contribution is created). They're sorted by this most primarily,
@@ -421,7 +438,7 @@ export function sortContributionsChronologically(data, sortThings, {
   const entries =
     data.map(contrib => ({
       entry: contrib,
-      thing: contrib.thing,
+      thing: getThing(contrib),
     }));
 
   sortEntryThingPairs(
diff --git a/src/common-util/wiki-data.js b/src/common-util/wiki-data.js
index 4bbef8ab..a4c6b3bd 100644
--- a/src/common-util/wiki-data.js
+++ b/src/common-util/wiki-data.js
@@ -102,6 +102,36 @@ export const commentaryRegexCaseSensitive =
 export const commentaryRegexCaseSensitiveOneShot =
   new RegExp(commentaryRegexRaw);
 
+// The #validators function isOldStyleLyrics() describes
+// what this regular expression detects against.
+export const multipleLyricsDetectionRegex =
+  /^<i>.*:<\/i>/m;
+
+export function matchContentEntries(sourceText) {
+  const matchEntries = [];
+
+  let previousMatchEntry = null;
+  let previousEndIndex = null;
+
+  for (const {0: matchText, index: startIndex, groups: matchEntry}
+          of sourceText.matchAll(commentaryRegexCaseSensitive)) {
+    if (previousMatchEntry) {
+      previousMatchEntry.body = sourceText.slice(previousEndIndex, startIndex);
+    }
+
+    matchEntries.push(matchEntry);
+
+    previousMatchEntry = matchEntry;
+    previousEndIndex = startIndex + matchText.length;
+  }
+
+  if (previousMatchEntry) {
+    previousMatchEntry.body = sourceText.slice(previousEndIndex);
+  }
+
+  return matchEntries;
+}
+
 export function filterAlbumsByCommentary(albums) {
   return albums
     .filter((album) => [album, ...album.tracks].some((x) => x.commentary));
diff --git a/src/content/dependencies/generateAlbumArtInfoBox.js b/src/content/dependencies/generateAlbumArtInfoBox.js
new file mode 100644
index 00000000..8c44c930
--- /dev/null
+++ b/src/content/dependencies/generateAlbumArtInfoBox.js
@@ -0,0 +1,39 @@
+export default {
+  contentDependencies: ['generateReleaseInfoContributionsLine'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, album) => ({
+    wallpaperArtistContributionsLine:
+      (album.wallpaperArtwork
+        ? relation('generateReleaseInfoContributionsLine',
+            album.wallpaperArtwork.artistContribs)
+        : null),
+
+    bannerArtistContributionsLine:
+      (album.bannerArtwork
+        ? relation('generateReleaseInfoContributionsLine',
+            album.bannerArtwork.artistContribs)
+        : null),
+  }),
+
+  generate: (relations, {html, language}) =>
+    language.encapsulate('releaseInfo', capsule =>
+      html.tag('div', {class: 'album-art-info'},
+        {[html.onlyIfContent]: true},
+
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            relations.wallpaperArtistContributionsLine?.slots({
+              stringKey: capsule + '.wallpaperArtBy',
+              chronologyKind: 'wallpaperArt',
+            }),
+
+            relations.bannerArtistContributionsLine?.slots({
+              stringKey: capsule + '.bannerArtBy',
+              chronologyKind: 'bannerArt',
+            }),
+          ]))),
+};
diff --git a/src/content/dependencies/generateAlbumArtworkColumn.js b/src/content/dependencies/generateAlbumArtworkColumn.js
new file mode 100644
index 00000000..e6762463
--- /dev/null
+++ b/src/content/dependencies/generateAlbumArtworkColumn.js
@@ -0,0 +1,38 @@
+export default {
+  contentDependencies: ['generateAlbumArtInfoBox', 'generateCoverArtwork'],
+  extraDependencies: ['html'],
+
+  relations: (relation, album) => ({
+    firstCover:
+      (album.hasCoverArt
+        ? relation('generateCoverArtwork', album.coverArtworks[0])
+        : null),
+
+    restCovers:
+      (album.hasCoverArt
+        ? album.coverArtworks.slice(1).map(artwork =>
+            relation('generateCoverArtwork', artwork))
+        : []),
+
+    albumArtInfoBox:
+      relation('generateAlbumArtInfoBox', album),
+  }),
+
+  generate: (relations, {html}) =>
+    html.tags([
+      relations.firstCover?.slots({
+        showOriginDetails: true,
+        showArtTagDetails: true,
+        showReferenceDetails: true,
+      }),
+
+      relations.albumArtInfoBox,
+
+      relations.restCovers.map(cover =>
+        cover.slots({
+          showOriginDetails: true,
+          showArtTagDetails: true,
+          showReferenceDetails: true,
+        })),
+    ]),
+};
diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
index f5df7c3d..1e39b47d 100644
--- a/src/content/dependencies/generateAlbumCommentaryPage.js
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -3,13 +3,12 @@ import {empty, stitchArrays} from '#sugar';
 export default {
   contentDependencies: [
     'generateAlbumCommentarySidebar',
-    'generateAlbumCoverArtwork',
     'generateAlbumNavAccent',
     'generateAlbumSecondaryNav',
     'generateAlbumStyleRules',
     'generateCommentaryEntry',
     'generateContentHeading',
-    'generateTrackCoverArtwork',
+    'generateCoverArtwork',
     'generatePageLayout',
     'linkAlbum',
     'linkExternal',
@@ -66,7 +65,7 @@ export default {
 
       if (album.hasCoverArt) {
         relations.albumCommentaryCover =
-          relation('generateAlbumCoverArtwork', album);
+          relation('generateCoverArtwork', album.coverArtworks[0]);
       }
 
       relations.albumCommentaryEntries =
@@ -91,7 +90,7 @@ export default {
       query.tracksWithCommentary
         .map(track =>
           (track.hasUniqueCoverArt
-            ? relation('generateTrackCoverArtwork', track)
+            ? relation('generateCoverArtwork', track.trackArtworks[0])
             : null));
 
     relations.trackCommentaryEntries =
diff --git a/src/content/dependencies/generateAlbumCoverArtwork.js b/src/content/dependencies/generateAlbumCoverArtwork.js
deleted file mode 100644
index ff7d2b85..00000000
--- a/src/content/dependencies/generateAlbumCoverArtwork.js
+++ /dev/null
@@ -1,100 +0,0 @@
-export default {
-  contentDependencies: [
-    'generateCoverArtwork',
-    'generateCoverArtworkArtTagDetails',
-    'generateCoverArtworkArtistDetails',
-    'generateCoverArtworkReferenceDetails',
-    'image',
-    'linkAlbumReferencedArtworks',
-    'linkAlbumReferencingArtworks',
-  ],
-
-  extraDependencies: ['html', 'language'],
-
-  relations: (relation, album) => ({
-    coverArtwork:
-      relation('generateCoverArtwork'),
-
-    image:
-      relation('image'),
-
-    artTagDetails:
-      relation('generateCoverArtworkArtTagDetails', album.artTags),
-
-    artistDetails:
-      relation('generateCoverArtworkArtistDetails', album.coverArtistContribs),
-
-    referenceDetails:
-      relation('generateCoverArtworkReferenceDetails',
-        album.referencedArtworks,
-        album.referencedByArtworks),
-
-    referencedArtworksLink:
-      relation('linkAlbumReferencedArtworks', album),
-
-    referencingArtworksLink:
-      relation('linkAlbumReferencingArtworks', album),
-  }),
-
-  data: (album) => ({
-    path:
-      ['media.albumCover', album.directory, album.coverArtFileExtension],
-
-    color:
-      album.color,
-
-    dimensions:
-      album.coverArtDimensions,
-
-    warnings:
-      album.artTags
-        .filter(tag => tag.isContentWarning)
-        .map(tag => tag.name),
-  }),
-
-  slots: {
-    mode: {type: 'string'},
-
-    details: {
-      validate: v => v.is('tags', 'artists'),
-      default: 'tags',
-    },
-
-    showReferenceLinks: {
-      type: 'boolean',
-      default: false,
-    },
-  },
-
-  generate: (data, relations, slots, {language}) =>
-    relations.coverArtwork.slots({
-      mode: slots.mode,
-
-      image:
-        relations.image.slots({
-          path: data.path,
-          color: data.color,
-          alt: language.$('misc.alt.albumCover'),
-        }),
-
-      dimensions: data.dimensions,
-      warnings: data.warnings,
-
-      details: [
-        slots.details === 'tags' &&
-          relations.artTagDetails,
-
-        slots.details === 'artists' &&
-          relations.artistDetails,
-
-        slots.showReferenceLinks &&
-          relations.referenceDetails.slots({
-            referencedLink:
-              relations.referencedArtworksLink,
-
-            referencingLink:
-              relations.referencingArtworksLink,
-          }),
-      ],
-    }),
-};
diff --git a/src/content/dependencies/generateAlbumGalleryAlbumGrid.js b/src/content/dependencies/generateAlbumGalleryAlbumGrid.js
new file mode 100644
index 00000000..7f152871
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryAlbumGrid.js
@@ -0,0 +1,90 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateCoverGrid',
+    'image',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query: (album) => ({
+    artworks:
+      (album.hasCoverArt
+        ? album.coverArtworks
+        : []),
+  }),
+
+  relations: (relation, query, album) => ({
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    albumLinks:
+      query.artworks.map(_artwork =>
+        relation('linkAlbum', album)),
+
+    images:
+      query.artworks
+        .map(artwork => relation('image', artwork)),
+  }),
+
+  data: (query, album) => ({
+    albumName:
+      album.name,
+
+    artworkLabels:
+      query.artworks
+        .map(artwork => artwork.label),
+
+    artworkArtists:
+      query.artworks
+        .map(artwork => artwork.artistContribs
+          .map(contrib => contrib.artist.name)),
+  }),
+
+  slots: {
+    attributes: {type: 'attributes', mutable: false},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+
+      slots.attributes,
+
+      [
+        relations.coverArtistsLine,
+
+        relations.coverGrid.slots({
+          links:
+            relations.albumLinks,
+
+          names:
+            data.artworkLabels
+              .map(label => label ?? data.albumName),
+
+          images:
+            stitchArrays({
+              image: relations.images,
+              label: data.artworkLabels,
+            }).map(({image, label}) =>
+                image.slots({
+                  missingSourceContent:
+                    language.$('misc.albumGalleryGrid.noCoverArt', {
+                      name:
+                        label ?? data.albumName,
+                    }),
+                })),
+
+          info:
+            data.artworkArtists.map(artists =>
+              language.$('misc.coverGrid.details.coverArtists', {
+                [language.onlyIfOptions]: ['artists'],
+
+                artists:
+                  language.formatUnitList(artists),
+              })),
+        }),
+      ]),
+};
diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js
index b48d92af..2ba3b272 100644
--- a/src/content/dependencies/generateAlbumGalleryPage.js
+++ b/src/content/dependencies/generateAlbumGalleryPage.js
@@ -1,18 +1,18 @@
-import {compareArrays, stitchArrays} from '#sugar';
+import {stitchArrays, unique} from '#sugar';
+import {getKebabCase} from '#wiki-data';
 
 export default {
   contentDependencies: [
-    'generateAlbumGalleryCoverArtistsLine',
+    'generateAlbumGalleryAlbumGrid',
     'generateAlbumGalleryNoTrackArtworksLine',
     'generateAlbumGalleryStatsLine',
+    'generateAlbumGalleryTrackGrid',
     'generateAlbumNavAccent',
     'generateAlbumSecondaryNav',
     'generateAlbumStyleRules',
-    'generateCoverGrid',
+    'generateIntrapageDotSwitcher',
     'generatePageLayout',
-    'image',
     'linkAlbum',
-    'linkTrack',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -20,147 +20,82 @@ export default {
   query(album) {
     const query = {};
 
-    const tracksWithUniqueCoverArt =
+    const trackArtworkLabels =
       album.tracks
-        .filter(track => track.hasUniqueCoverArt);
-
-    // Don't display "all artwork by..." for albums where there's
-    // only one unique artwork in the first place.
-    if (tracksWithUniqueCoverArt.length > 1) {
-      const allCoverArtistArrays =
-        tracksWithUniqueCoverArt
-          .map(track => track.coverArtistContribs)
-          .map(contribs => contribs.map(contrib => contrib.artist));
-
-      const allSameCoverArtists =
-        allCoverArtistArrays
-          .slice(1)
-          .every(artists => compareArrays(artists, allCoverArtistArrays[0]));
-
-      if (allSameCoverArtists) {
-        query.coverArtistsForAllTracks =
-          allCoverArtistArrays[0];
-      }
-    }
+        .map(track => track.trackArtworks
+          .map(artwork => artwork.label));
+
+    const recurranceThreshold = 2;
+
+    // This list may include null, if some artworks are not labelled!
+    // That's expected.
+    query.recurringTrackArtworkLabels =
+      unique(trackArtworkLabels.flat())
+        .filter(label =>
+          trackArtworkLabels
+            .filter(labels => labels.includes(label))
+            .length >=
+          (label === null
+            ? 1
+            : recurranceThreshold));
 
     return query;
   },
 
-  relations(relation, query, album) {
-    const relations = {};
+  relations: (relation, query, album) => ({
+    layout:
+      relation('generatePageLayout'),
 
-    relations.layout =
-      relation('generatePageLayout');
+    albumStyleRules:
+      relation('generateAlbumStyleRules', album, null),
 
-    relations.albumStyleRules =
-      relation('generateAlbumStyleRules', album, null);
-
-    relations.albumLink =
-      relation('linkAlbum', album);
-
-    relations.albumNavAccent =
-      relation('generateAlbumNavAccent', album, null);
-
-    relations.secondaryNav =
-      relation('generateAlbumSecondaryNav', album);
-
-    relations.statsLine =
-      relation('generateAlbumGalleryStatsLine', album);
-
-    if (album.tracks.every(track => !track.hasUniqueCoverArt)) {
-      relations.noTrackArtworksLine =
-        relation('generateAlbumGalleryNoTrackArtworksLine');
-    }
-
-    if (query.coverArtistsForAllTracks) {
-      relations.coverArtistsLine =
-        relation('generateAlbumGalleryCoverArtistsLine', query.coverArtistsForAllTracks);
-    }
-
-    relations.coverGrid =
-      relation('generateCoverGrid');
-
-    relations.links = [
+    albumLink:
       relation('linkAlbum', album),
 
-      ...
-        album.tracks
-          .map(track => relation('linkTrack', track)),
-    ];
-
-    relations.images = [
-      (album.hasCoverArt
-        ? relation('image', album.artTags)
-        : relation('image')),
+    albumNavAccent:
+      relation('generateAlbumNavAccent', album, null),
 
-      ...
-        album.tracks.map(track =>
-          (track.hasUniqueCoverArt
-            ? relation('image', track.artTags)
-            : relation('image'))),
-    ];
-
-    return relations;
-  },
+    secondaryNav:
+      relation('generateAlbumSecondaryNav', album),
 
-  data(query, album) {
-    const data = {};
+    statsLine:
+      relation('generateAlbumGalleryStatsLine', album),
 
-    data.name = album.name;
-    data.color = album.color;
-
-    data.names = [
-      album.name,
-      ...album.tracks.map(track => track.name),
-    ];
-
-    data.coverArtists = [
-      (album.hasCoverArt
-        ? album.coverArtistContribs.map(({artist}) => artist.name)
+    noTrackArtworksLine:
+      (album.tracks.every(track => !track.hasUniqueCoverArt)
+        ? relation('generateAlbumGalleryNoTrackArtworksLine')
         : null),
 
-      ...
-        album.tracks.map(track => {
-          if (query.coverArtistsForAllTracks) {
-            return null;
-          }
+    setSwitcher:
+      relation('generateIntrapageDotSwitcher'),
 
-          if (track.hasUniqueCoverArt) {
-            return track.coverArtistContribs.map(({artist}) => artist.name);
-          }
+    albumGrid:
+      relation('generateAlbumGalleryAlbumGrid', album),
 
-          return null;
-        }),
-    ];
-
-    data.paths = [
-      (album.hasCoverArt
-        ? ['media.albumCover', album.directory, album.coverArtFileExtension]
-        : null),
+    trackGrids:
+      query.recurringTrackArtworkLabels.map(label =>
+        relation('generateAlbumGalleryTrackGrid', album, label)),
+  }),
 
-      ...
-        album.tracks.map(track =>
-          (track.hasUniqueCoverArt
-            ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension]
-            : null)),
-    ];
+  data: (query, album) => ({
+    trackGridLabels:
+      query.recurringTrackArtworkLabels,
 
-    data.dimensions = [
-      (album.hasCoverArt
-        ? album.coverArtDimensions
-        : null),
+    trackGridIDs:
+      query.recurringTrackArtworkLabels.map(label =>
+        'track-grid-' +
+          (label
+            ? getKebabCase(label)
+            : 'no-label')),
 
-      ...
-        album.tracks.map(track =>
-          (track.hasUniqueCoverArt
-            ? track.coverArtDimensions
-            : null)),
-    ];
+    name:
+      album.name,
 
-    return data;
-  },
+    color:
+      album.color,
+  }),
 
-  generate: (data, relations, {language}) =>
+  generate: (data, relations, {html, language}) =>
     language.encapsulate('albumGalleryPage', pageCapsule =>
       relations.layout.slots({
         title:
@@ -176,34 +111,39 @@ export default {
         mainClasses: ['top-index'],
         mainContent: [
           relations.statsLine,
-          relations.coverArtistsLine,
+
+          relations.albumGrid,
+
           relations.noTrackArtworksLine,
 
-          relations.coverGrid
-            .slots({
-              links: relations.links,
-              names: data.names,
-              images:
-                stitchArrays({
-                  image: relations.images,
-                  path: data.paths,
-                  dimensions: data.dimensions,
-                  name: data.names,
-                }).map(({image, path, dimensions, name}) =>
-                    image.slots({
-                      path,
-                      dimensions,
-                      missingSourceContent:
-                        language.$('misc.albumGalleryGrid.noCoverArt', {name}),
-                    })),
-              info:
-                data.coverArtists.map(names =>
-                  (names === null
-                    ? null
-                    : language.$('misc.coverGrid.details.coverArtists', {
-                        artists: language.formatUnitList(names),
-                      }))),
-            }),
+          data.trackGridLabels.some(value => value !== null) &&
+            html.tag('p', {class: 'gallery-set-switcher'},
+              language.encapsulate(pageCapsule, 'setSwitcher', switcherCapsule =>
+                language.$(switcherCapsule, {
+                  sets:
+                    relations.setSwitcher.slots({
+                      initialOptionIndex: 0,
+
+                      titles:
+                        data.trackGridLabels.map(label =>
+                          label ??
+                          language.$(switcherCapsule, 'unlabeledSet')),
+
+                      targetIDs:
+                        data.trackGridIDs,
+                    }),
+                }))),
+
+          stitchArrays({
+            grid: relations.trackGrids,
+            id: data.trackGridIDs,
+          }).map(({grid, id}, index) =>
+              grid.slots({
+                attributes: [
+                  {id},
+                  index >= 1 && {style: 'display: none'},
+                ],
+              })),
         ],
 
         navLinkStyle: 'hierarchical',
diff --git a/src/content/dependencies/generateAlbumGalleryTrackGrid.js b/src/content/dependencies/generateAlbumGalleryTrackGrid.js
new file mode 100644
index 00000000..85e7576c
--- /dev/null
+++ b/src/content/dependencies/generateAlbumGalleryTrackGrid.js
@@ -0,0 +1,122 @@
+import {compareArrays, stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateAlbumGalleryCoverArtistsLine',
+    'generateCoverGrid',
+    'image',
+    'linkAlbum',
+    'linkTrack',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  query(album, label) {
+    const query = {};
+
+    query.artworks =
+      album.tracks.map(track =>
+        track.trackArtworks.find(artwork => artwork.label === label) ??
+        null);
+
+    const presentArtworks =
+      query.artworks.filter(Boolean);
+
+    if (presentArtworks.length > 1) {
+      const allArtistArrays =
+        presentArtworks
+          .map(artwork => artwork.artistContribs
+            .map(contrib => contrib.artist));
+
+      const allSameArtists =
+        allArtistArrays
+          .slice(1)
+          .every(artists => compareArrays(artists, allArtistArrays[0]));
+
+      if (allSameArtists) {
+        query.artistsForAllTrackArtworks =
+          allArtistArrays[0];
+      }
+    }
+
+    return query;
+  },
+
+  relations: (relation, query, album, _label) => ({
+    coverArtistsLine:
+      (query.artistsForAllTrackArtworks
+        ? relation('generateAlbumGalleryCoverArtistsLine',
+            query.artistsForAllTrackArtworks)
+        : null),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    trackLinks:
+      album.tracks
+        .map(track => relation('linkTrack', track)),
+
+    images:
+      query.artworks
+        .map(artwork => relation('image', artwork)),
+  }),
+
+  data: (query, album, _label) => ({
+    trackNames:
+      album.tracks
+        .map(track => track.name),
+
+    trackArtworkArtists:
+      query.artworks.map(artwork =>
+        (query.artistsForAllTrackArtworks
+          ? null
+       : artwork
+          ? artwork.artistContribs
+              .map(contrib => contrib.artist.name)
+          : null)),
+  }),
+
+  slots: {
+    attributes: {type: 'attributes', mutable: false},
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    html.tag('div',
+      {[html.onlyIfContent]: true},
+
+      slots.attributes,
+
+      [
+        relations.coverArtistsLine,
+
+        relations.coverGrid.slots({
+          links:
+            relations.trackLinks,
+
+          names:
+            data.trackNames,
+
+          images:
+            stitchArrays({
+              image: relations.images,
+              name: data.trackNames,
+            }).map(({image, name}) =>
+                image.slots({
+                  missingSourceContent:
+                    language.$('misc.albumGalleryGrid.noCoverArt', {name}),
+                })),
+
+          info:
+            data.trackArtworkArtists.map(artists =>
+              language.$('misc.coverGrid.details.coverArtists', {
+                [language.onlyIfOptions]: ['artists'],
+
+                artists:
+                  language.formatUnitList(artists),
+              })),
+        }),
+      ]),
+};
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index aae56637..d0788523 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -4,8 +4,8 @@ export default {
   contentDependencies: [
     'generateAdditionalNamesBox',
     'generateAlbumAdditionalFilesList',
+    'generateAlbumArtworkColumn',
     'generateAlbumBanner',
-    'generateAlbumCoverArtwork',
     'generateAlbumNavAccent',
     'generateAlbumReleaseInfo',
     'generateAlbumSecondaryNav',
@@ -44,10 +44,8 @@ export default {
     additionalNamesBox:
       relation('generateAdditionalNamesBox', album.additionalNames),
 
-    cover:
-      (album.hasCoverArt
-        ? relation('generateAlbumCoverArtwork', album)
-        : null),
+    artworkColumn:
+      relation('generateAlbumArtworkColumn', album),
 
     banner:
       (album.hasBannerArt
@@ -112,12 +110,8 @@ export default {
 
         additionalNames: relations.additionalNamesBox,
 
-        cover:
-          (relations.cover
-            ? relations.cover.slots({
-                showReferenceLinks: true,
-              })
-            : null),
+        artworkColumnContent:
+          relations.artworkColumn,
 
         mainContent: [
           relations.releaseInfo,
diff --git a/src/content/dependencies/generateAlbumReferencedArtworksPage.js b/src/content/dependencies/generateAlbumReferencedArtworksPage.js
index 3f3d77b3..7586393c 100644
--- a/src/content/dependencies/generateAlbumReferencedArtworksPage.js
+++ b/src/content/dependencies/generateAlbumReferencedArtworksPage.js
@@ -1,6 +1,5 @@
 export default {
   contentDependencies: [
-    'generateAlbumCoverArtwork',
     'generateAlbumStyleRules',
     'generateBackToAlbumLink',
     'generateReferencedArtworksPage',
@@ -11,7 +10,7 @@ export default {
 
   relations: (relation, album) => ({
     page:
-      relation('generateReferencedArtworksPage', album.referencedArtworks),
+      relation('generateReferencedArtworksPage', album.coverArtworks[0]),
 
     albumStyleRules:
       relation('generateAlbumStyleRules', album, null),
@@ -21,17 +20,11 @@ export default {
 
     backToAlbumLink:
       relation('generateBackToAlbumLink', album),
-
-    cover:
-      relation('generateAlbumCoverArtwork', album),
   }),
 
   data: (album) => ({
     name:
       album.name,
-
-    color:
-      album.color,
   }),
 
   generate: (data, relations, {html, language}) =>
@@ -42,11 +35,8 @@ export default {
             data.name,
         }),
 
-      color: data.color,
       styleRules: [relations.albumStyleRules],
 
-      cover: relations.cover,
-
       navLinks: [
         {auto: 'home'},
 
diff --git a/src/content/dependencies/generateAlbumReferencingArtworksPage.js b/src/content/dependencies/generateAlbumReferencingArtworksPage.js
index 8f2349f9..d072d2f6 100644
--- a/src/content/dependencies/generateAlbumReferencingArtworksPage.js
+++ b/src/content/dependencies/generateAlbumReferencingArtworksPage.js
@@ -1,6 +1,5 @@
 export default {
   contentDependencies: [
-    'generateAlbumCoverArtwork',
     'generateAlbumStyleRules',
     'generateBackToAlbumLink',
     'generateReferencingArtworksPage',
@@ -11,7 +10,7 @@ export default {
 
   relations: (relation, album) => ({
     page:
-      relation('generateReferencingArtworksPage', album.referencedByArtworks),
+      relation('generateReferencingArtworksPage', album.coverArtworks[0]),
 
     albumStyleRules:
       relation('generateAlbumStyleRules', album, null),
@@ -21,17 +20,11 @@ export default {
 
     backToAlbumLink:
       relation('generateBackToAlbumLink', album),
-
-    cover:
-      relation('generateAlbumCoverArtwork', album),
   }),
 
   data: (album) => ({
     name:
       album.name,
-
-    color:
-      album.color,
   }),
 
   generate: (data, relations, {html, language}) =>
@@ -42,11 +35,8 @@ export default {
             data.name,
         }),
 
-      color: data.color,
       styleRules: [relations.albumStyleRules],
 
-      cover: relations.cover,
-
       navLinks: [
         {auto: 'home'},
 
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
index 217282c0..0abb412c 100644
--- a/src/content/dependencies/generateAlbumReleaseInfo.js
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -14,9 +14,6 @@ export default {
     relations.artistContributionsLine =
       relation('generateReleaseInfoContributionsLine', album.artistContribs);
 
-    relations.coverArtistContributionsLine =
-      relation('generateReleaseInfoContributionsLine', album.coverArtistContribs);
-
     relations.wallpaperArtistContributionsLine =
       relation('generateReleaseInfoContributionsLine', album.wallpaperArtistContribs);
 
@@ -73,31 +70,11 @@ export default {
               chronologyKind: 'album',
             }),
 
-            relations.coverArtistContributionsLine.slots({
-              stringKey: capsule + '.coverArtBy',
-              chronologyKind: 'coverArt',
-            }),
-
-            relations.wallpaperArtistContributionsLine.slots({
-              stringKey: capsule + '.wallpaperArtBy',
-              chronologyKind: 'wallpaperArt',
-            }),
-
-            relations.bannerArtistContributionsLine.slots({
-              stringKey: capsule + '.bannerArtBy',
-              chronologyKind: 'bannerArt',
-            }),
-
             language.$(capsule, 'released', {
               [language.onlyIfOptions]: ['date'],
               date: language.formatDate(data.date),
             }),
 
-            language.$(capsule, 'artReleased', {
-              [language.onlyIfOptions]: ['date'],
-              date: language.formatDate(data.coverArtDate),
-            }),
-
             language.$(capsule, 'duration', {
               [language.onlyIfOptions]: ['duration'],
               duration:
diff --git a/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js b/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js
index 9f9aaf23..22dfa51c 100644
--- a/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js
+++ b/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js
@@ -67,6 +67,8 @@ export default {
 
   generate: (relations, slots) =>
     relations.parentSiblingsPart.slots({
+      attributes: {class: 'group-nav-links'},
+
       showPreviousNext: slots.mode === 'album',
 
       colorStyle: relations.colorStyle,
diff --git a/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js b/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js
index f579cdc9..16f205e3 100644
--- a/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js
+++ b/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js
@@ -62,7 +62,7 @@ export default {
 
   generate: (data, relations, slots, {language}) =>
     relations.parentSiblingsPart.slots({
-      attributes: {class: 'series-nav-link'},
+      attributes: {class: 'series-nav-links'},
 
       showPreviousNext: slots.mode === 'album',
 
diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js
index 88aea409..dae5fa03 100644
--- a/src/content/dependencies/generateAlbumSidebarTrackSection.js
+++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js
@@ -1,4 +1,4 @@
-import {empty} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: ['linkTrack'],
@@ -17,23 +17,25 @@ export default {
   data(album, track, trackSection) {
     const data = {};
 
-    data.hasTrackNumbers = album.hasTrackNumbers;
+    data.hasTrackNumbers =
+      album.hasTrackNumbers &&
+      !empty(trackSection.tracks);
+
     data.isTrackPage = !!track;
 
     data.name = trackSection.name;
     data.color = trackSection.color;
     data.isDefaultTrackSection = trackSection.isDefaultTrackSection;
 
-    data.firstTrackNumber = trackSection.startIndex + 1;
-    data.lastTrackNumber = trackSection.startIndex + trackSection.tracks.length;
+    data.firstTrackNumber =
+      (data.hasTrackNumbers
+        ? trackSection.tracks.at(0).trackNumber
+        : null);
 
-    if (track) {
-      const index = trackSection.tracks.indexOf(track);
-      if (index !== -1) {
-        data.includesCurrentTrack = true;
-        data.currentTrackIndex = index;
-      }
-    }
+    data.lastTrackNumber =
+      (data.hasTrackNumbers
+        ? trackSection.tracks.at(-1).trackNumber
+        : null);
 
     data.trackDirectories =
       trackSection.tracks
@@ -43,6 +45,13 @@ export default {
       trackSection.tracks
         .map(track => empty(track.commentary));
 
+    data.tracksAreCurrentTrack =
+      trackSection.tracks
+        .map(traaaaaaaack => traaaaaaaack === track);
+
+    data.includesCurrentTrack =
+      data.tracksAreCurrentTrack.includes(true);
+
     return data;
   },
 
@@ -72,29 +81,39 @@ export default {
     }
 
     const trackListItems =
-      relations.trackLinks.map((trackLink, index) =>
-        html.tag('li',
-          data.includesCurrentTrack &&
-          index === data.currentTrackIndex &&
-            {class: 'current'},
-
-          slots.mode === 'commentary' &&
-          data.tracksAreMissingCommentary[index] &&
-            {class: 'no-commentary'},
-
-          language.$(capsule, 'item', {
-            track:
-              (slots.mode === 'commentary' && data.tracksAreMissingCommentary[index]
-                ? trackLink.slots({
-                    linkless: true,
-                  })
-             : slots.anchor
-                ? trackLink.slots({
-                    anchor: true,
-                    hash: data.trackDirectories[index],
-                  })
-                : trackLink),
-          })));
+      stitchArrays({
+        trackLink: relations.trackLinks,
+        directory: data.trackDirectories,
+        isCurrentTrack: data.tracksAreCurrentTrack,
+        missingCommentary: data.tracksAreMissingCommentary,
+      }).map(({
+          trackLink,
+          directory,
+          isCurrentTrack,
+          missingCommentary,
+        }) =>
+          html.tag('li',
+            data.includesCurrentTrack &&
+            isCurrentTrack &&
+              {class: 'current'},
+
+            slots.mode === 'commentary' &&
+            missingCommentary &&
+              {class: 'no-commentary'},
+
+            language.$(capsule, 'item', {
+              track:
+                (slots.mode === 'commentary' && missingCommentary
+                  ? trackLink.slots({
+                      linkless: true,
+                    })
+               : slots.anchor
+                  ? trackLink.slots({
+                      anchor: true,
+                      hash: directory,
+                    })
+                  : trackLink),
+            })));
 
     return html.tag('details',
       data.includesCurrentTrack &&
@@ -121,17 +140,22 @@ export default {
           colorStyle,
 
           html.tag('span',
-            language.encapsulate(capsule, 'group', workingCapsule => {
-              const workingOptions = {group: sectionName};
-
-              if (data.hasTrackNumbers) {
-                workingCapsule += '.withRange';
-                workingOptions.range =
-                  `${data.firstTrackNumber}–${data.lastTrackNumber}`;
-              }
-
-              return language.$(workingCapsule, workingOptions);
-            }))),
+            language.encapsulate(capsule, 'group', groupCapsule =>
+              language.encapsulate(groupCapsule, workingCapsule => {
+                const workingOptions = {group: sectionName};
+
+                if (data.hasTrackNumbers) {
+                  workingCapsule += '.withRange';
+                  workingOptions.rangePart =
+                    html.tag('span', {class: 'track-section-range'},
+                      language.$(groupCapsule, 'withRange.rangePart', {
+                        range:
+                          `${data.firstTrackNumber}–${data.lastTrackNumber}`,
+                      }));
+                }
+
+                return language.$(workingCapsule, workingOptions);
+              })))),
 
         (data.hasTrackNumbers
           ? html.tag('ol',
diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js
index ad02e180..e28a3fd0 100644
--- a/src/content/dependencies/generateAlbumSocialEmbed.js
+++ b/src/content/dependencies/generateAlbumSocialEmbed.js
@@ -32,8 +32,7 @@ export default {
     data.hasImage = album.hasCoverArt;
 
     if (data.hasImage) {
-      data.coverArtDirectory = album.directory;
-      data.coverArtFileExtension = album.coverArtFileExtension;
+      data.imagePath = album.coverArtworks[0].path;
     }
 
     data.albumName = album.name;
@@ -65,7 +64,7 @@ export default {
 
         imagePath:
           (data.hasImage
-            ? ['media.albumCover', data.coverArtDirectory, data.coverArtFileExtension]
+            ? data.imagePath
             : null),
       })),
 };
diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js
index 9743c750..0a949ded 100644
--- a/src/content/dependencies/generateAlbumTrackList.js
+++ b/src/content/dependencies/generateAlbumTrackList.js
@@ -102,11 +102,11 @@ export default {
             .map(section => section.tracks.length > 1);
 
         if (album.hasTrackNumbers) {
-          data.trackSectionStartIndices =
+          data.trackSectionsStartCountingFrom =
             album.trackSections
-              .map(section => section.startIndex);
+              .map(section => section.startCountingFrom);
         } else {
-          data.trackSectionStartIndices =
+          data.trackSectionsStartCountingFrom =
             album.trackSections
               .map(() => null);
         }
@@ -147,7 +147,7 @@ export default {
             name: data.trackSectionNames,
             duration: data.trackSectionDurations,
             durationApproximate: data.trackSectionDurationsApproximate,
-            startIndex: data.trackSectionStartIndices,
+            startCountingFrom: data.trackSectionsStartCountingFrom,
           }).map(({
               heading,
               description,
@@ -156,7 +156,7 @@ export default {
               name,
               duration,
               durationApproximate,
-              startIndex,
+              startCountingFrom,
             }) => [
               language.encapsulate('trackList.section', capsule =>
                 heading.slots({
@@ -190,7 +190,7 @@ export default {
 
                 html.tag(listTag,
                   data.hasTrackNumbers &&
-                    {start: startIndex + 1},
+                    {start: startCountingFrom},
 
                   slotItems(items)),
               ]),
diff --git a/src/content/dependencies/generateArtTagAncestorDescendantMapList.js b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js
index 89150615..80d19b5a 100644
--- a/src/content/dependencies/generateArtTagAncestorDescendantMapList.js
+++ b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js
@@ -33,8 +33,8 @@ export default {
       const artTagsTimesFeaturedTotal =
         artTags.map(artTag =>
           unique([
-            ...artTag.directlyTaggedInThings,
-            ...artTag.indirectlyTaggedInThings,
+            ...artTag.directlyFeaturedInArtworks,
+            ...artTag.indirectlyFeaturedInArtworks,
           ]).length);
 
       const sublists =
diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js
index b633e58f..344e7bda 100644
--- a/src/content/dependencies/generateArtTagGalleryPage.js
+++ b/src/content/dependencies/generateArtTagGalleryPage.js
@@ -1,5 +1,5 @@
-import {sortAlbumsTracksChronologically} from '#sort';
-import {empty, stitchArrays, unique} from '#sugar';
+import {sortArtworksChronologically} from '#sort';
+import {empty, unique} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -11,10 +11,9 @@ export default {
     'generatePageLayout',
     'generateQuickDescription',
     'image',
-    'linkAlbum',
+    'linkAnythingMan',
     'linkArtTagGallery',
     'linkExternal',
-    'linkTrack',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
@@ -26,16 +25,13 @@ export default {
   },
 
   query(sprawl, artTag) {
-    const directThings = artTag.directlyTaggedInThings;
-    const indirectThings = artTag.indirectlyTaggedInThings;
-    const allThings = unique([...directThings, ...indirectThings]);
+    const directArtworks = artTag.directlyFeaturedInArtworks;
+    const indirectArtworks = artTag.indirectlyFeaturedInArtworks;
+    const allArtworks = unique([...directArtworks, ...indirectArtworks]);
 
-    sortAlbumsTracksChronologically(allThings, {
-      getDate: thing => thing.coverArtDate ?? thing.date,
-      latestFirst: true,
-    });
+    sortArtworksChronologically(allArtworks, {latestFirst: true});
 
-    return {directThings, indirectThings, allThings};
+    return {directArtworks, indirectArtworks, allArtworks};
   },
 
   relations(relation, query, sprawl, artTag) {
@@ -81,15 +77,12 @@ export default {
       relation('generateCoverGrid');
 
     relations.links =
-      query.allThings
-        .map(thing =>
-          (thing.album
-            ? relation('linkTrack', thing)
-            : relation('linkAlbum', thing)));
+      query.allArtworks
+        .map(artwork => relation('linkAnythingMan', artwork.thing));
 
     relations.images =
-      query.allThings
-        .map(thing => relation('image', thing.artTags));
+      query.allArtworks
+        .map(artwork => relation('image', artwork));
 
     return relations;
   },
@@ -102,30 +95,22 @@ export default {
     data.name = artTag.name;
     data.color = artTag.color;
 
-    data.numArtworksIndirectly = query.indirectThings.length;
-    data.numArtworksDirectly = query.directThings.length;
-    data.numArtworksTotal = query.allThings.length;
+    data.numArtworksIndirectly = query.indirectArtworks.length;
+    data.numArtworksDirectly = query.directArtworks.length;
+    data.numArtworksTotal = query.allArtworks.length;
 
     data.names =
-      query.allThings.map(thing => thing.name);
-
-    data.paths =
-      query.allThings.map(thing =>
-        (thing.album
-          ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
-          : ['media.albumCover', thing.directory, thing.coverArtFileExtension]));
-
-    data.dimensions =
-      query.allThings.map(thing => thing.coverArtDimensions);
+      query.allArtworks
+        .map(artwork => artwork.thing.name);
 
     data.coverArtists =
-      query.allThings.map(thing =>
-        thing.coverArtistContribs
-          .map(({artist}) => artist.name));
+      query.allArtworks
+        .map(artwork => artwork.artistContribs
+          .map(contrib => contrib.artist.name));
 
     data.onlyFeaturedIndirectly =
-      query.allThings.map(thing =>
-        !query.directThings.includes(thing));
+      query.allArtworks.map(artwork =>
+        !query.directArtworks.includes(artwork));
 
     data.hasMixedDirectIndirect =
       data.onlyFeaturedIndirectly.includes(true) &&
@@ -210,6 +195,7 @@ export default {
           relations.coverGrid
             .slots({
               links: relations.links,
+              images: relations.images,
               names: data.names,
               lazy: 12,
 
@@ -217,17 +203,6 @@ export default {
                 data.onlyFeaturedIndirectly.map(onlyFeaturedIndirectly =>
                   (onlyFeaturedIndirectly ? 'featured-indirectly' : '')),
 
-              images:
-                stitchArrays({
-                  image: relations.images,
-                  path: data.paths,
-                  dimensions: data.dimensions,
-                }).map(({image, path, dimensions}) =>
-                    image.slots({
-                      path,
-                      dimensions,
-                    })),
-
               info:
                 data.coverArtists.map(names =>
                   (names === null
diff --git a/src/content/dependencies/generateArtTagInfoPage.js b/src/content/dependencies/generateArtTagInfoPage.js
index 7765f159..9df51b77 100644
--- a/src/content/dependencies/generateArtTagInfoPage.js
+++ b/src/content/dependencies/generateArtTagInfoPage.js
@@ -23,10 +23,10 @@ export default {
     const query = {};
 
     query.directThings =
-      artTag.directlyTaggedInThings;
+      artTag.directlyFeaturedInArtworks;
 
     query.indirectThings =
-      artTag.indirectlyTaggedInThings;
+      artTag.indirectlyFeaturedInArtworks;
 
     query.allThings =
       unique([...query.directThings, ...query.indirectThings]);
@@ -111,8 +111,8 @@ export default {
     directDescendantTimesFeaturedTotal:
       artTag.directDescendantArtTags.map(artTag =>
         unique([
-          ...artTag.directlyTaggedInThings,
-          ...artTag.indirectlyTaggedInThings,
+          ...artTag.directlyFeaturedInArtworks,
+          ...artTag.indirectlyFeaturedInArtworks,
         ]).length),
   }),
 
diff --git a/src/content/dependencies/generateArtTagSidebar.js b/src/content/dependencies/generateArtTagSidebar.js
index c281b93d..9e2f813c 100644
--- a/src/content/dependencies/generateArtTagSidebar.js
+++ b/src/content/dependencies/generateArtTagSidebar.js
@@ -54,8 +54,8 @@ export default {
     directDescendantTimesFeaturedTotal:
       artTag.directDescendantArtTags.map(artTag =>
         unique([
-          ...artTag.directlyTaggedInThings,
-          ...artTag.indirectlyTaggedInThings,
+          ...artTag.directlyFeaturedInArtworks,
+          ...artTag.indirectlyFeaturedInArtworks,
         ]).length),
 
     furthestAncestorArtTagNames:
diff --git a/src/content/dependencies/generateArtistArtworkColumn.js b/src/content/dependencies/generateArtistArtworkColumn.js
new file mode 100644
index 00000000..a4135489
--- /dev/null
+++ b/src/content/dependencies/generateArtistArtworkColumn.js
@@ -0,0 +1,13 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+
+  relations: (relation, artist) => ({
+    coverArtwork:
+      (artist.hasAvatar
+        ? relation('generateCoverArtwork', artist.avatarArtwork)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.coverArtwork,
+};
diff --git a/src/content/dependencies/generateArtistCredit.js b/src/content/dependencies/generateArtistCredit.js
index 72d55854..6bdbeb23 100644
--- a/src/content/dependencies/generateArtistCredit.js
+++ b/src/content/dependencies/generateArtistCredit.js
@@ -80,6 +80,8 @@ export default {
     // It won't be used if contextContributions isn't provided.
     featuringStringKey: {type: 'string'},
 
+    additionalStringOptions: {validate: v => v.isObject},
+
     showAnnotation: {type: 'boolean', default: false},
     showExternalLinks: {type: 'boolean', default: false},
     showChronology: {type: 'boolean', default: false},
@@ -148,7 +150,10 @@ export default {
 
     if (empty(relations.featuringContributionLinks)) {
       if (data.normalContributionsDifferFromContext) {
-        return language.$(slots.normalStringKey, {artists: artistsList});
+        return language.$(slots.normalStringKey, {
+          ...slots.additionalStringOptions,
+          artists: artistsList,
+        });
       } else {
         return html.blank();
       }
@@ -156,13 +161,20 @@ export default {
 
     if (data.normalContributionsDifferFromContext && slots.normalFeaturingStringKey) {
       return language.$(slots.normalFeaturingStringKey, {
+        ...slots.additionalStringOptions,
         artists: artistsList,
         featuring: featuringList,
       });
     } else if (slots.featuringStringKey) {
-      return language.$(slots.featuringStringKey, {artists: featuringList});
+      return language.$(slots.featuringStringKey, {
+        ...slots.additionalStringOptions,
+        artists: featuringList,
+      });
     } else {
-      return language.$(slots.normalStringKey, {artists: everyoneList});
+      return language.$(slots.normalStringKey, {
+        ...slots.additionalStringOptions,
+        artists: everyoneList,
+      });
     }
   },
 };
diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js
index 7a76188a..6a24275e 100644
--- a/src/content/dependencies/generateArtistGalleryPage.js
+++ b/src/content/dependencies/generateArtistGalleryPage.js
@@ -1,5 +1,4 @@
-import {sortAlbumsTracksChronologically} from '#sort';
-import {stitchArrays} from '#sugar';
+import {sortArtworksChronologically} from '#sort';
 
 export default {
   contentDependencies: [
@@ -7,83 +6,59 @@ export default {
     'generateCoverGrid',
     'generatePageLayout',
     'image',
-    'linkAlbum',
-    'linkTrack',
+    'linkAnythingMan',
   ],
 
   extraDependencies: ['html', 'language'],
 
-  query(artist) {
-    const things =
-      ([
-        artist.albumCoverArtistContributions,
-        artist.trackCoverArtistContributions,
-      ]).flat()
-        .filter(({annotation}) => !annotation?.startsWith(`edits for wiki`))
-        .map(({thing}) => thing);
-
-    sortAlbumsTracksChronologically(things, {
-      latestFirst: true,
-      getDate: thing => thing.coverArtDate ?? thing.date,
-    });
-
-    return {things};
-  },
-
-  relations(relation, query, artist) {
-    const relations = {};
-
-    relations.layout =
-      relation('generatePageLayout');
-
-    relations.artistNavLinks =
-      relation('generateArtistNavLinks', artist);
-
-    relations.coverGrid =
-      relation('generateCoverGrid');
-
-    relations.links =
-      query.things.map(thing =>
-        (thing.album
-          ? relation('linkTrack', thing)
-          : relation('linkAlbum', thing)));
-
-    relations.images =
-      query.things.map(thing =>
-        relation('image', thing.artTags));
-
-    return relations;
-  },
-
-  data(query, artist) {
-    const data = {};
-
-    data.name = artist.name;
-
-    data.numArtworks = query.things.length;
-
-    data.names =
-      query.things.map(thing => thing.name);
-
-    data.paths =
-      query.things.map(thing =>
-        (thing.album
-          ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
-          : ['media.albumCover', thing.directory, thing.coverArtFileExtension]));
-
-    data.dimensions =
-      query.things.map(thing => thing.coverArtDimensions);
-
-    data.otherCoverArtists =
-      query.things.map(thing =>
-        (thing.coverArtistContribs.length > 1
-          ? thing.coverArtistContribs
-              .filter(({artist: otherArtist}) => otherArtist !== artist)
-              .map(({artist: otherArtist}) => otherArtist.name)
-          : null));
-
-    return data;
-  },
+  query: (artist) => ({
+    artworks:
+      sortArtworksChronologically(
+        ([
+          artist.albumCoverArtistContributions,
+          artist.trackCoverArtistContributions,
+        ]).flat()
+          .filter(contrib => !contrib.annotation?.startsWith(`edits for wiki`))
+          .map(contrib => contrib.thing),
+        {latestFirst: true}),
+  }),
+
+  relations: (relation, query, artist) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    artistNavLinks:
+      relation('generateArtistNavLinks', artist),
+
+    coverGrid:
+      relation('generateCoverGrid'),
+
+    links:
+      query.artworks
+        .map(artwork => relation('linkAnythingMan', artwork.thing)),
+
+    images:
+      query.artworks
+        .map(artwork => relation('image', artwork)),
+  }),
+
+  data: (query, artist) => ({
+    name:
+      artist.name,
+
+    numArtworks:
+      query.artworks.length,
+
+    names:
+      query.artworks
+        .map(artwork => artwork.thing.name),
+
+    otherCoverArtists:
+      query.artworks
+        .map(artwork => artwork.artistContribs
+          .filter(contrib => contrib.artist !== artist)
+          .map(contrib => contrib.artist.name)),
+  }),
 
   generate: (data, relations, {html, language}) =>
     language.encapsulate('artistGalleryPage', pageCapsule =>
@@ -100,7 +75,7 @@ export default {
           html.tag('p', {class: 'quick-info'},
             language.$(pageCapsule, 'infoLine', {
               coverArts:
-                language.countCoverArts(data.numArtworks, {
+                language.countArtworks(data.numArtworks, {
                   unit: true,
                 }),
             })),
@@ -108,27 +83,16 @@ export default {
           relations.coverGrid
             .slots({
               links: relations.links,
+              images: relations.images,
               names: data.names,
 
-              images:
-                stitchArrays({
-                  image: relations.images,
-                  path: data.paths,
-                  dimensions: data.dimensions,
-                }).map(({image, path, dimensions}) =>
-                    image.slots({
-                      path,
-                      dimensions,
-                    })),
-
-              // TODO: Can this be [language.onlyIfOptions]?
               info:
                 data.otherCoverArtists.map(names =>
-                  (names === null
-                    ? null
-                    : language.$('misc.coverGrid.details.otherCoverArtists', {
-                        artists: language.formatUnitList(names),
-                      }))),
+                  language.$('misc.coverGrid.details.otherCoverArtists', {
+                    [language.onlyIfOptions]: ['artists'],
+
+                    artists: language.formatUnitList(names),
+                  })),
             }),
         ],
 
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
index 0c4e4189..3a3cf8b7 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -2,6 +2,7 @@ import {empty, stitchArrays, unique} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateArtistArtworkColumn',
     'generateArtistGroupContributionsInfo',
     'generateArtistInfoPageArtworksChunkedList',
     'generateArtistInfoPageCommentaryChunkedList',
@@ -9,9 +10,7 @@ export default {
     'generateArtistInfoPageTracksChunkedList',
     'generateArtistNavLinks',
     'generateContentHeading',
-    'generateCoverArtwork',
     'generatePageLayout',
-    'image',
     'linkArtistGallery',
     'linkExternal',
     'linkGroup',
@@ -35,7 +34,7 @@ export default {
     // Artworks are different, though. We intentionally duplicate album data
     // objects when the artist has contributed some combination of cover art,
     // wallpaper, and banner - these each count as a unique contribution.
-    allArtworks:
+    allArtworkThings:
       ([
         artist.albumCoverArtistContributions,
         artist.albumWallpaperArtistContributions,
@@ -43,7 +42,7 @@ export default {
         artist.trackCoverArtistContributions,
       ]).flat()
         .filter(({annotation}) => !annotation?.startsWith('edits for wiki'))
-        .map(({thing}) => thing),
+        .map(({thing}) => thing.thing),
 
     // Banners and wallpapers don't show up in the artist gallery page, only
     // cover art.
@@ -69,15 +68,8 @@ export default {
     artistNavLinks:
       relation('generateArtistNavLinks', artist),
 
-    cover:
-      (artist.hasAvatar
-        ? relation('generateCoverArtwork', [], [])
-        : null),
-
-    image:
-      (artist.hasAvatar
-        ? relation('image')
-        : null),
+    artworkColumn:
+      relation('generateArtistArtworkColumn', artist),
 
     contentHeading:
       relation('generateContentHeading'),
@@ -110,7 +102,7 @@ export default {
       relation('generateArtistInfoPageArtworksChunkedList', artist, true),
 
     artworksGroupInfo:
-      relation('generateArtistGroupContributionsInfo', query.allArtworks),
+      relation('generateArtistGroupContributionsInfo', query.allArtworkThings),
 
     artistGalleryLink:
       (query.hasGallery
@@ -131,14 +123,6 @@ export default {
     name:
       artist.name,
 
-    directory:
-      artist.directory,
-
-    avatarFileExtension:
-      (artist.hasAvatar
-        ? artist.avatarFileExtension
-        : null),
-
     closeGroupAnnotations:
       query.generalLinkedGroups
         .map(({annotation}) => annotation),
@@ -156,19 +140,8 @@ export default {
         title: data.name,
         headingMode: 'sticky',
 
-        cover:
-          (relations.cover
-            ? relations.cover.slots({
-                image:
-                  relations.image.slots({
-                    path: [
-                      'media.artistAvatar',
-                      data.directory,
-                      data.avatarFileExtension,
-                    ],
-                  }),
-              })
-            : null),
+        artworkColumnContent:
+          relations.artworkColumn,
 
         mainContent: [
           html.tags([
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
index 089cfb8d..2f2fe0c5 100644
--- a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
@@ -24,7 +24,7 @@ export default {
 
     trackLink:
       (query.kind === 'track-cover'
-        ? relation('linkTrack', contrib.thing)
+        ? relation('linkTrack', contrib.thing.thing)
         : null),
 
     otherArtistLinks:
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
index 8b024147..75a4aa5a 100644
--- a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
@@ -27,20 +27,21 @@ export default {
 
     sortContributionsChronologically(
       filteredContributions,
-      sortAlbumsTracksChronologically);
+      sortAlbumsTracksChronologically,
+      {getThing: contrib => contrib.thing.thing});
 
     query.contribs =
       chunkByConditions(filteredContributions, [
         ({date: date1}, {date: date2}) =>
           +date1 !== +date2,
-        ({thing: thing1}, {thing: thing2}) =>
+        ({thing: {thing: thing1}}, {thing: {thing: thing2}}) =>
           (thing1.album ?? thing1) !==
           (thing2.album ?? thing2),
       ]);
 
     query.albums =
       query.contribs
-        .map(contribs => contribs[0].thing)
+        .map(contribs => contribs[0].thing.thing)
         .map(thing => thing.album ?? thing);
 
     return query;
diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js
index c93020f3..4cb618e3 100644
--- a/src/content/dependencies/generateCommentaryEntry.js
+++ b/src/content/dependencies/generateCommentaryEntry.js
@@ -98,8 +98,6 @@ export default {
 
                 return language.$(workingCapsule, workingOptions);
               })),
-
-            relations.date,
           ])),
 
         html.tag('blockquote', {class: 'commentary-entry-body'},
@@ -107,6 +105,10 @@ export default {
             relations.colorStyle.clone()
               .slot('color', slots.color),
 
-          relations.bodyContent.slot('mode', 'multiline')),
+          [
+            relations.date,
+
+            relations.bodyContent.slot('mode', 'multiline'),
+          ]),
       ])),
 };
diff --git a/src/content/dependencies/generateContributionTooltipChronologySection.js b/src/content/dependencies/generateContributionTooltipChronologySection.js
index 78c9051c..378c0e1c 100644
--- a/src/content/dependencies/generateContributionTooltipChronologySection.js
+++ b/src/content/dependencies/generateContributionTooltipChronologySection.js
@@ -1,3 +1,19 @@
+import Thing from '#thing';
+
+function getName(thing) {
+  if (!thing) {
+    return null;
+  }
+
+  const referenceType = thing.constructor[Thing.referenceType];
+
+  if (referenceType === 'artwork') {
+    return thing.thing.name;
+  }
+
+  return thing.name;
+}
+
 export default {
   contentDependencies: ['linkAnythingMan'],
   extraDependencies: ['html', 'language'],
@@ -30,14 +46,10 @@ export default {
 
   data: (query, _contribution) => ({
     previousName:
-      (query.previous
-        ? query.previous.thing.name
-        : null),
+      getName(query.previous?.thing),
 
     nextName:
-      (query.next
-        ? query.next.thing.name
-        : null),
+      getName(query.next?.thing),
   }),
 
   slots: {
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index 06972d6b..3a10ab20 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -1,11 +1,44 @@
 export default {
-  contentDependencies: ['image'],
+  contentDependencies: [
+    'generateCoverArtworkArtTagDetails',
+    'generateCoverArtworkArtistDetails',
+    'generateCoverArtworkOriginDetails',
+    'generateCoverArtworkReferenceDetails',
+    'image',
+  ],
+
   extraDependencies: ['html'],
 
+  relations: (relation, artwork) => ({
+    image:
+      relation('image', artwork),
+
+    originDetails:
+      relation('generateCoverArtworkOriginDetails', artwork),
+
+    artTagDetails:
+      relation('generateCoverArtworkArtTagDetails', artwork),
+
+    artistDetails:
+      relation('generateCoverArtworkArtistDetails', artwork),
+
+    referenceDetails:
+      relation('generateCoverArtworkReferenceDetails', artwork),
+  }),
+
+  data: (artwork) => ({
+    color:
+      artwork.thing.color ?? null,
+
+    dimensions:
+      artwork.dimensions,
+  }),
+
   slots: {
-    image: {
-      type: 'html',
-      mutable: true,
+    alt: {type: 'string'},
+
+    color: {
+      validate: v => v.isColor,
     },
 
     mode: {
@@ -13,13 +46,10 @@ export default {
       default: 'primary',
     },
 
-    dimensions: {
-      validate: v => v.isDimensions,
-    },
-
-    warnings: {
-      validate: v => v.looseArrayOf(v.isString),
-    },
+    showOriginDetails: {type: 'boolean', default: false},
+    showArtTagDetails: {type: 'boolean', default: false},
+    showArtistDetails: {type: 'boolean', default: false},
+    showReferenceDetails: {type: 'boolean', default: false},
 
     details: {
       type: 'html',
@@ -27,60 +57,65 @@ export default {
     },
   },
 
-  generate(slots, {html}) {
+  generate(data, relations, slots, {html}) {
+    const {image} = relations;
+
+    image.setSlots({
+      color: slots.color ?? data.color,
+      alt: slots.alt,
+    });
+
     const square =
-      (slots.dimensions
-        ? slots.dimensions[0] === slots.dimensions[1]
+      (data.dimensions
+        ? data.dimensions[0] === data.dimensions[1]
         : true);
 
-    const sizeSlots =
-      (square
-        ? {square: true}
-        : {dimensions: slots.dimensions});
-
-    switch (slots.mode) {
-      case 'primary':
-        return html.tags([
-          slots.image.slots({
-            thumb: 'medium',
-            reveal: true,
-            link: true,
-
-            warnings: slots.warnings,
-            ...sizeSlots,
-          }),
-
-          slots.details,
-        ]);
-
-      case 'thumbnail':
-        return (
-          slots.image.slots({
-            thumb: 'small',
-            reveal: false,
-            link: false,
-
-            warnings: slots.warnings,
-            ...sizeSlots,
-          }));
-
-      case 'commentary':
-        return (
-          slots.image.slots({
-            thumb: 'medium',
-            reveal: true,
-            link: true,
-            lazy: true,
-
-            warnings: slots.warnings,
-            ...sizeSlots,
-
-            attributes:
-              {class: 'commentary-art'},
-          }));
-
-      default:
-        return html.blank();
+    if (square) {
+      image.setSlot('square', true);
+    } else {
+      image.setSlot('dimensions', data.dimensions);
     }
+
+    return (
+      html.tag('div', {class: 'cover-artwork'},
+        slots.mode === 'commentary' &&
+          {class: 'commentary-art'},
+
+        (slots.mode === 'primary'
+          ? [
+              relations.image.slots({
+                thumb: 'medium',
+                reveal: true,
+                link: true,
+              }),
+
+              slots.showOriginDetails &&
+                relations.originDetails,
+
+              slots.showArtTagDetails &&
+                relations.artTagDetails,
+
+              slots.showArtistDetails &&
+                relations.artistDetails,
+
+              slots.showReferenceDetails &&
+                relations.referenceDetails,
+
+              slots.details,
+            ]
+       : slots.mode === 'thumbnail'
+          ? relations.image.slots({
+              thumb: 'small',
+              reveal: false,
+              link: false,
+            })
+       : slots.mode === 'commentary'
+          ? relations.image.slots({
+              thumb: 'medium',
+              reveal: true,
+              link: true,
+              lazy: true,
+            })
+          : html.blank())));
   },
 };
diff --git a/src/content/dependencies/generateCoverArtworkArtTagDetails.js b/src/content/dependencies/generateCoverArtworkArtTagDetails.js
index b4edbbdd..b20f599b 100644
--- a/src/content/dependencies/generateCoverArtworkArtTagDetails.js
+++ b/src/content/dependencies/generateCoverArtworkArtTagDetails.js
@@ -4,19 +4,19 @@ export default {
   contentDependencies: ['linkArtTagGallery'],
   extraDependencies: ['html'],
 
-  query: (artTags) => ({
+  query: (artwork) => ({
     linkableArtTags:
-      artTags
+      artwork.artTags
         .filter(tag => !tag.isContentWarning),
   }),
 
-  relations: (relation, query, _artTags) => ({
+  relations: (relation, query, _artwork) => ({
     artTagLinks:
       query.linkableArtTags
         .map(tag => relation('linkArtTagGallery', tag)),
   }),
 
-  data: (query, _artTags) => {
+  data: (query, _artwork) => {
     const seenShortNames = new Set();
     const duplicateShortNames = new Set();
 
diff --git a/src/content/dependencies/generateCoverArtworkArtistDetails.js b/src/content/dependencies/generateCoverArtworkArtistDetails.js
index 5b235353..3ead80ab 100644
--- a/src/content/dependencies/generateCoverArtworkArtistDetails.js
+++ b/src/content/dependencies/generateCoverArtworkArtistDetails.js
@@ -2,9 +2,9 @@ export default {
   contentDependencies: ['linkArtistGallery'],
   extraDependencies: ['html', 'language'],
 
-  relations: (relation, contributions) => ({
+  relations: (relation, artwork) => ({
     artistLinks:
-      contributions
+      artwork.artistContribs
         .map(contrib => contrib.artist)
         .map(artist =>
           relation('linkArtistGallery', artist)),
@@ -17,6 +17,8 @@ export default {
       {class: 'illustrator-details'},
 
       language.$('misc.coverGrid.details.coverArtists', {
+        [language.onlyIfOptions]: ['artists'],
+
         artists:
           language.formatConjunctionList(relations.artistLinks),
       })),
diff --git a/src/content/dependencies/generateCoverArtworkOriginDetails.js b/src/content/dependencies/generateCoverArtworkOriginDetails.js
new file mode 100644
index 00000000..08a01cfe
--- /dev/null
+++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js
@@ -0,0 +1,98 @@
+import Thing from '#thing';
+
+export default {
+  contentDependencies: [
+    'generateArtistCredit',
+    'generateAbsoluteDatetimestamp',
+    'linkAlbum',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language', 'pagePath'],
+
+  query: (artwork) => ({
+    artworkThingType:
+      artwork.thing.constructor[Thing.referenceType],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    credit:
+      relation('generateArtistCredit', artwork.artistContribs, []),
+
+    source:
+      relation('transformContent', artwork.source),
+
+    albumLink:
+      (query.artworkThingType === 'album'
+        ? relation('linkAlbum', artwork.thing)
+        : null),
+
+    datetimestamp:
+      (artwork.date && artwork.date !== artwork.thing.date
+        ? relation('generateAbsoluteDatetimestamp', artwork.date)
+        : null),
+  }),
+
+
+  data: (query, artwork) => ({
+    label:
+      artwork.label,
+
+    artworkThingType:
+      query.artworkThingType,
+  }),
+
+  generate: (data, relations, {html, language, pagePath}) =>
+    language.encapsulate('misc.coverArtwork', capsule =>
+      html.tag('p', {class: 'image-details'},
+        {[html.onlyIfContent]: true},
+        {[html.joinChildren]: html.tag('br')},
+
+        {class: 'origin-details'},
+
+        [
+          language.encapsulate(capsule, 'artworkBy', workingCapsule => {
+            const workingOptions = {};
+
+            if (data.label) {
+              workingCapsule += '.customLabel';
+              workingOptions.label = data.label;
+            }
+
+            if (relations.datetimestamp) {
+              workingCapsule += '.withYear';
+              workingOptions.year =
+                relations.datetimestamp.slots({
+                  style: 'year',
+                  tooltip: true,
+                });
+            }
+
+            return relations.credit.slots({
+              showAnnotation: true,
+              showExternalLinks: true,
+              showChronology: true,
+              showWikiEdits: true,
+
+              trimAnnotation: false,
+
+              chronologyKind: 'coverArt',
+
+              normalStringKey: workingCapsule,
+              additionalStringOptions: workingOptions,
+            });
+          }),
+
+          pagePath[0] === 'track' &&
+          data.artworkThingType === 'album' &&
+            language.$(capsule, 'trackArtFromAlbum', {
+              album:
+                relations.albumLink.slot('color', false),
+            }),
+
+          language.$(capsule, 'source', {
+            [language.onlyIfOptions]: ['source'],
+            source: relations.source.slot('mode', 'inline'),
+          }),
+        ])),
+};
diff --git a/src/content/dependencies/generateCoverArtworkReferenceDetails.js b/src/content/dependencies/generateCoverArtworkReferenceDetails.js
index 006b2b4b..035ab586 100644
--- a/src/content/dependencies/generateCoverArtworkReferenceDetails.js
+++ b/src/content/dependencies/generateCoverArtworkReferenceDetails.js
@@ -1,20 +1,24 @@
 export default {
+  contentDependencies: ['linkReferencedArtworks', 'linkReferencingArtworks'],
   extraDependencies: ['html', 'language'],
 
-  data: (referenced, referencedBy) => ({
+  relations: (relation, artwork) => ({
+    referencedArtworksLink:
+      relation('linkReferencedArtworks', artwork),
+
+    referencingArtworksLink:
+      relation('linkReferencingArtworks', artwork),
+  }),
+
+  data: (artwork) => ({
     referenced:
-      referenced.length,
+      artwork.referencedArtworks.length,
 
     referencedBy:
-      referencedBy.length,
+      artwork.referencedByArtworks.length,
   }),
 
-  slots: {
-    referencedLink: {type: 'html', mutable: true},
-    referencingLink: {type: 'html', mutable: true},
-  },
-
-  generate: (data, slots, {html, language}) =>
+  generate: (data, relations, {html, language}) =>
     language.encapsulate('releaseInfo', capsule => {
       const referencedText =
         language.$(capsule, 'referencesArtworks', {
@@ -47,10 +51,10 @@ export default {
 
           [
             !html.isBlank(referencedText) &&
-              slots.referencedLink.slot('content', referencedText),
+              relations.referencedArtworksLink.slot('content', referencedText),
 
             !html.isBlank(referencingText) &&
-              slots.referencingLink.slot('content', referencingText),
+              relations.referencingArtworksLink.slot('content', referencingText),
           ]));
     }),
 }
diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js
index 1898832f..29ac08b7 100644
--- a/src/content/dependencies/generateCoverGrid.js
+++ b/src/content/dependencies/generateCoverGrid.js
@@ -33,9 +33,11 @@ export default {
     actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
   },
 
-  generate(relations, slots, {html, language}) {
-    return (
-      html.tag('div', {class: 'grid-listing'}, [
+  generate: (relations, slots, {html, language}) =>
+    html.tag('div', {class: 'grid-listing'},
+      {[html.onlyIfContent]: true},
+
+      [
         stitchArrays({
           classes: slots.classes,
           image: slots.images,
@@ -84,6 +86,5 @@ export default {
 
         relations.actionLinks
           .slot('actionLinks', slots.actionLinks),
-      ]));
-  },
+      ]),
 };
diff --git a/src/content/dependencies/generateFlashActGalleryPage.js b/src/content/dependencies/generateFlashActGalleryPage.js
index 8f174b21..84ab549d 100644
--- a/src/content/dependencies/generateFlashActGalleryPage.js
+++ b/src/content/dependencies/generateFlashActGalleryPage.js
@@ -1,5 +1,3 @@
-import {stitchArrays} from '#sugar';
-
 import striptags from 'striptags';
 
 export default {
@@ -37,7 +35,7 @@ export default {
 
     coverGridImages:
       act.flashes
-        .map(_flash => relation('image')),
+        .map(flash => relation('image', flash.coverArtwork)),
 
     flashLinks:
       act.flashes
@@ -50,10 +48,6 @@ export default {
 
     flashNames:
       act.flashes.map(flash => flash.name),
-
-    flashCoverPaths:
-      act.flashes.map(flash =>
-        ['media.flashArt', flash.directory, flash.coverArtFileExtension])
   }),
 
   generate: (data, relations, {language}) =>
@@ -71,15 +65,9 @@ export default {
         mainContent: [
           relations.coverGrid.slots({
             links: relations.flashLinks,
+            images: relations.coverGridImages,
             names: data.flashNames,
             lazy: 6,
-
-            images:
-              stitchArrays({
-                image: relations.coverGridImages,
-                path: data.flashCoverPaths,
-              }).map(({image, path}) =>
-                  image.slot('path', path)),
           }),
         ],
 
diff --git a/src/content/dependencies/generateFlashArtworkColumn.js b/src/content/dependencies/generateFlashArtworkColumn.js
new file mode 100644
index 00000000..5987df9e
--- /dev/null
+++ b/src/content/dependencies/generateFlashArtworkColumn.js
@@ -0,0 +1,11 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+
+  relations: (relation, flash) => ({
+    coverArtwork:
+      relation('generateCoverArtwork', flash.coverArtwork),
+  }),
+
+  generate: (relations) =>
+    relations.coverArtwork,
+};
diff --git a/src/content/dependencies/generateFlashCoverArtwork.js b/src/content/dependencies/generateFlashCoverArtwork.js
deleted file mode 100644
index 4b0e5242..00000000
--- a/src/content/dependencies/generateFlashCoverArtwork.js
+++ /dev/null
@@ -1,41 +0,0 @@
-export default {
-  contentDependencies: ['generateCoverArtwork', 'image'],
-  extraDependencies: ['html', 'language'],
-
-  relations: (relation) => ({
-    coverArtwork:
-      relation('generateCoverArtwork'),
-
-    image:
-      relation('image'),
-  }),
-
-  data: (flash) => ({
-    path:
-      ['media.flashArt', flash.directory, flash.coverArtFileExtension],
-
-    color:
-      flash.color,
-
-    dimensions:
-      flash.coverArtDimensions,
-  }),
-
-  slots: {
-    mode: {type: 'string'},
-  },
-
-  generate: (data, relations, slots, {language}) =>
-    relations.coverArtwork.slots({
-      mode: slots.mode,
-
-      image:
-        relations.image.slots({
-          path: data.path,
-          color: data.color,
-          alt: language.$('misc.alt.flashArt'),
-        }),
-
-      dimensions: data.dimensions,
-    }),
-};
diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js
index a21bb49e..2788406c 100644
--- a/src/content/dependencies/generateFlashIndexPage.js
+++ b/src/content/dependencies/generateFlashIndexPage.js
@@ -53,7 +53,7 @@ export default {
     actCoverGridImages:
       query.flashActs
         .map(act => act.flashes
-          .map(() => relation('image'))),
+          .map(flash => relation('image', flash.coverArtwork))),
   }),
 
   data: (query) => ({
@@ -73,11 +73,6 @@ export default {
       query.flashActs
         .map(act => act.flashes
           .map(flash => flash.name)),
-
-    actCoverGridPaths:
-      query.flashActs
-        .map(act => act.flashes
-          .map(flash => ['media.flashArt', flash.directory, flash.coverArtFileExtension])),
   }),
 
   generate: (data, relations, {html, language}) =>
@@ -116,7 +111,6 @@ export default {
             coverGridImages: relations.actCoverGridImages,
             coverGridLinks: relations.actCoverGridLinks,
             coverGridNames: data.actCoverGridNames,
-            coverGridPaths: data.actCoverGridPaths,
           }).map(({
               colorStyle,
               actLink,
@@ -126,7 +120,6 @@ export default {
               coverGridImages,
               coverGridLinks,
               coverGridNames,
-              coverGridPaths,
             }, index) => [
               html.tag('h2',
                 {id: anchor},
@@ -135,15 +128,9 @@ export default {
 
               coverGrid.slots({
                 links: coverGridLinks,
+                images: coverGridImages,
                 names: coverGridNames,
                 lazy: index === 0 ? 4 : true,
-
-                images:
-                  stitchArrays({
-                    image: coverGridImages,
-                    path: coverGridPaths,
-                  }).map(({image, path}) =>
-                      image.slot('path', path)),
               }),
             ]),
         ],
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
index 350a0fc5..095e43c4 100644
--- a/src/content/dependencies/generateFlashInfoPage.js
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -7,7 +7,7 @@ export default {
     'generateContentHeading',
     'generateContributionList',
     'generateFlashActSidebar',
-    'generateFlashCoverArtwork',
+    'generateFlashArtworkColumn',
     'generateFlashNavAccent',
     'generatePageLayout',
     'generateTrackList',
@@ -47,8 +47,8 @@ export default {
       query.urls
         .map(url => relation('linkExternal', url)),
 
-    cover:
-      relation('generateFlashCoverArtwork', flash),
+    artworkColumn:
+      relation('generateFlashArtworkColumn', flash),
 
     contentHeading:
       relation('generateContentHeading'),
@@ -98,7 +98,7 @@ export default {
 
         additionalNames: relations.additionalNamesBox,
 
-        cover: relations.cover,
+        artworkColumnContent: relations.artworkColumn,
 
         mainContent: [
           html.tag('p',
diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js
index 206c495d..d51366ca 100644
--- a/src/content/dependencies/generateGroupGalleryPage.js
+++ b/src/content/dependencies/generateGroupGalleryPage.js
@@ -53,7 +53,7 @@ export default {
 
       relations.carouselImages =
         carouselAlbums
-          .map(album => relation('image', album.artTags));
+          .map(album => relation('image', album.coverArtworks[0]));
     }
 
     relations.quickDescription =
@@ -69,7 +69,7 @@ export default {
     relations.gridImages =
       albums.map(album =>
         (album.hasCoverArt
-          ? relation('image', album.artTags)
+          ? relation('image', album.coverArtworks[0])
           : relation('image')));
 
     return relations;
@@ -92,22 +92,6 @@ export default {
     data.gridDurations = albums.map(album => getTotalDuration(album.tracks));
     data.gridNumTracks = albums.map(album => album.tracks.length);
 
-    data.gridPaths =
-      albums.map(album =>
-        (album.hasCoverArt
-          ? ['media.albumCover', album.directory, album.coverArtFileExtension]
-          : null));
-
-    const carouselAlbums = filterItemsForCarousel(group.featuredAlbums);
-
-    if (!empty(group.featuredAlbums)) {
-      data.carouselPaths =
-        carouselAlbums.map(album =>
-          (album.hasCoverArt
-            ? ['media.albumCover', album.directory, album.coverArtFileExtension]
-            : null));
-    }
-
     return data;
   },
 
@@ -124,12 +108,7 @@ export default {
           relations.coverCarousel
             ?.slots({
               links: relations.carouselLinks,
-              images:
-                stitchArrays({
-                  image: relations.carouselImages,
-                  path: data.carouselPaths,
-                }).map(({image, path}) =>
-                    image.slot('path', path)),
+              images: relations.carouselImages,
             }),
 
           relations.quickDescription,
@@ -159,19 +138,19 @@ export default {
             .slots({
               links: relations.gridLinks,
               names: data.gridNames,
+
               images:
                 stitchArrays({
                   image: relations.gridImages,
-                  path: data.gridPaths,
                   name: data.gridNames,
-                }).map(({image, path, name}) =>
+                }).map(({image, name}) =>
                     image.slots({
-                      path,
                       missingSourceContent:
                         language.$('misc.coverGrid.noCoverArt', {
                           album: name,
                         }),
                     })),
+
               info:
                 stitchArrays({
                   numTracks: data.gridNumTracks,
diff --git a/src/content/dependencies/generateIntrapageDotSwitcher.js b/src/content/dependencies/generateIntrapageDotSwitcher.js
index 3f300676..1d58367d 100644
--- a/src/content/dependencies/generateIntrapageDotSwitcher.js
+++ b/src/content/dependencies/generateIntrapageDotSwitcher.js
@@ -42,6 +42,8 @@ export default {
         }).map(({title, targetID}) =>
             html.tag('a', {href: '#'},
               {'data-target-id': targetID},
+              {[html.onlyIfContent]: true},
+
               language.sanitize(title))),
     }),
 };
diff --git a/src/content/dependencies/generateLyricsEntry.js b/src/content/dependencies/generateLyricsEntry.js
new file mode 100644
index 00000000..4f9c22f1
--- /dev/null
+++ b/src/content/dependencies/generateLyricsEntry.js
@@ -0,0 +1,25 @@
+export default {
+  contentDependencies: [
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entry) => ({
+    content:
+      relation('transformContent', entry.body),
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    html.tag('div', {class: 'lyrics-entry'},
+      slots.attributes,
+
+      relations.content.slot('mode', 'lyrics')),
+};
diff --git a/src/content/dependencies/generateLyricsSection.js b/src/content/dependencies/generateLyricsSection.js
new file mode 100644
index 00000000..f6b719a9
--- /dev/null
+++ b/src/content/dependencies/generateLyricsSection.js
@@ -0,0 +1,81 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'generateContentHeading',
+    'generateIntrapageDotSwitcher',
+    'generateLyricsEntry',
+    'transformContent',
+  ],
+
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entries) => ({
+    heading:
+      relation('generateContentHeading'),
+
+    switcher:
+      relation('generateIntrapageDotSwitcher'),
+
+    entries:
+      entries
+        .map(entry => relation('generateLyricsEntry', entry)),
+
+    annotations:
+      entries
+        .map(entry => entry.annotation)
+        .map(annotation => relation('transformContent', annotation)),
+  }),
+
+  data: (entries) => ({
+    ids:
+      Array.from(
+        {length: entries.length},
+        (_, index) => 'lyrics-entry-' + index),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    language.encapsulate('releaseInfo.lyrics', capsule =>
+      html.tags([
+        relations.heading
+          .slots({
+            attributes: {id: 'lyrics'},
+            title: language.$(capsule),
+          }),
+
+        html.tag('p', {class: 'lyrics-switcher'},
+          {[html.onlyIfContent]: true},
+
+          language.$(capsule, 'switcher', {
+            [language.onlyIfOptions]: ['entries'],
+
+            entries:
+              relations.switcher.slots({
+                initialOptionIndex: 0,
+
+                titles:
+                  relations.annotations.map(annotation =>
+                    annotation.slots({
+                      mode: 'inline',
+                      textOnly: true,
+                    })),
+
+                targetIDs:
+                  data.ids,
+              }),
+          })),
+
+        stitchArrays({
+          entry: relations.entries,
+          id: data.ids,
+        }).map(({entry, id}, index) =>
+            entry.slots({
+              attributes: [
+                {id},
+
+                index >= 1 &&
+                  {style: 'display: none'},
+              ],
+            })),
+      ])),
+};
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index 8a073624..0acf401c 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -1,5 +1,5 @@
 import {openAggregate} from '#aggregate';
-import {empty, repeat} from '#sugar';
+import {atOffset, empty, repeat} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -93,7 +93,7 @@ export default {
       mutable: false,
     },
 
-    cover: {
+    artworkColumnContent: {
       type: 'html',
       mutable: false,
     },
@@ -262,6 +262,17 @@ export default {
         ? data.canonicalBase + pagePathStringFromRoot
         : null);
 
+    const firstItemInArtworkColumn =
+      html.smooth(slots.artworkColumnContent)
+        .content[0];
+
+    const primaryCover =
+      (firstItemInArtworkColumn &&
+       html.resolve(firstItemInArtworkColumn, {normalize: 'tag'})
+         .attributes.has('class', 'cover-artwork')
+        ? firstItemInArtworkColumn
+        : null);
+
     const titleContentsHTML =
       (html.isBlank(slots.title)
         ? null
@@ -279,7 +290,7 @@ export default {
         ? [
             relations.stickyHeadingContainer.slots({
               title: titleContentsHTML,
-              cover: slots.cover,
+              cover: primaryCover,
             }),
 
             relations.stickyHeadingContainer.clone().slots({
@@ -316,9 +327,11 @@ export default {
         [
           titleHTML,
 
-          html.tag('div', {id: 'cover-art-container'},
+          html.tag('div', {id: 'artwork-column'},
             {[html.onlyIfContent]: true},
-            slots.cover),
+            {class: 'isolate-tooltip-z-indexing'},
+
+            slots.artworkColumnContent),
 
           subtitleHTML,
 
@@ -361,7 +374,7 @@ export default {
 
             slots.navLinks
               ?.filter(Boolean)
-              ?.map((cur, i) => {
+              ?.map((cur, i, entries) => {
                 let content;
 
                 if (cur.html) {
@@ -395,25 +408,42 @@ export default {
                   (slots.navLinkStyle === 'hierarchical' &&
                     i === slots.navLinks.length - 1);
 
-                return (
-                  html.metatag('blockwrap',
-                    html.tag('span', {class: 'nav-link'},
-                      showAsCurrent &&
-                        {class: 'current'},
-
-                      [
-                        html.tag('span', {class: 'nav-link-content'},
-                          content),
-
-                        html.tag('span', {class: 'nav-link-accent'},
-                          {[html.noEdgeWhitespace]: true},
-                          {[html.onlyIfContent]: true},
-
-                          language.$('misc.navAccent', {
-                            [language.onlyIfOptions]: ['links'],
-                            links: cur.accent,
-                          })),
-                      ])));
+                const navLink =
+                  html.tag('span', {class: 'nav-link'},
+                    showAsCurrent &&
+                      {class: 'current'},
+
+                    [
+                      html.tag('span', {class: 'nav-link-content'},
+                        content),
+
+                      html.tag('span', {class: 'nav-link-accent'},
+                        {[html.noEdgeWhitespace]: true},
+                        {[html.onlyIfContent]: true},
+
+                        language.$('misc.navAccent', {
+                          [language.onlyIfOptions]: ['links'],
+                          links: cur.accent,
+                        })),
+                    ]);
+
+                if (slots.navLinkStyle === 'index') {
+                  return navLink;
+                }
+
+                const prev =
+                  atOffset(entries, i, -1);
+
+                if (
+                  prev &&
+                  prev.releaseRestToWrapTogether !== true &&
+                  (prev.releaseRestToWrapTogether === false ||
+                   prev.auto === 'home')
+                ) {
+                  return navLink;
+                } else {
+                  return html.metatag('blockwrap', navLink);
+                }
               })),
 
           html.tag('div', {class: 'nav-bottom-row'},
@@ -553,6 +583,11 @@ export default {
           `    background-image: url("${to('media.path', 'bg.jpg')}");\n` +
           `}`);
 
+    const goshFrigginDarnitStyleRule =
+      `.image-media-link::after {\n` +
+      `    mask-image: url("${to('staticMisc.path', 'image.svg')}");\n` +
+      `}`;
+
     const numWallpaperParts =
       html.resolve(slots.styleRules, {normalize: 'string'})
         .match(/\.wallpaper-part:nth-child/g)
@@ -703,6 +738,7 @@ export default {
                 .slot('color', slots.color ?? data.wikiColor),
 
               fallbackBackgroundStyleRule,
+              goshFrigginDarnitStyleRule,
               slots.styleRules,
             ]),
 
diff --git a/src/content/dependencies/generateReferencedArtworksPage.js b/src/content/dependencies/generateReferencedArtworksPage.js
index 3d21b15d..154b4762 100644
--- a/src/content/dependencies/generateReferencedArtworksPage.js
+++ b/src/content/dependencies/generateReferencedArtworksPage.js
@@ -1,67 +1,55 @@
-import {stitchArrays} from '#sugar';
-
 export default {
   contentDependencies: [
+    'generateCoverArtwork',
     'generateCoverGrid',
     'generatePageLayout',
     'image',
-    'linkAlbum',
-    'linkTrack',
+    'linkAnythingMan',
   ],
 
   extraDependencies: ['html', 'language'],
 
-  relations: (relation, referencedArtworks) => ({
+  relations: (relation, artwork) => ({
     layout:
       relation('generatePageLayout'),
 
+    cover:
+      relation('generateCoverArtwork', artwork),
+
     coverGrid:
       relation('generateCoverGrid'),
 
     links:
-      referencedArtworks.map(({thing}) =>
-        (thing.album
-          ? relation('linkTrack', thing)
-          : relation('linkAlbum', thing))),
+      artwork.referencedArtworks.map(({artwork}) =>
+        relation('linkAnythingMan', artwork.thing)),
 
     images:
-      referencedArtworks.map(({thing}) =>
-        relation('image', thing.artTags)),
+      artwork.referencedArtworks.map(({artwork}) =>
+        relation('image', artwork)),
   }),
 
-  data: (referencedArtworks) => ({
+  data: (artwork) => ({
+    color:
+      artwork.thing.color,
+
     count:
-      referencedArtworks.length,
+      artwork.referencedArtworks.length,
 
     names:
-      referencedArtworks
-        .map(({thing}) => thing.name),
-
-    paths:
-      referencedArtworks
-        .map(({thing}) =>
-          (thing.album
-            ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
-            : ['media.albumCover', thing.directory, thing.coverArtFileExtension])),
-
-    dimensions:
-      referencedArtworks
-        .map(({thing}) => thing.coverArtDimensions),
+      artwork.referencedArtworks
+        .map(({artwork}) => artwork.thing.name),
 
     coverArtistNames:
-      referencedArtworks
-        .map(({thing}) =>
-          thing.coverArtistContribs
+      artwork.referencedArtworks
+        .map(({artwork}) =>
+          artwork.artistContribs
             .map(contrib => contrib.artist.name)),
   }),
 
   slots: {
-    color: {validate: v => v.isColor},
-
     styleRules: {type: 'html', mutable: false},
 
     title: {type: 'html', mutable: false},
-    cover: {type: 'html', mutable: true},
 
     navLinks: {validate: v => v.isArray},
     navBottomRowContent: {type: 'html', mutable: false},
@@ -73,11 +61,13 @@ export default {
         title: slots.title,
         subtitle: language.$(pageCapsule, 'subtitle'),
 
-        color: slots.color,
+        color: data.color,
         styleRules: slots.styleRules,
 
-        cover:
-          slots.cover.slot('details', 'artists'),
+        artworkColumnContent:
+          relations.cover.slots({
+            showArtistDetails: true,
+          }),
 
         mainClasses: ['top-index'],
         mainContent: [
@@ -91,19 +81,9 @@ export default {
 
           relations.coverGrid.slots({
             links: relations.links,
+            images: relations.images,
             names: data.names,
 
-            images:
-              stitchArrays({
-                image: relations.images,
-                path: data.paths,
-                dimensions: data.dimensions,
-              }).map(({image, path, dimensions}) =>
-                  image.slots({
-                    path,
-                    dimensions,
-                  })),
-
             info:
               data.coverArtistNames.map(names =>
                 language.$('misc.coverGrid.details.coverArtists', {
diff --git a/src/content/dependencies/generateReferencingArtworksPage.js b/src/content/dependencies/generateReferencingArtworksPage.js
index 2fe2e93d..55977b37 100644
--- a/src/content/dependencies/generateReferencingArtworksPage.js
+++ b/src/content/dependencies/generateReferencingArtworksPage.js
@@ -1,67 +1,55 @@
-import {stitchArrays} from '#sugar';
-
 export default {
   contentDependencies: [
+    'generateCoverArtwork',
     'generateCoverGrid',
     'generatePageLayout',
     'image',
-    'linkAlbum',
-    'linkTrack',
+    'linkAnythingMan',
   ],
 
   extraDependencies: ['html', 'language'],
 
-  relations: (relation, referencingArtworks) => ({
+  relations: (relation, artwork) => ({
     layout:
       relation('generatePageLayout'),
 
+    cover:
+      relation('generateCoverArtwork', artwork),
+
     coverGrid:
       relation('generateCoverGrid'),
 
     links:
-      referencingArtworks.map(({thing}) =>
-        (thing.album
-          ? relation('linkTrack', thing)
-          : relation('linkAlbum', thing))),
+      artwork.referencedByArtworks.map(({artwork}) =>
+        relation('linkAnythingMan', artwork.thing)),
 
     images:
-      referencingArtworks.map(({thing}) =>
-        relation('image', thing.artTags)),
+      artwork.referencedByArtworks.map(({artwork}) =>
+        relation('image', artwork)),
   }),
 
-  data: (referencingArtworks) => ({
+  data: (artwork) => ({
+    color:
+      artwork.thing.color,
+
     count:
-      referencingArtworks.length,
+      artwork.referencedByArtworks.length,
 
     names:
-      referencingArtworks
-        .map(({thing}) => thing.name),
-
-    paths:
-      referencingArtworks
-        .map(({thing}) =>
-          (thing.album
-            ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension]
-            : ['media.albumCover', thing.directory, thing.coverArtFileExtension])),
-
-    dimensions:
-      referencingArtworks
-        .map(({thing}) => thing.coverArtDimensions),
+      artwork.referencedByArtworks
+        .map(({artwork}) => artwork.thing.name),
 
     coverArtistNames:
-      referencingArtworks
-        .map(({thing}) =>
-          thing.coverArtistContribs
+      artwork.referencedByArtworks
+        .map(({artwork}) =>
+          artwork.artistContribs
             .map(contrib => contrib.artist.name)),
   }),
 
   slots: {
-    color: {validate: v => v.isColor},
-
     styleRules: {type: 'html', mutable: false},
 
     title: {type: 'html', mutable: false},
-    cover: {type: 'html', mutable: true},
 
     navLinks: {validate: v => v.isArray},
     navBottomRowContent: {type: 'html', mutable: false},
@@ -73,11 +61,13 @@ export default {
         title: slots.title,
         subtitle: language.$(pageCapsule, 'subtitle'),
 
-        color: slots.color,
+        color: data.color,
         styleRules: slots.styleRules,
 
-        cover:
-          slots.cover.slot('details', 'artists'),
+        artworkColumnContent:
+          relations.cover.slots({
+            showArtistDetails: true,
+          }),
 
         mainClasses: ['top-index'],
         mainContent: [
@@ -91,19 +81,9 @@ export default {
 
           relations.coverGrid.slots({
             links: relations.links,
+            images: relations.images,
             names: data.names,
 
-            images:
-              stitchArrays({
-                image: relations.images,
-                path: data.paths,
-                dimensions: data.dimensions,
-              }).map(({image, path, dimensions}) =>
-                  image.slots({
-                    path,
-                    dimensions,
-                  })),
-
             info:
               data.coverArtistNames.map(names =>
                 language.$('misc.coverGrid.details.coverArtists', {
diff --git a/src/content/dependencies/generateTrackArtworkColumn.js b/src/content/dependencies/generateTrackArtworkColumn.js
new file mode 100644
index 00000000..f06d735b
--- /dev/null
+++ b/src/content/dependencies/generateTrackArtworkColumn.js
@@ -0,0 +1,33 @@
+export default {
+  contentDependencies: ['generateCoverArtwork'],
+  extraDependencies: ['html'],
+
+  relations: (relation, track) => ({
+    albumCover:
+      (!track.hasUniqueCoverArt && track.album.hasCoverArt
+        ? relation('generateCoverArtwork', track.album.coverArtworks[0])
+        : null),
+
+    trackCovers:
+      (track.hasUniqueCoverArt
+        ? track.trackArtworks.map(artwork =>
+            relation('generateCoverArtwork', artwork))
+        : []),
+  }),
+
+  generate: (relations, {html}) =>
+    html.tags([
+      relations.albumCover?.slots({
+        showOriginDetails: true,
+        showArtTagDetails: true,
+        showReferenceDetails: true,
+      }),
+
+      relations.trackCovers.map(cover =>
+        cover.slots({
+          showOriginDetails: true,
+          showArtTagDetails: true,
+          showReferenceDetails: true,
+        })),
+    ]),
+};
diff --git a/src/content/dependencies/generateTrackCoverArtwork.js b/src/content/dependencies/generateTrackCoverArtwork.js
deleted file mode 100644
index 9153e2fc..00000000
--- a/src/content/dependencies/generateTrackCoverArtwork.js
+++ /dev/null
@@ -1,143 +0,0 @@
-export default {
-  contentDependencies: [
-    'generateCoverArtwork',
-    'generateCoverArtworkArtTagDetails',
-    'generateCoverArtworkArtistDetails',
-    'generateCoverArtworkReferenceDetails',
-    'image',
-    'linkAlbum',
-    'linkTrackReferencedArtworks',
-    'linkTrackReferencingArtworks',
-  ],
-
-  extraDependencies: ['html', 'language'],
-
-  query: (track) => ({
-    artTags:
-      (track.hasUniqueCoverArt
-        ? track.artTags
-        : track.album.artTags),
-
-    coverArtistContribs:
-      (track.hasUniqueCoverArt
-        ? track.coverArtistContribs
-        : track.album.coverArtistContribs),
-  }),
-
-  relations: (relation, query, track) => ({
-    coverArtwork:
-      relation('generateCoverArtwork'),
-
-    image:
-      relation('image'),
-
-    artTagDetails:
-      relation('generateCoverArtworkArtTagDetails',
-        query.artTags),
-
-    artistDetails:
-      relation('generateCoverArtworkArtistDetails',
-        query.coverArtistContribs),
-
-    referenceDetails:
-      relation('generateCoverArtworkReferenceDetails',
-        track.referencedArtworks,
-        track.referencedByArtworks),
-
-    referencedArtworksLink:
-      relation('linkTrackReferencedArtworks', track),
-
-    referencingArtworksLink:
-      relation('linkTrackReferencingArtworks', track),
-
-    albumLink:
-      relation('linkAlbum', track.album),
-  }),
-
-  data: (query, track) => ({
-    path:
-      (track.hasUniqueCoverArt
-        ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension]
-        : ['media.albumCover', track.album.directory, track.album.coverArtFileExtension]),
-
-    color:
-      track.color,
-
-    dimensions:
-      (track.hasUniqueCoverArt
-        ? track.coverArtDimensions
-        : track.album.coverArtDimensions),
-
-    nonUnique:
-      !track.hasUniqueCoverArt,
-
-    warnings:
-      query.artTags
-        .filter(tag => tag.isContentWarning)
-        .map(tag => tag.name),
-  }),
-
-  slots: {
-    mode: {type: 'string'},
-
-    details: {
-      validate: v => v.is('tags', 'artists'),
-      default: 'tags',
-    },
-
-    showReferenceLinks: {
-      type: 'boolean',
-      default: false,
-    },
-
-    showNonUniqueLine: {
-      type: 'boolean',
-      default: false,
-    },
-  },
-
-  generate: (data, relations, slots, {html, language}) =>
-    relations.coverArtwork.slots({
-      mode: slots.mode,
-
-      image:
-        relations.image.slots({
-          path: data.path,
-          color: data.color,
-          alt: language.$('misc.alt.trackCover'),
-        }),
-
-      dimensions: data.dimensions,
-      warnings: data.warnings,
-
-      details: [
-        slots.details === 'tags' &&
-          relations.artTagDetails,
-
-        slots.details === 'artists'&&
-          relations.artistDetails,
-
-        slots.showReferenceLinks &&
-          relations.referenceDetails.slots({
-            referencedLink:
-              relations.referencedArtworksLink,
-
-            referencingLink:
-              relations.referencingArtworksLink,
-          }),
-
-        slots.showNonUniqueLine &&
-        data.nonUnique &&
-          html.tag('p', {class: 'image-details'},
-            {class: 'non-unique-details'},
-
-            language.$('misc.trackArtFromAlbum', {
-              album:
-                relations.albumLink.slots({
-                  color: false,
-                }),
-            })),
-      ],
-    }),
-};
-
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 1c349c2e..11d179ad 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -9,9 +9,10 @@ export default {
     'generateCommentaryEntry',
     'generateContentHeading',
     'generateContributionList',
+    'generateLyricsSection',
     'generatePageLayout',
     'generateTrackArtistCommentarySection',
-    'generateTrackCoverArtwork',
+    'generateTrackArtworkColumn',
     'generateTrackInfoPageFeaturedByFlashesList',
     'generateTrackInfoPageOtherReleasesList',
     'generateTrackList',
@@ -58,10 +59,8 @@ export default {
     additionalNamesBox:
       relation('generateAdditionalNamesBox', track.additionalNames),
 
-    cover:
-      (track.hasUniqueCoverArt || track.album.hasCoverArt
-        ? relation('generateTrackCoverArtwork', track)
-        : null),
+    artworkColumn:
+      relation('generateTrackArtworkColumn', track),
 
     contentHeading:
       relation('generateContentHeading'),
@@ -92,8 +91,8 @@ export default {
     flashesThatFeatureList:
       relation('generateTrackInfoPageFeaturedByFlashesList', track),
 
-    lyrics:
-      relation('transformContent', track.lyrics),
+    lyricsSection:
+      relation('generateLyricsSection', track.lyrics),
 
     sheetMusicFilesList:
       relation('generateAlbumAdditionalFilesList',
@@ -141,13 +140,8 @@ export default {
         color: data.color,
         styleRules: [relations.albumStyleRules],
 
-        cover:
-          (relations.cover
-            ? relations.cover.slots({
-                showReferenceLinks: true,
-                showNonUniqueLine: true,
-              })
-            : null),
+        artworkColumnContent:
+          relations.artworkColumn,
 
         mainContent: [
           relations.releaseInfo,
@@ -315,17 +309,7 @@ export default {
             relations.flashesThatFeatureList,
           ]),
 
-          html.tags([
-            relations.contentHeading.clone()
-              .slots({
-                attributes: {id: 'lyrics'},
-                title: language.$('releaseInfo.lyrics'),
-              }),
-
-            html.tag('blockquote',
-              {[html.onlyIfContent]: true},
-              relations.lyrics.slot('mode', 'lyrics')),
-          ]),
+          relations.lyricsSection,
 
           html.tags([
             relations.contentHeading.clone()
diff --git a/src/content/dependencies/generateTrackNavLinks.js b/src/content/dependencies/generateTrackNavLinks.js
index e01653f0..6a8b7c64 100644
--- a/src/content/dependencies/generateTrackNavLinks.js
+++ b/src/content/dependencies/generateTrackNavLinks.js
@@ -15,7 +15,7 @@ export default {
       track.album.hasTrackNumbers,
 
     trackNumber:
-      track.album.tracks.indexOf(track) + 1,
+      track.trackNumber,
   }),
 
   slots: {
diff --git a/src/content/dependencies/generateTrackReferencedArtworksPage.js b/src/content/dependencies/generateTrackReferencedArtworksPage.js
index ac81e525..93438c5b 100644
--- a/src/content/dependencies/generateTrackReferencedArtworksPage.js
+++ b/src/content/dependencies/generateTrackReferencedArtworksPage.js
@@ -3,7 +3,6 @@ export default {
     'generateAlbumStyleRules',
     'generateBackToTrackLink',
     'generateReferencedArtworksPage',
-    'generateTrackCoverArtwork',
     'generateTrackNavLinks',
   ],
 
@@ -11,7 +10,7 @@ export default {
 
   relations: (relation, track) => ({
     page:
-      relation('generateReferencedArtworksPage', track.referencedArtworks),
+      relation('generateReferencedArtworksPage', track.trackArtworks[0]),
 
     albumStyleRules:
       relation('generateAlbumStyleRules', track.album, track),
@@ -21,17 +20,11 @@ export default {
 
     backToTrackLink:
       relation('generateBackToTrackLink', track),
-
-    cover:
-      relation('generateTrackCoverArtwork', track),
   }),
 
   data: (track) => ({
     name:
       track.name,
-
-    color:
-      track.color,
   }),
 
   generate: (data, relations, {html, language}) =>
@@ -42,11 +35,8 @@ export default {
             data.name,
         }),
 
-      color: data.color,
       styleRules: [relations.albumStyleRules],
 
-      cover: relations.cover,
-
       navLinks:
         html.resolve(
           relations.navLinks
diff --git a/src/content/dependencies/generateTrackReferencingArtworksPage.js b/src/content/dependencies/generateTrackReferencingArtworksPage.js
index 097ee929..e9818bad 100644
--- a/src/content/dependencies/generateTrackReferencingArtworksPage.js
+++ b/src/content/dependencies/generateTrackReferencingArtworksPage.js
@@ -3,7 +3,6 @@ export default {
     'generateAlbumStyleRules',
     'generateBackToTrackLink',
     'generateReferencingArtworksPage',
-    'generateTrackCoverArtwork',
     'generateTrackNavLinks',
   ],
 
@@ -11,7 +10,7 @@ export default {
 
   relations: (relation, track) => ({
     page:
-      relation('generateReferencingArtworksPage', track.referencedByArtworks),
+      relation('generateReferencingArtworksPage', track.trackArtworks[0]),
 
     albumStyleRules:
       relation('generateAlbumStyleRules', track.album, track),
@@ -21,17 +20,11 @@ export default {
 
     backToTrackLink:
       relation('generateBackToTrackLink', track),
-
-    cover:
-      relation('generateTrackCoverArtwork', track),
   }),
 
   data: (track) => ({
     name:
       track.name,
-
-    color:
-      track.color,
   }),
 
   generate: (data, relations, {html, language}) =>
@@ -42,11 +35,8 @@ export default {
             data.name,
         }),
 
-      color: data.color,
       styleRules: [relations.albumStyleRules],
 
-      cover: relations.cover,
-
       navLinks:
         html.resolve(
           relations.navLinks
diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js
index 38b8383f..54e462c7 100644
--- a/src/content/dependencies/generateTrackReleaseInfo.js
+++ b/src/content/dependencies/generateTrackReleaseInfo.js
@@ -14,11 +14,6 @@ export default {
     relations.artistContributionLinks =
       relation('generateReleaseInfoContributionsLine', track.artistContribs);
 
-    if (track.hasUniqueCoverArt) {
-      relations.coverArtistContributionsLine =
-        relation('generateReleaseInfoContributionsLine', track.coverArtistContribs);
-    }
-
     if (!empty(track.urls)) {
       relations.externalLinks =
         track.urls.map(url =>
@@ -37,7 +32,6 @@ export default {
 
     if (
       track.hasUniqueCoverArt &&
-      track.coverArtDate &&
       +track.coverArtDate !== +track.date
     ) {
       data.coverArtDate = track.coverArtDate;
@@ -60,21 +54,11 @@ export default {
               chronologyKind: 'track',
             }),
 
-            relations.coverArtistContributionsLine?.slots({
-              stringKey: capsule + '.coverArtBy',
-              chronologyKind: 'trackArt',
-            }),
-
             language.$(capsule, 'released', {
               [language.onlyIfOptions]: ['date'],
               date: language.formatDate(data.date),
             }),
 
-            language.$(capsule, 'artReleased', {
-              [language.onlyIfOptions]: ['date'],
-              date: language.formatDate(data.coverArtDate),
-            }),
-
             language.$(capsule, 'duration', {
               [language.onlyIfOptions]: ['duration'],
               duration: language.formatDuration(data.duration),
diff --git a/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js b/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js
index 3068d951..b45bfc19 100644
--- a/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js
+++ b/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js
@@ -1,5 +1,3 @@
-import {stitchArrays} from '#sugar';
-
 export default {
   contentDependencies: ['generateCoverCarousel', 'image', 'linkAlbum'],
 
@@ -13,27 +11,12 @@ export default {
 
     images:
       row.albums
-        .map(album => relation('image', album.artTags)),
-  }),
-
-  data: (row) => ({
-    paths:
-      row.albums.map(album =>
-        (album.hasCoverArt
-          ? ['media.albumCover', album.directory, album.coverArtFileExtension]
-          : null)),
+        .map(album => relation('image', album.coverArtworks[0])),
   }),
 
-  generate: (data, relations) =>
+  generate: (relations) =>
     relations.coverCarousel.slots({
-      links:
-        relations.links,
-
-      images:
-        stitchArrays({
-          image: relations.images,
-          path: data.paths,
-        }).map(({image, path}) =>
-            image.slot('path', path)),
+      links: relations.links,
+      images: relations.images,
     }),
 };
diff --git a/src/content/dependencies/generateWikiHomepageAlbumGridRow.js b/src/content/dependencies/generateWikiHomepageAlbumGridRow.js
index c1d2c79d..a00136ba 100644
--- a/src/content/dependencies/generateWikiHomepageAlbumGridRow.js
+++ b/src/content/dependencies/generateWikiHomepageAlbumGridRow.js
@@ -45,20 +45,17 @@ export default {
 
     images:
       sprawl.albums
-        .map(album => relation('image', album.artTags)),
+        .map(album =>
+          relation('image',
+            (album.hasCoverArt
+              ? album.coverArtworks[0]
+              : null))),
   }),
 
   data: (sprawl, _row) => ({
     names:
       sprawl.albums
         .map(album => album.name),
-
-    paths:
-      sprawl.albums
-        .map(album =>
-          (album.hasCoverArt
-            ? ['media.albumCover', album.directory, album.coverArtFileExtension]
-            : null)),
   }),
 
   generate: (data, relations, {language}) =>
@@ -69,11 +66,9 @@ export default {
       images:
         stitchArrays({
           image: relations.images,
-          path: data.paths,
           name: data.names,
-        }).map(({image, path, name}) =>
+        }).map(({image, name}) =>
             image.slots({
-              path,
               missingSourceContent:
                 language.$('misc.coverGrid.noCoverArt', {
                   album: name,
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
index bc268ec1..bf47b14f 100644
--- a/src/content/dependencies/image.js
+++ b/src/content/dependencies/image.js
@@ -16,68 +16,77 @@ export default {
 
   contentDependencies: ['generateColorStyleAttribute'],
 
-  relations: (relation) => ({
+  relations: (relation, _artwork) => ({
     colorStyle:
       relation('generateColorStyleAttribute'),
   }),
 
-  data(artTags) {
-    const data = {};
-
-    if (artTags) {
-      data.contentWarnings =
-        artTags
-          .filter(artTag => artTag.isContentWarning)
-          .map(artTag => artTag.name);
-    } else {
-      data.contentWarnings = null;
-    }
-
-    return data;
-  },
+  data: (artwork) => ({
+    path:
+      (artwork
+        ? artwork.path
+        : null),
+
+    warnings:
+      (artwork
+        ? artwork.artTags
+            .filter(artTag => artTag.isContentWarning)
+            .map(artTag => artTag.name)
+        : null),
+
+    dimensions:
+      (artwork
+        ? artwork.dimensions
+        : null),
+  }),
 
   slots: {
-    src: {type: 'string'},
-
-    path: {
-      validate: v => v.validateArrayItems(v.isString),
-    },
-
     thumb: {type: 'string'},
 
+    reveal: {type: 'boolean', default: true},
+    lazy: {type: 'boolean', default: false},
+    square: {type: 'boolean', default: false},
+
     link: {
       validate: v => v.anyOf(v.isBoolean, v.isString),
       default: false,
     },
 
-    color: {
-      validate: v => v.isColor,
-    },
+    color: {validate: v => v.isColor},
 
-    warnings: {
-      validate: v => v.looseArrayOf(v.isString),
+    // Added to the .image-container.
+    attributes: {
+      type: 'attributes',
+      mutable: false,
     },
 
-    reveal: {type: 'boolean', default: true},
-    lazy: {type: 'boolean', default: false},
-
-    square: {type: 'boolean', default: false},
+    // Added to the <img> itself.
+    alt: {type: 'string'},
 
-    dimensions: {
-      validate: v => v.isDimensions,
-    },
+    // Specify 'src' or 'path', or the path will be used from the artwork.
+    // If none of the above is present, the message in missingSourceContent
+    // will be displayed instead.
 
-    alt: {type: 'string'},
+    src: {type: 'string'},
 
-    attributes: {
-      type: 'attributes',
-      mutable: false,
+    path: {
+      validate: v => v.validateArrayItems(v.isString),
     },
 
     missingSourceContent: {
       type: 'html',
       mutable: false,
     },
+
+    // These will also be used from the artwork if not specified as slots.
+
+    warnings: {
+      validate: v => v.looseArrayOf(v.isString),
+    },
+
+    dimensions: {
+      validate: v => v.isDimensions,
+    },
   },
 
   generate(data, relations, slots, {
@@ -91,15 +100,14 @@ export default {
     missingImagePaths,
     to,
   }) {
-    let originalSrc;
-
-    if (slots.src) {
-      originalSrc = slots.src;
-    } else if (!empty(slots.path)) {
-      originalSrc = to(...slots.path);
-    } else {
-      originalSrc = '';
-    }
+    const originalSrc =
+      (slots.src
+        ? slots.src
+     : slots.path
+        ? to(...slots.path)
+     : data.path
+        ? to(...data.path)
+        : '');
 
     // TODO: This feels janky. It's necessary to deal with static content that
     // includes strings like <img src="media/misc/foo.png">, but processing the
@@ -121,29 +129,27 @@ export default {
       !isMissingImageFile &&
       (typeof slots.link === 'string' || slots.link);
 
-    const contentWarnings =
-      slots.warnings ??
-      data.contentWarnings;
+    const warnings = slots.warnings ?? data.warnings;
+    const dimensions = slots.dimensions ?? data.dimensions;
 
     const willReveal =
       slots.reveal &&
       originalSrc &&
       !isMissingImageFile &&
-      !empty(contentWarnings);
-
-    const willSquare =
-      slots.square;
+      !empty(warnings);
 
     const imgAttributes = html.attributes([
       {class: 'image'},
 
       slots.alt && {alt: slots.alt},
 
-      slots.dimensions?.[0] &&
-        {width: slots.dimensions[0]},
+      dimensions &&
+      dimensions[0] &&
+        {width: dimensions[0]},
 
-      slots.dimensions?.[1] &&
-        {height: slots.dimensions[1]},
+      dimensions &&
+      dimensions[1] &&
+        {height: dimensions[1]},
     ]);
 
     const isPlaceholder =
@@ -169,7 +175,7 @@ export default {
 
         html.tag('span', {class: 'reveal-warnings'},
           language.$('misc.contentWarnings.warnings', {
-            warnings: language.formatUnitList(contentWarnings),
+            warnings: language.formatUnitList(warnings),
           })),
 
         html.tag('br'),
@@ -323,14 +329,14 @@ export default {
 
       wrapped =
         html.tag('div', {class: 'image-outer-area'},
-          willSquare &&
+          slots.square &&
             {class: 'square-content'},
 
           wrapped);
 
       wrapped =
         html.tag('div', {class: 'image-container'},
-          willSquare &&
+          slots.square &&
             {class: 'square'},
 
           typeof slots.link === 'string' &&
diff --git a/src/content/dependencies/linkAnythingMan.js b/src/content/dependencies/linkAnythingMan.js
index d4697403..e408c1b2 100644
--- a/src/content/dependencies/linkAnythingMan.js
+++ b/src/content/dependencies/linkAnythingMan.js
@@ -1,6 +1,7 @@
 export default {
   contentDependencies: [
     'linkAlbum',
+    'linkArtwork',
     'linkFlash',
     'linkTrack',
   ],
@@ -13,6 +14,8 @@ export default {
     link:
       (query.referenceType === 'album'
         ? relation('linkAlbum', thing)
+     : query.referenceType === 'artwork'
+        ? relation('linkArtwork', thing)
      : query.referenceType === 'flash'
         ? relation('linkFlash', thing)
      : query.referenceType === 'track'
diff --git a/src/content/dependencies/linkArtwork.js b/src/content/dependencies/linkArtwork.js
new file mode 100644
index 00000000..8cd6f359
--- /dev/null
+++ b/src/content/dependencies/linkArtwork.js
@@ -0,0 +1,20 @@
+export default {
+  contentDependencies: ['linkAlbum', 'linkTrack'],
+
+  query: (artwork) => ({
+    referenceType:
+      artwork.thing.constructor[Symbol.for('Thing.referenceType')],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbum', artwork.thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrack', artwork.thing)
+        : null),
+  }),
+
+  generate: (relations) =>
+    relations.link,
+};
diff --git a/src/content/dependencies/linkReferencedArtworks.js b/src/content/dependencies/linkReferencedArtworks.js
new file mode 100644
index 00000000..c456b808
--- /dev/null
+++ b/src/content/dependencies/linkReferencedArtworks.js
@@ -0,0 +1,24 @@
+import Thing from '#thing';
+
+export default {
+  contentDependencies: [
+    'linkAlbumReferencedArtworks',
+    'linkTrackReferencedArtworks',
+  ],
+
+  query: (artwork) => ({
+    referenceType:
+      artwork.thing.constructor[Thing.referenceType],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbumReferencedArtworks', artwork.thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrackReferencedArtworks', artwork.thing)
+        : null),
+  }),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkReferencingArtworks.js b/src/content/dependencies/linkReferencingArtworks.js
new file mode 100644
index 00000000..0cfca4db
--- /dev/null
+++ b/src/content/dependencies/linkReferencingArtworks.js
@@ -0,0 +1,24 @@
+import Thing from '#thing';
+
+export default {
+  contentDependencies: [
+    'linkAlbumReferencingArtworks',
+    'linkTrackReferencingArtworks',
+  ],
+
+  query: (artwork) => ({
+    referenceType:
+      artwork.thing.constructor[Thing.referenceType],
+  }),
+
+  relations: (relation, query, artwork) => ({
+    link:
+      (query.referenceType === 'album'
+        ? relation('linkAlbumReferencingArtworks', artwork.thing)
+     : query.referenceType === 'track'
+        ? relation('linkTrackReferencingArtworks', artwork.thing)
+        : null),
+  }),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/listArtTagNetwork.js b/src/content/dependencies/listArtTagNetwork.js
index 5386dcdc..93dd4ce8 100644
--- a/src/content/dependencies/listArtTagNetwork.js
+++ b/src/content/dependencies/listArtTagNetwork.js
@@ -29,21 +29,21 @@ export default {
 
     const getStats = (artTag) => ({
       directUses:
-        artTag.directlyTaggedInThings.length,
+        artTag.directlyFeaturedInArtworks.length,
 
       // Not currently displayed
       directAndIndirectUses:
         unique([
-          ...artTag.indirectlyTaggedInThings,
-          ...artTag.directlyTaggedInThings,
+          ...artTag.indirectlyFeaturedInArtworks,
+          ...artTag.directlyFeaturedInArtworks,
         ]).length,
 
       totalUses:
         [
-          ...artTag.directlyTaggedInThings,
+          ...artTag.directlyFeaturedInArtworks,
           ...
             artTag.allDescendantArtTags
-              .flatMap(artTag => artTag.directlyTaggedInThings),
+              .flatMap(artTag => artTag.directlyFeaturedInArtworks),
         ].length,
 
       descendants:
diff --git a/src/content/dependencies/listArtTagsByName.js b/src/content/dependencies/listArtTagsByName.js
index 31856478..1df9dfff 100644
--- a/src/content/dependencies/listArtTagsByName.js
+++ b/src/content/dependencies/listArtTagsByName.js
@@ -35,8 +35,8 @@ export default {
       counts:
         query.artTags.map(artTag =>
           unique([
-            ...artTag.indirectlyTaggedInThings,
-            ...artTag.directlyTaggedInThings,
+            ...artTag.indirectlyFeaturedInArtworks,
+            ...artTag.directlyFeaturedInArtworks,
           ]).length),
     };
   },
diff --git a/src/content/dependencies/listArtTagsByUses.js b/src/content/dependencies/listArtTagsByUses.js
index fcd324f7..eca7f1c6 100644
--- a/src/content/dependencies/listArtTagsByUses.js
+++ b/src/content/dependencies/listArtTagsByUses.js
@@ -17,8 +17,8 @@ export default {
     const counts =
       artTags.map(artTag =>
         unique([
-          ...artTag.directlyTaggedInThings,
-          ...artTag.indirectlyTaggedInThings,
+          ...artTag.directlyFeaturedInArtworks,
+          ...artTag.indirectlyFeaturedInArtworks,
         ]).length);
 
     filterByCount(artTags, counts);
diff --git a/src/content/dependencies/listArtistsByGroup.js b/src/content/dependencies/listArtistsByGroup.js
index 0bf9dd2d..17096cfc 100644
--- a/src/content/dependencies/listArtistsByGroup.js
+++ b/src/content/dependencies/listArtistsByGroup.js
@@ -37,20 +37,25 @@ export default {
         ([
           (unique(
             ([
-              artist.albumArtistContributions,
-              artist.albumCoverArtistContributions,
-              artist.albumWallpaperArtistContributions,
-              artist.albumBannerArtistContributions,
+              artist.albumArtistContributions
+                .map(contrib => contrib.thing),
+              artist.albumCoverArtistContributions
+                .map(contrib => contrib.thing.thing),
+              artist.albumWallpaperArtistContributions
+                .map(contrib => contrib.thing.thing),
+              artist.albumBannerArtistContributions
+                .map(contrib => contrib.thing.thing),
             ]).flat()
-              .map(({thing}) => thing)
           )).map(album => album.groups),
           (unique(
             ([
-              artist.trackArtistContributions,
-              artist.trackContributorContributions,
-              artist.trackCoverArtistContributions,
+              artist.trackArtistContributions
+                .map(contrib => contrib.thing),
+              artist.trackContributorContributions
+                .map(contrib => contrib.thing),
+              artist.trackCoverArtistContributions
+                .map(contrib => contrib.thing.thing),
             ]).flat()
-              .map(({thing}) => thing)
           )).map(track => track.album.groups),
         ]).flat()
           .map(groups => groups
diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js
index 27a2faa3..2a8d1b4c 100644
--- a/src/content/dependencies/listArtistsByLatestContribution.js
+++ b/src/content/dependencies/listArtistsByLatestContribution.js
@@ -98,13 +98,16 @@ export default {
       ])) {
         // Might combine later with 'track' of the same album and date.
         considerDate(artist, album.coverArtDate ?? album.date, album, 'artwork');
+        // '?? album.date' is kept here because wallpaper and banner may
+        // technically be present for an album w/o cover art, therefore
+        // also no cover art date.
       }
     }
 
     for (const track of tracksLatestFirst) {
       for (const artist of getArtists(track, 'coverArtistContribs')) {
         // No special effect if artist already has 'artwork' for the same album and date.
-        considerDate(artist, track.coverArtDate ?? track.date, track.album, 'artwork');
+        considerDate(artist, track.coverArtDate, track.album, 'artwork');
       }
 
       for (const artist of new Set([
diff --git a/src/content/dependencies/listTracksWithLyrics.js b/src/content/dependencies/listTracksWithLyrics.js
index a13a76f0..e6ab9d7d 100644
--- a/src/content/dependencies/listTracksWithLyrics.js
+++ b/src/content/dependencies/listTracksWithLyrics.js
@@ -2,7 +2,7 @@ export default {
   contentDependencies: ['listTracksWithExtra'],
 
   relations: (relation, spec) =>
-    ({page: relation('listTracksWithExtra', spec, 'lyrics', 'truthy')}),
+    ({page: relation('listTracksWithExtra', spec, 'lyrics', 'array')}),
 
   generate: (relations) =>
     relations.page,
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index f56a1da9..1bbd45e2 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -2,6 +2,7 @@ import {bindFind} from '#find';
 import {replacerSpec, parseInput} from '#replacer';
 
 import {Marked} from 'marked';
+import striptags from 'striptags';
 
 const commonMarkedOptions = {
   headerIds: false,
@@ -184,6 +185,8 @@ export default {
               link: relation(name, arg),
               label: node.data.label,
               hash: node.data.hash,
+              name: arg?.name,
+              shortName: arg?.shortName ?? arg?.nameShort,
             }
           : getPlaceholder(node, content));
 
@@ -241,6 +244,11 @@ export default {
       default: true,
     },
 
+    textOnly: {
+      type: 'boolean',
+      default: false,
+    },
+
     thumb: {
       validate: v => v.is('small', 'medium', 'large'),
       default: 'large',
@@ -452,7 +460,17 @@ export default {
                 nodeFromRelations.link,
                 {slots: ['content', 'hash']});
 
-            const {label, hash} = nodeFromRelations;
+            const {label, hash, shortName, name} = nodeFromRelations;
+
+            if (slots.textOnly) {
+              if (label) {
+                return {type: 'text', data: label};
+              } else if (slots.preferShortLinkNames) {
+                return {type: 'text', data: shortName ?? name};
+              } else {
+                return {type: 'text', data: name};
+              }
+            }
 
             // These are removed from the typical combined slots({})-style
             // because we don't want to override slots that were already set
@@ -506,6 +524,10 @@ export default {
             const {label} = node.data;
             const externalLink = relations.externalLinks[externalLinkIndex++];
 
+            if (slots.textOnly) {
+              return {type: 'text', data: label};
+            }
+
             externalLink.setSlots({
               content: label,
               fromContent: true,
@@ -542,12 +564,19 @@ export default {
                 ? valueFn(replacerValue)
                 : replacerValue);
 
-            const contents =
+            const content =
               (htmlFn
                 ? htmlFn(value, {html, language})
                 : value);
 
-            return {type: 'text', data: contents.toString()};
+            const contentText =
+              html.resolve(content, {normalize: 'string'});
+
+            if (slots.textOnly) {
+              return {type: 'text', data: striptags(contentText)};
+            } else {
+              return {type: 'text', data: contentText};
+            }
           }
 
           default:
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js
index 087f7825..a089e325 100644
--- a/src/data/cacheable-object.js
+++ b/src/data/cacheable-object.js
@@ -14,7 +14,7 @@ export default class CacheableObject {
   static cacheValid = Symbol.for('CacheableObject.cacheValid');
   static updateValue = Symbol.for('CacheableObject.updateValues');
 
-  constructor() {
+  constructor({seal = true} = {}) {
     this[CacheableObject.updateValue] = Object.create(null);
     this[CacheableObject.cachedValue] = Object.create(null);
     this[CacheableObject.cacheValid] = Object.create(null);
@@ -34,6 +34,10 @@ export default class CacheableObject {
         this[property] = null;
       }
     }
+
+    if (seal) {
+      Object.seal(this);
+    }
   }
 
   static finalizeCacheableObjectPrototype() {
diff --git a/src/data/checks.js b/src/data/checks.js
index 10261e4f..52024144 100644
--- a/src/data/checks.js
+++ b/src/data/checks.js
@@ -9,7 +9,6 @@ import {compareArrays, cut, cutStart, empty, getNestedProp, iterateMultiline}
   from '#sugar';
 import Thing from '#thing';
 import thingConstructors from '#things';
-import {combineWikiDataArrays, commentaryRegexCaseSensitive} from '#wiki-data';
 
 import {
   annotateErrorWithIndex,
@@ -50,7 +49,7 @@ export function reportDirectoryErrors(wikiData, {
     if (!thingData) continue;
 
     for (const thing of thingData) {
-      if (findSpec.include && !findSpec.include(thing)) {
+      if (findSpec.include && !findSpec.include(thing, thingConstructors)) {
         continue;
       }
 
@@ -185,7 +184,8 @@ export function filterReferenceErrors(wikiData, {
       groups: 'group',
       artTags: '_artTag',
       referencedArtworks: '_artwork',
-      commentary: '_commentary',
+      commentary: '_content',
+      creditSources: '_content',
     }],
 
     ['artTagData', {
@@ -193,7 +193,8 @@ export function filterReferenceErrors(wikiData, {
     }],
 
     ['flashData', {
-      commentary: '_commentary',
+      commentary: '_content',
+      creditSources: '_content',
     }],
 
     ['groupCategoryData', {
@@ -233,7 +234,9 @@ export function filterReferenceErrors(wikiData, {
       artTags: '_artTag',
       referencedArtworks: '_artwork',
       mainReleaseTrack: '_trackMainReleasesOnly',
-      commentary: '_commentary',
+      commentary: '_content',
+      creditSources: '_content',
+      lyrics: '_content',
     }],
 
     ['wikiInfo', {
@@ -268,12 +271,12 @@ export function filterReferenceErrors(wikiData, {
             let writeProperty = true;
 
             switch (findFnKey) {
-              case '_commentary':
+              case '_content':
                 if (value) {
                   value =
-                    Array.from(value.matchAll(commentaryRegexCaseSensitive))
-                      .map(({groups}) => groups.artistReferences)
-                      .map(text => text.split(',').map(text => text.trim()));
+                    value.map(entry =>
+                      CacheableObject.getUpdateValue(entry, 'artists') ??
+                      []);
                 }
 
                 writeProperty = false;
@@ -313,15 +316,12 @@ export function filterReferenceErrors(wikiData, {
               case '_artwork': {
                 const mixed =
                   find.mixed({
-                    album: find.albumWithArtwork,
-                    track: find.trackWithArtwork,
+                    album: find.albumPrimaryArtwork,
+                    track: find.trackPrimaryArtwork,
                   });
 
                 const data =
-                  combineWikiDataArrays([
-                    wikiData.albumData,
-                    wikiData.trackData,
-                  ]);
+                  wikiData.artworkData;
 
                 findFn = ref => mixed(ref.reference, data, {mode: 'error'});
 
@@ -332,7 +332,7 @@ export function filterReferenceErrors(wikiData, {
                 findFn = boundFind.artTag;
                 break;
 
-              case '_commentary':
+              case '_content':
                 findFn = findArtistOrAlias;
                 break;
 
@@ -464,7 +464,7 @@ export function filterReferenceErrors(wikiData, {
                 }
               }
 
-              if (findFnKey === '_commentary') {
+              if (findFnKey === '_content') {
                 filter(
                   value, {message: errorMessage},
                   decorateErrorWithIndex(refs =>
@@ -571,6 +571,12 @@ export function reportContentTextErrors(wikiData, {
     annotation: 'commentary annotation',
   };
 
+  const lyricsShape = {
+    body: 'lyrics body',
+    artistDisplayText: 'lyrics artist display text',
+    annotation: 'lyrics annotation',
+  };
+
   const contentTextSpec = [
     ['albumData', {
       additionalFiles: additionalFileShape,
@@ -617,7 +623,7 @@ export function reportContentTextErrors(wikiData, {
       additionalFiles: additionalFileShape,
       commentary: commentaryShape,
       creditSources: commentaryShape,
-      lyrics: '_content',
+      lyrics: lyricsShape,
       midiProjectFiles: additionalFileShape,
       sheetMusicFiles: additionalFileShape,
     }],
@@ -740,8 +746,8 @@ export function reportContentTextErrors(wikiData, {
         for (const thing of things) {
           nest({message: `Content text errors in ${inspect(thing)}`}, ({nest, push}) => {
 
-            for (const [property, shape] of Object.entries(propSpec)) {
-              const value = thing[property];
+            for (let [property, shape] of Object.entries(propSpec)) {
+              let value = thing[property];
 
               if (value === undefined) {
                 push(new TypeError(`Property ${colors.red(property)} isn't valid for ${colors.green(thing.constructor.name)}`));
@@ -795,3 +801,49 @@ export function reportContentTextErrors(wikiData, {
     }
   });
 }
+
+export function reportOrphanedArtworks(wikiData) {
+  const aggregate =
+    openAggregate({message: `Artwork objects are orphaned`});
+
+  const assess = ({
+    message,
+    filterThing,
+    filterContribs,
+    link,
+  }) => {
+    aggregate.nest({message: `Orphaned ${message}`}, ({push}) => {
+      const ostensibleArtworks =
+        wikiData.artworkData
+          .filter(artwork =>
+            artwork.thing instanceof filterThing &&
+            artwork.artistContribsFromThingProperty === filterContribs);
+
+      const orphanedArtworks =
+        ostensibleArtworks
+          .filter(artwork => !artwork.thing[link].includes(artwork));
+
+      for (const artwork of orphanedArtworks) {
+        push(new Error(`Orphaned: ${inspect(artwork)}`));
+      }
+    });
+  };
+
+  const {Album, Track} = thingConstructors;
+
+  assess({
+    message: `album cover artworks`,
+    filterThing: Album,
+    filterContribs: 'coverArtistContribs',
+    link: 'coverArtworks',
+  });
+
+  assess({
+    message: `track artworks`,
+    filterThing: Track,
+    filterContribs: 'coverArtistContribs',
+    link: 'trackArtworks',
+  });
+
+  aggregate.close();
+}
diff --git a/src/data/composite/data/withPropertyFromList.js b/src/data/composite/data/withPropertyFromList.js
index 65ebf77b..760095c2 100644
--- a/src/data/composite/data/withPropertyFromList.js
+++ b/src/data/composite/data/withPropertyFromList.js
@@ -5,11 +5,15 @@
 // original list are kept null here. Objects which don't have the specified
 // property are retained in-place as null.
 //
+// If the `internal` input is true, this reads the CacheableObject update value
+// of each object rather than its exposed value.
+//
 // See also:
 //  - withPropertiesFromList
 //  - withPropertyFromObject
 //
 
+import CacheableObject from '#cacheable-object';
 import {input, templateCompositeFrom} from '#composite';
 
 function getOutputName({list, property, prefix}) {
@@ -26,6 +30,7 @@ export default templateCompositeFrom({
     list: input({type: 'array'}),
     property: input({type: 'string'}),
     prefix: input.staticValue({type: 'string', defaultValue: null}),
+    internal: input({type: 'boolean', defaultValue: false}),
   },
 
   outputs: ({
@@ -37,13 +42,26 @@ export default templateCompositeFrom({
 
   steps: () => [
     {
-      dependencies: [input('list'), input('property')],
+      dependencies: [
+        input('list'),
+        input('property'),
+        input('internal'),
+      ],
+
       compute: (continuation, {
         [input('list')]: list,
         [input('property')]: property,
+        [input('internal')]: internal,
       }) => continuation({
         ['#values']:
-          list.map(item => item[property] ?? null),
+          list.map(item =>
+            (item === null
+              ? null
+           : internal
+              ? CacheableObject.getUpdateValue(item, property)
+                  ?? null
+              : item[property]
+                  ?? null)),
       }),
     },
 
diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js
index 8b5098f0..dfc6864f 100644
--- a/src/data/composite/things/album/index.js
+++ b/src/data/composite/things/album/index.js
@@ -1 +1,2 @@
+export {default as withHasCoverArt} from './withHasCoverArt.js';
 export {default as withTracks} from './withTracks.js';
diff --git a/src/data/composite/things/album/withHasCoverArt.js b/src/data/composite/things/album/withHasCoverArt.js
new file mode 100644
index 00000000..fd3f2894
--- /dev/null
+++ b/src/data/composite/things/album/withHasCoverArt.js
@@ -0,0 +1,64 @@
+// TODO: This shouldn't be coded as an Album-specific thing,
+// or even really to do with cover artworks in particular, either.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
+  from '#composite/control-flow';
+import {fillMissingListItems, withFlattenedList, withPropertyFromList}
+  from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: 'withHasCoverArt',
+
+  outputs: ['#hasCoverArt'],
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: 'coverArtistContribs',
+      mode: input.value('empty'),
+    }),
+
+    {
+      dependencies: ['#availability'],
+      compute: (continuation, {
+        ['#availability']: availability,
+      }) =>
+        (availability
+          ? continuation.raiseOutput({
+              ['#hasCoverArt']: true,
+            })
+          : continuation()),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: 'coverArtworks',
+      mode: input.value('empty'),
+      output: input.value({'#hasCoverArt': false}),
+    }),
+
+    withPropertyFromList({
+      list: 'coverArtworks',
+      property: input.value('artistContribs'),
+      internal: input.value(true),
+    }),
+
+    // Since we're getting the update value for each artwork's artistContribs,
+    // it may not be set at all, and in that case won't be exposing as [].
+    fillMissingListItems({
+      list: '#coverArtworks.artistContribs',
+      fill: input.value([]),
+    }),
+
+    withFlattenedList({
+      list: '#coverArtworks.artistContribs',
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#flattenedList',
+      mode: input.value('empty'),
+    }).outputs({
+      '#availability': '#hasCoverArt',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/artwork/index.js b/src/data/composite/things/artwork/index.js
new file mode 100644
index 00000000..b92bff72
--- /dev/null
+++ b/src/data/composite/things/artwork/index.js
@@ -0,0 +1 @@
+export {default as withDate} from './withDate.js';
diff --git a/src/data/composite/things/artwork/withDate.js b/src/data/composite/things/artwork/withDate.js
new file mode 100644
index 00000000..5e05b814
--- /dev/null
+++ b/src/data/composite/things/artwork/withDate.js
@@ -0,0 +1,41 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withDate`,
+
+  inputs: {
+    from: input({
+      defaultDependency: 'date',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#date'],
+
+  steps: () => [
+    {
+      dependencies: [input('from')],
+      compute: (continuation, {
+        [input('from')]: date,
+      }) =>
+        (date
+          ? continuation.raiseOutput({'#date': date})
+          : continuation()),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: 'dateFromThingProperty',
+      output: input.value({'#date': null}),
+    }),
+
+    withPropertyFromObject({
+      object: 'thing',
+      property: 'dateFromThingProperty',
+    }).outputs({
+      ['#value']: '#date',
+    }),
+  ],
+})
diff --git a/src/data/composite/things/commentary-entry/index.js b/src/data/composite/things/commentary-entry/index.js
new file mode 100644
index 00000000..091bae1a
--- /dev/null
+++ b/src/data/composite/things/commentary-entry/index.js
@@ -0,0 +1 @@
+export {default as withWebArchiveDate} from './withWebArchiveDate.js';
diff --git a/src/data/composite/things/commentary-entry/withWebArchiveDate.js b/src/data/composite/things/commentary-entry/withWebArchiveDate.js
new file mode 100644
index 00000000..3aaa4f64
--- /dev/null
+++ b/src/data/composite/things/commentary-entry/withWebArchiveDate.js
@@ -0,0 +1,41 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `withWebArchiveDate`,
+
+  outputs: ['#webArchiveDate'],
+
+  steps: () => [
+    {
+      dependencies: ['annotation'],
+
+      compute: (continuation, {annotation}) =>
+        continuation({
+          ['#dateText']:
+            annotation
+              ?.match(/https?:\/\/web.archive.org\/web\/([0-9]{8,8})[0-9]*\//)
+              ?.[1] ??
+            null,
+        }),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: '#dateText',
+      output: input.value({['#webArchiveDate']: null}),
+    }),
+
+    {
+      dependencies: ['#dateText'],
+      compute: (continuation, {['#dateText']: dateText}) =>
+        continuation({
+          ['#webArchiveDate']:
+            new Date(
+              dateText.slice(0, 4) + '/' +
+              dateText.slice(4, 6) + '/' +
+              dateText.slice(6, 8)),
+        }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/contribution/thingPropertyMatches.js b/src/data/composite/things/contribution/thingPropertyMatches.js
index 4a37f2cf..1e9019b8 100644
--- a/src/data/composite/things/contribution/thingPropertyMatches.js
+++ b/src/data/composite/things/contribution/thingPropertyMatches.js
@@ -1,6 +1,7 @@
 import {input, templateCompositeFrom} from '#composite';
 
 import {exitWithoutDependency} from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
 
 export default templateCompositeFrom({
   annotation: `thingPropertyMatches`,
@@ -12,19 +13,31 @@ export default templateCompositeFrom({
   },
 
   steps: () => [
+    {
+      dependencies: ['thing', 'thingProperty'],
+
+      compute: (continuation, {thing, thingProperty}) =>
+        continuation({
+          ['#thingProperty']:
+            (thing.constructor[Symbol.for('Thing.referenceType')] === 'artwork'
+              ? thing.artistContribsFromThingProperty
+              : thingProperty),
+        }),
+    },
+
     exitWithoutDependency({
-      dependency: 'thingProperty',
+      dependency: '#thingProperty',
       value: input.value(false),
     }),
 
     {
       dependencies: [
-        'thingProperty',
+        '#thingProperty',
         input('value'),
       ],
 
       compute: ({
-        ['thingProperty']: thingProperty,
+        ['#thingProperty']: thingProperty,
         [input('value')]: value,
       }) =>
         thingProperty === value,
diff --git a/src/data/composite/things/contribution/thingReferenceTypeMatches.js b/src/data/composite/things/contribution/thingReferenceTypeMatches.js
index 2ee811af..4042e78f 100644
--- a/src/data/composite/things/contribution/thingReferenceTypeMatches.js
+++ b/src/data/composite/things/contribution/thingReferenceTypeMatches.js
@@ -29,10 +29,37 @@ export default templateCompositeFrom({
         input('value'),
       ],
 
-      compute: ({
+      compute: (continuation, {
         ['#thing.constructor']: constructor,
         [input('value')]: value,
       }) =>
+        (constructor[Symbol.for('Thing.referenceType')] === value
+          ? continuation.exit(true)
+       : constructor[Symbol.for('Thing.referenceType')] === 'artwork'
+          ? continuation()
+          : continuation.exit(false)),
+    },
+
+    withPropertyFromObject({
+      object: 'thing',
+      property: input.value('thing'),
+    }),
+
+    withPropertyFromObject({
+      object: '#thing.thing',
+      property: input.value('constructor'),
+    }),
+
+    {
+      dependencies: [
+        '#thing.thing.constructor',
+        input('value'),
+      ],
+
+      compute: ({
+        ['#thing.thing.constructor']: constructor,
+        [input('value')]: value,
+      }) =>
         constructor[Symbol.for('Thing.referenceType')] === value,
     },
   ],
diff --git a/src/data/composite/things/track-section/index.js b/src/data/composite/things/track-section/index.js
index 3202ed49..f11a2ab5 100644
--- a/src/data/composite/things/track-section/index.js
+++ b/src/data/composite/things/track-section/index.js
@@ -1 +1,3 @@
 export {default as withAlbum} from './withAlbum.js';
+export {default as withContinueCountingFrom} from './withContinueCountingFrom.js';
+export {default as withStartCountingFrom} from './withStartCountingFrom.js';
diff --git a/src/data/composite/things/track-section/withContinueCountingFrom.js b/src/data/composite/things/track-section/withContinueCountingFrom.js
new file mode 100644
index 00000000..e034b7a5
--- /dev/null
+++ b/src/data/composite/things/track-section/withContinueCountingFrom.js
@@ -0,0 +1,25 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import withStartCountingFrom from './withStartCountingFrom.js';
+
+export default templateCompositeFrom({
+  annotation: `withContinueCountingFrom`,
+
+  outputs: ['#continueCountingFrom'],
+
+  steps: () => [
+    withStartCountingFrom(),
+
+    {
+      dependencies: ['#startCountingFrom', 'tracks'],
+      compute: (continuation, {
+        ['#startCountingFrom']: startCountingFrom,
+        ['tracks']: tracks,
+      }) => continuation({
+        ['#continueCountingFrom']:
+          startCountingFrom +
+          tracks.length,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track-section/withStartCountingFrom.js b/src/data/composite/things/track-section/withStartCountingFrom.js
new file mode 100644
index 00000000..ef345327
--- /dev/null
+++ b/src/data/composite/things/track-section/withStartCountingFrom.js
@@ -0,0 +1,64 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withNearbyItemFromList, withPropertyFromObject} from '#composite/data';
+
+import withAlbum from './withAlbum.js';
+
+export default templateCompositeFrom({
+  annotation: `withStartCountingFrom`,
+
+  inputs: {
+    from: input({
+      type: 'number',
+      defaultDependency: 'startCountingFrom',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#startCountingFrom'],
+
+  steps: () => [
+    {
+      dependencies: [input('from')],
+      compute: (continuation, {
+        [input('from')]: from,
+      }) =>
+        (from === null
+          ? continuation()
+          : continuation.raiseOutput({'#startCountingFrom': from})),
+    },
+
+    withAlbum(),
+
+    raiseOutputWithoutDependency({
+      dependency: '#album',
+      output: input.value({'#startCountingFrom': 1}),
+    }),
+
+    withPropertyFromObject({
+      object: '#album',
+      property: input.value('trackSections'),
+    }),
+
+    withNearbyItemFromList({
+      list: '#album.trackSections',
+      item: input.myself(),
+      offset: input.value(-1),
+    }).outputs({
+      '#nearbyItem': '#previousTrackSection',
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#previousTrackSection',
+      output: input.value({'#startCountingFrom': 1}),
+    }),
+
+    withPropertyFromObject({
+      object: '#previousTrackSection',
+      property: input.value('continueCountingFrom'),
+    }).outputs({
+      '#previousTrackSection.continueCountingFrom': '#startCountingFrom',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js
index beb8c6ab..e789e736 100644
--- a/src/data/composite/things/track/index.js
+++ b/src/data/composite/things/track/index.js
@@ -1,10 +1,10 @@
 export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.js';
 export {default as inheritContributionListFromMainRelease} from './inheritContributionListFromMainRelease.js';
 export {default as inheritFromMainRelease} from './inheritFromMainRelease.js';
-export {default as withAlbum} from './withAlbum.js';
 export {default as withAllReleases} from './withAllReleases.js';
 export {default as withAlwaysReferenceByDirectory} from './withAlwaysReferenceByDirectory.js';
 export {default as withContainingTrackSection} from './withContainingTrackSection.js';
+export {default as withCoverArtistContribs} from './withCoverArtistContribs.js';
 export {default as withDate} from './withDate.js';
 export {default as withDirectorySuffix} from './withDirectorySuffix.js';
 export {default as withHasUniqueCoverArt} from './withHasUniqueCoverArt.js';
@@ -14,3 +14,4 @@ export {default as withPropertyFromAlbum} from './withPropertyFromAlbum.js';
 export {default as withPropertyFromMainRelease} from './withPropertyFromMainRelease.js';
 export {default as withSuffixDirectoryFromAlbum} from './withSuffixDirectoryFromAlbum.js';
 export {default as withTrackArtDate} from './withTrackArtDate.js';
+export {default as withTrackNumber} from './withTrackNumber.js';
diff --git a/src/data/composite/things/track/withAlbum.js b/src/data/composite/things/track/withAlbum.js
deleted file mode 100644
index 4c55e1f4..00000000
--- a/src/data/composite/things/track/withAlbum.js
+++ /dev/null
@@ -1,22 +0,0 @@
-// Gets the track's album. This will early exit if albumData is missing.
-// If there's no album whose list of tracks includes this track, the output
-// dependency will be null.
-
-import {templateCompositeFrom} from '#composite';
-
-import {withUniqueReferencingThing} from '#composite/wiki-data';
-import {soupyReverse} from '#composite/wiki-properties';
-
-export default templateCompositeFrom({
-  annotation: `withAlbum`,
-
-  outputs: ['#album'],
-
-  steps: () => [
-    withUniqueReferencingThing({
-      reverse: soupyReverse.input('albumsWhoseTracksInclude'),
-    }).outputs({
-      ['#uniqueReferencingThing']: '#album',
-    }),
-  ],
-});
diff --git a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
index aebcf793..60faeaf4 100644
--- a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
+++ b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
@@ -17,6 +17,8 @@ import {
   exposeUpdateValueOrContinue,
 } from '#composite/control-flow';
 
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+
 export default templateCompositeFrom({
   annotation: `withAlwaysReferenceByDirectory`,
 
@@ -27,18 +29,7 @@ export default templateCompositeFrom({
       validate: input.value(isBoolean),
     }),
 
-    // withAlwaysReferenceByDirectory is sort of a fragile area - we can't
-    // find the track's album the normal way because albums' track lists
-    // recurse back into alwaysReferenceByDirectory!
-    withResolvedReference({
-      ref: 'dataSourceAlbum',
-      find: soupyFind.input('album'),
-    }).outputs({
-      '#resolvedReference': '#album',
-    }),
-
-    withPropertyFromObject({
-      object: '#album',
+    withPropertyFromAlbum({
       property: input.value('alwaysReferenceTracksByDirectory'),
     }),
 
diff --git a/src/data/composite/things/track/withCoverArtistContribs.js b/src/data/composite/things/track/withCoverArtistContribs.js
new file mode 100644
index 00000000..9057cfeb
--- /dev/null
+++ b/src/data/composite/things/track/withCoverArtistContribs.js
@@ -0,0 +1,73 @@
+import {input, templateCompositeFrom} from '#composite';
+import {isContributionList} from '#validators';
+
+import {exposeDependencyOrContinue} from '#composite/control-flow';
+
+import {
+  withRecontextualizedContributionList,
+  withRedatedContributionList,
+  withResolvedContribs,
+} from '#composite/wiki-data';
+
+import exitWithoutUniqueCoverArt from './exitWithoutUniqueCoverArt.js';
+import withPropertyFromAlbum from './withPropertyFromAlbum.js';
+import withTrackArtDate from './withTrackArtDate.js';
+
+export default templateCompositeFrom({
+  annotation: `withCoverArtistContribs`,
+
+  inputs: {
+    from: input({
+      defaultDependency: 'coverArtistContribs',
+      validate: isContributionList,
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#coverArtistContribs'],
+
+  steps: () => [
+    exitWithoutUniqueCoverArt({
+      value: input.value([]),
+    }),
+
+    withTrackArtDate(),
+
+    withResolvedContribs({
+      from: input('from'),
+      thingProperty: input.value('coverArtistContribs'),
+      artistProperty: input.value('trackCoverArtistContributions'),
+      date: '#trackArtDate',
+    }).outputs({
+      '#resolvedContribs': '#coverArtistContribs',
+    }),
+
+    exposeDependencyOrContinue({
+      dependency: '#coverArtistContribs',
+      mode: input.value('empty'),
+    }),
+
+    withPropertyFromAlbum({
+      property: input.value('trackCoverArtistContribs'),
+    }),
+
+    withRecontextualizedContributionList({
+      list: '#album.trackCoverArtistContribs',
+      artistProperty: input.value('trackCoverArtistContributions'),
+    }),
+
+    withRedatedContributionList({
+      list: '#album.trackCoverArtistContribs',
+      date: '#trackArtDate',
+    }),
+
+    {
+      dependencies: ['#album.trackCoverArtistContribs'],
+      compute: (continuation, {
+        ['#album.trackCoverArtistContribs']: coverArtistContribs,
+      }) => continuation({
+        ['#coverArtistContribs']: coverArtistContribs,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/withHasUniqueCoverArt.js b/src/data/composite/things/track/withHasUniqueCoverArt.js
index f7e65f25..85d3b92a 100644
--- a/src/data/composite/things/track/withHasUniqueCoverArt.js
+++ b/src/data/composite/things/track/withHasUniqueCoverArt.js
@@ -5,11 +5,18 @@
 // or a placeholder. (This property is named hasUniqueCoverArt instead of
 // the usual hasCoverArt to emphasize that it does not inherit from the
 // album.)
+//
+// withHasUniqueCoverArt is based only around the presence of *specified*
+// cover artist contributions, not whether the references to artists on those
+// contributions actually resolve to anything. It completely evades interacting
+// with find/replace.
 
 import {input, templateCompositeFrom} from '#composite';
-import {empty} from '#sugar';
 
-import {withResolvedContribs} from '#composite/wiki-data';
+import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
+  from '#composite/control-flow';
+import {fillMissingListItems, withFlattenedList, withPropertyFromList}
+  from '#composite/data';
 
 import withPropertyFromAlbum from './withPropertyFromAlbum.js';
 
@@ -29,36 +36,73 @@ export default templateCompositeFrom({
           : continuation()),
     },
 
-    withResolvedContribs({
+    withResultOfAvailabilityCheck({
       from: 'coverArtistContribs',
-      date: input.value(null),
+      mode: input.value('empty'),
     }),
 
     {
-      dependencies: ['#resolvedContribs'],
+      dependencies: ['#availability'],
       compute: (continuation, {
-        ['#resolvedContribs']: contribsFromTrack,
+        ['#availability']: availability,
       }) =>
-        (empty(contribsFromTrack)
-          ? continuation()
-          : continuation.raiseOutput({
+        (availability
+          ? continuation.raiseOutput({
               ['#hasUniqueCoverArt']: true,
-            })),
+            })
+          : continuation()),
     },
 
     withPropertyFromAlbum({
       property: input.value('trackCoverArtistContribs'),
+      internal: input.value(true),
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#album.trackCoverArtistContribs',
+      mode: input.value('empty'),
     }),
 
     {
-      dependencies: ['#album.trackCoverArtistContribs'],
+      dependencies: ['#availability'],
       compute: (continuation, {
-        ['#album.trackCoverArtistContribs']: contribsFromAlbum,
+        ['#availability']: availability,
       }) =>
-        continuation.raiseOutput({
-          ['#hasUniqueCoverArt']:
-            !empty(contribsFromAlbum),
-        }),
+        (availability
+          ? continuation.raiseOutput({
+              ['#hasUniqueCoverArt']: true,
+            })
+          : continuation()),
     },
+
+    raiseOutputWithoutDependency({
+      dependency: 'trackArtworks',
+      mode: input.value('empty'),
+      output: input.value({'#hasUniqueCoverArt': false}),
+    }),
+
+    withPropertyFromList({
+      list: 'trackArtworks',
+      property: input.value('artistContribs'),
+      internal: input.value(true),
+    }),
+
+    // Since we're getting the update value for each artwork's artistContribs,
+    // it may not be set at all, and in that case won't be exposing as [].
+    fillMissingListItems({
+      list: '#trackArtworks.artistContribs',
+      fill: input.value([]),
+    }),
+
+    withFlattenedList({
+      list: '#trackArtworks.artistContribs',
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#flattenedList',
+      mode: input.value('empty'),
+    }).outputs({
+      '#availability': '#hasUniqueCoverArt',
+    }),
   ],
 });
diff --git a/src/data/composite/things/track/withPropertyFromAlbum.js b/src/data/composite/things/track/withPropertyFromAlbum.js
index e9c5b56e..a203c2e7 100644
--- a/src/data/composite/things/track/withPropertyFromAlbum.js
+++ b/src/data/composite/things/track/withPropertyFromAlbum.js
@@ -5,13 +5,12 @@ import {input, templateCompositeFrom} from '#composite';
 
 import {withPropertyFromObject} from '#composite/data';
 
-import withAlbum from './withAlbum.js';
-
 export default templateCompositeFrom({
   annotation: `withPropertyFromAlbum`,
 
   inputs: {
     property: input.staticValue({type: 'string'}),
+    internal: input({type: 'boolean', defaultValue: false}),
   },
 
   outputs: ({
@@ -19,11 +18,21 @@ export default templateCompositeFrom({
   }) => ['#album.' + property],
 
   steps: () => [
-    withAlbum(),
+    // XXX: This is a ridiculous hack considering `defaultValue` above.
+    // If we were certain what was up, we'd just get around to fixing it LOL
+    {
+      dependencies: [input('internal')],
+      compute: (continuation, {
+        [input('internal')]: internal,
+      }) => continuation({
+        ['#internal']: internal ?? false,
+      }),
+    },
 
     withPropertyFromObject({
-      object: '#album',
+      object: 'album',
       property: input('property'),
+      internal: '#internal',
     }),
 
     {
diff --git a/src/data/composite/things/track/withTrackArtDate.js b/src/data/composite/things/track/withTrackArtDate.js
index e2c4d8bc..9b7b61c7 100644
--- a/src/data/composite/things/track/withTrackArtDate.js
+++ b/src/data/composite/things/track/withTrackArtDate.js
@@ -1,11 +1,3 @@
-// Gets the date of cover art release. This represents only the track's own
-// unique cover artwork, if any.
-//
-// If the 'fallback' option is false (the default), this will only output
-// the track's own coverArtDate or its album's trackArtDate. If 'fallback'
-// is set, and neither of these is available, it'll output the track's own
-// date instead.
-
 import {input, templateCompositeFrom} from '#composite';
 import {isDate} from '#validators';
 
@@ -24,11 +16,6 @@ export default templateCompositeFrom({
       defaultDependency: 'coverArtDate',
       acceptsNull: true,
     }),
-
-    fallback: input({
-      type: 'boolean',
-      defaultValue: false,
-    }),
   },
 
   outputs: ['#trackArtDate'],
@@ -57,20 +44,13 @@ export default templateCompositeFrom({
     }),
 
     {
-      dependencies: [
-        '#album.trackArtDate',
-        input('fallback'),
-      ],
-
+      dependencies: ['#album.trackArtDate'],
       compute: (continuation, {
         ['#album.trackArtDate']: albumTrackArtDate,
-        [input('fallback')]: fallback,
       }) =>
         (albumTrackArtDate
           ? continuation.raiseOutput({'#trackArtDate': albumTrackArtDate})
-       : fallback
-          ? continuation()
-          : continuation.raiseOutput({'#trackArtDate': null})),
+          : continuation()),
     },
 
     withDate().outputs({
diff --git a/src/data/composite/things/track/withTrackNumber.js b/src/data/composite/things/track/withTrackNumber.js
new file mode 100644
index 00000000..61428e8c
--- /dev/null
+++ b/src/data/composite/things/track/withTrackNumber.js
@@ -0,0 +1,50 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withIndexInList, withPropertiesFromObject} from '#composite/data';
+
+import withContainingTrackSection from './withContainingTrackSection.js';
+
+export default templateCompositeFrom({
+  annotation: `withTrackNumber`,
+
+  outputs: ['#trackNumber'],
+
+  steps: () => [
+    withContainingTrackSection(),
+
+    // Zero is the fallback, not one, but in most albums the first track
+    // (and its intended output by this composition) will be one.
+    raiseOutputWithoutDependency({
+      dependency: '#trackSection',
+      output: input.value({'#trackNumber': 0}),
+    }),
+
+    withPropertiesFromObject({
+      object: '#trackSection',
+      properties: input.value(['tracks', 'startCountingFrom']),
+    }),
+
+    withIndexInList({
+      list: '#trackSection.tracks',
+      item: input.myself(),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#index',
+      output: input.value({'#trackNumber': 0}),
+    }),
+
+    {
+      dependencies: ['#trackSection.startCountingFrom', '#index'],
+      compute: (continuation, {
+        ['#trackSection.startCountingFrom']: startCountingFrom,
+        ['#index']: index,
+      }) => continuation({
+        ['#trackNumber']:
+          startCountingFrom +
+          index,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
index be83e4c9..005c68c0 100644
--- a/src/data/composite/wiki-data/index.js
+++ b/src/data/composite/wiki-data/index.js
@@ -12,10 +12,10 @@ export {default as inputSoupyFind} from './inputSoupyFind.js';
 export {default as inputSoupyReverse} from './inputSoupyReverse.js';
 export {default as inputWikiData} from './inputWikiData.js';
 export {default as withClonedThings} from './withClonedThings.js';
+export {default as withConstitutedArtwork} from './withConstitutedArtwork.js';
 export {default as withContributionListSums} from './withContributionListSums.js';
 export {default as withCoverArtDate} from './withCoverArtDate.js';
 export {default as withDirectory} from './withDirectory.js';
-export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js';
 export {default as withRecontextualizedContributionList} from './withRecontextualizedContributionList.js';
 export {default as withRedatedContributionList} from './withRedatedContributionList.js';
 export {default as withResolvedAnnotatedReferenceList} from './withResolvedAnnotatedReferenceList.js';
diff --git a/src/data/composite/wiki-data/withConstitutedArtwork.js b/src/data/composite/wiki-data/withConstitutedArtwork.js
new file mode 100644
index 00000000..9e260abf
--- /dev/null
+++ b/src/data/composite/wiki-data/withConstitutedArtwork.js
@@ -0,0 +1,57 @@
+import {input, templateCompositeFrom} from '#composite';
+import thingConstructors from '#things';
+import {isContributionList} from '#validators';
+
+export default templateCompositeFrom({
+  annotation: `withConstitutedArtwork`,
+
+  inputs: {
+    dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}),
+    dateFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsArtistProperty: input({type: 'string', acceptsNull: true}),
+    artTagsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}),
+  },
+
+  outputs: ['#constitutedArtwork'],
+
+  steps: () => [
+    {
+      dependencies: [
+        input.myself(),
+        input('dimensionsFromThingProperty'),
+        input('fileExtensionFromThingProperty'),
+        input('dateFromThingProperty'),
+        input('artistContribsFromThingProperty'),
+        input('artistContribsArtistProperty'),
+        input('artTagsFromThingProperty'),
+        input('referencedArtworksFromThingProperty'),
+      ],
+
+      compute: (continuation, {
+        [input.myself()]: myself,
+        [input('dimensionsFromThingProperty')]: dimensionsFromThingProperty,
+        [input('fileExtensionFromThingProperty')]: fileExtensionFromThingProperty,
+        [input('dateFromThingProperty')]: dateFromThingProperty,
+        [input('artistContribsFromThingProperty')]: artistContribsFromThingProperty,
+        [input('artistContribsArtistProperty')]: artistContribsArtistProperty,
+        [input('artTagsFromThingProperty')]: artTagsFromThingProperty,
+        [input('referencedArtworksFromThingProperty')]: referencedArtworksFromThingProperty,
+      }) => continuation({
+        ['#constitutedArtwork']:
+          Object.assign(new thingConstructors.Artwork, {
+            thing: myself,
+            dimensionsFromThingProperty,
+            fileExtensionFromThingProperty,
+            artistContribsFromThingProperty,
+            artistContribsArtistProperty,
+            artTagsFromThingProperty,
+            dateFromThingProperty,
+            referencedArtworksFromThingProperty,
+          }),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withCoverArtDate.js b/src/data/composite/wiki-data/withCoverArtDate.js
index 0c644c77..a114d5ff 100644
--- a/src/data/composite/wiki-data/withCoverArtDate.js
+++ b/src/data/composite/wiki-data/withCoverArtDate.js
@@ -1,7 +1,3 @@
-// Gets the current thing's coverArtDate, or, if the 'fallback' option is set,
-// the thing's date. This is always null if the thing doesn't actually have
-// any coverArtistContribs.
-
 import {input, templateCompositeFrom} from '#composite';
 import {isDate} from '#validators';
 
@@ -18,11 +14,6 @@ export default templateCompositeFrom({
       defaultDependency: 'coverArtDate',
       acceptsNull: true,
     }),
-
-    fallback: input({
-      type: 'boolean',
-      defaultValue: false,
-    }),
   },
 
   outputs: ['#coverArtDate'],
@@ -50,21 +41,11 @@ export default templateCompositeFrom({
     },
 
     {
-      dependencies: [input('fallback')],
-      compute: (continuation, {
-        [input('fallback')]: fallback,
-      }) =>
-        (fallback
-          ? continuation()
-          : continuation.raiseOutput({'#coverArtDate': null})),
-    },
-
-    {
       dependencies: ['date'],
       compute: (continuation, {date}) =>
         (date
-          ? continuation.raiseOutput({'#coverArtDate': date})
-          : continuation.raiseOutput({'#coverArtDate': null})),
+          ? continuation({'#coverArtDate': date})
+          : continuation({'#coverArtDate': null})),
     },
   ],
 });
diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
deleted file mode 100644
index 9bf4278c..00000000
--- a/src/data/composite/wiki-data/withParsedCommentaryEntries.js
+++ /dev/null
@@ -1,260 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-import {stitchArrays} from '#sugar';
-import {isCommentary} from '#validators';
-import {commentaryRegexCaseSensitive} from '#wiki-data';
-
-import {
-  fillMissingListItems,
-  withFlattenedList,
-  withPropertiesFromList,
-  withUnflattenedList,
-} from '#composite/data';
-
-import inputSoupyFind from './inputSoupyFind.js';
-import withResolvedReferenceList from './withResolvedReferenceList.js';
-
-export default templateCompositeFrom({
-  annotation: `withParsedCommentaryEntries`,
-
-  inputs: {
-    from: input({validate: isCommentary}),
-  },
-
-  outputs: ['#parsedCommentaryEntries'],
-
-  steps: () => [
-    {
-      dependencies: [input('from')],
-
-      compute: (continuation, {
-        [input('from')]: commentaryText,
-      }) => continuation({
-        ['#rawMatches']:
-          Array.from(commentaryText.matchAll(commentaryRegexCaseSensitive)),
-      }),
-    },
-
-    withPropertiesFromList({
-      list: '#rawMatches',
-      properties: input.value([
-        '0', // The entire match as a string.
-        'groups',
-        'index',
-      ]),
-    }).outputs({
-      '#rawMatches.0': '#rawMatches.text',
-      '#rawMatches.groups': '#rawMatches.groups',
-      '#rawMatches.index': '#rawMatches.startIndex',
-    }),
-
-    {
-      dependencies: [
-        '#rawMatches.text',
-        '#rawMatches.startIndex',
-      ],
-
-      compute: (continuation, {
-        ['#rawMatches.text']: text,
-        ['#rawMatches.startIndex']: startIndex,
-      }) => continuation({
-        ['#rawMatches.endIndex']:
-          stitchArrays({text, startIndex})
-            .map(({text, startIndex}) => startIndex + text.length),
-      }),
-    },
-
-    {
-      dependencies: [
-        input('from'),
-        '#rawMatches.startIndex',
-        '#rawMatches.endIndex',
-      ],
-
-      compute: (continuation, {
-        [input('from')]: commentaryText,
-        ['#rawMatches.startIndex']: startIndex,
-        ['#rawMatches.endIndex']: endIndex,
-      }) => continuation({
-        ['#entries.body']:
-          stitchArrays({startIndex, endIndex})
-            .map(({endIndex}, index, stitched) =>
-              (index === stitched.length - 1
-                ? commentaryText.slice(endIndex)
-                : commentaryText.slice(
-                    endIndex,
-                    stitched[index + 1].startIndex)))
-            .map(body => body.trim()),
-      }),
-    },
-
-    withPropertiesFromList({
-      list: '#rawMatches.groups',
-      prefix: input.value('#entries'),
-      properties: input.value([
-        'artistReferences',
-        'artistDisplayText',
-        'annotation',
-        'date',
-        'secondDate',
-        'dateKind',
-        'accessDate',
-        'accessKind',
-      ]),
-    }),
-
-    // The artistReferences group will always have a value, since it's required
-    // for the line to match in the first place.
-
-    {
-      dependencies: ['#entries.artistReferences'],
-      compute: (continuation, {
-        ['#entries.artistReferences']: artistReferenceTexts,
-      }) => continuation({
-        ['#entries.artistReferences']:
-          artistReferenceTexts
-            .map(text => text.split(',').map(ref => ref.trim())),
-      }),
-    },
-
-    withFlattenedList({
-      list: '#entries.artistReferences',
-    }),
-
-    withResolvedReferenceList({
-      list: '#flattenedList',
-      find: inputSoupyFind.input('artist'),
-      notFoundMode: input.value('null'),
-    }),
-
-    withUnflattenedList({
-      list: '#resolvedReferenceList',
-    }).outputs({
-      '#unflattenedList': '#entries.artists',
-    }),
-
-    fillMissingListItems({
-      list: '#entries.artistDisplayText',
-      fill: input.value(null),
-    }),
-
-    fillMissingListItems({
-      list: '#entries.annotation',
-      fill: input.value(null),
-    }),
-
-    {
-      dependencies: ['#entries.annotation'],
-      compute: (continuation, {
-        ['#entries.annotation']: annotation,
-      }) => continuation({
-        ['#entries.webArchiveDate']:
-          annotation
-            .map(text => text?.match(/https?:\/\/web.archive.org\/web\/([0-9]{8,8})[0-9]*\//))
-            .map(match => match?.[1])
-            .map(dateText =>
-              (dateText
-                ? dateText.slice(0, 4) + '/' +
-                  dateText.slice(4, 6) + '/' +
-                  dateText.slice(6, 8)
-                : null)),
-      }),
-    },
-
-    {
-      dependencies: ['#entries.date'],
-      compute: (continuation, {
-        ['#entries.date']: date,
-      }) => continuation({
-        ['#entries.date']:
-          date
-            .map(date => date ? new Date(date) : null),
-      }),
-    },
-
-    {
-      dependencies: ['#entries.secondDate'],
-      compute: (continuation, {
-        ['#entries.secondDate']: secondDate,
-      }) => continuation({
-        ['#entries.secondDate']:
-          secondDate
-            .map(date => date ? new Date(date) : null),
-      }),
-    },
-
-    fillMissingListItems({
-      list: '#entries.dateKind',
-      fill: input.value(null),
-    }),
-
-    {
-      dependencies: ['#entries.accessDate', '#entries.webArchiveDate'],
-      compute: (continuation, {
-        ['#entries.accessDate']: accessDate,
-        ['#entries.webArchiveDate']: webArchiveDate,
-      }) => continuation({
-        ['#entries.accessDate']:
-          stitchArrays({accessDate, webArchiveDate})
-            .map(({accessDate, webArchiveDate}) =>
-              accessDate ??
-              webArchiveDate ??
-              null)
-            .map(date => date ? new Date(date) : date),
-      }),
-    },
-
-    {
-      dependencies: ['#entries.accessKind', '#entries.webArchiveDate'],
-      compute: (continuation, {
-        ['#entries.accessKind']: accessKind,
-        ['#entries.webArchiveDate']: webArchiveDate,
-      }) => continuation({
-        ['#entries.accessKind']:
-          stitchArrays({accessKind, webArchiveDate})
-            .map(({accessKind, webArchiveDate}) =>
-              accessKind ??
-              (webArchiveDate && 'captured') ??
-              null),
-      }),
-    },
-
-    {
-      dependencies: [
-        '#entries.artists',
-        '#entries.artistDisplayText',
-        '#entries.annotation',
-        '#entries.date',
-        '#entries.secondDate',
-        '#entries.dateKind',
-        '#entries.accessDate',
-        '#entries.accessKind',
-        '#entries.body',
-      ],
-
-      compute: (continuation, {
-        ['#entries.artists']: artists,
-        ['#entries.artistDisplayText']: artistDisplayText,
-        ['#entries.annotation']: annotation,
-        ['#entries.date']: date,
-        ['#entries.secondDate']: secondDate,
-        ['#entries.dateKind']: dateKind,
-        ['#entries.accessDate']: accessDate,
-        ['#entries.accessKind']: accessKind,
-        ['#entries.body']: body,
-      }) => continuation({
-        ['#parsedCommentaryEntries']:
-          stitchArrays({
-            artists,
-            artistDisplayText,
-            annotation,
-            date,
-            secondDate,
-            dateKind,
-            accessDate,
-            accessKind,
-            body,
-          }),
-      }),
-    },
-  ],
-});
diff --git a/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js
index c9a7c058..9cc52f29 100644
--- a/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js
+++ b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js
@@ -1,6 +1,6 @@
 import {input, templateCompositeFrom} from '#composite';
 import {stitchArrays} from '#sugar';
-import {isDate, isObject, validateArrayItems} from '#validators';
+import {isObject, validateArrayItems} from '#validators';
 
 import {withPropertyFromList} from '#composite/data';
 
@@ -22,11 +22,6 @@ export default templateCompositeFrom({
       acceptsNull: true,
     }),
 
-    date: input({
-      validate: isDate,
-      acceptsNull: true,
-    }),
-
     reference: input({type: 'string', defaultValue: 'reference'}),
     annotation: input({type: 'string', defaultValue: 'annotation'}),
     thing: input({type: 'string', defaultValue: 'thing'}),
@@ -91,17 +86,6 @@ export default templateCompositeFrom({
       }),
     },
 
-    {
-      dependencies: ['#matches', input('date')],
-      compute: (continuation, {
-        ['#matches']: matches,
-        [input('date')]: date,
-      }) => continuation({
-        ['#matches']:
-          matches.map(match => ({...match, date})),
-      }),
-    },
-
     withAvailabilityFilter({
       from: '#resolvedReferenceList',
     }),
diff --git a/src/data/composite/wiki-properties/annotatedReferenceList.js b/src/data/composite/wiki-properties/annotatedReferenceList.js
index bb6875f1..8e6c96a1 100644
--- a/src/data/composite/wiki-properties/annotatedReferenceList.js
+++ b/src/data/composite/wiki-properties/annotatedReferenceList.js
@@ -2,7 +2,6 @@ import {input, templateCompositeFrom} from '#composite';
 
 import {
   isContentString,
-  isDate,
   optional,
   validateArrayItems,
   validateProperties,
@@ -27,11 +26,6 @@ export default templateCompositeFrom({
     data: inputWikiData({allowMixedTypes: true}),
     find: inputSoupyFind(),
 
-    date: input({
-      validate: isDate,
-      acceptsNull: true,
-    }),
-
     reference: input.staticValue({type: 'string', defaultValue: 'reference'}),
     annotation: input.staticValue({type: 'string', defaultValue: 'annotation'}),
     thing: input.staticValue({type: 'string', defaultValue: 'thing'}),
@@ -57,8 +51,6 @@ export default templateCompositeFrom({
     withResolvedAnnotatedReferenceList({
       list: input.updateValue(),
 
-      date: input('date'),
-
       reference: input('reference'),
       annotation: input('annotation'),
       thing: input('thing'),
diff --git a/src/data/composite/wiki-properties/commentary.js b/src/data/composite/wiki-properties/commentary.js
deleted file mode 100644
index 9625278d..00000000
--- a/src/data/composite/wiki-properties/commentary.js
+++ /dev/null
@@ -1,30 +0,0 @@
-// Artist commentary! Generally present on tracks and albums.
-
-import {input, templateCompositeFrom} from '#composite';
-import {isCommentary} from '#validators';
-
-import {exitWithoutDependency, exposeDependency}
-  from '#composite/control-flow';
-import {withParsedCommentaryEntries} from '#composite/wiki-data';
-
-export default templateCompositeFrom({
-  annotation: `commentary`,
-
-  compose: false,
-
-  steps: () => [
-    exitWithoutDependency({
-      dependency: input.updateValue({validate: isCommentary}),
-      mode: input.value('falsy'),
-      value: input.value([]),
-    }),
-
-    withParsedCommentaryEntries({
-      from: input.updateValue(),
-    }),
-
-    exposeDependency({
-      dependency: '#parsedCommentaryEntries',
-    }),
-  ],
-});
diff --git a/src/data/composite/wiki-properties/commentatorArtists.js b/src/data/composite/wiki-properties/commentatorArtists.js
index c5c14769..54d3e1a5 100644
--- a/src/data/composite/wiki-properties/commentatorArtists.js
+++ b/src/data/composite/wiki-properties/commentatorArtists.js
@@ -7,7 +7,6 @@ import {exitWithoutDependency, exposeDependency}
   from '#composite/control-flow';
 import {withFlattenedList, withPropertyFromList, withUniqueItemsOnly}
   from '#composite/data';
-import {withParsedCommentaryEntries} from '#composite/wiki-data';
 
 export default templateCompositeFrom({
   annotation: `commentatorArtists`,
@@ -21,19 +20,13 @@ export default templateCompositeFrom({
       value: input.value([]),
     }),
 
-    withParsedCommentaryEntries({
-      from: 'commentary',
-    }),
-
     withPropertyFromList({
-      list: '#parsedCommentaryEntries',
+      list: 'commentary',
       property: input.value('artists'),
-    }).outputs({
-      '#parsedCommentaryEntries.artists': '#artistLists',
     }),
 
     withFlattenedList({
-      list: '#artistLists',
+      list: '#commentary.artists',
     }).outputs({
       '#flattenedList': '#artists',
     }),
diff --git a/src/data/composite/wiki-properties/constitutibleArtwork.js b/src/data/composite/wiki-properties/constitutibleArtwork.js
new file mode 100644
index 00000000..0ee3bfcd
--- /dev/null
+++ b/src/data/composite/wiki-properties/constitutibleArtwork.js
@@ -0,0 +1,68 @@
+// This composition does not actually inspect the values of any properties
+// specified, so it's not responsible for determining whether a constituted
+// artwork should exist at all.
+
+import {input, templateCompositeFrom} from '#composite';
+import {withEntries} from '#sugar';
+import Thing from '#thing';
+import {validateThing} from '#validators';
+
+import {exposeDependency, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+import {withConstitutedArtwork} from '#composite/wiki-data';
+
+const template = templateCompositeFrom({
+  annotation: `constitutibleArtwork`,
+
+  compose: false,
+
+  inputs: {
+    dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}),
+    dateFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsArtistProperty: input({type: 'string', acceptsNull: true}),
+    artTagsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}),
+  },
+
+  steps: () => [
+    exposeUpdateValueOrContinue({
+      validate: input.value(
+        validateThing({
+          referenceType: 'artwork',
+        })),
+    }),
+
+    withConstitutedArtwork({
+      dimensionsFromThingProperty: input('dimensionsFromThingProperty'),
+      fileExtensionFromThingProperty: input('fileExtensionFromThingProperty'),
+      dateFromThingProperty: input('dateFromThingProperty'),
+      artistContribsFromThingProperty: input('artistContribsFromThingProperty'),
+      artistContribsArtistProperty: input('artistContribsArtistProperty'),
+      artTagsFromThingProperty: input('artTagsFromThingProperty'),
+      referencedArtworksFromThingProperty: input('referencedArtworksFromThingProperty'),
+    }),
+
+    exposeDependency({
+      dependency: '#constitutedArtwork',
+    }),
+  ],
+});
+
+template.fromYAMLFieldSpec = function(field) {
+  const {[Thing.yamlDocumentSpec]: documentSpec} = this;
+
+  const {provide} = documentSpec.fields[field].transform;
+
+  const inputs =
+    withEntries(provide, entries =>
+      entries.map(([property, value]) => [
+        property,
+        input.value(value),
+      ]));
+
+  return template(inputs);
+};
+
+export default template;
diff --git a/src/data/composite/wiki-properties/constitutibleArtworkList.js b/src/data/composite/wiki-properties/constitutibleArtworkList.js
new file mode 100644
index 00000000..246c08b5
--- /dev/null
+++ b/src/data/composite/wiki-properties/constitutibleArtworkList.js
@@ -0,0 +1,70 @@
+// This composition does not actually inspect the values of any properties
+// specified, so it's not responsible for determining whether a constituted
+// artwork should exist at all.
+
+import {input, templateCompositeFrom} from '#composite';
+import {withEntries} from '#sugar';
+import Thing from '#thing';
+import {validateWikiData} from '#validators';
+
+import {exposeUpdateValueOrContinue} from '#composite/control-flow';
+import {withConstitutedArtwork} from '#composite/wiki-data';
+
+const template = templateCompositeFrom({
+  annotation: `constitutibleArtworkList`,
+
+  compose: false,
+
+  inputs: {
+    dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}),
+    dateFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsArtistProperty: input({type: 'string', acceptsNull: true}),
+    artTagsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}),
+  },
+
+  steps: () => [
+    exposeUpdateValueOrContinue({
+      validate: input.value(
+        validateWikiData({
+          referenceType: 'artwork',
+        })),
+    }),
+
+    withConstitutedArtwork({
+      dimensionsFromThingProperty: input('dimensionsFromThingProperty'),
+      fileExtensionFromThingProperty: input('fileExtensionFromThingProperty'),
+      dateFromThingProperty: input('dateFromThingProperty'),
+      artistContribsFromThingProperty: input('artistContribsFromThingProperty'),
+      artistContribsArtistProperty: input('artistContribsArtistProperty'),
+      artTagsFromThingProperty: input('artTagsFromThingProperty'),
+      referencedArtworksFromThingProperty: input('referencedArtworksFromThingProperty'),
+    }),
+
+    {
+      dependencies: ['#constitutedArtwork'],
+      compute: ({
+        ['#constitutedArtwork']: constitutedArtwork,
+      }) => [constitutedArtwork],
+    },
+  ],
+});
+
+template.fromYAMLFieldSpec = function(field) {
+  const {[Thing.yamlDocumentSpec]: documentSpec} = this;
+
+  const {provide} = documentSpec.fields[field].transform;
+
+  const inputs =
+    withEntries(provide, entries =>
+      entries.map(([property, value]) => [
+        property,
+        input.value(value),
+      ]));
+
+  return template(inputs);
+};
+
+export default template;
diff --git a/src/data/composite/wiki-properties/directory.js b/src/data/composite/wiki-properties/directory.js
index 9ca2a204..1756a8e5 100644
--- a/src/data/composite/wiki-properties/directory.js
+++ b/src/data/composite/wiki-properties/directory.js
@@ -18,6 +18,7 @@ export default templateCompositeFrom({
     name: input({
       validate: isName,
       defaultDependency: 'name',
+      acceptsNull: true,
     }),
 
     suffix: input({
diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js
index 4aaaeb72..d5e7657e 100644
--- a/src/data/composite/wiki-properties/index.js
+++ b/src/data/composite/wiki-properties/index.js
@@ -7,8 +7,9 @@ export {default as additionalFiles} from './additionalFiles.js';
 export {default as additionalNameList} from './additionalNameList.js';
 export {default as annotatedReferenceList} from './annotatedReferenceList.js';
 export {default as color} from './color.js';
-export {default as commentary} from './commentary.js';
 export {default as commentatorArtists} from './commentatorArtists.js';
+export {default as constitutibleArtwork} from './constitutibleArtwork.js';
+export {default as constitutibleArtworkList} from './constitutibleArtworkList.js';
 export {default as contentString} from './contentString.js';
 export {default as contribsPresent} from './contribsPresent.js';
 export {default as contributionList} from './contributionList.js';
diff --git a/src/data/composite/wiki-properties/referencedArtworkList.js b/src/data/composite/wiki-properties/referencedArtworkList.js
index 819b2f43..9ba2e393 100644
--- a/src/data/composite/wiki-properties/referencedArtworkList.js
+++ b/src/data/composite/wiki-properties/referencedArtworkList.js
@@ -1,7 +1,6 @@
 import {input, templateCompositeFrom} from '#composite';
 import find from '#find';
 import {isDate} from '#validators';
-import {combineWikiDataArrays} from '#wiki-data';
 
 import annotatedReferenceList from './annotatedReferenceList.js';
 
@@ -10,47 +9,24 @@ export default templateCompositeFrom({
 
   compose: false,
 
-  inputs: {
-    date: input({
-      validate: isDate,
-      acceptsNull: true,
-    }),
-  },
-
   steps: () => [
     {
-      dependencies: [
-        'albumData',
-        'trackData',
-      ],
-
-      compute: (continuation, {
-        albumData,
-        trackData,
-      }) => continuation({
-        ['#data']:
-          combineWikiDataArrays([
-            albumData,
-            trackData,
-          ]),
-      }),
-    },
-
-    {
       compute: (continuation) => continuation({
         ['#find']:
           find.mixed({
-            track: find.trackWithArtwork,
-            album: find.albumWithArtwork,
+            track: find.trackPrimaryArtwork,
+            album: find.albumPrimaryArtwork,
           }),
       }),
     },
 
     annotatedReferenceList({
       referenceType: input.value(['album', 'track']),
-      data: '#data',
+
+      data: 'artworkData',
       find: '#find',
-      date: input('date'),
+
+      thing: input.value('artwork'),
     }),
   ],
 });
diff --git a/src/data/composite/wiki-properties/soupyReverse.js b/src/data/composite/wiki-properties/soupyReverse.js
index 269ccd6f..784a66b4 100644
--- a/src/data/composite/wiki-properties/soupyReverse.js
+++ b/src/data/composite/wiki-properties/soupyReverse.js
@@ -19,4 +19,19 @@ soupyReverse.contributionsBy =
     referenced: contrib => [contrib.artist],
   });
 
+soupyReverse.artworkContributionsBy =
+  (bindTo, artworkProperty, {single = false} = {}) => ({
+    bindTo,
+
+    referencing: thing =>
+      (single
+        ? (thing[artworkProperty]
+            ? thing[artworkProperty].artistContribs
+            : [])
+        : thing[artworkProperty]
+            .flatMap(artwork => artwork.artistContribs)),
+
+    referenced: contrib => [contrib.artist],
+  });
+
 export default soupyReverse;
diff --git a/src/data/thing.js b/src/data/thing.js
index 90453c15..66f73de5 100644
--- a/src/data/thing.js
+++ b/src/data/thing.js
@@ -25,6 +25,10 @@ export default class Thing extends CacheableObject {
   static yamlSourceDocument = Symbol.for('Thing.yamlSourceDocument');
   static yamlSourceDocumentPlacement = Symbol.for('Thing.yamlSourceDocumentPlacement');
 
+  [Symbol.for('Thing.yamlSourceFilename')] = null;
+  [Symbol.for('Thing.yamlSourceDocument')] = null;
+  [Symbol.for('Thing.yamlSourceDocumentPlacement')] = null;
+
   static isThingConstructor = Symbol.for('Thing.isThingConstructor');
   static isThing = Symbol.for('Thing.isThing');
 
@@ -33,11 +37,13 @@ export default class Thing extends CacheableObject {
   static [Symbol.for('Thing.isThingConstructor')] = NaN;
 
   constructor() {
-    super();
+    super({seal: false});
 
     // To detect:
     // Object.hasOwn(object, Symbol.for('Thing.isThing'))
     this[Symbol.for('Thing.isThing')] = NaN;
+
+    Object.seal(this);
   }
 
   static [Symbol.for('Thing.selectAll')] = _wikiData => [];
diff --git a/src/data/things/album.js b/src/data/things/album.js
index 3eb6fc60..8a25a8ac 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -3,19 +3,23 @@ export const DATA_ALBUM_DIRECTORY = 'album';
 import * as path from 'node:path';
 import {inspect} from 'node:util';
 
+import CacheableObject from '#cacheable-object';
 import {colors} from '#cli';
 import {input} from '#composite';
 import {traverse} from '#node-utils';
 import {sortAlbumsTracksChronologically, sortChronologically} from '#sort';
 import {accumulateSum, empty} from '#sugar';
 import Thing from '#thing';
-import {isColor, isDate, isDirectory} from '#validators';
+import {isColor, isDate, isDirectory, isNumber} from '#validators';
 
 import {
   parseAdditionalFiles,
   parseAdditionalNames,
   parseAnnotatedReferences,
+  parseArtwork,
+  parseCommentary,
   parseContributors,
+  parseCreditingSources,
   parseDate,
   parseDimensions,
   parseWallpaperParts,
@@ -31,9 +35,10 @@ import {exitWithoutContribs, withDirectory, withCoverArtDate}
 import {
   additionalFiles,
   additionalNameList,
-  commentary,
   color,
   commentatorArtists,
+  constitutibleArtwork,
+  constitutibleArtworkList,
   contentString,
   contribsPresent,
   contributionList,
@@ -56,14 +61,18 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
-import {withTracks} from '#composite/things/album';
-import {withAlbum} from '#composite/things/track-section';
+import {withHasCoverArt, withTracks} from '#composite/things/album';
+import {withAlbum, withContinueCountingFrom, withStartCountingFrom}
+  from '#composite/things/track-section';
 
 export class Album extends Thing {
   static [Thing.referenceType] = 'album';
 
   static [Thing.getPropertyDescriptors] = ({
     ArtTag,
+    Artwork,
+    CommentaryEntry,
+    CreditingSourcesEntry,
     Group,
     Track,
     TrackSection,
@@ -103,16 +112,10 @@ export class Album extends Thing {
     dateAddedToWiki: simpleDate(),
 
     coverArtDate: [
-      // ~~TODO: Why does this fall back, but Track.coverArtDate doesn't?~~
-      // TODO: OK so it's because tracks don't *store* dates just like that.
-      // Really instead of fallback being a flag, it should be a date value,
-      // if this option is worth existing at all.
       withCoverArtDate({
         from: input.updateValue({
           validate: isDate,
         }),
-
-        fallback: input.value(true),
       }),
 
       exposeDependency({dependency: '#coverArtDate'}),
@@ -141,7 +144,11 @@ export class Album extends Thing {
     ],
 
     wallpaperParts: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
+      exitWithoutContribs({
+        contribs: 'wallpaperArtistContribs',
+        value: input.value([]),
+      }),
+
       wallpaperParts(),
     ],
 
@@ -162,12 +169,53 @@ export class Album extends Thing {
       dimensions(),
     ],
 
+    wallpaperArtwork: [
+      exitWithoutDependency({
+        dependency: 'wallpaperArtistContribs',
+        mode: input.value('empty'),
+        value: input.value(null),
+      }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Wallpaper Artwork'),
+    ],
+
+    bannerArtwork: [
+      exitWithoutDependency({
+        dependency: 'bannerArtistContribs',
+        mode: input.value('empty'),
+        value: input.value(null),
+      }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Banner Artwork'),
+    ],
+
+    coverArtworks: [
+      withHasCoverArt(),
+
+      exitWithoutDependency({
+        dependency: '#hasCoverArt',
+        mode: input.value('falsy'),
+        value: input.value([]),
+      }),
+
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
+    ],
+
     hasTrackNumbers: flag(true),
     isListedOnHomepage: flag(true),
     isListedInGalleries: flag(true),
 
-    commentary: commentary(),
-    creditSources: commentary(),
+    commentary: thingList({
+      class: input.value(CommentaryEntry),
+    }),
+
+    creditSources: thingList({
+      class: input.value(CreditingSourcesEntry),
+    }),
+
     additionalFiles: additionalFiles(),
 
     trackSections: thingList({
@@ -180,9 +228,7 @@ export class Album extends Thing {
     }),
 
     coverArtistContribs: [
-      withCoverArtDate({
-        fallback: input.value(true),
-      }),
+      withCoverArtDate(),
 
       contributionList({
         date: '#coverArtDate',
@@ -201,9 +247,7 @@ export class Album extends Thing {
     }),
 
     wallpaperArtistContribs: [
-      withCoverArtDate({
-        fallback: input.value(true),
-      }),
+      withCoverArtDate(),
 
       contributionList({
         date: '#coverArtDate',
@@ -212,9 +256,7 @@ export class Album extends Thing {
     ],
 
     bannerArtistContribs: [
-      withCoverArtDate({
-        fallback: input.value(true),
-      }),
+      withCoverArtDate(),
 
       contributionList({
         date: '#coverArtDate',
@@ -245,20 +287,7 @@ export class Album extends Thing {
         value: input.value([]),
       }),
 
-      {
-        dependencies: ['coverArtDate', 'date'],
-        compute: (continuation, {
-          coverArtDate,
-          date,
-        }) => continuation({
-          ['#date']:
-            coverArtDate ?? date,
-        }),
-      },
-
-      referencedArtworkList({
-        date: '#date',
-      }),
+      referencedArtworkList(),
     ],
 
     // Update only
@@ -267,13 +296,8 @@ export class Album extends Thing {
     reverse: soupyReverse(),
 
     // used for referencedArtworkList (mixedFind)
-    albumData: wikiData({
-      class: input.value(Album),
-    }),
-
-    // used for referencedArtworkList (mixedFind)
-    trackData: wikiData({
-      class: input.value(Track),
+    artworkData: wikiData({
+      class: input.value(Artwork),
     }),
 
     // used for withMatchingContributionPresets (indirectly by Contribution)
@@ -285,7 +309,11 @@ export class Album extends Thing {
 
     commentatorArtists: commentatorArtists(),
 
-    hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}),
+    hasCoverArt: [
+      withHasCoverArt(),
+      exposeDependency({dependency: '#hasCoverArt'}),
+    ],
+
     hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}),
     hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}),
 
@@ -293,17 +321,6 @@ export class Album extends Thing {
       withTracks(),
       exposeDependency({dependency: '#tracks'}),
     ],
-
-    referencedByArtworks: [
-      exitWithoutContribs({
-        contribs: 'coverArtistContribs',
-        value: input.value([]),
-      }),
-
-      reverseReferenceList({
-        reverse: soupyReverse.input('artworksWhichReference'),
-      }),
-    ],
   });
 
   static [Thing.getSerializeDescriptors] = ({
@@ -379,6 +396,31 @@ export class Album extends Thing {
           ? [] 
           : [album.name]),
     },
+
+    albumPrimaryArtwork: {
+      [Thing.findThisThingOnly]: false,
+
+      referenceTypes: [
+        'album',
+        'album-referencing-artworks',
+        'album-referenced-artworks',
+      ],
+
+      bindTo: 'artworkData',
+
+      include: (artwork, {Artwork, Album}) =>
+        artwork instanceof Artwork &&
+        artwork.thing instanceof Album &&
+        artwork === artwork.thing.coverArtworks[0],
+
+      getMatchableNames: ({thing: album}) =>
+        (album.alwaysReferenceByDirectory
+          ? []
+          : [album.name]),
+
+      getMatchableDirectories: ({thing: album}) =>
+        [album.directory],
+    },
   };
 
   static [Thing.reverseSpecs] = {
@@ -414,13 +456,13 @@ export class Album extends Thing {
       soupyReverse.contributionsBy('albumData', 'artistContribs'),
 
     albumCoverArtistContributionsBy:
-      soupyReverse.contributionsBy('albumData', 'coverArtistContribs'),
+      soupyReverse.artworkContributionsBy('albumData', 'coverArtworks'),
 
     albumWallpaperArtistContributionsBy:
-      soupyReverse.contributionsBy('albumData', 'wallpaperArtistContribs'),
+      soupyReverse.artworkContributionsBy('albumData', 'wallpaperArtwork', {single: true}),
 
     albumBannerArtistContributionsBy:
-      soupyReverse.contributionsBy('albumData', 'bannerArtistContribs'),
+      soupyReverse.artworkContributionsBy('albumData', 'bannerArtwork', {single: true}),
 
     albumsWithCommentaryBy: {
       bindTo: 'albumData',
@@ -470,6 +512,46 @@ export class Album extends Thing {
       'Listed on Homepage': {property: 'isListedOnHomepage'},
       'Listed in Galleries': {property: 'isListedInGalleries'},
 
+      'Cover Artwork': {
+        property: 'coverArtworks',
+        transform:
+          parseArtwork({
+            dimensionsFromThingProperty: 'coverArtDimensions',
+            fileExtensionFromThingProperty: 'coverArtFileExtension',
+            dateFromThingProperty: 'coverArtDate',
+            artistContribsFromThingProperty: 'coverArtistContribs',
+            artistContribsArtistProperty: 'albumCoverArtistContributions',
+            artTagsFromThingProperty: 'artTags',
+            referencedArtworksFromThingProperty: 'referencedArtworks',
+          }),
+      },
+
+      'Banner Artwork': {
+        property: 'bannerArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            dimensionsFromThingProperty: 'bannerDimensions',
+            fileExtensionFromThingProperty: 'bannerFileExtension',
+            dateFromThingProperty: 'date',
+            artistContribsFromThingProperty: 'bannerArtistContribs',
+            artistContribsArtistProperty: 'albumBannerArtistContributions',
+          }),
+      },
+
+      'Wallpaper Artwork': {
+        property: 'wallpaperArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            dimensionsFromThingProperty: null,
+            fileExtensionFromThingProperty: 'wallpaperFileExtension',
+            dateFromThingProperty: 'date',
+            artistContribsFromThingProperty: 'wallpaperArtistContribs',
+            artistContribsArtistProperty: 'albumWallpaperArtistContributions',
+          }),
+      },
+
       'Cover Art Date': {
         property: 'coverArtDate',
         transform: parseDate,
@@ -524,8 +606,15 @@ export class Album extends Thing {
         transform: parseDimensions,
       },
 
-      'Commentary': {property: 'commentary'},
-      'Credit Sources': {property: 'creditSources'},
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
+      },
+
+      'Credit Sources': {
+        property: 'creditSources',
+        transform: parseCreditingSources,
+      },
 
       'Additional Files': {
         property: 'additionalFiles',
@@ -597,6 +686,11 @@ export class Album extends Thing {
       const trackSectionData = [];
       const trackData = [];
 
+      const artworkData = [];
+      const commentaryData = [];
+      const creditingSourceData = [];
+      const lyricsData = [];
+
       for (const {header: album, entries} of results) {
         const trackSections = [];
 
@@ -636,17 +730,51 @@ export class Album extends Thing {
           currentTrackSectionTracks.push(entry);
           trackData.push(entry);
 
-          entry.dataSourceAlbum = albumRef;
+          // Set the track's album before accessing its list of artworks.
+          // The existence of its artwork objects may depend on access to
+          // its album's 'Default Track Cover Artists'.
+          entry.album = album;
+
+          artworkData.push(...entry.trackArtworks);
+          commentaryData.push(...entry.commentary);
+          creditingSourceData.push(...entry.creditSources);
+
+          // TODO: As exposed, Track.lyrics tries to inherit from the main
+          // release, which is impossible before the data's been linked.
+          // We just use the update value here. But it's icky!
+          lyricsData.push(...CacheableObject.getUpdateValue(entry, 'lyrics') ?? []);
         }
 
         closeCurrentTrackSection();
 
         albumData.push(album);
 
+        artworkData.push(...album.coverArtworks);
+
+        if (album.bannerArtwork) {
+          artworkData.push(album.bannerArtwork);
+        }
+
+        if (album.wallpaperArtwork) {
+          artworkData.push(album.wallpaperArtwork);
+        }
+
+        commentaryData.push(...album.commentary);
+        creditingSourceData.push(...album.creditSources);
+
         album.trackSections = trackSections;
       }
 
-      return {albumData, trackSectionData, trackData};
+      return {
+        albumData,
+        trackSectionData,
+        trackData,
+
+        artworkData,
+        commentaryData,
+        creditingSourceData,
+        lyricsData,
+      };
     },
 
     sort({albumData, trackData}) {
@@ -654,6 +782,44 @@ export class Album extends Thing {
       sortAlbumsTracksChronologically(trackData);
     },
   });
+
+  getOwnArtworkPath(artwork) {
+    if (artwork === this.bannerArtwork) {
+      return [
+        'media.albumBanner',
+        this.directory,
+        artwork.fileExtension,
+      ];
+    }
+
+    if (artwork === this.wallpaperArtwork) {
+      if (!empty(this.wallpaperParts)) {
+        return null;
+      }
+
+      return [
+        'media.albumWallpaper',
+        this.directory,
+        artwork.fileExtension,
+      ];
+    }
+
+    // TODO: using trackCover here is obviously, badly wrong
+    // but we ought to refactor banners and wallpapers similarly
+    // (i.e. depend on those intrinsic artwork paths rather than
+    // accessing media.{albumBanner,albumWallpaper} from content
+    // or other code directly)
+    return [
+      'media.trackCover',
+      this.directory,
+
+      (artwork.unqualifiedDirectory
+        ? 'cover-' + artwork.unqualifiedDirectory
+        : 'cover'),
+
+      artwork.fileExtension,
+    ];
+  }
 }
 
 export class TrackSection extends Thing {
@@ -682,6 +848,14 @@ export class TrackSection extends Thing {
       exposeDependency({dependency: '#album.color'}),
     ],
 
+    startCountingFrom: [
+      withStartCountingFrom({
+        from: input.updateValue({validate: isNumber}),
+      }),
+
+      exposeDependency({dependency: '#startCountingFrom'}),
+    ],
+
     dateOriginallyReleased: simpleDate(),
 
     isDefaultTrackSection: flag(false),
@@ -731,42 +905,10 @@ export class TrackSection extends Thing {
       },
     ],
 
-    startIndex: [
-      withAlbum(),
-
-      withPropertyFromObject({
-        object: '#album',
-        property: input.value('trackSections'),
-      }),
-
-      {
-        dependencies: ['#album.trackSections', input.myself()],
-        compute: (continuation, {
-          ['#album.trackSections']: trackSections,
-          [input.myself()]: myself,
-        }) => continuation({
-          ['#index']:
-            trackSections.indexOf(myself),
-        }),
-      },
+    continueCountingFrom: [
+      withContinueCountingFrom(),
 
-      exitWithoutDependency({
-        dependency: '#index',
-        mode: input.value('index'),
-        value: input.value(0),
-      }),
-
-      {
-        dependencies: ['#album.trackSections', '#index'],
-        compute: ({
-          ['#album.trackSections']: trackSections,
-          ['#index']: index,
-        }) =>
-          accumulateSum(
-            trackSections
-              .slice(0, index)
-              .map(section => section.tracks.length)),
-      },
+      exposeDependency({dependency: '#continueCountingFrom'}),
     ],
   });
 
@@ -797,6 +939,7 @@ export class TrackSection extends Thing {
     fields: {
       'Section': {property: 'name'},
       'Color': {property: 'color'},
+      'Start Counting From': {property: 'startCountingFrom'},
 
       'Date Originally Released': {
         property: 'dateOriginallyReleased',
@@ -820,12 +963,12 @@ export class TrackSection extends Thing {
 
       let first = null;
       try {
-        first = this.startIndex;
+        first = this.tracks.at(0).trackNumber;
       } catch {}
 
-      let length = null;
+      let last = null;
       try {
-        length = this.tracks.length;
+        last = this.tracks.at(-1).trackNumber;
       } catch {}
 
       if (album) {
@@ -838,8 +981,8 @@ export class TrackSection extends Thing {
             : `#${albumIndex + 1}`);
 
         const range =
-          (albumIndex >= 0 && first !== null && length !== null
-            ? `: ${first + 1}-${first + length}`
+          (albumIndex >= 0 && first !== null && last !== null
+            ? `: ${first}-${last}`
             : '');
 
         parts.push(` (${colors.yellow(num + range)} in ${colors.green(albumName)})`);
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
index c88fcdc2..57e156ee 100644
--- a/src/data/things/art-tag.js
+++ b/src/data/things/art-tag.js
@@ -68,8 +68,6 @@ export class ArtTag extends Thing {
       class: input.value(ArtTag),
       find: soupyFind.input('artTag'),
 
-      date: input.value(null),
-
       reference: input.value('artTag'),
       thing: input.value('artTag'),
     }),
@@ -94,22 +92,11 @@ export class ArtTag extends Thing {
       },
     ],
 
-    directlyTaggedInThings: {
-      flags: {expose: true},
-
-      expose: {
-        dependencies: ['this', 'reverse'],
-        compute: ({this: artTag, reverse}) =>
-          sortAlbumsTracksChronologically(
-            [
-              ...reverse.albumsWhoseArtworksFeature(artTag),
-              ...reverse.tracksWhoseArtworksFeature(artTag),
-            ],
-            {getDate: thing => thing.coverArtDate ?? thing.date}),
-      },
-    },
+    directlyFeaturedInArtworks: reverseReferenceList({
+      reverse: soupyReverse.input('artworksWhichFeature'),
+    }),
 
-    indirectlyTaggedInThings: [
+    indirectlyFeaturedInArtworks: [
       withAllDescendantArtTags(),
 
       {
@@ -117,7 +104,7 @@ export class ArtTag extends Thing {
         compute: ({'#allDescendantArtTags': allDescendantArtTags}) =>
           unique(
             allDescendantArtTags
-              .flatMap(artTag => artTag.directlyTaggedInThings)),
+              .flatMap(artTag => artTag.directlyFeaturedInArtworks)),
       },
     ],
 
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 7ed99a8e..87e1c563 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -10,8 +10,12 @@ import {stitchArrays} from '#sugar';
 import Thing from '#thing';
 import {isName, validateArrayItems} from '#validators';
 import {getKebabCase} from '#wiki-data';
+import {parseArtwork} from '#yaml';
+
+import {exitWithoutDependency} from '#composite/control-flow';
 
 import {
+  constitutibleArtwork,
   contentString,
   directory,
   fileExtension,
@@ -43,6 +47,16 @@ export class Artist extends Thing {
     hasAvatar: flag(false),
     avatarFileExtension: fileExtension('jpg'),
 
+    avatarArtwork: [
+      exitWithoutDependency({
+        dependency: 'hasAvatar',
+        value: input.value(null),
+      }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Avatar Artwork'),
+    ],
+
     aliasNames: {
       flags: {update: true, expose: true},
       update: {validate: validateArrayItems(isName)},
@@ -193,6 +207,16 @@ export class Artist extends Thing {
       'URLs': {property: 'urls'},
       'Context Notes': {property: 'contextNotes'},
 
+      // note: doesn't really work as an independent field yet
+      'Avatar Artwork': {
+        property: 'avatarArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            fileExtensionFromThingProperty: 'avatarFileExtension',
+          }),
+      },
+
       'Has Avatar': {property: 'hasAvatar'},
       'Avatar File Extension': {property: 'avatarFileExtension'},
 
@@ -238,7 +262,12 @@ export class Artist extends Thing {
 
       const artistData = [...artists, ...artistAliases];
 
-      return {artistData};
+      const artworkData =
+        artistData
+          .filter(artist => artist.hasAvatar)
+          .map(artist => artist.avatarArtwork);
+
+      return {artistData, artworkData};
     },
 
     sort({artistData}) {
@@ -266,4 +295,12 @@ export class Artist extends Thing {
 
     return parts.join('');
   }
+
+  getOwnArtworkPath(artwork) {
+    return [
+      'media.artistAvatar',
+      this.directory,
+      artwork.fileExtension,
+    ];
+  }
 }
diff --git a/src/data/things/artwork.js b/src/data/things/artwork.js
new file mode 100644
index 00000000..2a97fd6d
--- /dev/null
+++ b/src/data/things/artwork.js
@@ -0,0 +1,399 @@
+import {inspect} from 'node:util';
+
+import {input} from '#composite';
+import find from '#find';
+import Thing from '#thing';
+
+import {
+  isContentString,
+  isContributionList,
+  isDate,
+  isDimensions,
+  isFileExtension,
+  optional,
+  validateArrayItems,
+  validateProperties,
+  validateReference,
+  validateReferenceList,
+} from '#validators';
+
+import {
+  parseAnnotatedReferences,
+  parseContributors,
+  parseDate,
+  parseDimensions,
+} from '#yaml';
+
+import {withPropertyFromObject} from '#composite/data';
+
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  withRecontextualizedContributionList,
+  withResolvedAnnotatedReferenceList,
+  withResolvedContribs,
+  withResolvedReferenceList,
+} from '#composite/wiki-data';
+
+import {
+  contentString,
+  directory,
+  reverseReferenceList,
+  simpleString,
+  soupyFind,
+  soupyReverse,
+  thing,
+  wikiData,
+} from '#composite/wiki-properties';
+
+import {withDate} from '#composite/things/artwork';
+
+export class Artwork extends Thing {
+  static [Thing.referenceType] = 'artwork';
+
+  static [Thing.getPropertyDescriptors] = ({
+    ArtTag,
+    Contribution,
+  }) => ({
+    // Update & expose
+
+    unqualifiedDirectory: directory({
+      name: input.value(null),
+    }),
+
+    thing: thing(),
+
+    label: simpleString(),
+    source: contentString(),
+
+    dateFromThingProperty: simpleString(),
+
+    date: [
+      withDate({
+        from: input.updateValue({validate: isDate}),
+      }),
+
+      exposeDependency({dependency: '#date'}),
+    ],
+
+    fileExtensionFromThingProperty: simpleString(),
+
+    fileExtension: [
+      {
+        compute: (continuation) => continuation({
+          ['#default']: 'jpg',
+        }),
+      },
+
+      exposeUpdateValueOrContinue({
+        validate: input.value(isFileExtension),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'thing',
+        value: '#default',
+      }),
+
+      exitWithoutDependency({
+        dependency: 'fileExtensionFromThingProperty',
+        value: '#default',
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'fileExtensionFromThingProperty',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#value',
+      }),
+
+      exposeDependency({
+        dependency: '#default',
+      }),
+    ],
+
+    dimensionsFromThingProperty: simpleString(),
+
+    dimensions: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDimensions),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'artistContribsFromThingProperty',
+        value: input.value(null),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'dimensionsFromThingProperty',
+      }).outputs({
+        ['#value']: '#dimensionsFromThing',
+      }),
+
+      exitWithoutDependency({
+        dependency: 'dimensionsFromThingProperty',
+        value: input.value(null),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#dimensionsFromThing',
+      }),
+
+      exposeConstant({
+        value: input.value(null),
+      }),
+    ],
+
+    artistContribsFromThingProperty: simpleString(),
+    artistContribsArtistProperty: simpleString(),
+
+    artistContribs: [
+      withDate(),
+
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        date: '#date',
+        artistProperty: 'artistContribsArtistProperty',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#resolvedContribs',
+        mode: input.value('empty'),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'artistContribsFromThingProperty',
+        value: input.value([]),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'artistContribsFromThingProperty',
+      }).outputs({
+        ['#value']: '#artistContribs',
+      }),
+
+      withRecontextualizedContributionList({
+        list: '#artistContribs',
+      }),
+
+      exposeDependency({
+        dependency: '#artistContribs',
+      }),
+    ],
+
+    artTagsFromThingProperty: simpleString(),
+
+    artTags: [
+      withResolvedReferenceList({
+        list: input.updateValue({
+          validate:
+            validateReferenceList(ArtTag[Thing.referenceType]),
+        }),
+
+        find: soupyFind.input('artTag'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#resolvedReferenceList',
+        mode: input.value('empty'),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'artTagsFromThingProperty',
+        value: input.value([]),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'artTagsFromThingProperty',
+      }).outputs({
+        ['#value']: '#artTags',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#artTags',
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
+
+    referencedArtworksFromThingProperty: simpleString(),
+
+    referencedArtworks: [
+      {
+        compute: (continuation) => continuation({
+          ['#find']:
+            find.mixed({
+              track: find.trackPrimaryArtwork,
+              album: find.albumPrimaryArtwork,
+            }),
+        }),
+      },
+
+      withResolvedAnnotatedReferenceList({
+        list: input.updateValue({
+          validate:
+            // TODO: It's annoying to hardcode this when it's really the
+            // same behavior as through annotatedReferenceList and through
+            // referenceListUpdateDescription, the latter of which isn't
+            // available outside of #composite/wiki-data internals.
+            validateArrayItems(
+              validateProperties({
+                reference: validateReference(['album', 'track']),
+                annotation: optional(isContentString),
+              })),
+        }),
+
+        data: 'artworkData',
+        find: '#find',
+
+        thing: input.value('artwork'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#resolvedAnnotatedReferenceList',
+        mode: input.value('empty'),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'referencedArtworksFromThingProperty',
+        value: input.value([]),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'referencedArtworksFromThingProperty',
+      }).outputs({
+        ['#value']: '#referencedArtworks',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#referencedArtworks',
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
+
+    // Update only
+
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
+    // used for referencedArtworks (mixedFind)
+    artworkData: wikiData({
+      class: input.value(Artwork),
+    }),
+
+    // Expose only
+
+    referencedByArtworks: reverseReferenceList({
+      reverse: soupyReverse.input('artworksWhichReference'),
+    }),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Directory': {property: 'unqualifiedDirectory'},
+      'File Extension': {property: 'fileExtension'},
+
+      'Dimensions': {
+        property: 'dimensions',
+        transform: parseDimensions,
+      },
+
+      'Label': {property: 'label'},
+      'Source': {property: 'source'},
+
+      'Date': {
+        property: 'date',
+        transform: parseDate,
+      },
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      'Tags': {property: 'artTags'},
+
+      'Referenced Artworks': {
+        property: 'referencedArtworks',
+        transform: parseAnnotatedReferences,
+      },
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    artworksWhichReference: {
+      bindTo: 'artworkData',
+
+      referencing: referencingArtwork =>
+        referencingArtwork.referencedArtworks
+          .map(({artwork: referencedArtwork, ...referenceDetails}) => ({
+            referencingArtwork,
+            referencedArtwork,
+            referenceDetails,
+          })),
+
+      referenced: ({referencedArtwork}) => [referencedArtwork],
+
+      tidy: ({referencingArtwork, referenceDetails}) => ({
+        artwork: referencingArtwork,
+        ...referenceDetails,
+      }),
+
+      date: ({artwork}) => artwork.date,
+    },
+
+    artworksWhichFeature: {
+      bindTo: 'artworkData',
+
+      referencing: artwork => [artwork],
+      referenced: artwork => artwork.artTags,
+    },
+  };
+
+  get path() {
+    if (!this.thing) return null;
+    if (!this.thing.getOwnArtworkPath) return null;
+
+    return this.thing.getOwnArtworkPath(this);
+  }
+
+  [inspect.custom](depth, options, inspect) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (this.thing) {
+      if (depth >= 0) {
+        const newOptions = {
+          ...options,
+          depth:
+            (options.depth === null
+              ? null
+              : options.depth - 1),
+        };
+
+        parts.push(` for ${inspect(this.thing, newOptions)}`);
+      } else {
+        parts.push(` for ${colors.blue(Thing.getReference(this.thing))}`);
+      }
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/content.js b/src/data/things/content.js
new file mode 100644
index 00000000..7f352795
--- /dev/null
+++ b/src/data/things/content.js
@@ -0,0 +1,122 @@
+import {input} from '#composite';
+import find from '#find';
+import Thing from '#thing';
+import {is, isDate} from '#validators';
+import {parseDate} from '#yaml';
+
+import {contentString, referenceList, simpleDate, soupyFind, thing}
+  from '#composite/wiki-properties';
+
+import {
+  exposeConstant,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+  withResultOfAvailabilityCheck,
+} from '#composite/control-flow';
+
+import {withWebArchiveDate} from '#composite/things/commentary-entry';
+
+export class ContentEntry extends Thing {
+  static [Thing.getPropertyDescriptors] = ({Artist}) => ({
+    // Update & expose
+
+    thing: thing(),
+
+    artists: referenceList({
+      class: input.value(Artist),
+      find: soupyFind.input('artist'),
+    }),
+
+    artistText: contentString(),
+
+    annotation: contentString(),
+
+    dateKind: {
+      flags: {update: true, expose: true},
+
+      update: {
+        validate: is(...[
+          'sometime',
+          'throughout',
+          'around',
+        ]),
+      },
+    },
+
+    accessKind: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(
+          is(...[
+            'captured',
+            'accessed',
+          ])),
+      }),
+
+      withWebArchiveDate(),
+
+      withResultOfAvailabilityCheck({
+        from: '#webArchiveDate',
+      }),
+
+      {
+        dependencies: ['#availability'],
+        compute: (continuation, {['#availability']: availability}) =>
+          (availability
+            ? continuation.exit('captured')
+            : continuation()),
+      },
+
+      exposeConstant({
+        value: input.value(null),
+      }),
+    ],
+
+    date: simpleDate(),
+
+    secondDate: simpleDate(),
+
+    accessDate: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDate),
+      }),
+
+      withWebArchiveDate(),
+
+      exposeDependencyOrContinue({
+        dependency: '#webArchiveDate',
+      }),
+
+      exposeConstant({
+        value: input.value(null),
+      }),
+    ],
+
+    body: contentString(),
+
+    // Update only
+
+    find: soupyFind(),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Artists': {property: 'artists'},
+      'Artist Text': {property: 'artistText'},
+
+      'Annotation': {property: 'annotation'},
+
+      'Date Kind': {property: 'dateKind'},
+      'Access Kind': {property: 'accessKind'},
+
+      'Date': {property: 'date', transform: parseDate},
+      'Second Date': {property: 'secondDate', transform: parseDate},
+      'Access Date': {property: 'accessDate', transform: parseDate},
+
+      'Body': {property: 'body'},
+    },
+  };
+}
+
+export class CommentaryEntry extends ContentEntry {}
+export class LyricsEntry extends ContentEntry {}
+export class CreditingSourcesEntry extends ContentEntry {}
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
index fe1d17ff..dac674dd 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -6,8 +6,16 @@ import {sortFlashesChronologically} from '#sort';
 import Thing from '#thing';
 import {anyOf, isColor, isContentString, isDirectory, isNumber, isString}
   from '#validators';
-import {parseAdditionalNames, parseContributors, parseDate, parseDimensions}
-  from '#yaml';
+
+import {
+  parseArtwork,
+  parseAdditionalNames,
+  parseCommentary,
+  parseContributors,
+  parseCreditingSources,
+  parseDate,
+  parseDimensions,
+} from '#yaml';
 
 import {withPropertyFromObject} from '#composite/data';
 
@@ -21,8 +29,8 @@ import {
 import {
   additionalNameList,
   color,
-  commentary,
   commentatorArtists,
+  constitutibleArtwork,
   contentString,
   contributionList,
   dimensions,
@@ -34,6 +42,7 @@ import {
   soupyFind,
   soupyReverse,
   thing,
+  thingList,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
@@ -45,6 +54,8 @@ export class Flash extends Thing {
   static [Thing.referenceType] = 'flash';
 
   static [Thing.getPropertyDescriptors] = ({
+    CommentaryEntry,
+    CreditingSourcesEntry,
     Track,
     FlashAct,
     WikiInfo,
@@ -100,6 +111,10 @@ export class Flash extends Thing {
 
     coverArtDimensions: dimensions(),
 
+    coverArtwork:
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
+
     contributorContribs: contributionList({
       date: 'date',
       artistProperty: input.value('flashContributorContributions'),
@@ -114,8 +129,13 @@ export class Flash extends Thing {
 
     additionalNames: additionalNameList(),
 
-    commentary: commentary(),
-    creditSources: commentary(),
+    commentary: thingList({
+      class: input.value(CommentaryEntry),
+    }),
+
+    creditSources: thingList({
+      class: input.value(CreditingSourcesEntry),
+    }),
 
     // Update only
 
@@ -205,6 +225,16 @@ export class Flash extends Thing {
         transform: parseAdditionalNames,
       },
 
+      'Cover Artwork': {
+        property: 'coverArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            fileExtensionFromThingProperty: 'coverArtFileExtension',
+            dimensionsFromThingProperty: 'coverArtDimensions',
+          }),
+      },
+
       'Cover Art File Extension': {property: 'coverArtFileExtension'},
 
       'Cover Art Dimensions': {
@@ -219,12 +249,27 @@ export class Flash extends Thing {
         transform: parseContributors,
       },
 
-      'Commentary': {property: 'commentary'},
-      'Credit Sources': {property: 'creditSources'},
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
+      },
+
+      'Credit Sources': {
+        property: 'creditSources',
+        transform: parseCreditingSources,
+      },
 
       'Review Points': {ignore: true},
     },
   };
+
+  getOwnArtworkPath(artwork) {
+    return [
+      'media.flashArt',
+      this.directory,
+      artwork.fileExtension,
+    ];
+  }
 }
 
 export class FlashAct extends Thing {
@@ -411,7 +456,19 @@ export class FlashSide extends Thing {
       const flashActData = results.filter(x => x instanceof FlashAct);
       const flashSideData = results.filter(x => x instanceof FlashSide);
 
-      return {flashData, flashActData, flashSideData};
+      const artworkData = flashData.map(flash => flash.coverArtwork);
+      const commentaryData = flashData.flatMap(flash => flash.commentary);
+      const creditingSourceData = flashData.flatMap(flash => flash.creditSources);
+
+      return {
+        flashData,
+        flashActData,
+        flashSideData,
+
+        artworkData,
+        commentaryData,
+        creditingSourceData,
+      };
     },
 
     sort({flashData}) {
diff --git a/src/data/things/group.js b/src/data/things/group.js
index ed3c59bb..b40d15b4 100644
--- a/src/data/things/group.js
+++ b/src/data/things/group.js
@@ -34,8 +34,6 @@ export class Group extends Thing {
       class: input.value(Artist),
       find: soupyFind.input('artist'),
 
-      date: input.value(null),
-
       reference: input.value('artist'),
       thing: input.value('artist'),
     }),
diff --git a/src/data/things/index.js b/src/data/things/index.js
index 17471f31..b832ab75 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -12,6 +12,8 @@ import Thing from '#thing';
 import * as albumClasses from './album.js';
 import * as artTagClasses from './art-tag.js';
 import * as artistClasses from './artist.js';
+import * as artworkClasses from './artwork.js';
+import * as contentClasses from './content.js';
 import * as contributionClasses from './contribution.js';
 import * as flashClasses from './flash.js';
 import * as groupClasses from './group.js';
@@ -27,6 +29,8 @@ const allClassLists = {
   'album.js': albumClasses,
   'art-tag.js': artTagClasses,
   'artist.js': artistClasses,
+  'artwork.js': artworkClasses,
+  'content.js': contentClasses,
   'contribution.js': contributionClasses,
   'flash.js': flashClasses,
   'group.js': groupClasses,
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 69eb98a5..ae7be170 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -11,10 +11,14 @@ import {
   parseAdditionalFiles,
   parseAdditionalNames,
   parseAnnotatedReferences,
+  parseArtwork,
+  parseCommentary,
   parseContributors,
+  parseCreditingSources,
   parseDate,
   parseDimensions,
   parseDuration,
+  parseLyrics,
 } from '#yaml';
 
 import {withPropertyFromObject} from '#composite/data';
@@ -36,8 +40,8 @@ import {
 import {
   additionalFiles,
   additionalNameList,
-  commentary,
   commentatorArtists,
+  constitutibleArtworkList,
   contentString,
   contributionList,
   dimensions,
@@ -54,6 +58,7 @@ import {
   soupyFind,
   soupyReverse,
   thing,
+  thingList,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
@@ -62,10 +67,10 @@ import {
   exitWithoutUniqueCoverArt,
   inheritContributionListFromMainRelease,
   inheritFromMainRelease,
-  withAlbum,
   withAllReleases,
   withAlwaysReferenceByDirectory,
   withContainingTrackSection,
+  withCoverArtistContribs,
   withDate,
   withDirectorySuffix,
   withHasUniqueCoverArt,
@@ -74,6 +79,7 @@ import {
   withPropertyFromAlbum,
   withSuffixDirectoryFromAlbum,
   withTrackArtDate,
+  withTrackNumber,
 } from '#composite/things/track';
 
 export class Track extends Thing {
@@ -82,7 +88,11 @@ export class Track extends Thing {
   static [Thing.getPropertyDescriptors] = ({
     Album,
     ArtTag,
+    Artwork,
+    CommentaryEntry,
+    CreditingSourcesEntry,
     Flash,
+    LyricsEntry,
     TrackSection,
     WikiInfo,
   }) => ({
@@ -120,6 +130,10 @@ export class Track extends Thing {
       })
     ],
 
+    album: thing({
+      class: input.value(Album),
+    }),
+
     additionalNames: additionalNameList(),
 
     bandcampTrackIdentifier: simpleString(),
@@ -196,6 +210,8 @@ export class Track extends Thing {
     coverArtDimensions: [
       exitWithoutUniqueCoverArt(),
 
+      exposeUpdateValueOrContinue(),
+
       withPropertyFromAlbum({
         property: input.value('trackDimensions'),
       }),
@@ -205,12 +221,23 @@ export class Track extends Thing {
       dimensions(),
     ],
 
-    commentary: commentary(),
-    creditSources: commentary(),
+    commentary: thingList({
+      class: input.value(CommentaryEntry),
+    }),
+
+    creditSources: thingList({
+      class: input.value(CreditingSourcesEntry),
+    }),
 
     lyrics: [
+      // TODO: Inherited lyrics are literally the same objects, so of course
+      // their .thing properties aren't going to point back to this one, and
+      // certainly couldn't be recontextualized...
       inheritFromMainRelease(),
-      contentString(),
+
+      thingList({
+        class: input.value(LyricsEntry),
+      }),
     ],
 
     additionalFiles: additionalFiles(),
@@ -222,14 +249,6 @@ export class Track extends Thing {
       find: soupyFind.input('track'),
     }),
 
-    // Internal use only - for directly identifying an album inside a track's
-    // util.inspect display, if it isn't indirectly available (by way of being
-    // included in an album's track list).
-    dataSourceAlbum: singleReference({
-      class: input.value(Album),
-      find: soupyFind.input('album'),
-    }),
-
     artistContribs: [
       inheritContributionListFromMainRelease(),
 
@@ -278,43 +297,13 @@ export class Track extends Thing {
     ],
 
     coverArtistContribs: [
-      exitWithoutUniqueCoverArt({
-        value: input.value([]),
-      }),
-
-      withTrackArtDate({
-        fallback: input.value(true),
-      }),
-
-      withResolvedContribs({
-        from: input.updateValue({validate: isContributionList}),
-        thingProperty: input.thisProperty(),
-        artistProperty: input.value('trackCoverArtistContributions'),
-        date: '#trackArtDate',
-      }).outputs({
-        '#resolvedContribs': '#coverArtistContribs',
-      }),
-
-      exposeDependencyOrContinue({
-        dependency: '#coverArtistContribs',
-        mode: input.value('empty'),
-      }),
-
-      withPropertyFromAlbum({
-        property: input.value('trackCoverArtistContribs'),
-      }),
-
-      withRecontextualizedContributionList({
-        list: '#album.trackCoverArtistContribs',
-        artistProperty: input.value('trackCoverArtistContributions'),
-      }),
-
-      withRedatedContributionList({
-        list: '#album.trackCoverArtistContribs',
-        date: '#trackArtDate',
+      withCoverArtistContribs({
+        from: input.updateValue({
+          validate: isContributionList,
+        }),
       }),
 
-      exposeDependency({dependency: '#album.trackCoverArtistContribs'}),
+      exposeDependency({dependency: '#coverArtistContribs'}),
     ],
 
     referencedTracks: [
@@ -339,6 +328,15 @@ export class Track extends Thing {
       }),
     ],
 
+    trackArtworks: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
+      }),
+
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Track Artwork'),
+    ],
+
     artTags: [
       exitWithoutUniqueCoverArt({
         value: input.value([]),
@@ -355,13 +353,7 @@ export class Track extends Thing {
         value: input.value([]),
       }),
 
-      withTrackArtDate({
-        fallback: input.value(true),
-      }),
-
-      referencedArtworkList({
-        date: '#trackArtDate',
-      }),
+      referencedArtworkList(),
     ],
 
     // Update only
@@ -370,11 +362,11 @@ export class Track extends Thing {
     reverse: soupyReverse(),
 
     // used for referencedArtworkList (mixedFind)
-    albumData: wikiData({
-      class: input.value(Album),
+    artworkData: wikiData({
+      class: input.value(Artwork),
     }),
 
-    // used for referencedArtworkList (mixedFind)
+    // used for withAlwaysReferenceByDirectory (for some reason)
     trackData: wikiData({
       class: input.value(Track),
     }),
@@ -388,16 +380,16 @@ export class Track extends Thing {
 
     commentatorArtists: commentatorArtists(),
 
-    album: [
-      withAlbum(),
-      exposeDependency({dependency: '#album'}),
-    ],
-
     date: [
       withDate(),
       exposeDependency({dependency: '#date'}),
     ],
 
+    trackNumber: [
+      withTrackNumber(),
+      exposeDependency({dependency: '#trackNumber'}),
+    ],
+
     hasUniqueCoverArt: [
       withHasUniqueCoverArt(),
       exposeDependency({dependency: '#hasUniqueCoverArt'}),
@@ -447,16 +439,6 @@ export class Track extends Thing {
     featuredInFlashes: reverseReferenceList({
       reverse: soupyReverse.input('flashesWhichFeature'),
     }),
-
-    referencedByArtworks: [
-      exitWithoutUniqueCoverArt({
-        value: input.value([]),
-      }),
-
-      reverseReferenceList({
-        reverse: soupyReverse.input('artworksWhichReference'),
-      }),
-    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -515,9 +497,20 @@ export class Track extends Thing {
 
       'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
 
-      'Lyrics': {property: 'lyrics'},
-      'Commentary': {property: 'commentary'},
-      'Credit Sources': {property: 'creditSources'},
+      'Lyrics': {
+        property: 'lyrics',
+        transform: parseLyrics,
+      },
+
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
+      },
+
+      'Credit Sources': {
+        property: 'creditSources',
+        transform: parseCreditingSources,
+      },
 
       'Additional Files': {
         property: 'additionalFiles',
@@ -561,6 +554,20 @@ export class Track extends Thing {
         transform: parseContributors,
       },
 
+      'Track Artwork': {
+        property: 'trackArtworks',
+        transform:
+          parseArtwork({
+            dimensionsFromThingProperty: 'coverArtDimensions',
+            fileExtensionFromThingProperty: 'coverArtFileExtension',
+            dateFromThingProperty: 'coverArtDate',
+            artTagsFromThingProperty: 'artTags',
+            referencedArtworksFromThingProperty: 'referencedArtworks',
+            artistContribsFromThingProperty: 'coverArtistContribs',
+            artistContribsArtistProperty: 'trackCoverArtistContributions',
+          }),
+      },
+
       'Art Tags': {property: 'artTags'},
 
       'Review Points': {ignore: true},
@@ -652,6 +659,31 @@ export class Track extends Thing {
           ? []
           : [track.name]),
     },
+
+    trackPrimaryArtwork: {
+      [Thing.findThisThingOnly]: false,
+
+      referenceTypes: [
+        'track',
+        'track-referencing-artworks',
+        'track-referenced-artworks',
+      ],
+
+      bindTo: 'artworkData',
+
+      include: (artwork, {Artwork, Track}) =>
+        artwork instanceof Artwork &&
+        artwork.thing instanceof Track &&
+        artwork === artwork.thing.trackArtworks[0],
+
+      getMatchableNames: ({thing: track}) =>
+        (track.alwaysReferenceByDirectory
+          ? []
+          : [track.name]),
+
+      getMatchableDirectories: ({thing: track}) =>
+        [track.directory],
+    },
   };
 
   static [Thing.reverseSpecs] = {
@@ -683,7 +715,7 @@ export class Track extends Thing {
       soupyReverse.contributionsBy('trackData', 'contributorContribs'),
 
     trackCoverArtistContributionsBy:
-      soupyReverse.contributionsBy('trackData', 'coverArtistContribs'),
+      soupyReverse.artworkContributionsBy('trackData', 'trackArtworks'),
 
     tracksWithCommentaryBy: {
       bindTo: 'trackData',
@@ -703,6 +735,21 @@ export class Track extends Thing {
   // Track YAML loading is handled in album.js.
   static [Thing.getYamlLoadingSpec] = null;
 
+  getOwnArtworkPath(artwork) {
+    if (!this.album) return null;
+
+    return [
+      'media.trackCover',
+      this.album.directory,
+
+      (artwork.unqualifiedDirectory
+        ? this.directory + '-' + artwork.unqualifiedDirectory
+        : this.directory),
+
+      artwork.fileExtension,
+    ];
+  }
+
   [inspect.custom](depth) {
     const parts = [];
 
@@ -715,15 +762,7 @@ export class Track extends Thing {
     let album;
 
     if (depth >= 0) {
-      try {
-        album = this.album;
-      } catch (_error) {
-        // Computing album might crash for any reason, which we don't want to
-        // distract from another error we might be trying to work out at the
-        // moment (for which debugging might involve inspecting this track!).
-      }
-
-      album ??= this.dataSourceAlbum;
+      album = this.album;
     }
 
     if (album) {
diff --git a/src/data/yaml.js b/src/data/yaml.js
index a5614ea6..79602faa 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -11,8 +11,10 @@ import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli';
 import {sortByName} from '#sort';
 import Thing from '#thing';
 import thingConstructors from '#things';
+import {matchContentEntries, multipleLyricsDetectionRegex} from '#wiki-data';
 
 import {
+  aggregateThrows,
   annotateErrorWithFile,
   decorateErrorWithIndex,
   decorateErrorWithAnnotation,
@@ -88,6 +90,10 @@ function makeProcessDocument(thingConstructor, {
   // A or B.
   //
   invalidFieldCombinations = [],
+
+  // Bouncing function used to process subdocuments: this is a function which
+  // in turn calls the appropriate *result of* makeProcessDocument.
+  processDocument: bouncer,
 }) {
   if (!thingConstructor) {
     throw new Error(`Missing Thing class`);
@@ -97,6 +103,10 @@ function makeProcessDocument(thingConstructor, {
     throw new Error(`Expected fields to be provided`);
   }
 
+  if (!bouncer) {
+    throw new Error(`Missing processDocument bouncer`);
+  }
+
   const knownFields = Object.keys(fieldSpecs);
 
   const ignoredFields =
@@ -144,9 +154,12 @@ function makeProcessDocument(thingConstructor, {
         : `document`);
 
     const aggregate = openAggregate({
+      ...aggregateThrows(ProcessDocumentError),
       message: `Errors processing ${constructorPart}` + namePart,
     });
 
+    const thing = Reflect.construct(thingConstructor, []);
+
     const documentEntries = Object.entries(document)
       .filter(([field]) => !ignoredFields.includes(field));
 
@@ -194,13 +207,50 @@ function makeProcessDocument(thingConstructor, {
 
     const fieldValues = {};
 
+    const subdocSymbol = Symbol('subdoc');
+    const subdocLayouts = {};
+
+    const isSubdocToken = value =>
+      typeof value === 'object' &&
+      value !== null &&
+      Object.hasOwn(value, subdocSymbol);
+
+    const transformUtilities = {
+      ...thingConstructors,
+
+      subdoc(documentType, data, {
+        bindInto = null,
+        provide = null,
+      } = {}) {
+        if (!documentType)
+          throw new Error(`Expected document type, got ${typeAppearance(documentType)}`);
+        if (!data)
+          throw new Error(`Expected data, got ${typeAppearance(data)}`);
+        if (typeof data !== 'object' || data === null)
+          throw new Error(`Expected data to be an object, got ${typeAppearance(data)}`);
+        if (typeof bindInto !== 'string' && bindInto !== null)
+          throw new Error(`Expected bindInto to be a string, got ${typeAppearance(bindInto)}`);
+        if (typeof provide !== 'object' && provide !== null)
+          throw new Error(`Expected provide to be an object, got ${typeAppearance(provide)}`);
+
+        return {
+          [subdocSymbol]: {
+            documentType,
+            data,
+            bindInto,
+            provide,
+          },
+        };
+      },
+    };
+
     for (const [field, documentValue] of documentEntries) {
       if (skippedFields.has(field)) continue;
 
       // This variable would like to certify itself as "not into capitalism".
       let propertyValue =
         (fieldSpecs[field].transform
-          ? fieldSpecs[field].transform(documentValue)
+          ? fieldSpecs[field].transform(documentValue, transformUtilities)
           : documentValue);
 
       // Completely blank items in a YAML list are read as null.
@@ -223,10 +273,99 @@ function makeProcessDocument(thingConstructor, {
         }
       }
 
+      if (isSubdocToken(propertyValue)) {
+        subdocLayouts[field] = propertyValue[subdocSymbol];
+        continue;
+      }
+
+      if (Array.isArray(propertyValue) && propertyValue.every(isSubdocToken)) {
+        subdocLayouts[field] =
+          propertyValue
+            .map(token => token[subdocSymbol]);
+        continue;
+      }
+
       fieldValues[field] = propertyValue;
     }
 
-    const thing = Reflect.construct(thingConstructor, []);
+    const subdocErrors = [];
+
+    const followSubdocSetup = setup => {
+      let error = null;
+
+      let subthing;
+      try {
+        const result = bouncer(setup.data, setup.documentType);
+        subthing = result.thing;
+        result.aggregate.close();
+      } catch (caughtError) {
+        error = caughtError;
+      }
+
+      if (subthing) {
+        if (setup.bindInto) {
+          subthing[setup.bindInto] = thing;
+        }
+
+        if (setup.provide) {
+          Object.assign(subthing, setup.provide);
+        }
+      }
+
+      return {error, subthing};
+    };
+
+    for (const [field, layout] of Object.entries(subdocLayouts)) {
+      if (Array.isArray(layout)) {
+        const subthings = [];
+        let anySucceeded = false;
+        let anyFailed = false;
+
+        for (const [index, setup] of layout.entries()) {
+          const {subthing, error} = followSubdocSetup(setup);
+          if (error) {
+            subdocErrors.push(new SubdocError(
+              {field, index},
+              setup,
+              {cause: error}));
+          }
+
+          if (subthing) {
+            subthings.push(subthing);
+            anySucceeded = true;
+          } else {
+            anyFailed = true;
+          }
+        }
+
+        if (anySucceeded) {
+          fieldValues[field] = subthings;
+        } else if (anyFailed) {
+          skippedFields.add(field);
+        }
+      } else {
+        const setup = layout;
+        const {subthing, error} = followSubdocSetup(setup);
+
+        if (error) {
+          subdocErrors.push(new SubdocError(
+            {field},
+            setup,
+            {cause: error}));
+        }
+
+        if (subthing) {
+          fieldValues[field] = subthing;
+        } else {
+          skippedFields.add(field);
+        }
+      }
+    }
+
+    if (!empty(subdocErrors)) {
+      aggregate.push(new SubdocAggregateError(
+        subdocErrors, thingConstructor));
+    }
 
     const fieldValueErrors = [];
 
@@ -260,6 +399,8 @@ function makeProcessDocument(thingConstructor, {
   });
 }
 
+export class ProcessDocumentError extends AggregateError {}
+
 export class UnknownFieldsError extends Error {
   constructor(fields) {
     super(`Unknown fields ignored: ${fields.map(field => colors.red(field)).join(', ')}`);
@@ -347,12 +488,46 @@ export class SkippedFieldsSummaryError extends Error {
         : `${entries.length} fields`);
 
     super(
-      colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:\n`)) +
+      colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:`)) + '\n' +
       lines.join('\n') + '\n' +
       colors.bright(colors.yellow(`See above errors for details.`)));
   }
 }
 
+export class SubdocError extends Error {
+  constructor({field, index = null}, setup, options) {
+    const fieldText =
+      (index === null
+        ? colors.green(`"${field}"`)
+        : colors.yellow(`#${index + 1}`) + ' in ' +
+          colors.green(`"${field}"`));
+
+    const constructorText =
+      setup.documentType.name;
+
+    if (options.cause instanceof ProcessDocumentError) {
+      options.cause[Symbol.for('hsmusic.aggregate.translucent')] = true;
+    }
+
+    super(
+      `Errors processing ${constructorText} for ${fieldText} field`,
+      options);
+  }
+}
+
+export class SubdocAggregateError extends AggregateError {
+  [Symbol.for('hsmusic.aggregate.translucent')] = true;
+
+  constructor(errors, thingConstructor) {
+    const constructorText =
+      colors.green(thingConstructor.name);
+
+    super(
+      errors,
+      `Errors processing subdocuments for ${constructorText}`);
+  }
+}
+
 export function parseDate(date) {
   return new Date(date);
 }
@@ -615,6 +790,108 @@ export function parseAnnotatedReferences(entries, {
   });
 }
 
+export function parseArtwork({
+  single = false,
+  dimensionsFromThingProperty = null,
+  fileExtensionFromThingProperty = null,
+  dateFromThingProperty = null,
+  artistContribsFromThingProperty = null,
+  artistContribsArtistProperty = null,
+  artTagsFromThingProperty = null,
+  referencedArtworksFromThingProperty = null,
+}) {
+  const provide = {
+    dimensionsFromThingProperty,
+    fileExtensionFromThingProperty,
+    dateFromThingProperty,
+    artistContribsFromThingProperty,
+    artistContribsArtistProperty,
+    artTagsFromThingProperty,
+    referencedArtworksFromThingProperty,
+  };
+
+  const parseSingleEntry = (entry, {subdoc, Artwork}) =>
+    subdoc(Artwork, entry, {bindInto: 'thing', provide});
+
+  const transform = (value, ...args) =>
+    (Array.isArray(value)
+      ? value.map(entry => parseSingleEntry(entry, ...args))
+   : single
+      ? parseSingleEntry(value, ...args)
+      : [parseSingleEntry(value, ...args)]);
+
+  transform.provide = provide;
+
+  return transform;
+}
+
+export function parseContentEntries(thingClass, sourceText, {subdoc}) {
+  const map = matchEntry => ({
+    'Artists':
+      matchEntry.artistReferences
+        .split(',')
+        .map(ref => ref.trim()),
+
+    'Artist Text':
+      matchEntry.artistDisplayText,
+
+    'Annotation':
+      matchEntry.annotation,
+
+    'Date':
+      matchEntry.date,
+
+    'Second Date':
+      matchEntry.secondDate,
+
+    'Date Kind':
+      matchEntry.dateKind,
+
+    'Access Date':
+      matchEntry.accessDate,
+
+    'Access Kind':
+      matchEntry.accessKind,
+
+    'Body':
+      matchEntry.body,
+  });
+
+  const documents =
+    matchContentEntries(sourceText)
+      .map(matchEntry =>
+        withEntries(
+          map(matchEntry),
+          entries => entries
+            .filter(([key, value]) =>
+              value !== undefined &&
+              value !== null)));
+
+  const subdocs =
+    documents.map(document =>
+      subdoc(thingClass, document, {bindInto: 'thing'}));
+
+  return subdocs;
+}
+
+export function parseCommentary(sourceText, {subdoc, CommentaryEntry}) {
+  return parseContentEntries(CommentaryEntry, sourceText, {subdoc});
+}
+
+export function parseCreditingSources(sourceText, {subdoc, CreditingSourcesEntry}) {
+  return parseContentEntries(CreditingSourcesEntry, sourceText, {subdoc});
+}
+
+export function parseLyrics(sourceText, {subdoc, LyricsEntry}) {
+  if (!multipleLyricsDetectionRegex.test(sourceText)) {
+    const document = {'Body': sourceText};
+
+    return [subdoc(LyricsEntry, document, {bindInto: 'thing'})];
+  }
+
+  return parseContentEntries(LyricsEntry, sourceText, {subdoc});
+}
+
 // documentModes: Symbols indicating sets of behavior for loading and processing
 // data files.
 export const documentModes = {
@@ -899,7 +1176,7 @@ export function processThingsFromDataStep(documents, dataStep) {
         throw new Error(`Class "${thingClass.name}" doesn't specify Thing.yamlDocumentSpec`);
       }
 
-      fn = makeProcessDocument(thingClass, spec);
+      fn = makeProcessDocument(thingClass, {...spec, processDocument});
       submap.set(thingClass, fn);
     }
 
@@ -1280,8 +1557,7 @@ export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) {
     // link if the 'find' or 'reverse' properties will be implicitly linked
 
     ['albumData', [
-      'albumData',
-      'trackData',
+      'artworkData',
       'wikiInfo',
     ]],
 
@@ -1289,6 +1565,12 @@ export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) {
 
     ['artistData', [/* find, reverse */]],
 
+    ['artworkData', ['artworkData']],
+
+    ['commentaryData', [/* find */]],
+
+    ['creditingSourceData', [/* find */]],
+
     ['flashData', [
       'wikiInfo',
     ]],
@@ -1303,8 +1585,10 @@ export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) {
 
     ['homepageLayout.sections.rows', [/* find */]],
 
+    ['lyricsData', [/* find */]],
+
     ['trackData', [
-      'albumData',
+      'artworkData',
       'trackData',
       'wikiInfo',
     ]],
@@ -1571,14 +1855,16 @@ export function flattenThingLayoutToDocumentOrder(layout) {
 }
 
 export function* splitDocumentsInYAMLSourceText(sourceText) {
-  const dividerRegex = /^-{3,}\n?/gm;
+  // Not multiline!
+  const dividerRegex = /(?:\r\n|\n|^)-{3,}(?:\r\n|\n|$)/g;
+
   let previousDivider = '';
 
   while (true) {
     const {lastIndex} = dividerRegex;
     const match = dividerRegex.exec(sourceText);
     if (match) {
-      const nextDivider = match[0].trim();
+      const nextDivider = match[0];
 
       yield {
         previousDivider,
@@ -1589,11 +1875,12 @@ export function* splitDocumentsInYAMLSourceText(sourceText) {
       previousDivider = nextDivider;
     } else {
       const nextDivider = '';
+      const lineBreak = previousDivider.match(/\r?\n/)?.[0] ?? '';
 
       yield {
         previousDivider,
         nextDivider,
-        text: sourceText.slice(lastIndex).replace(/(?<!\n)$/, '\n'),
+        text: sourceText.slice(lastIndex).replace(/(?<!\n)$/, lineBreak),
       };
 
       return;
@@ -1619,7 +1906,7 @@ export function recombineDocumentsIntoYAMLSourceText(documents) {
 
   for (const document of documents) {
     if (sourceText) {
-      sourceText += divider + '\n';
+      sourceText += divider;
     }
 
     sourceText += document.text;
diff --git a/src/find.js b/src/find.js
index e590bc4f..e7f5cda1 100644
--- a/src/find.js
+++ b/src/find.js
@@ -42,7 +42,7 @@ export function processAvailableMatchesByName(data, {
   multipleNameMatches = Object.create(null),
 }) {
   for (const thing of data) {
-    if (!include(thing)) continue;
+    if (!include(thing, thingConstructors)) continue;
 
     for (const name of getMatchableNames(thing)) {
       if (typeof name !== 'string') {
@@ -79,7 +79,7 @@ export function processAvailableMatchesByDirectory(data, {
   results = Object.create(null),
 }) {
   for (const thing of data) {
-    if (!include(thing)) continue;
+    if (!include(thing, thingConstructors)) continue;
 
     for (const directory of getMatchableDirectories(thing)) {
       if (typeof directory !== 'string') {
@@ -266,9 +266,9 @@ export function postprocessFindSpec(spec, {thingConstructor}) {
   if (spec[Symbol.for('Thing.findThisThingOnly')] !== false) {
     if (spec.include) {
       const oldInclude = spec.include;
-      newSpec.include = thing =>
+      newSpec.include = (thing, ...args) =>
         thing instanceof thingConstructor &&
-        oldInclude(thing);
+        oldInclude(thing, ...args);
     } else {
       newSpec.include = thing =>
         thing instanceof thingConstructor;
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index 3ccd8ce2..97cf74a9 100644
--- a/src/gen-thumbs.js
+++ b/src/gen-thumbs.js
@@ -1242,41 +1242,15 @@ export function getExpectedImagePaths(mediaPath, {urls, wikiData}) {
   const fromRoot = urls.from('media.root');
 
   const paths = [
+    wikiData.artworkData
+      .filter(artwork => artwork.path)
+      .map(artwork => fromRoot.to(...artwork.path)),
+
     wikiData.albumData
-      .map(album => [
-        album.hasCoverArt && [
-          fromRoot.to('media.albumCover', album.directory, album.coverArtFileExtension),
-        ],
-
-        !empty(CacheableObject.getUpdateValue(album, 'bannerArtistContribs')) && [
-          fromRoot.to('media.albumBanner', album.directory, album.bannerFileExtension),
-        ],
-
-        !empty(CacheableObject.getUpdateValue(album, 'wallpaperArtistContribs')) &&
-        empty(album.wallpaperParts) && [
-          fromRoot.to('media.albumWallpaper', album.directory, album.wallpaperFileExtension),
-        ],
-
-        !empty(CacheableObject.getUpdateValue(album, 'wallpaperArtistContribs')) &&
-        !empty(album.wallpaperParts) &&
-          album.wallpaperParts.flatMap(part => [
-            part.asset &&
-              fromRoot.to('media.albumWallpaperPart', album.directory, part.asset),
-          ]),
-      ])
-      .flat(2)
-      .filter(Boolean),
-
-    wikiData.artistData
-      .filter(artist => artist.hasAvatar)
-      .map(artist => fromRoot.to('media.artistAvatar', artist.directory, artist.avatarFileExtension)),
-
-    wikiData.flashData
-      .map(flash => fromRoot.to('media.flashArt', flash.directory, flash.coverArtFileExtension)),
-
-    wikiData.trackData
-      .filter(track => track.hasUniqueCoverArt)
-      .map(track => fromRoot.to('media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension)),
+      .flatMap(album => album.wallpaperParts
+        .filter(part => part.asset)
+        .map(part =>
+          fromRoot.to('media.albumWallpaperPart', album.directory, part.asset))),
   ].flat();
 
   sortByName(paths, {getName: path => path});
diff --git a/src/page/album.js b/src/page/album.js
index 8c08b960..696e2854 100644
--- a/src/page/album.js
+++ b/src/page/album.js
@@ -43,7 +43,8 @@ export function pathsForTarget(album) {
       path: ['albumReferencedArtworks', album.directory],
 
       condition: () =>
-        !empty(album.referencedArtworks),
+        album.hasCoverArt &&
+        !empty(album.coverArtworks[0].referencedArtworks),
 
       contentFunction: {
         name: 'generateAlbumReferencedArtworksPage',
@@ -56,7 +57,8 @@ export function pathsForTarget(album) {
       path: ['albumReferencingArtworks', album.directory],
 
       condition: () =>
-        !empty(album.referencedByArtworks),
+        album.hasCoverArt &&
+        !empty(album.coverArtworks[0].referencedByArtworks),
 
       contentFunction: {
         name: 'generateAlbumReferencingArtworksPage',
diff --git a/src/page/track.js b/src/page/track.js
index 301af991..95647334 100644
--- a/src/page/track.js
+++ b/src/page/track.js
@@ -25,7 +25,8 @@ export function pathsForTarget(track) {
       path: ['trackReferencedArtworks', track.directory],
 
       condition: () =>
-        !empty(track.referencedArtworks),
+        track.hasUniqueCoverArt &&
+        !empty(track.trackArtworks[0].referencedArtworks),
 
       contentFunction: {
         name: 'generateTrackReferencedArtworksPage',
@@ -38,7 +39,8 @@ export function pathsForTarget(track) {
       path: ['trackReferencingArtworks', track.directory],
 
       condition: () =>
-        !empty(track.referencedByArtworks),
+        track.hasUniqueCoverArt &&
+        !empty(track.trackArtworks[0].referencedByArtworks),
 
       contentFunction: {
         name: 'generateTrackReferencingArtworksPage',
diff --git a/src/reverse.js b/src/reverse.js
index 9ad5c8a7..b4b225f0 100644
--- a/src/reverse.js
+++ b/src/reverse.js
@@ -83,7 +83,9 @@ function reverseHelper(spec) {
     for (const referencedThing of allReferencedThings) {
       if (cacheRecord.has(referencedThing)) {
         const referencingThings = cacheRecord.get(referencedThing);
-        sortByDate(referencingThings);
+        sortByDate(referencingThings, {
+          getDate: spec.date ?? (thing => thing.date),
+        });
       }
     }
 
@@ -100,28 +102,7 @@ function reverseHelper(spec) {
   };
 }
 
-const hardcodedReverseSpecs = {
-  // Artworks aren't Thing objects.
-  // This spec operates on albums and tracks alike!
-  artworksWhichReference: {
-    bindTo: 'wikiData',
-
-    referencing: ({albumData, trackData}) =>
-      [...albumData, ...trackData]
-        .flatMap(referencingThing =>
-          referencingThing.referencedArtworks
-            .map(({thing: referencedThing, ...referenceDetails}) => ({
-              referencingThing,
-              referencedThing,
-              referenceDetails,
-            }))),
-
-    referenced: ({referencedThing}) => [referencedThing],
-
-    tidy: ({referencingThing, referenceDetails}) =>
-      ({thing: referencingThing, ...referenceDetails}),
-  },
-};
+const hardcodedReverseSpecs = {};
 
 const findReverseHelperConfig = {
   word: `reverse`,
diff --git a/src/static/css/site.css b/src/static/css/site.css
index c92c65ad..0a7e36ae 100644
--- a/src/static/css/site.css
+++ b/src/static/css/site.css
@@ -583,6 +583,15 @@ summary.underline-white > span:hover a:not(:hover) {
   border-bottom-left-radius: 0;
 }
 
+.track-list-sidebar-box summary {
+  padding-left: 20px !important;
+  text-indent: -15px !important;
+}
+
+.track-list-sidebar-box .track-section-range {
+  white-space: nowrap;
+}
+
 .wiki-search-sidebar-box {
   padding: 1px 0 0 0;
 
@@ -922,7 +931,11 @@ a .normal-content {
 
   background-color: var(--primary-color);
 
-  mask-image: url(/static-4p1/misc/image.svg);
+  /* mask-image is set in content JavaScript,
+   * because we can't identify the correct nor
+   * absolute path to the file from CSS.
+   */
+
   mask-repeat: no-repeat;
   mask-position: calc(100% - 2px);
   vertical-align: text-bottom;
@@ -950,29 +963,46 @@ a .normal-content {
   font-weight: 800;
 }
 
+.nav-links-hierarchical .nav-link + .nav-link::before,
 .nav-links-hierarchical .nav-link + .blockwrap .nav-link::before {
   content: "\0020/\0020";
 }
 
-.series-nav-link {
+.series-nav-links {
   display: inline-block;
 }
 
-.series-nav-link:not(:first-child)::before {
+.series-nav-links:not(:first-child)::before {
   content: "\00a0»\00a0";
   font-weight: normal;
 }
 
-.series-nav-link:not(:last-child)::after {
+.series-nav-links:not(:last-child)::after {
   content: ",\00a0";
 }
 
-.series-nav-link + .series-nav-link::before {
+.series-nav-links + .series-nav-links::before {
   content: "";
 }
 
+.dot-switcher > span:not(:first-child) {
+  display: inline-block;
+  white-space: nowrap;
+}
+
+/* Yeah, all this stuff only applies to elements of the dot switcher
+ * besides the first, which will necessarily have a bullet point at left.
+ */
+.dot-switcher *:where(.dot-switcher > span:not(:first-child) > *) {
+  display: inline-block;
+  white-space: wrap;
+  text-align: left;
+  vertical-align: top;
+}
+
 .dot-switcher > span:not(:first-child)::before {
   content: "\0020\00b7\0020";
+  white-space: pre;
   font-weight: 800;
 }
 
@@ -999,7 +1029,7 @@ a .normal-content {
   display: block;
 }
 
-#secondary-nav.album-secondary-nav.with-previous-next {
+#secondary-nav.album-secondary-nav {
   display: flex;
   justify-content: space-around;
   padding-left: 7.5% !important;
@@ -1017,7 +1047,8 @@ a .normal-content {
   margin-right: 5px;
 }
 
-#secondary-nav.album-secondary-nav .dot-switcher {
+#secondary-nav.album-secondary-nav .group-nav-links .dot-switcher,
+#secondary-nav.album-secondary-nav .series-nav-links .dot-switcher {
   white-space: nowrap;
 }
 
@@ -1069,7 +1100,17 @@ a .normal-content {
   text-decoration: none !important;
 }
 
+.text-with-tooltip.wiki-edits > .hoverable {
+  white-space: nowrap;
+}
+
+.isolate-tooltip-z-indexing > * {
+  position: relative;
+  z-index: -1;
+}
+
 .tooltip {
+  font-size: 1rem;
   position: absolute;
   z-index: 3;
   left: -10px;
@@ -1077,7 +1118,12 @@ a .normal-content {
   display: none;
 }
 
-li:not(:first-child:last-child) .tooltip,
+.cover-artwork .tooltip,
+#sidebar .tooltip {
+  font-size: 0.9rem;
+}
+
+li:not(:first-child:last-child) .tooltip:where(:not(.cover-artwork .tooltip)),
 .offset-tooltips > :not(:first-child:last-child) .tooltip {
   left: 14px;
 }
@@ -1119,18 +1165,23 @@ li:not(:first-child:last-child) .tooltip,
 .thing-name-tooltip,
 .wiki-edits-tooltip {
   padding: 3px 4px 2px 2px;
-  left: -6px !important;
+  left: -6px;
 }
 
-.wiki-edits-tooltip {
+.thing-name-tooltip .tooltip-content,
+.wiki-edits-tooltip .tooltip-content {
   font-size: 0.85em;
 }
 
-/* Terrifying?
- * https://stackoverflow.com/a/64424759/4633828
- */
-.thing-name-tooltip { margin-right: -120px; }
-.wiki-edits-tooltip { margin-right: -200px; }
+.thing-name-tooltip .tooltip-content {
+  width: max-content;
+  max-width: 120px;
+}
+
+.wiki-edits-tooltip .tooltip-content {
+  width: max-content;
+  max-width: 200px;
+}
 
 .contribution-tooltip .tooltip-content {
   padding: 6px 2px 2px 2px;
@@ -1366,12 +1417,9 @@ hr.cute,
   border-style: none none dotted none;
 }
 
-#cover-art-container {
+.cover-artwork {
   font-size: 0.8em;
   border: 2px solid var(--primary-color);
-  box-shadow:
-    0 2px 14px -6px var(--primary-color),
-    0 0 12px 12px #00000080;
 
   border-radius: 0 0 4px 4px;
   background: var(--bg-black-color);
@@ -1380,37 +1428,53 @@ hr.cute,
           backdrop-filter: blur(3px);
 }
 
-#cover-art-container:has(.image-details),
-#cover-art-container.has-image-details {
+.cover-artwork:has(.image-details),
+.cover-artwork.has-image-details {
   border-radius: 0 0 6px 6px;
 }
 
-#cover-art-container:not(:has(.image-details)),
-#cover-art-container:not(.has-image-details) {
+.cover-artwork:not(:has(.image-details)),
+.cover-artwork:not(.has-image-details) {
   /* Hacky: `overflow: hidden` hides tag tooltips, so it can't be applied
    * if we've got tags/details visible. But it's okay, because we only
    * need to apply it if it *doesn't* - that's when the rounded border
-   * of #cover-art-container needs to cut off its child image-container
+   * of the .cover-artwork needs to cut off its child .image-container
    * (which has a background that otherwise causes sharp corners).
    */
   overflow: hidden;
 }
 
-#cover-art-container .image-container {
-  /* Border is handled on the cover-art-container. */
+#artwork-column .cover-artwork {
+  box-shadow:
+    0 2px 14px -6px var(--primary-color),
+    0 0 12px 12px #00000080;
+}
+
+#artwork-column .cover-artwork:not(:first-child) {
+  margin-top: 20px;
+  margin-left: 30px;
+  margin-right: 5px;
+}
+
+#artwork-column .cover-artwork:last-child:not(:first-child) {
+  margin-bottom: 25px;
+}
+
+.cover-artwork .image-container {
+  /* Border is handled on the .cover-artwork. */
   border: none;
-  border-radius: 0;
+  border-radius: 0 !important;
 }
 
-#cover-art-container .image-details {
+.cover-artwork .image-details {
   border-top-color: var(--deep-color);
 }
 
-#cover-art-container .image-details + .image-details {
+.cover-artwork .image-details + .image-details {
   border-top-color: var(--primary-color);
 }
 
-#cover-art-container .image {
+.cover-artwork .image {
   display: block;
   width: 100%;
   height: 100%;
@@ -1453,6 +1517,10 @@ hr.cute,
   margin-bottom: 2px;
 }
 
+ul.image-details.art-tag-details {
+  padding-bottom: 0;
+}
+
 ul.image-details.art-tag-details li {
   display: inline-block;
 }
@@ -1461,23 +1529,40 @@ ul.image-details.art-tag-details li:not(:last-child)::after {
   content: " \00b7 ";
 }
 
-.image-details.non-unique-details {
-  font-style: oblique;
-}
-
 p.image-details.illustrator-details {
   text-align: center;
   font-style: oblique;
 }
 
+p.image-details.origin-details {
+  margin-bottom: 2px;
+}
+
+.album-art-info {
+  font-size: 0.8em;
+  border: 2px solid var(--deep-color);
+
+  margin: 10px min(15px, 1vw) 15px;
+
+  background: var(--bg-black-color);
+  padding: 6px;
+  border-radius: 5px;
+
+  -webkit-backdrop-filter: blur(3px);
+          backdrop-filter: blur(3px);
+}
+
+.album-art-info p {
+  margin: 0;
+}
+
+/*
 p.content-heading:has(+ .commentary-entry-heading.dated) {
   clear: right;
 }
+*/
 
 .commentary-entry-heading {
-  display: flex;
-  flex-direction: row;
-
   margin-left: 15px;
   padding-left: 5px;
   max-width: 625px;
@@ -1487,7 +1572,7 @@ p.content-heading:has(+ .commentary-entry-heading.dated) {
 }
 
 .commentary-entry-heading-text {
-  flex-grow: 1;
+  display: block;
   padding-left: 1.25ch;
   text-indent: -1.25ch;
 }
@@ -1496,20 +1581,6 @@ p.content-heading:has(+ .commentary-entry-heading.dated) {
   font-style: oblique;
 }
 
-.commentary-entry-heading .commentary-date {
-  flex-shrink: 0;
-
-  margin-left: 0.75ch;
-  align-self: flex-end;
-
-  padding-left: 0.5ch;
-  padding-right: 0.25ch;
-}
-
-.commentary-entry-heading .hoverable {
-  box-shadow: 1px 2px 6px 5px #04040460;
-}
-
 .commentary-entry-body summary {
   list-style-position: outside;
 }
@@ -1518,6 +1589,19 @@ p.content-heading:has(+ .commentary-entry-heading.dated) {
   color: var(--primary-color);
 }
 
+.commentary-date {
+  float: right;
+  margin-top: -0.8em;
+  margin-left: 0.75ch;
+  padding-left: 0.5ch;
+  padding-right: 0.4em;
+  font-size: 0.9em;
+}
+
+.commentary-date .hoverable {
+  box-shadow: 1px 2px 6px 5px #04040460;
+}
+
 .commentary-art {
   float: right;
   width: 30%;
@@ -1532,6 +1616,20 @@ p.content-heading:has(+ .commentary-entry-heading.dated) {
   box-shadow: 0 0 4px 5px rgba(0, 0, 0, 0.25) !important;
 }
 
+.lyrics-switcher {
+  padding-left: 20px;
+}
+
+.lyrics-switcher > span:not(:first-child)::before {
+  content: "\0020\00b7\0020";
+  font-weight: 800;
+}
+
+.lyrics-entry {
+  padding-left: 40px;
+  max-width: 600px;
+}
+
 .js-hide,
 .js-show-once-data,
 .js-hide-once-data {
@@ -1668,6 +1766,10 @@ ul.quick-info li:not(:last-child)::after {
   margin-top: 25px;
 }
 
+.gallery-set-switcher {
+  text-align: center;
+}
+
 .quick-description:not(.has-external-links-only) {
   --clamped-padding-ratio: max(var(--responsive-padding-ratio), 0.06);
   margin-left: auto;
@@ -1752,7 +1854,7 @@ li .by a {
   display: inline-block;
 }
 
-p code {
+p code, li code {
   font-size: 0.95em;
   font-family: "courier new", monospace;
   font-weight: 800;
@@ -2919,11 +3021,11 @@ h3.content-heading {
   top: 0;
 }
 
-.content-sticky-heading-anchor:not(:matches(.content-sticky-heading-root[inert]) *) {
+.content-sticky-heading-anchor:not(:where(.content-sticky-heading-root[inert]) *) {
   position: relative;
 }
 
-.content-sticky-heading-container:not(:matches(.content-sticky-heading-root[inert]) *) {
+.content-sticky-heading-container:not(:where(.content-sticky-heading-root[inert]) *) {
   position: absolute;
 }
 
@@ -3049,7 +3151,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   transition: transform 0.35s, opacity 0.30s;
 }
 
-.content-sticky-heading-cover .image-container {
+.content-sticky-heading-cover .cover-artwork {
   border-width: 1px;
   border-radius: 1.25px;
   box-shadow: none;
@@ -3380,7 +3482,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   /* Cover art floats to the right. It's positioned in HTML beneath the
    * heading, so pull it up a little to "float" on top.
    */
-  #cover-art-container {
+  #artwork-column {
     float: right;
     width: 40%;
     max-width: 400px;
@@ -3393,18 +3495,18 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   /* ...Except on top-indexes, where cover art is displayed prominently
    * between the heading and subheading.
    */
-  #content.top-index #cover-art-container {
+  #content.top-index #artwork-column {
     float: none;
     margin: 2em auto 2.5em auto;
     max-width: 375px;
   }
 
-  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:not(:nth-child(n+10)) {
+  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:not(:nth-child(n+7)) {
     flex-basis: 23%;
     margin: 15px;
   }
 
-  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:nth-child(n+10) {
+  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:nth-child(n+7) {
     flex-basis: 18%;
     margin: 10px;
   }
@@ -3500,7 +3602,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
     --responsive-padding-ratio: 0.02;
   }
 
-  #cover-art-container {
+  #artwork-column {
     margin: 25px 0 5px 0;
     width: 100%;
     max-width: unset;
diff --git a/src/static/js/client/additional-names-box.js b/src/static/js/client/additional-names-box.js
index 3535a0e5..195ba25d 100644
--- a/src/static/js/client/additional-names-box.js
+++ b/src/static/js/client/additional-names-box.js
@@ -33,7 +33,7 @@ export function getPageReferences() {
       '.content-sticky-heading-container' +
       ' ' +
       'a[href="#additional-names-box"]' +
-      ':not(:matches([inert] *))');
+      ':not(:where([inert] *))');
 
   info.contentContainer =
     document.querySelector('#content');
@@ -121,7 +121,7 @@ function handleAdditionalNamesBoxLinkClicked(domEvent) {
       ? top > 0.4 * window.innerHeight
       : top > 0.5 * window.innerHeight) ||
 
-    (bottom && bottomFitsInFrame
+    (bottom && boxFitsInFrame
       ? bottom > window.innerHeight - 20
       : false);
 
diff --git a/src/static/js/client/css-compatibility-assistant.js b/src/static/js/client/css-compatibility-assistant.js
index 6e7b15b5..aa637cc4 100644
--- a/src/static/js/client/css-compatibility-assistant.js
+++ b/src/static/js/client/css-compatibility-assistant.js
@@ -1,22 +1,30 @@
 /* eslint-env browser */
 
+import {stitchArrays} from '../../shared-util/sugar.js';
+
 export const info = {
   id: 'cssCompatibilityAssistantInfo',
 
-  coverArtContainer: null,
-  coverArtImageDetails: null,
+  coverArtworks: null,
+  coverArtworkImageDetails: null,
 };
 
 export function getPageReferences() {
-  info.coverArtContainer =
-    document.getElementById('cover-art-container');
+  info.coverArtworks =
+    Array.from(document.querySelectorAll('.cover-artwork'));
 
-  info.coverArtImageDetails =
-    info.coverArtContainer?.querySelector('.image-details');
+  info.coverArtworkImageDetails =
+    info.coverArtworks
+      .map(artwork => artwork.querySelector('.image-details'));
 }
 
 export function mutatePageContent() {
-  if (info.coverArtImageDetails) {
-    info.coverArtContainer.classList.add('has-image-details');
-  }
+  stitchArrays({
+    coverArtwork: info.coverArtworks,
+    imageDetails: info.coverArtworkImageDetails,
+  }).forEach(({coverArtwork, imageDetails}) => {
+      if (imageDetails) {
+        coverArtwork.classList.add('has-image-details');
+      }
+    });
 }
diff --git a/src/static/js/client/hoverable-tooltip.js b/src/static/js/client/hoverable-tooltip.js
index 484f2ab0..9569de3e 100644
--- a/src/static/js/client/hoverable-tooltip.js
+++ b/src/static/js/client/hoverable-tooltip.js
@@ -576,6 +576,17 @@ export function showTooltipFromHoverable(hoverable) {
 
   hoverable.classList.add('has-visible-tooltip');
 
+  const isolator =
+    hoverable.closest('.isolate-tooltip-z-indexing > *');
+
+  if (isolator) {
+    for (const child of isolator.parentElement.children) {
+      cssProp(child, 'z-index', null);
+    }
+
+    cssProp(isolator, 'z-index', '1');
+  }
+
   positionTooltipFromHoverableWithBrains(hoverable);
 
   cssProp(tooltip, 'display', 'block');
@@ -667,12 +678,12 @@ export function positionTooltipFromHoverableWithBrains(hoverable) {
 
     for (let i = 0; i < numBaselineRects; i++) {
       for (const [dir1, dir2] of [
+        ['down', 'right'],
+        ['down', 'left'],
         ['right', 'down'],
         ['left', 'down'],
         ['right', 'up'],
         ['left', 'up'],
-        ['down', 'right'],
-        ['down', 'left'],
         ['up', 'right'],
         ['up', 'left'],
       ]) {
@@ -995,6 +1006,14 @@ export function getTooltipBaselineOpportunityAreas(tooltip) {
   return results;
 }
 
+export function mutatePageContent() {
+  for (const isolatorRoot of document.querySelectorAll('.isolate-tooltip-z-indexing')) {
+    if (isolatorRoot.firstElementChild) {
+      cssProp(isolatorRoot.firstElementChild, 'z-index', '1');
+    }
+  }
+}
+
 export function addPageListeners() {
   const {state} = info;
 
diff --git a/src/static/js/client/image-overlay.js b/src/static/js/client/image-overlay.js
index da192178..e9e2708d 100644
--- a/src/static/js/client/image-overlay.js
+++ b/src/static/js/client/image-overlay.js
@@ -96,7 +96,10 @@ function handleContainerClicked(evt) {
   // If you clicked anything near the action bar, don't hide the
   // image overlay.
   const rect = info.actionContainer.getBoundingClientRect();
-  if (evt.clientY >= rect.top - 40 && evt.clientY <= rect.bottom + 40) {
+  if (
+    evt.clientY >= rect.top - 40 && evt.clientY <= rect.bottom + 40 &&
+    evt.clientX >= rect.left + 20 && evt.clientX <= rect.right - 20
+  ) {
     return;
   }
 
diff --git a/src/static/js/client/sticky-heading.js b/src/static/js/client/sticky-heading.js
index fba05b84..b65574d0 100644
--- a/src/static/js/client/sticky-heading.js
+++ b/src/static/js/client/sticky-heading.js
@@ -23,6 +23,7 @@ export const info = {
 
   contentContainers: null,
   contentHeadings: null,
+  contentCoverColumns: null,
   contentCovers: null,
   contentCoversReveal: null,
 
@@ -82,9 +83,13 @@ export function getPageReferences() {
     info.stickyContainers
       .map(el => el.closest('.content-sticky-heading-root').parentElement);
 
-  info.contentCovers =
+  info.contentCoverColumns =
     info.contentContainers
-      .map(el => el.querySelector('#cover-art-container'));
+      .map(el => el.querySelector('#artwork-column'));
+
+  info.contentCovers =
+    info.contentCoverColumns
+      .map(el => el ? el.querySelector('.cover-artwork') : null);
 
   info.contentCoversReveal =
     info.contentCovers
@@ -212,10 +217,10 @@ function updateCollapseStatus(index) {
 function updateStickyCoverVisibility(index) {
   const stickyCoverContainer = info.stickyCoverContainers[index];
   const stickyContainer = info.stickyContainers[index];
-  const contentCover = info.contentCovers[index];
+  const contentCoverColumn = info.contentCoverColumns[index];
 
-  if (contentCover && stickyCoverContainer) {
-    if (contentCover.getBoundingClientRect().bottom < 4) {
+  if (contentCoverColumn && stickyCoverContainer) {
+    if (contentCoverColumn.getBoundingClientRect().bottom < 4) {
       stickyCoverContainer.classList.add('visible');
       stickyContainer.classList.add('cover-visible');
     } else {
diff --git a/src/static/js/rectangles.js b/src/static/js/rectangles.js
index cdab2cb8..b00ed98e 100644
--- a/src/static/js/rectangles.js
+++ b/src/static/js/rectangles.js
@@ -510,4 +510,46 @@ export class WikiRect extends DOMRect {
       height: this.height,
     });
   }
+
+  // Other utilities
+
+  #display = null;
+
+  display() {
+    if (!this.#display) {
+      this.#display = document.createElement('div');
+      document.body.appendChild(this.#display);
+    }
+
+    Object.assign(this.#display.style, {
+      position: 'fixed',
+      background: '#000c',
+      border: '3px solid var(--primary-color)',
+      borderRadius: '4px',
+      top: this.top + 'px',
+      left: this.left + 'px',
+      width: this.width + 'px',
+      height: this.height + 'px',
+      pointerEvents: 'none',
+    });
+
+    let i = 0;
+    const int = setInterval(() => {
+      i++;
+      if (i >= 3) clearInterval(int);
+      if (!this.#display) return;
+
+      this.#display.style.display = 'none';
+      setTimeout(() => {
+        this.#display.style.display = '';
+      }, 200);
+    }, 600);
+  }
+
+  hide() {
+    if (this.#display) {
+      this.#display.remove();
+      this.#display = null;
+    }
+  }
 }
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index 259e01bb..7a40bd0d 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -275,20 +275,23 @@ releaseInfo:
 
   from: "From {ALBUM}."
 
-  coverArtBy: "Cover art by {ARTISTS}."
-  wallpaperArtBy: "Wallpaper art by {ARTISTS}."
-  bannerArtBy: "Banner art by {ARTISTS}."
+  wallpaperArtBy: "Wallpaper by {ARTISTS}"
+  bannerArtBy: "Banner by {ARTISTS}"
 
   released: "Released {DATE}."
   albumReleased: "Album released {DATE}."
-  artReleased: "Art released {DATE}."
   trackReleased: "Track released {DATE}."
   addedToWiki: "Added to wiki {DATE}."
 
   duration: "Duration: {DURATION}."
 
   contributors: "Contributors:"
-  lyrics: "Lyrics:"
+
+  lyrics:
+    _: "Lyrics:"
+
+    switcher: "({ENTRIES})"
+
   note: "Context notes:"
 
   alsoReleasedOn: "Also released on {ALBUMS}."
@@ -880,8 +883,6 @@ misc:
   socialEmbed:
     heading: "{WIKI_NAME} | {HEADING}"
 
-  trackArtFromAlbum: "Album cover for {ALBUM}"
-
   # jumpTo:
   #   Generic action displayed at the top of some longer pages, for
   #   quickly scrolling down to a particular section.
@@ -899,6 +900,27 @@ misc:
     warnings: "{WARNINGS}"
     reveal: "click to show"
 
+  # coverArtwork:
+  #   Generic or particular strings for artworks outside a grid
+  #   context, when just one cover is being spotlighted.
+
+  coverArtwork:
+    artworkBy: >-
+      Artwork by {ARTISTS}
+
+    artworkBy.customLabel: >-
+      {LABEL} by {ARTISTS}
+
+    artworkBy.withYear: >-
+      Artwork ({YEAR}) by {ARTISTS}
+
+    artworkBy.customLabel.withYear: >-
+      {LABEL} ({YEAR}) by {ARTISTS}
+
+    source: "Via {SOURCE}"
+
+    trackArtFromAlbum: "Album cover for {ALBUM}"
+
   # coverGrid:
   #   Generic strings for various sorts of gallery grids, displayed
   #   on the homepage, album galleries, artist artwork galleries, and
@@ -972,7 +994,9 @@ albumSidebar:
 
     group:
       _: "{GROUP}"
-      withRange: "{GROUP} ({RANGE})"
+
+      withRange: "{GROUP} {RANGE_PART}"
+      withRange.rangePart: "({RANGE})"
 
   # groupBox:
   #   This is the box for groups. Apart from the next and previous
@@ -1089,6 +1113,15 @@ albumGalleryPage:
   noTrackArtworksLine: >-
     This album doesn't have any track artwork.
 
+  # setSwitcher:
+  #   This is displayed if multiple sets of artwork are available
+  #   across the album.
+
+  setSwitcher:
+    _: "({SETS})"
+
+    unlabeledSet: "Main album art"
+
 #
 # albumCommentaryPage:
 #   The album commentary page is a more minimal layout that brings
diff --git a/src/upd8.js b/src/upd8.js
index a9929154..86ecab69 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -68,8 +68,9 @@ import {
 
 import {
   filterReferenceErrors,
-  reportDirectoryErrors,
   reportContentTextErrors,
+  reportDirectoryErrors,
+  reportOrphanedArtworks,
 } from '#data-checks';
 
 import {
@@ -175,6 +176,10 @@ async function main() {
       {...defaultStepStatus, name: `report directory errors`,
         for: ['verify']},
 
+    reportOrphanedArtworks:
+      {...defaultStepStatus, name: `report orphaned artworks`,
+        for: ['verify']},
+
     filterReferenceErrors:
       {...defaultStepStatus, name: `filter reference errors`,
         for: ['verify']},
@@ -399,6 +404,11 @@ async function main() {
       type: 'flag',
     },
 
+    'skip-orphaned-artwork-validation': {
+      help: `Skips checking for internally orphaned artworks, which is a bad idea, unless you're debugging those in particular`,
+      type: 'flag',
+    },
+
     'skip-reference-validation': {
       help: `Skips checking and reporting reference errors, which speeds up the build but may silently allow erroneous data to pass through`,
       type: 'flag',
@@ -843,6 +853,16 @@ async function main() {
       },
     });
 
+    fallbackStep('reportOrphanedArtworks', {
+      default: 'perform',
+      cli: {
+        flag: 'skip-orphaned-artwork-validation',
+        negate: true,
+        warn:
+          `Skipping orphaned artwork validation. Hopefully you're debugging!`,
+      },
+    });
+
     fallbackStep('filterReferenceErrors', {
       default: 'perform',
       cli: {
@@ -1736,8 +1756,8 @@ async function main() {
     });
   }
 
-  // Filter out any things with duplicate directories throughout the data,
-  // warning about them too.
+  // Check for things with duplicate directories throughout the data,
+  // and halt if any are found.
 
   if (stepStatusSummary.reportDirectoryErrors.status === STATUS_NOT_STARTED) {
     Object.assign(stepStatusSummary.reportDirectoryErrors, {
@@ -1778,8 +1798,42 @@ async function main() {
     }
   }
 
-  // Filter out any reference errors throughout the data, warning about them
-  // too.
+  // Check for artwork objects which have been orphaned from their things,
+  // and halt if any are found.
+
+  if (stepStatusSummary.reportOrphanedArtworks.status === STATUS_NOT_STARTED) {
+    Object.assign(stepStatusSummary.reportOrphanedArtworks, {
+      status: STATUS_STARTED_NOT_DONE,
+      timeStart: Date.now(),
+    });
+
+    try {
+      reportOrphanedArtworks(wikiData, {getAllFindSpecs});
+
+      Object.assign(stepStatusSummary.reportOrphanedArtworks, {
+        status: STATUS_DONE_CLEAN,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+    } catch (aggregate) {
+      if (!paragraph) console.log('');
+      niceShowAggregate(aggregate);
+
+      logError`Failed to initialize artwork data connections properly.`;
+      fileIssue();
+
+      Object.assign(stepStatusSummary.reportOrphanedArtworks, {
+        status: STATUS_FATAL_ERROR,
+        annotation: `orphaned artworks found`,
+        timeEnd: Date.now(),
+        memory: process.memoryUsage(),
+      });
+
+      return false;
+    }
+  }
+
+  // Filter out any reference errors throughout the data, warning about these.
 
   if (stepStatusSummary.filterReferenceErrors.status === STATUS_NOT_STARTED) {
     Object.assign(stepStatusSummary.filterReferenceErrors, {
diff --git a/src/urls-default.yaml b/src/urls-default.yaml
index c3bf89eb..74225efd 100644
--- a/src/urls-default.yaml
+++ b/src/urls-default.yaml
@@ -11,7 +11,7 @@ yamlAliases:
   # part of a build. This is so that multiple builds of a wiki can coexist
   # served from the same server / file system root: older builds' HTML files
   # refer to earlier values of STATIC_VERSION, avoiding name collisions.
-  - &staticVersion 4p1
+  - &staticVersion 5p1
 
 data:
   prefix: 'data/'
diff --git a/src/urls.js b/src/urls.js
index 5e334c1e..9cc4a554 100644
--- a/src/urls.js
+++ b/src/urls.js
@@ -283,9 +283,14 @@ export function getURLsFrom({
       to = targetFullKey;
     }
 
-    return (
-      subdirectoryPrefix +
-      urls.from(from).to(to, ...args));
+    const toResult =
+      urls.from(from).to(to, ...args);
+
+    if (getOrigin(toResult)) {
+      return toResult;
+    } else {
+      return subdirectoryPrefix + toResult;
+    }
   };
 }
 
diff --git a/src/validators.js b/src/validators.js
index 3b23e8f6..5b8227fb 100644
--- a/src/validators.js
+++ b/src/validators.js
@@ -3,8 +3,12 @@ import {inspect as nodeInspect} from 'node:util';
 import {openAggregate, withAggregate} from '#aggregate';
 import {colors, ENABLE_COLOR} from '#cli';
 import {cut, empty, matchMultiline, typeAppearance} from '#sugar';
-import {commentaryRegexCaseInsensitive, commentaryRegexCaseSensitiveOneShot}
-  from '#wiki-data';
+
+import {
+  commentaryRegexCaseInsensitive,
+  commentaryRegexCaseSensitiveOneShot,
+  multipleLyricsDetectionRegex,
+} from '#wiki-data';
 
 function inspect(value) {
   return nodeInspect(value, {colors: ENABLE_COLOR});
@@ -288,69 +292,108 @@ export function isColor(color) {
   throw new TypeError(`Unknown color format`);
 }
 
-export function isCommentary(commentaryText) {
-  isContentString(commentaryText);
-
-  const rawMatches =
-    Array.from(commentaryText.matchAll(commentaryRegexCaseInsensitive));
+export function validateContentEntries({
+  headingPhrase,
+  entryPhrase,
 
-  if (empty(rawMatches)) {
-    throw new TypeError(`Expected at least one commentary heading`);
-  }
+  caseInsensitiveRegex,
+  caseSensitiveOneShotRegex,
+}) {
+  return content => {
+    isContentString(content);
 
-  const niceMatches =
-    rawMatches.map(match => ({
-      position: match.index,
-      length: match[0].length,
-    }));
+    const rawMatches =
+      Array.from(content.matchAll(caseInsensitiveRegex));
 
-  validateArrayItems(({position, length}, index) => {
-    if (index === 0 && position > 0) {
-      throw new TypeError(`Expected first commentary heading to be at top`);
+    if (empty(rawMatches)) {
+      throw new TypeError(`Expected at least one ${headingPhrase}`);
     }
 
-    const ownInput = commentaryText.slice(position, position + length);
-    const restOfInput = commentaryText.slice(position + length);
+    const niceMatches =
+      rawMatches.map(match => ({
+        position: match.index,
+        length: match[0].length,
+      }));
 
-    const upToNextLineBreak =
-      (restOfInput.includes('\n')
-        ? restOfInput.slice(0, restOfInput.indexOf('\n'))
-        : restOfInput);
+    validateArrayItems(({position, length}, index) => {
+      if (index === 0 && position > 0) {
+        throw new TypeError(`Expected first ${headingPhrase} to be at top`);
+      }
 
-    if (/\S/.test(upToNextLineBreak)) {
-      throw new TypeError(
-        `Expected commentary heading to occupy entire line, got extra text:\n` +
-        `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` +
-        `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` +
-        `(Check for missing "|-" in YAML, or a misshapen annotation)`);
-    }
+      const ownInput = content.slice(position, position + length);
+      const restOfInput = content.slice(position + length);
 
-    if (!commentaryRegexCaseSensitiveOneShot.test(ownInput)) {
-      throw new TypeError(
-        `Miscapitalization in commentary heading:\n` +
-        `${colors.red(`"${cut(ownInput, 60)}"`)}\n` +
-        `(Check for ${colors.red(`"<I>"`)} instead of ${colors.green(`"<i>"`)})`);
-    }
+      const upToNextLineBreak =
+        (restOfInput.includes('\n')
+          ? restOfInput.slice(0, restOfInput.indexOf('\n'))
+          : restOfInput);
 
-    const nextHeading =
-      (index === niceMatches.length - 1
-        ? commentaryText.length
-        : niceMatches[index + 1].position);
+      if (/\S/.test(upToNextLineBreak)) {
+        throw new TypeError(
+          `Expected ${headingPhrase} to occupy entire line, got extra text:\n` +
+          `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` +
+          `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` +
+          `(Check for missing "|-" in YAML, or a misshapen annotation)`);
+      }
 
-    const upToNextHeading =
-      commentaryText.slice(position + length, nextHeading);
+      if (!caseSensitiveOneShotRegex.test(ownInput)) {
+        throw new TypeError(
+          `Miscapitalization in ${headingPhrase}:\n` +
+          `${colors.red(`"${cut(ownInput, 60)}"`)}\n` +
+          `(Check for ${colors.red(`"<I>"`)} instead of ${colors.green(`"<i>"`)})`);
+      }
 
-    if (!/\S/.test(upToNextHeading)) {
-      throw new TypeError(
-        `Expected commentary entry to have body text, only got a heading`);
-    }
+      const nextHeading =
+        (index === niceMatches.length - 1
+          ? content.length
+          : niceMatches[index + 1].position);
+
+      const upToNextHeading =
+        content.slice(position + length, nextHeading);
+
+      if (!/\S/.test(upToNextHeading)) {
+        throw new TypeError(
+          `Expected ${entryPhrase} to have body text, only got a heading`);
+      }
+
+      return true;
+    })(niceMatches);
 
     return true;
-  })(niceMatches);
+  };
+}
+
+export const isCommentary =
+  validateContentEntries({
+    headingPhrase: `commentary heading`,
+    entryPhrase: `commentary entry`,
+
+    caseInsensitiveRegex: commentaryRegexCaseInsensitive,
+    caseSensitiveOneShotRegex: commentaryRegexCaseSensitiveOneShot,
+  });
+
+export function isOldStyleLyrics(content) {
+  isContentString(content);
+
+  if (multipleLyricsDetectionRegex.test(content)) {
+    throw new TypeError(
+      `Expected old-style lyrics block not to include "<i> ... :</i>" at start of any line`);
+  }
 
   return true;
 }
 
+export const isLyrics =
+  anyOf(
+    isOldStyleLyrics,
+    validateContentEntries({
+      headingPhrase: `lyrics heading`,
+      entryPhrase: `lyrics entry`,
+
+      caseInsensitiveRegex: commentaryRegexCaseInsensitive,
+      caseSensitiveOneShotRegex: commentaryRegexCaseSensitiveOneShot,
+    }));
+
 const isArtistRef = validateReference('artist');
 
 export function validateProperties(spec) {