mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-07 10:14:13 -04:00
Update master to the nex transcoder (#176)
This commit is contained in:
commit
5d377654aa
@ -1,5 +1,6 @@
|
||||
# Useful config options
|
||||
LIBRARY_ROOT=/video
|
||||
CACHE_ROOT=/tmp/kyoo_cache
|
||||
LIBRARY_LANGUAGES=en
|
||||
|
||||
# The following two values should be set to a random sequence of characters.
|
||||
|
89
.github/workflows/analysis.yml
vendored
89
.github/workflows/analysis.yml
vendored
@ -1,89 +0,0 @@
|
||||
name: Analysis
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- next
|
||||
pull_request:
|
||||
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Static Analysis
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: Cache SonarCloud packages
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/sonar/cache
|
||||
key: ${{ runner.os }}-sonar
|
||||
restore-keys: ${{ runner.os }}-sonar
|
||||
|
||||
- name: Cache SonarCloud scanner
|
||||
id: cache-sonar-scanner
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.sonar/scanner
|
||||
key: ${{ runner.os }}-sonar-scanner
|
||||
restore-keys: ${{ runner.os }}-sonar-scanner
|
||||
|
||||
- name: Install SonarCloud scanner
|
||||
if: steps.cache-sonar-scanner.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
cd back
|
||||
mkdir -p ~/.sonar/scanner
|
||||
dotnet tool update dotnet-sonarscanner --tool-path ~/.sonar/scanner
|
||||
|
||||
- name: Wait for tests to run (Push)
|
||||
uses: lewagon/wait-on-check-action@master
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
ref: ${{github.ref}}
|
||||
check-name: "Back tests"
|
||||
repo-token: ${{secrets.GITHUB_TOKEN}}
|
||||
running-workflow-name: analysis
|
||||
allowed-conclusions: success,skipped,cancelled,neutral,failure
|
||||
- name: Wait for tests to run (PR)
|
||||
uses: lewagon/wait-on-check-action@master
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
check-name: "Back tests"
|
||||
repo-token: ${{secrets.GITHUB_TOKEN}}
|
||||
running-workflow-name: analysis
|
||||
allowed-conclusions: success,skipped,cancelled,neutral,failure
|
||||
|
||||
- name: Download coverage report
|
||||
uses: dawidd6/action-download-artifact@v2
|
||||
with:
|
||||
commit: ${{env.COMMIT_SHA}}
|
||||
workflow: tests.yml
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
|
||||
- name: Build and analyze
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
cp -r results.xml/ coverage.xml/ back/
|
||||
cd back
|
||||
find . -name 'coverage.opencover.xml'
|
||||
dotnet build-server shutdown
|
||||
|
||||
~/.sonar/scanner/dotnet-sonarscanner begin \
|
||||
-k:"AnonymusRaccoon_Kyoo" \
|
||||
-o:"anonymus-raccoon" \
|
||||
-d:sonar.login="${{ secrets.SONAR_TOKEN }}" \
|
||||
-d:sonar.host.url="https://sonarcloud.io" \
|
||||
-d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" \
|
||||
-d:sonar.cs.vstest.reportsPaths="**/TestOutputResults.xml"
|
||||
|
||||
dotnet build --no-incremental '-p:SkipTranscoder=true'
|
||||
|
||||
~/.sonar/scanner/dotnet-sonarscanner end -d:sonar.login="${{ secrets.SONAR_TOKEN }}"
|
16
.github/workflows/coding-style.yml
vendored
16
.github/workflows/coding-style.yml
vendored
@ -41,6 +41,7 @@ jobs:
|
||||
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
|
||||
scanner:
|
||||
name: "Lint scanner"
|
||||
runs-on: ubuntu-latest
|
||||
@ -54,3 +55,18 @@ jobs:
|
||||
run: |
|
||||
pip install black-with-tabs
|
||||
black . --check
|
||||
|
||||
transcoder:
|
||||
name: "Lint transcoder"
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./transcoder
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Run cargo fmt
|
||||
run: |
|
||||
cargo fmt --check
|
||||
|
3
.github/workflows/docker.yml
vendored
3
.github/workflows/docker.yml
vendored
@ -24,6 +24,9 @@ jobs:
|
||||
- context: ./scanner
|
||||
label: scanner
|
||||
image: zoriya/kyoo_scanner
|
||||
- context: ./transcoder
|
||||
label: transcoder
|
||||
image: zoriya/kyoo_transcoder
|
||||
name: Build ${{matrix.label}}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM gcc:latest as transcoder
|
||||
FROM mcr.microsoft.com/dotnet/sdk:6.0 as transcoder
|
||||
RUN apt-get update && apt-get install -y cmake make libavutil-dev libavcodec-dev libavformat-dev
|
||||
WORKDIR /transcoder
|
||||
COPY src/Kyoo.Transcoder .
|
||||
@ -29,6 +29,6 @@ COPY --from=transcoder /transcoder/libtranscoder.so /app
|
||||
|
||||
WORKDIR /kyoo
|
||||
EXPOSE 5000
|
||||
HEALTHCHECK CMD curl --fail http://localhost:5000/health || exit
|
||||
HEALTHCHECK --interval=5s CMD curl --fail http://localhost:5000/health || exit
|
||||
CMD /app/Kyoo.Host
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
FROM gcc:latest as transcoder
|
||||
RUN apt-get update && apt-get install -y cmake make libavutil-dev libavcodec-dev libavformat-dev
|
||||
FROM mcr.microsoft.com/dotnet/sdk:6.0 as transcoder
|
||||
# Using the dotnet sdk as a base image to have the same versions of glibc/ffmpeg
|
||||
RUN apt-get update && apt-get install -y gcc cmake make libavutil-dev libavcodec-dev libavformat-dev
|
||||
WORKDIR /transcoder
|
||||
COPY src/Kyoo.Transcoder .
|
||||
RUN cmake . && make -j
|
||||
@ -20,11 +21,12 @@ COPY src/Kyoo.Swagger/Kyoo.Swagger.csproj src/Kyoo.Swagger/Kyoo.Swagger.csproj
|
||||
COPY tests/Kyoo.Tests/Kyoo.Tests.csproj tests/Kyoo.Tests/Kyoo.Tests.csproj
|
||||
RUN dotnet restore
|
||||
|
||||
COPY --from=transcoder /transcoder/libtranscoder.so /app/out/bin/Kyoo.Host/Debug/net6.0/libtranscoder.so
|
||||
COPY --from=transcoder /transcoder/libtranscoder.so /lib/libtranscoder.so
|
||||
|
||||
WORKDIR /kyoo
|
||||
EXPOSE 5000
|
||||
ENV DOTNET_USE_POLLING_FILE_WATCHER 1
|
||||
HEALTHCHECK CMD curl --fail http://localhost:5000/health || exit
|
||||
HEALTHCHECK --interval=5s CMD curl --fail http://localhost:5000/health || exit
|
||||
# ENV LD_DEBUG libs
|
||||
CMD dotnet watch run --no-restore --project /app/src/Kyoo.Host
|
||||
|
||||
|
@ -125,7 +125,7 @@ namespace Kyoo.Abstractions.Models
|
||||
/// <summary>
|
||||
/// The path of the video file for this episode. Any format supported by a <see cref="IFileSystem"/> is allowed.
|
||||
/// </summary>
|
||||
[SerializeIgnore] public string Path { get; set; }
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Dictionary<int, string> Images { get; set; }
|
||||
|
@ -141,11 +141,14 @@ namespace Kyoo.Abstractions.Models
|
||||
/// </summary>
|
||||
public ICollection<Chapter> Chapters { get; set; }
|
||||
|
||||
[SerializeIgnore]
|
||||
private string _Type => IsMovie ? "movie" : "episode";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public object Link => new
|
||||
{
|
||||
Direct = $"/video/direct/{Slug}",
|
||||
Transmux = $"/video/transmux/{Slug}/master.m3u8",
|
||||
Direct = $"/video/{_Type}/{Slug}/direct",
|
||||
Hls = $"/video/{_Type}/{Slug}/master.m3u8",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
@ -16,9 +16,9 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AspNetCore.Proxy;
|
||||
using Autofac;
|
||||
using Kyoo.Abstractions;
|
||||
using Kyoo.Abstractions.Controllers;
|
||||
@ -113,6 +113,7 @@ namespace Kyoo.Core
|
||||
x.EnableForHttps = true;
|
||||
});
|
||||
|
||||
services.AddProxies();
|
||||
services.AddHttpClient();
|
||||
}
|
||||
|
||||
|
@ -12,6 +12,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCore.Proxy" Version="4.4.0" />
|
||||
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="5.2.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.8" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
|
48
back/src/Kyoo.Core/Views/Watch/ProxyApi.cs
Normal file
48
back/src/Kyoo.Core/Views/Watch/ProxyApi.cs
Normal file
@ -0,0 +1,48 @@
|
||||
// Kyoo - A portable and vast media library solution.
|
||||
// Copyright (c) Kyoo.
|
||||
//
|
||||
// See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
//
|
||||
// Kyoo is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// any later version.
|
||||
//
|
||||
// Kyoo is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using AspNetCore.Proxy;
|
||||
using Kyoo.Abstractions.Models.Permissions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Kyoo.Core.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// Proxy to other services
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class ProxyApi : Controller
|
||||
{
|
||||
/// <summary>
|
||||
/// Transcoder proxy
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Simply proxy requests to the transcoder
|
||||
/// </remarks>
|
||||
/// <param name="rest">The path of the transcoder.</param>
|
||||
/// <returns>The return value of the transcoder.</returns>
|
||||
[Route("video/{**rest}")]
|
||||
[Permission("video", Kind.Read)]
|
||||
public Task Proxy(string rest)
|
||||
{
|
||||
// TODO: Use an env var to configure transcoder:7666.
|
||||
return this.HttpProxyAsync($"http://transcoder:7666/{rest}");
|
||||
}
|
||||
}
|
||||
}
|
@ -1,146 +0,0 @@
|
||||
// Kyoo - A portable and vast media library solution.
|
||||
// Copyright (c) Kyoo.
|
||||
//
|
||||
// See AUTHORS.md and LICENSE file in the project root for full license information.
|
||||
//
|
||||
// Kyoo is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// any later version.
|
||||
//
|
||||
// Kyoo is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Kyoo.Abstractions.Controllers;
|
||||
using Kyoo.Abstractions.Models;
|
||||
using Kyoo.Abstractions.Models.Attributes;
|
||||
using Kyoo.Abstractions.Models.Permissions;
|
||||
using Kyoo.Abstractions.Models.Utils;
|
||||
using Kyoo.Core.Models.Options;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Options;
|
||||
using static Kyoo.Abstractions.Models.Utils.Constants;
|
||||
|
||||
namespace Kyoo.Core.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// Get the video in a raw format or transcoded in the codec you want.
|
||||
/// </summary>
|
||||
[Route("videos")]
|
||||
[Route("video", Order = AlternativeRoute)]
|
||||
[ApiController]
|
||||
[ApiDefinition("Videos", Group = WatchGroup)]
|
||||
public class VideoApi : Controller
|
||||
{
|
||||
/// <summary>
|
||||
/// The library manager used to modify or retrieve information in the data store.
|
||||
/// </summary>
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// The file system used to send video files.
|
||||
/// </summary>
|
||||
private readonly IFileSystem _files;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new <see cref="VideoApi"/>.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager used to retrieve episodes.</param>
|
||||
/// <param name="files">The file manager used to send video files.</param>
|
||||
public VideoApi(ILibraryManager libraryManager,
|
||||
IFileSystem files)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_files = files;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Disabling the cache prevent an issue on firefox that skip the last 30 seconds of HLS files
|
||||
/// </remarks>
|
||||
public override void OnActionExecuted(ActionExecutedContext ctx)
|
||||
{
|
||||
base.OnActionExecuted(ctx);
|
||||
ctx.HttpContext.Response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
ctx.HttpContext.Response.Headers.Add("Pragma", "no-cache");
|
||||
ctx.HttpContext.Response.Headers.Add("Expires", "0");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direct video
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retrieve the raw video stream, in the same container as the one on the server. No transcoding or
|
||||
/// transmuxing is done.
|
||||
/// </remarks>
|
||||
/// <param name="identifier">The identifier of the episode to retrieve.</param>
|
||||
/// <returns>The raw video stream</returns>
|
||||
/// <response code="404">No episode exists for the given identifier.</response>
|
||||
// TODO enable the following line, this is disabled since the web app can't use bearers. [Permission("video", Kind.Read)]
|
||||
[HttpGet("direct/{identifier:id}")]
|
||||
[HttpGet("{identifier:id}", Order = AlternativeRoute)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Direct(Identifier identifier)
|
||||
{
|
||||
Episode episode = await identifier.Match(
|
||||
id => _libraryManager.GetOrDefault<Episode>(id),
|
||||
slug => _libraryManager.GetOrDefault<Episode>(slug)
|
||||
);
|
||||
return _files.FileResult(episode?.Path, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transmux video
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Change the container of the video to hls but don't re-encode the video or audio. This doesn't require mutch
|
||||
/// resources from the server.
|
||||
/// </remarks>
|
||||
/// <param name="identifier">The identifier of the episode to retrieve.</param>
|
||||
/// <returns>The transmuxed video stream</returns>
|
||||
/// <response code="404">No episode exists for the given identifier.</response>
|
||||
[HttpGet("transmux/{identifier:id}/master.m3u8")]
|
||||
[Permission("video", Kind.Read)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Transmux(Identifier identifier)
|
||||
{
|
||||
Episode episode = await identifier.Match(
|
||||
id => _libraryManager.GetOrDefault<Episode>(id),
|
||||
slug => _libraryManager.GetOrDefault<Episode>(slug)
|
||||
);
|
||||
return _files.Transmux(episode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transmuxed chunk
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retrieve a chunk of a transmuxed video.
|
||||
/// </remarks>
|
||||
/// <param name="episodeLink">The identifier of the episode.</param>
|
||||
/// <param name="chunk">The identifier of the chunk to retrieve.</param>
|
||||
/// <param name="options">The options used to retrieve the path of the segments.</param>
|
||||
/// <returns>A transmuxed video chunk.</returns>
|
||||
[HttpGet("transmux/{episodeLink}/segments/{chunk}", Order = AlternativeRoute)]
|
||||
[Permission("video", Kind.Read)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IActionResult GetTransmuxedChunk(string episodeLink, string chunk,
|
||||
[FromServices] IOptions<BasicOptions> options)
|
||||
{
|
||||
string path = Path.GetFullPath(Path.Combine(options.Value.TransmuxPath, episodeLink));
|
||||
path = Path.Combine(path, "segments", chunk);
|
||||
return PhysicalFile(path, "video/MP2T");
|
||||
}
|
||||
}
|
||||
}
|
@ -69,4 +69,4 @@ int main(int argc, char **argv)
|
||||
%s info video_path - Test info prober\n\
|
||||
%s transmux video_path m3u8_output_file - Test transmuxing\n", argv[0], argv[0]);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
@ -50,6 +50,20 @@ services:
|
||||
volumes:
|
||||
- ${LIBRARY_ROOT}:/video
|
||||
|
||||
transcoder:
|
||||
build:
|
||||
context: ./transcoder
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "7666:7666"
|
||||
restart: on-failure
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ./transcoder:/app
|
||||
- ${LIBRARY_ROOT}:/video
|
||||
- ${CACHE_ROOT}:/cache
|
||||
|
||||
ingress:
|
||||
image: nginx
|
||||
restart: on-failure
|
||||
|
@ -11,7 +11,6 @@ services:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- kyoo:/kyoo
|
||||
- ./cache:/kyoo/cached
|
||||
- ${LIBRARY_ROOT}:/video
|
||||
|
||||
front:
|
||||
@ -34,6 +33,15 @@ services:
|
||||
volumes:
|
||||
- ${LIBRARY_ROOT}:/video
|
||||
|
||||
transcoder:
|
||||
image: zoriya/kyoo_transcoder:edge
|
||||
restart: on-failure
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ${LIBRARY_ROOT}:/video
|
||||
- ${CACHE_ROOT}:/cache
|
||||
|
||||
ingress:
|
||||
image: nginx
|
||||
restart: on-failure
|
||||
|
@ -11,7 +11,6 @@ services:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- kyoo:/kyoo
|
||||
- ./cache:/kyoo/cached
|
||||
- ${LIBRARY_ROOT}:/video
|
||||
|
||||
front:
|
||||
@ -34,6 +33,15 @@ services:
|
||||
volumes:
|
||||
- ${LIBRARY_ROOT}:/video
|
||||
|
||||
transcoder:
|
||||
build: ./transcoder
|
||||
restart: on-failure
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ${LIBRARY_ROOT}:/video
|
||||
- ${CACHE_ROOT}:/cache
|
||||
|
||||
ingress:
|
||||
image: nginx
|
||||
restart: on-failure
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM node:16-alpine AS builder
|
||||
FROM node:16-alpine
|
||||
RUN apk add git bash
|
||||
WORKDIR /app
|
||||
COPY .yarn ./.yarn
|
||||
@ -14,4 +14,4 @@ RUN yarn --immutable
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
EXPOSE 3000
|
||||
EXPOSE 19000
|
||||
CMD ["yarn", "dev"]
|
||||
CMD yarn dev
|
||||
|
@ -78,7 +78,6 @@ export const getTokenWJ = async (cookies?: string): Promise<[string, Token] | [n
|
||||
export const getToken = async (cookies?: string): Promise<string | null> =>
|
||||
(await getTokenWJ(cookies))[0]
|
||||
|
||||
|
||||
export const logout = async () =>{
|
||||
deleteSecureItem("auth")
|
||||
}
|
||||
|
@ -129,7 +129,8 @@ const WatchMovieP = z.preprocess(
|
||||
*/
|
||||
releaseDate: zdate().nullable(),
|
||||
/**
|
||||
* The container of the video file of this episode. Common containers are mp4, mkv, avi and so on.
|
||||
* The container of the video file of this episode. Common containers are mp4, mkv, avi and so
|
||||
* on.
|
||||
*/
|
||||
container: z.string(),
|
||||
/**
|
||||
@ -157,7 +158,7 @@ const WatchMovieP = z.preprocess(
|
||||
*/
|
||||
link: z.object({
|
||||
direct: z.string().transform(imageFn),
|
||||
transmux: z.string().transform(imageFn),
|
||||
hls: z.string().transform(imageFn),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@ -185,7 +186,8 @@ const WatchEpisodeP = WatchMovieP.and(
|
||||
*/
|
||||
episodeNumber: z.number().nullable(),
|
||||
/**
|
||||
* The absolute number of this episode. It's an episode number that is not reset to 1 after a new season.
|
||||
* The absolute number of this episode. It's an episode number that is not reset to 1 after a
|
||||
* new season.
|
||||
*/
|
||||
absoluteNumber: z.number().nullable(),
|
||||
/**
|
||||
|
@ -43,7 +43,7 @@ const Menu = <AsProps,>({
|
||||
...props
|
||||
}: {
|
||||
Trigger: ComponentType<AsProps>;
|
||||
children: ReactNode | ReactNode[] | null;
|
||||
children?: ReactNode | ReactNode[] | null;
|
||||
onMenuOpen?: () => void;
|
||||
onMenuClose?: () => void;
|
||||
} & Omit<AsProps, "onPress">) => {
|
||||
|
@ -33,9 +33,9 @@ import {
|
||||
tooltip,
|
||||
ts,
|
||||
} from "@kyoo/primitives";
|
||||
import { Chapter, Font, Track } from "@kyoo/models";
|
||||
import { Chapter, Font, Track, WatchItem } from "@kyoo/models";
|
||||
import { useAtomValue, useSetAtom, useAtom } from "jotai";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { Platform, View, ViewProps } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { percent, rem, useYoshiki } from "yoshiki/native";
|
||||
import { useRouter } from "solito/router";
|
||||
@ -51,6 +51,7 @@ export const Hover = ({
|
||||
href,
|
||||
poster,
|
||||
chapters,
|
||||
qualities,
|
||||
subtitles,
|
||||
fonts,
|
||||
previousSlug,
|
||||
@ -66,6 +67,7 @@ export const Hover = ({
|
||||
href?: string;
|
||||
poster?: string | null;
|
||||
chapters?: Chapter[];
|
||||
qualities?: WatchItem["link"]
|
||||
subtitles?: Track[];
|
||||
fonts?: Font[];
|
||||
previousSlug?: string | null;
|
||||
@ -85,7 +87,8 @@ export const Hover = ({
|
||||
{...css(
|
||||
[
|
||||
{
|
||||
position: "absolute",
|
||||
// Fixed is used because firefox android make the hover disapear under the navigation bar in absolute
|
||||
position: Platform.OS === "web" ? "fixed" as any : "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
@ -104,6 +107,7 @@ export const Hover = ({
|
||||
marginLeft: { xs: ts(0.5), sm: ts(3) },
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
maxWidth: percent(100),
|
||||
})}
|
||||
>
|
||||
<H2 {...css({ paddingBottom: ts(1) })}>
|
||||
@ -117,6 +121,7 @@ export const Hover = ({
|
||||
<RightButtons
|
||||
subtitles={subtitles}
|
||||
fonts={fonts}
|
||||
qualities={qualities}
|
||||
onMenuOpen={onMenuOpen}
|
||||
onMenuClose={onMenuClose}
|
||||
/>
|
||||
|
@ -18,7 +18,7 @@
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Font, Track } from "@kyoo/models";
|
||||
import { Font, Track, WatchItem } from "@kyoo/models";
|
||||
import { IconButton, tooltip, Menu, ts } from "@kyoo/primitives";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
@ -27,21 +27,23 @@ import { useTranslation } from "react-i18next";
|
||||
import ClosedCaption from "@material-symbols/svg-400/rounded/closed_caption-fill.svg";
|
||||
import Fullscreen from "@material-symbols/svg-400/rounded/fullscreen-fill.svg";
|
||||
import FullscreenExit from "@material-symbols/svg-400/rounded/fullscreen_exit-fill.svg";
|
||||
import SettingsIcon from "@material-symbols/svg-400/rounded/settings-fill.svg";
|
||||
import MusicNote from "@material-symbols/svg-400/rounded/music_note-fill.svg";
|
||||
import { Stylable, useYoshiki } from "yoshiki/native";
|
||||
import { createParam } from "solito";
|
||||
import { fullscreenAtom, subtitleAtom } from "../state";
|
||||
|
||||
const { useParam } = createParam<{ subtitle?: string }>();
|
||||
import { AudiosMenu, QualitiesMenu } from "../video";
|
||||
|
||||
export const RightButtons = ({
|
||||
subtitles,
|
||||
fonts,
|
||||
qualities,
|
||||
onMenuOpen,
|
||||
onMenuClose,
|
||||
...props
|
||||
}: {
|
||||
subtitles?: Track[];
|
||||
fonts?: Font[];
|
||||
qualities?: WatchItem["link"];
|
||||
onMenuOpen: () => void;
|
||||
onMenuClose: () => void;
|
||||
} & Stylable) => {
|
||||
@ -63,7 +65,7 @@ export const RightButtons = ({
|
||||
|
||||
return (
|
||||
<View {...css({ flexDirection: "row" }, props)}>
|
||||
{subtitles && (
|
||||
{subtitles && subtitles.length > 0 && (
|
||||
<Menu
|
||||
Trigger={IconButton}
|
||||
icon={ClosedCaption}
|
||||
@ -87,6 +89,22 @@ export const RightButtons = ({
|
||||
))}
|
||||
</Menu>
|
||||
)}
|
||||
<AudiosMenu
|
||||
Trigger={IconButton}
|
||||
icon={MusicNote}
|
||||
onMenuOpen={onMenuOpen}
|
||||
onMenuClose={onMenuClose}
|
||||
{...tooltip(t("player.audios"), true)}
|
||||
{...spacing}
|
||||
/>
|
||||
<QualitiesMenu
|
||||
Trigger={IconButton}
|
||||
icon={SettingsIcon}
|
||||
onMenuOpen={onMenuOpen}
|
||||
onMenuClose={onMenuClose}
|
||||
{...tooltip(t("player.quality"), true)}
|
||||
{...spacing}
|
||||
/>
|
||||
{Platform.OS === "web" && (
|
||||
<IconButton
|
||||
icon={isFullscreen ? FullscreenExit : Fullscreen}
|
||||
|
@ -21,7 +21,7 @@
|
||||
import { QueryIdentifier, QueryPage, WatchItem, WatchItemP, useFetch } from "@kyoo/models";
|
||||
import { Head } from "@kyoo/primitives";
|
||||
import { useState, useEffect, ComponentProps } from "react";
|
||||
import { Platform, Pressable, StyleSheet } from "react-native";
|
||||
import { Platform, Pressable, StyleSheet, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "solito/router";
|
||||
import { useAtom } from "jotai";
|
||||
@ -42,7 +42,7 @@ const mapData = (
|
||||
data: WatchItem | undefined,
|
||||
previousSlug?: string,
|
||||
nextSlug?: string,
|
||||
): Partial<ComponentProps<typeof Hover>> => {
|
||||
): Partial<ComponentProps<typeof Hover>> & { isLoading: boolean } => {
|
||||
if (!data) return { isLoading: true };
|
||||
return {
|
||||
isLoading: false,
|
||||
@ -50,6 +50,7 @@ const mapData = (
|
||||
showName: data.isMovie ? data.name! : data.showTitle,
|
||||
href: data ? (data.isMovie ? `/movie/${data.slug}` : `/show/${data.showSlug}`) : "#",
|
||||
poster: data.poster,
|
||||
qualities: data.link,
|
||||
subtitles: data.subtitles,
|
||||
chapters: data.chapters,
|
||||
fonts: data.fonts,
|
||||
@ -131,12 +132,12 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
||||
data.isMovie
|
||||
? data.name
|
||||
: data.showTitle +
|
||||
" " +
|
||||
episodeDisplayNumber({
|
||||
seasonNumber: data.seasonNumber,
|
||||
episodeNumber: data.episodeNumber,
|
||||
absoluteNumber: data.absoluteNumber,
|
||||
})
|
||||
" " +
|
||||
episodeDisplayNumber({
|
||||
seasonNumber: data.seasonNumber,
|
||||
episodeNumber: data.episodeNumber,
|
||||
absoluteNumber: data.absoluteNumber,
|
||||
})
|
||||
}
|
||||
description={data.overview}
|
||||
/>
|
||||
@ -147,9 +148,9 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
||||
next={next}
|
||||
previous={previous}
|
||||
/>
|
||||
<Pressable
|
||||
<View
|
||||
focusable={false}
|
||||
onHoverOut={() => setMouseMoved(false)}
|
||||
onPointerLeave={(e) => { if (e.nativeEvent.pointerType === "mouse") setMouseMoved(false) }}
|
||||
{...css({
|
||||
flexGrow: 1,
|
||||
bg: "black",
|
||||
@ -157,11 +158,9 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
||||
cursor: displayControls ? "unset" : "none",
|
||||
})}
|
||||
>
|
||||
<Pressable
|
||||
focusable={false}
|
||||
onPress={(e) => {
|
||||
// TODO: use onPress event to diferenciate touch and click on the web (requires react native web 0.19)
|
||||
if (Platform.OS !== "web") {
|
||||
<View
|
||||
onPointerDown={(e) => {
|
||||
if (e.nativeEvent.pointerType !== "mouse") {
|
||||
displayControls ? setMouseMoved(false) : show();
|
||||
return;
|
||||
}
|
||||
@ -177,13 +176,7 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
||||
}, 400);
|
||||
setPlay(!isPlaying);
|
||||
}}
|
||||
{...css([
|
||||
StyleSheet.absoluteFillObject,
|
||||
{
|
||||
// @ts-ignore Web only
|
||||
cursor: "unset",
|
||||
},
|
||||
])}
|
||||
{...css(StyleSheet.absoluteFillObject)}
|
||||
>
|
||||
<Video
|
||||
links={data?.link}
|
||||
@ -199,14 +192,18 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
||||
}}
|
||||
{...css(StyleSheet.absoluteFillObject)}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
<LoadingIndicator />
|
||||
<Hover
|
||||
{...mapData(data, previous, next)}
|
||||
// @ts-ignore Web only types
|
||||
onMouseEnter={() => setHover(true)}
|
||||
// @ts-ignore Web only types
|
||||
onMouseLeave={() => setHover(false)}
|
||||
onPointerEnter={(e) => { if (e.nativeEvent.pointerType === "mouse") setHover(true) }}
|
||||
onPointerLeave={(e) => { if (e.nativeEvent.pointerType === "mouse") setHover(false) }}
|
||||
onPointerDown={(e) => {
|
||||
// also handle touch here because if we dont, the area where the hover should be will catch touches
|
||||
// without openning the hover.
|
||||
if (e.nativeEvent.pointerType !== "mouse")
|
||||
displayControls ? setMouseMoved(false) : show();
|
||||
}}
|
||||
onMenuOpen={() => setMenuOpen(true)}
|
||||
onMenuClose={() => {
|
||||
// Disable hover since the menu overlay makes the mouseout unreliable.
|
||||
@ -215,7 +212,7 @@ export const Player: QueryPage<{ slug: string }> = ({ slug }) => {
|
||||
}}
|
||||
show={displayControls}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -20,13 +20,20 @@
|
||||
|
||||
import { Track, WatchItem, Font } from "@kyoo/models";
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { memo, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { memo, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import NativeVideo, { VideoProperties as VideoProps } from "./video";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export const playAtom = atom(true);
|
||||
export const loadAtom = atom(false);
|
||||
|
||||
// TODO: Default to auto or pristine depending on the user settings.
|
||||
export enum PlayMode {
|
||||
Direct,
|
||||
Hls,
|
||||
}
|
||||
export const playModeAtom = atom<PlayMode>(PlayMode.Direct);
|
||||
|
||||
export const bufferedAtom = atom(0);
|
||||
export const durationAtom = atom<number | undefined>(undefined);
|
||||
|
||||
@ -56,15 +63,15 @@ export const fullscreenAtom = atom(
|
||||
set(privateFullscreen, false);
|
||||
screen.orientation.unlock();
|
||||
}
|
||||
} catch {}
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
const privateFullscreen = atom(false);
|
||||
|
||||
export const subtitleAtom = atom<Track | null>(null);
|
||||
|
||||
const MemoVideo = memo(NativeVideo);
|
||||
|
||||
export const Video = memo(function _Video({
|
||||
links,
|
||||
setError,
|
||||
@ -78,9 +85,12 @@ export const Video = memo(function _Video({
|
||||
const ref = useRef<NativeVideo | null>(null);
|
||||
const [isPlaying, setPlay] = useAtom(playAtom);
|
||||
const setLoad = useSetAtom(loadAtom);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [mode, setPlayMode] = useAtom(playModeAtom);
|
||||
|
||||
const publicProgress = useAtomValue(publicProgressAtom);
|
||||
const setPrivateProgress = useSetAtom(privateProgressAtom);
|
||||
const setPublicProgress = useSetAtom(publicProgressAtom);
|
||||
const setBuffered = useSetAtom(bufferedAtom);
|
||||
const setDuration = useSetAtom(durationAtom);
|
||||
useEffect(() => {
|
||||
@ -89,10 +99,12 @@ export const Video = memo(function _Video({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Reset the state when a new video is loaded.
|
||||
setSource((mode === PlayMode.Direct ? links?.direct : links?.hls) ?? null);
|
||||
setLoad(true);
|
||||
setPrivateProgress(0);
|
||||
setPublicProgress(0);
|
||||
setPlay(true);
|
||||
}, [links, setLoad, setPrivateProgress, setPlay]);
|
||||
}, [mode, links, setLoad, setPrivateProgress, setPublicProgress, setPlay]);
|
||||
|
||||
const volume = useAtomValue(volumeAtom);
|
||||
const isMuted = useAtomValue(mutedAtom);
|
||||
@ -109,13 +121,12 @@ export const Video = memo(function _Video({
|
||||
|
||||
const subtitle = useAtomValue(subtitleAtom);
|
||||
|
||||
if (!links) return null;
|
||||
if (!source || !links) return null;
|
||||
return (
|
||||
<MemoVideo
|
||||
<NativeVideo
|
||||
ref={ref}
|
||||
{...props}
|
||||
// @ts-ignore Web only
|
||||
source={{ uri: links.direct, transmux: links.transmux }}
|
||||
source={{ uri: source, ...links }}
|
||||
paused={!isPlaying}
|
||||
muted={isMuted}
|
||||
volume={volume}
|
||||
@ -139,6 +150,10 @@ export const Video = memo(function _Video({
|
||||
: { type: "disabled" }
|
||||
}
|
||||
fonts={fonts}
|
||||
onMediaUnsupported={() => {
|
||||
if (mode == PlayMode.Direct)
|
||||
setPlayMode(PlayMode.Hls);
|
||||
}}
|
||||
// TODO: textTracks: external subtitles
|
||||
/>
|
||||
);
|
||||
|
@ -18,7 +18,32 @@
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare module "react-native-video" {
|
||||
interface VideoProperties {
|
||||
fonts?: Font[];
|
||||
onPlayPause: (isPlaying: boolean) => void;
|
||||
onMediaUnsupported?: () => void;
|
||||
}
|
||||
export type VideoProps = Omit<VideoProperties, "source"> & {
|
||||
source: { uri: string; hls: string };
|
||||
};
|
||||
}
|
||||
|
||||
export * from "react-native-video";
|
||||
|
||||
import { Font } from "@kyoo/models";
|
||||
import { IconButton, Menu } from "@kyoo/primitives";
|
||||
import { ComponentProps } from "react";
|
||||
import Video from "react-native-video";
|
||||
export default Video;
|
||||
|
||||
// TODO: Implement those for mobile.
|
||||
|
||||
type CustomMenu = ComponentProps<typeof Menu<ComponentProps<typeof IconButton>>>;
|
||||
export const AudiosMenu = (props: CustomMenu) => {
|
||||
return <Menu {...props}></Menu>;
|
||||
};
|
||||
|
||||
export const QualitiesMenu = (props: CustomMenu) => {
|
||||
return <Menu {...props}></Menu>;
|
||||
};
|
||||
|
@ -18,7 +18,7 @@
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Font, Track } from "@kyoo/models";
|
||||
import { Font, getToken, Track } from "@kyoo/models";
|
||||
import {
|
||||
forwardRef,
|
||||
RefObject,
|
||||
@ -26,32 +26,45 @@ import {
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useReducer,
|
||||
ComponentProps,
|
||||
} from "react";
|
||||
import { VideoProps } from "react-native-video";
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useAtomValue, useSetAtom, useAtom } from "jotai";
|
||||
import { useYoshiki } from "yoshiki";
|
||||
import SubtitleOctopus from "libass-wasm";
|
||||
import { playAtom, subtitleAtom } from "./state";
|
||||
import Hls from "hls.js";
|
||||
import { playAtom, PlayMode, playModeAtom, subtitleAtom } from "./state";
|
||||
import Hls, { Level } from "hls.js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu } from "@kyoo/primitives";
|
||||
|
||||
declare module "react-native-video" {
|
||||
interface VideoProperties {
|
||||
fonts?: Font[];
|
||||
onPlayPause: (isPlaying: boolean) => void;
|
||||
}
|
||||
export type VideoProps = Omit<VideoProperties, "source"> & {
|
||||
source: { uri?: string; transmux?: string };
|
||||
};
|
||||
}
|
||||
|
||||
enum PlayMode {
|
||||
Direct,
|
||||
Transmux,
|
||||
}
|
||||
|
||||
const playModeAtom = atom<PlayMode>(PlayMode.Direct);
|
||||
let hls: Hls | null = null;
|
||||
|
||||
function uuidv4(): string {
|
||||
// @ts-ignore I have no clue how this works, thanks https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid
|
||||
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
|
||||
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16),
|
||||
);
|
||||
}
|
||||
|
||||
let client_id = typeof window === "undefined" ? "ssr" : uuidv4();
|
||||
|
||||
const initHls = async (): Promise<Hls> => {
|
||||
if (hls !== null) return hls;
|
||||
const token = await getToken();
|
||||
hls = new Hls({
|
||||
xhrSetup: (xhr) => {
|
||||
if (token) xhr.setRequestHeader("Authorization", `Bearer: {token}`);
|
||||
xhr.setRequestHeader("X-CLIENT-ID", client_id);
|
||||
},
|
||||
autoStartLoad: false,
|
||||
// debug: true,
|
||||
startPosition: 0,
|
||||
});
|
||||
// hls.currentLevel = hls.startLevel;
|
||||
return hls;
|
||||
};
|
||||
|
||||
const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function _Video(
|
||||
{
|
||||
source,
|
||||
@ -64,11 +77,13 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function
|
||||
onError,
|
||||
onEnd,
|
||||
onPlayPause,
|
||||
onMediaUnsupported,
|
||||
fonts,
|
||||
},
|
||||
forwaredRef,
|
||||
) {
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
const oldHls = useRef<string | null>(null);
|
||||
const { css } = useYoshiki();
|
||||
|
||||
useImperativeHandle(
|
||||
@ -82,8 +97,9 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || paused === ref.current.paused) return;
|
||||
if (paused) ref.current?.pause();
|
||||
else ref.current?.play().catch(() => {});
|
||||
else ref.current?.play().catch(() => { });
|
||||
}, [paused]);
|
||||
useEffect(() => {
|
||||
if (!ref.current || !volume) return;
|
||||
@ -94,28 +110,44 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function
|
||||
const subtitle = useAtomValue(subtitleAtom);
|
||||
useSubtitle(ref, subtitle, fonts);
|
||||
|
||||
const [playMode, setPlayMode] = useAtom(playModeAtom);
|
||||
useEffect(() => {
|
||||
setPlayMode(PlayMode.Direct);
|
||||
}, [source.uri, setPlayMode]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const src = playMode === PlayMode.Direct ? source?.uri : source?.transmux;
|
||||
|
||||
if (!ref?.current || !src) return;
|
||||
if (playMode == PlayMode.Direct || ref.current.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
ref.current.src = src;
|
||||
} else {
|
||||
if (hls === null) hls = new Hls();
|
||||
hls.loadSource(src);
|
||||
hls.attachMedia(ref.current);
|
||||
hls.on(Hls.Events.MANIFEST_LOADED, async () => {
|
||||
try {
|
||||
await ref.current?.play();
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
}, [playMode, source?.uri, source?.transmux]);
|
||||
(async () => {
|
||||
if (!ref?.current || !source.uri) return;
|
||||
if (!hls || oldHls.current !== source.hls) {
|
||||
// Reinit the hls player when we change track.
|
||||
if (hls)
|
||||
hls.destroy();
|
||||
hls = null;
|
||||
hls = await initHls();
|
||||
// Still load the hls source to list available qualities.
|
||||
// Note: This may ask the server to transmux the audio/video by loading the index.m3u8
|
||||
hls.loadSource(source.hls);
|
||||
oldHls.current = source.hls;
|
||||
}
|
||||
if (!source.uri.endsWith(".m3u8")) {
|
||||
hls.detachMedia();
|
||||
ref.current.src = source.uri;
|
||||
} else {
|
||||
hls.attachMedia(ref.current);
|
||||
hls.startLoad(0);
|
||||
hls.on(Hls.Events.MANIFEST_LOADED, async () => {
|
||||
try {
|
||||
await ref.current?.play();
|
||||
} catch { }
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, (_, d) => {
|
||||
console.log("Hls error", d);
|
||||
if (!d.fatal || !hls?.media) return;
|
||||
onError?.call(null, {
|
||||
error: { "": "", errorString: d.reason ?? d.err?.message ?? "Unknown hls error" },
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
// onError changes should not restart the playback.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [source.uri, source.hls]);
|
||||
|
||||
const setPlay = useSetAtom(playAtom);
|
||||
useEffect(() => {
|
||||
@ -136,7 +168,7 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function
|
||||
if (!ref.current) return;
|
||||
onLoad?.call(null, { duration: ref.current.duration } as any);
|
||||
}}
|
||||
onProgress={() => {
|
||||
onTimeUpdate={() => {
|
||||
if (!ref.current) return;
|
||||
onProgress?.call(null, {
|
||||
currentTime: ref.current.currentTime,
|
||||
@ -147,19 +179,17 @@ const Video = forwardRef<{ seek: (value: number) => void }, VideoProps>(function
|
||||
});
|
||||
}}
|
||||
onError={() => {
|
||||
if (
|
||||
ref?.current?.error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED &&
|
||||
playMode !== PlayMode.Transmux
|
||||
)
|
||||
setPlayMode(PlayMode.Transmux);
|
||||
if (ref?.current?.error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED)
|
||||
onMediaUnsupported?.call(undefined);
|
||||
else {
|
||||
onError?.call(null, {
|
||||
error: { "": "", errorString: ref.current?.error?.message ?? "Unknown error" },
|
||||
});
|
||||
}
|
||||
}}
|
||||
onPlay={() => onPlayPause?.call(null, true)}
|
||||
onPause={() => onPlayPause?.call(null, false)}
|
||||
// BUG: If this is enabled, switching to fullscreen or opening a menu make a play/pause loop until firefox crash.
|
||||
// onPlay={() => onPlayPause?.call(null, true)}
|
||||
// onPause={() => onPlayPause?.call(null, false)}
|
||||
onEnded={onEnd}
|
||||
{...css({ width: "100%", height: "100%" })}
|
||||
/>
|
||||
@ -224,3 +254,72 @@ const useSubtitle = (player: RefObject<HTMLVideoElement>, value: Track | null, f
|
||||
}
|
||||
}, [player, value, fonts]);
|
||||
};
|
||||
|
||||
export const AudiosMenu = (props: ComponentProps<typeof Menu>) => {
|
||||
if (!hls || hls.audioTracks.length < 2) return null;
|
||||
return (
|
||||
<Menu {...props}>
|
||||
{hls.audioTracks.map((x, i) => (
|
||||
<Menu.Item
|
||||
key={i.toString()}
|
||||
label={x.name}
|
||||
selected={hls!.audioTrack === i}
|
||||
onSelect={() => (hls!.audioTrack = i)}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const QualitiesMenu = (props: ComponentProps<typeof Menu>) => {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setPlayMode] = useAtom(playModeAtom);
|
||||
const [_, rerender] = useReducer((x) => x + 1, 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hls) return;
|
||||
hls.on(Hls.Events.LEVEL_SWITCHED, rerender);
|
||||
return () => hls!.off(Hls.Events.LEVEL_SWITCHED, rerender);
|
||||
});
|
||||
|
||||
const levelName = (label: Level, auto?: boolean): string => {
|
||||
const height = `${label.height}p`
|
||||
if (auto) return height;
|
||||
return label.uri.includes("original") ? `${t("player.transmux")} (${height})` : height;
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu {...props}>
|
||||
<Menu.Item
|
||||
label={t("player.direct")}
|
||||
selected={hls === null || mode == PlayMode.Direct}
|
||||
onSelect={() => setPlayMode(PlayMode.Direct)}
|
||||
/>
|
||||
<Menu.Item
|
||||
label={
|
||||
hls != null && hls.autoLevelEnabled && hls.currentLevel >= 0
|
||||
? `${t("player.auto")} (${levelName(hls.levels[hls.currentLevel], true)})`
|
||||
: t("player.auto")
|
||||
}
|
||||
selected={hls?.autoLevelEnabled && mode === PlayMode.Hls}
|
||||
onSelect={() => {
|
||||
setPlayMode(PlayMode.Hls);
|
||||
if (hls) hls.currentLevel = -1;
|
||||
}}
|
||||
/>
|
||||
{hls?.levels
|
||||
.map((x, i) => (
|
||||
<Menu.Item
|
||||
key={i.toString()}
|
||||
label={levelName(x)}
|
||||
selected={mode === PlayMode.Hls && hls!.currentLevel === i && !hls?.autoLevelEnabled}
|
||||
onSelect={() => {
|
||||
setPlayMode(PlayMode.Hls);
|
||||
hls!.currentLevel = i;
|
||||
}}
|
||||
/>
|
||||
))
|
||||
.reverse()}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
@ -43,9 +43,14 @@
|
||||
"pause": "Pause",
|
||||
"mute": "Toggle mute",
|
||||
"volume": "Volume",
|
||||
"quality": "Quality",
|
||||
"audios": "Audio",
|
||||
"subtitles": "Subtitles",
|
||||
"subtitle-none": "None",
|
||||
"fullscreen": "Fullscreen"
|
||||
"fullscreen": "Fullscreen",
|
||||
"direct": "Pristine",
|
||||
"transmux": "Original",
|
||||
"auto": "Auto"
|
||||
},
|
||||
"search": {
|
||||
"empty": "No result found. Try a different query."
|
||||
|
@ -43,9 +43,14 @@
|
||||
"pause": "Pause",
|
||||
"mute": "Muet",
|
||||
"volume": "Volume",
|
||||
"quality": "Qualité",
|
||||
"audios": "Audio",
|
||||
"subtitles": "Sous titres",
|
||||
"subtitle-none": "Aucun",
|
||||
"fullscreen": "Plein-écran"
|
||||
"fullscreen": "Plein-écran",
|
||||
"direct": "Pristine",
|
||||
"transmux": "Original",
|
||||
"auto": "Auto"
|
||||
},
|
||||
"search": {
|
||||
"empty": "Aucun résultat trouvé. Essayer avec une autre recherche."
|
||||
|
28
shell.nix
28
shell.nix
@ -13,18 +13,26 @@ in
|
||||
])
|
||||
python3
|
||||
python3Packages.pip
|
||||
cargo
|
||||
cargo-watch
|
||||
rustfmt
|
||||
rustc
|
||||
pkgconfig
|
||||
openssl
|
||||
];
|
||||
|
||||
RUST_SRC_PATH = "${pkgs.rust.packages.stable.rustPlatform.rustLibSrc}";
|
||||
|
||||
shellHook = ''
|
||||
# Install python modules
|
||||
SOURCE_DATE_EPOCH=$(date +%s)
|
||||
if [ ! -d "${venvDir}" ]; then
|
||||
${pkgs.python3}/bin/python3 -m venv ${venvDir}
|
||||
source ${venvDir}/bin/activate
|
||||
export PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
pip install -r ${pythonPkgs} >&2
|
||||
else
|
||||
source ${venvDir}/bin/activate
|
||||
fi
|
||||
# Install python modules
|
||||
SOURCE_DATE_EPOCH=$(date +%s)
|
||||
if [ ! -d "${venvDir}" ]; then
|
||||
${pkgs.python3}/bin/python3 -m venv ${toString ./.}/${venvDir}
|
||||
source ${venvDir}/bin/activate
|
||||
export PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
pip install -r ${pythonPkgs} >&2
|
||||
else
|
||||
source ${venvDir}/bin/activate
|
||||
fi
|
||||
'';
|
||||
}
|
||||
|
1
transcoder/.dockerignore
Normal file
1
transcoder/.dockerignore
Normal file
@ -0,0 +1 @@
|
||||
target/
|
1
transcoder/.gitignore
vendored
Normal file
1
transcoder/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
1706
transcoder/Cargo.lock
generated
Normal file
1706
transcoder/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
transcoder/Cargo.toml
Normal file
15
transcoder/Cargo.toml
Normal file
@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "transcoder"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
actix-web = "4"
|
||||
actix-files = "0.6.2"
|
||||
tokio = { version = "1.27.0", features = ["process"] }
|
||||
serde = { version = "1.0.159", features = ["derive"] }
|
||||
rand = "0.8.5"
|
||||
derive_more = "0.99.17"
|
||||
reqwest = { version = "0.11.16", default_features = false, features = ["json", "rustls-tls"] }
|
||||
utoipa = { version = "3", features = ["actix_extras"] }
|
||||
json = "0.12.4"
|
20
transcoder/Dockerfile
Normal file
20
transcoder/Dockerfile
Normal file
@ -0,0 +1,20 @@
|
||||
FROM rust:alpine as builder
|
||||
RUN apk add --no-cache musl-dev
|
||||
WORKDIR /app
|
||||
|
||||
# FIX: see https://github.com/rust-lang/cargo/issues/2644
|
||||
RUN mkdir src/ && touch src/lib.rs
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
|
||||
RUN cargo build
|
||||
RUN rm src/lib.rs
|
||||
|
||||
COPY src src
|
||||
RUN cargo install --path .
|
||||
|
||||
FROM alpine
|
||||
RUN apk add --no-cache ffmpeg mediainfo musl-dev
|
||||
COPY --from=builder /usr/local/cargo/bin/transcoder ./transcoder
|
||||
|
||||
EXPOSE 7666
|
||||
CMD ./transcoder
|
13
transcoder/Dockerfile.dev
Normal file
13
transcoder/Dockerfile.dev
Normal file
@ -0,0 +1,13 @@
|
||||
FROM rust:alpine
|
||||
RUN apk add --no-cache musl-dev ffmpeg mediainfo
|
||||
RUN cargo install cargo-watch
|
||||
WORKDIR /app
|
||||
|
||||
# FIX: see https://github.com/rust-lang/cargo/issues/2644
|
||||
RUN mkdir src/ && touch src/lib.rs
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
RUN cargo build
|
||||
RUN rm src/lib.rs
|
||||
|
||||
EXPOSE 7666
|
||||
CMD cargo watch -x run
|
1
transcoder/rustfmt.toml
Normal file
1
transcoder/rustfmt.toml
Normal file
@ -0,0 +1 @@
|
||||
hard_tabs = true
|
73
transcoder/src/audio.rs
Normal file
73
transcoder/src/audio.rs
Normal file
@ -0,0 +1,73 @@
|
||||
use crate::{error::ApiError, paths, state::Transcoder};
|
||||
use actix_files::NamedFile;
|
||||
use actix_web::{get, web, Result};
|
||||
|
||||
/// Transcode audio
|
||||
///
|
||||
/// Get the selected audio
|
||||
/// This route can take a few seconds to respond since it will way for at least one segment to be
|
||||
/// available.
|
||||
#[utoipa::path(
|
||||
responses(
|
||||
(status = 200, description = "Get the m3u8 playlist."),
|
||||
(status = NOT_FOUND, description = "Invalid slug.")
|
||||
),
|
||||
params(
|
||||
("resource" = String, Path, description = "Episode or movie"),
|
||||
("slug" = String, Path, description = "The slug of the movie/episode."),
|
||||
("audio" = u32, Path, description = "Specify the audio stream you want. For mappings, refer to the audios fields of the /watch response."),
|
||||
)
|
||||
)]
|
||||
#[get("/{resource}/{slug}/audio/{audio}/index.m3u8")]
|
||||
async fn get_audio_transcoded(
|
||||
query: web::Path<(String, String, u32)>,
|
||||
transcoder: web::Data<Transcoder>,
|
||||
) -> Result<String, ApiError> {
|
||||
let (resource, slug, audio) = query.into_inner();
|
||||
let path = paths::get_path(resource, slug)
|
||||
.await
|
||||
.map_err(|_| ApiError::NotFound)?;
|
||||
|
||||
transcoder.transcode_audio(path, audio).await.map_err(|e| {
|
||||
eprintln!("Error while transcoding audio: {}", e);
|
||||
ApiError::InternalError
|
||||
})
|
||||
}
|
||||
|
||||
/// Get audio chunk
|
||||
///
|
||||
/// Retrieve a chunk of a transcoded audio.
|
||||
#[utoipa::path(
|
||||
responses(
|
||||
(status = 200, description = "Get a hls chunk."),
|
||||
(status = NOT_FOUND, description = "Invalid slug.")
|
||||
),
|
||||
params(
|
||||
("resource" = String, Path, description = "Episode or movie"),
|
||||
("slug" = String, Path, description = "The slug of the movie/episode."),
|
||||
("audio" = u32, Path, description = "Specify the audio you want"),
|
||||
("chunk" = u32, Path, description = "The number of the chunk"),
|
||||
)
|
||||
)]
|
||||
#[get("/{resource}/{slug}/audio/{audio}/segments-{chunk}.ts")]
|
||||
async fn get_audio_chunk(
|
||||
query: web::Path<(String, String, u32, u32)>,
|
||||
transcoder: web::Data<Transcoder>,
|
||||
) -> Result<NamedFile, ApiError> {
|
||||
let (resource, slug, audio, chunk) = query.into_inner();
|
||||
let path = paths::get_path(resource, slug)
|
||||
.await
|
||||
.map_err(|_| ApiError::NotFound)?;
|
||||
|
||||
transcoder
|
||||
.get_audio_segment(path, audio, chunk)
|
||||
.await
|
||||
.map_err(|_| ApiError::BadRequest {
|
||||
error: "No transcode started for the selected show/audio.".to_string(),
|
||||
})
|
||||
.and_then(|path| {
|
||||
NamedFile::open(path).map_err(|_| ApiError::BadRequest {
|
||||
error: "Invalid segment number.".to_string(),
|
||||
})
|
||||
})
|
||||
}
|
36
transcoder/src/error.rs
Normal file
36
transcoder/src/error.rs
Normal file
@ -0,0 +1,36 @@
|
||||
use actix_web::{
|
||||
error,
|
||||
http::{header::ContentType, StatusCode},
|
||||
HttpResponse,
|
||||
};
|
||||
use derive_more::{Display, Error};
|
||||
|
||||
#[derive(Debug, Display, Error)]
|
||||
pub enum ApiError {
|
||||
#[display(fmt = "{}", error)]
|
||||
BadRequest { error: String },
|
||||
#[display(fmt = "Resource not found.")]
|
||||
NotFound,
|
||||
#[display(fmt = "An internal error occurred. Please try again later.")]
|
||||
InternalError,
|
||||
}
|
||||
|
||||
impl error::ResponseError for ApiError {
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
HttpResponse::build(self.status_code())
|
||||
.insert_header(ContentType::json())
|
||||
.body(format!(
|
||||
"{{ \"status\": \"{status}\", \"error\": \"{err}\" }}",
|
||||
status = self.status_code(),
|
||||
err = self.to_string()
|
||||
))
|
||||
}
|
||||
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
ApiError::BadRequest { error: _ } => StatusCode::BAD_REQUEST,
|
||||
ApiError::NotFound => StatusCode::NOT_FOUND,
|
||||
ApiError::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
156
transcoder/src/identify.rs
Normal file
156
transcoder/src/identify.rs
Normal file
@ -0,0 +1,156 @@
|
||||
use json::JsonValue;
|
||||
use serde::Serialize;
|
||||
use std::str::{self, FromStr};
|
||||
use tokio::process::Command;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::transcode::Quality;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct MediaInfo {
|
||||
/// The length of the media in seconds.
|
||||
pub length: f32,
|
||||
pub container: String,
|
||||
pub video: VideoTrack,
|
||||
pub audios: Vec<Track>,
|
||||
pub subtitles: Vec<Track>,
|
||||
pub fonts: Vec<String>,
|
||||
pub chapters: Vec<Chapter>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct VideoTrack {
|
||||
/// The codec of this stream (defined as the RFC 6381).
|
||||
pub codec: String,
|
||||
/// The language of this stream (as a ISO-639-2 language code)
|
||||
pub language: Option<String>,
|
||||
/// The max quality of this video track.
|
||||
pub quality: Quality,
|
||||
/// The width of the video stream
|
||||
pub width: u32,
|
||||
/// The height of the video stream
|
||||
pub height: u32,
|
||||
/// The average bitrate of the video in bytes/s
|
||||
pub bitrate: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct Track {
|
||||
/// The index of this track on the media.
|
||||
pub index: u32,
|
||||
/// The title of the stream.
|
||||
pub title: Option<String>,
|
||||
/// The language of this stream (as a ISO-639-2 language code)
|
||||
pub language: Option<String>,
|
||||
/// The codec of this stream.
|
||||
pub codec: String,
|
||||
/// Is this stream the default one of it's type?
|
||||
pub default: bool,
|
||||
/// Is this stream tagged as forced? (useful only for subtitles)
|
||||
pub forced: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct Chapter {
|
||||
/// The start time of the chapter (in second from the start of the episode).
|
||||
pub start: f32,
|
||||
/// The end time of the chapter (in second from the start of the episode).
|
||||
pub end: f32,
|
||||
/// The name of this chapter. This should be a human-readable name that could be presented to the user.
|
||||
pub name: String, // TODO: add a type field for Opening, Credits...
|
||||
}
|
||||
|
||||
pub async fn identify(path: String) -> Result<MediaInfo, std::io::Error> {
|
||||
let mediainfo = Command::new("mediainfo")
|
||||
.arg("--Output=JSON")
|
||||
.arg("--Language=raw")
|
||||
.arg(path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Error running the mediainfo command");
|
||||
assert!(mediainfo.status.success());
|
||||
let output = json::parse(str::from_utf8(mediainfo.stdout.as_slice()).unwrap()).unwrap();
|
||||
|
||||
let general = output["media"]["track"]
|
||||
.members()
|
||||
.find(|x| x["@type"] == "General")
|
||||
.unwrap();
|
||||
|
||||
fn parse<F: FromStr>(v: &JsonValue) -> Option<F> {
|
||||
v.as_str().and_then(|x| x.parse::<F>().ok())
|
||||
}
|
||||
|
||||
Ok(MediaInfo {
|
||||
length: parse::<f32>(&general["Duration"]).unwrap(),
|
||||
container: general["Format"].as_str().unwrap().to_string(),
|
||||
video: {
|
||||
let v = output["media"]["track"]
|
||||
.members()
|
||||
.find(|x| x["@type"] == "Video")
|
||||
.expect("File without video found. This is not supported");
|
||||
VideoTrack {
|
||||
// This codec is not in the right format (does not include bitdepth...).
|
||||
codec: v["Format"].as_str().unwrap().to_string(),
|
||||
language: v["Language"].as_str().map(|x| x.to_string()),
|
||||
quality: Quality::from_height(parse::<u32>(&v["Height"]).unwrap()),
|
||||
width: parse::<u32>(&v["Width"]).unwrap(),
|
||||
height: parse::<u32>(&v["Height"]).unwrap(),
|
||||
bitrate: parse::<u32>(&v["BitRate"]).unwrap(),
|
||||
}
|
||||
},
|
||||
audios: output["media"]["track"]
|
||||
.members()
|
||||
.filter(|x| x["@type"] == "Audio")
|
||||
.map(|a| Track {
|
||||
index: parse::<u32>(&a["StreamOrder"]).unwrap() - 1,
|
||||
title: a["Title"].as_str().map(|x| x.to_string()),
|
||||
language: a["Language"].as_str().map(|x| x.to_string()),
|
||||
// TODO: format is invalid. Channels count missing...
|
||||
codec: a["Format"].as_str().unwrap().to_string(),
|
||||
default: a["Default"] == "Yes",
|
||||
forced: a["Forced"] == "No",
|
||||
})
|
||||
.collect(),
|
||||
subtitles: output["media"]["track"]
|
||||
.members()
|
||||
.filter(|x| x["@type"] == "Text")
|
||||
.map(|a| Track {
|
||||
index: parse::<u32>(&a["StreamOrder"]).unwrap() - 1,
|
||||
title: a["Title"].as_str().map(|x| x.to_string()),
|
||||
language: a["Language"].as_str().map(|x| x.to_string()),
|
||||
// TODO: format is invalid. Channels count missing...
|
||||
codec: a["Format"].as_str().unwrap().to_string(),
|
||||
default: a["Default"] == "Yes",
|
||||
forced: a["Forced"] == "No",
|
||||
})
|
||||
.collect(),
|
||||
fonts: vec![],
|
||||
chapters: output["media"]["track"]
|
||||
.members()
|
||||
.find(|x| x["@type"] == "Menu")
|
||||
.map(|x| {
|
||||
std::iter::zip(x["extra"].entries(), x["extra"].entries().skip(1))
|
||||
.map(|((start, name), (end, _))| Chapter {
|
||||
start: time_to_seconds(start),
|
||||
end: time_to_seconds(end),
|
||||
name: name.as_str().unwrap().to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or(vec![]),
|
||||
})
|
||||
}
|
||||
|
||||
fn time_to_seconds(time: &str) -> f32 {
|
||||
let splited: Vec<f32> = time
|
||||
.split('_')
|
||||
.skip(1)
|
||||
.map(|x| x.parse().unwrap())
|
||||
.collect();
|
||||
let hours = splited[0];
|
||||
let minutes = splited[1];
|
||||
let seconds = splited[2];
|
||||
let ms = splited[3];
|
||||
|
||||
(hours * 60. + minutes) * 60. + seconds + ms / 1000.
|
||||
}
|
148
transcoder/src/main.rs
Normal file
148
transcoder/src/main.rs
Normal file
@ -0,0 +1,148 @@
|
||||
use actix_files::NamedFile;
|
||||
use actix_web::{
|
||||
get,
|
||||
web::{self, Json},
|
||||
App, HttpServer, Result,
|
||||
};
|
||||
use error::ApiError;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
use crate::{
|
||||
audio::*,
|
||||
identify::{identify, Chapter, MediaInfo, Track},
|
||||
state::Transcoder,
|
||||
video::*,
|
||||
};
|
||||
mod audio;
|
||||
mod error;
|
||||
mod identify;
|
||||
mod paths;
|
||||
mod state;
|
||||
mod transcode;
|
||||
mod utils;
|
||||
mod video;
|
||||
|
||||
/// Direct video
|
||||
///
|
||||
/// Retrieve the raw video stream, in the same container as the one on the server. No transcoding or
|
||||
/// transmuxing is done.
|
||||
#[utoipa::path(
|
||||
responses(
|
||||
(status = 200, description = "The item is returned"),
|
||||
(status = NOT_FOUND, description = "Invalid slug.")
|
||||
),
|
||||
params(
|
||||
("resource" = String, Path, description = "Episode or movie"),
|
||||
("slug" = String, Path, description = "The slug of the movie/episode."),
|
||||
)
|
||||
)]
|
||||
#[get("/{resource}/{slug}/direct")]
|
||||
async fn get_direct(query: web::Path<(String, String)>) -> Result<NamedFile> {
|
||||
let (resource, slug) = query.into_inner();
|
||||
let path = paths::get_path(resource, slug).await.map_err(|e| {
|
||||
eprintln!("Unhandled error occured while getting the path: {}", e);
|
||||
ApiError::NotFound
|
||||
})?;
|
||||
|
||||
Ok(NamedFile::open_async(path).await?)
|
||||
}
|
||||
|
||||
/// Get master playlist
|
||||
///
|
||||
/// Get a master playlist containing all possible video qualities and audios available for this resource.
|
||||
/// Note that the direct stream is missing (since the direct is not an hls stream) and
|
||||
/// subtitles/fonts are not included to support more codecs than just webvtt.
|
||||
#[utoipa::path(
|
||||
responses(
|
||||
(status = 200, description = "Get the m3u8 master playlist."),
|
||||
(status = NOT_FOUND, description = "Invalid slug.")
|
||||
),
|
||||
params(
|
||||
("resource" = String, Path, description = "Episode or movie"),
|
||||
("slug" = String, Path, description = "The slug of the movie/episode."),
|
||||
)
|
||||
)]
|
||||
#[get("/{resource}/{slug}/master.m3u8")]
|
||||
async fn get_master(
|
||||
query: web::Path<(String, String)>,
|
||||
transcoder: web::Data<Transcoder>,
|
||||
) -> Result<String, ApiError> {
|
||||
let (resource, slug) = query.into_inner();
|
||||
transcoder
|
||||
.build_master(resource, slug)
|
||||
.await
|
||||
.ok_or(ApiError::InternalError)
|
||||
}
|
||||
|
||||
/// Identify
|
||||
///
|
||||
/// Identify metadata about a file
|
||||
#[utoipa::path(
|
||||
responses(
|
||||
(status = 200, description = "Ok", body = MediaInfo),
|
||||
(status = NOT_FOUND, description = "Invalid slug.")
|
||||
),
|
||||
params(
|
||||
("resource" = String, Path, description = "Episode or movie"),
|
||||
("slug" = String, Path, description = "The slug of the movie/episode."),
|
||||
)
|
||||
)]
|
||||
#[get("/{resource}/{slug}/info")]
|
||||
async fn identify_resource(
|
||||
query: web::Path<(String, String)>,
|
||||
) -> Result<Json<MediaInfo>, ApiError> {
|
||||
let (resource, slug) = query.into_inner();
|
||||
let path = paths::get_path(resource, slug)
|
||||
.await
|
||||
.map_err(|_| ApiError::NotFound)?;
|
||||
|
||||
identify(path).await.map(|info| Json(info)).map_err(|e| {
|
||||
eprintln!(
|
||||
"Unhandled error occured while identifing the resource: {}",
|
||||
e
|
||||
);
|
||||
ApiError::InternalError
|
||||
})
|
||||
}
|
||||
|
||||
#[get("/openapi.json")]
|
||||
async fn get_swagger() -> String {
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(description = "Transcoder's open api."),
|
||||
paths(
|
||||
get_direct,
|
||||
get_master,
|
||||
get_transcoded,
|
||||
get_chunk,
|
||||
get_audio_transcoded,
|
||||
get_audio_chunk,
|
||||
identify_resource
|
||||
),
|
||||
components(schemas(MediaInfo, Track, Chapter))
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
||||
ApiDoc::openapi().to_pretty_json().unwrap()
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
let state = web::Data::new(Transcoder::new());
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(state.clone())
|
||||
.service(get_direct)
|
||||
.service(get_master)
|
||||
.service(get_transcoded)
|
||||
.service(get_chunk)
|
||||
.service(get_audio_transcoded)
|
||||
.service(get_audio_chunk)
|
||||
.service(identify_resource)
|
||||
.service(get_swagger)
|
||||
})
|
||||
.bind(("0.0.0.0", 7666))?
|
||||
.run()
|
||||
.await
|
||||
}
|
29
transcoder/src/paths.rs
Normal file
29
transcoder/src/paths.rs
Normal file
@ -0,0 +1,29 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Item {
|
||||
path: String,
|
||||
}
|
||||
|
||||
pub async fn get_path(_resource: String, slug: String) -> Result<String, reqwest::Error> {
|
||||
let api_url = std::env::var("API_URL").unwrap_or("http://back:5000".to_string());
|
||||
let api_key = std::env::var("KYOO_APIKEYS")
|
||||
.expect("Missing api keys.")
|
||||
.split(',')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
// TODO: Store the client somewhere gobal
|
||||
let client = reqwest::Client::new();
|
||||
// TODO: The api create dummy episodes for movies right now so we hard code the /episode/
|
||||
client
|
||||
.get(format!("{api_url}/episode/{slug}"))
|
||||
.header("X-API-KEY", api_key)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<Item>()
|
||||
.await
|
||||
.map(|x| x.path)
|
||||
}
|
176
transcoder/src/state.rs
Normal file
176
transcoder/src/state.rs
Normal file
@ -0,0 +1,176 @@
|
||||
use crate::identify::identify;
|
||||
use crate::paths::get_path;
|
||||
use crate::transcode::*;
|
||||
use crate::utils::Signalable;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::RwLock;
|
||||
|
||||
pub struct Transcoder {
|
||||
running: RwLock<HashMap<String, TranscodeInfo>>,
|
||||
audio_jobs: RwLock<Vec<(String, u32)>>,
|
||||
}
|
||||
|
||||
impl Transcoder {
|
||||
pub fn new() -> Transcoder {
|
||||
Self {
|
||||
running: RwLock::new(HashMap::new()),
|
||||
audio_jobs: RwLock::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn build_master(&self, resource: String, slug: String) -> Option<String> {
|
||||
let mut master = String::from("#EXTM3U\n");
|
||||
let path = get_path(resource, slug).await.ok()?;
|
||||
let info = identify(path).await.ok()?;
|
||||
|
||||
// TODO: Only add this if transmuxing is possible.
|
||||
if true {
|
||||
// Doc: https://developer.apple.com/documentation/http_live_streaming/example_playlists_for_http_live_streaming/creating_a_multivariant_playlist
|
||||
master.push_str("#EXT-X-STREAM-INF:");
|
||||
master.push_str(format!("AVERAGE-BANDWIDTH={},", info.video.bitrate).as_str());
|
||||
// Approximate a bit more because we can't know the maximum bandwidth.
|
||||
master.push_str(
|
||||
format!("BANDWIDTH={},", (info.video.bitrate as f32 * 1.2) as u32).as_str(),
|
||||
);
|
||||
master.push_str(
|
||||
format!("RESOLUTION={}x{},", info.video.width, info.video.height).as_str(),
|
||||
);
|
||||
// TODO: Find codecs in the RFC 6381 format.
|
||||
// master.push_str("CODECS=\"avc1.640028\",");
|
||||
// TODO: With multiple audio qualities, maybe switch qualities depending on the video quality.
|
||||
master.push_str("AUDIO=\"audio\"\n");
|
||||
master.push_str(format!("./{}/index.m3u8\n", Quality::Original).as_str());
|
||||
}
|
||||
|
||||
let aspect_ratio = info.video.width as f32 / info.video.height as f32;
|
||||
// Do not include a quality with the same height as the original (simpler for automatic
|
||||
// selection on the client side.)
|
||||
for quality in Quality::iter().filter(|x| x.height() < info.video.quality.height()) {
|
||||
// Doc: https://developer.apple.com/documentation/http_live_streaming/example_playlists_for_http_live_streaming/creating_a_multivariant_playlist
|
||||
master.push_str("#EXT-X-STREAM-INF:");
|
||||
master.push_str(format!("AVERAGE-BANDWIDTH={},", quality.average_bitrate()).as_str());
|
||||
master.push_str(format!("BANDWIDTH={},", quality.max_bitrate()).as_str());
|
||||
master.push_str(
|
||||
format!(
|
||||
"RESOLUTION={}x{},",
|
||||
(aspect_ratio * quality.height() as f32).round() as u32,
|
||||
quality.height()
|
||||
)
|
||||
.as_str(),
|
||||
);
|
||||
master.push_str("CODECS=\"avc1.640028\",");
|
||||
// TODO: With multiple audio qualities, maybe switch qualities depending on the video quality.
|
||||
master.push_str("AUDIO=\"audio\"\n");
|
||||
master.push_str(format!("./{}/index.m3u8\n", quality).as_str());
|
||||
}
|
||||
for audio in info.audios {
|
||||
// Doc: https://developer.apple.com/documentation/http_live_streaming/example_playlists_for_http_live_streaming/adding_alternate_media_to_a_playlist
|
||||
master.push_str("#EXT-X-MEDIA:TYPE=AUDIO,");
|
||||
// The group-id allows to distinguish multiple qualities from multiple variants.
|
||||
// We could create another quality set and use group-ids hiqual and lowqual.
|
||||
master.push_str("GROUP-ID=\"audio\",");
|
||||
if let Some(language) = audio.language {
|
||||
master.push_str(format!("LANGUAGE=\"{}\",", language).as_str());
|
||||
}
|
||||
if let Some(title) = audio.title {
|
||||
master.push_str(format!("NAME=\"{}\",", title).as_str());
|
||||
}
|
||||
// TODO: Support aac5.1 (and specify the number of channel bellow)
|
||||
// master.push_str(format!("CHANNELS=\"{}\",", 2).as_str());
|
||||
master.push_str("DEFAULT=YES,");
|
||||
master.push_str(format!("URI=\"./audio/{}/index.m3u8\"\n", audio.index).as_str());
|
||||
}
|
||||
|
||||
Some(master)
|
||||
}
|
||||
|
||||
pub async fn transcode(
|
||||
&self,
|
||||
client_id: String,
|
||||
path: String,
|
||||
quality: Quality,
|
||||
start_time: u32,
|
||||
) -> Result<String, std::io::Error> {
|
||||
// TODO: If the stream is not yet up to start_time (and is far), kill it and restart one at the right time.
|
||||
// TODO: Clear cache at startup/every X time without use.
|
||||
// TODO: cache transcoded output for a show/quality and reuse it for every future requests.
|
||||
if let Some(TranscodeInfo {
|
||||
show: (old_path, old_qual),
|
||||
job,
|
||||
uuid,
|
||||
..
|
||||
}) = self.running.write().unwrap().get_mut(&client_id)
|
||||
{
|
||||
if path != *old_path || quality != *old_qual {
|
||||
// If the job has already ended, interrupt returns an error but we don't care.
|
||||
_ = job.interrupt();
|
||||
} else {
|
||||
let mut path = get_cache_path_from_uuid(uuid);
|
||||
path.push("stream.m3u8");
|
||||
return std::fs::read_to_string(path);
|
||||
}
|
||||
}
|
||||
|
||||
let info = transcode_video(path, quality, start_time).await;
|
||||
let mut path = get_cache_path(&info);
|
||||
path.push("stream.m3u8");
|
||||
self.running.write().unwrap().insert(client_id, info);
|
||||
std::fs::read_to_string(path)
|
||||
}
|
||||
|
||||
// TODO: Use path/quality instead of client_id
|
||||
pub async fn get_segment(
|
||||
&self,
|
||||
client_id: String,
|
||||
_path: String,
|
||||
_quality: Quality,
|
||||
chunk: u32,
|
||||
) -> Result<PathBuf, SegmentError> {
|
||||
let hashmap = self.running.read().unwrap();
|
||||
let info = hashmap.get(&client_id).ok_or(SegmentError::NoTranscode)?;
|
||||
|
||||
// If the segment is in the playlist file, it is available so we don't need to check that.
|
||||
let mut path = get_cache_path(&info);
|
||||
path.push(format!("segments-{0:02}.ts", chunk));
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub async fn transcode_audio(
|
||||
&self,
|
||||
path: String,
|
||||
audio: u32,
|
||||
) -> Result<String, std::io::Error> {
|
||||
let mut stream = PathBuf::from(get_audio_path(&path, audio));
|
||||
stream.push("stream.m3u8");
|
||||
|
||||
if !self
|
||||
.audio_jobs
|
||||
.read()
|
||||
.unwrap()
|
||||
.contains(&(path.clone(), audio))
|
||||
{
|
||||
// TODO: If two concurrent requests for the same audio came, the first one will
|
||||
// initialize the transcode and wait for the second segment while the second will use
|
||||
// the same transcode but not wait and retrieve a potentially invalid playlist file.
|
||||
self.audio_jobs.write().unwrap().push((path.clone(), audio));
|
||||
transcode_audio(path, audio).await;
|
||||
}
|
||||
std::fs::read_to_string(stream)
|
||||
}
|
||||
|
||||
pub async fn get_audio_segment(
|
||||
&self,
|
||||
path: String,
|
||||
audio: u32,
|
||||
chunk: u32,
|
||||
) -> Result<PathBuf, std::io::Error> {
|
||||
let mut path = PathBuf::from(get_audio_path(&path, audio));
|
||||
path.push(format!("segments-{0:02}.ts", chunk));
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum SegmentError {
|
||||
NoTranscode,
|
||||
}
|
294
transcoder/src/transcode.rs
Normal file
294
transcoder/src/transcode.rs
Normal file
@ -0,0 +1,294 @@
|
||||
use derive_more::Display;
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::{thread_rng, Rng};
|
||||
use serde::Serialize;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::slice::Iter;
|
||||
use std::str::FromStr;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::watch;
|
||||
|
||||
const SEGMENT_TIME: u32 = 10;
|
||||
|
||||
#[derive(PartialEq, Eq, Serialize, Display, Clone, Copy)]
|
||||
pub enum Quality {
|
||||
#[display(fmt = "240p")]
|
||||
P240,
|
||||
#[display(fmt = "360p")]
|
||||
P360,
|
||||
#[display(fmt = "480p")]
|
||||
P480,
|
||||
#[display(fmt = "720p")]
|
||||
P720,
|
||||
#[display(fmt = "1080p")]
|
||||
P1080,
|
||||
#[display(fmt = "1440p")]
|
||||
P1440,
|
||||
#[display(fmt = "4k")]
|
||||
P4k,
|
||||
#[display(fmt = "8k")]
|
||||
P8k,
|
||||
#[display(fmt = "original")]
|
||||
Original,
|
||||
}
|
||||
|
||||
impl Quality {
|
||||
pub fn iter() -> Iter<'static, Quality> {
|
||||
static QUALITIES: [Quality; 8] = [
|
||||
Quality::P240,
|
||||
Quality::P360,
|
||||
Quality::P480,
|
||||
Quality::P720,
|
||||
Quality::P1080,
|
||||
Quality::P1440,
|
||||
Quality::P4k,
|
||||
Quality::P8k,
|
||||
// Purposfully removing Original from this list (since it require special treatments
|
||||
// anyways)
|
||||
];
|
||||
QUALITIES.iter()
|
||||
}
|
||||
|
||||
pub fn height(&self) -> u32 {
|
||||
match self {
|
||||
Self::P240 => 240,
|
||||
Self::P360 => 360,
|
||||
Self::P480 => 480,
|
||||
Self::P720 => 720,
|
||||
Self::P1080 => 1080,
|
||||
Self::P1440 => 1440,
|
||||
Self::P4k => 2160,
|
||||
Self::P8k => 4320,
|
||||
Self::Original => panic!("Original quality must be handled specially"),
|
||||
}
|
||||
}
|
||||
|
||||
// I'm not entierly sure about the values for bitrates. Double checking would be nice.
|
||||
pub fn average_bitrate(&self) -> u32 {
|
||||
match self {
|
||||
Self::P240 => 400_000,
|
||||
Self::P360 => 800_000,
|
||||
Self::P480 => 1200_000,
|
||||
Self::P720 => 2400_000,
|
||||
Self::P1080 => 4800_000,
|
||||
Self::P1440 => 9600_000,
|
||||
Self::P4k => 16_000_000,
|
||||
Self::P8k => 28_000_000,
|
||||
Self::Original => panic!("Original quality must be handled specially"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_bitrate(&self) -> u32 {
|
||||
match self {
|
||||
Self::P240 => 700_000,
|
||||
Self::P360 => 1400_000,
|
||||
Self::P480 => 2100_000,
|
||||
Self::P720 => 4000_000,
|
||||
Self::P1080 => 8000_000,
|
||||
Self::P1440 => 12_000_000,
|
||||
Self::P4k => 28_000_000,
|
||||
Self::P8k => 40_000_000,
|
||||
Self::Original => panic!("Original quality must be handled specially"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_height(height: u32) -> Self {
|
||||
Self::iter()
|
||||
.find(|x| x.height() >= height)
|
||||
.unwrap_or(&Quality::P240)
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct InvalidValueError;
|
||||
|
||||
impl FromStr for Quality {
|
||||
type Err = InvalidValueError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"240p" => Ok(Quality::P240),
|
||||
"360p" => Ok(Quality::P360),
|
||||
"480p" => Ok(Quality::P480),
|
||||
"720p" => Ok(Quality::P720),
|
||||
"1080p" => Ok(Quality::P1080),
|
||||
"1440p" => Ok(Quality::P1440),
|
||||
"4k" => Ok(Quality::P4k),
|
||||
"8k" => Ok(Quality::P8k),
|
||||
"original" => Ok(Quality::Original),
|
||||
_ => Err(InvalidValueError),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_transcode_audio_args(audio_idx: u32) -> Vec<String> {
|
||||
// TODO: Support multi audio qualities.
|
||||
return vec![
|
||||
"-map".to_string(),
|
||||
format!("0:a:{}", audio_idx),
|
||||
"-c:a".to_string(),
|
||||
"aac".to_string(),
|
||||
// TODO: Support 5.1 audio streams.
|
||||
"-ac".to_string(),
|
||||
"2".to_string(),
|
||||
"-b:a".to_string(),
|
||||
"128k".to_string(),
|
||||
];
|
||||
}
|
||||
|
||||
fn get_transcode_video_quality_args(quality: &Quality, segment_time: u32) -> Vec<String> {
|
||||
if *quality == Quality::Original {
|
||||
return vec!["-map", "0:V:0", "-c:v", "copy"]
|
||||
.iter()
|
||||
.map(|a| a.to_string())
|
||||
.collect();
|
||||
}
|
||||
vec![
|
||||
// superfast or ultrafast would produce a file extremly big so we prever veryfast.
|
||||
vec![
|
||||
"-map", "0:v:0", "-c:v", "libx264", "-crf", "21", "-preset", "veryfast",
|
||||
],
|
||||
vec![
|
||||
"-vf",
|
||||
format!("scale=-2:'min({height},ih)'", height = quality.height()).as_str(),
|
||||
],
|
||||
// Even less sure but bufsize are 5x the avergae bitrate since the average bitrate is only
|
||||
// useful for hls segments.
|
||||
vec!["-bufsize", (quality.max_bitrate() * 5).to_string().as_str()],
|
||||
vec!["-b:v", quality.average_bitrate().to_string().as_str()],
|
||||
vec!["-maxrate", quality.max_bitrate().to_string().as_str()],
|
||||
// Force segments to be exactly segment_time (only works when transcoding)
|
||||
vec![
|
||||
"-force_key_frames",
|
||||
format!("expr:gte(t,n_forced*{segment_time})").as_str(),
|
||||
"-strict",
|
||||
"-2",
|
||||
"-segment_time_delta",
|
||||
"0.1",
|
||||
],
|
||||
]
|
||||
.concat()
|
||||
.iter()
|
||||
.map(|arg| arg.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn transcode_audio(path: String, audio: u32) {
|
||||
start_transcode(
|
||||
&path,
|
||||
&get_audio_path(&path, audio),
|
||||
get_transcode_audio_args(audio),
|
||||
0,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn transcode_video(path: String, quality: Quality, start_time: u32) -> TranscodeInfo {
|
||||
// TODO: Use the out path below once cached segments can be reused.
|
||||
// let out_dir = format!("/cache/{show_hash}/{quality}");
|
||||
let uuid: String = thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(30)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
let out_dir = format!("/cache/{uuid}");
|
||||
|
||||
let child = start_transcode(
|
||||
&path,
|
||||
&out_dir,
|
||||
get_transcode_video_quality_args(&quality, SEGMENT_TIME),
|
||||
start_time,
|
||||
)
|
||||
.await;
|
||||
TranscodeInfo {
|
||||
show: (path, quality),
|
||||
job: child,
|
||||
uuid,
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_transcode(
|
||||
path: &String,
|
||||
out_dir: &String,
|
||||
encode_args: Vec<String>,
|
||||
start_time: u32,
|
||||
) -> Child {
|
||||
std::fs::create_dir_all(&out_dir).expect("Could not create cache directory");
|
||||
|
||||
let mut cmd = Command::new("ffmpeg");
|
||||
cmd.args(&["-progress", "pipe:1"])
|
||||
.args(&["-nostats", "-hide_banner", "-loglevel", "warning"])
|
||||
.args(&["-ss", start_time.to_string().as_str()])
|
||||
.args(&["-i", path.as_str()])
|
||||
.args(&["-f", "hls"])
|
||||
// Use a .tmp file for segments (.ts files)
|
||||
.args(&["-hls_flags", "temp_file"])
|
||||
// Cache can't be allowed since switching quality means starting a new encode for now.
|
||||
.args(&["-hls_allow_cache", "1"])
|
||||
// Keep all segments in the list (else only last X are presents, useful for livestreams)
|
||||
.args(&["-hls_list_size", "0"])
|
||||
.args(&["-hls_time", SEGMENT_TIME.to_string().as_str()])
|
||||
.args(&encode_args)
|
||||
.args(&[
|
||||
"-hls_segment_filename".to_string(),
|
||||
format!("{out_dir}/segments-%02d.ts"),
|
||||
format!("{out_dir}/stream.m3u8"),
|
||||
])
|
||||
.stdout(Stdio::piped());
|
||||
println!("Starting a transcode with the command: {:?}", cmd);
|
||||
let mut child = cmd.spawn().expect("ffmpeg failed to start");
|
||||
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let (tx, mut rx) = watch::channel(0u32);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stdout).lines();
|
||||
while let Some(line) = reader.next_line().await.unwrap() {
|
||||
if let Some((key, value)) = line.find('=').map(|i| line.split_at(i)) {
|
||||
let value = &value[1..];
|
||||
// Can't use ms since ms and us are both set to us /shrug
|
||||
if key == "out_time_us" {
|
||||
// Sometimes, the value is invalid (or negative), default to 0 in those cases
|
||||
let _ = tx.send(value.parse::<u32>().unwrap_or(0) / 1_000_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for 1.5 * segment time after start_time to be ready.
|
||||
loop {
|
||||
// TODO: Create a better error handling for here.
|
||||
rx.changed().await.expect("Invalid audio index.");
|
||||
let ready_time = *rx.borrow();
|
||||
if ready_time >= (1.5 * SEGMENT_TIME as f32) as u32 + start_time {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_audio_path(path: &String, audio: u32) -> String {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
path.hash(&mut hasher);
|
||||
audio.hash(&mut hasher);
|
||||
let hash = hasher.finish();
|
||||
format!("/cache/{hash:x}")
|
||||
}
|
||||
|
||||
pub fn get_cache_path(info: &TranscodeInfo) -> PathBuf {
|
||||
return get_cache_path_from_uuid(&info.uuid);
|
||||
}
|
||||
|
||||
pub fn get_cache_path_from_uuid(uuid: &String) -> PathBuf {
|
||||
return PathBuf::from(format!("/cache/{uuid}/", uuid = &uuid));
|
||||
}
|
||||
|
||||
pub struct TranscodeInfo {
|
||||
pub show: (String, Quality),
|
||||
pub job: Child,
|
||||
pub uuid: String,
|
||||
}
|
50
transcoder/src/utils.rs
Normal file
50
transcoder/src/utils.rs
Normal file
@ -0,0 +1,50 @@
|
||||
use actix_web::HttpRequest;
|
||||
use tokio::{io, process::Child};
|
||||
|
||||
use crate::error::ApiError;
|
||||
|
||||
extern "C" {
|
||||
fn kill(pid: i32, sig: i32) -> i32;
|
||||
}
|
||||
|
||||
/// Signal the process `pid`
|
||||
fn signal(pid: i32, signal: i32) -> io::Result<()> {
|
||||
let ret = unsafe { kill(pid, signal) };
|
||||
if ret == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::last_os_error())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Signalable {
|
||||
/// Signal the thing
|
||||
fn signal(&mut self, signal: i32) -> io::Result<()>;
|
||||
|
||||
/// Send SIGINT
|
||||
fn interrupt(&mut self) -> io::Result<()> {
|
||||
self.signal(2)
|
||||
}
|
||||
}
|
||||
|
||||
impl Signalable for Child {
|
||||
fn signal(&mut self, signal: i32) -> io::Result<()> {
|
||||
let id = self.id();
|
||||
|
||||
if self.try_wait()?.is_some() || id.is_none() {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"invalid argument: can't signal an exited process",
|
||||
))
|
||||
} else {
|
||||
crate::utils::signal(id.unwrap() as i32, signal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_client_id(req: HttpRequest) -> Result<String, ApiError> {
|
||||
// return Ok(String::from("1234"));
|
||||
req.headers().get("x-client-id")
|
||||
.ok_or(ApiError::BadRequest { error: String::from("Missing client id. Please specify the X-CLIENT-ID header to a guid constant for the lifetime of the player (but unique per instance)."), })
|
||||
.map(|x| x.to_str().unwrap().to_string())
|
||||
}
|
92
transcoder/src/video.rs
Normal file
92
transcoder/src/video.rs
Normal file
@ -0,0 +1,92 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::{error::ApiError, paths, state::Transcoder, transcode::Quality, utils::get_client_id};
|
||||
use actix_files::NamedFile;
|
||||
use actix_web::{get, web, HttpRequest, Result};
|
||||
|
||||
/// Transcode video
|
||||
///
|
||||
/// Transcode the video to the selected quality.
|
||||
/// This route can take a few seconds to respond since it will way for at least one segment to be
|
||||
/// available.
|
||||
#[utoipa::path(
|
||||
responses(
|
||||
(status = 200, description = "Get the m3u8 playlist."),
|
||||
(status = NOT_FOUND, description = "Invalid slug.")
|
||||
),
|
||||
params(
|
||||
("resource" = String, Path, description = "Episode or movie"),
|
||||
("slug" = String, Path, description = "The slug of the movie/episode."),
|
||||
("quality" = Quality, Path, description = "Specify the quality you want"),
|
||||
("x-client-id" = String, Header, description = "A unique identify for a player's instance. Used to cancel unused transcode"),
|
||||
)
|
||||
)]
|
||||
#[get("/{resource}/{slug}/{quality}/index.m3u8")]
|
||||
async fn get_transcoded(
|
||||
req: HttpRequest,
|
||||
query: web::Path<(String, String, String)>,
|
||||
transcoder: web::Data<Transcoder>,
|
||||
) -> Result<String, ApiError> {
|
||||
let (resource, slug, quality) = query.into_inner();
|
||||
let quality = Quality::from_str(quality.as_str()).map_err(|_| ApiError::BadRequest {
|
||||
error: "Invalid quality".to_string(),
|
||||
})?;
|
||||
let client_id = get_client_id(req)?;
|
||||
|
||||
let path = paths::get_path(resource, slug)
|
||||
.await
|
||||
.map_err(|_| ApiError::NotFound)?;
|
||||
// TODO: Handle start_time that is not 0
|
||||
transcoder
|
||||
.transcode(client_id, path, quality, 0)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
eprintln!("Unhandled error occured while transcoding: {}", e);
|
||||
ApiError::InternalError
|
||||
})
|
||||
}
|
||||
|
||||
/// Get transmuxed chunk
|
||||
///
|
||||
/// Retrieve a chunk of a transmuxed video.
|
||||
#[utoipa::path(
|
||||
responses(
|
||||
(status = 200, description = "Get a hls chunk."),
|
||||
(status = NOT_FOUND, description = "Invalid slug.")
|
||||
),
|
||||
params(
|
||||
("resource" = String, Path, description = "Episode or movie"),
|
||||
("slug" = String, Path, description = "The slug of the movie/episode."),
|
||||
("quality" = Quality, Path, description = "Specify the quality you want"),
|
||||
("chunk" = u32, Path, description = "The number of the chunk"),
|
||||
("x-client-id" = String, Header, description = "A unique identify for a player's instance. Used to cancel unused transcode"),
|
||||
)
|
||||
)]
|
||||
#[get("/{resource}/{slug}/{quality}/segments-{chunk}.ts")]
|
||||
async fn get_chunk(
|
||||
req: HttpRequest,
|
||||
query: web::Path<(String, String, String, u32)>,
|
||||
transcoder: web::Data<Transcoder>,
|
||||
) -> Result<NamedFile, ApiError> {
|
||||
let (resource, slug, quality, chunk) = query.into_inner();
|
||||
let quality = Quality::from_str(quality.as_str()).map_err(|_| ApiError::BadRequest {
|
||||
error: "Invalid quality".to_string(),
|
||||
})?;
|
||||
let client_id = get_client_id(req)?;
|
||||
|
||||
let path = paths::get_path(resource, slug)
|
||||
.await
|
||||
.map_err(|_| ApiError::NotFound)?;
|
||||
// TODO: Handle start_time that is not 0
|
||||
transcoder
|
||||
.get_segment(client_id, path, quality, chunk)
|
||||
.await
|
||||
.map_err(|_| ApiError::BadRequest {
|
||||
error: "No transcode started for the selected show/quality.".to_string(),
|
||||
})
|
||||
.and_then(|path| {
|
||||
NamedFile::open(path).map_err(|_| ApiError::BadRequest {
|
||||
error: "Invalid segment number.".to_string(),
|
||||
})
|
||||
})
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user