Labels: Add context menu

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer 2020-02-04 05:18:22 +01:00
parent afbbfbdc31
commit e02cbe1b10
20 changed files with 445 additions and 54 deletions

View file

@ -2,11 +2,12 @@ import PNotify from "./p-notify.vue";
import PNavigation from "./p-navigation.vue";
import PLoadingBar from "./p-loading-bar.vue";
import PPhotoSearch from "./p-photo-search.vue";
import PPhotoClipboard from "./p-photo-clipboard.vue";
import PPhotoDetails from "./p-photo-details.vue";
import PPhotoTiles from "./p-photo-tiles.vue";
import PPhotoMosaic from "./p-photo-mosaic.vue";
import PPhotoList from "./p-photo-list.vue";
import PPhotoClipboard from "./p-photo-clipboard.vue";
import PLabelClipboard from "./p-label-clipboard.vue";
import PAlbumClipboard from "./p-album-clipboard.vue";
import PAlbumToolbar from "./p-album-toolbar.vue";
import PPhotoViewer from "./p-photo-viewer.vue";
@ -25,6 +26,7 @@ components.install = (Vue) => {
Vue.component("p-photo-list", PPhotoList);
Vue.component("p-photo-search", PPhotoSearch);
Vue.component("p-photo-clipboard", PPhotoClipboard);
Vue.component("p-label-clipboard", PLabelClipboard);
Vue.component("p-album-clipboard", PAlbumClipboard);
Vue.component("p-album-toolbar", PAlbumToolbar);
Vue.component("p-scroll-top", PScrollTop);

View file

@ -0,0 +1,145 @@
<template>
<div>
<v-container fluid class="pa-0" v-if="selection.length > 0">
<v-speed-dial
fixed
bottom
right
direction="top"
v-model="expanded"
transition="slide-y-reverse-transition"
class="p-clipboard p-label-clipboard"
id="t-clipboard"
>
<v-btn
slot="activator"
color="accent darken-2"
dark
fab
class="p-label-clipboard-menu"
>
<v-icon v-if="selection.length === 0">menu</v-icon>
<span v-else class="t-clipboard-count">{{ selection.length }}</span>
</v-btn>
<!-- v-btn
fab
dark
small
:title="labels.download"
color="download"
@click.stop="download()"
class="p-label-clipboard-download"
:disabled="selection.length !== 1"
>
<v-icon>cloud_download</v-icon>
</v-btn -->
<v-btn
fab
dark
small
:title="labels.addToAlbum"
color="album"
:disabled="selection.length === 0"
@click.stop="dialog.album = true"
class="p-photo-clipboard-album"
>
<v-icon>folder</v-icon>
</v-btn>
<v-btn
fab
dark
small
color="remove"
:title="labels.delete"
@click.stop="dialog.delete = true"
:disabled="selection.length === 0"
class="p-label-clipboard-delete"
>
<v-icon>delete</v-icon>
</v-btn>
<v-btn
fab
dark
small
color="accent"
@click.stop="clearClipboard()"
class="p-label-clipboard-clear"
>
<v-icon>clear</v-icon>
</v-btn>
</v-speed-dial>
</v-container>
<p-photo-album-dialog :show="dialog.album" @cancel="dialog.album = false"
@confirm="addToAlbum"></p-photo-album-dialog>
<p-label-delete-dialog :show="dialog.delete" @cancel="dialog.delete = false"
@confirm="batchDelete"></p-label-delete-dialog>
</div>
</template>
<script>
import Api from "common/api";
import Notify from "common/notify";
export default {
name: 'p-label-clipboard',
props: {
selection: Array,
refresh: Function,
},
data() {
return {
expanded: false,
dialog: {
delete: false,
album: false,
edit: false,
},
labels: {
download: this.$gettext("Download"),
delete: this.$gettext("Delete"),
addToAlbum: this.$gettext("Add to album"),
removeFromAlbum: this.$gettext("Remove"),
},
};
},
methods: {
clearClipboard() {
this.selection.splice(0, this.selection.length);
this.expanded = false;
},
addToAlbum(albumUUID) {
this.dialog.album = false;
Api.post(`albums/${albumUUID}/photos`, {"labels": this.selection}).then(() => this.onAdded());
},
onAdded() {
this.clearClipboard();
},
batchDelete() {
this.dialog.delete = false;
Api.post("batch/labels/delete", {"labels": this.selection}).then(this.onDeleted.bind(this));
},
onDeleted() {
Notify.success(this.$gettext("Labels deleted"));
this.clearClipboard();
},
download() {
if(this.selection.length !== 1) {
Notify.error(this.$gettext("You can only download one label"));
return;
}
this.onDownload(`/api/v1/labels/${this.selection[0]}/download`);
this.expanded = false;
},
onDownload(path) {
Notify.success(this.$gettext("Downloading..."));
window.open(path, "_blank");
},
}
};
</script>

View file

@ -74,7 +74,6 @@ main {
#photoprism main .p-inline-edit a,
#photoprism main .p-inline-edit a span {
color: #333333;
cursor: text;
}

View file

@ -20,8 +20,12 @@
#photoprism .p-photo-tiles .p-photo-select,
#photoprism .p-photo-details .p-photo-select,
#photoprism .p-photo-mosaic .p-photo-select,
#photoprism .p-albums-details .p-album-select {
#photoprism .p-photo-mosaic .p-photo-select {
right: 4px; bottom: 4px;
}
#photoprism .p-albums-details .p-album-select,
#photoprism .p-labels-details .p-label-select {
right: 4px; bottom: 4px;
}

View file

@ -3,6 +3,7 @@ import PPhotoAlbumDialog from "./p-photo-album-dialog.vue";
import PPhotoEditDialog from "./p-photo-edit-dialog.vue";
import PPhotoShareDialog from "./p-photo-share-dialog.vue";
import PAlbumDeleteDialog from "./p-album-delete-dialog.vue";
import PLabelDeleteDialog from "./p-label-delete-dialog.vue";
import PUploadDialog from "./p-upload-dialog.vue";
const dialogs = {};
@ -13,6 +14,7 @@ dialogs.install = (Vue) => {
Vue.component("p-photo-edit-dialog", PPhotoEditDialog);
Vue.component("p-photo-share-dialog", PPhotoShareDialog);
Vue.component("p-album-delete-dialog", PAlbumDeleteDialog);
Vue.component("p-label-delete-dialog", PLabelDeleteDialog);
Vue.component("p-upload-dialog", PUploadDialog);
};

View file

@ -0,0 +1,43 @@
<template>
<v-dialog lazy v-model="show" persistent max-width="350" class="p-label-delete-dialog" @keydown.esc="cancel">
<v-card raised elevation="24">
<v-container fluid class="pb-2 pr-2 pl-2">
<v-layout row wrap>
<v-flex xs3 text-xs-center>
<v-icon size="54" color="grey lighten-1">delete_outline</v-icon>
</v-flex>
<v-flex xs9 text-xs-left align-self-center>
<div class="subheading pr-1"><translate>Are you sure you want to delete these labels?</translate></div>
</v-flex>
<v-flex xs12 text-xs-right class="pt-3">
<v-btn @click.stop="cancel" depressed color="grey lighten-3" class="p-photo-dialog-cancel">
<translate>Cancel</translate>
</v-btn>
<v-btn color="blue-grey lighten-2" depressed dark @click.stop="confirm"
class="p-photo-dialog-confirm"><translate>Delete</translate>
</v-btn>
</v-flex>
</v-layout>
</v-container>
</v-card>
</v-dialog>
</template>
<script>
export default {
name: 'p-label-delete-dialog',
props: {
show: Boolean,
},
data() {
return {}
},
methods: {
cancel() {
this.$emit('cancel');
},
confirm() {
this.$emit('confirm');
},
}
}
</script>

View file

@ -50,7 +50,7 @@
</div>
</v-card-title>
</v-card>
<v-layout row wrap class="p-results">
<v-layout row wrap class="p-album-results">
<v-flex
v-for="(album, index) in results"
:key="index"
@ -60,7 +60,8 @@
<v-hover>
<v-card tile class="accent lighten-3"
slot-scope="{ hover }"
:class="selection.includes(album.AlbumUUID) ? 'elevation-10 ma-0' : 'elevation-0 ma-1'"
:dark="selection.includes(album.AlbumUUID)"
:class="selection.includes(album.AlbumUUID) ? 'elevation-10 ma-0 accent darken-1 white--text' : 'elevation-0 ma-1 accent lighten-3'"
:to="{name: 'album', params: {uuid: album.AlbumUUID, slug: album.AlbumSlug}}"
>
<v-img
@ -80,7 +81,7 @@
</v-layout>
<v-btn v-if="hover || selection.length > 0" :flat="!hover" :ripple="false"
icon small absolute
icon large absolute
class="p-album-select"
@click.stop.prevent="toggleSelection(album.AlbumUUID)">
<v-icon v-if="selection.includes(album.AlbumUUID)" color="white">check_circle
@ -328,6 +329,13 @@
this.selection.push(uuid)
}
},
removeSelection(uuid) {
const pos = this.selection.indexOf(uuid);
if (pos !== -1) {
this.selection.splice(pos, 1);
}
},
onUpdate(ev, data) {
if (!this.listen) return;
@ -356,9 +364,12 @@
for (let i = 0; i < data.entities.length; i++) {
const uuid = data.entities[i];
const index = this.results.findIndex((m) => m.AlbumUUID === uuid);
if (index >= 0) {
this.results.splice(index, 1);
}
this.removeSelection(uuid)
}
break;

View file

@ -36,10 +36,12 @@
<v-progress-linear color="secondary-dark" :indeterminate="true"></v-progress-linear>
</v-container>
<v-container fluid class="pa-0" v-else>
<p-label-clipboard :refresh="refresh" :selection="selection"></p-label-clipboard>
<p-scroll-top></p-scroll-top>
<v-container grid-list-xs fluid class="pa-2 p-labels p-labels-details">
<v-card v-if="results.length === 0" class="p-labels-empty secondary-light lighten-1" flat>
<v-card v-if="results.length === 0" class="p-labels-empty secondary-light lighten-1 ma-1" flat>
<v-card-title primary-title>
<div>
<h3 class="title mb-3">
@ -51,7 +53,7 @@
</div>
</v-card-title>
</v-card>
<v-layout row wrap>
<v-layout row wrap class="p-label-results">
<v-flex
v-for="(label, index) in results"
:key="index"
@ -59,7 +61,10 @@
xs6 sm4 md3 lg2 d-flex
>
<v-hover>
<v-card tile class="elevation-0 ma-1 accent lighten-3"
<v-card tile class="accent lighten-3"
slot-scope="{ hover }"
:dark="selection.includes(label.LabelUUID)"
:class="selection.includes(label.LabelUUID) ? 'elevation-10 ma-0 accent darken-1 white--text' : 'elevation-0 ma-1 accent lighten-3'"
:to="{name: 'photos', query: {q: 'label:' + label.LabelSlug}}">
<v-img
:src="label.getThumbnailUrl('tile_500')"
@ -76,6 +81,15 @@
<v-progress-circular indeterminate
color="accent lighten-5"></v-progress-circular>
</v-layout>
<v-btn v-if="hover || selection.length > 0" :flat="!hover" :ripple="false"
icon large absolute
class="p-label-select"
@click.stop.prevent="toggleSelection(label.LabelUUID)">
<v-icon v-if="selection.includes(label.LabelUUID)" color="white">check_circle
</v-icon>
<v-icon v-else color="accent lighten-3">radio_button_off</v-icon>
</v-btn>
</v-img>
<v-card-actions @click.stop.prevent="">
@ -120,6 +134,7 @@
<script>
import Label from "model/label";
import Event from "pubsub-js";
export default {
name: 'p-page-labels',
@ -146,12 +161,16 @@
const settings = {};
return {
subscriptions: [],
listen: false,
dirty: false,
results: [],
scrollDisabled: true,
loading: true,
pageSize: 24,
offset: 0,
selection: this.$clipboard.selection,
page: 0,
selection: [],
settings: settings,
filter: filter,
lastFilter: {},
@ -179,28 +198,55 @@
this.filter.q = '';
this.updateQuery();
},
toggleSelection(uuid) {
const pos = this.selection.indexOf(uuid);
if (pos !== -1) {
this.selection.splice(pos, 1);
} else {
this.selection.push(uuid)
}
},
removeSelection(uuid) {
const pos = this.selection.indexOf(uuid);
if (pos !== -1) {
this.selection.splice(pos, 1);
}
},
loadMore() {
if (this.scrollDisabled) return;
this.scrollDisabled = true;
this.listen = false;
this.offset += this.pageSize;
const count = this.dirty ? (this.page + 2) * this.pageSize : this.pageSize;
const offset = this.dirty ? 0 : this.offset;
const params = {
count: this.pageSize,
offset: this.offset,
count: count,
offset: offset,
};
Object.assign(params, this.lastFilter);
Label.search(params).then(response => {
this.results = this.results.concat(response.models);
this.page++;
this.offset += this.pageSize;
this.scrollDisabled = (response.models.length < this.pageSize);
this.results = this.dirty ? response.models : this.results.concat(response.models);
this.scrollDisabled = (response.models.length < count);
if (this.scrollDisabled) {
this.$notify.info(this.$gettext('All ') + this.results.length + this.$gettext(' labels loaded'));
}
}).catch(() => {
this.scrollDisabled = false;
}).finally(() => {
this.dirty = false;
this.loading = false;
this.listen = true;
});
},
updateQuery() {
@ -256,12 +302,14 @@
Object.assign(this.lastFilter, this.filter);
this.offset = 0;
this.page = 0;
this.loading = true;
this.listen = false;
const params = this.searchParams();
Label.search(params).then(response => {
this.loading = false;
this.offset = this.pageSize;
this.results = response.models;
@ -274,11 +322,66 @@
this.$nextTick(() => this.$emit("scrollRefresh"));
}
}).catch(() => this.loading = false);
}).finally(() => {
this.dirty = false;
this.loading = false;
this.listen = true;
});
},
onUpdate(ev, data) {
if (!this.listen) return;
if (!data || !data.entities) {
return
}
const type = ev.split('.')[1];
switch (type) {
case 'updated':
for (let i = 0; i < data.entities.length; i++) {
const values = data.entities[i];
const model = this.results.find((m) => m.LabelUUID === values.LabelUUID);
for (let key in values) {
if (values.hasOwnProperty(key)) {
model[key] = values[key];
}
}
}
break;
case 'deleted':
this.dirty = true;
for (let i = 0; i < data.entities.length; i++) {
const uuid = data.entities[i];
const index = this.results.findIndex((m) => m.LabelUUID === uuid);
if (index >= 0) {
this.results.splice(index, 1);
}
this.removeSelection(uuid)
}
break;
case 'created':
this.dirty = true;
break;
default:
console.warn("unexpected event type", ev);
}
}
},
created() {
this.search();
this.subscriptions.push(Event.subscribe("labels", (ev, data) => this.onUpdate(ev, data)));
},
destroyed() {
for(let i = 0; i < this.subscriptions.length; i++) {
Event.unsubscribe(this.subscriptions[i]);
}
},
};
</script>

View file

@ -244,37 +244,35 @@ func AddPhotosToAlbum(router *gin.RouterGroup, conf *config.Config) {
return
}
var f form.PhotoUUIDs
var f form.Selection
if err := c.BindJSON(&f); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
return
}
if len(f.Photos) == 0 {
log.Error("no photos selected")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst("no photos selected")})
return
}
uuid := c.Param("uuid")
q := query.New(conf.OriginalsPath(), conf.Db())
a, err := q.FindAlbumByUUID(c.Param("uuid"))
a, err := q.FindAlbumByUUID(uuid)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, ErrAlbumNotFound)
return
}
photos, err := q.PhotoSelection(f)
if err != nil {
log.Errorf("album: %s", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
return
}
db := conf.Db()
var added []*entity.PhotoAlbum
var failed []string
for _, photoUUID := range f.Photos {
if p, err := q.FindPhotoByUUID(photoUUID); err != nil {
failed = append(failed, photoUUID)
} else {
added = append(added, entity.NewPhotoAlbum(p.PhotoUUID, a.AlbumUUID).FirstOrCreate(db))
}
for _, p := range photos {
added = append(added, entity.NewPhotoAlbum(p.PhotoUUID, a.AlbumUUID).FirstOrCreate(db))
}
if len(added) == 1 {
@ -283,7 +281,7 @@ func AddPhotosToAlbum(router *gin.RouterGroup, conf *config.Config) {
event.Success(fmt.Sprintf("%d photos added to %s", len(added), a.AlbumName))
}
c.JSON(http.StatusOK, gin.H{"message": "photos added to album", "album": a, "added": added, "failed": failed})
c.JSON(http.StatusOK, gin.H{"message": "photos added to album", "album": a, "added": added})
})
}
@ -295,7 +293,7 @@ func RemovePhotosFromAlbum(router *gin.RouterGroup, conf *config.Config) {
return
}
var f form.PhotoUUIDs
var f form.Selection
if err := c.BindJSON(&f); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})

View file

@ -97,9 +97,10 @@ func LikeLabel(router *gin.RouterGroup, conf *config.Config) {
return
}
id := c.Param("uuid")
q := query.New(conf.OriginalsPath(), conf.Db())
label, err := q.FindLabelByUUID(c.Param("uuid"))
label, err := q.FindLabelByUUID(id)
if err != nil {
c.AbortWithStatusJSON(404, gin.H{"error": txt.UcFirst(err.Error())})
@ -115,6 +116,8 @@ func LikeLabel(router *gin.RouterGroup, conf *config.Config) {
})
}
PublishLabelEvent(EntityUpdated, id, c, q)
c.JSON(http.StatusOK, http.Response{})
})
}
@ -130,9 +133,10 @@ func DislikeLabel(router *gin.RouterGroup, conf *config.Config) {
return
}
id := c.Param("uuid")
q := query.New(conf.OriginalsPath(), conf.Db())
label, err := q.FindLabelByUUID(c.Param("uuid"))
label, err := q.FindLabelByUUID(id)
if err != nil {
c.AbortWithStatusJSON(404, gin.H{"error": txt.UcFirst(err.Error())})
@ -148,6 +152,8 @@ func DislikeLabel(router *gin.RouterGroup, conf *config.Config) {
})
}
PublishLabelEvent(EntityUpdated, id, c, q)
c.JSON(http.StatusOK, http.Response{})
})
}

View file

@ -25,7 +25,7 @@ func BatchPhotosArchive(router *gin.RouterGroup, conf *config.Config) {
start := time.Now()
var f form.PhotoUUIDs
var f form.Selection
if err := c.BindJSON(&f); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
@ -66,7 +66,7 @@ func BatchPhotosRestore(router *gin.RouterGroup, conf *config.Config) {
start := time.Now()
var f form.PhotoUUIDs
var f form.Selection
if err := c.BindJSON(&f); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
@ -106,7 +106,7 @@ func BatchAlbumsDelete(router *gin.RouterGroup, conf *config.Config) {
return
}
var f form.AlbumUUIDs
var f form.Selection
if err := c.BindJSON(&f); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
@ -146,7 +146,7 @@ func BatchPhotosPrivate(router *gin.RouterGroup, conf *config.Config) {
start := time.Now()
var f form.PhotoUUIDs
var f form.Selection
if err := c.BindJSON(&f); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
@ -181,7 +181,7 @@ func BatchPhotosStory(router *gin.RouterGroup, conf *config.Config) {
start := time.Now()
var f form.PhotoUUIDs
var f form.Selection
if err := c.BindJSON(&f); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
@ -207,3 +207,40 @@ func BatchPhotosStory(router *gin.RouterGroup, conf *config.Config) {
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("photos marked as story in %s", elapsed)})
})
}
// POST /api/v1/batch/labels/delete
func BatchLabelsDelete(router *gin.RouterGroup, conf *config.Config) {
router.POST("/batch/labels/delete", func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
var f form.Selection
if err := c.BindJSON(&f); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
return
}
if len(f.Labels) == 0 {
log.Error("no labels selected")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst("no labels selected")})
return
}
log.Infof("deleting labels: %#v", f.Labels)
db := conf.Db()
db.Where("label_uuid IN (?)", f.Labels).Delete(&entity.Label{})
event.Publish("config.updated", event.Data(conf.ClientConfig()))
event.Publish("labels.deleted", event.Data{
"entities": f.Labels,
})
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("labels deleted")})
})
}

View file

@ -28,7 +28,7 @@ func CreateZip(router *gin.RouterGroup, conf *config.Config) {
return
}
var f form.PhotoUUIDs
var f form.Selection
start := time.Now()
if err := c.BindJSON(&f); err != nil {

View file

@ -1,5 +0,0 @@
package form
type AlbumUUIDs struct {
Albums []string `json:"albums"`
}

View file

@ -1,5 +0,0 @@
package form
type PhotoUUIDs struct {
Photos []string `json:"photos"`
}

View file

@ -0,0 +1,15 @@
package form
type Selection struct {
Photos []string `json:"photos"`
Albums []string `json:"albums"`
Labels []string `json:"labels"`
}
func (f Selection) Empty() bool {
if len(f.Photos) > 0 || len(f.Albums) > 0 || len(f.Labels) > 0 {
return false
}
return true
}

View file

@ -33,5 +33,7 @@ func (l Location) Name() (result string) {
result = result[:i]
}
result = strings.SplitN(result, "/", 2)[0]
return txt.Title(strings.TrimSpace(result))
}

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
gc "github.com/patrickmn/go-cache"
"github.com/photoprism/photoprism/pkg/s2"
@ -89,7 +90,7 @@ func (l Location) CellID() (result string) {
}
func (l Location) Name() (result string) {
return l.LocName
return strings.SplitN(l.LocName, "/", 2)[0]
}
func (l Location) Category() (result string) {

View file

@ -0,0 +1,32 @@
package query
import (
"errors"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
)
// PhotoSelection returns all selected photos.
func (s *Repo) PhotoSelection(f form.Selection) (results []entity.Photo, err error) {
if f.Empty() {
return results, errors.New("no photos selected")
}
q := s.db.NewScope(nil).DB()
q = q.Table("photos").
Select("photos.*").
Joins("LEFT JOIN photos_labels ON photos_labels.photo_id = photos.id").
Joins("LEFT JOIN labels ON photos_labels.label_id = labels.id AND labels.deleted_at IS NULL").
Where("photos.deleted_at IS NULL").
Group("photos.id")
q = q.Where("photos.photo_uuid IN (?) OR labels.label_uuid IN (?)", f.Photos, f.Labels)
if result := q.Scan(&results); result.Error != nil {
return results, result.Error
}
return results, nil
}

View file

@ -55,6 +55,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.BatchPhotosPrivate(v1, conf)
api.BatchPhotosStory(v1, conf)
api.BatchAlbumsDelete(v1, conf)
api.BatchLabelsDelete(v1, conf)
api.GetAlbum(v1, conf)
api.CreateAlbum(v1, conf)