Skip to content

Commit

Permalink
update 2024-11-20
Browse files Browse the repository at this point in the history
  • Loading branch information
aelew committed Nov 21, 2024
1 parent 7171893 commit 75faf69
Show file tree
Hide file tree
Showing 4 changed files with 180 additions and 77 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Cobalt Changelog

## [General improvements, API update] - 2024-11-20

- Updated dependencies
- Updated default API instance URL to `cobalt.aelew.dev` (see [imputnet/cobalt#860](https://github.com/imputnet/cobalt/discussions/860))
- Updated download logic to be compatible with the latest API version (v10.3.3)
- Added a toast notifying the user if they are using an old API instance URL
- Added `API Instance Key` preference
- Added `Always Proxy` preference
- Added `Disable Metadata` preference
- Added `YouTube: Use HLS` preference
- Removed `Mute Video Audio` preference (now under `Mode`)

## [Bug fixes and improvements] - 2024-08-16

- Updated dependencies
Expand Down
38 changes: 30 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,22 @@
{
"name": "apiInstanceUrl",
"title": "API Instance URL",
"description": "Want to use a different Cobalt API instance? Change your instance URL here!",
"placeholder": "https://api.cobalt.tools",
"default": "https://api.cobalt.tools",
"description": "Want to use a different Cobalt API instance? Set your instance URL here!",
"placeholder": "https://cobalt.aelew.dev",
"default": "https://cobalt.aelew.dev",
"type": "textfield",
"required": false
},
{
"name": "filenamePattern",
"name": "apiInstanceKey",
"title": "API Instance Key",
"description": "If the instance you are using requires an API key, enter it here.",
"placeholder": "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx",
"type": "password",
"required": false
},
{
"name": "filenameStyle",
"title": "File Name Style",
"description": "How downloaded files should be named.\n\nClassic: Default file name pattern.\nBasic: Title and basic info in brackets.\nPretty: Title and info in brackets.\nNerdy: Title and all info in brackets.\n\nSome services don't support rich file names and will always use the classic style.",
"type": "dropdown",
Expand Down Expand Up @@ -74,9 +82,23 @@
"default": true
},
{
"name": "muteVideoAudio",
"label": "Mute Video Audio",
"description": "Removes the audio from video downloads when possible.",
"name": "alwaysProxy",
"label": "Always Proxy",
"description": "Tunnels all downloads through the processing server, even when not necessary.",
"type": "checkbox",
"required": false
},
{
"name": "disableMetadata",
"label": "Disable Metadata",
"description": "Disables the addition of file metadata.",
"type": "checkbox",
"required": false
},
{
"name": "youtubeHLS",
"label": "YouTube: Use HLS",
"description": "Whether to use HLS when downloading video or audio from YouTube.",
"type": "checkbox",
"required": false
},
Expand All @@ -88,7 +110,7 @@
"required": false
},
{
"name": "downloadOriginalTikTokSound",
"name": "tiktokFullAudio",
"label": "TikTok: Use Original Audio",
"description": "Downloads the original sound used in the TikTok video without any additional changes by the post's author.",
"type": "checkbox",
Expand Down
158 changes: 89 additions & 69 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,84 +1,98 @@
import { Form, ActionPanel, Action, showToast, Toast, getPreferenceValues } from "@raycast/api";
import {
Form,
ActionPanel,
Action,
showToast,
Toast,
getPreferenceValues,
openExtensionPreferences,
Keyboard,
} from "@raycast/api";
import { runAppleScript, useForm } from "@raycast/utils";
import { mkdir } from "fs/promises";
import { Readable } from "stream";
import { useState } from "react";
import { useEffect, useState } from "react";
import fetch from "cross-fetch";
import path from "path";
import fs from "fs";
import type { CobaltRequest, CobaltResponse, FormValues } from "./types";
import { parse as parseContentDispositionHeader } from "content-disposition";

type FormValues = {
mode: "auto" | "audio";
vCodec: "h264" | "av1" | "vp9";
vQuality: "max" | "2160" | "1440" | "1080" | "720" | "480" | "360" | "240" | "144";
aFormat: "best" | "mp3" | "ogg" | "wav" | "opus";
url: string;
};

type CobaltResponse =
| { status: "stream"; url: string }
| { status: "redirect"; url: string }
| { status: "picker"; pickerType: "various" | "images"; picker: PickerItem[] }
| { status: "error"; text: string }
| { status: "success"; text: string }
| { status: "rate-limit"; text: string };

type PickerItem = { type: "video"; thumb: string; url: string };

const preferences = getPreferenceValues<Preferences>();
// official cobalt instance URLs that are no longer available
const oldCobaltInstances = ["https://co.wuk.sh", "https://api.cobalt.tools"];

export default function Command() {
const preferences = getPreferenceValues<Preferences>();
const [loading, setLoading] = useState(false);

useEffect(() => {
if (oldCobaltInstances.includes(preferences.apiInstanceUrl)) {
showToast({
style: Toast.Style.Failure,
title: "Official Cobalt API no longer available",
message:
"Please update your preferences to use a self-hosted API instance.\n" +
"If you do not have access to a custom instance, you may use https://cobalt.aelew.dev.",
primaryAction: {
title: "Open Preferences",
shortcut: Keyboard.Shortcut.Common.Open,
onAction: openExtensionPreferences,
},
});
}
}, [preferences]);

const { handleSubmit, itemProps } = useForm<FormValues>({
onSubmit(values) {
onSubmit(formValues) {
setLoading(true);

const url = values.url.trim();
const payload: Record<string, unknown> = {
url,
aFormat: values.aFormat,
filenamePattern: preferences.filenamePattern,
dubLang: false,
twitterGif: preferences.twitterGif,
tiktokH265: preferences.tiktokH265,
const headers: Record<string, string> = {
Accept: "application/json",
"User-Agent": "raycast-cobalt/20241120",
"Content-Type": "application/json",
};

if (values.mode === "audio") {
payload.isAudioOnly = true;
payload.isTTFullAudio = preferences.downloadOriginalTikTokSound;
} else {
payload.vQuality = values.vQuality;
payload.isAudioMuted = preferences.muteVideoAudio;
if (url.includes("youtube.com/") || url.includes("/youtu.be/")) {
payload.vCodec = values.vCodec;
}
if (preferences.apiInstanceKey) {
headers["Authorization"] = `Api-Key ${preferences.apiInstanceKey}`;
}

// this is intentional. cobalt requires an API key to restrict access based on user agent
if (preferences.apiInstanceUrl === "https://cobalt.aelew.dev") {
headers["Authorization"] = "Api-Key 00000000-0000-4000-a000-000000000000";
}

fetch(preferences.apiInstanceUrl + "/api/json", {
body: JSON.stringify(payload),
const body: CobaltRequest = {
...formValues,
url: formValues.url.trim(),
filenameStyle: preferences.filenameStyle,
alwaysProxy: preferences.alwaysProxy,
disableMetadata: preferences.disableMetadata,
youtubeHLS: preferences.youtubeHLS,
twitterGif: preferences.twitterGif,
tiktokFullAudio: preferences.tiktokFullAudio,
tiktokH265: preferences.tiktokH265,
};

fetch(preferences.apiInstanceUrl, {
body: JSON.stringify(body),
method: "POST",
headers: {
"user-agent": "raycast-cobalt",
"content-type": "application/json",
accept: "application/json",
},
headers,
})
.then((response) => response.json())
.then((result: CobaltResponse) => {
switch (result.status) {
case "stream":
case "tunnel":
case "redirect":
downloadFile(result.url);
downloadFile(result.url, result.filename);
break;
case "picker":
result.picker.forEach((item) => downloadFile(item.url));
if (result.audio) {
downloadFile(result.audio, result.audioFilename);
}
break;
case "error":
case "success":
case "rate-limit":
showToast(Toast.Style.Failure, "An unexpected error occurred", result.text);
showToast(Toast.Style.Failure, "An unexpected error occurred", result.error.code);
setLoading(false);
break;
}
Expand Down Expand Up @@ -111,8 +125,12 @@ export default function Command() {
},
});

async function downloadFile(url: string) {
const toast = await showToast({ style: Toast.Style.Animated, title: "Downloading file", message: "Starting..." });
async function downloadFile(url: string, filename?: string) {
const toast = await showToast({
style: Toast.Style.Animated,
title: "Downloading file",
message: "Starting...",
});

const response = await fetch(url);
if (!response.body) {
Expand All @@ -124,25 +142,26 @@ export default function Command() {
await mkdir(preferences.downloadDirectory);
}

// Use the file name from the content-disposition header if available. Otherwise, use the last part of the provided URL.
let fileName;
const contentDispositionHeader = response.headers.get("content-disposition");
if (contentDispositionHeader) {
fileName = parseContentDispositionHeader(contentDispositionHeader).parameters.filename.replace(".none", ".mp3");
} else {
fileName = url.substring(url.lastIndexOf("/") + 1).split("?")[0];
if (!filename) {
const contentDispositionHeader = response.headers.get("content-disposition");
if (contentDispositionHeader) {
filename = parseContentDispositionHeader(contentDispositionHeader).parameters.filename.replace(".none", ".mp3");
} else {
filename = url.substring(url.lastIndexOf("/") + 1).split("?")[0];
}
}

const destination = path.resolve(preferences.downloadDirectory, fileName);
const destination = path.resolve(preferences.downloadDirectory, filename);
const writeStream = fs.createWriteStream(destination);

const body = response.body as unknown as Readable;
const BYTES_PER_MB = 1048576;
let bytes = 0;

const BYTES_PER_MB = 1048576;

body.on("data", (chunk: Buffer) => {
bytes += chunk.length;
toast.message = parseFloat((bytes / BYTES_PER_MB).toFixed(1)) + " MB";
toast.message = `${parseFloat((bytes / BYTES_PER_MB).toFixed(1))} MB`;
});

body.pipe(writeStream);
Expand All @@ -152,9 +171,9 @@ export default function Command() {
writeStream.close();
toast.style = Toast.Style.Success;
toast.title = "Download complete";
toast.message = "Saved to " + fileName;
toast.message = `Saved to ${filename}`;
if (preferences.notifyOnDownload) {
runAppleScript(`display notification "Downloaded ${fileName}!" with title "Cobalt" sound name "Glass"`);
runAppleScript(`display notification "Downloaded ${filename}!" with title "Cobalt" sound name "Glass"`);
}
});

Expand All @@ -175,14 +194,15 @@ export default function Command() {
</ActionPanel>
}
>
<Form.Dropdown id="mode" title="Mode" storeValue>
<Form.Dropdown id="downloadMode" title="Mode" storeValue>
<Form.Dropdown.Item title="Auto" value="auto" />
<Form.Dropdown.Item title="Audio" value="audio" />
<Form.Dropdown.Item title="Mute" value="mute" />
</Form.Dropdown>
<Form.TextField title="URL" placeholder="Paste your link here" autoFocus {...itemProps.url} />
<Form.Separator />
<Form.Dropdown
id="vCodec"
id="youtubeVideoCodec"
title="Video codec"
storeValue
info={
Expand All @@ -198,7 +218,7 @@ export default function Command() {
<Form.Dropdown.Item title="VP9 (webm)" value="vp9" />
</Form.Dropdown>
<Form.Dropdown
id="vQuality"
id="videoQuality"
title="Video quality"
defaultValue="max"
storeValue
Expand All @@ -217,7 +237,7 @@ export default function Command() {
<Form.Dropdown.Item title="240p" value="240" />
<Form.Dropdown.Item title="144p" value="144" />
</Form.Dropdown>
<Form.Dropdown id="aFormat" title="Audio format" info="Only applies to audio downloads." storeValue>
<Form.Dropdown id="audioFormat" title="Audio format" info="Only applies to audio downloads." storeValue>
<Form.Dropdown.Item title="Best" value="best" />
<Form.Dropdown.Item title="mp3" value="mp3" />
<Form.Dropdown.Item title="ogg" value="ogg" />
Expand Down
49 changes: 49 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// https://github.com/imputnet/cobalt/blob/main/docs/api.md#request-body
type CobaltRequest = {
url: string;
videoQuality?: "144" | "240" | "360" | "480" | "720" | "1080" | "1440" | "2160" | "max";
audioFormat?: "best" | "mp3" | "ogg" | "wav" | "opus";
audioBitrate?: "320" | "256" | "128" | "96" | "64" | "8";
filenameStyle?: "classic" | "pretty" | "basic" | "nerdy";
downloadMode?: "auto" | "audio" | "mute";
youtubeVideoCodec?: "h264" | "av1" | "vp9";
youtubeDubLang?: string;
alwaysProxy?: boolean;
disableMetadata?: boolean;
tiktokFullAudio?: boolean;
tiktokH265?: boolean;
twitterGif?: boolean;
youtubeHLS?: boolean;
};

// https://github.com/imputnet/cobalt/blob/main/docs/api.md#response
type CobaltResponse =
| {
status: "tunnel" | "redirect";
url: string;
filename: string;
}
| {
status: "picker";
picker: {
type: "photo" | "video" | "gif";
url: string;
thumb: string;
}[];
audio?: string;
audioFilename?: string;
}
| {
status: "error";
error: {
code: string;
context?: {
service: string;
limit: number;
};
};
};

type FormValues = Pick<CobaltRequest, "url" | "downloadMode" | "youtubeVideoCodec" | "videoQuality" | "audioFormat">;

export type { CobaltRequest, CobaltResponse, FormValues };

0 comments on commit 75faf69

Please sign in to comment.