Skip to content
Merged
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ AUTOSUBS_MAX_TRANSCRIPTION_JOBS=2
AUTOSUBS_MAX_QUEUED_JOBS=256

# Optional first-boot transcription bootstrap. After the DB exists, Settings in the UI are authoritative.
# Prefer OpenAI-compatible base URLs ending in /v1; AutoSubs derives /models and /audio/transcriptions.
AUTOSUBS_TRANSCRIPTION_LANGUAGE=fr
AUTOSUBS_TRANSCRIPTION_URL=
AUTOSUBS_TRANSCRIPTION_MODEL=
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ All notable released changes are documented here. AutoSubs follows semantic vers

## [Unreleased]

## [3.1.2] - 2026-09-17

### Fixed

- OpenAI-compatible `/v1` provider URLs now resolve `/models` for discovery and `/audio/transcriptions` for transcription, while full transcription URLs remain accepted.
- Multipart asset uploads larger than Axum's default body limit are streamed correctly and remain capped by `AUTOSUBS_MAX_UPLOAD_BYTES`.

### Security

- Updated `rustls` to 0.23.45 and `chacha20` to 0.10.2 so the locked dependency set passes the current RustSec audit.

## [3.1.1] - 2026-09-02

### Added
Expand Down
10 changes: 5 additions & 5 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "autosubs"
version = "3.1.1"
version = "3.1.2"
edition = "2024"
rust-version = "1.98"
authors = ["GodsQuantum"]
Expand Down
2 changes: 2 additions & 0 deletions README.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ Variables d'initialisation d'une DB vide : `AUTOSUBS_TRANSCRIPTION_LANGUAGE`, `A

Les clés fournisseur peuvent être initialisées par environnement ou configurées ensuite dans Settings/API ; les secrets enregistrés ne sont jamais renvoyés en clair au navigateur.

Pour les fournisseurs compatibles OpenAI, renseigne de préférence l'URL de base terminant par `/v1` (par exemple `http://speaches:8000/v1` ou `https://api.groq.com/openai/v1`). AutoSubs dérive automatiquement `/models` pour la découverte et `/audio/transcriptions` pour la transcription. Une URL complète finissant par `/audio/transcriptions` reste acceptée.

## API

L'API actuelle vit sous `/api/v1` : jobs, suppression sans suppression des médias, retranscription/rerendu, streaming Range, annulation, sidecars, édition/regroupement/shift/export, uploads tus, catalogue de polices (`/fonts`, `/fonts/css`, `/fonts/{id}/content`), presets, marques, workflows, settings, picker de fichiers et assets. Voir la liste détaillée dans le [README anglais](README.md#api).
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ services:
AUTOSUBS_MAX_RENDER_JOBS: "2"
AUTOSUBS_MAX_TRANSCRIPTION_JOBS: "2"
AUTOSUBS_LOCAL_TRANSCRIPTION_ENABLED: "true"
AUTOSUBS_LOCAL_TRANSCRIPTION_URL: http://transcriber:8000/v1/audio/transcriptions
AUTOSUBS_LOCAL_TRANSCRIPTION_URL: http://transcriber:8000/v1
volumes:
- ./config:/config
- ./data:/data
Expand All @@ -125,6 +125,8 @@ services:

Provider environment variables **bootstrap an empty database only**. After first start, Settings in the UI are authoritative. That avoids a container restart unexpectedly overwriting a key/URL you changed from the UI.

For OpenAI-compatible providers, use the provider base URL ending in `/v1` (for example `http://speaches:8000/v1` or `https://api.groq.com/openai/v1`). AutoSubs derives `/models` for discovery and `/audio/transcriptions` for transcription. A full `.../audio/transcriptions` URL is also accepted for backward compatibility.

For migration, the old `SPEACHES_URL` variable is still accepted as a first-boot alias for `AUTOSUBS_LOCAL_TRANSCRIPTION_URL`.

## 🎬 Manual production flow
Expand Down
4 changes: 2 additions & 2 deletions frontend/package-lock.json

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

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "autosubs-frontend",
"private": true,
"version": "3.1.1",
"version": "3.1.2",
"type": "module",
"scripts": {
"dev": "vite dev",
Expand Down
8 changes: 4 additions & 4 deletions frontend/tests/integration-docs.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises';

const root = new URL('../../', import.meta.url);

test('v3.1.1 integration docs expose the shipped version and lifecycle API', async () => {
test('v3.1.2 integration docs expose the shipped version and lifecycle API', async () => {
const [cargo, frontend, lock, changelog, readme] = await Promise.all([
readFile(new URL('Cargo.toml', root), 'utf8'),
readFile(new URL('frontend/package.json', root), 'utf8'),
Expand All @@ -13,9 +13,9 @@ test('v3.1.1 integration docs expose the shipped version and lifecycle API', asy
readFile(new URL('README.md', root), 'utf8')
]);

assert.match(cargo, /version = "3\.1\.1"/);
assert.match(frontend, /"version": "3\.1\.1"/);
assert.match(lock, /"version": "3\.1\.1"/g);
assert.match(cargo, /version = "3\.1\.2"/);
assert.match(frontend, /"version": "3\.1\.2"/);
assert.match(lock, /"version": "3\.1\.2"/g);
for (const endpoint of ['/api/v1/fonts', '/api/v1/fonts/css', '/api/v1/jobs/{id}/retranscribe']) {
assert.ok(readme.includes(endpoint), `missing README endpoint: ${endpoint}`);
}
Expand Down
88 changes: 87 additions & 1 deletion src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod uploads;
use crate::{error::AppResult, state::AppState};
use axum::{
Json, Router,
extract::DefaultBodyLimit,
routing::{delete, get, options, post, put},
};
use serde::Serialize;
Expand Down Expand Up @@ -113,7 +114,10 @@ pub fn router() -> Router<AppState> {
.route("/api/v1/browse", get(browse::browse))
.route(
"/api/v1/assets",
get(assets::list_assets).post(assets::upload_asset),
get(assets::list_assets)
.post(assets::upload_asset)
// Multipart is streamed and capped by AUTOSUBS_MAX_UPLOAD_BYTES in the handler.
.layer(DefaultBodyLimit::disable()),
)
.route("/api/v1/assets/import", post(assets::import_asset))
.route("/api/v1/assets/{id}", delete(assets::delete_asset))
Expand All @@ -135,3 +139,85 @@ pub fn router() -> Router<AppState> {
)
.route("/api/events", get(events::events))
}

#[cfg(test)]
mod multipart_regression_tests {
use super::*;
use crate::config::Config;
use std::path::PathBuf;

fn config(root: &std::path::Path) -> Config {
Config {
host: "127.0.0.1".into(),
port: 0,
config_dir: root.join("config"),
data_dir: root.join("data"),
fonts_dir: root.join("fonts"),
dist_dir: PathBuf::new(),
allowed_roots: vec![root.join("data")],
max_render_jobs: 1,
max_transcription_jobs: 1,
max_queued_jobs: 8,
workflow_scan_seconds: 5,
file_stability_ms: 0,
max_upload_bytes: 10 * 1024 * 1024,
}
}

#[tokio::test]
async fn asset_multipart_upload_can_exceed_axum_default_body_limit() {
let dir = tempfile::tempdir().unwrap();
let state = AppState::load(config(dir.path())).await.unwrap();
let app = router().with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });

let payload = vec![b'x'; 3 * 1024 * 1024];
let part = reqwest::multipart::Part::bytes(payload)
.file_name("outro.mp4")
.mime_str("video/mp4")
.unwrap();
let response = reqwest::Client::new()
.post(format!("http://{addr}/api/v1/assets"))
.multipart(reqwest::multipart::Form::new().part("file", part))
.send()
.await
.unwrap();
assert!(
response.status().is_success(),
"status={}",
response.status()
);
}

#[tokio::test]
async fn asset_multipart_upload_still_respects_configured_maximum() {
let dir = tempfile::tempdir().unwrap();
let mut cfg = config(dir.path());
cfg.max_upload_bytes = 1024 * 1024;
let state = AppState::load(cfg).await.unwrap();
let app = router().with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });

let payload = vec![b'x'; 2 * 1024 * 1024];
let response = reqwest::Client::new()
.post(format!("http://{addr}/api/v1/assets"))
.multipart(reqwest::multipart::Form::new().part(
"file",
reqwest::multipart::Part::bytes(payload).file_name("too-large.mp4"),
))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
assert_eq!(
std::fs::read_dir(dir.path().join("data/assets"))
.unwrap()
.count(),
0
);
}
}
55 changes: 53 additions & 2 deletions src/api/settings.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::{
domain::{Encoder, Settings},
error::{AppError, AppResult},
media::transcribe::models_endpoint,
state::AppState,
};
use axum::{Json, extract::State};
Expand Down Expand Up @@ -171,8 +172,7 @@ pub async fn list_models(
State(state): State<AppState>,
Json(body): Json<ModelRequest>,
) -> AppResult<Json<Models>> {
let endpoint = body.endpoint.trim_end_matches('/');
let url = format!("{endpoint}/models");
let url = models_endpoint(&body.endpoint);
let mut request = state.http.get(url);
if !body.api_key.is_empty() {
request = request.bearer_auth(body.api_key);
Expand All @@ -199,3 +199,54 @@ pub async fn list_models(
models.sort();
Ok(Json(Models { models }))
}

#[cfg(test)]
mod model_endpoint_regression_tests {
use super::*;
use crate::config::Config;
use axum::{Json as AxumJson, Router, routing::get};
use serde_json::json;
use std::path::PathBuf;

fn config(root: &std::path::Path) -> Config {
Config {
host: "127.0.0.1".into(),
port: 0,
config_dir: root.join("config"),
data_dir: root.join("data"),
fonts_dir: root.join("fonts"),
dist_dir: PathBuf::new(),
allowed_roots: vec![root.join("data")],
max_render_jobs: 1,
max_transcription_jobs: 1,
max_queued_jobs: 8,
workflow_scan_seconds: 5,
file_stability_ms: 0,
max_upload_bytes: 10 * 1024 * 1024,
}
}

#[tokio::test]
async fn full_transcription_endpoint_still_discovers_v1_models() {
let app = Router::new().route(
"/v1/models",
get(|| async { AxumJson(json!({"data": [{"id": "demo-model"}]})) }),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });

let dir = tempfile::tempdir().unwrap();
let state = AppState::load(config(dir.path())).await.unwrap();
let response = list_models(
State(state),
Json(ModelRequest {
endpoint: format!("http://{addr}/v1/audio/transcriptions"),
api_key: String::new(),
}),
)
.await
.unwrap();
assert_eq!(response.0.models, vec!["demo-model"]);
}
}
59 changes: 59 additions & 0 deletions src/media/transcribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,30 @@ pub async fn extract_audio(
run_capture(command, token).await.map(|_| ())
}

pub(crate) fn endpoint_base(url: &str) -> String {
let mut endpoint = url.trim().trim_end_matches('/').to_owned();
for suffix in ["/audio/transcriptions", "/models"] {
if endpoint.ends_with(suffix) {
endpoint.truncate(endpoint.len() - suffix.len());
break;
}
}
endpoint
}

pub(crate) fn transcription_endpoint(url: &str) -> String {
let endpoint = url.trim().trim_end_matches('/');
if endpoint.ends_with("/audio/transcriptions") {
endpoint.to_owned()
} else {
format!("{}/audio/transcriptions", endpoint_base(endpoint))
}
}

pub(crate) fn models_endpoint(url: &str) -> String {
format!("{}/models", endpoint_base(url))
}

async fn request_endpoint(
url: &str,
api_key: &str,
Expand All @@ -50,6 +74,7 @@ async fn request_endpoint(
if url.trim().is_empty() {
return Err(TranscriptionError::NotConfigured);
}
let url = transcription_endpoint(url);
let file = File::open(audio).await?;
let stream = ReaderStream::new(file).map_err(std::io::Error::other);
let body = reqwest::Body::wrap_stream(stream);
Expand Down Expand Up @@ -193,3 +218,37 @@ pub async fn transcribe_audio(
}
Err(TranscriptionError::NotConfigured)
}

#[cfg(test)]
mod endpoint_regression_tests {
use super::*;
use axum::{Json, Router, routing::post};
use serde_json::json;

#[tokio::test]
async fn base_v1_endpoint_transcribes_via_audio_transcriptions() {
let app = Router::new().route(
"/v1/audio/transcriptions",
post(|| async { Json(json!({"text": "ok"})) }),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });

let dir = tempfile::tempdir().unwrap();
let audio = dir.path().join("sample.wav");
tokio::fs::write(&audio, b"RIFFtest").await.unwrap();
let settings = Settings {
local_transcription_enabled: false,
transcription_url: format!("http://{addr}/v1"),
transcription_model: "demo".into(),
language: "fr".into(),
..Settings::default()
};

let result = transcribe_audio(&audio, &settings, &Client::new(), &CancellationToken::new())
.await
.unwrap();
assert_eq!(result.text.as_deref(), Some("ok"));
}
}