From bdaa3a97b699f5cb0444b71fb6bdc416cae8ccda Mon Sep 17 00:00:00 2001 From: velvettear Date: Tue, 28 Nov 2023 15:52:53 +0100 Subject: [PATCH] initial commit --- .gitignore | 2 + .vscode/launch.json | 23 +++++++++++ LICENSE.md | 20 ++++++++++ README.md | 42 ++++++++++++++++++++ go.mod | 13 +++++++ go.sum | 15 ++++++++ internal/api/server.go | 64 +++++++++++++++++++++++++++++++ internal/cache.go | 44 +++++++++++++++++++++ internal/config/config.go | 80 +++++++++++++++++++++++++++++++++++++++ internal/palette.go | 65 +++++++++++++++++++++++++++++++ internal/scale.go | 43 +++++++++++++++++++++ internal/scanner.go | 80 +++++++++++++++++++++++++++++++++++++++ main.go | 19 ++++++++++ slideshow-api.service | 19 ++++++++++ 14 files changed, 529 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/server.go create mode 100644 internal/cache.go create mode 100644 internal/config/config.go create mode 100644 internal/palette.go create mode 100644 internal/scale.go create mode 100644 internal/scanner.go create mode 100644 main.go create mode 100644 slideshow-api.service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12e2b89 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +./slideshow-api +__debug_bin* \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5a11974 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "slideshow-api-scaled", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/main.go", + "env":{ + "SLIDESHOW_ADDRESS": "0.0.0.0", + "SLIDESHOW_PORT": "3000", + "SLIDESHOW_DIRECTORY": "/home/velvettear/images", + "SLIDESHOW_SCANINTERVAL": "10", + "SLIDESHOW_RESOLUTION": "600x1024", + "SLIDESHOW_LOGLEVEL": "debug", + "SLIDESHOW_PALETTE_ALGORITHM": "wsm", + "SLIDESHOW_PALETTE_COLORS": "16" + }, + "console": "integratedTerminal" + }, + ] +} diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..43b85c9 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,20 @@ +# MIT License +**Copyright (c) 2023 Daniel Sommer \** + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +**THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.** \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..edde804 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# slideshow + +a simple web server serving (scaled) images and color palettes for [slideshow](https://git.velvettear.de/velvettear/slideshow). + +## requirements + +- [ImageMagick](https://imagemagick.org/) (optional) + +## configuration + +configuration is entirely done via environment variables. + +| variable | default | description | +| --------------------------- | ----------------- | -----------------------------------------------------------| +| SLIDESHOW_ADDRESS | "0.0.0.0" | the listen address of the web server | +| SLIDESHOW_PORT | 3000 | the port of the web server | +| SLIDESHOW_DIRECTORY | "$HOME" | path to a directory containing images | +| SLIDESHOW_SCANINTERVAL | 60 | the interval for directory scans in seconds | +| SLIDESHOW_RESOLUTION | | the resolution to which images are scaled (i.e. 1920x1080) | +| SLIDESHOW_PALETTE_ALGORITHM | "wsm" | the algorithm used to generate the color palette | +| SLIDESHOW_PALETTE_COLORS | 16 | the amount of colors generated | +| SLIDESHOW_LOGLEVEL | "info" | the log level | + +**note:** +if `SLIDESHOW_RESOLUTION` is unset the images will served as they are (without any scaling). + +**available log levels:** + +- `debug` +- `info` +- `warning` +- `error` +- `fatal` + +## color palette + +**available algorithms:** + +- `wsm` +- `wu` + +for more information regarding the color palette see [color-thief](https://github.com/kennykarnama/color-thief). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..93a2707 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module git.velvettear.de/velvettear/slideshow + +go 1.21 + +require git.velvettear.de/velvettear/loggo v0.0.0-20231113084149-980a00b4e084 + +require ( + github.com/fatih/color v1.16.0 // indirect + github.com/kennykarnama/color-thief v0.0.0-20230222041546-c1bf65ec0808 + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b453435 --- /dev/null +++ b/go.sum @@ -0,0 +1,15 @@ +git.velvettear.de/velvettear/loggo v0.0.0-20231113084149-980a00b4e084 h1:13S20q+usZ+yJr2cxxOpnPrOd5+4PwCbQg56RuWSkKk= +git.velvettear.de/velvettear/loggo v0.0.0-20231113084149-980a00b4e084/go.mod h1:Jjjno0vz7v1Y6tCnpQHnq2TVL2+5m7TXkmNNYYREIMo= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/kennykarnama/color-thief v0.0.0-20230222041546-c1bf65ec0808 h1:9JJaKNm4eDnB/ad7rWfejfMw+1ufpchM8Eik9VcQzUQ= +github.com/kennykarnama/color-thief v0.0.0-20230222041546-c1bf65ec0808/go.mod h1:9qLIEhoYLAXPjhQQpgR0nVnhJxNXBtAqRZAp2Dj87WQ= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..3abe6c3 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,64 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + + "git.velvettear.de/velvettear/loggo" + "git.velvettear.de/velvettear/slideshow/internal" + "git.velvettear.de/velvettear/slideshow/internal/config" +) + +// start the web server +func Run() { + address := config.ServerAddress + ":" + strconv.Itoa(config.ServerPort) + loggo.Info("starting web server...", "address: "+address) + registerHandlers() + error := http.ListenAndServe(address, nil) + if error != nil { + loggo.Fatal("encountered an error starting the web server", "address: "+address, error.Error()) + } +} + +// register the handlers +func registerHandlers() { + http.HandleFunc("/", handleImageRequest) +} + +// handle the request to '/image' +func handleImageRequest(writer http.ResponseWriter, request *http.Request) { + if !internal.HasFoundImages() { + writer.WriteHeader(404) + return + } + 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) +} diff --git a/internal/cache.go b/internal/cache.go new file mode 100644 index 0000000..62c7645 --- /dev/null +++ b/internal/cache.go @@ -0,0 +1,44 @@ +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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3348fe0 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,80 @@ +package config + +import ( + "os" + "strconv" + "strings" + "time" + + "git.velvettear.de/velvettear/loggo" +) + +var ServerAddress string +var ServerPort int +var Directory string +var ScanInterval time.Duration +var Resolution string +var PaletteAlgorithm int +var PaletteColors int + +// initialize the config +func Initialize() { + loggo.SetLogLevelByName(os.Getenv("SLIDESHOW_LOGLEVEL")) + ServerAddress = os.Getenv("SLIDESHOW_ADDRESS") + tmpInt, _ := strconv.Atoi(os.Getenv("SLIDESHOW_PORT")) + if tmpInt <= 0 { + tmpInt = 3000 + } + ServerPort = tmpInt + Directory = os.Getenv("SLIDESHOW_DIRECTORY") + if len(Directory) == 0 { + tmp, error := os.UserHomeDir() + if error != nil { + loggo.Fatal("encountered an error getting the current user's home directory", error.Error()) + } + Directory = tmp + } + stats, error := os.Stat(Directory) + if error != nil { + loggo.Fatal("encountered an error checking the directory '"+Directory+"'", error.Error()) + } + if !stats.IsDir() { + loggo.Fatal("configured directory '" + Directory + "' is not a valid directory") + } + tmpInt, _ = strconv.Atoi(os.Getenv("SLIDESHOW_SCANINTERVAL")) + if tmpInt <= 0 { + tmpInt = 60 + } + ScanInterval = time.Duration(tmpInt) * time.Second + Resolution = os.Getenv("SLIDESHOW_RESOLUTION") + if len(Resolution) > 0 { + width, height, found := strings.Cut(Resolution, "x") + if !found { + loggo.Fatal("encountered an error parsing the configured resolution, make sure to specify the format like '1920x1080'") + } + _, error = strconv.Atoi(width) + if error != nil { + loggo.Fatal("encountered an error parsing the configured width '" + width + "'") + } + _, error = strconv.Atoi(height) + if error != nil { + loggo.Fatal("encountered an error parsing the configured height '" + height + "'") + } + } + tmpString := os.Getenv("SLIDESHOW_PALETTE_ALGORITHM") + if strings.ToLower(tmpString) == "wu" { + PaletteAlgorithm = 0 + } else { + PaletteAlgorithm = 1 + } + tmpInt, _ = strconv.Atoi(os.Getenv("SLIDESHOW_PALETTE_COLORS")) + if tmpInt <= 0 { + tmpInt = 16 + } + PaletteColors = tmpInt +} + +// check if a resolution has been specified +func IsResolutionSet() bool { + return len(Resolution) > 0 +} diff --git a/internal/palette.go b/internal/palette.go new file mode 100644 index 0000000..930ec9e --- /dev/null +++ b/internal/palette.go @@ -0,0 +1,65 @@ +package internal + +import ( + "bytes" + "fmt" + "image" + "image/color" + "strconv" + "time" + + "git.velvettear.de/velvettear/loggo" + "git.velvettear.de/velvettear/slideshow/internal/config" + color_thief "github.com/kennykarnama/color-thief" +) + +// extract the given amount of dominant colors of raw image bytes +func GetColorPalette(data []byte) (string, error) { + var palette string + timestamp := time.Now().UnixMilli() + amount := config.PaletteColors + img, _, error := image.Decode(bytes.NewReader(data)) + if error != nil { + loggo.ErrorTimed("encountered an error decoding the provided raw image bytes to an image", timestamp, error.Error()) + return palette, error + } + colors, error := color_thief.GetPalette(img, amount, config.PaletteAlgorithm) + if error != nil { + loggo.ErrorTimed("encountered an error generating the color palette from raw image bytes", timestamp, "colors: "+strconv.Itoa(amount)) + return palette, error + } else { + loggo.DebugTimed("generated color palette from raw image bytes", timestamp, "colors: "+strconv.Itoa(amount)) + } + 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) + "\"" + } + return palette, nil +} + +// extract the given amount of dominant colors of an image +func getColorPalette(image string) ([]color.Color, error) { + var colors []color.Color + timestamp := time.Now().UnixMilli() + amount := config.PaletteColors + colors, error := color_thief.GetPaletteFromFile(image, amount, config.PaletteAlgorithm) + if error != nil { + loggo.ErrorTimed("encountered an error generating the color palette from image", timestamp, "image: "+image, "colors: "+strconv.Itoa(amount)) + } else { + loggo.DebugTimed("generated color palette from image", timestamp, "image: "+image, "colors: "+strconv.Itoa(amount)) + } + return colors, error +} + +// parse rgb color values to 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)) +} diff --git a/internal/scale.go b/internal/scale.go new file mode 100644 index 0000000..ebddfcb --- /dev/null +++ b/internal/scale.go @@ -0,0 +1,43 @@ +package internal + +import ( + "errors" + "io" + "os/exec" + "strings" + "time" + + "git.velvettear.de/velvettear/loggo" + "git.velvettear.de/velvettear/slideshow/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 +} diff --git a/internal/scanner.go b/internal/scanner.go new file mode 100644 index 0000000..f57e3e3 --- /dev/null +++ b/internal/scanner.go @@ -0,0 +1,80 @@ +package internal + +import ( + "errors" + "io/fs" + "math/rand" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "git.velvettear.de/velvettear/loggo" + "git.velvettear.de/velvettear/slideshow/internal/config" +) + +// slice of images +var images []string + +// temporary slice of images +var tmpImages []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) + filepath.WalkDir(directory, checkDirectory) + images = tmpImages + tmpImages = nil + loggo.InfoTimed("found "+strconv.Itoa(len(images))+" image(s)", timestamp.UnixMilli()) + go scheduleRescan() +} + +// check if images has been found +func HasFoundImages() bool { + return len(images) > 0 +} + +// 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") + } + image := images[rand.Intn(len(images))] + if config.IsResolutionSet() { + data, error = ScaleImage(image) + } else { + data, error = os.ReadFile(image) + } + return image, data, error +} + +// sleep the specified interval and then trigger a rescan of the specified directory +func scheduleRescan() { + loggo.Debug("sleeping for " + strconv.FormatInt(config.ScanInterval.Milliseconds(), 10) + "ms before next scan...") + time.Sleep(config.ScanInterval) + Scan() +} + +// add image files to the slice of images and subdirectoies to the watcher +func checkDirectory(path string, dir fs.DirEntry, err error) error { + if err != nil { + return err + } + if dir.IsDir() || !isImage(path) { + return nil + } + tmpImages = append(tmpImages, path) + loggo.Debug("added image to temporary slice of images", path) + return nil +} + +// check if a file is an image +func isImage(file string) bool { + return strings.HasSuffix(file, ".jpeg") || strings.HasSuffix(file, ".jpg") || strings.HasSuffix(file, ".png") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..d29acff --- /dev/null +++ b/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "time" + + "git.velvettear.de/velvettear/loggo" + "git.velvettear.de/velvettear/slideshow/internal" + "git.velvettear.de/velvettear/slideshow/internal/api" + "git.velvettear.de/velvettear/slideshow/internal/config" +) + +func main() { + timestamp := time.Now().UnixMilli() + loggo.Info("slideshow-api is starting now...") + config.Initialize() + internal.Scan() + api.Run() + loggo.InfoTimed("slideshow-api is shutting down now!", timestamp) +} diff --git a/slideshow-api.service b/slideshow-api.service new file mode 100644 index 0000000..e553eb9 --- /dev/null +++ b/slideshow-api.service @@ -0,0 +1,19 @@ +[Unit] +Description=slideshow-api + +[Service] +Type=simple +User=velvettear +Environment="DISPLAY=:0" +Environment="SLIDESHOW_ADDRESS=0.0.0.0" +Environment="SLIDESHOW_PORT=3000" +Environment="SLIDESHOW_DIRECTORY=$HOME" +Environment="SLIDESHOW_SCANINTERVAL=60" +Environment="SLIDESHOW_RESOLUTION=" +Environment="SLIDESHOW_PALETTE_ALGORITHM=wsm" +Environment="SLIDESHOW_PALETTE_COLORS=16" +Environment="SLIDESHOW_LOGLEVEL=info" +ExecStart=/opt/slideshow-api/slideshow-api + +[Install] +WantedBy=multi-user.target \ No newline at end of file