Hide unaired nextups (#1421)

This commit is contained in:
Zoe Roux 2026-04-04 11:54:22 +02:00 committed by GitHub
commit fb03290b49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 2262 additions and 56 deletions

View File

@ -0,0 +1,4 @@
CREATE TYPE "kyoo"."entry_content" AS ENUM('story', 'recap', 'filler', 'ova');--> statement-breakpoint
ALTER TABLE "kyoo"."entries" ADD COLUMN "content" "kyoo"."entry_content";--> statement-breakpoint
UPDATE "kyoo"."entries" SET content = 'story';-->statement-breakpoint
ALTER TABLE "kyoo"."entries" ALTER COLUMN "content" SET NOT NULl;--> statement-breakpoint

File diff suppressed because it is too large Load Diff

View File

@ -218,6 +218,13 @@
"when": 1774974162419,
"tag": "0030_external_hist",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1775238108619,
"tag": "0031_entry-content",
"breakpoints": true
}
]
}

View File

@ -31,6 +31,7 @@ import {
MovieEntry,
Special,
} from "~/models/entry";
import { EntryContent } from "~/models/entry/base-entry";
import { KError } from "~/models/error";
import { madeInAbyss } from "~/models/examples";
import { Season } from "~/models/season";
@ -83,6 +84,7 @@ export const entryFilters: FilterDef = {
order: { column: entries.order, type: "float" },
runtime: { column: entries.runtime, type: "float" },
airDate: { column: entries.airDate, type: "date" },
content: { column: entries.content, type: "enum", values: EntryContent.enum },
playedDate: { column: entryProgressQ.playedDate, type: "date" },
isAvailable: { column: isNotNull(entries.availableSince), type: "bool" },
};

View File

@ -20,6 +20,7 @@ import { coalesce, sqlarr } from "~/db/utils";
import { Entry } from "~/models/entry";
import { KError } from "~/models/error";
import { SeedHistory } from "~/models/history";
import { Show } from "~/models/show";
import {
AcceptLanguage,
createPage,
@ -198,6 +199,7 @@ async function updateWatchlist(
and(
eq(nextEntry.showPk, entries.showPk),
ne(nextEntry.kind, "extra"),
eq(nextEntry.content, "story"),
gt(nextEntry.order, entries.order),
),
)
@ -366,15 +368,16 @@ export const historyH = new Elysia({ tags: ["profiles"] })
query,
sort,
filter: and(
isNotNull(entryProgressQ.playedDate),
eq(entryProgressQ.external, false),
isNotNull(historyProgressQ.playedDate),
eq(historyProgressQ.external, false),
ne(entries.kind, "extra"),
filter,
),
languages: langs,
userId: sub,
progressQ: historyProgressQ,
})) as Entry[];
relations: ["show"],
})) as (Entry & { show: Show })[];
return createPage(items, { url, sort, limit, headers });
},
@ -386,7 +389,7 @@ export const historyH = new Elysia({ tags: ["profiles"] })
"accept-language": AcceptLanguage({ autoFallback: true }),
}),
response: {
200: Page(Entry),
200: Page(t.Intersect([Entry, t.Object({ show: Show })])),
},
},
)

View File

@ -1,4 +1,4 @@
import { and, eq, sql } from "drizzle-orm";
import { and, eq, isNotNull, lt, or, sql } from "drizzle-orm";
import Elysia, { t } from "elysia";
import { auth } from "~/auth";
import { db } from "~/db";
@ -118,6 +118,10 @@ export const nextup = new Elysia({ tags: ["profiles"] })
.leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk))
.where(
and(
or(
lt(entries.airDate, sql`now()`),
isNotNull(entries.availableSince),
),
filter,
query ? sql`${transQ.name} %> ${query}::text` : undefined,
keysetPaginate({ after, sort }),

View File

@ -59,6 +59,7 @@ export const insertEntries = record(
const { translations, videos, video, ...entry } = seed;
return {
...entry,
content: entry.kind !== "extra" ? entry.content : "story",
showPk: show.pk,
slug: generateSlug(show.slug, seed),
thumbnail: enqueueOptImage(imgQueue, {

View File

@ -96,6 +96,7 @@ export const seedMovie = async (
{
...movie,
kind: "movie",
content: "story",
order: 1,
thumbnail: (movie.originalLanguage
? translations[movie.originalLanguage]

View File

@ -247,7 +247,7 @@ function getNextVideoEntry({
.leftJoin(entryProgressQ, eq(entries.pk, entryProgressQ.entryPk))
.crossJoinLateral(entryVideosQ)
.leftJoin(entryVideoJoin, eq(entries.pk, entryVideoJoin.entryPk))
.innerJoin(vids, eq(vids.pk, entryVideoJoin.videoPk))
.leftJoin(vids, eq(vids.pk, entryVideoJoin.videoPk))
.where(
and(
// either way it needs to be of the same show
@ -277,6 +277,7 @@ function getNextVideoEntry({
eq(vids.part, sql`${videos.part} ${sql.raw(prev ? "-" : "+")} 1`),
),
),
eq(entries.content, "story"),
),
)
.orderBy(

View File

@ -24,6 +24,13 @@ export const entryType = schema.enum("entry_type", [
"extra",
]);
export const entryContent = schema.enum("entry_content", [
"story",
"recap",
"filler",
"ova",
]);
export const entry_extid = () =>
jsonb()
.$type<
@ -68,6 +75,7 @@ export const entries = schema.table(
airDate: date(),
runtime: integer(),
thumbnail: image(),
content: entryContent().notNull(),
externalId: entry_extid(),

View File

@ -1,6 +1,8 @@
import { t } from "elysia";
import { Image } from "../utils/image";
export const EntryContent = t.UnionEnum(["story", "recap", "filler", "ova"]);
export const BaseEntry = () =>
t.Object({
airDate: t.Nullable(t.String({ format: "date" })),
@ -11,6 +13,7 @@ export const BaseEntry = () =>
}),
),
thumbnail: t.Nullable(Image),
content: EntryContent,
});
export const EntryTranslation = () =>

View File

@ -22,7 +22,7 @@ export const BaseExtra = t.Composite(
kind: ExtraType,
name: t.String(),
}),
t.Omit(BaseEntry(), ["nextRefresh", "airDate"]),
t.Omit(BaseEntry(), ["nextRefresh", "airDate", "content"]),
],
{
description: comment`

View File

@ -175,6 +175,7 @@ export const madeInAbyss = {
entries: [
{
kind: "episode",
content: "story",
order: 13,
seasonNumber: 1,
episodeNumber: 13,
@ -203,6 +204,7 @@ export const madeInAbyss = {
},
{
kind: "special",
content: "ova",
// between s1e13 & movie (which has 13.5 for the `order field`)
order: 13.25,
number: 3,
@ -230,6 +232,7 @@ export const madeInAbyss = {
},
{
kind: "movie",
content: "story",
slug: "made-in-abyss-dawn-of-the-deep-soul",
order: 13.5,
translations: {
@ -257,6 +260,7 @@ export const madeInAbyss = {
},
{
kind: "episode",
content: "story",
order: 14,
seasonNumber: 2,
episodeNumber: 1,
@ -284,6 +288,7 @@ export const madeInAbyss = {
},
{
kind: "episode",
content: "story",
order: 15,
seasonNumber: 2,
episodeNumber: 2,
@ -311,6 +316,7 @@ export const madeInAbyss = {
},
{
kind: "episode",
content: "story",
order: 16,
seasonNumber: 2,
episodeNumber: 3,
@ -338,6 +344,7 @@ export const madeInAbyss = {
},
{
kind: "episode",
content: "story",
order: 17,
seasonNumber: 2,
episodeNumber: 4,

View File

@ -16,10 +16,12 @@ export const FullVideo = t.Composite([
previous: t.Optional(
t.Nullable(
t.Object({
video: t.String({
format: "slug",
examples: ["made-in-abyss-s1e12"],
}),
video: t.Nullable(
t.String({
format: "slug",
examples: ["made-in-abyss-s1e12"],
}),
),
entry: Entry,
}),
),
@ -27,10 +29,12 @@ export const FullVideo = t.Composite([
next: t.Optional(
t.Nullable(
t.Object({
video: t.String({
format: "slug",
examples: ["made-in-abyss-dawn-of-the-deep-soul"],
}),
video: t.Nullable(
t.String({
format: "slug",
examples: ["made-in-abyss-dawn-of-the-deep-soul"],
}),
),
entry: Entry,
}),
),

View File

@ -28,7 +28,7 @@ beforeAll(async () => {
});
const miaEntrySlug = `${madeInAbyss.slug}-s1e13`;
const miaNextEntrySlug = `${madeInAbyss.slug}-sp3`;
const miaNextEntrySlug = "made-in-abyss-dawn-of-the-deep-soul";
describe("nextup", () => {
it("Watchlist populates nextup", async () => {

View File

@ -49,7 +49,7 @@
"part": "Part {{number}}",
"videos-map": "Edit video mappings",
"remap": "Remap",
"staff-as":"as {{character}}",
"staff-as": "as {{character}}",
"staff-kind": {
"actor": "Actor",
"director": "Director",
@ -253,7 +253,9 @@
"transmux": "Original",
"auto": "Auto",
"notInPristine": "Unavailable in pristine",
"unsupportedError": "Video codec not supported, transcoding in progress..."
"unsupportedError": "Video codec not supported, transcoding in progress...",
"not-available": "{{entry}} is not available on kyoo yet, ask your server admins about it",
"fatal": "Fatal playback error"
},
"search": {
"empty": "No result found. Try a different query."

View File

@ -36,7 +36,7 @@ export const EntryBox = ({
serieSlug: string | null;
name: string | null;
description: string | null;
href: string;
href: string | null;
thumbnail: KImage | null;
watchedPercent: number;
videos: Entry["videos"];
@ -51,7 +51,11 @@ export const EntryBox = ({
href={moreOpened || videos.length > 1 ? undefined : href}
onPress={videos.length > 1 ? onSelectVideos : undefined}
onLongPress={() => setMoreOpened(true)}
className={cn("group w-[350px] items-center p-1 outline-0", className)}
className={cn(
"group w-[350px] items-center p-1 outline-0",
href === null && "opacity-50",
className,
)}
{...props}
>
<ThumbnailBackground

View File

@ -56,13 +56,6 @@ export const EntryContext = ({
{...tooltip(t("misc.more"))}
{...(props as any)}
>
{account && (
<Menu.Item
label={t("show.watchlistMark.completed")}
icon={watchListIcon("completed")}
onSelect={() => markAsSeenMutation.mutate()}
/>
)}
{serieSlug && (
<Menu.Item
label={t("home.episodeMore.goToShow")}
@ -70,6 +63,13 @@ export const EntryContext = ({
href={`/${kind === "movie" ? "movies" : "series"}/${serieSlug}`}
/>
)}
{account && (
<Menu.Item
label={t("show.watchlistMark.completed")}
icon={watchListIcon("completed")}
onSelect={() => markAsSeenMutation.mutate()}
/>
)}
{/* <Menu.Item */}
{/* label={t("home.episodeMore.download")} */}
{/* icon={Download} */}

View File

@ -14,6 +14,8 @@ const Base = z.object({
runtime: z.number().nullable(),
thumbnail: KImage.nullable(),
content: z.enum(["story", "recap", "filler", "ova"]),
createdAt: zdate(),
updatedAt: zdate(),

View File

@ -49,8 +49,14 @@ export const FullVideo = Video.extend({
playedDate: zdate().nullable(),
videoId: z.string().nullable(),
}),
previous: z.object({ video: z.string(), entry: Entry }).nullable().optional(),
next: z.object({ video: z.string(), entry: Entry }).nullable().optional(),
previous: z
.object({ video: z.string().nullable(), entry: Entry })
.nullable()
.optional(),
next: z
.object({ video: z.string().nullable(), entry: Entry })
.nullable()
.optional(),
show: Show.optional().nullable(),
});
export type FullVideo = z.infer<typeof FullVideo>;

View File

@ -24,6 +24,7 @@ export const InfiniteFetch = <Data, Type extends string = string>({
query,
placeholderCount = 4,
incremental = false,
getKey,
getItemType,
getItemSizeMult,
getStickyIndices,
@ -43,6 +44,7 @@ export const InfiniteFetch = <Data, Type extends string = string>({
placeholderCount?: number;
layout: Layout;
horizontal?: boolean;
getKey?: (item: Data, index: number) => string;
getItemType?: (item: Data, index: number) => Type;
getItemSizeMult?: (item: Data, index: number, type: Type) => number;
getStickyIndices?: (items: Data[]) => number[];
@ -94,7 +96,10 @@ export const InfiniteFetch = <Data, Type extends string = string>({
renderItem={({ item, index }) =>
item ? <Render index={index} item={item} /> : <Loader index={index} />
}
keyExtractor={(item: any, index) => (item ? item.id : index + 1)}
keyExtractor={(item: any, index) => {
if (!item) return index + 1;
return getKey ? getKey(item, index) : item.id;
}}
horizontal={layout.layout === "horizontal"}
numColumns={layout.layout === "horizontal" ? 1 : numColumns}
onEndReached={

View File

@ -37,7 +37,6 @@ import {
HR,
IconButton,
IconFab,
Image,
ImageBackground,
LI,
Link,

View File

@ -246,8 +246,13 @@ EntryList.query = (
path: ["api", "series", slug, "entries"],
params: {
query,
// TODO: use a better filter, it removes specials and movies
filter: season ? `seasonNumber ge ${season}` : undefined,
filter: [
// TODO: use a better filter, it removes specials and movies
season && `seasonNumber ge ${season}`,
"(kind eq episode or isAvailable eq true or content eq story)",
]
.filter((x) => x)
.join(" and "),
includeSeasons: true,
},
infinite: true,

View File

@ -3,6 +3,7 @@ import { View } from "react-native";
import { type KImage, Role } from "~/models";
import { Container, H2, Link, P, Poster, Skeleton, SubP } from "~/primitives";
import { InfiniteGrid, type QueryIdentifier } from "~/query";
import { cn } from "~/utils";
import { EmptyView } from "../empty-view";
export const CharacterCard = ({
@ -21,11 +22,17 @@ export const CharacterCard = ({
return (
<Link
href={href}
className="flex-row items-center overflow-hidden rounded-xl bg-card"
className={cn(
"flex-row items-center overflow-hidden rounded-xl bg-card",
"group ring-accent hover:ring-3 focus-visible:ring-3",
)}
>
<Poster src={image} quality="low" className="w-28" />
<View className="flex-1 items-center justify-center py-5">
<P className="text-center font-semibold" numberOfLines={2}>
<P
className="text-center font-semibold group-hover:underline group-focus-visible:underline"
numberOfLines={2}
>
{name}
</P>
<SubP className="mt-1 text-center" numberOfLines={2}>

View File

@ -45,7 +45,7 @@ export const NewsList = () => {
name={`${item.show!.name} ${entryDisplayNumber(item)}`}
description={item.name}
thumbnail={item.thumbnail ?? item.show!.thumbnail}
href={item.href ?? "#"}
href={item.href}
watchedPercent={item.progress.percent}
videos={item.videos}
onSelectVideos={() =>

View File

@ -66,7 +66,7 @@ export const NextupList = () => {
name={`${item.show!.name} ${entryDisplayNumber(item)}`}
description={item.name}
thumbnail={item.thumbnail ?? item.show!.thumbnail}
href={item.href ?? "#"}
href={item.href}
watchedPercent={item.progress.percent}
videos={item.videos}
onSelectVideos={() =>

View File

@ -0,0 +1,29 @@
import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Heading, IconButton, P } from "~/primitives";
import { cn } from "~/utils";
export const ErrorPopup = ({
message,
dismiss,
}: {
message: string;
dismiss: () => void;
}) => {
const { t } = useTranslation();
return (
<View
className={cn(
"absolute inset-x-6 top-1/2 flex-1 -translate-y-1/2 flex-row justify-between",
"rounded-xl border border-slate-700 bg-background p-5",
)}
>
<View className="flex-1 flex-wrap">
<Heading className="my-2">{t("player.fatal")}</Heading>
<P className="mt-2 flex-1">{message}</P>
</View>
<IconButton icon={Close} onPress={dismiss} />
</View>
);
};

View File

@ -20,6 +20,7 @@ export const Controls = ({
chapters,
playPrev,
playNext,
forceShow,
}: {
player: VideoPlayer;
showHref?: string;
@ -31,6 +32,7 @@ export const Controls = ({
chapters: Chapter[];
playPrev: (() => boolean) | null;
playNext: (() => boolean) | null;
forceShow?: boolean;
}) => {
const isTouch = useIsTouch();
@ -56,7 +58,7 @@ export const Controls = ({
<View className="absolute inset-0">
<TouchControls
player={player}
forceShow={hover || menuOpened}
forceShow={hover || menuOpened || forceShow}
className="absolute inset-0"
>
<Back

View File

@ -2,6 +2,7 @@ import "react-native-get-random-values";
import { Stack, useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, View } from "react-native";
import { useEvent, useVideoPlayer, VideoView } from "react-native-video";
import { v4 as uuidv4 } from "uuid";
@ -14,6 +15,7 @@ import { type QueryIdentifier, useFetch } from "~/query";
import { Info } from "~/ui/info";
import { useQueryState } from "~/utils";
import { Controls, LoadingIndicator } from "./controls";
import { ErrorPopup } from "./controls/error-popup";
import { toggleFullscreen } from "./controls/misc";
import { PlayModeContext } from "./controls/tracks-menu";
import { useKeyboard } from "./keyboard";
@ -45,6 +47,7 @@ export const Player = () => {
);
const playModeState = useState(defaultPlayMode);
const [playMode, setPlayMode] = playModeState;
const [playbackError, setPlaybackError] = useState<KyooError | undefined>();
const player = useVideoPlayer(
{
uri: `${apiUrl}/api/videos/${slug}/${playMode === "direct" ? "direct" : "master.m3u8"}?clientId=${clientId}`,
@ -101,18 +104,39 @@ export const Player = () => {
}, [player, info?.fonts]);
const router = useRouter();
const { t } = useTranslation();
const playPrev = useCallback(() => {
if (!data?.previous) return false;
if (!data.previous.video) {
setPlaybackError({
status: "not-available",
message: t("player.not-available", {
entry: `${entryDisplayNumber(data.previous.entry)} ${data.previous.entry.name}`,
}),
});
return true;
}
setPlaybackError(undefined);
setStart("0");
setSlug(data.previous.video);
return true;
}, [data?.previous, setSlug, setStart]);
}, [data?.previous, setSlug, setStart, t]);
const playNext = useCallback(() => {
if (!data?.next) return false;
if (!data.next.video) {
setPlaybackError({
status: "not-available",
message: t("player.not-available", {
entry: `${entryDisplayNumber(data.next.entry)} ${data.next.entry.name}`,
}),
});
return true;
}
setPlaybackError(undefined);
setStart("0");
setSlug(data.next.video);
return true;
}, [data?.next, setSlug, setStart]);
}, [data?.next, setSlug, setStart, t]);
useProgressObserver(
player,
@ -148,7 +172,6 @@ export const Player = () => {
};
}, []);
const [playbackError, setPlaybackError] = useState<KyooError | undefined>();
useEvent(player, "onError", (error) => {
if (
error.code === "source/unsupported-content-type" &&
@ -157,9 +180,6 @@ export const Player = () => {
setPlayMode("hls");
else setPlaybackError({ status: error.code, message: error.message });
});
if (playbackError) {
throw playbackError;
}
return (
<View className="flex-1 bg-black">
@ -201,10 +221,17 @@ export const Player = () => {
: data?.path
}
chapters={info?.chapters ?? []}
playPrev={data?.previous?.video ? playPrev : null}
playNext={data?.next?.video ? playNext : null}
playPrev={data?.previous ? playPrev : null}
playNext={data?.next ? playNext : null}
forceShow={!!playbackError}
/>
</PlayModeContext.Provider>
{playbackError && (
<ErrorPopup
message={playbackError.message}
dismiss={() => setPlaybackError(undefined)}
/>
)}
</View>
);
};

View File

@ -3,12 +3,14 @@ import Cancel from "@material-symbols/svg-400/rounded/cancel-fill.svg";
import CheckCircle from "@material-symbols/svg-400/rounded/check_circle-fill.svg";
import Replay from "@material-symbols/svg-400/rounded/replay.svg";
import Clock from "@material-symbols/svg-400/rounded/schedule-fill.svg";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { EntryBox, entryDisplayNumber } from "~/components/entries";
import { EntrySelect } from "~/components/entries/select";
import { ItemGrid, itemMap } from "~/components/items";
import { Entry, Show, type User, User as UserModel } from "~/models";
import { Avatar, H1, H3, P, Tabs } from "~/primitives";
import { Avatar, H1, H3, P, Tabs, usePopup } from "~/primitives";
import { Fetch, InfiniteFetch, type QueryIdentifier } from "~/query";
import { EmptyView } from "~/ui/empty-view";
import { useQueryState } from "~/utils";
@ -58,6 +60,25 @@ const ProfileHeader = ({
setStatus: (value: WatchlistFilter) => void;
}) => {
const { t } = useTranslation();
const [setPopup, closePopup] = usePopup();
const openEntrySelect = useCallback(
(entry: {
displayNumber: string;
name: string | null;
videos: Entry["videos"];
}) => {
setPopup(
<EntrySelect
displayNumber={entry.displayNumber}
name={entry.name ?? ""}
videos={entry.videos}
close={closePopup}
/>,
);
},
[setPopup, closePopup],
);
return (
<View className="mx-2 my-4 gap-4">
@ -90,6 +111,7 @@ const ProfileHeader = ({
<InfiniteFetch
query={ProfilePage.historyQuery(slug)}
layout={{ ...EntryBox.layout, layout: "horizontal" }}
getKey={(x) => `${x.id}-${x.progress.playedDate?.toISOString()}`}
Empty={<EmptyView message={t("home.none")} />}
Render={({ item }) => (
<EntryBox
@ -103,10 +125,16 @@ const ProfileHeader = ({
}
description={item.name}
thumbnail={item.thumbnail ?? item.show?.thumbnail ?? null}
href={item.href ?? "#"}
href={item.href}
watchedPercent={item.progress.percent}
videos={item.videos}
onSelectVideos={() => {}}
onSelectVideos={() =>
openEntrySelect({
displayNumber: entryDisplayNumber(item),
name: item.name,
videos: item.videos,
})
}
/>
)}
Loader={EntryBox.Loader}
@ -170,9 +198,6 @@ ProfilePage.historyQuery = (slug: string): QueryIdentifier<Entry> => ({
parser: Entry,
infinite: true,
path: ["api", "profiles", slug, "history"],
params: {
with: ["show"],
},
});
ProfilePage.userQuery = (slug: string): QueryIdentifier<User> => ({

View File

@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import date
from enum import Enum
from typing import Any, Literal
from pydantic import Field
@ -9,12 +10,20 @@ from ..utils import Language, Model
from .metadataid import EpisodeId, MetadataId
class EntryContent(str, Enum):
STORY = "story"
RECAP = "recap"
FILLER = "filler"
OVA = "ova"
class Entry(Model):
kind: Literal["episode", "movie", "special"]
order: float
runtime: int | None
air_date: date | None
thumbnail: str | None
content: EntryContent
# Movie-specific fields
slug: str | None

View File

@ -11,7 +11,7 @@ from aiohttp import ClientResponseError, ClientSession
from langcodes import Language
from ..models.collection import Collection, CollectionTranslation
from ..models.entry import Entry, EntryTranslation
from ..models.entry import Entry, EntryContent, EntryTranslation
from ..models.genre import Genre
from ..models.metadataid import EpisodeId, MetadataId, SeasonId
from ..models.movie import Movie, MovieStatus, MovieTranslation, SearchMovie
@ -556,6 +556,7 @@ class TheMovieDatabase(Provider):
if episode["air_date"]
else None,
thumbnail=self._map_image(episode["still_path"]),
content=EntryContent.STORY,
slug=None,
season_number=episode["season_number"],
episode_number=episode["episode_number"],

View File

@ -11,7 +11,7 @@ from langcodes.data_dicts import LANGUAGE_REPLACEMENTS
from ..cache import cache
from ..models.collection import Collection, CollectionTranslation
from ..models.entry import Entry, EntryTranslation
from ..models.entry import Entry, EntryContent, EntryTranslation
from ..models.genre import Genre
from ..models.metadataid import EpisodeId, MetadataId, SeasonId
from ..models.movie import Movie, MovieStatus, MovieTranslation, SearchMovie
@ -541,6 +541,12 @@ class TVDB(Provider):
thumbnail=f"https://artworks.thetvdb.com{entry['image']}"
if entry["image"]
else None,
# Mark specials as ova, waiting for https://github.com/thetvdb/v4-api/issues/350
content=(
EntryContent.STORY
if entry["seasonNumber"] != 0 or entry["isMovie"]
else EntryContent.OVA
),
slug=None,
season_number=entry["seasonNumber"],
episode_number=entry["number"],
@ -620,7 +626,7 @@ class TVDB(Provider):
)
# handle specials and such that are between seasons
for entry in ret:
for entry in reversed(ret):
if entry.order != 0:
continue
@ -635,7 +641,11 @@ class TVDB(Provider):
)
after = min((x.order for x in ret if x.order > before), default=before)
entry.order = (before + after) / 2
elif entry.extra["airs_before_season"] is not None:
for entry in ret:
if entry.order != 0:
continue
if entry.extra["airs_before_season"] is not None:
before = (
next(
(