diff --git a/.vscode/launch.json b/.vscode/launch.json index 5a11974..20c8228 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,8 +10,8 @@ "env":{ "SLIDESHOW_ADDRESS": "0.0.0.0", "SLIDESHOW_PORT": "3000", - "SLIDESHOW_DIRECTORY": "/home/velvettear/images", - "SLIDESHOW_SCANINTERVAL": "10", + "SLIDESHOW_DIRECTORY": "/mnt/images", + "SLIDESHOW_SCANINTERVAL": "600", "SLIDESHOW_RESOLUTION": "600x1024", "SLIDESHOW_LOGLEVEL": "debug", "SLIDESHOW_PALETTE_ALGORITHM": "wsm", diff --git a/README.md b/README.md index 60715a7..d2e0f7c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ configuration is entirely done via environment variables. | SLIDESHOW_LOGLEVEL | "info" | the log level | **note:** -if `SLIDESHOW_RESOLUTION` is unset the images will served as they are (without any scaling). +if `SLIDESHOW_RESOLUTION` is unset or imagemagick's `convert` command is not in your `$PATH` the images will served as they are (without any scaling). **available log levels:** diff --git a/internal/api/response.go b/internal/api/response.go new file mode 100644 index 0000000..c89a896 --- /dev/null +++ b/internal/api/response.go @@ -0,0 +1,37 @@ +package api + +import ( + "encoding/json" + "net/http" + + "git.velvettear.de/velvettear/loggo" +) + +// struct for server responses +type response struct { + StatusCode int + Content interface{} + error error +} + +// send a response +func (response *response) send(writer http.ResponseWriter) { + if response.StatusCode <= 0 { + if response.error != nil { + response.StatusCode = 500 + } else { + response.StatusCode = 200 + } + } + if response.error != nil { + response.Content = response.error.Error() + } + data, error := json.Marshal(response) + if error != nil { + loggo.Error("encountered an error marshalling a json response", error.Error()) + response.StatusCode = 500 + return + } + writer.WriteHeader(response.StatusCode) + writer.Write(data) +} diff --git a/internal/api/server.go b/internal/api/server.go index 824ec33..50fe4df 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1,9 +1,10 @@ package api import ( - "encoding/json" "net/http" "strconv" + "strings" + "time" "git.velvettear.de/velvettear/loggo" "git.velvettear.de/velvettear/slideshow-api/internal" @@ -23,42 +24,47 @@ func Run() { // register the handlers func registerHandlers() { - http.HandleFunc("/", handleImageRequest) + http.HandleFunc("/", serveRandomImageName) + http.HandleFunc("/image/", serveImage) + http.HandleFunc("/palette/", servePalette) } -// handle the request to '/image' -func handleImageRequest(writer http.ResponseWriter, request *http.Request) { - if !internal.HasFoundImages() { - writer.WriteHeader(404) - return +// request url: '/' - serve the name of a random image +func serveRandomImageName(writer http.ResponseWriter, request *http.Request) { + timestamp := time.Now().UnixMilli() + var response response + response.Content, response.error = internal.GetRandomImage() + if response.error != nil { + response.StatusCode = 404 } - name, data, error := internal.GetRandomImage() - if error != nil { - loggo.Error("encountered an error getting a random image", error.Error()) - writer.WriteHeader(501) - writer.Write([]byte("encountered an internal server error (" + error.Error() + ")\n")) - return - } - palette, error := internal.GetColorPalette(data) - if error != nil { - writer.WriteHeader(501) - writer.Write([]byte("encountered an internal server error (" + error.Error() + ")\n")) - return - } - bytes, error := json.Marshal(struct { - Name string - Palette string - Data []byte - }{ - name, - palette, - data, - }) - if error != nil { - loggo.Error("encountered an error marshalling the json response", error.Error()) - writer.WriteHeader(501) - writer.Write([]byte("encountered an internal server error (" + error.Error() + ")\n")) - return - } - writer.Write(bytes) + response.send(writer) + loggo.DebugTimed("served random image '"+response.Content.(string)+"'", timestamp) +} + +// request url: '/image' - serve an image +func serveImage(writer http.ResponseWriter, request *http.Request) { + var response response + image, _ := strings.CutPrefix(request.URL.Path, "/image/") + image, error := internal.GetImagePath(image) + if error != nil { + response.error = error + response.StatusCode = 404 + response.send(writer) + return + } + streamImage(writer, image) +} + +// request url: '/palette' - serve the color palette of an image +func servePalette(writer http.ResponseWriter, request *http.Request) { + var response response + image, _ := strings.CutPrefix(request.URL.Path, "/palette/") + image, error := internal.GetImagePath(image) + if error != nil { + response.error = error + response.StatusCode = 404 + response.send(writer) + return + } + streamColorPalette(writer, image) } diff --git a/internal/api/stream.go b/internal/api/stream.go new file mode 100644 index 0000000..05c6a07 --- /dev/null +++ b/internal/api/stream.go @@ -0,0 +1,124 @@ +package api + +import ( + "errors" + "io" + "net/http" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "git.velvettear.de/velvettear/loggo" + "git.velvettear.de/velvettear/slideshow-api/internal" + "git.velvettear.de/velvettear/slideshow-api/internal/config" + color_thief "github.com/kennykarnama/color-thief" +) + +// central streaming method for images +func streamImage(writer http.ResponseWriter, image string) { + if config.IsResolutionSet() { + streamScaledImage(writer, image) + return + } + streamUnscaledImage(writer, image) +} + +// stream a color palette +func streamColorPalette(writer http.ResponseWriter, image string) { + timestamp := time.Now().UnixMilli() + var response response + amount := config.PaletteColors + colors, error := color_thief.GetPaletteFromFile(image, amount, config.PaletteAlgorithm) + if error != nil { + loggo.Error("encountered an error getting the color palette from image '"+image+"'", error.Error()) + response.error = error + response.send(writer) + return + } + 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 + "=\"" + internal.RgbToHex(color) + "\"" + } + data := []byte(palette) + writer.Write(data) + loggo.InfoTimed("successfully streamed color palette for image '"+image+"'", timestamp, "size: "+internal.FormatBytes(int64(len(data)))) +} + +// stream an unscaled image +func streamUnscaledImage(writer http.ResponseWriter, image string) { + timestamp := time.Now().UnixMilli() + var response response + file, error := os.Open(image) + if error != nil { + loggo.Error("encountered an error opening image '"+image+"' for streaming", error.Error()) + response.error = error + response.send(writer) + return + } + defer file.Close() + written, error := io.Copy(writer, file) + if error != nil { + loggo.Error("encountered an error piping image '"+image+"' to stream", error.Error()) + response.error = error + response.send(writer) + return + } + loggo.InfoTimed("successfully streamed image '"+image+"'", timestamp, "size: "+internal.FormatBytes(written)) +} + +// scale and stream an image +func streamScaledImage(writer http.ResponseWriter, image string) { + timestamp := time.Now().UnixMilli() + var response response + cmd := exec.Command("convert", image, "-resize", config.Resolution+"^", "-gravity", "center", "-extent", config.Resolution, "-") + stdout, stdoutError := cmd.StdoutPipe() + stderr, stderrError := cmd.StderrPipe() + cmd.Start() + if stdoutError != nil { + loggo.Error("enountered an error opening imagemagicks's stdout pipe", stdoutError.Error()) + response.error = stdoutError + response.send(writer) + return + } + if stderrError != nil { + loggo.Error("enountered an error opening imagemagicks's stderr pipe", stderrError.Error()) + response.error = stderrError + response.send(writer) + return + } + written, copyError := io.Copy(writer, stdout) + if copyError != nil { + loggo.Error("enountered an error piping imagemagicks's output to the stream", copyError.Error()) + response.error = copyError + response.send(writer) + return + } + if written == 0 { + errorBytes, stderrError := io.ReadAll(stderr) + if stderrError != nil { + loggo.Error("enountered an error reading imagemagicks's stderr", stderrError.Error()) + response.error = stderrError + response.send(writer) + return + } + if len(errorBytes) > 0 { + error := errors.New(strings.TrimSpace(string(errorBytes))) + loggo.Error("enountered an error executing imagemagick", error.Error()) + response.error = error + response.send(writer) + return + } + } + cmd.Wait() + loggo.InfoTimed("successfully streamed scaled image '"+image+"'", timestamp, "size: "+internal.FormatBytes(written), "resolution: "+config.Resolution) +} diff --git a/internal/cache.go b/internal/cache.go deleted file mode 100644 index 62c7645..0000000 --- a/internal/cache.go +++ /dev/null @@ -1,44 +0,0 @@ -package internal - -import ( - "image/color" - "strconv" -) - -// the internally pre buffered (and scaled) image -var cachedImage []byte - -// the internally pre buffered color palette -var cachedPalette []byte - -// cache an image -func cacheImage(image []byte) { - cachedImage = image -} - -// cache a color palette -func cachePalette(colors []color.Color) { - 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) + "\"" - } - cachedPalette = []byte(palette) -} - -// check if an image is cached -func hasCachedImage() bool { - return len(cachedImage) > 0 -} - -// check if a color palette is cached -func hasCachedColorPalette() bool { - return len(cachedPalette) > 0 -} diff --git a/internal/config/config.go b/internal/config/config.go index 3348fe0..e2869cd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "os" + "os/exec" "strconv" "strings" "time" @@ -16,6 +17,7 @@ var ScanInterval time.Duration var Resolution string var PaletteAlgorithm int var PaletteColors int +var BufferSize int // initialize the config func Initialize() { @@ -72,6 +74,25 @@ func Initialize() { tmpInt = 16 } PaletteColors = tmpInt + tmpInt, _ = strconv.Atoi(os.Getenv("SLIDESHOW_BUFFERSIZE")) + if tmpInt <= 0 { + tmpInt = 4096 + } + BufferSize = tmpInt + if IsResolutionSet() { + checkForConvert() + } +} + +// check if 'convert' is available +func checkForConvert() { + cmd := exec.Command("convert") + error := cmd.Run() + if error == nil { + return + } + Resolution = "" + loggo.Warning("could not find imagemagick's 'convert' command, no scaling will be done", error.Error()) } // check if a resolution has been specified diff --git a/internal/formatter.go b/internal/formatter.go new file mode 100644 index 0000000..70e937f --- /dev/null +++ b/internal/formatter.go @@ -0,0 +1,27 @@ +package internal + +import ( + "fmt" + "image/color" + "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 +} + +// parse rgb color values to a hex hex +func RgbToHex(color color.Color) string { + r, g, b, _ := color.RGBA() + return fmt.Sprintf("#%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8)) +} diff --git a/internal/scale.go b/internal/scale.go deleted file mode 100644 index eafeb43..0000000 --- a/internal/scale.go +++ /dev/null @@ -1,43 +0,0 @@ -package internal - -import ( - "errors" - "io" - "os/exec" - "strings" - "time" - - "git.velvettear.de/velvettear/loggo" - "git.velvettear.de/velvettear/slideshow-api/internal/config" -) - -// scale an image -func ScaleImage(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) - return data, nil -} diff --git a/internal/scanner.go b/internal/scanner.go index c452e5f..e06195e 100644 --- a/internal/scanner.go +++ b/internal/scanner.go @@ -4,7 +4,6 @@ import ( "errors" "io/fs" "math/rand" - "os" "path/filepath" "strconv" "strings" @@ -14,17 +13,18 @@ import ( "git.velvettear.de/velvettear/slideshow-api/internal/config" ) -// slice of images -var images []string +// map of images +var images map[string]string -// temporary slice of images -var tmpImages []string +// temporary map of images +var tmpImages map[string]string // Scan the specified directory func Scan() { timestamp := time.Now() directory := config.Directory loggo.Info("scanning directory for images and subdirectories...", "interval: "+strconv.FormatFloat(config.ScanInterval.Seconds(), 'f', 0, 64)+" seconds", "directory: "+directory) + tmpImages = make(map[string]string) filepath.WalkDir(directory, checkDirectory) images = tmpImages tmpImages = nil @@ -32,26 +32,33 @@ func Scan() { go scheduleRescan() } -// check if images has been found -func HasFoundImages() bool { - return len(images) > 0 +// get a random image +func GetRandomImage() (string, error) { + if len(images) == 0 { + return "", errors.New("no images have been found in directory '" + config.Directory + "'") + } + random := rand.Intn(len(images)) + index := 0 + for key := range images { + if index == random { + return key, nil + } + index++ + } + return "", errors.New("could not find a random image with index '" + strconv.Itoa(random) + "'") } -// get a random image -func GetRandomImage() (string, []byte, error) { - var name string - var data []byte - var error error - if len(images) == 0 { - return name, data, errors.New("no images have been found") +// get the path of an image by name +func GetImagePath(name string) (string, error) { + var path string + if len(name) == 0 { + return path, errors.New("no image name provided") } - image := images[rand.Intn(len(images))] - if config.IsResolutionSet() { - data, error = ScaleImage(image) - } else { - data, error = os.ReadFile(image) + image := images[name] + if len(image) == 0 { + return path, errors.New("could not find image '" + name + "' in internal map") } - return image, data, error + return image, nil } // sleep the specified interval and then trigger a rescan of the specified directory @@ -61,7 +68,7 @@ func scheduleRescan() { Scan() } -// add image files to the slice of images and subdirectoies to the watcher +// add image files to the map of images func checkDirectory(path string, dir fs.DirEntry, err error) error { if err != nil { return err @@ -69,8 +76,8 @@ func checkDirectory(path string, dir fs.DirEntry, err error) error { if dir.IsDir() || !isImage(path) { return nil } - tmpImages = append(tmpImages, path) - loggo.Debug("added image to temporary slice of images", path) + tmpImages[filepath.Base(path)] = path + loggo.Debug("added image to temporary map of images", path) return nil }