Files
lampac/mods/Music/Services/Playlists/MusicUserPlaylistService.cs
T

638 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Data.Sqlite;
namespace Music;
// пользовательские плейлисты — durable state (намерение пользователя),
// скоуп per-profile как у playback_history; payload = JSON-массив MusicTrack,
// плейлист самодостаточен и не требует метадата-лукапов при открытии
public static class MusicUserPlaylistService
{
public static async Task<List<MusicUserPlaylistSummary>> ListAsync(string profileId, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfile(profileId);
var result = new List<MusicUserPlaylistSummary>();
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<MusicUserPlaylistSummary> 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<List<MusicTrack>> GetTracksAsync(string profileId, string playlistId, CancellationToken cancellationToken = default)
{
string payload = await ReadPayloadAsync(NormalizeProfile(profileId), playlistId?.Trim(), cancellationToken);
return ParseTracks(payload);
}
// треки ВСЕХ плейлистов профиля (свежие плейлисты первыми) — источник
// артистов для «Миксов недели»: у пользователя без истории, но с
// импортированным плейлистом, миксы собираются из его артистов
public static async Task<List<MusicTrack>> GetAllTracksAsync(string profileId, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfile(profileId);
var result = new List<MusicTrack>();
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<string> 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<MusicUserPlaylistImportResult> 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<MusicUserPlaylistImportResult> 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<MusicUserPlaylistImportResult> 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<MusicUserPlaylistImportResult> 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<MusicTrack> MergeSyncedTracks(List<MusicTrack> local, List<MusicTrack> source)
{
source ??= new List<MusicTrack>();
if (local == null || local.Count == 0)
return source;
var localIds = new HashSet<string>(
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<string, MusicTrack>(StringComparer.Ordinal);
foreach (var track in source)
{
if (!string.IsNullOrWhiteSpace(track?.id))
sourceById.TryAdd(track.id, track);
}
var merged = new List<MusicTrack>(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<bool> 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<bool> 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<bool> 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<bool> 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<int> UpdatePayloadAsync(SqliteConnection connection, string profileId, string playlistId, List<MusicTrack> 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<int> UpsertPlaylistAsync(SqliteConnection connection, string profileId, string playlistId, string title, List<MusicTrack> 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<MusicTrack>()));
command.Parameters.AddWithValue("$source", SerializeSource(source));
command.Parameters.AddWithValue("$updated", DateTime.UtcNow);
return await command.ExecuteNonQueryAsync(cancellationToken);
}
static async Task<string> 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<string> 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<MusicUserPlaylistSource> 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<string> 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<bool> WriteAsync(Func<SqliteConnection, Task<bool>> 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<MusicTrack> ParseTracks(string payload)
{
if (string.IsNullOrWhiteSpace(payload))
return new List<MusicTrack>();
try
{
return MusicJson.Deserialize<List<MusicTrack>>(payload) ?? new List<MusicTrack>();
}
catch
{
return new List<MusicTrack>();
}
}
static MusicUserPlaylistSummary BuildSummary(string playlistId, string title, List<MusicTrack> tracks, MusicUserPlaylistSource source)
{
tracks ??= new List<MusicTrack>();
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<MusicImage>(),
source = source
};
}
static MusicUserPlaylistSource ParseSource(string payload)
{
if (string.IsNullOrWhiteSpace(payload))
return null;
try
{
return MusicJson.Deserialize<MusicUserPlaylistSource>(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();
}