diff --git a/README.md b/README.md new file mode 100644 index 0000000..eeef79b --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# Gallery + +Simple gallery. + +## Usage + +1. Create config file and set `WorkingDirectory` +2. Start program + + ```sh + gallery + ``` + +## Config structure + +```toml +WorkingDirectory = "/home/user/pictures" # Working directory +Crf = 40 # Preview CRF + # from 1 - maximum quality + # to 63 - minimum preview cache size +ProcessCount = 4 # Preview generator threads +``` diff --git a/black.png b/black.png new file mode 100644 index 0000000..cf671d1 Binary files /dev/null and b/black.png differ diff --git a/config.go b/config.go new file mode 100644 index 0000000..ad3a2ef --- /dev/null +++ b/config.go @@ -0,0 +1,30 @@ +package main + +import ( + "path/filepath" + + "github.com/ilyakaznacheev/cleanenv" +) + +type Config struct { + WorkingDirectory string `env-default:"."` + Crf uint64 `env-default:"40"` + ProcessCount uint64 `env-default:"4"` +} + +func loadConfig(path string) (*Config, error) { + var config Config + + err := cleanenv.ReadConfig("gallery.toml", &config) + if err != nil { + return nil, err + } + + workingDirectory, err := filepath.Abs(config.WorkingDirectory) + if err != nil { + return nil, err + } + config.WorkingDirectory = workingDirectory + + return &config, nil +} diff --git a/consts.go b/consts.go new file mode 100644 index 0000000..66b71a1 --- /dev/null +++ b/consts.go @@ -0,0 +1,5 @@ +package main + +const ( + defaultConfigPath = "gallery.conf" +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a445296 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/nxshock/gallery + +go 1.19 + +require ( + github.com/ilyakaznacheev/cleanenv v1.4.1 + github.com/nxshock/zkv v0.0.9 +) + +require ( + github.com/BurntSushi/toml v1.1.0 // indirect + github.com/joho/godotenv v1.4.0 // indirect + github.com/klauspost/compress v1.15.12 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0c39905 --- /dev/null +++ b/go.sum @@ -0,0 +1,19 @@ +github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I= +github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/ilyakaznacheev/cleanenv v1.4.1 h1:zroQjmb8e3w6DBcgbgFXtlQTX8xP8XCOg1etuYv4hX0= +github.com/ilyakaznacheev/cleanenv v1.4.1/go.mod h1:i0owW+HDxeGKE0/JPREJOdSCPIyOnmh6C0xhWAkF/xA= +github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= +github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.15.12 h1:YClS/PImqYbn+UILDnqxQCZ3RehC9N318SU3kElDUEM= +github.com/klauspost/compress v1.15.12/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= +github.com/nxshock/zkv v0.0.9 h1:WS4Rr79y9EFufwgOSI7gQsVyQJlbaYiB8633rt8Bijo= +github.com/nxshock/zkv v0.0.9/go.mod h1:5hMUAMHPeTWWW3HFD0CD+znB02BQLSEa+Z3+fZSOeh4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..4e08d9d --- /dev/null +++ b/handlers.go @@ -0,0 +1,72 @@ +package main + +import ( + "log" + "net/http" + "path/filepath" + "sort" + "strings" +) + +func getResponse(path string) ([]Item, error) { + fileNames, err := filepath.Glob(filepath.Join(path, "*")) + if err != nil { + return nil, err + } + + for i := range fileNames { + fileNames[i] = strings.TrimPrefix(filepath.ToSlash(fileNames[i]), filepath.ToSlash(config.WorkingDirectory)) + } + + items := make([]Item, 0) + + for _, fileName := range fileNames { + itemType, err := getType(filepath.Join(config.WorkingDirectory, fileName)) + if err != nil { + log.Println(err) + continue + } + + if itemType == Unknown { + continue + } + + items = append(items, Item{itemType, Path(fileName)}) + } + + sort.Slice(items, func(i, j int) bool { + return items[i].ItemType < items[j].ItemType + }) + + return items, nil +} + +func rootHandler(w http.ResponseWriter, r *http.Request) { + items, err := getResponse(filepath.Join(config.WorkingDirectory, r.URL.Path[1:])) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + _ = t.Execute(w, items) +} + +func previewHandler(w http.ResponseWriter, r *http.Request) { + fileName := strings.TrimPrefix(r.URL.Path, "/preview/") + + b, err := previewCache.Read(filepath.Join(config.WorkingDirectory, fileName)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "image/avif") + w.Header().Set("Cache-Control", "max-age=31536000") + _, _ = w.Write(b) +} + +func viewHandler(w http.ResponseWriter, r *http.Request) { + fileName := strings.TrimPrefix(r.URL.Path, "/view/") + + http.ServeFile(w, r, filepath.Join(config.WorkingDirectory, fileName)) +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..70fce03 --- /dev/null +++ b/index.html @@ -0,0 +1,221 @@ + + + + + + Gallery + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/itemtype.go b/itemtype.go new file mode 100644 index 0000000..c0a4efa --- /dev/null +++ b/itemtype.go @@ -0,0 +1,47 @@ +package main + +import ( + "os" + "path/filepath" + "strings" +) + +type ItemType int + +const ( + Unknown ItemType = iota + Directory + Picture + Video +) + +type Path string + +type Item struct { + ItemType + Path Path +} + +func (p *Path) Base() string { + return filepath.Base(string(*p)) +} + +func getType(path string) (ItemType, error) { + stat, err := os.Stat(path) + if err != nil { + return Unknown, err + } + + if stat.IsDir() { + return Directory, nil + } + + switch strings.ToLower(filepath.Ext(path)) { + case ".jpg", ".jpeg", ".png", ".avif", ".bmp", ".gif": + return Picture, nil + case ".mov", ".mp4", ".avi", ".mkv", ".3gp", ".webm": + return Video, nil + } + + return Unknown, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..971bb1e --- /dev/null +++ b/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "log" + "math/rand" + "net/http" + "os" + "os/signal" + "time" +) + +var ( + previewCache = NewPreviewCache("cache.zkv") + config *Config + semaphore chan struct{} +) + +func init() { + log.SetFlags(0) + rand.Seed(time.Now().Unix()) + + configPath := defaultConfigPath + if len(os.Args) > 1 { + configPath = os.Args[1] + } + + var err error + + config, err = loadConfig(configPath) + if err != nil { + log.Fatalln(err) + } + + err = os.Chdir(config.WorkingDirectory) + if err != nil { + log.Fatalln(err) + } + + semaphore = make(chan struct{}, config.ProcessCount) +} + +func main() { + go func() { + http.HandleFunc("/preview/", previewHandler) + http.HandleFunc("/view/", viewHandler) + http.HandleFunc("/", rootHandler) + log.Fatalln(http.ListenAndServe(":8080", nil)) + }() + + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt) + <-c + + err := previewCache.Save() + if err != nil { + log.Fatalln(err) + } +} diff --git a/preview.go b/preview.go new file mode 100644 index 0000000..63548e0 --- /dev/null +++ b/preview.go @@ -0,0 +1,103 @@ +package main + +import ( + "log" + "os" + "os/exec" + "path/filepath" + "strconv" + + "github.com/nxshock/zkv" +) + +type PreviewCache struct { + store *zkv.Store +} + +func NewPreviewCache(filePath string) *PreviewCache { + store, err := zkv.Open(filePath) + if err != nil { + log.Fatalln(err) + } + + reviewCache := &PreviewCache{store} + + return reviewCache +} + +func (pc *PreviewCache) Add(filePath string) ([]byte, error) { + defer func() { + <-semaphore + }() + semaphore <- struct{}{} + + tempFileName, err := TempFileName("preview*.avif") + if err != nil { + return nil, err + } + defer os.Remove(tempFileName) + + stat, err := os.Stat(filePath) + if err != nil { + return nil, err + } + + var cmd *exec.Cmd + if stat.IsDir() { // https://trac.ffmpeg.org/wiki/Create%20a%20mosaic%20out%20of%20several%20input%20videos + fileNames, err := getRandomFiles(filePath, 4) + if err != nil { + return nil, err + } + + cmd = exec.Command("ffmpeg", + "-i", fileNames[0], + "-i", fileNames[1], + "-i", fileNames[2], + "-i", fileNames[3], + "-filter_complex", "nullsrc=size=240x240 [base];[0:v] setpts=PTS-STARTPTS, scale=120x120:force_original_aspect_ratio=increase [upperleft];[1:v] setpts=PTS-STARTPTS, scale=120x120:force_original_aspect_ratio=increase [upperright];[2:v] setpts=PTS-STARTPTS, scale=120x120:force_original_aspect_ratio=increase [lowerleft];[3:v] setpts=PTS-STARTPTS, scale=120x120:force_original_aspect_ratio=increase [lowerright];[base][upperleft] overlay=shortest=1 [tmp1];[tmp1][upperright] overlay=shortest=1:x=120 [tmp2];[tmp2][lowerleft] overlay=shortest=1:y=120 [tmp3];[tmp3][lowerright] overlay=shortest=1:x=120:y=120", + "-frames:v", "1", + "-crf", strconv.FormatUint(config.Crf, 10), + "-f", "avif", + tempFileName) + } else { + cmd = exec.Command("ffmpeg.exe", + "-i", filepath.FromSlash(filePath), + "-vf", "scale=240:240:force_original_aspect_ratio=increase,crop=240:240:exact=1", + "-frames:v", "1", + "-crf", "40", + "-f", "avif", + tempFileName) + } + + err = cmd.Run() + if err != nil { + return nil, err + } + + b, err := os.ReadFile(tempFileName) + if err != nil { + return nil, err + } + + err = pc.store.Set(filePath, b) + if err != nil { + return nil, err + } + + return b, nil +} + +func (pc *PreviewCache) Read(filePath string) ([]byte, error) { + var b []byte + + err := pc.store.Get(filePath, &b) + if err != nil { + return pc.Add(filePath) + } + + return b, nil +} + +func (pc *PreviewCache) Save() error { + return pc.store.Close() +} diff --git a/template.go b/template.go new file mode 100644 index 0000000..b7f1835 --- /dev/null +++ b/template.go @@ -0,0 +1,11 @@ +package main + +import ( + _ "embed" + "html/template" +) + +//go:embed index.html +var mainTemplateStr string + +var t = template.Must(template.New("main").Parse(mainTemplateStr)) diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..229acda --- /dev/null +++ b/utils.go @@ -0,0 +1,99 @@ +package main + +import ( + "errors" + "math/rand" + "os" + "path/filepath" + "strconv" + "time" +) + +var errPatternHasSeparator = errors.New("pattern contains path separator") + +func TempFileName(pattern string) (string, error) { + dir := os.TempDir() + + prefix, suffix, err := prefixAndSuffix(pattern) + if err != nil { + return "", &os.PathError{Op: "createtemp", Path: pattern, Err: err} + } + prefix = joinPath(dir, prefix) + + try := 0 + for { + name := prefix + nextRandom() + suffix + if exists, _ := IsFileExists(name); exists { + if try++; try < 10000 { + continue + } + return "", &os.PathError{Op: "createtemp", Path: prefix + "*" + suffix, Err: os.ErrExist} + } + return name, err + } +} + +func IsFileExists(filePath string) (bool, error) { + if _, err := os.Stat(filePath); err == nil { + return true, nil + } else if errors.Is(err, os.ErrNotExist) { + return false, nil + + } else { + return false, err + } +} + +func prefixAndSuffix(pattern string) (prefix, suffix string, err error) { + for i := 0; i < len(pattern); i++ { + if os.IsPathSeparator(pattern[i]) { + return "", "", errPatternHasSeparator + } + } + if pos := lastIndex(pattern, '*'); pos != -1 { + prefix, suffix = pattern[:pos], pattern[pos+1:] + } else { + prefix = pattern + } + return prefix, suffix, nil +} + +func joinPath(dir, name string) string { + if len(dir) > 0 && os.IsPathSeparator(dir[len(dir)-1]) { + return dir + name + } + return dir + string(os.PathSeparator) + name +} + +func nextRandom() string { + + return strconv.Itoa(int(rand.Uint64())) +} + +func lastIndex(s string, sep byte) int { + for i := len(s) - 1; i >= 0; i-- { + if s[i] == sep { + return i + } + } + return -1 +} + +func getRandomFiles(path string, numberOfFiles int) ([]string, error) { // TODO: оптимизировать алгоритм и предусмотреть папки с менее 4 шт. файлов + fileNames, err := filepath.Glob(path + "/*") + if err != nil { + return nil, err + } + + rand.Seed(time.Now().UnixNano()) + for i := len(fileNames) - 1; i > 0; i-- { + j := rand.Intn(i + 1) + fileNames[i], fileNames[j] = fileNames[j], fileNames[i] + } + + for len(fileNames) < 4 { + fileNames = append(fileNames, "black.png") + } + + return fileNames[:4], nil +}