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
|
- 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
|
||||||
|
|
2
album.go
2
album.go
|
@ -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;"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
package photoprism
|
|
12
camera.go
12
camera.go
|
@ -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
|
||||||
}
|
}
|
|
@ -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{
|
||||||
|
|
|
@ -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)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
22
file.go
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -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' },
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
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
|
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)
|
||||||
|
|
52
location.go
52
location.go
|
@ -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
|
||||||
|
|
42
photo.go
42
photo.go
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
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"
|
||||||
"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
2
tag.go
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue