almost full rewrite of the app; now based on streams
This commit is contained in:
parent
9db0a257c0
commit
2dcb96399d
10 changed files with 285 additions and 150 deletions
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
|
@ -10,8 +10,8 @@
|
|||
"env":{
|
||||
"SLIDESHOW_ADDRESS": "0.0.0.0",
|
||||
"SLIDESHOW_PORT": "3000",
|
||||
"SLIDESHOW_DIRECTORY": "/home/velvettear/images",
|
||||
"SLIDESHOW_SCANINTERVAL": "10",
|
||||
"SLIDESHOW_DIRECTORY": "/mnt/images",
|
||||
"SLIDESHOW_SCANINTERVAL": "600",
|
||||
"SLIDESHOW_RESOLUTION": "600x1024",
|
||||
"SLIDESHOW_LOGLEVEL": "debug",
|
||||
"SLIDESHOW_PALETTE_ALGORITHM": "wsm",
|
||||
|
|
|
@ -22,7 +22,7 @@ configuration is entirely done via environment variables.
|
|||
| SLIDESHOW_LOGLEVEL | "info" | the log level |
|
||||
|
||||
**note:**
|
||||
if `SLIDESHOW_RESOLUTION` is unset the images will served as they are (without any scaling).
|
||||
if `SLIDESHOW_RESOLUTION` is unset or imagemagick's `convert` command is not in your `$PATH` the images will served as they are (without any scaling).
|
||||
|
||||
**available log levels:**
|
||||
|
||||
|
|
37
internal/api/response.go
Normal file
37
internal/api/response.go
Normal file
|
@ -0,0 +1,37 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.velvettear.de/velvettear/loggo"
|
||||
)
|
||||
|
||||
// struct for server responses
|
||||
type response struct {
|
||||
StatusCode int
|
||||
Content interface{}
|
||||
error error
|
||||
}
|
||||
|
||||
// send a response
|
||||
func (response *response) send(writer http.ResponseWriter) {
|
||||
if response.StatusCode <= 0 {
|
||||
if response.error != nil {
|
||||
response.StatusCode = 500
|
||||
} else {
|
||||
response.StatusCode = 200
|
||||
}
|
||||
}
|
||||
if response.error != nil {
|
||||
response.Content = response.error.Error()
|
||||
}
|
||||
data, error := json.Marshal(response)
|
||||
if error != nil {
|
||||
loggo.Error("encountered an error marshalling a json response", error.Error())
|
||||
response.StatusCode = 500
|
||||
return
|
||||
}
|
||||
writer.WriteHeader(response.StatusCode)
|
||||
writer.Write(data)
|
||||
}
|
|
@ -1,9 +1,10 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.velvettear.de/velvettear/loggo"
|
||||
"git.velvettear.de/velvettear/slideshow-api/internal"
|
||||
|
@ -23,42 +24,47 @@ func Run() {
|
|||
|
||||
// register the handlers
|
||||
func registerHandlers() {
|
||||
http.HandleFunc("/", handleImageRequest)
|
||||
http.HandleFunc("/", serveRandomImageName)
|
||||
http.HandleFunc("/image/", serveImage)
|
||||
http.HandleFunc("/palette/", servePalette)
|
||||
}
|
||||
|
||||
// handle the request to '/image'
|
||||
func handleImageRequest(writer http.ResponseWriter, request *http.Request) {
|
||||
if !internal.HasFoundImages() {
|
||||
writer.WriteHeader(404)
|
||||
return
|
||||
// request url: '/' - serve the name of a random image
|
||||
func serveRandomImageName(writer http.ResponseWriter, request *http.Request) {
|
||||
timestamp := time.Now().UnixMilli()
|
||||
var response response
|
||||
response.Content, response.error = internal.GetRandomImage()
|
||||
if response.error != nil {
|
||||
response.StatusCode = 404
|
||||
}
|
||||
name, data, error := internal.GetRandomImage()
|
||||
if error != nil {
|
||||
loggo.Error("encountered an error getting a random image", error.Error())
|
||||
writer.WriteHeader(501)
|
||||
writer.Write([]byte("encountered an internal server error (" + error.Error() + ")\n"))
|
||||
return
|
||||
}
|
||||
palette, error := internal.GetColorPalette(data)
|
||||
if error != nil {
|
||||
writer.WriteHeader(501)
|
||||
writer.Write([]byte("encountered an internal server error (" + error.Error() + ")\n"))
|
||||
return
|
||||
}
|
||||
bytes, error := json.Marshal(struct {
|
||||
Name string
|
||||
Palette string
|
||||
Data []byte
|
||||
}{
|
||||
name,
|
||||
palette,
|
||||
data,
|
||||
})
|
||||
if error != nil {
|
||||
loggo.Error("encountered an error marshalling the json response", error.Error())
|
||||
writer.WriteHeader(501)
|
||||
writer.Write([]byte("encountered an internal server error (" + error.Error() + ")\n"))
|
||||
return
|
||||
}
|
||||
writer.Write(bytes)
|
||||
response.send(writer)
|
||||
loggo.DebugTimed("served random image '"+response.Content.(string)+"'", timestamp)
|
||||
}
|
||||
|
||||
// request url: '/image' - serve an image
|
||||
func serveImage(writer http.ResponseWriter, request *http.Request) {
|
||||
var response response
|
||||
image, _ := strings.CutPrefix(request.URL.Path, "/image/")
|
||||
image, error := internal.GetImagePath(image)
|
||||
if error != nil {
|
||||
response.error = error
|
||||
response.StatusCode = 404
|
||||
response.send(writer)
|
||||
return
|
||||
}
|
||||
streamImage(writer, image)
|
||||
}
|
||||
|
||||
// request url: '/palette' - serve the color palette of an image
|
||||
func servePalette(writer http.ResponseWriter, request *http.Request) {
|
||||
var response response
|
||||
image, _ := strings.CutPrefix(request.URL.Path, "/palette/")
|
||||
image, error := internal.GetImagePath(image)
|
||||
if error != nil {
|
||||
response.error = error
|
||||
response.StatusCode = 404
|
||||
response.send(writer)
|
||||
return
|
||||
}
|
||||
streamColorPalette(writer, image)
|
||||
}
|
||||
|
|
124
internal/api/stream.go
Normal file
124
internal/api/stream.go
Normal file
|
@ -0,0 +1,124 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.velvettear.de/velvettear/loggo"
|
||||
"git.velvettear.de/velvettear/slideshow-api/internal"
|
||||
"git.velvettear.de/velvettear/slideshow-api/internal/config"
|
||||
color_thief "github.com/kennykarnama/color-thief"
|
||||
)
|
||||
|
||||
// central streaming method for images
|
||||
func streamImage(writer http.ResponseWriter, image string) {
|
||||
if config.IsResolutionSet() {
|
||||
streamScaledImage(writer, image)
|
||||
return
|
||||
}
|
||||
streamUnscaledImage(writer, image)
|
||||
}
|
||||
|
||||
// stream a color palette
|
||||
func streamColorPalette(writer http.ResponseWriter, image string) {
|
||||
timestamp := time.Now().UnixMilli()
|
||||
var response response
|
||||
amount := config.PaletteColors
|
||||
colors, error := color_thief.GetPaletteFromFile(image, amount, config.PaletteAlgorithm)
|
||||
if error != nil {
|
||||
loggo.Error("encountered an error getting the color palette from image '"+image+"'", error.Error())
|
||||
response.error = error
|
||||
response.send(writer)
|
||||
return
|
||||
}
|
||||
var palette string
|
||||
for index, color := range colors {
|
||||
if index > 0 {
|
||||
palette += "\n"
|
||||
}
|
||||
tmp := strconv.Itoa(index)
|
||||
if len(tmp) < 2 {
|
||||
tmp = "0" + tmp
|
||||
}
|
||||
tmp = "SLIDESHOW_COLOR" + tmp
|
||||
palette += tmp + "=\"" + internal.RgbToHex(color) + "\""
|
||||
}
|
||||
data := []byte(palette)
|
||||
writer.Write(data)
|
||||
loggo.InfoTimed("successfully streamed color palette for image '"+image+"'", timestamp, "size: "+internal.FormatBytes(int64(len(data))))
|
||||
}
|
||||
|
||||
// stream an unscaled image
|
||||
func streamUnscaledImage(writer http.ResponseWriter, image string) {
|
||||
timestamp := time.Now().UnixMilli()
|
||||
var response response
|
||||
file, error := os.Open(image)
|
||||
if error != nil {
|
||||
loggo.Error("encountered an error opening image '"+image+"' for streaming", error.Error())
|
||||
response.error = error
|
||||
response.send(writer)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
written, error := io.Copy(writer, file)
|
||||
if error != nil {
|
||||
loggo.Error("encountered an error piping image '"+image+"' to stream", error.Error())
|
||||
response.error = error
|
||||
response.send(writer)
|
||||
return
|
||||
}
|
||||
loggo.InfoTimed("successfully streamed image '"+image+"'", timestamp, "size: "+internal.FormatBytes(written))
|
||||
}
|
||||
|
||||
// scale and stream an image
|
||||
func streamScaledImage(writer http.ResponseWriter, image string) {
|
||||
timestamp := time.Now().UnixMilli()
|
||||
var response response
|
||||
cmd := exec.Command("convert", image, "-resize", config.Resolution+"^", "-gravity", "center", "-extent", config.Resolution, "-")
|
||||
stdout, stdoutError := cmd.StdoutPipe()
|
||||
stderr, stderrError := cmd.StderrPipe()
|
||||
cmd.Start()
|
||||
if stdoutError != nil {
|
||||
loggo.Error("enountered an error opening imagemagicks's stdout pipe", stdoutError.Error())
|
||||
response.error = stdoutError
|
||||
response.send(writer)
|
||||
return
|
||||
}
|
||||
if stderrError != nil {
|
||||
loggo.Error("enountered an error opening imagemagicks's stderr pipe", stderrError.Error())
|
||||
response.error = stderrError
|
||||
response.send(writer)
|
||||
return
|
||||
}
|
||||
written, copyError := io.Copy(writer, stdout)
|
||||
if copyError != nil {
|
||||
loggo.Error("enountered an error piping imagemagicks's output to the stream", copyError.Error())
|
||||
response.error = copyError
|
||||
response.send(writer)
|
||||
return
|
||||
}
|
||||
if written == 0 {
|
||||
errorBytes, stderrError := io.ReadAll(stderr)
|
||||
if stderrError != nil {
|
||||
loggo.Error("enountered an error reading imagemagicks's stderr", stderrError.Error())
|
||||
response.error = stderrError
|
||||
response.send(writer)
|
||||
return
|
||||
}
|
||||
if len(errorBytes) > 0 {
|
||||
error := errors.New(strings.TrimSpace(string(errorBytes)))
|
||||
loggo.Error("enountered an error executing imagemagick", error.Error())
|
||||
response.error = error
|
||||
response.send(writer)
|
||||
return
|
||||
}
|
||||
}
|
||||
cmd.Wait()
|
||||
loggo.InfoTimed("successfully streamed scaled image '"+image+"'", timestamp, "size: "+internal.FormatBytes(written), "resolution: "+config.Resolution)
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
package internal
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// the internally pre buffered (and scaled) image
|
||||
var cachedImage []byte
|
||||
|
||||
// the internally pre buffered color palette
|
||||
var cachedPalette []byte
|
||||
|
||||
// cache an image
|
||||
func cacheImage(image []byte) {
|
||||
cachedImage = image
|
||||
}
|
||||
|
||||
// cache a color palette
|
||||
func cachePalette(colors []color.Color) {
|
||||
var palette string
|
||||
for index, color := range colors {
|
||||
if index > 0 {
|
||||
palette += "\n"
|
||||
}
|
||||
tmp := strconv.Itoa(index)
|
||||
if len(tmp) < 2 {
|
||||
tmp = "0" + tmp
|
||||
}
|
||||
tmp = "SLIDESHOW_COLOR" + tmp
|
||||
palette += tmp + "=\"" + rgbToHex(color) + "\""
|
||||
}
|
||||
cachedPalette = []byte(palette)
|
||||
}
|
||||
|
||||
// check if an image is cached
|
||||
func hasCachedImage() bool {
|
||||
return len(cachedImage) > 0
|
||||
}
|
||||
|
||||
// check if a color palette is cached
|
||||
func hasCachedColorPalette() bool {
|
||||
return len(cachedPalette) > 0
|
||||
}
|
|
@ -2,6 +2,7 @@ package config
|
|||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -16,6 +17,7 @@ var ScanInterval time.Duration
|
|||
var Resolution string
|
||||
var PaletteAlgorithm int
|
||||
var PaletteColors int
|
||||
var BufferSize int
|
||||
|
||||
// initialize the config
|
||||
func Initialize() {
|
||||
|
@ -72,6 +74,25 @@ func Initialize() {
|
|||
tmpInt = 16
|
||||
}
|
||||
PaletteColors = tmpInt
|
||||
tmpInt, _ = strconv.Atoi(os.Getenv("SLIDESHOW_BUFFERSIZE"))
|
||||
if tmpInt <= 0 {
|
||||
tmpInt = 4096
|
||||
}
|
||||
BufferSize = tmpInt
|
||||
if IsResolutionSet() {
|
||||
checkForConvert()
|
||||
}
|
||||
}
|
||||
|
||||
// check if 'convert' is available
|
||||
func checkForConvert() {
|
||||
cmd := exec.Command("convert")
|
||||
error := cmd.Run()
|
||||
if error == nil {
|
||||
return
|
||||
}
|
||||
Resolution = ""
|
||||
loggo.Warning("could not find imagemagick's 'convert' command, no scaling will be done", error.Error())
|
||||
}
|
||||
|
||||
// check if a resolution has been specified
|
||||
|
|
27
internal/formatter.go
Normal file
27
internal/formatter.go
Normal file
|
@ -0,0 +1,27 @@
|
|||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// format bytes
|
||||
func FormatBytes(bytes int64) string {
|
||||
value := float64(bytes)
|
||||
unit := "bytes"
|
||||
if bytes > 1000000 {
|
||||
value = value / 1000000
|
||||
unit = "mega" + unit
|
||||
} else if bytes > 1000 {
|
||||
value = value / 1000
|
||||
unit = "kilo" + unit
|
||||
}
|
||||
return strconv.FormatFloat(value, 'f', 2, 64) + " " + unit
|
||||
}
|
||||
|
||||
// parse rgb color values to a hex hex
|
||||
func RgbToHex(color color.Color) string {
|
||||
r, g, b, _ := color.RGBA()
|
||||
return fmt.Sprintf("#%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8))
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
package internal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.velvettear.de/velvettear/loggo"
|
||||
"git.velvettear.de/velvettear/slideshow-api/internal/config"
|
||||
)
|
||||
|
||||
// scale an image
|
||||
func ScaleImage(image string) ([]byte, error) {
|
||||
timestamp := time.Now().UnixMilli()
|
||||
cmd := exec.Command("convert", image, "-resize", config.Resolution+"^", "-gravity", "center", "-extent", config.Resolution, "-")
|
||||
stdout, stdoutError := cmd.StdoutPipe()
|
||||
stderr, stderrError := cmd.StderrPipe()
|
||||
var data []byte
|
||||
cmd.Start()
|
||||
if stdoutError != nil {
|
||||
return data, stdoutError
|
||||
}
|
||||
if stderrError != nil {
|
||||
return data, stdoutError
|
||||
}
|
||||
data, stdoutError = io.ReadAll(stdout)
|
||||
if stdoutError != nil {
|
||||
return data, stdoutError
|
||||
}
|
||||
errorBytes, stderrError := io.ReadAll(stderr)
|
||||
if stderrError != nil {
|
||||
return data, stdoutError
|
||||
}
|
||||
cmd.Wait()
|
||||
errorMessage := strings.TrimSpace(string(errorBytes))
|
||||
if len(errorMessage) > 0 {
|
||||
return data, errors.New(errorMessage)
|
||||
}
|
||||
loggo.DebugTimed("successfully scaled image", timestamp, "image: "+image, "resolution: "+config.Resolution)
|
||||
return data, nil
|
||||
}
|
|
@ -4,7 +4,6 @@ import (
|
|||
"errors"
|
||||
"io/fs"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
@ -14,17 +13,18 @@ import (
|
|||
"git.velvettear.de/velvettear/slideshow-api/internal/config"
|
||||
)
|
||||
|
||||
// slice of images
|
||||
var images []string
|
||||
// map of images
|
||||
var images map[string]string
|
||||
|
||||
// temporary slice of images
|
||||
var tmpImages []string
|
||||
// temporary map of images
|
||||
var tmpImages map[string]string
|
||||
|
||||
// Scan the specified directory
|
||||
func Scan() {
|
||||
timestamp := time.Now()
|
||||
directory := config.Directory
|
||||
loggo.Info("scanning directory for images and subdirectories...", "interval: "+strconv.FormatFloat(config.ScanInterval.Seconds(), 'f', 0, 64)+" seconds", "directory: "+directory)
|
||||
tmpImages = make(map[string]string)
|
||||
filepath.WalkDir(directory, checkDirectory)
|
||||
images = tmpImages
|
||||
tmpImages = nil
|
||||
|
@ -32,26 +32,33 @@ func Scan() {
|
|||
go scheduleRescan()
|
||||
}
|
||||
|
||||
// check if images has been found
|
||||
func HasFoundImages() bool {
|
||||
return len(images) > 0
|
||||
// get a random image
|
||||
func GetRandomImage() (string, error) {
|
||||
if len(images) == 0 {
|
||||
return "", errors.New("no images have been found in directory '" + config.Directory + "'")
|
||||
}
|
||||
random := rand.Intn(len(images))
|
||||
index := 0
|
||||
for key := range images {
|
||||
if index == random {
|
||||
return key, nil
|
||||
}
|
||||
index++
|
||||
}
|
||||
return "", errors.New("could not find a random image with index '" + strconv.Itoa(random) + "'")
|
||||
}
|
||||
|
||||
// get a random image
|
||||
func GetRandomImage() (string, []byte, error) {
|
||||
var name string
|
||||
var data []byte
|
||||
var error error
|
||||
if len(images) == 0 {
|
||||
return name, data, errors.New("no images have been found")
|
||||
// get the path of an image by name
|
||||
func GetImagePath(name string) (string, error) {
|
||||
var path string
|
||||
if len(name) == 0 {
|
||||
return path, errors.New("no image name provided")
|
||||
}
|
||||
image := images[rand.Intn(len(images))]
|
||||
if config.IsResolutionSet() {
|
||||
data, error = ScaleImage(image)
|
||||
} else {
|
||||
data, error = os.ReadFile(image)
|
||||
image := images[name]
|
||||
if len(image) == 0 {
|
||||
return path, errors.New("could not find image '" + name + "' in internal map")
|
||||
}
|
||||
return image, data, error
|
||||
return image, nil
|
||||
}
|
||||
|
||||
// sleep the specified interval and then trigger a rescan of the specified directory
|
||||
|
@ -61,7 +68,7 @@ func scheduleRescan() {
|
|||
Scan()
|
||||
}
|
||||
|
||||
// add image files to the slice of images and subdirectoies to the watcher
|
||||
// add image files to the map of images
|
||||
func checkDirectory(path string, dir fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -69,8 +76,8 @@ func checkDirectory(path string, dir fs.DirEntry, err error) error {
|
|||
if dir.IsDir() || !isImage(path) {
|
||||
return nil
|
||||
}
|
||||
tmpImages = append(tmpImages, path)
|
||||
loggo.Debug("added image to temporary slice of images", path)
|
||||
tmpImages[filepath.Base(path)] = path
|
||||
loggo.Debug("added image to temporary map of images", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue