diff --git a/api/src/processing/create-filename.js b/api/src/processing/create-filename.js index cc985d511..911b5603e 100644 --- a/api/src/processing/create-filename.js +++ b/api/src/processing/create-filename.js @@ -15,7 +15,11 @@ export default (f, style, isAudioOnly, isAudioMuted) => { let classicTags = [...infoBase]; let basicTags = []; - const title = `${sanitizeString(f.title)} - ${sanitizeString(f.author)}`; + let title = sanitizeString(f.title); + + if (f.author) { + title += ` - ${sanitizeString(f.author)}`; + } if (f.resolution) { classicTags.push(f.resolution); diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index 262b3acf8..8d2c1e381 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -160,7 +160,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab case "audio": if (audioIgnore.includes(host) || (host === "reddit" && r.typeId === "redirect")) { return createResponse("error", { - code: "error.api.fetch.empty" + code: "error.api.service.audio_not_supported" }) } diff --git a/api/src/processing/match.js b/api/src/processing/match.js index fe587f09d..57f04b362 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -77,8 +77,9 @@ export default async function({ host, patternMatch, params }) { case "vk": r = await vk({ - userId: patternMatch.userId, + ownerId: patternMatch.ownerId, videoId: patternMatch.videoId, + accessKey: patternMatch.accessKey, quality: params.videoQuality }); break; diff --git a/api/src/processing/service-config.js b/api/src/processing/service-config.js index 0744474c1..81afaf39f 100644 --- a/api/src/processing/service-config.js +++ b/api/src/processing/service-config.js @@ -154,9 +154,14 @@ export const services = { }, vk: { patterns: [ - "video:userId_:videoId", - "clip:userId_:videoId", - "clips:duplicate?z=clip:userId_:videoId" + "video:ownerId_:videoId", + "clip:ownerId_:videoId", + "clips:duplicate?z=clip:ownerId_:videoId", + "videos:duplicate?z=video:ownerId_:videoId", + "video:ownerId_:videoId_:accessKey", + "clip:ownerId_:videoId_:accessKey", + "clips:duplicate?z=clip:ownerId_:videoId_:accessKey", + "videos:duplicate?z=video:ownerId_:videoId_:accessKey" ], subdomains: ["m"], altDomains: ["vkvideo.ru", "vk.ru"], diff --git a/api/src/processing/service-patterns.js b/api/src/processing/service-patterns.js index 0c3d63d4b..e8c466399 100644 --- a/api/src/processing/service-patterns.js +++ b/api/src/processing/service-patterns.js @@ -56,7 +56,8 @@ export const testers = { && (!pattern.password || pattern.password.length < 16), "vk": pattern => - pattern.userId?.length <= 10 && pattern.videoId?.length <= 10, + (pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10) || + (pattern.ownerId?.length <= 10 && pattern.videoId?.length <= 10 && pattern.videoId?.accessKey <= 18), "youtube": pattern => pattern.id?.length <= 11, diff --git a/api/src/processing/services/vk.js b/api/src/processing/services/vk.js index 6f5f2ea2f..33224d695 100644 --- a/api/src/processing/services/vk.js +++ b/api/src/processing/services/vk.js @@ -1,62 +1,140 @@ -import { genericUserAgent, env } from "../../config.js"; +import { env } from "../../config.js"; -const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240"]; +const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240", "144"]; -export default async function(o) { - let html, url, quality = o.quality === "max" ? 2160 : o.quality; +const oauthUrl = "https://oauth.vk.com/oauth/get_anonym_token"; +const apiUrl = "https://api.vk.com/method"; - html = await fetch(`https://vk.com/video${o.userId}_${o.videoId}`, { +const clientId = "51552953"; +const clientSecret = "qgr0yWwXCrsxA1jnRtRX"; + +// used in stream/shared.js for accessing media files +export const vkClientAgent = "com.vk.vkvideo.prod/822 (iPhone, iOS 16.7.7, iPhone10,4, Scale/2.0) SAK/1.119"; + +const cachedToken = { + token: "", + expiry: 0, + device_id: "", +}; + +const getToken = async () => { + if (cachedToken.expiry - 10 > Math.floor(new Date().getTime() / 1000)) { + return cachedToken.token; + } + + const randomDeviceId = crypto.randomUUID().toUpperCase(); + + const anonymOauth = new URL(oauthUrl); + anonymOauth.searchParams.set("client_id", clientId); + anonymOauth.searchParams.set("client_secret", clientSecret); + anonymOauth.searchParams.set("device_id", randomDeviceId); + + const oauthResponse = await fetch(anonymOauth.toString(), { headers: { - "user-agent": genericUserAgent + "user-agent": vkClientAgent, } + }).then(r => { + if (r.status === 200) { + return r.json(); + } + }); + + if (!oauthResponse) return; + + if (oauthResponse?.token && oauthResponse?.expired_at && typeof oauthResponse?.expired_at === "number") { + cachedToken.token = oauthResponse.token; + cachedToken.expiry = oauthResponse.expired_at; + cachedToken.device_id = randomDeviceId; + } + + if (!cachedToken.token) return; + + return cachedToken.token; +} + +const getVideo = async (ownerId, videoId, accessKey) => { + const video = await fetch(`${apiUrl}/video.get`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded; charset=utf-8", + "user-agent": vkClientAgent, + }, + body: new URLSearchParams({ + anonymous_token: cachedToken.token, + device_id: cachedToken.device_id, + lang: "en", + v: "5.244", + videos: `${ownerId}_${videoId}${accessKey ? `_${accessKey}` : ''}` + }).toString() }) - .then(r => r.arrayBuffer()) - .catch(() => {}); + .then(r => { + if (r.status === 200) { + return r.json(); + } + }); - if (!html) return { error: "fetch.fail" }; + return video; +} + +export default async function ({ ownerId, videoId, accessKey, quality }) { + const token = await getToken(); + if (!token) return { error: "fetch.fail" }; - // decode cyrillic from windows-1251 because vk still uses apis from prehistoric times - let decoder = new TextDecoder('windows-1251'); - html = decoder.decode(html); + const videoGet = await getVideo(ownerId, videoId, accessKey); - if (!html.includes(`{"lang":`)) return { error: "fetch.empty" }; + if (!videoGet || !videoGet.response || videoGet.response.items.length !== 1) { + return { error: "fetch.empty" }; + } - let js = JSON.parse('{"lang":' + html.split(`{"lang":`)[1].split(']);')[0]); + const video = videoGet.response.items[0]; - if (Number(js.mvData.is_active_live) !== 0) { - return { error: "content.video.live" }; + if (video.restriction) { + const title = video.restriction.title; + if (title.endsWith("country") || title.endsWith("region.")) { + return { error: "content.video.region" }; + } + if (title === "Processing video") { + return { error: "fetch.empty" }; + } + return { error: "content.video.unavailable" }; + } + + if (!video.files || !video.duration) { + return { error: "fetch.fail" }; } - if (js.mvData.duration > env.durationLimit) { + if (video.duration > env.durationLimit) { return { error: "content.too_long" }; } - for (let i in resolutions) { - if (js.player.params[0][`url${resolutions[i]}`]) { - quality = resolutions[i]; + const userQuality = quality === "max" ? resolutions[0] : quality; + let pickedQuality; + + for (const resolution of resolutions) { + if (video.files[`mp4_${resolution}`] && +resolution <= +userQuality) { + pickedQuality = resolution; break } } - if (Number(quality) > Number(o.quality)) quality = o.quality; - url = js.player.params[0][`url${quality}`]; + const url = video.files[`mp4_${pickedQuality}`]; + + if (!url) return { error: "fetch.fail" }; - let fileMetadata = { - title: js.player.params[0].md_title.trim(), - author: js.player.params[0].md_author.trim(), + const fileMetadata = { + title: video.title.trim(), } - if (url) return { + return { urls: url, + fileMetadata, filenameAttributes: { service: "vk", - id: `${o.userId}_${o.videoId}`, + id: `${ownerId}_${videoId}${accessKey ? `_${accessKey}` : ''}`, title: fileMetadata.title, - author: fileMetadata.author, - resolution: `${quality}p`, - qualityLabel: `${quality}p`, + resolution: `${pickedQuality}p`, + qualityLabel: `${pickedQuality}p`, extension: "mp4" } } - return { error: "fetch.empty" } } diff --git a/api/src/stream/shared.js b/api/src/stream/shared.js index 91e1ac2f7..65af03f0e 100644 --- a/api/src/stream/shared.js +++ b/api/src/stream/shared.js @@ -1,4 +1,5 @@ import { genericUserAgent } from "../config.js"; +import { vkClientAgent } from "../processing/services/vk.js"; const defaultHeaders = { 'user-agent': genericUserAgent @@ -13,6 +14,9 @@ const serviceHeaders = { origin: 'https://www.youtube.com', referer: 'https://www.youtube.com', DNT: '?1' + }, + vk: { + 'user-agent': vkClientAgent } } diff --git a/api/src/util/tests/vk.json b/api/src/util/tests/vk.json index 1dc3ca953..71720af5a 100644 --- a/api/src/util/tests/vk.json +++ b/api/src/util/tests/vk.json @@ -40,7 +40,7 @@ } }, { - "name": "4k video", + "name": "big 4k video", "url": "https://vk.com/video-1112285_456248465", "params": { "videoQuality": "max" @@ -50,6 +50,17 @@ "status": "tunnel" } }, + { + "name": "short 4k video, 480p, vkvideo.ru domain", + "url": "https://vkvideo.ru/video-26006257_456245538", + "params": { + "videoQuality": "480" + }, + "expected": { + "code": 200, + "status": "tunnel" + } + }, { "name": "ancient video (fallback to 240p)", "url": "https://vk.com/video-1959_28496479", diff --git a/web/i18n/en/error.json b/web/i18n/en/error.json index bd5125324..750233380 100644 --- a/web/i18n/en/error.json +++ b/web/i18n/en/error.json @@ -34,6 +34,7 @@ "api.service.unsupported": "this service is not supported yet. have you pasted the right link?", "api.service.disabled": "this service is generally supported by cobalt, but it's disabled on this processing instance. try a link from another service!", + "api.service.audio_not_supported": "this service doesn't support audio extraction. try a link from another service!", "api.link.invalid": "your link is invalid or this service is not supported yet. have you pasted the right link?", "api.link.unsupported": "{{ service }} is supported, but i couldn't recognize your link. have you pasted the right one?",