0a793f7d3f
This works for me, but for a more public site, I think I'll also add a boolean attribute named "upload_allowed" and "writable" to Config.Library.. Something to allow you to configure which directories can receive new uploads, and which directories we consider writable for other purposes (like editing meta data in yml, creating new thumbnails, ...) Co-authored-by: Heinrich Langos <gumbo2000@noreply@mills.io> Reviewed-on: https://git.mills.io/prologic/tube/pulls/39 Co-authored-by: Heinrich 'Henrik' Langos <gumbo2000@noreply@mills.io> Co-committed-by: Heinrich 'Henrik' Langos <gumbo2000@noreply@mills.io>
740 lines
19 KiB
Go
740 lines
19 KiB
Go
// Package app manages main application server.
|
|
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"io/ioutil"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.mills.io/prologic/tube/app/middleware"
|
|
"git.mills.io/prologic/tube/importers"
|
|
"git.mills.io/prologic/tube/media"
|
|
"git.mills.io/prologic/tube/static"
|
|
"git.mills.io/prologic/tube/templates"
|
|
"git.mills.io/prologic/tube/utils"
|
|
|
|
"github.com/dustin/go-humanize"
|
|
"github.com/fsnotify/fsnotify"
|
|
"github.com/gorilla/handlers"
|
|
"github.com/gorilla/mux"
|
|
shortuuid "github.com/lithammer/shortuuid/v3"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
// App represents main application.
|
|
type App struct {
|
|
Config *Config
|
|
Library *media.Library
|
|
Store Store
|
|
Watcher *fsnotify.Watcher
|
|
Templates *templateStore
|
|
Feed []byte
|
|
Listener net.Listener
|
|
Router *mux.Router
|
|
}
|
|
|
|
// NewApp returns a new instance of App from Config.
|
|
func NewApp(cfg *Config) (*App, error) {
|
|
if cfg == nil {
|
|
cfg = DefaultConfig()
|
|
}
|
|
a := &App{
|
|
Config: cfg,
|
|
}
|
|
// Setup Library
|
|
a.Library = media.NewLibrary()
|
|
// Setup Store
|
|
store, err := NewBitcaskStore(cfg.Server.StorePath)
|
|
if err != nil {
|
|
err := fmt.Errorf("error opening store %s: %w", cfg.Server.StorePath, err)
|
|
return nil, err
|
|
}
|
|
a.Store = store
|
|
// Setup Watcher
|
|
w, err := fsnotify.NewWatcher()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a.Watcher = w
|
|
// Setup Listener
|
|
ln, err := newListener(cfg.Server)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a.Listener = ln
|
|
|
|
// Templates
|
|
|
|
a.Templates = newTemplateStore("base")
|
|
|
|
templateFuncs := map[string]interface{}{
|
|
"bytes": func(size int64) string { return humanize.Bytes(uint64(size)) },
|
|
}
|
|
|
|
indexTemplate := template.New("index").Funcs(templateFuncs)
|
|
template.Must(indexTemplate.Parse(templates.MustGetTemplate("index.html")))
|
|
template.Must(indexTemplate.Parse(templates.MustGetTemplate("base.html")))
|
|
a.Templates.Add("index", indexTemplate)
|
|
|
|
uploadTemplate := template.New("upload").Funcs(templateFuncs)
|
|
template.Must(uploadTemplate.Parse(templates.MustGetTemplate("upload.html")))
|
|
template.Must(uploadTemplate.Parse(templates.MustGetTemplate("base.html")))
|
|
a.Templates.Add("upload", uploadTemplate)
|
|
|
|
importTemplate := template.New("import").Funcs(templateFuncs)
|
|
template.Must(importTemplate.Parse(templates.MustGetTemplate("import.html")))
|
|
template.Must(importTemplate.Parse(templates.MustGetTemplate("base.html")))
|
|
a.Templates.Add("import", importTemplate)
|
|
|
|
// Setup Router
|
|
authPassword := os.Getenv("auth_password")
|
|
isSandstorm := os.Getenv("SANDSTORM")
|
|
|
|
r := mux.NewRouter().StrictSlash(true)
|
|
r.HandleFunc("/", a.indexHandler).Methods("GET", "OPTIONS")
|
|
if isSandstorm == "1" {
|
|
r.HandleFunc("/upload", middleware.RequireSandstormPermission(a.uploadHandler, "upload")).Methods("GET", "OPTIONS", "POST")
|
|
} else {
|
|
r.HandleFunc("/upload", middleware.OptionallyRequireAdminAuth(a.uploadHandler, authPassword)).Methods("GET", "OPTIONS", "POST")
|
|
}
|
|
r.HandleFunc("/import", a.importHandler).Methods("GET", "OPTIONS", "POST")
|
|
r.HandleFunc("/v/{id}.mp4", a.videoHandler).Methods("GET")
|
|
r.HandleFunc("/v/{prefix}/{id}.mp4", a.videoHandler).Methods("GET")
|
|
r.HandleFunc("/t/{id}", a.thumbHandler).Methods("GET")
|
|
r.HandleFunc("/t/{prefix}/{id}", a.thumbHandler).Methods("GET")
|
|
r.HandleFunc("/v/{id}", a.pageHandler).Methods("GET")
|
|
r.HandleFunc("/v/{prefix}/{id}", a.pageHandler).Methods("GET")
|
|
r.HandleFunc("/feed.xml", a.rssHandler).Methods("GET")
|
|
// Static file handler
|
|
fsHandler := http.StripPrefix(
|
|
"/static",
|
|
http.FileServer(static.GetFilesystem()),
|
|
)
|
|
r.PathPrefix("/static/").Handler(fsHandler).Methods("GET")
|
|
|
|
cors := handlers.CORS(
|
|
handlers.AllowedHeaders([]string{
|
|
"X-Requested-With",
|
|
"Content-Type",
|
|
"Authorization",
|
|
}),
|
|
handlers.AllowedMethods([]string{
|
|
"GET",
|
|
"POST",
|
|
"PUT",
|
|
"HEAD",
|
|
"OPTIONS",
|
|
}),
|
|
handlers.AllowedOrigins([]string{"*"}),
|
|
handlers.AllowCredentials(),
|
|
)
|
|
|
|
r.Use(cors)
|
|
|
|
a.Router = r
|
|
return a, nil
|
|
}
|
|
|
|
// Run imports the library and starts server.
|
|
func (a *App) Run() error {
|
|
for _, pc := range a.Config.Library {
|
|
pc.Path = filepath.Clean(pc.Path)
|
|
p := &media.Path{
|
|
Path: pc.Path,
|
|
Prefix: pc.Prefix,
|
|
}
|
|
err := a.Library.AddPath(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = a.Library.Import(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.Watcher.Add(p.Path)
|
|
}
|
|
if err := os.MkdirAll(a.Config.Server.UploadPath, 0o755); err != nil {
|
|
return fmt.Errorf(
|
|
"error creating upload path %s: %w",
|
|
a.Config.Server.UploadPath, err,
|
|
)
|
|
}
|
|
buildFeed(a)
|
|
go startWatcher(a)
|
|
return http.Serve(a.Listener, a.Router)
|
|
}
|
|
|
|
func (a *App) render(name string, w http.ResponseWriter, ctx interface{}) {
|
|
buf, err := a.Templates.Exec(name, ctx)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
|
|
_, err = buf.WriteTo(w)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// HTTP handler for /
|
|
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
|
|
log.Printf("/")
|
|
pl := a.Library.Playlist()
|
|
if len(pl) > 0 {
|
|
http.Redirect(w, r, fmt.Sprintf("/v/%s?%s", pl[0].ID, r.URL.RawQuery), 302)
|
|
} else {
|
|
sort := strings.ToLower(r.URL.Query().Get("sort"))
|
|
quality := strings.ToLower(r.URL.Query().Get("quality"))
|
|
ctx := &struct {
|
|
Sort string
|
|
Quality string
|
|
Config *Config
|
|
Playing *media.Video
|
|
Playlist media.Playlist
|
|
}{
|
|
Sort: sort,
|
|
Quality: quality,
|
|
Config: a.Config,
|
|
Playing: &media.Video{ID: ""},
|
|
Playlist: a.Library.Playlist(),
|
|
}
|
|
|
|
a.render("index", w, ctx)
|
|
}
|
|
}
|
|
|
|
// HTTP handler for /upload
|
|
func (a *App) uploadHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == "GET" {
|
|
ctx := &struct {
|
|
Config *Config
|
|
Playing *media.Video
|
|
}{
|
|
Config: a.Config,
|
|
Playing: &media.Video{ID: ""},
|
|
}
|
|
a.render("upload", w, ctx)
|
|
} else if r.Method == "POST" {
|
|
r.ParseMultipartForm(a.Config.Server.MaxUploadSize)
|
|
|
|
file, handler, err := r.FormFile("video_file")
|
|
if err != nil {
|
|
err := fmt.Errorf("error processing form: %w", err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
title := r.FormValue("video_title")
|
|
description := r.FormValue("video_description")
|
|
if _, exists := a.Library.Paths[r.FormValue("target_library_path")]; !exists {
|
|
err := fmt.Errorf("uploading to invalid library path: %s", r.FormValue("target_library_path"))
|
|
log.Error(err)
|
|
return
|
|
}
|
|
targetLibraryPath := r.FormValue("target_library_path")
|
|
|
|
uf, err := ioutil.TempFile(
|
|
a.Config.Server.UploadPath,
|
|
fmt.Sprintf("tube-upload-*%s", filepath.Ext(handler.Filename)),
|
|
)
|
|
if err != nil {
|
|
err := fmt.Errorf("error creating temporary file for uploading: %w", err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer os.Remove(uf.Name())
|
|
|
|
_, err = io.Copy(uf, file)
|
|
if err != nil {
|
|
err := fmt.Errorf("error writing file: %w", err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
tf, err := ioutil.TempFile(
|
|
a.Config.Server.UploadPath,
|
|
fmt.Sprintf("tube-transcode-*.mp4"),
|
|
)
|
|
if err != nil {
|
|
err := fmt.Errorf("error creating temporary file for transcoding: %w", err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
vf := filepath.Join(
|
|
a.Library.Paths[targetLibraryPath].Path,
|
|
fmt.Sprintf("%s.mp4", shortuuid.New()),
|
|
)
|
|
thumbFn1 := fmt.Sprintf("%s.jpg", strings.TrimSuffix(tf.Name(), filepath.Ext(tf.Name())))
|
|
thumbFn2 := fmt.Sprintf("%s.jpg", strings.TrimSuffix(vf, filepath.Ext(vf)))
|
|
|
|
// TODO: Use a proper Job Queue and make this async
|
|
if err := utils.RunCmd(
|
|
a.Config.Transcoder.Timeout,
|
|
"ffmpeg",
|
|
"-y",
|
|
"-i", uf.Name(),
|
|
"-vcodec", "h264",
|
|
"-acodec", "aac",
|
|
"-strict", "-2",
|
|
"-loglevel", "quiet",
|
|
"-metadata", fmt.Sprintf("title=%s", title),
|
|
"-metadata", fmt.Sprintf("comment=%s", description),
|
|
tf.Name(),
|
|
); err != nil {
|
|
err := fmt.Errorf("error transcoding video: %w", err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := utils.RunCmd(
|
|
a.Config.Thumbnailer.Timeout,
|
|
"ffmpeg",
|
|
"-i", uf.Name(),
|
|
"-y",
|
|
"-vf", "thumbnail",
|
|
"-t", fmt.Sprint(a.Config.Thumbnailer.PositionFromStart),
|
|
"-vframes", "1",
|
|
"-strict", "-2",
|
|
"-loglevel", "quiet",
|
|
thumbFn1,
|
|
); err != nil {
|
|
err := fmt.Errorf("error generating thumbnail: %w", err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := os.Rename(thumbFn1, thumbFn2); err != nil {
|
|
err := fmt.Errorf("error renaming generated thumbnail: %w", err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := os.Rename(tf.Name(), vf); err != nil {
|
|
err := fmt.Errorf("error renaming transcoded video: %w", err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// TODO: Make this a background job
|
|
// Resize for lower quality options
|
|
for size, suffix := range a.Config.Transcoder.Sizes {
|
|
log.
|
|
WithField("size", size).
|
|
WithField("vf", filepath.Base(vf)).
|
|
Info("resizing video for lower quality playback")
|
|
sf := fmt.Sprintf(
|
|
"%s#%s.mp4",
|
|
strings.TrimSuffix(vf, filepath.Ext(vf)),
|
|
suffix,
|
|
)
|
|
|
|
if err := utils.RunCmd(
|
|
a.Config.Transcoder.Timeout,
|
|
"ffmpeg",
|
|
"-y",
|
|
"-i", vf,
|
|
"-s", size,
|
|
"-c:v", "libx264",
|
|
"-c:a", "aac",
|
|
"-crf", "18",
|
|
"-strict", "-2",
|
|
"-loglevel", "quiet",
|
|
"-metadata", fmt.Sprintf("title=%s", title),
|
|
"-metadata", fmt.Sprintf("comment=%s", description),
|
|
sf,
|
|
); err != nil {
|
|
err := fmt.Errorf("error transcoding video: %w", err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(w, "Video successfully uploaded!")
|
|
} else {
|
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// HTTP handler for /import
|
|
func (a *App) importHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == "GET" {
|
|
ctx := &struct{}{}
|
|
a.render("import", w, ctx)
|
|
} else if r.Method == "POST" {
|
|
r.ParseMultipartForm(1024)
|
|
|
|
url := r.FormValue("url")
|
|
if url == "" {
|
|
err := fmt.Errorf("error, no url supplied")
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// TODO: Make collection user selectable from drop-down in Form
|
|
// XXX: Assume we can put uploaded videos into the first collection (sorted) we find
|
|
keys := make([]string, 0, len(a.Library.Paths))
|
|
for k := range a.Library.Paths {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
collection := keys[0]
|
|
|
|
videoImporter, err := importers.NewImporter(url)
|
|
if err != nil {
|
|
err := fmt.Errorf("error creating video importer for %s: %w", url, err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
videoInfo, err := videoImporter.GetVideoInfo(url)
|
|
if err != nil {
|
|
err := fmt.Errorf("error retriving video info for %s: %w", url, err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
uf, err := ioutil.TempFile(
|
|
a.Config.Server.UploadPath,
|
|
fmt.Sprintf("tube-import-*.mp4"),
|
|
)
|
|
if err != nil {
|
|
err := fmt.Errorf("error creating temporary file for importing: %w", err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer os.Remove(uf.Name())
|
|
|
|
log.WithField("video_url", videoInfo.VideoURL).Info("requesting video size")
|
|
|
|
res, err := http.Head(videoInfo.VideoURL)
|
|
if err != nil {
|
|
err := fmt.Errorf("error getting size of video %w", err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
contentLength := utils.SafeParseInt64(res.Header.Get("Content-Length"), -1)
|
|
if contentLength == -1 {
|
|
err := fmt.Errorf("error calculating size of video")
|
|
log.WithField("contentLength", contentLength).Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if contentLength > a.Config.Server.MaxUploadSize {
|
|
err := fmt.Errorf(
|
|
"imported video would exceed maximum upload size of %s",
|
|
humanize.Bytes(uint64(a.Config.Server.MaxUploadSize)),
|
|
)
|
|
log.
|
|
WithField("contentLength", contentLength).
|
|
WithField("max_upload_size", a.Config.Server.MaxUploadSize).
|
|
Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
log.WithField("contentLength", contentLength).Info("downloading video")
|
|
|
|
if err := utils.Download(videoInfo.VideoURL, uf.Name()); err != nil {
|
|
err := fmt.Errorf("error downloading video %s: %w", url, err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
tf, err := ioutil.TempFile(
|
|
a.Config.Server.UploadPath,
|
|
fmt.Sprintf("tube-transcode-*.mp4"),
|
|
)
|
|
if err != nil {
|
|
err := fmt.Errorf("error creating temporary file for transcoding: %w", err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
vf := filepath.Join(
|
|
a.Library.Paths[collection].Path,
|
|
fmt.Sprintf("%s.mp4", shortuuid.New()),
|
|
)
|
|
thumbFn1 := fmt.Sprintf("%s.jpg", strings.TrimSuffix(tf.Name(), filepath.Ext(tf.Name())))
|
|
thumbFn2 := fmt.Sprintf("%s.jpg", strings.TrimSuffix(vf, filepath.Ext(vf)))
|
|
|
|
if err := utils.Download(videoInfo.ThumbnailURL, thumbFn1); err != nil {
|
|
err := fmt.Errorf("error downloading thumbnail: %w", err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// TODO: Use a proper Job Queue and make this async
|
|
if err := utils.RunCmd(
|
|
a.Config.Transcoder.Timeout,
|
|
"ffmpeg",
|
|
"-y",
|
|
"-i", uf.Name(),
|
|
"-vcodec", "h264",
|
|
"-acodec", "aac",
|
|
"-strict", "-2",
|
|
"-loglevel", "quiet",
|
|
"-metadata", fmt.Sprintf("title=%s", videoInfo.Title),
|
|
"-metadata", fmt.Sprintf("comment=%s", videoInfo.Description),
|
|
tf.Name(),
|
|
); err != nil {
|
|
err := fmt.Errorf("error transcoding video: %w", err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := os.Rename(thumbFn1, thumbFn2); err != nil {
|
|
err := fmt.Errorf("error renaming generated thumbnail: %w", err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := os.Rename(tf.Name(), vf); err != nil {
|
|
err := fmt.Errorf("error renaming transcoded video: %w", err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// TODO: Make this a background job
|
|
// Resize for lower quality options
|
|
for size, suffix := range a.Config.Transcoder.Sizes {
|
|
log.
|
|
WithField("size", size).
|
|
WithField("vf", filepath.Base(vf)).
|
|
Info("resizing video for lower quality playback")
|
|
sf := fmt.Sprintf(
|
|
"%s#%s.mp4",
|
|
strings.TrimSuffix(vf, filepath.Ext(vf)),
|
|
suffix,
|
|
)
|
|
|
|
if err := utils.RunCmd(
|
|
a.Config.Transcoder.Timeout,
|
|
"ffmpeg",
|
|
"-y",
|
|
"-i", vf,
|
|
"-s", size,
|
|
"-c:v", "libx264",
|
|
"-c:a", "aac",
|
|
"-crf", "18",
|
|
"-strict", "-2",
|
|
"-loglevel", "quiet",
|
|
"-metadata", fmt.Sprintf("title=%s", videoInfo.Title),
|
|
"-metadata", fmt.Sprintf("comment=%s", videoInfo.Description),
|
|
sf,
|
|
); err != nil {
|
|
err := fmt.Errorf("error transcoding video: %w", err)
|
|
log.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(w, "Video successfully imported!")
|
|
} else {
|
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// HTTP handler for /v/id
|
|
func (a *App) pageHandler(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
id := vars["id"]
|
|
prefix, ok := vars["prefix"]
|
|
if ok {
|
|
id = path.Join(prefix, id)
|
|
}
|
|
log.Printf("/v/%s", id)
|
|
playing, ok := a.Library.Videos[id]
|
|
if !ok {
|
|
sort := strings.ToLower(r.URL.Query().Get("sort"))
|
|
quality := strings.ToLower(r.URL.Query().Get("quality"))
|
|
ctx := &struct {
|
|
Sort string
|
|
Quality string
|
|
Config *Config
|
|
Playing *media.Video
|
|
Playlist media.Playlist
|
|
}{
|
|
Sort: sort,
|
|
Quality: quality,
|
|
Config: a.Config,
|
|
Playing: &media.Video{ID: ""},
|
|
Playlist: a.Library.Playlist(),
|
|
}
|
|
a.render("upload", w, ctx)
|
|
return
|
|
}
|
|
|
|
views, err := a.Store.GetViews(id)
|
|
if err != nil {
|
|
err := fmt.Errorf("error retrieving views for %s: %w", id, err)
|
|
log.Warn(err)
|
|
}
|
|
|
|
playing.Views = views
|
|
|
|
playlist := a.Library.Playlist()
|
|
|
|
// TODO: Optimize this? Bitcask has no concept of MultiGet / MGET
|
|
for _, video := range playlist {
|
|
views, err := a.Store.GetViews(video.ID)
|
|
if err != nil {
|
|
err := fmt.Errorf("error retrieving views for %s: %w", video.ID, err)
|
|
log.Warn(err)
|
|
}
|
|
video.Views = views
|
|
}
|
|
|
|
sort := strings.ToLower(r.URL.Query().Get("sort"))
|
|
switch sort {
|
|
case "views":
|
|
media.By(media.SortByViews).Sort(playlist)
|
|
case "", "timestamp":
|
|
media.By(media.SortByTimestamp).Sort(playlist)
|
|
default:
|
|
// By default the playlist is sorted by Timestamp
|
|
log.Warnf("invalid sort critiera: %s", sort)
|
|
}
|
|
|
|
quality := strings.ToLower(r.URL.Query().Get("quality"))
|
|
switch quality {
|
|
case "", "720p", "480p", "360p", "240p":
|
|
default:
|
|
log.WithField("quality", quality).Warn("invalid quality")
|
|
quality = ""
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
ctx := &struct {
|
|
Sort string
|
|
Quality string
|
|
Config *Config
|
|
Playing *media.Video
|
|
Playlist media.Playlist
|
|
}{
|
|
Sort: sort,
|
|
Quality: quality,
|
|
Config: a.Config,
|
|
Playing: playing,
|
|
Playlist: playlist,
|
|
}
|
|
a.render("index", w, ctx)
|
|
}
|
|
|
|
// HTTP handler for /v/id.mp4
|
|
func (a *App) videoHandler(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
id := vars["id"]
|
|
|
|
prefix, ok := vars["prefix"]
|
|
if ok {
|
|
id = path.Join(prefix, id)
|
|
}
|
|
|
|
log.Printf("/v/%s", id)
|
|
|
|
m, ok := a.Library.Videos[id]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var videoPath string
|
|
|
|
quality := strings.ToLower(r.URL.Query().Get("quality"))
|
|
switch quality {
|
|
case "720p", "480p", "360p", "240p":
|
|
videoPath = fmt.Sprintf(
|
|
"%s#%s.mp4",
|
|
strings.TrimSuffix(m.Path, filepath.Ext(m.Path)),
|
|
quality,
|
|
)
|
|
if !utils.FileExists(videoPath) {
|
|
log.
|
|
WithField("quality", quality).
|
|
WithField("videoPath", videoPath).
|
|
Warn("video with specified quality does not exist (defaulting to default quality)")
|
|
videoPath = m.Path
|
|
}
|
|
case "":
|
|
videoPath = m.Path
|
|
default:
|
|
log.WithField("quality", quality).Warn("invalid quality")
|
|
videoPath = m.Path
|
|
}
|
|
|
|
if err := a.Store.Migrate(prefix, id); err != nil {
|
|
err := fmt.Errorf("error migrating store data: %w", err)
|
|
log.Warn(err)
|
|
}
|
|
|
|
if err := a.Store.IncViews(id); err != nil {
|
|
err := fmt.Errorf("error updating view for %s: %w", id, err)
|
|
log.Warn(err)
|
|
}
|
|
|
|
title := m.Title
|
|
disposition := "attachment; filename=\"" + title + ".mp4\""
|
|
w.Header().Set("Content-Disposition", disposition)
|
|
w.Header().Set("Content-Type", "video/mp4")
|
|
http.ServeFile(w, r, videoPath)
|
|
}
|
|
|
|
// HTTP handler for /t/id
|
|
func (a *App) thumbHandler(w http.ResponseWriter, r *http.Request) {
|
|
vars := mux.Vars(r)
|
|
id := vars["id"]
|
|
prefix, ok := vars["prefix"]
|
|
if ok {
|
|
id = path.Join(prefix, id)
|
|
}
|
|
log.Printf("/t/%s", id)
|
|
m, ok := a.Library.Videos[id]
|
|
if !ok {
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "public, max-age=7776000")
|
|
if m.ThumbType == "" {
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
w.Write(static.MustGetFile("defaulticon.jpg"))
|
|
} else {
|
|
w.Header().Set("Content-Type", m.ThumbType)
|
|
w.Write(m.Thumb)
|
|
}
|
|
}
|
|
|
|
// HTTP handler for /feed.xml
|
|
func (a *App) rssHandler(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "public, max-age=7776000")
|
|
w.Header().Set("Content-Type", "text/xml")
|
|
w.Write(a.Feed)
|
|
}
|