@postfetch/core 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/download.d.ts +39 -0
- package/dist/hls.d.ts +15 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +958 -21
- package/dist/internal.d.ts +44 -2
- package/dist/isobmff.d.ts +20 -0
- package/dist/pinterest.d.ts +2 -0
- package/dist/postfetch.d.ts +31 -0
- package/dist/reddit.d.ts +2 -0
- package/dist/remux.d.ts +8 -0
- package/dist/soundcloud.d.ts +2 -0
- package/package.json +5 -2
- package/src/download.ts +104 -10
- package/src/hls.ts +120 -0
- package/src/index.ts +21 -0
- package/src/internal.ts +38 -1
- package/src/isobmff.ts +85 -0
- package/src/pinterest.ts +237 -0
- package/src/postfetch.ts +50 -1
- package/src/reddit.ts +293 -0
- package/src/remux.ts +172 -0
- package/src/soundcloud.ts +127 -0
- package/src/youtube.ts +54 -5
package/dist/index.js
CHANGED
|
@@ -520,6 +520,714 @@ function randomToken(length = 8) {
|
|
|
520
520
|
return crypto.getRandomValues(new Uint8Array(length)).reduce((value, byte) => value + (byte % 36).toString(36), "");
|
|
521
521
|
}
|
|
522
522
|
|
|
523
|
+
// src/isobmff.ts
|
|
524
|
+
function parseBoxes(data, start = 0, end = data.length) {
|
|
525
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
526
|
+
const boxes = [];
|
|
527
|
+
let offset = start;
|
|
528
|
+
while (offset + 8 <= end) {
|
|
529
|
+
let size = view.getUint32(offset);
|
|
530
|
+
const type = String.fromCharCode(data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7]);
|
|
531
|
+
let dataStart = offset + 8;
|
|
532
|
+
if (size === 1) {
|
|
533
|
+
size = Number(view.getBigUint64(offset + 8));
|
|
534
|
+
dataStart = offset + 16;
|
|
535
|
+
} else if (size === 0) {
|
|
536
|
+
size = end - offset;
|
|
537
|
+
}
|
|
538
|
+
if (size < 8 || offset + size > end) {
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
boxes.push({ type, start: offset, end: offset + size, dataStart });
|
|
542
|
+
offset += size;
|
|
543
|
+
}
|
|
544
|
+
return boxes;
|
|
545
|
+
}
|
|
546
|
+
function findBox(boxes, type) {
|
|
547
|
+
return boxes.find((box) => box.type === type);
|
|
548
|
+
}
|
|
549
|
+
function childBoxes(data, box) {
|
|
550
|
+
return parseBoxes(data, box.dataStart, box.end);
|
|
551
|
+
}
|
|
552
|
+
function sliceBox(data, box) {
|
|
553
|
+
return data.slice(box.start, box.end);
|
|
554
|
+
}
|
|
555
|
+
function buildBox(type, payload) {
|
|
556
|
+
const out = new Uint8Array(8 + payload.length);
|
|
557
|
+
const view = new DataView(out.buffer);
|
|
558
|
+
view.setUint32(0, out.length);
|
|
559
|
+
out[4] = type.charCodeAt(0);
|
|
560
|
+
out[5] = type.charCodeAt(1);
|
|
561
|
+
out[6] = type.charCodeAt(2);
|
|
562
|
+
out[7] = type.charCodeAt(3);
|
|
563
|
+
out.set(payload, 8);
|
|
564
|
+
return out;
|
|
565
|
+
}
|
|
566
|
+
function concat(parts) {
|
|
567
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
568
|
+
const out = new Uint8Array(total);
|
|
569
|
+
let offset = 0;
|
|
570
|
+
for (const part of parts) {
|
|
571
|
+
out.set(part, offset);
|
|
572
|
+
offset += part.length;
|
|
573
|
+
}
|
|
574
|
+
return out;
|
|
575
|
+
}
|
|
576
|
+
function readU32(data, offset) {
|
|
577
|
+
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset);
|
|
578
|
+
}
|
|
579
|
+
function writeU32(data, offset, value) {
|
|
580
|
+
new DataView(data.buffer, data.byteOffset, data.byteLength).setUint32(offset, value);
|
|
581
|
+
}
|
|
582
|
+
function boxVersion(data, box) {
|
|
583
|
+
return data[box.dataStart];
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// src/hls.ts
|
|
587
|
+
function isMasterPlaylist(text) {
|
|
588
|
+
return text.includes("#EXT-X-STREAM-INF");
|
|
589
|
+
}
|
|
590
|
+
function parseMaster(text, baseUrl) {
|
|
591
|
+
const lines = text.split(`
|
|
592
|
+
`).map((line) => line.trim());
|
|
593
|
+
const audio = {};
|
|
594
|
+
const variants = [];
|
|
595
|
+
for (let index = 0;index < lines.length; index += 1) {
|
|
596
|
+
const line = lines[index];
|
|
597
|
+
if (line.startsWith("#EXT-X-MEDIA:") && /TYPE=AUDIO/.test(line)) {
|
|
598
|
+
const group = line.match(/GROUP-ID="([^"]+)"/)?.[1];
|
|
599
|
+
const uri = line.match(/URI="([^"]+)"/)?.[1];
|
|
600
|
+
if (group && uri) {
|
|
601
|
+
audio[group] = new URL(uri, baseUrl).href;
|
|
602
|
+
}
|
|
603
|
+
} else if (line.startsWith("#EXT-X-STREAM-INF:")) {
|
|
604
|
+
const target = lines[index + 1];
|
|
605
|
+
if (target && !target.startsWith("#")) {
|
|
606
|
+
const resolution = line.match(/RESOLUTION=(\d+)x(\d+)/);
|
|
607
|
+
variants.push({
|
|
608
|
+
width: resolution ? Number(resolution[1]) : 0,
|
|
609
|
+
height: resolution ? Number(resolution[2]) : 0,
|
|
610
|
+
bandwidth: Number(line.match(/BANDWIDTH=(\d+)/)?.[1] ?? 0),
|
|
611
|
+
url: new URL(target, baseUrl).href,
|
|
612
|
+
audioGroup: line.match(/AUDIO="([^"]+)"/)?.[1] ?? null
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return { variants, audio };
|
|
618
|
+
}
|
|
619
|
+
async function assembleHls(net, playlistUrl, headers) {
|
|
620
|
+
const response = await net(playlistUrl, { headers });
|
|
621
|
+
if (!response.ok) {
|
|
622
|
+
throw new Error(`HLS playlist failed: ${response.status}`);
|
|
623
|
+
}
|
|
624
|
+
const segments = mediaSegments(await response.text(), playlistUrl);
|
|
625
|
+
if (segments.length === 0) {
|
|
626
|
+
throw new Error("HLS playlist has no segments");
|
|
627
|
+
}
|
|
628
|
+
return concat(await fetchSegments(net, segments, headers));
|
|
629
|
+
}
|
|
630
|
+
function mediaSegments(text, baseUrl) {
|
|
631
|
+
const segments = [];
|
|
632
|
+
let pendingRange = null;
|
|
633
|
+
let nextOffset = 0;
|
|
634
|
+
for (const raw of text.split(`
|
|
635
|
+
`)) {
|
|
636
|
+
const line = raw.trim();
|
|
637
|
+
if (line.startsWith("#EXT-X-MAP:")) {
|
|
638
|
+
const uri = line.match(/URI="([^"]+)"/)?.[1];
|
|
639
|
+
if (uri) {
|
|
640
|
+
const range = byteRange(line.match(/BYTERANGE="([^"]+)"/)?.[1], nextOffset);
|
|
641
|
+
segments.push({ url: new URL(uri, baseUrl).href, range });
|
|
642
|
+
nextOffset = range ? range.offset + range.length : nextOffset;
|
|
643
|
+
}
|
|
644
|
+
} else if (line.startsWith("#EXT-X-BYTERANGE:")) {
|
|
645
|
+
pendingRange = byteRange(line.slice("#EXT-X-BYTERANGE:".length), nextOffset);
|
|
646
|
+
nextOffset = pendingRange ? pendingRange.offset + pendingRange.length : nextOffset;
|
|
647
|
+
} else if (line.length > 0 && !line.startsWith("#")) {
|
|
648
|
+
segments.push({ url: new URL(line, baseUrl).href, range: pendingRange });
|
|
649
|
+
pendingRange = null;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return segments;
|
|
653
|
+
}
|
|
654
|
+
function byteRange(spec, runningOffset) {
|
|
655
|
+
const match = spec?.trim().match(/^(\d+)(?:@(\d+))?$/);
|
|
656
|
+
if (!match) {
|
|
657
|
+
return null;
|
|
658
|
+
}
|
|
659
|
+
return { length: Number(match[1]), offset: match[2] === undefined ? runningOffset : Number(match[2]) };
|
|
660
|
+
}
|
|
661
|
+
async function fetchSegments(net, segments, headers) {
|
|
662
|
+
const results = new Array(segments.length);
|
|
663
|
+
let cursor = 0;
|
|
664
|
+
async function worker() {
|
|
665
|
+
while (cursor < segments.length) {
|
|
666
|
+
const index = cursor;
|
|
667
|
+
cursor += 1;
|
|
668
|
+
results[index] = await fetchSegment(net, segments[index], headers);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const workers = Array.from({ length: Math.min(8, segments.length) }, worker);
|
|
672
|
+
await Promise.all(workers);
|
|
673
|
+
return results;
|
|
674
|
+
}
|
|
675
|
+
async function fetchSegment(net, segment, headers) {
|
|
676
|
+
const requestHeaders = new Headers(headers);
|
|
677
|
+
if (segment.range) {
|
|
678
|
+
requestHeaders.set("range", `bytes=${segment.range.offset}-${segment.range.offset + segment.range.length - 1}`);
|
|
679
|
+
}
|
|
680
|
+
const response = await net(segment.url, { headers: requestHeaders });
|
|
681
|
+
if (!response.ok) {
|
|
682
|
+
throw new Error(`HLS segment failed: ${response.status}`);
|
|
683
|
+
}
|
|
684
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/pinterest.ts
|
|
688
|
+
async function resolvePinterest(input) {
|
|
689
|
+
const id = await pinId(input.net, input.url);
|
|
690
|
+
const pin = await pinResource(input.net, id);
|
|
691
|
+
const items = await mediaItems2(input, pin, id);
|
|
692
|
+
if (items.length === 0) {
|
|
693
|
+
throw new Error("Pinterest media not found");
|
|
694
|
+
}
|
|
695
|
+
return { archiveFilename: filename(`pinterest_${id}.zip`), id, items, platform: "pinterest" };
|
|
696
|
+
}
|
|
697
|
+
async function pinId(net, input) {
|
|
698
|
+
const direct = parsePin(input);
|
|
699
|
+
if (direct) {
|
|
700
|
+
return direct;
|
|
701
|
+
}
|
|
702
|
+
const response = await net(input, { headers: { "user-agent": browserUserAgent() } }, 1);
|
|
703
|
+
const resolved = parsePin(response.url);
|
|
704
|
+
if (!resolved) {
|
|
705
|
+
throw new Error("Pinterest pin id not found");
|
|
706
|
+
}
|
|
707
|
+
return resolved;
|
|
708
|
+
}
|
|
709
|
+
function parsePin(input) {
|
|
710
|
+
return asUrl(input).pathname.match(/\/pin\/(\d+)/)?.[1] ?? null;
|
|
711
|
+
}
|
|
712
|
+
async function pinResource(net, id) {
|
|
713
|
+
const data = JSON.stringify({
|
|
714
|
+
options: { id, field_set_key: "unauth_react_main_pin", fetch_visual_search_objects: false },
|
|
715
|
+
context: {}
|
|
716
|
+
});
|
|
717
|
+
const url = new URL("https://www.pinterest.com/resource/PinResource/get/");
|
|
718
|
+
url.searchParams.set("source_url", `/pin/${id}/`);
|
|
719
|
+
url.searchParams.set("data", data);
|
|
720
|
+
const response = await net(url.href, {
|
|
721
|
+
headers: {
|
|
722
|
+
accept: "application/json",
|
|
723
|
+
"user-agent": browserUserAgent(),
|
|
724
|
+
"x-pinterest-pws-handler": "www/pin/[id].js"
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
if (!response.ok) {
|
|
728
|
+
throw new Error(`Pinterest resource failed: ${response.status}`);
|
|
729
|
+
}
|
|
730
|
+
const payload = await response.json().catch(() => null);
|
|
731
|
+
const wrapper = object(payload) && object(payload.resource_response) ? payload.resource_response : null;
|
|
732
|
+
const pin = wrapper && object(wrapper.data) ? wrapper.data : null;
|
|
733
|
+
if (!pin) {
|
|
734
|
+
throw new Error("Pinterest pin not found");
|
|
735
|
+
}
|
|
736
|
+
return pin;
|
|
737
|
+
}
|
|
738
|
+
async function mediaItems2(input, pin, id) {
|
|
739
|
+
const video = await standardVideo(input, pin, id);
|
|
740
|
+
if (video.length > 0) {
|
|
741
|
+
return video;
|
|
742
|
+
}
|
|
743
|
+
const story = await storyItems(input, pin, id);
|
|
744
|
+
if (story.length > 0) {
|
|
745
|
+
return story;
|
|
746
|
+
}
|
|
747
|
+
return imageItems(pin, id);
|
|
748
|
+
}
|
|
749
|
+
async function standardVideo(input, pin, id) {
|
|
750
|
+
const item = await videoFromList(input, id, null, videoList(object(pin.videos) ? pin.videos : null));
|
|
751
|
+
return item ? [item] : [];
|
|
752
|
+
}
|
|
753
|
+
async function storyItems(input, pin, id) {
|
|
754
|
+
const story = object(pin.story_pin_data) ? pin.story_pin_data : null;
|
|
755
|
+
if (!story) {
|
|
756
|
+
return [];
|
|
757
|
+
}
|
|
758
|
+
const pages = Array.isArray(story.pages) ? story.pages.filter(object) : [];
|
|
759
|
+
const blocks = pages.flatMap((page) => Array.isArray(page.blocks) ? page.blocks.filter(object) : []);
|
|
760
|
+
const videoBlocks = blocks.filter((block) => object(block.video));
|
|
761
|
+
const videos = [];
|
|
762
|
+
for (const [index, block] of videoBlocks.entries()) {
|
|
763
|
+
const item = await videoFromList(input, id, index + 1, videoList(object(block.video) ? block.video : null));
|
|
764
|
+
if (item) {
|
|
765
|
+
videos.push(item);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
if (videos.length > 0) {
|
|
769
|
+
return videos;
|
|
770
|
+
}
|
|
771
|
+
if (videoBlocks.length > 0) {
|
|
772
|
+
throw new Error("Pinterest idea pin video has no downloadable rendition");
|
|
773
|
+
}
|
|
774
|
+
return imageItems(pin, id);
|
|
775
|
+
}
|
|
776
|
+
async function videoFromList(input, id, index, renditions) {
|
|
777
|
+
const progressive = progressiveMp4(renditions);
|
|
778
|
+
if (progressive) {
|
|
779
|
+
return videoItem(id, index, progressive);
|
|
780
|
+
}
|
|
781
|
+
const master = hlsUrl(renditions);
|
|
782
|
+
return master ? hlsVideoItem(input.net, id, index, master, input.preferredWidth) : null;
|
|
783
|
+
}
|
|
784
|
+
async function hlsVideoItem(net, id, index, masterUrl, preferredWidth) {
|
|
785
|
+
const headers = { "user-agent": browserUserAgent() };
|
|
786
|
+
const suffix = index === null ? "" : `_${index}`;
|
|
787
|
+
const base = {
|
|
788
|
+
filename: filename(`pinterest_${id}${suffix}.mp4`),
|
|
789
|
+
headers,
|
|
790
|
+
hls: true,
|
|
791
|
+
id,
|
|
792
|
+
kind: "video",
|
|
793
|
+
mime: "video/mp4",
|
|
794
|
+
platform: "pinterest",
|
|
795
|
+
url: masterUrl
|
|
796
|
+
};
|
|
797
|
+
const response = await net(masterUrl, { headers });
|
|
798
|
+
if (!response.ok) {
|
|
799
|
+
throw new Error(`Pinterest HLS failed: ${response.status}`);
|
|
800
|
+
}
|
|
801
|
+
const playlist = await response.text();
|
|
802
|
+
if (!isMasterPlaylist(playlist)) {
|
|
803
|
+
return base;
|
|
804
|
+
}
|
|
805
|
+
const master = parseMaster(playlist, masterUrl);
|
|
806
|
+
const variant = pickVariant(master.variants, preferredWidth);
|
|
807
|
+
if (!variant) {
|
|
808
|
+
throw new Error("Pinterest HLS variant not found");
|
|
809
|
+
}
|
|
810
|
+
const audioUrl = variant.audioGroup ? master.audio[variant.audioGroup] : undefined;
|
|
811
|
+
return { ...base, url: variant.url, ...audioUrl ? { audio: { headers, url: audioUrl } } : {} };
|
|
812
|
+
}
|
|
813
|
+
function pickVariant(variants, preferredWidth) {
|
|
814
|
+
return variants.reduce((current, variant) => {
|
|
815
|
+
if (!current) {
|
|
816
|
+
return variant;
|
|
817
|
+
}
|
|
818
|
+
return Math.abs(variant.width - preferredWidth) < Math.abs(current.width - preferredWidth) ? variant : current;
|
|
819
|
+
}, null);
|
|
820
|
+
}
|
|
821
|
+
function hlsUrl(renditions) {
|
|
822
|
+
return renditions.map((rendition) => string(rendition.url)).find((url) => url !== null && url.toLowerCase().includes(".m3u8")) ?? null;
|
|
823
|
+
}
|
|
824
|
+
function imageItems(pin, id) {
|
|
825
|
+
const images = object(pin.images) ? pin.images : null;
|
|
826
|
+
const orig = images && object(images.orig) ? images.orig : null;
|
|
827
|
+
const url = orig ? string(orig.url) : null;
|
|
828
|
+
if (!url) {
|
|
829
|
+
return [];
|
|
830
|
+
}
|
|
831
|
+
return [
|
|
832
|
+
{
|
|
833
|
+
filename: filename(`pinterest_${id}.${imageExtension(url)}`),
|
|
834
|
+
headers: { "user-agent": browserUserAgent() },
|
|
835
|
+
id,
|
|
836
|
+
kind: "image",
|
|
837
|
+
mime: imageMime(url),
|
|
838
|
+
platform: "pinterest",
|
|
839
|
+
url
|
|
840
|
+
}
|
|
841
|
+
];
|
|
842
|
+
}
|
|
843
|
+
function videoList(videos) {
|
|
844
|
+
const list = videos && object(videos.video_list) ? videos.video_list : null;
|
|
845
|
+
return list ? Object.values(list).filter(object) : [];
|
|
846
|
+
}
|
|
847
|
+
function progressiveMp4(renditions) {
|
|
848
|
+
const mp4s = renditions.filter((rendition) => string(rendition.url)?.toLowerCase().endsWith(".mp4"));
|
|
849
|
+
const best = mp4s.reduce((current, rendition) => {
|
|
850
|
+
const width = number(rendition.width) ?? 0;
|
|
851
|
+
const currentWidth = current ? number(current.width) ?? 0 : -1;
|
|
852
|
+
return width > currentWidth ? rendition : current;
|
|
853
|
+
}, null);
|
|
854
|
+
return best ? string(best.url) : null;
|
|
855
|
+
}
|
|
856
|
+
function videoItem(id, index, url) {
|
|
857
|
+
const suffix = index === null ? "" : `_${index}`;
|
|
858
|
+
return {
|
|
859
|
+
filename: filename(`pinterest_${id}${suffix}.mp4`),
|
|
860
|
+
headers: { "user-agent": browserUserAgent() },
|
|
861
|
+
id,
|
|
862
|
+
kind: "video",
|
|
863
|
+
mime: "video/mp4",
|
|
864
|
+
platform: "pinterest",
|
|
865
|
+
url
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
function imageExtension(url) {
|
|
869
|
+
const extension = asUrl(url).pathname.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase();
|
|
870
|
+
return extension === "png" || extension === "webp" || extension === "gif" ? extension : "jpg";
|
|
871
|
+
}
|
|
872
|
+
function imageMime(url) {
|
|
873
|
+
const extension = imageExtension(url);
|
|
874
|
+
if (extension === "png") {
|
|
875
|
+
return "image/png";
|
|
876
|
+
}
|
|
877
|
+
if (extension === "webp") {
|
|
878
|
+
return "image/webp";
|
|
879
|
+
}
|
|
880
|
+
if (extension === "gif") {
|
|
881
|
+
return "image/gif";
|
|
882
|
+
}
|
|
883
|
+
return "image/jpeg";
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// src/reddit.ts
|
|
887
|
+
var installedClientId = "ohXpoqrZYub1kg";
|
|
888
|
+
async function resolveReddit(input) {
|
|
889
|
+
const id = await postId(input.net, input.url);
|
|
890
|
+
const token = await appToken(input.net);
|
|
891
|
+
const post = await fetchPost(input.net, token, id);
|
|
892
|
+
const items = await mediaItems3(input, post, id);
|
|
893
|
+
if (items.length === 0) {
|
|
894
|
+
throw new Error("Reddit media not found");
|
|
895
|
+
}
|
|
896
|
+
return { archiveFilename: filename(`reddit_${id}.zip`), id, items, platform: "reddit" };
|
|
897
|
+
}
|
|
898
|
+
async function postId(net, input) {
|
|
899
|
+
const direct = parseId(input);
|
|
900
|
+
if (direct) {
|
|
901
|
+
return direct;
|
|
902
|
+
}
|
|
903
|
+
const response = await net(input, { headers: { "user-agent": browserUserAgent() } }, 1);
|
|
904
|
+
const resolved = parseId(response.url);
|
|
905
|
+
if (!resolved) {
|
|
906
|
+
throw new Error("Reddit post id not found");
|
|
907
|
+
}
|
|
908
|
+
return resolved;
|
|
909
|
+
}
|
|
910
|
+
function parseId(input) {
|
|
911
|
+
const url = asUrl(input);
|
|
912
|
+
if (url.hostname === "redd.it") {
|
|
913
|
+
const slug = url.pathname.split("/").filter(Boolean)[0];
|
|
914
|
+
return slug && /^[a-z0-9]+$/i.test(slug) ? slug : null;
|
|
915
|
+
}
|
|
916
|
+
return url.pathname.match(/\/comments\/([a-z0-9]+)/i)?.[1] ?? null;
|
|
917
|
+
}
|
|
918
|
+
async function appToken(net) {
|
|
919
|
+
const response = await net("https://www.reddit.com/api/v1/access_token", {
|
|
920
|
+
body: "grant_type=https://oauth.reddit.com/grants/installed_client&device_id=DO_NOT_TRACK_THIS_DEVICE",
|
|
921
|
+
headers: {
|
|
922
|
+
authorization: `Basic ${btoa(`${installedClientId}:`)}`,
|
|
923
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
924
|
+
"user-agent": browserUserAgent()
|
|
925
|
+
},
|
|
926
|
+
method: "POST"
|
|
927
|
+
});
|
|
928
|
+
if (!response.ok) {
|
|
929
|
+
throw new Error(`Reddit auth failed: ${response.status}`);
|
|
930
|
+
}
|
|
931
|
+
const payload = await response.json().catch(() => null);
|
|
932
|
+
const token = object(payload) ? string(payload.access_token) : null;
|
|
933
|
+
if (!token) {
|
|
934
|
+
throw new Error("Reddit token not found");
|
|
935
|
+
}
|
|
936
|
+
return token;
|
|
937
|
+
}
|
|
938
|
+
async function fetchPost(net, token, id) {
|
|
939
|
+
const response = await net(`https://oauth.reddit.com/comments/${id}?raw_json=1&limit=1`, {
|
|
940
|
+
headers: { authorization: `Bearer ${token}`, "user-agent": browserUserAgent() }
|
|
941
|
+
});
|
|
942
|
+
if (!response.ok) {
|
|
943
|
+
throw new Error(`Reddit post failed: ${response.status}`);
|
|
944
|
+
}
|
|
945
|
+
const payload = await response.json().catch(() => null);
|
|
946
|
+
const listing = Array.isArray(payload) ? payload[0] : null;
|
|
947
|
+
const children = object(listing) && object(listing.data) && Array.isArray(listing.data.children) ? listing.data.children : [];
|
|
948
|
+
const first = children[0];
|
|
949
|
+
const post = object(first) && object(first.data) ? first.data : null;
|
|
950
|
+
if (!post) {
|
|
951
|
+
throw new Error("Reddit post not found");
|
|
952
|
+
}
|
|
953
|
+
return post;
|
|
954
|
+
}
|
|
955
|
+
async function mediaItems3(input, post, id) {
|
|
956
|
+
const own = await extract(input, post, id);
|
|
957
|
+
if (own.length > 0) {
|
|
958
|
+
return own;
|
|
959
|
+
}
|
|
960
|
+
const parents = Array.isArray(post.crosspost_parent_list) ? post.crosspost_parent_list.filter(object) : [];
|
|
961
|
+
return parents[0] ? extract(input, parents[0], id) : [];
|
|
962
|
+
}
|
|
963
|
+
async function extract(input, post, id) {
|
|
964
|
+
const gallery = galleryItems(post, id);
|
|
965
|
+
if (gallery.length > 0) {
|
|
966
|
+
return gallery;
|
|
967
|
+
}
|
|
968
|
+
const video = await videoItems(input, post, id);
|
|
969
|
+
if (video.length > 0) {
|
|
970
|
+
return video;
|
|
971
|
+
}
|
|
972
|
+
return imageItems2(post, id);
|
|
973
|
+
}
|
|
974
|
+
function galleryItems(post, id) {
|
|
975
|
+
const data = object(post.gallery_data) ? post.gallery_data : null;
|
|
976
|
+
const order = data && Array.isArray(data.items) ? data.items.filter(object) : [];
|
|
977
|
+
const metadata = object(post.media_metadata) ? post.media_metadata : {};
|
|
978
|
+
return order.flatMap((entry, index) => {
|
|
979
|
+
const mediaId2 = string(entry.media_id);
|
|
980
|
+
const meta = mediaId2 && object(metadata[mediaId2]) ? metadata[mediaId2] : null;
|
|
981
|
+
const best = meta && object(meta.s) ? meta.s : null;
|
|
982
|
+
if (!best) {
|
|
983
|
+
return [];
|
|
984
|
+
}
|
|
985
|
+
const video = string(best.mp4);
|
|
986
|
+
if (video) {
|
|
987
|
+
return [mediaItem(id, index + 1, "video", "video/mp4", video)];
|
|
988
|
+
}
|
|
989
|
+
const image = string(best.u) ?? string(best.gif);
|
|
990
|
+
return image ? [mediaItem(id, index + 1, "image", mimeOf(string(meta?.m), image), image)] : [];
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
async function videoItems(input, post, id) {
|
|
994
|
+
const media = redditVideo(post);
|
|
995
|
+
const fallback = media ? string(media.fallback_url) : null;
|
|
996
|
+
if (!media || !fallback) {
|
|
997
|
+
return [];
|
|
998
|
+
}
|
|
999
|
+
if (media.has_audio !== true) {
|
|
1000
|
+
return [mediaItem(id, null, "video", "video/mp4", fallback)];
|
|
1001
|
+
}
|
|
1002
|
+
const dashUrl = string(media.dash_url);
|
|
1003
|
+
const streams = dashUrl ? await dashStreams(input.net, dashUrl, input.preferredWidth) : null;
|
|
1004
|
+
if (!streams) {
|
|
1005
|
+
throw new Error("Reddit DASH manifest unavailable for muxing");
|
|
1006
|
+
}
|
|
1007
|
+
return [muxedVideoItem(id, streams.video, streams.audio)];
|
|
1008
|
+
}
|
|
1009
|
+
async function dashStreams(net, dashUrl, preferredWidth) {
|
|
1010
|
+
const response = await net(dashUrl, { headers: { "user-agent": browserUserAgent() } });
|
|
1011
|
+
if (!response.ok) {
|
|
1012
|
+
return null;
|
|
1013
|
+
}
|
|
1014
|
+
const manifest = await response.text();
|
|
1015
|
+
const base = dashUrl.slice(0, dashUrl.lastIndexOf("/") + 1);
|
|
1016
|
+
const query = dashUrl.includes("?") ? dashUrl.slice(dashUrl.indexOf("?")) : "";
|
|
1017
|
+
const video = videoRendition(manifest, preferredWidth);
|
|
1018
|
+
const audio = audioRendition(manifest);
|
|
1019
|
+
if (!video || !audio) {
|
|
1020
|
+
return null;
|
|
1021
|
+
}
|
|
1022
|
+
return { video: `${base}${video}${query}`, audio: `${base}${audio}${query}` };
|
|
1023
|
+
}
|
|
1024
|
+
function videoRendition(manifest, preferredWidth) {
|
|
1025
|
+
const renditions = renditionsOf(manifest, false);
|
|
1026
|
+
const sized = renditions.filter((rendition) => rendition.width !== null);
|
|
1027
|
+
const pool = sized.length > 0 ? sized : renditions;
|
|
1028
|
+
const best = pool.reduce((current, rendition) => {
|
|
1029
|
+
if (!current) {
|
|
1030
|
+
return rendition;
|
|
1031
|
+
}
|
|
1032
|
+
if (rendition.width !== null && current.width !== null) {
|
|
1033
|
+
return Math.abs(rendition.width - preferredWidth) < Math.abs(current.width - preferredWidth) ? rendition : current;
|
|
1034
|
+
}
|
|
1035
|
+
return rendition.bandwidth > current.bandwidth ? rendition : current;
|
|
1036
|
+
}, null);
|
|
1037
|
+
return best ? best.base : null;
|
|
1038
|
+
}
|
|
1039
|
+
function audioRendition(manifest) {
|
|
1040
|
+
const best = renditionsOf(manifest, true).reduce((current, rendition) => !current || rendition.bandwidth > current.bandwidth ? rendition : current, null);
|
|
1041
|
+
return best ? best.base : null;
|
|
1042
|
+
}
|
|
1043
|
+
function renditionsOf(manifest, audio) {
|
|
1044
|
+
return manifest.split("<AdaptationSet").slice(1).filter((chunk) => isAudioSet(chunk) === audio).flatMap((chunk) => [...chunk.matchAll(/<Representation\b([^>]*)>([\s\S]*?)<\/Representation>/g)].flatMap((match) => {
|
|
1045
|
+
const segment = match[2].match(/<BaseURL>([^<]+)<\/BaseURL>/)?.[1];
|
|
1046
|
+
if (!segment) {
|
|
1047
|
+
return [];
|
|
1048
|
+
}
|
|
1049
|
+
const width = match[1].match(/(?:^|[^a-zA-Z])width="(\d+)"/)?.[1];
|
|
1050
|
+
return [{ width: width ? Number(width) : null, bandwidth: Number(match[1].match(/bandwidth="(\d+)"/)?.[1] ?? 0), base: segment }];
|
|
1051
|
+
}));
|
|
1052
|
+
}
|
|
1053
|
+
function isAudioSet(chunk) {
|
|
1054
|
+
const head = chunk.slice(0, chunk.indexOf(">"));
|
|
1055
|
+
return /contentType="audio"/.test(head) || /mimeType="audio/.test(head);
|
|
1056
|
+
}
|
|
1057
|
+
function muxedVideoItem(id, videoUrl, audioUrl) {
|
|
1058
|
+
return {
|
|
1059
|
+
audio: { headers: { "user-agent": browserUserAgent() }, url: audioUrl },
|
|
1060
|
+
filename: filename(`reddit_${id}.mp4`),
|
|
1061
|
+
headers: { "user-agent": browserUserAgent() },
|
|
1062
|
+
id,
|
|
1063
|
+
kind: "video",
|
|
1064
|
+
mime: "video/mp4",
|
|
1065
|
+
platform: "reddit",
|
|
1066
|
+
url: videoUrl
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
function redditVideo(post) {
|
|
1070
|
+
const secure = object(post.secure_media) ? post.secure_media : null;
|
|
1071
|
+
const media = object(post.media) ? post.media : null;
|
|
1072
|
+
const fromSecure = secure && object(secure.reddit_video) ? secure.reddit_video : null;
|
|
1073
|
+
const fromMedia = media && object(media.reddit_video) ? media.reddit_video : null;
|
|
1074
|
+
return fromSecure ?? fromMedia;
|
|
1075
|
+
}
|
|
1076
|
+
function imageItems2(post, id) {
|
|
1077
|
+
const url = string(post.url_overridden_by_dest) ?? string(post.url);
|
|
1078
|
+
if (!url) {
|
|
1079
|
+
return [];
|
|
1080
|
+
}
|
|
1081
|
+
const path = asUrl(url).pathname;
|
|
1082
|
+
const isImage = string(post.post_hint) === "image" || asUrl(url).hostname === "i.redd.it" || /\.(jpe?g|png|webp|gif)$/i.test(path);
|
|
1083
|
+
return isImage ? [mediaItem(id, null, "image", mimeOf(null, url), url)] : [];
|
|
1084
|
+
}
|
|
1085
|
+
function mediaItem(id, index, kind, mime, url) {
|
|
1086
|
+
const suffix = index === null ? "" : `_${index}`;
|
|
1087
|
+
return {
|
|
1088
|
+
filename: filename(`reddit_${id}${suffix}.${extensionOf(mime)}`),
|
|
1089
|
+
headers: { "user-agent": browserUserAgent() },
|
|
1090
|
+
id,
|
|
1091
|
+
kind,
|
|
1092
|
+
mime,
|
|
1093
|
+
platform: "reddit",
|
|
1094
|
+
url
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
function mimeOf(declared, url) {
|
|
1098
|
+
if (declared && declared.includes("/")) {
|
|
1099
|
+
return declared.replace("image/jpg", "image/jpeg");
|
|
1100
|
+
}
|
|
1101
|
+
const extension = asUrl(url).pathname.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase();
|
|
1102
|
+
if (extension === "png") {
|
|
1103
|
+
return "image/png";
|
|
1104
|
+
}
|
|
1105
|
+
if (extension === "webp") {
|
|
1106
|
+
return "image/webp";
|
|
1107
|
+
}
|
|
1108
|
+
if (extension === "gif") {
|
|
1109
|
+
return "image/gif";
|
|
1110
|
+
}
|
|
1111
|
+
return "image/jpeg";
|
|
1112
|
+
}
|
|
1113
|
+
function extensionOf(mime) {
|
|
1114
|
+
if (mime === "video/mp4") {
|
|
1115
|
+
return "mp4";
|
|
1116
|
+
}
|
|
1117
|
+
if (mime === "image/png") {
|
|
1118
|
+
return "png";
|
|
1119
|
+
}
|
|
1120
|
+
if (mime === "image/webp") {
|
|
1121
|
+
return "webp";
|
|
1122
|
+
}
|
|
1123
|
+
if (mime === "image/gif") {
|
|
1124
|
+
return "gif";
|
|
1125
|
+
}
|
|
1126
|
+
return "jpg";
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// src/soundcloud.ts
|
|
1130
|
+
async function resolveSoundcloud(input) {
|
|
1131
|
+
const url = await canonicalUrl2(input.net, input.url);
|
|
1132
|
+
const clientId = await appClientId(input.net);
|
|
1133
|
+
const track = await resolveTrack(input.net, url, clientId);
|
|
1134
|
+
const id = trackId(track);
|
|
1135
|
+
const item = await audioItem(input.net, track, id, clientId);
|
|
1136
|
+
return { archiveFilename: filename(`soundcloud_${id}.zip`), id, items: [item], platform: "soundcloud" };
|
|
1137
|
+
}
|
|
1138
|
+
async function canonicalUrl2(net, input) {
|
|
1139
|
+
const host = asUrl(input).hostname;
|
|
1140
|
+
if (host !== "on.soundcloud.com" && host !== "snd.sc") {
|
|
1141
|
+
return input;
|
|
1142
|
+
}
|
|
1143
|
+
const response = await net(input, { headers: { "user-agent": browserUserAgent() } }, 1);
|
|
1144
|
+
return response.url;
|
|
1145
|
+
}
|
|
1146
|
+
async function appClientId(net) {
|
|
1147
|
+
const home = await net("https://soundcloud.com/", { headers: { "user-agent": browserUserAgent() } });
|
|
1148
|
+
if (!home.ok) {
|
|
1149
|
+
throw new Error(`SoundCloud home failed: ${home.status}`);
|
|
1150
|
+
}
|
|
1151
|
+
const html = await home.text();
|
|
1152
|
+
const scripts = [...html.matchAll(/<script[^>]+src="(https:\/\/a-v2\.sndcdn\.com\/assets\/[^"]+\.js)"/g)].map((match) => match[1]);
|
|
1153
|
+
for (const src of scripts.reverse()) {
|
|
1154
|
+
const response = await net(src, { headers: { "user-agent": browserUserAgent() } }, 1);
|
|
1155
|
+
if (!response.ok) {
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
const code = await response.text();
|
|
1159
|
+
const id = code.match(/client_id\s*[:=]\s*"([A-Za-z0-9]{20,})"/)?.[1];
|
|
1160
|
+
if (id) {
|
|
1161
|
+
return id;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
throw new Error("SoundCloud client id not found");
|
|
1165
|
+
}
|
|
1166
|
+
async function resolveTrack(net, url, clientId) {
|
|
1167
|
+
const api = new URL("https://api-v2.soundcloud.com/resolve");
|
|
1168
|
+
api.searchParams.set("url", url);
|
|
1169
|
+
api.searchParams.set("client_id", clientId);
|
|
1170
|
+
const response = await net(api.href, { headers: { accept: "application/json", "user-agent": browserUserAgent() } });
|
|
1171
|
+
if (!response.ok) {
|
|
1172
|
+
throw new Error(`SoundCloud resolve failed: ${response.status}`);
|
|
1173
|
+
}
|
|
1174
|
+
const payload = await response.json().catch(() => null);
|
|
1175
|
+
if (!object(payload) || string(payload.kind) !== "track") {
|
|
1176
|
+
throw new Error("SoundCloud track not found");
|
|
1177
|
+
}
|
|
1178
|
+
return payload;
|
|
1179
|
+
}
|
|
1180
|
+
async function audioItem(net, track, id, clientId) {
|
|
1181
|
+
const media = object(track.media) ? track.media : null;
|
|
1182
|
+
const transcodings = media && Array.isArray(media.transcodings) ? media.transcodings.filter(object) : [];
|
|
1183
|
+
const progressive = transcodings.find((transcoding) => protocol(transcoding) === "progressive");
|
|
1184
|
+
const hls = transcodings.find((transcoding) => protocol(transcoding) === "hls" && mimeType(transcoding)?.includes("mp4") === true);
|
|
1185
|
+
const chosen = progressive ?? hls;
|
|
1186
|
+
if (!chosen) {
|
|
1187
|
+
throw new Error("SoundCloud track has no downloadable stream");
|
|
1188
|
+
}
|
|
1189
|
+
const stream = await streamUrl(net, chosen, clientId);
|
|
1190
|
+
const title = string(track.title) ?? id;
|
|
1191
|
+
const headers = { "user-agent": browserUserAgent() };
|
|
1192
|
+
if (chosen === progressive) {
|
|
1193
|
+
return { filename: filename(`soundcloud_${title}.mp3`), headers, id, kind: "audio", mime: "audio/mpeg", platform: "soundcloud", url: stream };
|
|
1194
|
+
}
|
|
1195
|
+
return { filename: filename(`soundcloud_${title}.m4a`), headers, hls: true, id, kind: "audio", mime: "audio/mp4", platform: "soundcloud", url: stream };
|
|
1196
|
+
}
|
|
1197
|
+
async function streamUrl(net, transcoding, clientId) {
|
|
1198
|
+
const endpoint = string(transcoding.url);
|
|
1199
|
+
if (!endpoint) {
|
|
1200
|
+
throw new Error("SoundCloud stream endpoint not found");
|
|
1201
|
+
}
|
|
1202
|
+
const url = new URL(endpoint);
|
|
1203
|
+
url.searchParams.set("client_id", clientId);
|
|
1204
|
+
const response = await net(url.href, { headers: { accept: "application/json", "user-agent": browserUserAgent() } });
|
|
1205
|
+
if (!response.ok) {
|
|
1206
|
+
throw new Error(`SoundCloud stream failed: ${response.status}`);
|
|
1207
|
+
}
|
|
1208
|
+
const payload = await response.json().catch(() => null);
|
|
1209
|
+
const stream = object(payload) ? string(payload.url) : null;
|
|
1210
|
+
if (!stream) {
|
|
1211
|
+
throw new Error("SoundCloud stream url not found");
|
|
1212
|
+
}
|
|
1213
|
+
return stream;
|
|
1214
|
+
}
|
|
1215
|
+
function protocol(transcoding) {
|
|
1216
|
+
const format = object(transcoding.format) ? transcoding.format : null;
|
|
1217
|
+
return format ? string(format.protocol) : null;
|
|
1218
|
+
}
|
|
1219
|
+
function mimeType(transcoding) {
|
|
1220
|
+
const format = object(transcoding.format) ? transcoding.format : null;
|
|
1221
|
+
return format ? string(format.mime_type) : null;
|
|
1222
|
+
}
|
|
1223
|
+
function trackId(track) {
|
|
1224
|
+
const numeric = number(track.id);
|
|
1225
|
+
if (numeric !== null) {
|
|
1226
|
+
return String(numeric);
|
|
1227
|
+
}
|
|
1228
|
+
return string(track.permalink) ?? "track";
|
|
1229
|
+
}
|
|
1230
|
+
|
|
523
1231
|
// src/tiktok.ts
|
|
524
1232
|
var marker = '<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application/json">';
|
|
525
1233
|
async function resolveTiktok(input) {
|
|
@@ -543,7 +1251,7 @@ async function resolveTiktok(input) {
|
|
|
543
1251
|
if (page.cookie) {
|
|
544
1252
|
headers.cookie = page.cookie;
|
|
545
1253
|
}
|
|
546
|
-
const items =
|
|
1254
|
+
const items = mediaItems4(page.item, user, id, headers);
|
|
547
1255
|
if (items.length === 0) {
|
|
548
1256
|
throw new Error("TikTok media not found");
|
|
549
1257
|
}
|
|
@@ -603,10 +1311,10 @@ function downloadUrl(item) {
|
|
|
603
1311
|
const video = object(item.video) ? item.video : null;
|
|
604
1312
|
return video ? string(video.playAddr) ?? string(video.downloadAddr) : null;
|
|
605
1313
|
}
|
|
606
|
-
function
|
|
607
|
-
const images =
|
|
1314
|
+
function mediaItems4(item, user, id, headers) {
|
|
1315
|
+
const images = imageItems3(item, user, id, headers);
|
|
608
1316
|
if (images.length > 0) {
|
|
609
|
-
const audio =
|
|
1317
|
+
const audio = audioItem2(item, user, id, headers);
|
|
610
1318
|
return audio ? [...images, audio] : images;
|
|
611
1319
|
}
|
|
612
1320
|
const url = downloadUrl(item);
|
|
@@ -620,7 +1328,7 @@ function mediaItems2(item, user, id, headers) {
|
|
|
620
1328
|
url
|
|
621
1329
|
}] : [];
|
|
622
1330
|
}
|
|
623
|
-
function
|
|
1331
|
+
function imageItems3(item, user, id, headers) {
|
|
624
1332
|
const imagePost = object(item.imagePost) ? item.imagePost : null;
|
|
625
1333
|
const images = imagePost && Array.isArray(imagePost.images) ? imagePost.images.filter(object) : [];
|
|
626
1334
|
return images.flatMap((image, index) => {
|
|
@@ -638,7 +1346,7 @@ function imageItems(item, user, id, headers) {
|
|
|
638
1346
|
}] : [];
|
|
639
1347
|
});
|
|
640
1348
|
}
|
|
641
|
-
function
|
|
1349
|
+
function audioItem2(item, user, id, headers) {
|
|
642
1350
|
const video = object(item.video) ? item.video : null;
|
|
643
1351
|
const music = object(item.music) ? item.music : null;
|
|
644
1352
|
const url = video ? string(video.playAddr) : null;
|
|
@@ -756,22 +1464,64 @@ async function resolveYoutube(input) {
|
|
|
756
1464
|
const reason = object(payload) && object(payload.playabilityStatus) ? string(payload.playabilityStatus.reason) : null;
|
|
757
1465
|
throw new Error(reason ?? "YouTube video unavailable");
|
|
758
1466
|
}
|
|
759
|
-
const
|
|
760
|
-
if (!
|
|
761
|
-
throw new Error("YouTube
|
|
1467
|
+
const streams = selectStreams(payload, input.preferredWidth);
|
|
1468
|
+
if (!streams) {
|
|
1469
|
+
throw new Error("YouTube mp4 stream not found");
|
|
762
1470
|
}
|
|
763
1471
|
const title = object(payload) && object(payload.videoDetails) ? string(payload.videoDetails.title) : null;
|
|
1472
|
+
const headers = { "user-agent": androidVrClient.userAgent };
|
|
764
1473
|
const media = {
|
|
765
1474
|
filename: filename(`youtube_${title ?? id}_${id}.mp4`),
|
|
766
|
-
headers
|
|
1475
|
+
headers,
|
|
767
1476
|
id,
|
|
768
1477
|
kind: "video",
|
|
769
1478
|
mime: "video/mp4",
|
|
770
1479
|
platform: "youtube",
|
|
771
|
-
url:
|
|
1480
|
+
url: streams.video,
|
|
1481
|
+
...streams.audio ? { audio: { headers, url: streams.audio } } : {}
|
|
772
1482
|
};
|
|
773
1483
|
return { archiveFilename: filename(`youtube_${id}.zip`), id, items: [media], platform: "youtube" };
|
|
774
1484
|
}
|
|
1485
|
+
function selectStreams(payload, preferredWidth) {
|
|
1486
|
+
const adaptive = adaptiveFormats(payload);
|
|
1487
|
+
const video = bestVideo(adaptive, preferredWidth);
|
|
1488
|
+
const audio = bestAudio(adaptive);
|
|
1489
|
+
if (video && audio) {
|
|
1490
|
+
return { video, audio };
|
|
1491
|
+
}
|
|
1492
|
+
const progressive = selectFormat(payload);
|
|
1493
|
+
return progressive ? { video: progressive, audio: null } : null;
|
|
1494
|
+
}
|
|
1495
|
+
function adaptiveFormats(payload) {
|
|
1496
|
+
const root = object(payload) ? payload : null;
|
|
1497
|
+
const streaming = root && object(root.streamingData) ? root.streamingData : null;
|
|
1498
|
+
return streaming && Array.isArray(streaming.adaptiveFormats) ? streaming.adaptiveFormats.filter(object) : [];
|
|
1499
|
+
}
|
|
1500
|
+
function bestVideo(formats, preferredWidth) {
|
|
1501
|
+
const candidates = formats.filter((format) => {
|
|
1502
|
+
const mimeType2 = string(format.mimeType);
|
|
1503
|
+
return Boolean(string(format.url)) && mimeType2?.startsWith("video/mp4") === true && mimeType2.includes("avc1");
|
|
1504
|
+
});
|
|
1505
|
+
const best = candidates.reduce((current, format) => {
|
|
1506
|
+
if (!current) {
|
|
1507
|
+
return format;
|
|
1508
|
+
}
|
|
1509
|
+
const width = number(format.width) ?? 0;
|
|
1510
|
+
const currentWidth = number(current.width) ?? 0;
|
|
1511
|
+
return Math.abs(width - preferredWidth) < Math.abs(currentWidth - preferredWidth) ? format : current;
|
|
1512
|
+
}, null);
|
|
1513
|
+
return best ? string(best.url) : null;
|
|
1514
|
+
}
|
|
1515
|
+
function bestAudio(formats) {
|
|
1516
|
+
const candidates = formats.filter((format) => Boolean(string(format.url)) && string(format.mimeType)?.startsWith("audio/mp4") === true);
|
|
1517
|
+
const best = candidates.reduce((current, format) => {
|
|
1518
|
+
if (!current) {
|
|
1519
|
+
return format;
|
|
1520
|
+
}
|
|
1521
|
+
return (number(format.bitrate) ?? 0) > (number(current.bitrate) ?? 0) ? format : current;
|
|
1522
|
+
}, null);
|
|
1523
|
+
return best ? string(best.url) : null;
|
|
1524
|
+
}
|
|
775
1525
|
function playerBody(id, session) {
|
|
776
1526
|
return {
|
|
777
1527
|
contentCheckOk: true,
|
|
@@ -904,6 +1654,12 @@ async function postfetch(url, options = {}) {
|
|
|
904
1654
|
return resolveFacebook(context);
|
|
905
1655
|
case "instagram":
|
|
906
1656
|
return resolveInstagram(context);
|
|
1657
|
+
case "pinterest":
|
|
1658
|
+
return resolvePinterest(context);
|
|
1659
|
+
case "reddit":
|
|
1660
|
+
return resolveReddit(context);
|
|
1661
|
+
case "soundcloud":
|
|
1662
|
+
return resolveSoundcloud(context);
|
|
907
1663
|
case "tiktok":
|
|
908
1664
|
return resolveTiktok(context);
|
|
909
1665
|
case "twitter":
|
|
@@ -920,6 +1676,15 @@ function detect(url) {
|
|
|
920
1676
|
if (host.includes("instagram.com")) {
|
|
921
1677
|
return "instagram";
|
|
922
1678
|
}
|
|
1679
|
+
if (host.includes("pinterest.") || host === "pin.it") {
|
|
1680
|
+
return "pinterest";
|
|
1681
|
+
}
|
|
1682
|
+
if (host === "reddit.com" || host.endsWith(".reddit.com") || host === "redd.it") {
|
|
1683
|
+
return "reddit";
|
|
1684
|
+
}
|
|
1685
|
+
if (host.includes("soundcloud.com") || host === "snd.sc") {
|
|
1686
|
+
return "soundcloud";
|
|
1687
|
+
}
|
|
923
1688
|
if (host.includes("youtube.com") || host === "youtu.be") {
|
|
924
1689
|
return "youtube";
|
|
925
1690
|
}
|
|
@@ -929,8 +1694,135 @@ function detect(url) {
|
|
|
929
1694
|
if (host === "x.com" || host.endsWith(".x.com") || host.includes("twitter.com")) {
|
|
930
1695
|
return "twitter";
|
|
931
1696
|
}
|
|
932
|
-
throw new PostfetchError(400, "only Facebook, Instagram, TikTok, X and YouTube URLs are supported");
|
|
1697
|
+
throw new PostfetchError(400, "only Facebook, Instagram, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube URLs are supported");
|
|
1698
|
+
}
|
|
1699
|
+
// src/remux.ts
|
|
1700
|
+
function mergeAudioVideo(video, audio) {
|
|
1701
|
+
const videoSource = analyze(video);
|
|
1702
|
+
const audioSource = analyze(audio);
|
|
1703
|
+
const audioTrackId = videoSource.trackId === 1 ? 2 : 1;
|
|
1704
|
+
const moov = buildMoov(videoSource, audioSource, audioTrackId);
|
|
1705
|
+
const parts = [sliceBox(video, videoSource.ftyp), moov];
|
|
1706
|
+
let sequence = 1;
|
|
1707
|
+
const fragmentCount = Math.max(videoSource.fragments.length, audioSource.fragments.length);
|
|
1708
|
+
for (let index = 0;index < fragmentCount; index += 1) {
|
|
1709
|
+
const videoFragment = videoSource.fragments[index];
|
|
1710
|
+
if (videoFragment) {
|
|
1711
|
+
parts.push(rewriteMoof(video, videoFragment.moof, null, sequence));
|
|
1712
|
+
parts.push(video.subarray(videoFragment.mdat.start, videoFragment.mdat.end));
|
|
1713
|
+
sequence += 1;
|
|
1714
|
+
}
|
|
1715
|
+
const audioFragment = audioSource.fragments[index];
|
|
1716
|
+
if (audioFragment) {
|
|
1717
|
+
parts.push(rewriteMoof(audio, audioFragment.moof, audioTrackId, sequence));
|
|
1718
|
+
parts.push(audio.subarray(audioFragment.mdat.start, audioFragment.mdat.end));
|
|
1719
|
+
sequence += 1;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
return concat(parts);
|
|
1723
|
+
}
|
|
1724
|
+
function analyze(data) {
|
|
1725
|
+
const top = parseBoxes(data);
|
|
1726
|
+
const ftyp = findBox(top, "ftyp");
|
|
1727
|
+
const moov = findBox(top, "moov");
|
|
1728
|
+
if (!ftyp || !moov) {
|
|
1729
|
+
throw new Error("not a fragmented MP4");
|
|
1730
|
+
}
|
|
1731
|
+
const moovChildren = childBoxes(data, moov);
|
|
1732
|
+
const trak = findBox(moovChildren, "trak");
|
|
1733
|
+
const mvex = findBox(moovChildren, "mvex");
|
|
1734
|
+
if (!trak || !mvex) {
|
|
1735
|
+
throw new Error("MP4 is not fragmented (no trak/mvex)");
|
|
1736
|
+
}
|
|
1737
|
+
const tkhd = findBox(childBoxes(data, trak), "tkhd");
|
|
1738
|
+
if (!tkhd) {
|
|
1739
|
+
throw new Error("track header not found");
|
|
1740
|
+
}
|
|
1741
|
+
const fragments = [];
|
|
1742
|
+
for (let index = 0;index < top.length; index += 1) {
|
|
1743
|
+
if (top[index].type === "moof") {
|
|
1744
|
+
const mdat = top[index + 1];
|
|
1745
|
+
if (!mdat || mdat.type !== "mdat") {
|
|
1746
|
+
throw new Error("fragment without mdat");
|
|
1747
|
+
}
|
|
1748
|
+
fragments.push({ moof: top[index], mdat });
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
if (fragments.length === 0) {
|
|
1752
|
+
throw new Error("MP4 has no fragments");
|
|
1753
|
+
}
|
|
1754
|
+
return { data, ftyp, moov, trak, mvex, trackId: trackHeaderId(data, tkhd), fragments };
|
|
1755
|
+
}
|
|
1756
|
+
function trackHeaderId(data, tkhd) {
|
|
1757
|
+
return readU32(data, tkhd.dataStart + (boxVersion(data, tkhd) === 1 ? 20 : 12));
|
|
1758
|
+
}
|
|
1759
|
+
function buildMoov(video, audio, audioTrackId) {
|
|
1760
|
+
const moovChildren = childBoxes(video.data, video.moov);
|
|
1761
|
+
const mvhd = patchedMvhd(video, audioTrackId);
|
|
1762
|
+
const meta = findBox(moovChildren, "meta");
|
|
1763
|
+
const videoTrak = sliceBox(video.data, video.trak);
|
|
1764
|
+
const audioTrak = retrackTrak(sliceBox(audio.data, audio.trak), audioTrackId);
|
|
1765
|
+
const mvex = buildMvex(video, audio, audioTrackId);
|
|
1766
|
+
const children = [mvhd];
|
|
1767
|
+
if (meta) {
|
|
1768
|
+
children.push(sliceBox(video.data, meta));
|
|
1769
|
+
}
|
|
1770
|
+
children.push(videoTrak, audioTrak, mvex);
|
|
1771
|
+
return buildBox("moov", concat(children));
|
|
1772
|
+
}
|
|
1773
|
+
function patchedMvhd(video, audioTrackId) {
|
|
1774
|
+
const mvhd = findBox(childBoxes(video.data, video.moov), "mvhd");
|
|
1775
|
+
if (!mvhd) {
|
|
1776
|
+
throw new Error("movie header not found");
|
|
1777
|
+
}
|
|
1778
|
+
const copy = sliceBox(video.data, mvhd);
|
|
1779
|
+
writeU32(copy, copy.length - 4, Math.max(video.trackId, audioTrackId) + 1);
|
|
1780
|
+
return copy;
|
|
1781
|
+
}
|
|
1782
|
+
function buildMvex(video, audio, audioTrackId) {
|
|
1783
|
+
const videoMvex = childBoxes(video.data, video.mvex);
|
|
1784
|
+
const mehd = findBox(videoMvex, "mehd");
|
|
1785
|
+
const videoTrex = findBox(videoMvex, "trex");
|
|
1786
|
+
const audioTrex = findBox(childBoxes(audio.data, audio.mvex), "trex");
|
|
1787
|
+
if (!videoTrex || !audioTrex) {
|
|
1788
|
+
throw new Error("track extends box not found");
|
|
1789
|
+
}
|
|
1790
|
+
const audioTrexCopy = sliceBox(audio.data, audioTrex);
|
|
1791
|
+
writeU32(audioTrexCopy, audioTrex.dataStart - audioTrex.start + 4, audioTrackId);
|
|
1792
|
+
const children = [];
|
|
1793
|
+
if (mehd) {
|
|
1794
|
+
children.push(sliceBox(video.data, mehd));
|
|
1795
|
+
}
|
|
1796
|
+
children.push(sliceBox(video.data, videoTrex), audioTrexCopy);
|
|
1797
|
+
return buildBox("mvex", concat(children));
|
|
1798
|
+
}
|
|
1799
|
+
function retrackTrak(trak, trackId2) {
|
|
1800
|
+
const tkhd = findBox(parseBoxes(trak, 8, trak.length), "tkhd");
|
|
1801
|
+
if (!tkhd) {
|
|
1802
|
+
throw new Error("track header not found");
|
|
1803
|
+
}
|
|
1804
|
+
writeU32(trak, tkhd.dataStart + (boxVersion(trak, tkhd) === 1 ? 20 : 12), trackId2);
|
|
1805
|
+
return trak;
|
|
1806
|
+
}
|
|
1807
|
+
function rewriteMoof(data, moofBox, trackId2, sequence) {
|
|
1808
|
+
const moof = sliceBox(data, moofBox);
|
|
1809
|
+
const moofChildren = parseBoxes(moof, 8, moof.length);
|
|
1810
|
+
const mfhd = findBox(moofChildren, "mfhd");
|
|
1811
|
+
if (!mfhd) {
|
|
1812
|
+
throw new Error("movie fragment header not found");
|
|
1813
|
+
}
|
|
1814
|
+
writeU32(moof, mfhd.dataStart + 4, sequence);
|
|
1815
|
+
if (trackId2 !== null) {
|
|
1816
|
+
const traf = findBox(moofChildren, "traf");
|
|
1817
|
+
const tfhd = traf ? findBox(parseBoxes(moof, traf.dataStart, traf.end), "tfhd") : undefined;
|
|
1818
|
+
if (!tfhd) {
|
|
1819
|
+
throw new Error("track fragment header not found");
|
|
1820
|
+
}
|
|
1821
|
+
writeU32(moof, tfhd.dataStart + 4, trackId2);
|
|
1822
|
+
}
|
|
1823
|
+
return moof;
|
|
933
1824
|
}
|
|
1825
|
+
|
|
934
1826
|
// src/zip.ts
|
|
935
1827
|
var encoder = new TextEncoder;
|
|
936
1828
|
var table = new Uint32Array(256);
|
|
@@ -1024,8 +1916,15 @@ function crc32(data) {
|
|
|
1024
1916
|
}
|
|
1025
1917
|
|
|
1026
1918
|
// src/download.ts
|
|
1919
|
+
function buffered(item) {
|
|
1920
|
+
return Boolean(item.audio) || item.hls === true;
|
|
1921
|
+
}
|
|
1027
1922
|
async function download(item, options = {}) {
|
|
1028
1923
|
const net = createNet(options.fetch ?? globalThis.fetch);
|
|
1924
|
+
if (buffered(item)) {
|
|
1925
|
+
const bytes = await itemBytes(net, item);
|
|
1926
|
+
return new Response(toArrayBuffer(bytes), { headers: { "content-length": String(bytes.length), "content-type": item.mime } });
|
|
1927
|
+
}
|
|
1029
1928
|
const response = await net(item.url, { headers: item.headers });
|
|
1030
1929
|
if (!response.ok || !response.body) {
|
|
1031
1930
|
throw new PostfetchError(502, `download failed: ${response.status}`);
|
|
@@ -1034,13 +1933,7 @@ async function download(item, options = {}) {
|
|
|
1034
1933
|
}
|
|
1035
1934
|
async function archive(result, options = {}) {
|
|
1036
1935
|
const net = createNet(options.fetch ?? globalThis.fetch);
|
|
1037
|
-
const files = await Promise.all(result.items.map(async (item) => {
|
|
1038
|
-
const response = await net(item.url, { headers: item.headers });
|
|
1039
|
-
if (!response.ok) {
|
|
1040
|
-
throw new PostfetchError(502, `download failed: ${response.status}`);
|
|
1041
|
-
}
|
|
1042
|
-
return { data: new Uint8Array(await response.arrayBuffer()), name: item.filename };
|
|
1043
|
-
}));
|
|
1936
|
+
const files = await Promise.all(result.items.map(async (item) => ({ data: await itemBytes(net, item), name: item.filename })));
|
|
1044
1937
|
return { bytes: zip(files), filename: result.archiveFilename, mime: "application/zip" };
|
|
1045
1938
|
}
|
|
1046
1939
|
async function toResponse(result, options = {}) {
|
|
@@ -1050,6 +1943,21 @@ async function toResponse(result, options = {}) {
|
|
|
1050
1943
|
return archiveResponse(result, options);
|
|
1051
1944
|
}
|
|
1052
1945
|
async function singleResponse(item, options) {
|
|
1946
|
+
if (buffered(item)) {
|
|
1947
|
+
const net = createNet(options.fetch ?? globalThis.fetch);
|
|
1948
|
+
const bytes = await itemBytes(net, item);
|
|
1949
|
+
return new Response(toArrayBuffer(bytes), {
|
|
1950
|
+
headers: {
|
|
1951
|
+
"content-disposition": `attachment; filename="${item.filename}"`,
|
|
1952
|
+
"content-length": String(bytes.length),
|
|
1953
|
+
"content-type": item.mime,
|
|
1954
|
+
"x-media-count": "1",
|
|
1955
|
+
"x-media-id": item.id,
|
|
1956
|
+
"x-media-kind": item.kind,
|
|
1957
|
+
"x-media-platform": item.platform
|
|
1958
|
+
}
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1053
1961
|
const media = await download(item, options);
|
|
1054
1962
|
const headers = new Headers({
|
|
1055
1963
|
"content-disposition": `attachment; filename="${item.filename}"`,
|
|
@@ -1067,8 +1975,7 @@ async function singleResponse(item, options) {
|
|
|
1067
1975
|
}
|
|
1068
1976
|
async function archiveResponse(result, options) {
|
|
1069
1977
|
const { bytes, filename: filename2 } = await archive(result, options);
|
|
1070
|
-
|
|
1071
|
-
return new Response(body, {
|
|
1978
|
+
return new Response(toArrayBuffer(bytes), {
|
|
1072
1979
|
headers: {
|
|
1073
1980
|
"content-disposition": `attachment; filename="${filename2}"`,
|
|
1074
1981
|
"content-length": String(bytes.length),
|
|
@@ -1079,6 +1986,36 @@ async function archiveResponse(result, options) {
|
|
|
1079
1986
|
}
|
|
1080
1987
|
});
|
|
1081
1988
|
}
|
|
1989
|
+
async function itemBytes(net, item) {
|
|
1990
|
+
if (item.hls) {
|
|
1991
|
+
const video = await assembleHls(net, item.url, item.headers);
|
|
1992
|
+
if (item.audio) {
|
|
1993
|
+
return mergeAudioVideo(video, await assembleHls(net, item.audio.url, item.audio.headers));
|
|
1994
|
+
}
|
|
1995
|
+
return video;
|
|
1996
|
+
}
|
|
1997
|
+
if (item.audio) {
|
|
1998
|
+
return mergedBytes(net, item, item.audio);
|
|
1999
|
+
}
|
|
2000
|
+
return fetchBytes(net, item.url, item.headers);
|
|
2001
|
+
}
|
|
2002
|
+
async function mergedBytes(net, item, audio) {
|
|
2003
|
+
const [video, audioBytes] = await Promise.all([
|
|
2004
|
+
fetchBytes(net, item.url, item.headers),
|
|
2005
|
+
fetchBytes(net, audio.url, audio.headers)
|
|
2006
|
+
]);
|
|
2007
|
+
return mergeAudioVideo(video, audioBytes);
|
|
2008
|
+
}
|
|
2009
|
+
async function fetchBytes(net, url, headers) {
|
|
2010
|
+
const response = await net(url, { headers });
|
|
2011
|
+
if (!response.ok) {
|
|
2012
|
+
throw new PostfetchError(502, `download failed: ${response.status}`);
|
|
2013
|
+
}
|
|
2014
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
2015
|
+
}
|
|
2016
|
+
function toArrayBuffer(bytes) {
|
|
2017
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
2018
|
+
}
|
|
1082
2019
|
export {
|
|
1083
2020
|
toResponse,
|
|
1084
2021
|
postfetch,
|