Add support for output templates for video and audio files

filename_format
aandrew-me 4 weeks ago
parent da984110e2
commit 57ee3caed5

@ -210,7 +210,7 @@ input[type="text"],
} }
.prefBox, .prefBox,
#pathConfig { #pathConfig, .outputTemplateItem {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -220,13 +220,32 @@ input[type="text"],
background-color: var(--box-main); background-color: var(--box-main);
} }
.prefBox,
#pathConfig {
background-color: var(--box-main);
}
#ytdlpInfoBox { #ytdlpInfoBox {
margin: 15px 15px; margin: 15px 15px;
padding: 20px 15px; padding: 20px 15px;
border-radius: 15px; border-radius: 15px;
background-color: var(--box-main); background-color: var(--box-main);
} }
#outputTemplateTxt {
padding: 15px 0 15px 30px;
margin:0;
font-weight: bold;
font-size: 30px;
}
.outputTemplateItem {
background-color: var(--box-separation);
}
#outputTemplateBox {
margin: 15px 15px;
padding: 10px 15px;
border-radius: 15px;
background-color: var(--box-main);
}
.ytdlpInfoItem { .ytdlpInfoItem {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -244,7 +263,8 @@ input[type="text"],
border-radius: 15px; border-radius: 15px;
background-color: var(--box-separation); background-color: var(--box-separation);
} }
#path, .path { #path,
.path {
font-family: "JetBrains"; font-family: "JetBrains";
font-size: medium; font-size: medium;
} }

@ -156,7 +156,7 @@
<div id="ytDlpArgBox"> <div id="ytDlpArgBox">
<p> <p>
<span data-translate="customArgsTxt">Set custom yt-dlp arguments.</span> <span data-translate="customArgsTxt">Set custom yt-dlp arguments.</span>
<a data-translate="learnMore" id="learnMoreLink">Learn more</a> <a data-translate="learnMore" id="learnMoreYtdlpArgs">Learn more</a>
</p> </p>
<textarea spellcheck="false" id="customArgsInput" placeholder="--sponsorblock-remove all"></textarea> <textarea spellcheck="false" id="customArgsInput" placeholder="--sponsorblock-remove all"></textarea>
</div> </div>
@ -184,20 +184,40 @@
<input type="checkbox" class="cb" id="showMoreFormats"> <input type="checkbox" class="cb" id="showMoreFormats">
</div> </div>
<div class="prefBox"> <div id="outputTemplateBox">
<span id="fileFormatTxt" data-translate="playlistFilenameFormat">Filename format for playlists</span> <p id="outputTemplateTxt"><span data-translate="outputTemplates">Output Templates.</span> <a
<input type="text" id="filenameFormat" placeholder="yt-dlp format style" id="learnMoreOutputTemplates" data-translate="learnMore">Learn more</a></p>
value="%(playlist_index)s.%(title)s.%(ext)s"> <div class="outputTemplateItem">
<button class="redBtn" id="resetFilenameFormat" data-translate="resetToDefault">Reset to default</button> <span data-translate="audioFilenameTemplate">Filename template for audios</span>
</div> <input type="text" id="filenameTemplateAudio" placeholder="yt-dlp format style" value="%(title)s.%(ext)s">
<button class="redBtn" id="resetAudioFilenameTemplate" data-translate="resetToDefault">Reset to
default</button>
</div>
<div class="prefBox"> <div class="outputTemplateItem">
<span id="dirFormatTxt" data-translate="playlistFolderNameFormat">Folder name format for playlists</span> <span data-translate="videoFilenameTemplate">Filename template for videos</span>
<input type="text" id="foldernameFormat" value="%(playlist_title)s" placeholder="yt-dlp format style"> <input type="text" id="filenameTemplateVideo" placeholder="yt-dlp format style" value="%(title)s.%(ext)s">
<button class="redBtn" id="resetFoldernameFormat" data-translate="resetToDefault">Reset to default</button> <button class="redBtn" id="resetFilenameTemplateVideo" data-translate="resetToDefault">Reset to
default</button>
</div>
<div class="outputTemplateItem">
<span id="fileFormatTxt" data-translate="playlistFilenameFormat">Filename format for playlists</span>
<input type="text" id="filenameFormat" placeholder="yt-dlp format style"
value="%(playlist_index)s.%(title)s.%(ext)s">
<button class="redBtn" id="resetFilenameFormat" data-translate="resetToDefault">Reset to default</button>
</div>
<div class="outputTemplateItem">
<span id="dirFormatTxt" data-translate="playlistFolderNameFormat">Folder name format for playlists</span>
<input type="text" id="foldernameFormat" value="%(playlist_title)s" placeholder="yt-dlp format style">
<button class="redBtn" id="resetFoldernameFormat" data-translate="resetToDefault">Reset to default</button>
</div>
</div> </div>
<div class="prefBox"> <div class="prefBox">
<span id="maxTxt" data-translate="maxActiveDownloads">Maximum number of active downloads</span> <span id="maxTxt" data-translate="maxActiveDownloads">Maximum number of active downloads</span>
<input type="number" min="1" class="input" id="maxDownloads" value="5"> <input type="number" min="1" class="input" id="maxDownloads" value="5">

@ -285,12 +285,18 @@ ytDlpArgsInput.addEventListener("input", () => {
ytDlpArgsInput.style.height = ytDlpArgsInput.scrollHeight + "px"; ytDlpArgsInput.style.height = ytDlpArgsInput.scrollHeight + "px";
}); });
getId("learnMoreLink").addEventListener("click", () => { getId("learnMoreYtdlpArgs").addEventListener("click", () => {
shell.openExternal( shell.openExternal(
"https://github.com/aandrew-me/ytDownloader/wiki/Custom-yt%E2%80%90dlp-options", "https://github.com/aandrew-me/ytDownloader/wiki/Custom-yt%E2%80%90dlp-options",
); );
}); });
getId("learnMoreOutputTemplates").addEventListener("click", () => {
shell.openExternal(
"https://github.com/yt-dlp/yt-dlp#output-template",
);
});
// Reload // Reload
function reload() { function reload() {
ipcRenderer.send("reload"); ipcRenderer.send("reload");
@ -299,7 +305,43 @@ getId("restart").addEventListener("click", () => {
reload(); reload();
}); });
// Handling filename formats // Handling filename formats for videos
getId("filenameTemplateVideo").addEventListener("input", () => {
const text = getId("filenameTemplateVideo").value;
localStorage.setItem("filenameTemplateVideo", text);
});
if (localStorage.getItem("filenameTemplateVideo")) {
getId("filenameTemplateVideo").value = localStorage.getItem("filenameTemplateVideo");
}
getId("resetFilenameTemplateVideo").addEventListener("click", () => {
getId("filenameTemplateVideo").value = "%(title)s.%(ext)s";
localStorage.setItem(
"filenameTemplateVideo",
"%(title)s.%(ext)s"
);
});
// Handling filename formats for audios
getId("filenameTemplateAudio").addEventListener("input", () => {
const text = getId("filenameTemplateAudio").value;
localStorage.setItem("filenameTemplateAudio", text);
});
if (localStorage.getItem("filenameTemplateAudio")) {
getId("filenameTemplateAudio").value = localStorage.getItem("filenameTemplateAudio");
}
getId("resetAudioFilenameTemplate").addEventListener("click", () => {
getId("filenameTemplateAudio").value = "%(title)s.%(ext)s";
localStorage.setItem(
"filenameTemplateAudio",
"%(title)s.%(ext)s"
);
});
// Handling filename formats for playlists
getId("filenameFormat").addEventListener("input", () => { getId("filenameFormat").addEventListener("input", () => {
const text = getId("filenameFormat").value; const text = getId("filenameFormat").value;
localStorage.setItem("filenameFormat", text); localStorage.setItem("filenameFormat", text);

@ -130,6 +130,8 @@ class YtDownloaderApp {
proxy: "", proxy: "",
browserForCookies: "", browserForCookies: "",
customYtDlpArgs: "", customYtDlpArgs: "",
videoOutputTemplate: "%(title)s.%(ext)s",
audioOutputTemplate: "%(title)s.%(ext)s",
}, },
downloadControllers: new Map(), downloadControllers: new Map(),
downloadedItems: new Set(), downloadedItems: new Set(),
@ -674,6 +676,13 @@ class YtDownloaderApp {
prefs.configPath = prefs.configPath =
localStorage.getItem(CONSTANTS.LOCAL_STORAGE_KEYS.CONFIG_PATH) || localStorage.getItem(CONSTANTS.LOCAL_STORAGE_KEYS.CONFIG_PATH) ||
""; "";
prefs.videoOutputTemplate =
localStorage.getItem("filenameTemplateVideo") ||
"%(title)s.%(ext)s";
prefs.audioOutputTemplate =
localStorage.getItem("filenameTemplateAudio") ||
"%(title)s.%(ext)s";
const maxDownloads = Number( const maxDownloads = Number(
localStorage.getItem(CONSTANTS.LOCAL_STORAGE_KEYS.MAX_DOWNLOADS), localStorage.getItem(CONSTANTS.LOCAL_STORAGE_KEYS.MAX_DOWNLOADS),
@ -990,8 +999,7 @@ class YtDownloaderApp {
this.state.currentDownloads++; this.state.currentDownloads++;
const randomId = "item_" + Math.random().toString(36).substring(2, 12); const randomId = "item_" + Math.random().toString(36).substring(2, 12);
const {downloadArgs, finalFilename, finalExt} = const {downloadArgs, tempFilePath} = this._prepareDownloadArgs(job);
this._prepareDownloadArgs(job);
this._createDownloadUI(randomId, job); this._createDownloadUI(randomId, job);
@ -1009,7 +1017,31 @@ class YtDownloaderApp {
downloadProcess.ytDlpProcess.spawnargs.join(" "), downloadProcess.ytDlpProcess.spawnargs.join(" "),
); );
// Attach event listeners let actualFilePath = "";
downloadProcess.ytDlpProcess.stdout.on("data", (data) => {
const str = data.toString();
const destMatch = str.match(/Destination:\s+([^\r\n]+)/);
if (destMatch)
actualFilePath = destMatch[1]
.trim()
.replace(/(^["'])|(["']$)/g, "");
const mergeMatch = str.match(/Merging formats into\s+([^\r\n]+)/);
if (mergeMatch)
actualFilePath = mergeMatch[1]
.trim()
.replace(/(^["'])|(["']$)/g, "");
const existMatch = str.match(
/\[download\]\s+([^\r\n]+)\s+has already been downloaded/,
);
if (existMatch)
actualFilePath = existMatch[1]
.trim()
.replace(/(^["'])|(["']$)/g, "");
});
downloadProcess downloadProcess
.on("progress", (progress) => { .on("progress", (progress) => {
this._updateProgressUI(randomId, progress); this._updateProgressUI(randomId, progress);
@ -1018,22 +1050,36 @@ class YtDownloaderApp {
const el = $(`${randomId}_prog`); const el = $(`${randomId}_prog`);
if (el) el.textContent = i18n.__("downloading"); if (el) el.textContent = i18n.__("downloading");
}) })
// .on("ytDlpEvent", (eventType, eventData) => {
// console.log(eventData)
// })
.once("close", (code) => { .once("close", (code) => {
if (existsSync(tempFilePath)) {
try {
const fileContent = require("fs")
.readFileSync(tempFilePath, "utf-8")
.trim();
if (fileContent) {
actualFilePath = fileContent;
}
require("fs").unlinkSync(tempFilePath);
} catch (e) {
console.error("Error reading temp file:", e);
}
}
this._handleDownloadCompletion( this._handleDownloadCompletion(
code, code,
randomId, randomId,
finalFilename, actualFilePath,
finalExt,
job.thumbnail, job.thumbnail,
); );
}) })
.once("error", (error) => { .once("error", (error) => {
if (existsSync(tempFilePath)) {
try {
require("fs").unlinkSync(tempFilePath);
} catch (e) {}
}
this.state.downloadedItems.add(randomId); this.state.downloadedItems.add(randomId);
this._updateClearAllButton(); this._updateClearAllButton();
this._handleDownloadError(error, randomId); this._handleDownloadError(error, randomId);
}); });
} }
@ -1084,28 +1130,32 @@ class YtDownloaderApp {
/** /**
* Prepares the command-line arguments for yt-dlp based on the download job. * Prepares the command-line arguments for yt-dlp based on the download job.
* @param {object} job The download job object. * @param {object} job The download job object.
* @returns {{downloadArgs: string[], finalFilename: string, finalExt: string}} * @returns {{downloadArgs: string[], tempFilePath: string}}
*/ */
_prepareDownloadArgs(job) { _prepareDownloadArgs(job) {
const {type, url, title, options, uiSnapshot} = job; const {type, url, title, options, uiSnapshot} = job;
const {rangeOption, rangeCmd, subs, subLangs} = options; const {rangeOption, rangeCmd, subs, subLangs} = options;
const {proxy, browserForCookies, configPath} = this.state.preferences; const {
proxy,
browserForCookies,
configPath,
videoOutputTemplate,
audioOutputTemplate,
} = this.state.preferences;
let format_id, ext, audioForVideoFormat_id, audioFormat; let format_id, ext, audioForVideoFormat_id, audioFormat;
let template = videoOutputTemplate;
if (type === "video") { if (type === "video") {
const [videoFid, videoExt, _, videoCodec] = const [videoFid, videoExt, _, videoCodec] =
uiSnapshot.videoFormat.split("|"); uiSnapshot.videoFormat.split("|");
const [audioFid, audioExt] = const [audioFid, audioExt] =
uiSnapshot.audioForVideoFormat.split("|"); uiSnapshot.audioForVideoFormat.split("|");
format_id = videoFid; format_id = videoFid;
audioForVideoFormat_id = audioFid; audioForVideoFormat_id = audioFid;
const finalAudioExt = audioExt === "webm" ? "opus" : audioExt; const finalAudioExt = audioExt === "webm" ? "opus" : audioExt;
ext = videoExt; ext = videoExt;
if (videoExt === "mp4" && finalAudioExt === "opus") { if (videoExt === "mp4" && finalAudioExt === "opus") {
if (videoCodec.includes("avc")) ext = "mkv"; if (videoCodec.includes("avc")) ext = "mkv";
else if (videoCodec.includes("av01")) ext = "webm"; else if (videoCodec.includes("av01")) ext = "webm";
@ -1115,7 +1165,6 @@ class YtDownloaderApp {
) { ) {
ext = "mkv"; ext = "mkv";
} }
audioFormat = audioFormat =
audioForVideoFormat_id === "none" audioForVideoFormat_id === "none"
? "" ? ""
@ -1123,32 +1172,36 @@ class YtDownloaderApp {
} else if (type === "audio") { } else if (type === "audio") {
[format_id, ext] = uiSnapshot.audioFormat.split("|"); [format_id, ext] = uiSnapshot.audioFormat.split("|");
ext = ext === "webm" ? "opus" : ext; ext = ext === "webm" ? "opus" : ext;
template = audioOutputTemplate;
} else { } else {
// type === 'extract'
ext = ext =
{alac: "m4a"}[uiSnapshot.extractFormat] || {alac: "m4a"}[uiSnapshot.extractFormat] ||
uiSnapshot.extractFormat; uiSnapshot.extractFormat;
}
const invalidChars = template = audioOutputTemplate;
platform() === "win32" ? /[<>:"/\\|?*[\]`#]/g : /["/`#]/g;
let finalFilename = title
.replace(invalidChars, "")
.trim()
.slice(0, 100);
if (finalFilename.startsWith(".")) {
finalFilename = finalFilename.substring(1);
} }
if (rangeCmd) { if (rangeCmd) {
let rangeTxt = rangeCmd.replace("*", ""); let rangeTxt = rangeCmd.replace("*", "");
if (platform() === "win32") rangeTxt = rangeTxt.replace(/:/g, "_"); if (platform() === "win32") rangeTxt = rangeTxt.replace(/:/g, "_");
finalFilename += ` [${rangeTxt}]`;
if (template.includes(".%(ext)s")) {
template = template.replace(
".%(ext)s",
` [${rangeTxt}].%(ext)s`,
);
} else {
template += ` [${rangeTxt}]`;
}
} }
const outputPath = `"${join( const outputArgs = [
this.state.downloadDir, "-P",
`${finalFilename}.${ext}`, `"${this.state.downloadDir}"`,
)}"`; "-o",
`"${template}"`,
];
const baseArgs = [ const baseArgs = [
"--no-playlist", "--no-playlist",
@ -1184,14 +1237,13 @@ class YtDownloaderApp {
uiSnapshot.extractFormat, uiSnapshot.extractFormat,
"--audio-quality", "--audio-quality",
uiSnapshot.extractQuality, uiSnapshot.extractQuality,
"-o", ...outputArgs,
outputPath,
...baseArgs, ...baseArgs,
]; ];
} else { } else {
const formatString = const formatString =
type === "video" ? `${format_id}${audioFormat}` : format_id; type === "video" ? `${format_id}${audioFormat}` : format_id;
downloadArgs = ["-f", formatString, "-o", outputPath, ...baseArgs]; downloadArgs = ["-f", formatString, ...outputArgs, ...baseArgs];
} }
if (subs) downloadArgs.push(subs); if (subs) downloadArgs.push(subs);
@ -1206,24 +1258,47 @@ class YtDownloaderApp {
downloadArgs.push(...customArgs); downloadArgs.push(...customArgs);
} }
const randomId = "item_" + Math.random().toString(36).substring(2, 12);
const sanitizedTitle = (job.title || "Unknown Title").replace(
/"/g,
"'",
);
downloadArgs.push(
"--replace-in-metadata",
"title",
`"^.*$"`,
`"${sanitizedTitle}"`,
);
// Create a unique temporary file path to capture the exact filename from yt-dlp
const tmpDir = require("os").tmpdir();
const tempFilePath = join(tmpDir, `ytdlp_path_${randomId}.txt`);
// Tell yt-dlp to output the absolute final file path directly to the temp file
downloadArgs.push(
"--print-to-file",
"after_move:filepath",
`"${tempFilePath}"`,
);
downloadArgs.push(`"${url}"`); downloadArgs.push(`"${url}"`);
return {downloadArgs, finalFilename, finalExt: ext}; return {downloadArgs, tempFilePath};
} }
/** /**
* Handles the completion of a download process. * Handles the completion of a download process.
*/ */
_handleDownloadCompletion(code, randomId, filename, ext, thumbnail) { _handleDownloadCompletion(code, randomId, actualFilePath, thumbnail) {
this.state.currentDownloads--; this.state.currentDownloads--;
this.state.downloadControllers.delete(randomId); this.state.downloadControllers.delete(randomId);
if (code === 0) { if (code === 0) {
this._showDownloadSuccessUI(randomId, filename, ext, thumbnail); this._showDownloadSuccessUI(randomId, actualFilePath, thumbnail);
this.state.downloadedItems.add(randomId); this.state.downloadedItems.add(randomId);
this._updateClearAllButton(); this._updateClearAllButton();
} else if (code !== null) { } else if (code !== null) {
// code is null if aborted, so only show error if it's a real exit code
this._handleDownloadError( this._handleDownloadError(
new Error(`Download process exited with code ${code}.`), new Error(`Download process exited with code ${code}.`),
randomId, randomId,
@ -1464,7 +1539,10 @@ class YtDownloaderApp {
}); });
const hasAudioTrack = formats.some( const hasAudioTrack = formats.some(
(f) => f.acodec !== "none" && f.acodec !== undefined && f.vcodec === "none", (f) =>
f.acodec !== "none" &&
f.acodec !== undefined &&
f.vcodec === "none",
); );
const audioSection = $(CONSTANTS.DOM_IDS.AUDIO_PRESENT_SECTION); const audioSection = $(CONSTANTS.DOM_IDS.AUDIO_PRESENT_SECTION);
@ -1540,7 +1618,7 @@ class YtDownloaderApp {
className: "title", className: "title",
id: CONSTANTS.DOM_IDS.TITLE_INPUT, id: CONSTANTS.DOM_IDS.TITLE_INPUT,
type: "text", type: "text",
value: `${info.title} [${info.id}]`, value: `${info.title}`,
onchange: (e) => (this.state.videoInfo.title = e.target.value), onchange: (e) => (this.state.videoInfo.title = e.target.value),
}), }),
); );
@ -1632,12 +1710,72 @@ class YtDownloaderApp {
/** /**
* Updates a download item's UI to show it has completed successfully. * Updates a download item's UI to show it has completed successfully.
*/ */
_showDownloadSuccessUI(randomId, filename, ext, thumbnail) { _showDownloadSuccessUI(randomId, actualFilePath, thumbnail) {
const progressEl = $(`${randomId}_prog`); const progressEl = $(`${randomId}_prog`);
if (!progressEl) return; if (!progressEl) return;
const fullFilename = `${filename}.${ext}`; let fullPath;
const fullPath = join(this.state.downloadDir, fullFilename); if (actualFilePath) {
const isAbsolute =
actualFilePath.startsWith("/") ||
/^[a-zA-Z]:[\\\/]/.test(actualFilePath);
fullPath = isAbsolute
? actualFilePath
: join(this.state.downloadDir, actualFilePath);
}
if (!fullPath) {
console.error("Could not resolve downloaded file path.");
return;
}
const expectedExt = fullPath.includes(".")
? fullPath.split(".").pop()
: "";
// If file doesn't exist at the expected path, attempt to find it with a loose matching strategy
if (
!existsSync(fullPath) &&
this.state.videoInfo &&
this.state.videoInfo.title
) {
try {
const originalTitle = this.state.videoInfo.title;
const dirFiles = require("fs").readdirSync(
this.state.downloadDir,
);
const looseTitle = originalTitle
.replace(/[^a-zA-Z0-9]/g, "")
.toLowerCase();
const matchedFile = dirFiles.find((f) => {
const cleanF = f.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
return (
cleanF.includes(looseTitle) &&
(expectedExt
? f
.toLowerCase()
.endsWith(expectedExt.toLowerCase())
: true)
);
});
if (matchedFile) {
fullPath = join(this.state.downloadDir, matchedFile);
}
} catch (err) {
console.error("Failed to execute loose filename recovery");
}
}
// Extract just the filename
const fullFilename = fullPath.split(/[\/\\]/).pop();
const baseFilename =
fullFilename.substring(0, fullFilename.lastIndexOf(".")) ||
fullFilename;
const ext = fullFilename.split(".").pop();
progressEl.innerHTML = ""; // Clear progress bar progressEl.innerHTML = ""; // Clear progress bar
const link = document.createElement("b"); const link = document.createElement("b");
@ -1654,7 +1792,7 @@ class YtDownloaderApp {
body: fullFilename, body: fullFilename,
icon: thumbnail, icon: thumbnail,
}).onclick = () => { }).onclick = () => {
shell.showItemInFolder(fullPath); ipcRenderer.send("show-file", fullPath);
}; };
// Add to download history // Add to download history
@ -1666,7 +1804,7 @@ class YtDownloaderApp {
.invoke("add-to-history", { .invoke("add-to-history", {
title: this.state.videoInfo.title, title: this.state.videoInfo.title,
url: this.state.videoInfo.url, url: this.state.videoInfo.url,
filename: filename, filename: baseFilename,
filePath: fullPath, filePath: fullPath,
fileSize: fileSize, fileSize: fileSize,
format: ext, format: ext,

@ -75,8 +75,11 @@
"path": "Path:", "path": "Path:",
"selectConfigFile": "Select config file", "selectConfigFile": "Select config file",
"useConfigFile": "Use configuration file", "useConfigFile": "Use configuration file",
"playlistFilenameFormat": "Filename format for playlists", "outputTemplates": "Output templates.",
"playlistFolderNameFormat": "Folder name format for playlists", "audioFilenameTemplate": "Audio filename template",
"videoFilenameTemplate": "Video filename template",
"playlistFilenameFormat": "Filename template for playlists",
"playlistFolderNameFormat": "Folder name template for playlists",
"resetToDefault": "Reset to default", "resetToDefault": "Reset to default",
"playlistRange": "Playlist range", "playlistRange": "Playlist range",
"thumbnail": "Thumbnail", "thumbnail": "Thumbnail",

Loading…
Cancel
Save