8 Commits

Author SHA1 Message Date
cc9b6e69c8 Format code 2024-01-19 18:38:02 +01:00
53ce929fa7 Fix zod infering of queries 2024-01-19 18:11:19 +01:00
5fe75d5753 Run go mod tidy 2024-01-19 18:04:49 +01:00
750f9da9e8 Add a fast path for original quality 2024-01-19 17:44:14 +01:00
2551d5071b Add offline route 2024-01-19 17:43:01 +01:00
c0f6b5a85f Add a new offline route 2024-01-19 14:35:43 +01:00
523406f269 Use new download route and use selected quality 2024-01-19 14:23:07 +01:00
fac0528148 Add quality selector in settings 2024-01-19 14:20:24 +01:00
15 changed files with 316 additions and 25 deletions

View File

@@ -24,10 +24,9 @@ import {
QueryClient,
QueryFunctionContext,
useInfiniteQuery,
useMutation,
useQuery,
} from "@tanstack/react-query";
import { z } from "zod";
import { ZodType, z } from "zod";
import { KyooErrors } from "./kyoo-errors";
import { Page, Paged } from "./page";
import { Platform } from "react-native";
@@ -39,7 +38,7 @@ const kyooUrl =
// The url of kyoo, set after each query (used by the image parser).
export let kyooApiUrl = kyooUrl;
export const queryFn = async <Data,>(
export const queryFn = async <Data extends ZodType>(
context:
| (QueryFunctionContext & { timeout?: number; apiUrl?: string })
| {
@@ -50,9 +49,9 @@ export const queryFn = async <Data,>(
apiUrl?: string;
timeout?: number;
},
type?: z.ZodType<Data>,
type?: Data,
token?: string | null,
): Promise<Data> => {
): Promise<z.infer<Data>> => {
const url = context.apiUrl ?? (Platform.OS === "web" ? kyooUrl : getCurrentAccount()!.apiUrl);
kyooApiUrl = url;
@@ -109,7 +108,6 @@ export const queryFn = async <Data,>(
throw data as KyooErrors;
}
// @ts-expect-error Assume Data is nullable.
if (resp.status === 204) return null;
let data;

View File

@@ -34,6 +34,29 @@ export const UserP = ResourceP("user").extend({
* The list of permissions of the user. The format of this is implementation dependent.
*/
permissions: z.array(z.string()),
/**
* User settings
*/
settings: z
.object({
downloadQuality: z
.union([
z.literal("original"),
z.literal("8k"),
z.literal("4k"),
z.literal("1440p"),
z.literal("1080p"),
z.literal("720p"),
z.literal("480p"),
z.literal("360p"),
z.literal("240p"),
])
.default("original")
.catch("original"),
})
.catchall(z.string())
// keep a default for older versions of the api
.default({}),
});
export type User = z.infer<typeof UserP>;

View File

@@ -37,11 +37,12 @@ export const useDownloader = () => {
query.parser,
account?.token.access_token,
);
const quality = account?.settings.downloadQuality ?? "original";
// TODO: This methods does not work with auth.
const a = document.createElement("a");
a.style.display = "none";
a.href = `${kyooApiUrl}/video/${type}/${slug}/direct`;
a.href = `${kyooApiUrl}/video/${type}/${slug}/offline?quality=${quality}`;
a.download = `${slug}.${info.extension}`;
document.body.appendChild(a);
a.click();

View File

@@ -186,8 +186,7 @@ const download = (
const path = `${RNBackgroundDownloader.directories.documents}/${slug}-${id}.${extension}`;
const task = RNBackgroundDownloader.download({
id: id,
// TODO: support variant qualities
url: `${account.apiUrl}/video/${type}/${slug}/direct`,
url: `${account.apiUrl}/video/${type}/${slug}/offline?quality=${account.settings.downloadQuality}`,
destination: path,
headers: {
Authorization: account.token.access_token,

View File

@@ -61,6 +61,7 @@ import Mail from "@material-symbols/svg-400/outlined/mail.svg";
import Password from "@material-symbols/svg-400/outlined/password.svg";
import Logout from "@material-symbols/svg-400/rounded/logout.svg";
import Delete from "@material-symbols/svg-400/rounded/delete.svg";
import Quality from "@material-symbols/svg-400/rounded/high_quality.svg";
import Android from "@material-symbols/svg-400/rounded/android.svg";
import Public from "@material-symbols/svg-400/rounded/public.svg";
@@ -386,6 +387,43 @@ const AccountSettings = ({ setPopup }: { setPopup: (e?: ReactElement) => void })
);
};
const DownloadSettings = () => {
const { t } = useTranslation();
const account = useAccount();
const queryClient = useQueryClient();
const { mutateAsync } = useMutation({
mutationFn: async (update: Partial<Account>) =>
await queryFn({
path: ["auth", "me"],
method: "PATCH",
body: update,
}),
onSettled: async () => await queryClient.invalidateQueries({ queryKey: ["auth", "me"] }),
});
if (!account) return null;
return (
<SettingsContainer title={t("settings.downloads.label")}>
<Preference
icon={Quality}
label={t("settings.downloads.quality.label")}
description={t("settings.downloads.quality.description")}
>
<Select
label={t("settings.downloads.quality.label")}
value={account.settings.downloadQuality}
onValueChange={(value) =>
mutateAsync({ settings: { ...account.settings, downloadQuality: value } })
}
values={["original", "8k", "4k", "1440p", "1080p", "720p", "480p", "360p", "240p"]}
getLabel={(key) => (key === "original" ? t("player.direct") : key)}
/>
</Preference>
</SettingsContainer>
);
};
export const SettingsPage: QueryPage = () => {
const { t, i18n } = useTranslation();
const languages = new Intl.DisplayNames([i18n.language ?? "en"], { type: "language" });
@@ -428,6 +466,7 @@ export const SettingsPage: QueryPage = () => {
</Preference>
</SettingsContainer>
<AccountSettings setPopup={setPopup} />
<DownloadSettings />
<SettingsContainer title={t("settings.about.label")}>
<Link
href="https://github.com/zoriya/kyoo/releases/latest/download/kyoo.apk"

View File

@@ -110,6 +110,13 @@
"newPassword": "New password"
}
},
"downloads": {
"label": "Downloads",
"quality": {
"label": "Quality",
"description": "Movies and episodes downloaded will automatically be converted and downloaded in the selected quality"
}
},
"about": {
"label": "About",
"android-app": {

View File

@@ -110,6 +110,13 @@
"newPassword": "Nouveau mot de passe"
}
},
"downloads": {
"label": "Téléchargements",
"quality": {
"label": "Qualité",
"description": "Les films et épisodes téléchargés seront automatiquement convertis et téléchargés dans la qualité sélectionnée"
}
},
"about": {
"label": "À propos",
"android-app": {

View File

@@ -4,6 +4,8 @@ go 1.21
require github.com/labstack/echo/v4 v4.11.4 // direct
require github.com/zoriya/go-mediainfo v0.0.0-20240113000440-36f500affcfd
require (
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/labstack/gommon v0.4.2 // indirect
@@ -11,7 +13,6 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/zoriya/go-mediainfo v0.0.0-20240113000440-36f500affcfd // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.15.0 // indirect

View File

@@ -1,3 +1,5 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
@@ -9,12 +11,14 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/zoriya/go-mediainfo v0.0.0-20240112235842-ce0c807be738 h1:FV9TIvf/T84cRxRdBN6brSWKq+PqrGHmqbeDhmI+3tc=
github.com/zoriya/go-mediainfo v0.0.0-20240112235842-ce0c807be738/go.mod h1:jzun1oQGoJSh65g1XKaolTmjd6HW/34WHH7VMdJdbvM=
github.com/zoriya/go-mediainfo v0.0.0-20240113000440-36f500affcfd h1:AOdEpcmYJkmIW4I76TQim6LT4+9duYTdXNgkQsPHpuA=
github.com/zoriya/go-mediainfo v0.0.0-20240113000440-36f500affcfd/go.mod h1:jzun1oQGoJSh65g1XKaolTmjd6HW/34WHH7VMdJdbvM=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
@@ -29,3 +33,5 @@ golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -28,6 +28,33 @@ func DirectStream(c echo.Context) error {
return c.File(path)
}
// Download item
//
// Transcode the video/audio to the selected quality for offline use.
// This route will be slow and stream an incomplete file, this is not meant to be used while
// streaming.
//
// Path: /:resource/:slug/offline?quality=:quality
func (h *Handler) GetOffline(c echo.Context) error {
resource := c.Param("resource")
slug := c.Param("slug")
quality, err := src.QualityFromString(c.QueryParam("quality"))
if err != nil {
return err
}
path, err := GetPath(resource, slug)
if err != nil {
return err
}
ret, path, err := h.downloader.GetOffline(path, quality)
if err != nil {
return err
}
return ServeOfflineFile(path, ret, c)
}
// Get master playlist
//
// Get a master playlist containing all possible video qualities and audios available for this resource.
@@ -265,6 +292,7 @@ func (h *Handler) GetSubtitle(c echo.Context) error {
type Handler struct {
transcoder *src.Transcoder
extractor *src.Extractor
downloader *src.Downloader
}
func main() {
@@ -277,9 +305,14 @@ func main() {
e.Logger.Fatal(err)
return
}
h := Handler{transcoder: transcoder, extractor: src.NewExtractor()}
h := Handler{
transcoder: transcoder,
extractor: src.NewExtractor(),
downloader: src.NewDownloader(),
}
e.GET("/:resource/:slug/direct", DirectStream)
e.GET("/:resource/:slug/offline", h.GetOffline)
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)

View File

@@ -0,0 +1,101 @@
package src
import (
"fmt"
"log"
"os/exec"
"sync"
)
type Key struct {
path string
quality Quality
}
type Value struct {
done chan struct{}
path string
}
type Downloader struct {
processing map[Key]Value
lock sync.Mutex
}
func NewDownloader() *Downloader {
return &Downloader{
processing: make(map[Key]Value),
}
}
func (d *Downloader) GetOffline(path string, quality Quality) (<-chan struct{}, string, error) {
if quality == Original {
// no need to do anything for original quality
done := make(chan struct{})
close(done)
return done, path, nil
}
key := Key{path, quality}
d.lock.Lock()
defer d.lock.Unlock()
existing, ok := d.processing[key]
if ok {
return existing.done, existing.path, nil
}
info, err := GetInfo(path)
if err != nil {
return nil, "", err
}
outpath := fmt.Sprintf("%s/dl-%s-%s.mkv", GetOutPath(), info.Sha, quality)
ret := make(chan struct{})
d.processing[key] = Value{ret, outpath}
go func() {
cmd := exec.Command(
"ffmpeg",
"-nostats", "-hide_banner", "-loglevel", "warning",
"-i", path,
)
cmd.Args = append(cmd.Args, quality.getTranscodeArgs(nil)...)
// TODO: add custom audio settings depending on quality
cmd.Args = append(cmd.Args,
"-map", "0:a?",
"-c:a", "aac",
"-ac", "2",
"-b:a", "128k",
)
// also include subtitles, font attachments and chapters.
cmd.Args = append(cmd.Args,
"-map", "0:s?", "-c:s", "copy",
"-map", "0:d?",
"-map", "0:t?",
)
cmd.Args = append(cmd.Args, outpath)
log.Printf(
"Starting offline transcode (quality %s) of %s with the command: %s",
quality,
path,
cmd,
)
cmd.Stdout = nil
err := cmd.Run()
if err != nil {
log.Println("Error starting ffmpeg extract:", err)
// TODO: find a way to inform listeners that there was an error
d.lock.Lock()
delete(d.processing, key)
d.lock.Unlock()
} else {
log.Println("Transcode finished")
}
close(ret)
}()
return ret, outpath, nil
}

View File

@@ -49,14 +49,15 @@ func (e *Extractor) RunExtractor(path string, sha string, subs *[]Subtitle) <-ch
e.lock.Unlock()
go func() {
attachment_path := fmt.Sprintf("%s/%s/att/", GetMetadataPath(), sha)
subs_path := fmt.Sprintf("%s/%s/sub/", GetMetadataPath(), sha)
attachment_path := fmt.Sprintf("%s/%s/att", GetMetadataPath(), sha)
subs_path := fmt.Sprintf("%s/%s/sub", GetMetadataPath(), sha)
os.MkdirAll(attachment_path, 0o644)
os.MkdirAll(subs_path, 0o644)
fmt.Printf("Extract subs and fonts for %s", path)
cmd := exec.Command(
"ffmpeg",
"-nostats", "-hide_banner", "-loglevel", "warning",
"-dump_attachment:t", "",
"-i", path,
)

View File

@@ -111,7 +111,7 @@ func (ts *Stream) run(start int32) error {
"-copyts",
}
args = append(args, ts.handle.getTranscodeArgs(segments_str)...)
args = append(args, []string{
args = append(args,
"-f", "segment",
"-segment_time_delta", "0.2",
"-segment_format", "mpegts",
@@ -120,7 +120,7 @@ func (ts *Stream) run(start int32) error {
"-segment_list_type", "flat",
"-segment_list", "pipe:1",
outpath,
}...)
)
cmd := exec.Command("ffmpeg", args...)
log.Printf("Running %s", strings.Join(cmd.Args, " "))

View File

@@ -23,22 +23,32 @@ func (vs *VideoStream) getOutPath() string {
}
func (vs *VideoStream) getTranscodeArgs(segments string) []string {
if vs.quality == Original {
return vs.quality.getTranscodeArgs(&segments)
}
func (quality Quality) getTranscodeArgs(segments *string) []string {
if quality == Original {
return []string{"-map", "0:V:0", "-c:v", "copy"}
}
return []string{
ret := []string{
// superfast or ultrafast would produce a file extremly big so we prever veryfast or faster.
"-map", "0:V:0", "-c:v", "libx264", "-crf", "21", "-preset", "faster",
// resize but keep aspect ratio (also force a width that is a multiple of two else some apps behave badly.
"-vf", fmt.Sprintf("scale=-2:'min(%d,ih)'", vs.quality.Height()),
"-vf", fmt.Sprintf("scale=-2:'min(%d,ih)'", quality.Height()),
// Even less sure but bufsize are 5x the avergae bitrate since the average bitrate is only
// useful for hls segments.
"-bufsize", fmt.Sprint(vs.quality.MaxBitrate() * 5),
"-b:v", fmt.Sprint(vs.quality.AverageBitrate()),
"-maxrate", fmt.Sprint(vs.quality.MaxBitrate()),
// Force segments to be split exactly on keyframes (only works when transcoding)
"-force_key_frames", segments,
"-bufsize", fmt.Sprint(quality.MaxBitrate() * 5),
"-b:v", fmt.Sprint(quality.AverageBitrate()),
"-maxrate", fmt.Sprint(quality.MaxBitrate()),
"-strict", "-2",
}
if segments != nil {
ret = append(ret,
// Force segments to be split exactly on keyframes (only works when transcoding)
"-force_key_frames", *segments,
)
}
return ret
}

View File

@@ -4,6 +4,8 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
@@ -94,3 +96,66 @@ func ErrorHandler(err error, c echo.Context) {
Errors []string `json:"errors"`
}{Errors: []string{message}})
}
func ServeOfflineFile(path string, done <-chan struct{}, c echo.Context) error {
select {
case <-done:
// if the transcode is already finished, no need to do anything, just return the file
return c.File(path)
default:
break
}
var f *os.File
var err error
for {
// wait for the file to be created by the transcoder.
f, err = os.Open(path)
if err == nil {
break
}
time.Sleep(2 * time.Second)
}
defer f.Close()
// Offline transcoding always return a mkv and video/webm allow some browser to play a mkv video
c.Response().Header().Set(echo.HeaderContentType, "video/webm")
c.Response().Header().Set("Trailer", echo.HeaderContentLength)
c.Response().WriteHeader(http.StatusOK)
buffer := make([]byte, 1024)
not_done := true
for not_done {
select {
case <-done:
info, err := f.Stat()
if err != nil {
log.Printf("Stats error %s", err)
return err
}
c.Response().Header().Set(echo.HeaderContentLength, fmt.Sprint(info.Size()))
c.Response().WriteHeader(http.StatusOK)
not_done = false
case <-time.After(5 * time.Second):
}
read:
for {
size, err := f.Read(buffer)
if size == 0 && err == io.EOF {
break read
}
if err != nil && err != io.EOF {
return err
}
_, err = c.Response().Writer.Write(buffer[:size])
if err != nil {
log.Printf("Could not write transcoded file to response.")
return nil
}
}
c.Response().Flush()
}
return nil
}