Исправление: mods/Music/Services/Cache не попадал в git из-за .gitignore (cache/ без якоря)

- .gitignore: якоря /cache/, /database/, /dlna/ только для корневых каталогов
- git add -f: папка Cache модуля Music (MusicMetadataCacheService и др.) —
  без неё Roslyn-компиляция модуля падает (CS0103) на любой машине, где проект
  получен из git (сервер уходил в crash-loop)
This commit is contained in:
2026-10-02 17:49:28 +03:00
parent ebec4d6620
commit 2dd171612d
3 changed files with 499 additions and 4 deletions
@@ -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<T> where T : class
{
public CancellationToken OwnerCancellation { get; init; }
public Lazy<Task<T>> Work { get; init; }
}
static class PendingFills<T> where T : class
{
public static readonly ConcurrentDictionary<string, PendingFill<T>> Items = new();
}
public static async Task<T> GetOrCreateAsync<T>(
string providerId,
string entityType,
string cacheKey,
TimeSpan ttl,
Func<Task<T>> factory,
CancellationToken cancellationToken = default,
Func<T, TimeSpan> ttlSelector = null) where T : class
{
cancellationToken.ThrowIfCancellationRequested();
var cached = await GetAsync<T>(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<T> candidate = null;
candidate = new PendingFill<T>
{
OwnerCancellation = cancellationToken,
Work = new Lazy<Task<T>>(async () =>
{
try
{
// A previous fill may have finished between the miss and registration.
var existing = await GetAsync<T>(providerId, entityType, cacheKey, cancellationToken);
if (existing != null)
return existing;
return await CreateAndSaveAsync(providerId, entityType, cacheKey, ttl, factory, cancellationToken, ttlSelector);
}
finally
{
PendingFills<T>.Items.TryRemove(new KeyValuePair<string, PendingFill<T>>(key, candidate));
}
}, LazyThreadSafetyMode.ExecutionAndPublication)
};
var pending = PendingFills<T>.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<T> CreateAndSaveAsync<T>(
string providerId, string entityType, string cacheKey, TimeSpan ttl,
Func<Task<T>> factory, CancellationToken cancellationToken,
Func<T, TimeSpan> 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<T> GetAsync<T>(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<T>(BuildCacheKey(providerId, entityType, cacheKey), false, null, textJson: true);
return entry.succes ? entry.value : null;
}
public static Task SaveAsync<T>(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;
}
@@ -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<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;
}