mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-12-06 06:36:25 +00:00
Compare commits
8 Commits
feat/hls
...
feat/goqua
| Author | SHA1 | Date | |
|---|---|---|---|
| cc9b6e69c8 | |||
| 53ce929fa7 | |||
| 5fe75d5753 | |||
| 750f9da9e8 | |||
| 2551d5071b | |||
| c0f6b5a85f | |||
| 523406f269 | |||
| fac0528148 |
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
101
transcoder/src/downloader.go
Normal file
101
transcoder/src/downloader.go
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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, " "))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user