Merge pull request #32 from AnonymusRaccoon/the-moviedb

Adding TheMovieDB provider
This commit is contained in:
Zoe Roux 2021-08-06 13:01:00 +02:00 committed by GitHub
commit 387bf4c34d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
116 changed files with 7577 additions and 4844 deletions

View File

@ -24,7 +24,7 @@ jobs:
- name: Build
run: |
dotnet build --no-restore '-p:SkipWebApp=true;SkipTranscoder=true' -p:CopyLocalLockFileAssemblies=true
cp ./Kyoo.Common/bin/Debug/net5.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll ./Kyoo.Tests/bin/Debug/net5.0/
cp ./Kyoo.Common/bin/Debug/net5.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll ./tests/Kyoo.Tests/bin/Debug/net5.0/
- name: Test
run: dotnet test --no-build '-p:CollectCoverage=true;CoverletOutputFormat=opencover'
env:
@ -33,7 +33,7 @@ jobs:
POSTGRES_PASSWORD: postgres
- name: Sanitize coverage output
if: ${{ always() }}
run: sed -i "s'$(pwd)'.'" Kyoo.Tests/coverage.opencover.xml
run: sed -i "s'$(pwd)'.'" tests/Kyoo.Tests/coverage.opencover.xml
- name: Upload coverage report
if: ${{ always() }}
uses: actions/upload-artifact@v2

View File

@ -2,11 +2,25 @@ using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Kyoo.Models;
using Microsoft.AspNetCore.Mvc;
namespace Kyoo.Controllers
{
/// <summary>
/// A class wrapping a value that will be set after the completion of the task it is related to.
/// </summary>
/// <remarks>
/// This class replace the use of an out parameter on a task since tasks and out can't be combined.
/// </remarks>
/// <typeparam name="T">The type of the value</typeparam>
public class AsyncRef<T>
{
/// <summary>
/// The value that will be set before the completion of the task.
/// </summary>
public T Value { get; set; }
}
/// <summary>
/// A service to abstract the file system to allow custom file systems (like distant file systems or external providers)
/// </summary>
@ -43,6 +57,16 @@ namespace Kyoo.Controllers
/// <returns>A reader to read the file.</returns>
public Task<Stream> GetReader([NotNull] string path);
/// <summary>
/// Read a file present at <paramref name="path"/>. The reader can be used in an arbitrary context.
/// To return files from an http endpoint, use <see cref="FileResult"/>.
/// </summary>
/// <param name="path">The path of the file</param>
/// <param name="mime">The mime type of the opened file.</param>
/// <exception cref="FileNotFoundException">If the file could not be found.</exception>
/// <returns>A reader to read the file.</returns>
public Task<Stream> GetReader([NotNull] string path, AsyncRef<string> mime);
/// <summary>
/// Create a new file at <paramref name="path"></paramref>.
/// </summary>
@ -81,12 +105,13 @@ namespace Kyoo.Controllers
public Task<bool> Exists([NotNull] string path);
/// <summary>
/// Get the extra directory of a show.
/// Get the extra directory of a resource <typeparamref name="T"/>.
/// This method is in this system to allow a filesystem to use a different metadata policy for one.
/// It can be useful if the filesystem is readonly.
/// </summary>
/// <param name="show">The show to proceed</param>
/// <returns>The extra directory of the show</returns>
public string GetExtraDirectory([NotNull] Show show);
/// <param name="resource">The resource to proceed</param>
/// <typeparam name="T">The type of the resource.</typeparam>
/// <returns>The extra directory of the resource.</returns>
public Task<string> GetExtraDirectory<T>([NotNull] T resource);
}
}

View File

@ -225,42 +225,51 @@ namespace Kyoo.Controllers
/// </summary>
/// <param name="obj">The source object.</param>
/// <param name="member">A getter function for the member to load</param>
/// <param name="force">
/// <c>true</c> if you want to load the relation even if it is not null, <c>false</c> otherwise.
/// </param>
/// <typeparam name="T">The type of the source object</typeparam>
/// <typeparam name="T2">The related resource's type</typeparam>
/// <returns>The param <see cref="obj"/></returns>
/// <seealso cref="Load{T,T2}(T,System.Linq.Expressions.Expression{System.Func{T,System.Collections.Generic.ICollection{T2}}})"/>
/// <seealso cref="Load{T}(T, System.String)"/>
/// <seealso cref="Load(IResource, string)"/>
Task<T> Load<T, T2>([NotNull] T obj, Expression<Func<T, T2>> member)
/// <seealso cref="Load{T,T2}(T, System.Linq.Expressions.Expression{System.Func{T,System.Collections.Generic.ICollection{T2}}}, bool)"/>
/// <seealso cref="Load{T}(T, System.String, bool)"/>
/// <seealso cref="Load(IResource, string, bool)"/>
Task<T> Load<T, T2>([NotNull] T obj, Expression<Func<T, T2>> member, bool force = false)
where T : class, IResource
where T2 : class, IResource, new();
where T2 : class, IResource;
/// <summary>
/// Load a collection of related resource
/// </summary>
/// <param name="obj">The source object.</param>
/// <param name="member">A getter function for the member to load</param>
/// <param name="force">
/// <c>true</c> if you want to load the relation even if it is not null, <c>false</c> otherwise.
/// </param>
/// <typeparam name="T">The type of the source object</typeparam>
/// <typeparam name="T2">The related resource's type</typeparam>
/// <returns>The param <see cref="obj"/></returns>
/// <seealso cref="Load{T,T2}(T,System.Linq.Expressions.Expression{System.Func{T,T2}})"/>
/// <seealso cref="Load{T}(T, System.String)"/>
/// <seealso cref="Load(IResource, string)"/>
Task<T> Load<T, T2>([NotNull] T obj, Expression<Func<T, ICollection<T2>>> member)
/// <seealso cref="Load{T,T2}(T, System.Linq.Expressions.Expression{System.Func{T,T2}}, bool)"/>
/// <seealso cref="Load{T}(T, System.String, bool)"/>
/// <seealso cref="Load(IResource, string, bool)"/>
Task<T> Load<T, T2>([NotNull] T obj, Expression<Func<T, ICollection<T2>>> member, bool force = false)
where T : class, IResource
where T2 : class, new();
where T2 : class;
/// <summary>
/// Load a related resource by it's name
/// </summary>
/// <param name="obj">The source object.</param>
/// <param name="memberName">The name of the resource to load (case sensitive)</param>
/// <param name="force">
/// <c>true</c> if you want to load the relation even if it is not null, <c>false</c> otherwise.
/// </param>
/// <typeparam name="T">The type of the source object</typeparam>
/// <returns>The param <see cref="obj"/></returns>
/// <seealso cref="Load{T,T2}(T,System.Linq.Expressions.Expression{System.Func{T,T2}})"/>
/// <seealso cref="Load{T,T2}(T,System.Linq.Expressions.Expression{System.Func{T,System.Collections.Generic.ICollection{T2}}})"/>
/// <seealso cref="Load(IResource, string)"/>
Task<T> Load<T>([NotNull] T obj, string memberName)
/// <seealso cref="Load{T,T2}(T, System.Linq.Expressions.Expression{System.Func{T,T2}}, bool)"/>
/// <seealso cref="Load{T,T2}(T, System.Linq.Expressions.Expression{System.Func{T,System.Collections.Generic.ICollection{T2}}}, bool)"/>
/// <seealso cref="Load(IResource, string, bool)"/>
Task<T> Load<T>([NotNull] T obj, string memberName, bool force = false)
where T : class, IResource;
/// <summary>
@ -268,10 +277,13 @@ namespace Kyoo.Controllers
/// </summary>
/// <param name="obj">The source object.</param>
/// <param name="memberName">The name of the resource to load (case sensitive)</param>
/// <seealso cref="Load{T,T2}(T,System.Linq.Expressions.Expression{System.Func{T,T2}})"/>
/// <seealso cref="Load{T,T2}(T,System.Linq.Expressions.Expression{System.Func{T,System.Collections.Generic.ICollection{T2}}})"/>
/// <seealso cref="Load{T}(T, System.String)"/>
Task Load([NotNull] IResource obj, string memberName);
/// <param name="force">
/// <c>true</c> if you want to load the relation even if it is not null, <c>false</c> otherwise.
/// </param>
/// <seealso cref="Load{T,T2}(T, System.Linq.Expressions.Expression{System.Func{T,T2}}, bool)"/>
/// <seealso cref="Load{T,T2}(T, System.Linq.Expressions.Expression{System.Func{T,System.Collections.Generic.ICollection{T2}}}, bool)"/>
/// <seealso cref="Load{T}(T, System.String, bool)"/>
Task Load([NotNull] IResource obj, string memberName, bool force = false);
/// <summary>
/// Get items (A wrapper arround shows or collections) from a library.

View File

@ -590,10 +590,10 @@ namespace Kyoo.Controllers
/// <param name="limit">Pagination information (where to start and how many to get)</param>
/// <typeparam name="T">The type of metadata to retrieve</typeparam>
/// <returns>A filtered list of external ids.</returns>
Task<ICollection<MetadataID<T>>> GetMetadataID<T>(Expression<Func<MetadataID<T>, bool>> where = null,
Sort<MetadataID<T>> sort = default,
Task<ICollection<MetadataID>> GetMetadataID<T>(Expression<Func<MetadataID, bool>> where = null,
Sort<MetadataID> sort = default,
Pagination limit = default)
where T : class, IResource;
where T : class, IMetadata;
/// <summary>
/// Get a list of external ids that match all filters
@ -602,11 +602,11 @@ namespace Kyoo.Controllers
/// <param name="sort">A sort by expression</param>
/// <param name="limit">Pagination information (where to start and how many to get)</param>
/// <returns>A filtered list of external ids.</returns>
Task<ICollection<MetadataID<T>>> GetMetadataID<T>([Optional] Expression<Func<MetadataID<T>, bool>> where,
Expression<Func<MetadataID<T>, object>> sort,
Task<ICollection<MetadataID>> GetMetadataID<T>([Optional] Expression<Func<MetadataID, bool>> where,
Expression<Func<MetadataID, object>> sort,
Pagination limit = default
) where T : class, IResource
=> GetMetadataID(where, new Sort<MetadataID<T>>(sort), limit);
) where T : class, IMetadata
=> GetMetadataID<T>(where, new Sort<MetadataID>(sort), limit);
}
/// <summary>

View File

@ -1,5 +1,4 @@
using System;
using Kyoo.Models;
using Kyoo.Models;
using System.Threading.Tasks;
using JetBrains.Annotations;
@ -23,37 +22,17 @@ namespace Kyoo.Controllers
/// <typeparam name="T">The type of the item</typeparam>
/// <returns><c>true</c> if an image has been downloaded, <c>false</c> otherwise.</returns>
Task<bool> DownloadImages<T>([NotNull] T item, bool alwaysDownload = false)
where T : IResource;
where T : IThumbnails;
/// <summary>
/// Retrieve the local path of the poster of the given item.
/// Retrieve the local path of an image of the given item.
/// </summary>
/// <param name="item">The item to retrieve the poster from.</param>
/// <param name="imageID">The ID of the image. See <see cref="Images"/> for values.</param>
/// <typeparam name="T">The type of the item</typeparam>
/// <exception cref="NotSupportedException">If the type does not have a poster</exception>
/// <returns>The path of the poster for the given resource (it might or might not exists).</returns>
Task<string> GetPoster<T>([NotNull] T item)
where T : IResource;
/// <summary>
/// Retrieve the local path of the logo of the given item.
/// </summary>
/// <param name="item">The item to retrieve the logo from.</param>
/// <typeparam name="T">The type of the item</typeparam>
/// <exception cref="NotSupportedException">If the type does not have a logo</exception>
/// <returns>The path of the logo for the given resource (it might or might not exists).</returns>
Task<string> GetLogo<T>([NotNull] T item)
where T : IResource;
/// <summary>
/// Retrieve the local path of the thumbnail of the given item.
/// </summary>
/// <param name="item">The item to retrieve the thumbnail from.</param>
/// <typeparam name="T">The type of the item</typeparam>
/// <exception cref="NotSupportedException">If the type does not have a thumbnail</exception>
/// <returns>The path of the thumbnail for the given resource (it might or might not exists).</returns>
Task<string> GetThumbnail<T>([NotNull] T item)
where T : IResource;
/// <returns>The path of the image for the given resource or null if it does not exists.</returns>
Task<string> GetImagePath<T>([NotNull] T item, int imageID)
where T : IThumbnails;
}
}

View File

@ -162,34 +162,6 @@ namespace Kyoo.Controllers
return await EpisodeRepository.GetOrDefault(showSlug, seasonNumber, episodeNumber);
}
/// <inheritdoc />
public Task<T> Load<T, T2>(T obj, Expression<Func<T, T2>> member)
where T : class, IResource
where T2 : class, IResource, new()
{
if (member == null)
throw new ArgumentNullException(nameof(member));
return Load(obj, Utility.GetPropertyName(member));
}
/// <inheritdoc />
public Task<T> Load<T, T2>(T obj, Expression<Func<T, ICollection<T2>>> member)
where T : class, IResource
where T2 : class, new()
{
if (member == null)
throw new ArgumentNullException(nameof(member));
return Load(obj, Utility.GetPropertyName(member));
}
/// <inheritdoc />
public async Task<T> Load<T>(T obj, string memberName)
where T : class, IResource
{
await Load(obj as IResource, memberName);
return obj;
}
/// <summary>
/// Set relations between to objects.
/// </summary>
@ -211,11 +183,46 @@ namespace Kyoo.Controllers
}
/// <inheritdoc />
public Task Load(IResource obj, string memberName)
public Task<T> Load<T, T2>(T obj, Expression<Func<T, T2>> member, bool force = false)
where T : class, IResource
where T2 : class, IResource
{
if (member == null)
throw new ArgumentNullException(nameof(member));
return Load(obj, Utility.GetPropertyName(member), force);
}
/// <inheritdoc />
public Task<T> Load<T, T2>(T obj, Expression<Func<T, ICollection<T2>>> member, bool force = false)
where T : class, IResource
where T2 : class
{
if (member == null)
throw new ArgumentNullException(nameof(member));
return Load(obj, Utility.GetPropertyName(member), force);
}
/// <inheritdoc />
public async Task<T> Load<T>(T obj, string memberName, bool force = false)
where T : class, IResource
{
await Load(obj as IResource, memberName, force);
return obj;
}
/// <inheritdoc />
public Task Load(IResource obj, string memberName, bool force = false)
{
if (obj == null)
throw new ArgumentNullException(nameof(obj));
object existingValue = obj.GetType()
.GetProperties()
.FirstOrDefault(x => string.Equals(x.Name, memberName, StringComparison.InvariantCultureIgnoreCase))
?.GetValue(obj);
if (existingValue != null && !force)
return Task.CompletedTask;
return (obj, member: memberName) switch
{
(Library l, nameof(Library.Providers)) => ProviderRepository
@ -231,7 +238,12 @@ namespace Kyoo.Controllers
.Then(x => l.Collections = x),
(Collection c, nameof(Library.Shows)) => ShowRepository
(Collection c, nameof(Collection.ExternalIDs)) => SetRelation(c,
ProviderRepository.GetMetadataID<Collection>(x => x.ResourceID == obj.ID),
(x, y) => x.ExternalIDs = y,
(x, y) => { x.ResourceID = y.ID; }),
(Collection c, nameof(Collection.Shows)) => ShowRepository
.GetAll(x => x.Collections.Any(y => y.ID == obj.ID))
.Then(x => c.Shows = x),
@ -241,9 +253,9 @@ namespace Kyoo.Controllers
(Show s, nameof(Show.ExternalIDs)) => SetRelation(s,
ProviderRepository.GetMetadataID<Show>(x => x.FirstID == obj.ID),
ProviderRepository.GetMetadataID<Show>(x => x.ResourceID == obj.ID),
(x, y) => x.ExternalIDs = y,
(x, y) => { x.First = y; x.FirstID = y.ID; }),
(x, y) => { x.ResourceID = y.ID; }),
(Show s, nameof(Show.Genres)) => GenreRepository
.GetAll(x => x.Shows.Any(y => y.ID == obj.ID))
@ -281,9 +293,9 @@ namespace Kyoo.Controllers
(Season s, nameof(Season.ExternalIDs)) => SetRelation(s,
ProviderRepository.GetMetadataID<Season>(x => x.FirstID == obj.ID),
ProviderRepository.GetMetadataID<Season>(x => x.ResourceID == obj.ID),
(x, y) => x.ExternalIDs = y,
(x, y) => { x.First = y; x.FirstID = y.ID; }),
(x, y) => { x.ResourceID = y.ID; }),
(Season s, nameof(Season.Episodes)) => SetRelation(s,
EpisodeRepository.GetAll(x => x.Season.ID == obj.ID),
@ -300,9 +312,9 @@ namespace Kyoo.Controllers
(Episode e, nameof(Episode.ExternalIDs)) => SetRelation(e,
ProviderRepository.GetMetadataID<Episode>(x => x.FirstID == obj.ID),
ProviderRepository.GetMetadataID<Episode>(x => x.ResourceID == obj.ID),
(x, y) => x.ExternalIDs = y,
(x, y) => { x.First = y; x.FirstID = y.ID; }),
(x, y) => { x.ResourceID = y.ID; }),
(Episode e, nameof(Episode.Tracks)) => SetRelation(e,
TrackRepository.GetAll(x => x.Episode.ID == obj.ID),
@ -344,11 +356,16 @@ namespace Kyoo.Controllers
.GetAll(x => x.Studio.ID == obj.ID)
.Then(x => s.Shows = x),
(Studio s, nameof(Studio.ExternalIDs)) => SetRelation(s,
ProviderRepository.GetMetadataID<Studio>(x => x.ResourceID == obj.ID),
(x, y) => x.ExternalIDs = y,
(x, y) => { x.ResourceID = y.ID; }),
(People p, nameof(People.ExternalIDs)) => SetRelation(p,
ProviderRepository.GetMetadataID<People>(x => x.FirstID == obj.ID),
ProviderRepository.GetMetadataID<People>(x => x.ResourceID == obj.ID),
(x, y) => x.ExternalIDs = y,
(x, y) => { x.First = y; x.FirstID = y.ID; }),
(x, y) => { x.ResourceID = y.ID; }),
(People p, nameof(People.Roles)) => PeopleRepository
.GetFromPeople(obj.ID)

View File

@ -16,13 +16,11 @@
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<LangVersion>default</LangVersion>
<DefineConstants>ENABLE_INTERNAL_LINKS</DefineConstants>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Autofac" Version="6.2.0" />
<PackageReference Include="JetBrains.Annotations" Version="2021.1.0" />
<PackageReference Include="JetBrains.Annotations" Version="2021.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" />

View File

@ -1,13 +0,0 @@
using System;
using JetBrains.Annotations;
using Kyoo.Models.Attributes;
namespace Kyoo.Common.Models.Attributes
{
/// <summary>
/// An attribute to mark Link properties on resource.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
[MeansImplicitUse]
public class LinkAttribute : SerializeIgnoreAttribute { }
}

View File

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using Kyoo.Models.Attributes;
@ -18,7 +19,7 @@ namespace Kyoo.Models
/// A type union between <see cref="Show"/> and <see cref="Collection"/>.
/// This is used to list content put inside a library.
/// </summary>
public class LibraryItem : IResource
public class LibraryItem : IResource, IThumbnails
{
/// <inheritdoc />
public int ID { get; set; }
@ -53,12 +54,16 @@ namespace Kyoo.Models
/// </summary>
public DateTime? EndAir { get; set; }
/// <inheritdoc />
public Dictionary<int, string> Images { get; set; }
/// <summary>
/// The path of this item's poster.
/// By default, the http path for this poster is returned from the public API.
/// This can be disabled using the internal query flag.
/// </summary>
[SerializeAs("{HOST}/api/{Type:l}/{Slug}/poster")] public string Poster { get; set; }
[SerializeAs("{HOST}/api/{Type:l}/{Slug}/poster")]
public string Poster => Images?.GetValueOrDefault(Models.Images.Poster);
/// <summary>
/// The type of this item (ether a collection, a show or a movie).
@ -84,7 +89,7 @@ namespace Kyoo.Models
Status = show.Status;
StartAir = show.StartAir;
EndAir = show.EndAir;
Poster = show.Poster;
Images = show.Images;
Type = show.IsMovie ? ItemType.Movie : ItemType.Show;
}
@ -101,7 +106,7 @@ namespace Kyoo.Models
Status = Models.Status.Unknown;
StartAir = null;
EndAir = null;
Poster = collection.Poster;
Images = collection.Images;
Type = ItemType.Collection;
}
@ -117,7 +122,7 @@ namespace Kyoo.Models
Status = x.Status,
StartAir = x.StartAir,
EndAir = x.EndAir,
Poster= x.Poster,
Images = x.Images,
Type = x.IsMovie ? ItemType.Movie : ItemType.Show
};
@ -133,7 +138,7 @@ namespace Kyoo.Models
Status = Models.Status.Unknown,
StartAir = null,
EndAir = null,
Poster = x.Poster,
Images = x.Images,
Type = ItemType.Collection
};
}

View File

@ -1,163 +0,0 @@
using System;
using System.Linq.Expressions;
using Kyoo.Models.Attributes;
namespace Kyoo.Models
{
/// <summary>
/// A class representing a link between two resources.
/// </summary>
/// <remarks>
/// Links should only be used on the data layer and not on other application code.
/// </remarks>
public class Link
{
/// <summary>
/// The ID of the first item of the link.
/// The first item of the link should be the one to own the link.
/// </summary>
public int FirstID { get; set; }
/// <summary>
/// The ID of the second item of this link
/// The second item of the link should be the owned resource.
/// </summary>
public int SecondID { get; set; }
/// <summary>
/// Create a new typeless <see cref="Link"/>.
/// </summary>
public Link() {}
/// <summary>
/// Create a new typeless <see cref="Link"/> with two IDs.
/// </summary>
/// <param name="firstID">The ID of the first resource</param>
/// <param name="secondID">The ID of the second resource</param>
public Link(int firstID, int secondID)
{
FirstID = firstID;
SecondID = secondID;
}
/// <summary>
/// Create a new typeless <see cref="Link"/> between two resources.
/// </summary>
/// <param name="first">The first resource</param>
/// <param name="second">The second resource</param>
public Link(IResource first, IResource second)
{
FirstID = first.ID;
SecondID = second.ID;
}
/// <summary>
/// Create a new typed link between two resources.
/// This method can be used instead of the constructor to make use of generic parameters deduction.
/// </summary>
/// <param name="first">The first resource</param>
/// <param name="second">The second resource</param>
/// <typeparam name="T">The type of the first resource</typeparam>
/// <typeparam name="T2">The type of the second resource</typeparam>
/// <returns>A newly created typed link with both resources</returns>
public static Link<T, T2> Create<T, T2>(T first, T2 second)
where T : class, IResource
where T2 : class, IResource
{
return new(first, second);
}
/// <summary>
/// Create a new typed link between two resources without storing references to resources.
/// This is the same as <see cref="Create{T,T2}"/> but this method does not set <see cref="Link{T1,T2}.First"/>
/// and <see cref="Link{T1,T2}.Second"/> fields. Only IDs are stored and not references.
/// </summary>
/// <param name="first">The first resource</param>
/// <param name="second">The second resource</param>
/// <typeparam name="T">The type of the first resource</typeparam>
/// <typeparam name="T2">The type of the second resource</typeparam>
/// <returns>A newly created typed link with both resources</returns>
public static Link<T, T2> UCreate<T, T2>(T first, T2 second)
where T : class, IResource
where T2 : class, IResource
{
return new(first, second, true);
}
/// <summary>
/// The expression to retrieve the unique ID of a Link. This is an aggregate of the two resources IDs.
/// </summary>
public static Expression<Func<Link, object>> PrimaryKey
{
get
{
return x => new {First = x.FirstID, Second = x.SecondID};
}
}
}
/// <summary>
/// A strongly typed link between two resources.
/// </summary>
/// <typeparam name="T1">The type of the first resource</typeparam>
/// <typeparam name="T2">The type of the second resource</typeparam>
public class Link<T1, T2> : Link
where T1 : class, IResource
where T2 : class, IResource
{
/// <summary>
/// A reference of the first resource.
/// </summary>
[SerializeIgnore] public T1 First { get; set; }
/// <summary>
/// A reference to the second resource.
/// </summary>
[SerializeIgnore] public T2 Second { get; set; }
/// <summary>
/// Create a new, empty, typed <see cref="Link{T1,T2}"/>.
/// </summary>
public Link() {}
/// <summary>
/// Create a new typed link with two resources.
/// </summary>
/// <param name="first">The first resource</param>
/// <param name="second">The second resource</param>
/// <param name="privateItems">
/// True if no reference to resources should be kept, false otherwise.
/// The default is false (references are kept).
/// </param>
public Link(T1 first, T2 second, bool privateItems = false)
: base(first, second)
{
if (privateItems)
return;
First = first;
Second = second;
}
/// <summary>
/// Create a new typed link with IDs only.
/// </summary>
/// <param name="firstID">The ID of the first resource</param>
/// <param name="secondID">The ID of the second resource</param>
public Link(int firstID, int secondID)
: base(firstID, secondID)
{ }
/// <summary>
/// The expression to retrieve the unique ID of a typed Link. This is an aggregate of the two resources IDs.
/// </summary>
public new static Expression<Func<Link<T1, T2>, object>> PrimaryKey
{
get
{
return x => new {First = x.FirstID, Second = x.SecondID};
}
}
}
}

View File

@ -1,15 +1,29 @@
using System;
using System.Linq.Expressions;
using Kyoo.Models.Attributes;
namespace Kyoo.Models
{
/// <summary>
/// ID and link of an item on an external provider.
/// </summary>
/// <typeparam name="T"></typeparam>
public class MetadataID<T> : Link<T, Provider>
where T : class, IResource
public class MetadataID
{
/// <summary>
/// The ID of the resource which possess the metadata.
/// </summary>
[SerializeIgnore] public int ResourceID { get; set; }
/// <summary>
/// The ID of the provider.
/// </summary>
[SerializeIgnore] public int ProviderID { get; set; }
/// <summary>
/// The provider that can do something with this ID.
/// </summary>
public Provider Provider { get; set; }
/// <summary>
/// The ID of the resource on the external provider.
/// </summary>
@ -20,21 +34,12 @@ namespace Kyoo.Models
/// </summary>
public string Link { get; set; }
/// <summary>
/// A shortcut to access the provider of this metadata.
/// Unlike the <see cref="Link{T, T2}.Second"/> property, this is serializable.
/// </summary>
public Provider Provider => Second;
/// <summary>
/// The expression to retrieve the unique ID of a MetadataID. This is an aggregate of the two resources IDs.
/// </summary>
public new static Expression<Func<MetadataID<T>, object>> PrimaryKey
public static Expression<Func<MetadataID, object>> PrimaryKey
{
get
{
return x => new {First = x.FirstID, Second = x.SecondID};
}
get { return x => new { First = x.ResourceID, Second = x.ProviderID }; }
}
}
}

View File

@ -17,8 +17,8 @@ namespace Kyoo.Models
public string Slug => ForPeople ? Show.Slug : People.Slug;
/// <summary>
/// Should this role be used as a Show substitute (the value is <c>false</c>) or
/// as a People substitute (the value is <c>true</c>).
/// Should this role be used as a Show substitute (the value is <c>true</c>) or
/// as a People substitute (the value is <c>false</c>).
/// </summary>
public bool ForPeople { get; set; }

View File

@ -1,5 +1,5 @@
using System.Collections.Generic;
using Kyoo.Common.Models.Attributes;
using System;
using System.Collections.Generic;
using Kyoo.Models.Attributes;
namespace Kyoo.Models
@ -8,7 +8,7 @@ namespace Kyoo.Models
/// A class representing collections of <see cref="Show"/>.
/// A collection can also be stored in a <see cref="Library"/>.
/// </summary>
public class Collection : IResource
public class Collection : IResource, IMetadata, IThumbnails
{
/// <inheritdoc />
public int ID { get; set; }
@ -21,12 +21,17 @@ namespace Kyoo.Models
/// </summary>
public string Name { get; set; }
/// <inheritdoc />
public Dictionary<int, string> Images { get; set; }
/// <summary>
/// The path of this poster.
/// By default, the http path for this poster is returned from the public API.
/// This can be disabled using the internal query flag.
/// </summary>
[SerializeAs("{HOST}/api/collection/{Slug}/poster")] public string Poster { get; set; }
[SerializeAs("{HOST}/api/collection/{Slug}/poster")]
[Obsolete("Use Images instead of this, this is only kept for the API response.")]
public string Poster => Images?.GetValueOrDefault(Models.Images.Poster);
/// <summary>
/// The description of this collection.
@ -43,17 +48,7 @@ namespace Kyoo.Models
/// </summary>
[LoadableRelation] public ICollection<Library> Libraries { get; set; }
#if ENABLE_INTERNAL_LINKS
/// <summary>
/// The internal link between this collection and shows in the <see cref="Shows"/> list.
/// </summary>
[Link] public ICollection<Link<Collection, Show>> ShowLinks { get; set; }
/// <summary>
/// The internal link between this collection and libraries in the <see cref="Libraries"/> list.
/// </summary>
[Link] public ICollection<Link<Library, Collection>> LibraryLinks { get; set; }
#endif
/// <inheritdoc />
[EditableRelation] [LoadableRelation] public ICollection<MetadataID> ExternalIDs { get; set; }
}
}

View File

@ -10,7 +10,7 @@ namespace Kyoo.Models
/// <summary>
/// A class to represent a single show's episode.
/// </summary>
public class Episode : IResource
public class Episode : IResource, IMetadata, IThumbnails
{
/// <inheritdoc />
public int ID { get; set; }
@ -74,9 +74,13 @@ namespace Kyoo.Models
/// </summary>
[SerializeIgnore] public int? SeasonID { get; set; }
/// <summary>
/// The season that contains this episode. This must be explicitly loaded via a call to <see cref="ILibraryManager.Load"/>.
/// This can be null if the season is unknown and the episode is only identified by it's <see cref="AbsoluteNumber"/>.
/// The season that contains this episode.
/// This must be explicitly loaded via a call to <see cref="ILibraryManager.Load"/>.
/// </summary>
/// <remarks>
/// This can be null if the season is unknown and the episode is only identified
/// by it's <see cref="AbsoluteNumber"/>.
/// </remarks>
[LoadableRelation(nameof(SeasonID))] public Season Season { get; set; }
/// <summary>
@ -85,7 +89,7 @@ namespace Kyoo.Models
public int? SeasonNumber { get; set; }
/// <summary>
/// The number of this episode is it's season.
/// The number of this episode in it's season.
/// </summary>
public int? EpisodeNumber { get; set; }
@ -99,12 +103,17 @@ namespace Kyoo.Models
/// </summary>
[SerializeIgnore] public string Path { get; set; }
/// <inheritdoc />
public Dictionary<int, string> Images { get; set; }
/// <summary>
/// The path of this episode's thumbnail.
/// By default, the http path for the thumbnail is returned from the public API.
/// This can be disabled using the internal query flag.
/// </summary>
[SerializeAs("{HOST}/api/episodes/{Slug}/thumb")] public string Thumb { get; set; }
[SerializeAs("{HOST}/api/episodes/{Slug}/thumbnail")]
[Obsolete("Use Images instead of this, this is only kept for the API response.")]
public string Thumb => Images?.GetValueOrDefault(Models.Images.Thumbnail);
/// <summary>
/// The title of this episode.
@ -121,10 +130,8 @@ namespace Kyoo.Models
/// </summary>
public DateTime? ReleaseDate { get; set; }
/// <summary>
/// The link to metadata providers that this episode has. See <see cref="MetadataID{T}"/> for more information.
/// </summary>
[EditableRelation] [LoadableRelation] public ICollection<MetadataID<Episode>> ExternalIDs { get; set; }
/// <inheritdoc />
[EditableRelation] [LoadableRelation] public ICollection<MetadataID> ExternalIDs { get; set; }
/// <summary>
/// The list of tracks this episode has. This lists video, audio and subtitles available.

View File

@ -1,5 +1,4 @@
using System.Collections.Generic;
using Kyoo.Common.Models.Attributes;
using Kyoo.Models.Attributes;
namespace Kyoo.Models
@ -25,13 +24,6 @@ namespace Kyoo.Models
/// </summary>
[LoadableRelation] public ICollection<Show> Shows { get; set; }
#if ENABLE_INTERNAL_LINKS
/// <summary>
/// The internal link between this genre and shows in the <see cref="Shows"/> list.
/// </summary>
[Link] public ICollection<Link<Show, Genre>> ShowLinks { get; set; }
#endif
/// <summary>
/// Create a new, empty <see cref="Genre"/>.
/// </summary>

View File

@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using Kyoo.Models.Attributes;
namespace Kyoo.Models
{
/// <summary>
/// An interface applied to resources containing external metadata.
/// </summary>
public interface IMetadata
{
/// <summary>
/// The link to metadata providers that this show has. See <see cref="MetadataID"/> for more information.
/// </summary>
[EditableRelation] [LoadableRelation]
public ICollection<MetadataID> ExternalIDs { get; set; }
}
/// <summary>
/// A static class containing extensions method for every <see cref="IMetadata"/> class.
/// This allow one to use metadata more easily.
/// </summary>
public static class MetadataExtension
{
/// <summary>
/// Retrieve the internal provider's ID of an item using it's provider slug.
/// </summary>
/// <remarks>
/// This method will never return anything if the <see cref="IMetadata.ExternalIDs"/> are not loaded.
/// </remarks>
/// <param name="self">An instance of <see cref="IMetadata"/> to retrieve the ID from.</param>
/// <param name="provider">The slug of the provider</param>
/// <returns>The <see cref="MetadataID.DataID"/> field of the asked provider.</returns>
[CanBeNull]
public static string GetID(this IMetadata self, string provider)
{
return self.ExternalIDs?.FirstOrDefault(x => x.Provider.Slug == provider)?.DataID;
}
/// <summary>
/// Retrieve the internal provider's ID of an item using it's provider slug.
/// If the ID could be found, it is converted to the <typeparamref name="T"/> type and <c>true</c> is returned.
/// </summary>
/// <remarks>
/// This method will never succeed if the <see cref="IMetadata.ExternalIDs"/> are not loaded.
/// </remarks>
/// <param name="self">An instance of <see cref="IMetadata"/> to retrieve the ID from.</param>
/// <param name="provider">The slug of the provider</param>
/// <param name="id">
/// The <see cref="MetadataID.DataID"/> field of the asked provider parsed
/// and converted to the <typeparamref name="T"/> type.
/// It is only relevant if this method returns <c>true</c>.
/// </param>
/// <typeparam name="T">The type to convert the <see cref="MetadataID.DataID"/> to.</typeparam>
/// <returns><c>true</c> if this method succeeded, <c>false</c> otherwise.</returns>
public static bool TryGetID<T>(this IMetadata self, string provider, out T id)
{
string dataID = self.ExternalIDs?.FirstOrDefault(x => x.Provider.Slug == provider)?.DataID;
if (dataID == null)
{
id = default;
return false;
}
try
{
id = (T)Convert.ChangeType(dataID, typeof(T));
}
catch
{
id = default;
return false;
}
return true;
}
}
}

View File

@ -0,0 +1,49 @@
using System.Collections.Generic;
using Kyoo.Controllers;
namespace Kyoo.Models
{
/// <summary>
/// An interface representing items that contains images (like posters, thumbnails, logo, banners...)
/// </summary>
public interface IThumbnails
{
/// <summary>
/// The list of images mapped to a certain index.
/// The string value should be a path supported by the <see cref="IFileSystem"/>.
/// </summary>
/// <remarks>
/// An arbitrary index should not be used, instead use indexes from <see cref="Models.Images"/>
/// </remarks>
public Dictionary<int, string> Images { get; set; }
// TODO remove Posters properties add them via the json serializer for every IThumbnails
}
/// <summary>
/// A class containing constant values for images. To be used as index of a <see cref="IThumbnails.Images"/>.
/// </summary>
public static class Images
{
/// <summary>
/// A poster is a 9/16 format image with the cover of the resource.
/// </summary>
public const int Poster = 0;
/// <summary>
/// A thumbnail is a 16/9 format image, it could ether be used as a background or as a preview but it usually
/// is not an official image.
/// </summary>
public const int Thumbnail = 1;
/// <summary>
/// A logo is a small image representing the resource.
/// </summary>
public const int Logo = 2;
/// <summary>
/// A video of a few minutes that tease the content.
/// </summary>
public const int Trailer = 3;
}
}

View File

@ -1,5 +1,4 @@
using System.Collections.Generic;
using Kyoo.Common.Models.Attributes;
using Kyoo.Models.Attributes;
namespace Kyoo.Models
@ -39,22 +38,5 @@ namespace Kyoo.Models
/// The list of collections in this library.
/// </summary>
[LoadableRelation] public ICollection<Collection> Collections { get; set; }
#if ENABLE_INTERNAL_LINKS
/// <summary>
/// The internal link between this library and provider in the <see cref="Providers"/> list.
/// </summary>
[Link] public ICollection<Link<Library, Provider>> ProviderLinks { get; set; }
/// <summary>
/// The internal link between this library and shows in the <see cref="Shows"/> list.
/// </summary>
[Link] public ICollection<Link<Library, Show>> ShowLinks { get; set; }
/// <summary>
/// The internal link between this library and collection in the <see cref="Collections"/> list.
/// </summary>
[Link] public ICollection<Link<Library, Collection>> CollectionLinks { get; set; }
#endif
}
}

View File

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using Kyoo.Models.Attributes;
namespace Kyoo.Models
@ -6,7 +7,7 @@ namespace Kyoo.Models
/// <summary>
/// An actor, voice actor, writer, animator, somebody who worked on a <see cref="Show"/>.
/// </summary>
public class People : IResource
public class People : IResource, IMetadata, IThumbnails
{
/// <inheritdoc />
public int ID { get; set; }
@ -19,17 +20,20 @@ namespace Kyoo.Models
/// </summary>
public string Name { get; set; }
/// <inheritdoc />
public Dictionary<int, string> Images { get; set; }
/// <summary>
/// The path of this poster.
/// By default, the http path for this poster is returned from the public API.
/// This can be disabled using the internal query flag.
/// </summary>
[SerializeAs("{HOST}/api/people/{Slug}/poster")] public string Poster { get; set; }
[SerializeAs("{HOST}/api/people/{Slug}/poster")]
[Obsolete("Use Images instead of this, this is only kept for the API response.")]
public string Poster => Images?.GetValueOrDefault(Models.Images.Poster);
/// <summary>
/// The link to metadata providers that this person has. See <see cref="MetadataID{T}"/> for more information.
/// </summary>
[EditableRelation] [LoadableRelation] public ICollection<MetadataID<People>> ExternalIDs { get; set; }
/// <inheritdoc />
[EditableRelation] [LoadableRelation] public ICollection<MetadataID> ExternalIDs { get; set; }
/// <summary>
/// The list of roles this person has played in. See <see cref="PeopleRole"/> for more information.

View File

@ -1,5 +1,5 @@
using System;
using System.Collections.Generic;
using Kyoo.Common.Models.Attributes;
using Kyoo.Controllers;
using Kyoo.Models.Attributes;
@ -9,7 +9,7 @@ namespace Kyoo.Models
/// This class contains metadata about <see cref="IMetadataProvider"/>.
/// You can have providers even if you don't have the corresponding <see cref="IMetadataProvider"/>.
/// </summary>
public class Provider : IResource
public class Provider : IResource, IThumbnails
{
/// <inheritdoc />
public int ID { get; set; }
@ -22,30 +22,23 @@ namespace Kyoo.Models
/// </summary>
public string Name { get; set; }
/// <inheritdoc />
public Dictionary<int, string> Images { get; set; }
/// <summary>
/// The path of this provider's logo.
/// By default, the http path for this logo is returned from the public API.
/// This can be disabled using the internal query flag.
/// </summary>
[SerializeAs("{HOST}/api/providers/{Slug}/logo")] public string Logo { get; set; }
/// <summary>
/// The extension of the logo. This is used for http responses.
/// </summary>
[SerializeIgnore] public string LogoExtension { get; set; }
[SerializeAs("{HOST}/api/providers/{Slug}/logo")]
[Obsolete("Use Images instead of this, this is only kept for the API response.")]
public string Logo => Images?.GetValueOrDefault(Models.Images.Logo);
/// <summary>
/// The list of libraries that uses this provider.
/// </summary>
[LoadableRelation] public ICollection<Library> Libraries { get; set; }
#if ENABLE_INTERNAL_LINKS
/// <summary>
/// The internal link between this provider and libraries in the <see cref="Libraries"/> list.
/// </summary>
[Link] public ICollection<Link<Library, Provider>> LibraryLinks { get; set; }
#endif
/// <summary>
/// Create a new, default, <see cref="Provider"/>
/// </summary>
@ -61,7 +54,10 @@ namespace Kyoo.Models
{
Slug = Utility.ToSlug(name);
Name = name;
Logo = logo;
Images = new Dictionary<int, string>
{
[Models.Images.Logo] = logo
};
}
}
}

View File

@ -10,7 +10,7 @@ namespace Kyoo.Models
/// <summary>
/// A season of a <see cref="Show"/>.
/// </summary>
public class Season : IResource
public class Season : IResource, IMetadata, IThumbnails
{
/// <inheritdoc />
public int ID { get; set; }
@ -45,7 +45,8 @@ namespace Kyoo.Models
/// </summary>
[SerializeIgnore] public int ShowID { get; set; }
/// <summary>
/// The show that contains this season. This must be explicitly loaded via a call to <see cref="ILibraryManager.Load"/>.
/// The show that contains this season.
/// This must be explicitly loaded via a call to <see cref="ILibraryManager.Load"/>.
/// </summary>
[LoadableRelation(nameof(ShowID))] public Show Show { get; set; }
@ -74,17 +75,20 @@ namespace Kyoo.Models
/// </summary>
public DateTime? EndDate { get; set; }
/// <inheritdoc />
public Dictionary<int, string> Images { get; set; }
/// <summary>
/// The path of this poster.
/// By default, the http path for this poster is returned from the public API.
/// This can be disabled using the internal query flag.
/// </summary>
[SerializeAs("{HOST}/api/seasons/{Slug}/thumb")] public string Poster { get; set; }
[SerializeAs("{HOST}/api/seasons/{Slug}/thumb")]
[Obsolete("Use Images instead of this, this is only kept for the API response.")]
public string Poster => Images?.GetValueOrDefault(Models.Images.Poster);
/// <summary>
/// The link to metadata providers that this episode has. See <see cref="MetadataID{T}"/> for more information.
/// </summary>
[EditableRelation] [LoadableRelation] public ICollection<MetadataID<Season>> ExternalIDs { get; set; }
/// <inheritdoc />
[EditableRelation] [LoadableRelation] public ICollection<MetadataID> ExternalIDs { get; set; }
/// <summary>
/// The list of episodes that this season contains.

View File

@ -1,8 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using Kyoo.Common.Models.Attributes;
using Kyoo.Controllers;
using Kyoo.Models.Attributes;
@ -11,7 +8,7 @@ namespace Kyoo.Models
/// <summary>
/// A series or a movie.
/// </summary>
public class Show : IResource, IOnMerge
public class Show : IResource, IMetadata, IOnMerge, IThumbnails
{
/// <inheritdoc />
public int ID { get; set; }
@ -49,7 +46,8 @@ namespace Kyoo.Models
/// An URL to a trailer. This could be any path supported by the <see cref="IFileSystem"/>.
/// </summary>
/// TODO for now, this is set to a youtube url. It should be cached and converted to a local file.
public string TrailerUrl { get; set; }
[Obsolete("Use Images instead of this, this is only kept for the API response.")]
public string TrailerUrl => Images?.GetValueOrDefault(Models.Images.Trailer);
/// <summary>
/// The date this show started airing. It can be null if this is unknown.
@ -63,43 +61,51 @@ namespace Kyoo.Models
/// </summary>
public DateTime? EndAir { get; set; }
/// <inheritdoc />
public Dictionary<int, string> Images { get; set; }
/// <summary>
/// The path of this show's poster.
/// By default, the http path for this poster is returned from the public API.
/// This can be disabled using the internal query flag.
/// </summary>
[SerializeAs("{HOST}/api/shows/{Slug}/poster")] public string Poster { get; set; }
[SerializeAs("{HOST}/api/shows/{Slug}/poster")]
[Obsolete("Use Images instead of this, this is only kept for the API response.")]
public string Poster => Images?.GetValueOrDefault(Models.Images.Poster);
/// <summary>
/// The path of this show's logo.
/// By default, the http path for this logo is returned from the public API.
/// This can be disabled using the internal query flag.
/// </summary>
[SerializeAs("{HOST}/api/shows/{Slug}/logo")] public string Logo { get; set; }
[SerializeAs("{HOST}/api/shows/{Slug}/logo")]
[Obsolete("Use Images instead of this, this is only kept for the API response.")]
public string Logo => Images?.GetValueOrDefault(Models.Images.Logo);
/// <summary>
/// The path of this show's backdrop.
/// By default, the http path for this backdrop is returned from the public API.
/// This can be disabled using the internal query flag.
/// </summary>
[SerializeAs("{HOST}/api/shows/{Slug}/backdrop")] public string Backdrop { get; set; }
[SerializeAs("{HOST}/api/shows/{Slug}/backdrop")]
[Obsolete("Use Images instead of this, this is only kept for the API response.")]
public string Backdrop => Images?.GetValueOrDefault(Models.Images.Thumbnail);
/// <summary>
/// True if this show represent a movie, false otherwise.
/// </summary>
public bool IsMovie { get; set; }
/// <summary>
/// The link to metadata providers that this show has. See <see cref="MetadataID{T}"/> for more information.
/// </summary>
[EditableRelation] [LoadableRelation] public ICollection<MetadataID<Show>> ExternalIDs { get; set; }
/// <inheritdoc />
[EditableRelation] [LoadableRelation] public ICollection<MetadataID> ExternalIDs { get; set; }
/// <summary>
/// The ID of the Studio that made this show.
/// </summary>
[SerializeIgnore] public int? StudioID { get; set; }
/// <summary>
/// The Studio that made this show. This must be explicitly loaded via a call to <see cref="ILibraryManager.Load"/>.
/// The Studio that made this show.
/// This must be explicitly loaded via a call to <see cref="ILibraryManager.Load"/>.
/// </summary>
[LoadableRelation(nameof(StudioID))] [EditableRelation] public Studio Studio { get; set; }
@ -135,41 +141,9 @@ namespace Kyoo.Models
/// </summary>
[LoadableRelation] public ICollection<Collection> Collections { get; set; }
#if ENABLE_INTERNAL_LINKS
/// <summary>
/// The internal link between this show and libraries in the <see cref="Libraries"/> list.
/// </summary>
[Link] public ICollection<Link<Library, Show>> LibraryLinks { get; set; }
/// <summary>
/// The internal link between this show and collections in the <see cref="Collections"/> list.
/// </summary>
[Link] public ICollection<Link<Collection, Show>> CollectionLinks { get; set; }
/// <summary>
/// The internal link between this show and genres in the <see cref="Genres"/> list.
/// </summary>
[Link] public ICollection<Link<Show, Genre>> GenreLinks { get; set; }
#endif
/// <summary>
/// Retrieve the internal provider's ID of a show using it's provider slug.
/// </summary>
/// <remarks>This method will never return anything if the <see cref="ExternalIDs"/> are not loaded.</remarks>
/// <param name="provider">The slug of the provider</param>
/// <returns>The <see cref="MetadataID{T}.DataID"/> field of the asked provider.</returns>
[CanBeNull]
public string GetID(string provider)
{
return ExternalIDs?.FirstOrDefault(x => x.Second.Slug == provider)?.DataID;
}
/// <inheritdoc />
public void OnMerge(object merged)
{
if (ExternalIDs != null)
foreach (MetadataID<Show> id in ExternalIDs)
id.First = this;
if (People != null)
foreach (PeopleRole link in People)
link.Show = this;

View File

@ -6,7 +6,7 @@ namespace Kyoo.Models
/// <summary>
/// A studio that make shows.
/// </summary>
public class Studio : IResource
public class Studio : IResource, IMetadata
{
/// <inheritdoc />
public int ID { get; set; }
@ -24,6 +24,9 @@ namespace Kyoo.Models
/// </summary>
[LoadableRelation] public ICollection<Show> Shows { get; set; }
/// <inheritdoc />
[EditableRelation] [LoadableRelation] public ICollection<MetadataID> ExternalIDs { get; set; }
/// <summary>
/// Create a new, empty, <see cref="Studio"/>.
/// </summary>

View File

@ -1,12 +1,11 @@
using System.Collections.Generic;
using Kyoo.Common.Models.Attributes;
namespace Kyoo.Models
{
/// <summary>
/// A single user of the app.
/// </summary>
public class User : IResource
public class User : IResource, IThumbnails
{
/// <inheritdoc />
public int ID { get; set; }
@ -39,6 +38,9 @@ namespace Kyoo.Models
/// </summary>
public Dictionary<string, string> ExtraData { get; set; }
/// <inheritdoc />
public Dictionary<int, string> Images { get; set; }
/// <summary>
/// The list of shows the user has finished.
/// </summary>
@ -48,20 +50,28 @@ namespace Kyoo.Models
/// The list of episodes the user is watching (stopped in progress or the next episode of the show)
/// </summary>
public ICollection<WatchedEpisode> CurrentlyWatching { get; set; }
#if ENABLE_INTERNAL_LINKS
/// <summary>
/// Links between Users and Shows.
/// </summary>
[Link] public ICollection<Link<User, Show>> ShowLinks { get; set; }
#endif
}
/// <summary>
/// Metadata of episode currently watching by an user
/// </summary>
public class WatchedEpisode : Link<User, Episode>
public class WatchedEpisode
{
/// <summary>
/// The ID of the user that started watching this episode.
/// </summary>
public int UserID { get; set; }
/// <summary>
/// The ID of the episode started.
/// </summary>
public int EpisodeID { get; set; }
/// <summary>
/// The <see cref="Episode"/> started.
/// </summary>
public Episode Episode { get; set; }
/// <summary>
/// Where the player has stopped watching the episode (between 0 and 100).
/// </summary>

View File

@ -22,12 +22,13 @@ namespace Kyoo
/// <param name="second">The second enumerable to merge, if items from this list are equals to one from the first, they are not kept</param>
/// <param name="isEqual">Equality function to compare items. If this is null, duplicated elements are kept</param>
/// <returns>The two list merged as an array</returns>
public static T[] MergeLists<T>(IEnumerable<T> first,
IEnumerable<T> second,
Func<T, T, bool> isEqual = null)
[ContractAnnotation("first:notnull => notnull; second:notnull => notnull", true)]
public static T[] MergeLists<T>([CanBeNull] IEnumerable<T> first,
[CanBeNull] IEnumerable<T> second,
[CanBeNull] Func<T, T, bool> isEqual = null)
{
if (first == null)
return second.ToArray();
return second?.ToArray();
if (second == null)
return first.ToArray();
if (isEqual == null)
@ -36,6 +37,98 @@ namespace Kyoo
return list.Concat(second.Where(x => !list.Any(y => isEqual(x, y)))).ToArray();
}
/// <summary>
/// Merge two dictionary, if the same key is found on both dictionary, the values of the first one is kept.
/// </summary>
/// <param name="first">The first dictionary to merge</param>
/// <param name="second">The second dictionary to merge</param>
/// <typeparam name="T">The type of the keys in dictionaries</typeparam>
/// <typeparam name="T2">The type of values in the dictionaries</typeparam>
/// <returns>The first dictionary with the missing elements of <paramref name="second"/>.</returns>
/// <seealso cref="MergeDictionaries{T,T2}(System.Collections.Generic.IDictionary{T,T2},System.Collections.Generic.IDictionary{T,T2},out bool)"/>
[ContractAnnotation("first:notnull => notnull; second:notnull => notnull", true)]
public static IDictionary<T, T2> MergeDictionaries<T, T2>([CanBeNull] IDictionary<T, T2> first,
[CanBeNull] IDictionary<T, T2> second)
{
return MergeDictionaries(first, second, out bool _);
}
/// <summary>
/// Merge two dictionary, if the same key is found on both dictionary, the values of the first one is kept.
/// </summary>
/// <param name="first">The first dictionary to merge</param>
/// <param name="second">The second dictionary to merge</param>
/// <param name="hasChanged">
/// <c>true</c> if a new items has been added to the dictionary, <c>false</c> otherwise.
/// </param>
/// <typeparam name="T">The type of the keys in dictionaries</typeparam>
/// <typeparam name="T2">The type of values in the dictionaries</typeparam>
/// <returns>The first dictionary with the missing elements of <paramref name="second"/>.</returns>
[ContractAnnotation("first:notnull => notnull; second:notnull => notnull", true)]
public static IDictionary<T, T2> MergeDictionaries<T, T2>([CanBeNull] IDictionary<T, T2> first,
[CanBeNull] IDictionary<T, T2> second,
out bool hasChanged)
{
if (first == null)
{
hasChanged = true;
return second;
}
hasChanged = false;
if (second == null)
return first;
foreach ((T key, T2 value) in second)
hasChanged |= first.TryAdd(key, value);
return first;
}
/// <summary>
/// Merge two dictionary, if the same key is found on both dictionary, the values of the second one is kept.
/// </summary>
/// <remarks>
/// The only difference in this function compared to
/// <see cref="MergeDictionaries{T,T2}(System.Collections.Generic.IDictionary{T,T2},System.Collections.Generic.IDictionary{T,T2}, out bool)"/>
/// is the way <paramref name="hasChanged"/> is calculated and the order of the arguments.
/// <code>
/// MergeDictionaries(first, second);
/// </code>
/// will do the same thing as
/// <code>
/// CompleteDictionaries(second, first, out bool _);
/// </code>
/// </remarks>
/// <param name="first">The first dictionary to merge</param>
/// <param name="second">The second dictionary to merge</param>
/// <param name="hasChanged">
/// <c>true</c> if a new items has been added to the dictionary, <c>false</c> otherwise.
/// </param>
/// <typeparam name="T">The type of the keys in dictionaries</typeparam>
/// <typeparam name="T2">The type of values in the dictionaries</typeparam>
/// <returns>
/// A dictionary with the missing elements of <paramref name="second"/>
/// set to those of <paramref name="first"/>.
/// </returns>
[ContractAnnotation("first:notnull => notnull; second:notnull => notnull", true)]
public static IDictionary<T, T2> CompleteDictionaries<T, T2>([CanBeNull] IDictionary<T, T2> first,
[CanBeNull] IDictionary<T, T2> second,
out bool hasChanged)
{
if (first == null)
{
hasChanged = true;
return second;
}
hasChanged = false;
if (second == null)
return first;
hasChanged = second.Any(x => !x.Value.Equals(first[x.Key]));
foreach ((T key, T2 value) in first)
second.TryAdd(key, value);
return second;
}
/// <summary>
/// Set every fields of first to those of second. Ignore fields marked with the <see cref="NotMergeableAttribute"/> attribute
/// At the end, the OnMerge method of first will be called if first is a <see cref="IOnMerge"/>
@ -63,16 +156,34 @@ namespace Kyoo
}
/// <summary>
/// Set every default values of first to the value of second. ex: {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "test"}.
/// Set every non-default values of seconds to the corresponding property of second.
/// Dictionaries are handled like anonymous objects with a property per key/pair value
/// (see
/// <see cref="MergeDictionaries{T,T2}(System.Collections.Generic.IDictionary{T,T2},System.Collections.Generic.IDictionary{T,T2})"/>
/// for more details).
/// At the end, the OnMerge method of first will be called if first is a <see cref="IOnMerge"/>
/// </summary>
/// <param name="first">The object to complete</param>
/// <param name="second">Missing fields of first will be completed by fields of this item. If second is null, the function no-op.</param>
/// <param name="where">Filter fields that will be merged</param>
/// <remarks>
/// This does the opposite of <see cref="Merge{T}"/>.
/// </remarks>
/// <example>
/// {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "foo"}
/// </example>
/// <param name="first">
/// The object to complete
/// </param>
/// <param name="second">
/// Missing fields of first will be completed by fields of this item. If second is null, the function no-op.
/// </param>
/// <param name="where">
/// Filter fields that will be merged
/// </param>
/// <typeparam name="T">Fields of T will be completed</typeparam>
/// <returns><see cref="first"/></returns>
/// <exception cref="ArgumentNullException">If first is null</exception>
public static T Complete<T>([NotNull] T first, [CanBeNull] T second, Func<PropertyInfo, bool> where = null)
public static T Complete<T>([NotNull] T first,
[CanBeNull] T second,
[InstantHandle] Func<PropertyInfo, bool> where = null)
{
if (first == null)
throw new ArgumentNullException(nameof(first));
@ -93,7 +204,26 @@ namespace Kyoo
object defaultValue = property.GetCustomAttribute<DefaultValueAttribute>()?.Value
?? property.PropertyType.GetClrDefault();
if (value?.Equals(defaultValue) == false && value != property.GetValue(first))
if (value?.Equals(defaultValue) != false || value.Equals(property.GetValue(first)))
continue;
if (Utility.IsOfGenericType(property.PropertyType, typeof(IDictionary<,>)))
{
Type[] dictionaryTypes = Utility.GetGenericDefinition(property.PropertyType, typeof(IDictionary<,>))
.GenericTypeArguments;
object[] parameters = {
property.GetValue(first),
value,
false
};
object newDictionary = Utility.RunGenericMethod<object>(
typeof(Merger),
nameof(CompleteDictionaries),
dictionaryTypes,
parameters);
if ((bool)parameters[2])
property.SetValue(first, newDictionary);
}
else
property.SetValue(first, value);
}
@ -103,17 +233,28 @@ namespace Kyoo
}
/// <summary>
/// An advanced <see cref="Complete{T}"/> function.
/// This will set missing values of <see cref="first"/> to the corresponding values of <see cref="second"/>.
/// Enumerable will be merged (concatenated).
/// Enumerable will be merged (concatenated) and Dictionaries too.
/// At the end, the OnMerge method of first will be called if first is a <see cref="IOnMerge"/>.
/// </summary>
/// <param name="first">The object to complete</param>
/// <param name="second">Missing fields of first will be completed by fields of this item. If second is null, the function no-op.</param>
/// <example>
/// {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "test"}
/// </example>
/// <param name="first">
/// The object to complete
/// </param>
/// <param name="second">
/// Missing fields of first will be completed by fields of this item. If second is null, the function no-op.
/// </param>
/// <param name="where">
/// Filter fields that will be merged
/// </param>
/// <typeparam name="T">Fields of T will be merged</typeparam>
/// <returns><see cref="first"/></returns>
[ContractAnnotation("first:notnull => notnull; second:notnull => notnull", true)]
public static T Merge<T>([CanBeNull] T first, [CanBeNull] T second)
public static T Merge<T>([CanBeNull] T first,
[CanBeNull] T second,
[InstantHandle] Func<PropertyInfo, bool> where = null)
{
if (first == null)
return second;
@ -125,6 +266,9 @@ namespace Kyoo
.Where(x => x.CanRead && x.CanWrite
&& Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null);
if (where != null)
properties = properties.Where(where);
foreach (PropertyInfo property in properties)
{
object oldValue = property.GetValue(first);
@ -133,6 +277,23 @@ namespace Kyoo
if (oldValue?.Equals(defaultValue) != false)
property.SetValue(first, newValue);
else if (Utility.IsOfGenericType(property.PropertyType, typeof(IDictionary<,>)))
{
Type[] dictionaryTypes = Utility.GetGenericDefinition(property.PropertyType, typeof(IDictionary<,>))
.GenericTypeArguments;
object[] parameters = {
oldValue,
newValue,
false
};
object newDictionary = Utility.RunGenericMethod<object>(
typeof(Merger),
nameof(MergeDictionaries),
dictionaryTypes,
parameters);
if ((bool)parameters[2])
property.SetValue(first, newDictionary);
}
else if (typeof(IEnumerable).IsAssignableFrom(property.PropertyType)
&& property.PropertyType != typeof(string))
{

View File

@ -325,7 +325,7 @@ namespace Kyoo
if (types.Length < 1)
throw new ArgumentException($"The {nameof(types)} array is empty. At least one type is needed.");
MethodInfo method = GetMethod(owner, BindingFlags.Static, methodName, types, args);
return (T)method.MakeGenericMethod(types).Invoke(null, args.ToArray());
return (T)method.MakeGenericMethod(types).Invoke(null, args);
}
/// <summary>

View File

@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Kyoo.Controllers;
using Kyoo.Models;
using Kyoo.Models.Exceptions;
@ -60,6 +62,7 @@ namespace Kyoo
/// All providers of Kyoo. See <see cref="Provider"/>.
/// </summary>
public DbSet<Provider> Providers { get; set; }
/// <summary>
/// The list of registered users.
/// </summary>
@ -84,28 +87,34 @@ namespace Kyoo
public DbSet<LibraryItem> LibraryItems { get; set; }
/// <summary>
/// Get all metadataIDs (ExternalIDs) of a given resource. See <see cref="MetadataID{T}"/>.
/// Get all metadataIDs (ExternalIDs) of a given resource. See <see cref="MetadataID"/>.
/// </summary>
/// <typeparam name="T">The metadata of this type will be returned.</typeparam>
/// <returns>A queryable of metadata ids for a type.</returns>
public DbSet<MetadataID<T>> MetadataIds<T>()
where T : class, IResource
public DbSet<MetadataID> MetadataIds<T>()
where T : class, IMetadata
{
return Set<MetadataID<T>>();
return Set<MetadataID>(MetadataName<T>());
}
/// <summary>
/// Get a generic link between two resource types.
/// Add a many to many link between two resources.
/// </summary>
/// <remarks>Types are order dependant. You can't inverse the order. Please always put the owner first.</remarks>
/// <param name="first">The ID of the first resource.</param>
/// <param name="second">The ID of the second resource.</param>
/// <typeparam name="T1">The first resource type of the relation. It is the owner of the second</typeparam>
/// <typeparam name="T2">The second resource type of the relation. It is the contained resource.</typeparam>
/// <returns>All links between the two types.</returns>
public DbSet<Link<T1, T2>> Links<T1, T2>()
public async Task AddLinks<T1, T2>(int first, int second)
where T1 : class, IResource
where T2 : class, IResource
{
return Set<Link<T1, T2>>();
await Set<Dictionary<string, object>>(LinkName<T1, T2>())
.AddAsync(new Dictionary<string, object>
{
[LinkNameFk<T1>()] = first,
[LinkNameFk<T2>()] = second
});
}
@ -122,6 +131,32 @@ namespace Kyoo
: base(options)
{ }
/// <summary>
/// Get the name of the metadata table of the given type.
/// </summary>
/// <typeparam name="T">The type related to the metadata</typeparam>
/// <returns>The name of the table containing the metadata.</returns>
protected abstract string MetadataName<T>()
where T : IMetadata;
/// <summary>
/// Get the name of the link table of the two given types.
/// </summary>
/// <typeparam name="T">The owner type of the relation</typeparam>
/// <typeparam name="T2">The child type of the relation</typeparam>
/// <returns>The name of the table containing the links.</returns>
protected abstract string LinkName<T, T2>()
where T : IResource
where T2 : IResource;
/// <summary>
/// Get the name of a link's foreign key.
/// </summary>
/// <typeparam name="T">The type that will be accessible via the navigation</typeparam>
/// <returns>The name of the foreign key for the given resource.</returns>
protected abstract string LinkNameFk<T>()
where T : IResource;
/// <summary>
/// Set basic configurations (like preventing query tracking)
/// </summary>
@ -132,6 +167,58 @@ namespace Kyoo
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
/// <summary>
/// Build the metadata model for the given type.
/// </summary>
/// <param name="modelBuilder">The database model builder</param>
/// <typeparam name="T">The type to add metadata to.</typeparam>
private void _HasMetadata<T>(ModelBuilder modelBuilder)
where T : class, IMetadata
{
modelBuilder.SharedTypeEntity<MetadataID>(MetadataName<T>())
.HasKey(MetadataID.PrimaryKey);
modelBuilder.SharedTypeEntity<MetadataID>(MetadataName<T>())
.HasOne<T>()
.WithMany(x => x.ExternalIDs)
.HasForeignKey(x => x.ResourceID)
.OnDelete(DeleteBehavior.Cascade);
}
/// <summary>
/// Create a many to many relationship between the two entities.
/// The resulting relationship will have an available <see cref="AddLinks{T1,T2}"/> method.
/// </summary>
/// <param name="modelBuilder">The database model builder</param>
/// <param name="firstNavigation">The first navigation expression from T to T2</param>
/// <param name="secondNavigation">The second navigation expression from T2 to T</param>
/// <typeparam name="T">The owning type of the relationship</typeparam>
/// <typeparam name="T2">The owned type of the relationship</typeparam>
private void _HasManyToMany<T, T2>(ModelBuilder modelBuilder,
Expression<Func<T, IEnumerable<T2>>> firstNavigation,
Expression<Func<T2, IEnumerable<T>>> secondNavigation)
where T : class, IResource
where T2 : class, IResource
{
modelBuilder.Entity<T2>()
.HasMany(secondNavigation)
.WithMany(firstNavigation)
.UsingEntity<Dictionary<string, object>>(
LinkName<T, T2>(),
x => x
.HasOne<T>()
.WithMany()
.HasForeignKey(LinkNameFk<T>())
.OnDelete(DeleteBehavior.Cascade),
x => x
.HasOne<T2>()
.WithMany()
.HasForeignKey(LinkNameFk<T2>())
.OnDelete(DeleteBehavior.Cascade)
);
}
/// <summary>
/// Set database parameters to support every types of Kyoo.
/// </summary>
@ -140,6 +227,9 @@ namespace Kyoo
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<PeopleRole>()
.Ignore(x => x.ForPeople);
modelBuilder.Entity<Show>()
.HasMany(x => x.Seasons)
.WithOne(x => x.Show)
@ -162,117 +252,26 @@ namespace Kyoo
.WithMany(x => x.Shows)
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<Provider>()
.HasMany(x => x.Libraries)
.WithMany(x => x.Providers)
.UsingEntity<Link<Library, Provider>>(
y => y
.HasOne(x => x.First)
.WithMany(x => x.ProviderLinks),
y => y
.HasOne(x => x.Second)
.WithMany(x => x.LibraryLinks),
y => y.HasKey(Link<Library, Provider>.PrimaryKey));
modelBuilder.Entity<Collection>()
.HasMany(x => x.Libraries)
.WithMany(x => x.Collections)
.UsingEntity<Link<Library, Collection>>(
y => y
.HasOne(x => x.First)
.WithMany(x => x.CollectionLinks),
y => y
.HasOne(x => x.Second)
.WithMany(x => x.LibraryLinks),
y => y.HasKey(Link<Library, Collection>.PrimaryKey));
modelBuilder.Entity<Show>()
.HasMany(x => x.Libraries)
.WithMany(x => x.Shows)
.UsingEntity<Link<Library, Show>>(
y => y
.HasOne(x => x.First)
.WithMany(x => x.ShowLinks),
y => y
.HasOne(x => x.Second)
.WithMany(x => x.LibraryLinks),
y => y.HasKey(Link<Library, Show>.PrimaryKey));
modelBuilder.Entity<Show>()
.HasMany(x => x.Collections)
.WithMany(x => x.Shows)
.UsingEntity<Link<Collection, Show>>(
y => y
.HasOne(x => x.First)
.WithMany(x => x.ShowLinks),
y => y
.HasOne(x => x.Second)
.WithMany(x => x.CollectionLinks),
y => y.HasKey(Link<Collection, Show>.PrimaryKey));
modelBuilder.Entity<Genre>()
.HasMany(x => x.Shows)
.WithMany(x => x.Genres)
.UsingEntity<Link<Show, Genre>>(
y => y
.HasOne(x => x.First)
.WithMany(x => x.GenreLinks),
y => y
.HasOne(x => x.Second)
.WithMany(x => x.ShowLinks),
y => y.HasKey(Link<Show, Genre>.PrimaryKey));
_HasManyToMany<Library, Provider>(modelBuilder, x => x.Providers, x => x.Libraries);
_HasManyToMany<Library, Collection>(modelBuilder, x => x.Collections, x => x.Libraries);
_HasManyToMany<Library, Show>(modelBuilder, x => x.Shows, x => x.Libraries);
_HasManyToMany<Collection, Show>(modelBuilder, x => x.Shows, x => x.Collections);
_HasManyToMany<Show, Genre>(modelBuilder, x => x.Genres, x => x.Shows);
modelBuilder.Entity<User>()
.HasMany(x => x.Watched)
.WithMany("users")
.UsingEntity<Link<User, Show>>(
y => y
.HasOne(x => x.Second)
.WithMany(),
y => y
.HasOne(x => x.First)
.WithMany(x => x.ShowLinks),
y => y.HasKey(Link<User, Show>.PrimaryKey));
.WithMany("Users")
.UsingEntity(x => x.ToTable(LinkName<User, Show>()));
modelBuilder.Entity<MetadataID<Show>>()
.HasKey(MetadataID<Show>.PrimaryKey);
modelBuilder.Entity<MetadataID<Show>>()
.HasOne(x => x.First)
.WithMany(x => x.ExternalIDs)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MetadataID<Season>>()
.HasKey(MetadataID<Season>.PrimaryKey);
modelBuilder.Entity<MetadataID<Season>>()
.HasOne(x => x.First)
.WithMany(x => x.ExternalIDs)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MetadataID<Episode>>()
.HasKey(MetadataID<Episode>.PrimaryKey);
modelBuilder.Entity<MetadataID<Episode>>()
.HasOne(x => x.First)
.WithMany(x => x.ExternalIDs)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MetadataID<People>>()
.HasKey(MetadataID<People>.PrimaryKey);
modelBuilder.Entity<MetadataID<People>>()
.HasOne(x => x.First)
.WithMany(x => x.ExternalIDs)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MetadataID<Show>>().HasOne(x => x.Second).WithMany()
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MetadataID<Season>>().HasOne(x => x.Second).WithMany()
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MetadataID<Episode>>().HasOne(x => x.Second).WithMany()
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MetadataID<People>>().HasOne(x => x.Second).WithMany()
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MetadataID<Show>>().HasOne(x => x.Second).WithMany()
.OnDelete(DeleteBehavior.Cascade);
_HasMetadata<Collection>(modelBuilder);
_HasMetadata<Show>(modelBuilder);
_HasMetadata<Season>(modelBuilder);
_HasMetadata<Episode>(modelBuilder);
_HasMetadata<People>(modelBuilder);
_HasMetadata<Studio>(modelBuilder);
modelBuilder.Entity<WatchedEpisode>()
.HasKey(x => new {First = x.FirstID, Second = x.SecondID});
.HasKey(x => new { User = x.UserID, Episode = x.EpisodeID });
modelBuilder.Entity<Collection>().Property(x => x.Slug).IsRequired();
modelBuilder.Entity<Genre>().Property(x => x.Slug).IsRequired();
@ -505,6 +504,23 @@ namespace Kyoo
}
}
/// <summary>
/// Return the first resource with the given slug that is currently tracked by this context.
/// This allow one to limit redundant calls to <see cref="IRepository{T}.CreateIfNotExists"/> during the
/// same transaction and prevent fails from EF when two same entities are being tracked.
/// </summary>
/// <param name="slug">The slug of the resource to check</param>
/// <typeparam name="T">The type of entity to check</typeparam>
/// <returns>The local entity representing the resource with the given slug if it exists or null.</returns>
[CanBeNull]
public T LocalEntity<T>(string slug)
where T : class, IResource
{
return ChangeTracker.Entries<T>()
.FirstOrDefault(x => x.Entity.Slug == slug)
?.Entity;
}
/// <summary>
/// Check if the exception is a duplicated exception.
/// </summary>
@ -517,14 +533,12 @@ namespace Kyoo
/// </summary>
private void DiscardChanges()
{
foreach (EntityEntry entry in ChangeTracker.Entries().Where(x => x.State != EntityState.Unchanged
&& x.State != EntityState.Detached))
foreach (EntityEntry entry in ChangeTracker.Entries().Where(x => x.State != EntityState.Detached))
{
entry.State = EntityState.Detached;
}
}
/// <summary>
/// Perform a case insensitive like operation.
/// </summary>

View File

@ -234,16 +234,23 @@ namespace Kyoo.Controllers
finally
{
Database.ChangeTracker.LazyLoadingEnabled = lazyLoading;
Database.ChangeTracker.Clear();
}
}
/// <summary>
/// An overridable method to edit relation of a resource.
/// </summary>
/// <param name="resource">The non edited resource</param>
/// <param name="changed">The new version of <see cref="resource"/>. This item will be saved on the databse and replace <see cref="resource"/></param>
/// <param name="resetOld">A boolean to indicate if all values of resource should be discarded or not.</param>
/// <returns></returns>
/// <param name="resource">
/// The non edited resource
/// </param>
/// <param name="changed">
/// The new version of <see cref="resource"/>.
/// This item will be saved on the database and replace <see cref="resource"/>
/// </param>
/// <param name="resetOld">
/// A boolean to indicate if all values of resource should be discarded or not.
/// </param>
protected virtual Task EditRelations(T resource, T changed, bool resetOld)
{
return Validate(resource);
@ -254,7 +261,9 @@ namespace Kyoo.Controllers
/// It is also called on the default implementation of <see cref="EditRelations"/>
/// </summary>
/// <param name="resource">The resource that will be saved</param>
/// <exception cref="ArgumentException">You can throw this if the resource is illegal and should not be saved.</exception>
/// <exception cref="ArgumentException">
/// You can throw this if the resource is illegal and should not be saved.
/// </exception>
protected virtual Task Validate(T resource)
{
if (typeof(T).GetProperty(nameof(resource.Slug))!.GetCustomAttribute<ComputedAttribute>() != null)

View File

@ -23,7 +23,7 @@ namespace Kyoo.Postgresql.Migrations
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
slug = table.Column<string>(type: "text", nullable: false),
name = table.Column<string>(type: "text", nullable: true),
poster = table.Column<string>(type: "text", nullable: true),
images = table.Column<Dictionary<int, string>>(type: "jsonb", nullable: true),
overview = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
@ -68,7 +68,7 @@ namespace Kyoo.Postgresql.Migrations
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
slug = table.Column<string>(type: "text", nullable: false),
name = table.Column<string>(type: "text", nullable: true),
poster = table.Column<string>(type: "text", nullable: true)
images = table.Column<Dictionary<int, string>>(type: "jsonb", nullable: true)
},
constraints: table =>
{
@ -83,8 +83,7 @@ namespace Kyoo.Postgresql.Migrations
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
slug = table.Column<string>(type: "text", nullable: false),
name = table.Column<string>(type: "text", nullable: true),
logo = table.Column<string>(type: "text", nullable: true),
logo_extension = table.Column<string>(type: "text", nullable: true)
images = table.Column<Dictionary<int, string>>(type: "jsonb", nullable: true)
},
constraints: table =>
{
@ -116,7 +115,8 @@ namespace Kyoo.Postgresql.Migrations
email = table.Column<string>(type: "text", nullable: true),
password = table.Column<string>(type: "text", nullable: true),
permissions = table.Column<string[]>(type: "text[]", nullable: true),
extra_data = table.Column<Dictionary<string, string>>(type: "jsonb", nullable: true)
extra_data = table.Column<Dictionary<string, string>>(type: "jsonb", nullable: true),
images = table.Column<Dictionary<int, string>>(type: "jsonb", nullable: true)
},
constraints: table =>
{
@ -127,71 +127,97 @@ namespace Kyoo.Postgresql.Migrations
name: "link_library_collection",
columns: table => new
{
first_id = table.Column<int>(type: "integer", nullable: false),
second_id = table.Column<int>(type: "integer", nullable: false)
collection_id = table.Column<int>(type: "integer", nullable: false),
library_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_link_library_collection", x => new { x.first_id, x.second_id });
table.PrimaryKey("pk_link_library_collection", x => new { x.collection_id, x.library_id });
table.ForeignKey(
name: "fk_link_library_collection_collections_second_id",
column: x => x.second_id,
name: "fk_link_library_collection_collections_collection_id",
column: x => x.collection_id,
principalTable: "collections",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_link_library_collection_libraries_first_id",
column: x => x.first_id,
name: "fk_link_library_collection_libraries_library_id",
column: x => x.library_id,
principalTable: "libraries",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "collection_metadata_id",
columns: table => new
{
resource_id = table.Column<int>(type: "integer", nullable: false),
provider_id = table.Column<int>(type: "integer", nullable: false),
data_id = table.Column<string>(type: "text", nullable: true),
link = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_collection_metadata_id", x => new { x.resource_id, x.provider_id });
table.ForeignKey(
name: "fk_collection_metadata_id_collections_collection_id",
column: x => x.resource_id,
principalTable: "collections",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_collection_metadata_id_providers_provider_id",
column: x => x.provider_id,
principalTable: "providers",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "link_library_provider",
columns: table => new
{
first_id = table.Column<int>(type: "integer", nullable: false),
second_id = table.Column<int>(type: "integer", nullable: false)
library_id = table.Column<int>(type: "integer", nullable: false),
provider_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_link_library_provider", x => new { x.first_id, x.second_id });
table.PrimaryKey("pk_link_library_provider", x => new { x.library_id, x.provider_id });
table.ForeignKey(
name: "fk_link_library_provider_libraries_first_id",
column: x => x.first_id,
name: "fk_link_library_provider_libraries_library_id",
column: x => x.library_id,
principalTable: "libraries",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_link_library_provider_providers_second_id",
column: x => x.second_id,
name: "fk_link_library_provider_providers_provider_id",
column: x => x.provider_id,
principalTable: "providers",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "metadata_id_people",
name: "people_metadata_id",
columns: table => new
{
first_id = table.Column<int>(type: "integer", nullable: false),
second_id = table.Column<int>(type: "integer", nullable: false),
resource_id = table.Column<int>(type: "integer", nullable: false),
provider_id = table.Column<int>(type: "integer", nullable: false),
data_id = table.Column<string>(type: "text", nullable: true),
link = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_metadata_id_people", x => new { x.first_id, x.second_id });
table.PrimaryKey("pk_people_metadata_id", x => new { x.resource_id, x.provider_id });
table.ForeignKey(
name: "fk_metadata_id_people_people_first_id",
column: x => x.first_id,
name: "fk_people_metadata_id_people_people_id",
column: x => x.resource_id,
principalTable: "people",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_metadata_id_people_providers_second_id",
column: x => x.second_id,
name: "fk_people_metadata_id_providers_provider_id",
column: x => x.provider_id,
principalTable: "providers",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
@ -209,12 +235,9 @@ namespace Kyoo.Postgresql.Migrations
path = table.Column<string>(type: "text", nullable: true),
overview = table.Column<string>(type: "text", nullable: true),
status = table.Column<Status>(type: "status", nullable: false),
trailer_url = table.Column<string>(type: "text", nullable: true),
start_air = table.Column<DateTime>(type: "timestamp without time zone", nullable: true),
end_air = table.Column<DateTime>(type: "timestamp without time zone", nullable: true),
poster = table.Column<string>(type: "text", nullable: true),
logo = table.Column<string>(type: "text", nullable: true),
backdrop = table.Column<string>(type: "text", nullable: true),
images = table.Column<Dictionary<int, string>>(type: "jsonb", nullable: true),
is_movie = table.Column<bool>(type: "boolean", nullable: false),
studio_id = table.Column<int>(type: "integer", nullable: true)
},
@ -230,24 +253,50 @@ namespace Kyoo.Postgresql.Migrations
});
migrationBuilder.CreateTable(
name: "link_collection_show",
name: "studio_metadata_id",
columns: table => new
{
first_id = table.Column<int>(type: "integer", nullable: false),
second_id = table.Column<int>(type: "integer", nullable: false)
resource_id = table.Column<int>(type: "integer", nullable: false),
provider_id = table.Column<int>(type: "integer", nullable: false),
data_id = table.Column<string>(type: "text", nullable: true),
link = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_link_collection_show", x => new { x.first_id, x.second_id });
table.PrimaryKey("pk_studio_metadata_id", x => new { x.resource_id, x.provider_id });
table.ForeignKey(
name: "fk_link_collection_show_collections_first_id",
column: x => x.first_id,
name: "fk_studio_metadata_id_providers_provider_id",
column: x => x.provider_id,
principalTable: "providers",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_studio_metadata_id_studios_studio_id",
column: x => x.resource_id,
principalTable: "studios",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "link_collection_show",
columns: table => new
{
collection_id = table.Column<int>(type: "integer", nullable: false),
show_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_link_collection_show", x => new { x.collection_id, x.show_id });
table.ForeignKey(
name: "fk_link_collection_show_collections_collection_id",
column: x => x.collection_id,
principalTable: "collections",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_link_collection_show_shows_second_id",
column: x => x.second_id,
name: "fk_link_collection_show_shows_show_id",
column: x => x.show_id,
principalTable: "shows",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
@ -257,21 +306,21 @@ namespace Kyoo.Postgresql.Migrations
name: "link_library_show",
columns: table => new
{
first_id = table.Column<int>(type: "integer", nullable: false),
second_id = table.Column<int>(type: "integer", nullable: false)
library_id = table.Column<int>(type: "integer", nullable: false),
show_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_link_library_show", x => new { x.first_id, x.second_id });
table.PrimaryKey("pk_link_library_show", x => new { x.library_id, x.show_id });
table.ForeignKey(
name: "fk_link_library_show_libraries_first_id",
column: x => x.first_id,
name: "fk_link_library_show_libraries_library_id",
column: x => x.library_id,
principalTable: "libraries",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_link_library_show_shows_second_id",
column: x => x.second_id,
name: "fk_link_library_show_shows_show_id",
column: x => x.show_id,
principalTable: "shows",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
@ -281,21 +330,21 @@ namespace Kyoo.Postgresql.Migrations
name: "link_show_genre",
columns: table => new
{
first_id = table.Column<int>(type: "integer", nullable: false),
second_id = table.Column<int>(type: "integer", nullable: false)
genre_id = table.Column<int>(type: "integer", nullable: false),
show_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_link_show_genre", x => new { x.first_id, x.second_id });
table.PrimaryKey("pk_link_show_genre", x => new { x.genre_id, x.show_id });
table.ForeignKey(
name: "fk_link_show_genre_genres_second_id",
column: x => x.second_id,
name: "fk_link_show_genre_genres_genre_id",
column: x => x.genre_id,
principalTable: "genres",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_link_show_genre_shows_first_id",
column: x => x.first_id,
name: "fk_link_show_genre_shows_show_id",
column: x => x.show_id,
principalTable: "shows",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
@ -305,59 +354,32 @@ namespace Kyoo.Postgresql.Migrations
name: "link_user_show",
columns: table => new
{
first_id = table.Column<int>(type: "integer", nullable: false),
second_id = table.Column<int>(type: "integer", nullable: false)
users_id = table.Column<int>(type: "integer", nullable: false),
watched_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_link_user_show", x => new { x.first_id, x.second_id });
table.PrimaryKey("pk_link_user_show", x => new { x.users_id, x.watched_id });
table.ForeignKey(
name: "fk_link_user_show_shows_second_id",
column: x => x.second_id,
name: "fk_link_user_show_shows_watched_id",
column: x => x.watched_id,
principalTable: "shows",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_link_user_show_users_first_id",
column: x => x.first_id,
name: "fk_link_user_show_users_users_id",
column: x => x.users_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "metadata_id_show",
columns: table => new
{
first_id = table.Column<int>(type: "integer", nullable: false),
second_id = table.Column<int>(type: "integer", nullable: false),
data_id = table.Column<string>(type: "text", nullable: true),
link = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_metadata_id_show", x => new { x.first_id, x.second_id });
table.ForeignKey(
name: "fk_metadata_id_show_providers_second_id",
column: x => x.second_id,
principalTable: "providers",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_metadata_id_show_shows_first_id",
column: x => x.first_id,
principalTable: "shows",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "people_roles",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
for_people = table.Column<bool>(type: "boolean", nullable: false),
people_id = table.Column<int>(type: "integer", nullable: false),
show_id = table.Column<int>(type: "integer", nullable: false),
type = table.Column<string>(type: "text", nullable: true),
@ -393,7 +415,7 @@ namespace Kyoo.Postgresql.Migrations
overview = table.Column<string>(type: "text", nullable: true),
start_date = table.Column<DateTime>(type: "timestamp without time zone", nullable: true),
end_date = table.Column<DateTime>(type: "timestamp without time zone", nullable: true),
poster = table.Column<string>(type: "text", nullable: true)
images = table.Column<Dictionary<int, string>>(type: "jsonb", nullable: true)
},
constraints: table =>
{
@ -406,6 +428,32 @@ namespace Kyoo.Postgresql.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "show_metadata_id",
columns: table => new
{
resource_id = table.Column<int>(type: "integer", nullable: false),
provider_id = table.Column<int>(type: "integer", nullable: false),
data_id = table.Column<string>(type: "text", nullable: true),
link = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_show_metadata_id", x => new { x.resource_id, x.provider_id });
table.ForeignKey(
name: "fk_show_metadata_id_providers_provider_id",
column: x => x.provider_id,
principalTable: "providers",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_show_metadata_id_shows_show_id",
column: x => x.resource_id,
principalTable: "shows",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "episodes",
columns: table => new
@ -419,7 +467,7 @@ namespace Kyoo.Postgresql.Migrations
episode_number = table.Column<int>(type: "integer", nullable: true),
absolute_number = table.Column<int>(type: "integer", nullable: true),
path = table.Column<string>(type: "text", nullable: true),
thumb = table.Column<string>(type: "text", nullable: true),
images = table.Column<Dictionary<int, string>>(type: "jsonb", nullable: true),
title = table.Column<string>(type: "text", nullable: true),
overview = table.Column<string>(type: "text", nullable: true),
release_date = table.Column<DateTime>(type: "timestamp without time zone", nullable: true)
@ -442,52 +490,52 @@ namespace Kyoo.Postgresql.Migrations
});
migrationBuilder.CreateTable(
name: "metadata_id_season",
name: "season_metadata_id",
columns: table => new
{
first_id = table.Column<int>(type: "integer", nullable: false),
second_id = table.Column<int>(type: "integer", nullable: false),
resource_id = table.Column<int>(type: "integer", nullable: false),
provider_id = table.Column<int>(type: "integer", nullable: false),
data_id = table.Column<string>(type: "text", nullable: true),
link = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_metadata_id_season", x => new { x.first_id, x.second_id });
table.PrimaryKey("pk_season_metadata_id", x => new { x.resource_id, x.provider_id });
table.ForeignKey(
name: "fk_metadata_id_season_providers_second_id",
column: x => x.second_id,
name: "fk_season_metadata_id_providers_provider_id",
column: x => x.provider_id,
principalTable: "providers",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_metadata_id_season_seasons_first_id",
column: x => x.first_id,
name: "fk_season_metadata_id_seasons_season_id",
column: x => x.resource_id,
principalTable: "seasons",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "metadata_id_episode",
name: "episode_metadata_id",
columns: table => new
{
first_id = table.Column<int>(type: "integer", nullable: false),
second_id = table.Column<int>(type: "integer", nullable: false),
resource_id = table.Column<int>(type: "integer", nullable: false),
provider_id = table.Column<int>(type: "integer", nullable: false),
data_id = table.Column<string>(type: "text", nullable: true),
link = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_metadata_id_episode", x => new { x.first_id, x.second_id });
table.PrimaryKey("pk_episode_metadata_id", x => new { x.resource_id, x.provider_id });
table.ForeignKey(
name: "fk_metadata_id_episode_episodes_first_id",
column: x => x.first_id,
name: "fk_episode_metadata_id_episodes_episode_id",
column: x => x.resource_id,
principalTable: "episodes",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_metadata_id_episode_providers_second_id",
column: x => x.second_id,
name: "fk_episode_metadata_id_providers_provider_id",
column: x => x.provider_id,
principalTable: "providers",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
@ -526,33 +574,43 @@ namespace Kyoo.Postgresql.Migrations
name: "watched_episodes",
columns: table => new
{
first_id = table.Column<int>(type: "integer", nullable: false),
second_id = table.Column<int>(type: "integer", nullable: false),
user_id = table.Column<int>(type: "integer", nullable: false),
episode_id = table.Column<int>(type: "integer", nullable: false),
watched_percentage = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_watched_episodes", x => new { x.first_id, x.second_id });
table.PrimaryKey("pk_watched_episodes", x => new { x.user_id, x.episode_id });
table.ForeignKey(
name: "fk_watched_episodes_episodes_second_id",
column: x => x.second_id,
name: "fk_watched_episodes_episodes_episode_id",
column: x => x.episode_id,
principalTable: "episodes",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_watched_episodes_users_first_id",
column: x => x.first_id,
name: "fk_watched_episodes_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_collection_metadata_id_provider_id",
table: "collection_metadata_id",
column: "provider_id");
migrationBuilder.CreateIndex(
name: "ix_collections_slug",
table: "collections",
column: "slug",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_episode_metadata_id_provider_id",
table: "episode_metadata_id",
column: "provider_id");
migrationBuilder.CreateIndex(
name: "ix_episodes_season_id",
table: "episodes",
@ -583,54 +641,34 @@ namespace Kyoo.Postgresql.Migrations
unique: true);
migrationBuilder.CreateIndex(
name: "ix_link_collection_show_second_id",
name: "ix_link_collection_show_show_id",
table: "link_collection_show",
column: "second_id");
column: "show_id");
migrationBuilder.CreateIndex(
name: "ix_link_library_collection_second_id",
name: "ix_link_library_collection_library_id",
table: "link_library_collection",
column: "second_id");
column: "library_id");
migrationBuilder.CreateIndex(
name: "ix_link_library_provider_second_id",
name: "ix_link_library_provider_provider_id",
table: "link_library_provider",
column: "second_id");
column: "provider_id");
migrationBuilder.CreateIndex(
name: "ix_link_library_show_second_id",
name: "ix_link_library_show_show_id",
table: "link_library_show",
column: "second_id");
column: "show_id");
migrationBuilder.CreateIndex(
name: "ix_link_show_genre_second_id",
name: "ix_link_show_genre_show_id",
table: "link_show_genre",
column: "second_id");
column: "show_id");
migrationBuilder.CreateIndex(
name: "ix_link_user_show_second_id",
name: "ix_link_user_show_watched_id",
table: "link_user_show",
column: "second_id");
migrationBuilder.CreateIndex(
name: "ix_metadata_id_episode_second_id",
table: "metadata_id_episode",
column: "second_id");
migrationBuilder.CreateIndex(
name: "ix_metadata_id_people_second_id",
table: "metadata_id_people",
column: "second_id");
migrationBuilder.CreateIndex(
name: "ix_metadata_id_season_second_id",
table: "metadata_id_season",
column: "second_id");
migrationBuilder.CreateIndex(
name: "ix_metadata_id_show_second_id",
table: "metadata_id_show",
column: "second_id");
column: "watched_id");
migrationBuilder.CreateIndex(
name: "ix_people_slug",
@ -638,6 +676,11 @@ namespace Kyoo.Postgresql.Migrations
column: "slug",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_people_metadata_id_provider_id",
table: "people_metadata_id",
column: "provider_id");
migrationBuilder.CreateIndex(
name: "ix_people_roles_people_id",
table: "people_roles",
@ -654,6 +697,11 @@ namespace Kyoo.Postgresql.Migrations
column: "slug",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_season_metadata_id_provider_id",
table: "season_metadata_id",
column: "provider_id");
migrationBuilder.CreateIndex(
name: "ix_seasons_show_id_season_number",
table: "seasons",
@ -666,6 +714,11 @@ namespace Kyoo.Postgresql.Migrations
column: "slug",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_show_metadata_id_provider_id",
table: "show_metadata_id",
column: "provider_id");
migrationBuilder.CreateIndex(
name: "ix_shows_slug",
table: "shows",
@ -677,6 +730,11 @@ namespace Kyoo.Postgresql.Migrations
table: "shows",
column: "studio_id");
migrationBuilder.CreateIndex(
name: "ix_studio_metadata_id_provider_id",
table: "studio_metadata_id",
column: "provider_id");
migrationBuilder.CreateIndex(
name: "ix_studios_slug",
table: "studios",
@ -702,13 +760,19 @@ namespace Kyoo.Postgresql.Migrations
unique: true);
migrationBuilder.CreateIndex(
name: "ix_watched_episodes_second_id",
name: "ix_watched_episodes_episode_id",
table: "watched_episodes",
column: "second_id");
column: "episode_id");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "collection_metadata_id");
migrationBuilder.DropTable(
name: "episode_metadata_id");
migrationBuilder.DropTable(
name: "link_collection_show");
@ -728,20 +792,20 @@ namespace Kyoo.Postgresql.Migrations
name: "link_user_show");
migrationBuilder.DropTable(
name: "metadata_id_episode");
migrationBuilder.DropTable(
name: "metadata_id_people");
migrationBuilder.DropTable(
name: "metadata_id_season");
migrationBuilder.DropTable(
name: "metadata_id_show");
name: "people_metadata_id");
migrationBuilder.DropTable(
name: "people_roles");
migrationBuilder.DropTable(
name: "season_metadata_id");
migrationBuilder.DropTable(
name: "show_metadata_id");
migrationBuilder.DropTable(
name: "studio_metadata_id");
migrationBuilder.DropTable(
name: "tracks");
@ -758,10 +822,10 @@ namespace Kyoo.Postgresql.Migrations
name: "genres");
migrationBuilder.DropTable(
name: "providers");
name: "people");
migrationBuilder.DropTable(
name: "people");
name: "providers");
migrationBuilder.DropTable(
name: "episodes");

View File

@ -141,7 +141,7 @@ namespace Kyoo.Postgresql.Migrations
// language=PostgreSQL
migrationBuilder.Sql(@"
CREATE VIEW library_items AS
SELECT s.id, s.slug, s.title, s.overview, s.status, s.start_air, s.end_air, s.poster, CASE
SELECT s.id, s.slug, s.title, s.overview, s.status, s.start_air, s.end_air, s.images, CASE
WHEN s.is_movie THEN 'movie'::item_type
ELSE 'show'::item_type
END AS type
@ -149,11 +149,11 @@ namespace Kyoo.Postgresql.Migrations
WHERE NOT (EXISTS (
SELECT 1
FROM link_collection_show AS l
INNER JOIN collections AS c ON l.first_id = c.id
WHERE s.id = l.second_id))
INNER JOIN collections AS c ON l.collection_id = c.id
WHERE s.id = l.show_id))
UNION ALL
SELECT -c0.id, c0.slug, c0.name AS title, c0.overview, 'unknown'::status AS status,
NULL AS start_air, NULL AS end_air, c0.poster, 'collection'::item_type AS type
NULL AS start_air, NULL AS end_air, c0.images, 'collection'::item_type AS type
FROM collections AS c0");
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,8 @@
using System;
using System.Globalization;
using System.Linq.Expressions;
using System.Reflection;
using EFCore.NamingConventions.Internal;
using Kyoo.Models;
using Microsoft.EntityFrameworkCore;
using Npgsql;
@ -99,9 +101,55 @@ namespace Kyoo.Postgresql
.Property(x => x.ExtraData)
.HasColumnType("jsonb");
modelBuilder.Entity<LibraryItem>()
.Property(x => x.Images)
.HasColumnType("jsonb");
modelBuilder.Entity<Collection>()
.Property(x => x.Images)
.HasColumnType("jsonb");
modelBuilder.Entity<Show>()
.Property(x => x.Images)
.HasColumnType("jsonb");
modelBuilder.Entity<Season>()
.Property(x => x.Images)
.HasColumnType("jsonb");
modelBuilder.Entity<Episode>()
.Property(x => x.Images)
.HasColumnType("jsonb");
modelBuilder.Entity<People>()
.Property(x => x.Images)
.HasColumnType("jsonb");
modelBuilder.Entity<Provider>()
.Property(x => x.Images)
.HasColumnType("jsonb");
modelBuilder.Entity<User>()
.Property(x => x.Images)
.HasColumnType("jsonb");
base.OnModelCreating(modelBuilder);
}
/// <inheritdoc />
protected override string MetadataName<T>()
{
SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture);
return rewriter.RewriteName(typeof(T).Name + nameof(MetadataID));
}
/// <inheritdoc />
protected override string LinkName<T, T2>()
{
SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture);
return rewriter.RewriteName("Link" + typeof(T).Name + typeof(T2).Name);
}
/// <inheritdoc />
protected override string LinkNameFk<T>()
{
SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture);
return rewriter.RewriteName(typeof(T).Name + "ID");
}
/// <inheritdoc />
protected override bool IsDuplicateException(Exception ex)
{

View File

@ -66,7 +66,7 @@ namespace Kyoo.Postgresql
x.UseNpgsql(_configuration.GetDatabaseConnection("postgres"));
if (_environment.IsDevelopment())
x.EnableDetailedErrors().EnableSensitiveDataLogging();
});
}, ServiceLifetime.Transient);
}
/// <inheritdoc />

View File

@ -15,7 +15,7 @@ namespace Kyoo.SqLite.Migrations
.Annotation("Sqlite:Autoincrement", true),
Slug = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: true),
Poster = table.Column<string>(type: "TEXT", nullable: true),
Images = table.Column<string>(type: "TEXT", nullable: true),
Overview = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
@ -60,7 +60,7 @@ namespace Kyoo.SqLite.Migrations
.Annotation("Sqlite:Autoincrement", true),
Slug = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: true),
Poster = table.Column<string>(type: "TEXT", nullable: true)
Images = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
@ -75,8 +75,7 @@ namespace Kyoo.SqLite.Migrations
.Annotation("Sqlite:Autoincrement", true),
Slug = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: true),
Logo = table.Column<string>(type: "TEXT", nullable: true),
LogoExtension = table.Column<string>(type: "TEXT", nullable: true)
Images = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
@ -108,7 +107,8 @@ namespace Kyoo.SqLite.Migrations
Email = table.Column<string>(type: "TEXT", nullable: true),
Password = table.Column<string>(type: "TEXT", nullable: true),
Permissions = table.Column<string>(type: "TEXT", nullable: true),
ExtraData = table.Column<string>(type: "TEXT", nullable: true)
ExtraData = table.Column<string>(type: "TEXT", nullable: true),
Images = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
@ -116,74 +116,100 @@ namespace Kyoo.SqLite.Migrations
});
migrationBuilder.CreateTable(
name: "Link<Library, Collection>",
name: "LinkLibraryCollection",
columns: table => new
{
FirstID = table.Column<int>(type: "INTEGER", nullable: false),
SecondID = table.Column<int>(type: "INTEGER", nullable: false)
CollectionID = table.Column<int>(type: "INTEGER", nullable: false),
LibraryID = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Link<Library, Collection>", x => new { x.FirstID, x.SecondID });
table.PrimaryKey("PK_LinkLibraryCollection", x => new { x.CollectionID, x.LibraryID });
table.ForeignKey(
name: "FK_Link<Library, Collection>_Collections_SecondID",
column: x => x.SecondID,
name: "FK_LinkLibraryCollection_Collections_CollectionID",
column: x => x.CollectionID,
principalTable: "Collections",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Link<Library, Collection>_Libraries_FirstID",
column: x => x.FirstID,
name: "FK_LinkLibraryCollection_Libraries_LibraryID",
column: x => x.LibraryID,
principalTable: "Libraries",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Link<Library, Provider>",
name: "CollectionMetadataID",
columns: table => new
{
FirstID = table.Column<int>(type: "INTEGER", nullable: false),
SecondID = table.Column<int>(type: "INTEGER", nullable: false)
ResourceID = table.Column<int>(type: "INTEGER", nullable: false),
ProviderID = table.Column<int>(type: "INTEGER", nullable: false),
DataID = table.Column<string>(type: "TEXT", nullable: true),
Link = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Link<Library, Provider>", x => new { x.FirstID, x.SecondID });
table.PrimaryKey("PK_CollectionMetadataID", x => new { x.ResourceID, x.ProviderID });
table.ForeignKey(
name: "FK_Link<Library, Provider>_Libraries_FirstID",
column: x => x.FirstID,
principalTable: "Libraries",
name: "FK_CollectionMetadataID_Collections_ResourceID",
column: x => x.ResourceID,
principalTable: "Collections",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Link<Library, Provider>_Providers_SecondID",
column: x => x.SecondID,
name: "FK_CollectionMetadataID_Providers_ProviderID",
column: x => x.ProviderID,
principalTable: "Providers",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MetadataID<People>",
name: "LinkLibraryProvider",
columns: table => new
{
FirstID = table.Column<int>(type: "INTEGER", nullable: false),
SecondID = table.Column<int>(type: "INTEGER", nullable: false),
LibraryID = table.Column<int>(type: "INTEGER", nullable: false),
ProviderID = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LinkLibraryProvider", x => new { x.LibraryID, x.ProviderID });
table.ForeignKey(
name: "FK_LinkLibraryProvider_Libraries_LibraryID",
column: x => x.LibraryID,
principalTable: "Libraries",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_LinkLibraryProvider_Providers_ProviderID",
column: x => x.ProviderID,
principalTable: "Providers",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PeopleMetadataID",
columns: table => new
{
ResourceID = table.Column<int>(type: "INTEGER", nullable: false),
ProviderID = table.Column<int>(type: "INTEGER", nullable: false),
DataID = table.Column<string>(type: "TEXT", nullable: true),
Link = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MetadataID<People>", x => new { x.FirstID, x.SecondID });
table.PrimaryKey("PK_PeopleMetadataID", x => new { x.ResourceID, x.ProviderID });
table.ForeignKey(
name: "FK_MetadataID<People>_People_FirstID",
column: x => x.FirstID,
name: "FK_PeopleMetadataID_People_ResourceID",
column: x => x.ResourceID,
principalTable: "People",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MetadataID<People>_Providers_SecondID",
column: x => x.SecondID,
name: "FK_PeopleMetadataID_Providers_ProviderID",
column: x => x.ProviderID,
principalTable: "Providers",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
@ -201,12 +227,9 @@ namespace Kyoo.SqLite.Migrations
Path = table.Column<string>(type: "TEXT", nullable: true),
Overview = table.Column<string>(type: "TEXT", nullable: true),
Status = table.Column<int>(type: "INTEGER", nullable: false),
TrailerUrl = table.Column<string>(type: "TEXT", nullable: true),
StartAir = table.Column<DateTime>(type: "TEXT", nullable: true),
EndAir = table.Column<DateTime>(type: "TEXT", nullable: true),
Poster = table.Column<string>(type: "TEXT", nullable: true),
Logo = table.Column<string>(type: "TEXT", nullable: true),
Backdrop = table.Column<string>(type: "TEXT", nullable: true),
Images = table.Column<string>(type: "TEXT", nullable: true),
IsMovie = table.Column<bool>(type: "INTEGER", nullable: false),
StudioID = table.Column<int>(type: "INTEGER", nullable: true)
},
@ -222,134 +245,133 @@ namespace Kyoo.SqLite.Migrations
});
migrationBuilder.CreateTable(
name: "Link<Collection, Show>",
name: "StudioMetadataID",
columns: table => new
{
FirstID = table.Column<int>(type: "INTEGER", nullable: false),
SecondID = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Link<Collection, Show>", x => new { x.FirstID, x.SecondID });
table.ForeignKey(
name: "FK_Link<Collection, Show>_Collections_FirstID",
column: x => x.FirstID,
principalTable: "Collections",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Link<Collection, Show>_Shows_SecondID",
column: x => x.SecondID,
principalTable: "Shows",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Link<Library, Show>",
columns: table => new
{
FirstID = table.Column<int>(type: "INTEGER", nullable: false),
SecondID = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Link<Library, Show>", x => new { x.FirstID, x.SecondID });
table.ForeignKey(
name: "FK_Link<Library, Show>_Libraries_FirstID",
column: x => x.FirstID,
principalTable: "Libraries",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Link<Library, Show>_Shows_SecondID",
column: x => x.SecondID,
principalTable: "Shows",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Link<Show, Genre>",
columns: table => new
{
FirstID = table.Column<int>(type: "INTEGER", nullable: false),
SecondID = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Link<Show, Genre>", x => new { x.FirstID, x.SecondID });
table.ForeignKey(
name: "FK_Link<Show, Genre>_Genres_SecondID",
column: x => x.SecondID,
principalTable: "Genres",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Link<Show, Genre>_Shows_FirstID",
column: x => x.FirstID,
principalTable: "Shows",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Link<User, Show>",
columns: table => new
{
FirstID = table.Column<int>(type: "INTEGER", nullable: false),
SecondID = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Link<User, Show>", x => new { x.FirstID, x.SecondID });
table.ForeignKey(
name: "FK_Link<User, Show>_Shows_SecondID",
column: x => x.SecondID,
principalTable: "Shows",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Link<User, Show>_Users_FirstID",
column: x => x.FirstID,
principalTable: "Users",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MetadataID<Show>",
columns: table => new
{
FirstID = table.Column<int>(type: "INTEGER", nullable: false),
SecondID = table.Column<int>(type: "INTEGER", nullable: false),
ResourceID = table.Column<int>(type: "INTEGER", nullable: false),
ProviderID = table.Column<int>(type: "INTEGER", nullable: false),
DataID = table.Column<string>(type: "TEXT", nullable: true),
Link = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MetadataID<Show>", x => new { x.FirstID, x.SecondID });
table.PrimaryKey("PK_StudioMetadataID", x => new { x.ResourceID, x.ProviderID });
table.ForeignKey(
name: "FK_MetadataID<Show>_Providers_SecondID",
column: x => x.SecondID,
name: "FK_StudioMetadataID_Providers_ProviderID",
column: x => x.ProviderID,
principalTable: "Providers",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MetadataID<Show>_Shows_FirstID",
column: x => x.FirstID,
name: "FK_StudioMetadataID_Studios_ResourceID",
column: x => x.ResourceID,
principalTable: "Studios",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "LinkCollectionShow",
columns: table => new
{
CollectionID = table.Column<int>(type: "INTEGER", nullable: false),
ShowID = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LinkCollectionShow", x => new { x.CollectionID, x.ShowID });
table.ForeignKey(
name: "FK_LinkCollectionShow_Collections_CollectionID",
column: x => x.CollectionID,
principalTable: "Collections",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_LinkCollectionShow_Shows_ShowID",
column: x => x.ShowID,
principalTable: "Shows",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "LinkLibraryShow",
columns: table => new
{
LibraryID = table.Column<int>(type: "INTEGER", nullable: false),
ShowID = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LinkLibraryShow", x => new { x.LibraryID, x.ShowID });
table.ForeignKey(
name: "FK_LinkLibraryShow_Libraries_LibraryID",
column: x => x.LibraryID,
principalTable: "Libraries",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_LinkLibraryShow_Shows_ShowID",
column: x => x.ShowID,
principalTable: "Shows",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "LinkShowGenre",
columns: table => new
{
GenreID = table.Column<int>(type: "INTEGER", nullable: false),
ShowID = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LinkShowGenre", x => new { x.GenreID, x.ShowID });
table.ForeignKey(
name: "FK_LinkShowGenre_Genres_GenreID",
column: x => x.GenreID,
principalTable: "Genres",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_LinkShowGenre_Shows_ShowID",
column: x => x.ShowID,
principalTable: "Shows",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "LinkUserShow",
columns: table => new
{
UsersID = table.Column<int>(type: "INTEGER", nullable: false),
WatchedID = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LinkUserShow", x => new { x.UsersID, x.WatchedID });
table.ForeignKey(
name: "FK_LinkUserShow_Shows_WatchedID",
column: x => x.WatchedID,
principalTable: "Shows",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_LinkUserShow_Users_UsersID",
column: x => x.UsersID,
principalTable: "Users",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PeopleRoles",
columns: table => new
{
ID = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ForPeople = table.Column<bool>(type: "INTEGER", nullable: false),
PeopleID = table.Column<int>(type: "INTEGER", nullable: false),
ShowID = table.Column<int>(type: "INTEGER", nullable: false),
Type = table.Column<string>(type: "TEXT", nullable: true),
@ -385,7 +407,7 @@ namespace Kyoo.SqLite.Migrations
Overview = table.Column<string>(type: "TEXT", nullable: true),
StartDate = table.Column<DateTime>(type: "TEXT", nullable: true),
EndDate = table.Column<DateTime>(type: "TEXT", nullable: true),
Poster = table.Column<string>(type: "TEXT", nullable: true)
Images = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
@ -398,6 +420,32 @@ namespace Kyoo.SqLite.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ShowMetadataID",
columns: table => new
{
ResourceID = table.Column<int>(type: "INTEGER", nullable: false),
ProviderID = table.Column<int>(type: "INTEGER", nullable: false),
DataID = table.Column<string>(type: "TEXT", nullable: true),
Link = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ShowMetadataID", x => new { x.ResourceID, x.ProviderID });
table.ForeignKey(
name: "FK_ShowMetadataID_Providers_ProviderID",
column: x => x.ProviderID,
principalTable: "Providers",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ShowMetadataID_Shows_ResourceID",
column: x => x.ResourceID,
principalTable: "Shows",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Episodes",
columns: table => new
@ -411,7 +459,7 @@ namespace Kyoo.SqLite.Migrations
EpisodeNumber = table.Column<int>(type: "INTEGER", nullable: true),
AbsoluteNumber = table.Column<int>(type: "INTEGER", nullable: true),
Path = table.Column<string>(type: "TEXT", nullable: true),
Thumb = table.Column<string>(type: "TEXT", nullable: true),
Images = table.Column<string>(type: "TEXT", nullable: true),
Title = table.Column<string>(type: "TEXT", nullable: true),
Overview = table.Column<string>(type: "TEXT", nullable: true),
ReleaseDate = table.Column<DateTime>(type: "TEXT", nullable: true)
@ -434,52 +482,52 @@ namespace Kyoo.SqLite.Migrations
});
migrationBuilder.CreateTable(
name: "MetadataID<Season>",
name: "SeasonMetadataID",
columns: table => new
{
FirstID = table.Column<int>(type: "INTEGER", nullable: false),
SecondID = table.Column<int>(type: "INTEGER", nullable: false),
ResourceID = table.Column<int>(type: "INTEGER", nullable: false),
ProviderID = table.Column<int>(type: "INTEGER", nullable: false),
DataID = table.Column<string>(type: "TEXT", nullable: true),
Link = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MetadataID<Season>", x => new { x.FirstID, x.SecondID });
table.PrimaryKey("PK_SeasonMetadataID", x => new { x.ResourceID, x.ProviderID });
table.ForeignKey(
name: "FK_MetadataID<Season>_Providers_SecondID",
column: x => x.SecondID,
name: "FK_SeasonMetadataID_Providers_ProviderID",
column: x => x.ProviderID,
principalTable: "Providers",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MetadataID<Season>_Seasons_FirstID",
column: x => x.FirstID,
name: "FK_SeasonMetadataID_Seasons_ResourceID",
column: x => x.ResourceID,
principalTable: "Seasons",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MetadataID<Episode>",
name: "EpisodeMetadataID",
columns: table => new
{
FirstID = table.Column<int>(type: "INTEGER", nullable: false),
SecondID = table.Column<int>(type: "INTEGER", nullable: false),
ResourceID = table.Column<int>(type: "INTEGER", nullable: false),
ProviderID = table.Column<int>(type: "INTEGER", nullable: false),
DataID = table.Column<string>(type: "TEXT", nullable: true),
Link = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MetadataID<Episode>", x => new { x.FirstID, x.SecondID });
table.PrimaryKey("PK_EpisodeMetadataID", x => new { x.ResourceID, x.ProviderID });
table.ForeignKey(
name: "FK_MetadataID<Episode>_Episodes_FirstID",
column: x => x.FirstID,
name: "FK_EpisodeMetadataID_Episodes_ResourceID",
column: x => x.ResourceID,
principalTable: "Episodes",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MetadataID<Episode>_Providers_SecondID",
column: x => x.SecondID,
name: "FK_EpisodeMetadataID_Providers_ProviderID",
column: x => x.ProviderID,
principalTable: "Providers",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
@ -518,33 +566,43 @@ namespace Kyoo.SqLite.Migrations
name: "WatchedEpisodes",
columns: table => new
{
FirstID = table.Column<int>(type: "INTEGER", nullable: false),
SecondID = table.Column<int>(type: "INTEGER", nullable: false),
UserID = table.Column<int>(type: "INTEGER", nullable: false),
EpisodeID = table.Column<int>(type: "INTEGER", nullable: false),
WatchedPercentage = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WatchedEpisodes", x => new { x.FirstID, x.SecondID });
table.PrimaryKey("PK_WatchedEpisodes", x => new { x.UserID, x.EpisodeID });
table.ForeignKey(
name: "FK_WatchedEpisodes_Episodes_SecondID",
column: x => x.SecondID,
name: "FK_WatchedEpisodes_Episodes_EpisodeID",
column: x => x.EpisodeID,
principalTable: "Episodes",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_WatchedEpisodes_Users_FirstID",
column: x => x.FirstID,
name: "FK_WatchedEpisodes_Users_UserID",
column: x => x.UserID,
principalTable: "Users",
principalColumn: "ID",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_CollectionMetadataID_ProviderID",
table: "CollectionMetadataID",
column: "ProviderID");
migrationBuilder.CreateIndex(
name: "IX_Collections_Slug",
table: "Collections",
column: "Slug",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_EpisodeMetadataID_ProviderID",
table: "EpisodeMetadataID",
column: "ProviderID");
migrationBuilder.CreateIndex(
name: "IX_Episodes_SeasonID",
table: "Episodes",
@ -575,54 +633,34 @@ namespace Kyoo.SqLite.Migrations
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Link<Collection, Show>_SecondID",
table: "Link<Collection, Show>",
column: "SecondID");
name: "IX_LinkCollectionShow_ShowID",
table: "LinkCollectionShow",
column: "ShowID");
migrationBuilder.CreateIndex(
name: "IX_Link<Library, Collection>_SecondID",
table: "Link<Library, Collection>",
column: "SecondID");
name: "IX_LinkLibraryCollection_LibraryID",
table: "LinkLibraryCollection",
column: "LibraryID");
migrationBuilder.CreateIndex(
name: "IX_Link<Library, Provider>_SecondID",
table: "Link<Library, Provider>",
column: "SecondID");
name: "IX_LinkLibraryProvider_ProviderID",
table: "LinkLibraryProvider",
column: "ProviderID");
migrationBuilder.CreateIndex(
name: "IX_Link<Library, Show>_SecondID",
table: "Link<Library, Show>",
column: "SecondID");
name: "IX_LinkLibraryShow_ShowID",
table: "LinkLibraryShow",
column: "ShowID");
migrationBuilder.CreateIndex(
name: "IX_Link<Show, Genre>_SecondID",
table: "Link<Show, Genre>",
column: "SecondID");
name: "IX_LinkShowGenre_ShowID",
table: "LinkShowGenre",
column: "ShowID");
migrationBuilder.CreateIndex(
name: "IX_Link<User, Show>_SecondID",
table: "Link<User, Show>",
column: "SecondID");
migrationBuilder.CreateIndex(
name: "IX_MetadataID<Episode>_SecondID",
table: "MetadataID<Episode>",
column: "SecondID");
migrationBuilder.CreateIndex(
name: "IX_MetadataID<People>_SecondID",
table: "MetadataID<People>",
column: "SecondID");
migrationBuilder.CreateIndex(
name: "IX_MetadataID<Season>_SecondID",
table: "MetadataID<Season>",
column: "SecondID");
migrationBuilder.CreateIndex(
name: "IX_MetadataID<Show>_SecondID",
table: "MetadataID<Show>",
column: "SecondID");
name: "IX_LinkUserShow_WatchedID",
table: "LinkUserShow",
column: "WatchedID");
migrationBuilder.CreateIndex(
name: "IX_People_Slug",
@ -630,6 +668,11 @@ namespace Kyoo.SqLite.Migrations
column: "Slug",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PeopleMetadataID_ProviderID",
table: "PeopleMetadataID",
column: "ProviderID");
migrationBuilder.CreateIndex(
name: "IX_PeopleRoles_PeopleID",
table: "PeopleRoles",
@ -646,6 +689,11 @@ namespace Kyoo.SqLite.Migrations
column: "Slug",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SeasonMetadataID_ProviderID",
table: "SeasonMetadataID",
column: "ProviderID");
migrationBuilder.CreateIndex(
name: "IX_Seasons_ShowID_SeasonNumber",
table: "Seasons",
@ -658,6 +706,11 @@ namespace Kyoo.SqLite.Migrations
column: "Slug",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ShowMetadataID_ProviderID",
table: "ShowMetadataID",
column: "ProviderID");
migrationBuilder.CreateIndex(
name: "IX_Shows_Slug",
table: "Shows",
@ -669,6 +722,11 @@ namespace Kyoo.SqLite.Migrations
table: "Shows",
column: "StudioID");
migrationBuilder.CreateIndex(
name: "IX_StudioMetadataID_ProviderID",
table: "StudioMetadataID",
column: "ProviderID");
migrationBuilder.CreateIndex(
name: "IX_Studios_Slug",
table: "Studios",
@ -694,46 +752,52 @@ namespace Kyoo.SqLite.Migrations
unique: true);
migrationBuilder.CreateIndex(
name: "IX_WatchedEpisodes_SecondID",
name: "IX_WatchedEpisodes_EpisodeID",
table: "WatchedEpisodes",
column: "SecondID");
column: "EpisodeID");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Link<Collection, Show>");
name: "CollectionMetadataID");
migrationBuilder.DropTable(
name: "Link<Library, Collection>");
name: "EpisodeMetadataID");
migrationBuilder.DropTable(
name: "Link<Library, Provider>");
name: "LinkCollectionShow");
migrationBuilder.DropTable(
name: "Link<Library, Show>");
name: "LinkLibraryCollection");
migrationBuilder.DropTable(
name: "Link<Show, Genre>");
name: "LinkLibraryProvider");
migrationBuilder.DropTable(
name: "Link<User, Show>");
name: "LinkLibraryShow");
migrationBuilder.DropTable(
name: "MetadataID<Episode>");
name: "LinkShowGenre");
migrationBuilder.DropTable(
name: "MetadataID<People>");
name: "LinkUserShow");
migrationBuilder.DropTable(
name: "MetadataID<Season>");
migrationBuilder.DropTable(
name: "MetadataID<Show>");
name: "PeopleMetadataID");
migrationBuilder.DropTable(
name: "PeopleRoles");
migrationBuilder.DropTable(
name: "SeasonMetadataID");
migrationBuilder.DropTable(
name: "ShowMetadataID");
migrationBuilder.DropTable(
name: "StudioMetadataID");
migrationBuilder.DropTable(
name: "Tracks");
@ -750,10 +814,10 @@ namespace Kyoo.SqLite.Migrations
name: "Genres");
migrationBuilder.DropTable(
name: "Providers");
name: "People");
migrationBuilder.DropTable(
name: "People");
name: "Providers");
migrationBuilder.DropTable(
name: "Episodes");

View File

@ -154,19 +154,19 @@ namespace Kyoo.SqLite.Migrations
// language=SQLite
migrationBuilder.Sql(@"
CREATE VIEW LibraryItems AS
SELECT s.ID, s.Slug, s.Title, s.Overview, s.Status, s.StartAir, s.EndAir, s.Poster, CASE
SELECT s.ID, s.Slug, s.Title, s.Overview, s.Status, s.StartAir, s.EndAir, s.Images, CASE
WHEN s.IsMovie THEN 1
ELSE 0
END AS Type
FROM Shows AS s
WHERE NOT (EXISTS (
SELECT 1
FROM 'Link<Collection, Show>' AS l
INNER JOIN Collections AS c ON l.FirstID = c.ID
WHERE s.ID = l.SecondID))
FROM LinkCollectionShow AS l
INNER JOIN Collections AS c ON l.CollectionID = c.ID
WHERE s.ID = l.ShowID))
UNION ALL
SELECT -c0.ID, c0.Slug, c0.Name AS Title, c0.Overview, 3 AS Status,
NULL AS StartAir, NULL AS EndAir, c0.Poster, 2 AS Type
SELECT -c0.ID, c0.Slug, c0.Name AS Title, c0.Overview, 0 AS Status,
NULL AS StartAir, NULL AS EndAir, c0.Images, 2 AS Type
FROM collections AS c0");
}

File diff suppressed because it is too large Load Diff

View File

@ -102,12 +102,41 @@ namespace Kyoo.SqLite
.Property(x => x.Type)
.HasConversion<int>();
ValueConverter<Dictionary<string, string>, string> jsonConvertor = new(
ValueConverter<Dictionary<string, string>, string> extraDataConvertor = new(
x => JsonConvert.SerializeObject(x),
x => JsonConvert.DeserializeObject<Dictionary<string, string>>(x));
modelBuilder.Entity<User>()
.Property(x => x.ExtraData)
.HasConversion(extraDataConvertor);
ValueConverter<Dictionary<int, string>, string> jsonConvertor = new(
x => JsonConvert.SerializeObject(x),
x => JsonConvert.DeserializeObject<Dictionary<int, string>>(x));
modelBuilder.Entity<LibraryItem>()
.Property(x => x.Images)
.HasConversion(jsonConvertor);
modelBuilder.Entity<Collection>()
.Property(x => x.Images)
.HasConversion(jsonConvertor);
modelBuilder.Entity<Show>()
.Property(x => x.Images)
.HasConversion(jsonConvertor);
modelBuilder.Entity<Season>()
.Property(x => x.Images)
.HasConversion(jsonConvertor);
modelBuilder.Entity<Episode>()
.Property(x => x.Images)
.HasConversion(jsonConvertor);
modelBuilder.Entity<People>()
.Property(x => x.Images)
.HasConversion(jsonConvertor);
modelBuilder.Entity<Provider>()
.Property(x => x.Images)
.HasConversion(jsonConvertor);
modelBuilder.Entity<User>()
.Property(x => x.Images)
.HasConversion(jsonConvertor);
modelBuilder.Entity<LibraryItem>()
.ToView("LibraryItems")
@ -115,6 +144,24 @@ namespace Kyoo.SqLite
base.OnModelCreating(modelBuilder);
}
/// <inheritdoc />
protected override string MetadataName<T>()
{
return typeof(T).Name + nameof(MetadataID);
}
/// <inheritdoc />
protected override string LinkName<T, T2>()
{
return "Link" + typeof(T).Name + typeof(T2).Name;
}
/// <inheritdoc />
protected override string LinkNameFk<T>()
{
return typeof(T).Name + "ID";
}
/// <inheritdoc />
protected override bool IsDuplicateException(Exception ex)
{

View File

@ -66,7 +66,7 @@ namespace Kyoo.SqLite
x.UseSqlite(_configuration.GetDatabaseConnection("sqlite"));
if (_environment.IsDevelopment())
x.EnableDetailedErrors().EnableSensitiveDataLogging();
});
}, ServiceLifetime.Transient);
}
/// <inheritdoc />

View File

@ -1,67 +0,0 @@
using System;
using System.Threading.Tasks;
using Kyoo.Controllers;
using Xunit.Abstractions;
namespace Kyoo.Tests
{
public class RepositoryActivator : IDisposable, IAsyncDisposable
{
public TestContext Context { get; }
public ILibraryManager LibraryManager { get; }
private readonly DatabaseContext _database;
public RepositoryActivator(ITestOutputHelper output, PostgresFixture postgres = null)
{
Context = postgres == null
? new SqLiteTestContext(output)
: new PostgresTestContext(postgres, output);
_database = Context.New();
ProviderRepository provider = new(_database);
LibraryRepository library = new(_database, provider);
CollectionRepository collection = new(_database);
GenreRepository genre = new(_database);
StudioRepository studio = new(_database);
PeopleRepository people = new(_database, provider,
new Lazy<IShowRepository>(() => LibraryManager.ShowRepository));
ShowRepository show = new(_database, studio, people, genre, provider);
SeasonRepository season = new(_database, provider);
LibraryItemRepository libraryItem = new(_database,
new Lazy<ILibraryRepository>(() => LibraryManager.LibraryRepository));
TrackRepository track = new(_database);
EpisodeRepository episode = new(_database, provider, track);
UserRepository user = new(_database);
LibraryManager = new LibraryManager(new IBaseRepository[] {
provider,
library,
libraryItem,
collection,
show,
season,
episode,
track,
people,
studio,
genre,
user
});
}
public void Dispose()
{
_database.Dispose();
Context.Dispose();
GC.SuppressFinalize(this);
}
public async ValueTask DisposeAsync()
{
await _database.DisposeAsync();
await Context.DisposeAsync();
}
}
}

View File

@ -1,37 +0,0 @@
using Kyoo.Controllers;
using Kyoo.Models;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Database
{
namespace SqLite
{
public class CollectionTests : ACollectionTests
{
public CollectionTests(ITestOutputHelper output)
: base(new RepositoryActivator(output)) { }
}
}
namespace PostgreSQL
{
[Collection(nameof(Postgresql))]
public class CollectionTests : ACollectionTests
{
public CollectionTests(PostgresFixture postgres, ITestOutputHelper output)
: base(new RepositoryActivator(output, postgres)) { }
}
}
public abstract class ACollectionTests : RepositoryTests<Collection>
{
private readonly ICollectionRepository _repository;
protected ACollectionTests(RepositoryActivator repositories)
: base(repositories)
{
_repository = Repositories.LibraryManager.CollectionRepository;
}
}
}

View File

@ -1,51 +0,0 @@
using System.Linq;
using System.Threading.Tasks;
using Kyoo.Controllers;
using Kyoo.Models;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Database
{
namespace SqLite
{
public class LibraryTests : ALibraryTests
{
public LibraryTests(ITestOutputHelper output)
: base(new RepositoryActivator(output)) { }
}
}
namespace PostgreSQL
{
[Collection(nameof(Postgresql))]
public class LibraryTests : ALibraryTests
{
public LibraryTests(PostgresFixture postgres, ITestOutputHelper output)
: base(new RepositoryActivator(output, postgres)) { }
}
}
public abstract class ALibraryTests : RepositoryTests<Library>
{
private readonly ILibraryRepository _repository;
protected ALibraryTests(RepositoryActivator repositories)
: base(repositories)
{
_repository = Repositories.LibraryManager.LibraryRepository;
}
[Fact]
public async Task CreateWithProvider()
{
Library library = TestSample.GetNew<Library>();
library.Providers = new[] { TestSample.Get<Provider>() };
await _repository.Create(library);
Library retrieved = await _repository.Get(2);
await Repositories.LibraryManager.Load(retrieved, x => x.Providers);
Assert.Equal(1, retrieved.Providers.Count);
Assert.Equal(TestSample.Get<Provider>().Slug, retrieved.Providers.First().Slug);
}
}
}

View File

@ -1,37 +0,0 @@
using Kyoo.Controllers;
using Kyoo.Models;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Database
{
namespace SqLite
{
public class PeopleTests : APeopleTests
{
public PeopleTests(ITestOutputHelper output)
: base(new RepositoryActivator(output)) { }
}
}
namespace PostgreSQL
{
[Collection(nameof(Postgresql))]
public class PeopleTests : APeopleTests
{
public PeopleTests(PostgresFixture postgres, ITestOutputHelper output)
: base(new RepositoryActivator(output, postgres)) { }
}
}
public abstract class APeopleTests : RepositoryTests<People>
{
private readonly IPeopleRepository _repository;
protected APeopleTests(RepositoryActivator repositories)
: base(repositories)
{
_repository = Repositories.LibraryManager.PeopleRepository;
}
}
}

View File

@ -1,79 +0,0 @@
using System.Threading.Tasks;
using Kyoo.Controllers;
using Kyoo.Models;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Database
{
namespace SqLite
{
public class SeasonTests : ASeasonTests
{
public SeasonTests(ITestOutputHelper output)
: base(new RepositoryActivator(output)) { }
}
}
namespace PostgreSQL
{
[Collection(nameof(Postgresql))]
public class SeasonTests : ASeasonTests
{
public SeasonTests(PostgresFixture postgres, ITestOutputHelper output)
: base(new RepositoryActivator(output, postgres)) { }
}
}
public abstract class ASeasonTests : RepositoryTests<Season>
{
private readonly ISeasonRepository _repository;
protected ASeasonTests(RepositoryActivator repositories)
: base(repositories)
{
_repository = Repositories.LibraryManager.SeasonRepository;
}
[Fact]
public async Task SlugEditTest()
{
Season season = await _repository.Get(1);
Assert.Equal("anohana-s1", season.Slug);
Show show = new()
{
ID = season.ShowID,
Slug = "new-slug"
};
await Repositories.LibraryManager.ShowRepository.Edit(show, false);
season = await _repository.Get(1);
Assert.Equal("new-slug-s1", season.Slug);
}
[Fact]
public async Task SeasonNumberEditTest()
{
Season season = await _repository.Get(1);
Assert.Equal("anohana-s1", season.Slug);
await _repository.Edit(new Season
{
ID = 1,
SeasonNumber = 2
}, false);
season = await _repository.Get(1);
Assert.Equal("anohana-s2", season.Slug);
}
[Fact]
public async Task SeasonCreationSlugTest()
{
Season season = await _repository.Create(new Season
{
ShowID = TestSample.Get<Show>().ID,
SeasonNumber = 2
});
Assert.Equal($"{TestSample.Get<Show>().Slug}-s2", season.Slug);
}
}
}

View File

@ -1,212 +0,0 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Kyoo.Models;
using Kyoo.Models.Attributes;
using Xunit;
namespace Kyoo.Tests.Utility
{
public class MergerTests
{
[Fact]
public void NullifyTest()
{
Genre genre = new("test")
{
ID = 5
};
Merger.Nullify(genre);
Assert.Equal(0, genre.ID);
Assert.Null(genre.Name);
Assert.Null(genre.Slug);
}
[Fact]
public void MergeTest()
{
Genre genre = new()
{
ID = 5
};
Genre genre2 = new()
{
Name = "test"
};
Genre ret = Merger.Merge(genre, genre2);
Assert.True(ReferenceEquals(genre, ret));
Assert.Equal(5, ret.ID);
Assert.Equal("test", genre.Name);
Assert.Null(genre.Slug);
}
[Fact]
[SuppressMessage("ReSharper", "ExpressionIsAlwaysNull")]
public void MergeNullTests()
{
Genre genre = new()
{
ID = 5
};
Assert.True(ReferenceEquals(genre, Merger.Merge(genre, null)));
Assert.True(ReferenceEquals(genre, Merger.Merge(null, genre)));
Assert.Null(Merger.Merge<Genre>(null, null));
}
private class TestIOnMerge : IOnMerge
{
public void OnMerge(object other)
{
Exception exception = new();
exception.Data[0] = other;
throw exception;
}
}
[Fact]
public void OnMergeTest()
{
TestIOnMerge test = new();
TestIOnMerge test2 = new();
Assert.Throws<Exception>(() => Merger.Merge(test, test2));
try
{
Merger.Merge(test, test2);
}
catch (Exception ex)
{
Assert.True(ReferenceEquals(test2, ex.Data[0]));
}
}
private class Test
{
public int ID { get; set; }
public int[] Numbers { get; set; }
}
[Fact]
public void GlobalMergeListTest()
{
Test test = new()
{
ID = 5,
Numbers = new [] { 1 }
};
Test test2 = new()
{
Numbers = new [] { 3 }
};
Test ret = Merger.Merge(test, test2);
Assert.True(ReferenceEquals(test, ret));
Assert.Equal(5, ret.ID);
Assert.Equal(2, ret.Numbers.Length);
Assert.Equal(1, ret.Numbers[0]);
Assert.Equal(3, ret.Numbers[1]);
}
[Fact]
public void GlobalMergeListDuplicatesTest()
{
Test test = new()
{
ID = 5,
Numbers = new [] { 1 }
};
Test test2 = new()
{
Numbers = new []
{
1,
3,
3
}
};
Test ret = Merger.Merge(test, test2);
Assert.True(ReferenceEquals(test, ret));
Assert.Equal(5, ret.ID);
Assert.Equal(4, ret.Numbers.Length);
Assert.Equal(1, ret.Numbers[0]);
Assert.Equal(1, ret.Numbers[1]);
Assert.Equal(3, ret.Numbers[2]);
Assert.Equal(3, ret.Numbers[3]);
}
[Fact]
public void GlobalMergeListDuplicatesResourcesTest()
{
Show test = new()
{
ID = 5,
Genres = new [] { new Genre("test") }
};
Show test2 = new()
{
Genres = new []
{
new Genre("test"),
new Genre("test2")
}
};
Show ret = Merger.Merge(test, test2);
Assert.True(ReferenceEquals(test, ret));
Assert.Equal(5, ret.ID);
Assert.Equal(2, ret.Genres.Count);
Assert.Equal("test", ret.Genres.ToArray()[0].Slug);
Assert.Equal("test2", ret.Genres.ToArray()[1].Slug);
}
[Fact]
public void MergeListTest()
{
int[] first = { 1 };
int[] second = {
3,
3
};
int[] ret = Merger.MergeLists(first, second);
Assert.Equal(3, ret.Length);
Assert.Equal(1, ret[0]);
Assert.Equal(3, ret[1]);
Assert.Equal(3, ret[2]);
}
[Fact]
public void MergeListDuplicateTest()
{
int[] first = { 1 };
int[] second = {
1,
3,
3
};
int[] ret = Merger.MergeLists(first, second);
Assert.Equal(4, ret.Length);
Assert.Equal(1, ret[0]);
Assert.Equal(1, ret[1]);
Assert.Equal(3, ret[2]);
Assert.Equal(3, ret[3]);
}
[Fact]
public void MergeListDuplicateCustomEqualityTest()
{
int[] first = { 1 };
int[] second = {
3,
2
};
int[] ret = Merger.MergeLists(first, second, (x, y) => x % 2 == y % 2);
Assert.Equal(2, ret.Length);
Assert.Equal(1, ret[0]);
Assert.Equal(2, ret[1]);
}
}
}

View File

@ -0,0 +1,79 @@
using System.Collections.Generic;
using Kyoo.Models;
using TMDbLib.Objects.Search;
namespace Kyoo.TheMovieDb
{
/// <summary>
/// A class containing extensions methods to convert from TMDB's types to Kyoo's types.
/// </summary>
public static partial class Convertors
{
/// <summary>
/// Convert a <see cref="SearchCollection"/> into a <see cref="Collection"/>.
/// </summary>
/// <param name="collection">The collection to convert.</param>
/// <param name="provider">The provider representing TheMovieDb.</param>
/// <returns>The converted collection as a <see cref="Collection"/>.</returns>
public static Collection ToCollection(this TMDbLib.Objects.Collections.Collection collection, Provider provider)
{
return new Collection
{
Slug = Utility.ToSlug(collection.Name),
Name = collection.Name,
Overview = collection.Overview,
Images = new Dictionary<int, string>
{
[Images.Poster] = collection.PosterPath != null
? $"https://image.tmdb.org/t/p/original{collection.PosterPath}"
: null,
[Images.Thumbnail] = collection.BackdropPath != null
? $"https://image.tmdb.org/t/p/original{collection.BackdropPath}"
: null
},
ExternalIDs = new []
{
new MetadataID
{
Provider = provider,
Link = $"https://www.themoviedb.org/collection/{collection.Id}",
DataID = collection.Id.ToString()
}
}
};
}
/// <summary>
/// Convert a <see cref="SearchCollection"/> into a <see cref="Collection"/>.
/// </summary>
/// <param name="collection">The collection to convert.</param>
/// <param name="provider">The provider representing TheMovieDb.</param>
/// <returns>The converted collection as a <see cref="Collection"/>.</returns>
public static Collection ToCollection(this SearchCollection collection, Provider provider)
{
return new Collection
{
Slug = Utility.ToSlug(collection.Name),
Name = collection.Name,
Images = new Dictionary<int, string>
{
[Images.Poster] = collection.PosterPath != null
? $"https://image.tmdb.org/t/p/original{collection.PosterPath}"
: null,
[Images.Thumbnail] = collection.BackdropPath != null
? $"https://image.tmdb.org/t/p/original{collection.BackdropPath}"
: null
},
ExternalIDs = new []
{
new MetadataID
{
Provider = provider,
Link = $"https://www.themoviedb.org/collection/{collection.Id}",
DataID = collection.Id.ToString()
}
}
};
}
}
}

View File

@ -0,0 +1,47 @@
using System.Collections.Generic;
using Kyoo.Models;
using TMDbLib.Objects.TvShows;
namespace Kyoo.TheMovieDb
{
/// <summary>
/// A class containing extensions methods to convert from TMDB's types to Kyoo's types.
/// </summary>
public static partial class Convertors
{
/// <summary>
/// Convert a <see cref="TvEpisode"/> into a <see cref="Episode"/>.
/// </summary>
/// <param name="episode">The episode to convert.</param>
/// <param name="showID">The ID of the show inside TheMovieDb.</param>
/// <param name="provider">The provider representing TheMovieDb.</param>
/// <returns>The converted episode as a <see cref="Episode"/>.</returns>
public static Episode ToEpisode(this TvEpisode episode, int showID, Provider provider)
{
return new Episode
{
SeasonNumber = episode.SeasonNumber,
EpisodeNumber = episode.EpisodeNumber,
Title = episode.Name,
Overview = episode.Overview,
ReleaseDate = episode.AirDate,
Images = new Dictionary<int, string>
{
[Images.Thumbnail] = episode.StillPath != null
? $"https://image.tmdb.org/t/p/original{episode.StillPath}"
: null
},
ExternalIDs = new []
{
new MetadataID
{
Provider = provider,
Link = $"https://www.themoviedb.org/tv/{showID}" +
$"/season/{episode.SeasonNumber}/episode/{episode.EpisodeNumber}",
DataID = episode.Id.ToString()
}
}
};
}
}
}

View File

@ -0,0 +1,101 @@
using System.Collections.Generic;
using System.Linq;
using Kyoo.Models;
using TMDbLib.Objects.Movies;
using TMDbLib.Objects.Search;
namespace Kyoo.TheMovieDb
{
/// <summary>
/// A class containing extensions methods to convert from TMDB's types to Kyoo's types.
/// </summary>
public static partial class Convertors
{
/// <summary>
/// Convert a <see cref="Movie"/> into a <see cref="Show"/>.
/// </summary>
/// <param name="movie">The movie to convert.</param>
/// <param name="provider">The provider representing TheMovieDb.</param>
/// <returns>The converted movie as a <see cref="Show"/>.</returns>
public static Show ToShow(this Movie movie, Provider provider)
{
return new Show
{
Slug = Utility.ToSlug(movie.Title),
Title = movie.Title,
Aliases = movie.AlternativeTitles.Titles.Select(x => x.Title).ToArray(),
Overview = movie.Overview,
Status = movie.Status == "Released" ? Status.Finished : Status.Planned,
StartAir = movie.ReleaseDate,
EndAir = movie.ReleaseDate,
Images = new Dictionary<int, string>
{
[Images.Poster] = movie.PosterPath != null
? $"https://image.tmdb.org/t/p/original{movie.PosterPath}"
: null,
[Images.Thumbnail] = movie.BackdropPath != null
? $"https://image.tmdb.org/t/p/original{movie.BackdropPath}"
: null,
[Images.Trailer] = movie.Videos?.Results
.Where(x => x.Type is "Trailer" or "Teaser" && x.Site == "YouTube")
.Select(x => "https://www.youtube.com/watch?v=" + x.Key).FirstOrDefault(),
},
Genres = movie.Genres.Select(x => new Genre(x.Name)).ToArray(),
Studio = !string.IsNullOrEmpty(movie.ProductionCompanies.FirstOrDefault()?.Name)
? new Studio(movie.ProductionCompanies.First().Name)
: null,
IsMovie = true,
People = movie.Credits.Cast
.Select(x => x.ToPeople(provider))
.Concat(movie.Credits.Crew.Select(x => x.ToPeople(provider)))
.ToArray(),
ExternalIDs = new []
{
new MetadataID
{
Provider = provider,
Link = $"https://www.themoviedb.org/movie/{movie.Id}",
DataID = movie.Id.ToString()
}
}
};
}
/// <summary>
/// Convert a <see cref="SearchMovie"/> into a <see cref="Show"/>.
/// </summary>
/// <param name="movie">The movie to convert.</param>
/// <param name="provider">The provider representing TheMovieDb.</param>
/// <returns>The converted movie as a <see cref="Show"/>.</returns>
public static Show ToShow(this SearchMovie movie, Provider provider)
{
return new Show
{
Slug = Utility.ToSlug(movie.Title),
Title = movie.Title,
Overview = movie.Overview,
StartAir = movie.ReleaseDate,
EndAir = movie.ReleaseDate,
Images = new Dictionary<int, string>
{
[Images.Poster] = movie.PosterPath != null
? $"https://image.tmdb.org/t/p/original{movie.PosterPath}"
: null,
[Images.Thumbnail] = movie.BackdropPath != null
? $"https://image.tmdb.org/t/p/original{movie.BackdropPath}"
: null,
},
IsMovie = true,
ExternalIDs = new []
{
new MetadataID
{
Provider = provider,
Link = $"https://www.themoviedb.org/movie/{movie.Id}",
DataID = movie.Id.ToString()
}
}
};
}
}
}

View File

@ -0,0 +1,182 @@
using System.Collections.Generic;
using Kyoo.Models;
using TMDbLib.Objects.General;
using TMDbLib.Objects.People;
using TMDbLib.Objects.Search;
using Images = Kyoo.Models.Images;
using TvCast = TMDbLib.Objects.TvShows.Cast;
using MovieCast = TMDbLib.Objects.Movies.Cast;
namespace Kyoo.TheMovieDb
{
/// <summary>
/// A class containing extensions methods to convert from TMDB's types to Kyoo's types.
/// </summary>
public static partial class Convertors
{
/// <summary>
/// Convert a <see cref="MovieCast"/> to a <see cref="PeopleRole"/>.
/// </summary>
/// <param name="cast">An internal TheMovieDB cast.</param>
/// <param name="provider">The provider that represent TheMovieDB inside Kyoo.</param>
/// <returns>A <see cref="PeopleRole"/> representing the movie cast.</returns>
public static PeopleRole ToPeople(this MovieCast cast, Provider provider)
{
return new PeopleRole
{
People = new People
{
Slug = Utility.ToSlug(cast.Name),
Name = cast.Name,
Images = new Dictionary<int, string>
{
[Images.Poster] = cast.ProfilePath != null
? $"https://image.tmdb.org/t/p/original{cast.ProfilePath}"
: null
},
ExternalIDs = new[]
{
new MetadataID
{
Provider = provider,
DataID = cast.Id.ToString(),
Link = $"https://www.themoviedb.org/person/{cast.Id}"
}
}
},
Type = "Actor",
Role = cast.Character
};
}
/// <summary>
/// Convert a <see cref="TvCast"/> to a <see cref="PeopleRole"/>.
/// </summary>
/// <param name="cast">An internal TheMovieDB cast.</param>
/// <param name="provider">The provider that represent TheMovieDB inside Kyoo.</param>
/// <returns>A <see cref="PeopleRole"/> representing the movie cast.</returns>
public static PeopleRole ToPeople(this TvCast cast, Provider provider)
{
return new PeopleRole
{
People = new People
{
Slug = Utility.ToSlug(cast.Name),
Name = cast.Name,
Images = new Dictionary<int, string>
{
[Images.Poster] = cast.ProfilePath != null
? $"https://image.tmdb.org/t/p/original{cast.ProfilePath}"
: null
},
ExternalIDs = new[]
{
new MetadataID
{
Provider = provider,
DataID = cast.Id.ToString(),
Link = $"https://www.themoviedb.org/person/{cast.Id}"
}
}
},
Type = "Actor",
Role = cast.Character
};
}
/// <summary>
/// Convert a <see cref="Crew"/> to a <see cref="PeopleRole"/>.
/// </summary>
/// <param name="crew">An internal TheMovieDB crew member.</param>
/// <param name="provider">The provider that represent TheMovieDB inside Kyoo.</param>
/// <returns>A <see cref="PeopleRole"/> representing the movie crew.</returns>
public static PeopleRole ToPeople(this Crew crew, Provider provider)
{
return new PeopleRole
{
People = new People
{
Slug = Utility.ToSlug(crew.Name),
Name = crew.Name,
Images = new Dictionary<int, string>
{
[Images.Poster] = crew.ProfilePath != null
? $"https://image.tmdb.org/t/p/original{crew.ProfilePath}"
: null
},
ExternalIDs = new[]
{
new MetadataID
{
Provider = provider,
DataID = crew.Id.ToString(),
Link = $"https://www.themoviedb.org/person/{crew.Id}"
}
}
},
Type = crew.Department,
Role = crew.Job
};
}
/// <summary>
/// Convert a <see cref="Person"/> to a <see cref="People"/>.
/// </summary>
/// <param name="person">An internal TheMovieDB person.</param>
/// <param name="provider">The provider that represent TheMovieDB inside Kyoo.</param>
/// <returns>A <see cref="People"/> representing the person.</returns>
public static People ToPeople(this Person person, Provider provider)
{
return new People
{
Slug = Utility.ToSlug(person.Name),
Name = person.Name,
Images = new Dictionary<int, string>
{
[Images.Poster] = person.ProfilePath != null
? $"https://image.tmdb.org/t/p/original{person.ProfilePath}"
: null
},
ExternalIDs = new[]
{
new MetadataID
{
Provider = provider,
DataID = person.Id.ToString(),
Link = $"https://www.themoviedb.org/person/{person.Id}"
}
}
};
}
/// <summary>
/// Convert a <see cref="SearchPerson"/> to a <see cref="People"/>.
/// </summary>
/// <param name="person">An internal TheMovieDB person.</param>
/// <param name="provider">The provider that represent TheMovieDB inside Kyoo.</param>
/// <returns>A <see cref="People"/> representing the person.</returns>
public static People ToPeople(this SearchPerson person, Provider provider)
{
return new People
{
Slug = Utility.ToSlug(person.Name),
Name = person.Name,
Images = new Dictionary<int, string>
{
[Images.Poster] = person.ProfilePath != null
? $"https://image.tmdb.org/t/p/original{person.ProfilePath}"
: null
},
ExternalIDs = new[]
{
new MetadataID
{
Provider = provider,
DataID = person.Id.ToString(),
Link = $"https://www.themoviedb.org/person/{person.Id}"
}
}
};
}
}
}

View File

@ -0,0 +1,45 @@
using System.Collections.Generic;
using Kyoo.Models;
using TMDbLib.Objects.TvShows;
namespace Kyoo.TheMovieDb
{
/// <summary>
/// A class containing extensions methods to convert from TMDB's types to Kyoo's types.
/// </summary>
public static partial class Convertors
{
/// <summary>
/// Convert a <see cref="TvSeason"/> into a <see cref="Season"/>.
/// </summary>
/// <param name="season">The season to convert.</param>
/// <param name="showID">The ID of the show inside TheMovieDb.</param>
/// <param name="provider">The provider representing TheMovieDb.</param>
/// <returns>The converted season as a <see cref="Season"/>.</returns>
public static Season ToSeason(this TvSeason season, int showID, Provider provider)
{
return new Season
{
SeasonNumber = season.SeasonNumber,
Title = season.Name,
Overview = season.Overview,
StartDate = season.AirDate,
Images = new Dictionary<int, string>
{
[Images.Poster] = season.PosterPath != null
? $"https://image.tmdb.org/t/p/original{season.PosterPath}"
: null
},
ExternalIDs = new []
{
new MetadataID
{
Provider = provider,
Link = $"https://www.themoviedb.org/tv/{showID}/season/{season.SeasonNumber}",
DataID = season.Id?.ToString()
}
}
};
}
}
}

View File

@ -0,0 +1,98 @@
using System.Collections.Generic;
using System.Linq;
using Kyoo.Models;
using TMDbLib.Objects.Search;
using TMDbLib.Objects.TvShows;
namespace Kyoo.TheMovieDb
{
/// <summary>
/// A class containing extensions methods to convert from TMDB's types to Kyoo's types.
/// </summary>
public static partial class Convertors
{
/// <summary>
/// Convert a <see cref="TvShow"/> to a <see cref="Show"/>.
/// </summary>
/// <param name="tv">The show to convert.</param>
/// <param name="provider">The provider representing TheMovieDb.</param>
/// <returns>A converted <see cref="TvShow"/> as a <see cref="Show"/>.</returns>
public static Show ToShow(this TvShow tv, Provider provider)
{
return new Show
{
Slug = Utility.ToSlug(tv.Name),
Title = tv.Name,
Aliases = tv.AlternativeTitles.Results.Select(x => x.Title).ToArray(),
Overview = tv.Overview,
Status = tv.Status == "Ended" ? Status.Finished : Status.Planned,
StartAir = tv.FirstAirDate,
EndAir = tv.LastAirDate,
Images = new Dictionary<int, string>
{
[Images.Poster] = tv.PosterPath != null
? $"https://image.tmdb.org/t/p/original{tv.PosterPath}"
: null,
[Images.Thumbnail] = tv.BackdropPath != null
? $"https://image.tmdb.org/t/p/original{tv.BackdropPath}"
: null,
[Images.Trailer] = tv.Videos?.Results
.Where(x => x.Type is "Trailer" or "Teaser" && x.Site == "YouTube")
.Select(x => "https://www.youtube.com/watch?v=" + x.Key).FirstOrDefault()
},
Genres = tv.Genres.Select(x => new Genre(x.Name)).ToArray(),
Studio = !string.IsNullOrEmpty(tv.ProductionCompanies.FirstOrDefault()?.Name)
? new Studio(tv.ProductionCompanies.First().Name)
: null,
People = tv.Credits.Cast
.Select(x => x.ToPeople(provider))
.Concat(tv.Credits.Crew.Select(x => x.ToPeople(provider)))
.ToArray(),
ExternalIDs = new []
{
new MetadataID
{
Provider = provider,
Link = $"https://www.themoviedb.org/tv/{tv.Id}",
DataID = tv.Id.ToString()
}
}
};
}
/// <summary>
/// Convert a <see cref="SearchTv"/> to a <see cref="Show"/>.
/// </summary>
/// <param name="tv">The show to convert.</param>
/// <param name="provider">The provider representing TheMovieDb.</param>
/// <returns>A converted <see cref="SearchTv"/> as a <see cref="Show"/>.</returns>
public static Show ToShow(this SearchTv tv, Provider provider)
{
return new Show
{
Slug = Utility.ToSlug(tv.Name),
Title = tv.Name,
Overview = tv.Overview,
StartAir = tv.FirstAirDate,
Images = new Dictionary<int, string>
{
[Images.Poster] = tv.PosterPath != null
? $"https://image.tmdb.org/t/p/original{tv.PosterPath}"
: null,
[Images.Thumbnail] = tv.BackdropPath != null
? $"https://image.tmdb.org/t/p/original{tv.BackdropPath}"
: null,
},
ExternalIDs = new []
{
new MetadataID
{
Provider = provider,
Link = $"https://www.themoviedb.org/tv/{tv.Id}",
DataID = tv.Id.ToString()
}
}
};
}
}
}

View File

@ -0,0 +1,60 @@
using Kyoo.Models;
using TMDbLib.Objects.Companies;
using TMDbLib.Objects.Search;
namespace Kyoo.TheMovieDb
{
/// <summary>
/// A class containing extensions methods to convert from TMDB's types to Kyoo's types.
/// </summary>
public static partial class Convertors
{
/// <summary>
/// Convert a <see cref="Company"/> into a <see cref="Studio"/>.
/// </summary>
/// <param name="company">The company to convert.</param>
/// <param name="provider">The provider representing TheMovieDb.</param>
/// <returns>The converted company as a <see cref="Studio"/>.</returns>
public static Studio ToStudio(this Company company, Provider provider)
{
return new Studio
{
Slug = Utility.ToSlug(company.Name),
Name = company.Name,
ExternalIDs = new []
{
new MetadataID
{
Provider = provider,
Link = $"https://www.themoviedb.org/company/{company.Id}",
DataID = company.Id.ToString()
}
}
};
}
/// <summary>
/// Convert a <see cref="SearchCompany"/> into a <see cref="Studio"/>.
/// </summary>
/// <param name="company">The company to convert.</param>
/// <param name="provider">The provider representing TheMovieDb.</param>
/// <returns>The converted company as a <see cref="Studio"/>.</returns>
public static Studio ToStudio(this SearchCompany company, Provider provider)
{
return new Studio
{
Slug = Utility.ToSlug(company.Name),
Name = company.Name,
ExternalIDs = new[]
{
new MetadataID
{
Provider = provider,
Link = $"https://www.themoviedb.org/company/{company.Id}",
DataID = company.Id.ToString()
}
}
};
}
}
}

View File

@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<Company>SDG</Company>
<Authors>Zoe Roux</Authors>
<RepositoryUrl>https://github.com/AnonymusRaccoon/Kyoo</RepositoryUrl>
<LangVersion>default</LangVersion>
<RootNamespace>Kyoo.TheMovieDb</RootNamespace>
</PropertyGroup>
<PropertyGroup>
<OutputPath>../Kyoo/bin/$(Configuration)/$(TargetFramework)/plugins/the-moviedb</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
<GenerateDependencyFile>false</GenerateDependencyFile>
<GenerateRuntimeConfigurationFiles>false</GenerateRuntimeConfigurationFiles>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="5.0.0" />
<PackageReference Include="TMDbLib" Version="1.8.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Kyoo.Common/Kyoo.Common.csproj">
<PrivateAssets>all</PrivateAssets>
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
</Project>

View File

@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using Autofac;
using Kyoo.Controllers;
using Kyoo.Models.Attributes;
using Kyoo.TheMovieDb.Models;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Kyoo.TheMovieDb
{
/// <summary>
/// A plugin that add a <see cref="IMetadataProvider"/> for TheMovieDB.
/// </summary>
public class PluginTmdb : IPlugin
{
/// <inheritdoc />
public string Slug => "the-moviedb";
/// <inheritdoc />
public string Name => "TheMovieDb Provider";
/// <inheritdoc />
public string Description => "A metadata provider for TheMovieDB.";
/// <inheritdoc />
public ICollection<Type> Provides => new []
{
typeof(IMetadataProvider)
};
/// <inheritdoc />
public ICollection<ConditionalProvide> ConditionalProvides => ArraySegment<ConditionalProvide>.Empty;
/// <inheritdoc />
public ICollection<Type> Requires => ArraySegment<Type>.Empty;
/// <summary>
/// The configuration to use.
/// </summary>
private readonly IConfiguration _configuration;
/// <summary>
/// The configuration manager used to register typed/untyped implementations.
/// </summary>
[Injected] public IConfigurationManager ConfigurationManager { private get; set; }
/// <summary>
/// Create a new tmdb module instance and use the given configuration.
/// </summary>
/// <param name="configuration">The configuration to use</param>
public PluginTmdb(IConfiguration configuration)
{
_configuration = configuration;
}
/// <inheritdoc />
public void Configure(ContainerBuilder builder)
{
builder.RegisterProvider<TheMovieDbProvider>();
}
/// <inheritdoc />
public void Configure(IServiceCollection services, ICollection<Type> availableTypes)
{
services.Configure<TheMovieDbOptions>(_configuration.GetSection(TheMovieDbOptions.Path));
}
/// <inheritdoc />
public void ConfigureAspNet(IApplicationBuilder app)
{
ConfigurationManager.AddTyped<TheMovieDbOptions>(TheMovieDbOptions.Path);
}
}
}

View File

@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Kyoo.Controllers;
using Kyoo.Models;
using Kyoo.TheMovieDb.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TMDbLib.Client;
using TMDbLib.Objects.Movies;
using TMDbLib.Objects.Search;
using TMDbLib.Objects.TvShows;
namespace Kyoo.TheMovieDb
{
/// <summary>
/// A metadata provider for TheMovieDb.
/// </summary>
public class TheMovieDbProvider : IMetadataProvider
{
/// <summary>
/// The API key used to authenticate with TheMovieDb API.
/// </summary>
private readonly IOptions<TheMovieDbOptions> _apiKey;
/// <summary>
/// The logger to use in ase of issue.
/// </summary>
private readonly ILogger<TheMovieDbProvider> _logger;
/// <inheritdoc />
public Provider Provider => new()
{
Slug = "the-moviedb",
Name = "TheMovieDB",
Images = new Dictionary<int, string>
{
[Images.Logo] = "https://www.themoviedb.org/assets/2/v4/logos/v2/" +
"blue_short-8e7b30f73a4020692ccca9c88bafe5dcb6f8a62a4c6bc55cd9ba82bb2cd95f6c.svg"
}
};
/// <summary>
/// Create a new <see cref="TheMovieDbProvider"/> using the given api key.
/// </summary>
/// <param name="apiKey">The api key</param>
/// <param name="logger">The logger to use in case of issue.</param>
public TheMovieDbProvider(IOptions<TheMovieDbOptions> apiKey, ILogger<TheMovieDbProvider> logger)
{
_apiKey = apiKey;
_logger = logger;
}
/// <inheritdoc />
public Task<T> Get<T>(T item)
where T : class, IResource
{
return item switch
{
Collection collection => _GetCollection(collection) as Task<T>,
Show show => _GetShow(show) as Task<T>,
Season season => _GetSeason(season) as Task<T>,
Episode episode => _GetEpisode(episode) as Task<T>,
People person => _GetPerson(person) as Task<T>,
Studio studio => _GetStudio(studio) as Task<T>,
_ => null
};
}
/// <summary>
/// Get a collection using it's id, if the id is not present in the collection, fallback to a name search.
/// </summary>
/// <param name="collection">The collection to search for</param>
/// <returns>A collection containing metadata from TheMovieDb</returns>
private async Task<Collection> _GetCollection(Collection collection)
{
if (!collection.TryGetID(Provider.Slug, out int id))
{
Collection found = (await _SearchCollections(collection.Name ?? collection.Slug)).FirstOrDefault();
if (found?.TryGetID(Provider.Slug, out id) != true)
return found;
}
TMDbClient client = new(_apiKey.Value.ApiKey);
return (await client.GetCollectionAsync(id)).ToCollection(Provider);
}
/// <summary>
/// Get a show using it's id, if the id is not present in the show, fallback to a title search.
/// </summary>
/// <param name="show">The show to search for</param>
/// <returns>A show containing metadata from TheMovieDb</returns>
private async Task<Show> _GetShow(Show show)
{
if (!show.TryGetID(Provider.Slug, out int id))
{
Show found = (await _SearchShows(show.Title ?? show.Slug, show.StartAir?.Year))
.FirstOrDefault(x => x.IsMovie == show.IsMovie);
if (found?.TryGetID(Provider.Slug, out id) != true)
return found;
}
TMDbClient client = new(_apiKey.Value.ApiKey);
if (show.IsMovie)
{
return (await client
.GetMovieAsync(id, MovieMethods.AlternativeTitles | MovieMethods.Videos | MovieMethods.Credits))
?.ToShow(Provider);
}
return (await client
.GetTvShowAsync(id, TvShowMethods.AlternativeTitles | TvShowMethods.Videos | TvShowMethods.Credits))
?.ToShow(Provider);
}
/// <summary>
/// Get a season using it's show and it's season number.
/// </summary>
/// <param name="season">The season to retrieve metadata for.</param>
/// <returns>A season containing metadata from TheMovieDb</returns>
private async Task<Season> _GetSeason(Season season)
{
if (season.Show == null)
{
_logger.LogWarning("Metadata for a season was requested but it's show is not loaded. " +
"This is unsupported");
return null;
}
if (!season.Show.TryGetID(Provider.Slug, out int id))
return null;
TMDbClient client = new(_apiKey.Value.ApiKey);
return (await client.GetTvSeasonAsync(id, season.SeasonNumber))
.ToSeason(id, Provider);
}
/// <summary>
/// Get an episode using it's show, it's season number and it's episode number.
/// Absolute numbering is not supported.
/// </summary>
/// <param name="episode">The episode to retrieve metadata for.</param>
/// <returns>An episode containing metadata from TheMovieDb</returns>
private async Task<Episode> _GetEpisode(Episode episode)
{
if (episode.Show == null)
{
_logger.LogWarning("Metadata for an episode was requested but it's show is not loaded. " +
"This is unsupported");
return null;
}
if (!episode.Show.TryGetID(Provider.Slug, out int id)
|| episode.SeasonNumber == null || episode.EpisodeNumber == null)
return null;
TMDbClient client = new(_apiKey.Value.ApiKey);
return (await client.GetTvEpisodeAsync(id, episode.SeasonNumber.Value, episode.EpisodeNumber.Value))
.ToEpisode(id, Provider);
}
/// <summary>
/// Get a person using it's id, if the id is not present in the person, fallback to a name search.
/// </summary>
/// <param name="person">The person to search for</param>
/// <returns>A person containing metadata from TheMovieDb</returns>
private async Task<People> _GetPerson(People person)
{
if (!person.TryGetID(Provider.Slug, out int id))
{
People found = (await _SearchPeople(person.Name ?? person.Slug)).FirstOrDefault();
if (found?.TryGetID(Provider.Slug, out id) != true)
return found;
}
TMDbClient client = new(_apiKey.Value.ApiKey);
return (await client.GetPersonAsync(id)).ToPeople(Provider);
}
/// <summary>
/// Get a studio using it's id, if the id is not present in the studio, fallback to a name search.
/// </summary>
/// <param name="studio">The studio to search for</param>
/// <returns>A studio containing metadata from TheMovieDb</returns>
private async Task<Studio> _GetStudio(Studio studio)
{
if (!studio.TryGetID(Provider.Slug, out int id))
{
Studio found = (await _SearchStudios(studio.Name ?? studio.Slug)).FirstOrDefault();
if (found?.TryGetID(Provider.Slug, out id) != true)
return found;
}
TMDbClient client = new(_apiKey.Value.ApiKey);
return (await client.GetCompanyAsync(id)).ToStudio(Provider);
}
/// <inheritdoc />
public async Task<ICollection<T>> Search<T>(string query)
where T : class, IResource
{
if (typeof(T) == typeof(Collection))
return (await _SearchCollections(query) as ICollection<T>)!;
if (typeof(T) == typeof(Show))
return (await _SearchShows(query) as ICollection<T>)!;
if (typeof(T) == typeof(People))
return (await _SearchPeople(query) as ICollection<T>)!;
if (typeof(T) == typeof(Studio))
return (await _SearchStudios(query) as ICollection<T>)!;
return ArraySegment<T>.Empty;
}
/// <summary>
/// Search for a collection using it's name as a query.
/// </summary>
/// <param name="query">The query to search for</param>
/// <returns>A list of collections containing metadata from TheMovieDb</returns>
private async Task<ICollection<Collection>> _SearchCollections(string query)
{
TMDbClient client = new(_apiKey.Value.ApiKey);
return (await client.SearchCollectionAsync(query))
.Results
.Select(x => x.ToCollection(Provider))
.ToArray();
}
/// <summary>
/// Search for a show using it's name as a query.
/// </summary>
/// <param name="query">The query to search for</param>
/// <param name="year">The year in witch the show has aired.</param>
/// <returns>A list of shows containing metadata from TheMovieDb</returns>
private async Task<ICollection<Show>> _SearchShows(string query, int? year = null)
{
TMDbClient client = new(_apiKey.Value.ApiKey);
return (await client.SearchMultiAsync(query, year: year ?? 0))
.Results
.Select(x =>
{
return x switch
{
SearchTv tv => tv.ToShow(Provider),
SearchMovie movie => movie.ToShow(Provider),
_ => null
};
})
.Where(x => x != null)
.ToArray();
}
/// <summary>
/// Search for people using there name as a query.
/// </summary>
/// <param name="query">The query to search for</param>
/// <returns>A list of people containing metadata from TheMovieDb</returns>
private async Task<ICollection<People>> _SearchPeople(string query)
{
TMDbClient client = new(_apiKey.Value.ApiKey);
return (await client.SearchPersonAsync(query))
.Results
.Select(x => x.ToPeople(Provider))
.ToArray();
}
/// <summary>
/// Search for studios using there name as a query.
/// </summary>
/// <param name="query">The query to search for</param>
/// <returns>A list of studios containing metadata from TheMovieDb</returns>
private async Task<ICollection<Studio>> _SearchStudios(string query)
{
TMDbClient client = new(_apiKey.Value.ApiKey);
return (await client.SearchCompanyAsync(query))
.Results
.Select(x => x.ToStudio(Provider))
.ToArray();
}
}
}

View File

@ -0,0 +1,18 @@
namespace Kyoo.TheMovieDb.Models
{
/// <summary>
/// The option containing the api key for TheMovieDb.
/// </summary>
public class TheMovieDbOptions
{
/// <summary>
/// The path to get this option from the root configuration.
/// </summary>
public const string Path = "the-moviedb";
/// <summary>
/// The api key of TheMovieDb.
/// </summary>
public string ApiKey { get; set; }
}
}

View File

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Kyoo.Models;
@ -47,7 +48,7 @@ namespace Kyoo.TheTvdb
/// <returns>A show representing the given search result.</returns>
public static Show ToShow(this SeriesSearchResult result, Provider provider)
{
return new()
return new Show
{
Slug = result.Slug,
Title = result.SeriesName,
@ -55,14 +56,19 @@ namespace Kyoo.TheTvdb
Overview = result.Overview,
Status = _GetStatus(result.Status),
StartAir = _ParseDate(result.FirstAired),
Poster = result.Poster != null ? $"https://www.thetvdb.com{result.Poster}" : null,
Images = new Dictionary<int, string>
{
[Images.Poster] = !string.IsNullOrEmpty(result.Poster)
? $"https://www.thetvdb.com{result.Poster}"
: null,
},
ExternalIDs = new[]
{
new MetadataID<Show>
new MetadataID
{
DataID = result.Id.ToString(),
Link = $"https://www.thetvdb.com/series/{result.Slug}",
Second = provider
Provider = provider
}
}
};
@ -76,7 +82,7 @@ namespace Kyoo.TheTvdb
/// <returns>A show representing the given series.</returns>
public static Show ToShow(this Series series, Provider provider)
{
return new()
return new Show
{
Slug = series.Slug,
Title = series.SeriesName,
@ -84,16 +90,23 @@ namespace Kyoo.TheTvdb
Overview = series.Overview,
Status = _GetStatus(series.Status),
StartAir = _ParseDate(series.FirstAired),
Poster = series.Poster != null ? $"https://www.thetvdb.com/banners/{series.Poster}" : null,
Backdrop = series.FanArt != null ? $"https://www.thetvdb.com/banners/{series.FanArt}" : null,
Images = new Dictionary<int, string>
{
[Images.Poster] = !string.IsNullOrEmpty(series.Poster)
? $"https://www.thetvdb.com/banners/{series.Poster}"
: null,
[Images.Thumbnail] = !string.IsNullOrEmpty(series.FanArt)
? $"https://www.thetvdb.com/banners/{series.FanArt}"
: null
},
Genres = series.Genre.Select(y => new Genre(y)).ToList(),
ExternalIDs = new[]
{
new MetadataID<Show>
new MetadataID
{
DataID = series.Id.ToString(),
Link = $"https://www.thetvdb.com/series/{series.Slug}",
Second = provider
Provider = provider
}
}
};
@ -103,25 +116,20 @@ namespace Kyoo.TheTvdb
/// Convert a tvdb actor to a kyoo <see cref="PeopleRole"/>.
/// </summary>
/// <param name="actor">The actor to convert</param>
/// <param name="provider">The provider representing the tvdb inside kyoo</param>
/// <returns>A people role representing the given actor in the role they played.</returns>
public static PeopleRole ToPeopleRole(this Actor actor, Provider provider)
public static PeopleRole ToPeopleRole(this Actor actor)
{
return new()
return new PeopleRole
{
People = new People
{
Slug = Utility.ToSlug(actor.Name),
Name = actor.Name,
Poster = actor.Image != null ? $"https://www.thetvdb.com/banners/{actor.Image}" : null,
ExternalIDs = new []
Images = new Dictionary<int, string>
{
new MetadataID<People>()
{
DataID = actor.Id.ToString(),
Link = $"https://www.thetvdb.com/people/{actor.Id}",
Second = provider
}
[Images.Poster] = !string.IsNullOrEmpty(actor.Image)
? $"https://www.thetvdb.com/banners/{actor.Image}"
: null
}
},
Role = actor.Role,
@ -137,21 +145,26 @@ namespace Kyoo.TheTvdb
/// <returns>A episode representing the given tvdb episode.</returns>
public static Episode ToEpisode(this EpisodeRecord episode, Provider provider)
{
return new()
return new Episode
{
SeasonNumber = episode.AiredSeason,
EpisodeNumber = episode.AiredEpisodeNumber,
AbsoluteNumber = episode.AbsoluteNumber,
Title = episode.EpisodeName,
Overview = episode.Overview,
Thumb = episode.Filename != null ? $"https://www.thetvdb.com/banners/{episode.Filename}" : null,
Images = new Dictionary<int, string>
{
[Images.Thumbnail] = !string.IsNullOrEmpty(episode.Filename)
? $"https://www.thetvdb.com/banners/{episode.Filename}"
: null
},
ExternalIDs = new[]
{
new MetadataID<Episode>
new MetadataID
{
DataID = episode.Id.ToString(),
Link = $"https://www.thetvdb.com/series/{episode.SeriesId}/episodes/{episode.Id}",
Second = provider
Provider = provider
}
}
};

View File

@ -32,8 +32,10 @@ namespace Kyoo.TheTvdb
{
Slug = "the-tvdb",
Name = "TheTVDB",
LogoExtension = "png",
Logo = "https://www.thetvdb.com/images/logo.png"
Images = new Dictionary<int, string>
{
[Images.Logo] = "https://www.thetvdb.com/images/logo.png"
}
};
@ -93,7 +95,7 @@ namespace Kyoo.TheTvdb
Show ret = series.Data.ToShow(Provider);
TvDbResponse<Actor[]> people = await _client.Series.GetActorsAsync(id);
ret.People = people.Data.Select(x => x.ToPeopleRole(Provider)).ToArray();
ret.People = people.Data.Select(x => x.ToPeopleRole()).ToArray();
return ret;
}

@ -1 +1 @@
Subproject commit c037270d3339fcf0075984a089f353c5c332a751
Subproject commit dca10903ff54a8999732695b5c2a0a5c94f85200

View File

@ -5,8 +5,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.Common", "Kyoo.Common\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.CommonAPI", "Kyoo.CommonAPI\Kyoo.CommonAPI.csproj", "{6F91B645-F785-46BB-9C4F-1EFC83E489B6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.Tests", "Kyoo.Tests\Kyoo.Tests.csproj", "{D179D5FF-9F75-4B27-8E27-0DBDF1806611}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.Postgresql", "Kyoo.Postgresql\Kyoo.Postgresql.csproj", "{3213C96D-0BF3-460B-A8B5-B9977229408A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.Authentication", "Kyoo.Authentication\Kyoo.Authentication.csproj", "{7A841335-6523-47DB-9717-80AA7BD943FD}"
@ -15,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.SqLite", "Kyoo.SqLite\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.TheTvdb", "Kyoo.TheTvdb\Kyoo.TheTvdb.csproj", "{D06BF829-23F5-40F3-A62D-627D9F4B4D6C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.TheMovieDb", "Kyoo.TheMovieDb\Kyoo.TheMovieDb.csproj", "{BAB270D4-E0EA-4329-BA65-512FDAB01001}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.Tests", "tests\Kyoo.Tests\Kyoo.Tests.csproj", "{0C8AA7EA-E723-4532-852F-35AA4E8AFED5}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -33,10 +35,6 @@ Global
{6F91B645-F785-46BB-9C4F-1EFC83E489B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6F91B645-F785-46BB-9C4F-1EFC83E489B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6F91B645-F785-46BB-9C4F-1EFC83E489B6}.Release|Any CPU.Build.0 = Release|Any CPU
{D179D5FF-9F75-4B27-8E27-0DBDF1806611}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D179D5FF-9F75-4B27-8E27-0DBDF1806611}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D179D5FF-9F75-4B27-8E27-0DBDF1806611}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D179D5FF-9F75-4B27-8E27-0DBDF1806611}.Release|Any CPU.Build.0 = Release|Any CPU
{3213C96D-0BF3-460B-A8B5-B9977229408A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3213C96D-0BF3-460B-A8B5-B9977229408A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3213C96D-0BF3-460B-A8B5-B9977229408A}.Release|Any CPU.ActiveCfg = Release|Any CPU
@ -53,5 +51,13 @@ Global
{D06BF829-23F5-40F3-A62D-627D9F4B4D6C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D06BF829-23F5-40F3-A62D-627D9F4B4D6C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D06BF829-23F5-40F3-A62D-627D9F4B4D6C}.Release|Any CPU.Build.0 = Release|Any CPU
{BAB270D4-E0EA-4329-BA65-512FDAB01001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BAB270D4-E0EA-4329-BA65-512FDAB01001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BAB270D4-E0EA-4329-BA65-512FDAB01001}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BAB270D4-E0EA-4329-BA65-512FDAB01001}.Release|Any CPU.Build.0 = Release|Any CPU
{0C8AA7EA-E723-4532-852F-35AA4E8AFED5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0C8AA7EA-E723-4532-852F-35AA4E8AFED5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0C8AA7EA-E723-4532-852F-35AA4E8AFED5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0C8AA7EA-E723-4532-852F-35AA4E8AFED5}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -1,11 +0,0 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INDENT_STYLE/@EntryValue">Tab</s:String>
<s:String x:Key="/Default/CodeStyle/CSharpVarKeywordUsage/ForBuiltInTypes/@EntryValue">UseExplicitType</s:String>
<s:String x:Key="/Default/CodeStyle/CSharpVarKeywordUsage/ForOtherTypes/@EntryValue">UseExplicitType</s:String>
<s:String x:Key="/Default/CodeStyle/CSharpVarKeywordUsage/ForSimpleTypes/@EntryValue">UseExplicitType</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=API/@EntryIndexedValue">API</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=DB/@EntryIndexedValue">DB</s:String>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpKeepExistingMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpPlaceEmbeddedOnSameLineMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpUseContinuousIndentInsideBracesMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View File

@ -8,7 +8,9 @@ using Autofac.Features.Metadata;
using JetBrains.Annotations;
using Kyoo.Common.Models.Attributes;
using Kyoo.Models;
using Kyoo.Models.Options;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace Kyoo.Controllers
{
@ -23,14 +25,31 @@ namespace Kyoo.Controllers
/// </summary>
private readonly ICollection<Meta<Func<IFileSystem>, FileSystemMetadataAttribute>> _fileSystems;
/// <summary>
/// The library manager used to load shows to retrieve their path
/// (only if the option is set to metadata in show)
/// </summary>
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Options to check if the metadata should be kept in the show directory or in a kyoo's directory.
/// </summary>
private readonly IOptionsMonitor<BasicOptions> _options;
/// <summary>
/// Create a new <see cref="FileSystemComposite"/> from a list of <see cref="IFileSystem"/> mapped to their
/// metadata.
/// </summary>
/// <param name="fileSystems">The list of filesystem mapped to their metadata.</param>
public FileSystemComposite(ICollection<Meta<Func<IFileSystem>, FileSystemMetadataAttribute>> fileSystems)
/// <param name="libraryManager">The library manager used to load shows to retrieve their path.</param>
/// <param name="options">The options to use.</param>
public FileSystemComposite(ICollection<Meta<Func<IFileSystem>, FileSystemMetadataAttribute>> fileSystems,
ILibraryManager libraryManager,
IOptionsMonitor<BasicOptions> options)
{
_fileSystems = fileSystems;
_libraryManager = libraryManager;
_options = options;
}
@ -88,6 +107,15 @@ namespace Kyoo.Controllers
.GetReader(relativePath);
}
/// <inheritdoc />
public Task<Stream> GetReader(string path, AsyncRef<string> mime)
{
if (path == null)
throw new ArgumentNullException(nameof(path));
return _GetFileSystemForPath(path, out string relativePath)
.GetReader(relativePath, mime);
}
/// <inheritdoc />
public Task<Stream> NewFile(string path)
{
@ -132,12 +160,41 @@ namespace Kyoo.Controllers
}
/// <inheritdoc />
public string GetExtraDirectory(Show show)
public async Task<string> GetExtraDirectory<T>(T resource)
{
if (show == null)
throw new ArgumentNullException(nameof(show));
return _GetFileSystemForPath(show.Path, out string _)
.GetExtraDirectory(show);
switch (resource)
{
case Season season:
await _libraryManager.Load(season, x => x.Show);
break;
case Episode episode:
await _libraryManager.Load(episode, x => x.Show);
break;
case Track track:
await _libraryManager.Load(track, x => x.Episode);
await _libraryManager.Load(track.Episode, x => x.Show);
break;
}
IFileSystem fs = resource switch
{
Show show => _GetFileSystemForPath(show.Path, out string _),
Season season => _GetFileSystemForPath(season.Show.Path, out string _),
Episode episode => _GetFileSystemForPath(episode.Show.Path, out string _),
Track track => _GetFileSystemForPath(track.Episode.Show.Path, out string _),
_ => _GetFileSystemForPath(_options.CurrentValue.MetadataPath, out string _)
};
string path = await fs.GetExtraDirectory(resource)
?? resource switch
{
Season season => await GetExtraDirectory(season.Show),
Episode episode => await GetExtraDirectory(episode.Show),
Track track => await GetExtraDirectory(track.Episode),
IResource res => Combine(_options.CurrentValue.MetadataPath,
typeof(T).Name.ToLowerInvariant(), res.Slug),
_ => Combine(_options.CurrentValue.MetadataPath, typeof(T).Name.ToLowerInvariant())
};
return await CreateDirectory(path);
}
}
}

View File

@ -1,10 +1,10 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Kyoo.Common.Models.Attributes;
using Kyoo.Models;
using Microsoft.AspNetCore.Mvc;
namespace Kyoo.Controllers
@ -45,6 +45,16 @@ namespace Kyoo.Controllers
return client.GetStreamAsync(path);
}
/// <inheritdoc />
public async Task<Stream> GetReader(string path, AsyncRef<string> mime)
{
HttpClient client = _clientFactory.CreateClient();
HttpResponseMessage response = await client.GetAsync(path);
response.EnsureSuccessStatusCode();
mime.Value = response.Content.Headers.ContentType?.MediaType;
return await response.Content.ReadAsStreamAsync();
}
/// <inheritdoc />
public Task<Stream> NewFile(string path)
{
@ -76,7 +86,7 @@ namespace Kyoo.Controllers
}
/// <inheritdoc />
public string GetExtraDirectory(Show show)
public Task<string> GetExtraDirectory<T>(T resource)
{
throw new NotSupportedException("Extras can not be stored inside an http filesystem.");
}
@ -85,6 +95,8 @@ namespace Kyoo.Controllers
/// <summary>
/// An <see cref="IActionResult"/> to proxy an http request.
/// </summary>
// TODO remove this suppress message once the class has been implemented.
[SuppressMessage("ReSharper", "NotAccessedField.Local")]
public class HttpForwardResult : IActionResult
{
/// <summary>

View File

@ -4,8 +4,10 @@ using System.IO;
using System.Threading.Tasks;
using Kyoo.Common.Models.Attributes;
using Kyoo.Models;
using Kyoo.Models.Options;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Options;
namespace Kyoo.Controllers
{
@ -20,6 +22,20 @@ namespace Kyoo.Controllers
/// </summary>
private FileExtensionContentTypeProvider _provider;
/// <summary>
/// Options to check if the metadata should be kept in the show directory or in a kyoo's directory.
/// </summary>
private readonly IOptionsMonitor<BasicOptions> _options;
/// <summary>
/// Create a new <see cref="LocalFileSystem"/> with the specified options.
/// </summary>
/// <param name="options">The options to use.</param>
public LocalFileSystem(IOptionsMonitor<BasicOptions> options)
{
_options = options;
}
/// <summary>
/// Get the content type of a file using it's extension.
/// </summary>
@ -63,6 +79,16 @@ namespace Kyoo.Controllers
return Task.FromResult<Stream>(File.OpenRead(path));
}
/// <inheritdoc />
public Task<Stream> GetReader(string path, AsyncRef<string> mime)
{
if (path == null)
throw new ArgumentNullException(nameof(path));
_provider.TryGetContentType(path, out string mimeValue);
mime.Value = mimeValue;
return Task.FromResult<Stream>(File.OpenRead(path));
}
/// <inheritdoc />
public Task<Stream> NewFile(string path)
{
@ -104,11 +130,18 @@ namespace Kyoo.Controllers
}
/// <inheritdoc />
public string GetExtraDirectory(Show show)
public Task<string> GetExtraDirectory<T>(T resource)
{
string path = Path.Combine(show.Path, "Extra");
Directory.CreateDirectory(path);
return path;
if (!_options.CurrentValue.MetadataInShow)
return Task.FromResult<string>(null);
return Task.FromResult(resource switch
{
Show show => Combine(show.Path, "Extra"),
Season season => Combine(season.Show.Path, "Extra"),
Episode episode => Combine(episode.Show.Path, "Extra"),
Track track => Combine(track.Episode.Show.Path, "Extra"),
_ => null
});
}
}
}

View File

@ -18,6 +18,11 @@ namespace Kyoo.Controllers
/// </summary>
private readonly DatabaseContext _database;
/// <summary>
/// A provider repository to handle externalID creation and deletion
/// </summary>
private readonly IProviderRepository _providers;
/// <inheritdoc />
protected override Expression<Func<Collection, object>> DefaultSort => x => x.Name;
@ -25,17 +30,19 @@ namespace Kyoo.Controllers
/// Create a new <see cref="CollectionRepository"/>.
/// </summary>
/// <param name="database">The database handle to use</param>
public CollectionRepository(DatabaseContext database)
/// /// <param name="providers">A provider repository</param>
public CollectionRepository(DatabaseContext database, IProviderRepository providers)
: base(database)
{
_database = database;
_providers = providers;
}
/// <inheritdoc />
public override async Task<ICollection<Collection>> Search(string query)
{
return await _database.Collections
.Where(_database.Like<Collection>(x => x.Name, $"%{query}%"))
.Where(_database.Like<Collection>(x => x.Name + " " + x.Slug, $"%{query}%"))
.OrderBy(DefaultSort)
.Take(20)
.ToListAsync();
@ -50,6 +57,40 @@ namespace Kyoo.Controllers
return obj;
}
/// <inheritdoc />
protected override async Task Validate(Collection resource)
{
await base.Validate(resource);
if (string.IsNullOrEmpty(resource.Slug))
throw new ArgumentException("The collection's slug must be set and not empty");
if (string.IsNullOrEmpty(resource.Name))
throw new ArgumentException("The collection's name must be set and not empty");
if (resource.ExternalIDs != null)
{
foreach (MetadataID id in resource.ExternalIDs)
{
id.Provider = _database.LocalEntity<Provider>(id.Provider.Slug)
?? await _providers.CreateIfNotExists(id.Provider);
id.ProviderID = id.Provider.ID;
}
_database.MetadataIds<Collection>().AttachRange(resource.ExternalIDs);
}
}
/// <inheritdoc />
protected override async Task EditRelations(Collection resource, Collection changed, bool resetOld)
{
await Validate(changed);
if (changed.ExternalIDs != null || resetOld)
{
await Database.Entry(resource).Collection(x => x.ExternalIDs).LoadAsync();
resource.ExternalIDs = changed.ExternalIDs;
}
}
/// <inheritdoc />
public override async Task Delete(Collection obj)
{

View File

@ -99,7 +99,7 @@ namespace Kyoo.Controllers
public override async Task<ICollection<Episode>> Search(string query)
{
return await _database.Episodes
.Where(x => x.EpisodeNumber != null)
.Where(x => x.EpisodeNumber != null || x.AbsoluteNumber != null)
.Where(_database.Like<Episode>(x => x.Title, $"%{query}%"))
.OrderBy(DefaultSort)
.Take(20)
@ -111,7 +111,6 @@ namespace Kyoo.Controllers
{
await base.Create(obj);
_database.Entry(obj).State = EntityState.Added;
obj.ExternalIDs.ForEach(x => _database.Entry(x).State = EntityState.Added);
await _database.SaveChangesAsync($"Trying to insert a duplicated episode (slug {obj.Slug} already exists).");
return await ValidateTracks(obj);
}
@ -119,8 +118,7 @@ namespace Kyoo.Controllers
/// <inheritdoc />
protected override async Task EditRelations(Episode resource, Episode changed, bool resetOld)
{
if (resource.ShowID <= 0)
throw new InvalidOperationException($"Can't store an episode not related to any show (showID: {resource.ShowID}).");
await Validate(changed);
if (changed.Tracks != null || resetOld)
{
@ -134,8 +132,6 @@ namespace Kyoo.Controllers
await Database.Entry(resource).Collection(x => x.ExternalIDs).LoadAsync();
resource.ExternalIDs = changed.ExternalIDs;
}
await Validate(resource);
}
/// <summary>
@ -145,12 +141,16 @@ namespace Kyoo.Controllers
/// <returns>The <see cref="resource"/> parameter is returned.</returns>
private async Task<Episode> ValidateTracks(Episode resource)
{
resource.Tracks = await TaskUtils.DefaultIfNull(resource.Tracks?.SelectAsync(x =>
if (resource.Tracks == null)
return resource;
resource.Tracks = await resource.Tracks.SelectAsync(x =>
{
x.Episode = resource;
x.EpisodeSlug = resource.Slug;
return _tracks.Create(x);
}).ToListAsync());
}).ToListAsync();
_database.Tracks.AttachRange(resource.Tracks);
return resource;
}
@ -158,12 +158,24 @@ namespace Kyoo.Controllers
protected override async Task Validate(Episode resource)
{
await base.Validate(resource);
await resource.ExternalIDs.ForEachAsync(async x =>
if (resource.ShowID <= 0)
{
x.Second = await _providers.CreateIfNotExists(x.Second);
x.SecondID = x.Second.ID;
_database.Entry(x.Second).State = EntityState.Detached;
});
if (resource.Show == null)
throw new ArgumentException($"Can't store an episode not related " +
$"to any show (showID: {resource.ShowID}).");
resource.ShowID = resource.Show.ID;
}
if (resource.ExternalIDs != null)
{
foreach (MetadataID id in resource.ExternalIDs)
{
id.Provider = _database.LocalEntity<Provider>(id.Provider.Slug)
?? await _providers.CreateIfNotExists(id.Provider);
id.ProviderID = id.Provider.ID;
}
_database.MetadataIds<Episode>().AttachRange(resource.ExternalIDs);
}
}
/// <inheritdoc />

View File

@ -43,7 +43,7 @@ namespace Kyoo.Controllers
public override async Task<ICollection<Library>> Search(string query)
{
return await _database.Libraries
.Where(_database.Like<Library>(x => x.Name, $"%{query}%"))
.Where(_database.Like<Library>(x => x.Name + " " + x.Slug, $"%{query}%"))
.OrderBy(DefaultSort)
.Take(20)
.ToListAsync();
@ -54,7 +54,6 @@ namespace Kyoo.Controllers
{
await base.Create(obj);
_database.Entry(obj).State = EntityState.Added;
obj.ProviderLinks.ForEach(x => _database.Entry(x).State = EntityState.Added);
await _database.SaveChangesAsync($"Trying to insert a duplicated library (slug {obj.Slug} already exists).");
return obj;
}
@ -63,20 +62,7 @@ namespace Kyoo.Controllers
protected override async Task Validate(Library resource)
{
await base.Validate(resource);
resource.ProviderLinks = resource.Providers?
.Select(x => Link.Create(resource, x))
.ToList();
await resource.ProviderLinks.ForEachAsync(async id =>
{
id.Second = await _providers.CreateIfNotExists(id.Second);
id.SecondID = id.Second.ID;
_database.Entry(id.Second).State = EntityState.Detached;
});
}
/// <inheritdoc />
protected override async Task EditRelations(Library resource, Library changed, bool resetOld)
{
if (string.IsNullOrEmpty(resource.Slug))
throw new ArgumentException("The library's slug must be set and not empty");
if (string.IsNullOrEmpty(resource.Name))
@ -84,9 +70,22 @@ namespace Kyoo.Controllers
if (resource.Paths == null || !resource.Paths.Any())
throw new ArgumentException("The library should have a least one path.");
if (resource.Providers != null)
{
resource.Providers = await resource.Providers
.SelectAsync(x => _providers.CreateIfNotExists(x))
.ToListAsync();
_database.AttachRange(resource.Providers);
}
}
/// <inheritdoc />
protected override async Task EditRelations(Library resource, Library changed, bool resetOld)
{
await Validate(changed);
if (changed.Providers != null || resetOld)
{
await Validate(changed);
await Database.Entry(resource).Collection(x => x.Providers).LoadAsync();
resource.Providers = changed.Providers;
}

View File

@ -62,7 +62,6 @@ namespace Kyoo.Controllers
{
await base.Create(obj);
_database.Entry(obj).State = EntityState.Added;
obj.ExternalIDs.ForEach(x => _database.Entry(x).State = EntityState.Added);
await _database.SaveChangesAsync($"Trying to insert a duplicated people (slug {obj.Slug} already exists).");
return obj;
}
@ -71,23 +70,35 @@ namespace Kyoo.Controllers
protected override async Task Validate(People resource)
{
await base.Validate(resource);
await resource.ExternalIDs.ForEachAsync(async id =>
if (resource.ExternalIDs != null)
{
id.Second = await _providers.CreateIfNotExists(id.Second);
id.SecondID = id.Second.ID;
_database.Entry(id.Second).State = EntityState.Detached;
});
await resource.Roles.ForEachAsync(async role =>
foreach (MetadataID id in resource.ExternalIDs)
{
id.Provider = _database.LocalEntity<Provider>(id.Provider.Slug)
?? await _providers.CreateIfNotExists(id.Provider);
id.ProviderID = id.Provider.ID;
}
_database.MetadataIds<People>().AttachRange(resource.ExternalIDs);
}
if (resource.Roles != null)
{
role.Show = await _shows.Value.CreateIfNotExists(role.Show);
role.ShowID = role.Show.ID;
_database.Entry(role.Show).State = EntityState.Detached;
});
foreach (PeopleRole role in resource.Roles)
{
role.Show = _database.LocalEntity<Show>(role.Show.Slug)
?? await _shows.Value.CreateIfNotExists(role.Show);
role.ShowID = role.Show.ID;
_database.Entry(role).State = EntityState.Added;
}
}
}
/// <inheritdoc />
protected override async Task EditRelations(People resource, People changed, bool resetOld)
{
await Validate(changed);
if (changed.Roles != null || resetOld)
{
await Database.Entry(resource).Collection(x => x.Roles).LoadAsync();
@ -98,9 +109,7 @@ namespace Kyoo.Controllers
{
await Database.Entry(resource).Collection(x => x.ExternalIDs).LoadAsync();
resource.ExternalIDs = changed.ExternalIDs;
}
await base.EditRelations(resource, changed, resetOld);
}
/// <inheritdoc />

View File

@ -9,21 +9,18 @@ using Microsoft.EntityFrameworkCore;
namespace Kyoo.Controllers
{
/// <summary>
/// A local repository to handle providers.
/// A local repository to handle providers.
/// </summary>
public class ProviderRepository : LocalRepository<Provider>, IProviderRepository
{
/// <summary>
/// The database handle
/// The database handle
/// </summary>
private readonly DatabaseContext _database;
/// <inheritdoc />
protected override Expression<Func<Provider, object>> DefaultSort => x => x.Slug;
/// <summary>
/// Create a new <see cref="ProviderRepository"/>.
/// Create a new <see cref="ProviderRepository" />.
/// </summary>
/// <param name="database">The database handle</param>
public ProviderRepository(DatabaseContext database)
@ -32,6 +29,9 @@ namespace Kyoo.Controllers
_database = database;
}
/// <inheritdoc />
protected override Expression<Func<Provider, object>> DefaultSort => x => x.Slug;
/// <inheritdoc />
public override async Task<ICollection<Provider>> Search(string query)
{
@ -47,7 +47,8 @@ namespace Kyoo.Controllers
{
await base.Create(obj);
_database.Entry(obj).State = EntityState.Added;
await _database.SaveChangesAsync($"Trying to insert a duplicated provider (slug {obj.Slug} already exists).");
await _database.SaveChangesAsync("Trying to insert a duplicated provider " +
$"(slug {obj.Slug} already exists).");
return obj;
}
@ -62,14 +63,15 @@ namespace Kyoo.Controllers
}
/// <inheritdoc />
public Task<ICollection<MetadataID<T>>> GetMetadataID<T>(Expression<Func<MetadataID<T>, bool>> where = null,
Sort<MetadataID<T>> sort = default,
public Task<ICollection<MetadataID>> GetMetadataID<T>(Expression<Func<MetadataID, bool>> where = null,
Sort<MetadataID> sort = default,
Pagination limit = default)
where T : class, IResource
where T : class, IMetadata
{
return ApplyFilters(_database.MetadataIds<T>().Include(y => y.Second),
x => _database.MetadataIds<T>().FirstOrDefaultAsync(y => y.FirstID == x),
x => x.FirstID,
return ApplyFilters(_database.MetadataIds<T>()
.Include(y => y.Provider),
x => _database.MetadataIds<T>().FirstOrDefaultAsync(y => y.ResourceID == x),
x => x.ResourceID,
where,
sort,
limit);

View File

@ -87,7 +87,6 @@ namespace Kyoo.Controllers
{
await base.Create(obj);
_database.Entry(obj).State = EntityState.Added;
obj.ExternalIDs.ForEach(x => _database.Entry(x).State = EntityState.Added);
await _database.SaveChangesAsync($"Trying to insert a duplicated season (slug {obj.Slug} already exists).");
return obj;
}
@ -95,32 +94,37 @@ namespace Kyoo.Controllers
/// <inheritdoc/>
protected override async Task Validate(Season resource)
{
await base.Validate(resource);
if (resource.ShowID <= 0)
{
if (resource.Show == null)
throw new InvalidOperationException(
throw new ArgumentException(
$"Can't store a season not related to any show (showID: {resource.ShowID}).");
resource.ShowID = resource.Show.ID;
}
await base.Validate(resource);
await resource.ExternalIDs.ForEachAsync(async id =>
if (resource.ExternalIDs != null)
{
id.Second = await _providers.CreateIfNotExists(id.Second);
id.SecondID = id.Second.ID;
_database.Entry(id.Second).State = EntityState.Detached;
});
foreach (MetadataID id in resource.ExternalIDs)
{
id.Provider = _database.LocalEntity<Provider>(id.Provider.Slug)
?? await _providers.CreateIfNotExists(id.Provider);
id.ProviderID = id.Provider.ID;
}
_database.MetadataIds<Season>().AttachRange(resource.ExternalIDs);
}
}
/// <inheritdoc/>
protected override async Task EditRelations(Season resource, Season changed, bool resetOld)
{
await Validate(changed);
if (changed.ExternalIDs != null || resetOld)
{
await Database.Entry(resource).Collection(x => x.ExternalIDs).LoadAsync();
resource.ExternalIDs = changed.ExternalIDs;
}
await base.EditRelations(resource, changed, resetOld);
}
/// <inheritdoc/>

View File

@ -76,9 +76,6 @@ namespace Kyoo.Controllers
{
await base.Create(obj);
_database.Entry(obj).State = EntityState.Added;
obj.GenreLinks.ForEach(x => _database.Entry(x).State = EntityState.Added);
obj.People.ForEach(x => _database.Entry(x).State = EntityState.Added);
obj.ExternalIDs.ForEach(x => _database.Entry(x).State = EntityState.Added);
await _database.SaveChangesAsync($"Trying to insert a duplicated show (slug {obj.Slug} already exists).");
return obj;
}
@ -88,29 +85,40 @@ namespace Kyoo.Controllers
{
await base.Validate(resource);
if (resource.Studio != null)
{
resource.Studio = await _studios.CreateIfNotExists(resource.Studio);
resource.StudioID = resource.Studio.ID;
}
resource.GenreLinks = resource.Genres?
.Select(x => Link.Create(resource, x))
.ToList();
await resource.GenreLinks.ForEachAsync(async id =>
if (resource.Genres != null)
{
id.Second = await _genres.CreateIfNotExists(id.Second);
id.SecondID = id.Second.ID;
_database.Entry(id.Second).State = EntityState.Detached;
});
await resource.ExternalIDs.ForEachAsync(async id =>
resource.Genres = await resource.Genres
.SelectAsync(x => _genres.CreateIfNotExists(x))
.ToListAsync();
_database.AttachRange(resource.Genres);
}
if (resource.ExternalIDs != null)
{
id.Second = await _providers.CreateIfNotExists(id.Second);
id.SecondID = id.Second.ID;
_database.Entry(id.Second).State = EntityState.Detached;
});
await resource.People.ForEachAsync(async role =>
foreach (MetadataID id in resource.ExternalIDs)
{
id.Provider = _database.LocalEntity<Provider>(id.Provider.Slug)
?? await _providers.CreateIfNotExists(id.Provider);
id.ProviderID = id.Provider.ID;
}
_database.MetadataIds<Show>().AttachRange(resource.ExternalIDs);
}
if (resource.People != null)
{
role.People = await _people.CreateIfNotExists(role.People);
role.PeopleID = role.People.ID;
_database.Entry(role.People).State = EntityState.Detached;
});
foreach (PeopleRole role in resource.People)
{
role.People = _database.LocalEntity<People>(role.People.Slug)
?? await _people.CreateIfNotExists(role.People);
role.PeopleID = role.People.ID;
_database.Entry(role).State = EntityState.Added;
}
}
}
/// <inheritdoc />
@ -151,21 +159,18 @@ namespace Kyoo.Controllers
{
if (collectionID != null)
{
await _database.Links<Collection, Show>()
.AddAsync(new Link<Collection, Show>(collectionID.Value, showID));
await _database.AddLinks<Collection, Show>(collectionID.Value, showID);
await _database.SaveIfNoDuplicates();
if (libraryID != null)
{
await _database.Links<Library, Collection>()
.AddAsync(new Link<Library, Collection>(libraryID.Value, collectionID.Value));
await _database.AddLinks<Library, Collection>(libraryID.Value, collectionID.Value);
await _database.SaveIfNoDuplicates();
}
}
if (libraryID != null)
{
await _database.Links<Library, Show>()
.AddAsync(new Link<Library, Show>(libraryID.Value, showID));
await _database.AddLinks<Library, Show>(libraryID.Value, showID);
await _database.SaveIfNoDuplicates();
}
}

View File

@ -18,6 +18,11 @@ namespace Kyoo.Controllers
/// </summary>
private readonly DatabaseContext _database;
/// <summary>
/// A provider repository to handle externalID creation and deletion
/// </summary>
private readonly IProviderRepository _providers;
/// <inheritdoc />
protected override Expression<Func<Studio, object>> DefaultSort => x => x.Name;
@ -26,10 +31,12 @@ namespace Kyoo.Controllers
/// Create a new <see cref="StudioRepository"/>.
/// </summary>
/// <param name="database">The database handle</param>
public StudioRepository(DatabaseContext database)
/// <param name="providers">A provider repository</param>
public StudioRepository(DatabaseContext database, IProviderRepository providers)
: base(database)
{
_database = database;
_providers = providers;
}
/// <inheritdoc />
@ -51,6 +58,34 @@ namespace Kyoo.Controllers
return obj;
}
/// <inheritdoc />
protected override async Task Validate(Studio resource)
{
await base.Validate(resource);
if (resource.ExternalIDs != null)
{
foreach (MetadataID id in resource.ExternalIDs)
{
id.Provider = _database.LocalEntity<Provider>(id.Provider.Slug)
?? await _providers.CreateIfNotExists(id.Provider);
id.ProviderID = id.Provider.ID;
}
_database.MetadataIds<Studio>().AttachRange(resource.ExternalIDs);
}
}
/// <inheritdoc />
protected override async Task EditRelations(Studio resource, Studio changed, bool resetOld)
{
if (changed.ExternalIDs != null || resetOld)
{
await Database.Entry(resource).Collection(x => x.ExternalIDs).LoadAsync();
resource.ExternalIDs = changed.ExternalIDs;
}
await base.EditRelations(resource, changed, resetOld);
}
/// <inheritdoc />
public override async Task Delete(Studio obj)
{

View File

@ -37,19 +37,25 @@ namespace Kyoo.Controllers
throw new InvalidOperationException("Tracks do not support the search method.");
}
/// <inheritdoc />
protected override async Task Validate(Track resource)
{
await base.Validate(resource);
if (resource.EpisodeID <= 0)
{
resource.EpisodeID = resource.Episode?.ID ?? 0;
if (resource.EpisodeID <= 0)
throw new ArgumentException("Can't store a track not related to any episode " +
$"(episodeID: {resource.EpisodeID}).");
}
}
/// <inheritdoc />
public override async Task<Track> Create(Track obj)
{
if (obj == null)
throw new ArgumentNullException(nameof(obj));
if (obj.EpisodeID <= 0)
{
obj.EpisodeID = obj.Episode?.ID ?? 0;
if (obj.EpisodeID <= 0)
throw new InvalidOperationException($"Can't store a track not related to any episode (episodeID: {obj.EpisodeID}).");
}
await base.Create(obj);
_database.Entry(obj).State = EntityState.Added;
await _database.SaveChangesAsync();

View File

@ -1,11 +1,10 @@
using Kyoo.Models;
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Kyoo.Models.Options;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Kyoo.Controllers
{
@ -22,54 +21,17 @@ namespace Kyoo.Controllers
/// A logger to report errors.
/// </summary>
private readonly ILogger<ThumbnailsManager> _logger;
/// <summary>
/// The options containing the base path of people images and provider logos.
/// </summary>
private readonly IOptionsMonitor<BasicOptions> _options;
/// <summary>
/// A library manager used to load episode and seasons shows if they are not loaded.
/// </summary>
private readonly Lazy<ILibraryManager> _library;
/// <summary>
/// Create a new <see cref="ThumbnailsManager"/>.
/// </summary>
/// <param name="files">The file manager to use.</param>
/// <param name="logger">A logger to report errors</param>
/// <param name="options">The options to use.</param>
/// <param name="library">A library manager used to load shows if they are not loaded.</param>
public ThumbnailsManager(IFileSystem files,
ILogger<ThumbnailsManager> logger,
IOptionsMonitor<BasicOptions> options,
Lazy<ILibraryManager> library)
ILogger<ThumbnailsManager> logger)
{
_files = files;
_logger = logger;
_options = options;
_library = library;
options.OnChange(x =>
{
_files.CreateDirectory(x.PeoplePath);
_files.CreateDirectory(x.ProviderPath);
});
}
/// <inheritdoc />
public Task<bool> DownloadImages<T>(T item, bool alwaysDownload = false)
where T : IResource
{
if (item == null)
throw new ArgumentNullException(nameof(item));
return item switch
{
Show show => _Validate(show, alwaysDownload),
Season season => _Validate(season, alwaysDownload),
Episode episode => _Validate(episode, alwaysDownload),
People people => _Validate(people, alwaysDownload),
Provider provider => _Validate(provider, alwaysDownload),
_ => Task.FromResult(false)
};
}
/// <summary>
@ -86,8 +48,12 @@ namespace Kyoo.Controllers
try
{
await using Stream reader = await _files.GetReader(url);
await using Stream local = await _files.NewFile(localPath);
AsyncRef<string> mime = new();
await using Stream reader = await _files.GetReader(url, mime);
string extension = new FileExtensionContentTypeProvider()
.Mappings.FirstOrDefault(x => x.Value == mime.Value)
.Key;
await using Stream local = await _files.NewFile(localPath + extension);
await reader.CopyToAsync(local);
return true;
}
@ -98,195 +64,74 @@ namespace Kyoo.Controllers
}
}
/// <summary>
/// Download images of a specified show.
/// </summary>
/// <param name="show">
/// The item to cache images.
/// </param>
/// <param name="alwaysDownload">
/// <c>true</c> if images should be downloaded even if they already exists locally, <c>false</c> otherwise.
/// </param>
/// <returns><c>true</c> if an image has been downloaded, <c>false</c> otherwise.</returns>
private async Task<bool> _Validate([NotNull] Show show, bool alwaysDownload)
/// <inheritdoc />
public async Task<bool> DownloadImages<T>(T item, bool alwaysDownload = false)
where T : IThumbnails
{
if (item == null)
throw new ArgumentNullException(nameof(item));
if (item.Images == null)
return false;
string name = item is IResource res ? res.Slug : "???";
bool ret = false;
if (show.Poster != null)
foreach ((int id, string image) in item.Images.Where(x => x.Value != null))
{
string posterPath = await GetPoster(show);
if (alwaysDownload || !await _files.Exists(posterPath))
ret |= await _DownloadImage(show.Poster, posterPath, $"The poster of {show.Title}");
}
if (show.Logo != null)
{
string logoPath = await GetLogo(show);
if (alwaysDownload || !await _files.Exists(logoPath))
ret |= await _DownloadImage(show.Logo, logoPath, $"The logo of {show.Title}");
}
if (show.Backdrop != null)
{
string backdropPath = await GetThumbnail(show);
if (alwaysDownload || !await _files.Exists(backdropPath))
ret |= await _DownloadImage(show.Backdrop, backdropPath, $"The backdrop of {show.Title}");
string localPath = await _GetPrivateImagePath(item, id);
if (alwaysDownload || !await _files.Exists(localPath))
ret |= await _DownloadImage(image, localPath, $"The image n°{id} of {name}");
}
return ret;
}
/// <summary>
/// Download images of a specified person.
/// Retrieve the local path of an image of the given item <b>without an extension</b>.
/// </summary>
/// <param name="people">
/// The item to cache images.
/// </param>
/// <param name="alwaysDownload">
/// <c>true</c> if images should be downloaded even if they already exists locally, <c>false</c> otherwise.
/// </param>
/// <returns><c>true</c> if an image has been downloaded, <c>false</c> otherwise.</returns>
private async Task<bool> _Validate([NotNull] People people, bool alwaysDownload)
{
if (people == null)
throw new ArgumentNullException(nameof(people));
if (people.Poster == null)
return false;
string localPath = await GetPoster(people);
if (alwaysDownload || !await _files.Exists(localPath))
return await _DownloadImage(people.Poster, localPath, $"The profile picture of {people.Name}");
return false;
}
/// <summary>
/// Download images of a specified season.
/// </summary>
/// <param name="season">
/// The item to cache images.
/// </param>
/// <param name="alwaysDownload">
/// <c>true</c> if images should be downloaded even if they already exists locally, <c>false</c> otherwise.
/// </param>
/// <returns><c>true</c> if an image has been downloaded, <c>false</c> otherwise.</returns>
private async Task<bool> _Validate([NotNull] Season season, bool alwaysDownload)
{
if (season.Poster == null)
return false;
string localPath = await GetPoster(season);
if (alwaysDownload || !await _files.Exists(localPath))
return await _DownloadImage(season.Poster, localPath, $"The poster of {season.Slug}");
return false;
}
/// <summary>
/// Download images of a specified episode.
/// </summary>
/// <param name="episode">
/// The item to cache images.
/// </param>
/// <param name="alwaysDownload">
/// <c>true</c> if images should be downloaded even if they already exists locally, <c>false</c> otherwise.
/// </param>
/// <returns><c>true</c> if an image has been downloaded, <c>false</c> otherwise.</returns>
private async Task<bool> _Validate([NotNull] Episode episode, bool alwaysDownload)
{
if (episode.Thumb == null)
return false;
string localPath = await _GetEpisodeThumb(episode);
if (alwaysDownload || !await _files.Exists(localPath))
return await _DownloadImage(episode.Thumb, localPath, $"The thumbnail of {episode.Slug}");
return false;
}
/// <summary>
/// Download images of a specified provider.
/// </summary>
/// <param name="provider">
/// The item to cache images.
/// </param>
/// <param name="alwaysDownload">
/// <c>true</c> if images should be downloaded even if they already exists locally, <c>false</c> otherwise.
/// </param>
/// <returns><c>true</c> if an image has been downloaded, <c>false</c> otherwise.</returns>
private async Task<bool> _Validate([NotNull] Provider provider, bool alwaysDownload)
{
if (provider.Logo == null)
return false;
string localPath = await GetLogo(provider);
if (alwaysDownload || !await _files.Exists(localPath))
return await _DownloadImage(provider.Logo, localPath, $"The logo of {provider.Slug}");
return false;
}
/// <inheritdoc />
public Task<string> GetPoster<T>(T item)
where T : IResource
/// <param name="item">The item to retrieve the poster from.</param>
/// <param name="imageID">The ID of the image. See <see cref="Images"/> for values.</param>
/// <typeparam name="T">The type of the item</typeparam>
/// <returns>The path of the image for the given resource, <b>even if it does not exists</b></returns>
private async Task<string> _GetPrivateImagePath<T>(T item, int imageID)
{
if (item == null)
throw new ArgumentNullException(nameof(item));
return item switch
string directory = await _files.GetExtraDirectory(item);
string imageName = imageID switch
{
Show show => Task.FromResult(_files.Combine(_files.GetExtraDirectory(show), "poster.jpg")),
Season season => _GetSeasonPoster(season),
People actor => Task.FromResult(_files.Combine(_options.CurrentValue.PeoplePath, $"{actor.Slug}.jpg")),
_ => throw new NotSupportedException($"The type {typeof(T).Name} does not have a poster.")
Images.Poster => "poster",
Images.Logo => "logo",
Images.Thumbnail => "thumbnail",
Images.Trailer => "trailer",
_ => $"{imageID}"
};
}
/// <summary>
/// Retrieve the path of a season's poster.
/// </summary>
/// <param name="season">The season to retrieve the poster from.</param>
/// <returns>The path of the season's poster.</returns>
private async Task<string> _GetSeasonPoster(Season season)
{
if (season.Show == null)
await _library.Value.Load(season, x => x.Show);
return _files.Combine(_files.GetExtraDirectory(season.Show), $"season-{season.SeasonNumber}.jpg");
switch (item)
{
case Season season:
imageName = $"season-{season.SeasonNumber}-{imageName}";
break;
case Episode episode:
directory = await _files.CreateDirectory(_files.Combine(directory, "Thumbnails"));
imageName = $"{Path.GetFileNameWithoutExtension(episode.Path)}-{imageName}";
break;
}
return _files.Combine(directory, imageName);
}
/// <inheritdoc />
public Task<string> GetThumbnail<T>(T item)
where T : IResource
public async Task<string> GetImagePath<T>(T item, int imageID)
where T : IThumbnails
{
if (item == null)
throw new ArgumentNullException(nameof(item));
return item switch
{
Show show => Task.FromResult(_files.Combine(_files.GetExtraDirectory(show), "backdrop.jpg")),
Episode episode => _GetEpisodeThumb(episode),
_ => throw new NotSupportedException($"The type {typeof(T).Name} does not have a thumbnail.")
};
}
/// <summary>
/// Get the path for an episode's thumbnail.
/// </summary>
/// <param name="episode">The episode to retrieve the thumbnail from</param>
/// <returns>The path of the given episode's thumbnail.</returns>
private async Task<string> _GetEpisodeThumb(Episode episode)
{
if (episode.Show == null)
await _library.Value.Load(episode, x => x.Show);
string dir = _files.Combine(_files.GetExtraDirectory(episode.Show), "Thumbnails");
await _files.CreateDirectory(dir);
return _files.Combine(dir, $"{Path.GetFileNameWithoutExtension(episode.Path)}.jpg");
}
/// <inheritdoc />
public Task<string> GetLogo<T>(T item)
where T : IResource
{
if (item == null)
throw new ArgumentNullException(nameof(item));
return Task.FromResult(item switch
{
Show show => _files.Combine(_files.GetExtraDirectory(show), "logo.png"),
Provider provider => _files.Combine(_options.CurrentValue.ProviderPath,
$"{provider.Slug}.{provider.LogoExtension}"),
_ => throw new NotSupportedException($"The type {typeof(T).Name} does not have a thumbnail.")
});
string basePath = await _GetPrivateImagePath(item, imageID);
string directory = Path.GetDirectoryName(basePath);
string baseFile = Path.GetFileName(basePath);
return (await _files.ListFiles(directory!))
.FirstOrDefault(x => Path.GetFileNameWithoutExtension(x) == baseFile);
}
}
}

View File

@ -88,10 +88,8 @@ namespace Kyoo.Controllers
public async Task<Track[]> ExtractInfos(Episode episode, bool reextract)
{
if (episode.Show == null)
await _library.Value.Load(episode, x => x.Show);
string dir = _files.GetExtraDirectory(episode.Show);
await _library.Value.Load(episode, x => x.Show);
string dir = await _files.GetExtraDirectory(episode.Show);
if (dir == null)
throw new ArgumentException("Invalid path.");
return await Task.Factory.StartNew(

View File

@ -101,13 +101,13 @@ namespace Kyoo
/// <inheritdoc />
public void Configure(ContainerBuilder builder)
{
builder.RegisterComposite<FileSystemComposite, IFileSystem>();
builder.RegisterComposite<FileSystemComposite, IFileSystem>().InstancePerLifetimeScope();
builder.RegisterType<LocalFileSystem>().As<IFileSystem>().SingleInstance();
builder.RegisterType<HttpFileSystem>().As<IFileSystem>().SingleInstance();
builder.RegisterType<ConfigurationManager>().As<IConfigurationManager>().SingleInstance();
builder.RegisterType<Transcoder>().As<ITranscoder>().SingleInstance();
builder.RegisterType<ThumbnailsManager>().As<IThumbnailsManager>().SingleInstance();
builder.RegisterType<ThumbnailsManager>().As<IThumbnailsManager>().InstancePerLifetimeScope();
builder.RegisterType<TaskManager>().As<ITaskManager>().SingleInstance();
builder.RegisterType<LibraryManager>().As<ILibraryManager>().InstancePerLifetimeScope();
builder.RegisterType<RegexIdentifier>().As<IIdentifier>().SingleInstance();

View File

@ -40,7 +40,7 @@
<PackageReference Include="Autofac.Extras.AttributeMetadata" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="5.2.7" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.8" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices" Version="3.1.17" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices" Version="5.0.0-preview.8.20414.8" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="5.0.8" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
</ItemGroup>

View File

@ -25,16 +25,6 @@ namespace Kyoo.Models.Options
/// </summary>
public string PluginPath { get; set; } = "plugins/";
/// <summary>
/// The path of the people pictures.
/// </summary>
public string PeoplePath { get; set; } = "people/";
/// <summary>
/// The path of providers icons.
/// </summary>
public string ProviderPath { get; set; } = "providers/";
/// <summary>
/// The temporary folder to cache transmuxed file.
/// </summary>
@ -44,5 +34,22 @@ namespace Kyoo.Models.Options
/// The temporary folder to cache transcoded file.
/// </summary>
public string TranscodePath { get; set; } = "cached/transcode";
/// <summary>
/// <c>true</c> if the metadata of a show/season/episode should be stored in the same directory as video files,
/// <c>false</c> to save them in a kyoo specific directory.
/// </summary>
/// <remarks>
/// Some file systems might discard this option to store them somewhere else.
/// For example, readonly file systems will probably store them in a kyoo specific directory.
/// </remarks>
public bool MetadataInShow { get; set; } = true;
/// <summary>
/// The path for metadata if they are not stored near show (see <see cref="MetadataInShow"/>).
/// Some resources can't be stored near a show and they are stored in this directory
/// (like <see cref="Provider"/>).
/// </summary>
public string MetadataPath { get; set; } = "metadata/";
}
}

View File

@ -116,16 +116,11 @@ namespace Kyoo.Tasks
else
show = registeredShow;
// If they are not already loaded, load external ids to allow metadata providers to use them.
if (show.ExternalIDs == null)
await _libraryManager.Load(show, x => x.ExternalIDs);
progress.Report(50);
if (season != null)
season.Show = show;
season = await _RegisterAndFill(season);
if (season != null)
season.Title ??= $"Season {season.SeasonNumber}";
progress.Report(60);
episode.Show = show;
@ -163,16 +158,32 @@ namespace Kyoo.Tasks
/// <typeparam name="T">The type of the item</typeparam>
/// <returns>The existing or filled item.</returns>
private async Task<T> _RegisterAndFill<T>(T item)
where T : class, IResource
where T : class, IResource, IThumbnails, IMetadata
{
if (item == null || string.IsNullOrEmpty(item.Slug))
return null;
T existing = await _libraryManager.GetOrDefault<T>(item.Slug);
if (existing != null)
{
await _libraryManager.Load(existing, x => x.ExternalIDs);
return existing;
}
item = await _metadataProvider.Get(item);
await _thumbnailsManager.DownloadImages(item);
switch (item)
{
case Show show when show.People != null:
foreach (PeopleRole role in show.People)
await _thumbnailsManager.DownloadImages(role.People);
break;
case Season season:
season.Title ??= $"Season {season.SeasonNumber}";
break;
}
return await _libraryManager.CreateIfNotExists(item);
}
}

View File

@ -6,6 +6,7 @@ using Kyoo.Models;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
using Kyoo.CommonApi;
using Kyoo.Models.Exceptions;
using Kyoo.Models.Options;
using Kyoo.Models.Permissions;
using Microsoft.Extensions.Options;
@ -19,11 +20,18 @@ namespace Kyoo.Api
public class CollectionApi : CrudApi<Collection>
{
private readonly ILibraryManager _libraryManager;
private readonly IFileSystem _files;
private readonly IThumbnailsManager _thumbs;
public CollectionApi(ILibraryManager libraryManager, IOptions<BasicOptions> options)
public CollectionApi(ILibraryManager libraryManager,
IFileSystem files,
IThumbnailsManager thumbs,
IOptions<BasicOptions> options)
: base(libraryManager.CollectionRepository, options.Value.PublicUrl)
{
_libraryManager = libraryManager;
_files = files;
_thumbs = thumbs;
}
[HttpGet("{id:int}/show")]
@ -129,5 +137,48 @@ namespace Kyoo.Api
return BadRequest(new {Error = ex.Message});
}
}
[HttpGet("{slug}/poster")]
public async Task<IActionResult> GetPoster(string slug)
{
try
{
Collection collection = await _libraryManager.Get<Collection>(slug);
return _files.FileResult(await _thumbs.GetImagePath(collection, Images.Poster));
}
catch (ItemNotFoundException)
{
return NotFound();
}
}
[HttpGet("{slug}/logo")]
public async Task<IActionResult> GetLogo(string slug)
{
try
{
Collection collection = await _libraryManager.Get<Collection>(slug);
return _files.FileResult(await _thumbs.GetImagePath(collection, Images.Logo));
}
catch (ItemNotFoundException)
{
return NotFound();
}
}
[HttpGet("{slug}/backdrop")]
[HttpGet("{slug}/thumbnail")]
public async Task<IActionResult> GetBackdrop(string slug)
{
try
{
Collection collection = await _libraryManager.Get<Collection>(slug);
return _files.FileResult(await _thumbs.GetImagePath(collection, Images.Thumbnail));
}
catch (ItemNotFoundException)
{
return NotFound();
}
}
}
}

View File

@ -195,7 +195,7 @@ namespace Kyoo.Api
try
{
Episode episode = await _libraryManager.Get<Episode>(id);
return _files.FileResult(await _thumbnails.GetThumbnail(episode));
return _files.FileResult(await _thumbnails.GetImagePath(episode, Images.Thumbnail));
}
catch (ItemNotFoundException)
{
@ -210,7 +210,7 @@ namespace Kyoo.Api
try
{
Episode episode = await _libraryManager.Get<Episode>(slug);
return _files.FileResult(await _thumbnails.GetThumbnail(episode));
return _files.FileResult(await _thumbnails.GetImagePath(episode, Images.Thumbnail));
}
catch (ItemNotFoundException)
{

View File

@ -94,7 +94,7 @@ namespace Kyoo.Api
People people = await _libraryManager.GetOrDefault<People>(id);
if (people == null)
return NotFound();
return _files.FileResult(await _thumbs.GetPoster(people));
return _files.FileResult(await _thumbs.GetImagePath(people, Images.Poster));
}
[HttpGet("{slug}/poster")]
@ -103,7 +103,7 @@ namespace Kyoo.Api
People people = await _libraryManager.GetOrDefault<People>(slug);
if (people == null)
return NotFound();
return _files.FileResult(await _thumbs.GetPoster(people));
return _files.FileResult(await _thumbs.GetImagePath(people, Images.Poster));
}
}
}

View File

@ -36,7 +36,7 @@ namespace Kyoo.Api
Provider provider = await _libraryManager.GetOrDefault<Provider>(id);
if (provider == null)
return NotFound();
return _files.FileResult(await _thumbnails.GetLogo(provider));
return _files.FileResult(await _thumbnails.GetImagePath(provider, Images.Logo));
}
[HttpGet("{slug}/logo")]
@ -45,7 +45,7 @@ namespace Kyoo.Api
Provider provider = await _libraryManager.GetOrDefault<Provider>(slug);
if (provider == null)
return NotFound();
return _files.FileResult(await _thumbnails.GetLogo(provider));
return _files.FileResult(await _thumbnails.GetImagePath(provider, Images.Logo));
}
}
}

View File

@ -151,7 +151,7 @@ namespace Kyoo.Api
if (season == null)
return NotFound();
await _libraryManager.Load(season, x => x.Show);
return _files.FileResult(await _thumbs.GetPoster(season));
return _files.FileResult(await _thumbs.GetImagePath(season, Images.Poster));
}
[HttpGet("{slug}/poster")]
@ -161,7 +161,7 @@ namespace Kyoo.Api
if (season == null)
return NotFound();
await _libraryManager.Load(season, x => x.Show);
return _files.FileResult(await _thumbs.GetPoster(season));
return _files.FileResult(await _thumbs.GetImagePath(season, Images.Poster));
}
}
}

View File

@ -383,7 +383,7 @@ namespace Kyoo.Api
try
{
Show show = await _libraryManager.Get<Show>(slug);
string path = Path.Combine(_files.GetExtraDirectory(show), "Attachments");
string path = _files.Combine(await _files.GetExtraDirectory(show), "Attachments");
return (await _files.ListFiles(path))
.ToDictionary(Path.GetFileNameWithoutExtension,
x => $"{BaseURL}api/shows/{slug}/fonts/{Path.GetFileName(x)}");
@ -402,7 +402,7 @@ namespace Kyoo.Api
try
{
Show show = await _libraryManager.Get<Show>(showSlug);
string path = Path.Combine(_files.GetExtraDirectory(show), "Attachments", slug);
string path = _files.Combine(await _files.GetExtraDirectory(show), "Attachments", slug);
return _files.FileResult(path);
}
catch (ItemNotFoundException)
@ -417,7 +417,7 @@ namespace Kyoo.Api
try
{
Show show = await _libraryManager.Get<Show>(slug);
return _files.FileResult(await _thumbs.GetPoster(show));
return _files.FileResult(await _thumbs.GetImagePath(show, Images.Poster));
}
catch (ItemNotFoundException)
{
@ -431,7 +431,7 @@ namespace Kyoo.Api
try
{
Show show = await _libraryManager.Get<Show>(slug);
return _files.FileResult(await _thumbs.GetLogo(show));
return _files.FileResult(await _thumbs.GetImagePath(show, Images.Logo));
}
catch (ItemNotFoundException)
{
@ -446,7 +446,7 @@ namespace Kyoo.Api
try
{
Show show = await _libraryManager.Get<Show>(slug);
return _files.FileResult(await _thumbs.GetThumbnail(show));
return _files.FileResult(await _thumbs.GetImagePath(show, Images.Thumbnail));
}
catch (ItemNotFoundException)
{

View File

@ -3,10 +3,10 @@
"url": "http://*:5000",
"publicUrl": "http://localhost:5000/",
"pluginsPath": "plugins/",
"peoplePath": "people/",
"providerPath": "providers/",
"transmuxPath": "cached/transmux",
"transcodePath": "cached/transcode"
"transcodePath": "cached/transcode",
"metadataInShow": true,
"metadataPath": "metadata/"
},
"database": {
@ -70,5 +70,8 @@
"tvdb": {
"apiKey": "REDACTED"
},
"the-moviedb": {
"apiKey": "REDACTED"
}
}

View File

@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Kyoo.Controllers;
using Xunit.Abstractions;
namespace Kyoo.Tests
{
public class RepositoryActivator : IDisposable, IAsyncDisposable
{
public TestContext Context { get; }
public ILibraryManager LibraryManager { get; }
private readonly List<DatabaseContext> _databases = new();
public RepositoryActivator(ITestOutputHelper output, PostgresFixture postgres = null)
{
Context = postgres == null
? new SqLiteTestContext(output)
: new PostgresTestContext(postgres, output);
ProviderRepository provider = new(_NewContext());
LibraryRepository library = new(_NewContext(), provider);
CollectionRepository collection = new(_NewContext(), provider);
GenreRepository genre = new(_NewContext());
StudioRepository studio = new(_NewContext(), provider);
PeopleRepository people = new(_NewContext(), provider,
new Lazy<IShowRepository>(() => LibraryManager.ShowRepository));
ShowRepository show = new(_NewContext(), studio, people, genre, provider);
SeasonRepository season = new(_NewContext(), provider);
LibraryItemRepository libraryItem = new(_NewContext(),
new Lazy<ILibraryRepository>(() => LibraryManager.LibraryRepository));
TrackRepository track = new(_NewContext());
EpisodeRepository episode = new(_NewContext(), provider, track);
UserRepository user = new(_NewContext());
LibraryManager = new LibraryManager(new IBaseRepository[] {
provider,
library,
libraryItem,
collection,
show,
season,
episode,
track,
people,
studio,
genre,
user
});
}
private DatabaseContext _NewContext()
{
DatabaseContext context = Context.New();
_databases.Add(context);
return context;
}
public void Dispose()
{
foreach (DatabaseContext context in _databases)
context.Dispose();
Context.Dispose();
GC.SuppressFinalize(this);
}
public async ValueTask DisposeAsync()
{
foreach (DatabaseContext context in _databases)
await context.DisposeAsync();
await Context.DisposeAsync();
}
}
}

View File

@ -0,0 +1,198 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Kyoo.Controllers;
using Kyoo.Models;
using Microsoft.EntityFrameworkCore;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Database
{
namespace SqLite
{
public class CollectionTests : ACollectionTests
{
public CollectionTests(ITestOutputHelper output)
: base(new RepositoryActivator(output)) { }
}
}
namespace PostgreSQL
{
[Collection(nameof(Postgresql))]
public class CollectionTests : ACollectionTests
{
public CollectionTests(PostgresFixture postgres, ITestOutputHelper output)
: base(new RepositoryActivator(output, postgres)) { }
}
}
public abstract class ACollectionTests : RepositoryTests<Collection>
{
private readonly ICollectionRepository _repository;
protected ACollectionTests(RepositoryActivator repositories)
: base(repositories)
{
_repository = Repositories.LibraryManager.CollectionRepository;
}
[Fact]
public async Task CreateWithEmptySlugTest()
{
Collection collection = TestSample.GetNew<Collection>();
collection.Slug = "";
await Assert.ThrowsAsync<ArgumentException>(() => _repository.Create(collection));
}
[Fact]
public async Task CreateWithNumberSlugTest()
{
Collection collection = TestSample.GetNew<Collection>();
collection.Slug = "2";
Collection ret = await _repository.Create(collection);
Assert.Equal("2!", ret.Slug);
}
[Fact]
public async Task CreateWithoutNameTest()
{
Collection collection = TestSample.GetNew<Collection>();
collection.Name = null;
await Assert.ThrowsAsync<ArgumentException>(() => _repository.Create(collection));
}
[Fact]
public async Task CreateWithExternalIdTest()
{
Collection collection = TestSample.GetNew<Collection>();
collection.ExternalIDs = new[]
{
new MetadataID
{
Provider = TestSample.Get<Provider>(),
Link = "link",
DataID = "id"
},
new MetadataID
{
Provider = TestSample.GetNew<Provider>(),
Link = "new-provider-link",
DataID = "new-id"
}
};
await _repository.Create(collection);
Collection retrieved = await _repository.Get(2);
await Repositories.LibraryManager.Load(retrieved, x => x.ExternalIDs);
Assert.Equal(2, retrieved.ExternalIDs.Count);
KAssert.DeepEqual(collection.ExternalIDs.First(), retrieved.ExternalIDs.First());
KAssert.DeepEqual(collection.ExternalIDs.Last(), retrieved.ExternalIDs.Last());
}
[Fact]
public async Task EditTest()
{
Collection value = await _repository.Get(TestSample.Get<Collection>().Slug);
value.Name = "New Title";
value.Images = new Dictionary<int, string>
{
[Images.Poster] = "new-poster"
};
await _repository.Edit(value, false);
await using DatabaseContext database = Repositories.Context.New();
Collection retrieved = await database.Collections.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
[Fact]
public async Task EditMetadataTest()
{
Collection value = await _repository.Get(TestSample.Get<Collection>().Slug);
value.ExternalIDs = new[]
{
new MetadataID
{
Provider = TestSample.Get<Provider>(),
Link = "link",
DataID = "id"
},
};
await _repository.Edit(value, false);
await using DatabaseContext database = Repositories.Context.New();
Collection retrieved = await database.Collections
.Include(x => x.ExternalIDs)
.ThenInclude(x => x.Provider)
.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
[Fact]
public async Task AddMetadataTest()
{
Collection value = await _repository.Get(TestSample.Get<Collection>().Slug);
value.ExternalIDs = new List<MetadataID>
{
new()
{
Provider = TestSample.Get<Provider>(),
Link = "link",
DataID = "id"
},
};
await _repository.Edit(value, false);
{
await using DatabaseContext database = Repositories.Context.New();
Collection retrieved = await database.Collections
.Include(x => x.ExternalIDs)
.ThenInclude(x => x.Provider)
.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
value.ExternalIDs.Add(new MetadataID
{
Provider = TestSample.GetNew<Provider>(),
Link = "link",
DataID = "id"
});
await _repository.Edit(value, false);
{
await using DatabaseContext database = Repositories.Context.New();
Collection retrieved = await database.Collections
.Include(x => x.ExternalIDs)
.ThenInclude(x => x.Provider)
.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
}
[Theory]
[InlineData("test")]
[InlineData("super")]
[InlineData("title")]
[InlineData("TiTlE")]
[InlineData("SuPeR")]
public async Task SearchTest(string query)
{
Collection value = new()
{
Slug = "super-test",
Name = "This is a test title",
};
await _repository.Create(value);
ICollection<Collection> ret = await _repository.Search(query);
KAssert.DeepEqual(value, ret.First());
}
}
}

View File

@ -1,6 +1,9 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Kyoo.Controllers;
using Kyoo.Models;
using Microsoft.EntityFrameworkCore;
using Xunit;
using Xunit.Abstractions;
@ -59,7 +62,8 @@ namespace Kyoo.Tests.Database
episode = await _repository.Edit(new Episode
{
ID = 1,
SeasonNumber = 2
SeasonNumber = 2,
ShowID = 1
}, false);
Assert.Equal($"{TestSample.Get<Show>().Slug}-s2e1", episode.Slug);
episode = await _repository.Get(1);
@ -74,7 +78,8 @@ namespace Kyoo.Tests.Database
episode = await _repository.Edit(new Episode
{
ID = 1,
EpisodeNumber = 2
EpisodeNumber = 2,
ShowID = 1
}, false);
Assert.Equal($"{TestSample.Get<Show>().Slug}-s1e2", episode.Slug);
episode = await _repository.Get(1);
@ -93,10 +98,6 @@ namespace Kyoo.Tests.Database
Assert.Equal($"{TestSample.Get<Show>().Slug}-s2e4", episode.Slug);
}
// TODO absolute numbering tests
[Fact]
public void AbsoluteSlugTest()
{
@ -133,7 +134,8 @@ namespace Kyoo.Tests.Database
Episode episode = await _repository.Edit(new Episode
{
ID = 2,
AbsoluteNumber = 56
AbsoluteNumber = 56,
ShowID = 1
}, false);
Assert.Equal($"{TestSample.Get<Show>().Slug}-56", episode.Slug);
episode = await _repository.Get(2);
@ -148,7 +150,8 @@ namespace Kyoo.Tests.Database
{
ID = 2,
SeasonNumber = 1,
EpisodeNumber = 2
EpisodeNumber = 2,
ShowID = 1
}, false);
Assert.Equal($"{TestSample.Get<Show>().Slug}-s1e2", episode.Slug);
episode = await _repository.Get(2);
@ -188,5 +191,137 @@ namespace Kyoo.Tests.Database
Episode episode = await _repository.Get(3);
Assert.Equal("john-wick", episode.Slug);
}
[Fact]
public async Task CreateWithExternalIdTest()
{
Episode value = TestSample.GetNew<Episode>();
value.ExternalIDs = new[]
{
new MetadataID
{
Provider = TestSample.Get<Provider>(),
Link = "link",
DataID = "id"
},
new MetadataID
{
Provider = TestSample.GetNew<Provider>(),
Link = "new-provider-link",
DataID = "new-id"
}
};
await _repository.Create(value);
Episode retrieved = await _repository.Get(2);
await Repositories.LibraryManager.Load(retrieved, x => x.ExternalIDs);
Assert.Equal(2, retrieved.ExternalIDs.Count);
KAssert.DeepEqual(value.ExternalIDs.First(), retrieved.ExternalIDs.First());
KAssert.DeepEqual(value.ExternalIDs.Last(), retrieved.ExternalIDs.Last());
}
[Fact]
public async Task EditTest()
{
Episode value = await _repository.Get(TestSample.Get<Episode>().Slug);
value.Title = "New Title";
value.Images = new Dictionary<int, string>
{
[Images.Poster] = "new-poster"
};
await _repository.Edit(value, false);
await using DatabaseContext database = Repositories.Context.New();
Episode retrieved = await database.Episodes.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
[Fact]
public async Task EditMetadataTest()
{
Episode value = await _repository.Get(TestSample.Get<Episode>().Slug);
value.ExternalIDs = new[]
{
new MetadataID
{
Provider = TestSample.Get<Provider>(),
Link = "link",
DataID = "id"
},
};
await _repository.Edit(value, false);
await using DatabaseContext database = Repositories.Context.New();
Episode retrieved = await database.Episodes
.Include(x => x.ExternalIDs)
.ThenInclude(x => x.Provider)
.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
[Fact]
public async Task AddMetadataTest()
{
Episode value = await _repository.Get(TestSample.Get<Episode>().Slug);
value.ExternalIDs = new List<MetadataID>
{
new()
{
Provider = TestSample.Get<Provider>(),
Link = "link",
DataID = "id"
},
};
await _repository.Edit(value, false);
{
await using DatabaseContext database = Repositories.Context.New();
Episode retrieved = await database.Episodes
.Include(x => x.ExternalIDs)
.ThenInclude(x => x.Provider)
.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
value.ExternalIDs.Add(new MetadataID
{
Provider = TestSample.GetNew<Provider>(),
Link = "link",
DataID = "id"
});
await _repository.Edit(value, false);
{
await using DatabaseContext database = Repositories.Context.New();
Episode retrieved = await database.Episodes
.Include(x => x.ExternalIDs)
.ThenInclude(x => x.Provider)
.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
}
[Theory]
[InlineData("test")]
[InlineData("super")]
[InlineData("title")]
[InlineData("TiTlE")]
[InlineData("SuPeR")]
public async Task SearchTest(string query)
{
Episode value = new()
{
Title = "This is a test super title",
ShowID = 1,
AbsoluteNumber = 2
};
await _repository.Create(value);
ICollection<Episode> ret = await _repository.Search(query);
KAssert.DeepEqual(value, ret.First());
}
}
}

View File

@ -55,7 +55,7 @@ namespace Kyoo.Tests.Database
[Fact]
public async Task GetCollectionTests()
{
LibraryItem expected = new(TestSample.Get<Show>());
LibraryItem expected = new(TestSample.Get<Collection>());
LibraryItem actual = await _repository.Get(-1);
KAssert.DeepEqual(expected, actual);
}
@ -79,9 +79,10 @@ namespace Kyoo.Tests.Database
[Fact]
public async Task GetDuplicatedSlugTests()
{
await _repositories.LibraryManager.Create(new Collection()
await _repositories.LibraryManager.Create(new Collection
{
Slug = TestSample.Get<Show>().Slug
Slug = TestSample.Get<Show>().Slug,
Name = "name"
});
await Assert.ThrowsAsync<InvalidOperationException>(() => _repository.Get(TestSample.Get<Show>().Slug));
}

View File

@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Kyoo.Controllers;
using Kyoo.Models;
using Microsoft.EntityFrameworkCore;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Database
{
namespace SqLite
{
public class LibraryTests : ALibraryTests
{
public LibraryTests(ITestOutputHelper output)
: base(new RepositoryActivator(output)) { }
}
}
namespace PostgreSQL
{
[Collection(nameof(Postgresql))]
public class LibraryTests : ALibraryTests
{
public LibraryTests(PostgresFixture postgres, ITestOutputHelper output)
: base(new RepositoryActivator(output, postgres)) { }
}
}
public abstract class ALibraryTests : RepositoryTests<Library>
{
private readonly ILibraryRepository _repository;
protected ALibraryTests(RepositoryActivator repositories)
: base(repositories)
{
_repository = Repositories.LibraryManager.LibraryRepository;
}
[Fact]
public async Task CreateWithoutPathTest()
{
Library library = TestSample.GetNew<Library>();
library.Paths = null;
await Assert.ThrowsAsync<ArgumentException>(() => _repository.Create(library));
}
[Fact]
public async Task CreateWithEmptySlugTest()
{
Library library = TestSample.GetNew<Library>();
library.Slug = "";
await Assert.ThrowsAsync<ArgumentException>(() => _repository.Create(library));
}
[Fact]
public async Task CreateWithNumberSlugTest()
{
Library library = TestSample.GetNew<Library>();
library.Slug = "2";
Library ret = await _repository.Create(library);
Assert.Equal("2!", ret.Slug);
}
[Fact]
public async Task CreateWithoutNameTest()
{
Library library = TestSample.GetNew<Library>();
library.Name = null;
await Assert.ThrowsAsync<ArgumentException>(() => _repository.Create(library));
}
[Fact]
public async Task CreateWithProvider()
{
Library library = TestSample.GetNew<Library>();
library.Providers = new[] { TestSample.Get<Provider>() };
await _repository.Create(library);
Library retrieved = await _repository.Get(2);
await Repositories.LibraryManager.Load(retrieved, x => x.Providers);
Assert.Equal(1, retrieved.Providers.Count);
Assert.Equal(TestSample.Get<Provider>().Slug, retrieved.Providers.First().Slug);
}
[Fact]
public async Task EditTest()
{
Library value = await _repository.Get(TestSample.Get<Library>().Slug);
value.Paths = new [] {"/super", "/test"};
value.Name = "New Title";
Library edited = await _repository.Edit(value, false);
await using DatabaseContext database = Repositories.Context.New();
Library show = await database.Libraries.FirstAsync();
KAssert.DeepEqual(show, edited);
}
[Fact]
public async Task EditProvidersTest()
{
Library value = await _repository.Get(TestSample.Get<Library>().Slug);
value.Providers = new[]
{
TestSample.GetNew<Provider>()
};
Library edited = await _repository.Edit(value, false);
await using DatabaseContext database = Repositories.Context.New();
Library show = await database.Libraries
.Include(x => x.Providers)
.FirstAsync();
show.Providers.ForEach(x => x.Libraries = null);
edited.Providers.ForEach(x => x.Libraries = null);
KAssert.DeepEqual(show, edited);
}
[Fact]
public async Task AddProvidersTest()
{
Library value = await _repository.Get(TestSample.Get<Library>().Slug);
await Repositories.LibraryManager.Load(value, x => x.Providers);
value.Providers.Add(TestSample.GetNew<Provider>());
Library edited = await _repository.Edit(value, false);
await using DatabaseContext database = Repositories.Context.New();
Library show = await database.Libraries
.Include(x => x.Providers)
.FirstAsync();
show.Providers.ForEach(x => x.Libraries = null);
edited.Providers.ForEach(x => x.Libraries = null);
KAssert.DeepEqual(show, edited);
}
[Theory]
[InlineData("test")]
[InlineData("super")]
[InlineData("title")]
[InlineData("TiTlE")]
[InlineData("SuPeR")]
public async Task SearchTest(string query)
{
Library value = new()
{
Slug = "super-test",
Name = "This is a test title",
Paths = new [] {"path"}
};
await _repository.Create(value);
ICollection<Library> ret = await _repository.Search(query);
KAssert.DeepEqual(value, ret.First());
}
}
}

View File

@ -0,0 +1,170 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Kyoo.Controllers;
using Kyoo.Models;
using Microsoft.EntityFrameworkCore;
using Xunit;
using Xunit.Abstractions;
namespace Kyoo.Tests.Database
{
namespace SqLite
{
public class PeopleTests : APeopleTests
{
public PeopleTests(ITestOutputHelper output)
: base(new RepositoryActivator(output)) { }
}
}
namespace PostgreSQL
{
[Collection(nameof(Postgresql))]
public class PeopleTests : APeopleTests
{
public PeopleTests(PostgresFixture postgres, ITestOutputHelper output)
: base(new RepositoryActivator(output, postgres)) { }
}
}
public abstract class APeopleTests : RepositoryTests<People>
{
private readonly IPeopleRepository _repository;
protected APeopleTests(RepositoryActivator repositories)
: base(repositories)
{
_repository = Repositories.LibraryManager.PeopleRepository;
}
[Fact]
public async Task CreateWithExternalIdTest()
{
People value = TestSample.GetNew<People>();
value.ExternalIDs = new[]
{
new MetadataID
{
Provider = TestSample.Get<Provider>(),
Link = "link",
DataID = "id"
},
new MetadataID
{
Provider = TestSample.GetNew<Provider>(),
Link = "new-provider-link",
DataID = "new-id"
}
};
await _repository.Create(value);
People retrieved = await _repository.Get(2);
await Repositories.LibraryManager.Load(retrieved, x => x.ExternalIDs);
Assert.Equal(2, retrieved.ExternalIDs.Count);
KAssert.DeepEqual(value.ExternalIDs.First(), retrieved.ExternalIDs.First());
KAssert.DeepEqual(value.ExternalIDs.Last(), retrieved.ExternalIDs.Last());
}
[Fact]
public async Task EditTest()
{
People value = await _repository.Get(TestSample.Get<People>().Slug);
value.Name = "New Name";
value.Images = new Dictionary<int, string>
{
[Images.Poster] = "new-poster"
};
await _repository.Edit(value, false);
await using DatabaseContext database = Repositories.Context.New();
People retrieved = await database.People.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
[Fact]
public async Task EditMetadataTest()
{
People value = await _repository.Get(TestSample.Get<People>().Slug);
value.ExternalIDs = new[]
{
new MetadataID
{
Provider = TestSample.Get<Provider>(),
Link = "link",
DataID = "id"
},
};
await _repository.Edit(value, false);
await using DatabaseContext database = Repositories.Context.New();
People retrieved = await database.People
.Include(x => x.ExternalIDs)
.ThenInclude(x => x.Provider)
.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
[Fact]
public async Task AddMetadataTest()
{
People value = await _repository.Get(TestSample.Get<People>().Slug);
value.ExternalIDs = new List<MetadataID>
{
new()
{
Provider = TestSample.Get<Provider>(),
Link = "link",
DataID = "id"
},
};
await _repository.Edit(value, false);
{
await using DatabaseContext database = Repositories.Context.New();
People retrieved = await database.People
.Include(x => x.ExternalIDs)
.ThenInclude(x => x.Provider)
.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
value.ExternalIDs.Add(new MetadataID
{
Provider = TestSample.GetNew<Provider>(),
Link = "link",
DataID = "id"
});
await _repository.Edit(value, false);
{
await using DatabaseContext database = Repositories.Context.New();
People retrieved = await database.People
.Include(x => x.ExternalIDs)
.ThenInclude(x => x.Provider)
.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
}
[Theory]
[InlineData("Me")]
[InlineData("me")]
[InlineData("na")]
public async Task SearchTest(string query)
{
People value = new()
{
Slug = "slug",
Name = "name",
};
await _repository.Create(value);
ICollection<People> ret = await _repository.Search(query);
KAssert.DeepEqual(value, ret.First());
}
}
}

View File

@ -16,13 +16,6 @@ namespace Kyoo.Tests.Database
_repositories = new RepositoryActivator(output);
}
[Fact]
[SuppressMessage("ReSharper", "EqualExpressionComparison")]
public void SampleTest()
{
Assert.False(ReferenceEquals(TestSample.Get<Show>(), TestSample.Get<Show>()));
}
public void Dispose()
{
_repositories.Dispose();
@ -33,5 +26,12 @@ namespace Kyoo.Tests.Database
{
return _repositories.DisposeAsync();
}
[Fact]
[SuppressMessage("ReSharper", "EqualExpressionComparison")]
public void SampleTest()
{
Assert.False(ReferenceEquals(TestSample.Get<Show>(), TestSample.Get<Show>()));
}
}
}

Some files were not shown because too many files have changed in this diff Show More