Skip to content

Commit 85f4e40

Browse files
committed
feat: add transcoding for streaming
1 parent fe24fe5 commit 85f4e40

7 files changed

Lines changed: 790 additions & 23 deletions

File tree

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using System.Diagnostics;
3+
using TelegramDownloader.Data;
4+
using TelegramDownloader.Models;
5+
using TelegramDownloader.Services;
6+
7+
namespace TelegramDownloader.Controllers
8+
{
9+
[Route("api/localvideo")]
10+
[ApiController]
11+
public class LocalVideoStreamController : ControllerBase
12+
{
13+
private readonly ILogger<LocalVideoStreamController> _logger;
14+
15+
// Formats that need transcoding (not natively supported by browsers)
16+
private static readonly HashSet<string> TranscodingRequiredFormats = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
17+
{
18+
".mkv", ".avi", ".wmv", ".flv", ".mov", ".m4v", ".3gp", ".ts", ".mts", ".m2ts", ".vob", ".divx", ".xvid"
19+
};
20+
21+
// Formats natively supported by most browsers
22+
private static readonly HashSet<string> NativeFormats = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
23+
{
24+
".mp4", ".webm", ".ogg", ".ogv"
25+
};
26+
27+
public LocalVideoStreamController(ILogger<LocalVideoStreamController> logger)
28+
{
29+
_logger = logger;
30+
}
31+
32+
/// <summary>
33+
/// Check if a video format requires transcoding
34+
/// </summary>
35+
[HttpGet("needs-transcode")]
36+
public IActionResult NeedsTranscode([FromQuery] string fileName)
37+
{
38+
var extension = Path.GetExtension(fileName);
39+
var needsTranscode = TranscodingRequiredFormats.Contains(extension);
40+
return Ok(new { needsTranscode, extension });
41+
}
42+
43+
/// <summary>
44+
/// Stream local video with transcoding support for non-browser formats
45+
/// </summary>
46+
[HttpGet("stream")]
47+
public async Task<IActionResult> StreamVideo([FromQuery] string path)
48+
{
49+
if (string.IsNullOrEmpty(path))
50+
{
51+
return BadRequest("Path is required");
52+
}
53+
54+
// Decode and clean the path
55+
path = Uri.UnescapeDataString(path);
56+
57+
// Build the full path from the local directory
58+
var fullPath = Path.Combine(FileService.LOCALDIR, path.TrimStart('/').Replace("/", Path.DirectorySeparatorChar.ToString()));
59+
60+
if (!System.IO.File.Exists(fullPath))
61+
{
62+
_logger.LogWarning("File not found: {Path}", fullPath);
63+
return NotFound("File not found");
64+
}
65+
66+
var extension = Path.GetExtension(fullPath).ToLowerInvariant();
67+
68+
// If format is natively supported, serve directly
69+
if (NativeFormats.Contains(extension))
70+
{
71+
var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
72+
return File(stream, GetMimeType(extension), enableRangeProcessing: true);
73+
}
74+
75+
// Check if transcoding is enabled in config
76+
if (!GeneralConfigStatic.config.EnableVideoTranscoding)
77+
{
78+
_logger.LogInformation("Video transcoding is disabled in configuration");
79+
return StatusCode(403, new {
80+
error = "Video transcoding is disabled",
81+
message = "This video format requires transcoding. Enable 'Video Transcoding' in Settings to play this file.",
82+
format = extension
83+
});
84+
}
85+
86+
// Check if FFmpeg is available
87+
if (!IsFFmpegAvailable())
88+
{
89+
_logger.LogWarning("FFmpeg not available, falling back to direct stream");
90+
var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
91+
return File(stream, "video/mp4", enableRangeProcessing: true);
92+
}
93+
94+
try
95+
{
96+
// Set response headers for streaming
97+
Response.ContentType = "video/mp4";
98+
Response.Headers["Accept-Ranges"] = "none"; // Transcoded streams don't support range requests well
99+
Response.Headers["Cache-Control"] = "no-cache";
100+
101+
_logger.LogInformation("Starting FFmpeg transcoding for local file {FileName}", Path.GetFileName(fullPath));
102+
103+
// Start FFmpeg transcoding process
104+
await TranscodeAndStream(fullPath, Response.Body, HttpContext.RequestAborted);
105+
106+
return new EmptyResult();
107+
}
108+
catch (OperationCanceledException)
109+
{
110+
_logger.LogInformation("Client closed connection during video transcode - File: {FileName}", Path.GetFileName(fullPath));
111+
return new EmptyResult();
112+
}
113+
catch (Exception ex)
114+
{
115+
_logger.LogError(ex, "Error transcoding video {FileName}", Path.GetFileName(fullPath));
116+
return StatusCode(500, "Transcoding failed");
117+
}
118+
}
119+
120+
/// <summary>
121+
/// Get video info for a local file
122+
/// </summary>
123+
[HttpGet("info")]
124+
public IActionResult GetVideoInfo([FromQuery] string path)
125+
{
126+
try
127+
{
128+
if (string.IsNullOrEmpty(path))
129+
{
130+
return BadRequest("Path is required");
131+
}
132+
133+
path = Uri.UnescapeDataString(path);
134+
var fullPath = Path.Combine(FileService.LOCALDIR, path.TrimStart('/').Replace("/", Path.DirectorySeparatorChar.ToString()));
135+
136+
if (!System.IO.File.Exists(fullPath))
137+
{
138+
return NotFound();
139+
}
140+
141+
var extension = Path.GetExtension(fullPath).ToLowerInvariant();
142+
var needsTranscode = TranscodingRequiredFormats.Contains(extension);
143+
var ffmpegAvailable = IsFFmpegAvailable();
144+
145+
return Ok(new
146+
{
147+
fileName = Path.GetFileName(fullPath),
148+
extension,
149+
needsTranscode,
150+
ffmpegAvailable,
151+
canPlay = !needsTranscode || ffmpegAvailable
152+
});
153+
}
154+
catch (Exception ex)
155+
{
156+
_logger.LogError(ex, "Error getting video info");
157+
return StatusCode(500, ex.Message);
158+
}
159+
}
160+
161+
private string GetMimeType(string extension)
162+
{
163+
return extension.ToLowerInvariant() switch
164+
{
165+
".mp4" => "video/mp4",
166+
".webm" => "video/webm",
167+
".ogg" or ".ogv" => "video/ogg",
168+
".mov" => "video/quicktime",
169+
".avi" => "video/x-msvideo",
170+
".mkv" => "video/x-matroska",
171+
".wmv" => "video/x-ms-wmv",
172+
".flv" => "video/x-flv",
173+
_ => "video/mp4"
174+
};
175+
}
176+
177+
private bool IsFFmpegAvailable()
178+
{
179+
try
180+
{
181+
var process = new Process
182+
{
183+
StartInfo = new ProcessStartInfo
184+
{
185+
FileName = "ffmpeg",
186+
Arguments = "-version",
187+
RedirectStandardOutput = true,
188+
RedirectStandardError = true,
189+
UseShellExecute = false,
190+
CreateNoWindow = true
191+
}
192+
};
193+
process.Start();
194+
process.WaitForExit(5000);
195+
return process.ExitCode == 0;
196+
}
197+
catch
198+
{
199+
return false;
200+
}
201+
}
202+
203+
private async Task TranscodeAndStream(string sourcePath, Stream outputStream, CancellationToken cancellationToken)
204+
{
205+
// FFmpeg arguments for transcoding to browser-compatible MP4
206+
// -i: input file
207+
// -c:v libx264: use H.264 video codec
208+
// -preset ultrafast: fastest encoding (lower quality but real-time)
209+
// -crf 23: quality level (18-28 is good, lower = better quality)
210+
// -c:a aac: use AAC audio codec
211+
// -b:a 128k: audio bitrate
212+
// -movflags frag_keyframe+empty_moov+faststart: enable streaming
213+
// -f mp4: output format
214+
// pipe:1: output to stdout
215+
216+
var ffmpegArgs = $"-i \"{sourcePath}\" " +
217+
"-c:v libx264 -preset ultrafast -crf 23 " +
218+
"-c:a aac -b:a 128k " +
219+
"-movflags frag_keyframe+empty_moov+faststart " +
220+
"-f mp4 pipe:1";
221+
222+
_logger.LogDebug("FFmpeg command: ffmpeg {Args}", ffmpegArgs);
223+
224+
using var process = new Process
225+
{
226+
StartInfo = new ProcessStartInfo
227+
{
228+
FileName = "ffmpeg",
229+
Arguments = ffmpegArgs,
230+
RedirectStandardOutput = true,
231+
RedirectStandardError = true,
232+
UseShellExecute = false,
233+
CreateNoWindow = true
234+
}
235+
};
236+
237+
process.Start();
238+
239+
// Log FFmpeg stderr for debugging (async)
240+
_ = Task.Run(async () =>
241+
{
242+
using var reader = process.StandardError;
243+
while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested)
244+
{
245+
var line = await reader.ReadLineAsync();
246+
if (!string.IsNullOrEmpty(line))
247+
{
248+
_logger.LogDebug("FFmpeg: {Line}", line);
249+
}
250+
}
251+
}, cancellationToken);
252+
253+
try
254+
{
255+
// Stream FFmpeg output directly to the response
256+
await process.StandardOutput.BaseStream.CopyToAsync(outputStream, 64 * 1024, cancellationToken);
257+
await outputStream.FlushAsync(cancellationToken);
258+
}
259+
catch (OperationCanceledException)
260+
{
261+
_logger.LogInformation("Transcoding cancelled by client");
262+
}
263+
finally
264+
{
265+
if (!process.HasExited)
266+
{
267+
try
268+
{
269+
process.Kill(true);
270+
}
271+
catch { }
272+
}
273+
}
274+
}
275+
}
276+
}

0 commit comments

Comments
 (0)