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