complete rewrite of the project; now server-client based
This commit is contained in:
parent
5eb3fd0ea1
commit
36ef2274c5
14 changed files with 436 additions and 485 deletions
32
.vscode/launch.json
vendored
32
.vscode/launch.json
vendored
|
@ -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"
|
||||
}
|
||||
|
|
25
README.md
25
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`
|
||||
|
|
4
go.sum
4
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=
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
17
internal/formatter.go
Normal file
17
internal/formatter.go
Normal 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
101
internal/requests.go
Normal 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
115
internal/slideshow.go
Normal 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...)
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
73
internal/streams.go
Normal 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
|
||||
}
|
6
main.go
6
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)
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
|
@ -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]
|
||||
|
|
Loading…
Reference in a new issue