mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-09 03:04:20 -04:00
Merge branch 'zoriya:master' into feature/helmchart
This commit is contained in:
commit
df07e75737
179
api/README.md
Normal file
179
api/README.md
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
# Kyoo API
|
||||||
|
|
||||||
|
## Database schema
|
||||||
|
|
||||||
|
The many-to-many relation between entries (episodes/movies) & videos is NOT a mistake. Some video files can contain multiples episodes (like `MyShow 2&3.mvk`). One video file can also contain only a portion of an episode (like `MyShow 2 Part 1.mkv`)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
shows {
|
||||||
|
guid id PK
|
||||||
|
kind kind "serie|movie"
|
||||||
|
string(128) slug UK
|
||||||
|
genre[] genres
|
||||||
|
int rating "From 0 to 100"
|
||||||
|
status status "NN"
|
||||||
|
datetime added_date
|
||||||
|
date start_air
|
||||||
|
date end_air "null for movies"
|
||||||
|
datetime next_refresh
|
||||||
|
jsonb external_id
|
||||||
|
guid studio_id FK
|
||||||
|
string original_language
|
||||||
|
}
|
||||||
|
show_translations {
|
||||||
|
guid id PK, FK
|
||||||
|
string language PK
|
||||||
|
string name "NN"
|
||||||
|
string tagline
|
||||||
|
string[] aliases
|
||||||
|
string description
|
||||||
|
string[] tags
|
||||||
|
string trailerUrl
|
||||||
|
jsonb poster
|
||||||
|
jsonb banner
|
||||||
|
jsonb logo
|
||||||
|
jsonb thumbnail
|
||||||
|
}
|
||||||
|
shows ||--|{ show_translations : has
|
||||||
|
shows |o--|| entries : has
|
||||||
|
|
||||||
|
entries {
|
||||||
|
guid id PK
|
||||||
|
string(256) slug UK
|
||||||
|
guid show_id FK, UK
|
||||||
|
%% Order is absolute number.
|
||||||
|
uint order "NN"
|
||||||
|
uint season_number UK
|
||||||
|
uint episode_number UK "NN"
|
||||||
|
type type "episode|movie|special|extra"
|
||||||
|
date air_date
|
||||||
|
uint runtime
|
||||||
|
jsonb thumbnail
|
||||||
|
datetime next_refresh
|
||||||
|
jsonb external_id
|
||||||
|
}
|
||||||
|
entry_translations {
|
||||||
|
guid id PK, FK
|
||||||
|
string language PK
|
||||||
|
string name
|
||||||
|
string description
|
||||||
|
}
|
||||||
|
entries ||--|{ entry_translations : has
|
||||||
|
|
||||||
|
video {
|
||||||
|
guid id PK
|
||||||
|
string path "NN"
|
||||||
|
uint rendering "dedup for duplicates part1/2"
|
||||||
|
uint part
|
||||||
|
uint version "max version is preferred rendering"
|
||||||
|
}
|
||||||
|
video }|--|{ entries : for
|
||||||
|
|
||||||
|
collections {
|
||||||
|
guid id PK
|
||||||
|
string(256) slug UK
|
||||||
|
datetime added_date
|
||||||
|
datetime next_refresh
|
||||||
|
}
|
||||||
|
|
||||||
|
collection_translations {
|
||||||
|
guid id PK, FK
|
||||||
|
string language PK
|
||||||
|
string name "NN"
|
||||||
|
jsonb poster
|
||||||
|
jsonb thumbnail
|
||||||
|
}
|
||||||
|
collections ||--|{ collection_translations : has
|
||||||
|
collections |o--|{ shows : has
|
||||||
|
|
||||||
|
seasons {
|
||||||
|
guid id PK
|
||||||
|
string(256) slug UK
|
||||||
|
guid show_id FK
|
||||||
|
uint season_number "NN"
|
||||||
|
datetime added_date
|
||||||
|
date start_air
|
||||||
|
date end_air
|
||||||
|
datetime next_refresh
|
||||||
|
jsonb external_id
|
||||||
|
}
|
||||||
|
|
||||||
|
season_translations {
|
||||||
|
guid id PK,FK
|
||||||
|
string language PK
|
||||||
|
string name
|
||||||
|
string description
|
||||||
|
jsonb poster
|
||||||
|
jsonb banner
|
||||||
|
jsonb logo
|
||||||
|
jsonb thumbnail
|
||||||
|
}
|
||||||
|
seasons ||--|{ season_translations : has
|
||||||
|
seasons ||--o{ entries : has
|
||||||
|
shows ||--|{ seasons : has
|
||||||
|
|
||||||
|
watched_shows {
|
||||||
|
guid show_id PK, FK
|
||||||
|
guid user_id PK, FK
|
||||||
|
status status "completed|watching|droped|planned"
|
||||||
|
uint seen_entry_count "NN"
|
||||||
|
}
|
||||||
|
shows ||--|{ watched_shows : has
|
||||||
|
|
||||||
|
watched_entries {
|
||||||
|
guid entry_id PK, FK
|
||||||
|
guid user_id PK, FK
|
||||||
|
uint time "in seconds, null of finished"
|
||||||
|
uint progress "NN, from 0 to 100"
|
||||||
|
datetime played_date
|
||||||
|
}
|
||||||
|
entries ||--|{ watched_entries : has
|
||||||
|
|
||||||
|
roles {
|
||||||
|
guid show_id PK, FK
|
||||||
|
guid staff_id PK, FK
|
||||||
|
uint order
|
||||||
|
type type "actor|director|writer|producer|music|other"
|
||||||
|
jsonb character_image
|
||||||
|
}
|
||||||
|
|
||||||
|
role_translations {
|
||||||
|
string language PK
|
||||||
|
string character_name
|
||||||
|
}
|
||||||
|
roles||--o{ role_translations : has
|
||||||
|
shows ||--|{ roles : has
|
||||||
|
|
||||||
|
staff {
|
||||||
|
guid id PK
|
||||||
|
string(256) slug UK
|
||||||
|
jsonb image
|
||||||
|
datetime next_refresh
|
||||||
|
jsonb external_id
|
||||||
|
}
|
||||||
|
|
||||||
|
staff_translations {
|
||||||
|
guid id PK,FK
|
||||||
|
string language PK
|
||||||
|
string name "NN"
|
||||||
|
}
|
||||||
|
staff ||--|{ staff_translations : has
|
||||||
|
staff ||--|{ roles : has
|
||||||
|
|
||||||
|
studios {
|
||||||
|
guid id PK
|
||||||
|
string(128) slug UK
|
||||||
|
jsonb logo
|
||||||
|
datetime next_refresh
|
||||||
|
jsonb external_id
|
||||||
|
}
|
||||||
|
|
||||||
|
studio_translations {
|
||||||
|
guid id PK,FK
|
||||||
|
string language PK
|
||||||
|
string name
|
||||||
|
}
|
||||||
|
studios ||--|{ studio_translations : has
|
||||||
|
shows ||--|{ studios : has
|
||||||
|
```
|
@ -3,7 +3,7 @@
|
|||||||
"isRoot": true,
|
"isRoot": true,
|
||||||
"tools": {
|
"tools": {
|
||||||
"dotnet-ef": {
|
"dotnet-ef": {
|
||||||
"version": "8.0.7",
|
"version": "8.0.8",
|
||||||
"commands": [
|
"commands": [
|
||||||
"dotnet-ef"
|
"dotnet-ef"
|
||||||
]
|
]
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
||||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||||
<PackageReference Include="Serilog" Version="4.0.1" />
|
<PackageReference Include="Serilog" Version="4.0.1" />
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@
|
|||||||
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="3.0.2" />
|
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="3.0.2" />
|
||||||
<PackageReference Include="SkiaSharp" Version="2.88.8" />
|
<PackageReference Include="SkiaSharp" Version="2.88.8" />
|
||||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.8" />
|
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.8" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
@ -9,11 +9,11 @@
|
|||||||
<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" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.7" />
|
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.8" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
@ -191,7 +191,7 @@ export const AccountProvider = ({
|
|||||||
<AccountContext.Provider value={accounts}>
|
<AccountContext.Provider value={accounts}>
|
||||||
<ConnectionErrorContext.Provider
|
<ConnectionErrorContext.Provider
|
||||||
value={{
|
value={{
|
||||||
error: (selected ? initialSsrError.current ?? userError : null) ?? permissionError,
|
error: (selected ? (initialSsrError.current ?? userError) : null) ?? permissionError,
|
||||||
loading: userIsLoading,
|
loading: userIsLoading,
|
||||||
retry: () => {
|
retry: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["auth", "me"] });
|
queryClient.invalidateQueries({ queryKey: ["auth", "me"] });
|
||||||
|
@ -61,7 +61,8 @@ export const SnackbarProvider = ({ children }: { children: ReactElement | ReactE
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
addPortal("snackbar", <Snackbar {...top} />);
|
const { key, ...props } = top;
|
||||||
|
addPortal("snackbar", <Snackbar key={key} {...props} />);
|
||||||
timeout.current = setTimeout(() => {
|
timeout.current = setTimeout(() => {
|
||||||
removePortal("snackbar");
|
removePortal("snackbar");
|
||||||
updatePortal();
|
updatePortal();
|
||||||
|
@ -56,7 +56,7 @@ const MediaTypeTrigger = forwardRef<View, PressableProps & { mediaType: MediaTyp
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const labelKey =
|
const labelKey =
|
||||||
mediaType !== MediaTypeAll ? `browse.mediatypekey.${mediaType.key}` : "browse.mediatypelabel";
|
mediaType !== MediaTypeAll ? `browse.mediatypekey.${mediaType.key}` : "browse.mediatypelabel";
|
||||||
const icon = mediaType !== MediaTypeAll ? mediaType?.icon ?? FilterList : FilterList;
|
const icon = mediaType !== MediaTypeAll ? (mediaType?.icon ?? FilterList) : FilterList;
|
||||||
return (
|
return (
|
||||||
<PressableFeedback
|
<PressableFeedback
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
@ -53,11 +53,11 @@ export const itemMap = (
|
|||||||
href: item.href,
|
href: item.href,
|
||||||
poster: item.poster,
|
poster: item.poster,
|
||||||
thumbnail: item.thumbnail,
|
thumbnail: item.thumbnail,
|
||||||
watchStatus: item.kind !== "collection" ? item.watchStatus?.status ?? null : null,
|
watchStatus: item.kind !== "collection" ? (item.watchStatus?.status ?? null) : null,
|
||||||
type: item.kind,
|
type: item.kind,
|
||||||
watchPercent: item.kind !== "collection" ? item.watchStatus?.watchedPercent ?? null : null,
|
watchPercent: item.kind !== "collection" ? (item.watchStatus?.watchedPercent ?? null) : null,
|
||||||
unseenEpisodesCount:
|
unseenEpisodesCount:
|
||||||
item.kind === "show" ? item.watchStatus?.unseenEpisodesCount ?? item.episodesCount! : null,
|
item.kind === "show" ? (item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!) : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createFilterString = (mediaType: MediaType): string | undefined => {
|
export const createFilterString = (mediaType: MediaType): string | undefined => {
|
||||||
|
@ -178,7 +178,7 @@ export const CollectionPage: QueryPage<{ slug: string }> = ({ slug }) => {
|
|||||||
watchStatus={(item.kind !== "collection" && item.watchStatus?.status) || null}
|
watchStatus={(item.kind !== "collection" && item.watchStatus?.status) || null}
|
||||||
unseenEpisodesCount={
|
unseenEpisodesCount={
|
||||||
item.kind === "show"
|
item.kind === "show"
|
||||||
? item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!
|
? (item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!)
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
{...css({ marginX: ItemGrid.layout.gap })}
|
{...css({ marginX: ItemGrid.layout.gap })}
|
||||||
|
@ -307,7 +307,7 @@ export const Recommended = () => {
|
|||||||
watchStatus={(item.kind !== "collection" && item.watchStatus?.status) || null}
|
watchStatus={(item.kind !== "collection" && item.watchStatus?.status) || null}
|
||||||
unseenEpisodesCount={
|
unseenEpisodesCount={
|
||||||
item.kind === "show"
|
item.kind === "show"
|
||||||
? item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!
|
? (item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!)
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import Svg, { type SvgProps, Path } from "react-native-svg";
|
import Svg, { type SvgProps, Path } from "react-native-svg";
|
||||||
import { useYoshiki } from "yoshiki";
|
import { useYoshiki } from "yoshiki/native";
|
||||||
|
|
||||||
/* export const KyooLogo = (props: SvgProps) => ( */
|
/* export const KyooLogo = (props: SvgProps) => ( */
|
||||||
/* <Svg viewBox="0 0 128 128" {...props}> */
|
/* <Svg viewBox="0 0 128 128" {...props}> */
|
||||||
|
@ -92,7 +92,8 @@ export const Player = ({
|
|||||||
const [playbackError, setPlaybackError] = useState<string | undefined>(undefined);
|
const [playbackError, setPlaybackError] = useState<string | undefined>(undefined);
|
||||||
const { data, error } = useFetch(Player.query(type, slug));
|
const { data, error } = useFetch(Player.query(type, slug));
|
||||||
const { data: info, error: infoError } = useFetch(Player.infoQuery(type, slug));
|
const { data: info, error: infoError } = useFetch(Player.infoQuery(type, slug));
|
||||||
const image = data && data.type === "episode" ? data.show?.poster ?? data?.poster : data?.poster;
|
const image =
|
||||||
|
data && data.type === "episode" ? (data.show?.poster ?? data?.poster) : data?.poster;
|
||||||
const previous =
|
const previous =
|
||||||
data && data.type === "episode" && data.previousEpisode
|
data && data.type === "episode" && data.previousEpisode
|
||||||
? `/watch/${data.previousEpisode.slug}?t=0`
|
? `/watch/${data.previousEpisode.slug}?t=0`
|
||||||
|
@ -147,7 +147,7 @@ export const AudiosMenu = ({ audios, ...props }: CustomMenu & { audios?: Audio[]
|
|||||||
{info.audioTracks.map((x) => (
|
{info.audioTracks.map((x) => (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
key={x.index}
|
key={x.index}
|
||||||
label={audios ? getDisplayName(audios[x.index]) : x.title ?? x.language ?? "Unknown"}
|
label={audios ? getDisplayName(audios[x.index]) : (x.title ?? x.language ?? "Unknown")}
|
||||||
selected={audio!.index === x.index}
|
selected={audio!.index === x.index}
|
||||||
onSelect={() => setAudio(x as any)}
|
onSelect={() => setAudio(x as any)}
|
||||||
/>
|
/>
|
||||||
|
@ -71,7 +71,7 @@ export const GeneralSettings = () => {
|
|||||||
onValueChange={(value) => changeLanguage(value)}
|
onValueChange={(value) => changeLanguage(value)}
|
||||||
values={["system", ...Object.keys(i18n.options.resources!)]}
|
values={["system", ...Object.keys(i18n.options.resources!)]}
|
||||||
getLabel={(key) =>
|
getLabel={(key) =>
|
||||||
key === "system" ? t("settings.general.language.system") : getLanguageName(key) ?? key
|
key === "system" ? t("settings.general.language.system") : (getLanguageName(key) ?? key)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Preference>
|
</Preference>
|
||||||
|
@ -64,7 +64,7 @@ export const PlaybackSettings = () => {
|
|||||||
onValueChange={(value) => setAudio(value)}
|
onValueChange={(value) => setAudio(value)}
|
||||||
values={["default", ...allLanguages]}
|
values={["default", ...allLanguages]}
|
||||||
getLabel={(key) =>
|
getLabel={(key) =>
|
||||||
key === "default" ? t("mediainfo.default") : getLanguageName(key) ?? key
|
key === "default" ? t("mediainfo.default") : (getLanguageName(key) ?? key)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Preference>
|
</Preference>
|
||||||
@ -83,7 +83,7 @@ export const PlaybackSettings = () => {
|
|||||||
? t("settings.playback.subtitleLanguage.none")
|
? t("settings.playback.subtitleLanguage.none")
|
||||||
: key === "default"
|
: key === "default"
|
||||||
? t("mediainfo.default")
|
? t("mediainfo.default")
|
||||||
: getLanguageName(key) ?? key
|
: (getLanguageName(key) ?? key)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Preference>
|
</Preference>
|
||||||
|
285
front/translations/es.json
Normal file
285
front/translations/es.json
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
{
|
||||||
|
"home": {
|
||||||
|
"recommended": "Recomendado",
|
||||||
|
"news": "Noticias",
|
||||||
|
"watchlist": "Continuar viendo",
|
||||||
|
"info": "Ver mas",
|
||||||
|
"none": "Sin episodios",
|
||||||
|
"watchlistLogin": "Para hacer un seguimiento de lo que has visto o piensas ver, tienes que iniciar sesión.",
|
||||||
|
"refreshMetadata": "Refrescar metadatos",
|
||||||
|
"episodeMore": {
|
||||||
|
"goToShow": "Ir al show",
|
||||||
|
"download": "Descarga",
|
||||||
|
"mediainfo": "Ver información del archivo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"show": {
|
||||||
|
"play": "Reproducir",
|
||||||
|
"trailer": "Ver el tráiler",
|
||||||
|
"studio": "Estudio",
|
||||||
|
"genre": "Géneros",
|
||||||
|
"genre-none": "Sin géneros",
|
||||||
|
"staff": "Equipo",
|
||||||
|
"staff-none": "El equipo es desconocido",
|
||||||
|
"noOverview": "Resumen no disponible",
|
||||||
|
"episode-none": "No hay episodios en esta temporada",
|
||||||
|
"episodeNoMetadata": "Metadatos no disponibles",
|
||||||
|
"tags": "Etiquetas",
|
||||||
|
"links": "Enlaces",
|
||||||
|
"jumpToSeason": "Ir a temporada",
|
||||||
|
"partOf": "Parte de la",
|
||||||
|
"watchlistAdd": "Agregar a su lista de seguimiento",
|
||||||
|
"watchlistEdit": "Modificar el estado de visto",
|
||||||
|
"watchlistRemove": "Marcar como no visto",
|
||||||
|
"watchlistLogin": "Inicia sesión para añadir a tu lista de seguimiento",
|
||||||
|
"watchlistMark": {
|
||||||
|
"completed": "Marcar como completado",
|
||||||
|
"planned": "Marcar como planificado",
|
||||||
|
"watching": "Marcar como viendo",
|
||||||
|
"droped": "Marcar como descartado",
|
||||||
|
"null": "Marcar como no visto"
|
||||||
|
},
|
||||||
|
"nextUp": "Siguiente",
|
||||||
|
"season": "Temporada {{number}}"
|
||||||
|
},
|
||||||
|
"browse": {
|
||||||
|
"mediatypekey": {
|
||||||
|
"all": "Todo",
|
||||||
|
"movie": "Películas",
|
||||||
|
"show": "Programas de TV",
|
||||||
|
"collection": "Colección"
|
||||||
|
},
|
||||||
|
"mediatype-tt": "Tipos de medios",
|
||||||
|
"mediatypelabel": "Tipo de medio",
|
||||||
|
"sortby": "Ordenar por {{key}}",
|
||||||
|
"sortby-tt": "Ordenar por",
|
||||||
|
"sortkey": {
|
||||||
|
"relevance": "Relevancia",
|
||||||
|
"name": "Nombre",
|
||||||
|
"airDate": "Fecha de emisión",
|
||||||
|
"startAir": "Empieza emisión",
|
||||||
|
"endAir": "Finaliza emisión",
|
||||||
|
"addedDate": "Fecha de incorporación",
|
||||||
|
"rating": "Calificaciones"
|
||||||
|
},
|
||||||
|
"sortord": {
|
||||||
|
"asc": "ascendente",
|
||||||
|
"desc": "descendente"
|
||||||
|
},
|
||||||
|
"switchToGrid": "Cambiar a vista de cuadrícula",
|
||||||
|
"switchToList": "Cambiar a vista de lista"
|
||||||
|
},
|
||||||
|
"genres": {
|
||||||
|
"Action": "Acción",
|
||||||
|
"Adventure": "Aventura",
|
||||||
|
"Animation": "Animación",
|
||||||
|
"Comedy": "Comedia",
|
||||||
|
"Crime": "Crimen",
|
||||||
|
"Documentary": "Documental",
|
||||||
|
"Drama": "Drama",
|
||||||
|
"Family": "Familia",
|
||||||
|
"Fantasy": "Fantasía",
|
||||||
|
"History": "Historia",
|
||||||
|
"Horror": "Horror",
|
||||||
|
"Music": "Musica",
|
||||||
|
"Mystery": "Misterio",
|
||||||
|
"Romance": "Romance",
|
||||||
|
"ScienceFiction": "Ciencia ficción",
|
||||||
|
"Thriller": "Suspenso",
|
||||||
|
"War": "Bélica",
|
||||||
|
"Western": "Del oeste",
|
||||||
|
"Kids": "Niños",
|
||||||
|
"News": "Noticias",
|
||||||
|
"Reality": "Realidad",
|
||||||
|
"Soap": "Novela",
|
||||||
|
"Talk": "Entrevista",
|
||||||
|
"Politics": "Política"
|
||||||
|
},
|
||||||
|
"misc": {
|
||||||
|
"settings": "Ajustes",
|
||||||
|
"prev-page": "Pagina anterior",
|
||||||
|
"next-page": "Pagina siguiente",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"more": "Mas",
|
||||||
|
"expand": "Agrandar",
|
||||||
|
"collapse": "Expandir",
|
||||||
|
"edit": "Modificar",
|
||||||
|
"or": "O",
|
||||||
|
"loading": "Cargando"
|
||||||
|
},
|
||||||
|
"navbar": {
|
||||||
|
"home": "Principal",
|
||||||
|
"browse": "Navegar",
|
||||||
|
"download": "Descargar",
|
||||||
|
"search": "Buscar",
|
||||||
|
"login": "Acceder",
|
||||||
|
"admin": "Panel de administración"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"general": {
|
||||||
|
"label": "General",
|
||||||
|
"theme": {
|
||||||
|
"label": "Tema",
|
||||||
|
"description": "Establece el tema de tu aplicación",
|
||||||
|
"auto": "Sistema",
|
||||||
|
"light": "Claro",
|
||||||
|
"dark": "Oscuro"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"label": "Idioma",
|
||||||
|
"description": "Establecer el idioma de tu aplicación",
|
||||||
|
"system": "Sistema"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"playback": {
|
||||||
|
"label": "Reproducción",
|
||||||
|
"playmode": {
|
||||||
|
"label": "Modo de reproducción predeterminado",
|
||||||
|
"description": "El modo de reproducción predeterminado utilizado con este cliente. La calidad original requiere menos recursos en el servidor, pero no permite cambios automáticos de calidad"
|
||||||
|
},
|
||||||
|
"audioLanguage": {
|
||||||
|
"label": "Idioma del audio",
|
||||||
|
"description": "El idioma del audio predeterminado utilizado al reproducir videos con múltiples pistas de audio"
|
||||||
|
},
|
||||||
|
"subtitleLanguage": {
|
||||||
|
"label": "Idioma de los subtítulos",
|
||||||
|
"description": "Idioma de los subtítulos utilizado por defecto",
|
||||||
|
"none": "Ninguno"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"label": "Cuenta",
|
||||||
|
"username": {
|
||||||
|
"label": "Nombre de usuario"
|
||||||
|
},
|
||||||
|
"avatar": {
|
||||||
|
"label": "Avatar",
|
||||||
|
"description": "Cambiar icono del perfil"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"label": "Correo electrónico"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"label": "Contraseña",
|
||||||
|
"description": "Cambiar tu contraseña",
|
||||||
|
"oldPassword": "Contraseña anterior",
|
||||||
|
"newPassword": "Contraseña nueva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oidc": {
|
||||||
|
"label": "Cuentas vinculadas",
|
||||||
|
"connected": "Conectad@ como {{username}}.",
|
||||||
|
"not-connected": "Desconectad@",
|
||||||
|
"open-profile": "Abre tu perfil de {{provider}}",
|
||||||
|
"link": "Enlace",
|
||||||
|
"delete": "Desvincula tu cuenta de kyoo con tu cuenta de {{provider}}"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"label": "Acerca de",
|
||||||
|
"android-app": {
|
||||||
|
"label": "Aplicación de Android",
|
||||||
|
"description": "Descarga la aplicación de Android"
|
||||||
|
},
|
||||||
|
"git": {
|
||||||
|
"label": "Github",
|
||||||
|
"description": "Abre el repositorio de GitHub donde puedes explorar el código de Kyoo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"back": "Atrás",
|
||||||
|
"previous": "Episodio anterior",
|
||||||
|
"next": "Siguiente episodio",
|
||||||
|
"play": "Iniciar",
|
||||||
|
"pause": "Pausar",
|
||||||
|
"mute": "Alternar silencio",
|
||||||
|
"volume": "Volumen",
|
||||||
|
"quality": "Calidad",
|
||||||
|
"audios": "Audio",
|
||||||
|
"subtitles": "Subtítulos",
|
||||||
|
"subtitle-none": "Ninguno",
|
||||||
|
"fullscreen": "Pantalla completa",
|
||||||
|
"direct": "Prístino",
|
||||||
|
"transmux": "Original",
|
||||||
|
"auto": "Auto",
|
||||||
|
"notInPristine": "No disponible en prístino",
|
||||||
|
"unsupportedError": "Códec de video no compatible, transcodificación en progreso..."
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"empty": "Sin resultados. Intente otra búsqueda."
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"login": "Iniciar sesión",
|
||||||
|
"register": "Registrarse",
|
||||||
|
"guest": "Continuar como invitado",
|
||||||
|
"guest-forbidden": "Esta instancia de Kyoo no permite invitados.",
|
||||||
|
"via": "Continuar con {{provider}}",
|
||||||
|
"add-account": "Agregar cuenta",
|
||||||
|
"logout": "Desconectarse",
|
||||||
|
"server": "Dirección del servidor",
|
||||||
|
"email": "Correo electrónico",
|
||||||
|
"username": "Usuario",
|
||||||
|
"password": "Contraseña",
|
||||||
|
"confirm": "Confirmar Contraseña",
|
||||||
|
"or-register": "¿No tienes una cuenta? <1>Regístrate</1>.",
|
||||||
|
"or-login": "¿Ya tienes una cuenta? <1>Iniciar sesión</1>.",
|
||||||
|
"password-no-match": "La contraseña no coincide.",
|
||||||
|
"delete": "Elimine su cuenta",
|
||||||
|
"delete-confirmation": "Esta acción no se puede deshacer. ¿Está seguro?"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"empty": "Nada descargado aún, empieza a buscar algo que te guste",
|
||||||
|
"error": "Error: {{error}}",
|
||||||
|
"delete": "Eliminar elemento",
|
||||||
|
"deleteMessage": "¿Quieres eliminar este elemento de tu almacenamiento local?",
|
||||||
|
"pause": "Pausa",
|
||||||
|
"resume": "Reanudar",
|
||||||
|
"retry": "Reintentar"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"connection": "No se pudo conectar al servidor de Kyoo",
|
||||||
|
"connection-tips": "Consejos para solucionar problemas:\n - ¿Estás conectado a Internet?\n - ¿Está en línea tu servidor de kyoo?\n - ¿Tu cuenta ha sido baneada?",
|
||||||
|
"unknown": "Error desconocido",
|
||||||
|
"try-again": "Intentar nuevamente",
|
||||||
|
"re-login": "Reiniciar sesión",
|
||||||
|
"offline": "No estás conectado a internet. Intenta de nuevo más tarde.",
|
||||||
|
"unauthorized": "Faltan los permisos {{permission}} para acceder a esta página.",
|
||||||
|
"needVerification": "Tu cuenta debe ser verificada por el administrador del servidor antes de que puedas usarla.",
|
||||||
|
"needAccount": "Esta página no se puede acceder en modo invitado. Necesitas crear una cuenta o iniciar sesión.",
|
||||||
|
"setup": {
|
||||||
|
"MissingAdminAccount": "Aún no se ha creado ninguna cuenta de administrador. Por favor, regístrate para crear una.",
|
||||||
|
"NoVideoFound": "No se encontró ningún video aún. ¡Añade películas o series dentro de la carpeta de tu biblioteca para que se muestren aquí!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mediainfo": {
|
||||||
|
"file": "Archivo",
|
||||||
|
"container": "Recipiente",
|
||||||
|
"video": "Video",
|
||||||
|
"audio": "Audio",
|
||||||
|
"subtitles": "Subtítulos",
|
||||||
|
"forced": "Forzado",
|
||||||
|
"default": "Predeterminado",
|
||||||
|
"external": "Externo",
|
||||||
|
"duration": "Duración",
|
||||||
|
"size": "Tamaño",
|
||||||
|
"novideo": "Sin vídeo",
|
||||||
|
"nocontainer": "Contenedor invalido"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"users": {
|
||||||
|
"label": "Usuarios",
|
||||||
|
"adminUser": "Administrador",
|
||||||
|
"regularUser": "Usuario",
|
||||||
|
"set-permissions": "Establecer permisos",
|
||||||
|
"delete": "Eliminar usuario",
|
||||||
|
"unverifed": "Sin verificar",
|
||||||
|
"verify": "Verificar usuario"
|
||||||
|
},
|
||||||
|
"scanner": {
|
||||||
|
"label": "Escáner",
|
||||||
|
"scan": "Iniciar escaneo de la biblioteca",
|
||||||
|
"empty": "No se encontraron problemas. Todos tus elementos están registrados."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,54 +1,54 @@
|
|||||||
{
|
{
|
||||||
"home": {
|
"home": {
|
||||||
"recommended": "",
|
"recommended": "Raccomandati",
|
||||||
"news": "",
|
"news": "",
|
||||||
"watchlist": "",
|
"watchlist": "Continua a guardare",
|
||||||
"info": "",
|
"info": "",
|
||||||
"none": "",
|
"none": "Nessun episodio",
|
||||||
"watchlistLogin": "",
|
"watchlistLogin": "",
|
||||||
"refreshMetadata": "",
|
"refreshMetadata": "Aggiorna metadata",
|
||||||
"episodeMore": {
|
"episodeMore": {
|
||||||
"goToShow": "",
|
"goToShow": "Vai allo show",
|
||||||
"download": "",
|
"download": "",
|
||||||
"mediainfo": ""
|
"mediainfo": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"play": "",
|
"play": "Riproduci",
|
||||||
"trailer": "",
|
"trailer": "Riproduci Trailer",
|
||||||
"studio": "",
|
"studio": "",
|
||||||
"genre": "",
|
"genre": "Generi",
|
||||||
"genre-none": "",
|
"genre-none": "Nessun genere",
|
||||||
"staff": "",
|
"staff": "",
|
||||||
"staff-none": "",
|
"staff-none": "",
|
||||||
"noOverview": "",
|
"noOverview": "",
|
||||||
"episode-none": "",
|
"episode-none": "Non ci sono episodi in questa stagione",
|
||||||
"episodeNoMetadata": "",
|
"episodeNoMetadata": "",
|
||||||
"tags": "",
|
"tags": "",
|
||||||
"links": "",
|
"links": "Link",
|
||||||
"jumpToSeason": "",
|
"jumpToSeason": "",
|
||||||
"partOf": "",
|
"partOf": "",
|
||||||
"watchlistAdd": "",
|
"watchlistAdd": "",
|
||||||
"watchlistEdit": "",
|
"watchlistEdit": "",
|
||||||
"watchlistRemove": "",
|
"watchlistRemove": "Segna come non visto",
|
||||||
"watchlistLogin": "",
|
"watchlistLogin": "",
|
||||||
"watchlistMark": {
|
"watchlistMark": {
|
||||||
"completed": "",
|
"completed": "Segna come completato",
|
||||||
"planned": "",
|
"planned": "",
|
||||||
"watching": "",
|
"watching": "",
|
||||||
"droped": "",
|
"droped": "",
|
||||||
"null": ""
|
"null": "Segna come non visto"
|
||||||
},
|
},
|
||||||
"nextUp": "",
|
"nextUp": "Prossimo",
|
||||||
"season": ""
|
"season": "Stagione {{number}}"
|
||||||
},
|
},
|
||||||
"browse": {
|
"browse": {
|
||||||
"sortby": "",
|
"sortby": "Ordina per {{key}}",
|
||||||
"sortby-tt": "",
|
"sortby-tt": "Ordina per",
|
||||||
"sortkey": {
|
"sortkey": {
|
||||||
"relevance": "",
|
"relevance": "",
|
||||||
"name": "",
|
"name": "Nome",
|
||||||
"airDate": "",
|
"airDate": "Data di trasmissione",
|
||||||
"startAir": "",
|
"startAir": "",
|
||||||
"endAir": "",
|
"endAir": "",
|
||||||
"addedDate": "",
|
"addedDate": "",
|
||||||
@ -59,46 +59,52 @@
|
|||||||
"desc": ""
|
"desc": ""
|
||||||
},
|
},
|
||||||
"switchToGrid": "",
|
"switchToGrid": "",
|
||||||
"switchToList": ""
|
"switchToList": "",
|
||||||
|
"mediatypekey": {
|
||||||
|
"all": "Tutti",
|
||||||
|
"movie": "Film",
|
||||||
|
"show": "Serie TV",
|
||||||
|
"collection": "Collezione"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"settings": "",
|
"settings": "Impostazioni",
|
||||||
"prev-page": "",
|
"prev-page": "Pagina precedente",
|
||||||
"next-page": "",
|
"next-page": "Pagina successiva",
|
||||||
"delete": "",
|
"delete": "Elimina",
|
||||||
"cancel": "",
|
"cancel": "Annulla",
|
||||||
"more": "",
|
"more": "Di più",
|
||||||
"expand": "",
|
"expand": "Espandi",
|
||||||
"collapse": "",
|
"collapse": "",
|
||||||
"edit": "",
|
"edit": "Modifica",
|
||||||
"or": "",
|
"or": "",
|
||||||
"loading": ""
|
"loading": "Caricamento"
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
"home": "",
|
"home": "Home",
|
||||||
"browse": "",
|
"browse": "Cerca",
|
||||||
"search": "",
|
"search": "Ricerca",
|
||||||
"login": "",
|
"login": "Login",
|
||||||
"admin": ""
|
"admin": "Pannello amministratore"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"general": {
|
"general": {
|
||||||
"label": "",
|
"label": "Generale",
|
||||||
"theme": {
|
"theme": {
|
||||||
"label": "",
|
"label": "Tema",
|
||||||
"description": "",
|
"description": "Imposta il tema della tua applicazione",
|
||||||
"auto": "",
|
"auto": "Sistema",
|
||||||
"light": "",
|
"light": "Chiaro",
|
||||||
"dark": ""
|
"dark": "Scuro"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"label": "",
|
"label": "Lingua",
|
||||||
"description": "",
|
"description": "Imposta la lingua della tua applicazione",
|
||||||
"system": ""
|
"system": "Sistema"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"playback": {
|
"playback": {
|
||||||
"label": "",
|
"label": "Riproduzione",
|
||||||
"playmode": {
|
"playmode": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"description": ""
|
"description": ""
|
||||||
@ -241,5 +247,22 @@
|
|||||||
"scan": "",
|
"scan": "",
|
||||||
"empty": ""
|
"empty": ""
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"genres": {
|
||||||
|
"Mystery": "Mistero",
|
||||||
|
"Kids": "Bambini",
|
||||||
|
"Western": "Western",
|
||||||
|
"History": "Storico",
|
||||||
|
"Romance": "Romantico",
|
||||||
|
"ScienceFiction": "Fantascienza",
|
||||||
|
"Thriller": "Thriller",
|
||||||
|
"War": "Guerra",
|
||||||
|
"Animation": "Animazione",
|
||||||
|
"Action": "Azione",
|
||||||
|
"Adventure": "Avventura",
|
||||||
|
"Comedy": "Commedia",
|
||||||
|
"Crime": "Criminale",
|
||||||
|
"Documentary": "Documentario",
|
||||||
|
"Drama": "Drammatico"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -237,7 +237,8 @@
|
|||||||
"duration": "Uzunluk",
|
"duration": "Uzunluk",
|
||||||
"size": "Boyut",
|
"size": "Boyut",
|
||||||
"novideo": "Video yok",
|
"novideo": "Video yok",
|
||||||
"nocontainer": "Geçersiz kapsayıcı"
|
"nocontainer": "Geçersiz kapsayıcı",
|
||||||
|
"external": "Harici"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"users": {
|
"users": {
|
||||||
|
@ -26,6 +26,9 @@ class KyooClient:
|
|||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
jsons.set_serializer(lambda x, **_: format_date(x), type[Optional[date | int]])
|
jsons.set_serializer(lambda x, **_: format_date(x), type[Optional[date | int]])
|
||||||
self.client = ClientSession(
|
self.client = ClientSession(
|
||||||
|
headers={
|
||||||
|
"User-Agent": "kyoo",
|
||||||
|
},
|
||||||
json_serialize=lambda *args, **kwargs: jsons.dumps(
|
json_serialize=lambda *args, **kwargs: jsons.dumps(
|
||||||
*args, key_transformer=jsons.KEY_TRANSFORMER_CAMELCASE, **kwargs
|
*args, key_transformer=jsons.KEY_TRANSFORMER_CAMELCASE, **kwargs
|
||||||
),
|
),
|
||||||
|
@ -26,4 +26,7 @@ POSTGRES_PASSWORD=
|
|||||||
POSTGRES_DB=
|
POSTGRES_DB=
|
||||||
POSTGRES_SERVER=
|
POSTGRES_SERVER=
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
# (the schema "gocoder" will be used)
|
# Default is gocoder, you can specify "disabled" to use the default search_path of the user.
|
||||||
|
# If this is not "disabled", the schema will be created (if it does not exists) and
|
||||||
|
# the search_path of the user will be ignored (only the schema specified will be used).
|
||||||
|
POSTGRES_SCHEMA=gocoder
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
# FROM golang:1.22 as build
|
# FROM golang:1.22 as build
|
||||||
FROM debian:trixie-slim as build
|
FROM debian:trixie-slim AS build
|
||||||
# those were copied from https://github.com/docker-library/golang/blob/master/Dockerfile-linux.template
|
# those were copied from https://github.com/docker-library/golang/blob/master/Dockerfile-linux.template
|
||||||
ENV GOTOOLCHAIN=local
|
ENV GOTOOLCHAIN=local
|
||||||
ENV GOPATH /go
|
ENV GOPATH=/go
|
||||||
ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH
|
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
apt-get update; \
|
apt-get update; \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
|
@ -5,8 +5,8 @@
|
|||||||
FROM debian:trixie-slim
|
FROM debian:trixie-slim
|
||||||
# those were copied from https://github.com/docker-library/golang/blob/master/Dockerfile-linux.template
|
# those were copied from https://github.com/docker-library/golang/blob/master/Dockerfile-linux.template
|
||||||
ENV GOTOOLCHAIN=local
|
ENV GOTOOLCHAIN=local
|
||||||
ENV GOPATH /go
|
ENV GOPATH=/go
|
||||||
ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH
|
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
apt-get update; \
|
apt-get update; \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
|
@ -25,6 +25,6 @@ require (
|
|||||||
golang.org/x/image v0.19.0 // indirect
|
golang.org/x/image v0.19.0 // indirect
|
||||||
golang.org/x/net v0.28.0 // indirect
|
golang.org/x/net v0.28.0 // indirect
|
||||||
golang.org/x/sys v0.23.0 // indirect
|
golang.org/x/sys v0.23.0 // indirect
|
||||||
golang.org/x/text v0.17.0
|
golang.org/x/text v0.18.0
|
||||||
golang.org/x/time v0.6.0 // indirect
|
golang.org/x/time v0.6.0 // indirect
|
||||||
)
|
)
|
||||||
|
@ -76,8 +76,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
|
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
|
||||||
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||||
|
@ -9,6 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
@ -36,7 +37,7 @@ type MediaInfo struct {
|
|||||||
/// The file size of the video file.
|
/// The file size of the video file.
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
/// The length of the media in seconds.
|
/// The length of the media in seconds.
|
||||||
Duration float32 `json:"duration"`
|
Duration float64 `json:"duration"`
|
||||||
/// The container of the video file of this episode.
|
/// The container of the video file of this episode.
|
||||||
Container *string `json:"container"`
|
Container *string `json:"container"`
|
||||||
/// Version of the metadata. This can be used to invalidate older metadata from db if the extraction code has changed.
|
/// Version of the metadata. This can be used to invalidate older metadata from db if the extraction code has changed.
|
||||||
@ -55,6 +56,9 @@ type MediaInfo struct {
|
|||||||
Fonts []string `json:"fonts"`
|
Fonts []string `json:"fonts"`
|
||||||
/// The list of chapters. See Chapter for more information.
|
/// The list of chapters. See Chapter for more information.
|
||||||
Chapters []Chapter `json:"chapters"`
|
Chapters []Chapter `json:"chapters"`
|
||||||
|
|
||||||
|
/// lock used to read/set keyframes of video/audio
|
||||||
|
lock sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type Video struct {
|
type Video struct {
|
||||||
@ -238,7 +242,7 @@ func RetriveMediaInfo(path string, sha string) (*MediaInfo, error) {
|
|||||||
// Remove leading .
|
// Remove leading .
|
||||||
Extension: filepath.Ext(path)[1:],
|
Extension: filepath.Ext(path)[1:],
|
||||||
Size: ParseInt64(mi.Format.Size),
|
Size: ParseInt64(mi.Format.Size),
|
||||||
Duration: float32(mi.Format.DurationSeconds),
|
Duration: mi.Format.DurationSeconds,
|
||||||
Container: OrNull(mi.Format.FormatName),
|
Container: OrNull(mi.Format.FormatName),
|
||||||
Versions: Versions{
|
Versions: Versions{
|
||||||
Info: InfoVersion,
|
Info: InfoVersion,
|
||||||
|
@ -2,6 +2,7 @@ package src
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@ -88,11 +89,17 @@ type KeyframeKey struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *MetadataService) GetKeyframes(info *MediaInfo, isVideo bool, idx uint32) (*Keyframe, error) {
|
func (s *MetadataService) GetKeyframes(info *MediaInfo, isVideo bool, idx uint32) (*Keyframe, error) {
|
||||||
|
info.lock.Lock()
|
||||||
|
var ret *Keyframe
|
||||||
if isVideo && info.Videos[idx].Keyframes != nil {
|
if isVideo && info.Videos[idx].Keyframes != nil {
|
||||||
return info.Videos[idx].Keyframes, nil
|
ret = info.Videos[idx].Keyframes
|
||||||
}
|
}
|
||||||
if !isVideo && info.Audios[idx].Keyframes != nil {
|
if !isVideo && info.Audios[idx].Keyframes != nil {
|
||||||
return info.Audios[idx].Keyframes, nil
|
ret = info.Audios[idx].Keyframes
|
||||||
|
}
|
||||||
|
info.lock.Unlock()
|
||||||
|
if ret != nil {
|
||||||
|
return ret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
get_running, set := s.keyframeLock.Start(KeyframeKey{
|
get_running, set := s.keyframeLock.Start(KeyframeKey{
|
||||||
@ -110,6 +117,14 @@ func (s *MetadataService) GetKeyframes(info *MediaInfo, isVideo bool, idx uint32
|
|||||||
}
|
}
|
||||||
kf.info.ready.Add(1)
|
kf.info.ready.Add(1)
|
||||||
|
|
||||||
|
info.lock.Lock()
|
||||||
|
if isVideo {
|
||||||
|
info.Videos[idx].Keyframes = kf
|
||||||
|
} else {
|
||||||
|
info.Audios[idx].Keyframes = kf
|
||||||
|
}
|
||||||
|
info.lock.Unlock()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
var table string
|
var table string
|
||||||
var err error
|
var err error
|
||||||
@ -122,7 +137,7 @@ func (s *MetadataService) GetKeyframes(info *MediaInfo, isVideo bool, idx uint32
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Couldn't retrive keyframes for %s %s %d: %v", info.Path, table, idx, err)
|
log.Printf("Couldn't retrieve keyframes for %s %s %d: %v", info.Path, table, idx, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,6 +172,8 @@ func getVideoKeyframes(path string, video_idx uint32, kf *Keyframe) error {
|
|||||||
"-loglevel", "error",
|
"-loglevel", "error",
|
||||||
"-select_streams", fmt.Sprintf("V:%d", video_idx),
|
"-select_streams", fmt.Sprintf("V:%d", video_idx),
|
||||||
"-show_entries", "packet=pts_time,flags",
|
"-show_entries", "packet=pts_time,flags",
|
||||||
|
// some avi files don't have pts, we use this to ask ffmpeg to generate them (it uses the dts under the hood)
|
||||||
|
"-fflags", "+genpts",
|
||||||
"-of", "csv=print_section=0",
|
"-of", "csv=print_section=0",
|
||||||
path,
|
path,
|
||||||
)
|
)
|
||||||
@ -191,6 +208,8 @@ func getVideoKeyframes(path string, video_idx uint32, kf *Keyframe) error {
|
|||||||
pts, flags := x[0], x[1]
|
pts, flags := x[0], x[1]
|
||||||
|
|
||||||
// true if there is no keyframes (e.g. in a file w/o video track)
|
// true if there is no keyframes (e.g. in a file w/o video track)
|
||||||
|
// can also happen if a video has more packets than frames (so the last packet
|
||||||
|
// is emtpy and has a N/A pts)
|
||||||
if pts == "N/A" {
|
if pts == "N/A" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -231,16 +250,89 @@ func getVideoKeyframes(path string, video_idx uint32, kf *Keyframe) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DummyKeyframeDuration = float64(4)
|
||||||
|
|
||||||
// we can pretty much cut audio at any point so no need to get specific frames, just cut every 4s
|
// we can pretty much cut audio at any point so no need to get specific frames, just cut every 4s
|
||||||
func getAudioKeyframes(info *MediaInfo, audio_idx uint32, kf *Keyframe) error {
|
func getAudioKeyframes(info *MediaInfo, audio_idx uint32, kf *Keyframe) error {
|
||||||
dummyKeyframeDuration := float64(4)
|
defer printExecTime("ffprobe keyframe analysis for %s audio n%d", info.Path, audio_idx)()
|
||||||
segmentCount := int((float64(info.Duration) / dummyKeyframeDuration) + 1)
|
// Format's duration CAN be different than audio's duration. To make sure we do not
|
||||||
kf.Keyframes = make([]float64, segmentCount)
|
// miss a segment or make one more, we need to check the audio's duration.
|
||||||
for segmentIndex := 0; segmentIndex < segmentCount; segmentIndex += 1 {
|
//
|
||||||
kf.Keyframes[segmentIndex] = float64(segmentIndex) * dummyKeyframeDuration
|
// Since fetching the duration requires reading packets and is SLOW, we start by generating
|
||||||
|
// keyframes until a reasonably safe point of the file (if the format has a 20min duration, audio
|
||||||
|
// probably has a close duration).
|
||||||
|
// You can read why duration retrieval is slow on the comment below.
|
||||||
|
safe_duration := info.Duration - 20
|
||||||
|
segment_count := int((safe_duration / DummyKeyframeDuration) + 1)
|
||||||
|
if segment_count > 0 {
|
||||||
|
kf.Keyframes = make([]float64, segment_count)
|
||||||
|
for i := 0; i < segment_count; i += 1 {
|
||||||
|
kf.Keyframes[i] = float64(i) * DummyKeyframeDuration
|
||||||
|
}
|
||||||
|
kf.info.ready.Done()
|
||||||
|
} else {
|
||||||
|
segment_count = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some formats DO NOT contain a duration metadata, we need to manually fetch it
|
||||||
|
// from the packets.
|
||||||
|
//
|
||||||
|
// We could use the same command to retrieve all packets and know when we can cut PRECISELY
|
||||||
|
// but since packets always contain only a few ms we don't need this precision.
|
||||||
|
cmd := exec.Command(
|
||||||
|
"ffprobe",
|
||||||
|
"-select_streams", fmt.Sprintf("a:%d", audio_idx),
|
||||||
|
"-show_entries", "packet=pts_time",
|
||||||
|
// some avi files don't have pts, we use this to ask ffmpeg to generate them (it uses the dts under the hood)
|
||||||
|
"-fflags", "+genpts",
|
||||||
|
// We use a read_interval LARGER than the file (at least we estimate)
|
||||||
|
// This allows us to only decode the LAST packets
|
||||||
|
"-read_intervals", fmt.Sprintf("%f", info.Duration+10_000),
|
||||||
|
"-of", "csv=print_section=0",
|
||||||
|
info.Path,
|
||||||
|
)
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
var duration float64
|
||||||
|
for scanner.Scan() {
|
||||||
|
pts := scanner.Text()
|
||||||
|
if pts == "" || pts == "N/A" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
duration, err = strconv.ParseFloat(pts, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if duration <= 0 {
|
||||||
|
return errors.New("could not find audio's duration")
|
||||||
|
}
|
||||||
|
|
||||||
|
new_seg_count := int((duration / DummyKeyframeDuration) + 1)
|
||||||
|
if new_seg_count > segment_count {
|
||||||
|
new_segments := make([]float64, new_seg_count-segment_count)
|
||||||
|
for i := segment_count; i < new_seg_count; i += 1 {
|
||||||
|
new_segments[i-segment_count] = float64(i) * DummyKeyframeDuration
|
||||||
|
}
|
||||||
|
kf.add(new_segments)
|
||||||
|
if segment_count == 0 {
|
||||||
|
kf.info.ready.Done()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kf.IsDone = true
|
kf.IsDone = true
|
||||||
kf.info.ready.Done()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -24,21 +24,28 @@ type MetadataService struct {
|
|||||||
|
|
||||||
func NewMetadataService() (*MetadataService, error) {
|
func NewMetadataService() (*MetadataService, error) {
|
||||||
con := fmt.Sprintf(
|
con := fmt.Sprintf(
|
||||||
"postgresql://%v:%v@%v:%v/%v?application_name=gocoder&search_path=gocoder&sslmode=disable",
|
"postgresql://%v:%v@%v:%v/%v?application_name=gocoder&sslmode=disable",
|
||||||
url.QueryEscape(os.Getenv("POSTGRES_USER")),
|
url.QueryEscape(os.Getenv("POSTGRES_USER")),
|
||||||
url.QueryEscape(os.Getenv("POSTGRES_PASSWORD")),
|
url.QueryEscape(os.Getenv("POSTGRES_PASSWORD")),
|
||||||
url.QueryEscape(os.Getenv("POSTGRES_SERVER")),
|
url.QueryEscape(os.Getenv("POSTGRES_SERVER")),
|
||||||
url.QueryEscape(os.Getenv("POSTGRES_PORT")),
|
url.QueryEscape(os.Getenv("POSTGRES_PORT")),
|
||||||
url.QueryEscape(os.Getenv("POSTGRES_DB")),
|
url.QueryEscape(os.Getenv("POSTGRES_DB")),
|
||||||
)
|
)
|
||||||
|
schema := GetEnvOr("POSTGRES_SCHEMA", "gocoder")
|
||||||
|
if schema != "disabled" {
|
||||||
|
con = fmt.Sprintf("%s&search_path=%s", con, url.QueryEscape(schema))
|
||||||
|
}
|
||||||
db, err := sql.Open("postgres", con)
|
db, err := sql.Open("postgres", con)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("Could not connect to database, check your env variables!")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.Exec("create schema if not exists gocoder")
|
if schema != "disabled" {
|
||||||
if err != nil {
|
_, err = db.Exec(fmt.Sprintf("create schema if not exists %s", schema))
|
||||||
return nil, err
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
driver, err := postgres.WithInstance(db, &postgres.Config{})
|
driver, err := postgres.WithInstance(db, &postgres.Config{})
|
||||||
|
@ -238,6 +238,11 @@ func (ts *Stream) run(start int32) error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
args = append(args,
|
args = append(args,
|
||||||
|
// some avi files are missing pts, using this flag makes ffmpeg use dts as pts and prevents an error with
|
||||||
|
// -c:v copy. Only issue: pts is sometime wrong (+1fps than expected) and this leads to some clients refusing
|
||||||
|
// to play the file (they just switch back to the previous quality).
|
||||||
|
// since this is better than errorring or not supporting transmux at all, i'll keep it here for now.
|
||||||
|
"-fflags", "+genpts",
|
||||||
"-i", ts.file.Info.Path,
|
"-i", ts.file.Info.Path,
|
||||||
// this makes behaviors consistent between soft and hardware decodes.
|
// this makes behaviors consistent between soft and hardware decodes.
|
||||||
// this also means that after a -ss 50, the output video will start at 50s
|
// this also means that after a -ss 50, the output video will start at 50s
|
||||||
|
Loading…
x
Reference in New Issue
Block a user