feat: deploy transcoder API service (#156)

deployed standalone Rust/Axum transcoding service to convert AIFF/FLAC/etc to MP3 for browser compatibility.

**deployment:**
- service running at https://plyr-transcoder.fly.dev/
- 2 Fly machines with auto-stop/start for cost efficiency
- handles 1GB uploads, tested with 85-minute AIFF files

**security:**
- X-Transcoder-Key header authentication (shared secret)
- health endpoint bypasses auth for monitoring

**testing:**
- successfully transcodes 858MB AIFF → 195MB MP3 in 32 seconds
- 320kbps MP3 output with proper ID3 tags

**infrastructure:**
- justfile commands for local development
- test script for validation
- Dockerfile for containerized deployment

closes #154

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 6d0c5a04 3f901a07

+3
.gitignore
··· 48 48 frontend/.svelte-kit/ 49 49 frontend/build/ 50 50 51 + # Rust build artifacts 52 + transcoder/target/ 53 + 51 54 # docker profiling logs 52 55 docker-build-profile-*.log 53 56 *-build-profile.log
+3
justfile
··· 1 1 # plyr.fm dev workflows 2 + mod transcoder 3 + 2 4 3 5 # show available commands 4 6 default: 5 7 @just --list 8 + 6 9 7 10 # run backend server 8 11 run-backend:
+32
scripts/test-transcoder.sh
··· 1 + #!/usr/bin/env bash 2 + # Simple helper to POST an audio file to the local transcoder API and save the result. 3 + set -euo pipefail 4 + 5 + if [[ $# -lt 1 ]]; then 6 + echo "usage: $(basename "$0") <input-file> [output-file]" >&2 7 + exit 1 8 + fi 9 + 10 + INPUT_FILE="$1" 11 + OUTPUT_FILE="${2:-$(basename "${INPUT_FILE%.*}")}.mp3" 12 + PORT="${TRANSCODER_PORT:-8082}" 13 + AUTH_TOKEN="${TRANSCODER_AUTH_TOKEN:-}" 14 + 15 + CURL_ARGS=( 16 + --fail 17 + --silent 18 + --show-error 19 + -X POST 20 + -F "file=@${INPUT_FILE}" 21 + ) 22 + 23 + # add auth header if token is set 24 + if [[ -n "${AUTH_TOKEN}" ]]; then 25 + CURL_ARGS+=(-H "X-Transcoder-Key: ${AUTH_TOKEN}") 26 + fi 27 + 28 + curl "${CURL_ARGS[@]}" \ 29 + "http://127.0.0.1:${PORT}/transcode?target=mp3" \ 30 + --output "${OUTPUT_FILE}" 31 + 32 + echo "wrote ${OUTPUT_FILE}" >&2
+921
transcoder/Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "aho-corasick" 7 + version = "1.1.4" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 10 + dependencies = [ 11 + "memchr", 12 + ] 13 + 14 + [[package]] 15 + name = "anyhow" 16 + version = "1.0.100" 17 + source = "registry+https://github.com/rust-lang/crates.io-index" 18 + checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 19 + 20 + [[package]] 21 + name = "async-trait" 22 + version = "0.1.89" 23 + source = "registry+https://github.com/rust-lang/crates.io-index" 24 + checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" 25 + dependencies = [ 26 + "proc-macro2", 27 + "quote", 28 + "syn", 29 + ] 30 + 31 + [[package]] 32 + name = "atomic-waker" 33 + version = "1.1.2" 34 + source = "registry+https://github.com/rust-lang/crates.io-index" 35 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 36 + 37 + [[package]] 38 + name = "axum" 39 + version = "0.7.9" 40 + source = "registry+https://github.com/rust-lang/crates.io-index" 41 + checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" 42 + dependencies = [ 43 + "async-trait", 44 + "axum-core", 45 + "axum-macros", 46 + "bytes", 47 + "futures-util", 48 + "http", 49 + "http-body", 50 + "http-body-util", 51 + "hyper", 52 + "hyper-util", 53 + "itoa", 54 + "matchit", 55 + "memchr", 56 + "mime", 57 + "multer", 58 + "percent-encoding", 59 + "pin-project-lite", 60 + "rustversion", 61 + "serde", 62 + "serde_json", 63 + "serde_path_to_error", 64 + "serde_urlencoded", 65 + "sync_wrapper", 66 + "tokio", 67 + "tower", 68 + "tower-layer", 69 + "tower-service", 70 + "tracing", 71 + ] 72 + 73 + [[package]] 74 + name = "axum-core" 75 + version = "0.4.5" 76 + source = "registry+https://github.com/rust-lang/crates.io-index" 77 + checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" 78 + dependencies = [ 79 + "async-trait", 80 + "bytes", 81 + "futures-util", 82 + "http", 83 + "http-body", 84 + "http-body-util", 85 + "mime", 86 + "pin-project-lite", 87 + "rustversion", 88 + "sync_wrapper", 89 + "tower-layer", 90 + "tower-service", 91 + "tracing", 92 + ] 93 + 94 + [[package]] 95 + name = "axum-macros" 96 + version = "0.4.2" 97 + source = "registry+https://github.com/rust-lang/crates.io-index" 98 + checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" 99 + dependencies = [ 100 + "proc-macro2", 101 + "quote", 102 + "syn", 103 + ] 104 + 105 + [[package]] 106 + name = "bitflags" 107 + version = "2.10.0" 108 + source = "registry+https://github.com/rust-lang/crates.io-index" 109 + checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 110 + 111 + [[package]] 112 + name = "bytes" 113 + version = "1.10.1" 114 + source = "registry+https://github.com/rust-lang/crates.io-index" 115 + checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 116 + 117 + [[package]] 118 + name = "cfg-if" 119 + version = "1.0.4" 120 + source = "registry+https://github.com/rust-lang/crates.io-index" 121 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 122 + 123 + [[package]] 124 + name = "encoding_rs" 125 + version = "0.8.35" 126 + source = "registry+https://github.com/rust-lang/crates.io-index" 127 + checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" 128 + dependencies = [ 129 + "cfg-if", 130 + ] 131 + 132 + [[package]] 133 + name = "errno" 134 + version = "0.3.14" 135 + source = "registry+https://github.com/rust-lang/crates.io-index" 136 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 137 + dependencies = [ 138 + "libc", 139 + "windows-sys 0.61.2", 140 + ] 141 + 142 + [[package]] 143 + name = "fastrand" 144 + version = "2.3.0" 145 + source = "registry+https://github.com/rust-lang/crates.io-index" 146 + checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 147 + 148 + [[package]] 149 + name = "fnv" 150 + version = "1.0.7" 151 + source = "registry+https://github.com/rust-lang/crates.io-index" 152 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 153 + 154 + [[package]] 155 + name = "form_urlencoded" 156 + version = "1.2.2" 157 + source = "registry+https://github.com/rust-lang/crates.io-index" 158 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" 159 + dependencies = [ 160 + "percent-encoding", 161 + ] 162 + 163 + [[package]] 164 + name = "futures-channel" 165 + version = "0.3.31" 166 + source = "registry+https://github.com/rust-lang/crates.io-index" 167 + checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 168 + dependencies = [ 169 + "futures-core", 170 + ] 171 + 172 + [[package]] 173 + name = "futures-core" 174 + version = "0.3.31" 175 + source = "registry+https://github.com/rust-lang/crates.io-index" 176 + checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 177 + 178 + [[package]] 179 + name = "futures-task" 180 + version = "0.3.31" 181 + source = "registry+https://github.com/rust-lang/crates.io-index" 182 + checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 183 + 184 + [[package]] 185 + name = "futures-util" 186 + version = "0.3.31" 187 + source = "registry+https://github.com/rust-lang/crates.io-index" 188 + checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 189 + dependencies = [ 190 + "futures-core", 191 + "futures-task", 192 + "pin-project-lite", 193 + "pin-utils", 194 + ] 195 + 196 + [[package]] 197 + name = "getrandom" 198 + version = "0.3.4" 199 + source = "registry+https://github.com/rust-lang/crates.io-index" 200 + checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 201 + dependencies = [ 202 + "cfg-if", 203 + "libc", 204 + "r-efi", 205 + "wasip2", 206 + ] 207 + 208 + [[package]] 209 + name = "http" 210 + version = "1.3.1" 211 + source = "registry+https://github.com/rust-lang/crates.io-index" 212 + checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" 213 + dependencies = [ 214 + "bytes", 215 + "fnv", 216 + "itoa", 217 + ] 218 + 219 + [[package]] 220 + name = "http-body" 221 + version = "1.0.1" 222 + source = "registry+https://github.com/rust-lang/crates.io-index" 223 + checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 224 + dependencies = [ 225 + "bytes", 226 + "http", 227 + ] 228 + 229 + [[package]] 230 + name = "http-body-util" 231 + version = "0.1.3" 232 + source = "registry+https://github.com/rust-lang/crates.io-index" 233 + checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" 234 + dependencies = [ 235 + "bytes", 236 + "futures-core", 237 + "http", 238 + "http-body", 239 + "pin-project-lite", 240 + ] 241 + 242 + [[package]] 243 + name = "httparse" 244 + version = "1.10.1" 245 + source = "registry+https://github.com/rust-lang/crates.io-index" 246 + checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 247 + 248 + [[package]] 249 + name = "httpdate" 250 + version = "1.0.3" 251 + source = "registry+https://github.com/rust-lang/crates.io-index" 252 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 253 + 254 + [[package]] 255 + name = "hyper" 256 + version = "1.7.0" 257 + source = "registry+https://github.com/rust-lang/crates.io-index" 258 + checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" 259 + dependencies = [ 260 + "atomic-waker", 261 + "bytes", 262 + "futures-channel", 263 + "futures-core", 264 + "http", 265 + "http-body", 266 + "httparse", 267 + "httpdate", 268 + "itoa", 269 + "pin-project-lite", 270 + "pin-utils", 271 + "smallvec", 272 + "tokio", 273 + ] 274 + 275 + [[package]] 276 + name = "hyper-util" 277 + version = "0.1.17" 278 + source = "registry+https://github.com/rust-lang/crates.io-index" 279 + checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" 280 + dependencies = [ 281 + "bytes", 282 + "futures-core", 283 + "http", 284 + "http-body", 285 + "hyper", 286 + "pin-project-lite", 287 + "tokio", 288 + "tower-service", 289 + ] 290 + 291 + [[package]] 292 + name = "itoa" 293 + version = "1.0.15" 294 + source = "registry+https://github.com/rust-lang/crates.io-index" 295 + checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 296 + 297 + [[package]] 298 + name = "lazy_static" 299 + version = "1.5.0" 300 + source = "registry+https://github.com/rust-lang/crates.io-index" 301 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 302 + 303 + [[package]] 304 + name = "libc" 305 + version = "0.2.177" 306 + source = "registry+https://github.com/rust-lang/crates.io-index" 307 + checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" 308 + 309 + [[package]] 310 + name = "linux-raw-sys" 311 + version = "0.11.0" 312 + source = "registry+https://github.com/rust-lang/crates.io-index" 313 + checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" 314 + 315 + [[package]] 316 + name = "log" 317 + version = "0.4.28" 318 + source = "registry+https://github.com/rust-lang/crates.io-index" 319 + checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 320 + 321 + [[package]] 322 + name = "matchers" 323 + version = "0.2.0" 324 + source = "registry+https://github.com/rust-lang/crates.io-index" 325 + checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" 326 + dependencies = [ 327 + "regex-automata", 328 + ] 329 + 330 + [[package]] 331 + name = "matchit" 332 + version = "0.7.3" 333 + source = "registry+https://github.com/rust-lang/crates.io-index" 334 + checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" 335 + 336 + [[package]] 337 + name = "memchr" 338 + version = "2.7.6" 339 + source = "registry+https://github.com/rust-lang/crates.io-index" 340 + checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 341 + 342 + [[package]] 343 + name = "mime" 344 + version = "0.3.17" 345 + source = "registry+https://github.com/rust-lang/crates.io-index" 346 + checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 347 + 348 + [[package]] 349 + name = "mio" 350 + version = "1.1.0" 351 + source = "registry+https://github.com/rust-lang/crates.io-index" 352 + checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" 353 + dependencies = [ 354 + "libc", 355 + "wasi", 356 + "windows-sys 0.61.2", 357 + ] 358 + 359 + [[package]] 360 + name = "multer" 361 + version = "3.1.0" 362 + source = "registry+https://github.com/rust-lang/crates.io-index" 363 + checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" 364 + dependencies = [ 365 + "bytes", 366 + "encoding_rs", 367 + "futures-util", 368 + "http", 369 + "httparse", 370 + "memchr", 371 + "mime", 372 + "spin", 373 + "version_check", 374 + ] 375 + 376 + [[package]] 377 + name = "nu-ansi-term" 378 + version = "0.50.3" 379 + source = "registry+https://github.com/rust-lang/crates.io-index" 380 + checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 381 + dependencies = [ 382 + "windows-sys 0.61.2", 383 + ] 384 + 385 + [[package]] 386 + name = "once_cell" 387 + version = "1.21.3" 388 + source = "registry+https://github.com/rust-lang/crates.io-index" 389 + checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 390 + 391 + [[package]] 392 + name = "percent-encoding" 393 + version = "2.3.2" 394 + source = "registry+https://github.com/rust-lang/crates.io-index" 395 + checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 396 + 397 + [[package]] 398 + name = "pin-project-lite" 399 + version = "0.2.16" 400 + source = "registry+https://github.com/rust-lang/crates.io-index" 401 + checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 402 + 403 + [[package]] 404 + name = "pin-utils" 405 + version = "0.1.0" 406 + source = "registry+https://github.com/rust-lang/crates.io-index" 407 + checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 408 + 409 + [[package]] 410 + name = "proc-macro2" 411 + version = "1.0.103" 412 + source = "registry+https://github.com/rust-lang/crates.io-index" 413 + checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" 414 + dependencies = [ 415 + "unicode-ident", 416 + ] 417 + 418 + [[package]] 419 + name = "quote" 420 + version = "1.0.42" 421 + source = "registry+https://github.com/rust-lang/crates.io-index" 422 + checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" 423 + dependencies = [ 424 + "proc-macro2", 425 + ] 426 + 427 + [[package]] 428 + name = "r-efi" 429 + version = "5.3.0" 430 + source = "registry+https://github.com/rust-lang/crates.io-index" 431 + checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 432 + 433 + [[package]] 434 + name = "regex" 435 + version = "1.12.2" 436 + source = "registry+https://github.com/rust-lang/crates.io-index" 437 + checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" 438 + dependencies = [ 439 + "aho-corasick", 440 + "memchr", 441 + "regex-automata", 442 + "regex-syntax", 443 + ] 444 + 445 + [[package]] 446 + name = "regex-automata" 447 + version = "0.4.13" 448 + source = "registry+https://github.com/rust-lang/crates.io-index" 449 + checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" 450 + dependencies = [ 451 + "aho-corasick", 452 + "memchr", 453 + "regex-syntax", 454 + ] 455 + 456 + [[package]] 457 + name = "regex-syntax" 458 + version = "0.8.8" 459 + source = "registry+https://github.com/rust-lang/crates.io-index" 460 + checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" 461 + 462 + [[package]] 463 + name = "rustix" 464 + version = "1.1.2" 465 + source = "registry+https://github.com/rust-lang/crates.io-index" 466 + checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" 467 + dependencies = [ 468 + "bitflags", 469 + "errno", 470 + "libc", 471 + "linux-raw-sys", 472 + "windows-sys 0.61.2", 473 + ] 474 + 475 + [[package]] 476 + name = "rustversion" 477 + version = "1.0.22" 478 + source = "registry+https://github.com/rust-lang/crates.io-index" 479 + checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 480 + 481 + [[package]] 482 + name = "ryu" 483 + version = "1.0.20" 484 + source = "registry+https://github.com/rust-lang/crates.io-index" 485 + checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 486 + 487 + [[package]] 488 + name = "sanitize-filename" 489 + version = "0.5.0" 490 + source = "registry+https://github.com/rust-lang/crates.io-index" 491 + checksum = "2ed72fbaf78e6f2d41744923916966c4fbe3d7c74e3037a8ee482f1115572603" 492 + dependencies = [ 493 + "lazy_static", 494 + "regex", 495 + ] 496 + 497 + [[package]] 498 + name = "serde" 499 + version = "1.0.228" 500 + source = "registry+https://github.com/rust-lang/crates.io-index" 501 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 502 + dependencies = [ 503 + "serde_core", 504 + "serde_derive", 505 + ] 506 + 507 + [[package]] 508 + name = "serde_core" 509 + version = "1.0.228" 510 + source = "registry+https://github.com/rust-lang/crates.io-index" 511 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 512 + dependencies = [ 513 + "serde_derive", 514 + ] 515 + 516 + [[package]] 517 + name = "serde_derive" 518 + version = "1.0.228" 519 + source = "registry+https://github.com/rust-lang/crates.io-index" 520 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 521 + dependencies = [ 522 + "proc-macro2", 523 + "quote", 524 + "syn", 525 + ] 526 + 527 + [[package]] 528 + name = "serde_json" 529 + version = "1.0.145" 530 + source = "registry+https://github.com/rust-lang/crates.io-index" 531 + checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" 532 + dependencies = [ 533 + "itoa", 534 + "memchr", 535 + "ryu", 536 + "serde", 537 + "serde_core", 538 + ] 539 + 540 + [[package]] 541 + name = "serde_path_to_error" 542 + version = "0.1.20" 543 + source = "registry+https://github.com/rust-lang/crates.io-index" 544 + checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" 545 + dependencies = [ 546 + "itoa", 547 + "serde", 548 + "serde_core", 549 + ] 550 + 551 + [[package]] 552 + name = "serde_urlencoded" 553 + version = "0.7.1" 554 + source = "registry+https://github.com/rust-lang/crates.io-index" 555 + checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 556 + dependencies = [ 557 + "form_urlencoded", 558 + "itoa", 559 + "ryu", 560 + "serde", 561 + ] 562 + 563 + [[package]] 564 + name = "sharded-slab" 565 + version = "0.1.7" 566 + source = "registry+https://github.com/rust-lang/crates.io-index" 567 + checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 568 + dependencies = [ 569 + "lazy_static", 570 + ] 571 + 572 + [[package]] 573 + name = "signal-hook-registry" 574 + version = "1.4.6" 575 + source = "registry+https://github.com/rust-lang/crates.io-index" 576 + checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" 577 + dependencies = [ 578 + "libc", 579 + ] 580 + 581 + [[package]] 582 + name = "smallvec" 583 + version = "1.15.1" 584 + source = "registry+https://github.com/rust-lang/crates.io-index" 585 + checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 586 + 587 + [[package]] 588 + name = "socket2" 589 + version = "0.6.1" 590 + source = "registry+https://github.com/rust-lang/crates.io-index" 591 + checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" 592 + dependencies = [ 593 + "libc", 594 + "windows-sys 0.60.2", 595 + ] 596 + 597 + [[package]] 598 + name = "spin" 599 + version = "0.9.8" 600 + source = "registry+https://github.com/rust-lang/crates.io-index" 601 + checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 602 + 603 + [[package]] 604 + name = "syn" 605 + version = "2.0.110" 606 + source = "registry+https://github.com/rust-lang/crates.io-index" 607 + checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" 608 + dependencies = [ 609 + "proc-macro2", 610 + "quote", 611 + "unicode-ident", 612 + ] 613 + 614 + [[package]] 615 + name = "sync_wrapper" 616 + version = "1.0.2" 617 + source = "registry+https://github.com/rust-lang/crates.io-index" 618 + checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" 619 + 620 + [[package]] 621 + name = "tempfile" 622 + version = "3.23.0" 623 + source = "registry+https://github.com/rust-lang/crates.io-index" 624 + checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" 625 + dependencies = [ 626 + "fastrand", 627 + "getrandom", 628 + "once_cell", 629 + "rustix", 630 + "windows-sys 0.61.2", 631 + ] 632 + 633 + [[package]] 634 + name = "thiserror" 635 + version = "1.0.69" 636 + source = "registry+https://github.com/rust-lang/crates.io-index" 637 + checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 638 + dependencies = [ 639 + "thiserror-impl", 640 + ] 641 + 642 + [[package]] 643 + name = "thiserror-impl" 644 + version = "1.0.69" 645 + source = "registry+https://github.com/rust-lang/crates.io-index" 646 + checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 647 + dependencies = [ 648 + "proc-macro2", 649 + "quote", 650 + "syn", 651 + ] 652 + 653 + [[package]] 654 + name = "thread_local" 655 + version = "1.1.9" 656 + source = "registry+https://github.com/rust-lang/crates.io-index" 657 + checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 658 + dependencies = [ 659 + "cfg-if", 660 + ] 661 + 662 + [[package]] 663 + name = "tokio" 664 + version = "1.48.0" 665 + source = "registry+https://github.com/rust-lang/crates.io-index" 666 + checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" 667 + dependencies = [ 668 + "bytes", 669 + "libc", 670 + "mio", 671 + "pin-project-lite", 672 + "signal-hook-registry", 673 + "socket2", 674 + "tokio-macros", 675 + "windows-sys 0.61.2", 676 + ] 677 + 678 + [[package]] 679 + name = "tokio-macros" 680 + version = "2.6.0" 681 + source = "registry+https://github.com/rust-lang/crates.io-index" 682 + checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" 683 + dependencies = [ 684 + "proc-macro2", 685 + "quote", 686 + "syn", 687 + ] 688 + 689 + [[package]] 690 + name = "tower" 691 + version = "0.5.2" 692 + source = "registry+https://github.com/rust-lang/crates.io-index" 693 + checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" 694 + dependencies = [ 695 + "futures-core", 696 + "futures-util", 697 + "pin-project-lite", 698 + "sync_wrapper", 699 + "tokio", 700 + "tower-layer", 701 + "tower-service", 702 + "tracing", 703 + ] 704 + 705 + [[package]] 706 + name = "tower-layer" 707 + version = "0.3.3" 708 + source = "registry+https://github.com/rust-lang/crates.io-index" 709 + checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 710 + 711 + [[package]] 712 + name = "tower-service" 713 + version = "0.3.3" 714 + source = "registry+https://github.com/rust-lang/crates.io-index" 715 + checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 716 + 717 + [[package]] 718 + name = "tracing" 719 + version = "0.1.41" 720 + source = "registry+https://github.com/rust-lang/crates.io-index" 721 + checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 722 + dependencies = [ 723 + "log", 724 + "pin-project-lite", 725 + "tracing-attributes", 726 + "tracing-core", 727 + ] 728 + 729 + [[package]] 730 + name = "tracing-attributes" 731 + version = "0.1.30" 732 + source = "registry+https://github.com/rust-lang/crates.io-index" 733 + checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" 734 + dependencies = [ 735 + "proc-macro2", 736 + "quote", 737 + "syn", 738 + ] 739 + 740 + [[package]] 741 + name = "tracing-core" 742 + version = "0.1.34" 743 + source = "registry+https://github.com/rust-lang/crates.io-index" 744 + checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" 745 + dependencies = [ 746 + "once_cell", 747 + "valuable", 748 + ] 749 + 750 + [[package]] 751 + name = "tracing-log" 752 + version = "0.2.0" 753 + source = "registry+https://github.com/rust-lang/crates.io-index" 754 + checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 755 + dependencies = [ 756 + "log", 757 + "once_cell", 758 + "tracing-core", 759 + ] 760 + 761 + [[package]] 762 + name = "tracing-subscriber" 763 + version = "0.3.20" 764 + source = "registry+https://github.com/rust-lang/crates.io-index" 765 + checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" 766 + dependencies = [ 767 + "matchers", 768 + "nu-ansi-term", 769 + "once_cell", 770 + "regex-automata", 771 + "sharded-slab", 772 + "smallvec", 773 + "thread_local", 774 + "tracing", 775 + "tracing-core", 776 + "tracing-log", 777 + ] 778 + 779 + [[package]] 780 + name = "transcoder" 781 + version = "0.1.0" 782 + dependencies = [ 783 + "anyhow", 784 + "axum", 785 + "sanitize-filename", 786 + "serde", 787 + "serde_json", 788 + "tempfile", 789 + "thiserror", 790 + "tokio", 791 + "tracing", 792 + "tracing-subscriber", 793 + ] 794 + 795 + [[package]] 796 + name = "unicode-ident" 797 + version = "1.0.22" 798 + source = "registry+https://github.com/rust-lang/crates.io-index" 799 + checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" 800 + 801 + [[package]] 802 + name = "valuable" 803 + version = "0.1.1" 804 + source = "registry+https://github.com/rust-lang/crates.io-index" 805 + checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 806 + 807 + [[package]] 808 + name = "version_check" 809 + version = "0.9.5" 810 + source = "registry+https://github.com/rust-lang/crates.io-index" 811 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 812 + 813 + [[package]] 814 + name = "wasi" 815 + version = "0.11.1+wasi-snapshot-preview1" 816 + source = "registry+https://github.com/rust-lang/crates.io-index" 817 + checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 818 + 819 + [[package]] 820 + name = "wasip2" 821 + version = "1.0.1+wasi-0.2.4" 822 + source = "registry+https://github.com/rust-lang/crates.io-index" 823 + checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 824 + dependencies = [ 825 + "wit-bindgen", 826 + ] 827 + 828 + [[package]] 829 + name = "windows-link" 830 + version = "0.2.1" 831 + source = "registry+https://github.com/rust-lang/crates.io-index" 832 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 833 + 834 + [[package]] 835 + name = "windows-sys" 836 + version = "0.60.2" 837 + source = "registry+https://github.com/rust-lang/crates.io-index" 838 + checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 839 + dependencies = [ 840 + "windows-targets", 841 + ] 842 + 843 + [[package]] 844 + name = "windows-sys" 845 + version = "0.61.2" 846 + source = "registry+https://github.com/rust-lang/crates.io-index" 847 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 848 + dependencies = [ 849 + "windows-link", 850 + ] 851 + 852 + [[package]] 853 + name = "windows-targets" 854 + version = "0.53.5" 855 + source = "registry+https://github.com/rust-lang/crates.io-index" 856 + checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" 857 + dependencies = [ 858 + "windows-link", 859 + "windows_aarch64_gnullvm", 860 + "windows_aarch64_msvc", 861 + "windows_i686_gnu", 862 + "windows_i686_gnullvm", 863 + "windows_i686_msvc", 864 + "windows_x86_64_gnu", 865 + "windows_x86_64_gnullvm", 866 + "windows_x86_64_msvc", 867 + ] 868 + 869 + [[package]] 870 + name = "windows_aarch64_gnullvm" 871 + version = "0.53.1" 872 + source = "registry+https://github.com/rust-lang/crates.io-index" 873 + checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" 874 + 875 + [[package]] 876 + name = "windows_aarch64_msvc" 877 + version = "0.53.1" 878 + source = "registry+https://github.com/rust-lang/crates.io-index" 879 + checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" 880 + 881 + [[package]] 882 + name = "windows_i686_gnu" 883 + version = "0.53.1" 884 + source = "registry+https://github.com/rust-lang/crates.io-index" 885 + checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" 886 + 887 + [[package]] 888 + name = "windows_i686_gnullvm" 889 + version = "0.53.1" 890 + source = "registry+https://github.com/rust-lang/crates.io-index" 891 + checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" 892 + 893 + [[package]] 894 + name = "windows_i686_msvc" 895 + version = "0.53.1" 896 + source = "registry+https://github.com/rust-lang/crates.io-index" 897 + checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 898 + 899 + [[package]] 900 + name = "windows_x86_64_gnu" 901 + version = "0.53.1" 902 + source = "registry+https://github.com/rust-lang/crates.io-index" 903 + checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 904 + 905 + [[package]] 906 + name = "windows_x86_64_gnullvm" 907 + version = "0.53.1" 908 + source = "registry+https://github.com/rust-lang/crates.io-index" 909 + checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 910 + 911 + [[package]] 912 + name = "windows_x86_64_msvc" 913 + version = "0.53.1" 914 + source = "registry+https://github.com/rust-lang/crates.io-index" 915 + checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 916 + 917 + [[package]] 918 + name = "wit-bindgen" 919 + version = "0.46.0" 920 + source = "registry+https://github.com/rust-lang/crates.io-index" 921 + checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
+16
transcoder/Cargo.toml
··· 1 + [package] 2 + name = "transcoder" 3 + version = "0.1.0" 4 + edition = "2021" 5 + 6 + [dependencies] 7 + anyhow = "1.0" 8 + axum = { version = "0.7", features = ["macros", "json", "multipart"] } 9 + serde = { version = "1.0", features = ["derive"] } 10 + serde_json = "1.0" 11 + thiserror = "1.0" 12 + tokio = { version = "1.40", features = ["rt-multi-thread", "macros", "signal", "process", "fs", "io-util"] } 13 + tracing = "0.1" 14 + tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } 15 + tempfile = "3.10" 16 + sanitize-filename = "0.5"
+52
transcoder/Dockerfile
··· 1 + # syntax=docker/dockerfile:1.7 2 + 3 + FROM debian:bookworm-slim AS ffmpeg 4 + RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ 5 + --mount=type=cache,target=/var/lib/apt,sharing=locked \ 6 + apt-get update && apt-get install -y --no-install-recommends \ 7 + ca-certificates \ 8 + curl \ 9 + xz-utils \ 10 + && rm -rf /var/lib/apt/lists/* 11 + RUN mkdir -p /tmp/ffmpeg \ 12 + && curl -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \ 13 + | tar -xJ --strip-components=1 -C /tmp/ffmpeg 14 + 15 + FROM rust:1.81-slim AS builder 16 + RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ 17 + --mount=type=cache,target=/var/lib/apt,sharing=locked \ 18 + apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev \ 19 + && rm -rf /var/lib/apt/lists/* 20 + WORKDIR /app 21 + 22 + # leverage incremental builds by compiling deps first 23 + COPY Cargo.toml Cargo.lock ./ 24 + RUN mkdir src && echo "fn main() {}" > src/main.rs 25 + RUN --mount=type=cache,target=/usr/local/cargo/registry \ 26 + --mount=type=cache,target=/app/target \ 27 + cargo build --release 28 + RUN rm -rf src 29 + 30 + # now copy actual sources (binary output stored in image layer) 31 + COPY src ./src 32 + RUN --mount=type=cache,target=/usr/local/cargo/registry \ 33 + cargo build --release 34 + 35 + FROM debian:bookworm-slim 36 + RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ 37 + --mount=type=cache,target=/var/lib/apt,sharing=locked \ 38 + apt-get update && apt-get install -y --no-install-recommends \ 39 + ca-certificates \ 40 + libssl3 \ 41 + && rm -rf /var/lib/apt/lists/* 42 + 43 + COPY --from=ffmpeg /tmp/ffmpeg/ffmpeg /usr/local/bin/ffmpeg 44 + COPY --from=ffmpeg /tmp/ffmpeg/ffprobe /usr/local/bin/ffprobe 45 + COPY --from=builder /app/target/release/transcoder /usr/local/bin/transcoder 46 + 47 + ENV TRANSCODER_HOST=0.0.0.0 \ 48 + TRANSCODER_PORT=8080 \ 49 + TRANSCODER_MAX_UPLOAD_BYTES=536870912 50 + 51 + EXPOSE 8080 52 + CMD ["/usr/local/bin/transcoder"]
+32
transcoder/Justfile
··· 1 + set shell := ["bash", "-eu", "-o", "pipefail", "-c"] 2 + default := "run" 3 + 4 + alias r := run 5 + alias b := build 6 + 7 + run: 8 + cd {{justfile_directory()}}/transcoder && \ 9 + TRANSCODER_HOST="${TRANSCODER_HOST:-127.0.0.1}" \ 10 + TRANSCODER_PORT="${TRANSCODER_PORT:-8082}" \ 11 + cargo run 12 + 13 + build: 14 + cd {{justfile_directory()}}/transcoder && cargo build --release 15 + 16 + check: 17 + cd {{justfile_directory()}}/transcoder && cargo check 18 + 19 + fmt: 20 + cd {{justfile_directory()}}/transcoder && cargo fmt 21 + 22 + clippy: 23 + cd {{justfile_directory()}}/transcoder && cargo clippy --all-targets --all-features 24 + 25 + image tag="plyr-transcoder:local": 26 + cd {{justfile_directory()}}/transcoder && docker build -t {{tag}} . 27 + 28 + docker-run TAG="plyr-transcoder:local" PORT="8082": 29 + docker run --rm -p {{PORT}}:8080 {{TAG}} 30 + 31 + fly ARGS="": 32 + cd {{justfile_directory()}}/transcoder && fly deploy --config fly.toml {{ARGS}}
+27
transcoder/fly.toml
··· 1 + app = "plyr-transcoder" 2 + primary_region = "iad" 3 + 4 + [build] 5 + dockerfile = "Dockerfile" 6 + 7 + [http_service] 8 + internal_port = 8080 9 + force_https = true 10 + auto_stop_machines = "stop" 11 + auto_start_machines = true 12 + min_machines_running = 0 13 + 14 + [http_service.concurrency] 15 + type = "requests" 16 + hard_limit = 50 17 + soft_limit = 25 18 + 19 + [[vm]] 20 + cpu_kind = "shared" 21 + cpus = 1 22 + memory = "1gb" 23 + 24 + [env] 25 + TRANSCODER_HOST = "0.0.0.0" 26 + TRANSCODER_PORT = "8080" 27 + TRANSCODER_MAX_UPLOAD_BYTES = "1073741824" # 1GB for large files
+280
transcoder/src/main.rs
··· 1 + use std::{ 2 + env, 3 + net::SocketAddr, 4 + path::{Path, PathBuf}, 5 + }; 6 + 7 + use anyhow::anyhow; 8 + use axum::{ 9 + body::Body, 10 + extract::{DefaultBodyLimit, Multipart, Query, Request}, 11 + http::{header, HeaderValue, StatusCode}, 12 + middleware::{self, Next}, 13 + response::{IntoResponse, Response}, 14 + routing::{get, post}, 15 + Json, Router, 16 + }; 17 + use sanitize_filename::sanitize; 18 + use serde::Deserialize; 19 + use tempfile::TempDir; 20 + use tokio::{fs::File, io::AsyncWriteExt, net::TcpListener, process::Command}; 21 + use tracing::{error, info, warn}; 22 + 23 + #[derive(Debug, Deserialize, Default)] 24 + struct TranscodeParams { 25 + target: Option<String>, 26 + } 27 + 28 + #[derive(Debug, serde::Serialize)] 29 + struct HealthResponse { 30 + status: &'static str, 31 + } 32 + 33 + #[tokio::main] 34 + async fn main() -> anyhow::Result<()> { 35 + tracing_subscriber::fmt() 36 + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) 37 + .with_target(false) 38 + .init(); 39 + 40 + let max_upload_bytes: usize = env::var("TRANSCODER_MAX_UPLOAD_BYTES") 41 + .ok() 42 + .and_then(|v| v.parse().ok()) 43 + .unwrap_or(512 * 1024 * 1024); // 512MB default 44 + 45 + let auth_token = env::var("TRANSCODER_AUTH_TOKEN").ok(); 46 + 47 + let app = Router::new() 48 + .route("/health", get(health)) 49 + .route("/transcode", post(transcode)) 50 + .layer(middleware::from_fn(move |req, next| { 51 + auth_middleware(req, next, auth_token.clone()) 52 + })) 53 + .layer(DefaultBodyLimit::max(max_upload_bytes)); 54 + 55 + let port: u16 = env::var("TRANSCODER_PORT") 56 + .ok() 57 + .and_then(|v| v.parse().ok()) 58 + .unwrap_or(8082); 59 + let host = env::var("TRANSCODER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 60 + let addr: SocketAddr = format!("{}:{}", host, port) 61 + .parse() 62 + .map_err(|e| anyhow!("invalid bind addr: {e}"))?; 63 + info!(%addr, max_upload_bytes, "transcoder listening"); 64 + 65 + let listener = TcpListener::bind(addr).await?; 66 + axum::serve(listener, app).await?; 67 + Ok(()) 68 + } 69 + 70 + async fn auth_middleware( 71 + req: Request, 72 + next: Next, 73 + auth_token: Option<String>, 74 + ) -> Result<Response, StatusCode> { 75 + // skip auth for health endpoint 76 + if req.uri().path() == "/health" { 77 + return Ok(next.run(req).await); 78 + } 79 + 80 + // if no auth token configured, allow all requests (local dev mode) 81 + let Some(expected_token) = auth_token else { 82 + warn!("no TRANSCODER_AUTH_TOKEN set - accepting all requests"); 83 + return Ok(next.run(req).await); 84 + }; 85 + 86 + // check for X-Transcoder-Key header 87 + let token = req 88 + .headers() 89 + .get("X-Transcoder-Key") 90 + .and_then(|v| v.to_str().ok()); 91 + 92 + match token { 93 + Some(t) if t == expected_token => Ok(next.run(req).await), 94 + Some(_) => { 95 + warn!("invalid auth token provided"); 96 + Err(StatusCode::UNAUTHORIZED) 97 + } 98 + None => { 99 + warn!("missing X-Transcoder-Key header"); 100 + Err(StatusCode::UNAUTHORIZED) 101 + } 102 + } 103 + } 104 + 105 + async fn health() -> Json<HealthResponse> { 106 + Json(HealthResponse { status: "ok" }) 107 + } 108 + 109 + async fn transcode( 110 + Query(params): Query<TranscodeParams>, 111 + mut multipart: Multipart, 112 + ) -> Result<Response, AppError> { 113 + let target_ext = params.target.unwrap_or_else(|| "mp3".to_string()); 114 + 115 + let temp_dir = 116 + tempfile::tempdir().map_err(|e| AppError::Io(format!("failed to create temp dir: {e}")))?; 117 + let (input_path, original_name) = write_upload_to_disk(&mut multipart, &temp_dir).await?; 118 + 119 + let output_path = temp_dir.path().join(format!("output.{}", target_ext)); 120 + run_ffmpeg(&input_path, &output_path, &target_ext).await?; 121 + 122 + let bytes = tokio::fs::read(&output_path) 123 + .await 124 + .map_err(|e| AppError::Io(format!("failed to read output file: {e}")))?; 125 + 126 + let media_type = match target_ext.as_str() { 127 + "mp3" => "audio/mpeg", 128 + "wav" => "audio/wav", 129 + "m4a" => "audio/mp4", 130 + other => { 131 + info!( 132 + target = other, 133 + "unknown target format, defaulting to octet-stream" 134 + ); 135 + "application/octet-stream" 136 + } 137 + }; 138 + 139 + let download_name = format!("{}.{}", original_name, target_ext); 140 + let response = Response::builder() 141 + .status(StatusCode::OK) 142 + .header(header::CONTENT_TYPE, HeaderValue::from_static(media_type)) 143 + .header( 144 + header::CONTENT_DISPOSITION, 145 + HeaderValue::from_str(&format!("attachment; filename=\"{}\"", download_name)) 146 + .unwrap_or_else(|_| HeaderValue::from_static("attachment")), 147 + ) 148 + .body(Body::from(bytes)) 149 + .map_err(|e| AppError::Http(e.to_string()))?; 150 + 151 + Ok(response) 152 + } 153 + 154 + async fn write_upload_to_disk( 155 + multipart: &mut Multipart, 156 + temp_dir: &TempDir, 157 + ) -> Result<(PathBuf, String), AppError> { 158 + let mut file_path: Option<PathBuf> = None; 159 + let mut original_name: Option<String> = None; 160 + 161 + while let Some(mut field) = multipart 162 + .next_field() 163 + .await 164 + .map_err(|e| AppError::BadRequest(format!("invalid multipart data: {e}")))? 165 + { 166 + if field.name() != Some("file") { 167 + continue; 168 + } 169 + 170 + let filename = field 171 + .file_name() 172 + .map(|s| s.to_string()) 173 + .unwrap_or_else(|| "upload".to_string()); 174 + let sanitized_name = sanitize(&filename); 175 + let ext = std::path::Path::new(&sanitized_name) 176 + .extension() 177 + .and_then(|s| s.to_str()) 178 + .unwrap_or("bin"); 179 + let path = temp_dir.path().join(format!("input.{}", ext)); 180 + let mut file = File::create(&path) 181 + .await 182 + .map_err(|e| AppError::Io(format!("failed to create temp file: {e}")))?; 183 + 184 + while let Some(chunk) = field 185 + .chunk() 186 + .await 187 + .map_err(|e| AppError::BadRequest(format!("failed to read upload chunk: {e}")))? 188 + { 189 + file.write_all(&chunk) 190 + .await 191 + .map_err(|e| AppError::Io(format!("failed to write chunk: {e}")))?; 192 + } 193 + file.flush() 194 + .await 195 + .map_err(|e| AppError::Io(format!("failed to flush file: {e}")))?; 196 + 197 + file_path = Some(path); 198 + original_name = Some( 199 + std::path::Path::new(&sanitized_name) 200 + .file_stem() 201 + .and_then(|s| s.to_str()) 202 + .unwrap_or("track") 203 + .to_string(), 204 + ); 205 + break; 206 + } 207 + 208 + match (file_path, original_name) { 209 + (Some(path), Some(name)) => Ok((path, name)), 210 + _ => Err(AppError::BadRequest( 211 + "multipart form must include a 'file' field".into(), 212 + )), 213 + } 214 + } 215 + 216 + async fn run_ffmpeg(input: &Path, output: &Path, target_ext: &str) -> Result<(), AppError> { 217 + let mut cmd = Command::new("ffmpeg"); 218 + cmd.arg("-y").arg("-i").arg(input); 219 + 220 + match target_ext { 221 + "mp3" => { 222 + cmd.args(["-acodec", "libmp3lame", "-b:a", "320k", "-ar", "44100"]); 223 + } 224 + "wav" => { 225 + cmd.args(["-acodec", "pcm_s16le", "-ar", "44100"]); 226 + } 227 + "m4a" => { 228 + cmd.args(["-acodec", "aac", "-b:a", "256k", "-ar", "44100"]); 229 + } 230 + other => { 231 + return Err(AppError::BadRequest(format!( 232 + "unsupported target format: {}", 233 + other 234 + ))); 235 + } 236 + } 237 + 238 + cmd.arg(output); 239 + 240 + let output_res = cmd 241 + .output() 242 + .await 243 + .map_err(|e| AppError::Ffmpeg(format!("failed to spawn ffmpeg: {e}")))?; 244 + 245 + if !output_res.status.success() { 246 + let stderr = String::from_utf8_lossy(&output_res.stderr).to_string(); 247 + error!(%stderr, "ffmpeg failed"); 248 + return Err(AppError::Ffmpeg(stderr)); 249 + } 250 + 251 + Ok(()) 252 + } 253 + 254 + #[derive(Debug, thiserror::Error)] 255 + enum AppError { 256 + #[error("bad request: {0}")] 257 + BadRequest(String), 258 + #[error("io error: {0}")] 259 + Io(String), 260 + #[error("http error: {0}")] 261 + Http(String), 262 + #[error("ffmpeg error: {0}")] 263 + Ffmpeg(String), 264 + } 265 + 266 + impl IntoResponse for AppError { 267 + fn into_response(self) -> Response { 268 + tracing::error!(error = %self, "request failed"); 269 + let status = match self { 270 + AppError::BadRequest(_) => StatusCode::BAD_REQUEST, 271 + AppError::Io(_) | AppError::Http(_) | AppError::Ffmpeg(_) => { 272 + StatusCode::INTERNAL_SERVER_ERROR 273 + } 274 + }; 275 + let body = serde_json::json!({ 276 + "error": self.to_string(), 277 + }); 278 + (status, Json(body)).into_response() 279 + } 280 + }