From 130630b9974550c613bbbbdb7ce135eef1f5a759 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 26 Nov 2024 14:46:26 +0100 Subject: [PATCH] Move Immich API request to a separate service & add photos api controller --- app/assets/stylesheets/application.css | 8 ++ app/controllers/api/v1/photos_controller.rb | 11 ++ app/javascript/controllers/maps_controller.js | 69 +++++++++- app/services/immich/import_geodata.rb | 61 +-------- app/services/immich/request_photos.rb | 61 +++++++++ config/routes.rb | 1 + spec/requests/api/v1/photos_spec.rb | 11 ++ spec/services/immich/import_geodata_spec.rb | 129 +++++++----------- spec/services/immich/request_photos_spec.rb | 111 +++++++++++++++ 9 files changed, 331 insertions(+), 131 deletions(-) create mode 100644 app/controllers/api/v1/photos_controller.rb create mode 100644 app/services/immich/request_photos.rb create mode 100644 spec/requests/api/v1/photos_spec.rb create mode 100644 spec/services/immich/request_photos_spec.rb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 2cc74030..f823a8e7 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -57,3 +57,11 @@ .leaflet-settings-panel button:hover { background-color: #0056b3; } + +.photo-marker { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; +} diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb new file mode 100644 index 00000000..fba900c2 --- /dev/null +++ b/app/controllers/api/v1/photos_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +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 + end + + render json: @photos, status: :ok + end +end diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 263feaf2..4153624d 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -65,6 +65,8 @@ export default class extends Controller { this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map); this.fogOverlay = L.layerGroup(); // Initialize fog layer this.areasLayer = L.layerGroup(); // Initialize areas layer + this.photoMarkers = L.layerGroup(); + this.setupScratchLayer(this.countryCodesMap); if (!this.settingsButtonAdded) { @@ -77,7 +79,8 @@ export default class extends Controller { Heatmap: this.heatmapLayer, "Fog of War": this.fogOverlay, "Scratch map": this.scratchLayer, - Areas: this.areasLayer // Add the areas layer to the controls + Areas: this.areasLayer, + Photos: this.photoMarkers }; L.control @@ -133,6 +136,13 @@ export default class extends Controller { if (e.name === 'Areas') { this.map.addControl(this.drawControl); } + if (e.name === 'Photos') { + // Extract dates from URL parameters + const urlParams = new URLSearchParams(window.location.search); + const startDate = urlParams.get('start_at')?.split('T')[0] || new Date().toISOString().split('T')[0]; + const endDate = urlParams.get('end_at')?.split('T')[0] || new Date().toISOString().split('T')[0]; + this.fetchAndDisplayPhotos(startDate, endDate); + } }); this.map.on('overlayremove', (e) => { @@ -771,4 +781,61 @@ export default class extends Controller { this.map.removeControl(this.layerControl); this.layerControl = L.control.layers(this.baseMaps(), layerControl).addTo(this.map); } + + async fetchAndDisplayPhotos(startDate, endDate) { + try { + const params = new URLSearchParams({ + api_key: this.apiKey, + start_date: startDate, + end_date: endDate + }); + + const response = await fetch(`/api/v1/photos?${params}`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const photos = await response.json(); + + // Clear existing photo markers + this.photoMarkers.clearLayers(); + + // Create markers for each photo with coordinates + photos.forEach(photo => { + if (photo.exifInfo?.latitude && photo.exifInfo?.longitude) { + const marker = L.marker([photo.exifInfo.latitude, photo.exifInfo.longitude], { + icon: L.divIcon({ + className: 'photo-marker', + html: `
+ 📷 +
`, + iconSize: [24, 24] + }) + }); + + // Add popup with photo information + const popupContent = ` +
+

${photo.originalFileName}

+

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

+

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

+ ${photo.type === 'VIDEO' ? '🎥 Video' : '📷 Photo'} +
+ `; + marker.bindPopup(popupContent); + + this.photoMarkers.addLayer(marker); + } + }); + + // Add the layer group to the map if it's not already added + if (!this.map.hasLayer(this.photoMarkers)) { + this.photoMarkers.addTo(this.map); + } + + } catch (error) { + console.error('Error fetching photos:', error); + showFlashMessage('error', 'Failed to fetch photos'); + } + } } diff --git a/app/services/immich/import_geodata.rb b/app/services/immich/import_geodata.rb index 5b817ead..38c34c58 100644 --- a/app/services/immich/import_geodata.rb +++ b/app/services/immich/import_geodata.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true class Immich::ImportGeodata - attr_reader :user, :immich_api_base_url, :immich_api_key + attr_reader :user, :start_date, :end_date - def initialize(user) + def initialize(user, end_date:, start_date: '1970-01-01') @user = user - @immich_api_base_url = "#{user.settings['immich_url']}/api/search/metadata" - @immich_api_key = user.settings['immich_api_key'] + @start_date = start_date + @end_date = end_date end def call @@ -17,8 +17,6 @@ class Immich::ImportGeodata log_no_data and return if immich_data.empty? - write_raw_data(immich_data) - immich_data_json = parse_immich_data(immich_data) file_name = file_name(immich_data_json) import = user.imports.find_or_initialize_by(name: file_name, source: :immich_api) @@ -27,53 +25,14 @@ class Immich::ImportGeodata import.raw_data = immich_data_json import.save! + ImportJob.perform_later(user.id, import.id) end private - def headers - { - 'x-api-key' => immich_api_key, - 'accept' => 'application/json' - } - end - def retrieve_immich_data - page = 1 - data = [] - max_pages = 1000 # Prevent infinite loop - - while page <= max_pages - Rails.logger.debug "Retrieving next page: #{page}" - body = request_body(page) - response = JSON.parse(HTTParty.post(immich_api_base_url, headers: headers, body: body).body) - - items = response.dig('assets', 'items') - Rails.logger.debug "#{items.size} items found" - - break if items.empty? - - data << items - - Rails.logger.debug "next_page: #{response.dig('assets', 'nextPage')}" - - page += 1 - - Rails.logger.debug "#{data.flatten.size} data size" - end - - data.flatten - end - - def request_body(page) - { - createdAfter: '1970-01-01', - size: 1000, - page: page, - order: 'asc', - withExif: true - } + Immich::RequestPhotos.new(user, start_date:, end_date:).call end def parse_immich_data(immich_data) @@ -101,13 +60,7 @@ class Immich::ImportGeodata end def log_no_data - Rails.logger.debug 'No data found' - end - - def write_raw_data(immich_data) - File.open("tmp/imports/immich_raw_data_#{Time.current}_#{user.email}.json", 'w') do |file| - file.write(immich_data.to_json) - end + Rails.logger.info 'No data found' end def create_import_failed_notification(import_name) diff --git a/app/services/immich/request_photos.rb b/app/services/immich/request_photos.rb new file mode 100644 index 00000000..78be8197 --- /dev/null +++ b/app/services/immich/request_photos.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class Immich::RequestPhotos + attr_reader :user, :immich_api_base_url, :immich_api_key, :start_date, :end_date + + 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_key = user.settings['immich_api_key'] + @start_date = start_date + @end_date = end_date + end + + def call + retrieve_immich_data + end + + private + + def retrieve_immich_data + page = 1 + data = [] + max_pages = 100_000 # Prevent infinite loop + + while page <= max_pages + body = request_body(page) + response = JSON.parse(HTTParty.post(immich_api_base_url, headers: headers, body: body).body) + + items = response.dig('assets', 'items') + + break if items.empty? + + data << items + + page += 1 + end + + data.flatten + end + + def headers + { + 'x-api-key' => immich_api_key, + 'accept' => 'application/json' + } + end + + def request_body(page) + body = { + createdAfter: start_date, + size: 1000, + page: page, + order: 'asc', + withExif: true + } + + return body unless end_date + + body.merge(createdBefore: end_date) + end +end diff --git a/config/routes.rb b/config/routes.rb index c62fecfa..a7dd1f79 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -57,6 +57,7 @@ Rails.application.routes.draw do namespace :api do namespace :v1 do + get 'photos', to: 'photos#index' get 'health', to: 'health#index' patch 'settings', to: 'settings#update' get 'settings', to: 'settings#index' diff --git a/spec/requests/api/v1/photos_spec.rb b/spec/requests/api/v1/photos_spec.rb new file mode 100644 index 00000000..dc45702c --- /dev/null +++ b/spec/requests/api/v1/photos_spec.rb @@ -0,0 +1,11 @@ +require 'rails_helper' + +RSpec.describe "Api::V1::Photos", type: :request do + describe "GET /index" do + it "returns http success" do + get "/api/v1/photos/index" + expect(response).to have_http_status(:success) + end + end + +end diff --git a/spec/services/immich/import_geodata_spec.rb b/spec/services/immich/import_geodata_spec.rb index 77a1d414..b5460526 100644 --- a/spec/services/immich/import_geodata_spec.rb +++ b/spec/services/immich/import_geodata_spec.rb @@ -22,54 +22,54 @@ RSpec.describe Immich::ImportGeodata do "count": 1000, "items": [ { - "id": "7fe486e3-c3ba-4b54-bbf9-1281b39ed15c", - "deviceAssetId": "IMG_9913.jpeg-1168914", - "ownerId": "f579f328-c355-438c-a82c-fe3390bd5f08", - "deviceId": "CLI", + "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", + "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", + "duration": '0:00:00.00000', "exifInfo": { - "make": "Apple", - "model": "iPhone 12 Pro", + "make": 'Apple', + "model": 'iPhone 12 Pro', "exifImageWidth": 4032, "exifImageHeight": 3024, - "fileSizeInByte": 1168914, - "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", + "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", + "exposureTime": '1/60', "latitude": 52.11, "longitude": 13.22, - "city": "Johannisthal", - "state": "Berlin", - "country": "Germany", - "description": "", + "city": 'Johannisthal', + "state": 'Berlin', + "country": 'Germany', + "description": '', "projectionType": nil, "rating": nil }, "livePhotoVideoId": nil, "people": [], - "checksum": "aL1edPVg4ZpEnS6xCRWNUY0pUS8=", + "checksum": 'aL1edPVg4ZpEnS6xCRWNUY0pUS8=', "isOffline": false, "hasMetadata": true, - "duplicateId": "88a34bee-783d-46e4-aa52-33b75ffda375", + "duplicateId": '88a34bee-783d-46e4-aa52-33b75ffda375', "resized": true } ] @@ -77,58 +77,35 @@ RSpec.describe Immich::ImportGeodata do }.to_json end - context 'when user has immich_url and immich_api_key' do - before do - stub_request( - :any, - 'http://immich.app/api/search/metadata').to_return(status: 200, body: immich_data, headers: {}) + before do + stub_request( + :any, + 'http://immich.app/api/search/metadata' + ).to_return(status: 200, body: immich_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 'creates import' do - expect { service }.to change { Import.count }.by(1) - end - - it 'enqueues ImportJob' do - expect(ImportJob).to receive(:perform_later) + it 'does not enqueue ImportJob' do + expect(ImportJob).to_not 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 - - context 'when user has no immich_url' do - before do - user.settings['immich_url'] = nil - user.save - end - - it 'raises ArgumentError' do - expect { service }.to raise_error(ArgumentError, 'Immich URL is missing') - end - end - - context 'when user has no immich_api_key' do - before do - user.settings['immich_api_key'] = nil - user.save - end - - it 'raises ArgumentError' do - expect { service }.to raise_error(ArgumentError, 'Immich API key is missing') - end end end end diff --git a/spec/services/immich/request_photos_spec.rb b/spec/services/immich/request_photos_spec.rb new file mode 100644 index 00000000..e36b6449 --- /dev/null +++ b/spec/services/immich/request_photos_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Immich::RequestPhotos do + describe '#call' do + subject(:service) { described_class.new(user).call } + + let(:user) do + create(:user, settings: { 'immich_url' => 'http://immich.app', 'immich_api_key' => '123456' }) + end + let(:immich_data) do + { + "albums": { + "total": 0, + "count": 0, + "items": [], + "facets": [] + }, + "assets": { + "total": 1000, + "count": 1000, + "items": [ + { + "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 + } + ] + } + }.to_json + end + + context 'when user has immich_url and immich_api_key' do + before do + stub_request( + :any, + 'http://immich.app/api/search/metadata' + ).to_return(status: 200, body: immich_data, headers: {}) + end + end + + context 'when user has no immich_url' do + before do + user.settings['immich_url'] = nil + user.save + end + + it 'raises ArgumentError' do + expect { service }.to raise_error(ArgumentError, 'Immich URL is missing') + end + end + + context 'when user has no immich_api_key' do + before do + user.settings['immich_api_key'] = nil + user.save + end + + it 'raises ArgumentError' do + expect { service }.to raise_error(ArgumentError, 'Immich API key is missing') + end + end + end +end