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