This commit is contained in:
孙诚
2025-02-27 16:58:21 +08:00
commit 80ab8d76eb
40 changed files with 2482 additions and 0 deletions

23
src/Core/Command.cs Normal file
View File

@@ -0,0 +1,23 @@
using System.Net;
using System.Net.Http.Json;
namespace Core;
public static class Command
{
public static async Task<(HttpStatusCode responseCode, string responseStr)> ExecAsync(params string[] commands)
{
var execUrl = "http://192.168.31.14:45321/exec";
var client = new HttpClient();
var content = JsonContent.Create(new
{
Token = "4CeVaIQGbB4Qln9%V@Bh8bMYSpHIUV66",
Script = commands
});
var response = await client.PostAsync(execUrl, content);
return (response.StatusCode, await response.Content.ReadAsStringAsync());
}
}

15
src/Core/Core.csproj Normal file
View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
<PackageReference Include="FluentScheduler" Version="5.5.1"/>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
</ItemGroup>
</Project>

9
src/Core/Tools.cs Normal file
View File

@@ -0,0 +1,9 @@
namespace Core;
public static class Tools
{
public static long ToUnixTimeMilliseconds(this DateTime dateTime)
{
return new DateTimeOffset(dateTime).ToUnixTimeMilliseconds();
}
}

53
src/Core/WxNotify.cs Normal file
View File

@@ -0,0 +1,53 @@
using System.Text;
namespace Core;
public static class WxNotify
{
public static async Task SendDiskInfoAsync(string msg)
{
// 晚上11点到早上8点不通知输出到控制台
if (DateTime.Now.Hour >= 23 || DateTime.Now.Hour < 8)
{
Console.WriteLine("======================Skip WxNotify======================");
Console.WriteLine(msg);
Console.WriteLine("======================Skip WxNotify======================");
return;
}
var wxRebotUrl = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=96f9fa23-a959-4492-ac3a-7415fae19680";
var client = new HttpClient();
var requestBody =
"""
{
"msgtype": "markdown",
"markdown": {
"content": "{Msg}"
}
}
""";
// 转义
requestBody = requestBody.Replace("{Msg}", msg.Replace("\r\n", "\n"));
await client.PostAsync(wxRebotUrl, new StringContent(requestBody, Encoding.UTF8, "application/json"));
}
public static async Task SendCommonAsync(string msg)
{
var wxRebotUrl = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=96f9fa23-a959-4492-ac3a-7415fae19680";
var client = new HttpClient();
var requestBody =
"""
{
"msgtype": "markdown",
"markdown": {
"content": "{Msg}"
}
}
""";
// 转义
requestBody = requestBody.Replace("{Msg}", msg.Replace("\r\n", "\n"));
await client.PostAsync(wxRebotUrl, new StringContent(requestBody, Encoding.UTF8, "application/json"));
}
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,6 @@
namespace Interface.Jobs;
public interface IChineseNfoRegistry
{
void Job(bool ignoreLocked, bool ignoreCompleted);
}

View File

@@ -0,0 +1,6 @@
namespace Interface.Jobs;
public interface IDiskActionMonitorRegistry
{
void Job();
}

View File

@@ -0,0 +1,6 @@
namespace Interface.Jobs;
public interface IHealthyTaskRegistry
{
void Job();
}

View File

@@ -0,0 +1,6 @@
namespace Interface.Jobs;
public interface ILogTotalNotifyJobRegistry
{
void Job();
}

View File

@@ -0,0 +1,6 @@
namespace Interface.Jobs;
public interface IRSyncTaskRegistry
{
void Job();
}

View File

@@ -0,0 +1,8 @@
namespace Interface.Jobs;
public interface IShutdownRegistry
{
void Job();
Task CancelShutdown();
}

View File

@@ -0,0 +1,6 @@
namespace Interface.Jobs;
public interface IStartupRegistry
{
void Job();
}

View File

@@ -0,0 +1,245 @@
using System.Net;
using System.Text.Json.Nodes;
using System.Xml;
using FluentScheduler;
using Interface.Jobs;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace Service.Jobs;
public class ChineseNfoRegistry : Registry, IChineseNfoRegistry
{
private readonly IConfiguration _configuration;
private readonly ILogger<ChineseNfoRegistry> _logger;
private readonly HttpClient _client;
public ChineseNfoRegistry(IConfiguration configuration, ILogger<ChineseNfoRegistry> logger)
{
_configuration = configuration;
_logger = logger;
var proxyAddress = _configuration["ChineseNfo:HttpProxy"];
var httpClientHandler = new HttpClientHandler
{
Proxy = string.IsNullOrEmpty(proxyAddress) ? null : new WebProxy(proxyAddress, false),
UseProxy = !string.IsNullOrEmpty(proxyAddress)
};
_client = new HttpClient(httpClientHandler);
_client.BaseAddress = new Uri("https://api.themoviedb.org");
Schedule(() => Job(true, true)).ToRunEvery(1).Days();
}
public async void Job(bool ignoreLocked, bool ignoreCompleted)
{
try
{
await JobExecute(ignoreLocked, ignoreCompleted);
}
catch (Exception e)
{
_logger.LogError(e, "ChineseNfoRegistry.Job() error");
}
}
private async Task JobExecute(bool ignoreLocked, bool ignoreCompleted)
{
// 读取环境变量 tv folder
var tvFolder = _configuration["ChineseNfo:TvFolder"];
if (string.IsNullOrEmpty(tvFolder))
{
Console.WriteLine("请设置环境变量 tv_folder");
return;
}
// 扫描 tvshow.nfo 文件
var tvShowFiles = Directory.GetFiles(tvFolder, "tvshow.nfo", SearchOption.AllDirectories);
foreach (var tvShowFile in tvShowFiles)
{
var nfoContent = File.ReadAllText(tvShowFile);
// 读取 使用XML解析器 读取 <uniqueid type="tmdb">60059</uniqueid>
var tvXml = new XmlDocument();
tvXml.LoadXml(nfoContent);
var uniqueIdNode = tvXml.SelectSingleNode("//uniqueid[@type='tmdb']");
if (uniqueIdNode == null)
{
Console.WriteLine($"{tvShowFile} & 未找到 tmdb id");
continue;
}
if (!int.TryParse(uniqueIdNode.InnerText, out var tmdbId))
{
Console.WriteLine($"{tvShowFile} & tmdb id 不是数字");
continue;
}
// 获取 tvShowFile 的文件夹
var folderPath = Path.GetDirectoryName(tvShowFile);
if (folderPath == null)
{
Console.WriteLine($"{tvShowFile} & 未找到文件夹");
continue;
}
// 扫描文件夹下其他 nfo 文件
var seasonFiles = Directory.GetFiles(folderPath, "*.nfo", SearchOption.AllDirectories);
seasonFiles = seasonFiles.Where(x => !x.EndsWith("tvshow.nfo")).ToArray();
foreach (var seasonFile in seasonFiles)
{
var episodeContent = File.ReadAllText(seasonFile);
var episodeXml = new XmlDocument();
episodeXml.LoadXml(episodeContent);
// 判断有无 completed 节点
var completedNode = episodeXml.SelectSingleNode("//completed");
if (completedNode != null && ignoreCompleted == false)
{
Console.WriteLine($"{seasonFile} & 已完成");
continue;
}
// 判断 locked == true
var lockedNode = episodeXml.SelectSingleNode("//lockdata");
if (lockedNode != null && lockedNode.InnerText == "true" && ignoreLocked == false)
{
Console.WriteLine($"{seasonFile} & 已锁定");
continue;
}
// 读取 <season>1</season>
var seasonNode = episodeXml.SelectSingleNode("//season");
if (seasonNode == null)
{
Console.WriteLine($"{seasonFile} & 未找到 season");
continue;
}
// 读取 <episode>1</episode>
var episodeNode = episodeXml.SelectSingleNode("//episode");
if (episodeNode == null)
{
Console.WriteLine($"{seasonFile} & 未找到 episode");
continue;
}
if (!int.TryParse(seasonNode.InnerText, out var season))
{
Console.WriteLine($"{seasonFile} & season 不是数字");
continue;
}
if (!int.TryParse(episodeNode.InnerText, out var episode))
{
Console.WriteLine($"{seasonFile} & episode 不是数字");
continue;
}
// 设置 title
var titleNode = episodeXml.SelectSingleNode("//title");
if (titleNode == null)
{
Console.WriteLine($"{seasonFile} & 未找到 title");
continue;
}
var json = await GetTmdbEpisode(tmdbId, season, episode);
if (json == null)
{
continue;
}
var title = json["name"]?.ToString();
var overview = json["overview"]?.ToString();
var isUpdate = false;
if (!string.IsNullOrEmpty(title))
{
title = $"<![CDATA[{title}]]>";
if (titleNode.InnerXml != title)
{
isUpdate = true;
// 写入且不转义
titleNode.InnerXml = title;
}
}
var plotNode = episodeXml.SelectSingleNode("//plot");
if (!string.IsNullOrEmpty(overview) && plotNode != null)
{
overview = $"<![CDATA[{overview}]]>";
if (plotNode.InnerXml != overview)
{
isUpdate = true;
plotNode.InnerXml = overview;
}
}
if (isUpdate)
{
// 添加一个已完成节点
if (completedNode == null)
{
episodeXml.DocumentElement?.AppendChild(episodeXml.CreateElement("completed"));
}
// 锁定
if (lockedNode == null)
{
lockedNode = episodeXml.CreateElement("lockdata");
episodeXml.DocumentElement?.AppendChild(lockedNode);
lockedNode.InnerText = "true";
}
else
{
lockedNode.InnerText = "true";
}
episodeXml.Save(seasonFile);
Console.WriteLine($"{seasonFile} & {title} & {overview}");
}
else
{
Console.WriteLine($"{seasonFile} & 无更新");
}
await Task.Delay(10000);
}
}
}
private async Task<JsonObject?> GetTmdbEpisode(int tmdbId, int season, int episode)
{
try
{
const string episodeUrl = "/3/tv/{0}/season/{1}/episode/{2}?api_key=e28e1bc408db7adefc8bacce225c5085&language=zh-CN";
var requestUrl = string.Format(episodeUrl, tmdbId, season, episode);
var response = await _client.GetAsync(requestUrl);
if (!response.IsSuccessStatusCode)
{
Console.WriteLine($"{requestUrl} & {response.StatusCode}");
return null;
}
var content = await response.Content.ReadAsStringAsync();
var json = JsonNode.Parse(content);
return json as JsonObject;
}
catch (Exception e)
{
Console.WriteLine($"{tmdbId} & {season} & {episode} & {e.Message}");
return null;
}
}
}

View File

@@ -0,0 +1,174 @@
using Core;
using FluentScheduler;
using Interface.Jobs;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace Service.Jobs;
public enum DiskNotifyType
{
Action,
Sleep
}
public class DiskActionMonitorRegistry : Registry, IDiskActionMonitorRegistry
{
private readonly IConfiguration _configuration;
private static Dictionary<string, int> _lastReadCount = new();
private static Dictionary<string, int> _lastWriteCount = new();
private readonly ILogger<DiskActionMonitorRegistry> _logger;
private static readonly Dictionary<string, List<(DateTime, DiskNotifyType)>> LatestNotifyTimes = new();
private static readonly Dictionary<string, DateTime> LastChangedTimes = new();
public DiskActionMonitorRegistry(IConfiguration configuration, ILogger<DiskActionMonitorRegistry> logger)
{
_configuration = configuration;
_logger = logger;
Schedule(Job).ToRunEvery(5).Seconds();
}
public async void Job()
{
try
{
await JobExecute();
}
catch (Exception e)
{
_logger.LogError(e, "DiskActionMonitorRegistry.Job() error");
}
}
private async Task JobExecute()
{
var filePath = _configuration["DiskActionMonitor:FilePath"] ?? throw new ArgumentNullException($"DiskActionMonitor:FilePath");
var disks = _configuration
.GetSection("DiskActionMonitor:Disks")
.GetChildren()
.Select(x => x.Value);
foreach (var disk in disks)
{
if (string.IsNullOrEmpty(disk))
{
continue;
}
if (!_lastReadCount.ContainsKey(disk))
{
_lastReadCount.Add(disk, 0);
}
if (!_lastWriteCount.ContainsKey(disk))
{
_lastWriteCount.Add(disk, 0);
}
if (!LatestNotifyTimes.ContainsKey(disk))
{
LatestNotifyTimes.Add(disk, new());
}
if (!LastChangedTimes.ContainsKey(disk))
{
LastChangedTimes.Add(disk, DateTime.Now);
}
var content = await File.ReadAllTextAsync(string.Format(filePath, disk));
var cols = content.Split(" ", StringSplitOptions.RemoveEmptyEntries);
var readCount = TryGetByIndex(cols, 2);
var writeCount = TryGetByIndex(cols, 6);
if (!int.TryParse(readCount, out var read) || !int.TryParse(writeCount, out var write))
{
continue; // 读取失败
}
var readDiff = read - _lastReadCount[disk];
var writeDiff = write - _lastWriteCount[disk];
_lastReadCount[disk] = read;
_lastWriteCount[disk] = write;
if (readDiff > 0 || writeDiff > 0)
{
_logger.LogInformation($"[{disk}] ReadDiff: {readDiff}, WriteDiff: {writeDiff}");
}
if (readDiff > 10 || writeDiff > 10)
{
// 上一次通知的是休眠或者没有通知 发现有变换通知变化
if (LatestNotifyTimes[disk].Any() == false
|| LatestNotifyTimes[disk].Last().Item2 == DiskNotifyType.Sleep)
{
LatestNotifyTimes[disk].Add((DateTime.Now, DiskNotifyType.Action));
await WxNotify.SendDiskInfoAsync(@$"
## 磁盘 <font color='info'> {disk} </font> 刚发生读写操作 🙋
> 当前累计读: <font color='warning'> {GetEasyReadNumber(read)} </font>、新增读: <font color='warning'> {GetEasyReadNumber(readDiff)} </font>
> 当前累计写: <font color='warning'> {GetEasyReadNumber(write)} </font>、新增写: <font color='warning'> {GetEasyReadNumber(writeDiff)} </font>
当前时间: <font color='comment'> {DateTime.Now:yyyy-M-d H:m:s} </font>");
}
else if (DateTime.Now - LatestNotifyTimes[disk].Last().Item1 > TimeSpan.FromHours(1))
{
LatestNotifyTimes[disk].Add((DateTime.Now, DiskNotifyType.Action));
await WxNotify.SendDiskInfoAsync(@$"
## 磁盘 <font color='info'> {disk} </font> 连续运行超一小时 👨‍💻
> 当前累计读: <font color='warning'> {GetEasyReadNumber(read)} </font>
> 当前累计写: <font color='warning'> {GetEasyReadNumber(write)} </font>
当前时间: <font color='comment'> {DateTime.Now:yyyy-M-d H:m:s} </font>");
}
_lastReadCount[disk] = read;
_lastWriteCount[disk] = write;
LastChangedTimes[disk] = DateTime.Now;
continue;
}
// 没有变化 上一次是启动通知且超过10分钟通知休眠
if (LatestNotifyTimes[disk].Any() == false
|| (LatestNotifyTimes[disk].Last().Item2 == DiskNotifyType.Action
&& DateTime.Now - LastChangedTimes[disk] > TimeSpan.FromMinutes(10)))
{
LatestNotifyTimes[disk].Add((DateTime.Now, DiskNotifyType.Sleep));
await WxNotify.SendDiskInfoAsync(@$"
## 磁盘 <font color='info'> {disk} </font> 长时间无读写变化 😪
> 上一次读写时间:{LatestNotifyTimes[disk].Last(x => x.Item2 == DiskNotifyType.Action).Item1:yyyy-M-d H:m:s}
> 当前累计读: <font color='warning'> {GetEasyReadNumber(read)} </font>
> 当前累计写: <font color='warning'> {GetEasyReadNumber(write)} </font>
当前时间: <font color='comment'> {DateTime.Now:yyyy-M-d H:m:s} </font>");
}
}
}
private string TryGetByIndex(string[] arr, int index) => index < arr.Length ? arr[index] : string.Empty;
private string GetEasyReadNumber(int number)
{
if (number < 10000)
{
return number.ToString();
}
if (number < 100000000)
{
return $"{number / 10000M:0.##} 万";
}
return $"{number / 100000000M:0.##} 亿";
}
}

View File

@@ -0,0 +1,95 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using Core;
using FluentScheduler;
using Interface.Jobs;
using Microsoft.Extensions.Configuration;
namespace Service.Jobs;
public class HealthyTaskRegistry : Registry, IHealthyTaskRegistry
{
private readonly IConfiguration _configuration;
public HealthyTaskRegistry(IConfiguration configuration)
{
_configuration = configuration;
Schedule(Job).ToRunNow().AndEvery(2).Minutes();
}
public async void Job()
{
try
{
await JobExecute();
}
catch (Exception e)
{
Console.WriteLine(e);
await WxNotify.SendCommonAsync($"HealthyTaskRegistry.Job() error: {e}");
}
}
private async Task JobExecute()
{
// 获取 配置 是个 KV 集合
var configs = _configuration.GetSection("HealthyTasks");
// 遍历配置
foreach (var item in configs.GetChildren())
{
// 获取配置的 containerName 和 url
var containerName = item["ContainerName"];
var url = item["Url"];
// 执行
await ExecuteItem(containerName!, url!);
}
}
private async Task ExecuteItem(string containerName, string url)
{
try
{
var client = new HttpClient();
// basic auth
string authInfo = Convert.ToBase64String(Encoding.ASCII.GetBytes("suncheng:SCsunch940622"));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authInfo);
var response = await client.GetAsync(url);
if (response.StatusCode != HttpStatusCode.OK)
{
throw new Exception($"StatusCode: {response.StatusCode}");
}
Console.WriteLine($"ContainerName: {containerName}, Url: {url}, StatusCode: {response.StatusCode}");
}
catch (Exception e)
{
Console.WriteLine(e);
await WxNotify.SendCommonAsync($"ExecuteItem {containerName} error: {e.Message}");
await ExecuteReboot(containerName);
}
}
private async Task ExecuteReboot(string containerName)
{
var command = $"docker restart {containerName}";
var (responseCode, message) = await Command.ExecAsync(command);
if (responseCode != HttpStatusCode.OK)
{
await WxNotify.SendCommonAsync($"ExecuteReboot {containerName} error: {responseCode} message: {message}");
}
else
{
await WxNotify.SendCommonAsync($"ContainerName: {containerName}");
}
}
}

View File

@@ -0,0 +1,106 @@
using System.Text;
using Core;
using FluentScheduler;
using Interface.Jobs;
using Microsoft.Extensions.Configuration;
using Newtonsoft.Json.Linq;
namespace Service.Jobs;
public class LogTotalNotifyJobRegistry : Registry, ILogTotalNotifyJobRegistry
{
private readonly IConfiguration _configuration;
public LogTotalNotifyJobRegistry(IConfiguration configuration)
{
_configuration = configuration;
Schedule(Job).ToRunEvery(1).Days().At(8, 30);
}
public async void Job()
{
try
{
// await JobExecute();
}
catch (Exception e)
{
Console.WriteLine(e);
await WxNotify.SendCommonAsync($"LogTotalNotifyJobRegistry.Job() error: {e}");
}
}
private async Task JobExecute()
{
var client = new HttpClient();
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {_configuration["Grafana:Token"]}");
var requestBody =
"""
{
"queries": [
{
"datasource": {
"type": "loki",
"uid": "edf5cwf6n6i2oe"
},
"editorMode": "builder",
"expr": "sum by(container_name) (count_over_time({compose_project=~\"dockers|immich|nasrobot|elasticsearch|webui-docker\"} [1h]))",
"queryType": "range",
"refId": "A",
"datasourceId": 2,
"intervalMs": 3600000
}
],
"from": "1708963200000",
"to": "1709049600000"
}
""";
requestBody = requestBody.Replace("1708963200000", DateTime.Today.AddDays(-2).ToUnixTimeMilliseconds().ToString());
requestBody = requestBody.Replace("1709049600000", DateTime.Today.AddDays(-1).ToUnixTimeMilliseconds().ToString());
var content = new StringContent(requestBody, Encoding.UTF8, "application/json");
var response = await client.PostAsync(_configuration["Grafana:LokiUrl"], content);
var result = await response.Content.ReadAsStringAsync();
var jObject = JObject.Parse(result);
var frames = jObject["results"]?["A"]?["frames"];
if (frames == null)
{
throw new Exception("frames is null");
}
var msg = new StringBuilder();
msg.AppendLine($"## {DateTime.Today.AddDays(-1):yyyy-MM-dd}日志明细如下:");
var total = 0;
var kv = new Dictionary<string, int>();
foreach (var item in frames)
{
var name = item["schema"]?["fields"]?.LastOrDefault()?["labels"]?["container_name"]?.ToString();
if (string.IsNullOrEmpty(name))
{
continue;
}
var values = item["data"]?["values"]?.LastOrDefault()?.ToObject<int[]>();
var value = values?.Sum() ?? 0;
total += value;
kv[name] = value;
}
foreach (var (key, value) in kv.OrderByDescending(x => x.Value))
{
msg.AppendLine($"<font color='info'>**{key}**</font>: <font color='warning'>{value}</font>");
}
msg.AppendLine("");
msg.AppendLine($"> **总计**: <font color='warning'>{total}</font>");
await WxNotify.SendCommonAsync(msg.ToString());
}
}

View File

@@ -0,0 +1,69 @@
using System.Diagnostics;
using System.Net.Http.Json;
using Core;
using FluentScheduler;
using Interface.Jobs;
using Microsoft.Extensions.Configuration;
namespace Service.Jobs;
public class RSyncTaskRegistry : Registry, IRSyncTaskRegistry
{
private readonly IConfiguration _configuration;
public RSyncTaskRegistry(IConfiguration configuration)
{
_configuration = configuration;
Schedule(Job).ToRunEvery(1).Days().At(10, 0);
}
public async void Job()
{
try
{
// await JobExecute();
}
catch (Exception e)
{
Console.WriteLine(e);
await WxNotify.SendCommonAsync($"RSyncTaskRegistry.Job() error: {e}");
}
}
private async Task JobExecute()
{
var config = _configuration.GetSection("SyncTask");
foreach (var item in config.GetSection("SyncPaths").GetChildren())
{
var source = Path.Combine(config["SourceRoot"]!, item["Source"]!);
var isDeleteAfter = item["DeleteAfter"] == "true";
await ExecuteItem(source, config["TargetRoot"]!, item["Target"]!, isDeleteAfter);
}
}
private async Task ExecuteItem(string source, string remote, string destination, bool isDeleteAfter)
{
var logName = $"rclone_output_{destination.Replace("/", "_")}{DateTime.Now:yyMMddHHmm}.log";
var commands = new[]
{
$"rclone sync " +
$"{source} " +
$"{remote}:{destination} " +
$"--fast-list " +
$"--size-only " +
$"{(isDeleteAfter ? "--delete-after" : "--delete-excluded")} " +
$"> /wd/logs/{logName} 2>&1"
};
await WxNotify.SendCommonAsync($@"RSyncTaskRegistry.ExecuteItem()
`{commands[0]}`
> {DateTime.Now:yyyy-MM-dd HH:mm:ss}
");
_ = Command.ExecAsync(commands);
}
}

View File

@@ -0,0 +1,61 @@
using System.Net;
using Core;
using FluentScheduler;
using Interface.Jobs;
namespace Service.Jobs;
public class ShutdownRegistry : Registry, IShutdownRegistry
{
public ShutdownRegistry()
{
Schedule(Job).ToRunEvery(1).Days().At(23, 30);
}
public async void Job()
{
try
{
await JobExecute();
}
catch (Exception e)
{
Console.WriteLine(e);
await WxNotify.SendCommonAsync($"ShutdownRegistry.Job() error: {e}");
}
}
private async Task JobExecute()
{
var command = "shutdown 9";
var (responseCode, msg) = await Command.ExecAsync(command);
var wxMsg = $@"
# 定时关机指令执行
> 执行结果:{responseCode}
> 执行消息:{msg}
如需取消关机请点击:[取消关机](http://suncheng.online:35642/api/JobTrigger/CancelShutdown)
";
await WxNotify.SendCommonAsync(wxMsg);
if (responseCode == HttpStatusCode.OK)
{
// 每三分钟提醒一次
_ = Task.Delay(3 * 60 * 1000).ContinueWith(async _ => await WxNotify.SendCommonAsync(wxMsg));
}
}
public async Task CancelShutdown()
{
var command = "shutdown -c";
var (responseCode, _) = await Command.ExecAsync(command);
await WxNotify.SendCommonAsync($"取消关机指令执行完成 {responseCode}");
}
}

View File

@@ -0,0 +1,46 @@
using System.Net;
using Core;
using FluentScheduler;
using Interface.Jobs;
namespace Service.Jobs;
public class StartupRegistry : Registry, IStartupRegistry
{
public StartupRegistry()
{
Schedule(Job).ToRunNow();
}
public async void Job()
{
try
{
// await JobExecute();
}
catch (Exception e)
{
Console.WriteLine(e);
await WxNotify.SendCommonAsync($"StartupRegistry.Job() error: {e}");
}
}
private async Task JobExecute()
{
var commands = new[]
{
"chmod 777 /var/run/docker.sock",
};
var (responseCode, _) = await Command.ExecAsync(commands);
if (responseCode != HttpStatusCode.OK)
{
await WxNotify.SendCommonAsync($"StartupRegistry.Job() error: {responseCode}");
}
else
{
await WxNotify.SendCommonAsync("StartupRegistry.Job() success");
}
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" />
<ProjectReference Include="..\Interface\Interface.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Mvc;
namespace WebApi.Controllers;
[ApiController]
[Route("/api/[controller]/[action]")]
public class BaseController : ControllerBase
{
}

View File

@@ -0,0 +1,101 @@
using Interface.Jobs;
using Microsoft.AspNetCore.Mvc;
namespace WebApi.Controllers;
public class JobTriggerController : BaseController
{
private readonly ILogTotalNotifyJobRegistry _logTotalNotifyJobRegistry;
private readonly IDiskActionMonitorRegistry _diskActionMonitorRegistry;
private readonly IHealthyTaskRegistry _healthyTaskRegistry;
private readonly IRSyncTaskRegistry _rSyncTaskRegistry;
private readonly IStartupRegistry _startupRegistry;
private readonly IShutdownRegistry _shutdownRegistry;
private readonly IChineseNfoRegistry _chineseNfoRegistry;
/// <summary>
/// ctor
/// </summary>
public JobTriggerController(
ILogTotalNotifyJobRegistry logTotalNotifyJobRegistry,
IDiskActionMonitorRegistry diskActionMonitorRegistry,
IHealthyTaskRegistry healthyTaskRegistry,
IRSyncTaskRegistry rSyncTaskRegistry,
IStartupRegistry startupRegistry,
IShutdownRegistry shutdownRegistry,
IChineseNfoRegistry chineseNfoRegistry)
{
_logTotalNotifyJobRegistry = logTotalNotifyJobRegistry;
_diskActionMonitorRegistry = diskActionMonitorRegistry;
_healthyTaskRegistry = healthyTaskRegistry;
_rSyncTaskRegistry = rSyncTaskRegistry;
_startupRegistry = startupRegistry;
_shutdownRegistry = shutdownRegistry;
_chineseNfoRegistry = chineseNfoRegistry;
}
[HttpGet]
public string LogTotalNotify()
{
_logTotalNotifyJobRegistry.Job();
return "OK";
}
[HttpGet]
public string DiskActionMonitor()
{
_diskActionMonitorRegistry.Job();
return "OK";
}
[HttpGet]
public string HealthyTask()
{
_healthyTaskRegistry.Job();
return "OK";
}
[HttpGet]
public string RSyncTask()
{
_rSyncTaskRegistry.Job();
return "OK";
}
[HttpGet]
public string Startup()
{
_startupRegistry.Job();
return "OK";
}
[HttpGet]
public string Shutdown()
{
_shutdownRegistry.Job();
return "OK";
}
[HttpGet]
public string CancelShutdown()
{
_shutdownRegistry.CancelShutdown();
return "OK";
}
[HttpGet]
public string ConvertChineseNfo(bool ignoreLocked = false, bool ignoreCompleted = false)
{
_chineseNfoRegistry.Job(ignoreLocked, ignoreCompleted);
return "OK";
}
}

View File

@@ -0,0 +1,319 @@
using Core;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace WebApi.Controllers;
public class NotifyController : BaseController
{
public NotifyController()
{
}
[HttpPost]
public async Task<string> Radarr()
{
var body = Request.Body;
using var reader = new StreamReader(body);
var text = await reader.ReadToEndAsync();
Console.WriteLine(text);
/*
{
"movie": {
"id": 1,
"title": "Test Title",
"year": 1970,
"releaseDate": "1970-01-01",
"folderPath": "C:\\testpath",
"tmdbId": 0,
"tags": [
"test-tag"
]
},
"remoteMovie": {
"tmdbId": 1234,
"imdbId": "5678",
"title": "Test title",
"year": 1970
},
"release": {
"quality": "Test Quality",
"qualityVersion": 1,
"releaseGroup": "Test Group",
"releaseTitle": "Test Title",
"indexer": "Test Indexer",
"size": 9999999,
"customFormatScore": 0
},
"eventType": "Test",
"instanceName": "Radarr",
"applicationUrl": ""
}
*/
var jsonObj = JsonConvert.DeserializeObject<JObject>(text);
var notify = @$"# Radarr通知
> 时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}
电影:<font color='info'> {jsonObj!["movie"]!["title"]}({jsonObj["movie"]!["releaseDate"]}) </font>
<font color='info'> {string.Join(",", jsonObj["movie"]!["tags"]?.ToObject<string[]>() ?? Array.Empty<string>())} </font>
<font color='info'> {jsonObj["eventType"]} </font>
";
var file = jsonObj["release"] ?? jsonObj["movieFile"];
if (file != null)
{
var size = file["size"]?.ToObject<decimal>() ?? 0M;
var gb = (size / 1024M / 1024M / 1024M).ToString("0.##");
notify += @$"
索引器:<font color='info'> {file["indexer"]} </font>
<font color='info'> {file["releaseGroup"]}({file["quality"]}) </font>
<font color='info'> {file["releaseTitle"] ?? file["relativePath"]} </font>
<font color='info'> {gb}GB </font>";
}
await WxNotify.SendCommonAsync(notify);
return "OK";
}
[HttpPost]
public async Task<string> Sonarr()
{
var body = Request.Body;
using var reader = new StreamReader(body);
var text = await reader.ReadToEndAsync();
Console.WriteLine(text);
/*
{
"series": {
"id": 86,
"title": "Demon Slayer: Kimetsu no Yaiba",
"titleSlug": "demon-slayer-kimetsu-no-yaiba",
"path": "/data/anime/Demon Slayer - Kimetsu no Yaiba",
"tvdbId": 348545,
"tvMazeId": 41469,
"tmdbId": 85937,
"imdbId": "tt9335498",
"type": "anime",
"year": 2019,
"genres": [
"Action",
"Adventure",
"Animation",
"Anime",
"Drama",
"Fantasy",
"Thriller"
],
"images": [
{
"coverType": "banner",
"url": "/MediaCover/86/banner.jpg?lastWrite=638509998968281535",
"remoteUrl": "https://artworks.thetvdb.com/banners/graphical/5ccd960cc3aa0.jpg"
},
{
"coverType": "poster",
"url": "/MediaCover/86/poster.jpg?lastWrite=638509998969561521",
"remoteUrl": "https://artworks.thetvdb.com/banners/v4/series/348545/posters/60908d475f49a.jpg"
},
{
"coverType": "fanart",
"url": "/MediaCover/86/fanart.jpg?lastWrite=638509998971801497",
"remoteUrl": "https://artworks.thetvdb.com/banners/fanart/original/5c93cbb2b60b6.jpg"
},
{
"coverType": "clearlogo",
"url": "/MediaCover/86/clearlogo.png?lastWrite=638509998972921485",
"remoteUrl": "https://artworks.thetvdb.com/banners/v4/series/348545/clearlogo/611c7fa8222d6.png"
}
],
"tags": [
"anime"
]
},
"episodes": [
{
"id": 2716,
"episodeNumber": 2,
"seasonNumber": 5,
"title": "Water Hashira Giyu Tomioka's Pain",
"overview": "Kagaya's Kasugai Crow suddenly appears in front of Tamayo and invites her to the Demon Slayer headquarters — even though she is a demon. Meanwhile, Tanjiro, who is recovering at the Butterfly Mansion, receives a letter from Kagaya...",
"airDate": "2024-05-19",
"airDateUtc": "2024-05-19T14:15:00Z",
"seriesId": 86,
"tvdbId": 10445764
}
],
"release": {
"quality": "WEBDL-1080p",
"qualityVersion": 1,
"releaseGroup": "ToonsHub",
"releaseTitle": "[ToonsHub] Demon Slayer Kimetsu no Yaiba S05E02 1080p CR WEB-DL AAC2.0 x264 (Multi-Subs)",
"indexer": "Knaben ",
"size": 1395864320,
"customFormatScore": 0,
"customFormats": []
},
"downloadClient": "aria2",
"downloadClientType": "Aria2",
"downloadId": "915718C3A8A5B15BD3C32A2B05885953D96AFADD",
"customFormatInfo": {
"customFormats": [],
"customFormatScore": 0
},
"eventType": "Grab",
"instanceName": "Sonarr",
"applicationUrl": ""
}
--------------------------
{
"series": {
"id": 86,
"title": "Demon Slayer: Kimetsu no Yaiba",
"titleSlug": "demon-slayer-kimetsu-no-yaiba",
"path": "/data/anime/Demon Slayer - Kimetsu no Yaiba",
"tvdbId": 348545,
"tvMazeId": 41469,
"tmdbId": 85937,
"imdbId": "tt9335498",
"type": "anime",
"year": 2019,
"genres": [
"Action",
"Adventure",
"Animation",
"Anime",
"Drama",
"Fantasy",
"Thriller"
],
"images": [
{
"coverType": "banner",
"url": "/MediaCover/86/banner.jpg?lastWrite=638509998968281535",
"remoteUrl": "https://artworks.thetvdb.com/banners/graphical/5ccd960cc3aa0.jpg"
},
{
"coverType": "poster",
"url": "/MediaCover/86/poster.jpg?lastWrite=638509998969561521",
"remoteUrl": "https://artworks.thetvdb.com/banners/v4/series/348545/posters/60908d475f49a.jpg"
},
{
"coverType": "fanart",
"url": "/MediaCover/86/fanart.jpg?lastWrite=638509998971801497",
"remoteUrl": "https://artworks.thetvdb.com/banners/fanart/original/5c93cbb2b60b6.jpg"
},
{
"coverType": "clearlogo",
"url": "/MediaCover/86/clearlogo.png?lastWrite=638509998972921485",
"remoteUrl": "https://artworks.thetvdb.com/banners/v4/series/348545/clearlogo/611c7fa8222d6.png"
}
],
"tags": [
"anime"
]
},
"episodes": [
{
"id": 2716,
"episodeNumber": 2,
"seasonNumber": 5,
"title": "Water Hashira Giyu Tomioka's Pain",
"overview": "Kagaya's Kasugai Crow suddenly appears in front of Tamayo and invites her to the Demon Slayer headquarters — even though she is a demon. Meanwhile, Tanjiro, who is recovering at the Butterfly Mansion, receives a letter from Kagaya...",
"airDate": "2024-05-19",
"airDateUtc": "2024-05-19T14:15:00Z",
"seriesId": 86,
"tvdbId": 10445764
}
],
"downloadInfo": {
"quality": "WEBDL-1080p",
"qualityVersion": 1,
"title": "Demon.Slayer.Kimetsu.no.Yaiba.S57E02.Water.Hashira.Giyu.Tomiokas.Pain.1080p.CR.WEB-DL.JPN.AAC2.0.H.264.MSubs-ToonsHub.mkv",
"size": 1446462408
},
"downloadClient": "aria2",
"downloadClientType": "Aria2",
"downloadId": "915718C3A8A5B15BD3C32A2B05885953D96AFADD",
"downloadStatus": "Warning",
"downloadStatusMessages": [
{
"title": "One or more episodes expected in this release were not imported or missing from the release",
"messages": []
},
{
"title": "Demon.Slayer.Kimetsu.no.Yaiba.S57E02.Water.Hashira.Giyu.Tomiokas.Pain.1080p.CR.WEB-DL.JPN.AAC2.0.H.264.MSubs-ToonsHub.mkv",
"messages": [
"Invalid season or episode"
]
}
],
"customFormatInfo": {
"customFormats": [],
"customFormatScore": 0
},
"release": {
"releaseTitle": "[ToonsHub] Demon Slayer Kimetsu no Yaiba S05E02 1080p CR WEB-DL AAC2.0 x264 (Multi-Subs)",
"indexer": "Knaben ",
"size": 1395864320
},
"eventType": "ManualInteractionRequired",
"instanceName": "Sonarr",
"applicationUrl": ""
}
*/
var jsonObj = JsonConvert.DeserializeObject<JObject>(text);
var notify = @$"# Sonarr通知
> 时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}
剧集:<font color='info'> {jsonObj!["series"]!["title"]} </font>
<font color='info'> {string.Join(",", jsonObj["series"]!["tags"]?.ToObject<string[]>() ?? Array.Empty<string>())} </font>
<font color='info'> {jsonObj["eventType"]} </font>
";
var episodes = jsonObj["episodes"];
if (episodes != null)
{
foreach (var item in episodes)
{
notify += @$"
集数:<font color='info'> 第{item["seasonNumber"]}季-第{item["episodeNumber"]}集 </font>";
}
}
if (jsonObj["release"] != null)
{
var gb = (jsonObj["release"]!.ToObject<decimal>() / 1024M / 1024M / 1024M).ToString("0.##");
notify += @$"
索引器:<font color='info'> {jsonObj["release"]!["indexer"]} </font>
<font color='info'> {jsonObj["release"]!["releaseGroup"]}({jsonObj["release"]!["quality"] ?? jsonObj["downloadInfo"]!["quality"]}) </font>
<font color='info'> {jsonObj["release"]!["releaseTitle"]} </font>
<font color='info'> {gb}GB </font>
";
}
await WxNotify.SendCommonAsync(notify);
return "OK";
}
}

View File

@@ -0,0 +1,34 @@
using Core;
using Microsoft.AspNetCore.Mvc;
namespace WebApi.Controllers;
public class XiaoController : BaseController
{
private readonly ILogger<XiaoController> _logger;
public XiaoController(ILogger<XiaoController> logger)
{
_logger = logger;
}
[HttpGet]
public async Task<string> GotoHomeGame()
{
await WxNotify.SendCommonAsync("接收到启动游戏机指令");
var commands = new[]
{
"grub-reboot 2",
"reboot"
};
_ = Task.Run(async () =>
{
await Task.Delay(3000);
_ = await Command.ExecAsync(commands);
});
return "OK";
}
}

51
src/WebApi/Program.cs Normal file
View File

@@ -0,0 +1,51 @@
using System.Reflection;
using FluentScheduler;
using Interface.Jobs;
using Service.Jobs;
var builder = WebApplication.CreateBuilder(args);
var interfaces = Assembly.Load("Interface");
var services = Assembly.Load("Service");
foreach (var type in interfaces.GetTypes().Where(x => x.Namespace?.StartsWith("Interface") == true))
{
var serviceType = services.GetTypes().FirstOrDefault(x => type.IsAssignableFrom(x));
if (serviceType != null)
{
builder.Services.AddSingleton(type, serviceType);
}
}
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddHttpContextAccessor();
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.MapControllers();
#if !DEBUG
var logTotalNotifyJobRegistry = app.Services.GetRequiredService<ILogTotalNotifyJobRegistry>();
JobManager.Initialize((LogTotalNotifyJobRegistry)logTotalNotifyJobRegistry);
var diskActionMonitorRegistry = app.Services.GetRequiredService<IDiskActionMonitorRegistry>();
JobManager.Initialize((DiskActionMonitorRegistry)diskActionMonitorRegistry);
var syncTaskRegistry = app.Services.GetRequiredService<IRSyncTaskRegistry>();
JobManager.Initialize((RSyncTaskRegistry)syncTaskRegistry);
var healthyTaskRegistry = app.Services.GetRequiredService<IHealthyTaskRegistry>();
JobManager.Initialize((HealthyTaskRegistry)healthyTaskRegistry);
var rsyncTaskRegistry = app.Services.GetRequiredService<IRSyncTaskRegistry>();
JobManager.Initialize((RSyncTaskRegistry)rsyncTaskRegistry);
var startupRegistry = app.Services.GetRequiredService<IStartupRegistry>();
JobManager.Initialize((StartupRegistry)startupRegistry);
var shutdownRegistry = app.Services.GetRequiredService<IShutdownRegistry>();
JobManager.Initialize((ShutdownRegistry)shutdownRegistry);
var chineseNfoRegistry = app.Services.GetRequiredService<IChineseNfoRegistry>();
JobManager.Initialize((ChineseNfoRegistry)chineseNfoRegistry);
#endif
app.Run();

View File

@@ -0,0 +1,15 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://0.0.0.0:5236",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

20
src/WebApi/WebApi.csproj Normal file
View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Service\Service.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,36 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Grafana": {
"Token": "glsa_qNYNV1vbjTXVQKIFxM43nIRGlnNE1x0x_c59dd21d",
"LokiUrl": "http://suncheng.online:3000/api/ds/query?ds_type=loki"
},
"DiskActionMonitor": {
"FilePath": "D:\\OneDrive\\Desktop\\SC\\NasRobot\\src\\WebApi\\mocks\\disk_{0}\\stat.txt",
"Disks": [
"sdb",
"sdc1"
]
},
"SyncTask": {
"SourceRoot": "",
"TargetRoot": "",
"SyncPaths": [
{
"Source": "",
"Target": ""
}
]
},
"HealthyTasks": [
],
"ChineseNfo": {
"TvFolder": "D:\\codes\\others\\ConvertChineseNfo",
"HttpProxy": "http://suncheng.online:47890"
}
}

View File

@@ -0,0 +1,65 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Grafana": {
"Token": "glsa_qNYNV1vbjTXVQKIFxM43nIRGlnNE1x0x_c59dd21d",
"LokiUrl": "http://192.168.31.14:3000/api/ds/query?ds_type=loki"
},
"DiskActionMonitor": {
"FilePath": "/sys/block/{0}/stat",
"Disks": [
"sda",
"sdc"
]
},
"SyncTask": {
"SourceRoot": "/wd",
"TargetRoot": "aliyun",
"SyncPaths": [
{
"Source": "apps",
"Target": "/homenas/apps"
},
{
"Source": "volb/media/others",
"Target": "/homenas/jav",
"DeleteAfter": "true"
},
{
"Source": "vola/media/other",
"Target": "/homenas/other"
},
{
"Source": "volb/media/tv",
"Target": "/homenas/tv"
},
{
"Source": "vola/media/anime",
"Target": "/homenas/anime"
},
{
"Source": "vola/media/movies",
"Target": "/homenas/movie"
}
]
},
"HealthyTasks": [
{
"ContainerName": "netdata_proxy",
"Url": "http://192.168.31.14:19999"
},
{
"ContainerName": "nas_robot_proxy",
"Url": "http://192.168.31.14:35642/swagger/index.html"
}
],
"ChineseNfo": {
"TvFolder": "/data/tv",
"HttpProxy": "http://192.168.31.14:47890"
}
}

View File

@@ -0,0 +1 @@
219020 20383 38271508 2978589 12631 1972 20214944 78461 0 2973824 3057050 0 0 0 0 0 0

View File

@@ -0,0 +1 @@
219020 20383 38271508 2978589 12631 1972 20214944 78461 0 2973824 3057050 0 0 0 0 0 0