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