Files
lampac/mods/Music/Services/Cache/MusicSourceMatchService.cs
T
prodik 2dd171612d Исправление: mods/Music/Services/Cache не попадал в git из-за .gitignore (cache/ без якоря)
- .gitignore: якоря /cache/, /database/, /dlna/ только для корневых каталогов
- git add -f: папка Cache модуля Music (MusicMetadataCacheService и др.) —
  без неё Roslyn-компиляция модуля падает (CS0103) на любой машине, где проект
  получен из git (сервер уходил в crash-loop)
2026-10-02 17:49:28 +03:00

345 lines
14 KiB
C#

using Shared.Services.Hybrid;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Caching.Memory;
namespace Music;
public static class MusicSourceMatchService
{
const string CacheKeyPrefix = "music:sourcematch:v3";
const string MissingCacheKeyPrefix = "music:sourcematch-miss:v2";
static readonly TimeSpan MatchTtl = TimeSpan.FromDays(180);
static readonly TimeSpan MissingTtl = TimeSpan.FromMinutes(30);
const long MatchCacheSizeLimit = 10_000;
static readonly MemoryCache matchCache = new(new MemoryCacheOptions
{
SizeLimit = MatchCacheSizeLimit
});
static readonly MemoryCache missingCache = new(new MemoryCacheOptions());
// Fixed stripes bound memory use; one track/mode shares a gate across providers
// so reset, manual selection and late automatic results are ordered together.
static readonly SemaphoreSlim[] stateLocks = Enumerable.Range(0, 64)
.Select(_ => new SemaphoreSlim(1, 1)).ToArray();
static SemaphoreSlim StateLock(string trackId, string playbackMode)
{
string key = NormalizeTrackId(trackId) + ":" + MusicPlaybackModeService.Normalize(playbackMode);
return stateLocks[(int)((uint)key.GetHashCode() % (uint)stateLocks.Length)];
}
static MusicAudioMatch CopyMatch(MusicAudioMatch match)
=> match == null ? null : MusicJson.Deserialize<MusicAudioMatch>(MusicJson.Serialize(match));
static string ScopeProviderId(string providerId, string playbackMode)
{
providerId = providerId?.Trim();
if (string.IsNullOrWhiteSpace(providerId))
return providerId;
return $"{providerId}:{MusicPlaybackModeService.Normalize(playbackMode)}";
}
public static async Task<MusicAudioMatch> GetAsync(string trackId, string providerId, string playbackMode = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(providerId))
return null;
var stateLock = StateLock(trackId, playbackMode);
await stateLock.WaitAsync(cancellationToken);
try
{
string scopedProviderId = ScopeProviderId(providerId, playbackMode);
string cacheKey = BuildCacheKey(trackId, scopedProviderId);
if (matchCache.TryGetValue(cacheKey, out MusicAudioMatch cachedMatch))
return CopyMatch(cachedMatch);
var dbMatch = await ReadDbMatchAsync(trackId, scopedProviderId, cancellationToken);
if (dbMatch != null)
{
SetMatchCache(cacheKey, dbMatch);
return CopyMatch(dbMatch);
}
var entry = await HybridCache.Get().ReadCacheAsync<MusicAudioMatch>(cacheKey, false, null, textJson: true);
if (entry.succes && entry.value != null)
{
// hybrid — территория авто-подбора: pinned авторитетен только из БД,
// иначе legacy-запись воскресит сброшенный пользователем выбор
entry.value = CopyMatch(entry.value);
entry.value.pinned = false;
SetMatchCache(cacheKey, entry.value);
}
return entry.succes ? CopyMatch(entry.value) : null;
}
finally
{
stateLock.Release();
}
}
// сброс ручного выбора: убирает pinned-строки трека в данном режиме,
// авто-подбор в volatile-кэше не трогаем — резолвер продолжит с него
public static async Task<bool> DeleteAsync(string trackId, string playbackMode = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(trackId))
return false;
var stateLock = StateLock(trackId, playbackMode);
await stateLock.WaitAsync(cancellationToken);
try
{
if (!await DeleteDbMatchesAsync(trackId, playbackMode, cancellationToken))
return false;
foreach (var provider in MusicProviderRegistry.AudioProviders)
matchCache.Remove(BuildCacheKey(trackId, ScopeProviderId(provider.Id, playbackMode)));
return true;
}
finally
{
stateLock.Release();
}
}
static async Task<bool> DeleteDbMatchesAsync(string trackId, string playbackMode, CancellationToken cancellationToken)
{
trackId = NormalizeTrackId(trackId);
if (string.IsNullOrWhiteSpace(trackId))
return false;
string mode = MusicPlaybackModeService.Normalize(playbackMode);
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
try
{
if (!await semaphore.WaitAsync())
{
Console.WriteLine("[Music] source match reset skipped: db semaphore timeout");
return false;
}
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
DELETE FROM audio_source_matches
WHERE track_id = $track_id
AND provider_scope LIKE $scope_pattern;
""";
command.Parameters.AddWithValue("$track_id", trackId);
command.Parameters.AddWithValue("$scope_pattern", "%:" + NormalizeProviderScope(mode));
await command.ExecuteNonQueryAsync(cancellationToken);
return true;
}
catch (Exception ex)
{
Console.WriteLine($"[Music] source match reset failed: {ex.Message}");
return false;
}
finally
{
semaphore.Release();
}
}
public static async Task<bool> SaveAsync(string trackId, MusicAudioMatch match, string playbackMode = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(trackId) || match == null || string.IsNullOrWhiteSpace(match.provider_id) || string.IsNullOrWhiteSpace(match.id))
return false;
var stateLock = StateLock(trackId, playbackMode);
await stateLock.WaitAsync(cancellationToken);
try
{
match = CopyMatch(match);
string scopedProviderId = ScopeProviderId(match.provider_id, playbackMode);
string cacheKey = BuildCacheKey(trackId, scopedProviderId);
if (match.pinned)
{
// ручной выбор — durable state; memory обновляем только после
// успешной записи, чтобы «Сохранено» клиенту не врало
if (!await SaveDbMatchAsync(trackId, scopedProviderId, match, cancellationToken))
return false;
}
else
{
// авто-матч — volatile cache: SQLite не трогаем (правило модуля),
// in-process свежесть обеспечивает matchCache ниже
HybridCache.Get().Set(cacheKey, match, MatchTtl, textJson: true);
// A resolver may have started before the user pinned a source.
// Keep the automatic result volatile, but never replace that choice.
var pinned = await ReadDbMatchAsync(trackId, scopedProviderId, cancellationToken);
if (pinned != null)
match = pinned;
}
SetMatchCache(cacheKey, match);
missingCache.Remove(BuildMissingCacheKey(trackId, scopedProviderId));
HybridCache.Get().Set(BuildMissingCacheKey(trackId, scopedProviderId), false, MissingTtl, textJson: true);
return true;
}
finally
{
stateLock.Release();
}
}
public static async Task<bool> IsMarkedMissingAsync(string trackId, string providerId, string playbackMode = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(providerId))
return false;
_ = cancellationToken;
string scopedProviderId = ScopeProviderId(providerId, playbackMode);
string missingKey = BuildMissingCacheKey(trackId, scopedProviderId);
if (missingCache.TryGetValue(missingKey, out bool cachedMissing))
return cachedMissing;
var entry = await HybridCache.Get().ReadCacheAsync<bool>(BuildMissingCacheKey(trackId, scopedProviderId), false, null, textJson: true);
if (entry.succes && entry.value)
missingCache.Set(missingKey, true, MissingTtl);
return entry.succes && entry.value;
}
public static async Task MarkMissingAsync(string trackId, string providerId, string playbackMode = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(providerId))
return;
var stateLock = StateLock(trackId, playbackMode);
await stateLock.WaitAsync(cancellationToken);
try
{
string scopedProviderId = ScopeProviderId(providerId, playbackMode);
string missingKey = BuildMissingCacheKey(trackId, scopedProviderId);
// A lookup may have started before the user pinned a source and
// finished afterwards. Never let that stale miss hide durable state.
if (await ReadDbMatchAsync(trackId, scopedProviderId, cancellationToken) != null)
{
missingCache.Remove(missingKey);
HybridCache.Get().Set(missingKey, false, MissingTtl, textJson: true);
return;
}
missingCache.Set(missingKey, true, MissingTtl);
HybridCache.Get().Set(missingKey, true, MissingTtl, textJson: true);
}
finally
{
stateLock.Release();
}
}
static string BuildCacheKey(string trackId, string scopedProviderId)
{
return string.Join(':',
CacheKeyPrefix,
NormalizeTrackId(trackId),
NormalizeProviderScope(scopedProviderId));
}
static string BuildMissingCacheKey(string trackId, string scopedProviderId)
{
return string.Join(':',
MissingCacheKeyPrefix,
NormalizeTrackId(trackId),
NormalizeProviderScope(scopedProviderId));
}
static void SetMatchCache(string cacheKey, MusicAudioMatch match)
{
if (string.IsNullOrWhiteSpace(cacheKey) || match == null)
return;
matchCache.Set(cacheKey, match, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = MatchTtl,
Size = 1
});
}
static async Task<MusicAudioMatch> ReadDbMatchAsync(string trackId, string scopedProviderId, CancellationToken cancellationToken)
{
trackId = NormalizeTrackId(trackId);
scopedProviderId = NormalizeProviderScope(scopedProviderId);
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(scopedProviderId))
return null;
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT payload
FROM audio_source_matches
WHERE track_id = $track_id
AND provider_scope = $provider_scope
LIMIT 1;
""";
command.Parameters.AddWithValue("$track_id", trackId);
command.Parameters.AddWithValue("$provider_scope", scopedProviderId);
var payload = await command.ExecuteScalarAsync(cancellationToken) as string;
return string.IsNullOrWhiteSpace(payload) ? null : MusicJson.Deserialize<MusicAudioMatch>(payload);
}
static async Task<bool> SaveDbMatchAsync(string trackId, string scopedProviderId, MusicAudioMatch match, CancellationToken cancellationToken)
{
trackId = NormalizeTrackId(trackId);
scopedProviderId = NormalizeProviderScope(scopedProviderId);
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(scopedProviderId) || match == null)
return false;
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
try
{
if (!await semaphore.WaitAsync())
{
Console.WriteLine("[Music] source match save skipped: db semaphore timeout");
return false;
}
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO audio_source_matches (track_id, provider_scope, payload, updated)
VALUES ($track_id, $provider_scope, $payload, $updated)
ON CONFLICT(track_id, provider_scope) DO UPDATE SET
payload = excluded.payload,
updated = excluded.updated;
""";
command.Parameters.AddWithValue("$track_id", trackId);
command.Parameters.AddWithValue("$provider_scope", scopedProviderId);
command.Parameters.AddWithValue("$payload", MusicJson.Serialize(match));
command.Parameters.AddWithValue("$updated", DateTime.UtcNow);
await command.ExecuteNonQueryAsync(cancellationToken);
return true;
}
catch (Exception ex)
{
Console.WriteLine($"[Music] source match save failed: {ex.Message}");
return false;
}
finally
{
semaphore.Release();
}
}
static string NormalizeTrackId(string trackId) => trackId?.Trim() ?? string.Empty;
static string NormalizeProviderScope(string providerId) => providerId?.Trim().ToLowerInvariant() ?? string.Empty;
}