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 {