From c92fab5f04203bff614887d7131ec12e7b99fe4f Mon Sep 17 00:00:00 2001 From: exTerEX <30706568+exTerEX@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:01:16 +0200 Subject: [PATCH 01/16] feat: Phase 0-3 Go rewrite - shared, web server, organizer - Create Go project structure under go/ - Port shared constants, utils, thumbnails, file browser, job store - Set up gin web server with embedded static files - Port organizer module (date_sorter, renamer, scanner) - Add organizer API routes - Add Go Makefile targets - Single-pass filepath.WalkDir replaces 82+ Python rglob calls - Generic JobStore with TTL-based cleanup replaces leaky Python dicts - True goroutine parallelism throughout --- .gitignore | 1 + Makefile | 19 + go/cmd/morphic/main.go | 48 + go/go.mod | 45 + go/go.sum | 143 +++ go/internal/organizer/date_sorter.go | 156 +++ go/internal/organizer/renamer.go | 126 ++ go/internal/organizer/scanner.go | 166 +++ go/internal/shared/constants.go | 107 ++ go/internal/shared/file_browser.go | 69 ++ go/internal/shared/jobs.go | 94 ++ go/internal/shared/thumbnails.go | 135 +++ go/internal/shared/utils.go | 141 +++ go/web/routes_organizer.go | 77 ++ go/web/routes_shared.go | 62 + go/web/server.go | 45 + go/web/static/app.js | 1633 ++++++++++++++++++++++++++ go/web/static/style.css | 824 +++++++++++++ go/web/templates/index.html | 531 +++++++++ 19 files changed, 4422 insertions(+) create mode 100644 go/cmd/morphic/main.go create mode 100644 go/go.mod create mode 100644 go/go.sum create mode 100644 go/internal/organizer/date_sorter.go create mode 100644 go/internal/organizer/renamer.go create mode 100644 go/internal/organizer/scanner.go create mode 100644 go/internal/shared/constants.go create mode 100644 go/internal/shared/file_browser.go create mode 100644 go/internal/shared/jobs.go create mode 100644 go/internal/shared/thumbnails.go create mode 100644 go/internal/shared/utils.go create mode 100644 go/web/routes_organizer.go create mode 100644 go/web/routes_shared.go create mode 100644 go/web/server.go create mode 100644 go/web/static/app.js create mode 100644 go/web/static/style.css create mode 100644 go/web/templates/index.html diff --git a/.gitignore b/.gitignore index 633961c..79f72e9 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,4 @@ temp/ # PyPI .pypirc +bin/ diff --git a/Makefile b/Makefile index b982023..db0e089 100644 --- a/Makefile +++ b/Makefile @@ -43,4 +43,23 @@ clean: rm -rf .coverage rm -rf .ruff_cache/ rm -rf docs/_build/ + rm -rf bin/ find . -type d -name __pycache__ -exec rm -rf {} + + +# Go targets +.PHONY: go-build go-test go-run go-vet go-tidy + +go-tidy: + cd go && go mod tidy + +go-build: go-tidy + cd go && go build -o ../bin/morphic ./cmd/morphic + +go-test: + cd go && go test ./... + +go-vet: + cd go && go vet ./... + +go-run: go-build + ./bin/morphic diff --git a/go/cmd/morphic/main.go b/go/cmd/morphic/main.go new file mode 100644 index 0000000..58b3294 --- /dev/null +++ b/go/cmd/morphic/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os/exec" + "runtime" + + "github.com/exterex/morphic/internal/shared" + "github.com/exterex/morphic/web" +) + +func main() { + host := flag.String("host", "127.0.0.1", "Host to bind to") + port := flag.Int("port", 8000, "Port to listen on") + noBrowser := flag.Bool("no-browser", false, "Don't open browser automatically") + flag.Parse() + + addr := fmt.Sprintf("%s:%d", *host, *port) + url := fmt.Sprintf("http://%s", addr) + + if !*noBrowser { + go openBrowser(url) + } + + log.Printf("Morphic %s starting on %s", shared.Version, url) + + router := web.SetupRouter() + if err := router.Run(addr); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} + +func openBrowser(url string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "linux": + cmd = exec.Command("xdg-open", url) + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + default: + return + } + _ = cmd.Start() +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..d83f0ab --- /dev/null +++ b/go/go.mod @@ -0,0 +1,45 @@ +module github.com/exterex/morphic + +go 1.22 + +require ( + github.com/disintegration/imaging v1.6.2 + github.com/dsoprea/go-exif/v3 v3.0.1 + github.com/gin-gonic/gin v1.10.0 + github.com/google/uuid v1.6.0 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect + github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-errors/errors v1.4.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..c2c7b19 --- /dev/null +++ b/go/go.sum @@ -0,0 +1,143 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E= +github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8= +github.com/dsoprea/go-exif/v3 v3.0.0-20210625224831-a6301f85c82b/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk= +github.com/dsoprea/go-exif/v3 v3.0.0-20221003160559-cf5cd88aa559/go.mod h1:rW6DMEv25U9zCtE5ukC7ttBRllXj7g7TAHl7tQrT5No= +github.com/dsoprea/go-exif/v3 v3.0.0-20221003171958-de6cb6e380a8/go.mod h1:akyZEJZ/k5bmbC9gA612ZLQkcED8enS9vuTiuAkENr0= +github.com/dsoprea/go-exif/v3 v3.0.1 h1:/IE4iW7gvY7BablV1XY0unqhMv26EYpOquVMwoBo/wc= +github.com/dsoprea/go-exif/v3 v3.0.1/go.mod h1:10HkA1Wz3h398cDP66L+Is9kKDmlqlIJGPv8pk4EWvc= +github.com/dsoprea/go-logging v0.0.0-20190624164917-c4f10aab7696/go.mod h1:Nm/x2ZUNRW6Fe5C3LxdY1PyZY5wmDv/s5dkPJ/VB3iA= +github.com/dsoprea/go-logging v0.0.0-20200517223158-a10564966e9d/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8= +github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd h1:l+vLbuxptsC6VQyQsfD7NnEC8BZuFpz45PgY+pH8YTg= +github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8= +github.com/dsoprea/go-utility v0.0.0-20200711062821-fab8125e9bdf/go.mod h1:95+K3z2L0mqsVYd6yveIv1lmtT3tcQQ3dVakPySffW8= +github.com/dsoprea/go-utility/v2 v2.0.0-20200717064901-2fccff4aa15e/go.mod h1:uAzdkPTub5Y9yQwXe8W4m2XuP0tK4a9Q/dantD0+uaU= +github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LVjRU0RNUuMDqkPTxcALio0LWPFPXxxFCvVGVAwEpFc= +github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU= +github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw= +github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= +github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= +github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= +github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 h1:gtexQ/VGyN+VVFRXSFiguSNcXmS6rkKT+X7FdIrTtfo= +github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +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.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/go/internal/organizer/date_sorter.go b/go/internal/organizer/date_sorter.go new file mode 100644 index 0000000..bbd4e5a --- /dev/null +++ b/go/internal/organizer/date_sorter.go @@ -0,0 +1,156 @@ +package organizer + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + exif "github.com/dsoprea/go-exif/v3" +) + +// SortPlanEntry is a single planned file move/copy. +type SortPlanEntry struct { + Source string `json:"source"` + Destination string `json:"destination"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +// GetFileDate extracts the date from EXIF data or falls back to modification time. +func GetFileDate(path string) time.Time { + if t, err := getExifDate(path); err == nil { + return t + } + + info, err := os.Stat(path) + if err != nil { + return time.Now() + } + return info.ModTime() +} + +func getExifDate(path string) (time.Time, error) { + rawExif, err := exif.SearchFileAndExtractExif(path) + if err != nil { + return time.Time{}, err + } + + entries, _, err := exif.GetFlatExifData(rawExif, nil) + if err != nil { + return time.Time{}, err + } + + // Look for DateTimeOriginal (0x9003) or DateTime (0x0132) + for _, entry := range entries { + if entry.TagId == 0x9003 || entry.TagId == 0x0132 { + if s, ok := entry.Value.(string); ok { + s = strings.TrimRight(strings.TrimSpace(s), "\x00") + if s != "" && s != "0000:00:00 00:00:00" { + t, err := time.Parse("2006:01:02 15:04:05", s) + if err == nil { + return t, nil + } + } + } + } + } + + return time.Time{}, fmt.Errorf("no date tag found") +} + +// RenderTemplate replaces date tokens in a template string. +func RenderTemplate(tmpl string, date time.Time) string { + r := strings.NewReplacer( + "{year}", fmt.Sprintf("%04d", date.Year()), + "{month}", fmt.Sprintf("%02d", date.Month()), + "{day}", fmt.Sprintf("%02d", date.Day()), + "{hour}", fmt.Sprintf("%02d", date.Hour()), + "{minute}", fmt.Sprintf("%02d", date.Minute()), + ) + return r.Replace(tmpl) +} + +// PlanSort creates a plan for sorting files into date-based folders. +func PlanSort(files []string, template string, destination string) []SortPlanEntry { + var plan []SortPlanEntry + + for _, path := range files { + date := GetFileDate(path) + subDir := RenderTemplate(template, date) + destDir := filepath.Join(destination, subDir) + destPath := filepath.Join(destDir, filepath.Base(path)) + + plan = append(plan, SortPlanEntry{ + Source: path, + Destination: destPath, + Status: "pending", + }) + } + + return plan +} + +// ExecuteSort executes the sort plan using the given operation (move or copy). +func ExecuteSort(plan []SortPlanEntry, operation string) { + for i := range plan { + destDir := filepath.Dir(plan[i].Destination) + if err := os.MkdirAll(destDir, 0o755); err != nil { + plan[i].Status = "error" + plan[i].Error = err.Error() + continue + } + + var err error + switch operation { + case "move": + err = os.Rename(plan[i].Source, plan[i].Destination) + if err != nil { + // Cross-device move: copy + remove + err = copyFile(plan[i].Source, plan[i].Destination) + if err == nil { + os.Remove(plan[i].Source) + } + } + case "copy": + err = copyFile(plan[i].Source, plan[i].Destination) + default: + err = fmt.Errorf("unknown operation: %s", operation) + } + + if err != nil { + plan[i].Status = "error" + plan[i].Error = err.Error() + } else { + plan[i].Status = "done" + } + } +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + + // Preserve modification time + info, err := os.Stat(src) + if err == nil { + os.Chtimes(dst, info.ModTime(), info.ModTime()) + } + + return out.Close() +} diff --git a/go/internal/organizer/renamer.go b/go/internal/organizer/renamer.go new file mode 100644 index 0000000..93e54ad --- /dev/null +++ b/go/internal/organizer/renamer.go @@ -0,0 +1,126 @@ +package organizer + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// RenamePlanEntry is a single planned rename. +type RenamePlanEntry struct { + Source string `json:"source"` + Destination string `json:"destination"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +var seqPaddedRegex = regexp.MustCompile(`\{seq:(\d+)\}`) + +// RenderName applies the rename template to a file. +func RenderName(tmpl string, path string, seq int) string { + date := GetFileDate(path) + ext := filepath.Ext(path) + original := strings.TrimSuffix(filepath.Base(path), ext) + + result := tmpl + result = strings.ReplaceAll(result, "{date}", date.Format("20060102")) + result = strings.ReplaceAll(result, "{datetime}", date.Format("20060102_150405")) + result = strings.ReplaceAll(result, "{original}", original) + result = strings.ReplaceAll(result, "{ext}", strings.TrimPrefix(ext, ".")) + + // Handle {seq:N} with zero-padded sequence + result = seqPaddedRegex.ReplaceAllStringFunc(result, func(match string) string { + sub := seqPaddedRegex.FindStringSubmatch(match) + if len(sub) < 2 { + return match + } + width := 0 + fmt.Sscanf(sub[1], "%d", &width) + return fmt.Sprintf("%0*d", width, seq) + }) + + // Handle plain {seq} + result = strings.ReplaceAll(result, "{seq}", fmt.Sprintf("%d", seq)) + + return result + ext +} + +type fileWithDate struct { + Path string + Date time.Time +} + +// PlanRename creates a rename plan for the given files. +func PlanRename(files []string, tmpl string, operation string) []RenamePlanEntry { + // Sort by (date, path) for consistent sequencing + fwd := make([]fileWithDate, len(files)) + for i, f := range files { + fwd[i] = fileWithDate{Path: f, Date: GetFileDate(f)} + } + sort.Slice(fwd, func(i, j int) bool { + if fwd[i].Date.Equal(fwd[j].Date) { + return fwd[i].Path < fwd[j].Path + } + return fwd[i].Date.Before(fwd[j].Date) + }) + + plan := make([]RenamePlanEntry, len(fwd)) + destSet := make(map[string]int) // destination -> first index + + for i, f := range fwd { + newName := RenderName(tmpl, f.Path, i+1) + destPath := filepath.Join(filepath.Dir(f.Path), newName) + + entry := RenamePlanEntry{ + Source: f.Path, + Destination: destPath, + Status: "pending", + } + + // Check for conflicts + if prevIdx, exists := destSet[destPath]; exists { + entry.Status = "conflict" + entry.Error = fmt.Sprintf("conflicts with file #%d", prevIdx+1) + plan[prevIdx].Status = "conflict" + if plan[prevIdx].Error == "" { + plan[prevIdx].Error = fmt.Sprintf("conflicts with file #%d", i+1) + } + } else { + destSet[destPath] = i + } + + plan[i] = entry + } + + return plan +} + +// ExecuteRename executes the rename plan. +func ExecuteRename(plan []RenamePlanEntry, operation string) { + for i := range plan { + if plan[i].Status == "conflict" { + continue + } + + var err error + switch operation { + case "move": + err = os.Rename(plan[i].Source, plan[i].Destination) + case "copy": + err = copyFile(plan[i].Source, plan[i].Destination) + default: + err = fmt.Errorf("unknown operation: %s", operation) + } + + if err != nil { + plan[i].Status = "error" + plan[i].Error = err.Error() + } else { + plan[i].Status = "done" + } + } +} diff --git a/go/internal/organizer/scanner.go b/go/internal/organizer/scanner.go new file mode 100644 index 0000000..ffea6d1 --- /dev/null +++ b/go/internal/organizer/scanner.go @@ -0,0 +1,166 @@ +package organizer + +import ( + "sync" + "time" + + "github.com/exterex/morphic/internal/shared" +) + +// ScanJob represents an organizer scan/plan/execute job. +type ScanJob struct { + shared.Job + mu sync.Mutex + + Folder string `json:"folder"` + Phase string `json:"phase"` + ScanType string `json:"scan_type"` + Template string `json:"template"` + Destination string `json:"destination"` + Operation string `json:"operation"` + Mode string `json:"mode"` + Files []string `json:"files,omitempty"` + SortPlan []SortPlanEntry `json:"sort_plan,omitempty"` + RenamePlan []RenamePlanEntry `json:"rename_plan,omitempty"` + Total int `json:"total"` + Processed int `json:"processed"` +} + +var store = shared.NewJobStore[ScanJob]() + +func init() { + store.StartCleanup(30*time.Minute, func(j *ScanJob) time.Time { + return j.DoneAt + }) +} + +// StartPlanJob starts a new planning job. +func StartPlanJob(folder, mode, template, destination, operation, scanType string) string { + job := &ScanJob{ + Job: shared.NewJob(), + Folder: folder, + Phase: "scanning", + Mode: mode, + Template: template, + Destination: destination, + Operation: operation, + ScanType: scanType, + } + job.Status = shared.JobStatusRunning + + store.Set(job.ID, job) + + go runPlan(job) + + return job.ID +} + +// GetJob retrieves a job by ID. +func GetJob(id string) (*ScanJob, bool) { + return store.Get(id) +} + +// ExecuteJob starts the execution phase of a planned job. +func ExecuteJob(id string) bool { + job, ok := store.Get(id) + if !ok || job.Phase != "planned" { + return false + } + + job.mu.Lock() + job.Phase = "executing" + job.Progress = 0 + job.Processed = 0 + job.mu.Unlock() + + go runExecute(job) + return true +} + +func runPlan(job *ScanJob) { + var files []shared.FileInfo + var err error + + switch job.ScanType { + case "images": + files, err = shared.FindImageFiles(job.Folder) + case "videos": + files, err = shared.FindVideoFiles(job.Folder) + default: + files, err = shared.FindAllMediaFiles(job.Folder) + } + + if err != nil { + job.mu.Lock() + job.Status = shared.JobStatusFailed + job.Error = err.Error() + job.DoneAt = time.Now() + job.mu.Unlock() + return + } + + paths := make([]string, len(files)) + for i, f := range files { + paths[i] = f.Path + } + + job.mu.Lock() + job.Files = paths + job.Total = len(paths) + job.Phase = "planning" + job.Progress = 0.3 + job.mu.Unlock() + + switch job.Mode { + case "sort": + dest := job.Destination + if dest == "" { + dest = job.Folder + } + plan := PlanSort(paths, job.Template, dest) + job.mu.Lock() + job.SortPlan = plan + job.mu.Unlock() + case "rename": + plan := PlanRename(paths, job.Template, job.Operation) + job.mu.Lock() + job.RenamePlan = plan + job.mu.Unlock() + } + + job.mu.Lock() + job.Phase = "planned" + job.Progress = 1.0 + job.Message = "Plan ready for review" + job.mu.Unlock() +} + +func runExecute(job *ScanJob) { + switch job.Mode { + case "sort": + ExecuteSort(job.SortPlan, job.Operation) + job.mu.Lock() + for _, e := range job.SortPlan { + if e.Status == "done" { + job.Processed++ + } + } + job.mu.Unlock() + case "rename": + ExecuteRename(job.RenamePlan, job.Operation) + job.mu.Lock() + for _, e := range job.RenamePlan { + if e.Status == "done" { + job.Processed++ + } + } + job.mu.Unlock() + } + + job.mu.Lock() + job.Phase = "done" + job.Status = shared.JobStatusDone + job.Progress = 1.0 + job.DoneAt = time.Now() + job.mu.Unlock() +} diff --git a/go/internal/shared/constants.go b/go/internal/shared/constants.go new file mode 100644 index 0000000..2f9f9ca --- /dev/null +++ b/go/internal/shared/constants.go @@ -0,0 +1,107 @@ +package shared + +const Version = "0.1.0" + +const ( + DefaultImageThreshold = 0.90 + DefaultVideoThreshold = 0.85 + DefaultHashSize = 16 + DefaultNumFrames = 10 + DefaultNumWorkers = 4 + DefaultBatchSize = 1000 +) + +// ImageExtensions contains all supported image file extensions (lowercase, with dot prefix). +var ImageExtensions = map[string]struct{}{ + ".jpg": {}, + ".jpeg": {}, + ".png": {}, + ".tif": {}, + ".tiff": {}, + ".bmp": {}, + ".webp": {}, + ".gif": {}, + ".ico": {}, + ".heic": {}, + ".heif": {}, + ".avif": {}, + ".svg": {}, + ".raw": {}, + ".cr2": {}, + ".nef": {}, + ".arw": {}, + ".dng": {}, + ".orf": {}, + ".rw2": {}, + ".pef": {}, + ".srw": {}, +} + +// VideoExtensions contains all supported video file extensions. +var VideoExtensions = map[string]struct{}{ + ".mp4": {}, + ".mov": {}, + ".avi": {}, + ".mkv": {}, + ".webm": {}, + ".flv": {}, + ".wmv": {}, + ".m4v": {}, + ".mpeg": {}, + ".mpg": {}, + ".3gp": {}, + ".ts": {}, + ".ogv": {}, + ".mts": {}, + ".m2ts": {}, + ".vob": {}, + ".divx": {}, + ".xvid": {}, + ".asf": {}, + ".rm": {}, + ".rmvb": {}, +} + +// ExcludedFolders contains folder names to skip during scanning. +var ExcludedFolders = map[string]struct{}{ + // Windows + "$recycle.bin": {}, + "$recycle": {}, + "recycler": {}, + "recycled": {}, + "system volume information": {}, + "windows": {}, + "appdata": {}, + // macOS + ".trash": {}, + ".trashes": {}, + ".spotlight-v100": {}, + ".fseventsd": {}, + ".ds_store": {}, + // Linux + "lost+found": {}, + "trash": {}, + // Thumbnails + ".thumbnails": {}, + ".thumb": {}, + "thumbs": {}, + // NAS + "@eadir": {}, + // Version control + ".git": {}, + ".svn": {}, + ".hg": {}, + // Development + "__pycache__": {}, + ".cache": {}, + "node_modules": {}, + ".venv": {}, + "venv": {}, +} + +// Aliases maps alternative extensions to their canonical form. +var Aliases = map[string]string{ + ".jpeg": ".jpg", + ".tiff": ".tif", + ".mpg": ".mpeg", +} diff --git a/go/internal/shared/file_browser.go b/go/internal/shared/file_browser.go new file mode 100644 index 0000000..52951e2 --- /dev/null +++ b/go/internal/shared/file_browser.go @@ -0,0 +1,69 @@ +package shared + +import ( + "os" + "os/exec" + "runtime" + "strings" +) + +// OpenNativeFolderDialog opens a native folder selection dialog. +// Returns the selected folder path or empty string if cancelled. +func OpenNativeFolderDialog() (string, error) { + // Test mode: allow test to set folder via environment + if testFolder := os.Getenv("MORPHIC_TEST_FOLDER"); testFolder != "" { + return testFolder, nil + } + + switch runtime.GOOS { + case "linux": + return linuxFolderDialog() + case "darwin": + return macFolderDialog() + case "windows": + return windowsFolderDialog() + default: + return "", nil + } +} + +func linuxFolderDialog() (string, error) { + // Try zenity first + if path, err := exec.LookPath("zenity"); err == nil && path != "" { + cmd := exec.Command("zenity", "--file-selection", "--directory", "--title=Select Folder") + out, err := cmd.Output() + if err == nil { + return strings.TrimSpace(string(out)), nil + } + } + + // Try kdialog + if path, err := exec.LookPath("kdialog"); err == nil && path != "" { + cmd := exec.Command("kdialog", "--getexistingdirectory", ".") + out, err := cmd.Output() + if err == nil { + return strings.TrimSpace(string(out)), nil + } + } + + return "", nil +} + +func macFolderDialog() (string, error) { + cmd := exec.Command("osascript", "-e", `POSIX path of (choose folder with prompt "Select Folder")`) + out, err := cmd.Output() + if err != nil { + return "", nil + } + return strings.TrimSpace(string(out)), nil +} + +func windowsFolderDialog() (string, error) { + script := `Add-Type -AssemblyName System.Windows.Forms; $f = New-Object System.Windows.Forms.FolderBrowserDialog; if ($f.ShowDialog() -eq 'OK') { $f.SelectedPath }` + cmd := exec.Command("powershell", "-NoProfile", "-Command", script) + out, err := cmd.Output() + if err != nil { + return "", nil + } + return strings.TrimSpace(string(out)), nil +} diff --git a/go/internal/shared/jobs.go b/go/internal/shared/jobs.go new file mode 100644 index 0000000..2871510 --- /dev/null +++ b/go/internal/shared/jobs.go @@ -0,0 +1,94 @@ +package shared + +import ( + "sync" + "time" + + "github.com/google/uuid" +) + +// JobStatus represents the state of a background job. +type JobStatus string + +const ( + JobStatusPending JobStatus = "pending" + JobStatusRunning JobStatus = "running" + JobStatusDone JobStatus = "done" + JobStatusFailed JobStatus = "failed" + JobStatusPlanned JobStatus = "planned" +) + +// Job is a base type embedded in all module-specific jobs. +type Job struct { + ID string `json:"id"` + Status JobStatus `json:"status"` + Progress float64 `json:"progress"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + StartedAt time.Time `json:"started_at"` + DoneAt time.Time `json:"done_at,omitempty"` +} + +// NewJob creates a new job with a unique ID. +func NewJob() Job { + return Job{ + ID: uuid.New().String(), + Status: JobStatusPending, + Progress: 0, + StartedAt: time.Now(), + } +} + +// JobStore is a thread-safe generic store for background jobs. +type JobStore[T any] struct { + mu sync.RWMutex + jobs map[string]*T +} + +// NewJobStore creates a new JobStore. +func NewJobStore[T any]() *JobStore[T] { + return &JobStore[T]{ + jobs: make(map[string]*T), + } +} + +// Set stores a job. +func (s *JobStore[T]) Set(id string, job *T) { + s.mu.Lock() + defer s.mu.Unlock() + s.jobs[id] = job +} + +// Get retrieves a job by ID. +func (s *JobStore[T]) Get(id string) (*T, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + job, ok := s.jobs[id] + return job, ok +} + +// Delete removes a job by ID. +func (s *JobStore[T]) Delete(id string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.jobs, id) +} + +// StartCleanup runs a background goroutine that removes jobs older than ttl. +func (s *JobStore[T]) StartCleanup(ttl time.Duration, getDoneAt func(*T) time.Time) { + go func() { + ticker := time.NewTicker(ttl / 2) + defer ticker.Stop() + for range ticker.C { + now := time.Now() + s.mu.Lock() + for id, job := range s.jobs { + doneAt := getDoneAt(job) + if !doneAt.IsZero() && now.Sub(doneAt) > ttl { + delete(s.jobs, id) + } + } + s.mu.Unlock() + } + }() +} diff --git a/go/internal/shared/thumbnails.go b/go/internal/shared/thumbnails.go new file mode 100644 index 0000000..c8a1220 --- /dev/null +++ b/go/internal/shared/thumbnails.go @@ -0,0 +1,135 @@ +package shared + +import ( + "bytes" + "fmt" + "image/jpeg" + "os/exec" + "path/filepath" + "strings" + "sync" + + "github.com/disintegration/imaging" +) + +const ( + DefaultThumbnailSize = 200 + DefaultThumbnailQuality = 80 +) + +// ThumbnailCache provides a thread-safe cache for generated thumbnails. +type ThumbnailCache struct { + mu sync.RWMutex + store map[string][]byte +} + +var thumbnailCache = &ThumbnailCache{ + store: make(map[string][]byte), +} + +// GenerateImageThumbnail creates a JPEG thumbnail for an image file. +func GenerateImageThumbnail(path string, size int) ([]byte, error) { + if size <= 0 { + size = DefaultThumbnailSize + } + + cacheKey := fmt.Sprintf("%s:%d", path, size) + thumbnailCache.mu.RLock() + if data, ok := thumbnailCache.store[cacheKey]; ok { + thumbnailCache.mu.RUnlock() + return data, nil + } + thumbnailCache.mu.RUnlock() + + img, err := imaging.Open(path, imaging.AutoOrientation(true)) + if err != nil { + return nil, fmt.Errorf("failed to open image %s: %w", path, err) + } + + thumb := imaging.Fit(img, size, size, imaging.Lanczos) + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: DefaultThumbnailQuality}); err != nil { + return nil, fmt.Errorf("failed to encode thumbnail: %w", err) + } + + data := buf.Bytes() + thumbnailCache.mu.Lock() + thumbnailCache.store[cacheKey] = data + thumbnailCache.mu.Unlock() + + return data, nil +} + +// GenerateVideoThumbnail creates a JPEG thumbnail for a video file using ffmpeg. +func GenerateVideoThumbnail(path string, size int) ([]byte, error) { + if size <= 0 { + size = DefaultThumbnailSize + } + + cacheKey := fmt.Sprintf("video:%s:%d", path, size) + thumbnailCache.mu.RLock() + if data, ok := thumbnailCache.store[cacheKey]; ok { + thumbnailCache.mu.RUnlock() + return data, nil + } + thumbnailCache.mu.RUnlock() + + // Try extracting frame at 1 second, fallback to 0 seconds + data, err := extractVideoFrame(path, "00:00:01", size) + if err != nil { + data, err = extractVideoFrame(path, "00:00:00", size) + if err != nil { + return nil, fmt.Errorf("failed to extract video frame from %s: %w", path, err) + } + } + + thumbnailCache.mu.Lock() + thumbnailCache.store[cacheKey] = data + thumbnailCache.mu.Unlock() + + return data, nil +} + +func extractVideoFrame(videoPath, seekTime string, size int) ([]byte, error) { + cmd := exec.Command("ffmpeg", + "-ss", seekTime, + "-i", videoPath, + "-vframes", "1", + "-vf", fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", size, size), + "-f", "image2pipe", + "-vcodec", "mjpeg", + "-q:v", "5", + "pipe:1", + ) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("ffmpeg failed: %w, stderr: %s", err, stderr.String()) + } + + return stdout.Bytes(), nil +} + +// IsImageFile checks if a path has an image extension. +func IsImageFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + if alias, ok := Aliases[ext]; ok { + ext = alias + } + _, ok := ImageExtensions[ext] + return ok +} + +// IsVideoFile checks if a path has a video extension. +func IsVideoFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + if alias, ok := Aliases[ext]; ok { + ext = alias + } + _, ok := VideoExtensions[ext] + return ok +} diff --git a/go/internal/shared/utils.go b/go/internal/shared/utils.go new file mode 100644 index 0000000..15662fd --- /dev/null +++ b/go/internal/shared/utils.go @@ -0,0 +1,141 @@ +package shared + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// FileInfo holds metadata about a discovered file. +type FileInfo struct { + Path string + Name string + Size int64 + Ext string +} + +// FindFilesByExtension walks the folder tree once and returns all files +// matching the given extensions. This replaces the Python version which +// called rglob() twice per extension (82+ traversals). +func FindFilesByExtension(folder string, extensions map[string]struct{}, excludedFolders map[string]struct{}) ([]FileInfo, error) { + var files []FileInfo + seen := make(map[string]struct{}) + + err := filepath.WalkDir(folder, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil // skip inaccessible entries + } + + if d.IsDir() { + name := strings.ToLower(d.Name()) + if _, excluded := excludedFolders[name]; excluded { + return filepath.SkipDir + } + return nil + } + + ext := strings.ToLower(filepath.Ext(path)) + if alias, ok := Aliases[ext]; ok { + ext = alias + } + + if _, ok := extensions[ext]; !ok { + return nil + } + + // Deduplicate by resolved path + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + if _, dup := seen[abs]; dup { + return nil + } + seen[abs] = struct{}{} + + info, err := d.Info() + if err != nil { + return nil + } + + files = append(files, FileInfo{ + Path: abs, + Name: d.Name(), + Size: info.Size(), + Ext: ext, + }) + + return nil + }) + + if err != nil { + return nil, err + } + + sort.Slice(files, func(i, j int) bool { + return files[i].Path < files[j].Path + }) + + return files, nil +} + +// IsExcludedPath checks if any component of the path is in the exclusion set. +func IsExcludedPath(path string, excludedFolders map[string]struct{}) bool { + parts := strings.Split(filepath.ToSlash(path), "/") + for _, part := range parts { + if _, excluded := excludedFolders[strings.ToLower(part)]; excluded { + return true + } + } + return false +} + +// FindImageFiles returns all image files in the given folder. +func FindImageFiles(folder string) ([]FileInfo, error) { + return FindFilesByExtension(folder, ImageExtensions, ExcludedFolders) +} + +// FindVideoFiles returns all video files in the given folder. +func FindVideoFiles(folder string) ([]FileInfo, error) { + return FindFilesByExtension(folder, VideoExtensions, ExcludedFolders) +} + +// FindAllMediaFiles returns all image and video files. +func FindAllMediaFiles(folder string) ([]FileInfo, error) { + allExts := make(map[string]struct{}) + for k, v := range ImageExtensions { + allExts[k] = v + } + for k, v := range VideoExtensions { + allExts[k] = v + } + return FindFilesByExtension(folder, allExts, ExcludedFolders) +} + +// FormatFileSize formats file size in human-readable format. +func FormatFileSize(sizeBytes int64) string { + size := float64(sizeBytes) + for _, unit := range []string{"B", "KB", "MB", "GB"} { + if size < 1024 { + return fmt.Sprintf("%.2f %s", size, unit) + } + size /= 1024 + } + return fmt.Sprintf("%.2f TB", size) +} + +// FormatDuration formats duration in human-readable format. +func FormatDuration(seconds float64) string { + hours := int(seconds) / 3600 + minutes := (int(seconds) % 3600) / 60 + secs := int(seconds) % 60 + if hours > 0 { + return fmt.Sprintf("%dh %dm %ds", hours, minutes, secs) + } + if minutes > 0 { + return fmt.Sprintf("%dm %ds", minutes, secs) + } + return fmt.Sprintf("%ds", secs) +} diff --git a/go/web/routes_organizer.go b/go/web/routes_organizer.go new file mode 100644 index 0000000..988f9b8 --- /dev/null +++ b/go/web/routes_organizer.go @@ -0,0 +1,77 @@ +package web + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/exterex/morphic/internal/organizer" +) + +func registerOrganizerRoutes(r *gin.Engine) { + g := r.Group("/api/organizer") + { + g.POST("/scan", handleOrganizerScan) + g.POST("/execute/:id", handleOrganizerExecute) + g.GET("/status/:id", handleOrganizerStatus) + } +} + +func handleOrganizerScan(c *gin.Context) { + var req struct { + Folder string `json:"folder"` + Mode string `json:"mode"` + Template string `json:"template"` + Destination string `json:"destination"` + Operation string `json:"operation"` + ScanType string `json:"scan_type"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Folder == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "folder is required"}) + return + } + + jobID := organizer.StartPlanJob( + req.Folder, req.Mode, req.Template, + req.Destination, req.Operation, req.ScanType, + ) + + c.JSON(http.StatusAccepted, gin.H{"job_id": jobID}) +} + +func handleOrganizerExecute(c *gin.Context) { + id := c.Param("id") + if !organizer.ExecuteJob(id) { + c.JSON(http.StatusNotFound, gin.H{"error": "job not found or not in planned state"}) + return + } + + c.JSON(http.StatusAccepted, gin.H{"status": "executing"}) +} + +func handleOrganizerStatus(c *gin.Context) { + id := c.Param("id") + job, ok := organizer.GetJob(id) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "job not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "id": job.ID, + "status": job.Status, + "phase": job.Phase, + "progress": job.Progress, + "message": job.Message, + "error": job.Error, + "total": job.Total, + "processed": job.Processed, + "sort_plan": job.SortPlan, + "rename_plan": job.RenamePlan, + }) +} diff --git a/go/web/routes_shared.go b/go/web/routes_shared.go new file mode 100644 index 0000000..e417557 --- /dev/null +++ b/go/web/routes_shared.go @@ -0,0 +1,62 @@ +package web + +import ( + "net/http" + "runtime" + + "github.com/gin-gonic/gin" + "github.com/exterex/morphic/internal/shared" +) + +func registerSharedRoutes(r *gin.Engine) { + r.POST("/api/browse", handleBrowse) + r.GET("/api/thumbnail", handleThumbnail) + r.GET("/api/system-info", handleSystemInfo) +} + +func handleBrowse(c *gin.Context) { + folder, err := shared.OpenNativeFolderDialog() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if folder == "" { + c.JSON(http.StatusOK, gin.H{"folder": nil}) + return + } + c.JSON(http.StatusOK, gin.H{"folder": folder}) +} + +func handleThumbnail(c *gin.Context) { + path := c.Query("path") + if path == "" { + c.Status(http.StatusBadRequest) + return + } + + var data []byte + var err error + + if shared.IsVideoFile(path) { + data, err = shared.GenerateVideoThumbnail(path, shared.DefaultThumbnailSize) + } else { + data, err = shared.GenerateImageThumbnail(path, shared.DefaultThumbnailSize) + } + + if err != nil { + c.Status(http.StatusInternalServerError) + return + } + + c.Data(http.StatusOK, "image/jpeg", data) +} + +func handleSystemInfo(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "version": shared.Version, + "platform": runtime.GOOS, + "arch": runtime.GOARCH, + "go": runtime.Version(), + "cpus": runtime.NumCPU(), + }) +} diff --git a/go/web/server.go b/go/web/server.go new file mode 100644 index 0000000..5f34087 --- /dev/null +++ b/go/web/server.go @@ -0,0 +1,45 @@ +package web + +import ( + "embed" + "html/template" + "io/fs" + "net/http" + + "github.com/gin-gonic/gin" +) + +//go:embed templates static +var webFS embed.FS + +// SetupRouter creates and configures the gin router with all routes. +func SetupRouter() *gin.Engine { + r := gin.Default() + + // Parse templates from embedded FS + tmpl := template.Must(template.ParseFS(webFS, "templates/*.html")) + r.SetHTMLTemplate(tmpl) + + // Serve static files from embedded FS + staticFS, _ := fs.Sub(webFS, "static") + r.StaticFS("/static", http.FS(staticFS)) + + // No-cache middleware (mirrors Python's @app.after_request) + r.Use(func(c *gin.Context) { + c.Header("Cache-Control", "no-cache, no-store, must-revalidate") + c.Header("Pragma", "no-cache") + c.Header("Expires", "0") + c.Next() + }) + + // Index route + r.GET("/", func(c *gin.Context) { + c.HTML(http.StatusOK, "index.html", nil) + }) + + // Register API route groups + registerSharedRoutes(r) + registerOrganizerRoutes(r) + + return r +} diff --git a/go/web/static/app.js b/go/web/static/app.js new file mode 100644 index 0000000..5261a06 --- /dev/null +++ b/go/web/static/app.js @@ -0,0 +1,1633 @@ +/* ── Morphic — Unified JS ─────────────────────────────────────────── */ + +// ===================================================================== +// Global state +// ===================================================================== + +let activeTab = 'converter'; +let lastSelectedFolder = ''; + +// Converter state +let convScanData = null; // last scan result +let convFilterType = 'both'; // images|videos|both +let convFilterExt = null; // filter by specific extension +let convSelectedFiles = []; // files selected for conversion +let convAvailableTargets = []; // all targets currently available for batch conversion +let convAv1Available = false; // AV1 support from ffmpeg +let convLastFailedFiles = new Set(); // set of last conversion failures +let convBatchMode = 'intersection'; // union|intersection +let convAv1Crf = 32; +let convJobId = null; +let convPollTimer = null; +let showFullPaths = false; + +async function convLoadFormats() { + try { + const savedMode = localStorage.getItem('convBatchMode'); + if (savedMode === 'union' || savedMode === 'intersection') { + convBatchMode = savedMode; + } + + const savedAv1Crf = Number(localStorage.getItem('convAv1Crf')); + if (!Number.isNaN(savedAv1Crf) && savedAv1Crf >= 10 && savedAv1Crf <= 63) { + convAv1Crf = savedAv1Crf; + } + + const modeSelect = document.getElementById('convBatchMode'); + if (modeSelect) { + modeSelect.value = convBatchMode; + } + + const av1CrfInput = document.getElementById('convAv1Crf'); + if (av1CrfInput) { + av1CrfInput.value = convAv1Crf; + document.getElementById('convAv1CrfValue').textContent = convAv1Crf; + } + + const resp = await fetch('/api/converter/formats'); + const data = await resp.json(); + + // Augment with system diagnostic info for AV1 support + try { + const sysResp = await fetch('/api/system_info'); + const sysInfo = await sysResp.json(); + const ffenc = sysInfo.ffmpeg && sysInfo.ffmpeg.encoders || []; + convAv1Available = ffenc.some(e => e.includes('av1_nvenc') || e.includes('libsvtav1') || e.includes('libaom-av1')); + } catch (e) { + convAv1Available = false; + } + + const targets = new Set(); + if (data.image) { + Object.values(data.image).flat().forEach(t => targets.add(t)); + } + if (data.video) { + Object.values(data.video).flat().forEach(t => targets.add(t)); + } + + if (convAv1Available) { + targets.add('.mp4-av1'); + targets.add('.mkv-av1'); + targets.add('.webm-av1'); + } + + convAvailableTargets = [...targets].sort(); + convSetBatchTargets(convAvailableTargets); + } catch (e) { + console.error('Failed to load converter formats:', e); + } +} + +// Dupfinder state +let dupJobId = null; +let dupPollTimer = null; +let dupAllGroups = []; +let dupSelectedFiles = new Set(); + +// Inspector state +let inspJobId = null; +let inspPollTimer = null; + +// Resizer state +let resJobId = null; +let resPollTimer = null; + +// Organizer state +let orgJobId = null; +let orgPollTimer = null; + +// ===================================================================== +// Tabs +// ===================================================================== + +function switchTab(tab) { + activeTab = tab; + document.querySelectorAll('.tab-btn').forEach(b => { + b.classList.toggle('active', b.dataset.tab === tab); + }); + document.querySelectorAll('.tab-content').forEach(c => { + c.classList.toggle('active', c.id === 'tab-' + tab); + }); +} + +// ===================================================================== +// Shared: In-page Folder Browser +// ===================================================================== + +const _browserPrefixes = { + converter: 'conv', dupfinder: 'dup', + inspector: 'insp', resizer: 'res', + organizer: 'org', +}; + +function _bp(tab) { return _browserPrefixes[tab] || tab; } + +function _setLastFolder(path) { + lastSelectedFolder = path; + try { + localStorage.setItem('morphic_last_folder', path); + } catch (e) { + // ignore + } +} + +function _storeFolder(tab, path) { + _setLastFolder(path); + try { + localStorage.setItem(`morphic_folder_${tab}`, path); + } catch (e) { + // Ignore storage errors in privacy modes + } +} + +function _loadFolder(tab) { + try { + return localStorage.getItem(`morphic_folder_${tab}`) || ''; + } catch (e) { + return ''; + } +} + +function _loadLastFolder() { + try { + return localStorage.getItem('morphic_last_folder') || ''; + } catch (e) { + return ''; + } +} + + +function loadFolderPreferences() { + lastSelectedFolder = _loadLastFolder(); + const tabs = ['converter', 'dupfinder', 'inspector', 'resizer', 'organizer']; + for (const tab of tabs) { + const input = document.getElementById(_bp(tab) + 'Folder'); + if (!input) continue; + + let saved = _loadFolder(tab); + if (!saved) { + saved = lastSelectedFolder || ''; + } + + if (saved) { + input.value = saved; + _setLastFolder(saved); + } + + let indicator = input.closest('.form-group')?.querySelector('.folder-saved-indicator'); + if (!indicator && input.closest('.form-group')) { + indicator = document.createElement('span'); + indicator.className = 'folder-saved-indicator'; + indicator.style.marginLeft = '10px'; + indicator.style.fontSize = '12px'; + indicator.style.color = 'var(--success, #06c)'; + input.closest('.form-group').appendChild(indicator); + } + + if (indicator) { + indicator.textContent = saved ? '✅ saved' : ''; + } + + input.addEventListener('input', () => { + const value = input.value.trim(); + _storeFolder(tab, value); + if (indicator) { + indicator.textContent = value ? '✅ saved' : ''; + } + }); + } +} + +function toggleBrowser(tab) { + const browser = document.getElementById(_bp(tab) + 'Browser'); + if (browser.classList.contains('open')) { + browser.classList.remove('open'); + } else { + const input = document.getElementById(_bp(tab) + 'Folder'); + browseTo(input.value.trim() || '~', tab); + } +} + +async function openNativeFolderExplorer(tab) { + const input = document.getElementById(_bp(tab) + 'Folder'); + const initialDir = input?.value.trim() || lastSelectedFolder || '~'; + + try { + const resp = await fetch('/api/browse/native', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ initial_dir: initialDir }), + }); + const data = await resp.json(); + + if (data.folder) { + if (input) { + input.value = data.folder; + _storeFolder(tab, data.folder); + } + showToast('Folder selected: ' + data.folder, 'success'); + } else { + showToast(data.message || 'Native folder dialog was canceled', 'info'); + } + } catch (e) { + showToast('Native folder open failed: ' + e.message, 'error'); + } +} + +function closeBrowser(tab) { + document.getElementById(_bp(tab) + 'Browser').classList.remove('open'); +} + +function selectBrowserFolder(tab) { + closeBrowser(tab); + showToast('Folder selected', 'success'); +} + +async function browseTo(path, tab) { + try { + const resp = await fetch(`/api/browse?path=${encodeURIComponent(path)}`); + const data = await resp.json(); + if (data.error) { showToast(data.error, 'error'); return; } + + const prefix = _bp(tab); + const input = document.getElementById(prefix + 'Folder'); + input.value = data.current; + _storeFolder(tab, data.current); + + const browser = document.getElementById(prefix + 'Browser'); + const fbPath = browser.querySelector('.fb-path'); + fbPath.textContent = data.current; + + const parentEl = browser.querySelector('.fb-parent'); + if (data.parent) { + parentEl.style.display = 'flex'; + parentEl.onclick = () => browseTo(data.parent, tab); + } else { + parentEl.style.display = 'none'; + } + + const entriesEl = browser.querySelector('.fb-entries'); + entriesEl.innerHTML = ''; + for (const entry of data.entries) { + const div = document.createElement('div'); + div.className = 'fb-item'; + div.innerHTML = `📁 ${escapeHtml(entry.name)}`; + div.onclick = () => browseTo(entry.path, tab); + entriesEl.appendChild(div); + } + + browser.classList.add('open'); + } catch (e) { + showToast('Failed to browse: ' + e.message, 'error'); + } +} + +// ===================================================================== +// Converter: Scan +// ===================================================================== + +async function convScan() { + const folder = document.getElementById('convFolder').value.trim(); + if (!folder) { showToast('Enter a folder path', 'error'); return; } + _storeFolder('converter', folder); + convLastFailedFiles = new Set(); + + const includeSubfolders = document.getElementById('convSubfolders').checked; + const filterType = document.getElementById('convFilterType').value; + + document.getElementById('convScanBtn').disabled = true; + document.getElementById('convResults').style.display = 'none'; + + try { + const resp = await fetch('/api/converter/scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + folder, include_subfolders: includeSubfolders, filter_type: filterType, + }), + }); + const data = await resp.json(); + if (data.error) { showToast(data.error, 'error'); return; } + + convScanData = data; + convFilterType = filterType; + convFilterExt = null; + convSelectedFiles = []; + renderConvResults(); + } catch (e) { + showToast('Scan failed: ' + e.message, 'error'); + } finally { + document.getElementById('convScanBtn').disabled = false; + } +} + +function renderConvResults() { + if (!convScanData) return; + const section = document.getElementById('convResults'); + section.style.display = 'block'; + + // Summary + const summary = document.getElementById('convSummary'); + const entries = Object.entries(convScanData.summary); + if (entries.length === 0) { + summary.innerHTML = '
📂

No media files found

'; + document.getElementById('convFileTable').innerHTML = ''; + return; + } + + let html = `${convScanData.total} files found in ${escapeHtml(convScanData.folder)}`; + html += '
'; + html += `All`; + for (const [ext, count] of entries) { + const isActive = convFilterExt === ext; + html += `${ext} (${count})`; + } + html += '
'; + summary.innerHTML = html; + + // File table + let files = convScanData.files; + if (convFilterExt) { + files = files.filter(f => f.ext === convFilterExt); + } + + const table = document.getElementById('convFileTable'); + if (files.length === 0) { + table.innerHTML = '

No files match current filter.

'; + return; + } + + let thtml = ` + + + + + + + + `; + + for (const f of files) { + const thumbUrl = `/api/thumbnail?path=${encodeURIComponent(f.path)}`; + const displayName = showFullPaths ? f.path : f.name; + const failed = convLastFailedFiles.has(f.path); + + thtml += ` + + + + + + + `; + } + + thtml += '
TypeSizeConvert to
+ + ${failed ? 'Failed' : ''} + ${f.ext}${formatBytes(f.size)} + + + + ${failed ? `` : ''} + +
'; + table.innerHTML = thtml; + convUpdateBatchTargets(); +} + +function convSetBatchTargets(targets) { + const select = document.getElementById('convBatchTarget'); + const prevValue = select.value; + + const targetArray = Array.isArray(targets) ? targets.filter(Boolean) : []; + const uniqueTargets = [...new Set(targetArray.map(t => t.toLowerCase()))].sort(); + + select.innerHTML = ''; + if (uniqueTargets.length === 0) { + select.disabled = true; + const opt = document.createElement('option'); + opt.value = ''; + opt.textContent = 'No compatible target formats'; + opt.disabled = true; + select.appendChild(opt); + return; + } + + select.disabled = false; + for (const target of uniqueTargets) { + const option = document.createElement('option'); + option.value = target; + option.textContent = target; + select.appendChild(option); + } + + if (prevValue && uniqueTargets.includes(prevValue.toLowerCase())) { + select.value = prevValue.toLowerCase(); + } else { + select.value = uniqueTargets[0]; + } +} + +function convGetBatchTargets() { + const selectedFilePaths = new Set(convSelectedFiles || []); + const modeSelect = document.getElementById('convBatchMode'); + if (modeSelect) { + convBatchMode = modeSelect.value || 'union'; + localStorage.setItem('convBatchMode', convBatchMode); + } + + const av1CrfInput = document.getElementById('convAv1Crf'); + if (av1CrfInput) { + const val = Number(av1CrfInput.value); + if (!Number.isNaN(val) && val >= 10 && val <= 63) { + convAv1Crf = val; + localStorage.setItem('convAv1Crf', convAv1Crf); + const av1CrfValue = document.getElementById('convAv1CrfValue'); + if (av1CrfValue) { + av1CrfValue.textContent = String(convAv1Crf); + } + } + } + + const modeHint = document.getElementById('convBatchModeHint'); + if (modeHint) { + modeHint.textContent = convBatchMode === 'intersection' + ? 'Intersection = formats supported by each selected file; Union = any selected file' + : 'Union = formats supported by at least one selected file; Intersection = common to all'; + } + + // If no scan data yet, show global format list from converter/formats. + if (!convScanData || !convScanData.files || convScanData.files.length === 0) { + return [...new Set(convAvailableTargets)].sort(); + } + + const files = convScanData.files.filter(f => selectedFilePaths.size === 0 || selectedFilePaths.has(f.path)); + if (files.length === 0) { + return [...new Set(convAvailableTargets)].sort(); + } + + const hasVideo = files.some(f => f.type === 'video'); + const fileTargets = files.map(f => new Set((f.targets || []).map(t => t.toLowerCase()))); + + if (convBatchMode === 'intersection') { + let intersection = new Set(fileTargets[0]); + for (let i = 1; i < fileTargets.length; i++) { + intersection = new Set([...intersection].filter(t => fileTargets[i].has(t))); + } + if (hasVideo) { + ['.mp4-av1', '.mkv-av1', '.webm-av1'].forEach(v => intersection.add(v)); + } + return [...intersection].sort(); + } + + // union + const union = new Set(); + for (const targetSet of fileTargets) { + for (const t of targetSet) union.add(t); + } + if (hasVideo) { + ['.mp4-av1', '.mkv-av1', '.webm-av1'].forEach(v => union.add(v)); + } + return [...union].sort(); +} + +function convUpdateBatchTargets() { + const targets = convGetBatchTargets(); + convSetBatchTargets(targets); + + const batchBtn = document.getElementById('convBatchBtn'); + if (batchBtn) { + batchBtn.disabled = !targets || targets.length === 0; + } +} + +function convSetExtFilter(ext) { + convFilterExt = ext; + renderConvResults(); +} + +function convToggleAll(checked) { + document.querySelectorAll('.conv-check').forEach(cb => cb.checked = checked); + convUpdateSelection(); +} + +function convUpdateSelection() { + convSelectedFiles = []; + document.querySelectorAll('.conv-check:checked').forEach(cb => { + convSelectedFiles.push(cb.value); + }); + + const bar = document.getElementById('convBulkBar'); + if (convSelectedFiles.length > 0) { + bar.style.display = 'flex'; + document.getElementById('convBulkCount').textContent = convSelectedFiles.length; + } else { + bar.style.display = 'none'; + } + + convUpdateBatchTargets(); +} + +function toggleFullPaths() { + showFullPaths = !showFullPaths; + renderConvResults(); +} + +// ===================================================================== +// Converter: Convert +// ===================================================================== + +async function convConvertSingle(filePath, btnEl) { + const row = btnEl.closest('tr'); + const targetSelect = row.querySelector('.conv-target'); + const targetExt = targetSelect.value; + const deleteOrig = document.getElementById('convDeleteOrig').checked; + + btnEl.disabled = true; + btnEl.textContent = '...'; + + try { + const resp = await fetch('/api/converter/convert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + files: [filePath], + target_ext: targetExt, + delete_original: deleteOrig, + av1_crf: convAv1Crf, + }), + }); + const data = await resp.json(); + if (data.job_id) { + await convWaitForJob(data.job_id); + } + } catch (e) { + showToast('Convert failed: ' + e.message, 'error'); + } finally { + btnEl.disabled = false; + btnEl.textContent = 'Convert'; + } +} + +async function convConvertBatch() { + if (convSelectedFiles.length === 0) return; + const targetExt = document.getElementById('convBatchTarget').value; + if (!targetExt) { + showToast('No valid target format available for selected files', 'error'); + return; + } + const deleteOrig = document.getElementById('convDeleteOrig').checked; + + document.getElementById('convBatchBtn').disabled = true; + + try { + const resp = await fetch('/api/converter/convert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + files: convSelectedFiles, + target_ext: targetExt, + delete_original: deleteOrig, + av1_crf: convAv1Crf, + }), + }); + const data = await resp.json(); + if (data.job_id) { + convShowProgress(); + convPollProgress(data.job_id); + } + } catch (e) { + showToast('Batch convert failed: ' + e.message, 'error'); + document.getElementById('convBatchBtn').disabled = false; + } +} + +function convShowProgress() { + document.getElementById('convProgress').classList.add('active'); +} + +function convPollProgress(jobId) { + let lastCompleted = -1; + convPollTimer = setInterval(async () => { + try { + const resp = await fetch(`/api/converter/progress/${jobId}/poll?last=${lastCompleted}`); + const data = await resp.json(); + if (data.error) return; + + lastCompleted = data.completed; + const pct = data.total > 0 ? Math.round((data.completed / data.total) * 100) : 0; + document.getElementById('convProgressBar').style.width = pct + '%'; + document.getElementById('convProgressPct').textContent = pct + '%'; + document.getElementById('convProgressMsg').textContent = + data.current_file ? `Converting: ${data.current_file}` : 'Processing...'; + + if (data.status === 'done') { + clearInterval(convPollTimer); + convShowConversionResults(data); + document.getElementById('convProgress').classList.remove('active'); + document.getElementById('convBatchBtn').disabled = false; + } + } catch (e) { /* retry */ } + }, 500); +} + +async function convWaitForJob(jobId) { + // Simple wait for single-file conversions + while (true) { + const resp = await fetch(`/api/converter/progress/${jobId}`); + const data = await resp.json(); + if (data.status === 'done') { + const r = data.results[0]; + if (r.status === 'ok') { + const sizeInfo = r.original_size_fmt + ' → ' + r.new_size_fmt; + showToast(`Converted! (${sizeInfo})`, 'success'); + convLastFailedFiles.delete(r.source); + } else { + showToast('Error: ' + (r.error || 'unknown'), 'error'); + convLastFailedFiles.add(r.source); + } + renderConvResults(); + return; + } + await sleep(300); + } +} + +async function convRetrySingle(filePath, btnEl) { + const row = btnEl.closest('tr'); + const targetSelect = row.querySelector('.conv-target'); + const targetExt = targetSelect?.value; + const deleteOrig = document.getElementById('convDeleteOrig').checked; + + if (!targetExt) { + showToast('No target selected for retry', 'error'); + return; + } + + btnEl.disabled = true; + btnEl.textContent = 'Retrying...'; + + try { + const resp = await fetch('/api/converter/convert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ files: [filePath], target_ext: targetExt, delete_original: deleteOrig, av1_crf: convAv1Crf }), + }); + const data = await resp.json(); + if (data.job_id) { + await convWaitForJob(data.job_id); + } + } catch (e) { + showToast('Retry failed: ' + e.message, 'error'); + } finally { + btnEl.disabled = false; + btnEl.textContent = 'Retry'; + } +} + +function convShowConversionResults(data) { + let ok = 0, fail = 0; + const failedFiles = []; + convLastFailedFiles = new Set(); + + for (const r of data.results) { + if (r.status === 'ok') { + ok++; + } else { + fail++; + failedFiles.push(`${r.source} (${r.error || 'unknown error'})`); + } + } + + if (fail > 0) { + const feed = failedFiles.slice(0, 5).join(', '); + const rest = failedFiles.length > 5 ? `, +${failedFiles.length - 5} more` : ''; + showToast(`Done! ${ok} converted, ${fail} failed: ${feed}${rest}`, 'error'); + } else { + showToast(`Done! ${ok} converted`, 'success'); + } + + // Refresh list with failure markers + renderConvResults(); +} + +async function convDeleteSingle(filePath) { + if (!confirm('Delete this file permanently?')) return; + try { + const resp = await fetch('/api/converter/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ files: [filePath] }), + }); + const data = await resp.json(); + const r = data.results[0]; + if (r.status === 'deleted') { + showToast('Deleted', 'success'); + // Remove from scan data + if (convScanData) { + convScanData.files = convScanData.files.filter(f => f.path !== filePath); + convScanData.total = convScanData.files.length; + } + renderConvResults(); + } else { + showToast('Delete failed: ' + (r.status), 'error'); + } + } catch (e) { + showToast('Delete failed: ' + e.message, 'error'); + } +} + +async function convDeleteBatch() { + if (convSelectedFiles.length === 0) return; + if (!confirm(`Permanently delete ${convSelectedFiles.length} file(s)?`)) return; + try { + const resp = await fetch('/api/converter/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ files: convSelectedFiles }), + }); + const data = await resp.json(); + const deleted = data.results.filter(r => r.status === 'deleted'); + const failed = data.results.filter(r => r.status !== 'deleted'); + + if (deleted.length > 0) { + showToast(`Deleted ${deleted.length} file(s), freed ${data.total_freed_formatted}`, 'success'); + if (convScanData) { + const deletedPaths = new Set(deleted.map(r => r.path)); + convScanData.files = convScanData.files.filter(f => !deletedPaths.has(f.path)); + convScanData.total = convScanData.files.length; + // Recalculate summary + const newSummary = {}; + for (const f of convScanData.files) { + newSummary[f.ext] = (newSummary[f.ext] || 0) + 1; + } + convScanData.summary = newSummary; + } + } + if (failed.length > 0) { + showToast(`Failed to delete ${failed.length} file(s)`, 'error'); + } + + convSelectedFiles = []; + renderConvResults(); + } catch (e) { + showToast('Delete failed: ' + e.message, 'error'); + } +} + +function copyPath(path) { + navigator.clipboard.writeText(path).then(() => { + showToast('Path copied', 'success'); + }); +} + +// ===================================================================== +// Dupfinder: Scan +// ===================================================================== + +async function dupStartScan() { + const folder = document.getElementById('dupFolder').value.trim(); + if (!folder) { showToast('Enter a folder path', 'error'); return; } + _storeFolder('dupfinder', folder); + + const scanType = document.getElementById('dupScanType').value; + const imgThreshold = parseInt(document.getElementById('dupImgThreshold').value) / 100; + const vidThreshold = parseInt(document.getElementById('dupVidThreshold').value) / 100; + + document.getElementById('dupScanBtn').disabled = true; + document.getElementById('dupResults').classList.remove('active'); + document.getElementById('dupProgress').classList.add('active'); + document.getElementById('dupProgressBar').style.width = '0%'; + document.getElementById('dupProgressPct').textContent = '0%'; + document.getElementById('dupProgressMsg').textContent = 'Starting scan...'; + + try { + const resp = await fetch('/api/dupfinder/scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + folder, type: scanType, + image_threshold: imgThreshold, + video_threshold: vidThreshold, + }), + }); + const data = await resp.json(); + if (data.error) { + showToast(data.error, 'error'); + dupResetUI(); + return; + } + dupJobId = data.job_id; + dupPollProgress(); + } catch (e) { + showToast('Scan failed: ' + e.message, 'error'); + dupResetUI(); + } +} + +function dupPollProgress() { + if (!dupJobId) return; + dupPollTimer = setInterval(async () => { + try { + const resp = await fetch(`/api/dupfinder/scan/${dupJobId}/status`); + const data = await resp.json(); + + const pct = Math.round(data.progress * 100); + document.getElementById('dupProgressBar').style.width = pct + '%'; + document.getElementById('dupProgressPct').textContent = pct + '%'; + document.getElementById('dupProgressMsg').textContent = data.message || ''; + document.getElementById('dupProgressElapsed').textContent = formatDuration(data.elapsed_seconds || 0); + + if (data.status === 'done') { + clearInterval(dupPollTimer); + await dupLoadResults(); + } else if (data.status === 'error') { + clearInterval(dupPollTimer); + showToast('Scan failed: ' + (data.error || 'Unknown'), 'error'); + dupResetUI(); + } + } catch (e) { /* retry */ } + }, 500); +} + +async function dupLoadResults() { + try { + const resp = await fetch(`/api/dupfinder/scan/${dupJobId}/results`); + const data = await resp.json(); + + dupAllGroups = []; + let idx = 0; + for (const g of (data.image_groups || [])) dupAllGroups.push({ index: idx++, type: 'image', items: g }); + for (const g of (data.video_groups || [])) dupAllGroups.push({ index: idx++, type: 'video', items: g }); + + dupRenderResults(data); + document.getElementById('dupProgress').classList.remove('active'); + document.getElementById('dupResults').classList.add('active'); + dupResetUI(); + + document.getElementById('dupNoResults').style.display = + dupAllGroups.length === 0 ? 'block' : 'none'; + } catch (e) { + showToast('Failed to load results', 'error'); + dupResetUI(); + } +} + +function dupResetUI() { + document.getElementById('dupScanBtn').disabled = false; +} + +// ===================================================================== +// Dupfinder: Render +// ===================================================================== + +function dupRenderResults(data) { + const totalGroups = dupAllGroups.length; + const totalDups = dupAllGroups.reduce((s, g) => s + g.items.length - 1, 0); + + document.getElementById('dupSummary').innerHTML = ` +
${totalGroups}
Duplicate Groups
+
${totalDups}
Duplicate Files
+
${data.space_savings_formatted}
Potential Savings
+ `; + + document.getElementById('dupTitle').textContent = + `${totalGroups} Duplicate Group${totalGroups !== 1 ? 's' : ''} Found`; + + const container = document.getElementById('dupGroups'); + container.innerHTML = ''; + for (const group of dupAllGroups) { + container.appendChild(dupCreateGroupCard(group)); + } +} + +function dupCreateGroupCard(group) { + const div = document.createElement('div'); + div.className = 'group-card'; + div.dataset.groupIndex = group.index; + + const typeLabel = group.type === 'image' ? 'Image' : 'Video'; + const badgeClass = group.type === 'video' ? 'group-badge video' : 'group-badge'; + + div.innerHTML = ` +
+
+ ${typeLabel} + Group ${group.index + 1} — ${group.items.length} files +
+ +
+
+
+ ${group.items.map((item, i) => dupCreateFileCard(item, i === 0, group.index)).join('')} +
+
+ `; + return div; +} + +function dupCreateFileCard(item, isBest) { + const isSelected = dupSelectedFiles.has(item.path); + const selClass = isSelected ? 'selected-for-delete' : (isBest ? 'is-best' : ''); + + let thumb; + if (item.type === 'video') { + thumb = ``; + } else { + thumb = `${escapeAttr(item.filename)}`; + } + + const badges = []; + if (isBest) badges.push(`★ BEST`); + badges.push(`${item.similarity}%`); + + const meta = []; + meta.push(item.resolution); + meta.push(item.file_size_formatted); + if (item.duration_formatted) meta.push(item.duration_formatted); + if (item.format) meta.push(item.format); + + return ` +
+
+ ${thumb} +
${badges.join('')}
+
Click to preview
+
+
+
${escapeHtml(item.filename)}
+
${escapeHtml(item.directory)}
+
${meta.join(' · ')}
+
+
+ ${isBest + ? `` + : `` + } +
+
+ `; +} + +// ===================================================================== +// Dupfinder: Selection & Deletion +// ===================================================================== + +function dupToggleGroup(index) { + const body = document.getElementById(`dupGroupBody${index}`); + body.classList.toggle('collapsed'); + const icon = body.closest('.group-card').querySelector('.toggle-icon'); + icon.textContent = body.classList.contains('collapsed') ? '▶' : '▼'; +} + +function dupExpandAll() { + document.querySelectorAll('#dupGroups .group-body').forEach(b => b.classList.remove('collapsed')); + document.querySelectorAll('#dupGroups .toggle-icon').forEach(i => i.textContent = '▼'); +} + +function dupCollapseAll() { + document.querySelectorAll('#dupGroups .group-body').forEach(b => b.classList.add('collapsed')); + document.querySelectorAll('#dupGroups .toggle-icon').forEach(i => i.textContent = '▶'); +} + +function dupToggleFile(path) { + if (dupSelectedFiles.has(path)) dupSelectedFiles.delete(path); + else dupSelectedFiles.add(path); + dupUpdateSelectionUI(); +} + +function dupAutoSelect() { + dupSelectedFiles.clear(); + for (const g of dupAllGroups) { + for (let i = 1; i < g.items.length; i++) { + dupSelectedFiles.add(g.items[i].path); + } + } + dupUpdateSelectionUI(); + showToast(`Selected ${dupSelectedFiles.size} duplicate(s)`, 'success'); +} + +// Initialize converter formats for batch target dropdown +convLoadFormats(); +loadFolderPreferences(); + +function dupClearSelection() { + dupSelectedFiles.clear(); + dupUpdateSelectionUI(); +} + +function dupUpdateSelectionUI() { + document.querySelectorAll('#dupGroups .file-card').forEach(card => { + const path = card.dataset.path; + const isSelected = dupSelectedFiles.has(path); + card.classList.toggle('selected-for-delete', isSelected); + card.classList.toggle('is-best', !isSelected && !!card.querySelector('.best-badge')); + + const btn = card.querySelector('.file-actions button'); + if (btn) { + const hasBest = !!card.querySelector('.best-badge'); + if (isSelected) { + btn.className = 'btn btn-ghost btn-sm'; + btn.style.flex = '1'; + btn.style.borderColor = hasBest ? 'var(--danger)' : ''; + btn.innerHTML = hasBest ? '↩ Unselect (★ best)' : '↩ Unselect'; + } else if (hasBest) { + btn.className = 'btn btn-ghost btn-sm'; + btn.style.flex = '1'; + btn.style.borderColor = 'var(--success)'; + btn.innerHTML = '★ Recommended to keep'; + } else { + btn.className = 'btn btn-danger btn-sm'; + btn.style.flex = '1'; + btn.style.borderColor = ''; + btn.innerHTML = '🗑 Select to delete'; + } + } + }); + + const bulkBar = document.getElementById('dupBulkBar'); + if (dupSelectedFiles.size > 0) { + bulkBar.classList.add('active'); + document.getElementById('dupBulkCount').textContent = dupSelectedFiles.size; + let totalSize = 0; + document.querySelectorAll('#dupGroups .file-card').forEach(card => { + if (dupSelectedFiles.has(card.dataset.path)) { + totalSize += parseInt(card.dataset.size) || 0; + } + }); + document.getElementById('dupBulkSize').textContent = formatBytes(totalSize); + } else { + bulkBar.classList.remove('active'); + } +} + +function dupConfirmDelete() { + if (dupSelectedFiles.size === 0) return; + const fileList = document.getElementById('dupDeleteFileList'); + fileList.innerHTML = ''; + for (const path of dupSelectedFiles) { + const div = document.createElement('div'); + div.textContent = path; + fileList.appendChild(div); + } + document.getElementById('dupDeleteModal').classList.add('active'); +} + +function dupCloseModal() { + document.getElementById('dupDeleteModal').classList.remove('active'); +} + +async function dupExecuteDelete() { + const files = Array.from(dupSelectedFiles); + const btn = document.getElementById('dupConfirmDeleteBtn'); + btn.disabled = true; + btn.textContent = 'Deleting...'; + + try { + const resp = await fetch('/api/dupfinder/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ files }), + }); + const data = await resp.json(); + const deleted = data.results.filter(r => r.status === 'deleted'); + const failed = data.results.filter(r => r.status !== 'deleted'); + + if (deleted.length > 0) showToast(`Deleted ${deleted.length} file(s), freed ${data.total_freed_formatted}`, 'success'); + if (failed.length > 0) showToast(`Failed to delete ${failed.length} file(s)`, 'error'); + + for (const r of deleted) { + dupSelectedFiles.delete(r.path); + const card = document.querySelector(`#dupGroups .file-card[data-path="${CSS.escape(r.path)}"]`); + if (card) card.remove(); + for (const g of dupAllGroups) { + g.items = g.items.filter(item => item.path !== r.path); + } + } + + dupAllGroups = dupAllGroups.filter(g => g.items.length > 1); + const container = document.getElementById('dupGroups'); + container.innerHTML = ''; + for (const g of dupAllGroups) container.appendChild(dupCreateGroupCard(g)); + dupUpdateSelectionUI(); + + if (dupAllGroups.length === 0) { + document.getElementById('dupNoResults').style.display = 'block'; + document.getElementById('dupTitle').textContent = 'All duplicates resolved!'; + document.getElementById('dupSummary').innerHTML = ''; + } + } catch (e) { + showToast('Delete failed: ' + e.message, 'error'); + } finally { + dupCloseModal(); + btn.disabled = false; + btn.textContent = '🗑️ Delete'; + } +} + +// ===================================================================== +// Inspector Tab +// ===================================================================== + +async function inspStartScan() { + const folder = document.getElementById('inspFolder').value.trim(); + if (!folder) { showToast('Enter a folder path', 'error'); return; } + _storeFolder('inspector', folder); + const mode = document.getElementById('inspMode').value; + + document.getElementById('inspScanBtn').disabled = true; + document.getElementById('inspResults').style.display = 'none'; + document.getElementById('inspProgress').style.display = 'block'; + document.getElementById('inspProgressBar').style.width = '0%'; + document.getElementById('inspProgressPct').textContent = '0%'; + document.getElementById('inspProgressMsg').textContent = 'Starting...'; + + try { + const resp = await fetch('/api/inspector/scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder, mode }), + }); + const data = await resp.json(); + if (data.error) { showToast(data.error, 'error'); return; } + inspJobId = data.job_id; + inspPollTimer = setInterval(inspPollProgress, 800); + } catch (e) { + showToast('Scan failed: ' + e.message, 'error'); + document.getElementById('inspProgress').style.display = 'none'; + } finally { + document.getElementById('inspScanBtn').disabled = false; + } +} + +async function inspPollProgress() { + try { + const resp = await fetch(`/api/inspector/scan/${inspJobId}/status`); + const data = await resp.json(); + const total = data.total_files || 0; + const processed = data.processed_files || 0; + const pct = total > 0 ? Math.round((processed / total) * 100) : 0; + document.getElementById('inspProgressBar').style.width = pct + '%'; + document.getElementById('inspProgressPct').textContent = pct + '%'; + document.getElementById('inspProgressMsg').textContent = `${processed} / ${total} files`; + + if (data.status === 'done' || data.status === 'error') { + clearInterval(inspPollTimer); + document.getElementById('inspProgress').style.display = 'none'; + if (data.status === 'error') { + showToast('Scan error: ' + (data.error || 'Unknown'), 'error'); + } else { + inspLoadResults(); + } + } + } catch (e) { + clearInterval(inspPollTimer); + showToast('Poll error: ' + e.message, 'error'); + } +} + +async function inspLoadResults() { + try { + const resp = await fetch(`/api/inspector/scan/${inspJobId}/results`); + const data = await resp.json(); + const container = document.getElementById('inspResultsContent'); + const section = document.getElementById('inspResults'); + section.style.display = 'block'; + + if (!data.results || data.results.length === 0) { + container.innerHTML = '
📂

No files found

'; + return; + } + + const mode = document.getElementById('inspMode').value; + if (mode === 'exif') { + inspRenderExif(data.results, container); + } else { + inspRenderIntegrity(data.results, container); + } + } catch (e) { + showToast('Failed to load results: ' + e.message, 'error'); + } +} + +function inspRenderExif(results, container) { + let html = `
📊 EXIF Results — ${results.length} file(s)
`; + for (const item of results) { + const fname = item.file.split('/').pop(); + const exif = item.exif || {}; + const keys = Object.keys(exif).filter(k => !k.startsWith('_')); + html += `
+
+ 📄 ${escapeHtml(fname)} ${keys.length} tags +
+
+
+ +
+ `; + for (const k of keys) { + const val = typeof exif[k] === 'object' ? JSON.stringify(exif[k]) : String(exif[k]); + html += ``; + } + if (exif._gps_lat && exif._gps_lng) { + html += ``; + } + html += `
${escapeHtml(k)}${escapeHtml(val.substring(0, 200))}
📍 GPS${exif._gps_lat.toFixed(6)}, ${exif._gps_lng.toFixed(6)}
`; + } + container.innerHTML = html; +} + +function inspRenderIntegrity(results, container) { + let ok = 0, bad = 0; + for (const r of results) { if (r.ok) ok++; else bad++; } + + let html = `
+
📊 Integrity Results
+
+
${ok}
OK
+
${bad}
Errors
+
${results.length}
Total
+
`; + + if (bad > 0) { + html += '
⚠️ Failed Files
'; + for (const r of results.filter(x => !x.ok)) { + html += `
${escapeHtml(r.file.split('/').pop())}${escapeHtml(r.error || 'Unknown error')}
`; + } + html += '
'; + } + container.innerHTML = html; +} + +async function inspStripOne(filePath) { + try { + const resp = await fetch('/api/inspector/exif/strip', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ files: [filePath] }), + }); + const data = await resp.json(); + if (data.error) { showToast(data.error, 'error'); return; } + showToast('EXIF stripped from 1 file', 'success'); + } catch (e) { + showToast('Strip failed: ' + e.message, 'error'); + } +} + +// ===================================================================== +// Resizer Tab +// ===================================================================== + +async function resStartResize() { + const folder = document.getElementById('resFolder').value.trim(); + if (!folder) { showToast('Enter a folder path', 'error'); return; } + _storeFolder('resizer', folder); + + const width = parseInt(document.getElementById('resWidth').value) || 1920; + const height = parseInt(document.getElementById('resHeight').value) || 1080; + const mode = document.getElementById('resMode').value; + const quality = parseInt(document.getElementById('resQuality').value) || 90; + const bgColor = document.getElementById('resBgColor').value; + const outputFolder = document.getElementById('resOutput').value.trim() || null; + + document.getElementById('resScanBtn').disabled = true; + document.getElementById('resResults').style.display = 'none'; + document.getElementById('resProgress').style.display = 'block'; + document.getElementById('resProgressBar').style.width = '0%'; + document.getElementById('resProgressPct').textContent = '0%'; + document.getElementById('resProgressMsg').textContent = 'Starting...'; + + try { + const resp = await fetch('/api/resizer/scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder, width, height, mode, quality, bg_color: bgColor, output_folder: outputFolder }), + }); + const data = await resp.json(); + if (data.error) { showToast(data.error, 'error'); return; } + resJobId = data.job_id; + resPollTimer = setInterval(resPollProgress, 800); + } catch (e) { + showToast('Resize failed: ' + e.message, 'error'); + document.getElementById('resProgress').style.display = 'none'; + } finally { + document.getElementById('resScanBtn').disabled = false; + } +} + +async function resPollProgress() { + try { + const resp = await fetch(`/api/resizer/scan/${resJobId}/status`); + const data = await resp.json(); + const total = data.total_files || 0; + const processed = data.processed_files || 0; + const pct = total > 0 ? Math.round((processed / total) * 100) : 0; + document.getElementById('resProgressBar').style.width = pct + '%'; + document.getElementById('resProgressPct').textContent = pct + '%'; + document.getElementById('resProgressMsg').textContent = `${processed} / ${total} images`; + + if (data.status === 'done' || data.status === 'error') { + clearInterval(resPollTimer); + document.getElementById('resProgress').style.display = 'none'; + if (data.status === 'error') { + showToast('Resize error: ' + (data.error || 'Unknown'), 'error'); + } else { + resLoadResults(); + } + } + } catch (e) { + clearInterval(resPollTimer); + showToast('Poll error: ' + e.message, 'error'); + } +} + +async function resLoadResults() { + try { + const resp = await fetch(`/api/resizer/scan/${resJobId}/results`); + const data = await resp.json(); + const container = document.getElementById('resResultsContent'); + const section = document.getElementById('resResults'); + section.style.display = 'block'; + + const results = data.results || []; + let ok = 0, errors = 0; + for (const r of results) { if (r.status === 'ok') ok++; else errors++; } + + let html = `
+
📊 Resize Results
+
+
${ok}
Resized
+
${errors}
Errors
+
${results.length}
Total
+
`; + + if (errors > 0) { + html += '
⚠️ Errors
'; + for (const r of results.filter(x => x.status !== 'ok')) { + html += `
${escapeHtml(r.input.split('/').pop())}${escapeHtml(r.error || 'Unknown')}
`; + } + html += '
'; + } + container.innerHTML = html; + } catch (e) { + showToast('Failed to load results: ' + e.message, 'error'); + } +} + +// ===================================================================== +// Organizer Tab +// ===================================================================== + +function orgModeChanged() { + const mode = document.getElementById('orgMode').value; + document.getElementById('orgSortOptions').style.display = mode === 'sort' ? 'block' : 'none'; + document.getElementById('orgRenameOptions').style.display = mode === 'rename' ? 'block' : 'none'; + document.getElementById('orgExecBtn').style.display = 'none'; + document.getElementById('orgPlanResults').style.display = 'none'; + document.getElementById('orgExecResults').style.display = 'none'; +} + +async function orgStartPlan() { + const folder = document.getElementById('orgFolder').value.trim(); + if (!folder) { showToast('Enter a folder path', 'error'); return; } + _storeFolder('organizer', folder); + + const mode = document.getElementById('orgMode').value; + const operation = document.getElementById('orgOperation').value; + + let body = { folder, mode, operation }; + if (mode === 'sort') { + body.template = document.getElementById('orgTemplate').value.trim() || '{year}/{month}'; + body.destination = document.getElementById('orgDest').value.trim() || null; + } else { + body.template = document.getElementById('orgRenameTemplate').value.trim() || '{date}_{seq:4}'; + body.start_seq = parseInt(document.getElementById('orgStartSeq').value) || 1; + } + + document.getElementById('orgPlanBtn').disabled = true; + document.getElementById('orgExecBtn').style.display = 'none'; + document.getElementById('orgPlanResults').style.display = 'none'; + document.getElementById('orgExecResults').style.display = 'none'; + document.getElementById('orgProgress').style.display = 'block'; + document.getElementById('orgProgressBar').style.width = '0%'; + document.getElementById('orgProgressMsg').textContent = 'Planning...'; + + try { + const resp = await fetch('/api/organizer/plan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await resp.json(); + if (data.error) { showToast(data.error, 'error'); return; } + orgJobId = data.job_id; + orgPollTimer = setInterval(orgPollStatus, 600); + } catch (e) { + showToast('Plan failed: ' + e.message, 'error'); + document.getElementById('orgProgress').style.display = 'none'; + } finally { + document.getElementById('orgPlanBtn').disabled = false; + } +} + +async function orgPollStatus() { + try { + const resp = await fetch(`/api/organizer/status/${orgJobId}`); + const data = await resp.json(); + + const pct = Math.round((data.progress || 0) * 100); + document.getElementById('orgProgressBar').style.width = pct + '%'; + document.getElementById('orgProgressPct').textContent = pct + '%'; + + if (data.phase === 'planned') { + clearInterval(orgPollTimer); + document.getElementById('orgProgress').style.display = 'none'; + orgRenderPlan(data); + } else if (data.phase === 'done') { + clearInterval(orgPollTimer); + document.getElementById('orgProgress').style.display = 'none'; + orgRenderExecResults(data); + } else if (data.phase === 'error' || data.status === 'error') { + clearInterval(orgPollTimer); + document.getElementById('orgProgress').style.display = 'none'; + showToast('Error: ' + (data.error || 'Unknown'), 'error'); + } else { + document.getElementById('orgProgressMsg').textContent = + data.phase === 'executing' ? `Executing...` : `Planning...`; + } + } catch (e) { + clearInterval(orgPollTimer); + showToast('Poll error: ' + e.message, 'error'); + } +} + +function orgRenderPlan(data) { + const content = document.getElementById('orgPlanContent'); + const plan = data.plan || []; + const conflicts = data.conflicts || 0; + const planCount = data.plan_count || plan.length; + + let html = `
📋 Plan Preview — ${planCount} file(s)
`; + if (conflicts > 0) { + html += `
⚠️ ${conflicts} conflict(s) detected — those will be skipped.
`; + } + + html += '
'; + for (const item of plan.slice(0, 200)) { + const srcName = item.source.split('/').pop(); + const dstName = item.destination.split('/').pop(); + const conflict = item.conflict ? ' style="color:var(--danger);"' : ''; + html += ``; + } + if (plan.length > 200) html += ``; + html += '
SourceDestination
${escapeHtml(srcName)}${escapeHtml(dstName)}
… and ${plan.length - 200} more
'; + + content.innerHTML = html; + document.getElementById('orgPlanResults').style.display = 'block'; + document.getElementById('orgExecBtn').style.display = 'inline-flex'; +} + +async function orgExecute() { + if (!orgJobId) { showToast('No plan to execute', 'error'); return; } + + document.getElementById('orgExecBtn').disabled = true; + document.getElementById('orgProgress').style.display = 'block'; + document.getElementById('orgProgressMsg').textContent = 'Executing...'; + document.getElementById('orgProgressBar').style.width = '0%'; + + try { + const resp = await fetch('/api/organizer/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ job_id: orgJobId }), + }); + const data = await resp.json(); + if (data.error) { showToast(data.error, 'error'); return; } + orgPollTimer = setInterval(orgPollStatus, 600); + } catch (e) { + showToast('Execute failed: ' + e.message, 'error'); + document.getElementById('orgProgress').style.display = 'none'; + } finally { + document.getElementById('orgExecBtn').disabled = false; + } +} + +function orgRenderExecResults(data) { + const container = document.getElementById('orgExecContent'); + const r = data.execution || {}; + const completed = r.completed || 0; + const errList = r.errors || []; + const errors = typeof errList === 'number' ? errList : errList.length; + const skipped = r.skipped || 0; + + let html = `
+
Execution Complete
+
+
${completed}
Processed
+
${skipped}
Skipped
+
${errors}
Errors
+
`; + + if (errors > 0) { + html += '
⚠️ Errors
'; + for (const e of (r.errors || [])) { + html += `
${escapeHtml(e.file || 'unknown')}${escapeHtml(e.error || '')}
`; + } + html += '
'; + } + + container.innerHTML = html; + document.getElementById('orgExecResults').style.display = 'block'; + document.getElementById('orgPlanResults').style.display = 'none'; + document.getElementById('orgExecBtn').style.display = 'none'; + showToast(`Done: ${completed} file(s) processed`, 'success'); +} + +// ===================================================================== +// Shared: Preview +// ===================================================================== + +function openPreview(path, type) { + const content = document.getElementById('previewContent'); + if (type === 'video') { + content.innerHTML = ``; + } else { + content.innerHTML = ``; + } + document.getElementById('previewOverlay').classList.add('active'); +} + +function closePreview() { + const overlay = document.getElementById('previewOverlay'); + overlay.classList.remove('active'); + const video = overlay.querySelector('video'); + if (video) video.pause(); + document.getElementById('previewContent').innerHTML = ''; +} + +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { closePreview(); dupCloseModal(); } +}); + +// ===================================================================== +// Utilities +// ===================================================================== + +function escapeHtml(str) { + const d = document.createElement('div'); + d.textContent = str; + return d.innerHTML; +} + +function escapeAttr(str) { + return str.replace(/'/g, "\\'").replace(/"/g, '"'); +} + +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i]; +} + +function formatDuration(seconds) { + if (seconds < 60) return Math.round(seconds) + 's'; + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + return `${m}m ${s}s`; +} + +function showToast(message, type = 'success') { + const container = document.getElementById('toastContainer'); + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.textContent = message; + container.appendChild(toast); + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transition = 'opacity 0.3s'; + setTimeout(() => toast.remove(), 300); + }, 4000); +} + +function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } diff --git a/go/web/static/style.css b/go/web/static/style.css new file mode 100644 index 0000000..39ecaac --- /dev/null +++ b/go/web/static/style.css @@ -0,0 +1,824 @@ +/* ── Morphic – Shared Dark Theme ──────────────────────────────────── */ + +:root { + --bg: #0f1117; + --surface: #1a1d27; + --surface2: #242835; + --border: #2e3345; + --text: #e1e4ed; + --text-dim: #8b8fa3; + --accent: #6c7bf7; + --accent-hover: #8590ff; + --danger: #f75c6c; + --danger-hover: #ff7a87; + --success: #4ade80; + --warning: #fbbf24; + --radius: 10px; + --shadow: 0 2px 12px rgba(0,0,0,0.3); +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; + min-height: 100vh; +} + +/* ── Header ──────────────────────────────────────────────────────── */ + +.app-header { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 0 24px; + display: flex; + align-items: center; + gap: 16px; + position: sticky; + top: 0; + z-index: 100; + height: 56px; +} + +.app-header h1 { + font-size: 20px; + font-weight: 700; + background: linear-gradient(135deg, var(--accent), #a78bfa); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + margin-right: auto; +} + +/* ── Tabs ─────────────────────────────────────────────────────────── */ + +.tab-bar { + display: flex; + gap: 0; + height: 100%; +} + +.tab-btn { + background: none; + border: none; + color: var(--text-dim); + font-size: 14px; + font-weight: 500; + padding: 0 20px; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s; + height: 100%; + display: flex; + align-items: center; + gap: 6px; +} + +.tab-btn:hover { + color: var(--text); + background: var(--surface2); +} + +.tab-btn.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* ── Container ───────────────────────────────────────────────────── */ + +.container { + max-width: 1400px; + margin: 0 auto; + padding: 24px; +} + +/* ── Cards ────────────────────────────────────────────────────────── */ + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; + margin-bottom: 20px; +} + +.card-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 8px; +} + +.card-title .icon { font-size: 20px; } + +/* ── Form Controls ───────────────────────────────────────────────── */ + +.form-row { + display: flex; + gap: 12px; + align-items: flex-end; + flex-wrap: wrap; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + min-width: 200px; +} + +.form-group.narrow { + flex: 0 0 auto; + min-width: 140px; +} + +label { + font-size: 13px; + font-weight: 500; + color: var(--text-dim); +} + +input[type="text"], select { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 10px 14px; + color: var(--text); + font-size: 14px; + outline: none; + transition: border-color 0.2s; +} + +input[type="text"]:focus, select:focus { + border-color: var(--accent); +} + +input[type="range"] { + width: 100%; + accent-color: var(--accent); +} + +/* ── Checkboxes ──────────────────────────────────────────────────── */ + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + cursor: pointer; + color: var(--text-dim); +} + +.checkbox-label input[type="checkbox"] { + accent-color: var(--accent); +} + +/* ── Buttons ─────────────────────────────────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border-radius: 6px; + border: none; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.btn-primary { background: var(--accent); color: #fff; } +.btn-primary:hover:not(:disabled) { background: var(--accent-hover); } + +.btn-danger { background: var(--danger); color: #fff; } +.btn-danger:hover:not(:disabled) { background: var(--danger-hover); } + +.btn-ghost { + background: transparent; + color: var(--text-dim); + border: 1px solid var(--border); +} +.btn-ghost:hover { background: var(--surface2); color: var(--text); } + +.btn-sm { padding: 6px 12px; font-size: 13px; } + +.btn:disabled { opacity: 0.5; cursor: not-allowed; } + +/* ── Folder Browser ──────────────────────────────────────────────── */ + +.folder-input-row { + display: flex; + gap: 8px; +} + +.folder-input-row input { flex: 1; } + +.folder-browser { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 6px; + max-height: 250px; + overflow-y: auto; + margin-top: 8px; + display: none; +} + +.folder-browser.open { display: block; } + +.folder-browser .fb-header { + padding: 8px 12px; + font-size: 12px; + color: var(--text-dim); + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + background: var(--surface2); +} + +.folder-browser .fb-item { + padding: 8px 12px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + transition: background 0.15s; +} + +.folder-browser .fb-item:hover { background: var(--border); } +.folder-browser .fb-item .icon { opacity: 0.6; } + +.folder-browser .fb-footer { + padding: 8px 12px; + border-top: 1px solid var(--border); + display: flex; + justify-content: flex-end; + position: sticky; + bottom: 0; + background: var(--surface2); +} + +/* ── Progress ────────────────────────────────────────────────────── */ + +.progress-section { display: none; } +.progress-section.active { display: block; } + +.progress-bar-track { + width: 100%; + height: 6px; + background: var(--surface2); + border-radius: 3px; + overflow: hidden; + margin: 12px 0; +} + +.progress-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), #a78bfa); + border-radius: 3px; + transition: width 0.3s ease; + width: 0%; +} + +.progress-info { + display: flex; + justify-content: space-between; + font-size: 13px; + color: var(--text-dim); +} + +.progress-message { + font-size: 14px; + margin-top: 8px; +} + +.spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid var(--border); + border-top: 2px solid var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + vertical-align: middle; + margin-right: 6px; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── Filter Pills ────────────────────────────────────────────────── */ + +.filter-pills { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 12px; +} + +.filter-pill { + padding: 4px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 500; + background: var(--surface2); + border: 1px solid var(--border); + color: var(--text-dim); + cursor: pointer; + transition: all 0.2s; +} + +.filter-pill:hover { color: var(--text); border-color: var(--text-dim); } +.filter-pill.active { background: var(--accent); color: #fff; border-color: var(--accent); } + +/* ── File Table ──────────────────────────────────────────────────── */ + +.file-table { + width: 100%; + border-collapse: collapse; + margin-top: 12px; +} + +.file-table th, .file-table td { + padding: 8px 12px; + text-align: left; + font-size: 13px; + border-bottom: 1px solid var(--border); +} + +.file-table th { + font-weight: 500; + color: var(--text-dim); + background: var(--surface2); + position: sticky; + top: 0; +} + +.file-table tr:hover td { background: var(--surface2); } + +.file-table .file-name { + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +..file-table .file-name:hover { color: var(--accent); } + +.file-table tr.failed-file td { + background: rgba(247, 92, 108, 0.12); +} + +.failed-badge { + margin-left: 8px; + padding: 1px 6px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + background: rgba(247, 92, 108, 0.2); + color: var(--danger); +} + +.file-thumb { + width: 40px; + height: 40px; + object-fit: cover; + border-radius: 4px; + background: #111; + cursor: pointer; +} + +/* ── Converter Results ───────────────────────────────────────────── */ + +.conv-result { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + font-size: 13px; +} + +.conv-result .status-ok { color: var(--success); } +.conv-result .status-err { color: var(--danger); } + +.conv-result .size-change { + font-size: 12px; + color: var(--text-dim); +} + +/* ── Dupfinder Results ───────────────────────────────────────────── */ + +.results-section { display: none; } +.results-section.active { display: block; } + +.results-summary { + display: flex; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.stat-card { + background: var(--surface2); + border-radius: 8px; + padding: 16px 20px; + flex: 1; + min-width: 150px; +} + +.stat-card .stat-value { + font-size: 28px; + font-weight: 700; + color: var(--accent); +} + +.stat-card .stat-label { + font-size: 13px; + color: var(--text-dim); + margin-top: 4px; +} + +.stat-card.savings .stat-value { color: var(--success); } + +/* ── Duplicate Groups ────────────────────────────────────────────── */ + +.group-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 16px; + overflow: hidden; +} + +.group-header { + padding: 14px 20px; + background: var(--surface2); + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + user-select: none; +} + +.group-header:hover { background: var(--border); } + +.group-title { + font-weight: 600; + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; +} + +.group-badge { + background: var(--accent); + color: #fff; + padding: 2px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; +} + +.group-badge.video { background: #7c3aed; } + +.group-body { padding: 16px; } +.group-body.collapsed { display: none; } + +.dup-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} + +/* ── File Card (Dupfinder) ───────────────────────────────────────── */ + +.file-card { + background: var(--surface2); + border: 2px solid transparent; + border-radius: 8px; + overflow: hidden; + transition: border-color 0.2s, transform 0.15s; + position: relative; +} + +.file-card.selected-for-delete { border-color: var(--danger); } +.file-card.is-best { border-color: var(--success); } + +.file-card .thumb-container { + position: relative; + width: 100%; + aspect-ratio: 16/10; + overflow: hidden; + background: #111; + cursor: pointer; +} + +.file-card .thumb-container img, +.file-card .thumb-container video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.file-card .thumb-overlay { + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0,0,0,0.5); + opacity: 0; + transition: opacity 0.2s; + font-size: 14px; + font-weight: 500; +} + +.file-card .thumb-container:hover .thumb-overlay { opacity: 1; } + +.file-card .badge-row { + position: absolute; + top: 8px; + left: 8px; + display: flex; + gap: 6px; +} + +.file-card .sim-badge { + background: rgba(0,0,0,0.7); + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.file-card .best-badge { + background: var(--success); + color: #000; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; +} + +.file-card .file-info { padding: 12px; } + +.file-card .file-name { + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; +} + +.file-card .file-meta { + font-size: 12px; + color: var(--text-dim); + display: flex; + flex-wrap: wrap; + gap: 4px 12px; +} + +.file-card .file-actions { + padding: 0 12px 12px; + display: flex; + gap: 8px; +} + +.file-card .file-dir { + font-size: 11px; + color: var(--text-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 6px; + opacity: 0.7; +} + +/* ── Bulk Bar ────────────────────────────────────────────────────── */ + +.bulk-bar { + display: none; + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--surface); + border-top: 1px solid var(--border); + padding: 14px 24px; + z-index: 200; + box-shadow: 0 -4px 20px rgba(0,0,0,0.3); +} + +.bulk-bar.active { + display: flex; + align-items: center; + justify-content: space-between; +} + +.bulk-bar .bulk-info { font-size: 14px; } +.bulk-bar .bulk-info strong { color: var(--danger); } +.bulk-bar .bulk-actions { display: flex; gap: 10px; } + +/* ── Modal / Overlay ─────────────────────────────────────────────── */ + +.modal-overlay { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.8); + z-index: 500; + align-items: center; + justify-content: center; +} + +.modal-overlay.active { display: flex; } + +.modal { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + max-width: 600px; + width: 90%; + padding: 24px; +} + +.modal h3 { margin-bottom: 12px; } + +.modal .file-list { + max-height: 200px; + overflow-y: auto; + background: var(--surface2); + border-radius: 6px; + padding: 8px 12px; + margin: 12px 0; + font-size: 13px; +} + +.modal .file-list div { + padding: 4px 0; + border-bottom: 1px solid var(--border); +} + +.modal .file-list div:last-child { border-bottom: none; } +.modal .modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; } + +.preview-overlay { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.9); + z-index: 400; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.preview-overlay.active { display: flex; } +.preview-overlay img, .preview-overlay video { max-width: 90vw; max-height: 90vh; border-radius: 8px; } + +.preview-close { + position: fixed; + top: 16px; + right: 16px; + font-size: 32px; + color: #fff; + cursor: pointer; + z-index: 401; + background: rgba(0,0,0,0.5); + border-radius: 50%; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; +} + +/* ── Toast ────────────────────────────────────────────────────────── */ + +.toast-container { + position: fixed; + top: 16px; + right: 16px; + z-index: 600; + display: flex; + flex-direction: column; + gap: 8px; +} + +.toast { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 12px 16px; + font-size: 14px; + box-shadow: var(--shadow); + animation: slideIn 0.3s ease; + max-width: 400px; +} + +.toast.success { border-left: 3px solid var(--success); } +.toast.error { border-left: 3px solid var(--danger); } + +@keyframes slideIn { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} + +/* ── Empty State ─────────────────────────────────────────────────── */ + +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-dim); +} + +.empty-state .icon { font-size: 48px; margin-bottom: 12px; } +.empty-state h3 { margin-bottom: 8px; color: var(--text); } + +/* ── Scrollbar ───────────────────────────────────────────────────── */ + +::-webkit-scrollbar { width: 8px; } +::-webkit-scrollbar-track { background: var(--surface); } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-dim); } + +/* ── Inspector: EXIF cards ───────────────────────────────────────── */ + +.exif-card .exif-details { display: none; margin-top: 12px; } +.exif-card.expanded .exif-details { display: block; } + +.exif-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.exif-table th, .exif-table td { + padding: 6px 10px; + border-bottom: 1px solid var(--border); + text-align: left; +} +.exif-table .exif-key { + font-weight: 600; + color: var(--accent); + white-space: nowrap; + width: 200px; +} + +/* ── Inspector: Integrity items ──────────────────────────────────── */ + +.integrity-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + font-size: 13px; +} +.integrity-item.bad { background: rgba(255, 85, 85, 0.08); } +.integrity-error { color: var(--danger); font-size: 12px; } + +/* ── Stat row (shared) ───────────────────────────────────────────── */ + +.stat-row { + display: flex; + gap: 12px; + margin-top: 12px; + flex-wrap: wrap; +} +.stat-card { + flex: 1; + min-width: 100px; + text-align: center; + padding: 16px 12px; + background: var(--surface2); + border-radius: 8px; + border: 1px solid var(--border); +} +.stat-value { font-size: 28px; font-weight: 700; } +.stat-label { font-size: 12px; color: var(--text-dim); margin-top: 4px; } + +/* ── Responsive ──────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .form-row { flex-direction: column; } + .form-group.narrow { min-width: 100%; } + .container { padding: 12px; } + .dup-grid { grid-template-columns: 1fr; } + .tab-btn { padding: 0 12px; font-size: 13px; } + .stat-row { flex-direction: column; } +} diff --git a/go/web/templates/index.html b/go/web/templates/index.html new file mode 100644 index 0000000..b885376 --- /dev/null +++ b/go/web/templates/index.html @@ -0,0 +1,531 @@ + + + + + + Morphic + + + + + +
+

⚡ Morphic

+ +
+ + +
+ + + + +
+
+ + +
+
📁 Scan Folder
+ +
+
+ +
+ + +
+
+
+ ~ + +
+ +
+ +
+
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+ + +
+
+ + +
+
Converting...
+
+
+
+
+ 0% +
+
Starting...
+
+ + + + + + + +
+
+ + + + +
+
+ + +
+
⚙️ Scan Configuration
+ +
+
+ +
+ + +
+
+
+ ~ + +
+ +
+ +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
Scanning...
+
+
+
+
+ 0% + 0s +
+
Initializing...
+
+ + +
+
+ +
+

+
+ + + + +
+
+ +
+ + +
+ +
+
+ + + + +
+
+ +
+
🏷️ Inspect Files
+ +
+
+ +
+ + +
+
+
+ ~ + +
+ +
+ +
+
+
+ +
+
+ + +
+
+ + +
+ + +
+
Scanning...
+
+
+
+
+ 0% +
+
Initializing...
+
+ + + + +
+
+ + + + +
+
+ +
+
📐 Batch Resize
+ +
+
+ +
+ + +
+
+
+ ~ + +
+ +
+ +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
Resizing...
+
+
+
+
+ 0% +
+
Starting...
+
+ + + + +
+
+ + + + +
+
+ +
+
📂 Organize Files
+ +
+
+ +
+ + +
+
+
+ ~ + +
+ +
+ +
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+
+ + + Tokens: {year}, {month}, {day}, {hour}, {minute} +
+
+ + +
+
+
+ + + +
+ + +
+
+ + +
+
Working...
+
+
+
+
+ 0% +
+
Initializing...
+
+ + + + + + + +
+
+ + +
+
+ 0 file(s) selected for deletion + (0 B) +
+
+ + +
+
+ + +
+
+
+
+ + + + + + + From 9e217cf13023875d6e75a8d9bb86ce79034d9354 Mon Sep 17 00:00:00 2001 From: exTerEX <30706568+exTerEX@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:18:20 +0200 Subject: [PATCH 02/16] feat: implement Inspector, Resizer, Converter, Dupfinder modules (Phases 4-7) - Inspector: EXIF read/strip via go-exif, integrity check via imaging + ffprobe - Resizer: fit/fill/stretch/pad modes using disintegration/imaging - Converter: image conversion via imaging, video via ffmpeg subprocess - Dupfinder: CPU-only perceptual hashing via goimagehash, video frame extraction - All modules use goroutine workers with semaphore concurrency control - Added shared utilities: IsImage, IsVideo, NormaliseExt - Registered all routes in server.go (inspector, resizer, converter, dupfinder) - Added corona10/goimagehash v1.1.0 dependency --- go/go.mod | 2 + go/go.sum | 4 + go/internal/converter/constants.go | 98 ++++++++++ go/internal/converter/converter.go | 186 +++++++++++++++++++ go/internal/converter/scanner.go | 130 ++++++++++++++ go/internal/dupfinder/images.go | 182 +++++++++++++++++++ go/internal/dupfinder/scanner.go | 280 +++++++++++++++++++++++++++++ go/internal/dupfinder/videos.go | 233 ++++++++++++++++++++++++ go/internal/inspector/exif.go | 173 ++++++++++++++++++ go/internal/inspector/integrity.go | 169 +++++++++++++++++ go/internal/inspector/scanner.go | 213 ++++++++++++++++++++++ go/internal/resizer/operations.go | 128 +++++++++++++ go/internal/resizer/scanner.go | 153 ++++++++++++++++ go/internal/shared/utils.go | 23 +++ go/web/routes_converter.go | 275 ++++++++++++++++++++++++++++ go/web/routes_dupfinder.go | 150 ++++++++++++++++ go/web/routes_inspector.go | 163 +++++++++++++++++ go/web/routes_resizer.go | 111 ++++++++++++ go/web/server.go | 4 + 19 files changed, 2677 insertions(+) create mode 100644 go/internal/converter/constants.go create mode 100644 go/internal/converter/converter.go create mode 100644 go/internal/converter/scanner.go create mode 100644 go/internal/dupfinder/images.go create mode 100644 go/internal/dupfinder/scanner.go create mode 100644 go/internal/dupfinder/videos.go create mode 100644 go/internal/inspector/exif.go create mode 100644 go/internal/inspector/integrity.go create mode 100644 go/internal/inspector/scanner.go create mode 100644 go/internal/resizer/operations.go create mode 100644 go/internal/resizer/scanner.go create mode 100644 go/web/routes_converter.go create mode 100644 go/web/routes_dupfinder.go create mode 100644 go/web/routes_inspector.go create mode 100644 go/web/routes_resizer.go diff --git a/go/go.mod b/go/go.mod index d83f0ab..1eff467 100644 --- a/go/go.mod +++ b/go/go.mod @@ -3,6 +3,7 @@ module github.com/exterex/morphic go 1.22 require ( + github.com/corona10/goimagehash v1.1.0 github.com/disintegration/imaging v1.6.2 github.com/dsoprea/go-exif/v3 v3.0.1 github.com/gin-gonic/gin v1.10.0 @@ -30,6 +31,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect diff --git a/go/go.sum b/go/go.sum index c2c7b19..d33a3d9 100644 --- a/go/go.sum +++ b/go/go.sum @@ -6,6 +6,8 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI= +github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -75,6 +77,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/go/internal/converter/constants.go b/go/internal/converter/constants.go new file mode 100644 index 0000000..4827f9a --- /dev/null +++ b/go/internal/converter/constants.go @@ -0,0 +1,98 @@ +package converter + +import ( + "sort" + "strings" + + "github.com/exterex/morphic/internal/shared" +) + +// Canonical image formats we can write to. +var canonicalImage = map[string]struct{}{ + ".jpg": {}, + ".png": {}, + ".tif": {}, + ".bmp": {}, + ".webp": {}, + ".gif": {}, + ".ico": {}, + ".heic": {}, + ".heif": {}, + ".avif": {}, +} + +// Canonical video formats we can write to. +var canonicalVideo = map[string]struct{}{ + ".mp4": {}, + ".mov": {}, + ".avi": {}, + ".mkv": {}, + ".webm": {}, + ".flv": {}, + ".wmv": {}, + ".m4v": {}, + ".mpeg": {}, + ".3gp": {}, + ".ts": {}, +} + +// ImageConversions maps source image extension to list of target extensions. +var ImageConversions map[string][]string + +// VideoConversions maps source video extension to list of target extensions. +var VideoConversions map[string][]string + +func init() { + ImageConversions = make(map[string][]string) + for ext := range shared.ImageExtensions { + norm := shared.NormaliseExt(ext) + if _, ok := canonicalImage[norm]; !ok { + continue + } + var targets []string + for t := range canonicalImage { + if t != norm { + targets = append(targets, t) + } + } + sort.Strings(targets) + ImageConversions[ext] = targets + } + + VideoConversions = make(map[string][]string) + for ext := range shared.VideoExtensions { + norm := shared.NormaliseExt(ext) + if _, ok := canonicalVideo[norm]; !ok { + continue + } + var targets []string + for t := range canonicalVideo { + if t != norm { + targets = append(targets, t) + } + } + sort.Strings(targets) + VideoConversions[ext] = targets + } +} + +// GetCompatibleTargets returns the list of extensions a source can convert to. +func GetCompatibleTargets(sourcePath string) []string { + ext := shared.NormaliseExt(strings.ToLower(extOf(sourcePath))) + if targets, ok := ImageConversions[ext]; ok { + return targets + } + if targets, ok := VideoConversions[ext]; ok { + return targets + } + return nil +} + +func extOf(path string) string { + for i := len(path) - 1; i >= 0; i-- { + if path[i] == '.' { + return path[i:] + } + } + return "" +} diff --git a/go/internal/converter/converter.go b/go/internal/converter/converter.go new file mode 100644 index 0000000..6477cf5 --- /dev/null +++ b/go/internal/converter/converter.go @@ -0,0 +1,186 @@ +package converter + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/disintegration/imaging" + "github.com/exterex/morphic/internal/shared" +) + +// ffmpegAvailable checks if ffmpeg is on PATH. +func ffmpegAvailable() bool { + _, err := exec.LookPath("ffmpeg") + return err == nil +} + +// ffmpegHasEncoder checks if ffmpeg has a particular video encoder. +func ffmpegHasEncoder(encoder string) bool { + out, err := exec.Command("ffmpeg", "-hide_banner", "-encoders").Output() + if err != nil { + return false + } + for _, line := range strings.Split(string(out), "\n") { + if strings.Contains(line, encoder) { + return true + } + } + return false +} + +// getVideoEncoder selects a video encoder for the target extension. +// Returns (encoder, useHwaccel, outputExtension). +func getVideoEncoder(targetExt string) (string, bool, string) { + ext := strings.ToLower(strings.TrimPrefix(targetExt, ".")) + outputExt := ext + + // AV1 variants (e.g., "mp4-av1") + if strings.HasSuffix(ext, "-av1") { + outputExt = strings.Split(ext, "-")[0] + if ffmpegHasEncoder("libsvtav1") { + return "libsvtav1", false, outputExt + } + if ffmpegHasEncoder("libaom-av1") { + return "libaom-av1", false, outputExt + } + if ffmpegHasEncoder("libvpx-vp9") { + return "libvpx-vp9", false, outputExt + } + return "libx264", false, outputExt + } + + // Standard containers + switch outputExt { + case "mp4", "mkv", "mov": + return "libx264", false, outputExt + case "webm": + return "libvpx-vp9", false, outputExt + case "avi": + return "mpeg4", false, outputExt + case "flv", "mpeg", "3gp", "ts": + return "libx264", false, outputExt + } + + return "libx264", false, outputExt +} + +// ConvertImage converts an image file using the imaging library. +func ConvertImage(source, targetExt, outputDir string) (string, error) { + ext := shared.NormaliseExt(normaliseTargetExt(targetExt)) + + stem := strings.TrimSuffix(filepath.Base(source), filepath.Ext(source)) + var dest string + if outputDir != "" { + os.MkdirAll(outputDir, 0755) + dest = filepath.Join(outputDir, stem+ext) + } else { + dest = filepath.Join(filepath.Dir(source), stem+ext) + } + + // Avoid overwriting + if _, err := os.Stat(dest); err == nil { + dest = filepath.Join(filepath.Dir(dest), + strings.TrimSuffix(filepath.Base(dest), ext)+"_converted"+ext) + } + + img, err := imaging.Open(source) + if err != nil { + return "", err + } + + opts := []imaging.EncodeOption{} + extLower := strings.ToLower(ext) + if extLower == ".jpg" || extLower == ".jpeg" { + opts = append(opts, imaging.JPEGQuality(95)) + } + + if err := imaging.Save(img, dest, opts...); err != nil { + return "", err + } + + return dest, nil +} + +// ConvertVideo converts a video file using ffmpeg. +func ConvertVideo(source, targetExt, outputDir string, av1CRF int) (string, error) { + if !ffmpegAvailable() { + return "", fmt.Errorf("ffmpeg is not installed or not on PATH") + } + + ext := normaliseTargetExt(targetExt) + + codecTargetExt := ext + if strings.Contains(ext, "-av1") { + codecTargetExt = "." + strings.Split(strings.TrimPrefix(ext, "."), "-")[0] + } + + stem := strings.TrimSuffix(filepath.Base(source), filepath.Ext(source)) + var dest string + if outputDir != "" { + os.MkdirAll(outputDir, 0755) + dest = filepath.Join(outputDir, stem+codecTargetExt) + } else { + dest = filepath.Join(filepath.Dir(source), stem+codecTargetExt) + } + + // Avoid overwriting + if _, err := os.Stat(dest); err == nil { + dest = filepath.Join(filepath.Dir(dest), + strings.TrimSuffix(filepath.Base(dest), codecTargetExt)+"_converted"+codecTargetExt) + } + + var cmd []string + + // Stream-copy for container-only conversions + if codecTargetExt == ".mkv" || codecTargetExt == ".ts" { + cmd = []string{"ffmpeg", "-y", "-i", source, "-c", "copy", dest} + } else { + encoder, _, _ := getVideoEncoder(targetExt) + audioCodec := "aac" + if codecTargetExt == ".avi" { + audioCodec = "libmp3lame" + } + + cmd = []string{"ffmpeg", "-y", "-i", source, "-c:v", encoder, "-c:a", audioCodec} + + if strings.Contains(encoder, "av1") || encoder == "libaom-av1" || encoder == "libsvtav1" { + crf := 28 + if av1CRF >= 10 && av1CRF <= 63 { + crf = av1CRF + } + cmd = append(cmd, "-preset", "fast", "-crf", fmt.Sprintf("%d", crf)) + } else { + cmd = append(cmd, "-preset", "fast", "-crf", "23") + } + + cmd = append(cmd, dest) + } + + out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput() + if err != nil { + return "", fmt.Errorf("ffmpeg error: %s", strings.TrimSpace(string(out))) + } + + return dest, nil +} + +// ConvertFile is the high-level converter — routes to image or video handler. +func ConvertFile(source, targetExt, outputDir string, av1CRF int) (string, error) { + if shared.IsImage(source) { + return ConvertImage(source, targetExt, outputDir) + } + if shared.IsVideo(source) { + return ConvertVideo(source, targetExt, outputDir, av1CRF) + } + return "", fmt.Errorf("unsupported file type: %s", source) +} + +func normaliseTargetExt(ext string) string { + if !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + return shared.NormaliseExt(ext) +} diff --git a/go/internal/converter/scanner.go b/go/internal/converter/scanner.go new file mode 100644 index 0000000..0969782 --- /dev/null +++ b/go/internal/converter/scanner.go @@ -0,0 +1,130 @@ +package converter + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "github.com/exterex/morphic/internal/shared" +) + +// ScanResult holds the output of a folder scan. +type ScanResult struct { + Folder string `json:"folder"` + Summary map[string]int `json:"summary"` + Files []FileEntry `json:"files"` + Total int `json:"total"` +} + +// FileEntry describes one file found during scanning. +type FileEntry struct { + Path string `json:"path"` + Name string `json:"name"` + Ext string `json:"ext"` + Size int64 `json:"size"` + Type string `json:"type"` + Targets []string `json:"targets"` +} + +// ScanFolder walks folder and returns an inventory of convertible media files. +func ScanFolder(folder string, includeSubfolders bool, filterType string) (*ScanResult, error) { + allowed := make(map[string]struct{}) + if filterType == "images" || filterType == "both" { + for k, v := range shared.ImageExtensions { + allowed[k] = v + } + } + if filterType == "videos" || filterType == "both" { + for k, v := range shared.VideoExtensions { + allowed[k] = v + } + } + + summary := make(map[string]int) + var files []FileEntry + + if includeSubfolders { + filepath.WalkDir(folder, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + name := strings.ToLower(d.Name()) + if _, excl := shared.ExcludedFolders[name]; excl { + return filepath.SkipDir + } + return nil + } + addFileEntry(path, d, allowed, summary, &files) + return nil + }) + } else { + entries, err := os.ReadDir(folder) + if err != nil { + return nil, err + } + for _, d := range entries { + if d.IsDir() { + continue + } + path := filepath.Join(folder, d.Name()) + addFileEntry(path, d, allowed, summary, &files) + } + } + + // Sort summary by count descending + type kv struct { + K string + V int + } + var sorted []kv + for k, v := range summary { + sorted = append(sorted, kv{k, v}) + } + sort.Slice(sorted, func(i, j int) bool { return sorted[i].V > sorted[j].V }) + sortedSummary := make(map[string]int, len(sorted)) + for _, s := range sorted { + sortedSummary[s.K] = s.V + } + + // Sort files by name + sort.Slice(files, func(i, j int) bool { + return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) + }) + + return &ScanResult{ + Folder: folder, + Summary: sortedSummary, + Files: files, + Total: len(files), + }, nil +} + +func addFileEntry(path string, d os.DirEntry, allowed map[string]struct{}, summary map[string]int, files *[]FileEntry) { + ext := shared.NormaliseExt(strings.ToLower(filepath.Ext(path))) + if _, ok := allowed[ext]; !ok { + return + } + + var size int64 + if info, err := d.Info(); err == nil { + size = info.Size() + } + + ftype := "image" + if _, ok := shared.VideoExtensions[ext]; ok { + ftype = "video" + } + + targets := GetCompatibleTargets(path) + summary[ext]++ + *files = append(*files, FileEntry{ + Path: path, + Name: d.Name(), + Ext: ext, + Size: size, + Type: ftype, + Targets: targets, + }) +} diff --git a/go/internal/dupfinder/images.go b/go/internal/dupfinder/images.go new file mode 100644 index 0000000..b7ecb0d --- /dev/null +++ b/go/internal/dupfinder/images.go @@ -0,0 +1,182 @@ +package dupfinder + +import ( + "log" + "math/bits" + "os" + "sort" + "sync" + + "github.com/corona10/goimagehash" + "github.com/disintegration/imaging" + "github.com/exterex/morphic/internal/shared" +) + +// ImageInfo stores information about an image file. +type ImageInfo struct { + Path string `json:"path"` + Width int `json:"width"` + Height int `json:"height"` + FileSize int64 `json:"file_size"` + Format string `json:"format"` + PHash uint64 `json:"-"` + AHash uint64 `json:"-"` + DHash uint64 `json:"-"` + HasHash bool `json:"-"` +} + +// ComputeImageHashes loads an image and computes perceptual hashes. +func ComputeImageHashes(path string) ImageInfo { + info := ImageInfo{Path: path} + + st, err := os.Stat(path) + if err != nil { + return info + } + info.FileSize = st.Size() + + img, err := imaging.Open(path) + if err != nil { + log.Printf("dupfinder: cannot open image %s: %v", path, err) + return info + } + + bounds := img.Bounds() + info.Width = bounds.Dx() + info.Height = bounds.Dy() + + ph, err := goimagehash.PerceptionHash(img) + if err == nil { + info.PHash = ph.GetHash() + } + ah, err := goimagehash.AverageHash(img) + if err == nil { + info.AHash = ah.GetHash() + } + dh, err := goimagehash.DifferenceHash(img) + if err == nil { + info.DHash = dh.GetHash() + } + + info.HasHash = info.PHash != 0 || info.AHash != 0 || info.DHash != 0 + return info +} + +// ProcessImages hashes all images concurrently and returns successful results. +func ProcessImages(files []shared.FileInfo, numWorkers int) map[string]*ImageInfo { + result := make(map[string]*ImageInfo) + var mu sync.Mutex + var wg sync.WaitGroup + sem := make(chan struct{}, numWorkers) + + for _, f := range files { + wg.Add(1) + sem <- struct{}{} + go func(fi shared.FileInfo) { + defer wg.Done() + defer func() { <-sem }() + info := ComputeImageHashes(fi.Path) + if info.HasHash { + mu.Lock() + result[fi.Path] = &info + mu.Unlock() + } + }(f) + } + wg.Wait() + return result +} + +// hashSimilarity returns the similarity (0-1) between two 64-bit hashes. +func hashSimilarity(a, b uint64) float64 { + dist := bits.OnesCount64(a ^ b) + return 1.0 - float64(dist)/64.0 +} + +// ComputeSimilarity computes average similarity across phash, ahash, dhash. +func ComputeSimilarity(a, b *ImageInfo) float64 { + var total, count float64 + if a.PHash != 0 && b.PHash != 0 { + total += hashSimilarity(a.PHash, b.PHash) + count++ + } + if a.AHash != 0 && b.AHash != 0 { + total += hashSimilarity(a.AHash, b.AHash) + count++ + } + if a.DHash != 0 && b.DHash != 0 { + total += hashSimilarity(a.DHash, b.DHash) + count++ + } + if count == 0 { + return 0 + } + return total / count +} + +// DuplicateEntry represents one file in a duplicate group. +type DuplicateEntry struct { + Path string `json:"path"` + Similarity float64 `json:"similarity"` +} + +// FindImageDuplicates finds groups of duplicate images. +func FindImageDuplicates(infos map[string]*ImageInfo, threshold float64) [][]DuplicateEntry { + // Bucket exact PHash matches first + buckets := make(map[uint64][]string) + for path, info := range infos { + if info.PHash != 0 { + buckets[info.PHash] = append(buckets[info.PHash], path) + } + } + + var groups [][]DuplicateEntry + assigned := make(map[string]bool) + + // Exact hash groups + for _, paths := range buckets { + if len(paths) > 1 { + sort.Strings(paths) + var group []DuplicateEntry + for _, p := range paths { + group = append(group, DuplicateEntry{Path: p, Similarity: 1.0}) + assigned[p] = true + } + groups = append(groups, group) + } + } + + // Near-duplicate detection on remaining images + var remaining []string + for path := range infos { + if !assigned[path] { + remaining = append(remaining, path) + } + } + sort.Strings(remaining) + + for i := 0; i < len(remaining); i++ { + if assigned[remaining[i]] { + continue + } + group := []DuplicateEntry{{Path: remaining[i], Similarity: 1.0}} + + for j := i + 1; j < len(remaining); j++ { + if assigned[remaining[j]] { + continue + } + sim := ComputeSimilarity(infos[remaining[i]], infos[remaining[j]]) + if sim >= threshold { + group = append(group, DuplicateEntry{Path: remaining[j], Similarity: sim}) + assigned[remaining[j]] = true + } + } + + if len(group) > 1 { + assigned[remaining[i]] = true + groups = append(groups, group) + } + } + + return groups +} diff --git a/go/internal/dupfinder/scanner.go b/go/internal/dupfinder/scanner.go new file mode 100644 index 0000000..a61ec27 --- /dev/null +++ b/go/internal/dupfinder/scanner.go @@ -0,0 +1,280 @@ +package dupfinder + +import ( + "fmt" + "path/filepath" + "sort" + "sync" + "time" + + "github.com/exterex/morphic/internal/shared" +) + +// ScanJob represents a running or completed dupfinder job. +type ScanJob struct { + shared.Job + mu sync.Mutex + + Folder string `json:"folder"` + ScanType string `json:"scan_type"` // "images", "videos", "both" + ImageThreshold float64 `json:"image_threshold"` + VideoThreshold float64 `json:"video_threshold"` + ImageGroups [][]map[string]interface{} `json:"image_groups,omitempty"` + VideoGroups [][]map[string]interface{} `json:"video_groups,omitempty"` + TotalFound int `json:"total_files_found"` + TotalProcessed int `json:"total_files_processed"` + SpaceSavings int64 `json:"space_savings"` +} + +var store = shared.NewJobStore[ScanJob]() + +func init() { + store.StartCleanup(30*time.Minute, func(j *ScanJob) time.Time { + return j.DoneAt + }) +} + +// StartJob creates and launches a new dupfinder job. +func StartJob(folder, scanType string, imageThreshold, videoThreshold float64) string { + job := &ScanJob{ + Job: shared.NewJob(), + Folder: folder, + ScanType: scanType, + ImageThreshold: imageThreshold, + VideoThreshold: videoThreshold, + } + job.Status = shared.JobStatusRunning + store.Set(job.ID, job) + go runScan(job) + return job.ID +} + +// GetJob retrieves a job by ID. +func GetJob(id string) (*ScanJob, bool) { + return store.Get(id) +} + +func runScan(job *ScanJob) { + defer func() { + if r := recover(); r != nil { + job.mu.Lock() + job.Status = shared.JobStatusFailed + job.Error = fmt.Sprintf("%v", r) + job.DoneAt = time.Now() + job.mu.Unlock() + } + }() + + job.mu.Lock() + job.Message = fmt.Sprintf("Scanning folder: %s", job.Folder) + job.mu.Unlock() + + // Image scan + if job.ScanType == "images" || job.ScanType == "both" { + scanImages(job) + } + + // Video scan + if job.ScanType == "videos" || job.ScanType == "both" { + scanVideos(job) + } + + // Finalise + job.mu.Lock() + job.SpaceSavings = calculateSpaceSavings(job) + job.Status = shared.JobStatusDone + job.Progress = 1.0 + job.DoneAt = time.Now() + elapsed := job.DoneAt.Sub(job.StartedAt).Seconds() + totalGroups := len(job.ImageGroups) + len(job.VideoGroups) + job.Message = fmt.Sprintf("Done! Found %d duplicate group(s) in %.1fs. Potential savings: %s", + totalGroups, elapsed, shared.FormatFileSize(job.SpaceSavings)) + job.mu.Unlock() +} + +func scanImages(job *ScanJob) { + job.mu.Lock() + job.Message = "Finding image files..." + job.mu.Unlock() + + files, err := shared.FindImageFiles(job.Folder) + if err != nil { + return + } + + job.mu.Lock() + job.TotalFound += len(files) + job.Message = fmt.Sprintf("Found %d images. Processing hashes...", len(files)) + job.Progress = 0.1 + job.mu.Unlock() + + if len(files) == 0 { + return + } + + infos := ProcessImages(files, shared.DefaultNumWorkers) + + job.mu.Lock() + job.TotalProcessed += len(infos) + job.Progress = 0.4 + job.Message = fmt.Sprintf("Processed %d images. Finding duplicates...", len(infos)) + job.mu.Unlock() + + groups := FindImageDuplicates(infos, job.ImageThreshold) + formatted := formatImageGroups(groups, infos) + + job.mu.Lock() + job.ImageGroups = formatted + if job.ScanType == "both" { + job.Progress = 0.5 + } else { + job.Progress = 0.95 + } + job.mu.Unlock() +} + +func scanVideos(job *ScanJob) { + job.mu.Lock() + job.Message = "Finding video files..." + job.mu.Unlock() + + files, err := shared.FindVideoFiles(job.Folder) + if err != nil { + return + } + + job.mu.Lock() + job.TotalFound += len(files) + job.Message = fmt.Sprintf("Found %d videos. Processing hashes...", len(files)) + if job.ScanType == "both" { + job.Progress = 0.6 + } else { + job.Progress = 0.1 + } + job.mu.Unlock() + + if len(files) == 0 { + return + } + + infos := ProcessVideos(files, shared.DefaultNumFrames, shared.DefaultNumWorkers) + + job.mu.Lock() + job.TotalProcessed += len(infos) + if job.ScanType == "both" { + job.Progress = 0.8 + } else { + job.Progress = 0.7 + } + job.Message = fmt.Sprintf("Processed %d videos. Finding duplicates...", len(infos)) + job.mu.Unlock() + + groups := FindVideoDuplicates(infos, job.VideoThreshold) + formatted := formatVideoGroups(groups, infos) + + job.mu.Lock() + job.VideoGroups = formatted + job.Progress = 0.95 + job.mu.Unlock() +} + +func formatImageGroups(groups [][]DuplicateEntry, infos map[string]*ImageInfo) [][]map[string]interface{} { + var result [][]map[string]interface{} + for _, group := range groups { + // Sort by file size descending + sort.Slice(group, func(i, j int) bool { + ai := infos[group[i].Path] + aj := infos[group[j].Path] + if ai == nil || aj == nil { + return false + } + return ai.FileSize > aj.FileSize + }) + + var formatted []map[string]interface{} + for _, entry := range group { + info := infos[entry.Path] + if info == nil { + continue + } + formatted = append(formatted, map[string]interface{}{ + "path": entry.Path, + "filename": filepath.Base(entry.Path), + "directory": filepath.Dir(entry.Path), + "width": info.Width, + "height": info.Height, + "resolution": fmt.Sprintf("%dx%d", info.Width, info.Height), + "format": info.Format, + "file_size": info.FileSize, + "file_size_formatted": shared.FormatFileSize(info.FileSize), + "similarity": float64(int(entry.Similarity*1000)) / 10, + "type": "image", + }) + } + if len(formatted) > 1 { + result = append(result, formatted) + } + } + return result +} + +func formatVideoGroups(groups [][]DuplicateEntry, infos map[string]*VideoInfo) [][]map[string]interface{} { + var result [][]map[string]interface{} + for _, group := range groups { + sort.Slice(group, func(i, j int) bool { + ai := infos[group[i].Path] + aj := infos[group[j].Path] + if ai == nil || aj == nil { + return false + } + return ai.FileSize > aj.FileSize + }) + + var formatted []map[string]interface{} + for _, entry := range group { + info := infos[entry.Path] + if info == nil { + continue + } + formatted = append(formatted, map[string]interface{}{ + "path": entry.Path, + "filename": filepath.Base(entry.Path), + "directory": filepath.Dir(entry.Path), + "width": info.Width, + "height": info.Height, + "resolution": fmt.Sprintf("%dx%d", info.Width, info.Height), + "duration": info.Duration, + "duration_formatted": shared.FormatDuration(info.Duration), + "fps": float64(int(info.FPS*10)) / 10, + "file_size": info.FileSize, + "file_size_formatted": shared.FormatFileSize(info.FileSize), + "similarity": float64(int(entry.Similarity*1000)) / 10, + "type": "video", + }) + } + if len(formatted) > 1 { + result = append(result, formatted) + } + } + return result +} + +func calculateSpaceSavings(job *ScanJob) int64 { + var total int64 + allGroups := append(job.ImageGroups, job.VideoGroups...) + for _, group := range allGroups { + var sizes []int64 + for _, item := range group { + if s, ok := item["file_size"].(int64); ok { + sizes = append(sizes, s) + } + } + if len(sizes) > 1 { + sort.Slice(sizes, func(i, j int) bool { return sizes[i] < sizes[j] }) + for _, s := range sizes[:len(sizes)-1] { + total += s + } + } + } + return total +} diff --git a/go/internal/dupfinder/videos.go b/go/internal/dupfinder/videos.go new file mode 100644 index 0000000..b7d18a0 --- /dev/null +++ b/go/internal/dupfinder/videos.go @@ -0,0 +1,233 @@ +package dupfinder + +import ( + "fmt" + "log" + "math/bits" + "os" + "os/exec" + "sort" + "strconv" + "strings" + "sync" + + "github.com/corona10/goimagehash" + "github.com/disintegration/imaging" + "github.com/exterex/morphic/internal/shared" +) + +// VideoInfo stores information about a video file. +type VideoInfo struct { + Path string `json:"path"` + Duration float64 `json:"duration"` + FPS float64 `json:"fps"` + FrameCount int `json:"frame_count"` + Width int `json:"width"` + Height int `json:"height"` + FileSize int64 `json:"file_size"` + FrameHashes []uint64 `json:"-"` + HasHash bool `json:"-"` +} + +// ComputeVideoHashes extracts frames and computes perceptual hashes. +func ComputeVideoHashes(path string, numFrames int) VideoInfo { + info := VideoInfo{Path: path} + + st, err := os.Stat(path) + if err != nil { + return info + } + info.FileSize = st.Size() + + // Get video metadata via ffprobe + probeOut, err := exec.Command("ffprobe", + "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height,duration,r_frame_rate,nb_frames", + "-of", "csv=p=0", + path, + ).Output() + if err != nil { + log.Printf("dupfinder: ffprobe failed for %s: %v", path, err) + return info + } + + parts := strings.Split(strings.TrimSpace(string(probeOut)), ",") + if len(parts) >= 1 { + info.Width, _ = strconv.Atoi(parts[0]) + } + if len(parts) >= 2 { + info.Height, _ = strconv.Atoi(parts[1]) + } + if len(parts) >= 3 && parts[2] != "" && parts[2] != "N/A" { + info.Duration, _ = strconv.ParseFloat(parts[2], 64) + } + if len(parts) >= 4 { + info.FPS = parseFPS(parts[3]) + } + if len(parts) >= 5 { + info.FrameCount, _ = strconv.Atoi(parts[4]) + } + + if info.Duration <= 0 && info.FrameCount > 0 && info.FPS > 0 { + info.Duration = float64(info.FrameCount) / info.FPS + } + + if info.Duration <= 0 { + return info + } + + // Extract frames at regular intervals using ffmpeg + frameHashes := extractAndHashFrames(path, info.Duration, numFrames) + info.FrameHashes = frameHashes + info.HasHash = len(frameHashes) > 0 + + return info +} + +// extractAndHashFrames extracts frames at intervals and hashes them. +func extractAndHashFrames(path string, duration float64, numFrames int) []uint64 { + startTime := duration * 0.05 + endTime := duration * 0.95 + if endTime <= startTime { + startTime = 0 + endTime = duration + } + + interval := (endTime - startTime) / float64(numFrames+1) + var hashes []uint64 + + for i := 0; i < numFrames; i++ { + ts := startTime + float64(i+1)*interval + frameFile := fmt.Sprintf("/tmp/morphic_frame_%d_%d.jpg", os.Getpid(), i) + + cmd := exec.Command("ffmpeg", "-y", + "-ss", fmt.Sprintf("%.3f", ts), + "-i", path, + "-vframes", "1", + "-q:v", "2", + frameFile, + ) + cmd.Stdout = nil + cmd.Stderr = nil + + if err := cmd.Run(); err != nil { + continue + } + + img, err := imaging.Open(frameFile) + os.Remove(frameFile) + if err != nil { + continue + } + + ph, err := goimagehash.PerceptionHash(img) + if err != nil { + continue + } + hashes = append(hashes, ph.GetHash()) + } + + return hashes +} + +func parseFPS(s string) float64 { + s = strings.TrimSpace(s) + if strings.Contains(s, "/") { + parts := strings.Split(s, "/") + if len(parts) == 2 { + num, err1 := strconv.ParseFloat(parts[0], 64) + den, err2 := strconv.ParseFloat(parts[1], 64) + if err1 == nil && err2 == nil && den != 0 { + return num / den + } + } + } + f, _ := strconv.ParseFloat(s, 64) + return f +} + +// ProcessVideos hashes all videos concurrently and returns successful results. +func ProcessVideos(files []shared.FileInfo, numFrames, numWorkers int) map[string]*VideoInfo { + result := make(map[string]*VideoInfo) + var mu sync.Mutex + var wg sync.WaitGroup + sem := make(chan struct{}, numWorkers) + + for _, f := range files { + wg.Add(1) + sem <- struct{}{} + go func(fi shared.FileInfo) { + defer wg.Done() + defer func() { <-sem }() + info := ComputeVideoHashes(fi.Path, numFrames) + if info.HasHash { + mu.Lock() + result[fi.Path] = &info + mu.Unlock() + } + }(f) + } + wg.Wait() + return result +} + +// ComputeVideoSimilarity computes similarity between two videos using +// frame-level hash comparison. +func ComputeVideoSimilarity(a, b *VideoInfo) float64 { + if len(a.FrameHashes) == 0 || len(b.FrameHashes) == 0 { + return 0 + } + + var total float64 + for _, h1 := range a.FrameHashes { + bestSim := 0.0 + for _, h2 := range b.FrameHashes { + dist := bits.OnesCount64(h1 ^ h2) + sim := 1.0 - float64(dist)/64.0 + if sim > bestSim { + bestSim = sim + } + } + total += bestSim + } + + return total / float64(len(a.FrameHashes)) +} + +// FindVideoDuplicates finds groups of duplicate videos. +func FindVideoDuplicates(infos map[string]*VideoInfo, threshold float64) [][]DuplicateEntry { + paths := make([]string, 0, len(infos)) + for p := range infos { + paths = append(paths, p) + } + sort.Strings(paths) + + assigned := make(map[string]bool) + var groups [][]DuplicateEntry + + for i := 0; i < len(paths); i++ { + if assigned[paths[i]] { + continue + } + group := []DuplicateEntry{{Path: paths[i], Similarity: 1.0}} + + for j := i + 1; j < len(paths); j++ { + if assigned[paths[j]] { + continue + } + sim := ComputeVideoSimilarity(infos[paths[i]], infos[paths[j]]) + if sim >= threshold { + group = append(group, DuplicateEntry{Path: paths[j], Similarity: sim}) + assigned[paths[j]] = true + } + } + + if len(group) > 1 { + assigned[paths[i]] = true + groups = append(groups, group) + } + } + + return groups +} diff --git a/go/internal/inspector/exif.go b/go/internal/inspector/exif.go new file mode 100644 index 0000000..bd70b1a --- /dev/null +++ b/go/internal/inspector/exif.go @@ -0,0 +1,173 @@ +package inspector + +import ( + "fmt" + "math" + "os" + + exif "github.com/dsoprea/go-exif/v3" + exifcommon "github.com/dsoprea/go-exif/v3/common" +) + +// ExifData is a flat map of human-readable tag names to values. +type ExifData map[string]interface{} + +// ReadExif reads EXIF metadata from an image file and returns a flat +// map of tag names to decoded values. Includes "_gps_lat" and "_gps_lng" +// when GPS data is present. +func ReadExif(path string) (ExifData, error) { + if _, err := os.Stat(path); err != nil { + return nil, fmt.Errorf("file not found: %s", path) + } + + rawExif, err := exif.SearchFileAndExtractExif(path) + if err != nil { + // No EXIF data found — not an error, just empty + return ExifData{}, nil + } + + tags, _, err := exif.GetFlatExifData(rawExif, nil) + if err != nil { + return ExifData{}, nil + } + + result := make(ExifData, len(tags)) + var latVal, lngVal []exifcommon.Rational + var latRef, lngRef string + + for _, tag := range tags { + name := tag.TagName + result[name] = tag.FormattedFirst + + // Collect GPS fields for decimal conversion + switch tag.TagName { + case "GPSLatitude": + if vals, ok := tag.Value.([]exifcommon.Rational); ok { + latVal = vals + } + case "GPSLatitudeRef": + latRef = tag.FormattedFirst + case "GPSLongitude": + if vals, ok := tag.Value.([]exifcommon.Rational); ok { + lngVal = vals + } + case "GPSLongitudeRef": + lngRef = tag.FormattedFirst + } + } + + // Compute decimal GPS coordinates + if len(latVal) >= 3 && len(lngVal) >= 3 { + result["_gps_lat"] = gpsToDecimal(latVal, latRef) + result["_gps_lng"] = gpsToDecimal(lngVal, lngRef) + } + + return result, nil +} + +// gpsToDecimal converts DMS rational values to decimal degrees. +func gpsToDecimal(coords []exifcommon.Rational, ref string) float64 { + deg := float64(coords[0].Numerator) / float64(coords[0].Denominator) + min := float64(coords[1].Numerator) / float64(coords[1].Denominator) + sec := float64(coords[2].Numerator) / float64(coords[2].Denominator) + decimal := deg + min/60 + sec/3600 + if ref == "S" || ref == "W" { + decimal = -decimal + } + return math.Round(decimal*1e6) / 1e6 +} + +// StripExif removes all EXIF metadata from an image file by reading the +// image data and rewriting it without the EXIF segment. For JPEG files this +// uses piexif-style segment removal. +func StripExif(path string) error { + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("file not found: %s", path) + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + + // Check if there's EXIF to strip + _, err = exif.SearchAndExtractExif(data) + if err != nil { + // No EXIF to strip + return nil + } + + stripped, err := dropExifFromJPEG(data) + if err != nil { + return err + } + + return os.WriteFile(path, stripped, 0644) +} + +// dropExifFromJPEG removes APP1 (EXIF) segments from JPEG data. +func dropExifFromJPEG(data []byte) ([]byte, error) { + if len(data) < 2 || data[0] != 0xFF || data[1] != 0xD8 { + return nil, fmt.Errorf("not a JPEG file") + } + + out := []byte{0xFF, 0xD8} + i := 2 + for i < len(data)-1 { + if data[i] != 0xFF { + // Reached image data, copy the rest + out = append(out, data[i:]...) + break + } + marker := data[i+1] + + // SOS (start of scan) — everything after this is image data + if marker == 0xDA { + out = append(out, data[i:]...) + break + } + + // Markers without length + if marker == 0xD8 || marker == 0xD9 || (marker >= 0xD0 && marker <= 0xD7) { + out = append(out, data[i], data[i+1]) + i += 2 + continue + } + + if i+3 >= len(data) { + out = append(out, data[i:]...) + break + } + + segLen := int(data[i+2])<<8 | int(data[i+3]) + segEnd := i + 2 + segLen + if segEnd > len(data) { + segEnd = len(data) + } + + // APP1 marker (0xE1) is where EXIF lives — skip it + if marker == 0xE1 { + i = segEnd + continue + } + + out = append(out, data[i:segEnd]...) + i = segEnd + } + + return out, nil +} + +// StripExifBatch strips EXIF from multiple files and returns per-file results. +func StripExifBatch(paths []string) map[string]map[string]interface{} { + results := make(map[string]map[string]interface{}, len(paths)) + for _, p := range paths { + err := StripExif(p) + if err != nil { + results[p] = map[string]interface{}{"success": false, "error": err.Error()} + } else { + results[p] = map[string]interface{}{"success": true} + } + } + return results +} diff --git a/go/internal/inspector/integrity.go b/go/internal/inspector/integrity.go new file mode 100644 index 0000000..37848e7 --- /dev/null +++ b/go/internal/inspector/integrity.go @@ -0,0 +1,169 @@ +package inspector + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "sync" + + "github.com/disintegration/imaging" + "github.com/exterex/morphic/internal/shared" +) + +// IntegrityResult holds the outcome of a single file check. +type IntegrityResult struct { + Path string `json:"path"` + Valid bool `json:"valid"` + Error string `json:"error,omitempty"` + Size int64 `json:"size"` + SizeFormatted string `json:"size_formatted"` + Width int `json:"width"` + Height int `json:"height"` + Format string `json:"format,omitempty"` + Duration float64 `json:"duration,omitempty"` + Codec string `json:"codec,omitempty"` + Type string `json:"type"` + Filename string `json:"filename,omitempty"` + Directory string `json:"directory,omitempty"` +} + +// CheckImage validates an image file by opening and decoding it. +func CheckImage(path string) IntegrityResult { + r := IntegrityResult{Path: path, Type: "image"} + + info, err := os.Stat(path) + if err != nil { + r.Error = "File not found" + return r + } + r.Size = info.Size() + r.SizeFormatted = shared.FormatFileSize(r.Size) + + if r.Size == 0 { + r.Error = "Zero-byte file" + return r + } + + img, err := imaging.Open(path) + if err != nil { + r.Error = err.Error() + return r + } + + bounds := img.Bounds() + r.Width = bounds.Dx() + r.Height = bounds.Dy() + r.Valid = true + return r +} + +// CheckVideo validates a video file using ffprobe. +func CheckVideo(path string) IntegrityResult { + r := IntegrityResult{Path: path, Type: "video"} + + info, err := os.Stat(path) + if err != nil { + r.Error = "File not found" + return r + } + r.Size = info.Size() + r.SizeFormatted = shared.FormatFileSize(r.Size) + + if r.Size == 0 { + r.Error = "Zero-byte file" + return r + } + + cmd := exec.Command("ffprobe", + "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=codec_name,width,height,duration", + "-of", "csv=p=0", + path, + ) + + out, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := strings.TrimSpace(string(exitErr.Stderr)) + if stderr != "" { + r.Error = stderr + } else { + r.Error = fmt.Sprintf("ffprobe exit code %d", exitErr.ExitCode()) + } + } else { + r.Error = err.Error() + } + return r + } + + output := strings.TrimSpace(string(out)) + if output == "" { + r.Error = "No video stream found" + return r + } + + parts := strings.Split(output, ",") + if len(parts) >= 1 { + r.Codec = parts[0] + } + if len(parts) >= 2 { + r.Width, _ = strconv.Atoi(parts[1]) + } + if len(parts) >= 3 { + r.Height, _ = strconv.Atoi(parts[2]) + } + if len(parts) >= 4 && parts[3] != "" { + r.Duration, _ = strconv.ParseFloat(parts[3], 64) + } + + r.Valid = true + return r +} + +// CheckFiles checks integrity of all media files in a folder using +// concurrent goroutines. +func CheckFiles(folder string, maxWorkers int) ([]IntegrityResult, error) { + allExts := make(map[string]struct{}) + for k, v := range shared.ImageExtensions { + allExts[k] = v + } + for k, v := range shared.VideoExtensions { + allExts[k] = v + } + + files, err := shared.FindFilesByExtension(folder, allExts, shared.ExcludedFolders) + if err != nil { + return nil, err + } + + results := make([]IntegrityResult, len(files)) + var wg sync.WaitGroup + sem := make(chan struct{}, maxWorkers) + + for i, fi := range files { + wg.Add(1) + sem <- struct{}{} + go func(idx int, f shared.FileInfo) { + defer wg.Done() + defer func() { <-sem }() + + if shared.IsImage(f.Path) { + results[idx] = CheckImage(f.Path) + } else if shared.IsVideo(f.Path) { + results[idx] = CheckVideo(f.Path) + } else { + results[idx] = IntegrityResult{ + Path: f.Path, + Type: "unknown", + Error: "Unknown file type", + } + } + }(i, fi) + } + + wg.Wait() + return results, nil +} diff --git a/go/internal/inspector/scanner.go b/go/internal/inspector/scanner.go new file mode 100644 index 0000000..dfef8a4 --- /dev/null +++ b/go/internal/inspector/scanner.go @@ -0,0 +1,213 @@ +package inspector + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/exterex/morphic/internal/shared" +) + +// ScanJob represents a running or completed inspector job. +type ScanJob struct { + shared.Job + mu sync.Mutex + + Folder string `json:"folder"` + Mode string `json:"mode"` // "exif" or "integrity" + Results []map[string]interface{} `json:"results,omitempty"` + Total int `json:"total"` + Processed int `json:"processed"` +} + +var store = shared.NewJobStore[ScanJob]() + +func init() { + store.StartCleanup(30*time.Minute, func(j *ScanJob) time.Time { + return j.DoneAt + }) +} + +// StartJob creates and launches a new inspector job. +func StartJob(folder, mode string) string { + job := &ScanJob{ + Job: shared.NewJob(), + Folder: folder, + Mode: mode, + } + job.Status = shared.JobStatusRunning + store.Set(job.ID, job) + go runScan(job) + return job.ID +} + +// GetJob retrieves a job by ID. +func GetJob(id string) (*ScanJob, bool) { + return store.Get(id) +} + +func runScan(job *ScanJob) { + defer func() { + if r := recover(); r != nil { + job.mu.Lock() + job.Status = shared.JobStatusFailed + job.Error = fmt.Sprintf("%v", r) + job.DoneAt = time.Now() + job.mu.Unlock() + } + }() + + job.mu.Lock() + job.Message = fmt.Sprintf("Scanning folder: %s", job.Folder) + job.mu.Unlock() + + // Determine extensions to look for + var exts map[string]struct{} + if job.Mode == "exif" { + exts = shared.ImageExtensions + } else { + exts = make(map[string]struct{}) + for k, v := range shared.ImageExtensions { + exts[k] = v + } + for k, v := range shared.VideoExtensions { + exts[k] = v + } + } + + files, err := shared.FindFilesByExtension(job.Folder, exts, shared.ExcludedFolders) + if err != nil { + job.mu.Lock() + job.Status = shared.JobStatusFailed + job.Error = err.Error() + job.DoneAt = time.Now() + job.mu.Unlock() + return + } + + job.mu.Lock() + job.Total = len(files) + job.mu.Unlock() + + if len(files) == 0 { + job.mu.Lock() + job.Status = shared.JobStatusDone + job.Progress = 1.0 + job.DoneAt = time.Now() + job.Message = "No files found." + job.mu.Unlock() + return + } + + if job.Mode == "exif" { + scanExif(job, files) + } else { + scanIntegrity(job, files) + } + + job.mu.Lock() + job.Status = shared.JobStatusDone + job.Progress = 1.0 + job.DoneAt = time.Now() + elapsed := job.DoneAt.Sub(job.StartedAt).Seconds() + job.Message = fmt.Sprintf("Done! Processed %d files in %s.", + job.Processed, shared.FormatDuration(elapsed)) + job.mu.Unlock() +} + +func scanExif(job *ScanJob, files []shared.FileInfo) { + for i, f := range files { + if !shared.IsImage(f.Path) { + continue + } + + item := map[string]interface{}{ + "path": f.Path, + "filename": filepath.Base(f.Path), + "directory": filepath.Dir(f.Path), + "has_exif": false, + "has_gps": false, + } + + exifData, err := ReadExif(f.Path) + if err != nil { + item["exif"] = ExifData{} + item["error"] = err.Error() + } else { + item["exif"] = exifData + item["has_exif"] = len(exifData) > 0 + _, hasGPS := exifData["_gps_lat"] + item["has_gps"] = hasGPS + } + + job.mu.Lock() + job.Results = append(job.Results, item) + job.Processed = i + 1 + job.Progress = float64(i+1) / float64(job.Total) + job.Message = fmt.Sprintf("Reading EXIF: %d/%d", i+1, job.Total) + job.mu.Unlock() + } +} + +func scanIntegrity(job *ScanJob, files []shared.FileInfo) { + for i, f := range files { + var result IntegrityResult + if shared.IsImage(f.Path) { + result = CheckImage(f.Path) + } else if shared.IsVideo(f.Path) { + result = CheckVideo(f.Path) + } else { + result = IntegrityResult{ + Path: f.Path, + Type: "unknown", + Error: "Unknown file type", + } + } + result.Filename = filepath.Base(f.Path) + result.Directory = filepath.Dir(f.Path) + + item := map[string]interface{}{ + "path": result.Path, + "valid": result.Valid, + "error": nilIfEmpty(result.Error), + "size": result.Size, + "size_formatted": result.SizeFormatted, + "width": result.Width, + "height": result.Height, + "type": result.Type, + "filename": result.Filename, + "directory": result.Directory, + } + if result.Format != "" { + item["format"] = result.Format + } + if result.Codec != "" { + item["codec"] = result.Codec + } + if result.Duration > 0 { + item["duration"] = result.Duration + } + + job.mu.Lock() + job.Results = append(job.Results, item) + job.Processed = i + 1 + job.Progress = float64(i+1) / float64(job.Total) + job.Message = fmt.Sprintf("Checking: %d/%d", i+1, job.Total) + job.mu.Unlock() + } +} + +func nilIfEmpty(s string) interface{} { + if s == "" { + return nil + } + return s +} + +// Stat checks if a path exists. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/go/internal/resizer/operations.go b/go/internal/resizer/operations.go new file mode 100644 index 0000000..9cb6a20 --- /dev/null +++ b/go/internal/resizer/operations.go @@ -0,0 +1,128 @@ +package resizer + +import ( + "fmt" + "image" + "image/color" + "image/draw" + "image/png" + "os" + "path/filepath" + "strings" + + "github.com/disintegration/imaging" +) + +// ResizeModes lists the valid resize mode strings. +var ResizeModes = []string{"fit", "fill", "stretch", "pad"} + +// IsValidMode returns true if mode is one of the accepted values. +func IsValidMode(mode string) bool { + for _, m := range ResizeModes { + if m == mode { + return true + } + } + return false +} + +// ResizeImage resizes a single image. +// +// Modes: +// - "fit": resize to fit within width×height, preserving aspect ratio +// - "fill": resize to cover width×height then center-crop +// - "stretch": resize to exact width×height ignoring ratio +// - "pad": fit + pad borders with bgColor +// +// Returns the path to the output file. +func ResizeImage( + path string, + width, height int, + mode string, + outputFolder string, + bgColor string, + quality int, + outputFormat string, +) (string, error) { + if _, err := os.Stat(path); err != nil { + return "", fmt.Errorf("file not found: %s", path) + } + if !IsValidMode(mode) { + return "", fmt.Errorf("invalid mode '%s'", mode) + } + if width <= 0 || height <= 0 { + return "", fmt.Errorf("width and height must be positive") + } + + img, err := imaging.Open(path) + if err != nil { + return "", err + } + + var result *image.NRGBA + + switch mode { + case "fit": + result = imaging.Fit(img, width, height, imaging.Lanczos) + case "fill": + result = imaging.Fill(img, width, height, imaging.Center, imaging.Lanczos) + case "stretch": + result = imaging.Resize(img, width, height, imaging.Lanczos) + case "pad": + fitted := imaging.Fit(img, width, height, imaging.Lanczos) + bg := parseHexColor(bgColor) + canvas := imaging.New(width, height, bg) + offsetX := (width - fitted.Bounds().Dx()) / 2 + offsetY := (height - fitted.Bounds().Dy()) / 2 + draw.Draw(canvas, image.Rect(offsetX, offsetY, + offsetX+fitted.Bounds().Dx(), offsetY+fitted.Bounds().Dy()), + fitted, image.Point{}, draw.Over) + result = canvas + } + + // Determine output path + srcExt := filepath.Ext(path) + ext := outputFormat + if ext == "" { + ext = srcExt + } + if !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + + stem := strings.TrimSuffix(filepath.Base(path), srcExt) + var dest string + if outputFolder != "" { + os.MkdirAll(outputFolder, 0755) + dest = filepath.Join(outputFolder, stem+ext) + } else { + dest = filepath.Join(filepath.Dir(path), stem+ext) + } + + // imaging.Save handles format detection from extension + opts := []imaging.EncodeOption{} + extLower := strings.ToLower(ext) + if extLower == ".jpg" || extLower == ".jpeg" || extLower == ".webp" { + opts = append(opts, imaging.JPEGQuality(quality)) + } + if extLower == ".png" { + opts = append(opts, imaging.PNGCompressionLevel(png.DefaultCompression)) + } + + if err := imaging.Save(result, dest, opts...); err != nil { + return "", err + } + + return dest, nil +} + +// parseHexColor parses a CSS hex colour like "#FF0000" to a color.NRGBA. +func parseHexColor(s string) color.NRGBA { + s = strings.TrimPrefix(s, "#") + if len(s) != 6 { + return color.NRGBA{A: 255} // fallback to black + } + var r, g, b uint8 + fmt.Sscanf(s, "%02x%02x%02x", &r, &g, &b) + return color.NRGBA{R: r, G: g, B: b, A: 255} +} diff --git a/go/internal/resizer/scanner.go b/go/internal/resizer/scanner.go new file mode 100644 index 0000000..97afc24 --- /dev/null +++ b/go/internal/resizer/scanner.go @@ -0,0 +1,153 @@ +package resizer + +import ( + "fmt" + "os" + "sync" + "time" + + "github.com/exterex/morphic/internal/shared" +) + +// ScanJob represents a running or completed resize job. +type ScanJob struct { + shared.Job + mu sync.Mutex + + Folder string `json:"folder"` + Width int `json:"width"` + Height int `json:"height"` + Mode string `json:"mode"` + OutputFolder string `json:"output_folder,omitempty"` + BgColor string `json:"bg_color"` + Quality int `json:"quality"` + Total int `json:"total"` + Processed int `json:"processed"` + Results []map[string]interface{} `json:"results,omitempty"` + Errors []map[string]interface{} `json:"errors,omitempty"` +} + +var store = shared.NewJobStore[ScanJob]() + +func init() { + store.StartCleanup(30*time.Minute, func(j *ScanJob) time.Time { + return j.DoneAt + }) +} + +// StartJob creates and launches a new resize job. +func StartJob(folder string, width, height int, mode, outputFolder, bgColor string, quality int) string { + job := &ScanJob{ + Job: shared.NewJob(), + Folder: folder, + Width: width, + Height: height, + Mode: mode, + OutputFolder: outputFolder, + BgColor: bgColor, + Quality: quality, + } + job.Status = shared.JobStatusRunning + store.Set(job.ID, job) + go runResize(job) + return job.ID +} + +// GetJob retrieves a job by ID. +func GetJob(id string) (*ScanJob, bool) { + return store.Get(id) +} + +func runResize(job *ScanJob) { + defer func() { + if r := recover(); r != nil { + job.mu.Lock() + job.Status = shared.JobStatusFailed + job.Error = fmt.Sprintf("%v", r) + job.DoneAt = time.Now() + job.mu.Unlock() + } + }() + + job.mu.Lock() + job.Message = fmt.Sprintf("Scanning folder: %s", job.Folder) + job.mu.Unlock() + + files, err := shared.FindImageFiles(job.Folder) + if err != nil { + job.mu.Lock() + job.Status = shared.JobStatusFailed + job.Error = err.Error() + job.DoneAt = time.Now() + job.mu.Unlock() + return + } + + job.mu.Lock() + job.Total = len(files) + job.mu.Unlock() + + if len(files) == 0 { + job.mu.Lock() + job.Status = shared.JobStatusDone + job.Progress = 1.0 + job.DoneAt = time.Now() + job.Message = "No image files found." + job.mu.Unlock() + return + } + + errCount := 0 + for i, f := range files { + origSize := f.Size + dest, resizeErr := ResizeImage( + f.Path, job.Width, job.Height, job.Mode, + job.OutputFolder, job.BgColor, job.Quality, "", + ) + + result := map[string]interface{}{ + "source": f.Path, + } + + if resizeErr != nil { + errCount++ + result["destination"] = nil + result["status"] = "error" + result["error"] = resizeErr.Error() + + job.mu.Lock() + job.Errors = append(job.Errors, map[string]interface{}{ + "path": f.Path, + "error": resizeErr.Error(), + }) + job.mu.Unlock() + } else { + var newSize int64 + if info, err := os.Stat(dest); err == nil { + newSize = info.Size() + } + result["destination"] = dest + result["status"] = "ok" + result["original_size"] = origSize + result["new_size"] = newSize + result["original_size_fmt"] = shared.FormatFileSize(origSize) + result["new_size_fmt"] = shared.FormatFileSize(newSize) + } + + job.mu.Lock() + job.Results = append(job.Results, result) + job.Processed = i + 1 + job.Progress = float64(i+1) / float64(job.Total) + job.Message = fmt.Sprintf("Resizing: %d/%d (%d errors)", i+1, job.Total, errCount) + job.mu.Unlock() + } + + job.mu.Lock() + job.Status = shared.JobStatusDone + job.Progress = 1.0 + job.DoneAt = time.Now() + elapsed := job.DoneAt.Sub(job.StartedAt).Seconds() + job.Message = fmt.Sprintf("Done! Resized %d images in %s. %d error(s).", + job.Processed, shared.FormatDuration(elapsed), errCount) + job.mu.Unlock() +} diff --git a/go/internal/shared/utils.go b/go/internal/shared/utils.go index 15662fd..7d129c0 100644 --- a/go/internal/shared/utils.go +++ b/go/internal/shared/utils.go @@ -126,6 +126,29 @@ func FormatFileSize(sizeBytes int64) string { return fmt.Sprintf("%.2f TB", size) } +// NormaliseExt lowercases and resolves aliases (.jpeg → .jpg). +func NormaliseExt(ext string) string { + ext = strings.ToLower(ext) + if alias, ok := Aliases[ext]; ok { + return alias + } + return ext +} + +// IsImage returns true if the file extension is a known image type. +func IsImage(path string) bool { + ext := NormaliseExt(strings.ToLower(filepath.Ext(path))) + _, ok := ImageExtensions[ext] + return ok +} + +// IsVideo returns true if the file extension is a known video type. +func IsVideo(path string) bool { + ext := NormaliseExt(strings.ToLower(filepath.Ext(path))) + _, ok := VideoExtensions[ext] + return ok +} + // FormatDuration formats duration in human-readable format. func FormatDuration(seconds float64) string { hours := int(seconds) / 3600 diff --git a/go/web/routes_converter.go b/go/web/routes_converter.go new file mode 100644 index 0000000..a6641ed --- /dev/null +++ b/go/web/routes_converter.go @@ -0,0 +1,275 @@ +package web + +import ( + "net/http" + "os" + "time" + + "github.com/gin-gonic/gin" + "github.com/exterex/morphic/internal/converter" + "github.com/exterex/morphic/internal/shared" +) + +var conversionStore = shared.NewJobStore[conversionJob]() + +type conversionJob struct { + shared.Job + Total int `json:"total"` + Completed int `json:"completed"` + CurrentFile string `json:"current_file"` + Results []map[string]interface{} `json:"results"` +} + +func init() { + conversionStore.StartCleanup(30*time.Minute, func(j *conversionJob) time.Time { + return j.DoneAt + }) +} + +func registerConverterRoutes(r *gin.Engine) { + g := r.Group("/api/converter") + { + g.POST("/scan", handleConverterScan) + g.GET("/formats", handleConverterFormats) + g.POST("/convert", handleConverterConvert) + g.GET("/progress/:id", handleConverterProgress) + g.GET("/progress/:id/poll", handleConverterPoll) + g.POST("/delete", handleConverterDelete) + } +} + +func handleConverterScan(c *gin.Context) { + var req struct { + Folder string `json:"folder"` + IncludeSubfolders *bool `json:"include_subfolders"` + FilterType string `json:"filter_type"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if req.Folder == "" || !isDir(req.Folder) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid folder: " + req.Folder}) + return + } + includeSub := true + if req.IncludeSubfolders != nil { + includeSub = *req.IncludeSubfolders + } + filterType := req.FilterType + if filterType != "images" && filterType != "videos" && filterType != "both" { + filterType = "both" + } + + result, err := converter.ScanFolder(req.Folder, includeSub, filterType) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, result) +} + +func handleConverterFormats(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "image": converter.ImageConversions, + "video": converter.VideoConversions, + }) +} + +func handleConverterConvert(c *gin.Context) { + var req struct { + Files []string `json:"files"` + TargetExt string `json:"target_ext"` + DeleteOriginal bool `json:"delete_original"` + AV1CRF *int `json:"av1_crf"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if len(req.Files) == 0 || req.TargetExt == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "files and target_ext required"}) + return + } + + av1CRF := 0 + if req.AV1CRF != nil { + av1CRF = *req.AV1CRF + } + + job := &conversionJob{ + Job: shared.NewJob(), + Total: len(req.Files), + } + job.Status = shared.JobStatusRunning + conversionStore.Set(job.ID, job) + + go runConversion(job, req.Files, req.TargetExt, req.DeleteOriginal, av1CRF) + + c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID}) +} + +func runConversion(job *conversionJob, files []string, targetExt string, deleteOriginal bool, av1CRF int) { + for i, source := range files { + job.CurrentFile = source + + result := map[string]interface{}{ + "source": source, + "source_deleted": false, + } + + origSize := int64(0) + if info, err := os.Stat(source); err == nil { + origSize = info.Size() + } + + dest, err := converter.ConvertFile(source, targetExt, "", av1CRF) + if err != nil { + result["destination"] = nil + result["status"] = "error" + result["error"] = err.Error() + } else { + newSize := int64(0) + if info, err := os.Stat(dest); err == nil { + newSize = info.Size() + } + + result["destination"] = dest + result["status"] = "ok" + result["original_size"] = origSize + result["new_size"] = newSize + result["original_size_fmt"] = shared.FormatFileSize(origSize) + result["new_size_fmt"] = shared.FormatFileSize(newSize) + + // Delete original only if explicitly requested and safe + if deleteOriginal && dest != "" { + absSrc, _ := absPath(source) + absDest, _ := absPath(dest) + if absSrc != absDest && newSize > 0 { + if err := os.Remove(source); err == nil { + result["source_deleted"] = true + } + } + } + } + + job.Results = append(job.Results, result) + job.Completed = i + 1 + } + + job.Status = shared.JobStatusDone + job.CurrentFile = "" + job.DoneAt = time.Now() +} + +func handleConverterProgress(c *gin.Context) { + id := c.Param("id") + job, ok := conversionStore.Get(id) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "id": job.ID, + "status": job.Status, + "total": job.Total, + "completed": job.Completed, + "current_file": job.CurrentFile, + "results": job.Results, + "error": job.Error, + }) +} + +func handleConverterPoll(c *gin.Context) { + id := c.Param("id") + job, ok := conversionStore.Get(id) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"}) + return + } + + lastStr := c.Query("last") + last := -1 + if lastStr != "" { + for i := 0; i < len(lastStr); i++ { + if lastStr[i] >= '0' && lastStr[i] <= '9' { + last = last*10 + int(lastStr[i]-'0') + } + } + } + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if job.Completed != last || job.Status == shared.JobStatusDone { + break + } + time.Sleep(300 * time.Millisecond) + } + + c.JSON(http.StatusOK, gin.H{ + "id": job.ID, + "status": job.Status, + "total": job.Total, + "completed": job.Completed, + "current_file": job.CurrentFile, + "results": job.Results, + "error": job.Error, + }) +} + +func handleConverterDelete(c *gin.Context) { + var req struct { + Files []string `json:"files"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if len(req.Files) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "No files specified"}) + return + } + + var results []map[string]interface{} + totalFreed := int64(0) + + for _, fp := range req.Files { + info, err := os.Stat(fp) + if err != nil { + results = append(results, map[string]interface{}{"path": fp, "status": "not_found"}) + continue + } + if info.IsDir() { + results = append(results, map[string]interface{}{"path": fp, "status": "not_found"}) + continue + } + size := info.Size() + if err := os.Remove(fp); err != nil { + if os.IsPermission(err) { + results = append(results, map[string]interface{}{"path": fp, "status": "permission_denied"}) + } else { + results = append(results, map[string]interface{}{"path": fp, "status": "error", "error": err.Error()}) + } + } else { + totalFreed += size + results = append(results, map[string]interface{}{"path": fp, "status": "deleted", "size_freed": size}) + } + } + + c.JSON(http.StatusOK, gin.H{ + "results": results, + "total_freed": totalFreed, + "total_freed_formatted": shared.FormatFileSize(totalFreed), + }) +} + +func absPath(p string) (string, error) { + abs, err := os.Getwd() + if err != nil { + return p, err + } + if len(p) > 0 && p[0] == '/' { + return p, nil + } + return abs + "/" + p, nil +} diff --git a/go/web/routes_dupfinder.go b/go/web/routes_dupfinder.go new file mode 100644 index 0000000..d804539 --- /dev/null +++ b/go/web/routes_dupfinder.go @@ -0,0 +1,150 @@ +package web + +import ( + "net/http" + "os" + "time" + + "github.com/gin-gonic/gin" + "github.com/exterex/morphic/internal/dupfinder" + "github.com/exterex/morphic/internal/shared" +) + +func registerDupfinderRoutes(r *gin.Engine) { + g := r.Group("/api/dupfinder") + { + g.POST("/scan", handleDupfinderScan) + g.GET("/scan/:id/status", handleDupfinderStatus) + g.GET("/scan/:id/results", handleDupfinderResults) + g.POST("/delete", handleDupfinderDelete) + } +} + +func handleDupfinderScan(c *gin.Context) { + var req struct { + Folder string `json:"folder"` + Type string `json:"type"` + ImageThreshold float64 `json:"image_threshold"` + VideoThreshold float64 `json:"video_threshold"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if req.Folder == "" || !isDir(req.Folder) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid folder: " + req.Folder}) + return + } + if req.Type == "" { + req.Type = "both" + } + if req.Type != "images" && req.Type != "videos" && req.Type != "both" { + c.JSON(http.StatusBadRequest, gin.H{"error": "type must be images, videos, or both"}) + return + } + if req.ImageThreshold == 0 { + req.ImageThreshold = shared.DefaultImageThreshold + } + if req.VideoThreshold == 0 { + req.VideoThreshold = shared.DefaultVideoThreshold + } + + jobID := dupfinder.StartJob(req.Folder, req.Type, req.ImageThreshold, req.VideoThreshold) + c.JSON(http.StatusAccepted, gin.H{"job_id": jobID}) +} + +func handleDupfinderStatus(c *gin.Context) { + id := c.Param("id") + job, ok := dupfinder.GetJob(id) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"}) + return + } + + elapsed := 0.0 + if !job.StartedAt.IsZero() { + end := job.DoneAt + if end.IsZero() { + end = time.Now() + } + elapsed = end.Sub(job.StartedAt).Seconds() + } + + c.JSON(http.StatusOK, gin.H{ + "id": job.ID, + "status": job.Status, + "progress": job.Progress, + "message": job.Message, + "error": job.Error, + "total_files_found": job.TotalFound, + "total_files_processed": job.TotalProcessed, + "elapsed_seconds": round1(elapsed), + }) +} + +func handleDupfinderResults(c *gin.Context) { + id := c.Param("id") + job, ok := dupfinder.GetJob(id) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"}) + return + } + + if job.Status != "done" && job.Status != "failed" { + c.JSON(http.StatusConflict, gin.H{"error": "Scan not finished yet"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "image_groups": job.ImageGroups, + "video_groups": job.VideoGroups, + "space_savings": job.SpaceSavings, + "space_savings_formatted": shared.FormatFileSize(job.SpaceSavings), + }) +} + +func handleDupfinderDelete(c *gin.Context) { + var req struct { + Files []string `json:"files"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if len(req.Files) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "No files specified"}) + return + } + + var results []map[string]interface{} + totalFreed := int64(0) + + for _, fp := range req.Files { + info, err := os.Stat(fp) + if err != nil { + results = append(results, map[string]interface{}{"path": fp, "status": "not_found"}) + continue + } + if info.IsDir() { + results = append(results, map[string]interface{}{"path": fp, "status": "not_found"}) + continue + } + size := info.Size() + if err := os.Remove(fp); err != nil { + if os.IsPermission(err) { + results = append(results, map[string]interface{}{"path": fp, "status": "permission_denied"}) + } else { + results = append(results, map[string]interface{}{"path": fp, "status": "error", "error": err.Error()}) + } + } else { + totalFreed += size + results = append(results, map[string]interface{}{"path": fp, "status": "deleted", "size_freed": size}) + } + } + + c.JSON(http.StatusOK, gin.H{ + "results": results, + "total_freed": totalFreed, + "total_freed_formatted": shared.FormatFileSize(totalFreed), + }) +} diff --git a/go/web/routes_inspector.go b/go/web/routes_inspector.go new file mode 100644 index 0000000..fe129df --- /dev/null +++ b/go/web/routes_inspector.go @@ -0,0 +1,163 @@ +package web + +import ( + "net/http" + "os" + "time" + + "github.com/gin-gonic/gin" + "github.com/exterex/morphic/internal/inspector" +) + +func registerInspectorRoutes(r *gin.Engine) { + g := r.Group("/api/inspector") + { + g.POST("/scan", handleInspectorScan) + g.GET("/scan/:id/status", handleInspectorStatus) + g.GET("/scan/:id/results", handleInspectorResults) + g.POST("/exif/edit", handleExifEdit) + g.POST("/exif/strip", handleExifStrip) + } +} + +func handleInspectorScan(c *gin.Context) { + var req struct { + Folder string `json:"folder"` + Mode string `json:"mode"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if req.Folder == "" || !isDir(req.Folder) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid folder: " + req.Folder}) + return + } + if req.Mode == "" { + req.Mode = "exif" + } + if req.Mode != "exif" && req.Mode != "integrity" { + c.JSON(http.StatusBadRequest, gin.H{"error": "mode must be 'exif' or 'integrity'"}) + return + } + + jobID := inspector.StartJob(req.Folder, req.Mode) + c.JSON(http.StatusAccepted, gin.H{"job_id": jobID}) +} + +func handleInspectorStatus(c *gin.Context) { + id := c.Param("id") + job, ok := inspector.GetJob(id) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"}) + return + } + + elapsed := 0.0 + if !job.StartedAt.IsZero() { + end := job.DoneAt + if end.IsZero() { + end = time.Now() + } + elapsed = end.Sub(job.StartedAt).Seconds() + } + + c.JSON(http.StatusOK, gin.H{ + "id": job.ID, + "status": job.Status, + "mode": job.Mode, + "progress": job.Progress, + "message": job.Message, + "error": job.Error, + "total_files": job.Total, + "processed_files": job.Processed, + "elapsed_seconds": round1(elapsed), + }) +} + +func handleInspectorResults(c *gin.Context) { + id := c.Param("id") + job, ok := inspector.GetJob(id) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"}) + return + } + + if job.Status != "done" && job.Status != "failed" { + c.JSON(http.StatusConflict, gin.H{"error": "Scan not finished yet"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "mode": job.Mode, + "results": job.Results, + "total_files": job.Total, + }) +} + +func handleExifEdit(c *gin.Context) { + var req struct { + File string `json:"file"` + Updates map[string]interface{} `json:"updates"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if req.File == "" || !isFile(req.File) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file: " + req.File}) + return + } + if len(req.Updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "No updates provided"}) + return + } + + // EXIF editing with go-exif is read-only; for now return not-implemented + // to match the API contract. A future iteration could use a subprocess. + c.JSON(http.StatusNotImplemented, gin.H{ + "error": "EXIF editing is not yet supported in the Go version", + }) +} + +func handleExifStrip(c *gin.Context) { + var req struct { + Files []string `json:"files"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if len(req.Files) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "No files specified"}) + return + } + + results := inspector.StripExifBatch(req.Files) + successCount := 0 + for _, r := range results { + if s, ok := r["success"].(bool); ok && s { + successCount++ + } + } + + c.JSON(http.StatusOK, gin.H{ + "results": results, + "total": len(req.Files), + "success_count": successCount, + }) +} + +func isFile(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func isDir(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +func round1(f float64) float64 { + return float64(int(f*10+0.5)) / 10 +} diff --git a/go/web/routes_resizer.go b/go/web/routes_resizer.go new file mode 100644 index 0000000..6028f22 --- /dev/null +++ b/go/web/routes_resizer.go @@ -0,0 +1,111 @@ +package web + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/exterex/morphic/internal/resizer" +) + +func registerResizerRoutes(r *gin.Engine) { + g := r.Group("/api/resizer") + { + g.POST("/scan", handleResizerScan) + g.GET("/scan/:id/status", handleResizerStatus) + g.GET("/scan/:id/results", handleResizerResults) + } +} + +func handleResizerScan(c *gin.Context) { + var req struct { + Folder string `json:"folder"` + Width int `json:"width"` + Height int `json:"height"` + Mode string `json:"mode"` + BgColor string `json:"bg_color"` + Quality int `json:"quality"` + OutputFolder string `json:"output_folder"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if req.Folder == "" || !isDir(req.Folder) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid folder: " + req.Folder}) + return + } + if req.Width <= 0 || req.Height <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "width and height must be positive"}) + return + } + if req.Mode == "" { + req.Mode = "fit" + } + if !resizer.IsValidMode(req.Mode) { + c.JSON(http.StatusBadRequest, gin.H{"error": "mode must be one of fit, fill, stretch, pad"}) + return + } + if req.BgColor == "" { + req.BgColor = "#000000" + } + if req.Quality == 0 { + req.Quality = 90 + } + + jobID := resizer.StartJob( + req.Folder, req.Width, req.Height, req.Mode, + req.OutputFolder, req.BgColor, req.Quality, + ) + c.JSON(http.StatusAccepted, gin.H{"job_id": jobID}) +} + +func handleResizerStatus(c *gin.Context) { + id := c.Param("id") + job, ok := resizer.GetJob(id) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"}) + return + } + + elapsed := 0.0 + if !job.StartedAt.IsZero() { + end := job.DoneAt + if end.IsZero() { + end = time.Now() + } + elapsed = end.Sub(job.StartedAt).Seconds() + } + + c.JSON(http.StatusOK, gin.H{ + "id": job.ID, + "status": job.Status, + "progress": job.Progress, + "message": job.Message, + "error": job.Error, + "total_files": job.Total, + "processed_files": job.Processed, + "elapsed_seconds": round1(elapsed), + }) +} + +func handleResizerResults(c *gin.Context) { + id := c.Param("id") + job, ok := resizer.GetJob(id) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"}) + return + } + + if job.Status != "done" && job.Status != "failed" { + c.JSON(http.StatusConflict, gin.H{"error": "Job not finished yet"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "results": job.Results, + "errors": job.Errors, + "total_files": job.Total, + "processed_files": job.Processed, + }) +} diff --git a/go/web/server.go b/go/web/server.go index 5f34087..824a3e0 100644 --- a/go/web/server.go +++ b/go/web/server.go @@ -40,6 +40,10 @@ func SetupRouter() *gin.Engine { // Register API route groups registerSharedRoutes(r) registerOrganizerRoutes(r) + registerInspectorRoutes(r) + registerResizerRoutes(r) + registerConverterRoutes(r) + registerDupfinderRoutes(r) return r } From bbeda6bedd49b06a4067fb3e3131b243d1258885 Mon Sep 17 00:00:00 2001 From: exTerEX <30706568+exTerEX@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:26:57 +0200 Subject: [PATCH 03/16] fix: align shared & organizer routes with frontend JS API - Shared: add GET /api/browse (in-page dir browser), POST /api/browse/native - Shared: rename /api/system-info to /api/system_info, add ffmpeg encoders - Shared: add GET /api/media for full-size media preview - Organizer: rename /api/organizer/scan to /plan - Organizer: execute accepts {job_id} in body instead of URL param - Organizer: status returns unified 'plan' array, plan_count, conflicts, execution - Organizer: PlanRename accepts start_seq parameter --- go/internal/organizer/renamer.go | 4 +- go/internal/organizer/scanner.go | 95 ++++++++++++++++++---- go/web/routes_organizer.go | 72 ++++++++++++----- go/web/routes_shared.go | 131 ++++++++++++++++++++++++++++++- 4 files changed, 261 insertions(+), 41 deletions(-) diff --git a/go/internal/organizer/renamer.go b/go/internal/organizer/renamer.go index 93e54ad..304ace7 100644 --- a/go/internal/organizer/renamer.go +++ b/go/internal/organizer/renamer.go @@ -55,7 +55,7 @@ type fileWithDate struct { } // PlanRename creates a rename plan for the given files. -func PlanRename(files []string, tmpl string, operation string) []RenamePlanEntry { +func PlanRename(files []string, tmpl string, operation string, startSeq int) []RenamePlanEntry { // Sort by (date, path) for consistent sequencing fwd := make([]fileWithDate, len(files)) for i, f := range files { @@ -72,7 +72,7 @@ func PlanRename(files []string, tmpl string, operation string) []RenamePlanEntry destSet := make(map[string]int) // destination -> first index for i, f := range fwd { - newName := RenderName(tmpl, f.Path, i+1) + newName := RenderName(tmpl, f.Path, startSeq+i) destPath := filepath.Join(filepath.Dir(f.Path), newName) entry := RenamePlanEntry{ diff --git a/go/internal/organizer/scanner.go b/go/internal/organizer/scanner.go index ffea6d1..f4f4a32 100644 --- a/go/internal/organizer/scanner.go +++ b/go/internal/organizer/scanner.go @@ -14,11 +14,11 @@ type ScanJob struct { Folder string `json:"folder"` Phase string `json:"phase"` - ScanType string `json:"scan_type"` Template string `json:"template"` Destination string `json:"destination"` Operation string `json:"operation"` Mode string `json:"mode"` + StartSeq int `json:"start_seq"` Files []string `json:"files,omitempty"` SortPlan []SortPlanEntry `json:"sort_plan,omitempty"` RenamePlan []RenamePlanEntry `json:"rename_plan,omitempty"` @@ -35,7 +35,7 @@ func init() { } // StartPlanJob starts a new planning job. -func StartPlanJob(folder, mode, template, destination, operation, scanType string) string { +func StartPlanJob(folder, mode, template, destination, operation string, startSeq int) string { job := &ScanJob{ Job: shared.NewJob(), Folder: folder, @@ -44,7 +44,7 @@ func StartPlanJob(folder, mode, template, destination, operation, scanType strin Template: template, Destination: destination, Operation: operation, - ScanType: scanType, + StartSeq: startSeq, } job.Status = shared.JobStatusRunning @@ -78,17 +78,7 @@ func ExecuteJob(id string) bool { } func runPlan(job *ScanJob) { - var files []shared.FileInfo - var err error - - switch job.ScanType { - case "images": - files, err = shared.FindImageFiles(job.Folder) - case "videos": - files, err = shared.FindVideoFiles(job.Folder) - default: - files, err = shared.FindAllMediaFiles(job.Folder) - } + files, err := shared.FindAllMediaFiles(job.Folder) if err != nil { job.mu.Lock() @@ -122,7 +112,7 @@ func runPlan(job *ScanJob) { job.SortPlan = plan job.mu.Unlock() case "rename": - plan := PlanRename(paths, job.Template, job.Operation) + plan := PlanRename(paths, job.Template, job.Operation, job.StartSeq) job.mu.Lock() job.RenamePlan = plan job.mu.Unlock() @@ -164,3 +154,78 @@ func runExecute(job *ScanJob) { job.DoneAt = time.Now() job.mu.Unlock() } + +// GetUnifiedPlan returns the plan entries in a unified format matching the +// Python API's response (each entry has "source", "destination", "conflict"). +func GetUnifiedPlan(job *ScanJob) []map[string]interface{} { + job.mu.Lock() + defer job.mu.Unlock() + + if job.Mode == "sort" { + plan := make([]map[string]interface{}, len(job.SortPlan)) + for i, e := range job.SortPlan { + entry := map[string]interface{}{ + "source": e.Source, + "destination": e.Destination, + } + if e.Status == "conflict" { + entry["conflict"] = true + } + plan[i] = entry + } + return plan + } + + plan := make([]map[string]interface{}, len(job.RenamePlan)) + for i, e := range job.RenamePlan { + entry := map[string]interface{}{ + "source": e.Source, + "destination": e.Destination, + } + if e.Status == "conflict" { + entry["conflict"] = true + } + plan[i] = entry + } + return plan +} + +// GetExecutionResult returns execution stats matching the Python API format. +func GetExecutionResult(job *ScanJob) map[string]interface{} { + job.mu.Lock() + defer job.mu.Unlock() + + completed := 0 + errors := 0 + skipped := 0 + + if job.Mode == "sort" { + for _, e := range job.SortPlan { + switch e.Status { + case "done": + completed++ + case "error": + errors++ + case "conflict", "skipped": + skipped++ + } + } + } else { + for _, e := range job.RenamePlan { + switch e.Status { + case "done": + completed++ + case "error": + errors++ + case "conflict", "skipped": + skipped++ + } + } + } + + return map[string]interface{}{ + "completed": completed, + "errors": errors, + "skipped": skipped, + } +} diff --git a/go/web/routes_organizer.go b/go/web/routes_organizer.go index 988f9b8..6ba02a4 100644 --- a/go/web/routes_organizer.go +++ b/go/web/routes_organizer.go @@ -10,20 +10,20 @@ import ( func registerOrganizerRoutes(r *gin.Engine) { g := r.Group("/api/organizer") { - g.POST("/scan", handleOrganizerScan) - g.POST("/execute/:id", handleOrganizerExecute) + g.POST("/plan", handleOrganizerPlan) + g.POST("/execute", handleOrganizerExecute) g.GET("/status/:id", handleOrganizerStatus) } } -func handleOrganizerScan(c *gin.Context) { +func handleOrganizerPlan(c *gin.Context) { var req struct { Folder string `json:"folder"` Mode string `json:"mode"` Template string `json:"template"` Destination string `json:"destination"` Operation string `json:"operation"` - ScanType string `json:"scan_type"` + StartSeq int `json:"start_seq"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -35,23 +35,33 @@ func handleOrganizerScan(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "folder is required"}) return } + if req.StartSeq <= 0 { + req.StartSeq = 1 + } jobID := organizer.StartPlanJob( req.Folder, req.Mode, req.Template, - req.Destination, req.Operation, req.ScanType, + req.Destination, req.Operation, req.StartSeq, ) c.JSON(http.StatusAccepted, gin.H{"job_id": jobID}) } func handleOrganizerExecute(c *gin.Context) { - id := c.Param("id") - if !organizer.ExecuteJob(id) { + var req struct { + JobID string `json:"job_id"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.JobID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "job_id required"}) + return + } + + if !organizer.ExecuteJob(req.JobID) { c.JSON(http.StatusNotFound, gin.H{"error": "job not found or not in planned state"}) return } - c.JSON(http.StatusAccepted, gin.H{"status": "executing"}) + c.JSON(http.StatusAccepted, gin.H{"status": "executing", "job_id": req.JobID}) } func handleOrganizerStatus(c *gin.Context) { @@ -62,16 +72,38 @@ func handleOrganizerStatus(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{ - "id": job.ID, - "status": job.Status, - "phase": job.Phase, - "progress": job.Progress, - "message": job.Message, - "error": job.Error, - "total": job.Total, - "processed": job.Processed, - "sort_plan": job.SortPlan, - "rename_plan": job.RenamePlan, - }) + resp := gin.H{ + "id": job.ID, + "status": job.Status, + "phase": job.Phase, + "mode": job.Mode, + "operation": job.Operation, + "progress": job.Progress, + "message": job.Message, + "error": job.Error, + } + + // Include plan when planning is done (matches Python's response) + if job.Phase == "planned" || job.Phase == "executing" || job.Phase == "done" { + plan := organizer.GetUnifiedPlan(job) + resp["plan"] = plan + resp["plan_count"] = len(plan) + + conflicts := 0 + for _, entry := range plan { + if _, ok := entry["conflict"]; ok { + if entry["conflict"] == true { + conflicts++ + } + } + } + resp["conflicts"] = conflicts + } + + // Include execution results when done + if job.Phase == "done" { + resp["execution"] = organizer.GetExecutionResult(job) + } + + c.JSON(http.StatusOK, resp) } diff --git a/go/web/routes_shared.go b/go/web/routes_shared.go index e417557..a4cf167 100644 --- a/go/web/routes_shared.go +++ b/go/web/routes_shared.go @@ -1,27 +1,90 @@ package web import ( + "mime" "net/http" + "os" + "os/exec" + "path/filepath" "runtime" + "sort" + "strings" "github.com/gin-gonic/gin" "github.com/exterex/morphic/internal/shared" ) func registerSharedRoutes(r *gin.Engine) { - r.POST("/api/browse", handleBrowse) + r.GET("/api/browse", handleBrowseDirectory) + r.POST("/api/browse/native", handleBrowseNative) r.GET("/api/thumbnail", handleThumbnail) - r.GET("/api/system-info", handleSystemInfo) + r.GET("/api/system_info", handleSystemInfo) + r.GET("/api/media", handleMedia) } -func handleBrowse(c *gin.Context) { +// handleBrowseDirectory lists directories for the in-page folder browser. +func handleBrowseDirectory(c *gin.Context) { + path := c.Query("path") + if path == "" { + home, _ := os.UserHomeDir() + path = home + } + + path = filepath.Clean(path) + info, err := os.Stat(path) + if err != nil || !info.IsDir() { + c.JSON(http.StatusBadRequest, gin.H{"error": "Not a directory"}) + return + } + + entries, _ := os.ReadDir(path) + type dirEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + } + var dirs []dirEntry + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".") { + continue + } + if e.IsDir() { + dirs = append(dirs, dirEntry{ + Name: e.Name(), + Path: filepath.Join(path, e.Name()), + Type: "directory", + }) + } + } + sort.Slice(dirs, func(i, j int) bool { + return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) + }) + + parent := filepath.Dir(path) + var parentPtr interface{} = parent + if parent == path { + parentPtr = nil + } + + c.JSON(http.StatusOK, gin.H{ + "current": path, + "parent": parentPtr, + "entries": dirs, + }) +} + +// handleBrowseNative opens the OS-native folder picker dialog. +func handleBrowseNative(c *gin.Context) { folder, err := shared.OpenNativeFolderDialog() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if folder == "" { - c.JSON(http.StatusOK, gin.H{"folder": nil}) + c.JSON(http.StatusOK, gin.H{ + "folder": nil, + "message": "Dialog cancelled or unavailable", + }) return } c.JSON(http.StatusOK, gin.H{"folder": folder}) @@ -52,11 +115,71 @@ func handleThumbnail(c *gin.Context) { } func handleSystemInfo(c *gin.Context) { + ffmpegInfo := gin.H{ + "installed": false, + "hwaccels": []string{}, + "encoders": []string{}, + "nvenc_available": false, + } + + if _, err := exec.LookPath("ffmpeg"); err == nil { + ffmpegInfo["installed"] = true + + if out, err := exec.Command("ffmpeg", "-hide_banner", "-encoders"). + CombinedOutput(); err == nil { + var encoders []string + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if len(line) > 0 && (line[0] == 'V' || line[0] == 'A') { + encoders = append(encoders, line) + } + } + ffmpegInfo["encoders"] = encoders + for _, e := range encoders { + if strings.Contains(e, "nvenc") { + ffmpegInfo["nvenc_available"] = true + break + } + } + } + } + c.JSON(http.StatusOK, gin.H{ "version": shared.Version, "platform": runtime.GOOS, "arch": runtime.GOARCH, "go": runtime.Version(), "cpus": runtime.NumCPU(), + "ffmpeg": ffmpegInfo, }) } + +// handleMedia serves a media file for full-size preview. +func handleMedia(c *gin.Context) { + filePath := c.Query("path") + if filePath == "" { + c.Status(http.StatusBadRequest) + return + } + + filePath = filepath.Clean(filePath) + info, err := os.Stat(filePath) + if err != nil || info.IsDir() { + c.Status(http.StatusNotFound) + return + } + + ext := shared.NormaliseExt(filepath.Ext(filePath)) + _, isImg := shared.ImageExtensions[ext] + _, isVid := shared.VideoExtensions[ext] + if !isImg && !isVid { + c.Status(http.StatusForbidden) + return + } + + contentType := mime.TypeByExtension(filepath.Ext(filePath)) + if contentType == "" { + contentType = "application/octet-stream" + } + c.File(filePath) +} From 6fc067dc822f2f9a5130fe5c4c7df69479475c52 Mon Sep 17 00:00:00 2001 From: exTerEX <30706568+exTerEX@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:48:05 +0200 Subject: [PATCH 04/16] feat: add AVIF thumbnail/image support + lazy thumbnail loading --- Makefile | 8 ++-- {go/cmd => cmd}/morphic/main.go | 0 go/go.mod => go.mod | 0 go/go.sum => go.sum | 0 .../converter/constants.go | 0 .../converter/converter.go | 43 ++++++++++++++++++- .../converter/scanner.go | 0 {go/internal => internal}/dupfinder/images.go | 0 .../dupfinder/scanner.go | 0 {go/internal => internal}/dupfinder/videos.go | 0 {go/internal => internal}/inspector/exif.go | 0 .../inspector/integrity.go | 28 ++++++++++++ .../inspector/scanner.go | 0 .../organizer/date_sorter.go | 0 .../organizer/renamer.go | 0 .../organizer/scanner.go | 0 .../resizer/operations.go | 0 {go/internal => internal}/resizer/scanner.go | 0 {go/internal => internal}/shared/constants.go | 0 .../shared/file_browser.go | 0 {go/internal => internal}/shared/jobs.go | 0 .../shared/thumbnails.go | 36 ++++++++++++++-- {go/internal => internal}/shared/utils.go | 0 {go/web => web}/routes_converter.go | 0 {go/web => web}/routes_dupfinder.go | 0 {go/web => web}/routes_inspector.go | 0 {go/web => web}/routes_organizer.go | 0 {go/web => web}/routes_resizer.go | 0 {go/web => web}/routes_shared.go | 0 {go/web => web}/server.go | 3 +- {go/web => web}/static/app.js | 2 +- {go/web => web}/static/style.css | 0 {go/web => web}/templates/index.html | 10 ++--- 33 files changed, 115 insertions(+), 15 deletions(-) rename {go/cmd => cmd}/morphic/main.go (100%) rename go/go.mod => go.mod (100%) rename go/go.sum => go.sum (100%) rename {go/internal => internal}/converter/constants.go (100%) rename {go/internal => internal}/converter/converter.go (78%) rename {go/internal => internal}/converter/scanner.go (100%) rename {go/internal => internal}/dupfinder/images.go (100%) rename {go/internal => internal}/dupfinder/scanner.go (100%) rename {go/internal => internal}/dupfinder/videos.go (100%) rename {go/internal => internal}/inspector/exif.go (100%) rename {go/internal => internal}/inspector/integrity.go (83%) rename {go/internal => internal}/inspector/scanner.go (100%) rename {go/internal => internal}/organizer/date_sorter.go (100%) rename {go/internal => internal}/organizer/renamer.go (100%) rename {go/internal => internal}/organizer/scanner.go (100%) rename {go/internal => internal}/resizer/operations.go (100%) rename {go/internal => internal}/resizer/scanner.go (100%) rename {go/internal => internal}/shared/constants.go (100%) rename {go/internal => internal}/shared/file_browser.go (100%) rename {go/internal => internal}/shared/jobs.go (100%) rename {go/internal => internal}/shared/thumbnails.go (76%) rename {go/internal => internal}/shared/utils.go (100%) rename {go/web => web}/routes_converter.go (100%) rename {go/web => web}/routes_dupfinder.go (100%) rename {go/web => web}/routes_inspector.go (100%) rename {go/web => web}/routes_organizer.go (100%) rename {go/web => web}/routes_resizer.go (100%) rename {go/web => web}/routes_shared.go (100%) rename {go/web => web}/server.go (91%) rename {go/web => web}/static/app.js (99%) rename {go/web => web}/static/style.css (100%) rename {go/web => web}/templates/index.html (98%) diff --git a/Makefile b/Makefile index db0e089..01f10b7 100644 --- a/Makefile +++ b/Makefile @@ -50,16 +50,16 @@ clean: .PHONY: go-build go-test go-run go-vet go-tidy go-tidy: - cd go && go mod tidy + go mod tidy go-build: go-tidy - cd go && go build -o ../bin/morphic ./cmd/morphic + go build -o ./bin/morphic ./cmd/morphic go-test: - cd go && go test ./... + go test ./... go-vet: - cd go && go vet ./... + go vet ./... go-run: go-build ./bin/morphic diff --git a/go/cmd/morphic/main.go b/cmd/morphic/main.go similarity index 100% rename from go/cmd/morphic/main.go rename to cmd/morphic/main.go diff --git a/go/go.mod b/go.mod similarity index 100% rename from go/go.mod rename to go.mod diff --git a/go/go.sum b/go.sum similarity index 100% rename from go/go.sum rename to go.sum diff --git a/go/internal/converter/constants.go b/internal/converter/constants.go similarity index 100% rename from go/internal/converter/constants.go rename to internal/converter/constants.go diff --git a/go/internal/converter/converter.go b/internal/converter/converter.go similarity index 78% rename from go/internal/converter/converter.go rename to internal/converter/converter.go index 6477cf5..ac3a201 100644 --- a/go/internal/converter/converter.go +++ b/internal/converter/converter.go @@ -86,9 +86,15 @@ func ConvertImage(source, targetExt, outputDir string) (string, error) { strings.TrimSuffix(filepath.Base(dest), ext)+"_converted"+ext) } + sourceExt := shared.NormaliseExt(strings.ToLower(filepath.Ext(source))) + if sourceExt == ".avif" || ext == ".avif" { + return convertImageByFFmpeg(source, dest, ext) + } + img, err := imaging.Open(source) if err != nil { - return "", err + // Relax: fallback to ffmpeg conversion for special unsupported formats + return convertImageByFFmpeg(source, dest, ext) } opts := []imaging.EncodeOption{} @@ -104,6 +110,41 @@ func ConvertImage(source, targetExt, outputDir string) (string, error) { return dest, nil } +func convertImageByFFmpeg(source, dest, ext string) (string, error) { + if !ffmpegAvailable() { + return "", fmt.Errorf("ffmpeg is not installed or not on PATH") + } + + cmd := []string{"ffmpeg", "-y", "-i", source} + + extLower := strings.ToLower(ext) + if extLower == ".avif" { + if ffmpegHasEncoder("libsvtav1") { + cmd = append(cmd, "-c:v", "libsvtav1", "-crf", "28", "-preset", "fast") + } else if ffmpegHasEncoder("libaom-av1") { + cmd = append(cmd, "-c:v", "libaom-av1", "-crf", "28", "-preset", "fast") + } else { + cmd = append(cmd, "-c:v", "libx264") + } + } else if extLower == ".webp" { + cmd = append(cmd, "-c:v", "libwebp") + } else if extLower == ".png" || extLower == ".jpg" || extLower == ".jpeg" || extLower == ".bmp" || extLower == ".gif" { + // no explicit codec required + } else { + // generic fallback for unknown image extensions + } + + cmd = append(cmd, dest) + + out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput() + if err != nil { + return "", fmt.Errorf("ffmpeg image conversion failed: %s", strings.TrimSpace(string(out))) + } + + return dest, nil +} + + // ConvertVideo converts a video file using ffmpeg. func ConvertVideo(source, targetExt, outputDir string, av1CRF int) (string, error) { if !ffmpegAvailable() { diff --git a/go/internal/converter/scanner.go b/internal/converter/scanner.go similarity index 100% rename from go/internal/converter/scanner.go rename to internal/converter/scanner.go diff --git a/go/internal/dupfinder/images.go b/internal/dupfinder/images.go similarity index 100% rename from go/internal/dupfinder/images.go rename to internal/dupfinder/images.go diff --git a/go/internal/dupfinder/scanner.go b/internal/dupfinder/scanner.go similarity index 100% rename from go/internal/dupfinder/scanner.go rename to internal/dupfinder/scanner.go diff --git a/go/internal/dupfinder/videos.go b/internal/dupfinder/videos.go similarity index 100% rename from go/internal/dupfinder/videos.go rename to internal/dupfinder/videos.go diff --git a/go/internal/inspector/exif.go b/internal/inspector/exif.go similarity index 100% rename from go/internal/inspector/exif.go rename to internal/inspector/exif.go diff --git a/go/internal/inspector/integrity.go b/internal/inspector/integrity.go similarity index 83% rename from go/internal/inspector/integrity.go rename to internal/inspector/integrity.go index 37848e7..40482e3 100644 --- a/go/internal/inspector/integrity.go +++ b/internal/inspector/integrity.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strconv" "strings" "sync" @@ -46,6 +47,33 @@ func CheckImage(path string) IntegrityResult { return r } + ext := strings.ToLower(filepath.Ext(path)) + if alias, ok := shared.Aliases[ext]; ok { + ext = alias + } + + if ext == ".avif" { + // Use ffprobe for AVIF validation because imaging may not decode it. + cmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,width,height", "-of", "csv=p=0", path) + out, err := cmd.Output() + if err != nil { + r.Error = err.Error() + return r + } + parts := strings.Split(strings.TrimSpace(string(out)), ",") + if len(parts) >= 2 { + r.Width, _ = strconv.Atoi(parts[1]) + } + if len(parts) >= 3 { + r.Height, _ = strconv.Atoi(parts[2]) + } + if len(parts) >= 1 { + r.Format = parts[0] + } + r.Valid = true + return r + } + img, err := imaging.Open(path) if err != nil { r.Error = err.Error() diff --git a/go/internal/inspector/scanner.go b/internal/inspector/scanner.go similarity index 100% rename from go/internal/inspector/scanner.go rename to internal/inspector/scanner.go diff --git a/go/internal/organizer/date_sorter.go b/internal/organizer/date_sorter.go similarity index 100% rename from go/internal/organizer/date_sorter.go rename to internal/organizer/date_sorter.go diff --git a/go/internal/organizer/renamer.go b/internal/organizer/renamer.go similarity index 100% rename from go/internal/organizer/renamer.go rename to internal/organizer/renamer.go diff --git a/go/internal/organizer/scanner.go b/internal/organizer/scanner.go similarity index 100% rename from go/internal/organizer/scanner.go rename to internal/organizer/scanner.go diff --git a/go/internal/resizer/operations.go b/internal/resizer/operations.go similarity index 100% rename from go/internal/resizer/operations.go rename to internal/resizer/operations.go diff --git a/go/internal/resizer/scanner.go b/internal/resizer/scanner.go similarity index 100% rename from go/internal/resizer/scanner.go rename to internal/resizer/scanner.go diff --git a/go/internal/shared/constants.go b/internal/shared/constants.go similarity index 100% rename from go/internal/shared/constants.go rename to internal/shared/constants.go diff --git a/go/internal/shared/file_browser.go b/internal/shared/file_browser.go similarity index 100% rename from go/internal/shared/file_browser.go rename to internal/shared/file_browser.go diff --git a/go/internal/shared/jobs.go b/internal/shared/jobs.go similarity index 100% rename from go/internal/shared/jobs.go rename to internal/shared/jobs.go diff --git a/go/internal/shared/thumbnails.go b/internal/shared/thumbnails.go similarity index 76% rename from go/internal/shared/thumbnails.go rename to internal/shared/thumbnails.go index c8a1220..52dcd16 100644 --- a/go/internal/shared/thumbnails.go +++ b/internal/shared/thumbnails.go @@ -41,9 +41,35 @@ func GenerateImageThumbnail(path string, size int) ([]byte, error) { } thumbnailCache.mu.RUnlock() + ext := strings.ToLower(filepath.Ext(path)) + if alias, ok := Aliases[ext]; ok { + ext = alias + } + + if ext == ".avif" { + data, err := extractImageFrame(path, "00:00:00", size) + if err != nil { + return nil, fmt.Errorf("failed to generate AVIF thumbnail %s: %w", path, err) + } + + thumbnailCache.mu.Lock() + thumbnailCache.store[cacheKey] = data + thumbnailCache.mu.Unlock() + + return data, nil + } + img, err := imaging.Open(path, imaging.AutoOrientation(true)) if err != nil { - return nil, fmt.Errorf("failed to open image %s: %w", path, err) + // Fallback to ffmpeg for formats that imaging can't decode. + ffData, ffErr := extractImageFrame(path, "00:00:00", size) + if ffErr == nil { + thumbnailCache.mu.Lock() + thumbnailCache.store[cacheKey] = ffData + thumbnailCache.mu.Unlock() + return ffData, nil + } + return nil, fmt.Errorf("failed to open image %s: %w (ffmpeg fallback: %v)", path, err, ffErr) } thumb := imaging.Fit(img, size, size, imaging.Lanczos) @@ -92,10 +118,14 @@ func GenerateVideoThumbnail(path string, size int) ([]byte, error) { } func extractVideoFrame(videoPath, seekTime string, size int) ([]byte, error) { + return extractImageFrame(videoPath, seekTime, size) +} + +func extractImageFrame(imagePath, seekTime string, size int) ([]byte, error) { cmd := exec.Command("ffmpeg", "-ss", seekTime, - "-i", videoPath, - "-vframes", "1", + "-i", imagePath, + "-frames:v", "1", "-vf", fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", size, size), "-f", "image2pipe", "-vcodec", "mjpeg", diff --git a/go/internal/shared/utils.go b/internal/shared/utils.go similarity index 100% rename from go/internal/shared/utils.go rename to internal/shared/utils.go diff --git a/go/web/routes_converter.go b/web/routes_converter.go similarity index 100% rename from go/web/routes_converter.go rename to web/routes_converter.go diff --git a/go/web/routes_dupfinder.go b/web/routes_dupfinder.go similarity index 100% rename from go/web/routes_dupfinder.go rename to web/routes_dupfinder.go diff --git a/go/web/routes_inspector.go b/web/routes_inspector.go similarity index 100% rename from go/web/routes_inspector.go rename to web/routes_inspector.go diff --git a/go/web/routes_organizer.go b/web/routes_organizer.go similarity index 100% rename from go/web/routes_organizer.go rename to web/routes_organizer.go diff --git a/go/web/routes_resizer.go b/web/routes_resizer.go similarity index 100% rename from go/web/routes_resizer.go rename to web/routes_resizer.go diff --git a/go/web/routes_shared.go b/web/routes_shared.go similarity index 100% rename from go/web/routes_shared.go rename to web/routes_shared.go diff --git a/go/web/server.go b/web/server.go similarity index 91% rename from go/web/server.go rename to web/server.go index 824a3e0..7bbcaa3 100644 --- a/go/web/server.go +++ b/web/server.go @@ -34,7 +34,8 @@ func SetupRouter() *gin.Engine { // Index route r.GET("/", func(c *gin.Context) { - c.HTML(http.StatusOK, "index.html", nil) + initialFolder := "" + c.HTML(http.StatusOK, "index.html", gin.H{"initial_folder": initialFolder}) }) // Register API route groups diff --git a/go/web/static/app.js b/web/static/app.js similarity index 99% rename from go/web/static/app.js rename to web/static/app.js index 5261a06..dd6b7e4 100644 --- a/go/web/static/app.js +++ b/web/static/app.js @@ -373,7 +373,7 @@ function renderConvResults() { const failed = convLastFailedFiles.has(f.path); thtml += ` - +