mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Merge pull request #445 from Freika/feature/immich-photos-integration
Add immich photos to the map
This commit is contained in:
commit
45cbaf4b7e
14 changed files with 1099 additions and 135 deletions
|
|
@ -1 +1 @@
|
|||
0.16.9
|
||||
0.17.0
|
||||
|
|
|
|||
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
38
app/controllers/api/v1/photos_controller.rb
Normal file
38
app/controllers/api/v1/photos_controller.rb
Normal 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
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
76
app/services/immich/request_photos.rb
Normal file
76
app/services/immich/request_photos.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
44
spec/requests/api/v1/photos_spec.rb
Normal file
44
spec/requests/api/v1/photos_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
249
spec/swagger/api/v1/photos_controller_spec.rb
Normal file
249
spec/swagger/api/v1/photos_controller_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue