Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
082bc6145c | ||
|
|
bf3f16451b | ||
|
|
3cb37003cb | ||
|
|
9acfd2cd06 | ||
|
|
3242e7ebb8 | ||
|
|
7644d628e7 | ||
|
|
b4f19e6de4 | ||
|
|
0388425763 | ||
|
|
ca5d303ac7 | ||
|
|
18e66a92ad | ||
|
|
7d0a56ab98 | ||
|
|
5069792d12 | ||
|
|
c9789458b9 | ||
|
|
777a0d09ed | ||
|
|
4e2ebcc48a | ||
|
|
90fe1d7709 | ||
|
|
1576dd026e | ||
|
|
ee7a64eea9 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
|
||||
[*]
|
||||
charset=utf-8-bom
|
||||
charset=utf-8
|
||||
end_of_line=lf
|
||||
trim_trailing_whitespace=false
|
||||
insert_final_newline=false
|
||||
|
||||
+28
-1
@@ -5,6 +5,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.1-beta] - 2022-06-03
|
||||
### Fixed
|
||||
- Fix Jellyfin show library paging
|
||||
- Properly locate and identify multiple Plex servers
|
||||
- Properly restore `Unavailable`/`File Not Found` items when they are located on disk
|
||||
|
||||
### Added
|
||||
- Add basic music video credits subtitle generation
|
||||
- This can be enabled in channel settings
|
||||
|
||||
## [0.6.0-beta] - 2022-06-01
|
||||
### Fixed
|
||||
- Additional fix for duplicate `Other Videos` entries; trash may need to be emptied one last time after upgrading
|
||||
- Fix watermark opacity in cultures where `,` is a decimal separator
|
||||
- Rework playlist filtering to avoid empty playlist responses
|
||||
- Fix some QSV/VAAPI memory errors by always requesting 64 extra hardware frames
|
||||
|
||||
### Added
|
||||
- Enable QSV hardware acceleration for vaapi docker images
|
||||
|
||||
### Changed
|
||||
- Use paging to synchronize all media from Plex, Jellyfin and Emby
|
||||
- This will reduce memory use and improve reliability of synchronizing large libraries
|
||||
- Disable low power mode for `h264_qsv` and `hevc_qsv` encoders
|
||||
|
||||
## [0.5.8-beta] - 2022-05-20
|
||||
### Fixed
|
||||
- Fix error display with `HLS Segmenter` and `MPEG-TS` streaming modes
|
||||
@@ -1210,7 +1235,9 @@ 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.5.8-beta...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.1-beta...HEAD
|
||||
[0.6.1-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.0-beta...v0.6.1-beta
|
||||
[0.6.0-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.8-beta...v0.6.0-beta
|
||||
[0.5.8-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.7-beta...v0.5.8-beta
|
||||
[0.5.7-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.6-beta...v0.5.7-beta
|
||||
[0.5.6-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.5-beta...v0.5.6-beta
|
||||
|
||||
@@ -16,4 +16,5 @@ public record ChannelViewModel(
|
||||
int? FallbackFillerId,
|
||||
int PlayoutCount,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode);
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode);
|
||||
|
||||
@@ -16,4 +16,5 @@ public record CreateChannel
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, c => PersistChannel(dbContext, c));
|
||||
return await validation.Apply(c => PersistChannel(dbContext, c));
|
||||
}
|
||||
|
||||
private static async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
|
||||
@@ -72,7 +72,8 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
|
||||
Artwork = artwork,
|
||||
PreferredAudioLanguageCode = preferredAudioLanguageCode,
|
||||
PreferredSubtitleLanguageCode = preferredSubtitleLanguageCode,
|
||||
SubtitleMode = request.SubtitleMode
|
||||
SubtitleMode = request.SubtitleMode,
|
||||
MusicVideoCreditsMode = request.MusicVideoCreditsMode
|
||||
};
|
||||
|
||||
foreach (int id in watermarkId)
|
||||
|
||||
@@ -17,4 +17,5 @@ public record UpdateChannel
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
|
||||
@@ -44,6 +44,7 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
|
||||
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
|
||||
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
|
||||
c.SubtitleMode = update.SubtitleMode;
|
||||
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
|
||||
c.Artwork ??= new List<Artwork>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(update.Logo))
|
||||
|
||||
@@ -20,7 +20,8 @@ internal static class Mapper
|
||||
channel.FallbackFillerId,
|
||||
channel.Playouts?.Count ?? 0,
|
||||
channel.PreferredSubtitleLanguageCode,
|
||||
channel.SubtitleMode);
|
||||
channel.SubtitleMode,
|
||||
channel.MusicVideoCreditsMode);
|
||||
|
||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
|
||||
new(
|
||||
|
||||
@@ -9,7 +9,9 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, ChannelGu
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
|
||||
public GetChannelGuideHandler(IChannelRepository channelRepository, RecyclableMemoryStreamManager recyclableMemoryStreamManager)
|
||||
public GetChannelGuideHandler(
|
||||
IChannelRepository channelRepository,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager)
|
||||
{
|
||||
_channelRepository = channelRepository;
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
|
||||
@@ -20,7 +20,7 @@ public class CreateFFmpegProfileHandler :
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, profile => PersistFFmpegProfile(dbContext, profile));
|
||||
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
|
||||
}
|
||||
|
||||
private static async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
|
||||
|
||||
@@ -35,6 +35,27 @@ internal static class Mapper
|
||||
ffmpegProfile.VideoFormat.ToString().ToLowerInvariant(),
|
||||
ffmpegProfile.AudioFormat.ToString().ToLowerInvariant());
|
||||
|
||||
internal static FFmpegFullProfileResponseModel ProjectToFullResponseModel(FFmpegProfile ffmpegProfile) =>
|
||||
new(
|
||||
ffmpegProfile.Id,
|
||||
ffmpegProfile.Name,
|
||||
ffmpegProfile.ThreadCount,
|
||||
(int)ffmpegProfile.HardwareAcceleration,
|
||||
(int)ffmpegProfile.VaapiDriver,
|
||||
ffmpegProfile.VaapiDevice,
|
||||
ffmpegProfile.ResolutionId,
|
||||
(int)ffmpegProfile.VideoFormat,
|
||||
ffmpegProfile.VideoBitrate,
|
||||
ffmpegProfile.VideoBufferSize,
|
||||
(int)ffmpegProfile.AudioFormat,
|
||||
ffmpegProfile.AudioBitrate,
|
||||
ffmpegProfile.AudioBufferSize,
|
||||
ffmpegProfile.NormalizeLoudness,
|
||||
ffmpegProfile.AudioChannels,
|
||||
ffmpegProfile.AudioSampleRate,
|
||||
ffmpegProfile.NormalizeFramerate,
|
||||
ffmpegProfile.DeinterlaceVideo);
|
||||
|
||||
private static ResolutionViewModel Project(Resolution resolution) =>
|
||||
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public record GetFFmpegFullProfileByIdForApi(int Id) : IRequest<Option<FFmpegFullProfileResponseModel>>;
|
||||
@@ -0,0 +1,28 @@
|
||||
using ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.FFmpegProfiles.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class
|
||||
GetFFmpegProfileByIdForApiHandler : IRequestHandler<GetFFmpegFullProfileByIdForApi,
|
||||
Option<FFmpegFullProfileResponseModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetFFmpegProfileByIdForApiHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Option<FFmpegFullProfileResponseModel>> Handle(
|
||||
GetFFmpegFullProfileByIdForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.FFmpegProfiles
|
||||
.Include(p => p.Resolution)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.Id)
|
||||
.MapT(ProjectToFullResponseModel);
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
|
||||
long ptsOffset = await GetPtsOffset(mediator, _channelNumber, cancellationToken);
|
||||
long ptsOffset = await GetPtsOffset(mediator, _channelNumber, _firstProcess, cancellationToken);
|
||||
// _logger.LogInformation("PTS offset: {PtsOffset}", ptsOffset);
|
||||
|
||||
var request = new GetPlayoutItemProcessByChannelNumber(
|
||||
@@ -374,6 +374,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
private async Task<long> GetPtsOffset(
|
||||
IMediator mediator,
|
||||
string channelNumber,
|
||||
bool firstProcess,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await Slim.WaitAsync(cancellationToken);
|
||||
@@ -381,6 +382,12 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
long result = 0;
|
||||
|
||||
// the first process always starts at zero
|
||||
if (firstProcess)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
Either<BaseError, PtsAndDuration> queryResult = await mediator.Send(
|
||||
new GetLastPtsDuration(channelNumber),
|
||||
cancellationToken);
|
||||
|
||||
+38
-11
@@ -27,6 +27,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
|
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||
private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly ISongVideoGenerator _songVideoGenerator;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
@@ -42,6 +43,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
ITelevisionRepository televisionRepository,
|
||||
IArtistRepository artistRepository,
|
||||
ISongVideoGenerator songVideoGenerator,
|
||||
IMusicVideoCreditsGenerator musicVideoCreditsGenerator,
|
||||
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
|
||||
: base(dbContextFactory)
|
||||
{
|
||||
@@ -54,6 +56,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
_televisionRepository = televisionRepository;
|
||||
_artistRepository = artistRepository;
|
||||
_songVideoGenerator = songVideoGenerator;
|
||||
_musicVideoCreditsGenerator = musicVideoCreditsGenerator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -96,6 +99,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).Artist)
|
||||
.ThenInclude(mv => mv.ArtistMetadata)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(ovm => ovm.Subtitles)
|
||||
.Include(i => i.MediaItem)
|
||||
@@ -155,7 +161,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
.Map(result => result.IfNone(false));
|
||||
|
||||
List<Subtitle> subtitles = GetSubtitles(playoutItemWithPath);
|
||||
List<Subtitle> subtitles = await GetSubtitles(playoutItemWithPath, channel);
|
||||
|
||||
Command process = await _ffmpegProcessService.ForPlayoutItem(
|
||||
ffmpegPath,
|
||||
@@ -256,22 +262,22 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
return BaseError.New($"Unexpected error locating playout item for channel {channel.Number}");
|
||||
}
|
||||
|
||||
private static List<Subtitle> GetSubtitles(PlayoutItemWithPath playoutItemWithPath)
|
||||
private async Task<List<Subtitle>> GetSubtitles(
|
||||
PlayoutItemWithPath playoutItemWithPath,
|
||||
Channel channel)
|
||||
{
|
||||
List<Subtitle> allSubtitles = playoutItemWithPath.PlayoutItem.MediaItem switch
|
||||
{
|
||||
Episode episode => Optional(episode.EpisodeMetadata).Flatten().HeadOrNone()
|
||||
Episode episode => await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone()
|
||||
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
||||
.IfNone(new List<Subtitle>()),
|
||||
Movie movie => Optional(movie.MovieMetadata).Flatten().HeadOrNone()
|
||||
.IfNoneAsync(new List<Subtitle>()),
|
||||
Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone()
|
||||
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
||||
.IfNone(new List<Subtitle>()),
|
||||
MusicVideo musicVideo => Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone()
|
||||
.IfNoneAsync(new List<Subtitle>()),
|
||||
MusicVideo musicVideo => await GetMusicVideoSubtitles(musicVideo, channel),
|
||||
OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
|
||||
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
||||
.IfNone(new List<Subtitle>()),
|
||||
OtherVideo otherVideo => Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
|
||||
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
||||
.IfNone(new List<Subtitle>()),
|
||||
.IfNoneAsync(new List<Subtitle>()),
|
||||
_ => new List<Subtitle>()
|
||||
};
|
||||
|
||||
@@ -309,6 +315,27 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
return allSubtitles;
|
||||
}
|
||||
|
||||
private async Task<List<Subtitle>> GetMusicVideoSubtitles(MusicVideo musicVideo, Channel channel)
|
||||
{
|
||||
var subtitles = new List<Subtitle>();
|
||||
|
||||
bool musicVideoCredits = channel.MusicVideoCreditsMode == ChannelMusicVideoCreditsMode.GenerateSubtitles;
|
||||
if (musicVideoCredits)
|
||||
{
|
||||
subtitles.AddRange(
|
||||
await _musicVideoCreditsGenerator.GenerateCreditsSubtitle(musicVideo, channel.FFmpegProfile));
|
||||
}
|
||||
else
|
||||
{
|
||||
subtitles.AddRange(
|
||||
await Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone()
|
||||
.Map(mm => mm.Subtitles)
|
||||
.IfNoneAsync(new List<Subtitle>()));
|
||||
}
|
||||
|
||||
return subtitles;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlayoutItemWithPath>> CheckForFallbackFiller(
|
||||
TvContext dbContext,
|
||||
Channel channel,
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.0.1" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.4" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.6.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.1.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.7.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.1.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
|
||||
|
||||
@@ -20,7 +20,7 @@ public class HlsPlaylistFilterTests
|
||||
private HlsPlaylistFilter _hlsPlaylistFilter;
|
||||
|
||||
[Test]
|
||||
public void _hlsPlaylistFilter_ShouldRewriteProgramDateTime()
|
||||
public void HlsPlaylistFilter_ShouldRewriteProgramDateTime()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = NormalizeLineEndings(
|
||||
@@ -50,9 +50,8 @@ live001139.ts").Split(Environment.NewLine);
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
#EXT-X-DISCONTINUITY-SEQUENCE:0
|
||||
#EXT-X-DISCONTINUITY-SEQUENCE:1
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
|
||||
live001137.ts
|
||||
@@ -66,7 +65,7 @@ live001139.ts
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void _hlsPlaylistFilter_ShouldLimitSegments()
|
||||
public void HlsPlaylistFilter_ShouldLimitSegments()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = NormalizeLineEndings(
|
||||
@@ -96,9 +95,8 @@ live001139.ts").Split(Environment.NewLine);
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
#EXT-X-DISCONTINUITY-SEQUENCE:0
|
||||
#EXT-X-DISCONTINUITY-SEQUENCE:1
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
|
||||
live001137.ts
|
||||
@@ -109,7 +107,7 @@ live001138.ts
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void _hlsPlaylistFilter_ShouldAddDiscontinuity()
|
||||
public void HlsPlaylistFilter_ShouldAddDiscontinuity()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = NormalizeLineEndings(
|
||||
@@ -144,9 +142,8 @@ live001139.ts").Split(Environment.NewLine);
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
#EXT-X-DISCONTINUITY-SEQUENCE:0
|
||||
#EXT-X-DISCONTINUITY-SEQUENCE:1
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
|
||||
live001137.ts
|
||||
@@ -161,7 +158,7 @@ live001139.ts
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void _hlsPlaylistFilter_ShouldFilterOldSegments()
|
||||
public void HlsPlaylistFilter_ShouldFilterOldSegmentsBeyondMax()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = NormalizeLineEndings(
|
||||
@@ -181,48 +178,7 @@ live001138.ts
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
|
||||
live001139.ts").Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
|
||||
|
||||
result.PlaylistStart.Should().Be(start.AddSeconds(8));
|
||||
result.Sequence.Should().Be(1139);
|
||||
result.Playlist.Should().Be(
|
||||
NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1139
|
||||
#EXT-X-DISCONTINUITY-SEQUENCE:0
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
live001139.ts
|
||||
"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void _hlsPlaylistFilter_ShouldFilterOldDiscontinuity()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
|
||||
live001137.ts
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
|
||||
live001139.ts").Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
|
||||
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input, 1);
|
||||
|
||||
result.PlaylistStart.Should().Be(start.AddSeconds(8));
|
||||
result.Sequence.Should().Be(1139);
|
||||
@@ -234,13 +190,367 @@ live001139.ts").Split(Environment.NewLine);
|
||||
#EXT-X-MEDIA-SEQUENCE:1139
|
||||
#EXT-X-DISCONTINUITY-SEQUENCE:1
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
live001139.ts
|
||||
"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_ShouldFilterOldDiscontinuity()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
|
||||
live001137.ts
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
|
||||
live001139.ts").Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
|
||||
|
||||
result.PlaylistStart.Should().Be(start);
|
||||
result.Sequence.Should().Be(1137);
|
||||
result.Playlist.Should().Be(
|
||||
NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
#EXT-X-DISCONTINUITY-SEQUENCE:1
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
|
||||
live001137.ts
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
live001139.ts
|
||||
"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_Should_Increment_DiscontinuityCount()
|
||||
{
|
||||
var start = new DateTimeOffset(2022, 5, 25, 20, 8, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:08:55.981-0500
|
||||
live000000.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:08:59.985-0500
|
||||
live000001.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:03.989-0500
|
||||
live000002.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:07.993-0500
|
||||
live000003.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:11.997-0500
|
||||
live000004.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:16.001-0500
|
||||
live000005.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:20.005-0500
|
||||
live000006.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:24.009-0500
|
||||
live000007.ts
|
||||
#EXTINF:3.970633,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:28.013-0500
|
||||
live000008.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:31.983-0500
|
||||
live000009.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:35.987-0500
|
||||
live000010.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:39.991-0500
|
||||
live000011.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:43.995-0500
|
||||
live000012.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:47.999-0500
|
||||
live000013.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:52.003-0500
|
||||
live000014.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:09:56.007-0500
|
||||
live000015.ts
|
||||
#EXTINF:3.970633,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:00.011-0500
|
||||
live000016.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:03.982-0500
|
||||
live000017.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:07.986-0500
|
||||
live000018.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:11.990-0500
|
||||
live000019.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:15.994-0500
|
||||
live000020.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:19.998-0500
|
||||
live000021.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:24.002-0500
|
||||
live000022.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:28.006-0500
|
||||
live000023.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:32.010-0500
|
||||
live000024.ts
|
||||
#EXTINF:3.970633,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:36.014-0500
|
||||
live000025.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:39.985-0500
|
||||
live000026.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:43.989-0500
|
||||
live000027.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:47.993-0500
|
||||
live000028.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:51.997-0500
|
||||
live000029.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:10:56.001-0500
|
||||
live000030.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:00.005-0500
|
||||
live000031.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:04.009-0500
|
||||
live000032.ts
|
||||
#EXTINF:3.970633,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:08.013-0500
|
||||
live000033.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:11.983-0500
|
||||
live000034.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:15.987-0500
|
||||
live000035.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:19.991-0500
|
||||
live000036.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:23.995-0500
|
||||
live000037.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:27.999-0500
|
||||
live000038.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:32.003-0500
|
||||
live000039.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:36.007-0500
|
||||
live000040.ts
|
||||
#EXTINF:3.970633,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:40.011-0500
|
||||
live000041.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:43.982-0500
|
||||
live000042.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:47.986-0500
|
||||
live000043.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:51.990-0500
|
||||
live000044.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:55.994-0500
|
||||
live000045.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:59.998-0500
|
||||
live000046.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:04.002-0500
|
||||
live000047.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:08.006-0500
|
||||
live000048.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:12.010-0500
|
||||
live000049.ts
|
||||
#EXTINF:3.970633,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:16.014-0500
|
||||
live000050.ts
|
||||
#EXTINF:4.004000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:19.985-0500
|
||||
live000051.ts
|
||||
#EXTINF:0.433767,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:23.989-0500
|
||||
live000052.ts
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:30.007-0500
|
||||
live000053.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:34.007-0500
|
||||
live000054.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:38.007-0500
|
||||
live000055.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:42.007-0500
|
||||
live000056.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:46.007-0500
|
||||
live000057.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:50.007-0500
|
||||
live000058.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:54.007-0500
|
||||
live000059.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:58.007-0500
|
||||
live000060.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:02.007-0500
|
||||
live000061.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:06.007-0500
|
||||
live000062.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:10.007-0500
|
||||
live000063.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:14.007-0500
|
||||
live000064.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:18.007-0500
|
||||
live000065.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:22.007-0500
|
||||
live000066.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:26.007-0500
|
||||
live000067.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:30.007-0500
|
||||
live000068.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:34.007-0500
|
||||
live000069.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:38.007-0500
|
||||
live000070.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:42.007-0500
|
||||
live000071.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:46.007-0500
|
||||
live000072.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:50.007-0500
|
||||
live000073.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:54.007-0500
|
||||
live000074.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:58.007-0500
|
||||
live000075.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:02.007-0500
|
||||
live000076.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:06.007-0500
|
||||
live000077.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:10.007-0500
|
||||
live000078.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:14.007-0500
|
||||
live000079.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:18.007-0500
|
||||
live000080.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:22.007-0500
|
||||
live000081.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:26.007-0500
|
||||
live000082.ts").Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(220), input);
|
||||
|
||||
// result.PlaylistStart.Should().Be(start);
|
||||
result.Sequence.Should().Be(56);
|
||||
result.Playlist.Should().Be(
|
||||
NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:56
|
||||
#EXT-X-DISCONTINUITY-SEQUENCE:2
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:40.441-0500
|
||||
live000056.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:44.441-0500
|
||||
live000057.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:48.441-0500
|
||||
live000058.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:52.441-0500
|
||||
live000059.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:11:56.441-0500
|
||||
live000060.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:00.441-0500
|
||||
live000061.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:04.441-0500
|
||||
live000062.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:08.441-0500
|
||||
live000063.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:12.441-0500
|
||||
live000064.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:12:16.441-0500
|
||||
live000065.ts
|
||||
"));
|
||||
}
|
||||
|
||||
private static string NormalizeLineEndings(string str) =>
|
||||
str
|
||||
.Replace("\r\n", "\n")
|
||||
|
||||
@@ -566,10 +566,8 @@ public class TranscodingTests
|
||||
Optional(version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask();
|
||||
|
||||
public Task<Option<Domain.Subtitle>> SelectSubtitleStream(
|
||||
MediaVersion version,
|
||||
List<Domain.Subtitle> subtitles,
|
||||
StreamingMode streamingMode,
|
||||
string channelNumber,
|
||||
Channel channel,
|
||||
string preferredSubtitleLanguage,
|
||||
ChannelSubtitleMode subtitleMode) =>
|
||||
subtitles.HeadOrNone().AsTask();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
|
||||
public record FFmpegFullProfileResponseModel(
|
||||
int Id,
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
int HardwareAcceleration,
|
||||
int VaapiDriver,
|
||||
string VaapiDevice,
|
||||
int ResolutionId,
|
||||
int VideoFormat,
|
||||
int VideoBitrate,
|
||||
int VideoBufferSize,
|
||||
int AudioFormat,
|
||||
int AudioBitrate,
|
||||
int AudioBufferSize,
|
||||
bool NormalizeLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool? DeinterlaceVideo);
|
||||
@@ -25,4 +25,5 @@ public class Channel
|
||||
public string PreferredAudioLanguageCode { get; set; }
|
||||
public string PreferredSubtitleLanguageCode { get; set; }
|
||||
public ChannelSubtitleMode SubtitleMode { get; set; }
|
||||
public ChannelMusicVideoCreditsMode MusicVideoCreditsMode { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public enum ChannelMusicVideoCreditsMode
|
||||
{
|
||||
None = 0,
|
||||
GenerateSubtitles = 1
|
||||
}
|
||||
@@ -3,5 +3,7 @@
|
||||
public enum SubtitleKind
|
||||
{
|
||||
Embedded = 0,
|
||||
Sidecar = 1
|
||||
Sidecar = 1,
|
||||
|
||||
Generated = 99
|
||||
}
|
||||
|
||||
@@ -30,28 +30,22 @@ public class EmbyCollectionScanner : IEmbyCollectionScanner
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanCollections(string address, string apiKey)
|
||||
{
|
||||
// get all collections from db (item id, etag)
|
||||
List<EmbyCollection> existingCollections = await _embyCollectionRepository.GetCollections();
|
||||
|
||||
// get all collections from emby
|
||||
Either<BaseError, List<EmbyCollection>> maybeIncomingCollections =
|
||||
await _embyApiClient.GetCollectionLibraryItems(address, apiKey);
|
||||
|
||||
foreach (BaseError error in maybeIncomingCollections.LeftToSeq())
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Failed to get collections from Emby: {Error}", error.ToString());
|
||||
return error;
|
||||
}
|
||||
var incomingItemIds = new List<string>();
|
||||
|
||||
foreach (List<EmbyCollection> incomingCollections in maybeIncomingCollections.RightToSeq())
|
||||
{
|
||||
// loop over collections
|
||||
foreach (EmbyCollection collection in incomingCollections)
|
||||
// get all collections from db (item id, etag)
|
||||
List<EmbyCollection> existingCollections = await _embyCollectionRepository.GetCollections();
|
||||
|
||||
await foreach (EmbyCollection collection in _embyApiClient.GetCollectionLibraryItems(address, apiKey))
|
||||
{
|
||||
incomingItemIds.Add(collection.ItemId);
|
||||
|
||||
Option<EmbyCollection> maybeExisting = existingCollections.Find(c => c.ItemId == collection.ItemId);
|
||||
|
||||
// skip if unchanged (etag)
|
||||
if (await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty) == collection.Etag)
|
||||
if (await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty) ==
|
||||
collection.Etag)
|
||||
{
|
||||
_logger.LogDebug("Emby collection {Name} is unchanged", collection.Name);
|
||||
continue;
|
||||
@@ -75,12 +69,16 @@ public class EmbyCollectionScanner : IEmbyCollectionScanner
|
||||
}
|
||||
|
||||
// remove missing collections (and remove any lingering tags from those collections)
|
||||
foreach (EmbyCollection collection in existingCollections
|
||||
.Filter(e => incomingCollections.All(i => i.ItemId != e.ItemId)))
|
||||
foreach (EmbyCollection collection in existingCollections.Filter(e => !incomingItemIds.Contains(e.ItemId)))
|
||||
{
|
||||
await _embyCollectionRepository.RemoveCollection(collection);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get collections from Emby");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
@@ -90,32 +88,31 @@ public class EmbyCollectionScanner : IEmbyCollectionScanner
|
||||
string apiKey,
|
||||
EmbyCollection collection)
|
||||
{
|
||||
// get collection items from JF
|
||||
Either<BaseError, List<MediaItem>> maybeItems =
|
||||
await _embyApiClient.GetCollectionItems(address, apiKey, collection.ItemId);
|
||||
|
||||
foreach (BaseError error in maybeItems.LeftToSeq())
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Failed to get collection items from Emby: {Error}", error.ToString());
|
||||
return;
|
||||
// get collection items from Emby
|
||||
IAsyncEnumerable<MediaItem> items = _embyApiClient.GetCollectionItems(address, apiKey, collection.ItemId);
|
||||
|
||||
List<int> removedIds = await _embyCollectionRepository.RemoveAllTags(collection);
|
||||
|
||||
// sync tags on items
|
||||
var addedIds = new List<int>();
|
||||
await foreach (MediaItem item in items)
|
||||
{
|
||||
addedIds.Add(await _embyCollectionRepository.AddTag(item, collection));
|
||||
}
|
||||
|
||||
_logger.LogDebug("Emby collection {Name} contains {Count} items", collection.Name, addedIds.Count);
|
||||
|
||||
var changedIds = removedIds.Except(addedIds).ToList();
|
||||
changedIds.AddRange(addedIds.Except(removedIds));
|
||||
|
||||
await _searchIndex.RebuildItems(_searchRepository, changedIds);
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
|
||||
List<int> removedIds = await _embyCollectionRepository.RemoveAllTags(collection);
|
||||
|
||||
var embyItems = maybeItems.RightToSeq().Flatten().ToList();
|
||||
_logger.LogDebug("Emby collection {Name} contains {Count} items", collection.Name, embyItems.Count);
|
||||
|
||||
// sync tags on items
|
||||
var addedIds = new List<int>();
|
||||
foreach (MediaItem item in embyItems)
|
||||
catch (Exception ex)
|
||||
{
|
||||
addedIds.Add(await _embyCollectionRepository.AddTag(item, collection));
|
||||
_logger.LogWarning(ex, "Failed to synchronize Emby collection {Name}", collection.Name);
|
||||
}
|
||||
|
||||
var changedIds = removedIds.Except(addedIds).ToList();
|
||||
changedIds.AddRange(addedIds.Except(removedIds));
|
||||
|
||||
await _searchIndex.RebuildItems(_searchRepository, changedIds);
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ErsatzTV.Core.Emby;
|
||||
|
||||
public static class EmbyItemType
|
||||
{
|
||||
public static readonly string Movie = "Movie";
|
||||
public static readonly string Show = "Series";
|
||||
public static readonly string Season = "Season";
|
||||
public static readonly string Episode = "Episode";
|
||||
public static readonly string Collection = "BoxSet";
|
||||
public static readonly string CollectionItems = "Movie,Series,Season,Episode";
|
||||
}
|
||||
@@ -79,7 +79,16 @@ public class EmbyMovieLibraryScanner :
|
||||
protected override string MediaServerItemId(EmbyMovie movie) => movie.ItemId;
|
||||
protected override string MediaServerEtag(EmbyMovie movie) => movie.Etag;
|
||||
|
||||
protected override Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems(
|
||||
protected override Task<Either<BaseError, int>> CountMovieLibraryItems(
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyLibrary library) =>
|
||||
_embyApiClient.GetLibraryItemCount(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library.ItemId,
|
||||
EmbyItemType.Movie);
|
||||
|
||||
protected override IAsyncEnumerable<EmbyMovie> GetMovieLibraryItems(
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyLibrary library) =>
|
||||
_embyApiClient.GetMovieLibraryItems(
|
||||
|
||||
@@ -76,13 +76,19 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
protected override Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems(
|
||||
protected override Task<Either<BaseError, int>> CountShowLibraryItems(
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyLibrary library) =>
|
||||
_embyApiClient.GetShowLibraryItems(
|
||||
EmbyLibrary library)
|
||||
=> _embyApiClient.GetLibraryItemCount(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library.ItemId);
|
||||
library.ItemId,
|
||||
EmbyItemType.Show);
|
||||
|
||||
protected override IAsyncEnumerable<EmbyShow> GetShowLibraryItems(
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyLibrary library) =>
|
||||
_embyApiClient.GetShowLibraryItems(connectionParameters.Address, connectionParameters.ApiKey, library);
|
||||
|
||||
protected override string MediaServerItemId(EmbyShow show) => show.ItemId;
|
||||
protected override string MediaServerItemId(EmbySeason season) => season.ItemId;
|
||||
@@ -92,23 +98,46 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
|
||||
protected override string MediaServerEtag(EmbySeason season) => season.Etag;
|
||||
protected override string MediaServerEtag(EmbyEpisode episode) => episode.Etag;
|
||||
|
||||
protected override Task<Either<BaseError, List<EmbySeason>>> GetSeasonLibraryItems(
|
||||
protected override Task<Either<BaseError, int>> CountSeasonLibraryItems(
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyLibrary library,
|
||||
EmbyShow show) =>
|
||||
_embyApiClient.GetLibraryItemCount(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
show.ItemId,
|
||||
EmbyItemType.Season);
|
||||
|
||||
protected override IAsyncEnumerable<EmbySeason> GetSeasonLibraryItems(
|
||||
EmbyLibrary library,
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyShow show) =>
|
||||
_embyApiClient.GetSeasonLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library,
|
||||
show.ItemId);
|
||||
|
||||
protected override Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems(
|
||||
protected override Task<Either<BaseError, int>> CountEpisodeLibraryItems(
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyLibrary library,
|
||||
EmbySeason season) =>
|
||||
_embyApiClient.GetLibraryItemCount(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
season.ItemId,
|
||||
EmbyItemType.Episode);
|
||||
|
||||
protected override IAsyncEnumerable<EmbyEpisode> GetEpisodeLibraryItems(
|
||||
EmbyLibrary library,
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyShow show,
|
||||
EmbySeason season) =>
|
||||
_embyApiClient.GetEpisodeLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library,
|
||||
show.ItemId,
|
||||
season.ItemId);
|
||||
|
||||
protected override Task<Option<ShowMetadata>> GetFullMetadata(
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.0.1" />
|
||||
<PackageReference Include="Destructurama.Attributed" Version="3.0.0" />
|
||||
<PackageReference Include="Flurl" Version="3.0.5" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.1.0" />
|
||||
<PackageReference Include="Flurl" Version="3.0.6" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.1.1" />
|
||||
<PackageReference Include="MediatR" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
|
||||
|
||||
@@ -71,10 +71,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
preferredAudioLanguage);
|
||||
Option<Subtitle> maybeSubtitle =
|
||||
await _ffmpegStreamSelector.SelectSubtitleStream(
|
||||
videoVersion,
|
||||
subtitles,
|
||||
channel.StreamingMode,
|
||||
channel.Number,
|
||||
channel,
|
||||
preferredSubtitleLanguage,
|
||||
subtitleMode);
|
||||
|
||||
|
||||
@@ -86,19 +86,24 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
}
|
||||
|
||||
public async Task<Option<Subtitle>> SelectSubtitleStream(
|
||||
MediaVersion version,
|
||||
List<Subtitle> subtitles,
|
||||
StreamingMode streamingMode,
|
||||
string channelNumber,
|
||||
Channel channel,
|
||||
string preferredSubtitleLanguage,
|
||||
ChannelSubtitleMode subtitleMode)
|
||||
{
|
||||
if (channel.MusicVideoCreditsMode == ChannelMusicVideoCreditsMode.GenerateSubtitles &&
|
||||
subtitles.FirstOrDefault(s => s.SubtitleKind == SubtitleKind.Generated) is { } generatedSubtitle)
|
||||
{
|
||||
_logger.LogDebug("Selecting generated subtitle for channel {Number}", channel.Number);
|
||||
return Optional(generatedSubtitle);
|
||||
}
|
||||
|
||||
if (subtitleMode == ChannelSubtitleMode.None)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if (streamingMode == StreamingMode.HttpLiveStreamingDirect &&
|
||||
if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect &&
|
||||
string.IsNullOrWhiteSpace(preferredSubtitleLanguage))
|
||||
{
|
||||
// _logger.LogDebug(
|
||||
@@ -110,7 +115,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
string language = (preferredSubtitleLanguage ?? string.Empty).ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(language))
|
||||
{
|
||||
_logger.LogDebug("Channel {Number} has no preferred subtitle language code", channelNumber);
|
||||
_logger.LogDebug("Channel {Number} has no preferred subtitle language code", channel.Number);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -152,7 +157,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
|
||||
_logger.LogDebug(
|
||||
"Found no subtitles for channel {ChannelNumber} with mode {Mode} matching language {Language}",
|
||||
channelNumber,
|
||||
channel.Number,
|
||||
subtitleMode,
|
||||
preferredSubtitleLanguage);
|
||||
|
||||
|
||||
@@ -25,52 +25,38 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
|
||||
{
|
||||
try
|
||||
{
|
||||
// _logger.LogDebug(
|
||||
// "TrimPlaylist - Start {PlaylistStart}, FilterBefore {FilterBefore}, MaxSegments {MaxSegments}, EndWithDiscontinuity {EndWithDiscontinuity}",
|
||||
// playlistStart,
|
||||
// filterBefore,
|
||||
// maxSegments,
|
||||
// endWithDiscontinuity);
|
||||
List<PlaylistItem> items = new();
|
||||
|
||||
DateTimeOffset currentTime = playlistStart;
|
||||
DateTimeOffset nextPlaylistStart = DateTimeOffset.MaxValue;
|
||||
|
||||
var discontinuitySequence = 0;
|
||||
var startSequence = 0;
|
||||
var output = new StringBuilder();
|
||||
var started = false;
|
||||
var i = 0;
|
||||
var segments = 0;
|
||||
while (!lines[i].StartsWith("#EXTINF:"))
|
||||
{
|
||||
if (lines[i].StartsWith("#EXT-X-DISCONTINUITY-SEQUENCE"))
|
||||
{
|
||||
discontinuitySequence = int.Parse(lines[i].Split(':')[1]);
|
||||
}
|
||||
else if (lines[i].StartsWith("#EXT-X-DISCONTINUITY"))
|
||||
{
|
||||
items.Add(new PlaylistDiscontinuity());
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
while (i < lines.Length)
|
||||
{
|
||||
if (segments >= maxSegments)
|
||||
string line = lines[i];
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
break;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
string line = lines[i];
|
||||
// _logger.LogInformation("Line: {Line}", line);
|
||||
if (line.StartsWith("#EXT-X-DISCONTINUITY"))
|
||||
{
|
||||
if (started)
|
||||
{
|
||||
output.AppendLine("#EXT-X-DISCONTINUITY");
|
||||
}
|
||||
else
|
||||
{
|
||||
discontinuitySequence++;
|
||||
}
|
||||
|
||||
items.Add(new PlaylistDiscontinuity());
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -80,50 +66,23 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
|
||||
lines[i].TrimEnd(',').Split(':')[1],
|
||||
NumberStyles.Number,
|
||||
CultureInfo.InvariantCulture));
|
||||
if (currentTime < filterBefore)
|
||||
{
|
||||
currentTime += duration;
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextPlaylistStart = currentTime < nextPlaylistStart ? currentTime : nextPlaylistStart;
|
||||
|
||||
if (!started)
|
||||
{
|
||||
startSequence = int.Parse(lines[i + 2].Replace("live", string.Empty).Split('.')[0]);
|
||||
|
||||
output.AppendLine("#EXTM3U");
|
||||
output.AppendLine("#EXT-X-VERSION:6");
|
||||
output.AppendLine("#EXT-X-TARGETDURATION:4");
|
||||
output.AppendLine($"#EXT-X-MEDIA-SEQUENCE:{startSequence}");
|
||||
output.AppendLine($"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuitySequence}");
|
||||
output.AppendLine("#EXT-X-INDEPENDENT-SEGMENTS");
|
||||
output.AppendLine("#EXT-X-DISCONTINUITY");
|
||||
|
||||
started = true;
|
||||
}
|
||||
|
||||
output.AppendLine(lines[i]);
|
||||
string offset = currentTime.ToString("zzz").Replace(":", string.Empty);
|
||||
output.AppendLine($"#EXT-X-PROGRAM-DATE-TIME:{currentTime:yyyy-MM-ddTHH:mm:ss.fff}{offset}");
|
||||
output.AppendLine(lines[i + 2]);
|
||||
items.Add(new PlaylistSegment(currentTime, lines[i], lines[i + 2]));
|
||||
|
||||
currentTime += duration;
|
||||
segments++;
|
||||
i += 3;
|
||||
}
|
||||
|
||||
var playlist = output.ToString();
|
||||
if (endWithDiscontinuity && !playlist.EndsWith($"#EXT-X-DISCONTINUITY{Environment.NewLine}"))
|
||||
if (endWithDiscontinuity && items[^1] is not PlaylistDiscontinuity)
|
||||
{
|
||||
playlist += "#EXT-X-DISCONTINUITY" + Environment.NewLine;
|
||||
items.Add(new PlaylistDiscontinuity());
|
||||
}
|
||||
|
||||
if (playlist.Trim().Split(Environment.NewLine).All(l => string.IsNullOrWhiteSpace(l) || l.StartsWith('#')))
|
||||
{
|
||||
throw new Exception("Trimming playlist to nothing");
|
||||
}
|
||||
(string playlist, DateTimeOffset nextPlaylistStart, int startSequence, int segments) = GeneratePlaylist(
|
||||
items,
|
||||
filterBefore,
|
||||
discontinuitySequence,
|
||||
maxSegments);
|
||||
|
||||
return new TrimPlaylistResult(nextPlaylistStart, startSequence, playlist, segments);
|
||||
}
|
||||
@@ -153,6 +112,98 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
|
||||
DateTimeOffset filterBefore,
|
||||
string[] lines) =>
|
||||
TrimPlaylist(playlistStart, filterBefore, lines, int.MaxValue, true);
|
||||
|
||||
private static Tuple<string, DateTimeOffset, int, int> GeneratePlaylist(
|
||||
List<PlaylistItem> items,
|
||||
DateTimeOffset filterBefore,
|
||||
int discontinuitySequence,
|
||||
int maxSegments)
|
||||
{
|
||||
if (items.Any() && items[0] is PlaylistDiscontinuity)
|
||||
{
|
||||
discontinuitySequence++;
|
||||
}
|
||||
|
||||
while (items.Any() && items[0] is PlaylistDiscontinuity)
|
||||
{
|
||||
items.RemoveAt(0);
|
||||
}
|
||||
|
||||
var allSegments = items.OfType<PlaylistSegment>().ToList();
|
||||
// only filter if we have more than requested
|
||||
if (allSegments.Count > maxSegments)
|
||||
{
|
||||
var afterFilter = allSegments.Filter(s => s.StartTime >= filterBefore).ToList();
|
||||
|
||||
// if there are enough new segments after filtering, use those
|
||||
// otherwise return the last maxSegments
|
||||
allSegments = afterFilter.Count >= maxSegments
|
||||
? afterFilter.Take(maxSegments).ToList()
|
||||
: allSegments.TakeLast(maxSegments).ToList();
|
||||
}
|
||||
|
||||
int startSequence = allSegments
|
||||
.HeadOrNone()
|
||||
.Map(s => s.StartSequence)
|
||||
.IfNone(0);
|
||||
|
||||
// count all discontinuities that were filtered out
|
||||
if (allSegments.Any())
|
||||
{
|
||||
int index = items.IndexOf(allSegments.Head());
|
||||
int count = items.Take(index + 1).OfType<PlaylistDiscontinuity>().Count();
|
||||
discontinuitySequence += count;
|
||||
}
|
||||
|
||||
var output = new StringBuilder();
|
||||
output.AppendLine("#EXTM3U");
|
||||
output.AppendLine("#EXT-X-VERSION:6");
|
||||
output.AppendLine("#EXT-X-TARGETDURATION:4");
|
||||
output.AppendLine($"#EXT-X-MEDIA-SEQUENCE:{startSequence}");
|
||||
output.AppendLine($"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuitySequence}");
|
||||
output.AppendLine("#EXT-X-INDEPENDENT-SEGMENTS");
|
||||
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
switch (items[i])
|
||||
{
|
||||
case PlaylistDiscontinuity:
|
||||
if (i == items.Count - 1 || allSegments.Contains(items[i + 1]))
|
||||
{
|
||||
output.AppendLine("#EXT-X-DISCONTINUITY");
|
||||
}
|
||||
|
||||
break;
|
||||
case PlaylistSegment segment:
|
||||
if (allSegments.Contains(segment))
|
||||
{
|
||||
output.AppendLine(segment.ExtInf);
|
||||
string offset = segment.StartTime.ToString("zzz").Replace(":", string.Empty);
|
||||
output.AppendLine(
|
||||
$"#EXT-X-PROGRAM-DATE-TIME:{segment.StartTime:yyyy-MM-ddTHH:mm:ss.fff}{offset}");
|
||||
output.AppendLine(segment.Line);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var playlist = output.ToString();
|
||||
DateTimeOffset nextPlaylistStart = allSegments.HeadOrNone()
|
||||
.Map(s => s.StartTime)
|
||||
.IfNone(DateTimeOffset.MaxValue);
|
||||
|
||||
return Tuple(playlist, nextPlaylistStart, startSequence, allSegments.Count);
|
||||
}
|
||||
|
||||
private abstract record PlaylistItem;
|
||||
|
||||
private record PlaylistSegment(DateTimeOffset StartTime, string ExtInf, string Line) : PlaylistItem
|
||||
{
|
||||
public int StartSequence => int.Parse(Line.Replace("live", string.Empty).Split('.')[0]);
|
||||
}
|
||||
|
||||
private record PlaylistDiscontinuity : PlaylistItem;
|
||||
}
|
||||
|
||||
public record TrimPlaylistResult(DateTimeOffset PlaylistStart, int Sequence, string Playlist, int SegmentCount);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
public class MusicVideoCreditsGenerator : IMusicVideoCreditsGenerator
|
||||
{
|
||||
private readonly ITempFilePool _tempFilePool;
|
||||
|
||||
public MusicVideoCreditsGenerator(ITempFilePool tempFilePool) => _tempFilePool = tempFilePool;
|
||||
|
||||
public async Task<Option<Subtitle>> GenerateCreditsSubtitle(MusicVideo musicVideo, FFmpegProfile ffmpegProfile)
|
||||
{
|
||||
const int HORIZONTAL_MARGIN_PERCENT = 3;
|
||||
const int VERTICAL_MARGIN_PERCENT = 5;
|
||||
|
||||
var fontSize = (int)Math.Round(ffmpegProfile.Resolution.Height / 20.0);
|
||||
|
||||
int leftMarginPercent = HORIZONTAL_MARGIN_PERCENT;
|
||||
int rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
|
||||
|
||||
var leftMargin = (int)Math.Round(leftMarginPercent / 100.0 * ffmpegProfile.Resolution.Width);
|
||||
var rightMargin = (int)Math.Round(rightMarginPercent / 100.0 * ffmpegProfile.Resolution.Width);
|
||||
var verticalMargin =
|
||||
(int)Math.Round(VERTICAL_MARGIN_PERCENT / 100.0 * ffmpegProfile.Resolution.Height);
|
||||
|
||||
foreach (MusicVideoMetadata metadata in musicVideo.MusicVideoMetadata)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
string artist = string.Empty;
|
||||
foreach (ArtistMetadata artistMetadata in Optional(metadata.MusicVideo?.Artist?.ArtistMetadata).Flatten())
|
||||
{
|
||||
artist = artistMetadata.Title;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(artist))
|
||||
{
|
||||
sb.Append(artist);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Title))
|
||||
{
|
||||
sb.Append($"\\N\"{metadata.Title}\"");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Album))
|
||||
{
|
||||
sb.Append($"\\N{metadata.Album}");
|
||||
}
|
||||
|
||||
string subtitles = await new SubtitleBuilder(_tempFilePool)
|
||||
.WithResolution(ffmpegProfile.Resolution)
|
||||
.WithFontName("OPTIKabel-Heavy")
|
||||
.WithFontSize(fontSize)
|
||||
.WithPrimaryColor("&HFFFFFF")
|
||||
.WithOutlineColor("&H444444")
|
||||
.WithAlignment(0)
|
||||
.WithMarginRight(rightMargin)
|
||||
.WithMarginLeft(leftMargin)
|
||||
.WithMarginV(verticalMargin)
|
||||
.WithBorderStyle(1)
|
||||
.WithShadow(3)
|
||||
.WithFormattedContent(sb.ToString())
|
||||
.WithStartEnd(TimeSpan.FromSeconds(9), TimeSpan.FromSeconds(16))
|
||||
.WithFade(true)
|
||||
.BuildFile();
|
||||
|
||||
return new Subtitle
|
||||
{
|
||||
Codec = "ass",
|
||||
Default = true,
|
||||
Forced = true,
|
||||
IsExtracted = false,
|
||||
SubtitleKind = SubtitleKind.Generated,
|
||||
Path = subtitles,
|
||||
SDH = false
|
||||
};
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ public class SubtitleBuilder
|
||||
private Option<int> _alignment;
|
||||
private Option<int> _borderStyle;
|
||||
private string _content;
|
||||
private Option<TimeSpan> _end;
|
||||
private bool _fade;
|
||||
private Option<string> _fontName;
|
||||
private Option<int> _fontSize;
|
||||
private int _marginLeft;
|
||||
@@ -18,6 +20,7 @@ public class SubtitleBuilder
|
||||
private Option<string> _primaryColor;
|
||||
private Option<IDisplaySize> _resolution = None;
|
||||
private Option<int> _shadow;
|
||||
private Option<TimeSpan> _start;
|
||||
|
||||
public SubtitleBuilder(ITempFilePool tempFilePool) => _tempFilePool = tempFilePool;
|
||||
|
||||
@@ -93,6 +96,19 @@ public class SubtitleBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
public SubtitleBuilder WithStartEnd(TimeSpan start, TimeSpan end)
|
||||
{
|
||||
_start = start;
|
||||
_end = end;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SubtitleBuilder WithFade(bool fade)
|
||||
{
|
||||
_fade = fade;
|
||||
return this;
|
||||
}
|
||||
|
||||
public async Task<string> BuildFile()
|
||||
{
|
||||
string fileName = _tempFilePool.GetNextTempFile(TempFileCategory.Subtitle);
|
||||
@@ -116,15 +132,24 @@ public class SubtitleBuilder
|
||||
sb.AppendLine(
|
||||
$"Style: Default,{await _fontName.IfNoneAsync("")},{await _fontSize.IfNoneAsync(32)},{await _primaryColor.IfNoneAsync("")},{await _outlineColor.IfNoneAsync("")},{await _borderStyle.IfNoneAsync(0)},1,{await _shadow.IfNoneAsync(0)},{await _alignment.IfNoneAsync(0)},1");
|
||||
|
||||
var start = "0:00:00.00";
|
||||
foreach (TimeSpan startTime in _start)
|
||||
{
|
||||
start = $"{(int)startTime.TotalHours:00}:{startTime.ToString(@"mm\:ss\.ff")}";
|
||||
}
|
||||
|
||||
var end = "99:99:99.99";
|
||||
foreach (TimeSpan endTime in _end)
|
||||
{
|
||||
end = $"{(int)endTime.TotalHours:00}:{endTime.ToString(@"mm\:ss\.ff")}";
|
||||
}
|
||||
|
||||
string fade = _fade ? @"{\fad(1200, 1200)}" : string.Empty;
|
||||
|
||||
sb.AppendLine("[Events]");
|
||||
sb.AppendLine("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text");
|
||||
sb.AppendLine(
|
||||
$"Dialogue: 0,0:00:00.00,99:99:99.99,Default,,{_marginLeft},{_marginRight},{_marginV},,{_content}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_content))
|
||||
{
|
||||
sb.AppendLine(_content);
|
||||
}
|
||||
@$"Dialogue: 0,{start},{end},Default,,{_marginLeft},{_marginRight},{_marginV},,{fade}{_content}");
|
||||
|
||||
await File.WriteAllTextAsync(fileName, sb.ToString());
|
||||
|
||||
|
||||
@@ -8,33 +8,30 @@ public interface IEmbyApiClient
|
||||
Task<Either<BaseError, EmbyServerInformation>> GetServerInformation(string address, string apiKey);
|
||||
Task<Either<BaseError, List<EmbyLibrary>>> GetLibraries(string address, string apiKey);
|
||||
|
||||
Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library);
|
||||
IAsyncEnumerable<EmbyMovie> GetMovieLibraryItems(string address, string apiKey, EmbyLibrary library);
|
||||
|
||||
Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
string libraryId);
|
||||
IAsyncEnumerable<EmbyShow> GetShowLibraryItems(string address, string apiKey, EmbyLibrary library);
|
||||
|
||||
Task<Either<BaseError, List<EmbySeason>>> GetSeasonLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
string showId);
|
||||
|
||||
Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems(
|
||||
IAsyncEnumerable<EmbySeason> GetSeasonLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string seasonId);
|
||||
string showId);
|
||||
|
||||
Task<Either<BaseError, List<EmbyCollection>>> GetCollectionLibraryItems(
|
||||
string address,
|
||||
string apiKey);
|
||||
|
||||
Task<Either<BaseError, List<MediaItem>>> GetCollectionItems(
|
||||
IAsyncEnumerable<EmbyEpisode> GetEpisodeLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
string collectionId);
|
||||
EmbyLibrary library,
|
||||
string showId,
|
||||
string seasonId);
|
||||
|
||||
IAsyncEnumerable<EmbyCollection> GetCollectionLibraryItems(string address, string apiKey);
|
||||
|
||||
IAsyncEnumerable<MediaItem> GetCollectionItems(string address, string apiKey, string collectionId);
|
||||
|
||||
Task<Either<BaseError, int>> GetLibraryItemCount(
|
||||
string address,
|
||||
string apiKey,
|
||||
string parentId,
|
||||
string includeItemTypes);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,8 @@ public interface IFFmpegStreamSelector
|
||||
string preferredAudioLanguage);
|
||||
|
||||
Task<Option<Subtitle>> SelectSubtitleStream(
|
||||
MediaVersion version,
|
||||
List<Subtitle> subtitles,
|
||||
StreamingMode streamingMode,
|
||||
string channelNumber,
|
||||
Channel channel,
|
||||
string preferredSubtitleLanguage,
|
||||
ChannelSubtitleMode subtitleMode);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
|
||||
public interface IMusicVideoCreditsGenerator
|
||||
{
|
||||
Task<Option<Subtitle>> GenerateCreditsSubtitle(MusicVideo musicVideo, FFmpegProfile ffmpegProfile);
|
||||
}
|
||||
@@ -9,37 +9,35 @@ public interface IJellyfinApiClient
|
||||
Task<Either<BaseError, List<JellyfinLibrary>>> GetLibraries(string address, string apiKey);
|
||||
Task<Either<BaseError, string>> GetAdminUserId(string address, string apiKey);
|
||||
|
||||
Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library);
|
||||
IAsyncEnumerable<JellyfinMovie> GetMovieLibraryItems(string address, string apiKey, JellyfinLibrary library);
|
||||
|
||||
Task<Either<BaseError, List<JellyfinShow>>> GetShowLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
int mediaSourceId,
|
||||
string libraryId);
|
||||
IAsyncEnumerable<JellyfinShow> GetShowLibraryItems(string address, string apiKey, JellyfinLibrary library);
|
||||
|
||||
Task<Either<BaseError, List<JellyfinSeason>>> GetSeasonLibraryItems(
|
||||
IAsyncEnumerable<JellyfinSeason> GetSeasonLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
int mediaSourceId,
|
||||
JellyfinLibrary library,
|
||||
string showId);
|
||||
|
||||
Task<Either<BaseError, List<JellyfinEpisode>>> GetEpisodeLibraryItems(
|
||||
IAsyncEnumerable<JellyfinEpisode> GetEpisodeLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string seasonId);
|
||||
|
||||
Task<Either<BaseError, List<JellyfinCollection>>> GetCollectionLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
int mediaSourceId);
|
||||
IAsyncEnumerable<JellyfinCollection> GetCollectionLibraryItems(string address, string apiKey, int mediaSourceId);
|
||||
|
||||
Task<Either<BaseError, List<MediaItem>>> GetCollectionItems(
|
||||
IAsyncEnumerable<MediaItem> GetCollectionItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
int mediaSourceId,
|
||||
string collectionId);
|
||||
|
||||
Task<Either<BaseError, int>> GetLibraryItemCount(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string parentId,
|
||||
string includeItemTypes,
|
||||
bool excludeFolders);
|
||||
}
|
||||
|
||||
@@ -13,23 +13,33 @@ public interface IPlexServerApiClient
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
Task<Either<BaseError, List<PlexMovie>>> GetMovieLibraryContents(
|
||||
IAsyncEnumerable<PlexMovie> GetMovieLibraryContents(
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
Task<Either<BaseError, List<PlexShow>>> GetShowLibraryContents(
|
||||
IAsyncEnumerable<PlexShow> GetShowLibraryContents(
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
Task<Either<BaseError, List<PlexSeason>>> GetShowSeasons(
|
||||
Task<Either<BaseError, int>> CountShowSeasons(
|
||||
PlexShow show,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
IAsyncEnumerable<PlexSeason> GetShowSeasons(
|
||||
PlexLibrary library,
|
||||
PlexShow show,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
Task<Either<BaseError, List<PlexEpisode>>> GetSeasonEpisodes(
|
||||
Task<Either<BaseError, int>> CountSeasonEpisodes(
|
||||
PlexSeason season,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
IAsyncEnumerable<PlexEpisode> GetSeasonEpisodes(
|
||||
PlexLibrary library,
|
||||
PlexSeason season,
|
||||
PlexConnection connection,
|
||||
@@ -58,4 +68,9 @@ public interface IPlexServerApiClient
|
||||
string key,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
Task<Either<BaseError, int>> GetLibraryItemCount(
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ public interface IMediaServerTelevisionRepository<in TLibrary, TShow, TSeason, T
|
||||
Task<Unit> SetEtag(TSeason season, string etag);
|
||||
Task<Unit> SetEtag(TEpisode episode, string etag);
|
||||
Task<bool> FlagNormal(TLibrary library, TEpisode episode);
|
||||
Task<bool> FlagNormal(TLibrary library, TSeason season);
|
||||
Task<bool> FlagNormal(TLibrary library, TShow show);
|
||||
Task<List<int>> FlagFileNotFoundShows(TLibrary library, List<string> showItemIds);
|
||||
Task<List<int>> FlagFileNotFoundSeasons(TLibrary library, List<string> seasonItemIds);
|
||||
Task<List<int>> FlagFileNotFoundEpisodes(TLibrary library, List<string> episodeItemIds);
|
||||
|
||||
@@ -30,24 +30,21 @@ public class JellyfinCollectionScanner : IJellyfinCollectionScanner
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanCollections(string address, string apiKey, int mediaSourceId)
|
||||
{
|
||||
// get all collections from db (item id, etag)
|
||||
List<JellyfinCollection> existingCollections = await _jellyfinCollectionRepository.GetCollections();
|
||||
|
||||
// get all collections from jellyfin
|
||||
Either<BaseError, List<JellyfinCollection>> maybeIncomingCollections =
|
||||
await _jellyfinApiClient.GetCollectionLibraryItems(address, apiKey, mediaSourceId);
|
||||
|
||||
foreach (BaseError error in maybeIncomingCollections.LeftToSeq())
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Failed to get collections from Jellyfin: {Error}", error.ToString());
|
||||
return error;
|
||||
}
|
||||
var incomingItemIds = new List<string>();
|
||||
|
||||
// get all collections from db (item id, etag)
|
||||
List<JellyfinCollection> existingCollections = await _jellyfinCollectionRepository.GetCollections();
|
||||
|
||||
foreach (List<JellyfinCollection> incomingCollections in maybeIncomingCollections.RightToSeq())
|
||||
{
|
||||
// loop over collections
|
||||
foreach (JellyfinCollection collection in incomingCollections)
|
||||
await foreach (JellyfinCollection collection in _jellyfinApiClient.GetCollectionLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
mediaSourceId))
|
||||
{
|
||||
incomingItemIds.Add(collection.ItemId);
|
||||
|
||||
Option<JellyfinCollection> maybeExisting = existingCollections.Find(c => c.ItemId == collection.ItemId);
|
||||
|
||||
// skip if unchanged (etag)
|
||||
@@ -75,12 +72,17 @@ public class JellyfinCollectionScanner : IJellyfinCollectionScanner
|
||||
}
|
||||
|
||||
// remove missing collections (and remove any lingering tags from those collections)
|
||||
foreach (JellyfinCollection collection in existingCollections
|
||||
.Filter(e => incomingCollections.All(i => i.ItemId != e.ItemId)))
|
||||
foreach (JellyfinCollection collection in existingCollections.Filter(
|
||||
e => !incomingItemIds.Contains(e.ItemId)))
|
||||
{
|
||||
await _jellyfinCollectionRepository.RemoveCollection(collection);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get collections from Jellyfin");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
@@ -91,32 +93,35 @@ public class JellyfinCollectionScanner : IJellyfinCollectionScanner
|
||||
int mediaSourceId,
|
||||
JellyfinCollection collection)
|
||||
{
|
||||
// get collection items from JF
|
||||
Either<BaseError, List<MediaItem>> maybeItems =
|
||||
await _jellyfinApiClient.GetCollectionItems(address, apiKey, mediaSourceId, collection.ItemId);
|
||||
|
||||
foreach (BaseError error in maybeItems.LeftToSeq())
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Failed to get collection items from Jellyfin: {Error}", error.ToString());
|
||||
return;
|
||||
// get collection items from JF
|
||||
IAsyncEnumerable<MediaItem> items = _jellyfinApiClient.GetCollectionItems(
|
||||
address,
|
||||
apiKey,
|
||||
mediaSourceId,
|
||||
collection.ItemId);
|
||||
|
||||
List<int> removedIds = await _jellyfinCollectionRepository.RemoveAllTags(collection);
|
||||
|
||||
// sync tags on items
|
||||
var addedIds = new List<int>();
|
||||
await foreach (MediaItem item in items)
|
||||
{
|
||||
addedIds.Add(await _jellyfinCollectionRepository.AddTag(item, collection));
|
||||
}
|
||||
|
||||
_logger.LogDebug("Jellyfin collection {Name} contains {Count} items", collection.Name, addedIds.Count);
|
||||
|
||||
var changedIds = removedIds.Except(addedIds).ToList();
|
||||
changedIds.AddRange(addedIds.Except(removedIds));
|
||||
|
||||
await _searchIndex.RebuildItems(_searchRepository, changedIds);
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
|
||||
List<int> removedIds = await _jellyfinCollectionRepository.RemoveAllTags(collection);
|
||||
|
||||
var jellyfinItems = maybeItems.RightToSeq().Flatten().ToList();
|
||||
_logger.LogDebug("Jellyfin collection {Name} contains {Count} items", collection.Name, jellyfinItems.Count);
|
||||
|
||||
// sync tags on items
|
||||
var addedIds = new List<int>();
|
||||
foreach (MediaItem item in jellyfinItems)
|
||||
catch (Exception ex)
|
||||
{
|
||||
addedIds.Add(await _jellyfinCollectionRepository.AddTag(item, collection));
|
||||
_logger.LogWarning(ex, "Failed to synchronize Jellyfin collection {Name}", collection.Name);
|
||||
}
|
||||
|
||||
var changedIds = removedIds.Except(addedIds).ToList();
|
||||
changedIds.AddRange(addedIds.Except(removedIds));
|
||||
|
||||
await _searchIndex.RebuildItems(_searchRepository, changedIds);
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ErsatzTV.Core.Jellyfin;
|
||||
|
||||
public static class JellyfinItemType
|
||||
{
|
||||
public static readonly string Movie = "Movie";
|
||||
public static readonly string Show = "Series";
|
||||
public static readonly string Season = "Season";
|
||||
public static readonly string Episode = "Episode";
|
||||
public static readonly string Collection = "BoxSet";
|
||||
public static readonly string CollectionItems = "Movie,Series,Season,Episode";
|
||||
}
|
||||
@@ -80,7 +80,18 @@ public class JellyfinMovieLibraryScanner :
|
||||
|
||||
protected override string MediaServerEtag(JellyfinMovie movie) => movie.Etag;
|
||||
|
||||
protected override Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems(
|
||||
protected override Task<Either<BaseError, int>> CountMovieLibraryItems(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library) =>
|
||||
_jellyfinApiClient.GetLibraryItemCount(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library,
|
||||
library.ItemId,
|
||||
JellyfinItemType.Movie,
|
||||
true);
|
||||
|
||||
protected override IAsyncEnumerable<JellyfinMovie> GetMovieLibraryItems(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library) =>
|
||||
_jellyfinApiClient.GetMovieLibraryItems(
|
||||
|
||||
@@ -77,14 +77,21 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
protected override Task<Either<BaseError, List<JellyfinShow>>> GetShowLibraryItems(
|
||||
protected override Task<Either<BaseError, int>> CountShowLibraryItems(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library) =>
|
||||
_jellyfinApiClient.GetShowLibraryItems(
|
||||
JellyfinLibrary library)
|
||||
=> _jellyfinApiClient.GetLibraryItemCount(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library.MediaSourceId,
|
||||
library.ItemId);
|
||||
library,
|
||||
library.ItemId,
|
||||
JellyfinItemType.Show,
|
||||
false);
|
||||
|
||||
protected override IAsyncEnumerable<JellyfinShow> GetShowLibraryItems(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library) =>
|
||||
_jellyfinApiClient.GetShowLibraryItems(connectionParameters.Address, connectionParameters.ApiKey, library);
|
||||
|
||||
protected override string MediaServerItemId(JellyfinShow show) => show.ItemId;
|
||||
protected override string MediaServerItemId(JellyfinSeason season) => season.ItemId;
|
||||
@@ -94,19 +101,44 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
|
||||
protected override string MediaServerEtag(JellyfinSeason season) => season.Etag;
|
||||
protected override string MediaServerEtag(JellyfinEpisode episode) => episode.Etag;
|
||||
|
||||
protected override Task<Either<BaseError, List<JellyfinSeason>>> GetSeasonLibraryItems(
|
||||
protected override Task<Either<BaseError, int>> CountSeasonLibraryItems(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
JellyfinShow show) =>
|
||||
_jellyfinApiClient.GetLibraryItemCount(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library,
|
||||
show.ItemId,
|
||||
JellyfinItemType.Season,
|
||||
false);
|
||||
|
||||
protected override IAsyncEnumerable<JellyfinSeason> GetSeasonLibraryItems(
|
||||
JellyfinLibrary library,
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinShow show) =>
|
||||
_jellyfinApiClient.GetSeasonLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library.MediaSourceId,
|
||||
library,
|
||||
show.ItemId);
|
||||
|
||||
protected override Task<Either<BaseError, List<JellyfinEpisode>>> GetEpisodeLibraryItems(
|
||||
protected override Task<Either<BaseError, int>> CountEpisodeLibraryItems(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
JellyfinSeason season) =>
|
||||
_jellyfinApiClient.GetLibraryItemCount(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library,
|
||||
season.ItemId,
|
||||
JellyfinItemType.Episode,
|
||||
true);
|
||||
|
||||
protected override IAsyncEnumerable<JellyfinEpisode> GetEpisodeLibraryItems(
|
||||
JellyfinLibrary library,
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinShow _,
|
||||
JellyfinSeason season) =>
|
||||
_jellyfinApiClient.GetEpisodeLibraryItems(
|
||||
connectionParameters.Address,
|
||||
|
||||
@@ -53,23 +53,29 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
{
|
||||
try
|
||||
{
|
||||
Either<BaseError, List<TMovie>> entries = await GetMovieLibraryItems(connectionParameters, library);
|
||||
|
||||
foreach (BaseError error in entries.LeftToSeq())
|
||||
Either<BaseError, int> maybeCount = await CountMovieLibraryItems(connectionParameters, library);
|
||||
foreach (BaseError error in maybeCount.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
return await ScanLibrary(
|
||||
movieRepository,
|
||||
connectionParameters,
|
||||
library,
|
||||
getLocalPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
entries.RightToSeq().Flatten().ToList(),
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
foreach (int count in maybeCount.RightToSeq())
|
||||
{
|
||||
return await ScanLibrary(
|
||||
movieRepository,
|
||||
connectionParameters,
|
||||
library,
|
||||
getLocalPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
GetMovieLibraryItems(connectionParameters, library),
|
||||
count,
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// this won't happen
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
@@ -88,21 +94,24 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
Func<TMovie, string> getLocalPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<TMovie> movieEntries,
|
||||
IAsyncEnumerable<TMovie> movieEntries,
|
||||
int totalMovieCount,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var incomingItemIds = new List<string>();
|
||||
List<TEtag> existingMovies = await movieRepository.GetExistingMovies(library);
|
||||
|
||||
var sortedMovies = movieEntries.OrderBy(m => m.MovieMetadata.Head().SortTitle).ToList();
|
||||
foreach (TMovie incoming in sortedMovies)
|
||||
await foreach (TMovie incoming in movieEntries.WithCancellation(cancellationToken))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
decimal percentCompletion = (decimal)sortedMovies.IndexOf(incoming) / sortedMovies.Count;
|
||||
incomingItemIds.Add(MediaServerItemId(incoming));
|
||||
|
||||
decimal percentCompletion = Math.Clamp((decimal)incomingItemIds.Count / totalMovieCount, 0, 1);
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
|
||||
|
||||
string localPath = getLocalPath(incoming);
|
||||
@@ -165,8 +174,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
}
|
||||
|
||||
// trash movies that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingMovies.Map(m => m.MediaServerItemId)
|
||||
.Except(movieEntries.Map(MediaServerItemId)).ToList();
|
||||
var fileNotFoundItemIds = existingMovies.Map(m => m.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> ids = await movieRepository.FlagFileNotFound(library, fileNotFoundItemIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
|
||||
@@ -178,7 +186,11 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
protected abstract string MediaServerItemId(TMovie movie);
|
||||
protected abstract string MediaServerEtag(TMovie movie);
|
||||
|
||||
protected abstract Task<Either<BaseError, List<TMovie>>> GetMovieLibraryItems(
|
||||
protected abstract Task<Either<BaseError, int>> CountMovieLibraryItems(
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library);
|
||||
|
||||
protected abstract IAsyncEnumerable<TMovie> GetMovieLibraryItems(
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library);
|
||||
|
||||
@@ -212,9 +224,10 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
|
||||
string existingEtag = await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty);
|
||||
MediaItemState existingState = await maybeExisting.Map(e => e.State).IfNoneAsync(MediaItemState.Normal);
|
||||
|
||||
if (existingState == MediaItemState.Unavailable && existingEtag == MediaServerEtag(incoming))
|
||||
if (existingState is MediaItemState.Unavailable or MediaItemState.FileNotFound &&
|
||||
existingEtag == MediaServerEtag(incoming))
|
||||
{
|
||||
// skip scanning unavailable items that are unchanged and still don't exist locally
|
||||
// skip scanning unavailable/file not found items that are unchanged and still don't exist locally
|
||||
if (!_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
return false;
|
||||
|
||||
@@ -56,23 +56,31 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
{
|
||||
try
|
||||
{
|
||||
Either<BaseError, List<TShow>> entries = await GetShowLibraryItems(connectionParameters, library);
|
||||
|
||||
foreach (BaseError error in entries.LeftToSeq())
|
||||
Either<BaseError, int> maybeCount = await CountShowLibraryItems(connectionParameters, library);
|
||||
foreach (BaseError error in maybeCount.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
return await ScanLibrary(
|
||||
televisionRepository,
|
||||
connectionParameters,
|
||||
library,
|
||||
getLocalPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
entries.RightToSeq().Flatten().ToList(),
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
foreach (int count in maybeCount.RightToSeq())
|
||||
{
|
||||
_logger.LogDebug("Library {Library} contains {Count} shows", library.Name, count);
|
||||
|
||||
return await ScanLibrary(
|
||||
televisionRepository,
|
||||
connectionParameters,
|
||||
library,
|
||||
getLocalPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
GetShowLibraryItems(connectionParameters, library),
|
||||
count,
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// this won't happen
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
@@ -84,7 +92,11 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract Task<Either<BaseError, List<TShow>>> GetShowLibraryItems(
|
||||
protected abstract Task<Either<BaseError, int>> CountShowLibraryItems(
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library);
|
||||
|
||||
protected abstract IAsyncEnumerable<TShow> GetShowLibraryItems(
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library);
|
||||
|
||||
@@ -102,21 +114,24 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
Func<TEpisode, string> getLocalPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<TShow> showEntries,
|
||||
IAsyncEnumerable<TShow> showEntries,
|
||||
int totalShowCount,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var incomingItemIds = new List<string>();
|
||||
List<TEtag> existingShows = await televisionRepository.GetExistingShows(library);
|
||||
|
||||
var sortedShows = showEntries.OrderBy(s => s.ShowMetadata.Head().SortTitle).ToList();
|
||||
foreach (TShow incoming in showEntries)
|
||||
await foreach (TShow incoming in showEntries.WithCancellation(cancellationToken))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / sortedShows.Count;
|
||||
incomingItemIds.Add(MediaServerItemId(incoming));
|
||||
|
||||
decimal percentCompletion = Math.Clamp((decimal)incomingItemIds.Count / totalShowCount, 0, 1);
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
|
||||
|
||||
Either<BaseError, MediaItemScanResult<TShow>> maybeShow = await televisionRepository
|
||||
@@ -138,16 +153,23 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
|
||||
foreach (MediaItemScanResult<TShow> result in maybeShow.RightToSeq())
|
||||
{
|
||||
Either<BaseError, List<TSeason>> entries = await GetSeasonLibraryItems(
|
||||
library,
|
||||
Either<BaseError, int> maybeCount = await CountSeasonLibraryItems(
|
||||
connectionParameters,
|
||||
library,
|
||||
result.Item);
|
||||
|
||||
foreach (BaseError error in entries.LeftToSeq())
|
||||
foreach (BaseError error in maybeCount.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
foreach (int count in maybeCount.RightToSeq())
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Show {Title} contains {Count} seasons",
|
||||
result.Item.ShowMetadata.Head().Title,
|
||||
count);
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> scanResult = await ScanSeasons(
|
||||
televisionRepository,
|
||||
library,
|
||||
@@ -156,7 +178,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
connectionParameters,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
entries.RightToSeq().Flatten().ToList(),
|
||||
GetSeasonLibraryItems(library, connectionParameters, result.Item),
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
|
||||
@@ -167,6 +189,11 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
|
||||
await televisionRepository.SetEtag(result.Item, MediaServerEtag(incoming));
|
||||
|
||||
if (await televisionRepository.FlagNormal(library, result.Item))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
@@ -175,8 +202,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
}
|
||||
|
||||
// trash shows that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingShows.Map(s => s.MediaServerItemId)
|
||||
.Except(showEntries.Map(MediaServerItemId)).ToList();
|
||||
var fileNotFoundItemIds = existingShows.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> ids = await televisionRepository.FlagFileNotFoundShows(library, fileNotFoundItemIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
|
||||
@@ -185,14 +211,25 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
protected abstract Task<Either<BaseError, List<TSeason>>> GetSeasonLibraryItems(
|
||||
protected abstract Task<Either<BaseError, int>> CountSeasonLibraryItems(
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library,
|
||||
TShow show);
|
||||
|
||||
protected abstract IAsyncEnumerable<TSeason> GetSeasonLibraryItems(
|
||||
TLibrary library,
|
||||
TConnectionParameters connectionParameters,
|
||||
TShow show);
|
||||
|
||||
protected abstract Task<Either<BaseError, List<TEpisode>>> GetEpisodeLibraryItems(
|
||||
protected abstract Task<Either<BaseError, int>> CountEpisodeLibraryItems(
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library,
|
||||
TSeason season);
|
||||
|
||||
protected abstract IAsyncEnumerable<TEpisode> GetEpisodeLibraryItems(
|
||||
TLibrary library,
|
||||
TConnectionParameters connectionParameters,
|
||||
TShow show,
|
||||
TSeason season);
|
||||
|
||||
protected abstract Task<Option<ShowMetadata>> GetFullMetadata(
|
||||
@@ -236,14 +273,14 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
TConnectionParameters connectionParameters,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<TSeason> seasonEntries,
|
||||
IAsyncEnumerable<TSeason> seasonEntries,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var incomingItemIds = new List<string>();
|
||||
List<TEtag> existingSeasons = await televisionRepository.GetExistingSeasons(library, show);
|
||||
|
||||
var sortedSeasons = seasonEntries.OrderBy(s => s.SeasonNumber).ToList();
|
||||
foreach (TSeason incoming in sortedSeasons)
|
||||
await foreach (TSeason incoming in seasonEntries.WithCancellation(cancellationToken))
|
||||
{
|
||||
incoming.ShowId = show.Id;
|
||||
|
||||
@@ -252,6 +289,8 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
incomingItemIds.Add(MediaServerItemId(incoming));
|
||||
|
||||
Either<BaseError, MediaItemScanResult<TSeason>> maybeSeason = await televisionRepository
|
||||
.GetOrAdd(library, incoming)
|
||||
.BindT(existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan));
|
||||
@@ -272,16 +311,24 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
|
||||
foreach (MediaItemScanResult<TSeason> result in maybeSeason.RightToSeq())
|
||||
{
|
||||
Either<BaseError, List<TEpisode>> entries = await GetEpisodeLibraryItems(
|
||||
library,
|
||||
Either<BaseError, int> maybeCount = await CountEpisodeLibraryItems(
|
||||
connectionParameters,
|
||||
library,
|
||||
result.Item);
|
||||
|
||||
foreach (BaseError error in entries.LeftToSeq())
|
||||
foreach (BaseError error in maybeCount.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
foreach (int count in maybeCount.RightToSeq())
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Show {Title} season {Season} contains {Count} episodes",
|
||||
show.ShowMetadata.Head().Title,
|
||||
result.Item.SeasonNumber,
|
||||
count);
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> scanResult = await ScanEpisodes(
|
||||
televisionRepository,
|
||||
library,
|
||||
@@ -291,7 +338,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
connectionParameters,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
entries.RightToSeq().Flatten().ToList(),
|
||||
GetEpisodeLibraryItems(library, connectionParameters, show, result.Item),
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
|
||||
@@ -302,6 +349,11 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
|
||||
await televisionRepository.SetEtag(result.Item, MediaServerEtag(incoming));
|
||||
|
||||
if (await televisionRepository.FlagNormal(library, result.Item))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
|
||||
result.Item.Show = show;
|
||||
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
@@ -312,8 +364,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
}
|
||||
|
||||
// trash seasons that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingSeasons.Map(s => s.MediaServerItemId)
|
||||
.Except(seasonEntries.Map(MediaServerItemId)).ToList();
|
||||
var fileNotFoundItemIds = existingSeasons.Map(s => s.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> ids = await televisionRepository.FlagFileNotFoundSeasons(library, fileNotFoundItemIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
|
||||
@@ -329,20 +380,22 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
TConnectionParameters connectionParameters,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<TEpisode> episodeEntries,
|
||||
IAsyncEnumerable<TEpisode> episodeEntries,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var incomingItemIds = new List<string>();
|
||||
List<TEtag> existingEpisodes = await televisionRepository.GetExistingEpisodes(library, season);
|
||||
|
||||
var sortedEpisodes = episodeEntries.OrderBy(s => s.EpisodeMetadata.Head().EpisodeNumber).ToList();
|
||||
foreach (TEpisode incoming in sortedEpisodes)
|
||||
await foreach (TEpisode incoming in episodeEntries.WithCancellation(cancellationToken))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
incomingItemIds.Add(MediaServerItemId(incoming));
|
||||
|
||||
string localPath = getLocalPath(incoming);
|
||||
if (await ShouldScanItem(
|
||||
televisionRepository,
|
||||
@@ -414,8 +467,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
}
|
||||
|
||||
// trash episodes that are no longer present on the media server
|
||||
var fileNotFoundItemIds = existingEpisodes.Map(m => m.MediaServerItemId)
|
||||
.Except(episodeEntries.Map(MediaServerItemId)).ToList();
|
||||
var fileNotFoundItemIds = existingEpisodes.Map(m => m.MediaServerItemId).Except(incomingItemIds).ToList();
|
||||
List<int> ids = await televisionRepository.FlagFileNotFoundEpisodes(library, fileNotFoundItemIds);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
|
||||
@@ -442,9 +494,10 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
|
||||
string existingEtag = await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty);
|
||||
MediaItemState existingState = await maybeExisting.Map(e => e.State).IfNoneAsync(MediaItemState.Normal);
|
||||
|
||||
if (existingState == MediaItemState.Unavailable && existingEtag == MediaServerEtag(incoming))
|
||||
if (existingState is MediaItemState.Unavailable or MediaItemState.FileNotFound &&
|
||||
existingEtag == MediaServerEtag(incoming))
|
||||
{
|
||||
// skip scanning unavailable items that are unchanged and still don't exist locally
|
||||
// skip scanning unavailable/file not found items that are unchanged and still don't exist locally
|
||||
if (!_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
return false;
|
||||
|
||||
@@ -89,7 +89,15 @@ public class PlexMovieLibraryScanner :
|
||||
|
||||
protected override string MediaServerEtag(PlexMovie movie) => movie.Etag;
|
||||
|
||||
protected override Task<Either<BaseError, List<PlexMovie>>> GetMovieLibraryItems(
|
||||
protected override Task<Either<BaseError, int>> CountMovieLibraryItems(
|
||||
PlexConnectionParameters connectionParameters,
|
||||
PlexLibrary library)
|
||||
=> _plexServerApiClient.GetLibraryItemCount(
|
||||
library,
|
||||
connectionParameters.Connection,
|
||||
connectionParameters.Token);
|
||||
|
||||
protected override IAsyncEnumerable<PlexMovie> GetMovieLibraryItems(
|
||||
PlexConnectionParameters connectionParameters,
|
||||
PlexLibrary library) =>
|
||||
_plexServerApiClient.GetMovieLibraryContents(
|
||||
|
||||
@@ -137,7 +137,15 @@ public class PlexTelevisionLibraryScanner :
|
||||
// }
|
||||
// }
|
||||
|
||||
protected override Task<Either<BaseError, List<PlexShow>>> GetShowLibraryItems(
|
||||
protected override Task<Either<BaseError, int>> CountShowLibraryItems(
|
||||
PlexConnectionParameters connectionParameters,
|
||||
PlexLibrary library) =>
|
||||
_plexServerApiClient.GetLibraryItemCount(
|
||||
library,
|
||||
connectionParameters.Connection,
|
||||
connectionParameters.Token);
|
||||
|
||||
protected override IAsyncEnumerable<PlexShow> GetShowLibraryItems(
|
||||
PlexConnectionParameters connectionParameters,
|
||||
PlexLibrary library) =>
|
||||
_plexServerApiClient.GetShowLibraryContents(
|
||||
@@ -145,7 +153,16 @@ public class PlexTelevisionLibraryScanner :
|
||||
connectionParameters.Connection,
|
||||
connectionParameters.Token);
|
||||
|
||||
protected override Task<Either<BaseError, List<PlexSeason>>> GetSeasonLibraryItems(
|
||||
protected override Task<Either<BaseError, int>> CountSeasonLibraryItems(
|
||||
PlexConnectionParameters connectionParameters,
|
||||
PlexLibrary library,
|
||||
PlexShow show) =>
|
||||
_plexServerApiClient.CountShowSeasons(
|
||||
show,
|
||||
connectionParameters.Connection,
|
||||
connectionParameters.Token);
|
||||
|
||||
protected override IAsyncEnumerable<PlexSeason> GetSeasonLibraryItems(
|
||||
PlexLibrary library,
|
||||
PlexConnectionParameters connectionParameters,
|
||||
PlexShow show) =>
|
||||
@@ -155,9 +172,19 @@ public class PlexTelevisionLibraryScanner :
|
||||
connectionParameters.Connection,
|
||||
connectionParameters.Token);
|
||||
|
||||
protected override Task<Either<BaseError, List<PlexEpisode>>> GetEpisodeLibraryItems(
|
||||
protected override Task<Either<BaseError, int>> CountEpisodeLibraryItems(
|
||||
PlexConnectionParameters connectionParameters,
|
||||
PlexLibrary library,
|
||||
PlexSeason season) =>
|
||||
_plexServerApiClient.CountSeasonEpisodes(
|
||||
season,
|
||||
connectionParameters.Connection,
|
||||
connectionParameters.Token);
|
||||
|
||||
protected override IAsyncEnumerable<PlexEpisode> GetEpisodeLibraryItems(
|
||||
PlexLibrary library,
|
||||
PlexConnectionParameters connectionParameters,
|
||||
PlexShow _,
|
||||
PlexSeason season) =>
|
||||
_plexServerApiClient.GetSeasonEpisodes(
|
||||
library,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.6.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.7.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
|
||||
<PackageReference Include="Moq" Version="4.18.1" />
|
||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.FFmpeg.Filter;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using FluentAssertions;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Tests.Filter;
|
||||
|
||||
[TestFixture]
|
||||
public class WatermarkOpacityFilterTests
|
||||
{
|
||||
[Test]
|
||||
// this needs to be a culture where ',' is a decimal separator
|
||||
[SetCulture("it-IT")]
|
||||
public void Should_Return_Filter_With_Period_Decimal_Unlike_Local_Culture()
|
||||
{
|
||||
var filter = new WatermarkOpacityFilter(
|
||||
new WatermarkState(
|
||||
Option<List<WatermarkFadePoint>>.None,
|
||||
WatermarkLocation.BottomRight,
|
||||
WatermarkSize.ActualSize,
|
||||
50,
|
||||
50,
|
||||
50,
|
||||
75));
|
||||
|
||||
filter.Filter.Should().Be("colorchannelmixer=aa=0.75");
|
||||
}
|
||||
|
||||
[Test]
|
||||
[SetCulture("en-US")]
|
||||
public void Should_Return_Filter_With_Period_Decimal()
|
||||
{
|
||||
var filter = new WatermarkOpacityFilter(
|
||||
new WatermarkState(
|
||||
Option<List<WatermarkFadePoint>>.None,
|
||||
WatermarkLocation.BottomRight,
|
||||
WatermarkSize.ActualSize,
|
||||
50,
|
||||
50,
|
||||
50,
|
||||
75));
|
||||
|
||||
filter.Filter.Should().Be("colorchannelmixer=aa=0.75");
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,8 @@ public class PipelineGeneratorTests
|
||||
result.PipelineSteps.Should().Contain(ps => ps is EncoderLibx265);
|
||||
|
||||
string command = PrintCommand(videoInputFile, audioInputFile, None, None, result);
|
||||
command.Should().Be("-threads 1 -nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -re -i /tmp/whatever.mkv -map 0:1 -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:a aac -ac 2 -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
|
||||
command.Should().Be(
|
||||
"-threads 1 -nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -ss 00:00:01 -c:v h264 -re -i /tmp/whatever.mkv -map 0:1 -map 0:0 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -video_track_timescale 90000 -b:v 2000k -maxrate:v 2000k -bufsize:v 4000k -c:a aac -ac 2 -b:a 320k -maxrate:a 320k -bufsize:a 640k -ar 48k -c:v libx265 -tag:v hvc1 -x265-params log-level=error -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -20,6 +20,7 @@ public class EncoderH264Qsv : EncoderBase
|
||||
|
||||
public override string Name => "h264_qsv";
|
||||
public override StreamKind Kind => StreamKind.Video;
|
||||
public override IList<string> OutputOptions => new[] { "-c:v", "h264_qsv", "-low_power", "0" };
|
||||
|
||||
// need to upload if we're still in software and a watermark is used
|
||||
public override string Filter
|
||||
@@ -36,11 +37,11 @@ public class EncoderH264Qsv : EncoderBase
|
||||
// pixel format should already be converted to a supported format by QsvHardwareAccelerationOption
|
||||
foreach (IPixelFormat pixelFormat in _currentState.PixelFormat)
|
||||
{
|
||||
return $"format={pixelFormat.FFmpegName},hwupload=extra_hw_frames=128";
|
||||
return $"format={pixelFormat.FFmpegName},hwupload=extra_hw_frames=64";
|
||||
}
|
||||
|
||||
// default to nv12
|
||||
return "format=nv12,hwupload=extra_hw_frames=128";
|
||||
return "format=nv12,hwupload=extra_hw_frames=64";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ public class EncoderHevcQsv : EncoderBase
|
||||
|
||||
public override string Name => "hevc_qsv";
|
||||
public override StreamKind Kind => StreamKind.Video;
|
||||
public override IList<string> OutputOptions => new[] { "-c:v", "hevc_qsv", "-low_power", "0" };
|
||||
|
||||
// need to upload if we're still in software and a watermark is used
|
||||
public override string Filter
|
||||
@@ -36,11 +37,11 @@ public class EncoderHevcQsv : EncoderBase
|
||||
// pixel format should already be converted to a supported format by QsvHardwareAccelerationOption
|
||||
foreach (IPixelFormat pixelFormat in _currentState.PixelFormat)
|
||||
{
|
||||
return $"format={pixelFormat.FFmpegName},hwupload=extra_hw_frames=128";
|
||||
return $"format={pixelFormat.FFmpegName},hwupload=extra_hw_frames=64";
|
||||
}
|
||||
|
||||
// default to nv12
|
||||
return "format=nv12,hwupload=extra_hw_frames=128";
|
||||
return "format=nv12,hwupload=extra_hw_frames=64";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliWrap" Version="3.4.4" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.1.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.1.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ public class HardwareUploadFilter : BaseFilter
|
||||
{
|
||||
HardwareAccelerationMode.None => string.Empty,
|
||||
HardwareAccelerationMode.Nvenc => "hwupload_cuda",
|
||||
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=128",
|
||||
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=64",
|
||||
HardwareAccelerationMode.Vaapi => "format=nv12|vaapi,hwupload",
|
||||
_ => "hwupload"
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ public class DeinterlaceQsvFilter : BaseFilter
|
||||
|
||||
// deinterlace_qsv seems to only support nv12, not p010le
|
||||
public override string Filter => _currentState.FrameDataLocation == FrameDataLocation.Software
|
||||
? "format=nv12,hwupload=extra_hw_frames=128,deinterlace_qsv"
|
||||
? "format=nv12,hwupload=extra_hw_frames=64,deinterlace_qsv"
|
||||
: "deinterlace_qsv";
|
||||
|
||||
public override FrameState NextState(FrameState currentState)
|
||||
|
||||
@@ -49,10 +49,10 @@ public class ScaleQsvFilter : BaseFilter
|
||||
string initialPixelFormat = _currentState.PixelFormat.Match(pf => pf.FFmpegName, FFmpegFormat.NV12);
|
||||
if (!string.IsNullOrWhiteSpace(scale))
|
||||
{
|
||||
return $"format={initialPixelFormat},hwupload=extra_hw_frames=128,{scale}";
|
||||
return $"format={initialPixelFormat},hwupload=extra_hw_frames=64,{scale}";
|
||||
}
|
||||
|
||||
return $"format={initialPixelFormat},hwupload=extra_hw_frames=128";
|
||||
return $"format={initialPixelFormat},hwupload=extra_hw_frames=64";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ public class SubtitleHardwareUploadFilter : BaseFilter
|
||||
{
|
||||
HardwareAccelerationMode.None => string.Empty,
|
||||
HardwareAccelerationMode.Nvenc => "hwupload_cuda",
|
||||
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=128",
|
||||
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=64",
|
||||
|
||||
// leave vaapi in software since we don't (yet) use overlay_vaapi
|
||||
HardwareAccelerationMode.Vaapi when _currentState.FrameDataLocation == FrameDataLocation.Software =>
|
||||
|
||||
@@ -15,7 +15,7 @@ public class WatermarkHardwareUploadFilter : BaseFilter
|
||||
{
|
||||
HardwareAccelerationMode.None => string.Empty,
|
||||
HardwareAccelerationMode.Nvenc => "hwupload_cuda",
|
||||
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=128",
|
||||
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=64",
|
||||
|
||||
// leave vaapi in software since we don't (yet) use overlay_vaapi
|
||||
HardwareAccelerationMode.Vaapi when _currentState.FrameDataLocation == FrameDataLocation.Software =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using System.Globalization;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Filter;
|
||||
|
||||
@@ -13,7 +14,7 @@ public class WatermarkOpacityFilter : BaseFilter
|
||||
get
|
||||
{
|
||||
double opacity = _desiredState.Opacity / 100.0;
|
||||
return $"colorchannelmixer=aa={opacity:F2}";
|
||||
return $"colorchannelmixer=aa={opacity.ToString("F2", NumberFormatInfo.InvariantInfo)}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace ErsatzTV.Infrastructure;
|
||||
|
||||
public static class AsyncEnumerable
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an <see cref="IAsyncEnumerable{T}" /> which yields no results, similar to
|
||||
/// <see cref="Enumerable.Empty{TResult}" />.
|
||||
/// </summary>
|
||||
public static IAsyncEnumerable<T> Empty<T>() => EmptyAsyncEnumerator<T>.Instance;
|
||||
|
||||
private class EmptyAsyncEnumerator<T> : IAsyncEnumerator<T>, IAsyncEnumerable<T>
|
||||
{
|
||||
public static readonly EmptyAsyncEnumerator<T> Instance = new();
|
||||
|
||||
public IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return this;
|
||||
}
|
||||
|
||||
public T Current => default;
|
||||
public ValueTask DisposeAsync() => default;
|
||||
|
||||
public ValueTask<bool> MoveNextAsync() => new(false);
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,36 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
|
||||
new { LibraryId = library.Id, episode.ItemId }).Map(count => count > 0);
|
||||
}
|
||||
|
||||
public async Task<bool> FlagNormal(EmbyLibrary library, EmbySeason season)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
season.State = MediaItemState.Normal;
|
||||
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 0 WHERE Id IN
|
||||
(SELECT EmbySeason.Id FROM EmbySeason
|
||||
INNER JOIN MediaItem MI ON MI.Id = EmbySeason.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
|
||||
WHERE EmbySeason.ItemId = @ItemId)",
|
||||
new { LibraryId = library.Id, season.ItemId }).Map(count => count > 0);
|
||||
}
|
||||
|
||||
public async Task<bool> FlagNormal(EmbyLibrary library, EmbyShow show)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
show.State = MediaItemState.Normal;
|
||||
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 0 WHERE Id IN
|
||||
(SELECT EmbyShow.Id FROM EmbyShow
|
||||
INNER JOIN MediaItem MI ON MI.Id = EmbyShow.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
|
||||
WHERE EmbyShow.ItemId = @ItemId)",
|
||||
new { LibraryId = library.Id, show.ItemId }).Map(count => count > 0);
|
||||
}
|
||||
|
||||
public async Task<List<int>> FlagFileNotFoundShows(EmbyLibrary library, List<string> showItemIds)
|
||||
{
|
||||
if (showItemIds.Count == 0)
|
||||
|
||||
@@ -213,6 +213,36 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
|
||||
new { LibraryId = library.Id, episode.ItemId }).Map(count => count > 0);
|
||||
}
|
||||
|
||||
public async Task<bool> FlagNormal(JellyfinLibrary library, JellyfinSeason season)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
season.State = MediaItemState.Normal;
|
||||
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 0 WHERE Id IN
|
||||
(SELECT JellyfinSeason.Id FROM JellyfinSeason
|
||||
INNER JOIN MediaItem MI ON MI.Id = JellyfinSeason.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
|
||||
WHERE JellyfinSeason.ItemId = @ItemId)",
|
||||
new { LibraryId = library.Id, season.ItemId }).Map(count => count > 0);
|
||||
}
|
||||
|
||||
public async Task<bool> FlagNormal(JellyfinLibrary library, JellyfinShow show)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
show.State = MediaItemState.Normal;
|
||||
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 0 WHERE Id IN
|
||||
(SELECT JellyfinShow.Id FROM JellyfinShow
|
||||
INNER JOIN MediaItem MI ON MI.Id = JellyfinShow.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
|
||||
WHERE JellyfinShow.ItemId = @ItemId)",
|
||||
new { LibraryId = library.Id, show.ItemId }).Map(count => count > 0);
|
||||
}
|
||||
|
||||
public async Task<List<int>> FlagFileNotFoundShows(JellyfinLibrary library, List<string> showItemIds)
|
||||
{
|
||||
if (showItemIds.Count == 0)
|
||||
|
||||
@@ -31,6 +31,36 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
|
||||
new { LibraryId = library.Id, episode.Key }).Map(count => count > 0);
|
||||
}
|
||||
|
||||
public async Task<bool> FlagNormal(PlexLibrary library, PlexSeason season)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
season.State = MediaItemState.Normal;
|
||||
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 0 WHERE Id IN
|
||||
(SELECT PlexSeason.Id FROM PlexSeason
|
||||
INNER JOIN MediaItem MI ON MI.Id = PlexSeason.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
|
||||
WHERE PlexSeason.Key = @Key)",
|
||||
new { LibraryId = library.Id, season.Key }).Map(count => count > 0);
|
||||
}
|
||||
|
||||
public async Task<bool> FlagNormal(PlexLibrary library, PlexShow show)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
show.State = MediaItemState.Normal;
|
||||
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 0 WHERE Id IN
|
||||
(SELECT PlexShow.Id FROM PlexShow
|
||||
INNER JOIN MediaItem MI ON MI.Id = PlexShow.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
|
||||
WHERE PlexShow.Key = @Key)",
|
||||
new { LibraryId = library.Id, show.Key }).Map(count => count > 0);
|
||||
}
|
||||
|
||||
public async Task<Option<int>> FlagUnavailable(PlexLibrary library, PlexEpisode episode)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
@@ -71,137 +71,163 @@ public class EmbyApiClient : IEmbyApiClient
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library)
|
||||
{
|
||||
try
|
||||
{
|
||||
IEmbyApi service = RestService.For<IEmbyApi>(address);
|
||||
EmbyLibraryItemsResponse items = await service.GetMovieLibraryItems(apiKey, library.ItemId);
|
||||
return items.Items
|
||||
.Map(i => ProjectToMovie(library, i))
|
||||
.Somes()
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting emby movie library items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
public IAsyncEnumerable<EmbyMovie> GetMovieLibraryItems(string address, string apiKey, EmbyLibrary library)
|
||||
=> GetPagedLibraryContents(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
library.ItemId,
|
||||
EmbyItemType.Movie,
|
||||
(service, itemId, skip, pageSize) => service.GetMovieLibraryItems(
|
||||
apiKey,
|
||||
itemId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(maybeLibrary, item) => maybeLibrary.Map(lib => ProjectToMovie(lib, item)).Flatten());
|
||||
|
||||
public async Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
string libraryId)
|
||||
{
|
||||
try
|
||||
{
|
||||
IEmbyApi service = RestService.For<IEmbyApi>(address);
|
||||
EmbyLibraryItemsResponse items = await service.GetShowLibraryItems(apiKey, libraryId);
|
||||
return items.Items
|
||||
.Map(ProjectToShow)
|
||||
.Somes()
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting emby show library items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
public IAsyncEnumerable<EmbyShow> GetShowLibraryItems(string address, string apiKey, EmbyLibrary library)
|
||||
=> GetPagedLibraryContents(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
library.ItemId,
|
||||
EmbyItemType.Show,
|
||||
(service, itemId, skip, pageSize) => service.GetShowLibraryItems(
|
||||
apiKey,
|
||||
itemId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(_, item) => ProjectToShow(item));
|
||||
|
||||
public async Task<Either<BaseError, List<EmbySeason>>> GetSeasonLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
string showId)
|
||||
{
|
||||
try
|
||||
{
|
||||
IEmbyApi service = RestService.For<IEmbyApi>(address);
|
||||
EmbyLibraryItemsResponse items = await service.GetSeasonLibraryItems(apiKey, showId);
|
||||
return items.Items
|
||||
.Map(ProjectToSeason)
|
||||
.Somes()
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting emby show library items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems(
|
||||
public IAsyncEnumerable<EmbySeason> GetSeasonLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string seasonId)
|
||||
{
|
||||
try
|
||||
{
|
||||
IEmbyApi service = RestService.For<IEmbyApi>(address);
|
||||
EmbyLibraryItemsResponse items = await service.GetEpisodeLibraryItems(apiKey, seasonId);
|
||||
return items.Items
|
||||
.Map(i => ProjectToEpisode(library, i))
|
||||
.Somes()
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting emby episode library items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
string showId) => GetPagedLibraryContents(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
showId,
|
||||
EmbyItemType.Season,
|
||||
(service, itemId, skip, pageSize) => service.GetSeasonLibraryItems(
|
||||
apiKey,
|
||||
itemId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(_, item) => ProjectToSeason(item));
|
||||
|
||||
public async Task<Either<BaseError, List<EmbyCollection>>> GetCollectionLibraryItems(string address, string apiKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
// TODO: should we enumerate collection libraries here?
|
||||
|
||||
if (_memoryCache.TryGetValue("emby_collections_library_item_id", out string itemId))
|
||||
{
|
||||
IEmbyApi service = RestService.For<IEmbyApi>(address);
|
||||
EmbyLibraryItemsResponse items = await service.GetCollectionLibraryItems(apiKey, itemId);
|
||||
return items.Items
|
||||
.Map(ProjectToCollection)
|
||||
.Somes()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return BaseError.New("Emby collection item id is not available");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting Emby collection library items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<MediaItem>>> GetCollectionItems(
|
||||
public IAsyncEnumerable<EmbyEpisode> GetEpisodeLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
string collectionId)
|
||||
EmbyLibrary library,
|
||||
string showId,
|
||||
string seasonId) => GetPagedLibraryContents(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
seasonId,
|
||||
EmbyItemType.Episode,
|
||||
(service, _, skip, pageSize) => service.GetEpisodeLibraryItems(
|
||||
apiKey,
|
||||
showId,
|
||||
seasonId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(maybeLibrary, item) => maybeLibrary.Map(lib => ProjectToEpisode(lib, item)).Flatten());
|
||||
|
||||
public IAsyncEnumerable<EmbyCollection> GetCollectionLibraryItems(string address, string apiKey)
|
||||
{
|
||||
// TODO: should we enumerate collection libraries here?
|
||||
|
||||
if (_memoryCache.TryGetValue("emby_collections_library_item_id", out string itemId))
|
||||
{
|
||||
return GetPagedLibraryContents(
|
||||
address,
|
||||
apiKey,
|
||||
None,
|
||||
itemId,
|
||||
EmbyItemType.Collection,
|
||||
(service, _, skip, pageSize) => service.GetCollectionLibraryItems(
|
||||
apiKey,
|
||||
itemId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(_, item) => ProjectToCollection(item));
|
||||
}
|
||||
|
||||
return AsyncEnumerable.Empty<EmbyCollection>();
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<MediaItem> GetCollectionItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
string collectionId) =>
|
||||
GetPagedLibraryContents(
|
||||
address,
|
||||
apiKey,
|
||||
None,
|
||||
collectionId,
|
||||
EmbyItemType.CollectionItems,
|
||||
(service, _, skip, pageSize) => service.GetCollectionItems(
|
||||
apiKey,
|
||||
collectionId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(_, item) => ProjectToCollectionMediaItem(item));
|
||||
|
||||
public async Task<Either<BaseError, int>> GetLibraryItemCount(
|
||||
string address,
|
||||
string apiKey,
|
||||
string parentId,
|
||||
string includeItemTypes)
|
||||
{
|
||||
try
|
||||
{
|
||||
IEmbyApi service = RestService.For<IEmbyApi>(address);
|
||||
EmbyLibraryItemsResponse items = await service.GetCollectionItems(apiKey, collectionId);
|
||||
return items.Items
|
||||
.Map(ProjectToCollectionMediaItem)
|
||||
.Somes()
|
||||
.ToList();
|
||||
EmbyLibraryItemsResponse items = await service.GetLibraryStats(apiKey, parentId, includeItemTypes);
|
||||
return items.TotalRecordCount;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting Emby collection items");
|
||||
_logger.LogError(ex, "Error getting Emby library item count");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<TItem> GetPagedLibraryContents<TItem>(
|
||||
string address,
|
||||
string apiKey,
|
||||
Option<EmbyLibrary> maybeLibrary,
|
||||
string parentId,
|
||||
string itemType,
|
||||
Func<IEmbyApi, string, int, int, Task<EmbyLibraryItemsResponse>> getItems,
|
||||
Func<Option<EmbyLibrary>, EmbyLibraryItemResponse, Option<TItem>> mapper)
|
||||
{
|
||||
IEmbyApi service = RestService.For<IEmbyApi>(address);
|
||||
int size = await service
|
||||
.GetLibraryStats(apiKey, parentId, itemType)
|
||||
.Map(r => r.TotalRecordCount);
|
||||
|
||||
const int PAGE_SIZE = 10;
|
||||
|
||||
int pages = (size - 1) / PAGE_SIZE + 1;
|
||||
|
||||
for (var i = 0; i < pages; i++)
|
||||
{
|
||||
int skip = i * PAGE_SIZE;
|
||||
|
||||
Task<IEnumerable<TItem>> result = getItems(service, parentId, skip, PAGE_SIZE)
|
||||
.Map(items => items.Items.Map(item => mapper(maybeLibrary, item)).Somes());
|
||||
|
||||
#pragma warning disable VSTHRD003
|
||||
foreach (TItem item in await result)
|
||||
#pragma warning restore VSTHRD003
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Option<EmbyCollection> ProjectToCollection(EmbyLibraryItemResponse item)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -18,6 +18,21 @@ public interface IEmbyApi
|
||||
string apiKey);
|
||||
|
||||
[Get("/Items")]
|
||||
public Task<EmbyLibraryItemsResponse> GetLibraryStats(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
[Query]
|
||||
string parentId,
|
||||
[Query]
|
||||
string includeItemTypes,
|
||||
[Query]
|
||||
bool recursive = true,
|
||||
[Query]
|
||||
int startIndex = 0,
|
||||
[Query]
|
||||
int limit = 0);
|
||||
|
||||
[Get("/Items?sortOrder=Ascending&sortBy=SortName")]
|
||||
public Task<EmbyLibraryItemsResponse> GetMovieLibraryItems(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
@@ -29,9 +44,13 @@ public interface IEmbyApi
|
||||
[Query]
|
||||
string includeItemTypes = "Movie",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
bool recursive = true,
|
||||
[Query]
|
||||
int startIndex = 0,
|
||||
[Query]
|
||||
int limit = 0);
|
||||
|
||||
[Get("/Items")]
|
||||
[Get("/Items?sortOrder=Ascending&sortBy=SortName")]
|
||||
public Task<EmbyLibraryItemsResponse> GetShowLibraryItems(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
@@ -43,40 +62,40 @@ public interface IEmbyApi
|
||||
[Query]
|
||||
string includeItemTypes = "Series",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
bool recursive = true,
|
||||
[Query]
|
||||
int startIndex = 0,
|
||||
[Query]
|
||||
int limit = 0);
|
||||
|
||||
[Get("/Items")]
|
||||
[Get("/Shows/{parentId}/Seasons?sortOrder=Ascending&sortBy=SortName")]
|
||||
public Task<EmbyLibraryItemsResponse> GetSeasonLibraryItems(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
[Query]
|
||||
string parentId,
|
||||
[Query]
|
||||
string fields = "Path,DateCreated,Etag,Taglines,ProviderIds",
|
||||
[Query]
|
||||
string includeItemTypes = "Season",
|
||||
int startIndex = 0,
|
||||
[Query]
|
||||
string excludeLocationTypes = "Virtual",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
int limit = 0);
|
||||
|
||||
[Get("/Items")]
|
||||
[Get("/Shows/{showId}/Episodes?sortOrder=Ascending&sortBy=SortName")]
|
||||
public Task<EmbyLibraryItemsResponse> GetEpisodeLibraryItems(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
string showId,
|
||||
[Query]
|
||||
string parentId,
|
||||
string seasonId,
|
||||
[Query]
|
||||
string fields =
|
||||
"Path,DateCreated,Etag,Overview,ProductionYear,PremiereDate,MediaSources,LocationType,ProviderIds,People",
|
||||
[Query]
|
||||
string includeItemTypes = "Episode",
|
||||
int startIndex = 0,
|
||||
[Query]
|
||||
string excludeLocationTypes = "Virtual",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
int limit = 0);
|
||||
|
||||
[Get("/Items")]
|
||||
[Get("/Items?sortOrder=Ascending&sortBy=SortName")]
|
||||
public Task<EmbyLibraryItemsResponse> GetCollectionLibraryItems(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
@@ -87,9 +106,13 @@ public interface IEmbyApi
|
||||
[Query]
|
||||
string includeItemTypes = "BoxSet",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
bool recursive = true,
|
||||
[Query]
|
||||
int startIndex = 0,
|
||||
[Query]
|
||||
int limit = 0);
|
||||
|
||||
[Get("/Items")]
|
||||
[Get("/Items?sortOrder=Ascending&sortBy=SortName")]
|
||||
public Task<EmbyLibraryItemsResponse> GetCollectionItems(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
@@ -102,5 +125,9 @@ public interface IEmbyApi
|
||||
[Query]
|
||||
string excludeLocationTypes = "Virtual",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
bool recursive = true,
|
||||
[Query]
|
||||
int startIndex = 0,
|
||||
[Query]
|
||||
int limit = 0);
|
||||
}
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
public class EmbyLibraryItemsResponse
|
||||
{
|
||||
public List<EmbyLibraryItemResponse> Items { get; set; }
|
||||
public int TotalRecordCount { get; set; }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks;
|
||||
public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthCheck
|
||||
{
|
||||
private const string BundledVersion = "N-106635-g83e1a1de88";
|
||||
private const string BundledVersionVaapi = "N-106957-g27cffd16aa";
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public FFmpegVersionHealthCheck(IConfigElementRepository configElementRepository) =>
|
||||
@@ -74,7 +75,7 @@ public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthChe
|
||||
return FailResult($"{app} version {version} is too old; please install 5.0!");
|
||||
}
|
||||
|
||||
if (!version.StartsWith("5.0") && version != BundledVersion)
|
||||
if (!version.StartsWith("5.0") && version != BundledVersion && version != BundledVersionVaapi)
|
||||
{
|
||||
return WarningResult(
|
||||
$"{app} version {version} is unexpected and may have problems; please install 5.0!");
|
||||
|
||||
@@ -46,6 +46,7 @@ public class HardwareAccelerationHealthCheck : BaseHealthCheck, IHardwareAcceler
|
||||
else if (version.Contains("vaapi", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
accelerationKinds.Add(HardwareAccelerationKind.Vaapi);
|
||||
accelerationKinds.Add(HardwareAccelerationKind.Qsv);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,25 @@ public interface IJellyfinApi
|
||||
string apiKey);
|
||||
|
||||
[Get("/Items")]
|
||||
public Task<JellyfinLibraryItemsResponse> GetLibraryStats(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
[Query]
|
||||
string userId,
|
||||
[Query]
|
||||
string parentId,
|
||||
[Query]
|
||||
string includeItemTypes,
|
||||
[Query]
|
||||
bool recursive = true,
|
||||
[Query]
|
||||
string filters = "IsNotFolder",
|
||||
[Query]
|
||||
int startIndex = 0,
|
||||
[Query]
|
||||
int limit = 0);
|
||||
|
||||
[Get("/Items?sortOrder=Ascending&sortBy=SortName")]
|
||||
public Task<JellyfinLibraryItemsResponse> GetMovieLibraryItems(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
@@ -38,9 +57,13 @@ public interface IJellyfinApi
|
||||
[Query]
|
||||
bool recursive = true,
|
||||
[Query]
|
||||
string filters = "IsNotFolder");
|
||||
string filters = "IsNotFolder",
|
||||
[Query]
|
||||
int startIndex = 0,
|
||||
[Query]
|
||||
int limit = 0);
|
||||
|
||||
[Get("/Items")]
|
||||
[Get("/Items?sortOrder=Ascending&sortBy=SortName")]
|
||||
public Task<JellyfinLibraryItemsResponse> GetShowLibraryItems(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
@@ -54,9 +77,13 @@ public interface IJellyfinApi
|
||||
[Query]
|
||||
string includeItemTypes = "Series",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
bool recursive = true,
|
||||
[Query]
|
||||
int startIndex = 0,
|
||||
[Query]
|
||||
int limit = 0);
|
||||
|
||||
[Get("/Items")]
|
||||
[Get("/Items?sortOrder=Ascending&sortBy=SortName")]
|
||||
public Task<JellyfinLibraryItemsResponse> GetSeasonLibraryItems(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
@@ -69,9 +96,13 @@ public interface IJellyfinApi
|
||||
[Query]
|
||||
string includeItemTypes = "Season",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
bool recursive = true,
|
||||
[Query]
|
||||
int startIndex = 0,
|
||||
[Query]
|
||||
int limit = 0);
|
||||
|
||||
[Get("/Items")]
|
||||
[Get("/Items?sortOrder=Ascending&sortBy=SortName")]
|
||||
public Task<JellyfinLibraryItemsResponse> GetEpisodeLibraryItems(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
@@ -84,9 +115,13 @@ public interface IJellyfinApi
|
||||
[Query]
|
||||
string includeItemTypes = "Episode",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
bool recursive = true,
|
||||
[Query]
|
||||
int startIndex = 0,
|
||||
[Query]
|
||||
int limit = 0);
|
||||
|
||||
[Get("/Items")]
|
||||
[Get("/Items?sortOrder=Ascending&sortBy=SortName")]
|
||||
public Task<JellyfinLibraryItemsResponse> GetCollectionLibraryItems(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
@@ -99,9 +134,13 @@ public interface IJellyfinApi
|
||||
[Query]
|
||||
string includeItemTypes = "BoxSet",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
bool recursive = true,
|
||||
[Query]
|
||||
int startIndex = 0,
|
||||
[Query]
|
||||
int limit = 0);
|
||||
|
||||
[Get("/Items")]
|
||||
[Get("/Items?sortOrder=Ascending&sortBy=SortName")]
|
||||
public Task<JellyfinLibraryItemsResponse> GetCollectionItems(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
@@ -114,5 +153,9 @@ public interface IJellyfinApi
|
||||
[Query]
|
||||
string includeItemTypes = "Movie,Series,Season,Episode",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
bool recursive = true,
|
||||
[Query]
|
||||
int startIndex = 0,
|
||||
[Query]
|
||||
int limit = 0);
|
||||
}
|
||||
|
||||
@@ -91,174 +91,199 @@ public class JellyfinApiClient : IJellyfinApiClient
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems(
|
||||
public IAsyncEnumerable<JellyfinMovie> GetMovieLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{library.MediaSourceId}", out string userId))
|
||||
{
|
||||
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
|
||||
JellyfinLibraryItemsResponse items = await service.GetMovieLibraryItems(apiKey, userId, library.ItemId);
|
||||
return items.Items
|
||||
.Map(i => ProjectToMovie(library, i))
|
||||
.Somes()
|
||||
.ToList();
|
||||
}
|
||||
JellyfinLibrary library) =>
|
||||
GetPagedLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
library.MediaSourceId,
|
||||
library.ItemId,
|
||||
JellyfinItemType.Movie,
|
||||
(service, userId, itemId, skip, pageSize) => service.GetMovieLibraryItems(
|
||||
apiKey,
|
||||
userId,
|
||||
itemId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(maybeLibrary, item) => maybeLibrary.Map(lib => ProjectToMovie(lib, item)).Flatten());
|
||||
|
||||
return BaseError.New("Jellyfin admin user id is not available");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting jellyfin movie library items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<JellyfinShow>>> GetShowLibraryItems(
|
||||
public IAsyncEnumerable<JellyfinShow> GetShowLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
int mediaSourceId,
|
||||
string libraryId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{mediaSourceId}", out string userId))
|
||||
{
|
||||
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
|
||||
JellyfinLibraryItemsResponse items = await service.GetShowLibraryItems(apiKey, userId, libraryId);
|
||||
return items.Items
|
||||
.Map(ProjectToShow)
|
||||
.Somes()
|
||||
.ToList();
|
||||
}
|
||||
JellyfinLibrary library) =>
|
||||
GetPagedLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
library.MediaSourceId,
|
||||
library.ItemId,
|
||||
JellyfinItemType.Show,
|
||||
(service, userId, itemId, skip, pageSize) => service.GetShowLibraryItems(
|
||||
apiKey,
|
||||
userId,
|
||||
itemId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(_, item) => ProjectToShow(item));
|
||||
|
||||
return BaseError.New("Jellyfin admin user id is not available");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting jellyfin show library items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<JellyfinSeason>>> GetSeasonLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
int mediaSourceId,
|
||||
string showId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{mediaSourceId}", out string userId))
|
||||
{
|
||||
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
|
||||
JellyfinLibraryItemsResponse items = await service.GetSeasonLibraryItems(apiKey, userId, showId);
|
||||
return items.Items
|
||||
.Map(ProjectToSeason)
|
||||
.Somes()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return BaseError.New("Jellyfin admin user id is not available");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting jellyfin show library items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<JellyfinEpisode>>> GetEpisodeLibraryItems(
|
||||
public IAsyncEnumerable<JellyfinSeason> GetSeasonLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string seasonId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{library.MediaSourceId}", out string userId))
|
||||
{
|
||||
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
|
||||
JellyfinLibraryItemsResponse items = await service.GetEpisodeLibraryItems(apiKey, userId, seasonId);
|
||||
return items.Items
|
||||
.Map(i => ProjectToEpisode(library, i))
|
||||
.Somes()
|
||||
.ToList();
|
||||
}
|
||||
string showId) =>
|
||||
GetPagedLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
library.MediaSourceId,
|
||||
showId,
|
||||
JellyfinItemType.Season,
|
||||
(service, userId, _, skip, pageSize) => service.GetSeasonLibraryItems(
|
||||
apiKey,
|
||||
userId,
|
||||
showId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(_, item) => ProjectToSeason(item));
|
||||
|
||||
return BaseError.New("Jellyfin admin user id is not available");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting jellyfin episode library items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
public IAsyncEnumerable<JellyfinEpisode> GetEpisodeLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string seasonId) =>
|
||||
GetPagedLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
library.MediaSourceId,
|
||||
seasonId,
|
||||
JellyfinItemType.Episode,
|
||||
(service, userId, _, skip, pageSize) => service.GetEpisodeLibraryItems(
|
||||
apiKey,
|
||||
userId,
|
||||
seasonId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(maybeLibrary, item) => maybeLibrary.Map(lib => ProjectToEpisode(lib, item)).Flatten());
|
||||
|
||||
public async Task<Either<BaseError, List<JellyfinCollection>>> GetCollectionLibraryItems(
|
||||
public IAsyncEnumerable<JellyfinCollection> GetCollectionLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
int mediaSourceId)
|
||||
{
|
||||
// TODO: should we enumerate collection libraries here?
|
||||
|
||||
if (_memoryCache.TryGetValue("jellyfin_collections_library_item_id", out string itemId))
|
||||
{
|
||||
return GetPagedLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
None,
|
||||
mediaSourceId,
|
||||
itemId,
|
||||
JellyfinItemType.Collection,
|
||||
(service, userId, _, skip, pageSize) => service.GetCollectionLibraryItems(
|
||||
apiKey,
|
||||
userId,
|
||||
itemId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(_, item) => ProjectToCollection(item));
|
||||
}
|
||||
|
||||
return AsyncEnumerable.Empty<JellyfinCollection>();
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<MediaItem> GetCollectionItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
int mediaSourceId,
|
||||
string collectionId) =>
|
||||
GetPagedLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
None,
|
||||
mediaSourceId,
|
||||
collectionId,
|
||||
JellyfinItemType.CollectionItems,
|
||||
(service, userId, _, skip, pageSize) => service.GetCollectionItems(
|
||||
apiKey,
|
||||
userId,
|
||||
collectionId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(_, item) => ProjectToCollectionMediaItem(item));
|
||||
|
||||
public async Task<Either<BaseError, int>> GetLibraryItemCount(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string parentId,
|
||||
string includeItemTypes,
|
||||
bool excludeFolders)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{mediaSourceId}", out string userId))
|
||||
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{library.MediaSourceId}", out string userId))
|
||||
{
|
||||
// TODO: should we enumerate collection libraries here?
|
||||
|
||||
if (_memoryCache.TryGetValue("jellyfin_collections_library_item_id", out string itemId))
|
||||
{
|
||||
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
|
||||
JellyfinLibraryItemsResponse items =
|
||||
await service.GetCollectionLibraryItems(apiKey, userId, itemId);
|
||||
return items.Items
|
||||
.Map(ProjectToCollection)
|
||||
.Somes()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return BaseError.New("Jellyfin collection item id is not available");
|
||||
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
|
||||
JellyfinLibraryItemsResponse items = await service.GetLibraryStats(
|
||||
apiKey,
|
||||
userId,
|
||||
parentId,
|
||||
includeItemTypes,
|
||||
filters: excludeFolders ? "IsNotFolder" : null);
|
||||
return items.TotalRecordCount;
|
||||
}
|
||||
|
||||
return BaseError.New("Jellyfin admin user id is not available");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting jellyfin collection library items");
|
||||
_logger.LogError(ex, "Error getting jellyfin library item count");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<MediaItem>>> GetCollectionItems(
|
||||
private async IAsyncEnumerable<TItem> GetPagedLibraryItems<TItem>(
|
||||
string address,
|
||||
string apiKey,
|
||||
Option<JellyfinLibrary> maybeLibrary,
|
||||
int mediaSourceId,
|
||||
string collectionId)
|
||||
string parentId,
|
||||
string itemType,
|
||||
Func<IJellyfinApi, string, string, int, int, Task<JellyfinLibraryItemsResponse>> getItems,
|
||||
Func<Option<JellyfinLibrary>, JellyfinLibraryItemResponse, Option<TItem>> mapper)
|
||||
{
|
||||
try
|
||||
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{mediaSourceId}", out string userId))
|
||||
{
|
||||
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{mediaSourceId}", out string userId))
|
||||
{
|
||||
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
|
||||
JellyfinLibraryItemsResponse items = await service.GetCollectionItems(
|
||||
apiKey,
|
||||
userId,
|
||||
collectionId);
|
||||
return items.Items
|
||||
.Map(ProjectToCollectionMediaItem)
|
||||
.Somes()
|
||||
.ToList();
|
||||
}
|
||||
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
|
||||
string filters = itemType == JellyfinItemType.Movie || itemType == JellyfinItemType.Episode
|
||||
? "IsNotFolder"
|
||||
: null;
|
||||
int size = await service
|
||||
.GetLibraryStats(apiKey, userId, parentId, itemType, filters: filters)
|
||||
.Map(r => r.TotalRecordCount);
|
||||
|
||||
return BaseError.New("Jellyfin admin user id is not available");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting jellyfin collection items");
|
||||
return BaseError.New(ex.Message);
|
||||
const int PAGE_SIZE = 10;
|
||||
|
||||
int pages = (size - 1) / PAGE_SIZE + 1;
|
||||
|
||||
for (var i = 0; i < pages; i++)
|
||||
{
|
||||
int skip = i * PAGE_SIZE;
|
||||
|
||||
Task<IEnumerable<TItem>> result = getItems(service, userId, parentId, skip, PAGE_SIZE)
|
||||
.Map(items => items.Items.Map(item => mapper(maybeLibrary, item)).Somes());
|
||||
|
||||
foreach (TItem item in await result)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
public class JellyfinLibraryItemsResponse
|
||||
{
|
||||
public List<JellyfinLibraryItemResponse> Items { get; set; }
|
||||
public int TotalRecordCount { get; set; }
|
||||
}
|
||||
|
||||
Generated
+4291
File diff suppressed because it is too large
Load Diff
+30
@@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Reset_OtherVideoLibraries20220522 : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00' WHERE Id IN
|
||||
(SELECT LP.Id FROM LibraryPath LP INNER JOIN Library L on L.Id = LP.LibraryId WHERE MediaKind = 4)");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE MediaKind = 4");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@"DELETE FROM LibraryFolder WHERE Id IN
|
||||
(SELECT LF.Id FROM LibraryFolder LF
|
||||
INNER JOIN LibraryPath LP on LP.Id = LF.LibraryPathId
|
||||
INNER JOIN Library L on L.Id = LP.LibraryId
|
||||
WHERE L.MediaKind = 4)");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+4294
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_ChannelMusicVideoCreditsMode : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MusicVideoCreditsMode",
|
||||
table: "Channel",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MusicVideoCreditsMode",
|
||||
table: "Channel");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.4");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.5");
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
|
||||
{
|
||||
@@ -233,6 +233,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("ErsatzTV");
|
||||
|
||||
b.Property<int>("MusicVideoCreditsMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ namespace ErsatzTV.Infrastructure.Plex;
|
||||
public interface IPlexServerApi
|
||||
{
|
||||
[Get("/")]
|
||||
[Headers("Accept: application/json")]
|
||||
public Task Ping(
|
||||
[Headers("Accept: text/xml")]
|
||||
public Task<PlexXmlMediaContainerPingResponse> Ping(
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token);
|
||||
|
||||
@@ -17,11 +17,22 @@ public interface IPlexServerApi
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token);
|
||||
|
||||
[Get("/library/sections/{key}/all?X-Plex-Container-Start=0&X-Plex-Container-Size=0")]
|
||||
[Headers("Accept: text/xml")]
|
||||
public Task<PlexXmlMediaContainerStatsResponse> GetLibrarySection(
|
||||
string key,
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token);
|
||||
|
||||
[Get("/library/sections/{key}/all")]
|
||||
[Headers("Accept: application/json")]
|
||||
public Task<PlexMediaContainerResponse<PlexMediaContainerMetadataContent<PlexMetadataResponse>>>
|
||||
GetLibrarySectionContents(
|
||||
string key,
|
||||
[Query] [AliasAs("X-Plex-Container-Start")]
|
||||
int skip,
|
||||
[Query] [AliasAs("X-Plex-Container-Size")]
|
||||
int take,
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token);
|
||||
|
||||
@@ -41,19 +52,41 @@ public interface IPlexServerApi
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token);
|
||||
|
||||
[Get("/library/metadata/{key}/children?X-Plex-Container-Start=0&X-Plex-Container-Size=0")]
|
||||
[Headers("Accept: text/xml")]
|
||||
public Task<PlexXmlMediaContainerStatsResponse> CountShowChildren(
|
||||
string key,
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token);
|
||||
|
||||
[Get("/library/metadata/{key}/children")]
|
||||
[Headers("Accept: text/xml")]
|
||||
public Task<PlexXmlSeasonsMetadataResponseContainer>
|
||||
GetShowChildren(
|
||||
string key,
|
||||
[Query] [AliasAs("X-Plex-Container-Start")]
|
||||
int skip,
|
||||
[Query] [AliasAs("X-Plex-Container-Size")]
|
||||
int take,
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token);
|
||||
|
||||
[Get("/library/metadata/{key}/children?X-Plex-Container-Start=0&X-Plex-Container-Size=0")]
|
||||
[Headers("Accept: text/xml")]
|
||||
public Task<PlexXmlMediaContainerStatsResponse> CountSeasonChildren(
|
||||
string key,
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token);
|
||||
|
||||
[Get("/library/metadata/{key}/children")]
|
||||
[Headers("Accept: text/xml")]
|
||||
public Task<PlexXmlEpisodesMetadataResponseContainer>
|
||||
GetSeasonChildren(
|
||||
string key,
|
||||
[Query] [AliasAs("X-Plex-Container-Start")]
|
||||
int skip,
|
||||
[Query] [AliasAs("X-Plex-Container-Size")]
|
||||
int take,
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,20 @@ public class PlexMediaContainerMetadataContent<T>
|
||||
public List<T> Metadata { get; set; }
|
||||
}
|
||||
|
||||
[XmlRoot("MediaContainer", Namespace = null)]
|
||||
public class PlexXmlMediaContainerStatsResponse
|
||||
{
|
||||
[XmlAttribute("totalSize")]
|
||||
public int TotalSize { get; set; }
|
||||
}
|
||||
|
||||
[XmlRoot("MediaContainer", Namespace = null)]
|
||||
public class PlexXmlMediaContainerPingResponse
|
||||
{
|
||||
[XmlAttribute("machineIdentifier")]
|
||||
public string MachineIdentifier { get; set; }
|
||||
}
|
||||
|
||||
[XmlRoot("MediaContainer", Namespace = null)]
|
||||
public class PlexXmlVideoMetadataResponseContainer
|
||||
{
|
||||
|
||||
@@ -8,10 +8,8 @@ public class PlexEtag
|
||||
{
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
|
||||
public PlexEtag(RecyclableMemoryStreamManager recyclableMemoryStreamManager)
|
||||
{
|
||||
public PlexEtag(RecyclableMemoryStreamManager recyclableMemoryStreamManager) =>
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
}
|
||||
|
||||
public string ForMovie(PlexMetadataResponse response)
|
||||
{
|
||||
|
||||
@@ -26,21 +26,13 @@ public class PlexServerApiClient : IPlexServerApiClient
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> Ping(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
public async Task<bool> Ping(PlexConnection connection, PlexServerAuthToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
IPlexServerApi service = RestService.For<IPlexServerApi>(
|
||||
new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(connection.Uri),
|
||||
Timeout = TimeSpan.FromSeconds(5)
|
||||
});
|
||||
|
||||
await service.Ping(token.AuthToken);
|
||||
return true;
|
||||
IPlexServerApi service = XmlServiceFor(connection.Uri, TimeSpan.FromSeconds(5));
|
||||
PlexXmlMediaContainerPingResponse pingResult = await service.Ping(token.AuthToken);
|
||||
return token.ClientIdentifier == pingResult.MachineIdentifier;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
@@ -76,56 +68,58 @@ public class PlexServerApiClient : IPlexServerApiClient
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<PlexMovie>>> GetMovieLibraryContents(
|
||||
public IAsyncEnumerable<PlexMovie> GetMovieLibraryContents(
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
{
|
||||
try
|
||||
Task<PlexXmlMediaContainerStatsResponse> CountItems(IPlexServerApi service)
|
||||
{
|
||||
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
|
||||
return await service.GetLibrarySectionContents(library.Key, token.AuthToken)
|
||||
return service.GetLibrarySection(library.Key, token.AuthToken);
|
||||
}
|
||||
|
||||
Task<IEnumerable<PlexMovie>> GetItems(IPlexServerApi _, IPlexServerApi jsonService, int skip, int pageSize)
|
||||
{
|
||||
return jsonService
|
||||
.GetLibrarySectionContents(library.Key, skip, pageSize, token.AuthToken)
|
||||
.Map(r => r.MediaContainer.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0))
|
||||
.Map(list => list.Map(metadata => ProjectToMovie(metadata, library.MediaSourceId)).ToList());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.ToString());
|
||||
.Map(list => list.Map(metadata => ProjectToMovie(metadata, library.MediaSourceId)));
|
||||
}
|
||||
|
||||
return GetPagedLibraryContents(connection, CountItems, GetItems);
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<PlexShow>>> GetShowLibraryContents(
|
||||
public IAsyncEnumerable<PlexShow> GetShowLibraryContents(
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
{
|
||||
try
|
||||
Task<PlexXmlMediaContainerStatsResponse> CountItems(IPlexServerApi service)
|
||||
{
|
||||
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
|
||||
return await service.GetLibrarySectionContents(library.Key, token.AuthToken)
|
||||
return service.GetLibrarySection(library.Key, token.AuthToken);
|
||||
}
|
||||
|
||||
Task<IEnumerable<PlexShow>> GetItems(IPlexServerApi _, IPlexServerApi jsonService, int skip, int pageSize)
|
||||
{
|
||||
return jsonService
|
||||
.GetLibrarySectionContents(library.Key, skip, pageSize, token.AuthToken)
|
||||
.Map(r => r.MediaContainer.Metadata)
|
||||
.Map(
|
||||
list => (list ?? new List<PlexMetadataResponse>())
|
||||
.Map(metadata => ProjectToShow(metadata, library.MediaSourceId)).ToList());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.ToString());
|
||||
.Map(list => list.Map(metadata => ProjectToShow(metadata, library.MediaSourceId)));
|
||||
}
|
||||
|
||||
return GetPagedLibraryContents(connection, CountItems, GetItems);
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<PlexSeason>>> GetShowSeasons(
|
||||
PlexLibrary library,
|
||||
public async Task<Either<BaseError, int>> CountShowSeasons(
|
||||
PlexShow show,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
string showMetadataKey = show.Key.Split("/").Reverse().Skip(1).Head();
|
||||
IPlexServerApi service = XmlServiceFor(connection.Uri);
|
||||
return await service.GetShowChildren(show.Key.Split("/").Reverse().Skip(1).Head(), token.AuthToken)
|
||||
.Map(r => r.Metadata.Filter(m => !m.Key.Contains("allLeaves")))
|
||||
.Map(list => list.Map(metadata => ProjectToSeason(metadata, library.MediaSourceId)).ToList());
|
||||
return await service.CountShowChildren(showMetadataKey, token.AuthToken).Map(r => r.TotalSize);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -133,19 +127,39 @@ public class PlexServerApiClient : IPlexServerApiClient
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<PlexEpisode>>> GetSeasonEpisodes(
|
||||
public IAsyncEnumerable<PlexSeason> GetShowSeasons(
|
||||
PlexLibrary library,
|
||||
PlexShow show,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
{
|
||||
string showMetadataKey = show.Key.Split("/").Reverse().Skip(1).Head();
|
||||
|
||||
Task<PlexXmlMediaContainerStatsResponse> CountItems(IPlexServerApi service)
|
||||
{
|
||||
return service.CountShowChildren(showMetadataKey, token.AuthToken);
|
||||
}
|
||||
|
||||
Task<IEnumerable<PlexSeason>> GetItems(IPlexServerApi xmlService, IPlexServerApi _, int skip, int pageSize)
|
||||
{
|
||||
return xmlService.GetShowChildren(showMetadataKey, skip, pageSize, token.AuthToken)
|
||||
.Map(r => r.Metadata.Filter(m => !m.Key.Contains("allLeaves")))
|
||||
.Map(list => list.Map(metadata => ProjectToSeason(metadata, library.MediaSourceId)));
|
||||
}
|
||||
|
||||
return GetPagedLibraryContents(connection, CountItems, GetItems);
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, int>> CountSeasonEpisodes(
|
||||
PlexSeason season,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
string seasonMetadataKey = season.Key.Split("/").Reverse().Skip(1).Head();
|
||||
IPlexServerApi service = XmlServiceFor(connection.Uri);
|
||||
return await service.GetSeasonChildren(season.Key.Split("/").Reverse().Skip(1).Head(), token.AuthToken)
|
||||
.Map(r => r.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0))
|
||||
.Map(list => list.Map(metadata => ProjectToEpisode(metadata, library.MediaSourceId)))
|
||||
.Map(ProcessMultiEpisodeFiles);
|
||||
return await service.CountSeasonChildren(seasonMetadataKey, token.AuthToken).Map(r => r.TotalSize);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -153,6 +167,29 @@ public class PlexServerApiClient : IPlexServerApiClient
|
||||
}
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<PlexEpisode> GetSeasonEpisodes(
|
||||
PlexLibrary library,
|
||||
PlexSeason season,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
{
|
||||
string seasonMetadataKey = season.Key.Split("/").Reverse().Skip(1).Head();
|
||||
|
||||
Task<PlexXmlMediaContainerStatsResponse> CountItems(IPlexServerApi service)
|
||||
{
|
||||
return service.CountSeasonChildren(seasonMetadataKey, token.AuthToken);
|
||||
}
|
||||
|
||||
Task<IEnumerable<PlexEpisode>> GetItems(IPlexServerApi xmlService, IPlexServerApi _, int skip, int pageSize)
|
||||
{
|
||||
return xmlService.GetSeasonChildren(seasonMetadataKey, skip, pageSize, token.AuthToken)
|
||||
.Map(r => r.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0))
|
||||
.Map(list => list.Map(metadata => ProjectToEpisode(metadata, library.MediaSourceId)));
|
||||
}
|
||||
|
||||
return GetPagedLibraryContents(connection, CountItems, GetItems);
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, MovieMetadata>> GetMovieMetadata(
|
||||
PlexLibrary library,
|
||||
string key,
|
||||
@@ -254,6 +291,50 @@ public class PlexServerApiClient : IPlexServerApiClient
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, int>> GetLibraryItemCount(
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
IPlexServerApi service = XmlServiceFor(connection.Uri);
|
||||
return await service.GetLibrarySection(library.Key, token.AuthToken).Map(r => r.TotalSize);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<TItem> GetPagedLibraryContents<TItem>(
|
||||
PlexConnection connection,
|
||||
Func<IPlexServerApi, Task<PlexXmlMediaContainerStatsResponse>> countItems,
|
||||
Func<IPlexServerApi, IPlexServerApi, int, int, Task<IEnumerable<TItem>>> getItems)
|
||||
{
|
||||
IPlexServerApi xmlService = XmlServiceFor(connection.Uri);
|
||||
|
||||
int size = await countItems(xmlService).Map(r => r.TotalSize);
|
||||
|
||||
const int PAGE_SIZE = 10;
|
||||
|
||||
IPlexServerApi jsonService = RestService.For<IPlexServerApi>(connection.Uri);
|
||||
int pages = (size - 1) / PAGE_SIZE + 1;
|
||||
|
||||
for (var i = 0; i < pages; i++)
|
||||
{
|
||||
int skip = i * PAGE_SIZE;
|
||||
|
||||
Task<IEnumerable<TItem>> result = getItems(xmlService, jsonService, skip, PAGE_SIZE);
|
||||
|
||||
foreach (TItem item in await result)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: fix this with the addition of paging
|
||||
private List<PlexEpisode> ProcessMultiEpisodeFiles(IEnumerable<PlexEpisode> episodes)
|
||||
{
|
||||
// add all metadata from duplicate paths to first entry with given path
|
||||
@@ -275,14 +356,20 @@ public class PlexServerApiClient : IPlexServerApiClient
|
||||
return result.Values.ToList();
|
||||
}
|
||||
|
||||
private static IPlexServerApi XmlServiceFor(string uri)
|
||||
private static IPlexServerApi XmlServiceFor(string uri, TimeSpan? timeout = null)
|
||||
{
|
||||
var overrides = new XmlAttributeOverrides();
|
||||
var attrs = new XmlAttributes { XmlIgnore = true };
|
||||
overrides.Add(typeof(PlexMetadataResponse), "Media", attrs);
|
||||
|
||||
TimeSpan httpClientTimeout = timeout ?? TimeSpan.FromSeconds(30);
|
||||
|
||||
return RestService.For<IPlexServerApi>(
|
||||
uri,
|
||||
new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(uri),
|
||||
Timeout = httpClientTimeout
|
||||
},
|
||||
new RefitSettings
|
||||
{
|
||||
ContentSerializer = new XmlContentSerializer(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using ErsatzTV.Application.FFmpegProfiles;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ErsatzTV.Application.FFmpegProfiles;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -15,4 +17,21 @@ public class FFmpegProfileController
|
||||
[HttpGet("/api/ffmpeg/profiles")]
|
||||
public async Task<List<FFmpegProfileResponseModel>> GetAll() =>
|
||||
await _mediator.Send(new GetAllFFmpegProfilesForApi());
|
||||
|
||||
[HttpPost("/api/ffmpeg/profiles/new")]
|
||||
public async Task<Either<BaseError, CreateFFmpegProfileResult>> AddOne(
|
||||
[Required] [FromBody]
|
||||
CreateFFmpegProfile request) => await _mediator.Send(request);
|
||||
|
||||
[HttpPut("/api/ffmpeg/profiles/update")]
|
||||
public async Task<Either<BaseError, UpdateFFmpegProfileResult>> UpdateOne(
|
||||
[Required] [FromBody]
|
||||
UpdateFFmpegProfile request) => await _mediator.Send(request);
|
||||
|
||||
[HttpGet("/api/ffmpeg/profiles/{id}")]
|
||||
public async Task<Option<FFmpegFullProfileResponseModel>> GetOne(int id) =>
|
||||
await _mediator.Send(new GetFFmpegFullProfileByIdForApi(id));
|
||||
|
||||
[HttpDelete("/api/ffmpeg/delete/{id}")]
|
||||
public async Task DeleteProfileAsync(int id) => await _mediator.Send(new DeleteFFmpegProfile(id));
|
||||
}
|
||||
|
||||
@@ -55,10 +55,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag.AspNet.Core" Version="3.0.1" />
|
||||
<PackageReference Include="FluentValidation" Version="11.0.1" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.0.1" />
|
||||
<PackageReference Include="HtmlSanitizer" Version="7.1.488" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.1.0" />
|
||||
<PackageReference Include="FluentValidation" Version="11.0.2" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.0.2" />
|
||||
<PackageReference Include="HtmlSanitizer" Version="7.1.509" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.1.1" />
|
||||
<PackageReference Include="Markdig" Version="0.30.2" />
|
||||
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
|
||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="10.0.1" />
|
||||
@@ -116,4 +116,6 @@
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ProjectExtensions><VisualStudio><UserProperties client-app_4package_1json__JsonSchema="https://json.schemastore.org/phraseapp.json" /></VisualStudio></ProjectExtensions>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
@using ErsatzTV.Core.Domain.Filler
|
||||
@using ErsatzTV.Application.Channels
|
||||
@implements IDisposable
|
||||
@inject NavigationManager _navigationManager
|
||||
@inject ILogger<ChannelEditor> _logger
|
||||
@inject ISnackbar _snackbar
|
||||
@inject IMediator _mediator
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<ChannelEditor> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<div style="max-width: 400px;">
|
||||
@@ -71,6 +71,10 @@
|
||||
<MudSelectItem Value="@(ChannelSubtitleMode.Default)">Default</MudSelectItem>
|
||||
<MudSelectItem Value="@(ChannelSubtitleMode.Any)">Any</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudSelect Class="mt-3" Label="Music Video Credits Mode" @bind-Value="_model.MusicVideoCreditsMode" For="@(() => _model.MusicVideoCreditsMode)">
|
||||
<MudSelectItem Value="@(ChannelMusicVideoCreditsMode.None)">None</MudSelectItem>
|
||||
<MudSelectItem Value="@(ChannelMusicVideoCreditsMode.GenerateSubtitles)">Generate Subtitles</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
|
||||
<MudItem xs="6">
|
||||
<InputFile id="fileInput" OnChange="UploadLogo" hidden/>
|
||||
@@ -144,13 +148,13 @@
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await LoadFFmpegProfiles(_cts.Token);
|
||||
_availableCultures = await _mediator.Send(new GetAllLanguageCodes(), _cts.Token);
|
||||
_availableCultures = await Mediator.Send(new GetAllLanguageCodes(), _cts.Token);
|
||||
await LoadWatermarks(_cts.Token);
|
||||
await LoadFillerPresets(_cts.Token);
|
||||
|
||||
if (Id.HasValue)
|
||||
{
|
||||
Option<ChannelViewModel> maybeChannel = await _mediator.Send(new GetChannelById(Id.Value), _cts.Token);
|
||||
Option<ChannelViewModel> maybeChannel = await Mediator.Send(new GetChannelById(Id.Value), _cts.Token);
|
||||
maybeChannel.Match(
|
||||
channelViewModel =>
|
||||
{
|
||||
@@ -167,15 +171,16 @@
|
||||
_model.FallbackFillerId = channelViewModel.FallbackFillerId;
|
||||
_model.PreferredSubtitleLanguageCode = channelViewModel.PreferredSubtitleLanguageCode;
|
||||
_model.SubtitleMode = channelViewModel.SubtitleMode;
|
||||
_model.MusicVideoCreditsMode = channelViewModel.MusicVideoCreditsMode;
|
||||
},
|
||||
() => _navigationManager.NavigateTo("404"));
|
||||
() => NavigationManager.NavigateTo("404"));
|
||||
}
|
||||
else
|
||||
{
|
||||
FFmpegSettingsViewModel ffmpegSettings = await _mediator.Send(new GetFFmpegSettings(), _cts.Token);
|
||||
FFmpegSettingsViewModel ffmpegSettings = await Mediator.Send(new GetFFmpegSettings(), _cts.Token);
|
||||
|
||||
// TODO: command for new channel
|
||||
IEnumerable<int> channelNumbers = await _mediator.Send(new GetAllChannels(), _cts.Token)
|
||||
IEnumerable<int> channelNumbers = await Mediator.Send(new GetAllChannels(), _cts.Token)
|
||||
.Map(list => list.Map(c => int.TryParse(c.Number.Split(".").Head(), out int result) ? result : 0));
|
||||
int maxNumber = Optional(channelNumbers).Flatten().DefaultIfEmpty(0).Max();
|
||||
_model.Number = (maxNumber + 1).ToString();
|
||||
@@ -195,13 +200,13 @@
|
||||
private bool IsEdit => Id.HasValue;
|
||||
|
||||
private async Task LoadFFmpegProfiles(CancellationToken cancellationToken) =>
|
||||
_ffmpegProfiles = await _mediator.Send(new GetAllFFmpegProfiles(), cancellationToken);
|
||||
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles(), cancellationToken);
|
||||
|
||||
private async Task LoadWatermarks(CancellationToken cancellationToken) =>
|
||||
_watermarks = await _mediator.Send(new GetAllWatermarks(), cancellationToken);
|
||||
_watermarks = await Mediator.Send(new GetAllWatermarks(), cancellationToken);
|
||||
|
||||
private async Task LoadFillerPresets(CancellationToken cancellationToken) =>
|
||||
_fillerPresets = await _mediator.Send(new GetAllFillerPresets(), cancellationToken)
|
||||
_fillerPresets = await Mediator.Send(new GetAllFillerPresets(), cancellationToken)
|
||||
.Map(list => list.Filter(vm => vm.FillerKind == FillerKind.Fallback).ToList());
|
||||
|
||||
private async Task HandleSubmitAsync()
|
||||
@@ -210,16 +215,16 @@
|
||||
if (_editContext.Validate())
|
||||
{
|
||||
Seq<BaseError> errorMessage = IsEdit ?
|
||||
(await _mediator.Send(_model.ToUpdate(), _cts.Token)).LeftToSeq() :
|
||||
(await _mediator.Send(_model.ToCreate(), _cts.Token)).LeftToSeq();
|
||||
(await Mediator.Send(_model.ToUpdate(), _cts.Token)).LeftToSeq() :
|
||||
(await Mediator.Send(_model.ToCreate(), _cts.Token)).LeftToSeq();
|
||||
|
||||
errorMessage.HeadOrNone().Match(
|
||||
error =>
|
||||
{
|
||||
_snackbar.Add(error.Value, Severity.Error);
|
||||
_logger.LogError("Unexpected error saving channel: {Error}", error.Value);
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error saving channel: {Error}", error.Value);
|
||||
},
|
||||
() => _navigationManager.NavigateTo("/channels"));
|
||||
() => NavigationManager.NavigateTo("/channels"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +233,7 @@
|
||||
try
|
||||
{
|
||||
Either<BaseError, string> maybeCacheFileName =
|
||||
await _mediator.Send(new SaveArtworkToDisk(e.File.OpenReadStream(10 * 1024 * 1024), ArtworkKind.Logo), _cts.Token);
|
||||
await Mediator.Send(new SaveArtworkToDisk(e.File.OpenReadStream(10 * 1024 * 1024), ArtworkKind.Logo), _cts.Token);
|
||||
maybeCacheFileName.Match(
|
||||
relativeFileName =>
|
||||
{
|
||||
@@ -237,21 +242,19 @@
|
||||
},
|
||||
error =>
|
||||
{
|
||||
Console.WriteLine($"error saving {error}");
|
||||
_snackbar.Add($"Unexpected error saving channel logo: {error.Value}", Severity.Error);
|
||||
_logger.LogError("Unexpected error saving channel logo: {Error}", error.Value);
|
||||
Snackbar.Add($"Unexpected error saving channel logo: {error.Value}", Severity.Error);
|
||||
Logger.LogError("Unexpected error saving channel logo: {Error}", error.Value);
|
||||
});
|
||||
}
|
||||
catch (IOException ex)
|
||||
catch (IOException)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
_snackbar.Add("Channel logo exceeds maximum allowed file size of 10 MB", Severity.Error);
|
||||
_logger.LogError("Channel logo exceeds maximum allowed file size of 10 MB");
|
||||
Snackbar.Add("Channel logo exceeds maximum allowed file size of 10 MB", Severity.Error);
|
||||
Logger.LogError("Channel logo exceeds maximum allowed file size of 10 MB");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_snackbar.Add($"Unexpected error saving channel logo: {ex.Message}", Severity.Error);
|
||||
_logger.LogError("Unexpected error saving channel logo: {Error}", ex.Message);
|
||||
Snackbar.Add($"Unexpected error saving channel logo: {ex.Message}", Severity.Error);
|
||||
Logger.LogError("Unexpected error saving channel logo: {Error}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,13 +19,14 @@ public class ResourceExtractorService : IHostedService
|
||||
await ExtractResource(assembly, "song_background_2.png", cancellationToken);
|
||||
await ExtractResource(assembly, "song_background_3.png", cancellationToken);
|
||||
await ExtractResource(assembly, "ErsatzTV.png", cancellationToken);
|
||||
await ExtractResource(assembly, "Roboto-Regular.ttf", cancellationToken);
|
||||
await ExtractResource(assembly, "OPTIKabel-Heavy.otf", cancellationToken);
|
||||
|
||||
await ExtractFontResource(assembly, "Roboto-Regular.ttf", cancellationToken);
|
||||
await ExtractFontResource(assembly, "OPTIKabel-Heavy.otf", cancellationToken);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private async Task ExtractResource(Assembly assembly, string name, CancellationToken cancellationToken)
|
||||
private static async Task ExtractResource(Assembly assembly, string name, CancellationToken cancellationToken)
|
||||
{
|
||||
await using Stream resource = assembly.GetManifestResourceStream($"ErsatzTV.Resources.{name}");
|
||||
if (resource != null)
|
||||
@@ -35,4 +36,21 @@ public class ResourceExtractorService : IHostedService
|
||||
await resource.CopyToAsync(fs, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ExtractFontResource(Assembly assembly, string name, CancellationToken cancellationToken)
|
||||
{
|
||||
await using Stream resource = assembly.GetManifestResourceStream($"ErsatzTV.Resources.{name}");
|
||||
if (resource != null)
|
||||
{
|
||||
await using FileStream fs = File.Create(
|
||||
Path.Combine(FileSystemLayout.ResourcesCacheFolder, name));
|
||||
await resource.CopyToAsync(fs, cancellationToken);
|
||||
|
||||
resource.Position = 0;
|
||||
|
||||
await using FileStream fontCacheFileStream = File.Create(
|
||||
Path.Combine(FileSystemLayout.FontsCacheFolder, name));
|
||||
await resource.CopyToAsync(fontCacheFileStream, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +401,7 @@ public class Startup
|
||||
services.AddScoped<FFmpegProcessService>();
|
||||
|
||||
services.AddScoped<ISongVideoGenerator, SongVideoGenerator>();
|
||||
services.AddScoped<IMusicVideoCreditsGenerator, MusicVideoCreditsGenerator>();
|
||||
services.AddScoped<HlsSessionWorker>();
|
||||
services.AddScoped<IGitHubApiClient, GitHubApiClient>();
|
||||
services.AddScoped<IHtmlSanitizer, HtmlSanitizer>(
|
||||
|
||||
@@ -18,6 +18,7 @@ public class ChannelEditViewModel
|
||||
public int? FallbackFillerId { get; set; }
|
||||
public string PreferredSubtitleLanguageCode { get; set; }
|
||||
public ChannelSubtitleMode SubtitleMode { get; set; }
|
||||
public ChannelMusicVideoCreditsMode MusicVideoCreditsMode { get; set; }
|
||||
|
||||
public UpdateChannel ToUpdate() =>
|
||||
new(
|
||||
@@ -33,7 +34,8 @@ public class ChannelEditViewModel
|
||||
WatermarkId,
|
||||
FallbackFillerId,
|
||||
PreferredSubtitleLanguageCode,
|
||||
SubtitleMode);
|
||||
SubtitleMode,
|
||||
MusicVideoCreditsMode);
|
||||
|
||||
public CreateChannel ToCreate() =>
|
||||
new(
|
||||
@@ -48,5 +50,6 @@ public class ChannelEditViewModel
|
||||
WatermarkId,
|
||||
FallbackFillerId,
|
||||
PreferredSubtitleLanguageCode,
|
||||
SubtitleMode);
|
||||
SubtitleMode,
|
||||
MusicVideoCreditsMode);
|
||||
}
|
||||
|
||||
@@ -21,3 +21,8 @@ pnpm-debug.log*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# generated files
|
||||
src/assets/css/*.css
|
||||
src/models/*.js
|
||||
src/models/*.js.map
|
||||
|
||||
Generated
+46
@@ -9,16 +9,19 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@mdi/font": "5.9.55",
|
||||
"@sweetalert2/theme-dark": "^5.0.11",
|
||||
"axios": "^0.26.1",
|
||||
"core-js": "^3.8.3",
|
||||
"pinia": "^2.0.11",
|
||||
"pinia-plugin-persistedstate": "^1.5.1",
|
||||
"roboto-fontface": "*",
|
||||
"sweetalert2": "^11.4.17",
|
||||
"vue": "^2.6.14",
|
||||
"vue-class-component": "^7.2.6",
|
||||
"vue-i18n": "^8.26.3",
|
||||
"vue-property-decorator": "^9.1.2",
|
||||
"vue-router": "^3.2.0",
|
||||
"vue-sweetalert2": "^5.0.5",
|
||||
"vuetify": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1965,6 +1968,11 @@
|
||||
"integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@sweetalert2/theme-dark": {
|
||||
"version": "5.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@sweetalert2/theme-dark/-/theme-dark-5.0.11.tgz",
|
||||
"integrity": "sha512-yw42SqvvFc1ObRVLNLSo5HpJo3vDTYlpS2ba+JkWHRcVIEXDnEOaDfAVRtDPSK75GJkvdz9IdfUrc18zdmiE2w=="
|
||||
},
|
||||
"node_modules/@trysound/sax": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
||||
@@ -10843,6 +10851,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/sweetalert2": {
|
||||
"version": "11.4.17",
|
||||
"resolved": "https://registry.npmjs.org/sweetalert2/-/sweetalert2-11.4.17.tgz",
|
||||
"integrity": "sha512-z0iQW5fDdvtoDNle3iHTKunsuNWgPaMifVu0GPtdguR0uVPrvg9gg9bnSQeJ4tx3C7FwJOEnRshxrs6fFItRvw==",
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://sweetalert2.github.io/#donations"
|
||||
}
|
||||
},
|
||||
"node_modules/table": {
|
||||
"version": "6.8.0",
|
||||
"resolved": "https://registry.npmjs.org/table/-/table-6.8.0.tgz",
|
||||
@@ -11936,6 +11953,17 @@
|
||||
"integrity": "sha1-M7QHd3VMZDJXPBIMw4CLvRDUfwQ=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vue-sweetalert2": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/vue-sweetalert2/-/vue-sweetalert2-5.0.5.tgz",
|
||||
"integrity": "sha512-Q7+TjQxNwGruT2jfPaSKMz18HHWs81r4umEwlrrtOf/QL5Iwl1T+tUaNkcvKB7Rf9rYftubt7NnOMjfDMnDH3A==",
|
||||
"dependencies": {
|
||||
"sweetalert2": "11.x"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-template-compiler": {
|
||||
"version": "2.6.14",
|
||||
"resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.6.14.tgz",
|
||||
@@ -14142,6 +14170,11 @@
|
||||
"integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==",
|
||||
"dev": true
|
||||
},
|
||||
"@sweetalert2/theme-dark": {
|
||||
"version": "5.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@sweetalert2/theme-dark/-/theme-dark-5.0.11.tgz",
|
||||
"integrity": "sha512-yw42SqvvFc1ObRVLNLSo5HpJo3vDTYlpS2ba+JkWHRcVIEXDnEOaDfAVRtDPSK75GJkvdz9IdfUrc18zdmiE2w=="
|
||||
},
|
||||
"@trysound/sax": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
||||
@@ -20737,6 +20770,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"sweetalert2": {
|
||||
"version": "11.4.17",
|
||||
"resolved": "https://registry.npmjs.org/sweetalert2/-/sweetalert2-11.4.17.tgz",
|
||||
"integrity": "sha512-z0iQW5fDdvtoDNle3iHTKunsuNWgPaMifVu0GPtdguR0uVPrvg9gg9bnSQeJ4tx3C7FwJOEnRshxrs6fFItRvw=="
|
||||
},
|
||||
"table": {
|
||||
"version": "6.8.0",
|
||||
"resolved": "https://registry.npmjs.org/table/-/table-6.8.0.tgz",
|
||||
@@ -21552,6 +21590,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"vue-sweetalert2": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/vue-sweetalert2/-/vue-sweetalert2-5.0.5.tgz",
|
||||
"integrity": "sha512-Q7+TjQxNwGruT2jfPaSKMz18HHWs81r4umEwlrrtOf/QL5Iwl1T+tUaNkcvKB7Rf9rYftubt7NnOMjfDMnDH3A==",
|
||||
"requires": {
|
||||
"sweetalert2": "11.x"
|
||||
}
|
||||
},
|
||||
"vue-template-compiler": {
|
||||
"version": "2.6.14",
|
||||
"resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.6.14.tgz",
|
||||
|
||||
@@ -10,16 +10,19 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "5.9.55",
|
||||
"@sweetalert2/theme-dark": "^5.0.11",
|
||||
"axios": "^0.26.1",
|
||||
"core-js": "^3.8.3",
|
||||
"pinia": "^2.0.11",
|
||||
"pinia-plugin-persistedstate": "^1.5.1",
|
||||
"roboto-fontface": "*",
|
||||
"sweetalert2": "^11.4.17",
|
||||
"vue": "^2.6.14",
|
||||
"vue-class-component": "^7.2.6",
|
||||
"vue-i18n": "^8.26.3",
|
||||
"vue-property-decorator": "^9.1.2",
|
||||
"vue-router": "^3.2.0",
|
||||
"vue-sweetalert2": "^5.0.5",
|
||||
"vuetify": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.swal2-container.swal2-center > .swal2-popup {
|
||||
background-color: #1E1E1E !important;
|
||||
border-color: green;
|
||||
border-width: thin;
|
||||
border-style: solid;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.swal2-html-container, .swal2-title {
|
||||
color: white !important;
|
||||
font-family:Cambria;
|
||||
}
|
||||
@@ -2,14 +2,14 @@
|
||||
<v-list nav dense>
|
||||
<span v-for="(nav, i) in navigation" :key="i">
|
||||
<SideBarMenuItem
|
||||
v-if="!nav.children"
|
||||
v-if="(!nav.children || !nav.showchildren) && !nav.meta.hidden"
|
||||
:name="nav.name"
|
||||
:path="nav.path"
|
||||
:icon="nav.meta.icon"
|
||||
:disabled="nav.meta.disabled"
|
||||
/>
|
||||
<SideBarMenuItemExpandable
|
||||
v-else
|
||||
v-else-if="nav.children"
|
||||
@click.native="disableMiniNavigation()"
|
||||
:name="nav.name"
|
||||
:icon="nav.meta.icon"
|
||||
|
||||
@@ -50,6 +50,10 @@ export default {
|
||||
children: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
nonmenuchildren: {
|
||||
type: Array,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
data: () => ({
|
||||
|
||||
@@ -87,6 +87,13 @@
|
||||
},
|
||||
"ffmpeg-profiles": {
|
||||
"title": "FFmpeg Profiles",
|
||||
"add-profile": "Add FFmpeg Profile",
|
||||
"actions": "Actions",
|
||||
"delete-dialog-title": "Are you sure?",
|
||||
"delete-dialog-text": "Delete {profileName} FFmpeg Profile?",
|
||||
"no": "No",
|
||||
"yes": "Yes",
|
||||
"profile-deleted": "FFmpeg Profile deleted!",
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"resolution": "Resolution",
|
||||
@@ -94,6 +101,31 @@
|
||||
"audio": "Audio"
|
||||
}
|
||||
},
|
||||
"edit-ffmpeg-profile": {
|
||||
"General": "General",
|
||||
"Video": "Video",
|
||||
"Audio": "Audio",
|
||||
"Name": "Name",
|
||||
"thread-count": "Thread Count",
|
||||
"preferred-resolution": "Preferred Resolution",
|
||||
"format": "Format",
|
||||
"bitrate": "Bitrate",
|
||||
"buffer-size": "Buffer Size",
|
||||
"hardware-acceleration": "Hardware Acceleration",
|
||||
"vaapi-driver": "VAAPI Driver",
|
||||
"vaapi-device": "VAAPI Device",
|
||||
"normalize-framerate": "Normalize Framerate",
|
||||
"auto-deinterlace-video": "Auto Deinterlace Video",
|
||||
"channels": "Channels",
|
||||
"sample-rate": "Sample Rate",
|
||||
"normalize-loudness": "Normalize Loudness",
|
||||
"save-profile": "Save Profile",
|
||||
"cancel": "Cancel",
|
||||
"help": "Help",
|
||||
"add-profile": "Add FFmpeg Profile",
|
||||
"edit-profile": "Edit FFmpeg Profile",
|
||||
"profile-saved": "FFmpeg Profile Saved!"
|
||||
},
|
||||
"watermarks": {
|
||||
"title": "Watermarks"
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"$vuetify": {
|
||||
"badge": "Distintivo",
|
||||
"close": "Fechar",
|
||||
"Actions": "Ações",
|
||||
"dataIterator": {
|
||||
"noResultsText": "Nenhum dado encontrado",
|
||||
"loadingText": "Carregando itens..."
|
||||
@@ -87,10 +88,45 @@
|
||||
},
|
||||
"ffmpeg-profiles": {
|
||||
"title": "Perfis FFmpeg",
|
||||
"add-profile": "Adicionar perfil FFmpeg",
|
||||
"actions": "Ações",
|
||||
"delete-dialog-title": "Tem certeza?",
|
||||
"delete-dialog-text": "Excluir perfil {profileName} do FFmpeg?",
|
||||
"no": "Não",
|
||||
"yes": "Sim",
|
||||
"profile-deleted": "Perfil do FFmpeg excluído!",
|
||||
"table": {
|
||||
"name": "Nome"
|
||||
"name": "Nome",
|
||||
"resolution": "Resolução",
|
||||
"video": "Vídeo",
|
||||
"audio": "Áudio"
|
||||
}
|
||||
},
|
||||
"edit-ffmpeg-profile": {
|
||||
"General": "Em geral",
|
||||
"Video": "Vídeo",
|
||||
"Audio": "Áudio",
|
||||
"Name": "Nome",
|
||||
"thread-count": "Contagem de fios",
|
||||
"preferred-resolution": "Resolução preferencial",
|
||||
"format": "Formato",
|
||||
"bitrate": "Taxa de bits",
|
||||
"buffer-size": "Tamanho do buffer",
|
||||
"hardware-acceleration": "Aceleraçao do hardware",
|
||||
"vaapi-driver": "Driver VAAPI",
|
||||
"vaapi-device": "Dispositivo VAAPI",
|
||||
"normalize-framerate": "Normalizar taxa de quadros",
|
||||
"auto-deinterlace-video": "Vídeo de desentrelaçamento automático",
|
||||
"channels": "Canais",
|
||||
"sample-rate": "Taxa de amostragem",
|
||||
"normalize-loudness": "Normalizar volume",
|
||||
"save-profile": "Salvar perfil",
|
||||
"cancel": "Cancelar",
|
||||
"help": "Ajuda",
|
||||
"add-profile": "Adicionar perfil FFmpeg",
|
||||
"edit-profile": "Editar perfil do FFmpeg",
|
||||
"profile-saved": "Perfil do FFmpeg salvo!"
|
||||
},
|
||||
"watermarks": {
|
||||
"title": "Marcas d'água"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Vue from 'vue';
|
||||
import Vue from 'vue';
|
||||
import App from './App.vue';
|
||||
import vuetify from './plugins/vuetify';
|
||||
import router from './router';
|
||||
@@ -8,16 +8,26 @@ import autoPageTitleMixin from './mixins/autoPageTitle';
|
||||
import 'roboto-fontface/css/roboto/roboto-fontface.css';
|
||||
import '@mdi/font/css/materialdesignicons.css';
|
||||
import i18n from './plugins/i18n';
|
||||
import VueSweetalert2 from 'vue-sweetalert2';
|
||||
import 'sweetalert2/dist/sweetalert2.min.css';
|
||||
import './assets/css/global.scss';
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
|
||||
Vue.use(PiniaVuePlugin);
|
||||
const pinia = createPinia();
|
||||
pinia.use(piniaPluginPersistedstate);
|
||||
|
||||
// Mixin to automate the page title when navigating... Will default to "ErsatzTV if no title value exported from page.
|
||||
Vue.mixin(autoPageTitleMixin);
|
||||
|
||||
const options = {
|
||||
confirmButtonColor: '#1E1E1E',
|
||||
cancelButtonColor: '#1E1E1E',
|
||||
background: '#1E1E1E',
|
||||
iconColor: '#4CAF50'
|
||||
};
|
||||
Vue.use(VueSweetalert2, options);
|
||||
|
||||
new Vue({
|
||||
vuetify,
|
||||
router,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface FFmpegFullProfile {
|
||||
Id: number;
|
||||
name: string;
|
||||
threadCount: number;
|
||||
hardwareAcceleration: number;
|
||||
vaapiDriver: number;
|
||||
vaapiDevice: string;
|
||||
resolutionId: number;
|
||||
videoFormat: number;
|
||||
videoBitrate: number;
|
||||
videoBufferSize: number;
|
||||
audioFormat: number;
|
||||
audioBitrate: number;
|
||||
audioBufferSize: number;
|
||||
normalizeLoudness: boolean;
|
||||
audioChannels: number;
|
||||
audioSampleRate: number;
|
||||
normalizeFramerate: boolean;
|
||||
deinterlaceVideo: boolean;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import VueRouter from 'vue-router';
|
||||
import HomePage from '../views/HomePage.vue';
|
||||
import ChannelsPage from '../views/ChannelsPage.vue';
|
||||
import FFmpegProfilesPage from '../views/FFmpegProfilesPage.vue';
|
||||
import AddEditFFmpegProfilePage from '../views/AddEditFFmpegProfilePage.vue';
|
||||
|
||||
Vue.use(VueRouter);
|
||||
|
||||
@@ -32,7 +33,8 @@ const routes = [
|
||||
meta: {
|
||||
icon: 'mdi-video-input-component',
|
||||
disabled: false
|
||||
}
|
||||
},
|
||||
showchildren: false
|
||||
},
|
||||
{
|
||||
path: '/watermarks',
|
||||
@@ -49,6 +51,7 @@ const routes = [
|
||||
icon: 'mdi-server-network',
|
||||
disabled: false
|
||||
},
|
||||
showchildren: true,
|
||||
children: [
|
||||
{
|
||||
path: '/sources/local',
|
||||
@@ -91,6 +94,7 @@ const routes = [
|
||||
icon: 'mdi-cog',
|
||||
disabled: false
|
||||
},
|
||||
showchildren: true,
|
||||
children: [
|
||||
{
|
||||
path: '/media/libraries',
|
||||
@@ -157,6 +161,7 @@ const routes = [
|
||||
icon: 'mdi-format-list-bulleted',
|
||||
disabled: false
|
||||
},
|
||||
showchildren: true,
|
||||
children: [
|
||||
{
|
||||
path: '/lists/collections',
|
||||
@@ -215,6 +220,26 @@ const routes = [
|
||||
icon: 'mdi-card-text',
|
||||
disabled: true
|
||||
}
|
||||
},
|
||||
//hidden routes - used for non-menu routes
|
||||
{
|
||||
path: '/add-ffmpeg-profile',
|
||||
name: 'add-ffmpeg-profile',
|
||||
component: AddEditFFmpegProfilePage,
|
||||
meta: {
|
||||
disabled: false,
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/edit-ffmpeg-profile',
|
||||
name: 'edit-ffmpeg',
|
||||
component: AddEditFFmpegProfilePage,
|
||||
meta: {
|
||||
disabled: false,
|
||||
hidden: true
|
||||
},
|
||||
props: (route) => ({ query: route.query.id })
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AbstractApiService } from './AbstractApiService';
|
||||
import { FFmpegProfile } from '@/models/FFmpegProfile';
|
||||
import { FFmpegFullProfile } from '../models/FFmpegFullProfile';
|
||||
|
||||
class FFmpegProfileApiService extends AbstractApiService {
|
||||
public constructor() {
|
||||
@@ -12,6 +13,101 @@ class FFmpegProfileApiService extends AbstractApiService {
|
||||
.then(this.handleResponse.bind(this))
|
||||
.catch(this.handleError.bind(this));
|
||||
}
|
||||
|
||||
public getOne(id: string): Promise<FFmpegFullProfile[]> {
|
||||
return this.http
|
||||
.get('/api/ffmpeg/profiles/' + id)
|
||||
.then(this.handleResponse.bind(this))
|
||||
.catch(this.handleError.bind(this));
|
||||
}
|
||||
|
||||
public newFFmpegProfile(
|
||||
Name: string,
|
||||
ThreadCount: number,
|
||||
HardwareAcceleration: number,
|
||||
VaapiDriver: number,
|
||||
VaapiDevice: string,
|
||||
ResolutionId: number,
|
||||
VideoFormat: number,
|
||||
VideoBitrate: number,
|
||||
VideoBufferSize: number,
|
||||
AudioFormat: number,
|
||||
AudioBitrate: number,
|
||||
AudioBufferSize: number,
|
||||
NormalizeLoudness: boolean,
|
||||
AudioChannels: number,
|
||||
AudioSampleRate: number,
|
||||
NormalizeFramerate: boolean,
|
||||
DeinterlaceVideo: boolean
|
||||
) {
|
||||
const data = {
|
||||
Name: Name,
|
||||
ThreadCount: ThreadCount,
|
||||
HardwareAcceleration: HardwareAcceleration,
|
||||
VaapiDriver: VaapiDriver,
|
||||
VaapiDevice: VaapiDevice,
|
||||
ResolutionId: ResolutionId,
|
||||
VideoFormat: VideoFormat,
|
||||
VideoBitrate: VideoBitrate,
|
||||
VideoBufferSize: VideoBufferSize,
|
||||
AudioFormat: AudioFormat,
|
||||
AudioBitrate: AudioBitrate,
|
||||
AudioBufferSize: AudioBufferSize,
|
||||
NormalizeLoudness: NormalizeLoudness,
|
||||
AudioChannels: AudioChannels,
|
||||
AudioSampleRate: AudioSampleRate,
|
||||
NormalizeFramerate: NormalizeFramerate,
|
||||
DeinterlaceVideo: DeinterlaceVideo
|
||||
};
|
||||
this.http.post('/api/ffmpeg/profiles/new', data);
|
||||
}
|
||||
|
||||
public updateFFmpegProfile(
|
||||
Id: number,
|
||||
Name: string,
|
||||
ThreadCount: number,
|
||||
HardwareAcceleration: number,
|
||||
VaapiDriver: number,
|
||||
VaapiDevice: string,
|
||||
ResolutionId: number,
|
||||
VideoFormat: number,
|
||||
VideoBitrate: number,
|
||||
VideoBufferSize: number,
|
||||
AudioFormat: number,
|
||||
AudioBitrate: number,
|
||||
AudioBufferSize: number,
|
||||
NormalizeLoudness: boolean,
|
||||
AudioChannels: number,
|
||||
AudioSampleRate: number,
|
||||
NormalizeFramerate: boolean,
|
||||
DeinterlaceVideo: boolean
|
||||
) {
|
||||
const data = {
|
||||
Id: Id,
|
||||
Name: Name,
|
||||
ThreadCount: ThreadCount,
|
||||
HardwareAcceleration: HardwareAcceleration,
|
||||
VaapiDriver: VaapiDriver,
|
||||
VaapiDevice: VaapiDevice,
|
||||
ResolutionId: ResolutionId,
|
||||
VideoFormat: VideoFormat,
|
||||
VideoBitrate: VideoBitrate,
|
||||
VideoBufferSize: VideoBufferSize,
|
||||
AudioFormat: AudioFormat,
|
||||
AudioBitrate: AudioBitrate,
|
||||
AudioBufferSize: AudioBufferSize,
|
||||
NormalizeLoudness: NormalizeLoudness,
|
||||
AudioChannels: AudioChannels,
|
||||
AudioSampleRate: AudioSampleRate,
|
||||
NormalizeFramerate: NormalizeFramerate,
|
||||
DeinterlaceVideo: DeinterlaceVideo
|
||||
};
|
||||
this.http.put('/api/ffmpeg/profiles/update', data);
|
||||
}
|
||||
|
||||
public deleteRecord(id: string) {
|
||||
this.http.delete('/api/ffmpeg/delete/' + id);
|
||||
}
|
||||
}
|
||||
|
||||
export const ffmpegProfileApiService: FFmpegProfileApiService =
|
||||
|
||||
@@ -0,0 +1,909 @@
|
||||
<template>
|
||||
<div id="AddEditFFmpegProfile">
|
||||
<h1 id="title" class="mx-4" />
|
||||
<v-divider color="success" class="ma-2"></v-divider>
|
||||
<v-app>
|
||||
<v-form
|
||||
ref="form"
|
||||
v-model="isFormValid"
|
||||
id="ffmpegForm"
|
||||
lazy-validation
|
||||
>
|
||||
<v-container>
|
||||
<v-row justify="center">
|
||||
<v-flex shrink class="pb-10">
|
||||
<v-container style="max-height: 80px">
|
||||
<v-row justify="center">
|
||||
<h2 class="mx">
|
||||
{{ $t('edit-ffmpeg-profile.General') }}
|
||||
</h2>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<v-container style="max-height: 80px">
|
||||
<v-text-field
|
||||
v-model="newProfile.name"
|
||||
:rules="textRules"
|
||||
:label="$t('edit-ffmpeg-profile.Name')"
|
||||
required
|
||||
></v-text-field>
|
||||
</v-container>
|
||||
<v-container
|
||||
class="d-flex"
|
||||
style="max-height: 80px"
|
||||
>
|
||||
<v-text-field
|
||||
ref="myThreadCount"
|
||||
v-model="threadCount"
|
||||
:label="
|
||||
$t('edit-ffmpeg-profile.thread-count')
|
||||
"
|
||||
:rules="validInt"
|
||||
number
|
||||
required
|
||||
></v-text-field>
|
||||
<v-tooltip v-model="threadCountShow" top>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-icon
|
||||
class="pl-2"
|
||||
color="grey"
|
||||
v-ripple="false"
|
||||
:retain-focus="false"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
mdi-help-circle-outline
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>Recommended Thread Count: 0</span>
|
||||
</v-tooltip>
|
||||
</v-container>
|
||||
<v-container style="max-height: 40px">
|
||||
<v-select
|
||||
v-model="selectedResolution"
|
||||
@change="preferredResolutionChange"
|
||||
:items="preferredResolutions"
|
||||
item-value="id"
|
||||
item-text="name"
|
||||
:label="
|
||||
$t(
|
||||
'edit-ffmpeg-profile.preferred-resolution'
|
||||
)
|
||||
"
|
||||
required
|
||||
></v-select>
|
||||
</v-container>
|
||||
</v-flex>
|
||||
<v-divider
|
||||
style="max-height: 500px"
|
||||
vertical
|
||||
color="grey"
|
||||
></v-divider>
|
||||
<v-flex shrink class="pb-10">
|
||||
<v-container style="max-height: 80px">
|
||||
<v-row justify="center">
|
||||
<h2 class="mx">
|
||||
{{ $t('edit-ffmpeg-profile.Video') }}
|
||||
</h2>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<v-container
|
||||
class="d-flex"
|
||||
style="max-height: 80px"
|
||||
>
|
||||
<v-select
|
||||
v-model="selectedVideoFormat"
|
||||
@change="videoFormatChanged"
|
||||
:items="videoFormats"
|
||||
item-value="id"
|
||||
item-text="name"
|
||||
id="videoFormatSelector"
|
||||
:label="$t('edit-ffmpeg-profile.format')"
|
||||
required
|
||||
></v-select>
|
||||
<v-tooltip v-model="videoFormatShow" top>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-icon
|
||||
class="pl-2"
|
||||
color="grey"
|
||||
v-ripple="false"
|
||||
:retain-focus="false"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
mdi-help-circle-outline
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>Recommended Thread Count: 0</span>
|
||||
</v-tooltip>
|
||||
</v-container>
|
||||
<v-container
|
||||
class="d-flex"
|
||||
style="max-height: 80px"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="newProfile.videoBitRate"
|
||||
:label="$t('edit-ffmpeg-profile.bitrate')"
|
||||
:rules="validIntNonZero"
|
||||
required
|
||||
></v-text-field>
|
||||
<v-tooltip v-model="videoBitRateShow" top>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-icon
|
||||
class="pl-2"
|
||||
color="grey"
|
||||
v-ripple="false"
|
||||
:retain-focus="false"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
mdi-help-circle-outline
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>Recommended Thread Count: 0</span>
|
||||
</v-tooltip>
|
||||
</v-container>
|
||||
<v-container
|
||||
class="d-flex"
|
||||
style="max-height: 80px"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="newProfile.videoBufferSize"
|
||||
:label="
|
||||
$t('edit-ffmpeg-profile.buffer-size')
|
||||
"
|
||||
:rules="validIntNonZero"
|
||||
required
|
||||
></v-text-field>
|
||||
<v-tooltip v-model="videoBufferSizeShow" top>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-icon
|
||||
class="pl-2"
|
||||
color="grey"
|
||||
v-ripple="false"
|
||||
:retain-focus="false"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
mdi-help-circle-outline
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>Recommended Thread Count: 0</span>
|
||||
</v-tooltip>
|
||||
</v-container>
|
||||
<v-container
|
||||
class="d-flex"
|
||||
style="max-height: 80px"
|
||||
>
|
||||
<v-select
|
||||
v-model="selectedHardwareAcceleration"
|
||||
@change="hardwareAccelerationChanged"
|
||||
:items="hardwareAccelerations"
|
||||
item-value="id"
|
||||
item-text="name"
|
||||
:label="
|
||||
$t(
|
||||
'edit-ffmpeg-profile.hardware-acceleration'
|
||||
)
|
||||
"
|
||||
required
|
||||
></v-select>
|
||||
<v-tooltip
|
||||
v-model="hardwareAccelerationShow"
|
||||
top
|
||||
>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-icon
|
||||
class="pl-2"
|
||||
color="grey"
|
||||
v-ripple="false"
|
||||
:retain-focus="false"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
mdi-help-circle-outline
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>Recommended Thread Count: 0</span>
|
||||
</v-tooltip>
|
||||
</v-container>
|
||||
<v-container
|
||||
class="d-flex"
|
||||
style="max-height: 80px"
|
||||
>
|
||||
<v-select
|
||||
v-model="selectedVaapiDriver"
|
||||
@change="vaapiDriverChanged"
|
||||
:items="vaapiDrivers"
|
||||
:disabled="vaapiDriverDisabled"
|
||||
item-value="id"
|
||||
item-text="name"
|
||||
:label="
|
||||
$t('edit-ffmpeg-profile.vaapi-driver')
|
||||
"
|
||||
required
|
||||
></v-select>
|
||||
<v-tooltip v-model="vaapiDriverShow" top>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-icon
|
||||
class="pl-2"
|
||||
color="grey"
|
||||
v-ripple="false"
|
||||
:retain-focus="false"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
mdi-help-circle-outline
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>Recommended Thread Count: 0</span>
|
||||
</v-tooltip>
|
||||
</v-container>
|
||||
<v-container
|
||||
class="d-flex"
|
||||
style="max-height: 80px"
|
||||
>
|
||||
<v-select
|
||||
v-model="selectedVaapiDevice"
|
||||
@change="vaapiDeviceChanged"
|
||||
:items="vaapiDevices"
|
||||
:disabled="vaapiDriverDisabled"
|
||||
:label="
|
||||
$t('edit-ffmpeg-profile.vaapi-device')
|
||||
"
|
||||
required
|
||||
></v-select>
|
||||
<v-tooltip v-model="vaapiDeviceShow" top>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-icon
|
||||
class="pl-2"
|
||||
color="grey"
|
||||
v-ripple="false"
|
||||
:retain-focus="false"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
mdi-help-circle-outline
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>Recommended Thread Count: 0</span>
|
||||
</v-tooltip>
|
||||
</v-container>
|
||||
<v-container
|
||||
class="d-flex"
|
||||
style="max-height: 50px"
|
||||
>
|
||||
<v-checkbox
|
||||
class="mr-2"
|
||||
v-model="normalizeFrameRate"
|
||||
:label="
|
||||
$t(
|
||||
'edit-ffmpeg-profile.normalize-framerate'
|
||||
)
|
||||
"
|
||||
color="green lighten-1"
|
||||
required
|
||||
></v-checkbox>
|
||||
<v-tooltip v-model="normalizeFrameRateShow" top>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-icon
|
||||
class="pt-8"
|
||||
color="grey"
|
||||
v-ripple="false"
|
||||
:retain-focus="false"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
mdi-help-circle-outline
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>Recommended Thread Count: 0</span>
|
||||
</v-tooltip>
|
||||
</v-container>
|
||||
<v-container
|
||||
class="d-flex"
|
||||
style="max-height: 50px"
|
||||
>
|
||||
<v-checkbox
|
||||
class="mr-2"
|
||||
v-model="autoDeinterlaceVideo"
|
||||
:label="
|
||||
$t(
|
||||
'edit-ffmpeg-profile.auto-deinterlace-video'
|
||||
)
|
||||
"
|
||||
color="green lighten-1"
|
||||
required
|
||||
></v-checkbox>
|
||||
<v-tooltip
|
||||
v-model="autoDeinterlaceVideoShow"
|
||||
top
|
||||
>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-icon
|
||||
class="pt-8"
|
||||
color="grey"
|
||||
v-ripple="false"
|
||||
:retain-focus="false"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
mdi-help-circle-outline
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>Recommended Thread Count: 0</span>
|
||||
</v-tooltip>
|
||||
</v-container>
|
||||
</v-flex>
|
||||
<v-divider
|
||||
style="max-height: 500px"
|
||||
vertical
|
||||
color="grey"
|
||||
></v-divider>
|
||||
<v-flex shrink class="pb-10">
|
||||
<v-container style="max-height: 80px">
|
||||
<v-row justify="center">
|
||||
<h2 class="mx">
|
||||
{{ $t('edit-ffmpeg-profile.Audio') }}
|
||||
</h2>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<v-container
|
||||
class="d-flex"
|
||||
style="max-height: 80px"
|
||||
>
|
||||
<v-select
|
||||
v-model="selectedAudioFormat"
|
||||
@change="audioFormatChanged"
|
||||
:items="audioFormats"
|
||||
item-value="id"
|
||||
item-text="name"
|
||||
:label="$t('edit-ffmpeg-profile.format')"
|
||||
ref="audioFormat"
|
||||
required
|
||||
></v-select>
|
||||
<v-tooltip v-model="audioFormatShow" top>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-icon
|
||||
class="pl-2"
|
||||
color="grey"
|
||||
v-ripple="false"
|
||||
:retain-focus="false"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
mdi-help-circle-outline
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>Recommended Thread Count: 0</span>
|
||||
</v-tooltip>
|
||||
</v-container>
|
||||
<v-container
|
||||
class="d-flex"
|
||||
style="max-height: 80px"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="newProfile.audioBitRate"
|
||||
:label="$t('edit-ffmpeg-profile.bitrate')"
|
||||
:rules="validIntNonZero"
|
||||
required
|
||||
></v-text-field>
|
||||
<v-tooltip v-model="audioBitRateShow" top>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-icon
|
||||
class="pl-2"
|
||||
color="grey"
|
||||
v-ripple="false"
|
||||
:retain-focus="false"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
mdi-help-circle-outline
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>Recommended Thread Count: 0</span>
|
||||
</v-tooltip>
|
||||
</v-container>
|
||||
<v-container
|
||||
class="d-flex"
|
||||
style="max-height: 80px"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="newProfile.audioBufferSize"
|
||||
:label="
|
||||
$t('edit-ffmpeg-profile.buffer-size')
|
||||
"
|
||||
:rules="validIntNonZero"
|
||||
required
|
||||
></v-text-field>
|
||||
<v-tooltip v-model="audioBufferSizeShow" top>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-icon
|
||||
class="pl-2"
|
||||
color="grey"
|
||||
v-ripple="false"
|
||||
:retain-focus="false"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
mdi-help-circle-outline
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>Recommended Thread Count: 0</span>
|
||||
</v-tooltip>
|
||||
</v-container>
|
||||
<v-container
|
||||
class="d-flex"
|
||||
style="max-height: 80px"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="newProfile.channels"
|
||||
:label="$t('edit-ffmpeg-profile.channels')"
|
||||
:rules="validIntNonZero"
|
||||
required
|
||||
></v-text-field>
|
||||
<v-tooltip v-model="audioChannelsShow" top>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-icon
|
||||
class="pl-2"
|
||||
color="grey"
|
||||
v-ripple="false"
|
||||
:retain-focus="false"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
mdi-help-circle-outline
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>Recommended Thread Count: 0</span>
|
||||
</v-tooltip>
|
||||
</v-container>
|
||||
<v-container
|
||||
class="d-flex"
|
||||
style="max-height: 80px"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="newProfile.audioSampleRate"
|
||||
:label="
|
||||
$t('edit-ffmpeg-profile.sample-rate')
|
||||
"
|
||||
:rules="validIntNonZero"
|
||||
required
|
||||
></v-text-field>
|
||||
<v-tooltip v-model="audioSampleRateShow" top>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-icon
|
||||
class="pl-2"
|
||||
color="grey"
|
||||
v-ripple="false"
|
||||
:retain-focus="false"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
mdi-help-circle-outline
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>Recommended Thread Count: 0</span>
|
||||
</v-tooltip>
|
||||
</v-container>
|
||||
<v-container
|
||||
class="d-flex"
|
||||
style="max-height: 80px"
|
||||
>
|
||||
<v-checkbox
|
||||
v-model="normalizeLoudness"
|
||||
:label="
|
||||
$t(
|
||||
'edit-ffmpeg-profile.normalize-loudness'
|
||||
)
|
||||
"
|
||||
color="green lighten-1"
|
||||
required
|
||||
></v-checkbox>
|
||||
<v-tooltip v-model="normalizeLoudnessShow" top>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-icon
|
||||
class="pl-2"
|
||||
color="grey"
|
||||
v-ripple="false"
|
||||
:retain-focus="false"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
mdi-help-circle-outline
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>Recommended Thread Count: 0</span>
|
||||
</v-tooltip>
|
||||
</v-container>
|
||||
<v-spacer style="height: 80px"></v-spacer>
|
||||
<v-container>
|
||||
<v-btn
|
||||
color="green lighten-1"
|
||||
class="ma-2"
|
||||
:disabled="!isFormValid"
|
||||
@click="saveFFmpegProfile()"
|
||||
>
|
||||
{{ $t('edit-ffmpeg-profile.save-profile') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="cancel"
|
||||
class="ma-2"
|
||||
@click="cancelAdd()"
|
||||
>
|
||||
{{ $t('edit-ffmpeg-profile.cancel') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="indigo accent-1"
|
||||
class="ma-2"
|
||||
@click="cancelAdd()"
|
||||
>
|
||||
{{ $t('edit-ffmpeg-profile.help') }}
|
||||
</v-btn>
|
||||
</v-container>
|
||||
</v-flex>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-form>
|
||||
</v-app>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Watch } from 'vue-property-decorator';
|
||||
import { ffmpegProfileApiService } from '@/services/FFmpegProfileService';
|
||||
|
||||
@Component
|
||||
export default class AddEditFFmpegProfile extends Vue {
|
||||
//@Name({ default: 'AddEditFFmpegProfile' }) AddEditFFmpegProfile!: string;
|
||||
//@Prop({ default: -1 }) private id!: number;
|
||||
public newProfile: any = {};
|
||||
private refForm: any = this.$refs.form;
|
||||
public isFormValid = false;
|
||||
public threadCountShow = false;
|
||||
public videoFormatShow = false;
|
||||
public videoBitRateShow = false;
|
||||
public videoBufferSizeShow = false;
|
||||
public hardwareAccelerationShow = false;
|
||||
public vaapiDriverShow = false;
|
||||
public vaapiDeviceShow = false;
|
||||
public normalizeFrameRateShow = false;
|
||||
public autoDeinterlaceVideoShow = false;
|
||||
public audioFormatShow = false;
|
||||
public audioBitRateShow = false;
|
||||
public audioBufferSizeShow = false;
|
||||
public audioChannelsShow = false;
|
||||
public audioSampleRateShow = false;
|
||||
public normalizeLoudnessShow = false;
|
||||
|
||||
public AddEditFFmpegProfile() {
|
||||
console.log('test');
|
||||
}
|
||||
|
||||
public audioFormats: [
|
||||
{ id: number; name: string },
|
||||
{ id: number; name: string }
|
||||
] = [
|
||||
{ id: 1, name: 'aac' },
|
||||
{ id: 2, name: 'ac3' }
|
||||
];
|
||||
|
||||
private _selectedAudioFormat: number = 2;
|
||||
public selectedAudioFormat: { id: number; name: string } = {
|
||||
id: 2,
|
||||
name: 'ac3'
|
||||
};
|
||||
|
||||
public preferredResolutions: [
|
||||
{ id: number; name: string },
|
||||
{ id: number; name: string },
|
||||
{ id: number; name: string },
|
||||
{ id: number; name: string }
|
||||
] = [
|
||||
{ id: 0, name: '720x480' },
|
||||
{ id: 1, name: '1280x720' },
|
||||
{ id: 2, name: '1920x1080' },
|
||||
{ id: 3, name: '3840x2160' }
|
||||
];
|
||||
|
||||
private _selectedResolution: number = 2;
|
||||
public selectedResolution: { id: number; name: string } = {
|
||||
id: 2,
|
||||
name: '1920x1080'
|
||||
};
|
||||
|
||||
public videoFormats: [
|
||||
{ id: number; name: string },
|
||||
{ id: number; name: string },
|
||||
{ id: number; name: string }
|
||||
] = [
|
||||
{ id: 1, name: 'h264' },
|
||||
{ id: 2, name: 'hevc' },
|
||||
{ id: 3, name: 'mpeg-2' }
|
||||
];
|
||||
|
||||
private _selectedVideoFormat: number = 1;
|
||||
public selectedVideoFormat: { id: number; name: string } = {
|
||||
id: 1,
|
||||
name: 'h264'
|
||||
};
|
||||
|
||||
public hardwareAccelerations: [
|
||||
{ id: number; name: string },
|
||||
{ id: number; name: string },
|
||||
{ id: number; name: string },
|
||||
{ id: number; name: string },
|
||||
{ id: number; name: string }
|
||||
] = [
|
||||
{ id: 0, name: 'None' },
|
||||
{ id: 1, name: 'Qsv' },
|
||||
{ id: 2, name: 'Nvenc' },
|
||||
{ id: 3, name: 'Vaapi' },
|
||||
{ id: 4, name: 'VideoToolbox' }
|
||||
];
|
||||
|
||||
private _selectedHardwareAcceleration: number = 0;
|
||||
public selectedHardwareAcceleration: { id: number; name: string } = {
|
||||
id: 0,
|
||||
name: 'None'
|
||||
};
|
||||
|
||||
public vaapiDrivers: [
|
||||
{ id: number; name: string },
|
||||
{ id: number; name: string },
|
||||
{ id: number; name: string },
|
||||
{ id: number; name: string },
|
||||
{ id: number; name: string }
|
||||
] = [
|
||||
{ id: 0, name: 'Default' },
|
||||
{ id: 1, name: 'iHD' },
|
||||
{ id: 2, name: 'i965' },
|
||||
{ id: 3, name: 'RadeonSI' },
|
||||
{ id: 4, name: 'Nouveau' }
|
||||
];
|
||||
|
||||
public vaapiDriverDisabled = true;
|
||||
private _selectedVaapiDriver: number = 0;
|
||||
public selectedVaapiDriver: { id: number; name: string } = {
|
||||
id: 0,
|
||||
name: 'Default'
|
||||
};
|
||||
|
||||
public selectedVaapiDevice: string = '';
|
||||
public vaapiDevices: [string, string] = ['', '/dev/dri/renderD128'];
|
||||
|
||||
public normalizeFrameRate: boolean = false;
|
||||
public autoDeinterlaceVideo: boolean = true;
|
||||
public normalizeLoudness: boolean = true;
|
||||
|
||||
saveFFmpegProfile() {
|
||||
//this means we're adding
|
||||
if (isNaN(this.id)) {
|
||||
ffmpegProfileApiService.newFFmpegProfile(
|
||||
this.newProfile.name,
|
||||
this.threadCount,
|
||||
this._selectedHardwareAcceleration,
|
||||
this._selectedVaapiDriver,
|
||||
this.selectedVaapiDevice,
|
||||
this._selectedResolution,
|
||||
this._selectedVideoFormat,
|
||||
this.newProfile.videoBitRate,
|
||||
this.newProfile.videoBufferSize,
|
||||
this._selectedAudioFormat,
|
||||
this.newProfile.audioBitRate,
|
||||
this.newProfile.audioBufferSize,
|
||||
this.normalizeLoudness,
|
||||
this.newProfile.channels,
|
||||
this.newProfile.audioSampleRate,
|
||||
this.normalizeFrameRate,
|
||||
this.autoDeinterlaceVideo
|
||||
);
|
||||
} else {
|
||||
//this means we're editing
|
||||
ffmpegProfileApiService.updateFFmpegProfile(
|
||||
this.id,
|
||||
this.newProfile.name,
|
||||
this.threadCount,
|
||||
this._selectedHardwareAcceleration,
|
||||
this._selectedVaapiDriver,
|
||||
this.selectedVaapiDevice,
|
||||
this._selectedResolution,
|
||||
this._selectedVideoFormat,
|
||||
this.newProfile.videoBitRate,
|
||||
this.newProfile.videoBufferSize,
|
||||
this._selectedAudioFormat,
|
||||
this.newProfile.audioBitRate,
|
||||
this.newProfile.audioBufferSize,
|
||||
this.normalizeLoudness,
|
||||
this.newProfile.channels,
|
||||
this.newProfile.audioSampleRate,
|
||||
this.normalizeFrameRate,
|
||||
this.autoDeinterlaceVideo
|
||||
);
|
||||
}
|
||||
const Toast = this.$swal.mixin({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
timerProgressBar: true,
|
||||
didOpen: (toast) => {
|
||||
toast.addEventListener('mouseenter', this.$swal.stopTimer);
|
||||
toast.addEventListener('mouseleave', this.$swal.resumeTimer);
|
||||
}
|
||||
});
|
||||
|
||||
Toast.fire({
|
||||
icon: 'success',
|
||||
title: this.$t('edit-ffmpeg-profile.profile-saved').toString()
|
||||
});
|
||||
this.$router.push({
|
||||
name: 'ffmpeg-profiles.title'
|
||||
});
|
||||
}
|
||||
|
||||
cancelAdd() {
|
||||
this.$router.push({
|
||||
name: 'ffmpeg-profiles.title'
|
||||
});
|
||||
}
|
||||
|
||||
//~change events~//
|
||||
public audioFormatChanged(selectObj: number) {
|
||||
this._selectedAudioFormat = selectObj;
|
||||
}
|
||||
|
||||
public preferredResolutionChange(selectObj: number) {
|
||||
this._selectedResolution = selectObj + 1;
|
||||
}
|
||||
|
||||
public videoFormatChanged(selectObj: number) {
|
||||
this._selectedVideoFormat = selectObj;
|
||||
}
|
||||
|
||||
public hardwareAccelerationChanged(selectObj: number) {
|
||||
this._selectedHardwareAcceleration = selectObj;
|
||||
this.applyVaapiValidation();
|
||||
}
|
||||
|
||||
public applyVaapiValidation() {
|
||||
//If they pick VAAPI as the hardware acceleration,
|
||||
//they can now choose a vaapi driver and device.
|
||||
//If not, they cannot change the default options.
|
||||
if (this._selectedHardwareAcceleration == 3) {
|
||||
this.vaapiDriverDisabled = false;
|
||||
} else {
|
||||
this.vaapiDriverDisabled = true;
|
||||
this._selectedVaapiDriver = 0;
|
||||
this.selectedVaapiDriver = { id: 0, name: 'Default' };
|
||||
this.selectedVaapiDevice = '';
|
||||
}
|
||||
}
|
||||
|
||||
public vaapiDriverChanged(selectObj: number) {
|
||||
this._selectedVaapiDriver = selectObj;
|
||||
}
|
||||
|
||||
public vaapiDeviceChanged(selectObj: string) {
|
||||
this.selectedVaapiDevice = selectObj;
|
||||
}
|
||||
|
||||
//~ end change events~//
|
||||
|
||||
public threadCount = 0;
|
||||
|
||||
get validIntNonZero() {
|
||||
return [
|
||||
(v: any) =>
|
||||
(v && /^[0-9]+$/.test(v)) || this.$t('Must be a valid number.'),
|
||||
(v: any) => (v && v > 0) || 'Must be greater than 0.'
|
||||
];
|
||||
}
|
||||
|
||||
get validInt() {
|
||||
return [
|
||||
(v: any) =>
|
||||
(v && /^[0-9]+$/.test(v)) || this.$t('Must be a valid number.')
|
||||
];
|
||||
}
|
||||
|
||||
get textRules() {
|
||||
return [(v: any) => (v && v.length > 0) || 'Value must not be empty.'];
|
||||
}
|
||||
|
||||
props!: { id: number };
|
||||
@Watch('id', { immediate: true }) async onItemChanged() {
|
||||
console.log('ID', this.id);
|
||||
this.id = Number(this.$route.query.id) ?? -1;
|
||||
await this.loadPage();
|
||||
}
|
||||
|
||||
private loaded = false;
|
||||
private id = -1;
|
||||
|
||||
title: string = 'Modify FFmpeg Profile';
|
||||
|
||||
async loadPage(): Promise<void> {
|
||||
if (this.loaded) {
|
||||
return;
|
||||
}
|
||||
var title = document.getElementById('title');
|
||||
if (title === null || title === undefined) {
|
||||
//sometimes the element isn't loaded yet, it'll come
|
||||
//back to this when it's good to go. So skip for now.
|
||||
return;
|
||||
}
|
||||
if (!isNaN(this.id)) {
|
||||
title.innerHTML = this.$t(
|
||||
'edit-ffmpeg-profile.edit-profile'
|
||||
).toString();
|
||||
var ffmpegFullProfile = await ffmpegProfileApiService.getOne(
|
||||
this.id.toString()
|
||||
);
|
||||
var result = ffmpegFullProfile[0];
|
||||
if (result !== undefined) {
|
||||
//We have a profile, let's load it.
|
||||
this.threadCount = result.threadCount;
|
||||
this.selectedVaapiDevice = result.vaapiDevice;
|
||||
this.autoDeinterlaceVideo = result.deinterlaceVideo;
|
||||
this.normalizeFrameRate = result.normalizeFramerate;
|
||||
this.normalizeLoudness = result.normalizeLoudness;
|
||||
this.newProfile = {
|
||||
name: result.name,
|
||||
videoBitRate: result.videoBitrate,
|
||||
videoBufferSize: result.videoBufferSize,
|
||||
audioBitRate: result.audioBitrate,
|
||||
audioBufferSize: result.audioBufferSize,
|
||||
channels: result.audioChannels,
|
||||
audioSampleRate: result.audioSampleRate
|
||||
};
|
||||
|
||||
this._selectedAudioFormat = result.audioFormat;
|
||||
this.selectedAudioFormat =
|
||||
this.audioFormats[result.audioFormat - 1];
|
||||
this._selectedVideoFormat = result.videoFormat;
|
||||
this.selectedVideoFormat =
|
||||
this.videoFormats[result.videoFormat - 1];
|
||||
this._selectedResolution = result.resolutionId;
|
||||
this.selectedResolution =
|
||||
this.preferredResolutions[result.resolutionId - 1];
|
||||
this._selectedHardwareAcceleration =
|
||||
result.hardwareAcceleration;
|
||||
this.selectedHardwareAcceleration =
|
||||
this.hardwareAccelerations[result.hardwareAcceleration];
|
||||
this._selectedVaapiDriver = result.vaapiDriver;
|
||||
this.selectedVaapiDriver =
|
||||
this.vaapiDrivers[result.vaapiDriver];
|
||||
this.applyVaapiValidation();
|
||||
this.loaded = true;
|
||||
} else {
|
||||
//an ID was entered (probably in the URL) that doesn't exist. Let's returnt to the profile list.
|
||||
console.log('No ffmpeg profile found for ID: ' + this.id);
|
||||
this.$router.push({
|
||||
name: 'ffmpeg-profiles.title'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
//new profile!
|
||||
title.innerHTML = this.$t(
|
||||
'edit-ffmpeg-profile.add-profile'
|
||||
).toString();
|
||||
this._selectedAudioFormat = 2;
|
||||
this._selectedResolution = 3;
|
||||
this._selectedVideoFormat = 1;
|
||||
this._selectedHardwareAcceleration = 0;
|
||||
this._selectedVaapiDriver = 0;
|
||||
this.selectedVaapiDevice = '';
|
||||
this.newProfile = {
|
||||
name: 'New Profile',
|
||||
videoBitRate: 2000,
|
||||
videoBufferSize: 4000,
|
||||
audioBitRate: 192,
|
||||
audioBufferSize: 384,
|
||||
channels: 2,
|
||||
audioSampleRate: 48
|
||||
};
|
||||
this.loaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -5,7 +5,14 @@
|
||||
:items="channels"
|
||||
:sort-by="['number']"
|
||||
class="elevation-1"
|
||||
></v-data-table>
|
||||
>
|
||||
<template v-slot:[`item.actions`]="{ item }">
|
||||
<v-icon small class="mr-2" @click="editRow(item.id)">
|
||||
mdi-lead-pencil
|
||||
</v-icon>
|
||||
<v-icon small @click="deleteRow(item.id)">mdi-delete</v-icon>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -28,10 +35,21 @@ export default class Channels extends Vue {
|
||||
{
|
||||
text: this.$t('channels.table.ffmpeg-profile'),
|
||||
value: 'ffmpegProfile'
|
||||
}
|
||||
},
|
||||
{ text: 'Actions', value: 'actions', sortable: false }
|
||||
];
|
||||
}
|
||||
|
||||
deleteRow(item: any) {
|
||||
let index = this.channels.findIndex((it) => it.id === item.id);
|
||||
this.channels.splice(index, 1);
|
||||
}
|
||||
|
||||
editRow(item: any) {
|
||||
let index = this.channels.findIndex((it) => it.id === item.id);
|
||||
this.channels.splice(index, 1);
|
||||
}
|
||||
|
||||
title: string = 'Channels';
|
||||
|
||||
async mounted(): Promise<void> {
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-btn color="success" class="ma-4" @click="addRecord()">{{
|
||||
$t('ffmpeg-profiles.add-profile')
|
||||
}}</v-btn>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="ffmpegProfiles"
|
||||
:sort-by="['name']"
|
||||
class="elevation-1"
|
||||
>
|
||||
<template v-slot:[`item.actions`]="{ item }">
|
||||
<v-btn icon class="mr-2" @click="editRow(item.id)">
|
||||
<v-icon>mdi-lead-pencil</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon @click.stop="deleteRecord(item.id, item.name)">
|
||||
<v-icon>mdi-delete</v-icon></v-btn
|
||||
>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -17,7 +29,7 @@ import { ffmpegProfileApiService } from '@/services/FFmpegProfileService';
|
||||
|
||||
@Component
|
||||
export default class FFmpegProfiles extends Vue {
|
||||
private ffmpegProfiles: FFmpegProfile[] = [];
|
||||
public ffmpegProfiles: FFmpegProfile[] = [];
|
||||
|
||||
get headers() {
|
||||
return [
|
||||
@@ -27,10 +39,81 @@ export default class FFmpegProfiles extends Vue {
|
||||
value: 'resolution'
|
||||
},
|
||||
{ text: this.$t('ffmpeg-profiles.table.video'), value: 'video' },
|
||||
{ text: this.$t('ffmpeg-profiles.table.audio'), value: 'audio' }
|
||||
{ text: this.$t('ffmpeg-profiles.table.audio'), value: 'audio' },
|
||||
{
|
||||
text: this.$t('ffmpeg-profiles.actions'),
|
||||
value: 'actions',
|
||||
sortable: false
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
addRecord() {
|
||||
this.$router.push({
|
||||
name: 'add-ffmpeg-profile'
|
||||
});
|
||||
}
|
||||
|
||||
deleteRecord(record: any, recordName: any) {
|
||||
this.$swal
|
||||
.fire({
|
||||
title: this.$t(
|
||||
'ffmpeg-profiles.delete-dialog-title'
|
||||
).toString(),
|
||||
// text: this.$t(
|
||||
// 'Delete "' + recordName + '" FFmpeg Profile?'
|
||||
// ).toString(),
|
||||
text: this.$t('ffmpeg-profiles.delete-dialog-text', {
|
||||
profileName: '"' + recordName + '"'
|
||||
}).toString(),
|
||||
icon: 'warning',
|
||||
//iconColor: '#4CAF50',
|
||||
showCancelButton: true,
|
||||
cancelButtonText: this.$t('ffmpeg-profiles.no').toString(),
|
||||
confirmButtonText: this.$t('ffmpeg-profiles.yes').toString()
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
let index = this.ffmpegProfiles.findIndex(
|
||||
(it) => it.id === record
|
||||
);
|
||||
this.ffmpegProfiles.splice(index, 1);
|
||||
ffmpegProfileApiService.deleteRecord(String(record));
|
||||
const Toast = this.$swal.mixin({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
showConfirmButton: false,
|
||||
timer: 3000,
|
||||
timerProgressBar: true,
|
||||
didOpen: (toast) => {
|
||||
toast.addEventListener(
|
||||
'mouseenter',
|
||||
this.$swal.stopTimer
|
||||
);
|
||||
toast.addEventListener(
|
||||
'mouseleave',
|
||||
this.$swal.resumeTimer
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Toast.fire({
|
||||
icon: 'success',
|
||||
title: this.$t(
|
||||
'ffmpeg-profiles.profile-deleted'
|
||||
).toString()
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
editRow(id: any) {
|
||||
this.$router.push({
|
||||
name: 'edit-ffmpeg',
|
||||
query: { id: id }
|
||||
});
|
||||
}
|
||||
|
||||
title: string = 'FFMpeg Profiles';
|
||||
|
||||
async mounted(): Promise<void> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user