Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions plugins/SongDownloader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
},
"main": "./src/index.ts",
"dependencies": {
"node-taglib-sharp": "^6.0.3",
"sanitize-filename": "^1.6.3"
}
}
14 changes: 12 additions & 2 deletions plugins/SongDownloader/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ContextMenu, safeInterval, StyleTag } from "@luna/lib";

import { getDownloadFolder, getDownloadPath, getFileName } from "./helpers";
import { settings } from "./Settings";
import { tagM4a } from "./tagM4a.native";

import styles from "file://downloadButton.css?minify";

Expand Down Expand Up @@ -31,7 +32,7 @@ ContextMenu.onMediaItem(unloads, async ({ mediaCollection, contextMenu }) => {
}

downloadButton.text = `Loading tags...`;
const { tags } = await mediaItem.flacTags();
const { tags, coverUrl } = await mediaItem.flacTags();

downloadButton.text = `Fetching filename...`;
const fileName = await getFileName(mediaItem, settings.downloadQuality);
Expand All @@ -56,7 +57,16 @@ ContextMenu.onMediaItem(unloads, async ({ mediaCollection, contextMenu }) => {
},
50,
);
await mediaItem.download(path, settings.downloadQuality).catch(trace.msg.err.withContext(`Failed to download ${tags.title}`));
await mediaItem
.download(path, settings.downloadQuality)
.then(async () => {
// FLAC is tagged in-flight by the FlacStreamTagger, DASH (m4a) streams are written untagged
if ((await mediaItem.fileExtension(settings.downloadQuality)) === "m4a") {
downloadButton.text = `Writing tags...`;
await tagM4a(path, tags, coverUrl);
}
})
.catch(trace.msg.err.withContext(`Failed to download ${tags.title}`));
clearInterval();
}
downloadButton.text = defaultText;
Expand Down
122 changes: 122 additions & 0 deletions plugins/SongDownloader/src/tagM4a.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { ByteVector, File as TagFile, Picture, PictureType } from "node-taglib-sharp";
import sanitize from "sanitize-filename";

import { join, parse } from "path";

// FlacTags from @luna/lib isn't exported, use a structural type instead
type TagMap = Record<string, string | string[] | undefined | null>;

const first = (value: string | string[] | undefined | null): string | undefined => {
if (value === undefined || value === null) return undefined;
return Array.isArray(value) ? value[0] : value;
};
const asArray = (value: string | string[] | undefined | null): string[] => {
if (value === undefined || value === null) return [];
return (Array.isArray(value) ? value : [value]).filter((entry) => entry !== undefined && entry !== null && entry !== "");
};
const asInt = (value: string | undefined): number | undefined => {
if (value === undefined) return undefined;
const parsed = parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : undefined;
};
const yearFrom = (tags: TagMap): number | undefined => {
const year = asInt(first(tags.year));
if (year && year >= 1 && year <= 9999) return year;
const date = first(tags.date);
if (date && date.length >= 4) {
const dateYear = asInt(date.slice(0, 4));
if (dateYear && dateYear >= 1 && dateYear <= 9999) return dateYear;
}
return undefined;
};

/**
* Writes metadata to a downloaded m4a file.
* FLAC downloads are tagged in-flight by the FlacStreamTagger, but DASH (m4a) streams are written to disk untagged.
* Best-effort: a field that fails to write is skipped instead of aborting the rest.
*/
export const tagM4a = async (path: string | string[], tags: TagMap, coverUrl?: string): Promise<void> => {
// Resolve to the same path download() wrote to (it joins & sanitizes the basename)
if (Array.isArray(path)) path = join(...path);
const parsedPath = parse(path);
path = join(parsedPath.dir, sanitize(parsedPath.base));

// Fetch the cover before opening the file so a network failure can't corrupt it
let cover: Picture | undefined;
if (coverUrl) {
try {
const res = await fetch(coverUrl);
if (res.ok) {
cover = Picture.fromData(ByteVector.fromByteArray(Buffer.from(await res.arrayBuffer())));
cover.type = PictureType.FrontCover;
cover.mimeType = res.headers.get("content-type") ?? "image/jpeg";
}
} catch {}
}

let file: TagFile | undefined;
try {
file = TagFile.createFromPath(path);
const tag = file.tag;

const trySet = (set: () => void) => {
try {
set();
} catch {}
};

const title = first(tags.title);
if (title) trySet(() => (tag.title = title));

const performers = asArray(tags.artist);
if (performers.length) trySet(() => (tag.performers = performers));

const albumArtists = asArray(tags.albumArtist);
if (albumArtists.length) trySet(() => (tag.albumArtists = albumArtists));

const album = first(tags.album);
if (album) trySet(() => (tag.album = album));

const year = yearFrom(tags);
if (year) trySet(() => (tag.year = year));

const copyright = first(tags.copyright);
if (copyright) trySet(() => (tag.copyright = copyright));

const comment = first(tags.comment);
if (comment) trySet(() => (tag.comment = comment));

const genres = asArray(tags.genres);
if (genres.length) trySet(() => (tag.genres = genres));

const trackNumber = asInt(first(tags.trackNumber));
if (trackNumber) trySet(() => (tag.track = trackNumber));

const totalTracks = asInt(first(tags.totalTracks));
if (totalTracks) trySet(() => (tag.trackCount = totalTracks));

const discNumber = asInt(first(tags.discNumber));
if (discNumber) trySet(() => (tag.disc = discNumber));

const bpm = asInt(first(tags.bpm));
if (bpm) trySet(() => (tag.beatsPerMinute = bpm));

const lyrics = first(tags.lyrics);
if (lyrics) trySet(() => (tag.lyrics = lyrics));

const isrc = first(tags.isrc);
if (isrc) trySet(() => (tag.isrc = isrc));

const musicBrainzTrackId = first(tags.musicbrainz_trackid);
if (musicBrainzTrackId) trySet(() => (tag.musicBrainzTrackId = musicBrainzTrackId));

const musicBrainzAlbumId = first(tags.musicbrainz_albumid);
if (musicBrainzAlbumId) trySet(() => (tag.musicBrainzReleaseId = musicBrainzAlbumId));

if (cover) trySet(() => (tag.pictures = [cover!]));

file.save();
} finally {
file?.dispose();
}
};
42 changes: 42 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.