« 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
diff options
context:
space:
mode:
Diffstat (limited to 'src/data/things/album')
-rw-r--r--src/data/things/album/Album.js1174
-rw-r--r--src/data/things/album/AsideTrackSection.js20
-rw-r--r--src/data/things/album/CloseAsideTrackSection.js20
-rw-r--r--src/data/things/album/TrackSection.js290
-rw-r--r--src/data/things/album/index.js5
5 files changed, 1509 insertions, 0 deletions
diff --git a/src/data/things/album/Album.js b/src/data/things/album/Album.js
new file mode 100644
index 00000000..06329345
--- /dev/null
+++ b/src/data/things/album/Album.js
@@ -0,0 +1,1174 @@
+import {input, V} from '#composite';
+import {empty} from '#sugar';
+import Thing from '#thing';
+import {getKebabCase} from '#wiki-data';
+
+import {
+  anyOf,
+  is,
+  isBoolean,
+  isContributionList,
+  isDate,
+  isExcludingURLsReason,
+  isDirectory,
+  isNumber,
+  isString,
+} from '#validators';
+
+import {
+  parseAdditionalFiles,
+  parseAdditionalNames,
+  parseAlwaysReferenceByDirectory,
+  parseAnnotatedReferences,
+  parseArtwork,
+  parseCommentary,
+  parseContributors,
+  parseCreditingSources,
+  parseDate,
+  parseDimensions,
+  parseExcludingURLs,
+  parseMusicVideos,
+  parseWallpaperParts,
+  parseURLs,
+} from '#yaml';
+
+import {withFlattenedList, withPropertyFromList} from '#composite/data';
+
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  withDirectory,
+  withRecontextualizedContributionList,
+  withResolvedContribs,
+} from '#composite/wiki-data';
+
+import {
+  color,
+  commentatorArtists,
+  constitutibleArtwork,
+  constitutibleArtworkList,
+  contentString,
+  contributionList,
+  dimensions,
+  fileExtension,
+  flag,
+  hasArtwork,
+  name,
+  referencedArtworkList,
+  referenceList,
+  simpleDate,
+  simpleString,
+  soupyFind,
+  soupyReverse,
+  thing,
+  thingList,
+  urls,
+  wallpaperParts,
+  wikiData,
+} from '#composite/wiki-properties';
+
+export class Album extends Thing {
+  static [Thing.referenceType] = 'album';
+  static [Thing.wikiData] = 'albumData';
+
+  static [Thing.constitutibleProperties] = [
+    'coverArtworks',
+    'wallpaperArtwork',
+    'bannerArtwork',
+  ];
+
+  static [Thing.getPropertyDescriptors] = ({
+    AdditionalName,
+    AlbumArtistContribution,
+    AlbumBannerArtistContribution,
+    AlbumWallpaperArtistContribution,
+    ArtTag,
+    Artwork,
+    CommentaryEntry,
+    CreditingSourcesEntry,
+    Group,
+    MiscellaneousAdditionalFile,
+    MusicVideo,
+    TrackArtistContribution,
+    TrackSection,
+    WikiInfo,
+  }) => ({
+    // > Update & expose - Internal relationships
+
+    trackSections: thingList(V(TrackSection)),
+
+    // > Update & expose - Identifying metadata
+
+    name: name(V('Unnamed Album')),
+    nameDetail: simpleString(),
+
+    nameDetailForTracks: {
+      flags: {update: true, expose: true},
+
+      update: {validate: isString},
+
+      expose: {
+        dependencies: ['name', 'nameDetail'],
+        transform: (value, {name, nameDetail}) =>
+          (value
+            ? value
+         : nameDetail
+            ? `${name}, ${nameDetail}`
+            : name),
+      },
+    },
+
+    directory: [
+      withDirectory({
+        directory:
+          input.updateValue({
+            validate(value) {
+              isDirectory(value);
+
+              if (value === 'vgm') {
+                throw new Error(
+                  `"vgm" is a reserved directory and can't be used albums`);
+              }
+
+              return true;
+            },
+          }),
+
+        suffix: 'suffixDirectory',
+      }),
+
+      exposeDependency('#directory'),
+    ],
+
+    suffixDirectory: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(
+          anyOf(is(false), isDirectory)),
+      }),
+
+      {
+        transform: (value, continuation) =>
+          (value === false
+            ? null
+            : continuation()),
+      },
+
+      {
+        dependencies: ['nameDetail'],
+        compute(continuation, {nameDetail}) {
+          if (nameDetail) {
+            return getKebabCase(nameDetail);
+          }
+
+          return continuation();
+        },
+      },
+
+      exposeConstant(V(null)),
+    ],
+
+    directorySuffixForTracks: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDirectory),
+      }),
+
+      {
+        dependencies: ['directory', 'name', 'nameDetailForTracks'],
+        compute: ({directory, name, nameDetailForTracks}) =>
+          (nameDetailForTracks === name
+            ? directory
+            : getKebabCase(nameDetailForTracks)),
+      },
+    ],
+
+    suffixTrackDirectoriesByDefault: flag(V(false)),
+
+    alwaysReferenceByDirectory: flag(V(false)),
+
+    referenceTracksByDirectory: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(
+          is(...[
+            'always',
+            'outside album',
+            // 'outside groups',
+            'normally',
+          ])),
+      }),
+
+      exposeConstant(V('normally')),
+    ],
+
+    style: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(is(...[
+          'album',
+          'single',
+          'meta',
+          'in-game vgm',
+        ])),
+      }),
+
+      exposeConstant(V('album')),
+    ],
+
+    bandcampAlbumIdentifier: simpleString(),
+    bandcampArtworkIdentifier: simpleString(),
+
+    additionalNames: thingList(V(AdditionalName)),
+
+    dateReleased: simpleDate(),
+    datePosted: simpleDate(),
+    dateAddedToWiki: simpleDate(),
+
+    // > Update & expose - Credits and contributors
+
+    artistContribs: contributionList({
+      class: input.value(AlbumArtistContribution),
+      artistProperty: input.value('albumArtistContributions'),
+    }),
+
+    trackArtistText: contentString(),
+
+    trackArtistContribs: [
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        class: input.value(TrackArtistContribution),
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('albumTrackArtistContributions'),
+      }).outputs({
+        '#resolvedContribs': '#trackArtistContribs',
+      }),
+
+      exposeDependencyOrContinue('#trackArtistContribs', V('empty')),
+
+      withRecontextualizedContributionList('artistContribs', {
+        reclass: input.value(TrackArtistContribution),
+        artistProperty: input.value('albumTrackArtistContributions'),
+      }),
+
+      exposeDependency('#artistContribs'),
+    ],
+
+    // > Update & expose - General configuration
+
+    countTracksInArtistTotals: flag(V(true)),
+
+    excludingTrackURLs: {
+      flags: {update: true, expose: true},
+      update: {validate: isExcludingURLsReason},
+    },
+
+    isListedOnHomepage: flag(V(true)),
+    isListedInGalleries: flag(V(true)),
+
+    hasTrackNumbers: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      {
+        dependencies: ['style'],
+        compute: ({style}) =>
+          (style === 'in-game vgm'
+            ? false
+            : true),
+      },
+    ],
+
+    showAlbumInAllTracks: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      {
+        dependencies: ['style'],
+        compute: ({style}) =>
+          (style === 'in-game vgm'
+            ? true
+            : false),
+      },
+    ],
+
+    showAlbumInTracksWithoutArtists: flag(V(false)),
+
+    showTrackSectionInNavBar: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      {
+        dependencies: ['style'],
+        compute: ({style}) =>
+          (style === 'in-game vgm'
+            ? true
+            : false),
+      },
+    ],
+
+    showArtistsInTrackList: flag(V(true)),
+
+    hideDuration: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      {
+        dependencies: ['style'],
+        compute: ({style}) =>
+          (style === 'in-game vgm'
+            ? true
+         : style === 'meta'
+            ? true
+            : false),
+      },
+    ],
+
+    hideTrackSectionDurations: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      exposeDependency('hideDuration'),
+    ],
+
+    // > Update & expose - General metadata
+
+    color: color(),
+
+    urls: urls(),
+
+    // > Update & expose - Artworks
+
+    coverArtworks: [
+      exitWithoutDependency('hasCoverArt', {
+        value: input.value([]),
+        mode: input.value('falsy'),
+      }),
+
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
+    ],
+
+    hasCoverArt: [
+      {
+        dependencies: ['style'],
+        compute: (continuation, {style}) =>
+          continuation({
+            ['#default']:
+              (style === 'in-game vgm'
+                ? true
+                : false),
+          }),
+      },
+
+      hasArtwork({
+        contribs: '_coverArtistContribs',
+        artworks: '_coverArtworks',
+        default: '#default',
+      }),
+    ],
+
+    coverArtistContribs: contributionList({
+      date: 'coverArtDate',
+      artistProperty: input.value('albumCoverArtistContributions'),
+    }),
+
+    coverArtDate: [
+      exitWithoutDependency('hasCoverArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDate),
+      }),
+
+      exposeDependency('date'),
+    ],
+
+    coverArtFileExtension: [
+      exitWithoutDependency('hasCoverArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      fileExtension(V('jpg')),
+    ],
+
+    coverArtDimensions: [
+      exitWithoutDependency('hasCoverArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      dimensions(),
+    ],
+
+    artTags: [
+      exitWithoutDependency('hasCoverArt', {
+        value: input.value([]),
+        mode: input.value('falsy'),
+      }),
+
+      referenceList({
+        class: input.value(ArtTag),
+        find: soupyFind.input('artTag'),
+      }),
+    ],
+
+    referencedArtworks: [
+      exitWithoutDependency('hasCoverArt', {
+        value: input.value([]),
+        mode: input.value('falsy'),
+      }),
+
+      referencedArtworkList(),
+    ],
+
+    trackCoverArtistContribs: contributionList({
+      // May be null, indicating cover art was added for tracks on the date
+      // each track specifies, or else the track's own release date.
+      date: 'trackArtDate',
+
+      // This is the "correct" value, but it gets overwritten - with the same
+      // value - regardless.
+      artistProperty: input.value('trackCoverArtistContributions'),
+    }),
+
+    trackArtDate: simpleDate(),
+
+    trackCoverArtFileExtension: fileExtension(V('jpg')),
+
+    trackDimensions: dimensions(),
+
+    wallpaperBrightness: {
+      flags: {update: true, expose: true},
+      update: {validate: isNumber},
+    },
+
+    wallpaperArtwork: [
+      exitWithoutDependency('hasWallpaperArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Wallpaper Artwork'),
+    ],
+
+    hasWallpaperArt: hasArtwork({
+      contribs: '_wallpaperArtistContribs',
+      artwork: '_wallpaperArtwork',
+    }),
+
+    wallpaperArtistContribs: contributionList({
+      class: input.value(AlbumWallpaperArtistContribution),
+      date: 'coverArtDate',
+      artistProperty: input.value('albumWallpaperArtistContributions'),
+    }),
+
+    wallpaperFileExtension: [
+      exitWithoutDependency('hasWallpaperArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      fileExtension(V('jpg')),
+    ],
+
+    wallpaperStyle: [
+      exitWithoutDependency('hasWallpaperArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      simpleString(),
+    ],
+
+    wallpaperParts: [
+      exitWithoutDependency('hasWallpaperArt', {
+        value: input.value([]),
+        mode: input.value('falsy'),
+      }),
+
+      wallpaperParts(),
+    ],
+
+    bannerArtwork: [
+      exitWithoutDependency('hasBannerArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Banner Artwork'),
+    ],
+
+    hasBannerArt: hasArtwork({
+      contribs: '_bannerArtistContribs',
+      artwork: '_bannerArtwork',
+    }),
+
+    bannerArtistContribs: contributionList({
+      class: input.value(AlbumBannerArtistContribution),
+      date: 'coverArtDate',
+      artistProperty: input.value('albumBannerArtistContributions'),
+    }),
+
+    bannerFileExtension: [
+      exitWithoutDependency('hasBannerArt', {
+        value: input.value(null),
+        mode: input.value('falsy'),
+      }),
+
+      fileExtension(V('jpg')),
+    ],
+
+    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 - Music videos
+
+    musicVideos: thingList(V(MusicVideo)),
+
+    // > Update & expose - Content entries
+
+    commentary: thingList(V(CommentaryEntry)),
+    creditingSources: thingList(V(CreditingSourcesEntry)),
+
+    // > Update & expose - Additional files
+
+    additionalFiles: thingList(V(MiscellaneousAdditionalFile)),
+
+    // > Update only
+
+    find: soupyFind(),
+    reverse: soupyReverse(),
+
+    // used for referencedArtworkList (mixedFind)
+    artworkData: wikiData(V(Artwork)),
+
+    // used for withMatchingContributionPresets (indirectly by Contribution)
+    wikiInfo: thing(V(WikiInfo)),
+
+    // > Expose only
+
+    isAlbum: exposeConstant(V(true)),
+
+    nameForReferencingAcrossWiki: [
+      {
+        dependencies: ['alwaysReferenceByDirectory'],
+        compute: (continuation, {alwaysReferenceByDirectory}) =>
+          (alwaysReferenceByDirectory
+            ? continuation.exit(null)
+            : continuation()),
+      },
+
+      exposeDependency('nameForSorting'),
+    ],
+
+    nameForSorting: [
+      {
+        dependencies: ['name', 'nameDetail'],
+        compute: (continuation, {name, nameDetail}) =>
+          (nameDetail
+            ? continuation.exit(`${name} (${nameDetail})`)
+            : continuation()),
+      },
+
+      exposeDependency('name'),
+    ],
+
+    commentatorArtists: commentatorArtists(),
+
+    tracks: [
+      exitWithoutDependency('trackSections', V([])),
+
+      withPropertyFromList('trackSections', V('tracks')),
+      withFlattenedList('#trackSections.tracks'),
+      exposeDependency('#flattenedList'),
+    ],
+
+    date: [
+      exposeDependencyOrContinue('dateReleased'),
+      exposeDependencyOrContinue('datePosted'),
+      exposeConstant(V(null)),
+    ],
+
+    dateStyle: [
+      exitWithoutDependency('date'),
+
+      {
+        dependencies: ['_dateReleased', '_datePosted'],
+        compute: ({
+          ['_dateReleased']: dateReleased,
+          ['_datePosted']: datePosted,
+        }) =>
+          (dateReleased ? 'released'
+         : datePosted   ? 'posted'
+                        : null),
+      },
+    ]
+  });
+
+  static [Thing.getSerializeDescriptors] = ({
+    serialize: S,
+  }) => ({
+    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,
+    isListedOnHomepage: S.id,
+
+    commentary: S.toCommentaryRefs,
+
+    additionalFiles: S.id,
+
+    tracks: S.toRefs,
+    groups: S.toRefs,
+    artTags: S.toRefs,
+    commentatorArtists: S.toRefs,
+  });
+
+  static [Thing.findSpecs] = {
+    album: {
+      referenceTypes: [
+        'album',
+        'album-commentary',
+        'album-gallery',
+      ],
+
+      bindTo: 'albumData',
+
+      getMatchableNames: album =>
+        (album.alwaysReferenceByDirectory
+          ? []
+          : [album.name]),
+    },
+
+    albumSinglesOnly: {
+      referencing: ['album'],
+
+      bindTo: 'albumData',
+
+      incldue: album =>
+        album.style === 'single',
+
+      getMatchableNames: album =>
+        (album.alwaysReferenceByDirectory
+          ? []
+          : [album.name]),
+    },
+
+    albumWithArtwork: {
+      referenceTypes: [
+        'album',
+        'album-referencing-artworks',
+        'album-referenced-artworks',
+      ],
+
+      bindTo: 'albumData',
+
+      include: album =>
+        album.hasCoverArt,
+
+      getMatchableNames: album =>
+        (album.alwaysReferenceByDirectory
+          ? []
+          : [album.name]),
+    },
+
+    albumPrimaryArtwork: {
+      [Thing.findThisThingOnly]: false,
+
+      referenceTypes: [
+        'album',
+        'album-referencing-artworks',
+        'album-referenced-artworks',
+      ],
+
+      bindTo: 'artworkData',
+
+      include: (artwork) =>
+        artwork.isArtwork &&
+        artwork.thing.isAlbum &&
+        artwork === artwork.thing.coverArtworks[0],
+
+      getMatchableNames: ({thing: album}) =>
+        (album.alwaysReferenceByDirectory
+          ? []
+          : [album.name]),
+
+      getMatchableDirectories: ({thing: album}) =>
+        [album.directory],
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    albumsWhoseArtworksFeature: {
+      bindTo: 'albumData',
+
+      referencing: album => [album],
+      referenced: album => album.artTags,
+    },
+
+    albumsWhoseGroupsInclude: {
+      bindTo: 'albumData',
+
+      referencing: album => [album],
+      referenced: album => album.groups,
+    },
+
+    albumArtistContributionsBy:
+      soupyReverse.contributionsBy('albumData', 'artistContribs'),
+
+    albumTrackArtistContributionsBy:
+      soupyReverse.contributionsBy('albumData', 'trackArtistContribs'),
+
+    albumCoverArtistContributionsBy:
+      soupyReverse.artworkContributionsBy('albumData', 'coverArtworks'),
+
+    albumWallpaperArtistContributionsBy:
+      soupyReverse.artworkContributionsBy('albumData', 'wallpaperArtwork', {single: true}),
+
+    albumBannerArtistContributionsBy:
+      soupyReverse.artworkContributionsBy('albumData', 'bannerArtwork', {single: true}),
+
+    albumsWithCommentaryBy: {
+      bindTo: 'albumData',
+
+      referencing: album => [album],
+      referenced: album => album.commentatorArtists,
+    },
+  };
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      // Identifying metadata
+
+      'Album': {property: 'name'},
+      'Name Detail': {property: 'nameDetail'},
+      'Name Detail For Tracks': {property: 'nameDetailForTracks'},
+
+      'Directory': {property: 'directory'},
+      'Suffix Own Directory': {property: 'suffixDirectory'},
+
+      'Directory Suffix': {property: 'directorySuffixForTracks'},
+      'Suffix Track Directories': {property: 'suffixTrackDirectoriesByDefault'},
+
+      'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
+
+      'Reference Tracks By Directory': {property: 'referenceTracksByDirectory'},
+
+      'Always Reference Tracks By Directory': {
+        property: 'referenceTracksByDirectory',
+        transform: parseAlwaysReferenceByDirectory,
+      },
+
+      'Style': {property: 'style'},
+
+      'Bandcamp Album ID': {
+        property: 'bandcampAlbumIdentifier',
+        transform: String,
+      },
+
+      'Bandcamp Artwork ID': {
+        property: 'bandcampArtworkIdentifier',
+        transform: String,
+      },
+
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
+
+      'Date': {property: 'dateReleased', transform: parseDate},
+      'Date Posted': {property: 'datePosted', transform: parseDate},
+      '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'},
+
+      'Excluding Track URLs': {
+        property: 'excludingTrackURLs',
+        transform: parseExcludingURLs,
+      },
+
+      'Listed on Homepage': {property: 'isListedOnHomepage'},
+      'Listed in Galleries': {property: 'isListedInGalleries'},
+
+      'Has Track Numbers': {property: 'hasTrackNumbers'},
+      'Show Album In All Tracks': {property: 'showAlbumInAllTracks'},
+      'Show Album In Tracks Without Artists': {property: 'showAlbumInTracksWithoutArtists'},
+      'Show Section In Nav Bar': {property: 'showTrackSectionInNavBar'},
+      'Show Artists In Track List': {property: 'showArtistsInTrackList'},
+      'Hide Duration': {property: 'hideDuration'},
+      'Hide Track Section Durations': {property: 'hideTrackSectionDurations'},
+
+      // General metadata
+
+      'Color': {property: 'color'},
+
+      'URLs': {property: 'urls', transform: parseURLs},
+
+      // 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',
+          }),
+      },
+
+      'Banner Artwork': {
+        property: 'bannerArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            thingProperty: 'bannerArtwork',
+            dimensionsFromThingProperty: 'bannerDimensions',
+            fileExtensionFromThingProperty: 'bannerFileExtension',
+            dateFromThingProperty: 'date',
+            artistContribsFromThingProperty: 'bannerArtistContribs',
+            artistContribsArtistProperty: 'albumBannerArtistContributions',
+          }),
+      },
+
+      'Wallpaper Brightness': {property: 'wallpaperBrightness'},
+
+      'Wallpaper Artwork': {
+        property: 'wallpaperArtwork',
+        transform:
+          parseArtwork({
+            single: true,
+            thingProperty: 'wallpaperArtwork',
+            dimensionsFromThingProperty: null,
+            fileExtensionFromThingProperty: 'wallpaperFileExtension',
+            dateFromThingProperty: 'date',
+            artistContribsFromThingProperty: 'wallpaperArtistContribs',
+            artistContribsArtistProperty: 'albumWallpaperArtistContributions',
+          }),
+      },
+
+      'Has Cover Art': {property: 'hasCoverArt'},
+
+      '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,
+      },
+
+      'Has Wallpaper Art': {property: 'hasWallpaperArt'},
+
+      'Wallpaper Artists': {
+        property: 'wallpaperArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Wallpaper Style': {property: 'wallpaperStyle'},
+
+      'Wallpaper Parts': {
+        property: 'wallpaperParts',
+        transform: parseWallpaperParts,
+      },
+
+      'Has Banner Art': {property: 'hasBannerArt'},
+
+      'Banner Artists': {
+        property: 'bannerArtistContribs',
+        transform: parseContributors,
+      },
+
+      'Banner Dimensions': {
+        property: 'bannerDimensions',
+        transform: parseDimensions,
+      },
+
+      'Banner Style': {property: 'bannerStyle'},
+
+      '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,
+      },
+
+      // Groups
+
+      'Groups': {property: 'groups'},
+
+      // Music videos
+
+      'Music Videos': {
+        property: 'musicVideos',
+        transform: parseMusicVideos,
+      },
+
+      // Content entries
+
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
+      },
+
+      'Crediting Sources': {
+        property: 'creditingSources',
+        transform: parseCreditingSources,
+      },
+
+      // Additional files
+
+      'Additional Files': {
+        property: 'additionalFiles',
+        transform: parseAdditionalFiles,
+      },
+
+      // Shenanigans
+
+      'Franchises': {ignore: true},
+      'Review Points': {ignore: true},
+    },
+
+    invalidFieldCombinations: [
+      {
+        message: `Move commentary on singles to the track`,
+
+        fields: [
+          ['Style', 'single'],
+          'Commentary',
+        ],
+
+        drop: ['Commentary'],
+      },
+
+      {
+        message: `Move crediting sources on singles to the track`,
+
+        fields: [
+          ['Style', 'single'],
+          'Crediting Sources',
+        ],
+
+        drop: ['Crediting Sources'],
+      },
+
+      {
+        message: `Move additional names on singles to the track`,
+
+        fields: [
+          ['Style', 'single'],
+          'Additional Names',
+        ],
+
+        drop: ['Additional Names'],
+      },
+
+      {message: `Specify one wallpaper style or multiple wallpaper parts, not both`, fields: [
+        'Wallpaper Parts',
+        'Wallpaper Style',
+      ]},
+
+      {
+        message: `Wallpaper file extensions are specified on asset, per part`,
+
+        fields: [
+          'Wallpaper Parts',
+          'Wallpaper File Extension',
+        ],
+
+        drop: ['Wallpaper File Extension'],
+      },
+
+      {
+        message: `Albums of style 'in-game vgm' have cover art by default`,
+
+        fields: [
+          ['Has Cover Art', true],
+          ['Style', 'in-game vgm'],
+        ],
+
+        drop: ['Has Cover Art'],
+      },
+    ],
+  };
+
+  getOwnAdditionalFilePath(file, filename) {
+    if (file.folder) {
+      return [
+        'media.albumAdditionalFileInFolder',
+        this.directory,
+        file.folder,
+        filename,
+      ];
+    } else {
+      return [
+        'media.albumAdditionalFile',
+        this.directory,
+        filename,
+      ];
+    }
+  }
+
+  getOwnArtworkPath(artwork) {
+    const ext = artwork.fileExtension;
+
+    if (artwork === this.bannerArtwork) {
+      return this.getAlbumArtPath(`banner.${ext}`);
+    }
+
+    if (artwork === this.wallpaperArtwork) {
+      if (empty(this.wallpaperParts)) {
+        return this.getAlbumArtPath(`bg.${ext}`);
+      } else {
+        return null;
+      }
+    }
+
+    const basename =
+      (artwork.unqualifiedDirectory
+        ? 'cover-' + artwork.unqualifiedDirectory
+        : 'cover');
+
+    return this.getAlbumArtPath(`${basename}.${ext}`);
+  }
+
+  getWallpaperPartPath(part) {
+    return this.getAlbumArtPath(part.asset);
+  }
+
+  getOwnMusicVideoCoverPath(musicVideo) {
+    const filename =
+      musicVideo.unqualifiedDirectory +
+      `.${musicVideo.coverArtFileExtension}`;
+
+    return this.getAlbumArtPath(filename);
+  }
+
+  getAlbumArtPath(filename) {
+    const key = this.#getArtworkPathKey();
+    const front = [key, this.directory];
+    return [...front, filename];
+  }
+
+  #getArtworkPathKey(artwork) {
+    switch (this.style) {
+      case 'in-game vgm':
+        return 'media.vgmAlbumArt';
+
+      default:
+        return 'media.albumArt';
+    }
+  }
+
+  // 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;
+  }
+}
diff --git a/src/data/things/album/AsideTrackSection.js b/src/data/things/album/AsideTrackSection.js
new file mode 100644
index 00000000..9b1f9b57
--- /dev/null
+++ b/src/data/things/album/AsideTrackSection.js
@@ -0,0 +1,20 @@
+// Annoying subclass just for direct access in YAML-loading.
+
+import Thing from '#thing';
+
+import {TrackSection} from './TrackSection.js';
+
+export class AsideTrackSection extends TrackSection {
+  static [Thing.getPropertyDescriptors] = () => ({
+    style: {
+      flags: {expose: true},
+      expose: {compute: () => 'aside'},
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Aside Section': {property: 'name'},
+    },
+  };
+}
diff --git a/src/data/things/album/CloseAsideTrackSection.js b/src/data/things/album/CloseAsideTrackSection.js
new file mode 100644
index 00000000..19d486a6
--- /dev/null
+++ b/src/data/things/album/CloseAsideTrackSection.js
@@ -0,0 +1,20 @@
+// Mock thing that stands in for closing an "aside" track section.
+// Only exists while loading, is discarded during connect().
+
+import Thing from '#thing';
+import {isString} from '#validators';
+
+export class CloseAsideTrackSection extends Thing {
+  static [Thing.getPropertyDescriptors] = () => ({
+    name: {
+      flags: {update: true, expose: true},
+      update: {validate: isString},
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Close Aside Section': {property: 'name'},
+    },
+  };
+}
diff --git a/src/data/things/album/TrackSection.js b/src/data/things/album/TrackSection.js
new file mode 100644
index 00000000..86c754ac
--- /dev/null
+++ b/src/data/things/album/TrackSection.js
@@ -0,0 +1,290 @@
+import {inspect} from 'node:util';
+
+import {colors} from '#cli';
+import {input, V} from '#composite';
+import Thing from '#thing';
+import {getKebabCase} from '#wiki-data';
+import {parseDate, parseExcludingURLs} from '#yaml';
+
+import {
+  anyOf,
+  is,
+  isBoolean,
+  isColor,
+  isDirectory,
+  isExcludingURLsReason,
+  isNumber,
+  isString,
+} from '#validators';
+
+import {withLengthOfList, withNearbyItemFromList, withPropertyFromObject}
+  from '#composite/data';
+
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  contentString,
+  directory,
+  flag,
+  name,
+  simpleDate,
+  soupyReverse,
+  thing,
+  thingList,
+} from '#composite/wiki-properties';
+
+export class TrackSection extends Thing {
+  static [Thing.friendlyName] = `Track Section`;
+  static [Thing.referenceType] = `track-section`;
+  static [Thing.wikiData] = 'trackSectionData';
+
+  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+    // Update & expose
+
+    album: thing(V(Album)),
+
+    name: name(V('Unnamed Track Section')),
+
+    style: {
+      flags: {update: true, expose: true},
+      update: {validate: is('section', 'aside')},
+      expose: {transform: value => value ?? 'section'},
+    },
+
+    // Track sections don't have a Name Detail themselves, but they do provide
+    // a value which tracks can reference via 'Name Detail: section'.
+    nameDetailForTracks: {
+      flags: {update: true, expose: true},
+
+      update: {validate: isString},
+
+      expose: {
+        dependencies: ['name'],
+        transform: (value, {name}) =>
+          (value
+            ? value
+            : name),
+      },
+    },
+
+    directorySuffixForTracks: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDirectory),
+      }),
+
+      {
+        dependencies: ['nameDetailForTracks'],
+        compute: ({nameDetailForTracks}) =>
+          getKebabCase(nameDetailForTracks),
+      },
+    ],
+
+    // Not quite a flag, because it supports (and defaults to) null.
+    // The value false means to explicitly ignore that the album is
+    // providing Suffix Track Directories: true. The value true means
+    // to use the TRACK SECTION's own directory suffix. The value null
+    // defers to the album's suffixTrackDirectoriesByDefault, which is
+    // simply true or false.
+    suffixTrackDirectoriesByDefault: {
+      flags: {expose: true, update: true},
+      update: {validate: isBoolean},
+    },
+
+    color: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isColor),
+      }),
+
+      withPropertyFromObject('album', V('color')),
+      exposeDependency('#album.color'),
+    ],
+
+    hasTrackNumbers: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject('album', V('hasTrackNumbers')),
+      exposeDependency('#album.hasTrackNumbers'),
+    ],
+
+    startCountingFrom: [
+      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',
+      }),
+
+      exitWithoutDependency('#previousTrackSection', V(1)),
+
+      withPropertyFromObject('#previousTrackSection', V('continueCountingFrom')),
+      exposeDependency('#previousTrackSection.continueCountingFrom'),
+    ],
+
+    dateOriginallyReleased: simpleDate(),
+
+    countTracksInArtistTotals: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject('album', V('countTracksInArtistTotals')),
+      exposeDependency('#album.countTracksInArtistTotals'),
+    ],
+
+    hideDuration: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      {
+        dependencies: ['style'],
+        compute: (continuation, {style}) =>
+          (style === 'aside'
+            ? true
+            : continuation()),
+      },
+
+      withPropertyFromObject('album', V('hideTrackSectionDurations')),
+      exposeDependency('#album.hideTrackSectionDurations'),
+    ],
+
+    excludingTrackURLs: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(
+          anyOf(
+            is(false),
+            isExcludingURLsReason)),
+      }),
+
+      withPropertyFromObject('album', V('excludingTrackURLs')),
+      exposeDependency('#album.excludingTrackURLs'),
+    ],
+
+    isDefaultTrackSection: flag(V(false)),
+
+    description: contentString(),
+
+    tracks: thingList(V(Track)),
+
+    // Update only
+
+    reverse: soupyReverse(),
+
+    // Expose only
+
+    isTrackSection: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
+    continueCountingFrom: [
+      withPropertyFromObject('album', V('hasTrackNumbers')),
+      exitWithoutDependency('#album.hasTrackNumbers', V(null), V('falsy')),
+
+      {
+        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,
+      },
+    ],
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Section': {property: 'name'},
+      'Name Detail For Tracks': {property: 'nameDetailForTracks'},
+
+      'Directory Suffix': {property: 'directorySuffixForTracks'},
+      'Suffix Track Directories': {property: 'suffixTrackDirectoriesByDefault'},
+
+      'Color': {property: 'color'},
+      'Has Track Numbers': {property: 'hasTrackNumbers'},
+      'Start Counting From': {property: 'startCountingFrom'},
+
+      'Date Originally Released': {
+        property: 'dateOriginallyReleased',
+        transform: parseDate,
+      },
+
+      'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'},
+      'Hide Duration': {property: 'hideDuration'},
+
+      'Excluding Track URLs': {
+        property: 'excludingTrackURLs',
+        transform: parseExcludingURLs,
+      },
+
+      'Description': {property: 'description'},
+    },
+  };
+
+  [inspect.custom](depth) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (depth >= 0) showAlbum: {
+      let album = null;
+      try {
+        album = this.album;
+      } catch {
+        break showAlbum;
+      }
+
+      let first = null;
+      try {
+        first = this.tracks.at(0).trackNumber;
+      } catch {}
+
+      let last = null;
+      try {
+        last = this.tracks.at(-1).trackNumber;
+      } catch {}
+
+      const albumName = album.name;
+      const albumIndex = album.trackSections.indexOf(this);
+
+      const num =
+        (albumIndex === -1
+          ? 'indeterminate position'
+          : `#${albumIndex + 1}`);
+
+      const range =
+        (albumIndex >= 0 && first !== null && last !== null
+          ? `: ${first}-${last}`
+          : '');
+
+      parts.push(` (${colors.yellow(num + range)} in ${colors.green(`"${albumName}"`)})`);
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/album/index.js b/src/data/things/album/index.js
new file mode 100644
index 00000000..a19144d9
--- /dev/null
+++ b/src/data/things/album/index.js
@@ -0,0 +1,5 @@
+export * from './Album.js';
+export * from './TrackSection.js';
+
+export * from './AsideTrackSection.js';
+export * from './CloseAsideTrackSection.js';