« get me outta code hell

data, yaml: inherit music-related properties from original release - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-08-15 22:05:36 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-08-15 22:05:36 -0300
commit7e7117e2e1c3d72393289f63695d4f86d358e7ed (patch)
tree68d6e3c5b16328f282ce7f6b4d4536a36aa2cd70
parentd908377fa3d7a90df344744a9d2429c4a4095d01 (diff)
data, yaml: inherit music-related properties from original release
When a track has 'Originally Released As', these fields are now
automatically inherited:

* Artists
* Contributors
* Referenced Tracks
* Sampled Tracks

Including any of these fields alongside 'Originally Released As'
is an error.

Corresponding properties are valid, but ignored.

This uses a new "compositional" style to define how each of these
properties inherits while retaining the original behavior for
tracks that aren't re-releases, and avoids hard-coding much of
anything!
-rw-r--r--src/data/things/track.js100
-rw-r--r--src/data/yaml.js79
2 files changed, 150 insertions, 29 deletions
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 98a9fdd1..36e3adbe 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -245,35 +245,43 @@ export class Track extends Thing {
       },
     },
 
-    artistContribs: Thing.common.dynamicInheritContribs(
-      null,
-      'artistContribsByRef',
-      'artistContribsByRef',
-      'albumData',
-      Track.findAlbum
-    ),
-
-    contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'),
-
-    coverArtistContribs: Thing.common.dynamicInheritContribs(
-      'hasCoverArt',
-      'coverArtistContribsByRef',
-      'trackCoverArtistContribsByRef',
-      'albumData',
-      Track.findAlbum
-    ),
-
-    referencedTracks: Thing.common.dynamicThingsFromReferenceList(
-      'referencedTracksByRef',
-      'trackData',
-      find.track
-    ),
-
-    sampledTracks: Thing.common.dynamicThingsFromReferenceList(
-      'sampledTracksByRef',
-      'trackData',
-      find.track
-    ),
+    artistContribs:
+      Track.inheritFromOriginalRelease('artistContribs', [],
+        Thing.common.dynamicInheritContribs(
+          null,
+          'artistContribsByRef',
+          'artistContribsByRef',
+          'albumData',
+          Track.findAlbum)),
+
+    contributorContribs:
+      Track.inheritFromOriginalRelease('contributorContribs', [],
+        Thing.common.dynamicContribs('contributorContribsByRef')),
+
+    // Cover artists aren't inherited from the original release, since it
+    // typically varies by release and isn't defined by the musical qualities
+    // of the track.
+    coverArtistContribs:
+      Thing.common.dynamicInheritContribs(
+        'hasCoverArt',
+        'coverArtistContribsByRef',
+        'trackCoverArtistContribsByRef',
+        'albumData',
+        Track.findAlbum),
+
+    referencedTracks:
+      Track.inheritFromOriginalRelease('referencedTracks', [],
+        Thing.common.dynamicThingsFromReferenceList(
+          'referencedTracksByRef',
+          'trackData',
+          find.track)),
+
+    sampledTracks:
+      Track.inheritFromOriginalRelease('sampledTracks', [],
+        Thing.common.dynamicThingsFromReferenceList(
+          'sampledTracksByRef',
+          'trackData',
+          find.track)),
 
     // Specifically exclude re-releases from this list - while it's useful to
     // get from a re-release to the tracks it references, re-releases aren't
@@ -373,6 +381,40 @@ export class Track extends Thing {
     return false;
   }
 
+  static inheritFromOriginalRelease(
+    originalProperty,
+    originalMissingValue,
+    ownPropertyDescriptor
+  ) {
+    return {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: [
+          ...ownPropertyDescriptor.expose.dependencies,
+          'originalReleaseTrackByRef',
+          'trackData',
+        ],
+
+        compute(dependencies) {
+          const {
+            originalReleaseTrackByRef,
+            trackData,
+          } = dependencies;
+
+          if (originalReleaseTrackByRef) {
+            if (!trackData) return originalMissingValue;
+            const original = find.track(originalReleaseTrackByRef, trackData, {mode: 'quiet'});
+            if (!original) return originalMissingValue;
+            return original[originalProperty];
+          }
+
+          return ownPropertyDescriptor.expose.compute(dependencies);
+        },
+      },
+    };
+  }
+
   [inspect.custom]() {
     const base = Thing.prototype[inspect.custom].apply(this);
 
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 627f559c..7420ee4a 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -70,6 +70,7 @@ function makeProcessDocument(
     // Each key and value are a field name (not an update() property) and a
     // function which takes the value for that field and returns the value which
     // will be passed on to update().
+    //
     fieldTransformations = {},
 
     // Mapping of Thing.update() source properties to field names.
@@ -78,13 +79,36 @@ function makeProcessDocument(
     // shorthand convenience because properties are generally typical
     // camel-cased JS properties, while fields may contain whitespace and be
     // more easily represented as quoted strings.
+    //
     propertyFieldMapping,
 
     // Completely ignored fields. These won't throw an unknown field error if
     // they're present in a document, but they won't be used for Thing property
     // generation, either. Useful for stuff that's present in data files but not
     // yet implemented as part of a Thing's data model!
+    //
     ignoredFields = [],
+
+    // List of fields which are invalid when coexisting in a document.
+    // Data objects are generally allowing with regards to what properties go
+    // together, allowing for properties to be set separately from each other
+    // instead of complaining about invalid or unused-data cases. But it's
+    // useful to see these kinds of errors when actually validating YAML files!
+    //
+    // Each item of this array should itself be an object with a descriptive
+    // message and a list of fields. Of those fields, none should ever coexist
+    // with any other. For example:
+    //
+    //   [
+    //     {message: '...', fields: ['A', 'B', 'C']},
+    //     {message: '...', fields: ['C', 'D']},
+    //   ]
+    //
+    // ...means A can't coexist with B or C, B can't coexist with A or C, and
+    // C can't coexist iwth A, B, or D - but it's okay for D to coexist with
+    // A or B.
+    //
+    invalidFieldCombinations = [],
   }
 ) {
   if (!thingClass) {
@@ -132,6 +156,19 @@ function makeProcessDocument(
       throw new makeProcessDocument.UnknownFieldsError(unknownFields);
     }
 
+    const presentFields = Object.keys(document);
+
+    const fieldCombinationErrors = [];
+    for (const {message, fields} of invalidFieldCombinations) {
+      const fieldsPresent = presentFields.filter(field => fields.includes(field));
+      if (fieldsPresent.length > 1) {
+        fieldCombinationErrors.push(new makeProcessDocument.FieldCombinationError(fieldsPresent, message));
+      }
+    }
+    if (!empty(fieldCombinationErrors)) {
+      throw new makeProcessDocument.FieldCombinationsError(fieldCombinationErrors);
+    }
+
     const fieldValues = {};
 
     for (const [field, value] of documentEntries) {
@@ -175,6 +212,26 @@ makeProcessDocument.UnknownFieldsError = class UnknownFieldsError extends Error
   }
 };
 
+makeProcessDocument.FieldCombinationsError = class FieldCombinationsError extends AggregateError {
+  constructor(errors) {
+    super(errors, `Errors in combinations of fields present`);
+  }
+};
+
+makeProcessDocument.FieldCombinationError = class FieldCombinationError extends Error {
+  constructor(fields, message) {
+    const combinePart = `Don't combine ${fields.map(field => color.red(field)).join(', ')}`;
+
+    const messagePart =
+      (message
+        ? `: ${message}`
+        : ``);
+
+    super(combinePart + messagePart);
+    this.fields = fields;
+  }
+}
+
 export const processAlbumDocument = makeProcessDocument(T.Album, {
   fieldTransformations: {
     'Artists': parseContributors,
@@ -285,6 +342,28 @@ export const processTrackDocument = makeProcessDocument(T.Track, {
     coverArtistContribsByRef: 'Cover Artists',
     artTagsByRef: 'Art Tags',
   },
+
+  invalidFieldCombinations: [
+    {message: `Re-releases inherit references from the original`, fields: [
+      'Originally Released As',
+      'Referenced Tracks',
+    ]},
+
+    {message: `Re-releases inherit samples from the original`, fields: [
+      'Originally Released As',
+      'Sampled Tracks',
+    ]},
+
+    {message: `Re-releases inherit artists from the original`, fields: [
+      'Originally Released As',
+      'Artists',
+    ]},
+
+    {message: `Re-releases inherit contributors from the original`, fields: [
+      'Originally Released As',
+      'Contributors',
+    ]},
+  ],
 });
 
 export const processArtistDocument = makeProcessDocument(T.Artist, {