Lucas Vogel

Lucas Vogel

// Name: GitHub Repos
// Description: List GitHub repositories and perform actions on them
// Author: Vogelino
// Twitter: @soyvogelino
import "@johnlindquist/kit";
const { Octokit } = await npm("octokit");
const shodwon = await npm("showdown");
const converter = new shodwon.Converter({
ghMentions: true,
emoji: true,
});
converter.setFlavor("github");
interface RawRepositoryType {
id: string;
name: string;
html_url: string;
full_name: string;
visibility: string[];
description: string;
homepage?: string;
owner: {
login: string;
};
open_issues_count: number;
}
interface RawIssueType {
id: string;
title: string;
body: string;
labels: {
name: string;
}[];
number: string;
user: {
login: string;
};
html_url: string;
}
interface RawBranchName {
id: string;
name: string;
protected: boolean;
}
interface OptionType<ValueType = unknown> {
name: string;
descritpion?: string;
preview?: string;
value: ValueType;
}
const auth = await env(`GITHUB_ACCESS_TOKEN`, "Enter your GitHub access token");
const octokit = new Octokit({ auth });
const {
data: { login },
} = await octokit.rest.users.getAuthenticated();
const universalOptions = [
{
name: "Browse",
description: "Open the url in your default browser",
value: "browse",
},
{
name: "Copy URL",
description: "Copy the URL to the clipboard",
value: "copy",
},
];
const mapRawRepo = (repo: RawRepositoryType) => ({
name: repo.full_name,
description: [
repo.visibility[0].toUpperCase() + repo.visibility.slice(1),
repo.description,
repo.homepage,
]
.filter(Boolean)
.join(" ยท "),
value: repo,
});
const mapReposResponse = (response: { data: RawRepositoryType[] }) =>
(response.data || []).map(mapRawRepo);
async function fetchAllRepos() {
return await octokit.paginate(
octokit.rest.repos.listForAuthenticatedUser,
{ sort: "updated", per_page: 100 },
mapReposResponse
);
}
async function fetchRecentRepos() {
const res = await octokit.request("GET /user/repos", {
sort: "updated",
per_page: 50,
});
return res.data;
}
async function fetchOwnerRepos() {
const res = await octokit.request("GET /user/repos", {
sort: "updated",
per_page: 50,
affiliation: "owner",
});
return res.data;
}
function createIssuePullHandler(key: "pull" | "issue") {
return async (repo: RawRepositoryType) => {
const res = await octokit.request(`GET /repos/{owner}/{repo}/${key}s`, {
owner: repo.owner.login,
repo: repo.name,
pulls: key === "pull",
});
let { data: items } = res as { data: RawIssueType[] };
if (key === "issue") {
const pullsRes = (await octokit.request(
`GET /repos/{owner}/{repo}/pulls`,
{
owner: repo.owner.login,
repo: repo.name,
}
)) as { data: RawIssueType[] };
items = items.filter(
({ number }) => !pullsRes.data.find((p) => p.number === number)
);
}
if (items.length === 0) {
await div(`<div class="p-4 bg-white">No ${key}s</div>`);
return;
}
const itemSelected = await arg(
`Search for ${key}s`,
items.map((i) => ({
name: i.title,
description: [
`#${i.number}`,
i.user.login,
i.labels.map((l) => l.name).join(", "),
]
.filter(Boolean)
.join(" ยท "),
preview: `
<style>p,h1,h2,h3 { margin-bottom: 8px; }</style>
<div class="p-4 bg-white">
<h1>${i.title}</h1>
<small>By @${i.user.login} ยท ${i.labels
.map((l) => l.name)
.join(", ")}</small>
<hr/><br />
${i?.body ? converter.makeHtml(i.body) : "โ€“"}
</div>
`,
value: i,
}))
);
const action = await arg("Select an action to perform", [
...universalOptions,
{
name: `Copy ${key} number`,
description: `Copy ${key} number to clipboard for reference elswhere (eg. branch-name)`,
value: "number",
},
{
name: `Close ${key}`,
description: `Close ${key}`,
value: "close",
},
]);
switch (action) {
case "browse":
await browse(itemSelected.html_url);
exit();
case "copy":
await copy(itemSelected.html_url);
exit();
case "number":
await copy(`${itemSelected.number}`);
exit();
case "close":
await await octokit.request(
`PATCH /repos/{owner}/{repo}/${key}s/{${key}_number}`,
{
owner: repo.owner.login,
repo: repo.name,
[`${key}_number`]: itemSelected.number,
state: "closed",
}
);
break;
}
notify(`${action} successful`);
const nextHanlder = createIssuePullHandler(key);
await nextHanlder(repo);
};
}
async function getAllBranches(repo: RawRepositoryType) {
const { data } = await octokit.request(`GET /repos/{owner}/{repo}/branches`, {
owner: repo.owner.login,
repo: repo.name,
});
const items = data as RawBranchName[];
if (items.length === 0) {
await div(`<div class="p-4 bg-white">No branches</div>`);
return;
}
const branchSelected = await arg(
`Search for branches`,
items.map((b) => ({
name: b.name,
description: b.protected ? "Protected" : undefined,
value: b,
}))
);
const action = await arg("Select an action to perform", [
{
name: `Copy name`,
description: `Copy name to clipboard for reference elswhere (eg. in issue)`,
value: "name",
},
{
name: `Rename`,
value: "rename",
},
]);
switch (action) {
case "name":
await copy(branchSelected.name);
exit();
case "rename":
const newName = await arg(
`What should the new name be? (was ${branchSelected.name})`
);
await octokit.request(
`POST /repos/{owner}/{repo}/branches/{branch}/rename`,
{
owner: repo.owner.login,
repo: repo.name,
branch: branchSelected.name,
new_name: newName,
}
);
break;
}
notify(`${action} successful`);
await getAllBranches(repo);
}
const issueHandler = createIssuePullHandler(`issue`);
const pullHandler = createIssuePullHandler(`pull`);
function getTabHandler(getter: () => Promise<OptionType<RawRepositoryType>[]>) {
return async function handler() {
const repos = await getter();
const repoSelected = await arg(`Hello ${login}. Search for a repo`, repos);
if (repos.length === 0) {
await div(`<div class="p-4 bg-white">No repos</div>`);
await handler();
}
const action = await arg(
"Select an action to perform",
[
...universalOptions,
repoSelected.open_issues_count > 0 && {
name: "Recent issues",
description: "List top 10 most recent open issues",
value: "issues",
},
{
name: "Pull Requests",
description: "List top 10 most recent PRs",
value: "prs",
},
{
name: "Branches",
description: "List branches",
value: "branches",
},
].filter(Boolean)
);
switch (action) {
case "browse":
await browse(repoSelected.html_url);
exit();
case "copy":
await copy(repoSelected.html_url);
exit();
case "issues":
await issueHandler(repoSelected);
break;
case "prs":
await pullHandler(repoSelected);
break;
case "branches":
await getAllBranches(repoSelected);
break;
}
notify(`${action} successful`);
await handler();
};
}
const recentTab = getTabHandler(fetchRecentRepos);
onTab("Recent", recentTab);
onTab("Owner", getTabHandler(fetchOwnerRepos));
onTab("All", getTabHandler(fetchAllRepos));
await recentTab();
// Name: Change case of selected text
// Description: Choose a transformation method (eg. camelCase) to transform the selected text in-place (Or some text input)
// Author: Vogelino
// Twitter: @soyvogelino
import "@johnlindquist/kit";
const changeCase = await npm("change-case");
let text = await getSelectedText();
if (text.length <= 1) {
text = await arg("No text selected. What text should be transformed?");
}
const method = await arg(
"What transformation would you like to apply to your text?",
[
{
name: "Camel",
description: `test string -> testString`,
value: "camelCase",
},
{
name: "Capital",
description: `test string -> Test String`,
value: "capitalCase",
},
{
name: "Constant",
description: `test string -> TEST_STRING`,
value: "constantCase",
},
{
name: "Dot",
description: `test string -> test.string`,
value: "dotCase",
},
{
name: "Header",
description: `test string -> Test-String`,
value: "headerCase",
},
{
name: "Lower Case",
description: `test string -> test string`,
value: "noCase",
},
{
name: "Slugify (dashes)",
description: `test string -> test-string`,
value: "paramCase",
},
{
name: "Pascal",
description: `test string -> TestString`,
value: "pascalCase",
},
{
name: "Path",
description: `test string -> test/string`,
value: "pathCase",
},
{
name: "Sentence",
description: `test string -> Test string`,
value: "sentenceCase",
},
{
name: "Snake (underscore)",
description: `test string -> test_string`,
value: "snakeCase",
},
]
);
if (!method in changeCase) {
notify("The selected method '${method}' is not available");
} else {
const transformedText = changeCase[method](text);
await Promise.all([setSelectedText(transformedText), copy(transformedText)]);
notify(`The text was pasted and copied to the clipboard!`);
}
// Name: Get Tabler Icons
// Description: Copies a Tabler Icon from tabler open-sourced repository.
// Author: Lucas Vogel
// Twitter: @soyvogelino
import "@johnlindquist/kit";
const iconsPath = "https://tabler-icons.io/icons.json";
let files = [];
try {
let response = await get(iconsPath);
files = response.data;
} catch (error) {
notify(error);
}
const titleCase = (s) =>
s
.toLowerCase()
.split("-")
.map((w) => w[0].toUpperCase() + w.slice(1))
.join(" ");
const options = files.map((file, idx) => {
return {
name: titleCase(file.n),
description: file.t.split(" ").join(", "),
value: file,
preview: `
<table class="w-full">
<tr>
<td class="p-8">
<div className="m-8">${file.s}</div>
</td>
<td class="p-8">
<div>${file.s.replaceAll('"24"', '"100"')}</div>
</td>
<tr>
<td class="p-8 bg-black text-white">
<div className="m-8">${file.s}</div>
</td>
<td class="p-8 bg-black text-white">
<div>${file.s.replaceAll('"24"', '"100"')}</div>
</td>
</tr>
</tr>
</table>
`,
};
});
const icon = await arg("Search for an icon:", options);
try {
await copy(icon.s);
notify(`Copied ${titleCase(file.n)} icon to clipboard`);
} catch (error) {
notify(error);
}
// Name: Unsplash
// Description: Search unsplash for the perfect image to download
// Author: Vogelino
// Twitter: @soyvogelino
import "@johnlindquist/kit";
const { createApi } = await npm("unsplash-js");
const accessKey = await env(
"UNSPLASH_ACCESS_KEY",
"Enter your unsplash Access Key"
);
const downloadPath = await env(
"UNSPLASH_DOWNLOAD_PATH",
"Enter the path where to download the images"
);
const headers = {
"User-Agent": "scriptkit/unsplash",
};
const api = createApi({
accessKey,
});
const query = await arg("What do you want to search");
const photosRes = await api.search.getPhotos({
query,
page: 1,
perPage: 10,
});
if (photosRes.errors) {
console.log("error occurred: ", photosRes.errors[0]);
} else {
const { results } = photosRes.response;
const options = results.map((photo) => ({
name: photo.description || photo.alt_description || "Untitled",
description: `${photo.user.name} โ€“ ${photo.width} x ${photo.height} โ€“ ${photo.likes} Likes`,
preview: `
<style>.image { object-fit: contain; height: 80vh; }</style>
<img class="w-screen image" src="${photo.urls.regular}" />
`,
value: photo,
}));
const selectedPhoto = await arg("Select a photo to copy", options);
const buffer = await download(selectedPhoto.urls.raw, headers);
const filePath = `${downloadPath}/unsplash-image-${selectedPhoto.id}.png`;
await writeFile(filePath, buffer);
notify(`Successfully downloaded file: ${filePath}`);
}

// Name: Todoist
// Description: Create and browse your Todoist Tasks
// Author: Vogelino
// Twitter: @soyvogelino
import "@johnlindquist/kit";
const { TodoistApi } = await npm("@doist/todoist-api-typescript");
const { formatRelative } = await npm("date-fns");
interface ProjectType {
id: string;
name: string;
}
interface TaskType {
id: string;
content: string;
order: number;
due?: {
datetime: string;
};
}
const today = new Date();
const fromNow = (date: string) => formatRelative(new Date(date), today);
function sortTaskByDueDateOrOrder(a: TaskType, b: TaskType) {
const dateA = a.due?.datetime
? new Date(a.due?.datetime).getTime()
: Infinity;
const dateB = b.due?.datetime
? new Date(b.due?.datetime).getTime()
: Infinity;
return dateA - dateB || a.order - b.order;
}
const apiKey = await env(
"TODOIST_API_KEY",
"Please enter your Todoist API key"
);
const api = new TodoistApi(apiKey);
const allProjects = (await api.getProjects()) as ProjectType[];
const projectTabs = [{ id: undefined, name: "All" }, ...allProjects].reduce(
(acc, project) => ({
...acc,
[project.name]: async function (tasks?: TaskType[]) {
if (!tasks) {
tasks = (await api.getTasks(
project.id ? { project_id: project.id } : undefined
)) as TaskType[];
}
const options = tasks.sort(sortTaskByDueDateOrOrder).map((task) => ({
name: task.content,
description: task.due?.datetime
? fromNow(task.due?.datetime)
: undefined,
value: task.id,
}));
const taskIdToComplete = await arg(
`Search for a task in "${project.name}"`,
options
);
await api.closeTask(taskIdToComplete);
await projectTabs[project.name](
tasks.filter((t) => t.id !== taskIdToComplete)
);
},
}),
{}
);
const newTask = async () => {
const content = await arg("What is your task about?");
const dueString = await arg("Enter the due date or leave empty");
const projectId = await arg(
"In which project? (leave empty for Inbox)",
allProjects.map((project) => ({
name: project.name,
value: project.id,
}))
);
try {
await api.addTask({
content,
dueString,
dueLang: "en",
project_id: projectId,
});
notify(`The task "${content}" was added!`);
} catch (err) {
await div(`<p class="m-4 px-6 py-2 rounded bg-white border" style="border-color: red;">
๐Ÿ”ด The task could not be added: <code class="inline">${err.responseData}</code>
</p>`);
}
await newTask();
};
onTab("New", newTask);
Object.keys(projectTabs).forEach((projectName) => {
onTab(projectName, projectTabs[projectName]);
});
// Name: Search for YouTube Videos
// Description: Prompts for a search string and lists all youtube videos found (with preview)
// Author: Vogelino
// Twitter: @soyvogelino
// Shortcut: opt y
import "@johnlindquist/kit";
const apiKey = await env("YOUTUBE_API_KEY");
const previewAudioOn =
(await env(
"YOUTUBE_VIDEO_SEARCH_PREVIEW_AUDIO_ON",
"Would you like the video preview to run with sound? [y/n]"
)) === "y";
const previewAutoplayOn =
(await env(
"YOUTUBE_VIDEO_SEARCH_PREVIEW_AUTOPLAY_ON",
"Would you like the video preview to play automatically? [y/n]"
)) === "y";
const searchYoutubeVideos = async (searchString) => {
if (!searchString) throw new Error(`No search string entered`);
const ytUrl = new URL(`https://www.googleapis.com/youtube/v3/search`);
ytUrl.searchParams.set("key", apiKey);
ytUrl.searchParams.set("q", searchString);
ytUrl.searchParams.set("part", "snippet");
const { data } = await get(ytUrl.toString());
return data.items;
};
try {
let searchString = await arg("Enter your search");
let videos = await searchYoutubeVideos(searchString);
while (videos.length === 0) {
searchString = await arg("No results. Enter a new search");
videos = await searchYoutubeVideos(searchString);
}
const videoId = await arg(
"Select a video to open",
videos.map(({ snippet, id }) => ({
name: snippet.title,
description:
snippet.description.length > 50
? `${snippet.description.slice(0, 50)}...`
: snippet.description,
preview: () => `
<div style="padding: 16px">
<table style="margin-bottom: 8px;">
<tr>
<td width="96px">
<img
src="${snippet.thumbnails.default.url}"
width="80px"
style="float: left; margin-right: 16px"
/>
</td>
<td><h3>${snippet.title}</h3></td>
</tr>
</table>
<iframe
src="http://www.youtube.com/embed/${id.videoId}?autoplay=${
previewAudioOn ? 1 : 0
}"
width="560"
height="315"
frameborder="0"
${previewAutoplayOn && "autoplay"}
style="aspect-ratio: 16 / 9; width: 100%;"
></iframe>
</div>
`,
value: id.videoId,
}))
);
browse(`https://www.youtube.com/watch?v=${videoId}`);
} catch (err) {
console.log(err);
notify(err);
}

// Name: New Jitsi Meeting URL
// Description: Copies a random Jitsi Meeting URL to clipboard and offers to open it
// Author: Vogelino
// Twitter: @soyvogelino
import "@johnlindquist/kit";
const { hri } = await npm("human-readable-ids");
const newMeetingSlug = hri.random();
const newMeetingUrl = `https://meet.jit.si/${newMeetingSlug}`;
await copy(newMeetingUrl);
const shouldBrowse = await arg("Do you want to open the link?", [
{ name: "No", description: "๐Ÿ’พ - just copy", value: false },
{ name: "Yes", description: "๐Ÿ’พ + โ†—๏ธ - copy and open", value: true },
]);
shouldBrowse && browse(newMeetingUrl);
<img width="170" alt="Screenshot 2022-09-13 at 23 01 46" src="https://user-images.githubusercontent.com/2759340/190008408-7b976dcc-4310-4711-9512-d33fd3425f61.png"> Creates a little sticky widget to play NTS live on either channel 1 or 2
// Name: NTS Live
// Description: Stream NTS Live Channel 1 or 2
// Author: Vogelino
// Twitter: @soyvogelino
// Shortcut: cmd '
import "@johnlindquist/kit";
const PLAYER_HEIGHT = 56;
const PLAYER_WIDTH = 170;
const getLogoSVG = (color) => `
<svg width="32px" height="32px" style="fill:${color};" viewBox="0 0 26 26"><path d="M22.7 6.9L22.3 9h-1.5l.5-2c.1-.6.1-1.1-.6-1.1s-1 .5-1.1 1.1l-.4 1.7c-.1.5-.1 1 0 1.5l1.4 4.1c.2.6.3 1.3.1 2l-.6 2.6c-.4 1.5-1.5 2.4-2.9 2.4-1.6 0-2.3-.7-1.9-2.4l.5-2.2h1.5l-.5 2.1c-.2.8 0 1.2.7 1.2.6 0 1-.5 1.2-1.2l.5-2.3c.1-.5.1-1.1-.1-1.6l-1.3-3.8c-.2-.7-.3-1.2-.2-2.1l.4-2c.4-1.6 1.4-2.4 2.9-2.4 1.7 0 2.2.8 1.8 2.3zM11.2 21.1L14.6 6H13l.3-1.3h4.8L17.8 6h-1.7l-3.4 15.1h-1.5zm-4.5 0L8.1 6.6 4.8 21.1H3.5L7.2 4.8h2.2L8 18.7l3.2-14h1.3L8.8 21.1H6.7zM0 26h26V0H0v26z"></path></svg>
`;
const getCloseIconSVG = (color) => `
<svg id="x" class="ml-1" width="32px" height="32px" viewBox="0 0 24 24" fill="${color}" class="pointer-event-none">
<path id="x" fill-rule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm-1.72 6.97a.75.75 0 10-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 101.06 1.06L12 13.06l1.72 1.72a.75.75 0 101.06-1.06L13.06 12l1.72-1.72a.75.75 0 10-1.06-1.06L12 10.94l-1.72-1.72z" clip-rule="evenodd"/>
</svg>
`;
const btnCommonClasses = `px-3 text-xl cursor-default`;
const getJavascriptCode = () => `
const channels = {
one: "https://stream-relay-geo.ntslive.net/stream2?client=NTSWebApp&t=1663096772944",
two: "https://stream-relay-geo.ntslive.net/stream?client=NTSWebApp&t=1663095867072",
};
let currentChannel = channels.one;
function setChannel(channelKey) {
const audio = document.getElementById('audioPlayer');
const source = document.getElementById('audioSource');
currentChannel = channels[channelKey];
source.src = currentChannel;
audio.load();
audio.play();
}
function setActiveClasses(btnEl) {
btnEl.classList.remove("text-white")
btnEl.classList.add("bg-white")
btnEl.classList.add("text-black")
}
function setInactiveClasses(btnEl) {
btnEl.classList.add("text-white")
btnEl.classList.remove("bg-white")
btnEl.classList.remove("text-black")
}
const btnOne = document.getElementById('channelOneButton');
btnOne.addEventListener('click', () => {
setActiveClasses(btnOne);
setInactiveClasses(btnTwo);
setChannel("one");
}, false);
const btnTwo = document.getElementById('channelTwoButton');
btnTwo.addEventListener('click', () => {
setActiveClasses(btnTwo);
setInactiveClasses(btnOne);
setChannel("two");
}, false);
setActiveClasses(btnOne);
setInactiveClasses(btnTwo);
setChannel("one");
`;
const wgt = await widget(
`
<div class="bg-black p-4 text-white">
<table>
<tr>
<td width="32px" class="pr-2">${getLogoSVG("white")}</td>
<td id="channelOneButton" class="${btnCommonClasses}">1</td>
<td id="channelTwoButton" class="${btnCommonClasses}">2</td>
<td id="x">${getCloseIconSVG("white")}</td>
</tr>
</table>
<audio id="audioPlayer" class="hidden" controls controlslist="nofullscreen nodownload noremoteplayback noplaybackrate">
<source id="audioSource">
</audio>
</div>
<script type="text/javascript">${getJavascriptCode()}</script>
`,
{ alwaysOnTop: true, state: { channelKey: "one" } }
);
wgt.setSize(PLAYER_WIDTH, PLAYER_HEIGHT);
wgt.onClick((event) => event.targetId === "x" && wgt.close());
wgt.onResized(() => wgt.setSize(PLAYER_WIDTH, PLAYER_HEIGHT));
// Name: Download YouTube Video
// Description: Download a video from a YouTube URL as .mp4
// Author: Vogelino
// Twitter: @soyvogelino
import "@johnlindquist/kit";
const youtubeDlExec = await npm("youtube-dl-exec");
const slugify = await npm("slugify");
const apiKey = await env("YOUTUBE_API_KEY");
// Show feedback as HTML (Adds padding and some feedback styles)
const showFeedback = async (message) => {
await div(`
<style type="text/css">
.container { position: relative; padding-right: 232px !important; }
.error { border: 1px solid red; color: red; background: rgba(255,0,0,.1); }
.success { border: 1px solid green; color: darkgreen; background: rgba(0,255,0,.1); }
.default { border: 1px solid gray; color: black; background: rgba(0,0,0,.05); }
</style>
<main class="p-8">
${message}
</main>
`);
return message;
};
// Returns the SVG markup of an animated loading spinner
const getLoadingSpinner = () => `
<svg
style="margin: 0 8px 0 0; display: inline-block; shape-rendering: auto"
width="30px"
height="30px"
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid"
>
<defs>
<clipPath id="progress-4hqxcfiwb2u-cp" x="0" y="0" width="100" height="100">
<rect x="0" y="0" width="0" height="100">
<animate
attributeName="width"
repeatCount="indefinite"
dur="1s"
values="0;100;100"
keyTimes="0;0.5;1"
></animate>
<animate
attributeName="x"
repeatCount="indefinite"
dur="1s"
values="0;0;100"
keyTimes="0;0.5;1"
></animate>
</rect>
</clipPath>
</defs>
<path
fill="none"
stroke="rgba(0,0,0,.2)"
stroke-width="2.79"
d="M18 36.895L81.99999999999999 36.895A13.104999999999999 13.104999999999999 0 0 1 95.10499999999999 50L95.10499999999999 50A13.104999999999999 13.104999999999999 0 0 1 81.99999999999999 63.105L18 63.105A13.104999999999999 13.104999999999999 0 0 1 4.895000000000003 50L4.895000000000003 50A13.104999999999999 13.104999999999999 0 0 1 18 36.895 Z"
></path>
<path
fill="rgba(0,0,0,.8)"
clip-path="url(#progress-4hqxcfiwb2u-cp)"
d="M18 40.99L82 40.99A9.009999999999998 9.009999999999998 0 0 1 91.00999999999999 50L91.00999999999999 50A9.009999999999998 9.009999999999998 0 0 1 82 59.01L18 59.01A9.009999999999998 9.009999999999998 0 0 1 8.990000000000004 50L8.990000000000004 50A9.009999999999998 9.009999999999998 0 0 1 18 40.99 Z"
></path>
</svg>
`;
// Returns a small HTML structure showing basic information about the video currently downloaded
const getVideoTemplate = (title, metadata) => `
<h1 class="w-full truncate">${title}</h1>
<table className="container">
<tr>
<td width="${
(metadata.thumbnails?.default?.width || 0) + 32
}" className="pr-4 align-top">
<img
src="${metadata.thumbnails?.default?.url}"
width="${metadata.thumbnails?.default?.width}"
height="${metadata.thumbnails?.default?.height}"
/>
</td>
<td class="align-top">
<div>
<strong class="block">Destination</strong>
<span>${metadata.path}</span>
</div>
<div>
<strong>Language</strong>
<span>${metadata.defaultAudioLanguage}</span>
</div>
<div>
<strong>Channel</strong>
<span>${metadata.channelTitle}</span>
</div>
</td>
</tr>
</table>
`;
// Retruns basic information about the youtube video (For the feedback and the file name)
const getVideoMetadata = (url) =>
new Promise((resolve, reject) => {
const urlObj = new URL(url);
const id = urlObj.searchParams.get("v");
if (!id) return reject(`Video ID not present in the url`);
const ytUrl = new URL(`https://www.googleapis.com/youtube/v3/videos`);
ytUrl.searchParams.set("key", apiKey);
ytUrl.searchParams.set("id", id);
ytUrl.searchParams.set("part", "snippet");
console.log(ytUrl.toString());
get(ytUrl.toString())
.then((response) => response.data)
.then((data) => data.items[0].snippet)
.then(resolve);
});
// We save the metadata outside the try catch so it's available in the catch
let fullMetadata = {};
try {
const videoSrc = await arg("Video url:");
const videoMetadata = await getVideoMetadata(videoSrc);
const videoPath = "Downloads";
const videoName = slugify(videoMetadata.title.slice(0, 50).toLowerCase());
const fileName = videoName !== "" ? videoName : videoSrc;
const newPath = home(videoPath, path.basename(fileName) + ".mp4");
fullMetadata = { ...videoMetadata, path: `~/${videoPath}/${fileName}.mp4` };
// We display the loading state
void showFeedback(`
<div class="px-6 py-4 rounded default">
${getVideoTemplate(
`${getLoadingSpinner()} Downloading "${fullMetadata.title}"`,
fullMetadata
)}
</div>
`);
// We download the video
const res = await youtubeDlExec(videoSrc, { output: newPath });
console.log(res);
// If all went well, we can show a success message
showFeedback(`
<div class="px-6 py-4 rounded success">
${getVideoTemplate(
`โœ… Successfully downloaded "${fullMetadata.title}"`,
fullMetadata
)}
</div>
`);
await wait(1000);
// After a second, we offer the user the choice of what to do next
const nextStep = await arg("What would you like to do with this file?", [
{
name: "Show in finder โ†—๏ธ",
description: `Open ~/${videoPath}`,
value: "locate",
},
{
name: "Open video ๐ŸŽฅ",
description: `View video in default player`,
value: `view`,
},
]);
// We check for the user's choice in and open either the file or the location
if (nextStep === "locate") {
exec(`open --reveal ${newPath}`);
} else if (nextStep === "view") {
exec(`open ${newPath}`);
}
// In case something went wrong, we show the error
} catch (err) {
console.log(err);
await showFeedback(`
<div class="px-6 py-4 rounded error">
<p class="px-6 py-4 mb-4 rounded error">
๐Ÿ”ด Error ${err}
</p>
${getVideoTemplate(
`๐Ÿ”ด Error downloading "${fullMetadata?.title}"`,
fullMetadata || {}
)}
</div>
`);
}