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 4 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
142 changes: 112 additions & 30 deletions api/src/processing/services/vk.js
Original file line number Diff line number Diff line change
@@ -1,62 +1,144 @@
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;
}

// decode cyrillic from windows-1251 because vk still uses apis from prehistoric times
let decoder = new TextDecoder('windows-1251');
html = decoder.decode(html);
export default async function ({ ownerId, videoId, accessKey, quality }) {
const token = await getToken();
if (!token) return { error: "fetch.fail" };

const videoGet = await getVideo(ownerId, videoId, accessKey);

if (!videoGet || !videoGet.response || videoGet.response.items.length !== 1) {
return { error: "fetch.empty" };
}

if (!html.includes(`{"lang":`)) return { error: "fetch.empty" };
const video = videoGet.response.items[0];

let js = JSON.parse('{"lang":' + html.split(`{"lang":`)[1].split(']);')[0]);
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 (Number(js.mvData.is_active_live) !== 0) {
return { error: "content.video.live" };
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" };
}

const userQuality = quality === "max" ? 2160 : quality;
let pickedQuality;
wukko marked this conversation as resolved.
Show resolved Hide resolved

for (let i in resolutions) {
if (js.player.params[0][`url${resolutions[i]}`]) {
wukko marked this conversation as resolved.
Show resolved Hide resolved
quality = resolutions[i];
if (video.files[`mp4_${resolutions[i]}`]) {
pickedQuality = resolutions[i];
break
wukko marked this conversation as resolved.
Show resolved Hide resolved
}
}
if (Number(quality) > Number(o.quality)) quality = o.quality;

url = js.player.params[0][`url${quality}`];
if (Number(pickedQuality) > Number(userQuality)) {
pickedQuality = userQuality;
}

const url = video.files[`mp4_${pickedQuality}`];
wukko marked this conversation as resolved.
Show resolved Hide resolved

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
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