Compare commits

...
Author SHA1 Message Date
Jason DoveandGitHub e42b000b7f fix plex sign in (#99) 2021-03-23 02:09:05 +00:00
Jason DoveandGitHub 489f8d92ff properly store plex timestamps on update (#98) 2021-03-22 02:20:31 +00:00
Jason DoveandGitHub 527d3c6e4b attach existing episodes to correct show and season when adding nfo metadata (#97) 2021-03-22 01:57:32 +00:00
Jason DoveandGitHub c33c037188 use folder.ext when poster.ext is not found for movies or shows (#96) 2021-03-21 21:50:31 +00:00
Jason DoveandGitHub 4c70d61d48 metadata improvements (#95)
* fix episode fallback metadata processing, fix show fallback metadata year parsing

* fix sort title for "a" and "an"

* add and index studio metadata

* minimize circular logging with search index errors

* update plex movie sort titles as needed

* properly escape search links

* force refreshing all movie/show metadata
2021-03-21 18:43:08 +00:00
Jason DoveandGitHub 00fdc272e9 remove plex items from index after sign out (#94) 2021-03-21 15:23:53 +00:00
Jason DoveandGitHub f04c18c810 index release date for searching (#93) 2021-03-21 01:49:10 +00:00
Jason DoveandGitHub eca58dbe7f plex fixes (#92)
* fix updating plex path replacements

* fix adding/removing plex libraries

* fix adding/removing plex servers

* fix initial plex library sync after sign in

* code cleanup
2021-03-21 01:35:18 +00:00
Jason DoveandGitHub cf9479d2a9 log search indexing errors and continue indexing (#91) 2021-03-20 21:33:37 +00:00
Jason DoveandGitHub b6331331b0 use default ffmpeg profile with new channels (#90) 2021-03-20 20:56:15 +00:00
Jason DoveandGitHub ed365cfa43 keep search query in search field (#89)
* upgrade dependencies

* keep search query in search field
2021-03-20 20:45:02 +00:00
Jason Dove b3a1e71570 only search title by default, allow leading wildcards 2021-03-20 15:32:30 -05:00
47 changed files with 4349 additions and 207 deletions
@@ -1,8 +0,0 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCards.Queries
{
public record GetSearchCards(string Query) : IRequest<Either<BaseError, SearchCardResultsViewModel>>;
}
@@ -1,43 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCards.Queries
{
public class GetSearchCardsHandler : IRequestHandler<GetSearchCards, Either<BaseError, SearchCardResultsViewModel>>
{
private readonly ISearchRepository _searchRepository;
public GetSearchCardsHandler(ISearchRepository searchRepository) => _searchRepository = searchRepository;
public Task<Either<BaseError, SearchCardResultsViewModel>> Handle(
GetSearchCards request,
CancellationToken cancellationToken) =>
request.Query.Split(":").Head() switch
{
"genre" => GenreSearch(request.Query.Replace("genre:", string.Empty)),
"tag" => TagSearch(request.Query.Replace("tag:", string.Empty)),
_ => TitleSearch(request.Query)
};
private Task<Either<BaseError, SearchCardResultsViewModel>> TitleSearch(string query) =>
Try(_searchRepository.SearchMediaItemsByTitle(query)).Sequence()
.Map(ProjectToSearchResults)
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
private Task<Either<BaseError, SearchCardResultsViewModel>> GenreSearch(string query) =>
Try(_searchRepository.SearchMediaItemsByGenre(query)).Sequence()
.Map(ProjectToSearchResults)
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
private Task<Either<BaseError, SearchCardResultsViewModel>> TagSearch(string query) =>
Try(_searchRepository.SearchMediaItemsByTag(query)).Sequence()
.Map(ProjectToSearchResults)
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
}
}
+2 -1
View File
@@ -16,7 +16,8 @@ namespace ErsatzTV.Application.Movies
Artwork(metadata, ArtworkKind.Poster),
Artwork(metadata, ArtworkKind.FanArt),
metadata.Genres.Map(g => g.Name).ToList(),
metadata.Tags.Map(t => t.Name).ToList());
metadata.Tags.Map(t => t.Name).ToList(),
metadata.Studios.Map(s => s.Name).ToList());
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
@@ -9,5 +9,6 @@ namespace ErsatzTV.Application.Movies
string Poster,
string FanArt,
List<string> Genres,
List<string> Tags);
List<string> Tags,
List<string> Studios);
}
@@ -1,9 +1,11 @@
using System.Threading;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
namespace ErsatzTV.Application.Plex.Commands
@@ -13,20 +15,24 @@ namespace ErsatzTV.Application.Plex.Commands
private readonly IEntityLocker _entityLocker;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexSecretStore _plexSecretStore;
private readonly ISearchIndex _searchIndex;
public SignOutOfPlexHandler(
IMediaSourceRepository mediaSourceRepository,
IPlexSecretStore plexSecretStore,
IEntityLocker entityLocker)
IEntityLocker entityLocker,
ISearchIndex searchIndex)
{
_mediaSourceRepository = mediaSourceRepository;
_plexSecretStore = plexSecretStore;
_entityLocker = entityLocker;
_searchIndex = searchIndex;
}
public async Task<Either<BaseError, Unit>> Handle(SignOutOfPlex request, CancellationToken cancellationToken)
{
await _mediaSourceRepository.DeleteAllPlex();
List<int> ids = await _mediaSourceRepository.DeleteAllPlex();
await _searchIndex.RemoveItems(ids);
await _plexSecretStore.DeleteAll();
_entityLocker.UnlockPlex();
@@ -78,10 +78,10 @@ namespace ErsatzTV.Application.Plex.Commands
var existing = connectionParameters.PlexMediaSource.Libraries.OfType<PlexLibrary>().ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList();
var toRemove = existing.Filter(library => libraries.All(l => l.Key != library.Key)).ToList();
connectionParameters.PlexMediaSource.Libraries.AddRange(toAdd);
toRemove.ForEach(c => connectionParameters.PlexMediaSource.Libraries.Remove(c));
return _mediaSourceRepository.Update(connectionParameters.PlexMediaSource);
return _mediaSourceRepository.UpdateLibraries(
connectionParameters.PlexMediaSource.Id,
toAdd,
toRemove);
},
error =>
{
@@ -66,23 +66,20 @@ namespace ErsatzTV.Application.Plex.Commands
{
existing.ProductVersion = server.ProductVersion;
existing.ServerName = server.ServerName;
MergeConnections(existing.Connections, server.Connections);
if (existing.Connections.Any() && existing.Connections.All(c => !c.IsActive))
{
existing.Connections.Head().IsActive = true;
}
return _mediaSourceRepository.Update(existing);
var toAdd = server.Connections
.Filter(connection => existing.Connections.All(c => c.Uri != connection.Uri)).ToList();
var toRemove = existing.Connections
.Filter(connection => server.Connections.All(c => c.Uri != connection.Uri)).ToList();
return _mediaSourceRepository.Update(existing, toAdd, toRemove);
},
async () =>
{
await _mediaSourceRepository.Add(server);
if (server.Connections.Any())
{
server.Connections.Head().IsActive = true;
}
await _mediaSourceRepository.Update(server);
await _mediaSourceRepository.Add(server);
});
}
@@ -6,7 +6,6 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Plex.Commands
{
@@ -35,20 +34,7 @@ namespace ErsatzTV.Application.Plex.Commands
var toRemove = plexMediaSource.PathReplacements.Filter(r => incoming.All(pr => pr.Id != r.Id)).ToList();
var toUpdate = incoming.Except(toAdd).ToList();
plexMediaSource.PathReplacements.AddRange(toAdd);
toRemove.ForEach(pr => plexMediaSource.PathReplacements.Remove(pr));
foreach (PlexPathReplacement pathReplacement in toUpdate)
{
Optional(plexMediaSource.PathReplacements.SingleOrDefault(pr => pr.Id == pathReplacement.Id))
.IfSome(
pr =>
{
pr.PlexPath = pathReplacement.PlexPath;
pr.LocalPath = pathReplacement.LocalPath;
});
}
return _mediaSourceRepository.Update(plexMediaSource).ToUnit();
return _mediaSourceRepository.UpdatePathReplacements(plexMediaSource.Id, toAdd, toUpdate, toRemove);
}
private static PlexPathReplacement Project(PlexPathReplacementItem vm) =>
+3 -1
View File
@@ -16,7 +16,9 @@ namespace ErsatzTV.Application.Television
show.ShowMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(GetFanArt).IfNone(string.Empty),
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.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>()));
internal static TelevisionSeasonViewModel ProjectToViewModel(Season season) =>
new(
@@ -10,5 +10,6 @@ namespace ErsatzTV.Application.Television
string Poster,
string FanArt,
List<string> Genres,
List<string> Tags);
List<string> Tags,
List<string> Studios);
}
@@ -76,6 +76,9 @@ namespace ErsatzTV.Core.Tests.Fakes
throw new NotSupportedException();
public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
public Task<bool> AddTag(ShowMetadata metadata, Tag tag) => throw new NotSupportedException();
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) => throw new NotSupportedException();
public Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
throw new NotSupportedException();
@@ -238,6 +238,50 @@ namespace ErsatzTV.Core.Tests.Metadata
Times.Once);
}
[Test]
public async Task NewMovie_Statistics_And_FallbackMetadata_And_FolderPoster(
[ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))]
string videoExtension,
[ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.ImageFileExtensions))]
string imageExtension)
{
string moviePath = Path.Combine(
FakeRoot,
Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}"));
string posterPath = Path.Combine(
Path.GetDirectoryName(moviePath) ?? string.Empty,
$"folder.{imageExtension}");
MovieFolderScanner service = GetService(
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now },
new FakeFileEntry(posterPath) { LastWriteTime = DateTime.Now }
);
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify(
x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
_localMetadataProvider.Verify(
x => x.RefreshFallbackMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
_imageCache.Verify(
x => x.CopyArtworkToCache(posterPath, ArtworkKind.Poster),
Times.Once);
}
[Test]
public async Task NewMovie_Statistics_And_FallbackMetadata_And_MovieNamePoster(
[ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))]
@@ -17,5 +17,6 @@ namespace ErsatzTV.Core.Domain
public List<Artwork> Artwork { get; set; }
public List<Genre> Genres { get; set; }
public List<Tag> Tags { get; set; }
public List<Studio> Studios { get; set; }
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class Studio
{
public int Id { get; set; }
public string Name { get; set; }
}
}
@@ -20,10 +20,22 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<PlexPathReplacement>> GetPlexPathReplacementsByLibraryId(int plexLibraryPathId);
Task<int> CountMediaItems(int id);
Task Update(LocalMediaSource localMediaSource);
Task Update(PlexMediaSource plexMediaSource);
Task Update(PlexMediaSource plexMediaSource, List<PlexConnection> toAdd, List<PlexConnection> toDelete);
Task<Unit> UpdateLibraries(
int plexMediaSourceId,
List<PlexLibrary> toAdd,
List<PlexLibrary> toDelete);
Task<Unit> UpdatePathReplacements(
int plexMediaSourceId,
List<PlexPathReplacement> toAdd,
List<PlexPathReplacement> toUpdate,
List<PlexPathReplacement> toDelete);
Task Update(PlexLibrary plexMediaSourceLibrary);
Task Delete(int mediaSourceId);
Task<Unit> DeleteAllPlex();
Task<List<int>> DeleteAllPlex();
Task<List<int>> DisablePlexLibrarySync(List<int> libraryIds);
Task EnablePlexLibrarySync(IEnumerable<int> libraryIds);
}
@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
@@ -7,6 +8,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
public interface IMetadataRepository
{
Task<bool> RemoveGenre(Genre genre);
Task<bool> RemoveTag(Tag tag);
Task<bool> RemoveStudio(Studio studio);
Task<bool> Update(Domain.Metadata metadata);
Task<bool> Add(Domain.Metadata metadata);
Task<bool> UpdateLocalStatistics(MediaVersion mediaVersion);
@@ -14,5 +17,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Unit> UpdateArtworkPath(Artwork artwork);
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);
Task<Unit> MarkAsUpdated(ShowMetadata metadata, DateTime dateUpdated);
Task<Unit> MarkAsUpdated(SeasonMetadata metadata, DateTime dateUpdated);
Task<Unit> MarkAsUpdated(MovieMetadata metadata, DateTime dateUpdated);
}
}
@@ -18,6 +18,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath);
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
Task<bool> AddGenre(MovieMetadata metadata, Genre genre);
Task<bool> AddTag(MovieMetadata metadata, Tag tag);
Task<bool> AddStudio(MovieMetadata metadata, Studio studio);
Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys);
Task<bool> UpdateSortTitle(MovieMetadata movieMetadata);
}
}
@@ -40,6 +40,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item);
Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item);
Task<bool> AddGenre(ShowMetadata metadata, Genre genre);
Task<bool> AddTag(ShowMetadata metadata, Tag tag);
Task<bool> AddStudio(ShowMetadata metadata, Studio studio);
Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys);
Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys);
Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys);
@@ -23,7 +23,7 @@ namespace ErsatzTV.Core.Metadata
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileName(path);
var metadata = new EpisodeMetadata
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? path };
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? path, DateAdded = DateTime.UtcNow };
return fileName != null ? GetEpisodeMetadata(fileName, metadata) : Tuple(metadata, 0);
}
@@ -48,6 +48,16 @@ namespace ErsatzTV.Core.Metadata
return title.Substring(4);
}
if (title.StartsWith("a ", StringComparison.OrdinalIgnoreCase))
{
return title.Substring(2);
}
if (title.StartsWith("an ", StringComparison.OrdinalIgnoreCase))
{
return title.Substring(3);
}
if (title.StartsWith("Æ"))
{
return title.Replace("Æ", "E");
@@ -65,6 +75,7 @@ namespace ErsatzTV.Core.Metadata
if (match.Success)
{
metadata.Title = match.Groups[1].Value;
metadata.DateUpdated = DateTime.UtcNow;
return Tuple(metadata, int.Parse(match.Groups[3].Value));
}
}
@@ -89,6 +100,8 @@ namespace ErsatzTV.Core.Metadata
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
metadata.Genres = new List<Genre>();
metadata.Tags = new List<Tag>();
metadata.Studios = new List<Studio>();
metadata.DateUpdated = DateTime.UtcNow;
}
}
catch (Exception)
@@ -110,6 +123,7 @@ namespace ErsatzTV.Core.Metadata
metadata.Title = match.Groups[1].Value;
metadata.Year = int.Parse(match.Groups[2].Value);
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
metadata.DateUpdated = DateTime.UtcNow;
}
}
catch (Exception)
+85 -17
View File
@@ -23,16 +23,19 @@ namespace ErsatzTV.Core.Metadata
private readonly ILogger<LocalMetadataProvider> _logger;
private readonly IMetadataRepository _metadataRepository;
private readonly IMovieRepository _movieRepository;
private readonly ITelevisionRepository _televisionRepository;
public LocalMetadataProvider(
IMetadataRepository metadataRepository,
IMovieRepository movieRepository,
ITelevisionRepository televisionRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem,
ILogger<LocalMetadataProvider> logger)
{
_metadataRepository = metadataRepository;
_movieRepository = movieRepository;
_televisionRepository = televisionRepository;
_fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem;
@@ -138,7 +141,7 @@ namespace ErsatzTV.Core.Metadata
private Task<bool> ApplyMetadataUpdate(Movie movie, MovieMetadata metadata) =>
Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match(
existing =>
async existing =>
{
existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot;
@@ -158,29 +161,49 @@ namespace ErsatzTV.Core.Metadata
.ToList())
{
existing.Genres.Remove(genre);
await _metadataRepository.RemoveGenre(genre);
}
foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existing.Genres.Add(genre);
await _movieRepository.AddGenre(existing, genre);
}
foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
existing.Tags.Remove(tag);
await _metadataRepository.RemoveTag(tag);
}
foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
existing.Tags.Add(tag);
await _movieRepository.AddTag(existing, tag);
}
return _metadataRepository.Update(existing);
foreach (Studio studio in existing.Studios
.Filter(s => metadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Studios.Remove(studio);
await _metadataRepository.RemoveStudio(studio);
}
foreach (Studio studio in metadata.Studios
.Filter(s => existing.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Studios.Add(studio);
await _movieRepository.AddStudio(existing, studio);
}
return await _metadataRepository.Update(existing);
},
() =>
async () =>
{
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
@@ -188,12 +211,12 @@ namespace ErsatzTV.Core.Metadata
metadata.MovieId = movie.Id;
movie.MovieMetadata = new List<MovieMetadata> { metadata };
return _metadataRepository.Add(metadata);
return await _metadataRepository.Add(metadata);
});
private Task<bool> ApplyMetadataUpdate(Show show, ShowMetadata metadata) =>
Optional(show.ShowMetadata).Flatten().HeadOrNone().Match(
existing =>
async existing =>
{
existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot;
@@ -213,29 +236,49 @@ namespace ErsatzTV.Core.Metadata
.ToList())
{
existing.Genres.Remove(genre);
await _metadataRepository.RemoveGenre(genre);
}
foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existing.Genres.Add(genre);
await _televisionRepository.AddGenre(existing, genre);
}
foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
existing.Tags.Remove(tag);
await _metadataRepository.RemoveTag(tag);
}
foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
existing.Tags.Add(tag);
await _televisionRepository.AddTag(existing, tag);
}
return _metadataRepository.Update(existing);
foreach (Studio studio in existing.Studios
.Filter(s => metadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Studios.Remove(studio);
await _metadataRepository.RemoveStudio(studio);
}
foreach (Studio studio in metadata.Studios
.Filter(s => existing.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Studios.Add(studio);
await _televisionRepository.AddStudio(existing, studio);
}
return await _metadataRepository.Update(existing);
},
() =>
async () =>
{
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
@@ -243,7 +286,7 @@ namespace ErsatzTV.Core.Metadata
metadata.ShowId = show.Id;
show.ShowMetadata = new List<ShowMetadata> { metadata };
return _metadataRepository.Add(metadata);
return await _metadataRepository.Add(metadata);
});
private async Task<Option<MovieMetadata>> LoadMetadata(Movie mediaItem, string nfoFileName)
@@ -294,10 +337,11 @@ namespace ErsatzTV.Core.Metadata
Plot = nfo.Plot,
Outline = nfo.Outline,
Tagline = nfo.Tagline,
Year = nfo.Year,
ReleaseDate = GetAired(nfo.Premiered) ?? new DateTime(nfo.Year, 1, 1),
Year = GetYear(nfo.Year, nfo.Premiered),
ReleaseDate = GetAired(nfo.Year, nfo.Premiered),
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList()
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList()
},
None);
}
@@ -322,7 +366,7 @@ namespace ErsatzTV.Core.Metadata
MetadataKind = MetadataKind.Sidecar,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
Title = nfo.Title,
ReleaseDate = GetAired(nfo.Aired),
ReleaseDate = GetAired(0, nfo.Aired),
Plot = nfo.Plot
};
return Tuple(metadata, nfo.Episode);
@@ -354,7 +398,8 @@ namespace ErsatzTV.Core.Metadata
Outline = nfo.Outline,
Tagline = nfo.Tagline,
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList()
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList()
},
None);
}
@@ -365,21 +410,38 @@ namespace ErsatzTV.Core.Metadata
}
}
private static DateTime? GetAired(string aired)
private static int? GetYear(int year, string premiered)
{
if (string.IsNullOrWhiteSpace(aired))
if (year > 1000)
{
return year;
}
if (string.IsNullOrWhiteSpace(premiered))
{
return null;
}
if (DateTime.TryParse(aired, out DateTime parsed))
if (DateTime.TryParse(premiered, out DateTime parsed))
{
return parsed;
return parsed.Year;
}
return null;
}
private static DateTime? GetAired(int year, string aired)
{
DateTime? fallback = year > 1000 ? new DateTime(year, 1, 1) : null;
if (string.IsNullOrWhiteSpace(aired))
{
return fallback;
}
return DateTime.TryParse(aired, out DateTime parsed) ? parsed : fallback;
}
[XmlRoot("movie")]
public class MovieNfo
{
@@ -409,6 +471,9 @@ namespace ErsatzTV.Core.Metadata
[XmlElement("tag")]
public List<string> Tags { get; set; }
[XmlElement("studio")]
public List<string> Studios { get; set; }
}
[XmlRoot("tvshow")]
@@ -437,6 +502,9 @@ namespace ErsatzTV.Core.Metadata
[XmlElement("tag")]
public List<string> Tags { get; set; }
[XmlElement("studio")]
public List<string> Studios { get; set; }
}
[XmlRoot("episodedetails")]
+10 -2
View File
@@ -159,7 +159,7 @@ namespace ErsatzTV.Core.Metadata
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
return BaseError.New(ex.ToString());
}
}
@@ -181,7 +181,7 @@ namespace ErsatzTV.Core.Metadata
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
return BaseError.New(ex.ToString());
}
}
@@ -210,6 +210,14 @@ namespace ErsatzTV.Core.Metadata
ext => new[] { $"{segment}.{ext}", Path.GetFileNameWithoutExtension(path) + $"-{segment}.{ext}" })
.Map(f => Path.Combine(folder, f));
Option<string> result = possibleMoviePosters.Filter(p => _localFileSystem.FileExists(p)).HeadOrNone();
if (result.IsNone && artworkKind == ArtworkKind.Poster)
{
IEnumerable<string> possibleFolderPosters = ImageFileExtensions.Collect(
ext => new[] { $"folder.{ext}" })
.Map(f => Path.Combine(folder, f));
result = possibleFolderPosters.Filter(p => _localFileSystem.FileExists(p)).HeadOrNone();
}
return result;
}
}
@@ -200,7 +200,7 @@ namespace ErsatzTV.Core.Metadata
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
return BaseError.New(ex.ToString());
}
}
@@ -225,7 +225,11 @@ namespace ErsatzTV.Core.Metadata
},
async () =>
{
if (!Optional(episode.EpisodeMetadata).Flatten().Any())
bool shouldUpdate = Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
m => m.DateUpdated == DateTime.MinValue,
true);
if (shouldUpdate)
{
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
@@ -237,7 +241,7 @@ namespace ErsatzTV.Core.Metadata
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
return BaseError.New(ex.ToString());
}
}
@@ -260,7 +264,7 @@ namespace ErsatzTV.Core.Metadata
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
return BaseError.New(ex.ToString());
}
}
@@ -279,7 +283,7 @@ namespace ErsatzTV.Core.Metadata
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
return BaseError.New(ex.ToString());
}
}
@@ -298,7 +302,7 @@ namespace ErsatzTV.Core.Metadata
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
return BaseError.New(ex.ToString());
}
}
@@ -315,15 +319,16 @@ namespace ErsatzTV.Core.Metadata
private Option<string> LocateArtworkForShow(string showFolder, ArtworkKind artworkKind)
{
string segment = artworkKind switch
string[] segments = artworkKind switch
{
ArtworkKind.Poster => "poster",
ArtworkKind.FanArt => "fanart",
ArtworkKind.Poster => new[] { "poster", "folder" },
ArtworkKind.FanArt => new[] { "fanart" },
_ => throw new ArgumentOutOfRangeException(nameof(artworkKind))
};
return ImageFileExtensions
.Map(ext => $"{segment}.{ext}")
.Map(ext => segments.Map(segment => $"{segment}.{ext}"))
.Flatten()
.Map(f => Path.Combine(showFolder, f))
.Filter(s => _localFileSystem.FileExists(s))
.HeadOrNone();
@@ -158,6 +158,39 @@ namespace ErsatzTV.Core.Plex
}
}
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))
{
result.IsUpdated = true;
}
}
foreach (Studio studio in incomingMetadata.Studios
.Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Add(studio);
if (await _movieRepository.AddStudio(existingMetadata, studio))
{
result.IsUpdated = true;
}
}
if (incomingMetadata.SortTitle != existingMetadata.SortTitle)
{
existingMetadata.SortTitle = incomingMetadata.SortTitle;
if (await _movieRepository.UpdateSortTitle(existingMetadata))
{
result.IsUpdated = true;
}
}
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
// TODO: update other metadata?
}
@@ -176,6 +209,7 @@ namespace ErsatzTV.Core.Plex
{
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
}
return result;
@@ -131,6 +131,30 @@ namespace ErsatzTV.Core.Plex
result.IsUpdated = true;
}
}
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))
{
result.IsUpdated = true;
}
}
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))
{
result.IsUpdated = true;
}
}
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
}
return result;
@@ -148,6 +172,7 @@ namespace ErsatzTV.Core.Plex
{
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
}
return result;
@@ -213,6 +238,7 @@ namespace ErsatzTV.Core.Plex
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
}
return existing;
@@ -21,6 +21,10 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.HasMany(mm => mm.Tags)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Studios)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -14,11 +14,15 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Genres)
builder.HasMany(sm => sm.Genres)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Tags)
builder.HasMany(sm => sm.Tags)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(sm => sm.Studios)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class StudioConfiguration : IEntityTypeConfiguration<Studio>
{
public void Configure(EntityTypeBuilder<Studio> builder) => builder.ToTable("Studio");
}
}
@@ -152,11 +152,98 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await context.SaveChangesAsync();
}
public async Task Update(PlexMediaSource plexMediaSource)
public async Task Update(
PlexMediaSource plexMediaSource,
List<PlexConnection> toAdd,
List<PlexConnection> toDelete)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
context.PlexMediaSources.Update(plexMediaSource);
await context.SaveChangesAsync();
await _dbConnection.ExecuteAsync(
@"UPDATE PlexMediaSource SET ProductVersion = @ProductVersion, ServerName = @ServerName WHERE Id = @Id",
new { plexMediaSource.ProductVersion, plexMediaSource.ServerName, plexMediaSource.Id });
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
foreach (PlexConnection add in toAdd)
{
add.PlexMediaSourceId = plexMediaSource.Id;
dbContext.Entry(add).State = EntityState.Added;
}
foreach (PlexConnection delete in toDelete)
{
dbContext.Entry(delete).State = EntityState.Deleted;
}
await dbContext.SaveChangesAsync();
PlexMediaSource pms = await dbContext.PlexMediaSources.FindAsync(plexMediaSource.Id);
await dbContext.Entry(pms).Collection(x => x.Connections).LoadAsync();
if (plexMediaSource.Connections.Any() && plexMediaSource.Connections.All(c => !c.IsActive))
{
plexMediaSource.Connections.Head().IsActive = true;
await dbContext.SaveChangesAsync();
}
}
public async Task<Unit> UpdateLibraries(
int plexMediaSourceId,
List<PlexLibrary> toAdd,
List<PlexLibrary> toDelete)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
foreach (PlexLibrary add in toAdd)
{
add.MediaSourceId = plexMediaSourceId;
dbContext.Entry(add).State = EntityState.Added;
foreach (LibraryPath path in add.Paths)
{
dbContext.Entry(path).State = EntityState.Added;
}
}
foreach (PlexLibrary delete in toDelete)
{
dbContext.Entry(delete).State = EntityState.Deleted;
}
await dbContext.SaveChangesAsync();
return Unit.Default;
}
public async Task<Unit> UpdatePathReplacements(
int plexMediaSourceId,
List<PlexPathReplacement> toAdd,
List<PlexPathReplacement> toUpdate,
List<PlexPathReplacement> toDelete)
{
foreach (PlexPathReplacement add in toAdd)
{
await _dbConnection.ExecuteAsync(
@"INSERT INTO PlexPathReplacement
(PlexPath, LocalPath, PlexMediaSourceId)
VALUES (@PlexPath, @LocalPath, @PlexMediaSourceId)",
new { add.PlexPath, add.LocalPath, PlexMediaSourceId = plexMediaSourceId });
}
foreach (PlexPathReplacement update in toUpdate)
{
await _dbConnection.ExecuteAsync(
@"UPDATE PlexPathReplacement
SET PlexPath = @PlexPath, LocalPath = @LocalPath
WHERE Id = @Id",
new { update.PlexPath, update.LocalPath, update.Id });
}
foreach (PlexPathReplacement delete in toDelete)
{
await _dbConnection.ExecuteAsync(
@"DELETE FROM PlexPathReplacement WHERE Id = @Id",
new { delete.Id });
}
return Unit.Default;
}
public async Task Update(PlexLibrary plexMediaSourceLibrary)
@@ -174,7 +261,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await context.SaveChangesAsync();
}
public async Task<Unit> DeleteAllPlex()
public async Task<List<int>> DeleteAllPlex()
{
await using TvContext context = _dbContextFactory.CreateDbContext();
@@ -184,8 +271,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
List<PlexLibrary> allPlexLibraries = await context.PlexLibraries.ToListAsync();
context.PlexLibraries.RemoveRange(allPlexLibraries);
List<int> movieIds = await context.PlexMovies.Map(pm => pm.Id).ToListAsync();
List<int> showIds = await context.PlexShows.Map(ps => ps.Id).ToListAsync();
await context.SaveChangesAsync();
return Unit.Default;
return movieIds.Append(showIds).ToList();
}
public async Task<List<int>> DisablePlexLibrarySync(List<int> libraryIds)
@@ -1,4 +1,5 @@
using System.Data;
using System;
using System.Data;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core.Domain;
@@ -109,8 +110,31 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
OR ShowMetadataId = @Id OR SeasonMetadataId = @Id OR EpisodeMetadataId = @Id)",
new { ArtworkKind = artworkKind, metadata.Id }).ToUnit();
public Task<Unit> MarkAsUpdated(ShowMetadata metadata, DateTime dateUpdated) =>
_dbConnection.ExecuteAsync(
@"UPDATE ShowMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",
new { DateUpdated = dateUpdated, metadata.Id }).ToUnit();
public Task<Unit> MarkAsUpdated(SeasonMetadata metadata, DateTime dateUpdated) =>
_dbConnection.ExecuteAsync(
@"UPDATE SeasonMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",
new { DateUpdated = dateUpdated, metadata.Id }).ToUnit();
public Task<Unit> MarkAsUpdated(MovieMetadata metadata, DateTime dateUpdated) =>
_dbConnection.ExecuteAsync(
@"UPDATE MovieMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",
new { DateUpdated = dateUpdated, metadata.Id }).ToUnit();
public Task<bool> RemoveGenre(Genre genre) =>
_dbConnection.ExecuteAsync("DELETE FROM Genre WHERE Id = @GenreId", new { GenreId = genre.Id })
.Map(result => result > 0);
public Task<bool> RemoveTag(Tag tag) =>
_dbConnection.ExecuteAsync("DELETE FROM Tag WHERE Id = @TagId", new { TagId = tag.Id })
.Map(result => result > 0);
public Task<bool> RemoveStudio(Studio studio) =>
_dbConnection.ExecuteAsync("DELETE FROM Studio WHERE Id = @StudioId", new { StudioId = studio.Id })
.Map(result => result > 0);
}
}
@@ -41,6 +41,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(m => m.Genres)
.Include(m => m.MovieMetadata)
.ThenInclude(m => m.Tags)
.Include(m => m.MovieMetadata)
.ThenInclude(m => m.Studios)
.OrderBy(m => m.Id)
.SingleOrDefaultAsync(m => m.Id == movieId)
.Map(Optional);
@@ -56,6 +58,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Genres)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Tags)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Studios)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(i => i.MediaVersions)
@@ -82,6 +86,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Tags)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Studios)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
@@ -165,6 +171,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
"INSERT INTO Genre (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> AddTag(MovieMetadata metadata, Tag tag) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Tag (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> AddStudio(MovieMetadata metadata, Studio studio) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Studio (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
new { studio.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public async Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@@ -185,6 +201,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return ids;
}
public Task<bool> UpdateSortTitle(MovieMetadata movieMetadata) =>
_dbConnection.ExecuteAsync(
@"UPDATE MovieMetadata SET SortTitle = @SortTitle WHERE Id = @Id",
new { movieMetadata.SortTitle, movieMetadata.Id }).Map(result => result > 0);
private static async Task<Either<BaseError, MediaItemScanResult<Movie>>> AddMovie(
TvContext dbContext,
int libraryPathId,
@@ -235,7 +256,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
return BaseError.New(ex.ToString());
}
}
}
@@ -37,10 +37,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as Movie).MovieMetadata)
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as Movie).MovieMetadata)
.ThenInclude(mm => mm.Studios)
.Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Studios)
.OrderBy(mi => mi.Id)
.SingleOrDefaultAsync(mi => mi.Id == id)
.Map(Optional);
@@ -53,6 +53,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(sm => sm.Genres)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Tags)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Studios)
.OrderBy(s => s.Id)
.SingleOrDefaultAsync()
.Map(Optional);
@@ -283,9 +285,24 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
return await maybeExisting.Match(
episode => Right<BaseError, Episode>(episode).AsTask(),
() => AddEpisode(dbContext, season, libraryPath.Id, path));
return await maybeExisting.Match<Task<Either<BaseError, Episode>>>(
async episode =>
{
// move the file to the new season if needed
// this can happen when adding NFO metadata to existing content
if (episode.SeasonId != season.Id)
{
episode.SeasonId = season.Id;
episode.Season = season;
await _dbConnection.ExecuteAsync(
@"UPDATE Episode SET SeasonId = @SeasonId WHERE Id = @EpisodeId",
new { SeasonId = season.Id, EpisodeId = episode.Id });
}
return episode;
},
async () => await AddEpisode(dbContext, season, libraryPath.Id, path));
}
public Task<IEnumerable<string>> FindEpisodePaths(LibraryPath libraryPath) =>
@@ -354,11 +371,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
Option<PlexShow> maybeExisting = await dbContext.PlexShows
.AsNoTracking()
.Include(i => i.ShowMetadata)
.ThenInclude(mm => mm.Genres)
.ThenInclude(sm => sm.Genres)
.Include(i => i.ShowMetadata)
.ThenInclude(mm => mm.Tags)
.ThenInclude(sm => sm.Tags)
.Include(i => i.ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.ThenInclude(sm => sm.Studios)
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.OrderBy(i => i.Key)
@@ -464,9 +483,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, SeasonMetadataId) VALUES (@Name, @MetadataId)",
"INSERT INTO Genre (Name, ShowMetadataId) VALUES (@Name, @MetadataId)",
new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> AddTag(ShowMetadata metadata, Tag tag) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Tag (Name, ShowMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Studio (Name, ShowMetadataId) VALUES (@Name, @MetadataId)",
new { studio.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public async Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@@ -504,6 +533,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
SeasonMetadata = new List<SeasonMetadata>
{
new()
{
DateAdded = DateTime.UtcNow
}
}
};
await dbContext.Seasons.AddAsync(season);
@@ -524,6 +556,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
try
{
if (dbContext.MediaFiles.Any(mf => mf.Path == path))
{
return BaseError.New("Multi-episode files are not yet supported");
}
var episode = new Episode
{
LibraryPathId = libraryPathId,
@@ -532,6 +569,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
new()
{
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.MinValue,
MetadataKind = MetadataKind.Fallback
}
@@ -605,6 +643,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
try
{
if (dbContext.MediaFiles.Any(mf => mf.Path == item.MediaVersions.Head().MediaFiles.Head().Path))
{
return BaseError.New("Multi-episode files are not yet supported");
}
item.LibraryPathId = library.Paths.Head().Id;
await dbContext.PlexEpisodes.AddAsync(item);
@@ -16,7 +16,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.4" />
<PackageReference Include="Refit" Version="6.0.24" />
<PackageReference Include="Refit" Version="6.0.38" />
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.3" />
</ItemGroup>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,75 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_Studio : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
"Studio",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>("TEXT", nullable: true),
EpisodeMetadataId = table.Column<int>("INTEGER", nullable: true),
MovieMetadataId = table.Column<int>("INTEGER", nullable: true),
SeasonMetadataId = table.Column<int>("INTEGER", nullable: true),
ShowMetadataId = table.Column<int>("INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Studio", x => x.Id);
table.ForeignKey(
"FK_Studio_EpisodeMetadata_EpisodeMetadataId",
x => x.EpisodeMetadataId,
"EpisodeMetadata",
"Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
"FK_Studio_MovieMetadata_MovieMetadataId",
x => x.MovieMetadataId,
"MovieMetadata",
"Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
"FK_Studio_SeasonMetadata_SeasonMetadataId",
x => x.SeasonMetadataId,
"SeasonMetadata",
"Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
"FK_Studio_ShowMetadata_ShowMetadataId",
x => x.ShowMetadataId,
"ShowMetadata",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
"IX_Studio_EpisodeMetadataId",
"Studio",
"EpisodeMetadataId");
migrationBuilder.CreateIndex(
"IX_Studio_MovieMetadataId",
"Studio",
"MovieMetadataId");
migrationBuilder.CreateIndex(
"IX_Studio_SeasonMetadataId",
"Studio",
"SeasonMetadataId");
migrationBuilder.CreateIndex(
"IX_Studio_ShowMetadataId",
"Studio",
"ShowMetadataId");
}
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropTable(
"Studio");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Reset_MetadataDateUpdated_Studio : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"UPDATE MovieMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql(@"UPDATE ShowMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql(@"UPDATE Library SET LastScan = '0001-01-01 00:00:00'");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -845,6 +845,42 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("ShowMetadata");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Studio",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("ShowMetadataId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
b.ToTable("Studio");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Tag",
b =>
@@ -1499,6 +1535,29 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Show");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Studio",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Studios")
.HasForeignKey("EpisodeMetadataId");
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Studios")
.HasForeignKey("MovieMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Studios")
.HasForeignKey("SeasonMetadataId");
b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null)
.WithMany("Studios")
.HasForeignKey("ShowMetadataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Tag",
b =>
@@ -1744,6 +1803,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Genres");
b.Navigation("Studios");
b.Navigation("Tags");
});
@@ -1765,6 +1826,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Genres");
b.Navigation("Studios");
b.Navigation("Tags");
});
@@ -1794,6 +1857,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Genres");
b.Navigation("Studios");
b.Navigation("Tags");
});
@@ -1805,6 +1870,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Genres");
b.Navigation("Studios");
b.Navigation("Tags");
});
@@ -15,6 +15,7 @@ namespace ErsatzTV.Infrastructure.Plex.Models
public int AddedAt { get; set; }
public int UpdatedAt { get; set; }
public int Index { get; set; }
public string Studio { get; set; }
public List<PlexMediaResponse> Media { get; set; }
public List<PlexGenreResponse> Genre { get; set; }
}
@@ -177,9 +177,15 @@ namespace ErsatzTV.Infrastructure.Plex
DateAdded = dateAdded,
DateUpdated = lastWriteTime,
Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(),
Tags = new List<Tag>()
Tags = new List<Tag>(),
Studios = new List<Studio>()
};
if (!string.IsNullOrWhiteSpace(response.Studio))
{
metadata.Studios.Add(new Studio { Name = response.Studio });
}
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
{
metadata.ReleaseDate = releaseDate;
@@ -279,9 +285,15 @@ namespace ErsatzTV.Infrastructure.Plex
DateAdded = dateAdded,
DateUpdated = lastWriteTime,
Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(),
Tags = new List<Tag>()
Tags = new List<Tag>(),
Studios = new List<Studio>()
};
if (!string.IsNullOrWhiteSpace(response.Studio))
{
metadata.Studios.Add(new Studio { Name = response.Studio });
}
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
{
metadata.ReleaseDate = releaseDate;
@@ -51,17 +51,17 @@ namespace ErsatzTV.Infrastructure.Plex
.Append(httpsResources.Filter(resource => resource.HttpsRequired))
.ToList();
IEnumerable<PlexMediaSource> sources = allResources
IEnumerable<PlexMediaSource> sources = await allResources
.Filter(r => r.Provides.Split(",").Any(p => p == "server"))
.Filter(r => r.Owned) // TODO: maybe support non-owned servers in the future
.Map(
resource =>
async resource =>
{
var serverAuthToken = new PlexServerAuthToken(
resource.ClientIdentifier,
resource.AccessToken);
_plexSecretStore.UpsertServerAuthToken(serverAuthToken);
await _plexSecretStore.UpsertServerAuthToken(serverAuthToken);
List<PlexResourceConnection> sortedConnections = resource.HttpsRequired
? resource.Connections
: resource.Connections.OrderBy(c => c.Local ? 0 : 1).ToList();
@@ -76,7 +76,9 @@ namespace ErsatzTV.Infrastructure.Plex
};
return source;
});
})
.Sequence();
result.AddRange(sources);
}
+102 -50
View File
@@ -18,6 +18,7 @@ using Lucene.Net.Sandbox.Queries;
using Lucene.Net.Search;
using Lucene.Net.Store;
using Lucene.Net.Util;
using Microsoft.Extensions.Logging;
using Query = Lucene.Net.Search.Query;
namespace ErsatzTV.Infrastructure.Search
@@ -36,6 +37,8 @@ namespace ErsatzTV.Infrastructure.Search
private const string LibraryNameField = "library_name";
private const string TitleAndYearField = "title_and_year";
private const string JumpLetterField = "jump_letter";
private const string ReleaseDateField = "release_date";
private const string StudioField = "studio";
private const string MovieType = "movie";
private const string ShowType = "show";
@@ -43,17 +46,21 @@ namespace ErsatzTV.Infrastructure.Search
private static bool _isRebuilding;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<SearchIndex> _logger;
private readonly string[] _searchFields = { TitleField, GenreField, TagField };
private readonly ISearchRepository _searchRepository;
public SearchIndex(ILocalFileSystem localFileSystem, ISearchRepository searchRepository)
public SearchIndex(
ILocalFileSystem localFileSystem,
ISearchRepository searchRepository,
ILogger<SearchIndex> logger)
{
_localFileSystem = localFileSystem;
_searchRepository = searchRepository;
_logger = logger;
}
public int Version => 1;
public int Version => 2;
public Task<bool> Initialize()
{
@@ -150,7 +157,8 @@ namespace ErsatzTV.Infrastructure.Search
using var analyzer = new StandardAnalyzer(AppLuceneVersion);
QueryParser parser = !string.IsNullOrWhiteSpace(searchField)
? new QueryParser(AppLuceneVersion, searchField, analyzer)
: new MultiFieldQueryParser(AppLuceneVersion, _searchFields, analyzer);
: new MultiFieldQueryParser(AppLuceneVersion, new[] { TitleField }, analyzer);
parser.AllowLeadingWildcard = true;
Query query = ParseQuery(searchQuery, parser);
var filter = new DuplicateFilter(TitleAndYearField);
var sort = new Sort(new SortField(SortTitleField, SortFieldType.STRING));
@@ -212,77 +220,121 @@ namespace ErsatzTV.Infrastructure.Search
return new SearchPageMap(map);
}
private static void UpdateMovie(Movie movie, IndexWriter writer)
private void UpdateMovie(Movie movie, IndexWriter writer)
{
Option<MovieMetadata> maybeMetadata = movie.MovieMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
{
MovieMetadata metadata = maybeMetadata.ValueUnsafe();
var doc = new Document
try
{
new StringField(IdField, movie.Id.ToString(), Field.Store.YES),
new StringField(TypeField, MovieType, Field.Store.NO),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, movie.LibraryPath.Library.Name, Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
var doc = new Document
{
new StringField(IdField, movie.Id.ToString(), Field.Store.YES),
new StringField(TypeField, MovieType, Field.Store.NO),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, movie.LibraryPath.Library.Name, Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
if (!string.IsNullOrWhiteSpace(metadata.Plot))
{
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO));
if (metadata.ReleaseDate.HasValue)
{
doc.Add(
new StringField(
ReleaseDateField,
metadata.ReleaseDate.Value.ToString("yyyyMMdd"),
Field.Store.NO));
}
if (!string.IsNullOrWhiteSpace(metadata.Plot))
{
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO));
}
foreach (Genre genre in metadata.Genres)
{
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
}
foreach (Tag tag in metadata.Tags)
{
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
}
foreach (Studio studio in metadata.Studios)
{
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
}
writer.UpdateDocument(new Term(IdField, movie.Id.ToString()), doc);
}
foreach (Genre genre in metadata.Genres)
catch (Exception ex)
{
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
metadata.Movie = null;
_logger.LogWarning(ex, "Error indexing movie with metadata {@Metadata}", metadata);
}
foreach (Tag tag in metadata.Tags)
{
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
}
writer.UpdateDocument(new Term(IdField, movie.Id.ToString()), doc);
}
}
private static void UpdateShow(Show show, IndexWriter writer)
private void UpdateShow(Show show, IndexWriter writer)
{
Option<ShowMetadata> maybeMetadata = show.ShowMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
{
ShowMetadata metadata = maybeMetadata.ValueUnsafe();
var doc = new Document
try
{
new StringField(IdField, show.Id.ToString(), Field.Store.YES),
new StringField(TypeField, ShowType, Field.Store.NO),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, show.LibraryPath.Library.Name, Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
var doc = new Document
{
new StringField(IdField, show.Id.ToString(), Field.Store.YES),
new StringField(TypeField, ShowType, Field.Store.NO),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, show.LibraryPath.Library.Name, Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
if (!string.IsNullOrWhiteSpace(metadata.Plot))
{
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO));
if (metadata.ReleaseDate.HasValue)
{
doc.Add(
new StringField(
ReleaseDateField,
metadata.ReleaseDate.Value.ToString("yyyyMMdd"),
Field.Store.NO));
}
if (!string.IsNullOrWhiteSpace(metadata.Plot))
{
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO));
}
foreach (Genre genre in metadata.Genres)
{
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
}
foreach (Tag tag in metadata.Tags)
{
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
}
foreach (Studio studio in metadata.Studios)
{
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
}
writer.UpdateDocument(new Term(IdField, show.Id.ToString()), doc);
}
foreach (Genre genre in metadata.Genres)
catch (Exception ex)
{
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
metadata.Show = null;
_logger.LogWarning(ex, "Error indexing show with metadata {@Metadata}", metadata);
}
foreach (Tag tag in metadata.Tags)
{
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
}
writer.UpdateDocument(new Term(IdField, show.Id.ToString()), doc);
}
}
+5 -5
View File
@@ -11,8 +11,8 @@
<ItemGroup>
<PackageReference Include="Accelist.FluentValidation.Blazor" Version="4.0.0" />
<PackageReference Include="FluentValidation" Version="9.5.2" />
<PackageReference Include="FluentValidation.AspNetCore" Version="9.5.2" />
<PackageReference Include="FluentValidation" Version="9.5.3" />
<PackageReference Include="FluentValidation.AspNetCore" Version="9.5.3" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.4" />
@@ -20,14 +20,14 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MudBlazor" Version="5.0.5" />
<PackageReference Include="MudBlazor" Version="5.0.6" />
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.24" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.AspNetCore" Version="4.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.1.0" />
<PackageReference Include="Serilog.Sinks.SQLite" Version="5.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="6.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="6.1.1" />
</ItemGroup>
<ItemGroup>
+3 -1
View File
@@ -95,13 +95,15 @@
}
else
{
FFmpegSettingsViewModel ffmpegSettings = await Mediator.Send(new GetFFmpegSettings());
// TODO: command for new channel
IEnumerable<int> channelNumbers = await Mediator.Send(new GetAllChannels())
.Map(list => list.Map(c => int.TryParse(c.Number.Split(".").Head(), out int result) ? result : 0));
int maxNumber = Optional(channelNumbers).Flatten().DefaultIfEmpty(0).Max();
_model.Number = (maxNumber + 1).ToString();
_model.Name = "New Channel";
_model.FFmpegProfileId = _ffmpegProfiles.Head().Id;
_model.FFmpegProfileId = ffmpegSettings.DefaultFFmpegProfileId;
_model.StreamingMode = StreamingMode.TransportStream;
}
}
+12 -2
View File
@@ -43,13 +43,23 @@
</div>
</div>
</div>
@if (_movie.Studios.Any())
{
<MudText GutterBottom="true">Studios</MudText>
<div class="mb-2">
@foreach (string studio in _movie.Studios.OrderBy(s => s))
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@studio" Class="mr-2 mb-2" Link="@($"/search?query=studio%3a%22{Uri.EscapeDataString(studio.ToLowerInvariant())}%22")"/>
}
</div>
}
@if (_movie.Genres.Any())
{
<MudText GutterBottom="true">Genres</MudText>
<div class="mb-2">
@foreach (string genre in _movie.Genres.OrderBy(g => g))
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{genre.ToLowerInvariant()}%22")"/>
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre.ToLowerInvariant())}%22")"/>
}
</div>
}
@@ -59,7 +69,7 @@
<div>
@foreach (string tag in _movie.Tags.OrderBy(t => t))
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a%22{tag.ToLowerInvariant()}%22")"/>
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a%22{Uri.EscapeDataString(tag.ToLowerInvariant())}%22")"/>
}
</div>
}
+12 -2
View File
@@ -58,13 +58,23 @@
</div>
</div>
</div>
@if (_show.Studios.Any())
{
<MudText GutterBottom="true">Studios</MudText>
<div class="mb-2">
@foreach (string studio in _show.Studios.OrderBy(g => g))
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@studio" Class="mr-2 mb-2" Link="@($"/search?query=studio%3a%22{Uri.EscapeDataString(studio.ToLowerInvariant())}%22")"/>
}
</div>
}
@if (_show.Genres.Any())
{
<MudText GutterBottom="true">Genres</MudText>
<div class="mb-2">
@foreach (string genre in _show.Genres.OrderBy(g => g))
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{genre.ToLowerInvariant()}%22")"/>
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre.ToLowerInvariant())}%22")"/>
}
</div>
}
@@ -74,7 +84,7 @@
<div>
@foreach (string tag in _show.Tags.OrderBy(t => t))
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a%22{tag.ToLowerInvariant()}%22")"/>
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a%22{Uri.EscapeDataString(tag.ToLowerInvariant())}%22")"/>
}
</div>
}
+20 -4
View File
@@ -1,4 +1,6 @@
@using System.Reflection
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.Extensions.Primitives
@using System.Web
@inherits LayoutComponentBase
@inject NavigationManager NavigationManager
@@ -15,7 +17,7 @@
</a>
</div>
<MudTextField T="string"
@ref="_textField"
@bind-Value="@_query"
AdornmentIcon="@Icons.Material.Filled.Search"
Adornment="Adornment.Start"
Variant="Variant.Outlined"
@@ -65,7 +67,7 @@
@code {
private static readonly string InfoVersion = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "unknown";
private MudTextField<string> _textField;
private string _query;
private MudTheme _ersatzTvTheme => new()
{
@@ -89,12 +91,26 @@
}
};
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
string query = new Uri(NavigationManager.Uri).Query;
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
{
_query = value;
}
else
{
_query = string.Empty;
}
}
private void OnSearchKeyDown(KeyboardEventArgs args)
{
if (args.Code == "Enter")
{
string query = HttpUtility.UrlEncode(_textField.Value);
_textField.Reset();
string query = HttpUtility.UrlEncode(_query);
NavigationManager.NavigateTo($"/search?query={query}", true);
StateHasChanged();
}