Merge branch 'zoriya:master' into feature/helmchart

This commit is contained in:
acelinkio 2024-09-23 07:59:02 -07:00 committed by GitHub
commit df07e75737
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 693 additions and 89 deletions

179
api/README.md Normal file
View 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
```

View File

@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "8.0.7",
"version": "8.0.8",
"commands": [
"dotnet-ef"
]

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<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="Serilog" Version="4.0.1" />

View File

@ -23,7 +23,7 @@
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="3.0.2" />
<PackageReference Include="SkiaSharp" 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>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@ -9,11 +9,11 @@
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3" />
<PackageReference Include="EntityFrameworkCore.Projectables" Version="4.1.4-prebeta" />
<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>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</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" />
</ItemGroup>

View File

@ -191,7 +191,7 @@ export const AccountProvider = ({
<AccountContext.Provider value={accounts}>
<ConnectionErrorContext.Provider
value={{
error: (selected ? initialSsrError.current ?? userError : null) ?? permissionError,
error: (selected ? (initialSsrError.current ?? userError) : null) ?? permissionError,
loading: userIsLoading,
retry: () => {
queryClient.invalidateQueries({ queryKey: ["auth", "me"] });

View File

@ -61,7 +61,8 @@ export const SnackbarProvider = ({ children }: { children: ReactElement | ReactE
return;
}
addPortal("snackbar", <Snackbar {...top} />);
const { key, ...props } = top;
addPortal("snackbar", <Snackbar key={key} {...props} />);
timeout.current = setTimeout(() => {
removePortal("snackbar");
updatePortal();

View File

@ -56,7 +56,7 @@ const MediaTypeTrigger = forwardRef<View, PressableProps & { mediaType: MediaTyp
const { t } = useTranslation();
const labelKey =
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 (
<PressableFeedback
ref={ref}

View File

@ -53,11 +53,11 @@ export const itemMap = (
href: item.href,
poster: item.poster,
thumbnail: item.thumbnail,
watchStatus: item.kind !== "collection" ? item.watchStatus?.status ?? null : null,
watchStatus: item.kind !== "collection" ? (item.watchStatus?.status ?? null) : null,
type: item.kind,
watchPercent: item.kind !== "collection" ? item.watchStatus?.watchedPercent ?? null : null,
watchPercent: item.kind !== "collection" ? (item.watchStatus?.watchedPercent ?? null) : null,
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 => {

View File

@ -178,7 +178,7 @@ export const CollectionPage: QueryPage<{ slug: string }> = ({ slug }) => {
watchStatus={(item.kind !== "collection" && item.watchStatus?.status) || null}
unseenEpisodesCount={
item.kind === "show"
? item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!
? (item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!)
: null
}
{...css({ marginX: ItemGrid.layout.gap })}

View File

@ -307,7 +307,7 @@ export const Recommended = () => {
watchStatus={(item.kind !== "collection" && item.watchStatus?.status) || null}
unseenEpisodesCount={
item.kind === "show"
? item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!
? (item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!)
: null
}
/>

View File

@ -19,7 +19,7 @@
*/
import Svg, { type SvgProps, Path } from "react-native-svg";
import { useYoshiki } from "yoshiki";
import { useYoshiki } from "yoshiki/native";
/* export const KyooLogo = (props: SvgProps) => ( */
/* <Svg viewBox="0 0 128 128" {...props}> */

View File

@ -92,7 +92,8 @@ export const Player = ({
const [playbackError, setPlaybackError] = useState<string | undefined>(undefined);
const { data, error } = useFetch(Player.query(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 =
data && data.type === "episode" && data.previousEpisode
? `/watch/${data.previousEpisode.slug}?t=0`

View File

@ -147,7 +147,7 @@ export const AudiosMenu = ({ audios, ...props }: CustomMenu & { audios?: Audio[]
{info.audioTracks.map((x) => (
<Menu.Item
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}
onSelect={() => setAudio(x as any)}
/>

View File

@ -71,7 +71,7 @@ export const GeneralSettings = () => {
onValueChange={(value) => changeLanguage(value)}
values={["system", ...Object.keys(i18n.options.resources!)]}
getLabel={(key) =>
key === "system" ? t("settings.general.language.system") : getLanguageName(key) ?? key
key === "system" ? t("settings.general.language.system") : (getLanguageName(key) ?? key)
}
/>
</Preference>

View File

@ -64,7 +64,7 @@ export const PlaybackSettings = () => {
onValueChange={(value) => setAudio(value)}
values={["default", ...allLanguages]}
getLabel={(key) =>
key === "default" ? t("mediainfo.default") : getLanguageName(key) ?? key
key === "default" ? t("mediainfo.default") : (getLanguageName(key) ?? key)
}
/>
</Preference>
@ -83,7 +83,7 @@ export const PlaybackSettings = () => {
? t("settings.playback.subtitleLanguage.none")
: key === "default"
? t("mediainfo.default")
: getLanguageName(key) ?? key
: (getLanguageName(key) ?? key)
}
/>
</Preference>

285
front/translations/es.json Normal file
View 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."
}
}
}

View File

@ -1,54 +1,54 @@
{
"home": {
"recommended": "",
"recommended": "Raccomandati",
"news": "",
"watchlist": "",
"watchlist": "Continua a guardare",
"info": "",
"none": "",
"none": "Nessun episodio",
"watchlistLogin": "",
"refreshMetadata": "",
"refreshMetadata": "Aggiorna metadata",
"episodeMore": {
"goToShow": "",
"goToShow": "Vai allo show",
"download": "",
"mediainfo": ""
}
},
"show": {
"play": "",
"trailer": "",
"play": "Riproduci",
"trailer": "Riproduci Trailer",
"studio": "",
"genre": "",
"genre-none": "",
"genre": "Generi",
"genre-none": "Nessun genere",
"staff": "",
"staff-none": "",
"noOverview": "",
"episode-none": "",
"episode-none": "Non ci sono episodi in questa stagione",
"episodeNoMetadata": "",
"tags": "",
"links": "",
"links": "Link",
"jumpToSeason": "",
"partOf": "",
"watchlistAdd": "",
"watchlistEdit": "",
"watchlistRemove": "",
"watchlistRemove": "Segna come non visto",
"watchlistLogin": "",
"watchlistMark": {
"completed": "",
"completed": "Segna come completato",
"planned": "",
"watching": "",
"droped": "",
"null": ""
"null": "Segna come non visto"
},
"nextUp": "",
"season": ""
"nextUp": "Prossimo",
"season": "Stagione {{number}}"
},
"browse": {
"sortby": "",
"sortby-tt": "",
"sortby": "Ordina per {{key}}",
"sortby-tt": "Ordina per",
"sortkey": {
"relevance": "",
"name": "",
"airDate": "",
"name": "Nome",
"airDate": "Data di trasmissione",
"startAir": "",
"endAir": "",
"addedDate": "",
@ -59,46 +59,52 @@
"desc": ""
},
"switchToGrid": "",
"switchToList": ""
"switchToList": "",
"mediatypekey": {
"all": "Tutti",
"movie": "Film",
"show": "Serie TV",
"collection": "Collezione"
}
},
"misc": {
"settings": "",
"prev-page": "",
"next-page": "",
"delete": "",
"cancel": "",
"more": "",
"expand": "",
"settings": "Impostazioni",
"prev-page": "Pagina precedente",
"next-page": "Pagina successiva",
"delete": "Elimina",
"cancel": "Annulla",
"more": "Di più",
"expand": "Espandi",
"collapse": "",
"edit": "",
"edit": "Modifica",
"or": "",
"loading": ""
"loading": "Caricamento"
},
"navbar": {
"home": "",
"browse": "",
"search": "",
"login": "",
"admin": ""
"home": "Home",
"browse": "Cerca",
"search": "Ricerca",
"login": "Login",
"admin": "Pannello amministratore"
},
"settings": {
"general": {
"label": "",
"label": "Generale",
"theme": {
"label": "",
"description": "",
"auto": "",
"light": "",
"dark": ""
"label": "Tema",
"description": "Imposta il tema della tua applicazione",
"auto": "Sistema",
"light": "Chiaro",
"dark": "Scuro"
},
"language": {
"label": "",
"description": "",
"system": ""
"label": "Lingua",
"description": "Imposta la lingua della tua applicazione",
"system": "Sistema"
}
},
"playback": {
"label": "",
"label": "Riproduzione",
"playmode": {
"label": "",
"description": ""
@ -241,5 +247,22 @@
"scan": "",
"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"
}
}

View File

@ -237,7 +237,8 @@
"duration": "Uzunluk",
"size": "Boyut",
"novideo": "Video yok",
"nocontainer": "Geçersiz kapsayıcı"
"nocontainer": "Geçersiz kapsayıcı",
"external": "Harici"
},
"admin": {
"users": {

View File

@ -26,6 +26,9 @@ class KyooClient:
async def __aenter__(self):
jsons.set_serializer(lambda x, **_: format_date(x), type[Optional[date | int]])
self.client = ClientSession(
headers={
"User-Agent": "kyoo",
},
json_serialize=lambda *args, **kwargs: jsons.dumps(
*args, key_transformer=jsons.KEY_TRANSFORMER_CAMELCASE, **kwargs
),

View File

@ -26,4 +26,7 @@ POSTGRES_PASSWORD=
POSTGRES_DB=
POSTGRES_SERVER=
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

View File

@ -1,9 +1,9 @@
# 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
ENV GOTOOLCHAIN=local
ENV GOPATH /go
ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH
ENV GOPATH=/go
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \

View File

@ -5,8 +5,8 @@
FROM debian:trixie-slim
# those were copied from https://github.com/docker-library/golang/blob/master/Dockerfile-linux.template
ENV GOTOOLCHAIN=local
ENV GOPATH /go
ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH
ENV GOPATH=/go
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \

View File

@ -25,6 +25,6 @@ require (
golang.org/x/image v0.19.0 // indirect
golang.org/x/net v0.28.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
)

View File

@ -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/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
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/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=

View File

@ -9,6 +9,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/text/language"
@ -36,7 +37,7 @@ type MediaInfo struct {
/// The file size of the video file.
Size int64 `json:"size"`
/// The length of the media in seconds.
Duration float32 `json:"duration"`
Duration float64 `json:"duration"`
/// The container of the video file of this episode.
Container *string `json:"container"`
/// 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"`
/// The list of chapters. See Chapter for more information.
Chapters []Chapter `json:"chapters"`
/// lock used to read/set keyframes of video/audio
lock sync.Mutex
}
type Video struct {
@ -238,7 +242,7 @@ func RetriveMediaInfo(path string, sha string) (*MediaInfo, error) {
// Remove leading .
Extension: filepath.Ext(path)[1:],
Size: ParseInt64(mi.Format.Size),
Duration: float32(mi.Format.DurationSeconds),
Duration: mi.Format.DurationSeconds,
Container: OrNull(mi.Format.FormatName),
Versions: Versions{
Info: InfoVersion,

View File

@ -2,6 +2,7 @@ package src
import (
"bufio"
"errors"
"fmt"
"log"
"os/exec"
@ -88,11 +89,17 @@ type KeyframeKey struct {
}
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 {
return info.Videos[idx].Keyframes, nil
ret = info.Videos[idx].Keyframes
}
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{
@ -110,6 +117,14 @@ func (s *MetadataService) GetKeyframes(info *MediaInfo, isVideo bool, idx uint32
}
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() {
var table string
var err error
@ -122,7 +137,7 @@ func (s *MetadataService) GetKeyframes(info *MediaInfo, isVideo bool, idx uint32
}
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
}
@ -157,6 +172,8 @@ func getVideoKeyframes(path string, video_idx uint32, kf *Keyframe) error {
"-loglevel", "error",
"-select_streams", fmt.Sprintf("V:%d", video_idx),
"-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",
path,
)
@ -191,6 +208,8 @@ func getVideoKeyframes(path string, video_idx uint32, kf *Keyframe) error {
pts, flags := x[0], x[1]
// 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" {
break
}
@ -231,16 +250,89 @@ func getVideoKeyframes(path string, video_idx uint32, kf *Keyframe) error {
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
func getAudioKeyframes(info *MediaInfo, audio_idx uint32, kf *Keyframe) error {
dummyKeyframeDuration := float64(4)
segmentCount := int((float64(info.Duration) / dummyKeyframeDuration) + 1)
kf.Keyframes = make([]float64, segmentCount)
for segmentIndex := 0; segmentIndex < segmentCount; segmentIndex += 1 {
kf.Keyframes[segmentIndex] = float64(segmentIndex) * dummyKeyframeDuration
defer printExecTime("ffprobe keyframe analysis for %s audio n%d", info.Path, audio_idx)()
// Format's duration CAN be different than audio's duration. To make sure we do not
// miss a segment or make one more, we need to check the audio's duration.
//
// 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.info.ready.Done()
return nil
}

View File

@ -24,22 +24,29 @@ type MetadataService struct {
func NewMetadataService() (*MetadataService, error) {
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_PASSWORD")),
url.QueryEscape(os.Getenv("POSTGRES_SERVER")),
url.QueryEscape(os.Getenv("POSTGRES_PORT")),
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)
if err != nil {
fmt.Printf("Could not connect to database, check your env variables!")
return nil, err
}
_, err = db.Exec("create schema if not exists gocoder")
if schema != "disabled" {
_, err = db.Exec(fmt.Sprintf("create schema if not exists %s", schema))
if err != nil {
return nil, err
}
}
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {

View File

@ -238,6 +238,11 @@ func (ts *Stream) run(start int32) error {
)
}
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,
// this makes behaviors consistent between soft and hardware decodes.
// this also means that after a -ss 50, the output video will start at 50s