Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2700e087c | ||
|
|
34fbfce0a5 | ||
|
|
993293c104 | ||
|
|
ececa62446 | ||
|
|
237729e79d | ||
|
|
9c0ada2df5 | ||
|
|
dee264597b | ||
|
|
a8db294043 | ||
|
|
a2a63e0120 | ||
|
|
c7881aec14 | ||
|
|
558bdcb6b0 |
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
@HtmlContent
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
### Media Collection
|
||||
|
||||

|
||||
|
||||
## License
|
||||
|
||||
This project is inspired by [pseudotv-plex](https://github.com/DEFENDORe/pseudotv) and
|
||||
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 435 KiB |
@@ -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.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
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)
|
||||
|
||||
@@ -50,7 +50,7 @@ From the Admin Dashboard in Jellyfin, click `Live TV` and `+` to add a new tuner
|
||||
|
||||

|
||||
|
||||
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`.
|
||||
|
||||

|
||||
|
||||
@@ -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`
|
||||
|
||||

|
||||
|
||||
### Add Guide Data Provider
|
||||
|
||||
In Channels DVR Settings, click the gear icon next to the ErsatzTV channel source and select `Set Provider`:
|
||||
|
||||

|
||||
|
||||
Click the drop down next to zip code and select `XMLTV > Custom URL`:
|
||||
|
||||

|
||||
|
||||
Enter the `XMLTV` url from ErsatzTV (see [required urls](#required-urls)) and click `Save`.
|
||||
|
||||

|
||||
@@ -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.
|
||||
@@ -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 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,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 © 2020 - 2021 Jason Dove
|
||||
|
||||