Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Music;
|
||||
|
||||
// Общие хелперы импортёров плейлистов (SoundCloud / Spotify / Apple Music).
|
||||
//
|
||||
// Контракт импорта: результат либо ПОЛНЫЙ, либо available=false с message —
|
||||
// частично скачанный список сохранять нельзя (sync перезаписывает payload
|
||||
// целиком, усечёнка молча потеряла бы треки пользователя). Единственное
|
||||
// допустимое усечение — явный кап MaxImportTracks с truncated=true.
|
||||
public static class MusicPlaylistImportHelpers
|
||||
{
|
||||
public static MusicUserPlaylistImportResult ImportUnavailable(string message)
|
||||
=> new()
|
||||
{
|
||||
available = false,
|
||||
message = message,
|
||||
tracks = new List<MusicTrack>()
|
||||
};
|
||||
|
||||
// дедуп по id с перенумерацией track_number по итоговому порядку
|
||||
public static List<MusicTrack> DeduplicateTracks(IEnumerable<MusicTrack> tracks)
|
||||
{
|
||||
var result = new List<MusicTrack>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var track in tracks ?? Enumerable.Empty<MusicTrack>())
|
||||
{
|
||||
if (track == null || string.IsNullOrWhiteSpace(track.id) || !seen.Add(track.id))
|
||||
continue;
|
||||
|
||||
track.track_number = result.Count + 1;
|
||||
result.Add(track);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static JsonElement? GetProperty(JsonElement element, params string[] path)
|
||||
{
|
||||
var current = element;
|
||||
foreach (var name in path)
|
||||
{
|
||||
if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(name, out var next) || next.ValueKind == JsonValueKind.Null)
|
||||
return null;
|
||||
|
||||
current = next;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
public static JsonElement? GetProperty(JsonElement? element, params string[] path)
|
||||
=> element == null ? null : GetProperty(element.Value, path);
|
||||
|
||||
public static string GetString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(propertyName, out var property))
|
||||
return null;
|
||||
|
||||
return property.ValueKind == JsonValueKind.String ? property.GetString() : null;
|
||||
}
|
||||
|
||||
public static int? GetInt(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(propertyName, out var property))
|
||||
return null;
|
||||
|
||||
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out int value))
|
||||
return value;
|
||||
|
||||
if (property.ValueKind == JsonValueKind.String && int.TryParse(property.GetString(), out int parsed))
|
||||
return parsed;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user