diff --git a/.gitignore b/.gitignore index 1fdd3a0..164c49c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,13 @@ # Секреты config/passwd -# Локальные данные и кеш -cache/ -database/ +# Локальные данные и кеш (только корневые каталоги; якорь «/» обязателен — +# иначе «cache/» игнорирует и mods/Music/Services/Cache/ и т.п.) +/cache/ +/database/ # Медиа DLNA (том ./dlna -> /lampac/data/dlna) -dlna/ +/dlna/ # Системное .DS_Store diff --git a/mods/Music/Services/Cache/MusicMetadataCacheService.cs b/mods/Music/Services/Cache/MusicMetadataCacheService.cs new file mode 100644 index 0000000..32a1bc5 --- /dev/null +++ b/mods/Music/Services/Cache/MusicMetadataCacheService.cs @@ -0,0 +1,150 @@ +using Shared.Services.Hybrid; +using System.Collections.Concurrent; + +namespace Music; + +public static class MusicMetadataCacheService +{ + const string CacheKeyPrefix = "music:metadata:v2"; + + // пустой результат — чаще разовый сбой провайдера, чем правда: + // полный TTL превращал его в залипшее «ничего не найдено» + static readonly TimeSpan emptyTtl = TimeSpan.FromMinutes(20); + + sealed class PendingFill where T : class + { + public CancellationToken OwnerCancellation { get; init; } + public Lazy> Work { get; init; } + } + + static class PendingFills where T : class + { + public static readonly ConcurrentDictionary> Items = new(); + } + + public static async Task GetOrCreateAsync( + string providerId, + string entityType, + string cacheKey, + TimeSpan ttl, + Func> factory, + CancellationToken cancellationToken = default, + Func ttlSelector = null) where T : class + { + cancellationToken.ThrowIfCancellationRequested(); + var cached = await GetAsync(providerId, entityType, cacheKey, cancellationToken); + if (cached != null) + return cached; + + if (string.IsNullOrWhiteSpace(providerId) || string.IsNullOrWhiteSpace(entityType) || string.IsNullOrWhiteSpace(cacheKey)) + return await CreateAndSaveAsync(providerId, entityType, cacheKey, ttl, factory, cancellationToken, ttlSelector); + + string key = BuildCacheKey(providerId, entityType, cacheKey); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + PendingFill candidate = null; + candidate = new PendingFill + { + OwnerCancellation = cancellationToken, + Work = new Lazy>(async () => + { + try + { + // A previous fill may have finished between the miss and registration. + var existing = await GetAsync(providerId, entityType, cacheKey, cancellationToken); + if (existing != null) + return existing; + + return await CreateAndSaveAsync(providerId, entityType, cacheKey, ttl, factory, cancellationToken, ttlSelector); + } + finally + { + PendingFills.Items.TryRemove(new KeyValuePair>(key, candidate)); + } + }, LazyThreadSafetyMode.ExecutionAndPublication) + }; + + var pending = PendingFills.Items.GetOrAdd(key, candidate); + try + { + return await pending.Work.Value.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && pending.OwnerCancellation.IsCancellationRequested) + { + // Factories capture their caller's token. Retry with our own factory + // after an abandoned owner's fill, without cancelling other waiters. + } + } + } + + static async Task CreateAndSaveAsync( + string providerId, string entityType, string cacheKey, TimeSpan ttl, + Func> factory, CancellationToken cancellationToken, + Func ttlSelector) where T : class + { + cancellationToken.ThrowIfCancellationRequested(); + var created = await factory(); + cancellationToken.ThrowIfCancellationRequested(); + if (created != null) + { + TimeSpan selectedTtl = IsEmptyPayload(created) + ? emptyTtl + : ttlSelector?.Invoke(created) ?? ttl; + + await SaveAsync(providerId, entityType, cacheKey, created, selectedTtl, cancellationToken); + } + + return created; + } + + static bool IsEmptyPayload(object payload) + { + if (payload is System.Collections.ICollection collection) + return collection.Count == 0; + + if (payload is MusicSearchResult search) + { + return (search.artists == null || search.artists.Count == 0) + && (search.albums == null || search.albums.Count == 0) + && (search.tracks == null || search.tracks.Count == 0); + } + + return false; + } + + public static async Task GetAsync(string providerId, string entityType, string cacheKey, CancellationToken cancellationToken = default) where T : class + { + if (string.IsNullOrWhiteSpace(providerId) || string.IsNullOrWhiteSpace(entityType) || string.IsNullOrWhiteSpace(cacheKey)) + return null; + + var cache = HybridCache.Get(); + // fileCache:false — иначе чтение слепо к свежим записям в tempDb-буфере + // HybridCache (до флаша ~15-60s), и повторный запрос делает полный рефетч + var entry = await cache.ReadCacheAsync(BuildCacheKey(providerId, entityType, cacheKey), false, null, textJson: true); + return entry.succes ? entry.value : null; + } + + public static Task SaveAsync(string providerId, string entityType, string cacheKey, T payload, TimeSpan ttl, CancellationToken cancellationToken = default) where T : class + { + if (string.IsNullOrWhiteSpace(providerId) || string.IsNullOrWhiteSpace(entityType) || string.IsNullOrWhiteSpace(cacheKey) || payload == null) + return Task.CompletedTask; + + _ = cancellationToken; + + HybridCache.Get().Set(BuildCacheKey(providerId, entityType, cacheKey), payload, ttl, textJson: true); + return Task.CompletedTask; + } + + static string BuildCacheKey(string providerId, string entityType, string cacheKey) + { + return string.Join(':', + CacheKeyPrefix, + NormalizeSegment(providerId), + NormalizeSegment(entityType), + NormalizeKey(cacheKey)); + } + + static string NormalizeKey(string cacheKey) => cacheKey.Trim().ToLowerInvariant(); + static string NormalizeSegment(string value) => value?.Trim().ToLowerInvariant() ?? string.Empty; +} diff --git a/mods/Music/Services/Cache/MusicSourceMatchService.cs b/mods/Music/Services/Cache/MusicSourceMatchService.cs new file mode 100644 index 0000000..119eecb --- /dev/null +++ b/mods/Music/Services/Cache/MusicSourceMatchService.cs @@ -0,0 +1,344 @@ +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(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 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(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 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 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 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 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(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 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(payload); + } + + static async Task 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; +}