using Microsoft.Data.Sqlite; namespace Music; // пользовательские плейлисты — durable state (намерение пользователя), // скоуп per-profile как у playback_history; payload = JSON-массив MusicTrack, // плейлист самодостаточен и не требует метадата-лукапов при открытии public static class MusicUserPlaylistService { public static async Task> ListAsync(string profileId, CancellationToken cancellationToken = default) { profileId = NormalizeProfile(profileId); var result = new List(); try { await using var connection = new SqliteConnection(MusicContext.ConnectionString); await connection.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = """ SELECT playlist_id, title, payload, source FROM user_playlists WHERE profile_id = $profile_id ORDER BY updated DESC; """; command.Parameters.AddWithValue("$profile_id", profileId); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { var tracks = ParseTracks(reader.IsDBNull(2) ? null : reader.GetString(2)); result.Add(BuildSummary( reader.GetString(0), reader.GetString(1), tracks, ParseSource(reader.IsDBNull(3) ? null : reader.GetString(3)) )); } } catch (Exception ex) { Console.WriteLine($"[Music] playlists list failed: {ex.Message}"); } return result; } public static async Task GetSummaryAsync(string profileId, string playlistId, CancellationToken cancellationToken = default) { playlistId = playlistId?.Trim(); if (string.IsNullOrWhiteSpace(playlistId)) return null; profileId = NormalizeProfile(profileId); try { await using var connection = new SqliteConnection(MusicContext.ConnectionString); await connection.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = """ SELECT playlist_id, title, payload, source FROM user_playlists WHERE profile_id = $profile_id AND playlist_id = $playlist_id LIMIT 1; """; command.Parameters.AddWithValue("$profile_id", profileId); command.Parameters.AddWithValue("$playlist_id", playlistId); await using var reader = await command.ExecuteReaderAsync(cancellationToken); if (!await reader.ReadAsync(cancellationToken)) return null; var tracks = ParseTracks(reader.IsDBNull(2) ? null : reader.GetString(2)); return BuildSummary( reader.GetString(0), reader.GetString(1), tracks, ParseSource(reader.IsDBNull(3) ? null : reader.GetString(3)) ); } catch (Exception ex) { Console.WriteLine($"[Music] playlist summary failed: {ex.Message}"); return null; } } public static async Task> GetTracksAsync(string profileId, string playlistId, CancellationToken cancellationToken = default) { string payload = await ReadPayloadAsync(NormalizeProfile(profileId), playlistId?.Trim(), cancellationToken); return ParseTracks(payload); } // треки ВСЕХ плейлистов профиля (свежие плейлисты первыми) — источник // артистов для «Миксов недели»: у пользователя без истории, но с // импортированным плейлистом, миксы собираются из его артистов public static async Task> GetAllTracksAsync(string profileId, CancellationToken cancellationToken = default) { profileId = NormalizeProfile(profileId); var result = new List(); try { await using var connection = new SqliteConnection(MusicContext.ConnectionString); await connection.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = """ SELECT payload FROM user_playlists WHERE profile_id = $profile_id ORDER BY updated DESC; """; command.Parameters.AddWithValue("$profile_id", profileId); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) result.AddRange(ParseTracks(reader.IsDBNull(0) ? null : reader.GetString(0))); } catch (Exception ex) { Console.WriteLine($"[Music] playlists all tracks failed: {ex.Message}"); } return result; } public static async Task CreateAsync(string profileId, string title, CancellationToken cancellationToken = default) { title = title?.Trim(); if (string.IsNullOrWhiteSpace(title)) return null; profileId = NormalizeProfile(profileId); string playlistId = Guid.NewGuid().ToString("N"); bool saved = await WriteAsync(async connection => { await using var command = connection.CreateCommand(); command.CommandText = """ INSERT INTO user_playlists (profile_id, playlist_id, title, payload, updated) VALUES ($profile_id, $playlist_id, $title, '[]', $updated); """; command.Parameters.AddWithValue("$profile_id", profileId); command.Parameters.AddWithValue("$playlist_id", playlistId); command.Parameters.AddWithValue("$title", title); command.Parameters.AddWithValue("$updated", DateTime.UtcNow); return await command.ExecuteNonQueryAsync(cancellationToken) > 0; }, cancellationToken); return saved ? playlistId : null; } // импорт по ссылке: сервис определяется хостом (Spotify / Apple Music / SoundCloud), // SoundCloud остаётся веткой по умолчанию (короткие on.soundcloud.com ссылки) static Task ImportByUrlAsync(string url, CancellationToken cancellationToken) { if (SpotifySupport.CanHandleUrl(url)) return SpotifySupport.ImportPlaylistAsync(url, cancellationToken); if (AppleMusicSupport.CanHandleUrl(url)) return AppleMusicSupport.ImportPlaylistAsync(url, cancellationToken); return SoundCloudSupport.ImportPlaylistAsync(url, cancellationToken); } static Task ImportBySourceAsync(MusicUserPlaylistSource source, CancellationToken cancellationToken) { if (IsSpotifySource(source)) return SpotifySupport.ImportPlaylistAsync(source, cancellationToken); if (IsAppleMusicSource(source)) return AppleMusicSupport.ImportPlaylistAsync(source, cancellationToken); return SoundCloudSupport.ImportPlaylistAsync(source, cancellationToken); } public static async Task ImportAsync(string profileId, string url, CancellationToken cancellationToken = default) { var imported = await ImportByUrlAsync(url, cancellationToken); if (imported?.available != true || imported.tracks == null || imported.tracks.Count == 0 || imported.source == null) return imported ?? new MusicUserPlaylistImportResult { available = false, message = "Импорт не удался." }; profileId = NormalizeProfile(profileId); imported.title = string.IsNullOrWhiteSpace(imported.title) ? "Импорт" : imported.title.Trim(); imported.track_count = imported.tracks.Count; string savedPlaylistId = null; bool saved = await WriteAsync(async connection => { savedPlaylistId = await FindSourcePlaylistIdAsync(connection, profileId, imported.source, cancellationToken) ?? Guid.NewGuid().ToString("N"); return await UpsertPlaylistAsync(connection, profileId, savedPlaylistId, imported.title, imported.tracks, imported.source, cancellationToken) > 0; }, cancellationToken); if (!saved) { imported.available = false; imported.message = "Не удалось сохранить импортированный плейлист."; return imported; } imported.playlist_id = savedPlaylistId; return imported; } public static async Task SyncAsync(string profileId, string playlistId, CancellationToken cancellationToken = default) { playlistId = playlistId?.Trim(); profileId = NormalizeProfile(profileId); if (string.IsNullOrWhiteSpace(playlistId)) return new MusicUserPlaylistImportResult { available = false, message = "Плейлист не выбран." }; var source = await GetSourceAsync(profileId, playlistId, cancellationToken); if (source == null || !IsSyncableSource(source)) return new MusicUserPlaylistImportResult { available = false, playlist_id = playlistId, message = "У плейлиста нет источника для обновления." }; var imported = await ImportBySourceAsync(source, cancellationToken); if (imported?.available != true || imported.tracks == null || imported.tracks.Count == 0) return imported ?? new MusicUserPlaylistImportResult { available = false, playlist_id = playlistId, message = "Обновление не удалось." }; imported.playlist_id = playlistId; imported.title = string.IsNullOrWhiteSpace(imported.title) ? source.title ?? "Импорт" : imported.title.Trim(); imported.track_count = imported.tracks.Count; imported.source ??= source; bool saved = await WriteAsync(async connection => { // sync — слияние, а не зеркало: локальный порядок и ручные правки // сохраняются, новые треки источника добавляются В НАЧАЛО string currentPayload = await ReadPayloadAsync(connection, profileId, playlistId, cancellationToken); if (currentPayload == null) return false; // Deleted while the source was being fetched. var merged = MergeSyncedTracks(ParseTracks(currentPayload), imported.tracks); imported.tracks = merged; imported.track_count = merged.Count; return await UpsertPlaylistAsync(connection, profileId, playlistId, imported.title, merged, imported.source, cancellationToken, updateOnly: true) > 0; }, cancellationToken); if (!saved) { imported.available = false; imported.message = "Не удалось обновить плейлист."; } return imported; } static List MergeSyncedTracks(List local, List source) { source ??= new List(); if (local == null || local.Count == 0) return source; var localIds = new HashSet( local.Where(i => !string.IsNullOrWhiteSpace(i?.id)).Select(i => i.id), StringComparer.Ordinal); // новинки источника — наверх, в порядке источника var fresh = source .Where(i => !string.IsNullOrWhiteSpace(i?.id) && !localIds.Contains(i.id)) .ToList(); // существующие треки остаются в локальном порядке, но метаданные // обновляются свежими из источника (обложки, длительность и т.п.); // пропавшие из источника и добавленные вручную — не удаляются var sourceById = new Dictionary(StringComparer.Ordinal); foreach (var track in source) { if (!string.IsNullOrWhiteSpace(track?.id)) sourceById.TryAdd(track.id, track); } var merged = new List(fresh.Count + local.Count); merged.AddRange(fresh); foreach (var track in local) { if (track == null) continue; merged.Add(!string.IsNullOrWhiteSpace(track.id) && sourceById.TryGetValue(track.id, out var updated) ? updated : track); } return merged; } public static Task DeleteAsync(string profileId, string playlistId, CancellationToken cancellationToken = default) { playlistId = playlistId?.Trim(); if (string.IsNullOrWhiteSpace(playlistId)) return Task.FromResult(false); profileId = NormalizeProfile(profileId); return WriteAsync(async connection => { await using var command = connection.CreateCommand(); command.CommandText = """ DELETE FROM user_playlists WHERE profile_id = $profile_id AND playlist_id = $playlist_id; """; command.Parameters.AddWithValue("$profile_id", profileId); command.Parameters.AddWithValue("$playlist_id", playlistId); return await command.ExecuteNonQueryAsync(cancellationToken) > 0; }, cancellationToken); } public static async Task AddTrackAsync(string profileId, string playlistId, MusicTrack track, CancellationToken cancellationToken = default) { playlistId = playlistId?.Trim(); if (string.IsNullOrWhiteSpace(playlistId) || track == null || string.IsNullOrWhiteSpace(track.id) || string.IsNullOrWhiteSpace(track.title)) return false; profileId = NormalizeProfile(profileId); return await WriteAsync(async connection => { string payload = await ReadPayloadAsync(connection, profileId, playlistId, cancellationToken); if (payload == null) return false; var tracks = ParseTracks(payload); if (tracks.Any(i => i != null && string.Equals(i.id, track.id, StringComparison.Ordinal))) return true; tracks.Add(track); return await UpdatePayloadAsync(connection, profileId, playlistId, tracks, cancellationToken) > 0; }, cancellationToken); } public static async Task RemoveTrackAsync(string profileId, string playlistId, string trackId, CancellationToken cancellationToken = default) { playlistId = playlistId?.Trim(); trackId = trackId?.Trim(); if (string.IsNullOrWhiteSpace(playlistId) || string.IsNullOrWhiteSpace(trackId)) return false; profileId = NormalizeProfile(profileId); return await WriteAsync(async connection => { string payload = await ReadPayloadAsync(connection, profileId, playlistId, cancellationToken); if (payload == null) return false; var tracks = ParseTracks(payload); var next = tracks.Where(i => i != null && !string.Equals(i.id, trackId, StringComparison.Ordinal)).ToList(); if (next.Count == tracks.Count) return false; return await UpdatePayloadAsync(connection, profileId, playlistId, next, cancellationToken) > 0; }, cancellationToken); } public static async Task MoveTrackAsync(string profileId, string playlistId, string trackId, int position, CancellationToken cancellationToken = default) { playlistId = playlistId?.Trim(); trackId = trackId?.Trim(); if (string.IsNullOrWhiteSpace(playlistId) || string.IsNullOrWhiteSpace(trackId)) return false; profileId = NormalizeProfile(profileId); return await WriteAsync(async connection => { string payload = await ReadPayloadAsync(connection, profileId, playlistId, cancellationToken); if (payload == null) return false; var tracks = ParseTracks(payload); int index = tracks.FindIndex(i => i != null && string.Equals(i.id, trackId, StringComparison.Ordinal)); if (index < 0) return false; int target = Math.Clamp(position, 0, tracks.Count - 1); if (target == index) return true; var track = tracks[index]; tracks.RemoveAt(index); tracks.Insert(target, track); return await UpdatePayloadAsync(connection, profileId, playlistId, tracks, cancellationToken) > 0; }, cancellationToken); } static async Task UpdatePayloadAsync(SqliteConnection connection, string profileId, string playlistId, List tracks, CancellationToken cancellationToken) { await using var command = connection.CreateCommand(); command.CommandText = """ UPDATE user_playlists SET payload = $payload, updated = $updated WHERE profile_id = $profile_id AND playlist_id = $playlist_id; """; command.Parameters.AddWithValue("$profile_id", profileId); command.Parameters.AddWithValue("$playlist_id", playlistId); command.Parameters.AddWithValue("$payload", MusicJson.Serialize(tracks)); command.Parameters.AddWithValue("$updated", DateTime.UtcNow); return await command.ExecuteNonQueryAsync(cancellationToken); } static async Task UpsertPlaylistAsync(SqliteConnection connection, string profileId, string playlistId, string title, List tracks, MusicUserPlaylistSource source, CancellationToken cancellationToken, bool updateOnly = false) { await using var command = connection.CreateCommand(); command.CommandText = updateOnly ? """ UPDATE user_playlists SET title = $title, payload = $payload, source = $source, updated = $updated WHERE profile_id = $profile_id AND playlist_id = $playlist_id; """ : """ INSERT INTO user_playlists (profile_id, playlist_id, title, payload, source, updated) VALUES ($profile_id, $playlist_id, $title, $payload, $source, $updated) ON CONFLICT(profile_id, playlist_id) DO UPDATE SET title = excluded.title, payload = excluded.payload, source = excluded.source, updated = excluded.updated; """; command.Parameters.AddWithValue("$profile_id", profileId); command.Parameters.AddWithValue("$playlist_id", playlistId); command.Parameters.AddWithValue("$title", title); command.Parameters.AddWithValue("$payload", MusicJson.Serialize(tracks ?? new List())); command.Parameters.AddWithValue("$source", SerializeSource(source)); command.Parameters.AddWithValue("$updated", DateTime.UtcNow); return await command.ExecuteNonQueryAsync(cancellationToken); } static async Task ReadPayloadAsync(string profileId, string playlistId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(playlistId)) return null; try { await using var connection = new SqliteConnection(MusicContext.ConnectionString); await connection.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = """ SELECT payload FROM user_playlists WHERE profile_id = $profile_id AND playlist_id = $playlist_id LIMIT 1; """; command.Parameters.AddWithValue("$profile_id", profileId); command.Parameters.AddWithValue("$playlist_id", playlistId); return await command.ExecuteScalarAsync(cancellationToken) as string; } catch (Exception ex) { Console.WriteLine($"[Music] playlist read failed: {ex.Message}"); return null; } } static async Task ReadPayloadAsync(SqliteConnection connection, string profileId, string playlistId, CancellationToken cancellationToken) { await using var command = connection.CreateCommand(); command.CommandText = """ SELECT payload FROM user_playlists WHERE profile_id = $profile_id AND playlist_id = $playlist_id LIMIT 1; """; command.Parameters.AddWithValue("$profile_id", profileId); command.Parameters.AddWithValue("$playlist_id", playlistId); return await command.ExecuteScalarAsync(cancellationToken) as string; } static async Task GetSourceAsync(string profileId, string playlistId, CancellationToken cancellationToken) { try { await using var connection = new SqliteConnection(MusicContext.ConnectionString); await connection.OpenAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = """ SELECT source FROM user_playlists WHERE profile_id = $profile_id AND playlist_id = $playlist_id LIMIT 1; """; command.Parameters.AddWithValue("$profile_id", profileId); command.Parameters.AddWithValue("$playlist_id", playlistId); return ParseSource(await command.ExecuteScalarAsync(cancellationToken) as string); } catch (Exception ex) { Console.WriteLine($"[Music] playlist source read failed: {ex.Message}"); return null; } } static async Task FindSourcePlaylistIdAsync(SqliteConnection connection, string profileId, MusicUserPlaylistSource source, CancellationToken cancellationToken) { await using var command = connection.CreateCommand(); command.CommandText = """ SELECT playlist_id, source FROM user_playlists WHERE profile_id = $profile_id AND source <> ''; """; command.Parameters.AddWithValue("$profile_id", profileId); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { var existing = ParseSource(reader.IsDBNull(1) ? null : reader.GetString(1)); if (IsSameSource(existing, source)) return reader.GetString(0); } return null; } static async Task WriteAsync(Func> operation, CancellationToken cancellationToken) { var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20)); bool acquired = false; try { if (!await semaphore.WaitAsync()) { Console.WriteLine("[Music] playlist write skipped: db semaphore timeout"); return false; } acquired = true; await using var connection = new SqliteConnection(MusicContext.ConnectionString); await connection.OpenAsync(cancellationToken); return await operation(connection); } catch (Exception ex) { Console.WriteLine($"[Music] playlist write failed: {ex.Message}"); return false; } finally { if (acquired) semaphore.Release(); } } static List ParseTracks(string payload) { if (string.IsNullOrWhiteSpace(payload)) return new List(); try { return MusicJson.Deserialize>(payload) ?? new List(); } catch { return new List(); } } static MusicUserPlaylistSummary BuildSummary(string playlistId, string title, List tracks, MusicUserPlaylistSource source) { tracks ??= new List(); return new MusicUserPlaylistSummary { id = playlistId, title = title, track_count = tracks.Count, images = tracks.FirstOrDefault(i => i?.images != null && i.images.Count > 0)?.images ?? new List(), source = source }; } static MusicUserPlaylistSource ParseSource(string payload) { if (string.IsNullOrWhiteSpace(payload)) return null; try { return MusicJson.Deserialize(payload); } catch { return null; } } static string SerializeSource(MusicUserPlaylistSource source) => source == null || string.IsNullOrWhiteSpace(source.type) ? string.Empty : MusicJson.Serialize(source); static bool IsSoundCloudSource(MusicUserPlaylistSource source) => source != null && !string.IsNullOrWhiteSpace(source.type) && source.type.StartsWith("soundcloud_", StringComparison.OrdinalIgnoreCase); static bool IsSpotifySource(MusicUserPlaylistSource source) => source != null && !string.IsNullOrWhiteSpace(source.type) && source.type.StartsWith("spotify_", StringComparison.OrdinalIgnoreCase); static bool IsAppleMusicSource(MusicUserPlaylistSource source) => source != null && !string.IsNullOrWhiteSpace(source.type) && source.type.StartsWith("applemusic_", StringComparison.OrdinalIgnoreCase); static bool IsSyncableSource(MusicUserPlaylistSource source) => IsSoundCloudSource(source) || IsSpotifySource(source) || IsAppleMusicSource(source); static bool IsSameSource(MusicUserPlaylistSource left, MusicUserPlaylistSource right) { if (left == null || right == null) return false; if (!string.Equals(left.type, right.type, StringComparison.OrdinalIgnoreCase)) return false; if (!string.IsNullOrWhiteSpace(left.user_id) || !string.IsNullOrWhiteSpace(right.user_id)) return string.Equals(left.user_id, right.user_id, StringComparison.OrdinalIgnoreCase); if (!string.IsNullOrWhiteSpace(left.playlist_id) || !string.IsNullOrWhiteSpace(right.playlist_id)) return string.Equals(left.playlist_id, right.playlist_id, StringComparison.OrdinalIgnoreCase); return string.Equals(left.url, right.url, StringComparison.OrdinalIgnoreCase); } static string NormalizeProfile(string profileId) => string.IsNullOrWhiteSpace(profileId) ? "global" : profileId.Trim().ToLowerInvariant(); }