diff --git a/back/src/Kyoo.Core/Controllers/Base64RouteConstraint.cs b/back/src/Kyoo.Core/Controllers/Base64RouteConstraint.cs new file mode 100644 index 00000000..e69177a1 --- /dev/null +++ b/back/src/Kyoo.Core/Controllers/Base64RouteConstraint.cs @@ -0,0 +1,42 @@ +// 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.Text.RegularExpressions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Kyoo.Core.Controllers; + +public class Base64RouteConstraint : IRouteConstraint +{ + static Regex Base64Reg = new("^[-A-Za-z0-9+/]*={0,3}$"); + + /// + public bool Match( + HttpContext? httpContext, + IRouter? route, + string routeKey, + RouteValueDictionary values, + RouteDirection routeDirection + ) + { + return values.TryGetValue(routeKey, out object? val) + && val is string str + && Base64Reg.IsMatch(str); + } +} diff --git a/back/src/Kyoo.Core/Extensions/ServiceExtensions.cs b/back/src/Kyoo.Core/Extensions/ServiceExtensions.cs index 452aed44..5dd49309 100644 --- a/back/src/Kyoo.Core/Extensions/ServiceExtensions.cs +++ b/back/src/Kyoo.Core/Extensions/ServiceExtensions.cs @@ -76,6 +76,7 @@ public static class ServiceExtensions services.Configure(x => { x.ConstraintMap.Add("id", typeof(IdentifierRouteConstraint)); + x.ConstraintMap.Add("base64", typeof(Base64RouteConstraint)); }); services.AddResponseCompression(x => diff --git a/back/src/Kyoo.Core/Views/Content/ProxyApi.cs b/back/src/Kyoo.Core/Views/Content/ProxyApi.cs deleted file mode 100644 index 28b36253..00000000 --- a/back/src/Kyoo.Core/Views/Content/ProxyApi.cs +++ /dev/null @@ -1,95 +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 . - -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.Utils; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace Kyoo.Core.Api; - -/// -/// Proxy to other services -/// -[ApiController] -[Obsolete("Use /episode/id/master.m3u8 or routes like that")] -public class ProxyApi(ILibraryManager library) : Controller -{ - private Task _Proxy(string route, (string path, string route) info) - { - HttpProxyOptions proxyOptions = HttpProxyOptionsBuilder - .Instance.WithBeforeSend( - (ctx, req) => - { - req.Headers.Add("X-Path", info.path); - req.Headers.Add("X-Route", info.route); - return Task.CompletedTask; - } - ) - .WithHandleFailure( - async (context, exception) => - { - context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; - await context.Response.WriteAsJsonAsync( - new RequestError("Service unavailable") - ); - } - ) - .Build(); - return this.HttpProxyAsync($"{Transcoder.TranscoderUrl}{route}", proxyOptions); - } - - /// - /// Transcoder proxy - /// - /// - /// Simply proxy requests to the transcoder - /// - /// The path of the transcoder. - /// The return value of the transcoder. - [Route("video/{type}/{id:id}/{**rest}")] - [Permission("video", Kind.Read)] - [Obsolete("Use /episode/id/master.m3u8 or routes like that")] - public async Task Proxy( - string type, - Identifier id, - string rest, - [FromQuery] Dictionary query - ) - { - 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 _Proxy(rest + query.ToQueryString(), (path, $"{type}/{id}")); - } -} diff --git a/back/src/Kyoo.Core/Views/Content/VideoApi.cs b/back/src/Kyoo.Core/Views/Content/VideoApi.cs new file mode 100644 index 00000000..e3deb132 --- /dev/null +++ b/back/src/Kyoo.Core/Views/Content/VideoApi.cs @@ -0,0 +1,136 @@ +// 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; +using System.Threading.Tasks; +using AspNetCore.Proxy; +using AspNetCore.Proxy.Options; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Core.Api; + +/// +/// Private routes of the transcoder. +/// Url for these routes will be returned from /info or /master.m3u8 routes. +/// This should not be called manually +/// +[ApiController] +[Route("videos")] +[Route("video", Order = AlternativeRoute)] +[Permission("video", Kind.Read, Group = Group.Overall)] +[ApiDefinition("Video", Group = OtherGroup)] +public class VideoApi : Controller +{ + public static string TranscoderUrl = + Environment.GetEnvironmentVariable("TRANSCODER_URL") ?? "http://transcoder:7666"; + + private Task _Proxy(string route) + { + HttpProxyOptions proxyOptions = HttpProxyOptionsBuilder + .Instance.WithHandleFailure( + async (context, exception) => + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await context.Response.WriteAsJsonAsync( + new RequestError("Service unavailable") + ); + } + ) + .Build(); + return this.HttpProxyAsync($"{TranscoderUrl}/{route}", proxyOptions); + } + + [HttpGet("{path:base64}/direct")] + [PartialPermission(Kind.Play)] + [ProducesResponseType(StatusCodes.Status206PartialContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetDirectStream(string path) + { + await _Proxy($"{path}/direct"); + } + + [HttpGet("{path:base64}/master.m3u8")] + [PartialPermission(Kind.Play)] + [ProducesResponseType(StatusCodes.Status206PartialContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetMaster(string path) + { + await _Proxy($"{path}/master.m3u8"); + } + + [HttpGet("{path:base64}/{quality}/index.m3u8")] + [PartialPermission(Kind.Play)] + public async Task GetVideoIndex(string path, string quality) + { + await _Proxy($"{path}/{quality}/index.m3u8"); + } + + [HttpGet("{path:base64}/{quality}/{segment}")] + [PartialPermission(Kind.Play)] + public async Task GetVideoSegment(string path, string quality, string segment) + { + await _Proxy($"{path}/{quality}/{segment}"); + } + + [HttpGet("{path:base64}/audio/{audio}/index.m3u8")] + [PartialPermission(Kind.Play)] + public async Task GetAudioIndex(string path, string audio) + { + await _Proxy($"{path}/audio/{audio}/index.m3u8"); + } + + [HttpGet("{path:base64}/audio/{audio}/{segment}")] + [PartialPermission(Kind.Play)] + public async Task GetAudioSegment(string path, string audio, string segment) + { + await _Proxy($"{path}/audio/{audio}/{segment}"); + } + + [HttpGet("{path:base64}/attachment/{name}")] + [PartialPermission(Kind.Play)] + public async Task GetAttachment(string path, string name) + { + await _Proxy($"{path}/attachment/{name}"); + } + + [HttpGet("{path:base64}/subtitle/{name}")] + [PartialPermission(Kind.Play)] + public async Task GetSubtitle(string path, string name) + { + await _Proxy($"{path}/subtitle/{name}"); + } + + [HttpGet("{path:base64}/thumbnails.png")] + [PartialPermission(Kind.Read)] + public async Task GetThumbnails(string path) + { + await _Proxy($"{path}/thumbnails.png"); + } + + [HttpGet("{path:base64}/thumbnails.vtt")] + [PartialPermission(Kind.Read)] + public async Task GetThumbnailsVtt(string path) + { + await _Proxy($"{path}/thumbnails.vtt"); + } +} diff --git a/back/src/Kyoo.Core/Views/Helper/Transcoder.cs b/back/src/Kyoo.Core/Views/Helper/Transcoder.cs index 337f437b..43f25a8d 100644 --- a/back/src/Kyoo.Core/Views/Helper/Transcoder.cs +++ b/back/src/Kyoo.Core/Views/Helper/Transcoder.cs @@ -17,6 +17,7 @@ // along with Kyoo. If not, see . using System; +using System.Text; using System.Threading.Tasks; using AspNetCore.Proxy; using AspNetCore.Proxy.Options; @@ -29,27 +30,13 @@ using Microsoft.AspNetCore.Mvc; namespace Kyoo.Core.Api; -public static class Transcoder -{ - public static string TranscoderUrl = - Environment.GetEnvironmentVariable("TRANSCODER_URL") ?? "http://transcoder:7666"; -} - public abstract class TranscoderApi(IRepository repository) : CrudThumbsApi(repository) where T : class, IResource, IThumbnails, IQuery { - private Task _Proxy(string route, (string path, string route) info) + private Task _Proxy(string route) { HttpProxyOptions proxyOptions = HttpProxyOptionsBuilder - .Instance.WithBeforeSend( - (ctx, req) => - { - req.Headers.Add("X-Path", info.path); - req.Headers.Add("X-Route", info.route); - return Task.CompletedTask; - } - ) - .WithHandleFailure( + .Instance.WithHandleFailure( async (context, exception) => { context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; @@ -59,10 +46,16 @@ public abstract class TranscoderApi(IRepository repository) : CrudThumbsAp } ) .Build(); - return this.HttpProxyAsync($"{Transcoder.TranscoderUrl}{route}", proxyOptions); + return this.HttpProxyAsync($"{VideoApi.TranscoderUrl}/{route}", proxyOptions); } - protected abstract Task<(string path, string route)> GetPath(Identifier identifier); + protected abstract Task GetPath(Identifier identifier); + + private async Task _GetPath64(Identifier identifier) + { + string path = await GetPath(identifier); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(path)); + } /// /// Direct stream @@ -76,11 +69,12 @@ public abstract class TranscoderApi(IRepository repository) : CrudThumbsAp /// No episode with the given ID or slug could be found. [HttpGet("{identifier:id}/direct")] [PartialPermission(Kind.Play)] - [ProducesResponseType(StatusCodes.Status206PartialContent)] + [ProducesResponseType(StatusCodes.Status302Found)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetDirectStream(Identifier identifier) + public async Task GetDirectStream(Identifier identifier) { - await _Proxy("/direct", await GetPath(identifier)); + // TODO: Remove the /api and use a proxy rewrite instead. + return Redirect($"/api/video/{await _GetPath64(identifier)}/direct"); } /// @@ -96,39 +90,12 @@ public abstract class TranscoderApi(IRepository repository) : CrudThumbsAp /// No episode with the given ID or slug could be found. [HttpGet("{identifier:id}/master.m3u8")] [PartialPermission(Kind.Play)] - [ProducesResponseType(StatusCodes.Status206PartialContent)] + [ProducesResponseType(StatusCodes.Status302Found)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetMaster(Identifier identifier) + public async Task GetMaster(Identifier identifier) { - await _Proxy("/master.m3u8", await GetPath(identifier)); - } - - [HttpGet("{identifier:id}/{quality}/index.m3u8")] - [PartialPermission(Kind.Play)] - public async Task GetVideoIndex(Identifier identifier, string quality) - { - await _Proxy($"/{quality}/index.m3u8", await GetPath(identifier)); - } - - [HttpGet("{identifier:id}/{quality}/{segment}")] - [PartialPermission(Kind.Play)] - public async Task GetVideoSegment(Identifier identifier, string quality, string segment) - { - await _Proxy($"/{quality}/{segment}", await GetPath(identifier)); - } - - [HttpGet("{identifier:id}/audio/{audio}/index.m3u8")] - [PartialPermission(Kind.Play)] - public async Task GetAudioIndex(Identifier identifier, string audio) - { - await _Proxy($"/audio/{audio}/index.m3u8", await GetPath(identifier)); - } - - [HttpGet("{identifier:id}/audio/{audio}/{segment}")] - [PartialPermission(Kind.Play)] - public async Task GetAudioSegment(Identifier identifier, string audio, string segment) - { - await _Proxy($"/audio/{audio}/{segment}", await GetPath(identifier)); + // TODO: Remove the /api and use a proxy rewrite instead. + return Redirect($"/api/video/{await _GetPath64(identifier)}/master.m3u8"); } /// @@ -144,7 +111,7 @@ public abstract class TranscoderApi(IRepository repository) : CrudThumbsAp [PartialPermission(Kind.Read)] public async Task GetInfo(Identifier identifier) { - await _Proxy("/info", await GetPath(identifier)); + await _Proxy($"{await _GetPath64(identifier)}/info"); } /// @@ -160,7 +127,7 @@ public abstract class TranscoderApi(IRepository repository) : CrudThumbsAp [PartialPermission(Kind.Read)] public async Task GetThumbnails(Identifier identifier) { - await _Proxy("/thumbnails.png", await GetPath(identifier)); + await _Proxy($"{await _GetPath64(identifier)}/thumbnails.png"); } /// @@ -177,6 +144,6 @@ public abstract class TranscoderApi(IRepository repository) : CrudThumbsAp [PartialPermission(Kind.Read)] public async Task GetThumbnailsVtt(Identifier identifier) { - await _Proxy("/thumbnails.vtt", await GetPath(identifier)); + await _Proxy($"{await _GetPath64(identifier)}/thumbnails.vtt"); } } diff --git a/back/src/Kyoo.Core/Views/Resources/EpisodeApi.cs b/back/src/Kyoo.Core/Views/Resources/EpisodeApi.cs index 9fb268a0..b26c196c 100644 --- a/back/src/Kyoo.Core/Views/Resources/EpisodeApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/EpisodeApi.cs @@ -210,12 +210,12 @@ public class EpisodeApi(ILibraryManager libraryManager) await libraryManager.WatchStatus.DeleteEpisodeStatus(id, User.GetIdOrThrow()); } - protected override async Task<(string path, string route)> GetPath(Identifier identifier) + protected override async Task GetPath(Identifier identifier) { string path = await identifier.Match( async id => (await Repository.Get(id)).Path, async slug => (await Repository.Get(slug)).Path ); - return (path, $"/episodes/{identifier}"); + return path; } } diff --git a/back/src/Kyoo.Core/Views/Resources/MovieApi.cs b/back/src/Kyoo.Core/Views/Resources/MovieApi.cs index 1d95ab6f..274b4e31 100644 --- a/back/src/Kyoo.Core/Views/Resources/MovieApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/MovieApi.cs @@ -221,12 +221,12 @@ public class MovieApi(ILibraryManager libraryManager) : TranscoderApi(lib await libraryManager.WatchStatus.DeleteMovieStatus(id, User.GetIdOrThrow()); } - protected override async Task<(string path, string route)> GetPath(Identifier identifier) + protected override async Task GetPath(Identifier identifier) { string path = await identifier.Match( async id => (await Repository.Get(id)).Path, async slug => (await Repository.Get(slug)).Path ); - return (path, $"/movies/{identifier}"); + return path; } } diff --git a/docker-compose.build.yml b/docker-compose.build.yml index b03a6d87..4ff0419e 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -7,6 +7,8 @@ x-transcoder: &transcoder-base restart: on-failure env_file: - ./.env + environment: + - GOCODER_PREFIX=/video volumes: - ${LIBRARY_ROOT}:/video:ro - ${CACHE_ROOT}:/cache @@ -95,6 +97,7 @@ services: devices: - capabilities: [gpu] environment: + - GOCODER_PREFIX=/video - GOCODER_HWACCEL=nvidia profiles: ['nvidia'] @@ -103,6 +106,7 @@ services: devices: - /dev/dri:/dev/dri environment: + - GOCODER_PREFIX=/video - GOCODER_HWACCEL=vaapi - GOCODER_VAAPI_RENDERER=${GOCODER_VAAPI_RENDERER:-/dev/dri/renderD128} profiles: ['vaapi'] @@ -112,6 +116,7 @@ services: devices: - /dev/dri:/dev/dri environment: + - GOCODER_PREFIX=/video - GOCODER_HWACCEL=qsv - GOCODER_VAAPI_RENDERER=${GOCODER_VAAPI_RENDERER:-/dev/dri/renderD128} profiles: ['qsv'] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 4ee3f10a..6f2fa693 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -12,6 +12,8 @@ x-transcoder: &transcoder-base cpus: 1 env_file: - ./.env + environment: + - GOCODER_PREFIX=/video volumes: - ./transcoder:/app - ${LIBRARY_ROOT}:/video:ro @@ -119,6 +121,7 @@ services: devices: - capabilities: [gpu] environment: + - GOCODER_PREFIX=/video - GOCODER_HWACCEL=nvidia profiles: ['nvidia'] @@ -127,6 +130,7 @@ services: devices: - /dev/dri:/dev/dri environment: + - GOCODER_PREFIX=/video - GOCODER_HWACCEL=vaapi - GOCODER_VAAPI_RENDERER=${GOCODER_VAAPI_RENDERER:-/dev/dri/renderD128} profiles: ['vaapi'] @@ -136,6 +140,7 @@ services: devices: - /dev/dri:/dev/dri environment: + - GOCODER_PREFIX=/video - GOCODER_HWACCEL=qsv - GOCODER_VAAPI_RENDERER=${GOCODER_VAAPI_RENDERER:-/dev/dri/renderD128} profiles: ['qsv'] diff --git a/docker-compose.yml b/docker-compose.yml index 9185a6dd..fa10ead6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,8 @@ x-transcoder: &transcoder-base restart: unless-stopped env_file: - ./.env + environment: + - GOCODER_PREFIX=/video volumes: - ${LIBRARY_ROOT}:/video:ro - ${CACHE_ROOT}:/cache @@ -94,6 +96,7 @@ services: devices: - capabilities: [gpu] environment: + - GOCODER_PREFIX=/video - GOCODER_HWACCEL=nvidia profiles: ['nvidia'] @@ -102,6 +105,7 @@ services: devices: - /dev/dri:/dev/dri environment: + - GOCODER_PREFIX=/video - GOCODER_HWACCEL=vaapi - GOCODER_VAAPI_RENDERER=${GOCODER_VAAPI_RENDERER:-/dev/dri/renderD128} profiles: ['vaapi'] @@ -111,6 +115,7 @@ services: devices: - /dev/dri:/dev/dri environment: + - GOCODER_PREFIX=/video - GOCODER_HWACCEL=qsv - GOCODER_VAAPI_RENDERER=${GOCODER_VAAPI_RENDERER:-/dev/dri/renderD128} profiles: ['qsv'] diff --git a/front/packages/ui/src/player/components/scrubber.tsx b/front/packages/ui/src/player/components/scrubber.tsx index e2757312..acc9670a 100644 --- a/front/packages/ui/src/player/components/scrubber.tsx +++ b/front/packages/ui/src/player/components/scrubber.tsx @@ -68,7 +68,7 @@ export const useScrubber = (url: string) => { ret[i] = { from: parseTs(times[0]), to: parseTs(times[1]), - url: imageFn("/video/" + url[0]), + url: imageFn(url[0]), x: xywh[0], y: xywh[1], width: xywh[2], diff --git a/front/packages/ui/src/player/video.web.tsx b/front/packages/ui/src/player/video.web.tsx index a0454ab3..f0d01dce 100644 --- a/front/packages/ui/src/player/video.web.tsx +++ b/front/packages/ui/src/player/video.web.tsx @@ -359,7 +359,7 @@ export const AudiosMenu = ({ {hls.audioTracks.map((x, i) => ( setAudio(audios?.[i] ?? ({ index: i } as any))} /> diff --git a/transcoder/main.go b/transcoder/main.go index e6fc54fb..152b0972 100644 --- a/transcoder/main.go +++ b/transcoder/main.go @@ -16,9 +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: /direct +// Path: /:path/direct func DirectStream(c echo.Context) error { - path, err := GetPath(c) + path, _, err := GetPath(c) if err != nil { return err } @@ -31,18 +31,18 @@ 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: /master.m3u8 +// Path: /:path/master.m3u8 func (h *Handler) GetMaster(c echo.Context) error { client, err := GetClientId(c) if err != nil { return err } - path, err := GetPath(c) + path, sha, err := GetPath(c) if err != nil { return err } - ret, err := h.transcoder.GetMaster(path, client, GetRoute(c)) + ret, err := h.transcoder.GetMaster(path, client, sha) if err != nil { return err } @@ -55,7 +55,7 @@ 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: /:quality/index.m3u8 +// Path: /:path/:quality/index.m3u8 func (h *Handler) GetVideoIndex(c echo.Context) error { quality, err := src.QualityFromString(c.Param("quality")) if err != nil { @@ -65,12 +65,12 @@ func (h *Handler) GetVideoIndex(c echo.Context) error { if err != nil { return err } - path, err := GetPath(c) + path, sha, err := GetPath(c) if err != nil { return err } - ret, err := h.transcoder.GetVideoIndex(path, quality, client, GetRoute(c)) + ret, err := h.transcoder.GetVideoIndex(path, quality, client, sha) if err != nil { return err } @@ -83,7 +83,7 @@ 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: /audio/:audio/index.m3u8 +// Path: /:path/audio/:audio/index.m3u8 func (h *Handler) GetAudioIndex(c echo.Context) error { audio, err := strconv.ParseInt(c.Param("audio"), 10, 32) if err != nil { @@ -93,12 +93,12 @@ func (h *Handler) GetAudioIndex(c echo.Context) error { if err != nil { return err } - path, err := GetPath(c) + path, sha, err := GetPath(c) if err != nil { return err } - ret, err := h.transcoder.GetAudioIndex(path, int32(audio), client, GetRoute(c)) + ret, err := h.transcoder.GetAudioIndex(path, int32(audio), client, sha) if err != nil { return err } @@ -109,7 +109,7 @@ func (h *Handler) GetAudioIndex(c echo.Context) error { // // Retrieve a chunk of a transmuxed video. // -// Path: /:quality/segments-:chunk.ts +// Path: /:path/:quality/segments-:chunk.ts func (h *Handler) GetVideoSegment(c echo.Context) error { quality, err := src.QualityFromString(c.Param("quality")) if err != nil { @@ -123,12 +123,12 @@ func (h *Handler) GetVideoSegment(c echo.Context) error { if err != nil { return err } - path, err := GetPath(c) + path, sha, err := GetPath(c) if err != nil { return err } - ret, err := h.transcoder.GetVideoSegment(path, quality, segment, client, GetRoute(c)) + ret, err := h.transcoder.GetVideoSegment(path, quality, segment, client, sha) if err != nil { return err } @@ -139,7 +139,7 @@ func (h *Handler) GetVideoSegment(c echo.Context) error { // // Retrieve a chunk of a transcoded audio. // -// Path: /audio/:audio/segments-:chunk.ts +// Path: /:path/audio/:audio/segments-:chunk.ts func (h *Handler) GetAudioSegment(c echo.Context) error { audio, err := strconv.ParseInt(c.Param("audio"), 10, 32) if err != nil { @@ -153,12 +153,12 @@ func (h *Handler) GetAudioSegment(c echo.Context) error { if err != nil { return err } - path, err := GetPath(c) + path, sha, err := GetPath(c) if err != nil { return err } - ret, err := h.transcoder.GetAudioSegment(path, int32(audio), segment, client, GetRoute(c)) + ret, err := h.transcoder.GetAudioSegment(path, int32(audio), segment, client, sha) if err != nil { return err } @@ -169,29 +169,20 @@ func (h *Handler) GetAudioSegment(c echo.Context) error { // // Identify metadata about a file. // -// Path: /info +// Path: /:path/info func (h *Handler) GetInfo(c echo.Context) error { - path, err := GetPath(c) + path, sha, err := GetPath(c) if err != nil { return err } - route := GetRoute(c) - sha, err := src.GetHash(path) - if err != nil { - return err - } - ret, err := src.GetInfo(path, sha, route) + ret, err := src.GetInfo(path, sha) if err != nil { return err } // Run extractors to have them in cache - src.Extract(ret.Path, sha, route) - go src.ExtractThumbnail( - ret.Path, - route, - sha, - ) + src.Extract(ret.Path, sha) + go src.ExtractThumbnail(ret.Path, sha) return c.JSON(http.StatusOK, ret) } @@ -199,9 +190,9 @@ func (h *Handler) GetInfo(c echo.Context) error { // // Get a specific attachment. // -// Path: /attachment/:name +// Path: /:path/attachment/:name func (h *Handler) GetAttachment(c echo.Context) error { - path, err := GetPath(c) + path, sha, err := GetPath(c) if err != nil { return err } @@ -210,12 +201,7 @@ func (h *Handler) GetAttachment(c echo.Context) error { return err } - route := GetRoute(c) - sha, err := src.GetHash(path) - if err != nil { - return err - } - wait, err := src.Extract(path, sha, route) + wait, err := src.Extract(path, sha) if err != nil { return err } @@ -229,9 +215,9 @@ func (h *Handler) GetAttachment(c echo.Context) error { // // Get a specific subtitle. // -// Path: /subtitle/:name +// Path: /:path/subtitle/:name func (h *Handler) GetSubtitle(c echo.Context) error { - path, err := GetPath(c) + path, sha, err := GetPath(c) if err != nil { return err } @@ -240,12 +226,7 @@ func (h *Handler) GetSubtitle(c echo.Context) error { return err } - route := GetRoute(c) - sha, err := src.GetHash(path) - if err != nil { - return err - } - wait, err := src.Extract(path, sha, route) + wait, err := src.Extract(path, sha) if err != nil { return err } @@ -259,22 +240,14 @@ func (h *Handler) GetSubtitle(c echo.Context) error { // // Get a sprite file containing all the thumbnails of the show. // -// Path: /thumbnails.png +// Path: /:path/thumbnails.png func (h *Handler) GetThumbnails(c echo.Context) error { - path, err := GetPath(c) - if err != nil { - return err - } - sha, err := src.GetHash(path) + path, sha, err := GetPath(c) if err != nil { return err } - out, err := src.ExtractThumbnail( - path, - GetRoute(c), - sha, - ) + out, err := src.ExtractThumbnail(path, sha) if err != nil { return err } @@ -287,22 +260,14 @@ func (h *Handler) GetThumbnails(c echo.Context) error { // Get a vtt file containing timing/position of thumbnails inside the sprite file. // https://developer.bitmovin.com/playback/docs/webvtt-based-thumbnails for more info. // -// Path: /:resource/:slug/thumbnails.vtt +// Path: /:path/:resource/:slug/thumbnails.vtt func (h *Handler) GetThumbnailsVtt(c echo.Context) error { - path, err := GetPath(c) - if err != nil { - return err - } - sha, err := src.GetHash(path) + path, sha, err := GetPath(c) if err != nil { return err } - out, err := src.ExtractThumbnail( - path, - GetRoute(c), - sha, - ) + out, err := src.ExtractThumbnail(path, sha) if err != nil { return err } @@ -328,17 +293,17 @@ func main() { transcoder: transcoder, } - 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("/thumbnails.png", h.GetThumbnails) - e.GET("/thumbnails.vtt", h.GetThumbnailsVtt) - e.GET("/attachment/:name", h.GetAttachment) - e.GET("/subtitle/:name", h.GetSubtitle) + e.GET("/:path/direct", DirectStream) + e.GET("/:path/master.m3u8", h.GetMaster) + e.GET("/:path/:quality/index.m3u8", h.GetVideoIndex) + e.GET("/:path/audio/:audio/index.m3u8", h.GetAudioIndex) + e.GET("/:path/:quality/:chunk", h.GetVideoSegment) + e.GET("/:path/audio/:audio/:chunk", h.GetAudioSegment) + e.GET("/:path/info", h.GetInfo) + e.GET("/:path/thumbnails.png", h.GetThumbnails) + e.GET("/:path/thumbnails.vtt", h.GetThumbnailsVtt) + e.GET("/:path/attachment/:name", h.GetAttachment) + e.GET("/:path/subtitle/:name", h.GetSubtitle) e.Logger.Fatal(e.Start(":7666")) } diff --git a/transcoder/src/extract.go b/transcoder/src/extract.go index 55478dfc..cb6b77f7 100644 --- a/transcoder/src/extract.go +++ b/transcoder/src/extract.go @@ -9,7 +9,7 @@ import ( var extracted = NewCMap[string, <-chan struct{}]() -func Extract(path string, sha string, route string) (<-chan struct{}, error) { +func Extract(path string, sha string) (<-chan struct{}, error) { ret := make(chan struct{}) existing, created := extracted.GetOrSet(sha, ret) if !created { @@ -18,7 +18,7 @@ func Extract(path string, sha string, route string) (<-chan struct{}, error) { go func() { defer printExecTime("Starting extraction of %s", path)() - info, err := GetInfo(path, sha, route) + info, err := GetInfo(path, sha) if err != nil { extracted.Remove(sha) close(ret) diff --git a/transcoder/src/filestream.go b/transcoder/src/filestream.go index 984cafb1..f5fd2cfa 100644 --- a/transcoder/src/filestream.go +++ b/transcoder/src/filestream.go @@ -19,7 +19,7 @@ type FileStream struct { audios CMap[int32, *AudioStream] } -func NewFileStream(path string, sha string, route string) *FileStream { +func NewFileStream(path string, sha string) *FileStream { ret := &FileStream{ Path: path, Out: fmt.Sprintf("%s/%s", Settings.Outpath, sha), @@ -30,7 +30,7 @@ func NewFileStream(path string, sha string, route string) *FileStream { ret.ready.Add(1) go func() { defer ret.ready.Done() - info, err := GetInfo(path, sha, route) + info, err := GetInfo(path, sha) ret.Info = info if err != nil { ret.err = err diff --git a/transcoder/src/info.go b/transcoder/src/info.go index 75f4a80f..b79fdeb8 100644 --- a/transcoder/src/info.go +++ b/transcoder/src/info.go @@ -1,6 +1,7 @@ package src import ( + "encoding/base64" "encoding/json" "fmt" "io" @@ -180,7 +181,7 @@ type MICache struct { var infos = NewCMap[string, *MICache]() -func GetInfo(path string, sha string, route string) (*MediaInfo, error) { +func GetInfo(path string, sha string) (*MediaInfo, error) { var err error ret, _ := infos.GetOrCreate(sha, func() *MICache { @@ -195,7 +196,7 @@ func GetInfo(path string, sha string, route string) (*MediaInfo, error) { } var val *MediaInfo - val, err = getInfo(path, route) + val, err = getInfo(path) *mi.info = *val mi.info.Sha = sha mi.ready.Done() @@ -231,7 +232,7 @@ func saveInfo[T any](save_path string, mi *T) error { return os.WriteFile(save_path, content, 0o644) } -func getInfo(path string, route string) (*MediaInfo, error) { +func getInfo(path string) (*MediaInfo, error) { defer printExecTime("mediainfo for %s", path)() mi, err := mediainfo.Open(path) @@ -294,7 +295,7 @@ func getInfo(path string, route string) (*MediaInfo, error) { extension := OrNull(SubtitleExtensions[format]) var link *string if extension != nil { - x := fmt.Sprintf("%s/subtitle/%d.%s", route, i, *extension) + x := fmt.Sprintf("%s/%s/subtitle/%d.%s", Settings.RoutePrefix, base64.StdEncoding.EncodeToString([]byte(path)), i, *extension) link = &x } return Subtitle{ @@ -319,7 +320,7 @@ func getInfo(path string, route string) (*MediaInfo, error) { Fonts: Map( attachments, func(font string, _ int) string { - return fmt.Sprintf("%s/attachment/%s", route, font) + return fmt.Sprintf("%s/%s/attachment/%s", Settings.RoutePrefix, base64.StdEncoding.EncodeToString([]byte(path)), font) }), } if len(ret.Videos) > 0 { diff --git a/transcoder/src/settings.go b/transcoder/src/settings.go index c6af123d..8039759b 100644 --- a/transcoder/src/settings.go +++ b/transcoder/src/settings.go @@ -11,9 +11,10 @@ func GetEnvOr(env string, def string) string { } type SettingsT struct { - Outpath string - Metadata string - HwAccel HwAccelT + Outpath string + Metadata string + RoutePrefix string + HwAccel HwAccelT } type HwAccelT struct { @@ -24,7 +25,8 @@ type HwAccelT struct { } var Settings = SettingsT{ - Outpath: GetEnvOr("GOCODER_CACHE_ROOT", "/cache"), - Metadata: GetEnvOr("GOCODER_METADATA_ROOT", "/metadata"), - HwAccel: DetectHardwareAccel(), + Outpath: GetEnvOr("GOCODER_CACHE_ROOT", "/cache"), + Metadata: GetEnvOr("GOCODER_METADATA_ROOT", "/metadata"), + RoutePrefix: GetEnvOr("GOCODER_PREFIX", ""), + HwAccel: DetectHardwareAccel(), } diff --git a/transcoder/src/thumbnails.go b/transcoder/src/thumbnails.go index ed8e24c5..fde155b9 100644 --- a/transcoder/src/thumbnails.go +++ b/transcoder/src/thumbnails.go @@ -1,6 +1,7 @@ package src import ( + "encoding/base64" "fmt" "image" "image/color" @@ -27,14 +28,14 @@ type Thumbnail struct { var thumbnails = NewCMap[string, *Thumbnail]() -func ExtractThumbnail(path string, route string, sha string) (string, error) { +func ExtractThumbnail(path string, sha string) (string, error) { ret, _ := thumbnails.GetOrCreate(sha, func() *Thumbnail { ret := &Thumbnail{ path: fmt.Sprintf("%s/%s", Settings.Metadata, sha), } ret.ready.Add(1) go func() { - extractThumbnail(path, ret.path, fmt.Sprintf("%s/thumbnails.png", route)) + extractThumbnail(path, ret.path) ret.ready.Done() }() return ret @@ -43,7 +44,7 @@ func ExtractThumbnail(path string, route string, sha string) (string, error) { return ret.path, nil } -func extractThumbnail(path string, out string, name string) error { +func extractThumbnail(path string, out string) error { defer printExecTime("extracting thumbnails for %s", path)() os.MkdirAll(out, 0o755) sprite_path := fmt.Sprintf("%s/sprite.png", out) @@ -97,10 +98,11 @@ func extractThumbnail(path string, out string, name string) error { timestamps := ts ts += interval vtt += fmt.Sprintf( - "%s --> %s\n%s#xywh=%d,%d,%d,%d\n\n", + "%s --> %s\n%s/%s/thumbnails.png#xywh=%d,%d,%d,%d\n\n", tsToVttTime(timestamps), tsToVttTime(ts), - name, + Settings.RoutePrefix, + base64.StdEncoding.EncodeToString([]byte(path)), x, y, width, diff --git a/transcoder/src/transcoder.go b/transcoder/src/transcoder.go index 53cec7b1..1dffecb6 100644 --- a/transcoder/src/transcoder.go +++ b/transcoder/src/transcoder.go @@ -33,14 +33,10 @@ func NewTranscoder() (*Transcoder, error) { return ret, nil } -func (t *Transcoder) getFileStream(path string, route string) (*FileStream, error) { +func (t *Transcoder) getFileStream(path string, sha string) (*FileStream, error) { var err error ret, _ := t.streams.GetOrCreate(path, func() *FileStream { - sha, err := GetHash(path) - if err != nil { - return nil - } - return NewFileStream(path, sha, route) + return NewFileStream(path, sha) }) ret.ready.Wait() if err != nil || ret.err != nil { @@ -50,8 +46,8 @@ func (t *Transcoder) getFileStream(path string, route string) (*FileStream, erro return ret, nil } -func (t *Transcoder) GetMaster(path string, client string, route string) (string, error) { - stream, err := t.getFileStream(path, route) +func (t *Transcoder) GetMaster(path string, client string, sha string) (string, error) { + stream, err := t.getFileStream(path, sha) if err != nil { return "", err } @@ -69,9 +65,9 @@ func (t *Transcoder) GetVideoIndex( path string, quality Quality, client string, - route string, + sha string, ) (string, error) { - stream, err := t.getFileStream(path, route) + stream, err := t.getFileStream(path, sha) if err != nil { return "", err } @@ -89,9 +85,9 @@ func (t *Transcoder) GetAudioIndex( path string, audio int32, client string, - route string, + sha string, ) (string, error) { - stream, err := t.getFileStream(path, route) + stream, err := t.getFileStream(path, sha) if err != nil { return "", err } @@ -109,9 +105,9 @@ func (t *Transcoder) GetVideoSegment( quality Quality, segment int32, client string, - route string, + sha string, ) (string, error) { - stream, err := t.getFileStream(path, route) + stream, err := t.getFileStream(path, sha) if err != nil { return "", err } @@ -130,9 +126,9 @@ func (t *Transcoder) GetAudioSegment( audio int32, segment int32, client string, - route string, + sha string, ) (string, error) { - stream, err := t.getFileStream(path, route) + stream, err := t.getFileStream(path, sha) if err != nil { return "", err } diff --git a/transcoder/src/utils.go b/transcoder/src/utils.go index 9fd84e8b..5277def3 100644 --- a/transcoder/src/utils.go +++ b/transcoder/src/utils.go @@ -1,11 +1,8 @@ package src import ( - "crypto/sha1" - "encoding/hex" "fmt" "log" - "os" "time" ) @@ -18,15 +15,3 @@ func printExecTime(message string, args ...any) func() { log.Printf("%s finished in %s", msg, time.Since(start)) } } - -func GetHash(path string) (string, error) { - info, err := os.Stat(path) - if err != nil { - return "", err - } - h := sha1.New() - h.Write([]byte(path)) - h.Write([]byte(info.ModTime().String())) - sha := hex.EncodeToString(h.Sum(nil)) - return sha, nil -} diff --git a/transcoder/utils.go b/transcoder/utils.go index 4eaf7d4e..e68a6b93 100644 --- a/transcoder/utils.go +++ b/transcoder/utils.go @@ -1,30 +1,60 @@ package main import ( + "crypto/sha1" + "encoding/base64" + "encoding/hex" "fmt" "net/http" + "os" + "path/filepath" "strings" - "time" "github.com/labstack/echo/v4" + "github.com/zoriya/kyoo/transcoder/src" ) -var client = &http.Client{Timeout: 10 * time.Second} +var safe_path = src.GetEnvOr("GOCODER_SAFE_PATH", "/video") -type Item struct { - Path string `json:"path"` -} +// Encode the version in the hash path to update cached values. +// Older versions won't be deleted (needed to allow multiples versions of the transcoder to run at the same time) +// If the version changes a lot, we might want to automatically delete older versions. +var version = "v1." -func GetPath(c echo.Context) (string, error) { - key := c.Request().Header.Get("X-Path") +func GetPath(c echo.Context) (string, string, error) { + key := c.Param("path") if key == "" { - return "", echo.NewHTTPError(http.StatusBadRequest, "Missing resouce path. You need to set the X-Path header.") + return "", "", echo.NewHTTPError(http.StatusBadRequest, "Missing resouce path.") } - return key, nil + pathb, err := base64.StdEncoding.DecodeString(key) + if err != nil { + return "", "", echo.NewHTTPError(http.StatusBadRequest, "Invalid path. Should be base64 encoded.") + } + path := string(pathb) + if !filepath.IsAbs(path) { + return "", "", echo.NewHTTPError(http.StatusBadRequest, "Absolute path required.") + } + if !strings.HasPrefix(path, safe_path) { + return "", "", echo.NewHTTPError(http.StatusBadRequest, "Selected path is not marked as safe.") + } + hash, err := getHash(path) + if err != nil { + return "", "", echo.NewHTTPError(http.StatusNotFound, "File does not exist") + } + + return path, hash, nil } -func GetRoute(c echo.Context) string { - return c.Request().Header.Get("X-Route") +func getHash(path string) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", err + } + h := sha1.New() + h.Write([]byte(path)) + h.Write([]byte(info.ModTime().String())) + sha := hex.EncodeToString(h.Sum(nil)) + return version + sha, nil } func SanitizePath(path string) error {