Browse Source

globalization fixes (#2014)

* fix crashes caused by decimal separator

* improvements to playout reset ui

* remove code quality workflow
pull/2016/head
Jason Dove 1 year ago committed by GitHub
parent
commit
956734ce39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 23
      .github/workflows/code_quality.yml
  2. 3
      CHANGELOG.md
  3. 4
      ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs
  4. 4
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs
  5. 5
      ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs
  6. 5
      ErsatzTV.Core/Notifications/PlayoutUpdatedNotification.cs
  7. 13
      ErsatzTV.Infrastructure/Locking/EntityLocker.cs
  8. 4
      ErsatzTV/Pages/Channels.razor
  9. 3
      ErsatzTV/Pages/PlayoutEditor.razor
  10. 56
      ErsatzTV/Pages/Playouts.razor

23
.github/workflows/code_quality.yml

@ -1,23 +0,0 @@
name: Qodana
on:
workflow_dispatch:
push:
branches:
- main
jobs:
qodana:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
checks: write
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit
fetch-depth: 0 # a full history is required for pull request analysis
- name: 'Qodana Scan'
uses: JetBrains/qodana-action@v2024.1
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}

3
CHANGELOG.md

@ -20,6 +20,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Fixed ### Fixed
- Fix error message about synchronizing Plex collections from a Plex server that has zero collections - Fix error message about synchronizing Plex collections from a Plex server that has zero collections
- Fix navigation after form submission when using `ETV_BASE_URL` environment variable - Fix navigation after form submission when using `ETV_BASE_URL` environment variable
- Fix UI crashes when channel numbers contain a period `.` in locales that have a different decimal separator (e.g. `,`)
- Fix playout detail table to only reload once when resetting a playout
- Fix date formatting in playout detail table on reload (will now respect browser's `Accept-Language` header)
## [25.1.0] - 2025-01-10 ## [25.1.0] - 2025-01-10
### Added ### Added

4
ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs

@ -69,7 +69,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
{ {
try try
{ {
_entityLocker.LockPlayout(playout.Id); await _entityLocker.LockPlayout(playout.Id);
switch (playout.ProgramSchedulePlayoutType) switch (playout.ProgramSchedulePlayoutType)
{ {
@ -135,7 +135,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
} }
finally finally
{ {
_entityLocker.UnlockPlayout(playout.Id); await _entityLocker.UnlockPlayout(playout.Id);
} }
return Unit.Default; return Unit.Default;

4
ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs

@ -135,7 +135,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
foreach (int playoutId in playoutIdsToCheck) foreach (int playoutId in playoutIdsToCheck)
{ {
_entityLocker.LockPlayout(playoutId); await _entityLocker.LockPlayout(playoutId);
} }
_logger.LogDebug("Checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck); _logger.LogDebug("Checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck);
@ -193,7 +193,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
foreach (int playoutId in playoutIdsToCheck) foreach (int playoutId in playoutIdsToCheck)
{ {
_entityLocker.UnlockPlayout(playoutId); await _entityLocker.UnlockPlayout(playoutId);
} }
} }
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)

5
ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs

@ -9,7 +9,6 @@ public interface IEntityLocker
event EventHandler OnEmbyCollectionsChanged; event EventHandler OnEmbyCollectionsChanged;
event EventHandler OnJellyfinCollectionsChanged; event EventHandler OnJellyfinCollectionsChanged;
event EventHandler OnPlexCollectionsChanged; event EventHandler OnPlexCollectionsChanged;
event EventHandler<int> OnPlayoutChanged;
bool LockLibrary(int libraryId); bool LockLibrary(int libraryId);
bool UnlockLibrary(int libraryId); bool UnlockLibrary(int libraryId);
bool IsLibraryLocked(int libraryId); bool IsLibraryLocked(int libraryId);
@ -31,7 +30,7 @@ public interface IEntityLocker
bool LockPlexCollections(); bool LockPlexCollections();
bool UnlockPlexCollections(); bool UnlockPlexCollections();
bool ArePlexCollectionsLocked(); bool ArePlexCollectionsLocked();
bool LockPlayout(int playoutId); Task<bool> LockPlayout(int playoutId);
bool UnlockPlayout(int playoutId); Task<bool> UnlockPlayout(int playoutId);
bool IsPlayoutLocked(int playoutId); bool IsPlayoutLocked(int playoutId);
} }

5
ErsatzTV.Core/Notifications/PlayoutUpdatedNotification.cs

@ -0,0 +1,5 @@
using MediatR;
namespace ErsatzTV.Core.Notifications;
public record PlayoutUpdatedNotification(int PlayoutId, bool IsLocked) : INotification;

13
ErsatzTV.Infrastructure/Locking/EntityLocker.cs

@ -1,9 +1,11 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Notifications;
using MediatR;
namespace ErsatzTV.Infrastructure.Locking; namespace ErsatzTV.Infrastructure.Locking;
public class EntityLocker : IEntityLocker public class EntityLocker(IMediator mediator) : IEntityLocker
{ {
private readonly ConcurrentDictionary<int, byte> _lockedLibraries = new(); private readonly ConcurrentDictionary<int, byte> _lockedLibraries = new();
private readonly ConcurrentDictionary<int, byte> _lockedPlayouts = new(); private readonly ConcurrentDictionary<int, byte> _lockedPlayouts = new();
@ -21,7 +23,6 @@ public class EntityLocker : IEntityLocker
public event EventHandler OnEmbyCollectionsChanged; public event EventHandler OnEmbyCollectionsChanged;
public event EventHandler OnJellyfinCollectionsChanged; public event EventHandler OnJellyfinCollectionsChanged;
public event EventHandler OnPlexCollectionsChanged; public event EventHandler OnPlexCollectionsChanged;
public event EventHandler<int> OnPlayoutChanged;
public bool LockLibrary(int libraryId) public bool LockLibrary(int libraryId)
{ {
@ -208,22 +209,22 @@ public class EntityLocker : IEntityLocker
public bool ArePlexCollectionsLocked() => _plexCollections; public bool ArePlexCollectionsLocked() => _plexCollections;
public bool LockPlayout(int playoutId) public async Task<bool> LockPlayout(int playoutId)
{ {
if (!_lockedPlayouts.ContainsKey(playoutId) && _lockedPlayouts.TryAdd(playoutId, 0)) if (!_lockedPlayouts.ContainsKey(playoutId) && _lockedPlayouts.TryAdd(playoutId, 0))
{ {
OnPlayoutChanged?.Invoke(this, playoutId); await mediator.Publish(new PlayoutUpdatedNotification(playoutId, true));
return true; return true;
} }
return false; return false;
} }
public bool UnlockPlayout(int playoutId) public async Task<bool> UnlockPlayout(int playoutId)
{ {
if (_lockedPlayouts.TryRemove(playoutId, out byte _)) if (_lockedPlayouts.TryRemove(playoutId, out byte _))
{ {
OnPlayoutChanged?.Invoke(this, playoutId); await mediator.Publish(new PlayoutUpdatedNotification(playoutId, false));
return true; return true;
} }

4
ErsatzTV/Pages/Channels.razor

@ -29,7 +29,7 @@
</ColGroup> </ColGroup>
<HeaderContent> <HeaderContent>
<MudTh> <MudTh>
<MudTableSortLabel InitialDirection="SortDirection.Ascending" SortBy="new Func<ChannelViewModel, object>(x => decimal.Parse(x.Number))">Number</MudTableSortLabel> <MudTableSortLabel InitialDirection="SortDirection.Ascending" SortBy="new Func<ChannelViewModel, object>(x => decimal.Parse(x.Number, CultureInfo.InvariantCulture))">Number</MudTableSortLabel>
</MudTh> </MudTh>
<MudTh>Logo</MudTh> <MudTh>Logo</MudTh>
<MudTh> <MudTh>
@ -219,7 +219,7 @@
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.ChannelsPageSize, state.PageSize.ToString()), _cts.Token); await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.ChannelsPageSize, state.PageSize.ToString()), _cts.Token);
List<ChannelViewModel> channels = await Mediator.Send(new GetAllChannels(), _cts.Token); List<ChannelViewModel> channels = await Mediator.Send(new GetAllChannels(), _cts.Token);
IOrderedEnumerable<ChannelViewModel> sorted = channels.OrderBy(c => decimal.Parse(c.Number)); IOrderedEnumerable<ChannelViewModel> sorted = channels.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture));
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures); CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
var processedChannels = new List<ChannelViewModel>(); var processedChannels = new List<ChannelViewModel>();

3
ErsatzTV/Pages/PlayoutEditor.razor

@ -1,5 +1,6 @@
@page "/playouts/add" @page "/playouts/add"
@page "/playouts/add/{kind}" @page "/playouts/add/{kind}"
@using System.Globalization
@using ErsatzTV.Application.Channels @using ErsatzTV.Application.Channels
@using ErsatzTV.Application.ProgramSchedules @using ErsatzTV.Application.ProgramSchedules
@implements IDisposable @implements IDisposable
@ -99,7 +100,7 @@
_model.Kind = Kind; _model.Kind = Kind;
_channels = await Mediator.Send(new GetAllChannels(), _cts.Token) _channels = await Mediator.Send(new GetAllChannels(), _cts.Token)
.Map(list => list.OrderBy(vm => decimal.Parse(vm.Number)).ToList()); .Map(list => list.OrderBy(vm => decimal.Parse(vm.Number, CultureInfo.InvariantCulture)).ToList());
if (string.IsNullOrWhiteSpace(Kind)) if (string.IsNullOrWhiteSpace(Kind))
{ {

56
ErsatzTV/Pages/Playouts.razor

@ -1,12 +1,16 @@
@page "/playouts" @page "/playouts"
@using System.Globalization
@using ErsatzTV.Application.Configuration @using ErsatzTV.Application.Configuration
@using ErsatzTV.Application.Playouts @using ErsatzTV.Application.Playouts
@using ErsatzTV.Core.Scheduling @using ErsatzTV.Core.Scheduling
@using ErsatzTV.Core.Notifications
@using MediatR.Courier
@implements IDisposable @implements IDisposable
@inject IDialogService Dialog @inject IDialogService Dialog
@inject IMediator Mediator @inject IMediator Mediator
@inject ChannelWriter<IBackgroundServiceRequest> WorkerChannel @inject ChannelWriter<IBackgroundServiceRequest> WorkerChannel
@inject IEntityLocker EntityLocker; @inject IEntityLocker EntityLocker;
@inject ICourier Courier;
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<div> <div>
@ -50,7 +54,7 @@
</ColGroup> </ColGroup>
<HeaderContent> <HeaderContent>
<MudTh> <MudTh>
<MudTableSortLabel SortBy="new Func<PlayoutViewModel, object>(x => decimal.Parse(x.Channel.Number))"> <MudTableSortLabel SortBy="new Func<PlayoutViewModel, object>(x => decimal.Parse(x.Channel.Number, CultureInfo.InvariantCulture))">
Channel Channel
</MudTableSortLabel> </MudTableSortLabel>
</MudTh> </MudTh>
@ -199,8 +203,8 @@
<MudTh>Duration</MudTh> <MudTh>Duration</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Start">@context.Start.ToString("G")</MudTd> <MudTd DataLabel="Start">@context.Start.ToString("G", _dtf)</MudTd>
<MudTd DataLabel="Finish">@context.Finish.ToString("G")</MudTd> <MudTd DataLabel="Finish">@context.Finish.ToString("G", _dtf)</MudTd>
<MudTd DataLabel="Media Item">@context.Title</MudTd> <MudTd DataLabel="Media Item">@context.Title</MudTd>
<MudTd DataLabel="Duration">@context.Duration</MudTd> <MudTd DataLabel="Duration">@context.Duration</MudTd>
</RowTemplate> </RowTemplate>
@ -214,6 +218,8 @@
@code { @code {
private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _cts = new();
private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentUICulture.DateTimeFormat;
private MudTable<PlayoutNameViewModel> _table; private MudTable<PlayoutNameViewModel> _table;
private MudTable<PlayoutItemViewModel> _detailTable; private MudTable<PlayoutItemViewModel> _detailTable;
private int _rowsPerPage = 10; private int _rowsPerPage = 10;
@ -237,15 +243,30 @@
} }
} }
public void Dispose() protected override void OnInitialized()
{ {
EntityLocker.OnPlayoutChanged -= ReloadDetailsIfNeeded; Courier.Subscribe<PlayoutUpdatedNotification>(HandlePlayoutUpdated);
}
public void Dispose()
{
_cts.Cancel(); _cts.Cancel();
_cts.Dispose(); _cts.Dispose();
} }
protected override void OnInitialized() => EntityLocker.OnPlayoutChanged += ReloadDetailsIfNeeded; public async Task HandlePlayoutUpdated(PlayoutUpdatedNotification notification, CancellationToken cancellationToken)
{
// only refresh detail table on unlock operations (after playout has been modified)
if (notification.IsLocked == false)
{
if (notification.PlayoutId == _selectedPlayoutId && _detailTable is not null)
{
await InvokeAsync(() => _detailTable.ReloadServerData());
}
}
await InvokeAsync(StateHasChanged);
}
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
@ -320,15 +341,6 @@
private async Task ResetPlayout(PlayoutNameViewModel playout) private async Task ResetPlayout(PlayoutNameViewModel playout)
{ {
await WorkerChannel.WriteAsync(new BuildPlayout(playout.PlayoutId, PlayoutBuildMode.Reset), _cts.Token); await WorkerChannel.WriteAsync(new BuildPlayout(playout.PlayoutId, PlayoutBuildMode.Reset), _cts.Token);
if (_table != null)
{
await _table.ReloadServerData();
}
if (_selectedPlayoutId == playout.PlayoutId)
{
await PlayoutSelected(playout);
}
} }
private async Task ScheduleReset(PlayoutNameViewModel playout) private async Task ScheduleReset(PlayoutNameViewModel playout)
@ -356,7 +368,7 @@
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsPageSize, state.PageSize.ToString()), _cts.Token); await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsPageSize, state.PageSize.ToString()), _cts.Token);
List<PlayoutNameViewModel> playouts = await Mediator.Send(new GetAllPlayouts(), _cts.Token); List<PlayoutNameViewModel> playouts = await Mediator.Send(new GetAllPlayouts(), _cts.Token);
IOrderedEnumerable<PlayoutNameViewModel> sorted = playouts.OrderBy(p => decimal.Parse(p.ChannelNumber)); IOrderedEnumerable<PlayoutNameViewModel> sorted = playouts.OrderBy(p => decimal.Parse(p.ChannelNumber, CultureInfo.InvariantCulture));
// TODO: properly page this data // TODO: properly page this data
return new TableData<PlayoutNameViewModel> return new TableData<PlayoutNameViewModel>
@ -366,16 +378,6 @@
}; };
} }
private async void ReloadDetailsIfNeeded(object sender, int playoutId)
{
if (playoutId == _selectedPlayoutId && _detailTable is not null)
{
await InvokeAsync(() => _detailTable.ReloadServerData());
}
await InvokeAsync(StateHasChanged);
}
private async Task<TableData<PlayoutItemViewModel>> DetailServerReload(TableState state, CancellationToken cancellationToken) private async Task<TableData<PlayoutItemViewModel>> DetailServerReload(TableState state, CancellationToken cancellationToken)
{ {
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsDetailPageSize, state.PageSize.ToString()), _cts.Token); await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsDetailPageSize, state.PageSize.ToString()), _cts.Token);

Loading…
Cancel
Save