using Microsoft.Data.Sqlite; namespace Music; /// /// Дневная статистика прослушиваний (track_stats_daily). Счётчики отдельно /// от витрины: payload трека живёт в playback_history и джойнится при чтении. /// Инкремент вызывается ТОЛЬКО из честного play-пути (count_play=true в mark) — /// обновления payload (смена источника и т.п.) статистику не трогают. /// public static class MusicStatsService { // unlock «Твоего топа»: 10 разных треков обязательны (иначе «альбом из // трёх песен»), плюс либо фаворит на повторе, либо просто наслушан объём — // fallback для тех, кто слушает широко и не гоняет один трек по кругу const int UnlockTrackPlays = 10; const int UnlockDistinctTracks = 10; const int UnlockTotalPlays = 100; public static Task IncrementPlayAsync(string profileId, MusicTrack track, CancellationToken cancellationToken = default) => RecordPlayAsync(profileId, track, true, null, cancellationToken); public static async Task ClearAsync(string profileId, CancellationToken cancellationToken = default) { profileId = NormalizeProfileId(profileId); if (string.IsNullOrWhiteSpace(profileId)) return 0; var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20)); var acquired = false; try { if (!await semaphore.WaitAsync()) return 0; acquired = true; await using var connection = new SqliteConnection(MusicContext.ConnectionString); await connection.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = """ DELETE FROM track_stats_daily WHERE profile_id = $profile_id; """; command.Parameters.AddWithValue("$profile_id", profileId); return await command.ExecuteNonQueryAsync(cancellationToken); } finally { if (acquired) semaphore.Release(); } } public static async Task RecordPlayAsync(string profileId, MusicTrack track, bool countPlay, long? playedMs = null, CancellationToken cancellationToken = default) { profileId = NormalizeProfileId(profileId); if (track == null || string.IsNullOrWhiteSpace(track.id) || string.IsNullOrWhiteSpace(profileId)) return; long durationMs = Math.Max(0, track.duration_ms ?? 0); long listenedMs = Math.Max(0, playedMs ?? 0); // Старые клиенты присылали только count_play=true; для совместимости // оставляем прежнюю оценку полной длительностью. Новый клиент досылает // реальные куски played_ms, поэтому накрутка не превращается в часы. if (listenedMs <= 0 && countPlay) listenedMs = durationMs; if (durationMs > 0 && listenedMs > durationMs) listenedMs = durationMs; // sanity для треков без duration: один mark не должен записать сутки. listenedMs = Math.Min(listenedMs, TimeSpan.FromHours(6).Ticks / TimeSpan.TicksPerMillisecond); if (!countPlay && listenedMs <= 0) return; var now = DateTime.UtcNow; var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20)); var acquired = false; try { if (!await semaphore.WaitAsync()) return; acquired = true; await using var connection = new SqliteConnection(MusicContext.ConnectionString); await connection.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = countPlay ? """ INSERT INTO track_stats_daily (profile_id, track_id, day, play_count, total_ms, last_played) VALUES ($profile_id, $track_id, $day, 1, $total_ms, $last_played) ON CONFLICT(profile_id, track_id, day) DO UPDATE SET play_count = play_count + 1, total_ms = total_ms + excluded.total_ms, last_played = excluded.last_played; """ : """ UPDATE track_stats_daily SET total_ms = total_ms + $total_ms, last_played = $last_played WHERE profile_id = $profile_id AND track_id = $track_id AND day = $day AND play_count > 0; """; command.Parameters.AddWithValue("$profile_id", profileId); command.Parameters.AddWithValue("$track_id", track.id); command.Parameters.AddWithValue("$day", now.ToString("yyyy-MM-dd")); command.Parameters.AddWithValue("$total_ms", listenedMs); command.Parameters.AddWithValue("$last_played", now); await command.ExecuteNonQueryAsync(cancellationToken); } finally { if (acquired) semaphore.Release(); } } public static async Task GetTopAsync(string profileId, int limit = 30, CancellationToken cancellationToken = default) { profileId = NormalizeProfileId(profileId); var result = new MusicStatsTopResult(); if (string.IsNullOrWhiteSpace(profileId)) return result; limit = Math.Clamp(limit, 1, 100); var rows = new List<(string trackId, long plays, long totalMs, DateTime lastPlayed)>(); long totalPlays = 0; long totalMs = 0; long distinctTracks = 0; await using (var connection = new SqliteConnection(MusicContext.ConnectionString)) { await connection.OpenAsync(cancellationToken); await using (var totals = connection.CreateCommand()) { totals.CommandText = """ SELECT COALESCE(SUM(play_count), 0), COALESCE(SUM(total_ms), 0), COUNT(DISTINCT track_id) FROM track_stats_daily WHERE profile_id = $profile_id; """; totals.Parameters.AddWithValue("$profile_id", profileId); await using var totalsReader = await totals.ExecuteReaderAsync(cancellationToken); if (await totalsReader.ReadAsync(cancellationToken)) { totalPlays = totalsReader.GetInt64(0); totalMs = totalsReader.GetInt64(1); distinctTracks = totalsReader.GetInt64(2); } } await using (var top = connection.CreateCommand()) { top.CommandText = """ SELECT track_id, SUM(play_count) AS plays, SUM(total_ms) AS ms, MAX(last_played) AS last FROM track_stats_daily WHERE profile_id = $profile_id GROUP BY track_id ORDER BY plays DESC, last DESC LIMIT $limit; """; top.Parameters.AddWithValue("$profile_id", profileId); top.Parameters.AddWithValue("$limit", limit); await using var reader = await top.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { var trackId = reader.IsDBNull(0) ? null : reader.GetString(0); if (string.IsNullOrWhiteSpace(trackId)) continue; rows.Add(( trackId, reader.GetInt64(1), reader.GetInt64(2), reader.IsDBNull(3) ? DateTime.MinValue : reader.GetDateTime(3) )); } } } result.total_plays = totalPlays; result.total_ms = totalMs; long maxTrackPlays = rows.Count > 0 ? rows[0].plays : 0; result.unlocked = distinctTracks >= UnlockDistinctTracks && (maxTrackPlays >= UnlockTrackPlays || totalPlays >= UnlockTotalPlays); if (!result.unlocked || rows.Count == 0) return result; // витрина: payload треков из playback_history (mark всегда пишет туда же) var payloads = await MusicPlaybackHistoryService.GetTracksAsync(profileId, rows.Select(i => i.trackId), cancellationToken); foreach (var row in rows) { if (!payloads.TryGetValue(row.trackId, out var track) || track == null) continue; result.tracks.Add(new MusicStatsTopTrack { track = track, play_count = row.plays, total_ms = row.totalMs, last_played = row.lastPlayed == DateTime.MinValue ? null : row.lastPlayed }); } result.available = result.tracks.Count > 0; return result; } static string NormalizeProfileId(string profileId) => string.IsNullOrWhiteSpace(profileId) ? null : profileId.Trim().ToLowerInvariant(); } public class MusicStatsTopResult { public bool available { get; set; } public bool unlocked { get; set; } public long total_plays { get; set; } public long total_ms { get; set; } public List tracks { get; set; } = new(); } public class MusicStatsTopTrack { public MusicTrack track { get; set; } public long play_count { get; set; } public long total_ms { get; set; } public DateTime? last_played { get; set; } }