mirror of
https://github.com/imputnet/cobalt.git
synced 2026-10-25 19:42:24 +00:00
Merge cba14785e6 into a636575b09
This commit is contained in:
commit
2d68193fcb
8 changed files with 360 additions and 0 deletions
|
|
@ -26,6 +26,7 @@ if the desired service isn't supported yet, feel free to create an appropriate i
|
|||
| newgrounds | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| ok.ru | ✅ | ❌ | ✅ | ✅ | ✅ |
|
||||
| pinterest | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||
| pr0gramm (pr0.app)| ✅ | ✅ | ✅ | ➖ | ✅ |
|
||||
| reddit | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
| rutube | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| snapchat | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||
|
|
@ -51,6 +52,7 @@ if the desired service isn't supported yet, feel free to create an appropriate i
|
|||
| instagram | supports reels, photos, and videos. lets you pick what to save from multi-media posts. |
|
||||
| facebook | supports public accessible videos content only. |
|
||||
| pinterest | supports photos, gifs, videos and stories. |
|
||||
| pr0gramm | supports images and videos, in the h264 and vp9 variants that the site itself offers. also accepts pr0.app links. |
|
||||
| reddit | supports gifs and videos. |
|
||||
| snapchat | supports spotlights and stories. lets you pick what to save from stories. |
|
||||
| rutube | supports yappy & private links. |
|
||||
|
|
|
|||
|
|
@ -183,6 +183,13 @@ export default function({
|
|||
params = { type: "proxy" };
|
||||
break;
|
||||
|
||||
/* pr0gramm media is served over plain cdn urls, so both a
|
||||
redirect and a tunnel work. the redirect is preferred and
|
||||
`alwaysProxy` turns it into a tunnel when needed. */
|
||||
case "pr0gramm":
|
||||
responseType = "redirect";
|
||||
break;
|
||||
|
||||
case "facebook":
|
||||
case "instagram":
|
||||
case "tumblr":
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import loom from "./services/loom.js";
|
|||
import facebook from "./services/facebook.js";
|
||||
import bluesky from "./services/bluesky.js";
|
||||
import newgrounds from "./services/newgrounds.js";
|
||||
import pr0gramm from "./services/pr0gramm.js";
|
||||
|
||||
let freebind;
|
||||
|
||||
|
|
@ -266,6 +267,14 @@ export default async function({ host, patternMatch, params, authType }) {
|
|||
});
|
||||
break;
|
||||
|
||||
case "pr0gramm":
|
||||
r = await pr0gramm({
|
||||
...patternMatch,
|
||||
quality: params.videoQuality,
|
||||
isAudioOnly,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
return createResponse("error", {
|
||||
code: "error.api.service.unsupported"
|
||||
|
|
|
|||
|
|
@ -75,6 +75,19 @@ export const services = {
|
|||
"url_shortener/:shortLink"
|
||||
],
|
||||
},
|
||||
pr0gramm: {
|
||||
/* pr0gramm.com and pr0.app share the same url layout, and an item link
|
||||
is *always* "…/<item id>" — no matter whether it was shared from a
|
||||
feed (/top/<id>, /new/<id>, /junk/<id>), a profile
|
||||
(/user/<user>/<id>) or a collection (/collections/<name>/<id>).
|
||||
|
||||
pr0.app additionally redirects unknown path segments to /new/<id>
|
||||
(it puts that into its og:url meta tag), so the pattern stays as
|
||||
permissive as pr0gramm itself and the id is validated numerially in
|
||||
service-patterns.js instead. */
|
||||
patterns: ["**/:id"],
|
||||
altDomains: ["pr0.app"],
|
||||
},
|
||||
newgrounds: {
|
||||
patterns: [
|
||||
"portal/view/:id",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ export const testers = {
|
|||
pattern.id?.length <= 128 ||
|
||||
pattern.shortLink?.length <= 32,
|
||||
|
||||
"pr0gramm": pattern =>
|
||||
// item ids are numeric and can be anywhere up to int64
|
||||
pattern.id?.length <= 19 && /^\d+$/.test(pattern.id || ""),
|
||||
|
||||
"reddit": pattern =>
|
||||
pattern.id?.length <= 16 && !pattern.sub && !pattern.user ||
|
||||
(pattern.sub?.length <= 22 && pattern.id?.length <= 16) ||
|
||||
|
|
|
|||
201
api/src/processing/services/pr0gramm.js
Normal file
201
api/src/processing/services/pr0gramm.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
|
||||
import { genericUserAgent } from "../../config.js";
|
||||
|
||||
/*
|
||||
pr0gramm is an imageboard. every upload ("item") is a single image or a video,
|
||||
and all media is served from plain cdn hosts without any login.
|
||||
|
||||
the api returns bare paths (e.g. `2026/10/09/<hash>-h264-sd.mp4`), so the host
|
||||
has to be picked based on what is being downloaded:
|
||||
|
||||
- `image` / `fullsize` -> img.pr0gramm.com (images)
|
||||
- `image` (when the item is a video) / `variants` -> vid.pr0gramm.com
|
||||
- `thumb` -> thumb.pr0gramm.com (smaller re-encode, jpeg/webp)
|
||||
|
||||
videos come as h264 and vp9 variants. h264 always has audio, vp9 usually
|
||||
doesn't, so h264 is used for audio downloads.
|
||||
*/
|
||||
|
||||
const mediaHosts = {
|
||||
image: "https://img.pr0gramm.com/",
|
||||
video: "https://vid.pr0gramm.com/",
|
||||
thumb: "https://thumb.pr0gramm.com/",
|
||||
};
|
||||
|
||||
export const mediaURL = (path, host = "image") => {
|
||||
assert(mediaHosts[host]);
|
||||
return new URL(path, mediaHosts[host]).toString();
|
||||
};
|
||||
|
||||
const videoExtensions = ["mp4", "webm", "mov", "mkv"];
|
||||
|
||||
const fileExtension = (path) => path?.split(".").pop().toLowerCase();
|
||||
|
||||
// videos live on vid. and images on img., and both hosts are happy to serve
|
||||
// the other type — but only with the extension the path actually has, so it
|
||||
// has to be taken from the path itself
|
||||
export const mediaURLFor = (path) => mediaURL(
|
||||
path,
|
||||
videoExtensions.includes(fileExtension(path)) ? "video" : "image"
|
||||
);
|
||||
|
||||
/*
|
||||
pr0gramm does not always keep its media paths up to date: an item that was
|
||||
converted after its upload has the old path in `fullsize` (e.g. a `.png`
|
||||
that is really served as `.jpg`), and a video without any h264/vp9 variant
|
||||
has only its `image` path. so the reachable one is probed instead of
|
||||
trusting whichever field happens to be filled.
|
||||
*/
|
||||
const pickReachableMedia = async (candidates) => {
|
||||
for (const path of candidates) {
|
||||
if (!path) continue;
|
||||
|
||||
const url = mediaURLFor(path),
|
||||
reachable = await fetch(url, {
|
||||
method: "HEAD",
|
||||
headers: { "user-agent": genericUserAgent }
|
||||
}).then(r => r.ok).catch(() => false);
|
||||
|
||||
if (reachable) {
|
||||
return { url, extension: fileExtension(path) };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
variants are named `<codec>-<height class>`, and the height classes are
|
||||
consistent across qualities (sd is always 480p, hd always 720p, ...), so they
|
||||
are a more reliable source for picking a quality than the api heights.
|
||||
*/
|
||||
const heightClasses = {
|
||||
low_res: 360,
|
||||
sd: 480,
|
||||
hd: 720,
|
||||
full_hd: 1080,
|
||||
ultra_hd: 2160,
|
||||
"4k": 2160,
|
||||
"8k": 4320,
|
||||
};
|
||||
|
||||
const videoTypes = [
|
||||
{ mimeType: "video/mp4", extension: "mp4", codecs: ["h264", "hevc", "av1"] },
|
||||
{ mimeType: "video/webm", extension: "webm", codecs: ["vp9", "av1"] },
|
||||
];
|
||||
|
||||
const getVideoType = (variant) =>
|
||||
videoTypes.find(type => type.mimeType === variant?.mimeType);
|
||||
|
||||
export const selectVariant = (variants, quality, codec = "h264") => {
|
||||
const parsed = (variants || [])
|
||||
.filter(variant => variant?.path && getVideoType(variant))
|
||||
.map(variant => ({
|
||||
...variant,
|
||||
height: heightClasses[variant.name?.split("-").pop()]
|
||||
|| variant.height
|
||||
|| 0,
|
||||
}))
|
||||
.sort((a, b) => a.height - b.height);
|
||||
|
||||
if (!parsed.length) return;
|
||||
|
||||
// prefer the requested codec, but never fail just because it's missing
|
||||
const sameCodec = parsed.filter(variant => variant.codec === codec);
|
||||
const candidates = sameCodec.length ? sameCodec : parsed;
|
||||
|
||||
const target = quality === "max" ? Infinity : parseInt(quality) || 1080;
|
||||
return candidates.findLast(variant => variant.height <= target)
|
||||
?? candidates[0];
|
||||
};
|
||||
|
||||
const getFilename = (item, extension, qualityLabel, resolution) => ({
|
||||
filenameAttributes: {
|
||||
service: "pr0gramm",
|
||||
id: `${item.id}`,
|
||||
title: `pr0gramm ${item.id}`,
|
||||
...(item.user && { author: item.user }),
|
||||
...(extension && { extension }),
|
||||
...(qualityLabel && { qualityLabel: `${qualityLabel}p` }),
|
||||
...(resolution?.width && {
|
||||
resolution: `${resolution.width}x${resolution.height}`,
|
||||
}),
|
||||
},
|
||||
fileMetadata: {
|
||||
title: `${item.user ? `${item.user} - ` : ""}pr0gramm ${item.id}`,
|
||||
...(item.user && { artist: item.user }),
|
||||
...(item.created && {
|
||||
date: new Date(item.created * 1000).toISOString().split("T")[0],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
async function getItem(id) {
|
||||
const api = new URL("https://pr0gramm.com/api/items/get");
|
||||
api.searchParams.set("id", id);
|
||||
// `flags` is required by the api. 1 = sfw, the only flag that is
|
||||
// visible without an account
|
||||
api.searchParams.set("flags", "1");
|
||||
|
||||
const response = await fetch(api, {
|
||||
headers: { "user-agent": genericUserAgent }
|
||||
}).catch(() => {});
|
||||
|
||||
if (!response?.ok) return;
|
||||
|
||||
const data = await response.json().catch(() => {});
|
||||
if (data?.error) return;
|
||||
|
||||
// the api returns items *starting* at the requested id, so it has to be
|
||||
// looked up explicitly. `id` is a number in the response
|
||||
return data?.items?.find(item => item.id === +id);
|
||||
}
|
||||
|
||||
export default async function ({ id, quality, isAudioOnly }) {
|
||||
const item = await getItem(id);
|
||||
if (!item) return { error: "fetch.empty" };
|
||||
|
||||
const video = selectVariant(
|
||||
item.variants,
|
||||
isAudioOnly ? "max" : quality,
|
||||
"h264"
|
||||
);
|
||||
|
||||
if (isAudioOnly) {
|
||||
if (!video) return { error: "fetch.empty" };
|
||||
|
||||
return {
|
||||
urls: mediaURL(video.path, "video"),
|
||||
audioFilename: `pr0gramm_${item.id}_audio`,
|
||||
fileMetadata: getFilename(item).fileMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
if (!video) {
|
||||
// uploads without any video variant are images
|
||||
const photo = await pickReachableMedia([ item.image, item.fullsize ]);
|
||||
if (!photo) return { error: "fetch.empty" };
|
||||
|
||||
return {
|
||||
urls: photo.url,
|
||||
// photos have no quality labels, so a plain filename reads better
|
||||
// than the "… ()" that the filename styles would produce
|
||||
filename: `pr0gramm_${item.id}.${photo.extension}`,
|
||||
// the rare video without any variants is still a video, and
|
||||
// shouldn't be saved as a photo
|
||||
...(!videoExtensions.includes(photo.extension) && { isPhoto: true }),
|
||||
};
|
||||
}
|
||||
|
||||
const type = getVideoType(video),
|
||||
// pr0gramm transcribes audio uploads and renders the default subtitle
|
||||
// against the sd variant
|
||||
subtitle = item.audio
|
||||
? item.subtitles?.find(sub => sub.isDefault)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
urls: mediaURL(video.path, "video"),
|
||||
...getFilename(item, type.extension, video.height, video),
|
||||
...(subtitle && { subtitles: mediaURL(subtitle.path, "video") }),
|
||||
};
|
||||
}
|
||||
|
|
@ -31,6 +31,12 @@ function aliasURL(url) {
|
|||
}
|
||||
break;
|
||||
|
||||
case "pr0":
|
||||
if (services.pr0gramm.altDomains.includes(url.hostname)) {
|
||||
url.hostname = 'pr0gramm.com';
|
||||
}
|
||||
break;
|
||||
|
||||
case "pin":
|
||||
if (url.hostname === 'pin.it' && parts.length === 2) {
|
||||
url = new URL(`https://pinterest.com/url_shortener/${
|
||||
|
|
|
|||
118
api/src/util/tests/pr0gramm.json
Normal file
118
api/src/util/tests/pr0gramm.json
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
[
|
||||
{
|
||||
"name": "video",
|
||||
"url": "https://pr0gramm.com/top/7167415",
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "redirect"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "video on pr0.app",
|
||||
"url": "https://pr0.app/top/7167415",
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "redirect"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "video, quality capped at 360p",
|
||||
"url": "https://pr0gramm.com/top/7167415",
|
||||
"params": {
|
||||
"videoQuality": "360"
|
||||
},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "redirect"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "video, muted",
|
||||
"url": "https://pr0gramm.com/top/7167415",
|
||||
"params": {
|
||||
"downloadMode": "mute"
|
||||
},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "tunnel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "video, audio only",
|
||||
"url": "https://pr0gramm.com/top/7167415",
|
||||
"params": {
|
||||
"downloadMode": "audio"
|
||||
},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "tunnel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "photo",
|
||||
"url": "https://pr0gramm.com/top/7167378",
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "tunnel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "photo on pr0.app",
|
||||
"url": "https://pr0.app/top/7167378",
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "tunnel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "photo whose fullsize path is stale",
|
||||
"url": "https://pr0gramm.com/top/7167411",
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "tunnel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user link",
|
||||
"url": "https://pr0gramm.com/user/dex0r/7167415",
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "redirect"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "arbitrary path with an item id",
|
||||
"url": "https://pr0gramm.com/quatsch/foobar/7167415",
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 200,
|
||||
"status": "redirect"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "non numeric id",
|
||||
"url": "https://pr0gramm.com/top/dex0r",
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 400,
|
||||
"status": "error",
|
||||
"errorCode": "error.api.link.unsupported"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "inexistent item",
|
||||
"url": "https://pr0gramm.com/top/999999999",
|
||||
"params": {},
|
||||
"expected": {
|
||||
"code": 400,
|
||||
"status": "error",
|
||||
"errorCode": "error.api.fetch.empty"
|
||||
}
|
||||
}
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue