From 2b66d66d5d89f2a6d914bef1abb997497657b10d Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 16 Jan 2026 09:14:47 -0400 Subject: data, content, css: basic music video implementation --- src/content/dependencies/generateMusicVideo.js | 77 ++++++++++++ .../dependencies/generateTrackArtworkColumn.js | 6 + src/data/checks.js | 5 + src/data/things/artist.js | 8 ++ src/data/things/index.js | 2 + src/data/things/music-video.js | 131 +++++++++++++++++++++ src/data/things/track.js | 25 ++++ src/data/yaml.js | 10 ++ src/gen-thumbs.js | 4 + src/static/css/site.css | 43 ++++++- src/strings-default.yaml | 15 +++ 11 files changed, 320 insertions(+), 6 deletions(-) create mode 100644 src/content/dependencies/generateMusicVideo.js create mode 100644 src/data/things/music-video.js (limited to 'src') 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 -- cgit 1.3.0-6-gf8a5