From 130630b9974550c613bbbbdb7ce135eef1f5a759 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 26 Nov 2024 14:46:26 +0100 Subject: [PATCH 1/7] 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 From 428e9274329e160f3627f42e4c10e840faceaaa4 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 26 Nov 2024 16:36:02 +0100 Subject: [PATCH 2/7] Show individual photo markers on the map --- app/assets/stylesheets/application.css | 7 ++ app/controllers/api/v1/photos_controller.rb | 26 ++++++- app/javascript/controllers/maps_controller.js | 78 +++++++++++-------- config/routes.rb | 6 ++ 4 files changed, 84 insertions(+), 33 deletions(-) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index f823a8e7..08196e09 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -64,4 +64,11 @@ justify-content: center; background: transparent; border: none; + border-radius: 50%; +} + +.photo-marker img { + border-radius: 50%; + width: 48px; + height: 48px; } diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb index fba900c2..93f17208 100644 --- a/app/controllers/api/v1/photos_controller.rb +++ b/app/controllers/api/v1/photos_controller.rb @@ -4,8 +4,32 @@ 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 + end.reject { |photo| photo['type'].downcase == 'video' } render json: @photos, status: :ok end + + def thumbnail + response = 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: { + 'x-api-key' => current_api_user.settings['immich_api_key'], + 'accept' => 'application/octet-stream' + } + ) + end + + if response.success? + send_data( + response.body, + type: 'image/jpeg', + disposition: 'inline', + status: :ok + ) + else + Rails.logger.error "Failed to fetch thumbnail: #{response.code} - #{response.body}" + render json: { error: 'Failed to fetch thumbnail' }, status: response.code + end + end end diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 4153624d..8a54ea99 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -782,7 +782,10 @@ export default class extends Controller { this.layerControl = L.control.layers(this.baseMaps(), layerControl).addTo(this.map); } - async fetchAndDisplayPhotos(startDate, endDate) { + async fetchAndDisplayPhotos(startDate, endDate, retryCount = 0) { + const MAX_RETRIES = 3; + const RETRY_DELAY = 3000; // 3 seconds + try { const params = new URLSearchParams({ api_key: this.apiKey, @@ -796,46 +799,57 @@ export default class extends Controller { } 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] - }) - }); + photos.forEach(photo => this.createPhotoMarker(photo)); - // 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'); + + if (retryCount < MAX_RETRIES) { + console.log(`Retrying in ${RETRY_DELAY/1000} seconds... (Attempt ${retryCount + 1}/${MAX_RETRIES})`); + setTimeout(() => { + this.fetchAndDisplayPhotos(startDate, endDate, retryCount + 1); + }, RETRY_DELAY); + } else { + showFlashMessage('error', 'Failed to fetch photos after multiple attempts'); + } } } + + createPhotoMarker(photo) { + if (!photo.exifInfo?.latitude || !photo.exifInfo?.longitude) return; + + const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.apiKey}`; + + const icon = L.divIcon({ + className: 'photo-marker', + html: ``, + iconSize: [48, 48] + }); + + const marker = L.marker( + [photo.exifInfo.latitude, photo.exifInfo.longitude], + { icon } + ); + + const popupContent = ` +
+ ${photo.originalFileName} +

${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); + } } diff --git a/config/routes.rb b/config/routes.rb index a7dd1f79..dc7730b0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -78,6 +78,12 @@ Rails.application.routes.draw do namespace :countries do resources :borders, only: :index end + + resources :photos do + member do + get 'thumbnail', constraints: { id: %r{[^/]+} } + end + end end end end From 3c6f2e5ce392d9523fd9b7d59b4b98d4ad1bd03f Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 26 Nov 2024 17:36:22 +0100 Subject: [PATCH 3/7] Add loading spinner and checkmark --- app/assets/stylesheets/application.css | 35 +++++++++++++++ app/controllers/api/v1/photos_controller.rb | 8 +++- app/javascript/controllers/maps_controller.js | 45 ++++++++++++++++++- app/services/immich/request_photos.rb | 20 +++++++-- 4 files changed, 101 insertions(+), 7 deletions(-) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 08196e09..982d94b0 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -72,3 +72,38 @@ width: 48px; height: 48px; } + +.leaflet-loading-control { + padding: 5px; + border-radius: 4px; + box-shadow: 0 1px 5px rgba(0,0,0,0.2); + margin: 10px; + width: 32px; + height: 32px; + background: white; +} + +.loading-spinner { + display: flex; + align-items: center; + gap: 8px; + font-size: 18px; + color: gray; +} + +.loading-spinner::before { + content: '🔵'; + font-size: 18px; + animation: spinner 1s linear infinite; +} + +.loading-spinner.done::before { + content: '✅'; + animation: none; +} + +@keyframes spinner { + to { + transform: rotate(360deg); + } +} diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb index 93f17208..df042494 100644 --- a/app/controllers/api/v1/photos_controller.rb +++ b/app/controllers/api/v1/photos_controller.rb @@ -3,8 +3,12 @@ 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.reject { |photo| photo['type'].downcase == 'video' } + Immich::RequestPhotos.new( + current_api_user, + start_date: params[:start_date], + end_date: params[:end_date] + ).call.reject { |asset| asset['type'].downcase == 'video' } + end render json: @photos, status: :ok end diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 8a54ea99..70d42258 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -786,6 +786,18 @@ export default class extends Controller { const MAX_RETRIES = 3; const RETRY_DELAY = 3000; // 3 seconds + // Create loading control + const LoadingControl = L.Control.extend({ + onAdd: (map) => { + const container = L.DomUtil.create('div', 'leaflet-loading-control'); + container.innerHTML = '
'; + return container; + } + }); + + const loadingControl = new LoadingControl({ position: 'topleft' }); + this.map.addControl(loadingControl); + try { const params = new URLSearchParams({ api_key: this.apiKey, @@ -801,14 +813,42 @@ export default class extends Controller { const photos = await response.json(); this.photoMarkers.clearLayers(); - photos.forEach(photo => this.createPhotoMarker(photo)); + // Create a promise for each photo to track when it's fully loaded + const photoLoadPromises = photos.map(photo => { + return new Promise((resolve) => { + const img = new Image(); + const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.apiKey}`; + + img.onload = () => { + this.createPhotoMarker(photo); + resolve(); + }; + + img.onerror = () => { + console.error(`Failed to load photo ${photo.id}`); + resolve(); // Resolve anyway to not block other photos + }; + + img.src = thumbnailUrl; + }); + }); + + // Wait for all photos to be loaded and rendered + await Promise.all(photoLoadPromises); if (!this.map.hasLayer(this.photoMarkers)) { this.photoMarkers.addTo(this.map); } + // Show checkmark for 1 second before removing + const loadingSpinner = document.querySelector('.loading-spinner'); + loadingSpinner.classList.add('done'); + + await new Promise(resolve => setTimeout(resolve, 1000)); + } catch (error) { console.error('Error fetching photos:', error); + showFlashMessage('error', 'Failed to fetch photos'); if (retryCount < MAX_RETRIES) { console.log(`Retrying in ${RETRY_DELAY/1000} seconds... (Attempt ${retryCount + 1}/${MAX_RETRIES})`); @@ -818,6 +858,9 @@ export default class extends Controller { } else { showFlashMessage('error', 'Failed to fetch photos after multiple attempts'); } + } finally { + // Remove loading control after the delay + this.map.removeControl(loadingControl); } } diff --git a/app/services/immich/request_photos.rb b/app/services/immich/request_photos.rb index 78be8197..9073ad9e 100644 --- a/app/services/immich/request_photos.rb +++ b/app/services/immich/request_photos.rb @@ -12,7 +12,9 @@ class Immich::RequestPhotos end def call - retrieve_immich_data + data = retrieve_immich_data + + time_framed_data(data) end private @@ -20,11 +22,14 @@ class Immich::RequestPhotos def retrieve_immich_data page = 1 data = [] - max_pages = 100_000 # Prevent infinite loop + max_pages = 10_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) + response = JSON.parse( + HTTParty.post( + immich_api_base_url, headers: headers, body: request_body(page) + ).body + ) items = response.dig('assets', 'items') @@ -58,4 +63,11 @@ class Immich::RequestPhotos body.merge(createdBefore: end_date) end + + def time_framed_data(data) + data.select do |photo| + photo['localDateTime'] >= start_date && + (end_date.nil? || photo['localDateTime'] <= end_date) + end + end end From c5044781b9143e26741fe28fa4709ee8d5602a01 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 26 Nov 2024 18:03:46 +0100 Subject: [PATCH 4/7] Add an active link to the photo in Immich --- .app_version | 2 +- CHANGELOG.md | 8 ++++++++ app/javascript/controllers/maps_controller.js | 20 ++++++++++++++++--- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.app_version b/.app_version index ce62dc55..c5523bd0 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.16.9 +0.17.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2491962e..e040261f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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.17.0 - 2024-11-26 + +### Added + +- If you have provided your Immich instance URL and API key, the map will now show photos from your Immich instance when Photos layer is enabled. +- `GET /api/v1/photos` endpoint added to get photos from Immich. +- `GET /api/v1/photos/:id/thumbnail.jpg` endpoint added to get photo thumbnail from Immich. + # 0.16.9 - 2024-11-24 ### Changed diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 70d42258..1f21e465 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -880,11 +880,25 @@ export default class extends Controller { { icon } ); + 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)); + const immich_photo_link = `${this.userSettings.immich_url}/search?query=${encodedQuery}`; const popupContent = `
- ${photo.originalFileName} + + ${photo.originalFileName} +

${photo.originalFileName}

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

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

From 7b160bfe2d735edc1d7398181cbb41add25bfa95 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 26 Nov 2024 20:18:08 +0100 Subject: [PATCH 5/7] Add specs for swagger --- app/controllers/api/v1/photos_controller.rb | 1 - app/services/immich/import_geodata.rb | 5 +- app/services/immich/request_photos.rb | 3 + config/routes.rb | 2 +- spec/factories/users.rb | 9 + spec/requests/api/v1/photos_spec.rb | 45 ++- spec/swagger/api/v1/photos_controller_spec.rb | 249 +++++++++++++++ swagger/v1/swagger.yaml | 288 ++++++++++++++++++ 8 files changed, 590 insertions(+), 12 deletions(-) create mode 100644 spec/swagger/api/v1/photos_controller_spec.rb diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb index df042494..88baf2d7 100644 --- a/app/controllers/api/v1/photos_controller.rb +++ b/app/controllers/api/v1/photos_controller.rb @@ -32,7 +32,6 @@ class Api::V1::PhotosController < ApiController status: :ok ) else - Rails.logger.error "Failed to fetch thumbnail: #{response.code} - #{response.body}" render json: { error: 'Failed to fetch thumbnail' }, status: response.code end end diff --git a/app/services/immich/import_geodata.rb b/app/services/immich/import_geodata.rb index 38c34c58..766643a7 100644 --- a/app/services/immich/import_geodata.rb +++ b/app/services/immich/import_geodata.rb @@ -3,16 +3,13 @@ class Immich::ImportGeodata attr_reader :user, :start_date, :end_date - def initialize(user, end_date:, start_date: '1970-01-01') + def initialize(user, start_date: '1970-01-01', end_date: nil) @user = user @start_date = start_date @end_date = end_date end def call - raise ArgumentError, 'Immich API key is missing' if immich_api_key.blank? - raise ArgumentError, 'Immich URL is missing' if user.settings['immich_url'].blank? - immich_data = retrieve_immich_data log_no_data and return if immich_data.empty? diff --git a/app/services/immich/request_photos.rb b/app/services/immich/request_photos.rb index 9073ad9e..f553207c 100644 --- a/app/services/immich/request_photos.rb +++ b/app/services/immich/request_photos.rb @@ -12,6 +12,9 @@ class Immich::RequestPhotos end def call + raise ArgumentError, 'Immich API key is missing' if immich_api_key.blank? + raise ArgumentError, 'Immich URL is missing' if user.settings['immich_url'].blank? + data = retrieve_immich_data time_framed_data(data) diff --git a/config/routes.rb b/config/routes.rb index dc7730b0..430ba885 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -79,7 +79,7 @@ Rails.application.routes.draw do resources :borders, only: :index end - resources :photos do + resources :photos, only: %i[index] do member do get 'thumbnail', constraints: { id: %r{[^/]+} } end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 41a5035d..2d4e654f 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -22,5 +22,14 @@ FactoryBot.define do trait :admin do admin { true } end + + trait :with_immich_credentials do + settings do + { + immich_url: 'https://immich.example.com', + immich_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 dc45702c..e3f3d32c 100644 --- a/spec/requests/api/v1/photos_spec.rb +++ b/spec/requests/api/v1/photos_spec.rb @@ -1,11 +1,44 @@ +# frozen_string_literal: true + 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) +RSpec.describe 'Api::V1::Photos', type: :request do + describe 'GET /index' do + let(:user) { create(:user) } + + let(:photo_data) do + [ + { + 'id' => '123', + 'latitude' => 35.6762, + 'longitude' => 139.6503, + 'createdAt' => '2024-01-01T00:00:00.000Z', + 'type' => 'photo' + }, + { + 'id' => '456', + 'latitude' => 40.7128, + 'longitude' => -74.0060, + 'createdAt' => '2024-01-02T00:00:00.000Z', + 'type' => 'photo' + } + ] + end + + context 'when the request is successful' do + before do + allow_any_instance_of(Immich::RequestPhotos).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 - end diff --git a/spec/swagger/api/v1/photos_controller_spec.rb b/spec/swagger/api/v1/photos_controller_spec.rb new file mode 100644 index 00000000..eb3cb737 --- /dev/null +++ b/spec/swagger/api/v1/photos_controller_spec.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'Api::V1::PhotosController', type: :request do + let(:user) { create(:user, :with_immich_credentials) } + let(:api_key) { user.api_key } + let(:start_date) { '2024-01-01' } + let(:end_date) { '2024-01-02' } + let!(:immich_image) 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": '2024-01-01T09: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 + let(:immich_data) do + { + "albums": { + "total": 0, + "count": 0, + "items": [], + "facets": [] + }, + "assets": { + "total": 1000, + "count": 1000, + "items": [immich_image] + } + }.to_json + end + + before do + stub_request(:post, "#{user.settings['immich_url']}/api/search/metadata") + .to_return(status: 200, body: immich_data) + + stub_request(:get, "#{user.settings['immich_url']}/api/assets/7fe486e3-c3ba-4b54-bbf9-1281b39ed15c/thumbnail?size=preview") + .to_return(status: 200, body: immich_image.to_json, headers: {}) + + stub_request(:get, "#{user.settings['immich_url']}/api/assets/nonexistent/thumbnail?size=preview") + .to_return(status: 404, body: [].to_json, headers: {}) + end + + path '/api/v1/photos' do + get 'Lists photos' do + tags 'Photos' + produces 'application/json' + parameter name: :api_key, in: :query, type: :string, required: true + parameter name: :start_date, in: :query, type: :string, required: true, + description: 'Start date in ISO8601 format, e.g. 2024-01-01' + parameter name: :end_date, in: :query, type: :string, required: true, + description: 'End date in ISO8601 format, e.g. 2024-01-02' + + response '200', 'photos found' do + schema type: :array, + items: { + 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' }, + 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 } + }, + required: %w[id deviceAssetId ownerId type originalPath + originalFileName originalMimeType thumbhash + fileCreatedAt fileModifiedAt localDateTime + updatedAt isFavorite isArchived isTrashed duration + exifInfo checksum isOffline hasMetadata duplicateId resized] + } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data).to be_an(Array) + end + end + end + end + + path '/api/v1/photos/{id}/thumbnail' do + get 'Retrieves a photo' do + tags 'Photos' + produces 'application/json' + parameter name: :id, in: :path, type: :string, required: true + parameter name: :api_key, 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' }, + 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 } + } + + let(:id) { '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c' } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data).to be_a(Hash) + expect(data['id']).to eq(id) + end + end + + response '404', 'photo not found' do + let(:id) { 'nonexistent' } + let(:api_key) { user.api_key } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['error']).to eq('Failed to fetch thumbnail') + end + end + end + end +end diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 77a58f8d..3ecfb855 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -312,6 +312,294 @@ paths: isorcv: '2024-02-03T13:00:03Z' isotst: '2024-02-03T13:00:03Z' disptst: '2024-02-03 13:00:03' + "/api/v1/photos": + get: + summary: Lists photos + tags: + - Photos + parameters: + - name: api_key + in: query + required: true + schema: + type: string + - name: start_date + in: query + required: true + description: Start date in ISO8601 format, e.g. 2024-01-01 + schema: + type: string + - name: end_date + in: query + required: true + description: End date in ISO8601 format, e.g. 2024-01-02 + schema: + type: string + responses: + '200': + description: photos found + content: + application/json: + schema: + type: array + items: + 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 + 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: + - string + - 'null' + rating: + type: + - integer + - 'null' + checksum: + type: string + isOffline: + type: boolean + hasMetadata: + type: boolean + duplicateId: + type: string + resized: + type: boolean + required: + - id + - deviceAssetId + - ownerId + - type + - originalPath + - originalFileName + - originalMimeType + - thumbhash + - fileCreatedAt + - fileModifiedAt + - localDateTime + - updatedAt + - isFavorite + - isArchived + - isTrashed + - duration + - exifInfo + - checksum + - isOffline + - hasMetadata + - duplicateId + - resized + "/api/v1/photos/{id}/thumbnail": + get: + summary: Retrieves a photo + tags: + - Photos + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: api_key + in: query + required: true + schema: + type: string + responses: + '200': + description: photo found + content: + application/json: + 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 + 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: + - string + - 'null' + rating: + type: + - integer + - 'null' + checksum: + type: string + isOffline: + type: boolean + hasMetadata: + type: boolean + duplicateId: + type: string + resized: + type: boolean + '404': + description: photo not found "/api/v1/points": get: summary: Retrieves all points From 5ed9b3518b71fb42ff4ed2e54b46b79315b52e5f Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 26 Nov 2024 20:27:16 +0100 Subject: [PATCH 6/7] Add hover reaction to photos in the map --- app/javascript/controllers/maps_controller.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 1f21e465..97b0a134 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -894,9 +894,11 @@ export default class extends Controller { const immich_photo_link = `${this.userSettings.immich_url}/search?query=${encodedQuery}`; const popupContent = `
- + ${photo.originalFileName}

${photo.originalFileName}

From 3ee5654673ed41a9bca771968de30a585595f8b2 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Tue, 26 Nov 2024 20:52:07 +0100 Subject: [PATCH 7/7] Update changelog --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e040261f..d872effe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](http://semver.org/). # 0.17.0 - 2024-11-26 +## The Immich Photos release + +With this release, Dawarich can now show photos from your Immich instance on the map. + +To enable this feature, you need to provide your Immich instance URL and API key in the Settings page. Then you need to enable "Photos" layer on the map (top right corner). + +An important note to add here is that photos are heavy and hence generate a lot of traffic. The response from Immich for specific dates is being cached in Redis for 1 day, and that may lead to Redis taking a lot more space than previously. But since the cache is being expired after 24 hours, you'll get your space back pretty soon. + +The other thing worth mentioning is how Dawarich gets data from Immich. It goes like this: + +1. When you click on the "Photos" layer, Dawarich will make a request to `GET /api/v1/photos` endpoint to get photos for the selected timeframe. +2. This endpoint will make a request to `POST /search/metadata` endpoint of your Immich instance to get photos for the selected timeframe. +3. The response from Immich is being cached in Redis for 1 day. +4. Dawarich's frontend will make a request to `GET /api/v1/photos/:id/thumbnail.jpg` endpoint to get photo thumbnail from Immich. The number of requests to this endpoint will depend on how many photos you have in the selected timeframe. +5. For each photo, Dawarich's frontend will make a request to `GET /api/v1/photos/:id/thumbnail.jpg` endpoint to get photo thumbnail from Immich. This thumbnail request is also cached in Redis for 1 day. + + ### Added - If you have provided your Immich instance URL and API key, the map will now show photos from your Immich instance when Photos layer is enabled.