mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-06-06 07:04:12 -04:00
Create setup pages when no admin account or video exists (#516)
This commit is contained in:
commit
ef0260f3f5
@ -46,6 +46,11 @@ public class ServerInfo
|
|||||||
/// The list of permissions available for the guest account.
|
/// The list of permissions available for the guest account.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<string> GuestPermissions { get; set; }
|
public List<string> GuestPermissions { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if kyoo's setup is finished.
|
||||||
|
/// </summary>
|
||||||
|
public SetupStep SetupStatus { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class OidcInfo
|
public class OidcInfo
|
||||||
@ -60,3 +65,24 @@ public class OidcInfo
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? LogoUrl { get; set; }
|
public string? LogoUrl { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if kyoo's setup is finished.
|
||||||
|
/// </summary>
|
||||||
|
public enum SetupStep
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// No admin account exists, create an account before exposing kyoo to the internet!
|
||||||
|
/// </summary>
|
||||||
|
MissingAdminAccount,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// No video was registered on kyoo, have you configured the rigth library path?
|
||||||
|
/// </summary>
|
||||||
|
NoVideoFound,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Setup finished!
|
||||||
|
/// </summary>
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
@ -25,6 +25,7 @@ using System.Threading.Tasks;
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using Kyoo.Abstractions.Controllers;
|
using Kyoo.Abstractions.Controllers;
|
||||||
using Kyoo.Abstractions.Models;
|
using Kyoo.Abstractions.Models;
|
||||||
|
using Kyoo.Authentication.Models;
|
||||||
using Kyoo.Postgresql;
|
using Kyoo.Postgresql;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@ -129,6 +130,15 @@ public class MiscRepository(
|
|||||||
.OrderBy(x => x.RefreshDate)
|
.OrderBy(x => x.RefreshDate)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<SetupStep> GetSetupStep()
|
||||||
|
{
|
||||||
|
bool hasUser = await context.Users.AnyAsync();
|
||||||
|
if (!hasUser)
|
||||||
|
return SetupStep.MissingAdminAccount;
|
||||||
|
bool hasItem = await context.Movies.AnyAsync() || await context.Shows.AnyAsync();
|
||||||
|
return hasItem ? SetupStep.Done : SetupStep.NoVideoFound;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class RefreshableItem
|
public class RefreshableItem
|
||||||
|
@ -18,8 +18,10 @@
|
|||||||
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Kyoo.Abstractions.Models.Attributes;
|
using Kyoo.Abstractions.Models.Attributes;
|
||||||
using Kyoo.Authentication.Models;
|
using Kyoo.Authentication.Models;
|
||||||
|
using Kyoo.Core.Controllers;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using static Kyoo.Abstractions.Models.Utils.Constants;
|
using static Kyoo.Abstractions.Models.Utils.Constants;
|
||||||
|
|
||||||
@ -31,9 +33,9 @@ namespace Kyoo.Authentication.Views;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("info")]
|
[Route("info")]
|
||||||
[ApiDefinition("Info", Group = UsersGroup)]
|
[ApiDefinition("Info", Group = UsersGroup)]
|
||||||
public class InfoApi(PermissionOption options) : ControllerBase
|
public class InfoApi(PermissionOption options, MiscRepository info) : ControllerBase
|
||||||
{
|
{
|
||||||
public ActionResult<ServerInfo> GetInfo()
|
public async Task<ActionResult<ServerInfo>> GetInfo()
|
||||||
{
|
{
|
||||||
return Ok(
|
return Ok(
|
||||||
new ServerInfo()
|
new ServerInfo()
|
||||||
@ -48,6 +50,7 @@ public class InfoApi(PermissionOption options) : ControllerBase
|
|||||||
new() { DisplayName = x.Value.DisplayName, LogoUrl = x.Value.LogoUrl, }
|
new() { DisplayName = x.Value.DisplayName, LogoUrl = x.Value.LogoUrl, }
|
||||||
))
|
))
|
||||||
.ToDictionary(x => x.Key, x => x.Value),
|
.ToDictionary(x => x.Key, x => x.Value),
|
||||||
|
SetupStatus = await info.GetSetupStep(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -27,12 +27,15 @@ import {
|
|||||||
ConnectionErrorContext,
|
ConnectionErrorContext,
|
||||||
type QueryIdentifier,
|
type QueryIdentifier,
|
||||||
type QueryPage,
|
type QueryPage,
|
||||||
|
type ServerInfo,
|
||||||
ServerInfoP,
|
ServerInfoP,
|
||||||
|
SetupStep,
|
||||||
UserP,
|
UserP,
|
||||||
createQueryClient,
|
createQueryClient,
|
||||||
fetchQuery,
|
fetchQuery,
|
||||||
getTokenWJ,
|
getTokenWJ,
|
||||||
setSsrApiUrl,
|
setSsrApiUrl,
|
||||||
|
useFetch,
|
||||||
useUserTheme,
|
useUserTheme,
|
||||||
} from "@kyoo/models";
|
} from "@kyoo/models";
|
||||||
import { getCurrentAccount, readCookie, updateAccount } from "@kyoo/models/src/account-internal";
|
import { getCurrentAccount, readCookie, updateAccount } from "@kyoo/models/src/account-internal";
|
||||||
@ -51,7 +54,8 @@ import arrayShuffle from "array-shuffle";
|
|||||||
import NextApp, { type AppContext, type AppProps } from "next/app";
|
import NextApp, { type AppContext, type AppProps } from "next/app";
|
||||||
import { Poppins } from "next/font/google";
|
import { Poppins } from "next/font/google";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { type ComponentType, useContext, useState } from "react";
|
import { type NextRouter, useRouter } from "next/router";
|
||||||
|
import { type ComponentType, useContext, useEffect, useState } from "react";
|
||||||
import { Tooltip } from "react-tooltip";
|
import { Tooltip } from "react-tooltip";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { StyleRegistryProvider, useMobileHover, useStyleRegistry, useTheme } from "yoshiki/web";
|
import { StyleRegistryProvider, useMobileHover, useStyleRegistry, useTheme } from "yoshiki/web";
|
||||||
@ -131,6 +135,28 @@ const ConnectionErrorVerifier = ({
|
|||||||
return <WithLayout Component={ConnectionError} />;
|
return <WithLayout Component={ConnectionError} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SetupChecker = () => {
|
||||||
|
const { data } = useFetch({ path: ["info"], parser: ServerInfoP });
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const step = data?.setupStatus;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!step) return;
|
||||||
|
if (step !== SetupStep.Done && !SetupChecker.isRouteAllowed(router, step))
|
||||||
|
router.push(`/setup?step=${step}`);
|
||||||
|
if (step === SetupStep.Done && router.route === "/setup") router.replace("/");
|
||||||
|
}, [router.route, step, router]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
SetupChecker.isRouteAllowed = (router: NextRouter, step: SetupStep) =>
|
||||||
|
(router.route === "/setup" && router.query.step === step) ||
|
||||||
|
router.route === "/register" ||
|
||||||
|
router.route.startsWith("/login") ||
|
||||||
|
router.route === "/settings";
|
||||||
|
|
||||||
const WithLayout = ({ Component, ...props }: { Component: ComponentType }) => {
|
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 } =
|
||||||
@ -176,6 +202,7 @@ const App = ({ Component, pageProps }: AppProps) => {
|
|||||||
/>
|
/>
|
||||||
</ConnectionErrorVerifier>
|
</ConnectionErrorVerifier>
|
||||||
<Tooltip id="tooltip" positionStrategy={"fixed"} />
|
<Tooltip id="tooltip" positionStrategy={"fixed"} />
|
||||||
|
<SetupChecker />
|
||||||
</SnackbarProvider>
|
</SnackbarProvider>
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
</ThemeSelector>
|
</ThemeSelector>
|
||||||
@ -214,22 +241,36 @@ App.getInitialProps = async (ctx: AppContext) => {
|
|||||||
|
|
||||||
setSsrApiUrl();
|
setSsrApiUrl();
|
||||||
|
|
||||||
|
appProps.pageProps.theme = readCookie(ctx.ctx.req?.headers.cookie, "theme") ?? "auto";
|
||||||
|
|
||||||
const account = readCookie(ctx.ctx.req?.headers.cookie, "account", AccountP);
|
const account = readCookie(ctx.ctx.req?.headers.cookie, "account", AccountP);
|
||||||
if (account) urls.push({ path: ["auth", "me"], parser: UserP });
|
if (account) urls.push({ path: ["auth", "me"], parser: UserP });
|
||||||
const [authToken, token, error] = await getTokenWJ(account);
|
const [authToken, token, error] = await getTokenWJ(account);
|
||||||
if (error) appProps.pageProps.ssrError = error;
|
if (error) appProps.pageProps.ssrError = error;
|
||||||
else {
|
const client = (await fetchQuery(urls, authToken))!;
|
||||||
const client = (await fetchQuery(urls, authToken))!;
|
appProps.pageProps.queryState = dehydrate(client);
|
||||||
appProps.pageProps.queryState = dehydrate(client);
|
if (account) {
|
||||||
if (account) {
|
appProps.pageProps.token = token;
|
||||||
appProps.pageProps.token = token;
|
appProps.pageProps.account = {
|
||||||
appProps.pageProps.account = {
|
...client.getQueryData(["auth", "me"]),
|
||||||
...client.getQueryData(["auth", "me"]),
|
...account,
|
||||||
...account,
|
};
|
||||||
};
|
}
|
||||||
}
|
|
||||||
|
const info = client.getQueryData<ServerInfo>(["info"]);
|
||||||
|
if (
|
||||||
|
info!.setupStatus !== SetupStep.Done &&
|
||||||
|
!SetupChecker.isRouteAllowed(ctx.router, info!.setupStatus)
|
||||||
|
) {
|
||||||
|
ctx.ctx.res!.writeHead(307, { Location: `/setup?step=${info!.setupStatus}` });
|
||||||
|
ctx.ctx.res!.end();
|
||||||
|
return {} as any;
|
||||||
|
}
|
||||||
|
if (info!.setupStatus === SetupStep.Done && ctx.router.route === "/setup") {
|
||||||
|
ctx.ctx.res!.writeHead(307, { Location: "/" });
|
||||||
|
ctx.ctx.res!.end();
|
||||||
|
return {} as any;
|
||||||
}
|
}
|
||||||
appProps.pageProps.theme = readCookie(ctx.ctx.req?.headers.cookie, "theme") ?? "auto";
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("SSR error, disabling it.");
|
console.error("SSR error, disabling it.");
|
||||||
}
|
}
|
||||||
|
24
front/apps/web/src/pages/setup/index.tsx
Normal file
24
front/apps/web/src/pages/setup/index.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SetupPage } from "@kyoo/ui";
|
||||||
|
import { withRoute } from "~/router";
|
||||||
|
|
||||||
|
export default withRoute(SetupPage);
|
@ -33,6 +33,12 @@ export const OidcInfoP = z.object({
|
|||||||
logoUrl: z.string().nullable(),
|
logoUrl: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export enum SetupStep {
|
||||||
|
MissingAdminAccount = "MissingAdminAccount",
|
||||||
|
NoVideoFound = "NoVideoFound",
|
||||||
|
Done = "Done",
|
||||||
|
}
|
||||||
|
|
||||||
export const ServerInfoP = z
|
export const ServerInfoP = z
|
||||||
.object({
|
.object({
|
||||||
/*
|
/*
|
||||||
@ -51,6 +57,10 @@ export const ServerInfoP = z
|
|||||||
* The list of oidc providers configured for this instance of kyoo.
|
* The list of oidc providers configured for this instance of kyoo.
|
||||||
*/
|
*/
|
||||||
oidc: z.record(z.string(), OidcInfoP),
|
oidc: z.record(z.string(), OidcInfoP),
|
||||||
|
/*
|
||||||
|
* Check if kyoo's setup is finished.
|
||||||
|
*/
|
||||||
|
setupStatus: z.nativeEnum(SetupStep),
|
||||||
})
|
})
|
||||||
.transform((x) => {
|
.transform((x) => {
|
||||||
const baseUrl = Platform.OS === "web" ? x.publicUrl : "kyoo://";
|
const baseUrl = Platform.OS === "web" ? x.publicUrl : "kyoo://";
|
||||||
|
@ -21,3 +21,4 @@
|
|||||||
export * from "./error";
|
export * from "./error";
|
||||||
export * from "./unauthorized";
|
export * from "./unauthorized";
|
||||||
export * from "./connection";
|
export * from "./connection";
|
||||||
|
export * from "./setup";
|
||||||
|
48
front/packages/ui/src/errors/setup.tsx
Normal file
48
front/packages/ui/src/errors/setup.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Main } from "@expo/html-elements";
|
||||||
|
import { type QueryPage, SetupStep } from "@kyoo/models";
|
||||||
|
import { Button, Icon, Link, P, ts } from "@kyoo/primitives";
|
||||||
|
import Register from "@material-symbols/svg-400/rounded/app_registration.svg";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useRouter } from "solito/router";
|
||||||
|
import { useYoshiki } from "yoshiki/native";
|
||||||
|
import { Navbar, NavbarProfile } from "../navbar";
|
||||||
|
import { KyooLongLogo } from "../navbar/icon";
|
||||||
|
|
||||||
|
export const SetupPage: QueryPage<{ step: SetupStep }> = ({ step }) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const isValid = Object.values(SetupStep).includes(step) && step !== SetupStep.Done;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isValid) router.replace("/");
|
||||||
|
}, [isValid, router]);
|
||||||
|
|
||||||
|
if (!isValid) return <P>Loading...</P>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Main {...css({ flexGrow: 1, flexShrink: 1, justifyContent: "center", alignItems: "center" })}>
|
||||||
|
<P>{t(`errors.setup.${step}`)}</P>
|
||||||
|
{step === SetupStep.MissingAdminAccount && (
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
href={"/register"}
|
||||||
|
text={t("login.register")}
|
||||||
|
licon={<Icon icon={Register} {...css({ marginRight: ts(2) })} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
SetupPage.getLayout = ({ page }) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar left={<KyooLongLogo {...css({ marginX: ts(2) })} />} right={<NavbarProfile />} />
|
||||||
|
{page}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -38,19 +38,25 @@ import Login from "@material-symbols/svg-400/rounded/login.svg";
|
|||||||
import Logout from "@material-symbols/svg-400/rounded/logout.svg";
|
import Logout from "@material-symbols/svg-400/rounded/logout.svg";
|
||||||
import Search from "@material-symbols/svg-400/rounded/search-fill.svg";
|
import Search from "@material-symbols/svg-400/rounded/search-fill.svg";
|
||||||
import Settings from "@material-symbols/svg-400/rounded/settings.svg";
|
import Settings from "@material-symbols/svg-400/rounded/settings.svg";
|
||||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
import { type ReactElement, forwardRef, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, type TextInput, View, type ViewProps } from "react-native";
|
import { Platform, type TextInput, View, type ViewProps } from "react-native";
|
||||||
import { useRouter } from "solito/router";
|
import { useRouter } from "solito/router";
|
||||||
import { type Stylable, useYoshiki } from "yoshiki/native";
|
import { type Stylable, percent, useYoshiki } from "yoshiki/native";
|
||||||
import { AdminPage } from "../admin";
|
import { AdminPage } from "../admin";
|
||||||
import { KyooLongLogo } from "./icon";
|
import { KyooLongLogo } from "./icon";
|
||||||
|
|
||||||
export const NavbarTitle = (props: Stylable & { onLayout?: ViewProps["onLayout"] }) => {
|
export const NavbarTitle = (props: Stylable & { onLayout?: ViewProps["onLayout"] }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<A href="/" aria-label={t("navbar.home")} {...tooltip(t("navbar.home"))} {...props}>
|
<A
|
||||||
|
href="/"
|
||||||
|
aria-label={t("navbar.home")}
|
||||||
|
{...tooltip(t("navbar.home"))}
|
||||||
|
{...css({ fontSize: 0 }, props)}
|
||||||
|
>
|
||||||
<KyooLongLogo />
|
<KyooLongLogo />
|
||||||
</A>
|
</A>
|
||||||
);
|
);
|
||||||
@ -168,7 +174,11 @@ export const NavbarRight = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Navbar = (props: Stylable) => {
|
export const Navbar = ({
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
...props
|
||||||
|
}: { left?: ReactElement | null; right?: ReactElement | null } & Stylable) => {
|
||||||
const { css } = useYoshiki();
|
const { css } = useYoshiki();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@ -195,18 +205,24 @@ export const Navbar = (props: Stylable) => {
|
|||||||
props,
|
props,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<View {...css({ flexDirection: "row", alignItems: "center" })}>
|
<View {...css({ flexDirection: "row", alignItems: "center", height: percent(100) })}>
|
||||||
<NavbarTitle {...css({ marginX: ts(2) })} />
|
{left !== undefined ? (
|
||||||
<A
|
left
|
||||||
href="/browse"
|
) : (
|
||||||
{...css({
|
<>
|
||||||
textTransform: "uppercase",
|
<NavbarTitle {...css({ marginX: ts(2) })} />
|
||||||
fontWeight: "bold",
|
<A
|
||||||
color: (theme) => theme.contrast,
|
href="/browse"
|
||||||
})}
|
{...css({
|
||||||
>
|
textTransform: "uppercase",
|
||||||
{t("navbar.browse")}
|
fontWeight: "bold",
|
||||||
</A>
|
color: (theme) => theme.contrast,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t("navbar.browse")}
|
||||||
|
</A>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
{...css({
|
{...css({
|
||||||
@ -217,7 +233,7 @@ export const Navbar = (props: Stylable) => {
|
|||||||
marginX: ts(2),
|
marginX: ts(2),
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<NavbarRight />
|
{right !== undefined ? right : <NavbarRight />}
|
||||||
</Header>
|
</Header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -237,7 +237,11 @@
|
|||||||
"offline": "You are not connected to internet. Try again later.",
|
"offline": "You are not connected to internet. Try again later.",
|
||||||
"unauthorized": "You are missing the permissions {{permission}} to access this page.",
|
"unauthorized": "You are missing the permissions {{permission}} to access this page.",
|
||||||
"needVerification": "Your account needs to be verified by your server administrator before you can use it.",
|
"needVerification": "Your account needs to be verified by your server administrator before you can use it.",
|
||||||
"needAccount": "This page can't be accessed in guest mode. You need to create an account or login."
|
"needAccount": "This page can't be accessed in guest mode. You need to create an account or login.",
|
||||||
|
"setup": {
|
||||||
|
"MissingAdminAccount": "No admin account has been created yet. Please register to create one.",
|
||||||
|
"NoVideoFound": "No video was found yet. Add movies or series inside your library's folder for them to show here!"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"mediainfo": {
|
"mediainfo": {
|
||||||
"file": "File",
|
"file": "File",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user