Add metadata refresh (#430)

This commit is contained in:
Zoe Roux 2024-04-22 23:55:15 +02:00 committed by GitHub
commit 7d354249d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 461 additions and 152 deletions

View File

@ -0,0 +1,27 @@
// 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 System.Threading.Tasks;
namespace Kyoo.Abstractions.Controllers;
public interface IScanner
{
Task SendRefreshRequest(string kind, Guid id);
}

View File

@ -26,4 +26,18 @@ public interface IRefreshable
/// The date of the next metadata refresh. Null if auto-refresh is disabled. /// The date of the next metadata refresh. Null if auto-refresh is disabled.
/// </summary> /// </summary>
public DateTime? NextMetadataRefresh { get; set; } public DateTime? NextMetadataRefresh { get; set; }
public static DateTime ComputeNextRefreshDate(DateOnly? airDate)
{
if (airDate is null)
return DateTime.UtcNow.AddDays(1);
int days = airDate.Value.DayNumber - DateOnly.FromDateTime(DateTime.UtcNow).DayNumber;
return days switch
{
<= 7 => DateTime.UtcNow.AddDays(1),
<= 21 => DateTime.UtcNow.AddDays(5),
_ => DateTime.UtcNow.AddMonths(2)
};
}
} }

View File

@ -53,6 +53,7 @@ public class CollectionRepository(DatabaseContext database, IThumbnailsManager t
if (string.IsNullOrEmpty(resource.Name)) if (string.IsNullOrEmpty(resource.Name))
throw new ArgumentException("The collection's name must be set and not empty"); throw new ArgumentException("The collection's name must be set and not empty");
resource.NextMetadataRefresh ??= DateTime.UtcNow.AddMonths(2);
await thumbnails.DownloadImages(resource); await thumbnails.DownloadImages(resource);
} }

View File

@ -71,14 +71,6 @@ public class EpisodeRepository(
.ToListAsync(); .ToListAsync();
} }
/// <inheritdoc />
public override async Task<Episode> Create(Episode obj)
{
// Set it for the OnResourceCreated event and the return value.
obj.ShowSlug = obj.Show?.Slug ?? (await shows.Get(obj.ShowId)).Slug;
return await base.Create(obj);
}
/// <inheritdoc /> /// <inheritdoc />
protected override async Task Validate(Episode resource) protected override async Task Validate(Episode resource)
{ {
@ -86,6 +78,9 @@ public class EpisodeRepository(
resource.Show = null; resource.Show = null;
if (resource.ShowId == Guid.Empty) if (resource.ShowId == Guid.Empty)
throw new ValidationException("Missing show id"); throw new ValidationException("Missing show id");
// This is storred in db so it needs to be set before every create/edit (and before events)
resource.ShowSlug = (await shows.Get(resource.ShowId)).Slug;
resource.Season = null; resource.Season = null;
if (resource.SeasonId == null && resource.SeasonNumber != null) if (resource.SeasonId == null && resource.SeasonNumber != null)
{ {
@ -96,6 +91,8 @@ public class EpisodeRepository(
.Select(x => x.Id) .Select(x => x.Id)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
resource.NextMetadataRefresh ??= IRefreshable.ComputeNextRefreshDate(resource.ReleaseDate);
await thumbnails.DownloadImages(resource); await thumbnails.DownloadImages(resource);
} }

View File

@ -297,6 +297,8 @@ public abstract class GenericRepository<T>(DatabaseContext database) : IReposito
{ {
await Validate(edited); await Validate(edited);
Database.Update(edited); Database.Update(edited);
if (edited is IAddedDate date)
Database.Entry(date).Property(p => p.AddedDate).IsModified = false;
await Database.SaveChangesAsync(); await Database.SaveChangesAsync();
await IRepository<T>.OnResourceEdited(edited); await IRepository<T>.OnResourceEdited(edited);
return edited; return edited;
@ -305,23 +307,15 @@ public abstract class GenericRepository<T>(DatabaseContext database) : IReposito
/// <inheritdoc/> /// <inheritdoc/>
public virtual async Task<T> Patch(Guid id, Func<T, T> patch) public virtual async Task<T> Patch(Guid id, Func<T, T> patch)
{ {
bool lazyLoading = Database.ChangeTracker.LazyLoadingEnabled; T resource = await GetWithTracking(id);
Database.ChangeTracker.LazyLoadingEnabled = false;
try
{
T resource = await GetWithTracking(id);
resource = patch(resource); resource = patch(resource);
if (resource is IAddedDate date)
Database.Entry(date).Property(p => p.AddedDate).IsModified = false;
await Database.SaveChangesAsync(); await Database.SaveChangesAsync();
await IRepository<T>.OnResourceEdited(resource); await IRepository<T>.OnResourceEdited(resource);
return resource; return resource;
}
finally
{
Database.ChangeTracker.LazyLoadingEnabled = lazyLoading;
Database.ChangeTracker.Clear();
}
} }
/// <exception cref="ValidationException"> /// <exception cref="ValidationException">

View File

@ -74,6 +74,7 @@ public class MovieRepository(
resource.StudioId = (await studios.CreateIfNotExists(resource.Studio)).Id; resource.StudioId = (await studios.CreateIfNotExists(resource.Studio)).Id;
resource.Studio = null; resource.Studio = null;
} }
resource.NextMetadataRefresh ??= IRefreshable.ComputeNextRefreshDate(resource.AirDate);
await thumbnails.DownloadImages(resource); await thumbnails.DownloadImages(resource);
} }
} }

View File

@ -23,7 +23,6 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Exceptions;
using Kyoo.Abstractions.Models.Utils; using Kyoo.Abstractions.Models.Utils;
using Kyoo.Postgresql; using Kyoo.Postgresql;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -31,8 +30,11 @@ using Microsoft.Extensions.DependencyInjection;
namespace Kyoo.Core.Controllers; namespace Kyoo.Core.Controllers;
public class SeasonRepository(DatabaseContext database, IThumbnailsManager thumbnails) public class SeasonRepository(
: GenericRepository<Season>(database) DatabaseContext database,
IRepository<Show> shows,
IThumbnailsManager thumbnails
) : GenericRepository<Season>(database)
{ {
static SeasonRepository() static SeasonRepository()
{ {
@ -66,16 +68,6 @@ public class SeasonRepository(DatabaseContext database, IThumbnailsManager thumb
.ToListAsync(); .ToListAsync();
} }
/// <inheritdoc/>
public override async Task<Season> Create(Season obj)
{
// Set it for the OnResourceCreated event and the return value.
obj.ShowSlug =
(await Database.Shows.FirstOrDefaultAsync(x => x.Id == obj.ShowId))?.Slug
?? throw new ItemNotFoundException($"No show found with ID {obj.ShowId}");
return await base.Create(obj);
}
/// <inheritdoc/> /// <inheritdoc/>
protected override async Task Validate(Season resource) protected override async Task Validate(Season resource)
{ {
@ -83,6 +75,9 @@ public class SeasonRepository(DatabaseContext database, IThumbnailsManager thumb
resource.Show = null; resource.Show = null;
if (resource.ShowId == Guid.Empty) if (resource.ShowId == Guid.Empty)
throw new ValidationException("Missing show id"); throw new ValidationException("Missing show id");
// This is storred in db so it needs to be set before every create/edit (and before events)
resource.ShowSlug = (await shows.Get(resource.ShowId)).Slug;
resource.NextMetadataRefresh ??= IRefreshable.ComputeNextRefreshDate(resource.StartDate);
await thumbnails.DownloadImages(resource); await thumbnails.DownloadImages(resource);
} }
} }

View File

@ -74,6 +74,7 @@ public class ShowRepository(
resource.StudioId = (await studios.CreateIfNotExists(resource.Studio)).Id; resource.StudioId = (await studios.CreateIfNotExists(resource.Studio)).Id;
resource.Studio = null; resource.Studio = null;
} }
resource.NextMetadataRefresh ??= IRefreshable.ComputeNextRefreshDate(resource.StartAir);
await thumbnails.DownloadImages(resource); await thumbnails.DownloadImages(resource);
} }
} }

View File

@ -47,6 +47,29 @@ public class CollectionApi(
LibraryItemRepository items LibraryItemRepository items
) : CrudThumbsApi<Collection>(collections) ) : CrudThumbsApi<Collection>(collections)
{ {
/// <summary>
/// Refresh
/// </summary>
/// <remarks>
/// Ask a metadata refresh.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
/// <returns>Nothing</returns>
/// <response code="404">No episode with the given ID or slug could be found.</response>
[HttpPost("{identifier:id}/refresh")]
[PartialPermission(Kind.Write)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Refresh(Identifier identifier, [FromServices] IScanner scanner)
{
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await collections.Get(slug)).Id
);
await scanner.SendRefreshRequest(nameof(Collection), id);
return NoContent();
}
/// <summary> /// <summary>
/// Add a movie /// Add a movie
/// </summary> /// </summary>

View File

@ -41,6 +41,29 @@ namespace Kyoo.Core.Api;
public class EpisodeApi(ILibraryManager libraryManager) public class EpisodeApi(ILibraryManager libraryManager)
: TranscoderApi<Episode>(libraryManager.Episodes) : TranscoderApi<Episode>(libraryManager.Episodes)
{ {
/// <summary>
/// Refresh
/// </summary>
/// <remarks>
/// Ask a metadata refresh.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
/// <returns>Nothing</returns>
/// <response code="404">No episode with the given ID or slug could be found.</response>
[HttpPost("{identifier:id}/refresh")]
[PartialPermission(Kind.Write)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Refresh(Identifier identifier, [FromServices] IScanner scanner)
{
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await libraryManager.Episodes.Get(slug)).Id
);
await scanner.SendRefreshRequest(nameof(Episode), id);
return NoContent();
}
/// <summary> /// <summary>
/// Get episode's show /// Get episode's show
/// </summary> /// </summary>

View File

@ -38,10 +38,33 @@ namespace Kyoo.Core.Api;
[Route("movies")] [Route("movies")]
[Route("movie", Order = AlternativeRoute)] [Route("movie", Order = AlternativeRoute)]
[ApiController] [ApiController]
[PartialPermission(nameof(Show))] [PartialPermission(nameof(Movie))]
[ApiDefinition("Shows", Group = ResourcesGroup)] [ApiDefinition("Movie", Group = ResourcesGroup)]
public class MovieApi(ILibraryManager libraryManager) : TranscoderApi<Movie>(libraryManager.Movies) public class MovieApi(ILibraryManager libraryManager) : TranscoderApi<Movie>(libraryManager.Movies)
{ {
/// <summary>
/// Refresh
/// </summary>
/// <remarks>
/// Ask a metadata refresh.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param>
/// <returns>Nothing</returns>
/// <response code="404">No episode with the given ID or slug could be found.</response>
[HttpPost("{identifier:id}/refresh")]
[PartialPermission(Kind.Write)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Refresh(Identifier identifier, [FromServices] IScanner scanner)
{
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await libraryManager.Movies.Get(slug)).Id
);
await scanner.SendRefreshRequest(nameof(Movie), id);
return NoContent();
}
/// <summary> /// <summary>
/// Get studio that made the show /// Get studio that made the show
/// </summary> /// </summary>

View File

@ -16,6 +16,7 @@
// 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 System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -41,6 +42,29 @@ namespace Kyoo.Core.Api;
public class SeasonApi(ILibraryManager libraryManager) public class SeasonApi(ILibraryManager libraryManager)
: CrudThumbsApi<Season>(libraryManager.Seasons) : CrudThumbsApi<Season>(libraryManager.Seasons)
{ {
/// <summary>
/// Refresh
/// </summary>
/// <remarks>
/// Ask a metadata refresh.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Season"/>.</param>
/// <returns>Nothing</returns>
/// <response code="404">No episode with the given ID or slug could be found.</response>
[HttpPost("{identifier:id}/refresh")]
[PartialPermission(Kind.Write)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Refresh(Identifier identifier, [FromServices] IScanner scanner)
{
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await libraryManager.Seasons.Get(slug)).Id
);
await scanner.SendRefreshRequest(nameof(Season), id);
return NoContent();
}
/// <summary> /// <summary>
/// Get episodes in the season /// Get episodes in the season
/// </summary> /// </summary>

View File

@ -42,6 +42,29 @@ namespace Kyoo.Core.Api;
[ApiDefinition("Shows", Group = ResourcesGroup)] [ApiDefinition("Shows", Group = ResourcesGroup)]
public class ShowApi(ILibraryManager libraryManager) : CrudThumbsApi<Show>(libraryManager.Shows) public class ShowApi(ILibraryManager libraryManager) : CrudThumbsApi<Show>(libraryManager.Shows)
{ {
/// <summary>
/// Refresh
/// </summary>
/// <remarks>
/// Ask a metadata refresh.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
/// <returns>Nothing</returns>
/// <response code="404">No episode with the given ID or slug could be found.</response>
[HttpPost("{identifier:id}/refresh")]
[PartialPermission(Kind.Write)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> Refresh(Identifier identifier, [FromServices] IScanner scanner)
{
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await libraryManager.Shows.Get(slug)).Id
);
await scanner.SendRefreshRequest(nameof(Show), id);
return NoContent();
}
/// <summary> /// <summary>
/// Get seasons of this show /// Get seasons of this show
/// </summary> /// </summary>

View File

@ -16,6 +16,7 @@
// 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 Kyoo.Abstractions.Controllers;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -41,5 +42,6 @@ public static class RabbitMqModule
return factory.CreateConnection(); return factory.CreateConnection();
}); });
builder.Services.AddSingleton<RabbitProducer>(); builder.Services.AddSingleton<RabbitProducer>();
builder.Services.AddSingleton<IScanner, ScannerProducer>();
} }
} }

View File

@ -0,0 +1,75 @@
// 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.Text;
using System.Text.Json;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models;
using Kyoo.Utils;
using RabbitMQ.Client;
namespace Kyoo.RabbitMq;
public class ScannerProducer : IScanner
{
private readonly IModel _channel;
public ScannerProducer(IConnection rabbitConnection)
{
_channel = rabbitConnection.CreateModel();
_channel.QueueDeclare("scanner", exclusive: false, autoDelete: false);
}
private IRepository<T>.ResourceEventHandler _Publish<T>(
string exchange,
string type,
string action
)
where T : IResource, IQuery
{
return (T resource) =>
{
Message<T> message =
new()
{
Action = action,
Type = type,
Value = resource,
};
_channel.BasicPublish(
exchange,
routingKey: message.AsRoutingKey(),
body: message.AsBytes()
);
return Task.CompletedTask;
};
}
public Task SendRefreshRequest(string kind, Guid id)
{
var message = new
{
Action = "refresh",
Kind = kind.ToLowerInvariant(),
Id = id
};
var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message, Utility.JsonOptions));
_channel.BasicPublish("", routingKey: "scanner", body: body);
return Task.CompletedTask;
}
}

View File

@ -82,6 +82,7 @@ export const UserP = ResourceP("user")
...x, ...x,
logo: imageFn(`/user/${x.slug}/logo`), logo: imageFn(`/user/${x.slug}/logo`),
isVerified: x.permissions.length > 0, isVerified: x.permissions.length > 0,
isAdmin: x.permissions?.includes("admin.write"),
})); }));
export type User = z.infer<typeof UserP>; export type User = z.infer<typeof UserP>;

View File

@ -178,7 +178,7 @@ export const UserList = () => {
id={user.id} id={user.id}
username={user.username} username={user.username}
avatar={user.logo} avatar={user.logo}
isAdmin={user.permissions?.includes("admin.write")} isAdmin={user.isAdmin}
isVerified={user.isVerified} isVerified={user.isVerified}
/> />
)} )}

View File

@ -18,20 +18,21 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { IconButton, Menu, tooltip, usePopup } from "@kyoo/primitives";
import { ComponentProps, ReactElement, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import MoreVert from "@material-symbols/svg-400/rounded/more_vert.svg";
import Info from "@material-symbols/svg-400/rounded/info.svg";
import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg";
import Download from "@material-symbols/svg-400/rounded/download.svg";
import { WatchStatusV, queryFn, useAccount } from "@kyoo/models"; import { WatchStatusV, queryFn, useAccount } from "@kyoo/models";
import { HR, IconButton, Menu, tooltip, usePopup } from "@kyoo/primitives";
import Refresh from "@material-symbols/svg-400/rounded/autorenew.svg";
import Download from "@material-symbols/svg-400/rounded/download.svg";
import Info from "@material-symbols/svg-400/rounded/info.svg";
import MoreVert from "@material-symbols/svg-400/rounded/more_vert.svg";
import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { watchListIcon } from "./watchlist-info"; import { ComponentProps } from "react";
import { useDownloader } from "../downloads"; import { useTranslation } from "react-i18next";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useYoshiki } from "yoshiki/native"; import { useYoshiki } from "yoshiki/native";
import { useDownloader } from "../downloads";
import { MediaInfoPopup } from "./media-info"; import { MediaInfoPopup } from "./media-info";
import { watchListIcon } from "./watchlist-info";
export const EpisodesContext = ({ export const EpisodesContext = ({
type = "episode", type = "episode",
@ -63,6 +64,14 @@ export const EpisodesContext = ({
onSettled: async () => await queryClient.invalidateQueries({ queryKey: [type, slug] }), onSettled: async () => await queryClient.invalidateQueries({ queryKey: [type, slug] }),
}); });
const metadataRefreshMutation = useMutation({
mutationFn: () =>
queryFn({
path: [type, slug, "refresh"],
method: "POST",
}),
});
return ( return (
<> <>
<Menu <Menu
@ -114,6 +123,16 @@ export const EpisodesContext = ({
/> />
</> </>
)} )}
{account?.isAdmin === true && (
<>
<HR />
<Menu.Item
label={t("home.refreshMetadata")}
icon={Refresh}
onSelect={() => metadataRefreshMutation.mutate()}
/>
</>
)}
</Menu> </Menu>
</> </>
); );

View File

@ -19,65 +19,165 @@
*/ */
import { import {
Genre,
KyooImage,
Movie, Movie,
QueryIdentifier, QueryIdentifier,
Show, Show,
getDisplayDate,
Genre,
Studio, Studio,
KyooImage, getDisplayDate,
queryFn,
useAccount,
} from "@kyoo/models"; } from "@kyoo/models";
import { WatchStatusV } from "@kyoo/models/src/resources/watch-status";
import { import {
A,
Chip,
Container, Container,
DottedSeparator,
H1, H1,
ImageBackground, H2,
Skeleton, HR,
Poster, Head,
P,
tooltip,
Link,
IconButton, IconButton,
IconFab, IconFab,
Head, ImageBackground,
HR,
H2,
UL,
LI, LI,
A, Link,
Menu,
P,
Poster,
Skeleton,
UL,
capitalize,
tooltip,
ts, ts,
Chip,
DottedSeparator,
usePopup, usePopup,
} from "@kyoo/primitives"; } from "@kyoo/primitives";
import { Fragment } from "react"; import Refresh from "@material-symbols/svg-400/rounded/autorenew.svg";
import { useTranslation } from "react-i18next"; import Download from "@material-symbols/svg-400/rounded/download.svg";
import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg";
import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg"; import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg";
import { ImageStyle, Platform, View } from "react-native";
import {
Theme,
md,
px,
min,
max,
em,
percent,
rem,
vh,
useYoshiki,
Stylable,
} from "yoshiki/native";
import { Fetch } from "../fetch";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import Theaters from "@material-symbols/svg-400/rounded/theaters-fill.svg"; import Theaters from "@material-symbols/svg-400/rounded/theaters-fill.svg";
import { Rating } from "../components/rating"; import { useMutation } from "@tanstack/react-query";
import { displayRuntime } from "./episode"; import { Fragment } from "react";
import { WatchListInfo } from "../components/watchlist-info"; import { useTranslation } from "react-i18next";
import { WatchStatusV } from "@kyoo/models/src/resources/watch-status"; import { ImageStyle, Platform, View } from "react-native";
import { capitalize } from "@kyoo/primitives"; import {
import { ShowWatchStatusCard } from "./show"; Stylable,
import Download from "@material-symbols/svg-400/rounded/download.svg"; Theme,
import { useDownloader } from "../downloads"; em,
max,
md,
min,
percent,
px,
rem,
useYoshiki,
vh,
} from "yoshiki/native";
import { MediaInfoPopup } from "../components/media-info"; import { MediaInfoPopup } from "../components/media-info";
import { Rating } from "../components/rating";
import { WatchListInfo } from "../components/watchlist-info";
import { useDownloader } from "../downloads";
import { Fetch } from "../fetch";
import { displayRuntime } from "./episode";
import { ShowWatchStatusCard } from "./show";
const ButtonList = ({
playHref,
trailerUrl,
watchStatus,
type,
slug,
}: {
type: "movie" | "show" | "collection";
slug?: string;
playHref?: string | null;
trailerUrl?: string | null;
watchStatus?: WatchStatusV | null;
}) => {
const account = useAccount();
const { css, theme } = useYoshiki();
const { t } = useTranslation();
const downloader = useDownloader();
const [setPopup, close] = usePopup();
const metadataRefreshMutation = useMutation({
mutationFn: () =>
queryFn({
path: [type, slug, "refresh"],
method: "POST",
}),
});
return (
<View {...css({ flexDirection: "row", alignItems: "center", justifyContent: "center" })}>
{playHref !== null && (
<IconFab
icon={PlayArrow}
as={Link}
href={playHref}
color={{ xs: theme.user.colors.black, md: theme.colors.black }}
{...css({
bg: theme.user.accent,
fover: { self: { bg: theme.user.accent } },
})}
{...tooltip(t("show.play"))}
/>
)}
{trailerUrl && (
<IconButton
icon={Theaters}
as={Link}
href={trailerUrl}
target="_blank"
color={{ xs: theme.user.contrast, md: theme.colors.white }}
{...tooltip(t("show.trailer"))}
/>
)}
{watchStatus !== undefined && type !== "collection" && slug && (
<WatchListInfo
type={type}
slug={slug}
status={watchStatus}
color={{ xs: theme.user.contrast, md: theme.colors.white }}
/>
)}
{((type === "movie" && slug) || account?.isAdmin === true) && (
<Menu Trigger={IconButton} icon={MoreHoriz} {...tooltip(t("misc.more"))}>
{type === "movie" && slug && (
<>
<Menu.Item
icon={Download}
onSelect={() => downloader(type, slug)}
label={t("home.episodeMore.download")}
/>
<Menu.Item
icon={MovieInfo}
label={t("home.episodeMore.mediainfo")}
onSelect={() =>
setPopup(<MediaInfoPopup mediaType={"movie"} mediaSlug={slug!} close={close} />)
}
/>
</>
)}
{account?.isAdmin === true && (
<>
{type === "movie" && <HR />}
<Menu.Item
label={t("home.refreshMetadata")}
icon={Refresh}
onSelect={() => metadataRefreshMutation.mutate()}
/>
</>
)}
</Menu>
)}
</View>
);
};
export const TitleLine = ({ export const TitleLine = ({
isLoading, isLoading,
@ -111,8 +211,6 @@ export const TitleLine = ({
} & Stylable) => { } & Stylable) => {
const { css, theme } = useYoshiki(); const { css, theme } = useYoshiki();
const { t } = useTranslation(); const { t } = useTranslation();
const downloader = useDownloader();
const [setPopup, close] = usePopup();
return ( return (
<Container <Container
@ -221,60 +319,13 @@ export const TitleLine = ({
justifyContent: "center", justifyContent: "center",
})} })}
> >
<View <ButtonList
{...css({ flexDirection: "row", alignItems: "center", justifyContent: "center" })} type={type}
> slug={slug}
{playHref !== null && ( playHref={playHref}
<IconFab trailerUrl={trailerUrl}
icon={PlayArrow} watchStatus={watchStatus}
as={Link} />
href={playHref}
color={{ xs: theme.user.colors.black, md: theme.colors.black }}
{...css({
bg: theme.user.accent,
fover: { self: { bg: theme.user.accent } },
})}
{...tooltip(t("show.play"))}
/>
)}
{trailerUrl && (
<IconButton
icon={Theaters}
as={Link}
href={trailerUrl}
target="_blank"
color={{ xs: theme.user.contrast, md: theme.colors.white }}
{...tooltip(t("show.trailer"))}
/>
)}
{watchStatus !== undefined && type !== "collection" && slug && (
<WatchListInfo
type={type}
slug={slug}
status={watchStatus}
color={{ xs: theme.user.contrast, md: theme.colors.white }}
/>
)}
{type === "movie" && slug && (
<>
<IconButton
icon={Download}
onPress={() => downloader(type, slug)}
color={{ xs: theme.user.contrast, md: theme.colors.white }}
{...tooltip(t("home.episodeMore.download"))}
/>
<IconButton
icon={MovieInfo}
color={{ xs: theme.user.contrast, md: theme.colors.white }}
onPress={() =>
setPopup(
<MediaInfoPopup mediaType={"movie"} mediaSlug={slug!} close={close} />,
)
}
/>
</>
)}
</View>
<View <View
{...css({ flexDirection: "row", alignItems: "center", justifyContent: "center" })} {...css({ flexDirection: "row", alignItems: "center", justifyContent: "center" })}
> >

View File

@ -6,6 +6,7 @@
"info": "See more", "info": "See more",
"none": "No episodes", "none": "No episodes",
"watchlistLogin": "To keep track of what you watched or plan to watch, you need to login.", "watchlistLogin": "To keep track of what you watched or plan to watch, you need to login.",
"refreshMetadata": "Refresh metadata",
"episodeMore": { "episodeMore": {
"goToShow": "Go to show", "goToShow": "Go to show",
"download": "Download", "download": "Download",

View File

@ -6,6 +6,7 @@
"info": "Voir plus", "info": "Voir plus",
"none": "Aucun episode", "none": "Aucun episode",
"watchlistLogin": "Pour suivre ce que vous avez regardé ou prévoyez de regarder, vous devez vous connecter.", "watchlistLogin": "Pour suivre ce que vous avez regardé ou prévoyez de regarder, vous devez vous connecter.",
"refreshMetadata": "Actualiser les métadonnées",
"episodeMore": { "episodeMore": {
"goToShow": "Aller a la serie", "goToShow": "Aller a la serie",
"download": "Télécharger", "download": "Télécharger",

View File

@ -172,23 +172,36 @@ class Matcher:
kind: Literal["collection", "movie", "episode", "show", "season"], kind: Literal["collection", "movie", "episode", "show", "season"],
kyoo_id: str, kyoo_id: str,
): ):
async def id_season(season: dict, id: dict):
ret = await self._provider.identify_season(
id["dataId"], season["seasonNumber"]
)
ret.show_id = season["showId"]
return ret
async def id_episode(episode: dict, id: dict):
ret = await self._provider.identify_episode(
id["showId"], id["season"], id["episode"], episode["absoluteNumber"]
)
ret.show_id = episode["showId"]
ret.season_id = episode["seasonId"]
ret.path = episode["path"]
return ret
identify_table = { identify_table = {
"collection": lambda _, id: self._provider.identify_collection( "collection": lambda _, id: self._provider.identify_collection(
id["dataId"] id["dataId"]
), ),
"movie": lambda _, id: self._provider.identify_movie(id["dataId"]), "movie": lambda _, id: self._provider.identify_movie(id["dataId"]),
"show": lambda _, id: self._provider.identify_show(id["dataId"]), "show": lambda _, id: self._provider.identify_show(id["dataId"]),
"season": lambda season, id: self._provider.identify_season( "season": id_season,
id["dataId"], season["seasonNumber"] "episode": id_episode,
),
"episode": lambda episode, id: self._provider.identify_episode(
id["showId"], id["season"], id["episode"], episode["absoluteNumber"]
),
} }
current = await self._client.get(kind, kyoo_id) current = await self._client.get(kind, kyoo_id)
if self._provider.name not in current["externalId"]: if self._provider.name not in current["externalId"]:
logger.error( logger.error(
f"Could not refresh metadata of {kind}/{kyoo_id}. Missisg provider id." f"Could not refresh metadata of {kind}/{kyoo_id}. Missing provider id."
) )
return False return False
provider_id = current["externalId"][self._provider.name] provider_id = current["externalId"][self._provider.name]