feat: add fnOS packaging, storage workflows and release pipeline

This commit is contained in:
2026-08-11 18:05:49 +08:00
parent c5922f9b08
commit 95932f0199
181 changed files with 24024 additions and 1164 deletions
+232
View File
@@ -0,0 +1,232 @@
using System.Buffers.Binary;
using System.Text;
namespace dy.net.utils
{
/// <summary>
/// Generates the a_bogus value used by Douyin's web search endpoint.
/// The implementation is self-contained so fnOS packages do not need Node or a browser runtime.
/// </summary>
public sealed class DouyinABogusSigner
{
private const string UserAgent = DouyinRequestParamManager.DY_USER_AGENT;
private const string Browser = "1536|742|1536|864|0|0|0|0|1536|864|1536|864|1536|742|24|24|Win32";
private const string AlphabetS3 = "ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe";
private const string AlphabetS4 = "Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe";
private static readonly uint[] InitialRegisters =
{
1937774191, 1226093241, 388252375, 3666478592,
2842636476, 372324522, 3817729613, 2969243214
};
private readonly byte[] _userAgentCode;
private readonly int[] _browserCode = Browser.Select(x => (int)x).ToArray();
public DouyinABogusSigner()
{
var encrypted = Rc4Encrypt(UserAgent, "\0\x01\x0e");
_userAgentCode = Sm3Hash(Encoding.UTF8.GetBytes(GenerateResult(encrypted, AlphabetS3)));
}
public string Sign(string queryString)
{
var start = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var end = start + Random.Shared.Next(4, 9);
return Sign(queryString, "GET", start, end,
Random.Shared.NextDouble() * 10000,
Random.Shared.NextDouble() * 10000,
Random.Shared.NextDouble() * 10000);
}
/// <summary>Deterministic overload used by protocol fixture tests.</summary>
public string Sign(
string queryString,
string method,
long startTimeMilliseconds,
long endTimeMilliseconds,
double randomNumber1,
double randomNumber2,
double randomNumber3)
{
if (queryString == null) throw new ArgumentNullException(nameof(queryString));
var prefix = FromCharCodes(RandomList(randomNumber1, 170, 85, 1, 2, 5, 40))
+ FromCharCodes(RandomList(randomNumber2, 170, 85, 1, 0, 0, 0))
+ FromCharCodes(RandomList(randomNumber3, 170, 85, 1, 0, 5, 0));
var payload = GeneratePayload(queryString, method, startTimeMilliseconds, endTimeMilliseconds);
return GenerateResult(prefix + payload, AlphabetS4);
}
private string GeneratePayload(string queryString, string method, long start, long end)
{
var parameterCode = Sm3Hash(Sm3Hash(Encoding.UTF8.GetBytes(queryString + "cus")));
var methodCode = Sm3Hash(Sm3Hash(Encoding.UTF8.GetBytes((method ?? "GET") + "cus")));
var values = BuildPayloadValues(
(int)((end >> 24) & 255), parameterCode[21], _userAgentCode[23],
(int)((end >> 16) & 255), parameterCode[22], _userAgentCode[24],
(int)((end >> 8) & 255), (int)(end & 255),
(int)((start >> 24) & 255), (int)((start >> 16) & 255),
(int)((start >> 8) & 255), (int)(start & 255),
methodCode[21], methodCode[22],
(int)(end / 4294967296d), (int)(start / 4294967296d), Browser.Length);
var check = values.Aggregate(0, (current, value) => current ^ value);
values.AddRange(_browserCode);
values.Add(check);
return Rc4Encrypt(FromCharCodes(values), "y");
}
private static List<int> BuildPayloadValues(
int a, int b, int c, int d, int e, int f, int g, int h,
int i, int j, int k, int m, int n, int o, int p, int q, int r) =>
new()
{
44, a, 0, 0, 0, 0, 24, b, n, 0, c, d, 0, 0, 0, 1, 0, 239,
e, o, f, g, 0, 0, 0, 0, h, 0, 0, 14, i, j, 0, k, m, 3, p, 1,
q, 1, r, 0, 0, 0
};
private static IEnumerable<int> RandomList(
double randomNumber, int evenMask, int oddMask, int evenLow, int oddLow, int evenHigh, int oddHigh)
{
var value = (int)randomNumber;
var low = value & 255;
var high = value >> 8;
return new[]
{
(low & evenMask) | evenLow,
(low & oddMask) | oddLow,
(high & evenMask) | evenHigh,
(high & oddMask) | oddHigh
};
}
private static string FromCharCodes(IEnumerable<int> values) =>
new(values.Select(value => (char)value).ToArray());
private static string Rc4Encrypt(string plaintext, string key)
{
var state = Enumerable.Range(0, 256).ToArray();
var j = 0;
for (var i = 0; i < 256; i++)
{
j = (j + state[i] + key[i % key.Length]) % 256;
(state[i], state[j]) = (state[j], state[i]);
}
var output = new char[plaintext.Length];
var x = 0;
j = 0;
for (var index = 0; index < plaintext.Length; index++)
{
x = (x + 1) % 256;
j = (j + state[x]) % 256;
(state[x], state[j]) = (state[j], state[x]);
var keyByte = state[(state[x] + state[j]) % 256];
output[index] = (char)(keyByte ^ plaintext[index]);
}
return new string(output);
}
private static string GenerateResult(string value, string alphabet)
{
var result = new StringBuilder();
for (var index = 0; index < value.Length; index += 3)
{
var number = value[index] << 16;
if (index + 1 < value.Length) number |= value[index + 1] << 8;
if (index + 2 < value.Length) number |= value[index + 2];
result.Append(alphabet[(number & 0xfc0000) >> 18]);
result.Append(alphabet[(number & 0x03f000) >> 12]);
if (index + 1 < value.Length) result.Append(alphabet[(number & 0x0fc0) >> 6]);
if (index + 2 < value.Length) result.Append(alphabet[number & 0x3f]);
}
while (result.Length % 4 != 0) result.Append('=');
return result.ToString();
}
private static byte[] Sm3Hash(byte[] data)
{
var bitLength = (ulong)data.LongLength * 8;
var paddedLength = ((data.Length + 1 + 8 + 63) / 64) * 64;
var padded = new byte[paddedLength];
Buffer.BlockCopy(data, 0, padded, 0, data.Length);
padded[data.Length] = 0x80;
BinaryPrimitives.WriteUInt64BigEndian(padded.AsSpan(padded.Length - 8), bitLength);
var registers = (uint[])InitialRegisters.Clone();
for (var offset = 0; offset < padded.Length; offset += 64)
Compress(registers, padded.AsSpan(offset, 64));
var result = new byte[32];
for (var index = 0; index < registers.Length; index++)
BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(index * 4, 4), registers[index]);
return result;
}
private static void Compress(uint[] registers, ReadOnlySpan<byte> block)
{
var words = new uint[68];
var expanded = new uint[64];
for (var index = 0; index < 16; index++)
words[index] = BinaryPrimitives.ReadUInt32BigEndian(block.Slice(index * 4, 4));
for (var index = 16; index < 68; index++)
{
var value = words[index - 16] ^ words[index - 9] ^ RotateLeft(words[index - 3], 15);
words[index] = P1(value) ^ RotateLeft(words[index - 13], 7) ^ words[index - 6];
}
for (var index = 0; index < 64; index++) expanded[index] = words[index] ^ words[index + 4];
var a = registers[0];
var b = registers[1];
var c = registers[2];
var d = registers[3];
var e = registers[4];
var f = registers[5];
var g = registers[6];
var h = registers[7];
for (var index = 0; index < 64; index++)
{
var rotatedA = RotateLeft(a, 12);
var ss1 = RotateLeft(unchecked(rotatedA + e + RotateLeft(index < 16 ? 0x79cc4519u : 0x7a879d8au, index)), 7);
var ss2 = ss1 ^ rotatedA;
var tt1 = unchecked(Ff(index, a, b, c) + d + ss2 + expanded[index]);
var tt2 = unchecked(Gg(index, e, f, g) + h + ss1 + words[index]);
d = c;
c = RotateLeft(b, 9);
b = a;
a = tt1;
h = g;
g = RotateLeft(f, 19);
f = e;
e = P0(tt2);
}
registers[0] ^= a;
registers[1] ^= b;
registers[2] ^= c;
registers[3] ^= d;
registers[4] ^= e;
registers[5] ^= f;
registers[6] ^= g;
registers[7] ^= h;
}
private static uint RotateLeft(uint value, int count)
{
count &= 31;
return count == 0 ? value : (value << count) | (value >> (32 - count));
}
private static uint P0(uint value) => value ^ RotateLeft(value, 9) ^ RotateLeft(value, 17);
private static uint P1(uint value) => value ^ RotateLeft(value, 15) ^ RotateLeft(value, 23);
private static uint Ff(int index, uint x, uint y, uint z) =>
index < 16 ? x ^ y ^ z : (x & y) | (x & z) | (y & z);
private static uint Gg(int index, uint x, uint y, uint z) =>
index < 16 ? x ^ y ^ z : (x & y) | (~x & z);
}
}
+112
View File
@@ -0,0 +1,112 @@
using dy.net.model.dto;
using Newtonsoft.Json.Linq;
namespace dy.net.utils
{
public static class DouyinLiveStatusParser
{
public static DouyinLiveStatusProbe Parse(
int? liveStatus,
string roomId,
string roomIdStr,
JToken roomData)
{
var room = NormalizeRoomData(roomData);
var roomStatus = FindInt(room, "status");
var isLive = liveStatus == 1 || (!liveStatus.HasValue && roomStatus == 2);
var hasKnownStatus = liveStatus.HasValue || roomStatus.HasValue;
return new DouyinLiveStatusProbe
{
Status = isLive
? DouyinLiveStatusState.Live
: hasKnownStatus ? DouyinLiveStatusState.Offline : DouyinLiveStatusState.Unknown,
RoomId = FirstNonEmpty(
roomIdStr,
roomId,
FindString(room, "id_str"),
FindString(room, "room_id_str"),
FindString(room, "room_id")),
WebRid = FirstNonEmpty(
FindString(room, "web_rid"),
FindString(room, "webRid")),
Title = Limit(FindString(room, "title"), 500),
StartedAt = ParseUnixTime(FindLong(room, "start_time") ?? FindLong(room, "create_time"))
};
}
private static JToken NormalizeRoomData(JToken value)
{
if (value == null || value.Type == JTokenType.Null) return null;
if (value.Type != JTokenType.String) return value;
var text = value.Value<string>();
if (string.IsNullOrWhiteSpace(text)) return null;
try { return JToken.Parse(text); }
catch { return null; }
}
private static string FindString(JToken token, string name)
{
var value = Find(token, name);
return value?.Type == JTokenType.Null ? null : value?.ToString();
}
private static int? FindInt(JToken token, string name)
{
var value = Find(token, name);
return int.TryParse(value?.ToString(), out var parsed) ? parsed : null;
}
private static long? FindLong(JToken token, string name)
{
var value = Find(token, name);
return long.TryParse(value?.ToString(), out var parsed) ? parsed : null;
}
private static JToken Find(JToken token, string name)
{
if (token == null) return null;
if (token is JObject obj)
{
var property = obj.Properties().FirstOrDefault(x =>
string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase));
if (property != null) return property.Value;
foreach (var child in obj.Properties())
{
var found = Find(child.Value, name);
if (found != null) return found;
}
}
else if (token is JArray array)
{
foreach (var child in array)
{
var found = Find(child, name);
if (found != null) return found;
}
}
return null;
}
private static string FirstNonEmpty(params string[] values) =>
values.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x))?.Trim();
private static DateTime? ParseUnixTime(long? value)
{
if (!value.HasValue || value <= 0) return null;
try
{
var timestamp = value > 10_000_000_000
? DateTimeOffset.FromUnixTimeMilliseconds(value.Value)
: DateTimeOffset.FromUnixTimeSeconds(value.Value);
return timestamp.LocalDateTime;
}
catch { return null; }
}
private static string Limit(string value, int maxLength) =>
string.IsNullOrWhiteSpace(value)
? null
: value.Trim().Length <= maxLength ? value.Trim() : value.Trim()[..maxLength];
}
}
+65 -49
View File
@@ -236,12 +236,11 @@ namespace dy.net.utils
if (imageFilePaths == null || !imageFilePaths.Any())
throw new ArgumentException("图片路径列表不能为空。", nameof(imageFilePaths));
if (string.IsNullOrEmpty(audioFilePath) || !File.Exists(audioFilePath))
throw new FileNotFoundException("音频文件未找到。", audioFilePath);
if (string.IsNullOrEmpty(outputVideoPath))
throw new ArgumentNullException(nameof(outputVideoPath));
var hasAudio = !string.IsNullOrWhiteSpace(audioFilePath) && File.Exists(audioFilePath);
foreach (var imagePath in imageFilePaths)
{
if (!File.Exists(imagePath.Path))
@@ -278,7 +277,7 @@ namespace dy.net.utils
// 获取音频时长
double audioDurationSeconds = imageList.Count * ImageDisplayDurationSeconds;
try
if (hasAudio) try
{
var startInfo = new ProcessStartInfo
{
@@ -329,50 +328,10 @@ namespace dy.net.utils
// 组合最终滤镜:先适配尺寸,再循环
string filterComplex = $"[0:v]{fitFilter},loop=loop={loopCount - 1}:size={imageCount}[v]";
var arguments = new List<string>
{
"-y", // 覆盖输出文件
// 图片序列输入
"-f", "image2",
"-framerate", imageFps.ToString(CultureInfo.InvariantCulture),
"-start_number", "1", // 修正:临时文件是 temp_001、temp_002... 所以起始编号为1
"-i", imageSequencePattern,
// 音频输入
"-i", audioFilePath,
// 滤镜:尺寸适配 + 循环
"-filter_complex", filterComplex,
// 流映射
"-map", "[v]",
"-map", "1:a",
// 视频编码参数
"-c:v", VideoCodec,
"-preset", VideoPreset,
"-crf", $"{VideoCrf}",
"-s", $"{VideoWidth}x{VideoHeight}",
"-pix_fmt", "yuv420p",
"-profile:v", "main",
// 音频编码参数
"-c:a", AudioCodec,
"-b:a", $"{AudioBitrate}",
"-ac", "2",
"-ar", "44100",
// 封装优化
"-f", "mp4",
"-movflags", "+faststart",
// 同步参数
"-shortest",
// 输出路径
outputVideoPath
};
var arguments = BuildImageVideoArguments(
imageSequencePattern, hasAudio ? audioFilePath : null, outputVideoPath,
imageFps, filterComplex, VideoWidth, VideoHeight,
VideoCodec, VideoPreset, VideoCrf, AudioCodec, AudioBitrate);
// 执行FFmpeg命令
await ExecuteFFmpegAsync(arguments, progress, cancellationToken);
@@ -401,6 +360,63 @@ namespace dy.net.utils
}
}
internal static List<string> BuildImageVideoArguments(
string imageSequencePattern,
string audioFilePath,
string outputVideoPath,
double imageFps,
string filterComplex,
int videoWidth,
int videoHeight,
string videoCodec = "libx264",
string videoPreset = "medium",
int videoCrf = 23,
string audioCodec = "aac",
string audioBitrate = "192k")
{
if (string.IsNullOrWhiteSpace(imageSequencePattern)) throw new ArgumentNullException(nameof(imageSequencePattern));
if (string.IsNullOrWhiteSpace(outputVideoPath)) throw new ArgumentNullException(nameof(outputVideoPath));
if (string.IsNullOrWhiteSpace(filterComplex)) throw new ArgumentNullException(nameof(filterComplex));
var hasAudio = !string.IsNullOrWhiteSpace(audioFilePath) && File.Exists(audioFilePath);
var arguments = new List<string>
{
"-y",
"-f", "image2",
"-framerate", imageFps.ToString(CultureInfo.InvariantCulture),
"-start_number", "1",
"-i", imageSequencePattern
};
if (hasAudio) arguments.AddRange(new[] { "-i", audioFilePath });
arguments.AddRange(new[]
{
"-filter_complex", filterComplex,
"-map", "[v]",
"-c:v", videoCodec,
"-preset", videoPreset,
"-crf", $"{videoCrf}",
"-s", $"{videoWidth}x{videoHeight}",
"-pix_fmt", "yuv420p",
"-profile:v", "main"
});
if (hasAudio)
{
arguments.AddRange(new[]
{
"-map", "1:a",
"-c:a", audioCodec,
"-b:a", audioBitrate,
"-ac", "2",
"-ar", "44100"
});
}
else arguments.Add("-an");
arguments.AddRange(new[] { "-f", "mp4", "-movflags", "+faststart" });
if (hasAudio) arguments.Add("-shortest");
arguments.Add(outputVideoPath);
return arguments;
}
@@ -782,4 +798,4 @@ namespace dy.net.utils
_ffmpegProcess?.Dispose();
}
}
}
}
+72
View File
@@ -0,0 +1,72 @@
using System.Xml.Linq;
using System.Xml;
using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.storage;
namespace dy.net.utils
{
public static class NfoContentBuilder
{
public static IReadOnlyDictionary<string, string> Build(DouyinVideo video, string tvShowTitle = "")
{
var files = new Dictionary<string, string>();
if (video == null || video.OnlyImgOrOnlyMp3 || string.IsNullOrWhiteSpace(video.VideoSavePath)) return files;
var directory = StoragePath.DirectoryName(video.VideoSavePath);
var fileName = Path.GetFileNameWithoutExtension(video.VideoSavePath);
var isSeries = video.ViedoType == VideoTypeEnum.dy_mix || video.ViedoType == VideoTypeEnum.dy_series;
if (isSeries)
{
files[StoragePath.CombineRemote(directory, "tvshow.nfo")] = CreateXml(video, "tvshow", string.IsNullOrWhiteSpace(tvShowTitle) ? video.VideoTitle : tvShowTitle);
files[StoragePath.CombineRemote(directory, fileName + ".nfo")] = CreateXml(video, "episodedetails", video.VideoTitle);
}
else
{
files[StoragePath.CombineRemote(directory, fileName + ".nfo")] = CreateXml(video, "movie", video.VideoTitle);
}
return files;
}
private static string CreateXml(DouyinVideo video, string rootName, string title)
{
var root = new XElement(rootName,
new XElement("lockdata", true),
new XElement("title", Clean(title)),
new XElement("releasedate", video.CreateTime.ToString("yyyy-MM-dd")),
new XElement("premiered", video.CreateTime.ToString("yyyy-MM-dd")));
foreach (var genre in new[] { video.Tag1, video.Tag2, video.Tag3 }.Where(x => !string.IsNullOrWhiteSpace(x)))
root.Add(new XElement("genre", Clean(genre)));
if (!string.IsNullOrWhiteSpace(video.Author))
{
root.Add(new XElement("actor",
new XElement("name", Clean(video.Author)),
new XElement("role", "主演"),
new XElement("tmdbid", "")));
}
var poster = Path.GetFileName(video.VideoCoverSavePath);
if (!string.IsNullOrWhiteSpace(poster))
{
root.Add(new XElement("thumb", new XAttribute("aspect", "poster"), poster));
root.Add(new XElement("fanart", new XElement("thumb", poster)));
}
if (rootName == "episodedetails")
{
root.Add(new XElement("episode", Path.GetFileNameWithoutExtension(video.VideoSavePath).Replace("S01E0", "").Replace("S01E", "")));
root.Add(new XElement("season", "1"));
}
return new XDocument(new XDeclaration("1.0", "utf-8", null), root).ToString();
}
private static string Clean(string value) => string.IsNullOrWhiteSpace(value)
? string.Empty
: new string(value.Where(ch => XmlConvert.IsXmlChar(ch)).ToArray());
}
}