« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/data/things.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/data/things.js')
-rw-r--r--src/data/things.js2722
1 files changed, 1442 insertions, 1280 deletions
diff --git a/src/data/things.js b/src/data/things.js
index 6a5cdb5e..fa7a8d54 100644
--- a/src/data/things.js
+++ b/src/data/things.js
@@ -1,45 +1,45 @@
+/** @format */
+
 // things.js: class definitions for various object types used across the wiki,
 // most of which correspond to an output page, such as Track, Album, Artist
 
 import CacheableObject from './cacheable-object.js';
 
 import {
-    isAdditionalFileList,
-    isBoolean,
-    isColor,
-    isCommentary,
-    isCountingNumber,
-    isContributionList,
-    isDate,
-    isDimensions,
-    isDirectory,
-    isDuration,
-    isInstance,
-    isFileExtension,
-    isLanguageCode,
-    isName,
-    isNumber,
-    isURL,
-    isString,
-    isWholeNumber,
-    oneOf,
-    validateArrayItems,
-    validateInstanceOf,
-    validateReference,
-    validateReferenceList,
+  isAdditionalFileList,
+  isBoolean,
+  isColor,
+  isCommentary,
+  isCountingNumber,
+  isContributionList,
+  isDate,
+  isDimensions,
+  isDirectory,
+  isDuration,
+  isFileExtension,
+  isLanguageCode,
+  isName,
+  isNumber,
+  isURL,
+  isString,
+  oneOf,
+  validateArrayItems,
+  validateInstanceOf,
+  validateReference,
+  validateReferenceList,
 } from './validators.js';
 
 import * as S from './serialize.js';
 
 import {
-    getKebabCase,
-    sortAlbumsTracksChronologically,
+  getKebabCase,
+  sortAlbumsTracksChronologically,
 } from '../util/wiki-data.js';
 
 import find from '../util/find.js';
 
-import { inspect } from 'util';
-import { color } from '../util/cli.js';
+import {inspect} from 'util';
+import {color} from '../util/cli.js';
 
 // Stub classes (and their exports) at the top of the file - these are
 // referenced later when we actually define static class fields. We deliberately
@@ -112,551 +112,579 @@ Flash[Thing.referenceType] = 'flash';
 // duplicating less code across wiki data types. These are specialized utility
 // functions, so check each for how its own arguments behave!
 Thing.common = {
-    name: (defaultName) => ({
-        flags: {update: true, expose: true},
-        update: {validate: isName, default: defaultName}
-    }),
-
-    color: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isColor}
-    }),
-
-    directory: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isDirectory},
-        expose: {
-            dependencies: ['name'],
-            transform(directory, { name }) {
-                if (directory === null && name === null)
-                    return null;
-                else if (directory === null)
-                    return getKebabCase(name);
-                else
-                    return directory;
-            }
-        }
-    }),
-
-    urls: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: validateArrayItems(isURL)}
-    }),
-
-    // A file extension! Or the default, if provided when calling this.
-    fileExtension: (defaultFileExtension = null) => ({
-        flags: {update: true, expose: true},
-        update: {validate: isFileExtension},
-        expose: {transform: value => value ?? defaultFileExtension}
-    }),
-
-    // Straightforward flag descriptor for a variety of property purposes.
-    // Provide a default value, true or false!
-    flag: (defaultValue = false) => {
-        if (typeof defaultValue !== 'boolean') {
-            throw new TypeError(`Always set explicit defaults for flags!`);
-        }
+  name: (defaultName) => ({
+    flags: {update: true, expose: true},
+    update: {validate: isName, default: defaultName},
+  }),
+
+  color: () => ({
+    flags: {update: true, expose: true},
+    update: {validate: isColor},
+  }),
+
+  directory: () => ({
+    flags: {update: true, expose: true},
+    update: {validate: isDirectory},
+    expose: {
+      dependencies: ['name'],
+      transform(directory, {name}) {
+        if (directory === null && name === null) return null;
+        else if (directory === null) return getKebabCase(name);
+        else return directory;
+      },
+    },
+  }),
+
+  urls: () => ({
+    flags: {update: true, expose: true},
+    update: {validate: validateArrayItems(isURL)},
+  }),
+
+  // A file extension! Or the default, if provided when calling this.
+  fileExtension: (defaultFileExtension = null) => ({
+    flags: {update: true, expose: true},
+    update: {validate: isFileExtension},
+    expose: {transform: (value) => value ?? defaultFileExtension},
+  }),
+
+  // Straightforward flag descriptor for a variety of property purposes.
+  // Provide a default value, true or false!
+  flag: (defaultValue = false) => {
+    if (typeof defaultValue !== 'boolean') {
+      throw new TypeError(`Always set explicit defaults for flags!`);
+    }
 
-        return {
-            flags: {update: true, expose: true},
-            update: {validate: isBoolean, default: defaultValue}
-        };
-    },
+    return {
+      flags: {update: true, expose: true},
+      update: {validate: isBoolean, default: defaultValue},
+    };
+  },
+
+  // General date type, used as the descriptor for a bunch of properties.
+  // This isn't dynamic though - it won't inherit from a date stored on
+  // another object, for example.
+  simpleDate: () => ({
+    flags: {update: true, expose: true},
+    update: {validate: isDate},
+  }),
+
+  // General string type. This should probably generally be avoided in favor
+  // of more specific validation, but using it makes it easy to find where we
+  // might want to improve later, and it's a useful shorthand meanwhile.
+  simpleString: () => ({
+    flags: {update: true, expose: true},
+    update: {validate: isString},
+  }),
+
+  // External function. These should only be used as dependencies for other
+  // properties, so they're left unexposed.
+  externalFunction: () => ({
+    flags: {update: true},
+    update: {validate: (t) => typeof t === 'function'},
+  }),
+
+  // Super simple "contributions by reference" list, used for a variety of
+  // properties (Artists, Cover Artists, etc). This is the property which is
+  // externally provided, in the form:
+  //
+  //     [
+  //         {who: 'Artist Name', what: 'Viola'},
+  //         {who: 'artist:john-cena', what: null},
+  //         ...
+  //     ]
+  //
+  // ...processed from YAML, spreadsheet, or any other kind of input.
+  contribsByRef: () => ({
+    flags: {update: true, expose: true},
+    update: {validate: isContributionList},
+  }),
+
+  // Artist commentary! Generally present on tracks and albums.
+  commentary: () => ({
+    flags: {update: true, expose: true},
+    update: {validate: isCommentary},
+  }),
+
+  // This is a somewhat more involved data structure - it's for additional
+  // or "bonus" files associated with albums or tracks (or anything else).
+  // It's got this form:
+  //
+  //     [
+  //         {title: 'Booklet', files: ['Booklet.pdf']},
+  //         {
+  //             title: 'Wallpaper',
+  //             description: 'Cool Wallpaper!',
+  //             files: ['1440x900.png', '1920x1080.png']
+  //         },
+  //         {title: 'Alternate Covers', description: null, files: [...]},
+  //         ...
+  //     ]
+  //
+  additionalFiles: () => ({
+    flags: {update: true, expose: true},
+    update: {validate: isAdditionalFileList},
+  }),
+
+  // A reference list! Keep in mind this is for general references to wiki
+  // objects of (usually) other Thing subclasses, not specifically leitmotif
+  // references in tracks (although that property uses referenceList too!).
+  //
+  // The underlying function validateReferenceList expects a string like
+  // 'artist' or 'track', but this utility keeps from having to hard-code the
+  // string in multiple places by referencing the value saved on the class
+  // instead.
+  referenceList: (thingClass) => {
+    const {[Thing.referenceType]: referenceType} = thingClass;
+    if (!referenceType) {
+      throw new Error(
+        `The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`
+      );
+    }
 
-    // General date type, used as the descriptor for a bunch of properties.
-    // This isn't dynamic though - it won't inherit from a date stored on
-    // another object, for example.
-    simpleDate: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isDate}
-    }),
-
-    // General string type. This should probably generally be avoided in favor
-    // of more specific validation, but using it makes it easy to find where we
-    // might want to improve later, and it's a useful shorthand meanwhile.
-    simpleString: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isString}
-    }),
-
-    // External function. These should only be used as dependencies for other
-    // properties, so they're left unexposed.
-    externalFunction: () => ({
-        flags: {update: true},
-        update: {validate: t => typeof t === 'function'}
-    }),
-
-    // Super simple "contributions by reference" list, used for a variety of
-    // properties (Artists, Cover Artists, etc). This is the property which is
-    // externally provided, in the form:
-    //
-    //     [
-    //         {who: 'Artist Name', what: 'Viola'},
-    //         {who: 'artist:john-cena', what: null},
-    //         ...
-    //     ]
-    //
-    // ...processed from YAML, spreadsheet, or any other kind of input.
-    contribsByRef: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isContributionList}
-    }),
-
-    // Artist commentary! Generally present on tracks and albums.
-    commentary: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isCommentary}
-    }),
-
-    // This is a somewhat more involved data structure - it's for additional
-    // or "bonus" files associated with albums or tracks (or anything else).
-    // It's got this form:
-    //
-    //     [
-    //         {title: 'Booklet', files: ['Booklet.pdf']},
-    //         {
-    //             title: 'Wallpaper',
-    //             description: 'Cool Wallpaper!',
-    //             files: ['1440x900.png', '1920x1080.png']
-    //         },
-    //         {title: 'Alternate Covers', description: null, files: [...]},
-    //         ...
-    //     ]
-    //
-    additionalFiles: () => ({
-        flags: {update: true, expose: true},
-        update: {validate: isAdditionalFileList}
-    }),
-
-    // A reference list! Keep in mind this is for general references to wiki
-    // objects of (usually) other Thing subclasses, not specifically leitmotif
-    // references in tracks (although that property uses referenceList too!).
-    //
-    // The underlying function validateReferenceList expects a string like
-    // 'artist' or 'track', but this utility keeps from having to hard-code the
-    // string in multiple places by referencing the value saved on the class
-    // instead.
-    referenceList: thingClass => {
-        const { [Thing.referenceType]: referenceType } = thingClass;
-        if (!referenceType) {
-            throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`);
-        }
+    return {
+      flags: {update: true, expose: true},
+      update: {validate: validateReferenceList(referenceType)},
+    };
+  },
+
+  // Corresponding function for a single reference.
+  singleReference: (thingClass) => {
+    const {[Thing.referenceType]: referenceType} = thingClass;
+    if (!referenceType) {
+      throw new Error(
+        `The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`
+      );
+    }
 
-        return {
-            flags: {update: true, expose: true},
-            update: {validate: validateReferenceList(referenceType)}
-        };
-    },
+    return {
+      flags: {update: true, expose: true},
+      update: {validate: validateReference(referenceType)},
+    };
+  },
+
+  // Corresponding dynamic property to referenceList, which takes the values
+  // in the provided property and searches the specified wiki data for
+  // matching actual Thing-subclass objects.
+  dynamicThingsFromReferenceList: (
+    referenceListProperty,
+    thingDataProperty,
+    findFn
+  ) => ({
+    flags: {expose: true},
 
-    // Corresponding function for a single reference.
-    singleReference: thingClass => {
-        const { [Thing.referenceType]: referenceType } = thingClass;
-        if (!referenceType) {
-            throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`);
-        }
+    expose: {
+      dependencies: [referenceListProperty, thingDataProperty],
+      compute: ({
+        [referenceListProperty]: refs,
+        [thingDataProperty]: thingData,
+      }) =>
+        refs && thingData
+          ? refs
+              .map((ref) => findFn(ref, thingData, {mode: 'quiet'}))
+              .filter(Boolean)
+          : [],
+    },
+  }),
+
+  // Corresponding function for a single reference.
+  dynamicThingFromSingleReference: (
+    singleReferenceProperty,
+    thingDataProperty,
+    findFn
+  ) => ({
+    flags: {expose: true},
 
-        return {
-            flags: {update: true, expose: true},
-            update: {validate: validateReference(referenceType)}
-        };
-    },
+    expose: {
+      dependencies: [singleReferenceProperty, thingDataProperty],
+      compute: ({
+        [singleReferenceProperty]: ref,
+        [thingDataProperty]: thingData,
+      }) => (ref && thingData ? findFn(ref, thingData, {mode: 'quiet'}) : null),
+    },
+  }),
+
+  // Corresponding dynamic property to contribsByRef, which takes the values
+  // in the provided property and searches the object's artistData for
+  // matching actual Artist objects. The computed structure has the same form
+  // as contribsByRef, but with Artist objects instead of string references:
+  //
+  //     [
+  //         {who: (an Artist), what: 'Viola'},
+  //         {who: (an Artist), what: null},
+  //         ...
+  //     ]
+  //
+  // Contributions whose "who" values don't match anything in artistData are
+  // filtered out. (So if the list is all empty, chances are that either the
+  // reference list is somehow messed up, or artistData isn't being provided
+  // properly.)
+  dynamicContribs: (contribsByRefProperty) => ({
+    flags: {expose: true},
+    expose: {
+      dependencies: ['artistData', contribsByRefProperty],
+      compute: ({artistData, [contribsByRefProperty]: contribsByRef}) =>
+        contribsByRef && artistData
+          ? contribsByRef
+              .map(({who: ref, what}) => ({
+                who: find.artist(ref, artistData),
+                what,
+              }))
+              .filter(({who}) => who)
+          : [],
+    },
+  }),
+
+  // Dynamically inherit a contribution list from some other object, if it
+  // hasn't been overridden on this object. This is handy for solo albums
+  // where all tracks have the same artist, for example.
+  //
+  // Note: The arguments of this function aren't currently final! The final
+  // format will look more like (contribsByRef, parentContribsByRef), e.g.
+  // ('artistContribsByRef', '@album/artistContribsByRef').
+  dynamicInheritContribs: (
+    contribsByRefProperty,
+    parentContribsByRefProperty,
+    thingDataProperty,
+    findFn
+  ) => ({
+    flags: {expose: true},
+    expose: {
+      dependencies: [contribsByRefProperty, thingDataProperty, 'artistData'],
+      compute({
+        [Thing.instance]: thing,
+        [contribsByRefProperty]: contribsByRef,
+        [thingDataProperty]: thingData,
+        artistData,
+      }) {
+        if (!artistData) return [];
+        const refs =
+          contribsByRef ??
+          findFn(thing, thingData, {mode: 'quiet'})?.[
+            parentContribsByRefProperty
+          ];
+        if (!refs) return [];
+        return refs
+          .map(({who: ref, what}) => ({
+            who: find.artist(ref, artistData),
+            what,
+          }))
+          .filter(({who}) => who);
+      },
+    },
+  }),
+
+  // Neat little shortcut for "reversing" the reference lists stored on other
+  // things - for example, tracks specify a "referenced tracks" property, and
+  // you would use this to compute a corresponding "referenced *by* tracks"
+  // property. Naturally, the passed ref list property is of the things in the
+  // wiki data provided, not the requesting Thing itself.
+  reverseReferenceList: (wikiDataProperty, referencerRefListProperty) => ({
+    flags: {expose: true},
 
-    // Corresponding dynamic property to referenceList, which takes the values
-    // in the provided property and searches the specified wiki data for
-    // matching actual Thing-subclass objects.
-    dynamicThingsFromReferenceList: (
-        referenceListProperty,
-        thingDataProperty,
-        findFn
-    ) => ({
-        flags: {expose: true},
-
-        expose: {
-            dependencies: [referenceListProperty, thingDataProperty],
-            compute: ({ [referenceListProperty]: refs, [thingDataProperty]: thingData }) => (
-                (refs && thingData
-                    ? (refs
-                        .map(ref => findFn(ref, thingData, {mode: 'quiet'}))
-                        .filter(Boolean))
-                    : [])
-            )
-        }
-    }),
-
-    // Corresponding function for a single reference.
-    dynamicThingFromSingleReference: (
-        singleReferenceProperty,
-        thingDataProperty,
-        findFn
-    ) => ({
-        flags: {expose: true},
-
-        expose: {
-            dependencies: [singleReferenceProperty, thingDataProperty],
-            compute: ({ [singleReferenceProperty]: ref, [thingDataProperty]: thingData }) => (
-                (ref && thingData ? findFn(ref, thingData, {mode: 'quiet'}) : null)
-            )
-        }
-    }),
-
-    // Corresponding dynamic property to contribsByRef, which takes the values
-    // in the provided property and searches the object's artistData for
-    // matching actual Artist objects. The computed structure has the same form
-    // as contribsByRef, but with Artist objects instead of string references:
-    //
-    //     [
-    //         {who: (an Artist), what: 'Viola'},
-    //         {who: (an Artist), what: null},
-    //         ...
-    //     ]
-    //
-    // Contributions whose "who" values don't match anything in artistData are
-    // filtered out. (So if the list is all empty, chances are that either the
-    // reference list is somehow messed up, or artistData isn't being provided
-    // properly.)
-    dynamicContribs: (contribsByRefProperty) => ({
-        flags: {expose: true},
-        expose: {
-            dependencies: ['artistData', contribsByRefProperty],
-            compute: ({ artistData, [contribsByRefProperty]: contribsByRef }) => (
-                ((contribsByRef && artistData)
-                    ? (contribsByRef
-                        .map(({ who: ref, what }) => ({
-                            who: find.artist(ref, artistData),
-                            what
-                        }))
-                        .filter(({ who }) => who))
-                    : [])
-            )
-        }
-    }),
-
-    // Dynamically inherit a contribution list from some other object, if it
-    // hasn't been overridden on this object. This is handy for solo albums
-    // where all tracks have the same artist, for example.
-    //
-    // Note: The arguments of this function aren't currently final! The final
-    // format will look more like (contribsByRef, parentContribsByRef), e.g.
-    // ('artistContribsByRef', '@album/artistContribsByRef').
-    dynamicInheritContribs: (
-        contribsByRefProperty,
-        parentContribsByRefProperty,
-        thingDataProperty,
-        findFn
-    ) => ({
-        flags: {expose: true},
-        expose: {
-            dependencies: [contribsByRefProperty, thingDataProperty, 'artistData'],
-            compute({
-                [Thing.instance]: thing,
-                [contribsByRefProperty]: contribsByRef,
-                [thingDataProperty]: thingData,
-                artistData
-            }) {
-                if (!artistData) return [];
-                const refs = (contribsByRef ?? findFn(thing, thingData, {mode: 'quiet'})?.[parentContribsByRefProperty]);
-                if (!refs) return [];
-                return (refs
-                    .map(({ who: ref, what }) => ({
-                        who: find.artist(ref, artistData),
-                        what
-                    }))
-                    .filter(({ who }) => who));
-            }
-        }
-    }),
-
-    // Neat little shortcut for "reversing" the reference lists stored on other
-    // things - for example, tracks specify a "referenced tracks" property, and
-    // you would use this to compute a corresponding "referenced *by* tracks"
-    // property. Naturally, the passed ref list property is of the things in the
-    // wiki data provided, not the requesting Thing itself.
-    reverseReferenceList: (wikiDataProperty, referencerRefListProperty) => ({
-        flags: {expose: true},
-
-        expose: {
-            dependencies: [wikiDataProperty],
-
-            compute: ({ [wikiDataProperty]: wikiData, [Thing.instance]: thing }) => (
-                (wikiData
-                    ? wikiData.filter(t => t[referencerRefListProperty]?.includes(thing))
-                    : [])
+    expose: {
+      dependencies: [wikiDataProperty],
+
+      compute: ({[wikiDataProperty]: wikiData, [Thing.instance]: thing}) =>
+        wikiData
+          ? wikiData.filter((t) =>
+              t[referencerRefListProperty]?.includes(thing)
             )
-        }
-    }),
+          : [],
+    },
+  }),
 
-    // Corresponding function for single references. Note that the return value
-    // is still a list - this is for matching all the objects whose single
-    // reference (in the given property) matches this Thing.
-    reverseSingleReference: (wikiDataProperty, referencerRefListProperty) => ({
-        flags: {expose: true},
+  // Corresponding function for single references. Note that the return value
+  // is still a list - this is for matching all the objects whose single
+  // reference (in the given property) matches this Thing.
+  reverseSingleReference: (wikiDataProperty, referencerRefListProperty) => ({
+    flags: {expose: true},
 
-        expose: {
-            dependencies: [wikiDataProperty],
+    expose: {
+      dependencies: [wikiDataProperty],
 
-            compute: ({ [wikiDataProperty]: wikiData, [Thing.instance]: thing }) => (
-                wikiData?.filter(t => t[referencerRefListProperty] === thing))
-        }
-    }),
-
-    // General purpose wiki data constructor, for properties like artistData,
-    // trackData, etc.
-    wikiData: (thingClass) => ({
-        flags: {update: true},
-        update: {
-            validate: validateArrayItems(validateInstanceOf(thingClass))
-        }
-    }),
-
-    // This one's kinda tricky: it parses artist "references" from the
-    // commentary content, and finds the matching artist for each reference.
-    // This is mostly useful for credits and listings on artist pages.
-    commentatorArtists: () => ({
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['artistData', 'commentary'],
-
-            compute: ({ artistData, commentary }) => (
-                (artistData && commentary
-                    ? Array.from(new Set((Array
-                        .from(commentary
-                            .replace(/<\/?b>/g, '')
-                            .matchAll(/<i>(?<who>.*?):<\/i>/g))
-                        .map(({ groups: {who} }) => find.artist(who, artistData, {mode: 'quiet'})))))
-                    : []))
-        }
-    }),
+      compute: ({[wikiDataProperty]: wikiData, [Thing.instance]: thing}) =>
+        wikiData?.filter((t) => t[referencerRefListProperty] === thing),
+    },
+  }),
+
+  // General purpose wiki data constructor, for properties like artistData,
+  // trackData, etc.
+  wikiData: (thingClass) => ({
+    flags: {update: true},
+    update: {
+      validate: validateArrayItems(validateInstanceOf(thingClass)),
+    },
+  }),
+
+  // This one's kinda tricky: it parses artist "references" from the
+  // commentary content, and finds the matching artist for each reference.
+  // This is mostly useful for credits and listings on artist pages.
+  commentatorArtists: () => ({
+    flags: {expose: true},
+
+    expose: {
+      dependencies: ['artistData', 'commentary'],
+
+      compute: ({artistData, commentary}) =>
+        artistData && commentary
+          ? Array.from(
+              new Set(
+                Array.from(
+                  commentary
+                    .replace(/<\/?b>/g, '')
+                    .matchAll(/<i>(?<who>.*?):<\/i>/g)
+                ).map(({groups: {who}}) =>
+                  find.artist(who, artistData, {mode: 'quiet'})
+                )
+              )
+            )
+          : [],
+    },
+  }),
 };
 
 // Get a reference to a thing (e.g. track:showtime-piano-refrain), using its
 // constructor's [Thing.referenceType] as the prefix. This will throw an error
 // if the thing's directory isn't yet provided/computable.
-Thing.getReference = function(thing) {
-    if (!thing.constructor[Thing.referenceType])
-        throw TypeError(`Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`);
-
-    if (!thing.directory)
-        throw TypeError(`Passed ${thing.constructor.name} is missing its directory`);
-
-    return `${thing.constructor[Thing.referenceType]}:${thing.directory}`;
+Thing.getReference = function (thing) {
+  if (!thing.constructor[Thing.referenceType])
+    throw TypeError(
+      `Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`
+    );
+
+  if (!thing.directory)
+    throw TypeError(
+      `Passed ${thing.constructor.name} is missing its directory`
+    );
+
+  return `${thing.constructor[Thing.referenceType]}:${thing.directory}`;
 };
 
 // Default custom inspect function, which may be overridden by Thing subclasses.
 // This will be used when displaying aggregate errors and other in command-line
 // logging - it's the place to provide information useful in identifying the
 // Thing being presented.
-Thing.prototype[inspect.custom] = function() {
-    const cname = this.constructor.name;
-
-    return (this.name
-        ? `${cname} ${color.green(`"${this.name}"`)}`
-        : `${cname}`) + (this.directory
-            ? ` (${color.blue(Thing.getReference(this))})`
-            : '');
+Thing.prototype[inspect.custom] = function () {
+  const cname = this.constructor.name;
+
+  return (
+    (this.name ? `${cname} ${color.green(`"${this.name}"`)}` : `${cname}`) +
+    (this.directory ? ` (${color.blue(Thing.getReference(this))})` : '')
+  );
 };
 
 // -> Album
 
 Album.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Album'),
-    color: Thing.common.color(),
-    directory: Thing.common.directory(),
-    urls: Thing.common.urls(),
+  name: Thing.common.name('Unnamed Album'),
+  color: Thing.common.color(),
+  directory: Thing.common.directory(),
+  urls: Thing.common.urls(),
 
-    date: Thing.common.simpleDate(),
-    trackArtDate: Thing.common.simpleDate(),
-    dateAddedToWiki: Thing.common.simpleDate(),
+  date: Thing.common.simpleDate(),
+  trackArtDate: Thing.common.simpleDate(),
+  dateAddedToWiki: Thing.common.simpleDate(),
 
-    coverArtDate: {
-        flags: {update: true, expose: true},
+  coverArtDate: {
+    flags: {update: true, expose: true},
 
-        update: {validate: isDate},
+    update: {validate: isDate},
 
-        expose: {
-            dependencies: ['date'],
-            transform: (coverArtDate, { date }) => coverArtDate ?? date ?? null
-        }
+    expose: {
+      dependencies: ['date'],
+      transform: (coverArtDate, {date}) => coverArtDate ?? date ?? null,
     },
+  },
 
-    artistContribsByRef: Thing.common.contribsByRef(),
-    coverArtistContribsByRef: Thing.common.contribsByRef(),
-    trackCoverArtistContribsByRef: Thing.common.contribsByRef(),
-    wallpaperArtistContribsByRef: Thing.common.contribsByRef(),
-    bannerArtistContribsByRef: Thing.common.contribsByRef(),
+  artistContribsByRef: Thing.common.contribsByRef(),
+  coverArtistContribsByRef: Thing.common.contribsByRef(),
+  trackCoverArtistContribsByRef: Thing.common.contribsByRef(),
+  wallpaperArtistContribsByRef: Thing.common.contribsByRef(),
+  bannerArtistContribsByRef: Thing.common.contribsByRef(),
 
-    groupsByRef: Thing.common.referenceList(Group),
-    artTagsByRef: Thing.common.referenceList(ArtTag),
+  groupsByRef: Thing.common.referenceList(Group),
+  artTagsByRef: Thing.common.referenceList(ArtTag),
 
-    trackGroups: {
-        flags: {update: true, expose: true},
+  trackGroups: {
+    flags: {update: true, expose: true},
 
-        update: {
-            validate: validateArrayItems(validateInstanceOf(TrackGroup))
-        }
+    update: {
+      validate: validateArrayItems(validateInstanceOf(TrackGroup)),
     },
+  },
 
-    coverArtFileExtension: Thing.common.fileExtension('jpg'),
-    trackCoverArtFileExtension: Thing.common.fileExtension('jpg'),
+  coverArtFileExtension: Thing.common.fileExtension('jpg'),
+  trackCoverArtFileExtension: Thing.common.fileExtension('jpg'),
 
-    wallpaperStyle: Thing.common.simpleString(),
-    wallpaperFileExtension: Thing.common.fileExtension('jpg'),
-
-    bannerStyle: Thing.common.simpleString(),
-    bannerFileExtension: Thing.common.fileExtension('jpg'),
-    bannerDimensions: {
-        flags: {update: true, expose: true},
-        update: {validate: isDimensions}
-    },
+  wallpaperStyle: Thing.common.simpleString(),
+  wallpaperFileExtension: Thing.common.fileExtension('jpg'),
 
-    hasCoverArt: Thing.common.flag(true),
-    hasTrackArt: Thing.common.flag(true),
-    hasTrackNumbers: Thing.common.flag(true),
-    isMajorRelease: Thing.common.flag(false),
-    isListedOnHomepage: Thing.common.flag(true),
+  bannerStyle: Thing.common.simpleString(),
+  bannerFileExtension: Thing.common.fileExtension('jpg'),
+  bannerDimensions: {
+    flags: {update: true, expose: true},
+    update: {validate: isDimensions},
+  },
 
-    commentary: Thing.common.commentary(),
-    additionalFiles: Thing.common.additionalFiles(),
+  hasCoverArt: Thing.common.flag(true),
+  hasTrackArt: Thing.common.flag(true),
+  hasTrackNumbers: Thing.common.flag(true),
+  isMajorRelease: Thing.common.flag(false),
+  isListedOnHomepage: Thing.common.flag(true),
 
-    // Update only
+  commentary: Thing.common.commentary(),
+  additionalFiles: Thing.common.additionalFiles(),
 
-    artistData: Thing.common.wikiData(Artist),
-    artTagData: Thing.common.wikiData(ArtTag),
-    groupData: Thing.common.wikiData(Group),
-    trackData: Thing.common.wikiData(Track),
+  // Update only
 
-    // Expose only
+  artistData: Thing.common.wikiData(Artist),
+  artTagData: Thing.common.wikiData(ArtTag),
+  groupData: Thing.common.wikiData(Group),
+  trackData: Thing.common.wikiData(Track),
 
-    artistContribs: Thing.common.dynamicContribs('artistContribsByRef'),
-    coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'),
-    trackCoverArtistContribs: Thing.common.dynamicContribs('trackCoverArtistContribsByRef'),
-    wallpaperArtistContribs: Thing.common.dynamicContribs('wallpaperArtistContribsByRef'),
-    bannerArtistContribs: Thing.common.dynamicContribs('bannerArtistContribsByRef'),
+  // Expose only
 
-    commentatorArtists: Thing.common.commentatorArtists(),
+  artistContribs: Thing.common.dynamicContribs('artistContribsByRef'),
+  coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'),
+  trackCoverArtistContribs: Thing.common.dynamicContribs(
+    'trackCoverArtistContribsByRef'
+  ),
+  wallpaperArtistContribs: Thing.common.dynamicContribs(
+    'wallpaperArtistContribsByRef'
+  ),
+  bannerArtistContribs: Thing.common.dynamicContribs(
+    'bannerArtistContribsByRef'
+  ),
 
-    tracks: {
-        flags: {expose: true},
+  commentatorArtists: Thing.common.commentatorArtists(),
 
-        expose: {
-            dependencies: ['trackGroups', 'trackData'],
-            compute: ({ trackGroups, trackData }) => (
-                (trackGroups && trackData
-                    ? (trackGroups
-                        .flatMap(group => group.tracksByRef ?? [])
-                        .map(ref => find.track(ref, trackData, {mode: 'quiet'}))
-                        .filter(Boolean))
-                    : [])
-            )
-        }
-    },
-
-    groups: Thing.common.dynamicThingsFromReferenceList('groupsByRef', 'groupData', find.group),
+  tracks: {
+    flags: {expose: true},
 
-    artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag),
+    expose: {
+      dependencies: ['trackGroups', 'trackData'],
+      compute: ({trackGroups, trackData}) =>
+        trackGroups && trackData
+          ? trackGroups
+              .flatMap((group) => group.tracksByRef ?? [])
+              .map((ref) => find.track(ref, trackData, {mode: 'quiet'}))
+              .filter(Boolean)
+          : [],
+    },
+  },
+
+  groups: Thing.common.dynamicThingsFromReferenceList(
+    'groupsByRef',
+    'groupData',
+    find.group
+  ),
+
+  artTags: Thing.common.dynamicThingsFromReferenceList(
+    'artTagsByRef',
+    'artTagData',
+    find.artTag
+  ),
 };
 
 Album[S.serializeDescriptors] = {
-    name: S.id,
-    color: S.id,
-    directory: S.id,
-    urls: S.id,
-
-    date: S.id,
-    coverArtDate: S.id,
-    trackArtDate: S.id,
-    dateAddedToWiki: S.id,
-
-    artistContribs: S.toContribRefs,
-    coverArtistContribs: S.toContribRefs,
-    trackCoverArtistContribs: S.toContribRefs,
-    wallpaperArtistContribs: S.toContribRefs,
-    bannerArtistContribs: S.toContribRefs,
-
-    coverArtFileExtension: S.id,
-    trackCoverArtFileExtension: S.id,
-    wallpaperStyle: S.id,
-    wallpaperFileExtension: S.id,
-    bannerStyle: S.id,
-    bannerFileExtension: S.id,
-    bannerDimensions: S.id,
-
-    hasTrackArt: S.id,
-    isMajorRelease: S.id,
-    isListedOnHomepage: S.id,
-
-    commentary: S.id,
-    additionalFiles: S.id,
-
-    tracks: S.toRefs,
-    groups: S.toRefs,
-    artTags: S.toRefs,
-    commentatorArtists: S.toRefs,
+  name: S.id,
+  color: S.id,
+  directory: S.id,
+  urls: S.id,
+
+  date: S.id,
+  coverArtDate: S.id,
+  trackArtDate: S.id,
+  dateAddedToWiki: S.id,
+
+  artistContribs: S.toContribRefs,
+  coverArtistContribs: S.toContribRefs,
+  trackCoverArtistContribs: S.toContribRefs,
+  wallpaperArtistContribs: S.toContribRefs,
+  bannerArtistContribs: S.toContribRefs,
+
+  coverArtFileExtension: S.id,
+  trackCoverArtFileExtension: S.id,
+  wallpaperStyle: S.id,
+  wallpaperFileExtension: S.id,
+  bannerStyle: S.id,
+  bannerFileExtension: S.id,
+  bannerDimensions: S.id,
+
+  hasTrackArt: S.id,
+  isMajorRelease: S.id,
+  isListedOnHomepage: S.id,
+
+  commentary: S.id,
+  additionalFiles: S.id,
+
+  tracks: S.toRefs,
+  groups: S.toRefs,
+  artTags: S.toRefs,
+  commentatorArtists: S.toRefs,
 };
 
 TrackGroup.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Track Group'),
+  name: Thing.common.name('Unnamed Track Group'),
 
-    color: {
-        flags: {update: true, expose: true},
+  color: {
+    flags: {update: true, expose: true},
 
-        update: {validate: isColor},
+    update: {validate: isColor},
 
-        expose: {
-            dependencies: ['album'],
+    expose: {
+      dependencies: ['album'],
 
-            transform(color, { album }) {
-                return color ?? album?.color ?? null;
-            }
-        }
+      transform(color, {album}) {
+        return color ?? album?.color ?? null;
+      },
     },
+  },
 
-    dateOriginallyReleased: Thing.common.simpleDate(),
+  dateOriginallyReleased: Thing.common.simpleDate(),
 
-    tracksByRef: Thing.common.referenceList(Track),
+  tracksByRef: Thing.common.referenceList(Track),
 
-    isDefaultTrackGroup: Thing.common.flag(false),
+  isDefaultTrackGroup: Thing.common.flag(false),
 
-    // Update only
+  // Update only
 
-    album: {
-        flags: {update: true},
-        update: {validate: validateInstanceOf(Album)}
-    },
+  album: {
+    flags: {update: true},
+    update: {validate: validateInstanceOf(Album)},
+  },
 
-    trackData: Thing.common.wikiData(Track),
+  trackData: Thing.common.wikiData(Track),
 
-    // Expose only
+  // Expose only
 
-    tracks: {
-        flags: {expose: true},
+  tracks: {
+    flags: {expose: true},
 
-        expose: {
-            dependencies: ['tracksByRef', 'trackData'],
-            compute: ({ tracksByRef, trackData }) => (
-                (tracksByRef && trackData
-                    ? (tracksByRef
-                        .map(ref => find.track(ref, trackData))
-                        .filter(Boolean))
-                    : [])
-            )
-        }
+    expose: {
+      dependencies: ['tracksByRef', 'trackData'],
+      compute: ({tracksByRef, trackData}) =>
+        tracksByRef && trackData
+          ? tracksByRef.map((ref) => find.track(ref, trackData)).filter(Boolean)
+          : [],
     },
+  },
 
-    startIndex: {
-        flags: {expose: true},
+  startIndex: {
+    flags: {expose: true},
 
-        expose: {
-            dependencies: ['album'],
-            compute: ({ album, [TrackGroup.instance]: trackGroup }) => (album.trackGroups
-                .slice(0, album.trackGroups.indexOf(trackGroup))
-                .reduce((acc, tg) => acc + tg.tracks.length, 0))
-        }
+    expose: {
+      dependencies: ['album'],
+      compute: ({album, [TrackGroup.instance]: trackGroup}) =>
+        album.trackGroups
+          .slice(0, album.trackGroups.indexOf(trackGroup))
+          .reduce((acc, tg) => acc + tg.tracks.length, 0),
     },
+  },
 };
 
 // -> Track
@@ -665,1059 +693,1193 @@ TrackGroup.propertyDescriptors = {
 // several places. Ideally it wouldn't be - we'd just reuse the `album` property
 // - but support for that hasn't been coded yet :P
 Track.findAlbum = (track, albumData) => {
-    return albumData?.find(album => album.tracks.includes(track));
+  return albumData?.find((album) => album.tracks.includes(track));
 };
 
 // Another reused utility function. This one's logic is a bit more complicated.
-Track.hasCoverArt = (track, albumData, coverArtistContribsByRef, hasCoverArt) => {
-    return (
-        hasCoverArt ??
-        (coverArtistContribsByRef?.length > 0 || null) ??
-        Track.findAlbum(track, albumData)?.hasTrackArt ??
-        true);
+Track.hasCoverArt = (
+  track,
+  albumData,
+  coverArtistContribsByRef,
+  hasCoverArt
+) => {
+  return (
+    hasCoverArt ??
+    (coverArtistContribsByRef?.length > 0 || null) ??
+    Track.findAlbum(track, albumData)?.hasTrackArt ??
+    true
+  );
 };
 
 Track.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Track'),
-    directory: Thing.common.directory(),
+  name: Thing.common.name('Unnamed Track'),
+  directory: Thing.common.directory(),
 
-    duration: {
-        flags: {update: true, expose: true},
-        update: {validate: isDuration}
-    },
+  duration: {
+    flags: {update: true, expose: true},
+    update: {validate: isDuration},
+  },
 
-    urls: Thing.common.urls(),
-    dateFirstReleased: Thing.common.simpleDate(),
+  urls: Thing.common.urls(),
+  dateFirstReleased: Thing.common.simpleDate(),
 
-    hasURLs: Thing.common.flag(true),
+  hasURLs: Thing.common.flag(true),
 
-    artistContribsByRef: Thing.common.contribsByRef(),
-    contributorContribsByRef: Thing.common.contribsByRef(),
-    coverArtistContribsByRef: Thing.common.contribsByRef(),
+  artistContribsByRef: Thing.common.contribsByRef(),
+  contributorContribsByRef: Thing.common.contribsByRef(),
+  coverArtistContribsByRef: Thing.common.contribsByRef(),
 
-    referencedTracksByRef: Thing.common.referenceList(Track),
-    artTagsByRef: Thing.common.referenceList(ArtTag),
+  referencedTracksByRef: Thing.common.referenceList(Track),
+  artTagsByRef: Thing.common.referenceList(ArtTag),
 
-    hasCoverArt: {
-        flags: {update: true, expose: true},
+  hasCoverArt: {
+    flags: {update: true, expose: true},
 
-        update: {validate: isBoolean},
+    update: {validate: isBoolean},
 
-        expose: {
-            dependencies: ['albumData', 'coverArtistContribsByRef'],
-            transform: (hasCoverArt, { albumData, coverArtistContribsByRef, [Track.instance]: track }) => (
-                Track.hasCoverArt(track, albumData, coverArtistContribsByRef, hasCoverArt))
-        }
+    expose: {
+      dependencies: ['albumData', 'coverArtistContribsByRef'],
+      transform: (
+        hasCoverArt,
+        {albumData, coverArtistContribsByRef, [Track.instance]: track}
+      ) =>
+        Track.hasCoverArt(
+          track,
+          albumData,
+          coverArtistContribsByRef,
+          hasCoverArt
+        ),
     },
+  },
 
-    coverArtFileExtension: {
-        flags: {update: true, expose: true},
+  coverArtFileExtension: {
+    flags: {update: true, expose: true},
 
-        update: {validate: isFileExtension},
+    update: {validate: isFileExtension},
 
-        expose: {
-            dependencies: ['albumData', 'coverArtistContribsByRef'],
-            transform: (coverArtFileExtension, { albumData, coverArtistContribsByRef, hasCoverArt, [Track.instance]: track }) => (
-                coverArtFileExtension ??
-                (Track.hasCoverArt(track, albumData, coverArtistContribsByRef, hasCoverArt)
-                    ? Track.findAlbum(track, albumData)?.trackCoverArtFileExtension
-                    : Track.findAlbum(track, albumData)?.coverArtFileExtension) ??
-                'jpg')
+    expose: {
+      dependencies: ['albumData', 'coverArtistContribsByRef'],
+      transform: (
+        coverArtFileExtension,
+        {
+          albumData,
+          coverArtistContribsByRef,
+          hasCoverArt,
+          [Track.instance]: track,
         }
+      ) =>
+        coverArtFileExtension ??
+        (Track.hasCoverArt(
+          track,
+          albumData,
+          coverArtistContribsByRef,
+          hasCoverArt
+        )
+          ? Track.findAlbum(track, albumData)?.trackCoverArtFileExtension
+          : Track.findAlbum(track, albumData)?.coverArtFileExtension) ??
+        'jpg',
     },
+  },
 
-    // Previously known as: (track).aka
-    originalReleaseTrackByRef: Thing.common.singleReference(Track),
+  // Previously known as: (track).aka
+  originalReleaseTrackByRef: Thing.common.singleReference(Track),
 
-    dataSourceAlbumByRef: Thing.common.singleReference(Album),
+  dataSourceAlbumByRef: Thing.common.singleReference(Album),
 
-    commentary: Thing.common.commentary(),
-    lyrics: Thing.common.simpleString(),
-    additionalFiles: Thing.common.additionalFiles(),
+  commentary: Thing.common.commentary(),
+  lyrics: Thing.common.simpleString(),
+  additionalFiles: Thing.common.additionalFiles(),
 
-    // Update only
+  // Update only
 
-    albumData: Thing.common.wikiData(Album),
-    artistData: Thing.common.wikiData(Artist),
-    artTagData: Thing.common.wikiData(ArtTag),
-    flashData: Thing.common.wikiData(Flash),
-    trackData: Thing.common.wikiData(Track),
+  albumData: Thing.common.wikiData(Album),
+  artistData: Thing.common.wikiData(Artist),
+  artTagData: Thing.common.wikiData(ArtTag),
+  flashData: Thing.common.wikiData(Flash),
+  trackData: Thing.common.wikiData(Track),
 
-    // Expose only
+  // Expose only
 
-    commentatorArtists: Thing.common.commentatorArtists(),
+  commentatorArtists: Thing.common.commentatorArtists(),
 
-    album: {
-        flags: {expose: true},
+  album: {
+    flags: {expose: true},
 
-        expose: {
-            dependencies: ['albumData'],
-            compute: ({ [Track.instance]: track, albumData }) => (
-                albumData?.find(album => album.tracks.includes(track)) ?? null)
-        }
-    },
+    expose: {
+      dependencies: ['albumData'],
+      compute: ({[Track.instance]: track, albumData}) =>
+        albumData?.find((album) => album.tracks.includes(track)) ?? null,
+    },
+  },
+
+  // Note - this is an internal property used only to help identify a track.
+  // It should not be assumed in general that the album and dataSourceAlbum match
+  // (i.e. a track may dynamically be moved from one album to another, at
+  // which point dataSourceAlbum refers to where it was originally from, and is
+  // not generally relevant information). It's also not guaranteed that
+  // dataSourceAlbum is available (depending on the Track creator to optionally
+  // provide dataSourceAlbumByRef).
+  dataSourceAlbum: Thing.common.dynamicThingFromSingleReference(
+    'dataSourceAlbumByRef',
+    'albumData',
+    find.album
+  ),
+
+  date: {
+    flags: {expose: true},
 
-    // Note - this is an internal property used only to help identify a track.
-    // It should not be assumed in general that the album and dataSourceAlbum match
-    // (i.e. a track may dynamically be moved from one album to another, at
-    // which point dataSourceAlbum refers to where it was originally from, and is
-    // not generally relevant information). It's also not guaranteed that
-    // dataSourceAlbum is available (depending on the Track creator to optionally
-    // provide dataSourceAlbumByRef).
-    dataSourceAlbum: Thing.common.dynamicThingFromSingleReference('dataSourceAlbumByRef', 'albumData', find.album),
-
-    date: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['albumData', 'dateFirstReleased'],
-            compute: ({ albumData, dateFirstReleased, [Track.instance]: track }) => (
-                dateFirstReleased ??
-                Track.findAlbum(track, albumData)?.date ??
-                null
-            )
-        }
+    expose: {
+      dependencies: ['albumData', 'dateFirstReleased'],
+      compute: ({albumData, dateFirstReleased, [Track.instance]: track}) =>
+        dateFirstReleased ?? Track.findAlbum(track, albumData)?.date ?? null,
     },
+  },
 
-    color: {
-        flags: {expose: true},
+  color: {
+    flags: {expose: true},
 
-        expose: {
-            dependencies: ['albumData'],
+    expose: {
+      dependencies: ['albumData'],
 
-            compute: ({ albumData, [Track.instance]: track }) => (
-                (Track.findAlbum(track, albumData)?.trackGroups
-                    .find(tg => tg.tracks.includes(track))?.color)
-                ?? null
-            )
-        }
+      compute: ({albumData, [Track.instance]: track}) =>
+        Track.findAlbum(track, albumData)?.trackGroups.find((tg) =>
+          tg.tracks.includes(track)
+        )?.color ?? null,
     },
+  },
 
-    coverArtDate: {
-        flags: {update: true, expose: true},
+  coverArtDate: {
+    flags: {update: true, expose: true},
 
-        update: {validate: isDate},
+    update: {validate: isDate},
 
-        expose: {
-            dependencies: ['albumData', 'dateFirstReleased'],
-            transform: (coverArtDate, { albumData, dateFirstReleased, [Track.instance]: track }) => (
-                coverArtDate ??
-                dateFirstReleased ??
-                Track.findAlbum(track, albumData)?.trackArtDate ??
-                Track.findAlbum(track, albumData)?.date ??
-                null
-            )
-        }
-    },
-
-    originalReleaseTrack: Thing.common.dynamicThingFromSingleReference('originalReleaseTrackByRef', 'trackData', find.track),
-
-    otherReleases: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['originalReleaseTrackByRef', 'trackData'],
-
-            compute: ({ originalReleaseTrackByRef: t1origRef, trackData, [Track.instance]: t1 }) => {
-                if (!trackData) {
-                    return [];
-                }
-
-                const t1orig = find.track(t1origRef, trackData);
-
-                return [
-                    t1orig,
-                    ...trackData.filter(t2 => {
-                        const { originalReleaseTrack: t2orig } = t2;
-                        return (
-                            t2 !== t1 &&
-                            t2orig &&
-                            (t2orig === t1orig || t2orig === t1)
-                        );
-                    })
-                ].filter(Boolean);
-            }
-        }
-    },
+    expose: {
+      dependencies: ['albumData', 'dateFirstReleased'],
+      transform: (
+        coverArtDate,
+        {albumData, dateFirstReleased, [Track.instance]: track}
+      ) =>
+        coverArtDate ??
+        dateFirstReleased ??
+        Track.findAlbum(track, albumData)?.trackArtDate ??
+        Track.findAlbum(track, albumData)?.date ??
+        null,
+    },
+  },
+
+  originalReleaseTrack: Thing.common.dynamicThingFromSingleReference(
+    'originalReleaseTrackByRef',
+    'trackData',
+    find.track
+  ),
+
+  otherReleases: {
+    flags: {expose: true},
 
-    // Previously known as: (track).artists
-    artistContribs: Thing.common.dynamicInheritContribs('artistContribsByRef', 'artistContribsByRef', 'albumData', Track.findAlbum),
-
-    // Previously known as: (track).contributors
-    contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'),
-
-    // Previously known as: (track).coverArtists
-    coverArtistContribs: Thing.common.dynamicInheritContribs('coverArtistContribsByRef', 'trackCoverArtistContribsByRef', 'albumData', Track.findAlbum),
-
-    // Previously known as: (track).references
-    referencedTracks: Thing.common.dynamicThingsFromReferenceList('referencedTracksByRef', 'trackData', find.track),
-
-    // Specifically exclude re-releases from this list - while it's useful to
-    // get from a re-release to the tracks it references, re-releases aren't
-    // generally relevant from the perspective of the tracks being referenced.
-    // Filtering them from data here hides them from the corresponding field
-    // on the site (obviously), and has the bonus of not counting them when
-    // counting the number of times a track has been referenced, for use in
-    // the "Tracks - by Times Referenced" listing page (or other data
-    // processing).
-    referencedByTracks: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['trackData'],
-
-            compute: ({ trackData, [Track.instance]: track }) => (trackData
-                ? (trackData
-                    .filter(t => !t.originalReleaseTrack)
-                    .filter(t => t.referencedTracks?.includes(track)))
-                : [])
+    expose: {
+      dependencies: ['originalReleaseTrackByRef', 'trackData'],
+
+      compute: ({
+        originalReleaseTrackByRef: t1origRef,
+        trackData,
+        [Track.instance]: t1,
+      }) => {
+        if (!trackData) {
+          return [];
         }
-    },
 
-    // Previously known as: (track).flashes
-    featuredInFlashes: Thing.common.reverseReferenceList('flashData', 'featuredTracks'),
+        const t1orig = find.track(t1origRef, trackData);
+
+        return [
+          t1orig,
+          ...trackData.filter((t2) => {
+            const {originalReleaseTrack: t2orig} = t2;
+            return t2 !== t1 && t2orig && (t2orig === t1orig || t2orig === t1);
+          }),
+        ].filter(Boolean);
+      },
+    },
+  },
+
+  // Previously known as: (track).artists
+  artistContribs: Thing.common.dynamicInheritContribs(
+    'artistContribsByRef',
+    'artistContribsByRef',
+    'albumData',
+    Track.findAlbum
+  ),
+
+  // Previously known as: (track).contributors
+  contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'),
+
+  // Previously known as: (track).coverArtists
+  coverArtistContribs: Thing.common.dynamicInheritContribs(
+    'coverArtistContribsByRef',
+    'trackCoverArtistContribsByRef',
+    'albumData',
+    Track.findAlbum
+  ),
+
+  // Previously known as: (track).references
+  referencedTracks: Thing.common.dynamicThingsFromReferenceList(
+    'referencedTracksByRef',
+    'trackData',
+    find.track
+  ),
+
+  // Specifically exclude re-releases from this list - while it's useful to
+  // get from a re-release to the tracks it references, re-releases aren't
+  // generally relevant from the perspective of the tracks being referenced.
+  // Filtering them from data here hides them from the corresponding field
+  // on the site (obviously), and has the bonus of not counting them when
+  // counting the number of times a track has been referenced, for use in
+  // the "Tracks - by Times Referenced" listing page (or other data
+  // processing).
+  referencedByTracks: {
+    flags: {expose: true},
 
-    artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag),
+    expose: {
+      dependencies: ['trackData'],
+
+      compute: ({trackData, [Track.instance]: track}) =>
+        trackData
+          ? trackData
+              .filter((t) => !t.originalReleaseTrack)
+              .filter((t) => t.referencedTracks?.includes(track))
+          : [],
+    },
+  },
+
+  // Previously known as: (track).flashes
+  featuredInFlashes: Thing.common.reverseReferenceList(
+    'flashData',
+    'featuredTracks'
+  ),
+
+  artTags: Thing.common.dynamicThingsFromReferenceList(
+    'artTagsByRef',
+    'artTagData',
+    find.artTag
+  ),
 };
 
-Track.prototype[inspect.custom] = function() {
-    const base = Thing.prototype[inspect.custom].apply(this);
+Track.prototype[inspect.custom] = function () {
+  const base = Thing.prototype[inspect.custom].apply(this);
 
-    const { album, dataSourceAlbum } = this;
-    const albumName = (album ? album.name : dataSourceAlbum?.name);
-    const albumIndex = albumName && (album ? album.tracks.indexOf(this) : dataSourceAlbum.tracks.indexOf(this));
-    const trackNum = (albumIndex === -1 ? '#?' : `#${albumIndex + 1}`);
+  const {album, dataSourceAlbum} = this;
+  const albumName = album ? album.name : dataSourceAlbum?.name;
+  const albumIndex =
+    albumName &&
+    (album ? album.tracks.indexOf(this) : dataSourceAlbum.tracks.indexOf(this));
+  const trackNum = albumIndex === -1 ? '#?' : `#${albumIndex + 1}`;
 
-    return (albumName
-        ? base + ` (${color.yellow(trackNum)} in ${color.green(albumName)})`
-        : base);
+  return albumName
+    ? base + ` (${color.yellow(trackNum)} in ${color.green(albumName)})`
+    : base;
 };
 
 // -> Artist
 
 Artist.filterByContrib = (thingDataProperty, contribsProperty) => ({
-    flags: {expose: true},
+  flags: {expose: true},
 
-    expose: {
-        dependencies: [thingDataProperty],
+  expose: {
+    dependencies: [thingDataProperty],
 
-        compute: ({ [thingDataProperty]: thingData, [Artist.instance]: artist }) => (
-            thingData?.filter(({ [contribsProperty]: contribs }) => (
-                contribs?.some(contrib => contrib.who === artist))))
-    }
+    compute: ({[thingDataProperty]: thingData, [Artist.instance]: artist}) =>
+      thingData?.filter(({[contribsProperty]: contribs}) =>
+        contribs?.some((contrib) => contrib.who === artist)
+      ),
+  },
 });
 
 Artist.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Artist'),
-    directory: Thing.common.directory(),
-    urls: Thing.common.urls(),
-    contextNotes: Thing.common.simpleString(),
+  name: Thing.common.name('Unnamed Artist'),
+  directory: Thing.common.directory(),
+  urls: Thing.common.urls(),
+  contextNotes: Thing.common.simpleString(),
 
-    hasAvatar: Thing.common.flag(false),
-    avatarFileExtension: Thing.common.fileExtension('jpg'),
+  hasAvatar: Thing.common.flag(false),
+  avatarFileExtension: Thing.common.fileExtension('jpg'),
 
-    aliasNames: {
-        flags: {update: true, expose: true},
-        update: {
-            validate: validateArrayItems(isName)
-        }
+  aliasNames: {
+    flags: {update: true, expose: true},
+    update: {
+      validate: validateArrayItems(isName),
     },
+  },
 
-    isAlias: Thing.common.flag(),
-    aliasedArtistRef: Thing.common.singleReference(Artist),
-
-    // Update only
-
-    albumData: Thing.common.wikiData(Album),
-    artistData: Thing.common.wikiData(Artist),
-    flashData: Thing.common.wikiData(Flash),
-    trackData: Thing.common.wikiData(Track),
-
-    // Expose only
-
-    aliasedArtist: {
-        flags: {expose: true},
+  isAlias: Thing.common.flag(),
+  aliasedArtistRef: Thing.common.singleReference(Artist),
 
-        expose: {
-            dependencies: ['artistData', 'aliasedArtistRef'],
-            compute: ({ artistData, aliasedArtistRef }) => (
-                (aliasedArtistRef && artistData
-                    ? find.artist(aliasedArtistRef, artistData, {mode: 'quiet'})
-                    : null)
-            )
-        }
-    },
-
-    tracksAsArtist: Artist.filterByContrib('trackData', 'artistContribs'),
-    tracksAsContributor: Artist.filterByContrib('trackData', 'contributorContribs'),
-    tracksAsCoverArtist: Artist.filterByContrib('trackData', 'coverArtistContribs'),
+  // Update only
 
-    tracksAsAny: {
-        flags: {expose: true},
+  albumData: Thing.common.wikiData(Album),
+  artistData: Thing.common.wikiData(Artist),
+  flashData: Thing.common.wikiData(Flash),
+  trackData: Thing.common.wikiData(Track),
 
-        expose: {
-            dependencies: ['trackData'],
+  // Expose only
 
-            compute: ({ trackData, [Artist.instance]: artist }) => (
-                trackData?.filter(track => (
-                    [
-                        ...track.artistContribs,
-                        ...track.contributorContribs,
-                        ...track.coverArtistContribs
-                    ].some(({ who }) => who === artist))))
-        }
-    },
+  aliasedArtist: {
+    flags: {expose: true},
 
-    tracksAsCommentator: {
-        flags: {expose: true},
+    expose: {
+      dependencies: ['artistData', 'aliasedArtistRef'],
+      compute: ({artistData, aliasedArtistRef}) =>
+        aliasedArtistRef && artistData
+          ? find.artist(aliasedArtistRef, artistData, {mode: 'quiet'})
+          : null,
+    },
+  },
+
+  tracksAsArtist: Artist.filterByContrib('trackData', 'artistContribs'),
+  tracksAsContributor: Artist.filterByContrib(
+    'trackData',
+    'contributorContribs'
+  ),
+  tracksAsCoverArtist: Artist.filterByContrib(
+    'trackData',
+    'coverArtistContribs'
+  ),
+
+  tracksAsAny: {
+    flags: {expose: true},
 
-        expose: {
-            dependencies: ['trackData'],
+    expose: {
+      dependencies: ['trackData'],
 
-            compute: ({ trackData, [Artist.instance]: artist }) => (
-                trackData.filter(({ commentatorArtists }) => commentatorArtists?.includes(artist)))
-        }
+      compute: ({trackData, [Artist.instance]: artist}) =>
+        trackData?.filter((track) =>
+          [
+            ...track.artistContribs,
+            ...track.contributorContribs,
+            ...track.coverArtistContribs,
+          ].some(({who}) => who === artist)
+        ),
     },
+  },
 
-    albumsAsAlbumArtist: Artist.filterByContrib('albumData', 'artistContribs'),
-    albumsAsCoverArtist: Artist.filterByContrib('albumData', 'coverArtistContribs'),
-    albumsAsWallpaperArtist: Artist.filterByContrib('albumData', 'wallpaperArtistContribs'),
-    albumsAsBannerArtist: Artist.filterByContrib('albumData', 'bannerArtistContribs'),
+  tracksAsCommentator: {
+    flags: {expose: true},
 
-    albumsAsCommentator: {
-        flags: {expose: true},
+    expose: {
+      dependencies: ['trackData'],
+
+      compute: ({trackData, [Artist.instance]: artist}) =>
+        trackData.filter(({commentatorArtists}) =>
+          commentatorArtists?.includes(artist)
+        ),
+    },
+  },
+
+  albumsAsAlbumArtist: Artist.filterByContrib('albumData', 'artistContribs'),
+  albumsAsCoverArtist: Artist.filterByContrib(
+    'albumData',
+    'coverArtistContribs'
+  ),
+  albumsAsWallpaperArtist: Artist.filterByContrib(
+    'albumData',
+    'wallpaperArtistContribs'
+  ),
+  albumsAsBannerArtist: Artist.filterByContrib(
+    'albumData',
+    'bannerArtistContribs'
+  ),
+
+  albumsAsCommentator: {
+    flags: {expose: true},
 
-        expose: {
-            dependencies: ['albumData'],
+    expose: {
+      dependencies: ['albumData'],
 
-            compute: ({ albumData, [Artist.instance]: artist }) => (
-                albumData.filter(({ commentatorArtists }) => commentatorArtists?.includes(artist)))
-        }
+      compute: ({albumData, [Artist.instance]: artist}) =>
+        albumData.filter(({commentatorArtists}) =>
+          commentatorArtists?.includes(artist)
+        ),
     },
+  },
 
-    flashesAsContributor: Artist.filterByContrib('flashData', 'contributorContribs'),
+  flashesAsContributor: Artist.filterByContrib(
+    'flashData',
+    'contributorContribs'
+  ),
 };
 
 Artist[S.serializeDescriptors] = {
-    name: S.id,
-    directory: S.id,
-    urls: S.id,
-    contextNotes: S.id,
+  name: S.id,
+  directory: S.id,
+  urls: S.id,
+  contextNotes: S.id,
 
-    hasAvatar: S.id,
-    avatarFileExtension: S.id,
+  hasAvatar: S.id,
+  avatarFileExtension: S.id,
 
-    aliasNames: S.id,
+  aliasNames: S.id,
 
-    tracksAsArtist: S.toRefs,
-    tracksAsContributor: S.toRefs,
-    tracksAsCoverArtist: S.toRefs,
-    tracksAsCommentator: S.toRefs,
+  tracksAsArtist: S.toRefs,
+  tracksAsContributor: S.toRefs,
+  tracksAsCoverArtist: S.toRefs,
+  tracksAsCommentator: S.toRefs,
 
-    albumsAsAlbumArtist: S.toRefs,
-    albumsAsCoverArtist: S.toRefs,
-    albumsAsWallpaperArtist: S.toRefs,
-    albumsAsBannerArtist: S.toRefs,
-    albumsAsCommentator: S.toRefs,
+  albumsAsAlbumArtist: S.toRefs,
+  albumsAsCoverArtist: S.toRefs,
+  albumsAsWallpaperArtist: S.toRefs,
+  albumsAsBannerArtist: S.toRefs,
+  albumsAsCommentator: S.toRefs,
 
-    flashesAsContributor: S.toRefs,
+  flashesAsContributor: S.toRefs,
 };
 
 // -> Group
 
 Group.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Group'),
-    directory: Thing.common.directory(),
+  name: Thing.common.name('Unnamed Group'),
+  directory: Thing.common.directory(),
 
-    description: Thing.common.simpleString(),
+  description: Thing.common.simpleString(),
 
-    urls: Thing.common.urls(),
+  urls: Thing.common.urls(),
 
-    // Update only
+  // Update only
 
-    albumData: Thing.common.wikiData(Album),
-    groupCategoryData: Thing.common.wikiData(GroupCategory),
+  albumData: Thing.common.wikiData(Album),
+  groupCategoryData: Thing.common.wikiData(GroupCategory),
 
-    // Expose only
+  // Expose only
 
-    descriptionShort: {
-        flags: {expose: true},
+  descriptionShort: {
+    flags: {expose: true},
 
-        expose: {
-            dependencies: ['description'],
-            compute: ({ description }) => description.split('<hr class="split">')[0]
-        }
+    expose: {
+      dependencies: ['description'],
+      compute: ({description}) => description.split('<hr class="split">')[0],
     },
+  },
 
-    albums: {
-        flags: {expose: true},
+  albums: {
+    flags: {expose: true},
 
-        expose: {
-            dependencies: ['albumData'],
-            compute: ({ albumData, [Group.instance]: group }) => (
-                albumData?.filter(album => album.groups.includes(group)) ?? [])
-        }
+    expose: {
+      dependencies: ['albumData'],
+      compute: ({albumData, [Group.instance]: group}) =>
+        albumData?.filter((album) => album.groups.includes(group)) ?? [],
     },
+  },
 
-    color: {
-        flags: {expose: true},
+  color: {
+    flags: {expose: true},
 
-        expose: {
-            dependencies: ['groupCategoryData'],
+    expose: {
+      dependencies: ['groupCategoryData'],
 
-            compute: ({ groupCategoryData, [Group.instance]: group }) => (
-                groupCategoryData.find(category => category.groups.includes(group))?.color ?? null)
-        }
+      compute: ({groupCategoryData, [Group.instance]: group}) =>
+        groupCategoryData.find((category) => category.groups.includes(group))
+          ?.color ?? null,
     },
+  },
 
-    category: {
-        flags: {expose: true},
+  category: {
+    flags: {expose: true},
 
-        expose: {
-            dependencies: ['groupCategoryData'],
-            compute: ({ groupCategoryData, [Group.instance]: group }) => (
-                groupCategoryData.find(category => category.groups.includes(group)) ?? null)
-        }
+    expose: {
+      dependencies: ['groupCategoryData'],
+      compute: ({groupCategoryData, [Group.instance]: group}) =>
+        groupCategoryData.find((category) => category.groups.includes(group)) ??
+        null,
     },
+  },
 };
 
 GroupCategory.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Group Category'),
-    color: Thing.common.color(),
+  name: Thing.common.name('Unnamed Group Category'),
+  color: Thing.common.color(),
 
-    groupsByRef: Thing.common.referenceList(Group),
+  groupsByRef: Thing.common.referenceList(Group),
 
-    // Update only
+  // Update only
 
-    groupData: Thing.common.wikiData(Group),
+  groupData: Thing.common.wikiData(Group),
 
-    // Expose only
+  // Expose only
 
-    groups: Thing.common.dynamicThingsFromReferenceList('groupsByRef', 'groupData', find.group),
+  groups: Thing.common.dynamicThingsFromReferenceList(
+    'groupsByRef',
+    'groupData',
+    find.group
+  ),
 };
 
 // -> ArtTag
 
 ArtTag.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Art Tag'),
-    directory: Thing.common.directory(),
-    color: Thing.common.color(),
-    isContentWarning: Thing.common.flag(false),
+  name: Thing.common.name('Unnamed Art Tag'),
+  directory: Thing.common.directory(),
+  color: Thing.common.color(),
+  isContentWarning: Thing.common.flag(false),
 
-    // Update only
+  // Update only
 
-    albumData: Thing.common.wikiData(Album),
-    trackData: Thing.common.wikiData(Track),
+  albumData: Thing.common.wikiData(Album),
+  trackData: Thing.common.wikiData(Track),
 
-    // Expose only
+  // Expose only
 
-    // Previously known as: (tag).things
-    taggedInThings: {
-        flags: {expose: true},
+  // Previously known as: (tag).things
+  taggedInThings: {
+    flags: {expose: true},
 
-        expose: {
-            dependencies: ['albumData', 'trackData'],
-            compute: ({ albumData, trackData, [ArtTag.instance]: artTag }) => (
-                sortAlbumsTracksChronologically(
-                    ([...albumData, ...trackData]
-                        .filter(thing => thing.artTags?.includes(artTag))),
-                    {getDate: o => o.coverArtDate}))
-        }
-    }
+    expose: {
+      dependencies: ['albumData', 'trackData'],
+      compute: ({albumData, trackData, [ArtTag.instance]: artTag}) =>
+        sortAlbumsTracksChronologically(
+          [...albumData, ...trackData].filter((thing) =>
+            thing.artTags?.includes(artTag)
+          ),
+          {getDate: (o) => o.coverArtDate}
+        ),
+    },
+  },
 };
 
 // -> NewsEntry
 
 NewsEntry.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed News Entry'),
-    directory: Thing.common.directory(),
-    date: Thing.common.simpleDate(),
+  name: Thing.common.name('Unnamed News Entry'),
+  directory: Thing.common.directory(),
+  date: Thing.common.simpleDate(),
 
-    content: Thing.common.simpleString(),
+  content: Thing.common.simpleString(),
 
-    // Expose only
+  // Expose only
 
-    contentShort: {
-        flags: {expose: true},
+  contentShort: {
+    flags: {expose: true},
 
-        expose: {
-            dependencies: ['content'],
+    expose: {
+      dependencies: ['content'],
 
-            compute: ({ content }) => content.split('<hr class="split">')[0]
-        }
+      compute: ({content}) => content.split('<hr class="split">')[0],
     },
+  },
 };
 
 // -> StaticPage
 
 StaticPage.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Static Page'),
+  name: Thing.common.name('Unnamed Static Page'),
 
-    nameShort: {
-        flags: {update: true, expose: true},
-        update: {validate: isName},
+  nameShort: {
+    flags: {update: true, expose: true},
+    update: {validate: isName},
 
-        expose: {
-            dependencies: ['name'],
-            transform: (value, { name }) => value ?? name
-        }
+    expose: {
+      dependencies: ['name'],
+      transform: (value, {name}) => value ?? name,
     },
+  },
 
-    directory: Thing.common.directory(),
-    content: Thing.common.simpleString(),
-    stylesheet: Thing.common.simpleString(),
-    showInNavigationBar: Thing.common.flag(true),
+  directory: Thing.common.directory(),
+  content: Thing.common.simpleString(),
+  stylesheet: Thing.common.simpleString(),
+  showInNavigationBar: Thing.common.flag(true),
 };
 
 // -> HomepageLayout
 
 HomepageLayout.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    sidebarContent: Thing.common.simpleString(),
+  sidebarContent: Thing.common.simpleString(),
 
-    rows: {
-        flags: {update: true, expose: true},
+  rows: {
+    flags: {update: true, expose: true},
 
-        update: {
-            validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow))
-        }
+    update: {
+      validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)),
     },
+  },
 };
 
 HomepageLayoutRow.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Homepage Row'),
+  name: Thing.common.name('Unnamed Homepage Row'),
 
-    type: {
-        flags: {update: true, expose: true},
+  type: {
+    flags: {update: true, expose: true},
 
-        update: {
-            validate(value) {
-                throw new Error(`'type' property validator must be overridden`);
-            }
-        }
+    update: {
+      validate() {
+        throw new Error(`'type' property validator must be overridden`);
+      },
     },
+  },
 
-    color: Thing.common.color(),
+  color: Thing.common.color(),
 
-    // Update only
+  // Update only
 
-    // These aren't necessarily used by every HomepageLayoutRow subclass, but
-    // for convenience of providing this data, every row accepts all wiki data
-    // arrays depended upon by any subclass's behavior.
-    albumData: Thing.common.wikiData(Album),
-    groupData: Thing.common.wikiData(Group),
+  // These aren't necessarily used by every HomepageLayoutRow subclass, but
+  // for convenience of providing this data, every row accepts all wiki data
+  // arrays depended upon by any subclass's behavior.
+  albumData: Thing.common.wikiData(Album),
+  groupData: Thing.common.wikiData(Group),
 };
 
 HomepageLayoutAlbumsRow.propertyDescriptors = {
-    ...HomepageLayoutRow.propertyDescriptors,
+  ...HomepageLayoutRow.propertyDescriptors,
 
-    // Update & expose
+  // Update & expose
 
-    type: {
-        flags: {update: true, expose: true},
-        update: {
-            validate(value) {
-                if (value !== 'albums') {
-                    throw new TypeError(`Expected 'albums'`);
-                }
-
-                return true;
-            }
+  type: {
+    flags: {update: true, expose: true},
+    update: {
+      validate(value) {
+        if (value !== 'albums') {
+          throw new TypeError(`Expected 'albums'`);
         }
+
+        return true;
+      },
     },
+  },
 
-    sourceGroupByRef: Thing.common.singleReference(Group),
-    sourceAlbumsByRef: Thing.common.referenceList(Album),
+  sourceGroupByRef: Thing.common.singleReference(Group),
+  sourceAlbumsByRef: Thing.common.referenceList(Album),
 
-    countAlbumsFromGroup: {
-        flags: {update: true, expose: true},
-        update: {validate: isCountingNumber}
-    },
+  countAlbumsFromGroup: {
+    flags: {update: true, expose: true},
+    update: {validate: isCountingNumber},
+  },
 
-    actionLinks: {
-        flags: {update: true, expose: true},
-        update: {validate: validateArrayItems(isString)}
-    },
+  actionLinks: {
+    flags: {update: true, expose: true},
+    update: {validate: validateArrayItems(isString)},
+  },
 
-    // Expose only
+  // Expose only
 
-    sourceGroup: Thing.common.dynamicThingFromSingleReference('sourceGroupByRef', 'groupData', find.group),
-    sourceAlbums: Thing.common.dynamicThingsFromReferenceList('sourceAlbumsByRef', 'albumData', find.album),
+  sourceGroup: Thing.common.dynamicThingFromSingleReference(
+    'sourceGroupByRef',
+    'groupData',
+    find.group
+  ),
+  sourceAlbums: Thing.common.dynamicThingsFromReferenceList(
+    'sourceAlbumsByRef',
+    'albumData',
+    find.album
+  ),
 };
 
 // -> Flash
 
 Flash.propertyDescriptors = {
-    // Update & expose
-
-    name: Thing.common.name('Unnamed Flash'),
-
-    directory: {
-        flags: {update: true, expose: true},
-        update: {validate: isDirectory},
-
-        // Flashes expose directory differently from other Things! Their
-        // default directory is dependent on the page number (or ID), not
-        // the name.
-        expose: {
-            dependencies: ['page'],
-            transform(directory, { page }) {
-                if (directory === null && page === null)
-                    return null;
-                else if (directory === null)
-                    return page;
-                else
-                    return directory;
-            }
-        }
+  // Update & expose
+
+  name: Thing.common.name('Unnamed Flash'),
+
+  directory: {
+    flags: {update: true, expose: true},
+    update: {validate: isDirectory},
+
+    // Flashes expose directory differently from other Things! Their
+    // default directory is dependent on the page number (or ID), not
+    // the name.
+    expose: {
+      dependencies: ['page'],
+      transform(directory, {page}) {
+        if (directory === null && page === null) return null;
+        else if (directory === null) return page;
+        else return directory;
+      },
     },
+  },
 
-    page: {
-        flags: {update: true, expose: true},
-        update: {validate: oneOf(isString, isNumber)},
+  page: {
+    flags: {update: true, expose: true},
+    update: {validate: oneOf(isString, isNumber)},
 
-        expose: {
-            transform: value => (value === null ? null : value.toString())
-        }
+    expose: {
+      transform: (value) => (value === null ? null : value.toString()),
     },
+  },
 
-    date: Thing.common.simpleDate(),
+  date: Thing.common.simpleDate(),
 
-    coverArtFileExtension: Thing.common.fileExtension('jpg'),
+  coverArtFileExtension: Thing.common.fileExtension('jpg'),
 
-    contributorContribsByRef: Thing.common.contribsByRef(),
+  contributorContribsByRef: Thing.common.contribsByRef(),
 
-    featuredTracksByRef: Thing.common.referenceList(Track),
+  featuredTracksByRef: Thing.common.referenceList(Track),
 
-    urls: Thing.common.urls(),
+  urls: Thing.common.urls(),
 
-    // Update only
+  // Update only
 
-    artistData: Thing.common.wikiData(Artist),
-    trackData: Thing.common.wikiData(Track),
-    flashActData: Thing.common.wikiData(FlashAct),
+  artistData: Thing.common.wikiData(Artist),
+  trackData: Thing.common.wikiData(Track),
+  flashActData: Thing.common.wikiData(FlashAct),
 
-    // Expose only
+  // Expose only
 
-    contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'),
+  contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'),
 
-    featuredTracks: Thing.common.dynamicThingsFromReferenceList('featuredTracksByRef', 'trackData', find.track),
+  featuredTracks: Thing.common.dynamicThingsFromReferenceList(
+    'featuredTracksByRef',
+    'trackData',
+    find.track
+  ),
 
-    act: {
-        flags: {expose: true},
+  act: {
+    flags: {expose: true},
 
-        expose: {
-            dependencies: ['flashActData'],
+    expose: {
+      dependencies: ['flashActData'],
 
-            compute: ({ flashActData, [Flash.instance]: flash }) => (
-                flashActData.find(act => act.flashes.includes(flash)) ?? null)
-        }
+      compute: ({flashActData, [Flash.instance]: flash}) =>
+        flashActData.find((act) => act.flashes.includes(flash)) ?? null,
     },
+  },
 
-    color: {
-        flags: {expose: true},
+  color: {
+    flags: {expose: true},
 
-        expose: {
-            dependencies: ['flashActData'],
+    expose: {
+      dependencies: ['flashActData'],
 
-            compute: ({ flashActData, [Flash.instance]: flash }) => (
-                flashActData.find(act => act.flashes.includes(flash))?.color ?? null)
-        }
+      compute: ({flashActData, [Flash.instance]: flash}) =>
+        flashActData.find((act) => act.flashes.includes(flash))?.color ?? null,
     },
+  },
 };
 
 Flash[S.serializeDescriptors] = {
-    name: S.id,
-    page: S.id,
-    directory: S.id,
-    date: S.id,
-    contributors: S.toContribRefs,
-    tracks: S.toRefs,
-    urls: S.id,
-    color: S.id,
+  name: S.id,
+  page: S.id,
+  directory: S.id,
+  date: S.id,
+  contributors: S.toContribRefs,
+  tracks: S.toRefs,
+  urls: S.id,
+  color: S.id,
 };
 
 FlashAct.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Flash Act'),
-    color: Thing.common.color(),
-    anchor: Thing.common.simpleString(),
-    jump: Thing.common.simpleString(),
-    jumpColor: Thing.common.color(),
+  name: Thing.common.name('Unnamed Flash Act'),
+  color: Thing.common.color(),
+  anchor: Thing.common.simpleString(),
+  jump: Thing.common.simpleString(),
+  jumpColor: Thing.common.color(),
 
-    flashesByRef: Thing.common.referenceList(Flash),
+  flashesByRef: Thing.common.referenceList(Flash),
 
-    // Update only
+  // Update only
 
-    flashData: Thing.common.wikiData(Flash),
+  flashData: Thing.common.wikiData(Flash),
 
-    // Expose only
+  // Expose only
 
-    flashes: Thing.common.dynamicThingsFromReferenceList('flashesByRef', 'flashData', find.flash),
+  flashes: Thing.common.dynamicThingsFromReferenceList(
+    'flashesByRef',
+    'flashData',
+    find.flash
+  ),
 };
 
 // -> WikiInfo
 
 WikiInfo.propertyDescriptors = {
-    // Update & expose
+  // Update & expose
 
-    name: Thing.common.name('Unnamed Wiki'),
+  name: Thing.common.name('Unnamed Wiki'),
 
-    // Displayed in nav bar.
-    nameShort: {
-        flags: {update: true, expose: true},
-        update: {validate: isName},
+  // Displayed in nav bar.
+  nameShort: {
+    flags: {update: true, expose: true},
+    update: {validate: isName},
 
-        expose: {
-            dependencies: ['name'],
-            transform: (value, { name }) => value ?? name
-        }
+    expose: {
+      dependencies: ['name'],
+      transform: (value, {name}) => value ?? name,
     },
+  },
 
-    color: Thing.common.color(),
+  color: Thing.common.color(),
 
-    // One-line description used for <meta rel="description"> tag.
-    description: Thing.common.simpleString(),
+  // One-line description used for <meta rel="description"> tag.
+  description: Thing.common.simpleString(),
 
-    footerContent: Thing.common.simpleString(),
+  footerContent: Thing.common.simpleString(),
 
-    defaultLanguage: {
-        flags: {update: true, expose: true},
-        update: {validate: isLanguageCode}
-    },
+  defaultLanguage: {
+    flags: {update: true, expose: true},
+    update: {validate: isLanguageCode},
+  },
 
-    canonicalBase: {
-        flags: {update: true, expose: true},
-        update: {validate: isURL}
-    },
+  canonicalBase: {
+    flags: {update: true, expose: true},
+    update: {validate: isURL},
+  },
 
-    divideTrackListsByGroupsByRef: Thing.common.referenceList(Group),
+  divideTrackListsByGroupsByRef: Thing.common.referenceList(Group),
 
-    // Feature toggles
-    enableFlashesAndGames: Thing.common.flag(false),
-    enableListings: Thing.common.flag(false),
-    enableNews: Thing.common.flag(false),
-    enableArtTagUI: Thing.common.flag(false),
-    enableGroupUI: Thing.common.flag(false),
+  // Feature toggles
+  enableFlashesAndGames: Thing.common.flag(false),
+  enableListings: Thing.common.flag(false),
+  enableNews: Thing.common.flag(false),
+  enableArtTagUI: Thing.common.flag(false),
+  enableGroupUI: Thing.common.flag(false),
 
-    // Update only
+  // Update only
 
-    groupData: Thing.common.wikiData(Group),
+  groupData: Thing.common.wikiData(Group),
 
-    // Expose only
+  // Expose only
 
-    divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList('divideTrackListsByGroupsByRef', 'groupData', find.group),
+  divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList(
+    'divideTrackListsByGroupsByRef',
+    'groupData',
+    find.group
+  ),
 };
 
 // -> Language
 
 const intlHelper = (constructor, opts) => ({
-    flags: {expose: true},
-    expose: {
-        dependencies: ['code', 'intlCode'],
-        compute: ({ code, intlCode }) => {
-            const constructCode = intlCode ?? code;
-            if (!constructCode) return null;
-            return Reflect.construct(constructor, [constructCode, opts]);
-        }
-    }
+  flags: {expose: true},
+  expose: {
+    dependencies: ['code', 'intlCode'],
+    compute: ({code, intlCode}) => {
+      const constructCode = intlCode ?? code;
+      if (!constructCode) return null;
+      return Reflect.construct(constructor, [constructCode, opts]);
+    },
+  },
 });
 
 Language.propertyDescriptors = {
-    // Update & expose
-
-    // General language code. This is used to identify the language distinctly
-    // from other languages (similar to how "Directory" operates in many data
-    // objects).
-    code: {
-        flags: {update: true, expose: true},
-        update: {validate: isLanguageCode}
-    },
-
-    // Human-readable name. This should be the language's own native name, not
-    // localized to any other language.
-    name: Thing.common.simpleString(),
-
-    // Language code specific to JavaScript's Internationalization (Intl) API.
-    // Usually this will be the same as the language's general code, but it
-    // may be overridden to provide Intl constructors an alternative value.
-    intlCode: {
-        flags: {update: true, expose: true},
-        update: {validate: isLanguageCode},
-        expose: {
-            dependencies: ['code'],
-            transform: (intlCode, { code }) => intlCode ?? code
-        }
-    },
-
-    // Flag which represents whether or not to hide a language from general
-    // access. If a language is hidden, its portion of the website will still
-    // be built (with all strings localized to the language), but it won't be
-    // included in controls for switching languages or the <link rel=alternate>
-    // tags used for search engine optimization. This flag is intended for use
-    // with languages that are currently in development and not ready for
-    // formal release, or which are just kept hidden as "experimental zones"
-    // for wiki development or content testing.
-    hidden: Thing.common.flag(false),
-
-    // Mapping of translation keys to values (strings). Generally, don't
-    // access this object directly - use methods instead.
-    strings: {
-        flags: {update: true, expose: true},
-        update: {validate: t => typeof t === 'object'},
-        expose: {
-            dependencies: ['inheritedStrings'],
-            transform(strings, { inheritedStrings }) {
-                if (strings || inheritedStrings) {
-                    return {...inheritedStrings ?? {}, ...strings ?? {}};
-                } else {
-                    return null;
-                }
-            }
+  // Update & expose
+
+  // General language code. This is used to identify the language distinctly
+  // from other languages (similar to how "Directory" operates in many data
+  // objects).
+  code: {
+    flags: {update: true, expose: true},
+    update: {validate: isLanguageCode},
+  },
+
+  // Human-readable name. This should be the language's own native name, not
+  // localized to any other language.
+  name: Thing.common.simpleString(),
+
+  // Language code specific to JavaScript's Internationalization (Intl) API.
+  // Usually this will be the same as the language's general code, but it
+  // may be overridden to provide Intl constructors an alternative value.
+  intlCode: {
+    flags: {update: true, expose: true},
+    update: {validate: isLanguageCode},
+    expose: {
+      dependencies: ['code'],
+      transform: (intlCode, {code}) => intlCode ?? code,
+    },
+  },
+
+  // Flag which represents whether or not to hide a language from general
+  // access. If a language is hidden, its portion of the website will still
+  // be built (with all strings localized to the language), but it won't be
+  // included in controls for switching languages or the <link rel=alternate>
+  // tags used for search engine optimization. This flag is intended for use
+  // with languages that are currently in development and not ready for
+  // formal release, or which are just kept hidden as "experimental zones"
+  // for wiki development or content testing.
+  hidden: Thing.common.flag(false),
+
+  // Mapping of translation keys to values (strings). Generally, don't
+  // access this object directly - use methods instead.
+  strings: {
+    flags: {update: true, expose: true},
+    update: {validate: (t) => typeof t === 'object'},
+    expose: {
+      dependencies: ['inheritedStrings'],
+      transform(strings, {inheritedStrings}) {
+        if (strings || inheritedStrings) {
+          return {...(inheritedStrings ?? {}), ...(strings ?? {})};
+        } else {
+          return null;
         }
+      },
     },
+  },
 
-    // May be provided to specify "default" strings, generally (but not
-    // necessarily) inherited from another Language object.
-    inheritedStrings: {
-        flags: {update: true, expose: true},
-        update: {validate: t => typeof t === 'object'}
-    },
+  // May be provided to specify "default" strings, generally (but not
+  // necessarily) inherited from another Language object.
+  inheritedStrings: {
+    flags: {update: true, expose: true},
+    update: {validate: (t) => typeof t === 'object'},
+  },
 
-    // Update only
+  // Update only
 
-    escapeHTML: Thing.common.externalFunction(),
+  escapeHTML: Thing.common.externalFunction(),
 
-    // Expose only
+  // Expose only
 
-    intl_date: intlHelper(Intl.DateTimeFormat, {full: true}),
-    intl_number: intlHelper(Intl.NumberFormat),
-    intl_listConjunction: intlHelper(Intl.ListFormat, {type: 'conjunction'}),
-    intl_listDisjunction: intlHelper(Intl.ListFormat, {type: 'disjunction'}),
-    intl_listUnit: intlHelper(Intl.ListFormat, {type: 'unit'}),
-    intl_pluralCardinal: intlHelper(Intl.PluralRules, {type: 'cardinal'}),
-    intl_pluralOrdinal: intlHelper(Intl.PluralRules, {type: 'ordinal'}),
+  intl_date: intlHelper(Intl.DateTimeFormat, {full: true}),
+  intl_number: intlHelper(Intl.NumberFormat),
+  intl_listConjunction: intlHelper(Intl.ListFormat, {type: 'conjunction'}),
+  intl_listDisjunction: intlHelper(Intl.ListFormat, {type: 'disjunction'}),
+  intl_listUnit: intlHelper(Intl.ListFormat, {type: 'unit'}),
+  intl_pluralCardinal: intlHelper(Intl.PluralRules, {type: 'cardinal'}),
+  intl_pluralOrdinal: intlHelper(Intl.PluralRules, {type: 'ordinal'}),
 
-    validKeys: {
-        flags: {expose: true},
-
-        expose: {
-            dependencies: ['strings', 'inheritedStrings'],
-            compute: ({ strings, inheritedStrings }) => Array.from(new Set([
-                ...Object.keys(inheritedStrings ?? {}),
-                ...Object.keys(strings ?? {})
-            ]))
-        }
-    },
+  validKeys: {
+    flags: {expose: true},
 
-    strings_htmlEscaped: {
-        flags: {expose: true},
-        expose: {
-            dependencies: ['strings', 'inheritedStrings', 'escapeHTML'],
-            compute({ strings, inheritedStrings, escapeHTML }) {
-                if (!(strings || inheritedStrings) || !escapeHTML) return null;
-                const allStrings = {...inheritedStrings ?? {}, ...strings ?? {}};
-                return Object.fromEntries(Object.entries(allStrings)
-                    .map(([ k, v ]) => [k, escapeHTML(v)]));
-            }
-        }
-    },
+    expose: {
+      dependencies: ['strings', 'inheritedStrings'],
+      compute: ({strings, inheritedStrings}) =>
+        Array.from(
+          new Set([
+            ...Object.keys(inheritedStrings ?? {}),
+            ...Object.keys(strings ?? {}),
+          ])
+        ),
+    },
+  },
+
+  strings_htmlEscaped: {
+    flags: {expose: true},
+    expose: {
+      dependencies: ['strings', 'inheritedStrings', 'escapeHTML'],
+      compute({strings, inheritedStrings, escapeHTML}) {
+        if (!(strings || inheritedStrings) || !escapeHTML) return null;
+        const allStrings = {...(inheritedStrings ?? {}), ...(strings ?? {})};
+        return Object.fromEntries(
+          Object.entries(allStrings).map(([k, v]) => [k, escapeHTML(v)])
+        );
+      },
+    },
+  },
 };
 
-const countHelper = (stringKey, argName = stringKey) => function(value, {unit = false} = {}) {
+const countHelper = (stringKey, argName = stringKey) =>
+  function (value, {unit = false} = {}) {
     return this.$(
-        (unit
-            ? `count.${stringKey}.withUnit.` + this.getUnitForm(value)
-            : `count.${stringKey}`),
-        {[argName]: this.formatNumber(value)});
-};
+      unit
+        ? `count.${stringKey}.withUnit.` + this.getUnitForm(value)
+        : `count.${stringKey}`,
+      {[argName]: this.formatNumber(value)}
+    );
+  };
 
 Object.assign(Language.prototype, {
-    $(key, args = {}) {
-        return this.formatString(key, args);
-    },
-
-    assertIntlAvailable(property) {
-        if (!this[property]) {
-            throw new Error(`Intl API ${property} unavailable`);
-        }
-    },
-
-    getUnitForm(value) {
-        this.assertIntlAvailable('intl_pluralCardinal');
-        return this.intl_pluralCardinal.select(value);
-    },
-
-    formatString(key, args = {}) {
-        if (this.strings && !this.strings_htmlEscaped) {
-            throw new Error(`HTML-escaped strings unavailable - please ensure escapeHTML function is provided`);
-        }
-
-        return this.formatStringHelper(this.strings_htmlEscaped, key, args);
-    },
-
-    formatStringNoHTMLEscape(key, args = {}) {
-        return this.formatStringHelper(this.strings, key, args);
-    },
-
-    formatStringHelper(strings, key, args = {}) {
-        if (!strings) {
-            throw new Error(`Strings unavailable`);
-        }
-
-        if (!this.validKeys.includes(key)) {
-            throw new Error(`Invalid key ${key} accessed`);
-        }
-
-        const template = strings[key];
-
-        // Convert the keys on the args dict from camelCase to CONSTANT_CASE.
-        // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut
-        // like, who cares, dude?) Also, this is an array, 8ecause it's handy
-        // for the iterating we're a8out to do.
-        const processedArgs = Object.entries(args)
-            .map(([ k, v ]) => [k.replace(/[A-Z]/g, '_$&').toUpperCase(), v]);
-
-        // Replacement time! Woot. Reduce comes in handy here!
-        const output = processedArgs.reduce(
-            (x, [ k, v ]) => x.replaceAll(`{${k}}`, v),
-            template);
-
-        // Post-processing: if any expected arguments *weren't* replaced, that
-        // is almost definitely an error.
-        if (output.match(/\{[A-Z_]+\}/)) {
-            throw new Error(`Args in ${key} were missing - output: ${output}`);
-        }
-
-        return output;
-    },
+  $(key, args = {}) {
+    return this.formatString(key, args);
+  },
 
-    formatDate(date) {
-        this.assertIntlAvailable('intl_date');
-        return this.intl_date.format(date);
-    },
-
-    formatDateRange(startDate, endDate) {
-        this.assertIntlAvailable('intl_date');
-        return this.intl_date.formatRange(startDate, endDate);
-    },
-
-    formatDuration(secTotal, {approximate = false, unit = false} = {}) {
-        if (secTotal === 0) {
-            return this.formatString('count.duration.missing');
-        }
-
-        const hour = Math.floor(secTotal / 3600);
-        const min = Math.floor((secTotal - hour * 3600) / 60);
-        const sec = Math.floor(secTotal - hour * 3600 - min * 60);
-
-        const pad = val => val.toString().padStart(2, '0');
-
-        const stringSubkey = unit ? '.withUnit' : '';
-
-        const duration = (hour > 0
-            ? this.formatString('count.duration.hours' + stringSubkey, {
-                hours: hour,
-                minutes: pad(min),
-                seconds: pad(sec)
-            })
-            : this.formatString('count.duration.minutes' + stringSubkey, {
-                minutes: min,
-                seconds: pad(sec)
-            }));
-
-        return (approximate
-            ? this.formatString('count.duration.approximate', {duration})
-            : duration);
-    },
-
-    formatIndex(value) {
-        this.assertIntlAvailable('intl_pluralOrdinal');
-        return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value});
-    },
-
-    formatNumber(value) {
-        this.assertIntlAvailable('intl_number');
-        return this.intl_number.format(value);
-    },
-
-    formatWordCount(value) {
-        const num = this.formatNumber(value > 1000
-            ? Math.floor(value / 100) / 10
-            : value);
+  assertIntlAvailable(property) {
+    if (!this[property]) {
+      throw new Error(`Intl API ${property} unavailable`);
+    }
+  },
+
+  getUnitForm(value) {
+    this.assertIntlAvailable('intl_pluralCardinal');
+    return this.intl_pluralCardinal.select(value);
+  },
+
+  formatString(key, args = {}) {
+    if (this.strings && !this.strings_htmlEscaped) {
+      throw new Error(
+        `HTML-escaped strings unavailable - please ensure escapeHTML function is provided`
+      );
+    }
 
-        const words = (value > 1000
-            ? this.formatString('count.words.thousand', {words: num})
-            : this.formatString('count.words', {words: num}));
+    return this.formatStringHelper(this.strings_htmlEscaped, key, args);
+  },
 
-        return this.formatString('count.words.withUnit.' + this.getUnitForm(value), {words});
-    },
+  formatStringNoHTMLEscape(key, args = {}) {
+    return this.formatStringHelper(this.strings, key, args);
+  },
 
-    // Conjunction list: A, B, and C
-    formatConjunctionList(array) {
-        this.assertIntlAvailable('intl_listConjunction');
-        return this.intl_listConjunction.format(array);
-    },
+  formatStringHelper(strings, key, args = {}) {
+    if (!strings) {
+      throw new Error(`Strings unavailable`);
+    }
 
-    // Disjunction lists: A, B, or C
-    formatDisjunctionList(array) {
-        this.assertIntlAvailable('intl_listDisjunction');
-        return this.intl_listDisjunction.format(array);
-    },
+    if (!this.validKeys.includes(key)) {
+      throw new Error(`Invalid key ${key} accessed`);
+    }
 
-    // Unit lists: A, B, C
-    formatUnitList(array) {
-        this.assertIntlAvailable('intl_listUnit');
-        return this.intl_listUnit.format(array);
-    },
+    const template = strings[key];
+
+    // Convert the keys on the args dict from camelCase to CONSTANT_CASE.
+    // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut
+    // like, who cares, dude?) Also, this is an array, 8ecause it's handy
+    // for the iterating we're a8out to do.
+    const processedArgs = Object.entries(args).map(([k, v]) => [
+      k.replace(/[A-Z]/g, '_$&').toUpperCase(),
+      v,
+    ]);
+
+    // Replacement time! Woot. Reduce comes in handy here!
+    const output = processedArgs.reduce(
+      (x, [k, v]) => x.replaceAll(`{${k}}`, v),
+      template
+    );
+
+    // Post-processing: if any expected arguments *weren't* replaced, that
+    // is almost definitely an error.
+    if (output.match(/\{[A-Z_]+\}/)) {
+      throw new Error(`Args in ${key} were missing - output: ${output}`);
+    }
 
-    // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB
-    formatFileSize(bytes) {
-        if (!bytes) return '';
+    return output;
+  },
 
-        bytes = parseInt(bytes);
-        if (isNaN(bytes)) return '';
+  formatDate(date) {
+    this.assertIntlAvailable('intl_date');
+    return this.intl_date.format(date);
+  },
 
-        const round = exp => Math.round(bytes / 10 ** (exp - 1)) / 10;
+  formatDateRange(startDate, endDate) {
+    this.assertIntlAvailable('intl_date');
+    return this.intl_date.formatRange(startDate, endDate);
+  },
 
-        if (bytes >= 10 ** 12) {
-            return this.formatString('count.fileSize.terabytes', {terabytes: round(12)});
-        } else if (bytes >= 10 ** 9) {
-            return this.formatString('count.fileSize.gigabytes', {gigabytes: round(9)});
-        } else if (bytes >= 10 ** 6) {
-            return this.formatString('count.fileSize.megabytes', {megabytes: round(6)});
-        } else if (bytes >= 10 ** 3) {
-            return this.formatString('count.fileSize.kilobytes', {kilobytes: round(3)});
-        } else {
-            return this.formatString('count.fileSize.bytes', {bytes});
-        }
-    },
+  formatDuration(secTotal, {approximate = false, unit = false} = {}) {
+    if (secTotal === 0) {
+      return this.formatString('count.duration.missing');
+    }
 
-    // TODO: These are hard-coded. Is there a better way?
-    countAdditionalFiles: countHelper('additionalFiles', 'files'),
-    countAlbums: countHelper('albums'),
-    countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
-    countContributions: countHelper('contributions'),
-    countCoverArts: countHelper('coverArts'),
-    countTimesReferenced: countHelper('timesReferenced'),
-    countTimesUsed: countHelper('timesUsed'),
-    countTracks: countHelper('tracks'),
+    const hour = Math.floor(secTotal / 3600);
+    const min = Math.floor((secTotal - hour * 3600) / 60);
+    const sec = Math.floor(secTotal - hour * 3600 - min * 60);
+
+    const pad = (val) => val.toString().padStart(2, '0');
+
+    const stringSubkey = unit ? '.withUnit' : '';
+
+    const duration =
+      hour > 0
+        ? this.formatString('count.duration.hours' + stringSubkey, {
+            hours: hour,
+            minutes: pad(min),
+            seconds: pad(sec),
+          })
+        : this.formatString('count.duration.minutes' + stringSubkey, {
+            minutes: min,
+            seconds: pad(sec),
+          });
+
+    return approximate
+      ? this.formatString('count.duration.approximate', {duration})
+      : duration;
+  },
+
+  formatIndex(value) {
+    this.assertIntlAvailable('intl_pluralOrdinal');
+    return this.formatString(
+      'count.index.' + this.intl_pluralOrdinal.select(value),
+      {
+        index: value,
+      }
+    );
+  },
+
+  formatNumber(value) {
+    this.assertIntlAvailable('intl_number');
+    return this.intl_number.format(value);
+  },
+
+  formatWordCount(value) {
+    const num = this.formatNumber(
+      value > 1000 ? Math.floor(value / 100) / 10 : value
+    );
+
+    const words =
+      value > 1000
+        ? this.formatString('count.words.thousand', {words: num})
+        : this.formatString('count.words', {words: num});
+
+    return this.formatString(
+      'count.words.withUnit.' + this.getUnitForm(value),
+      {words}
+    );
+  },
+
+  // Conjunction list: A, B, and C
+  formatConjunctionList(array) {
+    this.assertIntlAvailable('intl_listConjunction');
+    return this.intl_listConjunction.format(array);
+  },
+
+  // Disjunction lists: A, B, or C
+  formatDisjunctionList(array) {
+    this.assertIntlAvailable('intl_listDisjunction');
+    return this.intl_listDisjunction.format(array);
+  },
+
+  // Unit lists: A, B, C
+  formatUnitList(array) {
+    this.assertIntlAvailable('intl_listUnit');
+    return this.intl_listUnit.format(array);
+  },
+
+  // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB
+  formatFileSize(bytes) {
+    if (!bytes) return '';
+
+    bytes = parseInt(bytes);
+    if (isNaN(bytes)) return '';
+
+    const round = (exp) => Math.round(bytes / 10 ** (exp - 1)) / 10;
+
+    if (bytes >= 10 ** 12) {
+      return this.formatString('count.fileSize.terabytes', {
+        terabytes: round(12),
+      });
+    } else if (bytes >= 10 ** 9) {
+      return this.formatString('count.fileSize.gigabytes', {
+        gigabytes: round(9),
+      });
+    } else if (bytes >= 10 ** 6) {
+      return this.formatString('count.fileSize.megabytes', {
+        megabytes: round(6),
+      });
+    } else if (bytes >= 10 ** 3) {
+      return this.formatString('count.fileSize.kilobytes', {
+        kilobytes: round(3),
+      });
+    } else {
+      return this.formatString('count.fileSize.bytes', {bytes});
+    }
+  },
+
+  // TODO: These are hard-coded. Is there a better way?
+  countAdditionalFiles: countHelper('additionalFiles', 'files'),
+  countAlbums: countHelper('albums'),
+  countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
+  countContributions: countHelper('contributions'),
+  countCoverArts: countHelper('coverArts'),
+  countTimesReferenced: countHelper('timesReferenced'),
+  countTimesUsed: countHelper('timesUsed'),
+  countTracks: countHelper('tracks'),
 });