From 91db46c3007ef8c67a4569177f755ec8c1e9d9c8 Mon Sep 17 00:00:00 2001 From: Nicolas MASSE Date: Sun, 3 May 2020 21:01:34 +0200 Subject: [PATCH] WiP --- .gitignore | 1 + README.md | 7 ++ bot.go | 11 ++- chatdb_test.go | 2 +- go.mod | 5 +- go.sum | 9 ++ http.go | 212 ++++++++++++++++++++++++++++++++++++++++ main.go | 10 +- media_store.go | 161 +++++++++++++++++++++++++----- media_store_test.go | 83 ++++++++++++++++ web/album.html.template | 31 ++++++ web/css/main.css | 67 +++++++++++++ web/js/main.js | 17 ++++ web/media.html.template | 23 +++++ 14 files changed, 605 insertions(+), 34 deletions(-) create mode 100644 http.go create mode 100644 web/album.html.template create mode 100644 web/css/main.css create mode 100644 web/js/main.js create mode 100644 web/media.html.template diff --git a/.gitignore b/.gitignore index b80c68b..135221b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *.yaml photo-bot +statik diff --git a/README.md b/README.md index ffda8e0..4e01591 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,13 @@ Fetch dependencies. go get -u github.com/go-telegram-bot-api/telegram-bot-api go get -u github.com/spf13/viper go get -u gopkg.in/yaml.v2 +go get -u github.com/rakyll/statik +``` + +Pack all web files + +```sh +go generate ``` Compile for Raspberry PI. diff --git a/bot.go b/bot.go index a28f857..efe76f7 100644 --- a/bot.go +++ b/bot.go @@ -12,8 +12,9 @@ import ( ) type PhotoBot struct { - Telegram TelegramBackend - MediaStore *MediaStore + Telegram TelegramBackend + MediaStore *MediaStore + WebInterface WebInterface } type TelegramBackend struct { @@ -246,15 +247,15 @@ func (bot *PhotoBot) handleHelpCommand(message *tgbotapi.Message) { } func (bot *PhotoBot) handleInfoCommand(message *tgbotapi.Message) { - albumName, err := bot.MediaStore.GetCurrentAlbum() + album, err := bot.MediaStore.GetCurrentAlbum() if err != nil { log.Printf("[%s] cannot get current album: %s", message.From.UserName, err) bot.Telegram.replyToCommandWithMessage(message, viper.GetString("MsgServerError")) return } - if albumName != "" { - bot.Telegram.replyWithMessage(message, fmt.Sprintf(viper.GetString("MsgInfo"), albumName)) + if album.Title != "" { + bot.Telegram.replyWithMessage(message, fmt.Sprintf(viper.GetString("MsgInfo"), album.Title)) } else { bot.Telegram.replyWithMessage(message, viper.GetString("MsgInfoNoAlbum")) } diff --git a/chatdb_test.go b/chatdb_test.go index afbc3cb..23b2ae6 100644 --- a/chatdb_test.go +++ b/chatdb_test.go @@ -54,5 +54,5 @@ func TestUpdateWith(t *testing.T) { if err != nil { t.Errorf("ioutil.ReadFile: %s", err) } - assert.Equal(t, "john: 123456\n", string(content), "chatdb content") + assert.Equal(t, string(content), "john: 123456\n", "chatdb content") } diff --git a/go.mod b/go.mod index 8fecfec..b9be3d3 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/Telegram-Photo-Album-Bot +module github.com/nmasse-itix/Telegram-Photo-Album-Bot go 1.14 @@ -6,7 +6,10 @@ require ( github.com/Flaque/filet v0.0.0-20190209224823-fc4d33cfcf93 github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible github.com/google/uuid v1.1.1 + github.com/julienschmidt/httprouter v1.2.0 github.com/magiconair/properties v1.8.1 + github.com/prometheus/common v0.4.0 + github.com/rakyll/statik v0.1.7 github.com/spf13/afero v1.1.2 github.com/spf13/viper v1.6.3 github.com/technoweenie/multipartstreamer v1.0.1 // indirect diff --git a/go.sum b/go.sum index 4f6b232..b2f585a 100644 --- a/go.sum +++ b/go.sum @@ -3,7 +3,9 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/Flaque/filet v0.0.0-20190209224823-fc4d33cfcf93 h1:NnAUCP75PRm8yWE7+MZBIAR6PA9iwsBYEc6ZNYOy+AQ= github.com/Flaque/filet v0.0.0-20190209224823-fc4d33cfcf93/go.mod h1:TK+jB3mBs+8ZMWhU5BqZKnZWJ1MrLo8etNVg51ueTBo= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -51,6 +53,7 @@ github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -78,11 +81,15 @@ github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDf github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0 h1:7etb9YClo3a6HjLzfl6rIQaU+FDfi0VSX39io3aQ+DM= github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rakyll/statik v0.1.7 h1:OF3QCZUuyPxuGEP7B4ypUa7sB/iHtqOTDYZXGM8KOdQ= +github.com/rakyll/statik v0.1.7/go.mod h1:AlZONWzMtEnMs7W4e/1LURLiI49pIMmp6V9Unghqrcc= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/sirupsen/logrus v1.2.0 h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= @@ -114,6 +121,7 @@ go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -145,6 +153,7 @@ google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9Ywl google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/http.go b/http.go new file mode 100644 index 0000000..0098650 --- /dev/null +++ b/http.go @@ -0,0 +1,212 @@ +package main + +import ( + "html/template" + "io" + "io/ioutil" + "log" + "net/http" + "path" + "strings" + + _ "github.com/nmasse-itix/Telegram-Photo-Album-Bot/statik" + "github.com/rakyll/statik/fs" +) + +func slurpFile(statikFS http.FileSystem, filename string) (string, error) { + fd, err := statikFS.Open(filename) + if err != nil { + return "", err + } + defer fd.Close() + + content, err := ioutil.ReadAll(fd) + if err != nil { + return "", err + } + + return string(content), nil +} + +func getTemplate(statikFS http.FileSystem, filename string, name string) (*template.Template, error) { + tmpl := template.New(name) + content, err := slurpFile(statikFS, filename) + if err != nil { + return nil, err + } + + customFunctions := template.FuncMap{ + "video": func(files []string) string { + for _, file := range files { + if strings.HasSuffix(file, ".mp4") { + return file + } + } + return "" + }, + "photo": func(files []string) string { + for _, file := range files { + if strings.HasSuffix(file, ".jpeg") { + return file + } + } + return "" + }, + } + + return tmpl.Funcs(customFunctions).Parse(content) +} + +// ShiftPath splits off the first component of p, which will be cleaned of +// relative components before processing. head will never contain a slash and +// tail will always be a rooted path without trailing slash. +// +// From https://blog.merovius.de/2017/06/18/how-not-to-use-an-http-router.html +func ShiftPath(p string) (head, tail string) { + p = path.Clean("/" + p) + i := strings.Index(p[1:], "/") + 1 + if i <= 0 { + //log.Printf("head: %s, tail: /", p[1:]) + return p[1:], "/" + } + //log.Printf("head: %s, tail: %s", p[1:i], p[i:]) + return p[1:i], p[i:] +} + +type WebInterface struct { + AlbumTemplate *template.Template + MediaTemplate *template.Template +} + +func (bot *PhotoBot) ServeWebInterface(listenAddr string) { + statikFS, err := fs.New() + if err != nil { + log.Fatal(err) + } + + bot.WebInterface.AlbumTemplate, err = getTemplate(statikFS, "/album.html.template", "album") + if err != nil { + log.Fatal(err) + } + + bot.WebInterface.MediaTemplate, err = getTemplate(statikFS, "/media.html.template", "media") + if err != nil { + log.Fatal(err) + } + + router := http.NewServeMux() + router.Handle("/js/", http.FileServer(statikFS)) + router.Handle("/css/", http.FileServer(statikFS)) + router.Handle("/", bot) + + server := &http.Server{ + Addr: listenAddr, + Handler: router, + } + log.Fatal(server.ListenAndServe()) +} + +func (bot *PhotoBot) HandleFileNotFound(w http.ResponseWriter, r *http.Request) { + http.Error(w, "File not found", http.StatusNotFound) +} + +func (bot *PhotoBot) HandleError(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Internal server error", http.StatusInternalServerError) +} + +func (bot *PhotoBot) HandleDisplayAlbum(w http.ResponseWriter, r *http.Request, albumName string) { + if albumName == "latest" { + albumName = "" + } + + album, err := bot.MediaStore.GetAlbum(albumName, false) + if err != nil { + log.Printf("GetAlbum: %s", err) + bot.HandleError(w, r) + return + } + + err = bot.WebInterface.AlbumTemplate.Execute(w, album) + if err != nil { + log.Printf("Template.Execute: %s", err) + bot.HandleError(w, r) + return + } +} + +func (bot *PhotoBot) HandleDisplayMedia(w http.ResponseWriter, r *http.Request, albumName string, mediaId string) { + if albumName == "latest" { + albumName = "" + } + + media, err := bot.MediaStore.GetMedia(albumName, mediaId) + if err != nil { + log.Printf("MediaStore.GetMedia: %s", err) + bot.HandleError(w, r) + return + + } + + if media == nil { + bot.HandleFileNotFound(w, r) + return + } + + err = bot.WebInterface.MediaTemplate.Execute(w, media) + if err != nil { + log.Printf("Template.Execute: %s", err) + bot.HandleError(w, r) + return + } +} + +func (bot *PhotoBot) HandleGetMedia(w http.ResponseWriter, r *http.Request, albumName string, mediaFilename string) { + if albumName == "latest" { + albumName = "" + } + + fd, err := bot.MediaStore.OpenFile(albumName, mediaFilename) + if err != nil { + log.Printf("MediaStore.OpenFile: %s", err) + bot.HandleError(w, r) + return + } + defer fd.Close() + io.Copy(w, fd) // Best effort +} + +func (bot *PhotoBot) ServeHTTP(w http.ResponseWriter, r *http.Request) { + originalPath := r.URL.Path + var resource string + resource, r.URL.Path = ShiftPath(r.URL.Path) + + switch r.Method { + case "GET": + if resource == "album" { + var albumName, kind, media string + albumName, r.URL.Path = ShiftPath(r.URL.Path) + kind, r.URL.Path = ShiftPath(r.URL.Path) + media, r.URL.Path = ShiftPath(r.URL.Path) + if albumName != "" { + if kind == "" && media == "" { + if !strings.HasSuffix(originalPath, "/") { + http.Redirect(w, r, originalPath+"/", http.StatusMovedPermanently) + return + } + bot.HandleDisplayAlbum(w, r, albumName) + return + } else if kind == "raw" && media != "" { + bot.HandleGetMedia(w, r, albumName, media) + return + } else if kind == "media" && media != "" { + bot.HandleDisplayMedia(w, r, albumName, media) + return + } + } + } + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + bot.HandleFileNotFound(w, r) +} diff --git a/main.go b/main.go index d39291a..bce6d0e 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,5 @@ +//go:generate statik -src=web/ -include=*.html,*.css,*.js,*.template + package main import ( @@ -74,6 +76,11 @@ func validateConfig() { log.Fatal("No Telegram Bot Token provided!") } + listenAddr := viper.GetString("HttpListen") + if listenAddr == "" { + log.Fatal("No listen address provided!") + } + authorizedUsersList := viper.GetStringSlice("AuthorizedUsers") if len(authorizedUsersList) == 0 { log.Fatal("A list of AuthorizedUsers must be given!") @@ -122,5 +129,6 @@ func main() { photoBot.Telegram.API.Debug = viper.GetBool("TelegramDebug") initLogFile() - photoBot.Process() + go photoBot.Process() + photoBot.ServeWebInterface(viper.GetString("HttpListen")) } diff --git a/media_store.go b/media_store.go index b6ebd26..1badf91 100644 --- a/media_store.go +++ b/media_store.go @@ -3,6 +3,7 @@ package main import ( "fmt" "io/ioutil" + "log" "os" "path/filepath" "regexp" @@ -20,6 +21,21 @@ type MediaStore struct { StoreLocation string } +type Album struct { + ID string `yaml:"-"` // Not part of the YAML struct + Title string `yaml:"title"` + Date time.Time `yaml:"date"` + Media []Media `yaml:"-"` // Not part of the YAML struct +} + +type Media struct { + Type string `yaml:"type"` + ID string `yaml:"id"` + Files []string `yaml:"-"` // Not part of the YAML struct + Caption string `yaml:"caption"` + Date time.Time `yaml:"date"` +} + func InitMediaStore(storeLocation string) (*MediaStore, error) { err := os.MkdirAll(filepath.Join(storeLocation, ".current"), os.ModePerm) if err != nil { @@ -46,11 +62,11 @@ func (store *MediaStore) CommitVideo(id string, timestamp time.Time, caption str } func (store *MediaStore) commitMedia(id string, timestamp time.Time, caption string, mediaType string) error { - entry := [1]map[string]string{{ - "type": mediaType, - "date": timestamp.Format("2006-01-02T15:04:05-0700"), - "caption": caption, - "id": id, + entry := [1]Media{{ + Type: mediaType, + Date: timestamp, + Caption: caption, + ID: id, }} yamlData, err := yaml.Marshal(entry) @@ -73,24 +89,117 @@ func appendToFile(filename string, data []byte) error { return nil } -func (store *MediaStore) GetCurrentAlbum() (string, error) { - yamlData, err := ioutil.ReadFile(filepath.Join(store.StoreLocation, ".current", "meta.yaml")) +func (store *MediaStore) ListAlbums() ([]Album, error) { + files, err := ioutil.ReadDir(store.StoreLocation) if err != nil { - if os.IsNotExist(err) { - // the album has not yet a name, it is not an error - return "", nil - } else { - return "", err + return nil, err + } + + albums := make([]Album, len(files)) + for i, file := range files { + album, err := store.GetAlbum(file.Name(), true) + if err != nil { + log.Printf("ListAlbum: Cannot extract album info for '%s'", file.Name()) + continue } + albums[i] = *album } - var metadata map[string]string = make(map[string]string) - err = yaml.UnmarshalStrict(yamlData, &metadata) + return albums, nil +} + +func (store *MediaStore) OpenFile(albumName string, filename string) (*os.File, error) { + if albumName == "" { + albumName = ".current" + } + + return os.OpenFile(filepath.Join(store.StoreLocation, albumName, filename), os.O_RDONLY, 0600) +} + +func (store *MediaStore) GetAlbum(name string, metadataOnly bool) (*Album, error) { + var album Album + var filename string + if name == "" || name == ".current" { + filename = ".current" + } else { + filename = filepath.Base(name) + album.ID = filename + } + + if !fileExists(filepath.Join(store.StoreLocation, filename)) { + return nil, fmt.Errorf("Unknown album '%s'", name) + } + + err := store.fillAlbumMetadata(filename, &album) + if err != nil { + return nil, err + } + + if metadataOnly { + return &album, nil + } + + err = store.fillAlbumContent(filename, &album) + if err != nil { + return nil, err + } + + return &album, nil +} + +func (store *MediaStore) fillAlbumContent(filename string, album *Album) error { + yamlData, err := ioutil.ReadFile(filepath.Join(store.StoreLocation, filename, "chat.yaml")) + // if chat.yaml is not there, it may be because there is no media yet + // It is not an error. + if err != nil && !os.IsNotExist(err) { + return nil + } + + err = yaml.UnmarshalStrict(yamlData, &album.Media) + if err != nil { + return err + } + + // Find media files matching each id + for i := range album.Media { + paths, _ := filepath.Glob(filepath.Join(store.StoreLocation, filename, album.Media[i].ID+".*")) + album.Media[i].Files = make([]string, len(paths)) + for j, path := range paths { + album.Media[i].Files[j] = filepath.Base(path) + } + } + + return nil +} + +func (store *MediaStore) fillAlbumMetadata(filename string, album *Album) error { + yamlData, err := ioutil.ReadFile(filepath.Join(store.StoreLocation, filename, "meta.yaml")) + // if meta.yaml is not there, it could be because the album has not yet + // been initialized. It is not an error. + if err != nil && !os.IsNotExist(err) { + return err + } + + return yaml.UnmarshalStrict(yamlData, album) +} + +func (store *MediaStore) GetMedia(albumName string, mediaId string) (*Media, error) { + album, err := store.GetAlbum(albumName, false) if err != nil { - return "", err + return nil, err } - return metadata["title"], nil + for _, media := range album.Media { + if media.ID == mediaId { + return &media, nil + } + } + + return nil, nil +} + +func (store *MediaStore) GetCurrentAlbum() (*Album, error) { + return store.GetAlbum("", true) } func (store *MediaStore) CloseAlbum() error { @@ -99,19 +208,19 @@ func (store *MediaStore) CloseAlbum() error { return err } - var metadata map[string]string = make(map[string]string) + var metadata Album err = yaml.UnmarshalStrict(yamlData, &metadata) if err != nil { return err } - date, err := time.Parse("2006-01-02T15:04:05-0700", metadata["date"]) + folderName := metadata.Date.Format("2006-01-02") + "-" + sanitizeAlbumName(metadata.Title) + err = os.Rename(filepath.Join(store.StoreLocation, ".current"), filepath.Join(store.StoreLocation, folderName)) if err != nil { return err } - folderName := date.Format("2006-01-02") + "-" + sanitizeAlbumName(metadata["title"]) - err = os.Rename(filepath.Join(store.StoreLocation, ".current"), filepath.Join(store.StoreLocation, folderName)) + err = os.MkdirAll(filepath.Join(store.StoreLocation, ".current"), os.ModePerm) if err != nil { return err } @@ -125,8 +234,8 @@ func fileExists(filename string) bool { } func (store *MediaStore) NewAlbum(title string) error { - if fileExists(filepath.Join(store.StoreLocation, ".current/")) { - if fileExists(filepath.Join(store.StoreLocation, "/.current/meta.yaml")) { + if fileExists(filepath.Join(store.StoreLocation, ".current")) { + if fileExists(filepath.Join(store.StoreLocation, ".current", "meta.yaml")) { err := store.CloseAlbum() if err != nil { return err @@ -134,14 +243,14 @@ func (store *MediaStore) NewAlbum(title string) error { } } - err := os.MkdirAll(filepath.Join(store.StoreLocation, ".current/"), os.ModePerm) + err := os.MkdirAll(filepath.Join(store.StoreLocation, ".current"), os.ModePerm) if err != nil { return err } - metadata := map[string]string{ - "title": title, - "date": time.Now().Format("2006-01-02T15:04:05-0700"), + metadata := Album{ + Title: title, + Date: time.Now(), } yamlData, err := yaml.Marshal(metadata) diff --git a/media_store_test.go b/media_store_test.go index 3e57db4..c034882 100644 --- a/media_store_test.go +++ b/media_store_test.go @@ -4,6 +4,9 @@ import ( "os" "path/filepath" "testing" + "time" + + "github.com/magiconair/properties/assert" ) func TestSanitizeAlbumName(t *testing.T) { @@ -27,3 +30,83 @@ func TestNewMediaStore(t *testing.T) { t.Errorf("InitMediaStore(): .current not created (error = %s)", err) } } + +func TestMediaStore(t *testing.T) { + tmp := createTempDir(t) + defer tmp.cleanup(t) + + store, err := InitMediaStore(tmp.RootDir) + if err != nil { + t.Errorf("InitMediaStore(): error %s", err) + } + + id1 := store.GetUniqueID() + fd1, err := store.AddFile(id1 + ".jpeg") + if err != nil { + t.Errorf("AddFile(): error %s", err) + } + fd1.WriteString("JPEG File") + fd1.Close() + + err = store.CommitPhoto(id1, time.Now(), "This is a test") + if err != nil { + t.Errorf("CommitPhoto(): error %s", err) + } + + id2 := store.GetUniqueID() + fd2, err := store.AddFile(id2 + ".jpeg") + if err != nil { + t.Errorf("AddFile(): error %s", err) + } + fd2.WriteString("JPEG File") + fd2.Close() + + fd3, err := store.AddFile(id2 + ".mp4") + if err != nil { + t.Errorf("AddFile(): error %s", err) + } + fd3.WriteString("MP4 File") + fd3.Close() + + err = store.CommitVideo(id2, time.Now(), "This is another test") + if err != nil { + t.Errorf("CommitVideo(): error %s", err) + } + + album, err := store.GetAlbum("", false) + if err != nil { + t.Errorf("GetAlbum(): error %s", err) + } + assert.Equal(t, album.Title, "", "current album title is empty") + assert.Equal(t, len(album.Media), 2, "current album has two media") + assert.Equal(t, len(album.Media[0].Files), 1, "current album, first media has one file") + assert.Equal(t, len(album.Media[1].Files), 2, "current album, second media has two files") + + now := time.Now() + err = store.NewAlbum("My album") + if err != nil { + t.Errorf("NewAlbum(): error %s", err) + } + err = store.CloseAlbum() + if err != nil { + t.Errorf("CloseAlbum(): error %s", err) + } + albumId := now.Format("2006-01-02") + "-my-album" + album, err = store.GetAlbum(albumId, false) + if err != nil { + t.Errorf("GetAlbum(): error %s", err) + } + assert.Equal(t, album.Title, "My album", "saved album title") + assert.Equal(t, len(album.Media), 2, "saved album has two media") + assert.Equal(t, len(album.Media[0].Files), 1, "saved album, first media has one file") + assert.Equal(t, len(album.Media[1].Files), 2, "saved album, second media has two files") + assert.Equal(t, album.ID, albumId, "saved album ID") + + albumList, err := store.ListAlbums() + if err != nil { + t.Errorf("ListAlbums(): error %s", err) + } + assert.Equal(t, len(albumList), 2, "album list has two items") + assert.Equal(t, albumList[0].ID, "", "album number one is the current album") + assert.Equal(t, albumList[1].ID, albumId, "album number two is 'My Album'") +} diff --git a/web/album.html.template b/web/album.html.template new file mode 100644 index 0000000..934c2e9 --- /dev/null +++ b/web/album.html.template @@ -0,0 +1,31 @@ + + + + {{ .Title }} + + + + + + +

{{ .Title }}

+ + + diff --git a/web/css/main.css b/web/css/main.css new file mode 100644 index 0000000..50716d9 --- /dev/null +++ b/web/css/main.css @@ -0,0 +1,67 @@ +/* CSS inspired by https://css-tricks.com/adaptive-photo-layout-with-flexbox/ */ + +/* Common */ + +h1 { + font-family: sans-serif; +} + +/* Album */ + +body { + margin: 3vh; +} + +ul { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + padding: 0; + margin: 0; +} + +li { + height: 20vh; + flex-grow: 0.5; + list-style-type: none; +} + + +li img, li video { + max-height: 100%; + min-width: 100%; + object-fit: cover; + vertical-align: bottom; +} + +li:last-child { + flex-grow: 10; +} + +/* Album */ + +div { + height: 100%; + width: 100%; + display: flex; + justify-content: center +} + +html, body.media { + height: 100%; + margin: 0 +} + +body.media h1 { + position: fixed; + bottom: 0px; + left: 10%; + right: 10%; + background-color: #888888AA; + padding: 1vh; + +} + +div img, div video { + object-fit: contain; +} \ No newline at end of file diff --git a/web/js/main.js b/web/js/main.js new file mode 100644 index 0000000..39532d7 --- /dev/null +++ b/web/js/main.js @@ -0,0 +1,17 @@ + +document.addEventListener('DOMContentLoaded', function(event) { + var videos = document.getElementsByTagName("video"); + + for (var i = 0; i < videos.length; i++) { + var video = videos[i]; + video.addEventListener("mouseenter", function(event) { + event.target.muted = false; + event.target.play(); + }); + + video.addEventListener("mouseleave", function(event) { + event.target.muted = true; + event.target.pause(); + }); + } +}, false); diff --git a/web/media.html.template b/web/media.html.template new file mode 100644 index 0000000..1803cb7 --- /dev/null +++ b/web/media.html.template @@ -0,0 +1,23 @@ + + + + {{ .Caption }} + + + + + +{{ if ne .Caption "" }} +

{{ .Caption }}

+{{ end }} +
+{{ if eq .Type "photo" }} + +{{ else if eq .Type "video" }} + +{{ end }} +
+ +