Skip to content

Commit e64b8eb

Browse files
committed
feat: audio transcoding endpoint for offline downloads (MP3/AAC)
- GET api/mobile/stream/tfm/{channelId}/{tfmId}/transcoded?format=mp3|aac&bitrate=N downloads the original into the streaming cache if needed, transcodes it with FFmpeg and serves the result with Range support; transcodes are cached on disk under _temp/transcoded so repeat requests are instant - GET api/mobile/stream/transcode/info reports FFmpeg availability so clients can warn the user and fall back to original downloads - Returns 501 when FFmpeg is missing; per-target locks avoid duplicate transcodes and a semaphore caps concurrent FFmpeg processes at 2 - MP3 keeps embedded cover art and tags; AAC (m4a) keeps tags
1 parent 6e61d7d commit e64b8eb

1 file changed

Lines changed: 256 additions & 0 deletions

File tree

TelegramDownloader/Controllers/Mobile/MobileStreamController.cs

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
using TelegramDownloader.Models;
55
using TelegramDownloader.Models.Mobile;
66
using TelegramDownloader.Services;
7+
using System.Collections.Concurrent;
8+
using System.Diagnostics;
79
using System.Web;
810

911
namespace TelegramDownloader.Controllers.Mobile
@@ -857,5 +859,259 @@ private static string GetMimeType(string extension)
857859
_ => "application/octet-stream"
858860
};
859861
}
862+
863+
// ============ Audio transcoding (offline downloads in MP3/AAC) ============
864+
865+
private static bool? _ffmpegAvailable;
866+
private static readonly SemaphoreSlim _transcodeSemaphore = new(2); // Max 2 concurrent FFmpeg processes
867+
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _transcodeLocks = new();
868+
869+
private static bool IsFFmpegAvailable()
870+
{
871+
if (_ffmpegAvailable.HasValue) return _ffmpegAvailable.Value;
872+
try
873+
{
874+
using var process = new Process
875+
{
876+
StartInfo = new ProcessStartInfo
877+
{
878+
FileName = "ffmpeg",
879+
Arguments = "-version",
880+
RedirectStandardOutput = true,
881+
RedirectStandardError = true,
882+
UseShellExecute = false,
883+
CreateNoWindow = true
884+
}
885+
};
886+
process.Start();
887+
process.WaitForExit(5000);
888+
_ffmpegAvailable = process.ExitCode == 0;
889+
}
890+
catch
891+
{
892+
_ffmpegAvailable = false;
893+
}
894+
return _ffmpegAvailable.Value;
895+
}
896+
897+
/// <summary>
898+
/// Capacidades de transcodificación del servidor
899+
/// </summary>
900+
/// <remarks>
901+
/// Permite al cliente comprobar si el servidor tiene FFmpeg disponible antes de
902+
/// ofrecer descargas transcodificadas. Si no lo está, el cliente debe avisar al
903+
/// usuario y descargar en formato original.
904+
/// </remarks>
905+
[HttpGet("transcode/info")]
906+
[ProducesResponseType(StatusCodes.Status200OK)]
907+
public IActionResult GetTranscodeInfo()
908+
{
909+
var available = IsFFmpegAvailable();
910+
return Ok(ApiResponse<object>.Ok(new
911+
{
912+
ffmpegAvailable = available,
913+
formats = available ? new[] { "mp3", "aac" } : Array.Empty<string>()
914+
}));
915+
}
916+
917+
/// <summary>
918+
/// Descargar audio transcodificado a MP3 o AAC (para descargas offline)
919+
/// </summary>
920+
/// <remarks>
921+
/// Transcodifica el archivo original (p.ej. FLAC) al formato y bitrate indicados
922+
/// usando FFmpeg y lo sirve con soporte Range. El resultado se cachea en disco,
923+
/// por lo que peticiones posteriores son inmediatas.
924+
///
925+
/// Requiere FFmpeg instalado en el servidor: si no está disponible responde
926+
/// **501 Not Implemented** y el cliente debe descargar el original.
927+
///
928+
/// La primera petición puede tardar: descarga el original de Telegram (si no está
929+
/// cacheado) y ejecuta la transcodificación antes de responder.
930+
/// </remarks>
931+
/// <param name="channelId">ID del canal de Telegram</param>
932+
/// <param name="tfmId">ID del archivo en la base de datos TFM</param>
933+
/// <param name="format">Formato destino: mp3 | aac</param>
934+
/// <param name="bitrate">Bitrate en kbps (64-320)</param>
935+
/// <param name="fileName">Nombre del archivo original (opcional)</param>
936+
[HttpGet("tfm/{channelId}/{tfmId}/transcoded")]
937+
[ProducesResponseType(StatusCodes.Status200OK)]
938+
[ProducesResponseType(StatusCodes.Status206PartialContent)]
939+
[ProducesResponseType(StatusCodes.Status404NotFound)]
940+
[ProducesResponseType(StatusCodes.Status501NotImplemented)]
941+
public async Task<IActionResult> StreamTranscodedAudio(
942+
string channelId,
943+
string tfmId,
944+
[FromQuery] string format = "mp3",
945+
[FromQuery] int bitrate = 192,
946+
[FromQuery] string? fileName = null)
947+
{
948+
try
949+
{
950+
format = format.ToLowerInvariant();
951+
if (format != "mp3" && format != "aac")
952+
{
953+
return BadRequest(ApiResponse<object>.Fail("Unsupported format. Use mp3 or aac."));
954+
}
955+
bitrate = Math.Clamp(bitrate, 64, 320);
956+
957+
if (!IsFFmpegAvailable())
958+
{
959+
return StatusCode(StatusCodes.Status501NotImplemented,
960+
ApiResponse<object>.Fail("FFmpeg is not available on the server"));
961+
}
962+
963+
var dbFile = await _fs.getItemById(channelId, tfmId);
964+
if (dbFile == null)
965+
{
966+
return NotFound(ApiResponse<object>.Fail("File not found in database"));
967+
}
968+
969+
var name = fileName ?? dbFile.Name;
970+
var cacheFileName = $"{channelId}-{(dbFile.MessageId != null ? dbFile.MessageId.ToString() : dbFile.Id)}-{name}";
971+
var tempPath = Path.Combine(FileService.TEMPDIR, "_temp");
972+
var sourcePath = Path.Combine(tempPath, cacheFileName);
973+
var transcodedDir = Path.Combine(tempPath, "transcoded");
974+
Directory.CreateDirectory(transcodedDir);
975+
976+
var targetExt = format == "mp3" ? "mp3" : "m4a";
977+
var mimeType = format == "mp3" ? "audio/mpeg" : "audio/mp4";
978+
var targetName = $"{Path.GetFileNameWithoutExtension(cacheFileName)}-{format}{bitrate}.{targetExt}";
979+
var targetPath = Path.Combine(transcodedDir, targetName);
980+
var downloadName = $"{Path.GetFileNameWithoutExtension(name)}.{targetExt}";
981+
982+
// Cached transcode: serve immediately with Range support
983+
if (System.IO.File.Exists(targetPath))
984+
{
985+
return PhysicalFile(targetPath, mimeType, downloadName, enableRangeProcessing: true);
986+
}
987+
988+
// Per-target lock so concurrent requests don't transcode twice
989+
var fileLock = _transcodeLocks.GetOrAdd(targetName, _ => new SemaphoreSlim(1, 1));
990+
await fileLock.WaitAsync(HttpContext.RequestAborted);
991+
try
992+
{
993+
if (!System.IO.File.Exists(targetPath))
994+
{
995+
// Ensure the original is fully cached first
996+
if (!System.IO.File.Exists(sourcePath) || new FileInfo(sourcePath).Length < dbFile.Size)
997+
{
998+
_logger.LogInformation("Downloading original before transcode: {FileName}", name);
999+
await DownloadOriginalToCache(channelId, dbFile, sourcePath);
1000+
}
1001+
1002+
await _transcodeSemaphore.WaitAsync(HttpContext.RequestAborted);
1003+
try
1004+
{
1005+
_logger.LogInformation("Transcoding {FileName} to {Format} {Bitrate}k", name, format, bitrate);
1006+
await TranscodeAudioFile(sourcePath, targetPath, format, bitrate, HttpContext.RequestAborted);
1007+
}
1008+
finally
1009+
{
1010+
_transcodeSemaphore.Release();
1011+
}
1012+
}
1013+
}
1014+
finally
1015+
{
1016+
fileLock.Release();
1017+
}
1018+
1019+
return PhysicalFile(targetPath, mimeType, downloadName, enableRangeProcessing: true);
1020+
}
1021+
catch (OperationCanceledException)
1022+
{
1023+
_logger.LogDebug("Transcoded download cancelled for {TfmId}", tfmId);
1024+
return new EmptyResult();
1025+
}
1026+
catch (Exception ex)
1027+
{
1028+
_logger.LogError(ex, "Error transcoding {TfmId} from channel {ChannelId}", tfmId, channelId);
1029+
return StatusCode(500, ApiResponse<object>.Fail("Error transcoding audio"));
1030+
}
1031+
}
1032+
1033+
// Sequential full download of the original file into the streaming cache
1034+
private async Task DownloadOriginalToCache(string channelId, BsonFileManagerModel dbFile, string path)
1035+
{
1036+
if (!dbFile.MessageId.HasValue)
1037+
{
1038+
throw new InvalidOperationException("File has no MessageId");
1039+
}
1040+
1041+
var message = await _ts.getMessageFile(channelId, dbFile.MessageId.Value);
1042+
if (message == null)
1043+
{
1044+
throw new InvalidOperationException("Message not found in Telegram");
1045+
}
1046+
1047+
using var fileStream = new FileStream(path, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read);
1048+
var offset = fileStream.Length;
1049+
fileStream.Seek(0, SeekOrigin.End);
1050+
1051+
const int chunkSize = 512 * 1024;
1052+
while (offset < dbFile.Size)
1053+
{
1054+
HttpContext.RequestAborted.ThrowIfCancellationRequested();
1055+
var toDownload = (int)Math.Min(chunkSize, dbFile.Size - offset);
1056+
var chunk = await _ts.DownloadFileStream(message, offset, toDownload);
1057+
if (chunk.Length == 0) break;
1058+
await fileStream.WriteAsync(chunk, 0, chunk.Length, HttpContext.RequestAborted);
1059+
offset += chunk.Length;
1060+
}
1061+
await fileStream.FlushAsync();
1062+
}
1063+
1064+
// Run FFmpeg to a temp file, then move into place so partial results
1065+
// are never served
1066+
private async Task TranscodeAudioFile(string sourcePath, string targetPath, string format, int bitrate, CancellationToken ct)
1067+
{
1068+
var tempOut = targetPath + ".part";
1069+
1070+
// mp3: keep embedded cover art (optional video stream copied as attached pic)
1071+
// aac/m4a: audio only (cover copying into ipod container is less reliable)
1072+
var codecArgs = format == "mp3"
1073+
? $"-map 0:a:0 -map 0:v? -c:v copy -disposition:v:0 attached_pic -codec:a libmp3lame -b:a {bitrate}k -id3v2_version 3 -f mp3"
1074+
: $"-vn -codec:a aac -b:a {bitrate}k -movflags +faststart -f ipod";
1075+
1076+
var args = $"-y -i \"{sourcePath}\" -map_metadata 0 {codecArgs} \"{tempOut}\"";
1077+
1078+
using var process = new Process
1079+
{
1080+
StartInfo = new ProcessStartInfo
1081+
{
1082+
FileName = "ffmpeg",
1083+
Arguments = args,
1084+
RedirectStandardOutput = true,
1085+
RedirectStandardError = true,
1086+
UseShellExecute = false,
1087+
CreateNoWindow = true
1088+
}
1089+
};
1090+
1091+
process.Start();
1092+
var stderrTask = process.StandardError.ReadToEndAsync();
1093+
_ = process.StandardOutput.ReadToEndAsync();
1094+
1095+
try
1096+
{
1097+
await process.WaitForExitAsync(ct);
1098+
}
1099+
catch (OperationCanceledException)
1100+
{
1101+
try { process.Kill(entireProcessTree: true); } catch { /* already exited */ }
1102+
try { System.IO.File.Delete(tempOut); } catch { /* best effort */ }
1103+
throw;
1104+
}
1105+
1106+
if (process.ExitCode != 0)
1107+
{
1108+
var stderr = await stderrTask;
1109+
try { System.IO.File.Delete(tempOut); } catch { /* best effort */ }
1110+
throw new InvalidOperationException(
1111+
$"FFmpeg exited with code {process.ExitCode}: {stderr.Substring(0, Math.Min(500, stderr.Length))}");
1112+
}
1113+
1114+
System.IO.File.Move(tempOut, targetPath, overwrite: true);
1115+
}
8601116
}
8611117
}

0 commit comments

Comments
 (0)