using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.Text; namespace Music; public class ApiController : BaseController { [HttpGet] [AllowAnonymous] [Route("music.js")] [Route("music/js/{token}")] public ActionResult MusicJS(string token) { string plugin = FileCache.ReadAllText($"{ModInit.modpath}/plugin.js", "music.js", false) .Replace("{localhost}", host) .Replace("{client_debug_enabled}", ModInit.conf?.client_debug_enabled == true ? "true" : "false") .Replace("{stats_clear_enabled}", ModInit.conf?.stats_clear_enabled == true ? "true" : "false") .Replace("{daily_reset_enabled}", ModInit.conf?.daily_reset_enabled == true ? "true" : "false") .Replace("{spotify_search_fallback_enabled}", ModInit.conf?.spotify_search_fallback_enabled == true ? "true" : "false"); // ETag считаем от ИТОГОВОГО текста: ответ зависит не только от plugin.js, // но и от host и флагов конфига выше — хэш исходного файла дал бы ложный // 304 после смены конфигурации и оставил бы клиенту старые значения. // no-cache (не no-store): браузер хранит копию, но перед использованием // перепроверяет её условным запросом — 304 без тела при неизменном файле. string etag = $"\"{Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(plugin)))[..32].ToLowerInvariant()}\""; Response.Headers["Cache-Control"] = "private, no-cache, must-revalidate"; Response.Headers["ETag"] = etag; string ifNoneMatch = Request.Headers["If-None-Match"].FirstOrDefault(); if (!string.IsNullOrEmpty(ifNoneMatch)) { foreach (var rawTag in ifNoneMatch.Split(',')) { string tag = rawTag.Trim(); if (tag == "*" || tag == etag || tag == $"W/{etag}") return new StatusCodeResult(304); } } return Content(plugin, "application/javascript; charset=utf-8"); } [HttpPost] [AllowAnonymous] [Route("music/clientlog")] public ActionResult ClientLog(string event_name, string state, string track_id, string message) { static string cut(string value, int max) { if (string.IsNullOrWhiteSpace(value)) return ""; value = value.Replace('\r', ' ').Replace('\n', ' ').Trim(); return value.Length <= max ? value : value.Substring(0, max); } if (ModInit.conf?.client_debug_enabled != true) return ContentTo(MusicJson.Serialize(new { ok = true, logged = false })); Console.WriteLine($"[MusicClient] event={cut(event_name, 64)} track={cut(track_id, 96)} state={cut(state, 6000)} message={cut(message, 400)}"); return ContentTo(MusicJson.Serialize(new { ok = true, logged = true })); } [HttpGet] [Route("music")] [Route("music/home")] async public Task Home(int? daily_salt) { string profileId = MusicProfileIdentity.Resolve(requestInfo, Request); var result = await MusicCatalogService.GetHomeAsync(profileId, daily_salt ?? 0); result = MusicImageProxyService.Apply(this, result); return ContentTo(MusicJson.Serialize(result)); } [HttpGet] [Route("music/playlists")] async public Task Playlists() { string profileId = MusicProfileIdentity.Resolve(requestInfo, Request); var result = await MusicUserPlaylistService.ListAsync(profileId, HttpContext.RequestAborted); result = MusicImageProxyService.Apply(this, result); return ContentTo(MusicJson.Serialize(new { available = true, playlists = result })); } [HttpGet] [Route("music/playlists/tracks")] async public Task PlaylistTracks(string id) { string profileId = MusicProfileIdentity.Resolve(requestInfo, Request); var tracks = await MusicUserPlaylistService.GetTracksAsync(profileId, id, HttpContext.RequestAborted); tracks = MusicImageProxyService.Apply(this, tracks); return ContentTo(MusicJson.Serialize(new { available = true, playlist_id = id, tracks })); } [HttpPost] [Route("music/playlists/create")] async public Task PlaylistCreate(string title) { string profileId = MusicProfileIdentity.Resolve(requestInfo, Request); string playlistId = await MusicUserPlaylistService.CreateAsync(profileId, title, HttpContext.RequestAborted); return ContentTo(MusicJson.Serialize(new { created = playlistId != null, playlist_id = playlistId, title })); } [HttpPost] [Route("music/playlists/delete")] async public Task PlaylistDelete(string id) { string profileId = MusicProfileIdentity.Resolve(requestInfo, Request); bool removed = await MusicUserPlaylistService.DeleteAsync(profileId, id, HttpContext.RequestAborted); return ContentTo(MusicJson.Serialize(new { removed, playlist_id = id })); } [HttpPost] [Route("music/playlists/import")] [Route("music/playlists/import/soundcloud")] // legacy-роут для старого клиентского JS async public Task PlaylistImport(string url) { string profileId = MusicProfileIdentity.Resolve(requestInfo, Request); var result = await MusicUserPlaylistService.ImportAsync(profileId, url, HttpContext.RequestAborted); result = MusicImageProxyService.Apply(this, result); return ContentTo(MusicJson.Serialize(result)); } [HttpPost] [Route("music/playlists/sync")] async public Task PlaylistSync(string id) { string profileId = MusicProfileIdentity.Resolve(requestInfo, Request); var result = await MusicUserPlaylistService.SyncAsync(profileId, id, HttpContext.RequestAborted); result = MusicImageProxyService.Apply(this, result); return ContentTo(MusicJson.Serialize(result)); } [HttpPost] [Route("music/playlists/track/add")] async public Task PlaylistTrackAdd(string id, string track) { string profileId = MusicProfileIdentity.Resolve(requestInfo, Request); MusicTrack parsed = null; try { parsed = string.IsNullOrWhiteSpace(track) ? null : MusicJson.Deserialize(track); } catch { } bool saved = await MusicUserPlaylistService.AddTrackAsync(profileId, id, parsed, HttpContext.RequestAborted); var playlist = saved ? await MusicUserPlaylistService.GetSummaryAsync(profileId, id, HttpContext.RequestAborted) : null; if (playlist != null) playlist = MusicImageProxyService.Apply(this, playlist); return ContentTo(MusicJson.Serialize(new { saved, playlist_id = id, playlist })); } [HttpPost] [Route("music/playlists/track/remove")] async public Task PlaylistTrackRemove(string id, string track_id) { string profileId = MusicProfileIdentity.Resolve(requestInfo, Request); bool removed = await MusicUserPlaylistService.RemoveTrackAsync(profileId, id, track_id, HttpContext.RequestAborted); var playlist = removed ? await MusicUserPlaylistService.GetSummaryAsync(profileId, id, HttpContext.RequestAborted) : null; if (playlist != null) playlist = MusicImageProxyService.Apply(this, playlist); return ContentTo(MusicJson.Serialize(new { removed, playlist_id = id, track_id, playlist })); } [HttpPost] [Route("music/playlists/track/move")] async public Task PlaylistTrackMove(string id, string track_id, int position) { string profileId = MusicProfileIdentity.Resolve(requestInfo, Request); bool moved = await MusicUserPlaylistService.MoveTrackAsync(profileId, id, track_id, position, HttpContext.RequestAborted); var playlist = moved ? await MusicUserPlaylistService.GetSummaryAsync(profileId, id, HttpContext.RequestAborted) : null; if (playlist != null) playlist = MusicImageProxyService.Apply(this, playlist); return ContentTo(MusicJson.Serialize(new { moved, playlist_id = id, track_id, position, playlist })); } [HttpGet] [Route("music/daily")] async public Task DailyMix(int day, int? salt) { string profileId = MusicProfileIdentity.Resolve(requestInfo, Request); var result = await MusicDailyMixService.GetMixAsync(profileId, day, salt ?? 0, HttpContext.RequestAborted); result = MusicImageProxyService.Apply(this, result); return ContentTo(MusicJson.Serialize(result)); } [HttpGet] [Route("music/stats/top")] async public Task StatsTop(int? limit) { string profileId = MusicProfileIdentity.Resolve(requestInfo, Request); var result = await MusicStatsService.GetTopAsync(profileId, limit ?? 30, HttpContext.RequestAborted); result = MusicImageProxyService.Apply(this, result); return ContentTo(MusicJson.Serialize(result)); } [HttpPost] [Route("music/stats/clear")] async public Task StatsClear() { string profileId = MusicProfileIdentity.Resolve(requestInfo, Request); int removed = await MusicStatsService.ClearAsync(profileId, HttpContext.RequestAborted); return ContentTo(MusicJson.Serialize(new { cleared = true, removed })); } [HttpPost] [Route("music/radio")] async public Task Radio(string seeds, string exclude, int? limit) { // защитные капы: сырой JSON режем ДО десериализации (клиент шлёт // exclude всей очереди ≤500 компакт-треков ≈ 150KB — 400KB с запасом), // распарсенные списки — до разумных размеров const int maxRawJsonLength = 400_000; const int maxSeeds = 10; const int maxExclude = 1000; static List parseTracks(string value, int maxItems) { if (string.IsNullOrWhiteSpace(value) || value.Length > maxRawJsonLength) return new List(); try { var parsed = MusicJson.Deserialize>(value) ?? new List(); return parsed.Count > maxItems ? parsed.Take(maxItems).ToList() : parsed; } catch { return new List(); } } string profileId = MusicProfileIdentity.Resolve(requestInfo, Request); var result = await MusicRadioService.GetAsync(profileId, new MusicRadioRequest { seeds = parseTracks(seeds, maxSeeds), exclude = parseTracks(exclude, maxExclude) }, limit ?? 20, HttpContext.RequestAborted); result = MusicImageProxyService.Apply(this, result); return ContentTo(MusicJson.Serialize(result)); } [HttpGet] [Route("music/lyrics")] async public Task Lyrics(string title, string artist_name, string album_title, int? duration_ms, string youtube_id) { var result = await MusicLyricsService.GetAsync(title, artist_name, album_title, duration_ms, youtube_id, HttpContext.RequestAborted); return ContentTo(MusicJson.Serialize(result)); } [HttpGet] [Route("music/providers")] public ActionResult Providers() { var result = new { metadata = MusicProviderRegistry.DescribeMetadata(), audio = MusicProviderRegistry.DescribeAudio(), auth = MusicProviderRegistry.DescribeAuth() }; return ContentTo(MusicJson.Serialize(result)); } [HttpGet] [Route("music/section")] async public Task Section(string id) { var result = await MusicCatalogService.GetBrowseSectionAsync(id); if (result == null) return ContentTo(MusicJson.Serialize(new { available = false, message = "Section not found." })); result = MusicImageProxyService.Apply(this, result); return ContentTo(MusicJson.Serialize(result)); } }