mirror of
https://github.com/4cuck/teapawt.git
synced 2026-10-27 09:04:35 +00:00
transcode: add GIF transcoding for Discord inline playback
This commit is contained in:
1 parent
12f224a8af
commit
5617d98947
13 files changed
+614
-47
No files matched your search
Generated
+22
@@ -225,6 +225,16 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eyre"
|
||||
version = "0.6.12"
|
||||
@@ -854,6 +864,16 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.8"
|
||||
@@ -920,6 +940,7 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"indexmap",
|
||||
"maud",
|
||||
"percent-encoding",
|
||||
"regex",
|
||||
@@ -1011,6 +1032,7 @@ dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
@@ -17,6 +17,7 @@ data-encoding = "2.10.0"
|
||||
eyre = "0.6.12"
|
||||
flate2 = "1.1.9"
|
||||
form_urlencoded = "1.2.2"
|
||||
indexmap = "2.9.0"
|
||||
http-body-util = "0.1.3"
|
||||
hyper = { features = [ "http1", "client" ], version = "1.8.1" }
|
||||
hyper-rustls = {
|
||||
@@ -58,6 +59,7 @@ tokio = {
|
||||
"sync",
|
||||
"fs",
|
||||
"io-util",
|
||||
"process",
|
||||
],
|
||||
version = "1.49.0",
|
||||
}
|
||||
|
||||
@@ -41,6 +41,17 @@ disableTid = false
|
||||
# Max requests per session to avoid race conditions
|
||||
maxConcurrentReqs = 2
|
||||
|
||||
[gifTranscoding]
|
||||
# Transcode Twitter GIF MP4s to actual GIF format for Discord inline playback
|
||||
# Modes: "off" (default), "local" (ffmpeg on server), "external" (URL rewrite)
|
||||
mode = "off"
|
||||
# Cache directory for transcoded GIFs (local mode only)
|
||||
cacheDir = "./cache/gif"
|
||||
# Maximum cache size in MB (local mode only)
|
||||
cacheMaxMb = 512
|
||||
# External transcoding domain (external mode only, e.g. "gif.example.com")
|
||||
externalDomain = ""
|
||||
|
||||
[preferences]
|
||||
# Default theme
|
||||
theme = "teapot"
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
}
|
||||
(
|
||||
lib.recursiveUpdate {
|
||||
inherit (cfg) cache preferences;
|
||||
inherit (cfg) cache preferences gifTranscoding;
|
||||
config = cfg.config // {
|
||||
hmacKey = "@hmac@";
|
||||
};
|
||||
@@ -254,6 +254,33 @@
|
||||
};
|
||||
};
|
||||
|
||||
gifTranscoding = {
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"off"
|
||||
"local"
|
||||
"external"
|
||||
];
|
||||
default = "off";
|
||||
description = "GIF transcoding mode. 'local' requires ffmpeg, 'external' rewrites URLs to an external domain.";
|
||||
};
|
||||
cacheDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/var/lib/teapot/cache/gif";
|
||||
description = "Cache directory for transcoded GIFs (local mode).";
|
||||
};
|
||||
cacheMaxMb = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 512;
|
||||
description = "Maximum cache size in MB (local mode).";
|
||||
};
|
||||
externalDomain = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "External transcoding domain (external mode).";
|
||||
};
|
||||
};
|
||||
|
||||
preferences = {
|
||||
theme = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
@@ -316,6 +343,7 @@
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
path = lib.mkIf (cfg.gifTranscoding.mode == "local") [ pkgs.ffmpeg-headless ];
|
||||
serviceConfig = {
|
||||
DynamicUser = true;
|
||||
LoadCredential = "sessionsFile:${cfg.sessionsFile}";
|
||||
|
||||
Vendored
+130
@@ -0,0 +1,130 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use tokio::{
|
||||
fs,
|
||||
sync::RwLock,
|
||||
};
|
||||
|
||||
struct GifCacheEntry {
|
||||
file_size: u64,
|
||||
last_access: SystemTime,
|
||||
}
|
||||
|
||||
pub struct GifCache {
|
||||
dir: PathBuf,
|
||||
max_bytes: u64,
|
||||
entries: Arc<RwLock<IndexMap<String, GifCacheEntry>>>,
|
||||
}
|
||||
|
||||
impl GifCache {
|
||||
/// Create a new GIF cache, scanning existing files to rebuild state.
|
||||
pub async fn new(dir: &str, max_mb: u64) -> eyre::Result<Self> {
|
||||
let dir = PathBuf::from(dir);
|
||||
fs::create_dir_all(&dir).await?;
|
||||
|
||||
let mut entries = IndexMap::new();
|
||||
let mut read_dir = fs::read_dir(&dir).await?;
|
||||
while let Some(entry) = read_dir.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|ext| ext == "gif")
|
||||
&& let Some(stem) = path.file_stem().and_then(|st| st.to_str())
|
||||
&& let Ok(meta) = entry.metadata().await
|
||||
{
|
||||
entries.insert(
|
||||
stem.to_owned(),
|
||||
GifCacheEntry {
|
||||
file_size: meta.len(),
|
||||
last_access: meta
|
||||
.accessed()
|
||||
.or_else(|_| meta.modified())
|
||||
.unwrap_or_else(|_| SystemTime::now()),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by last access time (oldest first) for LRU ordering
|
||||
entries.sort_by(|_, lhs, _, rhs| lhs.last_access.cmp(&rhs.last_access));
|
||||
|
||||
tracing::info!(
|
||||
"GIF cache loaded: {} entries from {}",
|
||||
entries.len(),
|
||||
dir.display()
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
dir,
|
||||
max_bytes: max_mb * 1024 * 1024,
|
||||
entries: Arc::new(RwLock::new(entries)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the path for a cached GIF, returning `None` if not cached.
|
||||
#[expect(clippy::significant_drop_tightening, reason = "entry borrows from map")]
|
||||
pub async fn get(&self, hash: &str) -> Option<PathBuf> {
|
||||
let path = self.dir.join(format!("{hash}.gif"));
|
||||
if !fs::try_exists(&path).await.unwrap_or(false) {
|
||||
// File was deleted externally — remove from tracking
|
||||
self.entries.write().await.shift_remove(hash);
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut entries = self.entries.write().await;
|
||||
if let Some(entry) = entries.get_mut(hash) {
|
||||
entry.last_access = SystemTime::now();
|
||||
// Move to end (most recently used)
|
||||
let idx = entries.get_index_of(hash).unwrap_or_default();
|
||||
let last = entries.len().saturating_sub(1);
|
||||
entries.move_index(idx, last);
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a transcoded GIF into the cache, evicting LRU entries if needed.
|
||||
pub async fn put(&self, hash: &str, data: &[u8]) -> eyre::Result<PathBuf> {
|
||||
let path = self.dir.join(format!("{hash}.gif"));
|
||||
fs::write(&path, data).await?;
|
||||
|
||||
let file_size = data.len() as u64;
|
||||
|
||||
let mut entries = self.entries.write().await;
|
||||
entries.insert(
|
||||
hash.to_owned(),
|
||||
GifCacheEntry {
|
||||
file_size,
|
||||
last_access: SystemTime::now(),
|
||||
},
|
||||
);
|
||||
Self::evict(&self.dir, &mut entries, self.max_bytes).await;
|
||||
drop(entries);
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Remove oldest entries until total size is under the limit.
|
||||
async fn evict(dir: &Path, entries: &mut IndexMap<String, GifCacheEntry>, max_bytes: u64) {
|
||||
let total: u64 = entries.values().map(|entry| entry.file_size).sum();
|
||||
if total <= max_bytes {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut current = total;
|
||||
while current > max_bytes && !entries.is_empty() {
|
||||
// Remove from the front (oldest)
|
||||
if let Some((hash, entry)) = entries.shift_remove_index(0) {
|
||||
current -= entry.file_size;
|
||||
let path = dir.join(format!("{hash}.gif"));
|
||||
if let Err(err) = fs::remove_file(&path).await {
|
||||
tracing::warn!("Failed to evict cached GIF {}: {err}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+4
@@ -1,3 +1,7 @@
|
||||
pub mod gif_cache;
|
||||
|
||||
pub use gif_cache::GifCache;
|
||||
|
||||
use std::{
|
||||
any::Any,
|
||||
collections::HashMap,
|
||||
|
||||
+72
-2
@@ -1,12 +1,52 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::Path,
|
||||
process::Command,
|
||||
};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum GifTranscodingMode {
|
||||
#[default]
|
||||
Off,
|
||||
Local,
|
||||
External,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GifTranscodingConfig {
|
||||
#[serde(default)]
|
||||
pub mode: GifTranscodingMode,
|
||||
#[serde(default = "default_gif_cache_dir", rename = "cacheDir")]
|
||||
pub cache_dir: String,
|
||||
#[serde(default = "default_gif_cache_max_mb", rename = "cacheMaxMb")]
|
||||
pub cache_max_mb: u64,
|
||||
#[serde(default, rename = "externalDomain")]
|
||||
pub external_domain: String,
|
||||
}
|
||||
|
||||
impl Default for GifTranscodingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: GifTranscodingMode::Off,
|
||||
cache_dir: default_gif_cache_dir(),
|
||||
cache_max_mb: default_gif_cache_max_mb(),
|
||||
external_domain: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_gif_cache_dir() -> String {
|
||||
"./cache/gif".to_owned()
|
||||
}
|
||||
const fn default_gif_cache_max_mb() -> u64 {
|
||||
512
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[expect(
|
||||
clippy::struct_field_names,
|
||||
@@ -17,10 +57,12 @@ pub struct Config {
|
||||
pub cache: CacheConfig,
|
||||
pub config: AppConfig,
|
||||
#[serde(default)]
|
||||
pub preferences: PreferencesConfig,
|
||||
pub preferences: PreferencesConfig,
|
||||
#[serde(default, rename = "gifTranscoding")]
|
||||
pub gif_transcoding: GifTranscodingConfig,
|
||||
/// Precomputed `{scheme}://{hostname}` — set in `Config::load`.
|
||||
#[serde(skip)]
|
||||
pub url_prefix: String,
|
||||
pub url_prefix: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -143,6 +185,7 @@ const fn default_true() -> bool {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
#[expect(clippy::cognitive_complexity, reason = "validation is straightforward")]
|
||||
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let mut config = toml::from_str::<Self>(&content)?;
|
||||
@@ -152,6 +195,33 @@ impl Config {
|
||||
config"
|
||||
);
|
||||
}
|
||||
// Validate GIF transcoding config
|
||||
match config.gif_transcoding.mode {
|
||||
GifTranscodingMode::Local => {
|
||||
if Command::new("ffmpeg")
|
||||
.arg("-version")
|
||||
.output()
|
||||
.is_err()
|
||||
{
|
||||
tracing::error!(
|
||||
"gifTranscoding.mode is 'local' but ffmpeg was not found on PATH — falling back \
|
||||
to 'off'"
|
||||
);
|
||||
config.gif_transcoding.mode = GifTranscodingMode::Off;
|
||||
}
|
||||
},
|
||||
GifTranscodingMode::External => {
|
||||
if config.gif_transcoding.external_domain.is_empty() {
|
||||
tracing::error!(
|
||||
"gifTranscoding.mode is 'external' but externalDomain is empty — falling back \
|
||||
to 'off'"
|
||||
);
|
||||
config.gif_transcoding.mode = GifTranscodingMode::Off;
|
||||
}
|
||||
},
|
||||
GifTranscodingMode::Off => {},
|
||||
}
|
||||
|
||||
let scheme = if config.server.https { "https" } else { "http" };
|
||||
let default_port = if config.server.https { 443 } else { 80 };
|
||||
let url_port = config.server.public_port.unwrap_or(config.server.port);
|
||||
|
||||
+36
-6
@@ -3,6 +3,7 @@ mod cache;
|
||||
mod config;
|
||||
mod error;
|
||||
mod routes;
|
||||
mod transcode;
|
||||
mod types;
|
||||
mod utils;
|
||||
mod views;
|
||||
@@ -40,16 +41,21 @@ use crate::{
|
||||
SessionPool,
|
||||
},
|
||||
cache::Cache,
|
||||
config::Config,
|
||||
config::{
|
||||
Config,
|
||||
GifTranscodingMode,
|
||||
},
|
||||
transcode::GifTranscoder,
|
||||
};
|
||||
|
||||
/// Application state shared across all routes.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: Arc<Config>,
|
||||
pub cache: Cache,
|
||||
pub api: ApiClient,
|
||||
pub http_client: HttpClient,
|
||||
pub config: Arc<Config>,
|
||||
pub cache: Cache,
|
||||
pub api: ApiClient,
|
||||
pub http_client: HttpClient,
|
||||
pub gif_transcoder: Option<Arc<GifTranscoder>>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -87,12 +93,36 @@ async fn main() -> eyre::Result<()> {
|
||||
// Initialize API client
|
||||
let api = ApiClient::new(&config, sessions);
|
||||
|
||||
// Initialize GIF transcoder if local mode
|
||||
let http_client = HttpClient::new(&config.config.proxy, &config.config.proxy_auth);
|
||||
let gif_transcoder = if config.gif_transcoding.mode == GifTranscodingMode::Local {
|
||||
match GifTranscoder::new(http_client.clone(), config.gif_transcoding.clone()).await {
|
||||
Ok(transcoder) => {
|
||||
tracing::info!("GIF transcoder enabled (local mode)");
|
||||
Some(Arc::new(transcoder))
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to initialize GIF transcoder: {err}");
|
||||
None
|
||||
},
|
||||
}
|
||||
} else {
|
||||
if config.gif_transcoding.mode == GifTranscodingMode::External {
|
||||
tracing::info!(
|
||||
"GIF transcoding enabled (external mode, domain: {})",
|
||||
config.gif_transcoding.external_domain
|
||||
);
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
// Create application state
|
||||
let state = AppState {
|
||||
config: Arc::clone(&config),
|
||||
cache,
|
||||
api,
|
||||
http_client: HttpClient::new(&config.config.proxy, &config.config.proxy_auth),
|
||||
http_client,
|
||||
gif_transcoder,
|
||||
};
|
||||
|
||||
// Build router - order matters: specific routes first, then static files
|
||||
|
||||
@@ -16,6 +16,7 @@ use axum::{
|
||||
},
|
||||
routing::get,
|
||||
};
|
||||
use tokio::fs;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
@@ -37,6 +38,8 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/pic/orig/enc/{url}", get(pic_orig_proxy_encoded))
|
||||
.route("/video/{sig}/{url}", get(video_proxy))
|
||||
.route("/video/enc/{sig}/{url}", get(video_proxy_encoded))
|
||||
.route("/gif/{sig}/{url}", get(gif_proxy))
|
||||
.route("/gif/enc/{sig}/{url}", get(gif_proxy_encoded))
|
||||
}
|
||||
|
||||
async fn pic_proxy(State(state): State<AppState>, Path(url): Path<String>) -> Result<Response> {
|
||||
@@ -162,6 +165,69 @@ async fn proxy_image(state: &AppState, url: &str, original: bool) -> Result<Resp
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn gif_proxy(
|
||||
State(state): State<AppState>,
|
||||
Path((sig, url)): Path<(String, String)>,
|
||||
) -> Result<Response> {
|
||||
// Strip .gif suffix added for Discord's image proxy extension detection
|
||||
let raw_url = url.strip_suffix(".gif").unwrap_or(&url);
|
||||
let decoded_url = percent_encoding::percent_decode_str(raw_url)
|
||||
.decode_utf8()
|
||||
.map_err(|_| Error::InvalidUrl("Invalid URL encoding".into()))?
|
||||
.to_string();
|
||||
|
||||
if !hmac::verify(&decoded_url, &sig, &state.config.config.hmac_key) {
|
||||
return Err(Error::HmacVerification);
|
||||
}
|
||||
|
||||
serve_gif(&state, &decoded_url).await
|
||||
}
|
||||
|
||||
async fn gif_proxy_encoded(
|
||||
State(state): State<AppState>,
|
||||
Path((sig, url)): Path<(String, String)>,
|
||||
) -> Result<Response> {
|
||||
let raw_url = url.strip_suffix(".gif").unwrap_or(&url);
|
||||
let decoded = formatters::base64_decode_url(raw_url)
|
||||
.ok_or_else(|| Error::InvalidUrl("Invalid base64 encoding".into()))?;
|
||||
|
||||
if !hmac::verify(&decoded, &sig, &state.config.config.hmac_key) {
|
||||
return Err(Error::HmacVerification);
|
||||
}
|
||||
|
||||
serve_gif(&state, &decoded).await
|
||||
}
|
||||
|
||||
async fn serve_gif(state: &AppState, mp4_url: &str) -> Result<Response> {
|
||||
let transcoder = state
|
||||
.gif_transcoder
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::Internal("GIF transcoding not enabled".into()))?;
|
||||
|
||||
match transcoder.get_or_transcode(mp4_url).await {
|
||||
Ok(path) => {
|
||||
let bytes = fs::read(&path).await.map_err(|err| {
|
||||
Error::Internal(format!("read cached GIF: {err}"))
|
||||
})?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, "image/gif".to_owned()),
|
||||
(header::CACHE_CONTROL, "public, max-age=604800".to_owned()),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::warn!("GIF transcode failed, falling back to MP4 proxy: {err}");
|
||||
// Fall back to proxying the MP4 directly
|
||||
proxy_video(state, mp4_url, &HeaderMap::new()).await
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn proxy_video(state: &AppState, url: &str, req_headers: &HeaderMap) -> Result<Response> {
|
||||
// Forward Range header to upstream for seeking support
|
||||
let mut upstream_headers = HeaderMap::new();
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::PathBuf,
|
||||
process::Stdio,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use data_encoding::HEXLOWER;
|
||||
use ring::digest;
|
||||
use tokio::{
|
||||
fs,
|
||||
process::Command,
|
||||
sync::{Mutex, Notify, Semaphore},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
api::HttpClient,
|
||||
cache::GifCache,
|
||||
config::GifTranscodingConfig,
|
||||
};
|
||||
|
||||
pub struct GifTranscoder {
|
||||
cache: GifCache,
|
||||
http_client: HttpClient,
|
||||
cache_dir: PathBuf,
|
||||
semaphore: Semaphore,
|
||||
inflight: Mutex<HashMap<String, Arc<Notify>>>,
|
||||
}
|
||||
|
||||
impl GifTranscoder {
|
||||
pub async fn new(
|
||||
http_client: HttpClient,
|
||||
config: GifTranscodingConfig,
|
||||
) -> eyre::Result<Self> {
|
||||
let cache = GifCache::new(&config.cache_dir, config.cache_max_mb).await?;
|
||||
Ok(Self {
|
||||
cache,
|
||||
http_client,
|
||||
cache_dir: PathBuf::from(config.cache_dir),
|
||||
semaphore: Semaphore::new(3),
|
||||
inflight: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Hash an MP4 URL to a cache key (truncated SHA-256, 32 hex chars).
|
||||
fn hash_url(url: &str) -> String {
|
||||
let hash = digest::digest(&digest::SHA256, url.as_bytes());
|
||||
HEXLOWER.encode(hash.as_ref())[..32].to_owned()
|
||||
}
|
||||
|
||||
/// Get a cached GIF or transcode the MP4. Returns the path to the GIF file.
|
||||
pub async fn get_or_transcode(&self, mp4_url: &str) -> eyre::Result<PathBuf> {
|
||||
let hash = Self::hash_url(mp4_url);
|
||||
|
||||
// Check cache
|
||||
if let Some(path) = self.cache.get(&hash).await {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
// Atomically check-and-register: either wait on an existing transcoder or
|
||||
// register ourselves as the inflight one.
|
||||
let existing = {
|
||||
let map = self.inflight.lock().await;
|
||||
map.get(&hash).map(Arc::clone)
|
||||
};
|
||||
if let Some(existing) = existing {
|
||||
existing.notified().await;
|
||||
if let Some(path) = self.cache.get(&hash).await {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
let notify = Arc::new(Notify::new());
|
||||
self.inflight
|
||||
.lock()
|
||||
.await
|
||||
.insert(hash.clone(), Arc::clone(¬ify));
|
||||
|
||||
// Acquire semaphore
|
||||
let _permit = self.semaphore.acquire().await?;
|
||||
|
||||
// Double-check cache after acquiring permit
|
||||
if let Some(path) = self.cache.get(&hash).await {
|
||||
self.inflight.lock().await.remove(&hash);
|
||||
notify.notify_waiters();
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let result = self.do_transcode(mp4_url, &hash).await;
|
||||
|
||||
// Notify waiters and remove inflight entry
|
||||
self.inflight.lock().await.remove(&hash);
|
||||
notify.notify_waiters();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn do_transcode(&self, mp4_url: &str, hash: &str) -> eyre::Result<PathBuf> {
|
||||
let cache_dir = &self.cache_dir;
|
||||
let input = cache_dir.join(format!("{hash}.mp4.tmp"));
|
||||
let palette = cache_dir.join(format!("{hash}.palette.png"));
|
||||
let output = cache_dir.join(format!("{hash}.gif.tmp"));
|
||||
|
||||
// Fetch MP4
|
||||
let response = self
|
||||
.http_client
|
||||
.get(mp4_url)
|
||||
.await
|
||||
.map_err(|err| eyre::eyre!("Failed to fetch MP4: {err}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(eyre::eyre!("MP4 fetch returned {}", response.status()));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| eyre::eyre!("Failed to read MP4 body: {err}"))?;
|
||||
fs::write(&input, &bytes).await?;
|
||||
|
||||
// Pass 1: generate palette
|
||||
let palette_out = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-i",
|
||||
&input.to_string_lossy(),
|
||||
"-vf",
|
||||
"fps=15,scale=480:-1:flags=lanczos,palettegen=stats_mode=diff",
|
||||
"-y",
|
||||
&palette.to_string_lossy(),
|
||||
])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !palette_out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&palette_out.stderr);
|
||||
Self::cleanup(&[&input, &palette, &output]).await;
|
||||
return Err(eyre::eyre!("ffmpeg palettegen failed: {stderr}"));
|
||||
}
|
||||
|
||||
// Pass 2: generate GIF with palette
|
||||
let gif_out = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-i",
|
||||
&input.to_string_lossy(),
|
||||
"-i",
|
||||
&palette.to_string_lossy(),
|
||||
"-lavfi",
|
||||
"fps=15,scale=480:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=5",
|
||||
"-f",
|
||||
"gif",
|
||||
"-y",
|
||||
&output.to_string_lossy(),
|
||||
])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !gif_out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&gif_out.stderr);
|
||||
Self::cleanup(&[&input, &palette, &output]).await;
|
||||
return Err(eyre::eyre!("ffmpeg paletteuse failed: {stderr}"));
|
||||
}
|
||||
|
||||
// Read the output GIF and insert into cache
|
||||
let gif_data = fs::read(&output).await?;
|
||||
let result = self.cache.put(hash, &gif_data).await;
|
||||
|
||||
// Clean up temp files
|
||||
Self::cleanup(&[&input, &palette, &output]).await;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn cleanup(paths: &[&PathBuf]) {
|
||||
for path in paths {
|
||||
let _ = fs::remove_file(path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,6 +201,19 @@ pub fn get_video_embed_url(config: &Config, tweet_id: i64) -> String {
|
||||
format!("{}/i/videos/tweet/{tweet_id}", config.url_prefix())
|
||||
}
|
||||
|
||||
/// Generate GIF URL with HMAC signature (for local transcoding).
|
||||
/// The `.gif` suffix is critical: Discord's image proxy uses the URL
|
||||
/// extension to decide whether to preserve animation.
|
||||
pub fn get_gif_url(mp4_url: &str, hmac_key: &str, base64_media: bool) -> String {
|
||||
let sig = super::sign(mp4_url, hmac_key);
|
||||
format!("{}.gif", encode_media_url(&format!("/gif/{sig}"), mp4_url, base64_media))
|
||||
}
|
||||
|
||||
/// Generate external GIF URL by rewriting the domain.
|
||||
pub fn get_external_gif_url(mp4_url: &str, external_domain: &str) -> String {
|
||||
mp4_url.replace("video.twimg.com", external_domain)
|
||||
}
|
||||
|
||||
/// Base64 encode a URL (URL-safe variant).
|
||||
pub fn base64_encode_url(url: &str) -> String {
|
||||
BASE64URL_NOPAD.encode(url.as_bytes())
|
||||
|
||||
+40
-36
@@ -13,7 +13,10 @@ use super::{
|
||||
tweet::TweetRenderer,
|
||||
};
|
||||
use crate::{
|
||||
config::Config,
|
||||
config::{
|
||||
Config,
|
||||
GifTranscodingMode,
|
||||
},
|
||||
types::{
|
||||
Gif,
|
||||
Prefs,
|
||||
@@ -87,14 +90,6 @@ pub fn og_images_with_quote(tweet: &Tweet) -> Vec<&str> {
|
||||
.map_or_else(Vec::new, og_images)
|
||||
}
|
||||
|
||||
/// Check if this tweet has video/gif, including from quote.
|
||||
pub fn has_video_with_quote(tweet: &Tweet) -> bool {
|
||||
with_quote_fallback(tweet, |tw| {
|
||||
(tw.video.is_some() || tw.gif.is_some()).then_some(())
|
||||
})
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Get the video, falling back to quote tweet.
|
||||
pub fn video_with_quote(tweet: &Tweet) -> Option<&Video> {
|
||||
with_quote_fallback(tweet, |tw| tw.video.as_ref())
|
||||
@@ -157,27 +152,32 @@ pub fn build_embed_description(tweet: &Tweet) -> String {
|
||||
/// Shared between `render_tweet_embed` and `render_status_page`.
|
||||
fn render_media_meta_tags(tweet: &Tweet, config: &Config, url_prefix: &str) -> Markup {
|
||||
let images = og_images_with_quote(tweet);
|
||||
let has_video = has_video_with_quote(tweet);
|
||||
let has_video = video_with_quote(tweet).is_some();
|
||||
// GIF tweets provide their own og:image (the transcoded .gif URL)
|
||||
// in the GIF branch below, so skip the thumbnail from the image loop.
|
||||
let has_gif = gif_with_quote(tweet).is_some();
|
||||
|
||||
html! {
|
||||
@if has_video {
|
||||
meta property="og:type" content="video";
|
||||
meta property="og:type" content="video.other";
|
||||
} @else if !images.is_empty() {
|
||||
meta property="og:type" content="photo";
|
||||
} @else {
|
||||
meta property="og:type" content="article";
|
||||
}
|
||||
|
||||
// Image meta tags
|
||||
@for image in &images {
|
||||
@let pic_url = formatters::get_pic_url(image, config.config.base64_media);
|
||||
@let full_pic_url = format!("{url_prefix}{pic_url}");
|
||||
meta property="og:image" content=(full_pic_url);
|
||||
// Image meta tags (skip for GIF tweets — GIF branch provides og:image)
|
||||
@if !has_gif {
|
||||
@for image in &images {
|
||||
@let pic_url = formatters::get_pic_url(image, config.config.base64_media);
|
||||
@let full_pic_url = format!("{url_prefix}{pic_url}");
|
||||
meta property="og:image" content=(full_pic_url);
|
||||
}
|
||||
}
|
||||
|
||||
@if has_video {
|
||||
meta property="twitter:image" content="0";
|
||||
} @else {
|
||||
} @else if !has_gif {
|
||||
@for image in &images {
|
||||
@let pic_url = formatters::get_pic_url(image, config.config.base64_media);
|
||||
@let full_pic_url = format!("{url_prefix}{pic_url}");
|
||||
@@ -209,25 +209,29 @@ fn render_media_meta_tags(tweet: &Tweet, config: &Config, url_prefix: &str) -> M
|
||||
meta name="twitter:player:stream:content_type" content="video/mp4";
|
||||
}
|
||||
} @else if let Some(gif) = gif_with_quote(tweet) {
|
||||
@let vid_url = formatters::get_vid_url(&gif.url, &config.config.hmac_key, config.config.base64_media);
|
||||
@let full_gif_url = format!("{url_prefix}{vid_url}");
|
||||
@let thumb_url = formatters::get_pic_url(&gif.thumb, config.config.base64_media);
|
||||
@let full_thumb_url = format!("{url_prefix}{thumb_url}");
|
||||
@let embed_url = formatters::get_video_embed_url(config, tweet.id);
|
||||
|
||||
meta property="og:video" content=(full_gif_url);
|
||||
meta property="og:video:secure_url" content=(full_gif_url);
|
||||
meta property="og:video:type" content="video/mp4";
|
||||
meta property="og:video:width" content="480";
|
||||
meta property="og:video:height" content="480";
|
||||
meta property="og:image" content=(full_thumb_url);
|
||||
|
||||
meta name="twitter:card" content="player";
|
||||
meta name="twitter:player" content=(embed_url);
|
||||
meta name="twitter:player:width" content="480";
|
||||
meta name="twitter:player:height" content="480";
|
||||
meta name="twitter:player:stream" content=(full_gif_url);
|
||||
meta name="twitter:player:stream:content_type" content="video/mp4";
|
||||
// GIF tweets: point og:image directly at the transcoded GIF.
|
||||
// Discord's image proxy will fetch it, get image/gif content,
|
||||
// and render it animated.
|
||||
@match config.gif_transcoding.mode {
|
||||
GifTranscodingMode::Local => {
|
||||
@let gif_url = formatters::get_gif_url(&gif.url, &config.config.hmac_key, config.config.base64_media);
|
||||
@let full_gif_url = format!("{url_prefix}{gif_url}");
|
||||
meta property="og:image" content=(full_gif_url);
|
||||
meta property="twitter:image" content=(full_gif_url);
|
||||
},
|
||||
GifTranscodingMode::External => {
|
||||
@let ext_gif_url = formatters::get_external_gif_url(&gif.url, &config.gif_transcoding.external_domain);
|
||||
meta property="og:image" content=(ext_gif_url);
|
||||
meta property="twitter:image" content=(ext_gif_url);
|
||||
},
|
||||
GifTranscodingMode::Off => {
|
||||
@let thumb_url = formatters::get_pic_url(&gif.thumb, config.config.base64_media);
|
||||
@let full_thumb_url = format!("{url_prefix}{thumb_url}");
|
||||
meta property="og:image" content=(full_thumb_url);
|
||||
meta property="twitter:image" content=(full_thumb_url);
|
||||
},
|
||||
}
|
||||
meta name="twitter:card" content="summary_large_image";
|
||||
} @else if !images.is_empty() {
|
||||
meta name="twitter:card" content="summary_large_image";
|
||||
} @else {
|
||||
|
||||
+7
-2
@@ -175,12 +175,17 @@ impl<'a> PageLayout<'a> {
|
||||
meta property="og:locale" content="en_US";
|
||||
meta property="og:title" content=(self.title);
|
||||
meta property="og:description" content=(strip_html(self.description));
|
||||
meta property="og:type" content=(self.og_type);
|
||||
// head_extra provides its own og:type and twitter:card
|
||||
// when present (e.g. video/player for GIF embeds).
|
||||
// Discord takes the first occurrence, so suppress defaults.
|
||||
@if self.head_extra.is_none() {
|
||||
meta property="og:type" content=(self.og_type);
|
||||
}
|
||||
@if !self.og_image.is_empty() {
|
||||
meta property="og:image" content=(self.og_image);
|
||||
meta property="twitter:image:src" content=(self.og_image);
|
||||
}
|
||||
@if !self.rss.is_empty() {
|
||||
@if self.head_extra.is_none() && !self.rss.is_empty() {
|
||||
meta name="twitter:card" content="summary";
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user