« 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--src/common-util/sort.js16
-rw-r--r--src/content/dependencies/generateAlbumArtInfoBox.js16
-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/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/generateArtistGalleryPage.js150
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js37
-rw-r--r--src/content/dependencies/generateCoverArtwork.js15
-rw-r--r--src/content/dependencies/generateCoverGrid.js11
-rw-r--r--src/content/dependencies/generateFlashActGalleryPage.js16
-rw-r--r--src/content/dependencies/generateFlashIndexPage.js17
-rw-r--r--src/content/dependencies/generateGroupGalleryPage.js33
-rw-r--r--src/content/dependencies/generateReferencedArtworksPage.js24
-rw-r--r--src/content/dependencies/generateReferencingArtworksPage.js24
-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/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/data/composite/wiki-data/withConstitutedArtwork.js14
-rw-r--r--src/data/composite/wiki-properties/constitutibleArtwork.js8
-rw-r--r--src/data/composite/wiki-properties/constitutibleArtworkList.js8
-rw-r--r--src/data/things/album.js2
-rw-r--r--src/data/things/art-tag.js21
-rw-r--r--src/data/things/artwork.js51
-rw-r--r--src/data/things/track.js2
-rw-r--r--src/data/yaml.js14
-rw-r--r--src/static/css/site.css4
-rw-r--r--src/strings-default.yaml9
-rw-r--r--src/urls.js11
37 files changed, 664 insertions, 602 deletions
diff --git a/src/common-util/sort.js b/src/common-util/sort.js
index 3cfe8f70..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,
diff --git a/src/content/dependencies/generateAlbumArtInfoBox.js b/src/content/dependencies/generateAlbumArtInfoBox.js
index f0bfd1b6..8c44c930 100644
--- a/src/content/dependencies/generateAlbumArtInfoBox.js
+++ b/src/content/dependencies/generateAlbumArtInfoBox.js
@@ -4,12 +4,16 @@ export default {
 
   relations: (relation, album) => ({
     wallpaperArtistContributionsLine:
-      relation('generateReleaseInfoContributionsLine',
-        album.wallpaperArtistContribs),
+      (album.wallpaperArtwork
+        ? relation('generateReleaseInfoContributionsLine',
+            album.wallpaperArtwork.artistContribs)
+        : null),
 
     bannerArtistContributionsLine:
-      relation('generateReleaseInfoContributionsLine',
-        album.bannerArtistContribs),
+      (album.bannerArtwork
+        ? relation('generateReleaseInfoContributionsLine',
+            album.bannerArtwork.artistContribs)
+        : null),
   }),
 
   generate: (relations, {html, language}) =>
@@ -22,12 +26,12 @@ export default {
           {[html.joinChildren]: html.tag('br')},
 
           [
-            relations.wallpaperArtistContributionsLine.slots({
+            relations.wallpaperArtistContributionsLine?.slots({
               stringKey: capsule + '.wallpaperArtBy',
               chronologyKind: 'wallpaperArt',
             }),
 
-            relations.bannerArtistContributionsLine.slots({
+            relations.bannerArtistContributionsLine?.slots({
               stringKey: capsule + '.bannerArtBy',
               chronologyKind: 'bannerArt',
             }),
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/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 d51700d4..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,
-      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/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js
index 38696c9c..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,
-    });
-
-    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 bd5e537a..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',
@@ -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'),
@@ -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',
 
-        coverColumnContent:
-          (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/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index 71c0747d..3a10ab20 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -11,7 +11,7 @@ export default {
 
   relations: (relation, artwork) => ({
     image:
-      relation('image'),
+      relation('image', artwork),
 
     originDetails:
       relation('generateCoverArtworkOriginDetails', artwork),
@@ -28,18 +28,10 @@ export default {
 
   data: (artwork) => ({
     color:
-      artwork.thing.color,
-
-    path:
-      artwork.path,
+      artwork.thing.color ?? null,
 
     dimensions:
       artwork.dimensions,
-
-    warnings:
-      artwork.artTags
-        .filter(tag => tag.isContentWarning)
-        .map(tag => tag.name),
   }),
 
   slots: {
@@ -69,9 +61,6 @@ export default {
     const {image} = relations;
 
     image.setSlots({
-      path: data.path,
-      warnings: data.warnings,
-
       color: slots.color ?? data.color,
       alt: slots.alt,
     });
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/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/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/generateReferencedArtworksPage.js b/src/content/dependencies/generateReferencedArtworksPage.js
index 5733631d..154b4762 100644
--- a/src/content/dependencies/generateReferencedArtworksPage.js
+++ b/src/content/dependencies/generateReferencedArtworksPage.js
@@ -1,5 +1,3 @@
-import {stitchArrays} from '#sugar';
-
 export default {
   contentDependencies: [
     'generateCoverArtwork',
@@ -27,7 +25,7 @@ export default {
 
     images:
       artwork.referencedArtworks.map(({artwork}) =>
-        relation('image', artwork.artTags)),
+        relation('image', artwork)),
   }),
 
   data: (artwork) => ({
@@ -41,14 +39,6 @@ export default {
       artwork.referencedArtworks
         .map(({artwork}) => artwork.thing.name),
 
-    paths:
-      artwork.referencedArtworks
-        .map(({artwork}) => artwork.path),
-
-    dimensions:
-      artwork.referencedArtworks
-        .map(({artwork}) => artwork.dimensions),
-
     coverArtistNames:
       artwork.referencedArtworks
         .map(({artwork}) =>
@@ -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 520a33c3..55977b37 100644
--- a/src/content/dependencies/generateReferencingArtworksPage.js
+++ b/src/content/dependencies/generateReferencingArtworksPage.js
@@ -1,5 +1,3 @@
-import {stitchArrays} from '#sugar';
-
 export default {
   contentDependencies: [
     'generateCoverArtwork',
@@ -27,7 +25,7 @@ export default {
 
     images:
       artwork.referencedByArtworks.map(({artwork}) =>
-        relation('image', artwork.artTags)),
+        relation('image', artwork)),
   }),
 
   data: (artwork) => ({
@@ -41,14 +39,6 @@ export default {
       artwork.referencedByArtworks
         .map(({artwork}) => artwork.thing.name),
 
-    paths:
-      artwork.referencedByArtworks
-        .map(({artwork}) => artwork.path),
-
-    dimensions:
-      artwork.referencedByArtworks
-        .map(({artwork}) => artwork.dimensions),
-
     coverArtistNames:
       artwork.referencedByArtworks
         .map(({artwork}) =>
@@ -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/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/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/data/composite/wiki-data/withConstitutedArtwork.js b/src/data/composite/wiki-data/withConstitutedArtwork.js
index 44623450..9e260abf 100644
--- a/src/data/composite/wiki-data/withConstitutedArtwork.js
+++ b/src/data/composite/wiki-data/withConstitutedArtwork.js
@@ -8,9 +8,11 @@ export default templateCompositeFrom({
   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}),
-    dateFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artTagsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}),
   },
 
   outputs: ['#constitutedArtwork'],
@@ -21,18 +23,22 @@ export default templateCompositeFrom({
         input.myself(),
         input('dimensionsFromThingProperty'),
         input('fileExtensionFromThingProperty'),
+        input('dateFromThingProperty'),
         input('artistContribsFromThingProperty'),
         input('artistContribsArtistProperty'),
-        input('dateFromThingProperty'),
+        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('dateFromThingProperty')]: dateFromThingProperty,
+        [input('artTagsFromThingProperty')]: artTagsFromThingProperty,
+        [input('referencedArtworksFromThingProperty')]: referencedArtworksFromThingProperty,
       }) => continuation({
         ['#constitutedArtwork']:
           Object.assign(new thingConstructors.Artwork, {
@@ -41,7 +47,9 @@ export default templateCompositeFrom({
             fileExtensionFromThingProperty,
             artistContribsFromThingProperty,
             artistContribsArtistProperty,
+            artTagsFromThingProperty,
             dateFromThingProperty,
+            referencedArtworksFromThingProperty,
           }),
       }),
     },
diff --git a/src/data/composite/wiki-properties/constitutibleArtwork.js b/src/data/composite/wiki-properties/constitutibleArtwork.js
index 9f7ba13e..0ee3bfcd 100644
--- a/src/data/composite/wiki-properties/constitutibleArtwork.js
+++ b/src/data/composite/wiki-properties/constitutibleArtwork.js
@@ -19,9 +19,11 @@ const template = templateCompositeFrom({
   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}),
-    dateFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artTagsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}),
   },
 
   steps: () => [
@@ -35,9 +37,11 @@ const template = templateCompositeFrom({
     withConstitutedArtwork({
       dimensionsFromThingProperty: input('dimensionsFromThingProperty'),
       fileExtensionFromThingProperty: input('fileExtensionFromThingProperty'),
+      dateFromThingProperty: input('dateFromThingProperty'),
       artistContribsFromThingProperty: input('artistContribsFromThingProperty'),
       artistContribsArtistProperty: input('artistContribsArtistProperty'),
-      dateFromThingProperty: input('dateFromThingProperty'),
+      artTagsFromThingProperty: input('artTagsFromThingProperty'),
+      referencedArtworksFromThingProperty: input('referencedArtworksFromThingProperty'),
     }),
 
     exposeDependency({
diff --git a/src/data/composite/wiki-properties/constitutibleArtworkList.js b/src/data/composite/wiki-properties/constitutibleArtworkList.js
index 29e6c774..246c08b5 100644
--- a/src/data/composite/wiki-properties/constitutibleArtworkList.js
+++ b/src/data/composite/wiki-properties/constitutibleArtworkList.js
@@ -18,9 +18,11 @@ const template = templateCompositeFrom({
   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}),
-    dateFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artTagsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}),
   },
 
   steps: () => [
@@ -34,9 +36,11 @@ const template = templateCompositeFrom({
     withConstitutedArtwork({
       dimensionsFromThingProperty: input('dimensionsFromThingProperty'),
       fileExtensionFromThingProperty: input('fileExtensionFromThingProperty'),
+      dateFromThingProperty: input('dateFromThingProperty'),
       artistContribsFromThingProperty: input('artistContribsFromThingProperty'),
       artistContribsArtistProperty: input('artistContribsArtistProperty'),
-      dateFromThingProperty: input('dateFromThingProperty'),
+      artTagsFromThingProperty: input('artTagsFromThingProperty'),
+      referencedArtworksFromThingProperty: input('referencedArtworksFromThingProperty'),
     }),
 
     {
diff --git a/src/data/things/album.js b/src/data/things/album.js
index 7c85366a..e8106e24 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -505,6 +505,8 @@ export class Album extends Thing {
             dateFromThingProperty: 'coverArtDate',
             artistContribsFromThingProperty: 'coverArtistContribs',
             artistContribsArtistProperty: 'albumCoverArtistContributions',
+            artTagsFromThingProperty: 'artTags',
+            referencedArtworksFromThingProperty: 'referencedArtworks',
           }),
       },
 
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
index 7944beb0..57e156ee 100644
--- a/src/data/things/art-tag.js
+++ b/src/data/things/art-tag.js
@@ -92,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}),
-      },
-    },
+    directlyFeaturedInArtworks: reverseReferenceList({
+      reverse: soupyReverse.input('artworksWhichFeature'),
+    }),
 
-    indirectlyTaggedInThings: [
+    indirectlyFeaturedInArtworks: [
       withAllDescendantArtTags(),
 
       {
@@ -115,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/artwork.js b/src/data/things/artwork.js
index 65032d86..2a97fd6d 100644
--- a/src/data/things/artwork.js
+++ b/src/data/things/artwork.js
@@ -3,7 +3,6 @@ import {inspect} from 'node:util';
 import {input} from '#composite';
 import find from '#find';
 import Thing from '#thing';
-import {parseAnnotatedReferences, parseContributors, parseDate} from '#yaml';
 
 import {
   isContentString,
@@ -18,6 +17,13 @@ import {
   validateReferenceList,
 } from '#validators';
 
+import {
+  parseAnnotatedReferences,
+  parseContributors,
+  parseDate,
+  parseDimensions,
+} from '#yaml';
+
 import {withPropertyFromObject} from '#composite/data';
 
 import {
@@ -132,6 +138,11 @@ export class Artwork extends Thing {
         ['#value']: '#dimensionsFromThing',
       }),
 
+      exitWithoutDependency({
+        dependency: 'dimensionsFromThingProperty',
+        value: input.value(null),
+      }),
+
       exposeDependencyOrContinue({
         dependency: '#dimensionsFromThing',
       }),
@@ -179,6 +190,8 @@ export class Artwork extends Thing {
       }),
     ],
 
+    artTagsFromThingProperty: simpleString(),
+
     artTags: [
       withResolvedReferenceList({
         list: input.updateValue({
@@ -194,13 +207,20 @@ export class Artwork extends Thing {
         mode: input.value('empty'),
       }),
 
+      exitWithoutDependency({
+        dependency: 'artTagsFromThingProperty',
+        value: input.value([]),
+      }),
+
       withPropertyFromObject({
         object: 'thing',
-        property: input.value('artTags'),
+        property: 'artTagsFromThingProperty',
+      }).outputs({
+        ['#value']: '#artTags',
       }),
 
       exposeDependencyOrContinue({
-        dependency: '#thing.artTags',
+        dependency: '#artTags',
       }),
 
       exposeConstant({
@@ -208,6 +228,8 @@ export class Artwork extends Thing {
       }),
     ],
 
+    referencedArtworksFromThingProperty: simpleString(),
+
     referencedArtworks: [
       {
         compute: (continuation) => continuation({
@@ -244,13 +266,20 @@ export class Artwork extends Thing {
         mode: input.value('empty'),
       }),
 
+      exitWithoutDependency({
+        dependency: 'referencedArtworksFromThingProperty',
+        value: input.value([]),
+      }),
+
       withPropertyFromObject({
         object: 'thing',
-        property: input.value('referencedArtworks'),
+        property: 'referencedArtworksFromThingProperty',
+      }).outputs({
+        ['#value']: '#referencedArtworks',
       }),
 
       exposeDependencyOrContinue({
-        dependency: '#thing.referencedArtworks',
+        dependency: '#referencedArtworks',
       }),
 
       exposeConstant({
@@ -280,6 +309,11 @@ export class Artwork extends Thing {
       'Directory': {property: 'unqualifiedDirectory'},
       'File Extension': {property: 'fileExtension'},
 
+      'Dimensions': {
+        property: 'dimensions',
+        transform: parseDimensions,
+      },
+
       'Label': {property: 'label'},
       'Source': {property: 'source'},
 
@@ -323,6 +357,13 @@ export class Artwork extends Thing {
 
       date: ({artwork}) => artwork.date,
     },
+
+    artworksWhichFeature: {
+      bindTo: 'artworkData',
+
+      referencing: artwork => [artwork],
+      referenced: artwork => artwork.artTags,
+    },
   };
 
   get path() {
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 2d2cc002..ca1d69af 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -533,6 +533,8 @@ export class Track extends Thing {
             dimensionsFromThingProperty: 'coverArtDimensions',
             fileExtensionFromThingProperty: 'coverArtFileExtension',
             dateFromThingProperty: 'coverArtDate',
+            artTagsFromThingProperty: 'artTags',
+            referencedArtworksFromThingProperty: 'referencedArtworks',
             artistContribsFromThingProperty: 'coverArtistContribs',
             artistContribsArtistProperty: 'trackCoverArtistContributions',
           }),
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 07dbe882..50317238 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -791,11 +791,13 @@ export function parseAnnotatedReferences(entries, {
 
 export function parseArtwork({
   single = false,
-  dimensionsFromThingProperty,
-  fileExtensionFromThingProperty,
-  dateFromThingProperty,
-  artistContribsFromThingProperty,
-  artistContribsArtistProperty,
+  dimensionsFromThingProperty = null,
+  fileExtensionFromThingProperty = null,
+  dateFromThingProperty = null,
+  artistContribsFromThingProperty = null,
+  artistContribsArtistProperty = null,
+  artTagsFromThingProperty = null,
+  referencedArtworksFromThingProperty = null,
 }) {
   const provide = {
     dimensionsFromThingProperty,
@@ -803,6 +805,8 @@ export function parseArtwork({
     dateFromThingProperty,
     artistContribsFromThingProperty,
     artistContribsArtistProperty,
+    artTagsFromThingProperty,
+    referencedArtworksFromThingProperty,
   };
 
   const parseSingleEntry = (entry, {subdoc, Artwork}) =>
diff --git a/src/static/css/site.css b/src/static/css/site.css
index e4057620..ab86915c 100644
--- a/src/static/css/site.css
+++ b/src/static/css/site.css
@@ -1746,6 +1746,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;
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index 751d8279..7d50dbb3 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -1108,6 +1108,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/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;
+    }
   };
 }