Merge pull request #518 from Freika/fix/trips-photoprism-integration

Trips photoprism integration
This commit is contained in:
Evgenii Burmakin 2024-12-10 20:04:32 +01:00 committed by GitHub
commit d246448386
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 281 additions and 69 deletions

View file

@ -1 +1 @@
0.19.3
0.19.4

View file

@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
# 0.19.4 - 2024-12-10
⚠️ This release introduces a breaking change. ⚠️
The `GET /api/v1/trips/:id/photos` endpoint now returns a different structure of the response:
```diff
{
id: 1,
latitude: 10,
longitude: 10,
localDateTime: "2024-01-01T00:00:00Z",
originalFileName: "photo.jpg",
city: "Berlin",
state: "Berlin",
country: "Germany",
type: "image",
+ orientation: "portrait",
source: "photoprism"
}
```
### Fixed
- Fixed a bug where the Photoprism photos were not being shown on the trip page.
- Fixed a bug where the Immich photos were not being shown on the trip page.
### Added
- A link to the Photoprism photos on the trip page if there are any.
- A `orientation` field in the Api::PhotoSerializer, hence the `GET /api/v1/photos` endpoint now includes the orientation of the photo. Valid values are `portrait` and `landscape`.
- Examples for the `type`, `orientation` and `source` fields in the `GET /api/v1/photos` endpoint in the Swagger UI.
# 0.19.3 - 2024-12-06
### Changed

View file

@ -36,9 +36,7 @@ class MapController < ApplicationController
@distance ||= 0
@coordinates.each_cons(2) do
@distance += Geocoder::Calculations.distance_between(
[_1[0], _1[1]], [_2[0], _2[1]], units: DISTANCE_UNIT
)
@distance += DistanceCalculator.new([_1[0], _1[1]], [_2[0], _2[1]]).call
end
@distance.round(1)

View file

@ -15,9 +15,10 @@ class TripsController < ApplicationController
:country
).map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] }
@photos = Rails.cache.fetch("trip_photos_#{@trip.id}", expires_in: 1.day) do
@trip.photos
@photo_previews = Rails.cache.fetch("trip_photos_#{@trip.id}", expires_in: 1.day) do
@trip.photo_previews
end
@photo_sources = @trip.photo_sources
end
def new

View file

@ -110,14 +110,4 @@ module ApplicationHelper
def human_date(date)
date.strftime('%e %B %Y')
end
def immich_search_url(base_url, start_date, end_date)
query = {
takenAfter: "#{start_date.to_date}T00:00:00.000Z",
takenBefore: "#{end_date.to_date}T23:59:59.999Z"
}
encoded_query = URI.encode_www_form_component(query.to_json)
"#{base_url}/search?query=#{encoded_query}"
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
module TripsHelper
def immich_search_url(base_url, start_date, end_date)
query = {
takenAfter: "#{start_date.to_date}T00:00:00.000Z",
takenBefore: "#{end_date.to_date}T23:59:59.999Z"
}
encoded_query = URI.encode_www_form_component(query.to_json)
"#{base_url}/search?query=#{encoded_query}"
end
def photoprism_search_url(base_url, start_date, _end_date)
"#{base_url}/library/browse?view=cards&year=#{start_date.year}&month=#{start_date.month}&order=newest&public=true&quality=3"
end
def photo_search_url(source, settings, start_date, end_date)
case source
when 'immich'
immich_search_url(settings['immich_url'], start_date, end_date)
when 'photoprism'
photoprism_search_url(settings['photoprism_url'], start_date, end_date)
end
end
end

View file

@ -18,8 +18,9 @@ class Import < ApplicationRecord
end
def years_and_months_tracked
points.order(:timestamp).map do |point|
[Time.zone.at(point.timestamp).year, Time.zone.at(point.timestamp).month]
points.order(:timestamp).pluck(:timestamp).map do |timestamp|
time = Time.zone.at(timestamp)
[time.year, time.month]
end.uniq
end
end

View file

@ -17,39 +17,34 @@ class Trip < ApplicationRecord
points.pluck(:country).uniq.compact
end
def photos
return [] if user.settings['immich_url'].blank? || user.settings['immich_api_key'].blank?
immich_photos = Immich::RequestPhotos.new(
user,
start_date: started_at.to_date.to_s,
end_date: ended_at.to_date.to_s
).call.reject { |asset| asset['type'].downcase == 'video' }
# let's count what photos are more: vertical or horizontal and select the ones that are more
vertical_photos = immich_photos.select { _1['exifInfo']['orientation'] == '6' }
horizontal_photos = immich_photos.select { _1['exifInfo']['orientation'] == '3' }
# this is ridiculous, but I couldn't find my way around frontend
# to show all photos in the same height
photos = vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos
photos.sample(12).sort_by { _1['localDateTime'] }.map do |asset|
{ url: "/api/v1/photos/#{asset['id']}/thumbnail.jpg?api_key=#{user.api_key}" }
def photo_previews
@photo_previews ||= select_dominant_orientation(photos).sample(12)
end
def photo_sources
@photo_sources ||= photos.map { _1[:source] }.uniq
end
private
def photos
@photos ||= Trips::Photos.new(self, user).call
end
def select_dominant_orientation(photos)
vertical_photos = photos.select { |photo| photo[:orientation] == 'portrait' }
horizontal_photos = photos.select { |photo| photo[:orientation] == 'landscape' }
# this is ridiculous, but I couldn't find my way around frontend
# to show all photos in the same height
vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos
end
def calculate_distance
distance = 0
points.each_cons(2) do |point1, point2|
distance_between = Geocoder::Calculations.distance_between(
point1.to_coordinates, point2.to_coordinates, units: ::DISTANCE_UNIT
)
distance += distance_between
distance += DistanceCalculator.new(point1, point2).call
end
self.distance = distance.round

View file

@ -28,7 +28,9 @@ class Visit < ApplicationRecord
def default_radius
return area&.radius if area.present?
radius = points.map { Geocoder::Calculations.distance_between(center, [_1.latitude, _1.longitude]) }.max
radius = points.map do |point|
DistanceCalculator.new(center, [point.latitude, point.longitude]).call
end.max
radius && radius >= 15 ? radius : 15
end

View file

@ -17,6 +17,7 @@ class Api::PhotoSerializer
state: state,
country: country,
type: type,
orientation: orientation,
source: source
}
end
@ -60,4 +61,13 @@ class Api::PhotoSerializer
def type
(photo['type'] || photo['Type']).downcase
end
def orientation
case source
when 'immich'
photo.dig('exifInfo', 'orientation') == '6' ? 'portrait' : 'landscape'
when 'photoprism'
photo['Portrait'] ? 'portrait' : 'landscape'
end
end
end

View file

@ -37,15 +37,7 @@ class Immich::RequestPhotos
items = response.dig('assets', 'items')
if items.blank?
Rails.logger.debug('==== IMMICH RESPONSE WITH NO ITEMS ====')
Rails.logger.debug("START_DATE: #{start_date}")
Rails.logger.debug("END_DATE: #{end_date}")
Rails.logger.debug(response)
Rails.logger.debug('==== IMMICH RESPONSE WITH NO ITEMS ====')
break
end
break if items.blank?
data << items

View file

@ -0,0 +1,43 @@
# frozen_string_literal: true
class Trips::Photos
def initialize(trip, user)
@trip = trip
@user = user
end
def call
return [] unless can_fetch_photos?
photos
end
private
attr_reader :trip, :user
def can_fetch_photos?
user.immich_integration_configured? || user.photoprism_integration_configured?
end
def photos
return @photos if defined?(@photos)
photos = Photos::Search.new(
user,
start_date: trip.started_at.to_date.to_s,
end_date: trip.ended_at.to_date.to_s
).call
@photos = photos.map { |photo| photo_thumbnail(photo) }
end
def photo_thumbnail(asset)
{
id: asset[:id],
url: "/api/v1/photos/#{asset[:id]}/thumbnail.jpg?api_key=#{user.api_key}&source=#{asset[:source]}",
source: asset[:source],
orientation: asset[:orientation]
}
end
end

View file

@ -36,8 +36,8 @@
</div>
<!-- Photos Grid Section -->
<% if @photos.any? %>
<% @photos.each_slice(4) do |slice| %>
<% if @photo_previews.any? %>
<% @photo_previews.each_slice(4) do |slice| %>
<div class="h-32 flex gap-4 mt-4 justify-center">
<% slice.each do |photo| %>
<div class="flex-1 h-full overflow-hidden rounded-lg transition-transform duration-300 hover:scale-105 hover:shadow-lg">
@ -52,9 +52,13 @@
<% end %>
<% end %>
<% if @photo_sources.any? %>
<div class="text-center mt-6">
<%= link_to "More photos on Immich", immich_search_url(current_user.settings['immich_url'], @trip.started_at, @trip.ended_at), class: "btn btn-primary", target: '_blank' %>
<% @photo_sources.each do |source| %>
<%= link_to "More photos on #{source}", photo_search_url(source, current_user.settings, @trip.started_at, @trip.ended_at), class: "btn btn-primary mt-2", target: '_blank' %>
<% end %>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -41,7 +41,7 @@ RSpec.describe Trip, type: :model do
end
end
describe '#photos' do
describe '#photo_previews' do
let(:photo_data) do
[
{
@ -80,8 +80,18 @@ RSpec.describe Trip, type: :model do
let(:trip) { create(:trip, user:) }
let(:expected_photos) do
[
{ url: "/api/v1/photos/456/thumbnail.jpg?api_key=#{user.api_key}" },
{ url: "/api/v1/photos/789/thumbnail.jpg?api_key=#{user.api_key}" }
{
id: '456',
url: "/api/v1/photos/456/thumbnail.jpg?api_key=#{user.api_key}&source=immich",
source: 'immich',
orientation: 'portrait'
},
{
id: '789',
url: "/api/v1/photos/789/thumbnail.jpg?api_key=#{user.api_key}&source=immich",
source: 'immich',
orientation: 'portrait'
}
]
end
@ -93,7 +103,7 @@ RSpec.describe Trip, type: :model do
let(:settings) { {} }
it 'returns an empty array' do
expect(trip.photos).to eq([])
expect(trip.photo_previews).to eq([])
end
end
@ -106,7 +116,9 @@ RSpec.describe Trip, type: :model do
end
it 'returns the photos' do
expect(trip.photos).to eq(expected_photos)
expect(trip.photo_previews).to include(expected_photos[0])
expect(trip.photo_previews).to include(expected_photos[1])
expect(trip.photo_previews.size).to eq(2)
end
end
end

View file

@ -25,7 +25,7 @@ RSpec.describe '/trips', type: :request do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
allow_any_instance_of(Trip).to receive(:photos).and_return([])
allow_any_instance_of(Trip).to receive(:photo_previews).and_return([])
sign_in user
end

View file

@ -73,6 +73,7 @@ RSpec.describe Api::PhotoSerializer do
state: 'Berlin',
country: 'Germany',
type: 'image',
orientation: 'portrait',
source: 'immich'
)
end
@ -152,6 +153,7 @@ RSpec.describe Api::PhotoSerializer do
state: 'Unknown',
country: 'zz',
type: 'image',
orientation: 'landscape',
source: 'photoprism'
)
end

View file

@ -74,7 +74,8 @@ RSpec.describe Photos::Search do
state: nil,
country: nil,
type: 'image',
source: 'immich'
source: 'immich',
orientation: 'landscape'
}
end
let(:serialized_photoprism) do
@ -88,7 +89,8 @@ RSpec.describe Photos::Search do
state: nil,
country: nil,
type: 'image',
source: 'photoprism'
source: 'photoprism',
orientation: 'landscape'
}
end

View file

@ -0,0 +1,72 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Trips::Photos do
let(:user) { instance_double('User') }
let(:trip) { instance_double('Trip', started_at: Date.new(2024, 1, 1), ended_at: Date.new(2024, 1, 7)) }
let(:service) { described_class.new(trip, user) }
describe '#call' do
context 'when user has no photo integrations configured' do
before do
allow(user).to receive(:immich_integration_configured?).and_return(false)
allow(user).to receive(:photoprism_integration_configured?).and_return(false)
end
it 'returns an empty array' do
expect(service.call).to eq([])
end
end
context 'when user has photo integrations configured' do
let(:photo_search) { instance_double('Photos::Search') }
let(:raw_photos) do
[
{
id: 1,
url: '/api/v1/photos/1/thumbnail.jpg?api_key=test-api-key&source=immich',
source: 'immich',
orientation: 'landscape'
},
{
id: 2,
url: '/api/v1/photos/2/thumbnail.jpg?api_key=test-api-key&source=photoprism',
source: 'photoprism',
orientation: 'portrait'
}
]
end
before do
allow(user).to receive(:immich_integration_configured?).and_return(true)
allow(user).to receive(:photoprism_integration_configured?).and_return(false)
allow(user).to receive(:api_key).and_return('test-api-key')
allow(Photos::Search).to receive(:new)
.with(user, start_date: '2024-01-01', end_date: '2024-01-07')
.and_return(photo_search)
allow(photo_search).to receive(:call).and_return(raw_photos)
end
it 'returns formatted photo thumbnails' do
expected_result = [
{
id: 1,
url: '/api/v1/photos/1/thumbnail.jpg?api_key=test-api-key&source=immich',
source: 'immich',
orientation: 'landscape'
},
{
id: 2,
url: '/api/v1/photos/2/thumbnail.jpg?api_key=test-api-key&source=photoprism',
source: 'photoprism',
orientation: 'portrait'
}
]
expect(service.call).to eq(expected_result)
end
end
end
end

View file

@ -110,8 +110,9 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do
city: { type: :string },
state: { type: :string },
country: { type: :string },
type: { type: :string },
source: { type: :string }
type: { type: :string, enum: %w[image video] },
orientation: { type: :string, enum: %w[portrait landscape] },
source: { type: :string, enum: %w[immich photoprism] }
},
required: %w[id latitude longitude localDateTime originalFileName city state country type source]
}
@ -142,8 +143,9 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do
city: { type: :string },
state: { type: :string },
country: { type: :string },
type: { type: :string },
source: { type: :string }
type: { type: :string, enum: %w[IMAGE VIDEO image video raw live animated] },
orientation: { type: :string, enum: %w[portrait landscape] },
source: { type: :string, enum: %w[immich photoprism] }
}
let(:id) { '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c' }

View file

@ -366,8 +366,19 @@ paths:
type: string
type:
type: string
enum:
- image
- video
orientation:
type: string
enum:
- portrait
- landscape
source:
type: string
enum:
- immich
- photoprism
required:
- id
- latitude
@ -429,8 +440,24 @@ paths:
type: string
type:
type: string
enum:
- IMAGE
- VIDEO
- image
- video
- raw
- live
- animated
orientation:
type: string
enum:
- portrait
- landscape
source:
type: string
enum:
- immich
- photoprism
'404':
description: photo not found
"/api/v1/points":