« get me outta code hell

data, content: additional file artists - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2026-05-19 21:52:18 -0300
committer(quasar) nebula <qznebula@protonmail.com>2026-05-19 21:52:18 -0300
commite56b564efe888e028e4b01891d9074e8532360d2 (patch)
tree4525dd2a274f8320d94be5cfbf1b1ad8586420d2 /src
parente518aba281c2deca085bcb1245f8da5448f28981 (diff)
data, content: additional file artists preview
Diffstat (limited to 'src')
-rw-r--r--src/common-util/wiki-data.js9
-rw-r--r--src/content/dependencies/generateAdditionalFilesList.js2
-rw-r--r--src/content/dependencies/generateAdditionalFilesListChunk.js34
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js57
-rw-r--r--src/content/dependencies/generateArtistInfoPageAdditionalFilesChunk.js41
-rw-r--r--src/content/dependencies/generateArtistInfoPageAdditionalFilesChunkItem.js115
-rw-r--r--src/content/dependencies/generateArtistInfoPageMidiProjectFilesChunkedList.js68
-rw-r--r--src/content/dependencies/generateArtistInfoPageMiscellaneousAdditionalFilesChunkedList.js68
-rw-r--r--src/content/dependencies/generateArtistInfoPageSheetMusicFilesChunkedList.js69
-rw-r--r--src/content/dependencies/generateListAllAdditionalFilesChunk.js112
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js22
-rw-r--r--src/data/checks.js9
-rw-r--r--src/data/things/AdditionalFile.js56
-rw-r--r--src/data/things/Artist.js12
-rw-r--r--src/data/things/Language.js19
-rw-r--r--src/data/things/Track.js14
-rw-r--r--src/data/things/additional-file/AdditionalFile.js109
-rw-r--r--src/data/things/additional-file/MidiProjectFile.js28
-rw-r--r--src/data/things/additional-file/MiscellaneousAdditionalFile.js28
-rw-r--r--src/data/things/additional-file/SheetMusicFile.js28
-rw-r--r--src/data/things/additional-file/index.js5
-rw-r--r--src/data/things/album/Album.js21
-rw-r--r--src/data/things/index.js2
-rw-r--r--src/data/yaml.js22
-rw-r--r--src/static/css/miscellany.css6
-rw-r--r--src/strings-default.yaml448
-rw-r--r--src/urls-default.yaml1
27 files changed, 1191 insertions, 214 deletions
diff --git a/src/common-util/wiki-data.js b/src/common-util/wiki-data.js
index f47a48ff..a8c9ac8a 100644
--- a/src/common-util/wiki-data.js
+++ b/src/common-util/wiki-data.js
@@ -235,6 +235,15 @@ export function getArtistNumContributions(artist) {
 
       artist.flashContributorContributions
         .filter(keep),
+
+      artist.miscellaneousAdditionalFileArtistContributions
+        .filter(keep),
+
+      artist.sheetMusicFileArtistContributions
+        .filter(keep),
+
+      artist.midiProjectFileArtistContributions
+        .filter(keep),
     ],
     ({length}) => length);
 }
diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js
index 699c5f86..9eb602f7 100644
--- a/src/content/dependencies/generateAdditionalFilesList.js
+++ b/src/content/dependencies/generateAdditionalFilesList.js
@@ -6,6 +6,7 @@ export default {
   }),
 
   slots: {
+    string: {type: 'string', default: 'miscellaneousAdditionalFiles'},
     showFileSizes: {type: 'boolean', default: true},
   },
 
@@ -14,6 +15,7 @@ export default {
       {[html.onlyIfContent]: true},
 
       relations.chunks.map(chunk => chunk.slots({
+        string: slots.string,
         showFileSizes: slots.showFileSizes,
       }))),
 };
diff --git a/src/content/dependencies/generateAdditionalFilesListChunk.js b/src/content/dependencies/generateAdditionalFilesListChunk.js
index 466a5d8d..7d0e41c1 100644
--- a/src/content/dependencies/generateAdditionalFilesListChunk.js
+++ b/src/content/dependencies/generateAdditionalFilesListChunk.js
@@ -8,6 +8,9 @@ export default {
     links:
       file.filenames
         .map(filename => relation('linkAdditionalFile', file, filename)),
+
+    artistCredit:
+      relation('generateArtistCredit', file.artistContribs, []),
   }),
 
   data: (file) => ({
@@ -19,6 +22,11 @@ export default {
   }),
 
   slots: {
+    string: {
+      type: 'string',
+      default: 'miscellaneousAdditionalFiles',
+    },
+
     showFileSizes: {
       type: 'boolean',
     },
@@ -34,9 +42,29 @@ export default {
           [
             html.tag('summary',
               html.tag('span',
-                language.$(capsule, 'entry', {
-                  title:
-                    html.tag('b', data.title),
+                language.encapsulate(capsule, 'entry', workingCapsule => {
+                  const workingOptions = {};
+                  const entryCapsule = workingCapsule;
+
+                  const titlePart =
+                    (data.title
+                      ? language.sanitize(data.title)
+                      : language.$('releaseInfo', slots.string, 'entry.placeholderTitle'));
+
+                  workingOptions.title =
+                    html.tag('b', titlePart);
+
+                  relations.artistCredit.setSlots({
+                    normalStringKey:
+                      entryCapsule + '.credit',
+                  });
+
+                  if (!html.isBlank(relations.artistCredit)) {
+                    workingCapsule += '.withCredit';
+                    workingOptions.credit = relations.artistCredit;
+                  }
+
+                  return language.$(workingCapsule, workingOptions);
                 }))),
 
             html.tag('ul', [
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
index ae21b361..c3ac0b9f 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -92,6 +92,15 @@ export default {
     flashesChunkedList:
       relation('generateArtistInfoPageFlashesChunkedList', artist),
 
+    sheetMusicFilesChunkedList:
+      relation('generateArtistInfoPageSheetMusicFilesChunkedList', artist),
+
+    midiProjectFilesChunkedList:
+      relation('generateArtistInfoPageMidiProjectFilesChunkedList', artist),
+
+    miscellaneousAdditionalFilesChunkedList:
+      relation('generateArtistInfoPageMiscellaneousAdditionalFilesChunkedList', artist),
+
     commentaryChunkedList:
       relation('generateArtistInfoPageCommentaryChunkedList', artist, false),
 
@@ -225,6 +234,21 @@ export default {
                       {href: '#music-videos'},
                       language.$(pageCapsule, 'musicVideoList.title')),
 
+                  !html.isBlank(relations.sheetMusicFilesChunkedList) &&
+                    html.tag('a',
+                      {href: '#sheet-music-files'},
+                      language.$(pageCapsule, 'sheetMusicFileList.title')),
+
+                  !html.isBlank(relations.midiProjectFilesChunkedList) &&
+                    html.tag('a',
+                      {href: '#midi-project-files'},
+                      language.$(pageCapsule, 'midiProjectFileList.title')),
+
+                  !html.isBlank(relations.miscellaneousAdditionalFilesChunkedList) &&
+                    html.tag('a',
+                      {href: '#additional-files'},
+                      language.$(pageCapsule, 'miscellaneousAdditionalFileList.title')),
+
                   !html.isBlank(relations.flashesChunkedList) &&
                     html.tag('a',
                       {href: '#flashes'},
@@ -358,6 +382,39 @@ export default {
             relations.contentHeading.clone()
               .slots({
                 tag: 'h2',
+                attributes: {id: 'sheet-music-files'},
+                title: language.$(pageCapsule, 'sheetMusicFileList.title'),
+              }),
+
+            relations.sheetMusicFilesChunkedList,
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                tag: 'h2',
+                attributes: {id: 'midi-project-files'},
+                title: language.$(pageCapsule, 'midiProjectFileList.title'),
+              }),
+
+            relations.midiProjectFilesChunkedList,
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                tag: 'h2',
+                attributes: {id: 'additional-files'},
+                title: language.$(pageCapsule, 'miscellaneousAdditionalFileList.title'),
+              }),
+
+            relations.miscellaneousAdditionalFilesChunkedList,
+          ]),
+
+          html.tags([
+            relations.contentHeading.clone()
+              .slots({
+                tag: 'h2',
                 attributes: {id: 'flashes'},
                 title: language.$(pageCapsule, 'flashList.title'),
               }),
diff --git a/src/content/dependencies/generateArtistInfoPageAdditionalFilesChunk.js b/src/content/dependencies/generateArtistInfoPageAdditionalFilesChunk.js
new file mode 100644
index 00000000..353ad047
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageAdditionalFilesChunk.js
@@ -0,0 +1,41 @@
+export default {
+  relations: (relation, artist, album, contribs) => ({
+    template:
+      relation('generateArtistInfoPageChunk'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    items:
+      contribs.map(contribs =>
+        relation('generateArtistInfoPageAdditionalFilesChunkItem',
+          artist,
+          contribs)),
+  }),
+
+  slots: {
+    string: {
+      type: 'string',
+      default: 'additionalFile',
+    },
+
+    disableStandaloneWithFiles: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (relations, slots, {html}) =>
+    relations.template.slots({
+      mode: 'album',
+      link: relations.albumLink,
+
+      list:
+        html.tag('ul',
+          relations.items
+            .map(item => item.slots({
+              string: slots.string,
+              disableStandaloneWithFiles: slots.disableStandaloneWithFiles,
+            }))),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageAdditionalFilesChunkItem.js b/src/content/dependencies/generateArtistInfoPageAdditionalFilesChunkItem.js
new file mode 100644
index 00000000..8352edba
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageAdditionalFilesChunkItem.js
@@ -0,0 +1,115 @@
+export default {
+  query(_artist, contribs) {
+    const query = {};
+
+    query.additionalFile = contribs[0].thing;
+
+    query.albumOrTrack = query.additionalFile.thing;
+
+    query.album =
+      (query.albumOrTrack.isAlbum
+        ? query.albumOrTrack
+        : query.albumOrTrack.album);
+
+    return query;
+  },
+
+  relations: (relation, query, artist, _contribs) => ({
+    template:
+      relation('generateArtistInfoPageChunkItem'),
+
+    trackLink:
+      (query.albumOrTrack.isTrack
+        ? relation('linkTrack', query.albumOrTrack)
+        : null),
+
+    artistCredit:
+      relation('generateArtistCredit',
+        query.additionalFile.artistContribs,
+        [artist.mockSimpleContribution]),
+  }),
+
+  data: (query, _artist, contribs) => ({
+    for:
+      (query.albumOrTrack.isAlbum
+        ? 'album'
+        : 'track'),
+
+    title:
+      query.additionalFile.title,
+
+    files:
+      query.additionalFile.filenames.length,
+
+    contribAnnotationParts:
+      contribs.flatMap(contrib => contrib.annotationParts),
+  }),
+
+  slots: {
+    string: {
+      type: 'string',
+      default: 'additionalFile',
+    },
+
+    disableStandaloneWithFiles: {
+      type: 'boolean',
+      default: false,
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    relations.template.slots({
+      annotation:
+        (data.contribAnnotationParts
+          ? language.formatUnitList(data.contribAnnotationParts)
+          : html.blank()),
+
+      content:
+        language.encapsulate('artistPage.creditList.entry', entryCapsule => {
+          let workingCapsule = entryCapsule;
+          let workingOptions = {};
+
+          workingCapsule += '.' + data.for + '.' + slots.string;
+
+          const additionalFileCapsule = workingCapsule;
+
+          if (data.for === 'track') {
+            workingOptions.track =
+              relations.trackLink;
+          }
+
+          if (data.title) {
+            relations.artistCredit.setSlots({
+              normalStringKey:
+                additionalFileCapsule + '.credit.alongsideTitle',
+            });
+          } else if (data.files && !slots.disableStandaloneWithFiles) {
+            relations.artistCredit.setSlots({
+              normalStringKey:
+                additionalFileCapsule + '.credit.standaloneWithFiles',
+
+              additionalStringOptions: {
+                files: language.countFiles(data.files, {unitOnly: true}),
+              },
+            });
+          } else {
+            relations.artistCredit.setSlots({
+              normalStringKey:
+                additionalFileCapsule + '.credit',
+            });
+          }
+
+          if (!html.isBlank(relations.artistCredit)) {
+            workingCapsule += '.withCredit';
+            workingOptions.credit = relations.artistCredit;
+          }
+
+          if (data.title) {
+            workingCapsule += '.withTitle';
+            workingOptions.title = language.sanitize(data.title);
+          }
+
+          return language.$(workingCapsule, workingOptions);
+        }),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageMidiProjectFilesChunkedList.js b/src/content/dependencies/generateArtistInfoPageMidiProjectFilesChunkedList.js
new file mode 100644
index 00000000..475350a7
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageMidiProjectFilesChunkedList.js
@@ -0,0 +1,68 @@
+import {chunkByConditions, stitchArrays} from '#sugar';
+import {sortAlbumsTracksChronologically, sortContributionsChronologically}
+  from '#sort';
+
+export default {
+  query(artist) {
+    const query = {};
+
+    const allContributions = [
+      ...artist.midiProjectFileArtistContributions,
+    ];
+
+    const getAdditionalFile = contrib =>
+      contrib.thing;
+
+    const getAlbumOrTrack = contrib =>
+      getAdditionalFile(contrib).thing;
+
+    sortContributionsChronologically(
+      allContributions,
+      sortAlbumsTracksChronologically,
+      {getThing: getAlbumOrTrack});
+
+    const getAlbum = contrib =>
+      (getAlbumOrTrack(contrib).isTrack
+        ? getAlbumOrTrack(contrib).album
+        : getAlbumOrTrack(contrib));
+
+    query.contribs =
+      chunkByConditions(allContributions, [
+        (a, b) => getAlbum(a) !== getAlbum(b),
+      ]).map(contribs =>
+          chunkByConditions(contribs, [
+            (a, b) => getAdditionalFile(a) !== getAdditionalFile(b),
+          ]));
+
+    query.albums =
+      query.contribs
+        .map(contribs => contribs[0][0])
+        .map(contrib => getAlbum(contrib));
+
+    return query;
+  },
+
+  relations: (relation, query, artist) => ({
+    template:
+      relation('generateArtistInfoPageChunkedList'),
+
+    chunks:
+      stitchArrays({
+        album: query.albums,
+        contribs: query.contribs,
+      }).map(({album, contribs}) =>
+          relation('generateArtistInfoPageAdditionalFilesChunk',
+            artist,
+            album,
+            contribs)),
+  }),
+
+  generate: (relations) =>
+    relations.template.slots({
+      chunks:
+        relations.chunks.map(chunk =>
+          chunk.slots({
+            string: 'midiProjectFile',
+          })),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageMiscellaneousAdditionalFilesChunkedList.js b/src/content/dependencies/generateArtistInfoPageMiscellaneousAdditionalFilesChunkedList.js
new file mode 100644
index 00000000..55a3119e
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageMiscellaneousAdditionalFilesChunkedList.js
@@ -0,0 +1,68 @@
+import {chunkByConditions, stitchArrays} from '#sugar';
+import {sortAlbumsTracksChronologically, sortContributionsChronologically}
+  from '#sort';
+
+export default {
+  query(artist) {
+    const query = {};
+
+    const allContributions = [
+      ...artist.miscellaneousAdditionalFileArtistContributions,
+    ];
+
+    const getAdditionalFile = contrib =>
+      contrib.thing;
+
+    const getAlbumOrTrack = contrib =>
+      getAdditionalFile(contrib).thing;
+
+    sortContributionsChronologically(
+      allContributions,
+      sortAlbumsTracksChronologically,
+      {getThing: getAlbumOrTrack});
+
+    const getAlbum = contrib =>
+      (getAlbumOrTrack(contrib).isTrack
+        ? getAlbumOrTrack(contrib).album
+        : getAlbumOrTrack(contrib));
+
+    query.contribs =
+      chunkByConditions(allContributions, [
+        (a, b) => getAlbum(a) !== getAlbum(b),
+      ]).map(contribs =>
+          chunkByConditions(contribs, [
+            (a, b) => getAdditionalFile(a) !== getAdditionalFile(b),
+          ]));
+
+    query.albums =
+      query.contribs
+        .map(contribs => contribs[0][0])
+        .map(contrib => getAlbum(contrib));
+
+    return query;
+  },
+
+  relations: (relation, query, artist) => ({
+    template:
+      relation('generateArtistInfoPageChunkedList'),
+
+    chunks:
+      stitchArrays({
+        album: query.albums,
+        contribs: query.contribs,
+      }).map(({album, contribs}) =>
+          relation('generateArtistInfoPageAdditionalFilesChunk',
+            artist,
+            album,
+            contribs)),
+  }),
+
+  generate: (relations) =>
+    relations.template.slots({
+      chunks:
+        relations.chunks.map(chunk =>
+          chunk.slots({
+            string: 'miscellaneousAdditionalFile',
+          })),
+    }),
+};
diff --git a/src/content/dependencies/generateArtistInfoPageSheetMusicFilesChunkedList.js b/src/content/dependencies/generateArtistInfoPageSheetMusicFilesChunkedList.js
new file mode 100644
index 00000000..63a6b23a
--- /dev/null
+++ b/src/content/dependencies/generateArtistInfoPageSheetMusicFilesChunkedList.js
@@ -0,0 +1,69 @@
+import {chunkByConditions, stitchArrays} from '#sugar';
+import {sortAlbumsTracksChronologically, sortContributionsChronologically}
+  from '#sort';
+
+export default {
+  query(artist) {
+    const query = {};
+
+    const allContributions = [
+      ...artist.sheetMusicFileArtistContributions,
+    ];
+
+    const getAdditionalFile = contrib =>
+      contrib.thing;
+
+    const getAlbumOrTrack = contrib =>
+      getAdditionalFile(contrib).thing;
+
+    sortContributionsChronologically(
+      allContributions,
+      sortAlbumsTracksChronologically,
+      {getThing: getAlbumOrTrack});
+
+    const getAlbum = contrib =>
+      (getAlbumOrTrack(contrib).isTrack
+        ? getAlbumOrTrack(contrib).album
+        : getAlbumOrTrack(contrib));
+
+    query.contribs =
+      chunkByConditions(allContributions, [
+        (a, b) => getAlbum(a) !== getAlbum(b),
+      ]).map(contribs =>
+          chunkByConditions(contribs, [
+            (a, b) => getAdditionalFile(a) !== getAdditionalFile(b),
+          ]));
+
+    query.albums =
+      query.contribs
+        .map(contribs => contribs[0][0])
+        .map(contrib => getAlbum(contrib));
+
+    return query;
+  },
+
+  relations: (relation, query, artist) => ({
+    template:
+      relation('generateArtistInfoPageChunkedList'),
+
+    chunks:
+      stitchArrays({
+        album: query.albums,
+        contribs: query.contribs,
+      }).map(({album, contribs}) =>
+          relation('generateArtistInfoPageAdditionalFilesChunk',
+            artist,
+            album,
+            contribs)),
+  }),
+
+  generate: (relations) =>
+    relations.template.slots({
+      chunks:
+        relations.chunks.map(chunk =>
+          chunk.slots({
+            string: 'sheetMusicFile',
+            disableStandaloneWithFiles: true,
+          })),
+    }),
+};
diff --git a/src/content/dependencies/generateListAllAdditionalFilesChunk.js b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
index d68e3bc1..fea565cb 100644
--- a/src/content/dependencies/generateListAllAdditionalFilesChunk.js
+++ b/src/content/dependencies/generateListAllAdditionalFilesChunk.js
@@ -6,6 +6,11 @@ export default {
       additionalFiles
         .map(file => file.filenames
           .map(filename => relation('linkAdditionalFile', file, filename))),
+
+    artistCredits:
+      additionalFiles
+        .map(file =>
+          relation('generateArtistCredit', file.artistContribs, [])),
   }),
 
   data: (additionalFiles) => ({
@@ -42,55 +47,76 @@ export default {
 
             stitchArrays({
               title: data.titles,
+              artistCredit: relations.artistCredits,
               links: relations.links,
               filenames: data.filenames,
             }).map(({
                 title,
+                artistCredit,
                 links,
                 filenames,
               }) =>
-                language.encapsulate(pageCapsule, 'file', capsule =>
-                  (links.length === 1
-                    ? html.tag('li',
-                        links[0].slots({
-                          content:
-                            language.$(capsule, {
-                              title: title,
-                            }),
-                        }))
-
-                 : links.length === 0
-                    ? html.tag('li',
-                        language.$(capsule, 'withNoFiles', {
-                          title: title,
-                        }))
-
-                    : html.tag('li', {class: 'has-details'},
-                        html.tag('details', [
-                          html.tag('summary',
-                            html.tag('span',
-                              language.$(capsule, 'withMultipleFiles', {
-                                title:
-                                  html.tag('b', title),
-
-                                files:
-                                  language.countAdditionalFiles(
-                                    links.length,
-                                    {unit: true}),
-                              }))),
-
-                          html.tag('ul',
-                            stitchArrays({
-                              link: links,
-                              filename: filenames,
-                            }).map(({link, filename}) =>
-                                html.tag('li',
-                                  link.slots({
-                                    content:
-                                      language.$(capsule, {
-                                        title: filename,
-                                      }),
-                                  })))),
-                        ]))))))),
+                language.encapsulate(pageCapsule, 'file', capsule => {
+                  const titleLine =
+                    language.encapsulate(capsule, workingCapsule => {
+                      const workingOptions = {};
+
+                      const titlePart =
+                        (title
+                          ? language.sanitize(title)
+                          : language.$(capsule, 'placeholderTitle'));
+
+                      workingOptions.title =
+                        (links.length <= 1
+                          ? links[0].slot('content', titlePart)
+                          : html.tag('b', titlePart));
+
+                      artistCredit.setSlots({
+                        normalStringKey: capsule + '.credit',
+                      });
+
+                      if (!html.isBlank(artistCredit)) {
+                        workingCapsule += '.withCredit';
+                        workingOptions.credit = artistCredit;
+                      }
+
+                      if (links.length === 0) {
+                        workingCapsule += '.withNoFiles';
+                      } else if (links.length >= 2) {
+                        workingCapsule += '.withMultipleFiles';
+                        workingOptions.files =
+                          language.countFiles(links.length, {unit: true});
+                      }
+
+                      return language.$(workingCapsule, workingOptions);
+                    });
+
+                  if (links.length <= 1) {
+                    return html.tag('li', titleLine);
+                  }
+
+                  const summary =
+                    html.tag('summary',
+                      html.tag('span', titleLine));
+
+                  const list =
+                    html.tag('ul',
+                      stitchArrays({
+                        link: links,
+                        filename: filenames,
+                      }).map(({link, filename}) =>
+                          html.tag('li',
+                            link.slots({
+                              content:
+                                language.$(capsule, {
+                                  title: filename,
+                                }),
+                            }))));
+
+                    return (
+                      html.tag('li', {class: 'has-details'},
+                        html.tag('details', [summary, list]))
+                    );
+                  })))),
       ])),
 };
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 1a21cc72..33242b4e 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -122,7 +122,7 @@ export default {
     midiProjectFilesList:
       relation('generateAdditionalFilesList', track.midiProjectFiles),
 
-    additionalFilesList:
+    miscellaneousAdditionalFilesList:
       relation('generateAdditionalFilesList', track.additionalFiles),
 
     artistCommentarySection:
@@ -219,12 +219,12 @@ export default {
                         language.$(capsule, 'link')),
                   })),
 
-              !html.isBlank(relations.additionalFilesList) &&
-                language.encapsulate(capsule, 'additionalFiles.shortcut', capsule =>
+              !html.isBlank(relations.miscellaneousAdditionalFilesList) &&
+                language.encapsulate(capsule, 'miscellaneousAdditionalFiles.shortcut', capsule =>
                   language.$(capsule, {
                     link:
                       html.tag('a',
-                        {href: '#midi-project-files'},
+                        {href: '#additional-files'},
                         language.$(capsule, 'link')),
                   })),
 
@@ -346,7 +346,9 @@ export default {
               title: language.$('releaseInfo.sheetMusicFiles.heading'),
             }),
 
-            relations.sheetMusicFilesList,
+            relations.sheetMusicFilesList.slots({
+              string: 'sheetMusicFiles',
+            }),
           ]),
 
           html.tags([
@@ -355,16 +357,20 @@ export default {
               title: language.$('releaseInfo.midiProjectFiles.heading'),
             }),
 
-            relations.midiProjectFilesList,
+            relations.midiProjectFilesList.slots({
+              string: 'midiProjectFiles',
+            }),
           ]),
 
           html.tags([
             relations.contentHeading.clone().slots({
               attributes: {id: 'additional-files'},
-              title: language.$('releaseInfo.additionalFiles.heading'),
+              title: language.$('releaseInfo.miscellaneousAdditionalFiles.heading'),
             }),
 
-            relations.additionalFilesList,
+            relations.miscellaneousAdditionalFilesList.slots({
+              string: 'miscellaneousAdditionalFiles',
+            }),
           ]),
 
           relations.artistCommentarySection,
diff --git a/src/data/checks.js b/src/data/checks.js
index 01b5cf9e..6909f011 100644
--- a/src/data/checks.js
+++ b/src/data/checks.js
@@ -248,6 +248,10 @@ export function filterReferenceErrors(wikiData, {
   find,
   bindFind,
 }) {
+  const additionalFileShape = {
+    artistContribs: '_contrib',
+  };
+
   const referenceSpec = [
     ['albumData', {
       artistContribs: '_contrib',
@@ -295,6 +299,9 @@ export function filterReferenceErrors(wikiData, {
       featuredTracks: 'track',
     }],
 
+    ['midiProjectFileData', additionalFileShape],
+    ['miscellaneousAdditionalFileData', additionalFileShape],
+
     ['musicVideoData', {
       artistContribs: '_contrib',
       contributorContribs: '_contrib',
@@ -304,6 +311,8 @@ export function filterReferenceErrors(wikiData, {
       albums: 'album',
     }],
 
+    ['sheetMusicFileData', additionalFileShape],
+
     ['trackData', {
       artistContribs: '_contrib',
       contributorContribs: '_contrib',
diff --git a/src/data/things/AdditionalFile.js b/src/data/things/AdditionalFile.js
deleted file mode 100644
index e3f309a6..00000000
--- a/src/data/things/AdditionalFile.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import {input} from '#composite';
-import Thing from '#thing';
-import {isString, validateArrayItems} from '#validators';
-
-import {exposeConstant, exposeUpdateValueOrContinue}
-  from '#composite/control-flow';
-import {contentString, simpleString, thing} from '#composite/wiki-properties';
-
-export class AdditionalFile extends Thing {
-  static [Thing.friendlyName] = `Additional File`;
-
-  static [Thing.getPropertyDescriptors] = () => ({
-    // Update & expose
-
-    thing: thing(),
-
-    title: simpleString(),
-
-    description: contentString(),
-
-    filenames: [
-      exposeUpdateValueOrContinue({
-        validate: input.value(validateArrayItems(isString)),
-      }),
-
-      exposeConstant({
-        value: input.value([]),
-      }),
-    ],
-
-    // Expose only
-
-    isAdditionalFile: [
-      exposeConstant({
-        value: input.value(true),
-      }),
-    ],
-  });
-
-  static [Thing.yamlDocumentSpec] = {
-    fields: {
-      'Title': {property: 'title'},
-      'Description': {property: 'description'},
-      'Files': {property: 'filenames'},
-    },
-  };
-
-  get paths() {
-    if (!this.thing) return null;
-    if (!this.thing.getOwnAdditionalFilePath) return null;
-
-    return (
-      this.filenames.map(filename =>
-        this.thing.getOwnAdditionalFilePath(this, filename)));
-  }
-}
diff --git a/src/data/things/Artist.js b/src/data/things/Artist.js
index 64798527..89da3c88 100644
--- a/src/data/things/Artist.js
+++ b/src/data/things/Artist.js
@@ -273,6 +273,18 @@ export class Artist extends Thing {
       exposeDependency('#otherArtistContributions'),
     ],
 
+    miscellaneousAdditionalFileArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('miscellaneousAdditionalFileArtistContributionsBy'),
+    }),
+
+    sheetMusicFileArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('sheetMusicFileArtistContributionsBy'),
+    }),
+
+    midiProjectFileArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('midiProjectFileArtistContributionsBy'),
+    }),
+
     totalDuration: [
       withPropertyFromList('musicContributions', V('thing')),
       withPropertyFromList('#musicContributions.thing', V('isMainRelease')),
diff --git a/src/data/things/Language.js b/src/data/things/Language.js
index 2df58d19..5265d851 100644
--- a/src/data/things/Language.js
+++ b/src/data/things/Language.js
@@ -955,6 +955,7 @@ export class Language extends Thing {
 const countHelper = (stringKey, optionName = stringKey) =>
   function(value, {
     unit = false,
+    unitOnly = false,
     blankIfZero = false,
   } = {}) {
     // Null or undefined value is blank content.
@@ -967,22 +968,30 @@ const countHelper = (stringKey, optionName = stringKey) =>
       return html.blank();
     }
 
-    return this.formatString(
-      unit
+    const string =
+      (unitOnly
+        ? `count.${stringKey}.unitOnly.` + this.getUnitForm(value)
+     : unit
         ? `count.${stringKey}.withUnit.` + this.getUnitForm(value)
-        : `count.${stringKey}`,
-      {[optionName]: this.formatNumber(value)});
+        : `count.${stringKey}`);
+
+    const options =
+      (unitOnly
+        ? {}
+        : {[optionName]: this.formatNumber(value)});
+
+    return this.formatString(string, options);
   };
 
 // TODO: These are hard-coded. Is there a better way?
 Object.assign(Language.prototype, {
-  countAdditionalFiles: countHelper('additionalFiles', 'files'),
   countAlbums: countHelper('albums'),
   countArtTags: countHelper('artTags', 'tags'),
   countArtworks: countHelper('artworks'),
   countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
   countContributions: countHelper('contributions'),
   countDays: countHelper('days'),
+  countFiles: countHelper('files'),
   countFlashes: countHelper('flashes'),
   countMonths: countHelper('months'),
   countTimesFeatured: countHelper('timesFeatured'),
diff --git a/src/data/things/Track.js b/src/data/things/Track.js
index 8752b2bb..c47729e9 100644
--- a/src/data/things/Track.js
+++ b/src/data/things/Track.js
@@ -37,8 +37,10 @@ import {
   parseDimensions,
   parseDuration,
   parseLyrics,
+  parseMidiProjectFiles,
   parseMusicVideos,
   parseReferencingSources,
+  parseSheetMusicFiles,
   parseURLs,
 } from '#yaml';
 
@@ -1123,12 +1125,12 @@ export class Track extends Thing {
 
       'Sheet Music Files': {
         property: 'sheetMusicFiles',
-        transform: parseAdditionalFiles,
+        transform: parseSheetMusicFiles,
       },
 
       'MIDI Project Files': {
         property: 'midiProjectFiles',
-        transform: parseAdditionalFiles,
+        transform: parseMidiProjectFiles,
       },
 
       // Content entries
@@ -1369,14 +1371,10 @@ export class Track extends Thing {
     },
   };
 
-  getOwnAdditionalFilePath(_file, filename) {
+  getOwnAdditionalFilePath(file, filename) {
     if (!this.album) return null;
 
-    return [
-      'media.albumAdditionalFile',
-      this.album.directory,
-      filename,
-    ];
+    return this.album.getOwnAdditionalFilePath(file, filename);
   }
 
   getOwnArtworkPath(artwork) {
diff --git a/src/data/things/additional-file/AdditionalFile.js b/src/data/things/additional-file/AdditionalFile.js
new file mode 100644
index 00000000..d137c741
--- /dev/null
+++ b/src/data/things/additional-file/AdditionalFile.js
@@ -0,0 +1,109 @@
+import {inspect} from 'node:util';
+
+import {colors} from '#cli';
+import {input, V} from '#composite';
+import Thing from '#thing';
+import {isString, validateArrayItems} from '#validators';
+import {parseContributors} from '#yaml';
+
+import {exposeConstant, exposeUpdateValueOrContinue}
+  from '#composite/control-flow';
+import {contributionList, contentString, simpleString, soupyFind, thing}
+  from '#composite/wiki-properties';
+
+export class AdditionalFile extends Thing {
+  static [Thing.friendlyName] = `Additional File`;
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    thing: thing(),
+
+    title: simpleString(),
+
+    description: contentString(),
+
+    folder: simpleString(),
+
+    filenames: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(validateArrayItems(isString)),
+      }),
+
+      exposeConstant(V([])),
+    ],
+
+    artistContribs: contributionList({
+      // Subclasses override with the relevant artistProperty.
+      artistProperty: input.value(null),
+    }),
+
+    // Update only
+
+    find: soupyFind(),
+
+    // Expose only
+
+    isAdditionalFile: exposeConstant(V(true)),
+
+    // The date property is generally expected by contributions.
+    // Additional files don't actually support dates, but provide a null
+    // value for convenience.
+    date: {
+      flags: {expose: true},
+      expose: {compute: () => null},
+    },
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Title': {property: 'title'},
+      'Description': {property: 'description'},
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      'Folder': {property: 'folder'},
+      'Files': {property: 'filenames'},
+    },
+  };
+
+  get paths() {
+    if (!this.thing) return null;
+    if (!this.thing.getOwnAdditionalFilePath) return null;
+
+    return (
+      this.filenames.map(filename =>
+        this.thing.getOwnAdditionalFilePath(this, filename)));
+  }
+
+  [inspect.custom](depth, options, inspect) {
+    const parts = [];
+
+    parts.push(this.constructor.name);
+
+    if (this.title) {
+      parts.push(` ${colors.green(`"${this.title}"`)}`);
+    }
+
+    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.inspectReference(this.thing))}`);
+      }
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/additional-file/MidiProjectFile.js b/src/data/things/additional-file/MidiProjectFile.js
new file mode 100644
index 00000000..8e7c19ca
--- /dev/null
+++ b/src/data/things/additional-file/MidiProjectFile.js
@@ -0,0 +1,28 @@
+import {input, V} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+import {contributionList, soupyReverse} from '#composite/wiki-properties';
+
+import {AdditionalFile} from './AdditionalFile.js';
+
+export class MidiProjectFile extends AdditionalFile {
+  static [Thing.wikiData] = 'midiProjectFileData';
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    artistContribs: contributionList({
+      artistProperty: input.value('midiProjectFileArtistContributions'),
+    }),
+
+    // Expose only
+
+    isMidiProjectFile: exposeConstant(V(true)),
+  });
+
+  static [Thing.reverseSpecs] = {
+    midiProjectFileArtistContributionsBy:
+      soupyReverse.contributionsBy('midiProjectFileData', 'artistContribs'),
+  };
+}
diff --git a/src/data/things/additional-file/MiscellaneousAdditionalFile.js b/src/data/things/additional-file/MiscellaneousAdditionalFile.js
new file mode 100644
index 00000000..0110f830
--- /dev/null
+++ b/src/data/things/additional-file/MiscellaneousAdditionalFile.js
@@ -0,0 +1,28 @@
+import {input, V} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+import {contributionList, soupyReverse} from '#composite/wiki-properties';
+
+import {AdditionalFile} from './AdditionalFile.js';
+
+export class MiscellaneousAdditionalFile extends AdditionalFile {
+  static [Thing.wikiData] = 'miscellaneousAdditionalFileData';
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    artistContribs: contributionList({
+      artistProperty: input.value('miscellaneousAdditionalFileArtistContributions'),
+    }),
+
+    // Expose only
+
+    isMiscellaneousAdditionalFile: exposeConstant(V(true)),
+  });
+
+  static [Thing.reverseSpecs] = {
+    miscellaneousAdditionalFileArtistContributionsBy:
+      soupyReverse.contributionsBy('miscellaneousAdditionalFileData', 'artistContribs'),
+  };
+}
diff --git a/src/data/things/additional-file/SheetMusicFile.js b/src/data/things/additional-file/SheetMusicFile.js
new file mode 100644
index 00000000..c06cde7f
--- /dev/null
+++ b/src/data/things/additional-file/SheetMusicFile.js
@@ -0,0 +1,28 @@
+import {input, V} from '#composite';
+import Thing from '#thing';
+
+import {exposeConstant} from '#composite/control-flow';
+import {contributionList, soupyReverse} from '#composite/wiki-properties';
+
+import {AdditionalFile} from './AdditionalFile.js';
+
+export class SheetMusicFile extends AdditionalFile {
+  static [Thing.wikiData] = 'sheetMusicFileData';
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    artistContribs: contributionList({
+      artistProperty: input.value('sheetMusicFileArtistContributions'),
+    }),
+
+    // Expose only
+
+    isSheetMusicFile: exposeConstant(V(true)),
+  });
+
+  static [Thing.reverseSpecs] = {
+    sheetMusicFileArtistContributionsBy:
+      soupyReverse.contributionsBy('sheetMusicFileData', 'artistContribs'),
+  };
+}
diff --git a/src/data/things/additional-file/index.js b/src/data/things/additional-file/index.js
new file mode 100644
index 00000000..d8de7455
--- /dev/null
+++ b/src/data/things/additional-file/index.js
@@ -0,0 +1,5 @@
+export * from './AdditionalFile.js';
+
+export * from './MidiProjectFile.js';
+export * from './MiscellaneousAdditionalFile.js';
+export * from './SheetMusicFile.js'
diff --git a/src/data/things/album/Album.js b/src/data/things/album/Album.js
index f07d552c..201aaf4e 100644
--- a/src/data/things/album/Album.js
+++ b/src/data/things/album/Album.js
@@ -852,12 +852,21 @@ export class Album extends Thing {
     ],
   };
 
-  getOwnAdditionalFilePath(_file, filename) {
-    return [
-      'media.albumAdditionalFile',
-      this.directory,
-      filename,
-    ];
+  getOwnAdditionalFilePath(file, filename) {
+    if (file.folder) {
+      return [
+        'media.albumAdditionalFileInFolder',
+        this.directory,
+        file.folder,
+        filename,
+      ];
+    } else {
+      return [
+        'media.albumAdditionalFile',
+        this.directory,
+        filename,
+      ];
+    }
   }
 
   getOwnArtworkPath(artwork) {
diff --git a/src/data/things/index.js b/src/data/things/index.js
index 3773864b..8cd21e9d 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -1,5 +1,6 @@
 // Not actually the entry point for #things - that's init.js in this folder.
 
+export * from './additional-file/index.js';
 export * from './album/index.js';
 export * from './content/index.js';
 export * from './contrib/index.js';
@@ -8,7 +9,6 @@ export * from './group/index.js';
 export * from './homepage-layout/index.js';
 export * from './sorting-rule/index.js';
 
-export * from './AdditionalFile.js';
 export * from './AdditionalName.js';
 export * from './ArtTag.js';
 export * from './Artist.js';
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 5ddeb63e..15d7b0ba 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -780,14 +780,26 @@ export function parseExcludingURLs(value) {
   return value;
 }
 
-export function parseAdditionalFiles(entries, {subdoc, AdditionalFile}) {
+export function parseAdditionalFilesEntries(thingClass, entries, {subdoc}) {
   return parseArrayEntries(entries, item => {
     if (typeof item !== 'object') return item;
 
-    return subdoc(AdditionalFile, item, {bindInto: 'thing'});
+    return subdoc(thingClass, item, {bindInto: 'thing'});
   });
 }
 
+export function parseAdditionalFiles(entries, {subdoc, MiscellaneousAdditionalFile}) {
+  return parseAdditionalFilesEntries(MiscellaneousAdditionalFile, entries, {subdoc});
+}
+
+export function parseMidiProjectFiles(entries, {subdoc, MidiProjectFile}) {
+  return parseAdditionalFilesEntries(MidiProjectFile, entries, {subdoc});
+}
+
+export function parseSheetMusicFiles(entries, {subdoc, SheetMusicFile}) {
+  return parseAdditionalFilesEntries(SheetMusicFile, entries, {subdoc});
+}
+
 export function parseAdditionalNames(entries, {subdoc, AdditionalName}) {
   return parseArrayEntries(entries, item => {
     if (typeof item === 'object') {
@@ -1873,12 +1885,18 @@ export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) {
 
     ['lyricsData', [/* find */]],
 
+    ['midiProjectFileData', [/* find */]],
+
+    ['miscellaneousAdditionalFileData', [/* find */]],
+
     ['musicVideoData', [/* find */]],
 
     ['referencingSourceData', [/* find */]],
 
     ['seriesData', [/* find */]],
 
+    ['sheetMusicFileData', [/* find */]],
+
     ['trackData', [
       'artworkData',
       'wikiInfo',
diff --git a/src/static/css/miscellany.css b/src/static/css/miscellany.css
index eb085693..37fefb40 100644
--- a/src/static/css/miscellany.css
+++ b/src/static/css/miscellany.css
@@ -503,9 +503,9 @@
     text-decoration: none !important;
   }
 
-  summary > span:hover:has(a:hover) a,
-  summary > span:hover:has(a.nested-hover) a,
-  summary.has-nested-hover > span a {
+  summary > span:hover:has(a:hover) a:hover,
+  summary > span:hover:has(a.nested-hover) a:hover,
+  summary.has-nested-hover > span a:hover {
     text-decoration: underline !important;
   }
 
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index 4e88ea90..18f93187 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -37,28 +37,28 @@ count:
 
   # Count things and objects
 
-  additionalFiles:
-    _: "{FILES}"
+  albums:
+    _: "{ALBUMS}"
+
     withUnit:
       zero: ""
-      one: "{FILES} file"
+      one: "{ALBUMS} album"
       two: ""
       few: ""
       many: ""
-      other: "{FILES} files"
+      other: "{ALBUMS} albums"
 
-  albums:
-    _: "{ALBUMS}"
-    withUnit:
+    unitOnly:
       zero: ""
-      one: "{ALBUMS} album"
+      one: "album"
       two: ""
       few: ""
       many: ""
-      other: "{ALBUMS} albums"
+      other: "albums"
 
   artTags:
     _: "{TAGS}"
+
     withUnit:
       zero: ""
       one: "{TAGS} tag"
@@ -67,8 +67,17 @@ count:
       many: ""
       other: "{TAGS} tags"
 
+    unitOnly:
+      zero: ""
+      one: "tag"
+      two: ""
+      few: ""
+      many: ""
+      other: "tags"
+
   artworks:
     _: "{ARTWORKS}"
+
     withUnit:
       zero: ""
       one: "{ARTWORKS} artwork"
@@ -77,8 +86,17 @@ count:
       many: ""
       other: "{ARTWORKS} artworks"
 
+    unitOnly:
+      zero: ""
+      one: "artwork"
+      two: ""
+      few: ""
+      many: ""
+      other: "artworks"
+
   commentaryEntries:
     _: "{ENTRIES}"
+
     withUnit:
       zero: ""
       one: "{ENTRIES} entry"
@@ -87,8 +105,17 @@ count:
       many: ""
       other: "{ENTRIES} entries"
 
+    unitOnly:
+      zero: ""
+      one: "entry"
+      two: ""
+      few: ""
+      many: ""
+      other: "entries"
+
   contributions:
     _: "{CONTRIBUTIONS}"
+
     withUnit:
       zero: ""
       one: "{CONTRIBUTIONS} contribution"
@@ -97,18 +124,55 @@ count:
       many: ""
       other: "{CONTRIBUTIONS} contributions"
 
+    unitOnly:
+      zero: ""
+      one: "contribution"
+      two: ""
+      few: ""
+      many: ""
+      other: "contributions"
+
+  files:
+    _: "{FILES}"
+
+    withUnit:
+      zero: ""
+      one: "{FILES} file"
+      two: ""
+      few: ""
+      many: ""
+      other: "{FILES} files"
+
+    unitOnly:
+      zero: ""
+      one: "file"
+      two: ""
+      few: ""
+      many: ""
+      other: "files"
+
   flashes:
     _: "{FLASHES}"
+
     withUnit:
       zero: ""
-      one: "{FLASHES} flashes"
+      one: "{FLASHES} flash"
       two: ""
       few: ""
       many: ""
       other: "{FLASHES} flashes"
 
+    unitOnly:
+      zero: ""
+      one: "flash"
+      two: ""
+      few: ""
+      many: ""
+      other: "flashes"
+
   tracks:
     _: "{TRACKS}"
+
     withUnit:
       zero: ""
       one: "{TRACKS} track"
@@ -117,10 +181,19 @@ count:
       many: ""
       other: "{TRACKS} tracks"
 
+    unitOnly:
+      zero: ""
+      one: "track"
+      two: ""
+      few: ""
+      many: ""
+      other: "tracks"
+
   # Count more abstract stuff
 
   days:
     _: "{DAYS}"
+
     withUnit:
       zero: ""
       one: "{DAYS} day"
@@ -129,8 +202,17 @@ count:
       many: ""
       other: "{DAYS} days"
 
+    unitOnly:
+      zero: ""
+      one: "day"
+      two: ""
+      few: ""
+      many: ""
+      other: "days"
+
   months:
     _: "{MONTHS}"
+
     withUnit:
       zero: ""
       one: "{MONTHS} month"
@@ -139,8 +221,17 @@ count:
       many: ""
       other: "{MONTHS} months"
 
+    unitOnly:
+      zero: ""
+      one: "month"
+      two: ""
+      few: ""
+      many: ""
+      other: "months"
+
   timesFeatured:
     _: "{TIMES_FEATURED}"
+
     withUnit:
       zero: ""
       one: "featured {TIMES_FEATURED} time"
@@ -149,8 +240,17 @@ count:
       many: ""
       other: "featured {TIMES_FEATURED} times"
 
+    unitOnly:
+      zero: ""
+      one: "time featured"
+      two: ""
+      few: ""
+      many: ""
+      other: "times featured"
+
   timesReferenced:
     _: "{TIMES_REFERENCED}"
+
     withUnit:
       zero: ""
       one: "{TIMES_REFERENCED} time referenced"
@@ -159,8 +259,17 @@ count:
       many: ""
       other: "{TIMES_REFERENCED} times referenced"
 
+    unitOnly:
+      zero: ""
+      one: "time referenced"
+      two: ""
+      few: ""
+      many: ""
+      other: "times referenced"
+
   timesUsed:
     _: "{TIMES_USED}"
+
     withUnit:
       zero: ""
       one: "used {TIMES_USED} time"
@@ -169,8 +278,17 @@ count:
       many: ""
       other: "used {TIMES_USED} times"
 
+    unitOnly:
+      zero: ""
+      one: "time used"
+      two: ""
+      few: ""
+      many: ""
+      other: "times used"
+
   weeks:
     _: "{WEEKS}"
+
     withUnit:
       zero: ""
       one: "{WEEKS} week"
@@ -179,9 +297,18 @@ count:
       many: ""
       other: "{WEEKS} weeks"
 
+    unitOnly:
+      zero: ""
+      one: "week"
+      two: ""
+      few: ""
+      many: ""
+      other: "weeks"
+
   words:
     _: "{WORDS}"
     thousand: "{WORDS}k"
+
     withUnit:
       zero: ""
       one: "{WORDS} word"
@@ -190,8 +317,17 @@ count:
       many: ""
       other: "{WORDS} words"
 
+    unitOnly:
+      zero: ""
+      one: "word"
+      two: ""
+      few: ""
+      many: ""
+      other: "words"
+
   years:
     _: "{YEARS}"
+
     withUnit:
       zero: ""
       one: "{YEARS} year"
@@ -200,6 +336,14 @@ count:
       many: ""
       other: "{YEARS} years"
 
+    unitOnly:
+      zero: ""
+      one: "year"
+      two: ""
+      few: ""
+      many: ""
+      other: "years"
+
   # Numerical things that aren't exactly counting, per se
 
   duration:
@@ -423,10 +567,14 @@ releaseInfo:
     link: "referencing sources"
 
   additionalFiles:
-    heading: "View or download additional files:"
-
     entry:
-      _: "{TITLE}"
+      _: >-
+        {TITLE}
+
+      withCredit: >-
+        {TITLE} {CREDIT}
+
+      credit: "by {ARTISTS}"
 
       noFilesAvailable: >-
         There are no files available or listed for this entry.
@@ -435,23 +583,29 @@ releaseInfo:
       _: "{FILE}"
       withSize: "{FILE} ({SIZE})"
 
+  miscellaneousAdditionalFiles:
+    heading: "View or download additional files:"
+    entry.placeholderTitle: "Additional file"
+
     shortcut:
       _: "View {LINK}."
       link: "additional files"
 
   sheetMusicFiles:
     heading: "Print or download sheet music files:"
+    entry.placeholderTitle: "Sheet music"
 
     shortcut:
       _: "Download {LINK}."
       link: "sheet music files"
 
   midiProjectFiles:
-    heading: "Download MIDI/project files:"
+    heading: "Download MIDI & project files:"
+    entry.placeholderTitle: "MIDI or project file"
 
     shortcut:
       _: "Download {LINK}."
-      link: "MIDI/project files"
+      link: "MIDI & project files"
 
 #
 # trackList:
@@ -1638,6 +1792,69 @@ artistPage:
         credit.alongsideTitle: >-
           by {ARTISTS}
 
+      track.miscellaneousAdditionalFile:
+        _: >-
+          {TRACK}
+
+        withTitle: >-
+          {TRACK} — {TITLE}
+
+        withCredit: >-
+          {TRACK}: {CREDIT}
+
+        withCredit.withTitle: >-
+          {TRACK}: {TITLE} {CREDIT}
+
+        credit: >-
+          files by {ARTISTS}
+
+        credit.standaloneWithFiles: >-
+          {FILES} by {ARTISTS}
+
+        credit.alongsideTitle: >-
+          by {ARTISTS}
+
+      track.sheetMusicFile:
+        _: >-
+          {TRACK}
+
+        withTitle: >-
+          {TRACK} — {TITLE}
+
+        withCredit: >-
+          {TRACK}: {CREDIT}
+
+        withCredit.withTitle: >-
+          {TRACK}: {TITLE} {CREDIT}
+
+        credit: >-
+          sheet music by {ARTISTS}
+
+        credit.alongsideTitle: >-
+          by {ARTISTS}
+
+      track.midiProjectFile:
+        _: >-
+          {TRACK}
+
+        withTitle: >-
+          {TRACK} — {TITLE}
+
+        withCredit: >-
+          {TRACK}: {CREDIT}
+
+        withCredit.withTitle: >-
+          {TRACK}: {TITLE} {CREDIT}
+
+        credit: >-
+          files by {ARTISTS}
+
+        credit.standaloneWithFiles: >-
+          {FILES} by {ARTISTS}
+
+        credit.alongsideTitle: >-
+          by {ARTISTS}
+
       # album:
       #   The artist info page doesn't display if the artist is
       #   musically credited outright for the album as a whole,
@@ -1652,87 +1869,109 @@ artistPage:
         bannerArt: "(banner art)"
         commentary: "(album commentary)"
 
-        musicVideo:
-          _: >-
-            (album music video)
+      album.musicVideo:
+        _: >-
+          (album music video)
+
+        withLinks: >-
+          (album music video: {LINKS})
+
+        withTitle: >-
+          (for album: {TITLE})
+
+        withTitle.withLinks: >-
+          (for album: {TITLE} - {LINKS})
+
+        withLabel: >-
+          (for album: {LABEL})
 
-          withLinks: >-
-            (album music video: {LINKS})
+        withLabel.withLinks: >-
+          (for album: {LABEL} - {LINKS})
 
-          withTitle: >-
-            (for album: {TITLE})
+        withCredit: >-
+          (album music video {CREDIT})
 
-          withTitle.withLinks: >-
-            (for album: {TITLE} - {LINKS})
+        withCredit.withLinks: >-
+          (album music video {CREDIT} - {LINKS})
 
-          withLabel: >-
-            (for album: {LABEL})
+        withCredit.withTitle: >-
+          (for album: {TITLE} {CREDIT})
 
-          withLabel.withLinks: >-
-            (for album: {LABEL} - {LINKS})
+        withCredit.withTitle.withLinks: >-
+          (for album: {TITLE} {CREDIT} - {LINKS})
 
-          withCredit: >-
-            (album music video {CREDIT})
+        withCredit.withLabel: >-
+          (for album: {LABEL} {CREDIT})
 
-          withCredit.withLinks: >-
-            (album music video {CREDIT} - {LINKS})
+        withCredit.withLabel.withLinks: >-
+          (for album: {LABEL} {CREDIT} - {LINKS})
 
-          withCredit.withTitle: >-
-            (for album: {TITLE} {CREDIT})
+        withDate: >-
+          ({DATE}: album music video)
 
-          withCredit.withTitle.withLinks: >-
-            (for album: {TITLE} {CREDIT} - {LINKS})
+        withDate.withLinks: >-
+          ({DATE}, album music video: {LINKS})
 
-          withCredit.withLabel: >-
-            (for album: {LABEL} {CREDIT})
+        withDate.withTitle: >-
+          ({DATE}, for album: {TITLE})
 
-          withCredit.withLabel.withLinks: >-
-            (for album: {LABEL} {CREDIT} - {LINKS})
+        withDate.withTitle.withLinks: >-
+          ({DATE}, for album: {TITLE} - {LINKS})
 
-          withDate: >-
-            ({DATE}: album music video)
+        withDate.withLabel: >-
+          ({DATE}, for album: {LABEL})
 
-          withDate.withLinks: >-
-            ({DATE}, album music video: {LINKS})
+        withDate.withLabel.withLinks: >-
+          ({DATE}, for album: {LABEL} - {LINKS})
 
-          withDate.withTitle: >-
-            ({DATE}, for album: {TITLE})
+        withDate.withCredit: >-
+          ({DATE}: album music video {CREDIT})
+
+        withDate.withCredit.withLinks: >-
+          ({DATE}, album music video {CREDIT} - {LINKS})
+
+        withDate.withCredit.withTitle: >-
+          ({DATE}, for album: {TITLE} {CREDIT})
+
+        withDate.withCredit.withTitle.withLinks: >-
+          ({DATE}, for album: {TITLE} {CREDIT} - {LINKS})
 
-          withDate.withTitle.withLinks: >-
-            ({DATE}, for album: {TITLE} - {LINKS})
+        withDate.withCredit.withLabel: >-
+          ({DATE}, for album: {LABEL} {CREDIT})
 
-          withDate.withLabel: >-
-            ({DATE}, for album: {LABEL})
+        withDate.withCredit.withLabel.withLinks: >-
+          ({DATE}, for album: {LABEL} {CREDIT} - {LINKS})
 
-          withDate.withLabel.withLinks: >-
-            ({DATE}, for album: {LABEL} - {LINKS})
+        credit: >-
+          by {ARTISTS}
 
-          withDate.withCredit: >-
-            ({DATE}: album music video {CREDIT})
+        credit.alongsideLabel: >-
+          by {ARTISTS}
 
-          withDate.withCredit.withLinks: >-
-            ({DATE}, album music video {CREDIT} - {LINKS})
+        credit.alongsideTitle: >-
+          by {ARTISTS}
 
-          withDate.withCredit.withTitle: >-
-            ({DATE}, for album: {TITLE} {CREDIT})
+      album.miscellaneousAdditionalFile:
+        _: >-
+          (album additional files)
 
-          withDate.withCredit.withTitle.withLinks: >-
-            ({DATE}, for album: {TITLE} {CREDIT} - {LINKS})
+        withTitle: >-
+          (for album: {TITLE})
 
-          withDate.withCredit.withLabel: >-
-            ({DATE}, for album: {LABEL} {CREDIT})
+        withCredit: >-
+          (for album: {CREDIT})
 
-          withDate.withCredit.withLabel.withLinks: >-
-            ({DATE}, for album: {LABEL} {CREDIT} - {LINKS})
+        withCredit.withTitle: >-
+          (for album: {TITLE} {CREDIT})
 
-          credit: >-
-            by {ARTISTS}
+        credit: >-
+          files by {ARTISTS}
 
-          credit.alongsideLabel: >-
-            by {ARTISTS}
+        credit.standaloneWithFiles: >-
+          {FILES} by {ARTISTS}
 
-          credit.alongsideTitle: >-
-            by {ARTISTS}
+        credit.alongsideTitle: >-
+          by {ARTISTS}
 
       flash: "{FLASH}"
 
@@ -1779,6 +2018,9 @@ artistPage:
   trackList.title: "Tracks"
   artList.title: "Artworks"
   musicVideoList.title: "Music Videos"
+  sheetMusicFileList.title: "Sheet Music Files"
+  midiProjectFileList.title: "MIDI & Project Files"
+  miscellaneousAdditionalFileList.title: "Additional Files"
   flashList.title: "Flashes"
   commentaryList.title: "Commentary"
 
@@ -2741,21 +2983,53 @@ listingPage:
 
       file:
         _: "{TITLE}"
-        withMultipleFiles: "{TITLE} ({FILES})"
-        withNoFiles: "{TITLE} (no files)"
+        placeholderTitle: "Sheet music"
+
+        withCredit: >-
+          {TITLE} {CREDIT}
+
+        withMultipleFiles: >-
+          {TITLE} ({FILES})
+
+        withNoFiles: >-
+          {TITLE} (no files)
+
+        withCredit.withMultipleFiles: >-
+          {TITLE} {CREDIT} ({FILES})
+
+        withCredit.withNoFiles: >-
+          {TITLE} {CREDIT} (no files)
+
+        credit: "by {ARTISTS}"
 
     # other.midiProjectFiles:
     #   Same as other.allSheetMusic, but for MIDI & project files.
 
     allMidiProjectFiles:
-      title: "All MIDI/Project Files"
-      title.short: "All MIDI/Project Files"
-      albumFiles: "Album MIDI/project files:"
+      title: "All MIDI / Project Files"
+      title.short: "All MIDI & Project Files"
+      albumFiles: "Album MIDI & project files:"
 
       file:
         _: "{TITLE}"
-        withMultipleFiles: "{TITLE} ({FILES})"
-        withNoFiles: "{TITLE} (no files)"
+        placeholderTitle: "MIDI or project file"
+
+        withCredit: >-
+          {TITLE} {CREDIT}
+
+        withMultipleFiles: >-
+          {TITLE} ({FILES})
+
+        withNoFiles: >-
+          {TITLE} (no files)
+
+        withCredit.withMultipleFiles: >-
+          {TITLE} {CREDIT} ({FILES})
+
+        withCredit.withNoFiles: >-
+          {TITLE} {CREDIT} (no files)
+
+        credit: "by {ARTISTS}"
 
     # other.additionalFiles:
     #   Same as other.allSheetMusic, but for additional files.
@@ -2767,8 +3041,24 @@ listingPage:
 
       file:
         _: "{TITLE}"
-        withMultipleFiles: "{TITLE} ({FILES})"
-        withNoFiles: "{TITLE} (no files available)"
+        placeholderTitle: "Additional file"
+
+        withCredit: >-
+          {TITLE} {CREDIT}
+
+        withMultipleFiles: >-
+          {TITLE} ({FILES})
+
+        withNoFiles: >-
+          {TITLE} (no files)
+
+        withCredit.withMultipleFiles: >-
+          {TITLE} {CREDIT} ({FILES})
+
+        withCredit.withNoFiles: >-
+          {TITLE} {CREDIT} (no files)
+
+        credit: "by {ARTISTS}"
 
     # other.randomPages:
     #   Special listing which shows a bunch of buttons that each
diff --git a/src/urls-default.yaml b/src/urls-default.yaml
index 2f05b207..dfa5b0e9 100644
--- a/src/urls-default.yaml
+++ b/src/urls-default.yaml
@@ -125,6 +125,7 @@ media:
   - *genericPaths
 
   - albumAdditionalFile: 'album-additional/<>/<>'
+    albumAdditionalFileInFolder: 'album-additional/<>/<>/<>'
     albumBanner: 'album-art/<>/banner.<>'
     albumCover: 'album-art/<>/cover.<>'
     albumWallpaper: 'album-art/<>/bg.<>'