Skip to content

Commit 406d987

Browse files
countnineclaude
andcommitted
feat(windows,core): 광고 구간에는 가사를 찾지 않는다 (AdSignals를 Engine으로)
Spotify 광고 중 SMTC가 "Spotify / (광고)"를 보고하면 Musebase Windows가 그 "곡"의 가사를 검색하고 서버에 miss를 기록했다. 소리는 Mutefy가 죽여 주는데 오버레이만 헛돌고 서버 로그가 지저분해진다(실제로 광고 행이 저장돼 있었다). Android에는 방금 넣었으니 Windows도 같게 한다. - Windows: SMTC에 광고 플래그도 mediaId도 없어 실측 검증된 신호 ③만 본다 (아티스트=Spotify/Sponsored Message + 앨범 비어 있음). 앨범 조건이 오탐 안전장치다 — 그 이름의 실제 곡이 있어도 앨범이 채워져 있어 걸리지 않는다. 디바운스는 두지 않는다: ③은 아티스트가 비면 false라 곡 전환 순간의 빈 메타데이터를 광고로 보지 않고, 한 틱 틀려도 가사가 잠깐 늦을 뿐이다. - 그러려면 판정 규칙이 두 플랫폼 공통이어야 해서 AdSignals를 Musebase.Android → Musebase.Engine으로 옮겼다. ADR-0006이 "나중에 코어로 옮겨 테스트를 붙이겠다"고 예고한 이동이며, 옮기면서 실제로 붙였다 (AdSignalsTests 5건 — 오탐 쪽을 특히 본다). - 뮤트 상태 기계(AdDecision/AdSignal)는 Android에 남는다. 볼륨을 다루는 안전장치라 가사 쪽에는 필요 없다. Windows에서 광고를 뮤트하고 MP3를 채우는 일은 별도 앱 Mutefy가 한다. 소스를 확인한 결과 충돌 지점이 없다 — Mutefy는 Spotify 프로세스의 오디오 세션만 음소거하고(SimpleAudioVolume, PID 대상), 필러는 WasapiOut 직접 출력이라 SMTC 세션을 만들지 않는다(필러가 "재생 중인 곡"으로 잡히지 않는다). 자동시작 Run 키 값 이름·설정 경로·단일 인스턴스 뮤텍스도 모두 다르고, SMTC는 둘 다 읽기 전용이라 동시 구독에 문제가 없다. 솔루션·Android 빌드 경고 0, 테스트 174개 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c2c0708 commit 406d987

7 files changed

Lines changed: 157 additions & 65 deletions

File tree

PROGRESS.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,12 @@
1313
- **동시 재생 중복 작업 줄이기(코어+서버, Android 일부)** — Spotify Connect로 PC 재생 + 폰 조작을 하면 두 기기가 같은 곡을 동시에 처리해 각자 검색하고 **각자 유료 번역**을 부른다. 서버 로그 실측: 조회 304건 중 동시 조회 26건, 그중 22건이 양쪽 다 miss(= 곡 11개를 두 번씩 번역).
1414
- **번역 양보** — 서버가 미스 응답 본문에 `pending`(최근 30초 안에 다른 기기도 같은 제목을 미스함)을 실어 준다. 받은 앱은 제공자 검색은 그대로 하되 **번역만** 미루고 서버를 재조회해(1~5초 간격, 상한 8초) 저쪽이 올린 번역본을 받아 쓴다. **원문 표시는 늦어지지 않는다** — 번역이 몇 초 뒤 붙는 건 원래 동작이라 손해가 없다. 받아 쓴 것은 되올리지 않는다(revision만 오른다). 판정 근거는 기존 `lookups`라 새 테이블이 없고, 조회 기록을 끄면 양보도 함께 꺼진다. `MUSEBASE_YIELD_WINDOW_SECONDS`(0이면 끔).
1515
- **아티스트 꼬리표에 `` 추가** — Spotify Android가 `Phoenix • 스마트셔플 추천`처럼 붙여 같은 곡이 기기별로 다른 키가 되고 있었다(조회 29건, 실제 중복 행 `uptown girl`·`Go!` 확인). `SearchTermCleaner.CleanArtist`(제공자 검색)와 `LyricsStore.StripAlbumSuffix`(서버 키) 양쪽에 반영.
16-
- **광고 트랙 차단** — 광고 구간에는 `TrackChanged`를 발화하지 않는다(같은 메타데이터에서 직접 판정 — `IsAdvertisement``RefreshTrack` 뒤에 갱신돼 아직 이전 곡 기준이다). 서버에 `Spotify / 광고 • 1/2`가 실제로 저장돼 있었다.
17-
- `IRemoteLyricsCache.GetAsync``RemoteLyricsResult`(가사 + `Pending`/`RetryAfterMs`/`Langs`)를 돌려주도록 바꿨다. 구버전 서버(본문 없는 404)와 그대로 호환. 테스트 5건 추가(169개 통과).
16+
- **광고 트랙 차단(Windows·Android 공통)** — 광고 구간에는 트랙을 만들지 않아 가사 검색·서버 조회가 아예 돌지 않는다. 서버에 `Spotify / 광고 • 1/2`가 실제로 저장돼 있었다.
17+
- Android는 같은 메타데이터에서 직접 판정한다 — `IsAdvertisement` 속성은 `RefreshTrack` **뒤에** 갱신돼 아직 이전 곡 기준이다.
18+
- Windows는 SMTC에 광고 플래그·`mediaId`가 없어 실측 검증된 신호 ③(아티스트=`Spotify`/`Sponsored Message` + 앨범 비어 있음)만 본다. 앨범 조건이 오탐 안전장치다.
19+
- 그러려면 판정 규칙이 공통이어야 해서 **`AdSignals``Musebase.Android``Musebase.Engine`으로 옮겼다**(ADR-0006이 예고한 이동). 뮤트 상태 기계(`AdDecision`)는 볼륨 안전장치라 Android에 남는다. 옮기면서 유닛 테스트 5건이 붙었다(`AdSignalsTests`).
20+
- Windows에서 광고를 뮤트하고 MP3를 채우는 건 별도 앱 **Mutefy**가 한다. 충돌 없음 — Mutefy는 Spotify 프로세스의 오디오 세션만 음소거하고, 필러는 `WasapiOut` 직접 출력이라 SMTC 세션을 만들지 않는다(자동시작 Run 키 값·설정 경로·단일 인스턴스 뮤텍스도 모두 다르다).
21+
- `IRemoteLyricsCache.GetAsync``RemoteLyricsResult`(가사 + `Pending`/`RetryAfterMs`/`Langs`)를 돌려주도록 바꿨다. 구버전 서버(본문 없는 404)와 그대로 호환. 테스트 10건 추가(174개 통과).
1822
- **서버 재배포 필요**(앱 릴리스와 별개). 앱이 구버전이어도 추가 필드를 무시할 뿐이라 안전하다.
1923
- **잠금화면 가사(Android)** — 플로팅 오버레이는 잠금화면 위에 뜰 수 **없다**(Android 8부터 `TYPE_APPLICATION_OVERLAY`가 키가드보다 아래 레이어로 고정, `TYPE_SYSTEM_OVERLAY`는 일반 앱에서 제거 — 오버레이 권한 예외 없음). 대신 이미 `Visibility.Public`인 포그라운드 알림에 현재 줄을 실어 잠금화면에서 읽히게 한다: 접힌 알림 = 원문, 펼친 알림 = **원문 + 번역**. 설정 [오버레이] 탭의 "알림에 현재 가사 표시"로 끌 수 있다(기본 켬 — 끄면 듣는 내용이 잠금화면에 남지 않는다). 갱신은 최소 400ms 간격으로 묶는다(시스템의 알림 갱신 빈도 제한 — 간주 표시 줄처럼 짧은 줄이 연달아 나오면 갱신이 통째로 버려진다). 카라오케·색은 알림에서 불가능.
2024
- **설정 [정보] 탭(Android)** — Windows [정보] 탭과 같은 내용(앱 이름·버전·구 LyricsX·소개·MPL-2.0·LyricsKit 출처·링크)에 안드로이드 전용 줄을 더했다: 패키지명·OS·기기를 보여 주고 **탭하면 버전까지 붙여 클립보드로 복사**(문제 신고용). 버전은 `PackageManager.GetPackageInfo`에서 읽어 릴리스마다 손댈 곳이 없다. 탭 이름은 5개가 한 줄에 들어가도록 줄였다(소스/번역/오버레이/광고/정보).

docs/adr/0006-android-ad-mute.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ Spotify만 음소거하고, 그동안 지정 폴더의 MP3를 랜덤 재생하
4747

4848
## 결과
4949

50-
- 감지 신호는 신뢰도 순으로 셋을 겹친다(`Services/AdDecision.cs``AdSignals`):
50+
- 감지 신호는 신뢰도 순으로 셋을 겹친다(`AdSignals` — 2026-08-01에 `Musebase.Engine`으로 옮겼다):
5151
① 표준 광고 플래그 → ② `spotify:ad` 미디어 ID → ③ `artist=Spotify` + 빈 앨범(Windows 실측 폴백).
5252
③의 앨범 조건이 안전장치다 — Spotify의 실제 곡은 앨범이 항상 채워져 있다.
5353
- **.NET for Android 바인딩에 `MetadataKeyAdvertisement` 상수가 없어** 플랫폼 키 문자열
@@ -62,6 +62,20 @@ Spotify만 음소거하고, 그동안 지정 폴더의 MP3를 랜덤 재생하
6262
net8.0이다. 그래서 판정·디바운스 로직(`AdSignals`/`AdDecision`)을 **Android 타입 무의존**으로
6363
써 두었다 — 나중에 코어 변경 요청으로 `Musebase.Engine`에 옮기면 그대로 테스트할 수 있다.
6464

65+
### 보완 (2026-08-01) — `AdSignals`를 Engine으로
66+
67+
위의 "나중에"가 왔다. **광고 구간에는 가사를 찾지 않는다**를 Windows에도 넣으면서 판정 규칙이
68+
두 플랫폼 공통이 됐으므로 `AdSignals``Musebase.Engine`으로 옮겼다(`AdSignalsTests` 5건 추가).
69+
70+
- 뮤트 상태 기계(`AdDecision`/`AdSignal`)는 **Android에 남는다** — 볼륨을 다루는 안전장치라
71+
가사 쪽에는 필요 없다.
72+
- Windows에는 광고 플래그도 `mediaId`도 없어 신호 ③만 쓴다(`LooksLikeAd(artist, album)` 오버로드).
73+
디바운스도 없다 — ③은 아티스트가 비면 false라서 곡 전환 순간의 빈 메타데이터를 광고로 보지 않고,
74+
설령 한 틱 틀려도 가사 검색이 잠깐 늦어질 뿐 볼륨처럼 사용자에게 남는 피해가 없다.
75+
- Windows에서 광고를 뮤트하고 MP3를 채우는 일은 **별도 앱(Mutefy)** 이 한다. Musebase Windows는
76+
광고를 **표시하지 않을** 뿐이며, 두 앱은 서로 간섭하지 않는다(Mutefy는 Spotify 프로세스의
77+
오디오 세션만 음소거하고, 필러는 `WasapiOut` 직접 출력이라 SMTC 세션을 만들지 않는다).
78+
6579
## 대안 (기각)
6680

6781
- **별도 앱(Mutefy Android)** — 제품 경계는 명확하지만 감지 계층 재구현 + 알림 접근 재허용 +

src/Musebase.Android/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ android 워크로드가 CI/모든 개발 머신에 없어도 메인 빌드가
1313
| `Services/MediaListenerService.cs` | `NotificationListenerService` — 알림 접근 권한의 앵커. 알림을 파싱하지 않고, `MediaSessionManager.GetActiveSessions(component)` 호출 자격만 제공. **앱 완전 종료용 `Unbind()`/`Rebind()`**(API 24+) — 알림 접근이 켜져 있으면 시스템이 이 서비스를 계속 바인드해 프로세스가 살아 있으므로, 종료 시 스스로 언바인드하고 앱을 다시 열 때 재바인드한다(권한 설정은 유지) |
1414
| `Services/AndroidNowPlayingSource.cs` | **광고 여부**(`IsAdvertisement`/`IsAdvertisementChanged`)와 세션 패키지(`CurrentSourcePackage`)를 Android 전용 속성으로 노출 — `Engine.TrackInfo`를 건드리지 않기 위함(골든룰). 광고 판정은 `TrackInfo` 생성 여부와 **독립**으로 계산한다(광고 구간에 제목이 비면 `RefreshTrack`이 트랙을 안 만드는데 그때도 광고임은 알아야 한다). `INowPlayingSource`(Musebase.Engine 계약)의 Android 구현 — 세션 선택(재생 중 우선), 콜백+500ms 폴링, 위치 보간(+1초 미만 역행 흡수). **재생 소스 선택**: `SetSource(mode, includeVideoApps, preferredSources)`로 자동/특정 앱 패키지 고정, 자동 모드에서는 영상·브라우저 앱(YouTube·크롬 등) 기본 제외(YouTube Music은 음악이라 포함). **선호 음악 앱**(`preferredSources`)을 하나라도 고르면 자동 모드에서 **그 앱들만** 후보가 된다(팟캐스트·영상 앱 차단). 비우면 종전 규칙. 감지된 세션 목록은 `ActiveSessionPackages`로 노출(설정 화면용) |
1515
| `Services/AndroidEngineDispatcher.cs` | `IEngineDispatcher`의 Android 구현 — 메인 Looper `Handler` 기반 Post/주기 타이머(WpfEngineDispatcher와 대칭) |
16-
| `Services/AdDecision.cs` | **광고 판정(Android 무의존)**`AdSignals`가 신호 셋을 신뢰도 순으로 겹친다: ① 표준 플래그 `android.media.metadata.ADVERTISEMENT``spotify:ad` 미디어 ID ③ `artist=Spotify`+빈 앨범(Windows 실측 폴백, 앨범 조건이 오탐 방지). `AdDecision`**진입 디바운스**(150ms — 곡 전환 시 메타데이터가 잠깐 비는 것을 광고로 오인하지 않게, **이탈은 즉시**)와 **안전 상한**(기본 180초 — 넘기면 강제 복구 후 재진입 차단)을 담는다. Android 타입에 의존하지 않으므로 나중에 코어 변경 요청으로 Engine에 옮겨 테스트를 붙일 수 있다 |
16+
| `Services/AdDecision.cs` | **뮤트용 상태 기계****진입 디바운스**(150ms — 곡 전환 시 메타데이터가 잠깐 비는 것을 광고로 오인하지 않게, **이탈은 즉시**)와 **안전 상한**(기본 180초 — 넘기면 강제 복구 후 재진입 차단). 판정 규칙 자체(`AdSignals`)는 **`Musebase.Engine`으로 옮겼다** — Windows도 광고 구간에는 가사를 찾지 않아야 해서 같은 코드를 쓴다. 옮기면서 유닛 테스트가 붙었다(`AdSignalsTests`) |
1717
| `Services/MediaVolumeMuter.cs` | **미디어 볼륨 제어**`AudioManager.SetStreamVolume(Stream.Music, …)`. 안드로이드에는 앱별 음소거 API가 없어 **기기 전체 미디어 볼륨**이 내려간다. 진입 시 이미 0이면 손대지 않고, 뮤트 중 볼륨이 0이 아니게 되면(사용자 볼륨 키) 그 구간을 포기한다. **원래 볼륨을 볼륨을 내리기 전에 `SharedPreferences`에 기록**해 프로세스가 광고 도중 죽어도 다음 실행이 `RestoreOrphanedVolume()`으로 복구한다 |
1818
| `Services/AdMuteController.cs` | 감지와 볼륨 제어를 잇는다 — Spotify(`com.spotify.music`) 세션에서만 동작. 1초 틱으로 사용자 개입 확인 + 안전 상한 시간 발동(신호가 계속 "광고"면 이벤트가 안 오므로 틱이 필요). **포그라운드 서비스를 만들지 않는다** — 알림 접근이 켜져 있으면 시스템이 `MediaListenerService`를 계속 바인드해 프로세스가 살아 있다 |
1919
| `MusebaseApp.cs` | 커스텀 `Application``LyricsEngineFactory.Create`로 엔진 1회 조립(화면 회전에도 유지). 소스=레지스트리 전체(개인용), 번역=MyMemory(무키·무료 기본), 대상 언어=기기 로케일, 캐시=`FilesDir/translations.db`, 텔레메트리=Noop |

src/Musebase.Android/Services/AdDecision.cs

Lines changed: 4 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,9 @@
1-
namespace Musebase.Android.Services;
1+
using Musebase.Engine;
22

3-
/// <summary>
4-
/// 광고 판정 규칙. Android 타입에 의존하지 않는다 — 나중에 코어 변경 요청으로 Engine에 옮겨
5-
/// 유닛 테스트를 붙일 수 있게 하기 위함(현재 Musebase.Android는 sln 밖이라 CI 테스트가 없다).
6-
///
7-
/// 신호를 신뢰도 순으로 겹친다. 1·2번은 Spotify가 시스템 계약에 맞춰 내보내는 값이라
8-
/// 지역·언어와 무관하고, 3번만 추측이다.
9-
/// </summary>
10-
public static class AdSignals
11-
{
12-
/// <summary>
13-
/// 표준 광고 메타데이터 키. 플랫폼 상수(<c>MediaMetadata.METADATA_KEY_ADVERTISEMENT</c>)를
14-
/// 그대로 쓰지 못하고 문자열을 박은 이유: <b>.NET for Android 바인딩에 이 상수가 없다</b>
15-
/// (Microsoft.Android.Ref.34의 <c>Android.Media.MediaMetadata</c>에는 <c>MetadataKeyMediaId</c>
16-
/// 등은 있지만 <c>MetadataKeyAdvertisement</c>는 없다 — 직접 확인함).
17-
/// 값 자체는 안드로이드 플랫폼 계약이라 바뀌지 않는다.
18-
/// </summary>
19-
public const string AdvertisementMetadataKey = "android.media.metadata.ADVERTISEMENT";
20-
21-
/// <summary>위 키가 광고를 뜻하는 값.</summary>
22-
private const long AdvertisementFlagSet = 1;
23-
24-
/// <summary>Spotify가 광고 구간에 쓰는 미디어 ID 접두사. macOS 원본이 쓰던 것과 같은 신호.</summary>
25-
private const string SpotifyAdMediaIdPrefix = "spotify:ad";
26-
27-
/// <summary>
28-
/// Windows(SMTC)에서 실측 검증된 폴백. 앨범 조건이 안전장치다 — Spotify의 실제 곡은 앨범이
29-
/// 항상 채워져 있으므로, 이 이름으로 발매된 진짜 곡이 있어도 뮤트되지 않는다.
30-
///
31-
/// <b>안드로이드에서는 이 표가 맞지 않는다</b> — 실측 결과 광고의 아티스트는
32-
/// <c>'광고 • 1/2'</c>(현지화 + 순번)였다. 순번이 붙어 고정 문자열로 잡을 수 없고 언어마다
33-
/// 다르다. 그래도 지우지 않는 이유는 이게 신호 ③이기 때문이다: ①(플래그)과 ②(mediaId)가
34-
/// 둘 다 오는 것을 확인했으므로 실무상 여기까지 내려올 일이 없고, 만약 Spotify가 ①②를
35-
/// 빼면 최소한 다른 지역/버전에서 걸릴 여지를 남겨 둔다.
36-
/// </summary>
37-
private static readonly string[] AdArtists = { "Spotify", "Sponsored Message" };
38-
39-
/// <param name="advertisementFlag">`METADATA_KEY_ADVERTISEMENT` 값(없으면 0).</param>
40-
/// <param name="mediaId">`METADATA_KEY_MEDIA_ID` 값.</param>
41-
public static bool LooksLikeAd(long advertisementFlag, string? mediaId, string? artist, string? album)
42-
{
43-
// 1) 시스템 표준 광고 플래그 — 있으면 이게 결론이다.
44-
if (advertisementFlag == AdvertisementFlagSet) return true;
45-
46-
// 2) Spotify 고유 광고 URI.
47-
if (!string.IsNullOrWhiteSpace(mediaId) &&
48-
mediaId!.TrimStart().StartsWith(SpotifyAdMediaIdPrefix, StringComparison.OrdinalIgnoreCase))
49-
return true;
50-
51-
// 3) 문자열 폴백. 앨범이 비어 있을 때만 본다.
52-
if (!string.IsNullOrWhiteSpace(album)) return false;
53-
if (string.IsNullOrWhiteSpace(artist)) return false;
54-
55-
var trimmed = artist!.Trim();
56-
foreach (var candidate in AdArtists)
57-
if (string.Equals(trimmed, candidate, StringComparison.OrdinalIgnoreCase)) return true;
3+
namespace Musebase.Android.Services;
584

59-
return false;
60-
}
61-
}
5+
// 광고 **판정 규칙**(AdSignals)은 Musebase.Engine으로 옮겼다 — Windows도 같은 규칙으로
6+
// 광고 구간에는 가사를 찾지 않아야 해서다. 여기 남은 것은 안드로이드 뮤트용 상태 기계뿐이다.
627

638
/// <summary>한 번의 관측 결과. <c>Unknown</c>이 있는 이유는 <see cref="AdDecision"/> 주석 참고.</summary>
649
public enum AdSignal

src/Musebase.Engine/AdSignals.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
namespace Musebase.Engine;
2+
3+
/// <summary>
4+
/// 광고 판정 규칙. 플랫폼 타입에 의존하지 않는 순수 함수라 Windows(SMTC)와 Android(MediaSession)가
5+
/// **같은 코드**로 판정한다(원래 Musebase.Android에 있었고, 그 주석의 "코어로 옮긴다"를 이행한 것이다 —
6+
/// 옮기면서 유닛 테스트가 붙는다. Musebase.Android는 sln 밖이라 CI에서 돌지 않는다).
7+
///
8+
/// 쓰임새는 두 가지다.
9+
/// - Android: 광고 구간 자동 뮤트(옵인, ADR-0006).
10+
/// - 공통: **광고 구간에는 가사를 찾지 않는다.** 광고는 곡이 아니라서 검색이 늘 헛돌고,
11+
/// 가사 서버에는 "Spotify / 광고 1/2" 같은 쓰레기 행이 쌓인다(실제로 쌓여 있었다).
12+
///
13+
/// 신호를 신뢰도 순으로 겹친다. 1·2번은 Spotify가 시스템 계약에 맞춰 내보내는 값이라
14+
/// 지역·언어와 무관하고, 3번만 추측이다.
15+
/// </summary>
16+
public static class AdSignals
17+
{
18+
/// <summary>
19+
/// 표준 광고 메타데이터 키. 플랫폼 상수(<c>MediaMetadata.METADATA_KEY_ADVERTISEMENT</c>)를
20+
/// 그대로 쓰지 못하고 문자열을 박은 이유: <b>.NET for Android 바인딩에 이 상수가 없다</b>
21+
/// (Microsoft.Android.Ref.34의 <c>Android.Media.MediaMetadata</c>에는 <c>MetadataKeyMediaId</c>
22+
/// 등은 있지만 <c>MetadataKeyAdvertisement</c>는 없다 — 직접 확인함).
23+
/// 값 자체는 안드로이드 플랫폼 계약이라 바뀌지 않는다. Windows(SMTC)에는 대응 개념이 없어
24+
/// 항상 0을 넘기게 된다.
25+
/// </summary>
26+
public const string AdvertisementMetadataKey = "android.media.metadata.ADVERTISEMENT";
27+
28+
/// <summary>위 키가 광고를 뜻하는 값.</summary>
29+
private const long AdvertisementFlagSet = 1;
30+
31+
/// <summary>Spotify가 광고 구간에 쓰는 미디어 ID 접두사. macOS 원본이 쓰던 것과 같은 신호.</summary>
32+
private const string SpotifyAdMediaIdPrefix = "spotify:ad";
33+
34+
/// <summary>
35+
/// Windows(SMTC)에서 실측 검증된 폴백. 앨범 조건이 안전장치다 — Spotify의 실제 곡은 앨범이
36+
/// 항상 채워져 있으므로, 이 이름으로 발매된 진짜 곡이 있어도 광고로 오인되지 않는다.
37+
///
38+
/// <b>안드로이드에서는 이 표가 맞지 않는다</b> — 실측 결과 광고의 아티스트는
39+
/// <c>'광고 • 1/2'</c>(현지화 + 순번)였다. 순번이 붙어 고정 문자열로 잡을 수 없고 언어마다
40+
/// 다르다. 그래도 지우지 않는 이유는 이게 신호 ③이기 때문이다: ①(플래그)과 ②(mediaId)가
41+
/// 둘 다 오는 것을 확인했으므로 실무상 여기까지 내려올 일이 없고, 만약 Spotify가 ①②를
42+
/// 빼면 최소한 다른 지역/버전에서 걸릴 여지를 남겨 둔다.
43+
/// </summary>
44+
private static readonly string[] AdArtists = { "Spotify", "Sponsored Message" };
45+
46+
/// <param name="advertisementFlag">`METADATA_KEY_ADVERTISEMENT` 값(없으면 0 — Windows는 항상 0).</param>
47+
/// <param name="mediaId">`METADATA_KEY_MEDIA_ID` 값(Windows에는 없다 — null).</param>
48+
public static bool LooksLikeAd(long advertisementFlag, string? mediaId, string? artist, string? album)
49+
{
50+
// 1) 시스템 표준 광고 플래그 — 있으면 이게 결론이다.
51+
if (advertisementFlag == AdvertisementFlagSet) return true;
52+
53+
// 2) Spotify 고유 광고 URI.
54+
if (!string.IsNullOrWhiteSpace(mediaId) &&
55+
mediaId!.TrimStart().StartsWith(SpotifyAdMediaIdPrefix, StringComparison.OrdinalIgnoreCase))
56+
return true;
57+
58+
// 3) 문자열 폴백. 앨범이 비어 있을 때만 본다.
59+
if (!string.IsNullOrWhiteSpace(album)) return false;
60+
if (string.IsNullOrWhiteSpace(artist)) return false;
61+
62+
var trimmed = artist!.Trim();
63+
foreach (var candidate in AdArtists)
64+
if (string.Equals(trimmed, candidate, StringComparison.OrdinalIgnoreCase)) return true;
65+
66+
return false;
67+
}
68+
69+
/// <summary>
70+
/// 메타데이터가 없는 플랫폼(Windows SMTC)용 간편 오버로드 — 신호 ③만 본다.
71+
/// </summary>
72+
public static bool LooksLikeAd(string? artist, string? album) =>
73+
LooksLikeAd(0, null, artist, album);
74+
}

0 commit comments

Comments
 (0)