├── 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 |
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 */
--------------------------------------------------------------------------------