diff --git a/package.json b/package.json index 7a240a87..c49adf8e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cobalt", "description": "save what you love", - "version": "7.8.6", + "version": "7.9", "author": "wukko", "exports": "./src/cobalt.js", "type": "module", diff --git a/src/config.json b/src/config.json index 91922a5e..923f4d7b 100644 --- a/src/config.json +++ b/src/config.json @@ -90,7 +90,8 @@ "mp4": ["-c:v", "copy", "-c:a", "copy", "-movflags", "faststart+frag_keyframe+empty_moov"], "copy": ["-c:a", "copy"], "audio": ["-ar", "48000", "-ac", "2", "-b:a", "320k"], - "m4a": ["-movflags", "frag_keyframe+empty_moov"] + "m4a": ["-movflags", "frag_keyframe+empty_moov"], + "gif": ["-vf", "scale=-1:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", "-loop", "0"] }, "sponsors": [{ "name": "royale", diff --git a/src/front/cobalt.js b/src/front/cobalt.js index f5db3a0b..7da2b8e9 100644 --- a/src/front/cobalt.js +++ b/src/front/cobalt.js @@ -30,6 +30,7 @@ const checkboxes = [ "reduceTransparency", "disableAnimations", "disableMetadata", + "twitterGif", ]; const exceptions = { // used for mobile devices "vQuality": "720" @@ -381,6 +382,7 @@ async function download(url) { } if (sGet("disableMetadata") === "true") req.disableMetadata = true; + if (sGet("twitterGif") === "true") req.twitterGif = true; let j = await fetch(`${apiURL}/api/json`, { method: "POST", diff --git a/src/localization/languages/en.json b/src/localization/languages/en.json index dd703f59..9a1e591f 100644 --- a/src/localization/languages/en.json +++ b/src/localization/languages/en.json @@ -117,7 +117,6 @@ "ShareURL": "share", "ErrorTweetUnavailable": "couldn't find anything about this tweet. this could be because its visibility is limited. try another one!", "ErrorTwitterRIP": "twitter has restricted access to any content to unauthenticated users. while there's a way to get regular tweets, spaces are, unfortunately, impossible to get at this time. i am looking into possible solutions.", - "UrgentDonate": "cobalt needs your help!", "PopupCloseDone": "done", "Accessibility": "accessibility", "SettingsReduceTransparency": "reduce transparency", @@ -134,10 +133,7 @@ "KeyboardShortcutClosePopup": "close all popups", "CollapseLegal": "terms and ethics", "FairUse": "cobalt is a web tool that makes it easier to download content from the internet and takes zero liability. processing servers work like limited proxies, so no media content is ever cached or stored.\n\nyou (end user) are responsible for what you download, how you use and distribute that content. please be mindful when using content of others and always credit original creators.\n\nwhen used in education purposes (lecture, homework, etc) please attach the source link.\n\nfair use and credits benefit everyone.", - "UrgentFeatureUpdate71": "more supported services!", - "UrgentThanks": "thank you for support!", "SettingsDisableMetadata": "don't add metadata", - "UrgentNewDomain": "new domain, same cobalt", "NewDomainWelcomeTitle": "hey there!", "NewDomainWelcome": "cobalt is moving! same features, same owner, simply a more rememberable domain. and still no ads.\n\ncobalt.tools is the new main domain, aka where you are now. make sure to update your bookmarks and reinstall the web app!", "DataTransferSuccess": "btw, your settings have been transferred automatically :)", @@ -154,11 +150,12 @@ "FilenamePreviewVideoTitle": "Video Title", "FilenamePreviewAudioTitle": "Audio Title", "FilenamePreviewAudioAuthor": "Audio Author", - "UrgentFilenameUpdate": "customizable file names!", - "UrgentTwitterPatch": "fixes and easier downloads", "StatusPage": "service status page", "TroubleshootingGuide": "self-troubleshooting guide", "DonateImageDescription": "cat sleeping on a laptop keyboard and typing letters repeatedly", - "UpdateNewYears": "new years clean up" + "UpdateNewYears": "new years clean up", + "SettingsTwitterGif": "convert gifs to .gif", + "SettingsTwitterGifDescription": "converting looping videos to .gif reduces quality and majorly increases file size. if you want best efficiency, keep this setting off.", + "UpdateTwitterGif": "twitter gifs and pinterest" } } diff --git a/src/localization/languages/ru.json b/src/localization/languages/ru.json index 8a028917..cae12454 100644 --- a/src/localization/languages/ru.json +++ b/src/localization/languages/ru.json @@ -118,7 +118,6 @@ "ShareURL": "поделиться", "ErrorTweetUnavailable": "не смог найти что-либо об этом твите. возможно его видимость была ограничена. попробуй другой!", "ErrorTwitterRIP": "твиттер ограничил доступ к любому контенту на сайте для пользователей без аккаунтов. я нашёл лазейку, чтобы доставать обычные твиты, а для spaces, к сожалению, нет. я ищу возможные варианты выхода из ситуации.", - "UrgentDonate": "нужна твоя помощь!", "PopupCloseDone": "готово", "Accessibility": "общедоступность", "SettingsReduceTransparency": "уменьшить прозрачность", @@ -135,10 +134,7 @@ "KeyboardShortcutClosePopup": "закрыть все окна", "CollapseLegal": "принципы и этика", "FairUse": "кобальт - это веб инструмент для облегчения скачивания контента из интернета. сервера обработки работают как ограниченные прокси, так что ничего никогда не сохраняется или кэшируется.\n\nкобальт не несёт никакой ответственности, только ты (конечный пользователь) несёшь ответственность за то, что скачиваешь, как используешь и распространяешь скачанный контент. будь сознателен при использовании чужого контента и всегда указывай авторов!\n\nприкладывай ссылку на источник при использовании в образовательных целях (лекции, домашние задания и т.п.)\n\nчестное использование и указание авторства выгодно всем.", - "UrgentFeatureUpdate71": "расширение поддержки сервисов!", - "UrgentThanks": "спасибо за поддержку!", "SettingsDisableMetadata": "не добавлять метаданные", - "UrgentNewDomain": "новый домен, тот же кобальт", "NewDomainWelcomeTitle": "привет!", "NewDomainWelcome": "кобальт переезжает! те же функции, тот же владелец, просто более запоминающийся домен. по-прежнему без рекламы.\n\ncobalt.tools - новый основной домен, т.е. где ты сейчас находишься. не забудь обновить закладки и переустановить веб-приложение!", "DataTransferSuccess": "кстати, твои настройки были перенесены автоматически :)", @@ -156,11 +152,12 @@ "FilenamePreviewVideoTitle": "Название Видео", "FilenamePreviewAudioTitle": "Название Аудио", "FilenamePreviewAudioAuthor": "Автор Аудио", - "UrgentFilenameUpdate": "изменяемые названия файлов!", - "UrgentTwitterPatch": "фиксы и удобное скачивание", "StatusPage": "статус серверов", "TroubleshootingGuide": "гайд по устранению проблем", "DonateImageDescription": "кошка спит на клавиатуре ноутбука и многократно печатает буквы", - "UpdateNewYears": "новогодняя уборка" + "UpdateNewYears": "новогодняя уборка", + "SettingsTwitterGif": "конвертировать гифки в .gif", + "SettingsTwitterGifDescription": "конвертирование зацикленного видео в .gif снижает качество и значительно увеличивает размер файла. если важна максимальная эффективность, то не используй эту функцию.", + "UpdateTwitterGif": "гифки с твиттера и одноклассники" } } diff --git a/src/modules/pageRender/page.js b/src/modules/pageRender/page.js index 4e37f9a4..ae215516 100644 --- a/src/modules/pageRender/page.js +++ b/src/modules/pageRender/page.js @@ -335,6 +335,16 @@ export default function(obj) { padding: "no-margin" }]) }) + + settingsCategory({ + name: "twitter", + title: "twitter", + body: checkbox([{ + action: "twitterGif", + name: t("SettingsTwitterGif"), + padding: "no-margin" + }]) + + explanation(t('SettingsTwitterGifDescription')) + }) + settingsCategory({ name: "codec", title: t('SettingsCodecSubtitle'), @@ -576,7 +586,7 @@ export default function(obj) {
- +
diff --git a/src/modules/processing/match.js b/src/modules/processing/match.js index 12c0bc10..0d5980ca 100644 --- a/src/modules/processing/match.js +++ b/src/modules/processing/match.js @@ -13,6 +13,7 @@ import reddit from "./services/reddit.js"; import twitter from "./services/twitter.js"; import youtube from "./services/youtube.js"; import vk from "./services/vk.js"; +import ok from "./services/ok.js"; import tiktok from "./services/tiktok.js"; import tumblr from "./services/tumblr.js"; import vimeo from "./services/vimeo.js"; @@ -37,24 +38,31 @@ export default async function(host, patternMatch, url, lang, obj) { case "twitter": r = await twitter({ id: patternMatch.id, - index: patternMatch.index - 1 + index: patternMatch.index - 1, + toGif: obj.twitterGif }); break; case "vk": r = await vk({ - userId: patternMatch["userId"], - videoId: patternMatch["videoId"], + userId: patternMatch.userId, + videoId: patternMatch.videoId, + quality: obj.vQuality + }); + break; + case "ok": + r = await ok({ + id: patternMatch.id, quality: obj.vQuality }); break; case "bilibili": r = await bilibili({ - id: patternMatch["id"].slice(0, 12) + id: patternMatch.id.slice(0, 12) }); break; case "youtube": let fetchInfo = { - id: patternMatch["id"].slice(0, 11), + id: patternMatch.id.slice(0, 11), quality: obj.vQuality, format: obj.vCodec, isAudioOnly: isAudioOnly, @@ -72,16 +80,16 @@ export default async function(host, patternMatch, url, lang, obj) { break; case "reddit": r = await reddit({ - sub: patternMatch["sub"], - id: patternMatch["id"] + sub: patternMatch.sub, + id: patternMatch.id }); break; case "douyin": case "tiktok": r = await tiktok({ host: host, - postId: patternMatch["postId"], - id: patternMatch["id"], + postId: patternMatch.postId, + id: patternMatch.id, noWatermark: obj.isNoTTWatermark, fullAudio: obj.isTTFullAudio, isAudioOnly: isAudioOnly @@ -96,7 +104,7 @@ export default async function(host, patternMatch, url, lang, obj) { break; case "vimeo": r = await vimeo({ - id: patternMatch["id"].slice(0, 11), + id: patternMatch.id.slice(0, 11), quality: obj.vQuality, isAudioOnly: isAudioOnly, forceDash: isAudioOnly ? true : obj.vimeoDash @@ -106,10 +114,10 @@ export default async function(host, patternMatch, url, lang, obj) { isAudioOnly = true; r = await soundcloud({ url, - author: patternMatch["author"], - song: patternMatch["song"], - shortLink: patternMatch["shortLink"] || false, - accessKey: patternMatch["accessKey"] || false + author: patternMatch.author, + song: patternMatch.song, + shortLink: patternMatch.shortLink || false, + accessKey: patternMatch.accessKey || false }); break; case "instagram": @@ -120,31 +128,32 @@ export default async function(host, patternMatch, url, lang, obj) { break; case "vine": r = await vine({ - id: patternMatch["id"] + id: patternMatch.id }); break; case "pinterest": r = await pinterest({ - id: patternMatch["id"] + id: patternMatch.id, + shortLink: patternMatch.shortLink || false }); break; case "streamable": r = await streamable({ - id: patternMatch["id"], + id: patternMatch.id, quality: obj.vQuality, isAudioOnly: isAudioOnly, }); break; case "twitch": r = await twitch({ - clipId: patternMatch["clip"] || false, + clipId: patternMatch.clip || false, quality: obj.vQuality, isAudioOnly: obj.isAudioOnly }); break; case "rutube": r = await rutube({ - id: patternMatch["id"], + id: patternMatch.id, quality: obj.vQuality, isAudioOnly: isAudioOnly }); @@ -166,7 +175,11 @@ export default async function(host, patternMatch, url, lang, obj) { : loc(lang, r.error) }) - return matchActionDecider(r, host, obj.aFormat, isAudioOnly, lang, isAudioMuted, disableMetadata, obj.filenamePattern) + return matchActionDecider( + r, host, obj.aFormat, isAudioOnly, + lang, isAudioMuted, disableMetadata, + obj.filenamePattern, obj.twitterGif + ) } catch (e) { return apiJSON(0, { t: genericError(lang, host) }) } diff --git a/src/modules/processing/matchActionDecider.js b/src/modules/processing/matchActionDecider.js index a7b8740b..f015ea13 100644 --- a/src/modules/processing/matchActionDecider.js +++ b/src/modules/processing/matchActionDecider.js @@ -3,7 +3,7 @@ import { apiJSON } from "../sub/utils.js"; import loc from "../../localization/manager.js"; import createFilename from "./createFilename.js"; -export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, disableMetadata, filenamePattern) { +export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, disableMetadata, filenamePattern, toGif) { let action, responseType = 2, defaultParams = { @@ -14,13 +14,14 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di fileMetadata: !disableMetadata ? r.fileMetadata : false }, params = {}, - audioFormat = String(userFormat) + audioFormat = String(userFormat); if (r.isPhoto) action = "photo"; else if (r.picker) action = "picker" else if (isAudioMuted) action = "muteVideo"; else if (isAudioOnly) action = "audio"; else if (r.isM3U8) action = "singleM3U8"; + else if (r.isGif && toGif) action = "gif"; else action = "video"; if (action === "picker" || action === "audio") { @@ -39,6 +40,10 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di case "photo": responseType = 1; break; + + case "gif": + params = { type: "gif" } + break; case "singleM3U8": params = { type: "remux" } diff --git a/src/modules/processing/services/ok.js b/src/modules/processing/services/ok.js new file mode 100644 index 00000000..bde163f6 --- /dev/null +++ b/src/modules/processing/services/ok.js @@ -0,0 +1,56 @@ +import { genericUserAgent, maxVideoDuration } from "../../config.js"; +import { cleanString } from "../../sub/utils.js"; + +const resolutions = { + "ultra": "2160", + "quad": "1440", + "full": "1080", + "hd": "720", + "sd": "480", + "low": "360", + "lowest": "240", + "mobile": "144" +} + +export default async function(o) { + let quality = o.quality === "max" ? "2160" : o.quality; + + let html = await fetch(`https://ok.ru/video/${o.id}`, { + headers: { "user-agent": genericUserAgent } + }).then((r) => { return r.text() }).catch(() => { return false }); + + if (!html) return { error: 'ErrorCouldntFetch' }; + if (!html.includes(`
maxVideoDuration / 1000) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] }; + + let videos = videoData.videos.filter(v => !v.disallowed); + let bestVideo = videos.find(v => resolutions[v.name] === quality) || videos[videos.length - 1]; + + let fileMetadata = { + title: cleanString(videoData.movie.title.trim()), + author: cleanString(videoData.author.name.trim()), + } + + if (bestVideo) return { + urls: bestVideo.url, + filenameAttributes: { + service: "ok", + id: o.id, + title: fileMetadata.title, + author: fileMetadata.author, + resolution: `${resolutions[bestVideo.name]}p`, + qualityLabel: `${resolutions[bestVideo.name]}p`, + extension: "mp4" + } + } + + return { error: 'ErrorEmptyDownload' } +} diff --git a/src/modules/processing/services/pinterest.js b/src/modules/processing/services/pinterest.js index 086573c6..156b65cd 100644 --- a/src/modules/processing/services/pinterest.js +++ b/src/modules/processing/services/pinterest.js @@ -1,29 +1,36 @@ -import { maxVideoDuration } from "../../config.js"; +import { genericUserAgent } from "../../config.js"; -export default async function(obj) { - const pinId = obj.id.split('--').reverse()[0]; - if (!(/^\d+$/.test(pinId))) return { error: 'ErrorCantGetID' }; - let data = await fetch(`https://www.pinterest.com/resource/PinResource/get?data=${encodeURIComponent(JSON.stringify({ - options: { - field_set_key: "unauth_react_main_pin", - id: pinId - } - }))}`).then((r) => { return r.json() }).catch(() => { return false }); - if (!data) return { error: 'ErrorCouldntFetch' }; +const videoLinkBase = { + "regular": "https://v1.pinimg.com/videos/mc/720p/", + "story": "https://v1.pinimg.com/videos/mc/720p/" +} - data = data["resource_response"]["data"]; +export default async function(o) { + let id = o.id, type = "regular"; - let video = null; + if (id.includes("--")) { + id = id.split("--")[1]; + type = "story"; + } + if (!o.id && o.shortLink) { + id = await fetch(`https://api.pinterest.com/url_shortener/${o.shortLink}/redirect/`, { redirect: "manual" }).then((r) => { + return r.headers.get("location").split('pin/')[1].split('/')[0] + }).catch(() => {}); + } + if (!id) return { error: 'ErrorCouldntFetch' }; - if (data.videos !== null) video = data.videos.video_list.V_720P; - else if (data.story_pin_data !== null) video = data.story_pin_data.pages[0].blocks[0].video.video_list.V_EXP7; + let html = await fetch(`https://www.pinterest.com/pin/${id}/`, { + headers: { "user-agent": genericUserAgent } + }).then((r) => { return r.text() }).catch(() => { return false }); - if (!video) return { error: 'ErrorEmptyDownload' }; - if (video.duration > maxVideoDuration) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] }; + if (!html) return { error: 'ErrorCouldntFetch' }; + + let videoLink = html.split(`"url":"${videoLinkBase[type]}`)[1]?.split('"')[0]; + if (!html.includes(videoLink)) return { error: 'ErrorEmptyDownload' }; return { - urls: video.url, - filename: `pinterest_${pinId}.mp4`, - audioFilename: `pinterest_${pinId}_audio` + urls: `${videoLinkBase[type]}${videoLink}`, + filename: `pinterest_${o.id}.mp4`, + audioFilename: `pinterest_${o.id}_audio` } } diff --git a/src/modules/processing/services/twitter.js b/src/modules/processing/services/twitter.js index 31b26b48..ee6a9f12 100644 --- a/src/modules/processing/services/twitter.js +++ b/src/modules/processing/services/twitter.js @@ -72,7 +72,7 @@ const requestTweet = (tweetId, token) => { }) } -export default async function({ id, index }) { +export default async function({ id, index, toGif }) { let guestToken = await getGuestToken(); if (!guestToken) return { error: 'ErrorCouldntFetch' }; @@ -110,7 +110,8 @@ export default async function({ id, index }) { type: needsFixing(media[0]) ? "remux" : "normal", urls: bestQuality(media[0].video_info.variants), filename: `twitter_${id}.mp4`, - audioFilename: `twitter_${id}_audio` + audioFilename: `twitter_${id}_audio`, + isGif: media[0].type === "animated_gif" }; default: const picker = media.map((video, i) => { @@ -120,7 +121,9 @@ export default async function({ id, index }) { service: 'twitter', type: 'remux', u: url, - filename: `twitter_${id}_${i + 1}.mp4` + filename: `twitter_${id}_${i + 1}.mp4`, + isGif: media[0].type === "animated_gif", + toGif: toGif ?? false }) } return { diff --git a/src/modules/processing/services/vimeo.js b/src/modules/processing/services/vimeo.js index 90dc9b3d..db623bdb 100644 --- a/src/modules/processing/services/vimeo.js +++ b/src/modules/processing/services/vimeo.js @@ -4,6 +4,7 @@ import { cleanString } from '../../sub/utils.js'; const resolutionMatch = { "3840": "2160", "2732": "1440", + "2560": "1440", "2048": "1080", "1920": "1080", "1366": "720", @@ -63,7 +64,7 @@ export default async function(obj) { if (!masterJSON) return { error: 'ErrorCouldntFetch' }; if (!masterJSON.video) return { error: 'ErrorEmptyDownload' }; - let masterJSON_Video = masterJSON.video.sort((a, b) => Number(b.width) - Number(a.width)).filter(a => a['format'] === "mp42"), + let masterJSON_Video = masterJSON.video.sort((a, b) => Number(b.width) - Number(a.width)).filter(a => ["dash", "mp42"].includes(a['format'])), bestVideo = masterJSON_Video[0]; if (Number(quality) < Number(resolutionMatch[bestVideo["width"]])) { bestVideo = masterJSON_Video.find(i => resolutionMatch[i["width"]] === quality) diff --git a/src/modules/processing/services/vk.js b/src/modules/processing/services/vk.js index 30d05f29..d956c3b9 100644 --- a/src/modules/processing/services/vk.js +++ b/src/modules/processing/services/vk.js @@ -12,7 +12,7 @@ export default async function(o) { if (!html) return { error: 'ErrorCouldntFetch' }; - // decode cyrillic from windows-1251 because vk still uses apis from prehistoring times + // decode cyrillic from windows-1251 because vk still uses apis from prehistoric times let decoder = new TextDecoder('windows-1251'); html = decoder.decode(html); @@ -35,7 +35,7 @@ export default async function(o) { let fileMetadata = { title: cleanString(js.player.params[0].md_title.trim()), - artist: cleanString(js.player.params[0].md_author.trim()), + author: cleanString(js.player.params[0].md_author.trim()), } if (url) return { @@ -44,7 +44,7 @@ export default async function(o) { service: "vk", id: `${o.userId}_${o.videoId}`, title: fileMetadata.title, - author: fileMetadata.artist, + author: fileMetadata.author, resolution: `${quality}p`, qualityLabel: `${quality}p`, extension: "mp4" diff --git a/src/modules/processing/servicesConfig.json b/src/modules/processing/servicesConfig.json index 6ecd2745..e9021490 100644 --- a/src/modules/processing/servicesConfig.json +++ b/src/modules/processing/servicesConfig.json @@ -1,5 +1,5 @@ { - "audioIgnore": ["vk"], + "audioIgnore": ["vk", "ok"], "config": { "bilibili": { "alias": "bilibili.com videos", @@ -28,6 +28,12 @@ "patterns": ["video:userId_:videoId", "clip:userId_:videoId", "clips:duplicate?z=clip:userId_:videoId"], "enabled": true }, + "ok": { + "alias": "ok video", + "tld": "ru", + "patterns": ["video/:id"], + "enabled": true + }, "youtube": { "alias": "youtube videos, shorts & music", "patterns": ["watch?v=:id", "embed/:id", "watch/:id"], @@ -80,7 +86,7 @@ }, "pinterest": { "alias": "pinterest videos & stories", - "patterns": ["pin/:id"], + "patterns": ["pin/:id", "url_shortener/:shortLink"], "enabled": true }, "streamable": { diff --git a/src/modules/processing/servicesPatternTesters.js b/src/modules/processing/servicesPatternTesters.js index e1f3d01c..970e8f40 100644 --- a/src/modules/processing/servicesPatternTesters.js +++ b/src/modules/processing/servicesPatternTesters.js @@ -5,9 +5,12 @@ export const testers = { "instagram": (patternMatch) => patternMatch.postId?.length <= 12 || (patternMatch.username?.length <= 30 && patternMatch.storyId?.length <= 24), + + "ok": (patternMatch) => + patternMatch.id?.length <= 16, "pinterest": (patternMatch) => - patternMatch.id?.length <= 128, + patternMatch.id?.length <= 128 || patternMatch.shortLink?.length <= 32, "reddit": (patternMatch) => patternMatch.sub?.length <= 22 && patternMatch.id?.length <= 10, diff --git a/src/modules/processing/url.js b/src/modules/processing/url.js index 2f1ac87f..9c87889d 100644 --- a/src/modules/processing/url.js +++ b/src/modules/processing/url.js @@ -25,6 +25,13 @@ export function aliasURL(url) { }`) } break; + case "pin": + if (url.hostname === 'pin.it' && parts.length === 2) { + url = new URL(`https://pinterest.com/url_shortener/${ + encodeURIComponent(parts[1]) + }`) + } + break; case "vxtwitter": case "fixvx": diff --git a/src/modules/stream/stream.js b/src/modules/stream/stream.js index fb4f7ed6..f254dacc 100644 --- a/src/modules/stream/stream.js +++ b/src/modules/stream/stream.js @@ -1,4 +1,4 @@ -import { streamAudioOnly, streamDefault, streamLiveRender, streamVideoOnly } from "./types.js"; +import { streamAudioOnly, streamDefault, streamLiveRender, streamVideoOnly, convertToGif } from "./types.js"; export default async function(res, streamInfo) { try { @@ -10,6 +10,9 @@ export default async function(res, streamInfo) { case "render": await streamLiveRender(streamInfo, res); break; + case "gif": + convertToGif(streamInfo, res); + break; case "remux": case "mute": streamVideoOnly(streamInfo, res); diff --git a/src/modules/stream/types.js b/src/modules/stream/types.js index c2bd2910..88ebc61e 100644 --- a/src/modules/stream/types.js +++ b/src/modules/stream/types.js @@ -212,3 +212,40 @@ export function streamVideoOnly(streamInfo, res) { shutdown(); } } + +export function convertToGif(streamInfo, res) { + let process; + const shutdown = () => (killProcess(process), closeResponse(res)); + + try { + let args = [ + '-loglevel', '-8' + ] + if (streamInfo.service === "twitter") { + args.push('-seekable', '0') + } + args.push('-i', streamInfo.urls) + args = args.concat(ffmpegArgs["gif"]); + args.push('-f', "gif", 'pipe:3'); + + process = spawn(ffmpeg, args, { + windowsHide: true, + stdio: [ + 'inherit', 'inherit', 'inherit', + 'pipe' + ], + }); + + const [,,, muxOutput] = process.stdio; + + res.setHeader('Connection', 'keep-alive'); + res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename.split('.')[0] + ".gif")); + + pipe(muxOutput, res, shutdown); + + process.on('close', shutdown); + res.on('finish', shutdown); + } catch { + shutdown(); + } +} diff --git a/src/modules/sub/utils.js b/src/modules/sub/utils.js index 28d37c6c..8b5a2c84 100644 --- a/src/modules/sub/utils.js +++ b/src/modules/sub/utils.js @@ -8,7 +8,7 @@ const apiVar = { aFormat: ["best", "mp3", "ogg", "wav", "opus"], filenamePattern: ["classic", "pretty", "basic", "nerdy"] }, - booleanOnly: ["isAudioOnly", "isNoTTWatermark", "isTTFullAudio", "isAudioMuted", "dubLang", "vimeoDash", "disableMetadata"] + booleanOnly: ["isAudioOnly", "isNoTTWatermark", "isTTFullAudio", "isAudioMuted", "dubLang", "vimeoDash", "disableMetadata", "twitterGif"] } const forbiddenChars = ['}', '{', '(', ')', '\\', '>', '<', '^', '*', '!', '~', ';', ':', ',', '`', '[', ']', '#', '$', '"', "'", "@", '==']; const forbiddenCharsString = ['}', '{', '%', '>', '<', '^', ';', '`', '$', '"', "@", '=']; @@ -84,7 +84,8 @@ export function checkJSONPost(obj) { isAudioMuted: false, disableMetadata: false, dubLang: false, - vimeoDash: false + vimeoDash: false, + twitterGif: false } try { let objKeys = Object.keys(obj); diff --git a/src/test/tests.json b/src/test/tests.json index c224f5e4..d3170858 100644 --- a/src/test/tests.json +++ b/src/test/tests.json @@ -1162,5 +1162,14 @@ "code": 200, "status": "stream" } + }], + "ok": [{ + "name": "regular video", + "url": "https://ok.ru/video/7204071410346", + "params": {}, + "expected": { + "code": 200, + "status": "stream" + } }] }