Merge feature/merge-upstream: Modal 小屏 + 视频播放 + Docker Alpine(冲突以 feature 为准)
部署到群晖 / deploy (push) Failing after 3h9m39s

# Conflicts:
#	Dockerfile
#	Dockerfile-arm
#	dto/VideoPageRequestDTO.cs
#	model/AppConfig.cs
#	repository/DyCollectVideoRepository.cs
#	service/CommonService.cs
#	service/DyCollectVideoService.cs
#	service/DyHttpClientService.cs
This commit is contained in:
yuming
2026-07-03 20:03:46 +08:00
203 changed files with 28876 additions and 10250 deletions
+382 -239
View File
@@ -1,10 +1,9 @@
using dy.net.dto;
using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.service;
using dy.net.utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using System.Drawing.Printing;
namespace dy.net.Controllers
{
@@ -12,33 +11,29 @@ namespace dy.net.Controllers
[ApiController]
public class VideoController : ControllerBase
{
private readonly DyCollectVideoService dyCollectVideoService;
private readonly DouyinVideoService douyinVideoService;
private readonly DouyinCommonService douyinCommonService;
public VideoController(DyCollectVideoService dyCollectVideoService)
public VideoController(DouyinVideoService dyCollectVideoService, DouyinCommonService douyinCommonService)
{
this.dyCollectVideoService = dyCollectVideoService;
this.douyinVideoService = dyCollectVideoService;
this.douyinCommonService = douyinCommonService;
}
/// <summary>
/// 分页查询收藏视频
/// </summary>
/// <param name="dto"></param>
[Authorize]
[HttpPost("paged")]
public async Task<IActionResult> GetPagedAsync(VideoPageRequestDTO dto)
public async Task<IActionResult> GetPagedAsync(DouyinVideoPageRequestDto dto)
{
// 调试日志:输出接收到的排序参数
Console.WriteLine($"排序参数 - SortField: {dto.SortField}, SortOrder: {dto.SortOrder}");
var (list, totalCount) = await dyCollectVideoService.GetPagedAsync(dto.PageIndex, dto.PageSize, dto.Tag, dto.Tags, dto.Author, dto.ViedoType, dto.Dates, dto.IsDeleted, dto.SortField, dto.SortOrder);
return Ok(new
var (list, totalCount) = await douyinVideoService.GetPagedAsync(dto);
return ApiResult.Success(new
{
code = 0,
data = new
{
data = list,
total=totalCount,
pageIndex=dto.PageIndex,
pageSize=dto.PageSize
}
data = list,
total = totalCount,
pageIndex = dto.PageIndex,
pageSize = dto.PageSize
});
}
@@ -61,286 +56,434 @@ namespace dy.net.Controllers
/// 查询统计数据
/// </summary>
/// <returns></returns>
[Authorize]
[HttpGet("statics")]
public async Task<IActionResult> GetStaticsAsync()
{
var data = await dyCollectVideoService.GetStatics();
return Ok(new
{
code = 0,
data
});
return ApiResult.Success(await douyinVideoService.GetStatics());
}
/// <summary>
/// 清理数据库中文件不存在的视频记录
/// 播放视频
/// </summary>
/// <param name="vid"></param>
/// <returns></returns>
[Authorize]
[HttpPost("cleanup")]
public async Task<IActionResult> CleanupMissingFilesAsync()
[AllowAnonymous]
[HttpGet("play/{vid}")]
public async Task<IActionResult> StreamVideo([FromRoute] string vid)
{
try
{
Serilog.Log.Information("开始清理文件不存在的视频记录");
var deletedCount = await dyCollectVideoService.CleanupMissingFilesAsync();
Serilog.Log.Information($"清理完成,删除了 {deletedCount} 条记录");
return Ok(new
var viedo = await douyinVideoService.GetById(vid);
if (viedo == null)
{
code = 0,
data = new
{
deletedCount = deletedCount,
message = $"清理完成,删除了 {deletedCount} 条文件不存在的记录"
}
});
return ApiResult.Fail($"视频不存在:{vid}");
}
return await PlayVideoAsync(viedo);
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "清理文件不存在的记录失败");
return StatusCode(500, new { code = -1, error = "清理失败:" + ex.Message });
return ApiResult.Fail($"视频加载失败:{ex.Message}");
}
}
/// <summary>
/// 抖音视频播放接口(优化版)
/// 解决内存泄漏、资源释放不及时、鲁棒性不足等问题
/// </summary>
/// <param name="video">视频实体(修正拼写错误:viedo -> video)</param>
/// <returns>视频流响应</returns>
public async Task<IActionResult> PlayVideoAsync(DouyinVideo video)
{
// 空值校验
if (video == null)
{
return ApiResult.Fail("视频信息不能为空");
}
// 1. 获取完整物理路径并校验
string videoFullPath = video.VideoSavePath;
if (string.IsNullOrWhiteSpace(videoFullPath))
{
return ApiResult.Fail("视频保存路径不能为空");
}
// 安全校验:防止路径遍历攻击
try
{
videoFullPath = Path.GetFullPath(videoFullPath);
}
catch
{
return ApiResult.Fail("视频路径格式非法");
}
// 2. 验证文件是否存在
if (!System.IO.File.Exists(videoFullPath))
{
return ApiResult.Fail($"视频文件不存在:{videoFullPath}");
}
try
{
// 3. 获取文件信息(使用using确保FileInfo资源释放)
var fileInfo = new FileInfo(videoFullPath);
long fileSize = fileInfo.Length;
// 校验空文件
if (fileSize == 0)
{
return ApiResult.Fail($"视频文件为空:{videoFullPath}");
}
string contentType = GetContentType(videoFullPath);
// 4. 处理分片请求(Range)
if (Request.Headers.ContainsKey("Range"))
{
return await HandleRangeRequestAsync(videoFullPath, fileSize, contentType);
}
else
{
// 完整文件请求(启用分片处理,兼容前端断点续传)
return PhysicalFile(videoFullPath, contentType, enableRangeProcessing: true);
}
}
catch (UnauthorizedAccessException)
{
return ApiResult.Fail($"没有权限访问视频文件:{videoFullPath}");
}
catch (IOException ex)
{
// 记录日志(建议添加日志框架,如Serilog/NLog)
// _logger.LogError(ex, "读取视频文件失败:{Path}", videoFullPath);
return ApiResult.Fail($"读取视频文件失败:{ex.Message}");
}
catch (Exception ex)
{
// _logger.LogError(ex, "视频播放接口异常:{Path}", videoFullPath);
return ApiResult.Fail($"服务器内部错误:{ex.Message}");
}
}
/// <summary>
/// 删除指定作者的所有视频数据
/// 处理分片请求(Range),异步安全处理流
/// </summary>
/// <param name="authorName">作者名称</param>
/// <param name="filePath">文件路径</param>
/// <param name="fileSize">文件总大小</param>
/// <param name="contentType">内容类型</param>
/// <returns>分片响应</returns>
private async Task<IActionResult> HandleRangeRequestAsync(string filePath, long fileSize, string contentType)
{
string rangeHeader = Request.Headers["Range"].ToString();
// 安全解析Range头
if (!rangeHeader.StartsWith("bytes="))
{
// 416 - 请求的范围无法满足
Response.StatusCode = StatusCodes.Status416RequestedRangeNotSatisfiable;
Response.Headers.Add("Content-Range", $"bytes */{fileSize}");
return new EmptyResult();
}
// 解析起始位置
string[] rangeParts = rangeHeader.Split('=')[1].Split('-');
if (!long.TryParse(rangeParts[0], out long start) || start < 0 || start >= fileSize)
{
Response.StatusCode = StatusCodes.Status416RequestedRangeNotSatisfiable;
Response.Headers.Add("Content-Range", $"bytes */{fileSize}");
return new EmptyResult();
}
// 计算分片结束位置(每片2MB,可配置)
long end = Math.Min(start + 1024 * 1024 * 2, fileSize - 1);
long chunkSize = end - start + 1;
// 设置206分片响应头
Response.StatusCode = StatusCodes.Status206PartialContent;
Response.Headers.Add("Content-Range", $"bytes {start}-{end}/{fileSize}");
Response.Headers.Add("Accept-Ranges", "bytes");
Response.Headers.Add("Content-Length", chunkSize.ToString());
Response.ContentType = contentType;
// 核心改进:使用异步流 + using确保释放(通过管道直接写入响应流)
await using var fileStream = new FileStream(
filePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 8192, // 增大缓冲区提升性能,减少IO次数
FileOptions.Asynchronous | FileOptions.SequentialScan); // 顺序扫描优化
// 定位到分片起始位置
fileStream.Seek(start, SeekOrigin.Begin);
// 直接写入响应流,避免FileStreamResult的延迟释放问题
var buffer = new byte[8192];
long remainingBytes = chunkSize;
while (remainingBytes > 0)
{
int bytesRead = await fileStream.ReadAsync(buffer, 0, (int)Math.Min(remainingBytes, buffer.Length));
if (bytesRead == 0) break;
await Response.Body.WriteAsync(buffer.AsMemory(0, bytesRead));
remainingBytes -= bytesRead;
}
await Response.Body.FlushAsync();
return new EmptyResult();
}
/// <summary>
/// 播放视频
/// </summary>
/// <param name="vid"></param>
/// <param name="k"></param>
/// <returns></returns>
[Authorize]
[HttpPost("delete-author")]
public async Task<IActionResult> DeleteAuthorVideosAsync([FromBody] DeleteAuthorRequest request)
[HttpGet("/share/{vid}/{k}")]
[AllowAnonymous]
public async Task<IActionResult> Share([FromRoute] string vid, [FromRoute] string k)
{
try
{
if (string.IsNullOrWhiteSpace(request?.AuthorName))
var viedo = await douyinVideoService.GetById(vid);
if (viedo == null)
{
return BadRequest(new { code = -1, error = "作者名称不能为空" });
return ApiResult.Fail($"视频不存在:{vid}");
}
Serilog.Log.Information($"开始删除作者 {request.AuthorName} 的视频数据");
var deletedCount = await dyCollectVideoService.DeleteAuthorVideosAsync(request.AuthorName);
Serilog.Log.Information($"删除完成,删除了 {deletedCount} 条记录");
return Ok(new
var expectedKey = (viedo.FileHash + viedo.AuthorId).Md5();
if (expectedKey != k)
{
code = 0,
data = new
{
deletedCount = deletedCount,
message = $"已删除作者 {request.AuthorName} 的 {deletedCount} 条视频记录"
}
});
return ApiResult.Fail($"视频地址无效");
}
return await PlayVideoAsync(viedo);
}
catch (Exception ex)
{
Serilog.Log.Error(ex, $"删除作者视频失败: {request?.AuthorName}");
return StatusCode(500, new { code = -1, error = "删除失败:" + ex.Message });
return ApiResult.Fail($"视频加载失败:{ex.Message}");
}
}
/// <summary>
/// 删除作者请求DTO
/// 辅助方法:根据文件名获取 MIME 类型(确保前端正确识别视频格式)
/// </summary>
public class DeleteAuthorRequest
private static string GetContentType(string filename)
{
public string AuthorName { get; set; }
}
/// <summary>
/// 获取视频流(支持Range请求,用于视频播放)
/// </summary>
/// <param name="id">视频ID</param>
/// <returns></returns>
[Authorize]
[HttpGet("stream/{id}")]
public async Task<IActionResult> GetVideoStream(string id)
{
try
{
var video = await dyCollectVideoService.GetByIdAsync(id);
if (video == null || string.IsNullOrWhiteSpace(video.VideoSavePath))
{
return NotFound(new { code = -1, error = "视频不存在" });
}
if (!System.IO.File.Exists(video.VideoSavePath))
{
return NotFound(new { code = -1, error = "视频文件不存在" });
}
var fileInfo = new FileInfo(video.VideoSavePath);
var contentType = GetContentType(video.VideoSavePath);
// 记录日志,便于调试
var rangeHeader = Request.Headers["Range"].ToString();
Serilog.Log.Debug($"视频流请求: ID={id}, 路径={video.VideoSavePath}, Content-Type={contentType}, Range={rangeHeader}");
var fileStream = new FileStream(video.VideoSavePath, FileMode.Open, FileAccess.Read, FileShare.Read);
// 使用ASP.NET Core内置的Range支持
Response.Headers.Add("Accept-Ranges", "bytes");
// 添加缓存控制头,确保浏览器可以缓存视频元数据
// 但允许 Range 请求,这样浏览器可以快速开始播放
Response.Headers.Add("Cache-Control", "public, max-age=3600");
// 添加 CORS 头(如果需要)
Response.Headers.Add("Access-Control-Allow-Origin", "*");
Response.Headers.Add("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
Response.Headers.Add("Access-Control-Allow-Headers", "Range");
Response.Headers.Add("Access-Control-Expose-Headers", "Content-Range, Accept-Ranges, Content-Length");
return File(fileStream, contentType, enableRangeProcessing: true);
}
catch (Exception ex)
{
Serilog.Log.Error(ex, $"获取视频流失败: {id}");
return StatusCode(500, new { code = -1, error = "获取视频流失败" });
}
}
/// <summary>
/// 获取视频封面图片
/// </summary>
/// <param name="id">视频ID</param>
/// <returns></returns>
[Authorize]
[HttpGet("cover/{id}")]
public async Task<IActionResult> GetVideoCover(string id)
{
try
{
var video = await dyCollectVideoService.GetByIdAsync(id);
if (video == null)
{
return GetDefaultCoverImage();
}
// 优先使用本地保存的封面
if (!string.IsNullOrWhiteSpace(video.VideoCoverSavePath) && System.IO.File.Exists(video.VideoCoverSavePath))
{
var fileInfo = new FileInfo(video.VideoCoverSavePath);
var contentType = GetImageContentType(video.VideoCoverSavePath);
var fileStream = System.IO.File.OpenRead(video.VideoCoverSavePath);
return File(fileStream, contentType);
}
// 如果没有本地封面,返回默认图片
return GetDefaultCoverImage();
}
catch (Exception ex)
{
Serilog.Log.Error(ex, $"获取视频封面失败: {id}");
return GetDefaultCoverImage();
}
}
/// <summary>
/// 返回默认封面图片(SVG 占位图)
/// </summary>
private IActionResult GetDefaultCoverImage()
{
var svgContent = @"<svg width=""400"" height=""225"" xmlns=""http://www.w3.org/2000/svg"">
<rect width=""400"" height=""225"" fill=""#f0f0f0""/>
<text x=""50%"" y=""50%"" font-family=""Arial, sans-serif"" font-size=""16"" fill=""#999"" text-anchor=""middle"" dy="".3em"">暂无封面</text>
</svg>";
var svgBytes = System.Text.Encoding.UTF8.GetBytes(svgContent);
return File(svgBytes, "image/svg+xml");
}
/// <summary>
/// 根据文件扩展名获取图片 Content-Type
/// </summary>
private string GetImageContentType(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
{
return "image/jpeg";
}
var extension = Path.GetExtension(fileName).ToLowerInvariant();
string extension = Path.GetExtension(filename).ToLowerInvariant();
return extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".svg" => "image/svg+xml",
".bmp" => "image/bmp",
_ => "image/jpeg"
".mp4" => "video/mp4",
_ => "application/octet-stream" // 默认二进制流
};
}
/// <summary>
/// 根据文件扩展名获取Content-Type
/// 重新下载
/// </summary>
private string GetContentType(string fileName)
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("redown")]
public async Task<IActionResult> ReDownload(ReDownViedoDto dto)
{
if (string.IsNullOrWhiteSpace(fileName))
if (dto == null)
{
Serilog.Log.Warning($"文件名为空,使用默认 video/mp4");
return "video/mp4"; // 抖音视频默认是 MP4
return ApiResult.Fail("参数错误");
}
var extension = Path.GetExtension(fileName).ToLowerInvariant();
// 如果没有扩展名,尝试从文件名推断(抖音视频格式通常是 mp4)
if (string.IsNullOrEmpty(extension))
else
{
Serilog.Log.Warning($"文件 {fileName} 没有扩展名,使用默认 video/mp4");
return "video/mp4";
var result = await douyinVideoService.ReDownloadViedoAsync(dto);
if (result)
{
return ApiResult.Success(true);
}
else
{
return ApiResult.Fail("错误");
}
}
var contentType = extension switch
{
".mp4" => "video/mp4",
".webm" => "video/webm",
".ogg" => "video/ogg",
".mov" => "video/quicktime",
".avi" => "video/x-msvideo",
".mkv" => "video/x-matroska",
".flv" => "video/x-flv",
".wmv" => "video/x-ms-wmv",
".3gp" => "video/3gpp",
".m3u8" => "application/x-mpegURL", // HLS 流
_ => null
};
// 如果无法识别扩展名,默认使用 video/mp4(抖音视频通常是 MP4)
if (contentType == null)
{
Serilog.Log.Warning($"无法识别的视频扩展名: {extension},文件: {fileName},使用默认 video/mp4");
return "video/mp4";
}
return contentType;
}
[AllowAnonymous]
[HttpGet]
public IActionResult Index()
/// <summary>
/// 批量删除
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("vdelete/batch")]
public async Task<IActionResult> BathRealDelete(ReDownViedoDto dto)
{
List<MyClass> myClasses = new List<MyClass>();
foreach (var drive in DriveInfo.GetDrives())
var result = await douyinVideoService.RealDeleteVideos(dto.Ids);
if (result)
{
myClasses.Add(new MyClass { name=drive.Name, x1= drive.TotalSize, x2= drive.TotalFreeSpace });
Serilog.Log.Debug($"Drive: {drive.Name}, Total Size: {drive.TotalSize}, Free Space: {drive.TotalFreeSpace}");
return ApiResult.Success(true);
}
else
{
return ApiResult.Fail("错误");
}
//var disk=DiskInfoHelper.GetDockerHostTotalDiskSpaceGB();
return Ok(JsonConvert.SerializeObject(myClasses)+"\r\n"+JsonConvert.SerializeObject(myClasses.Sum(x=>x.x1)+"\r\n"+ JsonConvert.SerializeObject(myClasses.Sum(x => x.x2))));
}
public class MyClass
/// <summary>
/// 删除视频-不再下载
/// </summary>
/// <param name="vid"></param>
/// <returns></returns>
[HttpGet("vdelete/{vid}")]
public async Task<IActionResult> DeleteVideo([FromRoute] string vid)
{
public string name { get; set; }
if (string.IsNullOrWhiteSpace(vid))
{
return ApiResult.Fail("参数错误");
}
else
{
var res = await douyinVideoService.RealDeleteVideos(new List<string> { vid });
if (res)
{
return ApiResult.Success("删除成功");
}
else
{
return ApiResult.Fail("删除失败");
}
}
}
public long x1 { get; set; }
/// <summary>
/// 查询已删除视频列表
/// </summary>
/// <returns></returns>
[HttpGet("vdelete/get")]
public async Task<IActionResult> GetDeleteVideo()
{
return ApiResult.Success(await douyinCommonService.GetDouyinDeleteVideos());
}
/// <summary>
/// 根据博主id删除博主所有视频
/// </summary>
/// <param name="uperUid"></param>
/// <returns></returns>
[HttpGet("vdelete/byauthor/{uperUid}")]
public async Task<IActionResult> DeleteByAuthor([FromRoute] string uperUid)
{
var videos = await douyinVideoService.GetByAuthorId(uperUid);
if (videos != null && videos.Any())
{
var res = await douyinVideoService.RealDeleteVideos(videos.Select(x => x.Id).ToList());
if (res)
{
return ApiResult.Success("删除成功");
}
else
{
return ApiResult.Fail("删除失败");
}
}
return ApiResult.Fail("未找到该博主视频");
}
public long x2 { get; set; }
//private async Task<(bool flowControl, IActionResult value)> BatchDeleteVideos(List<DouyinVideo> videos)
//{
// return (flowControl: true, value: null);
//}
/// <summary>
/// 查询最新N条数据
/// </summary>
/// <param name="top"></param>
/// <returns></returns>
[HttpGet("top{top}")]
public async Task<IActionResult> GetLastSyncTop([FromRoute] int top = 5)
{
return ApiResult.Success(await douyinVideoService.GetLastSyncTop(top));
}
/// <summary>
/// 删除无效视频记录
/// </summary>
/// <returns></returns>
[HttpGet("removeInvalid")]
public async Task<IActionResult> RemoveInvalidVideo()
{
var data = await douyinVideoService.DeleteInvalidVideo();
return Ok(data);
}
///// <summary>
///// 所有视频重新生成nfo文件
///// </summary>
///// <returns></returns>
//[HttpGet("renfo")]
//public async Task<IActionResult> ReCreateNfo()
//{
// var videos = await douyinVideoService.GetAllAsync();
// if (videos == null || videos.Count == 0)
// {
// return ApiResult.Success("暂无视频数据需要生成NFO文件");
// }
// _ = Task.Run(async () =>
// {
// try
// {
// var totalCount = videos.Count;
// foreach (var video in videos)
// {
// try
// {
// NfoFileGenerator.GenerateVideoNfoFile(video);
// Serilog.Log.Debug($"刮削视频(Path:{video.VideoSavePath})生成NFO成功!");
// await Task.Delay(50);
// }
// catch (Exception singleEx)
// {
// Serilog.Log.Error($"刮削视频(Path:{video.VideoSavePath})生成NFO失败:{singleEx.Message}");
// }
// }
// }
// catch (Exception ex)
// {
// Serilog.Log.Error($"NFO文件生成任务执行异常:{ex.Message}\n{ex.StackTrace}");
// }
// });
// return ApiResult.Success();
//}
/// <summary>
/// 获取7天视频同步趋势数据(曲线图)
/// </summary>
/// <returns>7天图表数据列表</returns>
[HttpGet("chart/{day}")]
public async Task<IActionResult> Chart([FromRoute] int day = 7)
{
try
{
var chartData = await douyinVideoService.GetChartData(day);
return ApiResult.Success(chartData);
}
catch (Exception ex)
{
return ApiResult.Fail(ex.Message);
}
}
[HttpGet("/Move")]
public async Task<IActionResult> Move()
{
await douyinVideoService.HandOldFolderVideos();
return ApiResult.Success(DateTime.Now);
}
}
}