initial commit

This commit is contained in:
Daniel Sommer 2023-11-28 15:52:53 +01:00
commit bdaa3a97b6
14 changed files with 529 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
./slideshow-api
__debug_bin*

23
.vscode/launch.json vendored Normal file
View file

@ -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"
},
]
}

20
LICENSE.md Normal file
View file

@ -0,0 +1,20 @@
# MIT License
**Copyright (c) 2023 Daniel Sommer \<daniel.sommer@velvettear.de\>**
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.**

42
README.md Normal file
View file

@ -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).

13
go.mod Normal file
View file

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

15
go.sum Normal file
View file

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

64
internal/api/server.go Normal file
View file

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

44
internal/cache.go Normal file
View file

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

80
internal/config/config.go Normal file
View file

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

65
internal/palette.go Normal file
View file

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

43
internal/scale.go Normal file
View file

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

80
internal/scanner.go Normal file
View file

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

19
main.go Normal file
View file

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

19
slideshow-api.service Normal file
View file

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