« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/content/dependencies/generateMusicVideo.js77
-rw-r--r--src/content/dependencies/generateTrackArtworkColumn.js6
-rw-r--r--src/data/checks.js5
-rw-r--r--src/data/things/artist.js8
-rw-r--r--src/data/things/index.js2
-rw-r--r--src/data/things/music-video.js131
-rw-r--r--src/data/things/track.js25
-rw-r--r--src/data/yaml.js10
-rw-r--r--src/gen-thumbs.js4
-rw-r--r--src/static/css/site.css43
-rw-r--r--src/strings-default.yaml15
11 files changed, 320 insertions, 6 deletions
diff --git a/src/content/dependencies/generateMusicVideo.js b/src/content/dependencies/generateMusicVideo.js
new file mode 100644
index 00000000..a61cd5b7
--- /dev/null
+++ b/src/content/dependencies/generateMusicVideo.js
@@ -0,0 +1,77 @@
+export default {
+  relations: (relation, musicVideo) => ({
+    image:
+      relation('image', {
+        path: musicVideo.path,
+        artTags: [],
+        dimensions: musicVideo.coverArtDimensions,
+      }),
+
+    artistCredit:
+      relation('generateArtistCredit', musicVideo.artistContribs, []),
+
+    contributorCredit:
+      relation('generateArtistCredit', musicVideo.contributorContribs, []),
+  }),
+
+  data: (musicVideo) => ({
+    label:
+      musicVideo.label,
+
+    url:
+      musicVideo.url,
+  }),
+
+  generate: (data, relations, {language, html}) =>
+    language.encapsulate('misc.musicVideo', capsule =>
+      html.tag('div', {class: 'music-video'}, [
+        html.tag('p', {class: 'music-video-label'},
+          language.encapsulate(capsule, 'label', workingCapsule => {
+            const workingOptions = {};
+
+            if (data.label) {
+              workingCapsule += '.customLabel';
+              workingOptions.label = data.label;
+            }
+
+            return language.$(workingCapsule, workingOptions);
+          })),
+
+        relations.image.slots({
+          link: data.url,
+        }),
+
+        html.tag('p', {class: 'music-video-credits'},
+          {[html.joinChildren]: html.tag('br')},
+
+          [
+            language.encapsulate(capsule, 'by', workingCapsule => {
+              const additionalStringOptions = {};
+
+              if (data.label) {
+                workingCapsule += '.customLabel';
+                additionalStringOptions.label = data.label;
+              }
+
+              return relations.artistCredit.slots({
+                normalStringKey: workingCapsule,
+                additionalStringOptions,
+
+                showAnnotation: true,
+                showChronology: true,
+
+                chronologyKind: 'musicVideo',
+              });
+            }),
+
+            relations.contributorCredit.slots({
+              normalStringKey: language.encapsulate(capsule, 'contributors'),
+
+              showAnnotation: true,
+              showChronology: true,
+
+              chronologyKind: 'musicVideoContribution',
+            }),
+          ]),
+      ])),
+};
diff --git a/src/content/dependencies/generateTrackArtworkColumn.js b/src/content/dependencies/generateTrackArtworkColumn.js
index 234586e0..dde37376 100644
--- a/src/content/dependencies/generateTrackArtworkColumn.js
+++ b/src/content/dependencies/generateTrackArtworkColumn.js
@@ -10,6 +10,10 @@ export default {
         ? track.trackArtworks.map(artwork =>
             relation('generateCoverArtwork', artwork))
         : []),
+
+    trackMusicVideos:
+      track.musicVideos.map(musicVideo =>
+        relation('generateMusicVideo', musicVideo)),
   }),
 
   generate: (relations, {html}) =>
@@ -26,5 +30,7 @@ export default {
           showArtTagDetails: true,
           showReferenceDetails: true,
         })),
+
+      relations.trackMusicVideos,
     ]),
 };
diff --git a/src/data/checks.js b/src/data/checks.js
index e99a40de..ebf6aad4 100644
--- a/src/data/checks.js
+++ b/src/data/checks.js
@@ -285,6 +285,11 @@ export function filterReferenceErrors(wikiData, {
       featuredTracks: 'track',
     }],
 
+    ['musicVideoData', {
+      artistContribs: '_contrib',
+      contributorContribs: '_contrib',
+    }],
+
     ['seriesData', {
       albums: 'album',
     }],
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 01eb2172..439386f8 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -211,6 +211,14 @@ export class Artist extends Thing {
       },
     ],
 
+    musicVideoArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('musicVideoArtistContributionsBy'),
+    }),
+
+    musicVideoContributorContributions: reverseReferenceList({
+      reverse: soupyReverse.input('musicVideoContributorContributionsBy'),
+    }),
+
     totalDuration: [
       withPropertyFromList('musicContributions', V('thing')),
       withPropertyFromList('#musicContributions.thing', V('isMainRelease')),
diff --git a/src/data/things/index.js b/src/data/things/index.js
index 09765fd2..35cd8cf2 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -21,6 +21,7 @@ import * as flashClasses from './flash.js';
 import * as groupClasses from './group.js';
 import * as homepageLayoutClasses from './homepage-layout.js';
 import * as languageClasses from './language.js';
+import * as musicVideoClasses from './music-video.js';
 import * as newsEntryClasses from './news-entry.js';
 import * as sortingRuleClasses from './sorting-rule.js';
 import * as staticPageClasses from './static-page.js';
@@ -40,6 +41,7 @@ const allClassLists = {
   'group.js': groupClasses,
   'homepage-layout.js': homepageLayoutClasses,
   'language.js': languageClasses,
+  'music-video.js': musicVideoClasses,
   'news-entry.js': newsEntryClasses,
   'sorting-rule.js': sortingRuleClasses,
   'static-page.js': staticPageClasses,
diff --git a/src/data/things/music-video.js b/src/data/things/music-video.js
new file mode 100644
index 00000000..267349e8
--- /dev/null
+++ b/src/data/things/music-video.js
@@ -0,0 +1,131 @@
+import {inspect} from 'node:util';
+
+import {colors} from '#cli';
+import {input, V} from '#composite';
+import find from '#find';
+import Thing from '#thing';
+import {isDate, isStringNonEmpty, isURL} from '#validators';
+import {parseContributors} from '#yaml';
+
+import {exposeConstant, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+import {constituteFrom} from '#composite/wiki-data';
+
+import {
+  contributionList,
+  dimensions,
+  directory,
+  fileExtension,
+  soupyFind,
+  soupyReverse,
+  thing,
+  urls,
+} from '#composite/wiki-properties';
+
+export class MusicVideo extends Thing {
+  static [Thing.referenceType] = 'music-video';
+  static [Thing.wikiData] = 'musicVideoData';
+
+  static [Thing.getPropertyDescriptors] = ({ArtTag}) => ({
+    // Update & expose
+
+    thing: thing(),
+
+    label: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+      expose: {transform: value => value ?? 'Music video'},
+    },
+
+    unqualifiedDirectory: directory({name: 'label'}),
+
+    date: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDate),
+      }),
+
+      constituteFrom('thing', V('date')),
+    ],
+
+    url: {
+      flags: {update: true, expose: true},
+      update: {validate: isURL},
+    },
+
+    coverArtFileExtension: fileExtension(V('jpg')),
+    coverArtDimensions: dimensions(),
+
+    artistContribs: contributionList({
+      artistProperty: input.value('musicVideoArtistContributions'),
+    }),
+
+    contributorContribs: contributionList({
+      artistProperty: input.value('musicVideoContributorContributions'),
+    }),
+
+    // Update only
+
+    find: soupyFind(),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Label': {property: 'label'},
+      'Directory': {property: 'unqualifiedDirectory'},
+      'Date': {property: 'date'},
+      'URL': {property: 'url'},
+
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+      'Cover Art Dimensions': {property: 'coverArtDimensions'},
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      'Contributors': {
+        property: 'contributorContribs',
+        transform: parseContributors,
+      },
+    },
+  };
+
+  static [Thing.reverseSpecs] = {
+    musicVideoArtistContributionsBy:
+      soupyReverse.contributionsBy('musicVideoData', 'artistContribs'),
+
+    musicVideoContributorContributionsBy:
+      soupyReverse.contributionsBy('musicVideoData', 'contributorContribs'),
+  };
+
+  get path() {
+    if (!this.thing) return null;
+    if (!this.thing.getOwnMusicVideoCoverPath) return null;
+
+    return this.thing.getOwnMusicVideoCoverPath(this);
+  }
+
+  [inspect.custom](depth, options, inspect) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (this.thing) {
+      if (depth >= 0) {
+        const newOptions = {
+          ...options,
+          depth:
+            (options.depth === null
+              ? null
+              : options.depth - 1),
+        };
+
+        parts.push(` for ${inspect(this.thing, newOptions)}`);
+      } else {
+        parts.push(` for ${colors.blue(Thing.getReference(this.thing))}`);
+      }
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 3c4b5409..8652fbdf 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -31,6 +31,7 @@ import {
   parseDimensions,
   parseDuration,
   parseLyrics,
+  parseMusicVideos,
 } from '#yaml';
 
 import {
@@ -113,6 +114,7 @@ export class Track extends Thing {
     CommentaryEntry,
     CreditingSourcesEntry,
     LyricsEntry,
+    MusicVideo,
     ReferencingSourcesEntry,
     TrackSection,
     WikiInfo,
@@ -488,6 +490,10 @@ export class Track extends Thing {
       }),
     ],
 
+    // > Update & expose - Music videos
+
+    musicVideos: thingList(V(MusicVideo)),
+
     // > Update & expose - Additional files
 
     additionalFiles: thingList(V(AdditionalFile)),
@@ -993,6 +999,13 @@ export class Track extends Thing {
       'Referenced Tracks': {property: 'referencedTracks'},
       'Sampled Tracks': {property: 'sampledTracks'},
 
+      // Music videos
+
+      'Music Videos': {
+        property: 'musicVideos',
+        transform: parseMusicVideos,
+      },
+
       // Additional files
 
       'Additional Files': {
@@ -1216,6 +1229,18 @@ export class Track extends Thing {
     ];
   }
 
+  getOwnMusicVideoCoverPath(musicVideo) {
+    if (!this.album) return null;
+    if (!musicVideo.unqualifiedDirectory) return null;
+
+    return [
+      'media.trackCover',
+      this.album.directory,
+      this.directory + '-' + musicVideo.unqualifiedDirectory,
+      musicVideo.coverArtFileExtension,
+    ];
+  }
+
   countOwnContributionInContributionTotals(_contrib) {
     if (!this.countInArtistTotals) {
       return false;
diff --git a/src/data/yaml.js b/src/data/yaml.js
index fbb4e5d6..908d42c6 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -730,6 +730,14 @@ export function parseAdditionalNames(entries, {subdoc, AdditionalName}) {
   });
 }
 
+export function parseMusicVideos(entries, {subdoc, MusicVideo}) {
+  return parseArrayEntries(entries, item => {
+    if (typeof item !== 'object') return item;
+
+    return subdoc(MusicVideo, item, {bindInto: 'thing'});
+  });
+}
+
 export function parseSerieses(entries, {subdoc, Series}) {
   return parseArrayEntries(entries, item => {
     if (typeof item !== 'object') return item;
@@ -1798,6 +1806,8 @@ export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) {
 
     ['lyricsData', [/* find */]],
 
+    ['musicVideoData', [/* find */]],
+
     ['referencingSourceData', [/* find */]],
 
     ['seriesData', [/* find */]],
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index 09f50881..a58e96d5 100644
--- a/src/gen-thumbs.js
+++ b/src/gen-thumbs.js
@@ -1234,6 +1234,10 @@ export function getExpectedImagePaths(mediaPath, {urls, wikiData}) {
         .map(part =>
           fromRoot.to('media.albumWallpaperPart', album.directory, part.asset))),
 
+    wikiData.musicVideoData
+      .filter(musicVideo => musicVideo.path)
+      .map(musicVideo => fromRoot.to(...musicVideo.path)),
+
     wikiData.wikiInfo.wikiWallpaperParts
       .filter(part => part.asset)
       .map(part =>
diff --git a/src/static/css/site.css b/src/static/css/site.css
index 1e3a781a..9a49a5d9 100644
--- a/src/static/css/site.css
+++ b/src/static/css/site.css
@@ -1718,7 +1718,8 @@ hr.cute,
   border-style: none none dotted none;
 }
 
-.cover-artwork {
+.cover-artwork,
+.music-video {
   font-size: 0.8em;
   border: 2px solid var(--primary-color);
 
@@ -1745,7 +1746,8 @@ hr.cute,
   overflow: hidden;
 }
 
-#artwork-column .cover-artwork {
+#artwork-column .cover-artwork,
+#artwork-column .music-video {
   --normal-shadow: 0 0 12px 12px #00000080;
 
   box-shadow:
@@ -1770,16 +1772,17 @@ hr.cute,
   margin-right: 17.5px;
 }
 
-.cover-artwork:where(#artwork-column .cover-artwork:not(:first-child)) {
+#artwork-column *:where(.cover-artwork, .music-video):where(:not(:first-child)) {
   margin-top: 20px;
 }
 
-#artwork-column .cover-artwork:last-child:not(:first-child) {
+#artwork-column *:is(.cover-artwork, .music-video):last-child:not(:first-child) {
   margin-bottom: 25px;
 }
 
-.cover-artwork .image-container {
-  /* Border is handled on the .cover-artwork. */
+.cover-artwork .image-container,
+.music-video .image-container {
+  /* Border is handled on the .cover-artwork or .music-video. */
   border: none;
   border-radius: 0 !important;
 }
@@ -1884,6 +1887,34 @@ p.image-details.origin-details .filename-line {
   margin-top: 0 !important;
 }
 
+.music-video {
+  border-radius: 4px;
+  padding: 0 4px;
+}
+
+.music-video .music-video-label {
+  margin: 6px 0 3px;
+  text-align: center;
+}
+
+.music-video .music-video-credits {
+  margin: 5px 5px 6px;
+}
+
+.music-video .image-container {
+  background: transparent;
+  border-style: dashed none;
+  border-width: 2px;
+  border-color: var(--dim-color);
+}
+
+.music-video .image {
+  display: block;
+  aspect-ratio: 16 / 9;
+  width: 100%;
+  height: 100%;
+}
+
 .album-art-info {
   font-size: 0.8em;
   border: 2px solid var(--deep-color);
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index b79dd67f..dc6bffdd 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -605,6 +605,8 @@ misc:
         bannerArt: "banner art"
         coverArt: "cover art"
         flash: "flash"
+        musicVideo: "music video"
+        musicVideoContribution: "video contribution"
         release: "release"
         track: "track"
         trackArt: "track art"
@@ -984,6 +986,19 @@ misc:
 
     sameTagsAsMainArtwork: "Same tags as main artwork"
 
+  # musicVideo:
+  #   Strings for music videos, which are presented in a very similar
+  #   fashion as cover artworks.
+
+  musicVideo:
+    label: "Music video!"
+    label.customLabel: "{LABEL}!"
+
+    by: "Music video by {ARTISTS}"
+    by.customLabel: "{LABEL} by {ARTISTS}"
+
+    contributors: "Contributors: {ARTISTS}"
+
   # coverGrid:
   #   Generic strings for various sorts of gallery grids, displayed
   #   on the homepage, album galleries, artist artwork galleries, and