Compare commits

...
Author SHA1 Message Date
Jason DoveandGitHub 2be729c10e search show by language (#149) 2021-04-06 07:58:45 -05:00
Jason DoveandGitHub 0aac702853 search movies and music videos by language (#148) 2021-04-06 07:42:21 -05:00
Jason DoveandGitHub 3f406ac556 log viewer improvements (#147) 2021-04-06 07:25:05 -05:00
Jason DoveandGitHub 454e2edf7c add documentation link to ui (#146) 2021-04-06 06:39:48 -05:00
b3f4fa8c23 add documentation (#145)
* start to reorganize documentation

* revert readme tag changes

* revert readme tag changes pt2

* doc updates; doc theme updates

* doc updates

* publish docs from documentation branch

* use favicon for docs

* create channel

* collections, jellyfin client, schedule items and playout docs

* channels dvr

* tivimate

* scale tivimate screenshots

* add channels dvr server setup

* add copyright and social

* Added UnRAID Docker install, formatting fixes (#100)

Co-authored-by: Thaddeus Cooper <redacted@redacted.co.nz>

* minor doc updates

* readme tweak

* add basic plex documentation

Co-authored-by: suckerface <9060047+suckerface@users.noreply.github.com>
Co-authored-by: Thaddeus Cooper <redacted@redacted.co.nz>
2021-04-06 06:20:49 -05:00
Jason DoveandGitHub a6496db58d settings rework (#144)
* add hdhr tuner count setting

* code cleanup
2021-04-05 19:48:17 -05:00
Jason Dove 3eed79b5e1 Merge branch 'main' of github.com:jasongdove/ErsatzTV 2021-04-05 16:19:22 -05:00
Jason DoveandGitHub 79bfba6428 better search index thread fix (#143)
* Revert "fix search index threading (#141)"

This reverts commit 3fb6da0754.

* better search index thread fix
2021-04-05 16:18:17 -05:00
Jason Dove 9f6d4114a6 Merge branch 'main' of github.com:jasongdove/ErsatzTV 2021-04-05 16:06:28 -05:00
Jason DoveandGitHub 9809c60924 send all audio streams on hls channels with no preferred language (#142)
* Revert "fix search index threading (#141)"

This reverts commit 3fb6da0754.

* send all audio streams on hls channels with no preferred language
2021-04-05 16:06:13 -05:00
Jason Dove 16072fed1c Revert "fix search index threading (#141)"
This reverts commit 3fb6da0754.
2021-04-05 07:44:42 -05:00
Jason DoveandGitHub 3fb6da0754 fix search index threading (#141)
* fix search index threading

* code cleanup
2021-04-05 05:41:29 -05:00
Jason DoveandGitHub 24cdf6295f clean up fragment letter anchor code (#140) 2021-04-04 20:56:47 -05:00
Jason DoveandGitHub c1b41e2865 use fragment navigation with letter bar (#139) 2021-04-04 20:30:40 -05:00
Jason DoveandGitHub d249e95f12 fix poster width (#138) 2021-04-04 20:13:29 -05:00
85 changed files with 976 additions and 261 deletions
+4
View File
@@ -79,3 +79,7 @@ indent_size=2
indent_style=space
indent_size=4
tab_width=4
[*.yml]
indent_style = space
indent_size = 2
+19
View File
@@ -0,0 +1,19 @@
name: Publish docs via GitHub Pages
on:
push:
branches:
- main
jobs:
build:
name: Deploy docs
runs-on: ubuntu-latest
steps:
- name: Checkout master
uses: actions/checkout@v1
- name: Deploy docs
uses: mhausenblas/mkdocs-deploy-gh-pages@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CUSTOM_DOMAIN: ersatztv.org
@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.HDHR.Commands
{
public record UpdateHDHRTunerCount(int TunerCount) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,45 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.HDHR.Commands
{
public class UpdateHDHRTunerCountHandler : MediatR.IRequestHandler<UpdateHDHRTunerCount, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
public UpdateHDHRTunerCountHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public Task<Either<BaseError, Unit>> Handle(
UpdateHDHRTunerCount request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => Upsert(ConfigElementKey.HDHRTunerCount, request.TunerCount.ToString()))
.Bind(v => v.ToEitherAsync());
private Task<Validation<BaseError, Unit>> Validate(UpdateHDHRTunerCount request) =>
Optional(request.TunerCount)
.Filter(tc => tc > 0)
.Map(_ => Unit.Default)
.ToValidation<BaseError>("Tuner count must be greater than zero")
.AsTask();
private Task<Unit> Upsert(ConfigElementKey key, string value) =>
_configElementRepository.Get(key).Match(
ce =>
{
ce.Value = value;
return _configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement { Key = key.Key, Value = value };
return _configElementRepository.Add(ce);
}).ToUnit();
}
}
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.HDHR.Queries
{
public record GetHDHRTunerCount : IRequest<int>;
}
@@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.HDHR.Queries
{
public class GetHDHRTunerCountHandler : IRequestHandler<GetHDHRTunerCount, int>
{
private readonly IConfigElementRepository _configElementRepository;
public GetHDHRTunerCountHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public Task<int> Handle(GetHDHRTunerCount request, CancellationToken cancellationToken) =>
_configElementRepository.GetValue<int>(ConfigElementKey.HDHRTunerCount)
.Map(result => result.IfNone(2));
}
}
@@ -1,12 +1,12 @@
using System;
using Serilog.Events;
namespace ErsatzTV.Application.Logs
{
public record LogEntryViewModel(
int Id,
DateTime Timestamp,
string Level,
LogEventLevel Level,
string Exception,
string RenderedMessage,
string Properties);
string Message);
}
+35 -6
View File
@@ -1,16 +1,45 @@
using ErsatzTV.Core.Domain;
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using Newtonsoft.Json.Linq;
using Serilog.Events;
namespace ErsatzTV.Application.Logs
{
internal static class Mapper
{
internal static LogEntryViewModel ProjectToViewModel(LogEntry logEntry) =>
new(
internal static LogEntryViewModel ProjectToViewModel(LogEntry logEntry)
{
string message = logEntry.RenderedMessage;
if (!string.IsNullOrWhiteSpace(logEntry.Properties))
{
foreach (KeyValuePair<string, JToken> property in JObject.Parse(logEntry.Properties))
{
var token = $"{{{property.Key}}}";
if (message.Contains(token))
{
message = message.Replace(token, property.Value.ToString());
}
var destructureToken = $"{{@{property.Key}}}";
if (message.Contains(destructureToken))
{
message = message.Replace(destructureToken, property.Value.ToString());
}
}
}
if (!Enum.TryParse(logEntry.Level, out LogEventLevel level))
{
level = LogEventLevel.Debug;
}
return new LogEntryViewModel(
logEntry.Id,
logEntry.Timestamp,
logEntry.Level,
level,
logEntry.Exception,
logEntry.RenderedMessage,
logEntry.Properties);
message);
}
}
}
@@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
@@ -14,6 +15,7 @@ namespace ErsatzTV.Application.Search.Commands
public class RebuildSearchIndexHandler : MediatR.IRequestHandler<RebuildSearchIndex, Unit>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RebuildSearchIndexHandler> _logger;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
@@ -22,18 +24,22 @@ namespace ErsatzTV.Application.Search.Commands
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem,
ILogger<RebuildSearchIndexHandler> logger)
{
_searchIndex = searchIndex;
_logger = logger;
_searchRepository = searchRepository;
_configElementRepository = configElementRepository;
_localFileSystem = localFileSystem;
}
public async Task<Unit> Handle(RebuildSearchIndex request, CancellationToken cancellationToken)
{
bool indexFolderExists = Directory.Exists(FileSystemLayout.SearchIndexFolder);
await _searchIndex.Initialize(_localFileSystem);
if (!indexFolderExists ||
await _configElementRepository.GetValue<int>(ConfigElementKey.SearchIndexVersion) <
_searchIndex.Version)
@@ -41,7 +47,7 @@ namespace ErsatzTV.Application.Search.Commands
_logger.LogDebug("Migrating search index to version {Version}", _searchIndex.Version);
List<int> itemIds = await _searchRepository.GetItemIdsToIndex();
await _searchIndex.Rebuild(itemIds);
await _searchIndex.Rebuild(_searchRepository, itemIds);
Option<ConfigElement> maybeVersion =
await _configElementRepository.Get(ConfigElementKey.SearchIndexVersion);
@@ -557,6 +557,7 @@ namespace ErsatzTV.Core.Tests.Metadata
new Mock<IMetadataRepository>().Object,
_imageCache.Object,
new Mock<ISearchIndex>().Object,
new Mock<ISearchRepository>().Object,
new Mock<IMediator>().Object,
new Mock<ILogger<MovieFolderScanner>>().Object
);
+1
View File
@@ -13,5 +13,6 @@
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
}
}
@@ -68,12 +68,12 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public Option<FFmpegComplexFilter> Build(int videoStreamIndex, int audioStreamIndex)
public Option<FFmpegComplexFilter> Build(int videoStreamIndex, Option<int> audioStreamIndex)
{
var complexFilter = new StringBuilder();
var videoLabel = $"0:{videoStreamIndex}";
var audioLabel = $"0:{audioStreamIndex}";
string audioLabel = audioStreamIndex.Match(index => $"0:{index}", () => "0:a");
HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None);
bool isHardwareDecode = acceleration switch
@@ -22,6 +22,7 @@ using System;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.FFmpeg
@@ -46,7 +47,7 @@ namespace ErsatzTV.Core.FFmpeg
FFmpegProfile ffmpegProfile,
MediaVersion version,
MediaStream videoStream,
MediaStream audioStream,
Option<MediaStream> audioStream,
DateTimeOffset start,
DateTimeOffset now)
{
@@ -113,10 +114,14 @@ namespace ErsatzTV.Core.FFmpeg
result.AudioBitrate = ffmpegProfile.AudioBitrate;
result.AudioBufferSize = ffmpegProfile.AudioBufferSize;
if (audioStream.Channels != ffmpegProfile.AudioChannels)
{
result.AudioChannels = ffmpegProfile.AudioChannels;
}
audioStream.IfSome(
stream =>
{
if (stream.Channels != ffmpegProfile.AudioChannels)
{
result.AudioChannels = ffmpegProfile.AudioChannels;
}
});
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
result.AudioDuration = version.Duration;
+7 -4
View File
@@ -354,12 +354,15 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithFilterComplex(int videoStreamIndex, int audioStreamIndex)
public FFmpegProcessBuilder WithFilterComplex(MediaStream videoStream, Option<MediaStream> maybeAudioStream)
{
var videoLabel = $"0:{videoStreamIndex}";
var audioLabel = $"0:{audioStreamIndex}";
int videoStreamIndex = videoStream.Index;
Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index);
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(videoStreamIndex, audioStreamIndex);
var videoLabel = $"0:{videoStreamIndex}";
var audioLabel = $"0:{maybeIndex.Match(i => i.ToString(), () => "a")}";
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(videoStreamIndex, maybeIndex);
maybeFilter.IfSome(
filter =>
{
+6 -6
View File
@@ -30,14 +30,14 @@ namespace ErsatzTV.Core.FFmpeg
DateTimeOffset now)
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
MediaStream audioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
channel.StreamingMode,
channel.FFmpegProfile,
version,
videoStream,
audioStream,
maybeAudioStream,
start,
now);
@@ -67,7 +67,7 @@ namespace ErsatzTV.Core.FFmpeg
}
builder = builder
.WithFilterComplex(videoStream.Index, audioStream.Index);
.WithFilterComplex(videoStream, maybeAudioStream);
},
() =>
{
@@ -76,18 +76,18 @@ namespace ErsatzTV.Core.FFmpeg
builder = builder
.WithDeinterlace(playbackSettings.Deinterlace)
.WithBlackBars(channel.FFmpegProfile.Resolution)
.WithFilterComplex(videoStream.Index, audioStream.Index);
.WithFilterComplex(videoStream, maybeAudioStream);
}
else if (playbackSettings.Deinterlace)
{
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
.WithAlignedAudio(playbackSettings.AudioDuration)
.WithFilterComplex(videoStream.Index, audioStream.Index);
.WithFilterComplex(videoStream, maybeAudioStream);
}
else
{
builder = builder
.WithFilterComplex(videoStream.Index, audioStream.Index);
.WithFilterComplex(videoStream, maybeAudioStream);
}
});
+11 -1
View File
@@ -6,6 +6,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.FFmpeg
{
@@ -25,8 +26,17 @@ namespace ErsatzTV.Core.FFmpeg
public Task<MediaStream> SelectVideoStream(Channel channel, MediaVersion version) =>
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask();
public async Task<MediaStream> SelectAudioStream(Channel channel, MediaVersion version)
public async Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version)
{
if (channel.StreamingMode == StreamingMode.HttpLiveStreaming &&
string.IsNullOrWhiteSpace(channel.PreferredLanguageCode))
{
_logger.LogDebug(
"Channel {Number} is HLS with no preferred language; using all audio streams",
channel.Number);
return None;
}
var audioStreams = version.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).ToList();
string language = (channel.PreferredLanguageCode ?? string.Empty).ToLowerInvariant();
@@ -1,11 +1,12 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface IFFmpegStreamSelector
{
Task<MediaStream> SelectVideoStream(Channel channel, MediaVersion version);
Task<MediaStream> SelectAudioStream(Channel channel, MediaVersion version);
Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version);
}
}
@@ -12,5 +12,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
public Task<List<MediaItem>> SearchMediaItemsByTitle(string query);
public Task<List<MediaItem>> SearchMediaItemsByGenre(string genre);
public Task<List<MediaItem>> SearchMediaItemsByTag(string tag);
public Task<List<string>> GetLanguagesForShow(Show show);
}
}
@@ -1,19 +1,23 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Search
{
public interface ISearchIndex
public interface ISearchIndex : IDisposable
{
public int Version { get; }
Task<bool> Initialize();
Task<Unit> Rebuild(List<int> itemIds);
Task<Unit> AddItems(List<MediaItem> items);
Task<Unit> UpdateItems(List<MediaItem> items);
Task<bool> Initialize(ILocalFileSystem localFileSystem);
Task<Unit> Rebuild(ISearchRepository searchRepository, List<int> itemIds);
Task<Unit> AddItems(ISearchRepository searchRepository, List<MediaItem> items);
Task<Unit> UpdateItems(ISearchRepository searchRepository, List<MediaItem> items);
Task<Unit> RemoveItems(List<int> ids);
Task<SearchResult> Search(string query, int skip, int limit, string searchField = "");
void Commit();
}
}
+6 -2
View File
@@ -26,6 +26,7 @@ namespace ErsatzTV.Core.Metadata
private readonly IMediator _mediator;
private readonly IMovieRepository _movieRepository;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
public MovieFolderScanner(
ILocalFileSystem localFileSystem,
@@ -35,6 +36,7 @@ namespace ErsatzTV.Core.Metadata
IMetadataRepository metadataRepository,
IImageCache imageCache,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IMediator mediator,
ILogger<MovieFolderScanner> logger)
: base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, logger)
@@ -43,6 +45,7 @@ namespace ErsatzTV.Core.Metadata
_movieRepository = movieRepository;
_localMetadataProvider = localMetadataProvider;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_logger = logger;
}
@@ -115,11 +118,11 @@ namespace ErsatzTV.Core.Metadata
{
if (result.IsAdded)
{
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
},
error =>
@@ -140,6 +143,7 @@ namespace ErsatzTV.Core.Metadata
}
}
_searchIndex.Commit();
return Unit.Default;
}
@@ -25,6 +25,7 @@ namespace ErsatzTV.Core.Metadata
private readonly IMediator _mediator;
private readonly IMusicVideoRepository _musicVideoRepository;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
public MusicVideoFolderScanner(
ILocalFileSystem localFileSystem,
@@ -33,6 +34,7 @@ namespace ErsatzTV.Core.Metadata
IMetadataRepository metadataRepository,
IImageCache imageCache,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IMusicVideoRepository musicVideoRepository,
IMediator mediator,
ILogger<MusicVideoFolderScanner> logger) : base(
@@ -45,6 +47,7 @@ namespace ErsatzTV.Core.Metadata
_localFileSystem = localFileSystem;
_localMetadataProvider = localMetadataProvider;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_musicVideoRepository = musicVideoRepository;
_mediator = mediator;
_logger = logger;
@@ -108,11 +111,11 @@ namespace ErsatzTV.Core.Metadata
{
if (result.IsAdded)
{
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
},
error =>
@@ -133,6 +136,7 @@ namespace ErsatzTV.Core.Metadata
}
}
_searchIndex.Commit();
return Unit.Default;
}
@@ -24,6 +24,7 @@ namespace ErsatzTV.Core.Metadata
private readonly ILogger<TelevisionFolderScanner> _logger;
private readonly IMediator _mediator;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly ITelevisionRepository _televisionRepository;
public TelevisionFolderScanner(
@@ -34,6 +35,7 @@ namespace ErsatzTV.Core.Metadata
IMetadataRepository metadataRepository,
IImageCache imageCache,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IMediator mediator,
ILogger<TelevisionFolderScanner> logger) : base(
localFileSystem,
@@ -46,6 +48,7 @@ namespace ErsatzTV.Core.Metadata
_televisionRepository = televisionRepository;
_localMetadataProvider = localMetadataProvider;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_logger = logger;
}
@@ -86,11 +89,11 @@ namespace ErsatzTV.Core.Metadata
{
if (result.IsAdded)
{
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
await ScanSeasons(
@@ -124,6 +127,7 @@ namespace ErsatzTV.Core.Metadata
List<int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath);
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
return Unit.Default;
}
@@ -21,12 +21,14 @@ namespace ErsatzTV.Core.Plex
private readonly IMovieRepository _movieRepository;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
public PlexMovieLibraryScanner(
IPlexServerApiClient plexServerApiClient,
IMovieRepository movieRepository,
IMetadataRepository metadataRepository,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IMediator mediator,
ILogger<PlexMovieLibraryScanner> logger)
: base(metadataRepository, logger)
@@ -35,6 +37,7 @@ namespace ErsatzTV.Core.Plex
_movieRepository = movieRepository;
_metadataRepository = metadataRepository;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_logger = logger;
}
@@ -69,11 +72,13 @@ namespace ErsatzTV.Core.Plex
{
if (result.IsAdded)
{
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { result.Item });
}
},
error =>
@@ -102,6 +107,7 @@ namespace ErsatzTV.Core.Plex
return Task.CompletedTask;
});
_searchIndex.Commit();
return Unit.Default;
}
@@ -21,6 +21,7 @@ namespace ErsatzTV.Core.Plex
private readonly IMetadataRepository _metadataRepository;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly ITelevisionRepository _televisionRepository;
public PlexTelevisionLibraryScanner(
@@ -28,6 +29,7 @@ namespace ErsatzTV.Core.Plex
ITelevisionRepository televisionRepository,
IMetadataRepository metadataRepository,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IMediator mediator,
ILogger<PlexTelevisionLibraryScanner> logger)
: base(metadataRepository, logger)
@@ -36,6 +38,7 @@ namespace ErsatzTV.Core.Plex
_televisionRepository = televisionRepository;
_metadataRepository = metadataRepository;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_logger = logger;
}
@@ -69,11 +72,13 @@ namespace ErsatzTV.Core.Plex
{
if (result.IsAdded)
{
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { result.Item });
}
await ScanSeasons(plexMediaSourceLibrary, result.Item, connection, token);
@@ -95,6 +100,7 @@ namespace ErsatzTV.Core.Plex
await _mediator.Publish(new LibraryScanProgress(plexMediaSourceLibrary.Id, 0));
_searchIndex.Commit();
return Unit.Default;
},
error =>
@@ -39,12 +39,22 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as Movie).MovieMetadata)
.ThenInclude(mm => mm.Studios)
.Include(mi => (mi as Movie).MediaVersions)
.ThenInclude(mm => mm.Streams)
.Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Studios)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Studios)
.Include(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mm => mm.Streams)
.OrderBy(mi => mi.Id)
.SingleOrDefaultAsync(mi => mi.Id == id)
.Map(Optional);
@@ -128,5 +138,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.OfType<MediaItem>()
.ToListAsync();
}
public Task<List<string>> GetLanguagesForShow(Show show) =>
_dbConnection.QueryAsync<string>(
@"SELECT DISTINCT Language
FROM MediaStream
INNER JOIN MediaVersion MV on MediaStream.MediaVersionId = MV.Id
INNER JOIN Episode E on MV.EpisodeId = E.Id
INNER JOIN Season S on E.SeasonId = S.Id
WHERE S.ShowId = @ShowId",
new { ShowId = show.Id }).Map(result => result.ToList());
}
}
+69 -60
View File
@@ -23,7 +23,7 @@ using Query = Lucene.Net.Search.Query;
namespace ErsatzTV.Infrastructure.Search
{
public class SearchIndex : ISearchIndex
public sealed class SearchIndex : ISearchIndex
{
private const LuceneVersion AppLuceneVersion = LuceneVersion.LUCENE_48;
@@ -40,111 +40,90 @@ namespace ErsatzTV.Infrastructure.Search
private const string JumpLetterField = "jump_letter";
private const string ReleaseDateField = "release_date";
private const string StudioField = "studio";
private const string LanguageField = "language";
private const string MovieType = "movie";
private const string ShowType = "show";
private const string MusicVideoType = "music_video";
private static bool _isRebuilding;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<SearchIndex> _logger;
private readonly ISearchRepository _searchRepository;
private FSDirectory _directory;
private IndexWriter _writer;
public SearchIndex(
ILocalFileSystem localFileSystem,
ISearchRepository searchRepository,
ILogger<SearchIndex> logger)
public SearchIndex(ILogger<SearchIndex> logger) => _logger = logger;
public int Version => 4;
public Task<bool> Initialize(ILocalFileSystem localFileSystem)
{
_localFileSystem = localFileSystem;
_searchRepository = searchRepository;
_logger = logger;
}
localFileSystem.EnsureFolderExists(FileSystemLayout.SearchIndexFolder);
public int Version => 2;
_directory = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
var analyzer = new StandardAnalyzer(AppLuceneVersion);
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer)
{ OpenMode = OpenMode.CREATE_OR_APPEND };
_writer = new IndexWriter(_directory, indexConfig);
public Task<bool> Initialize()
{
_localFileSystem.EnsureFolderExists(FileSystemLayout.SearchIndexFolder);
return Task.FromResult(true);
}
public async Task<Unit> Rebuild(List<int> itemIds)
public async Task<Unit> Rebuild(ISearchRepository searchRepository, List<int> itemIds)
{
_isRebuilding = true;
await Initialize();
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
var analyzer = new StandardAnalyzer(AppLuceneVersion);
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) { OpenMode = OpenMode.CREATE };
using var writer = new IndexWriter(dir, indexConfig);
foreach (int id in itemIds)
{
Option<MediaItem> maybeMediaItem = await _searchRepository.GetItemToIndex(id);
Option<MediaItem> maybeMediaItem = await searchRepository.GetItemToIndex(id);
if (maybeMediaItem.IsSome)
{
MediaItem mediaItem = maybeMediaItem.ValueUnsafe();
switch (mediaItem)
{
case Movie movie:
UpdateMovie(movie, writer);
UpdateMovie(movie);
break;
case Show show:
UpdateShow(show, writer);
await UpdateShow(searchRepository, show);
break;
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo, writer);
UpdateMusicVideo(musicVideo);
break;
}
}
}
_isRebuilding = false;
_writer.Commit();
return Unit.Default;
}
public Task<Unit> AddItems(List<MediaItem> items) => UpdateItems(items);
public Task<Unit> AddItems(ISearchRepository searchRepository, List<MediaItem> items) =>
UpdateItems(searchRepository, items);
public Task<Unit> UpdateItems(List<MediaItem> items)
public async Task<Unit> UpdateItems(ISearchRepository searchRepository, List<MediaItem> items)
{
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
var analyzer = new StandardAnalyzer(AppLuceneVersion);
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) { OpenMode = OpenMode.APPEND };
using var writer = new IndexWriter(dir, indexConfig);
foreach (MediaItem item in items)
{
switch (item)
{
case Movie movie:
UpdateMovie(movie, writer);
UpdateMovie(movie);
break;
case Show show:
UpdateShow(show, writer);
await UpdateShow(searchRepository, show);
break;
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo, writer);
UpdateMusicVideo(musicVideo);
break;
}
}
return Task.FromResult(Unit.Default);
return Unit.Default;
}
public Task<Unit> RemoveItems(List<int> ids)
{
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
var analyzer = new StandardAnalyzer(AppLuceneVersion);
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) { OpenMode = OpenMode.APPEND };
using var writer = new IndexWriter(dir, indexConfig);
foreach (int id in ids)
{
writer.DeleteDocuments(new Term(IdField, id.ToString()));
_writer.DeleteDocuments(new Term(IdField, id.ToString()));
}
return Task.FromResult(Unit.Default);
@@ -152,14 +131,12 @@ namespace ErsatzTV.Infrastructure.Search
public Task<SearchResult> Search(string searchQuery, int skip, int limit, string searchField = "")
{
if (_isRebuilding ||
string.IsNullOrWhiteSpace(searchQuery.Replace("*", string.Empty).Replace("?", string.Empty)))
if (string.IsNullOrWhiteSpace(searchQuery.Replace("*", string.Empty).Replace("?", string.Empty)))
{
return new SearchResult(new List<SearchItem>(), 0).AsTask();
}
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
using var reader = DirectoryReader.Open(dir);
using DirectoryReader reader = _writer.GetReader(true);
var searcher = new IndexSearcher(reader);
int hitsLimit = skip + limit;
using var analyzer = new StandardAnalyzer(AppLuceneVersion);
@@ -182,6 +159,14 @@ namespace ErsatzTV.Infrastructure.Search
return searchResult.AsTask();
}
public void Commit() => _writer.Commit();
public void Dispose()
{
_writer?.Dispose();
_directory?.Dispose();
}
private static Option<SearchPageMap> GetSearchPageMap(
IndexSearcher searcher,
Query query,
@@ -228,7 +213,7 @@ namespace ErsatzTV.Infrastructure.Search
return new SearchPageMap(map);
}
private void UpdateMovie(Movie movie, IndexWriter writer)
private void UpdateMovie(Movie movie)
{
Option<MovieMetadata> maybeMetadata = movie.MovieMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
@@ -248,6 +233,8 @@ namespace ErsatzTV.Infrastructure.Search
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
AddLanguages(doc, movie.MediaVersions);
if (metadata.ReleaseDate.HasValue)
{
doc.Add(
@@ -277,7 +264,7 @@ namespace ErsatzTV.Infrastructure.Search
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
}
writer.UpdateDocument(new Term(IdField, movie.Id.ToString()), doc);
_writer.UpdateDocument(new Term(IdField, movie.Id.ToString()), doc);
}
catch (Exception ex)
{
@@ -287,7 +274,21 @@ namespace ErsatzTV.Infrastructure.Search
}
}
private void UpdateShow(Show show, IndexWriter writer)
private void AddLanguages(Document doc, List<MediaVersion> mediaVersions)
{
Option<MediaVersion> maybeVersion = mediaVersions.HeadOrNone();
if (maybeVersion.IsSome)
{
MediaVersion version = maybeVersion.ValueUnsafe();
foreach (string lang in version.Streams.Map(ms => ms.Language).Distinct()
.Filter(s => !string.IsNullOrWhiteSpace(s)))
{
doc.Add(new StringField(LanguageField, lang, Field.Store.NO));
}
}
}
private async Task UpdateShow(ISearchRepository searchRepository, Show show)
{
Option<ShowMetadata> maybeMetadata = show.ShowMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
@@ -307,6 +308,12 @@ namespace ErsatzTV.Infrastructure.Search
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
List<string> languages = await searchRepository.GetLanguagesForShow(show);
foreach (string lang in languages.Distinct().Filter(s => !string.IsNullOrWhiteSpace(s)))
{
doc.Add(new StringField(LanguageField, lang, Field.Store.NO));
}
if (metadata.ReleaseDate.HasValue)
{
doc.Add(
@@ -336,7 +343,7 @@ namespace ErsatzTV.Infrastructure.Search
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
}
writer.UpdateDocument(new Term(IdField, show.Id.ToString()), doc);
_writer.UpdateDocument(new Term(IdField, show.Id.ToString()), doc);
}
catch (Exception ex)
{
@@ -346,7 +353,7 @@ namespace ErsatzTV.Infrastructure.Search
}
}
private void UpdateMusicVideo(MusicVideo musicVideo, IndexWriter writer)
private void UpdateMusicVideo(MusicVideo musicVideo)
{
Option<MusicVideoMetadata> maybeMetadata = musicVideo.MusicVideoMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
@@ -367,6 +374,8 @@ namespace ErsatzTV.Infrastructure.Search
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
AddLanguages(doc, musicVideo.MediaVersions);
if (metadata.ReleaseDate.HasValue)
{
doc.Add(
@@ -396,7 +405,7 @@ namespace ErsatzTV.Infrastructure.Search
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
}
writer.UpdateDocument(new Term(IdField, musicVideo.Id.ToString()), doc);
_writer.UpdateDocument(new Term(IdField, musicVideo.Id.ToString()), doc);
}
catch (Exception ex)
{
+6 -2
View File
@@ -1,7 +1,9 @@
using System.Threading.Tasks;
using ErsatzTV.Application.Channels.Queries;
using ErsatzTV.Application.HDHR.Queries;
using ErsatzTV.Core.Hdhr;
using ErsatzTV.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
@@ -20,8 +22,10 @@ namespace ErsatzTV.Controllers
new OkObjectResult(new DeviceXml(Request.Scheme, Request.Host.ToString()));
[HttpGet("discover.json")]
public IActionResult Discover() =>
new OkObjectResult(new Discover(Request.Scheme, Request.Host.ToString(), 2));
[ResponseCache(NoStore = true)]
public Task<IActionResult> Discover() =>
_mediator.Send(new GetHDHRTunerCount()).Map<int, IActionResult>(
tunerCount => new OkObjectResult(new Discover(Request.Scheme, Request.Host.ToString(), tunerCount)));
[HttpGet("lineup_status.json")]
public IActionResult LineupStatus() =>
+2 -69
View File
@@ -2,55 +2,13 @@
@using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.FFmpegProfiles.Commands
@using ErsatzTV.Application.FFmpegProfiles.Queries
@using ErsatzTV.Application.MediaItems.Queries
@using System.Globalization
@using Unit = LanguageExt.Unit
@inject IDialogService Dialog
@inject IMediator Mediator
@inject ILogger<FFmpeg> Logger
@inject ISnackbar Snackbar
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">FFmpeg Settings</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudForm @bind-IsValid="@_success">
<MudTextField T="string" Label="FFmpeg Path" @bind-Value="_ffmpegSettings.FFmpegPath" Validation="@(new Func<string, string>(ValidatePathExists))" Required="true" RequiredError="FFmpeg path is required!"/>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField T="string" Label="FFprobe Path" @bind-Value="_ffmpegSettings.FFprobePath" Validation="@(new Func<string, string>(ValidatePathExists))" Required="true" RequiredError="FFprobe path is required!"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudSelect Label="Default Profile" @bind-Value="_ffmpegSettings.DefaultFFmpegProfileId" For="@(() => _ffmpegSettings.DefaultFFmpegProfileId)">
@foreach (FFmpegProfileViewModel profile in _ffmpegProfiles)
{
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
}
</MudSelect>
</MudElement>
<MudSelect Class="mt-3" Label="Preferred Language" @bind-Value="_ffmpegSettings.PreferredLanguageCode" For="@(() => _ffmpegSettings.PreferredLanguageCode)" Required="true" RequiredError="Preferred Language Code is required!">
@foreach (CultureInfo culture in _availableCultures)
{
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
}
</MudSelect>
<MudElement HtmlTag="div" Class="mt-3">
<MudSwitch T="bool"
Label="Save troubleshooting reports to disk"
Color="Color.Primary"
@bind-Checked="@_ffmpegSettings.SaveReports"/>
</MudElement>
</MudForm>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!_success)" OnClick="@(_ => SaveSettings())">Save Settings</MudButton>
</MudCardActions>
</MudCard>
<MudTable Hover="true" Items="_ffmpegProfiles" Class="mt-8">
<MudTable Hover="true" Items="_ffmpegProfiles">
<ToolBarContent>
<MudText Typo="Typo.h6">FFmpeg Profiles</MudText>
<MudToolBarSpacer></MudToolBarSpacer>
@@ -114,34 +72,9 @@
</MudContainer>
@code {
private bool _success;
private FFmpegSettingsViewModel _ffmpegSettings;
private List<FFmpegProfileViewModel> _ffmpegProfiles;
private List<CultureInfo> _availableCultures;
protected override async Task OnParametersSetAsync()
{
_ffmpegSettings = await Mediator.Send(new GetFFmpegSettings());
_success = File.Exists(_ffmpegSettings.FFmpegPath) && File.Exists(_ffmpegSettings.FFprobePath);
await LoadFFmpegProfilesAsync();
_availableCultures = await Mediator.Send(new GetAllLanguageCodes());
}
private async Task SaveSettings()
{
Either<BaseError, Unit> result = await Mediator.Send(new UpdateFFmpegSettings(_ffmpegSettings));
result.Match(
Left: error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving FFmpeg settings: {Error}", error.Value);
},
Right: _ => Snackbar.Add("Successfully saved FFmpeg settings", Severity.Success));
}
private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null;
protected override async Task OnParametersSetAsync() => await LoadFFmpegProfilesAsync();
private async Task LoadFFmpegProfilesAsync() =>
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles());
+11 -5
View File
@@ -6,16 +6,22 @@
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable FixedHeader="true" Dense="true" Items="_logEntries">
<HeaderContent>
<MudTh>Timestamp</MudTh>
<MudTh>Level</MudTh>
<MudTh>
<MudTableSortLabel SortBy="new Func<LogEntryViewModel, object>(x => x.Timestamp)">
Timestamp
</MudTableSortLabel>
</MudTh>
<MudTh>
<MudTableSortLabel SortBy="new Func<LogEntryViewModel, object>(x => x.Level)">
Level
</MudTableSortLabel>
</MudTh>
<MudTh>Message</MudTh>
<MudTh>Properties</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Timestamp">@context.Timestamp</MudTd>
<MudTd DataLabel="Level">@context.Level</MudTd>
<MudTd DataLabel="Message">@context.RenderedMessage</MudTd>
<MudTd DataLabel="Message">@context.Properties</MudTd>
<MudTd DataLabel="Message">@context.Message</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
+1 -1
View File
@@ -19,7 +19,7 @@
@if (!string.IsNullOrWhiteSpace(_movie.Poster))
{
<img class="mud-elevation-2 mr-6"
style="border-radius: 4px; max-height: 440px"
style="border-radius: 4px; flex-shrink: 0; max-height: 440px;"
src="@($"/artwork/posters/{_movie.Poster}")" alt="movie poster"/>
}
<div style="display: flex; flex-direction: column; height: 100%">
+6 -7
View File
@@ -56,15 +56,14 @@
</MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MovieCardViewModel card in _data.Cards.Where(m => !string.IsNullOrWhiteSpace(m.Title)).OrderBy(m => m.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/movies/{card.MovieId}")"
<FragmentLetterAnchor TCard="MovieCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Link="@($"/media/movies/{context.MovieId}")"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
SelectClicked="@(e => SelectClicked(context, e))"
IsSelected="@IsSelected(context)"
IsSelectMode="@IsSelectMode()"/>
}
</FragmentLetterAnchor>
</MudContainer>
</MudContainer>
@if (_data.PageMap.IsSome)
+5 -6
View File
@@ -56,16 +56,15 @@
</MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MusicVideoCardViewModel card in _data.Cards.Where(m => !string.IsNullOrWhiteSpace(m.Title)).OrderBy(m => m.SortTitle))
{
<MediaCard Data="@card"
<FragmentLetterAnchor TCard="MusicVideoCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
SelectClicked="@(e => SelectClicked(context, e))"
IsSelected="@IsSelected(context)"
IsSelectMode="@IsSelectMode()"/>
}
</FragmentLetterAnchor>
</MudContainer>
</MudContainer>
@if (_data.PageMap.IsSome)
+3 -3
View File
@@ -128,10 +128,10 @@
}
}
private void PlexChanged(object sender, EventArgs e)
private async void PlexChanged(object sender, EventArgs e)
{
InvokeAsync(LoadMediaSources);
InvokeAsync(StateHasChanged);
await InvokeAsync(LoadMediaSources);
await InvokeAsync(StateHasChanged);
}
void IDisposable.Dispose() => Locker.OnPlexChanged -= PlexChanged;
+120
View File
@@ -0,0 +1,120 @@
@page "/settings"
@using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.FFmpegProfiles.Commands
@using ErsatzTV.Application.FFmpegProfiles.Queries
@using ErsatzTV.Application.HDHR.Commands
@using ErsatzTV.Application.HDHR.Queries
@using ErsatzTV.Application.MediaItems.Queries
@using System.Globalization
@using Unit = LanguageExt.Unit
@inject IMediator Mediator
@inject ISnackbar Snackbar
@inject ILogger<Settings> Logger
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="display: flex; flex-direction: row">
<MudCard Class="mr-6" Style="max-width: 400px">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">FFmpeg Settings</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudForm @bind-IsValid="@_success">
<MudTextField T="string" Label="FFmpeg Path" @bind-Value="_ffmpegSettings.FFmpegPath" Validation="@(new Func<string, string>(ValidatePathExists))" Required="true" RequiredError="FFmpeg path is required!"/>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField T="string" Label="FFprobe Path" @bind-Value="_ffmpegSettings.FFprobePath" Validation="@(new Func<string, string>(ValidatePathExists))" Required="true" RequiredError="FFprobe path is required!"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudSelect Label="Default Profile" @bind-Value="_ffmpegSettings.DefaultFFmpegProfileId" For="@(() => _ffmpegSettings.DefaultFFmpegProfileId)">
@foreach (FFmpegProfileViewModel profile in _ffmpegProfiles)
{
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
}
</MudSelect>
</MudElement>
<MudSelect Class="mt-3" Label="Preferred Language" @bind-Value="_ffmpegSettings.PreferredLanguageCode" For="@(() => _ffmpegSettings.PreferredLanguageCode)" Required="true" RequiredError="Preferred Language Code is required!">
@foreach (CultureInfo culture in _availableCultures)
{
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
}
</MudSelect>
<MudElement HtmlTag="div" Class="mt-3">
<MudSwitch T="bool"
Label="Save troubleshooting reports to disk"
Color="Color.Primary"
@bind-Checked="@_ffmpegSettings.SaveReports"/>
</MudElement>
</MudForm>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!_success)" OnClick="@(_ => SaveFFmpegSettings())">Save Settings</MudButton>
</MudCardActions>
</MudCard>
<MudCard Style="width: 350px">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">HDHomeRun Settings</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudForm @bind-IsValid="@_hdhrSuccess">
<MudTextField T="int" Label="Tuner Count" @bind-Value="_tunerCount" Validation="@(new Func<int, string>(ValidateTunerCount))" Required="true" RequiredError="Tuner count is required!"/>
</MudForm>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!_hdhrSuccess)" OnClick="@(_ => SaveHDHRSettings())">Save Settings</MudButton>
</MudCardActions>
</MudCard>
</MudContainer>
@code {
private bool _success;
private bool _hdhrSuccess;
private List<FFmpegProfileViewModel> _ffmpegProfiles;
private FFmpegSettingsViewModel _ffmpegSettings;
private List<CultureInfo> _availableCultures;
private int _tunerCount;
protected override async Task OnParametersSetAsync()
{
await LoadFFmpegProfilesAsync();
_ffmpegSettings = await Mediator.Send(new GetFFmpegSettings());
_success = File.Exists(_ffmpegSettings.FFmpegPath) && File.Exists(_ffmpegSettings.FFprobePath);
_availableCultures = await Mediator.Send(new GetAllLanguageCodes());
_tunerCount = await Mediator.Send(new GetHDHRTunerCount());
_hdhrSuccess = string.IsNullOrWhiteSpace(ValidateTunerCount(_tunerCount));
}
private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null;
private static string ValidateTunerCount(int tunerCount) => tunerCount <= 0 ? "Tuner count must be greater than zero" : null;
private async Task LoadFFmpegProfilesAsync() =>
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles());
private async Task SaveFFmpegSettings()
{
Either<BaseError, Unit> result = await Mediator.Send(new UpdateFFmpegSettings(_ffmpegSettings));
result.Match(
Left: error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving FFmpeg settings: {Error}", error.Value);
},
Right: _ => Snackbar.Add("Successfully saved FFmpeg settings", Severity.Success));
}
private async Task SaveHDHRSettings()
{
Either<BaseError, Unit> result = await Mediator.Send(new UpdateHDHRTunerCount(_tunerCount));
result.Match(
Left: error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving HDHomeRun settings: {Error}", error.Value);
},
Right: _ => Snackbar.Add("Successfully saved HDHomeRun settings", Severity.Success));
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
@if (!string.IsNullOrWhiteSpace(_season.Poster))
{
<img class="mud-elevation-2 mr-6"
style="border-radius: 4px; max-height: 440px"
style="border-radius: 4px; flex-shrink: 0; max-height: 440px;"
src="@($"/artwork/posters/{_season.Poster}")" alt="show poster"/>
}
<div style="display: flex; flex-direction: column; height: 100%">
+1 -1
View File
@@ -27,7 +27,7 @@
@if (!string.IsNullOrWhiteSpace(_show.Poster))
{
<img class="mud-elevation-2 mr-6"
style="border-radius: 4px; max-height: 440px"
style="border-radius: 4px; flex-shrink: 0; max-height: 440px;"
src="@($"/artwork/posters/{_show.Poster}")" alt="show poster"/>
}
<div style="display: flex; flex-direction: column; height: 100%">
+6 -7
View File
@@ -56,15 +56,14 @@
</MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionShowCardViewModel card in _data.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
<FragmentLetterAnchor TCard="TelevisionShowCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Link="@($"/media/tv/shows/{context.TelevisionShowId}")"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
SelectClicked="@(e => SelectClicked(context, e))"
IsSelected="@IsSelected(context)"
IsSelectMode="@IsSelectMode()"/>
}
</FragmentLetterAnchor>
</MudContainer>
</MudContainer>
@if (_data.PageMap.IsSome)
@@ -0,0 +1,27 @@
@using LanguageExt.UnsafeValueAccess
@typeparam TCard
@{ var letters = new System.Collections.Generic.HashSet<char>(); }
@foreach (TCard card in Cards.Filter(c => !string.IsNullOrWhiteSpace(c.Title)).OrderBy(c => c.SortTitle))
{
@if (!letters.Contains(card.SortTitle.Head()))
{
Option<char> maybeLetter = card.SortTitle.ToLowerInvariant().HeadOrNone();
if (maybeLetter.IsSome)
{
char letter = maybeLetter.ValueUnsafe();
if (letter >= '0' && letter <= '9')
{
letter = '#';
}
letters.Add(letter);
<div id="@($"letter-{letter}")" style="scroll-margin-top: 128px">
@ChildContent(card)
</div>
}
}
else
{
@ChildContent
}
}
@@ -0,0 +1,15 @@
using System.Collections.Generic;
using ErsatzTV.Application.MediaCards;
using Microsoft.AspNetCore.Components;
namespace ErsatzTV.Shared
{
public partial class FragmentLetterAnchor<TCard> where TCard : MediaCardViewModel
{
[Parameter]
public RenderFragment<TCard> ChildContent { get; set; }
[Parameter]
public List<TCard> Cards { get; set; }
}
}
+1 -1
View File
@@ -50,7 +50,7 @@
{
uri = QueryHelpers.AddQueryString(uri, "query", Query);
}
return uri;
return uri + $"#letter-{letter}";
}
}
+5 -1
View File
@@ -29,6 +29,9 @@
<MudLink Color="Color.Info" Href="/iptv/channels.m3u" Target="_blank" Underline="Underline.None">M3U</MudLink>
<MudLink Color="Color.Info" Href="/iptv/xmltv.xml" Target="_blank" Class="mx-4" Underline="Underline.None">XMLTV</MudLink>
<MudLink Color="Color.Info" Href="/swagger" Target="_blank" Class="mr-4" Underline="Underline.None">API</MudLink>
<MudTooltip Text="Documentation">
<MudIconButton Icon="@Icons.Material.Filled.Help" Color="Color.Primary" Link="https://ersatztv.org" Target="_blank"/>
</MudTooltip>
<MudTooltip Text="Discord">
<MudIconButton Icon="fab fa-discord" Color="Color.Primary" Link="https://discord.gg/hHaJm3yGy6" Target="_blank"/>
</MudTooltip>
@@ -39,7 +42,7 @@
<MudDrawer Open="true" Elevation="2" ClipMode="DrawerClipMode.Always">
<MudNavMenu>
<MudNavLink Href="/channels">Channels</MudNavLink>
<MudNavLink Href="/ffmpeg">FFmpeg</MudNavLink>
<MudNavLink Href="/ffmpeg">FFmpeg Profiles</MudNavLink>
<MudNavGroup Title="Media Sources" Expanded="true">
<MudNavLink Href="/media/plex">Plex</MudNavLink>
</MudNavGroup>
@@ -52,6 +55,7 @@
</MudNavGroup>
<MudNavLink Href="/schedules">Schedules</MudNavLink>
<MudNavLink Href="/playouts">Playouts</MudNavLink>
<MudNavLink Href="/settings">Settings</MudNavLink>
<MudNavLink Href="/system/logs">Logs</MudNavLink>
<MudDivider Class="my-6" DividerType="DividerType.Middle"/>
<MudContainer Style="text-align: right" Class="mr-6">
+1 -1
View File
@@ -185,6 +185,7 @@ namespace ErsatzTV
services.AddSingleton<IPlexSecretStore, PlexSecretStore>();
services.AddSingleton<IPlexTvApiClient, PlexTvApiClient>(); // TODO: does this need to be singleton?
services.AddSingleton<IEntityLocker, EntityLocker>();
services.AddSingleton<ISearchIndex, SearchIndex>();
AddChannel<IBackgroundServiceRequest>(services);
AddChannel<IPlexBackgroundServiceRequest>(services);
@@ -217,7 +218,6 @@ namespace ErsatzTV
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
services.AddScoped<ISearchIndex, SearchIndex>();
services.AddScoped<IRuntimeInfo, RuntimeInfo>();
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>();
+8 -51
View File
@@ -2,6 +2,8 @@
**ErsatzTV** is pre-alpha software for configuring and streaming custom live channels using your media library. The software is currently unstable and under active development.
Documentation is available at [ersatztv.org](https://ersatztv.org/).
Want to join the community or have a question? Join us on [Discord](https://discord.gg/hHaJm3yGy6).
## Current Features
@@ -12,69 +14,24 @@ Want to join the community or have a question? Join us on [Discord](https://disc
- Collection-based scheduling, with collections containing television shows, seasons, episodes and movies
- Powerful scheduling options such as chronological collection playback throughout the day or over multiple days
- [Hardware-accelerated transcoding](#Hardware-Transcoding) options (QSV, NVENC, VAAPI)
- [Plex](https://www.plex.tv/) media and metadata
- Music video libraries
## In Development
- [Plex](https://www.plex.tv/) media, metadata and collections
## Planned Features
- [Jellyfin](https://jellyfin.org/) media, metadata and collections
- [Jellyfin](https://jellyfin.org/) media and metadata
- Run as a Windows service
- Spots to fill unscheduled gaps
## Preview
## Screenshots
### Television Show
![Television Show](docs/television-show.png)
![Television Show](docs/images/television-show.png)
### Media Collection
![Media Collection](docs/media-collection.png)
### Plex Live TV
![Plex Live TV Stream](docs/plex-live-tv-stream.png)
Sintel is © copyright Blender Foundation | durian.blender.org
## Running ErsatzTV
The easiest way to run ErsatzTV is with Docker:
```
docker run -d \
-e TZ=America/Chicago \
-p 8409:8409 \
-v /path/to/appdata/config:/root/.local/share/ersatztv \
-v /path/to/shared/media:/path/to/shared/media:ro \
--restart unless-stopped \
jasongdove/ersatztv
```
After running ErsatzTV for the first time, configure it by visiting the web UI at http://[address]:8409.
### Software Transcoding
The following docker tags are available with software transcoding:
* `develop` - merges to `main` branch
* `latest` - latest release
### Hardware Transcoding
The following docker tags are available with hardware-accelerated transcoding:
* `develop-nvidia` - merges to `main` branch
* `develop-vaapi` - merges to `main` branch
* `latest-nvidia` - latest release
* `latest-vaapi` - latest release
QSV transcoding has not been tested in docker.
## Development
See [development documentation](docs/development.md).
![Media Collection](docs/images/media-collection.png)
## License
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Before

Width:  |  Height:  |  Size: 435 KiB

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

+14
View File
@@ -0,0 +1,14 @@
![ErsatzTV](../images/ersatztv.png)
**ErsatzTV** is pre-alpha software for configuring and streaming custom live channels using your media library. The software is currently unstable and under active development.
Want to join the community or have a question? Join us on [Discord](https://discord.gg/hHaJm3yGy6).
Want to say thanks? Consider [sponsorship on GitHub](https://github.com/sponsors/jasongdove) or [one-time donations on PayPal](https://www.paypal.me/jasongdove).
# Quick Start
1. [Install ErsatzTV](user-guide/install.md)
2. [Add Media Items](user-guide/add-media-items.md)
3. [Create Channels](user-guide/create-channels.md)
4. [Configure Clients](user-guide/configure-clients.md)
+14
View File
@@ -0,0 +1,14 @@
:root {
--md-primary-fg-color: #121212;
}
[data-md-color-scheme="slate"] {
--md-default-bg-color: #272727;
--md-typeset-a-color: #00a0a0;
--md-accent-fg-color: #00c0c0;
--md-default-fg-color--light: #ffffff;
--md-code-bg-color: #1f1f1f;
--md-footer-bg-color: #1f1f1f;
--md-footer-bg-color--dark: #121212;
}
+82
View File
@@ -0,0 +1,82 @@
ErsatzTV needs to know about your media items in order to create channels.
Two library kinds are currently supported: [Local](#local-libraries) and [Plex](#plex-libraries).
## Local Libraries
ErsatzTV provides three local libraries, one for each supported media kind: `Movies`, `Shows` and `Music Videos`.
### Metadata
With local libraries, ErsatzTV will read metadata from [NFO files](https://kodi.wiki/view/NFO_files), falling back to a *minimal* amount of metadata if NFO files are not found.
### Add Media Items
To add media items to a local library under `Media` > `Libraries`, click the edit button for the library:
![Libraries Edit Icon](../images/libraries-edit-icon.png)
Then click the `Add Library Path` button and enter the path where your media files of the appropriate kind are stored:
![Add Local Library Path](../images/shows-add-local-library-path.png)
Finally, click `Add Local Library Path` and ErsatzTV will scan and import your media items.
## Plex Libraries
Plex libraries provide a way to synchronize your media (metadata) from Plex to ErsatzTV.
This synchronization process is one-way: changes made within Plex are synchronized to ErsatzTV.
ErsatzTV will never make any modifications to your Plex configuration or media.
### Metadata
With Plex libraries, Plex provides all metadata.
### Add Media Items
#### Sign In
The add media items from Plex under `Media Sources` > `Plex`, click the `Sign in to Plex` button and sign in with your Plex account.
#### Synchronize Libraries
After signing in, select which libraries you would like to synchronize from Plex to ErsatzTV by clicking the `Edit Libraries` button for the desired Plex server.
![Plex Edit Libraries Icon](../images/plex-media-source-edit-libaries-icon.png)
In the libraries listing, click the `Synchronize` switch for all libraries you would like to synchronize from Plex to ErsatzTV, and click the `Save Changes` button to start the synchronization process.
![Plex Libraries Synchronize Switch](../images/plex-libraries-synchronize-switch.png)
### Path Replacements
Media items are not streamed from Plex media sources. Instead, ErsatzTV will attempt to play media items from disk using the same path that Plex uses to play the media item.
There are two ways to make this work:
1. Ensure ErsatzTV has access to exactly the same shares and mounts as Plex
2. Configure path replacements to tell ErsatzTV where it should look on its file system for a given Plex folder
Option 1 is recommended as it will "just work" without any further configuration.
To configure path replacements for a Plex media source, click the `Edit Path Replacements` button in the
![Plex Edit Path Replacements Icon](../images/plex-media-source-edit-path-replacements-icon.png)
Click `Add Path Replacement` to add a new path replacement, and enter the `Plex Path` along with the equivalent `Local Path` for ErsatzTV.
Click `Save Changes` after you have created all needed path replacements.
![Plex Path Replacements](../images/plex-media-source-path-replacements.png)
## Collections
ErsatzTV does not schedule individual media items; only collections of items can be scheduled.
To create a collection, find the media items you would like to include and select them by clicking the selection button in the top left of the poster:
![Poster Selection Button](../images/poster-selection-button.png)
Then, add to a collection by clicking the `Add To Collection` button at the top of the page.
![Add Selection To Collection](../images/add-selection-to-collection.png)
In the `Add To Collection` dialog, either select an existing collection for the items, or enter a new collection name to create a collection and add all of the selected items in a single step.
![Add To Collection Dialog](../images/add-to-collection-dialog.png)
+113
View File
@@ -0,0 +1,113 @@
## Required Urls
For all clients, the `M3U` and/or the `XMLTV` urls are needed and can be copied from the top right of the ErsatzTV UI.
![ErsatzTV M3U and XMLTV Links](../images/etv-m3u-xmltv-links.png)
## Supported Clients
- [Plex](#plex)
- [Jellyfin](#jellyfin)
- [TiviMate](#tivimate)
- [Channels DVR Server](#channels-dvr-server)
## Plex
A [Plex Pass](https://www.plex.tv/plex-pass/) is required for ErsatzTV to work with Plex.
### Add DVR
From Plex Settings, click `Live TV & DVR` and `Set Up Plex DVR` to add a new DVR.
If ErsatzTV is not automatically detected
* Click to `enter its network address manually`
* Enter ErsatzTV's IP address and port, like `192.168.1.100:8409` (use your server IP, not necessarily 192.168.1.100)
* Click `Connect`
![Plex DVR Manual Address](../images/plex-dvr-manual-address.png)
### Add XMLTV
Click `Continue` on the channel listing, then
* Click `Have an XMLTV guide on your server? Click here to use that instead.`
* Enter the `XMLTV` url from ErsatzTV (see [required urls](#required-urls)) and click `Continue`
![Plex DVR XMLTV Guide](../images/plex-dvr-xmltv-guide.png)
Make any desired changes to the channels, then click `Continue` to complete DVR setup.
## Jellyfin
Jellyfin requires two steps to configure Live TV:
- [Add Tuner Device](#add-tuner-device)
- [Add TV Guide Data](#add-tv-guide-data)
### Add Tuner Device
From the Admin Dashboard in Jellyfin, click `Live TV` and `+` to add a new tuner device:
![Jellyfin Add Tuner Device](../images/jellyfin-add-tuner-device.png)
For `Tuner Type` select `HD Homerun`, and for `Tuner IP Address` enter ErsatzTV's IP address and port, like `192.168.1.100:8409` (use your server IP, not necessarily 192.168.1.100).
![Jellyfin Live TV Tuner Setup](../images/jellyfin-live-tv-tuner-setup.png)
### Add TV Guide Data
From the Admin Dashboard in Jellyfin, click `Live TV` and `+` to add a tv guide data provider and select `XMLTV`.
![Jellyfin Add TV Guide Data Provider](../images/jellyfin-add-tv-guide-data-provider.png)
Enter the `XMLTV` url from ErsatzTV (see [required urls](#required-urls)) and click `Save`.
![Jellyfin XMLTV Settings](../images/jellyfin-xmltv-settings.png)
## TiviMate
### Add Playlist
Start by adding a playlist under `Settings` > `Playlists` > `Add playlist`.
The playlist type is `M3U Playlist` and the url is the `M3U` url from ErsatzTV (see [required urls](#required-urls)).
![TiviMate Playlist URL](../images/tivimate-playlist-url.png)
Change the playlist name if desired, and leave `TV playlist` selected.
### Add EPG
The EPG url should be automatically detected by TiviMate, but can be manually entered as the `XMLTV` url from ErsatzTV (see [required urls](#required-urls)).
![TiviMate EPG URL](../images/tivimate-epg-url.png)
## Channels DVR Server
[Channels Plus](https://getchannels.com/plus/) is required for ErsatzTV to work with Channels (via Channels DVR Server).
### Add Custom Channel Source
In Channels DVR Server Settings, click `Add Source` and select `Custom Channels`.
- Give your custom channel source a name
- Select `MPEG-TS` as the stream format
- Enter the `M3U` url from ErsatzTV (see [required urls](#required-urls))
- Select `Refresh URL daily`
- Set a stream limit if desired (not required)
- Click `Save`
![Channels Custom Channel Source](../images/channels-custom-channels-source.png)
### Add Guide Data Provider
In Channels DVR Settings, click the gear icon next to the ErsatzTV channel source and select `Set Provider`:
![Channels Guide Data Set Provider](../images/channels-guide-data-set-provider.png)
Click the drop down next to zip code and select `XMLTV > Custom URL`:
![Channels XMLTV Custom URL Dropdown](../images/channels-xmltv-custom-url-dropdown.png)
Enter the `XMLTV` url from ErsatzTV (see [required urls](#required-urls)) and click `Save`.
![Channels XMLTV Custom URL](../images/channels-xmltv-custom-url.png)
+79
View File
@@ -0,0 +1,79 @@
## Create Channel
Create a Channel by navigating to the `Channels` page and clicking `Add Channel`.
### Channel Number
Channel numbers can be whole numbers or can contain one decimal, like `500` or `500.5`.
### Streaming Mode
Two streaming modes are currently supported: `Transport Stream` and `HttpLiveStreaming`.
`Transport Stream` is considered stable and is recommended for most purposes.
`HttpLiveStreaming` is unstable and is not recommended for general use.
### FFmpeg Profile
FFmpeg Profiles are collections of transcoding settings that are applied to all content on a channel.
The default FFmpeg Profile is probably "good enough" for initial testing.
### Logo
Channel logos can be added using the `Upload Logo` button and the logos will display in most client program guides.
## Create Schedule
Schedules are used to control the playback order of media items on a channel.
Create a Schedule by navigating to the `Schedules` page, clicking `Add Schedule` and giving your schedule a name, a collection playback order, and clicking `Add Schedule` to confirm your selections.
### Collection Playback Order
Select the desired playback order for media items within each collection in the schedule:
- `Chronological`: Items are ordered by release date, then by season and episode number.
- `Random`: Items are randomly ordered and may contain repeats.
- `Shuffle`: Items are randomly ordered and no item will be played a second time until every item from the collection has been played once.
### Schedule Items
Schedules contain an ordered list of items (collections), and will play back one or more items from each collection before advancing to the next schedule item.
Edit the new schedule's items by clicking the `Edit Schedule Items` button for the schedule:
![Schedules Edit Schedule Items Button](../images/schedules-edit-schedule-items.png)
Add a new item to the schedule by clicking `Add Schedule Item` and configure as desired.
#### Start Type
Items with a `Dynamic` start type will start immediately after the preceding schedule item, while a `Fixed` start type requires a start time.
#### Collection Type
Schedule items can contain the following collection types:
- `Collection`: Select a collection that you have created manually.
- `Television Show`: Select an entire television show.
- `Television Season`: Select a specific season of a television show.
#### Collection
Based on the selected collection type, select the desired collection.
#### Playout Mode
Select how you want this schedule item to behave every time it is selected for playback.
- `One`: Play one media item from the collection before advancing to the next schedule item.
- `Multiple`: Play the specified `Multiple Count` of media items from the collection before advancing to the next schedule item.
- `Duration`: Play the maximum number of complete media items that will fit in the specified `Playout Duration` before either going offline for the remainder of the playout duration (an `Offline Tail`), or immediately advancing to the next schedule item.
- `Flood`: Play media items from the collection either forever or until the next schedule item's start time, if one exists.
Click `Save Changes` to save all changes made to the schedule's items.
## Create Playout
Playouts assign a schedule to a channel and individually track the ordered playback of collection items.
If a schedule is used in multiple playouts (channels), the channels may not have the same content playing at the same time.
To create a Playout, navigate to the `Playouts` page and click the `Add Playout` button. Then, select the appropriate channel and schedule, and click `Add Playout` to save.
+101
View File
@@ -0,0 +1,101 @@
ErsatzTV is available as Docker images and as pre-built binary packages for Windows, MacOS and Linux.
## Docker Images
<a href="https://hub.docker.com/r/jasongdove/ersatztv"><img alt="Docker Pull Count" src="https://img.shields.io/docker/pulls/jasongdove/ersatztv"></a>
### Latest Release Tags
Base (software transcoding): `jasongdove/ersatztv:latest`
Nvidia hardware-accelerated transcoding: `jasongdove/ersatztv:latest-nvidia`
VAAPI hardware-accelerated transcoding: `jasongdove/ersatztv:latest-vaapi`
### Development Tags
Development tags update much more frequently, but have the potential to be less stable than releases.
Base (software transcoding): `jasongdove/ersatztv:develop`
Nvidia hardware-accelerated transcoding: `jasongdove/ersatztv:develop-nvidia`
VAAPI hardware-accelerated transcoding: `jasongdove/ersatztv:develop-vaapi`
### Docker
1\. Download the latest container image
```
docker pull jasongdove/ersatztv
```
2\. Create a directory to store configuration data
```
mkdir /path/to/config
```
3\. Create and run a container
```
docker run -d \
--name ersatztv \
-e TZ=America/Chicago \
-p 8409:8409 \
-v /path/to/config:/root/.local/share/ersatztv \
-v /path/to/shared/media:/path/to/shared/media:ro \
--restart unless-stopped \
jasongdove/ersatztv
```
### Unraid Docker
1. Install the Commuinity Applications plugin by pasting the following URL in the Plugins / Install Plugin section of UnRAID
```
https://raw.githubusercontent.com/Squidly271/community.applications/master/plugins/community.applications.plg
```
![Community Applications Install](../images/unraid-install-community-apps.png)
2. Click on the `Apps` tab in UnRAID, search for `ersatztv`, and click the `install` button.
![Search Community Applications ErsatzTV](../images/unraid-docker-install-ersatz.png)
3. Choose an appropriate branch (Use `vaapi` for video acceleration for AMD GPUs and Intel CPUs with QuickSync, use `nvidia` for video acceleration for nVidia GPUs)
![ErsatzTV Docker branches](../images/unraid-docker-ersatz-branches.png)
<div align="center">`VAAPI` and `nVidia` branches are for hardware acceleration. See [latest release tags](install.md#latest-release-tags)</div>
4. Map your path to shared media
![Media Mapping](../images/unraid-docker-media-map.png)
5. <B>OPTIONAL</B> In advanced view, add extra parameters for video acceleration. **NOTE** The [nVidia-Driver plugin](https://forums.unraid.net/topic/98978-plugin-nvidia-driver/) must be installed prior to this step.
![Adding video acceleration options](../images/unraid-docker-ersatz-hwtranscoding.png)
6. Open your browser to `http://[docker-ip]:8409` (First run may take a few minutes to be available.)
## Windows
### Manual Installation
1. Create a folder `ersatztv` at your preferred install location.
2. Download and extract the latest version to the `ersatztv` folder.
3. Run `ErsatzTV.exe`
4. Open your browser to `http://[server-ip]:8409`
## MacOS
### Manual Installation
1. Create a folder `ersatztv` at your preferred install location.
2. Download and extract the latest version to the `ersatztv` folder.
3. Run `ErsatzTV`
4. Open your browser to `http://[server-ip]:8409`
## Linux
### Manual Installation
1. Create a folder `ersatztv` at your preferred install location.
2. Download and extract the latest version to the `ersatztv` folder.
3. Run `ErsatzTV`
4. Open your browser to `http://[server-ip]:8409`
+33
View File
@@ -0,0 +1,33 @@
site_name: ErsatzTV
nav:
- 'Quick Start':
- 'Install ErsatzTV': 'user-guide/install.md'
- 'Add Media Items': 'user-guide/add-media-items.md'
- 'Create Channels': 'user-guide/create-channels.md'
- 'Configure Clients': 'user-guide/configure-clients.md'
theme:
name: material
palette:
scheme: slate
logo: images/ersatztv-square-logo.png
favicon: images/favicon-32x32.png
extra_css:
- stylesheets/extra.css
copyright: Copyright &copy; 2020 - 2021 Jason Dove
extra:
social:
- icon: fontawesome/brands/discord
link: https://discord.gg/hHaJm3yGy6
name: ErsatzTV on Discord
- icon: fontawesome/brands/github
link: https://github.com/jasongdove/ErsatzTV
name: ErsatzTV on GitHub
- icon: fontawesome/regular/heart
link: https://github.com/sponsors/jasongdove
name: Sponsor on GitHub
- icon: fontawesome/brands/paypal
link: https://www.paypal.me/jasongdove
name: Donate on PayPal
google_analytics:
- UA-193031642-1
- auto