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 - High-performance command line tool
- [Web frontend](docs/img/screenshot.jpg) - [Web frontend](docs/img/screenshot.jpg)
- No proprietary or binary data formats - 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) - 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 - [Reverse geocoding](https://wiki.openstreetmap.org/wiki/Nominatim#Reverse_Geocoding) based on latitude and longitude
- Image search with powerful filters - Image search with powerful filters

View file

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

View file

@ -1 +0,0 @@
package photoprism

View file

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

View file

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

View file

@ -145,4 +145,8 @@ func (c *Config) MigrateDb() {
db := c.GetDb() db := c.GetDb()
db.AutoMigrate(&File{}, &Photo{}, &Tag{}, &Album{}, &Location{}, &Camera{}) 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) 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() infoRaw, err := image.GetExifData()

22
file.go
View file

@ -6,15 +6,15 @@ import (
type File struct { type File struct {
gorm.Model gorm.Model
Photo *Photo Photo *Photo
PhotoID uint PhotoID uint
PrimaryFile bool FilePrimary bool
Filename string FileName string
FileType string `gorm:"type:varchar(30)"` FileType string `gorm:"type:varchar(30)"`
MimeType string `gorm:"type:varchar(50)"` FileMime string `gorm:"type:varchar(50)"`
Width int FileWidth int
Height int FileHeight int
Orientation int FileOrientation int
AspectRatio float64 FileAspectRatio float64
Hash string `gorm:"type:varchar(100);unique_index"` 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/vuetify/dist/vuetify.min.css");
@import url("../node_modules/material-design-icons-iconfont/dist/material-design-icons.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"); @import url("alerts.css");
#app div.loading { #app div.loading {

View file

@ -118,39 +118,35 @@
<v-icon>delete</v-icon> <v-icon>delete</v-icon>
</v-btn> </v-btn>
</v-speed-dial> </v-speed-dial>
<div class="page-container photo-grid pt-3"> <v-container grid-list-sm fluid class="pa-0">
<template v-for="photo in items"> <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"> </v-flex>
<div class="info">{{ photo.TakenAt | moment("DD.MM.YYYY hh:mm:ss") }}<span class="right">{{ photo.CameraModel }}</span> </v-layout>
</div> </v-container>
<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>
<div style="clear: both"></div> <div style="clear: both"></div>
</v-container> </v-container>
</div> </div>
@ -175,7 +171,7 @@
return { return {
'advandedSearch': false, 'advandedSearch': false,
'items': [], 'results': [],
'query': { 'query': {
category: '', category: '',
country: '', country: '',
@ -188,7 +184,7 @@
'options': { 'options': {
'categories': [ { value: '', text: 'All Categories' }, { value: 'junction', text: 'Junction' }, { value: 'tourism', text: 'Tourism'}, { value: 'historic', text: 'Historic'} ], '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'}], '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' }], 'sorting': [{ value: '', text: 'Sort by date taken' }, { value: 'imported', text: 'Sort by date imported'}, { value: 'score', text: 'Sort by relevance' }],
}, },
'page': resultPage, 'page': resultPage,
@ -203,6 +199,9 @@
}; };
}, },
methods: { methods: {
selectPhoto(photo) {
this.$alert.success(photo.getEntityName());
},
likePhoto(photo) { likePhoto(photo) {
photo.Favorite = !photo.Favorite; photo.Favorite = !photo.Favorite;
}, },
@ -248,8 +247,8 @@
this.resultTotal = parseInt(response.headers['x-result-total']); this.resultTotal = parseInt(response.headers['x-result-total']);
this.resultCount = parseInt(response.headers['x-result-count']); this.resultCount = parseInt(response.headers['x-result-count']);
this.resultOffset = parseInt(response.headers['x-result-offset']); this.resultOffset = parseInt(response.headers['x-result-offset']);
this.items = response.models; this.results = response.models;
this.$alert.info(this.items.length + ' photos found'); this.$alert.info(this.results.length + ' photos found');
}); });
} }
}, },

View file

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

View file

@ -2,7 +2,7 @@ import Abstract from 'model/abstract';
class Photo extends Abstract { class Photo extends Abstract {
getEntityName() { getEntityName() {
return this.Title; return this.PhotoTitle;
} }
getId() { getId() {
@ -10,7 +10,7 @@ class Photo extends Abstract {
} }
getGoogleMapsLink() { 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() { static getCollectionResource() {

View file

@ -34,7 +34,7 @@ mock.onPut('foo/2').reply(200, putEntityResponse);
mock.onDelete('foo/2').reply(204, deleteEntityResponse); mock.onDelete('foo/2').reply(204, deleteEntityResponse);
describe('common/api', () => { 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( Api.get('foo').then(
(response) => { (response) => {
assert.equal(200, response.status); 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 if tag.Probability > 0.2 { // TODO: Use config variable
var tagModel Tag var tagModel Tag
if res := i.db.First(&tagModel, "label = ?", tag.Label); res.Error != nil { if res := i.db.First(&tagModel, "tag_label = ?", tag.Label); res.Error != nil {
tagModel.Label = tag.Label tagModel.TagLabel = tag.Label
} }
result = append(result, tagModel) result = append(result, tagModel)
@ -91,86 +91,84 @@ func (i *Indexer) IndexMediaFile(mediaFile *MediaFile) {
canonicalName := mediaFile.GetCanonicalNameFromFile() canonicalName := mediaFile.GetCanonicalNameFromFile()
fileHash := mediaFile.GetHash() 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 { if jpeg, err := mediaFile.GetJpeg(); err == nil {
// Perceptual Hash // Perceptual Hash
if perceptualHash, err := jpeg.GetPerceptualHash(); err == nil { if perceptualHash, err := jpeg.GetPerceptualHash(); err == nil {
photo.PerceptualHash = perceptualHash photo.PhotoPerceptualHash = perceptualHash
} }
// Geo Location // Geo Location
if exifData, err := jpeg.GetExifData(); err == nil { if exifData, err := jpeg.GetExifData(); err == nil {
photo.Lat = exifData.Lat photo.PhotoLat = exifData.Lat
photo.Long = exifData.Long photo.PhotoLong = exifData.Long
photo.Artist = exifData.Artist photo.PhotoArtist = exifData.Artist
} }
// Colors // PhotoColors
colorNames, photo.VibrantColor, photo.MutedColor = jpeg.GetColors() colorNames, photo.PhotoVibrantColor, photo.PhotoMutedColor = jpeg.GetColors()
photo.Colors = strings.Join(colorNames, ", ") photo.PhotoColors = strings.Join(colorNames, ", ")
// Tags (TensorFlow) // Tags (TensorFlow)
photo.Tags = i.GetImageTags(jpeg) photo.Tags = i.GetImageTags(jpeg)
for _, tag := range photo.Tags { for _, tag := range photo.Tags {
keywords = append(keywords, tag.Label) keywords = append(keywords, tag.TagLabel)
} }
} }
if location, err := mediaFile.GetLocation(); err == nil { if location, err := mediaFile.GetLocation(); err == nil {
i.db.FirstOrCreate(location, "id = ?", location.ID) i.db.FirstOrCreate(location, "id = ?", location.ID)
photo.Location = location 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 if location.LocName != "" { // TODO: User defined title format
photo.Title = fmt.Sprintf("%s / %s / %s", location.Name, location.Country, mediaFile.GetDateCreated().Format("2006")) photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocName, location.LocCountry, mediaFile.GetDateCreated().Format("2006"))
} else if location.City != "" { } else if location.LocCity != "" {
photo.Title = fmt.Sprintf("%s / %s / %s", location.City, location.Country, mediaFile.GetDateCreated().Format("2006")) photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocCity, location.LocCountry, mediaFile.GetDateCreated().Format("2006"))
} else if location.County != "" { } else if location.LocCounty != "" {
photo.Title = fmt.Sprintf("%s / %s / %s", location.County, location.Country, mediaFile.GetDateCreated().Format("2006")) 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 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 { } 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.Camera = NewCamera(mediaFile.GetCameraModel()).FirstOrCreate(i.db)
photo.TakenAt = mediaFile.GetDateCreated() photo.TakenAt = mediaFile.GetDateCreated()
photo.CanonicalName = canonicalName photo.PhotoCanonicalName = canonicalName
photo.Files = []File{} photo.Files = []File{}
photo.Albums = []Album{} photo.Albums = []Album{}
photo.Favorite = false photo.PhotoFavorite = false
photo.Private = true
photo.Deleted = false
i.db.Create(&photo) 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 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.PhotoID = photo.ID
file.PrimaryFile = isPrimary file.FilePrimary = isPrimary
file.Filename = mediaFile.GetFilename() file.FileName = mediaFile.GetFilename()
file.Hash = fileHash file.FileHash = fileHash
file.FileType = mediaFile.GetType() file.FileType = mediaFile.GetType()
file.MimeType = mediaFile.GetMimeType() file.FileMime = mediaFile.GetMimeType()
file.Orientation = mediaFile.GetOrientation() file.FileOrientation = mediaFile.GetOrientation()
if mediaFile.GetWidth() > 0 && mediaFile.GetHeight() > 0 { if mediaFile.GetWidth() > 0 && mediaFile.GetHeight() > 0 {
file.Width = mediaFile.GetWidth() file.FileWidth = mediaFile.GetWidth()
file.Height = mediaFile.GetHeight() file.FileHeight = mediaFile.GetHeight()
file.AspectRatio = mediaFile.GetAspectRatio() file.FileAspectRatio = mediaFile.GetAspectRatio()
} }
i.db.Create(&file) i.db.Create(&file)

View file

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

View file

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

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

2
tag.go
View file

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