« 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.js897
1 files changed, 527 insertions, 370 deletions
diff --git a/src/data/things/album.js b/src/data/things/album.js
index 7a7b387d..31d94ef1 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -3,14 +3,22 @@ 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 {input, V} 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, isNumber} from '#validators';
+
+import {
+  is,
+  isBoolean,
+  isColor,
+  isContributionList,
+  isDate,
+  isDirectory,
+  isNumber,
+} from '#validators';
 
 import {
   parseAdditionalFiles,
@@ -25,29 +33,40 @@ import {
   parseWallpaperParts,
 } from '#yaml';
 
-import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
-  from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
-
-import {exitWithoutContribs, withDirectory, withCoverArtDate}
+import {withRecontextualizedContributionList, withResolvedContribs}
   from '#composite/wiki-data';
 
 import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  withFlattenedList,
+  withLengthOfList,
+  withNearbyItemFromList,
+  withPropertyFromList,
+  withPropertyFromObject,
+} from '#composite/data';
+
+import {
   color,
   commentatorArtists,
   constitutibleArtwork,
   constitutibleArtworkList,
   contentString,
-  contribsPresent,
   contributionList,
   dimensions,
   directory,
   fileExtension,
   flag,
+  hasArtwork,
   name,
   referencedArtworkList,
   referenceList,
-  reverseReferenceList,
   simpleDate,
   simpleString,
   soupyFind,
@@ -59,12 +78,15 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
-import {withHasCoverArt, withTracks} from '#composite/things/album';
-import {withAlbum, withContinueCountingFrom, withStartCountingFrom}
-  from '#composite/things/track-section';
-
 export class Album extends Thing {
   static [Thing.referenceType] = 'album';
+  static [Thing.wikiData] = 'albumData';
+
+  static [Thing.constitutibleProperties] = [
+    'coverArtworks',
+    'wallpaperArtwork',
+    'bannerArtwork',
+  ];
 
   static [Thing.getPropertyDescriptors] = ({
     AdditionalFile,
@@ -74,13 +96,16 @@ export class Album extends Thing {
     CommentaryEntry,
     CreditingSourcesEntry,
     Group,
-    Track,
     TrackSection,
     WikiInfo,
   }) => ({
-    // Update & expose
+    // > Update & expose - Internal relationships
+
+    trackSections: thingList(V(TrackSection)),
 
-    name: name('Unnamed Album'),
+    // > Update & expose - Identifying metadata
+
+    name: name(V('Unnamed Album')),
     directory: directory(),
 
     directorySuffix: [
@@ -88,156 +113,143 @@ export class Album extends Thing {
         validate: input.value(isDirectory),
       }),
 
-      withDirectory(),
-
-      exposeDependency({
-        dependency: '#directory',
-      }),
+      exposeDependency('directory'),
     ],
 
-    alwaysReferenceByDirectory: flag(false),
-    alwaysReferenceTracksByDirectory: flag(false),
-    suffixTrackDirectories: flag(false),
+    alwaysReferenceByDirectory: flag(V(false)),
+    alwaysReferenceTracksByDirectory: flag(V(false)),
+    suffixTrackDirectories: flag(V(false)),
 
-    color: color(),
-    urls: urls(),
+    style: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(is(...[
+          'album',
+          'single',
+        ])),
+      }),
 
-    additionalNames: thingList({
-      class: input.value(AdditionalName),
-    }),
+      exposeConstant(V('album')),
+    ],
 
     bandcampAlbumIdentifier: simpleString(),
     bandcampArtworkIdentifier: simpleString(),
 
+    additionalNames: thingList(V(AdditionalName)),
+
     date: simpleDate(),
-    trackArtDate: simpleDate(),
     dateAddedToWiki: simpleDate(),
 
-    coverArtDate: [
-      withCoverArtDate({
-        from: input.updateValue({
-          validate: isDate,
-        }),
+    // > Update & expose - Credits and contributors
+
+    artistContribs: contributionList({
+      artistProperty: input.value('albumArtistContributions'),
+    }),
+
+    trackArtistText: contentString(),
+
+    trackArtistContribs: [
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('albumTrackArtistContributions'),
+      }).outputs({
+        '#resolvedContribs': '#trackArtistContribs',
       }),
 
-      exposeDependency({dependency: '#coverArtDate'}),
-    ],
+      exposeDependencyOrContinue('#trackArtistContribs', V('empty')),
 
-    coverArtFileExtension: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
-      fileExtension('jpg'),
+      withRecontextualizedContributionList('artistContribs', {
+        artistProperty: input.value('albumTrackArtistContributions'),
+      }),
+
+      exposeDependency('#artistContribs'),
     ],
 
-    trackCoverArtFileExtension: fileExtension('jpg'),
+    // > Update & expose - General configuration
 
-    wallpaperFileExtension: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
-      fileExtension('jpg'),
-    ],
+    countTracksInArtistTotals: flag(V(true)),
 
-    bannerFileExtension: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      fileExtension('jpg'),
-    ],
+    showAlbumInTracksWithoutArtists: flag(V(false)),
 
-    wallpaperStyle: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
-      simpleString(),
-    ],
+    hasTrackNumbers: flag(V(true)),
+    isListedOnHomepage: flag(V(true)),
+    isListedInGalleries: flag(V(true)),
 
-    wallpaperParts: [
-      exitWithoutContribs({
-        contribs: 'wallpaperArtistContribs',
+    hideDuration: flag(V(false)),
+
+    // > Update & expose - General metadata
+
+    color: color(),
+
+    urls: urls(),
+
+    // > Update & expose - Artworks
+
+    coverArtworks: [
+      exitWithoutDependency('hasCoverArt', {
         value: input.value([]),
+        mode: input.value('falsy'),
       }),
 
-      wallpaperParts(),
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
     ],
 
-    bannerStyle: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      simpleString(),
-    ],
+    coverArtistContribs: contributionList({
+      date: 'coverArtDate',
+      artistProperty: input.value('albumCoverArtistContributions'),
+    }),
 
-    coverArtDimensions: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
-      dimensions(),
-    ],
+    coverArtDate: [
+      exitWithoutDependency('hasCoverArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
 
-    trackDimensions: dimensions(),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDate),
+      }),
 
-    bannerDimensions: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      dimensions(),
+      exposeDependency('date'),
     ],
 
-    wallpaperArtwork: [
-      exitWithoutDependency({
-        dependency: 'wallpaperArtistContribs',
-        mode: input.value('empty'),
+    coverArtFileExtension: [
+      exitWithoutDependency('hasCoverArt', {
         value: input.value(null),
+        mode: input.value('falsy'),
       }),
 
-      constitutibleArtwork.fromYAMLFieldSpec
-        .call(this, 'Wallpaper Artwork'),
+      fileExtension(V('jpg')),
     ],
 
-    bannerArtwork: [
-      exitWithoutDependency({
-        dependency: 'bannerArtistContribs',
-        mode: input.value('empty'),
+    coverArtDimensions: [
+      exitWithoutDependency('hasCoverArt', {
         value: input.value(null),
+        mode: input.value('falsy'),
       }),
 
-      constitutibleArtwork.fromYAMLFieldSpec
-        .call(this, 'Banner Artwork'),
+      dimensions(),
     ],
 
-    coverArtworks: [
-      withHasCoverArt(),
-
-      exitWithoutDependency({
-        dependency: '#hasCoverArt',
-        mode: input.value('falsy'),
+    artTags: [
+      exitWithoutDependency('hasCoverArt', {
         value: input.value([]),
+        mode: input.value('falsy'),
       }),
 
-      constitutibleArtworkList.fromYAMLFieldSpec
-        .call(this, 'Cover Artwork'),
+      referenceList({
+        class: input.value(ArtTag),
+        find: soupyFind.input('artTag'),
+      }),
     ],
 
-    hasTrackNumbers: flag(true),
-    isListedOnHomepage: flag(true),
-    isListedInGalleries: flag(true),
-
-    commentary: thingList({
-      class: input.value(CommentaryEntry),
-    }),
-
-    creditSources: thingList({
-      class: input.value(CreditingSourcesEntry),
-    }),
-
-    additionalFiles: thingList({
-      class: input.value(AdditionalFile),
-    }),
-
-    trackSections: thingList({
-      class: input.value(TrackSection),
-    }),
-
-    artistContribs: contributionList({
-      date: 'date',
-      artistProperty: input.value('albumArtistContributions'),
-    }),
-
-    coverArtistContribs: [
-      withCoverArtDate(),
-
-      contributionList({
-        date: '#coverArtDate',
-        artistProperty: input.value('albumCoverArtistContributions'),
+    referencedArtworks: [
+      exitWithoutDependency('hasCoverArt', {
+        value: input.value([]),
+        mode: input.value('falsy'),
       }),
+
+      referencedArtworkList(),
     ],
 
     trackCoverArtistContribs: contributionList({
@@ -250,80 +262,150 @@ export class Album extends Thing {
       artistProperty: input.value('trackCoverArtistContributions'),
     }),
 
-    wallpaperArtistContribs: [
-      withCoverArtDate(),
+    trackArtDate: simpleDate(),
+
+    trackCoverArtFileExtension: fileExtension(V('jpg')),
 
-      contributionList({
-        date: '#coverArtDate',
-        artistProperty: input.value('albumWallpaperArtistContributions'),
+    trackDimensions: dimensions(),
+
+    wallpaperArtwork: [
+      exitWithoutDependency('hasWallpaperArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
       }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Wallpaper Artwork'),
     ],
 
-    bannerArtistContribs: [
-      withCoverArtDate(),
+    wallpaperArtistContribs: contributionList({
+      date: 'coverArtDate',
+      artistProperty: input.value('albumWallpaperArtistContributions'),
+    }),
 
-      contributionList({
-        date: '#coverArtDate',
-        artistProperty: input.value('albumBannerArtistContributions'),
+    wallpaperFileExtension: [
+      exitWithoutDependency('hasWallpaperArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
       }),
+
+      fileExtension(V('jpg')),
     ],
 
-    groups: referenceList({
-      class: input.value(Group),
-      find: soupyFind.input('group'),
-    }),
+    wallpaperStyle: [
+      exitWithoutDependency('hasWallpaperArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
 
-    artTags: [
-      exitWithoutContribs({
-        contribs: 'coverArtistContribs',
+      simpleString(),
+    ],
+
+    wallpaperParts: [
+      exitWithoutDependency('hasWallpaperArt', {
         value: input.value([]),
+        mode: input.value('falsy'),
       }),
 
-      referenceList({
-        class: input.value(ArtTag),
-        find: soupyFind.input('artTag'),
+      wallpaperParts(),
+    ],
+
+    bannerArtwork: [
+      exitWithoutDependency('hasBannerArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
       }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Banner Artwork'),
     ],
 
-    referencedArtworks: [
-      exitWithoutContribs({
-        contribs: 'coverArtistContribs',
-        value: input.value([]),
+    bannerArtistContribs: contributionList({
+      date: 'coverArtDate',
+      artistProperty: input.value('albumBannerArtistContributions'),
+    }),
+
+    bannerFileExtension: [
+      exitWithoutDependency('hasBannerArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
       }),
 
-      referencedArtworkList(),
+      fileExtension(V('jpg')),
     ],
 
-    // Update only
+    bannerDimensions: [
+      exitWithoutDependency('hasBannerArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      dimensions(),
+    ],
+
+    bannerStyle: [
+      exitWithoutDependency('hasBannerArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      simpleString(),
+    ],
+
+    // > Update & expose - Groups
+
+    groups: referenceList({
+      class: input.value(Group),
+      find: soupyFind.input('group'),
+    }),
+
+    // > Update & expose - Content entries
+
+    commentary: thingList(V(CommentaryEntry)),
+    creditingSources: thingList(V(CreditingSourcesEntry)),
+
+    // > Update & expose - Additional files
+
+    additionalFiles: thingList(V(AdditionalFile)),
+
+    // > Update only
 
     find: soupyFind(),
     reverse: soupyReverse(),
 
     // used for referencedArtworkList (mixedFind)
-    artworkData: wikiData({
-      class: input.value(Artwork),
-    }),
+    artworkData: wikiData(V(Artwork)),
 
     // used for withMatchingContributionPresets (indirectly by Contribution)
-    wikiInfo: thing({
-      class: input.value(WikiInfo),
-    }),
+    wikiInfo: thing(V(WikiInfo)),
 
-    // Expose only
+    // > Expose only
+
+    isAlbum: exposeConstant(V(true)),
 
     commentatorArtists: commentatorArtists(),
 
-    hasCoverArt: [
-      withHasCoverArt(),
-      exposeDependency({dependency: '#hasCoverArt'}),
-    ],
+    hasCoverArt: hasArtwork({
+      contribs: '_coverArtistContribs',
+      artworks: '_coverArtworks',
+    }),
+
+    hasWallpaperArt: hasArtwork({
+      contribs: '_wallpaperArtistContribs',
+      artwork: '_wallpaperArtwork',
+    }),
 
-    hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}),
-    hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}),
+    hasBannerArt: hasArtwork({
+      contribs: '_bannerArtistContribs',
+      artwork: '_bannerArtwork',
+    }),
 
     tracks: [
-      withTracks(),
-      exposeDependency({dependency: '#tracks'}),
+      exitWithoutDependency('trackSections', V([])),
+
+      withPropertyFromList('trackSections', V('tracks')),
+      withFlattenedList('#trackSections.tracks'),
+      exposeDependency('#flattenedList'),
     ],
   });
 
@@ -378,8 +460,22 @@ export class Album extends Thing {
       bindTo: 'albumData',
 
       getMatchableNames: album =>
-        (album.alwaysReferenceByDirectory 
-          ? [] 
+        (album.alwaysReferenceByDirectory
+          ? []
+          : [album.name]),
+    },
+
+    albumSinglesOnly: {
+      referencing: ['album'],
+
+      bindTo: 'albumData',
+
+      incldue: album =>
+        album.style === 'single',
+
+      getMatchableNames: album =>
+        (album.alwaysReferenceByDirectory
+          ? []
           : [album.name]),
     },
 
@@ -396,8 +492,8 @@ export class Album extends Thing {
         album.hasCoverArt,
 
       getMatchableNames: album =>
-        (album.alwaysReferenceByDirectory 
-          ? [] 
+        (album.alwaysReferenceByDirectory
+          ? []
           : [album.name]),
     },
 
@@ -428,20 +524,6 @@ export class Album extends Thing {
   };
 
   static [Thing.reverseSpecs] = {
-    albumsWhoseTracksInclude: {
-      bindTo: 'albumData',
-
-      referencing: album => [album],
-      referenced: album => album.tracks,
-    },
-
-    albumsWhoseTrackSectionsInclude: {
-      bindTo: 'albumData',
-
-      referencing: album => [album],
-      referenced: album => album.trackSections,
-    },
-
     albumsWhoseArtworksFeature: {
       bindTo: 'albumData',
 
@@ -459,6 +541,9 @@ export class Album extends Thing {
     albumArtistContributionsBy:
       soupyReverse.contributionsBy('albumData', 'artistContribs'),
 
+    albumTrackArtistContributionsBy:
+      soupyReverse.contributionsBy('albumData', 'trackArtistContribs'),
+
     albumCoverArtistContributionsBy:
       soupyReverse.artworkContributionsBy('albumData', 'coverArtworks'),
 
@@ -478,21 +563,15 @@ 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'},
+      'Style': {property: 'style'},
 
       'Bandcamp Album ID': {
         property: 'bandcampAlbumIdentifier',
@@ -504,18 +583,61 @@ 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,
+      },
+
+      'Track Artist Text': {
+        property: 'trackArtistText',
+      },
+
+      'Track Artists': {
+        property: 'trackArtistContribs',
+        transform: parseContributors,
+      },
+
+      // General configuration
+
+      'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'},
+
+      'Show Album In Tracks Without Artists': {
+        property: 'showAlbumInTracksWithoutArtists',
+      },
 
       'Has Track Numbers': {property: 'hasTrackNumbers'},
       'Listed on Homepage': {property: 'isListedOnHomepage'},
       'Listed in Galleries': {property: 'isListedInGalleries'},
 
+      'Hide Duration': {property: 'hideDuration'},
+
+      // 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:
@@ -559,27 +681,29 @@ export class Album extends Thing {
           }),
       },
 
+      'Cover Artists': {
+        property: 'coverArtistContribs',
+        transform: parseContributors,
+      },
+
       'Cover Art Date': {
         property: 'coverArtDate',
         transform: parseDate,
       },
 
-      'Default Track Cover Art Date': {
-        property: 'trackArtDate',
-        transform: parseDate,
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
       },
 
-      'Date Added': {
-        property: 'dateAddedToWiki',
-        transform: parseDate,
+      'Default Track Cover Artists': {
+        property: 'trackCoverArtistContribs',
+        transform: parseContributors,
       },
 
-      'Cover Art File Extension': {property: 'coverArtFileExtension'},
-      'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
-
-      'Cover Art Dimensions': {
-        property: 'coverArtDimensions',
-        transform: parseDimensions,
+      'Default Track Cover Art Date': {
+        property: 'trackArtDate',
+        transform: parseDate,
       },
 
       'Default Track Dimensions': {
@@ -593,7 +717,6 @@ export class Album extends Thing {
       },
 
       'Wallpaper Style': {property: 'wallpaperStyle'},
-      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
 
       'Wallpaper Parts': {
         property: 'wallpaperParts',
@@ -605,58 +728,70 @@ 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',
-        transform: parseCommentary,
-      },
+      'Banner Style': {property: 'bannerStyle'},
 
-      'Credit Sources': {
-        property: 'creditSources',
-        transform: parseCreditingSources,
-      },
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+      'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
+      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
+      'Banner File Extension': {property: 'bannerFileExtension'},
 
-      'Additional Files': {
-        property: 'additionalFiles',
-        transform: parseAdditionalFiles,
-      },
+      '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},
     },
 
     invalidFieldCombinations: [
+      {message: `Move commentary on singles to the track`, fields: [
+        ['Style', 'single'],
+        'Commentary',
+      ]},
+
+      {message: `Move crediting sources on singles to the track`, fields: [
+        ['Style', 'single'],
+        'Crediting Sources',
+      ]},
+
+      {message: `Move additional names on singles to the track`, fields: [
+        ['Style', 'single'],
+        'Additional Names',
+      ]},
+
       {message: `Specify one wallpaper style or multiple wallpaper parts, not both`, fields: [
         'Wallpaper Parts',
         'Wallpaper Style',
@@ -688,100 +823,48 @@ export class Album extends Thing {
         ? TrackSection
         : Track),
 
-    save(results) {
-      const albumData = [];
-      const trackSectionData = [];
-      const trackData = [];
-
-      const artworkData = [];
-      const commentaryData = [];
-      const creditingSourceData = [];
-      const lyricsData = [];
-
-      for (const {header: album, entries} of results) {
-        const trackSections = [];
-
-        let currentTrackSection = new TrackSection();
-        let currentTrackSectionTracks = [];
-
-        Object.assign(currentTrackSection, {
-          name: `Default Track Section`,
-          isDefaultTrackSection: true,
-        });
-
-        const albumRef = Thing.getReference(album);
-
-        const closeCurrentTrackSection = () => {
-          if (
-            currentTrackSection.isDefaultTrackSection &&
-            empty(currentTrackSectionTracks)
-          ) {
-            return;
-          }
-
-          currentTrackSection.tracks =
-            currentTrackSectionTracks;
-
-          trackSections.push(currentTrackSection);
-          trackSectionData.push(currentTrackSection);
-        };
-
-        for (const entry of entries) {
-          if (entry instanceof TrackSection) {
-            closeCurrentTrackSection();
-            currentTrackSection = entry;
-            currentTrackSectionTracks = [];
-            continue;
-          }
-
-          currentTrackSectionTracks.push(entry);
-          trackData.push(entry);
-
-          // Set the track's album before accessing its list of artworks.
-          // The existence of its artwork objects may depend on access to
-          // its album's 'Default Track Cover Artists'.
-          entry.album = album;
-
-          artworkData.push(...entry.trackArtworks);
-          commentaryData.push(...entry.commentary);
-          creditingSourceData.push(...entry.creditSources);
-
-          // TODO: As exposed, Track.lyrics tries to inherit from the main
-          // release, which is impossible before the data's been linked.
-          // We just use the update value here. But it's icky!
-          lyricsData.push(...CacheableObject.getUpdateValue(entry, 'lyrics') ?? []);
-        }
-
-        closeCurrentTrackSection();
+    connect({header: album, entries}) {
+      const trackSections = [];
 
-        albumData.push(album);
+      let currentTrackSection = new TrackSection();
+      let currentTrackSectionTracks = [];
 
-        artworkData.push(...album.coverArtworks);
+      Object.assign(currentTrackSection, {
+        name: `Default Track Section`,
+        isDefaultTrackSection: true,
+      });
 
-        if (album.bannerArtwork) {
-          artworkData.push(album.bannerArtwork);
+      const closeCurrentTrackSection = () => {
+        if (
+          currentTrackSection.isDefaultTrackSection &&
+          empty(currentTrackSectionTracks)
+        ) {
+          return;
         }
 
-        if (album.wallpaperArtwork) {
-          artworkData.push(album.wallpaperArtwork);
+        currentTrackSection.tracks = currentTrackSectionTracks;
+        currentTrackSection.album = album;
+
+        trackSections.push(currentTrackSection);
+      };
+
+      for (const entry of entries) {
+        if (entry instanceof TrackSection) {
+          closeCurrentTrackSection();
+          currentTrackSection = entry;
+          currentTrackSectionTracks = [];
+          continue;
         }
 
-        commentaryData.push(...album.commentary);
-        creditingSourceData.push(...album.creditSources);
+        entry.album = album;
+        entry.trackSection = currentTrackSection;
 
-        album.trackSections = trackSections;
+        currentTrackSectionTracks.push(entry);
       }
 
-      return {
-        albumData,
-        trackSectionData,
-        trackData,
+      closeCurrentTrackSection();
 
-        artworkData,
-        commentaryData,
-        creditingSourceData,
-        lyricsData,
-      };
+      album.trackSections = trackSections;
     },
 
     sort({albumData, trackData}) {
@@ -835,56 +918,120 @@ export class Album extends Thing {
       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.wikiData] = 'trackSectionData';
 
-  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+  static [Thing.getPropertyDescriptors] = ({Track}) => ({
     // Update & expose
 
-    name: name('Unnamed Track Section'),
+    album: thing(V(Album)),
+
+    name: name(V('Unnamed Track Section')),
 
     unqualifiedDirectory: directory(),
 
+    directorySuffix: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDirectory),
+      }),
+
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('directorySuffix'),
+      }),
+
+      exposeDependency({dependency: '#album.directorySuffix'}),
+    ],
+
+    suffixTrackDirectories: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('suffixTrackDirectories'),
+      }),
+
+      exposeDependency({dependency: '#album.suffixTrackDirectories'}),
+    ],
+
     color: [
       exposeUpdateValueOrContinue({
         validate: input.value(isColor),
       }),
 
-      withAlbum(),
-
       withPropertyFromObject({
-        object: '#album',
+        object: 'album',
         property: input.value('color'),
       }),
 
       exposeDependency({dependency: '#album.color'}),
     ],
 
+    hasTrackNumbers: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject('album', V('hasTrackNumbers')),
+      exposeDependency('#album.hasTrackNumbers'),
+    ],
+
     startCountingFrom: [
-      withStartCountingFrom({
-        from: input.updateValue({validate: isNumber}),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isNumber),
+      }),
+
+      withPropertyFromObject('album', V('hasTrackNumbers')),
+      exitWithoutDependency('#album.hasTrackNumbers', V(1), V('falsy')),
+
+      withPropertyFromObject('album', V('trackSections')),
+
+      withNearbyItemFromList({
+        list: '#album.trackSections',
+        item: input.myself(),
+        offset: input.value(-1),
+      }).outputs({
+        '#nearbyItem': '#previousTrackSection',
       }),
 
-      exposeDependency({dependency: '#startCountingFrom'}),
+      exitWithoutDependency('#previousTrackSection', V(1)),
+
+      withPropertyFromObject('#previousTrackSection', V('continueCountingFrom')),
+      exposeDependency('#previousTrackSection.continueCountingFrom'),
     ],
 
     dateOriginallyReleased: simpleDate(),
 
-    isDefaultTrackSection: flag(false),
+    countTracksInArtistTotals: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
 
-    description: contentString(),
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('countTracksInArtistTotals'),
+      }),
 
-    album: [
-      withAlbum(),
-      exposeDependency({dependency: '#album'}),
+      exposeDependency({dependency: '#album.countTracksInArtistTotals'}),
     ],
 
-    tracks: thingList({
-      class: input.value(Track),
-    }),
+    isDefaultTrackSection: flag(V(false)),
+
+    description: contentString(),
+
+    tracks: thingList(V(Track)),
 
     // Update only
 
@@ -892,38 +1039,51 @@ export class TrackSection extends Thing {
 
     // Expose only
 
-    directory: [
-      withAlbum(),
+    isTrackSection: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
 
+    directory: [
       exitWithoutDependency({
-        dependency: '#album',
+        dependency: 'album',
       }),
 
       withPropertyFromObject({
-        object: '#album',
+        object: 'album',
         property: input.value('directory'),
       }),
 
-      withDirectory({
-        directory: 'unqualifiedDirectory',
-      }).outputs({
-        '#directory': '#unqualifiedDirectory',
-      }),
-
       {
-        dependencies: ['#album.directory', '#unqualifiedDirectory'],
+        dependencies: ['#album.directory', 'unqualifiedDirectory'],
         compute: ({
           ['#album.directory']: albumDirectory,
-          ['#unqualifiedDirectory']: unqualifiedDirectory,
+          ['unqualifiedDirectory']: unqualifiedDirectory,
         }) =>
           albumDirectory + '/' + unqualifiedDirectory,
       },
     ],
 
     continueCountingFrom: [
-      withContinueCountingFrom(),
+      withPropertyFromObject('album', V('hasTrackNumbers')),
+      exitWithoutDependency('#album.hasTrackNumbers', V(null), V('falsy')),
 
-      exposeDependency({dependency: '#continueCountingFrom'}),
+      {
+        dependencies: ['hasTrackNumbers', 'startCountingFrom'],
+        compute: (continuation, {hasTrackNumbers, startCountingFrom}) =>
+          (hasTrackNumbers
+            ? continuation()
+            : continuation.exit(startCountingFrom)),
+      },
+
+      withLengthOfList('tracks'),
+
+      {
+        dependencies: ['startCountingFrom', '#tracks.length'],
+        compute: ({startCountingFrom, '#tracks.length': tracks}) =>
+          startCountingFrom + tracks,
+      },
     ],
   });
 
@@ -941,19 +1101,14 @@ export class TrackSection extends Thing {
     },
   };
 
-  static [Thing.reverseSpecs] = {
-    trackSectionsWhichInclude: {
-      bindTo: 'trackSectionData',
-
-      referencing: trackSection => [trackSection],
-      referenced: trackSection => trackSection.tracks,
-    },
-  };
-
   static [Thing.yamlDocumentSpec] = {
     fields: {
       'Section': {property: 'name'},
+      'Directory Suffix': {property: 'directorySuffix'},
+      'Suffix Track Directories': {property: 'suffixTrackDirectories'},
+
       'Color': {property: 'color'},
+      'Has Track Numbers': {property: 'hasTrackNumbers'},
       'Start Counting From': {property: 'startCountingFrom'},
 
       'Date Originally Released': {
@@ -961,6 +1116,8 @@ export class TrackSection extends Thing {
         transform: parseDate,
       },
 
+      'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'},
+
       'Description': {property: 'description'},
     },
   };
@@ -970,11 +1127,13 @@ 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 {
@@ -986,22 +1145,20 @@ export class TrackSection extends Thing {
         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 && last !== null
-            ? `: ${first}-${last}`
-            : '');
+      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('');