diff --git a/Kyoo.Common/Controllers/ILibraryManager.cs b/Kyoo.Common/Controllers/ILibraryManager.cs index fb597293..ae3c321b 100644 --- a/Kyoo.Common/Controllers/ILibraryManager.cs +++ b/Kyoo.Common/Controllers/ILibraryManager.cs @@ -143,13 +143,79 @@ namespace Kyoo.Controllers Task Get(string showSlug, int seasonNumber, int episodeNumber); /// - /// Get a tracck from it's slug and it's type. + /// Get a track from it's slug and it's type. /// /// The slug of the track /// The type (Video, Audio or Subtitle) /// If the item is not found /// The tracl found - Task GetTrack(string slug, StreamType type = StreamType.Unknown); + Task Get(string slug, StreamType type = StreamType.Unknown); + + /// + /// Get the resource by it's ID or null if it is not found. + /// + /// The id of the resource + /// The type of the resource + /// The resource found + Task GetOrDefault(int id) where T : class, IResource; + + /// + /// Get the resource by it's slug or null if it is not found. + /// + /// The slug of the resource + /// The type of the resource + /// The resource found + Task GetOrDefault(string slug) where T : class, IResource; + + /// + /// Get the resource by a filter function or null if it is not found. + /// + /// The filter function. + /// The type of the resource + /// The first resource found that match the where function + Task GetOrDefault(Expression> where) where T : class, IResource; + + /// + /// Get a season from it's showID and it's seasonNumber or null if it is not found. + /// + /// The id of the show + /// The season's number + /// The season found + Task GetOrDefault(int showID, int seasonNumber); + + /// + /// Get a season from it's show slug and it's seasonNumber or null if it is not found. + /// + /// The slug of the show + /// The season's number + /// The season found + Task GetOrDefault(string showSlug, int seasonNumber); + + /// + /// Get a episode from it's showID, it's seasonNumber and it's episode number or null if it is not found. + /// + /// The id of the show + /// The season's number + /// The episode's number + /// The episode found + Task GetOrDefault(int showID, int seasonNumber, int episodeNumber); + + /// + /// Get a episode from it's show slug, it's seasonNumber and it's episode number or null if it is not found. + /// + /// The slug of the show + /// The season's number + /// The episode's number + /// The episode found + Task GetOrDefault(string showSlug, int seasonNumber, int episodeNumber); + + /// + /// Get a track from it's slug and it's type or null if it is not found. + /// + /// The slug of the track + /// The type (Video, Audio or Subtitle) + /// The tracl found + Task GetOrDefault(string slug, StreamType type = StreamType.Unknown); /// @@ -423,7 +489,15 @@ namespace Kyoo.Controllers /// The item to register /// The type of resource /// The resource registers and completed by database's informations (related items & so on) - Task Create(T item) where T : class, IResource; + Task Create([NotNull] T item) where T : class, IResource; + + /// + /// Create a new resource if it does not exist already. If it does, the existing value is returned instead. + /// + /// The item to register + /// The type of resource + /// The newly created item or the existing value if it existed. + Task CreateIfNotExists([NotNull] T item) where T : class, IResource; /// /// Edit a resource @@ -431,6 +505,7 @@ namespace Kyoo.Controllers /// The resourcce to edit, it's ID can't change. /// Should old properties of the resource be discarded or should null values considered as not changed? /// The type of resources + /// If the item is not found /// The resource edited and completed by database's informations (related items & so on) Task Edit(T item, bool resetOld) where T : class, IResource; @@ -439,6 +514,7 @@ namespace Kyoo.Controllers /// /// The resource to delete /// The type of resource to delete + /// If the item is not found Task Delete(T item) where T : class, IResource; /// @@ -446,6 +522,7 @@ namespace Kyoo.Controllers /// /// The id of the resource to delete /// The type of resource to delete + /// If the item is not found Task Delete(int id) where T : class, IResource; /// @@ -453,6 +530,7 @@ namespace Kyoo.Controllers /// /// The slug of the resource to delete /// The type of resource to delete + /// If the item is not found Task Delete(string slug) where T : class, IResource; } } diff --git a/Kyoo.Common/Controllers/IRepository.cs b/Kyoo.Common/Controllers/IRepository.cs index 4fa70691..fbe20a95 100644 --- a/Kyoo.Common/Controllers/IRepository.cs +++ b/Kyoo.Common/Controllers/IRepository.cs @@ -146,6 +146,25 @@ namespace Kyoo.Controllers /// The resource found Task Get(Expression> where); + /// + /// Get a resource from it's ID or null if it is not found. + /// + /// The id of the resource + /// The resource found + Task GetOrDefault(int id); + /// + /// Get a resource from it's slug or null if it is not found. + /// + /// The slug of the resource + /// The resource found + Task GetOrDefault(string slug); + /// + /// Get the first resource that match the predicate or null if it is not found. + /// + /// A predicate to filter the resource. + /// The resource found + Task GetOrDefault(Expression> where); + /// /// Search for resources. /// @@ -203,6 +222,7 @@ namespace Kyoo.Controllers /// /// The resourcce to edit, it's ID can't change. /// Should old properties of the resource be discarded or should null values considered as not changed? + /// If the item is not found /// The resource edited and completed by database's informations (related items & so on) Task Edit([NotNull] T edited, bool resetOld); @@ -210,77 +230,193 @@ namespace Kyoo.Controllers /// Delete a resource by it's ID /// /// The ID of the resource + /// If the item is not found Task Delete(int id); /// /// Delete a resource by it's slug /// /// The slug of the resource + /// If the item is not found Task Delete(string slug); /// /// Delete a resource /// /// The resource to delete + /// If the item is not found Task Delete([NotNull] T obj); /// /// Delete a list of resources. /// /// One or multiple resources to delete + /// If the item is not found Task DeleteRange(params T[] objs) => DeleteRange(objs.AsEnumerable()); /// /// Delete a list of resources. /// /// An enumerable of resources to delete + /// If the item is not found Task DeleteRange(IEnumerable objs); /// /// Delete a list of resources. /// /// One or multiple resources's id + /// If the item is not found Task DeleteRange(params int[] ids) => DeleteRange(ids.AsEnumerable()); /// /// Delete a list of resources. /// /// An enumearble of resources's id + /// If the item is not found Task DeleteRange(IEnumerable ids); /// /// Delete a list of resources. /// /// One or multiple resources's slug + /// If the item is not found Task DeleteRange(params string[] slugs) => DeleteRange(slugs.AsEnumerable()); /// /// Delete a list of resources. /// /// An enumerable of resources's slug + /// If the item is not found Task DeleteRange(IEnumerable slugs); /// /// Delete a list of resources. /// /// A predicate to filter resources to delete. Every resource that match this will be deleted. + /// If the item is not found Task DeleteRange([NotNull] Expression> where); } + /// + /// A repository to handle shows. + /// public interface IShowRepository : IRepository { + /// + /// Link a show to a collection and/or a library. The given show is now part of thoses containers. + /// If both a library and a collection are given, the collection is added to the library too. + /// + /// The ID of the show + /// The ID of the library (optional) + /// The ID of the collection (optional) Task AddShowLink(int showID, int? libraryID, int? collectionID); + /// + /// Get a show's slug from it's ID. + /// + /// The ID of the show + /// If a show with the given ID is not found. + /// The show's slug Task GetSlug(int showID); } + /// + /// A repository to handle seasons. + /// public interface ISeasonRepository : IRepository { + /// + /// Get a season from it's showID and it's seasonNumber + /// + /// The id of the show + /// The season's number + /// If the item is not found + /// The season found Task Get(int showID, int seasonNumber); + + /// + /// Get a season from it's show slug and it's seasonNumber + /// + /// The slug of the show + /// The season's number + /// If the item is not found + /// The season found Task Get(string showSlug, int seasonNumber); - Task Delete(string showSlug, int seasonNumber); + + /// + /// Get a season from it's showID and it's seasonNumber or null if it is not found. + /// + /// The id of the show + /// The season's number + /// The season found + Task GetOrDefault(int showID, int seasonNumber); + + /// + /// Get a season from it's show slug and it's seasonNumber or null if it is not found. + /// + /// The slug of the show + /// The season's number + /// The season found + Task GetOrDefault(string showSlug, int seasonNumber); } + /// + /// The repository to handle episodes + /// public interface IEpisodeRepository : IRepository { + /// + /// Get a episode from it's showID, it's seasonNumber and it's episode number. + /// + /// The id of the show + /// The season's number + /// The episode's number + /// If the item is not found + /// The episode found Task Get(int showID, int seasonNumber, int episodeNumber); + /// + /// Get a episode from it's show slug, it's seasonNumber and it's episode number. + /// + /// The slug of the show + /// The season's number + /// The episode's number + /// If the item is not found + /// The episode found Task Get(string showSlug, int seasonNumber, int episodeNumber); + /// + /// Get a episode from it's season ID and it's episode number. + /// + /// The ID of the season + /// The episode number + /// If the item is not found + /// The episode found Task Get(int seasonID, int episodeNumber); + + /// + /// Get a episode from it's showID, it's seasonNumber and it's episode number or null if it is not found. + /// + /// The id of the show + /// The season's number + /// The episode's number + /// The episode found + Task GetOrDefault(int showID, int seasonNumber, int episodeNumber); + /// + /// Get a episode from it's show slug, it's seasonNumber and it's episode number or null if it is not found. + /// + /// The slug of the show + /// The season's number + /// The episode's number + /// The episode found + Task GetOrDefault(string showSlug, int seasonNumber, int episodeNumber); + + /// + /// Get a episode from it's showID and it's absolute number. + /// + /// The id of the show + /// The episode's absolute number (The episode number does not reset to 1 after the end of a season. + /// If the item is not found + /// The episode found Task GetAbsolute(int showID, int absoluteNumber); + /// + /// Get a episode from it's showID and it's absolute number. + /// + /// The slug of the show + /// The episode's absolute number (The episode number does not reset to 1 after the end of a season. + /// If the item is not found + /// The episode found Task GetAbsolute(string showSlug, int absoluteNumber); - Task Delete(string showSlug, int seasonNumber, int episodeNumber); } public interface ITrackRepository : IRepository diff --git a/Kyoo.Common/Controllers/Implementations/LibraryManager.cs b/Kyoo.Common/Controllers/Implementations/LibraryManager.cs index bd4a9f91..b1f66291 100644 --- a/Kyoo.Common/Controllers/Implementations/LibraryManager.cs +++ b/Kyoo.Common/Controllers/Implementations/LibraryManager.cs @@ -15,27 +15,6 @@ namespace Kyoo.Controllers /// private readonly IBaseRepository[] _repositories; - - /// - /// Create a new instancce with every repository available. - /// - /// The list of repositories that this library manager should manage. If a repository for every base type is not available, this instance won't be stable. - public LibraryManager(IEnumerable repositories) - { - _repositories = repositories.ToArray(); - LibraryRepository = GetRepository() as ILibraryRepository; - LibraryItemRepository = GetRepository() as ILibraryItemRepository; - CollectionRepository = GetRepository() as ICollectionRepository; - ShowRepository = GetRepository() as IShowRepository; - SeasonRepository = GetRepository() as ISeasonRepository; - EpisodeRepository = GetRepository() as IEpisodeRepository; - TrackRepository = GetRepository() as ITrackRepository; - PeopleRepository = GetRepository() as IPeopleRepository; - StudioRepository = GetRepository() as IStudioRepository; - GenreRepository = GetRepository() as IGenreRepository; - ProviderRepository = GetRepository() as IProviderRepository; - } - /// /// The repository that handle libraries. /// @@ -90,7 +69,60 @@ namespace Kyoo.Controllers /// The repository that handle providers. /// public IProviderRepository ProviderRepository { get; } + + + /// + /// Create a new instancce with every repository available. + /// + /// The list of repositories that this library manager should manage. If a repository for every base type is not available, this instance won't be stable. + public LibraryManager(IEnumerable repositories) + { + _repositories = repositories.ToArray(); + LibraryRepository = GetRepository() as ILibraryRepository; + LibraryItemRepository = GetRepository() as ILibraryItemRepository; + CollectionRepository = GetRepository() as ICollectionRepository; + ShowRepository = GetRepository() as IShowRepository; + SeasonRepository = GetRepository() as ISeasonRepository; + EpisodeRepository = GetRepository() as IEpisodeRepository; + TrackRepository = GetRepository() as ITrackRepository; + PeopleRepository = GetRepository() as IPeopleRepository; + StudioRepository = GetRepository() as IStudioRepository; + GenreRepository = GetRepository() as IGenreRepository; + ProviderRepository = GetRepository() as IProviderRepository; + } + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + foreach (IBaseRepository repo in _repositories) + repo.Dispose(); + GC.SuppressFinalize(this); + } + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources asynchronously. + /// + /// A task that represents the asynchronous dispose operation. + public async ValueTask DisposeAsync() + { + await Task.WhenAll(_repositories.Select(x => x.DisposeAsync().AsTask())); + } + + /// + /// Get the repository corresponding to the T item. + /// + /// The type you want + /// If the item is not found + /// The repository corresponding + public IRepository GetRepository() + where T : class, IResource + { + if (_repositories.FirstOrDefault(x => x.RepositoryType == typeof(T)) is IRepository ret) + return ret; + throw new ItemNotFound(); + } /// /// Get the resource by it's ID @@ -188,44 +220,104 @@ namespace Kyoo.Controllers /// The type (Video, Audio or Subtitle) /// If the item is not found /// The tracl found - public Task GetTrack(string slug, StreamType type = StreamType.Unknown) + public Task Get(string slug, StreamType type = StreamType.Unknown) { return TrackRepository.Get(slug, type); } /// - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// Get the resource by it's ID or null if it is not found. /// - public void Dispose() - { - foreach (IBaseRepository repo in _repositories) - repo.Dispose(); - GC.SuppressFinalize(this); - } - - /// - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources asynchronously. - /// - /// A task that represents the asynchronous dispose operation. - public async ValueTask DisposeAsync() - { - await Task.WhenAll(_repositories.Select(x => x.DisposeAsync().AsTask())); - } - - /// - /// Get the repository corresponding to the T item. - /// - /// The type you want - /// If the item is not found - /// The repository corresponding - public IRepository GetRepository() + /// The id of the resource + /// The type of the resource + /// The resource found + public async Task GetOrDefault(int id) where T : class, IResource { - if (_repositories.FirstOrDefault(x => x.RepositoryType == typeof(T)) is IRepository ret) - return ret; - throw new ItemNotFound(); + return await GetRepository().GetOrDefault(id); + } + + /// + /// Get the resource by it's slug or null if it is not found. + /// + /// The slug of the resource + /// The type of the resource + /// The resource found + public async Task GetOrDefault(string slug) + where T : class, IResource + { + return await GetRepository().GetOrDefault(slug); + } + + /// + /// Get the resource by a filter function or null if it is not found. + /// + /// The filter function. + /// The type of the resource + /// The first resource found that match the where function + public async Task GetOrDefault(Expression> where) + where T : class, IResource + { + return await GetRepository().GetOrDefault(where); } + /// + /// Get a season from it's showID and it's seasonNumber or null if it is not found. + /// + /// The id of the show + /// The season's number + /// The season found + public async Task GetOrDefault(int showID, int seasonNumber) + { + return await SeasonRepository.GetOrDefault(showID, seasonNumber); + } + + /// + /// Get a season from it's show slug and it's seasonNumber or null if it is not found. + /// + /// The slug of the show + /// The season's number + /// The season found + public async Task GetOrDefault(string showSlug, int seasonNumber) + { + return await SeasonRepository.GetOrDefault(showSlug, seasonNumber); + } + + /// + /// Get a episode from it's showID, it's seasonNumber and it's episode number or null if it is not found. + /// + /// The id of the show + /// The season's number + /// The episode's number + /// The episode found + public async Task GetOrDefault(int showID, int seasonNumber, int episodeNumber) + { + return await EpisodeRepository.GetOrDefault(showID, seasonNumber, episodeNumber); + } + + /// + /// Get a episode from it's show slug, it's seasonNumber and it's episode number or null if it is not found. + /// + /// The slug of the show + /// The season's number + /// The episode's number + /// The episode found + public async Task GetOrDefault(string showSlug, int seasonNumber, int episodeNumber) + { + return await EpisodeRepository.GetOrDefault(showSlug, seasonNumber, episodeNumber); + } + + /// + /// Get a track from it's slug and it's type or null if it is not found. + /// + /// The slug of the track + /// The type (Video, Audio or Subtitle) + /// The tracl found + public async Task GetOrDefault(string slug, StreamType type = StreamType.Unknown) + { + return await TrackRepository.GetOrDefault(slug, type); + } + /// /// Load a related resource /// @@ -360,7 +452,7 @@ namespace Kyoo.Controllers .Then(x => s.Collections = x), (Show s, nameof(Show.Studio)) => StudioRepository - .Get(x => x.Shows.Any(y => y.ID == obj.ID)) + .GetOrDefault(x => x.Shows.Any(y => y.ID == obj.ID)) .Then(x => { s.Studio = x; @@ -379,7 +471,7 @@ namespace Kyoo.Controllers (x, y) => { x.Season = y; x.SeasonID = y.ID; }), (Season s, nameof(Season.Show)) => ShowRepository - .Get(x => x.Seasons.Any(y => y.ID == obj.ID)) + .GetOrDefault(x => x.Seasons.Any(y => y.ID == obj.ID)) .Then(x => { s.Show = x; @@ -398,7 +490,7 @@ namespace Kyoo.Controllers (x, y) => { x.Episode = y; x.EpisodeID = y.ID; }), (Episode e, nameof(Episode.Show)) => ShowRepository - .Get(x => x.Episodes.Any(y => y.ID == obj.ID)) + .GetOrDefault(x => x.Episodes.Any(y => y.ID == obj.ID)) .Then(x => { e.Show = x; @@ -406,7 +498,7 @@ namespace Kyoo.Controllers }), (Episode e, nameof(Episode.Season)) => SeasonRepository - .Get(x => x.Episodes.Any(y => y.ID == e.ID)) + .GetOrDefault(x => x.Episodes.Any(y => y.ID == e.ID)) .Then(x => { e.Season = x; @@ -415,7 +507,7 @@ namespace Kyoo.Controllers (Track t, nameof(Track.Episode)) => EpisodeRepository - .Get(x => x.Tracks.Any(y => y.ID == obj.ID)) + .GetOrDefault(x => x.Tracks.Any(y => y.ID == obj.ID)) .Then(x => { t.Episode = x; @@ -623,6 +715,18 @@ namespace Kyoo.Controllers { return GetRepository().Create(item); } + + /// + /// Create a new resource if it does not exist already. If it does, the existing value is returned instead. + /// + /// The object to create + /// The type of resource + /// The newly created item or the existing value if it existed. + public Task CreateIfNotExists(T item) + where T : class, IResource + { + return GetRepository().CreateIfNotExists(item); + } /// /// Edit a resource @@ -630,6 +734,7 @@ namespace Kyoo.Controllers /// The resourcce to edit, it's ID can't change. /// Should old properties of the resource be discarded or should null values considered as not changed? /// The type of resources + /// If the item is not found /// The resource edited and completed by database's informations (related items & so on) public Task Edit(T item, bool resetOld) where T : class, IResource @@ -642,6 +747,7 @@ namespace Kyoo.Controllers /// /// The resource to delete /// The type of resource to delete + /// If the item is not found public Task Delete(T item) where T : class, IResource { @@ -653,6 +759,7 @@ namespace Kyoo.Controllers /// /// The id of the resource to delete /// The type of resource to delete + /// If the item is not found public Task Delete(int id) where T : class, IResource { @@ -664,6 +771,7 @@ namespace Kyoo.Controllers /// /// The slug of the resource to delete /// The type of resource to delete + /// If the item is not found public Task Delete(string slug) where T : class, IResource { diff --git a/Kyoo.CommonAPI/CrudApi.cs b/Kyoo.CommonAPI/CrudApi.cs index 0bbf7733..50dcb588 100644 --- a/Kyoo.CommonAPI/CrudApi.cs +++ b/Kyoo.CommonAPI/CrudApi.cs @@ -29,22 +29,28 @@ namespace Kyoo.CommonApi [Authorize(Policy = "Read")] public virtual async Task> Get(int id) { - T resource = await _repository.Get(id); - if (resource == null) + try + { + return await _repository.Get(id); + } + catch (ItemNotFound) + { return NotFound(); - - return resource; + } } [HttpGet("{slug}")] [Authorize(Policy = "Read")] public virtual async Task> Get(string slug) { - T resource = await _repository.Get(slug); - if (resource == null) + try + { + return await _repository.Get(slug); + } + catch (ItemNotFound) + { return NotFound(); - - return resource; + } } [HttpGet("count")] @@ -114,15 +120,19 @@ namespace Kyoo.CommonApi [Authorize(Policy = "Write")] public virtual async Task> Edit([FromQuery] bool resetOld, [FromBody] T resource) { - if (resource.ID > 0) + try + { + if (resource.ID > 0) + return await _repository.Edit(resource, resetOld); + + T old = await _repository.Get(resource.Slug); + resource.ID = old.ID; return await _repository.Edit(resource, resetOld); - - T old = await _repository.Get(resource.Slug); - if (old == null) + } + catch (ItemNotFound) + { return NotFound(); - - resource.ID = old.ID; - return await _repository.Edit(resource, resetOld); + } } [HttpPut("{id:int}")] @@ -144,11 +154,16 @@ namespace Kyoo.CommonApi [Authorize(Policy = "Write")] public virtual async Task> Edit(string slug, [FromQuery] bool resetOld, [FromBody] T resource) { - T old = await _repository.Get(slug); - if (old == null) + try + { + T old = await _repository.Get(slug); + resource.ID = old.ID; + return await _repository.Edit(resource, resetOld); + } + catch (ItemNotFound) + { return NotFound(); - resource.ID = old.ID; - return await _repository.Edit(resource, resetOld); + } } [HttpDelete("{id:int}")] diff --git a/Kyoo.CommonAPI/LocalRepository.cs b/Kyoo.CommonAPI/LocalRepository.cs index 1cea2af3..8f82093e 100644 --- a/Kyoo.CommonAPI/LocalRepository.cs +++ b/Kyoo.CommonAPI/LocalRepository.cs @@ -12,54 +12,112 @@ using Microsoft.EntityFrameworkCore; namespace Kyoo.Controllers { + /// + /// A base class to create repositories using Entity Framework. + /// + /// The type of this repository public abstract class LocalRepository : IRepository where T : class, IResource { + /// + /// The Entity Framework's Database handle. + /// protected readonly DbContext Database; + /// + /// The default sort order that will be used for this resource's type. + /// protected abstract Expression> DefaultSort { get; } + /// + /// Create a new base with the given database handle. + /// + /// A database connection to load resources of type protected LocalRepository(DbContext database) { Database = database; } + /// public Type RepositoryType => typeof(T); + /// public virtual void Dispose() { Database.Dispose(); GC.SuppressFinalize(this); } + /// public virtual ValueTask DisposeAsync() { return Database.DisposeAsync(); } - public virtual Task Get(int id) + /// + /// Get a resource from it's ID and make the instance track it. + /// + /// The ID of the resource + /// If the item is not found + /// The tracked resource with the given ID + protected virtual async Task GetWithTracking(int id) + { + T ret = await Database.Set().AsTracking().FirstOrDefaultAsync(x => x.ID == id); + if (ret == null) + throw new ItemNotFound($"No {typeof(T).Name} found with the id {id}"); + return ret; + } + + /// + public virtual async Task Get(int id) + { + T ret = await GetOrDefault(id); + if (ret == null) + throw new ItemNotFound($"No {typeof(T).Name} found with the id {id}"); + return ret; + } + + /// + public virtual async Task Get(string slug) + { + T ret = await GetOrDefault(slug); + if (ret == null) + throw new ItemNotFound($"No {typeof(T).Name} found with the slug {slug}"); + return ret; + } + + /// + public virtual async Task Get(Expression> where) + { + T ret = await GetOrDefault(where); + if (ret == null) + throw new ItemNotFound($"No {typeof(T).Name} found with the given predicate."); + return ret; + } + + /// + public Task GetOrDefault(int id) { return Database.Set().FirstOrDefaultAsync(x => x.ID == id); } - public virtual Task GetWithTracking(int id) - { - return Database.Set().AsTracking().FirstOrDefaultAsync(x => x.ID == id); - } - - public virtual Task Get(string slug) + /// + public Task GetOrDefault(string slug) { return Database.Set().FirstOrDefaultAsync(x => x.Slug == slug); } - - public virtual Task Get(Expression> predicate) - { - return Database.Set().FirstOrDefaultAsync(predicate); - } + /// + public Task GetOrDefault(Expression> where) + { + return Database.Set().FirstOrDefaultAsync(where); + } + + /// public abstract Task> Search(string query); + /// public virtual Task> GetAll(Expression> where = null, Sort sort = default, Pagination limit = default) @@ -67,6 +125,14 @@ namespace Kyoo.Controllers return ApplyFilters(Database.Set(), where, sort, limit); } + /// + /// Apply filters to a query to ease sort, pagination & where queries for resources of this repository + /// + /// The base query to filter. + /// An expression to filter based on arbitrary conditions + /// The sort settings (sort order & sort by) + /// Paginations information (where to start and how many to get) + /// The filtered query protected Task> ApplyFilters(IQueryable query, Expression> where = null, Sort sort = default, @@ -75,6 +141,17 @@ namespace Kyoo.Controllers return ApplyFilters(query, Get, DefaultSort, where, sort, limit); } + /// + /// Apply filters to a query to ease sort, pagination & where queries for any resources types. + /// For resources of type , see + /// + /// A function to asynchronously get a resource from the database using it's ID. + /// The default sort order of this resource's type. + /// The base query to filter. + /// An expression to filter based on arbitrary conditions + /// The sort settings (sort order & sort by) + /// Paginations information (where to start and how many to get) + /// The filtered query protected async Task> ApplyFilters(IQueryable query, Func> get, Expression> defaultSort, @@ -110,6 +187,7 @@ namespace Kyoo.Controllers return await query.ToListAsync(); } + /// public virtual Task GetCount(Expression> where = null) { IQueryable query = Database.Set(); @@ -118,6 +196,7 @@ namespace Kyoo.Controllers return query.CountAsync(); } + /// public virtual async Task Create(T obj) { if (obj == null) @@ -126,6 +205,7 @@ namespace Kyoo.Controllers return obj; } + /// public virtual async Task CreateIfNotExists(T obj, bool silentFail = false) { try @@ -141,10 +221,7 @@ namespace Kyoo.Controllers } catch (DuplicatedItemException) { - T old = await Get(obj!.Slug); - if (old == null) - throw new SystemException("Unknown database state."); - return old; + return await Get(obj.Slug); } catch { @@ -154,6 +231,7 @@ namespace Kyoo.Controllers } } + /// public virtual async Task Edit(T edited, bool resetOld) { if (edited == null) @@ -164,9 +242,7 @@ namespace Kyoo.Controllers try { T old = await GetWithTracking(edited.ID); - if (old == null) - throw new ItemNotFound($"No resource found with the ID {edited.ID}."); - + if (resetOld) Utility.Nullify(old); Utility.Complete(old, edited, x => x.GetCustomAttribute() == null); @@ -180,11 +256,24 @@ namespace Kyoo.Controllers } } + /// + /// An overridable method to edit relatiosn of a resource. + /// + /// The non edited resource + /// The new version of . This item will be saved on the databse and replace + /// A boolean to indicate if all values of resource should be discarded or not. + /// protected virtual Task EditRelations(T resource, T changed, bool resetOld) { return Validate(resource); } + /// + /// A method called just before saving a new resource to the database. + /// It is also called on the default implementation of + /// + /// The resource that will be saved + /// You can throw this if the resource is illegal and should not be saved. protected virtual Task Validate(T resource) { if (string.IsNullOrEmpty(resource.Slug)) @@ -207,38 +296,45 @@ namespace Kyoo.Controllers return Task.CompletedTask; } + /// public virtual async Task Delete(int id) { T resource = await Get(id); await Delete(resource); } + /// public virtual async Task Delete(string slug) { T resource = await Get(slug); await Delete(resource); } + /// public abstract Task Delete(T obj); + /// public virtual async Task DeleteRange(IEnumerable objs) { foreach (T obj in objs) await Delete(obj); } + /// public virtual async Task DeleteRange(IEnumerable ids) { foreach (int id in ids) await Delete(id); } + /// public virtual async Task DeleteRange(IEnumerable slugs) { foreach (string slug in slugs) await Delete(slug); } + /// public async Task DeleteRange(Expression> where) { ICollection resources = await GetAll(where); diff --git a/Kyoo.Tests/Library/RepositoryTests.cs b/Kyoo.Tests/Library/RepositoryTests.cs new file mode 100644 index 00000000..75ebfd47 --- /dev/null +++ b/Kyoo.Tests/Library/RepositoryTests.cs @@ -0,0 +1,17 @@ +using System.Linq; +using Xunit; + +namespace Kyoo.Tests +{ + public class RepositoryTests + { + [Fact] + public void Get_Test() + { + TestContext context = new(); + using DatabaseContext database = context.New(); + + Assert.Equal(1, database.Shows.Count()); + } + } +} \ No newline at end of file diff --git a/Kyoo.Tests/Library/TestContext.cs b/Kyoo.Tests/Library/TestContext.cs new file mode 100644 index 00000000..c9a83ad0 --- /dev/null +++ b/Kyoo.Tests/Library/TestContext.cs @@ -0,0 +1,79 @@ +using Kyoo.Models; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Kyoo.Tests +{ + /// + /// Class responsible to fill and create in memory databases for unit tests. + /// + public class TestContext + { + /// + /// The context's options that specify to use an in memory Sqlite database. + /// + private readonly DbContextOptions _context; + + /// + /// Create a new database and fill it with informations. + /// + public TestContext() + { + SqliteConnection connection = new("DataSource=:memory:"); + connection.Open(); + + try + { + _context = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + FillDatabase(); + } + finally + { + connection.Close(); + } + } + + /// + /// Fill the database with pre defined values using a clean context. + /// + private void FillDatabase() + { + using DatabaseContext context = new(_context); + context.Shows.Add(new Show + { + ID = 67, + Slug = "anohana", + Title = "Anohana: The Flower We Saw That Day", + Aliases = new[] + { + "Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai.", + "AnoHana", + "We Still Don't Know the Name of the Flower We Saw That Day." + }, + Overview = "When Yadomi Jinta was a child, he was a central piece in a group of close friends. " + + "In time, however, these childhood friends drifted apart, and when they became high " + + "school students, they had long ceased to think of each other as friends.", + Status = Status.Finished, + TrailerUrl = null, + StartYear = 2011, + EndYear = 2011, + Poster = "poster", + Logo = "logo", + Backdrop = "backdrop", + IsMovie = false, + Studio = null + }); + } + + /// + /// Get a new databse context connected to a in memory Sqlite databse. + /// + /// A valid DatabaseContext + public DatabaseContext New() + { + return new(_context); + } + } +} \ No newline at end of file diff --git a/Kyoo/Controllers/Repositories/EpisodeRepository.cs b/Kyoo/Controllers/Repositories/EpisodeRepository.cs index 64a43ce2..84232067 100644 --- a/Kyoo/Controllers/Repositories/EpisodeRepository.cs +++ b/Kyoo/Controllers/Repositories/EpisodeRepository.cs @@ -210,13 +210,7 @@ namespace Kyoo.Controllers return x; }).ToListAsync(); } - - public async Task Delete(string showSlug, int seasonNumber, int episodeNumber) - { - Episode obj = await Get(showSlug, seasonNumber, episodeNumber); - await Delete(obj); - } - + public override async Task Delete(Episode obj) { if (obj == null) diff --git a/Kyoo/Controllers/Repositories/SeasonRepository.cs b/Kyoo/Controllers/Repositories/SeasonRepository.cs index 29c3e400..fb17d58f 100644 --- a/Kyoo/Controllers/Repositories/SeasonRepository.cs +++ b/Kyoo/Controllers/Repositories/SeasonRepository.cs @@ -5,22 +5,39 @@ using System.Linq.Expressions; using System.Text.RegularExpressions; using System.Threading.Tasks; using Kyoo.Models; +using Kyoo.Models.Exceptions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace Kyoo.Controllers { + /// + /// A local repository to handle seasons. + /// public class SeasonRepository : LocalRepository, ISeasonRepository { + /// + /// Has this instance been disposed and should not handle requests? + /// private bool _disposed; private readonly DatabaseContext _database; private readonly IProviderRepository _providers; private readonly IShowRepository _shows; private readonly Lazy _episodes; + + /// protected override Expression> DefaultSort => x => x.SeasonNumber; - public SeasonRepository(DatabaseContext database, + /// + /// Create a new using the provided handle, a provider & a show repository and + /// a service provider to lazilly request an episode repository. + /// + /// The database handle that will be used + /// A provider repository + /// A show repository + /// A service provider to lazilly request an episode repository. + public SeasonRepository(DatabaseContext database, IProviderRepository providers, IShowRepository shows, IServiceProvider services) @@ -33,6 +50,7 @@ namespace Kyoo.Controllers } + /// public override void Dispose() { if (_disposed) @@ -46,6 +64,7 @@ namespace Kyoo.Controllers GC.SuppressFinalize(this); } + /// public override async ValueTask DisposeAsync() { if (_disposed) @@ -58,22 +77,23 @@ namespace Kyoo.Controllers await _episodes.Value.DisposeAsync(); } + /// public override async Task Get(int id) { Season ret = await base.Get(id); - if (ret != null) - ret.ShowSlug = await _shows.GetSlug(ret.ShowID); + ret.ShowSlug = await _shows.GetSlug(ret.ShowID); return ret; } + /// public override async Task Get(Expression> predicate) { Season ret = await base.Get(predicate); - if (ret != null) - ret.ShowSlug = await _shows.GetSlug(ret.ShowID); + ret.ShowSlug = await _shows.GetSlug(ret.ShowID); return ret; } + /// public override Task Get(string slug) { Match match = Regex.Match(slug, @"(?.*)-s(?\d*)"); @@ -83,24 +103,41 @@ namespace Kyoo.Controllers return Get(match.Groups["show"].Value, int.Parse(match.Groups["season"].Value)); } + /// public async Task Get(int showID, int seasonNumber) { - Season ret = await _database.Seasons.FirstOrDefaultAsync(x => x.ShowID == showID - && x.SeasonNumber == seasonNumber); - if (ret != null) - ret.ShowSlug = await _shows.GetSlug(showID); + Season ret = await GetOrDefault(showID, seasonNumber); + if (ret == null) + throw new ItemNotFound($"No season {seasonNumber} found for the show {showID}"); + ret.ShowSlug = await _shows.GetSlug(showID); return ret; } + /// public async Task Get(string showSlug, int seasonNumber) { - Season ret = await _database.Seasons.FirstOrDefaultAsync(x => x.Show.Slug == showSlug - && x.SeasonNumber == seasonNumber); - if (ret != null) - ret.ShowSlug = showSlug; + Season ret = await GetOrDefault(showSlug, seasonNumber); + if (ret == null) + throw new ItemNotFound($"No season {seasonNumber} found for the show {showSlug}"); + ret.ShowSlug = showSlug; return ret; } + /// + public Task GetOrDefault(int showID, int seasonNumber) + { + return _database.Seasons.FirstOrDefaultAsync(x => x.ShowID == showID + && x.SeasonNumber == seasonNumber); + } + + /// + public Task GetOrDefault(string showSlug, int seasonNumber) + { + return _database.Seasons.FirstOrDefaultAsync(x => x.Show.Slug == showSlug + && x.SeasonNumber == seasonNumber); + } + + /// public override async Task> Search(string query) { List seasons = await _database.Seasons @@ -113,6 +150,7 @@ namespace Kyoo.Controllers return seasons; } + /// public override async Task> GetAll(Expression> where = null, Sort sort = default, Pagination limit = default) @@ -123,6 +161,7 @@ namespace Kyoo.Controllers return seasons; } + /// public override async Task Create(Season obj) { await base.Create(obj); @@ -132,6 +171,7 @@ namespace Kyoo.Controllers return obj; } + /// protected override async Task Validate(Season resource) { if (resource.ShowID <= 0) @@ -146,6 +186,7 @@ namespace Kyoo.Controllers }); } + /// protected override async Task EditRelations(Season resource, Season changed, bool resetOld) { if (changed.ExternalIDs != null || resetOld) @@ -155,13 +196,8 @@ namespace Kyoo.Controllers } await base.EditRelations(resource, changed, resetOld); } - - public async Task Delete(string showSlug, int seasonNumber) - { - Season obj = await Get(showSlug, seasonNumber); - await Delete(obj); - } - + + /// public override async Task Delete(Season obj) { if (obj == null) diff --git a/Kyoo/Models/DatabaseContext.cs b/Kyoo/Models/DatabaseContext.cs index 0538ed67..7d76a3d4 100644 --- a/Kyoo/Models/DatabaseContext.cs +++ b/Kyoo/Models/DatabaseContext.cs @@ -58,13 +58,13 @@ namespace Kyoo modelBuilder.HasPostgresEnum(); modelBuilder.HasPostgresEnum(); - modelBuilder.Entity() - .Property(x => x.Paths) - .HasColumnType("text[]"); - - modelBuilder.Entity() - .Property(x => x.Aliases) - .HasColumnType("text[]"); + // modelBuilder.Entity() + // .Property(x => x.Paths) + // .HasColumnType("text[]"); + // + // modelBuilder.Entity() + // .Property(x => x.Aliases) + // .HasColumnType("text[]"); modelBuilder.Entity() .Property(t => t.IsDefault) diff --git a/Kyoo/Models/DatabaseMigrations/Internal/20210417232515_Initial.Designer.cs b/Kyoo/Models/DatabaseMigrations/Internal/20210420221509_Initial.Designer.cs similarity index 99% rename from Kyoo/Models/DatabaseMigrations/Internal/20210417232515_Initial.Designer.cs rename to Kyoo/Models/DatabaseMigrations/Internal/20210420221509_Initial.Designer.cs index 11722606..c27925c9 100644 --- a/Kyoo/Models/DatabaseMigrations/Internal/20210417232515_Initial.Designer.cs +++ b/Kyoo/Models/DatabaseMigrations/Internal/20210420221509_Initial.Designer.cs @@ -10,7 +10,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace Kyoo.Models.DatabaseMigrations.Internal { [DbContext(typeof(DatabaseContext))] - [Migration("20210417232515_Initial")] + [Migration("20210420221509_Initial")] partial class Initial { protected override void BuildTargetModel(ModelBuilder modelBuilder) diff --git a/Kyoo/Models/DatabaseMigrations/Internal/20210417232515_Initial.cs b/Kyoo/Models/DatabaseMigrations/Internal/20210420221509_Initial.cs similarity index 100% rename from Kyoo/Models/DatabaseMigrations/Internal/20210417232515_Initial.cs rename to Kyoo/Models/DatabaseMigrations/Internal/20210420221509_Initial.cs diff --git a/Kyoo/Tasks/Crawler.cs b/Kyoo/Tasks/Crawler.cs index b6ab53de..2634a3bb 100644 --- a/Kyoo/Tasks/Crawler.cs +++ b/Kyoo/Tasks/Crawler.cs @@ -144,44 +144,48 @@ namespace Kyoo.Controllers private async Task RegisterExternalSubtitle(string path, CancellationToken token) { - if (token.IsCancellationRequested || path.Split(Path.DirectorySeparatorChar).Contains("Subtitles")) - return; - using IServiceScope serviceScope = _serviceProvider.CreateScope(); - await using ILibraryManager libraryManager = serviceScope.ServiceProvider.GetService(); - - string patern = _config.GetValue("subtitleRegex"); - Regex regex = new(patern, RegexOptions.IgnoreCase); - Match match = regex.Match(path); - - if (!match.Success) + try { - await Console.Error.WriteLineAsync($"The subtitle at {path} does not match the subtitle's regex."); - return; + if (token.IsCancellationRequested || path.Split(Path.DirectorySeparatorChar).Contains("Subtitles")) + return; + using IServiceScope serviceScope = _serviceProvider.CreateScope(); + await using ILibraryManager libraryManager = serviceScope.ServiceProvider.GetService(); + + string patern = _config.GetValue("subtitleRegex"); + Regex regex = new(patern, RegexOptions.IgnoreCase); + Match match = regex.Match(path); + + if (!match.Success) + { + await Console.Error.WriteLineAsync($"The subtitle at {path} does not match the subtitle's regex."); + return; + } + + string episodePath = match.Groups["Episode"].Value; + Episode episode = await libraryManager!.Get(x => x.Path.StartsWith(episodePath)); + Track track = new() + { + Type = StreamType.Subtitle, + Language = match.Groups["Language"].Value, + IsDefault = match.Groups["Default"].Value.Length > 0, + IsForced = match.Groups["Forced"].Value.Length > 0, + Codec = SubtitleExtensions[Path.GetExtension(path)], + IsExternal = true, + Path = path, + Episode = episode + }; + + await libraryManager.Create(track); + Console.WriteLine($"Registering subtitle at: {path}."); } - - string episodePath = match.Groups["Episode"].Value; - Episode episode = await libraryManager!.Get(x => x.Path.StartsWith(episodePath)); - - if (episode == null) + catch (ItemNotFound) { await Console.Error.WriteLineAsync($"No episode found for subtitle at: ${path}."); - return; } - - Track track = new(StreamType.Subtitle, - null, - match.Groups["Language"].Value, - match.Groups["Default"].Value.Length > 0, - match.Groups["Forced"].Value.Length > 0, - SubtitleExtensions[Path.GetExtension(path)], - true, - path) + catch (Exception ex) { - Episode = episode - }; - - await libraryManager.Create(track); - Console.WriteLine($"Registering subtitle at: {path}."); + await Console.Error.WriteLineAsync($"Unknown error while registering subtitle: {ex.Message}"); + } } private async Task RegisterFile(string path, string relativePath, Library library, CancellationToken token) @@ -308,22 +312,20 @@ namespace Kyoo.Controllers { if (seasonNumber == -1) return default; - Season season = await libraryManager.Get(show.Slug, seasonNumber); - if (season == null) + try { - season = await _metadataProvider.GetSeason(show, seasonNumber, library); - try - { - await libraryManager.Create(season); - await _thumbnailsManager.Validate(season); - } - catch (DuplicatedItemException) - { - season = await libraryManager.Get(show.Slug, season.SeasonNumber); - } + Season season = await libraryManager.Get(show.Slug, seasonNumber); + season.Show = show; + return season; + } + catch (ItemNotFound) + { + Season season = await _metadataProvider.GetSeason(show, seasonNumber, library); + await libraryManager.CreateIfNotExists(season); + await _thumbnailsManager.Validate(season); + season.Show = show; + return season; } - season.Show = show; - return season; } private async Task GetEpisode(ILibraryManager libraryManager, diff --git a/Kyoo/Views/EpisodeApi.cs b/Kyoo/Views/EpisodeApi.cs index 7bc76af7..9d240e55 100644 --- a/Kyoo/Views/EpisodeApi.cs +++ b/Kyoo/Views/EpisodeApi.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading.Tasks; using Kyoo.CommonApi; using Kyoo.Controllers; +using Kyoo.Models.Exceptions; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Configuration; @@ -163,20 +164,30 @@ namespace Kyoo.Api [Authorize(Policy="Read")] public async Task GetThumb(int id) { - Episode episode = await _libraryManager.Get(id); - if (episode == null) + try + { + Episode episode = await _libraryManager.Get(id); + return _files.FileResult(await _thumbnails.GetEpisodeThumb(episode)); + } + catch (ItemNotFound) + { return NotFound(); - return _files.FileResult(await _thumbnails.GetEpisodeThumb(episode)); + } } [HttpGet("{slug}/thumb")] [Authorize(Policy="Read")] public async Task GetThumb(string slug) { - Episode episode = await _libraryManager.Get(slug); - if (episode == null) + try + { + Episode episode = await _libraryManager.Get(slug); + return _files.FileResult(await _thumbnails.GetEpisodeThumb(episode)); + } + catch (ItemNotFound) + { return NotFound(); - return _files.FileResult(await _thumbnails.GetEpisodeThumb(episode)); + } } } } \ No newline at end of file diff --git a/Kyoo/Views/ShowApi.cs b/Kyoo/Views/ShowApi.cs index fc8c28f7..de623916 100644 --- a/Kyoo/Views/ShowApi.cs +++ b/Kyoo/Views/ShowApi.cs @@ -376,13 +376,18 @@ namespace Kyoo.Api [Authorize(Policy = "Read")] public async Task>> GetFonts(string slug) { - Show show = await _libraryManager.Get(slug); - if (show == null) + try + { + Show show = await _libraryManager.Get(slug); + string path = Path.Combine(_files.GetExtraDirectory(show), "Attachments"); + return (await _files.ListFiles(path)) + .ToDictionary(Path.GetFileNameWithoutExtension, + x => $"{BaseURL}/api/shows/{slug}/fonts/{Path.GetFileName(x)}"); + } + catch (ItemNotFound) + { return NotFound(); - string path = Path.Combine(_files.GetExtraDirectory(show), "Attachments"); - return (await _files.ListFiles(path)) - .ToDictionary(Path.GetFileNameWithoutExtension, - x => $"{BaseURL}/api/shows/{slug}/fonts/{Path.GetFileName(x)}"); + } } [HttpGet("{showSlug}/font/{slug}")] @@ -390,41 +395,61 @@ namespace Kyoo.Api [Authorize(Policy = "Read")] public async Task GetFont(string showSlug, string slug) { - Show show = await _libraryManager.Get(showSlug); - if (show == null) + try + { + Show show = await _libraryManager.Get(showSlug); + string path = Path.Combine(_files.GetExtraDirectory(show), "Attachments", slug); + return _files.FileResult(path); + } + catch (ItemNotFound) + { return NotFound(); - string path = Path.Combine(_files.GetExtraDirectory(show), "Attachments", slug); - return _files.FileResult(path); + } } [HttpGet("{slug}/poster")] [Authorize(Policy = "Read")] public async Task GetPoster(string slug) { - Show show = await _libraryManager.Get(slug); - if (show == null) + try + { + Show show = await _libraryManager.Get(slug); + return _files.FileResult(await _thumbs.GetShowPoster(show)); + } + catch (ItemNotFound) + { return NotFound(); - return _files.FileResult(await _thumbs.GetShowPoster(show)); + } } [HttpGet("{slug}/logo")] [Authorize(Policy="Read")] public async Task GetLogo(string slug) { - Show show = await _libraryManager.Get(slug); - if (show == null) + try + { + Show show = await _libraryManager.Get(slug); + return _files.FileResult(await _thumbs.GetShowLogo(show)); + } + catch (ItemNotFound) + { return NotFound(); - return _files.FileResult(await _thumbs.GetShowLogo(show)); + } } [HttpGet("{slug}/backdrop")] [Authorize(Policy="Read")] public async Task GetBackdrop(string slug) { - Show show = await _libraryManager.Get(slug); - if (show == null) + try + { + Show show = await _libraryManager.Get(slug); + return _files.FileResult(await _thumbs.GetShowBackdrop(show)); + } + catch (ItemNotFound) + { return NotFound(); - return _files.FileResult(await _thumbs.GetShowBackdrop(show)); + } } } } diff --git a/Kyoo/Views/SubtitleApi.cs b/Kyoo/Views/SubtitleApi.cs index 73f151ee..f1a38ff3 100644 --- a/Kyoo/Views/SubtitleApi.cs +++ b/Kyoo/Views/SubtitleApi.cs @@ -30,7 +30,7 @@ namespace Kyoo.Api Track subtitle; try { - subtitle = await _libraryManager.GetTrack(slug, StreamType.Subtitle); + subtitle = await _libraryManager.Get(slug, StreamType.Subtitle); } catch (ArgumentException ex) { diff --git a/Kyoo/Views/WatchApi.cs b/Kyoo/Views/WatchApi.cs index 9d95d9ae..cd9327ae 100644 --- a/Kyoo/Views/WatchApi.cs +++ b/Kyoo/Views/WatchApi.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; using Kyoo.Controllers; using Kyoo.Models; +using Kyoo.Models.Exceptions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -21,10 +22,15 @@ namespace Kyoo.Api [Authorize(Policy="Read")] public async Task> GetWatchItem(string slug) { - Episode item = await _libraryManager.Get(slug); - if (item == null) + try + { + Episode item = await _libraryManager.Get(slug); + return await WatchItem.FromEpisode(item, _libraryManager); + } + catch (ItemNotFound) + { return NotFound(); - return await WatchItem.FromEpisode(item, _libraryManager); + } } } }