Move Immich API request to a separate service & add photos api controller

This commit is contained in:
Eugene Burmakin 2024-11-26 14:46:26 +01:00
parent 61df85bb07
commit 130630b997
9 changed files with 331 additions and 131 deletions

View file

@ -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;
}

View file

@ -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

View file

@ -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: `<div class="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
<span class="text-white text-xs">📷</span>
</div>`,
iconSize: [24, 24]
})
});
// Add popup with photo information
const popupContent = `
<div class="max-w-xs">
<h3 class="font-bold">${photo.originalFileName}</h3>
<p>Taken: ${new Date(photo.localDateTime).toLocaleString()}</p>
<p>Location: ${photo.exifInfo.city}, ${photo.exifInfo.state}, ${photo.exifInfo.country}</p>
${photo.type === 'VIDEO' ? '🎥 Video' : '📷 Photo'}
</div>
`;
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');
}
}
}

View file

@ -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)

View file

@ -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

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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