From 8f934da5e15047ca8e86042e99d74abecdcdb84f Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 16 Jul 2026 22:55:15 +0900 Subject: [PATCH 01/17] chore(core): scaffold Musebase.Browser (in sln) + Musebase.Android stub Pre-registers the Browser skeleton in Musebase.sln so parallel platform agents never touch shared files (sln/ci). Android stays out of the sln until the android workload lands in CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- Musebase.sln | 7 +++++++ src/Musebase.Android/README.md | 11 +++++++++++ src/Musebase.Browser/Musebase.Browser.csproj | 13 +++++++++++++ src/Musebase.Browser/Program.cs | 9 +++++++++ 4 files changed, 40 insertions(+) create mode 100644 src/Musebase.Android/README.md create mode 100644 src/Musebase.Browser/Musebase.Browser.csproj create mode 100644 src/Musebase.Browser/Program.cs diff --git a/Musebase.sln b/Musebase.sln index fbcb970..1089576 100644 --- a/Musebase.sln +++ b/Musebase.sln @@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Musebase.Windows", "src\Mus EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Musebase.Engine", "src\Musebase.Engine\Musebase.Engine.csproj", "{F5BE32CA-78FF-4CE1-B073-5885C21C855C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Musebase.Browser", "src\Musebase.Browser\Musebase.Browser.csproj", "{9A4B8FC0-44C2-4EAB-9D2E-EA4CFC5999A7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -60,6 +62,10 @@ Global {F5BE32CA-78FF-4CE1-B073-5885C21C855C}.Debug|Any CPU.Build.0 = Debug|Any CPU {F5BE32CA-78FF-4CE1-B073-5885C21C855C}.Release|Any CPU.ActiveCfg = Release|Any CPU {F5BE32CA-78FF-4CE1-B073-5885C21C855C}.Release|Any CPU.Build.0 = Release|Any CPU + {9A4B8FC0-44C2-4EAB-9D2E-EA4CFC5999A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9A4B8FC0-44C2-4EAB-9D2E-EA4CFC5999A7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9A4B8FC0-44C2-4EAB-9D2E-EA4CFC5999A7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9A4B8FC0-44C2-4EAB-9D2E-EA4CFC5999A7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {767ED419-F293-4407-86F6-A8071FE9B5F1} = {6B10242B-5617-4D10-8710-14F7FD8C2A2B} @@ -69,5 +75,6 @@ Global {DACF699E-7AA1-4E83-B5B8-B437B0A25FFE} = {6B10242B-5617-4D10-8710-14F7FD8C2A2B} {AB71719D-EE08-40E4-B14C-06E7479FF208} = {E2C96785-55D1-4839-885C-8AD7B97977B4} {F5BE32CA-78FF-4CE1-B073-5885C21C855C} = {E2C96785-55D1-4839-885C-8AD7B97977B4} + {9A4B8FC0-44C2-4EAB-9D2E-EA4CFC5999A7} = {E2C96785-55D1-4839-885C-8AD7B97977B4} EndGlobalSection EndGlobal diff --git a/src/Musebase.Android/README.md b/src/Musebase.Android/README.md new file mode 100644 index 0000000..710b7de --- /dev/null +++ b/src/Musebase.Android/README.md @@ -0,0 +1,11 @@ +# Musebase.Android (Phase 2 스파이크) + +`.NET for Android`(net8.0-android) 헤드. **아직 `Musebase.sln`에 포함하지 않는다** — +android 워크로드가 CI/모든 개발 머신에 없어도 메인 빌드가 깨지지 않게 하기 위함. +앱이 성숙하면 sln 등록 + `ci.yml`에 `dotnet workload install android` 단계를 추가한다. + +- 빌드(로컬): `dotnet workload install android`(관리자 필요할 수 있음) 후 + `dotnet build src/Musebase.Android -c Release` +- 스파이크 목표: MediaSession(NotificationListenerService)으로 `INowPlayingSource` 구현 + → 재생 곡명/아티스트/위치가 감지되는지 확인. 엔진 조립은 `LyricsEngineFactory` 재사용. +- 골든룰: `Musebase.Core`/`Musebase.Engine`/`contracts/`는 수정 금지(.claude/agents/android.md). diff --git a/src/Musebase.Browser/Musebase.Browser.csproj b/src/Musebase.Browser/Musebase.Browser.csproj new file mode 100644 index 0000000..92e8075 --- /dev/null +++ b/src/Musebase.Browser/Musebase.Browser.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/src/Musebase.Browser/Program.cs b/src/Musebase.Browser/Program.cs new file mode 100644 index 0000000..bfa548f --- /dev/null +++ b/src/Musebase.Browser/Program.cs @@ -0,0 +1,9 @@ +// Musebase.Browser — PlaybackViewState를 WebSocket으로 방송하고 정적 웹 디스플레이를 서빙하는 +// 서버(Phase 1). 계약은 contracts/playback-view-state.md 참고. 스켈레톤 — browser 에이전트가 구현. + +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); + +app.MapGet("/healthz", () => "ok"); + +app.Run(); From 918db1ffcce017e5a143740281a6425bea3d1be1 Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 16 Jul 2026 23:05:14 +0900 Subject: [PATCH 02/17] feat(browser): PlaybackViewState WS broadcast + web display (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StateBroadcaster: 최신 상태 보관 + 전체 WS 클라이언트 JSON 방송, 신규 접속 시 현재 상태 즉시 1회 전송. Publish(PlaybackViewState) 공개 API로 Windows 앱이 LyricsCoordinator.StateChanged를 직접 물릴 수 있음. 느린 클라이언트는 bounded channel(DropOldest)로 격리. - 엔드포인트: GET /healthz, WS /ws, wwwroot/ 정적 서빙. 기본 포트 5123 (--urls / ASPNETCORE_URLS로 재정의). - --demo: 자작 샘플 가사 4줄(카라오케 마크, 마지막 줄은 마크 없음)을 5초 간격 순환 방송 + 사이클마다 IsPlaying=false 1회로 숨김 검증. - wwwroot/index.html: 외부 CDN 없는 단일 파일 디스플레이. WS 자동 재접속, LineStartedAt 앵커 + Karaoke 마크 rAF 로컬 보간 (canvas measureText로 글자 채움 픽셀 계산), Karaoke null 단색, IsPlaying=false 페이드 숨김, 수신 시각-LineStartedAt 시계 오차 1회 보정. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Musebase.Browser/DemoLoop.cs | 95 ++++++++ src/Musebase.Browser/Musebase.Browser.csproj | 5 + src/Musebase.Browser/Program.cs | 50 +++- src/Musebase.Browser/StateBroadcaster.cs | 124 ++++++++++ src/Musebase.Browser/wwwroot/index.html | 243 +++++++++++++++++++ 5 files changed, 515 insertions(+), 2 deletions(-) create mode 100644 src/Musebase.Browser/DemoLoop.cs create mode 100644 src/Musebase.Browser/StateBroadcaster.cs create mode 100644 src/Musebase.Browser/wwwroot/index.html diff --git a/src/Musebase.Browser/DemoLoop.cs b/src/Musebase.Browser/DemoLoop.cs new file mode 100644 index 0000000..151c858 --- /dev/null +++ b/src/Musebase.Browser/DemoLoop.cs @@ -0,0 +1,95 @@ +using Musebase.Engine; + +namespace Musebase.Browser; + +/// +/// --demo 모드: 샘플 가사 4줄(카라오케 마크 포함, 마지막 줄은 마크 없이 단색 검증용)을 +/// 5초 간격으로 순환 방송하고, 한 사이클에 1회 IsPlaying=false 상태를 끼워 +/// 디스플레이의 숨김(페이드) 동작을 검증할 수 있게 한다. +/// 가사는 저작권 문제가 없는 자작 데모 문구다. +/// +public static class DemoLoop +{ + private const double SpanSeconds = 5.0; + private const string DemoTitle = "Demo Song"; + private const string DemoArtist = "Musebase"; + + private sealed record DemoLine( + string Content, string? Translation, KaraokeMark[]? Karaoke, double? KaraokeDuration); + + private static readonly DemoLine[] Lines = + [ + new("Shine on through the silent night", + "고요한 밤을 지나 계속 빛나줘", + [new(0, 0.0), new(6, 0.7), new(9, 1.3), new(17, 2.1), new(21, 2.7), new(28, 3.6)], + 4.2), + new("Every word you sing becomes a star", + "네가 부르는 모든 말이 별이 되어", + [new(0, 0.0), new(6, 0.6), new(11, 1.1), new(15, 1.7), new(20, 2.5), new(30, 3.4)], + 4.0), + new("가사가 흐르는 이 순간을 기억해", + "Remember this moment as the lyrics flow", + [new(0, 0.0), new(4, 0.8), new(8, 1.6), new(10, 2.0), new(14, 2.8)], + 4.0), + // Karaoke = null → 계약 규칙 3: 줄 전체 단색 표시 검증용. + new("We drift along the melody line", + "우리는 멜로디를 따라 흘러가", + null, + null), + ]; + + /// 취소될 때까지 데모 상태를 순환 방송한다. + public static async Task RunAsync(StateBroadcaster broadcaster, CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + for (var i = 0; i < Lines.Length; i++) + { + broadcaster.Publish(ToState(Lines[i])); + await Task.Delay(TimeSpan.FromSeconds(SpanSeconds), cancellationToken) + .ConfigureAwait(false); + + if (i == 1) + { + // 사이클마다 1회: 일시정지 상태 → 오버레이 전체 숨김 검증. + broadcaster.Publish(new PlaybackViewState( + IsPlaying: false, + TrackTitle: DemoTitle, + TrackArtist: DemoArtist, + LineContent: null, + LineTranslation: null, + Karaoke: null, + KaraokeDurationSeconds: null, + LineStartedAt: null, + LineSpanSeconds: 0, + CanPrevious: true, + CanPlayPause: true, + CanNext: true)); + await Task.Delay(TimeSpan.FromSeconds(SpanSeconds), cancellationToken) + .ConfigureAwait(false); + } + } + } + } + catch (OperationCanceledException) + { + // 서버 종료 — 정상. + } + } + + private static PlaybackViewState ToState(DemoLine line) => new( + IsPlaying: true, + TrackTitle: DemoTitle, + TrackArtist: DemoArtist, + LineContent: line.Content, + LineTranslation: line.Translation, + Karaoke: line.Karaoke, + KaraokeDurationSeconds: line.KaraokeDuration, + LineStartedAt: DateTimeOffset.UtcNow, + LineSpanSeconds: SpanSeconds, + CanPrevious: true, + CanPlayPause: true, + CanNext: true); +} diff --git a/src/Musebase.Browser/Musebase.Browser.csproj b/src/Musebase.Browser/Musebase.Browser.csproj index 92e8075..3b49b46 100644 --- a/src/Musebase.Browser/Musebase.Browser.csproj +++ b/src/Musebase.Browser/Musebase.Browser.csproj @@ -10,4 +10,9 @@ + + + + + diff --git a/src/Musebase.Browser/Program.cs b/src/Musebase.Browser/Program.cs index bfa548f..dab31d2 100644 --- a/src/Musebase.Browser/Program.cs +++ b/src/Musebase.Browser/Program.cs @@ -1,9 +1,55 @@ // Musebase.Browser — PlaybackViewState를 WebSocket으로 방송하고 정적 웹 디스플레이를 서빙하는 -// 서버(Phase 1). 계약은 contracts/playback-view-state.md 참고. 스켈레톤 — browser 에이전트가 구현. +// 서버(Phase 1). 계약: contracts/playback-view-state.md (System.Text.Json 기본 = PascalCase). +// +// 엔드포인트: +// GET /healthz — 상태 확인 +// WS /ws — PlaybackViewState JSON 방송 구독(접속 즉시 현재 상태 1회 수신) +// GET / — wwwroot/ 정적 웹 디스플레이 +// +// 실행: dotnet run (기본 http://localhost:5123) +// dotnet run -- --demo (샘플 가사 순환 방송) +// dotnet run -- --urls http://0.0.0.0:5123 (LAN 공개 시) + +using Musebase.Browser; + +var demo = args.Contains("--demo"); +var builder = WebApplication.CreateBuilder(args.Where(static a => a != "--demo").ToArray()); + +// 기본 포트 5123 — --urls 인자나 ASPNETCORE_URLS 환경변수로 재정의 가능. +if (builder.Configuration["urls"] is null && + Environment.GetEnvironmentVariable("ASPNETCORE_URLS") is null) +{ + builder.WebHost.UseUrls("http://localhost:5123"); +} + +builder.Services.AddSingleton(); -var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); +var broadcaster = app.Services.GetRequiredService(); + +app.UseDefaultFiles(); +app.UseStaticFiles(); +app.UseWebSockets(); app.MapGet("/healthz", () => "ok"); +app.Map("/ws", async context => +{ + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync("WebSocket endpoint — connect via ws://"); + return; + } + + using var socket = await context.WebSockets.AcceptWebSocketAsync(); + await broadcaster.HandleClientAsync(socket, context.RequestAborted); +}); + +if (demo) +{ + var lifetime = app.Services.GetRequiredService(); + _ = DemoLoop.RunAsync(broadcaster, lifetime.ApplicationStopping); +} + app.Run(); diff --git a/src/Musebase.Browser/StateBroadcaster.cs b/src/Musebase.Browser/StateBroadcaster.cs new file mode 100644 index 0000000..f53d900 --- /dev/null +++ b/src/Musebase.Browser/StateBroadcaster.cs @@ -0,0 +1,124 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Threading.Channels; +using Musebase.Engine; + +namespace Musebase.Browser; + +/// +/// 최신 를 보관하고, 연결된 모든 WebSocket 클라이언트에 +/// JSON(System.Text.Json 기본 = PascalCase, contracts/playback-view-state.md)으로 방송한다. +/// 새 클라이언트가 접속하면 현재 상태를 즉시 1회 전송한다. +/// +/// 라이브러리처럼도 쓸 수 있다: Windows 앱이 LyricsCoordinator.StateChanged를 +/// 에 물리면 원격 디스플레이로 상태가 흘러간다. +/// 모든 공개 멤버는 스레드 안전하다. +/// +public sealed class StateBroadcaster +{ + private readonly ConcurrentDictionary _clients = new(); + private volatile string? _latestJson; + + /// 현재 연결된 구독자 수(진단용). + public int ClientCount => _clients.Count; + + /// + /// 새 표시 상태를 방송한다. 어느 스레드에서 호출해도 안전하며 블로킹하지 않는다 + /// (느린 클라이언트는 자기 큐에서 오래된 상태부터 버린다 — 최신 상태만 의미가 있으므로). + /// + public void Publish(PlaybackViewState state) + { + var json = JsonSerializer.Serialize(state); + _latestJson = json; + foreach (var client in _clients.Values) + client.Enqueue(json); + } + + /// + /// 수락된 WebSocket 하나를 구독자로 등록하고 연결이 끝날 때까지 처리한다. + /// 등록 직후 현재 상태(있으면)를 1회 즉시 전송한다. 반환되면 구독이 해제된 것이다. + /// + public async Task HandleClientAsync(WebSocket socket, CancellationToken cancellationToken) + { + var id = Guid.NewGuid(); + var client = new Client(socket); + _clients[id] = client; + if (_latestJson is { } latest) + client.Enqueue(latest); + try + { + await client.RunAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _clients.TryRemove(id, out _); + } + } + + /// 구독자 1명: 전송 큐(최신 우선, 오래된 것 드롭) + 닫힘 감지 수신 루프. + private sealed class Client(WebSocket socket) + { + private readonly Channel _outbox = Channel.CreateBounded( + new BoundedChannelOptions(capacity: 16) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + }); + + public void Enqueue(string json) => _outbox.Writer.TryWrite(json); + + public async Task RunAsync(CancellationToken cancellationToken) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var send = SendLoopAsync(cts.Token); + var receive = ReceiveLoopAsync(cts.Token); + await Task.WhenAny(send, receive).ConfigureAwait(false); + cts.Cancel(); + try + { + await Task.WhenAll(send, receive).ConfigureAwait(false); + } + catch + { + // 취소·연결 끊김은 정상 종료 흐름. + } + + if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + try + { + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None) + .ConfigureAwait(false); + } + catch + { + // 이미 끊긴 소켓 — 무시. + } + } + } + + private async Task SendLoopAsync(CancellationToken ct) + { + await foreach (var json in _outbox.Reader.ReadAllAsync(ct).ConfigureAwait(false)) + { + var bytes = Encoding.UTF8.GetBytes(json); + await socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct) + .ConfigureAwait(false); + } + } + + private async Task ReceiveLoopAsync(CancellationToken ct) + { + // 구독자는 데이터를 보내지 않지만, Close 프레임/끊김을 감지하려면 계속 읽어야 한다. + var buffer = new byte[1024]; + while (!ct.IsCancellationRequested) + { + var result = await socket.ReceiveAsync(buffer, ct).ConfigureAwait(false); + if (result.MessageType == WebSocketMessageType.Close) + return; + } + } + } +} diff --git a/src/Musebase.Browser/wwwroot/index.html b/src/Musebase.Browser/wwwroot/index.html new file mode 100644 index 0000000..c213c7a --- /dev/null +++ b/src/Musebase.Browser/wwwroot/index.html @@ -0,0 +1,243 @@ + + + + + +Musebase Display + + + + +
connecting…
+ + + + From dfc05165d17d5d102719ebc0449d7179d0807cac Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 16 Jul 2026 23:08:48 +0900 Subject: [PATCH 03/17] feat(android): MediaSession now-playing spike (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .NET for Android(net8.0-android) 헤드 스캐폴드 + 재생 감지 스파이크. Musebase.sln에는 넣지 않음(워크로드 없는 머신/CI 보호). - MediaListenerService: NotificationListenerService — 알림 접근 권한 앵커 (BIND_NOTIFICATION_LISTENER_SERVICE 서비스 선언은 특성에서 생성) - AndroidNowPlayingSource: INowPlayingSource의 Android 구현 — MediaSessionManager.GetActiveSessions + OnActiveSessionsChangedListener, 재생 중 세션 우선 선택, 콜백+500ms 폴링, 위치 보간(1초 미만 역행 흡수) - MainActivity: 스파이크 UI — 권한 설정 버튼 + 곡명/아티스트/위치/소스앱 1초 갱신 - README: 환경 확인 결과(워크로드 설치됨, JDK/SDK 없음 → XA5300)와 InstallAndroidDependencies 기반 사용자 설치 절차, 사이드로드 테스트 절차 C# 소스는 Mono.Android(API 34) 참조 어셈블리 대상 경고 0 컴파일 검증 완료. APK 산출은 JDK 17 + Android SDK 설치 후 가능. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Musebase.Android/AndroidManifest.xml | 18 + src/Musebase.Android/MainActivity.cs | 135 +++++++ src/Musebase.Android/Musebase.Android.csproj | 30 ++ src/Musebase.Android/README.md | 69 +++- .../Services/AndroidNowPlayingSource.cs | 329 ++++++++++++++++++ .../Services/MediaListenerService.cs | 47 +++ 6 files changed, 622 insertions(+), 6 deletions(-) create mode 100644 src/Musebase.Android/AndroidManifest.xml create mode 100644 src/Musebase.Android/MainActivity.cs create mode 100644 src/Musebase.Android/Musebase.Android.csproj create mode 100644 src/Musebase.Android/Services/AndroidNowPlayingSource.cs create mode 100644 src/Musebase.Android/Services/MediaListenerService.cs diff --git a/src/Musebase.Android/AndroidManifest.xml b/src/Musebase.Android/AndroidManifest.xml new file mode 100644 index 0000000..189ced4 --- /dev/null +++ b/src/Musebase.Android/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/src/Musebase.Android/MainActivity.cs b/src/Musebase.Android/MainActivity.cs new file mode 100644 index 0000000..531e576 --- /dev/null +++ b/src/Musebase.Android/MainActivity.cs @@ -0,0 +1,135 @@ +using Android.App; +using Android.Content; +using Android.OS; +using Android.Views; +using Android.Widget; +using Musebase.Android.Services; + +namespace Musebase.Android; + +/// +/// Phase 2 스파이크 UI — 가사 오버레이 없음. 하는 일: +/// 1) 알림 접근 권한 상태 표시 + 시스템 설정으로 이동하는 버튼 +/// 2) 가 감지한 곡명/아티스트/위치/소스앱을 1초마다 갱신 표시 +/// 레이아웃 리소스 없이 코드로 UI를 만들어 스파이크 표면적을 최소화한다. +/// +[Activity( + Label = "Musebase", + Name = "com.countnine.musebase.MainActivity", + MainLauncher = true, + Exported = true)] +public sealed class MainActivity : Activity +{ + private const int UiRefreshMs = 1000; + + private AndroidNowPlayingSource? _source; + private readonly Handler _handler = new(Looper.MainLooper!); + private TextView? _permissionText; + private TextView? _statusText; + private bool _uiLoopRunning; + + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + + // ---- UI (코드 생성) ---- + var root = new LinearLayout(this) + { + Orientation = Orientation.Vertical, + }; + root.SetPadding(48, 96, 48, 48); + + var title = new TextView(this) { Text = "Musebase — MediaSession 스파이크" }; + title.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 20f); + root.AddView(title); + + _permissionText = new TextView(this); + _permissionText.SetPadding(0, 32, 0, 0); + root.AddView(_permissionText); + + var permissionButton = new Button(this) { Text = "알림 접근 권한 설정 열기" }; + permissionButton.Click += (_, _) => + StartActivity(new Intent(global::Android.Provider.Settings.ActionNotificationListenerSettings)); + root.AddView(permissionButton); + + _statusText = new TextView(this) { Text = "감지 대기 중…" }; + _statusText.SetTextSize(global::Android.Util.ComplexUnitType.Sp, 16f); + _statusText.SetPadding(0, 48, 0, 0); + root.AddView(_statusText); + + SetContentView(root, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent)); + + // ---- 감지 소스 ---- + _source = new AndroidNowPlayingSource(this); + _source.TrackChanged += t => + global::Android.Util.Log.Info("Musebase", $"track changed: {t?.ToString() ?? "(none)"}"); + _source.Start(); // 권한이 없어도 폴링하며 대기 — 권한이 켜지면 즉시 감지 시작 + } + + protected override void OnResume() + { + base.OnResume(); + if (!_uiLoopRunning) { _uiLoopRunning = true; UiTick(); } + } + + protected override void OnPause() + { + base.OnPause(); + _uiLoopRunning = false; + _handler.RemoveCallbacksAndMessages(null); + } + + /// 1초마다 권한/감지 상태 텍스트 갱신. + private void UiTick() + { + if (!_uiLoopRunning) return; + RenderStatus(); + _handler.PostDelayed(UiTick, UiRefreshMs); + } + + private void RenderStatus() + { + var source = _source; + if (source is null || _permissionText is null || _statusText is null) return; + + var granted = source.HasNotificationAccess; + _permissionText.Text = granted + ? "알림 접근: 허용됨 ✓" + : "알림 접근: 미허용 — 아래 버튼으로 설정에서 Musebase를 켜 주세요."; + + if (!granted) + { + _statusText.Text = "감지 불가 (알림 접근 권한 필요)"; + return; + } + + var track = source.CurrentTrack; + if (track is null) + { + _statusText.Text = "감지된 미디어 세션 없음\n(음악 앱에서 재생을 시작해 보세요)"; + return; + } + + var position = source.GetEstimatedPosition(); + _statusText.Text = + $"곡명: {track.Title}\n" + + $"아티스트: {track.Artist}\n" + + $"앨범: {track.Album}\n" + + $"위치: {Format(position)} / {Format(track.Duration)}\n" + + $"상태: {(source.IsPlaying ? "재생 중" : "일시정지")}\n" + + $"소스 앱: {track.SourceAppId}"; + } + + private static string Format(TimeSpan? t) => + t is { } v ? $"{(int)v.TotalMinutes}:{v.Seconds:00}" : "-:--"; + + protected override void OnDestroy() + { + _handler.RemoveCallbacksAndMessages(null); + _source?.Stop(); + _source?.Dispose(); + _source = null; + base.OnDestroy(); + } +} diff --git a/src/Musebase.Android/Musebase.Android.csproj b/src/Musebase.Android/Musebase.Android.csproj new file mode 100644 index 0000000..eece8a5 --- /dev/null +++ b/src/Musebase.Android/Musebase.Android.csproj @@ -0,0 +1,30 @@ + + + + + net8.0-android + Exe + enable + enable + Musebase.Android + Musebase.Android + + + 26.0 + + com.countnine.musebase + 1 + 0.1.0 + + + apk + AndroidManifest.xml + + + + + + + + diff --git a/src/Musebase.Android/README.md b/src/Musebase.Android/README.md index 710b7de..6984f0f 100644 --- a/src/Musebase.Android/README.md +++ b/src/Musebase.Android/README.md @@ -1,11 +1,68 @@ -# Musebase.Android (Phase 2 스파이크) +# Musebase.Android (Phase 2 스파이크 — MediaSession 재생 감지) `.NET for Android`(net8.0-android) 헤드. **아직 `Musebase.sln`에 포함하지 않는다** — android 워크로드가 CI/모든 개발 머신에 없어도 메인 빌드가 깨지지 않게 하기 위함. 앱이 성숙하면 sln 등록 + `ci.yml`에 `dotnet workload install android` 단계를 추가한다. -- 빌드(로컬): `dotnet workload install android`(관리자 필요할 수 있음) 후 - `dotnet build src/Musebase.Android -c Release` -- 스파이크 목표: MediaSession(NotificationListenerService)으로 `INowPlayingSource` 구현 - → 재생 곡명/아티스트/위치가 감지되는지 확인. 엔진 조립은 `LyricsEngineFactory` 재사용. -- 골든룰: `Musebase.Core`/`Musebase.Engine`/`contracts/`는 수정 금지(.claude/agents/android.md). +골든룰: `Musebase.Core`/`Musebase.Engine`/`contracts/`는 수정 금지(.claude/agents/android.md). + +## 구성 + +| 파일 | 역할 | +|---|---| +| `Services/MediaListenerService.cs` | `NotificationListenerService` — 알림 접근 권한의 앵커. 알림을 파싱하지 않고, `MediaSessionManager.GetActiveSessions(component)` 호출 자격만 제공 | +| `Services/AndroidNowPlayingSource.cs` | `INowPlayingSource`(Musebase.Engine 계약)의 Android 구현 — 세션 선택(재생 중 우선), 콜백+500ms 폴링, 위치 보간(+1초 미만 역행 흡수) | +| `MainActivity.cs` | 스파이크 UI — 알림 접근 설정 버튼 + 곡명/아티스트/앨범/위치/재생상태/소스앱 1초 갱신 표시 | +| `AndroidManifest.xml` | 템플릿. ``(BIND_NOTIFICATION_LISTENER_SERVICE)와 ``는 C# 특성에서 생성·병합 | + +가사 오버레이·`LyricsEngineFactory` 조립은 이번 스파이크 범위 밖(다음 단계). + +## 빌드 환경 (2026-07 이 머신에서 확인한 상태) + +- .NET SDK 8.0.423 (`C:\Program Files\dotnet`, PATH에 없음 — 세션마다 `$env:Path += ';C:\Program Files\dotnet'`) +- **android 워크로드: 설치됨** (`dotnet workload install android` — Microsoft.Android.Sdk.Windows 34.0.154). 관리자 승격 없이 성공했음(MSI가 UAC 자동 승인 환경). +- **JDK: 없음** (`where.exe java` 실패, `JAVA_HOME` 미설정) → **JDK 17+ 필요** (권장: Microsoft OpenJDK 17) +- **Android SDK: 없음** (`%LOCALAPPDATA%\Android\Sdk` 부재, `ANDROID_HOME` 미설정) → **platform-tools + platforms;android-34 + build-tools 필요** + +이 상태에서 `dotnet build src/Musebase.Android -c Debug`는 다음 오류로 중단된다: + +``` +error XA5300: Android SDK 디렉터리를 찾을 수 없습니다. (https://aka.ms/dotnet-android-install-sdk) +error XA5300: 'AndroidSdkDirectory' MSBuild 속성을 사용자 지정 경로로 설정합니다. +``` + +단, C# 소스 자체는 Mono.Android(API 34) 참조 어셈블리에 대해 **경고 0으로 컴파일 검증 완료** +(csc 직접 호출) — 남은 것은 JDK/SDK만 있으면 되는 패키징 단계다. + +## 사용자가 할 일 — APK 빌드까지 + +가장 쉬운 길은 .NET for Android의 자동 설치 타깃이다(관리자 불필요, 사용자 폴더에 설치): + +```powershell +$env:Path += ';C:\Program Files\dotnet' +# 1) JDK + Android SDK를 지정 폴더에 자동 다운로드/설치 +dotnet build src/Musebase.Android -t:InstallAndroidDependencies -f net8.0-android ` + -p:AndroidSdkDirectory="$env:LOCALAPPDATA\Android\Sdk" ` + -p:JavaSdkDirectory="$env:LOCALAPPDATA\Android\jdk" ` + -p:AcceptAndroidSDKLicenses=true + +# 2) 빌드 (이후에는 같은 -p: 경로 지정 또는 ANDROID_HOME/JAVA_HOME 환경변수 설정) +dotnet build src/Musebase.Android -c Debug ` + -p:AndroidSdkDirectory="$env:LOCALAPPDATA\Android\Sdk" ` + -p:JavaSdkDirectory="$env:LOCALAPPDATA\Android\jdk" +``` + +수동 설치 대안: Microsoft OpenJDK 17(msi) + Android Studio(또는 commandline-tools)로 +`%LOCALAPPDATA%\Android\Sdk`에 platform-tools/android-34 설치 후 `JAVA_HOME`/`ANDROID_HOME` 설정. + +APK 산출 경로(디버그 서명 포함): `src/Musebase.Android/bin/Debug/net8.0-android/com.countnine.musebase-Signed.apk` + +## 폰에서 테스트 (사이드로드) + +1. 폰 USB 디버깅 켜고 `adb install <위 APK 경로>` — 또는 APK를 폰에 복사해 설치 + (출처를 알 수 없는 앱 허용 필요). +2. Musebase 앱 실행 → "알림 접근 권한 설정 열기" 버튼 → 설정 목록에서 **Musebase** 토글 ON. + (설정 경로: 설정 > 알림 > 기기 및 앱 알림 접근 — 기종에 따라 다름) +3. 앱으로 돌아오면 "알림 접근: 허용됨 ✓" 표시. YouTube Music/Spotify/멜론 등에서 재생 시작. +4. 1초 이내에 곡명/아티스트/위치/소스앱이 화면에 갱신되면 스파이크 성공. + 위치가 초 단위로 흐르는지(보간 동작), 곡 넘김 시 즉시 바뀌는지 확인. diff --git a/src/Musebase.Android/Services/AndroidNowPlayingSource.cs b/src/Musebase.Android/Services/AndroidNowPlayingSource.cs new file mode 100644 index 0000000..ff831de --- /dev/null +++ b/src/Musebase.Android/Services/AndroidNowPlayingSource.cs @@ -0,0 +1,329 @@ +using Android.Content; +using Android.Media; +using Android.Media.Session; +using Android.OS; +using Musebase.Engine; + +namespace Musebase.Android.Services; + +/// +/// 의 Android 구현 — MediaSessionManager 래퍼. +/// +/// 동작 원리: +/// 1) 사용자가 설정에서 알림 접근(notification access)을 켜면 +/// (리스너 ComponentName)로 +/// 활성 목록을 얻을 수 있다. +/// 2) 재생 중인 컨트롤러를 우선 선택하고, 콜백()과 +/// 주기 폴링(500ms)으로 메타데이터/재생 상태 변화를 감지한다. +/// (폴링은 Windows판 NowPlayingService와 같은 이유 — 앱에 따라 콜백이 지연/누락된다.) +/// 3) 위치는 PlaybackState.Position + (elapsedRealtime - LastPositionUpdateTime) × 속도로 +/// 보간하고, 같은 곡 재생 중 1초 미만의 역행은 흡수한다(타임라인 갱신 떨림 완화). +/// +/// 모든 상태 갱신·이벤트 발화는 메인 루퍼에서 일어난다(생성/Start를 메인 스레드에서 호출할 것). +/// 계약상 이벤트 스레드 마샬링은 구독자 책임이지만, 이 구현은 메인 스레드로 정렬해 준다. +/// +public sealed class AndroidNowPlayingSource : Java.Lang.Object, + INowPlayingSource, MediaSessionManager.IOnActiveSessionsChangedListener +{ + private static readonly TimeSpan BackwardTolerance = TimeSpan.FromSeconds(1.0); + private const int PollIntervalMs = 500; + + private readonly Context _context; + private readonly ComponentName _listenerComponent; + private readonly Handler _handler = new(Looper.MainLooper!); + + private MediaSessionManager? _manager; + private MediaController? _controller; + private ControllerCallback? _callback; + private bool _started; + private bool _sessionListenerRegistered; + + // 위치 떨림 완화 상태 (같은 곡 재생 중 작은 역행 흡수) + private TimeSpan _smoothedPosition = TimeSpan.MinValue; + private string? _smoothedTrackKey; + + public TrackInfo? CurrentTrack { get; private set; } + public bool IsPlaying { get; private set; } + + public event Action? TrackChanged; + public event Action? IsPlayingChanged; + + public AndroidNowPlayingSource(Context context) + { + _context = context.ApplicationContext ?? context; + _listenerComponent = new ComponentName( + _context, Java.Lang.Class.FromType(typeof(MediaListenerService))); + } + + /// 알림 접근 권한이 이 앱에 허용되어 있는지. + public bool HasNotificationAccess + { + get + { + var enabled = global::Android.Provider.Settings.Secure.GetString( + _context.ContentResolver, "enabled_notification_listeners"); + return enabled?.Contains(_context.PackageName ?? "", StringComparison.Ordinal) == true; + } + } + + /// + /// 감지 시작(메인 스레드에서 호출). 권한이 아직 없으면 폴링만 돌며 + /// 권한이 생기는 즉시 세션 구독을 시작한다 — 재호출해도 안전(멱등). + /// + public void Start() + { + if (_started) return; + _started = true; + _manager ??= (MediaSessionManager?)_context.GetSystemService(Context.MediaSessionService); + PollOnce(); + SchedulePoll(); + } + + public void Stop() + { + _started = false; + _handler.RemoveCallbacksAndMessages(null); + if (_sessionListenerRegistered && _manager is not null) + { + try { _manager.RemoveOnActiveSessionsChangedListener(this); } catch { /* 이미 해제 */ } + _sessionListenerRegistered = false; + } + AttachController(null); + } + + // ---- MediaSessionManager.IOnActiveSessionsChangedListener ---- + + public void OnActiveSessionsChanged(IList? controllers) => + SelectBestController(controllers); + + // ---- 세션 선택 ---- + + private void SchedulePoll() + { + if (!_started) return; + _handler.PostDelayed(() => { PollOnce(); SchedulePoll(); }, PollIntervalMs); + } + + /// 폴링 1회: 권한 확인 → 리스너 등록 → 최적 세션 재선택 → 상태 갱신. + private void PollOnce() + { + if (_manager is null || !HasNotificationAccess) + { + // 권한이 회수됐거나 아직 없음 — 세션 없음으로 정리하고 다음 폴링에서 재시도. + _sessionListenerRegistered = false; + AttachController(null); + return; + } + + try + { + if (!_sessionListenerRegistered) + { + _manager.AddOnActiveSessionsChangedListener(this, _listenerComponent); + _sessionListenerRegistered = true; + } + SelectBestController(_manager.GetActiveSessions(_listenerComponent)); + } + catch (Java.Lang.SecurityException) + { + // 권한 회수 레이스 — 다음 폴링에서 HasNotificationAccess가 걸러 준다. + _sessionListenerRegistered = false; + AttachController(null); + return; + } + + RefreshTrack(); + RefreshPlayback(); + } + + /// + /// 부착할 컨트롤러를 결정한다: 재생 중인 세션 우선, 없으면 목록 첫 세션 + /// (GetActiveSessions는 최근 활성 순으로 정렬돼 있다). 바뀔 때만 재구독. + /// + private void SelectBestController(IList? controllers) + { + MediaController? best = null; + if (controllers is not null) + { + foreach (var c in controllers) + { + best ??= c; + if (c.PlaybackState?.State == PlaybackStateCode.Playing) { best = c; break; } + } + } + AttachController(best); + } + + private void AttachController(MediaController? controller) + { + if (SameController(controller, _controller)) return; + + if (_controller is not null && _callback is not null) + { + try { _controller.UnregisterCallback(_callback); } catch { /* 세션 소멸 레이스 */ } + } + + _controller = controller; + if (_controller is not null) + { + _callback ??= new ControllerCallback(this); + _controller.RegisterCallback(_callback, _handler); + } + + global::Android.Util.Log.Info("Musebase", + $"media session attached: {controller?.PackageName ?? "(none)"}"); + RefreshTrack(); + RefreshPlayback(); + } + + private static bool SameController(MediaController? a, MediaController? b) + { + if (ReferenceEquals(a, b)) return true; + if (a is null || b is null) return false; + // SessionToken 동일성이 정확하지만, 스파이크에선 패키지 단위 비교로 충분하다. + return string.Equals(a.PackageName, b.PackageName, StringComparison.Ordinal); + } + + // ---- 상태 갱신 ---- + + private void RefreshTrack() + { + TrackInfo? track = null; + var controller = _controller; + if (controller is not null) + { + try + { + var md = controller.Metadata; + var title = md?.GetString(MediaMetadata.MetadataKeyTitle); + if (!string.IsNullOrEmpty(title)) + { + var durationMs = md!.GetLong(MediaMetadata.MetadataKeyDuration); + track = new TrackInfo( + title, + md.GetString(MediaMetadata.MetadataKeyArtist) ?? "", + md.GetString(MediaMetadata.MetadataKeyAlbum) ?? "", + durationMs > 0 ? TimeSpan.FromMilliseconds(durationMs) : null, + controller.PackageName ?? ""); + } + } + catch { /* 세션 소멸 레이스 — 트랙 없음 처리 */ } + } + + if (!Equals(track, CurrentTrack)) + { + CurrentTrack = track; + TrackChanged?.Invoke(track); + } + } + + private void RefreshPlayback() + { + var playing = false; + try { playing = _controller?.PlaybackState?.State == PlaybackStateCode.Playing; } + catch { /* 세션 소멸 레이스 */ } + + if (playing != IsPlaying) + { + IsPlaying = playing; + IsPlayingChanged?.Invoke(playing); + } + } + + /// + /// 보간된 현재 재생 위치. PlaybackState는 갱신이 드물어 + /// LastPositionUpdateTime 이후 경과분 × 재생 속도를 더한다. + /// 같은 곡 재생 중 1초 미만의 역행은 흡수한다(시킹 등 큰 변화는 그대로 반영). + /// + public TimeSpan? GetEstimatedPosition() + { + PlaybackState? state; + try { state = _controller?.PlaybackState; } + catch { return null; } + if (state is null) return null; + + var positionMs = (double)state.Position; + var playing = state.State == PlaybackStateCode.Playing; + if (playing) + { + var elapsedMs = SystemClock.ElapsedRealtime() - state.LastPositionUpdateTime; + if (elapsedMs > 0 && elapsedMs < 30 * 60 * 1000) + { + var speed = state.PlaybackSpeed; + positionMs += elapsedMs * (speed > 0 ? speed : 1f); + } + } + var position = TimeSpan.FromMilliseconds(positionMs); + + var trackKey = CurrentTrack is { } t ? $"{t.Title}|{t.Artist}" : null; + if (playing && trackKey == _smoothedTrackKey && _smoothedPosition != TimeSpan.MinValue) + { + var delta = position - _smoothedPosition; + if (delta < TimeSpan.Zero && delta > -BackwardTolerance) + position = _smoothedPosition; // 작은 역행은 유지 + } + _smoothedPosition = position; + _smoothedTrackKey = trackKey; + return position; + } + + /// 현재 세션의 컨트롤 가용 여부(PlaybackState.Actions 비트). + public PlaybackControls GetControls() + { + long actions; + try { actions = _controller?.PlaybackState?.Actions ?? 0; } + catch { return PlaybackControls.None; } + + return new PlaybackControls( + (actions & PlaybackState.ActionSkipToPrevious) != 0, + (actions & (PlaybackState.ActionPlay | PlaybackState.ActionPause | PlaybackState.ActionPlayPause)) != 0, + (actions & PlaybackState.ActionSkipToNext) != 0); + } + + public Task TogglePlayPauseAsync() + { + var controller = _controller; + var tc = controller?.GetTransportControls(); + if (tc is null) return Task.FromResult(false); + try + { + if (controller!.PlaybackState?.State == PlaybackStateCode.Playing) tc.Pause(); + else tc.Play(); + return Task.FromResult(true); + } + catch { return Task.FromResult(false); } + } + + public Task SkipNextAsync() + { + var tc = _controller?.GetTransportControls(); + if (tc is null) return Task.FromResult(false); + try { tc.SkipToNext(); return Task.FromResult(true); } + catch { return Task.FromResult(false); } + } + + public Task SkipPreviousAsync() + { + var tc = _controller?.GetTransportControls(); + if (tc is null) return Task.FromResult(false); + try { tc.SkipToPrevious(); return Task.FromResult(true); } + catch { return Task.FromResult(false); } + } + + protected override void Dispose(bool disposing) + { + if (disposing) Stop(); + base.Dispose(disposing); + } + + /// 선택된 컨트롤러의 변경 콜백 → 소스 상태 갱신으로 전달. + private sealed class ControllerCallback : MediaController.Callback + { + private readonly AndroidNowPlayingSource _owner; + public ControllerCallback(AndroidNowPlayingSource owner) => _owner = owner; + + public override void OnMetadataChanged(MediaMetadata? metadata) => _owner.RefreshTrack(); + public override void OnPlaybackStateChanged(PlaybackState? state) => _owner.RefreshPlayback(); + public override void OnSessionDestroyed() => _owner.AttachController(null); + } +} diff --git a/src/Musebase.Android/Services/MediaListenerService.cs b/src/Musebase.Android/Services/MediaListenerService.cs new file mode 100644 index 0000000..ac78906 --- /dev/null +++ b/src/Musebase.Android/Services/MediaListenerService.cs @@ -0,0 +1,47 @@ +using Android.App; +using Android.Service.Notification; + +namespace Musebase.Android.Services; + +/// +/// 알림 접근(notification access) 권한의 앵커가 되는 . +/// +/// Android에서 MediaSessionManager.GetActiveSessions()는 알림 접근이 허용된 +/// NotificationListenerService의 ComponentName을 요구한다. 즉 이 서비스가 하는 일은 +/// "권한의 근거"가 전부다 — 알림 자체를 파싱하지 않으며, 사용자가 설정에서 알림 접근을 +/// 켜면 시스템이 이 서비스를 바인드하고, 그때부터 임의 컨텍스트에서 +/// GetActiveSessions(component)가 미디어 세션 목록을 반환한다. +/// +/// 매니페스트의 service 선언(BIND_NOTIFICATION_LISTENER_SERVICE 권한 + 인텐트 필터)은 +/// 아래 특성에서 생성된다. Name을 고정해 ACW(Java 래퍼) 클래스명이 빌드마다 +/// 흔들리지 않게 한다(설정 화면에서 사용자가 켠 토글이 유지되도록). +/// +[Service( + Label = "Musebase", + Name = "com.countnine.musebase.MediaListenerService", + Exported = true, + Permission = global::Android.Manifest.Permission.BindNotificationListenerService)] +[IntentFilter(new[] { "android.service.notification.NotificationListenerService" })] +public sealed class MediaListenerService : NotificationListenerService +{ + /// 시스템이 리스너를 바인드했는지(알림 접근 허용 + 연결 완료). + public static bool IsConnected { get; private set; } + + public override void OnListenerConnected() + { + base.OnListenerConnected(); + IsConnected = true; + global::Android.Util.Log.Info("Musebase", "MediaListenerService connected (notification access granted)."); + } + + public override void OnListenerDisconnected() + { + base.OnListenerDisconnected(); + IsConnected = false; + global::Android.Util.Log.Info("Musebase", "MediaListenerService disconnected."); + } + + // 알림 내용은 사용하지 않는다 — 재생 정보는 MediaSessionManager 경유(AndroidNowPlayingSource). + public override void OnNotificationPosted(StatusBarNotification? sbn) { } + public override void OnNotificationRemoved(StatusBarNotification? sbn) { } +} From 36f2d3c71aeb182472efcde28fdc164f96c44ec3 Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 16 Jul 2026 23:22:25 +0900 Subject: [PATCH 04/17] fix(android): alias MediaController to resolve CS0104 with implicit Android.Widget using Found on first real APK build (JDK/SDK installed via InstallAndroidDependencies). APK builds clean: 0 warnings, 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Musebase.Android/Services/AndroidNowPlayingSource.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Musebase.Android/Services/AndroidNowPlayingSource.cs b/src/Musebase.Android/Services/AndroidNowPlayingSource.cs index ff831de..45a62ad 100644 --- a/src/Musebase.Android/Services/AndroidNowPlayingSource.cs +++ b/src/Musebase.Android/Services/AndroidNowPlayingSource.cs @@ -3,6 +3,8 @@ using Android.Media.Session; using Android.OS; using Musebase.Engine; +// 암시적 using의 Android.Widget.MediaController와 모호 참조(CS0104) 방지 +using MediaController = Android.Media.Session.MediaController; namespace Musebase.Android.Services; From 36379ab375b3c816241cd0635e50862b45ee7f82 Mon Sep 17 00:00:00 2001 From: Jay Date: Thu, 16 Jul 2026 23:37:27 +0900 Subject: [PATCH 05/17] =?UTF-8?q?fix(android):=20embed=20assemblies=20into?= =?UTF-8?q?=20APK=20=E2=80=94=20sideloaded=20debug=20APK=20crashed=20at=20?= =?UTF-8?q?launch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debug default (Fast Deployment) leaves assemblies out of the APK; manual sideload then crashes on startup. EmbedAssembliesIntoApk=true always. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Musebase.Android/Musebase.Android.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Musebase.Android/Musebase.Android.csproj b/src/Musebase.Android/Musebase.Android.csproj index eece8a5..2d45cf7 100644 --- a/src/Musebase.Android/Musebase.Android.csproj +++ b/src/Musebase.Android/Musebase.Android.csproj @@ -20,6 +20,10 @@ apk AndroidManifest.xml + + + true From 2390020d5eb4ebbaa5a019f9a18cbc64af69c6d3 Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 17 Jul 2026 00:20:54 +0900 Subject: [PATCH 06/17] feat(backend): telemetry ingest worker (Cloudflare Workers + D1) - POST /ingest: anonymous opt-in event batches (validated types/sizes, no IP stored), GET /stats: public 30-day aggregates, GET /healthz - D1 schema: events table + indexes (raw retention 90d planned) - Deployed: https://musebase-telemetry.musebase.workers.dev Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 1 + backend/telemetry/.gitignore | 1 + backend/telemetry/schema.sql | 13 ++++++ backend/telemetry/src/worker.js | 80 +++++++++++++++++++++++++++++++++ backend/telemetry/wrangler.toml | 11 +++++ 5 files changed, 106 insertions(+) create mode 100644 backend/telemetry/.gitignore create mode 100644 backend/telemetry/schema.sql create mode 100644 backend/telemetry/src/worker.js create mode 100644 backend/telemetry/wrangler.toml diff --git a/CLAUDE.md b/CLAUDE.md index b657353..7b54d48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ | `src/Musebase.Android/` | android 에이전트 | (예정) .NET for Android | | `src/Musebase.Browser/` | browser 에이전트 | (예정) ASP.NET WS 방송 + 웹 디스플레이 | | `apple/` | apple 에이전트 | (예정) Swift — 코드 비공유, `contracts/`로만 정렬 | +| `backend/telemetry/` | core 에이전트 | 텔레메트리 수집 Worker(Cloudflare, `wrangler deploy`로 배포) | | `tests/`, `docs/`, `scripts/`, `tools/` | 공용 | 소유 경로에 대응하는 부분만 수정 | **골든룰: 코어(`Musebase.Core`/`Musebase.Engine`/`contracts/`)는 core 에이전트만 수정한다.** diff --git a/backend/telemetry/.gitignore b/backend/telemetry/.gitignore new file mode 100644 index 0000000..b75a0fa --- /dev/null +++ b/backend/telemetry/.gitignore @@ -0,0 +1 @@ +.wrangler/ diff --git a/backend/telemetry/schema.sql b/backend/telemetry/schema.sql new file mode 100644 index 0000000..1da47ce --- /dev/null +++ b/backend/telemetry/schema.sql @@ -0,0 +1,13 @@ +-- Musebase 텔레메트리 이벤트 저장소 (D1/SQLite) +-- 원본 이벤트 보존 90일(정리는 추후 크론), 개인정보 없음(익명 랜덤 client_id). +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + received_at TEXT NOT NULL, -- 서버 수신 시각(ISO-8601, UTC) + client_id TEXT NOT NULL, -- 앱이 로컬 생성한 랜덤 GUID(재설정 가능) + platform TEXT NOT NULL, -- windows | android | browser | macos | ios + app_version TEXT NOT NULL, + type TEXT NOT NULL, -- 이벤트 종류(app_session, lyrics_search, ...) + props TEXT NOT NULL DEFAULT '{}' -- 이벤트별 속성(JSON) +); +CREATE INDEX IF NOT EXISTS idx_events_type_time ON events(type, received_at); +CREATE INDEX IF NOT EXISTS idx_events_time ON events(received_at); diff --git a/backend/telemetry/src/worker.js b/backend/telemetry/src/worker.js new file mode 100644 index 0000000..dac9d5b --- /dev/null +++ b/backend/telemetry/src/worker.js @@ -0,0 +1,80 @@ +// Musebase 텔레메트리 수집 Worker. +// - POST /ingest : 앱이 보내는 익명 이벤트 배치 저장 (옵트인 사용자만 전송) +// - GET /stats : 최근 30일 이벤트 종류별 건수 (투명성 차원에서 공개) +// - GET /healthz +// 개인정보 없음: client_id는 앱이 만든 랜덤 GUID. IP는 저장하지 않는다. + +const MAX_BODY_BYTES = 64 * 1024; // 배치 상한 64KB +const MAX_EVENTS_PER_BATCH = 100; +const PLATFORMS = new Set(["windows", "android", "browser", "macos", "ios"]); +// 앱이 정의한 이벤트만 수용(오남용·쓰레기 데이터 차단). contracts/telemetry-events.md와 동기화. +const EVENT_TYPES = new Set([ + "app_session", + "playback_source", + "lyrics_search", + "lyrics_not_found", + "wrong_lyrics", + "translation", + "feature_use", + "error", +]); + +function json(data, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + }); +} + +async function handleIngest(request, env) { + if (request.headers.get("content-type")?.includes("application/json") !== true) + return json({ error: "content-type must be application/json" }, 415); + + const raw = await request.text(); + if (raw.length > MAX_BODY_BYTES) return json({ error: "body too large" }, 413); + + let body; + try { body = JSON.parse(raw); } catch { return json({ error: "invalid json" }, 400); } + + const { clientId, platform, appVersion, events } = body ?? {}; + if (typeof clientId !== "string" || clientId.length < 8 || clientId.length > 64) + return json({ error: "clientId" }, 400); + if (!PLATFORMS.has(platform)) return json({ error: "platform" }, 400); + if (typeof appVersion !== "string" || appVersion.length > 32) + return json({ error: "appVersion" }, 400); + if (!Array.isArray(events) || events.length === 0 || events.length > MAX_EVENTS_PER_BATCH) + return json({ error: "events" }, 400); + + const now = new Date().toISOString(); + const stmt = env.DB.prepare( + "INSERT INTO events (received_at, client_id, platform, app_version, type, props) VALUES (?1, ?2, ?3, ?4, ?5, ?6)" + ); + const rows = []; + for (const e of events) { + if (!e || !EVENT_TYPES.has(e.type)) return json({ error: `unknown event type` }, 400); + const props = JSON.stringify(e.props ?? {}); + if (props.length > 4096) return json({ error: "props too large" }, 400); + rows.push(stmt.bind(now, clientId, platform, appVersion, e.type, props)); + } + await env.DB.batch(rows); + return json({ ok: true, stored: rows.length }); +} + +async function handleStats(env) { + const since = new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString(); + const { results } = await env.DB.prepare( + `SELECT type, COUNT(*) AS count, COUNT(DISTINCT client_id) AS clients + FROM events WHERE received_at >= ?1 GROUP BY type ORDER BY count DESC` + ).bind(since).all(); + return json({ since, totals: results }); +} + +export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === "/healthz") return new Response("ok"); + if (url.pathname === "/ingest" && request.method === "POST") return handleIngest(request, env); + if (url.pathname === "/stats" && request.method === "GET") return handleStats(env); + return json({ error: "not found" }, 404); + }, +}; diff --git a/backend/telemetry/wrangler.toml b/backend/telemetry/wrangler.toml new file mode 100644 index 0000000..cc6be50 --- /dev/null +++ b/backend/telemetry/wrangler.toml @@ -0,0 +1,11 @@ +# Musebase 텔레메트리 수집 Worker (Cloudflare Workers + D1) +# 배포: backend/telemetry 에서 `wrangler deploy` +# 설계·수집 항목: docs/adr/0004(예정) + TELEMETRY.md(예정) +name = "musebase-telemetry" +main = "src/worker.js" +compatibility_date = "2026-07-01" + +[[d1_databases]] +binding = "DB" +database_name = "musebase-telemetry" +database_id = "7a4355b5-9813-421e-9b66-747651b60c87" From df70c9866595cf5996ab230589890bfc0d38da33 Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 17 Jul 2026 00:25:54 +0900 Subject: [PATCH 07/17] =?UTF-8?q?docs+core:=20telemetry=20foundation=20?= =?UTF-8?q?=E2=80=94=20ADR-0004,=20TELEMETRY.md,=20event=20contract,=20ITe?= =?UTF-8?q?lemetry=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR-0004: opt-in dual-tier anonymous telemetry, self-hosted backend - TELEMETRY.md: user-facing full disclosure (ko/en) linked from settings later - contracts/telemetry-events.md: wire format + event/props table (v1) - Musebase.Engine.ITelemetry + NoopTelemetry + TelemetryEvents constants (stable seam so core/windows agents can build against it in parallel) Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + TELEMETRY.md | 52 +++++++++++++++++++++++++++++++ contracts/telemetry-events.md | 42 +++++++++++++++++++++++++ docs/adr/0004-telemetry.md | 36 +++++++++++++++++++++ src/Musebase.Engine/ITelemetry.cs | 36 +++++++++++++++++++++ 5 files changed, 167 insertions(+) create mode 100644 TELEMETRY.md create mode 100644 contracts/telemetry-events.md create mode 100644 docs/adr/0004-telemetry.md create mode 100644 src/Musebase.Engine/ITelemetry.cs diff --git a/.gitignore b/.gitignore index 8c39f35..de6d017 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ Releases/ # 로컬 테스트 빌드 산출물 publish-test/ publish-singlefile/ +.wrangler/ diff --git a/TELEMETRY.md b/TELEMETRY.md new file mode 100644 index 0000000..40ed945 --- /dev/null +++ b/TELEMETRY.md @@ -0,0 +1,52 @@ +# Musebase 사용 통계(텔레메트리) 안내 / Telemetry Notice + +**한국어** · [English below](#english) + +Musebase는 서비스 개선을 위해 **사용자가 동의한 경우에만** 익명 사용 통계를 수집합니다. + +- **기본값은 꺼짐(옵트인)** — 첫 실행 때 묻고, 설정 [일반] 탭에서 언제든 바꿀 수 있습니다. +- **익명** — 앱이 로컬에서 만든 무작위 ID(GUID)만 사용합니다. 기기·계정 정보에서 파생하지 + 않으며 설정에서 재설정할 수 있습니다. 서버는 IP를 저장하지 않습니다. +- **동의를 끄면 수집 자체를 하지 않습니다** ("모아두고 안 보내는" 방식이 아님). +- 수집 서버도 직접 운영합니다(Cloudflare Workers). **제3자 분석 서비스에 보내지 않습니다.** +- 최근 30일 집계는 누구나 볼 수 있습니다: https://musebase-telemetry.musebase.workers.dev/stats + +## 무엇을 수집하나요? + +### ① 기본 통계 (곡 정보 없음) +| 항목 | 예 | 쓰임 | +|---|---|---| +| 앱 버전·OS 버전(대분류)·UI 언어·번역 대상 언어 | `0.10.0`, `Windows 10`, `ko` | 지원 우선순위 | +| 재생 소스 앱 | `Spotify.exe` | 어떤 음원 서비스를 우선 지원할지 | +| 가사 검색 결과 | 1위 소스, 소스별 히트/실패/응답시간, 캐시 적중 | 소스 랭킹·속도 개선 | +| 번역 | 선택 엔진, 캐시 적중률 | 무료 엔진 기본값 검증 | +| 기능 사용 횟수 | 미디어 컨트롤/편집/내보내기 등 | UX 우선순위 | +| 오류 | 종류·발생 위치(스택 최상위) | 크래시 수정 | + +### ② 품질 리포트 (별도 동의, 곡 제목·아티스트 포함) +| 항목 | 쓰임 | +|---|---| +| "틀린 가사"로 표시한 곡 (제목/아티스트/가사 소스) | 오매칭 수정 | +| 가사를 못 찾은 곡 (제목/아티스트) | 소스 커버리지 확대 | + +### 절대 수집하지 않는 것 +가사 본문, 전체 재생 이력, API 키, 파일 경로, 이메일·계정 등 개인정보, IP 저장. + +## 직접 확인하기 +- 이벤트 계약: [`contracts/telemetry-events.md`](contracts/telemetry-events.md) +- 수집 서버 코드: [`backend/telemetry/`](backend/telemetry/) +- 앱 쪽 구현: `Musebase.Engine.ITelemetry` 및 각 플랫폼 헤드 (전부 이 리포에 공개) +- 원본 이벤트 보존 90일, 집계는 영구. + +--- + +## English + +Musebase collects anonymous usage statistics **only if you opt in** (off by default; asked on +first run, changeable anytime in Settings). Two separate consents: **① basic stats** (features, +performance, environment — no song data) and **② quality reports** (title/artist of tracks you +mark as wrong lyrics or that fail lyrics search). The identifier is a locally generated random +GUID (resettable); the server stores no IP. When consent is off, nothing is collected at all. +We run our own collection endpoint (Cloudflare Workers) — no third-party analytics. Never +collected: lyrics text, full listening history, API keys, file paths, personal data. +30-day public aggregates: https://musebase-telemetry.musebase.workers.dev/stats diff --git a/contracts/telemetry-events.md b/contracts/telemetry-events.md new file mode 100644 index 0000000..c932ba9 --- /dev/null +++ b/contracts/telemetry-events.md @@ -0,0 +1,42 @@ +# 텔레메트리 이벤트 계약 (v1) + +앱 → 수집 서버(`backend/telemetry`, `POST /ingest`)의 언어 중립 계약. 단일 진실은 이 문서. +Worker의 화이트리스트(`EVENT_TYPES`)·앱 계측·`TELEMETRY.md`는 이 문서와 **같은 PR에서** 동기화한다. +정책(옵트인 2단계, 익명)은 ADR-0004. + +## 전송 형식 + +`POST https://musebase-telemetry.musebase.workers.dev/ingest` (application/json) + +```json +{ + "clientId": "f3a9…(로컬 생성 랜덤 GUID, 8–64자)", + "platform": "windows | android | browser | macos | ios", + "appVersion": "0.10.0", + "events": [ { "type": "<아래 표>", "props": { } } ] +} +``` + +- 배치 ≤ 100건, 본문 ≤ 64KB, `props` 직렬화 ≤ 4KB/건. 초과·미정의 type = 전체 400 거부. +- 시각 필드는 보내지 않는다 — 서버 수신 시각(`received_at`)만 저장(정밀 타임라인 불필요·익명성↑). +- 실패 시 앱은 로컬 큐에 보관 후 재시도(최대 보관 상한 있음), 앱 동작에는 절대 영향 없음. + +## 이벤트 (① = 기본 통계 동의, ② = 품질 리포트 동의) + +| type | 동의 | props | 비고 | +|---|---|---|---| +| `app_session` | ① | `uiLang`, `targetLang`, `engine`(deepl/libretranslate/none), `sources`(활성 소스 id 배열), `sourceMode`(auto/특정앱), `osVersion`(대분류, 예 "Windows 10") | 하루 1회(일일 ping 겸용) | +| `playback_source` | ① | `appId`(SMTC/MediaSession 앱 식별자) | 클라이언트가 하루 중 앱별 1회로 디바운스 | +| `lyrics_search` | ① | `winner`(채택 소스 id 또는 "none"), `perSource`({id: {hit: bool, latencyMs: int}}), `cached`(bool), `cleanedQueryUsed`(bool) | 곡 정보 없음 | +| `lyrics_not_found` | ② | `title`, `artist` | 검색 실패 곡 | +| `wrong_lyrics` | ② | `title`, `artist`, `source`(채택됐던 소스 id) | "틀린 가사" 표시 시 | +| `translation` | ① | `engine`, `cacheHitPct`(0–100 정수), `linesBucket`("1-10"/"11-50"/"51+") | 세션 집계로 전송 | +| `feature_use` | ① | `feature`(mediaControls/edit/export/offset/search/karaoke…), `count`(int) | 세션 집계로 전송 | +| `error` | ① | `kind`(예외 타입명), `frame`(최상위 스택프레임 1개), `fatal`(bool) | 메시지 본문·경로 금지 | + +## 변경 규칙 + +- **props 필드 추가 = 하위 호환**(서버는 JSON 그대로 저장) — v1 유지. type 추가는 Worker + 화이트리스트와 함께. type 의미 변경/삭제는 breaking → 버전 올리고 ADR 기록. +- 새 props에 개인정보·곡 정보(①에서)·자유 텍스트를 넣지 않는다. 자유 텍스트가 필요하면 + 버킷/열거형으로 바꿔 설계한다. diff --git a/docs/adr/0004-telemetry.md b/docs/adr/0004-telemetry.md new file mode 100644 index 0000000..aa2749f --- /dev/null +++ b/docs/adr/0004-telemetry.md @@ -0,0 +1,36 @@ +# ADR-0004: 익명 옵트인 텔레메트리 + +- 상태: 승인 (2026-07-17) +- 결정자: countnine +- 관련: ADR-0003(모노레포·거버넌스), `TELEMETRY.md`(사용자 공개 문서), `contracts/telemetry-events.md`(이벤트 계약) + +## 맥락 + +1인 개발 + 다중 플랫폼에서 개선 우선순위를 감으로 정하고 있다. 어떤 음원 서비스가 많이 +쓰이는지, 가사 소스별 1위 히트율, 틀린 가사로 표시되는 곡, 안 쓰이는 기능, 크래시를 +데이터로 알아야 한다. 단, 오픈소스 가사 앱의 신뢰가 자산이므로 프라이버시가 우선이다. + +## 결정 + +1. **옵트인(기본 꺼짐)**. 첫 실행 동의 다이얼로그 + 설정 토글로 언제든 변경. + 옵트아웃은 오픈소스 신뢰 훼손 + GDPR 복잡성으로 기각. +2. **2단계 동의** — 다이얼로그에 두 체크박스 모두 노출(기본 모두 꺼짐): + - **① 기본 통계**: 기능·성능·환경. **곡 정보 없음.** + - **② 품질 리포트**: 틀린가사(`wrong_lyrics`)·검색실패(`lyrics_not_found`)의 **곡 제목/아티스트 포함**. + 곡 메타데이터 없이는 가사 품질을 고칠 수 없어 분리 동의로 정직하게 받는다. +3. **익명**: 식별자는 로컬 생성 **랜덤 GUID**(하드웨어 파생 금지, 설정에서 재설정 가능). + 서버는 IP를 저장하지 않는다. 가사 본문·전체 재생 이력·API 키·파일 경로는 절대 수집 금지. +4. **동의 꺼짐 = `NoopTelemetry`** — "수집하되 미전송"이 아니라 수집 자체를 안 한다. +5. **백엔드는 자체 운영**: Cloudflare Workers + D1 (`backend/telemetry/`, + https://musebase-telemetry.musebase.workers.dev). 제3자 분석 SaaS(PostHog 등)는 + "익명 데이터도 제3자에게 안 보낸다"는 약속을 지키기 위해 기각. 무료 티어로 충분. + 원본 이벤트 보존 90일(집계는 영구), `GET /stats` 30일 집계는 투명성 차원에서 공개. +6. **구조**: `Musebase.Engine.ITelemetry`(플랫폼 무관 계약) → Core/Engine이 계측 훅 발화 → + 플랫폼 헤드가 구현(Windows: 로컬 JSONL 큐 + 배치 업로드, 실패 무해). 이벤트 스키마는 + `contracts/telemetry-events.md`가 단일 진실이며 Worker의 화이트리스트와 동기화한다. + +## 결과 + +- 사용자 공개 문서 `TELEMETRY.md`(수집 항목 전체 + 검증 방법) — 설정창에서 링크. +- 릴리스 노트에 도입 명시. 이벤트 추가/변경은 계약 문서·Worker 화이트리스트·TELEMETRY.md를 + 같은 PR에서 갱신(코어 에이전트 소유). diff --git a/src/Musebase.Engine/ITelemetry.cs b/src/Musebase.Engine/ITelemetry.cs new file mode 100644 index 0000000..502c081 --- /dev/null +++ b/src/Musebase.Engine/ITelemetry.cs @@ -0,0 +1,36 @@ +namespace Musebase.Engine; + +/// +/// 익명 옵트인 텔레메트리 계약(ADR-0004). 이벤트 스키마의 단일 진실은 +/// contracts/telemetry-events.md — type/props는 반드시 그 문서를 따른다. +/// Core/Engine은 이 인터페이스로 계측만 발화하고, 큐잉·전송·동의 관리는 플랫폼 헤드가 구현한다. +/// 구현은 절대 던지지 않아야 하며(수집 실패는 무해), 호출 스레드를 블로킹하지 않아야 한다. +/// +public interface ITelemetry +{ + /// 이벤트 1건 기록. 동의가 없으면 구현이 무시한다(② 전용 타입 포함). + void Track(string type, IReadOnlyDictionary? props = null); +} + +/// 동의 꺼짐/미주입 기본값 — 수집 자체를 하지 않는다. +public sealed class NoopTelemetry : ITelemetry +{ + public static readonly NoopTelemetry Instance = new(); + private NoopTelemetry() { } + public void Track(string type, IReadOnlyDictionary? props = null) { } +} + +/// 이벤트 type 상수(contracts/telemetry-events.md와 1:1). +public static class TelemetryEvents +{ + // ① 기본 통계 + public const string AppSession = "app_session"; + public const string PlaybackSource = "playback_source"; + public const string LyricsSearch = "lyrics_search"; + public const string Translation = "translation"; + public const string FeatureUse = "feature_use"; + public const string Error = "error"; + // ② 품질 리포트(곡 제목/아티스트 포함 — 별도 동의) + public const string LyricsNotFound = "lyrics_not_found"; + public const string WrongLyrics = "wrong_lyrics"; +} From 87819d485a013bce6e284f98a0d3ec30fbbe43f6 Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 17 Jul 2026 10:41:49 +0900 Subject: [PATCH 08/17] docs(contracts): clarify telemetry emission cadence (per-track translation, client-side feature_use aggregation) Co-Authored-By: Claude Opus 4.8 (1M context) --- contracts/telemetry-events.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/telemetry-events.md b/contracts/telemetry-events.md index c932ba9..e1f0cd5 100644 --- a/contracts/telemetry-events.md +++ b/contracts/telemetry-events.md @@ -30,8 +30,8 @@ Worker의 화이트리스트(`EVENT_TYPES`)·앱 계측·`TELEMETRY.md`는 이 | `lyrics_search` | ① | `winner`(채택 소스 id 또는 "none"), `perSource`({id: {hit: bool, latencyMs: int}}), `cached`(bool), `cleanedQueryUsed`(bool) | 곡 정보 없음 | | `lyrics_not_found` | ② | `title`, `artist` | 검색 실패 곡 | | `wrong_lyrics` | ② | `title`, `artist`, `source`(채택됐던 소스 id) | "틀린 가사" 표시 시 | -| `translation` | ① | `engine`, `cacheHitPct`(0–100 정수), `linesBucket`("1-10"/"11-50"/"51+") | 세션 집계로 전송 | -| `feature_use` | ① | `feature`(mediaControls/edit/export/offset/search/karaoke…), `count`(int) | 세션 집계로 전송 | +| `translation` | ① | `engine`, `cacheHitPct`(0–100 정수), `linesBucket`("1-10"/"11-50"/"51+") | 곡당 1회(번역 파이프라인 완료 시) | +| `feature_use` | ① | `feature`(mediaControls/edit/export/offset/search/karaoke…), `count`(int) | 클라이언트가 세션 단위로 집계해 전송 | | `error` | ① | `kind`(예외 타입명), `frame`(최상위 스택프레임 1개), `fatal`(bool) | 메시지 본문·경로 금지 | ## 변경 규칙 From 3a1a7e59c38e42467c8a82e5c8196f6d2e634fdd Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 17 Jul 2026 10:56:05 +0900 Subject: [PATCH 09/17] =?UTF-8?q?feat(core):=20telemetry=20instrumentation?= =?UTF-8?q?=20=E2=80=94=20lyrics=5Fsearch/not=5Ffound/wrong=5Flyrics/playb?= =?UTF-8?q?ack=5Fsource/translation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit contracts/telemetry-events.md(v1)에 따라 코어 계측 훅을 발화한다(ADR-0004). 동의 관리·큐잉·전송은 플랫폼 ITelemetry 구현 책임 — 코어는 Noop 기본값으로 무수집. - LyricsEngineFactory.Create에 선택 매개변수 ITelemetry? telemetry 추가 (기본 null → NoopTelemetry, 기존 호출부 무수정 컴파일 유지). BuildTranslation이 EngineId를 채워 translation 이벤트의 engine 식별에 사용. - lyrics_search(①): 검색 1회당 소스별 hit/latencyMs(SearchDiagnostics 집계), 채택 소스(winner, 없으면 "none"), cached, cleanedQueryUsed(정제 변형 채택 여부). 곡 제목/아티스트는 절대 포함하지 않음. 캐시 적중 시 cached=true·perSource={}. - lyrics_not_found(②): 모든 소스 전패 시 title/artist와 함께 발화. - wrong_lyrics(②): MarkWrongLyrics 시 title/artist + 채택됐던 소스 id. - playback_source(①): 트랙 변경 시 TrackInfo.SourceAppId — 같은 트랙 반복 통지(SMTC 재발화 등)는 억제, 일일 디바운스는 클라이언트 책임. - translation(①): 곡당 1회(실번역이 필요했던 첫 완료 시점) — engine, cacheHitPct(0–100 정수), linesBucket(1-10/11-50/51+). TranslationRunStats 집계. - 테스트 5건 추가(FakeTelemetry): 검색 성공/전패 필드 검증, wrong_lyrics, playback_source 중복 억제, translation 캐시 적중률·버킷. 전체 87건 통과. 계약·Worker 화이트리스트·TELEMETRY.md와 어긋남 없음(이벤트 type/props 문서 그대로). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Search/LyricsSearchService.cs | 16 +- src/Musebase.Core/Search/SearchDiagnostics.cs | 52 ++++ .../Translation/LyricsTranslationService.cs | 26 +- src/Musebase.Engine/LyricsCoordinator.cs | 106 +++++++- src/Musebase.Engine/LyricsEngineFactory.cs | 14 +- tests/Musebase.Core.Tests/TelemetryTests.cs | 246 ++++++++++++++++++ 6 files changed, 453 insertions(+), 7 deletions(-) create mode 100644 src/Musebase.Core/Search/SearchDiagnostics.cs create mode 100644 tests/Musebase.Core.Tests/TelemetryTests.cs diff --git a/src/Musebase.Core/Search/LyricsSearchService.cs b/src/Musebase.Core/Search/LyricsSearchService.cs index 2035f5a..e8a39e4 100644 --- a/src/Musebase.Core/Search/LyricsSearchService.cs +++ b/src/Musebase.Core/Search/LyricsSearchService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Threading.Channels; @@ -23,10 +24,12 @@ public LyricsSearchService(params ILyricsProvider[] providers) /// 모든 제공자 결과를 도착 순으로 스트리밍 (제공자 간 순서 비보장). /// 원본 검색어와 함께 정제 변형(피처링/리마스터 등 제거)도 검색해 커버리지를 넓히고, /// (제공자, 곡 토큰) 기준으로 중복 결과를 제거한다. + /// 를 주면 소스별 히트·지연을 집계한다(텔레메트리용, 곡 정보 없음). /// public async IAsyncEnumerable SearchAsync( LyricsSearchRequest request, - [EnumeratorCancellation] CancellationToken ct = default) + [EnumeratorCancellation] CancellationToken ct = default, + SearchDiagnostics? diagnostics = null) { var requests = new List { request }; foreach (var variant in SearchTermCleaner.Variants(request.Term)) @@ -38,10 +41,17 @@ public async IAsyncEnumerable SearchAsync( async Task ProcessAsync(ILyricsProvider provider, LyricsSearchRequest req) { + var sw = diagnostics is null ? null : Stopwatch.StartNew(); + var hit = false; try { await foreach (var lyrics in provider.GetLyricsAsync(req, ct).ConfigureAwait(false)) { + if (!hit) + { + hit = true; // 중복 제거 전 판정 — 제공자가 결과를 냈다는 사실 자체를 기록 + diagnostics?.ReportHit(provider.ServiceName, sw!.ElapsedMilliseconds); + } bool fresh; lock (seenLock) fresh = seen.Add(DedupKey(lyrics)); if (fresh) await channel.Writer.WriteAsync(lyrics, ct).ConfigureAwait(false); @@ -51,6 +61,10 @@ async Task ProcessAsync(ILyricsProvider provider, LyricsSearchRequest req) { // 취소는 조용히 } + finally + { + if (!hit) diagnostics?.ReportMiss(provider.ServiceName, sw?.ElapsedMilliseconds ?? 0); + } } var workers = (from provider in _providers diff --git a/src/Musebase.Core/Search/SearchDiagnostics.cs b/src/Musebase.Core/Search/SearchDiagnostics.cs new file mode 100644 index 0000000..f7fb696 --- /dev/null +++ b/src/Musebase.Core/Search/SearchDiagnostics.cs @@ -0,0 +1,52 @@ +namespace Musebase.Core.Search; + +/// +/// 검색 1회의 소스별 결과 요약(텔레메트리·진단용). 곡 정보는 담지 않는다. +/// 같은 제공자가 여러 검색어(원본 + 정제 변형)를 수행하므로 제공자(ServiceName) 단위로 집계한다: +/// 히트 시 첫 결과까지의 지연(가장 빠른 요청), 전패 시 마지막 요청 완료까지의 지연. +/// +public sealed class SearchDiagnostics +{ + /// 소스 1개의 결과: 히트 여부와 지연(ms). + public sealed record SourceStat(bool Hit, int LatencyMs); + + private readonly object _lock = new(); + private readonly Dictionary _perSource = new(); + + /// 제공자(ServiceName)별 히트 여부·지연 스냅샷. + public IReadOnlyDictionary PerSource + { + get { lock (_lock) return new Dictionary(_perSource); } + } + + /// 제공자가 첫 결과를 냈다. 여러 요청 중 가장 빠른 히트만 남긴다. + internal void ReportHit(string serviceName, long elapsedMs) + { + var ms = ClampMs(elapsedMs); + lock (_lock) + { + _perSource[serviceName] = _perSource.TryGetValue(serviceName, out var s) && s.Hit + ? s with { LatencyMs = Math.Min(s.LatencyMs, ms) } + : new SourceStat(true, ms); + } + } + + /// 제공자 요청 1건이 결과 없이 끝났다. 히트가 없을 때만 가장 늦은 완료 지연을 남긴다. + internal void ReportMiss(string serviceName, long elapsedMs) + { + var ms = ClampMs(elapsedMs); + lock (_lock) + { + if (_perSource.TryGetValue(serviceName, out var s)) + { + if (!s.Hit) _perSource[serviceName] = s with { LatencyMs = Math.Max(s.LatencyMs, ms) }; + } + else + { + _perSource[serviceName] = new SourceStat(false, ms); + } + } + } + + private static int ClampMs(long ms) => (int)Math.Clamp(ms, 0, int.MaxValue); +} diff --git a/src/Musebase.Core/Translation/LyricsTranslationService.cs b/src/Musebase.Core/Translation/LyricsTranslationService.cs index 71e92b0..b7cd05b 100644 --- a/src/Musebase.Core/Translation/LyricsTranslationService.cs +++ b/src/Musebase.Core/Translation/LyricsTranslationService.cs @@ -1,5 +1,18 @@ namespace Musebase.Core.Translation; +/// 번역 파이프라인 1회 실행 통계(텔레메트리·진단용). 곡 정보는 담지 않는다. +public sealed class TranslationRunStats +{ + /// 이번 실행에서 대상 언어 번역이 필요했던 라인 수(캐시 적중 + API, 중복 라인 포함). + public int LinesNeeded { get; internal set; } + + /// 그중 라인 캐시로 채운 수. + public int CacheHits { get; internal set; } + + /// 캐시 적중률(0–100 정수). 필요 라인이 없으면 0. + public int CacheHitPct => LinesNeeded == 0 ? 0 : (int)Math.Round(CacheHits * 100.0 / LinesNeeded); +} + /// /// 가사 이중언어 보장 서비스. /// @@ -24,11 +37,19 @@ public LyricsTranslationService(ITranslator? translator, ITranslationCache? cach /// 번역기가 구성되어 있는가 (키 입력 여부) public bool IsEnabled => _translator is not null; + /// + /// 이 서비스를 만든 번역 엔진 id(레지스트리 id, 텔레메트리용). + /// 팩토리(LyricsEngineFactory)가 구성값으로 채운다. 기본 "none". + /// + public string EngineId { get; init; } = TranslatorRegistry.None; + /// /// 가사에 대상 언어 번역을 채운다. 반환값: 변경된 라인 수. /// 실패(네트워크/키 오류)는 조용히 0 — 기능 강등이지 오류가 아니다. + /// 를 주면 필요 라인 수·캐시 적중을 집계한다(텔레메트리용). /// - public async Task EnsureTranslatedAsync(Lyrics lyrics, string targetLang, CancellationToken ct = default) + public async Task EnsureTranslatedAsync( + Lyrics lyrics, string targetLang, CancellationToken ct = default, TranslationRunStats? stats = null) { if (_translator is null) return 0; @@ -45,10 +66,13 @@ public async Task EnsureTranslatedAsync(Lyrics lyrics, string targetLang, C if (string.IsNullOrWhiteSpace(line.Content)) continue; if (line.Attachments[tag] is not null) continue; + if (stats is not null) stats.LinesNeeded++; + if (_cache.Get(line.Content, targetLang) is { } cached) { line.Attachments[tag] = cached; changed++; + if (stats is not null) stats.CacheHits++; continue; } diff --git a/src/Musebase.Engine/LyricsCoordinator.cs b/src/Musebase.Engine/LyricsCoordinator.cs index 238281b..63bdcb3 100644 --- a/src/Musebase.Engine/LyricsCoordinator.cs +++ b/src/Musebase.Engine/LyricsCoordinator.cs @@ -31,6 +31,10 @@ public sealed class LyricsCoordinator : IDisposable private CancellationTokenSource? _searchCts; private int _lastLineIndex = int.MinValue; + // 텔레메트리 발화 제어: playback_source는 같은 트랙 반복 발화 방지, translation은 곡당 1회 + private string? _lastPlaybackSourceKey; + private bool _translationReported; + // 직렬화 표시 상태(StateChanged)용 현재 라인 스냅샷 private DisplayLine? _currentLine; private DateTimeOffset? _currentLineStartedAt; @@ -50,6 +54,12 @@ public sealed class LyricsCoordinator : IDisposable /// 진단 로그 싱크(선택). 소비자가 Log.Write 등을 주입. public Action? Log { get; set; } + /// + /// 익명 텔레메트리 싱크(ADR-0004, 기본 Noop=무수집). 이벤트 type/props의 단일 진실은 + /// contracts/telemetry-events.md. ② 이벤트(곡 정보 포함)의 동의 필터링은 구현(플랫폼) 책임. + /// + public ITelemetry Telemetry { get; set; } = NoopTelemetry.Instance; + /// 기계번역 서비스 (키 미설정 시 IsEnabled=false로 무동작) public LyricsTranslationService? Translation { get; set; } @@ -132,6 +142,7 @@ private async void OnTrackChanged(TrackInfo? track) _lastLineIndex = int.MinValue; _currentLine = null; _currentLineStartedAt = null; + _translationReported = false; // translation 이벤트는 곡당 1회 CurrentLineChanged?.Invoke(null); EmitState(); // 새 트랙(제목) 반영, 라인은 아직 없음 @@ -141,6 +152,18 @@ private async void OnTrackChanged(TrackInfo? track) return; } + // playback_source: 재생 소스 앱 id(곡 정보 없음). 같은 트랙의 반복 통지(SMTC 메타 재발화 등)는 + // 억제하고, 하루 앱별 1회 디바운스는 클라이언트(플랫폼 ITelemetry 구현) 책임. + var playbackKey = $"{track.SourceAppId}|{LyricsCacheStore.MakeKey(track.Title, track.Artist)}"; + if (playbackKey != _lastPlaybackSourceKey) + { + _lastPlaybackSourceKey = playbackKey; + Telemetry.Track(TelemetryEvents.PlaybackSource, new Dictionary + { + ["appId"] = track.SourceAppId, + }); + } + // "틀린 가사"로 표시된 곡은 검색·표시하지 않는다 if (SuppressedTrackKeys.Contains(LyricsCacheStore.MakeKey(track.Title, track.Artist))) { @@ -154,6 +177,14 @@ private async void OnTrackChanged(TrackInfo? track) CurrentLyrics = cached; _lastLineIndex = int.MinValue; StatusChanged?.Invoke(new LyricsStatus(LyricsStatusKind.Cache, track.ToString(), cached.Metadata.ServiceName ?? "")); + // lyrics_search(캐시 적중): 네트워크 검색이 없었으므로 perSource는 빈 객체 + Telemetry.Track(TelemetryEvents.LyricsSearch, new Dictionary + { + ["winner"] = SourceIdOf(cached.Metadata.ServiceName), + ["perSource"] = new Dictionary(), + ["cached"] = true, + ["cleanedQueryUsed"] = false, + }); var cacheCts = new CancellationTokenSource(); _searchCts = cacheCts; await TranslateAsync(cached, cacheCts.Token); // 언어 변경 시 보충 번역 @@ -168,9 +199,10 @@ private async void OnTrackChanged(TrackInfo? track) { var request = LyricsSearchRequest.ByInfo( track.Title, track.Artist, track.Duration?.TotalSeconds ?? 0, limit: 3); + var diagnostics = new SearchDiagnostics(); // 첫 결과 우선 표시 후 더 좋은 후보로 교체 (지연 체감 최소화) - await foreach (var lyrics in _search.SearchAsync(request, cts.Token)) + await foreach (var lyrics in _search.SearchAsync(request, cts.Token, diagnostics)) { if (cts.Token.IsCancellationRequested) return; if (CurrentLyrics is null || lyrics.Quality() > CurrentLyrics.Quality()) @@ -183,9 +215,22 @@ private async void OnTrackChanged(TrackInfo? track) } } + // 검색 1회 완료 — 트랙이 교체됐으면(취소) 발화하지 않는다 + if (!cts.Token.IsCancellationRequested) + TrackLyricsSearch(request, diagnostics); + if (CurrentLyrics is null) { StatusChanged?.Invoke(new LyricsStatus(LyricsStatusKind.NotFound, track.ToString())); + if (!cts.Token.IsCancellationRequested) + { + // ② 품질 리포트 — 곡 정보 포함. 동의 필터링은 ITelemetry 구현 책임. + Telemetry.Track(TelemetryEvents.LyricsNotFound, new Dictionary + { + ["title"] = track.Title, + ["artist"] = track.Artist, + }); + } } else if (!cts.Token.IsCancellationRequested) { @@ -219,6 +264,14 @@ public void MarkWrongLyrics() { if (CurrentTrack is not { } track) return; + // ② 품질 리포트 — 채택됐던 소스 id는 지우기 전에 확보. 동의 필터링은 ITelemetry 구현 책임. + Telemetry.Track(TelemetryEvents.WrongLyrics, new Dictionary + { + ["title"] = track.Title, + ["artist"] = track.Artist, + ["source"] = SourceIdOf(CurrentLyrics?.Metadata.ServiceName), + }); + SuppressedTrackKeys.Add(LyricsCacheStore.MakeKey(track.Title, track.Artist)); try { Cache?.Remove(track.Title, track.Artist); } catch (Exception e) { Log?.Invoke($"[wrong] 캐시 제거 실패: {e.Message}"); } @@ -294,9 +347,22 @@ private async Task TranslateAsync(Lyrics lyrics, CancellationToken ct) if (Translation is not { IsEnabled: true } service) return; try { - var changed = await service.EnsureTranslatedAsync(lyrics, TargetLanguage, ct); + var stats = new TranslationRunStats(); + var changed = await service.EnsureTranslatedAsync(lyrics, TargetLanguage, ct, stats); if (changed > 0 && ReferenceEquals(CurrentLyrics, lyrics)) _lastLineIndex = int.MinValue; // 번역 반영 위해 현재 라인 재발행 + + // translation: 번역이 실제로 필요했던 첫 완료 시점에 곡당 1회 + if (!_translationReported && stats.LinesNeeded > 0) + { + _translationReported = true; + Telemetry.Track(TelemetryEvents.Translation, new Dictionary + { + ["engine"] = service.EngineId, + ["cacheHitPct"] = stats.CacheHitPct, + ["linesBucket"] = LinesBucket(stats.LinesNeeded), + }); + } } catch (OperationCanceledException) { @@ -304,6 +370,42 @@ private async Task TranslateAsync(Lyrics lyrics, CancellationToken ct) } } + /// lyrics_search 발화(계약 ① — 곡 정보 없음): 소스별 히트/지연 + 채택 소스 + 정제 검색어 사용 여부. + private void TrackLyricsSearch(LyricsSearchRequest request, SearchDiagnostics diagnostics) + { + var winner = CurrentLyrics; + + var perSource = new Dictionary(); + foreach (var (serviceName, stat) in diagnostics.PerSource) + { + perSource[SourceIdOf(serviceName)] = new Dictionary + { + ["hit"] = stat.Hit, + ["latencyMs"] = stat.LatencyMs, + }; + } + + // Metadata.Request는 실제 사용된 요청 — 원본과 검색어가 다르면 정제 변형에서 얻은 결과 + var cleanedQueryUsed = winner?.Metadata.Request is { } used && !Equals(used.Term, request.Term); + + Telemetry.Track(TelemetryEvents.LyricsSearch, new Dictionary + { + ["winner"] = winner is null ? "none" : SourceIdOf(winner.Metadata.ServiceName), + ["perSource"] = perSource, + ["cached"] = false, + ["cleanedQueryUsed"] = cleanedQueryUsed, + }); + } + + /// 제공자 ServiceName → 레지스트리 소스 id. 미등록(사용자 편집 등)은 소문자 이름, 비면 "none". + private static string SourceIdOf(string? serviceName) => + string.IsNullOrEmpty(serviceName) ? "none" + : LyricsSourceRegistry.Find(serviceName)?.Id ?? serviceName.ToLowerInvariant(); + + /// translation.linesBucket 버킷팅(contracts/telemetry-events.md). + private static string LinesBucket(int lines) => + lines <= 10 ? "1-10" : lines <= 50 ? "11-50" : "51+"; + private void Tick() { var lyrics = CurrentLyrics; diff --git a/src/Musebase.Engine/LyricsEngineFactory.cs b/src/Musebase.Engine/LyricsEngineFactory.cs index 72148e6..fa3c54e 100644 --- a/src/Musebase.Engine/LyricsEngineFactory.cs +++ b/src/Musebase.Engine/LyricsEngineFactory.cs @@ -25,15 +25,22 @@ public static class LyricsEngineFactory { /// 구성·캐시로 번역 서비스를 만든다(엔진/키 변경 시 재구성용으로도 사용). public static LyricsTranslationService BuildTranslation(EngineConfig config, ITranslationCache cache) => - new(TranslatorRegistry.Build(config.TranslationEngineId, config.TranslatorOptions), cache); + new(TranslatorRegistry.Build(config.TranslationEngineId, config.TranslatorOptions), cache) + { + EngineId = config.TranslationEngineId, // translation 텔레메트리의 engine 식별용 + }; - /// 재생 소스·디스패처·구성으로 완전 배선된 코디네이터를 만든다. + /// + /// 재생 소스·디스패처·구성으로 완전 배선된 코디네이터를 만든다. + /// 미주입(null) 시 NoopTelemetry — 수집하지 않는다(ADR-0004). + /// public static LyricsCoordinator Create( INowPlayingSource source, IEngineDispatcher dispatcher, EngineConfig config, ITranslationCache translationCache, - Action? log = null) => + Action? log = null, + ITelemetry? telemetry = null) => new(source, dispatcher, new LyricsSearchService(LyricsSourceRegistry.Build(config.EnabledLyricsSources))) { Translation = BuildTranslation(config, translationCache), @@ -42,5 +49,6 @@ public static LyricsCoordinator Create( ShowOnlyTargetTranslation = config.ShowOnlyTargetTranslation, ManualOffsetSeconds = config.ManualOffsetSeconds, Log = log, + Telemetry = telemetry ?? NoopTelemetry.Instance, }; } diff --git a/tests/Musebase.Core.Tests/TelemetryTests.cs b/tests/Musebase.Core.Tests/TelemetryTests.cs new file mode 100644 index 0000000..47ae49c --- /dev/null +++ b/tests/Musebase.Core.Tests/TelemetryTests.cs @@ -0,0 +1,246 @@ +using System.Runtime.CompilerServices; +using Musebase.Core; +using Musebase.Core.Search; +using Musebase.Core.Translation; +using Musebase.Engine; +using Xunit; + +namespace Musebase.Core.Tests; + +/// +/// 코어 텔레메트리 계측 검증(contracts/telemetry-events.md). +/// FakeTelemetry로 이벤트를 캡처해 type/props가 계약과 일치하는지 확인한다. +/// +public class TelemetryTests +{ + // ---- 테스트 더블 ---- + + private sealed class FakeTelemetry : ITelemetry + { + private readonly object _lock = new(); + private readonly List<(string Type, IReadOnlyDictionary Props)> _events = []; + + public void Track(string type, IReadOnlyDictionary? props = null) + { + lock (_lock) _events.Add((type, props ?? new Dictionary())); + } + + public int CountOf(string type) + { + lock (_lock) return _events.Count(e => e.Type == type); + } + + public List> AllOf(string type) + { + lock (_lock) return _events.Where(e => e.Type == type).Select(e => e.Props).ToList(); + } + + /// type 이벤트가 발화될 때까지 폴링 대기(비동기 검색 파이프라인 동기화용). + public async Task> WaitForAsync(string type, int timeoutMs = 10_000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (DateTime.UtcNow < deadline) + { + lock (_lock) + { + var hit = _events.FirstOrDefault(e => e.Type == type); + if (hit.Type is not null) return hit.Props; + } + await Task.Delay(10); + } + throw new TimeoutException($"텔레메트리 이벤트 미발화: {type}"); + } + } + + private sealed class FakeSource : INowPlayingSource + { + public TrackInfo? CurrentTrack { get; set; } + public bool IsPlaying { get; set; } + public event Action? TrackChanged; + public event Action? IsPlayingChanged; + public TimeSpan? GetEstimatedPosition() => TimeSpan.Zero; + public PlaybackControls GetControls() => PlaybackControls.None; + public Task TogglePlayPauseAsync() => Task.FromResult(true); + public Task SkipNextAsync() => Task.FromResult(true); + public Task SkipPreviousAsync() => Task.FromResult(true); + + public void RaiseTrack(TrackInfo? track) { CurrentTrack = track; TrackChanged?.Invoke(track); } + public void RaisePlaying(bool playing) { IsPlaying = playing; IsPlayingChanged?.Invoke(playing); } + } + + private sealed class InlineDispatcher : IEngineDispatcher + { + public void Post(Action action) => action(); + public IEngineTimer CreateTimer(TimeSpan interval, Action tick) => new NoopTimer(); + private sealed class NoopTimer : IEngineTimer { public void Start() { } public void Stop() { } } + } + + /// lrc가 null이면 결과 없음(미스), 아니면 파싱해 1건 반환(히트). + private sealed class FakeProvider(string serviceName, string? lrc) : ILyricsProvider + { + public string ServiceName => serviceName; + + public async IAsyncEnumerable GetLyricsAsync( + LyricsSearchRequest request, [EnumeratorCancellation] CancellationToken ct = default) + { + await Task.Yield(); + if (lrc is null) yield break; + var lyrics = Lyrics.Parse(lrc)!; + lyrics.Metadata.ServiceName = serviceName; + lyrics.Metadata.Request = request; + yield return lyrics; + } + } + + private sealed class FakeTranslator : ITranslator + { + public Task> TranslateAsync( + IReadOnlyList texts, string targetLang, CancellationToken ct = default) => + Task.FromResult>(texts.Select(t => (string?)$"{targetLang}:{t}").ToList()); + } + + private static TrackInfo Track(string title = "Song", string artist = "Artist", string appId = "TestPlayer.exe") => + new(title, artist, "", null, appId); + + private const string TwoLineLrc = "[00:01.00]hello\n[00:05.00]world"; + + // ---- lyrics_search ---- + + [Fact] + public async Task Search_Success_EmitsLyricsSearch_WithWinnerPerSource_NoSongInfo() + { + var telemetry = new FakeTelemetry(); + var source = new FakeSource { CurrentTrack = Track() }; + var search = new LyricsSearchService( + new FakeProvider("LRCLIB", TwoLineLrc), // 히트 + new FakeProvider("NetEase", null)); // 미스 + using var coordinator = new LyricsCoordinator(source, new InlineDispatcher(), search) + { + Telemetry = telemetry, + }; + coordinator.Start(); + + var props = await telemetry.WaitForAsync(TelemetryEvents.LyricsSearch); + + Assert.Equal("lrclib", props["winner"]); // ServiceName "LRCLIB" → 레지스트리 id + Assert.False((bool)props["cached"]!); + Assert.False((bool)props["cleanedQueryUsed"]!); // 원본 검색어로 얻은 결과 + + var perSource = Assert.IsType>(props["perSource"]); + var lrclib = Assert.IsType>(perSource["lrclib"]); + Assert.True((bool)lrclib["hit"]!); + Assert.True((int)lrclib["latencyMs"]! >= 0); + var netease = Assert.IsType>(perSource["netease"]); + Assert.False((bool)netease["hit"]!); + + // 계약 ①: 곡 제목/아티스트 절대 금지 + Assert.False(props.ContainsKey("title")); + Assert.False(props.ContainsKey("artist")); + } + + [Fact] + public async Task Search_AllMiss_EmitsWinnerNone_AndLyricsNotFoundWithSongInfo() + { + var telemetry = new FakeTelemetry(); + var source = new FakeSource { CurrentTrack = Track("Unknown Song", "Unknown Artist") }; + var search = new LyricsSearchService( + new FakeProvider("LRCLIB", null), + new FakeProvider("Kugou", null)); + using var coordinator = new LyricsCoordinator(source, new InlineDispatcher(), search) + { + Telemetry = telemetry, + }; + coordinator.Start(); + + var searchProps = await telemetry.WaitForAsync(TelemetryEvents.LyricsSearch); + Assert.Equal("none", searchProps["winner"]); + var perSource = Assert.IsType>(searchProps["perSource"]); + Assert.Equal(2, perSource.Count); + Assert.All(perSource.Values, v => + Assert.False((bool)Assert.IsType>(v)["hit"]!)); + + // ② 검색 전패 → 곡 정보 포함 리포트(동의 필터링은 플랫폼 구현 책임) + var nfProps = await telemetry.WaitForAsync(TelemetryEvents.LyricsNotFound); + Assert.Equal("Unknown Song", nfProps["title"]); + Assert.Equal("Unknown Artist", nfProps["artist"]); + } + + // ---- wrong_lyrics ---- + + [Fact] + public async Task MarkWrongLyrics_EmitsWrongLyrics_WithAdoptedSourceId() + { + var telemetry = new FakeTelemetry(); + var source = new FakeSource { CurrentTrack = Track("Bad Match", "Some Artist") }; + var search = new LyricsSearchService(new FakeProvider("NetEase", TwoLineLrc)); + using var coordinator = new LyricsCoordinator(source, new InlineDispatcher(), search) + { + Telemetry = telemetry, + }; + coordinator.Start(); + await telemetry.WaitForAsync(TelemetryEvents.LyricsSearch); // 채택 완료 동기화 + + coordinator.MarkWrongLyrics(); + + var props = await telemetry.WaitForAsync(TelemetryEvents.WrongLyrics); + Assert.Equal("Bad Match", props["title"]); + Assert.Equal("Some Artist", props["artist"]); + Assert.Equal("netease", props["source"]); + } + + // ---- playback_source ---- + + [Fact] + public void PlaybackSource_EmittedPerTrack_NotRepeatedForSameTrack() + { + var telemetry = new FakeTelemetry(); + var source = new FakeSource { CurrentTrack = Track(appId: "Spotify.exe") }; + var search = new LyricsSearchService(new FakeProvider("LRCLIB", null)); + using var coordinator = new LyricsCoordinator(source, new InlineDispatcher(), search) + { + Telemetry = telemetry, + }; + coordinator.Start(); // 발화 지점은 OnTrackChanged의 동기 구간 + + Assert.Equal(1, telemetry.CountOf(TelemetryEvents.PlaybackSource)); + + source.RaiseTrack(Track(appId: "Spotify.exe")); // 같은 트랙 반복 통지 → 미발화 + Assert.Equal(1, telemetry.CountOf(TelemetryEvents.PlaybackSource)); + + source.RaiseTrack(Track("Other Song", appId: "Spotify.exe")); // 트랙 변경 → 발화 + Assert.Equal(2, telemetry.CountOf(TelemetryEvents.PlaybackSource)); + + Assert.All(telemetry.AllOf(TelemetryEvents.PlaybackSource), + p => Assert.Equal("Spotify.exe", p["appId"])); + } + + // ---- translation ---- + + [Fact] + public async Task Translation_EmittedOncePerTrack_WithEngineCacheHitPctAndBucket() + { + var telemetry = new FakeTelemetry(); + var source = new FakeSource { CurrentTrack = Track() }; + var search = new LyricsSearchService(new FakeProvider("LRCLIB", TwoLineLrc)); + + var mtCache = new InMemoryTranslationCache(); + mtCache.Set("hello", "KO", "안녕"); // 2라인 중 1라인 캐시 적중 → 50% + var translation = new LyricsTranslationService(new FakeTranslator(), mtCache) { EngineId = "deepl" }; + + using var coordinator = new LyricsCoordinator(source, new InlineDispatcher(), search) + { + Telemetry = telemetry, + Translation = translation, + }; + coordinator.Start(); + + var props = await telemetry.WaitForAsync(TelemetryEvents.Translation); + Assert.Equal("deepl", props["engine"]); + Assert.Equal(50, props["cacheHitPct"]); + Assert.Equal("1-10", props["linesBucket"]); + + // 곡당 1회 + await telemetry.WaitForAsync(TelemetryEvents.LyricsSearch); // 파이프라인 종료 동기화 + Assert.Equal(1, telemetry.CountOf(TelemetryEvents.Translation)); + } +} From 6177552b548814c7f00715d13a1f2251f0d25b18 Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 17 Jul 2026 10:58:06 +0900 Subject: [PATCH 10/17] feat(windows): telemetry client, consent dialog, settings toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Services/TelemetryClient.cs: Musebase.Engine.ITelemetry 구현. 옵트인 2단계 게이트(꺼짐 = 수집 자체 안 함), 익명 clientId(최초 켜짐 시 랜덤 GUID 생성·재설정 API), 로컬 JSONL 큐(상한 500건), 시작 30초 후 + 6시간 주기 배치(≤100건) 업로드(실패 시 큐 보존). 클라이언트 집계: playback_source는 appId별 하루 1회 디바운스, feature_use는 세션 카운터 → 업로드 시 집계 이벤트, app_session은 하루 1회(일일 ping 겸용). - TelemetryConsentWindow.cs: 최초 1회 동의 다이얼로그(체크박스 2개 기본 꺼짐, ②는 곡 제목·아티스트 포함 명시, TELEMETRY.md 링크). - SettingsWindow [일반] 탭 "사용 통계" 섹션: 토글 2개(즉시 반영) + 수집 항목 링크 + 익명 ID 재설정 버튼. - 앱 수준 이벤트 배선(Program.cs): app_session(uiLang/targetLang/engine/ sources/sourceMode/osVersion 대분류), playback_source(TrackChanged), feature_use(search/edit/export/offset/mediaControls 카운트), error(Dispatcher+AppDomain 비처리 예외 → kind/frame/fatal, 메시지·경로 금지). - i18n: en/ko 카탈로그에 동의·설정 문구 추가(나머지 언어는 en 폴백). 엔진 계측 연결(LyricsEngineFactory.Create에 ITelemetry 주입)은 core PR 머지 후 오케스트레이터가 통합한다. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Musebase.Windows/Program.cs | 54 ++- src/Musebase.Windows/Services/AppSettings.cs | 14 + .../Services/TelemetryClient.cs | 410 ++++++++++++++++++ src/Musebase.Windows/SettingsWindow.cs | 44 ++ .../TelemetryConsentWindow.cs | 103 +++++ src/Musebase.Windows/i18n/en.json | 13 + src/Musebase.Windows/i18n/ko.json | 13 + 7 files changed, 647 insertions(+), 4 deletions(-) create mode 100644 src/Musebase.Windows/Services/TelemetryClient.cs create mode 100644 src/Musebase.Windows/TelemetryConsentWindow.cs diff --git a/src/Musebase.Windows/Program.cs b/src/Musebase.Windows/Program.cs index c439f61..73f03d8 100644 --- a/src/Musebase.Windows/Program.cs +++ b/src/Musebase.Windows/Program.cs @@ -29,6 +29,40 @@ private static void Main(string[] args) Loc.Initialize(settings.UiLanguage); // UI 다국어: 창 생성 전에 언어 확정 var nowPlaying = await NowPlayingService.CreateAsync(); + // ---- 텔레메트리(익명 옵트인, ADR-0004) ---- + // 동의가 꺼져 있으면 Track이 즉시 무시(수집 자체 안 함). 큐·업로드는 클라이언트가 관리. + var telemetry = new TelemetryClient(settings, Log.Write, appSessionProps: () => + new Dictionary + { + ["uiLang"] = Loc.CurrentCode, + ["targetLang"] = settings.EffectiveTargetLanguage, + ["engine"] = settings.EffectiveTranslationEngine, + ["sources"] = settings.EnabledLyricsSources.ToArray(), + ["sourceMode"] = settings.PlaybackSource, + ["osVersion"] = Environment.OSVersion.Version.Build >= 22000 ? "Windows 11" : "Windows 10", + }); + telemetry.StartUploader(); + + // 비처리 예외 → error 이벤트(kind/frame/fatal만 — 메시지 본문·경로 금지) + app.DispatcherUnhandledException += (_, e) => + { + telemetry.TrackError(e.Exception, fatal: true); + telemetry.FlushPendingToDisk(); // 프로세스 종료 전 큐 보존(다음 실행에서 업로드) + }; + AppDomain.CurrentDomain.UnhandledException += (_, e) => + { + if (e.ExceptionObject is Exception ex) telemetry.TrackError(ex, e.IsTerminating); + telemetry.FlushPendingToDisk(); + }; + + // 재생 소스 앱 통계(클라이언트가 appId별 하루 1회로 디바운스) + nowPlaying.TrackChanged += t => + { + if (t is { SourceAppId.Length: > 0 }) + telemetry.Track(TelemetryEvents.PlaybackSource, + new Dictionary { ["appId"] = t.SourceAppId }); + }; + // 번역: SQLite 라인 캐시 + 레지스트리에서 선택된 엔진(키 없으면 무키 무료로 폴백) var cacheDb = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @@ -87,9 +121,9 @@ private static void Main(string[] args) overlay.EnableMediaControls( controlsProvider: () => nowPlaying.GetControls(), playingProvider: () => nowPlaying.IsPlaying, - onPrevious: () => _ = nowPlaying.SkipPreviousAsync(), - onPlayPause: () => _ = nowPlaying.TogglePlayPauseAsync(), - onNext: () => _ = nowPlaying.SkipNextAsync()); + onPrevious: () => { telemetry.CountFeature("mediaControls"); _ = nowPlaying.SkipPreviousAsync(); }, + onPlayPause: () => { telemetry.CountFeature("mediaControls"); _ = nowPlaying.TogglePlayPauseAsync(); }, + onNext: () => { telemetry.CountFeature("mediaControls"); _ = nowPlaying.SkipNextAsync(); }); } // ---- 트레이 메뉴 ---- @@ -177,7 +211,11 @@ void RebuildSourceMenu() } }; var searchItem = new MenuItem { Header = Loc.T("tray.search") }; - searchItem.Click += (_, _) => new SearchWindow(coordinator).Show(); + searchItem.Click += (_, _) => + { + telemetry.CountFeature("search"); + new SearchWindow(coordinator).Show(); + }; // ---- 현재 가사 편집 / 내보내기 ---- var editItem = new MenuItem { Header = Loc.T("tray.edit") }; @@ -187,6 +225,7 @@ void RebuildSourceMenu() editItem.Click += (_, _) => { if (coordinator.CurrentLyrics is not { } lyrics || coordinator.CurrentTrack is not { } track) return; + telemetry.CountFeature("edit"); if (editorWindow is { IsLoaded: true }) { editorWindow.Activate(); @@ -208,6 +247,7 @@ void RebuildSourceMenu() FileName = SanitizeFileName($"{track.Artist} - {track.Title}") + ".lrc", }; if (dialog.ShowDialog() != true) return; + telemetry.CountFeature("export"); try { // 이중언어: [mm:ss.xx]원문【번역】 (표준 플레이어 호환). @@ -334,6 +374,7 @@ void UpdateOffsetLabel() => void AdjustOffset(double? delta) { + telemetry.CountFeature("offset"); coordinator.ManualOffsetSeconds = delta is { } d ? coordinator.ManualOffsetSeconds + d : 0; settings.ManualOffsetSeconds = coordinator.ManualOffsetSeconds; settings.Save(); @@ -388,6 +429,7 @@ void AdjustOffset(double? delta) app.Exit += (_, _) => { tray.Dispose(); + telemetry.Dispose(); // 세션 feature_use 카운터를 큐로 보존(다음 실행에서 업로드) settings.Save(); }; @@ -483,6 +525,10 @@ void ApplyMenuText() coordinator.Start(); // 배선 완료 후 시작 (캐시/번역/상태 이벤트 유효) Log.Write("=== Musebase 시작 ==="); + // 텔레메트리 동의 다이얼로그(최초 1회, 기본 모두 꺼짐 = 미수집) + if (!settings.TelemetryConsentAsked) + new TelemetryConsentWindow(settings).Show(); + // 시작 시 백그라운드 업데이트 확인(비침습: 발견 시 트레이 메뉴 라벨만 갱신) _ = RunUpdateCheckAsync(userInitiated: false); }; diff --git a/src/Musebase.Windows/Services/AppSettings.cs b/src/Musebase.Windows/Services/AppSettings.cs index 494d44b..5abb4ae 100644 --- a/src/Musebase.Windows/Services/AppSettings.cs +++ b/src/Musebase.Windows/Services/AppSettings.cs @@ -82,6 +82,20 @@ public sealed class AppSettings /// public List EnabledLyricsSources { get; set; } = LyricsSourceRegistry.AllIds.ToList(); + // ---- 사용 통계(텔레메트리, ADR-0004) — 옵트인 2단계, 기본 모두 꺼짐 ---- + + /// 최초 실행 동의 다이얼로그를 이미 보여줬는지(최초 1회만 표시). + public bool TelemetryConsentAsked { get; set; } + + /// ① 기본 통계(기능·성능·환경 — 곡 정보 없음). 기본 꺼짐(옵트인). + public bool TelemetryBasicEnabled { get; set; } + + /// ② 품질 리포트(틀린가사/검색실패 곡의 제목·아티스트 포함 — 별도 동의). 기본 꺼짐. + public bool TelemetryQualityEnabled { get; set; } + + /// 익명 클라이언트 ID(로컬 생성 랜덤 GUID). 동의 최초 켜짐 시 생성, 설정에서 재설정 가능. + public string? TelemetryClientId { get; set; } + // ---- 번역 엔진 선택 ---- /// 번역 엔진 id(TranslatorRegistry). 비면 EffectiveTranslationEngine으로 자동 결정. diff --git a/src/Musebase.Windows/Services/TelemetryClient.cs b/src/Musebase.Windows/Services/TelemetryClient.cs new file mode 100644 index 0000000..404ba9c --- /dev/null +++ b/src/Musebase.Windows/Services/TelemetryClient.cs @@ -0,0 +1,410 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Musebase.Engine; + +namespace Musebase.Windows.Services; + +/// +/// Windows 텔레메트리 클라이언트(ADR-0004, contracts/telemetry-events.md). +/// - 옵트인 2단계: ① 기본() / +/// ② 품질(). 꺼져 있으면 수집 자체를 안 한다. +/// - 로컬 큐: %LOCALAPPDATA%\Musebase\telemetry-queue.jsonl (1줄=1이벤트, 상한 500건). +/// - 업로드: 시작 30초 후 + 이후 6시간마다 배치(≤100건) POST. 실패 시 큐 보존 후 재시도. +/// - 은 논블로킹이며 절대 던지지 않는다(수집 실패는 무해). +/// - 클라이언트 집계: playback_source는 appId별 하루 1회 디바운스, feature_use는 세션 카운터로 +/// 모았다가 업로드 시 집계 이벤트로 변환, app_session은 하루 1회(일일 ping 겸용). +/// +public sealed class TelemetryClient : ITelemetry, IDisposable +{ + private const string IngestUrl = "https://musebase-telemetry.musebase.workers.dev/ingest"; + private const int MaxQueuedEvents = 500; // 로컬 큐 상한(초과 시 오래된 것부터 삭제) + private const int MaxEventsPerBatch = 100; // 서버 배치 상한 + private const int MaxBatchBytes = 60_000; // 서버 본문 상한 64KB 이하로 여유 + + /// ② 품질 리포트 동의가 있어야 기록되는 이벤트 type(곡 제목/아티스트 포함). + private static readonly HashSet QualityOnlyTypes = new(StringComparer.Ordinal) + { + TelemetryEvents.LyricsNotFound, + TelemetryEvents.WrongLyrics, + }; + + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(30) }; + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false }; + + private readonly AppSettings _settings; + private readonly Action? _log; + private readonly Func>? _appSessionProps; + private readonly string _queuePath; + private readonly string _statePath; + private readonly object _fileLock = new(); + private readonly ConcurrentQueue _pendingLines = new(); + private readonly ConcurrentDictionary _featureCounts = new(StringComparer.Ordinal); + private readonly CancellationTokenSource _cts = new(); + private DebounceState? _state; // 지연 로드(파일 IO는 첫 사용 시) + + /// 디바운스 상태(%LOCALAPPDATA%\Musebase\telemetry-state.json). 날짜는 로컬 yyyy-MM-dd. + private sealed class DebounceState + { + public string? AppSessionDate { get; set; } + public string? PlaybackSourceDate { get; set; } + public List PlaybackSourceApps { get; set; } = new(); + } + + /// + /// app_session 이벤트 props 공급자(업로드 주기마다 하루 1회 체크 후 발화). null이면 미발화. + /// + public TelemetryClient( + AppSettings settings, + Action? log = null, + Func>? appSessionProps = null) + { + _settings = settings; + _log = log; + _appSessionProps = appSessionProps; + var dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Musebase"); + _queuePath = Path.Combine(dir, "telemetry-queue.jsonl"); + _statePath = Path.Combine(dir, "telemetry-state.json"); + } + + // ---------------------------------------------------------------- ITelemetry + + /// + public void Track(string type, IReadOnlyDictionary? props = null) + { + try + { + // 동의 게이트: ② 전용 type은 ②, 그 외는 ①. 꺼져 있으면 수집 자체를 안 한다. + if (QualityOnlyTypes.Contains(type)) + { + if (!_settings.TelemetryQualityEnabled) return; + } + else if (!_settings.TelemetryBasicEnabled) + { + return; + } + + // feature_use: 세션 카운터로만 집계(업로드 시 집계 이벤트로 변환) + if (type == TelemetryEvents.FeatureUse) + { + if (props?.TryGetValue("feature", out var f) == true && f is string feature && feature.Length > 0) + _featureCounts.AddOrUpdate(feature, 1, (_, c) => c + 1); + return; + } + + // playback_source: 같은 appId는 하루 1회 + if (type == TelemetryEvents.PlaybackSource) + { + if (props?.TryGetValue("appId", out var a) != true || a is not string appId || appId.Length == 0) + return; + if (!TryMarkPlaybackSourceToday(appId)) return; + } + + // app_session: 하루 1회(일일 ping 겸용) + if (type == TelemetryEvents.AppSession && !TryMarkAppSessionToday()) + return; + + Enqueue(type, props); + } + catch + { + // 텔레메트리는 앱 동작에 절대 영향을 주지 않는다 + } + } + + /// feature_use 카운트 편의 메서드(기존 핸들러에 한 줄 추가용). + public void CountFeature(string feature) => + Track(TelemetryEvents.FeatureUse, new Dictionary { ["feature"] = feature }); + + /// + /// 비처리 예외 → error 이벤트. kind(예외 타입명)/frame(최상위 스택프레임 1개)/fatal만 — + /// 메시지 본문·파일 경로는 절대 포함하지 않는다. + /// + public void TrackError(Exception ex, bool fatal) + { + try + { + var method = new StackTrace(ex).GetFrame(0)?.GetMethod(); + var frame = method is null ? "unknown" : $"{method.DeclaringType?.FullName}.{method.Name}"; + Track(TelemetryEvents.Error, new Dictionary + { + ["kind"] = ex.GetType().Name, + ["frame"] = frame, + ["fatal"] = fatal, + }); + } + catch + { + // 무시 + } + } + + // ---------------------------------------------------------------- clientId + + /// 동의가 처음 켜질 때 익명 clientId(랜덤 GUID)를 생성해 설정에 저장한다. + public static void EnsureClientId(AppSettings settings) + { + if (string.IsNullOrWhiteSpace(settings.TelemetryClientId)) + { + settings.TelemetryClientId = Guid.NewGuid().ToString(); + settings.Save(); + } + } + + /// 익명 ID 재설정: 새 랜덤 GUID로 교체(설정 UI의 "익명 ID 재설정"). + public static void ResetClientId(AppSettings settings) + { + settings.TelemetryClientId = Guid.NewGuid().ToString(); + settings.Save(); + } + + // ---------------------------------------------------------------- 큐(JSONL) + + /// 이벤트를 메모리 대기열에 넣고 백그라운드로 디스크에 붙인다(호출 스레드 논블로킹). + private void Enqueue(string type, IReadOnlyDictionary? props) + { + var line = JsonSerializer.Serialize( + new Dictionary { ["type"] = type, ["props"] = props ?? new Dictionary() }, + JsonOptions); + _pendingLines.Enqueue(line); + _ = Task.Run(FlushPendingToDisk); + } + + /// 메모리 대기열을 큐 파일에 반영(상한 500건 유지). 치명 예외 핸들러에서 동기 호출 가능. + public void FlushPendingToDisk() + { + try + { + lock (_fileLock) + { + if (_pendingLines.IsEmpty) return; + var lines = new List(); + if (File.Exists(_queuePath)) + lines.AddRange(File.ReadAllLines(_queuePath).Where(l => !string.IsNullOrWhiteSpace(l))); + while (_pendingLines.TryDequeue(out var line)) lines.Add(line); + if (lines.Count > MaxQueuedEvents) + lines.RemoveRange(0, lines.Count - MaxQueuedEvents); // 오래된 것부터 삭제 + Directory.CreateDirectory(Path.GetDirectoryName(_queuePath)!); + File.WriteAllLines(_queuePath, lines); + } + } + catch + { + // 큐 기록 실패는 무해(이벤트 유실 허용) + } + } + + // ---------------------------------------------------------------- 업로더 + + /// + /// 백그라운드 업로더 시작: 초기 지연(기본 30초, 환경변수 + /// MUSEBASE_TELEMETRY_INITIAL_DELAY_SECONDS로 재정의 가능) 후 1회, 이후 6시간마다. + /// + public void StartUploader() + { + _ = Task.Run(async () => + { + var initial = TimeSpan.FromSeconds(30); + if (int.TryParse(Environment.GetEnvironmentVariable("MUSEBASE_TELEMETRY_INITIAL_DELAY_SECONDS"), + out var secs) && secs >= 0) + initial = TimeSpan.FromSeconds(secs); + try { await Task.Delay(initial, _cts.Token); } catch { return; } + while (!_cts.IsCancellationRequested) + { + await UploadOnceAsync().ConfigureAwait(false); + try { await Task.Delay(TimeSpan.FromHours(6), _cts.Token); } catch { return; } + } + }); + } + + /// 업로드 1주기: 일일 app_session 발화 → feature_use 집계 반영 → 큐 배치 전송. + public async Task UploadOnceAsync() + { + try + { + if (!_settings.TelemetryBasicEnabled && !_settings.TelemetryQualityEnabled) return; + + // 일일 app_session ping(하루 1회 — 자정 넘겨 계속 켜둔 세션도 커버) + if (_settings.TelemetryBasicEnabled && _appSessionProps is not null && TryMarkAppSessionToday()) + Enqueue(TelemetryEvents.AppSession, _appSessionProps()); + + DrainFeatureCounters(); + FlushPendingToDisk(); + + EnsureClientId(_settings); + var clientId = _settings.TelemetryClientId!; + var appVersion = AppVersion(); + + while (!_cts.IsCancellationRequested) + { + List all; + lock (_fileLock) + { + if (!File.Exists(_queuePath)) return; + all = File.ReadAllLines(_queuePath).Where(l => !string.IsNullOrWhiteSpace(l)).ToList(); + } + if (all.Count == 0) return; + + // 배치 구성: ≤100건, 본문 ≤64KB. 손상 라인은 버린다. + var batch = new List(); + var taken = 0; + var bytes = 0; + foreach (var line in all) + { + taken++; + JsonNode? node; + try { node = JsonNode.Parse(line); } catch { continue; } // 손상 라인 폐기 + if (node?["type"]?.GetValue() is not { Length: > 0 }) continue; + var size = Encoding.UTF8.GetByteCount(line); + if (batch.Count > 0 && (batch.Count >= MaxEventsPerBatch || bytes + size > MaxBatchBytes)) + { + taken--; // 이 라인은 다음 배치로 + break; + } + batch.Add(node.ToJsonString(JsonOptions)); + bytes += size; + } + + if (batch.Count > 0) + { + var body = $"{{\"clientId\":{JsonSerializer.Serialize(clientId)}," + + $"\"platform\":\"windows\"," + + $"\"appVersion\":{JsonSerializer.Serialize(appVersion)}," + + $"\"events\":[{string.Join(",", batch)}]}}"; + using var resp = await Http.PostAsync( + IngestUrl, new StringContent(body, Encoding.UTF8, "application/json"), + _cts.Token).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) + { + _log?.Invoke($"[telemetry] 업로드 실패(HTTP {(int)resp.StatusCode}) — 큐 보존, 다음 주기에 재시도"); + return; // 실패: 큐 보존 + } + _log?.Invoke($"[telemetry] 업로드 성공: {batch.Count}건 (HTTP {(int)resp.StatusCode})"); + } + + // 성공(또는 전부 손상 라인): 전송/폐기분을 큐에서 제거 + lock (_fileLock) + { + var current = File.Exists(_queuePath) + ? File.ReadAllLines(_queuePath).Where(l => !string.IsNullOrWhiteSpace(l)).ToList() + : new List(); + // 업로드 도중 추가된 이벤트는 보존: 처리한 앞부분(taken)만 제거 + var remaining = current.Count > taken ? current.Skip(taken).ToList() : new List(); + if (remaining.Count == 0) File.Delete(_queuePath); + else File.WriteAllLines(_queuePath, remaining); + } + if (taken >= all.Count) return; // 큐 소진 + } + } + catch (Exception e) + { + _log?.Invoke($"[telemetry] 업로드 오류: {e.GetType().Name} — 큐 보존"); + } + } + + /// 세션 feature_use 카운터를 집계 이벤트로 변환해 대기열에 넣는다. + private void DrainFeatureCounters() + { + foreach (var feature in _featureCounts.Keys.ToList()) + { + if (_featureCounts.TryRemove(feature, out var count) && count > 0) + Enqueue(TelemetryEvents.FeatureUse, new Dictionary + { + ["feature"] = feature, + ["count"] = count, + }); + } + } + + private static string AppVersion() => + typeof(TelemetryClient).Assembly.GetName().Version?.ToString(3) ?? "0.0.0"; + + // ---------------------------------------------------------------- 디바운스 상태 + + private static string Today() => DateTime.Now.ToString("yyyy-MM-dd"); + + private DebounceState LoadState() + { + if (_state is not null) return _state; + try + { + if (File.Exists(_statePath)) + _state = JsonSerializer.Deserialize(File.ReadAllText(_statePath)); + } + catch + { + // 손상 상태 파일은 초기화 + } + return _state ??= new DebounceState(); + } + + private void SaveState() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_statePath)!); + File.WriteAllText(_statePath, JsonSerializer.Serialize(_state, JsonOptions)); + } + catch + { + // 무시 + } + } + + /// 오늘 app_session을 아직 안 보냈으면 기록하고 true. + private bool TryMarkAppSessionToday() + { + lock (_fileLock) + { + var s = LoadState(); + var today = Today(); + if (s.AppSessionDate == today) return false; + s.AppSessionDate = today; + SaveState(); + return true; + } + } + + /// 오늘 이 appId의 playback_source를 아직 안 보냈으면 기록하고 true. + private bool TryMarkPlaybackSourceToday(string appId) + { + lock (_fileLock) + { + var s = LoadState(); + var today = Today(); + if (s.PlaybackSourceDate != today) + { + s.PlaybackSourceDate = today; + s.PlaybackSourceApps.Clear(); + } + if (s.PlaybackSourceApps.Contains(appId, StringComparer.OrdinalIgnoreCase)) return false; + s.PlaybackSourceApps.Add(appId); + SaveState(); + return true; + } + } + + // ---------------------------------------------------------------- 종료 + + /// 업로더 중지 + 세션 카운터/대기열을 디스크로 보존(다음 실행에서 업로드). + public void Dispose() + { + try + { + _cts.Cancel(); + DrainFeatureCounters(); + FlushPendingToDisk(); + _cts.Dispose(); + } + catch + { + // 무시 + } + } +} diff --git a/src/Musebase.Windows/SettingsWindow.cs b/src/Musebase.Windows/SettingsWindow.cs index 138e8d1..a4b2ba9 100644 --- a/src/Musebase.Windows/SettingsWindow.cs +++ b/src/Musebase.Windows/SettingsWindow.cs @@ -260,6 +260,50 @@ void UpdateEndpointVisibility() => Margin = new Thickness(0, 6, 0, 0), }); + // 사용 통계(텔레메트리, ADR-0004) — 토글 즉시 반영(저장 버튼과 무관) + general.Children.Add(Header("settings.telemetry.header")); + var telemetryBasicCheck = WrapCheck("telemetry.consent.basic", settings.TelemetryBasicEnabled, new Thickness(0, 2, 0, 0)); + var telemetryQualityCheck = WrapCheck("telemetry.consent.quality", settings.TelemetryQualityEnabled, new Thickness(0, 6, 0, 0)); + void ApplyTelemetryConsent() + { + if (_rebuilding) return; + settings.TelemetryBasicEnabled = telemetryBasicCheck.IsChecked == true; + settings.TelemetryQualityEnabled = telemetryQualityCheck.IsChecked == true; + if (settings.TelemetryBasicEnabled || settings.TelemetryQualityEnabled) + TelemetryClient.EnsureClientId(settings); // 최초 켜짐 시 익명 GUID 생성(+저장) + settings.Save(); + } + telemetryBasicCheck.Checked += (_, _) => ApplyTelemetryConsent(); + telemetryBasicCheck.Unchecked += (_, _) => ApplyTelemetryConsent(); + telemetryQualityCheck.Checked += (_, _) => ApplyTelemetryConsent(); + telemetryQualityCheck.Unchecked += (_, _) => ApplyTelemetryConsent(); + general.Children.Add(telemetryBasicCheck); + general.Children.Add(telemetryQualityCheck); + + var telemetryDoc = new TextBlock { Margin = new Thickness(0, 6, 0, 0), TextWrapping = TextWrapping.Wrap }; + var telemetryLink = new Hyperlink(new Run(Loc.T("settings.telemetry.details"))) + { + NavigateUri = new Uri(TelemetryConsentWindow.TelemetryDocUrl), + }; + telemetryLink.RequestNavigate += OnRequestNavigate; + telemetryDoc.Inlines.Add(telemetryLink); + general.Children.Add(telemetryDoc); + + var resetIdButton = new Button + { + Content = Loc.T("settings.telemetry.resetId"), + HorizontalAlignment = HorizontalAlignment.Left, + Padding = new Thickness(10, 2, 10, 2), + Margin = new Thickness(0, 6, 0, 0), + }; + resetIdButton.Click += (_, _) => + { + TelemetryClient.ResetClientId(settings); + MessageBox.Show(this, Loc.T("settings.telemetry.resetId.done"), + Loc.T("settings.telemetry.header"), MessageBoxButton.OK, MessageBoxImage.Information); + }; + general.Children.Add(resetIdButton); + // ================= [오버레이 스타일] 탭 ================= var appearance = new StackPanel { Margin = new Thickness(16), Width = 410, HorizontalAlignment = HorizontalAlignment.Left }; diff --git a/src/Musebase.Windows/TelemetryConsentWindow.cs b/src/Musebase.Windows/TelemetryConsentWindow.cs new file mode 100644 index 0000000..fa2a339 --- /dev/null +++ b/src/Musebase.Windows/TelemetryConsentWindow.cs @@ -0,0 +1,103 @@ +using System.Diagnostics; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using Musebase.Windows.Services; + +namespace Musebase.Windows; + +/// +/// 텔레메트리 동의 다이얼로그(최초 1회, ADR-0004). +/// 짧은 설명 + 체크박스 2개(① 기본 통계 / ② 품질 리포트 — 둘 다 기본 꺼짐) + +/// "자세히" 링크(TELEMETRY.md) + 확인 버튼. 창을 닫으면(확인 포함) 다시 묻지 않는다(둘 다 꺼짐 유지). +/// +public sealed class TelemetryConsentWindow : Window +{ + /// 수집 항목 전체 안내 문서. + public const string TelemetryDocUrl = "https://github.com/countnine/musebase/blob/master/TELEMETRY.md"; + + public TelemetryConsentWindow(AppSettings settings) + { + Title = Loc.T("telemetry.consent.title"); + Width = 470; + SizeToContent = SizeToContent.Height; + WindowStartupLocation = WindowStartupLocation.CenterScreen; + ResizeMode = ResizeMode.NoResize; + MaxHeight = SystemParameters.WorkArea.Height; + + var panel = new StackPanel { Margin = new Thickness(16), Width = 410, HorizontalAlignment = HorizontalAlignment.Left }; + + panel.Children.Add(new TextBlock + { + Text = Loc.T("telemetry.consent.intro"), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 0, 0, 10), + }); + + // 둘 다 기본 꺼짐(옵트인) + var basicCheck = new CheckBox + { + Content = new TextBlock { Text = Loc.T("telemetry.consent.basic"), TextWrapping = TextWrapping.Wrap }, + IsChecked = false, + Margin = new Thickness(0, 0, 0, 6), + }; + var qualityCheck = new CheckBox + { + Content = new TextBlock { Text = Loc.T("telemetry.consent.quality"), TextWrapping = TextWrapping.Wrap }, + IsChecked = false, + Margin = new Thickness(0, 0, 0, 10), + }; + panel.Children.Add(basicCheck); + panel.Children.Add(qualityCheck); + + // "자세히(수집 항목 전체)" — TELEMETRY.md + var details = new TextBlock { TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 4) }; + var link = new Hyperlink(new Run(Loc.T("telemetry.consent.details"))) { NavigateUri = new Uri(TelemetryDocUrl) }; + link.RequestNavigate += (_, e) => + { + try { Process.Start(new ProcessStartInfo(e.Uri.ToString()) { UseShellExecute = true }); } + catch { /* 브라우저 실행 실패는 무시 */ } + e.Handled = true; + }; + details.Inlines.Add(link); + panel.Children.Add(details); + + panel.Children.Add(new TextBlock + { + Text = Loc.T("telemetry.consent.note"), + Opacity = 0.7, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 4, 0, 0), + }); + + var okButton = new Button + { + Content = Loc.T("telemetry.consent.ok"), + Width = 90, + IsDefault = true, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(16, 10, 16, 12), + }; + okButton.Click += (_, _) => + { + settings.TelemetryBasicEnabled = basicCheck.IsChecked == true; + settings.TelemetryQualityEnabled = qualityCheck.IsChecked == true; + if (settings.TelemetryBasicEnabled || settings.TelemetryQualityEnabled) + TelemetryClient.EnsureClientId(settings); + Close(); + }; + + // 확인이든 X든 한 번 물었으면 다시 묻지 않는다(체크 안 하면 둘 다 꺼짐 = 미수집) + Closed += (_, _) => + { + settings.TelemetryConsentAsked = true; + settings.Save(); + }; + + var root = new DockPanel(); + DockPanel.SetDock(okButton, Dock.Bottom); + root.Children.Add(okButton); + root.Children.Add(panel); + Content = root; + } +} diff --git a/src/Musebase.Windows/i18n/en.json b/src/Musebase.Windows/i18n/en.json index 71a32da..9e2cbcf 100644 --- a/src/Musebase.Windows/i18n/en.json +++ b/src/Musebase.Windows/i18n/en.json @@ -38,6 +38,19 @@ "settings.contribute.tooltip": "Open the translation contribution guide (GitHub)", "settings.save": "Save", + "settings.telemetry.header": "Usage statistics", + "settings.telemetry.details": "What is collected (TELEMETRY.md)…", + "settings.telemetry.resetId": "Reset anonymous ID", + "settings.telemetry.resetId.done": "A new anonymous ID will be used from now on. Previously sent data cannot be linked to it.", + + "telemetry.consent.title": "Musebase — Usage statistics", + "telemetry.consent.intro": "Would you like to help improve Musebase by sharing anonymous usage statistics? Nothing is collected unless you opt in. The identifier is a random ID created on this device, and no lyrics text, listening history, or personal data is ever sent.", + "telemetry.consent.basic": "Basic statistics — features, performance, environment. No song information.", + "telemetry.consent.quality": "Quality reports — includes the song title and artist of tracks whose lyrics were not found or that you marked as wrong lyrics.", + "telemetry.consent.details": "Details: everything that is collected (TELEMETRY.md)…", + "telemetry.consent.note": "Both options are off by default. You can change them anytime in Settings → General.", + "telemetry.consent.ok": "OK", + "tray.tooltip.version": "Musebase v{version}", "tray.tooltip.status": "Musebase\n{status}", "tray.overlay.show": "Show overlay", diff --git a/src/Musebase.Windows/i18n/ko.json b/src/Musebase.Windows/i18n/ko.json index 6b84fd9..4ce0c39 100644 --- a/src/Musebase.Windows/i18n/ko.json +++ b/src/Musebase.Windows/i18n/ko.json @@ -38,6 +38,19 @@ "settings.contribute.tooltip": "번역 기여 안내 열기 (GitHub)", "settings.save": "저장", + "settings.telemetry.header": "사용 통계", + "settings.telemetry.details": "수집 항목 안내 (TELEMETRY.md)…", + "settings.telemetry.resetId": "익명 ID 재설정", + "settings.telemetry.resetId.done": "이제부터 새 익명 ID를 사용합니다. 이전에 보낸 데이터와는 연결되지 않습니다.", + + "telemetry.consent.title": "Musebase — 사용 통계", + "telemetry.consent.intro": "익명 사용 통계를 공유해 Musebase 개선을 도와주시겠어요? 동의한 경우에만 수집합니다. 식별자는 이 기기에서 만든 무작위 ID이며, 가사 본문·재생 이력·개인정보는 절대 보내지 않습니다.", + "telemetry.consent.basic": "기본 통계 — 기능·성능·환경 정보. 곡 정보는 포함하지 않습니다.", + "telemetry.consent.quality": "품질 리포트 — 가사를 못 찾았거나 '틀린 가사'로 표시한 곡의 제목·아티스트를 포함합니다.", + "telemetry.consent.details": "자세히: 수집 항목 전체 (TELEMETRY.md)…", + "telemetry.consent.note": "두 항목 모두 기본값은 꺼짐입니다. 설정 → 일반에서 언제든 바꿀 수 있습니다.", + "telemetry.consent.ok": "확인", + "tray.tooltip.version": "Musebase v{version}", "tray.tooltip.status": "Musebase\n{status}", "tray.overlay.show": "오버레이 표시", From ece654557cc2c045bcfe18c2434af3a694867fcb Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 17 Jul 2026 11:01:11 +0900 Subject: [PATCH 11/17] docs(contracts): daily debounce = local calendar date, aligned across platform heads Co-Authored-By: Claude Opus 4.8 (1M context) --- contracts/telemetry-events.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contracts/telemetry-events.md b/contracts/telemetry-events.md index e1f0cd5..ca5041d 100644 --- a/contracts/telemetry-events.md +++ b/contracts/telemetry-events.md @@ -27,6 +27,8 @@ Worker의 화이트리스트(`EVENT_TYPES`)·앱 계측·`TELEMETRY.md`는 이 |---|---|---|---| | `app_session` | ① | `uiLang`, `targetLang`, `engine`(deepl/libretranslate/none), `sources`(활성 소스 id 배열), `sourceMode`(auto/특정앱), `osVersion`(대분류, 예 "Windows 10") | 하루 1회(일일 ping 겸용) | | `playback_source` | ① | `appId`(SMTC/MediaSession 앱 식별자) | 클라이언트가 하루 중 앱별 1회로 디바운스 | + +"하루 1회"의 기준: **로컬 날짜(yyyy-MM-dd, 자정 리셋)** — 24시간 롤링 아님. 모든 플랫폼 헤드 동일. | `lyrics_search` | ① | `winner`(채택 소스 id 또는 "none"), `perSource`({id: {hit: bool, latencyMs: int}}), `cached`(bool), `cleanedQueryUsed`(bool) | 곡 정보 없음 | | `lyrics_not_found` | ② | `title`, `artist` | 검색 실패 곡 | | `wrong_lyrics` | ② | `title`, `artist`, `source`(채택됐던 소스 id) | "틀린 가사" 표시 시 | From 68c55ff9617e7f2569ab82aea35ab1a27bb052f5 Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 17 Jul 2026 11:13:45 +0900 Subject: [PATCH 12/17] feat: wire TelemetryClient into engine factory (activates core instrumentation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the app-level playback_source handler — the coordinator now emits it and the client still debounces per appId per local day. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Musebase.Windows/Program.cs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/Musebase.Windows/Program.cs b/src/Musebase.Windows/Program.cs index 73f03d8..f4d1e5f 100644 --- a/src/Musebase.Windows/Program.cs +++ b/src/Musebase.Windows/Program.cs @@ -55,13 +55,7 @@ private static void Main(string[] args) telemetry.FlushPendingToDisk(); }; - // 재생 소스 앱 통계(클라이언트가 appId별 하루 1회로 디바운스) - nowPlaying.TrackChanged += t => - { - if (t is { SourceAppId.Length: > 0 }) - telemetry.Track(TelemetryEvents.PlaybackSource, - new Dictionary { ["appId"] = t.SourceAppId }); - }; + // 재생 소스 앱 통계는 코디네이터(엔진 계측)가 발화하고, 클라이언트가 appId별 하루 1회로 디바운스한다. // 번역: SQLite 라인 캐시 + 레지스트리에서 선택된 엔진(키 없으면 무키 무료로 폴백) var cacheDb = Path.Combine( @@ -86,7 +80,8 @@ private static void Main(string[] args) // 공유 조합 팩토리로 코디네이터 조립(동일 조합을 Android/서버가 재사용) var coordinator = LyricsEngineFactory.Create( - nowPlaying, new WpfEngineDispatcher(app.Dispatcher), CurrentConfig(), translationCache, Log.Write); + nowPlaying, new WpfEngineDispatcher(app.Dispatcher), CurrentConfig(), translationCache, Log.Write, + telemetry: telemetry); // 엔진 계측(lyrics_search/translation/wrong_lyrics/…) 활성화 // "틀린 가사" 억제 목록을 설정에서 복원하고 변경 시 영속화 foreach (var key in settings.SuppressedTracks) coordinator.SuppressedTrackKeys.Add(key); From f21122652e4146c5e4070d455b14fcad648b7be4 Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 17 Jul 2026 11:30:57 +0900 Subject: [PATCH 13/17] chore: bump to 0.10.0-beta.1 (first Musebase-branded public beta) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Musebase.Windows/Musebase.Windows.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Musebase.Windows/Musebase.Windows.csproj b/src/Musebase.Windows/Musebase.Windows.csproj index 1c28f2c..8a3bbdb 100644 --- a/src/Musebase.Windows/Musebase.Windows.csproj +++ b/src/Musebase.Windows/Musebase.Windows.csproj @@ -12,7 +12,7 @@ app.manifest assets\app.ico - 0.9.2 + 0.10.0-beta.1 From 52d23981fe54db04864f84ffbf326bc7ee38a523 Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 17 Jul 2026 12:02:37 +0900 Subject: [PATCH 14/17] =?UTF-8?q?feat(backend):=20token-protected=20/admin?= =?UTF-8?q?=20page=20=E2=80=94=20quality=20reports=20+=20fleet=20overview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wrong_lyrics / lyrics_not_found grouped by song (frequency-sorted), event totals, platform/version distribution. Auth via ADMIN_TOKEN worker secret; 401 without it. noindex. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/telemetry/src/worker.js | 66 +++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/backend/telemetry/src/worker.js b/backend/telemetry/src/worker.js index dac9d5b..5bf2fe7 100644 --- a/backend/telemetry/src/worker.js +++ b/backend/telemetry/src/worker.js @@ -60,6 +60,71 @@ async function handleIngest(request, env) { return json({ ok: true, stored: rows.length }); } +// ---- 관리자 조회 페이지 (토큰 보호) ------------------------------------------- +// GET /admin?token= — 품질 리포트(틀린가사/검색실패, 빈도순) + 현황 요약 HTML. +// 토큰은 Worker Secret(ADMIN_TOKEN)으로 관리: `wrangler secret put ADMIN_TOKEN` + +function esc(s) { + return String(s ?? "").replace(/[&<>"']/g, c => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} + +async function handleAdmin(url, env) { + const token = url.searchParams.get("token") ?? ""; + if (!env.ADMIN_TOKEN || token !== env.ADMIN_TOKEN) + return new Response("unauthorized", { status: 401 }); + + const [wrong, notFound, summary, versions] = await Promise.all([ + env.DB.prepare( + `SELECT json_extract(props,'$.title') AS title, json_extract(props,'$.artist') AS artist, + json_extract(props,'$.source') AS source, COUNT(*) AS cnt, + COUNT(DISTINCT client_id) AS clients, MAX(received_at) AS last_seen + FROM events WHERE type='wrong_lyrics' + GROUP BY title, artist, source ORDER BY cnt DESC, last_seen DESC LIMIT 200`).all(), + env.DB.prepare( + `SELECT json_extract(props,'$.title') AS title, json_extract(props,'$.artist') AS artist, + COUNT(*) AS cnt, COUNT(DISTINCT client_id) AS clients, MAX(received_at) AS last_seen + FROM events WHERE type='lyrics_not_found' + GROUP BY title, artist ORDER BY cnt DESC, last_seen DESC LIMIT 200`).all(), + env.DB.prepare( + `SELECT type, COUNT(*) AS cnt, COUNT(DISTINCT client_id) AS clients, MAX(received_at) AS last_seen + FROM events GROUP BY type ORDER BY cnt DESC`).all(), + env.DB.prepare( + `SELECT platform, app_version, COUNT(DISTINCT client_id) AS clients, MAX(received_at) AS last_seen + FROM events GROUP BY platform, app_version ORDER BY last_seen DESC LIMIT 50`).all(), + ]); + + const rows = (rs, cols) => rs.results.length + ? rs.results.map(r => `${cols.map(c => `${esc(r[c])}`).join("")}`).join("") + : `아직 데이터 없음`; + + const html = ` + +Musebase 텔레메트리 관리자 +

Musebase 텔레메트리 관리자

+

생성 시각(UTC): ${new Date().toISOString()} · 원본 보존 90일 · 공개 집계

+

틀린 가사 리포트 (빈도순, 상위 200)

+ +${rows(wrong, ["title", "artist", "source", "cnt", "clients", "last_seen"])}
곡명아티스트가사 소스건수사용자수마지막
+

가사 검색 실패 곡 (빈도순, 상위 200)

+ +${rows(notFound, ["title", "artist", "cnt", "clients", "last_seen"])}
곡명아티스트건수사용자수마지막
+

이벤트 전체 현황

+ +${rows(summary, ["type", "cnt", "clients", "last_seen"])}
종류건수고유 사용자마지막
+

플랫폼·버전 분포

+ +${rows(versions, ["platform", "app_version", "clients", "last_seen"])}
플랫폼앱 버전고유 사용자마지막
+`; + return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }); +} + async function handleStats(env) { const since = new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString(); const { results } = await env.DB.prepare( @@ -75,6 +140,7 @@ export default { if (url.pathname === "/healthz") return new Response("ok"); if (url.pathname === "/ingest" && request.method === "POST") return handleIngest(request, env); if (url.pathname === "/stats" && request.method === "GET") return handleStats(env); + if (url.pathname === "/admin" && request.method === "GET") return handleAdmin(url, env); return json({ error: "not found" }, 404); }, }; From 3abdbc8b576ca2ea582097c4f8e8380c79fa700f Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 17 Jul 2026 12:21:55 +0900 Subject: [PATCH 15/17] feat(windows): 1h telemetry upload cadence + immediate upload on wrong-lyrics - Periodic upload interval 6h -> 1h - wrong_lyrics triggers an upload after a 3s coalescing delay so quality reports appear on the admin page right away - SemaphoreSlim gate prevents overlapping periodic/immediate uploads (no duplicate batches) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Services/TelemetryClient.cs | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/src/Musebase.Windows/Services/TelemetryClient.cs b/src/Musebase.Windows/Services/TelemetryClient.cs index 404ba9c..04f2066 100644 --- a/src/Musebase.Windows/Services/TelemetryClient.cs +++ b/src/Musebase.Windows/Services/TelemetryClient.cs @@ -15,7 +15,8 @@ namespace Musebase.Windows.Services; /// - 옵트인 2단계: ① 기본() / /// ② 품질(). 꺼져 있으면 수집 자체를 안 한다. /// - 로컬 큐: %LOCALAPPDATA%\Musebase\telemetry-queue.jsonl (1줄=1이벤트, 상한 500건). -/// - 업로드: 시작 30초 후 + 이후 6시간마다 배치(≤100건) POST. 실패 시 큐 보존 후 재시도. +/// - 업로드: 시작 30초 후 + 이후 1시간마다 배치(≤100건) POST. 실패 시 큐 보존 후 재시도. +/// wrong_lyrics(틀린 가사 표시)는 3초 병합 지연 후 즉시 업로드 트리거. /// - 은 논블로킹이며 절대 던지지 않는다(수집 실패는 무해). /// - 클라이언트 집계: playback_source는 appId별 하루 1회 디바운스, feature_use는 세션 카운터로 /// 모았다가 업로드 시 집계 이벤트로 변환, app_session은 하루 1회(일일 ping 겸용). @@ -46,6 +47,7 @@ public sealed class TelemetryClient : ITelemetry, IDisposable private readonly ConcurrentQueue _pendingLines = new(); private readonly ConcurrentDictionary _featureCounts = new(StringComparer.Ordinal); private readonly CancellationTokenSource _cts = new(); + private readonly SemaphoreSlim _uploadGate = new(1, 1); // 주기·즉시 업로드 중복 실행 방지 private DebounceState? _state; // 지연 로드(파일 IO는 첫 사용 시) /// 디바운스 상태(%LOCALAPPDATA%\Musebase\telemetry-state.json). 날짜는 로컬 yyyy-MM-dd. @@ -111,6 +113,10 @@ public void Track(string type, IReadOnlyDictionary? props = nul return; Enqueue(type, props); + + // 틀린 가사 표시는 관리자 페이지에서 바로 보이도록 즉시 업로드(3초 병합 지연) + if (type == TelemetryEvents.WrongLyrics) + ScheduleImmediateUpload(); } catch { @@ -118,6 +124,23 @@ public void Track(string type, IReadOnlyDictionary? props = nul } } + /// 짧은 병합 지연 후 업로드 1회 트리거(연속 표시를 한 배치로). + private void ScheduleImmediateUpload() + { + _ = Task.Run(async () => + { + try + { + await Task.Delay(TimeSpan.FromSeconds(3), _cts.Token).ConfigureAwait(false); + await UploadOnceAsync().ConfigureAwait(false); + } + catch + { + // 무시 — 다음 주기 업로드가 커버 + } + }); + } + /// feature_use 카운트 편의 메서드(기존 핸들러에 한 줄 추가용). public void CountFeature(string feature) => Track(TelemetryEvents.FeatureUse, new Dictionary { ["feature"] = feature }); @@ -204,7 +227,7 @@ public void FlushPendingToDisk() /// /// 백그라운드 업로더 시작: 초기 지연(기본 30초, 환경변수 - /// MUSEBASE_TELEMETRY_INITIAL_DELAY_SECONDS로 재정의 가능) 후 1회, 이후 6시간마다. + /// MUSEBASE_TELEMETRY_INITIAL_DELAY_SECONDS로 재정의 가능) 후 1회, 이후 1시간마다. /// public void StartUploader() { @@ -218,14 +241,16 @@ public void StartUploader() while (!_cts.IsCancellationRequested) { await UploadOnceAsync().ConfigureAwait(false); - try { await Task.Delay(TimeSpan.FromHours(6), _cts.Token); } catch { return; } + try { await Task.Delay(TimeSpan.FromHours(1), _cts.Token); } catch { return; } } }); } - /// 업로드 1주기: 일일 app_session 발화 → feature_use 집계 반영 → 큐 배치 전송. + /// 업로드 1주기: 일일 app_session 발화 → feature_use 집계 반영 → 큐 배치 전송. + /// 이미 실행 중이면 건너뛴다(주기 업로드와 즉시 트리거의 중복 전송 방지). public async Task UploadOnceAsync() { + if (!await _uploadGate.WaitAsync(0).ConfigureAwait(false)) return; try { if (!_settings.TelemetryBasicEnabled && !_settings.TelemetryQualityEnabled) return; @@ -306,6 +331,10 @@ public async Task UploadOnceAsync() { _log?.Invoke($"[telemetry] 업로드 오류: {e.GetType().Name} — 큐 보존"); } + finally + { + _uploadGate.Release(); + } } /// 세션 feature_use 카운터를 집계 이벤트로 변환해 대기열에 넣는다. From fc7a2629276690feee6b9d05fd873b83ea0fe6c7 Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 17 Jul 2026 12:25:50 +0900 Subject: [PATCH 16/17] chore: bump to 0.10.0-beta.2 (admin page + faster telemetry cadence) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Musebase.Windows/Musebase.Windows.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Musebase.Windows/Musebase.Windows.csproj b/src/Musebase.Windows/Musebase.Windows.csproj index 8a3bbdb..0e4cc88 100644 --- a/src/Musebase.Windows/Musebase.Windows.csproj +++ b/src/Musebase.Windows/Musebase.Windows.csproj @@ -12,7 +12,7 @@ app.manifest assets\app.ico - 0.10.0-beta.1 + 0.10.0-beta.2 From 6d49d2d99908255e3b361d2ecf8e497e8ac4cbcb Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 17 Jul 2026 12:30:42 +0900 Subject: [PATCH 17/17] =?UTF-8?q?chore:=20release=20windows-v0.10.0=20?= =?UTF-8?q?=E2=80=94=20first=20Musebase=20release=20(version,=20docs,=20ta?= =?UTF-8?q?g=20scheme)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 7 ++++++- RELEASING.md | 3 ++- src/Musebase.Windows/Musebase.Windows.csproj | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index f234d2f..20dca0a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,8 +1,13 @@ # PROGRESS — Musebase for Windows (구 LyricsX for Windows) -> **상태: v0.9.2 (2026-07-16) + Phase 0 진행 중** — 제품 개명(LyricsX→Musebase, 프로젝트/네임스페이스/데이터 경로 포함) + 멀티플랫폼 거버넌스 셋업 + LICENSE(MPL-2.0) +> **상태: windows-v0.10.0 (2026-07-17)** — 첫 Musebase 정식 릴리스(packId 클린 브레이크). 개명 + 거버넌스 + MPL-2.0 + 옵트인 텔레메트리 + Browser/Android 스파이크. > 재개 방법: "이어서"라고 입력하면 아래 백로그부터 진행. +## v0.10.0 추가분 (첫 Musebase 릴리스) +- **옵트인 텔레메트리(ADR-0004)**: 익명 랜덤 GUID, 2단계 동의(①기본/②품질 — 다이얼로그·설정 토글, 기본 꺼짐), Engine `ITelemetry` 계측(lyrics_search/translation/wrong_lyrics/…), Windows `TelemetryClient`(JSONL 큐→시작 30초+1시간 주기, 틀린가사 즉시 업로드), 백엔드 Cloudflare Workers+D1(`backend/telemetry/`, /stats 공개·/admin 토큰 보호). 공개 문서 `TELEMETRY.md`, 계약 `contracts/telemetry-events.md`. +- **Phase 1·2 스파이크**: `src/Musebase.Browser`(PlaybackViewState WS 방송+웹 카라오케 렌더러, --demo) · `src/Musebase.Android`(MediaSession 재생감지, 실기기 검증, sln 미등록). +- 릴리스 태그 스킴 전환: 플랫폼 접두 **`windows-vX.Y.Z`**(ADR-0003). + ## Phase 0 (개명 + 거버넌스, 2026-07-16) - **개명 LyricsX→Musebase**: 프로젝트 `Musebase.{Core,Engine,Windows}`(구 App→Windows)·`Musebase.sln`·네임스페이스·AssemblyName(`Musebase.exe`) 일괄. `%LOCALAPPDATA%\LyricsX`→`Musebase` 자동 이전(`MigrateLegacyAppData`), DPAPI entropy는 호환 위해 `"LyricsX.DeepL.v1"` 유지, 시작프로그램 레지스트리 값 `Musebase`(+구 값 정리). Velopack packId `Musebase` = 구 설치본 자동 업데이트 단절(클린 브레이크, RELEASING.md 참고). - **LICENSE(MPL-2.0) + 출처 표기**: 원본 LyricsX/LyricsKit(ddddxxx, MPL-2.0) 기반 명시. README 라이선스 절의 GPLv3 오기 수정. diff --git a/RELEASING.md b/RELEASING.md index b94cb9b..49c069e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -49,8 +49,9 @@ vpk upload github \ --repoUrl $REPO \ --publish \ --releaseName "Musebase $VERSION" \ - --tag $VERSION \ + --tag windows-v$VERSION \ --token +# 태그는 플랫폼 접두 스킴(ADR-0003): windows-vX.Y.Z. 홈페이지 동기화가 접두를 제거해 표기한다. ``` `vpk upload github`은 태그 `$VERSION`으로 릴리스를 만들고 `RELEASES`, 델타 `.nupkg`, diff --git a/src/Musebase.Windows/Musebase.Windows.csproj b/src/Musebase.Windows/Musebase.Windows.csproj index 0e4cc88..1be4382 100644 --- a/src/Musebase.Windows/Musebase.Windows.csproj +++ b/src/Musebase.Windows/Musebase.Windows.csproj @@ -12,7 +12,7 @@ app.manifest assets\app.ico - 0.10.0-beta.2 + 0.10.0