Nicolas Massé 6 years ago
parent
commit
91db46c300
  1. 1
      .gitignore
  2. 7
      README.md
  3. 7
      bot.go
  4. 2
      chatdb_test.go
  5. 5
      go.mod
  6. 9
      go.sum
  7. 212
      http.go
  8. 10
      main.go
  9. 159
      media_store.go
  10. 83
      media_store_test.go
  11. 31
      web/album.html.template
  12. 67
      web/css/main.css
  13. 17
      web/js/main.js
  14. 23
      web/media.html.template

1
.gitignore

@ -1,2 +1,3 @@
*.yaml
photo-bot
statik

7
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.

7
bot.go

@ -14,6 +14,7 @@ import (
type PhotoBot struct {
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"))
}

2
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")
}

5
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

9
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=

212
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)
}

10
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"))
}

159
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
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
}
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 {
return "", err
filename = filepath.Base(name)
album.ID = filename
}
if !fileExists(filepath.Join(store.StoreLocation, filename)) {
return nil, fmt.Errorf("Unknown album '%s'", name)
}
var metadata map[string]string = make(map[string]string)
err = yaml.UnmarshalStrict(yamlData, &metadata)
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
return err
}
return metadata["title"], nil
// 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 nil, err
}
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)

83
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'")
}

31
web/album.html.template

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ .Title }}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link rel="stylesheet" href="/css/main.css">
<script type="text/javascript" src="/js/main.js"></script>
</head>
<body>
<h1>{{ .Title }}</h1>
<ul>
{{ range .Media }}
{{ if eq .Type "photo" }}
<li>
<a href="media/{{ .ID }}/"><img src="raw/{{ .Files|photo }}" loading="lazy" /></a>
</li>
{{ else if eq .Type "video" }}
<li>
<a href="media/{{ .ID }}/">
<video loop muted poster="raw/{{ .Files|photo }}">
<source src="raw/{{ .Files|video }}" type="video/mp4">
</video>
</a>
</li>
{{ end }}
{{ end }}
<li><!-- Last item is here as a filler for the last row of the flex box --></li>
</ul>
</body>
</html>

67
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;
}

17
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);

23
web/media.html.template

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ .Caption }}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<link rel="stylesheet" href="/css/main.css">
</head>
<body class="media">
{{ if ne .Caption "" }}
<h1>{{ .Caption }}</h1>
{{ end }}
<div>
{{ if eq .Type "photo" }}
<img src="../../raw/{{ .Files|photo }}" />
{{ else if eq .Type "video" }}
<video controls autoplay poster="../../raw/{{ .Files|photo }}">
<source src="../../raw/{{ .Files|video }}" type="video/mp4">
</video>
{{ end }}
</div>
</body>
</html>
Loading…
Cancel
Save