add actors to movies and shows (#172)

* add actor metadata

* show actors in ui

* get full movie/show metadata from plex

* store actor thumbnail url

* rework movie detail page

* metadata fixes

* rework show detail page

* rework artist page

* code cleanup
This commit is contained in:
Jason Dove
2021-04-17 09:36:57 -05:00
committed by GitHub
parent e368d4a075
commit d8d21996b4
63 changed files with 8078 additions and 402 deletions
+57 -33
View File
@@ -58,46 +58,58 @@
</div>
</div>
</div>
@if (_artist.Languages.Any())
{
<MudText GutterBottom="true">Languages</MudText>
<div class="mb-2">
@foreach (CultureInfo language in _artist.Languages.OrderBy(l => l.EnglishName))
<MudCard Class="mb-6">
<MudCardContent>
@if (_sortedLanguages.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@language.EnglishName" Class="mr-2 mb-2" Link="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Languages:&nbsp;</MudText>
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(_sortedLanguages.Head().EnglishName.ToLowerInvariant())}%22")">@_sortedLanguages.Head().EnglishName</MudLink>
@foreach (CultureInfo language in _sortedLanguages.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")">@language.EnglishName</MudLink>
}
</div>
}
</div>
}
@if (_artist.Genres.Any())
{
<MudText GutterBottom="true">Genres</MudText>
<div class="mb-2">
@foreach (string genre in _artist.Genres.OrderBy(g => g))
@if (_sortedGenres.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Genres:&nbsp;</MudText>
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(_sortedGenres.Head())}%22")">@_sortedGenres.Head()</MudLink>
@foreach (string genre in _sortedGenres.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre)}%22")">@genre</MudLink>
}
</div>
}
</div>
}
@if (_artist.Styles.Any())
{
<MudText GutterBottom="true">Styles</MudText>
<div class="mb-2">
@foreach (string style in _artist.Styles.OrderBy(g => g))
@if (_sortedStyles.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@style" Class="mr-2 mb-2" Link="@($"/search?query=style%3a%22{Uri.EscapeDataString(style.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Styles:&nbsp;</MudText>
<MudLink Href="@($"/search?query=style%3a%22{Uri.EscapeDataString(_sortedStyles.Head())}%22")">@_sortedStyles.Head()</MudLink>
@foreach (string style in _sortedStyles.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=style%3a%22{Uri.EscapeDataString(style)}%22")">@style</MudLink>
}
</div>
}
</div>
}
@if (_artist.Moods.Any())
{
<MudText GutterBottom="true">Moods</MudText>
<div class="mb-2">
@foreach (string mood in _artist.Moods.OrderBy(g => g))
@if (_sortedMoods.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@mood" Class="mr-2 mb-2" Link="@($"/search?query=mood%3a%22{Uri.EscapeDataString(mood.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Moods:&nbsp;</MudText>
<MudLink Href="@($"/search?query=mood%3a%22{Uri.EscapeDataString(_sortedMoods.Head())}%22")">@_sortedMoods.Head()</MudLink>
@foreach (string mood in _sortedMoods.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=mood%3a%22{Uri.EscapeDataString(mood)}%22")">@mood</MudLink>
}
</div>
}
</div>
}
</MudCardContent>
</MudCard>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
@foreach (MusicVideoCardViewModel musicVideo in _musicVideos.Cards)
@@ -139,13 +151,25 @@
public int ArtistId { get; set; }
private ArtistViewModel _artist;
private List<CultureInfo> _sortedLanguages = new();
private List<string> _sortedGenres = new();
private List<string> _sortedStyles = new();
private List<string> _sortedMoods = new();
private MusicVideoCardResultsViewModel _musicVideos;
protected override Task OnParametersSetAsync() => RefreshData();
private async Task RefreshData()
{
await Mediator.Send(new GetArtistById(ArtistId)).IfSomeAsync(vm => _artist = vm);
await Mediator.Send(new GetArtistById(ArtistId)).IfSomeAsync(vm =>
{
_artist = vm;
_sortedLanguages = _artist.Languages.OrderBy(ci => ci.EnglishName).ToList();
_sortedGenres = _artist.Genres.OrderBy(g => g).ToList();
_sortedStyles = _artist.Styles.OrderBy(s => s).ToList();
_sortedMoods = _artist.Moods.OrderBy(m => m).ToList();
});
_musicVideos = await Mediator.Send(new GetMusicVideoCards(ArtistId, 1, 100));
}
+4 -7
View File
@@ -1,10 +1,10 @@
@page "/media/collections"
@using ErsatzTV.Application.Configuration.Commands
@using ErsatzTV.Application.Configuration.Queries
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.MediaCollections.Queries
@using ErsatzTV.Application.Configuration.Queries
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.Configuration.Commands
@inject IDialogService Dialog
@inject IMediator Mediator
@@ -56,11 +56,8 @@
private int _rowsPerPage;
protected override async Task OnParametersSetAsync()
{
_rowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.CollectionsPageSize))
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
}
protected override async Task OnParametersSetAsync() => _rowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.CollectionsPageSize))
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
private async Task DeleteMediaCollection(MediaCardViewModel vm)
{
+2 -2
View File
@@ -1,7 +1,7 @@
@page "/"
@using Microsoft.Extensions.Caching.Memory
@using System.Reflection
@using ErsatzTV.Core.Interfaces.GitHub
@using Microsoft.Extensions.Caching.Memory
@inject IGitHubApiClient _gitHubApiClient
@inject IMemoryCache _memoryCache
@@ -60,7 +60,7 @@
}
}
}
catch (Exception ex)
catch (Exception _)
{
// ignore
}
+72 -33
View File
@@ -2,6 +2,7 @@
@using ErsatzTV.Application.Movies
@using ErsatzTV.Application.Movies.Queries
@using System.Globalization
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@inject IMediator Mediator
@@ -44,47 +45,74 @@
</div>
</div>
</div>
@if (_movie.Languages.Any())
{
<MudText GutterBottom="true">Languages</MudText>
<div class="mb-2">
@foreach (CultureInfo language in _movie.Languages.OrderBy(l => l.EnglishName))
<MudCard Class="mb-6">
<MudCardContent>
@if (_sortedLanguages.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@language.EnglishName" Class="mr-2 mb-2" Link="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Languages:&nbsp;</MudText>
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(_sortedLanguages.Head().EnglishName.ToLowerInvariant())}%22")">@_sortedLanguages.Head().EnglishName</MudLink>
@foreach (CultureInfo language in _sortedLanguages.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")">@language.EnglishName</MudLink>
}
</div>
}
</div>
}
@if (_movie.Studios.Any())
{
<MudText GutterBottom="true">Studios</MudText>
<div class="mb-2">
@foreach (string studio in _movie.Studios.OrderBy(s => s))
@if (_sortedStudios.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@studio" Class="mr-2 mb-2" Link="@($"/search?query=studio%3a%22{Uri.EscapeDataString(studio.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Studios:&nbsp;</MudText>
<MudLink Href="@($"/search?query=studio%3a%22{Uri.EscapeDataString(_sortedStudios.Head())}%22")">@_sortedStudios.Head()</MudLink>
@foreach (string studio in _sortedStudios.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=studio%3a%22{Uri.EscapeDataString(studio)}%22")">@studio</MudLink>
}
</div>
}
</div>
}
@if (_movie.Genres.Any())
{
<MudText GutterBottom="true">Genres</MudText>
<div class="mb-2">
@foreach (string genre in _movie.Genres.OrderBy(g => g))
@if (_sortedGenres.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Genres:&nbsp;</MudText>
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(_sortedGenres.Head())}%22")">@_sortedGenres.Head()</MudLink>
@foreach (string genre in _sortedGenres.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre)}%22")">@genre</MudLink>
}
</div>
}
</div>
}
@if (_movie.Tags.Any())
{
<MudText GutterBottom="true">Tags</MudText>
<div>
@foreach (string tag in _movie.Tags.OrderBy(t => t))
@if (_sortedTags.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a%22{Uri.EscapeDataString(tag.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Tags:&nbsp;</MudText>
<MudLink Href="@($"/search?query=tag%3a%22{Uri.EscapeDataString(_sortedTags.Head())}%22")">@_sortedTags.Head()</MudLink>
@foreach (string tag in _sortedTags.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=tag%3a%22{Uri.EscapeDataString(tag)}%22")">@tag</MudLink>
}
</div>
}
</div>
}
</MudCardContent>
</MudCard>
</MudContainer>
@if (_movie.Actors.Any())
{
<MudContainer MaxWidth="MaxWidth.Large">
<MudText Class="mb-4">Actors</MudText>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Class="media-card-grid">
@foreach (ActorCardViewModel actor in _movie.Actors)
{
<MediaCard Data="@actor"
Link="@($"/search?query=actor%3a%22{Uri.EscapeDataString(actor.Name.ToLowerInvariant())}%22")"
IsRemoteArtwork="true"
ArtworkKind="ArtworkKind.Thumbnail"/>
}
</MudContainer>
}
@code {
@@ -92,11 +120,22 @@
public int MovieId { get; set; }
private MovieViewModel _movie;
private List<CultureInfo> _sortedLanguages = new();
private List<string> _sortedStudios = new();
private List<string> _sortedGenres = new();
private List<string> _sortedTags = new();
protected override Task OnParametersSetAsync() => RefreshData();
private Task RefreshData() =>
Mediator.Send(new GetMovieById(MovieId)).IfSomeAsync(vm => _movie = vm);
Mediator.Send(new GetMovieById(MovieId)).IfSomeAsync(vm =>
{
_movie = vm;
_sortedLanguages = _movie.Languages.OrderBy(ci => ci.EnglishName).ToList();
_sortedStudios = _movie.Studios.OrderBy(s => s).ToList();
_sortedGenres = _movie.Genres.OrderBy(g => g).ToList();
_sortedTags = _movie.Tags.OrderBy(t => t).ToList();
});
private async Task AddToCollection()
{
+75 -34
View File
@@ -59,48 +59,63 @@
</div>
</div>
</div>
@if (_show.Languages.Any())
{
<MudText GutterBottom="true">Languages</MudText>
<div class="mb-2">
@foreach (CultureInfo language in _show.Languages.OrderBy(l => l.EnglishName))
<MudCard Class="mb-6">
<MudCardContent>
@if (_sortedLanguages.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@language.EnglishName" Class="mr-2 mb-2" Link="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Languages:&nbsp;</MudText>
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(_sortedLanguages.Head().EnglishName.ToLowerInvariant())}%22")">@_sortedLanguages.Head().EnglishName</MudLink>
@foreach (CultureInfo language in _sortedLanguages.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")">@language.EnglishName</MudLink>
}
</div>
}
</div>
}
@if (_show.Studios.Any())
{
<MudText GutterBottom="true">Studios</MudText>
<div class="mb-2">
@foreach (string studio in _show.Studios.OrderBy(g => g))
@if (_sortedStudios.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@studio" Class="mr-2 mb-2" Link="@($"/search?query=studio%3a%22{Uri.EscapeDataString(studio.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Studios:&nbsp;</MudText>
<MudLink Href="@($"/search?query=studio%3a%22{Uri.EscapeDataString(_sortedStudios.Head())}%22")">@_sortedStudios.Head()</MudLink>
@foreach (string studio in _sortedStudios.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=studio%3a%22{Uri.EscapeDataString(studio)}%22")">@studio</MudLink>
}
</div>
}
</div>
}
@if (_show.Genres.Any())
{
<MudText GutterBottom="true">Genres</MudText>
<div class="mb-2">
@foreach (string genre in _show.Genres.OrderBy(g => g))
@if (_sortedGenres.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Genres:&nbsp;</MudText>
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(_sortedGenres.Head())}%22")">@_sortedGenres.Head()</MudLink>
@foreach (string genre in _sortedGenres.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre)}%22")">@genre</MudLink>
}
</div>
}
</div>
}
@if (_show.Tags.Any())
{
<MudText GutterBottom="true">Tags</MudText>
<div>
@foreach (string tag in _show.Tags.OrderBy(t => t))
@if (_sortedTags.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a%22{Uri.EscapeDataString(tag.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Tags:&nbsp;</MudText>
<MudLink Href="@($"/search?query=tag%3a%22{Uri.EscapeDataString(_sortedTags.Head())}%22")">@_sortedTags.Head()</MudLink>
@foreach (string tag in _sortedTags.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=tag%3a%22{Uri.EscapeDataString(tag)}%22")">@tag</MudLink>
}
</div>
}
</div>
}
</MudCardContent>
</MudCard>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Class="media-card-grid mt-8">
<MudContainer MaxWidth="MaxWidth.Large">
<MudText Class="mb-4">Seasons</MudText>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Class="media-card-grid">
@foreach (TelevisionSeasonCardViewModel card in _data.Cards)
{
<MediaCard Data="@card" Placeholder="@card.Placeholder"
@@ -108,6 +123,21 @@
AddToCollectionClicked="@AddSeasonToCollection"/>
}
</MudContainer>
@if (_show.Actors.Any())
{
<MudContainer MaxWidth="MaxWidth.Large">
<MudText Class="mb-4">Actors</MudText>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Class="media-card-grid">
@foreach (ActorCardViewModel actor in _show.Actors)
{
<MediaCard Data="@actor"
Link="@($"/search?query=actor%3a%22{Uri.EscapeDataString(actor.Name.ToLowerInvariant())}%22")"
IsRemoteArtwork="true"
ArtworkKind="ArtworkKind.Thumbnail"/>
}
</MudContainer>
}
@code {
@@ -115,6 +145,10 @@
public int ShowId { get; set; }
private TelevisionShowViewModel _show;
private List<CultureInfo> _sortedLanguages = new();
private List<string> _sortedStudios = new();
private List<string> _sortedGenres = new();
private List<string> _sortedTags = new();
private int _pageSize => 100;
private readonly int _pageNumber = 1;
@@ -126,7 +160,14 @@
private async Task RefreshData()
{
await Mediator.Send(new GetTelevisionShowById(ShowId))
.IfSomeAsync(vm => _show = vm);
.IfSomeAsync(vm =>
{
_show = vm;
_sortedLanguages = _show.Languages.OrderBy(ci => ci.EnglishName).ToList();
_sortedStudios = _show.Studios.OrderBy(s => s).ToList();
_sortedGenres = _show.Genres.OrderBy(g => g).ToList();
_sortedTags = _show.Tags.OrderBy(t => t).ToList();
});
_data = await Mediator.Send(new GetTelevisionSeasonCards(ShowId, _pageNumber, _pageSize));
}
+1 -1
View File
@@ -43,7 +43,7 @@
function enableSorting() {
$("#sortable-collection").sortable("option", "disabled", false);
}
function styleMarkdown() {
$("h2").addClass("mud-typography mud-typography-h4");
$("h3").addClass("mud-typography mud-typography-h5");
+4 -3
View File
@@ -61,9 +61,10 @@
private List<MediaCollectionViewModel> _collections;
private MediaCollectionViewModel _selectedCollection;
private record DummyModel;
private DummyModel _dummyModel = new();
private readonly DummyModel _dummyModel = new();
private bool CanSubmit() =>
_selectedCollection != null && (_selectedCollection != _newCollection || !string.IsNullOrWhiteSpace(_newCollectionName));
@@ -119,7 +120,7 @@
private async Task Cancel(MouseEventArgs e)
{
// this is gross, but [enter] seems to sometimes trigger cancel instead of submit
// this is gross, but [enter] seems to sometimes trigger cancel instead of submit
if (e.Detail == 0)
{
await Submit();
+1 -1
View File
@@ -56,7 +56,7 @@
private void Submit() => MudDialog.Close(DialogResult.Ok(_selectedSchedule));
private void Cancel() => MudDialog.Cancel();
private void OnKeyDown(KeyboardEventArgs e)
{
if (e.Code is "Enter" or "NumpadEnter")
+1 -1
View File
@@ -46,7 +46,7 @@
private void Submit() => MudDialog.Close(DialogResult.Ok(true));
private void Cancel() => MudDialog.Cancel();
private void OnKeyDown(KeyboardEventArgs e)
{
if (e.Code is "Enter" or "NumpadEnter")
+1 -1
View File
@@ -76,7 +76,7 @@
private record SearchModel;
private SearchModel _dummyModel = new();
private readonly SearchModel _dummyModel = new();
private MudTheme _ersatzTvTheme => new()
{
+14 -3
View File
@@ -119,6 +119,9 @@
[Parameter]
public Color SelectColor { get; set; } = Color.Tertiary;
[Parameter]
public bool IsRemoteArtwork { get; set; }
private string GetPlaceholder(string sortTitle)
{
if (Placeholder != null)
@@ -130,9 +133,17 @@
return char.IsDigit(first) || !char.IsLetter(first) ? "#" : first.ToString();
}
private string ArtworkForItem() => string.IsNullOrWhiteSpace(Data.Poster)
? "position: relative"
: $"position: relative; background-image: url(/artwork/{PathForArtwork()}/{Data.Poster}); background-size: cover; background-position: center";
private string ArtworkForItem()
{
if (IsRemoteArtwork)
{
return $"position: relative; background-image: url({Data.Poster}); background-size: cover; background-position: center";
}
return string.IsNullOrWhiteSpace(Data.Poster)
? "position: relative"
: $"position: relative; background-image: url(/artwork/{PathForArtwork()}/{Data.Poster}); background-size: cover; background-position: center";
}
private string PathForArtwork() => ArtworkKind switch
{
@@ -41,7 +41,7 @@
private void Submit() => MudDialog.Close(DialogResult.Ok(true));
private void Cancel() => MudDialog.Cancel();
private void OnKeyDown(KeyboardEventArgs e)
{
if (e.Code is "Enter" or "NumpadEnter")
@@ -30,4 +30,5 @@
Submit();
}
}
}
+5 -15
View File
@@ -74,9 +74,7 @@
border-radius: 4px;
}
.app-bar form {
flex-grow: 1;
}
.app-bar form { flex-grow: 1; }
.fanart-container {
position: relative;
@@ -126,18 +124,10 @@
justify-content: space-around;
}
.release-notes ul {
list-style: unset;
}
.release-notes ul { list-style: unset; }
.release-notes > ul {
margin-top: 10px;
}
.release-notes > ul { margin-top: 10px; }
.release-notes ul > li {
margin-left: 30px;
}
.release-notes ul > li { margin-left: 30px; }
.release-notes h3 {
margin-top: 20px;
}
.release-notes h3 { margin-top: 20px; }