« 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/album.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/data/things/album.js')
-rw-r--r--src/data/things/album.js719
1 files changed, 479 insertions, 240 deletions
diff --git a/src/data/things/album.js b/src/data/things/album.js
index 6bf683c5..45cadc12 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -3,19 +3,23 @@ export const DATA_ALBUM_DIRECTORY = 'album';
 import * as path from 'node:path';
 import {inspect} from 'node:util';
 
+import CacheableObject from '#cacheable-object';
 import {colors} from '#cli';
 import {input} from '#composite';
 import {traverse} from '#node-utils';
 import {sortAlbumsTracksChronologically, sortChronologically} from '#sort';
-import {accumulateSum, empty} from '#sugar';
+import {empty} from '#sugar';
 import Thing from '#thing';
-import {isColor, isDate, isDirectory} from '#validators';
+import {isColor, isDate, isDirectory, isNumber} from '#validators';
 
 import {
   parseAdditionalFiles,
   parseAdditionalNames,
   parseAnnotatedReferences,
+  parseArtwork,
+  parseCommentary,
   parseContributors,
+  parseCreditingSources,
   parseDate,
   parseDimensions,
   parseWallpaperParts,
@@ -24,16 +28,14 @@ import {
 import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
   from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
-
-import {exitWithoutContribs, withDirectory, withCoverArtDate}
+import {exitWithoutArtwork, withDirectory, withHasArtwork}
   from '#composite/wiki-data';
 
 import {
-  additionalFiles,
-  additionalNameList,
-  commentary,
   color,
   commentatorArtists,
+  constitutibleArtwork,
+  constitutibleArtworkList,
   contentString,
   contribsPresent,
   contributionList,
@@ -44,7 +46,6 @@ import {
   name,
   referencedArtworkList,
   referenceList,
-  reverseReferenceList,
   simpleDate,
   simpleString,
   soupyFind,
@@ -56,20 +57,31 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
-import {withTracks} from '#composite/things/album';
-import {withAlbum} from '#composite/things/track-section';
+import {withCoverArtDate, withTracks} from '#composite/things/album';
+import {withAlbum, withContinueCountingFrom, withStartCountingFrom}
+  from '#composite/things/track-section';
 
 export class Album extends Thing {
   static [Thing.referenceType] = 'album';
 
   static [Thing.getPropertyDescriptors] = ({
+    AdditionalFile,
+    AdditionalName,
     ArtTag,
+    Artwork,
+    CommentaryEntry,
+    CreditingSourcesEntry,
     Group,
-    Track,
     TrackSection,
     WikiInfo,
   }) => ({
-    // Update & expose
+    // > Update & expose - Internal relationships
+
+    trackSections: thingList({
+      class: input.value(TrackSection),
+    }),
+
+    // > Update & expose - Identifying metadata
 
     name: name('Unnamed Album'),
     directory: directory(),
@@ -90,104 +102,112 @@ export class Album extends Thing {
     alwaysReferenceTracksByDirectory: flag(false),
     suffixTrackDirectories: flag(false),
 
-    color: color(),
-    urls: urls(),
-
-    additionalNames: additionalNameList(),
-
     bandcampAlbumIdentifier: simpleString(),
     bandcampArtworkIdentifier: simpleString(),
 
+    additionalNames: thingList({
+      class: input.value(AdditionalName),
+    }),
+
     date: simpleDate(),
-    trackArtDate: simpleDate(),
     dateAddedToWiki: simpleDate(),
 
+    // > Update & expose - Credits and contributors
+
+    artistContribs: contributionList({
+      date: 'date',
+      artistProperty: input.value('albumArtistContributions'),
+    }),
+
+    // > Update & expose - General configuration
+
+    countTracksInArtistTotals: flag(true),
+
+    hasTrackNumbers: flag(true),
+    isListedOnHomepage: flag(true),
+    isListedInGalleries: flag(true),
+
+    // > Update & expose - General metadata
+
+    color: color(),
+
+    urls: urls(),
+
+    // > Update & expose - Artworks
+
+    coverArtworks: [
+      // This works, lol, because this array describes `expose.transform` for
+      // the coverArtworks property, and compositions generally access the
+      // update value, not what's exposed by property access out in the open.
+      // There's no recursion going on here.
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+        value: input.value([]),
+      }),
+
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
+    ],
+
+    coverArtistContribs: [
+      withCoverArtDate(),
+
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumCoverArtistContributions'),
+      }),
+    ],
+
     coverArtDate: [
-      // ~~TODO: Why does this fall back, but Track.coverArtDate doesn't?~~
-      // TODO: OK so it's because tracks don't *store* dates just like that.
-      // Really instead of fallback being a flag, it should be a date value,
-      // if this option is worth existing at all.
       withCoverArtDate({
         from: input.updateValue({
           validate: isDate,
         }),
-
-        fallback: input.value(true),
       }),
 
       exposeDependency({dependency: '#coverArtDate'}),
     ],
 
     coverArtFileExtension: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
-      fileExtension('jpg'),
-    ],
-
-    trackCoverArtFileExtension: fileExtension('jpg'),
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+      }),
 
-    wallpaperFileExtension: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
       fileExtension('jpg'),
     ],
 
-    bannerFileExtension: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      fileExtension('jpg'),
-    ],
-
-    wallpaperStyle: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
-      simpleString(),
-    ],
-
-    wallpaperParts: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
-      wallpaperParts(),
-    ],
-
-    bannerStyle: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      simpleString(),
-    ],
-
     coverArtDimensions: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
-      dimensions(),
-    ],
-
-    trackDimensions: dimensions(),
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+      }),
 
-    bannerDimensions: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
       dimensions(),
     ],
 
-    hasTrackNumbers: flag(true),
-    isListedOnHomepage: flag(true),
-    isListedInGalleries: flag(true),
-
-    commentary: commentary(),
-    creditSources: commentary(),
-    additionalFiles: additionalFiles(),
-
-    trackSections: thingList({
-      class: input.value(TrackSection),
-    }),
-
-    artistContribs: contributionList({
-      date: 'date',
-      artistProperty: input.value('albumArtistContributions'),
-    }),
+    artTags: [
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+        value: input.value([]),
+      }),
 
-    coverArtistContribs: [
-      withCoverArtDate({
-        fallback: input.value(true),
+      referenceList({
+        class: input.value(ArtTag),
+        find: soupyFind.input('artTag'),
       }),
+    ],
 
-      contributionList({
-        date: '#coverArtDate',
-        artistProperty: input.value('albumCoverArtistContributions'),
+    referencedArtworks: [
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+        value: input.value([]),
       }),
+
+      referencedArtworkList(),
     ],
 
     trackCoverArtistContribs: contributionList({
@@ -200,80 +220,139 @@ export class Album extends Thing {
       artistProperty: input.value('trackCoverArtistContributions'),
     }),
 
-    wallpaperArtistContribs: [
-      withCoverArtDate({
-        fallback: input.value(true),
+    trackArtDate: simpleDate(),
+
+    trackCoverArtFileExtension: fileExtension('jpg'),
+
+    trackDimensions: dimensions(),
+
+    wallpaperArtwork: [
+      exitWithoutDependency({
+        dependency: 'wallpaperArtistContribs',
+        mode: input.value('empty'),
+        value: input.value(null),
       }),
 
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Wallpaper Artwork'),
+    ],
+
+    wallpaperArtistContribs: [
+      withCoverArtDate(),
+
       contributionList({
         date: '#coverArtDate',
         artistProperty: input.value('albumWallpaperArtistContributions'),
       }),
     ],
 
-    bannerArtistContribs: [
-      withCoverArtDate({
-        fallback: input.value(true),
+    wallpaperFileExtension: [
+      exitWithoutArtwork({
+        contribs: 'wallpaperArtistContribs',
+        artwork: 'wallpaperArtwork',
+      }),
+
+      fileExtension('jpg'),
+    ],
+
+    wallpaperStyle: [
+      exitWithoutArtwork({
+        contribs: 'wallpaperArtistContribs',
+        artwork: 'wallpaperArtwork',
       }),
 
+      simpleString(),
+    ],
+
+    wallpaperParts: [
+      // kinda nonsensical or at least unlikely lol, but y'know
+      exitWithoutArtwork({
+        contribs: 'wallpaperArtistContribs',
+        artwork: 'wallpaperArtwork',
+        value: input.value([]),
+      }),
+
+      wallpaperParts(),
+    ],
+
+    bannerArtwork: [
+      exitWithoutDependency({
+        dependency: 'bannerArtistContribs',
+        mode: input.value('empty'),
+        value: input.value(null),
+      }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Banner Artwork'),
+    ],
+
+    bannerArtistContribs: [
+      withCoverArtDate(),
+
       contributionList({
         date: '#coverArtDate',
         artistProperty: input.value('albumBannerArtistContributions'),
       }),
     ],
 
-    groups: referenceList({
-      class: input.value(Group),
-      find: soupyFind.input('group'),
-    }),
-
-    artTags: [
-      exitWithoutContribs({
-        contribs: 'coverArtistContribs',
-        value: input.value([]),
+    bannerFileExtension: [
+      exitWithoutArtwork({
+        contribs: 'bannerArtistContribs',
+        artwork: 'bannerArtwork',
       }),
 
-      referenceList({
-        class: input.value(ArtTag),
-        find: soupyFind.input('artTag'),
-      }),
+      fileExtension('jpg'),
     ],
 
-    referencedArtworks: [
-      exitWithoutContribs({
-        contribs: 'coverArtistContribs',
-        value: input.value([]),
+    bannerDimensions: [
+      exitWithoutArtwork({
+        contribs: 'bannerArtistContribs',
+        artwork: 'bannerArtwork',
       }),
 
-      {
-        dependencies: ['coverArtDate', 'date'],
-        compute: (continuation, {
-          coverArtDate,
-          date,
-        }) => continuation({
-          ['#date']:
-            coverArtDate ?? date,
-        }),
-      },
+      dimensions(),
+    ],
 
-      referencedArtworkList({
-        date: '#date',
+    bannerStyle: [
+      exitWithoutArtwork({
+        contribs: 'bannerArtistContribs',
+        artwork: 'bannerArtwork',
       }),
+
+      simpleString(),
     ],
 
+    // > Update & expose - Groups
+
+    groups: referenceList({
+      class: input.value(Group),
+      find: soupyFind.input('group'),
+    }),
+
+    // > Update & expose - Content entries
+
+    commentary: thingList({
+      class: input.value(CommentaryEntry),
+    }),
+
+    creditingSources: thingList({
+      class: input.value(CreditingSourcesEntry),
+    }),
+
+    // Additional files
+
+    additionalFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
+
     // Update only
 
     find: soupyFind(),
     reverse: soupyReverse(),
 
     // used for referencedArtworkList (mixedFind)
-    albumData: wikiData({
-      class: input.value(Album),
-    }),
-
-    // used for referencedArtworkList (mixedFind)
-    trackData: wikiData({
-      class: input.value(Track),
+    artworkData: wikiData({
+      class: input.value(Artwork),
     }),
 
     // used for withMatchingContributionPresets (indirectly by Contribution)
@@ -285,7 +364,15 @@ export class Album extends Thing {
 
     commentatorArtists: commentatorArtists(),
 
-    hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}),
+    hasCoverArt: [
+      withHasArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+      }),
+
+      exposeDependency({dependency: '#hasArtwork'}),
+    ],
+
     hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}),
     hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}),
 
@@ -293,17 +380,6 @@ export class Album extends Thing {
       withTracks(),
       exposeDependency({dependency: '#tracks'}),
     ],
-
-    referencedByArtworks: [
-      exitWithoutContribs({
-        contribs: 'coverArtistContribs',
-        value: input.value([]),
-      }),
-
-      reverseReferenceList({
-        reverse: soupyReverse.input('artworksWhichReference'),
-      }),
-    ],
   });
 
   static [Thing.getSerializeDescriptors] = ({
@@ -379,6 +455,31 @@ export class Album extends Thing {
           ? [] 
           : [album.name]),
     },
+
+    albumPrimaryArtwork: {
+      [Thing.findThisThingOnly]: false,
+
+      referenceTypes: [
+        'album',
+        'album-referencing-artworks',
+        'album-referenced-artworks',
+      ],
+
+      bindTo: 'artworkData',
+
+      include: (artwork, {Artwork, Album}) =>
+        artwork instanceof Artwork &&
+        artwork.thing instanceof Album &&
+        artwork === artwork.thing.coverArtworks[0],
+
+      getMatchableNames: ({thing: album}) =>
+        (album.alwaysReferenceByDirectory
+          ? []
+          : [album.name]),
+
+      getMatchableDirectories: ({thing: album}) =>
+        [album.directory],
+    },
   };
 
   static [Thing.reverseSpecs] = {
@@ -414,13 +515,13 @@ export class Album extends Thing {
       soupyReverse.contributionsBy('albumData', 'artistContribs'),
 
     albumCoverArtistContributionsBy:
-      soupyReverse.contributionsBy('albumData', 'coverArtistContribs'),
+      soupyReverse.artworkContributionsBy('albumData', 'coverArtworks'),
 
     albumWallpaperArtistContributionsBy:
-      soupyReverse.contributionsBy('albumData', 'wallpaperArtistContribs'),
+      soupyReverse.artworkContributionsBy('albumData', 'wallpaperArtwork', {single: true}),
 
     albumBannerArtistContributionsBy:
-      soupyReverse.contributionsBy('albumData', 'bannerArtistContribs'),
+      soupyReverse.artworkContributionsBy('albumData', 'bannerArtwork', {single: true}),
 
     albumsWithCommentaryBy: {
       bindTo: 'albumData',
@@ -432,21 +533,14 @@ export class Album extends Thing {
 
   static [Thing.yamlDocumentSpec] = {
     fields: {
-      'Album': {property: 'name'},
+      // Identifying metadata
 
+      'Album': {property: 'name'},
       'Directory': {property: 'directory'},
       'Directory Suffix': {property: 'directorySuffix'},
       'Suffix Track Directories': {property: 'suffixTrackDirectories'},
-
       'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
-      'Always Reference Tracks By Directory': {
-        property: 'alwaysReferenceTracksByDirectory',
-      },
-
-      'Additional Names': {
-        property: 'additionalNames',
-        transform: parseAdditionalNames,
-      },
+      'Always Reference Tracks By Directory': {property: 'alwaysReferenceTracksByDirectory'},
 
       'Bandcamp Album ID': {
         property: 'bandcampAlbumIdentifier',
@@ -458,41 +552,114 @@ export class Album extends Thing {
         transform: String,
       },
 
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
+
       'Date': {
         property: 'date',
         transform: parseDate,
       },
 
-      'Color': {property: 'color'},
-      'URLs': {property: 'urls'},
+      'Date Added': {
+        property: 'dateAddedToWiki',
+        transform: parseDate,
+      },
+
+      // Credits and contributors
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      // General configuration
+
+      'Count Tracks In Artist Totals': {property: 'countInArtistTotals'},
 
       'Has Track Numbers': {property: 'hasTrackNumbers'},
       'Listed on Homepage': {property: 'isListedOnHomepage'},
       'Listed in Galleries': {property: 'isListedInGalleries'},
 
-      'Cover Art Date': {
-        property: 'coverArtDate',
-        transform: parseDate,
+      // General metadata
+
+      'Color': {property: 'color'},
+
+      'URLs': {property: 'urls'},
+
+      // Artworks
+      //  (Note - this YAML section is deliberately ordered differently
+      //   than the corresponding property descriptors.)
+
+      'Cover Artwork': {
+        property: 'coverArtworks',
+        transform:
+          parseArtwork({
+            thingProperty: 'coverArtworks',
+            dimensionsFromThingProperty: 'coverArtDimensions',
+            fileExtensionFromThingProperty: 'coverArtFileExtension',
+            dateFromThingProperty: 'coverArtDate',
+            artistContribsFromThingProperty: 'coverArtistContribs',
+            artistContribsArtistProperty: 'albumCoverArtistContributions',
+            artTagsFromThingProperty: 'artTags',
+            referencedArtworksFromThingProperty: 'referencedArtworks',
+          }),
       },
 
-      'Default Track Cover Art Date': {
-        property: 'trackArtDate',
-        transform: parseDate,
+      'Banner Artwork': {
+        property: 'bannerArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            thingProperty: 'bannerArtwork',
+            dimensionsFromThingProperty: 'bannerDimensions',
+            fileExtensionFromThingProperty: 'bannerFileExtension',
+            dateFromThingProperty: 'date',
+            artistContribsFromThingProperty: 'bannerArtistContribs',
+            artistContribsArtistProperty: 'albumBannerArtistContributions',
+          }),
       },
 
-      'Date Added': {
-        property: 'dateAddedToWiki',
-        transform: parseDate,
+      'Wallpaper Artwork': {
+        property: 'wallpaperArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            thingProperty: 'wallpaperArtwork',
+            dimensionsFromThingProperty: null,
+            fileExtensionFromThingProperty: 'wallpaperFileExtension',
+            dateFromThingProperty: 'date',
+            artistContribsFromThingProperty: 'wallpaperArtistContribs',
+            artistContribsArtistProperty: 'albumWallpaperArtistContributions',
+          }),
       },
 
-      'Cover Art File Extension': {property: 'coverArtFileExtension'},
-      'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
+      'Cover Artists': {
+        property: 'coverArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Cover Art Date': {
+        property: 'coverArtDate',
+        transform: parseDate,
+      },
 
       'Cover Art Dimensions': {
         property: 'coverArtDimensions',
         transform: parseDimensions,
       },
 
+      'Default Track Cover Artists': {
+        property: 'trackCoverArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Default Track Cover Art Date': {
+        property: 'trackArtDate',
+        transform: parseDate,
+      },
+
       'Default Track Dimensions': {
         property: 'trackDimensions',
         transform: parseDimensions,
@@ -504,7 +671,6 @@ export class Album extends Thing {
       },
 
       'Wallpaper Style': {property: 'wallpaperStyle'},
-      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
 
       'Wallpaper Parts': {
         property: 'wallpaperParts',
@@ -516,47 +682,51 @@ export class Album extends Thing {
         transform: parseContributors,
       },
 
-      'Banner Style': {property: 'bannerStyle'},
-      'Banner File Extension': {property: 'bannerFileExtension'},
-
       'Banner Dimensions': {
         property: 'bannerDimensions',
         transform: parseDimensions,
       },
 
-      'Commentary': {property: 'commentary'},
-      'Credit Sources': {property: 'creditSources'},
+      'Banner Style': {property: 'bannerStyle'},
 
-      'Additional Files': {
-        property: 'additionalFiles',
-        transform: parseAdditionalFiles,
-      },
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+      'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
+      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
+      'Banner File Extension': {property: 'bannerFileExtension'},
+
+      'Art Tags': {property: 'artTags'},
 
       'Referenced Artworks': {
         property: 'referencedArtworks',
         transform: parseAnnotatedReferences,
       },
 
-      'Franchises': {ignore: true},
+      // Groups
 
-      'Artists': {
-        property: 'artistContribs',
-        transform: parseContributors,
+      'Groups': {property: 'groups'},
+
+      // Content entries
+
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
       },
 
-      'Cover Artists': {
-        property: 'coverArtistContribs',
-        transform: parseContributors,
+      'Crediting Sources': {
+        property: 'creditingSources',
+        transform: parseCreditingSources,
       },
 
-      'Default Track Cover Artists': {
-        property: 'trackCoverArtistContribs',
-        transform: parseContributors,
+      // Additional files
+
+      'Additional Files': {
+        property: 'additionalFiles',
+        transform: parseAdditionalFiles,
       },
 
-      'Groups': {property: 'groups'},
-      'Art Tags': {property: 'artTags'},
+      // Shenanigans
 
+      'Franchises': {ignore: true},
       'Review Points': {ignore: true},
     },
 
@@ -597,6 +767,12 @@ export class Album extends Thing {
       const trackSectionData = [];
       const trackData = [];
 
+      const artworkData = [];
+      const commentaryData = [];
+      const creditingSourceData = [];
+      const referencingSourceData = [];
+      const lyricsData = [];
+
       for (const {header: album, entries} of results) {
         const trackSections = [];
 
@@ -608,8 +784,6 @@ export class Album extends Thing {
           isDefaultTrackSection: true,
         });
 
-        const albumRef = Thing.getReference(album);
-
         const closeCurrentTrackSection = () => {
           if (
             currentTrackSection.isDefaultTrackSection &&
@@ -636,17 +810,53 @@ export class Album extends Thing {
           currentTrackSectionTracks.push(entry);
           trackData.push(entry);
 
-          entry.dataSourceAlbum = albumRef;
+          // Set the track's album before accessing its list of artworks.
+          // The existence of its artwork objects may depend on access to
+          // its album's 'Default Track Cover Artists'.
+          entry.album = album;
+
+          artworkData.push(...entry.trackArtworks);
+          commentaryData.push(...entry.commentary);
+          creditingSourceData.push(...entry.creditingSources);
+          referencingSourceData.push(...entry.referencingSources);
+
+          // TODO: As exposed, Track.lyrics tries to inherit from the main
+          // release, which is impossible before the data's been linked.
+          // We just use the update value here. But it's icky!
+          lyricsData.push(...CacheableObject.getUpdateValue(entry, 'lyrics') ?? []);
         }
 
         closeCurrentTrackSection();
 
         albumData.push(album);
 
+        artworkData.push(...album.coverArtworks);
+
+        if (album.bannerArtwork) {
+          artworkData.push(album.bannerArtwork);
+        }
+
+        if (album.wallpaperArtwork) {
+          artworkData.push(album.wallpaperArtwork);
+        }
+
+        commentaryData.push(...album.commentary);
+        creditingSourceData.push(...album.creditingSources);
+
         album.trackSections = trackSections;
       }
 
-      return {albumData, trackSectionData, trackData};
+      return {
+        albumData,
+        trackSectionData,
+        trackData,
+
+        artworkData,
+        commentaryData,
+        creditingSourceData,
+        referencingSourceData,
+        lyricsData,
+      };
     },
 
     sort({albumData, trackData}) {
@@ -654,13 +864,65 @@ export class Album extends Thing {
       sortAlbumsTracksChronologically(trackData);
     },
   });
+
+  getOwnAdditionalFilePath(_file, filename) {
+    return [
+      'media.albumAdditionalFile',
+      this.directory,
+      filename,
+    ];
+  }
+
+  getOwnArtworkPath(artwork) {
+    if (artwork === this.bannerArtwork) {
+      return [
+        'media.albumBanner',
+        this.directory,
+        artwork.fileExtension,
+      ];
+    }
+
+    if (artwork === this.wallpaperArtwork) {
+      if (!empty(this.wallpaperParts)) {
+        return null;
+      }
+
+      return [
+        'media.albumWallpaper',
+        this.directory,
+        artwork.fileExtension,
+      ];
+    }
+
+    // TODO: using trackCover here is obviously, badly wrong
+    // but we ought to refactor banners and wallpapers similarly
+    // (i.e. depend on those intrinsic artwork paths rather than
+    // accessing media.{albumBanner,albumWallpaper} from content
+    // or other code directly)
+    return [
+      'media.trackCover',
+      this.directory,
+
+      (artwork.unqualifiedDirectory
+        ? 'cover-' + artwork.unqualifiedDirectory
+        : 'cover'),
+
+      artwork.fileExtension,
+    ];
+  }
+
+  // As of writing, albums don't even have a `duration` property...
+  // so this function will never be called... but the message stands...
+  countOwnContributionInDurationTotals(_contrib) {
+    return false;
+  }
 }
 
 export class TrackSection extends Thing {
   static [Thing.friendlyName] = `Track Section`;
   static [Thing.referenceType] = `track-section`;
 
-  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+  static [Thing.getPropertyDescriptors] = ({Track}) => ({
     // Update & expose
 
     name: name('Unnamed Track Section'),
@@ -682,6 +944,14 @@ export class TrackSection extends Thing {
       exposeDependency({dependency: '#album.color'}),
     ],
 
+    startCountingFrom: [
+      withStartCountingFrom({
+        from: input.updateValue({validate: isNumber}),
+      }),
+
+      exposeDependency({dependency: '#startCountingFrom'}),
+    ],
+
     dateOriginallyReleased: simpleDate(),
 
     isDefaultTrackSection: flag(false),
@@ -731,42 +1001,10 @@ export class TrackSection extends Thing {
       },
     ],
 
-    startIndex: [
-      withAlbum(),
-
-      withPropertyFromObject({
-        object: '#album',
-        property: input.value('trackSections'),
-      }),
-
-      {
-        dependencies: ['#album.trackSections', input.myself()],
-        compute: (continuation, {
-          ['#album.trackSections']: trackSections,
-          [input.myself()]: myself,
-        }) => continuation({
-          ['#index']:
-            trackSections.indexOf(myself),
-        }),
-      },
-
-      exitWithoutDependency({
-        dependency: '#index',
-        mode: input.value('index'),
-        value: input.value(0),
-      }),
+    continueCountingFrom: [
+      withContinueCountingFrom(),
 
-      {
-        dependencies: ['#album.trackSections', '#index'],
-        compute: ({
-          ['#album.trackSections']: trackSections,
-          ['#index']: index,
-        }) =>
-          accumulateSum(
-            trackSections
-              .slice(0, index)
-              .map(section => section.tracks.length)),
-      },
+      exposeDependency({dependency: '#continueCountingFrom'}),
     ],
   });
 
@@ -797,6 +1035,7 @@ export class TrackSection extends Thing {
     fields: {
       'Section': {property: 'name'},
       'Color': {property: 'color'},
+      'Start Counting From': {property: 'startCountingFrom'},
 
       'Date Originally Released': {
         property: 'dateOriginallyReleased',
@@ -812,38 +1051,38 @@ export class TrackSection extends Thing {
 
     parts.push(Thing.prototype[inspect.custom].apply(this));
 
-    if (depth >= 0) {
+    if (depth >= 0) showAlbum: {
       let album = null;
       try {
         album = this.album;
-      } catch {}
+      } catch {
+        break showAlbum;
+      }
 
       let first = null;
       try {
-        first = this.startIndex;
+        first = this.tracks.at(0).trackNumber;
       } catch {}
 
-      let length = null;
+      let last = null;
       try {
-        length = this.tracks.length;
+        last = this.tracks.at(-1).trackNumber;
       } catch {}
 
-      if (album) {
-        const albumName = album.name;
-        const albumIndex = album.trackSections.indexOf(this);
+      const albumName = album.name;
+      const albumIndex = album.trackSections.indexOf(this);
 
-        const num =
-          (albumIndex === -1
-            ? 'indeterminate position'
-            : `#${albumIndex + 1}`);
+      const num =
+        (albumIndex === -1
+          ? 'indeterminate position'
+          : `#${albumIndex + 1}`);
 
-        const range =
-          (albumIndex >= 0 && first !== null && length !== null
-            ? `: ${first + 1}-${first + length + 1}`
-            : '');
+      const range =
+        (albumIndex >= 0 && first !== null && last !== null
+          ? `: ${first}-${last}`
+          : '');
 
-        parts.push(` (${colors.yellow(num + range)} in ${colors.green(albumName)})`);
-      }
+      parts.push(` (${colors.yellow(num + range)} in ${colors.green(`"${albumName}"`)})`);
     }
 
     return parts.join('');