From 1f9e1f2f97e8b64b86999ed8690c9ba8373a7546 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 2 Dec 2024 16:52:05 +0100 Subject: [PATCH 01/24] Add basic Photoprism photos integration --- app/controllers/api/v1/settings_controller.rb | 16 ++-- app/controllers/settings_controller.rb | 2 +- app/models/user.rb | 6 ++ app/services/photoprism/request_photos.rb | 80 +++++++++++++++++++ app/views/settings/_navigation.html.erb | 2 +- app/views/settings/index.html.erb | 12 ++- 6 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 app/services/photoprism/request_photos.rb diff --git a/app/controllers/api/v1/settings_controller.rb b/app/controllers/api/v1/settings_controller.rb index 660f88e0..f87d9df7 100644 --- a/app/controllers/api/v1/settings_controller.rb +++ b/app/controllers/api/v1/settings_controller.rb @@ -12,16 +12,11 @@ class Api::V1::SettingsController < ApiController settings_params.each { |key, value| current_api_user.settings[key] = value } if current_api_user.save - render json: { - message: 'Settings updated', - settings: current_api_user.settings, - status: 'success' - }, status: :ok + render json: { message: 'Settings updated', settings: current_api_user.settings, status: 'success' }, + status: :ok else - render json: { - message: 'Something went wrong', - errors: current_api_user.errors.full_messages - }, status: :unprocessable_entity + render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages }, + status: :unprocessable_entity end end @@ -31,7 +26,8 @@ class Api::V1::SettingsController < ApiController params.require(:settings).permit( :meters_between_routes, :minutes_between_routes, :fog_of_war_meters, :time_threshold_minutes, :merge_threshold_minutes, :route_opacity, - :preferred_map_layer, :points_rendering_mode, :live_map_enabled + :preferred_map_layer, :points_rendering_mode, :live_map_enabled, + :immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key ) end end diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 8442bb94..243189cf 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -31,7 +31,7 @@ class SettingsController < ApplicationController params.require(:settings).permit( :meters_between_routes, :minutes_between_routes, :fog_of_war_meters, :time_threshold_minutes, :merge_threshold_minutes, :route_opacity, - :immich_url, :immich_api_key + :immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key ) end end diff --git a/app/models/user.rb b/app/models/user.rb index a102d0b5..e9da779f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -18,6 +18,7 @@ class User < ApplicationRecord has_many :trips, dependent: :destroy after_create :create_api_key + before_save :strip_trailing_slashes def countries_visited stats.pluck(:toponyms).flatten.map { _1['country'] }.uniq.compact @@ -60,4 +61,9 @@ class User < ApplicationRecord save end + + def strip_trailing_slashes + settings['immich_url'].gsub!(%r{/+\z}, '') + settings['photoprism_url'].gsub!(%r{/+\z}, '') + end end diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb new file mode 100644 index 00000000..9873c1e7 --- /dev/null +++ b/app/services/photoprism/request_photos.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +class Photoprism::RequestPhotos + attr_reader :user, :photoprism_api_base_url, :photoprism_api_key, :start_date, :end_date + + def initialize(user, start_date: '1970-01-01', end_date: nil) + @user = user + @photoprism_api_base_url = "#{user.settings['photoprism_url']}/api/v1/photos" + @photoprism_api_key = user.settings['photoprism_api_key'] + @start_date = start_date + @end_date = end_date + end + + def call + raise ArgumentError, 'Photoprism API key is missing' if photoprism_api_key.blank? + raise ArgumentError, 'Photoprism URL is missing' if user.settings['photoprism_url'].blank? + + data = retrieve_photoprism_data + + time_framed_data(data) + end + + private + + def retrieve_photoprism_data + data = [] + offset = 0 + + while offset < 1_000_000 + response = HTTParty.get( + photoprism_api_base_url, + headers: headers, + query: request_params(offset) + ) + + break if response.code != 200 + + photoprism_data = JSON.parse(response.body) + + data << photoprism_data + + break if photoprism_data.empty? + + offset += 1000 + end + + data + end + + def headers + { + 'Authorization' => "Bearer #{photoprism_api_key}", + 'accept' => 'application/json' + } + end + + def request_params(offset = 0) + params = { + q: '', + public: true, + quality: 3, + after: start_date, + offset: offset, + count: 1000 + } + + params.delete(:offset) if offset.zero? + params[:before] = end_date if end_date.present? + + params + end + + def time_framed_data(data) + data.flatten.select do |photo| + taken_at = DateTime.parse(photo['TakenAtLocal']) + end_date ||= Time.current + taken_at.between?(start_date.to_datetime, end_date.to_datetime) + end + end +end diff --git a/app/views/settings/_navigation.html.erb b/app/views/settings/_navigation.html.erb index 7232cc1c..b0b20437 100644 --- a/app/views/settings/_navigation.html.erb +++ b/app/views/settings/_navigation.html.erb @@ -1,5 +1,5 @@
- <%= link_to 'Main', settings_path, role: 'tab', class: "tab #{active_tab?(settings_path)}" %> + <%= link_to 'Integrations', settings_path, role: 'tab', class: "tab #{active_tab?(settings_path)}" %> <% if current_user.admin? %> <%= link_to 'Users', settings_users_path, role: 'tab', class: "tab #{active_tab?(settings_users_path)}" %> <%= link_to 'Background Jobs', settings_background_jobs_path, role: 'tab', class: "tab #{active_tab?(settings_background_jobs_path)}" %> diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb index eb8e605f..613cfe73 100644 --- a/app/views/settings/index.html.erb +++ b/app/views/settings/index.html.erb @@ -5,7 +5,7 @@
-

Edit your Dawarich settings!

+

Edit your Integrations settings!

<%= form_for :settings, url: settings_path, method: :patch, data: { turbo_method: :patch, turbo: false } do |f| %>
<%= f.label :immich_url %> @@ -15,6 +15,16 @@ <%= f.label :immich_api_key %> <%= f.text_field :immich_api_key, value: current_user.settings['immich_api_key'], class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
+
+
+ <%= f.label :photoprism_url %> + <%= f.text_field :photoprism_url, value: current_user.settings['photoprism_url'], class: "input input-bordered", placeholder: 'http://192.168.0.1:2342' %> +
+
+ <%= f.label :photoprism_api_key %> + <%= f.text_field :photoprism_api_key, value: current_user.settings['photoprism_api_key'], class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %> +
+
<%= f.submit "Update", class: "btn btn-primary" %>
From 360828250f23282776221492e3e78a0ef2063c18 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 2 Dec 2024 17:22:36 +0100 Subject: [PATCH 02/24] Add test for photoprism request photos --- app/models/user.rb | 4 +- app/services/photoprism/request_photos.rb | 45 +-- spec/services/immich/request_photos_spec.rb | 6 +- .../photoprism/request_photos_spec.rb | 278 ++++++++++++++++++ 4 files changed, 308 insertions(+), 25 deletions(-) create mode 100644 spec/services/photoprism/request_photos_spec.rb diff --git a/app/models/user.rb b/app/models/user.rb index e9da779f..b7edcb26 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -63,7 +63,7 @@ class User < ApplicationRecord end def strip_trailing_slashes - settings['immich_url'].gsub!(%r{/+\z}, '') - settings['photoprism_url'].gsub!(%r{/+\z}, '') + settings['immich_url']&.gsub!(%r{/+\z}, '') + settings['photoprism_url']&.gsub!(%r{/+\z}, '') end end diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb index 9873c1e7..a819c9a8 100644 --- a/app/services/photoprism/request_photos.rb +++ b/app/services/photoprism/request_photos.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Photoprism::RequestPhotos + class Error < StandardError; end attr_reader :user, :photoprism_api_base_url, :photoprism_api_key, :start_date, :end_date def initialize(user, start_date: '1970-01-01', end_date: nil) @@ -12,8 +13,8 @@ class Photoprism::RequestPhotos end def call - raise ArgumentError, 'Photoprism API key is missing' if photoprism_api_key.blank? raise ArgumentError, 'Photoprism URL is missing' if user.settings['photoprism_url'].blank? + raise ArgumentError, 'Photoprism API key is missing' if photoprism_api_key.blank? data = retrieve_photoprism_data @@ -27,19 +28,11 @@ class Photoprism::RequestPhotos offset = 0 while offset < 1_000_000 - response = HTTParty.get( - photoprism_api_base_url, - headers: headers, - query: request_params(offset) - ) + response_data = fetch_page(offset) + break unless response_data - break if response.code != 200 - - photoprism_data = JSON.parse(response.body) - - data << photoprism_data - - break if photoprism_data.empty? + data << response_data + break if response_data.empty? offset += 1000 end @@ -47,6 +40,18 @@ class Photoprism::RequestPhotos data end + def fetch_page(offset) + response = HTTParty.get( + photoprism_api_base_url, + headers: headers, + query: request_params(offset) + ) + + raise Error, "Photoprism API returned #{response.code}: #{response.body}" if response.code != 200 + + JSON.parse(response.body) + end + def headers { 'Authorization' => "Bearer #{photoprism_api_key}", @@ -55,19 +60,19 @@ class Photoprism::RequestPhotos end def request_params(offset = 0) - params = { + params = offset.zero? ? default_params : default_params.merge(offset: offset) + params[:before] = end_date if end_date.present? + params + end + + def default_params + { q: '', public: true, quality: 3, after: start_date, - offset: offset, count: 1000 } - - params.delete(:offset) if offset.zero? - params[:before] = end_date if end_date.present? - - params end def time_framed_data(data) diff --git a/spec/services/immich/request_photos_spec.rb b/spec/services/immich/request_photos_spec.rb index 255e6cd0..041ed4fd 100644 --- a/spec/services/immich/request_photos_spec.rb +++ b/spec/services/immich/request_photos_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Immich::RequestPhotos do let(:user) do create(:user, settings: { 'immich_url' => 'http://immich.app', 'immich_api_key' => '123456' }) end - let(:immich_data) do + let(:mock_immich_data) do { "albums": { "total": 0, @@ -134,11 +134,11 @@ RSpec.describe Immich::RequestPhotos do stub_request( :any, 'http://immich.app/api/search/metadata' - ).to_return(status: 200, body: immich_data, headers: {}) + ).to_return(status: 200, body: mock_immich_data, headers: {}) end it 'returns images and videos' do - expect(service.map { _1['type'] }.uniq).to eq(['IMAGE', 'VIDEO']) + expect(service.map { _1['type'] }.uniq).to eq(%w[IMAGE VIDEO]) end end diff --git a/spec/services/photoprism/request_photos_spec.rb b/spec/services/photoprism/request_photos_spec.rb new file mode 100644 index 00000000..6dc79e6d --- /dev/null +++ b/spec/services/photoprism/request_photos_spec.rb @@ -0,0 +1,278 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Photoprism::RequestPhotos do + let(:user) do + create(:user, + settings: { + 'photoprism_url' => 'http://photoprism.local', + 'photoprism_api_key' => 'test_api_key' + }) + end + + let(:start_date) { '2023-01-01' } + let(:end_date) { '2023-12-31' } + let(:service) { described_class.new(user, start_date: start_date, end_date: end_date) } + + let(:mock_photo_response) do + [ + { + 'ID' => '82', + 'UID' => 'psnveqq089xhy1c3', + 'Type' => 'image', + 'TypeSrc' => '', + 'TakenAt' => '2024-08-18T14:11:05Z', + 'TakenAtLocal' => '2024-08-18T16:11:05Z', + 'TakenSrc' => 'meta', + 'TimeZone' => 'Europe/Prague', + 'Path' => '2024/08', + 'Name' => '20240818_141105_44E61AED', + 'OriginalName' => 'PXL_20240818_141105789', + 'Title' => 'Moment / Karlovy Vary / 2024', + 'Description' => '', + 'Year' => 2024, + 'Month' => 8, + 'Day' => 18, + 'Country' => 'cz', + 'Stack' => 0, + 'Favorite' => false, + 'Private' => false, + 'Iso' => 37, + 'FocalLength' => 21, + 'FNumber' => 2.2, + 'Exposure' => '1/347', + 'Quality' => 4, + 'Resolution' => 10, + 'Color' => 2, + 'Scan' => false, + 'Panorama' => false, + 'CameraID' => 8, + 'CameraSrc' => 'meta', + 'CameraMake' => 'Google', + 'CameraModel' => 'Pixel 7 Pro', + 'LensID' => 11, + 'LensMake' => 'Google', + 'LensModel' => 'Pixel 7 Pro front camera 2.74mm f/2.2', + 'Altitude' => 423, + 'Lat' => 50.11, + 'Lng' => 12.12, + 'CellID' => 's2:47a09944f33c', + 'PlaceID' => 'cz:ciNqTjWuq6NN', + 'PlaceSrc' => 'meta', + 'PlaceLabel' => 'Karlovy Vary, Severozápad, Czech Republic', + 'PlaceCity' => 'Karlovy Vary', + 'PlaceState' => 'Severozápad', + 'PlaceCountry' => 'cz', + 'InstanceID' => '', + 'FileUID' => 'fsnveqqeusn692qo', + 'FileRoot' => '/', + 'FileName' => '2024/08/20240818_141105_44E61AED.jpg', + 'Hash' => 'cc5d0f544e52b288d7c8460d2e1bb17fa66e6089', + 'Width' => 2736, + 'Height' => 3648, + 'Portrait' => true, + 'Merged' => false, + 'CreatedAt' => '2024-12-02T14:25:38Z', + 'UpdatedAt' => '2024-12-02T14:25:38Z', + 'EditedAt' => '0001-01-01T00:00:00Z', + 'CheckedAt' => '2024-12-02T14:36:45Z', + 'Files' => nil + }, + { + 'ID' => '81', + 'UID' => 'psnveqpl96gcfdzf', + 'Type' => 'image', + 'TypeSrc' => '', + 'TakenAt' => '2024-08-18T14:11:04Z', + 'TakenAtLocal' => '2024-08-18T16:11:04Z', + 'TakenSrc' => 'meta', + 'TimeZone' => 'Europe/Prague', + 'Path' => '2024/08', + 'Name' => '20240818_141104_E9949CD4', + 'OriginalName' => 'PXL_20240818_141104633', + 'Title' => 'Portrait / Karlovy Vary / 2024', + 'Description' => '', + 'Year' => 2024, + 'Month' => 8, + 'Day' => 18, + 'Country' => 'cz', + 'Stack' => 0, + 'Favorite' => false, + 'Private' => false, + 'Iso' => 43, + 'FocalLength' => 21, + 'FNumber' => 2.2, + 'Exposure' => '1/356', + 'Faces' => 1, + 'Quality' => 4, + 'Resolution' => 10, + 'Color' => 2, + 'Scan' => false, + 'Panorama' => false, + 'CameraID' => 8, + 'CameraSrc' => 'meta', + 'CameraMake' => 'Google', + 'CameraModel' => 'Pixel 7 Pro', + 'LensID' => 11, + 'LensMake' => 'Google', + 'LensModel' => 'Pixel 7 Pro front camera 2.74mm f/2.2', + 'Altitude' => 423, + 'Lat' => 50.21, + 'Lng' => 12.85, + 'CellID' => 's2:47a09944f33c', + 'PlaceID' => 'cz:ciNqTjWuq6NN', + 'PlaceSrc' => 'meta', + 'PlaceLabel' => 'Karlovy Vary, Severozápad, Czech Republic', + 'PlaceCity' => 'Karlovy Vary', + 'PlaceState' => 'Severozápad', + 'PlaceCountry' => 'cz', + 'InstanceID' => '', + 'FileUID' => 'fsnveqp9xsl7onsv', + 'FileRoot' => '/', + 'FileName' => '2024/08/20240818_141104_E9949CD4.jpg', + 'Hash' => 'd5dfadc56a0b63051dfe0b5dec55ff1d81f033b7', + 'Width' => 2736, + 'Height' => 3648, + 'Portrait' => true, + 'Merged' => false, + 'CreatedAt' => '2024-12-02T14:25:37Z', + 'UpdatedAt' => '2024-12-02T14:25:37Z', + 'EditedAt' => '0001-01-01T00:00:00Z', + 'CheckedAt' => '2024-12-02T14:36:45Z', + 'Files' => nil + } + ] + end + + describe '#call' do + context 'with valid credentials' do + before do + stub_request( + :any, + "#{user.settings['photoprism_url']}/api/v1/photos?after=2023-01-01&before=2023-12-31&count=1000&public=true&q=&quality=3" + ).with( + headers: { + 'Accept' => 'application/json', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'Authorization' => 'Bearer test_api_key', + 'User-Agent' => 'Ruby' + } + ).to_return( + status: 200, + body: mock_photo_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + stub_request( + :any, + "#{user.settings['photoprism_url']}/api/v1/photos?after=2023-01-01&before=2023-12-31&count=1000&public=true&q=&quality=3&offset=1000" + ).to_return(status: 200, body: [].to_json) + end + + it 'returns photos within the specified date range' do + result = service.call + + expect(result).to be_an(Array) + expect(result.first['Title']).to eq('Moment / Karlovy Vary / 2024') + end + end + + context 'with missing credentials' do + let(:user) { create(:user, settings: {}) } + + it 'raises error when Photoprism URL is missing' do + expect { service.call }.to raise_error(ArgumentError, 'Photoprism URL is missing') + end + + it 'raises error when API key is missing' do + user.update(settings: { 'photoprism_url' => 'http://photoprism.local' }) + + expect { service.call }.to raise_error(ArgumentError, 'Photoprism API key is missing') + end + end + + context 'when API returns an error' do + before do + stub_request( + :get, + "#{user.settings['photoprism_url']}/api/v1/photos?after=2023-01-01&before=2023-12-31&count=1000&public=true&q=&quality=3" + ) + .to_return(status: 401, body: 'Unauthorized') + end + + it 'raises an error' do + expect do + service.call + end.to raise_error(Photoprism::RequestPhotos::Error, 'Photoprism API returned 401: Unauthorized') + end + end + + context 'with pagination' do + let(:first_page) { [{ 'TakenAtLocal' => '2023-06-15T14:30:00Z' }] } + let(:second_page) { [{ 'TakenAtLocal' => '2023-06-16T14:30:00Z' }] } + let(:empty_page) { [] } + + before do + common_headers = { + 'Accept' => 'application/json', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'Authorization' => 'Bearer test_api_key', + 'User-Agent' => 'Ruby' + } + + # First page + stub_request(:any, "#{user.settings['photoprism_url']}/api/v1/photos") + .with( + headers: common_headers, + query: { + after: '2023-01-01', + before: '2023-12-31', + count: '1000', + public: 'true', + q: '', + quality: '3' + } + ) + .to_return(status: 200, body: first_page.to_json) + + # Second page + stub_request(:any, "#{user.settings['photoprism_url']}/api/v1/photos") + .with( + headers: common_headers, + query: { + after: '2023-01-01', + before: '2023-12-31', + count: '1000', + public: 'true', + q: '', + quality: '3', + offset: '1000' + } + ) + .to_return(status: 200, body: second_page.to_json) + + # Last page (empty) + stub_request(:any, "#{user.settings['photoprism_url']}/api/v1/photos") + .with( + headers: common_headers, + query: { + after: '2023-01-01', + before: '2023-12-31', + count: '1000', + public: 'true', + q: '', + quality: '3', + offset: '2000' + } + ) + .to_return(status: 200, body: empty_page.to_json) + end + + it 'fetches all pages until empty result' do + result = service.call + expect(result.length).to eq(2) + end + end + end +end From 202396a93db4e1f995672fdcd44417fd393160b9 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 2 Dec 2024 17:34:16 +0100 Subject: [PATCH 03/24] Implement photos request for both immich and photoprism in single service class --- app/controllers/api/v1/photos_controller.rb | 6 +-- app/models/user.rb | 8 ++++ app/services/photoprism/request_photos.rb | 3 +- app/services/photos/request.rb | 38 +++++++++++++++++++ .../photoprism/request_photos_spec.rb | 9 +++-- 5 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 app/services/photos/request.rb diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb index 88baf2d7..c023a2d6 100644 --- a/app/controllers/api/v1/photos_controller.rb +++ b/app/controllers/api/v1/photos_controller.rb @@ -3,11 +3,7 @@ class Api::V1::PhotosController < ApiController def index @photos = Rails.cache.fetch("photos_#{params[:start_date]}_#{params[:end_date]}", expires_in: 1.day) do - Immich::RequestPhotos.new( - current_api_user, - start_date: params[:start_date], - end_date: params[:end_date] - ).call.reject { |asset| asset['type'].downcase == 'video' } + Photos::Request.new(current_api_user, start_date: params[:start_date], end_date: params[:end_date]).call end render json: @photos, status: :ok diff --git a/app/models/user.rb b/app/models/user.rb index b7edcb26..53adfa2d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -54,6 +54,14 @@ class User < ApplicationRecord tracked_points.select(:id).where.not(geodata: {}).count end + def immich_integration_configured? + settings['immich_url'].present? && settings['immich_api_key'].present? + end + + def photoprism_integration_configured? + settings['photoprism_url'].present? && settings['photoprism_api_key'].present? + end + private def create_api_key diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb index a819c9a8..2eb89bab 100644 --- a/app/services/photoprism/request_photos.rb +++ b/app/services/photoprism/request_photos.rb @@ -71,7 +71,8 @@ class Photoprism::RequestPhotos public: true, quality: 3, after: start_date, - count: 1000 + count: 1000, + photo: 'yes' } end diff --git a/app/services/photos/request.rb b/app/services/photos/request.rb new file mode 100644 index 00000000..7c490651 --- /dev/null +++ b/app/services/photos/request.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Photos::Request + attr_reader :user, :start_date, :end_date + + def initialize(user, start_date: '1970-01-01', end_date: nil) + @user = user + @start_date = start_date + @end_date = end_date + end + + def call + photos = [] + + photos << request_immich if user.immich_integration_configured? + photos << request_photoprism if user.photoprism_integration_configured? + + photos + end + + private + + def request_immich + Immich::RequestPhotos.new( + user, + start_date: start_date, + end_date: end_date + ).call.reject { |asset| asset['type'].downcase == 'video' } + end + + def request_photoprism + Photoprism::RequestPhotos.new( + user, + start_date: start_date, + end_date: end_date + ).call.select { |asset| asset['Type'].downcase == 'image' } + end +end diff --git a/spec/services/photoprism/request_photos_spec.rb b/spec/services/photoprism/request_photos_spec.rb index 6dc79e6d..fb09fd51 100644 --- a/spec/services/photoprism/request_photos_spec.rb +++ b/spec/services/photoprism/request_photos_spec.rb @@ -231,7 +231,8 @@ RSpec.describe Photoprism::RequestPhotos do count: '1000', public: 'true', q: '', - quality: '3' + quality: '3', + photo: 'yes' } ) .to_return(status: 200, body: first_page.to_json) @@ -247,7 +248,8 @@ RSpec.describe Photoprism::RequestPhotos do public: 'true', q: '', quality: '3', - offset: '1000' + offset: '1000', + photo: 'yes' } ) .to_return(status: 200, body: second_page.to_json) @@ -263,7 +265,8 @@ RSpec.describe Photoprism::RequestPhotos do public: 'true', q: '', quality: '3', - offset: '2000' + offset: '2000', + photo: 'yes' } ) .to_return(status: 200, body: empty_page.to_json) From be45af95fb9b6e2549d0f8968c47f63ca4fc5d25 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 2 Dec 2024 18:21:12 +0100 Subject: [PATCH 04/24] Implement photos serializer --- app/controllers/api/v1/photos_controller.rb | 18 +++--- app/javascript/controllers/maps_controller.js | 9 ++- app/serializers/api/photo_serializer.rb | 61 +++++++++++++++++++ app/services/immich/request_photos.rb | 2 +- app/services/photoprism/request_photos.rb | 22 ++++--- app/services/photos/request.rb | 2 +- .../photoprism/request_photos_spec.rb | 8 +-- 7 files changed, 96 insertions(+), 26 deletions(-) create mode 100644 app/serializers/api/photo_serializer.rb diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb index c023a2d6..a31e03ea 100644 --- a/app/controllers/api/v1/photos_controller.rb +++ b/app/controllers/api/v1/photos_controller.rb @@ -10,7 +10,14 @@ class Api::V1::PhotosController < ApiController end def thumbnail - response = Rails.cache.fetch("photo_thumbnail_#{params[:id]}", expires_in: 1.day) do + response = fetch_cached_thumbnail + handle_thumbnail_response(response) + end + + private + + def fetch_cached_thumbnail + Rails.cache.fetch("photo_thumbnail_#{params[:id]}", expires_in: 1.day) do HTTParty.get( "#{current_api_user.settings['immich_url']}/api/assets/#{params[:id]}/thumbnail?size=preview", headers: { @@ -19,14 +26,11 @@ class Api::V1::PhotosController < ApiController } ) end + end + def handle_thumbnail_response(response) if response.success? - send_data( - response.body, - type: 'image/jpeg', - disposition: 'inline', - status: :ok - ) + send_data(response.body, type: 'image/jpeg', disposition: 'inline', status: :ok) else render json: { error: 'Failed to fetch thumbnail' }, status: response.code end diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 20e058ee..338444cf 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -137,10 +137,13 @@ export default class extends Controller { this.map.addControl(this.drawControl); } if (e.name === 'Photos') { - if (!this.userSettings.immich_url || !this.userSettings.immich_api_key) { + if ( + (!this.userSettings.immich_url || !this.userSettings.immich_api_key) && + (!this.userSettings.photoprism_url || !this.userSettings.photoprism_api_key) + ) { showFlashMessage( 'error', - 'Immich integration is not configured. Please check your settings.' + 'Photos integration is not configured. Please check your integrations settings.' ); return; } @@ -836,7 +839,7 @@ export default class extends Controller {

${photo.originalFileName}

Taken: ${new Date(photo.localDateTime).toLocaleString()}

Location: ${photo.exifInfo.city}, ${photo.exifInfo.state}, ${photo.exifInfo.country}

- ${photo.type === 'VIDEO' ? '🎥 Video' : '📷 Photo'} + ${photo.type === 'video' ? '🎥 Video' : '📷 Photo'}
`; marker.bindPopup(popupContent); diff --git a/app/serializers/api/photo_serializer.rb b/app/serializers/api/photo_serializer.rb new file mode 100644 index 00000000..b063c541 --- /dev/null +++ b/app/serializers/api/photo_serializer.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class Api::PhotoSerializer + def initialize(photo) + @photo = photo + end + + def call + { + id: id, + latitude: latitude, + longitude: longitude, + localDateTime: local_date_time, + originalFileName: original_file_name, + city: city, + state: state, + country: country, + type: type + } + end + + private + + attr_reader :photo + + def id + photo['id'] || photo['ID'] + end + + def latitude + photo.dig('exifInfo', 'latitude') || photo['Lat'] + end + + def longitude + photo.dig('exifInfo', 'longitude') || photo['Lng'] + end + + def local_date_time + photo['localDateTime'] || photo['TakenAtLocal'] + end + + def original_file_name + photo['originalFileName'] || photo['OriginalName'] + end + + def city + photo.dig('exifInfo', 'city') || photo['PlaceCity'] + end + + def state + photo.dig('exifInfo', 'state') || photo['PlaceState'] + end + + def country + photo.dig('exifInfo', 'country') || photo['PlaceCountry'] + end + + def type + (photo['type'] || photo['Type']).downcase + end +end diff --git a/app/services/immich/request_photos.rb b/app/services/immich/request_photos.rb index 0f1eabc7..034a6452 100644 --- a/app/services/immich/request_photos.rb +++ b/app/services/immich/request_photos.rb @@ -5,7 +5,7 @@ class Immich::RequestPhotos def initialize(user, start_date: '1970-01-01', end_date: nil) @user = user - @immich_api_base_url = "#{user.settings['immich_url']}/api/search/metadata" + @immich_api_base_url = URI.parse("#{user.settings['immich_url']}/api/search/metadata") @immich_api_key = user.settings['immich_api_key'] @start_date = start_date @end_date = end_date diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb index 2eb89bab..4cf84810 100644 --- a/app/services/photoprism/request_photos.rb +++ b/app/services/photoprism/request_photos.rb @@ -1,12 +1,11 @@ # frozen_string_literal: true class Photoprism::RequestPhotos - class Error < StandardError; end attr_reader :user, :photoprism_api_base_url, :photoprism_api_key, :start_date, :end_date def initialize(user, start_date: '1970-01-01', end_date: nil) @user = user - @photoprism_api_base_url = "#{user.settings['photoprism_url']}/api/v1/photos" + @photoprism_api_base_url = URI.parse("#{user.settings['photoprism_url']}/api/v1/photos") @photoprism_api_key = user.settings['photoprism_api_key'] @start_date = start_date @end_date = end_date @@ -18,7 +17,9 @@ class Photoprism::RequestPhotos data = retrieve_photoprism_data - time_framed_data(data) + return [] if data[0]['error'].present? + + time_framed_data(data, start_date, end_date) end private @@ -29,15 +30,14 @@ class Photoprism::RequestPhotos while offset < 1_000_000 response_data = fetch_page(offset) - break unless response_data + break if response_data.blank? || response_data[0]['error'].present? data << response_data - break if response_data.empty? offset += 1000 end - data + data.flatten end def fetch_page(offset) @@ -47,7 +47,10 @@ class Photoprism::RequestPhotos query: request_params(offset) ) - raise Error, "Photoprism API returned #{response.code}: #{response.body}" if response.code != 200 + if response.code != 200 + Rails.logger.info "Photoprism API returned #{response.code}: #{response.body}" + Rails.logger.debug "Photoprism API request params: #{request_params(offset).inspect}" + end JSON.parse(response.body) end @@ -71,12 +74,11 @@ class Photoprism::RequestPhotos public: true, quality: 3, after: start_date, - count: 1000, - photo: 'yes' + count: 1000 } end - def time_framed_data(data) + def time_framed_data(data, start_date, end_date) data.flatten.select do |photo| taken_at = DateTime.parse(photo['TakenAtLocal']) end_date ||= Time.current diff --git a/app/services/photos/request.rb b/app/services/photos/request.rb index 7c490651..5e0fe828 100644 --- a/app/services/photos/request.rb +++ b/app/services/photos/request.rb @@ -15,7 +15,7 @@ class Photos::Request photos << request_immich if user.immich_integration_configured? photos << request_photoprism if user.photoprism_integration_configured? - photos + photos.flatten.map { |photo| Api::PhotoSerializer.new(photo).call } end private diff --git a/spec/services/photoprism/request_photos_spec.rb b/spec/services/photoprism/request_photos_spec.rb index fb09fd51..e8bcaaeb 100644 --- a/spec/services/photoprism/request_photos_spec.rb +++ b/spec/services/photoprism/request_photos_spec.rb @@ -201,10 +201,10 @@ RSpec.describe Photoprism::RequestPhotos do .to_return(status: 401, body: 'Unauthorized') end - it 'raises an error' do - expect do - service.call - end.to raise_error(Photoprism::RequestPhotos::Error, 'Photoprism API returned 401: Unauthorized') + it 'logs the error' do + expect(Rails.logger).to receive(:error).with('Photoprism API returned 401: Unauthorized') + + service.call end end From 8849a5e0a5ce97c141723a6adc89b4defc103438 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 3 Dec 2024 13:50:05 +0100 Subject: [PATCH 05/24] Add source to photos --- app/controllers/api/v1/photos_controller.rb | 23 +++++++++++++++++---- app/javascript/maps/helpers.js | 4 ++-- app/serializers/api/photo_serializer.rb | 8 ++++--- app/services/photoprism/request_photos.rb | 5 +++-- app/services/photos/request.rb | 13 +++++++++--- 5 files changed, 39 insertions(+), 14 deletions(-) diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb index a31e03ea..32fd75ad 100644 --- a/app/controllers/api/v1/photos_controller.rb +++ b/app/controllers/api/v1/photos_controller.rb @@ -10,18 +10,23 @@ class Api::V1::PhotosController < ApiController end def thumbnail - response = fetch_cached_thumbnail + return unauthorized_integration unless integration_configured? + + response = fetch_cached_thumbnail(params[:source]) handle_thumbnail_response(response) end private - def fetch_cached_thumbnail + def fetch_cached_thumbnail(source) Rails.cache.fetch("photo_thumbnail_#{params[:id]}", expires_in: 1.day) do + source_url = current_api_user.settings["#{source}_url"] + source_api_key = current_api_user.settings["#{source}_api_key"] + HTTParty.get( - "#{current_api_user.settings['immich_url']}/api/assets/#{params[:id]}/thumbnail?size=preview", + "#{source_url}/api/assets/#{params[:id]}/thumbnail?size=preview", headers: { - 'x-api-key' => current_api_user.settings['immich_api_key'], + 'x-api-key' => source_api_key, 'accept' => 'application/octet-stream' } ) @@ -35,4 +40,14 @@ class Api::V1::PhotosController < ApiController render json: { error: 'Failed to fetch thumbnail' }, status: response.code end end + + def integration_configured? + (params[:source] == 'immich' && current_api_user.immich_integration_configured?) || + (params[:source] == 'photoprism' && current_api_user.photoprism_integration_configured?) + end + + def unauthorized_integration + render json: { error: "#{params[:source].capitalize} integration not configured" }, + status: :unauthorized + end end diff --git a/app/javascript/maps/helpers.js b/app/javascript/maps/helpers.js index 9103f122..e21ca626 100644 --- a/app/javascript/maps/helpers.js +++ b/app/javascript/maps/helpers.js @@ -159,10 +159,10 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa start_date: startDate, end_date: endDate }); - + console.log(startDate, endDate); const response = await fetch(`/api/v1/photos?${params}`); if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + throw new Error(`HTTP error! status: ${response.status}, response: ${response.body}`); } const photos = await response.json(); diff --git a/app/serializers/api/photo_serializer.rb b/app/serializers/api/photo_serializer.rb index b063c541..169eff1d 100644 --- a/app/serializers/api/photo_serializer.rb +++ b/app/serializers/api/photo_serializer.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true class Api::PhotoSerializer - def initialize(photo) + def initialize(photo, source) @photo = photo + @source = source end def call @@ -15,13 +16,14 @@ class Api::PhotoSerializer city: city, state: state, country: country, - type: type + type: type, + source: source } end private - attr_reader :photo + attr_reader :photo, :source def id photo['id'] || photo['ID'] diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb index 4cf84810..f3efa61b 100644 --- a/app/services/photoprism/request_photos.rb +++ b/app/services/photoprism/request_photos.rb @@ -17,7 +17,7 @@ class Photoprism::RequestPhotos data = retrieve_photoprism_data - return [] if data[0]['error'].present? + return [] if data.blank? || data[0]['error'].present? time_framed_data(data, start_date, end_date) end @@ -30,7 +30,8 @@ class Photoprism::RequestPhotos while offset < 1_000_000 response_data = fetch_page(offset) - break if response_data.blank? || response_data[0]['error'].present? + + break if response_data.blank? || (response_data.is_a?(Hash) && response_data.try(:[], 'error').present?) data << response_data diff --git a/app/services/photos/request.rb b/app/services/photos/request.rb index 5e0fe828..3bb5d059 100644 --- a/app/services/photos/request.rb +++ b/app/services/photos/request.rb @@ -15,7 +15,7 @@ class Photos::Request photos << request_immich if user.immich_integration_configured? photos << request_photoprism if user.photoprism_integration_configured? - photos.flatten.map { |photo| Api::PhotoSerializer.new(photo).call } + photos.flatten.map { |photo| Api::PhotoSerializer.new(photo, photo[:source]).call } end private @@ -25,7 +25,7 @@ class Photos::Request user, start_date: start_date, end_date: end_date - ).call.reject { |asset| asset['type'].downcase == 'video' } + ).call.map { |asset| transform_asset(asset, 'immich') }.compact end def request_photoprism @@ -33,6 +33,13 @@ class Photos::Request user, start_date: start_date, end_date: end_date - ).call.select { |asset| asset['Type'].downcase == 'image' } + ).call.map { |asset| transform_asset(asset, 'photoprism') }.compact + end + + def transform_asset(asset, source) + asset_type = asset['type'] || asset['Type'] + return if asset_type.downcase == 'video' + + asset.merge(source: source) end end From bf569da9210f899ce16413c76b75afa2d7e5fa9c Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 3 Dec 2024 14:44:24 +0100 Subject: [PATCH 06/24] Implement thumbnail fetching for photoprism --- app/controllers/api/v1/photos_controller.rb | 11 +--- app/serializers/api/photo_serializer.rb | 2 +- .../photoprism/cache_preview_token.rb | 16 ++++++ app/services/photoprism/request_photos.rb | 8 +++ app/services/photos/thumbnail.rb | 54 +++++++++++++++++++ 5 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 app/services/photoprism/cache_preview_token.rb create mode 100644 app/services/photos/thumbnail.rb diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb index 32fd75ad..e2edec86 100644 --- a/app/controllers/api/v1/photos_controller.rb +++ b/app/controllers/api/v1/photos_controller.rb @@ -20,16 +20,7 @@ class Api::V1::PhotosController < ApiController def fetch_cached_thumbnail(source) Rails.cache.fetch("photo_thumbnail_#{params[:id]}", expires_in: 1.day) do - source_url = current_api_user.settings["#{source}_url"] - source_api_key = current_api_user.settings["#{source}_api_key"] - - HTTParty.get( - "#{source_url}/api/assets/#{params[:id]}/thumbnail?size=preview", - headers: { - 'x-api-key' => source_api_key, - 'accept' => 'application/octet-stream' - } - ) + Photos::Thumbnail.new(current_api_user, source, params[:id]).call end end diff --git a/app/serializers/api/photo_serializer.rb b/app/serializers/api/photo_serializer.rb index 169eff1d..97e972c4 100644 --- a/app/serializers/api/photo_serializer.rb +++ b/app/serializers/api/photo_serializer.rb @@ -26,7 +26,7 @@ class Api::PhotoSerializer attr_reader :photo, :source def id - photo['id'] || photo['ID'] + photo['id'] || photo['Hash'] end def latitude diff --git a/app/services/photoprism/cache_preview_token.rb b/app/services/photoprism/cache_preview_token.rb new file mode 100644 index 00000000..da16166c --- /dev/null +++ b/app/services/photoprism/cache_preview_token.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Photoprism::CachePreviewToken + attr_reader :user, :preview_token + + TOKEN_CACHE_KEY = 'dawarich/photoprism_preview_token' + + def initialize(user, preview_token) + @user = user + @preview_token = preview_token + end + + def call + Rails.cache.write("#{TOKEN_CACHE_KEY}_#{user.id}", preview_token) + end +end diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb index f3efa61b..7bc40025 100644 --- a/app/services/photoprism/request_photos.rb +++ b/app/services/photoprism/request_photos.rb @@ -53,6 +53,8 @@ class Photoprism::RequestPhotos Rails.logger.debug "Photoprism API request params: #{request_params(offset).inspect}" end + cache_preview_token(response.headers) + JSON.parse(response.body) end @@ -86,4 +88,10 @@ class Photoprism::RequestPhotos taken_at.between?(start_date.to_datetime, end_date.to_datetime) end end + + def cache_preview_token(headers) + preview_token = headers['X-Preview-Token'] + + Photoprism::CachePreviewToken.new(user, preview_token).call + end end diff --git a/app/services/photos/thumbnail.rb b/app/services/photos/thumbnail.rb new file mode 100644 index 00000000..be3063a1 --- /dev/null +++ b/app/services/photos/thumbnail.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class Photos::Thumbnail + def initialize(user, source, id) + @user = user + @source = source + @id = id + end + + def call + fetch_thumbnail_from_source + end + + private + + attr_reader :user, :source, :id + + def source_url + user.settings["#{source}_url"] + end + + def source_api_key + user.settings["#{source}_api_key"] + end + + def source_path + case source + when 'immich' + "/api/assets/#{id}/thumbnail?size=preview" + when 'photoprism' + preview_token = Rails.cache.read("#{Photoprism::CachePreviewToken::TOKEN_CACHE_KEY}_#{user.id}") + "/api/v1/t/#{id}/#{preview_token}/tile_500" + else + raise "Unsupported source: #{source}" + end + end + + def headers + request_headers = { + 'accept' => 'application/octet-stream' + } + + request_headers['X-Api-Key'] = source_api_key if source == 'immich' + + request_headers + end + + def fetch_thumbnail_from_source + url = "#{source_url}#{source_path}" + a = HTTParty.get(url, headers: headers) + pp url + a + end +end From 0a201d74ac1c76a54a0051f308b04b37be58f635 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 3 Dec 2024 14:53:10 +0100 Subject: [PATCH 07/24] Update marker rendering code to adapt to new photo format --- app/javascript/maps/helpers.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app/javascript/maps/helpers.js b/app/javascript/maps/helpers.js index e21ca626..24ec501d 100644 --- a/app/javascript/maps/helpers.js +++ b/app/javascript/maps/helpers.js @@ -171,7 +171,7 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa const photoLoadPromises = photos.map(photo => { return new Promise((resolve) => { const img = new Image(); - const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}`; + const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}&source=${photo.source}`; img.onload = () => { createPhotoMarker(photo, userSettings.immich_url, photoMarkers, apiKey); @@ -217,10 +217,10 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa } -export function createPhotoMarker(photo, immichUrl, photoMarkers,apiKey) { - if (!photo.exifInfo?.latitude || !photo.exifInfo?.longitude) return; +export function createPhotoMarker(photo, immichUrl, photoMarkers, apiKey) { + if (!photo.latitude || !photo.longitude) return; - const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}`; + const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}&source=${photo.source}`; const icon = L.divIcon({ className: 'photo-marker', @@ -229,7 +229,7 @@ export function createPhotoMarker(photo, immichUrl, photoMarkers,apiKey) { }); const marker = L.marker( - [photo.exifInfo.latitude, photo.exifInfo.longitude], + [photo.latitude, photo.longitude], { icon } ); @@ -256,7 +256,8 @@ export function createPhotoMarker(photo, immichUrl, photoMarkers,apiKey) {

${photo.originalFileName}

Taken: ${new Date(photo.localDateTime).toLocaleString()}

-

Location: ${photo.exifInfo.city}, ${photo.exifInfo.state}, ${photo.exifInfo.country}

+

Location: ${photo.city}, ${photo.state}, ${photo.country}

+

Source: ${photo.source}

${photo.type === 'VIDEO' ? '🎥 Video' : '📷 Photo'}
`; From bea7f281729cf03ff08b9c04b216edd24bb22550 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 3 Dec 2024 15:02:11 +0100 Subject: [PATCH 08/24] Update link to photos in maps photo popup --- app/javascript/maps/helpers.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/app/javascript/maps/helpers.js b/app/javascript/maps/helpers.js index 24ec501d..89dbd3f1 100644 --- a/app/javascript/maps/helpers.js +++ b/app/javascript/maps/helpers.js @@ -174,7 +174,7 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}&source=${photo.source}`; img.onload = () => { - createPhotoMarker(photo, userSettings.immich_url, photoMarkers, apiKey); + createPhotoMarker(photo, userSettings, photoMarkers, apiKey); resolve(); }; @@ -217,7 +217,7 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa } -export function createPhotoMarker(photo, immichUrl, photoMarkers, apiKey) { +export function createPhotoMarker(photo, userSettings, photoMarkers, apiKey) { if (!photo.latitude || !photo.longitude) return; const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}&source=${photo.source}`; @@ -244,10 +244,17 @@ export function createPhotoMarker(photo, immichUrl, photoMarkers, apiKey) { takenBefore: endOfDay.toISOString() }; const encodedQuery = encodeURIComponent(JSON.stringify(queryParams)); - const immich_photo_link = `${immichUrl}/search?query=${encodedQuery}`; + console.log(userSettings); + let photo_link; + if (photo.source === 'immich') { + photo_link = `${userSettings.immich_url}/search?query=${encodedQuery}`; + } else if (photo.source === 'photoprism') { + photo_link = `${userSettings.photoprism_url}/library/browse?view=cards&year=${photo.localDateTime.split('-')[0]}&month=${photo.localDateTime.split('-')[1]}&order=newest&public=true&quality=3`; + } + const source_url = photo.source === 'photoprism' ? userSettings.photoprism_url : userSettings.immich_url; const popupContent = ` `; From 83078c5b290f07d0ae4b97b9533fffa37b400305 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 3 Dec 2024 15:05:40 +0100 Subject: [PATCH 09/24] Refactor photo links code --- app/javascript/maps/helpers.js | 53 ++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/app/javascript/maps/helpers.js b/app/javascript/maps/helpers.js index 89dbd3f1..446122a8 100644 --- a/app/javascript/maps/helpers.js +++ b/app/javascript/maps/helpers.js @@ -216,6 +216,39 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa } } +function getPhotoLink(photo, userSettings) { + switch (photo.source) { + case 'immich': + const startOfDay = new Date(photo.localDateTime); + startOfDay.setHours(0, 0, 0, 0); + + const endOfDay = new Date(photo.localDateTime); + endOfDay.setHours(23, 59, 59, 999); + + const queryParams = { + takenAfter: startOfDay.toISOString(), + takenBefore: endOfDay.toISOString() + }; + const encodedQuery = encodeURIComponent(JSON.stringify(queryParams)); + + return `${userSettings.immich_url}/search?query=${encodedQuery}`; + case 'photoprism': + return `${userSettings.photoprism_url}/library/browse?view=cards&year=${photo.localDateTime.split('-')[0]}&month=${photo.localDateTime.split('-')[1]}&order=newest&public=true&quality=3`; + default: + return '#'; // Default or error case + } +} + +function getSourceUrl(photo, userSettings) { + switch (photo.source) { + case 'photoprism': + return userSettings.photoprism_url; + case 'immich': + return userSettings.immich_url; + default: + return '#'; // Default or error case + } +} export function createPhotoMarker(photo, userSettings, photoMarkers, apiKey) { if (!photo.latitude || !photo.longitude) return; @@ -233,25 +266,9 @@ export function createPhotoMarker(photo, userSettings, photoMarkers, apiKey) { { icon } ); - const startOfDay = new Date(photo.localDateTime); - startOfDay.setHours(0, 0, 0, 0); + const photo_link = getPhotoLink(photo, userSettings); + const source_url = getSourceUrl(photo, userSettings); - const endOfDay = new Date(photo.localDateTime); - endOfDay.setHours(23, 59, 59, 999); - - const queryParams = { - takenAfter: startOfDay.toISOString(), - takenBefore: endOfDay.toISOString() - }; - const encodedQuery = encodeURIComponent(JSON.stringify(queryParams)); - console.log(userSettings); - let photo_link; - if (photo.source === 'immich') { - photo_link = `${userSettings.immich_url}/search?query=${encodedQuery}`; - } else if (photo.source === 'photoprism') { - photo_link = `${userSettings.photoprism_url}/library/browse?view=cards&year=${photo.localDateTime.split('-')[0]}&month=${photo.localDateTime.split('-')[1]}&order=newest&public=true&quality=3`; - } - const source_url = photo.source === 'photoprism' ? userSettings.photoprism_url : userSettings.immich_url; const popupContent = `
Date: Tue, 3 Dec 2024 15:12:20 +0100 Subject: [PATCH 10/24] Consider both Immich and Photoprism integrations in trips controller --- app/javascript/controllers/trips_controller.js | 4 ++-- app/javascript/maps/helpers.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/javascript/controllers/trips_controller.js b/app/javascript/controllers/trips_controller.js index 00a2b497..497fe5e3 100644 --- a/app/javascript/controllers/trips_controller.js +++ b/app/javascript/controllers/trips_controller.js @@ -80,10 +80,10 @@ export default class extends Controller { this.map.on('overlayadd', (e) => { if (e.name !== 'Photos') return; - if (!this.userSettings.immich_url || !this.userSettings.immich_api_key) { + if ((!this.userSettings.immich_url || !this.userSettings.immich_api_key) && (!this.userSettings.photoprism_url || !this.userSettings.photoprism_api_key)) { showFlashMessage( 'error', - 'Immich integration is not configured. Please check your settings.' + 'Photos integration is not configured. Please check your integrations settings.' ); return; } diff --git a/app/javascript/maps/helpers.js b/app/javascript/maps/helpers.js index 446122a8..7fdcdbca 100644 --- a/app/javascript/maps/helpers.js +++ b/app/javascript/maps/helpers.js @@ -159,7 +159,7 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa start_date: startDate, end_date: endDate }); - console.log(startDate, endDate); + const response = await fetch(`/api/v1/photos?${params}`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}, response: ${response.body}`); From e17b671c9ca206615fe792480121a8bdfaed8e1b Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 3 Dec 2024 15:40:21 +0100 Subject: [PATCH 11/24] Add a button to import Photoprism geodata --- app/jobs/enqueue_background_job.rb | 2 + app/jobs/import/photoprism_geodata_job.rb | 12 ++++ app/models/import.rb | 2 +- app/services/immich/import_geodata.rb | 2 +- app/services/photoprism/import_geodata.rb | 80 +++++++++++++++++++++++ app/views/imports/index.html.erb | 5 ++ spec/requests/api/v1/photos_spec.rb | 20 ++++-- 7 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 app/jobs/import/photoprism_geodata_job.rb create mode 100644 app/services/photoprism/import_geodata.rb diff --git a/app/jobs/enqueue_background_job.rb b/app/jobs/enqueue_background_job.rb index aa5cdccf..61e103c3 100644 --- a/app/jobs/enqueue_background_job.rb +++ b/app/jobs/enqueue_background_job.rb @@ -7,6 +7,8 @@ class EnqueueBackgroundJob < ApplicationJob case job_name when 'start_immich_import' Import::ImmichGeodataJob.perform_later(user_id) + when 'start_photoprism_import' + Import::PhotoprismGeodataJob.perform_later(user_id) when 'start_reverse_geocoding', 'continue_reverse_geocoding' Jobs::Create.new(job_name, user_id).call else diff --git a/app/jobs/import/photoprism_geodata_job.rb b/app/jobs/import/photoprism_geodata_job.rb new file mode 100644 index 00000000..7aa2d27e --- /dev/null +++ b/app/jobs/import/photoprism_geodata_job.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class Import::PhotoprismGeodataJob < ApplicationJob + queue_as :imports + sidekiq_options retry: false + + def perform(user_id) + user = User.find(user_id) + + Photoprism::ImportGeodata.new(user).call + end +end diff --git a/app/models/import.rb b/app/models/import.rb index c6e5a8a6..067baf12 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -10,7 +10,7 @@ class Import < ApplicationRecord enum :source, { google_semantic_history: 0, owntracks: 1, google_records: 2, - google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6 + google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6, photoprism_api: 7 } def process! diff --git a/app/services/immich/import_geodata.rb b/app/services/immich/import_geodata.rb index 766643a7..469761d6 100644 --- a/app/services/immich/import_geodata.rb +++ b/app/services/immich/import_geodata.rb @@ -57,7 +57,7 @@ class Immich::ImportGeodata end def log_no_data - Rails.logger.info 'No data found' + Rails.logger.info 'No geodata found for Immich' end def create_import_failed_notification(import_name) diff --git a/app/services/photoprism/import_geodata.rb b/app/services/photoprism/import_geodata.rb new file mode 100644 index 00000000..a24f2542 --- /dev/null +++ b/app/services/photoprism/import_geodata.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +class Photoprism::ImportGeodata + attr_reader :user, :start_date, :end_date + + def initialize(user, start_date: '1970-01-01', end_date: nil) + @user = user + @start_date = start_date + @end_date = end_date + end + + def call + photoprism_data = retrieve_photoprism_data + + log_no_data and return if photoprism_data.empty? + + photoprism_data_json = parse_photoprism_data(photoprism_data) + file_name = file_name(photoprism_data_json) + import = user.imports.find_or_initialize_by(name: file_name, source: :photoprism_api) + + create_import_failed_notification(import.name) and return unless import.new_record? + + import.raw_data = photoprism_data_json + import.save! + + ImportJob.perform_later(user.id, import.id) + end + + private + + def retrieve_photoprism_data + Photoprism::RequestPhotos.new(user, start_date:, end_date:).call + end + + def parse_photoprism_data(photoprism_data) + geodata = photoprism_data.map do |asset| + next unless valid?(asset) + + extract_geodata(asset) + end + + geodata.compact.sort_by { |data| data[:timestamp] } + end + + def valid?(asset) + asset['Lat'] && + asset['Lat'] != 0 && + asset['Lng'] && + asset['Lng'] != 0 && + asset['TakenAt'] + end + + def extract_geodata(asset) + { + latitude: asset.dig('exifInfo', 'latitude'), + longitude: asset.dig('exifInfo', 'longitude'), + timestamp: Time.zone.parse(asset.dig('exifInfo', 'dateTimeOriginal')).to_i + } + end + + def log_no_data + Rails.logger.info 'No geodata found for Photoprism' + end + + def create_import_failed_notification(import_name) + Notifications::Create.new( + user:, + kind: :info, + title: 'Import was not created', + content: "Import with the same name (#{import_name}) already exists. If you want to proceed, delete the existing import and try again." + ).call + end + + def file_name(photoprism_data_json) + from = Time.zone.at(photoprism_data_json.first[:timestamp]).to_date + to = Time.zone.at(photoprism_data_json.last[:timestamp]).to_date + + "photoprism-geodata-#{user.email}-from-#{from}-to-#{to}.json" + end +end diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb index 32ab69ff..b3f8cbfc 100644 --- a/app/views/imports/index.html.erb +++ b/app/views/imports/index.html.erb @@ -10,6 +10,11 @@ <% else %> Import Immich data <% end %> + <% if current_user.settings['photoprism_url'] && current_user.settings['photoprism_api_key'] %> + <%= link_to 'Import Photoprism data', settings_background_jobs_path(job_name: 'start_photoprism_import'), method: :post, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %> + <% else %> + Import Photoprism data + <% end %>
diff --git a/spec/requests/api/v1/photos_spec.rb b/spec/requests/api/v1/photos_spec.rb index d15a5342..c24a5360 100644 --- a/spec/requests/api/v1/photos_spec.rb +++ b/spec/requests/api/v1/photos_spec.rb @@ -9,25 +9,35 @@ RSpec.describe 'Api::V1::Photos', type: :request do let(:photo_data) do [ { - 'id' => '123', + 'id' => 1, 'latitude' => 35.6762, 'longitude' => 139.6503, 'localDateTime' => '2024-01-01T00:00:00.000Z', - 'type' => 'photo' + 'originalFileName' => 'photo1.jpg', + 'city' => 'Tokyo', + 'state' => 'Tokyo', + 'country' => 'Japan', + 'type' => 'photo', + 'source' => 'photoprism' }, { - 'id' => '456', + 'id' => 2, 'latitude' => 40.7128, 'longitude' => -74.0060, 'localDateTime' => '2024-01-02T00:00:00.000Z', - 'type' => 'photo' + 'originalFileName' => 'photo2.jpg', + 'city' => 'New York', + 'state' => 'New York', + 'country' => 'USA', + 'type' => 'photo', + 'source' => 'immich' } ] end context 'when the request is successful' do before do - allow_any_instance_of(Immich::RequestPhotos).to receive(:call).and_return(photo_data) + allow_any_instance_of(Photos::Request).to receive(:call).and_return(photo_data) get '/api/v1/photos', params: { api_key: user.api_key } end From ba2a95233c2b02cee6872d2d222dbf7e31d15820 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 3 Dec 2024 15:59:34 +0100 Subject: [PATCH 12/24] Implement importing geodata from photoprism --- app/services/imports/create.rb | 12 +++--- app/services/photoprism/import_geodata.rb | 40 +++++++++++-------- .../{immich => photos}/import_parser.rb | 2 +- .../{immich => photos}/import_parser_spec.rb | 2 +- 4 files changed, 31 insertions(+), 25 deletions(-) rename app/services/{immich => photos}/import_parser.rb (97%) rename spec/services/{immich => photos}/import_parser_spec.rb (97%) diff --git a/app/services/imports/create.rb b/app/services/imports/create.rb index 4ce3e7c2..7c34cc1f 100644 --- a/app/services/imports/create.rb +++ b/app/services/imports/create.rb @@ -24,12 +24,12 @@ class Imports::Create def parser(source) # Bad classes naming by the way, they are not parsers, they are point creators case source - when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser - when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser - when 'owntracks' then OwnTracks::ExportParser - when 'gpx' then Gpx::TrackParser - when 'immich_api' then Immich::ImportParser - when 'geojson' then Geojson::ImportParser + when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser + when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser + when 'owntracks' then OwnTracks::ExportParser + when 'gpx' then Gpx::TrackParser + when 'geojson' then Geojson::ImportParser + when 'immich_api', 'photoprism_api' then Photos::ImportParser end end diff --git a/app/services/photoprism/import_geodata.rb b/app/services/photoprism/import_geodata.rb index a24f2542..182681e6 100644 --- a/app/services/photoprism/import_geodata.rb +++ b/app/services/photoprism/import_geodata.rb @@ -11,23 +11,29 @@ class Photoprism::ImportGeodata def call photoprism_data = retrieve_photoprism_data + return log_no_data if photoprism_data.empty? - log_no_data and return if photoprism_data.empty? - - photoprism_data_json = parse_photoprism_data(photoprism_data) - file_name = file_name(photoprism_data_json) - import = user.imports.find_or_initialize_by(name: file_name, source: :photoprism_api) - - create_import_failed_notification(import.name) and return unless import.new_record? - - import.raw_data = photoprism_data_json - import.save! - - ImportJob.perform_later(user.id, import.id) + json_data = parse_photoprism_data(photoprism_data) + create_and_process_import(json_data) end private + def create_and_process_import(json_data) + import = find_or_create_import(json_data) + return create_import_failed_notification(import.name) unless import.new_record? + + import.update!(raw_data: json_data) + ImportJob.perform_later(user.id, import.id) + end + + def find_or_create_import(json_data) + user.imports.find_or_initialize_by( + name: file_name(json_data), + source: :photoprism_api + ) + end + def retrieve_photoprism_data Photoprism::RequestPhotos.new(user, start_date:, end_date:).call end @@ -52,9 +58,9 @@ class Photoprism::ImportGeodata def extract_geodata(asset) { - latitude: asset.dig('exifInfo', 'latitude'), - longitude: asset.dig('exifInfo', 'longitude'), - timestamp: Time.zone.parse(asset.dig('exifInfo', 'dateTimeOriginal')).to_i + latitude: asset['Lat'], + longitude: asset['Lng'], + timestamp: Time.zone.parse(asset['TakenAt']).to_i } end @@ -72,8 +78,8 @@ class Photoprism::ImportGeodata end def file_name(photoprism_data_json) - from = Time.zone.at(photoprism_data_json.first[:timestamp]).to_date - to = Time.zone.at(photoprism_data_json.last[:timestamp]).to_date + from = Time.zone.at(photoprism_data_json.first[:timestamp]).to_date + to = Time.zone.at(photoprism_data_json.last[:timestamp]).to_date "photoprism-geodata-#{user.email}-from-#{from}-to-#{to}.json" end diff --git a/app/services/immich/import_parser.rb b/app/services/photos/import_parser.rb similarity index 97% rename from app/services/immich/import_parser.rb rename to app/services/photos/import_parser.rb index b0a2a38c..97b9c9d4 100644 --- a/app/services/immich/import_parser.rb +++ b/app/services/photos/import_parser.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Immich::ImportParser +class Photos::ImportParser include Imports::Broadcaster attr_reader :import, :json, :user_id diff --git a/spec/services/immich/import_parser_spec.rb b/spec/services/photos/import_parser_spec.rb similarity index 97% rename from spec/services/immich/import_parser_spec.rb rename to spec/services/photos/import_parser_spec.rb index cefa4dc6..33460398 100644 --- a/spec/services/immich/import_parser_spec.rb +++ b/spec/services/photos/import_parser_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe Immich::ImportParser do +RSpec.describe Photos::ImportParser do describe '#call' do subject(:service) { described_class.new(import, user.id).call } From 93e91e7944b3313c80b89c2c3ecfda9987da577d Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 3 Dec 2024 16:05:38 +0100 Subject: [PATCH 13/24] Fix swagger docs for /api/v1/photos/{id}/thumbnail --- app/controllers/api/v1/photos_controller.rb | 2 +- spec/models/import_spec.rb | 3 +- spec/swagger/api/v1/photos_controller_spec.rb | 129 +++------ swagger/v1/swagger.yaml | 249 +++--------------- 4 files changed, 75 insertions(+), 308 deletions(-) diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb index e2edec86..b3bb0e6a 100644 --- a/app/controllers/api/v1/photos_controller.rb +++ b/app/controllers/api/v1/photos_controller.rb @@ -38,7 +38,7 @@ class Api::V1::PhotosController < ApiController end def unauthorized_integration - render json: { error: "#{params[:source].capitalize} integration not configured" }, + render json: { error: "#{params[:source]&.capitalize} integration not configured" }, status: :unauthorized end end diff --git a/spec/models/import_spec.rb b/spec/models/import_spec.rb index 3ac6130d..2e04a91d 100644 --- a/spec/models/import_spec.rb +++ b/spec/models/import_spec.rb @@ -17,7 +17,8 @@ RSpec.describe Import, type: :model do google_phone_takeout: 3, gpx: 4, immich_api: 5, - geojson: 6 + geojson: 6, + photoprism_api: 7 ) end end diff --git a/spec/swagger/api/v1/photos_controller_spec.rb b/spec/swagger/api/v1/photos_controller_spec.rb index eb3cb737..5b63d307 100644 --- a/spec/swagger/api/v1/photos_controller_spec.rb +++ b/spec/swagger/api/v1/photos_controller_spec.rb @@ -102,60 +102,29 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do items: { type: :object, properties: { + # { + # id: id, + # latitude: latitude, + # longitude: longitude, + # localDateTime: local_date_time, + # originalFileName: original_file_name, + # city: city, + # state: state, + # country: country, + # type: type, + # source: source id: { type: :string }, - deviceAssetId: { type: :string }, - ownerId: { type: :string }, - type: { type: :string }, - originalPath: { type: :string }, - originalFileName: { type: :string }, - originalMimeType: { type: :string }, - thumbhash: { type: :string }, - fileCreatedAt: { type: :string, format: 'date-time' }, - fileModifiedAt: { type: :string, format: 'date-time' }, + latitude: { type: :number, format: :float }, + longitude: { type: :number, format: :float }, localDateTime: { type: :string, format: 'date-time' }, - updatedAt: { type: :string, format: 'date-time' }, - isFavorite: { type: :boolean }, - isArchived: { type: :boolean }, - isTrashed: { type: :boolean }, - duration: { type: :string }, - exifInfo: { - type: :object, - properties: { - make: { type: :string }, - model: { type: :string }, - exifImageWidth: { type: :integer }, - exifImageHeight: { type: :integer }, - fileSizeInByte: { type: :integer }, - orientation: { type: :string }, - dateTimeOriginal: { type: :string, format: 'date-time' }, - modifyDate: { type: :string, format: 'date-time' }, - timeZone: { type: :string }, - lensModel: { type: :string }, - fNumber: { type: :number, format: :float }, - focalLength: { type: :number, format: :float }, - iso: { type: :integer }, - exposureTime: { type: :string }, - latitude: { type: :number, format: :float }, - longitude: { type: :number, format: :float }, - city: { type: :string }, - state: { type: :string }, - country: { type: :string }, - description: { type: :string }, - projectionType: { type: %i[string null] }, - rating: { type: %i[integer null] } - } - }, - checksum: { type: :string }, - isOffline: { type: :boolean }, - hasMetadata: { type: :boolean }, - duplicateId: { type: :string }, - resized: { type: :boolean } + originalFileName: { type: :string }, + city: { type: :string }, + state: { type: :string }, + country: { type: :string }, + type: { type: :string }, + source: { type: :string } }, - required: %w[id deviceAssetId ownerId type originalPath - originalFileName originalMimeType thumbhash - fileCreatedAt fileModifiedAt localDateTime - updatedAt isFavorite isArchived isTrashed duration - exifInfo checksum isOffline hasMetadata duplicateId resized] + required: %w[id latitude longitude localDateTime originalFileName city state country type source] } run_test! do |response| @@ -172,61 +141,24 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do produces 'application/json' parameter name: :id, in: :path, type: :string, required: true parameter name: :api_key, in: :query, type: :string, required: true - + parameter name: :source, in: :query, type: :string, required: true response '200', 'photo found' do schema type: :object, properties: { id: { type: :string }, - deviceAssetId: { type: :string }, - ownerId: { type: :string }, - type: { type: :string }, - originalPath: { type: :string }, - originalFileName: { type: :string }, - originalMimeType: { type: :string }, - thumbhash: { type: :string }, - fileCreatedAt: { type: :string, format: 'date-time' }, - fileModifiedAt: { type: :string, format: 'date-time' }, + latitude: { type: :number, format: :float }, + longitude: { type: :number, format: :float }, localDateTime: { type: :string, format: 'date-time' }, - updatedAt: { type: :string, format: 'date-time' }, - isFavorite: { type: :boolean }, - isArchived: { type: :boolean }, - isTrashed: { type: :boolean }, - duration: { type: :string }, - exifInfo: { - type: :object, - properties: { - make: { type: :string }, - model: { type: :string }, - exifImageWidth: { type: :integer }, - exifImageHeight: { type: :integer }, - fileSizeInByte: { type: :integer }, - orientation: { type: :string }, - dateTimeOriginal: { type: :string, format: 'date-time' }, - modifyDate: { type: :string, format: 'date-time' }, - timeZone: { type: :string }, - lensModel: { type: :string }, - fNumber: { type: :number, format: :float }, - focalLength: { type: :number, format: :float }, - iso: { type: :integer }, - exposureTime: { type: :string }, - latitude: { type: :number, format: :float }, - longitude: { type: :number, format: :float }, - city: { type: :string }, - state: { type: :string }, - country: { type: :string }, - description: { type: :string }, - projectionType: { type: %i[string null] }, - rating: { type: %i[integer null] } - } - }, - checksum: { type: :string }, - isOffline: { type: :boolean }, - hasMetadata: { type: :boolean }, - duplicateId: { type: :string }, - resized: { type: :boolean } + originalFileName: { type: :string }, + city: { type: :string }, + state: { type: :string }, + country: { type: :string }, + type: { type: :string }, + source: { type: :string } } let(:id) { '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c' } + let(:source) { 'immich' } run_test! do |response| data = JSON.parse(response.body) @@ -238,6 +170,7 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do response '404', 'photo not found' do let(:id) { 'nonexistent' } let(:api_key) { user.api_key } + let(:source) { 'immich' } run_test! do |response| data = JSON.parse(response.body) diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 3ecfb855..6657aebf 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -347,130 +347,38 @@ paths: properties: id: type: string - deviceAssetId: - type: string - ownerId: - type: string - type: - type: string - originalPath: - type: string - originalFileName: - type: string - originalMimeType: - type: string - thumbhash: - type: string - fileCreatedAt: - type: string - format: date-time - fileModifiedAt: - type: string - format: date-time + latitude: + type: number + format: float + longitude: + type: number + format: float localDateTime: type: string format: date-time - updatedAt: + originalFileName: type: string - format: date-time - isFavorite: - type: boolean - isArchived: - type: boolean - isTrashed: - type: boolean - duration: + city: type: string - exifInfo: - type: object - properties: - make: - type: string - model: - type: string - exifImageWidth: - type: integer - exifImageHeight: - type: integer - fileSizeInByte: - type: integer - orientation: - type: string - dateTimeOriginal: - type: string - format: date-time - modifyDate: - type: string - format: date-time - timeZone: - type: string - lensModel: - type: string - fNumber: - type: number - format: float - focalLength: - type: number - format: float - iso: - type: integer - exposureTime: - type: string - latitude: - type: number - format: float - longitude: - type: number - format: float - city: - type: string - state: - type: string - country: - type: string - description: - type: string - projectionType: - type: - - string - - 'null' - rating: - type: - - integer - - 'null' - checksum: + state: type: string - isOffline: - type: boolean - hasMetadata: - type: boolean - duplicateId: + country: + type: string + type: + type: string + source: type: string - resized: - type: boolean required: - id - - deviceAssetId - - ownerId - - type - - originalPath - - originalFileName - - originalMimeType - - thumbhash - - fileCreatedAt - - fileModifiedAt + - latitude + - longitude - localDateTime - - updatedAt - - isFavorite - - isArchived - - isTrashed - - duration - - exifInfo - - checksum - - isOffline - - hasMetadata - - duplicateId - - resized + - originalFileName + - city + - state + - country + - type + - source "/api/v1/photos/{id}/thumbnail": get: summary: Retrieves a photo @@ -487,6 +395,11 @@ paths: required: true schema: type: string + - name: source + in: query + required: true + schema: + type: string responses: '200': description: photo found @@ -497,107 +410,27 @@ paths: properties: id: type: string - deviceAssetId: - type: string - ownerId: - type: string - type: - type: string - originalPath: - type: string - originalFileName: - type: string - originalMimeType: - type: string - thumbhash: - type: string - fileCreatedAt: - type: string - format: date-time - fileModifiedAt: - type: string - format: date-time + latitude: + type: number + format: float + longitude: + type: number + format: float localDateTime: type: string format: date-time - updatedAt: + originalFileName: type: string - format: date-time - isFavorite: - type: boolean - isArchived: - type: boolean - isTrashed: - type: boolean - duration: + city: type: string - exifInfo: - type: object - properties: - make: - type: string - model: - type: string - exifImageWidth: - type: integer - exifImageHeight: - type: integer - fileSizeInByte: - type: integer - orientation: - type: string - dateTimeOriginal: - type: string - format: date-time - modifyDate: - type: string - format: date-time - timeZone: - type: string - lensModel: - type: string - fNumber: - type: number - format: float - focalLength: - type: number - format: float - iso: - type: integer - exposureTime: - type: string - latitude: - type: number - format: float - longitude: - type: number - format: float - city: - type: string - state: - type: string - country: - type: string - description: - type: string - projectionType: - type: - - string - - 'null' - rating: - type: - - integer - - 'null' - checksum: + state: type: string - isOffline: - type: boolean - hasMetadata: - type: boolean - duplicateId: + country: + type: string + type: + type: string + source: type: string - resized: - type: boolean '404': description: photo not found "/api/v1/points": From e32ad54f35012d21c4244779a987f35d9e36746d Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 3 Dec 2024 16:26:28 +0100 Subject: [PATCH 14/24] Fix failing tests --- app/services/photoprism/request_photos.rb | 2 +- .../photoprism/request_photos_spec.rb | 68 ++++++++++--------- 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb index 7bc40025..a7fb000d 100644 --- a/app/services/photoprism/request_photos.rb +++ b/app/services/photoprism/request_photos.rb @@ -49,7 +49,7 @@ class Photoprism::RequestPhotos ) if response.code != 200 - Rails.logger.info "Photoprism API returned #{response.code}: #{response.body}" + Rails.logger.error "Photoprism API returned #{response.code}: #{response.body}" Rails.logger.debug "Photoprism API request params: #{request_params(offset).inspect}" end diff --git a/spec/services/photoprism/request_photos_spec.rb b/spec/services/photoprism/request_photos_spec.rb index e8bcaaeb..a4461151 100644 --- a/spec/services/photoprism/request_photos_spec.rb +++ b/spec/services/photoprism/request_photos_spec.rb @@ -4,15 +4,17 @@ require 'rails_helper' RSpec.describe Photoprism::RequestPhotos do let(:user) do - create(:user, - settings: { - 'photoprism_url' => 'http://photoprism.local', - 'photoprism_api_key' => 'test_api_key' - }) + create( + :user, + settings: { + 'photoprism_url' => 'http://photoprism.local', + 'photoprism_api_key' => 'test_api_key' + } + ) end - let(:start_date) { '2023-01-01' } - let(:end_date) { '2023-12-31' } + let(:start_date) { '2024-01-01' } + let(:end_date) { '2024-12-31' } let(:service) { described_class.new(user, start_date: start_date, end_date: end_date) } let(:mock_photo_response) do @@ -150,7 +152,7 @@ RSpec.describe Photoprism::RequestPhotos do before do stub_request( :any, - "#{user.settings['photoprism_url']}/api/v1/photos?after=2023-01-01&before=2023-12-31&count=1000&public=true&q=&quality=3" + "#{user.settings['photoprism_url']}/api/v1/photos?after=#{start_date}&before=#{end_date}&count=1000&public=true&q=&quality=3" ).with( headers: { 'Accept' => 'application/json', @@ -166,7 +168,7 @@ RSpec.describe Photoprism::RequestPhotos do stub_request( :any, - "#{user.settings['photoprism_url']}/api/v1/photos?after=2023-01-01&before=2023-12-31&count=1000&public=true&q=&quality=3&offset=1000" + "#{user.settings['photoprism_url']}/api/v1/photos?after=#{start_date}&before=#{end_date}&count=1000&public=true&q=&quality=3&offset=1000" ).to_return(status: 200, body: [].to_json) end @@ -196,43 +198,48 @@ RSpec.describe Photoprism::RequestPhotos do before do stub_request( :get, - "#{user.settings['photoprism_url']}/api/v1/photos?after=2023-01-01&before=2023-12-31&count=1000&public=true&q=&quality=3" - ) - .to_return(status: 401, body: 'Unauthorized') + "#{user.settings['photoprism_url']}/api/v1/photos?after=#{start_date}&before=#{end_date}&count=1000&public=true&q=&quality=3" + ).to_return(status: 400, body: { status: 400, error: 'Unable to do that' }.to_json) end it 'logs the error' do - expect(Rails.logger).to receive(:error).with('Photoprism API returned 401: Unauthorized') + expect(Rails.logger).to \ + receive(:error).with('Photoprism API returned 400: {"status":400,"error":"Unable to do that"}') + expect(Rails.logger).to \ + receive(:debug).with( + "Photoprism API request params: #{{ q: '', public: true, quality: 3, after: start_date, count: 1000, +before: end_date }}" + ) service.call end end context 'with pagination' do - let(:first_page) { [{ 'TakenAtLocal' => '2023-06-15T14:30:00Z' }] } - let(:second_page) { [{ 'TakenAtLocal' => '2023-06-16T14:30:00Z' }] } + let(:first_page) { [{ 'TakenAtLocal' => "#{start_date}T14:30:00Z" }] } + let(:second_page) { [{ 'TakenAtLocal' => "#{start_date}T14:30:00Z" }] } let(:empty_page) { [] } - - before do - common_headers = { + let(:common_headers) do + { 'Accept' => 'application/json', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Authorization' => 'Bearer test_api_key', 'User-Agent' => 'Ruby' } + end + before do # First page stub_request(:any, "#{user.settings['photoprism_url']}/api/v1/photos") .with( headers: common_headers, query: { - after: '2023-01-01', - before: '2023-12-31', + after: start_date, + before: end_date, count: '1000', public: 'true', q: '', - quality: '3', - photo: 'yes' + quality: '3' } ) .to_return(status: 200, body: first_page.to_json) @@ -242,14 +249,13 @@ RSpec.describe Photoprism::RequestPhotos do .with( headers: common_headers, query: { - after: '2023-01-01', - before: '2023-12-31', + after: start_date, + before: end_date, count: '1000', public: 'true', q: '', quality: '3', - offset: '1000', - photo: 'yes' + offset: '1000' } ) .to_return(status: 200, body: second_page.to_json) @@ -259,14 +265,13 @@ RSpec.describe Photoprism::RequestPhotos do .with( headers: common_headers, query: { - after: '2023-01-01', - before: '2023-12-31', + after: start_date, + before: end_date, count: '1000', public: 'true', q: '', quality: '3', - offset: '2000', - photo: 'yes' + offset: '2000' } ) .to_return(status: 200, body: empty_page.to_json) @@ -274,7 +279,8 @@ RSpec.describe Photoprism::RequestPhotos do it 'fetches all pages until empty result' do result = service.call - expect(result.length).to eq(2) + + expect(result.size).to eq(2) end end end From b22a13282ee9c9193f75ae427b472952217b8c2a Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 3 Dec 2024 16:32:45 +0100 Subject: [PATCH 15/24] Update changelog and version --- .app_version | 2 +- CHANGELOG.md | 9 +++++++++ README.md | 4 ++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.app_version b/.app_version index 503a21de..1cf0537c 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.18.2 +0.19.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c1cd353..6a29907c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ 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.0 - 2024-12-03 + +## The Photoprism integration release + +### Added + +- Photos from Photoprism are now can be shown on the map. To enable this feature, you need to provide your Photoprism instance URL and API key in the Settings page. Then you need to enable "Photos" layer on the map (top right corner). +- Geodata is now can be imported from Photoprism to Dawarich. + # 0.18.2 - 2024-11-29 ### Added diff --git a/README.md b/README.md index 9b88431a..16878306 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,10 @@ Simply install one of the supported apps on your device and configure it to send ### 📊 Statistics - Analyze your travel history: number of countries/cities visited, distance traveled, and time spent, broken down by year and month. +### 📸 Integrations +- Provide credentials for Immich or Photoprism (or both!) and Dawarich will automatically import geodata from your photos. +- You'll also be able to visualize your photos on the map! + ### 📥 Import Your Data - Import from various sources: - Google Maps Timeline From 7bcdff58685a14bee4ec468d41e9d02fb6922244 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 3 Dec 2024 16:39:09 +0100 Subject: [PATCH 16/24] Update changelog --- CHANGELOG.md | 19 +++++++++++++++++++ spec/swagger/api/v1/photos_controller_spec.rb | 11 ----------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a29907c..c0bfa4f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,25 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## The Photoprism integration release +⚠️ This release introduces a breaking change. The `GET /api/v1/photos` endpoint now returns following structure of the response: + +```json +[ + { + "id": id, + "latitude": latitude, + "longitude": longitude, + "localDateTime": local_date_time, + "originalFileName": original_file_name, + "city": city, + "state": state, + "country": country, + "type": type, // "image" or "video" + "source": source // "photoprism" or "immich" + } +] +``` + ### Added - Photos from Photoprism are now can be shown on the map. To enable this feature, you need to provide your Photoprism instance URL and API key in the Settings page. Then you need to enable "Photos" layer on the map (top right corner). diff --git a/spec/swagger/api/v1/photos_controller_spec.rb b/spec/swagger/api/v1/photos_controller_spec.rb index 5b63d307..f441c307 100644 --- a/spec/swagger/api/v1/photos_controller_spec.rb +++ b/spec/swagger/api/v1/photos_controller_spec.rb @@ -102,17 +102,6 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do items: { type: :object, properties: { - # { - # id: id, - # latitude: latitude, - # longitude: longitude, - # localDateTime: local_date_time, - # originalFileName: original_file_name, - # city: city, - # state: state, - # country: country, - # type: type, - # source: source id: { type: :string }, latitude: { type: :number, format: :float }, longitude: { type: :number, format: :float }, From 955f8946ad83bc327af4fcb7e0b04c71467235fc Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 4 Dec 2024 12:32:13 +0100 Subject: [PATCH 17/24] Add test for photos integration not being configured --- app/controllers/api/v1/photos_controller.rb | 16 +++- spec/factories/users.rb | 11 ++- spec/requests/api/v1/photos_spec.rb | 90 +++++++++++-------- spec/swagger/api/v1/photos_controller_spec.rb | 2 +- 4 files changed, 77 insertions(+), 42 deletions(-) diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb index b3bb0e6a..b2930888 100644 --- a/app/controllers/api/v1/photos_controller.rb +++ b/app/controllers/api/v1/photos_controller.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class Api::V1::PhotosController < ApiController + before_action :check_integration_configured, only: %i[index thumbnail] + before_action :check_source, only: %i[thumbnail] + def index @photos = Rails.cache.fetch("photos_#{params[:start_date]}_#{params[:end_date]}", expires_in: 1.day) do Photos::Request.new(current_api_user, start_date: params[:start_date], end_date: params[:end_date]).call @@ -10,8 +13,6 @@ class Api::V1::PhotosController < ApiController end def thumbnail - return unauthorized_integration unless integration_configured? - response = fetch_cached_thumbnail(params[:source]) handle_thumbnail_response(response) end @@ -33,8 +34,15 @@ class Api::V1::PhotosController < ApiController end def integration_configured? - (params[:source] == 'immich' && current_api_user.immich_integration_configured?) || - (params[:source] == 'photoprism' && current_api_user.photoprism_integration_configured?) + current_api_user.immich_integration_configured? || current_api_user.photoprism_integration_configured? + end + + def check_integration_configured + unauthorized_integration unless integration_configured? + end + + def check_source + unauthorized_integration unless params[:source] == 'immich' || params[:source] == 'photoprism' end def unauthorized_integration diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 2d4e654f..f3fe9d7b 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -23,7 +23,7 @@ FactoryBot.define do admin { true } end - trait :with_immich_credentials do + trait :with_immich_integration do settings do { immich_url: 'https://immich.example.com', @@ -31,5 +31,14 @@ FactoryBot.define do } end end + + trait :with_photoprism_integration do + settings do + { + photoprism_url: 'https://photoprism.example.com', + photoprism_api_key: '1234567890' + } + end + end end end diff --git a/spec/requests/api/v1/photos_spec.rb b/spec/requests/api/v1/photos_spec.rb index c24a5360..c1e440bc 100644 --- a/spec/requests/api/v1/photos_spec.rb +++ b/spec/requests/api/v1/photos_spec.rb @@ -4,50 +4,68 @@ require 'rails_helper' RSpec.describe 'Api::V1::Photos', type: :request do describe 'GET /index' do - let(:user) { create(:user) } + context 'when the integration is configured' do + let(:user) { create(:user, :with_photoprism_integration) } - let(:photo_data) do - [ - { - 'id' => 1, - 'latitude' => 35.6762, - 'longitude' => 139.6503, - 'localDateTime' => '2024-01-01T00:00:00.000Z', - 'originalFileName' => 'photo1.jpg', - 'city' => 'Tokyo', - 'state' => 'Tokyo', - 'country' => 'Japan', - 'type' => 'photo', - 'source' => 'photoprism' - }, - { - 'id' => 2, - 'latitude' => 40.7128, - 'longitude' => -74.0060, - 'localDateTime' => '2024-01-02T00:00:00.000Z', - 'originalFileName' => 'photo2.jpg', - 'city' => 'New York', - 'state' => 'New York', - 'country' => 'USA', - 'type' => 'photo', - 'source' => 'immich' - } - ] + let(:photo_data) do + [ + { + 'id' => 1, + 'latitude' => 35.6762, + 'longitude' => 139.6503, + 'localDateTime' => '2024-01-01T00:00:00.000Z', + 'originalFileName' => 'photo1.jpg', + 'city' => 'Tokyo', + 'state' => 'Tokyo', + 'country' => 'Japan', + 'type' => 'photo', + 'source' => 'photoprism' + }, + { + 'id' => 2, + 'latitude' => 40.7128, + 'longitude' => -74.0060, + 'localDateTime' => '2024-01-02T00:00:00.000Z', + 'originalFileName' => 'photo2.jpg', + 'city' => 'New York', + 'state' => 'New York', + 'country' => 'USA', + 'type' => 'photo', + 'source' => 'immich' + } + ] + end + + context 'when the request is successful' do + before do + allow_any_instance_of(Photos::Request).to receive(:call).and_return(photo_data) + + get '/api/v1/photos', params: { api_key: user.api_key } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'returns photos data as JSON' do + expect(JSON.parse(response.body)).to eq(photo_data) + end + end end - context 'when the request is successful' do + context 'when the integration is not configured' do + let(:user) { create(:user) } + before do - allow_any_instance_of(Photos::Request).to receive(:call).and_return(photo_data) - - get '/api/v1/photos', params: { api_key: user.api_key } + get '/api/v1/photos', params: { api_key: user.api_key, source: 'immich' } end - it 'returns http success' do - expect(response).to have_http_status(:success) + it 'returns http unauthorized' do + expect(response).to have_http_status(:unauthorized) end - it 'returns photos data as JSON' do - expect(JSON.parse(response.body)).to eq(photo_data) + it 'returns an error message' do + expect(JSON.parse(response.body)).to eq({ 'error' => 'Immich integration not configured' }) end end end diff --git a/spec/swagger/api/v1/photos_controller_spec.rb b/spec/swagger/api/v1/photos_controller_spec.rb index f441c307..eef5d9a5 100644 --- a/spec/swagger/api/v1/photos_controller_spec.rb +++ b/spec/swagger/api/v1/photos_controller_spec.rb @@ -3,7 +3,7 @@ require 'swagger_helper' RSpec.describe 'Api::V1::PhotosController', type: :request do - let(:user) { create(:user, :with_immich_credentials) } + let(:user) { create(:user, :with_immich_integration) } let(:api_key) { user.api_key } let(:start_date) { '2024-01-01' } let(:end_date) { '2024-01-02' } From 9d573d90f3d14b5e2c29b1b26479c1186866177e Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 4 Dec 2024 13:17:15 +0100 Subject: [PATCH 18/24] Add spec for photo serializer --- app/serializers/api/photo_serializer.rb | 2 +- spec/serializers/api/photo_serializer_spec.rb | 160 ++++++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 spec/serializers/api/photo_serializer_spec.rb diff --git a/app/serializers/api/photo_serializer.rb b/app/serializers/api/photo_serializer.rb index 97e972c4..5e3ce9a5 100644 --- a/app/serializers/api/photo_serializer.rb +++ b/app/serializers/api/photo_serializer.rb @@ -2,7 +2,7 @@ class Api::PhotoSerializer def initialize(photo, source) - @photo = photo + @photo = photo.with_indifferent_access @source = source end diff --git a/spec/serializers/api/photo_serializer_spec.rb b/spec/serializers/api/photo_serializer_spec.rb new file mode 100644 index 00000000..3dad077a --- /dev/null +++ b/spec/serializers/api/photo_serializer_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::PhotoSerializer do + describe '#call' do + subject(:serialized_photo) { described_class.new(photo, source).call } + + context 'when photo is from immich' do + let(:source) { 'immich' } + let(:photo) do + { + "id": '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c', + "deviceAssetId": 'IMG_9913.jpeg-1168914', + "ownerId": 'f579f328-c355-438c-a82c-fe3390bd5f08', + "deviceId": 'CLI', + "libraryId": nil, + "type": 'IMAGE', + "originalPath": 'upload/library/admin/2023/2023-06-08/IMG_9913.jpeg', + "originalFileName": 'IMG_9913.jpeg', + "originalMimeType": 'image/jpeg', + "thumbhash": '4RgONQaZqYaH93g3h3p3d6RfPPrG', + "fileCreatedAt": '2023-06-08T07:58:45.637Z', + "fileModifiedAt": '2023-06-08T09:58:45.000Z', + "localDateTime": '2023-06-08T09:58:45.637Z', + "updatedAt": '2024-08-24T18:20:47.965Z', + "isFavorite": false, + "isArchived": false, + "isTrashed": false, + "duration": '0:00:00.00000', + "exifInfo": { + "make": 'Apple', + "model": 'iPhone 12 Pro', + "exifImageWidth": 4032, + "exifImageHeight": 3024, + "fileSizeInByte": 1_168_914, + "orientation": '6', + "dateTimeOriginal": '2023-06-08T07:58:45.637Z', + "modifyDate": '2023-06-08T07:58:45.000Z', + "timeZone": 'Europe/Berlin', + "lensModel": 'iPhone 12 Pro back triple camera 4.2mm f/1.6', + "fNumber": 1.6, + "focalLength": 4.2, + "iso": 320, + "exposureTime": '1/60', + "latitude": 52.11, + "longitude": 13.22, + "city": 'Johannisthal', + "state": 'Berlin', + "country": 'Germany', + "description": '', + "projectionType": nil, + "rating": nil + }, + "livePhotoVideoId": nil, + "people": [], + "checksum": 'aL1edPVg4ZpEnS6xCRWNUY0pUS8=', + "isOffline": false, + "hasMetadata": true, + "duplicateId": '88a34bee-783d-46e4-aa52-33b75ffda375', + "resized": true + } + end + + it 'serializes the photo correctly' do + expect(serialized_photo).to eq( + id: '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c', + latitude: 52.11, + longitude: 13.22, + localDateTime: '2023-06-08T09:58:45.637Z', + originalFileName: 'IMG_9913.jpeg', + city: 'Johannisthal', + state: 'Berlin', + country: 'Germany', + type: 'image', + source: 'immich' + ) + end + end + + context 'when photo is from photoprism' do + let(:source) { 'photoprism' } + let(:photo) do + { + 'ID' => '102', + 'UID' => 'psnver0s3x7wxfnh', + 'Type' => 'image', + 'TypeSrc' => '', + 'TakenAt' => '2023-10-10T16:04:33Z', + 'TakenAtLocal' => '2023-10-10T16:04:33Z', + 'TakenSrc' => 'name', + 'TimeZone' => '', + 'Path' => '2023/10', + 'Name' => '20231010_160433_91981432', + 'OriginalName' => 'photo_2023-10-10 16.04.33', + 'Title' => 'Photo / 2023', + 'Description' => '', + 'Year' => 2023, + 'Month' => 10, + 'Day' => 10, + 'Country' => 'zz', + 'Stack' => 0, + 'Favorite' => false, + 'Private' => false, + 'Iso' => 0, + 'FocalLength' => 0, + 'FNumber' => 0, + 'Exposure' => '', + 'Quality' => 1, + 'Resolution' => 1, + 'Color' => 4, + 'Scan' => false, + 'Panorama' => false, + 'CameraID' => 1, + 'CameraModel' => 'Unknown', + 'LensID' => 1, + 'LensModel' => 'Unknown', + 'Lat' => 11, + 'Lng' => 22, + 'CellID' => 'zz', + 'PlaceID' => 'zz', + 'PlaceSrc' => '', + 'PlaceLabel' => 'Unknown', + 'PlaceCity' => 'Unknown', + 'PlaceState' => 'Unknown', + 'PlaceCountry' => 'zz', + 'InstanceID' => '', + 'FileUID' => 'fsnver0clrfzatmz', + 'FileRoot' => '/', + 'FileName' => '2023/10/20231010_160433_91981432.jpeg', + 'Hash' => 'ce1849fd7cf6a50eb201fbb669ab78c7ac13263b', + 'Width' => 1280, + 'Height' => 908, + 'Portrait' => false, + 'Merged' => false, + 'CreatedAt' => '2024-12-02T14:25:48Z', + 'UpdatedAt' => '2024-12-02T14:36:45Z', + 'EditedAt' => '0001-01-01T00:00:00Z', + 'CheckedAt' => '2024-12-02T14:36:45Z', + 'Files' => nil + } + end + + it 'serializes the photo correctly' do + expect(serialized_photo).to eq( + id: 'ce1849fd7cf6a50eb201fbb669ab78c7ac13263b', + latitude: 11, + longitude: 22, + localDateTime: '2023-10-10T16:04:33Z', + originalFileName: 'photo_2023-10-10 16.04.33', + city: 'Unknown', + state: 'Unknown', + country: 'zz', + type: 'image', + source: 'photoprism' + ) + end + end + end +end From 243a85ed4e385c0fc5d9d802a041cb5494917450 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 4 Dec 2024 13:33:15 +0100 Subject: [PATCH 19/24] Add specs for Imports::Create --- spec/services/imports/create_spec.rb | 105 +++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 spec/services/imports/create_spec.rb diff --git a/spec/services/imports/create_spec.rb b/spec/services/imports/create_spec.rb new file mode 100644 index 00000000..d35e1898 --- /dev/null +++ b/spec/services/imports/create_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Imports::Create do + let(:user) { create(:user) } + let(:service) { described_class.new(user, import) } + + describe '#call' do + context 'when source is google_semantic_history' do + let(:import) { create(:import, source: 'google_semantic_history') } + + it 'calls the GoogleMaps::SemanticHistoryParser' do + expect(GoogleMaps::SemanticHistoryParser).to receive(:new).with(import, user.id).and_return(double(call: true)) + service.call + end + end + + context 'when source is google_phone_takeout' do + let(:import) { create(:import, source: 'google_phone_takeout') } + + it 'calls the GoogleMaps::PhoneTakeoutParser' do + expect(GoogleMaps::PhoneTakeoutParser).to receive(:new).with(import, user.id).and_return(double(call: true)) + service.call + end + end + + context 'when source is owntracks' do + let(:import) { create(:import, source: 'owntracks') } + + it 'calls the OwnTracks::ExportParser' do + expect(OwnTracks::ExportParser).to receive(:new).with(import, user.id).and_return(double(call: true)) + service.call + end + + context 'when import is successful' do + it 'creates a finished notification' do + service.call + + expect(user.notifications.last.kind).to eq('info') + end + + it 'schedules stats creating' do + Sidekiq::Testing.inline! do + expect { service.call }.to have_enqueued_job(Stats::CalculatingJob) + end + end + + it 'schedules visit suggesting' do + Sidekiq::Testing.inline! do + expect { service.call }.to have_enqueued_job(VisitSuggestingJob) + end + end + end + + context 'when import fails' do + before do + allow(OwnTracks::ExportParser).to receive(:new).with(import, user.id).and_return(double(call: false)) + end + + it 'creates a failed notification' do + service.call + + expect(user.notifications.last.kind).to eq('error') + end + end + end + + context 'when source is gpx' do + let(:import) { create(:import, source: 'gpx') } + + it 'calls the Gpx::TrackParser' do + expect(Gpx::TrackParser).to receive(:new).with(import, user.id).and_return(double(call: true)) + service.call + end + end + + context 'when source is geojson' do + let(:import) { create(:import, source: 'geojson') } + + it 'calls the Geojson::ImportParser' do + expect(Geojson::ImportParser).to receive(:new).with(import, user.id).and_return(double(call: true)) + service.call + end + end + + context 'when source is immich_api' do + let(:import) { create(:import, source: 'immich_api') } + + it 'calls the Photos::ImportParser' do + expect(Photos::ImportParser).to receive(:new).with(import, user.id).and_return(double(call: true)) + service.call + end + end + + context 'when source is photoprism_api' do + let(:import) { create(:import, source: 'photoprism_api') } + + it 'calls the Photos::ImportParser' do + expect(Photos::ImportParser).to receive(:new).with(import, user.id).and_return(double(call: true)) + service.call + end + end + end +end From 4c9357989067e6f29e3d34628cca4203f3fcf8bc Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 4 Dec 2024 13:40:20 +0100 Subject: [PATCH 20/24] Add specs for Photoprism::CachePreviewToken and Photoprism::ImportGeodata --- .../photoprism/cache_preview_token_spec.rb | 19 ++ .../photoprism/import_geodata_spec.rb | 177 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 spec/services/photoprism/cache_preview_token_spec.rb create mode 100644 spec/services/photoprism/import_geodata_spec.rb diff --git a/spec/services/photoprism/cache_preview_token_spec.rb b/spec/services/photoprism/cache_preview_token_spec.rb new file mode 100644 index 00000000..298aee98 --- /dev/null +++ b/spec/services/photoprism/cache_preview_token_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Photoprism::CachePreviewToken, type: :service do + let(:user) { double('User', id: 1) } + let(:preview_token) { 'sample_token' } + let(:service) { described_class.new(user, preview_token) } + + describe '#call' do + it 'writes the preview token to the cache with the correct key' do + expect(Rails.cache).to receive(:write).with( + "dawarich/photoprism_preview_token_#{user.id}", preview_token + ) + + service.call + end + end +end diff --git a/spec/services/photoprism/import_geodata_spec.rb b/spec/services/photoprism/import_geodata_spec.rb new file mode 100644 index 00000000..341348fc --- /dev/null +++ b/spec/services/photoprism/import_geodata_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Photoprism::ImportGeodata do + describe '#call' do + subject(:service) { described_class.new(user).call } + + let(:user) do + create(:user, settings: { 'photoprism_url' => 'http://photoprism.app', 'photoprism_api_key' => '123456' }) + end + let(:photoprism_data) do + [ + { + 'ID' => '82', + 'UID' => 'psnveqq089xhy1c3', + 'Type' => 'image', + 'TypeSrc' => '', + 'TakenAt' => '2024-08-18T14:11:05Z', + 'TakenAtLocal' => '2024-08-18T16:11:05Z', + 'TakenSrc' => 'meta', + 'TimeZone' => 'Europe/Prague', + 'Path' => '2024/08', + 'Name' => '20240818_141105_44E61AED', + 'OriginalName' => 'PXL_20240818_141105789', + 'Title' => 'Moment / Karlovy Vary / 2024', + 'Description' => '', + 'Year' => 2024, + 'Month' => 8, + 'Day' => 18, + 'Country' => 'cz', + 'Stack' => 0, + 'Favorite' => false, + 'Private' => false, + 'Iso' => 37, + 'FocalLength' => 21, + 'FNumber' => 2.2, + 'Exposure' => '1/347', + 'Quality' => 4, + 'Resolution' => 10, + 'Color' => 2, + 'Scan' => false, + 'Panorama' => false, + 'CameraID' => 8, + 'CameraSrc' => 'meta', + 'CameraMake' => 'Google', + 'CameraModel' => 'Pixel 7 Pro', + 'LensID' => 11, + 'LensMake' => 'Google', + 'LensModel' => 'Pixel 7 Pro front camera 2.74mm f/2.2', + 'Altitude' => 423, + 'Lat' => 50.11, + 'Lng' => 12.12, + 'CellID' => 's2:47a09944f33c', + 'PlaceID' => 'cz:ciNqTjWuq6NN', + 'PlaceSrc' => 'meta', + 'PlaceLabel' => 'Karlovy Vary, Severozápad, Czech Republic', + 'PlaceCity' => 'Karlovy Vary', + 'PlaceState' => 'Severozápad', + 'PlaceCountry' => 'cz', + 'InstanceID' => '', + 'FileUID' => 'fsnveqqeusn692qo', + 'FileRoot' => '/', + 'FileName' => '2024/08/20240818_141105_44E61AED.jpg', + 'Hash' => 'cc5d0f544e52b288d7c8460d2e1bb17fa66e6089', + 'Width' => 2736, + 'Height' => 3648, + 'Portrait' => true, + 'Merged' => false, + 'CreatedAt' => '2024-12-02T14:25:38Z', + 'UpdatedAt' => '2024-12-02T14:25:38Z', + 'EditedAt' => '0001-01-01T00:00:00Z', + 'CheckedAt' => '2024-12-02T14:36:45Z', + 'Files' => nil + }, + { + 'ID' => '81', + 'UID' => 'psnveqpl96gcfdzf', + 'Type' => 'image', + 'TypeSrc' => '', + 'TakenAt' => '2024-08-18T14:11:04Z', + 'TakenAtLocal' => '2024-08-18T16:11:04Z', + 'TakenSrc' => 'meta', + 'TimeZone' => 'Europe/Prague', + 'Path' => '2024/08', + 'Name' => '20240818_141104_E9949CD4', + 'OriginalName' => 'PXL_20240818_141104633', + 'Title' => 'Portrait / Karlovy Vary / 2024', + 'Description' => '', + 'Year' => 2024, + 'Month' => 8, + 'Day' => 18, + 'Country' => 'cz', + 'Stack' => 0, + 'Favorite' => false, + 'Private' => false, + 'Iso' => 43, + 'FocalLength' => 21, + 'FNumber' => 2.2, + 'Exposure' => '1/356', + 'Faces' => 1, + 'Quality' => 4, + 'Resolution' => 10, + 'Color' => 2, + 'Scan' => false, + 'Panorama' => false, + 'CameraID' => 8, + 'CameraSrc' => 'meta', + 'CameraMake' => 'Google', + 'CameraModel' => 'Pixel 7 Pro', + 'LensID' => 11, + 'LensMake' => 'Google', + 'LensModel' => 'Pixel 7 Pro front camera 2.74mm f/2.2', + 'Altitude' => 423, + 'Lat' => 50.21, + 'Lng' => 12.85, + 'CellID' => 's2:47a09944f33c', + 'PlaceID' => 'cz:ciNqTjWuq6NN', + 'PlaceSrc' => 'meta', + 'PlaceLabel' => 'Karlovy Vary, Severozápad, Czech Republic', + 'PlaceCity' => 'Karlovy Vary', + 'PlaceState' => 'Severozápad', + 'PlaceCountry' => 'cz', + 'InstanceID' => '', + 'FileUID' => 'fsnveqp9xsl7onsv', + 'FileRoot' => '/', + 'FileName' => '2024/08/20240818_141104_E9949CD4.jpg', + 'Hash' => 'd5dfadc56a0b63051dfe0b5dec55ff1d81f033b7', + 'Width' => 2736, + 'Height' => 3648, + 'Portrait' => true, + 'Merged' => false, + 'CreatedAt' => '2024-12-02T14:25:37Z', + 'UpdatedAt' => '2024-12-02T14:25:37Z', + 'EditedAt' => '0001-01-01T00:00:00Z', + 'CheckedAt' => '2024-12-02T14:36:45Z', + 'Files' => nil + } + ].to_json + end + + before do + stub_request(:get, %r{http://photoprism\.app/api/v1/photos}).with( + headers: { + 'Accept' => 'application/json', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'Authorization' => 'Bearer 123456', + 'User-Agent' => 'Ruby' + } + ).to_return(status: 200, body: photoprism_data, headers: {}) + end + + it 'creates import' do + expect { service }.to change { Import.count }.by(1) + end + + it 'enqueues ImportJob' do + expect(ImportJob).to receive(:perform_later) + + service + end + + context 'when import already exists' do + before { service } + + it 'does not create new import' do + expect { service }.not_to(change { Import.count }) + end + + it 'does not enqueue ImportJob' do + expect(ImportJob).to_not receive(:perform_later) + + service + end + end + end +end From 1030bd5c37f9e9ef977cc8aa514ad26f182c9e84 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 4 Dec 2024 13:45:19 +0100 Subject: [PATCH 21/24] Rename Photos::Request to Photos::Search and add test for it --- app/controllers/api/v1/photos_controller.rb | 2 +- app/services/photos/{request.rb => search.rb} | 2 +- spec/requests/api/v1/photos_spec.rb | 2 +- spec/services/photos/search_spec.rb | 147 ++++++++++++++++++ 4 files changed, 150 insertions(+), 3 deletions(-) rename app/services/photos/{request.rb => search.rb} (97%) create mode 100644 spec/services/photos/search_spec.rb diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb index b2930888..5eee82c0 100644 --- a/app/controllers/api/v1/photos_controller.rb +++ b/app/controllers/api/v1/photos_controller.rb @@ -6,7 +6,7 @@ class Api::V1::PhotosController < ApiController def index @photos = Rails.cache.fetch("photos_#{params[:start_date]}_#{params[:end_date]}", expires_in: 1.day) do - Photos::Request.new(current_api_user, start_date: params[:start_date], end_date: params[:end_date]).call + Photos::Search.new(current_api_user, start_date: params[:start_date], end_date: params[:end_date]).call end render json: @photos, status: :ok diff --git a/app/services/photos/request.rb b/app/services/photos/search.rb similarity index 97% rename from app/services/photos/request.rb rename to app/services/photos/search.rb index 3bb5d059..20046268 100644 --- a/app/services/photos/request.rb +++ b/app/services/photos/search.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Photos::Request +class Photos::Search attr_reader :user, :start_date, :end_date def initialize(user, start_date: '1970-01-01', end_date: nil) diff --git a/spec/requests/api/v1/photos_spec.rb b/spec/requests/api/v1/photos_spec.rb index c1e440bc..8c8811b6 100644 --- a/spec/requests/api/v1/photos_spec.rb +++ b/spec/requests/api/v1/photos_spec.rb @@ -38,7 +38,7 @@ RSpec.describe 'Api::V1::Photos', type: :request do context 'when the request is successful' do before do - allow_any_instance_of(Photos::Request).to receive(:call).and_return(photo_data) + allow_any_instance_of(Photos::Search).to receive(:call).and_return(photo_data) get '/api/v1/photos', params: { api_key: user.api_key } end diff --git a/spec/services/photos/search_spec.rb b/spec/services/photos/search_spec.rb new file mode 100644 index 00000000..0ce34613 --- /dev/null +++ b/spec/services/photos/search_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Photos::Search do + let(:user) { create(:user) } + let(:start_date) { '2024-01-01' } + let(:end_date) { '2024-03-01' } + let(:service) { described_class.new(user, start_date: start_date, end_date: end_date) } + + describe '#call' do + context 'when user has no 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 Immich integration configured' do + let(:immich_photo) { { 'type' => 'image', 'id' => '1' } } + let(:serialized_photo) { { id: '1', source: 'immich' } } + + before do + allow(user).to receive(:immich_integration_configured?).and_return(true) + allow(user).to receive(:photoprism_integration_configured?).and_return(false) + + allow_any_instance_of(Immich::RequestPhotos).to receive(:call) + .and_return([immich_photo]) + + allow_any_instance_of(Api::PhotoSerializer).to receive(:call) + .and_return(serialized_photo) + end + + it 'fetches and transforms Immich photos' do + expect(service.call).to eq([serialized_photo]) + end + end + + context 'when user has Photoprism integration configured' do + let(:photoprism_photo) { { 'Type' => 'image', 'id' => '2' } } + let(:serialized_photo) { { id: '2', source: 'photoprism' } } + + before do + allow(user).to receive(:immich_integration_configured?).and_return(false) + allow(user).to receive(:photoprism_integration_configured?).and_return(true) + + allow_any_instance_of(Photoprism::RequestPhotos).to receive(:call) + .and_return([photoprism_photo]) + + allow_any_instance_of(Api::PhotoSerializer).to receive(:call) + .and_return(serialized_photo) + end + + it 'fetches and transforms Photoprism photos' do + expect(service.call).to eq([serialized_photo]) + end + end + + context 'when user has both integrations configured' do + let(:immich_photo) { { 'type' => 'image', 'id' => '1' } } + let(:photoprism_photo) { { 'Type' => 'image', 'id' => '2' } } + let(:serialized_immich) do + { + id: '1', + latitude: nil, + longitude: nil, + localDateTime: nil, + originalFileName: nil, + city: nil, + state: nil, + country: nil, + type: 'image', + source: 'immich' + } + end + let(:serialized_photoprism) do + { + id: '2', + latitude: nil, + longitude: nil, + localDateTime: nil, + originalFileName: nil, + city: nil, + state: nil, + country: nil, + type: 'image', + source: 'photoprism' + } + end + + before do + allow(user).to receive(:immich_integration_configured?).and_return(true) + allow(user).to receive(:photoprism_integration_configured?).and_return(true) + + allow_any_instance_of(Immich::RequestPhotos).to receive(:call) + .and_return([immich_photo]) + allow_any_instance_of(Photoprism::RequestPhotos).to receive(:call) + .and_return([photoprism_photo]) + end + + it 'fetches and transforms photos from both services' do + expect(service.call).to eq([serialized_immich, serialized_photoprism]) + end + end + + context 'when filtering out videos' do + let(:immich_photo) { { 'type' => 'video', 'id' => '1' } } + + before do + allow(user).to receive(:immich_integration_configured?).and_return(true) + allow(user).to receive(:photoprism_integration_configured?).and_return(false) + + allow_any_instance_of(Immich::RequestPhotos).to receive(:call) + .and_return([immich_photo]) + end + + it 'excludes video assets' do + expect(service.call).to eq([]) + end + end + end + + describe '#initialize' do + context 'with default parameters' do + let(:service_default) { described_class.new(user) } + + it 'sets default start_date' do + expect(service_default.start_date).to eq('1970-01-01') + end + + it 'sets default end_date to nil' do + expect(service_default.end_date).to be_nil + end + end + + context 'with custom parameters' do + it 'sets custom dates' do + expect(service.start_date).to eq(start_date) + expect(service.end_date).to eq(end_date) + end + end + end +end From d2bffdf1f13e30c7e14f5e354666c6048a83ec13 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 4 Dec 2024 13:50:41 +0100 Subject: [PATCH 22/24] Add spec for Photos::Thumbnail --- app/services/photos/thumbnail.rb | 13 ++--- spec/services/photos/thumbnail_spec.rb | 77 ++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 spec/services/photos/thumbnail_spec.rb diff --git a/app/services/photos/thumbnail.rb b/app/services/photos/thumbnail.rb index be3063a1..6bdb7fd5 100644 --- a/app/services/photos/thumbnail.rb +++ b/app/services/photos/thumbnail.rb @@ -8,7 +8,7 @@ class Photos::Thumbnail end def call - fetch_thumbnail_from_source + HTTParty.get(request_url, headers: headers) end private @@ -35,6 +35,10 @@ class Photos::Thumbnail end end + def request_url + "#{source_url}#{source_path}" + end + def headers request_headers = { 'accept' => 'application/octet-stream' @@ -44,11 +48,4 @@ class Photos::Thumbnail request_headers end - - def fetch_thumbnail_from_source - url = "#{source_url}#{source_path}" - a = HTTParty.get(url, headers: headers) - pp url - a - end end diff --git a/spec/services/photos/thumbnail_spec.rb b/spec/services/photos/thumbnail_spec.rb new file mode 100644 index 00000000..c687e370 --- /dev/null +++ b/spec/services/photos/thumbnail_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Photos::Thumbnail do + let(:user) { create(:user) } + let(:id) { 'photo123' } + + describe '#call' do + subject { described_class.new(user, source, id).call } + + context 'with immich source' do + let(:source) { 'immich' } + let(:api_key) { 'immich_key_123' } + let(:base_url) { 'https://photos.example.com' } + let(:expected_url) { "#{base_url}/api/assets/#{id}/thumbnail?size=preview" } + let(:expected_headers) do + { + 'accept' => 'application/octet-stream', + 'X-Api-Key' => api_key + } + end + + before do + allow(user).to receive(:settings).and_return( + 'immich_url' => base_url, + 'immich_api_key' => api_key + ) + end + + it 'fetches thumbnail with correct parameters' do + expect(HTTParty).to receive(:get) + .with(expected_url, headers: expected_headers) + .and_return('thumbnail_data') + + expect(subject).to eq('thumbnail_data') + end + end + + context 'with photoprism source' do + let(:source) { 'photoprism' } + let(:base_url) { 'https://photoprism.example.com' } + let(:preview_token) { 'preview_token_123' } + let(:expected_url) { "#{base_url}/api/v1/t/#{id}/#{preview_token}/tile_500" } + let(:expected_headers) do + { + 'accept' => 'application/octet-stream' + } + end + + before do + allow(user).to receive(:settings).and_return( + 'photoprism_url' => base_url + ) + allow(Rails.cache).to receive(:read) + .with("#{Photoprism::CachePreviewToken::TOKEN_CACHE_KEY}_#{user.id}") + .and_return(preview_token) + end + + it 'fetches thumbnail with correct parameters' do + expect(HTTParty).to receive(:get) + .with(expected_url, headers: expected_headers) + .and_return('thumbnail_data') + + expect(subject).to eq('thumbnail_data') + end + end + + context 'with unsupported source' do + let(:source) { 'unsupported' } + + it 'raises an error' do + expect { subject }.to raise_error(RuntimeError, 'Unsupported source: unsupported') + end + end + end +end From cabce29ee2e46948933258529f2b36666149efb0 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 4 Dec 2024 13:59:49 +0100 Subject: [PATCH 23/24] Update changelog --- CHANGELOG.md | 25 ++++++++++++----------- app/services/photoprism/request_photos.rb | 4 ++++ 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0bfa4f9..c58f2615 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,21 +9,22 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## The Photoprism integration release -⚠️ This release introduces a breaking change. The `GET /api/v1/photos` endpoint now returns following structure of the response: +⚠️ This release introduces a breaking change. ⚠️ +The `GET /api/v1/photos` endpoint now returns following structure of the response: ```json [ { - "id": id, - "latitude": latitude, - "longitude": longitude, - "localDateTime": local_date_time, - "originalFileName": original_file_name, - "city": city, - "state": state, - "country": country, - "type": type, // "image" or "video" - "source": source // "photoprism" or "immich" + "id": "1", + "latitude": "11.22", + "longitude": "12.33", + "localDateTime": "2024-01-01T00:00:00Z", + "originalFileName": "photo.jpg", + "city": "Berlin", + "state": "Berlin", + "country": "Germany", + "type": "image", // "image" or "video" + "source": "photoprism" // "photoprism" or "immich" } ] ``` @@ -31,7 +32,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Photos from Photoprism are now can be shown on the map. To enable this feature, you need to provide your Photoprism instance URL and API key in the Settings page. Then you need to enable "Photos" layer on the map (top right corner). -- Geodata is now can be imported from Photoprism to Dawarich. +- Geodata is now can be imported from Photoprism to Dawarich. The "Import Photoprism data" button on the Imports page will start the import process. # 0.18.2 - 2024-11-29 diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb index a7fb000d..276e7e5c 100644 --- a/app/services/photoprism/request_photos.rb +++ b/app/services/photoprism/request_photos.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true +# This integration built based on +# [September 15, 2024](https://github.com/photoprism/photoprism/releases/tag/240915-e1280b2fb) +# release of Photoprism. + class Photoprism::RequestPhotos attr_reader :user, :photoprism_api_base_url, :photoprism_api_key, :start_date, :end_date From 49fc333f034265b195e3367cda281febaceb64e4 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 4 Dec 2024 14:01:26 +0100 Subject: [PATCH 24/24] Fix changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c58f2615..23150af8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,8 @@ The `GET /api/v1/photos` endpoint now returns following structure of the respons [ { "id": "1", - "latitude": "11.22", - "longitude": "12.33", + "latitude": 11.22, + "longitude": 12.33, "localDateTime": "2024-01-01T00:00:00Z", "originalFileName": "photo.jpg", "city": "Berlin",