Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c1c1e7812 | ||
|
|
5f802c7484 | ||
|
|
7a06ac71e2 | ||
|
|
3b9b8796b9 | ||
|
|
a72d91507e | ||
|
|
45bfbfc179 | ||
|
|
7d24701a82 | ||
|
|
286580d5aa | ||
|
|
d9457c01e5 | ||
|
|
22cf759a29 | ||
|
|
0733a3d8d7 | ||
|
|
5f28707cce | ||
|
|
45f1c6b22a | ||
|
|
3bed81aee9 | ||
|
|
f2eda3033c | ||
|
|
8ce989c3c9 | ||
|
|
b5ba0dff27 | ||
|
|
de3e2ea754 | ||
|
|
2ac840b4bd | ||
|
|
c8ccb5b0a0 | ||
|
|
23c4fcf42c | ||
|
|
e2f3e86fd6 | ||
|
|
fd9f4a8f4e | ||
|
|
d5a0951a9b | ||
|
|
56d9724efd | ||
|
|
f91b5ab3b5 | ||
|
|
4b8e81ff06 | ||
|
|
1a7e6dda54 | ||
|
|
9fc6cdd0b7 | ||
|
|
cebab33d79 | ||
|
|
b580125e86 | ||
|
|
b38ba14c40 | ||
|
|
c10bc6b184 | ||
|
|
a75737a032 | ||
|
|
57aa14b764 |
@@ -0,0 +1,8 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: nuget
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
@@ -11,6 +11,9 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-x64
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-arm
|
||||
- os: windows-latest
|
||||
kind: windows
|
||||
target: win-x64
|
||||
|
||||
+62
-2
@@ -5,6 +5,62 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.50-alpha] - 2021-07-13
|
||||
### Added
|
||||
- Add Linux ARM release artifacts which can be used on Raspberry Pi devices
|
||||
|
||||
### Fixed
|
||||
- Fix bug preventing ingestion of local movies with fallback metadata (without NFO files)
|
||||
- Fix extra spaces in titles of local movies with fallback metadata (without NFO files)
|
||||
|
||||
## [0.0.49-prealpha] - 2021-07-11
|
||||
### Added
|
||||
- Include audio language metadata in all streaming modes
|
||||
- Add special zero-count case to `Multiple` playout mode
|
||||
- This configuration will automatically maintain the multiple count so that it is equal to the number of items in the collection
|
||||
- This configuration should be used if you want to play every item in a collection exactly once before advancing
|
||||
|
||||
### Changed
|
||||
- Use case-insensitive sorting for collections page and `Add to Collection` dialog
|
||||
- Use case-insensitive sorting for all collection lists in schedule items editor
|
||||
- Use natural sorting for schedules page and `Add to Schedule` dialog
|
||||
|
||||
### Fixed
|
||||
- Fix flooding schedule items that have a fixed start time
|
||||
|
||||
## [0.0.48-prealpha] - 2021-06-22
|
||||
### Added
|
||||
- Store pixel format with media statistics; this is needed to support normalization of 10-bit media items
|
||||
- This requires re-ingesting statistics for all media items the first time this version is launched
|
||||
|
||||
### Changed
|
||||
- Use ffprobe to retrieve statistics for Plex media items (Local, Emby and Jellyfin libraries already use ffprobe)
|
||||
|
||||
### Fixed
|
||||
- Fix playback of transcoded 10-bit media items (pixel format `yuv420p10le`) on Nvidia hardware
|
||||
- Emby and Jellyfin scanners now respect library refresh interval setting
|
||||
- Fix adding new seasons to existing Emby and Jellyfin shows
|
||||
- Fix adding new episodes to existing Emby and Jellyfin seasons
|
||||
|
||||
## [0.0.47-prealpha] - 2021-06-15
|
||||
### Added
|
||||
- Add warning during playout rebuild when schedule has been emptied
|
||||
- Save Logs, Playout Detail, Schedule Detail table page sizes
|
||||
|
||||
### Changed
|
||||
- Show all log entries in log viewer, not just most recent 100 entries
|
||||
- Use server-side paging and sorting for Logs table
|
||||
- Use server-side paging for Playout Detail table
|
||||
- Remove pager from Schedule Items editor (all schedule items will always be displayed)
|
||||
|
||||
### Fixed
|
||||
- Fix ui crash adding a channel without a watermark
|
||||
- Clear playout detail table when playout is deleted
|
||||
- Fix blazor error font color
|
||||
- Fix some audio stream languages missing from UI and search index
|
||||
- Fix audio stream selection for languages with multiple codes
|
||||
- Fix searching when queries contain non-ascii characters
|
||||
|
||||
## [0.0.46-prealpha] - 2021-06-14
|
||||
### Added
|
||||
- Add watermark opacity setting to allow blending with content
|
||||
@@ -18,7 +74,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- This allows easy watermark reuse across channels
|
||||
|
||||
### Fixed
|
||||
- Fix crash adding or editing schedule items due to Artist with no name
|
||||
- Fix ui crash adding or editing schedule items due to Artist with no name
|
||||
- Fix many potential sources of inconsistent data in UI
|
||||
|
||||
## [0.0.45-prealpha] - 2021-06-12
|
||||
@@ -449,7 +505,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.46-prealpha...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.50-alpha...HEAD
|
||||
[0.0.50-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.49-prealpha...v0.0.50-alpha
|
||||
[0.0.49-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.48-prealpha...v0.0.49-prealpha
|
||||
[0.0.48-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.47-prealpha...v0.0.48-prealpha
|
||||
[0.0.47-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.46-prealpha...v0.0.47-prealpha
|
||||
[0.0.46-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.45-prealpha...v0.0.46-prealpha
|
||||
[0.0.45-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.44-prealpha...v0.0.45-prealpha
|
||||
[0.0.44-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.43-prealpha...v0.0.44-prealpha
|
||||
|
||||
@@ -28,8 +28,9 @@ namespace ErsatzTV.Application.Artists.Queries
|
||||
return await maybeArtist.Match<Task<Option<ArtistViewModel>>>(
|
||||
async artist =>
|
||||
{
|
||||
List<string> languages = await _searchRepository.GetLanguagesForArtist(artist);
|
||||
return ProjectToViewModel(artist, languages);
|
||||
List<string> mediaCodes = await _searchRepository.GetLanguagesForArtist(artist);
|
||||
List<string> languageCodes = await _searchRepository.GetAllLanguageCodes(mediaCodes);
|
||||
return ProjectToViewModel(artist, languageCodes);
|
||||
},
|
||||
() => Task.FromResult(Option<ArtistViewModel>.None));
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
return new CreateChannelResult(channel.Id);
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
|
||||
(ValidateName(request), await ValidateNumber(dbContext, request),
|
||||
await FFmpegProfileMustExist(dbContext, request),
|
||||
ValidatePreferredLanguage(request),
|
||||
@@ -66,10 +66,14 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
FFmpegProfileId = ffmpegProfileId,
|
||||
StreamingMode = request.StreamingMode,
|
||||
Artwork = artwork,
|
||||
PreferredLanguageCode = preferredLanguageCode,
|
||||
WatermarkId = watermarkId
|
||||
PreferredLanguageCode = preferredLanguageCode
|
||||
};
|
||||
|
||||
foreach (int id in watermarkId)
|
||||
{
|
||||
channel.WatermarkId = id;
|
||||
}
|
||||
|
||||
return channel;
|
||||
});
|
||||
|
||||
@@ -111,20 +115,20 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
.MapT(_ => createChannel.FFmpegProfileId)
|
||||
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist."));
|
||||
|
||||
private static async Task<Validation<BaseError, int?>> WatermarkMustExist(
|
||||
private static async Task<Validation<BaseError, Option<int>>> WatermarkMustExist(
|
||||
TvContext dbContext,
|
||||
CreateChannel createChannel)
|
||||
{
|
||||
if (createChannel.WatermarkId is null)
|
||||
{
|
||||
return createChannel.WatermarkId;
|
||||
return Option<int>.None;
|
||||
}
|
||||
|
||||
return await dbContext.ChannelWatermarks
|
||||
.CountAsync(w => w.Id == createChannel.WatermarkId)
|
||||
.Map(Optional)
|
||||
.Filter(c => c > 0)
|
||||
.MapT(_ => createChannel.WatermarkId)
|
||||
.MapT(_ => Optional(createChannel.WatermarkId))
|
||||
.Map(o => o.ToValidation<BaseError>($"Watermark {createChannel.WatermarkId} does not exist."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,8 @@ namespace ErsatzTV.Application.Emby.Commands
|
||||
private async Task<Unit> Synchronize(RequestParameters parameters)
|
||||
{
|
||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
if (parameters.ForceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
||||
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
||||
{
|
||||
switch (parameters.Library.MediaKind)
|
||||
{
|
||||
@@ -104,12 +105,14 @@ namespace ErsatzTV.Application.Emby.Commands
|
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(
|
||||
ISynchronizeEmbyLibraryById request) =>
|
||||
(await ValidateConnection(request), await EmbyLibraryMustExist(request), await ValidateFFprobePath())
|
||||
(await ValidateConnection(request), await EmbyLibraryMustExist(request),
|
||||
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
|
||||
.Apply(
|
||||
(connectionParameters, embyLibrary, ffprobePath) => new RequestParameters(
|
||||
(connectionParameters, embyLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
|
||||
connectionParameters,
|
||||
embyLibrary,
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval,
|
||||
ffprobePath
|
||||
));
|
||||
|
||||
@@ -149,6 +152,11 @@ namespace ErsatzTV.Application.Emby.Commands
|
||||
_mediaSourceRepository.GetEmbyLibrary(request.EmbyLibraryId)
|
||||
.Map(v => v.ToValidation<BaseError>($"Emby library {request.EmbyLibraryId} does not exist."));
|
||||
|
||||
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.FilterT(lri => lri > 0)
|
||||
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
|
||||
|
||||
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
|
||||
.FilterT(File.Exists)
|
||||
@@ -160,6 +168,7 @@ namespace ErsatzTV.Application.Emby.Commands
|
||||
ConnectionParameters ConnectionParameters,
|
||||
EmbyLibrary Library,
|
||||
bool ForceScan,
|
||||
int LibraryRefreshInterval,
|
||||
string FFprobePath);
|
||||
|
||||
private record ConnectionParameters(
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -68,7 +68,8 @@ namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
private async Task<Unit> Synchronize(RequestParameters parameters)
|
||||
{
|
||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
if (parameters.ForceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
||||
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
||||
{
|
||||
switch (parameters.Library.MediaKind)
|
||||
{
|
||||
@@ -104,12 +105,14 @@ namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(
|
||||
ISynchronizeJellyfinLibraryById request) =>
|
||||
(await ValidateConnection(request), await JellyfinLibraryMustExist(request), await ValidateFFprobePath())
|
||||
(await ValidateConnection(request), await JellyfinLibraryMustExist(request),
|
||||
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
|
||||
.Apply(
|
||||
(connectionParameters, jellyfinLibrary, ffprobePath) => new RequestParameters(
|
||||
(connectionParameters, jellyfinLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
|
||||
connectionParameters,
|
||||
jellyfinLibrary,
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval,
|
||||
ffprobePath
|
||||
));
|
||||
|
||||
@@ -149,6 +152,11 @@ namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
_mediaSourceRepository.GetJellyfinLibrary(request.JellyfinLibraryId)
|
||||
.Map(v => v.ToValidation<BaseError>($"Jellyfin library {request.JellyfinLibraryId} does not exist."));
|
||||
|
||||
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.FilterT(lri => lri > 0)
|
||||
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
|
||||
|
||||
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
|
||||
.FilterT(File.Exists)
|
||||
@@ -160,6 +168,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
ConnectionParameters ConnectionParameters,
|
||||
JellyfinLibrary Library,
|
||||
bool ForceScan,
|
||||
int LibraryRefreshInterval,
|
||||
string FFprobePath);
|
||||
|
||||
private record ConnectionParameters(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Application.Logs
|
||||
{
|
||||
public record PagedLogEntriesViewModel(int TotalCount, List<LogEntryViewModel> Page);
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Linq.Expressions;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Logs.Queries
|
||||
{
|
||||
public record GetRecentLogEntries : IRequest<List<LogEntryViewModel>>;
|
||||
public record GetRecentLogEntries(int PageNum, int PageSize) : IRequest<PagedLogEntriesViewModel>
|
||||
{
|
||||
public Expression<Func<LogEntry, object>> SortExpression { get; set; }
|
||||
public Option<bool> SortDescending { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,20 +2,46 @@
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Logs.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Logs.Queries
|
||||
{
|
||||
public class GetRecentLogEntriesHandler : IRequestHandler<GetRecentLogEntries, List<LogEntryViewModel>>
|
||||
public class GetRecentLogEntriesHandler : IRequestHandler<GetRecentLogEntries, PagedLogEntriesViewModel>
|
||||
{
|
||||
private readonly ILogRepository _logRepository;
|
||||
private readonly IDbContextFactory<LogContext> _dbContextFactory;
|
||||
|
||||
public GetRecentLogEntriesHandler(ILogRepository logRepository) => _logRepository = logRepository;
|
||||
public GetRecentLogEntriesHandler(IDbContextFactory<LogContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public Task<List<LogEntryViewModel>> Handle(GetRecentLogEntries request, CancellationToken cancellationToken) =>
|
||||
_logRepository.GetRecentLogEntries().Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
public async Task<PagedLogEntriesViewModel> Handle(
|
||||
GetRecentLogEntries request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using LogContext logContext = _dbContextFactory.CreateDbContext();
|
||||
int count = await logContext.LogEntries.CountAsync(cancellationToken);
|
||||
|
||||
IOrderedQueryable<LogEntry> ordered = logContext.LogEntries
|
||||
.OrderByDescending(le => le.Id);
|
||||
|
||||
foreach (bool descending in request.SortDescending)
|
||||
{
|
||||
ordered = descending
|
||||
? logContext.LogEntries.OrderByDescending(request.SortExpression).ThenByDescending(le => le.Id)
|
||||
: logContext.LogEntries.OrderBy(request.SortExpression).ThenByDescending(le => le.Id);
|
||||
}
|
||||
|
||||
List<LogEntryViewModel> page = await ordered
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
return new PagedLogEntriesViewModel(count, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ namespace ErsatzTV.Application.MediaCollections.Queries
|
||||
List<MediaCollectionViewModel> page = await dbContext.Collections.FromSqlRaw(
|
||||
@"SELECT * FROM Collection
|
||||
ORDER BY Name
|
||||
COLLATE NOCASE
|
||||
LIMIT {0} OFFSET {1}",
|
||||
request.PageSize,
|
||||
request.PageNum * request.PageSize)
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaItems.Queries
|
||||
{
|
||||
public class GetAllLanguageCodesHandler : IRequestHandler<GetAllLanguageCodes, List<CultureInfo>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
|
||||
public GetAllLanguageCodesHandler(IMediaItemRepository mediaItemRepository) =>
|
||||
public GetAllLanguageCodesHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IMediaItemRepository mediaItemRepository)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
}
|
||||
|
||||
public async Task<List<CultureInfo>> Handle(GetAllLanguageCodes request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new List<CultureInfo>();
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var result = new System.Collections.Generic.HashSet<CultureInfo>();
|
||||
|
||||
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
|
||||
List<string> allLanguageCodes = await _mediaItemRepository.GetAllLanguageCodes();
|
||||
foreach (string code in allLanguageCodes)
|
||||
List<string> mediaCodes = await _mediaItemRepository.GetAllLanguageCodes();
|
||||
foreach (string mediaCode in mediaCodes)
|
||||
{
|
||||
Option<CultureInfo> maybeCulture = allCultures.Find(
|
||||
ci => string.Equals(code, ci.ThreeLetterISOLanguageName, StringComparison.OrdinalIgnoreCase));
|
||||
await maybeCulture.IfSomeAsync(cultureInfo => result.Add(cultureInfo));
|
||||
foreach (string code in await dbContext.LanguageCodes.GetAllLanguageCodes(mediaCode))
|
||||
{
|
||||
Option<CultureInfo> maybeCulture = allCultures.Find(
|
||||
c => string.Equals(code, c.ThreeLetterISOLanguageName, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (CultureInfo culture in maybeCulture)
|
||||
{
|
||||
result.Add(culture);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return result.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ namespace ErsatzTV.Application.Movies
|
||||
{
|
||||
internal static MovieViewModel ProjectToViewModel(
|
||||
Movie movie,
|
||||
List<string> languageCodes,
|
||||
Option<JellyfinMediaSource> maybeJellyfin,
|
||||
Option<EmbyMediaSource> maybeEmby)
|
||||
{
|
||||
@@ -28,7 +29,7 @@ namespace ErsatzTV.Application.Movies
|
||||
metadata.Studios.Map(s => s.Name).ToList(),
|
||||
(metadata.ContentRating ?? string.Empty).Split("/").Map(s => s.Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(),
|
||||
LanguagesForMovie(movie),
|
||||
LanguagesForMovie(languageCodes),
|
||||
metadata.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id)
|
||||
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby))
|
||||
.ToList(),
|
||||
@@ -40,13 +41,11 @@ namespace ErsatzTV.Application.Movies
|
||||
};
|
||||
}
|
||||
|
||||
private static List<CultureInfo> LanguagesForMovie(Movie movie)
|
||||
private static List<CultureInfo> LanguagesForMovie(List<string> languageCodes)
|
||||
{
|
||||
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
|
||||
|
||||
return movie.MediaVersions
|
||||
.Map(mv => mv.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).Map(s => s.Language))
|
||||
.Flatten()
|
||||
return languageCodes
|
||||
.Distinct()
|
||||
.Map(
|
||||
lang => allCultures.Filter(
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
using System.Threading;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Movies.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Movies.Queries
|
||||
@@ -11,10 +16,15 @@ namespace ErsatzTV.Application.Movies.Queries
|
||||
public class GetMovieByIdHandler : IRequestHandler<GetMovieById, Option<MovieViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
|
||||
public GetMovieByIdHandler(IMovieRepository movieRepository, IMediaSourceRepository mediaSourceRepository)
|
||||
public GetMovieByIdHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IMovieRepository movieRepository,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_movieRepository = movieRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
}
|
||||
@@ -23,6 +33,8 @@ namespace ErsatzTV.Application.Movies.Queries
|
||||
GetMovieById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
@@ -30,7 +42,20 @@ namespace ErsatzTV.Application.Movies.Queries
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
Option<Movie> movie = await _movieRepository.GetMovie(request.Id);
|
||||
return movie.Map(m => ProjectToViewModel(m, maybeJellyfin, maybeEmby));
|
||||
|
||||
Option<MediaVersion> maybeVersion = movie.Map(m => m.MediaVersions.HeadOrNone()).Flatten();
|
||||
var languageCodes = new List<string>();
|
||||
foreach (MediaVersion version in maybeVersion)
|
||||
{
|
||||
var mediaCodes = version.Streams
|
||||
.Filter(ms => ms.MediaStreamKind == MediaStreamKind.Audio)
|
||||
.Map(ms => ms.Language)
|
||||
.ToList();
|
||||
|
||||
languageCodes.AddRange(await dbContext.LanguageCodes.GetAllLanguageCodes(mediaCodes));
|
||||
}
|
||||
|
||||
return movie.Map(m => ProjectToViewModel(m, languageCodes, maybeJellyfin, maybeEmby));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts
|
||||
{
|
||||
public record PagedPlayoutItemsViewModel(int TotalCount, List<PlayoutItemViewModel> Page);
|
||||
}
|
||||
@@ -3,5 +3,5 @@ using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts.Queries
|
||||
{
|
||||
public record GetPlayoutItemsById(int PlayoutId) : IRequest<List<PlayoutItemViewModel>>;
|
||||
public record GetPlayoutItemsById(int PlayoutId, int PageNum, int PageSize) : IRequest<PagedPlayoutItemsViewModel>;
|
||||
}
|
||||
|
||||
@@ -11,27 +11,29 @@ using static ErsatzTV.Application.Playouts.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts.Queries
|
||||
{
|
||||
public class GetPlayoutItemsByIdHandler : IRequestHandler<GetPlayoutItemsById, List<PlayoutItemViewModel>>
|
||||
public class GetPlayoutItemsByIdHandler : IRequestHandler<GetPlayoutItemsById, PagedPlayoutItemsViewModel>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetPlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<PlayoutItemViewModel>> Handle(
|
||||
public async Task<PagedPlayoutItemsViewModel> Handle(
|
||||
GetPlayoutItemsById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.PlayoutItems
|
||||
|
||||
int totalCount = await dbContext.PlayoutItems
|
||||
.CountAsync(i => i.PlayoutId == request.PlayoutId, cancellationToken);
|
||||
|
||||
List<PlayoutItemViewModel> page = await dbContext.PlayoutItems
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as Movie).MediaVersions)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
|
||||
.Include(i => i.MediaItem)
|
||||
@@ -39,7 +41,6 @@ namespace ErsatzTV.Application.Playouts.Queries
|
||||
.ThenInclude(mm => mm.ArtistMetadata)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Artwork)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode).MediaVersions)
|
||||
.Include(i => i.MediaItem)
|
||||
@@ -49,8 +50,13 @@ namespace ErsatzTV.Application.Playouts.Queries
|
||||
.ThenInclude(mi => (mi as Episode).Season.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.Filter(i => i.PlayoutId == request.PlayoutId)
|
||||
.OrderBy(i => i.Start)
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
return new PagedPlayoutItemsViewModel(totalCount, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -74,13 +75,15 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
await _plexMovieLibraryScanner.ScanLibrary(
|
||||
parameters.ConnectionParameters.ActiveConnection,
|
||||
parameters.ConnectionParameters.PlexServerAuthToken,
|
||||
parameters.Library);
|
||||
parameters.Library,
|
||||
parameters.FFprobePath);
|
||||
break;
|
||||
case LibraryMediaKind.Shows:
|
||||
await _plexTelevisionLibraryScanner.ScanLibrary(
|
||||
parameters.ConnectionParameters.ActiveConnection,
|
||||
parameters.ConnectionParameters.PlexServerAuthToken,
|
||||
parameters.Library);
|
||||
parameters.Library,
|
||||
parameters.FFprobePath);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -100,13 +103,14 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(ISynchronizePlexLibraryById request) =>
|
||||
(await ValidateConnection(request), await PlexLibraryMustExist(request),
|
||||
await ValidateLibraryRefreshInterval())
|
||||
await ValidateLibraryRefreshInterval(), await ValidateFFprobePath())
|
||||
.Apply(
|
||||
(connectionParameters, plexLibrary, libraryRefreshInterval) => new RequestParameters(
|
||||
(connectionParameters, plexLibrary, libraryRefreshInterval, ffprobePath) => new RequestParameters(
|
||||
connectionParameters,
|
||||
plexLibrary,
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval
|
||||
libraryRefreshInterval,
|
||||
ffprobePath
|
||||
));
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> ValidateConnection(
|
||||
@@ -149,12 +153,20 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.FilterT(lri => lri > 0)
|
||||
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
|
||||
|
||||
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
|
||||
.FilterT(File.Exists)
|
||||
.Map(
|
||||
ffprobePath =>
|
||||
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
|
||||
|
||||
private record RequestParameters(
|
||||
ConnectionParameters ConnectionParameters,
|
||||
PlexLibrary Library,
|
||||
bool ForceScan,
|
||||
int LibraryRefreshInterval);
|
||||
int LibraryRefreshInterval,
|
||||
string FFprobePath);
|
||||
|
||||
private record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection)
|
||||
{
|
||||
|
||||
@@ -30,9 +30,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
case PlayoutMode.One:
|
||||
break;
|
||||
case PlayoutMode.Multiple:
|
||||
if (item.MultipleCount.GetValueOrDefault() <= 0)
|
||||
if (item.MultipleCount.GetValueOrDefault() < 0)
|
||||
{
|
||||
return BaseError.New("[MultipleCount] must be greater than 0 for playout mode 'multiple'");
|
||||
return BaseError.New("[MultipleCount] must be greater than or equal to 0 for playout mode 'multiple'");
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
@@ -39,8 +39,9 @@ namespace ErsatzTV.Application.Television.Queries
|
||||
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
List<string> languages = await _searchRepository.GetLanguagesForShow(show);
|
||||
return ProjectToViewModel(show, languages, maybeJellyfin, maybeEmby);
|
||||
List<string> mediaCodes = await _searchRepository.GetLanguagesForShow(show);
|
||||
List<string> languageCodes = await _searchRepository.GetAllLanguageCodes(mediaCodes);
|
||||
return ProjectToViewModel(show, languageCodes, maybeJellyfin, maybeEmby);
|
||||
},
|
||||
() => Task.FromResult(Option<TelevisionShowViewModel>.None));
|
||||
}
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
<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.9.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.16.1" />
|
||||
<PackageReference Include="NUnit" Version="3.13.2" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
|
||||
|
||||
@@ -93,5 +93,30 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
metadata.Count.Should().Be(2);
|
||||
metadata.Map(m => m.EpisodeNumber).Should().BeEquivalentTo(episode1, episode2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase("Something (2021).mkv", "Something")]
|
||||
[TestCase("Something Else (2021).mkv", "Something Else")]
|
||||
public void GetFallbackMetadata_Should_Set_Proper_Movie_Title(string path, string expectedTitle)
|
||||
{
|
||||
MovieMetadata metadata = _fallbackMetadataProvider.GetFallbackMetadata(
|
||||
new Movie
|
||||
{
|
||||
LibraryPath = new LibraryPath(),
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = path }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
metadata.Should().NotBeNull();
|
||||
metadata.Title.Should().Be(expectedTitle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,6 +458,182 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
result.Items[5].MediaItemId.Should().Be(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FloodContent_Should_FloodWithFixedStartTime()
|
||||
{
|
||||
var floodCollection = new Collection
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Flood Items",
|
||||
MediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
|
||||
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
|
||||
}
|
||||
};
|
||||
|
||||
var fixedCollection = new Collection
|
||||
{
|
||||
Id = 2,
|
||||
Name = "Fixed Items",
|
||||
MediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1)),
|
||||
TestMovie(4, TimeSpan.FromHours(1), new DateTime(2020, 1, 2))
|
||||
}
|
||||
};
|
||||
|
||||
var fakeRepository = new FakeMediaCollectionRepository(
|
||||
Map(
|
||||
(floodCollection.Id, floodCollection.MediaItems.ToList()),
|
||||
(fixedCollection.Id, fixedCollection.MediaItems.ToList())));
|
||||
|
||||
var items = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemFlood
|
||||
{
|
||||
Index = 1,
|
||||
Collection = floodCollection,
|
||||
CollectionId = floodCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(7)
|
||||
},
|
||||
new ProgramScheduleItemOne
|
||||
{
|
||||
Index = 2,
|
||||
Collection = fixedCollection,
|
||||
CollectionId = fixedCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(12)
|
||||
}
|
||||
};
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items,
|
||||
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
|
||||
};
|
||||
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(24);
|
||||
|
||||
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(6);
|
||||
|
||||
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(7));
|
||||
result.Items[0].MediaItemId.Should().Be(1);
|
||||
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(8));
|
||||
result.Items[1].MediaItemId.Should().Be(2);
|
||||
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(9));
|
||||
result.Items[2].MediaItemId.Should().Be(1);
|
||||
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(10));
|
||||
result.Items[3].MediaItemId.Should().Be(2);
|
||||
result.Items[4].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(11));
|
||||
result.Items[4].MediaItemId.Should().Be(1);
|
||||
|
||||
result.Items[5].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(12));
|
||||
result.Items[5].MediaItemId.Should().Be(3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FloodContent_Should_FloodWithFixedStartTime_FromAnchor()
|
||||
{
|
||||
var floodCollection = new Collection
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Flood Items",
|
||||
MediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
|
||||
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
|
||||
}
|
||||
};
|
||||
|
||||
var fixedCollection = new Collection
|
||||
{
|
||||
Id = 2,
|
||||
Name = "Fixed Items",
|
||||
MediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(3, TimeSpan.FromHours(2), new DateTime(2020, 1, 1)),
|
||||
TestMovie(4, TimeSpan.FromHours(1), new DateTime(2020, 1, 2))
|
||||
}
|
||||
};
|
||||
|
||||
var fakeRepository = new FakeMediaCollectionRepository(
|
||||
Map(
|
||||
(floodCollection.Id, floodCollection.MediaItems.ToList()),
|
||||
(fixedCollection.Id, fixedCollection.MediaItems.ToList())));
|
||||
|
||||
var items = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemFlood
|
||||
{
|
||||
Index = 1,
|
||||
Collection = floodCollection,
|
||||
CollectionId = floodCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(7)
|
||||
},
|
||||
new ProgramScheduleItemOne
|
||||
{
|
||||
Index = 2,
|
||||
Collection = fixedCollection,
|
||||
CollectionId = fixedCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(12)
|
||||
}
|
||||
};
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items,
|
||||
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
Anchor = new PlayoutAnchor
|
||||
{
|
||||
NextStart = HoursAfterMidnight(9).UtcDateTime,
|
||||
NextScheduleItem = items[0],
|
||||
NextScheduleItemId = 1,
|
||||
InFlood = true
|
||||
}
|
||||
};
|
||||
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(32);
|
||||
|
||||
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(5);
|
||||
|
||||
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(9));
|
||||
result.Items[0].MediaItemId.Should().Be(1);
|
||||
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(10));
|
||||
result.Items[1].MediaItemId.Should().Be(2);
|
||||
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(11));
|
||||
result.Items[2].MediaItemId.Should().Be(1);
|
||||
|
||||
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(12));
|
||||
result.Items[3].MediaItemId.Should().Be(3);
|
||||
|
||||
result.Items[4].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(7));
|
||||
result.Items[4].MediaItemId.Should().Be(2);
|
||||
|
||||
result.Anchor.InFlood.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FloodContent_Should_FloodAroundFixedContent_DurationWithoutOfflineTail()
|
||||
{
|
||||
@@ -729,6 +905,96 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
result.Anchor.MultipleRemaining.Should().Be(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Auto_Zero_MultipleCount()
|
||||
{
|
||||
var collectionOne = new Collection
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Multiple Items 1",
|
||||
MediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
|
||||
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
|
||||
TestMovie(3, TimeSpan.FromHours(1), new DateTime(2020, 1, 1))
|
||||
}
|
||||
};
|
||||
|
||||
var collectionTwo = new Collection
|
||||
{
|
||||
Id = 2,
|
||||
Name = "Multiple Items 2",
|
||||
MediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(4, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
|
||||
TestMovie(5, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
|
||||
}
|
||||
};
|
||||
|
||||
var fakeRepository = new FakeMediaCollectionRepository(
|
||||
Map(
|
||||
(collectionOne.Id, collectionOne.MediaItems.ToList()),
|
||||
(collectionTwo.Id, collectionTwo.MediaItems.ToList())));
|
||||
|
||||
var items = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemMultiple
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
Count = 0
|
||||
},
|
||||
new ProgramScheduleItemMultiple
|
||||
{
|
||||
Id = 2,
|
||||
Index = 2,
|
||||
Collection = collectionTwo,
|
||||
CollectionId = collectionTwo.Id,
|
||||
StartTime = null,
|
||||
Count = 0
|
||||
}
|
||||
};
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items,
|
||||
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
};
|
||||
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(5);
|
||||
|
||||
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(5);
|
||||
|
||||
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(0));
|
||||
result.Items[0].MediaItemId.Should().Be(1);
|
||||
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(1));
|
||||
result.Items[1].MediaItemId.Should().Be(2);
|
||||
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(2));
|
||||
result.Items[2].MediaItemId.Should().Be(3);
|
||||
|
||||
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3));
|
||||
result.Items[3].MediaItemId.Should().Be(4);
|
||||
result.Items[4].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(4));
|
||||
result.Items[4].MediaItemId.Should().Be(5);
|
||||
|
||||
result.Anchor.NextScheduleItem.Should().Be(items[0]);
|
||||
result.Anchor.MultipleRemaining.Should().BeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Alternating_Duration_Should_Maintain_Duration()
|
||||
{
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
|
||||
public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size");
|
||||
public static ConfigElementKey SchedulesPageSize => new("pages.schedules.page_size");
|
||||
public static ConfigElementKey SchedulesDetailPageSize => new("pages.schedules.detail_page_size");
|
||||
public static ConfigElementKey PlayoutsPageSize => new("pages.playouts.page_size");
|
||||
public static ConfigElementKey PlayoutsDetailPageSize => new("pages.playouts.detail_page_size");
|
||||
public static ConfigElementKey LogsPageSize => new("pages.logs.page_size");
|
||||
public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class LanguageCode
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string ThreeCode1 { get; set; }
|
||||
public string ThreeCode2 { get; set; }
|
||||
public string TwoCode { get; set; }
|
||||
public string EnglishName { get; set; }
|
||||
public string FrenchName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
public string Title { get; set; }
|
||||
public bool Default { get; set; }
|
||||
public bool Forced { get; set; }
|
||||
public string PixelFormat { get; set; }
|
||||
public int BitsPerRawSample { get; set; }
|
||||
public int MediaVersionId { get; set; }
|
||||
public MediaVersion MediaVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace ErsatzTV.Core.Domain
|
||||
public DateTime NextStart { get; set; }
|
||||
public int? MultipleRemaining { get; set; }
|
||||
public DateTime? DurationFinish { get; set; }
|
||||
public bool InFlood { get; set; }
|
||||
|
||||
public DateTimeOffset NextStartOffset => new DateTimeOffset(NextStart, TimeSpan.Zero).ToLocalTime();
|
||||
|
||||
|
||||
@@ -231,7 +231,6 @@ namespace ErsatzTV.Core.Emby
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
incoming.ShowId = show.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
_logger.LogDebug(
|
||||
@@ -239,7 +238,7 @@ namespace ErsatzTV.Core.Emby
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title);
|
||||
|
||||
await _televisionRepository.AddSeason(incoming);
|
||||
await _televisionRepository.AddSeason(show, incoming);
|
||||
});
|
||||
|
||||
List<EmbyItemEtag> existingEpisodes =
|
||||
@@ -364,7 +363,6 @@ namespace ErsatzTV.Core.Emby
|
||||
try
|
||||
{
|
||||
updateStatistics = true;
|
||||
incoming.SeasonId = season.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
_logger.LogDebug(
|
||||
@@ -373,7 +371,7 @@ namespace ErsatzTV.Core.Emby
|
||||
seasonName,
|
||||
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
|
||||
|
||||
if (await _televisionRepository.AddEpisode(incoming))
|
||||
if (await _televisionRepository.AddEpisode(season, incoming))
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
private IDisplaySize _resolution;
|
||||
private Option<IDisplaySize> _scaleToSize = None;
|
||||
private Option<ChannelWatermark> _watermark;
|
||||
private string _pixelFormat;
|
||||
|
||||
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
|
||||
{
|
||||
@@ -63,6 +64,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithInputPixelFormat(string pixelFormat)
|
||||
{
|
||||
_pixelFormat = pixelFormat;
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithWatermark(Option<ChannelWatermark> watermark, IDisplaySize resolution)
|
||||
{
|
||||
_watermark = watermark;
|
||||
@@ -128,6 +135,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
string filter = acceleration switch
|
||||
{
|
||||
HardwareAccelerationKind.Qsv => $"scale_qsv=w={size.Width}:h={size.Height}",
|
||||
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
|
||||
$"hwdownload,format=p010le,format=nv12,hwupload,scale_npp={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Nvenc => $"scale_npp={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Vaapi => $"scale_vaapi=w={size.Width}:h={size.Height}",
|
||||
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
|
||||
@@ -150,6 +159,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
string format = acceleration switch
|
||||
{
|
||||
HardwareAccelerationKind.Vaapi => "format=nv12|vaapi",
|
||||
HardwareAccelerationKind.Nvenc when _scaleToSize.IsNone && _pixelFormat == "yuv420p10le" =>
|
||||
"format=p010le,format=nv12",
|
||||
_ => "format=nv12"
|
||||
};
|
||||
videoFilterQueue.Add(format);
|
||||
|
||||
@@ -175,7 +175,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithInputCodec(string input, HardwareAccelerationKind hwAccel, string codec)
|
||||
public FFmpegProcessBuilder WithInputCodec(string input, HardwareAccelerationKind hwAccel, string codec, string pixelFormat)
|
||||
{
|
||||
if (hwAccel == HardwareAccelerationKind.Qsv && QsvMap.TryGetValue(codec, out string qsvCodec))
|
||||
{
|
||||
@@ -183,7 +183,9 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
_arguments.Add(qsvCodec);
|
||||
}
|
||||
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithInputCodec(codec);
|
||||
_complexFilterBuilder = _complexFilterBuilder
|
||||
.WithInputCodec(codec)
|
||||
.WithInputPixelFormat(pixelFormat);
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add($"{input}");
|
||||
@@ -228,14 +230,29 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithMetadata(Channel channel)
|
||||
public FFmpegProcessBuilder WithMetadata(Channel channel, Option<MediaStream> maybeAudioStream)
|
||||
{
|
||||
if (channel.StreamingMode == StreamingMode.TransportStream)
|
||||
{
|
||||
_arguments.AddRange(new[] { "-map_metadata", "-1" });
|
||||
}
|
||||
|
||||
foreach (MediaStream audioStream in maybeAudioStream)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(audioStream.Language))
|
||||
{
|
||||
_arguments.AddRange(new[] { "-metadata:s:a:0", $"language={audioStream.Language}" });
|
||||
}
|
||||
}
|
||||
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"-metadata", "service_provider=\"ErsatzTV\"",
|
||||
"-metadata", $"service_name=\"{channel.Name}\""
|
||||
};
|
||||
|
||||
_arguments.AddRange(arguments);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -321,7 +338,6 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
new[]
|
||||
{
|
||||
"-c:a", playbackSettings.AudioCodec,
|
||||
"-map_metadata", "-1",
|
||||
"-movflags", "+faststart",
|
||||
"-muxdelay", "0",
|
||||
"-muxpreload", "0"
|
||||
|
||||
@@ -62,7 +62,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
.WithSeek(playbackSettings.StreamSeek)
|
||||
.WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec)
|
||||
.WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec, videoStream.PixelFormat)
|
||||
.WithWatermark(maybeWatermark, maybeWatermarkPath, channel.FFmpegProfile.Resolution, isAnimated)
|
||||
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
@@ -105,7 +105,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
});
|
||||
|
||||
return builder.WithPlaybackArgs(playbackSettings)
|
||||
.WithMetadata(channel)
|
||||
.WithMetadata(channel, maybeAudioStream)
|
||||
.WithFormat("mpegts")
|
||||
.WithDuration(start + version.Duration - now)
|
||||
.WithPipe()
|
||||
@@ -130,7 +130,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.WithErrorText(desiredResolution, errorMessage)
|
||||
.WithPixfmt("yuv420p")
|
||||
.WithPlaybackArgs(playbackSettings)
|
||||
.WithMetadata(channel)
|
||||
.WithMetadata(channel, None)
|
||||
.WithFormat("mpegts");
|
||||
|
||||
duration.IfSome(d => builder = builder.WithDuration(d));
|
||||
@@ -149,7 +149,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
.WithInfiniteLoop()
|
||||
.WithConcat($"http://localhost:{Settings.ListenPort}/ffmpeg/concat/{channel.Number}")
|
||||
.WithMetadata(channel)
|
||||
.WithMetadata(channel, None)
|
||||
.WithFormat("mpegts")
|
||||
.WithPipe()
|
||||
.Build();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -13,12 +14,15 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ILogger<FFmpegStreamSelector> _logger;
|
||||
|
||||
public FFmpegStreamSelector(
|
||||
ISearchRepository searchRepository,
|
||||
ILogger<FFmpegStreamSelector> logger,
|
||||
IConfigElementRepository configElementRepository)
|
||||
{
|
||||
_searchRepository = searchRepository;
|
||||
_logger = logger;
|
||||
_configElementRepository = configElementRepository;
|
||||
}
|
||||
@@ -54,24 +58,31 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
});
|
||||
}
|
||||
|
||||
List<string> allCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language });
|
||||
if (allCodes.Count > 1)
|
||||
{
|
||||
_logger.LogDebug("Preferred language has multiple codes {Codes}", allCodes);
|
||||
}
|
||||
|
||||
var correctLanguage = audioStreams.Filter(
|
||||
s => string.Equals(
|
||||
s.Language,
|
||||
language,
|
||||
StringComparison.InvariantCultureIgnoreCase)).ToList();
|
||||
s => allCodes.Any(
|
||||
c => string.Equals(
|
||||
s.Language,
|
||||
c,
|
||||
StringComparison.InvariantCultureIgnoreCase))).ToList();
|
||||
if (correctLanguage.Any())
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Found {Count} audio streams with preferred language code {Code}; selecting stream with most channels",
|
||||
"Found {Count} audio streams with preferred language code(s) {Code}; selecting stream with most channels",
|
||||
correctLanguage.Count,
|
||||
language);
|
||||
allCodes);
|
||||
|
||||
return correctLanguage.OrderByDescending(s => s.Channels).Head();
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Unable to find audio stream with preferred language code {Code}; selecting stream with most channels",
|
||||
language);
|
||||
"Unable to find audio stream with preferred language code(s) {Code}; selecting stream with most channels",
|
||||
allCodes);
|
||||
|
||||
return audioStreams.OrderByDescending(s => s.Channels).Head();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace ErsatzTV.Core.Interfaces.Plex
|
||||
Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library);
|
||||
PlexLibrary library,
|
||||
string ffprobePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace ErsatzTV.Core.Interfaces.Plex
|
||||
Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library);
|
||||
PlexLibrary library,
|
||||
string ffprobePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<List<EmbyItemEtag>> GetExistingEpisodes(EmbyLibrary library, string seasonItemId);
|
||||
Task<bool> AddShow(EmbyShow show);
|
||||
Task<Option<EmbyShow>> Update(EmbyShow show);
|
||||
Task<bool> AddSeason(EmbySeason season);
|
||||
Task<bool> AddSeason(EmbyShow show, EmbySeason season);
|
||||
Task<Unit> Update(EmbySeason season);
|
||||
Task<bool> AddEpisode(EmbyEpisode episode);
|
||||
Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode);
|
||||
Task<Option<EmbyEpisode>> Update(EmbyEpisode episode);
|
||||
Task<List<int>> RemoveMissingShows(EmbyLibrary library, List<string> showIds);
|
||||
Task<Unit> RemoveMissingSeasons(EmbyLibrary library, List<string> seasonIds);
|
||||
|
||||
@@ -13,9 +13,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<List<JellyfinItemEtag>> GetExistingEpisodes(JellyfinLibrary library, string seasonItemId);
|
||||
Task<bool> AddShow(JellyfinShow show);
|
||||
Task<Option<JellyfinShow>> Update(JellyfinShow show);
|
||||
Task<bool> AddSeason(JellyfinSeason season);
|
||||
Task<bool> AddSeason(JellyfinShow show, JellyfinSeason season);
|
||||
Task<Unit> Update(JellyfinSeason season);
|
||||
Task<bool> AddEpisode(JellyfinEpisode episode);
|
||||
Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode);
|
||||
Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode);
|
||||
Task<List<int>> RemoveMissingShows(JellyfinLibrary library, List<string> showIds);
|
||||
Task<Unit> RemoveMissingSeasons(JellyfinLibrary library, List<string> seasonIds);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface ILogRepository
|
||||
{
|
||||
Task<List<LogEntry>> GetRecentLogEntries();
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,10 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface ISearchRepository
|
||||
{
|
||||
public Task<List<int>> GetItemIdsToIndex();
|
||||
public Task<Option<MediaItem>> GetItemToIndex(int id);
|
||||
public Task<List<string>> GetLanguagesForShow(Show show);
|
||||
public Task<List<string>> GetLanguagesForArtist(Artist artist);
|
||||
Task<List<int>> GetItemIdsToIndex();
|
||||
Task<Option<MediaItem>> GetItemToIndex(int id);
|
||||
Task<List<string>> GetLanguagesForShow(Show show);
|
||||
Task<List<string>> GetLanguagesForArtist(Artist artist);
|
||||
Task<List<string>> GetAllLanguageCodes(List<string> mediaCodes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +231,6 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
incoming.ShowId = show.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
_logger.LogDebug(
|
||||
@@ -239,7 +238,7 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title);
|
||||
|
||||
await _televisionRepository.AddSeason(incoming);
|
||||
await _televisionRepository.AddSeason(show, incoming);
|
||||
});
|
||||
|
||||
List<JellyfinItemEtag> existingEpisodes =
|
||||
@@ -365,7 +364,6 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
try
|
||||
{
|
||||
updateStatistics = true;
|
||||
incoming.SeasonId = season.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
_logger.LogDebug(
|
||||
@@ -374,7 +372,7 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
seasonName,
|
||||
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
|
||||
|
||||
if (await _televisionRepository.AddEpisode(incoming))
|
||||
if (await _televisionRepository.AddEpisode(season, incoming))
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
|
||||
}
|
||||
|
||||
@@ -161,13 +161,15 @@ namespace ErsatzTV.Core.Metadata
|
||||
Match match = Regex.Match(fileName, PATTERN);
|
||||
if (match.Success)
|
||||
{
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.Title = match.Groups[1].Value.Trim();
|
||||
metadata.Year = int.Parse(match.Groups[2].Value);
|
||||
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
|
||||
metadata.Genres = new List<Genre>();
|
||||
metadata.Tags = new List<Tag>();
|
||||
metadata.Studios = new List<Studio>();
|
||||
metadata.Actors = new List<Actor>();
|
||||
metadata.Directors = new List<Director>();
|
||||
metadata.Writers = new List<Writer>();
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,9 +194,15 @@ namespace ErsatzTV.Core.Metadata
|
||||
MediaStreamKind = MediaStreamKind.Video,
|
||||
Index = videoStream.index,
|
||||
Codec = videoStream.codec_name,
|
||||
Profile = (videoStream.profile ?? string.Empty).ToLowerInvariant()
|
||||
Profile = (videoStream.profile ?? string.Empty).ToLowerInvariant(),
|
||||
PixelFormat = (videoStream.pix_fmt ?? string.Empty).ToLowerInvariant(),
|
||||
};
|
||||
|
||||
if (int.TryParse(videoStream.bits_per_raw_sample, out int bitsPerRawSample))
|
||||
{
|
||||
stream.BitsPerRawSample = bitsPerRawSample;
|
||||
}
|
||||
|
||||
if (videoStream.disposition is not null)
|
||||
{
|
||||
stream.Default = videoStream.disposition.@default == 1;
|
||||
@@ -262,8 +268,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
int height,
|
||||
string sample_aspect_ratio,
|
||||
string display_aspect_ratio,
|
||||
string pix_fmt,
|
||||
string field_order,
|
||||
string r_frame_rate,
|
||||
string bits_per_raw_sample,
|
||||
FFprobeDisposition disposition,
|
||||
FFProbeTags tags);
|
||||
// ReSharper restore InconsistentNaming
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -18,6 +17,7 @@ namespace ErsatzTV.Core.Plex
|
||||
public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScanner
|
||||
{
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILogger<PlexMovieLibraryScanner> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMediator _mediator;
|
||||
@@ -38,6 +38,7 @@ namespace ErsatzTV.Core.Plex
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILogger<PlexMovieLibraryScanner> logger)
|
||||
: base(metadataRepository, logger)
|
||||
{
|
||||
@@ -50,13 +51,15 @@ namespace ErsatzTV.Core.Plex
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_localFileSystem = localFileSystem;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library)
|
||||
PlexLibrary library,
|
||||
string ffprobePath)
|
||||
{
|
||||
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
.GetPlexPathReplacements(library.MediaSourceId);
|
||||
@@ -95,7 +98,7 @@ namespace ErsatzTV.Core.Plex
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
|
||||
.GetOrAdd(library, incoming)
|
||||
.BindT(existing => UpdateStatistics(existing, incoming, library, connection, token))
|
||||
.BindT(existing => UpdateStatistics(pathReplacements, existing, incoming, ffprobePath))
|
||||
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token))
|
||||
.BindT(existing => UpdateArtwork(existing, incoming));
|
||||
|
||||
@@ -144,11 +147,10 @@ namespace ErsatzTV.Core.Plex
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateStatistics(
|
||||
List<PlexPathReplacement> pathReplacements,
|
||||
MediaItemScanResult<PlexMovie> result,
|
||||
PlexMovie incoming,
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
string ffprobePath)
|
||||
{
|
||||
PlexMovie existing = result.Item;
|
||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||
@@ -156,30 +158,37 @@ namespace ErsatzTV.Core.Plex
|
||||
|
||||
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
|
||||
{
|
||||
Either<BaseError, Tuple<MovieMetadata, MediaVersion>> maybeStatistics =
|
||||
await _plexServerApiClient.GetMovieMetadataAndStatistics(
|
||||
library,
|
||||
incoming.Key.Split("/").Last(),
|
||||
connection,
|
||||
token);
|
||||
|
||||
await maybeStatistics.Match(
|
||||
async tuple =>
|
||||
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
|
||||
pathReplacements,
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
{
|
||||
(MovieMetadata _, MediaVersion mediaVersion) = tuple;
|
||||
foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id))
|
||||
{
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { updated });
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Refreshing {Attribute} from {Path}",
|
||||
"Plex Statistics",
|
||||
existingVersion.MediaFiles.Head().Path);
|
||||
|
||||
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
|
||||
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
|
||||
existingVersion.DateUpdated = mediaVersion.DateUpdated;
|
||||
|
||||
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, mediaVersion);
|
||||
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, incomingVersion);
|
||||
},
|
||||
_ => Task.CompletedTask);
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||
"Statistics",
|
||||
localPath,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace ErsatzTV.Core.Plex
|
||||
public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionLibraryScanner
|
||||
{
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILogger<PlexTelevisionLibraryScanner> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMediator _mediator;
|
||||
@@ -40,6 +41,7 @@ namespace ErsatzTV.Core.Plex
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILogger<PlexTelevisionLibraryScanner> logger)
|
||||
: base(metadataRepository, logger)
|
||||
{
|
||||
@@ -52,13 +54,15 @@ namespace ErsatzTV.Core.Plex
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_localFileSystem = localFileSystem;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library)
|
||||
PlexLibrary library,
|
||||
string ffprobePath)
|
||||
{
|
||||
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
.GetPlexPathReplacements(library.MediaSourceId);
|
||||
@@ -85,7 +89,7 @@ namespace ErsatzTV.Core.Plex
|
||||
await maybeShow.Match(
|
||||
async result =>
|
||||
{
|
||||
await ScanSeasons(library, pathReplacements, result.Item, connection, token);
|
||||
await ScanSeasons(library, pathReplacements, result.Item, connection, token, ffprobePath);
|
||||
|
||||
if (result.IsAdded)
|
||||
{
|
||||
@@ -288,7 +292,8 @@ namespace ErsatzTV.Core.Plex
|
||||
List<PlexPathReplacement> pathReplacements,
|
||||
PlexShow show,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
PlexServerAuthToken token,
|
||||
string ffprobePath)
|
||||
{
|
||||
Either<BaseError, List<PlexSeason>> entries = await _plexServerApiClient.GetShowSeasons(
|
||||
library,
|
||||
@@ -309,7 +314,13 @@ namespace ErsatzTV.Core.Plex
|
||||
.BindT(existing => UpdateMetadataAndArtwork(existing, incoming));
|
||||
|
||||
await maybeSeason.Match(
|
||||
async season => await ScanEpisodes(library, pathReplacements, season, connection, token),
|
||||
async season => await ScanEpisodes(
|
||||
library,
|
||||
pathReplacements,
|
||||
season,
|
||||
connection,
|
||||
token,
|
||||
ffprobePath),
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
@@ -373,7 +384,8 @@ namespace ErsatzTV.Core.Plex
|
||||
List<PlexPathReplacement> pathReplacements,
|
||||
PlexSeason season,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
PlexServerAuthToken token,
|
||||
string ffprobePath)
|
||||
{
|
||||
Either<BaseError, List<PlexEpisode>> entries = await _plexServerApiClient.GetSeasonEpisodes(
|
||||
library,
|
||||
@@ -414,11 +426,13 @@ namespace ErsatzTV.Core.Plex
|
||||
.BindT(existing => UpdateMetadata(existing, incoming))
|
||||
.BindT(
|
||||
existing => UpdateStatistics(
|
||||
pathReplacements,
|
||||
existing,
|
||||
incoming,
|
||||
library,
|
||||
connection,
|
||||
token))
|
||||
token,
|
||||
ffprobePath))
|
||||
.BindT(existing => UpdateArtwork(existing, incoming));
|
||||
|
||||
await maybeEpisode.Match(
|
||||
@@ -484,57 +498,89 @@ namespace ErsatzTV.Core.Plex
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlexEpisode>> UpdateStatistics(
|
||||
List<PlexPathReplacement> pathReplacements,
|
||||
PlexEpisode existing,
|
||||
PlexEpisode incoming,
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
PlexServerAuthToken token,
|
||||
string ffprobePath)
|
||||
{
|
||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = incoming.MediaVersions.Head();
|
||||
|
||||
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
|
||||
{
|
||||
Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>> maybeStatistics =
|
||||
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
|
||||
library,
|
||||
incoming.Key.Split("/").Last(),
|
||||
connection,
|
||||
token);
|
||||
|
||||
await maybeStatistics.Match(
|
||||
async tuple =>
|
||||
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
|
||||
pathReplacements,
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
{
|
||||
(EpisodeMetadata incomingMetadata, MediaVersion mediaVersion) = tuple;
|
||||
|
||||
Option<EpisodeMetadata> maybeExisting = existing.EpisodeMetadata
|
||||
.Find(em => em.EpisodeNumber == incomingMetadata.EpisodeNumber);
|
||||
foreach (EpisodeMetadata existingMetadata in maybeExisting)
|
||||
foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id))
|
||||
{
|
||||
foreach (MetadataGuid guid in existingMetadata.Guids
|
||||
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Guids.Remove(guid);
|
||||
await _metadataRepository.RemoveGuid(guid);
|
||||
}
|
||||
|
||||
foreach (MetadataGuid guid in incomingMetadata.Guids
|
||||
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Guids.Add(guid);
|
||||
await _metadataRepository.AddGuid(existingMetadata, guid);
|
||||
}
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { updated });
|
||||
}
|
||||
|
||||
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
|
||||
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
|
||||
existingVersion.DateUpdated = mediaVersion.DateUpdated;
|
||||
Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>> maybeStatistics =
|
||||
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
|
||||
library,
|
||||
incoming.Key.Split("/").Last(),
|
||||
connection,
|
||||
token);
|
||||
|
||||
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, mediaVersion);
|
||||
await maybeStatistics.Match(
|
||||
async tuple =>
|
||||
{
|
||||
(EpisodeMetadata incomingMetadata, MediaVersion mediaVersion) = tuple;
|
||||
|
||||
Option<EpisodeMetadata> maybeExisting = existing.EpisodeMetadata
|
||||
.Find(em => em.EpisodeNumber == incomingMetadata.EpisodeNumber);
|
||||
foreach (EpisodeMetadata existingMetadata in maybeExisting)
|
||||
{
|
||||
foreach (MetadataGuid guid in existingMetadata.Guids
|
||||
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Guids.Remove(guid);
|
||||
await _metadataRepository.RemoveGuid(guid);
|
||||
}
|
||||
|
||||
foreach (MetadataGuid guid in incomingMetadata.Guids
|
||||
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Guids.Add(guid);
|
||||
await _metadataRepository.AddGuid(existingMetadata, guid);
|
||||
}
|
||||
}
|
||||
|
||||
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
|
||||
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
|
||||
existingVersion.DateUpdated = mediaVersion.DateUpdated;
|
||||
|
||||
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, mediaVersion);
|
||||
},
|
||||
_ => Task.CompletedTask);
|
||||
},
|
||||
_ => Task.CompletedTask);
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||
"Statistics",
|
||||
localPath,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
return Right<BaseError, PlexEpisode>(existing);
|
||||
|
||||
@@ -52,6 +52,15 @@ namespace ErsatzTV.Core.Scheduling
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (!collectionKeys.Any())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Playout {Playout} schedule {Schedule} has no items",
|
||||
playout.Channel.Name,
|
||||
playout.ProgramSchedule.Name);
|
||||
return playout;
|
||||
}
|
||||
|
||||
IEnumerable<Tuple<CollectionKey, List<MediaItem>>> tuples = await collectionKeys.Map(
|
||||
async collectionKey =>
|
||||
{
|
||||
@@ -192,6 +201,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
// start with the previous multiple/duration states
|
||||
Option<int> multipleRemaining = Optional(startAnchor.MultipleRemaining);
|
||||
Option<DateTimeOffset> durationFinish = startAnchor.DurationFinishOffset;
|
||||
bool inFlood = startAnchor.InFlood;
|
||||
|
||||
bool customGroup = multipleRemaining.IsSome || durationFinish.IsSome;
|
||||
|
||||
@@ -206,7 +216,8 @@ namespace ErsatzTV.Core.Scheduling
|
||||
scheduleItem,
|
||||
currentTime,
|
||||
multipleRemaining.IsSome,
|
||||
durationFinish.IsSome);
|
||||
durationFinish.IsSome,
|
||||
inFlood);
|
||||
|
||||
IMediaCollectionEnumerator enumerator = collectionEnumerators[CollectionKeyForItem(scheduleItem)];
|
||||
await enumerator.Current.IfSomeAsync(
|
||||
@@ -259,7 +270,16 @@ namespace ErsatzTV.Core.Scheduling
|
||||
case ProgramScheduleItemMultiple multiple:
|
||||
if (multipleRemaining.IsNone)
|
||||
{
|
||||
multipleRemaining = multiple.Count;
|
||||
if (multiple.Count == 0)
|
||||
{
|
||||
multipleRemaining = collectionMediaItems[CollectionKeyForItem(scheduleItem)]
|
||||
.Count;
|
||||
}
|
||||
else
|
||||
{
|
||||
multipleRemaining = multiple.Count;
|
||||
}
|
||||
|
||||
customGroup = true;
|
||||
}
|
||||
|
||||
@@ -309,6 +329,11 @@ namespace ErsatzTV.Core.Scheduling
|
||||
"Flood");
|
||||
index++;
|
||||
customGroup = false;
|
||||
inFlood = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
inFlood = true;
|
||||
}
|
||||
});
|
||||
break;
|
||||
@@ -365,7 +390,8 @@ namespace ErsatzTV.Core.Scheduling
|
||||
NextScheduleItemId = nextScheduleItem.Id,
|
||||
NextStart = GetStartTimeAfter(nextScheduleItem, currentTime).UtcDateTime,
|
||||
MultipleRemaining = multipleRemaining.IsSome ? multipleRemaining.ValueUnsafe() : null,
|
||||
DurationFinish = durationFinish.IsSome ? durationFinish.ValueUnsafe().UtcDateTime : null
|
||||
DurationFinish = durationFinish.IsSome ? durationFinish.ValueUnsafe().UtcDateTime : null,
|
||||
InFlood = inFlood
|
||||
};
|
||||
|
||||
// build program schedule anchors
|
||||
@@ -410,13 +436,15 @@ namespace ErsatzTV.Core.Scheduling
|
||||
ProgramScheduleItem item,
|
||||
DateTimeOffset start,
|
||||
bool inMultiple = false,
|
||||
bool inDuration = false)
|
||||
bool inDuration = false,
|
||||
bool inFlood = false)
|
||||
{
|
||||
switch (item.StartType)
|
||||
{
|
||||
case StartType.Fixed:
|
||||
if (item is ProgramScheduleItemMultiple && inMultiple ||
|
||||
item is ProgramScheduleItemDuration && inDuration)
|
||||
item is ProgramScheduleItemDuration && inDuration ||
|
||||
item is ProgramScheduleItemFlood && inFlood)
|
||||
{
|
||||
return start;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class LanguageCodeConfiguration : IEntityTypeConfiguration<LanguageCode>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LanguageCode> builder) => builder.ToTable("LanguageCode");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -12,6 +14,43 @@ namespace ErsatzTV.Infrastructure.Data
|
||||
{
|
||||
public static async Task<Unit> Initialize(TvContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!context.LanguageCodes.Any())
|
||||
{
|
||||
var assembly = Assembly.GetEntryAssembly();
|
||||
if (assembly != null)
|
||||
{
|
||||
await using Stream resource =
|
||||
assembly.GetManifestResourceStream("ErsatzTV.Resources.ISO-639-2_utf-8.txt");
|
||||
if (resource != null)
|
||||
{
|
||||
using var reader = new StreamReader(resource);
|
||||
while (!reader.EndOfStream)
|
||||
{
|
||||
string line = await reader.ReadLineAsync();
|
||||
if (line != null)
|
||||
{
|
||||
string[] split = line.Split("|");
|
||||
if (split.Length == 5)
|
||||
{
|
||||
var languageCode = new LanguageCode
|
||||
{
|
||||
ThreeCode1 = split[0],
|
||||
ThreeCode2 = split[1],
|
||||
TwoCode = split[2],
|
||||
EnglishName = split[3],
|
||||
FrenchName = split[4]
|
||||
};
|
||||
|
||||
await context.LanguageCodes.AddAsync(languageCode, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (context.Resolutions.Any())
|
||||
{
|
||||
return Unit.Default;
|
||||
|
||||
@@ -242,18 +242,29 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
return maybeExisting;
|
||||
}
|
||||
|
||||
public async Task<bool> AddSeason(EmbySeason season)
|
||||
public async Task<bool> AddSeason(EmbyShow show, EmbySeason season)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(season);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
try
|
||||
{
|
||||
season.ShowId = await _dbConnection.ExecuteScalarAsync<int>(
|
||||
@"SELECT Id FROM EmbyShow WHERE ItemId = @ItemId",
|
||||
new { show.ItemId });
|
||||
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(season);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Unit> Update(EmbySeason season)
|
||||
@@ -368,19 +379,30 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public async Task<bool> AddEpisode(EmbyEpisode episode)
|
||||
public async Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(episode);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
try
|
||||
{
|
||||
episode.SeasonId = await _dbConnection.ExecuteScalarAsync<int>(
|
||||
@"SELECT Id FROM EmbySeason WHERE ItemId = @ItemId",
|
||||
new { season.ItemId });
|
||||
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(episode);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Option<EmbyEpisode>> Update(EmbyEpisode episode)
|
||||
|
||||
@@ -259,18 +259,29 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
return maybeExisting;
|
||||
}
|
||||
|
||||
public async Task<bool> AddSeason(JellyfinSeason season)
|
||||
public async Task<bool> AddSeason(JellyfinShow show, JellyfinSeason season)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(season);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
try
|
||||
{
|
||||
season.ShowId = await _dbConnection.ExecuteScalarAsync<int>(
|
||||
@"SELECT Id FROM JellyfinShow WHERE ItemId = @ItemId",
|
||||
new { show.ItemId });
|
||||
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(season);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Unit> Update(JellyfinSeason season)
|
||||
@@ -368,19 +379,30 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public async Task<bool> AddEpisode(JellyfinEpisode episode)
|
||||
public async Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(episode);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
try
|
||||
{
|
||||
episode.SeasonId = await _dbConnection.ExecuteScalarAsync<int>(
|
||||
@"SELECT Id FROM JellyfinSeason WHERE ItemId = @ItemId",
|
||||
new { season.ItemId });
|
||||
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(episode);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode)
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
public class LogRepository : ILogRepository
|
||||
{
|
||||
private readonly LogContext _logContext;
|
||||
|
||||
public LogRepository(LogContext logContext) => _logContext = logContext;
|
||||
|
||||
public Task<List<LogEntry>> GetRecentLogEntries() =>
|
||||
_logContext.LogEntries.OrderByDescending(e => e.Id).Take(100).ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -166,25 +166,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
() => Task.FromResult(false));
|
||||
}
|
||||
|
||||
public async Task<bool> UpdatePlexStatistics(int mediaVersionId, MediaVersion incoming)
|
||||
{
|
||||
bool updatedVersion = await _dbConnection.ExecuteAsync(
|
||||
public Task<bool> UpdatePlexStatistics(int mediaVersionId, MediaVersion incoming) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"UPDATE MediaVersion SET
|
||||
SampleAspectRatio = @SampleAspectRatio,
|
||||
VideoScanKind = @VideoScanKind,
|
||||
DateUpdated = @DateUpdated
|
||||
WHERE Id = @MediaVersionId",
|
||||
new
|
||||
{
|
||||
incoming.SampleAspectRatio,
|
||||
incoming.VideoScanKind,
|
||||
incoming.DateUpdated,
|
||||
MediaVersionId = mediaVersionId
|
||||
}).Map(result => result > 0);
|
||||
|
||||
return await UpdateLocalStatistics(mediaVersionId, incoming, false) || updatedVersion;
|
||||
}
|
||||
|
||||
public Task<Unit> UpdateArtworkPath(Artwork artwork) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"UPDATE Artwork SET Path = @Path, DateUpdated = @DateUpdated WHERE Id = @Id",
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static LanguageExt.Prelude;
|
||||
@@ -110,5 +111,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
INNER JOIN Artist A on MV.ArtistId = A.Id
|
||||
WHERE MediaStreamKind = 2 AND A.Id = @ArtistId",
|
||||
new { ArtistId = artist.Id }).Map(result => result.ToList());
|
||||
|
||||
public async Task<List<string>> GetAllLanguageCodes(List<string> mediaCodes)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.LanguageCodes.GetAllLanguageCodes(mediaCodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ namespace ErsatzTV.Infrastructure.Data
|
||||
public DbSet<PlayoutProgramScheduleAnchor> PlayoutProgramScheduleItemAnchors { get; set; }
|
||||
public DbSet<FFmpegProfile> FFmpegProfiles { get; set; }
|
||||
public DbSet<Resolution> Resolutions { get; set; }
|
||||
public DbSet<LanguageCode> LanguageCodes { get; set; }
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
|
||||
optionsBuilder.UseLoggerFactory(_loggerFactory);
|
||||
|
||||
@@ -13,16 +13,16 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Dapper" Version="2.0.90" />
|
||||
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00013" />
|
||||
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00013" />
|
||||
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00013" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.6" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.6">
|
||||
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00014" />
|
||||
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00014" />
|
||||
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00014" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.6" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.7" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Extensions
|
||||
{
|
||||
public static class LanguageCodeQueryableExtensions
|
||||
{
|
||||
public static async Task<List<string>> GetAllLanguageCodes(
|
||||
this IQueryable<LanguageCode> languageCodes,
|
||||
string mediaCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mediaCode))
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
string code = mediaCode.ToLowerInvariant();
|
||||
|
||||
List<LanguageCode> maybeLanguages = await languageCodes
|
||||
.Filter(lc => lc.ThreeCode1 == code || lc.ThreeCode2 == code)
|
||||
.ToListAsync();
|
||||
|
||||
var result = new HashSet<string>();
|
||||
foreach (LanguageCode language in maybeLanguages)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(language.ThreeCode1))
|
||||
{
|
||||
result.Add(language.ThreeCode1);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(language.ThreeCode2))
|
||||
{
|
||||
result.Add(language.ThreeCode2);
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToList();
|
||||
}
|
||||
|
||||
public static async Task<List<string>> GetAllLanguageCodes(
|
||||
this IQueryable<LanguageCode> languageCodes,
|
||||
List<string> mediaCodes)
|
||||
{
|
||||
var validCodes = mediaCodes
|
||||
.Filter(c => !string.IsNullOrWhiteSpace(c))
|
||||
.Map(c => c.ToLowerInvariant())
|
||||
.ToList();
|
||||
|
||||
if (validCodes.Count == 0)
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
List<LanguageCode> maybeLanguages = await languageCodes
|
||||
.Filter(lc => validCodes.Contains(lc.ThreeCode1) || validCodes.Contains(lc.ThreeCode2))
|
||||
.ToListAsync();
|
||||
|
||||
var result = new HashSet<string>();
|
||||
foreach (LanguageCode language in maybeLanguages)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(language.ThreeCode1))
|
||||
{
|
||||
result.Add(language.ThreeCode1);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(language.ThreeCode2))
|
||||
{
|
||||
result.Add(language.ThreeCode2);
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
+2970
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_LanguageCode : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LanguageCode",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
ThreeCode1 = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ThreeCode2 = table.Column<string>(type: "TEXT", nullable: true),
|
||||
TwoCode = table.Column<string>(type: "TEXT", nullable: true),
|
||||
EnglishName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
FrenchName = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LanguageCode", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "LanguageCode");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2976
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_MediaStreamPixelFormat : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "BitsPerRawSample",
|
||||
table: "MediaStream",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PixelFormat",
|
||||
table: "MediaStream",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BitsPerRawSample",
|
||||
table: "MediaStream");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PixelFormat",
|
||||
table: "MediaStream");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2976
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Reset_AllStatistics : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("UPDATE MediaVersion SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql("UPDATE LibraryFolder SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE EmbyMovie SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE EmbyShow SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE EmbySeason SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE EmbyEpisode SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE JellyfinMovie SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE JellyfinShow SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE JellyfinSeason SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE JellyfinEpisode SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql("UPDATE Library SET LastScan = '0001-01-01 00:00:00'");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+2979
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_PlayoutAnchorInFlood : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Anchor_InFlood",
|
||||
table: "Playout",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Anchor_InFlood",
|
||||
table: "Playout");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2979
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Fix_PlayoutAnchorInFlood : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("UPDATE Playout SET Anchor_InFlood = 0 WHERE Anchor_InFlood IS NULL");
|
||||
|
||||
migrationBuilder.AlterColumn<bool>(
|
||||
name: "Anchor_InFlood",
|
||||
table: "Playout",
|
||||
type: "INTEGER",
|
||||
nullable: false);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2979
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Fix_MovieFallbackMetadataTitle : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE MovieMetadata SET Title = TRIM(Title), SortTitle = TRIM(SortTitle) WHERE MetadataKind = 0");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "5.0.6");
|
||||
.HasAnnotation("ProductVersion", "5.0.7");
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
|
||||
{
|
||||
@@ -583,6 +583,32 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.ToTable("JellyfinPathReplacement");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.LanguageCode", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("EnglishName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FrenchName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreeCode1")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ThreeCode2")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TwoCode")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("LanguageCode");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -707,6 +733,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BitsPerRawSample")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Channels")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -731,6 +760,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Property<int>("MediaVersionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PixelFormat")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Profile")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -2157,6 +2189,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b1.Property<DateTime?>("DurationFinish")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<bool>("InFlood")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int?>("MultipleRemaining")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
_initialized = false;
|
||||
}
|
||||
|
||||
public int Version => 13;
|
||||
public int Version => 14;
|
||||
|
||||
public Task<bool> Initialize(ILocalFileSystem localFileSystem)
|
||||
{
|
||||
@@ -91,41 +91,6 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
return Task.FromResult(_initialized);
|
||||
}
|
||||
|
||||
public async Task<Unit> Rebuild(ISearchRepository searchRepository, List<int> itemIds)
|
||||
{
|
||||
_writer.DeleteAll();
|
||||
|
||||
foreach (int id in itemIds)
|
||||
{
|
||||
Option<MediaItem> maybeMediaItem = await searchRepository.GetItemToIndex(id);
|
||||
if (maybeMediaItem.IsSome)
|
||||
{
|
||||
MediaItem mediaItem = maybeMediaItem.ValueUnsafe();
|
||||
switch (mediaItem)
|
||||
{
|
||||
case Movie movie:
|
||||
UpdateMovie(movie);
|
||||
break;
|
||||
case Show show:
|
||||
await UpdateShow(searchRepository, show);
|
||||
break;
|
||||
case Artist artist:
|
||||
await UpdateArtist(searchRepository, artist);
|
||||
break;
|
||||
case MusicVideo musicVideo:
|
||||
UpdateMusicVideo(musicVideo);
|
||||
break;
|
||||
case Episode episode:
|
||||
UpdateEpisode(episode);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_writer.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public Task<Unit> AddItems(ISearchRepository searchRepository, List<MediaItem> items) =>
|
||||
UpdateItems(searchRepository, items);
|
||||
|
||||
@@ -136,7 +101,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
switch (item)
|
||||
{
|
||||
case Movie movie:
|
||||
UpdateMovie(movie);
|
||||
await UpdateMovie(searchRepository, movie);
|
||||
break;
|
||||
case Show show:
|
||||
await UpdateShow(searchRepository, show);
|
||||
@@ -145,10 +110,10 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
await UpdateArtist(searchRepository, artist);
|
||||
break;
|
||||
case MusicVideo musicVideo:
|
||||
UpdateMusicVideo(musicVideo);
|
||||
await UpdateMusicVideo(searchRepository, musicVideo);
|
||||
break;
|
||||
case Episode episode:
|
||||
UpdateEpisode(episode);
|
||||
await UpdateEpisode(searchRepository, episode);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -217,6 +182,41 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
_directory?.Dispose();
|
||||
}
|
||||
|
||||
public async Task<Unit> Rebuild(ISearchRepository searchRepository, List<int> itemIds)
|
||||
{
|
||||
_writer.DeleteAll();
|
||||
|
||||
foreach (int id in itemIds)
|
||||
{
|
||||
Option<MediaItem> maybeMediaItem = await searchRepository.GetItemToIndex(id);
|
||||
if (maybeMediaItem.IsSome)
|
||||
{
|
||||
MediaItem mediaItem = maybeMediaItem.ValueUnsafe();
|
||||
switch (mediaItem)
|
||||
{
|
||||
case Movie movie:
|
||||
await UpdateMovie(searchRepository, movie);
|
||||
break;
|
||||
case Show show:
|
||||
await UpdateShow(searchRepository, show);
|
||||
break;
|
||||
case Artist artist:
|
||||
await UpdateArtist(searchRepository, artist);
|
||||
break;
|
||||
case MusicVideo musicVideo:
|
||||
await UpdateMusicVideo(searchRepository, musicVideo);
|
||||
break;
|
||||
case Episode episode:
|
||||
await UpdateEpisode(searchRepository, episode);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_writer.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Option<SearchPageMap> GetSearchPageMap(
|
||||
IndexSearcher searcher,
|
||||
Query query,
|
||||
@@ -263,7 +263,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
return new SearchPageMap(map);
|
||||
}
|
||||
|
||||
private void UpdateMovie(Movie movie)
|
||||
private async Task UpdateMovie(ISearchRepository searchRepository, Movie movie)
|
||||
{
|
||||
Option<MovieMetadata> maybeMetadata = movie.MovieMetadata.HeadOrNone();
|
||||
if (maybeMetadata.IsSome)
|
||||
@@ -284,7 +284,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
|
||||
};
|
||||
|
||||
AddLanguages(doc, movie.MediaVersions);
|
||||
await AddLanguages(searchRepository, doc, movie.MediaVersions);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.ContentRating))
|
||||
{
|
||||
@@ -349,25 +349,38 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
}
|
||||
}
|
||||
|
||||
private void AddLanguages(Document doc, List<MediaVersion> mediaVersions)
|
||||
private async Task AddLanguages(ISearchRepository searchRepository, Document doc, List<MediaVersion> mediaVersions)
|
||||
{
|
||||
Option<MediaVersion> maybeVersion = mediaVersions.HeadOrNone();
|
||||
if (maybeVersion.IsSome)
|
||||
{
|
||||
MediaVersion version = maybeVersion.ValueUnsafe();
|
||||
foreach (CultureInfo cultureInfo in version.Streams
|
||||
var mediaCodes = version.Streams
|
||||
.Filter(ms => ms.MediaStreamKind == MediaStreamKind.Audio)
|
||||
.Map(ms => ms.Language).Distinct()
|
||||
.Filter(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Map(
|
||||
l => _cultureInfos.Filter(
|
||||
c => string.Equals(c.ThreeLetterISOLanguageName, l, StringComparison.OrdinalIgnoreCase)))
|
||||
.Sequence()
|
||||
.Flatten())
|
||||
.ToList();
|
||||
|
||||
await AddLanguages(searchRepository, doc, mediaCodes);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddLanguages(ISearchRepository searchRepository, Document doc, List<string> mediaCodes)
|
||||
{
|
||||
var englishNames = new System.Collections.Generic.HashSet<string>();
|
||||
foreach (string code in await searchRepository.GetAllLanguageCodes(mediaCodes))
|
||||
{
|
||||
Option<CultureInfo> maybeCultureInfo = _cultureInfos.Find(
|
||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, code, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (CultureInfo cultureInfo in maybeCultureInfo)
|
||||
{
|
||||
doc.Add(new TextField(LanguageField, cultureInfo.EnglishName, Field.Store.NO));
|
||||
englishNames.Add(cultureInfo.EnglishName);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string englishName in englishNames)
|
||||
{
|
||||
doc.Add(new TextField(LanguageField, englishName, Field.Store.NO));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateShow(ISearchRepository searchRepository, Show show)
|
||||
@@ -392,20 +405,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
};
|
||||
|
||||
List<string> languages = await searchRepository.GetLanguagesForShow(show);
|
||||
foreach (CultureInfo cultureInfo in languages
|
||||
.Distinct()
|
||||
.Filter(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Map(
|
||||
l => _cultureInfos.Filter(
|
||||
c => string.Equals(
|
||||
c.ThreeLetterISOLanguageName,
|
||||
l,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
.Sequence()
|
||||
.Flatten())
|
||||
{
|
||||
doc.Add(new TextField(LanguageField, cultureInfo.EnglishName, Field.Store.NO));
|
||||
}
|
||||
await AddLanguages(searchRepository, doc, languages);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.ContentRating))
|
||||
{
|
||||
@@ -482,20 +482,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
};
|
||||
|
||||
List<string> languages = await searchRepository.GetLanguagesForArtist(artist);
|
||||
foreach (CultureInfo cultureInfo in languages
|
||||
.Distinct()
|
||||
.Filter(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Map(
|
||||
l => _cultureInfos.Filter(
|
||||
c => string.Equals(
|
||||
c.ThreeLetterISOLanguageName,
|
||||
l,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
.Sequence()
|
||||
.Flatten())
|
||||
{
|
||||
doc.Add(new TextField(LanguageField, cultureInfo.EnglishName, Field.Store.NO));
|
||||
}
|
||||
await AddLanguages(searchRepository, doc, languages);
|
||||
|
||||
foreach (Genre genre in metadata.Genres)
|
||||
{
|
||||
@@ -522,7 +509,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateMusicVideo(MusicVideo musicVideo)
|
||||
private async Task UpdateMusicVideo(ISearchRepository searchRepository, MusicVideo musicVideo)
|
||||
{
|
||||
Option<MusicVideoMetadata> maybeMetadata = musicVideo.MusicVideoMetadata.HeadOrNone();
|
||||
if (maybeMetadata.IsSome)
|
||||
@@ -543,7 +530,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
|
||||
};
|
||||
|
||||
AddLanguages(doc, musicVideo.MediaVersions);
|
||||
await AddLanguages(searchRepository, doc, musicVideo.MediaVersions);
|
||||
|
||||
if (metadata.ReleaseDate.HasValue)
|
||||
{
|
||||
@@ -584,7 +571,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEpisode(Episode episode)
|
||||
private async Task UpdateEpisode(ISearchRepository searchRepository, Episode episode)
|
||||
{
|
||||
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata)
|
||||
{
|
||||
@@ -611,7 +598,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
doc.Add(new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO));
|
||||
doc.Add(new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES));
|
||||
|
||||
AddLanguages(doc, episode.MediaVersions);
|
||||
await AddLanguages(searchRepository, doc, episode.MediaVersions);
|
||||
|
||||
if (metadata.ReleaseDate.HasValue)
|
||||
{
|
||||
|
||||
@@ -17,24 +17,25 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Blazored.LocalStorage" Version="4.1.1" />
|
||||
<PackageReference Include="FluentValidation" Version="10.1.0" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="10.1.0" />
|
||||
<PackageReference Include="Blazored.LocalStorage" Version="4.1.2" />
|
||||
<PackageReference Include="FluentValidation" Version="10.3.0" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="10.3.0" />
|
||||
<PackageReference Include="HtmlSanitizer" Version="5.0.404" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
|
||||
<PackageReference Include="Markdig" Version="0.24.0" />
|
||||
<PackageReference Include="Markdig" Version="0.25.0" />
|
||||
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="3.0.1" />
|
||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.6" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.6">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MudBlazor" Version="5.0.10" />
|
||||
<PackageReference Include="MudBlazor" Version="5.0.15" />
|
||||
<PackageReference Include="NaturalSort.Extension" Version="3.1.0" />
|
||||
<PackageReference Include="PPioli.FluentValidation.Blazor" Version="5.0.0" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.38" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
@@ -53,6 +54,7 @@
|
||||
<EmbeddedResource Include="Resources\background.png" />
|
||||
<EmbeddedResource Include="Resources\ErsatzTV.png" />
|
||||
<EmbeddedResource Include="Resources\Roboto-Regular.ttf" />
|
||||
<EmbeddedResource Include="Resources\ISO-639-2_utf-8.txt" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace ErsatzTV.Extensions
|
||||
{
|
||||
public static class StringExtensions
|
||||
{
|
||||
public static string GetSearchQuery(this string uri)
|
||||
{
|
||||
try
|
||||
{
|
||||
string query = new Uri(uri).Query;
|
||||
Dictionary<string, StringValues> parsed = QueryHelpers.ParseQuery(query);
|
||||
if (parsed.TryGetValue("query", out StringValues value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
if (parsed.TryGetValue("b64query", out StringValues base64Value))
|
||||
{
|
||||
return base64Value.DecodeBase64();
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
public static string GetRelativeSearchQuery(this string query)
|
||||
{
|
||||
(string key, string value) = EncodeQuery(query);
|
||||
return $"/search?{key}={value}";
|
||||
}
|
||||
|
||||
private static string DecodeBase64(this StringValues input) =>
|
||||
Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(input));
|
||||
|
||||
public static EncodedQueryResult EncodeQuery(this string query)
|
||||
{
|
||||
string encoded = Uri.EscapeDataString(query);
|
||||
|
||||
// TODO: remove this on dotnet 6
|
||||
// see https://github.com/dotnet/aspnetcore/pull/26769
|
||||
var fakeAbsolute = $"https://whatever.com/test?query={encoded}";
|
||||
return Uri.IsWellFormedUriString(fakeAbsolute, UriKind.Absolute)
|
||||
? new EncodedQueryResult("query", encoded)
|
||||
: new EncodedQueryResult("b64query", WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(query)));
|
||||
}
|
||||
|
||||
public record EncodedQueryResult(string Key, string Value);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
@using ErsatzTV.Application.ProgramSchedules
|
||||
@using ErsatzTV.Application.ProgramSchedules.Commands
|
||||
@using System.Globalization
|
||||
@using ErsatzTV.Extensions
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inject IMediator _mediator
|
||||
@inject IDialogService _dialog
|
||||
@@ -73,11 +74,11 @@
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Languages: </MudText>
|
||||
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(_sortedLanguages.Head().EnglishName.ToLowerInvariant())}%22")">@_sortedLanguages.Head().EnglishName</MudLink>
|
||||
<MudLink Href="@(@$"language:""{_sortedLanguages.Head().EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedLanguages.Head().EnglishName</MudLink>
|
||||
@foreach (CultureInfo language in _sortedLanguages.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")">@language.EnglishName</MudLink>
|
||||
<MudLink Href="@(@$"language:""{language.EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@language.EnglishName</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -85,11 +86,11 @@
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Genres: </MudText>
|
||||
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(_sortedGenres.Head())}%22")">@_sortedGenres.Head()</MudLink>
|
||||
<MudLink Href="@(@$"genre:""{_sortedGenres.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedGenres.Head()</MudLink>
|
||||
@foreach (string genre in _sortedGenres.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre)}%22")">@genre</MudLink>
|
||||
<MudLink Href="@(@$"genre:""{genre.ToLowerInvariant()}""".GetRelativeSearchQuery())">@genre</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -97,11 +98,11 @@
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Styles: </MudText>
|
||||
<MudLink Href="@($"/search?query=style%3a%22{Uri.EscapeDataString(_sortedStyles.Head())}%22")">@_sortedStyles.Head()</MudLink>
|
||||
<MudLink Href="@(@$"style:""{_sortedStyles.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedStyles.Head()</MudLink>
|
||||
@foreach (string style in _sortedStyles.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=style%3a%22{Uri.EscapeDataString(style)}%22")">@style</MudLink>
|
||||
<MudLink Href="@(@$"style:""{style.ToLowerInvariant()}""".GetRelativeSearchQuery())">@style</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -109,11 +110,11 @@
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Moods: </MudText>
|
||||
<MudLink Href="@($"/search?query=mood%3a%22{Uri.EscapeDataString(_sortedMoods.Head())}%22")">@_sortedMoods.Head()</MudLink>
|
||||
<MudLink Href="@(@$"mood:""{_sortedMoods.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedMoods.Head()</MudLink>
|
||||
@foreach (string mood in _sortedMoods.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=mood%3a%22{Uri.EscapeDataString(mood)}%22")">@mood</MudLink>
|
||||
<MudLink Href="@(@$"mood:""{mood.ToLowerInvariant()}""".GetRelativeSearchQuery())">@mood</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
@page "/media/music/artists/page/{PageNumber:int}"
|
||||
@using LanguageExt.UnsafeValueAccess
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using Microsoft.Extensions.Primitives
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaCollections.Commands
|
||||
@using ErsatzTV.Application.Search.Queries
|
||||
@using ErsatzTV.Extensions
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inherits MultiSelectBase<MusicVideoList>
|
||||
@inject NavigationManager _navigationManager
|
||||
@@ -90,16 +90,7 @@
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
string query = new Uri(_navigationManager.Uri).Query;
|
||||
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
|
||||
{
|
||||
_query = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
_query = null;
|
||||
}
|
||||
|
||||
_query = _navigationManager.Uri.GetSearchQuery();
|
||||
return RefreshData();
|
||||
}
|
||||
|
||||
@@ -114,7 +105,8 @@
|
||||
var uri = $"/media/music/artists/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
_navigationManager.NavigateTo(uri);
|
||||
}
|
||||
@@ -124,7 +116,8 @@
|
||||
var uri = $"/media/music/artists/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
_navigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
@page "/media/tv/episodes/page/{PageNumber:int}"
|
||||
@using LanguageExt.UnsafeValueAccess
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using Microsoft.Extensions.Primitives
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaCollections.Commands
|
||||
@using ErsatzTV.Application.Search.Queries
|
||||
@using ErsatzTV.Extensions
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inherits MultiSelectBase<EpisodeList>
|
||||
@inject NavigationManager _navigationManager
|
||||
@@ -90,16 +90,7 @@
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
string query = new Uri(_navigationManager.Uri).Query;
|
||||
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
|
||||
{
|
||||
_query = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
_query = null;
|
||||
}
|
||||
|
||||
_query = _navigationManager.Uri.GetSearchQuery();
|
||||
return RefreshData();
|
||||
}
|
||||
|
||||
@@ -114,7 +105,8 @@
|
||||
var uri = $"/media/tv/episodes/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
_navigationManager.NavigateTo(uri);
|
||||
}
|
||||
@@ -124,7 +116,8 @@
|
||||
var uri = $"/media/tv/episodes/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
_navigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
@page "/system/logs"
|
||||
@using ErsatzTV.Application.Logs
|
||||
@using ErsatzTV.Application.Logs.Queries
|
||||
@using ErsatzTV.Application.Configuration.Queries
|
||||
@using ErsatzTV.Application.Configuration.Commands
|
||||
@inject IMediator _mediator
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudTable FixedHeader="true" Dense="true" Items="_logEntries">
|
||||
<MudTable FixedHeader="true"
|
||||
@bind-RowsPerPage="@_rowsPerPage"
|
||||
ServerData="@(new Func<TableState, Task<TableData<LogEntryViewModel>>>(ServerReload))"
|
||||
Dense="true"
|
||||
@ref="_table">
|
||||
<HeaderContent>
|
||||
<MudTh>
|
||||
<MudTableSortLabel SortBy="new Func<LogEntryViewModel, object>(x => x.Timestamp)">
|
||||
<MudTableSortLabel T="LogEntryViewModel" SortLabel="Timestamp">
|
||||
Timestamp
|
||||
</MudTableSortLabel>
|
||||
</MudTh>
|
||||
<MudTh>
|
||||
<MudTableSortLabel SortBy="new Func<LogEntryViewModel, object>(x => x.Level)">
|
||||
<MudTableSortLabel T="LogEntryViewModel" SortLabel="Level">
|
||||
Level
|
||||
</MudTableSortLabel>
|
||||
</MudTh>
|
||||
@@ -30,8 +36,47 @@
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private List<LogEntryViewModel> _logEntries;
|
||||
private MudTable<LogEntryViewModel> _table;
|
||||
private int _rowsPerPage;
|
||||
|
||||
protected override async Task OnInitializedAsync() => _logEntries = await _mediator.Send(new GetRecentLogEntries());
|
||||
protected override async Task OnParametersSetAsync() => _rowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.LogsPageSize))
|
||||
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
|
||||
|
||||
private async Task<TableData<LogEntryViewModel>> ServerReload(TableState state)
|
||||
{
|
||||
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.LogsPageSize, state.PageSize.ToString()));
|
||||
|
||||
PagedLogEntriesViewModel data;
|
||||
|
||||
switch (state.SortLabel?.ToLowerInvariant())
|
||||
{
|
||||
case "timestamp":
|
||||
data = await _mediator.Send(new GetRecentLogEntries(state.Page, state.PageSize)
|
||||
{
|
||||
SortExpression = le => le.Timestamp,
|
||||
SortDescending = state.SortDirection == SortDirection.None
|
||||
? Option<bool>.None
|
||||
: state.SortDirection == SortDirection.Descending
|
||||
});
|
||||
break;
|
||||
case "level":
|
||||
data = await _mediator.Send(new GetRecentLogEntries(state.Page, state.PageSize)
|
||||
{
|
||||
SortExpression = le => le.Level,
|
||||
SortDescending = state.SortDirection == SortDirection.None
|
||||
? Option<bool>.None
|
||||
: state.SortDirection == SortDirection.Descending
|
||||
});
|
||||
break;
|
||||
default:
|
||||
data = await _mediator.Send(new GetRecentLogEntries(state.Page, state.PageSize)
|
||||
{
|
||||
SortDescending = Option<bool>.None
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return new TableData<LogEntryViewModel> { TotalItems = data.TotalCount, Items = data.Page };
|
||||
}
|
||||
|
||||
}
|
||||
+16
-15
@@ -5,6 +5,7 @@
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaCollections.Commands
|
||||
@using ErsatzTV.Extensions
|
||||
@inject IMediator _mediator
|
||||
@inject IDialogService _dialog
|
||||
@inject NavigationManager _navigationManager
|
||||
@@ -67,11 +68,11 @@
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Content Ratings: </MudText>
|
||||
<MudLink Href="@($"/search?query=content_rating%3a%22{Uri.EscapeDataString(_sortedContentRatings.Head())}%22")">@_sortedContentRatings.Head()</MudLink>
|
||||
<MudLink Href="@(@$"content_rating:""{_sortedContentRatings.Head()}""".GetRelativeSearchQuery())">@_sortedContentRatings.Head()</MudLink>
|
||||
@foreach (string contentRating in _sortedContentRatings.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=content_rating%3a%22{Uri.EscapeDataString(contentRating)}%22")">@contentRating</MudLink>
|
||||
<MudLink Href="@(@$"content_rating:""{contentRating}""".GetRelativeSearchQuery())">@contentRating</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -79,11 +80,11 @@
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Languages: </MudText>
|
||||
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(_sortedLanguages.Head().EnglishName.ToLowerInvariant())}%22")">@_sortedLanguages.Head().EnglishName</MudLink>
|
||||
<MudLink Href="@(@$"language:""{_sortedLanguages.Head().EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedLanguages.Head().EnglishName</MudLink>
|
||||
@foreach (CultureInfo language in _sortedLanguages.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")">@language.EnglishName</MudLink>
|
||||
<MudLink Href="@(@$"language:""{language.EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@language.EnglishName</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -91,11 +92,11 @@
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Studios: </MudText>
|
||||
<MudLink Href="@($"/search?query=studio%3a%22{Uri.EscapeDataString(_sortedStudios.Head())}%22")">@_sortedStudios.Head()</MudLink>
|
||||
<MudLink Href="@(@$"studio:""{_sortedStudios.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedStudios.Head()</MudLink>
|
||||
@foreach (string studio in _sortedStudios.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=studio%3a%22{Uri.EscapeDataString(studio)}%22")">@studio</MudLink>
|
||||
<MudLink Href="@(@$"studio:""{studio.ToLowerInvariant()}""".GetRelativeSearchQuery())">@studio</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -103,11 +104,11 @@
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Directors: </MudText>
|
||||
<MudLink Href="@($"/search?query=director%3a%22{Uri.EscapeDataString(_sortedDirectors.Head())}%22")">@_sortedDirectors.Head()</MudLink>
|
||||
<MudLink Href="@(@$"director:""{_sortedDirectors.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedDirectors.Head()</MudLink>
|
||||
@foreach (string director in _sortedDirectors.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=director%3a%22{Uri.EscapeDataString(director)}%22")">@director</MudLink>
|
||||
<MudLink Href="@(@$"director:""{director.ToLowerInvariant()}""".GetRelativeSearchQuery())">@director</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -115,11 +116,11 @@
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Writers: </MudText>
|
||||
<MudLink Href="@($"/search?query=writer%3a%22{Uri.EscapeDataString(_sortedWriters.Head())}%22")">@_sortedWriters.Head()</MudLink>
|
||||
<MudLink Href="@(@$"writer:""{_sortedWriters.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedWriters.Head()</MudLink>
|
||||
@foreach (string writer in _sortedWriters.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=writer%3a%22{Uri.EscapeDataString(writer)}%22")">@writer</MudLink>
|
||||
<MudLink Href="@(@$"writer:""{writer.ToLowerInvariant()}""".GetRelativeSearchQuery())">@writer</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -127,11 +128,11 @@
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Genres: </MudText>
|
||||
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(_sortedGenres.Head())}%22")">@_sortedGenres.Head()</MudLink>
|
||||
<MudLink Href="@(@$"genre:""{_sortedGenres.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedGenres.Head()</MudLink>
|
||||
@foreach (string genre in _sortedGenres.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre)}%22")">@genre</MudLink>
|
||||
<MudLink Href="@(@$"genre:""{genre.ToLowerInvariant()}""".GetRelativeSearchQuery())">@genre</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -139,11 +140,11 @@
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Tags: </MudText>
|
||||
<MudLink Href="@($"/search?query=tag%3a%22{Uri.EscapeDataString(_sortedTags.Head())}%22")">@_sortedTags.Head()</MudLink>
|
||||
<MudLink Href="@(@$"tag:""{_sortedTags.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedTags.Head()</MudLink>
|
||||
@foreach (string tag in _sortedTags.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=tag%3a%22{Uri.EscapeDataString(tag)}%22")">@tag</MudLink>
|
||||
<MudLink Href="@(@$"tag:""{tag.ToLowerInvariant()}""".GetRelativeSearchQuery())">@tag</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -159,7 +160,7 @@
|
||||
@foreach (ActorCardViewModel actor in _movie.Actors)
|
||||
{
|
||||
<MediaCard Data="@actor"
|
||||
Link="@($"/search?query=actor%3a%22{Uri.EscapeDataString(actor.Name.ToLowerInvariant())}%22")"
|
||||
Link="@(@$"actor:""{actor.Name.ToLowerInvariant()}""".GetRelativeSearchQuery())"
|
||||
IsRemoteArtwork="true"
|
||||
ArtworkKind="ArtworkKind.Thumbnail"/>
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
@page "/media/movies/page/{PageNumber:int}"
|
||||
@using LanguageExt.UnsafeValueAccess
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using Microsoft.Extensions.Primitives
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaCollections.Commands
|
||||
@using ErsatzTV.Application.Search.Queries
|
||||
@using ErsatzTV.Extensions
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inherits MultiSelectBase<MovieList>
|
||||
@inject NavigationManager _navigationManager
|
||||
@@ -89,16 +89,7 @@
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
string query = new Uri(_navigationManager.Uri).Query;
|
||||
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
|
||||
{
|
||||
_query = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
_query = null;
|
||||
}
|
||||
|
||||
_query = _navigationManager.Uri.GetSearchQuery();
|
||||
return RefreshData();
|
||||
}
|
||||
|
||||
@@ -113,7 +104,8 @@
|
||||
var uri = $"/media/movies/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
_navigationManager.NavigateTo(uri);
|
||||
}
|
||||
@@ -123,7 +115,8 @@
|
||||
var uri = $"/media/movies/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
_navigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
@page "/media/music/videos/page/{PageNumber:int}"
|
||||
@using LanguageExt.UnsafeValueAccess
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using Microsoft.Extensions.Primitives
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaCollections.Commands
|
||||
@using ErsatzTV.Application.Search.Queries
|
||||
@using ErsatzTV.Extensions
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inherits MultiSelectBase<MusicVideoList>
|
||||
@inject NavigationManager _navigationManager
|
||||
@@ -90,16 +90,7 @@
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
string query = new Uri(_navigationManager.Uri).Query;
|
||||
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
|
||||
{
|
||||
_query = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
_query = null;
|
||||
}
|
||||
|
||||
_query = _navigationManager.Uri.GetSearchQuery();
|
||||
return RefreshData();
|
||||
}
|
||||
|
||||
@@ -114,7 +105,8 @@
|
||||
var uri = $"/media/music/videos/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
_navigationManager.NavigateTo(uri);
|
||||
}
|
||||
@@ -124,7 +116,8 @@
|
||||
var uri = $"/media/music/videos/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
_navigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
@@ -63,9 +63,14 @@
|
||||
Add Playout
|
||||
</MudButton>
|
||||
|
||||
@if (_selectedPlayoutItems != null)
|
||||
@if (_selectedPlayoutId != null)
|
||||
{
|
||||
<MudTable Hover="true" Dense="true" Items="_selectedPlayoutItems.OrderBy(i => i.Start)" Class="mt-8">
|
||||
<MudTable Class="mt-8"
|
||||
Hover="true"
|
||||
Dense="true"
|
||||
@bind-RowsPerPage="@_detailRowsPerPage"
|
||||
ServerData="@(new Func<TableState, Task<TableData<PlayoutItemViewModel>>>(DetailServerReload))"
|
||||
@ref="_detailTable">
|
||||
<ToolBarContent>
|
||||
<MudText Typo="Typo.h6">Playout Detail</MudText>
|
||||
</ToolBarContent>
|
||||
@@ -88,20 +93,23 @@
|
||||
|
||||
@code {
|
||||
private MudTable<PlayoutNameViewModel> _table;
|
||||
private MudTable<PlayoutItemViewModel> _detailTable;
|
||||
private int _rowsPerPage;
|
||||
private List<PlayoutItemViewModel> _selectedPlayoutItems;
|
||||
private int _detailRowsPerPage;
|
||||
private int? _selectedPlayoutId;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_rowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.PlayoutsPageSize))
|
||||
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
|
||||
_detailRowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.PlayoutsDetailPageSize))
|
||||
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
|
||||
}
|
||||
|
||||
private async Task PlayoutSelected(PlayoutNameViewModel playout)
|
||||
{
|
||||
_selectedPlayoutId = playout.PlayoutId;
|
||||
_selectedPlayoutItems = await _mediator.Send(new GetPlayoutItemsById(playout.PlayoutId));
|
||||
await _detailTable.ReloadServerData();
|
||||
}
|
||||
|
||||
private async Task DeletePlayout(PlayoutNameViewModel playout)
|
||||
@@ -115,6 +123,10 @@
|
||||
{
|
||||
await _mediator.Send(new DeletePlayout(playout.PlayoutId));
|
||||
await _table.ReloadServerData();
|
||||
if (_selectedPlayoutId == playout.PlayoutId)
|
||||
{
|
||||
_selectedPlayoutId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,4 +155,22 @@
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<TableData<PlayoutItemViewModel>> DetailServerReload(TableState state)
|
||||
{
|
||||
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsDetailPageSize, state.PageSize.ToString()));
|
||||
|
||||
if (_selectedPlayoutId.HasValue)
|
||||
{
|
||||
PagedPlayoutItemsViewModel data =
|
||||
await _mediator.Send(new GetPlayoutItemsById(_selectedPlayoutId.Value, state.Page, state.PageSize));
|
||||
return new TableData<PlayoutItemViewModel>
|
||||
{
|
||||
TotalItems = data.TotalCount,
|
||||
Items = data.Page
|
||||
};
|
||||
}
|
||||
|
||||
return new TableData<PlayoutItemViewModel> { TotalItems = 0 };
|
||||
}
|
||||
|
||||
}
|
||||
@@ -71,9 +71,6 @@
|
||||
</MudIconButton>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager/>
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Default" OnClick="@(_ => AddScheduleItem())" Class="mt-4">
|
||||
Add Schedule Item
|
||||
@@ -167,7 +164,7 @@
|
||||
<MudText Class="mt-3 mb-2" Typo="Typo.h6">Playout Mode</MudText>
|
||||
<ul class="mud-typography-body1">
|
||||
<li><b>One</b> - to play one media item from the collection before advancing to the next schedule item.</li>
|
||||
<li><b>Multiple</b> - to play a specified <b>count</b> of media items from the collection before advancing to the next schedule item.</li>
|
||||
<li><b>Multiple</b> - to play a specified <b>count</b> of media items from the collection before advancing to the next schedule item. A count of <b>0</b> is a special case that will automatically and always equal the number of items in the collection, so each item from the collection will be played once before advancing.</li>
|
||||
<li><b>Duration</b> - to play the maximum number of complete media items that will fit in the specified <b>playout duration</b>, before either going offline for the remainder of the <b>playout duration</b> (an <b>offline tail</b>), or immediately advancing to the next schedule item.</li>
|
||||
<li><b>Flood</b> - to play media items from the collection forever, or until the next schedule item's <b>start time</b> if one exists.</li>
|
||||
</ul>
|
||||
@@ -196,10 +193,14 @@
|
||||
private async Task LoadScheduleItems()
|
||||
{
|
||||
// TODO: fix performance
|
||||
_mediaCollections = await _mediator.Send(new GetAllCollections());
|
||||
_televisionShows = await _mediator.Send(new GetAllTelevisionShows());
|
||||
_televisionSeasons = await _mediator.Send(new GetAllTelevisionSeasons());
|
||||
_artists = await _mediator.Send(new GetAllArtists());
|
||||
_mediaCollections = await _mediator.Send(new GetAllCollections())
|
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
|
||||
_televisionShows = await _mediator.Send(new GetAllTelevisionShows())
|
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
|
||||
_televisionSeasons = await _mediator.Send(new GetAllTelevisionSeasons())
|
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
|
||||
_artists = await _mediator.Send(new GetAllArtists())
|
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
|
||||
|
||||
string name = string.Empty;
|
||||
Option<ProgramScheduleViewModel> maybeSchedule = await _mediator.Send(new GetProgramScheduleById(Id));
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@using ErsatzTV.Application.ProgramSchedules.Queries
|
||||
@using ErsatzTV.Application.Configuration.Queries
|
||||
@using ErsatzTV.Application.Configuration.Commands
|
||||
@using NaturalSort.Extension
|
||||
@inject IDialogService _dialog
|
||||
@inject IMediator _mediator
|
||||
|
||||
@@ -62,9 +63,13 @@
|
||||
Add Schedule
|
||||
</MudButton>
|
||||
|
||||
@if (_selectedScheduleItems != null)
|
||||
@if (_selectedSchedule != null)
|
||||
{
|
||||
<MudTable Hover="true" Items="_selectedScheduleItems.OrderBy(i => i.Index)" Class="mt-8">
|
||||
<MudTable Hover="true"
|
||||
Class="mt-8"
|
||||
@bind-RowsPerPage="@_detailRowsPerPage"
|
||||
ServerData="@(new Func<TableState, Task<TableData<ProgramScheduleItemViewModel>>>(DetailServerReload))"
|
||||
@ref="_detailTable">
|
||||
<ToolBarContent>
|
||||
<MudText Typo="Typo.h6">@_selectedSchedule.Name Items</MudText>
|
||||
</ToolBarContent>
|
||||
@@ -89,21 +94,23 @@
|
||||
|
||||
@code {
|
||||
private MudTable<ProgramScheduleViewModel> _table;
|
||||
private MudTable<ProgramScheduleItemViewModel> _detailTable;
|
||||
private int _rowsPerPage;
|
||||
private List<ProgramScheduleItemViewModel> _selectedScheduleItems;
|
||||
private int _detailRowsPerPage;
|
||||
private ProgramScheduleViewModel _selectedSchedule;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_rowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.SchedulesPageSize))
|
||||
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
|
||||
_detailRowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.SchedulesDetailPageSize))
|
||||
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
|
||||
}
|
||||
|
||||
private async Task ScheduleSelected(ProgramScheduleViewModel schedule)
|
||||
{
|
||||
_selectedSchedule = schedule;
|
||||
await _mediator.Send(new GetProgramScheduleItems(schedule.Id))
|
||||
.Map(results => _selectedScheduleItems = results.OrderBy(x => x.Name).ToList());
|
||||
await _detailTable.ReloadServerData();
|
||||
}
|
||||
|
||||
private async Task DeleteSchedule(ProgramScheduleViewModel programSchedule)
|
||||
@@ -117,6 +124,10 @@
|
||||
{
|
||||
await _mediator.Send(new DeleteProgramSchedule(programSchedule.Id));
|
||||
await _table.ReloadServerData();
|
||||
if (_selectedSchedule == programSchedule)
|
||||
{
|
||||
_selectedSchedule = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +136,7 @@
|
||||
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.SchedulesPageSize, state.PageSize.ToString()));
|
||||
|
||||
List<ProgramScheduleViewModel> schedules = await _mediator.Send(new GetAllProgramSchedules());
|
||||
IOrderedEnumerable<ProgramScheduleViewModel> sorted = schedules.OrderBy(s => s.Name);
|
||||
IOrderedEnumerable<ProgramScheduleViewModel> sorted = schedules.OrderBy(s => s.Name, new NaturalSortComparer(StringComparison.CurrentCultureIgnoreCase));
|
||||
|
||||
// TODO: properly page this data
|
||||
return new TableData<ProgramScheduleViewModel>
|
||||
@@ -135,4 +146,19 @@
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<TableData<ProgramScheduleItemViewModel>> DetailServerReload(TableState state)
|
||||
{
|
||||
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.SchedulesDetailPageSize, state.PageSize.ToString()));
|
||||
|
||||
List<ProgramScheduleItemViewModel> scheduleItems = await _mediator.Send(new GetProgramScheduleItems(_selectedSchedule.Id));
|
||||
IOrderedEnumerable<ProgramScheduleItemViewModel> sorted = scheduleItems.OrderBy(s => s.Index);
|
||||
|
||||
// TODO: properly page this data
|
||||
return new TableData<ProgramScheduleItemViewModel>
|
||||
{
|
||||
TotalItems = scheduleItems.Count,
|
||||
Items = sorted.Skip(state.Page * state.PageSize).Take(state.PageSize)
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
+23
-21
@@ -4,8 +4,8 @@
|
||||
@using ErsatzTV.Application.MediaCollections.Commands
|
||||
@using ErsatzTV.Application.Search
|
||||
@using ErsatzTV.Application.Search.Queries
|
||||
@using ErsatzTV.Extensions
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using Microsoft.Extensions.Primitives
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inherits MultiSelectBase<Search>
|
||||
@inject NavigationManager _navigationManager
|
||||
@@ -35,27 +35,27 @@
|
||||
else
|
||||
{
|
||||
<MudText Style="margin-bottom: auto; margin-top: auto">@_query</MudText>
|
||||
if (_movies.Count > 0)
|
||||
if (_movies?.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#movies")" Style="margin-bottom: auto; margin-top: auto">@_movies.Count Movies</MudLink>
|
||||
}
|
||||
|
||||
if (_shows.Count > 0)
|
||||
if (_shows?.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#shows")" Style="margin-bottom: auto; margin-top: auto">@_shows.Count Shows</MudLink>
|
||||
}
|
||||
|
||||
if (_episodes.Count > 0)
|
||||
if (_episodes?.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#episodes")" Style="margin-bottom: auto; margin-top: auto">@_episodes.Count Episodes</MudLink>
|
||||
}
|
||||
|
||||
if (_artists.Count > 0)
|
||||
if (_artists?.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#artists")" Style="margin-bottom: auto; margin-top: auto">@_artists.Count Artists</MudLink>
|
||||
}
|
||||
|
||||
if (_musicVideos.Count > 0)
|
||||
if (_musicVideos?.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#music_videos")" Style="margin-bottom: auto; margin-top: auto">@_musicVideos.Count Music Videos</MudLink>
|
||||
}
|
||||
@@ -71,7 +71,7 @@
|
||||
</div>
|
||||
</MudPaper>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Style="margin-top: 96px">
|
||||
@if (_movies.Count > 0)
|
||||
@if (_movies?.Count > 0)
|
||||
{
|
||||
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
|
||||
<MudText Typo="Typo.h4"
|
||||
@@ -98,7 +98,7 @@
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@if (_shows.Count > 0)
|
||||
@if (_shows?.Count > 0)
|
||||
{
|
||||
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
|
||||
<MudText Typo="Typo.h4"
|
||||
@@ -125,7 +125,7 @@
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@if (_episodes.Count > 0)
|
||||
@if (_episodes?.Count > 0)
|
||||
{
|
||||
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
|
||||
<MudText Typo="Typo.h4"
|
||||
@@ -153,7 +153,7 @@
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@if (_artists.Count > 0)
|
||||
@if (_artists?.Count > 0)
|
||||
{
|
||||
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
|
||||
<MudText Typo="Typo.h4"
|
||||
@@ -181,7 +181,7 @@
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@if (_musicVideos.Count > 0)
|
||||
@if (_musicVideos?.Count > 0)
|
||||
{
|
||||
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
|
||||
<MudText Typo="Typo.h4"
|
||||
@@ -220,12 +220,9 @@
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
string query = new Uri(_navigationManager.Uri).Query;
|
||||
|
||||
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
|
||||
_query = _navigationManager.Uri.GetSearchQuery();
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
_query = value;
|
||||
|
||||
_movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50));
|
||||
_shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50));
|
||||
_episodes = await Mediator.Send(new QuerySearchIndexEpisodes($"type:episode AND ({_query})", 1, 50));
|
||||
@@ -362,7 +359,8 @@
|
||||
var uri = "/media/movies/page/1";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
@@ -372,7 +370,8 @@
|
||||
var uri = "/media/tv/shows/page/1";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
@@ -382,7 +381,8 @@
|
||||
var uri = "/media/tv/episodes/page/1";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
@@ -392,7 +392,8 @@
|
||||
var uri = "/media/music/artists/page/1";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
@@ -402,7 +403,8 @@
|
||||
var uri = "/media/music/videos/page/1";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
@@ -116,11 +116,11 @@
|
||||
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Directors: </MudText>
|
||||
<MudLink Href="@($"/search?query=director%3a%22{Uri.EscapeDataString(sorted.Head())}%22")">@sorted.Head()</MudLink>
|
||||
<MudLink Href="@(@$"director:""{sorted.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@sorted.Head()</MudLink>
|
||||
@foreach (string director in sorted.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=director%3a%22{Uri.EscapeDataString(director)}%22")">@director</MudLink>
|
||||
<MudLink Href="@(@$"director:""{director.ToLowerInvariant()}""".GetRelativeSearchQuery())">@director</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -130,11 +130,11 @@
|
||||
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Writers: </MudText>
|
||||
<MudLink Href="@($"/search?query=writer%3a%22{Uri.EscapeDataString(sorted.Head())}%22")">@sorted.Head()</MudLink>
|
||||
<MudLink Href="@(@$"writer:""{sorted.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@sorted.Head()</MudLink>
|
||||
@foreach (string writer in sorted.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=writer%3a%22{Uri.EscapeDataString(writer)}%22")">@writer</MudLink>
|
||||
<MudLink Href="@(@$"writer:""{writer.ToLowerInvariant()}""".GetRelativeSearchQuery())">@writer</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
@using ErsatzTV.Application.MediaCollections.Commands
|
||||
@using ErsatzTV.Application.ProgramSchedules
|
||||
@using ErsatzTV.Application.ProgramSchedules.Commands
|
||||
@using ErsatzTV.Extensions
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inject IMediator _mediator
|
||||
@inject ILogger<TelevisionSeasonList> _logger
|
||||
@@ -81,11 +82,11 @@
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Content Ratings: </MudText>
|
||||
<MudLink Href="@($"/search?query=content_rating%3a%22{Uri.EscapeDataString(_sortedContentRatings.Head())}%22")">@_sortedContentRatings.Head()</MudLink>
|
||||
<MudLink Href="@(@$"content_rating:""{_sortedContentRatings.Head()}""".GetRelativeSearchQuery())">@_sortedContentRatings.Head()</MudLink>
|
||||
@foreach (string contentRating in _sortedContentRatings.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=content_rating%3a%22{Uri.EscapeDataString(contentRating)}%22")">@contentRating</MudLink>
|
||||
<MudLink Href="@(@$"content_rating:""{contentRating}""".GetRelativeSearchQuery())">@contentRating</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -93,11 +94,11 @@
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Languages: </MudText>
|
||||
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(_sortedLanguages.Head().EnglishName.ToLowerInvariant())}%22")">@_sortedLanguages.Head().EnglishName</MudLink>
|
||||
<MudLink Href="@(@$"language:""{_sortedLanguages.Head().EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedLanguages.Head().EnglishName</MudLink>
|
||||
@foreach (CultureInfo language in _sortedLanguages.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")">@language.EnglishName</MudLink>
|
||||
<MudLink Href="@(@$"language:""{language.EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@language.EnglishName</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -105,11 +106,11 @@
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Studios: </MudText>
|
||||
<MudLink Href="@($"/search?query=studio%3a%22{Uri.EscapeDataString(_sortedStudios.Head())}%22")">@_sortedStudios.Head()</MudLink>
|
||||
<MudLink Href="@(@$"studio:""{_sortedStudios.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedStudios.Head()</MudLink>
|
||||
@foreach (string studio in _sortedStudios.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=studio%3a%22{Uri.EscapeDataString(studio)}%22")">@studio</MudLink>
|
||||
<MudLink Href="@(@$"studio:""{studio.ToLowerInvariant()}""".GetRelativeSearchQuery())">@studio</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -117,11 +118,11 @@
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Genres: </MudText>
|
||||
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(_sortedGenres.Head())}%22")">@_sortedGenres.Head()</MudLink>
|
||||
<MudLink Href="@(@$"genre:""{_sortedGenres.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedGenres.Head()</MudLink>
|
||||
@foreach (string genre in _sortedGenres.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre)}%22")">@genre</MudLink>
|
||||
<MudLink Href="@(@$"genre:""{genre.ToLowerInvariant()}""".GetRelativeSearchQuery())">@genre</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -129,11 +130,11 @@
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Tags: </MudText>
|
||||
<MudLink Href="@($"/search?query=tag%3a%22{Uri.EscapeDataString(_sortedTags.Head())}%22")">@_sortedTags.Head()</MudLink>
|
||||
<MudLink Href="@(@$"tag:""{_sortedTags.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedTags.Head()</MudLink>
|
||||
@foreach (string tag in _sortedTags.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=tag%3a%22{Uri.EscapeDataString(tag)}%22")">@tag</MudLink>
|
||||
<MudLink Href="@(@$"tag:""{tag.ToLowerInvariant()}""".GetRelativeSearchQuery())">@tag</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -160,7 +161,7 @@
|
||||
@foreach (ActorCardViewModel actor in _show.Actors)
|
||||
{
|
||||
<MediaCard Data="@actor"
|
||||
Link="@($"/search?query=actor%3a%22{Uri.EscapeDataString(actor.Name.ToLowerInvariant())}%22")"
|
||||
Link="@(@$"actor:""{actor.Name.ToLowerInvariant()}""".GetRelativeSearchQuery())"
|
||||
IsRemoteArtwork="true"
|
||||
ArtworkKind="ArtworkKind.Thumbnail"/>
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
@page "/media/tv/shows/page/{PageNumber:int}"
|
||||
@using LanguageExt.UnsafeValueAccess
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using Microsoft.Extensions.Primitives
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaCollections.Commands
|
||||
@using ErsatzTV.Application.Search.Queries
|
||||
@using ErsatzTV.Extensions
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inherits MultiSelectBase<TelevisionShowList>
|
||||
@inject NavigationManager _navigationManager
|
||||
@@ -89,16 +89,7 @@
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
string query = new Uri(_navigationManager.Uri).Query;
|
||||
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
|
||||
{
|
||||
_query = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
_query = null;
|
||||
}
|
||||
|
||||
_query = _navigationManager.Uri.GetSearchQuery();
|
||||
return RefreshData();
|
||||
}
|
||||
|
||||
@@ -113,7 +104,8 @@
|
||||
var uri = $"/media/tv/shows/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
_navigationManager.NavigateTo(uri);
|
||||
}
|
||||
@@ -123,7 +115,8 @@
|
||||
var uri = $"/media/tv/shows/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
_navigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
aar||aa|Afar|afar
|
||||
abk||ab|Abkhazian|abkhaze
|
||||
ace|||Achinese|aceh
|
||||
ach|||Acoli|acoli
|
||||
ada|||Adangme|adangme
|
||||
ady|||Adyghe; Adygei|adyghé
|
||||
afa|||Afro-Asiatic languages|afro-asiatiques, langues
|
||||
afh|||Afrihili|afrihili
|
||||
afr||af|Afrikaans|afrikaans
|
||||
ain|||Ainu|aïnou
|
||||
aka||ak|Akan|akan
|
||||
akk|||Akkadian|akkadien
|
||||
alb|sqi|sq|Albanian|albanais
|
||||
ale|||Aleut|aléoute
|
||||
alg|||Algonquian languages|algonquines, langues
|
||||
alt|||Southern Altai|altai du Sud
|
||||
amh||am|Amharic|amharique
|
||||
ang|||English, Old (ca.450-1100)|anglo-saxon (ca.450-1100)
|
||||
anp|||Angika|angika
|
||||
apa|||Apache languages|apaches, langues
|
||||
ara||ar|Arabic|arabe
|
||||
arc|||Official Aramaic (700-300 BCE); Imperial Aramaic (700-300 BCE)|araméen d'empire (700-300 BCE)
|
||||
arg||an|Aragonese|aragonais
|
||||
arm|hye|hy|Armenian|arménien
|
||||
arn|||Mapudungun; Mapuche|mapudungun; mapuche; mapuce
|
||||
arp|||Arapaho|arapaho
|
||||
art|||Artificial languages|artificielles, langues
|
||||
arw|||Arawak|arawak
|
||||
asm||as|Assamese|assamais
|
||||
ast|||Asturian; Bable; Leonese; Asturleonese|asturien; bable; léonais; asturoléonais
|
||||
ath|||Athapascan languages|athapascanes, langues
|
||||
aus|||Australian languages|australiennes, langues
|
||||
ava||av|Avaric|avar
|
||||
ave||ae|Avestan|avestique
|
||||
awa|||Awadhi|awadhi
|
||||
aym||ay|Aymara|aymara
|
||||
aze||az|Azerbaijani|azéri
|
||||
bad|||Banda languages|banda, langues
|
||||
bai|||Bamileke languages|bamiléké, langues
|
||||
bak||ba|Bashkir|bachkir
|
||||
bal|||Baluchi|baloutchi
|
||||
bam||bm|Bambara|bambara
|
||||
ban|||Balinese|balinais
|
||||
baq|eus|eu|Basque|basque
|
||||
bas|||Basa|basa
|
||||
bat|||Baltic languages|baltes, langues
|
||||
bej|||Beja; Bedawiyet|bedja
|
||||
bel||be|Belarusian|biélorusse
|
||||
bem|||Bemba|bemba
|
||||
ben||bn|Bengali|bengali
|
||||
ber|||Berber languages|berbères, langues
|
||||
bho|||Bhojpuri|bhojpuri
|
||||
bih||bh|Bihari languages|langues biharis
|
||||
bik|||Bikol|bikol
|
||||
bin|||Bini; Edo|bini; edo
|
||||
bis||bi|Bislama|bichlamar
|
||||
bla|||Siksika|blackfoot
|
||||
bnt|||Bantu languages|bantou, langues
|
||||
bos||bs|Bosnian|bosniaque
|
||||
bra|||Braj|braj
|
||||
bre||br|Breton|breton
|
||||
btk|||Batak languages|batak, langues
|
||||
bua|||Buriat|bouriate
|
||||
bug|||Buginese|bugi
|
||||
bul||bg|Bulgarian|bulgare
|
||||
bur|mya|my|Burmese|birman
|
||||
byn|||Blin; Bilin|blin; bilen
|
||||
cad|||Caddo|caddo
|
||||
cai|||Central American Indian languages|amérindiennes de L'Amérique centrale, langues
|
||||
car|||Galibi Carib|karib; galibi; carib
|
||||
cat||ca|Catalan; Valencian|catalan; valencien
|
||||
cau|||Caucasian languages|caucasiennes, langues
|
||||
ceb|||Cebuano|cebuano
|
||||
cel|||Celtic languages|celtiques, langues; celtes, langues
|
||||
cha||ch|Chamorro|chamorro
|
||||
chb|||Chibcha|chibcha
|
||||
che||ce|Chechen|tchétchène
|
||||
chg|||Chagatai|djaghataï
|
||||
chi|zho|zh|Chinese|chinois
|
||||
chk|||Chuukese|chuuk
|
||||
chm|||Mari|mari
|
||||
chn|||Chinook jargon|chinook, jargon
|
||||
cho|||Choctaw|choctaw
|
||||
chp|||Chipewyan; Dene Suline|chipewyan
|
||||
chr|||Cherokee|cherokee
|
||||
chu||cu|Church Slavic; Old Slavonic; Church Slavonic; Old Bulgarian; Old Church Slavonic|slavon d'église; vieux slave; slavon liturgique; vieux bulgare
|
||||
chv||cv|Chuvash|tchouvache
|
||||
chy|||Cheyenne|cheyenne
|
||||
cmc|||Chamic languages|chames, langues
|
||||
cnr|||Montenegrin|monténégrin
|
||||
cop|||Coptic|copte
|
||||
cor||kw|Cornish|cornique
|
||||
cos||co|Corsican|corse
|
||||
cpe|||Creoles and pidgins, English based|créoles et pidgins basés sur l'anglais
|
||||
cpf|||Creoles and pidgins, French-based|créoles et pidgins basés sur le français
|
||||
cpp|||Creoles and pidgins, Portuguese-based|créoles et pidgins basés sur le portugais
|
||||
cre||cr|Cree|cree
|
||||
crh|||Crimean Tatar; Crimean Turkish|tatar de Crimé
|
||||
crp|||Creoles and pidgins|créoles et pidgins
|
||||
csb|||Kashubian|kachoube
|
||||
cus|||Cushitic languages|couchitiques, langues
|
||||
cze|ces|cs|Czech|tchèque
|
||||
dak|||Dakota|dakota
|
||||
dan||da|Danish|danois
|
||||
dar|||Dargwa|dargwa
|
||||
day|||Land Dayak languages|dayak, langues
|
||||
del|||Delaware|delaware
|
||||
den|||Slave (Athapascan)|esclave (athapascan)
|
||||
dgr|||Dogrib|dogrib
|
||||
din|||Dinka|dinka
|
||||
div||dv|Divehi; Dhivehi; Maldivian|maldivien
|
||||
doi|||Dogri|dogri
|
||||
dra|||Dravidian languages|dravidiennes, langues
|
||||
dsb|||Lower Sorbian|bas-sorabe
|
||||
dua|||Duala|douala
|
||||
dum|||Dutch, Middle (ca.1050-1350)|néerlandais moyen (ca. 1050-1350)
|
||||
dut|nld|nl|Dutch; Flemish|néerlandais; flamand
|
||||
dyu|||Dyula|dioula
|
||||
dzo||dz|Dzongkha|dzongkha
|
||||
efi|||Efik|efik
|
||||
egy|||Egyptian (Ancient)|égyptien
|
||||
eka|||Ekajuk|ekajuk
|
||||
elx|||Elamite|élamite
|
||||
eng||en|English|anglais
|
||||
enm|||English, Middle (1100-1500)|anglais moyen (1100-1500)
|
||||
epo||eo|Esperanto|espéranto
|
||||
est||et|Estonian|estonien
|
||||
ewe||ee|Ewe|éwé
|
||||
ewo|||Ewondo|éwondo
|
||||
fan|||Fang|fang
|
||||
fao||fo|Faroese|féroïen
|
||||
fat|||Fanti|fanti
|
||||
fij||fj|Fijian|fidjien
|
||||
fil|||Filipino; Pilipino|filipino; pilipino
|
||||
fin||fi|Finnish|finnois
|
||||
fiu|||Finno-Ugrian languages|finno-ougriennes, langues
|
||||
fon|||Fon|fon
|
||||
fre|fra|fr|French|français
|
||||
frm|||French, Middle (ca.1400-1600)|français moyen (1400-1600)
|
||||
fro|||French, Old (842-ca.1400)|français ancien (842-ca.1400)
|
||||
frr|||Northern Frisian|frison septentrional
|
||||
frs|||Eastern Frisian|frison oriental
|
||||
fry||fy|Western Frisian|frison occidental
|
||||
ful||ff|Fulah|peul
|
||||
fur|||Friulian|frioulan
|
||||
gaa|||Ga|ga
|
||||
gay|||Gayo|gayo
|
||||
gba|||Gbaya|gbaya
|
||||
gem|||Germanic languages|germaniques, langues
|
||||
geo|kat|ka|Georgian|géorgien
|
||||
ger|deu|de|German|allemand
|
||||
gez|||Geez|guèze
|
||||
gil|||Gilbertese|kiribati
|
||||
gla||gd|Gaelic; Scottish Gaelic|gaélique; gaélique écossais
|
||||
gle||ga|Irish|irlandais
|
||||
glg||gl|Galician|galicien
|
||||
glv||gv|Manx|manx; mannois
|
||||
gmh|||German, Middle High (ca.1050-1500)|allemand, moyen haut (ca. 1050-1500)
|
||||
goh|||German, Old High (ca.750-1050)|allemand, vieux haut (ca. 750-1050)
|
||||
gon|||Gondi|gond
|
||||
gor|||Gorontalo|gorontalo
|
||||
got|||Gothic|gothique
|
||||
grb|||Grebo|grebo
|
||||
grc|||Greek, Ancient (to 1453)|grec ancien (jusqu'à 1453)
|
||||
gre|ell|el|Greek, Modern (1453-)|grec moderne (après 1453)
|
||||
grn||gn|Guarani|guarani
|
||||
gsw|||Swiss German; Alemannic; Alsatian|suisse alémanique; alémanique; alsacien
|
||||
guj||gu|Gujarati|goudjrati
|
||||
gwi|||Gwich'in|gwich'in
|
||||
hai|||Haida|haida
|
||||
hat||ht|Haitian; Haitian Creole|haïtien; créole haïtien
|
||||
hau||ha|Hausa|haoussa
|
||||
haw|||Hawaiian|hawaïen
|
||||
heb||he|Hebrew|hébreu
|
||||
her||hz|Herero|herero
|
||||
hil|||Hiligaynon|hiligaynon
|
||||
him|||Himachali languages; Western Pahari languages|langues himachalis; langues paharis occidentales
|
||||
hin||hi|Hindi|hindi
|
||||
hit|||Hittite|hittite
|
||||
hmn|||Hmong; Mong|hmong
|
||||
hmo||ho|Hiri Motu|hiri motu
|
||||
hrv||hr|Croatian|croate
|
||||
hsb|||Upper Sorbian|haut-sorabe
|
||||
hun||hu|Hungarian|hongrois
|
||||
hup|||Hupa|hupa
|
||||
iba|||Iban|iban
|
||||
ibo||ig|Igbo|igbo
|
||||
ice|isl|is|Icelandic|islandais
|
||||
ido||io|Ido|ido
|
||||
iii||ii|Sichuan Yi; Nuosu|yi de Sichuan
|
||||
ijo|||Ijo languages|ijo, langues
|
||||
iku||iu|Inuktitut|inuktitut
|
||||
ile||ie|Interlingue; Occidental|interlingue
|
||||
ilo|||Iloko|ilocano
|
||||
ina||ia|Interlingua (International Auxiliary Language Association)|interlingua (langue auxiliaire internationale)
|
||||
inc|||Indic languages|indo-aryennes, langues
|
||||
ind||id|Indonesian|indonésien
|
||||
ine|||Indo-European languages|indo-européennes, langues
|
||||
inh|||Ingush|ingouche
|
||||
ipk||ik|Inupiaq|inupiaq
|
||||
ira|||Iranian languages|iraniennes, langues
|
||||
iro|||Iroquoian languages|iroquoises, langues
|
||||
ita||it|Italian|italien
|
||||
jav||jv|Javanese|javanais
|
||||
jbo|||Lojban|lojban
|
||||
jpn||ja|Japanese|japonais
|
||||
jpr|||Judeo-Persian|judéo-persan
|
||||
jrb|||Judeo-Arabic|judéo-arabe
|
||||
kaa|||Kara-Kalpak|karakalpak
|
||||
kab|||Kabyle|kabyle
|
||||
kac|||Kachin; Jingpho|kachin; jingpho
|
||||
kal||kl|Kalaallisut; Greenlandic|groenlandais
|
||||
kam|||Kamba|kamba
|
||||
kan||kn|Kannada|kannada
|
||||
kar|||Karen languages|karen, langues
|
||||
kas||ks|Kashmiri|kashmiri
|
||||
kau||kr|Kanuri|kanouri
|
||||
kaw|||Kawi|kawi
|
||||
kaz||kk|Kazakh|kazakh
|
||||
kbd|||Kabardian|kabardien
|
||||
kha|||Khasi|khasi
|
||||
khi|||Khoisan languages|khoïsan, langues
|
||||
khm||km|Central Khmer|khmer central
|
||||
kho|||Khotanese; Sakan|khotanais; sakan
|
||||
kik||ki|Kikuyu; Gikuyu|kikuyu
|
||||
kin||rw|Kinyarwanda|rwanda
|
||||
kir||ky|Kirghiz; Kyrgyz|kirghiz
|
||||
kmb|||Kimbundu|kimbundu
|
||||
kok|||Konkani|konkani
|
||||
kom||kv|Komi|kom
|
||||
kon||kg|Kongo|kongo
|
||||
kor||ko|Korean|coréen
|
||||
kos|||Kosraean|kosrae
|
||||
kpe|||Kpelle|kpellé
|
||||
krc|||Karachay-Balkar|karatchai balkar
|
||||
krl|||Karelian|carélien
|
||||
kro|||Kru languages|krou, langues
|
||||
kru|||Kurukh|kurukh
|
||||
kua||kj|Kuanyama; Kwanyama|kuanyama; kwanyama
|
||||
kum|||Kumyk|koumyk
|
||||
kur||ku|Kurdish|kurde
|
||||
kut|||Kutenai|kutenai
|
||||
lad|||Ladino|judéo-espagnol
|
||||
lah|||Lahnda|lahnda
|
||||
lam|||Lamba|lamba
|
||||
lao||lo|Lao|lao
|
||||
lat||la|Latin|latin
|
||||
lav||lv|Latvian|letton
|
||||
lez|||Lezghian|lezghien
|
||||
lim||li|Limburgan; Limburger; Limburgish|limbourgeois
|
||||
lin||ln|Lingala|lingala
|
||||
lit||lt|Lithuanian|lituanien
|
||||
lol|||Mongo|mongo
|
||||
loz|||Lozi|lozi
|
||||
ltz||lb|Luxembourgish; Letzeburgesch|luxembourgeois
|
||||
lua|||Luba-Lulua|luba-lulua
|
||||
lub||lu|Luba-Katanga|luba-katanga
|
||||
lug||lg|Ganda|ganda
|
||||
lui|||Luiseno|luiseno
|
||||
lun|||Lunda|lunda
|
||||
luo|||Luo (Kenya and Tanzania)|luo (Kenya et Tanzanie)
|
||||
lus|||Lushai|lushai
|
||||
mac|mkd|mk|Macedonian|macédonien
|
||||
mad|||Madurese|madourais
|
||||
mag|||Magahi|magahi
|
||||
mah||mh|Marshallese|marshall
|
||||
mai|||Maithili|maithili
|
||||
mak|||Makasar|makassar
|
||||
mal||ml|Malayalam|malayalam
|
||||
man|||Mandingo|mandingue
|
||||
mao|mri|mi|Maori|maori
|
||||
map|||Austronesian languages|austronésiennes, langues
|
||||
mar||mr|Marathi|marathe
|
||||
mas|||Masai|massaï
|
||||
may|msa|ms|Malay|malais
|
||||
mdf|||Moksha|moksa
|
||||
mdr|||Mandar|mandar
|
||||
men|||Mende|mendé
|
||||
mga|||Irish, Middle (900-1200)|irlandais moyen (900-1200)
|
||||
mic|||Mi'kmaq; Micmac|mi'kmaq; micmac
|
||||
min|||Minangkabau|minangkabau
|
||||
mis|||Uncoded languages|langues non codées
|
||||
mkh|||Mon-Khmer languages|môn-khmer, langues
|
||||
mlg||mg|Malagasy|malgache
|
||||
mlt||mt|Maltese|maltais
|
||||
mnc|||Manchu|mandchou
|
||||
mni|||Manipuri|manipuri
|
||||
mno|||Manobo languages|manobo, langues
|
||||
moh|||Mohawk|mohawk
|
||||
mon||mn|Mongolian|mongol
|
||||
mos|||Mossi|moré
|
||||
mul|||Multiple languages|multilingue
|
||||
mun|||Munda languages|mounda, langues
|
||||
mus|||Creek|muskogee
|
||||
mwl|||Mirandese|mirandais
|
||||
mwr|||Marwari|marvari
|
||||
myn|||Mayan languages|maya, langues
|
||||
myv|||Erzya|erza
|
||||
nah|||Nahuatl languages|nahuatl, langues
|
||||
nai|||North American Indian languages|nord-amérindiennes, langues
|
||||
nap|||Neapolitan|napolitain
|
||||
nau||na|Nauru|nauruan
|
||||
nav||nv|Navajo; Navaho|navaho
|
||||
nbl||nr|Ndebele, South; South Ndebele|ndébélé du Sud
|
||||
nde||nd|Ndebele, North; North Ndebele|ndébélé du Nord
|
||||
ndo||ng|Ndonga|ndonga
|
||||
nds|||Low German; Low Saxon; German, Low; Saxon, Low|bas allemand; bas saxon; allemand, bas; saxon, bas
|
||||
nep||ne|Nepali|népalais
|
||||
new|||Nepal Bhasa; Newari|nepal bhasa; newari
|
||||
nia|||Nias|nias
|
||||
nic|||Niger-Kordofanian languages|nigéro-kordofaniennes, langues
|
||||
niu|||Niuean|niué
|
||||
nno||nn|Norwegian Nynorsk; Nynorsk, Norwegian|norvégien nynorsk; nynorsk, norvégien
|
||||
nob||nb|Bokmål, Norwegian; Norwegian Bokmål|norvégien bokmål
|
||||
nog|||Nogai|nogaï; nogay
|
||||
non|||Norse, Old|norrois, vieux
|
||||
nor||no|Norwegian|norvégien
|
||||
nqo|||N'Ko|n'ko
|
||||
nso|||Pedi; Sepedi; Northern Sotho|pedi; sepedi; sotho du Nord
|
||||
nub|||Nubian languages|nubiennes, langues
|
||||
nwc|||Classical Newari; Old Newari; Classical Nepal Bhasa|newari classique
|
||||
nya||ny|Chichewa; Chewa; Nyanja|chichewa; chewa; nyanja
|
||||
nym|||Nyamwezi|nyamwezi
|
||||
nyn|||Nyankole|nyankolé
|
||||
nyo|||Nyoro|nyoro
|
||||
nzi|||Nzima|nzema
|
||||
oci||oc|Occitan (post 1500)|occitan (après 1500)
|
||||
oji||oj|Ojibwa|ojibwa
|
||||
ori||or|Oriya|oriya
|
||||
orm||om|Oromo|galla
|
||||
osa|||Osage|osage
|
||||
oss||os|Ossetian; Ossetic|ossète
|
||||
ota|||Turkish, Ottoman (1500-1928)|turc ottoman (1500-1928)
|
||||
oto|||Otomian languages|otomi, langues
|
||||
paa|||Papuan languages|papoues, langues
|
||||
pag|||Pangasinan|pangasinan
|
||||
pal|||Pahlavi|pahlavi
|
||||
pam|||Pampanga; Kapampangan|pampangan
|
||||
pan||pa|Panjabi; Punjabi|pendjabi
|
||||
pap|||Papiamento|papiamento
|
||||
pau|||Palauan|palau
|
||||
peo|||Persian, Old (ca.600-400 B.C.)|perse, vieux (ca. 600-400 av. J.-C.)
|
||||
per|fas|fa|Persian|persan
|
||||
phi|||Philippine languages|philippines, langues
|
||||
phn|||Phoenician|phénicien
|
||||
pli||pi|Pali|pali
|
||||
pol||pl|Polish|polonais
|
||||
pon|||Pohnpeian|pohnpei
|
||||
por||pt|Portuguese|portugais
|
||||
pra|||Prakrit languages|prâkrit, langues
|
||||
pro|||Provençal, Old (to 1500); Occitan, Old (to 1500)|provençal ancien (jusqu'à 1500); occitan ancien (jusqu'à 1500)
|
||||
pus||ps|Pushto; Pashto|pachto
|
||||
qaa-qtz|||Reserved for local use|réservée à l'usage local
|
||||
que||qu|Quechua|quechua
|
||||
raj|||Rajasthani|rajasthani
|
||||
rap|||Rapanui|rapanui
|
||||
rar|||Rarotongan; Cook Islands Maori|rarotonga; maori des îles Cook
|
||||
roa|||Romance languages|romanes, langues
|
||||
roh||rm|Romansh|romanche
|
||||
rom|||Romany|tsigane
|
||||
rum|ron|ro|Romanian; Moldavian; Moldovan|roumain; moldave
|
||||
run||rn|Rundi|rundi
|
||||
rup|||Aromanian; Arumanian; Macedo-Romanian|aroumain; macédo-roumain
|
||||
rus||ru|Russian|russe
|
||||
sad|||Sandawe|sandawe
|
||||
sag||sg|Sango|sango
|
||||
sah|||Yakut|iakoute
|
||||
sai|||South American Indian languages|sud-amérindiennes, langues
|
||||
sal|||Salishan languages|salishennes, langues
|
||||
sam|||Samaritan Aramaic|samaritain
|
||||
san||sa|Sanskrit|sanskrit
|
||||
sas|||Sasak|sasak
|
||||
sat|||Santali|santal
|
||||
scn|||Sicilian|sicilien
|
||||
sco|||Scots|écossais
|
||||
sel|||Selkup|selkoupe
|
||||
sem|||Semitic languages|sémitiques, langues
|
||||
sga|||Irish, Old (to 900)|irlandais ancien (jusqu'à 900)
|
||||
sgn|||Sign Languages|langues des signes
|
||||
shn|||Shan|chan
|
||||
sid|||Sidamo|sidamo
|
||||
sin||si|Sinhala; Sinhalese|singhalais
|
||||
sio|||Siouan languages|sioux, langues
|
||||
sit|||Sino-Tibetan languages|sino-tibétaines, langues
|
||||
sla|||Slavic languages|slaves, langues
|
||||
slo|slk|sk|Slovak|slovaque
|
||||
slv||sl|Slovenian|slovène
|
||||
sma|||Southern Sami|sami du Sud
|
||||
sme||se|Northern Sami|sami du Nord
|
||||
smi|||Sami languages|sames, langues
|
||||
smj|||Lule Sami|sami de Lule
|
||||
smn|||Inari Sami|sami d'Inari
|
||||
smo||sm|Samoan|samoan
|
||||
sms|||Skolt Sami|sami skolt
|
||||
sna||sn|Shona|shona
|
||||
snd||sd|Sindhi|sindhi
|
||||
snk|||Soninke|soninké
|
||||
sog|||Sogdian|sogdien
|
||||
som||so|Somali|somali
|
||||
son|||Songhai languages|songhai, langues
|
||||
sot||st|Sotho, Southern|sotho du Sud
|
||||
spa||es|Spanish; Castilian|espagnol; castillan
|
||||
srd||sc|Sardinian|sarde
|
||||
srn|||Sranan Tongo|sranan tongo
|
||||
srp||sr|Serbian|serbe
|
||||
srr|||Serer|sérère
|
||||
ssa|||Nilo-Saharan languages|nilo-sahariennes, langues
|
||||
ssw||ss|Swati|swati
|
||||
suk|||Sukuma|sukuma
|
||||
sun||su|Sundanese|soundanais
|
||||
sus|||Susu|soussou
|
||||
sux|||Sumerian|sumérien
|
||||
swa||sw|Swahili|swahili
|
||||
swe||sv|Swedish|suédois
|
||||
syc|||Classical Syriac|syriaque classique
|
||||
syr|||Syriac|syriaque
|
||||
tah||ty|Tahitian|tahitien
|
||||
tai|||Tai languages|tai, langues
|
||||
tam||ta|Tamil|tamoul
|
||||
tat||tt|Tatar|tatar
|
||||
tel||te|Telugu|télougou
|
||||
tem|||Timne|temne
|
||||
ter|||Tereno|tereno
|
||||
tet|||Tetum|tetum
|
||||
tgk||tg|Tajik|tadjik
|
||||
tgl||tl|Tagalog|tagalog
|
||||
tha||th|Thai|thaï
|
||||
tib|bod|bo|Tibetan|tibétain
|
||||
tig|||Tigre|tigré
|
||||
tir||ti|Tigrinya|tigrigna
|
||||
tiv|||Tiv|tiv
|
||||
tkl|||Tokelau|tokelau
|
||||
tlh|||Klingon; tlhIngan-Hol|klingon
|
||||
tli|||Tlingit|tlingit
|
||||
tmh|||Tamashek|tamacheq
|
||||
tog|||Tonga (Nyasa)|tonga (Nyasa)
|
||||
ton||to|Tonga (Tonga Islands)|tongan (Îles Tonga)
|
||||
tpi|||Tok Pisin|tok pisin
|
||||
tsi|||Tsimshian|tsimshian
|
||||
tsn||tn|Tswana|tswana
|
||||
tso||ts|Tsonga|tsonga
|
||||
tuk||tk|Turkmen|turkmène
|
||||
tum|||Tumbuka|tumbuka
|
||||
tup|||Tupi languages|tupi, langues
|
||||
tur||tr|Turkish|turc
|
||||
tut|||Altaic languages|altaïques, langues
|
||||
tvl|||Tuvalu|tuvalu
|
||||
twi||tw|Twi|twi
|
||||
tyv|||Tuvinian|touva
|
||||
udm|||Udmurt|oudmourte
|
||||
uga|||Ugaritic|ougaritique
|
||||
uig||ug|Uighur; Uyghur|ouïgour
|
||||
ukr||uk|Ukrainian|ukrainien
|
||||
umb|||Umbundu|umbundu
|
||||
und|||Undetermined|indéterminée
|
||||
urd||ur|Urdu|ourdou
|
||||
uzb||uz|Uzbek|ouszbek
|
||||
vai|||Vai|vaï
|
||||
ven||ve|Venda|venda
|
||||
vie||vi|Vietnamese|vietnamien
|
||||
vol||vo|Volapük|volapük
|
||||
vot|||Votic|vote
|
||||
wak|||Wakashan languages|wakashanes, langues
|
||||
wal|||Wolaitta; Wolaytta|wolaitta; wolaytta
|
||||
war|||Waray|waray
|
||||
was|||Washo|washo
|
||||
wel|cym|cy|Welsh|gallois
|
||||
wen|||Sorbian languages|sorabes, langues
|
||||
wln||wa|Walloon|wallon
|
||||
wol||wo|Wolof|wolof
|
||||
xal|||Kalmyk; Oirat|kalmouk; oïrat
|
||||
xho||xh|Xhosa|xhosa
|
||||
yao|||Yao|yao
|
||||
yap|||Yapese|yapois
|
||||
yid||yi|Yiddish|yiddish
|
||||
yor||yo|Yoruba|yoruba
|
||||
ypk|||Yupik languages|yupik, langues
|
||||
zap|||Zapotec|zapotèque
|
||||
zbl|||Blissymbols; Blissymbolics; Bliss|symboles Bliss; Bliss
|
||||
zen|||Zenaga|zenaga
|
||||
zgh|||Standard Moroccan Tamazight|amazighe standard marocain
|
||||
zha||za|Zhuang; Chuang|zhuang; chuang
|
||||
znd|||Zande languages|zandé, langues
|
||||
zul||zu|Zulu|zoulou
|
||||
zun|||Zuni|zuni
|
||||
zxx|||No linguistic content; Not applicable|pas de contenu linguistique; non applicable
|
||||
zza|||Zaza; Dimili; Dimli; Kirdki; Kirmanjki; Zazaki|zaza; dimili; dimli; kirdki; kirmanjki; zazaki
|
||||
@@ -72,7 +72,7 @@
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_collections = await _mediator.Send(new GetAllCollections())
|
||||
.Map(list => new[] { _newCollection }.Append(list).ToList());
|
||||
.Map(list => new[] { _newCollection }.Append(list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase)).ToList());
|
||||
|
||||
if (_memoryCache.TryGetValue("AddToCollectionDialog.SelectedCollectionId", out int id))
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@using ErsatzTV.Application.ProgramSchedules
|
||||
@using ErsatzTV.Application.ProgramSchedules.Queries
|
||||
@using NaturalSort.Extension
|
||||
@inject IMediator _mediator
|
||||
|
||||
<div @onkeydown="@OnKeyDown">
|
||||
@@ -49,7 +50,8 @@
|
||||
private ProgramScheduleViewModel _selectedSchedule;
|
||||
|
||||
protected override async Task OnParametersSetAsync() =>
|
||||
_schedules = await _mediator.Send(new GetAllProgramSchedules());
|
||||
_schedules = await _mediator.Send(new GetAllProgramSchedules())
|
||||
.Map(list => list.OrderBy(vm => vm.Name, new NaturalSortComparer(StringComparison.CurrentCultureIgnoreCase)).ToList());
|
||||
|
||||
private string FormatText() => $"Select the schedule to add the {EntityType} {EntityName}";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@using ErsatzTV.Core.Search
|
||||
@using ErsatzTV.Extensions
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
<div class="letter-bar-container">
|
||||
<div class="letter-bar">
|
||||
@@ -48,7 +49,8 @@
|
||||
var uri = $"{BaseUri}/page/{PageMap.PageMap[letter]}";
|
||||
if (!string.IsNullOrWhiteSpace(Query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", Query);
|
||||
(string key, string value) = Query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
return uri + $"#letter-{letter}";
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
@using System.Reflection
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using Microsoft.Extensions.Primitives
|
||||
@using System.Web
|
||||
@using ErsatzTV.Extensions
|
||||
@inherits LayoutComponentBase
|
||||
@inject NavigationManager _navigationManager
|
||||
|
||||
@@ -107,22 +105,12 @@
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await base.OnParametersSetAsync();
|
||||
|
||||
string query = new Uri(_navigationManager.Uri).Query;
|
||||
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
|
||||
{
|
||||
_query = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
_query = string.Empty;
|
||||
}
|
||||
_query = _navigationManager.Uri.GetSearchQuery();
|
||||
}
|
||||
|
||||
private void PerformSearch()
|
||||
{
|
||||
string query = HttpUtility.UrlEncode(_query);
|
||||
_navigationManager.NavigateTo($"/search?query={query}", true);
|
||||
_navigationManager.NavigateTo(_query.GetRelativeSearchQuery(), true);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
|
||||
+12
-3
@@ -7,6 +7,7 @@ using Blazored.LocalStorage;
|
||||
using Dapper;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Channels.Queries;
|
||||
using ErsatzTV.Application.Logs.Queries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
@@ -144,14 +145,21 @@ namespace ErsatzTV
|
||||
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
|
||||
o.MigrationsAssembly("ErsatzTV.Infrastructure");
|
||||
}));
|
||||
|
||||
|
||||
services.AddTransient<IDbConnection>(_ => new SqliteConnection(connectionString));
|
||||
SqlMapper.AddTypeHandler(new DateTimeOffsetHandler());
|
||||
SqlMapper.AddTypeHandler(new GuidHandler());
|
||||
SqlMapper.AddTypeHandler(new TimeSpanHandler());
|
||||
|
||||
var logConnectionString = $"Data Source={FileSystemLayout.LogDatabasePath}";
|
||||
|
||||
services.AddDbContext<LogContext>(
|
||||
options => options.UseSqlite($"Data Source={FileSystemLayout.LogDatabasePath}"));
|
||||
options => options.UseSqlite(logConnectionString),
|
||||
ServiceLifetime.Scoped,
|
||||
ServiceLifetime.Singleton);
|
||||
|
||||
services.AddDbContextFactory<LogContext>(
|
||||
options => options.UseSqlite(logConnectionString));
|
||||
|
||||
services.AddMediatR(typeof(GetAllChannels).Assembly);
|
||||
|
||||
@@ -196,7 +204,6 @@ namespace ErsatzTV
|
||||
services.AddScoped<IMediaItemRepository, MediaItemRepository>();
|
||||
services.AddScoped<IMediaCollectionRepository, MediaCollectionRepository>();
|
||||
services.AddScoped<IConfigElementRepository, ConfigElementRepository>();
|
||||
services.AddScoped<ILogRepository, LogRepository>();
|
||||
services.AddScoped<ITelevisionRepository, TelevisionRepository>();
|
||||
services.AddScoped<ISearchRepository, SearchRepository>();
|
||||
services.AddScoped<IMovieRepository, MovieRepository>();
|
||||
@@ -244,6 +251,8 @@ namespace ErsatzTV
|
||||
services.AddScoped<IEmbySecretStore, EmbySecretStore>();
|
||||
services.AddScoped<IEpisodeNfoReader, EpisodeNfoReader>();
|
||||
|
||||
// services.AddTransient(typeof(IRequestHandler<,>), typeof(GetRecentLogEntriesHandler<>));
|
||||
|
||||
services.AddHostedService<EndpointValidatorService>();
|
||||
services.AddHostedService<DatabaseMigratorService>();
|
||||
services.AddHostedService<CacheCleanerService>();
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ErsatzTV.Validators
|
||||
() => RuleFor(i => i.StartTime).NotNull());
|
||||
When(
|
||||
i => i.PlayoutMode == PlayoutMode.Multiple,
|
||||
() => RuleFor(i => i.MultipleCount).NotNull().GreaterThan(0));
|
||||
() => RuleFor(i => i.MultipleCount).NotNull().GreaterThanOrEqualTo(0));
|
||||
When(
|
||||
i => i.PlayoutMode == PlayoutMode.Duration,
|
||||
() => RuleFor(i => i.PlayoutDuration).NotNull());
|
||||
|
||||
@@ -132,4 +132,7 @@
|
||||
|
||||
.release-notes h3 { margin-top: 20px; }
|
||||
|
||||
.mud-table-container { overflow-x: unset; }
|
||||
.mud-table-container { overflow-x: unset; }
|
||||
|
||||
#blazor-error-ui { color: black; }
|
||||
#blazor-error-ui a { color: unset; }
|
||||
|
||||
Reference in New Issue
Block a user