Major search API and database refactoring

This commit is contained in:
Michael Mayer 2018-09-12 16:37:30 +02:00
parent c1315b3741
commit 801b680f12
22 changed files with 307 additions and 207 deletions

View File

@ -23,7 +23,8 @@ Our goal is to provide the following features (tested as a proof-of-concept):
- High-performance command line tool
- [Web frontend](docs/img/screenshot.jpg)
- No proprietary or binary data formats
- Duplicate detection (RAW and JPEG can be used simultaneously)
- Automatic RAW to JPEG conversion
- Duplicate detection (JPEG and RAW can be used simultaneously)
- Automated tagging using [Google TensorFlow](https://www.tensorflow.org/install/install_go)
- [Reverse geocoding](https://wiki.openstreetmap.org/wiki/Nominatim#Reverse_Geocoding) based on latitude and longitude
- Image search with powerful filters

View File

@ -6,6 +6,6 @@ import (
type Album struct {
gorm.Model
Name string
AlbumName string
Photos []Photo `gorm:"many2many:album_photos;"`
}

View File

@ -1 +0,0 @@
package photoprism

View File

@ -6,9 +6,9 @@ import (
type Camera struct {
gorm.Model
ModelName string
Type string
Notes string
CameraModel string
CameraType string
CameraNotes string
}
func NewCamera(modelName string) *Camera {
@ -17,14 +17,14 @@ func NewCamera(modelName string) *Camera {
}
result := &Camera{
ModelName: modelName,
CameraModel: modelName,
}
return result
}
func (c *Camera) FirstOrCreate(db *gorm.DB) *Camera {
db.FirstOrCreate(c, "model_name = ?", c.ModelName)
db.FirstOrCreate(c, "camera_model = ?", c.CameraModel)
return c
}
}

View File

@ -14,7 +14,7 @@ func main() {
app := cli.NewApp()
app.Name = "PhotoPrism"
app.Usage = "Long-Term Digital Photo Archive"
app.Usage = "Digital Photo Archive"
app.Version = "0.2.0"
app.Flags = globalCliFlags
app.Commands = []cli.Command{

View File

@ -145,4 +145,8 @@ func (c *Config) MigrateDb() {
db := c.GetDb()
db.AutoMigrate(&File{}, &Photo{}, &Tag{}, &Album{}, &Location{}, &Camera{})
if !db.Dialect().HasIndex("photos", "photos_fulltext") {
db.Exec("CREATE FULLTEXT INDEX photos_fulltext ON photos (photo_title, photo_description, photo_artist, photo_keywords, photo_colors)")
}
}

View File

@ -73,7 +73,7 @@ func TestConverter_ConvertAll(t *testing.T) {
image := NewMediaFile(jpegFilename)
assert.Equal(t, jpegFilename, image.filename, "Filename must be the same")
assert.Equal(t, jpegFilename, image.filename, "FileName must be the same")
infoRaw, err := image.GetExifData()

22
file.go
View File

@ -6,15 +6,15 @@ import (
type File struct {
gorm.Model
Photo *Photo
PhotoID uint
PrimaryFile bool
Filename string
FileType string `gorm:"type:varchar(30)"`
MimeType string `gorm:"type:varchar(50)"`
Width int
Height int
Orientation int
AspectRatio float64
Hash string `gorm:"type:varchar(100);unique_index"`
Photo *Photo
PhotoID uint
FilePrimary bool
FileName string
FileType string `gorm:"type:varchar(30)"`
FileMime string `gorm:"type:varchar(50)"`
FileWidth int
FileHeight int
FileOrientation int
FileAspectRatio float64
FileHash string `gorm:"type:varchar(100);unique_index"`
}

View File

@ -1,6 +1,6 @@
@import url("../node_modules/vuetify/dist/vuetify.min.css");
@import url("../node_modules/material-design-icons-iconfont/dist/material-design-icons.css");
@import url("../node_modules/@fortawesome/fontawesome-free/css/all.css");
@import url("alerts.css");
#app div.loading {

View File

@ -118,39 +118,35 @@
<v-icon>delete</v-icon>
</v-btn>
</v-speed-dial>
<div class="page-container photo-grid pt-3">
<template v-for="photo in items">
<v-container grid-list-sm fluid class="pa-0">
<v-layout row wrap>
<v-flex
v-for="photo in results"
:key="photo.ID"
xs2
d-flex
>
<v-card-actions flat tile class="d-flex" @click="selectPhoto(photo)">
<v-img :src="'/api/v1/files/' + photo.FileID + '/square_thumbnail?size=500'"
aspect-ratio="1"
:title="photo.TakenAt | moment('DD.MM.YYYY hh:mm:ss')"
class="grey lighten-2"
>
<v-layout
slot="placeholder"
fill-height
align-center
justify-center
ma-0
>
<v-progress-circular indeterminate color="grey lighten-5"></v-progress-circular>
</v-layout>
</v-img>
</v-card-actions>
<div class="photo hover-12">
<div class="info">{{ photo.TakenAt | moment("DD.MM.YYYY hh:mm:ss") }}<span class="right">{{ photo.CameraModel }}</span>
</div>
<div class="actions">
<span class="left">
<a class="action like" v-bind:class="{ favorite: photo.Favorite }"
v-on:click="likePhoto(photo)">
<i v-if="!photo.Favorite" class="far fa-heart"></i>
<i v-if="photo.Favorite" class="fas fa-heart"></i>
</a>
</span>
<span class="center" v-if="photo.Location">
<v-tooltip bottom>
<a slot="activator" class="location" target="_blank" :href="photo.getGoogleMapsLink()">{{ photo.Location.Country }}</a>
<span>{{ photo.Location.DisplayName }}</span>
</v-tooltip>
</span>
<span class="right">
<a class="action delete" v-on:click="deletePhoto(photo)">
<i class="fas fa-trash"></i>
</a>
</span>
</div>
<template v-for="file in photo.Files">
<img v-if="file.FileType === 'jpg'"
:src="'/api/v1/files/' + file.ID + '/square_thumbnail?size=250'">
</template>
</div>
</template>
</div>
</v-flex>
</v-layout>
</v-container>
<div style="clear: both"></div>
</v-container>
</div>
@ -175,7 +171,7 @@
return {
'advandedSearch': false,
'items': [],
'results': [],
'query': {
category: '',
country: '',
@ -188,7 +184,7 @@
'options': {
'categories': [ { value: '', text: 'All Categories' }, { value: 'junction', text: 'Junction' }, { value: 'tourism', text: 'Tourism'}, { value: 'historic', text: 'Historic'} ],
'countries': [{ value: '', text: 'All Countries' }, { value: 'de', text: 'Germany' }, { value: 'ca', text: 'Canada'}, { value: 'us', text: 'United States'}],
'cameras': [{ value: '', text: 'All Cameras' }, { value: 'iPhone SE', text: 'iPhone SE' }, { value: 'EOS 6D', text: 'Canon EOS 6D'}],
'cameras': [{ value: '', text: 'All Cameras' }, { value: '1', text: 'iPhone SE' }, { value: '2', text: 'Canon EOS 6D'}],
'sorting': [{ value: '', text: 'Sort by date taken' }, { value: 'imported', text: 'Sort by date imported'}, { value: 'score', text: 'Sort by relevance' }],
},
'page': resultPage,
@ -203,6 +199,9 @@
};
},
methods: {
selectPhoto(photo) {
this.$alert.success(photo.getEntityName());
},
likePhoto(photo) {
photo.Favorite = !photo.Favorite;
},
@ -248,8 +247,8 @@
this.resultTotal = parseInt(response.headers['x-result-total']);
this.resultCount = parseInt(response.headers['x-result-count']);
this.resultOffset = parseInt(response.headers['x-result-offset']);
this.items = response.models;
this.$alert.info(this.items.length + ' photos found');
this.results = response.models;
this.$alert.info(this.results.length + ' photos found');
});
}
},

View File

@ -197,7 +197,7 @@
const q = query.hasOwnProperty('q') ? query['q'] : '';
return {
items: [
results: [
{ title: 'Photos', route: 'photos', icon: 'photo_library' },
{ title: 'Filters', route: 'filters', icon: 'search' },
{ title: 'Albums', route: 'albums', icon: 'folder' },

View File

@ -2,7 +2,7 @@ import Abstract from 'model/abstract';
class Photo extends Abstract {
getEntityName() {
return this.Title;
return this.PhotoTitle;
}
getId() {
@ -10,7 +10,7 @@ class Photo extends Abstract {
}
getGoogleMapsLink() {
return 'https://www.google.com/maps/place/' + this.Lat + ',' + this.Long;
return 'https://www.google.com/maps/place/' + this.PhotoLat + ',' + this.PhotoLong;
}
static getCollectionResource() {

View File

@ -34,7 +34,7 @@ mock.onPut('foo/2').reply(200, putEntityResponse);
mock.onDelete('foo/2').reply(204, deleteEntityResponse);
describe('common/api', () => {
it('get("foo") should return a list of items and return with HTTP code 200', (done) => {
it('get("foo") should return a list of results and return with HTTP code 200', (done) => {
Api.get('foo').then(
(response) => {
assert.equal(200, response.status);

View File

@ -38,8 +38,8 @@ func (i *Indexer) GetImageTags(jpeg *MediaFile) (result []Tag) {
if tag.Probability > 0.2 { // TODO: Use config variable
var tagModel Tag
if res := i.db.First(&tagModel, "label = ?", tag.Label); res.Error != nil {
tagModel.Label = tag.Label
if res := i.db.First(&tagModel, "tag_label = ?", tag.Label); res.Error != nil {
tagModel.TagLabel = tag.Label
}
result = append(result, tagModel)
@ -91,86 +91,84 @@ func (i *Indexer) IndexMediaFile(mediaFile *MediaFile) {
canonicalName := mediaFile.GetCanonicalNameFromFile()
fileHash := mediaFile.GetHash()
if result := i.db.First(&photo, "canonical_name = ?", canonicalName); result.Error != nil {
if result := i.db.First(&photo, "photo_canonical_name = ?", canonicalName); result.Error != nil {
if jpeg, err := mediaFile.GetJpeg(); err == nil {
// Perceptual Hash
if perceptualHash, err := jpeg.GetPerceptualHash(); err == nil {
photo.PerceptualHash = perceptualHash
photo.PhotoPerceptualHash = perceptualHash
}
// Geo Location
if exifData, err := jpeg.GetExifData(); err == nil {
photo.Lat = exifData.Lat
photo.Long = exifData.Long
photo.Artist = exifData.Artist
photo.PhotoLat = exifData.Lat
photo.PhotoLong = exifData.Long
photo.PhotoArtist = exifData.Artist
}
// Colors
colorNames, photo.VibrantColor, photo.MutedColor = jpeg.GetColors()
// PhotoColors
colorNames, photo.PhotoVibrantColor, photo.PhotoMutedColor = jpeg.GetColors()
photo.Colors = strings.Join(colorNames, ", ")
photo.PhotoColors = strings.Join(colorNames, ", ")
// Tags (TensorFlow)
photo.Tags = i.GetImageTags(jpeg)
for _, tag := range photo.Tags {
keywords = append(keywords, tag.Label)
keywords = append(keywords, tag.TagLabel)
}
}
if location, err := mediaFile.GetLocation(); err == nil {
i.db.FirstOrCreate(location, "id = ?", location.ID)
photo.Location = location
keywords = append(keywords, location.City, location.County, location.Country, location.LocationCategory, location.Name, location.LocationType)
keywords = append(keywords, location.LocCity, location.LocCounty, location.LocCountry, location.LocCategory, location.LocName, location.LocType)
if location.Name != "" { // TODO: User defined title format
photo.Title = fmt.Sprintf("%s / %s / %s", location.Name, location.Country, mediaFile.GetDateCreated().Format("2006"))
} else if location.City != "" {
photo.Title = fmt.Sprintf("%s / %s / %s", location.City, location.Country, mediaFile.GetDateCreated().Format("2006"))
} else if location.County != "" {
photo.Title = fmt.Sprintf("%s / %s / %s", location.County, location.Country, mediaFile.GetDateCreated().Format("2006"))
if location.LocName != "" { // TODO: User defined title format
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocName, location.LocCountry, mediaFile.GetDateCreated().Format("2006"))
} else if location.LocCity != "" {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocCity, location.LocCountry, mediaFile.GetDateCreated().Format("2006"))
} else if location.LocCounty != "" {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocCounty, location.LocCountry, mediaFile.GetDateCreated().Format("2006"))
}
}
if photo.Title == "" {
if photo.PhotoTitle == "" {
if len(photo.Tags) > 0 { // TODO: User defined title format
photo.Title = fmt.Sprintf("%s / %s", strings.Title(photo.Tags[0].Label), mediaFile.GetDateCreated().Format("2006"))
photo.PhotoTitle = fmt.Sprintf("%s / %s", strings.Title(photo.Tags[0].TagLabel), mediaFile.GetDateCreated().Format("2006"))
} else {
photo.Title = fmt.Sprintf("Unknown / %s", mediaFile.GetDateCreated().Format("2006"))
photo.PhotoTitle = fmt.Sprintf("Unknown / %s", mediaFile.GetDateCreated().Format("2006"))
}
}
photo.Keywords = getKeywordsAsString(keywords)
photo.PhotoKeywords = getKeywordsAsString(keywords)
photo.Camera = NewCamera(mediaFile.GetCameraModel()).FirstOrCreate(i.db)
photo.TakenAt = mediaFile.GetDateCreated()
photo.CanonicalName = canonicalName
photo.PhotoCanonicalName = canonicalName
photo.Files = []File{}
photo.Albums = []Album{}
photo.Favorite = false
photo.Private = true
photo.Deleted = false
photo.PhotoFavorite = false
i.db.Create(&photo)
}
if result := i.db.Where("file_type = 'jpg' AND primary_file = 1 AND photo_id = ?", photo.ID).First(&primaryFile); result.Error != nil {
if result := i.db.Where("file_type = 'jpg' AND file_primary = 1 AND photo_id = ?", photo.ID).First(&primaryFile); result.Error != nil {
isPrimary = mediaFile.GetType() == FileTypeJpeg
}
if result := i.db.First(&file, "hash = ?", fileHash); result.Error != nil {
if result := i.db.First(&file, "file_hash = ?", fileHash); result.Error != nil {
file.PhotoID = photo.ID
file.PrimaryFile = isPrimary
file.Filename = mediaFile.GetFilename()
file.Hash = fileHash
file.FilePrimary = isPrimary
file.FileName = mediaFile.GetFilename()
file.FileHash = fileHash
file.FileType = mediaFile.GetType()
file.MimeType = mediaFile.GetMimeType()
file.Orientation = mediaFile.GetOrientation()
file.FileMime = mediaFile.GetMimeType()
file.FileOrientation = mediaFile.GetOrientation()
if mediaFile.GetWidth() > 0 && mediaFile.GetHeight() > 0 {
file.Width = mediaFile.GetWidth()
file.Height = mediaFile.GetHeight()
file.AspectRatio = mediaFile.GetAspectRatio()
file.FileWidth = mediaFile.GetWidth()
file.FileHeight = mediaFile.GetHeight()
file.FileAspectRatio = mediaFile.GetAspectRatio()
}
i.db.Create(&file)

View File

@ -11,19 +11,19 @@ import (
type Location struct {
gorm.Model
DisplayName string
Lat float64
Long float64
Name string
City string
Postcode string
County string
State string
Country string
CountryCode string
LocationCategory string
LocationType string
Favorite bool
LocDisplayName string
LocLat float64
LocLong float64
LocCategory string
LocType string
LocName string
LocCity string
LocPostcode string
LocCounty string
LocState string
LocCountry string
LocCountryCode string
LocFavorite bool
}
type OpenstreetmapAddress struct {
@ -78,30 +78,30 @@ func (m *MediaFile) GetLocation() (*Location, error) {
}
if openstreetmapLocation.Address.City != "" {
location.City = openstreetmapLocation.Address.City
location.LocCity = openstreetmapLocation.Address.City
} else {
location.City = openstreetmapLocation.Address.Town
location.LocCity = openstreetmapLocation.Address.Town
}
if lat, err := strconv.ParseFloat(openstreetmapLocation.Lat, 64); err == nil {
location.Lat = lat
location.LocLat = lat
}
if lon, err := strconv.ParseFloat(openstreetmapLocation.Lon, 64); err == nil {
location.Long = lon
location.LocLong = lon
}
location.Name = openstreetmapLocation.Name
location.Postcode = openstreetmapLocation.Address.Postcode
location.County = openstreetmapLocation.Address.County
location.State = openstreetmapLocation.Address.State
location.Country = openstreetmapLocation.Address.Country
location.CountryCode = openstreetmapLocation.Address.CountryCode
location.DisplayName = openstreetmapLocation.DisplayName
location.LocationCategory = openstreetmapLocation.Category
location.LocName = openstreetmapLocation.Name
location.LocPostcode = openstreetmapLocation.Address.Postcode
location.LocCounty = openstreetmapLocation.Address.County
location.LocState = openstreetmapLocation.Address.State
location.LocCountry = openstreetmapLocation.Address.Country
location.LocCountryCode = openstreetmapLocation.Address.CountryCode
location.LocDisplayName = openstreetmapLocation.DisplayName
location.LocCategory = openstreetmapLocation.Category
if openstreetmapLocation.Type != "yes" && openstreetmapLocation.Type != "unclassified" {
location.LocationType = openstreetmapLocation.Type
location.LocType = openstreetmapLocation.Type
}
m.location = location

View File

@ -7,26 +7,24 @@ import (
type Photo struct {
gorm.Model
Title string
Description string `gorm:"type:text;"`
Artist string
Keywords string
Colors string
VibrantColor string
MutedColor string
TakenAt time.Time
CanonicalName string
PerceptualHash string
Tags []Tag `gorm:"many2many:photo_tags;"`
Files []File
Albums []Album `gorm:"many2many:album_photos;"`
Camera *Camera
CameraID uint
Lat float64
Long float64
Location *Location
LocationID uint
Favorite bool
Private bool
Deleted bool
TakenAt time.Time
PhotoTitle string
PhotoDescription string `gorm:"type:text;"`
PhotoArtist string
PhotoKeywords string
PhotoColors string
PhotoVibrantColor string
PhotoMutedColor string
PhotoCanonicalName string
PhotoPerceptualHash string
PhotoFavorite bool
PhotoLat float64
PhotoLong float64
Location *Location
LocationID uint
Tags []Tag `gorm:"many2many:photo_tags;"`
Files []File
Albums []Album `gorm:"many2many:album_photos;"`
Camera *Camera
CameraID uint
}

View File

@ -1,50 +0,0 @@
package photoprism
import (
"github.com/jinzhu/gorm"
)
type Search struct {
originalsPath string
db *gorm.DB
}
func NewQuery(originalsPath string, db *gorm.DB) *Search {
instance := &Search{
originalsPath: originalsPath,
db: db,
}
return instance
}
func (s *Search) FindPhotos(query string, count int, offset int) (photos []Photo) {
q := s.db.Preload("Tags").Preload("Files").Preload("Location").Preload("Albums")
q = q.Joins("JOIN files ON files.photo_id = photos.id AND files.primary_file")
if query != "" {
q = q.Joins("JOIN photo_tags ON photo_tags.photo_id = photos.id")
q = q.Joins("JOIN tags ON photo_tags.tag_id = tags.id")
q = q.Where("tags.label LIKE ?", "%"+query+"%").
Or("photos.keywords LIKE ?", "%"+query+"%").
Or("photos.colors LIKE ?", "%"+query+"%")
}
q = q.Where(&Photo{Deleted: false}).Order("taken_at").Limit(count).Offset(offset)
q = q.Find(&photos)
return photos
}
func (s *Search) FindFiles(count int, offset int) (files []File) {
s.db.Where(&File{}).Limit(count).Offset(offset).Find(&files)
return files
}
func (s *Search) FindFile(id string) (file File) {
s.db.Where("id = ?", id).First(&file)
return file
}

119
search.go Normal file
View File

@ -0,0 +1,119 @@
package photoprism
import (
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/forms"
"time"
)
type Search struct {
originalsPath string
db *gorm.DB
}
type PhotoSearchResult struct {
// Photo
ID uint
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt time.Time
TakenAt time.Time
PhotoTitle string
PhotoDescription string
PhotoArtist string
PhotoKeywords string
PhotoColors string
PhotoVibrantColor string
PhotoMutedColor string
PhotoCanonicalName string
PhotoPerceptualHash string
PhotoLat float64
PhotoLong float64
PhotoFavorite bool
// Camera
CameraID uint
CameraModel string
// Location
LocationID uint
LocDisplayName string
LocName string
LocCity string
LocPostcode string
LocCounty string
LocState string
LocCountry string
LocCountryCode string
LocCategory string
LocType string
// File
FileID uint
FileName string
FileType string
FileMime string
FileWidth int
FileHeight int
FileOrientation int
FileAspectRatio float64
}
func NewQuery(originalsPath string, db *gorm.DB) *Search {
instance := &Search{
originalsPath: originalsPath,
db: db,
}
return instance
}
func (s *Search) Photos(form forms.PhotoSearchForm) ([]PhotoSearchResult, error) {
q := s.db.Preload("Tags").Preload("Files").Preload("Location").Preload("Albums")
q = q.Table("photos").
Select(`photos.*,
files.id AS file_id, files.file_name, files.file_type, files.file_mime, files.file_width, files.file_height, files.file_aspect_ratio, files.file_orientation,
cameras.camera_model,
locations.loc_display_name, locations.loc_name, locations.loc_city, locations.loc_postcode, locations.loc_country, locations.loc_country_code, locations.loc_category, locations.loc_type`).
Joins("JOIN files ON files.photo_id = photos.id AND files.file_primary AND files.deleted_at IS NULL").
Joins("JOIN cameras ON cameras.id = photos.camera_id").
Joins("LEFT JOIN locations ON locations.id = photos.location_id").
Where("photos.deleted_at IS NULL")
if form.Query != "" {
q = q.Where("MATCH (photo_title, photo_description, photo_artist, photo_keywords, photo_colors) AGAINST (? IN BOOLEAN MODE)", form.Query)
}
q = q.Order("taken_at").Limit(form.Count).Offset(form.Offset)
results := make([]PhotoSearchResult, 0, form.Count)
rows, err := q.Rows()
if err != nil {
return results, err
}
defer rows.Close()
for rows.Next() {
var result PhotoSearchResult
s.db.ScanRows(rows, &result)
results = append(results, result)
}
return results, nil
}
func (s *Search) FindFiles(count int, offset int) (files []File) {
s.db.Where(&File{}).Limit(count).Offset(offset).Find(&files)
return files
}
func (s *Search) FindFile(id string) (file File) {
s.db.Where("id = ?", id).First(&file)
return file
}

30
search_test.go Normal file
View File

@ -0,0 +1,30 @@
package photoprism
import (
"github.com/photoprism/photoprism/forms"
"testing"
)
func TestSearch_Photos(t *testing.T) {
conf := NewTestConfig()
conf.CreateDirectories()
conf.InitializeTestData(t)
search := NewQuery(conf.OriginalsPath, conf.GetDb())
var form forms.PhotoSearchForm
form.Query = "elephant"
form.Count = 3
form.Offset = 0
photos, err := search.Photos(form)
if err != nil {
t.Fatal(err)
}
t.Log(photos)
}

View File

@ -5,7 +5,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/photoprism/photoprism"
"github.com/photoprism/photoprism/server/forms"
"github.com/photoprism/photoprism/forms"
"net/http"
"strconv"
)
@ -28,13 +28,15 @@ func Start(address string, port int, conf *photoprism.Config) {
c.MustBindWith(&form, binding.Form)
photos := search.FindPhotos(form.Query, form.Count, form.Offset)
if photos, err := search.Photos(form); err == nil {
c.Header("x-result-total", strconv.Itoa(len(photos)))
c.Header("x-result-count", strconv.Itoa(form.Count))
c.Header("x-result-offset", strconv.Itoa(form.Offset))
c.Header("x-result-total", strconv.Itoa(len(photos)))
c.Header("x-result-count", strconv.Itoa(form.Count))
c.Header("x-result-offset", strconv.Itoa(form.Offset))
c.JSON(http.StatusOK, photos)
c.JSON(http.StatusOK, photos)
} else {
c.AbortWithError(500, err)
}
})
// v1.OPTIONS()
@ -55,7 +57,7 @@ func Start(address string, port int, conf *photoprism.Config) {
file := search.FindFile(id)
mediaFile := photoprism.NewMediaFile(file.Filename)
mediaFile := photoprism.NewMediaFile(file.FileName)
thumbnail, _ := mediaFile.GetThumbnail(conf.ThumbnailsPath, size)
@ -70,7 +72,7 @@ func Start(address string, port int, conf *photoprism.Config) {
file := search.FindFile(id)
mediaFile := photoprism.NewMediaFile(file.Filename)
mediaFile := photoprism.NewMediaFile(file.FileName)
thumbnail, _ := mediaFile.GetSquareThumbnail(conf.ThumbnailsPath, size)

2
tag.go
View File

@ -6,5 +6,5 @@ import (
type Tag struct {
gorm.Model
Label string `gorm:"type:varchar(100);unique_index"`
TagLabel string `gorm:"type:varchar(100);unique_index"`
}