Download all related files using their existing name by default #449

Related issues:
- Configure download name for files and albums #848
- When downloading live photos, zip all the associated files #437
This commit is contained in:
Michael Mayer 2021-01-27 21:30:10 +01:00
parent 40ccb29313
commit 993e7466fa
19 changed files with 199 additions and 123 deletions

View file

@ -174,14 +174,14 @@ export default {
return;
}
Notify.success(this.$gettext("Downloading…"));
this.onDownload(`/api/v1/albums/${this.selection[0]}/dl?t=${this.$config.downloadToken()}`);
this.expanded = false;
},
onDownload(path) {
Notify.success(this.$gettext("Downloading…"));
download(path, "album.zip");
download(path, "photoprism-album.zip");
},
}
};

View file

@ -157,6 +157,7 @@ import Api from "common/api";
import Notify from "common/notify";
import Event from "pubsub-js";
import download from "common/download";
import Photo from "model/photo";
export default {
name: 'PPhotoClipboard',
@ -258,19 +259,19 @@ export default {
this.clearClipboard();
},
download() {
if (this.selection.length === 1) {
this.onDownload(`/api/v1/photos/${this.selection[0]}/dl?t=${this.$config.downloadToken()}`);
} else {
Api.post("zip", {"photos": this.selection}).then(r => {
switch (this.selection.length) {
case 0: return;
case 1: new Photo().find(this.selection[0]).then(p => p.downloadAll()); break;
default: Api.post("zip", {"photos": this.selection}).then(r => {
this.onDownload(`/api/v1/zip/${r.data.filename}?t=${this.$config.downloadToken()}`);
});
}
Notify.success(this.$gettext("Downloading…"));
this.expanded = false;
},
onDownload(path) {
Notify.success(this.$gettext("Downloading…"));
download(path, "photos.zip");
},
edit() {

View file

@ -181,10 +181,8 @@ export default {
}
Notify.success(this.$gettext("Downloading…"));
let photo = new Photo();
photo.find(this.item.uid).then((p) => {
p.downloadAll();
});
new Photo().find(this.item.uid).then(p => p.downloadAll());
},
onEdit() {
this.onPause();

View file

@ -416,21 +416,28 @@ export class Photo extends RestModel {
}
downloadAll() {
const token = config.downloadToken();
if (!this.Files) {
download(
`/api/v1/dl/${this.mainFileHash()}?t=${config.downloadToken()}`,
this.baseName(false)
);
const hash = this.mainFileHash();
if (hash) {
download(`/api/v1/dl/${hash}?t=${token}`, this.baseName(false));
} else if (config.debug) {
console.log("download: failed, empty file hash", this);
}
return;
}
this.Files.forEach((file) => {
if (!file || !file.Hash) {
console.warn("no file hash found for download", file);
if (!file || !file.Hash || file.Sidecar) {
// Don't download broken files and sidecars.
if (config.debug) console.log("download: skipped file", file);
return;
}
download(`/api/v1/dl/${file.Hash}?t=${config.downloadToken()}`, this.fileBase(file.Name));
download(`/api/v1/dl/${file.Hash}?t=${token}`, this.fileBase(file.Name));
});
}

View file

@ -80,14 +80,14 @@ export default {
return;
}
Notify.success(this.$gettext("Downloading…"));
this.onDownload(`/api/v1/albums/${this.selection[0]}/dl?t=${this.$config.downloadToken()}`);
this.expanded = false;
},
onDownload(path) {
Notify.success(this.$gettext("Downloading…"));
download(path, "album.zip");
download(path, "photoprism-album.zip");
},
}
};

View file

@ -9,7 +9,7 @@
transition="slide-y-reverse-transition"
class="p-clipboard p-photo-clipboard"
>
<template v-slot:activator>
<template #activator>
<v-btn
fab
dark
@ -49,6 +49,7 @@
import Api from "common/api";
import Notify from "common/notify";
import download from "common/download";
import Photo from "model/photo";
export default {
name: 'PPhotoClipboard',
@ -75,19 +76,19 @@ export default {
this.expanded = false;
},
download() {
if (this.selection.length === 1) {
this.onDownload(`/api/v1/photos/${this.selection[0]}/dl?t=${this.$config.downloadToken()}`);
} else {
Api.post("zip", {"photos": this.selection}).then(r => {
switch (this.selection.length) {
case 0: return;
case 1: new Photo().find(this.selection[0]).then(p => p.downloadAll()); break;
default: Api.post("zip", {"photos": this.selection}).then(r => {
this.onDownload(`/api/v1/zip/${r.data.filename}?t=${this.$config.downloadToken()}`);
});
}
Notify.success(this.$gettext("Downloading…"));
this.expanded = false;
},
onDownload(path) {
Notify.success(this.$gettext("Downloading…"));
download(path, "photos.zip");
},
}

View file

@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"path"
"strings"
"time"
"github.com/gin-gonic/gin"
@ -188,10 +189,19 @@ func ShareWithAccount(router *gin.RouterGroup) {
return
}
var aliases = make(map[string]int)
for _, file := range files {
dstFileName := path.Join(dst, file.ShareBase())
fileShare := entity.NewFileShare(file.ID, m.ID, dstFileName)
entity.FirstOrCreateFileShare(fileShare)
alias := path.Join(dst, file.ShareBase(0))
key := strings.ToLower(alias)
if seq := aliases[key]; seq > 0 {
alias = file.ShareBase(seq)
}
aliases[key] += 1
entity.FirstOrCreateFileShare(entity.NewFileShare(file.ID, m.ID, alias))
}
workers.StartShare(service.Config())

View file

@ -8,6 +8,8 @@ import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
@ -17,10 +19,6 @@ import (
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -473,37 +471,61 @@ func DownloadAlbum(router *gin.RouterGroup) {
return
}
p, err := query.AlbumPhotos(a, 10000)
files, err := query.AlbumPhotos(a, 10000)
if err != nil {
AbortEntityNotFound(c)
return
}
zipToken := rnd.Token(3)
zipFileName := fmt.Sprintf("%s-%s.zip", strings.Title(a.AlbumSlug), zipToken)
albumName := strings.Title(a.AlbumSlug)
if len(albumName) < 2 {
albumName = fmt.Sprintf("photoprism-album-%s", a.AlbumUID)
}
zipFileName := fmt.Sprintf("%s.zip", albumName)
AddDownloadHeader(c, zipFileName)
zipWriter := zip.NewWriter(c.Writer)
defer func() { _ = zipWriter.Close() }()
for _, f := range p {
fileName := photoprism.FileName(f.FileRoot, f.FileName)
fileAlias := f.ShareFileName()
var aliases = make(map[string]int)
for _, file := range files {
if file.FileHash == "" {
log.Warnf("download: empty file hash, skipped %s", txt.Quote(file.FileName))
continue
}
if file.FileSidecar {
log.Debugf("download: skipped sidecar %s", txt.Quote(file.FileName))
continue
}
fileName := photoprism.FileName(file.FileRoot, file.FileName)
alias := file.ShareBase(0)
key := strings.ToLower(alias)
if seq := aliases[key]; seq > 0 {
alias = file.ShareBase(seq)
}
aliases[key] += 1
if fs.FileExists(fileName) {
if err := addFileToZip(zipWriter, fileName, fileAlias); err != nil {
if err := addFileToZip(zipWriter, fileName, alias); err != nil {
log.Error(err)
Abort(c, http.StatusInternalServerError, i18n.ErrZipFailed)
return
}
log.Infof("album: added %s as %s", txt.Quote(f.FileName), txt.Quote(fileAlias))
log.Infof("download: added %s as %s", txt.Quote(file.FileName), txt.Quote(alias))
} else {
log.Errorf("album: file %s is missing", txt.Quote(f.FileName))
log.Errorf("download: file %s is missing", txt.Quote(file.FileName))
}
}
log.Infof("album: archive %s created in %s", txt.Quote(zipFileName), time.Since(start))
log.Infof("download: album zip %s created in %s", txt.Quote(zipFileName), time.Since(start))
})
}

View file

@ -117,13 +117,13 @@ func AlbumCover(router *gin.RouterGroup) {
return
}
cache.SetDefault(cacheKey, ThumbCache{thumbnail, f.ShareBase()})
cache.SetDefault(cacheKey, ThumbCache{thumbnail, f.ShareBase(0)})
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
AddCoverCacheHeader(c)
if c.Query("download") != "" {
c.FileAttachment(thumbnail, f.ShareBase())
c.FileAttachment(thumbnail, f.DownloadName(DownloadName(c), 0))
} else {
c.File(thumbnail)
}
@ -229,13 +229,13 @@ func LabelCover(router *gin.RouterGroup) {
return
}
cache.SetDefault(cacheKey, ThumbCache{thumbnail, f.ShareBase()})
cache.SetDefault(cacheKey, ThumbCache{thumbnail, f.ShareBase(0)})
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
AddCoverCacheHeader(c)
if c.Query("download") != "" {
c.FileAttachment(thumbnail, f.ShareBase())
c.FileAttachment(thumbnail, f.DownloadName(DownloadName(c), 0))
} else {
c.File(thumbnail)
}

View file

@ -19,6 +19,20 @@ import (
// TODO: GET /api/v1/dl/photo/:uid
// TODO: GET /api/v1/dl/album/:uid
// DownloadName returns the download file name type.
func DownloadName(c *gin.Context) entity.DownloadName {
switch c.Query("name") {
case "file":
return entity.DownloadNameFile
case "share":
return entity.DownloadNameShare
case "original":
return entity.DownloadNameOriginal
default:
return service.Config().Settings().Download.Name
}
}
// GET /api/v1/dl/:hash
//
// Parameters:
@ -51,30 +65,6 @@ func GetDownload(router *gin.RouterGroup) {
return
}
name := entity.DownloadNameFile
switch c.Query("name") {
case "file":
name = entity.DownloadNameFile
case "share":
name = entity.DownloadNameShare
case "original":
name = entity.DownloadNameOriginal
default:
name = service.Config().Settings().Download.Name
}
var downloadName string
switch name {
case entity.DownloadNameFile:
downloadName = f.Base()
case entity.DownloadNameOriginal:
downloadName = f.OriginalBase()
default:
downloadName = f.ShareBase()
}
c.FileAttachment(fileName, downloadName)
c.FileAttachment(fileName, f.DownloadName(DownloadName(c), 0))
})
}

View file

@ -147,9 +147,7 @@ func GetPhotoDownload(router *gin.RouterGroup) {
return
}
downloadFileName := f.ShareBase()
c.FileAttachment(fileName, downloadFileName)
c.FileAttachment(fileName, f.DownloadName(DownloadName(c), 0))
})
}

View file

@ -154,13 +154,13 @@ func GetThumb(router *gin.RouterGroup) {
return
}
cache.SetDefault(cacheKey, ThumbCache{thumbnail, f.ShareBase()})
cache.SetDefault(cacheKey, ThumbCache{thumbnail, f.ShareBase(0)})
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
AddThumbCacheHeader(c)
if download {
c.FileAttachment(thumbnail, f.ShareBase())
c.FileAttachment(thumbnail, f.DownloadName(DownloadName(c), 0))
} else {
c.File(thumbnail)
}

View file

@ -90,7 +90,7 @@ func GetVideo(router *gin.RouterGroup) {
AddContentTypeHeader(c, ContentTypeAvc)
if c.Query("download") != "" {
c.FileAttachment(fileName, f.ShareBase())
c.FileAttachment(fileName, f.DownloadName(DownloadName(c), 0))
} else {
c.File(fileName)
}

View file

@ -8,8 +8,11 @@ import (
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
@ -17,7 +20,6 @@ import (
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/gin-gonic/gin"
@ -64,9 +66,8 @@ func CreateZip(router *gin.RouterGroup) {
}
zipPath := path.Join(conf.TempPath(), "zip")
zipToken := rnd.Token(3)
zipYear := time.Now().Format("January-2006")
zipBaseName := fmt.Sprintf("Photos-%s-%s.zip", zipYear, zipToken)
zipToken := rnd.Token(8)
zipBaseName := fmt.Sprintf("photoprism-download-%s-%s.zip", time.Now().Format("20060102-150405"), zipToken)
zipFileName := path.Join(zipPath, zipBaseName)
if err := os.MkdirAll(zipPath, 0700); err != nil {
@ -86,25 +87,46 @@ func CreateZip(router *gin.RouterGroup) {
zipWriter := zip.NewWriter(newZipFile)
defer zipWriter.Close()
for _, f := range files {
fileName := photoprism.FileName(f.FileRoot, f.FileName)
fileAlias := f.ShareBase()
dlName := DownloadName(c)
var aliases = make(map[string]int)
for _, file := range files {
if file.FileHash == "" {
log.Warnf("download: empty file hash, skipped %s", txt.Quote(file.FileName))
continue
}
if file.FileSidecar {
log.Debugf("download: skipped sidecar %s", txt.Quote(file.FileName))
continue
}
fileName := photoprism.FileName(file.FileRoot, file.FileName)
alias := file.DownloadName(dlName, 0)
key := strings.ToLower(alias)
if seq := aliases[key]; seq > 0 {
alias = file.DownloadName(dlName, seq)
}
aliases[key] += 1
if fs.FileExists(fileName) {
if err := addFileToZip(zipWriter, fileName, fileAlias); err != nil {
if err := addFileToZip(zipWriter, fileName, alias); err != nil {
Error(c, http.StatusInternalServerError, err, i18n.ErrZipFailed)
return
}
log.Infof("zip: added %s as %s", txt.Quote(f.FileName), txt.Quote(fileAlias))
log.Infof("download: added %s as %s", txt.Quote(file.FileName), txt.Quote(alias))
} else {
log.Warnf("zip: file %s is missing", txt.Quote(f.FileName))
logError("zip", f.Update("FileMissing", true))
log.Warnf("download: file %s is missing", txt.Quote(file.FileName))
logError("download", file.Update("FileMissing", true))
}
}
elapsed := int(time.Since(start).Seconds())
log.Infof("zip: archive %s created in %s", txt.Quote(zipBaseName), time.Since(start))
log.Infof("download: zip %s created in %s", txt.Quote(zipBaseName), time.Since(start))
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "message": i18n.Msg(i18n.MsgZipCreatedIn, elapsed), "filename": zipBaseName})
})
@ -132,7 +154,7 @@ func DownloadZip(router *gin.RouterGroup) {
c.FileAttachment(zipFileName, zipBaseName)
if err := os.Remove(zipFileName); err != nil {
log.Errorf("zip: failed removing %s (%s)", txt.Quote(zipFileName), err.Error())
log.Errorf("download: failed removing %s (%s)", txt.Quote(zipFileName), err.Error())
}
})
}

View file

@ -108,26 +108,50 @@ func (m *File) BeforeCreate(scope *gorm.Scope) error {
return scope.SetColumn("FileUID", rnd.PPID('f'))
}
// DownloadName returns the download file name.
func (m *File) DownloadName(n DownloadName, seq int) string {
switch n {
case DownloadNameFile:
return m.Base(seq)
case DownloadNameOriginal:
return m.OriginalBase(seq)
default:
return m.ShareBase(seq)
}
}
// Base returns the file name without path.
func (m *File) Base() string {
func (m *File) Base(seq int) string {
if m.FileName == "" {
return m.ShareBase()
return m.ShareBase(seq)
}
return filepath.Base(m.FileName)
base := filepath.Base(m.FileName)
if seq > 0 {
return fmt.Sprintf("%s (%d)%s", fs.StripExt(base), seq, filepath.Ext(base))
}
return base
}
// OriginalBase returns the original file name without path.
func (m *File) OriginalBase() string {
func (m *File) OriginalBase(seq int) string {
if m.OriginalName == "" {
return m.Base()
return m.Base(seq)
}
return filepath.Base(m.OriginalName)
base := filepath.Base(m.OriginalName)
if seq > 0 {
return fmt.Sprintf("%s (%d)%s", fs.StripExt(base), seq, filepath.Ext(base))
}
return base
}
// ShareBase returns a meaningful file name useful for sharing.
func (m *File) ShareBase() string {
// ShareBase returns a meaningful file name for sharing.
func (m *File) ShareBase(seq int) string {
photo := m.RelatedPhoto()
if photo == nil {
@ -140,11 +164,12 @@ func (m *File) ShareBase() string {
name := strings.Title(slug.MakeLang(photo.PhotoTitle, "en"))
taken := photo.TakenAtLocal.Format("20060102-150405")
token := rnd.Token(3)
result := fmt.Sprintf("%s-%s-%s.%s", taken, name, token, m.FileType)
if seq > 0 {
return fmt.Sprintf("%s-%s (%d).%s", taken, name, seq, m.FileType)
}
return result
return fmt.Sprintf("%s-%s.%s", taken, name, m.FileType)
}
// Changed returns true if new and old file size or modified time are different.

View file

@ -29,7 +29,7 @@ func TestFile_ShareFileName(t *testing.T) {
photo := &Photo{TakenAtLocal: time.Date(2019, 01, 15, 0, 0, 0, 0, time.UTC), PhotoTitle: "Berlin / Morning Mood"}
file := &File{Photo: photo, FileType: "jpg", FileUID: "foobar345678765", FileHash: "e98eb86480a72bd585d228a709f0622f90e86cbc"}
filename := file.ShareBase()
filename := file.ShareBase(0)
assert.Contains(t, filename, "20190115-000000-Berlin-Morning-Mood")
assert.Contains(t, filename, fs.JpegExt)
@ -38,14 +38,14 @@ func TestFile_ShareFileName(t *testing.T) {
photo := &Photo{TakenAtLocal: time.Date(2019, 01, 15, 0, 0, 0, 0, time.UTC), PhotoTitle: ""}
file := &File{Photo: photo, FileType: "jpg", PhotoUID: "123", FileUID: "foobar345678765", FileHash: "e98eb86480a72bd585d228a709f0622f90e86cbc"}
filename := file.ShareBase()
filename := file.ShareBase(0)
assert.Equal(t, filename, "e98eb86480a72bd585d228a709f0622f90e86cbc.jpg")
})
t.Run("photo without photo", func(t *testing.T) {
file := &File{Photo: nil, FileType: "jpg", FileUID: "foobar345678765", FileHash: "e98eb86480a72bd585d228a709f0622f90e86cbc"}
filename := file.ShareBase()
filename := file.ShareBase(0)
assert.Equal(t, "e98eb86480a72bd585d228a709f0622f90e86cbc.jpg", filename)
})
@ -54,14 +54,14 @@ func TestFile_ShareFileName(t *testing.T) {
file := &File{Photo: photo, FileType: "jpg", FileUID: "foobar345678765", FileHash: "e98"}
filename := file.ShareBase()
filename := file.ShareBase(0)
assert.NotContains(t, filename, "20190115-000000-Berlin-Morning-Mood")
})
t.Run("no file uid", func(t *testing.T) {
file := &File{Photo: nil, FileType: "jpg", FileHash: "e98ijhyt"}
filename := file.ShareBase()
filename := file.ShareBase(0)
assert.Equal(t, filename, "e98ijhyt.jpg")
})

View file

@ -7,7 +7,6 @@ import (
"github.com/gosimple/slug"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/ulule/deepcopier"
)
@ -73,6 +72,7 @@ type PhotoResult struct {
FileHeight int `json:"Height"`
FilePortrait bool `json:"Portrait"`
FilePrimary bool `json:"-"`
FileSidecar bool `json:"-"`
FileMissing bool `json:"-"`
FileVideo bool `json:"-"`
FileDuration time.Duration `json:"-"`
@ -144,7 +144,8 @@ func (m PhotoResults) Merged() (PhotoResults, int, error) {
return merged, count, nil
}
func (m *PhotoResult) ShareFileName() string {
// ShareBase returns a meaningful file name for sharing.
func (m *PhotoResult) ShareBase(seq int) string {
var name string
if m.PhotoTitle != "" {
@ -154,9 +155,10 @@ func (m *PhotoResult) ShareFileName() string {
}
taken := m.TakenAtLocal.Format("20060102-150405")
token := rnd.Token(3)
result := fmt.Sprintf("%s-%s-%s.%s", taken, name, token, m.FileType)
if seq > 0 {
return fmt.Sprintf("%s-%s (%d).%s", taken, name, seq, m.FileType)
}
return result
return fmt.Sprintf("%s-%s.%s", taken, name, m.FileType)
}

View file

@ -322,7 +322,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
Files: nil,
}
r := result1.ShareFileName()
r := result1.ShareBase(0)
assert.Contains(t, r, "20131111-090718-Phototitle123")
})
t.Run("without photo title", func(t *testing.T) {
@ -385,7 +385,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
Files: nil,
}
r := result1.ShareFileName()
r := result1.ShareBase(0)
assert.Contains(t, r, "20151111-090718-uid123")
})
}

View file

@ -27,11 +27,11 @@ func PhotoSearch(f form.PhotoSearch) (results PhotoResults, count int, err error
// Base query.
s = s.Table("photos").
Select(`photos.*, photos.id AS composite_id,
files.id AS file_id, files.file_uid, files.instance_id, files.file_primary, files.file_missing, files.file_name,
files.file_root, files.file_hash, files.file_codec, files.file_type, files.file_mime, files.file_width,
files.file_height, files.file_portrait, files.file_aspect_ratio, files.file_orientation, files.file_main_color,
files.file_colors, files.file_luminance, files.file_chroma, files.file_projection,
files.file_diff, files.file_video, files.file_duration, files.file_size,
files.id AS file_id, files.file_uid, files.instance_id, files.file_primary, files.file_sidecar,
files.file_portrait,files.file_video, files.file_missing, files.file_name, files.file_root, files.file_hash,
files.file_codec, files.file_type, files.file_mime, files.file_width, files.file_height,
files.file_aspect_ratio, files.file_orientation, files.file_main_color, files.file_colors, files.file_luminance,
files.file_chroma, files.file_projection, files.file_diff, files.file_duration, files.file_size,
cameras.camera_make, cameras.camera_model,
lenses.lens_make, lenses.lens_model,
places.place_label, places.place_city, places.place_state, places.place_country`).