Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

api/vk: use proper api, add support for more links & access keys, refactor #964

Merged
merged 6 commits into from
Nov 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion api/src/processing/create-filename.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion api/src/processing/match-action.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
})
}

Expand Down
3 changes: 2 additions & 1 deletion api/src/processing/match.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 8 additions & 3 deletions api/src/processing/service-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
3 changes: 2 additions & 1 deletion api/src/processing/service-patterns.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
140 changes: 109 additions & 31 deletions api/src/processing/services/vk.js
Original file line number Diff line number Diff line change
@@ -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" }
}
4 changes: 4 additions & 0 deletions api/src/stream/shared.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { genericUserAgent } from "../config.js";
import { vkClientAgent } from "../processing/services/vk.js";

const defaultHeaders = {
'user-agent': genericUserAgent
Expand All @@ -13,6 +14,9 @@ const serviceHeaders = {
origin: 'https://www.youtube.com',
referer: 'https://www.youtube.com',
DNT: '?1'
},
vk: {
'user-agent': vkClientAgent
}
}

Expand Down
13 changes: 12 additions & 1 deletion api/src/util/tests/vk.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
}
},
{
"name": "4k video",
"name": "big 4k video",
"url": "https://vk.com/video-1112285_456248465",
"params": {
"videoQuality": "max"
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions web/i18n/en/error.json
Original file line number Diff line number Diff line change
Expand Up @@ -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?",
Expand Down
Loading