From 36ef2274c5e64c6dfb1f39c514ee7280198d7067 Mon Sep 17 00:00:00 2001 From: velvettear Date: Thu, 30 Nov 2023 16:04:26 +0100 Subject: [PATCH] complete rewrite of the project; now server-client based --- .vscode/launch.json | 32 ++---- README.md | 27 +++-- go.sum | 4 +- internal/config/config.go | 132 ++++++++++++++++------ internal/formatter.go | 17 +++ internal/requests.go | 101 +++++++++++++++++ internal/slideshow.go | 115 +++++++++++++++++++ internal/slideshow/palette.go | 86 --------------- internal/slideshow/scale.go | 46 -------- internal/slideshow/slideshow.go | 188 -------------------------------- internal/streams.go | 73 +++++++++++++ main.go | 6 +- scanner/scanner.go | 90 --------------- slideshow.service | 4 +- 14 files changed, 436 insertions(+), 485 deletions(-) create mode 100644 internal/formatter.go create mode 100644 internal/requests.go create mode 100644 internal/slideshow.go delete mode 100644 internal/slideshow/palette.go delete mode 100644 internal/slideshow/scale.go delete mode 100644 internal/slideshow/slideshow.go create mode 100644 internal/streams.go delete mode 100644 scanner/scanner.go diff --git a/.vscode/launch.json b/.vscode/launch.json index 8b368f6..689a434 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,38 +2,22 @@ "version": "0.0.1", "configurations": [ { - "name": "slideshow-scaled", + "name": "slideshow", "type": "go", "request": "launch", "mode": "auto", "program": "${workspaceFolder}/main.go", "env":{ - "SLIDESHOW_INTERVAL": "10", - "SLIDESHOW_DIRECTORY": "/home/velvettear/images", - "SLIDESHOW_SCANINTERVAL": "10", - "SLIDESHOW_TMPFILE": "/tmp/.slideshow.img", + "SLIDESHOW_API": "localhost:3000", + "SLIDESHOW_INTERVAL": "60", "SLIDESHOW_RESOLUTION": "", - "SLIDESHOW_LOGLEVEL": "debug", "SLIDESHOW_PALETTE": "/tmp/.slideshow.palette", - "SLIDESHOW_PALETTE_ALGORITHM": "wsm", - "SLIDESHOW_PALETTE_COLORS": "32" - }, - "console": "integratedTerminal" - }, - { - "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_PALETTE_ALGORITHM": "", + "SLIDESHOW_PALETTE_COLORS": "", + "SLIDESHOW_SCRIPT": "/tmp/cat-palette.sh", + "SLIDESHOW_SCRIPT_ASYNC": "true", + "SLIDESHOW_SCRIPT_STAGE": "post_image", "SLIDESHOW_LOGLEVEL": "debug", - "SLIDESHOW_PALETTE": "/tmp/.slideshow.palette", - "SLIDESHOW_PALETTE_ALGORITHM": "wsm", - "SLIDESHOW_PALETTE_COLORS": "16" }, "console": "integratedTerminal" } diff --git a/README.md b/README.md index 9dbb898..f17780f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # 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 - [feh](https://feh.finalrewind.org/) -- [ImageMagick](https://imagemagick.org/) (optional) ## configuration @@ -13,21 +12,31 @@ configuration is entirely done via environment variables. | variable | default | description | | --------------------------- | ----------------- | -----------------------------------------------------------| +| SLIDESHOW_API | | the address of the slideshow-api server | | 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_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_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 | **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` - `info` @@ -42,4 +51,4 @@ configuration is entirely done via environment variables. - `wsm` - `wu` -for more information regarding the color palette see [color-thief](https://github.com/kennykarnama/color-thief). +for more information regarding the color palette see [color-thief](https://github.com/kennykarnama/color-thief). \ No newline at end of file diff --git a/go.sum b/go.sum index b453435..ce34235 100644 --- a/go.sum +++ b/go.sum @@ -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= 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= @@ -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.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= 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= diff --git a/internal/config/config.go b/internal/config/config.go index 6024ee3..4d4fc94 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "os" + "os/exec" "strconv" "strings" "time" @@ -9,51 +10,38 @@ import ( "git.velvettear.de/velvettear/loggo" ) +var ApiAddress string var Interval time.Duration -var Directory string -var ScanInterval time.Duration -var TmpFile string var Resolution string var PaletteFile string -var PaletteAlgorithm int +var PaletteAlgorithm string var PaletteColors int +var Script string +var ScriptAsync bool +var ScriptStage string // initialize the config func Initialize() { 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")) if tmpInt <= 0 { tmpInt = 60 } 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") 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) + _, error := strconv.Atoi(width) if error != nil { loggo.Fatal("encountered an error parsing the configured width '" + width + "'") } @@ -63,17 +51,66 @@ func Initialize() { } } PaletteFile = os.Getenv("SLIDESHOW_PALETTE") - tmpString := os.Getenv("SLIDESHOW_PALETTE_ALGORITHM") - if strings.ToLower(tmpString) == "wu" { - PaletteAlgorithm = 0 - } else { - PaletteAlgorithm = 1 - } + PaletteAlgorithm = os.Getenv("SLIDESHOW_PALETTE_ALGORITHM") tmpInt, _ = strconv.Atoi(os.Getenv("SLIDESHOW_PALETTE_COLORS")) if tmpInt <= 0 { tmpInt = 16 } 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 @@ -86,7 +123,32 @@ func IsPaletteSet() bool { return len(PaletteFile) > 0 } -// check if a temporary file has been specified -func IsTemporaryFileSet() bool { - return len(TmpFile) > 0 +// check if a script has been specified +func IsScriptSet() bool { + 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" } diff --git a/internal/formatter.go b/internal/formatter.go new file mode 100644 index 0000000..7dd1670 --- /dev/null +++ b/internal/formatter.go @@ -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 +} diff --git a/internal/requests.go b/internal/requests.go new file mode 100644 index 0000000..8107521 --- /dev/null +++ b/internal/requests.go @@ -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 +} diff --git a/internal/slideshow.go b/internal/slideshow.go new file mode 100644 index 0000000..324fc49 --- /dev/null +++ b/internal/slideshow.go @@ -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...) +} diff --git a/internal/slideshow/palette.go b/internal/slideshow/palette.go deleted file mode 100644 index 5f16e1e..0000000 --- a/internal/slideshow/palette.go +++ /dev/null @@ -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)) -} diff --git a/internal/slideshow/scale.go b/internal/slideshow/scale.go deleted file mode 100644 index 7e34e52..0000000 --- a/internal/slideshow/scale.go +++ /dev/null @@ -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 -} diff --git a/internal/slideshow/slideshow.go b/internal/slideshow/slideshow.go deleted file mode 100644 index 76ff575..0000000 --- a/internal/slideshow/slideshow.go +++ /dev/null @@ -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 -} diff --git a/internal/streams.go b/internal/streams.go new file mode 100644 index 0000000..a2365f1 --- /dev/null +++ b/internal/streams.go @@ -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 +} diff --git a/main.go b/main.go index 3e71c18..1733b3c 100644 --- a/main.go +++ b/main.go @@ -4,16 +4,14 @@ import ( "time" "git.velvettear.de/velvettear/loggo" + "git.velvettear.de/velvettear/slideshow/internal" "git.velvettear.de/velvettear/slideshow/internal/config" - "git.velvettear.de/velvettear/slideshow/internal/slideshow" - "git.velvettear.de/velvettear/slideshow/scanner" ) func main() { timestamp := time.Now().UnixMilli() loggo.Info("slideshow is starting now...") config.Initialize() - scanner.Initialize() - slideshow.Start() + internal.StartSlideshow() loggo.InfoTimed("slideshow is shutting down now!", timestamp) } diff --git a/scanner/scanner.go b/scanner/scanner.go deleted file mode 100644 index f80a994..0000000 --- a/scanner/scanner.go +++ /dev/null @@ -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") -} diff --git a/slideshow.service b/slideshow.service index 8a02496..aebfdfc 100644 --- a/slideshow.service +++ b/slideshow.service @@ -9,10 +9,12 @@ Environment="SLIDESHOW_INTERVAL=60" Environment="SLIDESHOW_DIRECTORY=$HOME" Environment="SLIDESHOW_SCANINTERVAL=60" Environment="SLIDESHOW_RESOLUTION=1920x1080" -Environment="SLIDESHOW_LOGLEVEL=info" Environment="SLIDESHOW_PALETTE=/tmp/.slideshow.palette" Environment="SLIDESHOW_PALETTE_ALGORITHM=wsm" Environment="SLIDESHOW_PALETTE_COLORS=16" +Environment="SLIDESHOW_SCRIPT=" +Environment="SLIDESHOW_SCRIPT_ASYNC=true" +Environment="SLIDESHOW_LOGLEVEL=info" ExecStart=/opt/slideshow/slideshow [Install]