« 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/wiki-data.js54
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js36
-rw-r--r--src/content/dependencies/generateArtistInfoPageChunk.js47
-rw-r--r--src/content/dependencies/generateArtistInfoPageMusicVideosChunk.js58
-rw-r--r--src/content/dependencies/generateArtistInfoPageMusicVideosChunkItem.js118
-rw-r--r--src/content/dependencies/generateArtistInfoPageMusicVideosChunkedList.js66
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunk.js17
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkItem.js65
-rw-r--r--src/content/dependencies/generateTrackListItem.js115
-rw-r--r--src/content/dependencies/generateTrackListMissingDuration.js37
-rw-r--r--src/data/composite/wiki-data/helpers/withResolvedReverse.js2
-rw-r--r--src/data/thing.js67
-rw-r--r--src/data/things/Artist.js33
-rw-r--r--src/data/things/Artwork.js2
-rw-r--r--src/data/things/MusicVideo.js38
-rw-r--r--src/data/things/contrib/Contribution.js14
-rw-r--r--src/reverse.js7
-rw-r--r--src/strings-default.yaml215
18 files changed, 813 insertions, 178 deletions
diff --git a/src/common-util/wiki-data.js b/src/common-util/wiki-data.js
index 21e15725..14ae8e96 100644
--- a/src/common-util/wiki-data.js
+++ b/src/common-util/wiki-data.js
@@ -262,17 +262,14 @@ export function getArtistAvatar(artist, {to}) {
 // Used in multiple content functions for the artist info page,
 // because shared logic is torture oooooooooooooooo.
 export function chunkArtistTrackContributions(contributions) {
-  const date = contrib => contrib.date;
-
   const album = contrib =>
     (contrib.thing.isTrack
       ? contrib.thing.album
       : contrib.thing);
 
   return (
-    // First chunk by (contribution) date and album.
+    // First chunk by (contribution) album.
     chunkByConditions(contributions, [
-      (a, b) => +date(a) !== +date(b),
       (a, b) => album(a) !== album(b),
     ]).map(contribs =>
         // Then, *within* the boundaries of the existing chunks,
@@ -283,6 +280,55 @@ export function chunkArtistTrackContributions(contributions) {
         ])));
 }
 
+// Ditto. More shared logic for the artist page.
+export function selectRepresentativeArtistContributorContribs(contribs) {
+  const creditedAsNormalArtist =
+    contribs
+      .some(contrib =>
+        contrib.thingProperty === 'artistContribs' &&
+       !contrib.isFeaturingCredit);
+
+  const creditedAsContributor =
+    contribs
+      .some(contrib => contrib.thingProperty === 'contributorContribs');
+
+  const annotatedContribs =
+    contribs
+      .filter(contrib => !empty(contrib.annotationParts));
+
+  const annotatedArtistContribs =
+    annotatedContribs
+      .filter(contrib => contrib.thingProperty === 'artistContribs');
+
+  const annotatedContributorContribs =
+    annotatedContribs
+      .filter(contrib => contrib.thingProperty === 'contributorContribs');
+
+  // Don't display annotations associated with crediting in the
+  // Contributors field if the artist is also credited as an Artist
+  // *and* the Artist-field contribution is non-annotated. This is
+  // so that we don't misrepresent the artist - the contributor
+  // annotation tends to be for "secondary" and performance roles.
+  // For example, this avoids crediting Marcy Nabors on Renewed
+  // Return seemingly only for "bass clarinet" when they're also
+  // the one who composed and arranged Renewed Return!
+  if (
+    creditedAsNormalArtist &&
+    creditedAsContributor &&
+    empty(annotatedArtistContribs)
+  ) {
+    return null;
+  } else if (
+    !empty(annotatedArtistContribs) ||
+    !empty(annotatedContributorContribs)
+  ) {
+    return [
+      ...annotatedArtistContribs,
+      ...annotatedContributorContribs,
+    ];
+  }
+}
+
 // Big-ass homepage row functions
 
 export function getNewAdditions(numAlbums, {albumData}) {
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
index cf8ce994..29bc34e6 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -14,6 +14,11 @@ export default {
       ...artist.trackCoverArtistContributions,
     ],
 
+    musicVideoContributions: [
+      ...artist.musicVideoArtistContributions,
+      ...artist.musicVideoContributorContributions,
+    ],
+
     // Banners and wallpapers don't show up in the artist gallery page, only
     // cover art.
     hasGallery:
@@ -79,6 +84,12 @@ export default {
         ? relation('linkArtistGallery', artist)
         : null),
 
+    musicVideosChunkedList:
+      relation('generateArtistInfoPageMusicVideosChunkedList', artist),
+
+    musicVideosGroupInfo:
+      relation('generateArtistGroupContributionsInfo', query.musicVideoContributions),
+
     flashesChunkedList:
       relation('generateArtistInfoPageFlashesChunkedList', artist),
 
@@ -216,6 +227,11 @@ export default {
                         {href: '#art'},
                         language.$(pageCapsule, 'artList.title')),
 
+                  !html.isBlank(relations.musicVideosChunkedList) &&
+                    html.tag('a',
+                      {href: '#music-videos'},
+                      language.$(pageCapsule, 'musicVideoList.title')),
+
                   !html.isBlank(relations.flashesChunkedList) &&
                     html.tag('a',
                       {href: '#flashes'},
@@ -329,6 +345,26 @@ export default {
             relations.contentHeading.clone()
               .slots({
                 tag: 'h2',
+                attributes: {id: 'music-videos'},
+                title: language.$(pageCapsule, 'musicVideoList.title'),
+              }),
+
+            relations.musicVideosChunkedList.slots({
+              groupInfo:
+                language.encapsulate(pageCapsule, 'groupContributions', capsule =>
+                  relations.musicVideosGroupInfo.slots({
+                    title: language.$(capsule, 'title.artworks'),
+                    showBothColumns: false,
+                    sort: 'count',
+                    countUnit: 'artworks',
+                  })),
+            }),
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                tag: 'h2',
                 attributes: {id: 'flashes'},
                 title: language.$(pageCapsule, 'flashList.title'),
               }),
diff --git a/src/content/dependencies/generateArtistInfoPageChunk.js b/src/content/dependencies/generateArtistInfoPageChunk.js
index 3fa46c61..e19030c9 100644
--- a/src/content/dependencies/generateArtistInfoPageChunk.js
+++ b/src/content/dependencies/generateArtistInfoPageChunk.js
@@ -18,30 +18,30 @@ export default {
       mutable: false,
     },
 
-    dates: {
-      validate: v => v.sparseArrayOf(v.isDate),
-    },
+    // Container and items, respectively.
+    date: {validate: v => v.isDate},
+    dates: {validate: v => v.sparseArrayOf(v.isDate)},
 
     duration: {validate: v => v.isDuration},
     durationApproximate: {type: 'boolean'},
   },
 
   generate(slots, {html, language}) {
-    let earliestDate = null;
-    let latestDate = null;
-    let onlyDate = null;
+    let earliestItemDate = null;
+    let latestItemDate = null;
+    let onlyItemDate = null;
 
     if (!empty(slots.dates)) {
-      earliestDate =
-        slots.dates
-          .reduce((a, b) => a <= b ? a : b);
+      earliestItemDate = slots.dates[0];
+      latestItemDate = slots.dates[1];
 
-      latestDate =
-        slots.dates
-          .reduce((a, b) => a <= b ? b : a);
+      for (const date of slots.dates.slice(1)) {
+        if (date < earliestItemDate) earliestItemDate = date;
+        if (date > latestItemDate) latestItemDate = date;
+      }
 
-      if (+earliestDate === +latestDate) {
-        onlyDate = earliestDate;
+      if (+earliestItemDate === +latestItemDate) {
+        onlyItemDate = earliestItemDate;
       }
     }
 
@@ -51,9 +51,16 @@ export default {
         const options = {album: slots.link};
         const parts = ['artistPage.creditList.album'];
 
-        if (onlyDate) {
+        if (slots.date) {
+          parts.push('withDate');
+          options.date = language.formatDate(slots.date);
+        } else if (onlyItemDate) {
           parts.push('withDate');
-          options.date = language.formatDate(onlyDate);
+          options.date = language.formatDate(onlyItemDate);
+        } else if (earliestItemDate && latestItemDate) {
+          parts.push('withDateRange');
+          options.dateRange =
+            language.formatDateRange(earliestItemDate, latestItemDate);
         }
 
         if (slots.duration) {
@@ -72,13 +79,13 @@ export default {
         const options = {act: slots.link};
         const parts = ['artistPage.creditList.flashAct'];
 
-        if (onlyDate) {
+        if (onlyItemDate) {
           parts.push('withDate');
-          options.date = language.formatDate(onlyDate);
-        } else if (earliestDate && latestDate) {
+          options.date = language.formatDate(onlyItemDate);
+        } else if (earliestItemDate && latestItemDate) {
           parts.push('withDateRange');
           options.dateRange =
-            language.formatDateRange(earliestDate, latestDate);
+            language.formatDateRange(earliestItemDate, latestItemDate);
         }
 
         accentedLink = language.formatString(...parts, options);
diff --git a/src/content/dependencies/generateArtistInfoPageMusicVideosChunk.js b/src/content/dependencies/generateArtistInfoPageMusicVideosChunk.js
new file mode 100644
index 00000000..6912d4d6
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageMusicVideosChunk.js
@@ -0,0 +1,58 @@
+export default {
+  relations: (relation, artist, album, contribs) => ({
+    template:
+      relation('generateArtistInfoPageChunk'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    albumArtistCredit:
+      relation('generateArtistCredit', album.artistContribs, []),
+
+    items:
+      contribs.map(contribs =>
+        relation('generateArtistInfoPageMusicVideosChunkItem',
+          artist,
+          contribs)),
+  }),
+
+  data: (_artist, album, contribs) => ({
+    albumDate:
+      album.date,
+
+    contribDates:
+      contribs
+        .flat()
+        .map(contrib => contrib.date),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.template.slots({
+      mode: 'album',
+
+      link:
+        language.encapsulate('artistPage.creditList.album', workingCapsule => {
+          const creditCapsule = workingCapsule + '.credit';
+          const workingOptions = {album: relations.albumLink};
+
+          relations.albumArtistCredit.setSlots({
+            normalStringKey: creditCapsule + '.by',
+          });
+
+          if (!html.isBlank(relations.albumArtistCredit)) {
+            workingCapsule += '.withCredit';
+            workingOptions.credit =
+              html.tag('span', {class: 'by'},
+                relations.albumArtistCredit);
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }),
+
+      date: data.albumDate,
+      dates: data.contribDates,
+
+      list:
+        html.tag('ul', relations.items),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageMusicVideosChunkItem.js b/src/content/dependencies/generateArtistInfoPageMusicVideosChunkItem.js
new file mode 100644
index 00000000..8bae860d
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageMusicVideosChunkItem.js
@@ -0,0 +1,118 @@
+import {empty} from '#sugar';
+import {selectRepresentativeArtistContributorContribs} from '#wiki-data';
+
+export default {
+  query(_artist, contribs) {
+    const query = {};
+
+    query.musicVideo = contribs[0].thing;
+
+    query.albumOrTrack = query.musicVideo.thing;
+
+    query.album =
+      (query.albumOrTrack.isAlbum
+        ? query.albumOrTrack
+        : query.albumOrTrack.album);
+
+    query.displayedContributions =
+      selectRepresentativeArtistContributorContribs(contribs);
+
+    return query;
+  },
+
+  relations: (relation, query, artist, _contribs) => ({
+    template:
+      relation('generateArtistInfoPageChunkItem'),
+
+    trackLink:
+      (query.albumOrTrack.isTrack
+        ? relation('linkTrack', query.albumOrTrack)
+        : null),
+
+    artistCredit:
+      relation('generateArtistCredit',
+        query.musicVideo.artistContribs,
+        (empty(query.album.artistContribs)
+          ? [artist.mockSimpleContribution]
+          : query.album.artistContribs)),
+
+    externalLinks:
+      query.musicVideo.urls
+        .map(url => relation('linkExternal', url)),
+  }),
+
+  data: (query, _artist, contribs) => ({
+    date: contribs[0].date,
+
+    for:
+      (query.albumOrTrack.isAlbum
+        ? 'album'
+        : 'track'),
+
+    title: query.musicVideo.title,
+    label: query.musicVideo.label,
+
+    contribAnnotationParts:
+      (query.displayedContributions
+        ? query.displayedContributions
+            .flatMap(contrib => contrib.annotationParts)
+        : null),
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.template.slots({
+      annotation:
+        (data.contribAnnotationParts
+          ? language.formatUnitList(data.contribAnnotationParts)
+          : html.blank()),
+
+      content:
+        language.encapsulate('artistPage.creditList.entry', entryCapsule => {
+          let workingCapsule = entryCapsule;
+          let workingOptions = {};
+
+          workingCapsule += '.' + data.for + '.musicVideo';
+
+          const musicVideoCapsule = workingCapsule;
+
+          if (data.for === 'track') {
+            workingOptions.track =
+              relations.trackLink;
+          }
+
+          if (data.date) {
+            workingCapsule += '.withDate';
+            workingOptions.date = language.formatDate(data.date);
+          }
+
+          relations.artistCredit.setSlots({
+            normalStringKey:
+              musicVideoCapsule + '.credit' +
+                (data.title ? '.alongsideTitle'
+               : data.label ? '.alongsideLabel'
+                            : ''),
+          });
+
+          if (!html.isBlank(relations.artistCredit)) {
+            workingCapsule += '.withCredit';
+            workingOptions.credit = relations.artistCredit;
+          }
+
+          if (data.title) {
+            workingCapsule += '.withTitle';
+            workingOptions.title = language.sanitize(data.title);
+          } else if (data.label) {
+            workingCapsule += '.withLabel';
+            workingOptions.label = language.sanitize(data.label);
+          }
+
+          if (!empty(relations.externalLinks)) {
+            workingCapsule += '.withLinks';
+            workingOptions.links =
+              language.formatUnitList(relations.externalLinks);
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageMusicVideosChunkedList.js b/src/content/dependencies/generateArtistInfoPageMusicVideosChunkedList.js
new file mode 100644
index 00000000..588fbbeb
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageMusicVideosChunkedList.js
@@ -0,0 +1,66 @@
+import {chunkByConditions, stitchArrays} from '#sugar';
+import {sortAlbumsTracksChronologically, sortContributionsChronologically}
+  from '#sort';
+
+export default {
+  query(artist) {
+    const query = {};
+
+    const allContributions = [
+      ...artist.musicVideoArtistContributions,
+      ...artist.musicVideoContributorContributions,
+      ...artist.otherMusicVideoArtistContributionsToOwnAlbums,
+    ];
+
+    const getMusicVideo = contrib =>
+      contrib.thing;
+
+    const getAlbumOrTrack = contrib =>
+      getMusicVideo(contrib).thing;
+
+    sortContributionsChronologically(
+      allContributions,
+      sortAlbumsTracksChronologically,
+      {getThing: getAlbumOrTrack});
+
+    const getAlbum = contrib =>
+      (getAlbumOrTrack(contrib).isTrack
+        ? getAlbumOrTrack(contrib).album
+        : getAlbumOrTrack(contrib));
+
+    query.contribs =
+      chunkByConditions(allContributions, [
+        (a, b) => getAlbum(a) !== getAlbum(b),
+      ]).map(contribs =>
+          chunkByConditions(contribs, [
+            (a, b) => getMusicVideo(a) !== getMusicVideo(b),
+          ]));
+
+    query.albums =
+      query.contribs
+        .map(contribs => contribs[0][0])
+        .map(contrib => getAlbum(contrib));
+
+    return query;
+  },
+
+  relations: (relation, query, artist) => ({
+    template:
+      relation('generateArtistInfoPageChunkedList'),
+
+    chunks:
+      stitchArrays({
+        album: query.albums,
+        contribs: query.contribs,
+      }).map(({album, contribs}) =>
+          relation('generateArtistInfoPageMusicVideosChunk',
+            artist,
+            album,
+            contribs)),
+  }),
+
+  generate: (relations) =>
+    relations.template.slots({
+      chunks: relations.chunks,
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunk.js b/src/content/dependencies/generateArtistInfoPageTracksChunk.js
index b3727756..7a7fc6a9 100644
--- a/src/content/dependencies/generateArtistInfoPageTracksChunk.js
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunk.js
@@ -61,7 +61,7 @@ export default {
         .filter(contribs => countTowardTrackTotals(contribs) === false),
   }),
 
-  relations: (relation, query, artist, album, _trackContribLists) => ({
+  relations: (relation, query, artist, album, trackContribLists) => ({
     template:
       relation('generateArtistInfoPageChunk'),
 
@@ -82,13 +82,15 @@ export default {
       query.contribListsCountingTowardTotals.map(trackContribs =>
         relation('generateArtistInfoPageTracksChunkItem',
           artist,
-          trackContribs)),
+          trackContribs,
+          trackContribLists)),
 
     itemsNotCountingTowardTotals:
       query.contribListsNotCountingTowardTotals.map(trackContribs =>
         relation('generateArtistInfoPageTracksChunkItem',
           artist,
-          trackContribs)),
+          trackContribs,
+          trackContribLists)),
   }),
 
   data(artist, _query, album, trackContribLists) {
@@ -97,7 +99,10 @@ export default {
     const contribs =
       trackContribLists.flat();
 
-    data.dates =
+    data.albumDate =
+      album.date;
+
+    data.contribDates =
       contribs
         .map(contrib => contrib.date);
 
@@ -168,7 +173,9 @@ export default {
           return language.$(workingCapsule, workingOptions);
         }),
 
-      dates: data.dates,
+      date: data.albumDate,
+      dates: data.contribDates,
+
       duration: data.duration,
       durationApproximate: data.durationApproximate,
 
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
index 3d6e274b..22a4a228 100644
--- a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
@@ -1,60 +1,23 @@
 import {sortAlbumsTracksChronologically} from '#sort';
 import {empty} from '#sugar';
+import {selectRepresentativeArtistContributorContribs} from '#wiki-data';
 
 export default {
-  query(artist, contribs) {
+  query(artist, contribs, chunkContribs) {
     const query = {};
 
-    // TODO: Very mysterious what to do if the set of contributions is,
-    // in total, associated with more than one thing. No design yet.
     query.track =
       contribs[0].thing;
 
-    const creditedAsNormalArtist =
-      contribs
-        .some(contrib =>
-          contrib.thingProperty === 'artistContribs' &&
-         !contrib.isFeaturingCredit);
-
-    const creditedAsContributor =
-      contribs
-        .some(contrib => contrib.thingProperty === 'contributorContribs');
-
-    const annotatedContribs =
-      contribs
-        .filter(contrib => !empty(contrib.annotationParts));
-
-    const annotatedArtistContribs =
-      annotatedContribs
-        .filter(contrib => contrib.thingProperty === 'artistContribs');
-
-    const annotatedContributorContribs =
-      annotatedContribs
-        .filter(contrib => contrib.thingProperty === 'contributorContribs');
-
-    // Don't display annotations associated with crediting in the
-    // Contributors field if the artist is also credited as an Artist
-    // *and* the Artist-field contribution is non-annotated. This is
-    // so that we don't misrepresent the artist - the contributor
-    // annotation tends to be for "secondary" and performance roles.
-    // For example, this avoids crediting Marcy Nabors on Renewed
-    // Return seemingly only for "bass clarinet" when they're also
-    // the one who composed and arranged Renewed Return!
-    if (
-      creditedAsNormalArtist &&
-      creditedAsContributor &&
-      empty(annotatedArtistContribs)
-    ) {
-      query.displayedContributions = null;
-    } else if (
-      !empty(annotatedArtistContribs) ||
-      !empty(annotatedContributorContribs)
-    ) {
-      query.displayedContributions = [
-        ...annotatedArtistContribs,
-        ...annotatedContributorContribs,
-      ];
-    }
+    query.date =
+      contribs[0].date;
+
+    query.anyItemsExpresslyDated =
+      chunkContribs.flat()
+        .some(contrib => +contrib.date !== +query.track.album.date);
+
+    query.displayedContributions =
+      selectRepresentativeArtistContributorContribs(contribs);
 
     // It's kinda awkward to perform this chronological sort here,
     // per track, rather than just reusing the one that's done to
@@ -112,6 +75,11 @@ export default {
   }),
 
   data: (query) => ({
+    date:
+      (query.anyItemsExpresslyDated
+        ? query.date
+        : null),
+
     duration:
       query.track.duration,
 
@@ -146,6 +114,7 @@ export default {
               relations.trackListItem.slots({
                 showArtists: 'auto',
                 showDuration: slots.showDuration,
+                showDate: data.date,
               })),
         }),
     }),
diff --git a/src/content/dependencies/generateTrackListItem.js b/src/content/dependencies/generateTrackListItem.js
index c8c57534..383f0025 100644
--- a/src/content/dependencies/generateTrackListItem.js
+++ b/src/content/dependencies/generateTrackListItem.js
@@ -25,6 +25,9 @@ export default {
   }),
 
   data: (track, _contextContributions) => ({
+    date:
+      track.date,
+
     duration:
       track.duration ?? 0,
 
@@ -47,6 +50,11 @@ export default {
       default: false,
     },
 
+    showDate: {
+      validate: v => v.anyOf(v.isBoolean, v.isDate),
+      default: false,
+    },
+
     colorMode: {
       validate: v => v.is('none', 'track', 'line'),
       default: 'track',
@@ -62,48 +70,83 @@ export default {
         language.encapsulate(itemCapsule, workingCapsule => {
           const workingOptions = {};
 
-          workingOptions.track =
-            relations.trackLink
-              .slot('color', slots.colorMode === 'track');
+          const accent =
+            language.encapsulate(itemCapsule, 'accent', accentCapsule => {
+              let workingCapsule = accentCapsule;
+              let workingOptions = {};
+              let any = false;
+
+              if (slots.showDate) {
+                any = true;
+                workingCapsule += '.withDate';
+                workingOptions.date =
+                  language.$(accentCapsule, 'date', {
+                    date:
+                      (slots.showDate === true
+                        ? language.formatDate(data.date)
+                        : language.formatDate(slots.showDate)),
+                  });
+              }
+
+              if (slots.showDuration) {
+                any = true;
+                workingCapsule += '.withDuration';
+                workingOptions.duration =
+                  (data.trackHasDuration
+                    ? language.$(accentCapsule, 'duration', {
+                        duration:
+                          language.formatDuration(data.duration),
+                      })
+                    : relations.missingDuration);
+              }
+
+              if (any) {
+                return language.$(workingCapsule, workingOptions);
+              } else {
+                return html.blank();
+              }
+            });
 
-          if (slots.showDuration) {
-            workingCapsule += '.withDuration';
-            workingOptions.duration =
-              (data.trackHasDuration
-                ? language.$(itemCapsule, 'withDuration.duration', {
-                    duration:
-                      language.formatDuration(data.duration),
-                  })
-                : relations.missingDuration);
+          if (!html.isBlank(accent)) {
+            workingCapsule += '.withAccent';
+            workingOptions.accent = accent;
           }
 
-          const chosenCredit =
-            (slots.showArtists === true
-              ? relations.acontextualCredit
-           : slots.showArtists === 'auto'
-              ? relations.contextualCredit
-              : null);
-
-          if (chosenCredit) {
-            const artistCapsule = language.encapsulate(itemCapsule, 'withArtists');
-
-            chosenCredit.setSlots({
-              normalStringKey:
-                artistCapsule + '.by',
-
-              featuringStringKey:
-                artistCapsule + '.featuring',
+          workingOptions.track =
+            relations.trackLink
+              .slot('color', slots.colorMode === 'track');
 
-              normalFeaturingStringKey:
-                artistCapsule + '.by.featuring',
+          const artists =
+            language.encapsulate(itemCapsule, 'artists', artistsCapsule => {
+              const chosenCredit =
+                (slots.showArtists === true
+                  ? relations.acontextualCredit
+               : slots.showArtists === 'auto'
+                  ? relations.contextualCredit
+                  : null);
+
+              if (!chosenCredit) {
+                return html.blank();
+              }
+
+              // This might still be blank, if the contextual credit is chosen
+              // and it matches its context credit.
+              return chosenCredit.slots({
+                normalStringKey:
+                  artistsCapsule + '.by',
+
+                featuringStringKey:
+                  artistsCapsule + '.featuring',
+
+                normalFeaturingStringKey:
+                  artistsCapsule + '.by.featuring',
+              });
             });
 
-            if (!html.isBlank(chosenCredit)) {
-              workingCapsule += '.withArtists';
-              workingOptions.by =
-                html.tag('span', {class: 'by'},
-                  chosenCredit);
-            }
+          if (!html.isBlank(artists)) {
+            workingCapsule += '.withArtists';
+            workingOptions.artists =
+              html.tag('span', {class: 'by'}, artists);
           }
 
           return language.$(workingCapsule, workingOptions);
diff --git a/src/content/dependencies/generateTrackListMissingDuration.js b/src/content/dependencies/generateTrackListMissingDuration.js
index 70db23c2..f3c5c6ce 100644
--- a/src/content/dependencies/generateTrackListMissingDuration.js
+++ b/src/content/dependencies/generateTrackListMissingDuration.js
@@ -8,27 +8,26 @@ export default {
   }),
 
   generate: (relations, {html, language}) =>
-    language.encapsulate('trackList.item.withDuration', itemCapsule =>
-      language.encapsulate(itemCapsule, 'duration', durationCapsule =>
-        relations.textWithTooltip.slots({
-          attributes: {class: 'missing-duration'},
-          customInteractionCue: true,
+    language.encapsulate('trackList.item.accent.duration', capsule =>
+      relations.textWithTooltip.slots({
+        attributes: {class: 'missing-duration'},
+        customInteractionCue: true,
 
-          text:
-            language.$(durationCapsule, {
-              duration:
-                html.tag('span', {class: 'text-with-tooltip-interaction-cue'},
-                  {tabindex: '0'},
+        text:
+          language.$(capsule, {
+            duration:
+              html.tag('span', {class: 'text-with-tooltip-interaction-cue'},
+                {tabindex: '0'},
 
-                  language.$(durationCapsule, 'missing')),
-            }),
+                language.$(capsule, 'missing')),
+          }),
 
-          tooltip:
-            relations.tooltip.slots({
-              attributes: {class: 'missing-duration-tooltip'},
+        tooltip:
+          relations.tooltip.slots({
+            attributes: {class: 'missing-duration-tooltip'},
 
-              content:
-                language.$(durationCapsule, 'missing.info'),
-            }),
-        }))),
+            content:
+              language.$(capsule, 'missing.info'),
+          }),
+      })),
 };
diff --git a/src/data/composite/wiki-data/helpers/withResolvedReverse.js b/src/data/composite/wiki-data/helpers/withResolvedReverse.js
index 818f60b7..bad64925 100644
--- a/src/data/composite/wiki-data/helpers/withResolvedReverse.js
+++ b/src/data/composite/wiki-data/helpers/withResolvedReverse.js
@@ -5,7 +5,7 @@ import {input, templateCompositeFrom} from '#composite';
 import inputWikiData from '../inputWikiData.js';
 
 export default templateCompositeFrom({
-  annotation: `withReverseReferenceList`,
+  annotation: `withResolvedReverse`,
 
   inputs: {
     data: inputWikiData({allowMixedTypes: true}),
diff --git a/src/data/thing.js b/src/data/thing.js
index 0a6e3be4..f5afe076 100644
--- a/src/data/thing.js
+++ b/src/data/thing.js
@@ -67,14 +67,7 @@ export default class Thing extends CacheableObject {
       name = colors.yellow(`couldn't get name`);
     }
 
-    let reference;
-    try {
-      if (this.directory) {
-        reference = colors.blue(Thing.getReference(this));
-      }
-    } catch {
-      reference = colors.yellow(`couldn't get reference`);
-    }
+    let reference = Thing.inspectReference(this, {showConstructor: false});
 
     return (
       (name ? `${constructorName} ${name}` : `${constructorName}`) +
@@ -127,6 +120,64 @@ export default class Thing extends CacheableObject {
     return `${thing.constructor[Thing.referenceType]}:${thing.directory}`;
   }
 
+  static inspectReference(thing, {showConstructor = true} = {}) {
+    const referenceType =
+      thing.constructor[Thing.referenceType] ??
+      null;
+
+    const constructorPart =
+      (showConstructor
+        ? `${thing.constructor.name} `
+        : ``);
+
+    let errored = false;
+    const tryToGet = property => {
+      try {
+        return thing[property] ?? null;
+      } catch {
+        errored = true;
+        return null;
+      }
+    };
+
+    const directoryPart = this.inspectDirectory(thing);
+    const directoryErrored = directoryPart === null;
+
+    if (directoryPart && referenceType) {
+      return colors.blue(`${referenceType}:${directoryPart}`);
+    } else if (directoryPart) {
+      return constructorPart + `(${colors.blue(directoryPart)})`;
+    } else if (tryToGet('name')) {
+      return constructorPart + `(named ${inspect(thing.name)}`;
+    } else if (errored && directoryErrored) {
+      return constructorPart + `(${colors.yellow(`couldn't compute reference`)})`;
+    } else {
+      return constructorPart;
+    }
+  }
+
+  static inspectDirectory(thing) {
+    let errored = false;
+    const tryToGet = property => {
+      try {
+        return thing[property] ?? null;
+      } catch {
+        errored = true;
+        return null;
+      }
+    };
+
+    if (tryToGet('directory')) {
+      return thing.directory;
+    } else if (tryToGet('unqualifiedDirectory')) {
+      return `…${thing.unqualifiedDirectory}`;
+    } else if (errored) {
+      return null;
+    } else {
+      return '';
+    }
+  }
+
   static extendDocumentSpec(thingClass, subspec) {
     const superspec = thingClass[Thing.yamlDocumentSpec];
 
diff --git a/src/data/things/Artist.js b/src/data/things/Artist.js
index f518e31e..b82ef8bf 100644
--- a/src/data/things/Artist.js
+++ b/src/data/things/Artist.js
@@ -14,8 +14,10 @@ import {
 
 import {exitWithoutDependency, exposeConstant, exposeDependency}
   from '#composite/control-flow';
-import {withFilteredList, withPropertyFromList} from '#composite/data';
-import {withContributionListSums} from '#composite/wiki-data';
+import {withFilteredList, withMappedList, withPropertyFromList}
+  from '#composite/data';
+import {withContributionListSums, withReverseReferenceList}
+  from '#composite/wiki-data';
 
 import {
   constitutibleArtwork,
@@ -216,6 +218,33 @@ export class Artist extends Thing {
       reverse: soupyReverse.input('musicVideoContributorContributionsBy'),
     }),
 
+    otherMusicVideoArtistContributionsToOwnAlbums: [
+      withReverseReferenceList({
+        reverse: soupyReverse.input('musicVideoArtistContributionsToAlbumsBy'),
+      }).outputs({
+        '#reverseReferenceList': '#allArtistContributions',
+      }),
+
+      {
+        dependencies: [input.myself()],
+        compute: (continuation, {
+          [input.myself()]: myself,
+        }) => continuation({
+          ['#isNotMyself']: artist => artist !== myself,
+        }),
+      },
+
+      withPropertyFromList('#allArtistContributions', V('artist')),
+
+      withMappedList('#allArtistContributions.artist', '#isNotMyself')
+        .outputs({'#mappedList': '#differentArtistFilter'}),
+
+      withFilteredList('#allArtistContributions', '#differentArtistFilter')
+        .outputs({'#filteredList': '#otherArtistContributions'}),
+
+      exposeDependency('#otherArtistContributions'),
+    ],
+
     totalDuration: [
       withPropertyFromList('musicContributions', V('thing')),
       withPropertyFromList('#musicContributions.thing', V('isMainRelease')),
diff --git a/src/data/things/Artwork.js b/src/data/things/Artwork.js
index 7beb3567..d2bd31ba 100644
--- a/src/data/things/Artwork.js
+++ b/src/data/things/Artwork.js
@@ -422,7 +422,7 @@ export class Artwork extends Thing {
 
         parts.push(` for ${inspect(this.thing, newOptions)}`);
       } else {
-        parts.push(` for ${colors.blue(Thing.getReference(this.thing))}`);
+        parts.push(` for ${Thing.inspectReference(this.thing)}`);
       }
     }
 
diff --git a/src/data/things/MusicVideo.js b/src/data/things/MusicVideo.js
index 3a41caf5..77c8c619 100644
--- a/src/data/things/MusicVideo.js
+++ b/src/data/things/MusicVideo.js
@@ -167,6 +167,32 @@ export class MusicVideo extends Thing {
 
     musicVideoContributorContributionsBy:
       soupyReverse.contributionsBy('musicVideoData', 'contributorContribs'),
+
+    musicVideoArtistContributionsToAlbumsBy: {
+      bindTo: 'musicVideoData',
+
+      referencing: musicVideo => musicVideo.artistContribs,
+
+      *referenced(musicVideoContrib) {
+        const musicVideo = musicVideoContrib.thing;
+        const trackOrAlbum = musicVideo.thing;
+        if (trackOrAlbum.isTrack) {
+          const albumArtists =
+            trackOrAlbum.album.artistContribs
+              .map(albumContrib => albumContrib.artist);
+
+          for (const trackContrib of trackOrAlbum.artistContribs) {
+            if (albumArtists.includes(trackContrib.artist)) {
+              yield trackContrib.artist;
+            }
+          }
+        } else {
+          for (const albumContrib of trackOrAlbum.artistContribs) {
+            yield albumContrib.artist;
+          }
+        }
+      },
+    },
   };
 
   get path() {
@@ -179,7 +205,15 @@ export class MusicVideo extends Thing {
   [inspect.custom](depth, options, inspect) {
     const parts = [];
 
-    parts.push(Thing.prototype[inspect.custom].apply(this));
+    parts.push(this.constructor.name);
+
+    if (this.title) {
+      parts.push(` ${colors.green(`"${this.title}"`)}`);
+    } else if (this.label) {
+      parts.push(` (${colors.green(`"${this.label}"`)})`);
+    } else if (this.unqualifiedDirectory !== 'music-video') {
+      parts.push(` (${colors.blue(this.unqualifiedDirectory)})`);
+    }
 
     if (this.thing) {
       if (depth >= 0) {
@@ -193,7 +227,7 @@ export class MusicVideo extends Thing {
 
         parts.push(` for ${inspect(this.thing, newOptions)}`);
       } else {
-        parts.push(` for ${colors.blue(Thing.getReference(this.thing))}`);
+        parts.push(` for ${colors.blue(Thing.inspectReference(this.thing))}`);
       }
     }
 
diff --git a/src/data/things/contrib/Contribution.js b/src/data/things/contrib/Contribution.js
index 4352b58a..305e6a14 100644
--- a/src/data/things/contrib/Contribution.js
+++ b/src/data/things/contrib/Contribution.js
@@ -314,8 +314,7 @@ export class Contribution extends Thing {
       }
 
       if (artist) {
-        artistRef =
-          colors.blue(Thing.getReference(artist));
+        artistRef = Thing.inspectReference(artist);
       }
     } else {
       artistRef =
@@ -326,7 +325,7 @@ export class Contribution extends Thing {
       accentParts.push(`by ${artistRef}`);
     }
 
-    if (this.thing) {
+    if (this.thing) toPart: {
       if (depth >= 0) {
         const newOptions = {
           ...options,
@@ -336,10 +335,13 @@ export class Contribution extends Thing {
               : options.depth - 1),
         };
 
-        accentParts.push(`to ${inspect(this.thing, newOptions)}`);
-      } else {
-        accentParts.push(`to ${colors.blue(Thing.getReference(this.thing))}`);
+        try {
+          accentParts.push(`to ${inspect(this.thing, newOptions)}`);
+          break toPart;
+        } catch {}
       }
+
+      accentParts.push(`to ${Thing.inspectReference(this.thing)}`);
     }
 
     if (!empty(accentParts)) {
diff --git a/src/reverse.js b/src/reverse.js
index b4b225f0..7d7e3672 100644
--- a/src/reverse.js
+++ b/src/reverse.js
@@ -45,11 +45,12 @@ function reverseHelper(spec) {
 
     const interstitialReferencingThings =
       (spec.bindTo === 'wikiData'
-        ? spec.referencing(data)
-        : data.flatMap(thing => spec.referencing(thing)));
+        ? Array.from(spec.referencing(data))
+        : data.flatMap(thing => Array.from(spec.referencing(thing))));
 
     const referencedThings =
-      interstitialReferencingThings.map(thing => spec.referenced(thing));
+      interstitialReferencingThings
+        .map(thing => Array.from(spec.referenced(thing)));
 
     const referencingThings =
       (spec.tidy
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index daca8347..c27d45ae 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -447,25 +447,35 @@ trackList:
   item:
     _: "{TRACK}"
 
-    withDuration:
-      _: >-
-        {DURATION} {TRACK}
+    withAccent: >-
+      {ACCENT} {TRACK}
 
-      duration:
-        _: "({DURATION})"
-        missing: "_:__"
-        missing.info: "no duration provided; treated as zero seconds long"
+    accent.withDate: >-
+      ({DATE})
 
-    withArtists:
-      _: >-
-        {TRACK} {BY}
+    accent.withDate.withDuration: >-
+      ({DURATION}; {DATE})
+
+    accent.withDuration: >-
+      ({DURATION})
+
+    accent.date: "{DATE}"
+
+    accent.duration:
+      _: "{DURATION}"
+      missing: "_:__"
+      missing.info: "no duration provided; treated as zero seconds long"
 
+    withArtists: >-
+      {TRACK} {ARTISTS}
+
+    artists:
       by: "by {ARTISTS}"
       featuring: "feat. {ARTISTS}"
       by.featuring: "by {ARTISTS} feat. {FEATURING}"
 
-    withDuration.withArtists: >-
-      {DURATION} {TRACK} {BY}
+    withAccent.withArtists: >-
+      {ACCENT} {TRACK} {ARTISTS}
 
     rerelease: >-
       {TRACK} (rerelease)
@@ -1447,6 +1457,88 @@ artistPage:
 
       track: "{TRACK}"
 
+      track.musicVideo:
+        _: >-
+          {TRACK}
+
+        withLinks: >-
+          {TRACK}: {LINKS}
+
+        withTitle: >-
+          {TRACK} — {TITLE}
+
+        withTitle.withLinks: >-
+          {TRACK} — {TITLE}: {LINKS}
+
+        withLabel: >-
+          {TRACK} — {LABEL}
+
+        withLabel.withLinks: >-
+          {TRACK} — {LABEL}: {LINKS}
+
+        withCredit: >-
+          {TRACK}, {CREDIT}
+
+        withCredit.withLinks: >-
+          {TRACK}, {CREDIT}: {LINKS}
+
+        withCredit.withTitle: >-
+          {TRACK}, {TITLE} {CREDIT}
+
+        withCredit.withTitle.withLinks: >-
+          {TRACK}, {TITLE} {CREDIT}: {LINKS}
+
+        withCredit.withLabel: >-
+          {TRACK}, {LABEL} {CREDIT}
+
+        withCredit.withLabel.withLinks: >-
+          {TRACK}, {LABEL} {CREDIT}: {LINKS}
+
+        withDate: >-
+          ({DATE}) {TRACK}
+
+        withDate.withLinks: >-
+          ({DATE}) {TRACK}: {LINKS}
+
+        withDate.withTitle: >-
+          ({DATE}) {TRACK} — {TITLE}
+
+        withDate.withTitle.withLinks: >-
+          ({DATE}) {TRACK} — {TITLE}: {LINKS}
+
+        withDate.withLabel: >-
+          ({DATE}) {TRACK} — {LABEL}
+
+        withDate.withLabel.withLinks: >-
+          ({DATE}) {TRACK} — {LABEL}: {LINKS}
+
+        withDate.withCredit: >-
+          ({DATE}) {TRACK}, {CREDIT}
+
+        withDate.withCredit.withLinks: >-
+          ({DATE}) {TRACK}, {CREDIT}: {LINKS}
+
+        withDate.withCredit.withTitle: >-
+          ({DATE}) {TRACK}, {TITLE} {CREDIT}
+
+        withDate.withCredit.withTitle.withLinks: >-
+          ({DATE}) {TRACK}, {TITLE} {CREDIT}: {LINKS}
+
+        withDate.withCredit.withLabel: >-
+          ({DATE}) {TRACK}, {LABEL} {CREDIT}
+
+        withDate.withCredit.withLabel.withLinks: >-
+          ({DATE}) {TRACK}, {LABEL} {CREDIT}: {LINKS}
+
+        credit: >-
+          video by {ARTISTS}
+
+        credit.alongsideLabel: >-
+          by {ARTISTS}
+
+        credit.alongsideTitle: >-
+          by {ARTISTS}
+
       # album:
       #   The artist info page doesn't display if the artist is
       #   musically credited outright for the album as a whole,
@@ -1461,6 +1553,88 @@ artistPage:
         bannerArt: "(banner art)"
         commentary: "(album commentary)"
 
+        musicVideo:
+          _: >-
+            (album music video)
+
+          withLinks: >-
+            (album music video: {LINKS})
+
+          withTitle: >-
+            (for album: {TITLE})
+
+          withTitle.withLinks: >-
+            (for album: {TITLE} - {LINKS})
+
+          withLabel: >-
+            (for album: {LABEL})
+
+          withLabel.withLinks: >-
+            (for album: {LABEL} - {LINKS})
+
+          withCredit: >-
+            (album music video {CREDIT})
+
+          withCredit.withLinks: >-
+            (album music video {CREDIT} - {LINKS})
+
+          withCredit.withTitle: >-
+            (for album: {TITLE} {CREDIT})
+
+          withCredit.withTitle.withLinks: >-
+            (for album: {TITLE} {CREDIT} - {LINKS})
+
+          withCredit.withLabel: >-
+            (for album: {LABEL} {CREDIT})
+
+          withCredit.withLabel.withLinks: >-
+            (for album: {LABEL} {CREDIT} - {LINKS})
+
+          withDate: >-
+            ({DATE}: album music video)
+
+          withDate.withLinks: >-
+            ({DATE}, album music video: {LINKS})
+
+          withDate.withTitle: >-
+            ({DATE}, for album: {TITLE})
+
+          withDate.withTitle.withLinks: >-
+            ({DATE}, for album: {TITLE} - {LINKS})
+
+          withDate.withLabel: >-
+            ({DATE}, for album: {LABEL})
+
+          withDate.withLabel.withLinks: >-
+            ({DATE}, for album: {LABEL} - {LINKS})
+
+          withDate.withCredit: >-
+            ({DATE}: album music video {CREDIT})
+
+          withDate.withCredit.withLinks: >-
+            ({DATE}, album music video {CREDIT} - {LINKS})
+
+          withDate.withCredit.withTitle: >-
+            ({DATE}, for album: {TITLE} {CREDIT})
+
+          withDate.withCredit.withTitle.withLinks: >-
+            ({DATE}, for album: {TITLE} {CREDIT} - {LINKS})
+
+          withDate.withCredit.withLabel: >-
+            ({DATE}, for album: {LABEL} {CREDIT})
+
+          withDate.withCredit.withLabel.withLinks: >-
+            ({DATE}, for album: {LABEL} {CREDIT} - {LINKS})
+
+          credit: >-
+            by {ARTISTS}
+
+          credit.alongsideLabel: >-
+            by {ARTISTS}
+
+          credit.alongsideTitle: >-
+            by {ARTISTS}
+
       flash: "{FLASH}"
 
       artwork.accent:
@@ -1490,6 +1664,7 @@ artistPage:
     title:
       music: "Contributed music to groups:"
       artworks: "Contributed artworks to groups:"
+      musicVideos: "Contributed to music videos in groups:"
       withSortButton: "{TITLE} ({SORT})"
 
       sorting:
@@ -1502,17 +1677,11 @@ artistPage:
       countDurationAccent: "({COUNT} — {DURATION})"
       durationCountAccent: "({DURATION} — {COUNT})"
 
-  trackList:
-    title: "Tracks"
-
-  artList:
-    title: "Artworks"
-
-  flashList:
-    title: "Flashes"
-
-  commentaryList:
-    title: "Commentary"
+  trackList.title: "Tracks"
+  artList.title: "Artworks"
+  musicVideoList.title: "Music Videos"
+  flashList.title: "Flashes"
+  commentaryList.title: "Commentary"
 
   # viewArtGallery:
   #   This is shown twice on the page - once at almost the very top