almost full rewrite of the app; now based on streams

This commit is contained in:
Daniel Sommer 2023-11-29 16:00:35 +01:00
parent 9db0a257c0
commit 2dcb96399d
10 changed files with 285 additions and 150 deletions

4
.vscode/launch.json vendored
View file

@ -10,8 +10,8 @@
"env":{ "env":{
"SLIDESHOW_ADDRESS": "0.0.0.0", "SLIDESHOW_ADDRESS": "0.0.0.0",
"SLIDESHOW_PORT": "3000", "SLIDESHOW_PORT": "3000",
"SLIDESHOW_DIRECTORY": "/home/velvettear/images", "SLIDESHOW_DIRECTORY": "/mnt/images",
"SLIDESHOW_SCANINTERVAL": "10", "SLIDESHOW_SCANINTERVAL": "600",
"SLIDESHOW_RESOLUTION": "600x1024", "SLIDESHOW_RESOLUTION": "600x1024",
"SLIDESHOW_LOGLEVEL": "debug", "SLIDESHOW_LOGLEVEL": "debug",
"SLIDESHOW_PALETTE_ALGORITHM": "wsm", "SLIDESHOW_PALETTE_ALGORITHM": "wsm",

View file

@ -22,7 +22,7 @@ configuration is entirely done via environment variables.
| SLIDESHOW_LOGLEVEL | "info" | the log level | | SLIDESHOW_LOGLEVEL | "info" | the log level |
**note:** **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:** **available log levels:**

37
internal/api/response.go Normal file
View 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)
}

View file

@ -1,9 +1,10 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time"
"git.velvettear.de/velvettear/loggo" "git.velvettear.de/velvettear/loggo"
"git.velvettear.de/velvettear/slideshow-api/internal" "git.velvettear.de/velvettear/slideshow-api/internal"
@ -23,42 +24,47 @@ func Run() {
// register the handlers // register the handlers
func registerHandlers() { func registerHandlers() {
http.HandleFunc("/", handleImageRequest) http.HandleFunc("/", serveRandomImageName)
http.HandleFunc("/image/", serveImage)
http.HandleFunc("/palette/", servePalette)
} }
// handle the request to '/image' // request url: '/' - serve the name of a random image
func handleImageRequest(writer http.ResponseWriter, request *http.Request) { func serveRandomImageName(writer http.ResponseWriter, request *http.Request) {
if !internal.HasFoundImages() { timestamp := time.Now().UnixMilli()
writer.WriteHeader(404) var response response
return response.Content, response.error = internal.GetRandomImage()
if response.error != nil {
response.StatusCode = 404
} }
name, data, error := internal.GetRandomImage() response.send(writer)
if error != nil { loggo.DebugTimed("served random image '"+response.Content.(string)+"'", timestamp)
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")) // request url: '/image' - serve an image
return func serveImage(writer http.ResponseWriter, request *http.Request) {
} var response response
palette, error := internal.GetColorPalette(data) image, _ := strings.CutPrefix(request.URL.Path, "/image/")
if error != nil { image, error := internal.GetImagePath(image)
writer.WriteHeader(501) if error != nil {
writer.Write([]byte("encountered an internal server error (" + error.Error() + ")\n")) response.error = error
return response.StatusCode = 404
} response.send(writer)
bytes, error := json.Marshal(struct { return
Name string }
Palette string streamImage(writer, image)
Data []byte }
}{
name, // request url: '/palette' - serve the color palette of an image
palette, func servePalette(writer http.ResponseWriter, request *http.Request) {
data, var response response
}) image, _ := strings.CutPrefix(request.URL.Path, "/palette/")
if error != nil { image, error := internal.GetImagePath(image)
loggo.Error("encountered an error marshalling the json response", error.Error()) if error != nil {
writer.WriteHeader(501) response.error = error
writer.Write([]byte("encountered an internal server error (" + error.Error() + ")\n")) response.StatusCode = 404
return response.send(writer)
} return
writer.Write(bytes) }
streamColorPalette(writer, image)
} }

124
internal/api/stream.go Normal file
View 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)
}

View file

@ -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
}

View file

@ -2,6 +2,7 @@ package config
import ( import (
"os" "os"
"os/exec"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -16,6 +17,7 @@ var ScanInterval time.Duration
var Resolution string var Resolution string
var PaletteAlgorithm int var PaletteAlgorithm int
var PaletteColors int var PaletteColors int
var BufferSize int
// initialize the config // initialize the config
func Initialize() { func Initialize() {
@ -72,6 +74,25 @@ func Initialize() {
tmpInt = 16 tmpInt = 16
} }
PaletteColors = tmpInt 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 // check if a resolution has been specified

27
internal/formatter.go Normal file
View 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))
}

View file

@ -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
}

View file

@ -4,7 +4,6 @@ import (
"errors" "errors"
"io/fs" "io/fs"
"math/rand" "math/rand"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@ -14,17 +13,18 @@ import (
"git.velvettear.de/velvettear/slideshow-api/internal/config" "git.velvettear.de/velvettear/slideshow-api/internal/config"
) )
// slice of images // map of images
var images []string var images map[string]string
// temporary slice of images // temporary map of images
var tmpImages []string var tmpImages map[string]string
// Scan the specified directory // Scan the specified directory
func Scan() { func Scan() {
timestamp := time.Now() timestamp := time.Now()
directory := config.Directory directory := config.Directory
loggo.Info("scanning directory for images and subdirectories...", "interval: "+strconv.FormatFloat(config.ScanInterval.Seconds(), 'f', 0, 64)+" seconds", "directory: "+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) filepath.WalkDir(directory, checkDirectory)
images = tmpImages images = tmpImages
tmpImages = nil tmpImages = nil
@ -32,26 +32,33 @@ func Scan() {
go scheduleRescan() go scheduleRescan()
} }
// check if images has been found // get a random image
func HasFoundImages() bool { func GetRandomImage() (string, error) {
return len(images) > 0 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 // get the path of an image by name
func GetRandomImage() (string, []byte, error) { func GetImagePath(name string) (string, error) {
var name string var path string
var data []byte if len(name) == 0 {
var error error return path, errors.New("no image name provided")
if len(images) == 0 {
return name, data, errors.New("no images have been found")
} }
image := images[rand.Intn(len(images))] image := images[name]
if config.IsResolutionSet() { if len(image) == 0 {
data, error = ScaleImage(image) return path, errors.New("could not find image '" + name + "' in internal map")
} else {
data, error = os.ReadFile(image)
} }
return image, data, error return image, nil
} }
// sleep the specified interval and then trigger a rescan of the specified directory // sleep the specified interval and then trigger a rescan of the specified directory
@ -61,7 +68,7 @@ func scheduleRescan() {
Scan() 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 { func checkDirectory(path string, dir fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
@ -69,8 +76,8 @@ func checkDirectory(path string, dir fs.DirEntry, err error) error {
if dir.IsDir() || !isImage(path) { if dir.IsDir() || !isImage(path) {
return nil return nil
} }
tmpImages = append(tmpImages, path) tmpImages[filepath.Base(path)] = path
loggo.Debug("added image to temporary slice of images", path) loggo.Debug("added image to temporary map of images", path)
return nil return nil
} }