Merge pull request #445 from Freika/feature/immich-photos-integration

Add immich photos to the map
This commit is contained in:
Evgenii Burmakin 2024-11-26 20:57:01 +01:00 committed by GitHub
commit 45cbaf4b7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1099 additions and 135 deletions

View file

@ -1 +1 @@
0.16.9
0.17.0

View file

@ -5,6 +5,31 @@ 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
## 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.
- `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

View file

@ -57,3 +57,53 @@
.leaflet-settings-panel button:hover {
background-color: #0056b3;
}
.photo-marker {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 50%;
}
.photo-marker img {
border-radius: 50%;
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);
}
}

View file

@ -0,0 +1,38 @@
# 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.reject { |asset| asset['type'].downcase == 'video' }
end
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
render json: { error: 'Failed to fetch thumbnail' }, status: response.code
end
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,134 @@ 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, retryCount = 0) {
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 = '<div class="loading-spinner"></div>';
return container;
}
});
const loadingControl = new LoadingControl({ position: 'topleft' });
this.map.addControl(loadingControl);
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();
this.photoMarkers.clearLayers();
// 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})`);
setTimeout(() => {
this.fetchAndDisplayPhotos(startDate, endDate, retryCount + 1);
}, RETRY_DELAY);
} else {
showFlashMessage('error', 'Failed to fetch photos after multiple attempts');
}
} finally {
// Remove loading control after the delay
this.map.removeControl(loadingControl);
}
}
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: `<img src="${thumbnailUrl}" style="width: 48px; height: 48px;">`,
iconSize: [48, 48]
});
const marker = L.marker(
[photo.exifInfo.latitude, photo.exifInfo.longitude],
{ 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 = `
<div class="max-w-xs">
<a href="${immich_photo_link}" target="_blank" onmouseover="this.firstElementChild.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.3)';"
onmouseout="this.firstElementChild.style.boxShadow = '';">
<img src="${thumbnailUrl}"
class="w-8 h-8 mb-2 rounded"
style="transition: box-shadow 0.3s ease;"
alt="${photo.originalFileName}">
</a>
<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);
}
}

View file

@ -1,24 +1,19 @@
# 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, 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
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?
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 +22,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 +57,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,76 @@
# 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
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)
end
private
def retrieve_immich_data
page = 1
data = []
max_pages = 10_000 # Prevent infinite loop
while page <= max_pages
response = JSON.parse(
HTTParty.post(
immich_api_base_url, headers: headers, body: request_body(page)
).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
def time_framed_data(data)
data.select do |photo|
photo['localDateTime'] >= start_date &&
(end_date.nil? || photo['localDateTime'] <= end_date)
end
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'
@ -77,6 +78,12 @@ Rails.application.routes.draw do
namespace :countries do
resources :borders, only: :index
end
resources :photos, only: %i[index] do
member do
get 'thumbnail', constraints: { id: %r{[^/]+} }
end
end
end
end
end

View file

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

View file

@ -0,0 +1,44 @@
# frozen_string_literal: true
require 'rails_helper'
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

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

View file

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

View file

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