Compare commits

...
51 changed files with 2886 additions and 192 deletions
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Globalization;
namespace ErsatzTV.Application.Artists
{
@@ -10,5 +11,6 @@ namespace ErsatzTV.Application.Artists
string FanArt,
List<string> Genres,
List<string> Styles,
List<string> Moods);
List<string> Moods,
List<CultureInfo> Languages);
}
+22 -3
View File
@@ -1,12 +1,16 @@
using System.Linq;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using ErsatzTV.Core.Domain;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Artists
{
internal static class Mapper
{
internal static ArtistViewModel ProjectToViewModel(Artist artist)
internal static ArtistViewModel ProjectToViewModel(Artist artist, List<string> languages)
{
ArtistMetadata metadata = Optional(artist.ArtistMetadata).Flatten().Head();
return new ArtistViewModel(
@@ -17,11 +21,26 @@ namespace ErsatzTV.Application.Artists
Artwork(metadata, ArtworkKind.FanArt),
metadata.Genres.Map(g => g.Name).ToList(),
metadata.Styles.Map(s => s.Name).ToList(),
metadata.Moods.Map(m => m.Name).ToList());
metadata.Moods.Map(m => m.Name).ToList(),
LanguagesForArtist(languages));
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
private static List<CultureInfo> LanguagesForArtist(List<string> languages)
{
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
return languages
.Distinct()
.Map(
lang => allCultures.Filter(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten()
.ToList();
}
}
}
@@ -1,5 +1,7 @@
using System.Threading;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
@@ -10,12 +12,26 @@ namespace ErsatzTV.Application.Artists.Queries
public class GetArtistByIdHandler : IRequestHandler<GetArtistById, Option<ArtistViewModel>>
{
private readonly IArtistRepository _artistRepository;
private readonly ISearchRepository _searchRepository;
public GetArtistByIdHandler(IArtistRepository artistRepository) => _artistRepository = artistRepository;
public GetArtistByIdHandler(IArtistRepository artistRepository, ISearchRepository searchRepository)
{
_artistRepository = artistRepository;
_searchRepository = searchRepository;
}
public Task<Option<ArtistViewModel>> Handle(
public async Task<Option<ArtistViewModel>> Handle(
GetArtistById request,
CancellationToken cancellationToken) =>
_artistRepository.GetArtist(request.ArtistId).MapT(ProjectToViewModel);
CancellationToken cancellationToken)
{
Option<Artist> maybeArtist = await _artistRepository.GetArtist(request.ArtistId);
return await maybeArtist.Match<Task<Option<ArtistViewModel>>>(
async artist =>
{
List<string> languages = await _searchRepository.GetLanguagesForArtist(artist);
return ProjectToViewModel(artist, languages);
},
() => Task.FromResult(Option<ArtistViewModel>.None));
}
}
}
+1 -1
View File
@@ -82,7 +82,7 @@ namespace ErsatzTV.Application.MediaCards
collection.MediaItems.OfType<Season>().Map(ProjectToViewModel).ToList(),
collection.MediaItems.OfType<Episode>().Map(e => ProjectToViewModel(e.EpisodeMetadata.Head()))
.ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
@@ -1,12 +1,18 @@
namespace ErsatzTV.Application.MediaCards
{
public record MusicVideoCardViewModel
(int MusicVideoId, string Title, string Subtitle, string SortTitle, string Plot, string Poster) : MediaCardViewModel(
MusicVideoId,
Title,
Subtitle,
SortTitle,
Poster)
(
int MusicVideoId,
string Title,
string Subtitle,
string SortTitle,
string Plot,
string Poster) : MediaCardViewModel(
MusicVideoId,
Title,
Subtitle,
SortTitle,
Poster)
{
public int CustomIndex { get; set; }
}
@@ -12,9 +12,9 @@ namespace ErsatzTV.Application.MediaCollections.Commands
public class
AddArtistToCollectionHandler : MediatR.IRequestHandler<AddArtistToCollection, Either<BaseError, Unit>>
{
private readonly IArtistRepository _artistRepository;
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IArtistRepository _artistRepository;
public AddArtistToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
@@ -20,7 +20,7 @@ namespace ErsatzTV.Application.MediaItems.Queries
{
var result = new List<CultureInfo>();
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.AllCultures);
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
List<string> allLanguageCodes = await _mediaItemRepository.GetAllLanguageCodes();
foreach (string code in allLanguageCodes)
{
+23 -2
View File
@@ -1,5 +1,9 @@
using System.Linq;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using ErsatzTV.Core.Domain;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Movies
@@ -17,7 +21,24 @@ namespace ErsatzTV.Application.Movies
Artwork(metadata, ArtworkKind.FanArt),
metadata.Genres.Map(g => g.Name).ToList(),
metadata.Tags.Map(t => t.Name).ToList(),
metadata.Studios.Map(s => s.Name).ToList());
metadata.Studios.Map(s => s.Name).ToList(),
LanguagesForMovie(movie));
}
private static List<CultureInfo> LanguagesForMovie(Movie movie)
{
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
return movie.MediaVersions
.Map(mv => mv.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).Map(s => s.Language))
.Flatten()
.Distinct()
.Map(
lang => allCultures.Filter(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten()
.ToList();
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Globalization;
namespace ErsatzTV.Application.Movies
{
@@ -10,5 +11,6 @@ namespace ErsatzTV.Application.Movies
string FanArt,
List<string> Genres,
List<string> Tags,
List<string> Studios);
List<string> Studios,
List<CultureInfo> Languages);
}
@@ -48,7 +48,7 @@ namespace ErsatzTV.Application.Playouts.Commands
}
private async Task<Validation<BaseError, Playout>> Validate(CreatePlayout request) =>
(await ChannelMustExist(request), await ProgramScheduleMustExist(request), ValidatePlayoutType(request))
(await ValidateChannel(request), await ProgramScheduleMustExist(request), ValidatePlayoutType(request))
.Apply(
(channel, programSchedule, playoutType) => new Playout
{
@@ -57,10 +57,19 @@ namespace ErsatzTV.Application.Playouts.Commands
ProgramSchedulePlayoutType = playoutType
});
private Task<Validation<BaseError, Channel>> ValidateChannel(CreatePlayout createPlayout) =>
ChannelMustExist(createPlayout).BindT(ChannelMustNotHavePlayouts);
private async Task<Validation<BaseError, Channel>> ChannelMustExist(CreatePlayout createPlayout) =>
(await _channelRepository.Get(createPlayout.ChannelId))
.ToValidation<BaseError>("Channel does not exist.");
private async Task<Validation<BaseError, Channel>> ChannelMustNotHavePlayouts(Channel channel) =>
Optional(await _channelRepository.CountPlayouts(channel.Id))
.Filter(count => count == 0)
.Map(_ => channel)
.ToValidation<BaseError>("Channel already has one playout.");
private async Task<Validation<BaseError, ProgramSchedule>> ProgramScheduleMustExist(
CreatePlayout createPlayout) =>
(await _programScheduleRepository.GetWithPlayouts(createPlayout.ProgramScheduleId))
@@ -2,5 +2,9 @@
namespace ErsatzTV.Application.Search
{
public record SearchResultAllItemsViewModel(List<int> MovieIds, List<int> ShowIds, List<int> ArtistIds, List<int> MusicVideoIds);
public record SearchResultAllItemsViewModel(
List<int> MovieIds,
List<int> ShowIds,
List<int> ArtistIds,
List<int> MusicVideoIds);
}
+21 -3
View File
@@ -1,13 +1,16 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using ErsatzTV.Core.Domain;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Television
{
internal static class Mapper
{
internal static TelevisionShowViewModel ProjectToViewModel(Show show) =>
internal static TelevisionShowViewModel ProjectToViewModel(Show show, List<string> languages) =>
new(
show.Id,
show.ShowMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
@@ -18,7 +21,8 @@ namespace ErsatzTV.Application.Television
show.ShowMetadata.HeadOrNone().Map(m => m.Genres.Map(g => g.Name).ToList()).IfNone(new List<string>()),
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()),
show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList())
.IfNone(new List<string>()));
.IfNone(new List<string>()),
LanguagesForShow(languages));
internal static TelevisionSeasonViewModel ProjectToViewModel(Season season) =>
new(
@@ -48,5 +52,19 @@ namespace ErsatzTV.Application.Television
private static string GetArtwork(Metadata metadata, ArtworkKind artworkKind) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
private static List<CultureInfo> LanguagesForShow(List<string> languages)
{
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
return languages
.Distinct()
.Map(
lang => allCultures.Filter(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten()
.ToList();
}
}
}
@@ -1,5 +1,7 @@
using System.Threading;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
@@ -9,15 +11,29 @@ namespace ErsatzTV.Application.Television.Queries
{
public class GetTelevisionShowByIdHandler : IRequestHandler<GetTelevisionShowById, Option<TelevisionShowViewModel>>
{
private readonly ISearchRepository _searchRepository;
private readonly ITelevisionRepository _televisionRepository;
public GetTelevisionShowByIdHandler(ITelevisionRepository televisionRepository) =>
public GetTelevisionShowByIdHandler(
ITelevisionRepository televisionRepository,
ISearchRepository searchRepository)
{
_televisionRepository = televisionRepository;
_searchRepository = searchRepository;
}
public Task<Option<TelevisionShowViewModel>> Handle(
public async Task<Option<TelevisionShowViewModel>> Handle(
GetTelevisionShowById request,
CancellationToken cancellationToken) =>
_televisionRepository.GetShow(request.Id)
.MapT(ProjectToViewModel);
CancellationToken cancellationToken)
{
Option<Show> maybeShow = await _televisionRepository.GetShow(request.Id);
return await maybeShow.Match<Task<Option<TelevisionShowViewModel>>>(
async show =>
{
List<string> languages = await _searchRepository.GetLanguagesForShow(show);
return ProjectToViewModel(show, languages);
},
() => Task.FromResult(Option<TelevisionShowViewModel>.None));
}
}
}
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Globalization;
namespace ErsatzTV.Application.Television
{
@@ -11,5 +12,6 @@ namespace ErsatzTV.Application.Television
string FanArt,
List<string> Genres,
List<string> Tags,
List<string> Studios);
List<string> Studios,
List<CultureInfo> Languages);
}
@@ -0,0 +1,11 @@
using System.Threading.Tasks;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.GitHub
{
public interface IGitHubApiClient
{
Task<Either<BaseError, string>> GetLatestReleaseNotes();
Task<Either<BaseError, string>> GetReleaseNotes(string tag);
}
}
@@ -14,5 +14,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<Channel>> GetAllForGuide();
Task Update(Channel channel);
Task Delete(int channelId);
Task<int> CountPlayouts(int channelId);
}
}
@@ -124,52 +124,52 @@ namespace ErsatzTV.Core.Plex
// TODO: this probably doesn't work
// plex doesn't seem to update genres returned by the main library call
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
foreach (Genre genre in existingMetadata.Genres
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
foreach (Genre genre in existingMetadata.Genres
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
existingMetadata.Genres.Remove(genre);
if (await _metadataRepository.RemoveGenre(genre))
{
existingMetadata.Genres.Remove(genre);
if (await _metadataRepository.RemoveGenre(genre))
{
result.IsUpdated = true;
}
result.IsUpdated = true;
}
}
foreach (Genre genre in incomingMetadata.Genres
.Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
foreach (Genre genre in incomingMetadata.Genres
.Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Add(genre);
if (await _televisionRepository.AddGenre(existingMetadata, genre))
{
existingMetadata.Genres.Add(genre);
if (await _televisionRepository.AddGenre(existingMetadata, genre))
{
result.IsUpdated = true;
}
result.IsUpdated = true;
}
}
foreach (Studio studio in existingMetadata.Studios
.Filter(s => incomingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
foreach (Studio studio in existingMetadata.Studios
.Filter(s => incomingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Remove(studio);
if (await _metadataRepository.RemoveStudio(studio))
{
existingMetadata.Studios.Remove(studio);
if (await _metadataRepository.RemoveStudio(studio))
{
result.IsUpdated = true;
}
result.IsUpdated = true;
}
}
foreach (Studio studio in incomingMetadata.Studios
.Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
foreach (Studio studio in incomingMetadata.Studios
.Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Add(studio);
if (await _televisionRepository.AddStudio(existingMetadata, studio))
{
existingMetadata.Studios.Add(studio);
if (await _televisionRepository.AddStudio(existingMetadata, studio))
{
result.IsUpdated = true;
}
result.IsUpdated = true;
}
}
if (result.IsUpdated)
{
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
}
@@ -103,7 +103,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.SaveChangesAsync();
return ids;
}
public async Task<Option<Artist>> GetArtist(int artistId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
@@ -1,6 +1,8 @@
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
@@ -12,8 +14,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public class ChannelRepository : IChannelRepository
{
private readonly TvContext _dbContext;
private readonly IDbConnection _dbConnection;
public ChannelRepository(TvContext dbContext) => _dbContext = dbContext;
public ChannelRepository(TvContext dbContext, IDbConnection dbConnection)
{
_dbContext = dbContext;
_dbConnection = dbConnection;
}
public async Task<Channel> Add(Channel channel)
{
@@ -81,5 +88,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_dbContext.Channels.Remove(channel);
await _dbContext.SaveChangesAsync();
}
public Task<int> CountPlayouts(int channelId) =>
_dbConnection.QuerySingleAsync<int>(
@"SELECT COUNT(*) FROM Playout WHERE ChannelId = @ChannelId",
new { ChannelId = channelId });
}
}
@@ -43,6 +43,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(m => m.Tags)
.Include(m => m.MovieMetadata)
.ThenInclude(m => m.Studios)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams)
.OrderBy(m => m.Id)
.SingleOrDefaultAsync(m => m.Id == movieId)
.Map(Optional);
@@ -0,0 +1,38 @@
using System;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.GitHub;
using LanguageExt;
using Refit;
namespace ErsatzTV.Infrastructure.GitHub
{
public class GitHubApiClient : IGitHubApiClient
{
public async Task<Either<BaseError, string>> GetLatestReleaseNotes()
{
try
{
IGitHubApi service = RestService.For<IGitHubApi>("https://api.github.com");
return await service.GetReleases().Map(releases => releases.Head().Body);
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
public async Task<Either<BaseError, string>> GetReleaseNotes(string tag)
{
try
{
IGitHubApi service = RestService.For<IGitHubApi>("https://api.github.com");
return await service.GetTag(tag).Map(t => t.Body);
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
}
}
@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Infrastructure.GitHub.Models;
using Refit;
namespace ErsatzTV.Infrastructure.GitHub
{
[Headers("Accept: application/vnd.github.v3+json", "User-Agent: jasongdove/ErsatzTV")]
public interface IGitHubApi
{
[Get("/repos/jasongdove/ErsatzTV/releases")]
public Task<List<GitHubTag>> GetReleases();
[Get("/repos/jasongdove/ErsatzTV/releases/tags/{tag}")]
public Task<GitHubTag> GetTag(string tag);
}
}
@@ -0,0 +1,7 @@
namespace ErsatzTV.Infrastructure.GitHub.Models
{
public class GitHubTag
{
public string Body { get; set; }
}
}
@@ -9,25 +9,25 @@ namespace ErsatzTV.Infrastructure.Migrations
migrationBuilder.Sql(
@"DELETE FROM Genre
WHERE MovieMetadataId NOT IN (SELECT Id FROM MovieMetadata)
OR ShowMetadataId NOT IN (SELECT Id FROM Show)
OR SeasonMetadataId NOT IN (SELECT Id FROM Season)
OR EpisodeMetadataId NOT IN (SELECT Id FROM Episode)
OR ShowMetadataId NOT IN (SELECT Id FROM ShowMetadata)
OR SeasonMetadataId NOT IN (SELECT Id FROM SeasonMetadata)
OR EpisodeMetadataId NOT IN (SELECT Id FROM EpisodeMetadata)
OR MusicVideoMetadataId NOT IN (SELECT Id FROM MusicVideoMetadata)");
migrationBuilder.Sql(
@"DELETE FROM Tag
WHERE MovieMetadataId NOT IN (SELECT Id FROM MovieMetadata)
OR ShowMetadataId NOT IN (SELECT Id FROM Show)
OR SeasonMetadataId NOT IN (SELECT Id FROM Season)
OR EpisodeMetadataId NOT IN (SELECT Id FROM Episode)
OR ShowMetadataId NOT IN (SELECT Id FROM ShowMetadata)
OR SeasonMetadataId NOT IN (SELECT Id FROM SeasonMetadata)
OR EpisodeMetadataId NOT IN (SELECT Id FROM EpisodeMetadata)
OR MusicVideoMetadataId NOT IN (SELECT Id FROM MusicVideoMetadata)");
migrationBuilder.Sql(
@"DELETE FROM Studio
WHERE MovieMetadataId NOT IN (SELECT Id FROM MovieMetadata)
OR ShowMetadataId NOT IN (SELECT Id FROM Show)
OR SeasonMetadataId NOT IN (SELECT Id FROM Season)
OR EpisodeMetadataId NOT IN (SELECT Id FROM Episode)
OR ShowMetadataId NOT IN (SELECT Id FROM ShowMetadata)
OR SeasonMetadataId NOT IN (SELECT Id FROM SeasonMetadata)
OR EpisodeMetadataId NOT IN (SELECT Id FROM EpisodeMetadata)
OR MusicVideoMetadataId NOT IN (SELECT Id FROM MusicVideoMetadata)");
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Update_LibraryLastScan_Metadata : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00' WHERE Id IN
(SELECT LP.Id FROM LibraryPath LP INNER JOIN Library L on L.Id = LP.LibraryId WHERE MediaKind = 2)");
migrationBuilder.Sql(
@"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE MediaKind = 2");
migrationBuilder.Sql(
@"UPDATE ShowMetadata SET DateUpdated = '0001-01-01 00:00:00' WHERE MetadataKind = 1");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
+42 -10
View File
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core;
@@ -47,15 +48,20 @@ namespace ErsatzTV.Infrastructure.Search
private const string ShowType = "show";
private const string ArtistType = "artist";
private const string MusicVideoType = "music_video";
private readonly List<CultureInfo> _cultureInfos;
private readonly ILogger<SearchIndex> _logger;
private FSDirectory _directory;
private IndexWriter _writer;
public SearchIndex(ILogger<SearchIndex> logger) => _logger = logger;
public SearchIndex(ILogger<SearchIndex> logger)
{
_logger = logger;
_cultureInfos = CultureInfo.GetCultures(CultureTypes.NeutralCultures).ToList();
}
public int Version => 6;
public int Version => 7;
public Task<bool> Initialize(ILocalFileSystem localFileSystem)
{
@@ -296,11 +302,17 @@ namespace ErsatzTV.Infrastructure.Search
if (maybeVersion.IsSome)
{
MediaVersion version = maybeVersion.ValueUnsafe();
foreach (string lang in version.Streams.Filter(ms => ms.MediaStreamKind == MediaStreamKind.Video)
foreach (CultureInfo cultureInfo in version.Streams
.Filter(ms => ms.MediaStreamKind == MediaStreamKind.Audio)
.Map(ms => ms.Language).Distinct()
.Filter(s => !string.IsNullOrWhiteSpace(s)))
.Filter(s => !string.IsNullOrWhiteSpace(s))
.Map(
l => _cultureInfos.Filter(
c => string.Equals(c.ThreeLetterISOLanguageName, l, StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten())
{
doc.Add(new StringField(LanguageField, lang, Field.Store.NO));
doc.Add(new TextField(LanguageField, cultureInfo.EnglishName, Field.Store.NO));
}
}
}
@@ -326,9 +338,19 @@ namespace ErsatzTV.Infrastructure.Search
};
List<string> languages = await searchRepository.GetLanguagesForShow(show);
foreach (string lang in languages.Distinct().Filter(s => !string.IsNullOrWhiteSpace(s)))
foreach (CultureInfo cultureInfo in languages
.Distinct()
.Filter(s => !string.IsNullOrWhiteSpace(s))
.Map(
l => _cultureInfos.Filter(
c => string.Equals(
c.ThreeLetterISOLanguageName,
l,
StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten())
{
doc.Add(new StringField(LanguageField, lang, Field.Store.NO));
doc.Add(new TextField(LanguageField, cultureInfo.EnglishName, Field.Store.NO));
}
if (metadata.ReleaseDate.HasValue)
@@ -391,9 +413,19 @@ namespace ErsatzTV.Infrastructure.Search
};
List<string> languages = await searchRepository.GetLanguagesForArtist(artist);
foreach (string lang in languages.Distinct().Filter(s => !string.IsNullOrWhiteSpace(s)))
foreach (CultureInfo cultureInfo in languages
.Distinct()
.Filter(s => !string.IsNullOrWhiteSpace(s))
.Map(
l => _cultureInfos.Filter(
c => string.Equals(
c.ThreeLetterISOLanguageName,
l,
StringComparison.OrdinalIgnoreCase)))
.Sequence()
.Flatten())
{
doc.Add(new StringField(LanguageField, lang, Field.Store.NO));
doc.Add(new TextField(LanguageField, cultureInfo.EnglishName, Field.Store.NO));
}
foreach (Genre genre in metadata.Genres)
@@ -416,7 +448,7 @@ namespace ErsatzTV.Infrastructure.Search
catch (Exception ex)
{
metadata.Artist = null;
_logger.LogWarning(ex, "Error indexing show with metadata {@Metadata}", metadata);
_logger.LogWarning(ex, "Error indexing artist with metadata {@Metadata}", metadata);
}
}
}
+2
View File
@@ -19,7 +19,9 @@
<PackageReference Include="Blazored.LocalStorage" Version="3.0.0" />
<PackageReference Include="FluentValidation" Version="9.5.3" />
<PackageReference Include="FluentValidation.AspNetCore" Version="9.5.3" />
<PackageReference Include="HtmlSanitizer" Version="5.0.376" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Markdig" Version="0.24.0" />
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="3.0.1" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.4" />
+13 -2
View File
@@ -5,6 +5,7 @@
@using ErsatzTV.Application.MediaCards.Queries
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using System.Globalization
@using Unit = LanguageExt.Unit
@inject IMediator Mediator
@inject IDialogService Dialog
@@ -57,6 +58,16 @@
</div>
</div>
</div>
@if (_artist.Languages.Any())
{
<MudText GutterBottom="true">Languages</MudText>
<div class="mb-2">
@foreach (CultureInfo language in _artist.Languages.OrderBy(l => l.EnglishName))
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@language.EnglishName" Class="mr-2 mb-2" Link="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")"/>
}
</div>
}
@if (_artist.Genres.Any())
{
<MudText GutterBottom="true">Genres</MudText>
@@ -101,7 +112,7 @@
}
else
{
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Animation="Animation.False" Height="220px" Width="293px" />
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Animation="Animation.False" Height="220px" Width="293px"/>
}
<MudCardContent Class="ml-3">
<div style="display: flex; flex-direction: column; height: 100%">
@@ -151,7 +162,7 @@
NavigationManager.NavigateTo($"/media/collections/{collection.Id}");
}
}
private async Task AddMusicVideoToCollection(MusicVideoCardViewModel musicVideo)
{
var parameters = new DialogParameters { { "EntityType", "music video" }, { "EntityName", musicVideo.Title } };
+4 -1
View File
@@ -131,7 +131,10 @@
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems() => _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
List<MediaCardViewModel> GetSortedItems()
{
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
}
SelectClicked(GetSortedItems, card, e);
}
+10 -7
View File
@@ -271,13 +271,16 @@
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems() => _data.MovieCards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
.Append(_data.SeasonCards.OrderBy(s => s.SortTitle))
.Append(_data.EpisodeCards.OrderBy(ep => ep.Aired))
.Append(_data.ArtistCards.OrderBy(a => a.SortTitle))
.Append(_data.MusicVideoCards.OrderBy(mv => mv.SortTitle))
.ToList();
List<MediaCardViewModel> GetSortedItems()
{
return _data.MovieCards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
.Append(_data.SeasonCards.OrderBy(s => s.SortTitle))
.Append(_data.EpisodeCards.OrderBy(ep => ep.Aired))
.Append(_data.ArtistCards.OrderBy(a => a.SortTitle))
.Append(_data.MusicVideoCards.OrderBy(mv => mv.SortTitle))
.ToList();
}
SelectClicked(GetSortedItems, card, e);
}
+63 -66
View File
@@ -1,72 +1,69 @@
@page "/"
@using System.Reflection
@using ErsatzTV.Core.Interfaces.GitHub
@using Microsoft.Extensions.Caching.Memory
@inject IGitHubApiClient _gitHubApiClient
@inject IMemoryCache _memoryCache
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudCard>
<MudCardContent>
<MudText Typo="Typo.h3">Welcome to ErsatzTV!</MudText>
<MudElement HtmlTag="div" Class="mt-6">
<MudText Typo="Typo.h4" GutterBottom="true">Channels</MudText>
<MudText>
<MudLink Href="/channels">Channels</MudLink> are not directly associated with any media. Channels have a <b>number</b>, a <b>name</b>, and a <b>streaming mode</b> that indicates how the channel will play media.
</MudText>
<MudText Class="mt-3">
In <b>TransportStream</b> mode, the channel will also require an <b>FFmpeg profile</b> to configure transcoding and normalization.
In <b>HttpLiveStreaming</b> mode, the channel will attempt to serve the channel's media without transcoding or normalization beyond the container format.
</MudText>
</MudElement>
<MudElement HtmlTag="div" Class="mt-6">
<MudText Typo="Typo.h4" GutterBottom="true">FFmpeg Profiles</MudText>
<MudText>
<MudLink Href="/ffmpeg">FFmpeg Profiles</MudLink> are collections of FFmpeg settings that are applied at the channel level.
All content on a given channel will use the same FFmpeg settings. This also means the same content on different channels can use different settings.
</MudText>
</MudElement>
<MudElement HtmlTag="div" Class="mt-6">
<MudText Typo="Typo.h4" GutterBottom="true">Libraries</MudText>
<MudText>
Two local <MudLink Href="/media/libraries">libraries</MudLink> are available, one for each <b>media kind</b>: Shows and Movies. Libraries contain <b>paths</b> (folders) to regularly scan for media items.
Support for Plex libraries is under active development; Jellyfin and Emby library support is planned.
</MudText>
</MudElement>
<MudElement HtmlTag="div" Class="mt-6">
<MudText Typo="Typo.h4" GutterBottom="true">Collections</MudText>
<MudText>
<MudLink Href="/media/collections">Collections</MudLink> have a <b>name</b> and contain a logical grouping of media items.
Collections may contain shows, seasons, episodes or movies.
Collections containing shows and seasons are automatically updated as media is added or removed from the linked shows and seasons.
</MudText>
</MudElement>
<MudElement HtmlTag="div" Class="mt-6">
<MudText Typo="Typo.h4" GutterBottom="true">Schedules</MudText>
<MudText>
<MudLink Href="/schedules">Schedules</MudLink> have a <b>name</b>, a <b>collection playback order</b> and <b>items</b> to continually loop through.
</MudText>
<MudText Class="mt-3 mb-2">Three <b>collection playback orders</b> are supported:</MudText>
<ul class="mud-typography-body1">
<li><b>Random</b> - to randomly play collection items; repeating is allowed before all collection items have been played.</li>
<li><b>Shuffle</b> - to randomly play collection items; repeating is <i>not</i> allowed until all collection items have been played.</li>
<li><b>Chronological</b> - to play collection items sorted by air date and then by season and episode number (for when multiple episodes aired on a single day).</li>
</ul>
<MudText Class="mt-3">
Schedule items have a <b>start type</b>, a <b>start time</b>, a <b>collection</b> and a <b>playout mode</b>.
</MudText>
<MudText Class="mt-3">
A <b>fixed</b> start type requires a <b>start time</b>, while a <b>dynamic</b> start type means the schedule item will start immediately after the preceding schedule item.
</MudText>
<MudText Class="mt-3 mb-2">Four <b>playout modes</b> are supported:</MudText>
<ul class="mud-typography-body1">
<li><b>One</b> - to play one media item from the collection before advancing to the next schedule item.</li>
<li><b>Multiple</b> - to play a specified <b>count</b> of media items from the collection before advancing to the next schedule item.</li>
<li><b>Duration</b> - to play the maximum number of complete media items that will fit in the specified <b>playout duration</b>, before either going offline for the remainder of the <b>playout duration</b> (an <b>offline tail</b>), or immediately advancing to the next schedule item.</li>
<li><b>Flood</b> - to play media items from the collection forever, or until the next schedule item's <b>start time</b> if one exists.</li>
</ul>
</MudElement>
<MudElement HtmlTag="div" Class="mt-6">
<MudText Typo="Typo.h4" GutterBottom="true">Playouts</MudText>
<MudText>
<MudLink Href="/playouts">Playouts</MudLink> assign a <b>schedule</b> to a <b>channel</b> and individually track the ordered playback of collection items.
</MudText>
</MudElement>
<MudCardContent Class="release-notes mud-typography mud-typography-body1">
<MarkdownView Content="@_releaseNotes"/>
</MudCardContent>
</MudCard>
</MudContainer>
</MudContainer>
@code {
private string _releaseNotes;
protected override async Task OnParametersSetAsync()
{
try
{
if (_memoryCache.TryGetValue("Index.ReleaseNotesHtml", out string releaseNotesHtml))
{
_releaseNotes = releaseNotesHtml;
}
else
{
var assembly = Assembly.GetEntryAssembly();
if (assembly != null)
{
string version = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
if (version != null)
{
Either<BaseError, string> maybeNotes;
if (version != "develop")
{
string gitHubVersion = version.Split("-").Head() + "-prealpha";
if (!gitHubVersion.StartsWith("v"))
{
gitHubVersion = $"v{gitHubVersion}";
}
maybeNotes = await _gitHubApiClient.GetReleaseNotes(gitHubVersion);
maybeNotes.IfRight(notes => _releaseNotes = notes);
}
else
{
maybeNotes = await _gitHubApiClient.GetLatestReleaseNotes();
maybeNotes.IfRight(notes => _releaseNotes = notes);
}
}
}
if (_releaseNotes != null)
{
_memoryCache.Set("Index.ReleaseNotesHtml", _releaseNotes);
}
}
}
catch (Exception ex)
{
// ignore
}
}
}
+11
View File
@@ -1,6 +1,7 @@
@page "/media/movies/{MovieId:int}"
@using ErsatzTV.Application.Movies
@using ErsatzTV.Application.Movies.Queries
@using System.Globalization
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@inject IMediator Mediator
@@ -43,6 +44,16 @@
</div>
</div>
</div>
@if (_movie.Languages.Any())
{
<MudText GutterBottom="true">Languages</MudText>
<div class="mb-2">
@foreach (CultureInfo language in _movie.Languages.OrderBy(l => l.EnglishName))
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@language.EnglishName" Class="mr-2 mb-2" Link="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")"/>
}
</div>
}
@if (_movie.Studios.Any())
{
<MudText GutterBottom="true">Studios</MudText>
+3 -3
View File
@@ -44,7 +44,7 @@
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")" Style="margin-bottom: auto; margin-top: auto">@_shows.Count Shows</MudLink>
}
if (_artists.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#artists")" Style="margin-bottom: auto; margin-top: auto">@_artists.Count Artists</MudLink>
@@ -256,7 +256,7 @@
Right: _ => Snackbar.Add($"Added {show.Title} to collection {collection.Name}", Severity.Success));
}
}
if (card is ArtistCardViewModel artist)
{
var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", artist.Title } };
@@ -319,7 +319,7 @@
}
return uri;
}
private string GetArtistsLink()
{
var uri = "/media/music/artists/page/1";
+11
View File
@@ -7,6 +7,7 @@
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.ProgramSchedules
@using ErsatzTV.Application.ProgramSchedules.Commands
@using System.Globalization
@using Unit = LanguageExt.Unit
@inject IMediator Mediator
@inject ILogger<TelevisionSeasonList> Logger
@@ -58,6 +59,16 @@
</div>
</div>
</div>
@if (_show.Languages.Any())
{
<MudText GutterBottom="true">Languages</MudText>
<div class="mb-2">
@foreach (CultureInfo language in _show.Languages.OrderBy(l => l.EnglishName))
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@language.EnglishName" Class="mr-2 mb-2" Link="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")"/>
}
</div>
}
@if (_show.Studios.Any())
{
<MudText GutterBottom="true">Studios</MudText>
+5
View File
@@ -43,6 +43,11 @@
function enableSorting() {
$("#sortable-collection").sortable("option", "disabled", false);
}
function styleMarkdown() {
$("h2").addClass("mud-typography mud-typography-h4");
$("h3").addClass("mud-typography mud-typography-h5");
}
</script>
</head>
<body>
+1
View File
@@ -0,0 +1 @@
@HtmlContent
+55
View File
@@ -0,0 +1,55 @@
using System.Threading.Tasks;
using Ganss.XSS;
using Markdig;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace ErsatzTV.Shared
{
public partial class MarkdownView
{
private string _content;
[Inject]
public IHtmlSanitizer HtmlSanitizer { get; set; }
[Inject]
public IJSRuntime JsRuntime { get; set; }
[Parameter]
public string Content
{
get => _content;
set
{
_content = value;
HtmlContent = ConvertStringToMarkupString(_content);
}
}
public MarkupString HtmlContent { get; private set; }
private MarkupString ConvertStringToMarkupString(string value)
{
if (!string.IsNullOrWhiteSpace(_content))
{
// Convert markdown string to HTML
string html = Markdown.ToHtml(value, new MarkdownPipelineBuilder().UseAdvancedExtensions().Build());
// Sanitize HTML before rendering
string sanitizedHtml = HtmlSanitizer.Sanitize(html);
// Return sanitized HTML as a MarkupString that Blazor can render
return new MarkupString(sanitizedHtml);
}
return new MarkupString();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await JsRuntime.InvokeVoidAsync("styleMarkdown");
await base.OnAfterRenderAsync(firstRender);
}
}
}
+11
View File
@@ -10,6 +10,7 @@ using ErsatzTV.Application.Channels.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.GitHub;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
@@ -24,6 +25,7 @@ using ErsatzTV.Core.Scheduling;
using ErsatzTV.Formatters;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Infrastructure.GitHub;
using ErsatzTV.Infrastructure.Images;
using ErsatzTV.Infrastructure.Locking;
using ErsatzTV.Infrastructure.Plex;
@@ -33,6 +35,7 @@ using ErsatzTV.Serialization;
using ErsatzTV.Services;
using ErsatzTV.Services.RunOnce;
using FluentValidation.AspNetCore;
using Ganss.XSS;
using MediatR;
using MediatR.Courier.DependencyInjection;
using Microsoft.AspNetCore.Builder;
@@ -225,6 +228,14 @@ namespace ErsatzTV
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>();
services.AddScoped<FFmpegProcessService>();
services.AddScoped<IGitHubApiClient, GitHubApiClient>();
services.AddScoped<IHtmlSanitizer, HtmlSanitizer>(
_ =>
{
var sanitizer = new HtmlSanitizer();
sanitizer.AllowedAttributes.Add("class");
return sanitizer;
});
services.AddHostedService<DatabaseMigratorService>();
services.AddHostedService<CacheCleanerService>();
+16
View File
@@ -120,4 +120,20 @@
flex-direction: column;
height: 100%;
justify-content: space-around;
}
.release-notes ul {
list-style: unset;
}
.release-notes > ul {
margin-top: 10px;
}
.release-notes ul > li {
margin-left: 30px;
}
.release-notes h3 {
margin-top: 20px;
}
-10
View File
@@ -23,16 +23,6 @@ Want to join the community or have a question? Join us on [Discord](https://disc
- Run as a Windows service
- Spots to fill unscheduled gaps
## Screenshots
### Television Show
![Television Show](docs/images/television-show.png)
### Media Collection
![Media Collection](docs/images/media-collection.png)
## License
This project is inspired by [pseudotv-plex](https://github.com/DEFENDORe/pseudotv) and
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

+8
View File
@@ -80,3 +80,11 @@ Then, add to a collection by clicking the `Add To Collection` button at the top
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)
---
Big Buck Bunny: (c) copyright 2008, Blender Foundation / [www.bigbuckbunny.org](https://www.bigbuckbunny.org)
Sintel: © copyright Blender Foundation | [www.sintel.org](https://www.sintel.org)
Tears of Steel: (CC) Blender Foundation | [mango.blender.org](https://mango.blender.org)
+4 -16
View File
@@ -50,7 +50,7 @@ From the Admin Dashboard in Jellyfin, click `Live TV` and `+` to add a new tuner
![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).
For `Tuner Type` select `M3U Tuner`, for `File or URL` enter the `M3U` url from ErsatzTV (see [required urls](#required-urls)), and click `Save`.
![Jellyfin Live TV Tuner Setup](../images/jellyfin-live-tv-tuner-setup.png)
@@ -91,23 +91,11 @@ 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))
- Enter the `M3U` url from ErsatzTV (see [required urls](#required-urls)) as the Source
- Select `Refresh URL daily`
- Set a stream limit if desired (not required)
- Enter the `XMLTV` url from ErsatzTV (see [required urls](#required-urls)) as the XMLTV Guide Data
- Select a refresh interval based on how often you expect to make changes to playouts
- 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)
+109
View File
@@ -0,0 +1,109 @@
## Movies
### Folder Layout
The `Movies` library requires movie subfolders. The following is a (non-exhaustive) list of valid locations for movies:
- `Movie (1999)\Movie (1999).mkv`
- `Movie\Movie.mkv`
### NFO Metadata
Each movie folder may contain a `movie.nfo` file, or an NFO file with exactly the same name as the movie, except for the `.nfo` extension. See [Kodi Wiki](https://kodi.wiki/view/NFO_files/Movies) for more information.
ErsatzTV will read the following fields from the movie NFO:
- Title
- Year
- Premiered
- Plot
- Genre(s)
- Tag(s)
- Studio(s)
### Movie Fallback Metadata
When no movie NFO is found, the movie metadata will only contain a title and a year, both parsed from the movie file name. Example:
- `Movie (1999).mkv`
## Shows
### Folder Layout
The `Shows` library requires show and season subfolders. The following is a (non-exhaustive) list of valid locations for episodes:
- `Show (1999)\Season 01\Show - S01E01.mp4`
- `Show\Season 1\Show - s1e1.mp4`
### Show NFO Metadata
Each show folder may contain a `tvshow.nfo` file. See [Kodi Wiki](https://kodi.wiki/view/NFO_files/TV_shows#TV_Show) for more information.
ErsatzTV will read the following fields from the show NFO:
- Title
- Year
- Premiered
- Plot
- Genre(s)
- Tag(s)
- Studio(s)
### Show Fallback Metadata
When no show NFO is found, the show metadata will only contain the title and an optional year, both parsed from the episode file name.
Examples:
- `Title`
- `Title (1999)`
### Season Metadata
The season number is parsed from the season subfolder.
Examples:
- `Season 01`
- `Season 1`
### Episode NFO Metadata
Each episode may have a corresponding NFO file with exactly the same name, except for the `.nfo` extension. See [Kodi Wiki](https://kodi.wiki/view/NFO_files/TV_shows#Episodes) for more information.
ErsatzTV will read the following fields from the episode NFO:
- Title
- Episode
- Aired
- Plot
### Episode Fallback Metadata
When no episode NFO is found, the episode metadata will only contain the title and the episode number, both parsed from the episode file name.
Examples:
- `Title - s01e04.mkv`
- `Title - S1E4.mkv`
## Music Videos
### Folder Layout
The `Music Videos` library requires artist subfolders. The following is a (non-exhaustive) list of valid locations for music videos:
- `Artist\Album\Track.mp4`
- `Artist\Track\Track.mp4`
- `Artist\Track.mp4`
### Artist NFO Metadata
Each artist subfolder may contain an `artist.nfo` file. See [Kodi Wiki](https://kodi.wiki/view/NFO_files/Music#Artists) for more information.
ErsatzTV will read the following fields from the artist NFO:
- Name
- Disambiguation
- Biography
- Genre(s)
- Style(s)
- Mood(s)
### Artist Fallback Metadata
When no artist NFO is found, the artist metadata will only contain a name, which will be the exact name of the artist subfolder.
+45
View File
@@ -0,0 +1,45 @@
## Search Box
Movies, Shows, Artists and Music Videos can be searched using the search box next to the ErsatzTV logo.
![Search Box](../images/search-box.png)
## Search Fields
The following fields are available for searching:
- `title`: The movie, show, artist or music video name/title
- `genre`: The movie, show, or artist genre
- `tag`: The movie or show tag (not available with Plex metadata)
- `style`: The artist style
- `mood`: The artist mood
- `plot`: The movie or show plot
- `studio`: The movie or show studio
- `library_name`: The name of the library
- `language`: The movie, show or music video audio stream language
- `release_date`: The movie or show release date (YYYYMMDD)
- `type`: The media item type: `movie`, `show`, `artist` or `music_video`
Note that the `title` field is searched by default if no other field is specified.
## Sample Searches
### Christmas
`plot:christmas`
### Christmas without Horror
`plot:christmas NOT genre:horror`
### 1970's Movies
`type:movie AND release_date:197*`
### 1970's-1980's Comedies
`genre:comedy AND (release_date:197* OR release_date:198*)`
### Lush Music
`mood:lush`
+5
View File
@@ -5,12 +5,17 @@ nav:
- 'Add Media Items': 'user-guide/add-media-items.md'
- 'Create Channels': 'user-guide/create-channels.md'
- 'Configure Clients': 'user-guide/configure-clients.md'
- 'Advanced':
- 'Local Libraries': 'user-guide/local-libraries.md'
- 'Search': 'user-guide/search.md'
theme:
name: material
palette:
scheme: slate
logo: images/ersatztv-square-logo.png
favicon: images/favicon-32x32.png
features:
- navigation.expand
extra_css:
- stylesheets/extra.css
copyright: Copyright &copy; 2020 - 2021 Jason Dove