Add watchlists on news and library items

This commit is contained in:
Zoe Roux 2023-11-29 14:03:43 +01:00
parent 948c98f95b
commit 6fbd00a38f
11 changed files with 127 additions and 14 deletions

View File

@ -0,0 +1,37 @@
// Kyoo - A portable and vast media library solution.
// Copyright (c) Kyoo.
//
// See AUTHORS.md and LICENSE file in the project root for full license information.
//
// Kyoo is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// Kyoo is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// 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 Kyoo.Utils;
namespace Kyoo.Abstractions.Models.Attributes;
[AttributeUsage(AttributeTargets.Class)]
public class SqlFirstColumnAttribute : Attribute
{
/// <summary>
/// The name of the first column of the element. Used to split multiples
/// items on a single sql query. If not specified, it defaults to "Id".
/// </summary>
public string Name { get; set; }
public SqlFirstColumnAttribute(string name)
{
Name = name.ToSnakeCase();
}
}

View File

@ -244,7 +244,11 @@ namespace Kyoo.Abstractions.Models
/// Metadata of what an user as started/planned to watch.
/// </summary>
[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)]
[LoadableRelation] public EpisodeWatchStatus? WatchStatus { get; set; }
[LoadableRelation(
Sql = "episode_watch_status",
On = "episode_id = \"this\".id and \"relation\".user_id = [current_user]"
)]
public EpisodeWatchStatus? WatchStatus { get; set; }
// There is a global query filter to filter by user so we just need to do single.
private EpisodeWatchStatus? _WatchStatus => Watched!.FirstOrDefault();

View File

@ -20,6 +20,7 @@ using System;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Kyoo.Abstractions.Models.Attributes;
using Newtonsoft.Json;
namespace Kyoo.Abstractions.Models
@ -47,6 +48,7 @@ namespace Kyoo.Abstractions.Models
}
[TypeConverter(typeof(ImageConvertor))]
[SqlFirstColumn(nameof(Source))]
public class Image
{
/// <summary>

View File

@ -152,7 +152,11 @@ namespace Kyoo.Abstractions.Models
/// Metadata of what an user as started/planned to watch.
/// </summary>
[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)]
[LoadableRelation] public MovieWatchStatus? WatchStatus { get; set; }
[LoadableRelation(
Sql = "movie_watch_status",
On = "movie_id = \"this\".id and \"relation\".user_id = [current_user]"
)]
public MovieWatchStatus? WatchStatus { get; set; }
// There is a global query filter to filter by user so we just need to do single.
private MovieWatchStatus? _WatchStatus => Watched!.FirstOrDefault();

View File

@ -193,7 +193,11 @@ namespace Kyoo.Abstractions.Models
/// Metadata of what an user as started/planned to watch.
/// </summary>
[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)]
[LoadableRelation] public ShowWatchStatus? WatchStatus { get; set; }
[LoadableRelation(
Sql = "show_watch_status",
On = "show_id = \"this\".id and \"relation\".user_id = [current_user]"
)]
public ShowWatchStatus? WatchStatus { get; set; }
// There is a global query filter to filter by user so we just need to do single.
private ShowWatchStatus? _WatchStatus => Watched!.FirstOrDefault();

View File

@ -53,6 +53,7 @@ namespace Kyoo.Abstractions.Models
/// <summary>
/// Metadata of what an user as started/planned to watch.
/// </summary>
[SqlFirstColumn(nameof(UserId))]
public class MovieWatchStatus : IAddedDate
{
/// <summary>
@ -105,6 +106,7 @@ namespace Kyoo.Abstractions.Models
public int? WatchedPercent { get; set; }
}
[SqlFirstColumn(nameof(UserId))]
public class EpisodeWatchStatus : IAddedDate
{
/// <summary>
@ -157,6 +159,7 @@ namespace Kyoo.Abstractions.Models
public int? WatchedPercent { get; set; }
}
[SqlFirstColumn(nameof(UserId))]
public class ShowWatchStatus : IAddedDate
{
/// <summary>

View File

@ -28,15 +28,35 @@ using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Dapper;
using InterpolatedSql.Dapper;
using InterpolatedSql.Dapper.SqlBuilders;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Attributes;
using Kyoo.Abstractions.Models.Utils;
using Kyoo.Authentication;
using Kyoo.Utils;
using Microsoft.AspNetCore.Http;
namespace Kyoo.Core.Controllers;
public static class DapperHelper
{
public static SqlBuilder ProcessVariables(SqlBuilder sql, SqlVariableContext context)
{
int start = 0;
while ((start = sql.IndexOf("[", start, false)) != -1)
{
int end = sql.IndexOf("]", start, false);
if (end == -1)
throw new ArgumentException("Invalid sql variable substitue (missing ])");
string var = sql.Format[(start + 1)..end];
sql.Remove(start, end - start + 1);
sql.Insert(start, $"{context.ReadVar(var)}");
}
return sql;
}
public static string Property(string key, Dictionary<string, Type> config)
{
if (key == "kind")
@ -95,10 +115,12 @@ public static class DapperHelper
string owner = config.First(x => x.Value == declaring).Key;
string lateral = sql.Contains("\"this\"") ? " lateral" : string.Empty;
sql = sql.Replace("\"this\"", owner);
on = on?.Replace("\"this\"", owner);
on = on?.Replace("\"this\"", owner)?.Replace("\"relation\"", $"r{relation}");
if (sql.Any(char.IsWhiteSpace))
sql = $"({sql})";
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;
case Include.ProjectedRelation:
continue;
@ -197,13 +219,14 @@ public static class DapperHelper
Dictionary<string, Type> config,
Func<List<object?>, T> mapper,
Func<Guid, Task<T>> get,
SqlVariableContext context,
Include<T>? include,
Filter<T>? filter,
Sort<T>? sort,
Pagination? limit)
where T : class, IResource, IQuery
{
InterpolatedSql.Dapper.SqlBuilders.SqlBuilder query = new(db, command);
SqlBuilder query = new(db, command);
// Include handling
include ??= new();
@ -227,6 +250,8 @@ public static class DapperHelper
if (limit != null)
query += $"\nlimit {limit.Limit}";
ProcessVariables(query, context);
// Build query and prepare to do the query/projections
IDapperSqlCommand cmd = query.Build();
string sql = cmd.Sql;
@ -280,7 +305,7 @@ public static class DapperHelper
return mapIncludes(mapper(nItems), nItems.Skip(config.Count));
},
ParametersDictionary.LoadFrom(cmd),
splitOn: string.Join(',', types.Select(x => x == typeof(Image) ? "source" : "id"))
splitOn: string.Join(',', types.Select(x => x.GetCustomAttribute<SqlFirstColumnAttribute>()?.Name ?? "id"))
);
if (limit?.Reverse == true)
data = data.Reverse();
@ -292,6 +317,7 @@ public static class DapperHelper
FormattableString command,
Dictionary<string, Type> config,
Func<List<object?>, T> mapper,
SqlVariableContext context,
Include<T>? include,
Filter<T>? filter,
Sort<T>? sort = null,
@ -303,6 +329,7 @@ public static class DapperHelper
config,
mapper,
get: null!,
context,
include,
filter,
sort,
@ -315,6 +342,7 @@ public static class DapperHelper
this IDbConnection db,
FormattableString command,
Dictionary<string, Type> config,
SqlVariableContext context,
Filter<T>? filter)
where T : class, IResource
{
@ -322,7 +350,7 @@ public static class DapperHelper
if (filter != null)
query += ProcessFilter(filter, config);
ProcessVariables(query, context);
IDapperSqlCommand cmd = query.Build();
// language=postgreSQL
@ -334,3 +362,22 @@ public static class DapperHelper
);
}
}
public class SqlVariableContext
{
private readonly IHttpContextAccessor _accessor;
public SqlVariableContext(IHttpContextAccessor accessor)
{
_accessor = accessor;
}
public object? ReadVar(string var)
{
return var switch
{
"current_user" => _accessor.HttpContext?.User.GetId(),
_ => throw new ArgumentException($"Invalid sql variable name: {var}")
};
}
}

View File

@ -25,7 +25,6 @@ using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Exceptions;
using Kyoo.Abstractions.Models.Utils;
using static Kyoo.Core.Controllers.DapperHelper;
namespace Kyoo.Core.Controllers;
@ -42,9 +41,13 @@ public abstract class DapperRepository<T> : IRepository<T>
protected DbConnection Database { get; init; }
public DapperRepository(DbConnection database)
protected SqlVariableContext Context { get; init; }
public DapperRepository(DbConnection database, SqlVariableContext context)
{
Database = database;
Context = context;
}
/// <inheritdoc/>
@ -83,6 +86,7 @@ public abstract class DapperRepository<T> : IRepository<T>
Config,
Mapper,
(id) => Get(id),
Context,
include,
Filter.Or(ids.Select(x => new Filter<T>.Eq("id", x)).ToArray()),
sort: null,
@ -99,6 +103,7 @@ public abstract class DapperRepository<T> : IRepository<T>
Sql,
Config,
Mapper,
Context,
include,
new Filter<T>.Eq(nameof(IResource.Id), id)
);
@ -113,6 +118,7 @@ public abstract class DapperRepository<T> : IRepository<T>
Sql,
Config,
Mapper,
Context,
include,
filter: null,
new Sort<T>.Random()
@ -122,6 +128,7 @@ public abstract class DapperRepository<T> : IRepository<T>
Sql,
Config,
Mapper,
Context,
include,
new Filter<T>.Eq(nameof(IResource.Slug), slug)
);
@ -137,6 +144,7 @@ public abstract class DapperRepository<T> : IRepository<T>
Sql,
Config,
Mapper,
Context,
include,
filter,
sortBy
@ -154,6 +162,7 @@ public abstract class DapperRepository<T> : IRepository<T>
Config,
Mapper,
(id) => Get(id),
Context,
include,
filter,
sort ?? new Sort<T>.Default(),
@ -167,6 +176,7 @@ public abstract class DapperRepository<T> : IRepository<T>
return Database.Count(
Sql,
Config,
Context,
filter
);
}

View File

@ -76,8 +76,8 @@ namespace Kyoo.Core.Controllers
throw new InvalidDataException();
}
public LibraryItemRepository(DbConnection database)
: base(database)
public LibraryItemRepository(DbConnection database, SqlVariableContext context)
: base(database, context)
{ }
public async Task<ICollection<ILibraryItem>> GetAllOfCollection(
@ -118,6 +118,7 @@ namespace Kyoo.Core.Controllers
},
Mapper,
(id) => Get(id),
Context,
include,
filter,
sort ?? new Sort<ILibraryItem>.Default(),

View File

@ -60,8 +60,8 @@ namespace Kyoo.Core.Controllers
throw new InvalidDataException();
}
public NewsRepository(DbConnection database)
: base(database)
public NewsRepository(DbConnection database, SqlVariableContext context)
: base(database, context)
{ }
}
}

View File

@ -68,6 +68,7 @@ namespace Kyoo.Core
builder.RegisterRepository<UserRepository>();
builder.RegisterRepository<NewsRepository>();
builder.RegisterType<WatchStatusRepository>().As<IWatchStatusRepository>().AsSelf().InstancePerLifetimeScope();
builder.RegisterType<SqlVariableContext>().InstancePerLifetimeScope();
}
/// <inheritdoc />