Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
41 changes: 38 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,17 @@ async fn resource(body: &str, content_type: &str, cache: bool) -> Result<Respons

Ok(res)
}
fn resource_bytes(content: &'static [u8], mime: &'static str, _shared: bool) -> Response<Body> {
Response::builder()
.header("Content-Type", mime)
// Ensure these match your existing 'resource' function's security headers
.header("Content-Security-Policy", "default-src 'none'; font-src 'self'; script-src 'self' blob: 'wasm-unsafe-eval'; manifest-src 'self'; media-src 'self' data: blob: about:; style-src 'self' 'unsafe-inline'; base-uri 'none'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; connect-src 'self'; worker-src 'self' blob:;")
.header("Cross-Origin-Embedder-Policy", "require-corp")
.header("Cross-Origin-Opener-Policy", "same-origin")
.status(200)
.body(Body::from(content))
.unwrap()
}

async fn style() -> Result<Response<Body>, String> {
let mut res = include_str!("../static/style.css").to_string();
Expand Down Expand Up @@ -211,7 +222,9 @@ async fn main() {
"Referrer-Policy" => "no-referrer",
"X-Content-Type-Options" => "nosniff",
"X-Frame-Options" => "DENY",
"Content-Security-Policy" => "default-src 'none'; font-src 'self'; script-src 'self' blob:; manifest-src 'self'; media-src 'self' data: blob: about:; style-src 'self' 'unsafe-inline'; base-uri 'none'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; connect-src 'self'; worker-src blob:;"
"Content-Security-Policy" => "default-src 'none'; font-src 'self'; script-src 'self' blob: 'wasm-unsafe-eval'; manifest-src 'self'; media-src 'self' data: blob: about:; style-src 'self' 'unsafe-inline'; base-uri 'none'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; connect-src 'self'; worker-src 'self' blob:;",
"Cross-Origin-Embedder-Policy" => "require-corp",
"Cross-Origin-Opener-Policy" => "same-origin"
};

if let Some(expire_time) = hsts {
Expand Down Expand Up @@ -259,13 +272,35 @@ async fn main() {
.at("/check_update.js")
.get(|_| resource(include_str!("../static/check_update.js"), "text/javascript", false).boxed());
app.at("/copy.js").get(|_| resource(include_str!("../static/copy.js"), "text/javascript", false).boxed());

app
.at("/downloadCombinedVideo.js")
.get(|_| resource(include_str!("../static/downloadCombinedVideo.js"), "text/javascript", false).boxed());


app.at("/static/ffmpeg/ffmpeg.min.js")
.get(|_| resource(include_str!("../static/ffmpeg/ffmpeg.min.js"), "text/javascript", false).boxed());
app.at("/static/ffmpeg/ffmpeg-util.min.js")
.get(|_| resource(include_str!("../static/ffmpeg/ffmpeg-util.min.js"), "text/javascript", false).boxed());
app.at("/static/ffmpeg/ffmpeg-core.js")
.get(|_| resource(include_str!("../static/ffmpeg/ffmpeg-core.js"), "text/javascript", false).boxed());
app.at("/static/ffmpeg/ffmpeg-core.wasm")
.get(|_| async {
// Wrap the response in Ok()
Ok(resource_bytes(include_bytes!("../static/ffmpeg/ffmpeg-core.wasm"), "application/wasm", false))
}.boxed());
app.at("/static/ffmpeg/814.ffmpeg.js")
.get(|_| resource(include_str!("../static/ffmpeg/814.ffmpeg.js"), "text/javascript", false).boxed());

app.at("/commits.atom").get(|_| async move { proxy_commit_info().await }.boxed());
app.at("/instances.json").get(|_| async move { proxy_instances().await }.boxed());

// Proxy media through Redlib
app.at("/vid/:id/:size").get(|r| proxy(r, "https://v.redd.it/{id}/DASH_{size}").boxed());
app.at("/vid/:id/dash/:size").get(|r| proxy(r, "https://v.redd.it/{id}/DASH_{size}").boxed());
app.at("/vid/:id/cmaf/:size").get(|r| proxy(r, "https://v.redd.it/{id}/CMAF_{size}").boxed());
app.at("/vid/:id/dash/audio/:bitrate").get(|r| proxy(r, "https://v.redd.it/{id}/AUDIO_{bitrate}").boxed());
app.at("/vid/:id/cmaf/audio/:bitrate").get(|r| proxy(r, "https://v.redd.it/{id}/CMAF_AUDIO_{bitrate}").boxed());
app.at("/hls/:id/*path").get(|r| proxy(r, "https://v.redd.it/{id}/{path}").boxed());
app.at("/audio/:id/*path").get(|r| proxy(r, "https://v.redd.it/{id}/{path}").boxed());
app.at("/img/*path").get(|r| proxy(r, "https://i.redd.it/{path}").boxed());
app.at("/thumb/:point/:id").get(|r| proxy(r, "https://{point}.thumbs.redditmedia.com/{id}").boxed());
app.at("/emoji/:id/:name").get(|r| proxy(r, "https://emoji.redditmedia.com/{id}/{name}").boxed());
Expand Down
44 changes: 40 additions & 4 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ pub struct Flags {
#[derive(Debug, Serialize)]
pub struct Media {
pub url: String,
pub url_audio: String,
pub alt_url: String,
pub width: i64,
pub height: i64,
Expand All @@ -194,20 +195,25 @@ impl Media {
let secure_media = &data["secure_media"]["reddit_video"];
let crosspost_parent_media = &data["crosspost_parent_list"][0]["secure_media"]["reddit_video"];

let mut has_audio=false;

// If post is a video, return the video
let (post_type, url_val, alt_url_val) = if data_preview["fallback_url"].is_string() {
has_audio=data_preview["has_audio"].as_bool().unwrap_or(false);
(
if data_preview["is_gif"].as_bool().unwrap_or(false) { "gif" } else { "video" },
&data_preview["fallback_url"],
Some(&data_preview["hls_url"]),
)
} else if secure_media["fallback_url"].is_string() {
has_audio=secure_media["has_audio"].as_bool().unwrap_or(false);
(
if secure_media["is_gif"].as_bool().unwrap_or(false) { "gif" } else { "video" },
&secure_media["fallback_url"],
Some(&secure_media["hls_url"]),
)
} else if crosspost_parent_media["fallback_url"].is_string() {
has_audio=crosspost_parent_media["has_audio"].as_bool().unwrap_or(false);
(
if crosspost_parent_media["is_gif"].as_bool().unwrap_or(false) { "gif" } else { "video" },
&crosspost_parent_media["fallback_url"],
Expand Down Expand Up @@ -261,15 +267,41 @@ impl Media {
let permalink_base = url_path_basename(data["permalink"].as_str().unwrap_or_default());
let media_url_base = url_path_basename(url_val.as_str().unwrap_or_default());

// Remove .mp4 extension for cleaner combined filename
let media_url_base = media_url_base.strip_suffix(".mp4").unwrap_or(&media_url_base);

format!("redlib_{permalink_base}_{media_url_base}")
} else {
String::new()
};

// Extract audio URL from video ID
// DASH manifest contains direct audio MP4 files: CMAF_AUDIO_64.mp4 and CMAF_AUDIO_128.mp4
let video_url = format_url(url_val.as_str().unwrap_or_default());
let url_audio = if post_type == "video" && has_audio {
// Extract video ID from the video URL
// video_url format: /vid/{id}/cmaf/{quality}.mp4 or /vid/{id}/dash/{quality}.mp4
if video_url.starts_with("/vid/") {
let parts: Vec<&str> = video_url.split('/').collect();
if parts.len() >= 5 {
let video_id = parts[2];
// Use highest quality audio (128 kbps) as direct MP4 file
format!("/vid/{video_id}/cmaf/audio/128.mp4")
} else {
String::new()
}
} else {
String::new()
}
} else {
String::new()
};

(
post_type.to_string(),
Self {
url: format_url(url_val.as_str().unwrap_or_default()),
url: video_url,
url_audio,
alt_url,
// Note: in the data["is_reddit_media_domain"] path above
// width and height will be 0.
Expand Down Expand Up @@ -420,6 +452,7 @@ impl Post {
post_type,
thumbnail: Media {
url: format_url(val(post, "thumbnail").as_str()),
url_audio: String::new(),
alt_url: String::new(),
width: data["thumbnail_width"].as_i64().unwrap_or_default(),
height: data["thumbnail_height"].as_i64().unwrap_or_default(),
Expand Down Expand Up @@ -852,6 +885,7 @@ pub async fn parse_post(post: &Value) -> Post {
media,
thumbnail: Media {
url: format_url(val(post, "thumbnail").as_str()),
url_audio: String::new(),
alt_url: String::new(),
width: post["data"]["thumbnail_width"].as_i64().unwrap_or_default(),
height: post["data"]["thumbnail_height"].as_i64().unwrap_or_default(),
Expand Down Expand Up @@ -1007,7 +1041,7 @@ static REGEX_URL_WWW: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://w
static REGEX_URL_OLD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://old\.reddit\.com/(.*)").unwrap());
static REGEX_URL_NP: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://np\.reddit\.com/(.*)").unwrap());
static REGEX_URL_PLAIN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://reddit\.com/(.*)").unwrap());
static REGEX_URL_VIDEOS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://v\.redd\.it/(.*)/DASH_([0-9]{2,4}(\.mp4|$|\?source=fallback))").unwrap());
static REGEX_URL_VIDEOS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://v\.redd\.it/(.*)/(DASH|CMAF)_([0-9]{2,4}(\.mp4|$|\?source=fallback))").unwrap());
static REGEX_URL_VIDEOS_HLS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://v\.redd\.it/(.+)/(HLSPlaylist\.m3u8.*)$").unwrap());
static REGEX_URL_IMAGES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://i\.redd\.it/(.*)").unwrap());
static REGEX_URL_THUMBS_A: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://a\.thumbs\.redditmedia\.com/(.*)").unwrap());
Expand All @@ -1030,6 +1064,7 @@ pub fn format_url(url: &str) -> String {
regex.captures(url).map_or(String::new(), |caps| match segments {
1 => [format, &caps[1]].join(""),
2 => [format, &caps[1], "/", &caps[2]].join(""),
3 => [format, &caps[1], "/", &caps[2].to_lowercase().as_str(), "/", &caps[3]].join(""),
_ => String::new(),
})
};
Expand Down Expand Up @@ -1060,7 +1095,7 @@ pub fn format_url(url: &str) -> String {
"old.reddit.com" => capture(&REGEX_URL_OLD, "/", 1),
"np.reddit.com" => capture(&REGEX_URL_NP, "/", 1),
"reddit.com" => capture(&REGEX_URL_PLAIN, "/", 1),
"v.redd.it" => chain!(capture(&REGEX_URL_VIDEOS, "/vid/", 2), capture(&REGEX_URL_VIDEOS_HLS, "/hls/", 2)),
"v.redd.it" => chain!(capture(&REGEX_URL_VIDEOS, "/vid/", 3), capture(&REGEX_URL_VIDEOS_HLS, "/hls/", 2)),
"i.redd.it" => capture(&REGEX_URL_IMAGES, "/img/", 1),
"a.thumbs.redditmedia.com" => capture(&REGEX_URL_THUMBS_A, "/thumb/a/", 1),
"b.thumbs.redditmedia.com" => capture(&REGEX_URL_THUMBS_B, "/thumb/b/", 1),
Expand Down Expand Up @@ -1499,7 +1534,8 @@ mod tests {
format_url("https://preview.redd.it/qwerty.jpg?auto=webp&s=asdf"),
"/preview/pre/qwerty.jpg?auto=webp&s=asdf"
);
assert_eq!(format_url("https://v.redd.it/foo/DASH_360.mp4?source=fallback"), "/vid/foo/360.mp4");
assert_eq!(format_url("https://v.redd.it/foo/DASH_360.mp4?source=fallback"), "/vid/foo/dash/360.mp4");
assert_eq!(format_url("https://v.redd.it/foo/CMAF_720.mp4?source=fallback"), "/vid/foo/cmaf/720.mp4");
assert_eq!(
format_url("https://v.redd.it/foo/HLSPlaylist.m3u8?a=bar&v=1&f=sd"),
"/hls/foo/HLSPlaylist.m3u8?a=bar&v=1&f=sd"
Expand Down
62 changes: 62 additions & 0 deletions static/downloadCombinedVideo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Download combined video and audio using ffmpeg.wasm
// Usage: downloadCombinedVideo(videoUrl, audioUrl, downloadName)

(function() {
const { FFmpeg } = FFmpegWASM;
const { fetchFile } = FFmpegUtil;

window.downloadCombinedVideo = async function(videoUrl, audioUrl, downloadName) {
const ffmpeg = new FFmpeg();

await ffmpeg.load({
coreURL: '/static/ffmpeg/ffmpeg-core.js',
wasmURL: '/static/ffmpeg/ffmpeg-core.wasm',
});

// 2. Fetch the separate files and write them to the virtual filesystem
await ffmpeg.writeFile('input_video.mp4', await fetchFile(videoUrl));
await ffmpeg.writeFile('input_audio.mp4', await fetchFile(audioUrl));

// 3. Execute the muxing command (Copy codecs for speed)
await ffmpeg.exec([
'-i', 'input_video.mp4',
'-i', 'input_audio.mp4',
'-c:v', 'copy',
'-c:a', 'copy',
'-map', '0:v:0',
'-map', '1:a:0',
'output.mp4'
]);

// 4. Read the result and trigger a browser download
const data = await ffmpeg.readFile('output.mp4');
const url = URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' }));

const a = document.createElement('a');
a.href = url;
a.download = downloadName || 'combined_video.mp4';
a.click();

// Cleanup
URL.revokeObjectURL(url);
};

// Attach click handlers to combined download links
function attachDownloadHandlers() {
document.querySelectorAll('a.combined_download').forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();
const videoUrl = link.getAttribute('data-video');
const audioUrl = link.getAttribute('data-audio');
const downloadName = link.getAttribute('data-name');
downloadCombinedVideo(videoUrl, audioUrl, downloadName);
});
});
}

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', attachDownloadHandlers);
} else {
attachDownloadHandlers();
}
})();
2 changes: 2 additions & 0 deletions static/ffmpeg/814.ffmpeg.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions static/ffmpeg/ffmpeg-core.js

Large diffs are not rendered by default.

Binary file added static/ffmpeg/ffmpeg-core.wasm
Binary file not shown.
7 changes: 7 additions & 0 deletions static/ffmpeg/ffmpeg-util.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading