using System.Net; using System.Text.RegularExpressions; using System.Xml; using Core; using FluentScheduler; using FreeSql; using FreeSql.DataAnnotations; using Interface.Jobs; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; namespace Service.Jobs; public class ChineseNfoRegistry : Registry, IChineseNfoRegistry { private readonly HttpClient _apiClient; private readonly HttpClient _imageClient; private readonly IConfiguration _configuration; private readonly ILogger _logger; private readonly IFreeSql _freeSql; public ChineseNfoRegistry(IConfiguration configuration, ILogger logger) { _configuration = configuration; _logger = logger; var httpClientHandler = new HttpClientHandler(); var proxyAddress = _configuration["ChineseNfo:HttpProxy"]; if (string.IsNullOrEmpty(proxyAddress) == false) { httpClientHandler.Proxy = string.IsNullOrEmpty(proxyAddress) ? null : new WebProxy(proxyAddress, false); httpClientHandler.UseProxy = !string.IsNullOrEmpty(proxyAddress); } _apiClient = new HttpClient(httpClientHandler); _apiClient.BaseAddress = new Uri("http://api.themoviedb.org"); _imageClient = new HttpClient(httpClientHandler); _imageClient.BaseAddress = new Uri("http://image.tmdb.org"); _freeSql = new FreeSqlBuilder() .UseConnectionString(FreeSql.DataType.Sqlite, _configuration["ChineseNfo:ConnectionString"]) .UseAutoSyncStructure(true) .Build(); Schedule(() => Job(ignoreLocked: true, ignoreCompleted: true)).ToRunEvery(1).Days(); } public void Job( string? path = null, int? seasonNumber = null, int? episodeNumber = null, bool ignoreLocked = false, bool ignoreCompleted = false ) { try { var lockAll = "lock-all"; lock (lockAll) { var lockKey = $"{path}-{seasonNumber}-{episodeNumber}"; lock (lockKey) { JobExecute(path, seasonNumber, episodeNumber, ignoreLocked, ignoreCompleted).Wait(); } } } catch (Exception e) { _logger.LogError(e, "error"); WxNotify.SendCommonAsync($"ChineseNfoRegistry.Job() 执行失败 {e.Message}").Wait(); } finally { _logger.LogInformation("ChineseNfoRegistry.Job() 执行结束"); } } private async Task JobExecute( string? requestPath = null, int? requestSeasonNumber = null, int? requestEpisodeNumber = null, bool ignoreLocked = false, bool ignoreCompleted = false) { var tvFolder = _configuration["ChineseNfo:TvFolder"]; if (string.IsNullOrEmpty(tvFolder)) { _logger.LogError("tvFolder is null or empty"); return; } /* * 2. 接口数据保留 * 3. 支持语言降级到繁体 * 4. 支持演员名称翻译 */ var tvNfos = Directory.GetFiles(tvFolder, "tvshow.nfo", SearchOption.AllDirectories); if (tvNfos.Length == 0) { _logger.LogError("tvNfo is null or empty"); return; } var ctn = new ChineseNfoContent(); foreach (var tv in tvNfos) { ctn.tvNfoPath = tv; if (await HandleTv() == false) { continue; } var sseasons = Directory.GetDirectories(Path.GetDirectoryName(tv) ?? string.Empty, "Season *", SearchOption.AllDirectories); var sseasonsNumbers = sseasons.Select(x => x.Split("Season ")[1]).Select(int.Parse).ToList(); foreach (var seasonNumber in sseasonsNumbers) { ctn.seasonNfoPath = Path.Combine(Path.GetDirectoryName(tv) ?? string.Empty, $"Season {seasonNumber}", "season.nfo"); if (requestSeasonNumber != null && requestSeasonNumber != seasonNumber) { continue; } if (File.Exists(ctn.seasonNfoPath) && await HandleSeason() == false) { continue; } var episodeNfos = Directory .GetFiles(Path.GetDirectoryName(ctn.seasonNfoPath) ?? string.Empty, "*.nfo", SearchOption.AllDirectories) .Where(x => !x.EndsWith("season.nfo")) .ToList(); foreach (var episode in episodeNfos) { ctn.episodeNfoPath = episode; _logger.LogInformation("ctn.episodeNfoPath: " + ctn.episodeNfoPath); await HandleEpisode(); } } } async Task HandleTv() { if (!string.IsNullOrEmpty(requestPath)) { var latestPath = Path.GetFileName(requestPath) ?? string.Empty; if (!ctn.tvNfoPath.Contains(latestPath)) { return false; } } _logger.LogInformation("开始处理 TV"); var nfoContent = File.ReadAllText(ctn.tvNfoPath); var tvXml = new XmlDocument(); tvXml.LoadXml(nfoContent); var uniqueIdNode = tvXml.SelectSingleNode("//uniqueid[@type='tmdb']"); if (uniqueIdNode == null) { _logger.LogError("uniqueIdNode is null"); return false; } if (!int.TryParse(uniqueIdNode.InnerText, out var tmdbId)) { _logger.LogError("tmdbId is null"); return false; } ctn.tvId = tmdbId; var isLockedNode = tvXml.SelectSingleNode("//locked"); if (isLockedNode != null && isLockedNode.InnerText == "true" && ignoreLocked) { _logger.LogInformation("tvNfo is locked"); return true; } var isCompletedNode = tvXml.SelectSingleNode("//completed"); if (isCompletedNode != null && isCompletedNode.InnerText == "true" && ignoreCompleted) { _logger.LogInformation("tvNfo is completed"); return true; } var tvInfo = await GetTmdbTv(ctn.tvNfoPath, tmdbId); if (tvInfo == null) { _logger.LogError("tvInfo is null"); return true; } if (tvInfo["name"] != null) { var titleNode = tvXml.SelectSingleNode("//title"); if (titleNode != null) { titleNode.InnerXml = $""; } var sorttitleNode = tvXml.SelectSingleNode("//sorttitle"); if (sorttitleNode != null) { sorttitleNode.InnerXml = $""; } _logger.LogInformation("tvInfo: {tvInfo}", tvInfo["name"]); } if (tvInfo["overview"] != null) { var plotNode = tvXml.SelectSingleNode("//plot"); if (plotNode != null) { plotNode.InnerXml = $""; } var outlineNode = tvXml.SelectSingleNode("//outline"); if (outlineNode != null) { outlineNode.InnerXml = $""; } _logger.LogInformation("tvInfo: {tvInfo}", tvInfo["overview"]); } if (tvInfo["poster_path"] != null) { var posterPath = tvInfo["poster_path"]!.ToString(); var image = await GetTmdbImage(ctn.tvNfoPath, posterPath); if (image != null) { var imagePath = Path.Combine(Path.GetDirectoryName(ctn.tvNfoPath) ?? string.Empty, "poster" + Path.GetExtension(posterPath)); await File.WriteAllBytesAsync(imagePath, image); } } var actors = tvXml.SelectNodes("//actor"); if (actors != null) { foreach (XmlNode actor in actors) { await HandleActor(actor); } } _logger.LogInformation("tmdbId: {tmdbId}, name: {name}", tmdbId, tvInfo["name"]?.ToString()); if (isLockedNode != null) { isLockedNode.InnerText = "true"; } else { isLockedNode = tvXml.CreateElement("locked"); tvXml.DocumentElement?.AppendChild(isLockedNode); isLockedNode.InnerText = "true"; } if (isCompletedNode != null) { isCompletedNode.InnerText = "true"; } else { isCompletedNode = tvXml.CreateElement("completed"); tvXml.DocumentElement?.AppendChild(isCompletedNode); isCompletedNode.InnerText = "true"; } // 保存 tvXml.Save(ctn.tvNfoPath); return true; } async Task HandleSeason() { if (string.IsNullOrEmpty(ctn.seasonNfoPath)) { _logger.LogError("seasonNfoPath is null or empty"); return false; } var nfoContent = File.ReadAllText(ctn.seasonNfoPath); var seasonXml = new XmlDocument(); seasonXml.LoadXml(nfoContent); var seasonNumberNode = seasonXml.SelectSingleNode("//seasonnumber"); if (seasonNumberNode == null) { _logger.LogError("seasonNumberNode is null"); return false; } if (!int.TryParse(seasonNumberNode.InnerText, out var seasonNumber)) { _logger.LogError("seasonNumber is null"); return false; } ctn.seasonNumber = seasonNumber; _logger.LogInformation("开始处理 Season"); var isLockedNode = seasonXml.SelectSingleNode("//locked"); if (isLockedNode != null && isLockedNode.InnerText == "true" && ignoreLocked) { _logger.LogInformation("seasonNfo is locked"); return true; } var isCompletedNode = seasonXml.SelectSingleNode("//completed"); if (isCompletedNode != null && isCompletedNode.InnerText == "true" && ignoreCompleted) { _logger.LogInformation("seasonNfo is completed"); return true; } var seasonInfo = await GetTmdbSeason(ctn); if (seasonInfo == null) { _logger.LogError("seasonInfo is null"); return true; } var titleNode = seasonXml.SelectSingleNode("//sorttitle"); if (titleNode != null && seasonInfo["name"] != null) { _logger.LogInformation("seasonInfo: {seasonInfo}", seasonInfo["name"]); titleNode.InnerXml = $""; } var plotNode = seasonXml.SelectSingleNode("//plot"); if (plotNode != null && seasonInfo["overview"] != null) { _logger.LogInformation("seasonInfo: {seasonInfo}", seasonInfo["overview"]); plotNode.InnerXml = $""; } var outlineNode = seasonXml.SelectSingleNode("//outline"); if (outlineNode != null && seasonInfo["overview"] != null) { _logger.LogInformation("seasonInfo: {seasonInfo}", seasonInfo["overview"]); outlineNode.InnerXml = $""; } var poster_path = seasonInfo["poster_path"]?.ToString(); if (poster_path != null) { var image = await GetTmdbImage(ctn.seasonNfoPath, poster_path); if (image != null) { var imagePath = Path.Combine(Path.GetDirectoryName(ctn.tvNfoPath) ?? string.Empty, "season" + seasonNumber.ToString("D2") + "-poster" + Path.GetExtension(poster_path)); await File.WriteAllBytesAsync(imagePath, image); } } if (isLockedNode != null) { isLockedNode.InnerText = "true"; } else { isLockedNode = seasonXml.CreateElement("locked"); seasonXml.DocumentElement?.AppendChild(isLockedNode); isLockedNode.InnerText = "true"; } if (isCompletedNode != null) { isCompletedNode.InnerText = "true"; } else { isCompletedNode = seasonXml.CreateElement("completed"); seasonXml.DocumentElement?.AppendChild(isCompletedNode); isCompletedNode.InnerText = "true"; } seasonXml.Save(ctn.seasonNfoPath); return true; } async Task HandleEpisode() { if (string.IsNullOrEmpty(ctn.episodeNfoPath)) { _logger.LogError("episodeNfoPath is null or empty"); return; } var nfoContent = File.ReadAllText(ctn.episodeNfoPath); var episodeXml = new XmlDocument(); episodeXml.LoadXml(nfoContent); var episodeNumberNode = episodeXml.SelectSingleNode("//episode"); if (episodeNumberNode == null) { _logger.LogError("episodeNumberNode is null"); return; } if (!int.TryParse(episodeNumberNode.InnerText, out var episodeNumber)) { _logger.LogError("episodeNumber is null"); return; } ctn.episodeNumber = episodeNumber; if (!string.IsNullOrEmpty(requestPath)) { if (requestSeasonNumber != null) { if (requestEpisodeNumber != null) { if (episodeNumber != requestEpisodeNumber) { return; } } } } _logger.LogInformation("开始处理 Episode"); var isLockedNode = episodeXml.SelectSingleNode("//locked"); if (isLockedNode != null && isLockedNode.InnerText == "true" && ignoreLocked) { _logger.LogInformation("episodeNfo is locked"); return; } var isCompletedNode = episodeXml.SelectSingleNode("//completed"); if (isCompletedNode != null && isCompletedNode.InnerText == "true" && ignoreCompleted) { _logger.LogInformation("episodeNfo is completed"); return; } var episodeInfo = await GetTmdbEpisode(ctn); if (episodeInfo == null) { _logger.LogError("episodeInfo is null"); return; } var titleNode = episodeXml.SelectSingleNode("//title"); if (titleNode != null && episodeInfo["name"] != null) { _logger.LogInformation("episodeInfo: {episodeInfo}", episodeInfo["name"]); titleNode.InnerXml = $""; } var plotNode = episodeXml.SelectSingleNode("//plot"); if (plotNode != null && episodeInfo["overview"] != null) { _logger.LogInformation("episodeInfo: {episodeInfo}", episodeInfo["overview"]); plotNode.InnerXml = $""; } var outlineNode = episodeXml.SelectSingleNode("//outline"); if (outlineNode != null && episodeInfo["overview"] != null) { _logger.LogInformation("episodeInfo: {episodeInfo}", episodeInfo["overview"]); outlineNode.InnerXml = $""; } var actors = episodeXml.SelectNodes("//actor"); if (actors != null) { foreach (XmlNode actor in actors) { await HandleActor(actor); } } _logger.LogInformation("tmdbId: {tmdbId}, name: {name}", ctn.tvId, episodeInfo["name"]?.ToString()); if (isLockedNode != null) { isLockedNode.InnerText = "true"; } else { isLockedNode = episodeXml.CreateElement("locked"); episodeXml.DocumentElement?.AppendChild(isLockedNode); isLockedNode.InnerText = "true"; } if (isCompletedNode != null) { isCompletedNode.InnerText = "true"; } else { isCompletedNode = episodeXml.CreateElement("completed"); episodeXml.DocumentElement?.AppendChild(isCompletedNode); isCompletedNode.InnerText = "true"; } // 保存 episodeXml.Save(ctn.episodeNfoPath); } async Task GetTmdbPersonName( string path, int? tmdbId = null, string? name = null ) { if (tmdbId != null) { var person = await GetTmdbPerson(path, tmdbId.Value); return person?["name"]?.ToString() ?? name; } if (string.IsNullOrEmpty(name) == false) { // 如果名称是中文直接返回 if (Regex.IsMatch(name, "^[\u4e00-\u9fa5]+$")) { return name; } var person = await GetTmdbPersonSearch(path, name); return person?["name"]?.ToString() ?? name; } return null; } async Task HandleActor(XmlNode actor) { var nameNode = actor.SelectSingleNode("name"); var tmdbIdNode = actor.SelectSingleNode("tmdbid"); if (nameNode != null || tmdbIdNode != null) { var name = await GetTmdbPersonName( tvFolder, int.TryParse(tmdbIdNode?.InnerText, out var actorTmdbId) ? actorTmdbId : null, nameNode?.InnerText ); if (name == null) { return; } if (nameNode != null) { nameNode.InnerXml = $""; } else { actor.AppendChild(new XmlDocument().CreateElement("name")); nameNode = actor.SelectSingleNode("name"); nameNode!.InnerXml = $""; } _logger.LogInformation("actor: {actor}", name); } } } private async Task GetTmdbTv( string path, int tmdbId) { var record = await _freeSql.Select() .Where(x => x.Type == TheMovieDbType.Tv) .Where(x => x.UniqueId == tmdbId.ToString()) .FirstAsync(); if (record != null) { return JObject.Parse(record.Json); } record = new TheMovieDbRecord { Type = TheMovieDbType.Tv, UniqueId = tmdbId.ToString() }; string tvUrl = "/3/tv/{0}?api_key=e28e1bc408db7adefc8bacce225c5085&language=zh-CN"; var requestUrl = string.Format(tvUrl, tmdbId); try { var response = await _apiClient.GetAsync(requestUrl); if (!response.IsSuccessStatusCode) { _logger.LogError("ChineseNfoRegistry.GetTmdbTv() 接口调用失败 {requestUrl} & {path} & {response.StatusCode}", requestUrl, path, response.StatusCode); return null; } var str = await response.Content.ReadAsStringAsync(); var json = JObject.Parse(str); record.Json = str; await _freeSql.Insert(record).ExecuteIdentityAsync(); _logger.LogInformation("ChineseNfoRegistry.GetTmdbTv() 接口调用, 休眠 1S"); await Task.Delay(1000); return json; } catch (Exception e) { _logger.LogError("ChineseNfoRegistry.GetTmdbTv() 接口调用失败 {requestUrl} & {path} \r {e}", requestUrl, path, e); return null; } } private async Task GetTmdbPerson( string path, int tmdbId) { var record = await _freeSql.Select() .Where(x => x.Type == TheMovieDbType.Person) .Where(x => x.UniqueId == tmdbId.ToString()) .FirstAsync(); if (record != null) { return JObject.Parse(record.Json); } record = new TheMovieDbRecord { Type = TheMovieDbType.Person, UniqueId = tmdbId.ToString(), Language = TheMovieDbLanguage.ZhCn }; var requestUrl = string.Format("/3/person/{0}?api_key=e28e1bc408db7adefc8bacce225c5085&language=zh-CN", tmdbId); try { var response = await _apiClient.GetAsync(requestUrl); if (!response.IsSuccessStatusCode) { _logger.LogError("ChineseNfoRegistry.GetTmdbPerson() 接口调用失败 {requestUrl} & {path} & {response.StatusCode}", requestUrl, path, response.StatusCode); return null; } var str = await response.Content.ReadAsStringAsync(); var json = JObject.Parse(str); record.Json = str; await _freeSql.Insert(record).ExecuteIdentityAsync(); _logger.LogInformation("ChineseNfoRegistry.GetTmdbPerson() 接口调用, 休眠 1S"); await Task.Delay(1000); return json; } catch (Exception e) { _logger.LogError("ChineseNfoRegistry.GetTmdbPerson() 接口调用失败 {requestUrl} & {path} \r {e}", requestUrl, path, e); return null; } } private async Task GetTmdbPersonSearch( string path, string name) { var record = await _freeSql.Select() .Where(x => x.Type == TheMovieDbType.Person) .Where(x => x.UniqueId == name) .FirstAsync(); if (record != null) { return JObject.Parse(record.Json); } record = new TheMovieDbRecord { Type = TheMovieDbType.Person, UniqueId = name }; var requestUrl = string.Format("/3/search/person?api_key=e28e1bc408db7adefc8bacce225c5085&language=zh-CN&query={0}", name); try { var response = await _apiClient.GetAsync(requestUrl); if (!response.IsSuccessStatusCode) { _logger.LogError("ChineseNfoRegistry.GetTmdbPersonSearch() 接口调用失败 {requestUrl} & {path} & {response.StatusCode}", requestUrl, path, response.StatusCode); return null; } var str = await response.Content.ReadAsStringAsync(); var json = JObject.Parse(str); var results = json["results"]; if (results != null) { foreach (var result in results.Cast()) { record.Json = result.ToString(); await _freeSql.Insert(record).ExecuteIdentityAsync(); _logger.LogInformation("ChineseNfoRegistry.GetTmdbPersonSearch() 接口调用, 休眠 1S"); await Task.Delay(1000); return result; } } return null; } catch (Exception e) { _logger.LogError("ChineseNfoRegistry.GetTmdbPersonSearch() 接口调用失败 {requestUrl} & {path} \r {e}", requestUrl, path, e); return null; } } private async Task GetTmdbImage( string path, string posterPath) { var requestUrl = string.Format("/t/p/w1280/{0}", posterPath); try { var response = await _imageClient.GetAsync(requestUrl); if (!response.IsSuccessStatusCode) { _logger.LogError("ChineseNfoRegistry.GetTmdbImage() 接口调用失败 {requestUrl} & {path} & {response.StatusCode}", requestUrl, path, response.StatusCode); return null; } return await response.Content.ReadAsByteArrayAsync(); } catch (Exception e) { _logger.LogError("ChineseNfoRegistry.GetTmdbImage() 接口调用失败 {requestUrl} & {path} \r {e}", requestUrl, path, e); return null; } } private async Task GetTmdbSeason(ChineseNfoContent ctn) { if (ctn.seasonNumber == 0 || string.IsNullOrEmpty(ctn.seasonNfoPath)) { _logger.LogError("seasonNfoPath is null or empty"); return null; } var uniqueId = $"{ctn.tvId}-{ctn.seasonNumber}"; var record = await _freeSql.Select() .Where(x => x.Type == TheMovieDbType.Season) .Where(x => x.UniqueId == uniqueId) .FirstAsync(); if (record != null) { return JObject.Parse(record.Json); } record = new TheMovieDbRecord { Type = TheMovieDbType.Season, UniqueId = uniqueId }; var requestUrl = string.Format("/3/tv/{0}/season/{1}?api_key=e28e1bc408db7adefc8bacce225c5085&language=zh-CN", ctn.tvId, ctn.seasonNumber); try { var response = await _apiClient.GetAsync(requestUrl); if (!response.IsSuccessStatusCode) { _logger.LogError("ChineseNfoRegistry.GetTmdbSeason() 接口调用失败 {requestUrl} & {path} & {response.StatusCode}", requestUrl, ctn.tvNfoPath, response.StatusCode); return null; } var str = await response.Content.ReadAsStringAsync(); record.Json = str; await _freeSql.Insert(record).ExecuteIdentityAsync(); _logger.LogInformation("ChineseNfoRegistry.GetTmdbSeason() 接口调用, 休眠 1S"); await Task.Delay(1000); return JObject.Parse(str); } catch (Exception e) { _logger.LogError("ChineseNfoRegistry.GetTmdbSeason() 接口调用失败 {requestUrl} & {path} \r {e}", requestUrl, ctn.tvNfoPath, e); return null; } } private async Task GetTmdbEpisode(ChineseNfoContent ctn) { var uniqueId = $"{ctn.tvId}-{ctn.seasonNumber}-{ctn.episodeNumber}"; var record = await _freeSql.Select() .Where(x => x.Type == TheMovieDbType.Episode) .Where(x => x.UniqueId == uniqueId) .FirstAsync(); if (record != null) { return JObject.Parse(record.Json); } record = new TheMovieDbRecord { Type = TheMovieDbType.Episode, UniqueId = uniqueId }; var requestUrl = string.Format("/3/tv/{0}/season/{1}/episode/{2}?api_key=e28e1bc408db7adefc8bacce225c5085&language=zh-CN", ctn.tvId, ctn.seasonNumber, ctn.episodeNumber); try { var response = await _apiClient.GetAsync(requestUrl); if (!response.IsSuccessStatusCode) { _logger.LogError("ChineseNfoRegistry.GetTmdbEpisode() 接口调用失败 {requestUrl} & {path} & {response.StatusCode}", requestUrl, ctn.episodeNfoPath, response.StatusCode); return null; } var str = await response.Content.ReadAsStringAsync(); record.Json = str; await _freeSql.Insert(record).ExecuteIdentityAsync(); _logger.LogInformation("ChineseNfoRegistry.GetTmdbEpisode() 接口调用, 休眠 1S"); await Task.Delay(1000); return JObject.Parse(str); } catch (Exception e) { _logger.LogError("ChineseNfoRegistry.GetTmdbEpisode() 接口调用失败 {requestUrl} & {path} \r {e}", requestUrl, ctn.episodeNfoPath, e); return null; } } } public class TheMovieDbRecord { [Column(IsPrimary = true, IsIdentity = true)] public long Id { get; set; } public TheMovieDbType Type { get; set; } public string UniqueId { get; set; } = string.Empty; public TheMovieDbLanguage Language { get; set; } [Column(DbType = "text")] public string Json { get; set; } = string.Empty; } public enum TheMovieDbType { Tv = 1, Season = 2, Episode = 3, Person = 4 } public enum TheMovieDbLanguage { ZhCn = 1, ZhTw = 2 } public class ChineseNfoContent { public int tvId { get; set; } public string tvNfoPath { get; set; } = string.Empty; public int seasonNumber { get; set; } public string seasonNfoPath { get; set; } = string.Empty; public int episodeNumber { get; set; } public string episodeNfoPath { get; set; } = string.Empty; }