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