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,10 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Paca.Avalonia.App"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>

View file

@ -0,0 +1,23 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace Paca.Avalonia;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
}
base.OnFrameworkInitializationCompleted();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View file

@ -0,0 +1,187 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
using Avalonia.Threading;
using Paca.Core.Config;
using Paca.Core.Dash;
using Paca.Core.Hls;
using Paca.Core.Models;
using Paca.Core.Social;
using Paca.Core.Util;
namespace Paca.Avalonia;
/// <summary>Phase 2 Gate #0: 크로스플랫폼 셸의 최소 뷰모델.
/// URL 을 붙여넣으면 유형(HLS/DASH/소셜/직링크) 을 판별해 Core 다운로더를 직접 호출해 진행률을 표시.
/// 브라우저/감지/쿠키(Gate #1) 는 아직 없으므로 DownloadManager 가 아닌 Core 엔진을 직접 사용.</summary>
public class MainViewModel : INotifyPropertyChanged, IDisposable
{
private readonly AppConfig _config = new();
private CancellationTokenSource? _cts;
private string _url = "";
private string _status = "URL 을 붙여넣고 받기를 누르세요";
private double _progress;
private bool _isBusy;
private bool _isPaused;
private string _result = "";
public string Url { get => _url; set { _url = value; On(); _downloadCommand.RaiseCanExecuteChanged(); } }
public string Status { get => _status; set { _status = value; On(); } }
public double Progress { get => _progress; set { _progress = value; On(); } }
public bool IsBusy
{
get => _isBusy;
set
{
_isBusy = value;
if (!_isBusy) _isPaused = false;
On();
On(nameof(IsPaused));
_downloadCommand.RaiseCanExecuteChanged();
_cancelCommand.RaiseCanExecuteChanged();
_pauseCommand.RaiseCanExecuteChanged();
_resumeCommand.RaiseCanExecuteChanged();
}
}
public bool IsPaused
{
get => _isPaused;
set
{
_isPaused = value;
On();
_pauseCommand.RaiseCanExecuteChanged();
_resumeCommand.RaiseCanExecuteChanged();
}
}
public string Result { get => _result; set { _result = value; On(); On(nameof(HasResult)); } }
public bool HasResult => !string.IsNullOrEmpty(_result);
public ObservableCollection<string> Log { get; } = new();
internal bool HasActiveCts => _cts != null;
internal void AddLogEntry(string detail)
{
if (string.IsNullOrEmpty(detail)) return;
Log.Insert(0, detail);
while (Log.Count > 200) Log.RemoveAt(Log.Count - 1);
}
private readonly RelayCommand _downloadCommand;
private readonly RelayCommand _cancelCommand;
private readonly RelayCommand _pauseCommand;
private readonly RelayCommand _resumeCommand;
public ICommand DownloadCommand => _downloadCommand;
public ICommand CancelCommand => _cancelCommand;
public ICommand PauseCommand => _pauseCommand;
public ICommand ResumeCommand => _resumeCommand;
public MainViewModel()
{
_downloadCommand = new RelayCommand(_ => _ = DownloadAsync(), _ => !IsBusy && !string.IsNullOrWhiteSpace(Url));
_cancelCommand = new RelayCommand(_ => Cancel(), _ => IsBusy);
_pauseCommand = new RelayCommand(_ => Pause(), _ => IsBusy && !IsPaused);
_resumeCommand = new RelayCommand(_ => Resume(), _ => IsBusy && IsPaused);
}
public void Pause()
{
if (IsBusy && !IsPaused)
{
IsPaused = true;
Status = "일시정지됨";
}
}
public void Resume()
{
if (IsBusy && IsPaused)
{
IsPaused = false;
Status = "다운로드 재개 중...";
}
}
public void Cancel() { try { _cts?.Cancel(); } catch { } Status = "취소 중..."; }
public void Dispose()
{
Cancel();
_cts?.Dispose();
_cts = null;
}
internal async Task RunDownloadAsync() => await DownloadAsync();
private async Task DownloadAsync()
{
if (!Uri.TryCreate(Url.Trim(), UriKind.Absolute, out var uri))
{ Status = "올바른 URL 이 아닙니다"; return; }
// UI 스레드에서 미리 유형 판별 + 상태 표시(백그라운드에서 INPC 속성을 건드리지 않도록).
var kind = Classify(uri);
IsBusy = true; Result = ""; Progress = 0; Log.Clear();
Status = kind switch { Kind.Social => "yt-dlp 소셜 다운로드", Kind.Hls => "HLS 다운로드", Kind.Dash => "DASH 다운로드", _ => "직접 다운로드" };
_cts = new CancellationTokenSource();
var ct = _cts.Token;
var progress = new Progress<DownloadProgress>(p => Dispatcher.UIThread.Post(() =>
{
if (p.OverridePercent >= 0) Progress = p.OverridePercent;
Status = !string.IsNullOrEmpty(p.Detail) ? p.Detail
: (p.Stage == "downloading" && p.Total > 0 ? $"{p.Completed}/{p.Total}" : p.Stage);
if (p.OverridePercent < 0 && !string.IsNullOrEmpty(p.Detail)) AddLogEntry(p.Detail);
}));
try
{
var name = FileNameUtil.Sanitize(FileNameUtil.DeriveName(uri));
var outputBase = Path.Combine(_config.OutputFolder, name);
Directory.CreateDirectory(_config.OutputFolder);
string final = await Task.Run(() => DownloadByKindAsync(uri, kind, outputBase, progress, ct), ct);
Progress = 100;
Result = $"완료: {final}";
Status = "완료";
}
catch (OperationCanceledException) { Status = "취소됨"; }
catch (Exception e) { Status = "오류"; Result = $"{e.GetType().Name}: {e.Message}"; }
finally
{
IsBusy = false;
_cts?.Dispose();
_cts = null;
}
}
// 백그라운드 스레드에서 실행. UI 속성 미참조(진행률은 progress 콜백→UI 디스패처로 전달).
private async Task<string> DownloadByKindAsync(Uri uri, Kind kind, string outputBase, IProgress<DownloadProgress> progress, CancellationToken ct)
{
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
http.DefaultRequestHeaders.UserAgent.ParseAdd(_config.UserAgent);
return kind switch
{
Kind.Social => await new YtDlpDownloader(_config)
.DownloadAsync(uri, outputBase, new FormatOption("최고 화질 (MP4)", "bv*+ba/b"), null, progress, ct),
Kind.Hls => await new HlsDownloader(http, _config).DownloadAsync(uri, outputBase, progress, ct),
Kind.Dash => await new DashDownloader(http, _config).DownloadAsync(uri, outputBase, progress, ct),
_ => await new DirectDownloader(http, _config).DownloadAsync(uri, outputBase, progress, ct),
};
}
internal enum Kind { Social, Hls, Dash, Direct }
internal static Kind Classify(Uri uri)
{
string host = uri.Host.ToLowerInvariant(), path = uri.AbsolutePath.ToLowerInvariant();
bool social = host.Contains("youtube") || host.Contains("youtu.be") || host.Contains("instagram")
|| host.Contains("tiktok") || host.Contains("x.com") || host.Contains("twitter") || host.Contains("facebook");
if (social) return Kind.Social;
if (path.EndsWith(".m3u8")) return Kind.Hls;
if (path.EndsWith(".mpd")) return Kind.Dash;
return Kind.Direct;
}
public event PropertyChangedEventHandler? PropertyChanged;
private void On([CallerMemberName] string? p = null) => PropertyChanged?.Invoke(this, new(p));
}

View file

@ -0,0 +1,93 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Paca.Avalonia"
x:DataType="local:MainViewModel"
mc:Ignorable="d" d:DesignWidth="740" d:DesignHeight="600"
x:Class="Paca.Avalonia.MainWindow"
Title="Paca (Avalonia · 크로스플랫폼)"
Icon="/Assets/paca.ico"
Width="760" Height="620"
WindowStartupLocation="CenterScreen"
Background="#090A10">
<!-- DataContext 는 MainWindow 생성자에서 코드비하인드로 설정(MainViewModel). -->
<Grid Margin="20">
<!-- 배경 글래스모피즘 그라데이션 장식 -->
<Border Background="#12131F" CornerRadius="16" BorderBrush="#222438" BorderThickness="1" Padding="24">
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*">
<!-- 헤더 툴바 -->
<DockPanel Grid.Row="0" Margin="0,0,0,20" LastChildFill="True">
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" VerticalAlignment="Center">
<Border Width="34" Height="34" CornerRadius="9" Background="#5E56E8" VerticalAlignment="Center" Margin="0,0,12,0">
<TextBlock Text="▶" HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="White" FontSize="13"/>
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Paca" FontSize="18" FontWeight="SemiBold" Foreground="#F2F3F8"/>
<TextBlock Text="크로스플랫폼 미디어 다운로더 · HLS / DASH / yt-dlp" FontSize="11" Foreground="#8E94A8"/>
</StackPanel>
</StackPanel>
<Border DockPanel.Dock="Right" Background="#181A2A" CornerRadius="8" Padding="12,5" VerticalAlignment="Center" BorderBrush="#2D304D" BorderThickness="1">
<TextBlock Text="Avalonia Engine" FontSize="11" Foreground="#C4B5FD" FontWeight="SemiBold"/>
</Border>
</DockPanel>
<!-- URL 입력 카드 (글래스모피즘) -->
<Border Grid.Row="1" Background="#181A28" BorderBrush="#2A2D44" BorderThickness="1" CornerRadius="12" Padding="16" Margin="0,0,0,16">
<StackPanel Spacing="10">
<TextBlock Text="영상 링크 (YouTube, Instagram, X, HLS m3u8, DASH mpd, 직링크)" FontSize="12" FontWeight="SemiBold" Foreground="#A9B0C7"/>
<TextBox x:Name="UrlBox"
Text="{Binding Url}"
PlaceholderText="https://www.youtube.com/watch?v=... 또는 미디어 URL 입력"
Background="#0E101B" Foreground="#F2F3F8"
BorderBrush="#343753" BorderThickness="1" Padding="12,10" CornerRadius="8" FontSize="13"/>
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button Content="취소" Command="{Binding CancelCommand}"
Background="Transparent" Foreground="#A9B0C7" BorderBrush="#343753" BorderThickness="1"
Padding="18,7" CornerRadius="8" FontSize="12" Cursor="Hand"/>
<Button Content="다운로드 시작" Command="{Binding DownloadCommand}"
Background="#5E56E8" Foreground="White" BorderThickness="0"
Padding="22,7" CornerRadius="8" FontWeight="SemiBold" FontSize="12" Cursor="Hand"/>
</StackPanel>
</StackPanel>
</Border>
<!-- 진행률 카드 -->
<Border Grid.Row="2" Background="#181A28" BorderBrush="#2A2D44" BorderThickness="1" CornerRadius="12" Padding="16" Margin="0,0,0,16">
<StackPanel Spacing="8">
<DockPanel LastChildFill="True">
<TextBlock Text="{Binding Status}" Foreground="#F2F3F8" FontSize="12.5" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Progress, StringFormat={}{0:0}%}" DockPanel.Dock="Right" HorizontalAlignment="Right"
Foreground="#C4B5FD" FontWeight="Bold" FontSize="12.5"/>
</DockPanel>
<ProgressBar Value="{Binding Progress}" Maximum="100"
MinHeight="6" CornerRadius="3" Foreground="#5E56E8" Background="#24263B"/>
<TextBlock Text="{Binding Result}" Foreground="#34D399" FontSize="11.5" TextWrapping="Wrap"
IsVisible="{Binding HasResult}"/>
</StackPanel>
</Border>
<!-- 로그 콘솔 -->
<DockPanel Grid.Row="3" Margin="0,0,0,6">
<TextBlock Text="작업 로그" FontSize="12" FontWeight="SemiBold" Foreground="#8E94A8"/>
</DockPanel>
<Border Grid.Row="4" Background="#0E101B" BorderBrush="#202235" BorderThickness="1" CornerRadius="10" Padding="12" Height="140">
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Log}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" Foreground="#8E94A8" FontSize="11" FontFamily="Cascadia Mono, Consolas, Courier New" Margin="0,2"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</Grid>
</Border>
</Grid>
</Window>

View file

@ -0,0 +1,19 @@
using Avalonia.Controls;
namespace Paca.Avalonia;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new MainViewModel();
// URL 입력창에 포커스 + Enter 로 받기 실행.
UrlBox.AttachedToVisualTree += (_, _) => UrlBox.Focus();
UrlBox.KeyDown += (_, e) =>
{
if (e.Key == global::Avalonia.Input.Key.Enter && DataContext is MainViewModel vm && !vm.IsBusy && !string.IsNullOrWhiteSpace(vm.Url))
vm.DownloadCommand.Execute(null);
};
}
}

View file

@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<!-- Core(net8.0)와 정렬 + Mac/Linux LTS 가용성. 크로스플랫폼 Avalonia 셸. -->
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Paca.Avalonia</RootNamespace>
<AssemblyName>Paca.Avalonia</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\paca.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Paca.Tests" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.0" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.0" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
</ItemGroup>
<!-- 공유 엔진(HLS/DASH/Social yt-dlp + 자동복구/자동설치). WPF 앱과 동일 Core 재사용. -->
<ItemGroup>
<ProjectReference Include="..\Paca.Core\Paca.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,24 @@
using Avalonia;
using System;
namespace Paca.Avalonia;
class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
#if DEBUG
.WithDeveloperTools()
#endif
.WithInterFont()
.LogToTrace();
}

View file

@ -0,0 +1,16 @@
using System.Windows.Input;
namespace Paca.Avalonia;
/// <summary>간단한 ICommand 구현(CommunityToolkit 없이 MVVM 커맨드 바인딩용).</summary>
public sealed class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Func<object?, bool>? _canExecute;
public RelayCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
{ _execute = execute; _canExecute = canExecute; }
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
public void Execute(object? parameter) => _execute(parameter);
public event EventHandler? CanExecuteChanged;
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="Paca.Avalonia.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>