diff --git a/.editorconfig b/.editorconfig index 213a189..7e86231 100644 --- a/.editorconfig +++ b/.editorconfig @@ -55,11 +55,12 @@ resharper_align_multline_type_parameter_list = true resharper_align_tuple_components = true resharper_allow_comment_after_lbrace = true resharper_csharp_alignment_tab_fill_style = optimal_fill -resharper_csharp_align_first_arg_by_paren = true +resharper_csharp_align_first_arg_by_paren = false resharper_csharp_outdent_commas = true resharper_csharp_outdent_dots = true resharper_csharp_wrap_arguments_style = chop_if_long resharper_csharp_wrap_before_binary_opsign = true +resharper_csharp_wrap_before_invocation_rpar = true resharper_enforce_line_ending_style = true resharper_fsharp_keep_if_then_in_same_line = true resharper_fsharp_space_around_delimiter = false @@ -109,5 +110,5 @@ resharper_web_config_wrong_module_highlighting = warning tab_width = 4 [*.{js,ts,jsx,tsx,json,vue}] -indent_style = spaces -indent_size = 2 +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8af4450..b77faac 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,7 +18,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v1 with: - dotnet-version: 6.0.x + dotnet-version: 7.0.x - name: Publish run: | diff --git a/UwuRadio.Server/Constants.cs b/UwuRadio.Server/Constants.cs index 1b7928b..f3fa02d 100644 --- a/UwuRadio.Server/Constants.cs +++ b/UwuRadio.Server/Constants.cs @@ -5,6 +5,9 @@ namespace UwuRadio.Server; [SuppressMessage("ReSharper", "UnassignedField.Global")] [SuppressMessage("ReSharper", "FieldCanBeMadeReadOnly.Global")] +[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] +[SuppressMessage("ReSharper", "AutoPropertyCanBeMadeGetOnly.Global")] +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] // ReSharper disable once ClassNeverInstantiated.Global public class Constants { @@ -23,7 +26,7 @@ public static Constants public int BufferTime { get; set; } /// - /// Time remaining when the next song should be broadcasted for preloading + /// Time remaining when the next song should be broadcast for preloading /// public int PreloadTime { get; set; } @@ -46,4 +49,8 @@ public static Constants /// Where the raw data is stored /// public string IngestFolder { get; set; } = null!; -} + + // these two are not in the JSON but are here for convenience + public string IngestSubmittersFolder => Path.Combine(IngestFolder, "submitters"); + public string IngestChannelsFolder => Path.Combine(IngestFolder, "channels"); +} \ No newline at end of file diff --git a/UwuRadio.Server/Controllers/ApiController.cs b/UwuRadio.Server/Controllers/ApiController.cs index d2e90d2..6afc6ad 100644 --- a/UwuRadio.Server/Controllers/ApiController.cs +++ b/UwuRadio.Server/Controllers/ApiController.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Net; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Primitives; @@ -21,33 +22,42 @@ public ApiController(DataService dataService, DownloadService downloadService) public IActionResult Ping() => Ok("Pong!"); // /api/time - public IActionResult Time() => Json(Helpers.Now().ToUnixTimeSeconds()); + public IActionResult Time() => Ok(Helpers.Now().ToUnixTimeSeconds()); // /api/data public IActionResult Data() => Json(new - { - //Songs = _dataService.AllSongs, - Submitters = _dataService.Submitters.Values.ToArray() - }); + { + Submitters = _dataService.Submitters.Values.ToArray(), + Channels = _dataService.Channels + .Select(c => new KeyValuePair( + c.Key, + c.Value with { Songs = Array.Empty() } + ) + ) + .ToImmutableSortedDictionary() + } + ); // /api/file/id public IActionResult File(string id) { if (!_downloadService.IsDownloaded(id)) - return StatusCode((int) HttpStatusCode.ServiceUnavailable, "The server does not have this file cached"); + return StatusCode((int) HttpStatusCode.ServiceUnavailable, + "The server does not have this file cached" + ); var fileInfo = _downloadService.GetFileInfo(id); // check ETag to facilitate caching - if (Request.Headers.IfNoneMatch.FirstOrDefault() == ('"' + fileInfo.Md5 + '"')) + if (Request.Headers.IfNoneMatch.FirstOrDefault() == '"' + fileInfo.Md5 + '"') return StatusCode(StatusCodes.Status304NotModified); // if range processing is enabled it makes the client get very angy - return File( - fileInfo.File.OpenRead(), - "audio/mpeg", - null, - new EntityTagHeaderValue(new StringSegment('"' + fileInfo.Md5 + '"')), - false); + return File(fileInfo.File.OpenRead(), + "audio/mpeg", + null, + new EntityTagHeaderValue(new StringSegment('"' + fileInfo.Md5 + '"')), + false + ); } } diff --git a/UwuRadio.Server/Domain.cs b/UwuRadio.Server/Domain.cs new file mode 100644 index 0000000..9cebda0 --- /dev/null +++ b/UwuRadio.Server/Domain.cs @@ -0,0 +1,38 @@ +namespace UwuRadio.Server; + +public record Channel(string Name, Song[] Songs, string? Category = null); + +public record Song(string Name, string Artist, string StreamUrl, string? ArtUrl, string? Album, + string Submitter, string[]? Channels = null, bool? IncludeInGlobal = null) +{ + // lazy & cached + private string? _id; + public string Id => _id ??= Helpers.ComputeSongId(this); +} + +public record TransitSong(string Name, string Artist, string? DlUrl, string? SourceUrl, + string? ArtUrl, string? Album, string Submitter) +{ + public TransitSong(Song song) : this(song.Name, + song.Artist, + Constants.C.ServerDlUrl + song.Id, + song.StreamUrl, + song.ArtUrl, + song.Album, + song.Submitter + ) + { + } +} + +public record Submitter(string Name, string PfpUrl, string[] Quotes); + +public record IngestChannel(string Name, string? Category = null) +{ + public Channel ToChannel(Song[] songs) => new(Name, songs, Category); +} + +public record IngestSubmitter(string Name, string PfpUrl, string[] Quotes, Song[] Songs) +{ + public Submitter ToSubmitter() => new(Name, PfpUrl, Quotes); +} diff --git a/UwuRadio.Server/Helpers.cs b/UwuRadio.Server/Helpers.cs index 0110766..f9aa1d1 100644 --- a/UwuRadio.Server/Helpers.cs +++ b/UwuRadio.Server/Helpers.cs @@ -9,10 +9,12 @@ public static class Helpers { public static Instant Now() => SystemClock.Instance.GetCurrentInstant(); +/* /// /// Only the date component of an Instant /// public static Instant StripTime(Instant inst) => inst.InUtc().Date.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(); +*/ // ReSharper disable once InconsistentNaming public static string MD5(byte[] data) @@ -38,46 +40,18 @@ public static Duration ParseDuration(string raw) return parsed; } - public static void Log(string? subSeg, params object[] payload) - { - var segments = subSeg == null - ? new[] { ("uwu radio", ConsoleColor.Green) } - : new[] - { - ("uwu radio", ConsoleColor.Green), - (subSeg, ConsoleColor.Blue) - }; - - foreach (var (seg, col) in segments) - { - Console.Write("⸨"); - - var origCol = Console.ForegroundColor; - Console.ForegroundColor = col; - Console.Write(seg); - Console.ForegroundColor = origCol; - - Console.Write("⸩ "); - } - - foreach (var p in payload) - Console.Write(p.ToString()); - - Console.WriteLine(); - } - - private static char[] base60Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwx".ToCharArray(); + private static readonly char[] Base60Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwx".ToCharArray(); private static string ToBase60(this ulong num) { var i = 32; - char[] buffer = new char[i]; + var buffer = new char[i]; do { - buffer[--i] = base60Chars[num % 60]; - num = num / 60; + buffer[--i] = Base60Chars[num % 60]; + num /= 60; } while (num > 0); - char[] result = new char[32 - i]; + var result = new char[32 - i]; Array.Copy(buffer, i, result, 0, 32 - i); return new string(result); } @@ -87,4 +61,11 @@ public static string ComputeSongId(Song song) { var hash = XXHash.Hash64(buffer); return hash.ToBase60(); } + + public static TV? GetOrDefault(this IDictionary dict, TK k) + => dict.TryGetValue(k, out var val) ? val : default; + + public static PrettyLogger PrettyNamed(this ILogger logger, T self) + where T : IPrettyNamed + => new(logger, self); } \ No newline at end of file diff --git a/UwuRadio.Server/Logging.cs b/UwuRadio.Server/Logging.cs new file mode 100644 index 0000000..3b64983 --- /dev/null +++ b/UwuRadio.Server/Logging.cs @@ -0,0 +1,66 @@ +using Serilog.Context; +using Serilog.Core; +using Serilog.Events; + +namespace UwuRadio.Server; + +/// +/// Enriches Serilog logs with the source class name, like built-in SourceContext but better. +/// +public class SourceContextEnricher : ILogEventEnricher +{ + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + if (logEvent.Properties.TryGetValue("PrettyName", out var prettyName)) + { + var pnScalar = prettyName as ScalarValue; + var ptStr = pnScalar?.Value as string; + + if (!string.IsNullOrWhiteSpace(ptStr)) + { + logEvent.AddOrUpdateProperty( + new LogEventProperty("SourceContext", new ScalarValue(ptStr)) + ); + return; + } + } + + if (!logEvent.Properties.TryGetValue("SourceContext", out var fullCtxtRaw)) return; + + + // convert into Serilog's ScalarValue wrapper first + // https://stackoverflow.com/a/75332511/8388655 + var scalar = fullCtxtRaw as ScalarValue; + var ctxtStr = scalar?.Value as string; + + // when comparing nullables you must either ==true, ==false, ??true, or ??false + if (ctxtStr?.StartsWith("UwuRadio.Server") != true) return; + + var className = ctxtStr.Split(".").LastOrDefault(); + + if (!string.IsNullOrWhiteSpace(className)) + logEvent.AddOrUpdateProperty(new LogEventProperty("SourceContext", new ScalarValue( + className))); + } +} + +public interface IPrettyNamed +{ + public string PrettyName { get; } +} + +public record PrettyLogger(ILogger Underlying, T Owner) : ILogger + where T : IPrettyNamed +{ + public IDisposable? BeginScope(TState state) where TState : notnull + => Underlying.BeginScope(state); + + public bool IsEnabled(LogLevel logLevel) => Underlying.IsEnabled(logLevel); + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + using (LogContext.PushProperty("PrettyName", Owner.PrettyName)) + Underlying.Log(logLevel, eventId, state, exception, formatter); + } +} \ No newline at end of file diff --git a/UwuRadio.Server/Program.cs b/UwuRadio.Server/Program.cs index 6775c8d..1536a7f 100644 --- a/UwuRadio.Server/Program.cs +++ b/UwuRadio.Server/Program.cs @@ -1,7 +1,14 @@ +using Serilog; +using Serilog.Events; using UwuRadio.Server; using UwuRadio.Server.Services; -Helpers.Log(null, "Hello, world!"); +Log.Logger = new LoggerConfiguration().MinimumLevel.Override("Microsoft", LogEventLevel.Information) + .MinimumLevel.Debug() + .WriteTo.Console() + .CreateBootstrapLogger(); + +Log.Information("Hello, world!"); var builder = WebApplication.CreateBuilder(args); @@ -12,26 +19,43 @@ // our own custom services builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddTransient(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddTransient(); +builder.Services.AddSingleton(); + +builder.Host.UseSerilog( + (ctxt, services, cfg) => cfg.ReadFrom.Configuration(ctxt.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext() + .Enrich.With() +#if DEBUG + .MinimumLevel.Debug() +#else + .MinimumLevel.Information() +#endif + .WriteTo.Console( + outputTemplate: + "[{Timestamp:HH:mm:ss} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}" + ) +); var app = builder.Build(); // https://stackoverflow.com/a/66240442/8388655 -app.UseCors(cors => cors.AllowAnyHeader().AllowAnyMethod().SetIsOriginAllowed(_ => true).AllowCredentials()); - -app.UseRouting(); +app.UseCors( + cors => cors.AllowAnyHeader().AllowAnyMethod().SetIsOriginAllowed(_ => true).AllowCredentials() +); -app.UseEndpoints(endpoints => -{ - endpoints.MapHub("/sync"); - endpoints.MapDefaultControllerRoute(); -}); -// start the services way before they're technically needed so that it starts downloading instantly -app.Services.GetService(); +app.MapHub("/sync"); +app.MapDefaultControllerRoute(); -Helpers.Log(null, "Kickstarted services successfully, starting web server now"); +// start the services before a web req so that it starts downloading songs instantly +// we need to pass this service our service provider so it can instantiate services that are +// managed and disposed correctly +app.Services.GetService()!.StartCoordinators(app.Services); app.Run(); + +Log.CloseAndFlush(); \ No newline at end of file diff --git a/UwuRadio.Server/Services/CoordServOwnerService.cs b/UwuRadio.Server/Services/CoordServOwnerService.cs new file mode 100644 index 0000000..292aae9 --- /dev/null +++ b/UwuRadio.Server/Services/CoordServOwnerService.cs @@ -0,0 +1,50 @@ +namespace UwuRadio.Server.Services; + +/// +/// Controls all of the Coordinator Services +/// +public class CoordServOwnerService +{ + private readonly ILogger _logger; + private readonly DataService _dataService; + private readonly Dictionary _channels = new(); + private CoordinatorService? _globalChannel; + + public CoordServOwnerService(DataService dataService, ILogger logger) + { + _dataService = dataService; + _logger = logger; + } + + public void StartCoordinators(IServiceProvider providerService) + { + _globalChannel = providerService.GetService(); + + foreach (var chan in _dataService.Channels.Keys) + _channels[chan] = providerService.GetService() + ?? throw new InvalidOperationException(); + + _logger.LogInformation("Successfully instantiated {ChannelCount} services", _channels.Count + 1); + } + + public CoordinatorService GetServiceByChannel(string? channel = null) + { + var srv = channel == null ? _globalChannel : _channels.GetOrDefault(channel); + if (srv == null) + throw new ArgumentOutOfRangeException(nameof(channel), $"no service exists for the channel {channel}!"); + return srv; + } + + public string? GetOwnChannel(CoordinatorService self) + { + if (self == _globalChannel) return null; + + // lmao using a hashmap backwards so inefficient! + foreach (var (chan, srv) in _channels) + if (srv == self) + return chan; + + throw new ArgumentOutOfRangeException(nameof(self), + $"this {nameof(CoordServOwnerService)} does not own this {nameof(CoordinatorService)}!"); + } +} \ No newline at end of file diff --git a/UwuRadio.Server/Services/CoordinatorService.cs b/UwuRadio.Server/Services/CoordinatorService.cs index a9b0bbe..b913a21 100644 --- a/UwuRadio.Server/Services/CoordinatorService.cs +++ b/UwuRadio.Server/Services/CoordinatorService.cs @@ -6,82 +6,128 @@ namespace UwuRadio.Server.Services; /// /// Keeps track of state and controls the queue advancing, starting downloads, etc. /// -public class CoordinatorService : IDisposable +public class CoordinatorService : IDisposable, IPrettyNamed { - private readonly DownloadService _dlService; - private readonly IHubContext _hubCtxt; - private readonly PickerService _pickerService; + private readonly PrettyLogger _logger; + private readonly DownloadService _dlService; + private readonly IHubContext _hubContext; + private readonly PickerService _pickerService; private bool _haltThread; - public Song Current; + public string? Channel; + + public string PrettyName => nameof(CoordinatorService) + " - " + (Channel ?? ""); + + public Song Current = null!; public Instant CurrentEnds; public Instant CurrentStarted; - public Song Next; + public Song Next = null!; - public CoordinatorService(IHubContext hubCtxt, DownloadService dlService, PickerService pickerService) + public CoordinatorService(IHubContext hubContext, DownloadService dlService, + PickerService pickerService, CoordServOwnerService owner, + ILogger logger) { - _hubCtxt = hubCtxt; - _dlService = dlService; + _hubContext = hubContext; + _dlService = dlService; _pickerService = pickerService; - - Current = _pickerService.SelectSong(); - Next = _pickerService.SelectSong(); + _logger = logger.PrettyNamed(this); // run this explicitly on another thread - Task.Run(StartBgThread); + Task.Run(() => StartBgThread(owner)); } public void Dispose() { - _haltThread = true; - Helpers.Log(nameof(CoordinatorService), "Disposed"); + _haltThread = true; + _logger.LogDebug("Disposed"); } - private async Task StartBgThread() + private async Task StartBgThread(CoordServOwnerService owner) { - _dlService.EnsureDownloaded(Current); - _dlService.EnsureDownloaded(Next); + if (_haltThread) return; + + // its non-ideal doing this here but + // i need all of the services to be started before this is called + InitSongs(owner); // poll for the very first song to be downloaded and ready - while (!_haltThread && !_dlService.IsDownloaded(Current)) - await Task.Delay(1000); + await WaitForReady(); + + if (_haltThread) return; CurrentStarted = Helpers.Now(); CurrentEnds = CurrentStarted + _dlService.GetFileInfo(Current).Length; - await _hubCtxt.Clients.All.SendAsync("ReceiveState", - new TransitSong(Current), - CurrentStarted.ToUnixTimeSeconds(), - new TransitSong(Next), - CurrentEnds.ToUnixTimeSeconds() + Constants.C.BufferTime); + await _hubContext.Clients.All.SendAsync("ReceiveState", + new TransitSong(Current), + CurrentStarted.ToUnixTimeSeconds(), + new TransitSong(Next), + CurrentEnds.ToUnixTimeSeconds() + + Constants.C.BufferTime, + Channel + ); + + _logger.LogInformation("Ready to serve clients"); + + await MainLoop(); + } + + private void InitSongs(CoordServOwnerService owner) + { + Channel = owner.GetOwnChannel(this); + _pickerService.Channel = Channel; + + Current = _pickerService.SelectSong(); + Next = _pickerService.SelectSong(); + + _dlService.EnsureDownloaded(Current); + _dlService.EnsureDownloaded(Next); + } + + private async Task WaitForReady() + { + while (!_haltThread + && !(_dlService.IsDownloaded(Current) || _dlService.IsBlacklisted(Current))) + { + if (_dlService.IsBlacklisted(Current)) + { + Current = _pickerService.SelectSong(); + _dlService.EnsureDownloaded(Current); + } + await Task.Delay(1000); + } + } + + private async Task MainLoop() + { var preloadHandled = false; - Helpers.Log(nameof(CoordinatorService), "Ready to serve clients"); - while (!_haltThread) { // this lives at the top so we can just use continue; to return to idle state from anywhere. await Task.Delay(1000); - + // if there is a blacklisted song, keep skipping until a song downloads if (_dlService.IsBlacklisted(Next)) { - Helpers.Log(nameof(CoordinatorService), "Encountered blacklisted song, skipping it!"); + _logger.LogWarning("Encountered blacklisted song {SongName}, skipping it!", + Next.Name + ); Next = _pickerService.SelectSong(); _dlService.EnsureDownloaded(Next); // wait for the song to either succeed or fail to download // the loop *probably* shouldn't be blocked for this but its an unlikely code path - // and the state to mutex this isnt worth it + // and the state to mutex this isn't worth it while (!_dlService.IsDownloaded(Next) && !_dlService.IsBlacklisted(Next)) await Task.Delay(100); - + continue; } - + // handle advancing song if (Helpers.Now() >= CurrentEnds) { @@ -90,7 +136,7 @@ await _hubCtxt.Clients.All.SendAsync("ReceiveState", await Task.Delay(100); if (_dlService.IsBlacklisted(Next)) continue; - + preloadHandled = false; var info = _dlService.GetFileInfo(Next); @@ -103,25 +149,34 @@ await _hubCtxt.Clients.All.SendAsync("ReceiveState", // clients are only told about the next when we need to handle preloading // however we will *need* to know the length of the song and be able to serve it at preload time // so its good for us to get a healthy head start - most likely 3-5 minutes! - // also makes it feasible to use really really slow hosts such as niconico + // also makes it feasible to use really really slow hosts such as NicoNico _dlService.EnsureDownloaded(Next); - Helpers.Log(nameof(CoordinatorService), - $"Aadvanced queue, current song: {Current.Name}, next song: {Next.Name}"); + _logger.LogInformation( + "Advanced queue, current song: {CurrentName}, next song: {NextName}", + Current.Name, + Next.Name + ); continue; } // handle preloading - if (!preloadHandled && Helpers.Now() >= CurrentEnds - Duration.FromSeconds(Constants.C.PreloadTime)) + // ReSharper disable once InvertIf + if (!preloadHandled && Helpers.Now() + >= CurrentEnds - Duration.FromSeconds(Constants.C.PreloadTime)) { preloadHandled = true; - await _hubCtxt.Clients.All.SendAsync("BroadcastNext", - new TransitSong(Next), - CurrentEnds.ToUnixTimeSeconds() + Constants.C.BufferTime); - - Helpers.Log(nameof(CoordinatorService), $"Broadcasted next song ({Next.Name}) to clients"); - + await _hubContext.Clients.All.SendAsync("BroadcastNext", + new TransitSong(Next), + CurrentEnds.ToUnixTimeSeconds() + + Constants.C.BufferTime, + Channel + ); + + _logger.LogInformation("Broadcast next song ({Name}) to clients", Next.Name); + + // ReSharper disable once RedundantJumpStatement continue; } } diff --git a/UwuRadio.Server/Services/DataService.cs b/UwuRadio.Server/Services/DataService.cs index 624c430..6008fcf 100644 --- a/UwuRadio.Server/Services/DataService.cs +++ b/UwuRadio.Server/Services/DataService.cs @@ -1,60 +1,161 @@ +using System.Collections.Immutable; using System.Text.Json; namespace UwuRadio.Server.Services; -/// -/// The data ingest for one submitter -/// -internal class Ingest -{ - public string Name { get; set; } = null!; - public string PfpUrl { get; set; } = null!; - public string[] Quotes { get; set; } = null!; - public Song[] Songs { get; set; } = null!; -} - /// /// Ingests and stores data /// public class DataService { - public Song[] Songs = Array.Empty(); - public readonly Dictionary Submitters = new(); + public IImmutableDictionary Channels; + + public IImmutableDictionary Submitters; + public Song[] GlobalSongs = Array.Empty(); + + private static readonly JsonSerializerOptions JsonOpts = new() + { + ReadCommentHandling = JsonCommentHandling.Skip + }; + + // ReSharper disable once SuggestBaseTypeForParameterInConstructor + public DataService(ILogger logger) + { + var rawChannels = IngestChannels(); + var rawSubmitters = IngestSubmitters(); + PostProcessIngests(rawChannels, rawSubmitters); + + logger.LogInformation(@"Ingest successful: + - {Sum} total songs + - {ChannelCount} channels + - {SubmitterCount} submitters", + Channels!.Values.Sum(c => c.Songs.Length), + Channels.Count, + Submitters!.Count + ); + } + + private static bool SongInvalid(Song s) + => string.IsNullOrWhiteSpace(s.Name) || string.IsNullOrWhiteSpace(s.Artist) + || string.IsNullOrWhiteSpace(s.StreamUrl) + || ((s.Channels == null || s.Channels.Length == 0) + && s.IncludeInGlobal == false); + + private static bool SubmitterInvalid(IngestSubmitter submitter) + => string.IsNullOrWhiteSpace(submitter.Name) || string.IsNullOrWhiteSpace(submitter.PfpUrl) + || submitter.Quotes == null! + || submitter.Songs.Any(SongInvalid); - public DataService() + private static bool ChannelInvalid(IngestChannel channel) + => string.IsNullOrWhiteSpace(channel.Name); + + private static IImmutableDictionary IngestSubmitters() { - DiskIngest(); - - Helpers.Log(nameof(DataService), $"Ingested {Songs.Length} songs from {Submitters.Count} submitters"); + return Directory.GetFiles(Constants.C.IngestSubmittersFolder) + .Select(file => + { + var txt = File.ReadAllText(file); + var ingested + = JsonSerializer.Deserialize(txt, JsonOpts); + if (ingested == null || SubmitterInvalid(ingested)) + throw new InvalidDataException( + $"Failure ingesting submitter {file}, ingest did not pass validation." + ); + + return new KeyValuePair( + Path.GetFileNameWithoutExtension(file), + ingested + ); + } + ) + .ToImmutableDictionary(); } - - private static bool IngestInvalid(Ingest ingest) - => string.IsNullOrWhiteSpace(ingest.Name) || string.IsNullOrWhiteSpace(ingest.PfpUrl) - || ingest.Songs.Any(s => string.IsNullOrWhiteSpace(s.Name) - || string.IsNullOrWhiteSpace(s.Artist) - || string.IsNullOrWhiteSpace(s.StreamUrl)) - || ingest.Quotes == null!; - - private void DiskIngest() + + private static IImmutableDictionary IngestChannels() + { + return Directory.GetFiles(Constants.C.IngestChannelsFolder) + .Select(file => + { + var txt = File.ReadAllText(file); + var ingested + = JsonSerializer.Deserialize(txt, JsonOpts); + if (ingested == null || ChannelInvalid(ingested)) + throw new InvalidDataException( + $"Failure ingesting channel {file}, ingest did not pass validation." + ); + + return new KeyValuePair( + Path.GetFileNameWithoutExtension(file), + ingested + ); + } + ) + .ToImmutableDictionary(); + } + + /// + /// Makes sure songs end up where they should be + /// + private void PostProcessIngests(IImmutableDictionary rawChannels, + IImmutableDictionary rawSubmitters) { - var ingests = Directory.GetFiles(Constants.C.IngestFolder) - .Select(file => - { - var txt = File.ReadAllText(file); - var ingested = JsonSerializer.Deserialize(txt, new JsonSerializerOptions - { - ReadCommentHandling = JsonCommentHandling.Skip - }); - if (ingested == null || IngestInvalid(ingested)) - throw new - InvalidDataException($"Failure ingesting {file}, ingest did not pass validation."); - return ingested; - }) - .ToArray(); - - Songs = ingests.SelectMany(ingest => ingest.Songs.Select(s => s with { Submitter = ingest.Name })).ToArray(); - - foreach (var ingest in ingests) - Submitters[ingest.Name] = new Submitter(ingest.Name, ingest.PfpUrl, ingest.Quotes); + var globalSongs = new List(); + var channelSongs = new Dictionary>(); + + foreach (var (key, submitter) in rawSubmitters) + { + channelSongs[key] = new List(); + + foreach (var song in submitter.Songs) + if (song.Channels == null || song.Channels.Length == 0) + { + // not in any explicit channels + if (song.IncludeInGlobal == false) + throw new Exception("song passed validation but should not have, bailing"); + + globalSongs.Add(song); + channelSongs[key].Add(song); + } + else + { + if (song.IncludeInGlobal == true) + { + globalSongs.Add(song); + channelSongs[key].Add(song); + } + + foreach (var chan in song.Channels) + if (channelSongs.TryGetValue(chan, out var cSongs)) + cSongs.Add(song); + else channelSongs[chan] = new List { song }; + } + } + + GlobalSongs = globalSongs.ToArray(); + + Channels = rawChannels + .Select(pair => new KeyValuePair( + pair.Key, + pair.Value.ToChannel(channelSongs.TryGetValue(pair.Key, out var songs) + ? songs.ToArray() + : Array.Empty() + ) + ) + ) + .Concat(rawSubmitters.Select( + pair => new KeyValuePair( + pair.Key, + new Channel(pair.Key, channelSongs[pair.Key].ToArray(), "submitters") + ) + ) + ) + .ToImmutableSortedDictionary(); + + Submitters = rawSubmitters.Select( + pair => new KeyValuePair(pair.Key, + pair.Value.ToSubmitter() + ) + ) + .ToImmutableSortedDictionary(); } } diff --git a/UwuRadio.Server/Services/DownloadService.cs b/UwuRadio.Server/Services/DownloadService.cs index aa92672..899f17e 100644 --- a/UwuRadio.Server/Services/DownloadService.cs +++ b/UwuRadio.Server/Services/DownloadService.cs @@ -12,51 +12,66 @@ public record SongFileInfo(FileInfo File, string Md5, Duration Length); /// public class DownloadService : IDisposable { + private readonly ILogger _logger; private readonly Queue _downloadQueue = new(); private readonly Dictionary _fileInfos = new(); private readonly HashSet _downloadBlacklist = new(); private bool _isCurrentlyDownloading; - public DownloadService() { Directory.CreateDirectory(Constants.C.CacheFolder); } + public DownloadService(ILogger logger) + { + _logger = logger; + Directory.CreateDirectory(Constants.C.CacheFolder); + } public void Dispose() => Directory.Delete(Constants.C.CacheFolder, true); public bool IsBlacklisted(Song song) => _downloadBlacklist.Contains(song.Id); - + public void EnsureDownloaded(Song song) { if (IsDownloaded(song)) return; if (IsBlacklisted(song)) return; - - Helpers.Log(nameof(DownloadService), $"Queued {song.Name} for DL"); + if (IsDownloading(song)) return; + _logger.LogDebug("Queued {SongName} for DL", song.Name); - _downloadQueue.Enqueue(song); - if (!_isCurrentlyDownloading) StartDownloading(); + lock (_downloadQueue) + { + _downloadQueue.Enqueue(song); + if (_isCurrentlyDownloading) return; + _isCurrentlyDownloading = true; + } + + // this is structured weirdly but I want this to be outside of the lock + // and the _isCurrentlyDownloading stuff to be inside the lock + StartDownloading(); } public bool IsDownloaded(string id) => _fileInfos.ContainsKey(id); public bool IsDownloaded(Song song) => IsDownloaded(song.Id); + private bool IsDownloading(Song song) => _downloadQueue.Contains(song); + public SongFileInfo GetFileInfo(string id) => _fileInfos[id]; public SongFileInfo GetFileInfo(Song song) => GetFileInfo(song.Id); private async void StartDownloading() { - _isCurrentlyDownloading = true; while (_downloadQueue.TryDequeue(out var song)) { try { _fileInfos[song.Id] = await DownloadSong(song.StreamUrl); } catch (Exception e) { - Helpers.Log(nameof(DownloadService), - $"Caught exception while downloading {song.Name}! Blacklisting from future download attempts.\n" - + e); + _logger.LogError(e, + "Caught exception while downloading {SongName}! Blacklisting from future download attempts", + song.Name + ); _downloadBlacklist.Add(song.Id); continue; } - - Helpers.Log(nameof(DownloadService), $"Downloaded and cached {song.Name}"); + + _logger.LogDebug("Downloaded and cached {SongName}", song.Name); } _isCurrentlyDownloading = false; @@ -64,6 +79,7 @@ private async void StartDownloading() private static async Task DownloadSong(string url) { + // ReSharper disable once StringLiteralTypo var args = $"\"{url}\" -O after_move:filepath --quiet --print-json -f bestaudio --extract-audio --audio-format mp3 --audio-quality 0"; @@ -73,7 +89,7 @@ var args if (ext != ".mp3") throw new Exception("Incorrect extension after yt-dlp invocation"); - + var hash = Helpers.MD5(await File.ReadAllBytesAsync(rawPath)); var cachePath = Path.Combine(Constants.C.CacheFolder, hash); @@ -101,9 +117,11 @@ var args return (rawPath, durationStr); } + // ReSharper disable once CommentTypo /// /// Please ignore outside of DownloadService: exists to read the output of YTDL /// + // ReSharper disable once IdentifierTypo public class YtdlOutputObject { [JsonPropertyName("duration_string")] diff --git a/UwuRadio.Server/Services/PickerService.cs b/UwuRadio.Server/Services/PickerService.cs index 1245420..174718b 100644 --- a/UwuRadio.Server/Services/PickerService.cs +++ b/UwuRadio.Server/Services/PickerService.cs @@ -32,32 +32,66 @@ namespace UwuRadio.Server.Services; /// /// Selects new songs /// -public class PickerService +public class PickerService /* : IPrettyNamed*/ { - private Song[] _queue; - private Song[] _unpickedSongs; + private readonly DataService _dataService; + + private Song[] _queue = Array.Empty(); + private Song[] _unpickedSongs = Array.Empty(); private int _queuePos; - public PickerService(DataService dataService) + public string? Channel; + + //public string PrettyName => nameof(PickerService) + " - " + (Channel ?? ""); + + public PickerService(DataService dataService) => _dataService = dataService; + + private bool _inited; + + // we need our channel set before we can actually init + private void LateInit() { - var allSongs = SpotifyShuffle(dataService.Songs).ToArray(); + if (_inited) return; + _inited = true; + + var songs = Channel == null + ? _dataService.GlobalSongs + : _dataService.Channels[Channel!].Songs; - var queueSize = 2 * (allSongs.Length / 3); + if (songs.Length < 3) + { + _queue = songs; + return; + } + + var shuffledSongs = SpotifyShuffle(songs).ToArray(); + + var queueSize = 2 * (shuffledSongs.Length / 3); _queue = new Song[queueSize]; - _unpickedSongs = new Song[allSongs.Length - queueSize]; + _unpickedSongs = new Song[shuffledSongs.Length - queueSize]; - MemCopy(allSongs, _queue, 0, 0); - MemCopy(allSongs, _unpickedSongs, queueSize, 0); + MemCopy(shuffledSongs, _queue, 0, 0); + MemCopy(shuffledSongs, _unpickedSongs, queueSize, 0); ShuffleQueue(); } public Song SelectSong() { + LateInit(); + + if (_queue.Length < 3 && _unpickedSongs.Length == 0) + { + var song = _queue[_queuePos++]; + _queuePos %= _queue.Length; + return song; + } + _queuePos++; + // ReSharper disable once InvertIf if (_queuePos >= _queue.Length) { _queuePos -= _queue.Length; @@ -94,27 +128,29 @@ private static void FisherYatesShuffle(IList arr) // https://engineering.atspotify.com/2014/02/how-to-shuffle-songs // https://codegolf.stackexchange.com/questions/198094 private static IEnumerable SpotifyShuffle(IEnumerable arr) => arr - .GroupBy(song => song.Submitter + song.Artist) - .SelectMany( - group => - { - var groupArr = group.ToArray(); - FisherYatesShuffle(groupArr); - + .GroupBy(song => song.Submitter + song.Artist) + .SelectMany(group => + { + var groupArr = group.ToArray(); + FisherYatesShuffle(groupArr); + var groupOset = Random.Shared.NextDouble() * (1.0 / groupArr.Length); - return groupArr.Select((song, idx) => - { - var songOset = Random.Shared.NextDouble() * (0.2 / groupArr.Length) - - (0.1 / groupArr.Length); - - var pos = (double) idx / groupArr.Length + groupOset + songOset; - - return (song, pos); - }); - }) - .OrderBy(t => t.Item2) - .Select(t => t.Item1); + return groupArr.Select((song, idx) => + { + // ReSharper disable once ArrangeRedundantParentheses + var songOset = Random.Shared.NextDouble() * (0.2 / groupArr.Length) + - (0.1 / groupArr.Length); + + var pos = (double) idx / groupArr.Length + groupOset + songOset; + + return (song, pos); + } + ); + } + ) + .OrderBy(t => t.Item2) + .Select(t => t.Item1); private static void MemCopy(T[] src, T[] dest, int srcIdx, int destIdx, int count = -1) { diff --git a/UwuRadio.Server/Song.cs b/UwuRadio.Server/Song.cs deleted file mode 100644 index e65db24..0000000 --- a/UwuRadio.Server/Song.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace UwuRadio.Server; - -public record Song(string Name, string Artist, string StreamUrl, string? ArtUrl, string? Album, string Submitter) -{ - private string? _Id; - public string Id => _Id ??= Helpers.ComputeSongId(this); -} - -public record TransitSong(string Name, string Artist, string? DlUrl, string? SourceUrl, string? ArtUrl, string? Album, string Submitter) -{ - public TransitSong(Song song) : this(song.Name, - song.Artist, - Constants.C.ServerDlUrl + song.Id, - song.StreamUrl, - song.ArtUrl, - song.Album, - song.Submitter) - { - } -} diff --git a/UwuRadio.Server/Submitter.cs b/UwuRadio.Server/Submitter.cs deleted file mode 100644 index 5acd147..0000000 --- a/UwuRadio.Server/Submitter.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace UwuRadio.Server; - -public record Submitter(string Name, string PfpUrl, string[] Quotes); \ No newline at end of file diff --git a/UwuRadio.Server/SyncHub.cs b/UwuRadio.Server/SyncHub.cs index ebebe75..1c5b74f 100644 --- a/UwuRadio.Server/SyncHub.cs +++ b/UwuRadio.Server/SyncHub.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.SignalR; using UwuRadio.Server.Services; @@ -8,20 +9,24 @@ namespace UwuRadio.Server; /// BroadcastNext is sent over this connection /// from , so check there. /// +[SuppressMessage("ReSharper", "UnusedMember.Global")] public class SyncHub : Hub { - private readonly CoordinatorService _coordinatorService; + private readonly CoordServOwnerService _ownerService; - public SyncHub(CoordinatorService cServ) { _coordinatorService = cServ; } + public SyncHub(CoordServOwnerService cServ) => _ownerService = cServ; - public async Task RequestState() => await Clients.Caller.SendAsync("ReceiveState", - new TransitSong(_coordinatorService.Current), - _coordinatorService.CurrentStarted - .ToUnixTimeSeconds(), - new TransitSong(_coordinatorService.Next), - _coordinatorService.CurrentEnds - .ToUnixTimeSeconds() + Constants.C.BufferTime); + public async Task RequestState(string? channel = null) + { + var service = _ownerService.GetServiceByChannel(channel); - public async Task RequestSeekPos() - => await Clients.Caller.SendAsync("ReceiveSeekPos", _coordinatorService.CurrentStarted.ToUnixTimeSeconds()); -} \ No newline at end of file + await Clients.Caller.SendAsync( + "ReceiveState", + new TransitSong(service.Current), + service.CurrentStarted.ToUnixTimeSeconds(), + new TransitSong(service.Next), + service.CurrentEnds.ToUnixTimeSeconds() + Constants.C.BufferTime, + service.Channel + ); + } +} diff --git a/UwuRadio.Server/UwuRadio.Server.csproj b/UwuRadio.Server/UwuRadio.Server.csproj index 1f01c68..1e4d6fb 100644 --- a/UwuRadio.Server/UwuRadio.Server.csproj +++ b/UwuRadio.Server/UwuRadio.Server.csproj @@ -1,10 +1,10 @@ - net6.0 + net7.0 enable enable - true + @@ -12,6 +12,7 @@ + - \ No newline at end of file + diff --git a/clients/web/src/App.vue b/clients/web/src/App.vue index 54bbe1c..4582e35 100644 --- a/clients/web/src/App.vue +++ b/clients/web/src/App.vue @@ -2,20 +2,15 @@ import TheSidebar from "./components/TheSidebar.vue"; import ThePlayer from "./components/ThePlayer.vue"; import { defineAsyncComponent, ref } from "vue"; -// @ts-expect-error this lib is not typed lol import canAutoplay from "can-autoplay"; import { visualizerEnabled } from "./visualizer"; import { setupMediaSession } from "./mediaSession"; import { audioCtx } from "./audio"; const TheChurner = defineAsyncComponent(() => import("./components/TheChurner.vue")); -interface CanAutoplay { - result: boolean; -} - const clicked = ref(false); -canAutoplay.audio().then(({ result }: CanAutoplay) => { +canAutoplay.audio().then(({ result }) => { if (result == true) clicked.value = true; }); diff --git a/clients/web/src/audio.ts b/clients/web/src/audio.ts index d929df3..57ab1bb 100644 --- a/clients/web/src/audio.ts +++ b/clients/web/src/audio.ts @@ -1,4 +1,4 @@ -import { ref, reactive, computed, watchEffect } from "vue"; +import { computed, reactive, ref, watchEffect } from "vue"; import { setupMediaSession } from "./mediaSession"; import type { Song } from "./syncClient"; import { currentTimestamp } from "./util"; @@ -24,8 +24,7 @@ watchEffect(() => { const prettyFormatTime = (time: number) => `${~~(time / 60)}:${(~~(time % 60)).toString().padStart(2, "0")}`; -const seek = ref(); -export { seek }; +export const seek = ref(); export const prettySeek = computed(() => prettyFormatTime(seek.value!)); setInterval( @@ -33,39 +32,29 @@ setInterval( 100, ); -export const seekTo = (seek: number) => { - startSeek = seek; - audioSource?.start(0, seek); -}; - export const getDuration = () => audioSource?.buffer?.duration ?? 0; export const prettyDuration = () => prettyFormatTime(getDuration()); -async function loadAudio(url: string) { - const response = await fetch(url); - const arrayBuffer = await response.arrayBuffer(); - const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer); - return audioBuffer; -} +const loadAudio = (url: string) => + fetch(url) + .then((r) => r.arrayBuffer()) + .then((buf) => audioCtx.decodeAudioData(buf)); -export async function preload(url: string) { - return (songs[url] ??= loadAudio(url)); -} +export const loadCached = (url: string) => (songs[url] ??= loadAudio(url)); export async function play(song: Song, seek: number) { const then = currentTimestamp(); setupMediaSession(); - audioSource?.stop(); - const url = song.dlUrl!; + const buffer = await loadCached(song.dlUrl!); + + audioSource?.stop(); - audioSource = new AudioBufferSourceNode(audioCtx, { - buffer: await (songs[url] ?? preload(url)), - }); + audioSource = new AudioBufferSourceNode(audioCtx, { buffer }); audioSource.connect(audioAnalyser).connect(audioGain).connect(audioCtx.destination); - seek = seek + currentTimestamp() - then; + seek += currentTimestamp() - then; startTime = audioCtx.currentTime; startSeek = seek; audioSource.start(0, seek); diff --git a/clients/web/src/channel.ts b/clients/web/src/channel.ts index 6c379db..f9b84a3 100644 --- a/clients/web/src/channel.ts +++ b/clients/web/src/channel.ts @@ -1,6 +1,14 @@ -import { ref, watch } from "vue"; +import { effect, ref } from "vue"; +import { startSyncClient, currentChannel as selectedChannel } from "./syncClient"; export const currentChannel = ref(window.location.hash.substring(1)); -watch(currentChannel, () => { + +effect(async () => { window.location.hash = "#" + currentChannel.value; + + const channel = currentChannel.value === "" ? null : currentChannel.value; + selectedChannel.value = channel; + + const hub = await startSyncClient(); + await hub.invoke("RequestState", channel); }); diff --git a/clients/web/src/components/TheChurner.vue b/clients/web/src/components/TheChurner.vue index 2bbe589..c3c4abd 100644 --- a/clients/web/src/components/TheChurner.vue +++ b/clients/web/src/components/TheChurner.vue @@ -4,12 +4,9 @@ import { computed, effect, onMounted, onUnmounted, ref, watch } from "vue"; import butterchurn from "butterchurn"; import butterchurnPresets from "butterchurn-presets"; import { audioCtx, audioAnalyser } from "@/audio"; -import { timePromise } from "@/util"; -import { getClient } from "@/syncClient"; import { visualizerEnabled } from "@/visualizer"; import TheChurnerSelect from "./TheChurnerSelect.vue"; - -const client = await timePromise.then(() => getClient()); +import { currentSong } from "@/syncClient"; const presets = butterchurnPresets.getPresets(); const presetNames = Object.keys(presets); @@ -54,11 +51,11 @@ onMounted(() => { visualizer.setRendererSize(width.value, height.value); }); - const clearTitleWatch = watch(client.current, () => { - if (client.current.value) visualizer.launchSongTitleAnim(client.current.value.name); + const clearTitleWatch = watch(currentSong, () => { + if (currentSong.value) visualizer.launchSongTitleAnim(currentSong.value.name); }); - if (client.currentSong !== undefined && client.currentSong?.submitter !== "...") - visualizer.launchSongTitleAnim(client.currentSong.name); + if (currentSong.value !== undefined && currentSong.value.submitter !== "...") + visualizer.launchSongTitleAnim(currentSong.value.name); let stopRender = false; const render = () => { diff --git a/clients/web/src/components/ThePlayer.vue b/clients/web/src/components/ThePlayer.vue index c958214..36f7b32 100644 --- a/clients/web/src/components/ThePlayer.vue +++ b/clients/web/src/components/ThePlayer.vue @@ -4,17 +4,16 @@ import isButterchurnSupported from "butterchurn/lib/isSupported.min"; import RangeSlider from "./RangeSlider.vue"; import TheHistory from "./TheHistory.vue"; import { prettySeek, prettyDuration, volume, getDuration, seek } from "@/audio"; -import { getClient } from "@/syncClient"; import TheClients from "./TheClients.vue"; import { timePromise } from "@/util"; import { visualizerEnabled } from "@/visualizer"; +import { startSyncClient, currentSong, submitters, reconnecting } from "@/syncClient"; const visualizerSupported = isButterchurnSupported(); -const client = await timePromise.then(() => getClient()); +await timePromise.then(startSyncClient); -// @ts-expect-error IT IS COMPLETELY FINE IF UNDEFINED GETS RETURNED, OPTIONAL CHAINING EXISTS PLEASE SHUT THE FUCK UP. -const quotes = computed(() => client.submitters.get(client.currentSong?.submitter)?.quotes); +const quotes = computed(() => submitters.get(currentSong.value.submitter)?.quotes); const randomQuote = computed(() => quotes.value?.length ? `"${quotes.value[~~(Math.random() * quotes.value.length)]}"` : "", @@ -24,19 +23,19 @@ const randomQuote = computed(() =>