Compare commits

...
Author SHA1 Message Date
Jason Dove ca8bcacbd3 update changelog for release 59 [no ci] 2021-09-18 14:35:01 -05:00
Jason Dove f27286d1dd properly disable transcoding when unchecked in mpeg-ts mode (#378)
* properly disable transcoding in MPEG-TS mode

* update changelog
2021-09-18 14:21:49 -05:00
Jason Dove 23870b75f7 update changelog [no ci] 2021-09-18 14:02:49 -05:00
Jason Dove 7f5a91c643 include libva-x11-2 in vaapi docker image 2021-09-18 13:25:01 -05:00
Jason Dove f1f50e883c add vaapi driver setting and health check (#377)
* add vaapi driver option

* add vaapi driver setting and health check
2021-09-18 13:00:36 -05:00
Jason Dove 7506f49f5b remove codeql [no docker] 2021-09-18 11:30:16 -05:00
Jason Dove 944f1e4307 add scheduled playout rebuild (#376)
* configure scheduled playout rebuild

* implement scheduled playout rebuild

* remove variable
2021-09-18 11:23:58 -05:00
Jason Dove f7de9ac5ea include intel-media-va-driver-non-free in vaapi image (#375)
* include intel-media-va-driver-non-free in vaapi image

* tweak changelog
2021-09-17 21:13:27 -05:00
Jason Dove 1eb51ad2f4 add some health checks to home page (#374) 2021-09-17 17:59:59 -05:00
Jason Dove c3e0aaf0b7 missing metadata fixes (#373) 2021-09-17 08:53:41 -05:00
Jason Dove b9912b47df update changelog for release 58 [no ci] 2021-09-15 21:53:34 -05:00
Jason Dove 55fb2624e7 add multi-part grouping tooltip (#371) 2021-09-15 21:18:41 -05:00
Jason Dove 8ced20dc39 dont offset collections during shuffle in order (#370) 2021-09-15 20:54:32 -05:00
Jason Dove e718cb0faf fix building playouts in timezones with positive offsets (#368) 2021-09-15 09:07:35 -05:00
Jason Dove e218ff9a6d fix watermark when no video filters are required (#367) 2021-09-15 05:12:23 -05:00
Jason Dove c2a49cbaea update dependencies (#365) 2021-09-14 18:10:59 -05:00
Jason Dove 17e74f7314 add more release date search options (#362) 2021-09-12 18:32:38 -05:00
Long-Man 2032bb4777 Update search.md (#361)
Add release_date and released_nointhelast to music video search
2021-09-12 12:18:07 -05:00
Jason Dove 7877ec641e update changelog for release 57 [no ci] 2021-09-11 10:37:23 -05:00
Jason Dove 767a9779bb more kodi artwork fixes (#360) 2021-09-11 10:08:33 -05:00
Jason Dove bb9127e546 fix artwork in kodi (#359) 2021-09-11 09:45:09 -05:00
Jason Dove c932577cb8 allow adding smart collections to multi collections (#358) 2021-09-11 09:29:20 -05:00
Jason Dove ad2685fb2e add released_inthelast queries (#357) 2021-09-10 21:11:14 -05:00
Jason Dove 96bc2c28f2 update changelog for release 56 [no ci] 2021-09-10 13:59:14 -05:00
Jason Dove a076b3eb30 add shuffle-in-order support to all collections (#356) 2021-09-10 13:33:04 -05:00
Jason Dove fc360602ad add smart collections (#355)
* start to add smart collections

* add smart collection table; delete smart collection

* overwrite smart collections

* support scheduling smart collections

* update changelog
2021-09-10 11:58:24 -05:00
Jason Dove d8b4d00a73 clarify changelog [no ci] 2021-09-09 08:22:46 -05:00
Jason Dove 0638ac8a5e more missing metadata fixes (#354)
* more missing metadata fixes

* update mudblazor
2021-09-09 06:38:46 -05:00
Jason Dove f1f09bd4cb fix sorting episodes without metadata (#353) 2021-09-08 22:12:47 -05:00
Jason Dove f6680f29e7 try to fix doc formatting [no docker] 2021-09-07 13:36:25 -05:00
Jason Dove 1c0413452b fix m3u xmltv mapping 2021-09-07 06:34:17 -05:00
Jason Dove 77308a9ac5 generate valid xmltv (#351) 2021-09-07 06:12:13 -05:00
149 changed files with 18772 additions and 331 deletions
-71
View File
@@ -1,71 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: '30 3 * * 0'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'csharp' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
+69 -1
View File
@@ -5,6 +5,70 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.0.59-alpha] - 2021-09-18
### Added
- Add `Health Checks` table to home page to identify and surface common misconfigurations
- `FFmpeg Version` checks `ffmpeg` and `ffprobe` versions
- `FFmpeg Reports` checks whether ffmpeg troubleshooting reports are enabled since they can use a lot of disk space over time
- `Hardware Acceleration` checks whether channels that transcode are using acceleration methods that ffmpeg claims to support
- `Movie Metadata` checks whether all movies have metadata (fallback metadata counts as metadata)
- `Episode Metadata` checks whether all episodes have metadata (fallback metadata counts as metadata)
- `Zero Duration` checks whether all movies and episodes have a valid (non-zero) duration
- `VAAPI Driver` checks whether a vaapi driver preference is configured when using the vaapi docker image
- Add setting to each playout to schedule an automatic daily rebuild
- This is useful if the playout uses a smart collection with `released_onthisday`
### Fixed
- Fix docker vaapi support for newer Intel platforms (Gen 8+)
- This includes a new setting to force a particular vaapi driver (`iHD` or `i965`), as some Gen 8 or 9 hardware that is supported by both drivers will perform better with one or the other
- Fix scanning and indexing local movies and episodes without NFO metadata
- Fix displaying seasons for shows with no year (in metadata or in folder name)
- Fix "direct play" in MPEG-TS mode (copy audio and video stream when `Transcode` is unchecked)
## [0.0.58-alpha] - 2021-09-15
### Added
- Add `released_notinthelast` search field for relative release date queries
- Syntax is a number and a unit (days, weeks, months, years) like `1 week` or `2 years`
- Add `released_onthisday` search field for historical queries
- Syntax is `released_onthisday:1` and will search for items released on this month number and day number in prior years
- Add tooltip explaining `Keep Multi-Part Episodes Together`
### Fixed
- Properly display watermark when no other video filters (like scaling or padding) are required
- Fix building some playouts in timezones with positive offsets (like UTC+2)
- Fix `Shuffle In Order` so all collections/shows start from the earliest episode
- You may need to rebuild playouts to see this fixed behavior more quickly
## [0.0.57-alpha] - 2021-09-10
### Added
- Add `released_inthelast` search field for relative release date queries
- Syntax is a number and a unit (days, weeks, months, years) like `1 week` or `2 years`
- Allow adding smart collections to multi collections
### Fixed
- Fix loading artwork in Kodi
- Use fake image extension (`.jpg`) for artwork in M3U and XMLTV since Kodi detects MIME type from URL
- Enable HEAD requests for IPTV image paths since Kodi requires those
## [0.0.56-alpha] - 2021-09-10
### Added
- Add Smart Collections
- Smart Collections use search queries and can be created from the search result page
- Smart Collections are re-evaluated every time playouts are extended or rebuilt to automatically include newly-matching items
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
- Allow `Shuffle In Order` with Collections and Smart Collections
- Episodes will be grouped by show, and music videos will be grouped by artist
- All movies will be a single group (multi-collections are probably better if `Shuffle In Order` is desired for movies)
- All groups will be be ordered chronologically (custom ordering is only supported in multi-collections)
### Fixed
- Generate XMLTV that validates successfully
- Properly order elements
- Omit channels with no programmes
- Properly identify channels using the format number.etv like `15.etv`
- Fix building playouts when multi-part episode grouping is enabled and episodes are missing metadata
- Fix incorrect total items count in `Multi Collections` table
## [0.0.55-alpha] - 2021-09-03
### Fixed
- Fix all local library scanners to ignore dot underscore files (`._`)
@@ -550,7 +614,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.55-alpha...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.59-alpha...HEAD
[0.0.59-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.58-alpha...v0.0.59-alpha
[0.0.58-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.57-alpha...v0.0.58-alpha
[0.0.57-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.56-alpha...v0.0.57-alpha
[0.0.56-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.55-alpha...v0.0.56-alpha
[0.0.55-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.54-alpha...v0.0.55-alpha
[0.0.54-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.53-alpha...v0.0.54-alpha
[0.0.53-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.52-alpha...v0.0.53-alpha
@@ -67,7 +67,7 @@ namespace ErsatzTV.Application.Emby.Commands
private async Task<Unit> Synchronize(RequestParameters parameters)
{
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{
@@ -37,17 +37,17 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
p.Transcode = update.Transcode;
p.HardwareAcceleration = update.HardwareAcceleration;
p.ResolutionId = update.ResolutionId;
p.NormalizeVideo = update.NormalizeVideo;
p.NormalizeVideo = update.Transcode && update.NormalizeVideo;
p.VideoCodec = update.VideoCodec;
p.VideoBitrate = update.VideoBitrate;
p.VideoBufferSize = update.VideoBufferSize;
p.AudioCodec = update.AudioCodec;
p.AudioBitrate = update.AudioBitrate;
p.AudioBufferSize = update.AudioBufferSize;
p.NormalizeLoudness = update.NormalizeLoudness;
p.NormalizeLoudness = update.Transcode && update.NormalizeLoudness;
p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeAudio = update.NormalizeAudio;
p.NormalizeAudio = update.Transcode && update.NormalizeAudio;
await dbContext.SaveChangesAsync();
return new UpdateFFmpegProfileResult(p.Id);
}
@@ -115,6 +115,10 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId);
}
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegVaapiDriver,
(int)request.Settings.VaapiDriver);
return Unit.Default;
}
}
@@ -1,4 +1,6 @@
namespace ErsatzTV.Application.FFmpegProfiles
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Application.FFmpegProfiles
{
public class FFmpegSettingsViewModel
{
@@ -8,5 +10,6 @@
public string PreferredLanguageCode { get; set; }
public bool SaveReports { get; set; }
public int? GlobalWatermarkId { get; set; }
public VaapiDriver VaapiDriver { get; set; }
}
}
@@ -1,6 +1,7 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
@@ -28,6 +29,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
Option<int> watermark =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
Option<int> vaapiDriver =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegVaapiDriver);
var result = new FFmpegSettingsViewModel
{
@@ -36,6 +39,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0),
SaveReports = await saveReports.IfNoneAsync(false),
PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng"),
VaapiDriver = (VaapiDriver)await vaapiDriver.IfNoneAsync(0)
};
foreach (int watermarkId in watermark)
@@ -0,0 +1,8 @@
using System.Collections.Generic;
using ErsatzTV.Core.Health;
using MediatR;
namespace ErsatzTV.Application.Health.Queries
{
public record GetAllHealthCheckResults : IRequest<List<HealthCheckResult>>;
}
@@ -0,0 +1,25 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Health;
using MediatR;
namespace ErsatzTV.Application.Health.Queries
{
public class GetAllHealthCheckResultsHandler : IRequestHandler<GetAllHealthCheckResults, List<HealthCheckResult>>
{
private readonly IHealthCheckService _healthCheckService;
public GetAllHealthCheckResultsHandler(IHealthCheckService healthCheckService) =>
_healthCheckService = healthCheckService;
public async Task<List<HealthCheckResult>> Handle(
GetAllHealthCheckResults request,
CancellationToken cancellationToken)
{
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks();
return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList();
}
}
}
@@ -67,7 +67,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
private async Task<Unit> Synchronize(RequestParameters parameters)
{
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{
+2 -1
View File
@@ -1,5 +1,6 @@
using System;
using System.Linq;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
@@ -43,7 +44,7 @@ namespace ErsatzTV.Application.MediaCards
bool isSearchResult) =>
new(
episodeMetadata.EpisodeId,
episodeMetadata.ReleaseDate ?? DateTime.MinValue,
episodeMetadata.ReleaseDate ?? SystemTime.MinValueUtc,
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone().Match(
m => m.Title ?? string.Empty,
() => string.Empty),
@@ -6,7 +6,7 @@ using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record CreateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record CreateMultiCollectionItem(int? CollectionId, int? SmartCollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record CreateMultiCollection
(string Name, List<CreateMultiCollectionItem> Items) : IRequest<Either<BaseError, MultiCollectionViewModel>>;
@@ -41,6 +41,11 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Query()
.Include(i => i.Collection)
.LoadAsync();
await dbContext.Entry(multiCollection)
.Collection(c => c.MultiCollectionSmartItems)
.Query()
.Include(i => i.SmartCollection)
.LoadAsync();
return ProjectToViewModel(multiCollection);
}
@@ -51,12 +56,44 @@ namespace ErsatzTV.Application.MediaCollections.Commands
name => new MultiCollection
{
Name = name,
MultiCollectionItems = request.Items.Map(i => new MultiCollectionItem
{
CollectionId = i.CollectionId,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
}).ToList()
MultiCollectionItems = request.Items.Map(
i =>
{
if (i.CollectionId.HasValue)
{
return Some(
new MultiCollectionItem
{
CollectionId = i.CollectionId.Value,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
});
}
return None;
})
.Sequence()
.Flatten()
.ToList(),
MultiCollectionSmartItems = request.Items.Map(
i =>
{
if (i.SmartCollectionId.HasValue)
{
return Some(
new MultiCollectionSmartItem
{
SmartCollectionId = i.SmartCollectionId.Value,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
});
}
return None;
})
.Sequence()
.Flatten()
.ToList()
});
private static async Task<Validation<BaseError, string>> ValidateName(
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record CreateSmartCollection
(string Query, string Name) : IRequest<Either<BaseError, SmartCollectionViewModel>>;
}
@@ -0,0 +1,70 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class CreateSmartCollectionHandler :
IRequestHandler<CreateSmartCollection, Either<BaseError, SmartCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, SmartCollectionViewModel>> Handle(
CreateSmartCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request);
return await validation.Apply(c => PersistCollection(dbContext, c));
}
private static async Task<SmartCollectionViewModel> PersistCollection(
TvContext dbContext,
SmartCollection smartCollection)
{
await dbContext.SmartCollections.AddAsync(smartCollection);
await dbContext.SaveChangesAsync();
return ProjectToViewModel(smartCollection);
}
private static Task<Validation<BaseError, SmartCollection>> Validate(
TvContext dbContext,
CreateSmartCollection request) =>
ValidateName(dbContext, request).MapT(
name => new SmartCollection
{
Name = name,
Query = request.Query
});
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
CreateSmartCollection createSmartCollection)
{
List<string> allNames = await dbContext.SmartCollections
.Map(c => c.Name)
.ToListAsync();
Validation<BaseError, string> result1 = createSmartCollection.NotEmpty(c => c.Name)
.Bind(_ => createSmartCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createSmartCollection.Name)
.Filter(name => !allNames.Contains(name))
.ToValidation<BaseError>("SmartCollection name must be unique");
return (result1, result2).Apply((_, _) => createSmartCollection.Name);
}
}
}
@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record DeleteSmartCollection(int SmartCollectionId) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,42 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class DeleteSmartCollectionHandler : MediatR.IRequestHandler<DeleteSmartCollection, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public DeleteSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, Unit>> Handle(
DeleteSmartCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, SmartCollection> validation = await SmartCollectionMustExist(dbContext, request);
return await validation.Apply(c => DoDeletion(dbContext, c));
}
private static Task<Unit> DoDeletion(TvContext dbContext, SmartCollection smartCollection)
{
dbContext.SmartCollections.Remove(smartCollection);
return dbContext.SaveChangesAsync().ToUnit();
}
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
TvContext dbContext,
DeleteSmartCollection request) =>
dbContext.SmartCollections
.SelectOneAsync(c => c.Id, c => c.Id == request.SmartCollectionId)
.Map(o => o.ToValidation<BaseError>($"SmartCollection {request.SmartCollectionId} does not exist."));
}
}
@@ -5,7 +5,7 @@ using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record UpdateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record UpdateMultiCollectionItem(int? CollectionId, int? SmartCollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record UpdateMultiCollection
(
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
@@ -44,15 +45,17 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{
c.Name = request.Name;
// save name first so playouts don't get rebuild for a name change
// save name first so playouts don't get rebuilt for a name change
await dbContext.SaveChangesAsync();
var toAdd = request.Items
.Filter(i => c.MultiCollectionItems.All(i2 => i2.CollectionId != i.CollectionId))
.Map(
i => new MultiCollectionItem
.Filter(i => i.CollectionId.HasValue)
// ReSharper disable once PossibleInvalidOperationException
.Filter(i => c.MultiCollectionItems.All(i2 => i2.CollectionId != i.CollectionId.Value))
.Map(i => new MultiCollectionItem
{
CollectionId = i.CollectionId,
// ReSharper disable once PossibleInvalidOperationException
CollectionId = i.CollectionId.Value,
MultiCollectionId = c.Id,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
@@ -79,6 +82,40 @@ namespace ErsatzTV.Application.MediaCollections.Commands
// add new items
c.MultiCollectionItems.AddRange(toAdd);
var toAddSmart = request.Items
.Filter(i => i.SmartCollectionId.HasValue)
// ReSharper disable once PossibleInvalidOperationException
.Filter(i => c.MultiCollectionSmartItems.All(i2 => i2.SmartCollectionId != i.SmartCollectionId.Value))
.Map(i => new MultiCollectionSmartItem
{
// ReSharper disable once PossibleInvalidOperationException
SmartCollectionId = i.SmartCollectionId.Value,
MultiCollectionId = c.Id,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
})
.ToList();
var toRemoveSmart = c.MultiCollectionSmartItems
.Filter(i => request.Items.All(i2 => i2.SmartCollectionId != i.SmartCollectionId))
.ToList();
// remove items that are no longer present
c.MultiCollectionSmartItems.RemoveAll(toRemoveSmart.Contains);
// update existing items
foreach (MultiCollectionSmartItem item in c.MultiCollectionSmartItems)
{
foreach (UpdateMultiCollectionItem incoming in request.Items.Filter(
i => i.SmartCollectionId == item.SmartCollectionId))
{
item.ScheduleAsGroup = incoming.ScheduleAsGroup;
item.PlaybackOrder = incoming.PlaybackOrder;
}
}
// add new items
c.MultiCollectionSmartItems.AddRange(toAddSmart);
// rebuild playouts
if (await dbContext.SaveChangesAsync() > 0)
{
@@ -104,6 +141,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
UpdateMultiCollection updateCollection) =>
dbContext.MultiCollections
.Include(mc => mc.MultiCollectionItems)
.Include(mc => mc.MultiCollectionSmartItems)
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.MultiCollectionId)
.Map(o => o.ToValidation<BaseError>("MultiCollection does not exist."));
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record UpdateSmartCollection(int Id, string Query) : IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,71 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class UpdateSmartCollectionHandler : MediatR.IRequestHandler<UpdateSmartCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateSmartCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public async Task<Either<BaseError, Unit>> Handle(
UpdateSmartCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, SmartCollection c, UpdateSmartCollection request)
{
c.Query = request.Query;
// rebuild playouts
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this smart collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingSmartCollection(request.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private static Task<Validation<BaseError, SmartCollection>> Validate(
TvContext dbContext,
UpdateSmartCollection request) => SmartCollectionMustExist(dbContext, request);
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
TvContext dbContext,
UpdateSmartCollection updateCollection) =>
dbContext.SmartCollections
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.Id)
.Map(o => o.ToValidation<BaseError>("SmartCollection does not exist."));
}
}
@@ -13,7 +13,11 @@ namespace ErsatzTV.Application.MediaCollections
new(
multiCollection.Id,
multiCollection.Name,
Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList());
Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList(),
Optional(multiCollection.MultiCollectionSmartItems).Flatten().Map(ProjectToViewModel).ToList());
internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) =>
new(collection.Id, collection.Name, collection.Query);
private static MultiCollectionItemViewModel ProjectToViewModel(MultiCollectionItem multiCollectionItem) =>
new(
@@ -21,5 +25,12 @@ namespace ErsatzTV.Application.MediaCollections
ProjectToViewModel(multiCollectionItem.Collection),
multiCollectionItem.ScheduleAsGroup,
multiCollectionItem.PlaybackOrder);
private static MultiCollectionSmartItemViewModel ProjectToViewModel(MultiCollectionSmartItem multiCollectionSmartItem) =>
new(
multiCollectionSmartItem.MultiCollectionId,
ProjectToViewModel(multiCollectionSmartItem.SmartCollection),
multiCollectionSmartItem.ScheduleAsGroup,
multiCollectionSmartItem.PlaybackOrder);
}
}
@@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections
{
public record MultiCollectionSmartItemViewModel(
int MultiCollectionId,
SmartCollectionViewModel SmartCollection,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder);
}
@@ -2,5 +2,9 @@
namespace ErsatzTV.Application.MediaCollections
{
public record MultiCollectionViewModel(int Id, string Name, List<MultiCollectionItemViewModel> Items);
public record MultiCollectionViewModel(
int Id,
string Name,
List<MultiCollectionItemViewModel> Items,
List<MultiCollectionSmartItemViewModel> SmartItems);
}
@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCollections
{
public record PagedSmartCollectionsViewModel(int TotalCount, List<SmartCollectionViewModel> Page);
}
@@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetAllSmartCollections : IRequest<List<SmartCollectionViewModel>>;
}
@@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetAllSmartCollectionsHandler : IRequestHandler<GetAllSmartCollections, List<SmartCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetAllSmartCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<SmartCollectionViewModel>> Handle(
GetAllSmartCollections request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.SmartCollections
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
}
}
}
@@ -24,6 +24,8 @@ namespace ErsatzTV.Application.MediaCollections.Queries
return await dbContext.MultiCollections
.Include(mc => mc.MultiCollectionItems)
.ThenInclude(mc => mc.Collection)
.Include(mc => mc.MultiCollectionSmartItems)
.ThenInclude(mc => mc.SmartCollection)
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
.MapT(ProjectToViewModel);
}
@@ -27,7 +27,7 @@ namespace ErsatzTV.Application.MediaCollections.Queries
GetPagedMultiCollections request,
CancellationToken cancellationToken)
{
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM Collection");
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM MultiCollection");
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<MultiCollectionViewModel> page = await dbContext.MultiCollections.FromSqlRaw(
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetPagedSmartCollections(int PageNum, int PageSize) : IRequest<PagedSmartCollectionsViewModel>;
}
@@ -0,0 +1,46 @@
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetPagedSmartCollectionsHandler : IRequestHandler<GetPagedSmartCollections, PagedSmartCollectionsViewModel>
{
private readonly IDbConnection _dbConnection;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetPagedSmartCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
{
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
public async Task<PagedSmartCollectionsViewModel> Handle(
GetPagedSmartCollections request,
CancellationToken cancellationToken)
{
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM SmartCollection");
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<SmartCollectionViewModel> page = await dbContext.SmartCollections.FromSqlRaw(
@"SELECT * FROM SmartCollection
ORDER BY Name
COLLATE NOCASE
LIMIT {0} OFFSET {1}",
request.PageSize,
request.PageNum * request.PageSize)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new PagedSmartCollectionsViewModel(count, page);
}
}
}
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.MediaCollections
{
public record SmartCollectionViewModel(int Id, string Name, string Query);
}
@@ -78,7 +78,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
decimal progressMin = (decimal) i / localLibrary.Paths.Count;
decimal progressMax = (decimal) (i + 1) / localLibrary.Paths.Count;
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
if (forceScan || nextScan < DateTimeOffset.Now)
{
@@ -0,0 +1,10 @@
using System;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Playouts.Commands
{
public record UpdatePlayout
(int PlayoutId, Option<TimeSpan> DailyRebuildTime) : IRequest<Either<BaseError, PlayoutNameViewModel>>;
}
@@ -0,0 +1,65 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Playouts.Commands
{
public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseError, PlayoutNameViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public UpdatePlayoutHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, PlayoutNameViewModel>> Handle(
UpdatePlayout request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Playout> validation = await Validate(dbContext, request);
return await validation.Apply(playout => ApplyUpdateRequest(dbContext, request, playout));
}
private static async Task<PlayoutNameViewModel> ApplyUpdateRequest(
TvContext dbContext,
UpdatePlayout request,
Playout playout)
{
playout.DailyRebuildTime = null;
foreach (TimeSpan dailyRebuildTime in request.DailyRebuildTime)
{
playout.DailyRebuildTime = dailyRebuildTime;
}
await dbContext.SaveChangesAsync();
return new PlayoutNameViewModel(
playout.Id,
playout.Channel.Name,
playout.Channel.Number,
playout.ProgramSchedule.Name,
Optional(playout.DailyRebuildTime));
}
private static Task<Validation<BaseError, Playout>> Validate(TvContext dbContext, UpdatePlayout request) =>
PlayoutMustExist(dbContext, request);
private static Task<Validation<BaseError, Playout>> PlayoutMustExist(
TvContext dbContext,
UpdatePlayout updatePlayout) =>
dbContext.Playouts
.Include(p => p.Channel)
.Include(p => p.ProgramSchedule)
.SelectOneAsync(p => p.Id, p => p.Id == updatePlayout.PlayoutId)
.Map(o => o.ToValidation<BaseError>("Playout does not exist."));
}
}
@@ -1,8 +1,12 @@
namespace ErsatzTV.Application.Playouts
using System;
using LanguageExt;
namespace ErsatzTV.Application.Playouts
{
public record PlayoutNameViewModel(
int PlayoutId,
string ChannelName,
string ChannelNumber,
string ScheduleName);
string ScheduleName,
Option<TimeSpan> DailyRebuildTime);
}
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using ErsatzTV.Infrastructure.Data;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Playouts.Queries
{
@@ -21,7 +22,13 @@ namespace ErsatzTV.Application.Playouts.Queries
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Playouts
.Filter(p => p.Channel != null && p.ProgramSchedule != null)
.Map(p => new PlayoutNameViewModel(p.Id, p.Channel.Name, p.Channel.Number, p.ProgramSchedule.Name))
.Map(
p => new PlayoutNameViewModel(
p.Id,
p.Channel.Name,
p.Channel.Number,
p.ProgramSchedule.Name,
Optional(p.DailyRebuildTime)))
.ToListAsync(cancellationToken);
}
}
@@ -65,7 +65,7 @@ namespace ErsatzTV.Application.Plex.Commands
private async Task<Unit> Synchronize(RequestParameters parameters)
{
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{
@@ -14,6 +14,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? MediaItemId,
PlaybackOrder PlaybackOrder,
int? MultipleCount,
@@ -9,6 +9,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ProgramScheduleItemCollectionType CollectionType { get; }
int? CollectionId { get; }
int? MultiCollectionId { get; }
int? SmartCollectionId { get; }
int? MediaItemId { get; }
PlayoutMode PlayoutMode { get; }
PlaybackOrder PlaybackOrder { get; }
@@ -36,10 +36,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
break;
}
}
else if (item.PlaybackOrder == PlaybackOrder.ShuffleInOrder)
{
return BaseError.New("Invalid playback order: 'Shuffle In Order'");
}
switch (item.PlayoutMode)
{
@@ -112,6 +108,13 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
return BaseError.New("[MultiCollection] is required for collection type 'MultiCollection'");
}
break;
case ProgramScheduleItemCollectionType.SmartCollection:
if (item.SmartCollectionId is null)
{
return BaseError.New("[SmartCollection] is required for collection type 'SmartCollection'");
}
break;
default:
return BaseError.New("[CollectionType] is invalid");
@@ -134,6 +137,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle
@@ -146,6 +150,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle
@@ -158,6 +163,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
Count = item.MultipleCount.GetValueOrDefault(),
@@ -171,6 +177,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()),
@@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? MediaItemId,
PlaybackOrder PlaybackOrder,
int? MultipleCount,
@@ -88,7 +88,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
item.CollectionType,
item.CollectionId,
item.MediaItemId,
item.MultiCollectionId);
item.MultiCollectionId,
item.SmartCollectionId);
if (keyOrders.TryGetValue(key, out System.Collections.Generic.HashSet<PlaybackOrder> playbackOrders))
{
@@ -111,6 +112,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId);
int? MultiCollectionId,
int? SmartCollectionId);
}
}
@@ -28,6 +28,9 @@ namespace ErsatzTV.Application.ProgramSchedules
duration.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.MultiCollection)
: null,
duration.SmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.SmartCollection)
: null,
duration.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
@@ -52,6 +55,9 @@ namespace ErsatzTV.Application.ProgramSchedules
flood.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(flood.MultiCollection)
: null,
flood.SmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(flood.SmartCollection)
: null,
flood.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
@@ -74,6 +80,9 @@ namespace ErsatzTV.Application.ProgramSchedules
multiple.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(multiple.MultiCollection)
: null,
multiple.SmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(multiple.SmartCollection)
: null,
multiple.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
@@ -97,6 +106,9 @@ namespace ErsatzTV.Application.ProgramSchedules
one.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(one.MultiCollection)
: null,
one.SmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(one.SmartCollection)
: null,
one.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
@@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
TimeSpan playoutDuration,
@@ -28,6 +29,7 @@ namespace ErsatzTV.Application.ProgramSchedules
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle)
@@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
string customTitle) : base(
@@ -26,6 +27,7 @@ namespace ErsatzTV.Application.ProgramSchedules
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle)
@@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
int count,
@@ -27,6 +28,7 @@ namespace ErsatzTV.Application.ProgramSchedules
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle) =>
@@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
string customTitle) : base(
@@ -26,6 +27,7 @@ namespace ErsatzTV.Application.ProgramSchedules
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle)
@@ -14,6 +14,7 @@ namespace ErsatzTV.Application.ProgramSchedules
ProgramScheduleItemCollectionType CollectionType,
MediaCollectionViewModel Collection,
MultiCollectionViewModel MultiCollection,
SmartCollectionViewModel SmartCollection,
NamedMediaItemViewModel MediaItem,
PlaybackOrder PlaybackOrder,
string CustomTitle)
@@ -29,6 +30,8 @@ namespace ErsatzTV.Application.ProgramSchedules
MediaItem?.Name,
ProgramScheduleItemCollectionType.MultiCollection =>
MultiCollection?.Name,
ProgramScheduleItemCollectionType.SmartCollection =>
SmartCollection?.Name,
_ => string.Empty
};
}
@@ -28,6 +28,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Queries
.Filter(psi => psi.ProgramScheduleId == request.Id)
.Include(i => i.Collection)
.Include(i => i.MultiCollection)
.Include(i => i.SmartCollection)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
@@ -99,6 +99,10 @@ namespace ErsatzTV.Application.Streaming.Queries
watermarkId => dbContext.ChannelWatermarks
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId));
Option<VaapiDriver> maybeVaapiDriver = await dbContext.ConfigElements
.GetValue<int>(ConfigElementKey.FFmpegVaapiDriver)
.MapT(i => (VaapiDriver)i);
return Right<BaseError, Process>(
await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
@@ -108,7 +112,8 @@ namespace ErsatzTV.Application.Streaming.Queries
playoutItemWithPath.Path,
playoutItemWithPath.PlayoutItem.StartOffset,
now,
maybeGlobalWatermark));
maybeGlobalWatermark,
maybeVaapiDriver));
},
async error =>
{
@@ -4,6 +4,7 @@ using ErsatzTV.Core.FFmpeg;
using FluentAssertions;
using LanguageExt;
using NUnit.Framework;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Tests.FFmpeg
{
@@ -113,6 +114,161 @@ namespace ErsatzTV.Core.Tests.FFmpeg
});
}
[Test]
[TestCase(
false,
false,
false,
ChannelWatermarkLocation.BottomLeft,
false,
100,
"[0:0][1:v]overlay=x=134:y=H-h-54[v]",
"0:1",
"[v]")]
[TestCase(
false,
false,
false,
ChannelWatermarkLocation.BottomRight,
false,
100,
"[0:0][1:v]overlay=x=W-w-134:y=H-h-54[v]",
"0:1",
"[v]")]
[TestCase(
false,
false,
false,
ChannelWatermarkLocation.TopLeft,
false,
100,
"[0:0][1:v]overlay=x=134:y=54[v]",
"0:1",
"[v]")]
[TestCase(
false,
false,
false,
ChannelWatermarkLocation.TopRight,
false,
100,
"[0:0][1:v]overlay=x=W-w-134:y=54[v]",
"0:1",
"[v]")]
[TestCase(
false,
false,
true,
ChannelWatermarkLocation.TopLeft,
false,
100,
"[0:0][1:v]overlay=x=134:y=54:enable='lt(mod(mod(time(0),60*60),10*60),15)'[v]",
"0:1",
"[v]")]
[TestCase(
false,
false,
false,
ChannelWatermarkLocation.TopLeft,
true,
100,
"[1:v]scale=384:-1[wmp];[0:0][wmp]overlay=x=134:y=54[v]",
"0:1",
"[v]")]
[TestCase(
false,
false,
false,
ChannelWatermarkLocation.TopLeft,
false,
90,
"[1:v]format=yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,colorchannelmixer=aa=0.90[wmp];[0:0][wmp]overlay=x=134:y=54[v]",
"0:1",
"[v]")]
[TestCase(
false,
true,
false,
ChannelWatermarkLocation.TopLeft,
false,
100,
"[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
"0:1",
"[v]")]
[TestCase(
false,
true,
false,
ChannelWatermarkLocation.TopLeft,
true,
100,
"[0:0]yadif=1[vt];[1:v]scale=384:-1[wmp];[vt][wmp]overlay=x=134:y=54[v]",
"0:1",
"[v]")]
[TestCase(
true,
true,
false,
ChannelWatermarkLocation.TopLeft,
false,
100,
"[0:1]apad=whole_dur=3300000ms[a];[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
"[a]",
"[v]")]
[TestCase(
true,
false,
false,
ChannelWatermarkLocation.TopLeft,
false,
100,
"[0:1]apad=whole_dur=3300000ms[a];[0:0][1:v]overlay=x=134:y=54[v]",
"[a]",
"[v]")]
public void Should_Return_Watermark(
bool alignAudio,
bool deinterlace,
bool intermittent,
ChannelWatermarkLocation location,
bool scaled,
int opacity,
string expectedVideoFilter,
string expectedAudioLabel,
string expectedVideoLabel)
{
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithWatermark(
Some(
new ChannelWatermark
{
Mode = intermittent
? ChannelWatermarkMode.Intermittent
: ChannelWatermarkMode.Permanent,
DurationSeconds = intermittent ? 15 : 0,
FrequencyMinutes = intermittent ? 10 : 0,
Location = location,
Size = scaled ? ChannelWatermarkSize.Scaled : ChannelWatermarkSize.ActualSize,
WidthPercent = scaled ? 20 : 0,
Opacity = opacity,
HorizontalMarginPercent = 7,
VerticalMarginPercent = 5
}),
new Resolution { Width = 1920, Height = 1080 })
.WithDeinterlace(deinterlace)
.WithAlignedAudio(alignAudio ? Some(TimeSpan.FromMinutes(55)) : None);
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be(expectedAudioLabel);
filter.VideoLabel.Should().Be(expectedVideoLabel);
});
}
[Test]
[TestCase(true, false, false, "[0:0]deinterlace_qsv[v]", "[v]")]
[TestCase(
@@ -258,6 +258,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -283,6 +284,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1280, Height = 720 }
};
@@ -309,6 +311,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -334,6 +337,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeVideo = false,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -359,6 +363,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "testCodec"
@@ -387,6 +392,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "testCodec"
@@ -416,6 +422,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "testCodec"
@@ -444,6 +451,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "libx264"
@@ -473,6 +481,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = false,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "libx264"
@@ -495,12 +504,47 @@ namespace ErsatzTV.Core.Tests.FFmpeg
actual.PadToDesiredResolution.Should().BeFalse();
actual.VideoCodec.Should().Be("copy");
}
[Test]
public void
Should_SetCopyVideoCodec_AndCopyAudioCodec_When_NotTranscoding_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = false,
NormalizeVideo = true,
NormalizeAudio = true,
NormalizeLoudness = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoCodec = "libx264"
};
// not anamorphic
var version = new MediaVersion
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
actual.VideoCodec.Should().Be("copy");
actual.NormalizeLoudness.Should().BeFalse();
actual.AudioCodec.Should().Be("copy");
}
[Test]
public void Should_SetVideoBitrate_When_ContentIsPadded_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoBitrate = 2525
@@ -528,6 +572,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoBitrate = 2525
@@ -556,6 +601,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoBufferSize = 2525
@@ -584,6 +630,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
var ffmpegProfile = new FFmpegProfile
{
Transcode = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
VideoBufferSize = 2525
@@ -612,6 +659,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioCodec = "aac"
};
@@ -658,6 +706,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioCodec = "aac"
};
@@ -681,6 +730,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioCodec = "aac"
};
@@ -704,6 +754,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioBitrate = 2424,
AudioCodec = "ac3"
@@ -728,6 +779,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioBufferSize = 2424,
AudioCodec = "ac3"
@@ -752,6 +804,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioCodec = "ac3",
AudioChannels = 6
@@ -776,6 +829,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioCodec = "ac3",
AudioSampleRate = 48
@@ -800,6 +854,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioChannels = 6
};
@@ -823,6 +878,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioSampleRate = 48
};
@@ -846,6 +902,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
AudioSampleRate = 48,
AudioCodec = "ac3"
@@ -870,6 +927,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = true,
NormalizeLoudness = true
};
@@ -893,6 +951,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Transcode = true,
NormalizeAudio = false,
NormalizeLoudness = true
};
+1 -1
View File
@@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Tests.Fakes
{
public record FakeFileEntry(string Path)
{
public DateTime LastWriteTime { get; set; } = DateTime.MinValue;
public DateTime LastWriteTime { get; set; } = SystemTime.MinValueUtc;
}
}
@@ -41,7 +41,7 @@ namespace ErsatzTV.Core.Tests.Fakes
public DateTime GetLastWriteTime(string path) =>
Optional(_files.SingleOrDefault(f => f.Path == path))
.Map(f => f.LastWriteTime)
.IfNone(DateTime.MinValue);
.IfNone(SystemTime.MinValueUtc);
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
_files.Any(f => f.Path.StartsWith(libraryPath.Path + Path.DirectorySeparatorChar));
@@ -20,15 +20,22 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<List<MediaItem>> GetItems(int id) => _data[id].ToList().AsTask();
public Task<List<MediaItem>> GetMultiCollectionItems(int id) => throw new NotSupportedException();
public Task<List<MediaItem>> GetSmartCollectionItems(int id) => throw new NotSupportedException();
public Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id) =>
throw new NotSupportedException();
public Task<List<CollectionWithItems>> GetFakeMultiCollectionCollections(int? collectionId, int? smartCollectionId) =>
throw new NotSupportedException();
public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) => throw new NotSupportedException();
public Task<List<int>> PlayoutIdsUsingMultiCollection(int multiCollectionId) =>
throw new NotSupportedException();
public Task<List<int>> PlayoutIdsUsingSmartCollection(int smartCollectionId) =>
throw new NotSupportedException();
public Task<bool> IsCustomPlaybackOrder(int collectionId) => false.AsTask();
}
}
@@ -7,6 +7,8 @@ namespace ErsatzTV.Core.Domain
public int Id { get; set; }
public string Name { get; set; }
public List<Collection> Collections { get; set; }
public List<SmartCollection> SmartCollections { get; set; }
public List<MultiCollectionItem> MultiCollectionItems { get; set; }
public List<MultiCollectionSmartItem> MultiCollectionSmartItems { get; set; }
}
}
@@ -0,0 +1,12 @@
namespace ErsatzTV.Core.Domain
{
public class MultiCollectionSmartItem
{
public int MultiCollectionId { get; set; }
public MultiCollection MultiCollection { get; set; }
public int SmartCollectionId { get; set; }
public SmartCollection SmartCollection { get; set; }
public bool ScheduleAsGroup { get; set; }
public PlaybackOrder PlaybackOrder { get; set; }
}
}
@@ -0,0 +1,13 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class SmartCollection
{
public int Id { get; set; }
public string Name { get; set; }
public string Query { get; set; }
public List<MultiCollection> MultiCollections { get; set; }
public List<MultiCollectionSmartItem> MultiCollectionSmartItems { get; set; }
}
}
+2
View File
@@ -13,11 +13,13 @@
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id");
public static ConfigElementKey FFmpegVaapiDriver => new("ffmpeg.vaapi_driver");
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size");
public static ConfigElementKey MultiCollectionsPageSize => new("pages.multi_collections.page_size");
public static ConfigElementKey SmartCollectionsPageSize => new("pages.smart_collections.page_size");
public static ConfigElementKey SchedulesPageSize => new("pages.schedules.page_size");
public static ConfigElementKey SchedulesDetailPageSize => new("pages.schedules.detail_page_size");
public static ConfigElementKey PlayoutsPageSize => new("pages.playouts.page_size");
+1 -1
View File
@@ -3,7 +3,7 @@ using System.Diagnostics;
namespace ErsatzTV.Core.Domain
{
[DebuggerDisplay("{EpisodeMetadata[0].Title}")]
[DebuggerDisplay("{EpisodeMetadata[0].Title ?? \"[unknown episode]\"}")]
public class Episode : MediaItem
{
public int SeasonId { get; set; }
+3 -1
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
@@ -13,5 +14,6 @@ namespace ErsatzTV.Core.Domain
public List<PlayoutItem> Items { get; set; }
public PlayoutAnchor Anchor { get; set; }
public List<PlayoutProgramScheduleAnchor> ProgramScheduleAnchors { get; set; }
public TimeSpan? DailyRebuildTime { get; set; }
}
}
@@ -12,6 +12,8 @@
public Collection Collection { get; set; }
public int? MultiCollectionId { get; set; }
public MultiCollection MultiCollection { get; set; }
public int? SmartCollectionId { get; set; }
public SmartCollection SmartCollection { get; set; }
public int? MediaItemId { get; set; }
public MediaItem MediaItem { get; set; }
public CollectionEnumeratorState EnumeratorState { get; set; }
@@ -18,6 +18,8 @@ namespace ErsatzTV.Core.Domain
public MediaItem MediaItem { get; set; }
public int? MultiCollectionId { get; set; }
public MultiCollection MultiCollection { get; set; }
public int? SmartCollectionId { get; set; }
public SmartCollection SmartCollection { get; set; }
public PlaybackOrder PlaybackOrder { get; set; }
}
}
@@ -6,6 +6,7 @@
TelevisionShow = 1,
TelevisionSeason = 2,
Artist = 3,
MultiCollection = 4
MultiCollection = 4,
SmartCollection = 5
}
}
@@ -233,28 +233,39 @@ namespace ErsatzTV.Core.FFmpeg
complexFilter.Append(audioLabel);
}
if (videoFilterQueue.Any())
if (videoFilterQueue.Any() || !string.IsNullOrWhiteSpace(watermarkOverlay))
{
if (hasAudioFilters)
{
complexFilter.Append(';');
}
complexFilter.Append($"[{videoLabel}]");
var filters = string.Join(",", videoFilterQueue);
complexFilter.Append(filters);
if (videoFilterQueue.Any())
{
complexFilter.Append($"[{videoLabel}]");
var filters = string.Join(",", videoFilterQueue);
complexFilter.Append(filters);
}
if (!string.IsNullOrWhiteSpace(watermarkOverlay))
{
complexFilter.Append("[vt]");
if (videoFilterQueue.Any())
{
complexFilter.Append("[vt];");
}
var watermarkLabel = "[1:v]";
if (!string.IsNullOrWhiteSpace(watermarkPreprocess))
{
complexFilter.Append($";{watermarkLabel}{watermarkPreprocess}[wmp]");
complexFilter.Append($"{watermarkLabel}{watermarkPreprocess}[wmp];");
watermarkLabel = "[wmp]";
}
complexFilter.Append($";[vt]{watermarkLabel}{watermarkOverlay}");
complexFilter.Append(
videoFilterQueue.Any()
? $"[vt]{watermarkLabel}{watermarkOverlay}"
: $"[{videoLabel}]{watermarkLabel}{watermarkOverlay}");
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None)
{
complexFilter.Append(",hwupload");
@@ -85,12 +85,12 @@ namespace ErsatzTV.Core.FFmpeg
}
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(version);
if (ffmpegProfile.NormalizeVideo && !sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo && !sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
{
result.PadToDesiredResolution = true;
}
if (ffmpegProfile.NormalizeVideo)
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo)
{
result.VideoTrackTimeScale = 90000;
}
@@ -107,7 +107,7 @@ namespace ErsatzTV.Core.FFmpeg
result.VideoCodec = "copy";
}
if (ffmpegProfile.NormalizeAudio)
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeAudio)
{
result.AudioCodec = ffmpegProfile.AudioCodec;
result.AudioBitrate = ffmpegProfile.AudioBitrate;
@@ -152,7 +152,7 @@ namespace ErsatzTV.Core.FFmpeg
};
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) =>
ffmpegProfile.NormalizeVideo &&
ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo &&
IsIncorrectSize(ffmpegProfile.Resolution, version) ||
IsTooLarge(ffmpegProfile.Resolution, version) ||
IsOddSize(version);
@@ -170,7 +170,7 @@ namespace ErsatzTV.Core.FFmpeg
version.Height % 2 == 1 || version.Width % 2 == 1;
private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaStream videoStream) =>
ffmpegProfile.NormalizeVideo && ffmpegProfile.VideoCodec != videoStream.Codec;
ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo && ffmpegProfile.VideoCodec != videoStream.Codec;
private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaVersion version)
{
@@ -43,6 +43,8 @@ namespace ErsatzTV.Core.FFmpeg
private readonly bool _saveReports;
private FFmpegComplexFilterBuilder _complexFilterBuilder = new();
private bool _isConcat;
private VaapiDriver _vaapiDriver;
private HardwareAccelerationKind _hwAccel;
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports)
{
@@ -50,6 +52,16 @@ namespace ErsatzTV.Core.FFmpeg
_saveReports = saveReports;
}
public FFmpegProcessBuilder WithVaapiDriver(Option<VaapiDriver> maybeVaapiDriver)
{
foreach (VaapiDriver vaapiDriver in maybeVaapiDriver)
{
_vaapiDriver = vaapiDriver;
}
return this;
}
public FFmpegProcessBuilder WithThreads(int threads)
{
_arguments.Add("-threads");
@@ -59,6 +71,8 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegProcessBuilder WithHardwareAcceleration(HardwareAccelerationKind hwAccel)
{
_hwAccel = hwAccel;
switch (hwAccel)
{
case HardwareAccelerationKind.Qsv:
@@ -433,6 +447,19 @@ namespace ErsatzTV.Core.FFmpeg
StandardOutputEncoding = Encoding.UTF8
};
if (_hwAccel == HardwareAccelerationKind.Vaapi)
{
switch (_vaapiDriver)
{
case VaapiDriver.i965:
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "i965");
break;
case VaapiDriver.iHD:
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "iHD");
break;
}
}
if (_saveReports)
{
string fileName = _isConcat
+5 -2
View File
@@ -34,7 +34,8 @@ namespace ErsatzTV.Core.FFmpeg
string path,
DateTimeOffset start,
DateTimeOffset now,
Option<ChannelWatermark> globalWatermark)
Option<ChannelWatermark> globalWatermark,
Option<VaapiDriver> maybeVaapiDriver)
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
@@ -58,6 +59,7 @@ namespace ErsatzTV.Core.FFmpeg
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports)
.WithThreads(playbackSettings.ThreadCount)
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration)
.WithVaapiDriver(maybeVaapiDriver)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
@@ -160,7 +162,8 @@ namespace ErsatzTV.Core.FFmpeg
private WatermarkOptions GetWatermarkOptions(Channel channel, Option<ChannelWatermark> globalWatermark)
{
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && channel.FFmpegProfile.Transcode &&
channel.FFmpegProfile.NormalizeVideo)
{
// check for channel watermark
if (channel.Watermark != null)
+12
View File
@@ -0,0 +1,12 @@
using System.Diagnostics.CodeAnalysis;
namespace ErsatzTV.Core.FFmpeg
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum VaapiDriver
{
Default = 0,
iHD = 1,
i965 = 2
}
}
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Health.Checks
{
public interface IEpisodeMetadataHealthCheck : IHealthCheck
{
}
}
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Health.Checks
{
public interface IFFmpegReportsHealthCheck : IHealthCheck
{
}
}
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Health.Checks
{
public interface IFFmpegVersionHealthCheck : IHealthCheck
{
}
}
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Health.Checks
{
public interface IHardwareAccelerationHealthCheck : IHealthCheck
{
}
}
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Health.Checks
{
public interface IMovieMetadataHealthCheck : IHealthCheck
{
}
}
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Health.Checks
{
public interface IVaapiDriverHealthCheck : IHealthCheck
{
}
}
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Health.Checks
{
public interface IZeroDurationHealthCheck : IHealthCheck
{
}
}
@@ -0,0 +1,4 @@
namespace ErsatzTV.Core.Health
{
public record HealthCheckResult(string Title, HealthCheckStatus Status, string Message);
}
+11
View File
@@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Health
{
public enum HealthCheckStatus
{
Pass = 0,
Fail = 1,
Warning = 2,
Info = 3,
NotApplicable = 4
}
}
+9
View File
@@ -0,0 +1,9 @@
using System.Threading.Tasks;
namespace ErsatzTV.Core.Health
{
public interface IHealthCheck
{
Task<HealthCheckResult> Check();
}
}
@@ -0,0 +1,10 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace ErsatzTV.Core.Health
{
public interface IHealthCheckService
{
Task<List<HealthCheckResult>> PerformHealthChecks();
}
}
@@ -11,9 +11,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id);
Task<List<MediaItem>> GetItems(int id);
Task<List<MediaItem>> GetMultiCollectionItems(int id);
Task<List<MediaItem>> GetSmartCollectionItems(int id);
Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id);
Task<List<CollectionWithItems>> GetFakeMultiCollectionCollections(int? collectionId, int? smartCollectionId);
Task<List<int>> PlayoutIdsUsingCollection(int collectionId);
Task<List<int>> PlayoutIdsUsingMultiCollection(int multiCollectionId);
Task<List<int>> PlayoutIdsUsingSmartCollection(int smartCollectionId);
Task<bool> IsCustomPlaybackOrder(int collectionId);
}
}
+65 -56
View File
@@ -36,32 +36,39 @@ namespace ErsatzTV.Core.Iptv
xml.WriteStartElement("tv");
xml.WriteAttributeString("generator-info-name", "ersatztv");
var sortedChannelItems = new Dictionary<Channel, List<PlayoutItem>>();
foreach (Channel channel in _channels.OrderBy(c => decimal.Parse(c.Number)))
{
xml.WriteStartElement("channel");
xml.WriteAttributeString("id", channel.Number);
var sortedItems = channel.Playouts.Collect(p => p.Items).OrderBy(x => x.Start).ToList();
sortedChannelItems.Add(channel, sortedItems);
xml.WriteStartElement("display-name");
xml.WriteAttributeString("lang", "en");
xml.WriteString(channel.Name);
xml.WriteEndElement(); // display-name
if (sortedItems.Any())
{
xml.WriteStartElement("channel");
xml.WriteAttributeString("id", $"{channel.Number}.etv");
xml.WriteStartElement("icon");
string logo = Optional(channel.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Match(
artwork => $"{_scheme}://{_host}/iptv/logos/{artwork.Path}",
() => $"{_scheme}://{_host}/iptv/images/ersatztv-500.png");
xml.WriteAttributeString("src", logo);
xml.WriteEndElement(); // icon
xml.WriteStartElement("display-name");
xml.WriteAttributeString("lang", "en");
xml.WriteString(channel.Name);
xml.WriteEndElement(); // display-name
xml.WriteEndElement(); // channel
xml.WriteStartElement("icon");
string logo = Optional(channel.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Match(
artwork => $"{_scheme}://{_host}/iptv/logos/{artwork.Path}.jpg",
() => $"{_scheme}://{_host}/iptv/images/ersatztv-500.png");
xml.WriteAttributeString("src", logo);
xml.WriteEndElement(); // icon
xml.WriteEndElement(); // channel
}
}
foreach (Channel channel in _channels.OrderBy(c => c.Number))
foreach ((Channel channel, List<PlayoutItem> sorted) in sortedChannelItems.OrderBy(kvp => kvp.Key.Number))
{
var sorted = channel.Playouts.Collect(p => p.Items).OrderBy(x => x.Start).ToList();
var i = 0;
while (i < sorted.Count)
{
@@ -90,7 +97,9 @@ namespace ErsatzTV.Core.Iptv
PlayoutItem finishItem = sorted[finishIndex];
i = finishIndex;
// ReSharper disable once StringLiteralTypo
string start = startItem.StartOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
// ReSharper disable once StringLiteralTypo
string stop = finishItem.FinishOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
string title = GetTitle(startItem);
@@ -101,27 +110,51 @@ namespace ErsatzTV.Core.Iptv
xml.WriteStartElement("programme");
xml.WriteAttributeString("start", start);
xml.WriteAttributeString("stop", stop);
xml.WriteAttributeString("channel", channel.Number);
xml.WriteAttributeString("channel", $"{channel.Number}.etv");
xml.WriteStartElement("title");
xml.WriteAttributeString("lang", "en");
xml.WriteString(title);
xml.WriteEndElement(); // title
if (!string.IsNullOrWhiteSpace(subtitle))
{
xml.WriteStartElement("sub-title");
xml.WriteAttributeString("lang", "en");
xml.WriteString(subtitle);
xml.WriteEndElement(); // subtitle
}
if (!isSameCustomShow)
{
if (!string.IsNullOrWhiteSpace(description))
{
xml.WriteStartElement("desc");
xml.WriteAttributeString("lang", "en");
xml.WriteString(description);
xml.WriteEndElement(); // desc
}
}
if (!hasCustomTitle && startItem.MediaItem is Movie movie)
{
xml.WriteStartElement("category");
xml.WriteAttributeString("lang", "en");
xml.WriteString("Movie");
xml.WriteEndElement(); // category
Option<MovieMetadata> maybeMetadata = movie.MovieMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
foreach (MovieMetadata metadata in movie.MovieMetadata.HeadOrNone())
{
MovieMetadata metadata = maybeMetadata.ValueUnsafe();
if (metadata.Year.HasValue)
{
xml.WriteStartElement("date");
xml.WriteString(metadata.Year.Value.ToString());
xml.WriteEndElement(); // date
}
}
xml.WriteStartElement("category");
xml.WriteAttributeString("lang", "en");
xml.WriteString("Movie");
xml.WriteEndElement(); // category
foreach (MovieMetadata metadata in movie.MovieMetadata.HeadOrNone())
{
string poster = Optional(metadata.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
.HeadOrNone()
@@ -136,22 +169,6 @@ namespace ErsatzTV.Core.Iptv
}
}
xml.WriteStartElement("title");
xml.WriteAttributeString("lang", "en");
xml.WriteString(title);
xml.WriteEndElement(); // title
if (!string.IsNullOrWhiteSpace(subtitle))
{
xml.WriteStartElement("sub-title");
xml.WriteAttributeString("lang", "en");
xml.WriteString(subtitle);
xml.WriteEndElement(); // subtitle
}
xml.WriteStartElement("previously-shown");
xml.WriteEndElement(); // previously-shown
if (startItem.MediaItem is Episode episode && (!hasCustomTitle || isSameCustomShow))
{
Option<ShowMetadata> maybeMetadata =
@@ -200,17 +217,9 @@ namespace ErsatzTV.Core.Iptv
}
}
}
if (!isSameCustomShow)
{
if (!string.IsNullOrWhiteSpace(description))
{
xml.WriteStartElement("desc");
xml.WriteAttributeString("lang", "en");
xml.WriteString(description);
xml.WriteEndElement(); // desc
}
}
xml.WriteStartElement("previously-shown");
xml.WriteEndElement(); // previously-shown
foreach (ContentRating rating in contentRating)
{
@@ -267,7 +276,7 @@ namespace ErsatzTV.Core.Iptv
_ => "posters"
};
artworkPath = $"{_scheme}://{_host}/iptv/artwork/{artworkFolder}/{artwork.Path}";
artworkPath = $"{_scheme}://{_host}/iptv/artwork/{artworkFolder}/{artwork.Path}.jpg";
}
return artworkPath;
+2 -2
View File
@@ -32,7 +32,7 @@ namespace ErsatzTV.Core.Iptv
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Match(
artwork => $"{_scheme}://{_host}/iptv/logos/{artwork.Path}",
artwork => $"{_scheme}://{_host}/iptv/logos/{artwork.Path}.jpg",
() => $"{_scheme}://{_host}/iptv/images/ersatztv-500.png");
string shortUniqueId = Convert.ToBase64String(channel.UniqueId.ToByteArray())
@@ -51,7 +51,7 @@ namespace ErsatzTV.Core.Iptv
string acodec = channel.FFmpegProfile.AudioCodec;
sb.AppendLine(
$"#EXTINF:0 tvg-id=\"{channel.Number}\" channel-id=\"{shortUniqueId}\" channel-number=\"{channel.Number}\" CUID=\"{shortUniqueId}\" tvg-chno=\"{channel.Number}\" tvg-name=\"{channel.Name}\" tvg-logo=\"{logo}\" group-title=\"ErsatzTV\" tvc-stream-vcodec=\"{vcodec}\" tvc-stream-acodec=\"{acodec}\", {channel.Name}");
$"#EXTINF:0 tvg-id=\"{channel.Number}.etv\" channel-id=\"{shortUniqueId}\" channel-number=\"{channel.Number}\" CUID=\"{shortUniqueId}\" tvg-chno=\"{channel.Number}\" tvg-name=\"{channel.Name}\" tvg-logo=\"{logo}\" group-title=\"ErsatzTV\" tvc-stream-vcodec=\"{vcodec}\" tvc-stream-acodec=\"{acodec}\", {channel.Name}");
sb.AppendLine($"{_scheme}://{_host}/iptv/channel/{channel.Number}.{format}");
}
@@ -15,7 +15,14 @@ namespace ErsatzTV.Core.Metadata
{
string fileName = Path.GetFileName(showFolder);
var metadata = new ShowMetadata
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? showFolder };
{
MetadataKind = MetadataKind.Fallback,
Title = fileName ?? showFolder,
Genres = new List<Genre>(),
Tags = new List<Tag>(),
Studios = new List<Studio>(),
Actors = new List<Actor>()
};
return GetTelevisionShowMetadata(fileName, metadata);
}
@@ -33,7 +40,7 @@ namespace ErsatzTV.Core.Metadata
var baseMetadata = new EpisodeMetadata
{
MetadataKind = MetadataKind.Fallback,
Title = fileName ?? path,
Title = Path.GetFileNameWithoutExtension(path) ?? path,
DateAdded = DateTime.UtcNow,
EpisodeNumber = 0,
Actors = new List<Actor>(),
@@ -54,7 +61,17 @@ namespace ErsatzTV.Core.Metadata
{
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileName(path);
var metadata = new MovieMetadata { MetadataKind = MetadataKind.Fallback, Title = fileName ?? path };
var metadata = new MovieMetadata
{
MetadataKind = MetadataKind.Fallback,
Title = Path.GetFileNameWithoutExtension(path) ?? path,
Genres = new List<Genre>(),
Tags = new List<Tag>(),
Studios = new List<Studio>(),
Actors = new List<Actor>(),
Directors = new List<Director>(),
Writers = new List<Writer>()
};
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
}
@@ -128,6 +145,7 @@ namespace ErsatzTV.Core.Metadata
EpisodeNumber = episodeNumber,
DateAdded = baseMetadata.DateAdded,
DateUpdated = baseMetadata.DateAdded,
Title = baseMetadata.Title,
Actors = new List<Actor>(),
Artwork = new List<Artwork>(),
Directors = new List<Director>(),
@@ -164,12 +182,6 @@ namespace ErsatzTV.Core.Metadata
metadata.Title = match.Groups[1].Value.Trim();
metadata.Year = int.Parse(match.Groups[2].Value);
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
metadata.Genres = new List<Genre>();
metadata.Tags = new List<Tag>();
metadata.Studios = new List<Studio>();
metadata.Actors = new List<Actor>();
metadata.Directors = new List<Director>();
metadata.Writers = new List<Writer>();
metadata.DateUpdated = DateTime.UtcNow;
}
}
@@ -214,10 +226,6 @@ namespace ErsatzTV.Core.Metadata
metadata.Title = match.Groups[1].Value;
metadata.Year = int.Parse(match.Groups[2].Value);
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
metadata.Genres = new List<Genre>();
metadata.Tags = new List<Tag>();
metadata.Studios = new List<Studio>();
metadata.Actors = new List<Actor>();
metadata.DateUpdated = DateTime.UtcNow;
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ namespace ErsatzTV.Core.Metadata
}
public DateTime GetLastWriteTime(string path) =>
Try(File.GetLastWriteTimeUtc(path)).IfFail(() => DateTime.MinValue);
Try(File.GetLastWriteTimeUtc(path)).IfFail(() => SystemTime.MinValueUtc);
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
Directory.Exists(libraryPath.Path);
@@ -218,7 +218,7 @@ namespace ErsatzTV.Core.Metadata
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
if (existing.DateAdded == DateTime.MinValue)
if (existing.DateAdded == SystemTime.MinValueUtc)
{
existing.DateAdded = metadata.DateAdded;
}
@@ -318,7 +318,7 @@ namespace ErsatzTV.Core.Metadata
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
if (existing.DateAdded == DateTime.MinValue)
if (existing.DateAdded == SystemTime.MinValueUtc)
{
existing.DateAdded = metadata.DateAdded;
}
@@ -423,7 +423,7 @@ namespace ErsatzTV.Core.Metadata
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
if (existing.DateAdded == DateTime.MinValue)
if (existing.DateAdded == SystemTime.MinValueUtc)
{
existing.DateAdded = metadata.DateAdded;
}
@@ -486,7 +486,7 @@ namespace ErsatzTV.Core.Metadata
existing.Disambiguation = metadata.Disambiguation;
existing.Biography = metadata.Biography;
if (existing.DateAdded == DateTime.MinValue)
if (existing.DateAdded == SystemTime.MinValueUtc)
{
existing.DateAdded = metadata.DateAdded;
}
@@ -581,7 +581,7 @@ namespace ErsatzTV.Core.Metadata
existing.Plot = metadata.Plot;
existing.Album = metadata.Album;
if (existing.DateAdded == DateTime.MinValue)
if (existing.DateAdded == SystemTime.MinValueUtc)
{
existing.DateAdded = metadata.DateAdded;
}
@@ -322,7 +322,7 @@ namespace ErsatzTV.Core.Metadata
async () =>
{
bool shouldUpdate = Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
m => m.DateUpdated == DateTime.MinValue,
m => m.DateUpdated == SystemTime.MinValueUtc,
true);
if (shouldUpdate)
@@ -66,13 +66,17 @@ namespace ErsatzTV.Core.Scheduling
int episode1 = x switch
{
Episode e => e.EpisodeMetadata.Max(em => em.EpisodeNumber),
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.EpisodeNumber,
() => int.MaxValue),
_ => int.MaxValue
};
int episode2 = y switch
{
Episode e => e.EpisodeMetadata.Max(em => em.EpisodeNumber),
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.EpisodeNumber,
() => int.MaxValue),
_ => int.MaxValue
};
@@ -121,32 +121,35 @@ namespace ErsatzTV.Core.Scheduling
private static Option<int> FindPartNumber(Episode e)
{
const string PATTERN = @"^.*\((\d+)\)( - .*)?$";
Match match = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN);
if (match.Success && int.TryParse(match.Groups[1].Value, out int value1))
foreach (EpisodeMetadata metadata in e.EpisodeMetadata.HeadOrNone())
{
return value1;
}
const string PATTERN = @"^.*\((\d+)\)( - .*)?$";
Match match = Regex.Match(metadata.Title ?? string.Empty, PATTERN);
if (match.Success && int.TryParse(match.Groups[1].Value, out int value1))
{
return value1;
}
const string PATTERN_2 = @"^.*\(?Part (\d+)\)?$";
Match match2 = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN_2);
if (match2.Success && int.TryParse(match2.Groups[1].Value, out int value2))
{
return value2;
}
const string PATTERN_2 = @"^.*\(?Part (\d+)\)?$";
Match match2 = Regex.Match(metadata.Title ?? string.Empty, PATTERN_2);
if (match2.Success && int.TryParse(match2.Groups[1].Value, out int value2))
{
return value2;
}
const string PATTERN_3 = @"^.*\(([MDCLXVI]+)\)( - .*)?$";
Match match3 = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN_3);
if (match3.Success && TryParseRoman(match3.Groups[1].Value, out int value3))
{
return value3;
}
const string PATTERN_3 = @"^.*\(([MDCLXVI]+)\)( - .*)?$";
Match match3 = Regex.Match(metadata.Title ?? string.Empty, PATTERN_3);
if (match3.Success && TryParseRoman(match3.Groups[1].Value, out int value3))
{
return value3;
}
const string PATTERN_4 = @"^.*Part (\w+)$";
Match match4 = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN_4);
if (match4.Success && TryParseEnglish(match4.Groups[1].Value, out int value4))
{
return value4;
const string PATTERN_4 = @"^.*Part (\w+)$";
Match match4 = Regex.Match(metadata.Title ?? string.Empty, PATTERN_4);
if (match4.Success && TryParseEnglish(match4.Groups[1].Value, out int value4))
{
return value4;
}
}
return None;
+23 -4
View File
@@ -91,6 +91,11 @@ namespace ErsatzTV.Core.Scheduling
await _mediaCollectionRepository.GetMultiCollectionItems(
collectionKey.MultiCollectionId ?? 0);
return Tuple(collectionKey, multiCollectionItems);
case ProgramScheduleItemCollectionType.SmartCollection:
List<MediaItem> smartCollectionItems =
await _mediaCollectionRepository.GetSmartCollectionItems(
collectionKey.SmartCollectionId ?? 0);
return Tuple(collectionKey, smartCollectionItems);
default:
return Tuple(collectionKey, new List<MediaItem>());
}
@@ -98,9 +103,9 @@ namespace ErsatzTV.Core.Scheduling
var collectionMediaItems = Map.createRange(tuples);
// using IDisposable scope = _logger.BeginScope(new { PlayoutId = playout.Id });
_logger.LogDebug(
$"{(rebuild ? "Rebuilding" : "Building")} playout {{PlayoutId}} for channel {{ChannelNumber}} - {{ChannelName}}",
"{Action} playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}",
rebuild ? "Rebuilding" : "Building",
playout.Id,
playout.Channel.Number,
playout.Channel.Name);
@@ -369,9 +374,9 @@ namespace ErsatzTV.Core.Scheduling
}
bool willNotFinishInTime =
currentTime <= durationFinish.IfNone(DateTime.MinValue) &&
currentTime <= durationFinish.IfNone(SystemTime.MinValueUtc) &&
currentTime + peekVersion.Duration >
durationFinish.IfNone(DateTime.MinValue);
durationFinish.IfNone(SystemTime.MinValueUtc);
if (willNotFinishInTime)
{
_logger.LogDebug(
@@ -514,6 +519,7 @@ namespace ErsatzTV.Core.Scheduling
CollectionType = collectionKey.CollectionType,
CollectionId = collectionKey.CollectionId,
MultiCollectionId = collectionKey.MultiCollectionId,
SmartCollectionId = collectionKey.SmartCollectionId,
MediaItemId = collectionKey.MediaItemId,
EnumeratorState = maybeEnumeratorState[collectionKey]
});
@@ -535,6 +541,7 @@ namespace ErsatzTV.Core.Scheduling
&& a.CollectionType == collectionKey.CollectionType
&& a.CollectionId == collectionKey.CollectionId
&& a.MultiCollectionId == collectionKey.MultiCollectionId
&& a.SmartCollectionId == collectionKey.SmartCollectionId
&& a.MediaItemId == collectionKey.MediaItemId);
CollectionEnumeratorState state = maybeAnchor.Match(
@@ -606,6 +613,12 @@ namespace ErsatzTV.Core.Scheduling
result = await _mediaCollectionRepository.GetMultiCollectionCollections(
collectionKey.MultiCollectionId.Value);
}
else
{
result = await _mediaCollectionRepository.GetFakeMultiCollectionCollections(
collectionKey.CollectionId,
collectionKey.SmartCollectionId);
}
return result;
}
@@ -661,6 +674,11 @@ namespace ErsatzTV.Core.Scheduling
CollectionType = item.CollectionType,
MultiCollectionId = item.MultiCollectionId
},
ProgramScheduleItemCollectionType.SmartCollection => new CollectionKey
{
CollectionType = item.CollectionType,
SmartCollectionId = item.SmartCollectionId
},
_ => throw new ArgumentOutOfRangeException(nameof(item))
};
@@ -669,6 +687,7 @@ namespace ErsatzTV.Core.Scheduling
public ProgramScheduleItemCollectionType CollectionType { get; set; }
public int? CollectionId { get; set; }
public int? MultiCollectionId { get; set; }
public int? SmartCollectionId { get; set; }
public int? MediaItemId { get; set; }
}
}
@@ -147,13 +147,7 @@ namespace ErsatzTV.Core.Scheduling
ordered.AddRange(larger);
}
int offset = random.Next(ordered.Count);
result.Add(
new OrderedCollection
{
Index = 0,
Items = ordered.Skip(offset).Concat(ordered.Take(offset)).ToList()
});
result.Add(new OrderedCollection { Index = 0, Items = ordered });
}
return result;
+1 -1
View File
@@ -1,4 +1,4 @@
namespace ErsatzTV.Core.Search
{
public record SearchItem(int Id);
public record SearchItem(string Type, int Id);
}
+9
View File
@@ -0,0 +1,9 @@
using System;
namespace ErsatzTV.Core
{
public static class SystemTime
{
public static DateTime MinValueUtc = new(0, DateTimeKind.Utc);
}
}
@@ -22,6 +22,19 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
.HasForeignKey(mci => mci.MultiCollectionId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasKey(mci => new { mci.MultiCollectionId, mci.CollectionId }));
builder.HasMany(m => m.SmartCollections)
.WithMany(m => m.MultiCollections)
.UsingEntity<MultiCollectionSmartItem>(
j => j.HasOne(mci => mci.SmartCollection)
.WithMany(c => c.MultiCollectionSmartItems)
.HasForeignKey(mci => mci.SmartCollectionId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasOne(mci => mci.MultiCollection)
.WithMany(mc => mc.MultiCollectionSmartItems)
.HasForeignKey(mci => mci.MultiCollectionId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasKey(mci => new { mci.MultiCollectionId, mci.SmartCollectionId }));
}
}
}
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class SmartCollectionConfiguration : IEntityTypeConfiguration<SmartCollection>
{
public void Configure(EntityTypeBuilder<SmartCollection> builder) => builder.ToTable("SmartCollection");
}
}
@@ -5,8 +5,11 @@ using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Extensions;
using ErsatzTV.Infrastructure.Search;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
@@ -16,12 +19,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public class MediaCollectionRepository : IMediaCollectionRepository
{
private readonly IDbConnection _dbConnection;
private readonly ISearchIndex _searchIndex;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public MediaCollectionRepository(
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory,
IDbConnection dbConnection)
{
_searchIndex = searchIndex;
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
@@ -60,6 +66,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
Option<MultiCollection> maybeMultiCollection = await dbContext.MultiCollections
.Include(mc => mc.Collections)
.Include(mc => mc.SmartCollections)
.SelectOneAsync(mc => mc.Id, mc => mc.Id == id);
foreach (MultiCollection multiCollection in maybeMultiCollection)
@@ -73,11 +80,62 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
result.AddRange(await GetArtistItems(dbContext, collectionId));
result.AddRange(await GetMusicVideoItems(dbContext, collectionId));
}
foreach (int smartCollectionId in multiCollection.SmartCollections.Map(c => c.Id))
{
result.AddRange(await GetSmartCollectionItems(smartCollectionId));
}
}
return result.Distinct().ToList();
}
public async Task<List<MediaItem>> GetSmartCollectionItems(int id)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
var result = new List<MediaItem>();
Option<SmartCollection> maybeCollection = await dbContext.SmartCollections
.SelectOneAsync(sc => sc.Id, sc => sc.Id == id);
foreach (SmartCollection collection in maybeCollection)
{
SearchResult searchResults = await _searchIndex.Search(collection.Query, 0, 0);
var movieIds = searchResults.Items
.Filter(i => i.Type == SearchIndex.MovieType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetMovieItems(dbContext, movieIds));
foreach (int showId in searchResults.Items.Filter(i => i.Type == SearchIndex.ShowType).Map(i => i.Id))
{
result.AddRange(await GetShowItemsFromShowId(dbContext, showId));
}
foreach (int artistId in searchResults.Items.Filter(i => i.Type == SearchIndex.ArtistType)
.Map(i => i.Id))
{
result.AddRange(await GetArtistItemsFromArtistId(dbContext, artistId));
}
var musicVideoIds = searchResults.Items
.Filter(i => i.Type == SearchIndex.MusicVideoType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetMusicVideoItems(dbContext, musicVideoIds));
var episodeIds = searchResults.Items
.Filter(i => i.Type == SearchIndex.EpisodeType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetEpisodeItems(dbContext, episodeIds));
}
return result;
}
public async Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
@@ -86,8 +144,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
Option<MultiCollection> maybeMultiCollection = await dbContext.MultiCollections
.Include(mc => mc.Collections)
.Include(mc => mc.SmartCollections)
.Include(mc => mc.MultiCollectionItems)
.ThenInclude(mci => mci.Collection)
.Include(mc => mc.MultiCollectionSmartItems)
.ThenInclude(mci => mci.SmartCollection)
.SelectOneAsync(mc => mc.Id, mc => mc.Id == id);
foreach (MultiCollection multiCollection in maybeMultiCollection)
@@ -126,6 +187,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
multiCollectionItem.Collection.UseCustomPlaybackOrder));
}
}
foreach (MultiCollectionSmartItem multiCollectionSmartItem in multiCollection.MultiCollectionSmartItems)
{
List<MediaItem> items = await GetSmartCollectionItems(multiCollectionSmartItem.SmartCollectionId);
result.Add(
new CollectionWithItems(
multiCollectionSmartItem.SmartCollectionId,
items,
multiCollectionSmartItem.ScheduleAsGroup,
multiCollectionSmartItem.PlaybackOrder,
false));
}
}
// remove duplicate items from ungrouped collections
@@ -143,6 +217,93 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return result;
}
public async Task<List<CollectionWithItems>> GetFakeMultiCollectionCollections(
int? collectionId,
int? smartCollectionId)
{
var items = new List<MediaItem>();
if (collectionId.HasValue)
{
items = await GetItems(collectionId.Value);
}
if (smartCollectionId.HasValue)
{
items = await GetSmartCollectionItems(smartCollectionId.Value);
}
return GroupIntoFakeCollections(items);
}
private static List<CollectionWithItems> GroupIntoFakeCollections(List<MediaItem> items)
{
int id = -1;
var result = new List<CollectionWithItems>();
var showCollections = new Dictionary<int, List<MediaItem>>();
foreach (Episode episode in items.OfType<Episode>())
{
List<MediaItem> list = showCollections.ContainsKey(episode.Season.ShowId)
? showCollections[episode.Season.ShowId]
: new List<MediaItem>();
if (list.All(i => i.Id != episode.Id))
{
list.Add(episode);
}
showCollections[episode.Season.ShowId] = list;
}
foreach ((int _, List<MediaItem> list) in showCollections)
{
result.Add(
new CollectionWithItems(
id--,
list,
true,
PlaybackOrder.Chronological,
false));
}
var artistCollections = new Dictionary<int, List<MediaItem>>();
foreach (MusicVideo musicVideo in items.OfType<MusicVideo>())
{
List<MediaItem> list = artistCollections.ContainsKey(musicVideo.ArtistId)
? artistCollections[musicVideo.ArtistId]
: new List<MediaItem>();
if (list.All(i => i.Id != musicVideo.Id))
{
list.Add(musicVideo);
}
artistCollections[musicVideo.ArtistId] = list;
}
foreach ((int _, List<MediaItem> list) in artistCollections)
{
result.Add(
new CollectionWithItems(
id--,
list,
true,
PlaybackOrder.Chronological,
false));
}
result.Add(
new CollectionWithItems(
id,
items.OfType<Movie>().Cast<MediaItem>().ToList(),
true,
PlaybackOrder.Chronological,
false));
return result;
}
public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) =>
_dbConnection.QueryAsync<int>(
@"SELECT DISTINCT p.PlayoutId
@@ -159,6 +320,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
new { MultiCollectionId = multiCollectionId })
.Map(result => result.ToList());
public Task<List<int>> PlayoutIdsUsingSmartCollection(int smartCollectionId) =>
_dbConnection.QueryAsync<int>(
@"SELECT DISTINCT p.PlayoutId
FROM PlayoutProgramScheduleAnchor p
WHERE p.SmartCollectionId = @SmartCollectionId",
new { SmartCollectionId = smartCollectionId })
.Map(result => result.ToList());
public Task<bool> IsCustomPlaybackOrder(int collectionId) =>
_dbConnection.QuerySingleAsync<bool>(
@"SELECT IFNULL(MIN(UseCustomPlaybackOrder), 0) FROM Collection WHERE Id = @CollectionId",
@@ -172,12 +341,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collectionId });
return await dbContext.Movies
return await GetMovieItems(dbContext, ids);
}
private static Task<List<Movie>> GetMovieItems(TvContext dbContext, IEnumerable<int> movieIds) =>
dbContext.Movies
.Include(m => m.MovieMetadata)
.Include(m => m.MediaVersions)
.Filter(m => ids.Contains(m.Id))
.Filter(m => movieIds.Contains(m.Id))
.ToListAsync();
}
private async Task<List<MusicVideo>> GetArtistItems(TvContext dbContext, int collectionId)
{
@@ -188,15 +360,30 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collectionId });
return await dbContext.MusicVideos
return await GetArtistItemsFromMusicVideoIds(dbContext, ids);
}
private static Task<List<MusicVideo>> GetArtistItemsFromMusicVideoIds(
TvContext dbContext,
IEnumerable<int> musicVideoIds) =>
dbContext.MusicVideos
.Include(m => m.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions)
.Filter(m => ids.Contains(m.Id))
.Filter(m => musicVideoIds.Contains(m.Id))
.ToListAsync();
}
private async Task<List<MusicVideo>> GetArtistItemsFromArtistId(TvContext dbContext, int artistId)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT MusicVideo.Id FROM Artist
INNER JOIN MusicVideo on Artist.Id = MusicVideo.ArtistId
WHERE Artist.Id = @ArtistId",
new { ArtistId = artistId });
return await GetArtistItemsFromMusicVideoIds(dbContext, ids);
}
private async Task<List<MusicVideo>> GetMusicVideoItems(TvContext dbContext, int collectionId)
{
@@ -206,14 +393,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collectionId });
return await dbContext.MusicVideos
return await GetMusicVideoItems(dbContext, ids);
}
private static Task<List<MusicVideo>> GetMusicVideoItems(TvContext dbContext, IEnumerable<int> musicVideoIds) =>
dbContext.MusicVideos
.Include(m => m.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions)
.Filter(m => ids.Contains(m.Id))
.Filter(m => musicVideoIds.Contains(m.Id))
.ToListAsync();
}
private async Task<List<Episode>> GetShowItems(TvContext dbContext, int collectionId)
{
@@ -225,14 +415,29 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collectionId });
return await dbContext.Episodes
return await GetShowItemsFromEpisodeIds(dbContext, ids);
}
private static Task<List<Episode>> GetShowItemsFromEpisodeIds(TvContext dbContext, IEnumerable<int> episodeIds) =>
dbContext.Episodes
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(e => ids.Contains(e.Id))
.Filter(e => episodeIds.Contains(e.Id))
.ToListAsync();
private async Task<List<Episode>> GetShowItemsFromShowId(TvContext dbContext, int showId)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT Episode.Id FROM Show
INNER JOIN Season ON Season.ShowId = Show.Id
INNER JOIN Episode ON Episode.SeasonId = Season.Id
WHERE Show.Id = @ShowId",
new { ShowId = showId });
return await GetShowItemsFromEpisodeIds(dbContext, ids);
}
private async Task<List<Episode>> GetSeasonItems(TvContext dbContext, int collectionId)
@@ -244,14 +449,28 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collectionId });
return await dbContext.Episodes
return await GetSeasonItemsFromEpisodeIds(dbContext, ids);
}
private static Task<List<Episode>> GetSeasonItemsFromEpisodeIds(TvContext dbContext, IEnumerable<int> episodeIds) =>
dbContext.Episodes
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(e => ids.Contains(e.Id))
.Filter(e => episodeIds.Contains(e.Id))
.ToListAsync();
private async Task<List<Episode>> GetSeasonItemsFromSeasonId(TvContext dbContext, int seasonId)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT Episode.Id FROM Season
INNER JOIN Episode ON Episode.SeasonId = Season.Id
WHERE Season.Id = @SeasonId",
new { SeasonId = seasonId });
return await GetSeasonItemsFromEpisodeIds(dbContext, ids);
}
private async Task<List<Episode>> GetEpisodeItems(TvContext dbContext, int collectionId)
@@ -262,14 +481,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collectionId });
return await dbContext.Episodes
return await GetEpisodeItems(dbContext, ids);
}
private static Task<List<Episode>> GetEpisodeItems(TvContext dbContext, IEnumerable<int> episodeIds) =>
dbContext.Episodes
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(e => ids.Contains(e.Id))
.Filter(e => episodeIds.Contains(e.Id))
.ToListAsync();
}
}
}
@@ -144,7 +144,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
@"SELECT m1.ShowId
FROM ShowMetadata m1
LEFT OUTER JOIN ShowMetadata m2 ON m2.ShowId = @ShowId
WHERE m1.Title = m2.Title AND m1.Year = m2.Year",
WHERE m1.Title = m2.Title AND m1.Year is m2.Year",
new { ShowId = televisionShowId })
.Map(results => results.ToList());
@@ -703,7 +703,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
new()
{
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.MinValue,
DateUpdated = SystemTime.MinValueUtc,
MetadataKind = MetadataKind.Fallback,
Actors = new List<Actor>(),
Guids = new List<MetadataGuid>(),
@@ -59,6 +59,7 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<Collection> Collections { get; set; }
public DbSet<CollectionItem> CollectionItems { get; set; }
public DbSet<MultiCollection> MultiCollections { get; set; }
public DbSet<SmartCollection> SmartCollections { get; set; }
public DbSet<ProgramSchedule> ProgramSchedules { get; set; }
public DbSet<ProgramScheduleItem> ProgramScheduleItems { get; set; }
public DbSet<Playout> Playouts { get; set; }
@@ -16,12 +16,12 @@
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00014" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00014" />
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00014" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.9">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.10" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

Some files were not shown because too many files have changed in this diff Show More