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

feat: threads support #575

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ this list is not final and keeps expanding over time. if support for a service y
| snapchat | ✅ | ✅ | ✅ | ➖ | ➖ |
| soundcloud | ➖ | ✅ | ➖ | ✅ | ✅ |
| streamable | ✅ | ✅ | ✅ | ➖ | ➖ |
| threads posts | ✅ | ✅ | ✅ | ➖ | ➖ |
| tiktok | ✅ | ✅ | ✅ | ❌ | ❌ |
| tumblr | ✅ | ✅ | ✅ | ➖ | ➖ |
| twitch clips | ✅ | ✅ | ✅ | ✅ | ✅ |
Expand All @@ -68,6 +69,7 @@ this list is not final and keeps expanding over time. if support for a service y
| snapchat | supports spotlights and stories. lets you pick what to save from stories. |
| rutube | supports yappy & private links. |
| soundcloud | supports private links. |
| threads | supports photos and videos. lets you pick what to save from multi-media posts. |
| tiktok | supports videos with or without watermark, images from slideshow without watermark, and full (original) audios. |
| twitter/x | lets you pick what to save from multi-media posts. may not be 100% reliable due to current management. |
| vimeo | audio downloads are only available for dash. |
Expand Down
2 changes: 2 additions & 0 deletions api/src/processing/match-action.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
switch (host) {
case "instagram":
case "twitter":
case "threads":
case "snapchat":
case "bsky":
params = { picker: r.picker };
Expand Down Expand Up @@ -151,6 +152,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
case "streamable":
case "snapchat":
case "loom":
case "threads":
case "twitch":
responseType = "redirect";
break;
Expand Down
10 changes: 10 additions & 0 deletions api/src/processing/match.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import rutube from "./services/rutube.js";
import dailymotion from "./services/dailymotion.js";
import snapchat from "./services/snapchat.js";
import loom from "./services/loom.js";
import threads from "./services/threads.js";
import facebook from "./services/facebook.js";
import bluesky from "./services/bluesky.js";

Expand Down Expand Up @@ -230,6 +231,15 @@ export default async function({ host, patternMatch, params }) {
});
break;

case "threads":
r = await threads({
...patternMatch,
quality: params.videoQuality,
alwaysProxy: params.alwaysProxy,
dispatcher
});
break;

case "facebook":
r = await facebook({
...patternMatch
Expand Down
4 changes: 4 additions & 0 deletions api/src/processing/service-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ export const services = {
"s/:id"
],
},
threads: {
patterns: [":user/post/:id"],
tld: "net",
},
tiktok: {
patterns: [
":user/video/:postId",
Expand Down
3 changes: 3 additions & 0 deletions api/src/processing/service-patterns.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export const testers = {
"streamable": pattern =>
pattern.id?.length === 6,

"threads": pattern =>
pattern.user?.length <= 33 && pattern.id?.length <= 32,

"tiktok": pattern =>
pattern.postId?.length <= 21 || pattern.id?.length <= 13,

Expand Down
121 changes: 121 additions & 0 deletions api/src/processing/services/threads.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { createStream } from "../../stream/manage.js";
import { getCookie, updateCookie } from "../cookie/manager.js";

const commonHeaders = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.7",
"Cache-Control": "no-cache",
"Dnt": "1",
"Priority": "u=0, i",
"Sec-Ch-Ua": '"Not/A)Brand";v="8", "Chromium";v="126", "Brave";v="126"',
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Model": '""',
"Sec-Ch-Ua-Platform": '"Windows"',
"Sec-Ch-Ua-Platform-Version": '"15.0.0"',
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "same-origin",
"Sec-Gpc": "1",
"Upgrade-Insecure-Requests": "1",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
};

const DATA_REGEX = /<script type="application\/json" {2}data-content-len="\d+" data-sjs>({"require":\[\["ScheduledServerJS","handle",null,\[{"__bbox":{"require":\[\["RelayPrefetchedStreamCache(?:(?:@|\\u0040)[0-9a-f]{32})?","next",\[],\["adp_BarcelonaPostPageQueryRelayPreloader_[0-9a-f]{23}",[^\n]+})<\/script>\n/;

export default async function({ user, id, quality, alwaysProxy, dispatcher }) {
const cookie = getCookie("threads");
const response = await fetch(`https://www.threads.net/${user}/post/${id}`, {
headers: {
...commonHeaders,
cookie
},
dispatcher
});
if (cookie) updateCookie(cookie, response.headers);

if (response.status !== 200) {
return { error: "fetch.fail" };
}
const html = await response.text();
const dataString = html.match(DATA_REGEX)?.[1];
if (!dataString) {
return { error: "fetch.fail" };
}

const data = JSON.parse(dataString);
const post = data?.require?.[0]?.[3]?.[0]?.__bbox?.require?.[0]?.[3]?.[1]?.__bbox?.result?.data?.data?.edges[0]?.node?.thread_items[0]?.post;
if (!post) {
return { error: "fetch.fail" };
}

const filenameBase = `threads_${post.user.username}_${post.code}`;

// Video
if (post.media_type === 2) {
if (!post.video_versions) {
return { error: "fetch.empty" };
}

// types: 640p = 101, 480p = 102, 480p-low = 103
const selectedQualityType = quality === "max" ? 101 : quality && parseInt(quality) <= 480 ? 102 : 101;
const video = post.video_versions.find((v) => v.type === selectedQualityType) || post.video_versions.sort((a, b) => a.type - b.type)[0];
if (!video) {
return { error: "fetch.empty" };
}

return {
urls: video.url,
filename: `${filenameBase}.mp4`,
audioFilename: `${filenameBase}_audio`
}
}

// Photo
if (post.media_type === 1) {
if (!post.image_versions2?.candidates) {
return { error: "fetch.empty" };
}

return {
urls: post.image_versions2.candidates[0].url,
filename: `${filenameBase}.jpg`,
isPhoto: true
}
}

// Mixed
if (post.media_type === 8) {
if (!post.carousel_media) {
return { error: "fetch.empty" };
}

return {
picker: post.carousel_media.map((media, i) => {
const type = media.video_versions ? "video" : "photo";
let url = media.video_versions ? media.video_versions[0].url : media.image_versions2.candidates[0].url;
const thumbProxy = createStream({
service: "threads",
type: "proxy",
u: media.image_versions2.candidates[0].url,
filename: `${filenameBase}_${i}.jpg`,
});
if (alwaysProxy) {
url = type === 'photo' ? thumbProxy : createStream({
service: "threads",
type: "proxy",
u: media.video_versions[0].url,
filename: `${filenameBase}_${i}.mp4`,
});
}

return {
type,
url,
thumb: thumbProxy
}
})
}
}

return { error: "fetch.fail" };
}
29 changes: 29 additions & 0 deletions api/src/util/tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -1493,5 +1493,34 @@
"status": "error"
}
}
],
"threads": [
{
"name": "video",
"url": "https://www.threads.net/@zuck/post/CzecNnZPaxr",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "photo",
"url": "https://www.threads.net/@soren.iverson/post/C8PdJ59pMLr",
"params": {},
"expected": {
"code": 200,
"status": "redirect"
}
},
{
"name": "mixed media",
"url": "https://www.threads.net/@snazzahguy/post/C8Q7UZDseWz",
"params": {},
"expected": {
"code": 200,
"status": "picker"
}
}
]
}
Loading