Compare commits

...
Author SHA1 Message Date
Jason Dove 0ecec3cb07 include hidden plex libraries 2021-03-16 20:40:50 -05:00
Jason DoveandGitHub a8e861abc0 add optional ffmpeg reports (#81)
* log full exceptions in plex tv api client

* add optional ffmpeg reports
2021-03-17 01:22:09 +00:00
Jason DoveandGitHub 76446e0d69 prevent repeated playout items when reshuffling (#80) 2021-03-15 11:28:07 +00:00
Jason Dove c6d90ad750 allow plex re-authentication 2021-03-14 21:05:14 -05:00
Jason Dove e5a9ef6196 add episode posters to xmltv 2021-03-14 18:49:30 -05:00
Jason Dove 8439d6fd54 fix channel logos in xmltv 2021-03-14 18:44:19 -05:00
Jason DoveandGitHub 1773691c39 create collection from add to collection dialog (#79) 2021-03-14 20:50:23 +00:00
Jason Dove 940cdd10a3 update all references 2021-03-14 15:29:14 -05:00
Jason Dove 6beb9f7e33 regularly scan plex media sources 2021-03-14 15:21:23 -05:00
Jason DoveandGitHub 898a21dcd9 clean up tables (#78)
* add plex library sorting options

* add playout sorting options
2021-03-14 20:13:28 +00:00
26 changed files with 299 additions and 104 deletions
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
@@ -70,12 +71,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
private async Task<Unit> ApplyUpdate(UpdateFFmpegSettings request)
{
Option<ConfigElement> ffmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath);
Option<ConfigElement> ffprobePath = await _configElementRepository.Get(ConfigElementKey.FFprobePath);
Option<ConfigElement> defaultFFmpegProfileId =
await _configElementRepository.Get(ConfigElementKey.FFmpegDefaultProfileId);
ffmpegPath.Match(
await _configElementRepository.Get(ConfigElementKey.FFmpegPath).Match(
ce =>
{
ce.Value = request.Settings.FFmpegPath;
@@ -88,7 +84,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
_configElementRepository.Add(ce);
});
ffprobePath.Match(
await _configElementRepository.Get(ConfigElementKey.FFprobePath).Match(
ce =>
{
ce.Value = request.Settings.FFprobePath;
@@ -101,7 +97,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
_configElementRepository.Add(ce);
});
defaultFFmpegProfileId.Match(
await _configElementRepository.Get(ConfigElementKey.FFmpegDefaultProfileId).Match(
ce =>
{
ce.Value = request.Settings.DefaultFFmpegProfileId.ToString();
@@ -117,6 +113,27 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
_configElementRepository.Add(ce);
});
await _configElementRepository.Get(ConfigElementKey.FFmpegSaveReports).Match(
ce =>
{
ce.Value = request.Settings.SaveReports.ToString();
_configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement
{
Key = ConfigElementKey.FFmpegSaveReports.Key,
Value = request.Settings.SaveReports.ToString()
};
_configElementRepository.Add(ce);
});
if (request.Settings.SaveReports && !Directory.Exists(FileSystemLayout.FFmpegReportsFolder))
{
Directory.CreateDirectory(FileSystemLayout.FFmpegReportsFolder);
}
return Unit.Default;
}
}
@@ -5,5 +5,6 @@
public string FFmpegPath { get; set; }
public string FFprobePath { get; set; }
public int DefaultFFmpegProfileId { get; set; }
public bool SaveReports { get; set; }
}
}
@@ -22,12 +22,15 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
Option<string> ffprobePath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath);
Option<int> defaultFFmpegProfileId =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId);
Option<bool> saveReports =
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports);
return new FFmpegSettingsViewModel
{
FFmpegPath = ffmpegPath.IfNone(string.Empty),
FFprobePath = ffprobePath.IfNone(string.Empty),
DefaultFFmpegProfileId = defaultFFmpegProfileId.IfNone(0)
DefaultFFmpegProfileId = defaultFFmpegProfileId.IfNone(0),
SaveReports = saveReports.IfNone(false)
};
}
}
@@ -23,6 +23,7 @@ namespace ErsatzTV.Application.Streaming.Queries
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IConfigElementRepository _configElementRepository;
private readonly IPlayoutRepository _playoutRepository;
public GetPlayoutItemProcessByChannelNumberHandler(
@@ -35,6 +36,7 @@ namespace ErsatzTV.Application.Streaming.Queries
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
: base(channelRepository, configElementRepository)
{
_configElementRepository = configElementRepository;
_playoutRepository = playoutRepository;
_mediaSourceRepository = mediaSourceRepository;
_ffmpegProcessService = ffmpegProcessService;
@@ -54,7 +56,7 @@ namespace ErsatzTV.Application.Streaming.Queries
.BindT(ValidatePlayoutItemPath);
return await maybePlayoutItem.Match(
playoutItemWithPath =>
async playoutItemWithPath =>
{
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem switch
{
@@ -63,14 +65,18 @@ namespace ErsatzTV.Application.Streaming.Queries
_ => throw new ArgumentOutOfRangeException(nameof(playoutItemWithPath))
};
bool saveReports = await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
return Right<BaseError, Process>(
_ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
saveReports,
channel,
version,
playoutItemWithPath.Path,
playoutItemWithPath.PlayoutItem.StartOffset,
now)).AsTask();
now));
},
async error =>
{
@@ -17,7 +17,7 @@
<PackageReference Include="Microsoft.Extensions.Logging" Version="5.0.0" />
<PackageReference Include="RestSharp" Version="106.11.7" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="3.1.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="4.1.2" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
</ItemGroup>
@@ -8,13 +8,13 @@
<PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" />
<PackageReference Include="Moq" Version="4.16.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.1" />
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.Debug" Version="1.0.1" />
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
</ItemGroup>
<ItemGroup>
@@ -15,6 +15,54 @@ namespace ErsatzTV.Core.Tests.Scheduling
// this seed will produce (shuffle) 1-10 in order
private const int MagicSeed = 670596;
[Test]
public void Episodes_Should_Not_Duplicate_When_Reshuffling()
{
List<MediaItem> contents = Episodes(10);
// normally returns 10 5 7 4 3 6 2 8 9 1 1 (note duplicate 1 at end)
var state = new CollectionEnumeratorState { Seed = 8 };
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state);
var list = new List<int>();
for (var i = 1; i <= 1000; i++)
{
shuffledContent.Current.IsSome.Should().BeTrue();
shuffledContent.Current.Do(x => list.Add(x.Id));
shuffledContent.MoveNext();
}
for (var i = 0; i < list.Count - 1; i++)
{
if (list[i] == list[i + 1])
{
Assert.Fail("List contains duplicate items");
}
}
}
[Test]
[Timeout(2000)]
public void Duplicate_Check_Should_Ignore_Single_Item()
{
List<MediaItem> contents = Episodes(1);
var state = new CollectionEnumeratorState();
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state);
var list = new List<int>();
for (var i = 1; i <= 10; i++)
{
shuffledContent.Current.IsSome.Should().BeTrue();
shuffledContent.Current.Do(x => list.Add(x.Id));
shuffledContent.MoveNext();
}
list.Should().Equal(1, 1, 1, 1, 1, 1, 1, 1, 1, 1);
}
[Test]
public void Episodes_Should_Shuffle()
{
+1
View File
@@ -10,5 +10,6 @@
public static ConfigElementKey FFprobePath => new("ffmpeg.ffprobe_path");
public static ConfigElementKey FFmpegDefaultProfileId => new("ffmpeg.default_profile_id");
public static ConfigElementKey FFmpegDefaultResolutionId => new("ffmpeg.default_resolution_id");
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
}
}
+13 -1
View File
@@ -21,6 +21,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
@@ -39,9 +40,14 @@ namespace ErsatzTV.Core.FFmpeg
private readonly List<string> _arguments = new();
private readonly string _ffmpegPath;
private readonly bool _saveReports;
private FFmpegComplexFilterBuilder _complexFilterBuilder = new();
public FFmpegProcessBuilder(string ffmpegPath) => _ffmpegPath = ffmpegPath;
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports)
{
_ffmpegPath = ffmpegPath;
_saveReports = saveReports;
}
public FFmpegProcessBuilder WithThreads(int threads)
{
@@ -365,6 +371,12 @@ namespace ErsatzTV.Core.FFmpeg
StandardOutputEncoding = Encoding.UTF8
};
if (_saveReports)
{
string fileName = Path.Combine(FileSystemLayout.FFmpegReportsFolder, "%p-%t.log");
startInfo.EnvironmentVariables.Add("FFREPORT", $"file={fileName}:level=32");
}
foreach (string argument in _arguments)
{
startInfo.ArgumentList.Add(argument);
+4 -3
View File
@@ -15,6 +15,7 @@ namespace ErsatzTV.Core.FFmpeg
public Process ForPlayoutItem(
string ffmpegPath,
bool saveReports,
Channel channel,
MediaVersion version,
string path,
@@ -28,7 +29,7 @@ namespace ErsatzTV.Core.FFmpeg
start,
now);
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath)
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports)
.WithThreads(playbackSettings.ThreadCount)
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration)
.WithQuiet()
@@ -91,7 +92,7 @@ namespace ErsatzTV.Core.FFmpeg
IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution;
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath)
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
@@ -114,7 +115,7 @@ namespace ErsatzTV.Core.FFmpeg
{
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
return new FFmpegProcessBuilder(ffmpegPath)
return new FFmpegProcessBuilder(ffmpegPath, false)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
+2
View File
@@ -19,6 +19,8 @@ namespace ErsatzTV.Core
public static readonly string PlexSecretsPath = Path.Combine(AppDataFolder, "plex-secrets.json");
public static readonly string FFmpegReportsFolder = Path.Combine(AppDataFolder, "ffmpeg-reports");
public static readonly string ArtworkCacheFolder = Path.Combine(AppDataFolder, "cache", "artwork");
public static readonly string PosterCacheFolder = Path.Combine(ArtworkCacheFolder, "posters");
+20
View File
@@ -152,6 +152,26 @@ namespace ErsatzTV.Core.Iptv
if (playoutItem.MediaItem is Episode episode)
{
Option<ShowMetadata> maybeMetadata =
Optional(episode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten();
if (maybeMetadata.IsSome)
{
ShowMetadata metadata = maybeMetadata.ValueUnsafe();
string poster = Optional(metadata.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
.HeadOrNone()
.Match(
artwork => $"{_scheme}://{_host}/artwork/posters/{artwork.Path}",
() => string.Empty);
if (!string.IsNullOrWhiteSpace(poster))
{
xml.WriteStartElement("icon");
xml.WriteAttributeString("src", poster);
xml.WriteEndElement(); // icon
}
}
int s = Optional(episode.Season?.SeasonNumber).IfNone(0);
int e = episode.EpisodeNumber;
if (s > 0 && e > 0)
@@ -34,13 +34,21 @@ namespace ErsatzTV.Core.Scheduling
public void MoveNext()
{
State.Index++;
if (State.Index % _shuffled.Count == 0)
if ((State.Index + 1) % _shuffled.Count == 0)
{
Option<MediaItem> tail = Current;
State.Index = 0;
State.Seed = _random.Next();
_random = new Random(State.Seed);
_shuffled = Shuffle(_mediaItems, _random);
do
{
State.Seed = _random.Next();
_random = new Random(State.Seed);
_shuffled = Shuffle(_mediaItems, _random);
} while (_mediaItems.Count > 1 && Current == tail);
}
else
{
State.Index++;
}
State.Index %= _shuffled.Count;
@@ -45,6 +45,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<List<Channel>> GetAllForGuide() =>
_dbContext.Channels
.Include(c => c.Artwork)
.Include(c => c.Playouts)
.ThenInclude(p => p.Items)
.ThenInclude(i => i.MediaItem)
@@ -55,6 +56,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(c => c.Playouts)
.ThenInclude(p => p.Items)
.ThenInclude(i => i.MediaItem)
@@ -7,14 +7,14 @@
<ItemGroup>
<PackageReference Include="Dapper" Version="2.0.78" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.3">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.3" />
<PackageReference Include="Refit" Version="6.0.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.4" />
<PackageReference Include="Refit" Version="6.0.24" />
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.3" />
</ItemGroup>
<ItemGroup>
@@ -31,7 +31,7 @@ namespace ErsatzTV.Infrastructure.Plex
List<PlexLibraryResponse> directory =
await service.GetLibraries(token.AuthToken).Map(r => r.MediaContainer.Directory);
return directory
.Filter(l => l.Hidden == 0)
// .Filter(l => l.Hidden == 0)
.Map(Project)
.Somes()
.ToList();
@@ -8,22 +8,24 @@ using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Plex;
using ErsatzTV.Infrastructure.Plex.Models;
using LanguageExt;
using Microsoft.Extensions.Logging;
using Refit;
namespace ErsatzTV.Infrastructure.Plex
{
public class PlexTvApiClient : IPlexTvApiClient
{
private const string AppName = "ErsatzTV";
private readonly ILogger<PlexTvApiClient> _logger;
private readonly IPlexSecretStore _plexSecretStore;
private readonly IPlexTvApi _plexTvApi;
public PlexTvApiClient(IPlexTvApi plexTvApi, IPlexSecretStore plexSecretStore)
public PlexTvApiClient(IPlexTvApi plexTvApi, IPlexSecretStore plexSecretStore, ILogger<PlexTvApiClient> logger)
{
// var client = new HttpClient(new HttpLoggingHandler()) { BaseAddress = new Uri("https://plex.tv/api/v2") };
_plexTvApi = plexTvApi; // RestService.For<IPlexTvApi>(client);
_plexTvApi = plexTvApi;
_plexSecretStore = plexSecretStore;
_logger = logger;
}
public async Task<Either<BaseError, List<PlexMediaSource>>> GetServers()
@@ -80,8 +82,18 @@ namespace ErsatzTV.Infrastructure.Plex
return result;
}
catch (ApiException apiException)
{
if (apiException.ReasonPhrase == "Unauthorized")
{
await _plexSecretStore.DeleteAll();
}
return BaseError.New(apiException.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting plex servers");
return BaseError.New(ex.Message);
}
}
@@ -96,6 +108,7 @@ namespace ErsatzTV.Infrastructure.Plex
}
catch (Exception ex)
{
_logger.LogError(ex, "Error starting plex pin flow");
return BaseError.New(ex.Message);
}
}
@@ -122,9 +135,9 @@ namespace ErsatzTV.Infrastructure.Plex
return true;
}
}
catch (Exception)
catch (Exception ex)
{
// ignored
_logger.LogError(ex, "Error completing plex pin flow");
}
return false;
+9 -9
View File
@@ -11,23 +11,23 @@
<ItemGroup>
<PackageReference Include="Accelist.FluentValidation.Blazor" Version="4.0.0" />
<PackageReference Include="FluentValidation" Version="9.5.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="9.5.0" />
<PackageReference Include="FluentValidation" Version="9.5.2" />
<PackageReference Include="FluentValidation.AspNetCore" Version="9.5.2" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.3">
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MudBlazor" Version="5.0.2" />
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.1" />
<PackageReference Include="MudBlazor" Version="5.0.5" />
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.24" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.AspNetCore" Version="3.4.0" />
<PackageReference Include="Serilog.AspNetCore" Version="4.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.1.0" />
<PackageReference Include="Serilog.Sinks.SQLite" Version="5.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.0.2" />
<PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="6.0.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="6.1.0" />
</ItemGroup>
<ItemGroup>
+6
View File
@@ -29,6 +29,12 @@
}
</MudSelect>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudSwitch T="bool"
Label="Save troubleshooting reports to disk"
Color="Color.Primary"
@bind-Checked="@_ffmpegSettings.SaveReports" />
</MudElement>
</MudForm>
</MudCardContent>
<MudCardActions>
-25
View File
@@ -1,25 +0,0 @@
@* @page "/media/sources" *@
@* @page "/media/sources/{PanelIndex:int}" *@
@* @inject NavigationManager NavigationManager *@
@* *@
@* <MudContainer MaxWidth="MaxWidth.ExtraLarge"> *@
@* <MudTabs @ref="_tabs" Elevation="1"> *@
@* <MudTabPanel Text="Local" OnClick="@(() => NavigationManager.NavigateTo("/media/sources/0"))"> *@
@* <LocalMediaSources></LocalMediaSources> *@
@* </MudTabPanel> *@
@* <MudTabPanel Text="Plex" OnClick="@(() => NavigationManager.NavigateTo("/media/sources/1"))"> *@
@* <PlexMediaSources></PlexMediaSources> *@
@* </MudTabPanel> *@
@* </MudTabs> *@
@* </MudContainer> *@
@* *@
@* @code { *@
@* *@
@* [Parameter] *@
@* public int PanelIndex { get; set; } *@
@* *@
@* private MudTabs _tabs; *@
@* *@
@* protected override void OnAfterRender(bool firstRender) => _tabs.ActivatePanel(PanelIndex); *@
@* *@
@* } *@
+12 -6
View File
@@ -11,20 +11,25 @@
<MudText Typo="Typo.h6">Playouts</MudText>
</ToolBarContent>
<ColGroup>
<col/>
<col/>
<col/>
<col style="width: 120px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Id</MudTh>
<MudTh>Channel</MudTh>
<MudTh>Schedule</MudTh>
<MudTh>
<MudTableSortLabel SortBy="new Func<PlayoutViewModel, object>(x => decimal.Parse(x.Channel.Number))">
Channel
</MudTableSortLabel>
</MudTh>
<MudTh>
<MudTableSortLabel SortBy="new Func<PlayoutViewModel, object>(x => x.ProgramSchedule.Name)">
Schedule
</MudTableSortLabel>
</MudTh>
@* <MudTh>Playout Type</MudTh> *@
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Id">@context.Id</MudTd>
<MudTd DataLabel="Channel">@context.Channel.Number - @context.Channel.Name</MudTd>
<MudTd DataLabel="Schedule">@context.ProgramSchedule.Name</MudTd>
@* <MudTd DataLabel="Playout Type">@context.ProgramSchedulePlayoutType</MudTd> *@
@@ -110,7 +115,8 @@
}
private async Task LoadAllPlayouts() =>
_playouts = await Mediator.Send(new GetAllPlayouts());
_playouts = await Mediator.Send(new GetAllPlayouts())
.Map(list => list.OrderBy(x => decimal.Parse(x.Channel.Number)).ToList());
}
+11 -3
View File
@@ -20,8 +20,16 @@
<col style="width: 100px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Media Type</MudTh>
<MudTh>
<MudTableSortLabel SortBy="new Func<PlexMediaSourceLibraryEditViewModel, object>(x => x.Name)">
Name
</MudTableSortLabel>
</MudTh>
<MudTh>
<MudTableSortLabel SortBy="new Func<PlexMediaSourceLibraryEditViewModel, object>(x => x.MediaKind)">
Media Kind
</MudTableSortLabel>
</MudTh>
<MudTh>Synchronize</MudTh>
</HeaderContent>
<RowTemplate>
@@ -55,7 +63,7 @@
{
_source = source;
_libraries = await Mediator.Send(new GetPlexLibrariesBySourceId(Id))
.Map(list => list.Map(ProjectToEditViewModel).ToList());
.Map(list => list.Map(ProjectToEditViewModel).OrderBy(x => x.MediaKind).ThenBy(x => x.Name).ToList());
},
() =>
{
+21 -2
View File
@@ -1,4 +1,5 @@
@page "/media/plex"
@using ErsatzTV.Core.Interfaces.Plex
@using ErsatzTV.Application.Plex
@using ErsatzTV.Application.Plex.Commands
@using ErsatzTV.Application.Plex.Queries
@@ -9,6 +10,7 @@
@inject ISnackbar Snackbar
@inject ILogger<PlexMediaSources> Logger
@inject IJSRuntime JsRuntime
@inject IPlexSecretStore PlexSecretStore
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Dense="true" Items="_mediaSources">
@@ -64,18 +66,35 @@
Sign in to plex
</MudButton>
}
@if (_mediaSources.Any() && !_isAuthorized)
{
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
OnClick="@(_ => AddPlexMediaSource())"
Disabled="@Locker.IsPlexLocked()"
Class="ml-4 mt-4">
Fix Plex Credentials
</MudButton>
}
</MudContainer>
@code {
private List<PlexMediaSourceViewModel> _mediaSources;
private List<PlexMediaSourceViewModel> _mediaSources = new();
private bool _isAuthorized;
protected override async Task OnParametersSetAsync() => await LoadMediaSources();
protected override void OnInitialized() =>
Locker.OnPlexChanged += PlexChanged;
private async Task LoadMediaSources() =>
private async Task LoadMediaSources()
{
_isAuthorized = await PlexSecretStore.GetUserAuthTokens().Map(list => Prelude.Optional(list).Flatten().Any());
_mediaSources = await Mediator.Send(new GetAllPlexMediaSources());
}
private async Task SignOutOfPlex()
{
+1 -4
View File
@@ -11,19 +11,16 @@
<MudText Typo="Typo.h6">Schedules</MudText>
</ToolBarContent>
<ColGroup>
<col/>
<col/>
<col/>
<col style="width: 180px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Id</MudTh>
<MudTh>Name</MudTh>
<MudTh>Collection Playback Order</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Id">@context.Id</MudTd>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Name">@context.MediaCollectionPlaybackOrder</MudTd>
<MudTd>
@@ -88,7 +85,7 @@
{
_selectedSchedule = schedule;
await Mediator.Send(new GetProgramScheduleItems(schedule.Id))
.IterT(results => _selectedScheduleItems = results.ToList());
.IterT(results => _selectedScheduleItems = results.OrderBy(x => x.Name).ToList());
}
private async Task DeleteSchedule(ProgramScheduleViewModel programSchedule)
+22 -14
View File
@@ -7,6 +7,8 @@ using System.Threading.Tasks;
using ErsatzTV.Application;
using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Application.Plex.Commands;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -54,6 +56,7 @@ namespace ErsatzTV.Services
{
await BuildPlayouts(cancellationToken);
await ScanLocalMediaSources(cancellationToken);
await ScanPlexMediaSources(cancellationToken);
}
private async Task BuildPlayouts(CancellationToken cancellationToken)
@@ -87,21 +90,26 @@ namespace ErsatzTV.Services
cancellationToken);
}
}
}
// List<PlexLibrary> plexLibraries = await dbContext.PlexLibraries
// .Filter(l => l.ShouldSyncItems)
// .ToListAsync(cancellationToken);
//
// foreach (PlexLibrary library in plexLibraries)
// {
// // TODO: this locking won't work...
// // if (_entityLocker.LockMediaSource(library.PlexMediaSourceId))
// // {
// // await _channel.WriteAsync(
// // new SynchronizePlexLibraryByIdIfNeeded(library.PlexMediaSourceId, library.Id),
// // cancellationToken);
// // }
// }
private async Task ScanPlexMediaSources(CancellationToken cancellationToken)
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
List<PlexLibrary> plexLibraries = await dbContext.PlexLibraries
.Filter(l => l.ShouldSyncItems)
.ToListAsync(cancellationToken);
foreach (PlexLibrary library in plexLibraries)
{
if (_entityLocker.LockLibrary(library.Id))
{
await _channel.WriteAsync(
new SynchronizePlexLibraryByIdIfNeeded(library.Id),
cancellationToken);
}
}
}
}
}
+47 -5
View File
@@ -1,8 +1,11 @@
@using Microsoft.Extensions.Caching.Memory
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.MediaCollections.Queries
@inject IMediator Mediator
@inject IMemoryCache MemoryCache
@inject ISnackbar Snackbar
@inject ILogger<AddToCollectionDialog> Logger
<MudDialog>
<DialogContent>
@@ -18,10 +21,16 @@
<MudSelectItem Value="@collection">@collection.Name</MudSelectItem>
}
</MudSelect>
<MudTextFieldString Label="New Collection Name"
Disabled="@(_selectedCollection != _newCollection)"
@bind-Text="@_newCollectionName"
Immediate="true"
Class="mb-6 mx-4">
</MudTextFieldString>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" Disabled="@(_selectedCollection == null)" OnClick="Submit">
<MudButton Color="Color.Primary" Variant="Variant.Filled" Disabled="@(!CanSubmit())" OnClick="Submit">
Add To Collection
</MudButton>
</DialogActions>
@@ -44,25 +53,58 @@
[Parameter]
public string DetailHighlight { get; set; }
private readonly MediaCollectionViewModel _newCollection = new(-1, "(New Collection)");
private string _newCollectionName;
private List<MediaCollectionViewModel> _collections;
private MediaCollectionViewModel _selectedCollection;
private bool CanSubmit() =>
_selectedCollection != null && (_selectedCollection != _newCollection || !string.IsNullOrWhiteSpace(_newCollectionName));
protected override async Task OnParametersSetAsync()
{
_collections = await Mediator.Send(new GetAllCollections());
_collections = await Mediator.Send(new GetAllCollections())
.Map(list => new[] { _newCollection }.Append(list).ToList());
if (MemoryCache.TryGetValue("AddToCollectionDialog.SelectedCollectionId", out int id))
{
_selectedCollection = _collections.SingleOrDefault(c => c.Id == id);
}
else
{
_selectedCollection = _newCollection;
}
}
private string FormatText() => $"Select the collection to add the {EntityType} {EntityName}";
private void Submit()
private async Task Submit()
{
MemoryCache.Set("AddToCollectionDialog.SelectedCollectionId", _selectedCollection.Id);
MudDialog.Close(DialogResult.Ok(_selectedCollection));
if (_selectedCollection == _newCollection)
{
Either<BaseError, MediaCollectionViewModel> maybeResult =
await Mediator.Send(new CreateCollection(_newCollectionName));
maybeResult.Match(
collection =>
{
MemoryCache.Set("AddToCollectionDialog.SelectedCollectionId", collection.Id);
MudDialog.Close(DialogResult.Ok(collection));
},
error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Error creating new collection: {Error}", error.Value);
MudDialog.Close(DialogResult.Cancel());
});
}
else
{
MemoryCache.Set("AddToCollectionDialog.SelectedCollectionId", _selectedCollection.Id);
MudDialog.Close(DialogResult.Ok(_selectedCollection));
}
}
private void Cancel() => MudDialog.Cancel();