mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Move Immich API request to a separate service & add photos api controller
This commit is contained in:
parent
61df85bb07
commit
130630b997
9 changed files with 331 additions and 131 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
11
app/controllers/api/v1/photos_controller.rb
Normal file
11
app/controllers/api/v1/photos_controller.rb
Normal 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
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
61
app/services/immich/request_photos.rb
Normal file
61
app/services/immich/request_photos.rb
Normal 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
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
11
spec/requests/api/v1/photos_spec.rb
Normal file
11
spec/requests/api/v1/photos_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
111
spec/services/immich/request_photos_spec.rb
Normal file
111
spec/services/immich/request_photos_spec.rb
Normal 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
|
||||
Loading…
Reference in a new issue