feat(ads/seo/release): v1.2.0 with live Google Ads, isolated ad profile, ads.txt, and SEO/AEO/GEO optimization

This commit is contained in:
Yun Chan 2026-09-14 00:39:44 +09:00
parent a88aafa1e5
commit 0e568f1f0a
975 changed files with 130593 additions and 16783 deletions

View file

@ -0,0 +1,175 @@
using System.Net;
using Paca.Core.Config;
using Paca.Core.Hls;
using Paca.Core.Platform;
using Xunit;
namespace Paca.Tests.Core;
public class DownloadFolderCleanlinessAndPacaIsolationGateTests
{
[Fact]
public void Gate_DownloadMetaPathResolver_CreatesHiddenPacaFolder_AndSubdirectories()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "paca_clean_test_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
try
{
var outputBase = Path.Combine(tempRoot, "test_video");
var partsDir = DownloadMetaPathResolver.GetPartsDirectory(outputBase);
var tempPartFile = DownloadMetaPathResolver.GetTempPartFilePath(outputBase);
var pacaDir = DownloadMetaPathResolver.GetPacaDirectory(tempRoot);
Assert.True(Directory.Exists(pacaDir), ".paca 폴더가 생성되어야 한다.");
var di = new DirectoryInfo(pacaDir);
Assert.True((di.Attributes & FileAttributes.Hidden) != 0, ".paca 폴더는 Hidden 속성을 가져야 한다.");
// partsDir은 .paca/parts 하위에 존재해야 함
Assert.Contains(Path.Combine(".paca", "parts"), partsDir);
Assert.DoesNotContain(tempRoot + Path.DirectorySeparatorChar + "test_video.parts", partsDir);
// tempPartFile은 .paca/temp 하위에 존재해야 함
Assert.Contains(Path.Combine(".paca", "temp"), tempPartFile);
}
finally
{
if (Directory.Exists(tempRoot))
{
try { Directory.Delete(tempRoot, recursive: true); } catch { }
}
}
}
[Fact]
public async Task Gate_DownloadFolder_HlsDownloader_StoresPartsInsidePacaHiddenFolder()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "paca_hls_clean_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
// Fake HTTP Listener for sample m3u8
var listener = new HttpListener();
var port = 58921;
listener.Prefixes.Add($"http://127.0.0.1:{port}/");
listener.Start();
var serverTask = Task.Run(async () =>
{
while (listener.IsListening)
{
try
{
var ctx = await listener.GetContextAsync();
var path = ctx.Request.Url?.AbsolutePath ?? "";
if (path.EndsWith("playlist.m3u8"))
{
var m3u8 = "#EXTM3U\n#EXT-X-TARGETDURATION:2\n#EXTINF:2.0,\nseg0.ts\n#EXT-X-ENDLIST\n";
var bytes = System.Text.Encoding.UTF8.GetBytes(m3u8);
ctx.Response.ContentType = "application/vnd.apple.mpegurl";
ctx.Response.OutputStream.Write(bytes);
ctx.Response.Close();
}
else if (path.EndsWith("seg0.ts"))
{
var seg = new byte[1024];
new Random(42).NextBytes(seg);
ctx.Response.ContentType = "video/MP2T";
ctx.Response.OutputStream.Write(seg);
ctx.Response.Close();
}
}
catch { break; }
}
});
try
{
using var http = new HttpClient();
var config = new AppConfig { Concurrency = 1, OutputFolder = tempRoot };
var fakeMuxer = new FakeMediaMuxer();
var hls = new HlsDownloader(http, config, fakeMuxer);
var outputBase = Path.Combine(tempRoot, "my_video");
var res = await hls.DownloadAsync(new Uri($"http://127.0.0.1:{port}/playlist.m3u8"), outputBase);
// 1. 루트 폴더에 .parts 폴더나 .json 파일이 절대 없어야 한다!
var rootDirs = Directory.GetDirectories(tempRoot).Select(Path.GetFileName).ToList();
Assert.DoesNotContain("my_video.parts", rootDirs);
var rootFiles = Directory.GetFiles(tempRoot).Select(Path.GetFileName).ToList();
Assert.DoesNotContain(".chunkcache.json", rootFiles);
Assert.DoesNotContain("chunkcache.json", rootFiles);
// 2. 최종 mp4 파일은 루트에 존재해야 한다
Assert.True(File.Exists(outputBase + ".mp4"), "최종 mp4는 루트에 있어야 함");
}
finally
{
listener.Stop();
try { await serverTask; } catch { }
if (Directory.Exists(tempRoot))
{
try { Directory.Delete(tempRoot, recursive: true); } catch { }
}
}
}
[Fact]
public async Task Gate_DownloadFolder_DirectDownloader_IsolatesPartFileInPacaHiddenDir()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "paca_direct_clean_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
try
{
var fakeHandler = new SimpleMockHandler(new byte[512]);
using var client = new HttpClient(fakeHandler);
var config = new AppConfig { OutputFolder = tempRoot };
var downloader = new DirectDownloader(client, config);
var outputBase = Path.Combine(tempRoot, "direct_file");
var final = await downloader.DownloadAsync(new Uri("https://test.paca/video.mp4"), outputBase);
Assert.True(File.Exists(final));
// 루트 폴더에 .part 파일이 없어야 함
Assert.False(File.Exists(outputBase + ".part"), "루트 폴더에 .part 파일이 존재해서는 안 됨");
var rootFiles = Directory.GetFiles(tempRoot).Select(Path.GetFileName).ToList();
Assert.DoesNotContain("direct_file.part", rootFiles);
}
finally
{
if (Directory.Exists(tempRoot))
{
try { Directory.Delete(tempRoot, recursive: true); } catch { }
}
}
}
private sealed class SimpleMockHandler : HttpMessageHandler
{
private readonly byte[] _data;
public SimpleMockHandler(byte[] data) => _data = data;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var resp = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(_data)
};
resp.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("video/mp4");
return Task.FromResult(resp);
}
}
private sealed class FakeMediaMuxer : IMediaMuxer
{
public Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct)
{
File.Copy(input, output, overwrite: true);
return Task.CompletedTask;
}
public Task DownloadDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec, HttpClient http, IProgress<DownloadProgress>? progress, CancellationToken ct) => Task.CompletedTask;
public string? ResolveFfmpeg(AppConfig config) => "fake-ffmpeg";
}
}