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
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models.Attributes;
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>
/// 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; }
}
}
static Sort IQuery.DefaultSort => new Sort<INews>.By(nameof(AddedDate));
}

View File

@ -31,7 +31,7 @@ namespace Kyoo.Abstractions.Models
/// <summary>
/// A class to represent a single show's episode.
/// </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.
public static Sort DefaultSort => new Sort<Episode>.Conglomerate(

View File

@ -29,7 +29,7 @@ namespace Kyoo.Abstractions.Models
/// <summary>
/// A series or a movie.
/// </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);

View File

@ -39,12 +39,12 @@ public static class DapperHelper
{
private static string _Property(string key, Dictionary<string, Type> config)
{
if (config.Count == 1)
return $"{config.First()}.{key.ToSnakeCase()}";
IEnumerable<string> keys = config
string[] keys = config
.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)})";
}
@ -66,14 +66,16 @@ public static class DapperHelper
}
public static (
Dictionary<string, Type> config,
string projection,
string join,
List<Type> types,
Func<T, IEnumerable<object?>, T> map
) ProcessInclude<T>(Include<T> include, Dictionary<string, Type> config)
where T : class
{
int relation = 0;
Dictionary<string, Type> retConfig = new();
List<Type> types = new();
StringBuilder projection = new();
StringBuilder join = new();
foreach (Include.Metadata metadata in include.Metadatas)
@ -83,7 +85,8 @@ public static class DapperHelper
{
case Include.SingleRelation(var name, var type, var rid):
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)}");
break;
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;
sql = sql.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}");
break;
case Include.ProjectedRelation:
@ -114,7 +118,7 @@ public static class DapperHelper
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)
@ -187,9 +191,8 @@ public static class DapperHelper
// Include handling
include ??= new();
var (includeConfig, includeJoin, mapIncludes) = ProcessInclude(include, config);
var (includeProjection, includeJoin, includeTypes, mapIncludes) = ProcessInclude(include, config);
query.AppendLiteral(includeJoin);
string includeProjection = string.Join(string.Empty, includeConfig.Select(x => $", {x.Key}.*"));
query.Replace("/* includes */", $"{includeProjection:raw}", out bool replaced);
if (!replaced)
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
IDapperSqlCommand cmd = query.Build();
string sql = cmd.Sql;
List<Type> types = config.Select(x => x.Value)
.Concat(includeConfig.Select(x => x.Value))
.ToList();
List<Type> types = config.Select(x => x.Value).Concat(includeTypes).ToList();
// Expand projections on every types received.
sql = Regex.Replace(sql, @"(,?) -- (\w+)( as (\w+))?", (match) =>
@ -296,6 +297,7 @@ public static class DapperHelper
query += ProcessFilter(filter, config);
IDapperSqlCommand cmd = query.Build();
// language=postgreSQL
string sql = $"select count(*) from ({cmd.Sql}) as query";

View File

@ -18,53 +18,53 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers;
using System.Data.Common;
using System.IO;
using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Utils;
using Kyoo.Postgresql;
namespace Kyoo.Core.Controllers
{
/// <summary>
/// A local repository to handle shows
/// </summary>
public class NewsRepository : LocalRepository<News>
public class NewsRepository : DapperRepository<INews>
{
public NewsRepository(DatabaseContext database, IThumbnailsManager thumbs)
: base(database, thumbs)
// language=PostgreSQL
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]
[PartialPermission("LibraryItem")]
[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)
{ }
}

View File

@ -92,14 +92,6 @@ namespace Kyoo.Postgresql
/// </summary>
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>
/// Add a many to many link between two resources.
/// </summary>
@ -284,7 +276,6 @@ namespace Kyoo.Postgresql
.WithMany("Users")
.UsingEntity(x => x.ToTable(LinkName<User, Show>()));
_HasMetadata<News>(modelBuilder);
_HasMetadata<Collection>(modelBuilder);
_HasMetadata<Movie>(modelBuilder);
_HasMetadata<Show>(modelBuilder);
@ -293,7 +284,6 @@ namespace Kyoo.Postgresql
_HasMetadata<People>(modelBuilder);
_HasMetadata<Studio>(modelBuilder);
_HasImages<News>(modelBuilder);
_HasImages<Collection>(modelBuilder);
_HasImages<Movie>(modelBuilder);
_HasImages<Show>(modelBuilder);
@ -301,7 +291,6 @@ namespace Kyoo.Postgresql
_HasImages<Episode>(modelBuilder);
_HasImages<People>(modelBuilder);
_HasAddedDate<News>(modelBuilder);
_HasAddedDate<Collection>(modelBuilder);
_HasAddedDate<Movie>(modelBuilder);
_HasAddedDate<Show>(modelBuilder);
@ -347,14 +336,6 @@ namespace Kyoo.Postgresql
modelBuilder.Entity<Movie>()
.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>

View File

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

View File

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

View File

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

View File

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

View File

@ -37,7 +37,10 @@ export const EpisodeP = BaseEpisodeP.and(
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.

View File

@ -20,9 +20,7 @@
import { z } from "zod";
import { MovieP } from "./movie";
import { BaseEpisodeP } from "./episode.base";
import { ResourceP } from "../traits/resource";
import { withImages } from "../traits/images";
import { EpisodeP } from "./episode";
/**
* The type of item, ether a a movie or an episode.
@ -36,29 +34,7 @@ export const NewsP = z.union([
/*
* Either an episode
*/
BaseEpisodeP.and(
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;
}),
EpisodeP.and(z.object({ kind: z.literal(NewsKind.Episode) })),
/*
* Or a Movie
*/

View File

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

View File

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