diff --git a/go.mod b/go.mod index 8f385f8..851d87a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module go.balki.me/collage-maker -go 1.21 +go 1.22 require ( go.balki.me/anyhttp v0.3.0 diff --git a/main.go b/main.go index 7442ec0..24b2bdb 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "io" "io/fs" "net/http" + "net/url" "os" "path" "regexp" @@ -23,29 +24,45 @@ import ( ) var ( - imagesDir string - collageDir string - devMode bool - collageNameGen *nameGen - imagesDirFs fs.FS - listenAddr string + imagesDir string + collageDir string + photosDir string + devMode bool + collageNameGen *nameGen + imagesDirFs fs.FS + listenAddr string + photoPrismURL *url.URL + photoPrismToken string //go:embed web webFS embed.FS ) func main() { + var ppURL string + flag.StringVar(&imagesDir, "images-dir", "images", "Sets the images dir") flag.StringVar(&collageDir, "collages-dir", "collages", "Sets the collages dir") + flag.StringVar(&photosDir, "photos-dir", "photos", "Cache directory for downloaded photos") flag.BoolVar(&devMode, "dev", false, "Serve local assets during development") flag.StringVar(&listenAddr, "addr", "127.0.0.1:8767", "Web listen address, see https://pkg.go.dev/go.balki.me/anyhttp#readme-address-syntax") + flag.StringVar(&ppURL, "pp-url", "", "Base url for photoprism") + flag.StringVar(&photoPrismToken, "pp-token", "", "API token for photoprism") flag.Parse() + photoPrismURL = func() *url.URL { + photoPrismURL, err := url.Parse(ppURL) + if err != nil { + panic(err) + } + return photoPrismURL + }() collageNameGen = NewNameGen() imagesDirFs = os.DirFS(imagesDir) imagesURLPath := "images" collagesPath := "collages" + photosPath := "photos" addFileServer := func(path, dir string) { httpFileServer := http.FileServer(http.Dir(dir)) @@ -54,6 +71,7 @@ func main() { addFileServer(imagesURLPath, imagesDir) addFileServer(collagesPath, collageDir) + addFileServer(photosPath, photosDir) if devMode { httpFileServer := http.FileServer(http.Dir("web")) @@ -100,16 +118,65 @@ func main() { w.WriteHeader(http.StatusInternalServerError) return } - collageFile, err := MakeCollage(&collageReq) + collageFilePath, err := MakeCollage(&collageReq) if err != nil { slog.Error("failed to make collage", "error", err) w.WriteHeader(http.StatusInternalServerError) return } - if _, err := w.Write([]byte(collageFile)); err != nil { + if _, err := w.Write([]byte(collageFilePath)); err != nil { slog.Error("Failed to write collageFile", "error", err) } }) + + http.HandleFunc("/get-albums", func(w http.ResponseWriter, r *http.Request) { + albums, err := GetAlbums() + if err != nil { + slog.Error("failed to get albums", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + jData, err := json.Marshal(albums) + if err != nil { + slog.Error("failed to marshal albums", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(jData) + }) + + http.HandleFunc("/load-photos/{albumID}", func(w http.ResponseWriter, r *http.Request) { + albumID := r.PathValue("albumID") + photos, err := LoadPhotos(albumID) + if err != nil { + slog.Error("failed to load photos", "error", err, "album", albumID) + w.WriteHeader(http.StatusInternalServerError) + return + } + photoURLs := []string{} + for _, photo := range photos { + photoURLs = append(photoURLs, fmt.Sprintf("/%s/%s.jpg", photosPath, photo.FileUID)) + } + go func() { + for _, photo := range photos { + _, err := DownloadPhoto(&photo) + if err != nil { + slog.Error("failed to download photo", "error", err, "album", albumID, "photoID", photo.FileUID) + } + } + }() + + jData, err := json.Marshal(photoURLs) + if err != nil { + slog.Error("failed to marshal photoURLs", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(jData) + }) + addrType, server, done, err := anyhttp.Serve(listenAddr, idle.WrapHandler(nil)) if err != nil { slog.Error("anyhttp Serve failed", "error", err) @@ -141,3 +208,120 @@ func MakeCollage(req *collage.Request) (string, error) { } return collageFile, nil } + +type Album struct { + Title string `json:"Title"` + UID string `json:"UID"` +} + +func GetAlbums() ([]Album, error) { + albumURL := func() string { + u := *photoPrismURL + u.Path = "/api/v1/albums" + v := url.Values{} + v.Add("count", "20") + v.Add("type", "album") + u.RawQuery = v.Encode() + return u.String() + }() + + req, err := http.NewRequest("GET", albumURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", photoPrismToken)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + albums := []Album{} + + err = json.Unmarshal(respBytes, &albums) + if err != nil { + return nil, err + } + + return albums, nil +} + +type Photo struct { + Hash string `json:"Hash"` + FileUID string `json:"FileUID"` +} + +func LoadPhotos(albumID string) ([]Photo, error) { + loadPhotosURL := func() string { + u := *photoPrismURL + u.Path = "/api/v1/photos" + v := url.Values{} + v.Add("count", "50") + v.Add("s", albumID) + v.Add("merged", "true") + v.Add("video", "false") + u.RawQuery = v.Encode() + return u.String() + + }() + + req, err := http.NewRequest("GET", loadPhotosURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", photoPrismToken)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + photos := []Photo{} + err = json.Unmarshal(respBytes, &photos) + if err != nil { + return nil, err + } + + return photos, nil +} + +func DownloadPhoto(photo *Photo) (string, error) { + photoPath := path.Join(photosDir, fmt.Sprintf("%s.jpg", photo.FileUID)) + _, err := os.Stat(photoPath) + if err == nil { + return photoPath, nil + } else if !os.IsNotExist(err) { + return "", err + } + out, err := os.Create(photoPath) + if err != nil { + return "", fmt.Errorf("failed to create collage output file, err: %w", err) + } + defer out.Close() + downloadPhotoURL := func() string { + u := *photoPrismURL + u.Path = fmt.Sprintf("/api/v1/dl/%s", photo.Hash) + return u.String() + }() + req, err := http.NewRequest("GET", downloadPhotoURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", photoPrismToken)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + + _, err = io.Copy(out, resp.Body) + if err != nil { + return "", err + } + + return photoPath, nil +} diff --git a/web/choose.css b/web/choose.css new file mode 100644 index 0000000..5e09e58 --- /dev/null +++ b/web/choose.css @@ -0,0 +1,47 @@ +#album_photos { + display: flex; + gap: 1rem; + flex-wrap: wrap; + background-color: lightblue; + padding: 1rem; +} + +#album_photos img { + width: 200px; + height: 200px; +} + +#selected_photos { + display: flex; + gap: 1rem; + flex-wrap: wrap; + background-color: lightyellow; + padding: 1rem; +} + +#selected_photos img { + width: 100px; + height: 100px; +} + +.container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.controls { + background-color: lightgrey; + display: flex; + gap: 1rem; + padding: 1rem; +} + +#album_selector { + width: 400px; +} + +.current { + border: 5px solid; + border-color: red; +} diff --git a/web/choose.html b/web/choose.html new file mode 100644 index 0000000..08a55ee --- /dev/null +++ b/web/choose.html @@ -0,0 +1,38 @@ + + +
+ + + + + + + +