Skip to content

Instantly share code, notes, and snippets.

@n3ko-asakura
Created July 17, 2025 06:45
Show Gist options
  • Save n3ko-asakura/68c5efb5808ee966c219b8ed1b0f951c to your computer and use it in GitHub Desktop.
Save n3ko-asakura/68c5efb5808ee966c219b8ed1b0f951c to your computer and use it in GitHub Desktop.
// Downloads video fragments from [Abema global](www.abema-global.com).
// Only downloads 1080p streams.
// Read comments below for how to set `EVENT_ID`, `DEVICE_ID` and `AUTH_HEADER`.
//
// Usage: `node ./abema-downloader.js`
// Tested node version: v22.14.0
import * as fs from "fs";
// The user-visible link to the event is `https://www.abema-global.com/en/lives/<EVENT_ID>`
const EVENT_ID = "<RANDOM-STRING-OF-CHARACTERS>";
// Upon opening the video player, there will be a network request that looks like this:
// https://api.abema-global.com/v1/events/<EVENT_ID>:watchArchive
// Replace DEVICE_ID with the request body's `deviceId` field in that request.
// Not 100% sure if it can just be any random UUIDv4.
const DEVICE_ID = "<YOUR-DEVICE-ID-IS-A-UUIDv4>";
// The aforementioned request's authorization header.
const AUTH_HEADER = "Bearer <YOUR-BEARER-TOKEN-IS-A-JWT>";
// Path to the downloaded fragments and file list
const FRAGMENT_FOLDER = "frags";
const FILELIST_PATH = "filelist.txt";
async function generateKeyToken(publicKey) {
const exportedKey = await crypto.subtle.exportKey("spki", publicKey);
return btoa(String.fromCharCode.apply(null, new Uint8Array(exportedKey)));
}
async function decryptRsa(privateKey, base64Data) {
const rawData = atob(base64Data);
const data = new ArrayBuffer(rawData.length);
const dataView = new Uint8Array(data);
for (let i = 0; i < rawData.length; i++) {
dataView[i] = rawData.charCodeAt(i);
}
const decrypted = await crypto.subtle.decrypt({ name: "RSA-OAEP" }, privateKey, data);
return new TextDecoder().decode(decrypted);
}
async function getArchiveInfo() {
const { privateKey, publicKey } = await crypto.subtle.generateKey({
name: "RSA-OAEP",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-1"
}, true, ["decrypt"]);
const publicKeyToken = await generateKeyToken(publicKey);
return fetch(`https://api.abema-global.com/v1/events/${EVENT_ID}:watchArchive`, {
method: "POST",
headers: {
Authorization: AUTH_HEADER,
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({
deviceId: DEVICE_ID,
token: publicKeyToken,
method: 1,
encryptType: 1
})
})
.then(res => res.json())
.then(async (data) => ({
key: await decryptRsa(privateKey, data.hls.key),
iv: await decryptRsa(privateKey, data.hls.iv),
url: data.hls.urls[0], // `data.hls.urls` only contained a single URL for the archives I tested
}));
}
async function highestQualityStream(indexUrl) {
const res = await fetch(indexUrl, { method: "GET" })
.then(res => res.text());
const lines = res.split('\n');
// Assume 1080p is highest quality
const resIndex = lines.findIndex(line => line.includes("RESOLUTION=1920x1080"));
const filename = lines[resIndex + 1]?.trim();
return indexUrl.slice(0, indexUrl.lastIndexOf('/') + 1) + filename;
}
function hexToArrayBuffer(hex) {
var bytes = [];
for (var c = 0; c < hex.length; c += 2)
bytes.push(parseInt(hex.substr(c, 2), 16));
return new Uint8Array(bytes).buffer;
}
async function getFramentUrls(m3u8Url) {
const m3u8 = await fetch(m3u8Url, { method: "GET" }).then(res => res.text());
const frags = [];
const lines = m3u8.split('\n');
for (const line of lines) {
if (line.trim() === "#EXT-X-ENDLIST") break; // End of playlist
if (line.startsWith("#")) continue; // Skip metadata lines
if (!line.trim()) continue; // Skip empty lines
frags.push(line.trim());
}
return frags;
}
async function main() {
const archiveInfo = await getArchiveInfo();
console.log("archive info:", archiveInfo);
const hqUrl = await highestQualityStream(archiveInfo.url);
console.log("High quality URL:", hqUrl);
const key = await crypto.subtle.importKey(
"raw",
hexToArrayBuffer(archiveInfo.key),
{ name: "AES-CBC" },
false,
["encrypt", "decrypt"],
);
const iv = hexToArrayBuffer(archiveInfo.iv);
fs.mkdirSync(FRAGMENT_FOLDER, { recursive: true });
if (fs.existsSync(FILELIST_PATH)) {
fs.unlinkSync(FILELIST_PATH);
}
const fragUrlBase = hqUrl.slice(0, hqUrl.lastIndexOf('/')); // Remove the last part of the URL
const frags = await getFramentUrls(hqUrl);
for (const frag of frags) {
const fragPath = `${FRAGMENT_FOLDER}/${frag}`;
fs.appendFileSync(FILELIST_PATH, `file '${fragPath}'\n`);
if (fs.existsSync(fragPath)) {
console.log(`${frag} already downloaded. Skipping...`);
continue;
}
const data = await fetch(`${fragUrlBase}/${frag}`, { method: "GET" })
.then(res => res.arrayBuffer())
.then(data => crypto.subtle.decrypt({ name: "AES-CBC", iv: iv }, key, data));
fs.writeFileSync(fragPath, Buffer.from(data));
console.log(`Downloaded fragment: ${fragPath}`);
}
console.log("All fragments downloaded. You can use the following command to concatenate them into a single file:");
console.log(`ffmpeg -f concat -safe 0 -i ${FILELIST_PATH} -c copy out.ts`);
}
main();
@Krazete
Copy link

Krazete commented Sep 21, 2025

Thank you so much for this!
But I noticed my download's duration is 2:27:04 (8824s) while the website's duration is 2:24:40 (8680s). There are 2168 fragments, so it appears to be adding an extra 66.4ms every 4 seconds.
It's subtle but still noticeable. Is there any way to fix this?

@n3ko-asakura
Copy link
Author

I don't have any idea what might be causing this or how to fix this on the downloader side. But if it's very consistent in how it adds extra video you could write a script to remove them.

Unfortunately I have no plans to maintain this (unless I have something else I want to download from abema, which might be in many years or never).

@Krazete
Copy link

Krazete commented Sep 21, 2025

Figured it out! Something about concat demuxer vs concat protocol and how the former has to deal with codec delay while the latter doesn't since it's simple concatenation.
I renamed filelist.txt to filelist.sh and changed all the lines in it from file 'frags/index_1_XXXX.ts' to cat 'frags/index_1_XXXX.ts' >> out.ts and ran it.
The resulting out.ts doesn't play smoothly at all, but after doing ffmpeg -i out.ts -c copy out.mp4 it comes out perfectly. No more stuttering or audio delay; it matches the website video exactly now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment