diff --git a/CHANGELOG.md b/CHANGELOG.md index ff29385..37dd89a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index c59dc84..e9a9d0c 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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 }, @@ -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", diff --git a/src/index.tsx b/src/index.tsx index 438bb69..b7a0aee 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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(); +// 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(); 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({ - onSubmit(values) { + onSubmit(formValues) { setLoading(true); - const url = values.url.trim(); - const payload: Record = { - url, - aFormat: values.aFormat, - filenamePattern: preferences.filenamePattern, - dubLang: false, - twitterGif: preferences.twitterGif, - tiktokH265: preferences.tiktokH265, + const headers: Record = { + 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; } @@ -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) { @@ -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); @@ -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"`); } }); @@ -175,14 +194,15 @@ export default function Command() { } > - + + - + diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..97630dd --- /dev/null +++ b/src/types.ts @@ -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; + +export type { CobaltRequest, CobaltResponse, FormValues };