mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Merge branch 'master' into feature/helmchart
This commit is contained in:
commit
ad8dbc3c1a
34
DIAGRAMS.md
34
DIAGRAMS.md
@ -1,4 +1,5 @@
|
||||
# Diagrams
|
||||
These diagrams are created with Mermaid and rendered locally. For the best experience, please use a browser.
|
||||
|
||||
# Project Structure
|
||||
Kyoo is a monorepo that consists of several projects each in their own directory. Diagram below shows an outline of kyoo, projects, and artifacts.
|
||||
@ -99,9 +100,9 @@ C4Container
|
||||
System_Boundary(internal, "Kyoo") {
|
||||
Container(frontend, "front/")
|
||||
Container(backend, "back/")
|
||||
ContainerQueue(emb, "emb", "", "EnterpriseMessageBus")
|
||||
Container(transcoder, "transcoder/")
|
||||
Container(scanner, "scanner/")
|
||||
ContainerQueue(emb, "emb", "", "EnterpriseMessageBus")
|
||||
Container(autosync, "autosync/")
|
||||
}
|
||||
System_Boundary(external, "") {
|
||||
@ -118,7 +119,6 @@ C4Container
|
||||
Rel(user, backend, "")
|
||||
Rel(frontend, backend, "")
|
||||
Rel(backend, emb, "")
|
||||
Rel(backend, media, "")
|
||||
Rel(backend, transcoder, "")
|
||||
Rel_Back(autosync, emb, "")
|
||||
Rel(autosync, tracker, "")
|
||||
@ -162,7 +162,7 @@ C4Component
|
||||
### Back
|
||||
```mermaid
|
||||
C4Component
|
||||
UpdateLayoutConfig($c4ShapeInRow="4", $c4BoundaryInRow="3")
|
||||
UpdateLayoutConfig($c4ShapeInRow="5", $c4BoundaryInRow="2")
|
||||
|
||||
title Component Diagram for Back
|
||||
|
||||
@ -172,41 +172,37 @@ C4Component
|
||||
Component(frontend_c1, "kyoo_front", "typescript, node.js", "Static Content")
|
||||
}
|
||||
Container_Boundary(backend, "back") {
|
||||
Component(backend_c1, "kyoo_migrations", "C#, .NET 8.0", "Postgres Migration")
|
||||
ComponentDb(backend_db1, "backend", "Postgres", "user data and session state")
|
||||
Component(backend_c3, "BackendMetadata", "Volume", "Persistent. Distributed Metadata")
|
||||
ComponentDb(backend_db2, "search", "Meilisearch", "search resource")
|
||||
Component(backend_c3, "BackendMetadata", "Volume", "Persistent. Distributed Metadata")
|
||||
ComponentDb(backend_db1, "backend", "Postgres", "user data and session state")
|
||||
Component(backend_c1, "kyoo_migrations", "C#, .NET 8.0", "Postgres Migration")
|
||||
Component(backend_c2, "kyoo_back", "C#, .NET 8.0", "API Backend")
|
||||
}
|
||||
|
||||
Container_Boundary(media, "MediaLibrary") {
|
||||
Component_Ext(media_c1, "MediaShare", "Volume", "Read Only")
|
||||
}
|
||||
Container_Boundary(transcoder, "transcoder") {
|
||||
Component(transcoder_c1, "kyoo_transcoder", "go, go", "Video Transcoder")
|
||||
}
|
||||
Container_Boundary(emb, "emb") {
|
||||
ComponentQueue(emb_e1, "events.watched", "RabbitMQ, Exchange", "")
|
||||
ComponentQueue(emb_q2, "scanner.rescan", "RabbitMQ, Queue", "")
|
||||
ComponentQueue(emb_q1, "autosync", "RabbitMQ, Queue", "")
|
||||
ComponentQueue(emb_q2, "scanner.rescan", "RabbitMQ, Queue", "")
|
||||
ComponentQueue(emb_e2, "events.resource", "RabbitMQ, Exchange", "unused")
|
||||
}
|
||||
|
||||
Container_Boundary(scanner, "scanner") {
|
||||
Component(scanner_c1, "kyoo_scanner", "python, python3.12", "scanner")
|
||||
Component(scanner_c2, "kyoo_scanner", "python, python3.12", "matcher")
|
||||
Component(scanner_c1, "kyoo_scanner", "python, python3.12", "scanner")
|
||||
}
|
||||
|
||||
Container_Boundary(autosync, "autosync") {
|
||||
Component(autosync_c1, "kyoo_autosync", "python, python3.12", "")
|
||||
}
|
||||
|
||||
Container_Boundary(transcoder, "transcoder") {
|
||||
Component(transcoder_c1, "kyoo_transcoder", "go, go", "Video Transcoder")
|
||||
}
|
||||
|
||||
Rel(user, backend_c2, "")
|
||||
Rel(backend_c1, backend_db1, "")
|
||||
Rel(backend_c2, backend_db1, "")
|
||||
Rel(backend_c2, backend_db2, "")
|
||||
Rel(backend_c2, media_c1, "")
|
||||
Rel(backend_c2, transcoder_c1, "")
|
||||
Rel(backend_c2, backend_c3, "")
|
||||
Rel(backend_c2, emb_q2, "produces")
|
||||
@ -260,14 +256,14 @@ C4Component
|
||||
Component(scanner_c1, "kyoo_scanner", "python, python3.12", "scanner")
|
||||
}
|
||||
|
||||
Container_Boundary(emb, "emb") {
|
||||
ComponentQueue(emb_q2, "scanner.rescan", "RabbitMQ, Queue", "")
|
||||
}
|
||||
|
||||
Container_Boundary(backend, "back") {
|
||||
Component(backend_c2, "kyoo_back", "C#, .NET 8.0", "API Backend")
|
||||
}
|
||||
|
||||
Container_Boundary(emb, "emb") {
|
||||
ComponentQueue(emb_q2, "scanner.rescan", "RabbitMQ, Queue", "")
|
||||
}
|
||||
|
||||
Rel(scanner_c1, scanner_q1, "produces")
|
||||
Rel(scanner_c1, media_c1, "watches")
|
||||
Rel(scanner_c1, backend_c2, "Fetch existing scans")
|
||||
|
@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "8.0.6",
|
||||
"version": "8.0.7",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
|
@ -1,9 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="Serilog" Version="4.0.0" />
|
||||
<PackageReference Include="Serilog" Version="4.0.1" />
|
||||
|
||||
<ProjectReference Include="../Kyoo.Abstractions/Kyoo.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
@ -16,14 +16,14 @@
|
||||
<PackageReference Include="InterpolatedSql.Dapper" Version="2.3.0" />
|
||||
<PackageReference Include="FlexLabs.EntityFrameworkCore.Upsert" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog" Version="4.0.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog" Version="4.0.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
|
||||
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
|
||||
<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.6">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
@ -69,6 +69,15 @@ public class VideoApi : Controller
|
||||
await _Proxy($"{path}/direct");
|
||||
}
|
||||
|
||||
[HttpGet("{path:base64}/direct/{identifier}")]
|
||||
[PartialPermission(Kind.Play)]
|
||||
[ProducesResponseType(StatusCodes.Status206PartialContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task GetDirectStream(string path, string identifier)
|
||||
{
|
||||
await _Proxy($"{path}/direct/{identifier}");
|
||||
}
|
||||
|
||||
[HttpGet("{path:base64}/master.m3u8")]
|
||||
[PartialPermission(Kind.Play)]
|
||||
[ProducesResponseType(StatusCodes.Status206PartialContent)]
|
||||
@ -78,18 +87,18 @@ public class VideoApi : Controller
|
||||
await _Proxy($"{path}/master.m3u8");
|
||||
}
|
||||
|
||||
[HttpGet("{path:base64}/{quality}/index.m3u8")]
|
||||
[HttpGet("{path:base64}/{video:int}/{quality}/index.m3u8")]
|
||||
[PartialPermission(Kind.Play)]
|
||||
public async Task GetVideoIndex(string path, string quality)
|
||||
public async Task GetVideoIndex(string path, int video, string quality)
|
||||
{
|
||||
await _Proxy($"{path}/{quality}/index.m3u8");
|
||||
await _Proxy($"{path}/{video}/{quality}/index.m3u8");
|
||||
}
|
||||
|
||||
[HttpGet("{path:base64}/{quality}/{segment}")]
|
||||
[HttpGet("{path:base64}/{video:int}/{quality}/{segment}")]
|
||||
[PartialPermission(Kind.Play)]
|
||||
public async Task GetVideoSegment(string path, string quality, string segment)
|
||||
public async Task GetVideoSegment(string path, int video, string quality, string segment)
|
||||
{
|
||||
await _Proxy($"{path}/{quality}/{segment}");
|
||||
await _Proxy($"{path}/{video}/{quality}/{segment}");
|
||||
}
|
||||
|
||||
[HttpGet("{path:base64}/audio/{audio}/index.m3u8")]
|
||||
|
@ -5,7 +5,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MeiliSearch" Version="0.15.0" />
|
||||
<PackageReference Include="MeiliSearch" Version="0.15.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
@ -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.6">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.7" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
|
@ -6,7 +6,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NSwag.AspNetCore" Version="14.0.8" />
|
||||
<PackageReference Include="NSwag.AspNetCore" Version="14.1.0" />
|
||||
<ProjectReference Include="../Kyoo.Abstractions/Kyoo.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
@ -39,7 +39,7 @@
|
||||
"formatter": {
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingComma": "all",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSpacing": true,
|
||||
|
@ -11,8 +11,8 @@ COPY packages/models/package.json packages/models/package.json
|
||||
RUN yarn --immutable
|
||||
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
RUN yarn build:web
|
||||
|
||||
|
||||
@ -25,8 +25,8 @@ COPY --from=builder /app/apps/web/.next/static ./.next/static/
|
||||
COPY --from=builder /app/apps/web/public ./public
|
||||
|
||||
EXPOSE 8901
|
||||
ENV PORT 8901
|
||||
ENV PORT=8901
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
CMD ["node", "server.js"]
|
||||
|
@ -11,7 +11,7 @@ COPY packages/primitives/package.json packages/primitives/package.json
|
||||
COPY packages/models/package.json packages/models/package.json
|
||||
RUN yarn --immutable
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
EXPOSE 3000
|
||||
EXPOSE 8081
|
||||
ENTRYPOINT ["yarn", "dev"]
|
||||
|
@ -56,10 +56,6 @@ const config: ExpoConfig = {
|
||||
backgroundColor: "#eff1f5",
|
||||
},
|
||||
splash,
|
||||
permissions: [
|
||||
"android.permission.FOREGROUND_SERVICE",
|
||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||
],
|
||||
},
|
||||
updates: {
|
||||
url: "https://u.expo.dev/55de6b52-c649-4a15-9a45-569ff5ed036c",
|
||||
@ -73,27 +69,16 @@ const config: ExpoConfig = {
|
||||
projectId: "55de6b52-c649-4a15-9a45-569ff5ed036c",
|
||||
},
|
||||
},
|
||||
plugins: ["expo-build-properties", "expo-localization"],
|
||||
};
|
||||
|
||||
const withForegroundService = (c: ExpoConfig): ExpoConfig => {
|
||||
return withAndroidManifest(c, async (config) => {
|
||||
const manifest = config.modResults.manifest;
|
||||
manifest.application![0].service ??= [];
|
||||
manifest.application![0].service.push({
|
||||
$: {
|
||||
"android:name": "com.brentvatne.exoplayer.VideoPlaybackService",
|
||||
"android:exported": "false",
|
||||
"android:foregroundServiceType": "mediaPlayback",
|
||||
plugins: [
|
||||
"expo-build-properties",
|
||||
"expo-localization",
|
||||
[
|
||||
"react-native-video",
|
||||
{
|
||||
enableNotificationControls: true,
|
||||
},
|
||||
"intent-filter": [
|
||||
{
|
||||
action: [{ $: { "android:name": "androidx.media3.session.MediaSessionService" } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
return config;
|
||||
});
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
export default withForegroundService(config);
|
||||
export default config;
|
||||
|
@ -40,6 +40,8 @@ export default function SignGuard() {
|
||||
<Stack
|
||||
screenOptions={{
|
||||
navigationBarColor: "transparent",
|
||||
// @ts-expect-error Not yet available. Waiting for expo-router update.
|
||||
navigationBarTranslucent: true,
|
||||
headerTitle: () => <NavbarTitle />,
|
||||
headerRight: () => <NavbarRight />,
|
||||
contentStyle: {
|
||||
|
@ -18,54 +18,53 @@
|
||||
"@formatjs/intl-displaynames": "^6.6.8",
|
||||
"@formatjs/intl-locale": "^4.0.0",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@kesha-antonov/react-native-background-downloader": "^3.1.3",
|
||||
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
|
||||
"@kyoo/ui": "workspace:^",
|
||||
"@material-symbols/svg-400": "^0.18.0",
|
||||
"@react-native-community/netinfo": "11.3.1",
|
||||
"@shopify/flash-list": "1.6.4",
|
||||
"@tanstack/query-sync-storage-persister": "^5.38.0",
|
||||
"@tanstack/react-query": "^5.39.0",
|
||||
"@tanstack/react-query-persist-client": "^5.39.0",
|
||||
"@material-symbols/svg-400": "^0.22.0",
|
||||
"@react-native-community/netinfo": "11.3.2",
|
||||
"@shopify/flash-list": "1.7.1",
|
||||
"@tanstack/query-sync-storage-persister": "^5.51.21",
|
||||
"@tanstack/react-query": "^5.51.23",
|
||||
"@tanstack/react-query-persist-client": "^5.51.23",
|
||||
"array-shuffle": "^3.0.0",
|
||||
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
|
||||
"expo": "^51.0.8",
|
||||
"expo-build-properties": "~0.12.1",
|
||||
"expo-constants": "~16.0.1",
|
||||
"expo-dev-client": "~4.0.14",
|
||||
"expo": "^51.0.26",
|
||||
"expo-build-properties": "~0.12.5",
|
||||
"expo-constants": "~16.0.2",
|
||||
"expo-dev-client": "~4.0.22",
|
||||
"expo-file-system": "~17.0.1",
|
||||
"expo-font": "~12.0.5",
|
||||
"expo-image-picker": "~15.0.5",
|
||||
"expo-font": "~12.0.9",
|
||||
"expo-image-picker": "~15.0.7",
|
||||
"expo-linear-gradient": "~13.0.2",
|
||||
"expo-linking": "~6.3.1",
|
||||
"expo-localization": "~15.0.3",
|
||||
"expo-navigation-bar": "~3.0.4",
|
||||
"expo-router": "3.5.14",
|
||||
"expo-navigation-bar": "~3.0.7",
|
||||
"expo-router": "3.5.21",
|
||||
"expo-screen-orientation": "~7.0.5",
|
||||
"expo-secure-store": "~13.0.1",
|
||||
"expo-secure-store": "~13.0.2",
|
||||
"expo-status-bar": "~1.12.1",
|
||||
"expo-updates": "~0.25.14",
|
||||
"i18next": "^23.11.5",
|
||||
"expo-updates": "~0.25.22",
|
||||
"i18next": "^23.12.2",
|
||||
"intl-pluralrules": "^2.0.1",
|
||||
"moti": "^0.29.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-i18next": "^14.1.2",
|
||||
"react-native": "0.74.1",
|
||||
"react": "18.3.1",
|
||||
"react-i18next": "^15.0.1",
|
||||
"react-native": "0.74.5",
|
||||
"react-native-blurhash": "^2.0.3",
|
||||
"react-native-fast-image": "^8.6.3",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"react-native-reanimated": "~3.10.1",
|
||||
"react-native-safe-area-context": "4.10.1",
|
||||
"react-native-screens": "~3.31.1",
|
||||
"react-native-reanimated": "~3.15.0",
|
||||
"react-native-safe-area-context": "4.10.8",
|
||||
"react-native-screens": "3.34.0",
|
||||
"react-native-svg": "15.2.0",
|
||||
"react-native-uuid": "^2.0.2",
|
||||
"react-native-video": "^6.1.2",
|
||||
"react-native-video": "^6.4.3",
|
||||
"yoshiki": "1.2.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.6",
|
||||
"react-native-svg-transformer": "^1.4.0",
|
||||
"typescript": "~5.3.3"
|
||||
"@babel/core": "^7.25.2",
|
||||
"react-native-svg-transformer": "^1.5.0",
|
||||
"typescript": "~5.5.4"
|
||||
},
|
||||
"installConfig": {
|
||||
"hoistingLimits": "workspaces"
|
||||
|
@ -51,8 +51,14 @@ const nextConfig = {
|
||||
alias: {
|
||||
...config.resolve.alias,
|
||||
"react-native$": "react-native-web",
|
||||
"react-native/Libraries/Image/AssetRegistry$":
|
||||
"react-native-web/dist/modules/AssetRegistry",
|
||||
// "react-native/Libraries/Image/AssetRegistry$":
|
||||
// "react-native-web/dist/modules/AssetRegistry",
|
||||
"react-native/Libraries/EventEmitter/RCTDeviceEventEmitter$":
|
||||
"react-native-web/dist/vendor/react-native/NativeEventEmitter/RCTDeviceEventEmitter",
|
||||
"react-native/Libraries/vendor/emitter/EventEmitter$":
|
||||
"react-native-web/dist/vendor/react-native/emitter/EventEmitter",
|
||||
"react-native/Libraries/EventEmitter/NativeEventEmitter$":
|
||||
"react-native-web/dist/vendor/react-native/NativeEventEmitter",
|
||||
},
|
||||
extensions: [".web.ts", ".web.tsx", ".web.js", ".web.jsx", ...config.resolve.extensions],
|
||||
};
|
||||
@ -94,6 +100,7 @@ const nextConfig = {
|
||||
"@kyoo/ui",
|
||||
"@kyoo/primitives",
|
||||
"@kyoo/models",
|
||||
"@react-native/assets-registry",
|
||||
"solito",
|
||||
"react-native",
|
||||
"react-native-web",
|
||||
|
@ -16,45 +16,45 @@
|
||||
"@kyoo/models": "workspace:^",
|
||||
"@kyoo/primitives": "workspace:^",
|
||||
"@kyoo/ui": "workspace:^",
|
||||
"@material-symbols/svg-400": "^0.18.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@tanstack/react-query": "^5.39.0",
|
||||
"@tanstack/react-query-devtools": "^5.39.0",
|
||||
"@material-symbols/svg-400": "^0.22.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@tanstack/react-query": "^5.51.23",
|
||||
"@tanstack/react-query-devtools": "^5.51.23",
|
||||
"array-shuffle": "^3.0.0",
|
||||
"expo-image-picker": "~15.0.5",
|
||||
"expo-image-picker": "~15.0.7",
|
||||
"expo-linear-gradient": "^13.0.2",
|
||||
"expo-modules-core": "^1.12.11",
|
||||
"hls.js": "^1.5.8",
|
||||
"i18next": "^23.11.5",
|
||||
"jassub": "^1.7.15",
|
||||
"jotai": "^2.8.1",
|
||||
"expo-modules-core": "^1.12.20",
|
||||
"hls.js": "^1.5.14",
|
||||
"i18next": "^23.12.2",
|
||||
"jassub": "1.7.15",
|
||||
"jotai": "^2.9.2",
|
||||
"moti": "^0.29.0",
|
||||
"next": "14.2.3",
|
||||
"next": "14.2.5",
|
||||
"next-translate": "^2.6.2",
|
||||
"raf": "^3.4.1",
|
||||
"react": "18.2.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "^14.1.2",
|
||||
"react-native-reanimated": "3.11.0",
|
||||
"react-native-svg": "15.3.0",
|
||||
"react-native-video": "^6.1.2",
|
||||
"react-i18next": "^15.0.1",
|
||||
"react-native-reanimated": "3.15.0",
|
||||
"react-native-svg": "15.2.0",
|
||||
"react-native-video": "^6.4.3",
|
||||
"react-native-web": "0.19.12",
|
||||
"react-tooltip": "^5.26.4",
|
||||
"react-tooltip": "^5.28.0",
|
||||
"solito": "^4.2.2",
|
||||
"srt-webvtt": "zoriya/srt-webvtt#build",
|
||||
"superjson": "^2.2.1",
|
||||
"sweetalert2": "^11.11.0",
|
||||
"sweetalert2": "^11.12.4",
|
||||
"yoshiki": "1.2.14",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@types/node": "20.12.12",
|
||||
"@types/node": "22.2.0",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"copy-webpack-plugin": "^12.0.2",
|
||||
"react-native": "0.74.1",
|
||||
"typescript": "^5.4.5",
|
||||
"webpack": "^5.91.0"
|
||||
"react-native": "0.74.5",
|
||||
"typescript": "^5.5.4",
|
||||
"webpack": "^5.93.0"
|
||||
}
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ export const withTranslations = (
|
||||
...commonOptions,
|
||||
lng: props.pageProps.__lang,
|
||||
});
|
||||
i18next.systemLanguage = props.pageProps.__sysLang;
|
||||
i18next.systemLanguage = props.pageProps?.__sysLang;
|
||||
return i18next;
|
||||
});
|
||||
|
||||
@ -66,6 +66,7 @@ export const withTranslations = (
|
||||
lng,
|
||||
});
|
||||
i18n.systemLanguage = sysLng;
|
||||
props.pageProps ??= {};
|
||||
props.pageProps.__lang = lng;
|
||||
props.pageProps.__sysLang = sysLng;
|
||||
return props;
|
||||
|
@ -201,7 +201,7 @@ const App = ({ Component, pageProps }: AppProps) => {
|
||||
{...props}
|
||||
/>
|
||||
</ConnectionErrorVerifier>
|
||||
<Tooltip id="tooltip" positionStrategy={"fixed"} />
|
||||
<Tooltip id="tooltip" style={{ zIndex: 10 }} positionStrategy={"fixed"} />
|
||||
<SetupChecker />
|
||||
</SnackbarProvider>
|
||||
</PortalProvider>
|
||||
@ -264,12 +264,12 @@ App.getInitialProps = async (ctx: AppContext) => {
|
||||
) {
|
||||
ctx.ctx.res!.writeHead(307, { Location: `/setup?step=${info!.setupStatus}` });
|
||||
ctx.ctx.res!.end();
|
||||
return {} as any;
|
||||
return { pageProps: {} };
|
||||
}
|
||||
if (info!.setupStatus === SetupStep.Done && ctx.router.route === "/setup") {
|
||||
ctx.ctx.res!.writeHead(307, { Location: "/" });
|
||||
ctx.ctx.res!.end();
|
||||
return {} as any;
|
||||
return { pageProps: {} };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("SSR error, disabling it.");
|
||||
|
@ -17,8 +17,8 @@
|
||||
},
|
||||
"workspaces": ["apps/*", "packages/*"],
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.7.3",
|
||||
"typescript": "5.4.5"
|
||||
"@biomejs/biome": "1.8.3",
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"packageManager": "yarn@3.2.4"
|
||||
}
|
||||
|
@ -6,7 +6,7 @@
|
||||
"packageManager": "yarn@3.2.4",
|
||||
"devDependencies": {
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"typescript": "^5.4.5"
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "*",
|
||||
|
@ -23,14 +23,42 @@ import { imageFn } from "../traits";
|
||||
import { QualityP } from "./quality";
|
||||
|
||||
/**
|
||||
* A Video track
|
||||
* A audio or subtitle track.
|
||||
*/
|
||||
export const VideoP = z.object({
|
||||
export const TrackP = z.object({
|
||||
/**
|
||||
* The Codec of the Video Track.
|
||||
* E.g. "AVC"
|
||||
* The index of this track on the episode.
|
||||
* NOTE: external subtitles can have a null index
|
||||
*/
|
||||
index: z.number().nullable(),
|
||||
/**
|
||||
* The title of the stream.
|
||||
*/
|
||||
title: z.string().nullable(),
|
||||
/**
|
||||
* The language of this stream (as a ISO-639-2 language code)
|
||||
*/
|
||||
language: z.string().nullable(),
|
||||
/**
|
||||
* The codec of this stream.
|
||||
*/
|
||||
codec: z.string(),
|
||||
/**
|
||||
* Is this stream the default one of it's type?
|
||||
*/
|
||||
isDefault: z.boolean(),
|
||||
/**
|
||||
* Is this stream tagged as forced?
|
||||
* NOTE: not available for videos
|
||||
*/
|
||||
isForced: z.boolean().optional(),
|
||||
});
|
||||
export type Track = z.infer<typeof TrackP>;
|
||||
|
||||
/**
|
||||
* A Video track
|
||||
*/
|
||||
export const VideoP = TrackP.extend({
|
||||
/**
|
||||
* The Quality of the Video
|
||||
* E.g. "1080p"
|
||||
@ -55,37 +83,6 @@ export const VideoP = z.object({
|
||||
|
||||
export type Video = z.infer<typeof VideoP>;
|
||||
|
||||
/**
|
||||
* A audio or subtitle track.
|
||||
*/
|
||||
export const TrackP = z.object({
|
||||
/**
|
||||
* The index of this track on the episode.
|
||||
*/
|
||||
index: z.number(),
|
||||
/**
|
||||
* The title of the stream.
|
||||
*/
|
||||
title: z.string().nullable(),
|
||||
/**
|
||||
* The language of this stream (as a ISO-639-2 language code)
|
||||
*/
|
||||
language: z.string().nullable(),
|
||||
/**
|
||||
* The codec of this stream.
|
||||
*/
|
||||
codec: z.string(),
|
||||
/**
|
||||
* Is this stream the default one of it's type?
|
||||
*/
|
||||
isDefault: z.boolean(),
|
||||
/**
|
||||
* Is this stream tagged as forced?
|
||||
*/
|
||||
isForced: z.boolean(),
|
||||
});
|
||||
export type Track = z.infer<typeof TrackP>;
|
||||
|
||||
export const AudioP = TrackP;
|
||||
export type Audio = z.infer<typeof AudioP>;
|
||||
|
||||
@ -94,6 +91,10 @@ export const SubtitleP = TrackP.extend({
|
||||
* The url of this track (only if this is a subtitle)..
|
||||
*/
|
||||
link: z.string().transform(imageFn).nullable(),
|
||||
/*
|
||||
* Is this an external subtitle (as in stored in a different file)
|
||||
*/
|
||||
isExternal: z.boolean(),
|
||||
});
|
||||
export type Subtitle = z.infer<typeof SubtitleP>;
|
||||
|
||||
@ -152,7 +153,7 @@ export const WatchInfoP = z
|
||||
/**
|
||||
* The video track.
|
||||
*/
|
||||
video: VideoP.nullable(),
|
||||
videos: z.array(VideoP),
|
||||
/**
|
||||
* The list of audio tracks.
|
||||
*/
|
||||
|
@ -6,7 +6,7 @@
|
||||
"packageManager": "yarn@3.2.4",
|
||||
"devDependencies": {
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"typescript": "^5.4.5"
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@gorhom/portal": "*",
|
||||
@ -53,14 +53,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/html-elements": "^0.10.1",
|
||||
"@tanstack/react-query": "^5.39.0",
|
||||
"@tanstack/react-query": "^5.51.23",
|
||||
"solito": "^4.2.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"blurhash": "^2.0.5",
|
||||
"react-native-blurhash": "^2.0.3",
|
||||
"react-native-fast-image": "^8.6.3",
|
||||
"react-native-safe-area-context": "4.10.1"
|
||||
"react-native-safe-area-context": "4.10.8"
|
||||
}
|
||||
}
|
||||
|
@ -157,6 +157,12 @@ const MenuItem = forwardRef<
|
||||
<style jsx global>{`
|
||||
[data-highlighted] {
|
||||
background: ${theme.variant.accent};
|
||||
svg {
|
||||
fill: ${theme.alternate.contrast};
|
||||
}
|
||||
div {
|
||||
color: ${theme.alternate.contrast};
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<Item
|
||||
|
@ -10,10 +10,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@shopify/flash-list": "^1.6.4",
|
||||
"@shopify/flash-list": "^1.7.1",
|
||||
"@types/langmap": "^0.0.3",
|
||||
"react-native-uuid": "^2.0.2",
|
||||
"typescript": "^5.4.5"
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@gorhom/portal": "*",
|
||||
@ -35,9 +35,9 @@
|
||||
"yoshiki": "*"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@kesha-antonov/react-native-background-downloader": "^3.1.3",
|
||||
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
|
||||
"expo-file-system": "^17.0.1",
|
||||
"expo-router": "^3.5.14"
|
||||
"expo-router": "^3.5.21"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@kesha-antonov/react-native-background-downloader": {
|
||||
|
@ -22,6 +22,7 @@ import {
|
||||
type Audio,
|
||||
type QueryIdentifier,
|
||||
type Subtitle,
|
||||
type Video,
|
||||
type WatchInfo,
|
||||
WatchInfoP,
|
||||
} from "@kyoo/models";
|
||||
@ -33,8 +34,10 @@ import { useYoshiki } from "yoshiki/native";
|
||||
import { Fetch } from "../fetch";
|
||||
import { useDisplayName } from "../utils";
|
||||
|
||||
const formatBitrate = (b: number) => `${(b / 1000000).toFixed(2)} Mbps`;
|
||||
|
||||
const MediaInfoTable = ({
|
||||
mediaInfo: { path, video, container, audios, subtitles, duration, size },
|
||||
mediaInfo: { path, videos, container, audios, subtitles, duration, size },
|
||||
}: {
|
||||
mediaInfo: Partial<WatchInfo>;
|
||||
}) => {
|
||||
@ -42,22 +45,22 @@ const MediaInfoTable = ({
|
||||
const { t } = useTranslation();
|
||||
const { css } = useYoshiki();
|
||||
|
||||
const formatBitrate = (b: number) => `${(b / 1000000).toFixed(2)} Mbps`;
|
||||
const formatTrackTable = (trackTable: (Audio | Subtitle)[], type: "subtitles" | "audio") => {
|
||||
if (trackTable.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const singleTrack = trackTable.length === 1;
|
||||
return trackTable.reduce(
|
||||
(collected, audioTrack, index) => {
|
||||
(collected, track, index) => {
|
||||
// If there is only one track, we do not need to show an index
|
||||
collected[singleTrack ? t(`mediainfo.${type}`) : `${t(`mediainfo.${type}`)} ${index + 1}`] =
|
||||
[
|
||||
getDisplayName(audioTrack),
|
||||
getDisplayName(track),
|
||||
// Only show it if there is more than one track
|
||||
audioTrack.isDefault && !singleTrack ? t("mediainfo.default") : undefined,
|
||||
audioTrack.isForced ? t("mediainfo.forced") : undefined,
|
||||
audioTrack.codec,
|
||||
track.isDefault && !singleTrack ? t("mediainfo.default") : undefined,
|
||||
track.isForced ? t("mediainfo.forced") : undefined,
|
||||
"isExternal" in track && track.isExternal ? t("mediainfo.external") : undefined,
|
||||
track.codec,
|
||||
]
|
||||
.filter((x) => x !== undefined)
|
||||
.join(" - ");
|
||||
@ -66,6 +69,29 @@ const MediaInfoTable = ({
|
||||
{} as Record<string, string | undefined>,
|
||||
);
|
||||
};
|
||||
const formatVideoTable = (trackTable: Video[]) => {
|
||||
if (trackTable.length === 0) {
|
||||
return { [t("mediainfo.video")]: t("mediainfo.novideo") };
|
||||
}
|
||||
const singleTrack = trackTable.length === 1;
|
||||
return trackTable.reduce(
|
||||
(collected, video, index) => {
|
||||
// If there is only one track, we do not need to show an index
|
||||
collected[singleTrack ? t("mediainfo.video") : `${t("mediainfo.video")} ${index + 1}`] = [
|
||||
getDisplayName(video),
|
||||
`${video.width}x${video.height} (${video.quality})`,
|
||||
formatBitrate(video.bitrate),
|
||||
// Only show it if there is more than one track
|
||||
video.isDefault && !singleTrack ? t("mediainfo.default") : undefined,
|
||||
video.codec,
|
||||
]
|
||||
.filter((x) => x !== undefined)
|
||||
.join(" - ");
|
||||
return collected;
|
||||
},
|
||||
{} as Record<string, string | undefined>,
|
||||
);
|
||||
};
|
||||
const table = (
|
||||
[
|
||||
{
|
||||
@ -74,15 +100,7 @@ const MediaInfoTable = ({
|
||||
[t("mediainfo.duration")]: duration,
|
||||
[t("mediainfo.size")]: size,
|
||||
},
|
||||
{
|
||||
[t("mediainfo.video")]: video
|
||||
? `${video.width}x${video.height} (${video.quality}) - ${formatBitrate(
|
||||
video.bitrate,
|
||||
)} - ${video.codec}`
|
||||
: video === null
|
||||
? t("mediainfo.novideo")
|
||||
: undefined,
|
||||
},
|
||||
videos === undefined ? { [t("mediainfo.video")]: undefined } : formatVideoTable(videos),
|
||||
audios === undefined
|
||||
? { [t("mediainfo.audio")]: undefined }
|
||||
: formatTrackTable(audios, "audio"),
|
||||
|
@ -329,7 +329,7 @@ export const TitleLine = ({
|
||||
<View
|
||||
{...css({ flexDirection: "row", alignItems: "center", justifyContent: "center" })}
|
||||
>
|
||||
{rating !== null && (
|
||||
{rating !== null && rating !== 0 && (
|
||||
<>
|
||||
<DottedSeparator
|
||||
{...css({ color: { xs: theme.user.contrast, md: theme.colors.white } })}
|
||||
|
@ -40,7 +40,7 @@ import { FlashList } from "@shopify/flash-list";
|
||||
import { useRouter } from "expo-router";
|
||||
import { type Atom, useAtomValue } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type ImageStyle, View } from "react-native";
|
||||
import { View } from "react-native";
|
||||
import { percent, useYoshiki } from "yoshiki/native";
|
||||
import { EpisodeLine, displayRuntime, episodeDisplayNumber } from "../details/episode";
|
||||
import { EmptyView } from "../fetch";
|
||||
|
@ -19,6 +19,7 @@
|
||||
*/
|
||||
|
||||
import { Main } from "@kyoo/primitives";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import type { ReactElement } from "react";
|
||||
import { useYoshiki, vw } from "yoshiki/native";
|
||||
import { Navbar } from "./navbar";
|
||||
@ -30,8 +31,7 @@ export const DefaultLayout = ({
|
||||
page: ReactElement;
|
||||
transparent?: boolean;
|
||||
}) => {
|
||||
const { css } = useYoshiki();
|
||||
|
||||
const { css, theme } = useYoshiki();
|
||||
return (
|
||||
<>
|
||||
<Navbar
|
||||
@ -45,6 +45,22 @@ export const DefaultLayout = ({
|
||||
shadowOpacity: 0,
|
||||
},
|
||||
)}
|
||||
background={
|
||||
transparent ? (
|
||||
<LinearGradient
|
||||
start={{ x: 0, y: 0.25 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
colors={[theme.themeOverlay, "transparent"]}
|
||||
{...css({
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
})}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<Main
|
||||
{...css({
|
||||
|
@ -85,8 +85,8 @@ const SearchBar = forwardRef<TextInput, Stylable>(function SearchBar(props, ref)
|
||||
setQuery(q);
|
||||
}}
|
||||
placeholder={t("navbar.search")}
|
||||
placeholderTextColor={theme.colors.white}
|
||||
containerStyle={{ height: ts(4), flexShrink: 1, borderColor: (theme) => theme.colors.white }}
|
||||
placeholderTextColor={theme.contrast}
|
||||
containerStyle={{ height: ts(4), flexShrink: 1, borderColor: (theme) => theme.contrast }}
|
||||
{...tooltip(t("navbar.search"))}
|
||||
{...props}
|
||||
/>
|
||||
@ -177,9 +177,14 @@ export const NavbarRight = () => {
|
||||
export const Navbar = ({
|
||||
left,
|
||||
right,
|
||||
background,
|
||||
...props
|
||||
}: { left?: ReactElement | null; right?: ReactElement | null } & Stylable) => {
|
||||
const { css } = useYoshiki();
|
||||
}: {
|
||||
left?: ReactElement | null;
|
||||
right?: ReactElement | null;
|
||||
background?: ReactElement;
|
||||
} & Stylable) => {
|
||||
const { css, theme } = useYoshiki();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@ -205,6 +210,7 @@ export const Navbar = ({
|
||||
props,
|
||||
)}
|
||||
>
|
||||
{background}
|
||||
<View {...css({ flexDirection: "row", alignItems: "center", height: percent(100) })}>
|
||||
{left !== undefined ? (
|
||||
left
|
||||
|
@ -71,9 +71,9 @@ export const RightButtons = ({
|
||||
selected={!selectedSubtitle}
|
||||
onSelect={() => setSubtitle(null)}
|
||||
/>
|
||||
{subtitles.map((x) => (
|
||||
{subtitles.map((x, i) => (
|
||||
<Menu.Item
|
||||
key={x.index}
|
||||
key={x.index ?? i}
|
||||
label={x.link ? getDisplayName(x) : `${getDisplayName(x)} (${x.codec})`}
|
||||
selected={selectedSubtitle === x}
|
||||
disabled={!x.link}
|
||||
|
@ -65,6 +65,17 @@ const mapData = (
|
||||
};
|
||||
};
|
||||
|
||||
const formatTitleMetadata = (item: Item) => {
|
||||
if (item.type === "movie") {
|
||||
return item.name;
|
||||
}
|
||||
return `${item.name} (${episodeDisplayNumber({
|
||||
seasonNumber: item.seasonNumber,
|
||||
episodeNumber: item.episodeNumber,
|
||||
absoluteNumber: item.absoluteNumber,
|
||||
})})`;
|
||||
};
|
||||
|
||||
export const Player = ({
|
||||
slug,
|
||||
type,
|
||||
@ -81,6 +92,7 @@ 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 previous =
|
||||
data && data.type === "episode" && data.previousEpisode
|
||||
? `/watch/${data.previousEpisode.slug}?t=0`
|
||||
@ -89,15 +101,8 @@ export const Player = ({
|
||||
data && data.type === "episode" && data.nextEpisode
|
||||
? `/watch/${data.nextEpisode.slug}?t=0`
|
||||
: undefined;
|
||||
const title =
|
||||
data &&
|
||||
(data.type === "movie"
|
||||
? data.name
|
||||
: `${data.show!.name} ${episodeDisplayNumber({
|
||||
seasonNumber: data.seasonNumber,
|
||||
episodeNumber: data.episodeNumber,
|
||||
absoluteNumber: data.absoluteNumber,
|
||||
})}`);
|
||||
const title = data && formatTitleMetadata(data);
|
||||
const subtitle = data && data.type === "episode" ? data.show?.name : undefined;
|
||||
|
||||
useVideoKeyboard(info?.subtitles, info?.fonts, previous, next);
|
||||
|
||||
@ -141,8 +146,9 @@ export const Player = ({
|
||||
<Video
|
||||
metadata={{
|
||||
title: title ?? t("show.episodeNoMetadata"),
|
||||
artist: subtitle ?? undefined,
|
||||
description: data?.overview ?? undefined,
|
||||
imageUri: data?.thumbnail?.high,
|
||||
imageUri: image?.medium,
|
||||
next: next,
|
||||
previous: previous,
|
||||
}}
|
||||
|
@ -26,12 +26,16 @@ import { durationAtom, playAtom, progressAtom } from "./state";
|
||||
|
||||
export const MediaSessionManager = ({
|
||||
title,
|
||||
image,
|
||||
subtitle,
|
||||
artist,
|
||||
imageUri,
|
||||
previous,
|
||||
next,
|
||||
}: {
|
||||
title?: string;
|
||||
image?: string | null;
|
||||
subtitle?: string;
|
||||
artist?: string;
|
||||
imageUri?: string | null;
|
||||
previous?: string;
|
||||
next?: string;
|
||||
}) => {
|
||||
@ -45,9 +49,11 @@ export const MediaSessionManager = ({
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: title,
|
||||
artwork: image ? [{ src: image }] : undefined,
|
||||
album: subtitle,
|
||||
artist: artist,
|
||||
artwork: imageUri ? [{ src: imageUri }] : undefined,
|
||||
});
|
||||
}, [title, image]);
|
||||
}, [title, subtitle, artist, imageUri]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
|
@ -20,7 +20,7 @@
|
||||
|
||||
import { type Audio, type Episode, type Subtitle, getLocalSetting, useAccount } from "@kyoo/models";
|
||||
import { useSnackbar } from "@kyoo/primitives";
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { atom, getDefaultStore, useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useAtomCallback } from "jotai/utils";
|
||||
import {
|
||||
type ElementRef,
|
||||
@ -33,7 +33,7 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import NativeVideo, { canPlay, type VideoProps } from "./video";
|
||||
import NativeVideo, { canPlay, type VideoMetadata, type VideoProps } from "./video";
|
||||
|
||||
export const playAtom = atom(true);
|
||||
export const loadAtom = atom(false);
|
||||
@ -114,13 +114,7 @@ export const Video = memo(function Video({
|
||||
setError: (error: string | undefined) => void;
|
||||
fonts?: string[];
|
||||
startTime?: number | null;
|
||||
metadata: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
imageUri?: string;
|
||||
previous?: string;
|
||||
next?: string;
|
||||
};
|
||||
metadata: VideoMetadata & { next?: string; previous?: string };
|
||||
} & Partial<VideoProps>) {
|
||||
const ref = useRef<ElementRef<typeof NativeVideo> | null>(null);
|
||||
const [isPlaying, setPlay] = useAtom(playAtom);
|
||||
@ -238,6 +232,7 @@ export const Video = memo(function Video({
|
||||
showNotificationControls
|
||||
playInBackground
|
||||
playWhenInactive
|
||||
disableDisconnectError
|
||||
paused={!isPlaying}
|
||||
muted={isMuted}
|
||||
volume={volume}
|
||||
@ -251,7 +246,10 @@ export const Video = memo(function Video({
|
||||
setPrivateProgress(progress.currentTime);
|
||||
setBuffered(progress.playableDuration);
|
||||
}}
|
||||
onPlaybackStateChanged={(state) => setPlay(state.isPlaying)}
|
||||
onPlaybackStateChanged={(state) => {
|
||||
if (state.isSeeking || getDefaultStore().get(loadAtom)) return;
|
||||
setPlay(state.isPlaying);
|
||||
}}
|
||||
fonts={fonts}
|
||||
subtitles={subtitles}
|
||||
onMediaUnsupported={() => {
|
||||
|
@ -246,8 +246,8 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function
|
||||
onLoadedMetadata={() => {
|
||||
if (source.startPosition) setProgress(source.startPosition / 1000);
|
||||
}}
|
||||
onPlay={() => onPlaybackStateChanged?.({ isPlaying: true })}
|
||||
onPause={() => onPlaybackStateChanged?.({ isPlaying: false })}
|
||||
onPlay={() => onPlaybackStateChanged?.({ isPlaying: true, isSeeking: false })}
|
||||
onPause={() => onPlaybackStateChanged?.({ isPlaying: false, isSeeking: false })}
|
||||
onEnded={onEnd}
|
||||
{...css({ width: "100%", height: "100%", objectFit: "contain" })}
|
||||
/>
|
||||
|
@ -19,7 +19,7 @@
|
||||
*/
|
||||
|
||||
import { type MutationParam, WatchStatusV, useAccount } from "@kyoo/models";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useAtomCallback } from "jotai/utils";
|
||||
import { useCallback, useEffect } from "react";
|
||||
@ -35,11 +35,11 @@ export const WatchStatusObserver = ({
|
||||
duration: number;
|
||||
}) => {
|
||||
const account = useAccount();
|
||||
const queryClient = useQueryClient();
|
||||
// const queryClient = useQueryClient();
|
||||
const { mutate: _mutate } = useMutation<unknown, Error, MutationParam>({
|
||||
mutationKey: [type, slug, "watchStatus"],
|
||||
onSettled: async () =>
|
||||
await queryClient.invalidateQueries({ queryKey: [type === "episode" ? "show" : type, slug] }),
|
||||
// onSettled: async () =>
|
||||
// await queryClient.invalidateQueries({ queryKey: [type === "episode" ? "show" : type, slug] }),
|
||||
});
|
||||
const mutate = useCallback(
|
||||
(type: string, slug: string, seconds: number) => {
|
||||
|
@ -14,6 +14,7 @@ export const useDisplayName = () => {
|
||||
if (lng && sub.title && sub.title !== lng) return `${lng} - ${sub.title}`;
|
||||
if (lng) return lng;
|
||||
if (sub.title) return sub.title;
|
||||
return `Unknown (${sub.index})`;
|
||||
if (sub.index !== null) return `Unknown (${sub.index})`;
|
||||
return "Unknown";
|
||||
};
|
||||
};
|
||||
|
@ -39,7 +39,8 @@
|
||||
"droped": "Als abgebrochen markieren",
|
||||
"null": "Als ungesehen markieren"
|
||||
},
|
||||
"nextUp": "Als Nächstes"
|
||||
"nextUp": "Als Nächstes",
|
||||
"season": "Staffel {{number}}"
|
||||
},
|
||||
"browse": {
|
||||
"sortby": "Sortieren nach {{key}}",
|
||||
@ -58,7 +59,15 @@
|
||||
"desc": "absteigend"
|
||||
},
|
||||
"switchToGrid": "Zu Rasteransicht wechseln",
|
||||
"switchToList": "Zu Listenansicht wechseln"
|
||||
"switchToList": "Zu Listenansicht wechseln",
|
||||
"mediatypekey": {
|
||||
"all": "Alles",
|
||||
"movie": "Filme",
|
||||
"show": "Serien",
|
||||
"collection": "Sammlungen"
|
||||
},
|
||||
"mediatype-tt": "Medientyp",
|
||||
"mediatypelabel": "Medientyp"
|
||||
},
|
||||
"misc": {
|
||||
"settings": "Einstellungen",
|
||||
@ -78,7 +87,8 @@
|
||||
"browse": "Durchsuchen",
|
||||
"search": "Suchen",
|
||||
"login": "Anmelden",
|
||||
"admin": "Administration"
|
||||
"admin": "Administration",
|
||||
"download": "Herunterladen"
|
||||
},
|
||||
"settings": {
|
||||
"general": {
|
||||
@ -210,7 +220,11 @@
|
||||
"offline": "Du bist nicht mit dem Internet verbunden. Versuche es später nochmal.",
|
||||
"unauthorized": "Du hast keine Berechtigungen {{permission}} um diese Seite aufzurufen",
|
||||
"needVerification": "Dein Konto muss vom Server Administrator verifiziert werden bevor du es benutzen kannst.",
|
||||
"needAccount": "Diese Seite kann als Gast nicht aufgerufen werden. Du musst dich anmelden oder ein Konto erstellen."
|
||||
"needAccount": "Diese Seite kann als Gast nicht aufgerufen werden. Du musst dich anmelden oder ein Konto erstellen.",
|
||||
"setup": {
|
||||
"MissingAdminAccount": "Es wurde noch kein Adminkonto erstellt. Bitte registriere dich um eines zu erstellen.",
|
||||
"NoVideoFound": "Es wurden noch keine Videos gefunden. Füge deinem Medienordner Filme oder Serien hinzu um diese hier anzuzeigen!"
|
||||
}
|
||||
},
|
||||
"mediainfo": {
|
||||
"file": "Datei",
|
||||
@ -240,5 +254,31 @@
|
||||
"scan": "Mediathek scannen",
|
||||
"empty": "Keine Probleme gefunden. Alle Elemente sind eingelesen"
|
||||
}
|
||||
},
|
||||
"genres": {
|
||||
"Family": "Familienfilm",
|
||||
"Animation": "Animation",
|
||||
"Comedy": "Komödie",
|
||||
"Crime": "Krimi",
|
||||
"Documentary": "Dokumentation",
|
||||
"Drama": "Drama",
|
||||
"Fantasy": "Fantasy",
|
||||
"Horror": "Horror",
|
||||
"Mystery": "Mystery",
|
||||
"Romance": "Liebesfilm",
|
||||
"ScienceFiction": "Science-Fiction",
|
||||
"Thriller": "Thriller",
|
||||
"War": "Kriegsfilm",
|
||||
"Western": "Western",
|
||||
"Kids": "Kinderfilm",
|
||||
"News": "Neu",
|
||||
"Reality": "Reality-TV",
|
||||
"Soap": "Soap",
|
||||
"Talk": "Talkshow",
|
||||
"Politics": "Politik",
|
||||
"Adventure": "Abenteuer",
|
||||
"History": "Geschichte",
|
||||
"Music": "Musikfilm",
|
||||
"Action": "Action"
|
||||
}
|
||||
}
|
||||
|
@ -213,7 +213,7 @@
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"guest": "Continue as guest",
|
||||
"guest-forbidden": "This instance of kyoo does not allow guests",
|
||||
"guest-forbidden": "This instance of kyoo does not allow guests.",
|
||||
"via": "Continue with {{provider}}",
|
||||
"add-account": "Add account",
|
||||
"logout": "Logout",
|
||||
@ -260,6 +260,7 @@
|
||||
"subtitles": "Subtitles",
|
||||
"forced": "Forced",
|
||||
"default": "Default",
|
||||
"external": "External",
|
||||
"duration": "Duration",
|
||||
"size": "Size",
|
||||
"novideo": "No video",
|
||||
|
@ -19,7 +19,7 @@
|
||||
"studio": "Studio",
|
||||
"genre": "Genres",
|
||||
"genre-none": "Aucun genres",
|
||||
"staff": "Staff",
|
||||
"staff": "Équipe",
|
||||
"staff-none": "Aucun membre du staff connu",
|
||||
"noOverview": "Aucune description disponible",
|
||||
"episode-none": "Il n'y a pas d'épisodes dans cette saison",
|
||||
@ -39,7 +39,8 @@
|
||||
"droped": "Marquer comme abandonné",
|
||||
"null": "Marquer comme non vu"
|
||||
},
|
||||
"nextUp": "Continuer"
|
||||
"nextUp": "Continuer",
|
||||
"season": "Saison {{number}}"
|
||||
},
|
||||
"browse": {
|
||||
"sortby": "Trier par {{key}}",
|
||||
@ -58,7 +59,15 @@
|
||||
"desc": "decs"
|
||||
},
|
||||
"switchToGrid": "Passer en vue grille",
|
||||
"switchToList": "Passer en vue liste"
|
||||
"switchToList": "Passer en vue liste",
|
||||
"mediatypekey": {
|
||||
"show": "Séries",
|
||||
"collection": "Collection",
|
||||
"movie": "Films",
|
||||
"all": "Tous"
|
||||
},
|
||||
"mediatype-tt": "Type de média",
|
||||
"mediatypelabel": "Type de média"
|
||||
},
|
||||
"misc": {
|
||||
"settings": "Paramètres",
|
||||
@ -75,10 +84,11 @@
|
||||
},
|
||||
"navbar": {
|
||||
"home": "Accueil",
|
||||
"browse": "Liste",
|
||||
"browse": "Galerie",
|
||||
"search": "Rechercher",
|
||||
"login": "Connexion",
|
||||
"admin": "Section admin"
|
||||
"admin": "Section admin",
|
||||
"download": "Télécharger"
|
||||
},
|
||||
"settings": {
|
||||
"general": {
|
||||
@ -168,7 +178,7 @@
|
||||
"transmux": "Original",
|
||||
"auto": "Auto",
|
||||
"notInPristine": "Non disponible en pristine",
|
||||
"unsupportedError": "Video codec not supported, transcoding in progress..."
|
||||
"unsupportedError": "Le codec vidéo n'est pas pris en charge, transcode en cours…"
|
||||
},
|
||||
"search": {
|
||||
"empty": "Aucun résultat trouvé. Essayer avec une autre recherche."
|
||||
@ -183,18 +193,18 @@
|
||||
"logout": "Déconnexion",
|
||||
"server": "Addresse du serveur",
|
||||
"email": "Email",
|
||||
"username": "Username",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"confirm": "Confirm Password",
|
||||
"confirm": "Confirmez votre mot de passe",
|
||||
"or-register": "Vous n'avez pas de compte ? <1>Inscrivez-vous</1>.",
|
||||
"or-login": "Vous avez déjà un compte ? <1>Connectez-vous.<1/>",
|
||||
"password-no-match": "Mots de passe differents",
|
||||
"or-login": "Vous avez déjà un compte ? <1>Connectez-vous<1/>.",
|
||||
"password-no-match": "Mots de passe différents.",
|
||||
"delete": "Supprimer votre compte",
|
||||
"delete-confirmation": "Cette action ne peut pas être annulée. Êtes-vous sur?"
|
||||
"delete-confirmation": "Cette action ne peut pas être annulée. Êtes-vous sûr ?"
|
||||
},
|
||||
"downloads": {
|
||||
"empty": "Rien de téléchargé pour l'instant, commencez à rechercher quelque chose que vous aimez",
|
||||
"error": "Erreur: {{error}}",
|
||||
"error": "Erreur : {{error}}",
|
||||
"delete": "Supprimer un item télechargé",
|
||||
"deleteMessage": "Voulez-vous vraiment supprimer un item télechgargé ?",
|
||||
"pause": "Pause",
|
||||
@ -210,7 +220,11 @@
|
||||
"offline": "Vous n'êtes pas connecté à Internet. Réessayez plus tard.",
|
||||
"unauthorized": "Il vous manque les autorisations {{permission}} pour accéder à cette page.",
|
||||
"needVerification": "Votre compte doit être vérifié par l'administrateur de votre serveur avant de pouvoir l'utiliser.",
|
||||
"needAccount": "Cette page n'est pas accessible en mode invité. Vous devez créer un compte ou vous connecter."
|
||||
"needAccount": "Cette page n'est pas accessible en mode invité. Vous devez créer un compte ou vous connecter.",
|
||||
"setup": {
|
||||
"NoVideoFound": "Aucune vidéo n'a été trouvée pour le moment. Ajoutez des films ou des séries dans votre librairie afin qu'ils puissent s'afficher ici !",
|
||||
"MissingAdminAccount": "Aucun compte administrateur n'a été créé. Veuillez en créer un, s'il vous plaît."
|
||||
}
|
||||
},
|
||||
"mediainfo": {
|
||||
"file": "Fichier",
|
||||
@ -240,5 +254,31 @@
|
||||
"scan": "Déclencher le scan de la bibliothèque",
|
||||
"empty": "Aucun problème trouvé. Toutes vos vidéos sont enregistrés."
|
||||
}
|
||||
},
|
||||
"genres": {
|
||||
"Action": "Action",
|
||||
"Adventure": "Aventure",
|
||||
"Comedy": "Comédie",
|
||||
"Documentary": "Documentaire",
|
||||
"Drama": "Drama",
|
||||
"Family": "Famille",
|
||||
"Fantasy": "Fantastique",
|
||||
"History": "Histoire",
|
||||
"Crime": "Scène de crime",
|
||||
"Horror": "Horreur",
|
||||
"Music": "Musique",
|
||||
"Mystery": "Mystère",
|
||||
"Romance": "Romance",
|
||||
"ScienceFiction": "Science-fiction",
|
||||
"War": "Guerre",
|
||||
"Kids": "Jeunesse",
|
||||
"Thriller": "Thriller",
|
||||
"Western": "Western",
|
||||
"Politics": "Politique",
|
||||
"Soap": "Soap",
|
||||
"Talk": "Talkshow",
|
||||
"Animation": "Animation",
|
||||
"News": "Actualité",
|
||||
"Reality": "Télé-réalité"
|
||||
}
|
||||
}
|
||||
|
284
front/translations/ml.json
Normal file
284
front/translations/ml.json
Normal file
@ -0,0 +1,284 @@
|
||||
{
|
||||
"home": {
|
||||
"recommended": "ശുപാർശ ചെയ്ത",
|
||||
"news": "വാർത്ത",
|
||||
"watchlist": "കാണുന്നത് തുടരുക",
|
||||
"info": "കൂടുതൽ കാണുക",
|
||||
"none": "എപ്പിസോഡുകളൊന്നുമില്ല",
|
||||
"watchlistLogin": "നിങ്ങൾ കണ്ടതിൻ്റെ ട്രാക്ക് സൂക്ഷിക്കുന്നതിനോ കാണാൻ പ്ലാൻ ചെയ്യുന്നതിനോ, നിങ്ങൾ ലോഗിൻ ചെയ്യേണ്ടതുണ്ട്.",
|
||||
"refreshMetadata": "മെറ്റാഡാറ്റ പുതുക്കുക",
|
||||
"episodeMore": {
|
||||
"goToShow": "",
|
||||
"download": "ഡൗൺലോഡ്",
|
||||
"mediainfo": "ഫയൽ വിവരം കാണുക"
|
||||
}
|
||||
},
|
||||
"show": {
|
||||
"play": "",
|
||||
"trailer": "",
|
||||
"studio": "",
|
||||
"genre": "",
|
||||
"genre-none": "",
|
||||
"staff": "",
|
||||
"staff-none": "",
|
||||
"noOverview": "",
|
||||
"episode-none": "",
|
||||
"episodeNoMetadata": "",
|
||||
"tags": "",
|
||||
"links": "",
|
||||
"jumpToSeason": "",
|
||||
"partOf": "",
|
||||
"watchlistAdd": "",
|
||||
"watchlistEdit": "",
|
||||
"watchlistRemove": "",
|
||||
"watchlistLogin": "",
|
||||
"watchlistMark": {
|
||||
"completed": "",
|
||||
"planned": "",
|
||||
"watching": "",
|
||||
"droped": "",
|
||||
"null": ""
|
||||
},
|
||||
"nextUp": "",
|
||||
"season": ""
|
||||
},
|
||||
"browse": {
|
||||
"mediatypekey": {
|
||||
"all": "",
|
||||
"movie": "",
|
||||
"show": "",
|
||||
"collection": ""
|
||||
},
|
||||
"mediatype-tt": "",
|
||||
"mediatypelabel": "",
|
||||
"sortby": "",
|
||||
"sortby-tt": "",
|
||||
"sortkey": {
|
||||
"relevance": "",
|
||||
"name": "",
|
||||
"airDate": "",
|
||||
"startAir": "",
|
||||
"endAir": "",
|
||||
"addedDate": "",
|
||||
"rating": ""
|
||||
},
|
||||
"sortord": {
|
||||
"asc": "",
|
||||
"desc": ""
|
||||
},
|
||||
"switchToGrid": "",
|
||||
"switchToList": ""
|
||||
},
|
||||
"genres": {
|
||||
"Action": "",
|
||||
"Adventure": "",
|
||||
"Animation": "",
|
||||
"Comedy": "",
|
||||
"Crime": "",
|
||||
"Documentary": "",
|
||||
"Drama": "",
|
||||
"Family": "",
|
||||
"Fantasy": "",
|
||||
"History": "",
|
||||
"Horror": "",
|
||||
"Music": "",
|
||||
"Mystery": "",
|
||||
"Romance": "",
|
||||
"ScienceFiction": "",
|
||||
"Thriller": "",
|
||||
"War": "",
|
||||
"Western": "",
|
||||
"Kids": "",
|
||||
"News": "",
|
||||
"Reality": "",
|
||||
"Soap": "",
|
||||
"Talk": "",
|
||||
"Politics": ""
|
||||
},
|
||||
"misc": {
|
||||
"settings": "",
|
||||
"prev-page": "",
|
||||
"next-page": "",
|
||||
"delete": "",
|
||||
"cancel": "",
|
||||
"more": "",
|
||||
"expand": "",
|
||||
"collapse": "",
|
||||
"edit": "",
|
||||
"or": "",
|
||||
"loading": ""
|
||||
},
|
||||
"navbar": {
|
||||
"home": "",
|
||||
"browse": "",
|
||||
"download": "",
|
||||
"search": "",
|
||||
"login": "",
|
||||
"admin": ""
|
||||
},
|
||||
"settings": {
|
||||
"general": {
|
||||
"label": "",
|
||||
"theme": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"auto": "",
|
||||
"light": "",
|
||||
"dark": ""
|
||||
},
|
||||
"language": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"system": ""
|
||||
}
|
||||
},
|
||||
"playback": {
|
||||
"label": "",
|
||||
"playmode": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"audioLanguage": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"subtitleLanguage": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"none": ""
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"label": "",
|
||||
"username": {
|
||||
"label": ""
|
||||
},
|
||||
"avatar": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"email": {
|
||||
"label": ""
|
||||
},
|
||||
"password": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"oldPassword": "",
|
||||
"newPassword": ""
|
||||
}
|
||||
},
|
||||
"oidc": {
|
||||
"label": "",
|
||||
"connected": "",
|
||||
"not-connected": "",
|
||||
"open-profile": "",
|
||||
"link": "",
|
||||
"delete": ""
|
||||
},
|
||||
"about": {
|
||||
"label": "",
|
||||
"android-app": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"git": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"back": "",
|
||||
"previous": "",
|
||||
"next": "",
|
||||
"play": "",
|
||||
"pause": "",
|
||||
"mute": "",
|
||||
"volume": "",
|
||||
"quality": "",
|
||||
"audios": "",
|
||||
"subtitles": "",
|
||||
"subtitle-none": "",
|
||||
"fullscreen": "",
|
||||
"direct": "",
|
||||
"transmux": "",
|
||||
"auto": "",
|
||||
"notInPristine": "",
|
||||
"unsupportedError": ""
|
||||
},
|
||||
"search": {
|
||||
"empty": ""
|
||||
},
|
||||
"login": {
|
||||
"login": "",
|
||||
"register": "",
|
||||
"guest": "",
|
||||
"guest-forbidden": "",
|
||||
"via": "",
|
||||
"add-account": "",
|
||||
"logout": "",
|
||||
"server": "",
|
||||
"email": "",
|
||||
"username": "",
|
||||
"password": "",
|
||||
"confirm": "",
|
||||
"or-register": "",
|
||||
"or-login": "",
|
||||
"password-no-match": "",
|
||||
"delete": "",
|
||||
"delete-confirmation": ""
|
||||
},
|
||||
"downloads": {
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"delete": "",
|
||||
"deleteMessage": "",
|
||||
"pause": "",
|
||||
"resume": "",
|
||||
"retry": ""
|
||||
},
|
||||
"errors": {
|
||||
"connection": "",
|
||||
"connection-tips": "",
|
||||
"unknown": "",
|
||||
"try-again": "",
|
||||
"re-login": "",
|
||||
"offline": "",
|
||||
"unauthorized": "",
|
||||
"needVerification": "",
|
||||
"needAccount": "",
|
||||
"setup": {
|
||||
"MissingAdminAccount": "",
|
||||
"NoVideoFound": ""
|
||||
}
|
||||
},
|
||||
"mediainfo": {
|
||||
"file": "",
|
||||
"container": "",
|
||||
"video": "",
|
||||
"audio": "",
|
||||
"subtitles": "",
|
||||
"forced": "",
|
||||
"default": "",
|
||||
"duration": "",
|
||||
"size": "",
|
||||
"novideo": "",
|
||||
"nocontainer": ""
|
||||
},
|
||||
"admin": {
|
||||
"users": {
|
||||
"label": "",
|
||||
"adminUser": "",
|
||||
"regularUser": "",
|
||||
"set-permissions": "",
|
||||
"delete": "",
|
||||
"unverifed": "",
|
||||
"verify": ""
|
||||
},
|
||||
"scanner": {
|
||||
"label": "",
|
||||
"scan": "",
|
||||
"empty": ""
|
||||
}
|
||||
}
|
||||
}
|
@ -147,12 +147,24 @@
|
||||
"delete": "Usuń konto",
|
||||
"login": "Zaloguj się",
|
||||
"or-register": "Nie masz konta? <1>Register</1>.",
|
||||
"or-login": "Posiadasz konto? <1>Log in</1>."
|
||||
"or-login": "Posiadasz konto? <1>Log in</1>.",
|
||||
"password-no-match": "Hasła nie są takie same.",
|
||||
"delete-confirmation": "Ta akcja nie może zostać cofnięta. Napewno chcesz to zrobić?"
|
||||
},
|
||||
"errors": {
|
||||
"unknown": "Nieznany błąd",
|
||||
"try-again": "Spróbuj ponownie",
|
||||
"re-login": "Zaloguj się ponownie"
|
||||
"re-login": "Zaloguj się ponownie",
|
||||
"unauthorized": "Brakuje ci uprawnień {{permission}} aby otworzyć tę stronę.",
|
||||
"setup": {
|
||||
"MissingAdminAccount": "Konto administratora nie zostało jeszcze stworzone. Zarejestruj się aby je stworzyć.",
|
||||
"NoVideoFound": "Żaden film nie został jeszcze znaleziony. Dodaj filmy lub seriale do twojego folderu biblioteki aby je tu wyświetlić!"
|
||||
},
|
||||
"offline": "Brak połączenia z internetem. Spróbuj ponownie później.",
|
||||
"connection": "Nie udało się połączyć z serwerami kyoo",
|
||||
"connection-tips": "Wskazówki dotyczące rozwiązywania problemów:\n- Czy masz połączenie z Internetem?\n- Czy serwer kyoo jest online?\n- Czy twoje konto zostało zablokowane?",
|
||||
"needVerification": "Twoje konto musi zostać zweryfikowane przez administratora twojego serweru, zanim będziesz mógł go używać.",
|
||||
"needAccount": "Aby odwiedzić tę stronę musisz zalogować się lub stworzyć konto."
|
||||
},
|
||||
"browse": {
|
||||
"sortkey": {
|
||||
@ -171,7 +183,15 @@
|
||||
"switchToGrid": "Widok siatki",
|
||||
"switchToList": "Widok listy",
|
||||
"sortby-tt": "Sortuj",
|
||||
"sortby": "Sortuj według {{key}}"
|
||||
"sortby": "Sortuj według {{key}}",
|
||||
"mediatypekey": {
|
||||
"all": "Wszystko",
|
||||
"movie": "Filmy",
|
||||
"show": "Seriale",
|
||||
"collection": "Kolekcja"
|
||||
},
|
||||
"mediatype-tt": "Typ",
|
||||
"mediatypelabel": "Typ"
|
||||
},
|
||||
"misc": {
|
||||
"more": "Więcej",
|
||||
@ -220,6 +240,43 @@
|
||||
"browse": "Przeglądaj",
|
||||
"search": "Wyszukaj",
|
||||
"login": "Zaloguj się",
|
||||
"admin": "Panel Administratora"
|
||||
"admin": "Panel Administratora",
|
||||
"download": "Pobierz"
|
||||
},
|
||||
"downloads": {
|
||||
"delete": "Usuń",
|
||||
"empty": "Nic jeszcze nie pobrano, zacznij przeglądać w celu znalezienia czegoś, co Ci się podoba",
|
||||
"deleteMessage": "Chcesz usunąć ten element z lokalnej pamięci?",
|
||||
"pause": "Zatrzymaj",
|
||||
"resume": "Wznów",
|
||||
"retry": "Ponów",
|
||||
"error": "Błąd:{{error}}"
|
||||
},
|
||||
"mediainfo": {
|
||||
"audio": "Audio",
|
||||
"subtitles": "Napisy",
|
||||
"default": "Domyślne",
|
||||
"duration": "Czas trwania",
|
||||
"size": "Rozmiar",
|
||||
"novideo": "Brak filmu",
|
||||
"forced": "Wymuszony",
|
||||
"video": "Wideo",
|
||||
"file": "Plik"
|
||||
},
|
||||
"admin": {
|
||||
"users": {
|
||||
"adminUser": "Administrator",
|
||||
"regularUser": "Użytkownik",
|
||||
"set-permissions": "Nadawanie uprawnień",
|
||||
"delete": "Usuń użytkownika",
|
||||
"unverifed": "Niezweryfikowany",
|
||||
"verify": "Zweryfikuj użytkownika",
|
||||
"label": "Użytkownicy"
|
||||
},
|
||||
"scanner": {
|
||||
"label": "Skaner",
|
||||
"scan": "Wywołaj skanowanie biblioteki",
|
||||
"empty": "Nie znaleziono błędów. Wszystkie elementy są zarejestrowane."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
284
front/translations/pt.json
Normal file
284
front/translations/pt.json
Normal file
@ -0,0 +1,284 @@
|
||||
{
|
||||
"home": {
|
||||
"recommended": "Recomendados",
|
||||
"news": "Notícias",
|
||||
"watchlist": "Continue assistindo",
|
||||
"info": "Veja mais",
|
||||
"none": "Nenhum episódio",
|
||||
"watchlistLogin": "",
|
||||
"refreshMetadata": "",
|
||||
"episodeMore": {
|
||||
"goToShow": "",
|
||||
"download": "",
|
||||
"mediainfo": ""
|
||||
}
|
||||
},
|
||||
"show": {
|
||||
"play": "",
|
||||
"trailer": "",
|
||||
"studio": "",
|
||||
"genre": "",
|
||||
"genre-none": "",
|
||||
"staff": "",
|
||||
"staff-none": "",
|
||||
"noOverview": "",
|
||||
"episode-none": "",
|
||||
"episodeNoMetadata": "",
|
||||
"tags": "",
|
||||
"links": "",
|
||||
"jumpToSeason": "",
|
||||
"partOf": "",
|
||||
"watchlistAdd": "",
|
||||
"watchlistEdit": "",
|
||||
"watchlistRemove": "",
|
||||
"watchlistLogin": "",
|
||||
"watchlistMark": {
|
||||
"completed": "",
|
||||
"planned": "",
|
||||
"watching": "",
|
||||
"droped": "",
|
||||
"null": ""
|
||||
},
|
||||
"nextUp": "",
|
||||
"season": ""
|
||||
},
|
||||
"browse": {
|
||||
"mediatypekey": {
|
||||
"all": "",
|
||||
"movie": "",
|
||||
"show": "",
|
||||
"collection": ""
|
||||
},
|
||||
"mediatype-tt": "",
|
||||
"mediatypelabel": "",
|
||||
"sortby": "",
|
||||
"sortby-tt": "",
|
||||
"sortkey": {
|
||||
"relevance": "",
|
||||
"name": "",
|
||||
"airDate": "",
|
||||
"startAir": "",
|
||||
"endAir": "",
|
||||
"addedDate": "",
|
||||
"rating": ""
|
||||
},
|
||||
"sortord": {
|
||||
"asc": "",
|
||||
"desc": ""
|
||||
},
|
||||
"switchToGrid": "",
|
||||
"switchToList": ""
|
||||
},
|
||||
"genres": {
|
||||
"Action": "",
|
||||
"Adventure": "",
|
||||
"Animation": "",
|
||||
"Comedy": "",
|
||||
"Crime": "",
|
||||
"Documentary": "",
|
||||
"Drama": "",
|
||||
"Family": "",
|
||||
"Fantasy": "",
|
||||
"History": "",
|
||||
"Horror": "",
|
||||
"Music": "",
|
||||
"Mystery": "",
|
||||
"Romance": "",
|
||||
"ScienceFiction": "",
|
||||
"Thriller": "",
|
||||
"War": "",
|
||||
"Western": "",
|
||||
"Kids": "",
|
||||
"News": "",
|
||||
"Reality": "",
|
||||
"Soap": "",
|
||||
"Talk": "",
|
||||
"Politics": ""
|
||||
},
|
||||
"misc": {
|
||||
"settings": "",
|
||||
"prev-page": "",
|
||||
"next-page": "",
|
||||
"delete": "",
|
||||
"cancel": "",
|
||||
"more": "",
|
||||
"expand": "",
|
||||
"collapse": "",
|
||||
"edit": "",
|
||||
"or": "",
|
||||
"loading": ""
|
||||
},
|
||||
"navbar": {
|
||||
"home": "",
|
||||
"browse": "",
|
||||
"download": "",
|
||||
"search": "",
|
||||
"login": "",
|
||||
"admin": ""
|
||||
},
|
||||
"settings": {
|
||||
"general": {
|
||||
"label": "",
|
||||
"theme": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"auto": "",
|
||||
"light": "",
|
||||
"dark": ""
|
||||
},
|
||||
"language": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"system": ""
|
||||
}
|
||||
},
|
||||
"playback": {
|
||||
"label": "",
|
||||
"playmode": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"audioLanguage": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"subtitleLanguage": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"none": ""
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"label": "",
|
||||
"username": {
|
||||
"label": ""
|
||||
},
|
||||
"avatar": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"email": {
|
||||
"label": ""
|
||||
},
|
||||
"password": {
|
||||
"label": "",
|
||||
"description": "",
|
||||
"oldPassword": "",
|
||||
"newPassword": ""
|
||||
}
|
||||
},
|
||||
"oidc": {
|
||||
"label": "",
|
||||
"connected": "",
|
||||
"not-connected": "",
|
||||
"open-profile": "",
|
||||
"link": "",
|
||||
"delete": ""
|
||||
},
|
||||
"about": {
|
||||
"label": "",
|
||||
"android-app": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"git": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"back": "",
|
||||
"previous": "",
|
||||
"next": "",
|
||||
"play": "",
|
||||
"pause": "",
|
||||
"mute": "",
|
||||
"volume": "",
|
||||
"quality": "",
|
||||
"audios": "",
|
||||
"subtitles": "",
|
||||
"subtitle-none": "",
|
||||
"fullscreen": "",
|
||||
"direct": "",
|
||||
"transmux": "",
|
||||
"auto": "",
|
||||
"notInPristine": "",
|
||||
"unsupportedError": ""
|
||||
},
|
||||
"search": {
|
||||
"empty": ""
|
||||
},
|
||||
"login": {
|
||||
"login": "",
|
||||
"register": "",
|
||||
"guest": "",
|
||||
"guest-forbidden": "",
|
||||
"via": "",
|
||||
"add-account": "",
|
||||
"logout": "",
|
||||
"server": "",
|
||||
"email": "",
|
||||
"username": "",
|
||||
"password": "",
|
||||
"confirm": "",
|
||||
"or-register": "",
|
||||
"or-login": "",
|
||||
"password-no-match": "",
|
||||
"delete": "",
|
||||
"delete-confirmation": ""
|
||||
},
|
||||
"downloads": {
|
||||
"empty": "",
|
||||
"error": "",
|
||||
"delete": "",
|
||||
"deleteMessage": "",
|
||||
"pause": "",
|
||||
"resume": "",
|
||||
"retry": ""
|
||||
},
|
||||
"errors": {
|
||||
"connection": "",
|
||||
"connection-tips": "",
|
||||
"unknown": "",
|
||||
"try-again": "",
|
||||
"re-login": "",
|
||||
"offline": "",
|
||||
"unauthorized": "",
|
||||
"needVerification": "",
|
||||
"needAccount": "",
|
||||
"setup": {
|
||||
"MissingAdminAccount": "",
|
||||
"NoVideoFound": ""
|
||||
}
|
||||
},
|
||||
"mediainfo": {
|
||||
"file": "",
|
||||
"container": "",
|
||||
"video": "",
|
||||
"audio": "",
|
||||
"subtitles": "",
|
||||
"forced": "",
|
||||
"default": "",
|
||||
"duration": "",
|
||||
"size": "",
|
||||
"novideo": "",
|
||||
"nocontainer": ""
|
||||
},
|
||||
"admin": {
|
||||
"users": {
|
||||
"label": "",
|
||||
"adminUser": "",
|
||||
"regularUser": "",
|
||||
"set-permissions": "",
|
||||
"delete": "",
|
||||
"unverifed": "",
|
||||
"verify": ""
|
||||
},
|
||||
"scanner": {
|
||||
"label": "",
|
||||
"scan": "",
|
||||
"empty": ""
|
||||
}
|
||||
}
|
||||
}
|
284
front/translations/pt_br.json
Normal file
284
front/translations/pt_br.json
Normal file
@ -0,0 +1,284 @@
|
||||
{
|
||||
"home": {
|
||||
"recommended": "Recomendados",
|
||||
"news": "Notícias",
|
||||
"watchlist": "Continue assistindo",
|
||||
"info": "Veja mais",
|
||||
"none": "Nenhum episódio",
|
||||
"watchlistLogin": "Para salvar o que você assistiu ou planeja assistir, é necessário fazer login.",
|
||||
"refreshMetadata": "Atualizar metadados",
|
||||
"episodeMore": {
|
||||
"goToShow": "Ir para série",
|
||||
"download": "Baixar",
|
||||
"mediainfo": "Veja informação do arquivo"
|
||||
}
|
||||
},
|
||||
"show": {
|
||||
"play": "Reproduzir",
|
||||
"trailer": "Reproduzir Trailer",
|
||||
"studio": "Estúdio",
|
||||
"genre": "Gêneros",
|
||||
"genre-none": "Nenhum gênero",
|
||||
"staff": "Staff",
|
||||
"staff-none": "A staff é desconhecida",
|
||||
"noOverview": "Resumo indisponível",
|
||||
"episode-none": "Nenhum episódio nessa temporada",
|
||||
"episodeNoMetadata": "Metadado indisponível",
|
||||
"tags": "Tags",
|
||||
"links": "Links",
|
||||
"jumpToSeason": "Pular para temporada",
|
||||
"partOf": "Parte de",
|
||||
"watchlistAdd": "Adicionar a lista de desejos",
|
||||
"watchlistEdit": "Editar status de visto",
|
||||
"watchlistRemove": "Marque como não assistido",
|
||||
"watchlistLogin": "Faça login para adicionar a sua lista",
|
||||
"watchlistMark": {
|
||||
"completed": "Marque como assistido",
|
||||
"planned": "Marque como planejado",
|
||||
"watching": "Marque como assistindo",
|
||||
"droped": "Marque como desistido",
|
||||
"null": "Marque como não assistido"
|
||||
},
|
||||
"nextUp": "Próximo",
|
||||
"season": "Temporada {{number}}"
|
||||
},
|
||||
"browse": {
|
||||
"mediatypekey": {
|
||||
"all": "Todos",
|
||||
"movie": "Filmes",
|
||||
"show": "Séries",
|
||||
"collection": "Coleção"
|
||||
},
|
||||
"mediatype-tt": "Tipo de mídia",
|
||||
"mediatypelabel": "Tipo de mídia",
|
||||
"sortby": "Ordenar por {{key}}",
|
||||
"sortby-tt": "Ordenar por",
|
||||
"sortkey": {
|
||||
"relevance": "Relevância",
|
||||
"name": "Nome",
|
||||
"airDate": "Data de transmissão",
|
||||
"startAir": "Data de início",
|
||||
"endAir": "Data de finalização",
|
||||
"addedDate": "Adicionado na data",
|
||||
"rating": "Avaliações"
|
||||
},
|
||||
"sortord": {
|
||||
"asc": "crescente",
|
||||
"desc": "decrescente"
|
||||
},
|
||||
"switchToGrid": "Mudara para visualização em grade",
|
||||
"switchToList": "Mudara para visualização de lista"
|
||||
},
|
||||
"genres": {
|
||||
"Action": "Ação",
|
||||
"Adventure": "Aventura",
|
||||
"Animation": "Animação",
|
||||
"Comedy": "Comédia",
|
||||
"Crime": "Crime",
|
||||
"Documentary": "Documentário",
|
||||
"Drama": "Drama",
|
||||
"Family": "Família",
|
||||
"Fantasy": "Fantasia",
|
||||
"History": "História",
|
||||
"Horror": "Terror",
|
||||
"Music": "Música",
|
||||
"Mystery": "Mistério",
|
||||
"Romance": "Romance",
|
||||
"ScienceFiction": "Ficção cientifica",
|
||||
"Thriller": "Suspense",
|
||||
"War": "Guerra",
|
||||
"Western": "Faroeste",
|
||||
"Kids": "Infantil",
|
||||
"News": "Notícias",
|
||||
"Reality": "Realidade",
|
||||
"Soap": "Novela",
|
||||
"Talk": "",
|
||||
"Politics": "Política"
|
||||
},
|
||||
"misc": {
|
||||
"settings": "Configurações",
|
||||
"prev-page": "Pagina anterior",
|
||||
"next-page": "Próxima pagina",
|
||||
"delete": "Deletar",
|
||||
"cancel": "Cancelar",
|
||||
"more": "Mais",
|
||||
"expand": "Expandir",
|
||||
"collapse": "Recolher",
|
||||
"edit": "Editar",
|
||||
"or": "OU",
|
||||
"loading": "Carregando"
|
||||
},
|
||||
"navbar": {
|
||||
"home": "Início",
|
||||
"browse": "Navegar",
|
||||
"download": "Baixar",
|
||||
"search": "Pesquisar",
|
||||
"login": "Entrar",
|
||||
"admin": "Painel de admin"
|
||||
},
|
||||
"settings": {
|
||||
"general": {
|
||||
"label": "Geral",
|
||||
"theme": {
|
||||
"label": "Tema",
|
||||
"description": "Defina o tema da sua aplicação",
|
||||
"auto": "Sistema",
|
||||
"light": "Claro",
|
||||
"dark": "Escuro"
|
||||
},
|
||||
"language": {
|
||||
"label": "Linguagem",
|
||||
"description": "Defina o idioma da sua aplicação",
|
||||
"system": "Sistema"
|
||||
}
|
||||
},
|
||||
"playback": {
|
||||
"label": "Reprodução",
|
||||
"playmode": {
|
||||
"label": "Modo de reprodução padrão",
|
||||
"description": "O modo de reprodução padrão usado neste cliente. Puro é mais leve mas não permite mudanças de qualidade automáticas"
|
||||
},
|
||||
"audioLanguage": {
|
||||
"label": "Idioma do áudio",
|
||||
"description": "O idioma de áudio padrão usado ao reproduzir vídeos multi-linguagem"
|
||||
},
|
||||
"subtitleLanguage": {
|
||||
"label": "Idioma das legendas",
|
||||
"description": "O idioma padrão usado nas legendas",
|
||||
"none": "Nenhum"
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"label": "Conta",
|
||||
"username": {
|
||||
"label": "Usuário"
|
||||
},
|
||||
"avatar": {
|
||||
"label": "Foto",
|
||||
"description": "Mude seu ícone de perfil"
|
||||
},
|
||||
"email": {
|
||||
"label": "Email"
|
||||
},
|
||||
"password": {
|
||||
"label": "Senha",
|
||||
"description": "Mude sua senha",
|
||||
"oldPassword": "Senha antiga",
|
||||
"newPassword": "Nova senha"
|
||||
}
|
||||
},
|
||||
"oidc": {
|
||||
"label": "Contas vinculadas",
|
||||
"connected": "Conectado como {{username}}.",
|
||||
"not-connected": "Desconectado",
|
||||
"open-profile": "Abre seu perfil em {{provider}}",
|
||||
"link": "Link",
|
||||
"delete": "Desvincule sua conta kyoo's do provedor {{provider}}"
|
||||
},
|
||||
"about": {
|
||||
"label": "Sobre",
|
||||
"android-app": {
|
||||
"label": "Aplicativo Android",
|
||||
"description": "Baixe o aplicativo Android"
|
||||
},
|
||||
"git": {
|
||||
"label": "Github",
|
||||
"description": "Abre o repositório no GitHub onde é possivel acessar o código do kyoo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"back": "Voltar",
|
||||
"previous": "Episódio anterior",
|
||||
"next": "Próximo episódio",
|
||||
"play": "Reproduzir",
|
||||
"pause": "Pausar",
|
||||
"mute": "Mutar",
|
||||
"volume": "Volume",
|
||||
"quality": "Qualidade",
|
||||
"audios": "Áudio",
|
||||
"subtitles": "Legendas",
|
||||
"subtitle-none": "Nenhum",
|
||||
"fullscreen": "Tela cheia",
|
||||
"direct": "Puro",
|
||||
"transmux": "Original",
|
||||
"auto": "Automático",
|
||||
"notInPristine": "Indisponível no \"Puro\"",
|
||||
"unsupportedError": "Codec de vídeo não suportado, transcodificando..."
|
||||
},
|
||||
"search": {
|
||||
"empty": "Nenhum resultado encontrado. Tente algo diferente."
|
||||
},
|
||||
"login": {
|
||||
"login": "Entrar",
|
||||
"register": "Registrar",
|
||||
"guest": "Continuar como visitante",
|
||||
"guest-forbidden": "Essa instância kyoo não permite visitantes.",
|
||||
"via": "Continuar com {{provider}}",
|
||||
"add-account": "Adicionar conta",
|
||||
"logout": "Sair",
|
||||
"server": "Endereço do servidor",
|
||||
"email": "E-mail",
|
||||
"username": "Usuário",
|
||||
"password": "Senha",
|
||||
"confirm": "Confirmar senha",
|
||||
"or-register": "Não tem uma conta? <1>Registre-se</1>.",
|
||||
"or-login": "Já possui uma conta? <1>Entre</1>.",
|
||||
"password-no-match": "Senhas não coincidem.",
|
||||
"delete": "Apagar sua conta",
|
||||
"delete-confirmation": "Esta ação não pode ser revertida. Tem certeza?"
|
||||
},
|
||||
"downloads": {
|
||||
"empty": "Nada baixado ainda, comece a explorar algo que lhe interesse",
|
||||
"error": "Erro: {{error}}",
|
||||
"delete": "Remover item",
|
||||
"deleteMessage": "Você deseja remover este item do seu armazenamento local?",
|
||||
"pause": "Pausar",
|
||||
"resume": "Retomar",
|
||||
"retry": "Tentar novamente"
|
||||
},
|
||||
"errors": {
|
||||
"connection": "Não foi possível se conectar com servidor kyoo",
|
||||
"connection-tips": "Dicas de solução:\n- Você está conectado a internet?\n- Seu servidor kyoo está online?\n- Sua conta foi banida?",
|
||||
"unknown": "Erro desconhecido",
|
||||
"try-again": "Tente novamente",
|
||||
"re-login": "Entrar novamente",
|
||||
"offline": "Você não está conectado a internet. Tente novamente mais tarde.",
|
||||
"unauthorized": "Você não possui as permissões {{permission}} para acessar esta página.",
|
||||
"needVerification": "Sua conta precisa ser verificada pelo administrador antes que você possa usá-la.",
|
||||
"needAccount": "Esta página não pode ser acessada em modo visitante. Voce precisa criar uma conta ou entrar.",
|
||||
"setup": {
|
||||
"MissingAdminAccount": "Nenhuma conta adminisitrativa foi criada ainda. Por favor registre-se para criá-la.",
|
||||
"NoVideoFound": "Nenhum vídeo encontrado. Adicione filmes ou séries na sua biblioteca para que eles apareçam aqui!"
|
||||
}
|
||||
},
|
||||
"mediainfo": {
|
||||
"file": "Arquivo",
|
||||
"container": "Contêiner",
|
||||
"video": "Vídeo",
|
||||
"audio": "Áudio",
|
||||
"subtitles": "Legendas",
|
||||
"forced": "Forçado",
|
||||
"default": "Padrão",
|
||||
"duration": "Duração",
|
||||
"size": "Tamanho",
|
||||
"novideo": "Sem vídeo",
|
||||
"nocontainer": "Contêiner inválido"
|
||||
},
|
||||
"admin": {
|
||||
"users": {
|
||||
"label": "Usuários",
|
||||
"adminUser": "Administrador",
|
||||
"regularUser": "Usuário",
|
||||
"set-permissions": "Definir permissões",
|
||||
"delete": "Remover usuário",
|
||||
"unverifed": "Não verificado",
|
||||
"verify": "Verificar usuário"
|
||||
},
|
||||
"scanner": {
|
||||
"label": "Escanear",
|
||||
"scan": "Iniciar escaneamento de mídia",
|
||||
"empty": "Nenhum problema encontrado. Todos os seus itens estão registrados."
|
||||
}
|
||||
}
|
||||
}
|
284
front/translations/ru.json
Normal file
284
front/translations/ru.json
Normal file
@ -0,0 +1,284 @@
|
||||
{
|
||||
"home": {
|
||||
"recommended": "Рекомендации",
|
||||
"news": "Новинки",
|
||||
"watchlist": "Продолжить просмотр",
|
||||
"info": "Ещё",
|
||||
"none": "Эпизоды отсутствуют",
|
||||
"watchlistLogin": "Чтобы следить за тем, что вы смотрели или планируете посмотреть, вам нужно авторизоваться.",
|
||||
"refreshMetadata": "Обновить метаданные",
|
||||
"episodeMore": {
|
||||
"goToShow": "Перейти к сериалу",
|
||||
"download": "Скачать",
|
||||
"mediainfo": "Информация о файле"
|
||||
}
|
||||
},
|
||||
"show": {
|
||||
"play": "Начать просмотр",
|
||||
"trailer": "Просмотр трейлера",
|
||||
"studio": "Студия",
|
||||
"genre": "Жанры",
|
||||
"genre-none": "Жанры отсутствуют",
|
||||
"staff": "Команда",
|
||||
"staff-none": "Неизвестная команда",
|
||||
"noOverview": "Обзор недоступен",
|
||||
"episode-none": "В этом сезоне эпизодов нет",
|
||||
"episodeNoMetadata": "Метаданные недоступны",
|
||||
"tags": "Теги",
|
||||
"links": "Ссылки",
|
||||
"jumpToSeason": "Перейти к сезону",
|
||||
"partOf": "Часть",
|
||||
"watchlistAdd": "Добавить в список к просмотру",
|
||||
"watchlistEdit": "Изменить статус просмотра",
|
||||
"watchlistRemove": "Отметить как не просмотренное",
|
||||
"watchlistLogin": "Войти чтобы добавить в список просмотра",
|
||||
"watchlistMark": {
|
||||
"completed": "Отметить как завершенное",
|
||||
"planned": "Отметить как запланированное",
|
||||
"watching": "Отметить как просматриваемое",
|
||||
"droped": "Отметить как заброшенное",
|
||||
"null": "Отметить как не просмотренное"
|
||||
},
|
||||
"nextUp": "Следующее",
|
||||
"season": "Сезон {{number}}"
|
||||
},
|
||||
"browse": {
|
||||
"mediatypekey": {
|
||||
"all": "Все",
|
||||
"movie": "Фильмы",
|
||||
"show": "Сериалы",
|
||||
"collection": "Коллекция"
|
||||
},
|
||||
"mediatype-tt": "Тип медиа",
|
||||
"mediatypelabel": "Тип медиа",
|
||||
"sortby": "Сортировать по {{key}}",
|
||||
"sortby-tt": "Сортировать по",
|
||||
"sortkey": {
|
||||
"relevance": "Актуальность",
|
||||
"name": "Имя",
|
||||
"airDate": "Дата премьеры",
|
||||
"startAir": "Впервые вышло",
|
||||
"endAir": "Завершение выхода",
|
||||
"addedDate": "Дата добавления",
|
||||
"rating": "Рейтинг"
|
||||
},
|
||||
"sortord": {
|
||||
"asc": "по возрастанию",
|
||||
"desc": "по убыванию"
|
||||
},
|
||||
"switchToGrid": "Перейти к виду сеткой",
|
||||
"switchToList": "Перейти в режим списка"
|
||||
},
|
||||
"genres": {
|
||||
"Action": "Экшн",
|
||||
"Adventure": "Приключение",
|
||||
"Animation": "Мультфильм",
|
||||
"Comedy": "Комедия",
|
||||
"Crime": "Криминал",
|
||||
"Documentary": "Документальный",
|
||||
"Drama": "Драма",
|
||||
"Family": "Семейный",
|
||||
"Fantasy": "Фэнтези",
|
||||
"History": "Исторический",
|
||||
"Horror": "Ужасы",
|
||||
"Music": "Музыкальный",
|
||||
"Mystery": "Мистический",
|
||||
"Romance": "Романтический",
|
||||
"ScienceFiction": "Научная фантастика",
|
||||
"Thriller": "Триллер",
|
||||
"War": "Военный",
|
||||
"Western": "Вестерн",
|
||||
"Kids": "Детский",
|
||||
"News": "Новости",
|
||||
"Reality": "Реалити-шоу",
|
||||
"Soap": "Мыльная опера",
|
||||
"Talk": "Ток-шоу",
|
||||
"Politics": "Политика"
|
||||
},
|
||||
"misc": {
|
||||
"settings": "Настройки",
|
||||
"prev-page": "Предыдущая страница",
|
||||
"next-page": "Следующая страница",
|
||||
"delete": "Удалить",
|
||||
"cancel": "Отмена",
|
||||
"more": "Больше",
|
||||
"expand": "Развернуть",
|
||||
"collapse": "Свернуть",
|
||||
"edit": "Редактировать",
|
||||
"or": "ИЛИ",
|
||||
"loading": "Загрузка"
|
||||
},
|
||||
"navbar": {
|
||||
"home": "Домой",
|
||||
"browse": "Библиотека",
|
||||
"download": "Скачать",
|
||||
"search": "Поиск",
|
||||
"login": "Вход",
|
||||
"admin": "Панель администратора"
|
||||
},
|
||||
"settings": {
|
||||
"general": {
|
||||
"label": "Общие настройки",
|
||||
"theme": {
|
||||
"label": "Тема",
|
||||
"description": "Установить тему для вашего приложения",
|
||||
"auto": "Система",
|
||||
"light": "Светлая",
|
||||
"dark": "Темная"
|
||||
},
|
||||
"language": {
|
||||
"label": "Язык",
|
||||
"description": "Установить язык вашего приложения",
|
||||
"system": "Системный"
|
||||
}
|
||||
},
|
||||
"playback": {
|
||||
"label": "Воспроизведение",
|
||||
"playmode": {
|
||||
"label": "Воспроизведение по умолчанию",
|
||||
"description": "Режим воспроизведения по умолчанию используется на этом клиенте. Оригинальное качество меньше нагружает сервер, но при этом не может быть изменено автоматически"
|
||||
},
|
||||
"audioLanguage": {
|
||||
"label": "Язык аудио",
|
||||
"description": "Язык по-умолчанию используется при воспроизведении видео с несколькими языковыми аудиодорожками"
|
||||
},
|
||||
"subtitleLanguage": {
|
||||
"label": "Язык субтитров",
|
||||
"description": "Язык по-умолчанию используется для субтитров",
|
||||
"none": "Никакой"
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"label": "Аккаунт",
|
||||
"username": {
|
||||
"label": "Имя пользователя"
|
||||
},
|
||||
"avatar": {
|
||||
"label": "Аватар",
|
||||
"description": "Изменить иконку профиля"
|
||||
},
|
||||
"email": {
|
||||
"label": "Email"
|
||||
},
|
||||
"password": {
|
||||
"label": "Пароль",
|
||||
"description": "Изменить пароль",
|
||||
"oldPassword": "Старый пароль",
|
||||
"newPassword": "Новый пароль"
|
||||
}
|
||||
},
|
||||
"oidc": {
|
||||
"label": "Связанные аккаунты",
|
||||
"connected": "Подключен как {{username}}.",
|
||||
"not-connected": "Отключено",
|
||||
"open-profile": "Открыть ваш {{provider}} профиль",
|
||||
"link": "Ссылка",
|
||||
"delete": "Отвязать ваш аккаунт kyoo от {{provider}}"
|
||||
},
|
||||
"about": {
|
||||
"label": "Информация",
|
||||
"android-app": {
|
||||
"label": "Приложение Android",
|
||||
"description": "Скачать приложение для Android"
|
||||
},
|
||||
"git": {
|
||||
"label": "Github",
|
||||
"description": "Открыть репозиторий Github, где вы можете посмотреть код kyoo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"back": "Назад",
|
||||
"previous": "Предыдущий эпизод",
|
||||
"next": "Следующий эпизод",
|
||||
"play": "Воспроизвести",
|
||||
"pause": "Пауза",
|
||||
"mute": "Убрать звук",
|
||||
"volume": "Громкость",
|
||||
"quality": "Качество",
|
||||
"audios": "Аудио",
|
||||
"subtitles": "Субтитры",
|
||||
"subtitle-none": "Отсутствуют",
|
||||
"fullscreen": "Полноэкранный режим",
|
||||
"direct": "Исходное",
|
||||
"transmux": "Оригинальное",
|
||||
"auto": "Автоматически",
|
||||
"notInPristine": "Недоступно в исходном",
|
||||
"unsupportedError": "Видео кодек не поддерживается, производится транскодирование..."
|
||||
},
|
||||
"search": {
|
||||
"empty": "Ничего не найдено. Попробуйте выполнить другой запрос."
|
||||
},
|
||||
"login": {
|
||||
"login": "Авторизация",
|
||||
"register": "Регистрация",
|
||||
"guest": "Продолжить как гость",
|
||||
"guest-forbidden": "Этот экземпляр kyoo не разрешает пользоваться гостевым аккаунтом",
|
||||
"via": "Продолжить с {{provider}}",
|
||||
"add-account": "Добавить аккаунт",
|
||||
"logout": "Выйти",
|
||||
"server": "Адрес сервера",
|
||||
"email": "Email",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"confirm": "Подтверждение пароля",
|
||||
"or-register": "Ещё не создали аккаунт? <1>Register</1>.",
|
||||
"or-login": "Уже есть аккаунт? <1>Log in</1>.",
|
||||
"password-no-match": "Пароли не совпадают.",
|
||||
"delete": "Удалить аккаунт",
|
||||
"delete-confirmation": "Это действие необратимо. Вы уверены?"
|
||||
},
|
||||
"downloads": {
|
||||
"empty": "Пока ничего не загружено, начните искать то, что вам нравится",
|
||||
"error": "Ошибка: {{error}}",
|
||||
"delete": "Удалить объект",
|
||||
"deleteMessage": "Вы действительно хотите удалить объект из вашего локального хранилища?",
|
||||
"pause": "Пауза",
|
||||
"resume": "Возобновить",
|
||||
"retry": "Повторить"
|
||||
},
|
||||
"errors": {
|
||||
"connection": "Не удалось подключиться к серверу kyoo",
|
||||
"connection-tips": "Возможные решения проблемы:\n - У вас есть соединение к интернету?\n - Ваш сервер kyoo онлайн?\n - Ваш аккаунт был заблокирован?",
|
||||
"unknown": "Неизвестная ошибка",
|
||||
"try-again": "Попробуйте ещё",
|
||||
"re-login": "Перезайти",
|
||||
"offline": "Отсутствует подключение к интернету. Пожалуйста, попробуйте позже.",
|
||||
"unauthorized": "У вас отсутствуют разрешения {{permission}} для доступа к этой странице.",
|
||||
"needVerification": "Перед использованием ваш аккаунт должен быть подтвержден администратором сервера.",
|
||||
"needAccount": "Эта страница недоступна в режиме гостя. Вам необходимо создать аккаунт или авторизоваться.",
|
||||
"setup": {
|
||||
"MissingAdminAccount": "Аккаунт администратора ещё не был создан. Пожалуйста, зарегистрируйтесь, чтобы создать его.",
|
||||
"NoVideoFound": "Пока не найдено ни одного видео. Добавьте фильмы или сериалы в папку вашей библиотеки, чтобы они появились здесь!"
|
||||
}
|
||||
},
|
||||
"mediainfo": {
|
||||
"file": "Файл",
|
||||
"container": "Контейнер",
|
||||
"video": "Видео",
|
||||
"audio": "Аудио",
|
||||
"subtitles": "Субтитры",
|
||||
"forced": "Принудительно",
|
||||
"default": "По умолчанию",
|
||||
"duration": "Продолжительность",
|
||||
"size": "Размер",
|
||||
"novideo": "Видео отсутствует",
|
||||
"nocontainer": "Недействительный контейнер"
|
||||
},
|
||||
"admin": {
|
||||
"users": {
|
||||
"label": "Пользователи",
|
||||
"adminUser": "Администратор",
|
||||
"regularUser": "Пользователь",
|
||||
"set-permissions": "Установить права",
|
||||
"delete": "Удалить пользователя",
|
||||
"unverifed": "Не подтверждён",
|
||||
"verify": "Подтвердить пользователя"
|
||||
},
|
||||
"scanner": {
|
||||
"label": "Сканер",
|
||||
"scan": "Запустить сканирование библиотеки",
|
||||
"empty": "Проблем не найдено. Все ваши объекты зарегистрированы успешно."
|
||||
}
|
||||
}
|
||||
}
|
@ -59,7 +59,15 @@
|
||||
"desc": "降序"
|
||||
},
|
||||
"switchToGrid": "切换到网格视图",
|
||||
"switchToList": "切换到列表视图"
|
||||
"switchToList": "切换到列表视图",
|
||||
"mediatypekey": {
|
||||
"all": "全部",
|
||||
"movie": "电影",
|
||||
"show": "系列",
|
||||
"collection": "集合"
|
||||
},
|
||||
"mediatype-tt": "媒体类型",
|
||||
"mediatypelabel": "媒体类型"
|
||||
},
|
||||
"misc": {
|
||||
"settings": "设置",
|
||||
|
6512
front/yarn.lock
6512
front/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended", ":disableRateLimiting", "customManagers:biomeVersions"],
|
||||
"extends": ["config:recommended", ":disableRateLimiting"],
|
||||
"minimumReleaseAge": "5 days",
|
||||
"ignorePaths": ["**/front/**"],
|
||||
"packageRules": [
|
||||
|
@ -37,6 +37,7 @@ in
|
||||
pgformatter
|
||||
biome
|
||||
kubernetes-helm
|
||||
go-migrate
|
||||
];
|
||||
|
||||
DOTNET_ROOT = "${dotnet}";
|
||||
|
29
transcoder/.env.example
Normal file
29
transcoder/.env.example
Normal file
@ -0,0 +1,29 @@
|
||||
# vi: ft=sh
|
||||
# shellcheck disable=SC2034
|
||||
|
||||
|
||||
# where to store temporary transcoded files
|
||||
GOCODER_CACHE_ROOT="/cache"
|
||||
# where to store extracted data (subtitles/attachments/comptuted thumbnails for scrubbing)
|
||||
GOCODER_METADATA_ROOT="/metadata"
|
||||
# path prefix needed to reach the http endpoint
|
||||
GOCODER_PREFIX=""
|
||||
# base absolute path that contains video files (everything in this directory can be served)
|
||||
GOCODER_SAFE_PATH="/video"
|
||||
# hardware acceleration profile (valid values: disabled, vaapi, qsv, nvidia)
|
||||
GOCODER_HWACCEL="disabled"
|
||||
# the preset used during transcode. faster means worst quality, you can probably use a slower preset with hwaccels
|
||||
# warning: using vaapi hwaccel disable presets (they are not supported).
|
||||
GOCODER_PRESET="fast"
|
||||
# the vaapi device path (only used with GOCODER_HWACCEL=vaapi)
|
||||
GOCODER_VAAPI_RENDERER="/dev/dri/renderD128"
|
||||
# the qsv device path (only used with GOCODER_HWACCEL=qsv)
|
||||
GOCODER_QSV_RENDERER="/dev/dri/renderD128"
|
||||
|
||||
# Database things
|
||||
POSTGRES_USER=
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_DB=
|
||||
POSTGRES_SERVER=
|
||||
POSTGRES_PORT=5432
|
||||
# (the schema "gocoder" will be used)
|
@ -1,4 +1,4 @@
|
||||
# FROM golang:1.21 as build
|
||||
# FROM golang:1.22 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
|
||||
@ -53,6 +53,7 @@ RUN set -x && apt-get update \
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/transcoder /app/transcoder
|
||||
COPY ./migrations /app/migrations
|
||||
|
||||
# flags for nvidia acceleration on docker < 25.0
|
||||
ENV NVIDIA_VISIBLE_DEVICES="all"
|
||||
|
45
transcoder/README.md
Normal file
45
transcoder/README.md
Normal file
@ -0,0 +1,45 @@
|
||||
# Gocoder
|
||||
|
||||
## Features
|
||||
|
||||
- Lazily transcode via **HLS**
|
||||
- Support automatic quality switches
|
||||
- Support transmuxing as a quality
|
||||
- Allows multiples clients to share the same transcode stream
|
||||
- **Hardware acceleration** support (vaapi, qsv, cuda)
|
||||
- Extract **media info** from files (bitrate, chapters...) and stores it in database
|
||||
- Extract and serves **subtitles** or attachments (like fonts)
|
||||
- Create **thumbnails sprites** (& vtt metadata) for scrubbing (mouse hover on seek bar)
|
||||
|
||||
## Content/Quality negotation
|
||||
|
||||
Instead of having complex logic to know which client supports which codecs, we use HLS to it's full potential.
|
||||
Each quality & codec supported by gocoder is specified in the manifest and the client is free to pick the one it can play (and switch on the fly to change quality if network changes).
|
||||
|
||||
This makes the API easier (to adopt for new clients & to write) but it means it's harder to handle transmuxing.
|
||||
I did a blog post explaining the core idea, you can find it at [zoriya.dev/blogs/transcoder](https://zoriya.dev/blogs/transcoder).
|
||||
|
||||
## Usage
|
||||
|
||||
Gocoder is shipped as a docker image configurable via env variables (see `.env.example`). Using it outside Kyoo is supported.
|
||||
There is no swagger as of now, you can look at `main.go` for the list of routes.
|
||||
|
||||
Projects using gocoder:
|
||||
- Kyoo (obviously)
|
||||
- [Meelo](https://github.com/Arthi-chaud/Meelo)
|
||||
- [Blee](https://github.com/Arthi-chaud/Blee)
|
||||
- Add your own?
|
||||
|
||||
## How does this work
|
||||
|
||||
I did a blog post explaining the core idea, you can find it at [zoriya.dev/blogs/transcoder](https://zoriya.dev/blogs/transcoder).
|
||||
|
||||
## TODO:
|
||||
- Add a swagger
|
||||
- Add configurable JWT authorization (v5 of kyoo)
|
||||
- Add credits/recaps/intro/preview detection
|
||||
- Add multiples qualities for audio streams
|
||||
- Improve multi-video support
|
||||
- Add optional redis synchronization for replication (`RunLock` was made with this in mind)
|
||||
- fmp4 support
|
||||
- transcode downloads
|
@ -1,23 +1,30 @@
|
||||
module github.com/zoriya/kyoo/transcoder
|
||||
|
||||
go 1.21
|
||||
go 1.22
|
||||
|
||||
require github.com/labstack/echo/v4 v4.12.0 // direct
|
||||
require (
|
||||
github.com/golang-migrate/migrate/v4 v4.17.1
|
||||
github.com/labstack/echo/v4 v4.12.0
|
||||
github.com/lib/pq v1.10.9
|
||||
gitlab.com/opennota/screengen v1.0.2
|
||||
gopkg.in/vansante/go-ffprobe.v2 v2.2.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
gitlab.com/opennota/screengen v1.0.2
|
||||
golang.org/x/crypto v0.22.0 // indirect
|
||||
golang.org/x/image v0.10.0 // indirect
|
||||
golang.org/x/net v0.24.0 // indirect
|
||||
golang.org/x/sys v0.19.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
gopkg.in/vansante/go-ffprobe.v2 v2.2.0
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.26.0 // indirect
|
||||
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/time v0.6.0 // indirect
|
||||
)
|
||||
|
@ -1,18 +1,53 @@
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
|
||||
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhui/dktest v0.4.1 h1:/w+IWuDXVymg3IrRJCHHOkMK10m9aNVMOyD0X12YVTg=
|
||||
github.com/dhui/dktest v0.4.1/go.mod h1:DdOqcUpL7vgyP4GlF3X3w7HbSlz8cEQzwewPveYEQbA=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8=
|
||||
github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0=
|
||||
github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
|
||||
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4=
|
||||
github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
|
||||
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
|
||||
github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
@ -21,54 +56,32 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
gitlab.com/opennota/screengen v1.0.2 h1:GxYTJdAPEzmg5v5CV4dgn45JVW+EcXXAvCxhE7w6UDw=
|
||||
gitlab.com/opennota/screengen v1.0.2/go.mod h1:4kED4yriw2zslwYmXFCa5qCvEKwleBA7l5OE+d94NTU=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.10.0 h1:gXjUUtwtx5yOE0VKWq1CH4IJAClq4UGgUA3i+rpON9M=
|
||||
golang.org/x/image v0.10.0/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/image v0.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ=
|
||||
golang.org/x/image v0.19.0/go.mod h1:y0zrRqlQRWQ5PXaYCOMLTW2fpsxZ8Qh9I/ohnInJEys=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
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.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
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/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=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
gopkg.in/vansante/go-ffprobe.v2 v2.2.0 h1:iuOqTsbfYuqIz4tAU9NWh22CmBGxlGHdgj4iqP+NUmY=
|
||||
gopkg.in/vansante/go-ffprobe.v2 v2.2.0/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
@ -55,8 +55,12 @@ func (h *Handler) GetMaster(c echo.Context) error {
|
||||
// This route can take a few seconds to respond since it will way for at least one segment to be
|
||||
// available.
|
||||
//
|
||||
// Path: /:path/:quality/index.m3u8
|
||||
// Path: /:path/:video/:quality/index.m3u8
|
||||
func (h *Handler) GetVideoIndex(c echo.Context) error {
|
||||
video, err := strconv.ParseInt(c.Param("video"), 10, 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
quality, err := src.QualityFromString(c.Param("quality"))
|
||||
if err != nil {
|
||||
return err
|
||||
@ -70,7 +74,7 @@ func (h *Handler) GetVideoIndex(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ret, err := h.transcoder.GetVideoIndex(path, quality, client, sha)
|
||||
ret, err := h.transcoder.GetVideoIndex(path, uint32(video), quality, client, sha)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -98,7 +102,7 @@ func (h *Handler) GetAudioIndex(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ret, err := h.transcoder.GetAudioIndex(path, int32(audio), client, sha)
|
||||
ret, err := h.transcoder.GetAudioIndex(path, uint32(audio), client, sha)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -109,8 +113,12 @@ func (h *Handler) GetAudioIndex(c echo.Context) error {
|
||||
//
|
||||
// Retrieve a chunk of a transmuxed video.
|
||||
//
|
||||
// Path: /:path/:quality/segments-:chunk.ts
|
||||
// Path: /:path/:video/:quality/segments-:chunk.ts
|
||||
func (h *Handler) GetVideoSegment(c echo.Context) error {
|
||||
video, err := strconv.ParseInt(c.Param("video"), 10, 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
quality, err := src.QualityFromString(c.Param("quality"))
|
||||
if err != nil {
|
||||
return err
|
||||
@ -128,7 +136,14 @@ func (h *Handler) GetVideoSegment(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ret, err := h.transcoder.GetVideoSegment(path, quality, segment, client, sha)
|
||||
ret, err := h.transcoder.GetVideoSegment(
|
||||
path,
|
||||
uint32(video),
|
||||
quality,
|
||||
segment,
|
||||
client,
|
||||
sha,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -158,7 +173,7 @@ func (h *Handler) GetAudioSegment(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ret, err := h.transcoder.GetAudioSegment(path, int32(audio), segment, client, sha)
|
||||
ret, err := h.transcoder.GetAudioSegment(path, uint32(audio), segment, client, sha)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -176,13 +191,14 @@ func (h *Handler) GetInfo(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ret, err := src.GetInfo(path, sha)
|
||||
ret, err := h.metadata.GetMetadata(path, sha)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Run extractors to have them in cache
|
||||
src.Extract(ret.Path, sha)
|
||||
go src.ExtractThumbnail(ret.Path, sha)
|
||||
err = ret.SearchExternalSubtitles()
|
||||
if err != nil {
|
||||
fmt.Printf("Couldn't find external subtitles: %v", err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, ret)
|
||||
}
|
||||
|
||||
@ -192,7 +208,7 @@ func (h *Handler) GetInfo(c echo.Context) error {
|
||||
//
|
||||
// Path: /:path/attachment/:name
|
||||
func (h *Handler) GetAttachment(c echo.Context) error {
|
||||
path, sha, err := GetPath(c)
|
||||
_, sha, err := GetPath(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -201,13 +217,10 @@ func (h *Handler) GetAttachment(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
wait, err := src.Extract(path, sha)
|
||||
ret, err := h.metadata.GetAttachmentPath(sha, false, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
<-wait
|
||||
|
||||
ret := fmt.Sprintf("%s/%s/att/%s", src.Settings.Metadata, sha, name)
|
||||
return c.File(ret)
|
||||
}
|
||||
|
||||
@ -217,7 +230,7 @@ func (h *Handler) GetAttachment(c echo.Context) error {
|
||||
//
|
||||
// Path: /:path/subtitle/:name
|
||||
func (h *Handler) GetSubtitle(c echo.Context) error {
|
||||
path, sha, err := GetPath(c)
|
||||
_, sha, err := GetPath(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -226,13 +239,10 @@ func (h *Handler) GetSubtitle(c echo.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
wait, err := src.Extract(path, sha)
|
||||
ret, err := h.metadata.GetAttachmentPath(sha, true, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
<-wait
|
||||
|
||||
ret := fmt.Sprintf("%s/%s/sub/%s", src.Settings.Metadata, sha, name)
|
||||
return c.File(ret)
|
||||
}
|
||||
|
||||
@ -246,13 +256,12 @@ func (h *Handler) GetThumbnails(c echo.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := src.ExtractThumbnail(path, sha)
|
||||
sprite, _, err := h.metadata.GetThumb(path, sha)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.File(fmt.Sprintf("%s/sprite.png", out))
|
||||
return c.File(sprite)
|
||||
}
|
||||
|
||||
// Get thumbnail vtt
|
||||
@ -266,17 +275,17 @@ func (h *Handler) GetThumbnailsVtt(c echo.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := src.ExtractThumbnail(path, sha)
|
||||
_, vtt, err := h.metadata.GetThumb(path, sha)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.File(fmt.Sprintf("%s/sprite.vtt", out))
|
||||
return c.File(vtt)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
transcoder *src.Transcoder
|
||||
metadata *src.MetadataService
|
||||
}
|
||||
|
||||
func main() {
|
||||
@ -284,20 +293,27 @@ func main() {
|
||||
e.Use(middleware.Logger())
|
||||
e.HTTPErrorHandler = ErrorHandler
|
||||
|
||||
transcoder, err := src.NewTranscoder()
|
||||
metadata, err := src.NewMetadataService()
|
||||
if err != nil {
|
||||
e.Logger.Fatal(err)
|
||||
return
|
||||
}
|
||||
transcoder, err := src.NewTranscoder(metadata)
|
||||
if err != nil {
|
||||
e.Logger.Fatal(err)
|
||||
return
|
||||
}
|
||||
h := Handler{
|
||||
transcoder: transcoder,
|
||||
metadata: metadata,
|
||||
}
|
||||
|
||||
e.GET("/:path/direct", DirectStream)
|
||||
e.GET("/:path/direct/:identifier", DirectStream)
|
||||
e.GET("/:path/master.m3u8", h.GetMaster)
|
||||
e.GET("/:path/:quality/index.m3u8", h.GetVideoIndex)
|
||||
e.GET("/:path/:video/:quality/index.m3u8", h.GetVideoIndex)
|
||||
e.GET("/:path/audio/:audio/index.m3u8", h.GetAudioIndex)
|
||||
e.GET("/:path/:quality/:chunk", h.GetVideoSegment)
|
||||
e.GET("/:path/:video/:quality/:chunk", h.GetVideoSegment)
|
||||
e.GET("/:path/audio/:audio/:chunk", h.GetAudioSegment)
|
||||
e.GET("/:path/info", h.GetInfo)
|
||||
e.GET("/:path/thumbnails.png", h.GetThumbnails)
|
||||
|
10
transcoder/migrations/000001_init_db.down.sql
Normal file
10
transcoder/migrations/000001_init_db.down.sql
Normal file
@ -0,0 +1,10 @@
|
||||
begin;
|
||||
|
||||
drop table info;
|
||||
drop table videos;
|
||||
drop table audios;
|
||||
drop table subtitles;
|
||||
drop table chapters;
|
||||
drop type chapter_type;
|
||||
|
||||
commit;
|
75
transcoder/migrations/000001_init_db.up.sql
Normal file
75
transcoder/migrations/000001_init_db.up.sql
Normal file
@ -0,0 +1,75 @@
|
||||
begin;
|
||||
|
||||
create table info(
|
||||
sha varchar(40) not null primary key,
|
||||
path varchar(4096) not null unique,
|
||||
extension varchar(16),
|
||||
mime_codec varchar(1024),
|
||||
size bigint not null,
|
||||
duration real not null,
|
||||
container varchar(256),
|
||||
fonts text[] not null,
|
||||
ver_info integer not null,
|
||||
ver_extract integer not null,
|
||||
ver_thumbs integer not null,
|
||||
ver_keyframes integer not null
|
||||
);
|
||||
|
||||
create table videos(
|
||||
sha varchar(40) not null references info(sha) on delete cascade,
|
||||
idx integer not null,
|
||||
title varchar(1024),
|
||||
language varchar(256),
|
||||
codec varchar(256) not null,
|
||||
mime_codec varchar(256),
|
||||
width integer not null,
|
||||
height integer not null,
|
||||
bitrate integer not null,
|
||||
is_default boolean not null,
|
||||
|
||||
keyframes double precision[],
|
||||
|
||||
constraint videos_pk primary key (sha, idx)
|
||||
);
|
||||
|
||||
create table audios(
|
||||
sha varchar(40) not null references info(sha) on delete cascade,
|
||||
idx integer not null,
|
||||
title varchar(1024),
|
||||
language varchar(256),
|
||||
codec varchar(256) not null,
|
||||
mime_codec varchar(256),
|
||||
bitrate integer not null,
|
||||
is_default boolean not null,
|
||||
|
||||
keyframes double precision[],
|
||||
|
||||
constraint audios_pk primary key (sha, idx)
|
||||
);
|
||||
|
||||
create table subtitles(
|
||||
sha varchar(40) not null references info(sha) on delete cascade,
|
||||
idx integer not null,
|
||||
title varchar(1024),
|
||||
language varchar(256),
|
||||
codec varchar(256) not null,
|
||||
extension varchar(16),
|
||||
is_default boolean not null,
|
||||
is_forced boolean not null,
|
||||
|
||||
constraint subtitle_pk primary key (sha, idx)
|
||||
);
|
||||
|
||||
create type chapter_type as enum('content', 'recap', 'intro', 'credits', 'preview');
|
||||
|
||||
create table chapters(
|
||||
sha varchar(40) not null references info(sha) on delete cascade,
|
||||
start_time real not null,
|
||||
end_time real not null,
|
||||
name varchar(1024),
|
||||
type chapter_type,
|
||||
|
||||
constraint chapter_pk primary key (sha, start_time)
|
||||
);
|
||||
|
||||
commit;
|
@ -7,15 +7,21 @@ import (
|
||||
|
||||
type AudioStream struct {
|
||||
Stream
|
||||
index int32
|
||||
index uint32
|
||||
}
|
||||
|
||||
func NewAudioStream(file *FileStream, idx int32) *AudioStream {
|
||||
log.Printf("Creating a audio stream %d for %s", idx, file.Path)
|
||||
func (t *Transcoder) NewAudioStream(file *FileStream, idx uint32) (*AudioStream, error) {
|
||||
log.Printf("Creating a audio stream %d for %s", idx, file.Info.Path)
|
||||
|
||||
keyframes, err := t.metadataService.GetKeyframes(file.Info, false, idx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret := new(AudioStream)
|
||||
ret.index = idx
|
||||
NewStream(file, ret, &ret.Stream)
|
||||
return ret
|
||||
NewStream(file, keyframes, ret, &ret.Stream)
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (as *AudioStream) getOutPath(encoder_id int) string {
|
||||
|
@ -7,62 +7,73 @@ import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
var extracted = NewCMap[string, <-chan struct{}]()
|
||||
const ExtractVersion = 1
|
||||
|
||||
func Extract(path string, sha string) (<-chan struct{}, error) {
|
||||
ret := make(chan struct{})
|
||||
existing, created := extracted.GetOrSet(sha, ret)
|
||||
if !created {
|
||||
return existing, nil
|
||||
func (s *MetadataService) ExtractSubs(info *MediaInfo) (interface{}, error) {
|
||||
get_running, set := s.extractLock.Start(info.Sha)
|
||||
if get_running != nil {
|
||||
return get_running()
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer printExecTime("Starting extraction of %s", path)()
|
||||
info, err := GetInfo(path, sha)
|
||||
if err != nil {
|
||||
extracted.Remove(sha)
|
||||
close(ret)
|
||||
return
|
||||
}
|
||||
attachment_path := fmt.Sprintf("%s/%s/att", Settings.Metadata, sha)
|
||||
subs_path := fmt.Sprintf("%s/%s/sub", Settings.Metadata, sha)
|
||||
os.MkdirAll(attachment_path, 0o644)
|
||||
os.MkdirAll(subs_path, 0o755)
|
||||
|
||||
// If there is no subtitles, there is nothing to extract (also fonts would be useless).
|
||||
if len(info.Subtitles) == 0 {
|
||||
close(ret)
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command(
|
||||
"ffmpeg",
|
||||
"-dump_attachment:t", "",
|
||||
// override old attachments
|
||||
"-y",
|
||||
"-i", path,
|
||||
)
|
||||
cmd.Dir = attachment_path
|
||||
|
||||
for _, sub := range info.Subtitles {
|
||||
if ext := sub.Extension; ext != nil {
|
||||
cmd.Args = append(
|
||||
cmd.Args,
|
||||
"-map", fmt.Sprintf("0:s:%d", sub.Index),
|
||||
"-c:s", "copy",
|
||||
fmt.Sprintf("%s/%d.%s", subs_path, sub.Index, *ext),
|
||||
)
|
||||
}
|
||||
}
|
||||
log.Printf("Starting extraction with the command: %s", cmd)
|
||||
cmd.Stdout = nil
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
extracted.Remove(sha)
|
||||
fmt.Println("Error starting ffmpeg extract:", err)
|
||||
}
|
||||
close(ret)
|
||||
}()
|
||||
|
||||
return ret, nil
|
||||
err := extractSubs(info)
|
||||
if err != nil {
|
||||
return set(nil, err)
|
||||
}
|
||||
_, err = s.database.Exec(`update info set ver_extract = $2 where sha = $1`, info.Sha, ExtractVersion)
|
||||
return set(nil, err)
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetAttachmentPath(sha string, is_sub bool, name string) (string, error) {
|
||||
_, err := s.extractLock.WaitFor(sha)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := "att"
|
||||
if is_sub {
|
||||
dir = "sub"
|
||||
}
|
||||
return fmt.Sprintf("%s/%s/%s/%s", Settings.Metadata, sha, dir, name), nil
|
||||
}
|
||||
|
||||
func extractSubs(info *MediaInfo) error {
|
||||
defer printExecTime("extraction of %s", info.Path)()
|
||||
|
||||
attachment_path := fmt.Sprintf("%s/%s/att", Settings.Metadata, info.Sha)
|
||||
subs_path := fmt.Sprintf("%s/%s/sub", Settings.Metadata, info.Sha)
|
||||
|
||||
os.MkdirAll(attachment_path, 0o755)
|
||||
os.MkdirAll(subs_path, 0o755)
|
||||
|
||||
// If there is no subtitles, there is nothing to extract (also fonts would be useless).
|
||||
if len(info.Subtitles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.Command(
|
||||
"ffmpeg",
|
||||
"-dump_attachment:t", "",
|
||||
// override old attachments
|
||||
"-y",
|
||||
"-i", info.Path,
|
||||
)
|
||||
cmd.Dir = attachment_path
|
||||
|
||||
for _, sub := range info.Subtitles {
|
||||
if ext := sub.Extension; ext != nil {
|
||||
cmd.Args = append(
|
||||
cmd.Args,
|
||||
"-map", fmt.Sprintf("0:s:%d", *sub.Index),
|
||||
"-c:s", "copy",
|
||||
fmt.Sprintf("%s/%d.%s", subs_path, *sub.Index, *ext),
|
||||
)
|
||||
}
|
||||
}
|
||||
log.Printf("Starting extraction with the command: %s", cmd)
|
||||
cmd.Stdout = nil
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
fmt.Println("Error starting ffmpeg extract:", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -10,40 +10,38 @@ import (
|
||||
)
|
||||
|
||||
type FileStream struct {
|
||||
ready sync.WaitGroup
|
||||
err error
|
||||
Path string
|
||||
Out string
|
||||
Keyframes *Keyframe
|
||||
Info *MediaInfo
|
||||
videos CMap[Quality, *VideoStream]
|
||||
audios CMap[int32, *AudioStream]
|
||||
transcoder *Transcoder
|
||||
ready sync.WaitGroup
|
||||
err error
|
||||
Out string
|
||||
Info *MediaInfo
|
||||
videos CMap[VideoKey, *VideoStream]
|
||||
audios CMap[uint32, *AudioStream]
|
||||
}
|
||||
|
||||
func NewFileStream(path string, sha string) *FileStream {
|
||||
type VideoKey struct {
|
||||
idx uint32
|
||||
quality Quality
|
||||
}
|
||||
|
||||
func (t *Transcoder) newFileStream(path string, sha string) *FileStream {
|
||||
ret := &FileStream{
|
||||
Path: path,
|
||||
Out: fmt.Sprintf("%s/%s", Settings.Outpath, sha),
|
||||
videos: NewCMap[Quality, *VideoStream](),
|
||||
audios: NewCMap[int32, *AudioStream](),
|
||||
transcoder: t,
|
||||
Out: fmt.Sprintf("%s/%s", Settings.Outpath, sha),
|
||||
videos: NewCMap[VideoKey, *VideoStream](),
|
||||
audios: NewCMap[uint32, *AudioStream](),
|
||||
}
|
||||
|
||||
ret.ready.Add(1)
|
||||
go func() {
|
||||
defer ret.ready.Done()
|
||||
info, err := GetInfo(path, sha)
|
||||
info, err := t.metadataService.GetMetadata(path, sha)
|
||||
ret.Info = info
|
||||
if err != nil {
|
||||
ret.err = err
|
||||
}
|
||||
}()
|
||||
|
||||
ret.ready.Add(1)
|
||||
go func() {
|
||||
defer ret.ready.Done()
|
||||
ret.Keyframes = GetKeyframes(sha, path)
|
||||
}()
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
@ -62,58 +60,15 @@ func (fs *FileStream) Kill() {
|
||||
}
|
||||
|
||||
func (fs *FileStream) Destroy() {
|
||||
log.Printf("Removing all transcode cache files for %s", fs.Path)
|
||||
log.Printf("Removing all transcode cache files for %s", fs.Info.Path)
|
||||
fs.Kill()
|
||||
_ = os.RemoveAll(fs.Out)
|
||||
}
|
||||
|
||||
func (fs *FileStream) GetMaster() string {
|
||||
master := "#EXTM3U\n"
|
||||
if fs.Info.Video != nil {
|
||||
var transmux_quality Quality
|
||||
for _, quality := range Qualities {
|
||||
if quality.Height() >= fs.Info.Video.Quality.Height() || quality.AverageBitrate() >= fs.Info.Video.Bitrate {
|
||||
transmux_quality = quality
|
||||
break
|
||||
}
|
||||
}
|
||||
// original stream
|
||||
{
|
||||
bitrate := float64(fs.Info.Video.Bitrate)
|
||||
master += "#EXT-X-STREAM-INF:"
|
||||
master += fmt.Sprintf("AVERAGE-BANDWIDTH=%d,", int(math.Min(bitrate*0.8, float64(transmux_quality.AverageBitrate()))))
|
||||
master += fmt.Sprintf("BANDWIDTH=%d,", int(math.Min(bitrate, float64(transmux_quality.MaxBitrate()))))
|
||||
master += fmt.Sprintf("RESOLUTION=%dx%d,", fs.Info.Video.Width, fs.Info.Video.Height)
|
||||
if fs.Info.Video.MimeCodec != nil {
|
||||
master += fmt.Sprintf("CODECS=\"%s\",", *fs.Info.Video.MimeCodec)
|
||||
}
|
||||
master += "AUDIO=\"audio\","
|
||||
master += "CLOSED-CAPTIONS=NONE\n"
|
||||
master += fmt.Sprintf("./%s/index.m3u8\n", Original)
|
||||
}
|
||||
|
||||
aspectRatio := float32(fs.Info.Video.Width) / float32(fs.Info.Video.Height)
|
||||
// codec is the prefix + the level, the level is not part of the codec we want to compare for the same_codec check bellow
|
||||
transmux_prefix := "avc1.6400"
|
||||
transmux_codec := transmux_prefix + "28"
|
||||
|
||||
for _, quality := range Qualities {
|
||||
same_codec := fs.Info.Video.MimeCodec != nil && strings.HasPrefix(*fs.Info.Video.MimeCodec, transmux_prefix)
|
||||
inc_lvl := quality.Height() < fs.Info.Video.Quality.Height() ||
|
||||
(quality.Height() == fs.Info.Video.Quality.Height() && !same_codec)
|
||||
|
||||
if inc_lvl {
|
||||
master += "#EXT-X-STREAM-INF:"
|
||||
master += fmt.Sprintf("AVERAGE-BANDWIDTH=%d,", quality.AverageBitrate())
|
||||
master += fmt.Sprintf("BANDWIDTH=%d,", quality.MaxBitrate())
|
||||
master += fmt.Sprintf("RESOLUTION=%dx%d,", int(aspectRatio*float32(quality.Height())+0.5), quality.Height())
|
||||
master += fmt.Sprintf("CODECS=\"%s\",", transmux_codec)
|
||||
master += "AUDIO=\"audio\","
|
||||
master += "CLOSED-CAPTIONS=NONE\n"
|
||||
master += fmt.Sprintf("./%s/index.m3u8\n", quality)
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: support multiples audio qualities (and original)
|
||||
for _, audio := range fs.Info.Audios {
|
||||
master += "#EXT-X-MEDIA:TYPE=AUDIO,"
|
||||
master += "GROUP-ID=\"audio\","
|
||||
@ -130,41 +85,141 @@ func (fs *FileStream) GetMaster() string {
|
||||
if audio.IsDefault {
|
||||
master += "DEFAULT=YES,"
|
||||
}
|
||||
master += fmt.Sprintf("URI=\"./audio/%d/index.m3u8\"\n", audio.Index)
|
||||
master += "CHANNELS=\"2\","
|
||||
master += fmt.Sprintf("URI=\"audio/%d/index.m3u8\"\n", audio.Index)
|
||||
}
|
||||
master += "\n"
|
||||
|
||||
// codec is the prefix + the level, the level is not part of the codec we want to compare for the same_codec check bellow
|
||||
transmux_prefix := "avc1.6400"
|
||||
transmux_codec := transmux_prefix + "28"
|
||||
audio_codec := "mp4a.40.2"
|
||||
|
||||
var def_video *Video
|
||||
for _, video := range fs.Info.Videos {
|
||||
if video.IsDefault {
|
||||
def_video = &video
|
||||
break
|
||||
}
|
||||
}
|
||||
if def_video == nil && len(fs.Info.Videos) > 0 {
|
||||
def_video = &fs.Info.Videos[0]
|
||||
}
|
||||
|
||||
if def_video != nil {
|
||||
qualities := Filter(Qualities, func(quality Quality) bool {
|
||||
same_codec := def_video.MimeCodec != nil && strings.HasPrefix(*def_video.MimeCodec, transmux_prefix)
|
||||
return quality.Height() < def_video.Height ||
|
||||
(quality.Height() == def_video.Height && !same_codec)
|
||||
})
|
||||
qualities = append(qualities, Original)
|
||||
|
||||
for _, quality := range qualities {
|
||||
for _, video := range fs.Info.Videos {
|
||||
master += "#EXT-X-MEDIA:TYPE=VIDEO,"
|
||||
master += fmt.Sprintf("GROUP-ID=\"%s\",", quality)
|
||||
if video.Language != nil {
|
||||
master += fmt.Sprintf("LANGUAGE=\"%s\",", *video.Language)
|
||||
}
|
||||
if video.Title != nil {
|
||||
master += fmt.Sprintf("NAME=\"%s\",", *video.Title)
|
||||
} else if video.Language != nil {
|
||||
master += fmt.Sprintf("NAME=\"%s\",", *video.Language)
|
||||
} else {
|
||||
master += fmt.Sprintf("NAME=\"Video %d\",", video.Index)
|
||||
}
|
||||
if video == *def_video {
|
||||
master += "DEFAULT=YES\n"
|
||||
} else {
|
||||
master += fmt.Sprintf("URI=\"%d/%s/index.m3u8\"\n", video.Index, quality)
|
||||
}
|
||||
}
|
||||
}
|
||||
master += "\n"
|
||||
|
||||
// original stream
|
||||
{
|
||||
bitrate := float64(def_video.Bitrate)
|
||||
master += "#EXT-X-STREAM-INF:"
|
||||
master += fmt.Sprintf("AVERAGE-BANDWIDTH=%d,", int(math.Min(bitrate*0.8, float64(def_video.Quality().AverageBitrate()))))
|
||||
master += fmt.Sprintf("BANDWIDTH=%d,", int(math.Min(bitrate, float64(def_video.Quality().MaxBitrate()))))
|
||||
master += fmt.Sprintf("RESOLUTION=%dx%d,", def_video.Width, def_video.Height)
|
||||
if def_video.MimeCodec != nil {
|
||||
master += fmt.Sprintf("CODECS=\"%s\",", strings.Join([]string{*def_video.MimeCodec, audio_codec}, ","))
|
||||
}
|
||||
master += "AUDIO=\"audio\","
|
||||
master += "CLOSED-CAPTIONS=NONE\n"
|
||||
master += fmt.Sprintf("%d/%s/index.m3u8\n", def_video.Index, Original)
|
||||
}
|
||||
|
||||
aspectRatio := float32(def_video.Width) / float32(def_video.Height)
|
||||
|
||||
for i, quality := range qualities {
|
||||
if i == len(qualities)-1 {
|
||||
// skip the original stream that already got handled
|
||||
continue
|
||||
}
|
||||
|
||||
master += "#EXT-X-STREAM-INF:"
|
||||
master += fmt.Sprintf("AVERAGE-BANDWIDTH=%d,", quality.AverageBitrate())
|
||||
master += fmt.Sprintf("BANDWIDTH=%d,", quality.MaxBitrate())
|
||||
master += fmt.Sprintf("RESOLUTION=%dx%d,", int(aspectRatio*float32(quality.Height())+0.5), quality.Height())
|
||||
master += fmt.Sprintf("CODECS=\"%s\",", strings.Join([]string{transmux_codec, audio_codec}, ","))
|
||||
master += "AUDIO=\"audio\","
|
||||
master += "CLOSED-CAPTIONS=NONE\n"
|
||||
master += fmt.Sprintf("%d/%s/index.m3u8\n", def_video.Index, quality)
|
||||
}
|
||||
}
|
||||
|
||||
return master
|
||||
}
|
||||
|
||||
func (fs *FileStream) getVideoStream(quality Quality) *VideoStream {
|
||||
stream, _ := fs.videos.GetOrCreate(quality, func() *VideoStream {
|
||||
return NewVideoStream(fs, quality)
|
||||
func (fs *FileStream) getVideoStream(idx uint32, quality Quality) (*VideoStream, error) {
|
||||
stream, _ := fs.videos.GetOrCreate(VideoKey{idx, quality}, func() *VideoStream {
|
||||
ret, _ := fs.transcoder.NewVideoStream(fs, idx, quality)
|
||||
return ret
|
||||
})
|
||||
return stream
|
||||
stream.ready.Wait()
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
func (fs *FileStream) GetVideoIndex(quality Quality) (string, error) {
|
||||
stream := fs.getVideoStream(quality)
|
||||
func (fs *FileStream) GetVideoIndex(idx uint32, quality Quality) (string, error) {
|
||||
stream, err := fs.getVideoStream(idx, quality)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return stream.GetIndex()
|
||||
}
|
||||
|
||||
func (fs *FileStream) GetVideoSegment(quality Quality, segment int32) (string, error) {
|
||||
stream := fs.getVideoStream(quality)
|
||||
func (fs *FileStream) GetVideoSegment(idx uint32, quality Quality, segment int32) (string, error) {
|
||||
stream, err := fs.getVideoStream(idx, quality)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return stream.GetSegment(segment)
|
||||
}
|
||||
|
||||
func (fs *FileStream) getAudioStream(audio int32) *AudioStream {
|
||||
func (fs *FileStream) getAudioStream(audio uint32) (*AudioStream, error) {
|
||||
stream, _ := fs.audios.GetOrCreate(audio, func() *AudioStream {
|
||||
return NewAudioStream(fs, audio)
|
||||
ret, _ := fs.transcoder.NewAudioStream(fs, audio)
|
||||
return ret
|
||||
})
|
||||
return stream
|
||||
stream.ready.Wait()
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
func (fs *FileStream) GetAudioIndex(audio int32) (string, error) {
|
||||
stream := fs.getAudioStream(audio)
|
||||
func (fs *FileStream) GetAudioIndex(audio uint32) (string, error) {
|
||||
stream, err := fs.getAudioStream(audio)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
return stream.GetIndex()
|
||||
}
|
||||
|
||||
func (fs *FileStream) GetAudioSegment(audio int32, segment int32) (string, error) {
|
||||
stream := fs.getAudioStream(audio)
|
||||
func (fs *FileStream) GetAudioSegment(audio uint32, segment int32) (string, error) {
|
||||
stream, err := fs.getAudioStream(audio)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
return stream.GetSegment(segment)
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ func DetectHardwareAccel() HwAccelT {
|
||||
preset := GetEnvOr("GOCODER_PRESET", "fast")
|
||||
|
||||
switch name {
|
||||
case "disabled":
|
||||
case "disabled", "cpu":
|
||||
return HwAccelT{
|
||||
Name: "disabled",
|
||||
DecodeFlags: []string{},
|
||||
|
@ -4,22 +4,26 @@ import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
"gopkg.in/vansante/go-ffprobe.v2"
|
||||
)
|
||||
|
||||
const InfoVersion = 1
|
||||
|
||||
type Versions struct {
|
||||
Info int32 `json:"info"`
|
||||
Extract int32 `json:"extract"`
|
||||
Thumbs int32 `json:"thumbs"`
|
||||
Keyframes int32 `json:"keyframes"`
|
||||
}
|
||||
|
||||
type MediaInfo struct {
|
||||
// The sha1 of the video file.
|
||||
Sha string `json:"sha"`
|
||||
@ -30,13 +34,17 @@ type MediaInfo struct {
|
||||
/// The whole mimetype (defined as the RFC 6381). ex: `video/mp4; codecs="avc1.640028, mp4a.40.2"`
|
||||
MimeCodec *string `json:"mimeCodec"`
|
||||
/// The file size of the video file.
|
||||
Size uint64 `json:"size"`
|
||||
Size int64 `json:"size"`
|
||||
/// The length of the media in seconds.
|
||||
Duration float32 `json:"duration"`
|
||||
/// The container of the video file of this episode.
|
||||
Container *string `json:"container"`
|
||||
/// The video codec and informations.
|
||||
Video *Video `json:"video"`
|
||||
/// Version of the metadata. This can be used to invalidate older metadata from db if the extraction code has changed.
|
||||
Versions Versions `json:"versions"`
|
||||
|
||||
// TODO: remove on next major
|
||||
Video Video `json:"video"`
|
||||
|
||||
/// The list of videos if there are multiples.
|
||||
Videos []Video `json:"videos"`
|
||||
/// The list of audio tracks.
|
||||
@ -50,20 +58,27 @@ type MediaInfo struct {
|
||||
}
|
||||
|
||||
type Video struct {
|
||||
/// The index of this track on the media.
|
||||
Index uint32 `json:"index"`
|
||||
/// The title of the stream.
|
||||
Title *string `json:"title"`
|
||||
/// The language of this stream (as a ISO-639-2 language code)
|
||||
Language *string `json:"language"`
|
||||
/// The human readable codec name.
|
||||
Codec string `json:"codec"`
|
||||
/// The codec of this stream (defined as the RFC 6381).
|
||||
MimeCodec *string `json:"mimeCodec"`
|
||||
/// The language of this stream (as a ISO-639-2 language code)
|
||||
Language *string `json:"language"`
|
||||
/// The max quality of this video track.
|
||||
Quality Quality `json:"quality"`
|
||||
/// The width of the video stream
|
||||
Width uint32 `json:"width"`
|
||||
/// The height of the video stream
|
||||
Height uint32 `json:"height"`
|
||||
/// The average bitrate of the video in bytes/s
|
||||
Bitrate uint32 `json:"bitrate"`
|
||||
/// Is this stream the default one of it's type?
|
||||
IsDefault bool `json:"isDefault"`
|
||||
|
||||
/// Keyframes of this video
|
||||
Keyframes *Keyframe `json:"-"`
|
||||
}
|
||||
|
||||
type Audio struct {
|
||||
@ -77,15 +92,21 @@ type Audio struct {
|
||||
Codec string `json:"codec"`
|
||||
/// The codec of this stream (defined as the RFC 6381).
|
||||
MimeCodec *string `json:"mimeCodec"`
|
||||
/// The average bitrate of the audio in bytes/s
|
||||
Bitrate uint32 `json:"bitrate"`
|
||||
/// Is this stream the default one of it's type?
|
||||
IsDefault bool `json:"isDefault"`
|
||||
/// Is this stream tagged as forced? (useful only for subtitles)
|
||||
|
||||
/// Keyframes of this video
|
||||
Keyframes *Keyframe `json:"-"`
|
||||
|
||||
//TODO: remove this in next major
|
||||
IsForced bool `json:"isForced"`
|
||||
}
|
||||
|
||||
type Subtitle struct {
|
||||
/// The index of this track on the media.
|
||||
Index uint32 `json:"index"`
|
||||
Index *uint32 `json:"index"`
|
||||
/// The title of the stream.
|
||||
Title *string `json:"title"`
|
||||
/// The language of this stream (as a IETF-BCP-47 language code)
|
||||
@ -96,8 +117,12 @@ type Subtitle struct {
|
||||
Extension *string `json:"extension"`
|
||||
/// Is this stream the default one of it's type?
|
||||
IsDefault bool `json:"isDefault"`
|
||||
/// Is this stream tagged as forced? (useful only for subtitles)
|
||||
/// Is this stream tagged as forced?
|
||||
IsForced bool `json:"isForced"`
|
||||
/// Is this an external subtitle (as in stored in a different file)
|
||||
IsExternal bool `json:"isExternal"`
|
||||
/// Where the subtitle is stored (null if stored inside the video)
|
||||
Path *string `json:"path"`
|
||||
/// The link to access this subtitle.
|
||||
Link *string `json:"link"`
|
||||
}
|
||||
@ -109,9 +134,20 @@ type Chapter struct {
|
||||
EndTime float32 `json:"endTime"`
|
||||
/// The name of this chapter. This should be a human-readable name that could be presented to the user.
|
||||
Name string `json:"name"`
|
||||
// TODO: add a type field for Opening, Credits...
|
||||
/// The type value is used to mark special chapters (openning/credits...)
|
||||
Type ChapterType
|
||||
}
|
||||
|
||||
type ChapterType string
|
||||
|
||||
const (
|
||||
Content ChapterType = "content"
|
||||
Recap ChapterType = "recap"
|
||||
Intro ChapterType = "intro"
|
||||
Credits ChapterType = "credits"
|
||||
Preview ChapterType = "preview"
|
||||
)
|
||||
|
||||
func ParseFloat(str string) float32 {
|
||||
f, err := strconv.ParseFloat(str, 32)
|
||||
if err != nil {
|
||||
@ -129,8 +165,8 @@ func ParseUint(str string) uint32 {
|
||||
return uint32(i)
|
||||
}
|
||||
|
||||
func ParseUint64(str string) uint64 {
|
||||
i, err := strconv.ParseUint(str, 10, 64)
|
||||
func ParseInt64(str string) int64 {
|
||||
i, err := strconv.ParseInt(str, 10, 64)
|
||||
if err != nil {
|
||||
println(str)
|
||||
return 0
|
||||
@ -185,67 +221,7 @@ var SubtitleExtensions = map[string]string{
|
||||
"vtt": "vtt",
|
||||
}
|
||||
|
||||
type MICache struct {
|
||||
info *MediaInfo
|
||||
ready sync.WaitGroup
|
||||
}
|
||||
|
||||
var infos = NewCMap[string, *MICache]()
|
||||
|
||||
func GetInfo(path string, sha string) (*MediaInfo, error) {
|
||||
var err error
|
||||
|
||||
ret, _ := infos.GetOrCreate(sha, func() *MICache {
|
||||
mi := &MICache{info: &MediaInfo{Sha: sha}}
|
||||
mi.ready.Add(1)
|
||||
go func() {
|
||||
save_path := fmt.Sprintf("%s/%s/info.json", Settings.Metadata, sha)
|
||||
if err := getSavedInfo(save_path, mi.info); err == nil {
|
||||
log.Printf("Using mediainfo cache on filesystem for %s", path)
|
||||
mi.ready.Done()
|
||||
return
|
||||
}
|
||||
|
||||
var val *MediaInfo
|
||||
val, err = getInfo(path)
|
||||
if err == nil {
|
||||
*mi.info = *val
|
||||
mi.info.Sha = sha
|
||||
}
|
||||
mi.ready.Done()
|
||||
saveInfo(save_path, mi.info)
|
||||
}()
|
||||
return mi
|
||||
})
|
||||
ret.ready.Wait()
|
||||
return ret.info, err
|
||||
}
|
||||
|
||||
func getSavedInfo[T any](save_path string, mi *T) error {
|
||||
saved_file, err := os.Open(save_path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
saved, err := io.ReadAll(saved_file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = json.Unmarshal([]byte(saved), mi)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveInfo[T any](save_path string, mi *T) error {
|
||||
content, err := json.Marshal(*mi)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(save_path, content, 0o644)
|
||||
}
|
||||
|
||||
func getInfo(path string) (*MediaInfo, error) {
|
||||
func RetriveMediaInfo(path string, sha string) (*MediaInfo, error) {
|
||||
defer printExecTime("mediainfo for %s", path)()
|
||||
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
@ -257,24 +233,33 @@ func getInfo(path string) (*MediaInfo, error) {
|
||||
}
|
||||
|
||||
ret := MediaInfo{
|
||||
Sha: sha,
|
||||
Path: path,
|
||||
// Remove leading .
|
||||
Extension: filepath.Ext(path)[1:],
|
||||
Size: ParseUint64(mi.Format.Size),
|
||||
Size: ParseInt64(mi.Format.Size),
|
||||
Duration: float32(mi.Format.DurationSeconds),
|
||||
Container: OrNull(mi.Format.FormatName),
|
||||
Versions: Versions{
|
||||
Info: InfoVersion,
|
||||
Extract: 0,
|
||||
Thumbs: 0,
|
||||
Keyframes: 0,
|
||||
},
|
||||
Videos: MapStream(mi.Streams, ffprobe.StreamVideo, func(stream *ffprobe.Stream, i uint32) Video {
|
||||
lang, _ := language.Parse(stream.Tags.Language)
|
||||
return Video{
|
||||
Index: i,
|
||||
Codec: stream.CodecName,
|
||||
MimeCodec: GetMimeCodec(stream),
|
||||
Title: OrNull(stream.Tags.Title),
|
||||
Language: NullIfUnd(lang.String()),
|
||||
Quality: QualityFromHeight(uint32(stream.Height)),
|
||||
Width: uint32(stream.Width),
|
||||
Height: uint32(stream.Height),
|
||||
// ffmpeg does not report bitrate in mkv files, fallback to bitrate of the whole container
|
||||
// (bigger than the result since it contains audio and other videos but better than nothing).
|
||||
Bitrate: ParseUint(cmp.Or(stream.BitRate, mi.Format.BitRate)),
|
||||
Bitrate: ParseUint(cmp.Or(stream.BitRate, mi.Format.BitRate)),
|
||||
IsDefault: stream.Disposition.Default != 0,
|
||||
}
|
||||
}),
|
||||
Audios: MapStream(mi.Streams, ffprobe.StreamAudio, func(stream *ffprobe.Stream, i uint32) Audio {
|
||||
@ -285,27 +270,27 @@ func getInfo(path string) (*MediaInfo, error) {
|
||||
Language: NullIfUnd(lang.String()),
|
||||
Codec: stream.CodecName,
|
||||
MimeCodec: GetMimeCodec(stream),
|
||||
Bitrate: ParseUint(cmp.Or(stream.BitRate, mi.Format.BitRate)),
|
||||
IsDefault: stream.Disposition.Default != 0,
|
||||
IsForced: stream.Disposition.Forced != 0,
|
||||
}
|
||||
}),
|
||||
Subtitles: MapStream(mi.Streams, ffprobe.StreamSubtitle, func(stream *ffprobe.Stream, i uint32) Subtitle {
|
||||
extension := OrNull(SubtitleExtensions[stream.CodecName])
|
||||
var link *string
|
||||
var link string
|
||||
if extension != nil {
|
||||
x := fmt.Sprintf("%s/%s/subtitle/%d.%s", Settings.RoutePrefix, base64.StdEncoding.EncodeToString([]byte(path)), i, *extension)
|
||||
link = &x
|
||||
link = fmt.Sprintf("%s/%s/subtitle/%d.%s", Settings.RoutePrefix, base64.RawURLEncoding.EncodeToString([]byte(path)), i, *extension)
|
||||
}
|
||||
lang, _ := language.Parse(stream.Tags.Language)
|
||||
idx := uint32(i)
|
||||
return Subtitle{
|
||||
Index: uint32(i),
|
||||
Index: &idx,
|
||||
Title: OrNull(stream.Tags.Title),
|
||||
Language: NullIfUnd(lang.String()),
|
||||
Codec: stream.CodecName,
|
||||
Extension: extension,
|
||||
IsDefault: stream.Disposition.Default != 0,
|
||||
IsForced: stream.Disposition.Forced != 0,
|
||||
Link: link,
|
||||
Link: &link,
|
||||
}
|
||||
}),
|
||||
Chapters: Map(mi.Chapters, func(c *ffprobe.Chapter, _ int) Chapter {
|
||||
@ -313,11 +298,13 @@ func getInfo(path string) (*MediaInfo, error) {
|
||||
Name: c.Title(),
|
||||
StartTime: float32(c.StartTimeSeconds),
|
||||
EndTime: float32(c.EndTimeSeconds),
|
||||
// TODO: detect content type
|
||||
Type: Content,
|
||||
}
|
||||
}),
|
||||
Fonts: MapStream(mi.Streams, ffprobe.StreamAttachment, func(stream *ffprobe.Stream, i uint32) string {
|
||||
font, _ := stream.TagList.GetString("filename")
|
||||
return fmt.Sprintf("%s/%s/attachment/%s", Settings.RoutePrefix, base64.StdEncoding.EncodeToString([]byte(path)), font)
|
||||
return fmt.Sprintf("%s/%s/attachment/%s", Settings.RoutePrefix, base64.RawURLEncoding.EncodeToString([]byte(path)), font)
|
||||
}),
|
||||
}
|
||||
var codecs []string
|
||||
@ -337,9 +324,8 @@ func getInfo(path string) (*MediaInfo, error) {
|
||||
ret.MimeCodec = &container
|
||||
}
|
||||
}
|
||||
|
||||
if len(ret.Videos) > 0 {
|
||||
ret.Video = &ret.Videos[0]
|
||||
ret.Video = ret.Videos[0]
|
||||
}
|
||||
return &ret, nil
|
||||
}
|
||||
|
@ -8,18 +8,20 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const KeyframeVersion = 1
|
||||
|
||||
type Keyframe struct {
|
||||
Sha string
|
||||
Keyframes []float64
|
||||
CanTransmux bool
|
||||
IsDone bool
|
||||
info *KeyframeInfo
|
||||
Keyframes []float64
|
||||
IsDone bool
|
||||
info *KeyframeInfo
|
||||
}
|
||||
type KeyframeInfo struct {
|
||||
mutex sync.RWMutex
|
||||
ready sync.WaitGroup
|
||||
mutex sync.RWMutex
|
||||
listeners []func(keyframes []float64)
|
||||
}
|
||||
|
||||
@ -35,7 +37,12 @@ func (kf *Keyframe) Slice(start int32, end int32) []float64 {
|
||||
}
|
||||
kf.info.mutex.RLock()
|
||||
defer kf.info.mutex.RUnlock()
|
||||
|
||||
ref := kf.Keyframes[start:end]
|
||||
if kf.IsDone {
|
||||
return ref
|
||||
}
|
||||
// make a copy since we will continue to mutate the array.
|
||||
ret := make([]float64, end-start)
|
||||
copy(ret, ref)
|
||||
return ret
|
||||
@ -62,37 +69,85 @@ func (kf *Keyframe) AddListener(callback func(keyframes []float64)) {
|
||||
kf.info.listeners = append(kf.info.listeners, callback)
|
||||
}
|
||||
|
||||
var keyframes = NewCMap[string, *Keyframe]()
|
||||
|
||||
func GetKeyframes(sha string, path string) *Keyframe {
|
||||
ret, _ := keyframes.GetOrCreate(sha, func() *Keyframe {
|
||||
kf := &Keyframe{
|
||||
Sha: sha,
|
||||
IsDone: false,
|
||||
info: &KeyframeInfo{},
|
||||
}
|
||||
kf.info.ready.Add(1)
|
||||
go func() {
|
||||
save_path := fmt.Sprintf("%s/%s/keyframes.json", Settings.Metadata, sha)
|
||||
if err := getSavedInfo(save_path, kf); err == nil {
|
||||
log.Printf("Using keyframes cache on filesystem for %s", path)
|
||||
kf.info.ready.Done()
|
||||
return
|
||||
}
|
||||
|
||||
err := getKeyframes(path, kf, sha)
|
||||
if err == nil {
|
||||
saveInfo(save_path, kf)
|
||||
}
|
||||
}()
|
||||
return kf
|
||||
})
|
||||
ret.info.ready.Wait()
|
||||
return ret
|
||||
func (kf *Keyframe) Scan(src interface{}) error {
|
||||
var arr pq.Float64Array
|
||||
err := arr.Scan(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kf.Keyframes = arr
|
||||
kf.IsDone = true
|
||||
kf.info = &KeyframeInfo{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getKeyframes(path string, kf *Keyframe, sha string) error {
|
||||
defer printExecTime("ffprobe analysis for %s", path)()
|
||||
type KeyframeKey struct {
|
||||
Sha string
|
||||
IsVideo bool
|
||||
Index uint32
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetKeyframes(info *MediaInfo, isVideo bool, idx uint32) (*Keyframe, error) {
|
||||
if isVideo && info.Videos[idx].Keyframes != nil {
|
||||
return info.Videos[idx].Keyframes, nil
|
||||
}
|
||||
if !isVideo && info.Audios[idx].Keyframes != nil {
|
||||
return info.Audios[idx].Keyframes, nil
|
||||
}
|
||||
|
||||
get_running, set := s.keyframeLock.Start(KeyframeKey{
|
||||
Sha: info.Sha,
|
||||
IsVideo: isVideo,
|
||||
Index: idx,
|
||||
})
|
||||
if get_running != nil {
|
||||
return get_running()
|
||||
}
|
||||
|
||||
kf := &Keyframe{
|
||||
IsDone: false,
|
||||
info: &KeyframeInfo{},
|
||||
}
|
||||
kf.info.ready.Add(1)
|
||||
|
||||
go func() {
|
||||
var table string
|
||||
var err error
|
||||
if isVideo {
|
||||
table = "videos"
|
||||
err = getVideoKeyframes(info.Path, idx, kf)
|
||||
} else {
|
||||
table = "audios"
|
||||
err = getAudioKeyframes(info, idx, kf)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Couldn't retrive keyframes for %s %s %d: %v", info.Path, table, idx, err)
|
||||
return
|
||||
}
|
||||
|
||||
kf.info.ready.Wait()
|
||||
tx, _ := s.database.Begin()
|
||||
tx.Exec(
|
||||
fmt.Sprintf(`update %s set keyframes = $3 where sha = $1 and idx = $2`, table),
|
||||
info.Sha,
|
||||
idx,
|
||||
pq.Array(kf.Keyframes),
|
||||
)
|
||||
tx.Exec(`update info set ver_keyframes = $2 where sha = $1`, info.Sha, KeyframeVersion)
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
log.Printf("Couldn't store keyframes on database: %v", err)
|
||||
}
|
||||
}()
|
||||
return set(kf, nil)
|
||||
}
|
||||
|
||||
// Retrive video's keyframes and store them inside the kf var.
|
||||
// Returns when all key frames are retrived (or an error occurs)
|
||||
// info.ready.Done() is called when more than 100 are retrived (or extraction is done)
|
||||
func getVideoKeyframes(path string, video_idx uint32, kf *Keyframe) error {
|
||||
defer printExecTime("ffprobe keyframe analysis for %s video n%d", path, video_idx)()
|
||||
// run ffprobe to return all IFrames, IFrames are points where we can split the video in segments.
|
||||
// We ask ffprobe to return the time of each frame and it's flags
|
||||
// We could ask it to return only i-frames (keyframes) with the -skip_frame nokey but using it is extremly slow
|
||||
@ -100,7 +155,7 @@ func getKeyframes(path string, kf *Keyframe, sha string) error {
|
||||
cmd := exec.Command(
|
||||
"ffprobe",
|
||||
"-loglevel", "error",
|
||||
"-select_streams", "v:0",
|
||||
"-select_streams", fmt.Sprintf("V:%d", video_idx),
|
||||
"-show_entries", "packet=pts_time,flags",
|
||||
"-of", "csv=print_section=0",
|
||||
path,
|
||||
@ -117,7 +172,7 @@ func getKeyframes(path string, kf *Keyframe, sha string) error {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
|
||||
ret := make([]float64, 0, 1000)
|
||||
max := 100
|
||||
limit := 100
|
||||
done := 0
|
||||
// sometimes, videos can start at a timing greater than 0:00. We need to take that into account
|
||||
// and only list keyframes that come after the start of the video (without that, our segments count
|
||||
@ -156,44 +211,36 @@ func getKeyframes(path string, kf *Keyframe, sha string) error {
|
||||
|
||||
ret = append(ret, fpts)
|
||||
|
||||
if len(ret) == max {
|
||||
if len(ret) == limit {
|
||||
kf.add(ret)
|
||||
if done == 0 {
|
||||
kf.info.ready.Done()
|
||||
} else if done >= 500 {
|
||||
max = 500
|
||||
limit = 500
|
||||
}
|
||||
done += max
|
||||
done += limit
|
||||
// clear the array without reallocing it
|
||||
ret = ret[:0]
|
||||
}
|
||||
}
|
||||
// If there is less than 2 (i.e. equals 0 or 1 (it happens for audio files with poster))
|
||||
if len(ret) < 2 {
|
||||
dummy, err := getDummyKeyframes(path, sha)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ret = dummy
|
||||
}
|
||||
kf.add(ret)
|
||||
kf.IsDone = true
|
||||
if done == 0 {
|
||||
kf.info.ready.Done()
|
||||
}
|
||||
kf.IsDone = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func getDummyKeyframes(path string, sha string) ([]float64, error) {
|
||||
dummyKeyframeDuration := float64(2)
|
||||
info, err := GetInfo(path, sha)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 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)
|
||||
ret := make([]float64, segmentCount)
|
||||
kf.Keyframes = make([]float64, segmentCount)
|
||||
for segmentIndex := 0; segmentIndex < segmentCount; segmentIndex += 1 {
|
||||
ret[segmentIndex] = float64(segmentIndex) * dummyKeyframeDuration
|
||||
kf.Keyframes[segmentIndex] = float64(segmentIndex) * dummyKeyframeDuration
|
||||
}
|
||||
return ret, nil
|
||||
|
||||
kf.IsDone = true
|
||||
kf.info.ready.Done()
|
||||
return nil
|
||||
}
|
||||
|
312
transcoder/src/metadata.go
Normal file
312
transcoder/src/metadata.go
Normal file
@ -0,0 +1,312 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
"github.com/lib/pq"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
type MetadataService struct {
|
||||
database *sql.DB
|
||||
lock RunLock[string, *MediaInfo]
|
||||
thumbLock RunLock[string, interface{}]
|
||||
extractLock RunLock[string, interface{}]
|
||||
keyframeLock RunLock[KeyframeKey, *Keyframe]
|
||||
}
|
||||
|
||||
func NewMetadataService() (*MetadataService, error) {
|
||||
con := fmt.Sprintf(
|
||||
"postgresql://%v:%v@%v:%v/%v?application_name=gocoder&search_path=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")),
|
||||
)
|
||||
db, err := sql.Open("postgres", con)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = db.Exec("create schema if not exists gocoder")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
driver, err := postgres.WithInstance(db, &postgres.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := migrate.NewWithDatabaseInstance("file://migrations", "postgres", driver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Up()
|
||||
|
||||
return &MetadataService{
|
||||
database: db,
|
||||
lock: NewRunLock[string, *MediaInfo](),
|
||||
thumbLock: NewRunLock[string, interface{}](),
|
||||
extractLock: NewRunLock[string, interface{}](),
|
||||
keyframeLock: NewRunLock[KeyframeKey, *Keyframe](),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetMetadata(path string, sha string) (*MediaInfo, error) {
|
||||
ret, err := s.getMetadata(path, sha)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if ret.Versions.Thumbs < ThumbsVersion {
|
||||
go s.ExtractThumbs(path, sha)
|
||||
}
|
||||
if ret.Versions.Extract < ExtractVersion {
|
||||
go s.ExtractSubs(ret)
|
||||
}
|
||||
if ret.Versions.Keyframes < KeyframeVersion && ret.Versions.Keyframes != 0 {
|
||||
for _, video := range ret.Videos {
|
||||
video.Keyframes = nil
|
||||
}
|
||||
for _, audio := range ret.Audios {
|
||||
audio.Keyframes = nil
|
||||
}
|
||||
tx, err := s.database.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tx.Exec(`update videos set keyframes = null where sha = $1`, sha)
|
||||
tx.Exec(`update audios set keyframes = null where sha = $1`, sha)
|
||||
tx.Exec(`update info set ver_keyframes = 0 where sha = $1`, sha)
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
fmt.Printf("error deleteing old keyframes from database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) getMetadata(path string, sha string) (*MediaInfo, error) {
|
||||
var ret MediaInfo
|
||||
var fonts pq.StringArray
|
||||
err := s.database.QueryRow(
|
||||
`select i.sha, i.path, i.extension, i.mime_codec, i.size, i.duration, i.container,
|
||||
i.fonts, i.ver_info, i.ver_extract, i.ver_thumbs, i.ver_keyframes
|
||||
from info as i where i.sha=$1`,
|
||||
sha,
|
||||
).Scan(
|
||||
&ret.Sha, &ret.Path, &ret.Extension, &ret.MimeCodec, &ret.Size, &ret.Duration, &ret.Container,
|
||||
&fonts, &ret.Versions.Info, &ret.Versions.Extract, &ret.Versions.Thumbs, &ret.Versions.Keyframes,
|
||||
)
|
||||
ret.Fonts = fonts
|
||||
ret.Videos = make([]Video, 0)
|
||||
ret.Audios = make([]Audio, 0)
|
||||
ret.Subtitles = make([]Subtitle, 0)
|
||||
ret.Chapters = make([]Chapter, 0)
|
||||
|
||||
if err == sql.ErrNoRows || (ret.Versions.Info < InfoVersion && ret.Versions.Info != 0) {
|
||||
return s.storeFreshMetadata(path, sha)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := s.database.Query(
|
||||
`select v.idx, v.title, v.language, v.codec, v.mime_codec, v.width, v.height, v.bitrate, v.is_default, v.keyframes
|
||||
from videos as v where v.sha=$1`,
|
||||
sha,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var v Video
|
||||
err := rows.Scan(&v.Index, &v.Title, &v.Language, &v.Codec, &v.MimeCodec, &v.Width, &v.Height, &v.Bitrate, &v.IsDefault, &v.Keyframes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret.Videos = append(ret.Videos, v)
|
||||
}
|
||||
|
||||
rows, err = s.database.Query(
|
||||
`select a.idx, a.title, a.language, a.codec, a.mime_codec, a.bitrate, a.is_default, a.keyframes
|
||||
from audios as a where a.sha=$1`,
|
||||
sha,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var a Audio
|
||||
err := rows.Scan(&a.Index, &a.Title, &a.Language, &a.Codec, &a.MimeCodec, &a.Bitrate, &a.IsDefault, &a.Keyframes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret.Audios = append(ret.Audios, a)
|
||||
}
|
||||
|
||||
rows, err = s.database.Query(
|
||||
`select s.idx, s.title, s.language, s.codec, s.extension, s.is_default, s.is_forced
|
||||
from subtitles as s where s.sha=$1`,
|
||||
sha,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var s Subtitle
|
||||
err := rows.Scan(&s.Index, &s.Title, &s.Language, &s.Codec, &s.Extension, &s.IsDefault, &s.IsForced)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.Extension != nil {
|
||||
link := fmt.Sprintf(
|
||||
"%s/%s/subtitle/%d.%s",
|
||||
Settings.RoutePrefix,
|
||||
base64.RawURLEncoding.EncodeToString([]byte(ret.Path)),
|
||||
*s.Index,
|
||||
*s.Extension,
|
||||
)
|
||||
s.Link = &link
|
||||
}
|
||||
ret.Subtitles = append(ret.Subtitles, s)
|
||||
}
|
||||
|
||||
rows, err = s.database.Query(
|
||||
`select c.start_time, c.end_time, c.name, c.type
|
||||
from chapters as c where c.sha=$1`,
|
||||
sha,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var c Chapter
|
||||
err := rows.Scan(&c.StartTime, &c.EndTime, &c.Name, &c.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret.Chapters = append(ret.Chapters, c)
|
||||
}
|
||||
|
||||
if len(ret.Videos) > 0 {
|
||||
ret.Video = ret.Videos[0]
|
||||
}
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) storeFreshMetadata(path string, sha string) (*MediaInfo, error) {
|
||||
get_running, set := s.lock.Start(sha)
|
||||
if get_running != nil {
|
||||
return get_running()
|
||||
}
|
||||
|
||||
ret, err := RetriveMediaInfo(path, sha)
|
||||
if err != nil {
|
||||
return set(nil, err)
|
||||
}
|
||||
|
||||
tx, err := s.database.Begin()
|
||||
err = tx.QueryRow(`
|
||||
insert into info(sha, path, extension, mime_codec, size, duration, container,
|
||||
fonts, ver_info, ver_extract, ver_thumbs, ver_keyframes)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
on conflict (path) do update set
|
||||
sha = excluded.sha,
|
||||
path = excluded.path,
|
||||
extension = excluded.extension,
|
||||
mime_codec = excluded.mime_codec,
|
||||
size = excluded.size,
|
||||
duration = excluded.duration,
|
||||
container = excluded.container,
|
||||
fonts = excluded.fonts,
|
||||
ver_info = excluded.ver_info
|
||||
returning ver_extract, ver_thumbs, ver_keyframes
|
||||
`,
|
||||
// on conflict do not update versions of extract/thumbs/keyframes
|
||||
ret.Sha, ret.Path, ret.Extension, ret.MimeCodec, ret.Size, ret.Duration, ret.Container,
|
||||
pq.Array(ret.Fonts), ret.Versions.Info, ret.Versions.Extract, ret.Versions.Thumbs, ret.Versions.Keyframes,
|
||||
).Scan(&ret.Versions.Extract, &ret.Versions.Thumbs, &ret.Versions.Keyframes)
|
||||
for _, v := range ret.Videos {
|
||||
tx.Exec(`
|
||||
insert into videos(sha, idx, title, language, codec, mime_codec, width, height, is_default, bitrate)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
on conflict (sha, idx) do update set
|
||||
sha = excluded.sha,
|
||||
idx = excluded.idx,
|
||||
title = excluded.title,
|
||||
language = excluded.language,
|
||||
codec = excluded.codec,
|
||||
mime_codec = excluded.mime_codec,
|
||||
width = excluded.width,
|
||||
height = excluded.height,
|
||||
is_default = excluded.is_default,
|
||||
bitrate = excluded.bitrate
|
||||
`,
|
||||
ret.Sha, v.Index, v.Title, v.Language, v.Codec, v.MimeCodec, v.Width, v.Height, v.IsDefault, v.Bitrate,
|
||||
)
|
||||
}
|
||||
for _, a := range ret.Audios {
|
||||
tx.Exec(`
|
||||
insert into audios(sha, idx, title, language, codec, mime_codec, is_default, bitrate)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
on conflict (sha, idx) do update set
|
||||
sha = excluded.sha,
|
||||
idx = excluded.idx,
|
||||
title = excluded.title,
|
||||
language = excluded.language,
|
||||
codec = excluded.codec,
|
||||
mime_codec = excluded.mime_codec,
|
||||
is_default = excluded.is_default,
|
||||
bitrate = excluded.bitrate
|
||||
`,
|
||||
ret.Sha, a.Index, a.Title, a.Language, a.Codec, a.MimeCodec, a.IsDefault, a.Bitrate,
|
||||
)
|
||||
}
|
||||
for _, s := range ret.Subtitles {
|
||||
tx.Exec(`
|
||||
insert into subtitles(sha, idx, title, language, codec, extension, is_default, is_forced)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
on conflict (sha, idx) do update set
|
||||
sha = excluded.sha,
|
||||
idx = excluded.idx,
|
||||
title = excluded.title,
|
||||
language = excluded.language,
|
||||
codec = excluded.codec,
|
||||
extension = excluded.extension,
|
||||
is_default = excluded.is_default,
|
||||
is_forced = excluded.is_forced
|
||||
`,
|
||||
ret.Sha, s.Index, s.Title, s.Language, s.Codec, s.Extension, s.IsDefault, s.IsForced,
|
||||
)
|
||||
}
|
||||
for _, c := range ret.Chapters {
|
||||
tx.Exec(`
|
||||
insert into chapters(sha, start_time, end_time, name, type)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
on conflict (sha, start_time) do update set
|
||||
sha = excluded.sha,
|
||||
start_time = excluded.start_time,
|
||||
end_time = excluded.end_time,
|
||||
name = excluded.name,
|
||||
type = excluded.type
|
||||
`,
|
||||
ret.Sha, c.StartTime, c.EndTime, c.Name, c.Type,
|
||||
)
|
||||
}
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return set(ret, err)
|
||||
}
|
||||
|
||||
return set(ret, nil)
|
||||
}
|
@ -28,8 +28,7 @@ func QualityFromString(str string) (Quality, error) {
|
||||
return Original, nil
|
||||
}
|
||||
|
||||
qualities := Qualities
|
||||
for _, quality := range qualities {
|
||||
for _, quality := range Qualities {
|
||||
if string(quality) == str {
|
||||
return quality, nil
|
||||
}
|
||||
@ -110,10 +109,9 @@ func (q Quality) Height() uint32 {
|
||||
panic("Invalid quality value")
|
||||
}
|
||||
|
||||
func QualityFromHeight(height uint32) Quality {
|
||||
qualities := Qualities
|
||||
for _, quality := range qualities {
|
||||
if quality.Height() >= height {
|
||||
func (video *Video) Quality() Quality {
|
||||
for _, quality := range Qualities {
|
||||
if quality.Height() >= video.Height || quality.AverageBitrate() >= video.Bitrate {
|
||||
return quality
|
||||
}
|
||||
}
|
||||
|
81
transcoder/src/runlock.go
Normal file
81
transcoder/src/runlock.go
Normal file
@ -0,0 +1,81 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type RunLock[K comparable, V any] struct {
|
||||
running map[K]*Task[V]
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
type Task[V any] struct {
|
||||
ready sync.WaitGroup
|
||||
listeners []chan Result[V]
|
||||
}
|
||||
|
||||
type Result[V any] struct {
|
||||
ok V
|
||||
err error
|
||||
}
|
||||
|
||||
func NewRunLock[K comparable, V any]() RunLock[K, V] {
|
||||
return RunLock[K, V]{
|
||||
running: make(map[K]*Task[V]),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RunLock[K, V]) Start(key K) (func() (V, error), func(val V, err error) (V, error)) {
|
||||
r.lock.Lock()
|
||||
defer r.lock.Unlock()
|
||||
task, ok := r.running[key]
|
||||
|
||||
if ok {
|
||||
ret := make(chan Result[V])
|
||||
task.listeners = append(task.listeners, ret)
|
||||
return func() (V, error) {
|
||||
res := <-ret
|
||||
return res.ok, res.err
|
||||
}, nil
|
||||
}
|
||||
|
||||
r.running[key] = &Task[V]{
|
||||
listeners: make([]chan Result[V], 0),
|
||||
}
|
||||
|
||||
return nil, func(val V, err error) (V, error) {
|
||||
r.lock.Lock()
|
||||
defer r.lock.Unlock()
|
||||
|
||||
task, ok = r.running[key]
|
||||
if !ok {
|
||||
return val, errors.New("invalid run lock state. aborting.")
|
||||
}
|
||||
|
||||
for _, listener := range task.listeners {
|
||||
listener <- Result[V]{ok: val, err: err}
|
||||
close(listener)
|
||||
}
|
||||
delete(r.running, key)
|
||||
return val, err
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RunLock[K, V]) WaitFor(key K) (V, error) {
|
||||
r.lock.Lock()
|
||||
task, ok := r.running[key]
|
||||
|
||||
if !ok {
|
||||
r.lock.Unlock()
|
||||
var val V
|
||||
return val, nil
|
||||
}
|
||||
|
||||
ret := make(chan Result[V])
|
||||
task.listeners = append(task.listeners, ret)
|
||||
|
||||
r.lock.Unlock()
|
||||
res := <-ret
|
||||
return res.ok, res.err
|
||||
}
|
@ -30,10 +30,12 @@ type StreamHandle interface {
|
||||
}
|
||||
|
||||
type Stream struct {
|
||||
handle StreamHandle
|
||||
file *FileStream
|
||||
segments []Segment
|
||||
heads []Head
|
||||
handle StreamHandle
|
||||
ready sync.WaitGroup
|
||||
file *FileStream
|
||||
keyframes *Keyframe
|
||||
segments []Segment
|
||||
heads []Head
|
||||
// the lock used for the the heads
|
||||
lock sync.RWMutex
|
||||
}
|
||||
@ -62,32 +64,39 @@ var DeletedHead = Head{
|
||||
command: nil,
|
||||
}
|
||||
|
||||
func NewStream(file *FileStream, handle StreamHandle, ret *Stream) {
|
||||
func NewStream(file *FileStream, keyframes *Keyframe, handle StreamHandle, ret *Stream) {
|
||||
ret.handle = handle
|
||||
ret.file = file
|
||||
ret.keyframes = keyframes
|
||||
ret.heads = make([]Head, 0)
|
||||
|
||||
length, is_done := file.Keyframes.Length()
|
||||
ret.segments = make([]Segment, length, max(length, 2000))
|
||||
for seg := range ret.segments {
|
||||
ret.segments[seg].channel = make(chan struct{})
|
||||
}
|
||||
ret.ready.Add(1)
|
||||
go func() {
|
||||
keyframes.info.ready.Wait()
|
||||
|
||||
if !is_done {
|
||||
file.Keyframes.AddListener(func(keyframes []float64) {
|
||||
ret.lock.Lock()
|
||||
defer ret.lock.Unlock()
|
||||
old_length := len(ret.segments)
|
||||
if cap(ret.segments) > len(keyframes) {
|
||||
ret.segments = ret.segments[:len(keyframes)]
|
||||
} else {
|
||||
ret.segments = append(ret.segments, make([]Segment, len(keyframes)-old_length)...)
|
||||
}
|
||||
for seg := old_length; seg < len(keyframes); seg++ {
|
||||
ret.segments[seg].channel = make(chan struct{})
|
||||
}
|
||||
})
|
||||
}
|
||||
length, is_done := keyframes.Length()
|
||||
ret.segments = make([]Segment, length, max(length, 2000))
|
||||
for seg := range ret.segments {
|
||||
ret.segments[seg].channel = make(chan struct{})
|
||||
}
|
||||
|
||||
if !is_done {
|
||||
keyframes.AddListener(func(keyframes []float64) {
|
||||
ret.lock.Lock()
|
||||
defer ret.lock.Unlock()
|
||||
old_length := len(ret.segments)
|
||||
if cap(ret.segments) > len(keyframes) {
|
||||
ret.segments = ret.segments[:len(keyframes)]
|
||||
} else {
|
||||
ret.segments = append(ret.segments, make([]Segment, len(keyframes)-old_length)...)
|
||||
}
|
||||
for seg := old_length; seg < len(keyframes); seg++ {
|
||||
ret.segments[seg].channel = make(chan struct{})
|
||||
}
|
||||
})
|
||||
}
|
||||
ret.ready.Done()
|
||||
}()
|
||||
}
|
||||
|
||||
// Remember to lock before calling this.
|
||||
@ -118,7 +127,7 @@ func toSegmentStr(segments []float64) string {
|
||||
|
||||
func (ts *Stream) run(start int32) error {
|
||||
// Start the transcode up to the 100th segment (or less)
|
||||
length, is_done := ts.file.Keyframes.Length()
|
||||
length, is_done := ts.keyframes.Length()
|
||||
end := min(start+100, length)
|
||||
// if keyframes analysys is not finished, always have a 1-segment padding
|
||||
// for the extra segment needed for precise split (look comment before -to flag)
|
||||
@ -148,7 +157,7 @@ func (ts *Stream) run(start int32) error {
|
||||
log.Printf(
|
||||
"Starting transcode %d for %s (from %d to %d out of %d segments)",
|
||||
encoder_id,
|
||||
ts.file.Path,
|
||||
ts.file.Info.Path,
|
||||
start,
|
||||
end,
|
||||
length,
|
||||
@ -166,7 +175,7 @@ func (ts *Stream) run(start int32) error {
|
||||
// the previous segment is played another time. the -segment_times is way more precise so it does not do the same with this one
|
||||
start_segment = start - 1
|
||||
if ts.handle.getFlags()&AudioF != 0 {
|
||||
start_ref = ts.file.Keyframes.Get(start_segment)
|
||||
start_ref = ts.keyframes.Get(start_segment)
|
||||
} else {
|
||||
// the param for the -ss takes the keyframe before the specificed time
|
||||
// (if the specified time is a keyframe, it either takes that keyframe or the one before)
|
||||
@ -175,9 +184,9 @@ func (ts *Stream) run(start int32) error {
|
||||
// this can't be used with audio since we need to have context before the start-time
|
||||
// without this context, the cut loses a bit of audio (audio gap of ~100ms)
|
||||
if start_segment+1 == length {
|
||||
start_ref = (ts.file.Keyframes.Get(start_segment) + float64(ts.file.Info.Duration)) / 2
|
||||
start_ref = (ts.keyframes.Get(start_segment) + float64(ts.file.Info.Duration)) / 2
|
||||
} else {
|
||||
start_ref = (ts.file.Keyframes.Get(start_segment) + ts.file.Keyframes.Get(start_segment+1)) / 2
|
||||
start_ref = (ts.keyframes.Get(start_segment) + ts.keyframes.Get(start_segment+1)) / 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -185,7 +194,7 @@ func (ts *Stream) run(start int32) error {
|
||||
if end == length {
|
||||
end_padding = 0
|
||||
}
|
||||
segments := ts.file.Keyframes.Slice(start_segment+1, end+end_padding)
|
||||
segments := ts.keyframes.Slice(start_segment+1, end+end_padding)
|
||||
if len(segments) == 0 {
|
||||
// we can't leave that empty else ffmpeg errors out.
|
||||
segments = []float64{9999999}
|
||||
@ -219,17 +228,17 @@ func (ts *Stream) run(start int32) error {
|
||||
if end+1 < length {
|
||||
// sometimes, the duration is shorter than expected (only during transcode it seems)
|
||||
// always include more and use the -f segment to split the file where we want
|
||||
end_ref := ts.file.Keyframes.Get(end + 1)
|
||||
end_ref := ts.keyframes.Get(end + 1)
|
||||
// it seems that the -to is confused when -ss seek before the given time (because it searches for a keyframe)
|
||||
// add back the time that would be lost otherwise
|
||||
// this only appens when -to is before -i but having -to after -i gave a bug (not sure, don't remember)
|
||||
end_ref += start_ref - ts.file.Keyframes.Get(start_segment)
|
||||
end_ref += start_ref - ts.keyframes.Get(start_segment)
|
||||
args = append(args,
|
||||
"-to", fmt.Sprintf("%.6f", end_ref),
|
||||
)
|
||||
}
|
||||
args = append(args,
|
||||
"-i", ts.file.Path,
|
||||
"-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
|
||||
"-start_at_zero",
|
||||
@ -255,7 +264,7 @@ func (ts *Stream) run(start int32) error {
|
||||
// segment_times want durations, not timestamps so we must substract the -ss param
|
||||
// since we give a greater value to -ss to prevent wrong seeks but -segment_times
|
||||
// needs precise segments, we use the keyframe we want to seek to as a reference.
|
||||
return seg - ts.file.Keyframes.Get(start_segment)
|
||||
return seg - ts.keyframes.Get(start_segment)
|
||||
})),
|
||||
"-segment_list_type", "flat",
|
||||
"-segment_list", "pipe:1",
|
||||
@ -358,16 +367,16 @@ func (ts *Stream) GetIndex() (string, error) {
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
`
|
||||
length, is_done := ts.file.Keyframes.Length()
|
||||
length, is_done := ts.keyframes.Length()
|
||||
|
||||
for segment := int32(0); segment < length-1; segment++ {
|
||||
index += fmt.Sprintf("#EXTINF:%.6f\n", ts.file.Keyframes.Get(segment+1)-ts.file.Keyframes.Get(segment))
|
||||
index += fmt.Sprintf("#EXTINF:%.6f\n", ts.keyframes.Get(segment+1)-ts.keyframes.Get(segment))
|
||||
index += fmt.Sprintf("segment-%d.ts\n", segment)
|
||||
}
|
||||
// do not forget to add the last segment between the last keyframe and the end of the file
|
||||
// if the keyframes extraction is not done, do not bother to add it, it will be retrived on the next index retrival
|
||||
if is_done {
|
||||
index += fmt.Sprintf("#EXTINF:%.6f\n", float64(ts.file.Info.Duration)-ts.file.Keyframes.Get(length-1))
|
||||
index += fmt.Sprintf("#EXTINF:%.6f\n", float64(ts.file.Info.Duration)-ts.keyframes.Get(length-1))
|
||||
index += fmt.Sprintf("segment-%d.ts\n", length-1)
|
||||
index += `#EXT-X-ENDLIST`
|
||||
}
|
||||
@ -439,13 +448,13 @@ func (ts *Stream) prerareNextSegements(segment int32) {
|
||||
}
|
||||
|
||||
func (ts *Stream) getMinEncoderDistance(segment int32) float64 {
|
||||
time := ts.file.Keyframes.Get(segment)
|
||||
time := ts.keyframes.Get(segment)
|
||||
distances := Map(ts.heads, func(head Head, _ int) float64 {
|
||||
// ignore killed heads or heads after the current time
|
||||
if head.segment < 0 || ts.file.Keyframes.Get(head.segment) > time || segment >= head.end {
|
||||
if head.segment < 0 || ts.keyframes.Get(head.segment) > time || segment >= head.end {
|
||||
return math.Inf(1)
|
||||
}
|
||||
return time - ts.file.Keyframes.Get(head.segment)
|
||||
return time - ts.keyframes.Get(head.segment)
|
||||
})
|
||||
if len(distances) == 0 {
|
||||
return math.Inf(1)
|
||||
|
72
transcoder/src/subtitles.go
Normal file
72
transcoder/src/subtitles.go
Normal file
@ -0,0 +1,72 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
var separator = regexp.MustCompile(`[\s.]+`)
|
||||
|
||||
func (mi *MediaInfo) SearchExternalSubtitles() error {
|
||||
base_path := strings.TrimSuffix(mi.Path, filepath.Ext(mi.Path))
|
||||
dir, err := os.ReadDir(filepath.Dir(mi.Path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outer:
|
||||
for _, entry := range dir {
|
||||
match := filepath.Join(filepath.Dir(mi.Path), entry.Name())
|
||||
if entry.IsDir() || !strings.HasPrefix(match, base_path) {
|
||||
continue
|
||||
}
|
||||
|
||||
for codec, ext := range SubtitleExtensions {
|
||||
if strings.HasSuffix(match, ext) {
|
||||
link := fmt.Sprintf(
|
||||
"%s/%s/direct/%s",
|
||||
Settings.RoutePrefix,
|
||||
base64.RawURLEncoding.EncodeToString([]byte(match)),
|
||||
filepath.Base(match),
|
||||
)
|
||||
sub := Subtitle{
|
||||
Index: nil,
|
||||
Codec: codec,
|
||||
Extension: &ext,
|
||||
IsExternal: true,
|
||||
Path: &match,
|
||||
Link: &link,
|
||||
}
|
||||
flags := separator.Split(match[len(base_path):], -1)
|
||||
// remove extension from flags
|
||||
flags = flags[:len(flags)-1]
|
||||
|
||||
for _, flag := range flags {
|
||||
switch strings.ToLower(flag) {
|
||||
case "default":
|
||||
sub.IsDefault = true
|
||||
case "forced":
|
||||
sub.IsForced = true
|
||||
default:
|
||||
lang, err := language.Parse(flag)
|
||||
if err == nil && lang != language.Und {
|
||||
lang := lang.String()
|
||||
sub.Language = &lang
|
||||
} else {
|
||||
sub.Title = &flag
|
||||
}
|
||||
}
|
||||
}
|
||||
mi.Subtitles = append(mi.Subtitles, sub)
|
||||
continue outer
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
@ -8,6 +8,7 @@ import (
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
@ -26,31 +27,48 @@ type Thumbnail struct {
|
||||
path string
|
||||
}
|
||||
|
||||
var thumbnails = NewCMap[string, *Thumbnail]()
|
||||
const ThumbsVersion = 1
|
||||
|
||||
func ExtractThumbnail(path string, sha string) (string, error) {
|
||||
ret, _ := thumbnails.GetOrCreate(sha, func() *Thumbnail {
|
||||
ret := &Thumbnail{
|
||||
path: fmt.Sprintf("%s/%s", Settings.Metadata, sha),
|
||||
}
|
||||
ret.ready.Add(1)
|
||||
go func() {
|
||||
extractThumbnail(path, ret.path)
|
||||
ret.ready.Done()
|
||||
}()
|
||||
return ret
|
||||
})
|
||||
ret.ready.Wait()
|
||||
return ret.path, nil
|
||||
func getThumbGlob(sha string) string {
|
||||
return fmt.Sprintf("%s/%s/thumbs-v*.*", Settings.Metadata, sha)
|
||||
}
|
||||
|
||||
func extractThumbnail(path string, out string) error {
|
||||
defer printExecTime("extracting thumbnails for %s", path)()
|
||||
os.MkdirAll(out, 0o755)
|
||||
sprite_path := fmt.Sprintf("%s/sprite.png", out)
|
||||
vtt_path := fmt.Sprintf("%s/sprite.vtt", out)
|
||||
func getThumbPath(sha string) string {
|
||||
return fmt.Sprintf("%s/%s/thumbs-v%d.png", Settings.Metadata, sha, ThumbsVersion)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(sprite_path); err == nil {
|
||||
func getThumbVttPath(sha string) string {
|
||||
return fmt.Sprintf("%s/%s/thumbs-v%d.vtt", Settings.Metadata, sha, ThumbsVersion)
|
||||
}
|
||||
|
||||
func (s *MetadataService) GetThumb(path string, sha string) (string, string, error) {
|
||||
_, err := s.ExtractThumbs(path, sha)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return getThumbPath(sha), getThumbVttPath(sha), nil
|
||||
}
|
||||
|
||||
func (s *MetadataService) ExtractThumbs(path string, sha string) (interface{}, error) {
|
||||
get_running, set := s.thumbLock.Start(sha)
|
||||
if get_running != nil {
|
||||
return get_running()
|
||||
}
|
||||
|
||||
err := extractThumbnail(path, sha)
|
||||
if err != nil {
|
||||
return set(nil, err)
|
||||
}
|
||||
_, err = s.database.Exec(`update info set ver_thumbs = $2 where sha = $1`, sha, ThumbsVersion)
|
||||
return set(nil, err)
|
||||
}
|
||||
|
||||
func extractThumbnail(path string, sha string) error {
|
||||
defer printExecTime("extracting thumbnails for %s", path)()
|
||||
|
||||
os.MkdirAll(fmt.Sprintf("%s/%s", Settings.Metadata), 0o755)
|
||||
|
||||
if _, err := os.Stat(getThumbPath(sha)); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -102,7 +120,7 @@ func extractThumbnail(path string, out string) error {
|
||||
tsToVttTime(timestamps),
|
||||
tsToVttTime(ts),
|
||||
Settings.RoutePrefix,
|
||||
base64.StdEncoding.EncodeToString([]byte(path)),
|
||||
base64.RawURLEncoding.EncodeToString([]byte(path)),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
@ -110,11 +128,20 @@ func extractThumbnail(path string, out string) error {
|
||||
)
|
||||
}
|
||||
|
||||
err = os.WriteFile(vtt_path, []byte(vtt), 0o644)
|
||||
// Cleanup old versions of thumbnails
|
||||
files, err := filepath.Glob(getThumbGlob(sha))
|
||||
if err == nil {
|
||||
for _, f := range files {
|
||||
// ignore errors
|
||||
os.Remove(f)
|
||||
}
|
||||
}
|
||||
|
||||
err = os.WriteFile(getThumbVttPath(sha), []byte(vtt), 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = imaging.Save(sprite, sprite_path)
|
||||
err = imaging.Save(sprite, getThumbPath(sha))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -6,11 +6,13 @@ import (
|
||||
)
|
||||
|
||||
type ClientInfo struct {
|
||||
client string
|
||||
path string
|
||||
quality *Quality
|
||||
audio int32
|
||||
head int32
|
||||
client string
|
||||
sha string
|
||||
path string
|
||||
video *VideoKey
|
||||
audio *uint32
|
||||
vhead int32
|
||||
ahead int32
|
||||
}
|
||||
|
||||
type Tracker struct {
|
||||
@ -18,7 +20,7 @@ type Tracker struct {
|
||||
clients map[string]ClientInfo
|
||||
// key: client_id
|
||||
visitDate map[string]time.Time
|
||||
// key: path
|
||||
// key: sha
|
||||
lastUsage map[string]time.Time
|
||||
transcoder *Transcoder
|
||||
deletedStream chan string
|
||||
@ -55,35 +57,41 @@ func (t *Tracker) start() {
|
||||
|
||||
old, ok := t.clients[info.client]
|
||||
// First fixup the info. Most routes ruturn partial infos
|
||||
if ok && old.path == info.path {
|
||||
if info.quality == nil {
|
||||
info.quality = old.quality
|
||||
if ok && old.sha == info.sha {
|
||||
if info.video == nil {
|
||||
info.video = old.video
|
||||
}
|
||||
if info.audio == -1 {
|
||||
if info.audio == nil {
|
||||
info.audio = old.audio
|
||||
}
|
||||
if info.head == -1 {
|
||||
info.head = old.head
|
||||
if info.vhead == -1 {
|
||||
info.vhead = old.vhead
|
||||
}
|
||||
if info.ahead == -1 {
|
||||
info.ahead = old.ahead
|
||||
}
|
||||
}
|
||||
|
||||
t.clients[info.client] = info
|
||||
t.visitDate[info.client] = time.Now()
|
||||
t.lastUsage[info.path] = time.Now()
|
||||
t.lastUsage[info.sha] = time.Now()
|
||||
|
||||
// now that the new info is stored and fixed, kill old streams
|
||||
if ok && old.path == info.path {
|
||||
if old.audio != info.audio && old.audio != -1 {
|
||||
t.KillAudioIfDead(old.path, old.audio)
|
||||
if ok && old.sha == info.sha {
|
||||
if old.audio != nil && (info.audio == nil || *info.audio != *old.audio) {
|
||||
t.KillAudioIfDead(old.sha, old.path, *old.audio)
|
||||
}
|
||||
if old.quality != info.quality && old.quality != nil {
|
||||
t.KillQualityIfDead(old.path, *old.quality)
|
||||
if old.video != nil && (info.video == nil || *info.video != *old.video) {
|
||||
t.KillVideoIfDead(old.sha, old.path, *old.video)
|
||||
}
|
||||
if old.head != -1 && Abs(info.head-old.head) > 100 {
|
||||
t.KillOrphanedHeads(old.path, old.quality, old.audio)
|
||||
if old.vhead != -1 && Abs(info.vhead-old.vhead) > 100 {
|
||||
t.KillOrphanedHeads(old.sha, old.video, nil)
|
||||
}
|
||||
if old.ahead != -1 && Abs(info.ahead-old.ahead) > 100 {
|
||||
t.KillOrphanedHeads(old.sha, nil, old.audio)
|
||||
}
|
||||
} else if ok {
|
||||
t.KillStreamIfDead(old.path)
|
||||
t.KillStreamIfDead(old.sha, old.path)
|
||||
}
|
||||
|
||||
case <-timer:
|
||||
@ -98,11 +106,11 @@ func (t *Tracker) start() {
|
||||
delete(t.clients, client)
|
||||
delete(t.visitDate, client)
|
||||
|
||||
if !t.KillStreamIfDead(info.path) {
|
||||
audio_cleanup := info.audio != -1 && t.KillAudioIfDead(info.path, info.audio)
|
||||
video_cleanup := info.quality != nil && t.KillQualityIfDead(info.path, *info.quality)
|
||||
if !t.KillStreamIfDead(info.sha, info.path) {
|
||||
audio_cleanup := info.audio != nil && t.KillAudioIfDead(info.sha, info.path, *info.audio)
|
||||
video_cleanup := info.video != nil && t.KillVideoIfDead(info.sha, info.path, *info.video)
|
||||
if !audio_cleanup || !video_cleanup {
|
||||
t.KillOrphanedHeads(info.path, info.quality, info.audio)
|
||||
t.KillOrphanedHeads(info.sha, info.video, info.audio)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -112,46 +120,46 @@ func (t *Tracker) start() {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tracker) KillStreamIfDead(path string) bool {
|
||||
func (t *Tracker) KillStreamIfDead(sha string, path string) bool {
|
||||
for _, stream := range t.clients {
|
||||
if stream.path == path {
|
||||
if stream.sha == sha {
|
||||
return false
|
||||
}
|
||||
}
|
||||
log.Printf("Nobody is watching %s. Killing it", path)
|
||||
|
||||
stream, ok := t.transcoder.streams.Get(path)
|
||||
stream, ok := t.transcoder.streams.Get(sha)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
stream.Kill()
|
||||
go func() {
|
||||
time.Sleep(4 * time.Hour)
|
||||
t.deletedStream <- path
|
||||
t.deletedStream <- sha
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *Tracker) DestroyStreamIfOld(path string) {
|
||||
if time.Since(t.lastUsage[path]) < 4*time.Hour {
|
||||
func (t *Tracker) DestroyStreamIfOld(sha string) {
|
||||
if time.Since(t.lastUsage[sha]) < 4*time.Hour {
|
||||
return
|
||||
}
|
||||
stream, ok := t.transcoder.streams.GetAndRemove(path)
|
||||
stream, ok := t.transcoder.streams.GetAndRemove(sha)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
stream.Destroy()
|
||||
}
|
||||
|
||||
func (t *Tracker) KillAudioIfDead(path string, audio int32) bool {
|
||||
func (t *Tracker) KillAudioIfDead(sha string, path string, audio uint32) bool {
|
||||
for _, stream := range t.clients {
|
||||
if stream.path == path && stream.audio == audio {
|
||||
if stream.sha == sha && stream.audio != nil && *stream.audio == audio {
|
||||
return false
|
||||
}
|
||||
}
|
||||
log.Printf("Nobody is listening audio %d of %s. Killing it", audio, path)
|
||||
|
||||
stream, ok := t.transcoder.streams.Get(path)
|
||||
stream, ok := t.transcoder.streams.Get(sha)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
@ -163,19 +171,19 @@ func (t *Tracker) KillAudioIfDead(path string, audio int32) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *Tracker) KillQualityIfDead(path string, quality Quality) bool {
|
||||
func (t *Tracker) KillVideoIfDead(sha string, path string, video VideoKey) bool {
|
||||
for _, stream := range t.clients {
|
||||
if stream.path == path && stream.quality != nil && *stream.quality == quality {
|
||||
if stream.sha == sha && stream.video != nil && *stream.video == video {
|
||||
return false
|
||||
}
|
||||
}
|
||||
log.Printf("Nobody is watching quality %s of %s. Killing it", quality, path)
|
||||
log.Printf("Nobody is watching %s video %d quality %s. Killing it", path, video.idx, video.quality)
|
||||
|
||||
stream, ok := t.transcoder.streams.Get(path)
|
||||
stream, ok := t.transcoder.streams.Get(sha)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
vstream, vok := stream.videos.Get(quality)
|
||||
vstream, vok := stream.videos.Get(video)
|
||||
if !vok {
|
||||
return false
|
||||
}
|
||||
@ -183,27 +191,27 @@ func (t *Tracker) KillQualityIfDead(path string, quality Quality) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *Tracker) KillOrphanedHeads(path string, quality *Quality, audio int32) {
|
||||
stream, ok := t.transcoder.streams.Get(path)
|
||||
func (t *Tracker) KillOrphanedHeads(sha string, video *VideoKey, audio *uint32) {
|
||||
stream, ok := t.transcoder.streams.Get(sha)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if quality != nil {
|
||||
vstream, vok := stream.videos.Get(*quality)
|
||||
if video != nil {
|
||||
vstream, vok := stream.videos.Get(*video)
|
||||
if vok {
|
||||
t.killOrphanedeheads(&vstream.Stream)
|
||||
t.killOrphanedeheads(&vstream.Stream, true)
|
||||
}
|
||||
}
|
||||
if audio != -1 {
|
||||
astream, aok := stream.audios.Get(audio)
|
||||
if audio != nil {
|
||||
astream, aok := stream.audios.Get(*audio)
|
||||
if aok {
|
||||
t.killOrphanedeheads(&astream.Stream)
|
||||
t.killOrphanedeheads(&astream.Stream, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tracker) killOrphanedeheads(stream *Stream) {
|
||||
func (t *Tracker) killOrphanedeheads(stream *Stream, is_video bool) {
|
||||
stream.lock.Lock()
|
||||
defer stream.lock.Unlock()
|
||||
|
||||
@ -214,13 +222,14 @@ func (t *Tracker) killOrphanedeheads(stream *Stream) {
|
||||
|
||||
distance := int32(99999)
|
||||
for _, info := range t.clients {
|
||||
if info.head == -1 {
|
||||
continue
|
||||
ihead := info.ahead
|
||||
if is_video {
|
||||
ihead = info.vhead
|
||||
}
|
||||
distance = min(Abs(info.head-head.segment), distance)
|
||||
distance = min(Abs(ihead-head.segment), distance)
|
||||
}
|
||||
if distance > 20 {
|
||||
log.Printf("Killing orphaned head %s %d", stream.file.Path, encoder_id)
|
||||
log.Printf("Killing orphaned head %s %d", stream.file.Info.Path, encoder_id)
|
||||
stream.KillHead(encoder_id)
|
||||
}
|
||||
}
|
||||
|
@ -6,13 +6,14 @@ import (
|
||||
)
|
||||
|
||||
type Transcoder struct {
|
||||
// All file streams currently running, index is file path
|
||||
streams CMap[string, *FileStream]
|
||||
clientChan chan ClientInfo
|
||||
tracker *Tracker
|
||||
// All file streams currently running, index is sha
|
||||
streams CMap[string, *FileStream]
|
||||
clientChan chan ClientInfo
|
||||
tracker *Tracker
|
||||
metadataService *MetadataService
|
||||
}
|
||||
|
||||
func NewTranscoder() (*Transcoder, error) {
|
||||
func NewTranscoder(metadata *MetadataService) (*Transcoder, error) {
|
||||
out := Settings.Outpath
|
||||
dir, err := os.ReadDir(out)
|
||||
if err != nil {
|
||||
@ -26,20 +27,20 @@ func NewTranscoder() (*Transcoder, error) {
|
||||
}
|
||||
|
||||
ret := &Transcoder{
|
||||
streams: NewCMap[string, *FileStream](),
|
||||
clientChan: make(chan ClientInfo, 10),
|
||||
streams: NewCMap[string, *FileStream](),
|
||||
clientChan: make(chan ClientInfo, 10),
|
||||
metadataService: metadata,
|
||||
}
|
||||
ret.tracker = NewTracker(ret)
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (t *Transcoder) getFileStream(path string, sha string) (*FileStream, error) {
|
||||
var err error
|
||||
ret, _ := t.streams.GetOrCreate(path, func() *FileStream {
|
||||
return NewFileStream(path, sha)
|
||||
ret, _ := t.streams.GetOrCreate(sha, func() *FileStream {
|
||||
return t.newFileStream(path, sha)
|
||||
})
|
||||
ret.ready.Wait()
|
||||
if err != nil || ret.err != nil {
|
||||
if ret.err != nil {
|
||||
t.streams.Remove(path)
|
||||
return nil, ret.err
|
||||
}
|
||||
@ -52,17 +53,20 @@ func (t *Transcoder) GetMaster(path string, client string, sha string) (string,
|
||||
return "", err
|
||||
}
|
||||
t.clientChan <- ClientInfo{
|
||||
client: client,
|
||||
path: path,
|
||||
quality: nil,
|
||||
audio: -1,
|
||||
head: -1,
|
||||
client: client,
|
||||
sha: sha,
|
||||
path: path,
|
||||
video: nil,
|
||||
audio: nil,
|
||||
vhead: -1,
|
||||
ahead: -1,
|
||||
}
|
||||
return stream.GetMaster(), nil
|
||||
}
|
||||
|
||||
func (t *Transcoder) GetVideoIndex(
|
||||
path string,
|
||||
video uint32,
|
||||
quality Quality,
|
||||
client string,
|
||||
sha string,
|
||||
@ -72,18 +76,20 @@ func (t *Transcoder) GetVideoIndex(
|
||||
return "", err
|
||||
}
|
||||
t.clientChan <- ClientInfo{
|
||||
client: client,
|
||||
path: path,
|
||||
quality: &quality,
|
||||
audio: -1,
|
||||
head: -1,
|
||||
client: client,
|
||||
sha: sha,
|
||||
path: path,
|
||||
video: &VideoKey{video, quality},
|
||||
audio: nil,
|
||||
vhead: -1,
|
||||
ahead: -1,
|
||||
}
|
||||
return stream.GetVideoIndex(quality)
|
||||
return stream.GetVideoIndex(video, quality)
|
||||
}
|
||||
|
||||
func (t *Transcoder) GetAudioIndex(
|
||||
path string,
|
||||
audio int32,
|
||||
audio uint32,
|
||||
client string,
|
||||
sha string,
|
||||
) (string, error) {
|
||||
@ -93,15 +99,18 @@ func (t *Transcoder) GetAudioIndex(
|
||||
}
|
||||
t.clientChan <- ClientInfo{
|
||||
client: client,
|
||||
sha: sha,
|
||||
path: path,
|
||||
audio: audio,
|
||||
head: -1,
|
||||
audio: &audio,
|
||||
vhead: -1,
|
||||
ahead: -1,
|
||||
}
|
||||
return stream.GetAudioIndex(audio)
|
||||
}
|
||||
|
||||
func (t *Transcoder) GetVideoSegment(
|
||||
path string,
|
||||
video uint32,
|
||||
quality Quality,
|
||||
segment int32,
|
||||
client string,
|
||||
@ -112,18 +121,20 @@ func (t *Transcoder) GetVideoSegment(
|
||||
return "", err
|
||||
}
|
||||
t.clientChan <- ClientInfo{
|
||||
client: client,
|
||||
path: path,
|
||||
quality: &quality,
|
||||
audio: -1,
|
||||
head: segment,
|
||||
client: client,
|
||||
sha: sha,
|
||||
path: path,
|
||||
video: &VideoKey{video, quality},
|
||||
vhead: segment,
|
||||
audio: nil,
|
||||
ahead: -1,
|
||||
}
|
||||
return stream.GetVideoSegment(quality, segment)
|
||||
return stream.GetVideoSegment(video, quality, segment)
|
||||
}
|
||||
|
||||
func (t *Transcoder) GetAudioSegment(
|
||||
path string,
|
||||
audio int32,
|
||||
audio uint32,
|
||||
segment int32,
|
||||
client string,
|
||||
sha string,
|
||||
@ -134,9 +145,11 @@ func (t *Transcoder) GetAudioSegment(
|
||||
}
|
||||
t.clientChan <- ClientInfo{
|
||||
client: client,
|
||||
sha: sha,
|
||||
path: path,
|
||||
audio: audio,
|
||||
head: segment,
|
||||
audio: &audio,
|
||||
ahead: segment,
|
||||
vhead: -1,
|
||||
}
|
||||
return stream.GetAudioSegment(audio, segment)
|
||||
}
|
||||
|
@ -15,3 +15,13 @@ func printExecTime(message string, args ...any) func() {
|
||||
log.Printf("%s finished in %s", msg, time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
func Filter[E any](s []E, f func(E) bool) []E {
|
||||
s2 := make([]E, 0, len(s))
|
||||
for _, e := range s {
|
||||
if f(e) {
|
||||
s2 = append(s2, e)
|
||||
}
|
||||
}
|
||||
return s2
|
||||
}
|
||||
|
@ -7,15 +7,34 @@ import (
|
||||
|
||||
type VideoStream struct {
|
||||
Stream
|
||||
video *Video
|
||||
quality Quality
|
||||
}
|
||||
|
||||
func NewVideoStream(file *FileStream, quality Quality) *VideoStream {
|
||||
log.Printf("Creating a new video stream for %s in quality %s", file.Path, quality)
|
||||
func (t *Transcoder) NewVideoStream(file *FileStream, idx uint32, quality Quality) (*VideoStream, error) {
|
||||
log.Printf(
|
||||
"Creating a new video stream for %s (n %d) in quality %s",
|
||||
file.Info.Path,
|
||||
idx,
|
||||
quality,
|
||||
)
|
||||
|
||||
keyframes, err := t.metadataService.GetKeyframes(file.Info, true, idx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret := new(VideoStream)
|
||||
ret.quality = quality
|
||||
NewStream(file, ret, &ret.Stream)
|
||||
return ret
|
||||
for _, video := range file.Info.Videos {
|
||||
if video.Index == idx {
|
||||
ret.video = &video
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
NewStream(file, keyframes, ret, &ret.Stream)
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (vs *VideoStream) getFlags() Flags {
|
||||
@ -41,7 +60,7 @@ func closestMultiple(n int32, x int32) int32 {
|
||||
|
||||
func (vs *VideoStream) getTranscodeArgs(segments string) []string {
|
||||
args := []string{
|
||||
"-map", "0:V:0",
|
||||
"-map", fmt.Sprintf("0:V:%d", vs.video.Index),
|
||||
}
|
||||
|
||||
if vs.quality == Original {
|
||||
@ -52,7 +71,7 @@ func (vs *VideoStream) getTranscodeArgs(segments string) []string {
|
||||
}
|
||||
|
||||
args = append(args, Settings.HwAccel.EncodeFlags...)
|
||||
width := int32(float64(vs.quality.Height()) / float64(vs.file.Info.Video.Height) * float64(vs.file.Info.Video.Width))
|
||||
width := int32(float64(vs.quality.Height()) / float64(vs.video.Height) * float64(vs.video.Width))
|
||||
// force a width that is a multiple of two else some apps behave badly.
|
||||
width = closestMultiple(width, 2)
|
||||
args = append(args,
|
||||
|
@ -14,11 +14,6 @@ import (
|
||||
"github.com/zoriya/kyoo/transcoder/src"
|
||||
)
|
||||
|
||||
// Encode the version in the hash path to update cached values.
|
||||
// Older versions won't be deleted (needed to allow multiples versions of the transcoder to run at the same time)
|
||||
// If the version changes a lot, we might want to automatically delete older versions.
|
||||
var version = "v3-"
|
||||
|
||||
func GetPath(c echo.Context) (string, string, error) {
|
||||
key := c.Param("path")
|
||||
if key == "" {
|
||||
@ -52,7 +47,7 @@ func getHash(path string) (string, error) {
|
||||
h.Write([]byte(path))
|
||||
h.Write([]byte(info.ModTime().String()))
|
||||
sha := hex.EncodeToString(h.Sum(nil))
|
||||
return version + sha, nil
|
||||
return sha, nil
|
||||
}
|
||||
|
||||
func SanitizePath(path string) error {
|
||||
|
Loading…
x
Reference in New Issue
Block a user