Start to remove transcoder dependence on kyoo

This commit is contained in:
Zoe Roux 2024-02-18 17:19:24 +01:00
parent 19485a110a
commit 0a0939fa3d
9 changed files with 223 additions and 147 deletions

View File

@ -44,9 +44,14 @@ namespace Kyoo.Abstractions.Models.Permissions
Create,
/// <summary>
/// Allow the user to delete this kind od data.
/// Allow the user to delete this kind of data.
/// </summary>
Delete
Delete,
/// <summary>
/// Allow the user to play this file.
/// </summary>
Play,
}
/// <summary>

View File

@ -271,8 +271,8 @@ namespace Kyoo.Abstractions.Models
public VideoLinks Links =>
new()
{
Direct = $"/video/episode/{Slug}/direct",
Hls = $"/video/episode/{Slug}/master.m3u8",
Direct = $"/episode/{Slug}/direct",
Hls = $"/episode/{Slug}/master.m3u8",
};
/// <summary>

View File

@ -68,8 +68,7 @@ public class WatchStatusRepository : IWatchStatusRepository
DatabaseContext db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
WatchStatusRepository repo =
scope.ServiceProvider.GetRequiredService<WatchStatusRepository>();
List<Guid> users = await db
.ShowWatchStatus.IgnoreQueryFilters()
List<Guid> users = await db.ShowWatchStatus.IgnoreQueryFilters()
.Where(x => x.ShowId == ep.ShowId && x.Status == WatchStatus.Completed)
.Select(x => x.UserId)
.ToListAsync();

View File

@ -0,0 +1,52 @@
// 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 AspNetCore.Proxy.Options;
using Kyoo.Abstractions.Models.Utils;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Kyoo.Core.Controllers;
public class Transcoder : Controller
{
public Task Proxy(string route, string path)
{
HttpProxyOptions proxyOptions = HttpProxyOptionsBuilder
.Instance.WithBeforeSend(
(ctx, req) =>
{
req.Headers.Add("X-Path", path);
return Task.CompletedTask;
}
)
.WithHandleFailure(
async (context, exception) =>
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsJsonAsync(
new RequestError("Service unavailable")
);
}
)
.Build();
return this.HttpProxyAsync($"http://transcoder:7666/{route}", proxyOptions);
}
}

View File

@ -110,8 +110,7 @@ namespace Kyoo.Core
options.SuppressMapClientErrors = true;
options.InvalidModelStateResponseFactory = ctx =>
{
string[] errors = ctx
.ModelState.SelectMany(x => x.Value!.Errors)
string[] errors = ctx.ModelState.SelectMany(x => x.Value!.Errors)
.Select(x => x.ErrorMessage)
.ToArray();
return new BadRequestObjectResult(new RequestError(errors));

View File

@ -24,6 +24,7 @@ using Kyoo.Abstractions.Models.Attributes;
using Kyoo.Abstractions.Models.Permissions;
using Kyoo.Abstractions.Models.Utils;
using Kyoo.Authentication;
using Kyoo.Core.Controllers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using static Kyoo.Abstractions.Models.Utils.Constants;
@ -38,26 +39,12 @@ namespace Kyoo.Core.Api
[ApiController]
[PartialPermission(nameof(Episode))]
[ApiDefinition("Episodes", Group = ResourcesGroup)]
public class EpisodeApi : CrudThumbsApi<Episode>
public class EpisodeApi(
ILibraryManager libraryManager,
IThumbnailsManager thumbnails,
Transcoder transcoder
) : CrudThumbsApi<Episode>(libraryManager.Episodes, thumbnails)
{
/// <summary>
/// The library manager used to modify or retrieve information in the data store.
/// </summary>
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Create a new <see cref="EpisodeApi"/>.
/// </summary>
/// <param name="libraryManager">
/// The library manager used to modify or retrieve information in the data store.
/// </param>
/// <param name="thumbnails">The thumbnail manager used to retrieve images paths.</param>
public EpisodeApi(ILibraryManager libraryManager, IThumbnailsManager thumbnails)
: base(libraryManager.Episodes, thumbnails)
{
_libraryManager = libraryManager;
}
/// <summary>
/// Get episode's show
/// </summary>
@ -77,7 +64,7 @@ namespace Kyoo.Core.Api
[FromQuery] Include<Show> fields
)
{
return await _libraryManager.Shows.Get(
return await libraryManager.Shows.Get(
identifier.IsContainedIn<Show, Episode>(x => x.Episodes!),
fields
);
@ -104,15 +91,15 @@ namespace Kyoo.Core.Api
[FromQuery] Include<Season> fields
)
{
Season? ret = await _libraryManager.Seasons.GetOrDefault(
Season? ret = await libraryManager.Seasons.GetOrDefault(
identifier.IsContainedIn<Season, Episode>(x => x.Episodes!),
fields
);
if (ret != null)
return ret;
Episode? episode = await identifier.Match(
id => _libraryManager.Episodes.GetOrDefault(id),
slug => _libraryManager.Episodes.GetOrDefault(slug)
id => libraryManager.Episodes.GetOrDefault(id),
slug => libraryManager.Episodes.GetOrDefault(slug)
);
return episode == null ? NotFound() : NoContent();
}
@ -136,9 +123,9 @@ namespace Kyoo.Core.Api
{
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await _libraryManager.Episodes.Get(slug)).Id
async slug => (await libraryManager.Episodes.Get(slug)).Id
);
return await _libraryManager.WatchStatus.GetEpisodeStatus(id, User.GetIdOrThrow());
return await libraryManager.WatchStatus.GetEpisodeStatus(id, User.GetIdOrThrow());
}
/// <summary>
@ -170,9 +157,9 @@ namespace Kyoo.Core.Api
{
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await _libraryManager.Episodes.Get(slug)).Id
async slug => (await libraryManager.Episodes.Get(slug)).Id
);
return await _libraryManager.WatchStatus.SetEpisodeStatus(
return await libraryManager.WatchStatus.SetEpisodeStatus(
id,
User.GetIdOrThrow(),
status,
@ -199,9 +186,100 @@ namespace Kyoo.Core.Api
{
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await _libraryManager.Episodes.Get(slug)).Id
async slug => (await libraryManager.Episodes.Get(slug)).Id
);
await _libraryManager.WatchStatus.DeleteEpisodeStatus(id, User.GetIdOrThrow());
await libraryManager.WatchStatus.DeleteEpisodeStatus(id, User.GetIdOrThrow());
}
/// <summary>
/// Direct stream
/// </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 ID or slug of the <see cref="Episode"/>.</param>
/// <returns>The video file of this episode.</returns>
/// <response code="404">No episode with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/direct")]
[PartialPermission(Kind.Play)]
[ProducesResponseType(StatusCodes.Status206PartialContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task GetDirectStream(Identifier identifier)
{
string path = await identifier.Match(
async id => (await libraryManager.Episodes.Get(id)).Path,
async slug => (await libraryManager.Episodes.Get(slug)).Path
);
await transcoder.Proxy("/direct", path);
}
/// <summary>
/// Get master playlist
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
/// <returns>The master playlist of this episode.</returns>
/// <response code="404">No episode with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/master.m3u8")]
[PartialPermission(Kind.Play)]
[ProducesResponseType(StatusCodes.Status206PartialContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task GetMaster(Identifier identifier)
{
string path = await identifier.Match(
async id => (await libraryManager.Episodes.Get(id)).Path,
async slug => (await libraryManager.Episodes.Get(slug)).Path
);
await transcoder.Proxy("/master.m3u8", path);
}
[HttpGet("{identifier:id}/{quality}/index.m3u8")]
[PartialPermission(Kind.Play)]
public async Task GetVideoIndex(Identifier identifier, string quality)
{
string path = await identifier.Match(
async id => (await libraryManager.Episodes.Get(id)).Path,
async slug => (await libraryManager.Episodes.Get(slug)).Path
);
await transcoder.Proxy($"/{quality}/index.m3u8", path);
}
[HttpGet("{identifier:id}/audio/{audio}/index.m3u8")]
[PartialPermission(Kind.Play)]
public async Task GetAudioIndex(Identifier identifier, string audio)
{
string path = await identifier.Match(
async id => (await libraryManager.Episodes.Get(id)).Path,
async slug => (await libraryManager.Episodes.Get(slug)).Path
);
await transcoder.Proxy($"/audio/{audio}/index.m3u8", path);
}
[HttpGet("{identifier:id}/audio/{audio}/{segment}")]
[PartialPermission(Kind.Play)]
public async Task GetAudioSegment(Identifier identifier, string audio, string segment)
{
string path = await identifier.Match(
async id => (await libraryManager.Episodes.Get(id)).Path,
async slug => (await libraryManager.Episodes.Get(slug)).Path
);
await transcoder.Proxy($"/audio/{audio}/{segment}", path);
}
[HttpGet("{identifier:id}/info")]
[PartialPermission(Kind.Play)]
public async Task GetInfo(Identifier identifier)
{
string path = await identifier.Match(
async id => (await libraryManager.Episodes.Get(id)).Path,
async slug => (await libraryManager.Episodes.Get(slug)).Path
);
await transcoder.Proxy("/info", path);
}
}
}

View File

@ -16,14 +16,14 @@
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AspNetCore.Proxy;
using AspNetCore.Proxy.Options;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models.Permissions;
using Kyoo.Abstractions.Models.Utils;
using Kyoo.Core.Controllers;
using Kyoo.Utils;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Kyoo.Core.Api
@ -32,20 +32,9 @@ namespace Kyoo.Core.Api
/// Proxy to other services
/// </summary>
[ApiController]
public class ProxyApi : Controller
[Obsolete("Use /episode/id/master.m3u8 or routes like that")]
public class ProxyApi(ILibraryManager library, Transcoder transcoder) : Controller
{
private readonly HttpProxyOptions _proxyOptions = HttpProxyOptionsBuilder
.Instance.WithHandleFailure(
async (context, exception) =>
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsJsonAsync(
new RequestError("Service unavailable")
);
}
)
.Build();
/// <summary>
/// Transcoder proxy
/// </summary>
@ -54,15 +43,28 @@ namespace Kyoo.Core.Api
/// </remarks>
/// <param name="rest">The path of the transcoder.</param>
/// <returns>The return value of the transcoder.</returns>
[Route("video/{**rest}")]
[Route("video/{type}/{id:id}/{**rest}")]
[Permission("video", Kind.Read)]
public Task Proxy(string rest, [FromQuery] Dictionary<string, string> query)
[Obsolete("Use /episode/id/master.m3u8 or routes like that")]
public async Task Proxy(
string type,
Identifier id,
string rest,
[FromQuery] Dictionary<string, string> query
)
{
// TODO: Use an env var to configure transcoder:7666.
return this.HttpProxyAsync(
$"http://transcoder:7666/{rest}" + query.ToQueryString(),
_proxyOptions
string path = await (
type is "movie" or "movies"
? id.Match(
async id => (await library.Movies.Get(id)).Path,
async slug => (await library.Movies.Get(slug)).Path
)
: id.Match(
async id => (await library.Episodes.Get(id)).Path,
async slug => (await library.Episodes.Get(slug)).Path
)
);
await transcoder.Proxy(rest + query.ToQueryString(), path);
}
}
}

View File

@ -16,12 +16,9 @@ import (
// Retrieve the raw video stream, in the same container as the one on the server. No transcoding or
// transmuxing is done.
//
// Path: /:resource/:slug/direct
// Path: /direct
func DirectStream(c echo.Context) error {
resource := c.Param("resource")
slug := c.Param("slug")
path, err := GetPath(resource, slug)
path, err := GetPath(c)
if err != nil {
return err
}
@ -34,17 +31,13 @@ func DirectStream(c echo.Context) error {
// 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.
//
// Path: /:resource/:slug/master.m3u8
// Path: /master.m3u8
func (h *Handler) GetMaster(c echo.Context) error {
resource := c.Param("resource")
slug := c.Param("slug")
client, err := GetClientId(c)
if err != nil {
return err
}
path, err := GetPath(resource, slug)
path, err := GetPath(c)
if err != nil {
return err
}
@ -62,21 +55,17 @@ func (h *Handler) GetMaster(c echo.Context) error {
// This route can take a few seconds to respond since it will way for at least one segment to be
// available.
//
// Path: /:resource/:slug/:quality/index.m3u8
// Path: /:quality/index.m3u8
func (h *Handler) GetVideoIndex(c echo.Context) error {
resource := c.Param("resource")
slug := c.Param("slug")
quality, err := src.QualityFromString(c.Param("quality"))
if err != nil {
return err
}
client, err := GetClientId(c)
if err != nil {
return err
}
path, err := GetPath(resource, slug)
path, err := GetPath(c)
if err != nil {
return err
}
@ -94,21 +83,17 @@ func (h *Handler) GetVideoIndex(c echo.Context) error {
// This route can take a few seconds to respond since it will way for at least one segment to be
// available.
//
// Path: /:resource/:slug/audio/:audio/index.m3u8
// Path: /audio/:audio/index.m3u8
func (h *Handler) GetAudioIndex(c echo.Context) error {
resource := c.Param("resource")
slug := c.Param("slug")
audio, err := strconv.ParseInt(c.Param("audio"), 10, 32)
if err != nil {
return err
}
client, err := GetClientId(c)
if err != nil {
return err
}
path, err := GetPath(resource, slug)
path, err := GetPath(c)
if err != nil {
return err
}
@ -124,10 +109,8 @@ func (h *Handler) GetAudioIndex(c echo.Context) error {
//
// Retrieve a chunk of a transmuxed video.
//
// Path: /:resource/:slug/:quality/segments-:chunk.ts
// Path: /:quality/segments-:chunk.ts
func (h *Handler) GetVideoSegment(c echo.Context) error {
resource := c.Param("resource")
slug := c.Param("slug")
quality, err := src.QualityFromString(c.Param("quality"))
if err != nil {
return err
@ -136,13 +119,11 @@ func (h *Handler) GetVideoSegment(c echo.Context) error {
if err != nil {
return err
}
client, err := GetClientId(c)
if err != nil {
return err
}
path, err := GetPath(resource, slug)
path, err := GetPath(c)
if err != nil {
return err
}
@ -158,10 +139,8 @@ func (h *Handler) GetVideoSegment(c echo.Context) error {
//
// Retrieve a chunk of a transcoded audio.
//
// Path: /:resource/:slug/audio/:audio/segments-:chunk.ts
// Path: /audio/:audio/segments-:chunk.ts
func (h *Handler) GetAudioSegment(c echo.Context) error {
resource := c.Param("resource")
slug := c.Param("slug")
audio, err := strconv.ParseInt(c.Param("audio"), 10, 32)
if err != nil {
return err
@ -170,13 +149,11 @@ func (h *Handler) GetAudioSegment(c echo.Context) error {
if err != nil {
return err
}
client, err := GetClientId(c)
if err != nil {
return err
}
path, err := GetPath(resource, slug)
path, err := GetPath(c)
if err != nil {
return err
}
@ -192,12 +169,9 @@ func (h *Handler) GetAudioSegment(c echo.Context) error {
//
// Identify metadata about a file.
//
// Path: /:resource/:slug/info
// Path: /info
func (h *Handler) GetInfo(c echo.Context) error {
resource := c.Param("resource")
slug := c.Param("slug")
path, err := GetPath(resource, slug)
path, err := GetPath(c)
if err != nil {
return err
}
@ -208,10 +182,10 @@ func (h *Handler) GetInfo(c echo.Context) error {
}
// Run extractors to have them in cache
h.extractor.RunExtractor(ret.Path, ret.Sha, &ret.Subtitles)
go h.thumbnails.ExtractThumbnail(
ret.Path,
fmt.Sprintf("%s/%s/thumbnails.png", resource, slug),
)
// go h.thumbnails.ExtractThumbnail(
// ret.Path,
// fmt.Sprintf("%s/%s/thumbnails.png", resource, slug),
// )
return c.JSON(http.StatusOK, ret)
}
@ -340,13 +314,13 @@ func main() {
thumbnails: src.NewThumbnailsCreator(),
}
e.GET("/:resource/:slug/direct", DirectStream)
e.GET("/:resource/:slug/master.m3u8", h.GetMaster)
e.GET("/:resource/:slug/:quality/index.m3u8", h.GetVideoIndex)
e.GET("/:resource/:slug/audio/:audio/index.m3u8", h.GetAudioIndex)
e.GET("/:resource/:slug/:quality/:chunk", h.GetVideoSegment)
e.GET("/:resource/:slug/audio/:audio/:chunk", h.GetAudioSegment)
e.GET("/:resource/:slug/info", h.GetInfo)
e.GET("/direct", DirectStream)
e.GET("/master.m3u8", h.GetMaster)
e.GET("/:quality/index.m3u8", h.GetVideoIndex)
e.GET("/audio/:audio/index.m3u8", h.GetAudioIndex)
e.GET("/:quality/:chunk", h.GetVideoSegment)
e.GET("/audio/:audio/:chunk", h.GetAudioSegment)
e.GET("/info", h.GetInfo)
e.GET("/:resource/:slug/thumbnails.png", h.GetThumbnails)
e.GET("/:resource/:slug/thumbnails.vtt", h.GetThumbnailsVtt)
e.GET("/:sha/attachment/:name", h.GetAttachment)

View File

@ -1,11 +1,8 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
@ -18,42 +15,12 @@ type Item struct {
Path string `json:"path"`
}
func GetPath(resource string, slug string) (string, error) {
url := os.Getenv("API_URL")
if url == "" {
url = "http://back:5000"
}
key := os.Getenv("KYOO_APIKEYS")
func GetPath(c echo.Context) (string, error) {
key := c.Request().Header.Get("X-Path")
if key == "" {
return "", errors.New("missing api keys")
return "", echo.NewHTTPError(http.StatusBadRequest, "Missing resouce path. You need to set the X-Path header.")
}
key = strings.Split(key, ",")[0]
req, err := http.NewRequest("GET", strings.Join([]string{url, resource, slug}, "/"), nil)
if err != nil {
return "", err
}
req.Header.Set("X-API-KEY", key)
res, err := client.Do(req)
if err != nil {
return "", err
}
if res.StatusCode != 200 {
return "", echo.NewHTTPError(
http.StatusNotFound,
fmt.Sprintf("No %s found with the slug %s.", resource, slug),
)
}
defer res.Body.Close()
ret := Item{}
err = json.NewDecoder(res.Body).Decode(&ret)
if err != nil {
return "", err
}
return ret.Path, nil
return key, nil
}
func SanitizePath(path string) error {