Update master to the nex transcoder (#176)

This commit is contained in:
Zoe Roux 2023-06-06 03:01:43 +09:00 committed by GitHub
commit 5d377654aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 3220 additions and 361 deletions

View File

@ -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.

View File

@ -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 }}"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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; }

View File

@ -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>

View File

@ -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();
}

View File

@ -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" />

View 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}");
}
}
}

View File

@ -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");
}
}
}

View File

@ -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;
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")
}

View File

@ -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(),
/**

View File

@ -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">) => {

View File

@ -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}
/>

View File

@ -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}

View File

@ -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>
</>
);
};

View File

@ -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
/>
);

View File

@ -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>;
};

View File

@ -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>
);
};

View File

@ -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."

View File

@ -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."

View File

@ -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
View File

@ -0,0 +1 @@
target/

1
transcoder/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1706
transcoder/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

15
transcoder/Cargo.toml Normal file
View 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
View 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
View 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
View File

@ -0,0 +1 @@
hard_tabs = true

73
transcoder/src/audio.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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(),
})
})
}