Convert news items to dapper implementation

This commit is contained in:
Zoe Roux 2023-11-27 02:37:24 +01:00
parent 948f8694f2
commit ee4cc6706e
15 changed files with 86 additions and 302 deletions

View File

@ -16,180 +16,16 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>. // along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System; using Kyoo.Abstractions.Controllers;
using System.Collections.Generic; using Kyoo.Abstractions.Models.Attributes;
using System.ComponentModel.DataAnnotations;
namespace Kyoo.Abstractions.Models namespace Kyoo.Abstractions.Models;
/// <summary>
/// A show, a movie or a collection.
/// </summary>
[OneOf(Types = new[] { typeof(Episode), typeof(Movie) })]
public interface INews : IResource, IThumbnails, IMetadata, IAddedDate, IQuery
{ {
/// <summary> static Sort IQuery.DefaultSort => new Sort<INews>.By(nameof(AddedDate));
/// The type of item, ether a show, a movie or a collection.
/// </summary>
public enum NewsKind
{
/// <summary>
/// The <see cref="ILibraryItem"/> is an <see cref="Episode"/>.
/// </summary>
Episode,
/// <summary>
/// The <see cref="ILibraryItem"/> is a Movie.
/// </summary>
Movie,
}
/// <summary>
/// A new item
/// </summary>
public class News : IResource, IMetadata, IThumbnails, IAddedDate, IQuery
{
/// <inheritdoc />
public int Id { get; set; }
/// <inheritdoc />
[MaxLength(256)]
public string Slug { get; set; }
/// <summary>
/// The title of this show.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// A catchphrase for this movie.
/// </summary>
public string? Tagline { get; set; }
/// <summary>
/// The list of alternative titles of this show.
/// </summary>
public string[] Aliases { get; set; } = Array.Empty<string>();
/// <summary>
/// The path of the movie video file.
/// </summary>
public string Path { get; set; }
/// <summary>
/// The summary of this show.
/// </summary>
public string? Overview { get; set; }
/// <summary>
/// A list of tags that match this movie.
/// </summary>
public string[] Tags { get; set; } = Array.Empty<string>();
/// <summary>
/// The list of genres (themes) this show has.
/// </summary>
public Genre[] Genres { get; set; } = Array.Empty<Genre>();
/// <summary>
/// Is this show airing, not aired yet or finished?
/// </summary>
public Status? Status { get; set; }
/// <summary>
/// How well this item is rated? (from 0 to 100).
/// </summary>
public int? Rating { get; set; }
/// <summary>
/// How long is this movie or episode? (in minutes)
/// </summary>
public int Runtime { get; set; }
/// <summary>
/// The date this movie aired.
/// </summary>
public DateTime? AirDate { get; set; }
/// <summary>
/// The date this movie aired.
/// </summary>
public DateTime? ReleaseDate => AirDate;
/// <inheritdoc />
public DateTime AddedDate { get; set; }
/// <inheritdoc />
public Image? Poster { get; set; }
/// <inheritdoc />
public Image? Thumbnail { get; set; }
/// <inheritdoc />
public Image? Logo { get; set; }
/// <summary>
/// A video of a few minutes that tease the content.
/// </summary>
public string? Trailer { get; set; }
/// <inheritdoc />
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
/// <summary>
/// The season in witch this episode is in.
/// </summary>
public int? SeasonNumber { get; set; }
/// <summary>
/// The number of this episode in it's season.
/// </summary>
public int? EpisodeNumber { get; set; }
/// <summary>
/// The absolute number of this episode. It's an episode number that is not reset to 1 after a new season.
/// </summary>
public int? AbsoluteNumber { get; set; }
/// <summary>
/// A simple summary of informations about the show of this episode
/// (this is specially useful since news can't have includes).
/// </summary>
public ShowInfo? Show { get; set; }
/// <summary>
/// Is the item a a movie or an episode?
/// </summary>
public NewsKind Kind { get; set; }
/// <summary>
/// Links to watch this movie.
/// </summary>
public VideoLinks Links => new()
{
Direct = $"/video/{Kind.ToString().ToLower()}/{Slug}/direct",
Hls = $"/video/{Kind.ToString().ToLower()}/{Slug}/master.m3u8",
};
/// <summary>
/// A simple summary of informations about the show of this episode
/// (this is specially useful since news can't have includes).
/// </summary>
public class ShowInfo : IResource, IThumbnails
{
/// <inheritdoc/>
public int Id { get; set; }
/// <inheritdoc/>
public string Slug { get; set; }
/// <summary>
/// The title of this show.
/// </summary>
public string Name { get; set; }
/// <inheritdoc />
public Image? Poster { get; set; }
/// <inheritdoc />
public Image? Thumbnail { get; set; }
/// <inheritdoc />
public Image? Logo { get; set; }
}
}
} }

View File

@ -31,7 +31,7 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// A class to represent a single show's episode. /// A class to represent a single show's episode.
/// </summary> /// </summary>
public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, INews
{ {
// Use absolute numbers by default and fallback to season/episodes if it does not exists. // Use absolute numbers by default and fallback to season/episodes if it does not exists.
public static Sort DefaultSort => new Sort<Episode>.Conglomerate( public static Sort DefaultSort => new Sort<Episode>.Conglomerate(

View File

@ -29,7 +29,7 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// A series or a movie. /// A series or a movie.
/// </summary> /// </summary>
public class Movie : IQuery, IResource, IMetadata, IOnMerge, IThumbnails, IAddedDate, ILibraryItem public class Movie : IQuery, IResource, IMetadata, IOnMerge, IThumbnails, IAddedDate, ILibraryItem, INews
{ {
public static Sort DefaultSort => new Sort<Movie>.By(x => x.Name); public static Sort DefaultSort => new Sort<Movie>.By(x => x.Name);

View File

@ -39,12 +39,12 @@ public static class DapperHelper
{ {
private static string _Property(string key, Dictionary<string, Type> config) private static string _Property(string key, Dictionary<string, Type> config)
{ {
if (config.Count == 1) string[] keys = config
return $"{config.First()}.{key.ToSnakeCase()}";
IEnumerable<string> keys = config
.Where(x => key == "id" || x.Value.GetProperty(key) != null) .Where(x => key == "id" || x.Value.GetProperty(key) != null)
.Select(x => $"{x.Key}.{x.Value.GetProperty(key)?.GetCustomAttribute<ColumnAttribute>()?.Name ?? key.ToSnakeCase()}"); .Select(x => $"{x.Key}.{x.Value.GetProperty(key)?.GetCustomAttribute<ColumnAttribute>()?.Name ?? key.ToSnakeCase()}")
.ToArray();
if (keys.Length == 1)
return keys.First();
return $"coalesce({string.Join(", ", keys)})"; return $"coalesce({string.Join(", ", keys)})";
} }
@ -66,14 +66,16 @@ public static class DapperHelper
} }
public static ( public static (
Dictionary<string, Type> config, string projection,
string join, string join,
List<Type> types,
Func<T, IEnumerable<object?>, T> map Func<T, IEnumerable<object?>, T> map
) ProcessInclude<T>(Include<T> include, Dictionary<string, Type> config) ) ProcessInclude<T>(Include<T> include, Dictionary<string, Type> config)
where T : class where T : class
{ {
int relation = 0; int relation = 0;
Dictionary<string, Type> retConfig = new(); List<Type> types = new();
StringBuilder projection = new();
StringBuilder join = new(); StringBuilder join = new();
foreach (Include.Metadata metadata in include.Metadatas) foreach (Include.Metadata metadata in include.Metadatas)
@ -83,7 +85,8 @@ public static class DapperHelper
{ {
case Include.SingleRelation(var name, var type, var rid): case Include.SingleRelation(var name, var type, var rid):
string tableName = type.GetCustomAttribute<TableAttribute>()?.Name ?? $"{type.Name.ToSnakeCase()}s"; string tableName = type.GetCustomAttribute<TableAttribute>()?.Name ?? $"{type.Name.ToSnakeCase()}s";
retConfig.Add($"r{relation}", type); types.Add(type);
projection.AppendLine($", r{relation}.* -- {type.Name} as r{relation}");
join.Append($"\nleft join {tableName} as r{relation} on r{relation}.id = {_Property(rid, config)}"); join.Append($"\nleft join {tableName} as r{relation} on r{relation}.id = {_Property(rid, config)}");
break; break;
case Include.CustomRelation(var name, var type, var sql, var on, var declaring): case Include.CustomRelation(var name, var type, var sql, var on, var declaring):
@ -91,7 +94,8 @@ public static class DapperHelper
string lateral = sql.Contains("\"this\"") ? " lateral" : string.Empty; string lateral = sql.Contains("\"this\"") ? " lateral" : string.Empty;
sql = sql.Replace("\"this\"", owner); sql = sql.Replace("\"this\"", owner);
on = on?.Replace("\"this\"", owner); on = on?.Replace("\"this\"", owner);
retConfig.Add($"r{relation}", type); types.Add(type);
projection.AppendLine($", r{relation}.*");
join.Append($"\nleft join{lateral} ({sql}) as r{relation} on r{relation}.{on}"); join.Append($"\nleft join{lateral} ({sql}) as r{relation} on r{relation}.{on}");
break; break;
case Include.ProjectedRelation: case Include.ProjectedRelation:
@ -114,7 +118,7 @@ public static class DapperHelper
return item; return item;
} }
return (retConfig, join.ToString(), Map); return (projection.ToString(), join.ToString(), types, Map);
} }
public static FormattableString ProcessFilter<T>(Filter<T> filter, Dictionary<string, Type> config) public static FormattableString ProcessFilter<T>(Filter<T> filter, Dictionary<string, Type> config)
@ -187,9 +191,8 @@ public static class DapperHelper
// Include handling // Include handling
include ??= new(); include ??= new();
var (includeConfig, includeJoin, mapIncludes) = ProcessInclude(include, config); var (includeProjection, includeJoin, includeTypes, mapIncludes) = ProcessInclude(include, config);
query.AppendLiteral(includeJoin); query.AppendLiteral(includeJoin);
string includeProjection = string.Join(string.Empty, includeConfig.Select(x => $", {x.Key}.*"));
query.Replace("/* includes */", $"{includeProjection:raw}", out bool replaced); query.Replace("/* includes */", $"{includeProjection:raw}", out bool replaced);
if (!replaced) if (!replaced)
throw new ArgumentException("Missing '/* includes */' placeholder in top level sql select to support includes."); throw new ArgumentException("Missing '/* includes */' placeholder in top level sql select to support includes.");
@ -210,9 +213,7 @@ public static class DapperHelper
// Build query and prepare to do the query/projections // Build query and prepare to do the query/projections
IDapperSqlCommand cmd = query.Build(); IDapperSqlCommand cmd = query.Build();
string sql = cmd.Sql; string sql = cmd.Sql;
List<Type> types = config.Select(x => x.Value) List<Type> types = config.Select(x => x.Value).Concat(includeTypes).ToList();
.Concat(includeConfig.Select(x => x.Value))
.ToList();
// Expand projections on every types received. // Expand projections on every types received.
sql = Regex.Replace(sql, @"(,?) -- (\w+)( as (\w+))?", (match) => sql = Regex.Replace(sql, @"(,?) -- (\w+)( as (\w+))?", (match) =>
@ -296,6 +297,7 @@ public static class DapperHelper
query += ProcessFilter(filter, config); query += ProcessFilter(filter, config);
IDapperSqlCommand cmd = query.Build(); IDapperSqlCommand cmd = query.Build();
// language=postgreSQL // language=postgreSQL
string sql = $"select count(*) from ({cmd.Sql}) as query"; string sql = $"select count(*) from ({cmd.Sql}) as query";

View File

@ -18,53 +18,53 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Data.Common;
using Kyoo.Abstractions.Controllers; using System.IO;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Utils;
using Kyoo.Postgresql;
namespace Kyoo.Core.Controllers namespace Kyoo.Core.Controllers
{ {
/// <summary> /// <summary>
/// A local repository to handle shows /// A local repository to handle shows
/// </summary> /// </summary>
public class NewsRepository : LocalRepository<News> public class NewsRepository : DapperRepository<INews>
{ {
public NewsRepository(DatabaseContext database, IThumbnailsManager thumbs) // language=PostgreSQL
: base(database, thumbs) protected override FormattableString Sql => $"""
select
e.*, -- Episode as e
m.*
/* includes */
from
episodes as e
full outer join (
select
* -- Movie
from
movies
) as m on false
""";
protected override Dictionary<string, Type> Config => new()
{
{ "e", typeof(Episode) },
{ "m", typeof(Movie) },
};
protected override INews Mapper(List<object?> items)
{
if (items[0] is Episode episode && episode.Id != 0)
return episode;
if (items[1] is Movie movie && movie.Id != 0)
{
movie.Id = -movie.Id;
return movie;
}
throw new InvalidDataException();
}
public NewsRepository(DbConnection database)
: base(database)
{ } { }
/// <inheritdoc />
public override Task<ICollection<News>> Search(string query, Include<News>? include = default)
=> throw new InvalidOperationException();
/// <inheritdoc />
public override Task<News> Create(News obj)
=> throw new InvalidOperationException();
/// <inheritdoc />
public override Task<News> CreateIfNotExists(News obj)
=> throw new InvalidOperationException();
/// <inheritdoc />
public override Task<News> Edit(News edited)
=> throw new InvalidOperationException();
/// <inheritdoc />
public override Task<News> Patch(int id, Func<News, Task<bool>> patch)
=> throw new InvalidOperationException();
/// <inheritdoc />
public override Task Delete(int id)
=> throw new InvalidOperationException();
/// <inheritdoc />
public override Task Delete(string slug)
=> throw new InvalidOperationException();
/// <inheritdoc />
public override Task Delete(News obj)
=> throw new InvalidOperationException();
} }
} }

View File

@ -33,9 +33,9 @@ namespace Kyoo.Core.Api
[ApiController] [ApiController]
[PartialPermission("LibraryItem")] [PartialPermission("LibraryItem")]
[ApiDefinition("News", Group = ResourcesGroup)] [ApiDefinition("News", Group = ResourcesGroup)]
public class NewsApi : CrudThumbsApi<News> public class NewsApi : CrudThumbsApi<INews>
{ {
public NewsApi(IRepository<News> news, IThumbnailsManager thumbs) public NewsApi(IRepository<INews> news, IThumbnailsManager thumbs)
: base(news, thumbs) : base(news, thumbs)
{ } { }
} }

View File

@ -92,14 +92,6 @@ namespace Kyoo.Postgresql
/// </summary> /// </summary>
public DbSet<PeopleRole> PeopleRoles { get; set; } public DbSet<PeopleRole> PeopleRoles { get; set; }
/// <summary>
/// The list of new items (episodes and movies).
/// </summary>
/// <remarks>
/// This set is ready only, on most database this will be a view.
/// </remarks>
public DbSet<News> News { get; set; }
/// <summary> /// <summary>
/// Add a many to many link between two resources. /// Add a many to many link between two resources.
/// </summary> /// </summary>
@ -284,7 +276,6 @@ namespace Kyoo.Postgresql
.WithMany("Users") .WithMany("Users")
.UsingEntity(x => x.ToTable(LinkName<User, Show>())); .UsingEntity(x => x.ToTable(LinkName<User, Show>()));
_HasMetadata<News>(modelBuilder);
_HasMetadata<Collection>(modelBuilder); _HasMetadata<Collection>(modelBuilder);
_HasMetadata<Movie>(modelBuilder); _HasMetadata<Movie>(modelBuilder);
_HasMetadata<Show>(modelBuilder); _HasMetadata<Show>(modelBuilder);
@ -293,7 +284,6 @@ namespace Kyoo.Postgresql
_HasMetadata<People>(modelBuilder); _HasMetadata<People>(modelBuilder);
_HasMetadata<Studio>(modelBuilder); _HasMetadata<Studio>(modelBuilder);
_HasImages<News>(modelBuilder);
_HasImages<Collection>(modelBuilder); _HasImages<Collection>(modelBuilder);
_HasImages<Movie>(modelBuilder); _HasImages<Movie>(modelBuilder);
_HasImages<Show>(modelBuilder); _HasImages<Show>(modelBuilder);
@ -301,7 +291,6 @@ namespace Kyoo.Postgresql
_HasImages<Episode>(modelBuilder); _HasImages<Episode>(modelBuilder);
_HasImages<People>(modelBuilder); _HasImages<People>(modelBuilder);
_HasAddedDate<News>(modelBuilder);
_HasAddedDate<Collection>(modelBuilder); _HasAddedDate<Collection>(modelBuilder);
_HasAddedDate<Movie>(modelBuilder); _HasAddedDate<Movie>(modelBuilder);
_HasAddedDate<Show>(modelBuilder); _HasAddedDate<Show>(modelBuilder);
@ -347,14 +336,6 @@ namespace Kyoo.Postgresql
modelBuilder.Entity<Movie>() modelBuilder.Entity<Movie>()
.Ignore(x => x.Links); .Ignore(x => x.Links);
modelBuilder.Entity<News>()
.Ignore(x => x.Links);
var builder = modelBuilder.Entity<News>()
.OwnsOne(x => x.Show);
builder.OwnsOne(x => x.Poster);
builder.OwnsOne(x => x.Thumbnail);
builder.OwnsOne(x => x.Logo);
} }
/// <summary> /// <summary>

View File

@ -370,10 +370,6 @@ namespace Kyoo.Postgresql.Migrations
.HasColumnType("genre[]") .HasColumnType("genre[]")
.HasColumnName("genres"); .HasColumnName("genres");
b.Property<NewsKind>("Kind")
.HasColumnType("news_kind")
.HasColumnName("kind");
b.Property<string>("Name") b.Property<string>("Name")
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("name"); .HasColumnName("name");

View File

@ -390,10 +390,6 @@ namespace Kyoo.Postgresql.Migrations
.HasColumnType("genre[]") .HasColumnType("genre[]")
.HasColumnName("genres"); .HasColumnName("genres");
b.Property<NewsKind>("Kind")
.HasColumnType("news_kind")
.HasColumnName("kind");
b.Property<string>("Name") b.Property<string>("Name")
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("name"); .HasColumnName("name");

View File

@ -387,10 +387,6 @@ namespace Kyoo.Postgresql.Migrations
.HasColumnType("genre[]") .HasColumnType("genre[]")
.HasColumnName("genres"); .HasColumnName("genres");
b.Property<NewsKind>("Kind")
.HasColumnType("news_kind")
.HasColumnName("kind");
b.Property<string>("Name") b.Property<string>("Name")
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("name"); .HasColumnName("name");

View File

@ -18,11 +18,8 @@
using System; using System;
using System.Globalization; using System.Globalization;
using System.Linq.Expressions;
using System.Reflection;
using EFCore.NamingConventions.Internal; using EFCore.NamingConventions.Internal;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
using Kyoo.Utils;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query.SqlExpressions; using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
using Npgsql; using Npgsql;
@ -50,7 +47,6 @@ namespace Kyoo.Postgresql
{ {
NpgsqlConnection.GlobalTypeMapper.MapEnum<Status>(); NpgsqlConnection.GlobalTypeMapper.MapEnum<Status>();
NpgsqlConnection.GlobalTypeMapper.MapEnum<Genre>(); NpgsqlConnection.GlobalTypeMapper.MapEnum<Genre>();
NpgsqlConnection.GlobalTypeMapper.MapEnum<NewsKind>();
} }
/// <summary> /// <summary>
@ -103,7 +99,6 @@ namespace Kyoo.Postgresql
{ {
modelBuilder.HasPostgresEnum<Status>(); modelBuilder.HasPostgresEnum<Status>();
modelBuilder.HasPostgresEnum<Genre>(); modelBuilder.HasPostgresEnum<Genre>();
modelBuilder.HasPostgresEnum<NewsKind>();
modelBuilder.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(MD5))!) modelBuilder.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(MD5))!)
.HasTranslation(args => .HasTranslation(args =>

View File

@ -37,7 +37,10 @@ export const EpisodeP = BaseEpisodeP.and(
show: ShowP.optional(), show: ShowP.optional(),
}), }),
); ).transform((x) => {
if (x.show && !x.thumbnail && x.show.thumbnail) x.thumbnail = x.show.thumbnail;
return x;
});
/** /**
* A class to represent a single show's episode. * A class to represent a single show's episode.

View File

@ -20,9 +20,7 @@
import { z } from "zod"; import { z } from "zod";
import { MovieP } from "./movie"; import { MovieP } from "./movie";
import { BaseEpisodeP } from "./episode.base"; import { EpisodeP } from "./episode";
import { ResourceP } from "../traits/resource";
import { withImages } from "../traits/images";
/** /**
* The type of item, ether a a movie or an episode. * The type of item, ether a a movie or an episode.
@ -36,29 +34,7 @@ export const NewsP = z.union([
/* /*
* Either an episode * Either an episode
*/ */
BaseEpisodeP.and( EpisodeP.and(z.object({ kind: z.literal(NewsKind.Episode) })),
z.object({
kind: z.literal(NewsKind.Episode),
show: withImages(
ResourceP.extend({
name: z.string(),
}),
"shows",
).transform((x) => {
if (!x.thumbnail && x.poster) {
x.thumbnail = { ...x.poster };
if (x.thumbnail) {
x.thumbnail.low = x.thumbnail.high;
x.thumbnail.medium = x.thumbnail.high;
}
}
return x;
}),
}),
).transform((x) => {
if (!x.thumbnail && x.show.thumbnail) x.thumbnail = x.show.thumbnail;
return x;
}),
/* /*
* Or a Movie * Or a Movie
*/ */

View File

@ -92,5 +92,6 @@ NewsList.query = (): QueryIdentifier<News> => ({
params: { params: {
// Limit the inital numbers of items // Limit the inital numbers of items
limit: 10, limit: 10,
fields: ["show"],
}, },
}); });

View File

@ -149,13 +149,15 @@ export const ItemDetails = ({
minHeight: px(50), minHeight: px(50),
})} })}
> >
<ScrollView horizontal {...css({ alignItems: "center" })}> {(isLoading || genres) && (
{(genres || [...Array(3)])?.map((x, i) => ( <ScrollView horizontal {...css({ alignItems: "center" })}>
<Chip key={x ?? i} size="small" {...css({ mX: ts(0.5) })}> {(genres || [...Array(3)])?.map((x, i) => (
{x ?? <Skeleton {...css({ width: rem(3), height: rem(0.8) })} />} <Chip key={x ?? i} size="small" {...css({ mX: ts(0.5) })}>
</Chip> {x ?? <Skeleton {...css({ width: rem(3), height: rem(0.8) })} />}
))} </Chip>
</ScrollView> ))}
</ScrollView>
)}
{playHref !== null && ( {playHref !== null && (
<IconFab <IconFab
icon={PlayArrow} icon={PlayArrow}