Browse Source

Initial code, mostly working from limited tests.

master
Syfaro 2 years ago
parent
commit
6cc3ad1388
7 changed files with 500 additions and 0 deletions
  1. +5
    -0
      .gitignore
  2. +6
    -0
      .idea/misc.xml
  3. +6
    -0
      .idea/vcs.xml
  4. +57
    -0
      client/client.go
  5. +12
    -0
      server/config.example.json
  6. +166
    -0
      server/index.html
  7. +248
    -0
      server/server.go

+ 5
- 0
.gitignore View File

@@ -0,0 +1,5 @@
*.exe
*.json

data/
backups/

+ 6
- 0
.idea/misc.xml View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptSettings">
<option name="languageLevel" value="ES6" />
</component>
</project>

+ 6
- 0
.idea/vcs.xml View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

+ 57
- 0
client/client.go View File

@@ -0,0 +1,57 @@
package main

import (
"flag"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"strconv"
"time"
)

var endpoint = "http://127.0.0.1:9090"

var nowait = flag.Bool("nowait", false, "Immediately exit without waiting for transcode to finish")

func getNumberFromURL(url string) (int64, error) {
resp, err := http.Get(url)
if err != nil {
return -1, err
}
defer resp.Body.Close()

bytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
return -1, err
}

return strconv.ParseInt(string(bytes), 10, 64)
}

func main() {
flag.Parse()

url := fmt.Sprintf("%s/api/add?path=%s", endpoint, url.QueryEscape(flag.Arg(0)))
queueID, err := getNumberFromURL(url)
if err != nil {
panic(err)
}

if *nowait {
return
}

for {
time.Sleep(10 * time.Second) // If it's the first item, there'll be a delay until it's the current

currentID, err := getNumberFromURL(endpoint + "/api/current")
if err != nil {
panic(err)
}

if currentID > queueID || currentID == -1 {
return
}
}
}

+ 12
- 0
server/config.example.json View File

@@ -0,0 +1,12 @@
{
"http_host": ":9090",
"data_folder": "./data/",
"handbrake_path": "HandBrakeCLI.exe",
"handbrake_preset_file": "handbrake_presets.json",
"handbrake_preset": "1080p Fast",
"remove_old": true,
"use_backup": true,
"backup_folder": "./backups/",
"use_constant_folder": true,
"constant_folder": "./transcodes/"
}

+ 166
- 0
server/index.html View File

@@ -0,0 +1,166 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">

<title>Transcoder Status</title>

<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootswatch/4.0.0-beta.3/lux/bootstrap.min.css">

<style>
.stdout,
.stderr {
max-height: 500px;
overflow-y: scroll;
}
</style>
</head>
<body>
<div class="container">
<div class="row">
<div class="col-sm-12">
<h1>Transcoder Status</h1>
</div>
</div>

<div class="row">
<div class="col-sm-12">
<h2 class="current-item"></h2>
</div>
</div>

<div class="row items-in-queue">
<div class="col-sm-12">
<h3 class="items-left"></h3>
</div>
</div>
</div>

<div class="container-fluid">
<div class="row">
<div class="col-sm-12 col-md-6">
<h3>Stdout</h3>

<pre class="stdout"></pre>

<div class="form-check">
<input class="form-check-input" type="checkbox" id="stdout-check" checked>
<label class="form-check-label" for="stdout-check">Keep at bottom</label>
</div>
</div>

<div class="col-sm-12 col-md-6">
<h3>Stderr</h3>

<pre class="stderr"></pre>

<div class="form-check">
<input class="form-check-input" type="checkbox" id="stderr-check" checked>
<label class="form-check-label" for="stderr-check">Keep at bottom</label>
</div>
</div>
</div>
</div>

<div class="container">
<div class="row">
<div class="col-sm-12">
<h2>Manual Add</h2>

<form>
<div class="form-group">
<label for="path">Path</label>
<input type="text" class="form-control" id="path" placeholder="Path">
</div>

<button type="submit" class="btn btn-primary">Add to queue</button>
</form>
</div>
</div>
</div>

<script>
const escape = str => {
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
};

Array.from(document.querySelectorAll('.bottom')).forEach(node => {
node.addEventListener('click', ev => {
ev.preventDefault();

const node = document.querySelector(ev.target.dataset.node);

node.scrollTop = node.scrollHeight;
})
});

document.querySelector('form').addEventListener('submit', ev => {
ev.preventDefault();

const path = document.getElementById('path');

fetch(`/api/add?path=${encodeURIComponent(path.value)}`).then(resp => resp.text()).then(resp => {
console.log(resp);
path.value = '';
}).catch(err => {
alert(err);
});
});

let hasRunning = true;

const update = () => {
fetch('/api/current').then(resp => resp.text()).then(resp => {
let message;

if (resp === '-1') {
hasRunning = false;
message = 'Nothing currently running';
} else {
hasRunning = true;
message = `Currently processing queue item ${resp}`;
}

document.querySelector('.current-item').innerHTML = message;
});

fetch('/api/length').then(resp => resp.text()).then(resp => {
const len = parseInt(resp, 10);

const node = document.querySelector('.items-in-queue');

if (len > 0) {
node.style.display = 'inline-block';
document.querySelector('.items-left').innerHTML = `${len} item${len === 1 ? '' : 's'} after current one`;
} else {
node.style.display = 'none';
}
});

if (hasRunning) {
fetch('/api/current/stdout').then(resp => resp.text()).then(resp => {
const node = document.querySelector('.stdout');
const shouldScroll = document.getElementById('stdout-check').checked;

node.innerHTML = escape(resp);
if (shouldScroll) node.scrollTop = node.scrollHeight;
});

fetch('/api/current/stderr').then(resp => resp.text()).then(resp => {
const node = document.querySelector('.stderr');
const shouldScroll = document.getElementById('stderr-check').checked;

node.innerHTML = escape(resp);
if (shouldScroll) node.scrollTop = node.scrollHeight;
});
}
};

update();
setInterval(update, 1000);
</script>
</body>
</html>

+ 248
- 0
server/server.go View File

@@ -0,0 +1,248 @@
package main

import (
"bytes"
"encoding/json"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"

"github.com/beeker1121/goque"
)

type config struct {
HTTPHost string `json:"http_host"` // Host to listen for HTTP requests on
DataFolder string `json:"data_folder"` // Path for queue database

HandBrakePath string `json:"handbrake_path"` // Path to HandBrake, may use just the name if on PATH
HandBrakePresetFile string `json:"handbrake_preset_file"` // Path to HandBrake presets JSON file
HandBrakePresetName string `json:"handbrake_preset"` // Name of preset to use

RemoveOld bool `json:"remove_old"` // Enable to remove original files

UseBackup bool `json:"use_backup"` // Enable moving original files into a backup directory, only useful with RemoveOld
BackupFolder string `json:"backup_folder"` // Path of backup directory

UseConstantFolder bool `json:"use_constant_folder"` // Move all transcodes to constant folder, rather than source folder
ConstantFolder string `json:"constant_folder"` // Folder to move to if constant folder
}

type httpServer struct {
config config

inProgress *uint64
shuttingDown bool

server *http.Server
queue *goque.Queue

stdout bytes.Buffer
stderr bytes.Buffer
}

func copy(fromPath, toPath string) error {
from, err := os.Open(fromPath)
if err != nil {
return err
}
defer from.Close()

to, err := os.OpenFile(toPath, os.O_RDWR|os.O_CREATE, 0666)
if err != nil {
return err
}
defer to.Close()

_, err = io.Copy(to, from)
return err
}

func (server *httpServer) transcodeVideo(path string) error {
dir, err := ioutil.TempDir("", "transcode")
if err != nil {
return err
}
defer os.RemoveAll(dir)

source, file := filepath.Split(path)
ext := filepath.Ext(file)
if ext == ".mp4" || ext == ".m4v" {
log.Println("Got file that was already mp4/m4v")
return nil
}
filename := strings.TrimSuffix(file, ext)
newName := filepath.Join(dir, filename+".mp4")

log.Printf("Starting transcode in directory %s for file %s\n", dir, path)

server.stdout = bytes.Buffer{}
server.stderr = bytes.Buffer{}

cmd := exec.Command(server.config.HandBrakePath,
"--preset-import-file", server.config.HandBrakePresetFile,
"--preset", server.config.HandBrakePresetName,
"-i", path, "-o", newName)
cmd.Stdout = &server.stdout
cmd.Stderr = &server.stderr
err = cmd.Run()
if err != nil {
log.Println(err)
}

var newPath string

if server.config.UseConstantFolder {
newPath = server.config.ConstantFolder
} else {
newPath = source
}

// Can't just do a os.Rename because can't rename across disks
if err = copy(newName, filepath.Join(newPath, filename+".mp4")); err != nil {
return err
}

if server.config.RemoveOld {
if server.config.UseBackup {
if err = copy(path, filepath.Join(server.config.BackupFolder, file)); err != nil {
return err
}
}

if err = os.Remove(path); err != nil {
return err
}
}

if err = os.Remove(newName); err != nil {
return err
}

log.Printf("Finished transcode for file %s\n", path)

return nil
}

func (server *httpServer) runQueue() {
log.Println("Starting queue")

for {
if server.shuttingDown {
log.Println("Attempting shutdown")
server.server.Shutdown(nil)
return
}

item, err := server.queue.Dequeue()
if err != nil {
if err == goque.ErrEmpty {
time.Sleep(5 * time.Second)
continue
}

panic(err)
}

server.inProgress = &item.ID
if err = server.transcodeVideo(string(item.Value)); err != nil {
panic(err)
}
server.inProgress = nil
}
}

func (server *httpServer) shutdown(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Shutting down after current queue item finishes."))
server.shuttingDown = true
}

func (server *httpServer) addItemToQueue(w http.ResponseWriter, r *http.Request) {
path := r.URL.Query().Get("path")
if path == "" {
http.Error(w, "Missing path", http.StatusBadRequest)
return
}

item, err := server.queue.EnqueueString(path)
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}

w.Write([]byte(strconv.FormatUint(item.ID, 10)))
}

func (server *httpServer) currentItem(w http.ResponseWriter, r *http.Request) {
if server.inProgress == nil {
w.Write([]byte("-1"))
return
}

w.Write([]byte(strconv.FormatUint(*server.inProgress, 10)))
}

func (server *httpServer) currentStdout(w http.ResponseWriter, r *http.Request) {
w.Write(server.stdout.Bytes())
}

func (server *httpServer) currentStderr(w http.ResponseWriter, r *http.Request) {
w.Write(server.stderr.Bytes())
}

func (server *httpServer) index(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "index.html")
}

func (server *httpServer) queueLength(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(strconv.FormatUint(server.queue.Length(), 10)))
}

func main() {
bytes, err := ioutil.ReadFile("config.json")
if err != nil {
panic(err)
}

var cfg config
if err = json.Unmarshal(bytes, &cfg); err != nil {
panic(err)
}

log.Printf("Loaded config\n%+v\n", cfg)

server := httpServer{
config: cfg,
shuttingDown: false,
server: &http.Server{Addr: cfg.HTTPHost},
}

q, err := goque.OpenQueue("data")
if err != nil {
panic(err)
}
defer q.Close()

server.queue = q

go server.runQueue()

http.HandleFunc("/", server.index)

http.HandleFunc("/api/add", server.addItemToQueue)
http.HandleFunc("/api/length", server.queueLength)
http.HandleFunc("/api/current", server.currentItem)
http.HandleFunc("/api/current/stdout", server.currentStdout)
http.HandleFunc("/api/current/stderr", server.currentStderr)
http.HandleFunc("/api/shutdown", server.shutdown)

if err = server.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
panic(err)
}
}

Loading…
Cancel
Save