diff --git a/back/src/Kyoo.Abstractions/Models/Attributes/Permission/PermissionAttribute.cs b/back/src/Kyoo.Abstractions/Models/Attributes/Permission/PermissionAttribute.cs index 1f43853a..9e5d27f9 100644 --- a/back/src/Kyoo.Abstractions/Models/Attributes/Permission/PermissionAttribute.cs +++ b/back/src/Kyoo.Abstractions/Models/Attributes/Permission/PermissionAttribute.cs @@ -44,9 +44,14 @@ namespace Kyoo.Abstractions.Models.Permissions Create, /// - /// Allow the user to delete this kind od data. + /// Allow the user to delete this kind of data. /// - Delete + Delete, + + /// + /// Allow the user to play this file. + /// + Play, } /// diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs b/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs index 771faaf7..a0ec9149 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs @@ -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", }; /// diff --git a/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs index e5a12cfc..f58f4990 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/WatchStatusRepository.cs @@ -68,8 +68,7 @@ public class WatchStatusRepository : IWatchStatusRepository DatabaseContext db = scope.ServiceProvider.GetRequiredService(); WatchStatusRepository repo = scope.ServiceProvider.GetRequiredService(); - List users = await db - .ShowWatchStatus.IgnoreQueryFilters() + List users = await db.ShowWatchStatus.IgnoreQueryFilters() .Where(x => x.ShowId == ep.ShowId && x.Status == WatchStatus.Completed) .Select(x => x.UserId) .ToListAsync(); diff --git a/back/src/Kyoo.Core/Controllers/Transcoder.cs b/back/src/Kyoo.Core/Controllers/Transcoder.cs new file mode 100644 index 00000000..dbb5d5ee --- /dev/null +++ b/back/src/Kyoo.Core/Controllers/Transcoder.cs @@ -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 . + +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); + } +} diff --git a/back/src/Kyoo.Core/CoreModule.cs b/back/src/Kyoo.Core/CoreModule.cs index 24138b52..18f0c229 100644 --- a/back/src/Kyoo.Core/CoreModule.cs +++ b/back/src/Kyoo.Core/CoreModule.cs @@ -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)); diff --git a/back/src/Kyoo.Core/Views/Resources/EpisodeApi.cs b/back/src/Kyoo.Core/Views/Resources/EpisodeApi.cs index 5d2a3c86..8d095c5c 100644 --- a/back/src/Kyoo.Core/Views/Resources/EpisodeApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/EpisodeApi.cs @@ -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 + public class EpisodeApi( + ILibraryManager libraryManager, + IThumbnailsManager thumbnails, + Transcoder transcoder + ) : CrudThumbsApi(libraryManager.Episodes, thumbnails) { - /// - /// The library manager used to modify or retrieve information in the data store. - /// - private readonly ILibraryManager _libraryManager; - - /// - /// Create a new . - /// - /// - /// The library manager used to modify or retrieve information in the data store. - /// - /// The thumbnail manager used to retrieve images paths. - public EpisodeApi(ILibraryManager libraryManager, IThumbnailsManager thumbnails) - : base(libraryManager.Episodes, thumbnails) - { - _libraryManager = libraryManager; - } - /// /// Get episode's show /// @@ -77,7 +64,7 @@ namespace Kyoo.Core.Api [FromQuery] Include fields ) { - return await _libraryManager.Shows.Get( + return await libraryManager.Shows.Get( identifier.IsContainedIn(x => x.Episodes!), fields ); @@ -104,15 +91,15 @@ namespace Kyoo.Core.Api [FromQuery] Include fields ) { - Season? ret = await _libraryManager.Seasons.GetOrDefault( + Season? ret = await libraryManager.Seasons.GetOrDefault( identifier.IsContainedIn(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()); } /// @@ -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()); + } + + /// + /// Direct stream + /// + /// + /// Retrieve the raw video stream, in the same container as the one on the server. No transcoding or + /// transmuxing is done. + /// + /// The ID or slug of the . + /// The video file of this episode. + /// No episode with the given ID or slug could be found. + [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); + } + + /// + /// 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. + /// + /// The ID or slug of the . + /// The master playlist of this episode. + /// No episode with the given ID or slug could be found. + [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); } } } diff --git a/back/src/Kyoo.Core/Views/Watch/ProxyApi.cs b/back/src/Kyoo.Core/Views/Watch/ProxyApi.cs index c603a543..9c22d3b6 100644 --- a/back/src/Kyoo.Core/Views/Watch/ProxyApi.cs +++ b/back/src/Kyoo.Core/Views/Watch/ProxyApi.cs @@ -16,14 +16,14 @@ // You should have received a copy of the GNU General Public License // along with Kyoo. If not, see . +using System; using System.Collections.Generic; using System.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 /// [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(); - /// /// Transcoder proxy /// @@ -54,15 +43,28 @@ namespace Kyoo.Core.Api /// /// The path of the transcoder. /// The return value of the transcoder. - [Route("video/{**rest}")] + [Route("video/{type}/{id:id}/{**rest}")] [Permission("video", Kind.Read)] - public Task Proxy(string rest, [FromQuery] Dictionary query) + [Obsolete("Use /episode/id/master.m3u8 or routes like that")] + public async Task Proxy( + string type, + Identifier id, + string rest, + [FromQuery] Dictionary 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); } } } diff --git a/transcoder/main.go b/transcoder/main.go index 0584d0b1..65f4f3ad 100644 --- a/transcoder/main.go +++ b/transcoder/main.go @@ -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) diff --git a/transcoder/utils.go b/transcoder/utils.go index 8dafd3d4..6474aebd 100644 --- a/transcoder/utils.go +++ b/transcoder/utils.go @@ -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 {