Major search API and database refactoring
This commit is contained in:
parent
c1315b3741
commit
801b680f12
22 changed files with 307 additions and 207 deletions
|
@ -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
|
||||
|
|
2
album.go
2
album.go
|
@ -6,6 +6,6 @@ import (
|
|||
|
||||
type Album struct {
|
||||
gorm.Model
|
||||
Name string
|
||||
AlbumName string
|
||||
Photos []Photo `gorm:"many2many:album_photos;"`
|
||||
}
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
package photoprism
|
12
camera.go
12
camera.go
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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{
|
||||
|
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
22
file.go
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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' },
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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);
|
||||
|
|
70
indexer.go
70
indexer.go
|
@ -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)
|
||||
|
|
52
location.go
52
location.go
|
@ -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
|
||||
|
|
42
photo.go
42
photo.go
|
@ -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
|
||||
}
|
||||
|
|
50
query.go
50
query.go
|
@ -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
119
search.go
Normal 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
30
search_test.go
Normal 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)
|
||||
}
|
|
@ -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
2
tag.go
|
@ -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"`
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue