├── src ├── Icon.ico ├── wwwroot │ ├── favicon.ico │ ├── imgs │ │ └── favicon.png │ ├── js │ │ ├── custom.js │ │ └── chart-data.js │ ├── index.html │ └── css │ │ ├── custom.css │ │ ├── normalize.css │ │ └── milligram.css ├── Payloads │ ├── PayloadOp.cs │ ├── BasePayload.cs │ ├── Inbound │ │ └── ConnectPayload.cs │ └── Outbound │ │ └── RestResponse.cs ├── Objects │ ├── EndpointOptions.cs │ ├── AuthenticationOptions.cs │ ├── LoggingOptions.cs │ └── ApplicationOptions.cs ├── configuration.json ├── Extensions │ ├── JsonExtensions.cs │ ├── LoggingExtensions.cs │ ├── StringExtensions.cs │ └── MiscExtensions.cs ├── Properties │ └── launchSettings.json ├── Controllers │ ├── PingController.cs │ ├── PlayerController.cs │ ├── WebSocketHandler.cs │ └── SearchController.cs ├── Startup.cs ├── Internals │ ├── Middlewares │ │ ├── ExceptionMiddleware.cs │ │ └── AuthenticationMiddleware.cs │ ├── Attributes │ │ └── ProviderFilterAttribute.cs │ └── Logging │ │ ├── ColorfulLogger.cs │ │ └── LoggerProvider.cs ├── Rhapsody.csproj ├── GuildPlayer.cs └── Program.cs ├── .github └── workflows │ └── dotnetcore.yml ├── README.md ├── Rhapsody.sln ├── docs └── Design.md └── .gitignore /src/Icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yucked/Rhapsody/HEAD/src/Icon.ico -------------------------------------------------------------------------------- /src/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yucked/Rhapsody/HEAD/src/wwwroot/favicon.ico -------------------------------------------------------------------------------- /src/wwwroot/imgs/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yucked/Rhapsody/HEAD/src/wwwroot/imgs/favicon.png -------------------------------------------------------------------------------- /src/Payloads/PayloadOp.cs: -------------------------------------------------------------------------------- 1 | namespace Rhapsody.Payloads { 2 | public enum PayloadOp { 3 | Connect = 0 4 | } 5 | } -------------------------------------------------------------------------------- /src/Payloads/BasePayload.cs: -------------------------------------------------------------------------------- 1 | namespace Rhapsody.Payloads { 2 | public class BasePayload { 3 | public PayloadOp Op { get; private set; } 4 | } 5 | } -------------------------------------------------------------------------------- /src/Objects/EndpointOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Rhapsody.Objects { 2 | public struct EndpointOptions { 3 | public string Host { get; set; } 4 | public ushort Port { get; set; } 5 | public bool FallbackRandom { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /src/wwwroot/js/custom.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', function () { 2 | const menu = document.getElementById('menu'); 3 | menu.addEventListener('click', function () { 4 | menu.classList.toggle('active'); 5 | }); 6 | }); -------------------------------------------------------------------------------- /src/Objects/AuthenticationOptions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Rhapsody.Objects { 4 | public sealed class AuthenticationOptions { 5 | public string Password { get; set; } 6 | public List Endpoints { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /src/configuration.json: -------------------------------------------------------------------------------- 1 | {"endpoint":{"host":"*","port":2020,"fallbackRandom":false},"authentication":{"password":"Rhapsody","endpoints":["/api/search","/ws"]},"logging":{"defaultLevel":"Trace","filters":{"System.*":3}},"providers":{"youtube":true,"soundcloud":true,"bandcamp":true,"hearthisat":true,"http":true}} -------------------------------------------------------------------------------- /src/Objects/LoggingOptions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | using Microsoft.Extensions.Logging; 4 | 5 | namespace Rhapsody.Objects { 6 | public struct LoggingOptions { 7 | [JsonConverter(typeof(JsonStringEnumConverter))] 8 | public LogLevel DefaultLevel { get; set; } 9 | 10 | public IDictionary Filters { get; set; } 11 | } 12 | } -------------------------------------------------------------------------------- /src/Payloads/Inbound/ConnectPayload.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Rhapsody.Payloads.Inbound { 4 | public sealed class ConnectPayload : BasePayload { 5 | [Required] 6 | public ulong UserId { get; } 7 | 8 | [Required] 9 | public string SessionId { get; } 10 | 11 | [Required] 12 | public string Token { get; } 13 | 14 | [Required] 15 | public string Endpoint { get; } 16 | } 17 | } -------------------------------------------------------------------------------- /src/wwwroot/js/chart-data.js: -------------------------------------------------------------------------------- 1 | function addData(chart, label, data) { 2 | chart.data.labels.push(label); 3 | chart.data.datasets.forEach((dataset) => { 4 | dataset.data.push(data); 5 | }); 6 | 7 | chart.options = { 8 | response: true 9 | }; 10 | chart.update(); 11 | } 12 | 13 | function removeData(chart) { 14 | chart.data.labels.pop(); 15 | chart.data.datasets.forEach((dataset) => { 16 | dataset.data.pop(); 17 | }); 18 | 19 | chart.options = { 20 | response: true 21 | }; 22 | chart.update(); 23 | } -------------------------------------------------------------------------------- /src/Extensions/JsonExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | 3 | namespace Rhapsody.Extensions { 4 | public static class JsonExtensions { 5 | public static JsonSerializerOptions JsonOptions = new JsonSerializerOptions { 6 | PropertyNamingPolicy = JsonNamingPolicy.CamelCase 7 | }; 8 | 9 | public static T Deserialize(this byte[] bytes) { 10 | return JsonSerializer.Deserialize(bytes, JsonOptions); 11 | } 12 | 13 | public static byte[] Serialize(this T value) { 14 | return JsonSerializer.SerializeToUtf8Bytes(value, JsonOptions); 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/Objects/ApplicationOptions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace Rhapsody.Objects { 5 | public sealed class ApplicationOptions { 6 | public EndpointOptions Endpoint { get; set; } 7 | public AuthenticationOptions Authentication { get; set; } 8 | public LoggingOptions Logging { get; set; } 9 | public IDictionary Providers { get; set; } 10 | 11 | [JsonIgnore] 12 | public string Url 13 | => $"{Endpoint.Host}:{Endpoint.Port}"; 14 | 15 | public const string FILE_NAME = "configuration.json"; 16 | } 17 | } -------------------------------------------------------------------------------- /src/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:57080/", 7 | "sslPort": 44302 8 | } 9 | }, 10 | "profiles": { 11 | "IIS Express": { 12 | "commandName": "IISExpress", 13 | "launchBrowser": false, 14 | "environmentVariables": { 15 | "ASPNETCORE_ENVIRONMENT": "Development" 16 | } 17 | }, 18 | "Rhapsody": { 19 | "commandName": "Project", 20 | "launchBrowser": false, 21 | "environmentVariables": { 22 | "ASPNETCORE_ENVIRONMENT": "Development" 23 | }, 24 | "applicationUrl": "https://localhost:5001;http://localhost:5000" 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/Controllers/PingController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.Extensions.Caching.Memory; 4 | using Rhapsody.Payloads.Outbound; 5 | 6 | namespace Rhapsody.Controllers { 7 | [Route("api/[controller]"), ApiController, Produces("application/json")] 8 | public sealed class PingController : ControllerBase { 9 | private readonly IMemoryCache _memoryCache; 10 | 11 | public PingController(IMemoryCache memoryCache) { 12 | _memoryCache = memoryCache; 13 | } 14 | 15 | [HttpGet] 16 | public IActionResult Ping() { 17 | if (_memoryCache.TryGetValue("PING", out DateTimeOffset old)) { 18 | return RestResponse.Ok(old); 19 | } 20 | 21 | old = DateTimeOffset.UtcNow; 22 | _memoryCache.Set("PING", old); 23 | return RestResponse.Ok(old); 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /src/Startup.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Rhapsody.Controllers; 4 | using Rhapsody.Internals.Middlewares; 5 | 6 | namespace Rhapsody { 7 | public sealed class Startup { 8 | public void ConfigureServices(IServiceCollection services) { 9 | } 10 | 11 | public void Configure(IApplicationBuilder app) { 12 | app.UseFileServer(); 13 | app.UseWebSockets(); 14 | app.UseRouting(); 15 | 16 | app.UseResponseCaching(); 17 | app.UseResponseCompression(); 18 | 19 | app.UseMiddleware(); 20 | app.UseMiddleware(); 21 | 22 | app.UseEndpoints(endpoints => { 23 | endpoints.MapControllers(); 24 | endpoints.MapConnectionHandler("/player/{guildId}"); 25 | }); 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /.github/workflows/dotnetcore.yml: -------------------------------------------------------------------------------- 1 | name: .NET Core 2 | 3 | on: 4 | push: 5 | branches: [ beta, v1 ] 6 | 7 | pull_request: 8 | branches: [ beta, v1 ] 9 | 10 | env: 11 | GITHUB: ${{ toJson(github) }} 12 | BUILD_VERSION: 0.0.${{ github.run_number }} 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v1 20 | 21 | - name: Setup .NET Core 22 | uses: actions/setup-dotnet@v1 23 | with: 24 | dotnet-version: 5.0.100-preview.3 25 | 26 | - name: Build Project 27 | run: dotnet build /p:Version=$BUILD_VERSION-${GITHUB_REF##*/} --configuration Release -o Output 28 | 29 | - name: Discord WebHook 30 | if: always() 31 | shell: pwsh 32 | run: | 33 | wget https://raw.githubusercontent.com/Yucked/Scripy/master/Powershell/ActionSend.ps1 34 | pwsh .\ActionSend.ps1 ${{ secrets.WEBHOOK }} ${{ job.status }} 35 | -------------------------------------------------------------------------------- /src/Internals/Middlewares/ExceptionMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Http; 4 | using Microsoft.Extensions.Logging; 5 | 6 | namespace Rhapsody.Internals.Middlewares { 7 | public readonly struct ExceptionMiddleware { 8 | private readonly RequestDelegate _requestDelegate; 9 | private readonly ILogger _logger; 10 | 11 | public ExceptionMiddleware(RequestDelegate requestDelegate, ILogger logger) { 12 | _requestDelegate = requestDelegate; 13 | _logger = logger; 14 | } 15 | 16 | public async Task Invoke(HttpContext context) { 17 | try { 18 | await _requestDelegate(context); 19 | } 20 | catch (Exception ex) { 21 | _logger.LogError(ex.Message, ex.StackTrace); 22 | context.Response.StatusCode = 500; 23 | } 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /src/Rhapsody.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 0.0.1-beta 5 | netcoreapp5.0 6 | Icon.ico 7 | true 8 | true 9 | OutOfProcess 10 | https://www.myget.org/F/yucked/api/v3/index.json 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |

17 | Coming soon in theaters near you! 18 |

19 |

-------------------------------------------------------------------------------- /src/wwwroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Rhapsody 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 31 | 32 |
33 |

Yeee hawe

34 |
35 | 36 | -------------------------------------------------------------------------------- /src/Payloads/Outbound/RestResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace Rhapsody.Payloads.Outbound { 6 | public sealed class RestResponse : IActionResult { 7 | public bool IsSuccess { get; } 8 | public string Message { get; } 9 | public object Data { get; } 10 | 11 | public RestResponse(bool isSuccess, string message) { 12 | IsSuccess = isSuccess; 13 | Message = message; 14 | } 15 | 16 | public RestResponse(object data) { 17 | IsSuccess = true; 18 | Data = data; 19 | } 20 | 21 | public static IActionResult Error(string message) 22 | => new RestResponse(false, message); 23 | 24 | public static IActionResult Ok(object data) 25 | => new RestResponse(data); 26 | 27 | public static IActionResult Ok(string message) 28 | => new RestResponse(true, message); 29 | 30 | public async Task ExecuteResultAsync(ActionContext context) { 31 | var jsonResult = new JsonResult(this) { 32 | ContentType = "application/json", 33 | StatusCode = IsSuccess ? StatusCodes.Status200OK : StatusCodes.Status400BadRequest, 34 | Value = this 35 | }; 36 | 37 | await jsonResult.ExecuteResultAsync(context); 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /src/Internals/Attributes/ProviderFilterAttribute.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Microsoft.AspNetCore.Mvc.Filters; 3 | using Microsoft.Extensions.Configuration; 4 | using Rhapsody.Objects; 5 | using Rhapsody.Payloads.Outbound; 6 | 7 | namespace Rhapsody.Internals.Attributes { 8 | public sealed class ProviderFilterAttribute : ActionFilterAttribute { 9 | private readonly IDictionary _providers; 10 | 11 | public ProviderFilterAttribute(IConfiguration configuration) { 12 | _providers = configuration.Get().Providers; 13 | } 14 | 15 | public override void OnActionExecuting(ActionExecutingContext context) { 16 | if (context.Controller.GetType() != typeof(Controllers.SearchController)) { 17 | return; 18 | } 19 | 20 | var requestPath = context.HttpContext.Request.Path; 21 | if (!requestPath.HasValue) { 22 | context.Result = RestResponse.Error("Request path value wasn't specified."); 23 | return; 24 | } 25 | 26 | var provider = requestPath.Value[12..]; 27 | if (!_providers.TryGetValue(provider, out var isEnabled)) { 28 | context.Result = RestResponse.Error("Invalid provider. Is the provider added in configuration?"); 29 | return; 30 | } 31 | 32 | if (isEnabled) { 33 | return; 34 | } 35 | 36 | context.Result = RestResponse.Error($"{provider} is disabled in configuration."); 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /src/Extensions/LoggingExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Drawing; 3 | using Microsoft.Extensions.Logging; 4 | 5 | namespace Rhapsody.Extensions { 6 | public static class LoggingExtensions { 7 | public static Color GetLogLevelColor(this LogLevel logLevel) { 8 | return logLevel switch { 9 | LogLevel.Trace => Color.LightBlue, 10 | LogLevel.Debug => Color.PaleVioletRed, 11 | LogLevel.Information => Color.GreenYellow, 12 | LogLevel.Warning => Color.Coral, 13 | LogLevel.Error => Color.Crimson, 14 | LogLevel.Critical => Color.Red, 15 | LogLevel.None => Color.Coral, 16 | _ => Color.White 17 | }; 18 | } 19 | 20 | public static string GetShortLogLevel(this LogLevel logLevel) { 21 | return logLevel switch { 22 | LogLevel.Trace => "TRCE", 23 | LogLevel.Debug => "DBUG", 24 | LogLevel.Information => "INFO", 25 | LogLevel.Warning => "WARN", 26 | LogLevel.Error => "EROR", 27 | LogLevel.Critical => "CRIT", 28 | LogLevel.None => "NONE", 29 | _ => "UKON" 30 | }; 31 | } 32 | 33 | public static bool TryGetFilter(this IDictionary filters, ref string category, 34 | out LogLevel logLevel) { 35 | foreach (var (filter, level) in filters) { 36 | if (!filter.IsMatchFilter(ref category)) { 37 | continue; 38 | } 39 | 40 | logLevel = level; 41 | break; 42 | } 43 | 44 | logLevel = LogLevel.Trace; 45 | return true; 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /src/Internals/Logging/ColorfulLogger.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Drawing; 3 | using Colorful; 4 | using Microsoft.Extensions.Logging; 5 | using Rhapsody.Extensions; 6 | 7 | namespace Rhapsody.Internals.Logging { 8 | public readonly struct ColorfulLogger : ILogger { 9 | private readonly string _categoryName; 10 | private readonly LogLevel _categoryLevel; 11 | private readonly LoggerProvider _provider; 12 | 13 | public ColorfulLogger(string categoryName, LogLevel categoryLevel, LoggerProvider provider) { 14 | _categoryName = categoryName; 15 | _categoryLevel = categoryLevel; 16 | _provider = provider; 17 | } 18 | 19 | public IDisposable BeginScope(TState state) { 20 | return default; 21 | } 22 | 23 | public bool IsEnabled(LogLevel logLevel) { 24 | var isEnabled = _categoryLevel <= logLevel; 25 | return isEnabled; 26 | } 27 | 28 | public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, 29 | Func formatter) { 30 | if (!IsEnabled(logLevel)) { 31 | return; 32 | } 33 | 34 | var message = formatter(state, exception); 35 | if (string.IsNullOrWhiteSpace(message)) { 36 | return; 37 | } 38 | 39 | var formatters = new[] { 40 | new Formatter($"{DateTimeOffset.Now:MMM d - hh:mm:ss tt}", Color.Plum), 41 | new Formatter(logLevel.GetShortLogLevel(), logLevel.GetLogLevelColor()), 42 | new Formatter(_categoryName, Color.Orange), 43 | new Formatter(message.Indent(), Color.Gray) 44 | }; 45 | 46 | _provider.Enqueue(formatters); 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /src/Internals/Middlewares/AuthenticationMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.Extensions.Configuration; 4 | using Microsoft.Extensions.Logging; 5 | using Rhapsody.Objects; 6 | 7 | namespace Rhapsody.Internals.Middlewares { 8 | public readonly struct AuthenticationMiddleware { 9 | private readonly RequestDelegate _requestDelegate; 10 | private readonly ILogger _logger; 11 | private readonly AuthenticationOptions _authenticationOptions; 12 | 13 | public AuthenticationMiddleware(RequestDelegate requestDelegate, IConfiguration configuration, 14 | ILogger logger) { 15 | _requestDelegate = requestDelegate; 16 | _authenticationOptions = configuration.Get().Authentication; 17 | _logger = logger; 18 | } 19 | 20 | public async Task Invoke(HttpContext context) { 21 | if (!_authenticationOptions.Endpoints.Contains(context.Request.Path)) { 22 | await _requestDelegate(context); 23 | return; 24 | } 25 | 26 | var headers = context.Request.Headers; 27 | var remoteEndpoint = $"{context.Connection.RemoteIpAddress}:{context.Connection.RemotePort}"; 28 | 29 | if (!headers.TryGetValue("Authorization", out var authorization)) { 30 | _logger.LogError($"{remoteEndpoint} didn't include authorization headers."); 31 | context.Response.StatusCode = 401; 32 | await context.Response.CompleteAsync(); 33 | return; 34 | } 35 | 36 | if (authorization != _authenticationOptions.Password) { 37 | _logger.LogError($"{remoteEndpoint} provided wrong authorization value."); 38 | context.Response.StatusCode = 401; 39 | await context.Response.CompleteAsync(); 40 | return; 41 | } 42 | 43 | await _requestDelegate(context); 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /src/GuildPlayer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO.Pipelines; 3 | using System.Net.WebSockets; 4 | using System.Text.Json.Serialization; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Microsoft.Extensions.Logging; 8 | using Rhapsody.Extensions; 9 | using Rhapsody.Payloads.Inbound; 10 | 11 | namespace Rhapsody { 12 | public struct GuildPlayer { 13 | [JsonPropertyName("user_id")] 14 | public ulong UserId { get; } 15 | 16 | [JsonPropertyName("session_id")] 17 | public string SessionId { get; } 18 | 19 | [JsonPropertyName("token")] 20 | public string Token { get; } 21 | 22 | [JsonPropertyName("endpoint")] 23 | public string Endpoint { get; } 24 | 25 | [JsonPropertyName("remote_endpoint")] 26 | public string RemoteEndpoint { get; } 27 | 28 | [JsonIgnore] 29 | public WebSocket Socket { get; private set; } 30 | 31 | private readonly ILogger _logger; 32 | 33 | public GuildPlayer(ConnectPayload connectPayload, string remoteEndpoint, ILogger logger) { 34 | UserId = connectPayload.UserId; 35 | SessionId = connectPayload.SessionId; 36 | Token = connectPayload.Token; 37 | Endpoint = connectPayload.Endpoint; 38 | RemoteEndpoint = remoteEndpoint; 39 | _logger = logger; 40 | Socket = default; 41 | } 42 | 43 | public async ValueTask OnConnectedAsync(WebSocket webSocket) { 44 | Socket = webSocket; 45 | _logger.LogInformation($"WebSocket connection opened from {RemoteEndpoint}."); 46 | } 47 | 48 | public async ValueTask OnDisconnectedAsync(Exception exception = default) { 49 | _logger.LogError($"WebSocket connection dropped by {RemoteEndpoint}."); 50 | if (Socket.State.TryMatchAny(WebSocketState.Connecting, WebSocketState.Open)) { 51 | await Socket.CloseAsync(WebSocketCloseStatus.InternalServerError, exception?.Message, CancellationToken.None); 52 | } 53 | 54 | Socket.Dispose(); 55 | } 56 | 57 | public async ValueTask OnMessageAsync(PipeReader pipeReader) { 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /src/Internals/Logging/LoggerProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.Collections.Generic; 3 | using System.Drawing; 4 | using System.Threading.Tasks; 5 | using Colorful; 6 | using Microsoft.Extensions.Logging; 7 | using Rhapsody.Extensions; 8 | using Rhapsody.Objects; 9 | 10 | namespace Rhapsody.Internals.Logging { 11 | public struct LoggerProvider : ILoggerProvider { 12 | private const string MESSAGE_FORMAT = "[{0}] [{1}] [{2}]\n{3}"; 13 | private readonly LogLevel _defaultLevel; 14 | private readonly IDictionary _filters; 15 | private readonly ConcurrentQueue _queue; 16 | private readonly ConcurrentDictionary _loggers; 17 | private bool _isDisposed; 18 | 19 | public LoggerProvider(LoggingOptions loggingOptions) { 20 | _defaultLevel = loggingOptions.DefaultLevel; 21 | _filters = loggingOptions.Filters; 22 | _loggers = new ConcurrentDictionary(); 23 | _queue = new ConcurrentQueue(); 24 | _isDisposed = false; 25 | 26 | _ = RunAsync(); 27 | } 28 | 29 | public readonly ILogger CreateLogger(string categoryName) { 30 | if (_filters.TryGetFilter(ref categoryName, out var logLevel)) { 31 | } 32 | 33 | if (_loggers.TryGetValue(categoryName, out var logger)) { 34 | return logger; 35 | } 36 | 37 | 38 | logger = new ColorfulLogger(categoryName, logLevel, this); 39 | _loggers.TryAdd(categoryName, logger); 40 | return logger; 41 | } 42 | 43 | public void Dispose() { 44 | _isDisposed = true; 45 | _loggers.Clear(); 46 | } 47 | 48 | public readonly void Enqueue(Formatter[] formatters) { 49 | _queue.Enqueue(formatters); 50 | } 51 | 52 | private readonly async Task RunAsync() { 53 | while (!_isDisposed) { 54 | if (!_queue.TryDequeue(out var formatters)) { 55 | await Task.Delay(5); 56 | continue; 57 | } 58 | 59 | Console.WriteLineFormatted(MESSAGE_FORMAT, Color.White, formatters); 60 | await Task.Delay(5); 61 | } 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /src/Controllers/PlayerController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.Extensions.Caching.Memory; 4 | using Microsoft.Extensions.Logging; 5 | using Rhapsody.Payloads.Inbound; 6 | using Rhapsody.Payloads.Outbound; 7 | 8 | namespace Rhapsody.Controllers { 9 | [Route("api/[controller]"), ApiController, Produces("application/json")] 10 | public sealed class PlayerController : ControllerBase { 11 | private readonly IMemoryCache _memoryCache; 12 | private readonly ILoggerFactory _loggerFactory; 13 | 14 | public PlayerController(IMemoryCache memoryCache, ILoggerFactory loggerFactory) { 15 | _memoryCache = memoryCache; 16 | _loggerFactory = loggerFactory; 17 | } 18 | 19 | [HttpGet("{guildId}")] 20 | [ProducesResponseType(StatusCodes.Status200OK)] 21 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 22 | public IActionResult Get(ulong guildId) { 23 | return !_memoryCache.TryGetValue(guildId, out GuildPlayer player) 24 | ? RestResponse.Error($"Couldn't find player with {guildId} id.") 25 | : RestResponse.Ok(player); 26 | } 27 | 28 | [HttpDelete("{guildId}")] 29 | [ProducesResponseType(StatusCodes.Status200OK)] 30 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 31 | public IActionResult Delete(ulong guildId) { 32 | if (!_memoryCache.TryGetValue(guildId, out _)) { 33 | return RestResponse.Error($"Couldn't find player with {guildId} id."); 34 | } 35 | 36 | _memoryCache.Remove(guildId); 37 | return RestResponse.Ok($"Player with {guildId} id successfully removed."); 38 | } 39 | 40 | [HttpPost("{guildId}")] 41 | [ProducesResponseType(StatusCodes.Status200OK)] 42 | [ProducesResponseType(StatusCodes.Status201Created)] 43 | [ProducesResponseType(StatusCodes.Status204NoContent)] 44 | [ProducesResponseType(StatusCodes.Status406NotAcceptable)] 45 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 46 | public IActionResult HandlePayload(ulong guildId, object payload) { 47 | if (payload is ConnectPayload connectPayload) { 48 | return Ok(connectPayload); 49 | } 50 | 51 | return BadRequest(RestResponse.Error("Failed")); 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /src/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Dysc; 6 | using Microsoft.AspNetCore.Builder; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.Configuration.Json; 10 | using Microsoft.Extensions.DependencyInjection; 11 | using Microsoft.Extensions.Hosting; 12 | using Microsoft.Extensions.Logging; 13 | using Rhapsody.Extensions; 14 | using Rhapsody.Internals.Attributes; 15 | using Rhapsody.Internals.Logging; 16 | using Rhapsody.Objects; 17 | 18 | namespace Rhapsody { 19 | public readonly struct Program { 20 | public static async Task Main() { 21 | try { 22 | MiscExtensions.SetupApplicationInformation(); 23 | var applicationOptions = MiscExtensions.VerifyOptions(); 24 | 25 | await Host.CreateDefaultBuilder() 26 | .ConfigureAppConfiguration(builder => { 27 | var sources = builder.Sources.OfType().ToArray(); 28 | foreach (var source in sources) { 29 | builder.Sources.Remove(source); 30 | } 31 | 32 | builder.SetBasePath(Directory.GetCurrentDirectory()); 33 | builder.AddJsonFile(ApplicationOptions.FILE_NAME, false, true); 34 | }) 35 | .ConfigureWebHostDefaults(webBuilder => { 36 | webBuilder.UseUrls($"http://{applicationOptions.Url}"); 37 | webBuilder.UseStartup(); 38 | }) 39 | .ConfigureLogging(logging => { 40 | logging.SetMinimumLevel(applicationOptions.Logging.DefaultLevel); 41 | logging.ClearProviders(); 42 | logging.AddProvider(new LoggerProvider(applicationOptions.Logging)); 43 | }) 44 | .ConfigureServices((context, collection) => { 45 | collection.AddConnections(); 46 | collection.AddControllers(); 47 | collection.AddHttpClient(); 48 | collection.AddMemoryCache(cacheOptions => { 49 | cacheOptions.ExpirationScanFrequency = TimeSpan.FromSeconds(30); 50 | cacheOptions.CompactionPercentage = 0.5; 51 | }); 52 | collection.AddResponseCaching(cachingOptions => { cachingOptions.SizeLimit = 5; }); 53 | collection.AddResponseCompression(); 54 | collection.Configure(context.Configuration); 55 | 56 | collection.AddScoped(); 57 | collection.AddSingleton(); 58 | }) 59 | .RunConsoleAsync(); 60 | } 61 | catch (Exception exception) { 62 | Console.WriteLine(exception); 63 | Console.ReadKey(); 64 | } 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /src/wwwroot/css/custom.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Nanum+Pen+Script&display=swap'); 2 | 3 | html, body { 4 | overflow: visible; 5 | scrollbar-width: none; 6 | -ms-overflow-style: none; 7 | 8 | font-family: 'Nanum Pen Script', cursive !important; 9 | background: #F0F0F0; 10 | padding: 12px; 11 | } 12 | 13 | *, ::after, ::before { 14 | box-sizing: unset !important; 15 | } 16 | 17 | .menu { 18 | background: #fff; 19 | width: 60px; 20 | height: 60px; 21 | border-radius: 50%; 22 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); 23 | } 24 | 25 | .menu .bar { 26 | width: 30px; 27 | height: 4px; 28 | margin: 0 auto; 29 | background: #2e2e2e; 30 | position: relative; 31 | transition: all 0.5s ease; 32 | } 33 | 34 | .menu .bar ul { 35 | list-style-type: none; 36 | position: relative; 37 | margin: 0; 38 | top: -200px; 39 | transform: rotate(-90deg); 40 | } 41 | 42 | .menu .bar ul li { 43 | line-height: 2.1; 44 | margin: 0; 45 | height: 30px; 46 | padding: 15px; 47 | text-align: left; 48 | color: #2e2e2e; 49 | opacity: 0; 50 | font-size: 20px; 51 | transition: opacity 0.5s ease; 52 | } 53 | 54 | .menu .bar ul li a:hover { 55 | text-decoration: none; 56 | color: #303F9F; 57 | } 58 | 59 | .menu .bar:nth-child(1) { 60 | top: 15px; 61 | } 62 | 63 | .menu .bar:nth-child(2) { 64 | top: 24px; 65 | } 66 | 67 | .menu .bar:nth-child(3) { 68 | top: 33px; 69 | } 70 | 71 | .menu:hover { 72 | cursor: pointer; 73 | } 74 | 75 | .menu.active .bar:nth-child(1) { 76 | transform: rotate(45deg) translateX(6px) translateY(-2px); 77 | transform-origin: 0 0; 78 | } 79 | 80 | .menu.active .bar:nth-child(2) { 81 | transform: rotate(135deg); 82 | } 83 | 84 | .menu.active .bar:nth-child(3) { 85 | top: 100px; 86 | transform: rotate(90deg); 87 | transform-origin: 15px 0; 88 | width: 240px; 89 | } 90 | 91 | .menu.active .bar:nth-child(3) ul li { 92 | opacity: 1; 93 | } 94 | 95 | .has-border-radius-20 { 96 | border-radius: 20px; 97 | } 98 | 99 | .has-border-radius-15 { 100 | border-radius: 15px; 101 | } 102 | 103 | .has-shadow { 104 | box-shadow: 0 1px 10px black; 105 | } 106 | 107 | .is-tea-pink { 108 | color: #131313 !important; 109 | background-color: #fcd4fb !important; 110 | } 111 | 112 | .is-somewhat-yellow { 113 | color: #131313 !important; 114 | background-color: #fff454 !important; 115 | } -------------------------------------------------------------------------------- /src/Controllers/WebSocketHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO.Pipelines; 3 | using System.Net.WebSockets; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Connections; 7 | using Microsoft.AspNetCore.Http; 8 | using Microsoft.AspNetCore.Http.Connections; 9 | using Microsoft.Extensions.Caching.Memory; 10 | using Microsoft.Extensions.Logging; 11 | using Rhapsody.Extensions; 12 | 13 | namespace Rhapsody.Controllers { 14 | public sealed class WebSocketHandler : ConnectionHandler { 15 | private readonly ILogger _logger; 16 | private readonly IMemoryCache _memoryCache; 17 | private readonly Pipe _pipe; 18 | private const int BUFFER_SIZE = 256; 19 | 20 | public WebSocketHandler(ILogger logger, IMemoryCache memoryCache) { 21 | _logger = logger; 22 | _memoryCache = memoryCache; 23 | _pipe = new Pipe(); 24 | } 25 | 26 | public override async Task OnConnectedAsync(ConnectionContext connection) { 27 | var httpContext = connection.GetHttpContext(); 28 | 29 | if (!httpContext.WebSockets.IsWebSocketRequest) { 30 | await httpContext.Response.WriteAsync("Only WebSocket requests are allowed at this endpoint."); 31 | await httpContext.Response.CompleteAsync(); 32 | return; 33 | } 34 | 35 | if (!httpContext.IsValidRoute(out var guildId)) { 36 | await httpContext.Response.CompleteAsync(); 37 | return; 38 | } 39 | 40 | if (!_memoryCache.TryGetValue(guildId, out GuildPlayer guildPlayer)) { 41 | await httpContext.Response.WriteAsync($"You must send a ConnectPayload to /api/player/{guildId}."); 42 | await httpContext.Response.CompleteAsync(); 43 | return; 44 | } 45 | 46 | await httpContext.WebSockets.AcceptWebSocketAsync() 47 | .ContinueWith(async task => { 48 | var webSocket = await task; 49 | await guildPlayer.OnConnectedAsync(webSocket); 50 | await HandleConnectionAsync(guildPlayer); 51 | }); 52 | } 53 | 54 | private async Task HandleConnectionAsync(GuildPlayer guildPlayer) { 55 | var writer = _pipe.Writer; 56 | var webSocket = guildPlayer.Socket; 57 | 58 | try { 59 | do { 60 | var memory = writer.GetMemory(BUFFER_SIZE); 61 | var receiveResult = await webSocket.ReceiveAsync(memory, CancellationToken.None); 62 | if (!receiveResult.EndOfMessage) { 63 | writer.Advance(receiveResult.Count); 64 | continue; 65 | } 66 | 67 | await writer.FlushAsync(); 68 | await guildPlayer.OnMessageAsync(_pipe.Reader); 69 | } while (webSocket.State == WebSocketState.Open); 70 | } 71 | catch (Exception exception) { 72 | _logger.LogCritical(exception, exception.StackTrace); 73 | 74 | await writer.CompleteAsync(exception); 75 | await guildPlayer.OnDisconnectedAsync(exception); 76 | } 77 | } 78 | } 79 | } -------------------------------------------------------------------------------- /Rhapsody.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26124.0 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rhapsody", "src\Rhapsody.csproj", "{055F55B0-D3D5-4F99-8EA1-3429B35E6294}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dysc", "..\Dysc\src\Dysc.csproj", "{7BA2777E-6357-45BC-A30C-70A09FF65BD8}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | Debug|x64 = Debug|x64 14 | Debug|x86 = Debug|x86 15 | Release|Any CPU = Release|Any CPU 16 | Release|x64 = Release|x64 17 | Release|x86 = Release|x86 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 23 | {055F55B0-D3D5-4F99-8EA1-3429B35E6294}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {055F55B0-D3D5-4F99-8EA1-3429B35E6294}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {055F55B0-D3D5-4F99-8EA1-3429B35E6294}.Debug|x64.ActiveCfg = Debug|Any CPU 26 | {055F55B0-D3D5-4F99-8EA1-3429B35E6294}.Debug|x64.Build.0 = Debug|Any CPU 27 | {055F55B0-D3D5-4F99-8EA1-3429B35E6294}.Debug|x86.ActiveCfg = Debug|Any CPU 28 | {055F55B0-D3D5-4F99-8EA1-3429B35E6294}.Debug|x86.Build.0 = Debug|Any CPU 29 | {055F55B0-D3D5-4F99-8EA1-3429B35E6294}.Release|Any CPU.ActiveCfg = Release|Any CPU 30 | {055F55B0-D3D5-4F99-8EA1-3429B35E6294}.Release|Any CPU.Build.0 = Release|Any CPU 31 | {055F55B0-D3D5-4F99-8EA1-3429B35E6294}.Release|x64.ActiveCfg = Release|Any CPU 32 | {055F55B0-D3D5-4F99-8EA1-3429B35E6294}.Release|x64.Build.0 = Release|Any CPU 33 | {055F55B0-D3D5-4F99-8EA1-3429B35E6294}.Release|x86.ActiveCfg = Release|Any CPU 34 | {055F55B0-D3D5-4F99-8EA1-3429B35E6294}.Release|x86.Build.0 = Release|Any CPU 35 | {7BA2777E-6357-45BC-A30C-70A09FF65BD8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 36 | {7BA2777E-6357-45BC-A30C-70A09FF65BD8}.Debug|Any CPU.Build.0 = Debug|Any CPU 37 | {7BA2777E-6357-45BC-A30C-70A09FF65BD8}.Debug|x64.ActiveCfg = Debug|Any CPU 38 | {7BA2777E-6357-45BC-A30C-70A09FF65BD8}.Debug|x64.Build.0 = Debug|Any CPU 39 | {7BA2777E-6357-45BC-A30C-70A09FF65BD8}.Debug|x86.ActiveCfg = Debug|Any CPU 40 | {7BA2777E-6357-45BC-A30C-70A09FF65BD8}.Debug|x86.Build.0 = Debug|Any CPU 41 | {7BA2777E-6357-45BC-A30C-70A09FF65BD8}.Release|Any CPU.ActiveCfg = Release|Any CPU 42 | {7BA2777E-6357-45BC-A30C-70A09FF65BD8}.Release|Any CPU.Build.0 = Release|Any CPU 43 | {7BA2777E-6357-45BC-A30C-70A09FF65BD8}.Release|x64.ActiveCfg = Release|Any CPU 44 | {7BA2777E-6357-45BC-A30C-70A09FF65BD8}.Release|x64.Build.0 = Release|Any CPU 45 | {7BA2777E-6357-45BC-A30C-70A09FF65BD8}.Release|x86.ActiveCfg = Release|Any CPU 46 | {7BA2777E-6357-45BC-A30C-70A09FF65BD8}.Release|x86.Build.0 = Release|Any CPU 47 | EndGlobalSection 48 | EndGlobal 49 | -------------------------------------------------------------------------------- /docs/Design.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 |

5 | Rhapsody performs similar to Lavalink but implements a different design pattern. This design doc should aid you in writing your own client.
6 | Before proceeding, please keep in mind this design is subject to change. It is your responsbibility to keep your clients up to date. 7 |

8 |

9 | 10 | 11 | --- 12 | 13 |

14 | 15 |

16 | 17 | --- 18 | Rhapsody provides wide range of options to configure it to your liking. 19 | ```json 20 | { 21 | "endpoint": { 22 | "host": "*", 23 | "port": 5000, 24 | "fallbackRandom": true 25 | }, 26 | "logging": { 27 | "defaltLevel": "Trace", 28 | "filters": { 29 | "System.*": 0 30 | } 31 | }, 32 | "authorization": { 33 | "password": "Rhapsody", 34 | "endpoints": [ 35 | "/api/search", 36 | "/ws/{guildId}" 37 | ] 38 | }, 39 | "sourceProviders": { 40 | "youtube": true, 41 | "soundcloud": true 42 | } 43 | } 44 | ``` 45 | 46 | - `host:` Can be set to `*` to listen on both IPV4 and IPV6 addresses from any interface. 47 | - `fallbackRandom`: Used if a port is unavailable or if an address is unavailable. 48 | - `logging.defaltLevel`: The following log levels are supported: None, Trace, Debug, Information, Warning, Error, Critical. 49 | - `logging.filters:` Don't like receiving too many messages from `System.Threading.Tasks`? or anything from `System` namespace? 50 | You can specify a log filter for namespaces. If you'd like to block anything from `System.XYZ` use `System.*` and everything will fall under `System` instead. 51 | - `authorization.endpoints`: You can specify which routes require authorization. 52 | - `sourceProviders`: You can toggle source providers. 53 | 54 | Now that you have a basic understanding of how you can configure Rhapsody let's discuss routes. 55 | 56 |

57 | 58 |

59 | 60 | --- 61 | All REST routes begin with `{endpoint}/api/` these routes can be be found in `Controllers` directory.\ 62 | Here are the following routes that are available: 63 | - `/ping`: Returns datetime for now 64 | - `/player/{guildId}`: Let's you configure your guild player. This is where payloads are sent. 65 | - `/search/{provider}`: Depends on which source provider you have enabled in options. 66 | - `/ws/{guildId}`: This is where you establish a websocket connection for your guild to receive track and stat updates. 67 | 68 | Additionally, `/player/{guildId}` handles few CRUD operations such as GET, DELETE, POST. 69 | - `GET`: Returns guild player information. 70 | - `DELETE`: To remove player after your Discord client has lost connection or just want to remove player. 71 | - `POST`: Send payloads related to your guild player. 72 | 73 |

74 | 75 |

76 | 77 | --- 78 | Without a `ConnectPayload` you can't establish any WS connection or perform player related REST operations. 79 | The very first payload sent should be `Connect`. 80 | 81 | ```json5 82 | { 83 | "endpoint": "...", // Voice server endpoint 84 | "token": "...", // Voice server token 85 | "sessionId": "...", // Voice server session id 86 | "userId": 123456789012345 // Your discord client id 87 | } 88 | ``` -------------------------------------------------------------------------------- /src/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | 4 | namespace Rhapsody.Extensions { 5 | public static class StringExtensions { 6 | public static string Indent(this string content, int indent = 5) { 7 | var splits = content.Split(Environment.NewLine); 8 | if (splits.Length <= 1) { 9 | return $"{new string(' ', indent)}{content}"; 10 | } 11 | 12 | var stringLength = splits.Sum(x => x.Length + indent) + splits.Length; 13 | var range = Enumerable.Range(0, indent).ToArray(); 14 | 15 | return string.Create(stringLength, splits, (span, strings) => { 16 | var position = 0; 17 | for (var i = 0; i < strings.Length; i++) { 18 | var trimmed = strings[i].Trim(); 19 | var tempChars = new char[trimmed.Length + indent + 1]; 20 | 21 | for (var s = 0; s < tempChars.Length; s++) { 22 | if (range.Contains(s)) { 23 | tempChars[s] = ' '; 24 | } 25 | else { 26 | tempChars[s] = tempChars.Length - 1 == s 27 | ? '\n' 28 | : trimmed[s - indent]; 29 | } 30 | } 31 | 32 | if (i == strings.Length - 1) { 33 | tempChars.AsSpan().Slice(0, tempChars.Length - 1); 34 | } 35 | 36 | var charSpan = tempChars.AsSpan(); 37 | var str = i == strings.Length - 1 38 | ? charSpan.Slice(0, charSpan.Length - 1) 39 | : charSpan; 40 | str.CopyTo(i == 0 ? span : span.Slice(position)); 41 | position += str.Length; 42 | } 43 | }); 44 | } 45 | 46 | public static string Wrap(this string str, int wrapAfter = 50) { 47 | var words = str.Split(' '); 48 | if (words.Length <= 50) { 49 | return str; 50 | } 51 | 52 | var wrappedAt = 0; 53 | var result = new string[words.Length + words.Length - 1]; 54 | for (var i = 0; i < words.Length; i++) { 55 | var s = i++; 56 | result[i] = words[i]; 57 | if (wrappedAt == wrapAfter) { 58 | wrappedAt = 0; 59 | result[s] = "\n"; 60 | } 61 | else { 62 | result[s] = " "; 63 | wrappedAt++; 64 | } 65 | } 66 | 67 | return string.Join("", result); 68 | } 69 | 70 | public static bool IsMatchFilter(this string categoryFilter, ref string category) { 71 | if (categoryFilter.Contains("*")) { 72 | categoryFilter = categoryFilter[..^2]; 73 | } 74 | 75 | var splits = category.Split('.'); 76 | var tempCategory = string.Empty; 77 | 78 | foreach (var split in splits) { 79 | tempCategory += split; 80 | if (categoryFilter == tempCategory) { 81 | break; 82 | } 83 | 84 | tempCategory += "."; 85 | } 86 | 87 | category = tempCategory; 88 | return true; 89 | } 90 | 91 | public static bool IsFuzzyMatch(this string str, string against, int percentage = 95) { 92 | var n = str.Length; 93 | var m = against.Length; 94 | var d = new int[n + 1, m + 1]; 95 | 96 | if (n == 0) { 97 | return false; 98 | } 99 | 100 | if (m == 0) { 101 | return false; 102 | } 103 | 104 | for (var i = 0; i <= n; d[i, 0] = i++) { 105 | } 106 | 107 | for (var j = 0; j <= m; d[0, j] = j++) { 108 | } 109 | 110 | for (var i = 1; i <= n; i++) { 111 | for (var j = 1; j <= m; j++) { 112 | var cost = against[j - 1] == str[i - 1] ? 0 : 1; 113 | d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost); 114 | } 115 | } 116 | 117 | var result = 100 - d[n, m]; 118 | return result >= percentage; 119 | } 120 | } 121 | } -------------------------------------------------------------------------------- /src/Extensions/MiscExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Drawing; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Net.WebSockets; 7 | using System.Reflection; 8 | using System.Runtime.InteropServices; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | using Colorful; 12 | using Dysc; 13 | using Microsoft.AspNetCore.Http; 14 | using Microsoft.Extensions.Logging; 15 | using Rhapsody.Objects; 16 | using Console = Colorful.Console; 17 | 18 | namespace Rhapsody.Extensions { 19 | public static class MiscExtensions { 20 | public static void SetupApplicationInformation() { 21 | var versionAttribute = typeof(Startup).Assembly.GetCustomAttribute(); 22 | 23 | Console.SetWindowSize(120, 25); 24 | Console.Title = $"{nameof(Rhapsody)} v{versionAttribute?.InformationalVersion}"; 25 | 26 | const string TITLE = 27 | @" _ _ _ _ _ _ _ _ 28 | _/\\___ _/\\___ __/\\__ _/\\___ /\\__ __/\\___ __/\\___ _ /\\ 29 | (_ _ ))(_ __ __)) (_ ____)(_ _ _)) / \\(_ _))(_ ____)) /\\ / // 30 | / |))// / |_| \\ / _ \\ / |))\\ _\ \_// / _ \\ / _ \\ \ \/ // 31 | /:. \\ /:. _ \\/:./_\ \\/:. ___//// \:.\ /:.(_)) \\/:. |_\ \\ _\:.// 32 | \___| // \___| | //\ _ //\_ \\ \\__ / \ _____//\ _____//(_ _)) 33 | \// \// \// \// \// \\/ \// \// \// 34 | "; 35 | Console.WriteWithGradient(TITLE, Color.DarkOrange, Color.SlateBlue); 36 | Console.ReplaceAllColorsWithDefaults(); 37 | 38 | const string LOG_MESSAGE = 39 | "-> Version: {0}\n-> Framework: {1}\n-> OS Arch: {2}\n-> Process Arch: {3}\n-> OS: {4}"; 40 | 41 | var formatters = new[] { 42 | new Formatter(versionAttribute?.InformationalVersion.Trim(), Color.Gold), 43 | new Formatter(RuntimeInformation.FrameworkDescription, Color.Aqua), 44 | new Formatter(RuntimeInformation.OSArchitecture, Color.Gold), 45 | new Formatter(RuntimeInformation.ProcessArchitecture, Color.LawnGreen), 46 | new Formatter(RuntimeInformation.OSDescription, Color.HotPink) 47 | }; 48 | 49 | Console.WriteLineFormatted(LOG_MESSAGE, Color.White, formatters); 50 | Console.WriteLine(new string('-', 100), Color.Gray); 51 | } 52 | 53 | public static Task SendAsync(this WebSocket webSocket, T data) { 54 | return webSocket.SendAsync(data.Serialize(), WebSocketMessageType.Binary, true, CancellationToken.None); 55 | } 56 | 57 | public static bool IsValidRoute(this HttpContext httpContext, out ulong guildId) { 58 | if (!httpContext.Request.RouteValues.TryGetValue("guildId", out var obj)) { 59 | httpContext.Response.StatusCode = 403; 60 | guildId = default; 61 | return false; 62 | } 63 | 64 | if (ulong.TryParse($"{obj}", out guildId)) { 65 | return true; 66 | } 67 | 68 | httpContext.Response.StatusCode = 403; 69 | return false; 70 | } 71 | 72 | public static bool TryMatchAny(this T value, params T[] against) where T : struct { 73 | return against.Contains(value); 74 | } 75 | 76 | public static ApplicationOptions VerifyOptions() { 77 | ApplicationOptions applicationOptions; 78 | if (File.Exists(ApplicationOptions.FILE_NAME)) { 79 | var bytes = File.ReadAllBytes(ApplicationOptions.FILE_NAME); 80 | applicationOptions = bytes.Deserialize(); 81 | } 82 | else { 83 | applicationOptions = new ApplicationOptions { 84 | Endpoint = new EndpointOptions { 85 | Host = "*", 86 | Port = 2020, 87 | FallbackRandom = false 88 | }, 89 | Authentication = new AuthenticationOptions { 90 | Password = nameof(Rhapsody), 91 | Endpoints = new List { 92 | "/api/search", 93 | "/ws" 94 | } 95 | }, 96 | Logging = new LoggingOptions { 97 | Filters = new Dictionary { 98 | { 99 | "System.*", LogLevel.Warning 100 | } 101 | }, 102 | DefaultLevel = LogLevel.Trace 103 | }, 104 | Providers = Enum.GetNames(typeof(SourceProvider)) 105 | .ToDictionary(x => x.ToLower(), x => true) 106 | }; 107 | var serialize = applicationOptions.Serialize(); 108 | File.WriteAllBytes(ApplicationOptions.FILE_NAME, serialize); 109 | } 110 | 111 | return applicationOptions; 112 | } 113 | } 114 | } -------------------------------------------------------------------------------- /src/Controllers/SearchController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Dysc; 4 | using Microsoft.AspNetCore.Http; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.Extensions.Caching.Memory; 7 | using Microsoft.Extensions.Logging; 8 | using Rhapsody.Internals.Attributes; 9 | using Rhapsody.Payloads.Outbound; 10 | 11 | namespace Rhapsody.Controllers { 12 | [Route("api/[controller]"), ApiController, Produces("application/json")] 13 | [ServiceFilter(typeof(ProviderFilterAttribute))] 14 | public sealed class SearchController : ControllerBase { 15 | private readonly Dysk _dysk; 16 | private readonly IMemoryCache _memoryCache; 17 | private readonly ILogger _logger; 18 | 19 | public SearchController(Dysk dysk, ILogger logger, IMemoryCache memoryCache) { 20 | _dysk = dysk; 21 | _logger = logger; 22 | _memoryCache = memoryCache; 23 | } 24 | 25 | 26 | [HttpGet("youtube")] 27 | [ProducesResponseType(StatusCodes.Status200OK)] 28 | [ProducesResponseType(StatusCodes.Status500InternalServerError)] 29 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 30 | public ValueTask GetYouTubeAsync(string query, bool isPlaylist = false) { 31 | return SearchAsync(SourceProvider.YouTube, query, isPlaylist); 32 | } 33 | 34 | [HttpGet("soundcloud")] 35 | [ProducesResponseType(StatusCodes.Status200OK)] 36 | [ProducesResponseType(StatusCodes.Status500InternalServerError)] 37 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 38 | public ValueTask GetSoundCloudAsync(string query, bool isPlaylist = false) { 39 | return SearchAsync(SourceProvider.SoundCloud, query, isPlaylist); 40 | } 41 | 42 | [HttpGet("bandcamp")] 43 | [ProducesResponseType(StatusCodes.Status200OK)] 44 | [ProducesResponseType(StatusCodes.Status500InternalServerError)] 45 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 46 | public ValueTask GetBandCampAsync(string query, bool isPlaylist = false) { 47 | return SearchAsync(SourceProvider.BandCamp, query, isPlaylist); 48 | } 49 | 50 | [HttpGet("hearthisat")] 51 | [ProducesResponseType(StatusCodes.Status200OK)] 52 | [ProducesResponseType(StatusCodes.Status500InternalServerError)] 53 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 54 | public ValueTask GetHearThisAtAsync(string query, bool isPlaylist = false) { 55 | return SearchAsync(SourceProvider.HearThisAt, query, isPlaylist); 56 | } 57 | 58 | [HttpGet("http")] 59 | [ProducesResponseType(StatusCodes.Status200OK)] 60 | [ProducesResponseType(StatusCodes.Status500InternalServerError)] 61 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 62 | public ValueTask GetHttpAsync(string query, bool isPlaylist = false) { 63 | return !Uri.IsWellFormedUriString(query, UriKind.Absolute) 64 | ? new ValueTask(RestResponse.Error("Query must be an absolute URI string.")) 65 | : SearchAsync(SourceProvider.Http, query, isPlaylist); 66 | } 67 | 68 | private async ValueTask SearchAsync(SourceProvider providerType, string query, bool isPlaylist) { 69 | if (string.IsNullOrWhiteSpace(query)) { 70 | return RestResponse.Error("Query must not be empty."); 71 | } 72 | 73 | try { 74 | var provider = _dysk.GetProvider(providerType); 75 | if (isPlaylist) { 76 | var playlistResult = await provider.GetPlaylistAsync(query); 77 | return RestResponse.Ok(playlistResult); 78 | } 79 | 80 | var trackResults = await provider.SearchAsync(query); 81 | return RestResponse.Ok(trackResults); 82 | } 83 | catch (Exception exception) { 84 | _logger.LogCritical(exception, exception.StackTrace); 85 | return RestResponse.Error(exception.Message); 86 | } 87 | } 88 | 89 | /* 90 | private bool TrySearchCache(SourceProvider providerType, string query, out SearchResponse searchResponse) { 91 | searchResponse = default; 92 | if (!_memoryCache.TryGetValue(providerType, out ICollection searchResponses)) { 93 | return false; 94 | } 95 | 96 | foreach (var response in searchResponses) { 97 | if (response.Query.IsFuzzyMatch(query)) { 98 | searchResponse = response; 99 | return true; 100 | } 101 | 102 | var sum = response.Tracks.Sum(info => { 103 | if (info.Url.IsFuzzyMatch(query)) { 104 | return 1; 105 | } 106 | 107 | if (info.Title.IsFuzzyMatch(query)) { 108 | return 1; 109 | } 110 | 111 | return $"{info.Author} {info.Title}".IsFuzzyMatch(query) ? 1 : 0; 112 | }); 113 | 114 | if (sum / response.Tracks.Count <= 85) { 115 | continue; 116 | } 117 | 118 | searchResponse = response; 119 | return true; 120 | } 121 | 122 | return false; 123 | } 124 | */ 125 | } 126 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | 33 | # Visual Studio 2015/2017 cache/options directory 34 | .vs/ 35 | # Uncomment if you have tasks that create the project's static files in wwwroot 36 | #wwwroot/ 37 | 38 | # Visual Studio 2017 auto generated files 39 | Generated\ Files/ 40 | 41 | # MSTest test Results 42 | [Tt]est[Rr]esult*/ 43 | [Bb]uild[Ll]og.* 44 | 45 | # NUNIT 46 | *.VisualState.xml 47 | TestResult.xml 48 | 49 | # Build Results of an ATL Project 50 | [Dd]ebugPS/ 51 | [Rr]eleasePS/ 52 | dlldata.c 53 | 54 | # Benchmark Results 55 | BenchmarkDotNet.Artifacts/ 56 | 57 | # .NET Core 58 | project.lock.json 59 | project.fragment.lock.json 60 | artifacts/ 61 | 62 | # StyleCop 63 | StyleCopReport.xml 64 | 65 | # Files built by Visual Studio 66 | *_i.c 67 | *_p.c 68 | *_h.h 69 | *.ilk 70 | *.meta 71 | *.obj 72 | *.iobj 73 | *.pch 74 | *.pdb 75 | *.ipdb 76 | *.pgc 77 | *.pgd 78 | *.rsp 79 | *.sbr 80 | *.tlb 81 | *.tli 82 | *.tlh 83 | *.tmp 84 | *.tmp_proj 85 | *_wpftmp.csproj 86 | *.log 87 | *.vspscc 88 | *.vssscc 89 | .builds 90 | *.pidb 91 | *.svclog 92 | *.scc 93 | 94 | # Chutzpah Test files 95 | _Chutzpah* 96 | 97 | # Visual C++ cache files 98 | ipch/ 99 | *.aps 100 | *.ncb 101 | *.opendb 102 | *.opensdf 103 | *.sdf 104 | *.cachefile 105 | *.VC.db 106 | *.VC.VC.opendb 107 | 108 | # Visual Studio profiler 109 | *.psess 110 | *.vsp 111 | *.vspx 112 | *.sap 113 | 114 | # Visual Studio Trace Files 115 | *.e2e 116 | 117 | # TFS 2012 Local Workspace 118 | $tf/ 119 | 120 | # Guidance Automation Toolkit 121 | *.gpState 122 | 123 | # ReSharper is a .NET coding add-in 124 | _ReSharper*/ 125 | *.[Rr]e[Ss]harper 126 | *.DotSettings.user 127 | 128 | # JustCode is a .NET coding add-in 129 | .JustCode 130 | 131 | # TeamCity is a build add-in 132 | _TeamCity* 133 | 134 | # DotCover is a Code Coverage Tool 135 | *.dotCover 136 | 137 | # AxoCover is a Code Coverage Tool 138 | .axoCover/* 139 | !.axoCover/settings.json 140 | 141 | # Visual Studio code coverage results 142 | *.coverage 143 | *.coveragexml 144 | 145 | # NCrunch 146 | _NCrunch_* 147 | .*crunch*.local.xml 148 | nCrunchTemp_* 149 | 150 | # MightyMoose 151 | *.mm.* 152 | AutoTest.Net/ 153 | 154 | # Web workbench (sass) 155 | .sass-cache/ 156 | 157 | # Installshield output folder 158 | [Ee]xpress/ 159 | 160 | # DocProject is a documentation generator add-in 161 | DocProject/buildhelp/ 162 | DocProject/Help/*.HxT 163 | DocProject/Help/*.HxC 164 | DocProject/Help/*.hhc 165 | DocProject/Help/*.hhk 166 | DocProject/Help/*.hhp 167 | DocProject/Help/Html2 168 | DocProject/Help/html 169 | 170 | # Click-Once directory 171 | publish/ 172 | 173 | # Publish Web Output 174 | *.[Pp]ublish.xml 175 | *.azurePubxml 176 | # Note: Comment the next line if you want to checkin your web deploy settings, 177 | # but database connection strings (with potential passwords) will be unencrypted 178 | *.pubxml 179 | *.publishproj 180 | 181 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 182 | # checkin your Azure Web App publish settings, but sensitive information contained 183 | # in these scripts will be unencrypted 184 | PublishScripts/ 185 | 186 | # NuGet Packages 187 | *.nupkg 188 | # The packages folder can be ignored because of Package Restore 189 | **/[Pp]ackages/* 190 | # except build/, which is used as an MSBuild target. 191 | !**/[Pp]ackages/build/ 192 | # Uncomment if necessary however generally it will be regenerated when needed 193 | #!**/[Pp]ackages/repositories.config 194 | # NuGet v3's project.json files produces more ignorable files 195 | *.nuget.props 196 | *.nuget.targets 197 | 198 | # Microsoft Azure Build Output 199 | csx/ 200 | *.build.csdef 201 | 202 | # Microsoft Azure Emulator 203 | ecf/ 204 | rcf/ 205 | 206 | # Windows Store app package directories and files 207 | AppPackages/ 208 | BundleArtifacts/ 209 | Package.StoreAssociation.xml 210 | _pkginfo.txt 211 | *.appx 212 | *.appxbundle 213 | *.appxupload 214 | 215 | # Visual Studio cache files 216 | # files ending in .cache can be ignored 217 | *.[Cc]ache 218 | # but keep track of directories ending in .cache 219 | !?*.[Cc]ache/ 220 | 221 | # Others 222 | ClientBin/ 223 | ~$* 224 | *~ 225 | *.dbmdl 226 | *.dbproj.schemaview 227 | *.jfm 228 | *.pfx 229 | *.publishsettings 230 | orleans.codegen.cs 231 | 232 | # Including strong name files can present a security risk 233 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 234 | #*.snk 235 | 236 | # Since there are multiple workflows, uncomment next line to ignore bower_components 237 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 238 | #bower_components/ 239 | 240 | # RIA/Silverlight projects 241 | Generated_Code/ 242 | 243 | # Backup & report files from converting an old project file 244 | # to a newer Visual Studio version. Backup files are not needed, 245 | # because we have git ;-) 246 | _UpgradeReport_Files/ 247 | Backup*/ 248 | UpgradeLog*.XML 249 | UpgradeLog*.htm 250 | ServiceFabricBackup/ 251 | *.rptproj.bak 252 | 253 | # SQL Server files 254 | *.mdf 255 | *.ldf 256 | *.ndf 257 | 258 | # Business Intelligence projects 259 | *.rdl.data 260 | *.bim.layout 261 | *.bim_*.settings 262 | *.rptproj.rsuser 263 | *- Backup*.rdl 264 | 265 | # Microsoft Fakes 266 | FakesAssemblies/ 267 | 268 | # GhostDoc plugin setting file 269 | *.GhostDoc.xml 270 | 271 | # Node.js Tools for Visual Studio 272 | .ntvs_analysis.dat 273 | node_modules/ 274 | 275 | # Visual Studio 6 build log 276 | *.plg 277 | 278 | # Visual Studio 6 workspace options file 279 | *.opt 280 | 281 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 282 | *.vbw 283 | 284 | # Visual Studio LightSwitch build output 285 | **/*.HTMLClient/GeneratedArtifacts 286 | **/*.DesktopClient/GeneratedArtifacts 287 | **/*.DesktopClient/ModelManifest.xml 288 | **/*.Server/GeneratedArtifacts 289 | **/*.Server/ModelManifest.xml 290 | _Pvt_Extensions 291 | 292 | # Paket dependency manager 293 | .paket/paket.exe 294 | paket-files/ 295 | 296 | # FAKE - F# Make 297 | .fake/ 298 | 299 | # CodeRush personal settings 300 | .cr/personal 301 | 302 | # Python Tools for Visual Studio (PTVS) 303 | __pycache__/ 304 | *.pyc 305 | 306 | # Cake - Uncomment if you are using it 307 | # tools/** 308 | # !tools/packages.config 309 | 310 | # Tabs Studio 311 | *.tss 312 | 313 | # Telerik's JustMock configuration file 314 | *.jmconfig 315 | 316 | # BizTalk build output 317 | *.btp.cs 318 | *.btm.cs 319 | *.odx.cs 320 | *.xsd.cs 321 | 322 | # OpenCover UI analysis results 323 | OpenCover/ 324 | 325 | # Azure Stream Analytics local run output 326 | ASALocalRun/ 327 | 328 | # MSBuild Binary and Structured Log 329 | *.binlog 330 | 331 | # NVidia Nsight GPU debugger configuration file 332 | *.nvuser 333 | 334 | # MFractors (Xamarin productivity tool) working folder 335 | .mfractor/ 336 | 337 | # Local History for Visual Studio 338 | .localhistory/ 339 | 340 | # BeatPulse healthcheck temp database 341 | healthchecksdb 342 | 343 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 344 | MigrationBackup/ 345 | 346 | ## 347 | ## Visual studio for Mac 348 | ## 349 | 350 | 351 | # globs 352 | Makefile.in 353 | *.userprefs 354 | *.usertasks 355 | config.make 356 | config.status 357 | aclocal.m4 358 | install-sh 359 | autom4te.cache/ 360 | *.tar.gz 361 | tarballs/ 362 | test-results/ 363 | 364 | # Mac bundle stuff 365 | *.dmg 366 | *.app 367 | 368 | # content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore 369 | # General 370 | .DS_Store 371 | .AppleDouble 372 | .LSOverride 373 | 374 | # Icon must end with two \r 375 | Icon 376 | 377 | 378 | # Thumbnails 379 | ._* 380 | 381 | # Files that might appear in the root of a volume 382 | .DocumentRevisions-V100 383 | .fseventsd 384 | .Spotlight-V100 385 | .TemporaryItems 386 | .Trashes 387 | .VolumeIcon.icns 388 | .com.apple.timemachine.donotpresent 389 | 390 | # Directories potentially created on remote AFP share 391 | .AppleDB 392 | .AppleDesktop 393 | Network Trash Folder 394 | Temporary Items 395 | .apdisk 396 | 397 | # content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore 398 | # Windows thumbnail cache files 399 | Thumbs.db 400 | ehthumbs.db 401 | ehthumbs_vista.db 402 | 403 | # Dump file 404 | *.stackdump 405 | 406 | # Folder config file 407 | [Dd]esktop.ini 408 | 409 | # Recycle Bin used on file shares 410 | $RECYCLE.BIN/ 411 | 412 | # Windows Installer files 413 | *.cab 414 | *.msi 415 | *.msix 416 | *.msm 417 | *.msp 418 | 419 | # Windows shortcuts 420 | *.lnk 421 | 422 | # JetBrains Rider 423 | .idea/ 424 | *.sln.iml 425 | 426 | ## 427 | ## Visual Studio Code 428 | ## 429 | .vscode/* 430 | !.vscode/settings.json 431 | !.vscode/tasks.json 432 | !.vscode/launch.json 433 | !.vscode/extensions.json 434 | 435 | options.json -------------------------------------------------------------------------------- /src/wwwroot/css/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /** 4 | * 1. Change the default font family in all browsers (opinionated). 5 | * 2. Correct the line height in all browsers. 6 | * 3. Prevent adjustments of font size after orientation changes in 7 | * IE on Windows Phone and in iOS. 8 | */ 9 | 10 | /* Document 11 | ========================================================================== */ 12 | 13 | html { 14 | font-family: sans-serif; /* 1 */ 15 | line-height: 1.15; /* 2 */ 16 | -ms-text-size-adjust: 100%; /* 3 */ 17 | -webkit-text-size-adjust: 100%; /* 3 */ 18 | } 19 | 20 | /* Sections 21 | ========================================================================== */ 22 | 23 | /** 24 | * Remove the margin in all browsers (opinionated). 25 | */ 26 | 27 | body { 28 | margin: 0; 29 | } 30 | 31 | /** 32 | * Add the correct display in IE 9-. 33 | */ 34 | 35 | article, 36 | aside, 37 | footer, 38 | header, 39 | nav, 40 | section { 41 | display: block; 42 | } 43 | 44 | /** 45 | * Correct the font size and margin on `h1` elements within `section` and 46 | * `article` contexts in Chrome, Firefox, and Safari. 47 | */ 48 | 49 | h1 { 50 | font-size: 2em; 51 | margin: 0.67em 0; 52 | } 53 | 54 | /* Grouping content 55 | ========================================================================== */ 56 | 57 | /** 58 | * Add the correct display in IE 9-. 59 | * 1. Add the correct display in IE. 60 | */ 61 | 62 | figcaption, 63 | figure, 64 | main { /* 1 */ 65 | display: block; 66 | } 67 | 68 | /** 69 | * Add the correct margin in IE 8. 70 | */ 71 | 72 | figure { 73 | margin: 1em 40px; 74 | } 75 | 76 | /** 77 | * 1. Add the correct box sizing in Firefox. 78 | * 2. Show the overflow in Edge and IE. 79 | */ 80 | 81 | hr { 82 | box-sizing: content-box; /* 1 */ 83 | height: 0; /* 1 */ 84 | overflow: visible; /* 2 */ 85 | } 86 | 87 | /** 88 | * 1. Correct the inheritance and scaling of font size in all browsers. 89 | * 2. Correct the odd `em` font sizing in all browsers. 90 | */ 91 | 92 | pre { 93 | font-family: monospace, monospace; /* 1 */ 94 | font-size: 1em; /* 2 */ 95 | } 96 | 97 | /* Text-level semantics 98 | ========================================================================== */ 99 | 100 | /** 101 | * 1. Remove the gray background on active links in IE 10. 102 | * 2. Remove gaps in links underline in iOS 8+ and Safari 8+. 103 | */ 104 | 105 | a { 106 | background-color: transparent; /* 1 */ 107 | -webkit-text-decoration-skip: objects; /* 2 */ 108 | } 109 | 110 | /** 111 | * Remove the outline on focused links when they are also active or hovered 112 | * in all browsers (opinionated). 113 | */ 114 | 115 | a:active, 116 | a:hover { 117 | outline-width: 0; 118 | } 119 | 120 | /** 121 | * 1. Remove the bottom border in Firefox 39-. 122 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 123 | */ 124 | 125 | abbr[title] { 126 | border-bottom: none; /* 1 */ 127 | text-decoration: underline; /* 2 */ 128 | text-decoration: underline dotted; /* 2 */ 129 | } 130 | 131 | /** 132 | * Prevent the duplicate application of `bolder` by the next rule in Safari 6. 133 | */ 134 | 135 | b, 136 | strong { 137 | font-weight: inherit; 138 | } 139 | 140 | /** 141 | * Add the correct font weight in Chrome, Edge, and Safari. 142 | */ 143 | 144 | b, 145 | strong { 146 | font-weight: bolder; 147 | } 148 | 149 | /** 150 | * 1. Correct the inheritance and scaling of font size in all browsers. 151 | * 2. Correct the odd `em` font sizing in all browsers. 152 | */ 153 | 154 | code, 155 | kbd, 156 | samp { 157 | font-family: monospace, monospace; /* 1 */ 158 | font-size: 1em; /* 2 */ 159 | } 160 | 161 | /** 162 | * Add the correct font style in Android 4.3-. 163 | */ 164 | 165 | dfn { 166 | font-style: italic; 167 | } 168 | 169 | /** 170 | * Add the correct background and color in IE 9-. 171 | */ 172 | 173 | mark { 174 | background-color: #ff0; 175 | color: #000; 176 | } 177 | 178 | /** 179 | * Add the correct font size in all browsers. 180 | */ 181 | 182 | small { 183 | font-size: 80%; 184 | } 185 | 186 | /** 187 | * Prevent `sub` and `sup` elements from affecting the line height in 188 | * all browsers. 189 | */ 190 | 191 | sub, 192 | sup { 193 | font-size: 75%; 194 | line-height: 0; 195 | position: relative; 196 | vertical-align: baseline; 197 | } 198 | 199 | sub { 200 | bottom: -0.25em; 201 | } 202 | 203 | sup { 204 | top: -0.5em; 205 | } 206 | 207 | /* Embedded content 208 | ========================================================================== */ 209 | 210 | /** 211 | * Add the correct display in IE 9-. 212 | */ 213 | 214 | audio, 215 | video { 216 | display: inline-block; 217 | } 218 | 219 | /** 220 | * Add the correct display in iOS 4-7. 221 | */ 222 | 223 | audio:not([controls]) { 224 | display: none; 225 | height: 0; 226 | } 227 | 228 | /** 229 | * Remove the border on images inside links in IE 10-. 230 | */ 231 | 232 | img { 233 | border-style: none; 234 | } 235 | 236 | /** 237 | * Hide the overflow in IE. 238 | */ 239 | 240 | svg:not(:root) { 241 | overflow: hidden; 242 | } 243 | 244 | /* Forms 245 | ========================================================================== */ 246 | 247 | /** 248 | * 1. Change the font styles in all browsers (opinionated). 249 | * 2. Remove the margin in Firefox and Safari. 250 | */ 251 | 252 | button, 253 | input, 254 | optgroup, 255 | select, 256 | textarea { 257 | font-family: sans-serif; /* 1 */ 258 | font-size: 100%; /* 1 */ 259 | line-height: 1.15; /* 1 */ 260 | margin: 0; /* 2 */ 261 | } 262 | 263 | /** 264 | * Show the overflow in IE. 265 | * 1. Show the overflow in Edge. 266 | */ 267 | 268 | button, 269 | input { /* 1 */ 270 | overflow: visible; 271 | } 272 | 273 | /** 274 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 275 | * 1. Remove the inheritance of text transform in Firefox. 276 | */ 277 | 278 | button, 279 | select { /* 1 */ 280 | text-transform: none; 281 | } 282 | 283 | /** 284 | * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video` 285 | * controls in Android 4. 286 | * 2. Correct the inability to style clickable types in iOS and Safari. 287 | */ 288 | 289 | button, 290 | html [type="button"], /* 1 */ 291 | [type="reset"], 292 | [type="submit"] { 293 | -webkit-appearance: button; /* 2 */ 294 | } 295 | 296 | /** 297 | * Remove the inner border and padding in Firefox. 298 | */ 299 | 300 | button::-moz-focus-inner, 301 | [type="button"]::-moz-focus-inner, 302 | [type="reset"]::-moz-focus-inner, 303 | [type="submit"]::-moz-focus-inner { 304 | border-style: none; 305 | padding: 0; 306 | } 307 | 308 | /** 309 | * Restore the focus styles unset by the previous rule. 310 | */ 311 | 312 | button:-moz-focusring, 313 | [type="button"]:-moz-focusring, 314 | [type="reset"]:-moz-focusring, 315 | [type="submit"]:-moz-focusring { 316 | outline: 1px dotted ButtonText; 317 | } 318 | 319 | /** 320 | * Change the border, margin, and padding in all browsers (opinionated). 321 | */ 322 | 323 | fieldset { 324 | border: 1px solid #c0c0c0; 325 | margin: 0 2px; 326 | padding: 0.35em 0.625em 0.75em; 327 | } 328 | 329 | /** 330 | * 1. Correct the text wrapping in Edge and IE. 331 | * 2. Correct the color inheritance from `fieldset` elements in IE. 332 | * 3. Remove the padding so developers are not caught out when they zero out 333 | * `fieldset` elements in all browsers. 334 | */ 335 | 336 | legend { 337 | box-sizing: border-box; /* 1 */ 338 | color: inherit; /* 2 */ 339 | display: table; /* 1 */ 340 | max-width: 100%; /* 1 */ 341 | padding: 0; /* 3 */ 342 | white-space: normal; /* 1 */ 343 | } 344 | 345 | /** 346 | * 1. Add the correct display in IE 9-. 347 | * 2. Add the correct vertical alignment in Chrome, Firefox, and Opera. 348 | */ 349 | 350 | progress { 351 | display: inline-block; /* 1 */ 352 | vertical-align: baseline; /* 2 */ 353 | } 354 | 355 | /** 356 | * Remove the default vertical scrollbar in IE. 357 | */ 358 | 359 | textarea { 360 | overflow: auto; 361 | } 362 | 363 | /** 364 | * 1. Add the correct box sizing in IE 10-. 365 | * 2. Remove the padding in IE 10-. 366 | */ 367 | 368 | [type="checkbox"], 369 | [type="radio"] { 370 | box-sizing: border-box; /* 1 */ 371 | padding: 0; /* 2 */ 372 | } 373 | 374 | /** 375 | * Correct the cursor style of increment and decrement buttons in Chrome. 376 | */ 377 | 378 | [type="number"]::-webkit-inner-spin-button, 379 | [type="number"]::-webkit-outer-spin-button { 380 | height: auto; 381 | } 382 | 383 | /** 384 | * 1. Correct the odd appearance in Chrome and Safari. 385 | * 2. Correct the outline style in Safari. 386 | */ 387 | 388 | [type="search"] { 389 | -webkit-appearance: textfield; /* 1 */ 390 | outline-offset: -2px; /* 2 */ 391 | } 392 | 393 | /** 394 | * Remove the inner padding and cancel buttons in Chrome and Safari on macOS. 395 | */ 396 | 397 | [type="search"]::-webkit-search-cancel-button, 398 | [type="search"]::-webkit-search-decoration { 399 | -webkit-appearance: none; 400 | } 401 | 402 | /** 403 | * 1. Correct the inability to style clickable types in iOS and Safari. 404 | * 2. Change font properties to `inherit` in Safari. 405 | */ 406 | 407 | ::-webkit-file-upload-button { 408 | -webkit-appearance: button; /* 1 */ 409 | font: inherit; /* 2 */ 410 | } 411 | 412 | /* Interactive 413 | ========================================================================== */ 414 | 415 | /* 416 | * Add the correct display in IE 9-. 417 | * 1. Add the correct display in Edge, IE, and Firefox. 418 | */ 419 | 420 | details, /* 1 */ 421 | menu { 422 | display: block; 423 | } 424 | 425 | /* 426 | * Add the correct display in all browsers. 427 | */ 428 | 429 | summary { 430 | display: list-item; 431 | } 432 | 433 | /* Scripting 434 | ========================================================================== */ 435 | 436 | /** 437 | * Add the correct display in IE 9-. 438 | */ 439 | 440 | canvas { 441 | display: inline-block; 442 | } 443 | 444 | /** 445 | * Add the correct display in IE. 446 | */ 447 | 448 | template { 449 | display: none; 450 | } 451 | 452 | /* Hidden 453 | ========================================================================== */ 454 | 455 | /** 456 | * Add the correct display in IE 10-. 457 | */ 458 | 459 | [hidden] { 460 | display: none; 461 | } 462 | -------------------------------------------------------------------------------- /src/wwwroot/css/milligram.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Milligram v1.3.0 3 | * https://milligram.github.io 4 | * 5 | * Copyright (c) 2017 CJ Patoilo 6 | * Licensed under the MIT license 7 | */ 8 | *, 9 | *:after, 10 | *:before { 11 | box-sizing: inherit; 12 | } 13 | 14 | html { 15 | box-sizing: border-box; 16 | font-size: 62.5%; 17 | } 18 | 19 | body { 20 | color: #606c76; 21 | font-family: 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; 22 | font-size: 1.6em; 23 | font-weight: 300; 24 | letter-spacing: .01em; 25 | line-height: 1.6; 26 | } 27 | 28 | blockquote { 29 | border-left: 0.3rem solid #d1d1d1; 30 | margin-left: 0; 31 | margin-right: 0; 32 | padding: 1rem 1.5rem; 33 | } 34 | 35 | blockquote *:last-child { 36 | margin-bottom: 0; 37 | } 38 | 39 | .button, 40 | button, 41 | input[type='button'], 42 | input[type='reset'], 43 | input[type='submit'] { 44 | background-color: #9b4dca; 45 | border: 0.1rem solid #9b4dca; 46 | border-radius: .4rem; 47 | color: #fff; 48 | cursor: pointer; 49 | display: inline-block; 50 | font-size: 1.1rem; 51 | font-weight: 700; 52 | height: 3.8rem; 53 | letter-spacing: .1rem; 54 | line-height: 3.8rem; 55 | padding: 0 3.0rem; 56 | text-align: center; 57 | text-decoration: none; 58 | text-transform: uppercase; 59 | white-space: nowrap; 60 | } 61 | 62 | .button:focus, .button:hover, 63 | button:focus, 64 | button:hover, 65 | input[type='button']:focus, 66 | input[type='button']:hover, 67 | input[type='reset']:focus, 68 | input[type='reset']:hover, 69 | input[type='submit']:focus, 70 | input[type='submit']:hover { 71 | background-color: #606c76; 72 | border-color: #606c76; 73 | color: #fff; 74 | outline: 0; 75 | } 76 | 77 | .button[disabled], 78 | button[disabled], 79 | input[type='button'][disabled], 80 | input[type='reset'][disabled], 81 | input[type='submit'][disabled] { 82 | cursor: default; 83 | opacity: .5; 84 | } 85 | 86 | .button[disabled]:focus, .button[disabled]:hover, 87 | button[disabled]:focus, 88 | button[disabled]:hover, 89 | input[type='button'][disabled]:focus, 90 | input[type='button'][disabled]:hover, 91 | input[type='reset'][disabled]:focus, 92 | input[type='reset'][disabled]:hover, 93 | input[type='submit'][disabled]:focus, 94 | input[type='submit'][disabled]:hover { 95 | background-color: #9b4dca; 96 | border-color: #9b4dca; 97 | } 98 | 99 | .button.button-outline, 100 | button.button-outline, 101 | input[type='button'].button-outline, 102 | input[type='reset'].button-outline, 103 | input[type='submit'].button-outline { 104 | background-color: transparent; 105 | color: #9b4dca; 106 | } 107 | 108 | .button.button-outline:focus, .button.button-outline:hover, 109 | button.button-outline:focus, 110 | button.button-outline:hover, 111 | input[type='button'].button-outline:focus, 112 | input[type='button'].button-outline:hover, 113 | input[type='reset'].button-outline:focus, 114 | input[type='reset'].button-outline:hover, 115 | input[type='submit'].button-outline:focus, 116 | input[type='submit'].button-outline:hover { 117 | background-color: transparent; 118 | border-color: #606c76; 119 | color: #606c76; 120 | } 121 | 122 | .button.button-outline[disabled]:focus, .button.button-outline[disabled]:hover, 123 | button.button-outline[disabled]:focus, 124 | button.button-outline[disabled]:hover, 125 | input[type='button'].button-outline[disabled]:focus, 126 | input[type='button'].button-outline[disabled]:hover, 127 | input[type='reset'].button-outline[disabled]:focus, 128 | input[type='reset'].button-outline[disabled]:hover, 129 | input[type='submit'].button-outline[disabled]:focus, 130 | input[type='submit'].button-outline[disabled]:hover { 131 | border-color: inherit; 132 | color: #9b4dca; 133 | } 134 | 135 | .button.button-clear, 136 | button.button-clear, 137 | input[type='button'].button-clear, 138 | input[type='reset'].button-clear, 139 | input[type='submit'].button-clear { 140 | background-color: transparent; 141 | border-color: transparent; 142 | color: #9b4dca; 143 | } 144 | 145 | .button.button-clear:focus, .button.button-clear:hover, 146 | button.button-clear:focus, 147 | button.button-clear:hover, 148 | input[type='button'].button-clear:focus, 149 | input[type='button'].button-clear:hover, 150 | input[type='reset'].button-clear:focus, 151 | input[type='reset'].button-clear:hover, 152 | input[type='submit'].button-clear:focus, 153 | input[type='submit'].button-clear:hover { 154 | background-color: transparent; 155 | border-color: transparent; 156 | color: #606c76; 157 | } 158 | 159 | .button.button-clear[disabled]:focus, .button.button-clear[disabled]:hover, 160 | button.button-clear[disabled]:focus, 161 | button.button-clear[disabled]:hover, 162 | input[type='button'].button-clear[disabled]:focus, 163 | input[type='button'].button-clear[disabled]:hover, 164 | input[type='reset'].button-clear[disabled]:focus, 165 | input[type='reset'].button-clear[disabled]:hover, 166 | input[type='submit'].button-clear[disabled]:focus, 167 | input[type='submit'].button-clear[disabled]:hover { 168 | color: #9b4dca; 169 | } 170 | 171 | code { 172 | background: #f4f5f6; 173 | border-radius: .4rem; 174 | font-size: 86%; 175 | margin: 0 .2rem; 176 | padding: .2rem .5rem; 177 | white-space: nowrap; 178 | } 179 | 180 | pre { 181 | background: #f4f5f6; 182 | border-left: 0.3rem solid #9b4dca; 183 | overflow-y: hidden; 184 | } 185 | 186 | pre > code { 187 | border-radius: 0; 188 | display: block; 189 | padding: 1rem 1.5rem; 190 | white-space: pre; 191 | } 192 | 193 | hr { 194 | border: 0; 195 | border-top: 0.1rem solid #f4f5f6; 196 | margin: 3.0rem 0; 197 | } 198 | 199 | input[type='email'], 200 | input[type='number'], 201 | input[type='password'], 202 | input[type='search'], 203 | input[type='tel'], 204 | input[type='text'], 205 | input[type='url'], 206 | textarea, 207 | select { 208 | -webkit-appearance: none; 209 | -moz-appearance: none; 210 | appearance: none; 211 | background-color: transparent; 212 | border: 0.1rem solid #d1d1d1; 213 | border-radius: .4rem; 214 | box-shadow: none; 215 | box-sizing: inherit; 216 | height: 3.8rem; 217 | padding: .6rem 1.0rem; 218 | width: 100%; 219 | } 220 | 221 | input[type='email']:focus, 222 | input[type='number']:focus, 223 | input[type='password']:focus, 224 | input[type='search']:focus, 225 | input[type='tel']:focus, 226 | input[type='text']:focus, 227 | input[type='url']:focus, 228 | textarea:focus, 229 | select:focus { 230 | border-color: #9b4dca; 231 | outline: 0; 232 | } 233 | 234 | select { 235 | background: url('data:image/svg+xml;utf8,') center right no-repeat; 236 | padding-right: 3.0rem; 237 | } 238 | 239 | select:focus { 240 | background-image: url('data:image/svg+xml;utf8,'); 241 | } 242 | 243 | textarea { 244 | min-height: 6.5rem; 245 | } 246 | 247 | label, 248 | legend { 249 | display: block; 250 | font-size: 1.6rem; 251 | font-weight: 700; 252 | margin-bottom: .5rem; 253 | } 254 | 255 | fieldset { 256 | border-width: 0; 257 | padding: 0; 258 | } 259 | 260 | input[type='checkbox'], 261 | input[type='radio'] { 262 | display: inline; 263 | } 264 | 265 | .label-inline { 266 | display: inline-block; 267 | font-weight: normal; 268 | margin-left: .5rem; 269 | } 270 | 271 | .container { 272 | margin: 0 auto; 273 | max-width: 112.0rem; 274 | padding: 0 2.0rem; 275 | position: relative; 276 | width: 100%; 277 | } 278 | 279 | .row { 280 | display: flex; 281 | flex-direction: column; 282 | padding: 0; 283 | width: 100%; 284 | } 285 | 286 | .row.row-no-padding { 287 | padding: 0; 288 | } 289 | 290 | .row.row-no-padding > .column { 291 | padding: 0; 292 | } 293 | 294 | .row.row-wrap { 295 | flex-wrap: wrap; 296 | } 297 | 298 | .row.row-top { 299 | align-items: flex-start; 300 | } 301 | 302 | .row.row-bottom { 303 | align-items: flex-end; 304 | } 305 | 306 | .row.row-center { 307 | align-items: center; 308 | } 309 | 310 | .row.row-stretch { 311 | align-items: stretch; 312 | } 313 | 314 | .row.row-baseline { 315 | align-items: baseline; 316 | } 317 | 318 | .row .column { 319 | display: block; 320 | flex: 1 1 auto; 321 | margin-left: 0; 322 | max-width: 100%; 323 | width: 100%; 324 | } 325 | 326 | .row .column.column-offset-10 { 327 | margin-left: 10%; 328 | } 329 | 330 | .row .column.column-offset-20 { 331 | margin-left: 20%; 332 | } 333 | 334 | .row .column.column-offset-25 { 335 | margin-left: 25%; 336 | } 337 | 338 | .row .column.column-offset-33, .row .column.column-offset-34 { 339 | margin-left: 33.3333%; 340 | } 341 | 342 | .row .column.column-offset-50 { 343 | margin-left: 50%; 344 | } 345 | 346 | .row .column.column-offset-66, .row .column.column-offset-67 { 347 | margin-left: 66.6666%; 348 | } 349 | 350 | .row .column.column-offset-75 { 351 | margin-left: 75%; 352 | } 353 | 354 | .row .column.column-offset-80 { 355 | margin-left: 80%; 356 | } 357 | 358 | .row .column.column-offset-90 { 359 | margin-left: 90%; 360 | } 361 | 362 | .row .column.column-10 { 363 | flex: 0 0 10%; 364 | max-width: 10%; 365 | } 366 | 367 | .row .column.column-20 { 368 | flex: 0 0 20%; 369 | max-width: 20%; 370 | } 371 | 372 | .row .column.column-25 { 373 | flex: 0 0 25%; 374 | max-width: 25%; 375 | } 376 | 377 | .row .column.column-33, .row .column.column-34 { 378 | flex: 0 0 33.3333%; 379 | max-width: 33.3333%; 380 | } 381 | 382 | .row .column.column-40 { 383 | flex: 0 0 40%; 384 | max-width: 40%; 385 | } 386 | 387 | .row .column.column-50 { 388 | flex: 0 0 50%; 389 | max-width: 50%; 390 | } 391 | 392 | .row .column.column-60 { 393 | flex: 0 0 60%; 394 | max-width: 60%; 395 | } 396 | 397 | .row .column.column-66, .row .column.column-67 { 398 | flex: 0 0 66.6666%; 399 | max-width: 66.6666%; 400 | } 401 | 402 | .row .column.column-75 { 403 | flex: 0 0 75%; 404 | max-width: 75%; 405 | } 406 | 407 | .row .column.column-80 { 408 | flex: 0 0 80%; 409 | max-width: 80%; 410 | } 411 | 412 | .row .column.column-90 { 413 | flex: 0 0 90%; 414 | max-width: 90%; 415 | } 416 | 417 | .row .column .column-top { 418 | align-self: flex-start; 419 | } 420 | 421 | .row .column .column-bottom { 422 | align-self: flex-end; 423 | } 424 | 425 | .row .column .column-center { 426 | -ms-grid-row-align: center; 427 | align-self: center; 428 | } 429 | 430 | @media (min-width: 40rem) { 431 | .row { 432 | flex-direction: row; 433 | margin-left: -1.0rem; 434 | width: calc(100% + 2.0rem); 435 | } 436 | 437 | .row .column { 438 | margin-bottom: inherit; 439 | padding: 0 1.0rem; 440 | } 441 | } 442 | 443 | a { 444 | color: #9b4dca; 445 | text-decoration: none; 446 | } 447 | 448 | a:focus, a:hover { 449 | color: #606c76; 450 | } 451 | 452 | dl, 453 | ol, 454 | ul { 455 | list-style: none; 456 | margin-top: 0; 457 | /* padding-left: 0; */ 458 | } 459 | 460 | dl dl, 461 | dl ol, 462 | dl ul, 463 | ol dl, 464 | ol ol, 465 | ol ul, 466 | ul dl, 467 | ul ol, 468 | ul ul { 469 | font-size: 90%; 470 | margin: 1.5rem 0 1.5rem 3.0rem; 471 | } 472 | 473 | ol { 474 | list-style: decimal inside; 475 | } 476 | 477 | ul { 478 | list-style: circle inside; 479 | } 480 | 481 | .button, 482 | button, 483 | dd, 484 | dt, 485 | li { 486 | margin-bottom: 1.0rem; 487 | } 488 | 489 | fieldset, 490 | input, 491 | select, 492 | textarea { 493 | margin-bottom: 1.5rem; 494 | } 495 | 496 | blockquote, 497 | dl, 498 | figure, 499 | form, 500 | ol, 501 | p, 502 | pre, 503 | table, 504 | ul { 505 | margin-bottom: 2.5rem; 506 | } 507 | 508 | table { 509 | border-spacing: 0; 510 | width: 100%; 511 | } 512 | 513 | td, 514 | th { 515 | border-bottom: 0.1rem solid #e1e1e1; 516 | padding: 1.2rem 1.5rem; 517 | text-align: left; 518 | } 519 | 520 | td:first-child, 521 | th:first-child { 522 | padding-left: 0; 523 | } 524 | 525 | td:last-child, 526 | th:last-child { 527 | padding-right: 0; 528 | } 529 | 530 | b, 531 | strong { 532 | font-weight: bold; 533 | } 534 | 535 | p { 536 | margin-top: 0; 537 | } 538 | 539 | h1, 540 | h2, 541 | h3, 542 | h4, 543 | h5, 544 | h6 { 545 | font-weight: 300; 546 | letter-spacing: -.1rem; 547 | margin-bottom: 2.0rem; 548 | margin-top: 0; 549 | } 550 | 551 | h1 { 552 | font-size: 4.6rem; 553 | line-height: 1.2; 554 | } 555 | 556 | h2 { 557 | font-size: 3.6rem; 558 | line-height: 1.25; 559 | } 560 | 561 | h3 { 562 | font-size: 2.8rem; 563 | line-height: 1.3; 564 | } 565 | 566 | h4 { 567 | font-size: 2.2rem; 568 | letter-spacing: -.08rem; 569 | line-height: 1.35; 570 | } 571 | 572 | h5 { 573 | font-size: 1.8rem; 574 | letter-spacing: -.05rem; 575 | line-height: 1.5; 576 | } 577 | 578 | h6 { 579 | font-size: 1.6rem; 580 | letter-spacing: 0; 581 | line-height: 1.4; 582 | } 583 | 584 | img { 585 | max-width: 100%; 586 | } 587 | 588 | .clearfix:after { 589 | clear: both; 590 | content: ' '; 591 | display: table; 592 | } 593 | 594 | .float-left { 595 | float: left; 596 | } 597 | 598 | .float-right { 599 | float: right; 600 | } 601 | 602 | /*# sourceMappingURL=milligram.css.map */ --------------------------------------------------------------------------------