complete rewrite of the project; now server-client based

This commit is contained in:
Daniel Sommer 2023-11-30 16:04:26 +01:00
parent 5eb3fd0ea1
commit 36ef2274c5
14 changed files with 436 additions and 485 deletions

32
.vscode/launch.json vendored
View file

@ -2,38 +2,22 @@
"version": "0.0.1", "version": "0.0.1",
"configurations": [ "configurations": [
{ {
"name": "slideshow-scaled", "name": "slideshow",
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
"program": "${workspaceFolder}/main.go", "program": "${workspaceFolder}/main.go",
"env":{ "env":{
"SLIDESHOW_INTERVAL": "10", "SLIDESHOW_API": "localhost:3000",
"SLIDESHOW_DIRECTORY": "/home/velvettear/images", "SLIDESHOW_INTERVAL": "60",
"SLIDESHOW_SCANINTERVAL": "10",
"SLIDESHOW_TMPFILE": "/tmp/.slideshow.img",
"SLIDESHOW_RESOLUTION": "", "SLIDESHOW_RESOLUTION": "",
"SLIDESHOW_LOGLEVEL": "debug",
"SLIDESHOW_PALETTE": "/tmp/.slideshow.palette", "SLIDESHOW_PALETTE": "/tmp/.slideshow.palette",
"SLIDESHOW_PALETTE_ALGORITHM": "wsm", "SLIDESHOW_PALETTE_ALGORITHM": "",
"SLIDESHOW_PALETTE_COLORS": "32" "SLIDESHOW_PALETTE_COLORS": "",
}, "SLIDESHOW_SCRIPT": "/tmp/cat-palette.sh",
"console": "integratedTerminal" "SLIDESHOW_SCRIPT_ASYNC": "true",
}, "SLIDESHOW_SCRIPT_STAGE": "post_image",
{
"name": "slideshow-unscaled",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/main.go",
"env":{
"SLIDESHOW_INTERVAL": "10",
"SLIDESHOW_DIRECTORY": "/home/velvettear/images",
"SLIDESHOW_SCANINTERVAL": "10",
"SLIDESHOW_LOGLEVEL": "debug", "SLIDESHOW_LOGLEVEL": "debug",
"SLIDESHOW_PALETTE": "/tmp/.slideshow.palette",
"SLIDESHOW_PALETTE_ALGORITHM": "wsm",
"SLIDESHOW_PALETTE_COLORS": "16"
}, },
"console": "integratedTerminal" "console": "integratedTerminal"
} }

View file

@ -1,11 +1,10 @@
# slideshow # slideshow
a simple cli application to start a background image slideshow using 'feh'. [slideshow-api](https://git.velvettear.de/velvettear/slideshow-api)'s client to set (scaled) images as background via 'feh' and retrieve color palettes.
## requirements ## requirements
- [feh](https://feh.finalrewind.org/) - [feh](https://feh.finalrewind.org/)
- [ImageMagick](https://imagemagick.org/) (optional)
## configuration ## configuration
@ -13,21 +12,31 @@ configuration is entirely done via environment variables.
| variable | default | description | | variable | default | description |
| --------------------------- | ----------------- | -----------------------------------------------------------| | --------------------------- | ----------------- | -----------------------------------------------------------|
| SLIDESHOW_API | | the address of the slideshow-api server |
| SLIDESHOW_INTERVAL | 60 | the interval of the slideshow in seconds | | SLIDESHOW_INTERVAL | 60 | the interval of the slideshow in seconds |
| SLIDESHOW_DIRECTORY | "$HOME" | path to a directory containing images |
| SLIDESHOW_SCANINTERVAL | 60 | the interval for directory scans in seconds |
| SLIDESHOW_TMPFILE | | path to a temporary file |
| SLIDESHOW_RESOLUTION | | the resolution to which images are scaled (i.e. 1920x1080) | | SLIDESHOW_RESOLUTION | | the resolution to which images are scaled (i.e. 1920x1080) |
| SLIDESHOW_PALETTE | | path to a file where the color palette will be stored | | SLIDESHOW_PALETTE | | path to a file where the color palette will be stored |
| SLIDESHOW_PALETTE_ALGORITHM | "wsm" | the algorithm used to generate the color palette | | SLIDESHOW_PALETTE_ALGORITHM | "wsm" | the algorithm used to generate the color palette |
| SLIDESHOW_PALETTE_COLORS | 16 | the amount of colors generated | | SLIDESHOW_PALETTE_COLORS | 16 | the amount of colors generated |
| SLIDESHOW_SCRIPT | | path to a script to execute after each loop |
| SLIDESHOW_SCRIPT_ASYNC | false | run the script asynchronously (in a goroutine) |
| SLIDESHOW_SCRIPT_STAGE | | the stage at which the script is executed |
| SLIDESHOW_LOGLEVEL | "info" | the log level | | SLIDESHOW_LOGLEVEL | "info" | the log level |
**note:** **note:**
- if `SLIDESHOW_RESOLUTION` is unset the images will be displayed as they are (without any scaling).
- if `SLIDESHOW_TMPFILE` is set a temporary file will be used instead of keeping the image internally buffered. this could be useful on systems with limited ram.
**available log levels:** - if `SLIDESHOW_RESOLUTION` is unset images will be requested in their original resolution.
- if `SLIDESHOW_PALETTE` is unset no color palettes will be requested.
- if `SLIDESHOW_SCRIPT_STAGE` is unset the script (if specified) will be executed **after** the background image has been set **and** the color palette file has been generated.
### stages:
- `pre_image`: executes the script **before** the background image will be set.
- `post_image`: executes the script **after** the background image has been set.
- `pre_palette`: executes the script **before** the color palette file will be generated.
- `post_palette`: executes the script **after** the color palette has been generated.
### log levels:
- `debug` - `debug`
- `info` - `info`

4
go.sum
View file

@ -2,8 +2,6 @@ git.velvettear.de/velvettear/loggo v0.0.0-20231113084149-980a00b4e084 h1:13S20q+
git.velvettear.de/velvettear/loggo v0.0.0-20231113084149-980a00b4e084/go.mod h1:Jjjno0vz7v1Y6tCnpQHnq2TVL2+5m7TXkmNNYYREIMo= 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 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 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 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 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.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@ -13,3 +11,5 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/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 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

View file

@ -2,6 +2,7 @@ package config
import ( import (
"os" "os"
"os/exec"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -9,51 +10,38 @@ import (
"git.velvettear.de/velvettear/loggo" "git.velvettear.de/velvettear/loggo"
) )
var ApiAddress string
var Interval time.Duration var Interval time.Duration
var Directory string
var ScanInterval time.Duration
var TmpFile string
var Resolution string var Resolution string
var PaletteFile string var PaletteFile string
var PaletteAlgorithm int var PaletteAlgorithm string
var PaletteColors int var PaletteColors int
var Script string
var ScriptAsync bool
var ScriptStage string
// initialize the config // initialize the config
func Initialize() { func Initialize() {
loggo.SetLogLevelByName(os.Getenv("SLIDESHOW_LOGLEVEL")) loggo.SetLogLevelByName(os.Getenv("SLIDESHOW_LOGLEVEL"))
ApiAddress = os.Getenv("SLIDESHOW_API")
if len(ApiAddress) == 0 {
loggo.Fatal("no adress of the slideshow-api server has been specified")
}
if !strings.HasPrefix(ApiAddress, "http://") && !strings.HasPrefix(ApiAddress, "https://") {
ApiAddress = "http://" + ApiAddress
}
tmpInt, _ := strconv.Atoi(os.Getenv("SLIDESHOW_INTERVAL")) tmpInt, _ := strconv.Atoi(os.Getenv("SLIDESHOW_INTERVAL"))
if tmpInt <= 0 { if tmpInt <= 0 {
tmpInt = 60 tmpInt = 60
} }
Interval = time.Duration(tmpInt) * time.Second Interval = time.Duration(tmpInt) * time.Second
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
TmpFile = os.Getenv("SLIDESHOW_TMPFILE")
Resolution = os.Getenv("SLIDESHOW_RESOLUTION") Resolution = os.Getenv("SLIDESHOW_RESOLUTION")
if len(Resolution) > 0 { if len(Resolution) > 0 {
width, height, found := strings.Cut(Resolution, "x") width, height, found := strings.Cut(Resolution, "x")
if !found { if !found {
loggo.Fatal("encountered an error parsing the configured resolution, make sure to specify the format like '1920x1080'") loggo.Fatal("encountered an error parsing the configured resolution, make sure to specify the format like '1920x1080'")
} }
_, error = strconv.Atoi(width) _, error := strconv.Atoi(width)
if error != nil { if error != nil {
loggo.Fatal("encountered an error parsing the configured width '" + width + "'") loggo.Fatal("encountered an error parsing the configured width '" + width + "'")
} }
@ -63,17 +51,66 @@ func Initialize() {
} }
} }
PaletteFile = os.Getenv("SLIDESHOW_PALETTE") PaletteFile = os.Getenv("SLIDESHOW_PALETTE")
tmpString := os.Getenv("SLIDESHOW_PALETTE_ALGORITHM") PaletteAlgorithm = os.Getenv("SLIDESHOW_PALETTE_ALGORITHM")
if strings.ToLower(tmpString) == "wu" {
PaletteAlgorithm = 0
} else {
PaletteAlgorithm = 1
}
tmpInt, _ = strconv.Atoi(os.Getenv("SLIDESHOW_PALETTE_COLORS")) tmpInt, _ = strconv.Atoi(os.Getenv("SLIDESHOW_PALETTE_COLORS"))
if tmpInt <= 0 { if tmpInt <= 0 {
tmpInt = 16 tmpInt = 16
} }
PaletteColors = tmpInt PaletteColors = tmpInt
Script = os.Getenv("SLIDESHOW_SCRIPT")
ScriptAsync, _ = strconv.ParseBool(os.Getenv("SLIDESHOW_SCRIPT_ASYNC"))
ScriptStage = strings.ToLower(os.Getenv("SLIDESHOW_SCRIPT_STAGE"))
checkScript()
checkFehCommand()
}
// check if a script has been specified and exists
func checkScript() {
if len(Script) == 0 {
return
}
stats, error := os.Stat(Script)
if error != nil {
loggo.Warning("encountered an error getting stats for the script '"+Script+"', script execution is unavailable", error.Error())
Script = ""
return
}
if stats.IsDir() {
loggo.Warning("the script '"+Script+"' seems to be a directory, script execution is unavailable", error.Error())
Script = ""
return
}
if string(stats.Mode().Perm().String()[3]) != "x" {
loggo.Warning("the script '"+Script+"' seems to be a directory, script execution is unavailable", error.Error())
Script = ""
}
checkScriptStage()
}
// check if the specified script stage is valid
func checkScriptStage() {
switch ScriptStage {
case "pre_image":
fallthrough
case "post_image":
fallthrough
case "pre_palette":
fallthrough
case "post_palette":
return
default:
ScriptStage = ""
}
}
// check if 'feh' is available
func checkFehCommand() {
cmd := exec.Command("which", "feh")
error := cmd.Run()
if error == nil {
return
}
loggo.Fatal("could not find command 'feh'")
} }
// check if a resolution has been specified // check if a resolution has been specified
@ -86,7 +123,32 @@ func IsPaletteSet() bool {
return len(PaletteFile) > 0 return len(PaletteFile) > 0
} }
// check if a temporary file has been specified // check if a script has been specified
func IsTemporaryFileSet() bool { func IsScriptSet() bool {
return len(TmpFile) > 0 return len(Script) > 0
}
// check if a script stage has been specified
func IsScriptStageSet() bool {
return len(ScriptStage) > 0
}
// check if script stage 'pre_image' has been specified
func IsScriptStagePreImage() bool {
return ScriptStage == "pre_image"
}
// check if script stage 'post_image' has been specified
func IsScriptStagePostImage() bool {
return ScriptStage == "post_image"
}
// check if script stage 'pre_palette' has been specified
func IsScriptStagePrePalette() bool {
return ScriptStage == "pre_palette"
}
// check if script stage 'post_palette' has been specified
func IsScriptStagePostPalette() bool {
return ScriptStage == "post_palette"
} }

17
internal/formatter.go Normal file
View file

@ -0,0 +1,17 @@
package internal
import "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
}

101
internal/requests.go Normal file
View file

@ -0,0 +1,101 @@
package internal
import (
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"time"
"git.velvettear.de/velvettear/loggo"
"git.velvettear.de/velvettear/slideshow/internal/config"
)
// get the endpoint url of the api
func getEndpointUrl(endpoint string) string {
url := config.ApiAddress
if !strings.HasSuffix(url, "/") && endpoint != "/" {
url += "/"
}
if len(endpoint) > 0 {
url += endpoint
}
if !strings.HasSuffix(url, "/") {
url += "/"
}
return url
}
// request the name of an image from the api
func requestImageName() (string, error) {
timestamp := time.Now().UnixMilli()
var name string
url := getEndpointUrl("/")
request, error := http.NewRequest("GET", url, nil)
if error != nil {
return name, error
}
client := http.Client{}
response, error := client.Do(request)
if error != nil {
return name, error
}
bytes, error := io.ReadAll(response.Body)
if error != nil {
return name, error
}
tmp := make(map[string]interface{})
error = json.Unmarshal(bytes, &tmp)
if error != nil {
return name, error
}
name = tmp["Content"].(string)
loggo.DebugTimed("successfully requested image '"+name+"'", timestamp, "url: "+url)
return name, nil
}
// request the data stream of an image by name from the api
func requestImageData(name string) (io.ReadCloser, error) {
timestamp := time.Now().UnixMilli()
var stream io.ReadCloser
url := getEndpointUrl("/image/") + name
request, error := http.NewRequest("GET", url, nil)
if error != nil {
return stream, error
}
queryParameters := request.URL.Query()
if config.IsResolutionSet() {
queryParameters.Set("resolution", config.Resolution)
}
request.URL.RawQuery = queryParameters.Encode()
client := http.Client{}
response, error := client.Do(request)
if error != nil {
return stream, error
}
loggo.DebugTimed("successfully requested data stream for image '"+name+"'", timestamp)
return response.Body, nil
}
// request the color palette of an image by name from the api as stream
func requestColorPalette(name string) (io.ReadCloser, error) {
timestamp := time.Now().UnixMilli()
var stream io.ReadCloser
url := getEndpointUrl("/palette/") + name
request, error := http.NewRequest("GET", url, nil)
if error != nil {
return stream, error
}
queryParameters := request.URL.Query()
queryParameters.Set("colors", strconv.Itoa(config.PaletteColors))
queryParameters.Set("algorithm", config.PaletteAlgorithm)
request.URL.RawQuery = queryParameters.Encode()
client := http.Client{}
response, error := client.Do(request)
if error != nil {
return stream, error
}
loggo.DebugTimed("successfully requested color palette stream for image '"+name+"'", timestamp)
return response.Body, nil
}

115
internal/slideshow.go Normal file
View file

@ -0,0 +1,115 @@
package internal
import (
"os/exec"
"strconv"
"sync"
"time"
"git.velvettear.de/velvettear/loggo"
"git.velvettear.de/velvettear/slideshow/internal/config"
)
var loop int64
// start the loop for the slideshow
func StartSlideshow() {
for {
timestamp := time.Now().UnixMilli()
loop++
imageLoop()
loggo.InfoTimed("slideshow loop #"+strconv.FormatInt(loop, 10)+" finished", timestamp)
loggo.Debug("sleeping for " + strconv.FormatFloat(config.Interval.Seconds(), 'f', 0, 64) + " seconds...")
time.Sleep(config.Interval)
}
}
// the image loop of the slideshoow
func imageLoop() {
name, error := requestImageName()
if error != nil {
loggo.Error("encountered an error requesting the name of an image", error.Error())
return
}
var waitgroup sync.WaitGroup
waitgroup.Add(2)
go setBackground(name, &waitgroup)
go exportColorPalette(name, &waitgroup)
waitgroup.Wait()
if !config.IsScriptStageSet() {
runScript(false)
}
}
// encapsulated method to set the background image in a goroutine
func setBackground(name string, waitgroup *sync.WaitGroup) {
timestamp := time.Now().UnixMilli()
defer waitgroup.Done()
if config.IsScriptStagePreImage() {
runScript(false)
}
stream, error := requestImageData(name)
if error != nil {
loggo.Error("encountered an error requesting the data stream for image '"+name+"'", error.Error())
return
}
error = streamToFeh(stream)
if error != nil {
loggo.Error("encountered an error piping the data stream for image '"+name+"' to 'feh'", error.Error())
}
loggo.InfoTimed("set new background image '"+name+"' via 'feh'", timestamp)
if config.IsScriptStagePostImage() {
runScript(false)
}
}
// encapsulated method to set the background image in a goroutine
func exportColorPalette(name string, waitgroup *sync.WaitGroup) {
timestamp := time.Now().UnixMilli()
if !config.IsPaletteSet() {
return
}
defer waitgroup.Done()
if config.IsScriptStagePrePalette() {
runScript(false)
}
stream, error := requestColorPalette(name)
if error != nil {
loggo.Error("encountered an error requesting the data stream for the color palette of image '"+name+"'", error.Error())
return
}
error = streamToFile(stream, config.PaletteFile)
if error != nil {
loggo.Error("encountered an error piping the data stream for the color palette of image '"+name+"' to file '"+config.PaletteFile+"'", error.Error())
}
loggo.InfoTimed("exported color palette of image '"+name+"' to '"+config.PaletteFile+"'", timestamp)
if config.IsScriptStagePostPalette() {
runScript(false)
}
}
// encapsulated method to run the defined script
func runScript(running bool) {
if !running && config.ScriptAsync {
go runScript(true)
return
}
timestamp := time.Now().UnixMilli()
if !config.IsScriptSet() {
return
}
cmd := exec.Command(config.Script)
error := cmd.Run()
if error != nil {
loggo.Error("encountered an error executing the script '"+config.Script+"'", error.Error())
return
}
var extras []string
if config.ScriptAsync {
extras = append(extras, "async: "+strconv.FormatBool(config.ScriptAsync))
}
if config.IsScriptStageSet() {
extras = append(extras, "stage: "+config.ScriptStage)
}
loggo.InfoTimed("executed script '"+config.Script+"'", timestamp, extras...)
}

View file

@ -1,86 +0,0 @@
package slideshow
import (
"bytes"
"fmt"
"image"
"image/color"
"os"
"strconv"
"time"
"git.velvettear.de/velvettear/loggo"
"git.velvettear.de/velvettear/slideshow/internal/config"
color_thief "github.com/kennykarnama/color-thief"
)
// write the base16 color palette to a file
func exportPalette(colors []color.Color) {
if len(colors) == 0 || !config.IsPaletteSet() {
return
}
timestamp := time.Now().UnixMilli()
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) + "\""
}
error := os.WriteFile(config.PaletteFile, []byte(palette), 0775)
if error != nil {
loggo.ErrorTimed("encountered an error exporting a color palette", timestamp, error.Error())
} else {
loggo.DebugTimed("exported color palette to filesystem", timestamp, "path: "+config.PaletteFile)
}
}
// extract the given amount of dominant colors of raw image bytes
func getColorPaletteRaw(data []byte) ([]color.Color, error) {
var colors []color.Color
if !config.IsPaletteSet() {
return colors, nil
}
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 colors, 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))
} else {
loggo.DebugTimed("generated color palette from raw image bytes", timestamp, "colors: "+strconv.Itoa(amount))
}
return colors, error
}
// extract the given amount of dominant colors of an image
func getColorPalette(image string) ([]color.Color, error) {
var colors []color.Color
if !config.IsPaletteSet() {
return colors, nil
}
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))
}

View file

@ -1,46 +0,0 @@
package slideshow
import (
"errors"
"io"
"os/exec"
"strings"
"time"
"git.velvettear.de/velvettear/loggo"
"git.velvettear.de/velvettear/slideshow/internal/config"
)
// scale an image
func scale(image string, output string) ([]byte, error) {
timestamp := time.Now().UnixMilli()
if len(output) == 0 {
output = "-"
}
cmd := exec.Command("convert", image, "-resize", config.Resolution+"^", "-gravity", "center", "-extent", config.Resolution, output)
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

@ -1,188 +0,0 @@
package slideshow
import (
"errors"
"image/color"
"io"
"os"
"os/exec"
"strconv"
"strings"
"time"
"git.velvettear.de/velvettear/loggo"
"git.velvettear.de/velvettear/slideshow/internal/config"
"git.velvettear.de/velvettear/slideshow/scanner"
)
// remember last shown image
var previousImage string
// start the slideshow
func Start() {
loggo.Info("starting the image slideshow...", "interval: "+strconv.FormatFloat(config.Interval.Seconds(), 'f', 0, 64)+" seconds")
var sleepTime time.Duration
var loopTime time.Duration
for {
loopTimestamp := time.Now()
for !scanner.HasFoundImages() {
sleepTime := time.Until(scanner.GetNextScan().Add(scanner.GetLastScanDuration()))
if sleepTime <= 0 {
continue
}
loggo.Info("there have no images been found yet, waiting for " + strconv.FormatInt(sleepTime.Milliseconds(), 10) + "ms...")
time.Sleep(sleepTime)
}
var image string
var palette []color.Color
var data []byte
for {
image = scanner.GetRandomImage()
if image != previousImage {
break
}
}
if config.IsResolutionSet() {
tmp, error := scale(image, config.TmpFile)
if error != nil {
loggo.Error("encountered an error scaling an image", "image: "+image, error.Error())
continue
}
data = tmp
} else {
if !config.IsTemporaryFileSet() {
tmp, error := os.ReadFile(image)
if error != nil {
loggo.Error("encountered an error reading an image", "image: "+image, error.Error())
continue
}
data = tmp
} else {
file, error := os.Open(image)
if error != nil {
loggo.Error("encountered an error opening an image", "image: "+image, error.Error())
continue
}
defer file.Close()
tmpFile, error := os.Create(config.TmpFile)
if error != nil {
loggo.Error("encountered an error opening the temporary file", "path: "+config.TmpFile, error.Error())
continue
}
defer tmpFile.Close()
var errorMessage string
buffer := make([]byte, 4096)
for {
read, error := file.Read(buffer)
if error != nil && error != io.EOF {
errorMessage = error.Error()
break
}
if read == 0 {
break
}
_, error = tmpFile.Write(buffer[:read])
if error != nil {
errorMessage = error.Error()
break
}
}
if len(errorMessage) > 0 {
loggo.Error("encountered an error reading an image", "image: "+image, errorMessage)
continue
}
}
}
if config.IsTemporaryFileSet() {
palette, _ = getColorPalette(config.TmpFile)
} else {
palette, _ = getColorPaletteRaw(data)
}
loopTime = time.Since(loopTimestamp)
if sleepTime > 0 {
loggo.Debug("sleeping for " + strconv.FormatInt(sleepTime.Milliseconds(), 10) + "ms before next image will be displayed...")
time.Sleep(sleepTime)
}
go exportPalette(palette)
var err error
if config.IsTemporaryFileSet() {
err = setBackgroundImage(config.TmpFile)
} else {
err = setBackground(data)
}
if err != nil {
loggo.Error("encountered an error setting the background image", err.Error())
}
loggo.Info("set new background image", "image: "+image)
sleepTime = config.Interval - loopTime
}
}
// pipe data to 'feh' via stdin to set the background image
func setBackground(data []byte) error {
cmd := exec.Command("feh", "--no-fehbg", "--bg-fill", "-")
stdin, stdinError := cmd.StdinPipe()
stdout, stdoutError := cmd.StdoutPipe()
stderr, stderrError := cmd.StderrPipe()
cmd.Start()
if stdinError != nil {
return stdinError
}
if stdoutError != nil {
return stdoutError
}
if stderrError != nil {
return stderrError
}
defer stdin.Close()
defer stdout.Close()
defer stderr.Close()
_, stdinError = stdin.Write(data)
if stdinError != nil {
return stdinError
} else {
stdin.Close()
}
_, stdoutError = io.ReadAll(stdout)
if stdoutError != nil {
return stdoutError
}
errorBytes, stderrError := io.ReadAll(stderr)
if stderrError != nil {
return stderrError
}
cmd.Wait()
error := strings.TrimSpace(string(errorBytes))
if len(error) > 0 {
return errors.New(error)
}
return nil
}
// set the background image via 'feh'
func setBackgroundImage(image string) error {
cmd := exec.Command("feh", "--no-fehbg", "--bg-fill", image)
stdout, stdoutError := cmd.StdoutPipe()
stderr, stderrError := cmd.StderrPipe()
cmd.Start()
if stdoutError != nil {
return stdoutError
}
if stderrError != nil {
return stderrError
}
_, stdoutError = io.ReadAll(stdout)
if stdoutError != nil {
return stdoutError
}
errorBytes, stderrError := io.ReadAll(stderr)
if stderrError != nil {
return stderrError
}
cmd.Wait()
error := strings.TrimSpace(string(errorBytes))
if len(error) > 0 {
return errors.New(error)
}
return nil
}

73
internal/streams.go Normal file
View file

@ -0,0 +1,73 @@
package internal
import (
"errors"
"io"
"os"
"os/exec"
"strings"
"time"
"git.velvettear.de/velvettear/loggo"
)
// pipe data from a stream to 'feh'
func streamToFeh(stream io.ReadCloser) error {
timestamp := time.Now().UnixMilli()
defer stream.Close()
cmd := exec.Command("feh", "--no-fehbg", "--bg-fill", "-")
stdin, stdinError := cmd.StdinPipe()
if stdinError != nil {
return stdinError
}
defer stdin.Close()
stdout, stdoutError := cmd.StdoutPipe()
if stdoutError != nil {
return stdoutError
}
defer stdout.Close()
stderr, stderrError := cmd.StderrPipe()
if stderrError != nil {
return stderrError
}
defer stderr.Close()
cmd.Start()
written, copyError := io.Copy(stdin, stream)
if copyError != nil {
return copyError
}
if written == 0 {
_, stdoutError = io.ReadAll(stdout)
if stdoutError != nil {
return stdoutError
}
errorBytes, stderrError := io.ReadAll(stderr)
if stderrError != nil {
return stderrError
}
error := strings.TrimSpace(string(errorBytes))
if len(error) > 0 {
return errors.New(error)
}
}
stdin.Close()
cmd.Wait()
loggo.DebugTimed("successfully piped the data stream to 'feh''", timestamp, "size: "+FormatBytes(written))
return nil
}
// write data from a stream to a file
func streamToFile(stream io.ReadCloser, path string) error {
timestamp := time.Now().UnixMilli()
defer stream.Close()
file, error := os.Create(path)
if error != nil {
return error
}
written, error := io.Copy(file, stream)
if error != nil {
return error
}
loggo.DebugTimed("successfully piped the data stream to file '"+path+"'", timestamp, "size: "+FormatBytes(written))
return nil
}

View file

@ -4,16 +4,14 @@ import (
"time" "time"
"git.velvettear.de/velvettear/loggo" "git.velvettear.de/velvettear/loggo"
"git.velvettear.de/velvettear/slideshow/internal"
"git.velvettear.de/velvettear/slideshow/internal/config" "git.velvettear.de/velvettear/slideshow/internal/config"
"git.velvettear.de/velvettear/slideshow/internal/slideshow"
"git.velvettear.de/velvettear/slideshow/scanner"
) )
func main() { func main() {
timestamp := time.Now().UnixMilli() timestamp := time.Now().UnixMilli()
loggo.Info("slideshow is starting now...") loggo.Info("slideshow is starting now...")
config.Initialize() config.Initialize()
scanner.Initialize() internal.StartSlideshow()
slideshow.Start()
loggo.InfoTimed("slideshow is shutting down now!", timestamp) loggo.InfoTimed("slideshow is shutting down now!", timestamp)
} }

View file

@ -1,90 +0,0 @@
package scanner
import (
"io/fs"
"math/rand"
"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
// time of next scan
var nextScan time.Time
// duration of the last scan
var lastScanDuration time.Duration
// scan the specified directories for images
func Initialize() {
directory := config.Directory
scan(directory)
}
// get a random image
func GetRandomImage() string {
return images[rand.Intn(len(images))]
}
// check if images has been found
func HasFoundImages() bool {
return len(images) > 0
}
// get the time of the next scan
func GetNextScan() time.Time {
return nextScan
}
// get the duration of the last scan
func GetLastScanDuration() time.Duration {
return lastScanDuration
}
// scan the specified directory
func scan(directory string) {
timestamp := time.Now()
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())
lastScanDuration = time.Since(timestamp)
nextScan = time.Now().Add(config.ScanInterval)
go scheduleRescan(directory, nextScan)
}
// sleep the specified interval and then trigger a rescan of the specified directory
func scheduleRescan(directory string, timestamp time.Time) {
sleepTime := time.Until(timestamp)
loggo.Debug("sleeping for " + strconv.FormatInt(sleepTime.Milliseconds(), 10) + "ms before next scan...")
time.Sleep(sleepTime)
scan(directory)
}
// 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")
}

View file

@ -9,10 +9,12 @@ Environment="SLIDESHOW_INTERVAL=60"
Environment="SLIDESHOW_DIRECTORY=$HOME" Environment="SLIDESHOW_DIRECTORY=$HOME"
Environment="SLIDESHOW_SCANINTERVAL=60" Environment="SLIDESHOW_SCANINTERVAL=60"
Environment="SLIDESHOW_RESOLUTION=1920x1080" Environment="SLIDESHOW_RESOLUTION=1920x1080"
Environment="SLIDESHOW_LOGLEVEL=info"
Environment="SLIDESHOW_PALETTE=/tmp/.slideshow.palette" Environment="SLIDESHOW_PALETTE=/tmp/.slideshow.palette"
Environment="SLIDESHOW_PALETTE_ALGORITHM=wsm" Environment="SLIDESHOW_PALETTE_ALGORITHM=wsm"
Environment="SLIDESHOW_PALETTE_COLORS=16" Environment="SLIDESHOW_PALETTE_COLORS=16"
Environment="SLIDESHOW_SCRIPT="
Environment="SLIDESHOW_SCRIPT_ASYNC=true"
Environment="SLIDESHOW_LOGLEVEL=info"
ExecStart=/opt/slideshow/slideshow ExecStart=/opt/slideshow/slideshow
[Install] [Install]