Compare commits
No commits in common. "e8edfdaf8318ae3110e3cff13eb1acaa94b248d3" and "5d9b028c29f46f9c7cfbb77aa9c8154d1cd0c9ca" have entirely different histories.
e8edfdaf83
...
5d9b028c29
19 changed files with 597 additions and 452 deletions
36
.vscode/launch.json
vendored
36
.vscode/launch.json
vendored
|
@ -2,23 +2,37 @@
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "slideshow",
|
"name": "slideshow-scaled",
|
||||||
"type": "go",
|
"type": "go",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"mode": "auto",
|
"mode": "auto",
|
||||||
"program": "${workspaceFolder}/main.go",
|
"program": "${workspaceFolder}/main.go",
|
||||||
"env":{
|
"env":{
|
||||||
"SLIDESHOW_API": "localhost:3000",
|
"SLIDESHOW_INTERVAL": "10",
|
||||||
"SLIDESHOW_INTERVAL": "3",
|
"SLIDESHOW_DIRECTORY": "/home/velvettear/images",
|
||||||
"SLIDESHOW_RESOLUTION": "",
|
"SLIDESHOW_SCANINTERVAL": "300",
|
||||||
"SLIDESHOW_PALETTE": "",
|
"SLIDESHOW_RESOLUTION": "600x1024",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
|
|
25
README.md
25
README.md
|
@ -1,11 +1,11 @@
|
||||||
# slideshow
|
# slideshow
|
||||||
|
|
||||||
client for the [slideshow-api](https://git.velvettear.de/velvettear/slideshow-api).
|
a simple cli application to start a background image slideshow using 'feh'.
|
||||||
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,32 +13,19 @@ 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).
|
||||||
|
|
||||||
- if `SLIDESHOW_RESOLUTION` is unset images will be requested in their original resolution.
|
**available log levels:**
|
||||||
- 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`
|
||||||
|
|
3
go.mod
3
go.mod
|
@ -6,7 +6,8 @@ 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.15.0 // indirect
|
golang.org/x/sys v0.14.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
4
go.sum
4
go.sum
|
@ -2,6 +2,8 @@ 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=
|
||||||
|
@ -11,5 +13,3 @@ 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=
|
|
||||||
|
|
105
images/cache.go
Normal file
105
images/cache.go
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
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)
|
||||||
|
// }
|
40
images/history.go
Normal file
40
images/history.go
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
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
|
||||||
|
// }
|
7
images/initialize.go
Normal file
7
images/initialize.go
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
package images
|
||||||
|
|
||||||
|
// func Initialize() {
|
||||||
|
// scanForImages()
|
||||||
|
// cacheImages()
|
||||||
|
// startSlideshow()
|
||||||
|
// }
|
31
images/scaledimage.go
Normal file
31
images/scaledimage.go
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
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
|
||||||
|
// }
|
|
@ -2,7 +2,6 @@ package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -10,39 +9,49 @@ 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 string
|
var PaletteAlgorithm int
|
||||||
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 + "'")
|
||||||
}
|
}
|
||||||
|
@ -52,67 +61,17 @@ func Initialize() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PaletteFile = os.Getenv("SLIDESHOW_PALETTE")
|
PaletteFile = os.Getenv("SLIDESHOW_PALETTE")
|
||||||
PaletteAlgorithm = os.Getenv("SLIDESHOW_PALETTE_ALGORITHM")
|
tmpString := 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
|
||||||
|
@ -124,33 +83,3 @@ 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"
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,101 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,115 +0,0 @@
|
||||||
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...)
|
|
||||||
}
|
|
86
internal/slideshow/palette.go
Normal file
86
internal/slideshow/palette.go
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
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))
|
||||||
|
}
|
44
internal/slideshow/scale.go
Normal file
44
internal/slideshow/scale.go
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
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
|
||||||
|
}
|
138
internal/slideshow/slideshow.go
Normal file
138
internal/slideshow/slideshow.go
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -1,73 +0,0 @@
|
||||||
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
10
main.go
|
@ -1,17 +1,23 @@
|
||||||
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()
|
||||||
internal.StartSlideshow()
|
scanner.Initialize()
|
||||||
|
slideshow.Start()
|
||||||
|
waitgroup.Wait()
|
||||||
loggo.InfoTimed("slideshow is shutting down now!", timestamp)
|
loggo.InfoTimed("slideshow is shutting down now!", timestamp)
|
||||||
}
|
}
|
||||||
|
|
66
scanner/scanner.go
Normal file
66
scanner/scanner.go
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
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")
|
||||||
|
}
|
|
@ -5,17 +5,14 @@ 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_RESOLUTION="
|
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=/tmp/.slideshow.palette"
|
||||||
Environment="SLIDESHOW_PALETTE_ALGORITHM="
|
Environment="SLIDESHOW_PALETTE_ALGORITHM=wsm"
|
||||||
Environment="SLIDESHOW_PALETTE_COLORS="
|
Environment="SLIDESHOW_PALETTE_COLORS=16"
|
||||||
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]
|
||||||
|
|
Loading…
Reference in a new issue