|
| 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