Fix lots of bugs (#354)

This commit is contained in:
Zoe Roux 2024-03-25 00:43:47 +01:00 committed by GitHub
commit 66fa07f341
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1662 additions and 246 deletions

64
.gitattributes vendored
View File

@ -1,63 +1 @@
############################################################################### *.Designer.cs linguist-generated=true
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain

View File

@ -24,6 +24,9 @@ jobs:
- context: ./scanner - context: ./scanner
label: scanner label: scanner
image: zoriya/kyoo_scanner image: zoriya/kyoo_scanner
- context: ./autosync
label: autosync
image: zoriya/kyoo_autosync
- context: ./transcoder - context: ./transcoder
label: transcoder label: transcoder
image: zoriya/kyoo_transcoder image: zoriya/kyoo_transcoder

View File

@ -8,7 +8,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Autofac" Version="8.0.0" /> <PackageReference Include="Autofac" Version="8.0.0" />
<PackageReference Include="Dapper" Version="2.1.35" /> <PackageReference Include="Dapper" Version="2.1.37" />
<PackageReference Include="EntityFrameworkCore.Projectables" Version="4.1.4-prebeta" /> <PackageReference Include="EntityFrameworkCore.Projectables" Version="4.1.4-prebeta" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />

View File

@ -151,7 +151,7 @@ public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, IN
/// <summary> /// <summary>
/// The release date of this episode. It can be null if unknown. /// The release date of this episode. It can be null if unknown.
/// </summary> /// </summary>
public DateTime? ReleaseDate { get; set; } public DateOnly? ReleaseDate { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public DateTime AddedDate { get; set; } public DateTime AddedDate { get; set; }

View File

@ -20,6 +20,7 @@ using System;
using System.ComponentModel; using System.ComponentModel;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization; using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Kyoo.Abstractions.Models.Attributes; using Kyoo.Abstractions.Models.Attributes;
@ -47,7 +48,7 @@ public interface IThumbnails
public Image? Logo { get; set; } public Image? Logo { get; set; }
} }
[TypeConverter(typeof(ImageConvertor))] [JsonConverter(typeof(ImageConvertor))]
[SqlFirstColumn(nameof(Source))] [SqlFirstColumn(nameof(Source))]
public class Image public class Image
{ {
@ -71,32 +72,32 @@ public class Image
Blurhash = blurhash ?? "000000"; Blurhash = blurhash ?? "000000";
} }
public class ImageConvertor : TypeConverter public class ImageConvertor : JsonConverter<Image>
{ {
/// <inheritdoc /> /// <inheritdoc />
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) public override Image? Read(
{ ref Utf8JsonReader reader,
if (sourceType == typeof(string)) Type typeToConvert,
return true; JsonSerializerOptions options
return base.CanConvertFrom(context, sourceType);
}
/// <inheritdoc />
public override object ConvertFrom(
ITypeDescriptorContext? context,
CultureInfo? culture,
object value
) )
{ {
if (value is not string source) if (reader.TokenType == JsonTokenType.String && reader.GetString() is string source)
return base.ConvertFrom(context, culture, value)!; return new Image(source);
return new Image(source); using JsonDocument document = JsonDocument.ParseValue(ref reader);
return document.RootElement.Deserialize<Image>();
} }
/// <inheritdoc /> /// <inheritdoc />
public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) public override void Write(
Utf8JsonWriter writer,
Image value,
JsonSerializerOptions options
)
{ {
return false; writer.WriteStartObject();
writer.WriteString("source", value.Source);
writer.WriteString("blurhash", value.Blurhash);
writer.WriteEndObject();
} }
} }
} }

View File

@ -104,7 +104,7 @@ public class Movie
/// <summary> /// <summary>
/// The date this movie aired. /// The date this movie aired.
/// </summary> /// </summary>
public DateTime? AirDate { get; set; } public DateOnly? AirDate { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public DateTime AddedDate { get; set; } public DateTime AddedDate { get; set; }
@ -120,11 +120,11 @@ public class Movie
[JsonIgnore] [JsonIgnore]
[Column("air_date")] [Column("air_date")]
public DateTime? StartAir => AirDate; public DateOnly? StartAir => AirDate;
[JsonIgnore] [JsonIgnore]
[Column("air_date")] [Column("air_date")]
public DateTime? EndAir => AirDate; public DateOnly? EndAir => AirDate;
/// <summary> /// <summary>
/// A video of a few minutes that tease the content. /// A video of a few minutes that tease the content.

View File

@ -97,7 +97,7 @@ public class Season : IQuery, IResource, IMetadata, IThumbnails, IAddedDate
/// <summary> /// <summary>
/// The starting air date of this season. /// The starting air date of this season.
/// </summary> /// </summary>
public DateTime? StartDate { get; set; } public DateOnly? StartDate { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public DateTime AddedDate { get; set; } public DateTime AddedDate { get; set; }
@ -105,7 +105,7 @@ public class Season : IQuery, IResource, IMetadata, IThumbnails, IAddedDate
/// <summary> /// <summary>
/// The ending date of this season. /// The ending date of this season.
/// </summary> /// </summary>
public DateTime? EndDate { get; set; } public DateOnly? EndDate { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public Image? Poster { get; set; } public Image? Poster { get; set; }

View File

@ -94,13 +94,13 @@ public class Show
/// <summary> /// <summary>
/// The date this show started airing. It can be null if this is unknown. /// The date this show started airing. It can be null if this is unknown.
/// </summary> /// </summary>
public DateTime? StartAir { get; set; } public DateOnly? StartAir { get; set; }
/// <summary> /// <summary>
/// The date this show finished airing. /// The date this show finished airing.
/// It can also be null if this is unknown. /// It can also be null if this is unknown.
/// </summary> /// </summary>
public DateTime? EndAir { get; set; } public DateOnly? EndAir { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public DateTime AddedDate { get; set; } public DateTime AddedDate { get; set; }
@ -121,7 +121,7 @@ public class Show
[JsonIgnore] [JsonIgnore]
[Column("start_air")] [Column("start_air")]
public DateTime? AirDate => StartAir; public DateOnly? AirDate => StartAir;
/// <inheritdoc /> /// <inheritdoc />
public Dictionary<string, MetadataId> ExternalId { get; set; } = new(); public Dictionary<string, MetadataId> ExternalId { get; set; } = new();

View File

@ -8,7 +8,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCore.Proxy" Version="4.5.0" /> <PackageReference Include="AspNetCore.Proxy" Version="4.5.0" />
<PackageReference Include="Blurhash.SkiaSharp" Version="2.0.0" /> <PackageReference Include="Blurhash.SkiaSharp" Version="2.0.0" />
<PackageReference Include="Dapper" Version="2.1.35" /> <PackageReference Include="Dapper" Version="2.1.37" />
<PackageReference Include="InterpolatedSql.Dapper" Version="2.3.0" /> <PackageReference Include="InterpolatedSql.Dapper" Version="2.3.0" />
<PackageReference Include="FlexLabs.EntityFrameworkCore.Upsert" Version="8.0.0" /> <PackageReference Include="FlexLabs.EntityFrameworkCore.Upsert" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" /> <PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />

View File

@ -6,7 +6,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35" /> <PackageReference Include="Dapper" Version="2.1.37" />
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3" /> <PackageReference Include="EFCore.NamingConventions" Version="8.0.3" />
<PackageReference Include="EntityFrameworkCore.Projectables" Version="4.1.4-prebeta" /> <PackageReference Include="EntityFrameworkCore.Projectables" Version="4.1.4-prebeta" />
<PackageReference Include="InterpolatedSql.Dapper" Version="2.3.0" /> <PackageReference Include="InterpolatedSql.Dapper" Version="2.3.0" />

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,181 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Kyoo.Postgresql.Migrations
{
/// <inheritdoc />
public partial class UseDateOnly : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder
.AlterDatabase()
.Annotation(
"Npgsql:Enum:genre",
"action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western"
)
.Annotation("Npgsql:Enum:status", "unknown,finished,airing,planned")
.Annotation("Npgsql:Enum:watch_status", "completed,watching,droped,planned,deleted")
.OldAnnotation(
"Npgsql:Enum:genre",
"action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western"
)
.OldAnnotation("Npgsql:Enum:status", "unknown,finished,airing,planned")
.OldAnnotation("Npgsql:Enum:watch_status", "completed,watching,droped,planned");
migrationBuilder.AlterColumn<DateOnly>(
name: "start_air",
table: "shows",
type: "date",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true
);
migrationBuilder.AlterColumn<DateOnly>(
name: "end_air",
table: "shows",
type: "date",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true
);
migrationBuilder.AlterColumn<DateOnly>(
name: "start_date",
table: "seasons",
type: "date",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true
);
migrationBuilder.AlterColumn<DateOnly>(
name: "end_date",
table: "seasons",
type: "date",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true
);
migrationBuilder.AlterColumn<DateOnly>(
name: "air_date",
table: "movies",
type: "date",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true
);
migrationBuilder.AlterColumn<DateOnly>(
name: "release_date",
table: "episodes",
type: "date",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "timestamp with time zone",
oldNullable: true
);
migrationBuilder.CreateIndex(
name: "ix_users_username",
table: "users",
column: "username",
unique: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(name: "ix_users_username", table: "users");
migrationBuilder
.AlterDatabase()
.Annotation(
"Npgsql:Enum:genre",
"action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western"
)
.Annotation("Npgsql:Enum:status", "unknown,finished,airing,planned")
.Annotation("Npgsql:Enum:watch_status", "completed,watching,droped,planned")
.OldAnnotation(
"Npgsql:Enum:genre",
"action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western"
)
.OldAnnotation("Npgsql:Enum:status", "unknown,finished,airing,planned")
.OldAnnotation(
"Npgsql:Enum:watch_status",
"completed,watching,droped,planned,deleted"
);
migrationBuilder.AlterColumn<DateTime>(
name: "start_air",
table: "shows",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateOnly),
oldType: "date",
oldNullable: true
);
migrationBuilder.AlterColumn<DateTime>(
name: "end_air",
table: "shows",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateOnly),
oldType: "date",
oldNullable: true
);
migrationBuilder.AlterColumn<DateTime>(
name: "start_date",
table: "seasons",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateOnly),
oldType: "date",
oldNullable: true
);
migrationBuilder.AlterColumn<DateTime>(
name: "end_date",
table: "seasons",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateOnly),
oldType: "date",
oldNullable: true
);
migrationBuilder.AlterColumn<DateTime>(
name: "air_date",
table: "movies",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateOnly),
oldType: "date",
oldNullable: true
);
migrationBuilder.AlterColumn<DateTime>(
name: "release_date",
table: "episodes",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateOnly),
oldType: "date",
oldNullable: true
);
}
}
}

View File

@ -19,12 +19,12 @@ namespace Kyoo.Postgresql.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "7.0.12") .HasAnnotation("ProductVersion", "8.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "action", "adventure", "animation", "comedy", "crime", "documentary", "drama", "family", "fantasy", "history", "horror", "music", "mystery", "romance", "science_fiction", "thriller", "war", "western" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "status", new[] { "unknown", "finished", "airing", "planned" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "status", new[] { "unknown", "finished", "airing", "planned" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "watch_status", new[] { "completed", "watching", "droped", "planned" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "watch_status", new[] { "completed", "watching", "droped", "planned", "deleted" });
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b => modelBuilder.Entity("Kyoo.Abstractions.Models.Collection", b =>
@ -109,8 +109,8 @@ namespace Kyoo.Postgresql.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("path"); .HasColumnName("path");
b.Property<DateTime?>("ReleaseDate") b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("timestamp with time zone") .HasColumnType("date")
.HasColumnName("release_date"); .HasColumnName("release_date");
b.Property<int?>("Runtime") b.Property<int?>("Runtime")
@ -238,8 +238,8 @@ namespace Kyoo.Postgresql.Migrations
.HasColumnName("added_date") .HasColumnName("added_date")
.HasDefaultValueSql("now() at time zone 'utc'"); .HasDefaultValueSql("now() at time zone 'utc'");
b.Property<DateTime?>("AirDate") b.Property<DateOnly?>("AirDate")
.HasColumnType("timestamp with time zone") .HasColumnType("date")
.HasColumnName("air_date"); .HasColumnName("air_date");
b.Property<string[]>("Aliases") b.Property<string[]>("Aliases")
@ -373,8 +373,8 @@ namespace Kyoo.Postgresql.Migrations
.HasColumnName("added_date") .HasColumnName("added_date")
.HasDefaultValueSql("now() at time zone 'utc'"); .HasDefaultValueSql("now() at time zone 'utc'");
b.Property<DateTime?>("EndDate") b.Property<DateOnly?>("EndDate")
.HasColumnType("timestamp with time zone") .HasColumnType("date")
.HasColumnName("end_date"); .HasColumnName("end_date");
b.Property<string>("ExternalId") b.Property<string>("ExternalId")
@ -404,8 +404,8 @@ namespace Kyoo.Postgresql.Migrations
.HasColumnType("character varying(256)") .HasColumnType("character varying(256)")
.HasColumnName("slug"); .HasColumnName("slug");
b.Property<DateTime?>("StartDate") b.Property<DateOnly?>("StartDate")
.HasColumnType("timestamp with time zone") .HasColumnType("date")
.HasColumnName("start_date"); .HasColumnName("start_date");
b.HasKey("Id") b.HasKey("Id")
@ -440,8 +440,8 @@ namespace Kyoo.Postgresql.Migrations
.HasColumnType("text[]") .HasColumnType("text[]")
.HasColumnName("aliases"); .HasColumnName("aliases");
b.Property<DateTime?>("EndAir") b.Property<DateOnly?>("EndAir")
.HasColumnType("timestamp with time zone") .HasColumnType("date")
.HasColumnName("end_air"); .HasColumnName("end_air");
b.Property<string>("ExternalId") b.Property<string>("ExternalId")
@ -473,8 +473,8 @@ namespace Kyoo.Postgresql.Migrations
.HasColumnType("character varying(256)") .HasColumnType("character varying(256)")
.HasColumnName("slug"); .HasColumnName("slug");
b.Property<DateTime?>("StartAir") b.Property<DateOnly?>("StartAir")
.HasColumnType("timestamp with time zone") .HasColumnType("date")
.HasColumnName("start_air"); .HasColumnName("start_air");
b.Property<Status>("Status") b.Property<Status>("Status")
@ -651,6 +651,10 @@ namespace Kyoo.Postgresql.Migrations
.IsUnique() .IsUnique()
.HasDatabaseName("ix_users_slug"); .HasDatabaseName("ix_users_slug");
b.HasIndex("Username")
.IsUnique()
.HasDatabaseName("ix_users_username");
b.ToTable("users", (string)null); b.ToTable("users", (string)null);
}); });

View File

@ -55,7 +55,7 @@ import arrayShuffle from "array-shuffle";
import { Tooltip } from "react-tooltip"; import { Tooltip } from "react-tooltip";
import { getCurrentAccount, readCookie, updateAccount } from "@kyoo/models/src/account-internal"; import { getCurrentAccount, readCookie, updateAccount } from "@kyoo/models/src/account-internal";
import { PortalProvider } from "@gorhom/portal"; import { PortalProvider } from "@gorhom/portal";
import { ConnectionError, ErrorContext } from "@kyoo/ui"; import { ConnectionError } from "@kyoo/ui";
const font = Poppins({ weight: ["300", "400", "900"], subsets: ["latin"], display: "swap" }); const font = Poppins({ weight: ["300", "400", "900"], subsets: ["latin"], display: "swap" });
@ -136,17 +136,7 @@ const WithLayout = ({ Component, ...props }: { Component: ComponentType }) => {
const layoutInfo = (Component as QueryPage).getLayout ?? (({ page }) => page); const layoutInfo = (Component as QueryPage).getLayout ?? (({ page }) => page);
const { Layout, props: layoutProps } = const { Layout, props: layoutProps } =
typeof layoutInfo === "function" ? { Layout: layoutInfo, props: {} } : layoutInfo; typeof layoutInfo === "function" ? { Layout: layoutInfo, props: {} } : layoutInfo;
return ( return <Layout page={<Component {...props} />} randomItems={[]} {...layoutProps} />;
<Layout
page={
<ErrorContext>
<Component {...props} />
</ErrorContext>
}
randomItems={[]}
{...layoutProps}
/>
);
}; };
const App = ({ Component, pageProps }: AppProps) => { const App = ({ Component, pageProps }: AppProps) => {

View File

@ -18,7 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { ReactNode, createContext, useContext, useEffect, useMemo, useRef } from "react"; import { ReactNode, createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import { ServerInfoP, User, UserP } from "./resources"; import { ServerInfoP, User, UserP } from "./resources";
import { z } from "zod"; import { z } from "zod";
import { zdate } from "./utils"; import { zdate } from "./utils";
@ -69,7 +69,8 @@ export const ConnectionErrorContext = createContext<{
error: KyooErrors | null; error: KyooErrors | null;
loading: boolean; loading: boolean;
retry?: () => void; retry?: () => void;
}>({ error: null, loading: true }); setError: (error: KyooErrors) => void;
}>({ error: null, loading: true, setError: () => {} });
/* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable react-hooks/rules-of-hooks */
export const AccountProvider = ({ export const AccountProvider = ({
@ -96,6 +97,7 @@ export const AccountProvider = ({
retry: () => { retry: () => {
queryClient.resetQueries({ queryKey: ["auth", "me"] }); queryClient.resetQueries({ queryKey: ["auth", "me"] });
}, },
setError: () => {},
}} }}
> >
{children} {children}
@ -156,15 +158,18 @@ export const AccountProvider = ({
} }
}, [selected, queryClient]); }, [selected, queryClient]);
const [permissionError, setPermissionError] = useState<KyooErrors | null>(null);
return ( return (
<AccountContext.Provider value={accounts}> <AccountContext.Provider value={accounts}>
<ConnectionErrorContext.Provider <ConnectionErrorContext.Provider
value={{ value={{
error: selected ? initialSsrError.current ?? user.error : null, error: selected ? initialSsrError.current ?? user.error ?? permissionError : null,
loading: user.isLoading, loading: user.isLoading,
retry: () => { retry: () => {
queryClient.invalidateQueries({ queryKey: ["auth", "me"] }); queryClient.invalidateQueries({ queryKey: ["auth", "me"] });
}, },
setError: setPermissionError,
}} }}
> >
{children} {children}

View File

@ -49,7 +49,7 @@ export const A = ({
replace replace
? { ? {
nativeBehavior: "stack-replace", nativeBehavior: "stack-replace",
isNestedNavigator: false, isNestedNavigator: true,
} }
: undefined : undefined
} }
@ -105,7 +105,7 @@ export const Link = ({
const linkProps = useLink({ const linkProps = useLink({
href: href ?? "#", href: href ?? "#",
replace, replace,
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, experimental: { nativeBehavior: "stack-replace", isNestedNavigator: true },
}); });
// @ts-ignore Missing hrefAttrs type definition. // @ts-ignore Missing hrefAttrs type definition.
linkProps.hrefAttrs = { ...linkProps.hrefAttrs, target }; linkProps.hrefAttrs = { ...linkProps.hrefAttrs, target };

View File

@ -335,7 +335,7 @@ export const EpisodeLine = ({
{isLoading || <P numberOfLines={descriptionExpanded ? undefined : 3}>{overview}</P>} {isLoading || <P numberOfLines={descriptionExpanded ? undefined : 3}>{overview}</P>}
</Skeleton> </Skeleton>
<IconButton <IconButton
{...css(["more"])} {...css(["more", Platform.OS !== "web" && { opacity: 1 }])}
icon={descriptionExpanded ? ExpandLess : ExpandMore} icon={descriptionExpanded ? ExpandLess : ExpandMore}
{...tooltip(t(descriptionExpanded ? "misc.collapse" : "misc.expand"))} {...tooltip(t(descriptionExpanded ? "misc.collapse" : "misc.expand"))}
onPress={(e) => { onPress={(e) => {

View File

@ -18,21 +18,49 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { ConnectionErrorContext } from "@kyoo/models"; import { ConnectionErrorContext, useAccount } from "@kyoo/models";
import { Button, H1, P, ts } from "@kyoo/primitives"; import { Button, H1, Icon, Link, P, ts } from "@kyoo/primitives";
import { useRouter } from "solito/router"; import { useRouter } from "solito/router";
import { useContext } from "react"; import { useContext } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { useYoshiki } from "yoshiki/native"; import { useYoshiki } from "yoshiki/native";
import { DefaultLayout } from "../layout"; import { DefaultLayout } from "../layout";
import { ErrorView } from "./error";
import Register from "@material-symbols/svg-400/rounded/app_registration.svg";
export const ConnectionError = () => { export const ConnectionError = () => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const { error, retry } = useContext(ConnectionErrorContext); const { error, retry } = useContext(ConnectionErrorContext);
const account = useAccount();
if (error && (error.status === 401 || error.status == 403)) {
if (!account) {
return (
<View
{...css({ flexGrow: 1, flexShrink: 1, justifyContent: "center", alignItems: "center" })}
>
<P>{t("errors.needAccount")}</P>
<Button
as={Link}
href={"/register"}
text={t("login.register")}
licon={<Icon icon={Register} {...css({ marginRight: ts(2) })} />}
/>
</View>
);
}
if (account.isVerified) return <ErrorView error={error} noBubble />;
return (
<View
{...css({ flexGrow: 1, flexShrink: 1, justifyContent: "center", alignItems: "center" })}
>
<P>{t("errors.needVerification")}</P>
</View>
);
}
return ( return (
<View {...css({ padding: ts(2) })}> <View {...css({ padding: ts(2) })}>
<H1 {...css({ textAlign: "center" })}>{t("errors.connection")}</H1> <H1 {...css({ textAlign: "center" })}>{t("errors.connection")}</H1>

View File

@ -18,19 +18,11 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { KyooErrors, useAccount } from "@kyoo/models"; import { ConnectionErrorContext, KyooErrors } from "@kyoo/models";
import { P } from "@kyoo/primitives"; import { P } from "@kyoo/primitives";
import { import { useContext, useLayoutEffect } from "react";
ReactElement,
createContext,
useContext,
useEffect,
useLayoutEffect,
useState,
} from "react";
import { View } from "react-native"; import { View } from "react-native";
import { useYoshiki } from "yoshiki/native"; import { useYoshiki } from "yoshiki/native";
import { PermissionError } from "./unauthorized";
export const ErrorView = ({ export const ErrorView = ({
error, error,
@ -40,7 +32,7 @@ export const ErrorView = ({
noBubble?: boolean; noBubble?: boolean;
}) => { }) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const setError = useErrorContext(); const { setError } = useContext(ConnectionErrorContext);
useLayoutEffect(() => { useLayoutEffect(() => {
// if this is a permission error, make it go up the tree to have a whole page login screen. // if this is a permission error, make it go up the tree to have a whole page login screen.
@ -65,22 +57,3 @@ export const ErrorView = ({
</View> </View>
); );
}; };
const ErrorCtx = createContext<(val: KyooErrors | null) => void>(null!);
export const ErrorContext = ({ children }: { children: ReactElement }) => {
const [error, setError] = useState<KyooErrors | null>(null);
const account = useAccount();
useEffect(() => {
setError(null);
}, [account, children]);
if (error && (error.status === 401 || error.status === 403))
return <PermissionError error={error} />;
if (error) return <ErrorView error={error} noBubble />;
return <ErrorCtx.Provider value={setError}>{children}</ErrorCtx.Provider>;
};
export const useErrorContext = () => {
return useContext(ErrorCtx);
};

View File

@ -18,13 +18,10 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>. * along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { KyooErrors, useAccount } from "@kyoo/models"; import { P } from "@kyoo/primitives";
import { Button, Icon, Link, P, ts } from "@kyoo/primitives";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { rem, useYoshiki } from "yoshiki/native"; import { useYoshiki } from "yoshiki/native";
import { ErrorView } from "./error";
import Register from "@material-symbols/svg-400/rounded/app_registration.svg";
export const Unauthorized = ({ missing }: { missing: string[] }) => { export const Unauthorized = ({ missing }: { missing: string[] }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -43,31 +40,3 @@ export const Unauthorized = ({ missing }: { missing: string[] }) => {
</View> </View>
); );
}; };
export const PermissionError = ({ error }: { error: KyooErrors }) => {
const { t } = useTranslation();
const { css } = useYoshiki();
const account = useAccount();
if (!account) {
return (
<View
{...css({ flexGrow: 1, flexShrink: 1, justifyContent: "center", alignItems: "center" })}
>
<P>{t("errors.needAccount")}</P>
<Button
as={Link}
href={"/register"}
text={t("login.register")}
licon={<Icon icon={Register} {...css({ marginRight: ts(2) })} />}
/>
</View>
);
}
if (account.isVerified) return <ErrorView error={error} noBubble />;
return (
<View {...css({ flexGrow: 1, flexShrink: 1, justifyContent: "center", alignItems: "center" })}>
<P>{t("errors.needVerification")}</P>
</View>
);
};

View File

@ -46,7 +46,7 @@ export const LoginPage: QueryPage<{ apiUrl?: string; error?: string }> = ({
useEffect(() => { useEffect(() => {
if (!apiUrl && Platform.OS !== "web") if (!apiUrl && Platform.OS !== "web")
router.replace("/server-url", undefined, { router.replace("/server-url", undefined, {
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, experimental: { nativeBehavior: "stack-replace", isNestedNavigator: true },
}); });
}, [apiUrl, router]); }, [apiUrl, router]);
@ -74,7 +74,7 @@ export const LoginPage: QueryPage<{ apiUrl?: string; error?: string }> = ({
setError(error); setError(error);
if (error) return; if (error) return;
router.replace("/", undefined, { router.replace("/", undefined, {
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, experimental: { nativeBehavior: "stack-replace", isNestedNavigator: true },
}); });
}} }}
{...css({ {...css({

View File

@ -106,7 +106,7 @@ export const OidcCallbackPage: QueryPage<{
function onError(error: string) { function onError(error: string) {
router.replace({ pathname: "/login", query: { error, apiUrl } }, undefined, { router.replace({ pathname: "/login", query: { error, apiUrl } }, undefined, {
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, experimental: { nativeBehavior: "stack-replace", isNestedNavigator: true },
}); });
} }
async function run() { async function run() {
@ -114,7 +114,7 @@ export const OidcCallbackPage: QueryPage<{
if (loginError) onError(loginError); if (loginError) onError(loginError);
else { else {
router.replace("/", undefined, { router.replace("/", undefined, {
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, experimental: { nativeBehavior: "stack-replace", isNestedNavigator: true },
}); });
} }
} }

View File

@ -45,7 +45,7 @@ export const RegisterPage: QueryPage<{ apiUrl?: string }> = ({ apiUrl }) => {
useEffect(() => { useEffect(() => {
if (!apiUrl && Platform.OS !== "web") if (!apiUrl && Platform.OS !== "web")
router.replace("/server-url", undefined, { router.replace("/server-url", undefined, {
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, experimental: { nativeBehavior: "stack-replace", isNestedNavigator: true },
}); });
}, [apiUrl, router]); }, [apiUrl, router]);
@ -85,7 +85,7 @@ export const RegisterPage: QueryPage<{ apiUrl?: string }> = ({ apiUrl }) => {
setError(error); setError(error);
if (error) return; if (error) return;
router.replace("/", undefined, { router.replace("/", undefined, {
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, experimental: { nativeBehavior: "stack-replace", isNestedNavigator: true },
}); });
}} }}
{...css({ {...css({

View File

@ -157,11 +157,11 @@ export const Player = ({
if (!data) return; if (!data) return;
if (data.type === "movie") if (data.type === "movie")
router.replace(`/movie/${data.slug}`, undefined, { router.replace(`/movie/${data.slug}`, undefined, {
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, experimental: { nativeBehavior: "stack-replace", isNestedNavigator: true },
}); });
else else
router.replace(next ?? `/show/${data.show!.slug}`, undefined, { router.replace(next ?? `/show/${data.show!.slug}`, undefined, {
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, experimental: { nativeBehavior: "stack-replace", isNestedNavigator: true },
}); });
}} }}
{...css(StyleSheet.absoluteFillObject)} {...css(StyleSheet.absoluteFillObject)}

View File

@ -220,7 +220,7 @@ export const Video = memo(function Video({
onLoad={(info) => { onLoad={(info) => {
setDuration(info.duration); setDuration(info.duration);
}} }}
onPlayPause={setPlay} onPlaybackStateChanged={(state) => setPlay(state.isPlaying)}
fonts={fonts} fonts={fonts}
subtitles={subtitles} subtitles={subtitles}
onMediaUnsupported={() => { onMediaUnsupported={() => {

View File

@ -24,7 +24,6 @@ declare module "react-native-video" {
interface ReactVideoProps { interface ReactVideoProps {
fonts?: string[]; fonts?: string[];
subtitles?: Subtitle[]; subtitles?: Subtitle[];
onPlayPause: (isPlaying: boolean) => void;
onMediaUnsupported?: () => void; onMediaUnsupported?: () => void;
} }
export type VideoProps = Omit<ReactVideoProps, "source"> & { export type VideoProps = Omit<ReactVideoProps, "source"> & {
@ -103,13 +102,18 @@ const Video = forwardRef<VideoRef, VideoProps>(function Video(
onLoad?.(info); onLoad?.(info);
}} }}
onBuffer={onBuffer} onBuffer={onBuffer}
onError={onMediaUnsupported} onError={(error) => {
console.error(error);
if (mode === PlayMode.Direct) onMediaUnsupported?.();
else onError?.(error);
}}
selectedVideoTrack={ selectedVideoTrack={
video === -1 video === -1
? { type: SelectedVideoTrackType.AUDO } ? { type: SelectedVideoTrackType.AUDO }
: { type: SelectedVideoTrackType.RESOLUTION, value: video } : { type: SelectedVideoTrackType.RESOLUTION, value: video }
} }
selectedAudioTrack={{ type: SelectedTrackType.INDEX, value: audio.index }} // when video file is invalid, audio is undefined
selectedAudioTrack={{ type: SelectedTrackType.INDEX, value: audio?.index ?? 0 }}
textTracks={subtitles?.map((x) => ({ textTracks={subtitles?.map((x) => ({
type: MimeTypes.get(x.codec) as any, type: MimeTypes.get(x.codec) as any,
uri: x.link!, uri: x.link!,

View File

@ -115,7 +115,7 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function
onProgress, onProgress,
onError, onError,
onEnd, onEnd,
onPlayPause, onPlaybackStateChanged,
onMediaUnsupported, onMediaUnsupported,
fonts, fonts,
}, },
@ -182,7 +182,7 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function
if (!hls) return; if (!hls) return;
const update = () => { const update = () => {
if (!hls) return; if (!hls) return;
hls.audioTrack = audio.index; hls.audioTrack = audio?.index ?? 0;
}; };
update(); update();
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, update); hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, update);
@ -234,9 +234,8 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function
onLoadedMetadata={() => { onLoadedMetadata={() => {
if (source.startPosition) setProgress(source.startPosition / 1000); if (source.startPosition) setProgress(source.startPosition / 1000);
}} }}
// BUG: If this is enabled, switching to fullscreen or opening a menu make a play/pause loop until firefox crash. onPlay={() => onPlaybackStateChanged?.({ isPlaying: true })}
// onPlay={() => onPlayPause?.call(null, true)} onPause={() => onPlaybackStateChanged?.({ isPlaying: false })}
// onPause={() => onPlayPause?.call(null, false)}
onEnded={onEnd} onEnded={onEnd}
{...css({ width: "100%", height: "100%", objectFit: "contain" })} {...css({ width: "100%", height: "100%", objectFit: "contain" })}
/> />

View File

@ -9,13 +9,12 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync"
"github.com/zoriya/go-mediainfo" "github.com/zoriya/go-mediainfo"
) )
type MediaInfo struct { type MediaInfo struct {
// closed if the mediainfo is ready for read. open otherwise
ready <-chan struct{}
// The sha1 of the video file. // The sha1 of the video file.
Sha string `json:"sha"` Sha string `json:"sha"`
/// The internal path of the video file. /// The internal path of the video file.
@ -177,37 +176,38 @@ var SubtitleExtensions = map[string]string{
"vtt": "vtt", "vtt": "vtt",
} }
var infos = NewCMap[string, *MediaInfo]() type MICache struct {
info *MediaInfo
ready sync.WaitGroup
}
var infos = NewCMap[string, *MICache]()
func GetInfo(path string, sha string, route string) (*MediaInfo, error) { func GetInfo(path string, sha string, route string) (*MediaInfo, error) {
var err error var err error
ret, _ := infos.GetOrCreate(sha, func() *MediaInfo { ret, _ := infos.GetOrCreate(sha, func() *MICache {
readyChan := make(chan struct{}) mi := &MICache{info: &MediaInfo{Sha: sha}}
mi := &MediaInfo{ mi.ready.Add(1)
Sha: sha,
ready: readyChan,
}
go func() { go func() {
save_path := fmt.Sprintf("%s/%s/info.json", Settings.Metadata, sha) save_path := fmt.Sprintf("%s/%s/info.json", Settings.Metadata, sha)
if err := getSavedInfo(save_path, mi); err == nil { if err := getSavedInfo(save_path, mi.info); err == nil {
log.Printf("Using mediainfo cache on filesystem for %s", path) log.Printf("Using mediainfo cache on filesystem for %s", path)
close(readyChan) mi.ready.Done()
return return
} }
var val *MediaInfo var val *MediaInfo
val, err = getInfo(path, route) val, err = getInfo(path, route)
*mi = *val *mi.info = *val
mi.ready = readyChan mi.info.Sha = sha
mi.Sha = sha mi.ready.Done()
close(readyChan) saveInfo(save_path, mi.info)
saveInfo(save_path, mi)
}() }()
return mi return mi
}) })
<-ret.ready ret.ready.Wait()
return ret, err return ret.info, err
} }
func getSavedInfo[T any](save_path string, mi *T) error { func getSavedInfo[T any](save_path string, mi *T) error {

View File

@ -15,14 +15,17 @@ type Keyframe struct {
Keyframes []float64 Keyframes []float64
CanTransmux bool CanTransmux bool
IsDone bool IsDone bool
mutex sync.RWMutex info *KeyframeInfo
ready sync.WaitGroup }
listeners []func(keyframes []float64) type KeyframeInfo struct {
mutex sync.RWMutex
ready sync.WaitGroup
listeners []func(keyframes []float64)
} }
func (kf *Keyframe) Get(idx int32) float64 { func (kf *Keyframe) Get(idx int32) float64 {
kf.mutex.RLock() kf.info.mutex.RLock()
defer kf.mutex.RUnlock() defer kf.info.mutex.RUnlock()
return kf.Keyframes[idx] return kf.Keyframes[idx]
} }
@ -30,8 +33,8 @@ func (kf *Keyframe) Slice(start int32, end int32) []float64 {
if end <= start { if end <= start {
return []float64{} return []float64{}
} }
kf.mutex.RLock() kf.info.mutex.RLock()
defer kf.mutex.RUnlock() defer kf.info.mutex.RUnlock()
ref := kf.Keyframes[start:end] ref := kf.Keyframes[start:end]
ret := make([]float64, end-start) ret := make([]float64, end-start)
copy(ret, ref) copy(ret, ref)
@ -39,24 +42,24 @@ func (kf *Keyframe) Slice(start int32, end int32) []float64 {
} }
func (kf *Keyframe) Length() (int32, bool) { func (kf *Keyframe) Length() (int32, bool) {
kf.mutex.RLock() kf.info.mutex.RLock()
defer kf.mutex.RUnlock() defer kf.info.mutex.RUnlock()
return int32(len(kf.Keyframes)), kf.IsDone return int32(len(kf.Keyframes)), kf.IsDone
} }
func (kf *Keyframe) add(values []float64) { func (kf *Keyframe) add(values []float64) {
kf.mutex.Lock() kf.info.mutex.Lock()
defer kf.mutex.Unlock() defer kf.info.mutex.Unlock()
kf.Keyframes = append(kf.Keyframes, values...) kf.Keyframes = append(kf.Keyframes, values...)
for _, listener := range kf.listeners { for _, listener := range kf.info.listeners {
listener(kf.Keyframes) listener(kf.Keyframes)
} }
} }
func (kf *Keyframe) AddListener(callback func(keyframes []float64)) { func (kf *Keyframe) AddListener(callback func(keyframes []float64)) {
kf.mutex.Lock() kf.info.mutex.Lock()
defer kf.mutex.Unlock() defer kf.info.mutex.Unlock()
kf.listeners = append(kf.listeners, callback) kf.info.listeners = append(kf.info.listeners, callback)
} }
var keyframes = NewCMap[string, *Keyframe]() var keyframes = NewCMap[string, *Keyframe]()
@ -66,13 +69,14 @@ func GetKeyframes(sha string, path string) *Keyframe {
kf := &Keyframe{ kf := &Keyframe{
Sha: sha, Sha: sha,
IsDone: false, IsDone: false,
info: &KeyframeInfo{},
} }
kf.ready.Add(1) kf.info.ready.Add(1)
go func() { go func() {
save_path := fmt.Sprintf("%s/%s/keyframes.json", Settings.Metadata, sha) save_path := fmt.Sprintf("%s/%s/keyframes.json", Settings.Metadata, sha)
if err := getSavedInfo(save_path, kf); err == nil { if err := getSavedInfo(save_path, kf); err == nil {
log.Printf("Using keyframes cache on filesystem for %s", path) log.Printf("Using keyframes cache on filesystem for %s", path)
kf.ready.Done() kf.info.ready.Done()
return return
} }
@ -83,7 +87,7 @@ func GetKeyframes(sha string, path string) *Keyframe {
}() }()
return kf return kf
}) })
ret.ready.Wait() ret.info.ready.Wait()
return ret return ret
} }
@ -154,7 +158,7 @@ func getKeyframes(path string, kf *Keyframe) error {
if len(ret) == max { if len(ret) == max {
kf.add(ret) kf.add(ret)
if done == 0 { if done == 0 {
kf.ready.Done() kf.info.ready.Done()
} else if done >= 500 { } else if done >= 500 {
max = 500 max = 500
} }
@ -165,7 +169,7 @@ func getKeyframes(path string, kf *Keyframe) error {
} }
kf.add(ret) kf.add(ret)
if done == 0 { if done == 0 {
kf.ready.Done() kf.info.ready.Done()
} }
kf.IsDone = true kf.IsDone = true
return nil return nil