« get me outta code hell

data: misc. additions, fixes & refactoring - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/data
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-08-27 16:15:34 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-09-05 21:02:53 -0300
commit083a4b8c3a0e545a2d8195255d57c5b7e0c49028 (patch)
tree5d79ff12deda4584776c102f1c91e3546eb2e3bd /src/data
parent618f49e0ddcea245a4e0972efe5450419b27c639 (diff)
data: misc. additions, fixes & refactoring
Thing.composite.from:
* Transparently support expose.transform steps inside nested
  compositions, w/ various Thing.composite.from clean-up
* Support continuation.raise() without provided dependencies

* add Thing.composite.exposeConstant
* add Thing.composite.withResultOfAvailabilityCheck
  * supports {mode: 'null' | 'empty' | 'falsy'}
  * works with dependency or update value
* add Thing.composite.earlyExitWithoutDependency
* refactor Thing.composite.exposeDependencyOrContinue
* refactor Thing.composite.exposeUpdateValueOrContinue

* add Track.withHasUniqueCoverArt
* refactor Track.coverArtFileExtension
* refactor Track.hasUniqueCoverArt
Diffstat (limited to 'src/data')
-rw-r--r--src/data/things/thing.js433
-rw-r--r--src/data/things/track.js137
2 files changed, 331 insertions, 239 deletions
diff --git a/src/data/things/thing.js b/src/data/things/thing.js
index f88e8726..892a3a4b 100644
--- a/src/data/things/thing.js
+++ b/src/data/things/thing.js
@@ -780,6 +780,16 @@ export default class Thing extends CacheableObject {
               break expose;
             }
 
+            if (
+              step.expose.transform &&
+              !step.expose.compute &&
+              !base.flags.update &&
+              !base.flags.compose
+            ) {
+              push(new TypeError(`Steps which only transform can't be composed with a non-updating base`));
+              break expose;
+            }
+
             if (step.expose.dependencies) {
               for (const dependency of step.expose.dependencies) {
                 if (typeof dependency === 'string' && dependency.startsWith('#')) continue;
@@ -794,26 +804,7 @@ export default class Thing extends CacheableObject {
               }
             }
 
-            let fn, type;
-            if (base.flags.update) {
-              if (step.expose.transform) {
-                type = 'transform';
-                fn = step.expose.transform;
-              } else {
-                type = 'compute';
-                fn = step.expose.compute;
-              }
-            } else {
-              if (step.expose.transform && !step.expose.compute) {
-                push(new TypeError(`Steps which only transform can't be composed with a non-updating base`));
-                break expose;
-              }
-
-              type = 'compute';
-              fn = step.expose.compute;
-            }
-
-            exposeSteps.push(step.expose);
+            exposeSteps.push(step);
           }
         });
       }
@@ -845,38 +836,38 @@ export default class Thing extends CacheableObject {
 
         function _filterDependencies(dependencies, step) {
           const filteredDependencies =
-            (step.dependencies
-              ? filterProperties(dependencies, step.dependencies)
+            (step.expose.dependencies
+              ? filterProperties(dependencies, step.expose.dependencies)
               : {});
 
-          if (step.mapDependencies) {
-            for (const [to, from] of Object.entries(step.mapDependencies)) {
+          if (step.expose.mapDependencies) {
+            for (const [to, from] of Object.entries(step.expose.mapDependencies)) {
               filteredDependencies[to] = dependencies[from] ?? null;
             }
           }
 
-          if (step.options) {
-            filteredDependencies['#options'] = step.options;
+          if (step.expose.options) {
+            filteredDependencies['#options'] = step.expose.options;
           }
 
           return filteredDependencies;
         }
 
         function _assignDependencies(continuationAssignment, step) {
-          if (!step.mapContinuation) {
+          if (!step.expose.mapContinuation) {
             return continuationAssignment;
           }
 
           const assignDependencies = {};
 
-          for (const [from, to] of Object.entries(step.mapContinuation)) {
+          for (const [from, to] of Object.entries(step.expose.mapContinuation)) {
             assignDependencies[to] = continuationAssignment[from] ?? null;
           }
 
           return assignDependencies;
         }
 
-        function _prepareContinuation(transform, step) {
+        function _prepareContinuation(transform) {
           const continuationStorage = {
             returnedWith: null,
             providedDependencies: null,
@@ -930,27 +921,25 @@ export default class Thing extends CacheableObject {
 
           debug(() => color.bright(`begin composition (annotation: ${annotation})`));
 
-          for (let i = 0; i < exposeSteps.length; i++) {
+          stepLoop: for (let i = 0; i < exposeSteps.length; i++) {
             const step = exposeSteps[i];
             debug(() => [`step #${i+1}:`, step]);
 
             const transform =
               valueSoFar !== noTransformSymbol &&
-              step.transform;
+              step.expose.transform;
 
             const filteredDependencies = _filterDependencies(dependencies, step);
-            const {continuation, continuationStorage} = _prepareContinuation(transform, step);
+            const {continuation, continuationStorage} = _prepareContinuation(transform);
 
-            if (transform) {
-              debug(() => `step #${i+1} - transform with dependencies: ${inspect(filteredDependencies, {depth: 0})}`);
-            } else {
-              debug(() => `step #${i+1} - compute with dependencies: ${inspect(filteredDependencies, {depth: 0})}`);
-            }
+            debug(() =>
+              `step #${i+1} - ${transform ? 'transform' : 'compute'} ` +
+              `with dependencies: ${inspect(filteredDependencies, {depth: 0})}`);
 
             const result =
               (transform
-                ? step.transform(valueSoFar, filteredDependencies, continuation)
-                : step.compute(filteredDependencies, continuation));
+                ? step.expose.transform(valueSoFar, filteredDependencies, continuation)
+                : step.expose.compute(filteredDependencies, continuation));
 
             if (result !== continuationSymbol) {
               if (base.flags.compose) {
@@ -964,39 +953,34 @@ export default class Thing extends CacheableObject {
               return result;
             }
 
-            if (continuationStorage.returnedWith === 'exit') {
-              debug(() => `step #${i+1} - result: early-exit (explicit)`);
-              debug(() => `early-exit: ${inspect(continuationStorage.providedValue, {compact: true})}`);
-              debug(() => color.bright(`end composition (annotation: ${annotation})`));
-
-              return continuationStorage.providedValue;
-            }
-
-            if (continuationStorage.returnedWith === 'raise') {
-              if (transform) {
-                valueSoFar = continuationStorage.providedValue;
-              }
-
-              exportDependencies = _assignDependencies(continuationStorage.providedDependencies, step);
-
-              debug(() => `step #${i+1} - result: raise`);
-
-              break;
-            }
-
-            if (continuationStorage.returnedWith === 'continuation') {
-              if (transform) {
-                valueSoFar = continuationStorage.providedValue;
-              }
-
-              debug(() => `step #${i+1} - result: continuation`);
+            switch (continuationStorage.returnedWith) {
+              case 'exit':
+                debug(() => `step #${i+1} - result: early-exit (explicit)`);
+                debug(() => `early-exit: ${inspect(continuationStorage.providedValue, {compact: true})}`);
+                debug(() => color.bright(`end composition (annotation: ${annotation})`));
+                return continuationStorage.providedValue;
+
+              case 'raise':
+                debug(() => `step #${i+1} - result: raise`);
+                exportDependencies = _assignDependencies(continuationStorage.providedDependencies, step) ?? {};
+                if (transform) valueSoFar = continuationStorage.providedValue;
+                break stepLoop;
+
+              case 'continuation':
+                if (transform) {
+                  valueSoFar = continuationStorage.providedValue;
+                }
 
-              if (continuationStorage.providedDependencies) {
-                const assignDependencies = _assignDependencies(continuationStorage.providedDependencies, step);
-                Object.assign(dependencies, assignDependencies);
+                if (continuationStorage.providedDependencies) {
+                  const assignDependencies = _assignDependencies(continuationStorage.providedDependencies, step);
+                  Object.assign(dependencies, assignDependencies);
+                  debug(() => `step #${i+1} - result: continuation`);
+                  debug(() => [`assign dependencies:`, assignDependencies]);
+                } else {
+                  debug(() => `step #${i+1} - result: continuation (no provided dependencies)`);
+                }
 
-                debug(() => [`assign dependencies:`, assignDependencies]);
-              }
+                break;
             }
           }
 
@@ -1008,53 +992,50 @@ export default class Thing extends CacheableObject {
 
           debug(() => `completed all steps, reached base`);
 
-          const filteredDependencies = _filterDependencies(dependencies, base.expose);
+          const filteredDependencies = _filterDependencies(dependencies, base);
 
-          // Note: base.flags.compose is not compatible with base.flags.update.
-          if (base.expose.transform) {
-            debug(() => `base - transform with dependencies: ${inspect(filteredDependencies, {depth: 0})}`);
+          const transform =
+            valueSoFar !== noTransformSymbol &&
+            base.expose.transform;
 
-            const result = base.expose.transform(valueSoFar, filteredDependencies);
+          debug(() =>
+            `base - ${transform ? 'transform' : 'compute'} ` +
+            `with dependencies: ${inspect(filteredDependencies, {depth: 0})}`);
 
-            debug(() => `base - non-compose (final) result: ${inspect(result, {compact: true})}`);
-
-            return result;
-          } else if (base.flags.compose) {
-            const {continuation, continuationStorage} = _prepareContinuation(false, base.expose);
-
-            debug(() => `base - compute with dependencies: ${inspect(filteredDependencies, {depth: 0})}`);
+          if (base.flags.compose) {
+            const {continuation, continuationStorage} = _prepareContinuation(transform);
 
-            const result = base.expose.compute(filteredDependencies, continuation);
+            const result =
+              (transform
+                ? base.expose.transform(valueSoFar, filteredDependencies, continuation)
+                : base.expose.compute(filteredDependencies, continuation));
 
             if (result !== continuationSymbol) {
               throw new TypeError(`Use continuation.exit() or continuation.raise() in {compose: true} composition`);
             }
 
-            if (continuationStorage.returnedWith === 'continuation') {
-              throw new TypeError(`Use continuation.raise() in base of {compose: true} composition`);
-            }
-
-            if (continuationStorage.returnedWith === 'exit') {
-              debug(() => `base - result: early-exit (explicit)`);
-              debug(() => `early-exit: ${inspect(continuationStorage.providedValue, {compact: true})}`);
-              debug(() => color.bright(`end composition (annotation: ${annotation})`));
-
-              return continuationStorage.providedValue;
-            }
-
-            if (continuationStorage.returnedWith === 'raise') {
-              exportDependencies = _assignDependencies(continuationStorage.providedDependencies, base.expose);
-
-              debug(() => `base - result: raise`);
-              debug(() => `raise dependencies: ${inspect(exportDependencies, {compact: true})}`);
-              debug(() => color.bright(`end composition (annotation: ${annotation})`));
-
-              return continuationIfApplicable(exportDependencies);
+            switch (continuationStorage.returnedWith) {
+              case 'continuation':
+                throw new TypeError(`Use continuation.raise() in base of {compose: true} composition`);
+
+              case 'exit':
+                debug(() => `base - result: early-exit (explicit)`);
+                debug(() => `early-exit: ${inspect(continuationStorage.providedValue, {compact: true})}`);
+                debug(() => color.bright(`end composition (annotation: ${annotation})`));
+                return continuationStorage.providedValue;
+
+              case 'raise':
+                exportDependencies = _assignDependencies(continuationStorage.providedDependencies, base);
+                debug(() => `base - result: raise`);
+                debug(() => `raise dependencies: ${inspect(exportDependencies, {compact: true})}`);
+                debug(() => color.bright(`end composition (annotation: ${annotation})`));
+                return continuationIfApplicable(exportDependencies);
             }
           } else {
-            debug(() => `base - compute with dependencies: ${inspect(filteredDependencies, {depth: 0})}`);
-
-            const result = base.expose.compute(filteredDependencies);
+            const result =
+              (transform
+                ? base.expose.transform(valueSoFar, filteredDependencies)
+                : base.expose.compute(filteredDependencies));
 
             debug(() => `base - non-compose (final) result: ${inspect(result, {compact: true})}`);
             debug(() => color.bright(`end composition (annotation: ${annotation})`));
@@ -1063,14 +1044,23 @@ export default class Thing extends CacheableObject {
           }
         }
 
-        if (base.flags.update) {
-          expose.transform =
-            (value, initialDependencies, continuationIfApplicable) =>
-              _computeOrTransform(value, initialDependencies, continuationIfApplicable);
+        const transformFn =
+          (value, initialDependencies, continuationIfApplicable) =>
+            _computeOrTransform(value, initialDependencies, continuationIfApplicable);
+
+        const computeFn =
+          (initialDependencies, continuationIfApplicable) =>
+            _computeOrTransform(noTransformSymbol, initialDependencies, continuationIfApplicable);
+
+        if (base.flags.compose) {
+          if (exposeSteps.some(step => step.expose.transform)) {
+            expose.transform = transformFn;
+          }
+          expose.compute = computeFn;
+        } else if (base.flags.update) {
+          expose.transform = transformFn;
         } else {
-          expose.compute =
-            (initialDependencies, continuationIfApplicable) =>
-              _computeOrTransform(noTransformSymbol, initialDependencies, continuationIfApplicable);
+          expose.compute = computeFn;
         }
       }
 
@@ -1146,68 +1136,177 @@ export default class Thing extends CacheableObject {
           : null),
     }),
 
-    // Exposes a dependency as it is, or continues if it's unavailable.
-    // By default, "unavailable" means dependency === null; provide
-    // {mode: 'empty'} to check with empty() instead, continuing for
-    // empty arrays also.
-    exposeDependencyOrContinue(dependency, {mode = 'null'} = {}) {
-      if (mode !== 'null' && mode !== 'empty') {
-        throw new TypeError(`Expected mode to be null or empty`);
+    // Exposes a constant value exactly as it is; like exposeDependency, this
+    // is typically the base of a composition serving as a particular property
+    // descriptor. It generally follows steps which will conditionally early
+    // exit with some other value, with the exposeConstant base serving as the
+    // fallback default value. Like exposeDependency, set {update} to true or
+    // an object to indicate that the property as a whole updates.
+    exposeConstant: (value, {update = false} = {}) => ({
+      annotation: `Thing.composite.exposeConstant`,
+      flags: {expose: true, update: !!update},
+
+      expose: {
+        options: {value},
+        compute: ({'#options': {value}}) => value,
+      },
+
+      update:
+        (typeof update === 'object'
+          ? update
+          : null),
+    }),
+
+    // Checks the availability of a dependency or the update value and provides
+    // the result to later steps under '#availability' (by default). This is
+    // mainly intended for use by the more specific utilities, which you should
+    // consider using instead. Customize {mode} to select one of these modes,
+    // or leave unset and default to 'null':
+    //
+    // * 'null':  Check that the value isn't null.
+    // * 'empty': Check that the value is neither null nor an empty array.
+    // * 'falsy': Check that the value isn't false when treated as a boolean
+    //            (nor an empty array). Keep in mind this will also be false
+    //            for values like zero and the empty string!
+    //
+    withResultOfAvailabilityCheck({
+      fromUpdateValue,
+      fromDependency,
+      mode = 'null',
+      to = '#availability',
+    }) {
+      if (!['null', 'empty', 'falsy'].includes(mode)) {
+        throw new TypeError(`Expected mode to be null, empty, or falsy`);
       }
 
-      return {
-        annotation: `Thing.composite.exposeDependencyOrContinue`,
-        flags: {expose: true, compose: true},
-        expose: {
-          options: {mode},
-          mapDependencies: {dependency},
-
-          compute({dependency, '#options': {mode}}, continuation) {
-            const shouldContinue =
-              (mode === 'empty'
-                ? empty(dependency)
-                : dependency === null);
-
-            if (shouldContinue) {
-              return continuation();
-            } else {
-              return continuation.exit(dependency);
-            }
-          },
-        },
+      if (fromUpdateValue && fromDependency) {
+        throw new TypeError(`Don't provide both fromUpdateValue and fromDependency`);
+      }
+
+      if (!fromUpdateValue && !fromDependency) {
+        throw new TypeError(`Missing dependency name (or fromUpdateValue)`);
+      }
+
+      const checkAvailability = (value, mode) => {
+        switch (mode) {
+          case 'null': return value !== null;
+          case 'empty': return !empty(value);
+          case 'falsy': return !empty(value) && !!value;
+          default: return false;
+        }
       };
+
+      if (fromDependency) {
+        return {
+          annotation: `Thing.composite.withResultOfCommonComparison.fromDependency`,
+          flags: {expose: true, compose: true},
+          expose: {
+            mapDependencies: {from: fromDependency},
+            mapContinuation: {to},
+            options: {mode},
+            compute: ({from, '#options': {mode}}, continuation) =>
+              continuation({to: checkAvailability(from, mode)}),
+          },
+        };
+      } else {
+        return {
+          annotation: `Thing.composite.withResultOfCommonComparison.fromUpdateValue`,
+          flags: {expose: true, compose: true},
+          expose: {
+            mapContinuation: {to},
+            options: {mode},
+            transform: (value, {'#options': {mode}}, continuation) =>
+              continuation(value, {to: checkAvailability(value, mode)}),
+          },
+        };
+      }
     },
 
+    // Exposes a dependency as it is, or continues if it's unavailable.
+    // See withResultOfAvailabilityCheck for {mode} options!
+    exposeDependencyOrContinue: (dependency, {mode = 'null'} = {}) =>
+      Thing.composite.from(`Thing.composite.exposeDependencyOrContinue`, [
+        Thing.composite.withResultOfAvailabilityCheck({
+          fromDependency: dependency,
+          mode,
+        }),
+
+        {
+          flags: {expose: true, compose: true},
+          expose: {
+            dependencies: ['#availability'],
+            compute: ({'#availability': availability}, continuation) =>
+              (availability
+                ? continuation()
+                : continuation.raise()),
+          },
+        },
+
+        {
+          flags: {expose: true, compose: true},
+          expose: {
+            mapDependencies: {dependency},
+            compute: ({dependency}, continuation) =>
+              continuation.exit(dependency),
+          },
+        },
+      ]),
+
     // Exposes the update value of an {update: true} property as it is,
-    // or continues if it's unavailable. By default, "unavailable" means
-    // value === null; provide {mode: 'empty'} to check with empty() instead,
-    // continuing for empty arrays also.
-    exposeUpdateValueOrContinue({mode = 'null'} = {}) {
-      if (mode !== 'null' && mode !== 'empty') {
-        throw new TypeError(`Expected mode to be null or empty`);
-      }
+    // or continues if it's unavailable. See withResultOfAvailabilityCheck
+    // for {mode} options!
+    exposeUpdateValueOrContinue: ({mode = 'null'} = {}) =>
+      Thing.composite.from(`Thing.composite.exposeUpdateValueOrContinue`, [
+        Thing.composite.withResultOfAvailabilityCheck({
+          fromUpdateValue: true,
+          mode,
+        }),
 
-      return {
-        annotation: `Thing.composite.exposeUpdateValueOrContinue`,
-        flags: {expose: true, compose: true},
-        expose: {
-          options: {mode},
-
-          transform(value, {'#options': {mode}}, continuation) {
-            const shouldContinue =
-              (mode === 'empty'
-                ? empty(value)
-                : value === null);
-
-            if (shouldContinue) {
-              return continuation(value);
-            } else {
-              return continuation.exit(value);
-            }
-          }
+        {
+          flags: {expose: true, compose: true},
+          expose: {
+            dependencies: ['#availability'],
+            compute: ({'#availability': availability}, continuation) =>
+              (availability
+                ? continuation()
+                : continuation.raise()),
+          },
         },
-      };
-    },
+
+        {
+          flags: {expose: true, compose: true},
+          expose: {
+            transform: (value, {}, continuation) =>
+              continuation.exit(value),
+          },
+        },
+      ]),
+
+    // Early exits if a dependency isn't available.
+    // See withResultOfAvailabilityCheck for {mode} options!
+    earlyExitWithoutDependency: (dependency, {mode = 'null', value = null} = {}) =>
+      Thing.composite.from(`Thing.composite.earlyExitWithoutDependency`, [
+        Thing.composite.withResultOfAvailabilityCheck({
+          fromDependency: dependency,
+          mode,
+        }),
+
+        {
+          flags: {expose: true, compose: true},
+          expose: {
+            dependencies: ['#availability'],
+            options: {value},
+
+            compute: ({
+              '#availability': availability,
+              '#options': {value},
+            }, continuation) =>
+              (availability
+                ? continuation()
+                : continuation.exit(value)),
+          },
+        },
+      ]),
 
     // -- Compositional steps for processing data --
 
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 228b2af1..dc1f5f2a 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -76,40 +76,24 @@ export class Track extends Thing {
     disableUniqueCoverArt: Thing.common.flag(),
 
     // File extension for track's corresponding media file. This represents the
-    // track's unique cover artwork, if any, and does not inherit the cover's
-    // main artwork. (It does inherit `trackCoverArtFileExtension` if present
-    // on the album.)
+    // track's unique cover artwork, if any, and does not inherit the extension
+    // of the album's main artwork. It does inherit trackCoverArtFileExtension,
+    // if present on the album.
     coverArtFileExtension: Thing.composite.from(`Track.coverArtFileExtension`, [
-      Track.composite.withAlbumProperties({
-        properties: [
-          'trackCoverArtistContribsByRef',
-          'trackCoverArtFileExtension',
-        ],
-      }),
+      // No cover art file extension if the track doesn't have unique artwork
+      // in the first place.
+      Track.composite.withHasUniqueCoverArt(),
+      Thing.composite.earlyExitWithoutDependency('#hasUniqueCoverArt', {mode: 'falsy'}),
 
-      {
-        flags: {update: true, expose: true},
-        update: {validate: isFileExtension},
-        expose: {
-          dependencies: [
-            'coverArtistContribsByRef',
-            'disableUniqueCoverArt',
-            '#album.trackCoverArtistContribsByRef',
-            '#album.trackCoverArtFileExtension',
-          ],
+      // Expose custom coverArtFileExtension update value first.
+      Thing.composite.exposeUpdateValueOrContinue(),
 
-          transform(coverArtFileExtension, {
-            coverArtistContribsByRef,
-            disableUniqueCoverArt,
-            '#album.trackCoverArtistContribsByRef': trackCoverArtistContribsByRef,
-            '#album.trackCoverArtFileExtension': trackCoverArtFileExtension,
-          }) {
-            if (disableUniqueCoverArt) return null;
-            if (empty(coverArtistContribsByRef) && empty(trackCoverArtistContribsByRef)) return null;
-            return coverArtFileExtension ?? trackCoverArtFileExtension ?? 'jpg';
-          },
-        },
-      },
+      // Expose album's trackCoverArtFileExtension if no update value set.
+      Track.composite.withAlbumProperty('trackCoverArtFileExtension'),
+      Thing.composite.exposeDependencyOrContinue('#album.trackCoverArtFileExtension'),
+
+      // Fallback to 'jpg'.
+      Thing.composite.exposeConstant('jpg'),
     ]),
 
     // Date of cover art release. Like coverArtFileExtension, this represents
@@ -204,47 +188,8 @@ export class Track extends Thing {
     // the usual hasCoverArt to emphasize that it does not inherit from the
     // album.)
     hasUniqueCoverArt: Thing.composite.from(`Track.hasUniqueCoverArt`, [
-      {
-        flags: {expose: true, compose: true},
-        expose: {
-          dependencies: ['disableUniqueCoverArt'],
-          compute: ({disableUniqueCoverArt}, continuation) =>
-            (disableUniqueCoverArt
-              ? false
-              : continuation()),
-        },
-      },
-
-      Thing.composite.withResolvedContribs({
-        from: 'coverArtistContribsByRef',
-        to: '#coverArtistContribs',
-      }),
-
-      {
-        flags: {expose: true, compose: true},
-        expose: {
-          dependencies: ['#coverArtistContribs'],
-          compute: ({'#coverArtistContribs': coverArtistContribs}, continuation) =>
-            (empty(coverArtistContribs)
-              ? continuation()
-              : true),
-        },
-      },
-
-      Track.composite.withAlbumProperties({
-        properties: ['trackCoverArtistContribs'],
-      }),
-
-      {
-        flags: {expose: true},
-        expose: {
-          dependencies: ['#album.trackCoverArtistContribs'],
-          compute: ({'#album.trackCoverArtistContribs': trackCoverArtistContribs}) =>
-            (empty(trackCoverArtistContribs)
-              ? false
-              : true),
-        },
-      },
+      Track.composite.withHasUniqueCoverArt(),
+      Thing.composite.exposeDependency('#hasUniqueCoverArt'),
     ]),
 
     originalReleaseTrack: Thing.common.dynamicThingFromSingleReference(
@@ -609,6 +554,54 @@ export class Track extends Thing {
           [outputDependency]: '#originalRelease',
         }),
       ]),
+
+    // The algorithm for checking if a track has unique cover art is used in a
+    // couple places, so it's defined in full as a compositional step.
+    withHasUniqueCoverArt: ({to = '#hasUniqueCoverArt'} = {}) =>
+      Thing.composite.from(`Track.composite.withHasUniqueCoverArt`, [
+        {
+          flags: {expose: true, compose: true},
+          expose: {
+            dependencies: ['disableUniqueCoverArt'],
+            mapContinuation: {to},
+            compute: ({disableUniqueCoverArt}, continuation) =>
+              (disableUniqueCoverArt
+                ? continuation.raise({to: false})
+                : continuation()),
+          },
+        },
+
+        Thing.composite.withResolvedContribs({
+          from: 'coverArtistContribsByRef',
+          to: '#coverArtistContribs',
+        }),
+
+        {
+          flags: {expose: true, compose: true},
+          expose: {
+            dependencies: ['#coverArtistContribs'],
+            mapContinuation: {to},
+            compute: ({'#coverArtistContribs': contribsFromTrack}, continuation) =>
+              (empty(contribsFromTrack)
+                ? continuation()
+                : continuation.raise({to: true})),
+          },
+        },
+
+        Track.composite.withAlbumProperty('trackCoverArtistContribs'),
+
+        {
+          flags: {expose: true, compose: true},
+          expose: {
+            dependencies: ['#album.trackCoverArtistContribs'],
+            mapContinuation: {to},
+            compute: ({'#album.trackCoverArtistContribs': contribsFromAlbum}, continuation) =>
+              (empty(contribsFromAlbum)
+                ? continuation.raise({to: false})
+                : continuation.raise({to: true})),
+          },
+        },
+      ]),
   };
 
   [inspect.custom]() {