Compare commits

..
Author SHA1 Message Date
Jason Dove f0b429efb5 update changelog for release 44 [no docker] 2021-06-09 18:39:52 -05:00
Jason DoveandGitHub da5148affd quickly skip missing files during plex library scan (#251) 2021-06-07 20:34:24 -05:00
Jason DoveandGitHub cec5a09839 add us content ratings to xmltv (#250) 2021-06-07 18:58:38 -05:00
Jason DoveandGitHub e20f9be702 exclude strm files from jellyfin scanners (#249)
* exclude strm files from jellyfin scanners

* update changelog
2021-06-07 07:41:59 -05:00
Jason Dove 3bc3faa7c4 artist schedule doc update [no docker] 2021-06-06 20:25:39 -05:00
Jason DoveandGitHub db24ba84f7 add artists directly to schedules (#248) 2021-06-06 20:12:17 -05:00
Jason DoveandGitHub 8346a02747 ignore unsupported plex guids (#246) 2021-06-05 15:53:38 -05:00
Jason Dove c3b33c184f fix changelog [no docker] 2021-06-05 13:39:29 -05:00
28 changed files with 3277 additions and 58 deletions
+13 -1
View File
@@ -5,6 +5,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.0.44-prealpha] - 2021-06-09
### Added
- Add artists directly to schedules
- Include MPAA and VCHIP content ratings in XMLTV guide data
- Quickly skip missing files during Plex library scan
### Fixed
- Ignore unsupported plex guids (this prevented some libraries from scanning correctly)
- Ignore unsupported STRM files from Jellyfin
## [0.0.43-prealpha] - 2021-06-05
### Added
- Support `(Part #)` name suffixes for multi-part episode grouping
@@ -17,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Changed
- Rename channel mode `TransportStream` to `MPEG-TS` and `HttpLiveStreaming` to `HLS Direct`
- Improve `HLS Direct` mode compatibility with Channels DVR Server
### Fixed
- Fix search result crashes due to missing season metadata
@@ -408,7 +419,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.43-prealpha...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.44-prealpha...HEAD
[0.0.43-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.43-prealpha...v0.0.44-prealpha
[0.0.43-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.42-prealpha...v0.0.43-prealpha
[0.0.42-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.41-prealpha...v0.0.42-prealpha
[0.0.41-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.40-prealpha...v0.0.41-prealpha
@@ -0,0 +1,8 @@
using System.Collections.Generic;
using ErsatzTV.Application.MediaItems;
using MediatR;
namespace ErsatzTV.Application.Artists.Queries
{
public record GetAllArtists : IRequest<List<NamedMediaItemViewModel>>;
}
@@ -0,0 +1,24 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaItems.Mapper;
namespace ErsatzTV.Application.Artists.Queries
{
public class GetAllArtistsHandler : IRequestHandler<GetAllArtists, List<NamedMediaItemViewModel>>
{
private readonly IArtistRepository _artistRepository;
public GetAllArtistsHandler(IArtistRepository artistRepository) => _artistRepository = artistRepository;
public Task<List<NamedMediaItemViewModel>> Handle(
GetAllArtists request,
CancellationToken cancellationToken) =>
_artistRepository.GetAllArtists().Map(list => list.Map(ProjectToViewModel).ToList());
}
}
+5 -2
View File
@@ -7,12 +7,15 @@ namespace ErsatzTV.Application.MediaItems
internal static MediaItemViewModel ProjectToViewModel(MediaItem mediaItem) =>
new(mediaItem.Id, mediaItem.LibraryPathId);
public static NamedMediaItemViewModel ProjectToViewModel(Show show) =>
internal static NamedMediaItemViewModel ProjectToViewModel(Show show) =>
new(show.Id, show.ShowMetadata.HeadOrNone().Map(sm => $"{sm?.Title} ({sm?.Year})").IfNone("???"));
public static NamedMediaItemViewModel ProjectToViewModel(Season season) =>
internal static NamedMediaItemViewModel ProjectToViewModel(Season season) =>
new(season.Id, $"{ShowTitle(season)} ({SeasonDescription(season)})");
internal static NamedMediaItemViewModel ProjectToViewModel(Artist artist) =>
new(artist.Id, artist.ArtistMetadata.HeadOrNone().Match(am => am.Title, () => "???"));
private static string ShowTitle(Season season) =>
season.Show.ShowMetadata.HeadOrNone().Map(sm => sm.Title).IfNone("???");
@@ -79,6 +79,13 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
return BaseError.New("[MediaItem] is required for collection type 'TelevisionSeason'");
}
break;
case ProgramScheduleItemCollectionType.Artist:
if (item.MediaItemId is null)
{
return BaseError.New("[MediaItem] is required for collection type 'Artist'");
}
break;
default:
return BaseError.New("[CollectionType] is invalid");
@@ -30,6 +30,7 @@ namespace ErsatzTV.Application.ProgramSchedules
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
duration.PlayoutDuration,
@@ -49,6 +50,7 @@ namespace ErsatzTV.Application.ProgramSchedules
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
flood.CustomTitle),
@@ -66,6 +68,7 @@ namespace ErsatzTV.Application.ProgramSchedules
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
multiple.Count,
@@ -84,6 +87,7 @@ namespace ErsatzTV.Application.ProgramSchedules
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
one.CustomTitle),
@@ -19,10 +19,12 @@ namespace ErsatzTV.Application.ProgramSchedules
public string Name => CollectionType switch
{
ProgramScheduleItemCollectionType.Collection => Collection?.Name,
ProgramScheduleItemCollectionType
.TelevisionShow => MediaItem?.Name, // $"{TelevisionShow?.Title} ({TelevisionShow?.Year})",
ProgramScheduleItemCollectionType
.TelevisionSeason => MediaItem?.Name, // $"{TelevisionSeason?.Title} ({TelevisionSeason?.Plot})",
ProgramScheduleItemCollectionType.TelevisionShow =>
MediaItem?.Name, // $"{TelevisionShow?.Title} ({TelevisionShow?.Year})",
ProgramScheduleItemCollectionType.TelevisionSeason =>
MediaItem?.Name, // $"{TelevisionSeason?.Title} ({TelevisionSeason?.Plot})",
ProgramScheduleItemCollectionType.Artist =>
MediaItem?.Name,
_ => string.Empty
};
}
@@ -3,11 +3,13 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Tests.Fakes;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using Serilog;
using static LanguageExt.Prelude;
@@ -349,7 +351,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
};
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(6);
@@ -429,7 +432,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
};
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(7);
@@ -515,7 +519,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
};
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(6);
@@ -605,7 +610,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
};
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(6);
@@ -699,7 +705,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
};
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(5);
@@ -792,7 +799,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
};
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(5);
@@ -851,7 +859,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
var collectionRepo = new FakeMediaCollectionRepository(Map((mediaCollection.Id, mediaItems)));
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(collectionRepo, televisionRepo, _logger);
var artistRepo = new Mock<IArtistRepository>();
var builder = new PlayoutBuilder(collectionRepo, televisionRepo, artistRepo.Object, _logger);
var items = new List<ProgramScheduleItem> { Flood(mediaCollection) };
@@ -4,6 +4,7 @@
{
Collection = 0,
TelevisionShow = 1,
TelevisionSeason = 2
TelevisionSeason = 2,
Artist = 3
}
}
@@ -1,9 +1,12 @@
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Plex
{
public interface IPlexPathReplacementService
{
Task<string> GetReplacementPlexPath(int libraryPathId, string path);
string GetReplacementPlexPath(List<PlexPathReplacement> pathReplacements, string path, bool log = true);
}
}
@@ -21,5 +21,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> AddGenre(ArtistMetadata metadata, Genre genre);
Task<bool> AddStyle(ArtistMetadata metadata, Style style);
Task<bool> AddMood(ArtistMetadata metadata, Mood mood);
Task<List<MusicVideo>> GetArtistItems(int artistId);
Task<List<Artist>> GetAllArtists();
}
}
+47 -5
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
@@ -8,6 +9,7 @@ using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using Serilog;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Iptv
@@ -94,7 +96,7 @@ namespace ErsatzTV.Core.Iptv
string title = GetTitle(startItem);
string subtitle = GetSubtitle(startItem);
string description = GetDescription(startItem);
string contentRating = string.Empty;
Option<ContentRating> contentRating = GetContentRating(startItem);
xml.WriteStartElement("programme");
xml.WriteAttributeString("start", start);
@@ -210,12 +212,12 @@ namespace ErsatzTV.Core.Iptv
}
}
if (!string.IsNullOrWhiteSpace(contentRating))
foreach (ContentRating rating in contentRating)
{
xml.WriteStartElement("rating");
xml.WriteAttributeString("system", "MPAA");
xml.WriteAttributeString("system", rating.System);
xml.WriteStartElement("value");
xml.WriteString(contentRating);
xml.WriteString(rating.Value);
xml.WriteEndElement(); // value
xml.WriteEndElement(); // rating
}
@@ -322,5 +324,45 @@ namespace ErsatzTV.Core.Iptv
_ => string.Empty
};
}
private static Option<ContentRating> GetContentRating(PlayoutItem playoutItem)
{
try
{
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata
.HeadOrNone()
.Match(mm => ParseContentRating(mm.ContentRating, "MPAA"), () => None),
Episode e => e.Season.Show.ShowMetadata
.HeadOrNone()
.Match(sm => ParseContentRating(sm.ContentRating, "VCHIP"), () => None),
_ => None
};
}
catch (Exception ex)
{
Log.Logger.Warning(ex, "Failed to get content rating for playout item {Item}", GetTitle(playoutItem));
return None;
}
}
private static Option<ContentRating> ParseContentRating(string contentRating, string system)
{
Option<string> maybeFirst = contentRating.Split('/').HeadOrNone();
return maybeFirst.Map<Option<ContentRating>>(
first =>
{
string[] split = first.Split(':');
if (split.Length == 2 && split[0].ToLowerInvariant() == "us")
{
return new ContentRating(system, split[1].ToUpperInvariant());
}
return None;
}).Flatten();
}
private record ContentRating(string System, string Value);
}
}
+34 -3
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
@@ -18,6 +19,9 @@ namespace ErsatzTV.Core.Plex
{
private readonly ILogger<PlexMovieLibraryScanner> _logger;
private readonly IMediator _mediator;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly ILocalFileSystem _localFileSystem;
private readonly IMetadataRepository _metadataRepository;
private readonly IMovieRepository _movieRepository;
private readonly IPlexServerApiClient _plexServerApiClient;
@@ -31,6 +35,9 @@ namespace ErsatzTV.Core.Plex
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IMediator mediator,
IMediaSourceRepository mediaSourceRepository,
IPlexPathReplacementService plexPathReplacementService,
ILocalFileSystem localFileSystem,
ILogger<PlexMovieLibraryScanner> logger)
: base(metadataRepository, logger)
{
@@ -40,6 +47,9 @@ namespace ErsatzTV.Core.Plex
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_mediaSourceRepository = mediaSourceRepository;
_plexPathReplacementService = plexPathReplacementService;
_localFileSystem = localFileSystem;
_logger = logger;
}
@@ -48,6 +58,9 @@ namespace ErsatzTV.Core.Plex
PlexServerAuthToken token,
PlexLibrary library)
{
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
.GetPlexPathReplacements(library.MediaSourceId);
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetMovieLibraryContents(
library,
connection,
@@ -56,9 +69,27 @@ namespace ErsatzTV.Core.Plex
await entries.Match(
async movieEntries =>
{
foreach (PlexMovie incoming in movieEntries)
var validMovies = new List<PlexMovie>();
foreach (PlexMovie movie in movieEntries.OrderBy(m => m.MovieMetadata.Head().Title))
{
decimal percentCompletion = (decimal) movieEntries.IndexOf(incoming) / movieEntries.Count;
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
movie.MediaVersions.Head().MediaFiles.Head().Path,
false);
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning("Skipping plex movie that does not exist at {Path}", localPath);
}
else
{
validMovies.Add(movie);
}
}
foreach (PlexMovie incoming in validMovies)
{
decimal percentCompletion = (decimal) validMovies.IndexOf(incoming) / validMovies.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
// TODO: figure out how to rebuild playlists
@@ -92,7 +123,7 @@ namespace ErsatzTV.Core.Plex
});
}
var movieKeys = movieEntries.Map(s => s.Key).ToList();
var movieKeys = validMovies.Map(s => s.Key).ToList();
List<int> ids = await _movieRepository.RemoveMissingPlexMovies(library, movieKeys);
await _searchIndex.RemoveItems(ids);
@@ -31,7 +31,13 @@ namespace ErsatzTV.Core.Plex
{
List<PlexPathReplacement> replacements =
await _mediaSourceRepository.GetPlexPathReplacementsByLibraryId(libraryPathId);
Option<PlexPathReplacement> maybeReplacement = replacements
return GetReplacementPlexPath(replacements, path);
}
public string GetReplacementPlexPath(List<PlexPathReplacement> pathReplacements, string path, bool log = true)
{
Option<PlexPathReplacement> maybeReplacement = pathReplacements
.SingleOrDefault(
r =>
{
@@ -39,6 +45,7 @@ namespace ErsatzTV.Core.Plex
string prefix = r.PlexPath.EndsWith(separatorChar) ? r.PlexPath : r.PlexPath + separatorChar;
return path.StartsWith(prefix);
});
return maybeReplacement.Match(
replacement =>
{
@@ -52,11 +59,15 @@ namespace ErsatzTV.Core.Plex
finalPath = finalPath.Replace(@"/", @"\");
}
_logger.LogInformation(
"Replacing plex path {PlexPath} with {LocalPath} resulting in {FinalPath}",
replacement.PlexPath,
replacement.LocalPath,
finalPath);
if (log)
{
_logger.LogInformation(
"Replacing plex path {PlexPath} with {LocalPath} resulting in {FinalPath}",
replacement.PlexPath,
replacement.LocalPath,
finalPath);
}
return finalPath;
},
() => path);
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
@@ -18,9 +19,12 @@ namespace ErsatzTV.Core.Plex
{
public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionLibraryScanner
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<PlexTelevisionLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
private readonly IMetadataRepository _metadataRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
@@ -33,6 +37,9 @@ namespace ErsatzTV.Core.Plex
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IMediator mediator,
IMediaSourceRepository mediaSourceRepository,
IPlexPathReplacementService plexPathReplacementService,
ILocalFileSystem localFileSystem,
ILogger<PlexTelevisionLibraryScanner> logger)
: base(metadataRepository, logger)
{
@@ -42,6 +49,9 @@ namespace ErsatzTV.Core.Plex
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_mediaSourceRepository = mediaSourceRepository;
_plexPathReplacementService = plexPathReplacementService;
_localFileSystem = localFileSystem;
_logger = logger;
}
@@ -50,6 +60,9 @@ namespace ErsatzTV.Core.Plex
PlexServerAuthToken token,
PlexLibrary library)
{
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
.GetPlexPathReplacements(library.MediaSourceId);
Either<BaseError, List<PlexShow>> entries = await _plexServerApiClient.GetShowLibraryContents(
library,
connection,
@@ -72,7 +85,7 @@ namespace ErsatzTV.Core.Plex
await maybeShow.Match(
async result =>
{
await ScanSeasons(library, result.Item, connection, token);
await ScanSeasons(library, pathReplacements, result.Item, connection, token);
if (result.IsAdded)
{
@@ -271,13 +284,14 @@ namespace ErsatzTV.Core.Plex
}
private async Task<Either<BaseError, Unit>> ScanSeasons(
PlexLibrary plexMediaSourceLibrary,
PlexLibrary library,
List<PlexPathReplacement> pathReplacements,
PlexShow show,
PlexConnection connection,
PlexServerAuthToken token)
{
Either<BaseError, List<PlexSeason>> entries = await _plexServerApiClient.GetShowSeasons(
plexMediaSourceLibrary,
library,
show,
connection,
token);
@@ -291,11 +305,11 @@ namespace ErsatzTV.Core.Plex
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexSeason> maybeSeason = await _televisionRepository
.GetOrAddPlexSeason(plexMediaSourceLibrary, incoming)
.GetOrAddPlexSeason(library, incoming)
.BindT(existing => UpdateMetadataAndArtwork(existing, incoming));
await maybeSeason.Match(
async season => await ScanEpisodes(plexMediaSourceLibrary, season, connection, token),
async season => await ScanEpisodes(library, pathReplacements, season, connection, token),
error =>
{
_logger.LogWarning(
@@ -315,7 +329,7 @@ namespace ErsatzTV.Core.Plex
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
plexMediaSourceLibrary.Name,
library.Name,
error.Value);
return Left<BaseError, Unit>(error).AsTask();
@@ -355,13 +369,14 @@ namespace ErsatzTV.Core.Plex
}
private async Task<Either<BaseError, Unit>> ScanEpisodes(
PlexLibrary plexMediaSourceLibrary,
PlexLibrary library,
List<PlexPathReplacement> pathReplacements,
PlexSeason season,
PlexConnection connection,
PlexServerAuthToken token)
{
Either<BaseError, List<PlexEpisode>> entries = await _plexServerApiClient.GetSeasonEpisodes(
plexMediaSourceLibrary,
library,
season,
connection,
token);
@@ -369,19 +384,39 @@ namespace ErsatzTV.Core.Plex
return await entries.Match<Task<Either<BaseError, Unit>>>(
async episodeEntries =>
{
foreach (PlexEpisode incoming in episodeEntries)
var validEpisodes = new List<PlexEpisode>();
foreach (PlexEpisode episode in episodeEntries)
{
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
episode.MediaVersions.Head().MediaFiles.Head().Path,
false);
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning(
"Skipping plex episode that does not exist at {Path}",
localPath);
}
else
{
validEpisodes.Add(episode);
}
}
foreach (PlexEpisode incoming in validEpisodes)
{
incoming.SeasonId = season.Id;
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexEpisode> maybeEpisode = await _televisionRepository
.GetOrAddPlexEpisode(plexMediaSourceLibrary, incoming)
.GetOrAddPlexEpisode(library, incoming)
.BindT(existing => UpdateMetadata(existing, incoming))
.BindT(
existing => UpdateStatistics(
existing,
incoming,
plexMediaSourceLibrary,
library,
connection,
token))
.BindT(existing => UpdateArtwork(existing, incoming));
@@ -401,7 +436,7 @@ namespace ErsatzTV.Core.Plex
});
}
var episodeKeys = episodeEntries.Map(s => s.Key).ToList();
var episodeKeys = validEpisodes.Map(s => s.Key).ToList();
List<int> ids = await _televisionRepository.RemoveMissingPlexEpisodes(season.Key, episodeKeys);
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
@@ -412,7 +447,7 @@ namespace ErsatzTV.Core.Plex
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
plexMediaSourceLibrary.Name,
library.Name,
error.Value);
return Left<BaseError, Unit>(error).AsTask();
@@ -18,6 +18,7 @@ namespace ErsatzTV.Core.Scheduling
public class PlayoutBuilder : IPlayoutBuilder
{
private static readonly Random Random = new();
private readonly IArtistRepository _artistRepository;
private readonly ILogger<PlayoutBuilder> _logger;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository;
@@ -25,10 +26,12 @@ namespace ErsatzTV.Core.Scheduling
public PlayoutBuilder(
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
ILogger<PlayoutBuilder> logger)
{
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_logger = logger;
}
@@ -66,6 +69,10 @@ namespace ErsatzTV.Core.Scheduling
List<Episode> seasonItems =
await _televisionRepository.GetSeasonItems(collectionKey.MediaItemId ?? 0);
return Tuple(collectionKey, seasonItems.Cast<MediaItem>().ToList());
case ProgramScheduleItemCollectionType.Artist:
List<MusicVideo> artistItems =
await _artistRepository.GetArtistItems(collectionKey.MediaItemId ?? 0);
return Tuple(collectionKey, artistItems.Cast<MediaItem>().ToList());
default:
return Tuple(collectionKey, new List<MediaItem>());
}
@@ -555,6 +562,11 @@ namespace ErsatzTV.Core.Scheduling
CollectionType = item.CollectionType,
MediaItemId = item.MediaItemId
},
ProgramScheduleItemCollectionType.Artist => new CollectionKey
{
CollectionType = item.CollectionType,
MediaItemId = item.MediaItemId
},
_ => throw new ArgumentOutOfRangeException(nameof(item))
};
@@ -146,5 +146,28 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_dbConnection.ExecuteAsync(
"INSERT INTO Mood (Name, ArtistMetadataId) VALUES (@Name, @MetadataId)",
new { mood.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public async Task<List<MusicVideo>> GetArtistItems(int artistId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.MusicVideos
.AsNoTracking()
.Include(mv => mv.MusicVideoMetadata)
.Include(mv => mv.MediaVersions)
.Include(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Filter(mv => mv.ArtistId == artistId)
.ToListAsync();
}
public async Task<List<Artist>> GetAllArtists()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Artists
.AsNoTracking()
.Include(a => a.ArtistMetadata)
.ThenInclude(am => am.Artwork)
.ToListAsync();
}
}
}
@@ -72,6 +72,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
using TvContext context = _dbContextFactory.CreateDbContext();
return context.PlexPathReplacements
.Include(ppr => ppr.PlexMediaSource)
.Filter(r => r.PlexMediaSourceId == plexMediaSourceId)
.ToListAsync();
}
@@ -76,6 +76,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Show).ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Artist).ArtistMetadata)
.ThenInclude(am => am.Artwork)
.LoadAsync();
return programSchedule.Items;
}).Sequence();
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -235,6 +236,12 @@ namespace ErsatzTV.Infrastructure.Jellyfin
return None;
}
if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm")
{
_logger.LogInformation("STRM files are not supported; skipping {Path}", item.Path);
return None;
}
var version = new MediaVersion
{
Name = "Main",
@@ -543,6 +550,12 @@ namespace ErsatzTV.Infrastructure.Jellyfin
return None;
}
if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm")
{
_logger.LogWarning("STRM files are not supported; skipping {Path}", item.Path);
return None;
}
var version = new MediaVersion
{
Name = "Main",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Delete_JellyfinStrmFiles : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT MI.Id FROM MediaItem MI
INNER JOIN MediaVersion MV on MV.MovieId = MI.Id
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
WHERE MF.Path LIKE '%.strm')");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -10,6 +10,7 @@ using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Plex;
using ErsatzTV.Infrastructure.Plex.Models;
using LanguageExt;
using Microsoft.Extensions.Logging;
using Refit;
using static LanguageExt.Prelude;
@@ -18,9 +19,15 @@ namespace ErsatzTV.Infrastructure.Plex
public class PlexServerApiClient : IPlexServerApiClient
{
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILogger<PlexServerApiClient> _logger;
public PlexServerApiClient(IFallbackMetadataProvider fallbackMetadataProvider) =>
public PlexServerApiClient(
IFallbackMetadataProvider fallbackMetadataProvider,
ILogger<PlexServerApiClient> logger)
{
_fallbackMetadataProvider = fallbackMetadataProvider;
_logger = logger;
}
public async Task<Either<BaseError, List<PlexLibrary>>> GetLibraries(
PlexConnection connection,
@@ -358,10 +365,13 @@ namespace ErsatzTV.Infrastructure.Plex
metadata.Guids = Optional(xml.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList();
if (!string.IsNullOrWhiteSpace(xml.PlexGuid))
{
string normalized = NormalizeGuid(xml.PlexGuid);
if (!string.IsNullOrWhiteSpace(normalized) && metadata.Guids.All(g => g.Guid != normalized))
Option<string> normalized = NormalizeGuid(xml.PlexGuid);
foreach (string guid in normalized)
{
metadata.Guids.Add(new MetadataGuid { Guid = normalized });
if (metadata.Guids.All(g => g.Guid != guid))
{
metadata.Guids.Add(new MetadataGuid { Guid = guid });
}
}
}
}
@@ -525,10 +535,13 @@ namespace ErsatzTV.Infrastructure.Plex
metadata.Guids = Optional(xml.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList();
if (!string.IsNullOrWhiteSpace(xml.PlexGuid))
{
string normalized = NormalizeGuid(xml.PlexGuid);
if (!string.IsNullOrWhiteSpace(normalized) && metadata.Guids.All(g => g.Guid != normalized))
Option<string> normalized = NormalizeGuid(xml.PlexGuid);
foreach (string guid in normalized)
{
metadata.Guids.Add(new MetadataGuid { Guid = normalized });
if (metadata.Guids.All(g => g.Guid != guid))
{
metadata.Guids.Add(new MetadataGuid { Guid = guid });
}
}
}
}
@@ -598,10 +611,13 @@ namespace ErsatzTV.Infrastructure.Plex
metadata.Guids = Optional(response.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList();
if (!string.IsNullOrWhiteSpace(response.PlexGuid))
{
string normalized = NormalizeGuid(response.PlexGuid);
if (!string.IsNullOrWhiteSpace(normalized) && metadata.Guids.All(g => g.Guid != normalized))
Option<string> normalized = NormalizeGuid(response.PlexGuid);
foreach (string guid in normalized)
{
metadata.Guids.Add(new MetadataGuid { Guid = normalized });
if (metadata.Guids.All(g => g.Guid != guid))
{
metadata.Guids.Add(new MetadataGuid { Guid = guid });
}
}
}
@@ -711,10 +727,13 @@ namespace ErsatzTV.Infrastructure.Plex
metadata.Guids = Optional(xml.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList();
if (!string.IsNullOrWhiteSpace(xml.PlexGuid))
{
string normalized = NormalizeGuid(xml.PlexGuid);
if (!string.IsNullOrWhiteSpace(normalized) && metadata.Guids.All(g => g.Guid != normalized))
Option<string> normalized = NormalizeGuid(xml.PlexGuid);
foreach (string guid in normalized)
{
metadata.Guids.Add(new MetadataGuid { Guid = normalized });
if (metadata.Guids.All(g => g.Guid != guid))
{
metadata.Guids.Add(new MetadataGuid { Guid = guid });
}
}
}
}
@@ -763,7 +782,7 @@ namespace ErsatzTV.Infrastructure.Plex
return actor;
}
private string NormalizeGuid(string guid)
private Option<string> NormalizeGuid(string guid)
{
if (guid.StartsWith("plex://show") ||
guid.StartsWith("plex://season") ||
@@ -787,7 +806,9 @@ namespace ErsatzTV.Infrastructure.Plex
return $"tmdb://{strip2}";
}
throw new NotSupportedException(guid);
_logger.LogWarning("Unsupported guid format from Plex; ignoring: {Guid}", guid);
return None;
}
}
}
+1
View File
@@ -44,6 +44,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=probesize/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=setsar/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=showtitle/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=strm/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=tvdb/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=tvshow/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=uniqueid/@EntryIndexedValue">True</s:Boolean>
+23
View File
@@ -5,6 +5,8 @@
@using ErsatzTV.Application.MediaCards.Queries
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.ProgramSchedules
@using ErsatzTV.Application.ProgramSchedules.Commands
@using System.Globalization
@using Unit = LanguageExt.Unit
@inject IMediator _mediator
@@ -55,6 +57,13 @@
OnClick="@AddToCollection">
Add To Collection
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Schedule"
OnClick="@AddToSchedule">
Add To Schedule
</MudButton>
</div>
</div>
</div>
@@ -187,6 +196,20 @@
}
}
private async Task AddToSchedule()
{
var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", _artist.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = _dialog.Show<AddToScheduleDialog>("Add To Schedule", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is ProgramScheduleViewModel schedule)
{
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.Artist, null, ArtistId, null, null, null, null));
_navigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
}
}
private async Task AddMusicVideoToCollection(MusicVideoCardViewModel musicVideo)
{
var parameters = new DialogParameters { { "EntityType", "music video" }, { "EntityName", musicVideo.Title } };
+16
View File
@@ -6,6 +6,7 @@
@using ErsatzTV.Application.ProgramSchedules.Commands
@using ErsatzTV.Application.ProgramSchedules.Queries
@using ErsatzTV.Application.Television.Queries
@using ErsatzTV.Application.Artists.Queries
@inject NavigationManager _navigationManager
@inject ILogger<ScheduleItemsEditor> _logger
@inject ISnackbar _snackbar
@@ -129,6 +130,15 @@
SearchFunc="@SearchTelevisionSeasons"
ToStringFunc="@(s => s?.Name)"/>
}
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Artist)
{
<MudAutocomplete Class="mt-3"
T="NamedMediaItemViewModel"
Label="Artist"
@bind-value="_selectedItem.MediaItem"
SearchFunc="@SearchArtists"
ToStringFunc="@(s => s?.Name)"/>
}
<MudSelect Class="mt-3" Label="Playout Mode" @bind-Value="@_selectedItem.PlayoutMode" For="@(() => _selectedItem.PlayoutMode)">
@foreach (PlayoutMode playoutMode in Enum.GetValues<PlayoutMode>())
{
@@ -177,6 +187,7 @@
private List<MediaCollectionViewModel> _mediaCollections;
private List<NamedMediaItemViewModel> _televisionShows;
private List<NamedMediaItemViewModel> _televisionSeasons;
private List<NamedMediaItemViewModel> _artists;
private ProgramScheduleItemEditViewModel _selectedItem;
@@ -184,9 +195,11 @@
private async Task LoadScheduleItems()
{
// TODO: fix performance
_mediaCollections = await _mediator.Send(new GetAllCollections());
_televisionShows = await _mediator.Send(new GetAllTelevisionShows());
_televisionSeasons = await _mediator.Send(new GetAllTelevisionSeasons());
_artists = await _mediator.Send(new GetAllArtists());
string name = string.Empty;
Option<ProgramScheduleViewModel> maybeSchedule = await _mediator.Send(new GetProgramScheduleById(Id));
@@ -276,6 +289,9 @@
private Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionSeasons(string value) =>
_televisionSeasons.Filter(s => s.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask();
private Task<IEnumerable<NamedMediaItemViewModel>> SearchArtists(string value) =>
_artists.Filter(s => s.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask();
private async Task SaveChanges()
{
var items = _schedule.Items.Map(item => new ReplaceProgramScheduleItem(
@@ -54,6 +54,7 @@ namespace ErsatzTV.ViewModels
ProgramScheduleItemCollectionType.Collection => Collection?.Name,
ProgramScheduleItemCollectionType.TelevisionShow => MediaItem?.Name,
ProgramScheduleItemCollectionType.TelevisionSeason => MediaItem?.Name,
ProgramScheduleItemCollectionType.Artist => MediaItem?.Name,
_ => string.Empty
};
+1
View File
@@ -55,6 +55,7 @@ Schedule items can contain the following collection types:
- `Collection`: Select a collection that you have created manually.
- `Television Show`: Select an entire television show.
- `Television Season`: Select a specific season of a television show.
- `Artist`: Select all music videos for a specific artist.
#### Collection