Added additional photo meta data like aperture, lens and camera make; smaller perceptive hash; implemented stub for single photo view

This commit is contained in:
Michael Mayer 2018-09-24 19:07:43 +02:00
parent 13426caba2
commit bbab05f9db
15 changed files with 456 additions and 62 deletions

View File

@ -33,6 +33,7 @@ Vue.use(Vuetify, {
success: '#00BFA5',
warning: '#FFD600',
delete: '#E57373',
love: '#EF5350',
},
});

View File

@ -175,42 +175,63 @@
:key="photo.ID"
xs12 sm6 md4 lg3 d-flex
>
<v-card tile class="ma-2">
<v-img
:src="photo.getThumbnailUrl('square', 500)"
aspect-ratio="1"
v-bind:class="{ selected: photo.selected }"
@click="selectPhoto(photo)"
>
<v-layout
slot="placeholder"
fill-height
align-center
justify-center
ma-0
<v-hover>
<v-card tile class="ma-2" slot-scope="{ hover }"
:class="photo.selected ? 'elevation-14' : 'elevation-2'">
<v-img
:src="photo.getThumbnailUrl('square', 500)"
aspect-ratio="1"
v-bind:class="{ selected: photo.selected }"
style="cursor: pointer"
class="grey lighten-2"
@click="openPhoto(photo)"
>
<v-progress-circular indeterminate color="grey lighten-5"></v-progress-circular>
</v-layout>
</v-img>
<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-card-title primary-title class="pa-3">
<div>
<h3 class="subheading mb-0">{{ photo.PhotoTitle | truncate(50)}}</h3>
<div><v-icon small>date_range</v-icon> {{ photo.TakenAt | moment('DD/MM/YYYY hh:mm:ss') }}
<v-spacer></v-spacer>
<v-icon small>photo_camera</v-icon> {{ photo.CameraModel }}<v-spacer></v-spacer>
<template v-if="photo.LocationID"><v-icon small>location_on</v-icon> {{ photo.LocName ? photo.LocName + ', ' : ''}}{{ photo.LocCity ? photo.LocCity + ', ' : ''}}{{ photo.LocCounty ? photo.LocCounty + ', ' : ''}}{{ photo.LocCountry }}</template>
<template v-if="photo.CountryName"><v-icon small>location_on</v-icon> {{ photo.CountryName }}</template>
<v-btn v-if="hover || photo.selected" :flat="!hover" icon large absolute
:ripple="false" style="right: 4px; bottom: 4px;"
@click.stop.prevent="selectPhoto(photo)">
<v-icon v-if="photo.selected" color="white">check_box</v-icon>
<v-icon v-else color="white">check_box_outline_blank</v-icon>
</v-btn>
<v-btn v-if="hover || photo.PhotoFavorite" :flat="!hover" icon large absolute
:ripple="false" style="top: 4px; left: 4px"
@click.stop.prevent="likePhoto(photo)">
<v-icon v-if="photo.PhotoFavorite" color="white">favorite
</v-icon>
<v-icon v-else color="white">favorite_border</v-icon>
</v-btn>
</v-img>
<v-card-title primary-title class="pa-3">
<div>
<h3 class="subheading mb-2" :title="photo.PhotoTitle">{{ photo.PhotoTitle |
truncate(80) }}</h3>
<div class="caption">
<v-icon size="14">date_range</v-icon>
{{ photo.TakenAt | moment('DD/MM/YYYY hh:mm:ss') }}
<br/>
<v-icon size="14">photo_camera</v-icon>
{{ photo.getCamera() }}
<br/>
<v-icon size="14">location_on</v-icon>
<span :title="photo.getFullLocation()">{{ photo.getLocation() }}</span>
</div>
</div>
</div>
</v-card-title>
<!-- v-card-actions>
<v-btn flat color="orange">Like</v-btn>
<v-btn flat color="orange">Edit</v-btn>
</v-card-actions -->
</v-card>
</v-card-title>
</v-card>
</v-hover>
</v-flex>
</v-layout>
</v-container>
@ -231,14 +252,15 @@
:key="photo.ID"
xs12 sm6 md3 lg2 d-flex
v-bind:class="{ selected: photo.selected }"
class="photo-tile"
>
<v-tooltip bottom>
<v-card-actions flat tile class="d-flex" slot="activator" @click="selectPhoto(photo)"
@mouseover="overPhoto(photo)" @mouseleave="leavePhoto(photo)">
<v-hover>
<v-card tile class="ma-2" slot-scope="{ hover }"
:class="photo.selected ? 'elevation-14' : hover ? 'elevation-6' : 'elevation-2'">
<v-img :src="photo.getThumbnailUrl('square', 500)"
aspect-ratio="1"
class="grey lighten-2"
style="cursor: pointer"
@click="openPhoto(photo)"
>
<v-layout
slot="placeholder"
@ -247,12 +269,27 @@
justify-center
ma-0
>
<v-progress-circular indeterminate color="grey lighten-5"></v-progress-circular>
<v-progress-circular indeterminate
color="grey lighten-5"></v-progress-circular>
</v-layout>
<v-btn v-if="hover || photo.selected" :flat="!hover" icon large absolute
:ripple="false" style="right: 4px; bottom: 4px;"
@click.stop.prevent="selectPhoto(photo)">
<v-icon v-if="photo.selected" color="white">check_box</v-icon>
<v-icon v-else color="white">check_box_outline_blank</v-icon>
</v-btn>
<v-btn v-if="hover || photo.PhotoFavorite" :flat="!hover" icon large absolute
:ripple="false" style="top: 4px; left: 4px"
@click.stop.prevent="likePhoto(photo)">
<v-icon v-if="photo.PhotoFavorite" color="white">favorite</v-icon>
<v-icon v-else color="white">favorite_border</v-icon>
</v-btn>
</v-img>
</v-card-actions>
<span>{{ photo.PhotoTitle }}<br/>{{ photo.TakenAt | moment('DD/MM/YYYY') }} / {{ photo.CameraModel }}</span>
</v-tooltip>
</v-card>
</v-hover>
</v-flex>
</v-layout>
</v-container>
@ -273,6 +310,35 @@
</v-btn>
</v-snackbar>
</v-container>
<v-dialog v-model="viewDialog" fullscreen hide-overlay transition="dialog-bottom-transition">
<v-card v-if="viewDialogPhoto">
<v-img :src="viewDialogPhoto.getThumbnailUrl('fit', 500)"
:aspect-ratio="viewDialogPhoto.FileAspectRatio"
contain
class="black"
@click="closePhoto()"
style="cursor: pointer"
:max-width="window.width"
:max-height="window.height"
:srcset="viewDialogPhoto.getThumbnailSrcset()"
:sizes="viewDialogPhoto.getThumbnailSizes()"
>
<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>
</v-dialog>
</div>
</template>
@ -299,6 +365,12 @@
'snackbarVisible': false,
'snackbarText': '',
'advandedSearch': false,
'viewDialog': false,
'viewDialogPhoto': null,
'window': {
width: 0,
height: 0
},
'results': [],
'query': {
view: view,
@ -347,7 +419,14 @@
'selected': [],
};
},
destroyed() {
window.removeEventListener('resize', this.handleResize)
},
methods: {
handleResize() {
this.window.width = window.innerWidth;
this.window.height = window.innerHeight;
},
overPhoto(photo) {
},
@ -359,10 +438,22 @@
this.selected[i].selected = false;
}
this.selected = [];
this.snackbarText = '';
this.updateSnackbar();
},
updateSnackbar(text) {
if(!text) text = "";
this.snackbarText = text;
this.snackbarVisible = this.snackbarText !== "";
},
showSnackbar() {
this.snackbarVisible = this.snackbarText !== "";
},
hideSnackbar() {
this.snackbarVisible = false;
},
selectPhoto(photo) {
selectPhoto(photo, ev) {
if (photo.selected) {
for (let i = 0; i < this.selected.length; i++) {
if (this.selected[i].id === photo.id) {
@ -389,8 +480,19 @@
this.snackbarVisible = false;
}
},
openPhoto(photo) {
this.$alert.success('Open photo' + photo.PhotoTitle);
this.viewDialogPhoto = photo;
this.viewDialog = true;
this.hideSnackbar();
},
closePhoto() {
this.viewDialogPhoto = null;
this.viewDialog = false;
this.showSnackbar();
},
likePhoto(photo) {
photo.Favorite = !photo.Favorite;
photo.PhotoFavorite = !photo.PhotoFavorite;
},
deletePhoto(photo) {
this.$alert.success('Photo deleted');
@ -459,7 +561,17 @@
});
}
},
beforeRouteLeave(to, from, next) {
if (this.viewDialog) {
this.closePhoto()
next(false)
} else {
next()
}
},
created() {
window.addEventListener('resize', this.handleResize);
this.handleResize();
this.refreshList();
},
};

View File

@ -17,6 +17,106 @@ class Photo extends Abstract {
return '/api/v1/thumbnails/' + type + '/' + size + '/' + this.FileHash;
}
getThumbnailSrcset() {
const result = [];
result.push(this.getThumbnailUrl('fit', 320) + ' 320w');
result.push(this.getThumbnailUrl('fit', 500) + ' 500w');
result.push(this.getThumbnailUrl('fit', 720) + ' 720w');
result.push(this.getThumbnailUrl('fit', 1280) + ' 1280w');
result.push(this.getThumbnailUrl('fit', 1920) + ' 1920w');
result.push(this.getThumbnailUrl('fit', 2560) + ' 2560w');
result.push(this.getThumbnailUrl('fit', 3840) + ' 3840w');
return result.join(', ');
}
getThumbnailSizes() {
const result = [];
result.push('(max-width: 320px) or (max-height: 320px) 320px');
result.push('(max-width: 500px) or (max-height: 500px) 500px');
result.push('(max-width: 720px) or (max-height: 720px) 720px');
result.push('(max-width: 1280px) or (max-height: 1280px) 1280px');
result.push('(max-width: 1920px) or (max-height: 1920px) 1920px');
result.push('(max-width: 2560px) or (max-height: 2560px) 2560px');
result.push('(min-width: 1920px) or (min-height: 1920px) 3840px');
return result.join(', ');
}
getLocation() {
const location = [];
if (this.LocationID) {
if (this.LocName && !this.LocCity && !this.LocCounty) {
location.push(this.LocName)
} else if (this.LocCity) {
location.push(this.LocCity)
} else if (this.LocCounty) {
location.push(this.LocCounty)
}
if (this.LocState && LocState !== LocCity) {
location.push(this.LocState)
}
if (this.LocCountry) {
location.push(this.LocCountry)
}
} else if (this.CountryName) {
location.push(this.CountryName)
} else {
location.push('Unknown')
}
return location.join(', ');
}
getFullLocation() {
const location = [];
if (this.LocationID) {
if (this.LocName) {
location.push(this.LocName)
}
if (this.LocCity) {
location.push(this.LocCity)
}
if (this.LocPostcode) {
location.push(this.LocPostcode)
}
if (this.LocCounty) {
location.push(this.LocCounty)
}
if (this.LocState) {
location.push(this.LocState)
}
if (this.LocCountry) {
location.push(this.LocCountry)
}
} else if (this.CountryName) {
location.push(this.CountryName)
} else {
location.push('Unknown')
}
return location.join(', ');
}
getCamera() {
if (this.CameraModel) {
return this.CameraModel
}
return 'Unknown'
}
static getCollectionResource() {
return 'photos';
}

View File

@ -9,13 +9,14 @@ type Camera struct {
gorm.Model
CameraSlug string
CameraModel string
CameraMake string
CameraType string
CameraOwner string
CameraDescription string `gorm:"type:text;"`
CameraNotes string `gorm:"type:text;"`
}
func NewCamera(modelName string) *Camera {
func NewCamera(modelName string, makeName string) *Camera {
if modelName == "" {
modelName = "Unknown"
}
@ -24,6 +25,7 @@ func NewCamera(modelName string) *Camera {
result := &Camera{
CameraModel: modelName,
CameraMake: makeName,
CameraSlug: cameraSlug,
}
@ -31,7 +33,7 @@ func NewCamera(modelName string) *Camera {
}
func (c *Camera) FirstOrCreate(db *gorm.DB) *Camera {
db.FirstOrCreate(c, "camera_model = ?", c.CameraModel)
db.FirstOrCreate(c, "camera_model = ? AND camera_make = ?", c.CameraModel, c.CameraMake)
return c
}

43
internal/models/lens.go Normal file
View File

@ -0,0 +1,43 @@
package models
import (
"github.com/gosimple/slug"
"github.com/jinzhu/gorm"
)
type Lens struct {
gorm.Model
LensSlug string
LensModel string
LensMake string
LensType string
LensOwner string
LensDescription string `gorm:"type:text;"`
LensNotes string `gorm:"type:text;"`
}
func (Lens) TableName() string {
return "lenses"
}
func NewLens(modelName string, makeName string) *Lens {
if modelName == "" {
modelName = "Unknown"
}
lensSlug := slug.MakeLang(modelName, "en")
result := &Lens{
LensModel: modelName,
LensMake: makeName,
LensSlug: lensSlug,
}
return result
}
func (c *Lens) FirstOrCreate(db *gorm.DB) *Lens {
db.FirstOrCreate(c, "lens_model = ? AND lens_make = ?", c.LensModel, c.LensMake)
return c
}

View File

@ -9,6 +9,7 @@ type Photo struct {
gorm.Model
TakenAt time.Time
PhotoTitle string
PhotoTitleChanged bool
PhotoDescription string `gorm:"type:text;"`
PhotoNotes string `gorm:"type:text;"`
PhotoArtist string
@ -20,13 +21,19 @@ type Photo struct {
PhotoFavorite bool
PhotoLat float64
PhotoLong float64
PhotoFocalLength float64
PhotoAperture float64
Camera *Camera
CameraID uint
Lens *Lens
LensID uint
Country *Country
CountryID string
CountryChanged bool
Location *Location
LocationID uint
LocationChanged bool
Tags []*Tag `gorm:"many2many:photo_tags;"`
Files []*File
Albums []*Album `gorm:"many2many:album_photos;"`
Camera *Camera
CameraID uint
}

View File

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

View File

@ -0,0 +1,3 @@
package photoprism
const PerceptualHashSize = 4

View File

@ -4,6 +4,7 @@ import (
"errors"
"github.com/rwcarlsen/goexif/exif"
"github.com/rwcarlsen/goexif/mknote"
"math"
"strings"
"time"
)
@ -11,7 +12,12 @@ import (
type ExifData struct {
DateTime time.Time
Artist string
CameraMake string
CameraModel string
LensMake string
LensModel string
Aperture float64
FocalLength float64
UniqueID string
Lat float64
Long float64
@ -60,6 +66,42 @@ func (m *MediaFile) GetExifData() (*ExifData, error) {
m.exifData.CameraModel = strings.Replace(camModel.String(), "\"", "", -1)
}
if camMake, err := x.Get(exif.Make); err == nil {
m.exifData.CameraMake = strings.Replace(camMake.String(), "\"", "", -1)
}
if lensMake, err := x.Get(exif.LensMake); err == nil {
m.exifData.LensMake = strings.Replace(lensMake.String(), "\"", "", -1)
}
if lensModel, err := x.Get(exif.LensModel); err == nil {
m.exifData.LensModel = strings.Replace(lensModel.String(), "\"", "", -1)
}
if aperture, err := x.Get(exif.ApertureValue); err == nil {
number, denom, _ := aperture.Rat2(0)
if denom == 0 {
denom = 1
}
value := float64(number) / float64(denom)
m.exifData.Aperture = math.Round(value * 1000) / 1000
}
if focal, err := x.Get(exif.FocalLength); err == nil {
number, denom, _ := focal.Rat2(0)
if denom == 0 {
denom = 1
}
value := float64(number) / float64(denom)
m.exifData.FocalLength = math.Round(value * 1000) / 1000
}
if tm, err := x.DateTime(); err == nil {
m.exifData.DateTime = tm
}

View File

@ -55,5 +55,5 @@ func TestImporter_GetDestinationFilename(t *testing.T) {
assert.Nil(t, err)
assert.Equal(t, conf.OriginalsPath+"/2018/02/20180204_170813_B0770443A5F7.cr2", filename)
assert.Equal(t, conf.OriginalsPath+"/2018/02/20180204_170813_863A6248DCCA.cr2", filename)
}

View File

@ -115,7 +115,11 @@ func (i *Indexer) IndexMediaFile(mediaFile *MediaFile) string {
tags = i.appendTag(tags, location.LocType)
if photo.PhotoTitle == "" && location.LocName != "" { // TODO: User defined title format
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocName, location.LocCountry, mediaFile.GetDateCreated().Format("2006"))
if len(location.LocName) > 40 {
photo.PhotoTitle = fmt.Sprintf("%s / %s", strings.Title(location.LocName), mediaFile.GetDateCreated().Format("2006"))
} else {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", strings.Title(location.LocName), location.LocCity, mediaFile.GetDateCreated().Format("2006"))
}
} else if photo.PhotoTitle == "" && location.LocCity != "" {
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocCity, location.LocCountry, mediaFile.GetDateCreated().Format("2006"))
} else if photo.PhotoTitle == "" && location.LocCounty != "" {
@ -131,17 +135,23 @@ func (i *Indexer) IndexMediaFile(mediaFile *MediaFile) string {
}
}
photo.Tags = tags
if photo.PhotoTitle == "" {
if len(photo.Tags) > 0 { // TODO: User defined title format
photo.PhotoTitle = fmt.Sprintf("%s / %s", strings.Title(photo.Tags[0].TagLabel), mediaFile.GetDateCreated().Format("2006"))
} else if photo.Country != nil && photo.Country.CountryName != "" {
photo.PhotoTitle = fmt.Sprintf("%s / %s", strings.Title(photo.Country.CountryName), mediaFile.GetDateCreated().Format("2006"))
} else {
photo.PhotoTitle = fmt.Sprintf("Unknown / %s", mediaFile.GetDateCreated().Format("2006"))
}
}
photo.Tags = tags
photo.Camera = NewCamera(mediaFile.GetCameraModel(), mediaFile.GetCameraMake()).FirstOrCreate(i.db)
photo.Lens = NewLens(mediaFile.GetLensModel(), mediaFile.GetLensMake()).FirstOrCreate(i.db)
photo.PhotoFocalLength = mediaFile.GetFocalLength()
photo.PhotoAperture = mediaFile.GetAperture()
photo.Camera = NewCamera(mediaFile.GetCameraModel()).FirstOrCreate(i.db)
photo.TakenAt = mediaFile.GetDateCreated()
photo.PhotoCanonicalName = canonicalName
photo.PhotoFavorite = false
@ -160,9 +170,14 @@ func (i *Indexer) IndexMediaFile(mediaFile *MediaFile) string {
colorNames, photo.PhotoVibrantColor, photo.PhotoMutedColor = jpeg.GetColors()
photo.PhotoColors = strings.Join(colorNames, ", ")
photo.Camera = NewCamera(mediaFile.GetCameraModel(), mediaFile.GetCameraMake()).FirstOrCreate(i.db)
photo.Lens = NewLens(mediaFile.GetLensModel(), mediaFile.GetLensMake()).FirstOrCreate(i.db)
photo.PhotoFocalLength = mediaFile.GetFocalLength()
photo.PhotoAperture = mediaFile.GetAperture()
}
if photo.CountryID == "" {
if photo.LocationID == 0 {
var recentPhoto Photo
if result := i.db.Order(gorm.Expr("ABS(DATEDIFF(taken_at, ?)) ASC", photo.TakenAt)).Preload("Country").First(&recentPhoto); result.Error == nil {

View File

@ -163,6 +163,66 @@ func (m *MediaFile) GetCameraModel() string {
return result
}
func (m *MediaFile) GetCameraMake() string {
info, err := m.GetExifData()
var result string
if err == nil {
result = info.CameraMake
}
return result
}
func (m *MediaFile) GetLensModel() string {
info, err := m.GetExifData()
var result string
if err == nil {
result = info.LensModel
}
return result
}
func (m *MediaFile) GetLensMake() string {
info, err := m.GetExifData()
var result string
if err == nil {
result = info.LensMake
}
return result
}
func (m *MediaFile) GetFocalLength() float64 {
info, err := m.GetExifData()
var result float64
if err == nil {
result = info.FocalLength
}
return result
}
func (m *MediaFile) GetAperture() float64 {
info, err := m.GetExifData()
var result float64
if err == nil {
result = info.Aperture
}
return result
}
func (m *MediaFile) GetCanonicalName() string {
var postfix string
@ -198,7 +258,7 @@ func (m *MediaFile) GetPerceptualHash() (string, error) {
return m.perceptualHash, nil
}
hasher := ish.NewDifferenceHash(8, 8)
hasher := ish.NewDifferenceHash(PerceptualHashSize, PerceptualHashSize)
img, _, err := ish.LoadFile(m.GetFilename())
if err != nil {

View File

@ -85,27 +85,27 @@ func TestMediaFile_GetPerceptiveHash(t *testing.T) {
assert.Nil(t, err)
hash1, _ := mediaFile1.GetPerceptualHash()
assert.Equal(t, "66debc383325d3bd", hash1)
assert.Equal(t, "ef95", hash1)
mediaFile2, err := NewMediaFile(conf.ImportPath + "/20130203_193332_0AE340D280_V2.jpg")
assert.Nil(t, err)
hash2, _ := mediaFile2.GetPerceptualHash()
assert.Equal(t, "e6debc393325c3b9", hash2)
assert.Equal(t, "6f95", hash2)
distance, _ := mediaFile1.GetPerceptualDistance(hash2)
assert.Equal(t, 4, distance)
assert.Equal(t, 1, distance)
mediaFile3, err := NewMediaFile(conf.ImportPath + "/iphone/IMG_6788.JPG")
assert.Nil(t, err)
hash3, _ := mediaFile3.GetPerceptualHash()
assert.Equal(t, "f1e2858b171d3e78", hash3)
assert.Equal(t, "ad73", hash3)
distance, _ = mediaFile1.GetPerceptualDistance(hash3)
assert.Equal(t, 33, distance)
assert.Equal(t, 7, distance)
}
func TestMediaFile_GetMimeType(t *testing.T) {

View File

@ -7,6 +7,7 @@ import (
"github.com/pkg/errors"
"net/http"
"strconv"
"strings"
)
type OpenstreetmapAddress struct {
@ -74,7 +75,7 @@ func (m *MediaFile) GetLocation() (*Location, error) {
location.LocLong = lon
}
location.LocName = openstreetmapLocation.Name
location.LocName = strings.Title(openstreetmapLocation.Name)
location.LocPostcode = openstreetmapLocation.Address.Postcode
location.LocCounty = openstreetmapLocation.Address.County
location.LocState = openstreetmapLocation.Address.State

View File

@ -40,6 +40,12 @@ type PhotoSearchResult struct {
// Camera
CameraID uint
CameraModel string
CameraMake string
// Lens
LensID uint
LensModel string
LensMake string
// Country
CountryID string
@ -87,12 +93,14 @@ func (s *Search) Photos(form PhotoSearchForm) ([]PhotoSearchResult, error) {
q = q.Table("photos").
Select(`SQL_CALC_FOUND_ROWS photos.*,
files.id AS file_id, files.file_name, files.file_hash, files.file_type, files.file_mime, files.file_width, files.file_height, files.file_aspect_ratio, files.file_orientation,
cameras.camera_model,
cameras.camera_make, cameras.camera_model,
lenses.lens_make, lenses.lens_model,
countries.country_name,
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,
locations.loc_display_name, locations.loc_name, locations.loc_city, locations.loc_postcode, locations.loc_county, locations.loc_state, locations.loc_country, locations.loc_country_code, locations.loc_category, locations.loc_type,
GROUP_CONCAT(tags.tag_label) AS tags`).
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("JOIN lenses ON lenses.id = photos.lens_id").
Joins("LEFT JOIN countries ON countries.id = photos.country_id").
Joins("LEFT JOIN locations ON locations.id = photos.location_id").
Joins("LEFT JOIN photo_tags ON photo_tags.photo_id = photos.id").