Compare commits

...

10 commits

19 changed files with 452 additions and 597 deletions

36
.vscode/launch.json vendored
View file

@ -2,37 +2,23 @@
"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": "3",
"SLIDESHOW_SCANINTERVAL": "300", "SLIDESHOW_RESOLUTION": "",
"SLIDESHOW_RESOLUTION": "600x1024", "SLIDESHOW_PALETTE": "",
"SLIDESHOW_PALETTE_ALGORITHM": "",
"SLIDESHOW_PALETTE_COLORS": "",
"SLIDESHOW_SCRIPT": "",
"SLIDESHOW_SCRIPT_ARGS": "",
"SLIDESHOW_SCRIPT_ASYNC": "",
"SLIDESHOW_SCRIPT_STAGE": "",
"SLIDESHOW_LOGLEVEL": "debug", "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_LOGLEVEL": "debug",
"SLIDESHOW_PALETTE": "/tmp/.slideshow.palette",
"SLIDESHOW_PALETTE_ALGORITHM": "wsm",
"SLIDESHOW_PALETTE_COLORS": "16"
}, },
"console": "integratedTerminal" "console": "integratedTerminal"
} }

View file

@ -1,11 +1,11 @@
# slideshow # slideshow
a simple cli application to start a background image slideshow using 'feh'. client for the [slideshow-api](https://git.velvettear.de/velvettear/slideshow-api).
sets (scaled) images as background via 'feh', retrieves color palettes (and executes a script).
## requirements ## requirements
- [feh](https://feh.finalrewind.org/) - [feh](https://feh.finalrewind.org/)
- [ImageMagick](https://imagemagick.org/) (optional)
## configuration ## configuration
@ -13,19 +13,32 @@ 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_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 each loop |
| SLIDESHOW_SCRIPT_ARGS | | arguments to pass to the script |
| 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 no resolution is set the images will be displayed as they are (without any scaling).
**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`
@ -40,4 +53,4 @@ if no resolution is set the images will be displayed as they are (without any sc
- `wsm` - `wsm`
- `wu` - `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).

3
go.mod
View file

@ -6,8 +6,7 @@ require git.velvettear.de/velvettear/loggo v0.0.0-20231113084149-980a00b4e084
require ( require (
github.com/fatih/color v1.16.0 // indirect github.com/fatih/color v1.16.0 // indirect
github.com/kennykarnama/color-thief v0.0.0-20230222041546-c1bf65ec0808
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/sys v0.14.0 // indirect golang.org/x/sys v0.15.0 // indirect
) )

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

@ -1,105 +0,0 @@
package images
// import (
// "runtime"
// "strconv"
// "sync"
// "time"
// "git.velvettear.de/velvettear/image-frame/config"
// "git.velvettear.de/velvettear/loggo"
// )
// // cache for scaled images
// var cache []scaledImage
// // timestamp of the next cache rotation
// var NextRotation time.Time
// // get the previous image from the history and set it as the first scaled image in the cache
// func SetPreviousImage() error {
// previousImage, error := getLatestFromHistory()
// if error != nil {
// return error
// }
// var tmpCache []scaledImage
// tmpCache = append(tmpCache, previousImage)
// tmpCache = append(tmpCache, cache...)
// cache = tmpCache
// tmpCache = nil
// return nil
// }
// // get the first scaled image from the cache
// func GetCachedImage() scaledImage {
// return cache[0]
// }
// // replace the first element in the cache with the last one and add a new scaled image to the cache
// func RotateCache() {
// addToHistory(GetCachedImage())
// loggo.Debug("removing first element from image cache...")
// cacheSize := len(cache)
// if cacheSize == 1 {
// cache = nil
// cacheImages()
// return
// }
// cacheSize--
// cache[0] = cache[cacheSize]
// cache = cache[:cacheSize]
// go cacheImages()
// }
// // start the "slideshow" in a goroutine - rotate the cache based on the set interval
// func startSlideshow() {
// go func() {
// interval := time.Duration(config.GetImageSlideshowInterval()) * time.Second
// for {
// NextRotation = time.Now().Add(interval)
// time.Sleep(interval)
// RotateCache()
// }
// }()
// }
// // fill the cache with scaled images
// func cacheImages() {
// timestamp := time.Now().UnixMilli()
// imageCount := len(images)
// cacheSize := len(cache)
// cacheLimit := config.GetImageCache()
// if imageCount < cacheLimit {
// cacheLimit = imageCount
// }
// imagesToCache := cacheLimit - cacheSize
// if imagesToCache <= 0 {
// return
// }
// concurrency := runtime.NumCPU()
// if imagesToCache < concurrency {
// concurrency = imagesToCache
// }
// loggo.Debug("filling image cache with "+strconv.Itoa(imagesToCache)+" element(s)", "concurrency: "+strconv.Itoa(concurrency))
// var waitgroup sync.WaitGroup
// waitgroup.Add(imagesToCache)
// channel := make(chan struct{}, concurrency)
// var cached int
// for cached = 0; cached < imagesToCache; cached++ {
// channel <- struct{}{}
// var randomScaledImage scaledImage
// randomScaledImage.Name = getRandomImage()
// if randomScaledImage.isCached() {
// continue
// }
// go func(randomScaledImage scaledImage) {
// randomScaledImage.Data = scale(randomScaledImage.Name, config.GetImageWidth(), config.GetImageHeight())
// cache = append(cache, randomScaledImage)
// loggo.Debug("added scaled image '" + randomScaledImage.Name + "' to cache")
// <-channel
// waitgroup.Done()
// }(randomScaledImage)
// }
// waitgroup.Wait()
// loggo.DebugTimed("filled image cache with "+strconv.Itoa(cached)+" images", timestamp)
// }

View file

@ -1,40 +0,0 @@
package images
// import (
// "errors"
// "strconv"
// "time"
// "git.velvettear.de/velvettear/image-frame/config"
// "git.velvettear.de/velvettear/loggo"
// )
// // internal history of displayed images
// var history []scaledImage
// // add a image to the history
// func addToHistory(scaledImage scaledImage) {
// timestamp := time.Now().UnixMilli()
// historyLimit := config.GetImageSlideshowHistory()
// if historyLimit <= 0 {
// return
// }
// history = append(history, scaledImage)
// diff := len(history) - historyLimit
// if diff > 0 {
// history = history[diff:]
// }
// loggo.DebugTimed("added image to history", timestamp, "history size: "+strconv.Itoa(len(history)))
// }
// // get and remove the latest image from history
// func getLatestFromHistory() (scaledImage, error) {
// var scaledImage scaledImage
// if len(history) == 0 {
// return scaledImage, errors.New("history is empty")
// }
// index := len(history) - 1
// scaledImage = history[index]
// history = history[:index]
// return scaledImage, nil
// }

View file

@ -1,7 +0,0 @@
package images
// func Initialize() {
// scanForImages()
// cacheImages()
// startSlideshow()
// }

View file

@ -1,31 +0,0 @@
package images
// import (
// "math/rand"
// )
// // struct for scaled images
// type scaledImage struct {
// Name string
// Data []byte
// }
// // get a random image
// func getRandomImage() string {
// return images[rand.Intn(len(images)-1)]
// }
// // check if the scaled image is already cached
// func (scaledImage *scaledImage) isCached() bool {
// return isCached(scaledImage.Name)
// }
// // check (by name) if the scaled image is already cached
// func isCached(scaledImageName string) bool {
// for _, cachedImage := range cache {
// if cachedImage.Name == scaledImageName {
// return true
// }
// }
// return false
// }

View file

@ -2,6 +2,7 @@ package config
import ( import (
"os" "os"
"os/exec"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -9,49 +10,39 @@ 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 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 ScriptArgs []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
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 + "'")
} }
@ -61,17 +52,67 @@ 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")
ScriptArgs = strings.Split(os.Getenv("SLIDESHOW_SCRIPT_ARGS"), " ")
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
@ -83,3 +124,33 @@ func IsResolutionSet() bool {
func IsPaletteSet() bool { func IsPaletteSet() bool {
return len(PaletteFile) > 0 return len(PaletteFile) > 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
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()
defer waitgroup.Done()
if !config.IsPaletteSet() {
return
}
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, config.ScriptArgs...)
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,44 +0,0 @@
package slideshow
import (
"errors"
"io"
"os/exec"
"strconv"
"strings"
"time"
"git.velvettear.de/velvettear/loggo"
"git.velvettear.de/velvettear/slideshow/internal/config"
)
// scale an image
func scale(image string) ([]byte, error) {
timestamp := time.Now().UnixMilli()
cmd := exec.Command("convert", image, "-resize", config.Resolution+"^", "-gravity", "center", "-extent", config.Resolution, "-")
stdout, stdoutError := cmd.StdoutPipe()
stderr, stderrError := cmd.StderrPipe()
var data []byte
cmd.Start()
if stdoutError != nil {
return data, stdoutError
}
if stderrError != nil {
return data, stdoutError
}
data, stdoutError = io.ReadAll(stdout)
if stdoutError != nil {
return data, stdoutError
}
errorBytes, stderrError := io.ReadAll(stderr)
if stderrError != nil {
return data, stdoutError
}
cmd.Wait()
errorMessage := strings.TrimSpace(string(errorBytes))
if len(errorMessage) > 0 {
return data, errors.New(errorMessage)
}
loggo.DebugTimed("successfully scaled image", timestamp, "image: "+image, "resolution: "+config.Resolution, "size: "+strconv.Itoa(len(data)))
return data, nil
}

View file

@ -1,138 +0,0 @@
package slideshow
import (
"errors"
"image/color"
"io"
"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 scaleTime time.Duration
scaleImages := config.IsResolutionSet()
for {
var image string
var palette []color.Color
var data []byte
for {
image = scanner.GetRandomImage()
if image != previousImage {
break
}
}
if scaleImages {
scaleTimestamp := time.Now()
tmp, error := scale(image)
if error != nil {
loggo.Error("encountered an error scaling an image", "image: "+image, error.Error())
continue
}
data = tmp
scaleTime = time.Since(scaleTimestamp)
palette, _ = getColorPaletteRaw(data)
} else {
palette, _ = getColorPalette(image)
}
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)
if scaleImages {
error := setBackgroundPiped(data)
if error != nil {
loggo.Error("encountered an error setting the background via pipe to feh's stdin", error.Error())
}
loggo.Info("set new scaled background image", "image: "+image, "resolution: "+config.Resolution)
} else {
error := setBackgroundImage(image)
if error != nil {
loggo.Error("encountered an error setting the background image", "image: "+image, error.Error())
}
loggo.Info("set new background image", "image: "+image)
}
sleepTime = config.Interval - scaleTime
}
}
// 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
}
// pipe data to 'feh' via stdin to set the background image
func setBackgroundPiped(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
}

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
}

10
main.go
View file

@ -1,23 +1,17 @@
package main package main
import ( import (
"sync"
"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...")
var waitgroup sync.WaitGroup
waitgroup.Add(1)
config.Initialize() config.Initialize()
scanner.Initialize() internal.StartSlideshow()
slideshow.Start()
waitgroup.Wait()
loggo.InfoTimed("slideshow is shutting down now!", timestamp) loggo.InfoTimed("slideshow is shutting down now!", timestamp)
} }

View file

@ -1,66 +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
// 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))]
}
// scan the specified directory
func scan(directory string) {
timestamp := time.Now().UnixMilli()
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)
go scheduleRescan(directory)
}
// sleep the specified interval and then trigger a rescan of the specified directory
func scheduleRescan(directory string) {
loggo.Debug("sleeping for " + strconv.FormatInt(config.ScanInterval.Milliseconds(), 10) + "ms before next scan...")
time.Sleep(config.ScanInterval)
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

@ -5,14 +5,17 @@ Description=slideshow
Type=simple Type=simple
User=velvettear User=velvettear
Environment="DISPLAY=:0" Environment="DISPLAY=:0"
ENVIRONMENT="SLIDESHOW_API=localhost:3000"
Environment="SLIDESHOW_INTERVAL=60" Environment="SLIDESHOW_INTERVAL=60"
Environment="SLIDESHOW_DIRECTORY=$HOME" Environment="SLIDESHOW_RESOLUTION="
Environment="SLIDESHOW_SCANINTERVAL=60"
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="
Environment="SLIDESHOW_PALETTE_COLORS=16" Environment="SLIDESHOW_PALETTE_COLORS="
Environment="SLIDESHOW_SCRIPT="
Environment="SLIDESHOW_SCRIPT_ARGS="
Environment="SLIDESHOW_SCRIPT_ASYNC="
Environment="SLIDESHOW_SCRIPT_STAGE="
Environment="SLIDESHOW_LOGLEVEL=debug"
ExecStart=/opt/slideshow/slideshow ExecStart=/opt/slideshow/slideshow
[Install] [Install]