Исправление: 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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user