<commit_before>package main

import (
	"flag"
	"fmt"
	"io"
	"io/ioutil"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"

	"github.com/gin-gonic/gin"
)

const VERSION = "0.1.1"

type Response struct {
	Success bool   `json:"success"`
	Message string `json:"message"`
}

type StatusResponse struct {
	Running       bool   `json:"running"`            // True if player is running
	File          string `json:"file"`               // Path to current media file
	Name          string `json:"name"`               // Titleized filename
	MovieDbApiKey string `json:"tmdb_api_key"`       // TheMovieDb API key
	Position      string `json:"position,omitempty"` // Current position in the movie
	Duration      string `json:"duration,omitempty"` // Movie duration
}

type FileEntry struct {
	Filename string `json:"filename"`
	IsDir    bool   `json:"directory"`
}

var (
	// Regular expression to match all supported video files
	RegexFormats = regexp.MustCompile(`.(avi|mpg|mov|flv|wmv|asf|mpeg|m4v|divx|mp4|mkv)$`)

	// Regular expression to convert filenames to titles
	RegexBrackets = regexp.MustCompile(`[\(\[\]\)]`)
	RegexYear     = regexp.MustCompile(`((19|20)[\d]{2})`)
	RegexEpisode  = regexp.MustCompile(`(?i)S[\d]+E[\d]+`)
	RegexJunk     = regexp.MustCompile(`(?i)(1080p|720p|3d|brrip|bluray|webrip|x264|aac)`)
	RegexSpace    = regexp.MustCompile(`\s{2,}`)

	// OMXPlayer control commands, these are piped via STDIN to omxplayer process
	Commands = map[string]string{
		"pause":             "p",            // Pause/continue playback
		"stop":              "q",            // Stop playback and exit
		"volume_up":         "+",            // Change volume by +3dB
		"volume_down":       "-",            // Change volume by -3dB
		"subtitles":         "s",            // Enable/disable subtitles
		"seek_back":         "\x1b\x5b\x44", // Seek -30 seconds
		"seek_back_fast":    "\x1b\x5b\x42", // Seek -600 second
		"seek_forward":      "\x1b\x5b\x43", // Seek +30 second
		"seek_forward_fast": "\x1b\x5b\x41", // Seek +600 seconds
	}

	// Path where all media files are stored
	MediaPath string

	// Path to omxplayer executable
	OmxPath string

	// Child process for spawning omxplayer
	Omx *exec.Cmd

	// Child process STDIN pipe to send commands
	OmxIn io.WriteCloser

	// Channel to pass along commands to the player routine
	Command chan string

	// Currently playing media file name
	CurrentFile string

	// Enable Zeroconf discovery
	Zeroconf bool

	// Serve frontend app
	Frontend bool

	// Current stream
	stream *Stream
)

// Returns true if specified file exists
func fileExists(path string) bool {
	_, err := os.Stat(path)
	return err == nil
}

// Scan given path for all directories and matching video files.
// If nothing was found it will return an empty slice.
func scanPath(path string) []FileEntry {
	entries := make([]FileEntry, 0)

	files, err := ioutil.ReadDir(path)
	if err != nil {
		return entries
	}

	for _, file := range files {
		entry := FileEntry{
			Filename: file.Name(),
			IsDir:    file.IsDir(),
		}

		// Do not include non-video files in the list
		if !file.IsDir() && !omxCanPlay(file.Name()) {
			continue
		}

		entries = append(entries, entry)
	}

	return entries
}

// Convert media filename to regular title
func fileToTitle(name string) string {
	// Remove file extension from name
	name = strings.Replace(name, filepath.Ext(name), "", 1)

	// Replace all dots with white space
	name = strings.Replace(name, ".", " ", -1)

	// Replace parenteses and brackets
	name = RegexBrackets.ReplaceAllString(name, "")

	// Check if name has a typical torrent format: "name year format etc"
	pos := RegexYear.FindStringIndex(name)
	if len(pos) > 0 {
		name = name[0:pos[0]]
	} else {
		// Check if its an episode, i.e. "S##E##"
		pos = RegexEpisode.FindStringIndex(name)

		if len(pos) > 0 {
			name = name[0:pos[0]]
		}
	}

	// Remove junk stuff
	name = RegexJunk.ReplaceAllString(name, "")

	// Remove any extra white space
	name = RegexSpace.ReplaceAllString(name, "")

	// Truncate space
	name = strings.TrimSpace(name)

	return name
}

// Determine the full path to omxplayer executable. Returns error if not found.
func omxDetect() error {
	buff, err := exec.Command("which", "omxplayer").Output()
	if err != nil {
		return err
	}

	// Set path in global variable
	OmxPath = strings.TrimSpace(string(buff))

	return nil
}

// Start command listener. Commands are coming in through a channel.
func omxListen() {
	Command = make(chan string)

	for {
		command := <-Command

		// Skip command handling of omx player is not active
		if Omx == nil {
			continue
		}

		// Send command to the player
		omxWrite(command)

		// Attempt to kill the process if stop command is requested
		if command == "stop" {
			Omx.Process.Kill()
		}
	}
}

// Start omxplayer playback for a given video file. Returns error if start fails.
func omxPlay(file string) error {
	Omx = exec.Command(
		OmxPath,       // path to omxplayer executable
		"--stats",     // print stats to stdout (buffers, time, etc)
		"--with-info", // print stats about streams before playback
		"--refresh",   // adjust framerate/resolution to video
		"--blank",     // set background to black
		"--adev",      // audio out device
		"hdmi",        // using hdmi for audio/video
		file,          // path to video file
	)

	// Grab child process STDIN
	stdin, err := Omx.StdinPipe()
	if err != nil {
		return err
	}
	defer stdin.Close()

	stderr, err := Omx.StderrPipe()
	if err != nil {
		return err
	}
	defer stderr.Close()

	stdout, err := Omx.StdoutPipe()
	if err != nil {
		return err
	}
	defer stdout.Close()

	stream = NewStream()
	go stream.Start(stdout, stderr)

	// Start omxplayer execution.
	// If successful, something will appear on HDMI display.
	err = Omx.Start()
	if err != nil {
		return err
	}

	// Set current file
	CurrentFile = file

	// Make child's STDIN globally available
	OmxIn = stdin

	// Wait until child process is finished
	err = Omx.Wait()
	if err != nil {
		fmt.Fprintln(os.Stdout, "Process exited with error:", err)
	}

	omxCleanup()

	return nil
}

// Write a command string to the omxplayer process's STDIN
func omxWrite(command string) {
	if OmxIn != nil {
		io.WriteString(OmxIn, Commands[command])
	}
}

// Terminate any running omxplayer processes. Fixes random hangs.
func omxKill() {
	exec.Command("killall", "omxplayer.bin").Output()
	exec.Command("killall", "omxplayer").Output()
}

// Reset internal state and stop any running processes
func omxCleanup() {
	Omx = nil
	OmxIn = nil
	CurrentFile = ""

	omxKill()

	stream = nil
}

// Check if player is currently active
func omxIsActive() bool {
	return Omx != nil
}

// Check if player can play the file
func omxCanPlay(path string) bool {
	if RegexFormats.Match([]byte(path)) {
		return true
	}

	return false
}

// Check if TheMovieDB API could be used
func movieDbApiKey() string {
	return os.Getenv("TMDB_API_KEY")
}

func httpBrowse(c *gin.Context) {
	path := c.Request.FormValue("path")

	if path != "" {
		path = fmt.Sprintf("%s/%s", MediaPath, path)
	} else {
		path = MediaPath
	}

	c.JSON(200, scanPath(path))
}

func httpCommand(c *gin.Context) {
	val := c.Params.ByName("command")

	if _, ok := Commands[val]; !ok {
		c.JSON(400, Response{false, "Invalid command"})
		return
	}

	fmt.Println("Received command:", val)

	// Handle requested commmand
	Command <- val

	c.JSON(200, Response{true, "OK"})
}

func httpServe(c *gin.Context) {
	file := c.Request.URL.Query().Get("file")
	if file == "" {
		return
	}

	file = fmt.Sprintf("%s/%s", MediaPath, file)
	if !fileExists(file) {
		c.String(404, "Not found")
		return
	}

	if !omxCanPlay(file) {
		c.String(400, "Invalid format")
		return
	}

	http.ServeFile(c.Writer, c.Request, file)
}

func httpPlay(c *gin.Context) {
	if omxIsActive() {
		c.JSON(400, Response{false, "Player is already running"})
		return
	}

	file := c.Request.FormValue("file")
	if file == "" {
		c.JSON(400, Response{false, "File is required"})
		return
	}

	file = fmt.Sprintf("%s/%s", MediaPath, file)

	if !fileExists(file) {
		c.JSON(400, Response{false, "File does not exist"})
		return
	}

	if !omxCanPlay(file) {
		c.JSON(400, Response{false, "File cannot be played"})
		return
	}

	go omxPlay(file)

	c.JSON(200, Response{true, "OK"})
}

func httpStatus(c *gin.Context) {
	resp := StatusResponse{
		Running:       omxIsActive(),
		File:          CurrentFile,
		Name:          fileToTitle(filepath.Base(CurrentFile)),
		MovieDbApiKey: movieDbApiKey(),
	}

	if stream != nil {
		resp.Duration = durationFromSeconds(stream.duration)
		resp.Position = stream.pos.String()
	}

	c.JSON(200, resp)
}

func httpIndex(c *gin.Context) {
	data, err := Asset("static/index.html")

	if err != nil {
		c.String(400, err.Error())
		return
	}

	c.Data(200, "text/html; charset=utf-8", data)
}

func terminate(message string, code int) {
	fmt.Println(message)
	os.Exit(code)
}

func usage() {
	terminate("Usage: omxremote path/to/media/dir", 0)
}

func init() {
	var printVersion bool

	flag.StringVar(&MediaPath, "media", "./", "Path to media files")
	flag.BoolVar(&Frontend, "frontend", true, "Enable frontend applicaiton")
	flag.BoolVar(&Zeroconf, "zeroconf", false, "Enable service advertisement with Zeroconf")
	flag.BoolVar(&printVersion, "v", false, "Print version")
	flag.Parse()

	if printVersion {
		fmt.Printf("omxremote v%v\n", VERSION)
		os.Exit(0)
	}
}

func main() {
	// Expand media path if needed
	MediaPath = strings.Replace(MediaPath, "~", os.Getenv("HOME"), 1)

	// Get path from arguments and remove trailing slash
	MediaPath = strings.TrimRight(MediaPath, "/")

	if !fileExists(MediaPath) {
		terminate(fmt.Sprintf("Directory does not exist: %s", MediaPath), 1)
	}

	// Check if player is installed
	if omxDetect() != nil {
		terminate("omxplayer is not installed", 1)
	}

	// Make sure nothing is running
	omxCleanup()

	// Start a remote command listener
	go omxListen()

	// Start zeroconf service advertisement
	if Zeroconf {
		stopZeroconf := make(chan bool)
		go startZeroConfAdvertisement(stopZeroconf)
	}

	// Disable debugging mode
	gin.SetMode("release")

	// Setup HTTP server
	router := gin.Default()

	// Handle CORS
	router.Use(func(c *gin.Context) {
		c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
		c.Header("Access-Control-Allow-Origin", "*")
		c.Header("Access-Control-Expose-Headers", "*")
	})

	// Server frontend application only if its enabled
	if Frontend == true {
		router.GET("/", httpIndex)
	}

	router.GET("/status", httpStatus)
	router.GET("/browse", httpBrowse)
	router.GET("/play", httpPlay)
	router.GET("/serve", httpServe)
	router.GET("/command/:command", httpCommand)

	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}

	host := os.Getenv("HOST")
	if host == "" {
		host = "0.0.0.0"
	}

	fmt.Println("Starting server on " + host + ":" + port)
	router.Run(host + ":" + port)
}
<commit_msg>Add /host endpoint for show host stats<commit_after>package main

import (
	"bytes"
	"flag"
	"fmt"
	"io"
	"io/ioutil"
	"log"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"
	"sync"

	"github.com/gin-gonic/gin"
)

const VERSION = "0.1.1"

type Response struct {
	Success bool   `json:"success"`
	Message string `json:"message"`
}

type StatusResponse struct {
	Running       bool   `json:"running"`            // True if player is running
	File          string `json:"file"`               // Path to current media file
	Name          string `json:"name"`               // Titleized filename
	MovieDbApiKey string `json:"tmdb_api_key"`       // TheMovieDb API key
	Position      string `json:"position,omitempty"` // Current position in the movie
	Duration      string `json:"duration,omitempty"` // Movie duration
}

type FileEntry struct {
	Filename string `json:"filename"`
	IsDir    bool   `json:"directory"`
}

var (
	// Regular expression to match all supported video files
	RegexFormats = regexp.MustCompile(`.(avi|mpg|mov|flv|wmv|asf|mpeg|m4v|divx|mp4|mkv)$`)

	// Regular expression to convert filenames to titles
	RegexBrackets = regexp.MustCompile(`[\(\[\]\)]`)
	RegexYear     = regexp.MustCompile(`((19|20)[\d]{2})`)
	RegexEpisode  = regexp.MustCompile(`(?i)S[\d]+E[\d]+`)
	RegexJunk     = regexp.MustCompile(`(?i)(1080p|720p|3d|brrip|bluray|webrip|x264|aac)`)
	RegexSpace    = regexp.MustCompile(`\s{2,}`)

	// OMXPlayer control commands, these are piped via STDIN to omxplayer process
	Commands = map[string]string{
		"pause":             "p",            // Pause/continue playback
		"stop":              "q",            // Stop playback and exit
		"volume_up":         "+",            // Change volume by +3dB
		"volume_down":       "-",            // Change volume by -3dB
		"subtitles":         "s",            // Enable/disable subtitles
		"seek_back":         "\x1b\x5b\x44", // Seek -30 seconds
		"seek_back_fast":    "\x1b\x5b\x42", // Seek -600 second
		"seek_forward":      "\x1b\x5b\x43", // Seek +30 second
		"seek_forward_fast": "\x1b\x5b\x41", // Seek +600 seconds
	}

	// Path where all media files are stored
	MediaPath string

	// Path to omxplayer executable
	OmxPath string

	// Child process for spawning omxplayer
	Omx *exec.Cmd

	// Child process STDIN pipe to send commands
	OmxIn io.WriteCloser

	// Channel to pass along commands to the player routine
	Command chan string

	// Currently playing media file name
	CurrentFile string

	// Enable Zeroconf discovery
	Zeroconf bool

	// Serve frontend app
	Frontend bool

	// Current stream
	stream *Stream
)

// Returns true if specified file exists
func fileExists(path string) bool {
	_, err := os.Stat(path)
	return err == nil
}

// Scan given path for all directories and matching video files.
// If nothing was found it will return an empty slice.
func scanPath(path string) []FileEntry {
	entries := make([]FileEntry, 0)

	files, err := ioutil.ReadDir(path)
	if err != nil {
		return entries
	}

	for _, file := range files {
		entry := FileEntry{
			Filename: file.Name(),
			IsDir:    file.IsDir(),
		}

		// Do not include non-video files in the list
		if !file.IsDir() && !omxCanPlay(file.Name()) {
			continue
		}

		entries = append(entries, entry)
	}

	return entries
}

// Convert media filename to regular title
func fileToTitle(name string) string {
	// Remove file extension from name
	name = strings.Replace(name, filepath.Ext(name), "", 1)

	// Replace all dots with white space
	name = strings.Replace(name, ".", " ", -1)

	// Replace parenteses and brackets
	name = RegexBrackets.ReplaceAllString(name, "")

	// Check if name has a typical torrent format: "name year format etc"
	pos := RegexYear.FindStringIndex(name)
	if len(pos) > 0 {
		name = name[0:pos[0]]
	} else {
		// Check if its an episode, i.e. "S##E##"
		pos = RegexEpisode.FindStringIndex(name)

		if len(pos) > 0 {
			name = name[0:pos[0]]
		}
	}

	// Remove junk stuff
	name = RegexJunk.ReplaceAllString(name, "")

	// Remove any extra white space
	name = RegexSpace.ReplaceAllString(name, "")

	// Truncate space
	name = strings.TrimSpace(name)

	return name
}

// Determine the full path to omxplayer executable. Returns error if not found.
func omxDetect() error {
	buff, err := exec.Command("which", "omxplayer").Output()
	if err != nil {
		return err
	}

	// Set path in global variable
	OmxPath = strings.TrimSpace(string(buff))

	return nil
}

// Start command listener. Commands are coming in through a channel.
func omxListen() {
	Command = make(chan string)

	for {
		command := <-Command

		// Skip command handling of omx player is not active
		if Omx == nil {
			continue
		}

		// Send command to the player
		omxWrite(command)

		// Attempt to kill the process if stop command is requested
		if command == "stop" {
			Omx.Process.Kill()
		}
	}
}

// Start omxplayer playback for a given video file. Returns error if start fails.
func omxPlay(file string) error {
	Omx = exec.Command(
		OmxPath,       // path to omxplayer executable
		"--stats",     // print stats to stdout (buffers, time, etc)
		"--with-info", // print stats about streams before playback
		"--refresh",   // adjust framerate/resolution to video
		"--blank",     // set background to black
		"--adev",      // audio out device
		"hdmi",        // using hdmi for audio/video
		file,          // path to video file
	)

	// Grab child process STDIN
	stdin, err := Omx.StdinPipe()
	if err != nil {
		return err
	}
	defer stdin.Close()

	stderr, err := Omx.StderrPipe()
	if err != nil {
		return err
	}
	defer stderr.Close()

	stdout, err := Omx.StdoutPipe()
	if err != nil {
		return err
	}
	defer stdout.Close()

	stream = NewStream()
	go stream.Start(stdout, stderr)

	// Start omxplayer execution.
	// If successful, something will appear on HDMI display.
	err = Omx.Start()
	if err != nil {
		return err
	}

	// Set current file
	CurrentFile = file

	// Make child's STDIN globally available
	OmxIn = stdin

	// Wait until child process is finished
	err = Omx.Wait()
	if err != nil {
		fmt.Fprintln(os.Stdout, "Process exited with error:", err)
	}

	omxCleanup()

	return nil
}

// Write a command string to the omxplayer process's STDIN
func omxWrite(command string) {
	if OmxIn != nil {
		io.WriteString(OmxIn, Commands[command])
	}
}

// Terminate any running omxplayer processes. Fixes random hangs.
func omxKill() {
	exec.Command("killall", "omxplayer.bin").Output()
	exec.Command("killall", "omxplayer").Output()
}

// Reset internal state and stop any running processes
func omxCleanup() {
	Omx = nil
	OmxIn = nil
	CurrentFile = ""

	omxKill()

	stream = nil
}

// Check if player is currently active
func omxIsActive() bool {
	return Omx != nil
}

// Check if player can play the file
func omxCanPlay(path string) bool {
	if RegexFormats.Match([]byte(path)) {
		return true
	}

	return false
}

// Check if TheMovieDB API could be used
func movieDbApiKey() string {
	return os.Getenv("TMDB_API_KEY")
}

func httpBrowse(c *gin.Context) {
	path := c.Request.FormValue("path")

	if path != "" {
		path = fmt.Sprintf("%s/%s", MediaPath, path)
	} else {
		path = MediaPath
	}

	c.JSON(200, scanPath(path))
}

func httpCommand(c *gin.Context) {
	val := c.Params.ByName("command")

	if _, ok := Commands[val]; !ok {
		c.JSON(400, Response{false, "Invalid command"})
		return
	}

	fmt.Println("Received command:", val)

	// Handle requested commmand
	Command <- val

	c.JSON(200, Response{true, "OK"})
}

func httpServe(c *gin.Context) {
	file := c.Request.URL.Query().Get("file")
	if file == "" {
		return
	}

	file = fmt.Sprintf("%s/%s", MediaPath, file)
	if !fileExists(file) {
		c.String(404, "Not found")
		return
	}

	if !omxCanPlay(file) {
		c.String(400, "Invalid format")
		return
	}

	http.ServeFile(c.Writer, c.Request, file)
}

func httpPlay(c *gin.Context) {
	if omxIsActive() {
		c.JSON(400, Response{false, "Player is already running"})
		return
	}

	file := c.Request.FormValue("file")
	if file == "" {
		c.JSON(400, Response{false, "File is required"})
		return
	}

	file = fmt.Sprintf("%s/%s", MediaPath, file)

	if !fileExists(file) {
		c.JSON(400, Response{false, "File does not exist"})
		return
	}

	if !omxCanPlay(file) {
		c.JSON(400, Response{false, "File cannot be played"})
		return
	}

	go omxPlay(file)

	c.JSON(200, Response{true, "OK"})
}

func httpStatus(c *gin.Context) {
	resp := StatusResponse{
		Running:       omxIsActive(),
		File:          CurrentFile,
		Name:          fileToTitle(filepath.Base(CurrentFile)),
		MovieDbApiKey: movieDbApiKey(),
	}

	if stream != nil {
		resp.Duration = durationFromSeconds(stream.duration)
		resp.Position = stream.pos.String()
	}

	c.JSON(200, resp)
}

func httpIndex(c *gin.Context) {
	data, err := Asset("static/index.html")

	if err != nil {
		c.String(400, err.Error())
		return
	}

	c.Data(200, "text/html; charset=utf-8", data)
}

// Retrieve information about the host: uptime, storage, etc
func httpHost(c *gin.Context) {
	commands := map[string]string{
		"uptime":  "uptime",
		"storage": "df -h",
		"os":      "uname -a",
	}

	output := map[string]string{
		"uptime":  "",
		"storage": "",
		"os":      "",
	}

	wg := &sync.WaitGroup{}
	wg.Add(len(commands))

	for k, v := range commands {
		go func(key, name string) {
			defer wg.Done()

			args := strings.Split(name, " ")

			out := bytes.NewBuffer(nil)
			cmd := exec.Command(args[0], args[1:]...)
			cmd.Stdout = out
			cmd.Stderr = out

			if err := cmd.Run(); err != nil {
				log.Println("Failed to execute command", k, err)
				return
			}

			output[key] = out.String()
		}(k, v)
	}

	wg.Wait()

	c.JSON(200, output)
}

func terminate(message string, code int) {
	fmt.Println(message)
	os.Exit(code)
}

func usage() {
	terminate("Usage: omxremote path/to/media/dir", 0)
}

func init() {
	var printVersion bool

	flag.StringVar(&MediaPath, "media", "./", "Path to media files")
	flag.BoolVar(&Frontend, "frontend", true, "Enable frontend applicaiton")
	flag.BoolVar(&Zeroconf, "zeroconf", false, "Enable service advertisement with Zeroconf")
	flag.BoolVar(&printVersion, "v", false, "Print version")
	flag.Parse()

	if printVersion {
		fmt.Printf("omxremote v%v\n", VERSION)
		os.Exit(0)
	}
}

func main() {
	// Expand media path if needed
	MediaPath = strings.Replace(MediaPath, "~", os.Getenv("HOME"), 1)

	// Get path from arguments and remove trailing slash
	MediaPath = strings.TrimRight(MediaPath, "/")

	if !fileExists(MediaPath) {
		terminate(fmt.Sprintf("Directory does not exist: %s", MediaPath), 1)
	}

	// Check if player is installed
	if omxDetect() != nil {
		terminate("omxplayer is not installed", 1)
	}

	// Make sure nothing is running
	omxCleanup()

	// Start a remote command listener
	go omxListen()

	// Start zeroconf service advertisement
	if Zeroconf {
		stopZeroconf := make(chan bool)
		go startZeroConfAdvertisement(stopZeroconf)
	}

	// Disable debugging mode
	gin.SetMode("release")

	// Setup HTTP server
	router := gin.Default()

	// Handle CORS
	router.Use(func(c *gin.Context) {
		c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
		c.Header("Access-Control-Allow-Origin", "*")
		c.Header("Access-Control-Expose-Headers", "*")
	})

	// Server frontend application only if its enabled
	if Frontend == true {
		router.GET("/", httpIndex)
	}

	router.GET("/status", httpStatus)
	router.GET("/browse", httpBrowse)
	router.GET("/play", httpPlay)
	router.GET("/serve", httpServe)
	router.GET("/command/:command", httpCommand)
	router.GET("/host", httpHost)

	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}

	host := os.Getenv("HOST")
	if host == "" {
		host = "0.0.0.0"
	}

	fmt.Println("Starting server on " + host + ":" + port)
	router.Run(host + ":" + port)
}
<|endoftext|>


<commit_before>package main

import (
	"flag"
	"fmt"
	"io"
	"io/ioutil"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"

	"github.com/gin-gonic/gin"
)

const VERSION = "0.1.1"

type Response struct {
	Success bool   `json:"success"`
	Message string `json:"message"`
}

type StatusResponse struct {
	Running       bool   `json:"running"`            // True if player is running
	File          string `json:"file"`               // Path to current media file
	Name          string `json:"name"`               // Titleized filename
	MovieDbApiKey string `json:"tmdb_api_key"`       // TheMovieDb API key
	Position      string `json:"position,omitempty"` // Current position in the movie
	Duration      string `json:"duration,omitempty"` // Movie duration
}

type FileEntry struct {
	Filename string `json:"filename"`
	IsDir    bool   `json:"directory"`
}

var (
	// Regular expression to match all supported video files
	RegexFormats = regexp.MustCompile(`.(avi|mpg|mov|flv|wmv|asf|mpeg|m4v|divx|mp4|mkv)$`)

	// Regular expression to convert filenames to titles
	RegexBrackets = regexp.MustCompile(`[\(\[\]\)]`)
	RegexYear     = regexp.MustCompile(`((19|20)[\d]{2})`)
	RegexEpisode  = regexp.MustCompile(`(?i)S[\d]+E[\d]+`)
	RegexJunk     = regexp.MustCompile(`(?i)(1080p|720p|3d|brrip|bluray|webrip|x264|aac)`)
	RegexSpace    = regexp.MustCompile(`\s{2,}`)

	// OMXPlayer control commands, these are piped via STDIN to omxplayer process
	Commands = map[string]string{
		"pause":             "p",            // Pause/continue playback
		"stop":              "q",            // Stop playback and exit
		"volume_up":         "+",            // Change volume by +3dB
		"volume_down":       "-",            // Change volume by -3dB
		"subtitles":         "s",            // Enable/disable subtitles
		"seek_back":         "\x1b\x5b\x44", // Seek -30 seconds
		"seek_back_fast":    "\x1b\x5b\x42", // Seek -600 second
		"seek_forward":      "\x1b\x5b\x43", // Seek +30 second
		"seek_forward_fast": "\x1b\x5b\x41", // Seek +600 seconds
	}

	// Path where all media files are stored
	MediaPath string

	// Path to omxplayer executable
	OmxPath string

	// Child process for spawning omxplayer
	Omx *exec.Cmd

	// Child process STDIN pipe to send commands
	OmxIn io.WriteCloser

	// Channel to pass along commands to the player routine
	Command chan string

	// Currently playing media file name
	CurrentFile string

	// Enable Zeroconf discovery
	Zeroconf bool

	// Current stream
	stream *Stream
)

// Returns true if specified file exists
func fileExists(path string) bool {
	_, err := os.Stat(path)
	return err == nil
}

// Scan given path for all directories and matching video files.
// If nothing was found it will return an empty slice.
func scanPath(path string) []FileEntry {
	entries := make([]FileEntry, 0)

	files, err := ioutil.ReadDir(path)
	if err != nil {
		return entries
	}

	for _, file := range files {
		entry := FileEntry{
			Filename: file.Name(),
			IsDir:    file.IsDir(),
		}

		// Do not include non-video files in the list
		if !file.IsDir() && !omxCanPlay(file.Name()) {
			continue
		}

		entries = append(entries, entry)
	}

	return entries
}

// Convert media filename to regular title
func fileToTitle(name string) string {
	// Remove file extension from name
	name = strings.Replace(name, filepath.Ext(name), "", 1)

	// Replace all dots with white space
	name = strings.Replace(name, ".", " ", -1)

	// Replace parenteses and brackets
	name = RegexBrackets.ReplaceAllString(name, "")

	// Check if name has a typical torrent format: "name year format etc"
	pos := RegexYear.FindStringIndex(name)
	if len(pos) > 0 {
		name = name[0:pos[0]]
	} else {
		// Check if its an episode, i.e. "S##E##"
		pos = RegexEpisode.FindStringIndex(name)

		if len(pos) > 0 {
			name = name[0:pos[0]]
		}
	}

	// Remove junk stuff
	name = RegexJunk.ReplaceAllString(name, "")

	// Remove any extra white space
	name = RegexSpace.ReplaceAllString(name, "")

	// Truncate space
	name = strings.TrimSpace(name)

	return name
}

// Determine the full path to omxplayer executable. Returns error if not found.
func omxDetect() error {
	buff, err := exec.Command("which", "omxplayer").Output()
	if err != nil {
		return err
	}

	// Set path in global variable
	OmxPath = strings.TrimSpace(string(buff))

	return nil
}

// Start command listener. Commands are coming in through a channel.
func omxListen() {
	Command = make(chan string)

	for {
		command := <-Command

		// Skip command handling of omx player is not active
		if Omx == nil {
			continue
		}

		// Send command to the player
		omxWrite(command)

		// Attempt to kill the process if stop command is requested
		if command == "stop" {
			Omx.Process.Kill()
		}
	}
}

// Start omxplayer playback for a given video file. Returns error if start fails.
func omxPlay(file string) error {
	Omx = exec.Command(
		OmxPath,       // path to omxplayer executable
		"--stats",     // print stats to stdout (buffers, time, etc)
		"--with-info", // print stats about streams before playback
		"--refresh",   // adjust framerate/resolution to video
		"--blank",     // set background to black
		"--adev",      // audio out device
		"hdmi",        // using hdmi for audio/video
		file,          // path to video file
	)

	// Grab child process STDIN
	stdin, err := Omx.StdinPipe()
	if err != nil {
		return err
	}
	defer stdin.Close()

	stderr, err := Omx.StderrPipe()
	if err != nil {
		return err
	}
	defer stderr.Close()

	stdout, err := Omx.StdoutPipe()
	if err != nil {
		return err
	}
	defer stdout.Close()

	stream = NewStream()
	go stream.Start(stdout, stderr)

	// Start omxplayer execution.
	// If successful, something will appear on HDMI display.
	err = Omx.Start()
	if err != nil {
		return err
	}

	// Set current file
	CurrentFile = file

	// Make child's STDIN globally available
	OmxIn = stdin

	// Wait until child process is finished
	err = Omx.Wait()
	if err != nil {
		fmt.Fprintln(os.Stdout, "Process exited with error:", err)
	}

	omxCleanup()

	return nil
}

// Write a command string to the omxplayer process's STDIN
func omxWrite(command string) {
	if OmxIn != nil {
		io.WriteString(OmxIn, Commands[command])
	}
}

// Terminate any running omxplayer processes. Fixes random hangs.
func omxKill() {
	exec.Command("killall", "omxplayer.bin").Output()
	exec.Command("killall", "omxplayer").Output()
}

// Reset internal state and stop any running processes
func omxCleanup() {
	Omx = nil
	OmxIn = nil
	CurrentFile = ""

	omxKill()

	stream = nil
}

// Check if player is currently active
func omxIsActive() bool {
	return Omx != nil
}

// Check if player can play the file
func omxCanPlay(path string) bool {
	if RegexFormats.Match([]byte(path)) {
		return true
	}

	return false
}

// Check if TheMovieDB API could be used
func movieDbApiKey() string {
	return os.Getenv("TMDB_API_KEY")
}

func httpBrowse(c *gin.Context) {
	path := c.Request.FormValue("path")

	if path != "" {
		path = fmt.Sprintf("%s/%s", MediaPath, path)
	} else {
		path = MediaPath
	}

	c.JSON(200, scanPath(path))
}

func httpCommand(c *gin.Context) {
	val := c.Params.ByName("command")

	if _, ok := Commands[val]; !ok {
		c.JSON(400, Response{false, "Invalid command"})
		return
	}

	fmt.Println("Received command:", val)

	// Handle requested commmand
	Command <- val

	c.JSON(200, Response{true, "OK"})
}

func httpServe(c *gin.Context) {
	file := c.Request.URL.Query().Get("file")
	if file == "" {
		return
	}

	file = fmt.Sprintf("%s/%s", MediaPath, file)
	if !fileExists(file) {
		c.String(404, "Not found")
		return
	}

	if !omxCanPlay(file) {
		c.String(400, "Invalid format")
		return
	}

	http.ServeFile(c.Writer, c.Request, file)
}

func httpPlay(c *gin.Context) {
	if omxIsActive() {
		c.JSON(400, Response{false, "Player is already running"})
		return
	}

	file := c.Request.FormValue("file")
	if file == "" {
		c.JSON(400, Response{false, "File is required"})
		return
	}

	file = fmt.Sprintf("%s/%s", MediaPath, file)

	if !fileExists(file) {
		c.JSON(400, Response{false, "File does not exist"})
		return
	}

	if !omxCanPlay(file) {
		c.JSON(400, Response{false, "File cannot be played"})
		return
	}

	go omxPlay(file)

	c.JSON(200, Response{true, "OK"})
}

func httpStatus(c *gin.Context) {
	resp := StatusResponse{
		Running:       omxIsActive(),
		File:          CurrentFile,
		Name:          fileToTitle(filepath.Base(CurrentFile)),
		MovieDbApiKey: movieDbApiKey(),
	}

	if stream != nil {
		resp.Duration = durationFromSeconds(stream.duration)
		resp.Position = stream.pos.String()
	}

	c.JSON(200, resp)
}

func httpIndex(c *gin.Context) {
	data, err := Asset("static/index.html")

	if err != nil {
		c.String(400, err.Error())
		return
	}

	c.Data(200, "text/html; charset=utf-8", data)
}

func terminate(message string, code int) {
	fmt.Println(message)
	os.Exit(code)
}

func usage() {
	terminate("Usage: omxremote path/to/media/dir", 0)
}

func init() {
	var printVersion bool

	flag.StringVar(&MediaPath, "media", "./", "Path to media files")
	flag.BoolVar(&Zeroconf, "zeroconf", false, "Enable service advertisement with Zeroconf")
	flag.BoolVar(&printVersion, "v", false, "Print version")
	flag.Parse()

	if printVersion {
		fmt.Printf("omxremote v%v\n", VERSION)
		os.Exit(0)
	}
}

func main() {
	// Expand media path if needed
	MediaPath = strings.Replace(MediaPath, "~", os.Getenv("HOME"), 1)

	// Get path from arguments and remove trailing slash
	MediaPath = strings.TrimRight(MediaPath, "/")

	if !fileExists(MediaPath) {
		terminate(fmt.Sprintf("Directory does not exist: %s", MediaPath), 1)
	}

	// Check if player is installed
	if omxDetect() != nil {
		terminate("omxplayer is not installed", 1)
	}

	// Make sure nothing is running
	omxCleanup()

	// Start a remote command listener
	go omxListen()

	// Start zeroconf service advertisement
	if (Zeroconf) {
		stopZeroconf := make(chan bool)
		go startZeroConfAdvertisement(stopZeroconf)
	}

	// Disable debugging mode
	gin.SetMode("release")

	// Setup HTTP server
	router := gin.Default()

	// Handle CORS
	router.Use(func(c *gin.Context) {
		c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
		c.Header("Access-Control-Allow-Origin", "*")
		c.Header("Access-Control-Expose-Headers", "*")
	})

	router.GET("/", httpIndex)
	router.GET("/status", httpStatus)
	router.GET("/browse", httpBrowse)
	router.GET("/play", httpPlay)
	router.GET("/serve", httpServe)
	router.GET("/command/:command", httpCommand)

	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}

	host := os.Getenv("HOST")
	if host == "" {
		host = "0.0.0.0"
	}

	fmt.Println("Starting server on " + host + ":" + port)
	router.Run(host + ":" + port)
}
<commit_msg>Add option to disable frontend app<commit_after>package main

import (
	"flag"
	"fmt"
	"io"
	"io/ioutil"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"

	"github.com/gin-gonic/gin"
)

const VERSION = "0.1.1"

type Response struct {
	Success bool   `json:"success"`
	Message string `json:"message"`
}

type StatusResponse struct {
	Running       bool   `json:"running"`            // True if player is running
	File          string `json:"file"`               // Path to current media file
	Name          string `json:"name"`               // Titleized filename
	MovieDbApiKey string `json:"tmdb_api_key"`       // TheMovieDb API key
	Position      string `json:"position,omitempty"` // Current position in the movie
	Duration      string `json:"duration,omitempty"` // Movie duration
}

type FileEntry struct {
	Filename string `json:"filename"`
	IsDir    bool   `json:"directory"`
}

var (
	// Regular expression to match all supported video files
	RegexFormats = regexp.MustCompile(`.(avi|mpg|mov|flv|wmv|asf|mpeg|m4v|divx|mp4|mkv)$`)

	// Regular expression to convert filenames to titles
	RegexBrackets = regexp.MustCompile(`[\(\[\]\)]`)
	RegexYear     = regexp.MustCompile(`((19|20)[\d]{2})`)
	RegexEpisode  = regexp.MustCompile(`(?i)S[\d]+E[\d]+`)
	RegexJunk     = regexp.MustCompile(`(?i)(1080p|720p|3d|brrip|bluray|webrip|x264|aac)`)
	RegexSpace    = regexp.MustCompile(`\s{2,}`)

	// OMXPlayer control commands, these are piped via STDIN to omxplayer process
	Commands = map[string]string{
		"pause":             "p",            // Pause/continue playback
		"stop":              "q",            // Stop playback and exit
		"volume_up":         "+",            // Change volume by +3dB
		"volume_down":       "-",            // Change volume by -3dB
		"subtitles":         "s",            // Enable/disable subtitles
		"seek_back":         "\x1b\x5b\x44", // Seek -30 seconds
		"seek_back_fast":    "\x1b\x5b\x42", // Seek -600 second
		"seek_forward":      "\x1b\x5b\x43", // Seek +30 second
		"seek_forward_fast": "\x1b\x5b\x41", // Seek +600 seconds
	}

	// Path where all media files are stored
	MediaPath string

	// Path to omxplayer executable
	OmxPath string

	// Child process for spawning omxplayer
	Omx *exec.Cmd

	// Child process STDIN pipe to send commands
	OmxIn io.WriteCloser

	// Channel to pass along commands to the player routine
	Command chan string

	// Currently playing media file name
	CurrentFile string

	// Enable Zeroconf discovery
	Zeroconf bool

	// Serve frontend app
	Frontend bool

	// Current stream
	stream *Stream
)

// Returns true if specified file exists
func fileExists(path string) bool {
	_, err := os.Stat(path)
	return err == nil
}

// Scan given path for all directories and matching video files.
// If nothing was found it will return an empty slice.
func scanPath(path string) []FileEntry {
	entries := make([]FileEntry, 0)

	files, err := ioutil.ReadDir(path)
	if err != nil {
		return entries
	}

	for _, file := range files {
		entry := FileEntry{
			Filename: file.Name(),
			IsDir:    file.IsDir(),
		}

		// Do not include non-video files in the list
		if !file.IsDir() && !omxCanPlay(file.Name()) {
			continue
		}

		entries = append(entries, entry)
	}

	return entries
}

// Convert media filename to regular title
func fileToTitle(name string) string {
	// Remove file extension from name
	name = strings.Replace(name, filepath.Ext(name), "", 1)

	// Replace all dots with white space
	name = strings.Replace(name, ".", " ", -1)

	// Replace parenteses and brackets
	name = RegexBrackets.ReplaceAllString(name, "")

	// Check if name has a typical torrent format: "name year format etc"
	pos := RegexYear.FindStringIndex(name)
	if len(pos) > 0 {
		name = name[0:pos[0]]
	} else {
		// Check if its an episode, i.e. "S##E##"
		pos = RegexEpisode.FindStringIndex(name)

		if len(pos) > 0 {
			name = name[0:pos[0]]
		}
	}

	// Remove junk stuff
	name = RegexJunk.ReplaceAllString(name, "")

	// Remove any extra white space
	name = RegexSpace.ReplaceAllString(name, "")

	// Truncate space
	name = strings.TrimSpace(name)

	return name
}

// Determine the full path to omxplayer executable. Returns error if not found.
func omxDetect() error {
	buff, err := exec.Command("which", "omxplayer").Output()
	if err != nil {
		return err
	}

	// Set path in global variable
	OmxPath = strings.TrimSpace(string(buff))

	return nil
}

// Start command listener. Commands are coming in through a channel.
func omxListen() {
	Command = make(chan string)

	for {
		command := <-Command

		// Skip command handling of omx player is not active
		if Omx == nil {
			continue
		}

		// Send command to the player
		omxWrite(command)

		// Attempt to kill the process if stop command is requested
		if command == "stop" {
			Omx.Process.Kill()
		}
	}
}

// Start omxplayer playback for a given video file. Returns error if start fails.
func omxPlay(file string) error {
	Omx = exec.Command(
		OmxPath,       // path to omxplayer executable
		"--stats",     // print stats to stdout (buffers, time, etc)
		"--with-info", // print stats about streams before playback
		"--refresh",   // adjust framerate/resolution to video
		"--blank",     // set background to black
		"--adev",      // audio out device
		"hdmi",        // using hdmi for audio/video
		file,          // path to video file
	)

	// Grab child process STDIN
	stdin, err := Omx.StdinPipe()
	if err != nil {
		return err
	}
	defer stdin.Close()

	stderr, err := Omx.StderrPipe()
	if err != nil {
		return err
	}
	defer stderr.Close()

	stdout, err := Omx.StdoutPipe()
	if err != nil {
		return err
	}
	defer stdout.Close()

	stream = NewStream()
	go stream.Start(stdout, stderr)

	// Start omxplayer execution.
	// If successful, something will appear on HDMI display.
	err = Omx.Start()
	if err != nil {
		return err
	}

	// Set current file
	CurrentFile = file

	// Make child's STDIN globally available
	OmxIn = stdin

	// Wait until child process is finished
	err = Omx.Wait()
	if err != nil {
		fmt.Fprintln(os.Stdout, "Process exited with error:", err)
	}

	omxCleanup()

	return nil
}

// Write a command string to the omxplayer process's STDIN
func omxWrite(command string) {
	if OmxIn != nil {
		io.WriteString(OmxIn, Commands[command])
	}
}

// Terminate any running omxplayer processes. Fixes random hangs.
func omxKill() {
	exec.Command("killall", "omxplayer.bin").Output()
	exec.Command("killall", "omxplayer").Output()
}

// Reset internal state and stop any running processes
func omxCleanup() {
	Omx = nil
	OmxIn = nil
	CurrentFile = ""

	omxKill()

	stream = nil
}

// Check if player is currently active
func omxIsActive() bool {
	return Omx != nil
}

// Check if player can play the file
func omxCanPlay(path string) bool {
	if RegexFormats.Match([]byte(path)) {
		return true
	}

	return false
}

// Check if TheMovieDB API could be used
func movieDbApiKey() string {
	return os.Getenv("TMDB_API_KEY")
}

func httpBrowse(c *gin.Context) {
	path := c.Request.FormValue("path")

	if path != "" {
		path = fmt.Sprintf("%s/%s", MediaPath, path)
	} else {
		path = MediaPath
	}

	c.JSON(200, scanPath(path))
}

func httpCommand(c *gin.Context) {
	val := c.Params.ByName("command")

	if _, ok := Commands[val]; !ok {
		c.JSON(400, Response{false, "Invalid command"})
		return
	}

	fmt.Println("Received command:", val)

	// Handle requested commmand
	Command <- val

	c.JSON(200, Response{true, "OK"})
}

func httpServe(c *gin.Context) {
	file := c.Request.URL.Query().Get("file")
	if file == "" {
		return
	}

	file = fmt.Sprintf("%s/%s", MediaPath, file)
	if !fileExists(file) {
		c.String(404, "Not found")
		return
	}

	if !omxCanPlay(file) {
		c.String(400, "Invalid format")
		return
	}

	http.ServeFile(c.Writer, c.Request, file)
}

func httpPlay(c *gin.Context) {
	if omxIsActive() {
		c.JSON(400, Response{false, "Player is already running"})
		return
	}

	file := c.Request.FormValue("file")
	if file == "" {
		c.JSON(400, Response{false, "File is required"})
		return
	}

	file = fmt.Sprintf("%s/%s", MediaPath, file)

	if !fileExists(file) {
		c.JSON(400, Response{false, "File does not exist"})
		return
	}

	if !omxCanPlay(file) {
		c.JSON(400, Response{false, "File cannot be played"})
		return
	}

	go omxPlay(file)

	c.JSON(200, Response{true, "OK"})
}

func httpStatus(c *gin.Context) {
	resp := StatusResponse{
		Running:       omxIsActive(),
		File:          CurrentFile,
		Name:          fileToTitle(filepath.Base(CurrentFile)),
		MovieDbApiKey: movieDbApiKey(),
	}

	if stream != nil {
		resp.Duration = durationFromSeconds(stream.duration)
		resp.Position = stream.pos.String()
	}

	c.JSON(200, resp)
}

func httpIndex(c *gin.Context) {
	data, err := Asset("static/index.html")

	if err != nil {
		c.String(400, err.Error())
		return
	}

	c.Data(200, "text/html; charset=utf-8", data)
}

func terminate(message string, code int) {
	fmt.Println(message)
	os.Exit(code)
}

func usage() {
	terminate("Usage: omxremote path/to/media/dir", 0)
}

func init() {
	var printVersion bool

	flag.StringVar(&MediaPath, "media", "./", "Path to media files")
	flag.BoolVar(&Frontend, "frontend", true, "Enable frontend applicaiton")
	flag.BoolVar(&Zeroconf, "zeroconf", false, "Enable service advertisement with Zeroconf")
	flag.BoolVar(&printVersion, "v", false, "Print version")
	flag.Parse()

	if printVersion {
		fmt.Printf("omxremote v%v\n", VERSION)
		os.Exit(0)
	}
}

func main() {
	// Expand media path if needed
	MediaPath = strings.Replace(MediaPath, "~", os.Getenv("HOME"), 1)

	// Get path from arguments and remove trailing slash
	MediaPath = strings.TrimRight(MediaPath, "/")

	if !fileExists(MediaPath) {
		terminate(fmt.Sprintf("Directory does not exist: %s", MediaPath), 1)
	}

	// Check if player is installed
	if omxDetect() != nil {
		terminate("omxplayer is not installed", 1)
	}

	// Make sure nothing is running
	omxCleanup()

	// Start a remote command listener
	go omxListen()

	// Start zeroconf service advertisement
	if Zeroconf {
		stopZeroconf := make(chan bool)
		go startZeroConfAdvertisement(stopZeroconf)
	}

	// Disable debugging mode
	gin.SetMode("release")

	// Setup HTTP server
	router := gin.Default()

	// Handle CORS
	router.Use(func(c *gin.Context) {
		c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
		c.Header("Access-Control-Allow-Origin", "*")
		c.Header("Access-Control-Expose-Headers", "*")
	})

	// Server frontend application only if its enabled
	if Frontend == true {
		router.GET("/", httpIndex)
	}

	router.GET("/status", httpStatus)
	router.GET("/browse", httpBrowse)
	router.GET("/play", httpPlay)
	router.GET("/serve", httpServe)
	router.GET("/command/:command", httpCommand)

	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}

	host := os.Getenv("HOST")
	if host == "" {
		host = "0.0.0.0"
	}

	fmt.Println("Starting server on " + host + ":" + port)
	router.Run(host + ":" + port)
}
<|endoftext|>


<commit_before>/*
 * (c) 2013, Caoimhe Chaos <caoimhechaos@protonmail.com>,
 *	     Starship Factory. All rights reserved.
 *
 * Redistribution and use in source  and binary forms, with or without
 * modification, are permitted  provided that the following conditions
 * are met:
 *
 * * Redistributions of  source code  must retain the  above copyright
 *   notice, this list of conditions and the following disclaimer.
 * * Redistributions in binary form must reproduce the above copyright
 *   notice, this  list of conditions and the  following disclaimer in
 *   the  documentation  and/or  other  materials  provided  with  the
 *   distribution.
 * * Neither  the name  of the Starship Factory  nor the  name  of its
 *   contributors may  be used to endorse or  promote products derived
 *   from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS"  AND ANY EXPRESS  OR IMPLIED WARRANTIES  OF MERCHANTABILITY
 * AND FITNESS  FOR A PARTICULAR  PURPOSE ARE DISCLAIMED. IN  NO EVENT
 * SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
 * INDIRECT, INCIDENTAL, SPECIAL,  EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED  TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE,  DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
 * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
 * STRICT  LIABILITY,  OR  TORT  (INCLUDING NEGLIGENCE  OR  OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
 * OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package main

import (
	"database/cassandra"
	"encoding/json"
	"expvar"
	"fmt"
	"html/template"
	"log"
	"net/http"

	"ancient-solutions.com/ancientauth"
)

// Individual search results.
type SearchResult struct {
	Name    string
	Picture string
	Path    string
}

// Search results with different categories of results.
// They are separated so they can be grouped together.
type CategorizedSearchResult struct {
	Products []*SearchResult
	Vendors  []*SearchResult
}

// Total number of requests received.
var numRequests *expvar.Int = expvar.NewInt("num-http-requests")

// Total number of requests to the API.
var numAPIRequests *expvar.Int = expvar.NewInt("num-api-requests")

// Number of requests which didn't fit the requested scope.
var numDisallowedScope *expvar.Int = expvar.NewInt("num-requests-rejected-for-scope")

// Number of JSON marshalling errors.
var numJSONMarshalErrors *expvar.Int = expvar.NewInt("num-json-marshalling-errors")

// Map of HTTP write errors by type.
var numHTTPWriteErrors *expvar.Map = expvar.NewMap("num-http-write-errors")

// Map of Cassandra errors by type.
var numCassandraErrors *expvar.Map = expvar.NewMap("num-cassandra-errors")

type ProductSearchForm struct {
	authenticator        *ancientauth.Authenticator
	permissionDeniedTmpl *template.Template
	searchifTmpl         *template.Template
	scope                string
}

type ProductSearchAPI struct {
	authenticator *ancientauth.Authenticator
	client        *cassandra.RetryCassandraClient
	scope         string
}

func UUID2String(uuid []byte) string {
	var ret string
	var i int

	for i = 0; i < 4; i++ {
		ret += fmt.Sprintf("%02X", uuid[i])
	}

	ret += "-"

	for i = 4; i < 6; i++ {
		ret += fmt.Sprintf("%02X", uuid[i])
	}

	ret += "-"

	for i = 6; i < 8; i++ {
		ret += fmt.Sprintf("%02X", uuid[i])
	}

	ret += "-"

	for i = 8; i < 12; i++ {
		ret += fmt.Sprintf("%02X", uuid[i])
	}

	ret += "-"

	for i = 12; i < len(uuid); i++ {
		ret += fmt.Sprintf("%02X", uuid[i])
	}

	return ret
}

func (self *ProductSearchForm) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	var err error
	numRequests.Add(1)

	if self.authenticator.GetAuthenticatedUser(req) == "" {
		self.authenticator.RequestAuthorization(w, req)
		return
	}

	// Check the user is in the reqeuested scope.
	if !self.authenticator.IsAuthenticatedScope(req, self.scope) {
		numDisallowedScope.Add(1)
		w.WriteHeader(http.StatusForbidden)
		err = self.permissionDeniedTmpl.Execute(w, self.scope)
		if err != nil {
			log.Print("Error executing permission denied template: ", err)
		}
		return
	}

	// Otherwise, we simply serve the search template.
	err = self.searchifTmpl.Execute(w, nil)
	if err != nil {
		log.Print("Error executing permission denied template: ", err)
	}
}

func (self *ProductSearchAPI) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	var err error
	var query string = req.FormValue("q")
	var rawdata []byte
	var res CategorizedSearchResult

	numRequests.Add(1)
	numAPIRequests.Add(1)

	// Check the user is in the reqeuested scope.
	if !self.authenticator.IsAuthenticatedScope(req, self.scope) {
		numDisallowedScope.Add(1)
		http.Error(w,
			"You are not in the right group to access this resource",
			http.StatusForbidden)
		return
	}

	if len(query) >= 3 {
		var product_results [][]byte
		var cp *cassandra.ColumnParent = cassandra.NewColumnParent()
		var pred *cassandra.SlicePredicate = cassandra.NewSlicePredicate()
		var kr *cassandra.KeyRange = cassandra.NewKeyRange()
		var colmap map[string][]*cassandra.ColumnOrSuperColumn
		var slices []*cassandra.KeySlice
		var ire *cassandra.InvalidRequestException
		var ue *cassandra.UnavailableException
		var te *cassandra.TimedOutException
		var endkey []byte = []byte(query)
		var r *SearchResult
		var pos int = len(endkey) - 1

		// Handle overflows
		for endkey[pos] == 255 && pos > 0 {
			endkey[pos] = 0
			pos--
		}

		// Produce endkey by incrementing the last byte of the start key.
		endkey[pos]++

		// Search for projects with the given name.
		cp.ColumnFamily = "products_byname"
		pred.ColumnNames = [][]byte{[]byte("product")}

		kr.StartKey = []byte(query)
		kr.EndKey = endkey
		kr.Count = 32 // Limit to 32 results.

		slices, ire, ue, te, err = self.client.GetRangeSlices(
			cp, pred, kr, cassandra.ConsistencyLevel_ONE)
		if ire != nil {
			log.Print("Error fetching products_byname: ", ire.Why)
			numCassandraErrors.Add("invalid-request", 1)
			http.Error(w, ire.Why, http.StatusInternalServerError)
			return
		}
		if ue != nil {
			log.Print("Cassandra unavailable when fetching products_byname")
			numCassandraErrors.Add("invalid-request", 1)
			http.Error(w, "Database unavailable", http.StatusInternalServerError)
			return
		}
		if te != nil {
			log.Print("Cassandra timed out when fetching products_byname")
			numCassandraErrors.Add("timeout", 1)
			http.Error(w, "Database timed out", http.StatusInternalServerError)
			return
		}
		if err != nil {
			log.Print("OS error when fetching products_byname: ", err)
			numCassandraErrors.Add("generic-error", 1)
			http.Error(w, "OS error talking to database",
				http.StatusInternalServerError)
			return
		}

		for _, slice := range slices {
			for _, csc := range slice.Columns {
				var col *cassandra.Column = csc.Column
				if col == nil || !col.IsSetValue() {
					continue
				}

				if string(col.Name) != "product" {
					log.Print("Bizarre products_byname row ",
						string(slice.Key), " (", slice.Key, "), has ",
						string(col.Name), " (", col.Name, ")")
					continue
				}

				r = new(SearchResult)
				r.Name = string(slice.Key)
				r.Path = "/product/" + UUID2String(col.Value)
				res.Products = append(res.Products, r)
			}
		}

		// Search for projects with the given name.
		cp.ColumnFamily = "products_bybarcode"
		pred.ColumnNames = [][]byte{[]byte("product")}

		kr.StartKey = []byte(query)
		kr.EndKey = endkey
		kr.Count = 32 // Limit to 32 results.

		slices, ire, ue, te, err = self.client.GetRangeSlices(
			cp, pred, kr, cassandra.ConsistencyLevel_ONE)
		if ire != nil {
			log.Print("Error fetching products_bybarcode: ", ire.Why)
			numCassandraErrors.Add("invalid-request", 1)
			http.Error(w, ire.Why, http.StatusInternalServerError)
			return
		}
		if ue != nil {
			log.Print("Cassandra unavailable when fetching products_bybarcode")
			numCassandraErrors.Add("invalid-request", 1)
			http.Error(w, "Database unavailable", http.StatusInternalServerError)
			return
		}
		if te != nil {
			log.Print("Cassandra timed out when fetching products_bybarcode")
			numCassandraErrors.Add("timeout", 1)
			http.Error(w, "Database timed out", http.StatusInternalServerError)
			return
		}
		if err != nil {
			log.Print("OS error when fetching products_bybarcode: ", err)
			numCassandraErrors.Add("generic-error", 1)
			http.Error(w, "OS error talking to database",
				http.StatusInternalServerError)
			return
		}

		for _, slice := range slices {
			for _, csc := range slice.Columns {
				var col *cassandra.Column = csc.Column
				if col == nil || !col.IsSetValue() {
					continue
				}

				if string(col.Name) != "product" {
					log.Print("Bizarre products_bybarcode row ",
						string(slice.Key), " (", slice.Key, "), has ",
						string(col.Name), " (", col.Name, ")")
					continue
				}

				product_results = append(product_results, col.Value)
			}
		}

		// Now, fetch all the product names for products we spotted above.
		cp.ColumnFamily = "products"
		pred.ColumnNames = [][]byte{[]byte("name")}
		colmap, ire, ue, te, err = self.client.MultigetSlice(
			product_results, cp, pred, cassandra.ConsistencyLevel_ONE)
		if ire != nil {
			log.Print("Error fetching products: ", ire.Why)
			numCassandraErrors.Add("invalid-request", 1)
			http.Error(w, ire.Why, http.StatusInternalServerError)
			return
		}
		if ue != nil {
			log.Print("Cassandra unavailable when fetching products")
			numCassandraErrors.Add("invalid-request", 1)
			http.Error(w, "Database unavailable", http.StatusInternalServerError)
			return
		}
		if te != nil {
			log.Print("Cassandra timed out when fetching products")
			numCassandraErrors.Add("timeout", 1)
			http.Error(w, "Database timed out", http.StatusInternalServerError)
			return
		}
		if err != nil {
			log.Print("OS error when fetching products: ", err)
			numCassandraErrors.Add("generic-error", 1)
			http.Error(w, "OS error talking to database",
				http.StatusInternalServerError)
			return
		}

		for key, cscv := range colmap {
			for _, csc := range cscv {
				var col *cassandra.Column = csc.Column
				if col == nil || !col.IsSetValue() {
					continue
				}

				if string(col.Name) != "name" {
					log.Print("Cassandra returned additional column ",
						string(col.Name), " (", col.Name, ") for row ",
						key, " (", []byte(key), ")")
					continue
				}

				r = new(SearchResult)
				r.Name = string(col.Value)
				r.Path = "/product/" + UUID2String([]byte(key))
				res.Products = append(res.Products, r)
			}
		}

		// TODO(caoimhe): stub
		r = new(SearchResult)
		r.Name = "ACME Inc."
		r.Path = "/vendor/acme"
		res.Vendors = append(res.Vendors, r)

		r = new(SearchResult)
		r.Name = "Starship Factory"
		r.Path = "/vendor/starshipfactory"
		res.Vendors = append(res.Vendors, r)

		r = new(SearchResult)
		r.Name = "RaumZeitLabor e.V."
		r.Path = "/vendor/rzl"
		res.Vendors = append(res.Vendors, r)

		r = new(SearchResult)
		r.Name = "Doctor in the TARDIS"
		r.Path = "/vendor/doctor"
		res.Vendors = append(res.Vendors, r)
	}

	rawdata, err = json.Marshal(res)
	if err != nil {
		log.Print("Error marshalling JSON: ", err)
		numJSONMarshalErrors.Add(1)
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	w.Header().Add("Content-type", "application/json")
	w.WriteHeader(http.StatusOK)
	_, err = w.Write(rawdata)
	if err != nil {
		log.Print("Error writing JSON response: ", err)
		numHTTPWriteErrors.Add(err.Error(), 1)
	}
}
<commit_msg>Don't try fetching barcode details if we don't have any to look up.<commit_after>/*
 * (c) 2013, Caoimhe Chaos <caoimhechaos@protonmail.com>,
 *	     Starship Factory. All rights reserved.
 *
 * Redistribution and use in source  and binary forms, with or without
 * modification, are permitted  provided that the following conditions
 * are met:
 *
 * * Redistributions of  source code  must retain the  above copyright
 *   notice, this list of conditions and the following disclaimer.
 * * Redistributions in binary form must reproduce the above copyright
 *   notice, this  list of conditions and the  following disclaimer in
 *   the  documentation  and/or  other  materials  provided  with  the
 *   distribution.
 * * Neither  the name  of the Starship Factory  nor the  name  of its
 *   contributors may  be used to endorse or  promote products derived
 *   from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS"  AND ANY EXPRESS  OR IMPLIED WARRANTIES  OF MERCHANTABILITY
 * AND FITNESS  FOR A PARTICULAR  PURPOSE ARE DISCLAIMED. IN  NO EVENT
 * SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
 * INDIRECT, INCIDENTAL, SPECIAL,  EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED  TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE,  DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
 * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
 * STRICT  LIABILITY,  OR  TORT  (INCLUDING NEGLIGENCE  OR  OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
 * OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package main

import (
	"database/cassandra"
	"encoding/json"
	"expvar"
	"fmt"
	"html/template"
	"log"
	"net/http"

	"ancient-solutions.com/ancientauth"
)

// Individual search results.
type SearchResult struct {
	Name    string
	Picture string
	Path    string
}

// Search results with different categories of results.
// They are separated so they can be grouped together.
type CategorizedSearchResult struct {
	Products []*SearchResult
	Vendors  []*SearchResult
}

// Total number of requests received.
var numRequests *expvar.Int = expvar.NewInt("num-http-requests")

// Total number of requests to the API.
var numAPIRequests *expvar.Int = expvar.NewInt("num-api-requests")

// Number of requests which didn't fit the requested scope.
var numDisallowedScope *expvar.Int = expvar.NewInt("num-requests-rejected-for-scope")

// Number of JSON marshalling errors.
var numJSONMarshalErrors *expvar.Int = expvar.NewInt("num-json-marshalling-errors")

// Map of HTTP write errors by type.
var numHTTPWriteErrors *expvar.Map = expvar.NewMap("num-http-write-errors")

// Map of Cassandra errors by type.
var numCassandraErrors *expvar.Map = expvar.NewMap("num-cassandra-errors")

type ProductSearchForm struct {
	authenticator        *ancientauth.Authenticator
	permissionDeniedTmpl *template.Template
	searchifTmpl         *template.Template
	scope                string
}

type ProductSearchAPI struct {
	authenticator *ancientauth.Authenticator
	client        *cassandra.RetryCassandraClient
	scope         string
}

func UUID2String(uuid []byte) string {
	var ret string
	var i int

	for i = 0; i < 4; i++ {
		ret += fmt.Sprintf("%02X", uuid[i])
	}

	ret += "-"

	for i = 4; i < 6; i++ {
		ret += fmt.Sprintf("%02X", uuid[i])
	}

	ret += "-"

	for i = 6; i < 8; i++ {
		ret += fmt.Sprintf("%02X", uuid[i])
	}

	ret += "-"

	for i = 8; i < 12; i++ {
		ret += fmt.Sprintf("%02X", uuid[i])
	}

	ret += "-"

	for i = 12; i < len(uuid); i++ {
		ret += fmt.Sprintf("%02X", uuid[i])
	}

	return ret
}

func (self *ProductSearchForm) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	var err error
	numRequests.Add(1)

	if self.authenticator.GetAuthenticatedUser(req) == "" {
		self.authenticator.RequestAuthorization(w, req)
		return
	}

	// Check the user is in the reqeuested scope.
	if !self.authenticator.IsAuthenticatedScope(req, self.scope) {
		numDisallowedScope.Add(1)
		w.WriteHeader(http.StatusForbidden)
		err = self.permissionDeniedTmpl.Execute(w, self.scope)
		if err != nil {
			log.Print("Error executing permission denied template: ", err)
		}
		return
	}

	// Otherwise, we simply serve the search template.
	err = self.searchifTmpl.Execute(w, nil)
	if err != nil {
		log.Print("Error executing permission denied template: ", err)
	}
}

func (self *ProductSearchAPI) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	var err error
	var query string = req.FormValue("q")
	var rawdata []byte
	var res CategorizedSearchResult

	numRequests.Add(1)
	numAPIRequests.Add(1)

	// Check the user is in the reqeuested scope.
	if !self.authenticator.IsAuthenticatedScope(req, self.scope) {
		numDisallowedScope.Add(1)
		http.Error(w,
			"You are not in the right group to access this resource",
			http.StatusForbidden)
		return
	}

	if len(query) >= 3 {
		var product_results [][]byte
		var cp *cassandra.ColumnParent = cassandra.NewColumnParent()
		var pred *cassandra.SlicePredicate = cassandra.NewSlicePredicate()
		var kr *cassandra.KeyRange = cassandra.NewKeyRange()
		var colmap map[string][]*cassandra.ColumnOrSuperColumn
		var slices []*cassandra.KeySlice
		var ire *cassandra.InvalidRequestException
		var ue *cassandra.UnavailableException
		var te *cassandra.TimedOutException
		var endkey []byte = []byte(query)
		var r *SearchResult
		var pos int = len(endkey) - 1

		// Handle overflows
		for endkey[pos] == 255 && pos > 0 {
			endkey[pos] = 0
			pos--
		}

		// Produce endkey by incrementing the last byte of the start key.
		endkey[pos]++

		// Search for projects with the given name.
		cp.ColumnFamily = "products_byname"
		pred.ColumnNames = [][]byte{[]byte("product")}

		kr.StartKey = []byte(query)
		kr.EndKey = endkey
		kr.Count = 32 // Limit to 32 results.

		slices, ire, ue, te, err = self.client.GetRangeSlices(
			cp, pred, kr, cassandra.ConsistencyLevel_ONE)
		if ire != nil {
			log.Print("Error fetching products_byname: ", ire.Why)
			numCassandraErrors.Add("invalid-request", 1)
			http.Error(w, ire.Why, http.StatusInternalServerError)
			return
		}
		if ue != nil {
			log.Print("Cassandra unavailable when fetching products_byname")
			numCassandraErrors.Add("invalid-request", 1)
			http.Error(w, "Database unavailable", http.StatusInternalServerError)
			return
		}
		if te != nil {
			log.Print("Cassandra timed out when fetching products_byname")
			numCassandraErrors.Add("timeout", 1)
			http.Error(w, "Database timed out", http.StatusInternalServerError)
			return
		}
		if err != nil {
			log.Print("OS error when fetching products_byname: ", err)
			numCassandraErrors.Add("generic-error", 1)
			http.Error(w, "OS error talking to database",
				http.StatusInternalServerError)
			return
		}

		for _, slice := range slices {
			for _, csc := range slice.Columns {
				var col *cassandra.Column = csc.Column
				if col == nil || !col.IsSetValue() {
					continue
				}

				if string(col.Name) != "product" {
					log.Print("Bizarre products_byname row ",
						string(slice.Key), " (", slice.Key, "), has ",
						string(col.Name), " (", col.Name, ")")
					continue
				}

				r = new(SearchResult)
				r.Name = string(slice.Key)
				r.Path = "/product/" + UUID2String(col.Value)
				res.Products = append(res.Products, r)
			}
		}

		// Search for projects with the given name.
		cp.ColumnFamily = "products_bybarcode"
		pred.ColumnNames = [][]byte{[]byte("product")}

		kr.StartKey = []byte(query)
		kr.EndKey = endkey
		kr.Count = 32 // Limit to 32 results.

		slices, ire, ue, te, err = self.client.GetRangeSlices(
			cp, pred, kr, cassandra.ConsistencyLevel_ONE)
		if ire != nil {
			log.Print("Error fetching products_bybarcode: ", ire.Why)
			numCassandraErrors.Add("invalid-request", 1)
			http.Error(w, ire.Why, http.StatusInternalServerError)
			return
		}
		if ue != nil {
			log.Print("Cassandra unavailable when fetching products_bybarcode")
			numCassandraErrors.Add("invalid-request", 1)
			http.Error(w, "Database unavailable", http.StatusInternalServerError)
			return
		}
		if te != nil {
			log.Print("Cassandra timed out when fetching products_bybarcode")
			numCassandraErrors.Add("timeout", 1)
			http.Error(w, "Database timed out", http.StatusInternalServerError)
			return
		}
		if err != nil {
			log.Print("OS error when fetching products_bybarcode: ", err)
			numCassandraErrors.Add("generic-error", 1)
			http.Error(w, "OS error talking to database",
				http.StatusInternalServerError)
			return
		}

		for _, slice := range slices {
			for _, csc := range slice.Columns {
				var col *cassandra.Column = csc.Column
				if col == nil || !col.IsSetValue() {
					continue
				}

				if string(col.Name) != "product" {
					log.Print("Bizarre products_bybarcode row ",
						string(slice.Key), " (", slice.Key, "), has ",
						string(col.Name), " (", col.Name, ")")
					continue
				}

				product_results = append(product_results, col.Value)
			}
		}

		// Now, fetch all the product names for products we spotted above.
		if len(product_results) > 0 {
			cp.ColumnFamily = "products"
			pred.ColumnNames = [][]byte{[]byte("name")}
			colmap, ire, ue, te, err = self.client.MultigetSlice(
				product_results, cp, pred, cassandra.ConsistencyLevel_ONE)
			if ire != nil {
				log.Print("Error fetching products: ", ire.Why)
				numCassandraErrors.Add("invalid-request", 1)
				http.Error(w, ire.Why, http.StatusInternalServerError)
				return
			}
			if ue != nil {
				log.Print("Cassandra unavailable when fetching products")
				numCassandraErrors.Add("invalid-request", 1)
				http.Error(w, "Database unavailable",
					http.StatusInternalServerError)
				return
			}
			if te != nil {
				log.Print("Cassandra timed out when fetching products")
				numCassandraErrors.Add("timeout", 1)
				http.Error(w, "Database timed out",
					http.StatusInternalServerError)
				return
			}
			if err != nil {
				log.Print("OS error when fetching products: ", err)
				numCassandraErrors.Add("generic-error", 1)
				http.Error(w, "OS error talking to database",
					http.StatusInternalServerError)
				return
			}

			for key, cscv := range colmap {
				for _, csc := range cscv {
					var col *cassandra.Column = csc.Column
					if col == nil || !col.IsSetValue() {
						continue
					}

					if string(col.Name) != "name" {
						log.Print("Cassandra returned additional column ",
							string(col.Name), " (", col.Name, ") for row ",
							key, " (", []byte(key), ")")
						continue
					}

					r = new(SearchResult)
					r.Name = string(col.Value)
					r.Path = "/product/" + UUID2String([]byte(key))
					res.Products = append(res.Products, r)
				}
			}
		}

		// TODO(caoimhe): stub
		r = new(SearchResult)
		r.Name = "ACME Inc."
		r.Path = "/vendor/acme"
		res.Vendors = append(res.Vendors, r)

		r = new(SearchResult)
		r.Name = "Starship Factory"
		r.Path = "/vendor/starshipfactory"
		res.Vendors = append(res.Vendors, r)

		r = new(SearchResult)
		r.Name = "RaumZeitLabor e.V."
		r.Path = "/vendor/rzl"
		res.Vendors = append(res.Vendors, r)

		r = new(SearchResult)
		r.Name = "Doctor in the TARDIS"
		r.Path = "/vendor/doctor"
		res.Vendors = append(res.Vendors, r)
	}

	rawdata, err = json.Marshal(res)
	if err != nil {
		log.Print("Error marshalling JSON: ", err)
		numJSONMarshalErrors.Add(1)
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	w.Header().Add("Content-type", "application/json")
	w.WriteHeader(http.StatusOK)
	_, err = w.Write(rawdata)
	if err != nil {
		log.Print("Error writing JSON response: ", err)
		numHTTPWriteErrors.Add(err.Error(), 1)
	}
}
<|endoftext|>


<commit_before>package mpb_test

import (
	"bytes"
	"context"
	"errors"
	"io"
	"math/rand"
	"strings"
	"testing"
	"time"

	"github.com/vbauerster/mpb/v8"
	"github.com/vbauerster/mpb/v8/decor"
)

const (
	timeout = 200 * time.Millisecond
)

func init() {
	rand.Seed(time.Now().UnixNano())
}

func TestBarCount(t *testing.T) {
	p := mpb.New(mpb.WithOutput(io.Discard))

	b := p.AddBar(0, mpb.BarRemoveOnComplete())

	if count := p.BarCount(); count != 1 {
		t.Errorf("BarCount want: %d, got: %d\n", 1, count)
	}

	b.SetTotal(100, true)

	b.Wait()

	if count := p.BarCount(); count != 0 {
		t.Errorf("BarCount want: %d, got: %d\n", 0, count)
	}

	p.Wait()
}

func TestBarAbort(t *testing.T) {
	shutdown := make(chan struct{})
	p := mpb.New(mpb.WithShutdownNotifier(shutdown), mpb.WithOutput(io.Discard))
	n := 2
	bars := make([]*mpb.Bar, n)
	for i := 0; i < n; i++ {
		b := p.AddBar(100)
		switch i {
		case n - 1:
			var abortCalledTimes int
			for j := 0; !b.Aborted(); j++ {
				if j >= 10 {
					b.Abort(true)
					abortCalledTimes++
				} else {
					b.Increment()
				}
			}
			if abortCalledTimes != 1 {
				t.Errorf("Expected abortCalledTimes: %d, got: %d\n", 1, abortCalledTimes)
			}
			b.Wait()
			count := p.BarCount()
			if count != 1 {
				t.Errorf("BarCount want: %d, got: %d\n", 1, count)
			}
		default:
			go func() {
				for !b.Completed() {
					b.Increment()
					time.Sleep(randomDuration(100 * time.Millisecond))
				}
			}()
		}
		bars[i] = b
	}

	bars[0].Abort(false)

	go p.Wait()

	select {
	case <-shutdown:
	case <-time.After(timeout):
		t.Errorf("Progress didn't shutdown after %v", timeout)
	}
}

func TestWithContext(t *testing.T) {
	shutdown := make(chan struct{})
	ctx, cancel := context.WithCancel(context.Background())
	p := mpb.NewWithContext(ctx, mpb.WithShutdownNotifier(shutdown), mpb.WithOutput(io.Discard))

	bar := p.AddBar(0) // never complete bar
	go func() {
		for !bar.Aborted() {
			time.Sleep(randomDuration(100 * time.Millisecond))
			cancel()
		}
	}()

	go p.Wait()

	select {
	case <-shutdown:
	case <-time.After(timeout):
		t.Errorf("Progress didn't shutdown after %v", timeout)
	}
}

// MaxWidthDistributor shouldn't stuck in the middle while removing or aborting a bar
func TestMaxWidthDistributor(t *testing.T) {

	makeWrapper := func(f func([]chan int), start, end chan struct{}) func([]chan int) {
		return func(column []chan int) {
			start <- struct{}{}
			f(column)
			<-end
		}
	}

	ready := make(chan struct{})
	start := make(chan struct{})
	end := make(chan struct{})
	mpb.MaxWidthDistributor = makeWrapper(mpb.MaxWidthDistributor, start, end)

	total := 100
	numBars := 6
	p := mpb.New(mpb.WithOutput(io.Discard))
	for i := 0; i < numBars; i++ {
		bar := p.AddBar(int64(total),
			mpb.BarOptional(mpb.BarRemoveOnComplete(), i == 0),
			mpb.PrependDecorators(decor.EwmaETA(decor.ET_STYLE_GO, 60, decor.WCSyncSpace)),
		)
		go func() {
			<-ready
			for i := 0; i < total; i++ {
				start := time.Now()
				if id := bar.ID(); id > 1 && i >= 32 {
					if id&1 == 1 {
						bar.Abort(true)
					} else {
						bar.Abort(false)
					}
				}
				time.Sleep(randomDuration(100 * time.Millisecond))
				bar.EwmaIncrInt64(rand.Int63n(5)+1, time.Since(start))
			}
		}()
	}

	go func() {
		<-ready
		p.Wait()
		close(start)
	}()

	res := t.Run("maxWidthDistributor", func(t *testing.T) {
		close(ready)
		for v := range start {
			timer := time.NewTimer(100 * time.Millisecond)
			select {
			case end <- v:
				timer.Stop()
			case <-timer.C:
				t.FailNow()
			}
		}
	})

	if !res {
		t.Error("maxWidthDistributor stuck in the middle")
	}
}

func TestProgressShutdownsWithErrFiller(t *testing.T) {
	var debug bytes.Buffer
	shutdown := make(chan struct{})
	p := mpb.New(
		mpb.WithShutdownNotifier(shutdown),
		mpb.WithOutput(io.Discard),
		mpb.WithDebugOutput(&debug),
	)

	testError := errors.New("test error")
	bar := p.AddBar(100,
		mpb.BarFillerMiddleware(func(base mpb.BarFiller) mpb.BarFiller {
			return mpb.BarFillerFunc(func(w io.Writer, st decor.Statistics) error {
				if st.Current >= 42 {
					return testError
				}
				return base.Fill(w, st)
			})
		}),
	)

	for bar.IsRunning() {
		time.Sleep(randomDuration(100 * time.Millisecond))
		bar.Increment()
	}

	go p.Wait()

	select {
	case <-shutdown:
		if err := strings.TrimSpace(debug.String()); err != testError.Error() {
			t.Errorf("Expected err: %q, got %q\n", testError.Error(), err)
		}
	case <-time.After(timeout):
		t.Errorf("Progress didn't shutdown after %v", timeout)
	}
}

func randomDuration(max time.Duration) time.Duration {
	return time.Duration(rand.Intn(10)+1) * max / 10
}
<commit_msg>refactoring: TestWithContext<commit_after>package mpb_test

import (
	"bytes"
	"context"
	"errors"
	"io"
	"math/rand"
	"strings"
	"testing"
	"time"

	"github.com/vbauerster/mpb/v8"
	"github.com/vbauerster/mpb/v8/decor"
)

const (
	timeout = 200 * time.Millisecond
)

func init() {
	rand.Seed(time.Now().UnixNano())
}

func TestBarCount(t *testing.T) {
	p := mpb.New(mpb.WithOutput(io.Discard))

	b := p.AddBar(0, mpb.BarRemoveOnComplete())

	if count := p.BarCount(); count != 1 {
		t.Errorf("BarCount want: %d, got: %d\n", 1, count)
	}

	b.SetTotal(100, true)

	b.Wait()

	if count := p.BarCount(); count != 0 {
		t.Errorf("BarCount want: %d, got: %d\n", 0, count)
	}

	p.Wait()
}

func TestBarAbort(t *testing.T) {
	shutdown := make(chan struct{})
	p := mpb.New(mpb.WithShutdownNotifier(shutdown), mpb.WithOutput(io.Discard))
	n := 2
	bars := make([]*mpb.Bar, n)
	for i := 0; i < n; i++ {
		b := p.AddBar(100)
		switch i {
		case n - 1:
			var abortCalledTimes int
			for j := 0; !b.Aborted(); j++ {
				if j >= 10 {
					b.Abort(true)
					abortCalledTimes++
				} else {
					b.Increment()
				}
			}
			if abortCalledTimes != 1 {
				t.Errorf("Expected abortCalledTimes: %d, got: %d\n", 1, abortCalledTimes)
			}
			b.Wait()
			count := p.BarCount()
			if count != 1 {
				t.Errorf("BarCount want: %d, got: %d\n", 1, count)
			}
		default:
			go func() {
				for !b.Completed() {
					b.Increment()
					time.Sleep(randomDuration(100 * time.Millisecond))
				}
			}()
		}
		bars[i] = b
	}

	bars[0].Abort(false)

	go p.Wait()

	select {
	case <-shutdown:
	case <-time.After(timeout):
		t.Errorf("Progress didn't shutdown after %v", timeout)
	}
}

func TestWithContext(t *testing.T) {
	shutdown := make(chan struct{})
	ctx, cancel := context.WithCancel(context.Background())
	p := mpb.NewWithContext(ctx,
		mpb.WithShutdownNotifier(shutdown),
		mpb.WithOutput(io.Discard),
	)
	_ = p.AddBar(0) // never complete bar
	_ = p.AddBar(0) // never complete bar
	go func() {
		time.Sleep(randomDuration(100 * time.Millisecond))
		cancel()
		p.Wait()
	}()

	select {
	case <-shutdown:
	case <-time.After(timeout):
		t.Errorf("Progress didn't shutdown after %v", timeout)
	}
}

// MaxWidthDistributor shouldn't stuck in the middle while removing or aborting a bar
func TestMaxWidthDistributor(t *testing.T) {

	makeWrapper := func(f func([]chan int), start, end chan struct{}) func([]chan int) {
		return func(column []chan int) {
			start <- struct{}{}
			f(column)
			<-end
		}
	}

	ready := make(chan struct{})
	start := make(chan struct{})
	end := make(chan struct{})
	mpb.MaxWidthDistributor = makeWrapper(mpb.MaxWidthDistributor, start, end)

	total := 100
	numBars := 6
	p := mpb.New(mpb.WithOutput(io.Discard))
	for i := 0; i < numBars; i++ {
		bar := p.AddBar(int64(total),
			mpb.BarOptional(mpb.BarRemoveOnComplete(), i == 0),
			mpb.PrependDecorators(decor.EwmaETA(decor.ET_STYLE_GO, 60, decor.WCSyncSpace)),
		)
		go func() {
			<-ready
			for i := 0; i < total; i++ {
				start := time.Now()
				if id := bar.ID(); id > 1 && i >= 32 {
					if id&1 == 1 {
						bar.Abort(true)
					} else {
						bar.Abort(false)
					}
				}
				time.Sleep(randomDuration(100 * time.Millisecond))
				bar.EwmaIncrInt64(rand.Int63n(5)+1, time.Since(start))
			}
		}()
	}

	go func() {
		<-ready
		p.Wait()
		close(start)
	}()

	res := t.Run("maxWidthDistributor", func(t *testing.T) {
		close(ready)
		for v := range start {
			timer := time.NewTimer(100 * time.Millisecond)
			select {
			case end <- v:
				timer.Stop()
			case <-timer.C:
				t.FailNow()
			}
		}
	})

	if !res {
		t.Error("maxWidthDistributor stuck in the middle")
	}
}

func TestProgressShutdownsWithErrFiller(t *testing.T) {
	var debug bytes.Buffer
	shutdown := make(chan struct{})
	p := mpb.New(
		mpb.WithShutdownNotifier(shutdown),
		mpb.WithOutput(io.Discard),
		mpb.WithDebugOutput(&debug),
	)

	testError := errors.New("test error")
	bar := p.AddBar(100,
		mpb.BarFillerMiddleware(func(base mpb.BarFiller) mpb.BarFiller {
			return mpb.BarFillerFunc(func(w io.Writer, st decor.Statistics) error {
				if st.Current >= 42 {
					return testError
				}
				return base.Fill(w, st)
			})
		}),
	)

	for bar.IsRunning() {
		time.Sleep(randomDuration(100 * time.Millisecond))
		bar.Increment()
	}

	go p.Wait()

	select {
	case <-shutdown:
		if err := strings.TrimSpace(debug.String()); err != testError.Error() {
			t.Errorf("Expected err: %q, got %q\n", testError.Error(), err)
		}
	case <-time.After(timeout):
		t.Errorf("Progress didn't shutdown after %v", timeout)
	}
}

func randomDuration(max time.Duration) time.Duration {
	return time.Duration(rand.Intn(10)+1) * max / 10
}
<|endoftext|>


<commit_before>package project

type EmptyService struct {
}

func (e *EmptyService) Create() error {
	return nil
}

func (e *EmptyService) Build() error {
	return nil
}

func (e *EmptyService) Up() error {
	return nil
}

func (e *EmptyService) Start() error {
	return nil
}

func (e *EmptyService) Down() error {
	return nil
}

func (e *EmptyService) Delete() error {
	return nil
}

func (e *EmptyService) Restart() error {
	return nil
}

func (e *EmptyService) Log() error {
	return nil
}

func (e *EmptyService) Pull() error {
	return nil
}

func (e *EmptyService) Kill() error {
	return nil
}

func (e *EmptyService) Containers() ([]Container, error) {
	return []Container{}, nil
}

func (e *EmptyService) Scale(count int) error {
	return nil
}
<commit_msg>Add Info() to EmptyService<commit_after>package project

type EmptyService struct {
}

func (e *EmptyService) Create() error {
	return nil
}

func (e *EmptyService) Build() error {
	return nil
}

func (e *EmptyService) Up() error {
	return nil
}

func (e *EmptyService) Start() error {
	return nil
}

func (e *EmptyService) Down() error {
	return nil
}

func (e *EmptyService) Delete() error {
	return nil
}

func (e *EmptyService) Restart() error {
	return nil
}

func (e *EmptyService) Log() error {
	return nil
}

func (e *EmptyService) Pull() error {
	return nil
}

func (e *EmptyService) Kill() error {
	return nil
}

func (e *EmptyService) Containers() ([]Container, error) {
	return []Container{}, nil
}

func (e *EmptyService) Scale(count int) error {
	return nil
}

func (e *EmptyService) Info() (InfoSet, error) {
	return InfoSet{}, nil
}
<|endoftext|>


<commit_before>package prompt

import (
	"github.com/antham/strumt"

	"github.com/antham/chyle/prompt/internal/builder"
)

// Prompts held prompts
type Prompts struct {
	prompts strumt.Prompts
}

// New creates a new prompt chain
func New() Prompts {
	return Prompts{strumt.NewPrompts()}
}

func (p *Prompts) populatePrompts(prompts []strumt.Prompter) {
	for _, item := range prompts {
		switch prompt := item.(type) {
		case strumt.LinePrompter:
			p.prompts.AddLinePrompter(prompt)
		case strumt.MultilinePrompter:
			p.prompts.AddMultilinePrompter(prompt)
		}
	}
}

// Run starts a prompt session
func (p *Prompts) Run() (*builder.Store, error) {
	store := &builder.Store{}
	prompts := mergePrompters(
		newMainMenu(),
		newMandatoryOption(store),
		newMatchers(store),
		newExtractors(store),
		newDecorators(store),
		newSenders(store),
	)

	p.populatePrompts(prompts)

	p.prompts.SetFirst("referenceFrom")
	p.prompts.Run()

	return store, nil
}
<commit_msg>ref(prompt) : expose reader and writer to be mocked outside<commit_after>package prompt

import (
	"github.com/antham/strumt"
	"io"

	"github.com/antham/chyle/prompt/internal/builder"
)

// Prompts held prompts
type Prompts struct {
	prompts strumt.Prompts
}

// New creates a new prompt chain
func New(reader io.Reader, writer io.Writer) Prompts {
	return Prompts{strumt.NewPromptsFromReaderAndWriter(reader, writer)}
}

func (p *Prompts) populatePrompts(prompts []strumt.Prompter) {
	for _, item := range prompts {
		switch prompt := item.(type) {
		case strumt.LinePrompter:
			p.prompts.AddLinePrompter(prompt)
		case strumt.MultilinePrompter:
			p.prompts.AddMultilinePrompter(prompt)
		}
	}
}

// Run starts a prompt session
func (p *Prompts) Run() (*builder.Store, error) {
	store := &builder.Store{}
	prompts := mergePrompters(
		newMainMenu(),
		newMandatoryOption(store),
		newMatchers(store),
		newExtractors(store),
		newDecorators(store),
		newSenders(store),
	)

	p.populatePrompts(prompts)

	p.prompts.SetFirst("referenceFrom")
	p.prompts.Run()

	return store, nil
}
<|endoftext|>


<commit_before>package protocol

import (
	"errors"
	"os"
	"path/filepath"
	"reflect"
	"regexp"
	"strings"

	"golang-refactoring.org/go-doctor/doctor"
)

type XRun struct {
	Transformation string                 `json:"transformation"`
	Fileselection  []string               `json:"fileselection"`
	Textselection  map[string]interface{} `json:"textselection"`
	Arguments      []interface{}          `json:"arguments"`
	Limit          int                    `json:"limit"`
	Mode           string                 `json:"mode" chk:"text|patch"`
}

// TODO implement
func (x *XRun) Run(state *State, input map[string]interface{}) (Reply, error) {
	if valid, err := x.Validate(state, input); !valid {
		return Reply{map[string]interface{}{"reply": "Error", "message": err.Error()}}, err
	}
	// setup TextSelection
	textselection := input["textselection"].(map[string]interface{})
	ts := doctor.TextSelection{
		Filename:  filepath.Join(state.Dir, textselection["filename"].(string)),
		StartLine: int(textselection["startline"].(float64)),
		StartCol:  int(textselection["startcol"].(float64)),
		EndLine:   int(textselection["endline"].(float64)),
		EndCol:    int(textselection["endcol"].(float64)),
	}

	// get refactoring
	refactoring := doctor.GetRefactoring(input["transformation"].(string))

	config := &doctor.Config{
		FileSystem: state.Filesystem,
		Scope:      nil,
		Selection:  ts,
		Args:       input["arguments"].([]interface{}),
	}

	// run
	result := refactoring.Run(config)

	// grab logs
	logs := make([]map[string]interface{}, 0)
	for _, entry := range result.Log.Entries {
		var severity string
		switch entry.Severity {
		case doctor.INFO:
			// No prefix
		case doctor.WARNING:
			severity = "warning"
		case doctor.ERROR:
			severity = "error"
		case doctor.FATAL_ERROR:
			severity = "fatal"
		}
		log := map[string]interface{}{"severity": severity, "message": entry.Message}
		logs = append(logs, log)
	}
	changes := make([]map[string]string, 0)
	if input["mode"].(string) == "patch" {
		for f, e := range result.Edits {
			var p *doctor.Patch
			var err error
			p, err = doctor.CreatePatchForFile(e, f)
			if err != nil {
				return Reply{map[string]interface{}{"reply": "Error", "message": err.Error()}}, err
			}
			diffFile, err := os.Create(strings.Join([]string{f, ".diff"}, ""))
			p.Write(f, f, diffFile)
			//fmt.Println(f)
			//fmt.Println(diffFile.Name())
			changes = append(changes, map[string]string{"filename": f, "patchFile": diffFile.Name()})
			diffFile.Close()
		}
	} else {
		for f, e := range result.Edits {
			content, err := doctor.ApplyToFile(e, f)
			if err != nil {
				return Reply{map[string]interface{}{"reply": "Error", "message": err.Error()}}, err
			}
			changes = append(changes, map[string]string{"filename": f, "content": string(content)})
		}
	}

	return Reply{map[string]interface{}{"reply": "OK", "description": refactoring.Description().Name, "log": logs, "files": changes}}, nil
}

// TODO validate TextSelection, FileSelection, arguments
func (x *XRun) Validate(state *State, input map[string]interface{}) (bool, error) {
	if state.State < 2 {
		return false, errors.New("State of 2 (file system configured) is required")
	}

	// check transformation is valid
	var valid bool
	for shortName, _ := range doctor.AllRefactorings() {
		if shortName == input["transformation"].(string) {
			valid = true
		}
	}
	if !valid {
		return false, errors.New("Transformation given is not a valid refactoring name")
	}

	// check limit is > 0 if exists
	if limit, found := input["limit"]; found {
		if limit.(int) < 0 {
			return false, errors.New("\"limit\" key must be a positive integer")
		}
	}

	// check mode key if exists
	if mode, found := input["mode"]; found {
		field, _ := reflect.TypeOf(x).Elem().FieldByName("Mode")
		qualityValidator := regexp.MustCompile(field.Tag.Get("chk"))

		if valid := qualityValidator.MatchString(mode.(string)); !valid {
			return false, errors.New("\"mode\" key must be \"text|patch\"")
		}
	}

	// all good?
	return true, nil
}
<commit_msg>added file system changes to the return from xrun<commit_after>package protocol

import (
	"errors"
	"os"
	"path/filepath"
	"reflect"
	"regexp"
	"strings"

	"golang-refactoring.org/go-doctor/doctor"
)

type XRun struct {
	Transformation string                 `json:"transformation"`
	Fileselection  []string               `json:"fileselection"`
	Textselection  map[string]interface{} `json:"textselection"`
	Arguments      []interface{}          `json:"arguments"`
	Limit          int                    `json:"limit"`
	Mode           string                 `json:"mode" chk:"text|patch"`
}

// TODO implement
func (x *XRun) Run(state *State, input map[string]interface{}) (Reply, error) {
	if valid, err := x.Validate(state, input); !valid {
		return Reply{map[string]interface{}{"reply": "Error", "message": err.Error()}}, err
	}
	// setup TextSelection
	textselection := input["textselection"].(map[string]interface{})
	ts := doctor.TextSelection{
		Filename:  filepath.Join(state.Dir, textselection["filename"].(string)),
		StartLine: int(textselection["startline"].(float64)),
		StartCol:  int(textselection["startcol"].(float64)),
		EndLine:   int(textselection["endline"].(float64)),
		EndCol:    int(textselection["endcol"].(float64)),
	}

	// get refactoring
	refactoring := doctor.GetRefactoring(input["transformation"].(string))

	config := &doctor.Config{
		FileSystem: state.Filesystem,
		Scope:      nil,
		Selection:  ts,
		Args:       input["arguments"].([]interface{}),
	}

	// run
	result := refactoring.Run(config)

	// grab logs
	logs := make([]map[string]interface{}, 0)
	fatalError := false
	for _, entry := range result.Log.Entries {
		var severity string
		switch entry.Severity {
		case doctor.INFO:
			// No prefix
		case doctor.WARNING:
			severity = "warning"
		case doctor.ERROR:
			severity = "error"
		case doctor.FATAL_ERROR:
			severity = "fatal"
			fatalError = true
		}
		log := map[string]interface{}{"severity": severity, "message": entry.Message}
		logs = append(logs, log)
	}
	// any fatal errors? return without giving changes
	if fatalError {
		return Reply{map[string]interface{}{"reply": "OK", "description": refactoring.Description().Name, "log": logs}}, nil
	}

	changes := make([]map[string]string, 0)

	// if mode == patch or no mode was given
	if mode, found := input["mode"]; !found || mode.(string) == "patch" {
		for f, e := range result.Edits {
			var p *doctor.Patch
			var err error
			p, err = doctor.CreatePatchForFile(e, f)
			if err != nil {
				return Reply{map[string]interface{}{"reply": "Error", "message": err.Error()}}, err
			}
			diffFile, err := os.Create(strings.Join([]string{f, ".diff"}, ""))
			p.Write(f, f, diffFile)
			//fmt.Println(f)
			//fmt.Println(diffFile.Name())
			changes = append(changes, map[string]string{"filename": f, "patchFile": diffFile.Name()})
			diffFile.Close()
		}
	} else {
		for f, e := range result.Edits {
			content, err := doctor.ApplyToFile(e, f)
			if err != nil {
				return Reply{map[string]interface{}{"reply": "Error", "message": err.Error()}}, err
			}
			changes = append(changes, map[string]string{"filename": f, "content": string(content)})
		}
	}

	// filesystem changes
	var fschanges []map[string]string
	if len(result.FSChanges) > 0 {
		fschanges = make([]map[string]string, len(result.FSChanges))
		for i, change := range result.FSChanges {
			switch change := change.(type) {
			case *doctor.FSCreateFile:
				fschanges[i] = map[string]string{"change": "create", "file": change.Path, "content": change.Contents}
			case *doctor.FSRemove:
				fschanges[i] = map[string]string{"change": "delete", "path": change.Path}
			case *doctor.FSRename:
				fschanges[i] = map[string]string{"change": "rename", "from": change.Path, "to": change.NewName}
			}
		}
		// return with filesystem changes
		return Reply{map[string]interface{}{"reply": "OK", "description": refactoring.Description().Name, "log": logs, "files": changes, "fsChanges": fschanges}}, nil
	}

	// return without filesystem changes
	return Reply{map[string]interface{}{"reply": "OK", "description": refactoring.Description().Name, "log": logs, "files": changes}}, nil
}

// TODO validate TextSelection, FileSelection, arguments
func (x *XRun) Validate(state *State, input map[string]interface{}) (bool, error) {
	if state.State < 2 {
		return false, errors.New("State of 2 (file system configured) is required")
	}

	// check transformation is valid
	var valid bool
	for shortName, _ := range doctor.AllRefactorings() {
		if shortName == input["transformation"].(string) {
			valid = true
		}
	}
	if !valid {
		return false, errors.New("Transformation given is not a valid refactoring name")
	}

	// check limit is > 0 if exists
	if limit, found := input["limit"]; found {
		if limit.(int) < 0 {
			return false, errors.New("\"limit\" key must be a positive integer")
		}
	}

	// check mode key if exists
	if mode, found := input["mode"]; found {
		field, _ := reflect.TypeOf(x).Elem().FieldByName("Mode")
		qualityValidator := regexp.MustCompile(field.Tag.Get("chk"))

		if valid := qualityValidator.MatchString(mode.(string)); !valid {
			return false, errors.New("\"mode\" key must be \"text|patch\"")
		}
	}

	// all good?
	return true, nil
}
<|endoftext|>