diff --git a/.env.example b/.env.example index 123a0388..7dc4f780 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml deleted file mode 100644 index 0b30aa3c..00000000 --- a/.github/workflows/analysis.yml +++ /dev/null @@ -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 }}" diff --git a/.github/workflows/coding-style.yml b/.github/workflows/coding-style.yml index e27da2f0..cc6bc419 100644 --- a/.github/workflows/coding-style.yml +++ b/.github/workflows/coding-style.yml @@ -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 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3da3af3b..32cbe682 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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 diff --git a/back/Dockerfile b/back/Dockerfile index 1a3818d2..c4c5935a 100644 --- a/back/Dockerfile +++ b/back/Dockerfile @@ -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 diff --git a/back/Dockerfile.dev b/back/Dockerfile.dev index 373dfa5b..34882c9f 100644 --- a/back/Dockerfile.dev +++ b/back/Dockerfile.dev @@ -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 diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs b/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs index bb60183f..ccf1f420 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs @@ -125,7 +125,7 @@ namespace Kyoo.Abstractions.Models /// /// The path of the video file for this episode. Any format supported by a is allowed. /// - [SerializeIgnore] public string Path { get; set; } + public string Path { get; set; } /// public Dictionary Images { get; set; } diff --git a/back/src/Kyoo.Abstractions/Models/WatchItem.cs b/back/src/Kyoo.Abstractions/Models/WatchItem.cs index 15d7ddf5..13e61ae0 100644 --- a/back/src/Kyoo.Abstractions/Models/WatchItem.cs +++ b/back/src/Kyoo.Abstractions/Models/WatchItem.cs @@ -141,11 +141,14 @@ namespace Kyoo.Abstractions.Models /// public ICollection Chapters { get; set; } + [SerializeIgnore] + private string _Type => IsMovie ? "movie" : "episode"; + /// 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", }; /// diff --git a/back/src/Kyoo.Core/CoreModule.cs b/back/src/Kyoo.Core/CoreModule.cs index 5e808c9a..e1613c5c 100644 --- a/back/src/Kyoo.Core/CoreModule.cs +++ b/back/src/Kyoo.Core/CoreModule.cs @@ -16,9 +16,9 @@ // You should have received a copy of the GNU General Public License // along with Kyoo. If not, see . -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(); } diff --git a/back/src/Kyoo.Core/Kyoo.Core.csproj b/back/src/Kyoo.Core/Kyoo.Core.csproj index 8f113039..7342f457 100644 --- a/back/src/Kyoo.Core/Kyoo.Core.csproj +++ b/back/src/Kyoo.Core/Kyoo.Core.csproj @@ -12,6 +12,7 @@ + diff --git a/back/src/Kyoo.Core/Views/Watch/ProxyApi.cs b/back/src/Kyoo.Core/Views/Watch/ProxyApi.cs new file mode 100644 index 00000000..207700a1 --- /dev/null +++ b/back/src/Kyoo.Core/Views/Watch/ProxyApi.cs @@ -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 . + +using System.Threading.Tasks; +using AspNetCore.Proxy; +using Kyoo.Abstractions.Models.Permissions; +using Microsoft.AspNetCore.Mvc; + +namespace Kyoo.Core.Api +{ + /// + /// Proxy to other services + /// + [ApiController] + public class ProxyApi : Controller + { + /// + /// Transcoder proxy + /// + /// + /// Simply proxy requests to the transcoder + /// + /// The path of the transcoder. + /// The return value of the transcoder. + [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}"); + } + } +} diff --git a/back/src/Kyoo.Core/Views/Watch/VideoApi.cs b/back/src/Kyoo.Core/Views/Watch/VideoApi.cs deleted file mode 100644 index 40f8c881..00000000 --- a/back/src/Kyoo.Core/Views/Watch/VideoApi.cs +++ /dev/null @@ -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 . - -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 -{ - /// - /// Get the video in a raw format or transcoded in the codec you want. - /// - [Route("videos")] - [Route("video", Order = AlternativeRoute)] - [ApiController] - [ApiDefinition("Videos", Group = WatchGroup)] - public class VideoApi : Controller - { - /// - /// The library manager used to modify or retrieve information in the data store. - /// - private readonly ILibraryManager _libraryManager; - - /// - /// The file system used to send video files. - /// - private readonly IFileSystem _files; - - /// - /// Create a new . - /// - /// The library manager used to retrieve episodes. - /// The file manager used to send video files. - public VideoApi(ILibraryManager libraryManager, - IFileSystem files) - { - _libraryManager = libraryManager; - _files = files; - } - - /// - /// - /// Disabling the cache prevent an issue on firefox that skip the last 30 seconds of HLS files - /// - 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"); - } - - /// - /// Direct video - /// - /// - /// Retrieve the raw video stream, in the same container as the one on the server. No transcoding or - /// transmuxing is done. - /// - /// The identifier of the episode to retrieve. - /// The raw video stream - /// No episode exists for the given identifier. - // 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 Direct(Identifier identifier) - { - Episode episode = await identifier.Match( - id => _libraryManager.GetOrDefault(id), - slug => _libraryManager.GetOrDefault(slug) - ); - return _files.FileResult(episode?.Path, true); - } - - /// - /// Transmux video - /// - /// - /// 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. - /// - /// The identifier of the episode to retrieve. - /// The transmuxed video stream - /// No episode exists for the given identifier. - [HttpGet("transmux/{identifier:id}/master.m3u8")] - [Permission("video", Kind.Read)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task Transmux(Identifier identifier) - { - Episode episode = await identifier.Match( - id => _libraryManager.GetOrDefault(id), - slug => _libraryManager.GetOrDefault(slug) - ); - return _files.Transmux(episode); - } - - /// - /// Transmuxed chunk - /// - /// - /// Retrieve a chunk of a transmuxed video. - /// - /// The identifier of the episode. - /// The identifier of the chunk to retrieve. - /// The options used to retrieve the path of the segments. - /// A transmuxed video chunk. - [HttpGet("transmux/{episodeLink}/segments/{chunk}", Order = AlternativeRoute)] - [Permission("video", Kind.Read)] - [ProducesResponseType(StatusCodes.Status200OK)] - public IActionResult GetTransmuxedChunk(string episodeLink, string chunk, - [FromServices] IOptions options) - { - string path = Path.GetFullPath(Path.Combine(options.Value.TransmuxPath, episodeLink)); - path = Path.Combine(path, "segments", chunk); - return PhysicalFile(path, "video/MP2T"); - } - } -} diff --git a/back/src/Kyoo.Transcoder/tests/test_main.c b/back/src/Kyoo.Transcoder/tests/test_main.c index 653c3015..fba69376 100644 --- a/back/src/Kyoo.Transcoder/tests/test_main.c +++ b/back/src/Kyoo.Transcoder/tests/test_main.c @@ -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; -} \ No newline at end of file +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 215bef95..b510fa5b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 861448f9..618a3454 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 3402bfca..fc1b6db5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/front/Dockerfile.dev b/front/Dockerfile.dev index b1e58e67..6c22cbbd 100644 --- a/front/Dockerfile.dev +++ b/front/Dockerfile.dev @@ -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 diff --git a/front/packages/models/src/login.ts b/front/packages/models/src/login.ts index 513170c2..6ade564a 100644 --- a/front/packages/models/src/login.ts +++ b/front/packages/models/src/login.ts @@ -78,7 +78,6 @@ export const getTokenWJ = async (cookies?: string): Promise<[string, Token] | [n export const getToken = async (cookies?: string): Promise => (await getTokenWJ(cookies))[0] - export const logout = async () =>{ deleteSecureItem("auth") } diff --git a/front/packages/models/src/resources/watch-item.ts b/front/packages/models/src/resources/watch-item.ts index e063f749..76995234 100644 --- a/front/packages/models/src/resources/watch-item.ts +++ b/front/packages/models/src/resources/watch-item.ts @@ -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(), /** diff --git a/front/packages/primitives/src/menu.tsx b/front/packages/primitives/src/menu.tsx index ffc52b86..a1c4b4ba 100644 --- a/front/packages/primitives/src/menu.tsx +++ b/front/packages/primitives/src/menu.tsx @@ -43,7 +43,7 @@ const Menu = ({ ...props }: { Trigger: ComponentType; - children: ReactNode | ReactNode[] | null; + children?: ReactNode | ReactNode[] | null; onMenuOpen?: () => void; onMenuClose?: () => void; } & Omit) => { diff --git a/front/packages/ui/src/player/components/hover.tsx b/front/packages/ui/src/player/components/hover.tsx index e63e173b..3c92dba7 100644 --- a/front/packages/ui/src/player/components/hover.tsx +++ b/front/packages/ui/src/player/components/hover.tsx @@ -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), })} >

@@ -117,6 +121,7 @@ export const Hover = ({ diff --git a/front/packages/ui/src/player/components/right-buttons.tsx b/front/packages/ui/src/player/components/right-buttons.tsx index 0e7f9869..b5fa5146 100644 --- a/front/packages/ui/src/player/components/right-buttons.tsx +++ b/front/packages/ui/src/player/components/right-buttons.tsx @@ -18,7 +18,7 @@ * along with Kyoo. If not, see . */ -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 ( - {subtitles && ( + {subtitles && subtitles.length > 0 && ( )} + + {Platform.OS === "web" && ( > => { +): Partial> & { 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} /> - 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", })} > - { - // TODO: use onPress event to diferenciate touch and click on the web (requires react native web 0.19) - if (Platform.OS !== "web") { + { + 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)} > + 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} /> - + ); }; diff --git a/front/packages/ui/src/player/state.tsx b/front/packages/ui/src/player/state.tsx index 2c049a07..993edca9 100644 --- a/front/packages/ui/src/player/state.tsx +++ b/front/packages/ui/src/player/state.tsx @@ -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.Direct); + export const bufferedAtom = atom(0); export const durationAtom = atom(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(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(null); const [isPlaying, setPlay] = useAtom(playAtom); const setLoad = useSetAtom(loadAtom); + const [source, setSource] = useState(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 ( - { + if (mode == PlayMode.Direct) + setPlayMode(PlayMode.Hls); + }} // TODO: textTracks: external subtitles /> ); diff --git a/front/packages/ui/src/player/video.tsx b/front/packages/ui/src/player/video.tsx index 26f0f7b5..8094c4bb 100644 --- a/front/packages/ui/src/player/video.tsx +++ b/front/packages/ui/src/player/video.tsx @@ -18,7 +18,32 @@ * along with Kyoo. If not, see . */ +declare module "react-native-video" { + interface VideoProperties { + fonts?: Font[]; + onPlayPause: (isPlaying: boolean) => void; + onMediaUnsupported?: () => void; + } + export type VideoProps = Omit & { + 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>>; +export const AudiosMenu = (props: CustomMenu) => { + return ; +}; + +export const QualitiesMenu = (props: CustomMenu) => { + return ; +}; diff --git a/front/packages/ui/src/player/video.web.tsx b/front/packages/ui/src/player/video.web.tsx index a9b966ee..215f728e 100644 --- a/front/packages/ui/src/player/video.web.tsx +++ b/front/packages/ui/src/player/video.web.tsx @@ -18,7 +18,7 @@ * along with Kyoo. If not, see . */ -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 & { - source: { uri?: string; transmux?: string }; - }; -} - -enum PlayMode { - Direct, - Transmux, -} - -const playModeAtom = atom(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 => { + 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(null); + const oldHls = useRef(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, value: Track | null, f } }, [player, value, fonts]); }; + +export const AudiosMenu = (props: ComponentProps) => { + if (!hls || hls.audioTracks.length < 2) return null; + return ( + + {hls.audioTracks.map((x, i) => ( + (hls!.audioTrack = i)} + /> + ))} + + ); +}; + +export const QualitiesMenu = (props: ComponentProps) => { + 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 ( + + setPlayMode(PlayMode.Direct)} + /> + = 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) => ( + { + setPlayMode(PlayMode.Hls); + hls!.currentLevel = i; + }} + /> + )) + .reverse()} + + ); +}; diff --git a/front/translations/en.json b/front/translations/en.json index 6d322b56..a3e4d298 100644 --- a/front/translations/en.json +++ b/front/translations/en.json @@ -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." diff --git a/front/translations/fr.json b/front/translations/fr.json index a3cefc2c..e33b041e 100644 --- a/front/translations/fr.json +++ b/front/translations/fr.json @@ -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." diff --git a/shell.nix b/shell.nix index b77d93d3..4cca2868 100644 --- a/shell.nix +++ b/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 ''; } diff --git a/transcoder/.dockerignore b/transcoder/.dockerignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/transcoder/.dockerignore @@ -0,0 +1 @@ +target/ diff --git a/transcoder/.gitignore b/transcoder/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/transcoder/.gitignore @@ -0,0 +1 @@ +/target diff --git a/transcoder/Cargo.lock b/transcoder/Cargo.lock new file mode 100644 index 00000000..d2dd0728 --- /dev/null +++ b/transcoder/Cargo.lock @@ -0,0 +1,1706 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "actix-codec" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617a8268e3537fe1d8c9ead925fca49ef6400927ee7bc26750e90ecee14ce4b8" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-files" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d832782fac6ca7369a70c9ee9a20554623c5e51c76e190ad151780ebea1cf689" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "askama_escape", + "bitflags", + "bytes", + "derive_more", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", +] + +[[package]] +name = "actix-http" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2079246596c18b4a33e274ae10c0e50613f4d32a4198e09c7b93771013fed74" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "ahash 0.8.3", + "base64", + "bitflags", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "futures-core", + "h2", + "http", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand", + "sha1", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465a6172cf69b960917811022d8f29bc0b7fa1398bc4f78b3c466673db1213b6" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "actix-router" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66ff4d247d2b160861fa2866457e85706833527840e4133f8f49aa423a38799" +dependencies = [ + "bytestring", + "http", + "regex", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15265b6b8e2347670eb363c47fc8c75208b4a4994b27192f345fcbe707804f3e" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e8613a75dd50cc45f473cee3c34d59ed677c0f7b44480ce3b8247d7dc519327" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "num_cpus", + "socket2", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b894941f818cfdc7ccc4b9e60fa7e53b5042a2e8567270f9147d5591893373a" +dependencies = [ + "futures-core", + "paste", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3cb42f9566ab176e1ef0b8b3a896529062b4efc6be0123046095914c4c1c96" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "actix-web-codegen", + "ahash 0.7.6", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "futures-core", + "futures-util", + "http", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2", + "time", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2262160a7ae29e3415554a3f1fc04c764b1540c116aa524683208078b7a75bc9" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "base64" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "3.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a0b1dbcc8ae29329621f8d4f0d835787c1c38bb1401979b49d13b0b305ff68" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "2.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6561fd3f895a11e8f72af2cb7d22e08366bebc2b6b57f7744c4bda27034744" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" + +[[package]] +name = "bytes" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" + +[[package]] +name = "bytestring" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "238e4886760d98c4f899360c834fa93e62cf7f721ac3c2da375cbdf4b8679aae" +dependencies = [ + "bytes", +] + +[[package]] +name = "cc" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" +dependencies = [ + "jobserver", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cpufeatures" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e4c1eaa2012c47becbbad2ab175484c2a84d1185b566fb2cc5b8707343dfe58" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "derive_more" +version = "0.99.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 1.0.109", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "encoding_rs" +version = "0.8.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071a31f4ee85403370b58aca746f01041ede6f0da2730960ad001edc2b71b394" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "flate2" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" + +[[package]] +name = "futures-sink" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" + +[[package]] +name = "futures-task" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" + +[[package]] +name = "futures-util" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "h2" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d357c7ae988e7d2182f7d7871d0b963962420b0678b0997ce7de72001aeab782" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hermit-abi" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" +dependencies = [ + "libc", +] + +[[package]] +name = "http" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" + +[[package]] +name = "hyper" +version = "0.14.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab302d72a6f11a3b910431ff93aae7e773078c769f0a3ef15fb9ec692ed147d4" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0646026eb1b3eea4cd9ba47912ea5ce9cc07713d105b1a14698f4e6433d348b7" +dependencies = [ + "http", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown", + "serde", +] + +[[package]] +name = "ipnet" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12b6ee2129af8d4fb011108c73d99a1b83a85977f23b82460c0ae2e25bb4b57f" + +[[package]] +name = "itoa" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" + +[[package]] +name = "jobserver" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "936cfd212a0155903bcbc060e316fb6cc7cbf2e1907329391ebadc1fe0ce77c2" +dependencies = [ + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f37a4a5928311ac501dee68b3c7613a1037d0edb30c8e5427bd832d55d1b790" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "json" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078e285eafdfb6c4b434e0d31e8cfcb5115b651496faca5749b88fafd4f23bfd" + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.144" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1" + +[[package]] +name = "local-channel" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f303ec0e94c6c54447f84f3b0ef7af769858a9c4ef56ef2a986d3dcd4c3fc9c" +dependencies = [ + "futures-core", + "futures-sink", + "futures-util", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34f76eb3611940e0e7d53a9aaa4e6a3151f69541a282fd0dad5571420c53ff1" + +[[package]] +name = "lock_api" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518ef76f2f87365916b142844c16d8fefd85039bc5699050210a7778ee1cd1de" + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "num_cpus" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9670a07f94779e00908f3e686eab508878ebb390ba6e604d3a284c00e8d0487b" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9069cbb9f99e3a5083476ccb29ceb1de18b9118cafa53e90c9551235de2b9521" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-sys 0.45.0", +] + +[[package]] +name = "paste" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f746c4065a8fa3fe23974dd82f15431cc8d40779821001404d10d2e79ca7d79" + +[[package]] +name = "percent-encoding" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" + +[[package]] +name = "pin-project-lite" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeca18b86b413c660b781aa319e4e2648a3e6f9eadc9b47e9038e6fe9f3451b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81ca098a9821bd52d6b24fd8b10bd081f47d39c22778cafaa75a2857a62c6390" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436b050e76ed2903236f032a59761c1eb99e1b0aead2c257922771dab1fc8c78" + +[[package]] +name = "reqwest" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde824a14b7c14f85caff81225f411faacc04a2013f41670f41443742b1c1c55" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-rustls", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted", + "web-sys", + "winapi", +] + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c911ba11bc8433e811ce56fde130ccf32f5127cab0e0194e9c68c5a5b671791e" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d194b56d58803a43635bdc398cd17e383d6f71f9182b9a192c127ca42494a59b" +dependencies = [ + "base64", +] + +[[package]] +name = "rustls-webpki" +version = "0.100.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6207cd5ed3d8dca7816f8f3725513a34609c0c765bf652b8c3cb4cfd87db46b" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "sct" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "semver" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed" + +[[package]] +name = "serde" +version = "1.0.163" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2113ab51b87a539ae008b5c6c02dc020ffa39afd2d83cffcb3f4eb2722cebec2" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.163" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c805777e3930c8883389c602315a24224bcc738b63905ef87cd1420353ea93e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.18", +] + +[[package]] +name = "serde_json" +version = "1.0.96" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" + +[[package]] +name = "socket2" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "time" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3403384eaacbca9923fa06940178ac13e4edb725486d70e8e15881d0c836cc" +dependencies = [ + "itoa", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" + +[[package]] +name = "time-macros" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "372950940a5f07bf38dbe211d7283c9e6d7327df53794992d293e534c733d09b" +dependencies = [ + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94d7b1cfd2aa4011f2de74c2c4c63665e27a71006b0a192dcd2710272e73dfa2" +dependencies = [ + "autocfg", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d409377ff5b1e3ca6437aa86c1eb7d40c134bfec254e44c830defa92669db5" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" +dependencies = [ + "cfg-if", + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "transcoder" +version = "0.1.0" +dependencies = [ + "actix-files", + "actix-web", + "derive_more", + "json", + "rand", + "reqwest", + "serde", + "tokio", + "utoipa", +] + +[[package]] +name = "try-lock" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" + +[[package]] +name = "typenum" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" + +[[package]] +name = "unicase" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" + +[[package]] +name = "unicode-ident" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utoipa" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ae74ef183fae36d650f063ae7bde1cacbe1cd7e72b617cbe1e985551878b98" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ea8ac818da7e746a63285594cce8a96f5e00ee31994e655bd827569cb8b137b" +dependencies = [ + "lazy_static", + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn 2.0.18", +] + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "want" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +dependencies = [ + "log", + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bba0e8cb82ba49ff4e229459ff22a191bbe9a1cb3a341610c9c33efc27ddf73" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b04bc93f9d6bdee709f6bd2118f57dd6679cf1176a1af464fca3ab0d66d8fb" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.18", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d1985d03709c53167ce907ff394f5316aa22cb4e12761295c5dc57dacb6297e" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14d6b024f1a526bb0234f52840389927257beb670610081360e5a03c5df9c258" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e128beba882dd1eb6200e1dc92ae6c5dbaa4311aa7bb211ca035779e5efc39f8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.18", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9d5b4305409d1fc9482fee2d7f9bcbf24b3972bf59817ef757e23982242a93" + +[[package]] +name = "web-sys" +version = "0.3.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bdd9ef4e984da1187bf8110c5cf5b845fbc87a23602cdf912386a76fcd3a7c2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" +dependencies = [ + "webpki", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.0", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +dependencies = [ + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "zstd" +version = "0.12.3+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76eea132fb024e0e13fd9c2f5d5d595d8a967aa72382ac2f9d39fcc95afd0806" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "6.0.5+zstd.1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d9e60b4b1758206c238a10165fbcae3ca37b01744e394c463463f6529d23b" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.8+zstd.1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5556e6ee25d32df2586c098bbfa278803692a20d0ab9565e049480d52707ec8c" +dependencies = [ + "cc", + "libc", + "pkg-config", +] diff --git a/transcoder/Cargo.toml b/transcoder/Cargo.toml new file mode 100644 index 00000000..23d66708 --- /dev/null +++ b/transcoder/Cargo.toml @@ -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" diff --git a/transcoder/Dockerfile b/transcoder/Dockerfile new file mode 100644 index 00000000..7971f120 --- /dev/null +++ b/transcoder/Dockerfile @@ -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 diff --git a/transcoder/Dockerfile.dev b/transcoder/Dockerfile.dev new file mode 100644 index 00000000..c08026a0 --- /dev/null +++ b/transcoder/Dockerfile.dev @@ -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 diff --git a/transcoder/rustfmt.toml b/transcoder/rustfmt.toml new file mode 100644 index 00000000..218e2032 --- /dev/null +++ b/transcoder/rustfmt.toml @@ -0,0 +1 @@ +hard_tabs = true diff --git a/transcoder/src/audio.rs b/transcoder/src/audio.rs new file mode 100644 index 00000000..f321cd7f --- /dev/null +++ b/transcoder/src/audio.rs @@ -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, +) -> Result { + 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, +) -> Result { + 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(), + }) + }) +} diff --git a/transcoder/src/error.rs b/transcoder/src/error.rs new file mode 100644 index 00000000..1c487d99 --- /dev/null +++ b/transcoder/src/error.rs @@ -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, + } + } +} diff --git a/transcoder/src/identify.rs b/transcoder/src/identify.rs new file mode 100644 index 00000000..d4778133 --- /dev/null +++ b/transcoder/src/identify.rs @@ -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, + pub subtitles: Vec, + pub fonts: Vec, + pub chapters: Vec, +} + +#[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, + /// 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, + /// The language of this stream (as a ISO-639-2 language code) + pub language: Option, + /// 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 { + 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(v: &JsonValue) -> Option { + v.as_str().and_then(|x| x.parse::().ok()) + } + + Ok(MediaInfo { + length: parse::(&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::(&v["Height"]).unwrap()), + width: parse::(&v["Width"]).unwrap(), + height: parse::(&v["Height"]).unwrap(), + bitrate: parse::(&v["BitRate"]).unwrap(), + } + }, + audios: output["media"]["track"] + .members() + .filter(|x| x["@type"] == "Audio") + .map(|a| Track { + index: parse::(&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::(&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 = 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. +} diff --git a/transcoder/src/main.rs b/transcoder/src/main.rs new file mode 100644 index 00000000..949c7841 --- /dev/null +++ b/transcoder/src/main.rs @@ -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 { + 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, +) -> Result { + 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, 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 +} diff --git a/transcoder/src/paths.rs b/transcoder/src/paths.rs new file mode 100644 index 00000000..25853a39 --- /dev/null +++ b/transcoder/src/paths.rs @@ -0,0 +1,29 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +struct Item { + path: String, +} + +pub async fn get_path(_resource: String, slug: String) -> Result { + 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::() + .await + .map(|x| x.path) +} diff --git a/transcoder/src/state.rs b/transcoder/src/state.rs new file mode 100644 index 00000000..6df4deec --- /dev/null +++ b/transcoder/src/state.rs @@ -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>, + audio_jobs: RwLock>, +} + +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 { + 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 { + // 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 { + 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 { + 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 { + let mut path = PathBuf::from(get_audio_path(&path, audio)); + path.push(format!("segments-{0:02}.ts", chunk)); + Ok(path) + } +} + +pub enum SegmentError { + NoTranscode, +} diff --git a/transcoder/src/transcode.rs b/transcoder/src/transcode.rs new file mode 100644 index 00000000..7db5f866 --- /dev/null +++ b/transcoder/src/transcode.rs @@ -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 { + 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 { + // 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 { + 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, + 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::().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, +} diff --git a/transcoder/src/utils.rs b/transcoder/src/utils.rs new file mode 100644 index 00000000..5e27aed8 --- /dev/null +++ b/transcoder/src/utils.rs @@ -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 { + // 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()) +} diff --git a/transcoder/src/video.rs b/transcoder/src/video.rs new file mode 100644 index 00000000..e088b981 --- /dev/null +++ b/transcoder/src/video.rs @@ -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, +) -> Result { + 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, +) -> Result { + 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(), + }) + }) +}