Merge pull request #475 from Freika/feature/photoprism-integration

Photoprism integration
This commit is contained in:
Evgenii Burmakin 2024-12-04 14:05:26 +01:00 committed by GitHub
commit c2605ed805
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1649 additions and 408 deletions

View file

@ -1 +1 @@
0.18.2 0.19.0

View file

@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/) The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/). and this project adheres to [Semantic Versioning](http://semver.org/).
# 0.19.0 - 2024-12-03
## The Photoprism integration release
⚠️ This release introduces a breaking change. ⚠️
The `GET /api/v1/photos` endpoint now returns following structure of the response:
```json
[
{
"id": "1",
"latitude": 11.22,
"longitude": 12.33,
"localDateTime": "2024-01-01T00:00:00Z",
"originalFileName": "photo.jpg",
"city": "Berlin",
"state": "Berlin",
"country": "Germany",
"type": "image", // "image" or "video"
"source": "photoprism" // "photoprism" or "immich"
}
]
```
### Added
- Photos from Photoprism are now can be shown on the map. To enable this feature, you need to provide your Photoprism instance URL and API key in the Settings page. Then you need to enable "Photos" layer on the map (top right corner).
- Geodata is now can be imported from Photoprism to Dawarich. The "Import Photoprism data" button on the Imports page will start the import process.
# 0.18.2 - 2024-11-29 # 0.18.2 - 2024-11-29
### Added ### Added

View file

@ -99,6 +99,10 @@ Simply install one of the supported apps on your device and configure it to send
### 📊 Statistics ### 📊 Statistics
- Analyze your travel history: number of countries/cities visited, distance traveled, and time spent, broken down by year and month. - Analyze your travel history: number of countries/cities visited, distance traveled, and time spent, broken down by year and month.
### 📸 Integrations
- Provide credentials for Immich or Photoprism (or both!) and Dawarich will automatically import geodata from your photos.
- You'll also be able to visualize your photos on the map!
### 📥 Import Your Data ### 📥 Import Your Data
- Import from various sources: - Import from various sources:
- Google Maps Timeline - Google Maps Timeline

View file

@ -1,38 +1,52 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::PhotosController < ApiController class Api::V1::PhotosController < ApiController
before_action :check_integration_configured, only: %i[index thumbnail]
before_action :check_source, only: %i[thumbnail]
def index def index
@photos = Rails.cache.fetch("photos_#{params[:start_date]}_#{params[:end_date]}", expires_in: 1.day) do @photos = Rails.cache.fetch("photos_#{params[:start_date]}_#{params[:end_date]}", expires_in: 1.day) do
Immich::RequestPhotos.new( Photos::Search.new(current_api_user, start_date: params[:start_date], end_date: params[:end_date]).call
current_api_user,
start_date: params[:start_date],
end_date: params[:end_date]
).call.reject { |asset| asset['type'].downcase == 'video' }
end end
render json: @photos, status: :ok render json: @photos, status: :ok
end end
def thumbnail def thumbnail
response = Rails.cache.fetch("photo_thumbnail_#{params[:id]}", expires_in: 1.day) do response = fetch_cached_thumbnail(params[:source])
HTTParty.get( handle_thumbnail_response(response)
"#{current_api_user.settings['immich_url']}/api/assets/#{params[:id]}/thumbnail?size=preview", end
headers: {
'x-api-key' => current_api_user.settings['immich_api_key'],
'accept' => 'application/octet-stream'
}
)
end
private
def fetch_cached_thumbnail(source)
Rails.cache.fetch("photo_thumbnail_#{params[:id]}", expires_in: 1.day) do
Photos::Thumbnail.new(current_api_user, source, params[:id]).call
end
end
def handle_thumbnail_response(response)
if response.success? if response.success?
send_data( send_data(response.body, type: 'image/jpeg', disposition: 'inline', status: :ok)
response.body,
type: 'image/jpeg',
disposition: 'inline',
status: :ok
)
else else
render json: { error: 'Failed to fetch thumbnail' }, status: response.code render json: { error: 'Failed to fetch thumbnail' }, status: response.code
end end
end end
def integration_configured?
current_api_user.immich_integration_configured? || current_api_user.photoprism_integration_configured?
end
def check_integration_configured
unauthorized_integration unless integration_configured?
end
def check_source
unauthorized_integration unless params[:source] == 'immich' || params[:source] == 'photoprism'
end
def unauthorized_integration
render json: { error: "#{params[:source]&.capitalize} integration not configured" },
status: :unauthorized
end
end end

View file

@ -12,16 +12,11 @@ class Api::V1::SettingsController < ApiController
settings_params.each { |key, value| current_api_user.settings[key] = value } settings_params.each { |key, value| current_api_user.settings[key] = value }
if current_api_user.save if current_api_user.save
render json: { render json: { message: 'Settings updated', settings: current_api_user.settings, status: 'success' },
message: 'Settings updated', status: :ok
settings: current_api_user.settings,
status: 'success'
}, status: :ok
else else
render json: { render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages },
message: 'Something went wrong', status: :unprocessable_entity
errors: current_api_user.errors.full_messages
}, status: :unprocessable_entity
end end
end end
@ -31,7 +26,8 @@ class Api::V1::SettingsController < ApiController
params.require(:settings).permit( params.require(:settings).permit(
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters, :meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity, :time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
:preferred_map_layer, :points_rendering_mode, :live_map_enabled :preferred_map_layer, :points_rendering_mode, :live_map_enabled,
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key
) )
end end
end end

View file

@ -31,7 +31,7 @@ class SettingsController < ApplicationController
params.require(:settings).permit( params.require(:settings).permit(
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters, :meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity, :time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
:immich_url, :immich_api_key :immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key
) )
end end
end end

View file

@ -137,10 +137,13 @@ export default class extends Controller {
this.map.addControl(this.drawControl); this.map.addControl(this.drawControl);
} }
if (e.name === 'Photos') { if (e.name === 'Photos') {
if (!this.userSettings.immich_url || !this.userSettings.immich_api_key) { if (
(!this.userSettings.immich_url || !this.userSettings.immich_api_key) &&
(!this.userSettings.photoprism_url || !this.userSettings.photoprism_api_key)
) {
showFlashMessage( showFlashMessage(
'error', 'error',
'Immich integration is not configured. Please check your settings.' 'Photos integration is not configured. Please check your integrations settings.'
); );
return; return;
} }
@ -836,7 +839,7 @@ export default class extends Controller {
<h3 class="font-bold">${photo.originalFileName}</h3> <h3 class="font-bold">${photo.originalFileName}</h3>
<p>Taken: ${new Date(photo.localDateTime).toLocaleString()}</p> <p>Taken: ${new Date(photo.localDateTime).toLocaleString()}</p>
<p>Location: ${photo.exifInfo.city}, ${photo.exifInfo.state}, ${photo.exifInfo.country}</p> <p>Location: ${photo.exifInfo.city}, ${photo.exifInfo.state}, ${photo.exifInfo.country}</p>
${photo.type === 'VIDEO' ? '🎥 Video' : '📷 Photo'} ${photo.type === 'video' ? '🎥 Video' : '📷 Photo'}
</div> </div>
`; `;
marker.bindPopup(popupContent); marker.bindPopup(popupContent);

View file

@ -80,10 +80,10 @@ export default class extends Controller {
this.map.on('overlayadd', (e) => { this.map.on('overlayadd', (e) => {
if (e.name !== 'Photos') return; if (e.name !== 'Photos') return;
if (!this.userSettings.immich_url || !this.userSettings.immich_api_key) { if ((!this.userSettings.immich_url || !this.userSettings.immich_api_key) && (!this.userSettings.photoprism_url || !this.userSettings.photoprism_api_key)) {
showFlashMessage( showFlashMessage(
'error', 'error',
'Immich integration is not configured. Please check your settings.' 'Photos integration is not configured. Please check your integrations settings.'
); );
return; return;
} }

View file

@ -162,7 +162,7 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa
const response = await fetch(`/api/v1/photos?${params}`); const response = await fetch(`/api/v1/photos?${params}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}, response: ${response.body}`);
} }
const photos = await response.json(); const photos = await response.json();
@ -171,10 +171,10 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa
const photoLoadPromises = photos.map(photo => { const photoLoadPromises = photos.map(photo => {
return new Promise((resolve) => { return new Promise((resolve) => {
const img = new Image(); const img = new Image();
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}`; const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}&source=${photo.source}`;
img.onload = () => { img.onload = () => {
createPhotoMarker(photo, userSettings.immich_url, photoMarkers, apiKey); createPhotoMarker(photo, userSettings, photoMarkers, apiKey);
resolve(); resolve();
}; };
@ -216,11 +216,44 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa
} }
} }
function getPhotoLink(photo, userSettings) {
switch (photo.source) {
case 'immich':
const startOfDay = new Date(photo.localDateTime);
startOfDay.setHours(0, 0, 0, 0);
export function createPhotoMarker(photo, immichUrl, photoMarkers,apiKey) { const endOfDay = new Date(photo.localDateTime);
if (!photo.exifInfo?.latitude || !photo.exifInfo?.longitude) return; endOfDay.setHours(23, 59, 59, 999);
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}`; const queryParams = {
takenAfter: startOfDay.toISOString(),
takenBefore: endOfDay.toISOString()
};
const encodedQuery = encodeURIComponent(JSON.stringify(queryParams));
return `${userSettings.immich_url}/search?query=${encodedQuery}`;
case 'photoprism':
return `${userSettings.photoprism_url}/library/browse?view=cards&year=${photo.localDateTime.split('-')[0]}&month=${photo.localDateTime.split('-')[1]}&order=newest&public=true&quality=3`;
default:
return '#'; // Default or error case
}
}
function getSourceUrl(photo, userSettings) {
switch (photo.source) {
case 'photoprism':
return userSettings.photoprism_url;
case 'immich':
return userSettings.immich_url;
default:
return '#'; // Default or error case
}
}
export function createPhotoMarker(photo, userSettings, photoMarkers, apiKey) {
if (!photo.latitude || !photo.longitude) return;
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}&source=${photo.source}`;
const icon = L.divIcon({ const icon = L.divIcon({
className: 'photo-marker', className: 'photo-marker',
@ -229,25 +262,16 @@ export function createPhotoMarker(photo, immichUrl, photoMarkers,apiKey) {
}); });
const marker = L.marker( const marker = L.marker(
[photo.exifInfo.latitude, photo.exifInfo.longitude], [photo.latitude, photo.longitude],
{ icon } { icon }
); );
const startOfDay = new Date(photo.localDateTime); const photo_link = getPhotoLink(photo, userSettings);
startOfDay.setHours(0, 0, 0, 0); const source_url = getSourceUrl(photo, userSettings);
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 = `${immichUrl}/search?query=${encodedQuery}`;
const popupContent = ` const popupContent = `
<div class="max-w-xs"> <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)';" <a href="${photo_link}" target="_blank" onmouseover="this.firstElementChild.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.3)';"
onmouseout="this.firstElementChild.style.boxShadow = '';"> onmouseout="this.firstElementChild.style.boxShadow = '';">
<img src="${thumbnailUrl}" <img src="${thumbnailUrl}"
class="mb-2 rounded" class="mb-2 rounded"
@ -256,7 +280,8 @@ export function createPhotoMarker(photo, immichUrl, photoMarkers,apiKey) {
</a> </a>
<h3 class="font-bold">${photo.originalFileName}</h3> <h3 class="font-bold">${photo.originalFileName}</h3>
<p>Taken: ${new Date(photo.localDateTime).toLocaleString()}</p> <p>Taken: ${new Date(photo.localDateTime).toLocaleString()}</p>
<p>Location: ${photo.exifInfo.city}, ${photo.exifInfo.state}, ${photo.exifInfo.country}</p> <p>Location: ${photo.city}, ${photo.state}, ${photo.country}</p>
<p>Source: <a href="${source_url}" target="_blank">${photo.source}</a></p>
${photo.type === 'VIDEO' ? '🎥 Video' : '📷 Photo'} ${photo.type === 'VIDEO' ? '🎥 Video' : '📷 Photo'}
</div> </div>
`; `;

View file

@ -7,6 +7,8 @@ class EnqueueBackgroundJob < ApplicationJob
case job_name case job_name
when 'start_immich_import' when 'start_immich_import'
Import::ImmichGeodataJob.perform_later(user_id) Import::ImmichGeodataJob.perform_later(user_id)
when 'start_photoprism_import'
Import::PhotoprismGeodataJob.perform_later(user_id)
when 'start_reverse_geocoding', 'continue_reverse_geocoding' when 'start_reverse_geocoding', 'continue_reverse_geocoding'
Jobs::Create.new(job_name, user_id).call Jobs::Create.new(job_name, user_id).call
else else

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
class Import::PhotoprismGeodataJob < ApplicationJob
queue_as :imports
sidekiq_options retry: false
def perform(user_id)
user = User.find(user_id)
Photoprism::ImportGeodata.new(user).call
end
end

View file

@ -10,7 +10,7 @@ class Import < ApplicationRecord
enum :source, { enum :source, {
google_semantic_history: 0, owntracks: 1, google_records: 2, google_semantic_history: 0, owntracks: 1, google_records: 2,
google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6 google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6, photoprism_api: 7
} }
def process! def process!

View file

@ -18,6 +18,7 @@ class User < ApplicationRecord
has_many :trips, dependent: :destroy has_many :trips, dependent: :destroy
after_create :create_api_key after_create :create_api_key
before_save :strip_trailing_slashes
def countries_visited def countries_visited
stats.pluck(:toponyms).flatten.map { _1['country'] }.uniq.compact stats.pluck(:toponyms).flatten.map { _1['country'] }.uniq.compact
@ -53,6 +54,14 @@ class User < ApplicationRecord
tracked_points.select(:id).where.not(geodata: {}).count tracked_points.select(:id).where.not(geodata: {}).count
end end
def immich_integration_configured?
settings['immich_url'].present? && settings['immich_api_key'].present?
end
def photoprism_integration_configured?
settings['photoprism_url'].present? && settings['photoprism_api_key'].present?
end
private private
def create_api_key def create_api_key
@ -60,4 +69,9 @@ class User < ApplicationRecord
save save
end end
def strip_trailing_slashes
settings['immich_url']&.gsub!(%r{/+\z}, '')
settings['photoprism_url']&.gsub!(%r{/+\z}, '')
end
end end

View file

@ -0,0 +1,63 @@
# frozen_string_literal: true
class Api::PhotoSerializer
def initialize(photo, source)
@photo = photo.with_indifferent_access
@source = source
end
def call
{
id: id,
latitude: latitude,
longitude: longitude,
localDateTime: local_date_time,
originalFileName: original_file_name,
city: city,
state: state,
country: country,
type: type,
source: source
}
end
private
attr_reader :photo, :source
def id
photo['id'] || photo['Hash']
end
def latitude
photo.dig('exifInfo', 'latitude') || photo['Lat']
end
def longitude
photo.dig('exifInfo', 'longitude') || photo['Lng']
end
def local_date_time
photo['localDateTime'] || photo['TakenAtLocal']
end
def original_file_name
photo['originalFileName'] || photo['OriginalName']
end
def city
photo.dig('exifInfo', 'city') || photo['PlaceCity']
end
def state
photo.dig('exifInfo', 'state') || photo['PlaceState']
end
def country
photo.dig('exifInfo', 'country') || photo['PlaceCountry']
end
def type
(photo['type'] || photo['Type']).downcase
end
end

View file

@ -57,7 +57,7 @@ class Immich::ImportGeodata
end end
def log_no_data def log_no_data
Rails.logger.info 'No data found' Rails.logger.info 'No geodata found for Immich'
end end
def create_import_failed_notification(import_name) def create_import_failed_notification(import_name)

View file

@ -5,7 +5,7 @@ class Immich::RequestPhotos
def initialize(user, start_date: '1970-01-01', end_date: nil) def initialize(user, start_date: '1970-01-01', end_date: nil)
@user = user @user = user
@immich_api_base_url = "#{user.settings['immich_url']}/api/search/metadata" @immich_api_base_url = URI.parse("#{user.settings['immich_url']}/api/search/metadata")
@immich_api_key = user.settings['immich_api_key'] @immich_api_key = user.settings['immich_api_key']
@start_date = start_date @start_date = start_date
@end_date = end_date @end_date = end_date

View file

@ -24,12 +24,12 @@ class Imports::Create
def parser(source) def parser(source)
# Bad classes naming by the way, they are not parsers, they are point creators # Bad classes naming by the way, they are not parsers, they are point creators
case source case source
when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser
when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser
when 'owntracks' then OwnTracks::ExportParser when 'owntracks' then OwnTracks::ExportParser
when 'gpx' then Gpx::TrackParser when 'gpx' then Gpx::TrackParser
when 'immich_api' then Immich::ImportParser when 'geojson' then Geojson::ImportParser
when 'geojson' then Geojson::ImportParser when 'immich_api', 'photoprism_api' then Photos::ImportParser
end end
end end

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
class Photoprism::CachePreviewToken
attr_reader :user, :preview_token
TOKEN_CACHE_KEY = 'dawarich/photoprism_preview_token'
def initialize(user, preview_token)
@user = user
@preview_token = preview_token
end
def call
Rails.cache.write("#{TOKEN_CACHE_KEY}_#{user.id}", preview_token)
end
end

View file

@ -0,0 +1,86 @@
# frozen_string_literal: true
class Photoprism::ImportGeodata
attr_reader :user, :start_date, :end_date
def initialize(user, start_date: '1970-01-01', end_date: nil)
@user = user
@start_date = start_date
@end_date = end_date
end
def call
photoprism_data = retrieve_photoprism_data
return log_no_data if photoprism_data.empty?
json_data = parse_photoprism_data(photoprism_data)
create_and_process_import(json_data)
end
private
def create_and_process_import(json_data)
import = find_or_create_import(json_data)
return create_import_failed_notification(import.name) unless import.new_record?
import.update!(raw_data: json_data)
ImportJob.perform_later(user.id, import.id)
end
def find_or_create_import(json_data)
user.imports.find_or_initialize_by(
name: file_name(json_data),
source: :photoprism_api
)
end
def retrieve_photoprism_data
Photoprism::RequestPhotos.new(user, start_date:, end_date:).call
end
def parse_photoprism_data(photoprism_data)
geodata = photoprism_data.map do |asset|
next unless valid?(asset)
extract_geodata(asset)
end
geodata.compact.sort_by { |data| data[:timestamp] }
end
def valid?(asset)
asset['Lat'] &&
asset['Lat'] != 0 &&
asset['Lng'] &&
asset['Lng'] != 0 &&
asset['TakenAt']
end
def extract_geodata(asset)
{
latitude: asset['Lat'],
longitude: asset['Lng'],
timestamp: Time.zone.parse(asset['TakenAt']).to_i
}
end
def log_no_data
Rails.logger.info 'No geodata found for Photoprism'
end
def create_import_failed_notification(import_name)
Notifications::Create.new(
user:,
kind: :info,
title: 'Import was not created',
content: "Import with the same name (#{import_name}) already exists. If you want to proceed, delete the existing import and try again."
).call
end
def file_name(photoprism_data_json)
from = Time.zone.at(photoprism_data_json.first[:timestamp]).to_date
to = Time.zone.at(photoprism_data_json.last[:timestamp]).to_date
"photoprism-geodata-#{user.email}-from-#{from}-to-#{to}.json"
end
end

View file

@ -0,0 +1,101 @@
# frozen_string_literal: true
# This integration built based on
# [September 15, 2024](https://github.com/photoprism/photoprism/releases/tag/240915-e1280b2fb)
# release of Photoprism.
class Photoprism::RequestPhotos
attr_reader :user, :photoprism_api_base_url, :photoprism_api_key, :start_date, :end_date
def initialize(user, start_date: '1970-01-01', end_date: nil)
@user = user
@photoprism_api_base_url = URI.parse("#{user.settings['photoprism_url']}/api/v1/photos")
@photoprism_api_key = user.settings['photoprism_api_key']
@start_date = start_date
@end_date = end_date
end
def call
raise ArgumentError, 'Photoprism URL is missing' if user.settings['photoprism_url'].blank?
raise ArgumentError, 'Photoprism API key is missing' if photoprism_api_key.blank?
data = retrieve_photoprism_data
return [] if data.blank? || data[0]['error'].present?
time_framed_data(data, start_date, end_date)
end
private
def retrieve_photoprism_data
data = []
offset = 0
while offset < 1_000_000
response_data = fetch_page(offset)
break if response_data.blank? || (response_data.is_a?(Hash) && response_data.try(:[], 'error').present?)
data << response_data
offset += 1000
end
data.flatten
end
def fetch_page(offset)
response = HTTParty.get(
photoprism_api_base_url,
headers: headers,
query: request_params(offset)
)
if response.code != 200
Rails.logger.error "Photoprism API returned #{response.code}: #{response.body}"
Rails.logger.debug "Photoprism API request params: #{request_params(offset).inspect}"
end
cache_preview_token(response.headers)
JSON.parse(response.body)
end
def headers
{
'Authorization' => "Bearer #{photoprism_api_key}",
'accept' => 'application/json'
}
end
def request_params(offset = 0)
params = offset.zero? ? default_params : default_params.merge(offset: offset)
params[:before] = end_date if end_date.present?
params
end
def default_params
{
q: '',
public: true,
quality: 3,
after: start_date,
count: 1000
}
end
def time_framed_data(data, start_date, end_date)
data.flatten.select do |photo|
taken_at = DateTime.parse(photo['TakenAtLocal'])
end_date ||= Time.current
taken_at.between?(start_date.to_datetime, end_date.to_datetime)
end
end
def cache_preview_token(headers)
preview_token = headers['X-Preview-Token']
Photoprism::CachePreviewToken.new(user, preview_token).call
end
end

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Immich::ImportParser class Photos::ImportParser
include Imports::Broadcaster include Imports::Broadcaster
attr_reader :import, :json, :user_id attr_reader :import, :json, :user_id

View file

@ -0,0 +1,45 @@
# frozen_string_literal: true
class Photos::Search
attr_reader :user, :start_date, :end_date
def initialize(user, start_date: '1970-01-01', end_date: nil)
@user = user
@start_date = start_date
@end_date = end_date
end
def call
photos = []
photos << request_immich if user.immich_integration_configured?
photos << request_photoprism if user.photoprism_integration_configured?
photos.flatten.map { |photo| Api::PhotoSerializer.new(photo, photo[:source]).call }
end
private
def request_immich
Immich::RequestPhotos.new(
user,
start_date: start_date,
end_date: end_date
).call.map { |asset| transform_asset(asset, 'immich') }.compact
end
def request_photoprism
Photoprism::RequestPhotos.new(
user,
start_date: start_date,
end_date: end_date
).call.map { |asset| transform_asset(asset, 'photoprism') }.compact
end
def transform_asset(asset, source)
asset_type = asset['type'] || asset['Type']
return if asset_type.downcase == 'video'
asset.merge(source: source)
end
end

View file

@ -0,0 +1,51 @@
# frozen_string_literal: true
class Photos::Thumbnail
def initialize(user, source, id)
@user = user
@source = source
@id = id
end
def call
HTTParty.get(request_url, headers: headers)
end
private
attr_reader :user, :source, :id
def source_url
user.settings["#{source}_url"]
end
def source_api_key
user.settings["#{source}_api_key"]
end
def source_path
case source
when 'immich'
"/api/assets/#{id}/thumbnail?size=preview"
when 'photoprism'
preview_token = Rails.cache.read("#{Photoprism::CachePreviewToken::TOKEN_CACHE_KEY}_#{user.id}")
"/api/v1/t/#{id}/#{preview_token}/tile_500"
else
raise "Unsupported source: #{source}"
end
end
def request_url
"#{source_url}#{source_path}"
end
def headers
request_headers = {
'accept' => 'application/octet-stream'
}
request_headers['X-Api-Key'] = source_api_key if source == 'immich'
request_headers
end
end

View file

@ -10,6 +10,11 @@
<% else %> <% else %>
<a href='' class="rounded-lg py-3 px-5 bg-blue-900 text-gray block font-medium tooltip cursor-not-allowed" data-tip="You need to provide your Immich instance data in the Settings">Import Immich data</a> <a href='' class="rounded-lg py-3 px-5 bg-blue-900 text-gray block font-medium tooltip cursor-not-allowed" data-tip="You need to provide your Immich instance data in the Settings">Import Immich data</a>
<% end %> <% end %>
<% if current_user.settings['photoprism_url'] && current_user.settings['photoprism_api_key'] %>
<%= link_to 'Import Photoprism data', settings_background_jobs_path(job_name: 'start_photoprism_import'), method: :post, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %>
<% else %>
<a href='' class="rounded-lg py-3 px-5 bg-blue-900 text-gray block font-medium tooltip cursor-not-allowed" data-tip="You need to provide your Photoprism instance data in the Settings">Import Photoprism data</a>
<% end %>
</div> </div>
<div id="imports" class="min-w-full"> <div id="imports" class="min-w-full">

View file

@ -1,5 +1,5 @@
<div role="tablist" class="tabs tabs-lifted tabs-lg"> <div role="tablist" class="tabs tabs-lifted tabs-lg">
<%= link_to 'Main', settings_path, role: 'tab', class: "tab #{active_tab?(settings_path)}" %> <%= link_to 'Integrations', settings_path, role: 'tab', class: "tab #{active_tab?(settings_path)}" %>
<% if current_user.admin? %> <% if current_user.admin? %>
<%= link_to 'Users', settings_users_path, role: 'tab', class: "tab #{active_tab?(settings_users_path)}" %> <%= link_to 'Users', settings_users_path, role: 'tab', class: "tab #{active_tab?(settings_users_path)}" %>
<%= link_to 'Background Jobs', settings_background_jobs_path, role: 'tab', class: "tab #{active_tab?(settings_background_jobs_path)}" %> <%= link_to 'Background Jobs', settings_background_jobs_path, role: 'tab', class: "tab #{active_tab?(settings_background_jobs_path)}" %>

View file

@ -5,7 +5,7 @@
<div class="flex flex-col lg:flex-row w-full my-10 space-x-4"> <div class="flex flex-col lg:flex-row w-full my-10 space-x-4">
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5 mx-5"> <div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5 mx-5">
<h2 class="text-2xl font-bold">Edit your Dawarich settings!</h1> <h2 class="text-2xl font-bold">Edit your Integrations settings!</h1>
<%= form_for :settings, url: settings_path, method: :patch, data: { turbo_method: :patch, turbo: false } do |f| %> <%= form_for :settings, url: settings_path, method: :patch, data: { turbo_method: :patch, turbo: false } do |f| %>
<div class="form-control my-2"> <div class="form-control my-2">
<%= f.label :immich_url %> <%= f.label :immich_url %>
@ -15,6 +15,16 @@
<%= f.label :immich_api_key %> <%= f.label :immich_api_key %>
<%= f.text_field :immich_api_key, value: current_user.settings['immich_api_key'], class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %> <%= f.text_field :immich_api_key, value: current_user.settings['immich_api_key'], class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
</div> </div>
<div class="divider"></div>
<div class="form-control my-2">
<%= f.label :photoprism_url %>
<%= f.text_field :photoprism_url, value: current_user.settings['photoprism_url'], class: "input input-bordered", placeholder: 'http://192.168.0.1:2342' %>
</div>
<div class="form-control my-2">
<%= f.label :photoprism_api_key %>
<%= f.text_field :photoprism_api_key, value: current_user.settings['photoprism_api_key'], class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
</div>
<div class="form-control my-2"> <div class="form-control my-2">
<%= f.submit "Update", class: "btn btn-primary" %> <%= f.submit "Update", class: "btn btn-primary" %>
</div> </div>

View file

@ -23,7 +23,7 @@ FactoryBot.define do
admin { true } admin { true }
end end
trait :with_immich_credentials do trait :with_immich_integration do
settings do settings do
{ {
immich_url: 'https://immich.example.com', immich_url: 'https://immich.example.com',
@ -31,5 +31,14 @@ FactoryBot.define do
} }
end end
end end
trait :with_photoprism_integration do
settings do
{
photoprism_url: 'https://photoprism.example.com',
photoprism_api_key: '1234567890'
}
end
end
end end
end end

View file

@ -17,7 +17,8 @@ RSpec.describe Import, type: :model do
google_phone_takeout: 3, google_phone_takeout: 3,
gpx: 4, gpx: 4,
immich_api: 5, immich_api: 5,
geojson: 6 geojson: 6,
photoprism_api: 7
) )
end end
end end

View file

@ -4,40 +4,68 @@ require 'rails_helper'
RSpec.describe 'Api::V1::Photos', type: :request do RSpec.describe 'Api::V1::Photos', type: :request do
describe 'GET /index' do describe 'GET /index' do
let(:user) { create(:user) } context 'when the integration is configured' do
let(:user) { create(:user, :with_photoprism_integration) }
let(:photo_data) do let(:photo_data) do
[ [
{ {
'id' => '123', 'id' => 1,
'latitude' => 35.6762, 'latitude' => 35.6762,
'longitude' => 139.6503, 'longitude' => 139.6503,
'localDateTime' => '2024-01-01T00:00:00.000Z', 'localDateTime' => '2024-01-01T00:00:00.000Z',
'type' => 'photo' 'originalFileName' => 'photo1.jpg',
}, 'city' => 'Tokyo',
{ 'state' => 'Tokyo',
'id' => '456', 'country' => 'Japan',
'latitude' => 40.7128, 'type' => 'photo',
'longitude' => -74.0060, 'source' => 'photoprism'
'localDateTime' => '2024-01-02T00:00:00.000Z', },
'type' => 'photo' {
} 'id' => 2,
] 'latitude' => 40.7128,
'longitude' => -74.0060,
'localDateTime' => '2024-01-02T00:00:00.000Z',
'originalFileName' => 'photo2.jpg',
'city' => 'New York',
'state' => 'New York',
'country' => 'USA',
'type' => 'photo',
'source' => 'immich'
}
]
end
context 'when the request is successful' do
before do
allow_any_instance_of(Photos::Search).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
context 'when the request is successful' do context 'when the integration is not configured' do
let(:user) { create(:user) }
before 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, source: 'immich' }
get '/api/v1/photos', params: { api_key: user.api_key }
end end
it 'returns http success' do it 'returns http unauthorized' do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:unauthorized)
end end
it 'returns photos data as JSON' do it 'returns an error message' do
expect(JSON.parse(response.body)).to eq(photo_data) expect(JSON.parse(response.body)).to eq({ 'error' => 'Immich integration not configured' })
end end
end end
end end

View file

@ -0,0 +1,160 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Api::PhotoSerializer do
describe '#call' do
subject(:serialized_photo) { described_class.new(photo, source).call }
context 'when photo is from immich' do
let(:source) { 'immich' }
let(:photo) 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": '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
}
end
it 'serializes the photo correctly' do
expect(serialized_photo).to eq(
id: '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c',
latitude: 52.11,
longitude: 13.22,
localDateTime: '2023-06-08T09:58:45.637Z',
originalFileName: 'IMG_9913.jpeg',
city: 'Johannisthal',
state: 'Berlin',
country: 'Germany',
type: 'image',
source: 'immich'
)
end
end
context 'when photo is from photoprism' do
let(:source) { 'photoprism' }
let(:photo) do
{
'ID' => '102',
'UID' => 'psnver0s3x7wxfnh',
'Type' => 'image',
'TypeSrc' => '',
'TakenAt' => '2023-10-10T16:04:33Z',
'TakenAtLocal' => '2023-10-10T16:04:33Z',
'TakenSrc' => 'name',
'TimeZone' => '',
'Path' => '2023/10',
'Name' => '20231010_160433_91981432',
'OriginalName' => 'photo_2023-10-10 16.04.33',
'Title' => 'Photo / 2023',
'Description' => '',
'Year' => 2023,
'Month' => 10,
'Day' => 10,
'Country' => 'zz',
'Stack' => 0,
'Favorite' => false,
'Private' => false,
'Iso' => 0,
'FocalLength' => 0,
'FNumber' => 0,
'Exposure' => '',
'Quality' => 1,
'Resolution' => 1,
'Color' => 4,
'Scan' => false,
'Panorama' => false,
'CameraID' => 1,
'CameraModel' => 'Unknown',
'LensID' => 1,
'LensModel' => 'Unknown',
'Lat' => 11,
'Lng' => 22,
'CellID' => 'zz',
'PlaceID' => 'zz',
'PlaceSrc' => '',
'PlaceLabel' => 'Unknown',
'PlaceCity' => 'Unknown',
'PlaceState' => 'Unknown',
'PlaceCountry' => 'zz',
'InstanceID' => '',
'FileUID' => 'fsnver0clrfzatmz',
'FileRoot' => '/',
'FileName' => '2023/10/20231010_160433_91981432.jpeg',
'Hash' => 'ce1849fd7cf6a50eb201fbb669ab78c7ac13263b',
'Width' => 1280,
'Height' => 908,
'Portrait' => false,
'Merged' => false,
'CreatedAt' => '2024-12-02T14:25:48Z',
'UpdatedAt' => '2024-12-02T14:36:45Z',
'EditedAt' => '0001-01-01T00:00:00Z',
'CheckedAt' => '2024-12-02T14:36:45Z',
'Files' => nil
}
end
it 'serializes the photo correctly' do
expect(serialized_photo).to eq(
id: 'ce1849fd7cf6a50eb201fbb669ab78c7ac13263b',
latitude: 11,
longitude: 22,
localDateTime: '2023-10-10T16:04:33Z',
originalFileName: 'photo_2023-10-10 16.04.33',
city: 'Unknown',
state: 'Unknown',
country: 'zz',
type: 'image',
source: 'photoprism'
)
end
end
end
end

View file

@ -9,7 +9,7 @@ RSpec.describe Immich::RequestPhotos do
let(:user) do let(:user) do
create(:user, settings: { 'immich_url' => 'http://immich.app', 'immich_api_key' => '123456' }) create(:user, settings: { 'immich_url' => 'http://immich.app', 'immich_api_key' => '123456' })
end end
let(:immich_data) do let(:mock_immich_data) do
{ {
"albums": { "albums": {
"total": 0, "total": 0,
@ -134,11 +134,11 @@ RSpec.describe Immich::RequestPhotos do
stub_request( stub_request(
:any, :any,
'http://immich.app/api/search/metadata' 'http://immich.app/api/search/metadata'
).to_return(status: 200, body: immich_data, headers: {}) ).to_return(status: 200, body: mock_immich_data, headers: {})
end end
it 'returns images and videos' do it 'returns images and videos' do
expect(service.map { _1['type'] }.uniq).to eq(['IMAGE', 'VIDEO']) expect(service.map { _1['type'] }.uniq).to eq(%w[IMAGE VIDEO])
end end
end end

View file

@ -0,0 +1,105 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Imports::Create do
let(:user) { create(:user) }
let(:service) { described_class.new(user, import) }
describe '#call' do
context 'when source is google_semantic_history' do
let(:import) { create(:import, source: 'google_semantic_history') }
it 'calls the GoogleMaps::SemanticHistoryParser' do
expect(GoogleMaps::SemanticHistoryParser).to receive(:new).with(import, user.id).and_return(double(call: true))
service.call
end
end
context 'when source is google_phone_takeout' do
let(:import) { create(:import, source: 'google_phone_takeout') }
it 'calls the GoogleMaps::PhoneTakeoutParser' do
expect(GoogleMaps::PhoneTakeoutParser).to receive(:new).with(import, user.id).and_return(double(call: true))
service.call
end
end
context 'when source is owntracks' do
let(:import) { create(:import, source: 'owntracks') }
it 'calls the OwnTracks::ExportParser' do
expect(OwnTracks::ExportParser).to receive(:new).with(import, user.id).and_return(double(call: true))
service.call
end
context 'when import is successful' do
it 'creates a finished notification' do
service.call
expect(user.notifications.last.kind).to eq('info')
end
it 'schedules stats creating' do
Sidekiq::Testing.inline! do
expect { service.call }.to have_enqueued_job(Stats::CalculatingJob)
end
end
it 'schedules visit suggesting' do
Sidekiq::Testing.inline! do
expect { service.call }.to have_enqueued_job(VisitSuggestingJob)
end
end
end
context 'when import fails' do
before do
allow(OwnTracks::ExportParser).to receive(:new).with(import, user.id).and_return(double(call: false))
end
it 'creates a failed notification' do
service.call
expect(user.notifications.last.kind).to eq('error')
end
end
end
context 'when source is gpx' do
let(:import) { create(:import, source: 'gpx') }
it 'calls the Gpx::TrackParser' do
expect(Gpx::TrackParser).to receive(:new).with(import, user.id).and_return(double(call: true))
service.call
end
end
context 'when source is geojson' do
let(:import) { create(:import, source: 'geojson') }
it 'calls the Geojson::ImportParser' do
expect(Geojson::ImportParser).to receive(:new).with(import, user.id).and_return(double(call: true))
service.call
end
end
context 'when source is immich_api' do
let(:import) { create(:import, source: 'immich_api') }
it 'calls the Photos::ImportParser' do
expect(Photos::ImportParser).to receive(:new).with(import, user.id).and_return(double(call: true))
service.call
end
end
context 'when source is photoprism_api' do
let(:import) { create(:import, source: 'photoprism_api') }
it 'calls the Photos::ImportParser' do
expect(Photos::ImportParser).to receive(:new).with(import, user.id).and_return(double(call: true))
service.call
end
end
end
end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Photoprism::CachePreviewToken, type: :service do
let(:user) { double('User', id: 1) }
let(:preview_token) { 'sample_token' }
let(:service) { described_class.new(user, preview_token) }
describe '#call' do
it 'writes the preview token to the cache with the correct key' do
expect(Rails.cache).to receive(:write).with(
"dawarich/photoprism_preview_token_#{user.id}", preview_token
)
service.call
end
end
end

View file

@ -0,0 +1,177 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Photoprism::ImportGeodata do
describe '#call' do
subject(:service) { described_class.new(user).call }
let(:user) do
create(:user, settings: { 'photoprism_url' => 'http://photoprism.app', 'photoprism_api_key' => '123456' })
end
let(:photoprism_data) do
[
{
'ID' => '82',
'UID' => 'psnveqq089xhy1c3',
'Type' => 'image',
'TypeSrc' => '',
'TakenAt' => '2024-08-18T14:11:05Z',
'TakenAtLocal' => '2024-08-18T16:11:05Z',
'TakenSrc' => 'meta',
'TimeZone' => 'Europe/Prague',
'Path' => '2024/08',
'Name' => '20240818_141105_44E61AED',
'OriginalName' => 'PXL_20240818_141105789',
'Title' => 'Moment / Karlovy Vary / 2024',
'Description' => '',
'Year' => 2024,
'Month' => 8,
'Day' => 18,
'Country' => 'cz',
'Stack' => 0,
'Favorite' => false,
'Private' => false,
'Iso' => 37,
'FocalLength' => 21,
'FNumber' => 2.2,
'Exposure' => '1/347',
'Quality' => 4,
'Resolution' => 10,
'Color' => 2,
'Scan' => false,
'Panorama' => false,
'CameraID' => 8,
'CameraSrc' => 'meta',
'CameraMake' => 'Google',
'CameraModel' => 'Pixel 7 Pro',
'LensID' => 11,
'LensMake' => 'Google',
'LensModel' => 'Pixel 7 Pro front camera 2.74mm f/2.2',
'Altitude' => 423,
'Lat' => 50.11,
'Lng' => 12.12,
'CellID' => 's2:47a09944f33c',
'PlaceID' => 'cz:ciNqTjWuq6NN',
'PlaceSrc' => 'meta',
'PlaceLabel' => 'Karlovy Vary, Severozápad, Czech Republic',
'PlaceCity' => 'Karlovy Vary',
'PlaceState' => 'Severozápad',
'PlaceCountry' => 'cz',
'InstanceID' => '',
'FileUID' => 'fsnveqqeusn692qo',
'FileRoot' => '/',
'FileName' => '2024/08/20240818_141105_44E61AED.jpg',
'Hash' => 'cc5d0f544e52b288d7c8460d2e1bb17fa66e6089',
'Width' => 2736,
'Height' => 3648,
'Portrait' => true,
'Merged' => false,
'CreatedAt' => '2024-12-02T14:25:38Z',
'UpdatedAt' => '2024-12-02T14:25:38Z',
'EditedAt' => '0001-01-01T00:00:00Z',
'CheckedAt' => '2024-12-02T14:36:45Z',
'Files' => nil
},
{
'ID' => '81',
'UID' => 'psnveqpl96gcfdzf',
'Type' => 'image',
'TypeSrc' => '',
'TakenAt' => '2024-08-18T14:11:04Z',
'TakenAtLocal' => '2024-08-18T16:11:04Z',
'TakenSrc' => 'meta',
'TimeZone' => 'Europe/Prague',
'Path' => '2024/08',
'Name' => '20240818_141104_E9949CD4',
'OriginalName' => 'PXL_20240818_141104633',
'Title' => 'Portrait / Karlovy Vary / 2024',
'Description' => '',
'Year' => 2024,
'Month' => 8,
'Day' => 18,
'Country' => 'cz',
'Stack' => 0,
'Favorite' => false,
'Private' => false,
'Iso' => 43,
'FocalLength' => 21,
'FNumber' => 2.2,
'Exposure' => '1/356',
'Faces' => 1,
'Quality' => 4,
'Resolution' => 10,
'Color' => 2,
'Scan' => false,
'Panorama' => false,
'CameraID' => 8,
'CameraSrc' => 'meta',
'CameraMake' => 'Google',
'CameraModel' => 'Pixel 7 Pro',
'LensID' => 11,
'LensMake' => 'Google',
'LensModel' => 'Pixel 7 Pro front camera 2.74mm f/2.2',
'Altitude' => 423,
'Lat' => 50.21,
'Lng' => 12.85,
'CellID' => 's2:47a09944f33c',
'PlaceID' => 'cz:ciNqTjWuq6NN',
'PlaceSrc' => 'meta',
'PlaceLabel' => 'Karlovy Vary, Severozápad, Czech Republic',
'PlaceCity' => 'Karlovy Vary',
'PlaceState' => 'Severozápad',
'PlaceCountry' => 'cz',
'InstanceID' => '',
'FileUID' => 'fsnveqp9xsl7onsv',
'FileRoot' => '/',
'FileName' => '2024/08/20240818_141104_E9949CD4.jpg',
'Hash' => 'd5dfadc56a0b63051dfe0b5dec55ff1d81f033b7',
'Width' => 2736,
'Height' => 3648,
'Portrait' => true,
'Merged' => false,
'CreatedAt' => '2024-12-02T14:25:37Z',
'UpdatedAt' => '2024-12-02T14:25:37Z',
'EditedAt' => '0001-01-01T00:00:00Z',
'CheckedAt' => '2024-12-02T14:36:45Z',
'Files' => nil
}
].to_json
end
before do
stub_request(:get, %r{http://photoprism\.app/api/v1/photos}).with(
headers: {
'Accept' => 'application/json',
'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
'Authorization' => 'Bearer 123456',
'User-Agent' => 'Ruby'
}
).to_return(status: 200, body: photoprism_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 'does not enqueue ImportJob' do
expect(ImportJob).to_not receive(:perform_later)
service
end
end
end
end

View file

@ -0,0 +1,287 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Photoprism::RequestPhotos do
let(:user) do
create(
:user,
settings: {
'photoprism_url' => 'http://photoprism.local',
'photoprism_api_key' => 'test_api_key'
}
)
end
let(:start_date) { '2024-01-01' }
let(:end_date) { '2024-12-31' }
let(:service) { described_class.new(user, start_date: start_date, end_date: end_date) }
let(:mock_photo_response) do
[
{
'ID' => '82',
'UID' => 'psnveqq089xhy1c3',
'Type' => 'image',
'TypeSrc' => '',
'TakenAt' => '2024-08-18T14:11:05Z',
'TakenAtLocal' => '2024-08-18T16:11:05Z',
'TakenSrc' => 'meta',
'TimeZone' => 'Europe/Prague',
'Path' => '2024/08',
'Name' => '20240818_141105_44E61AED',
'OriginalName' => 'PXL_20240818_141105789',
'Title' => 'Moment / Karlovy Vary / 2024',
'Description' => '',
'Year' => 2024,
'Month' => 8,
'Day' => 18,
'Country' => 'cz',
'Stack' => 0,
'Favorite' => false,
'Private' => false,
'Iso' => 37,
'FocalLength' => 21,
'FNumber' => 2.2,
'Exposure' => '1/347',
'Quality' => 4,
'Resolution' => 10,
'Color' => 2,
'Scan' => false,
'Panorama' => false,
'CameraID' => 8,
'CameraSrc' => 'meta',
'CameraMake' => 'Google',
'CameraModel' => 'Pixel 7 Pro',
'LensID' => 11,
'LensMake' => 'Google',
'LensModel' => 'Pixel 7 Pro front camera 2.74mm f/2.2',
'Altitude' => 423,
'Lat' => 50.11,
'Lng' => 12.12,
'CellID' => 's2:47a09944f33c',
'PlaceID' => 'cz:ciNqTjWuq6NN',
'PlaceSrc' => 'meta',
'PlaceLabel' => 'Karlovy Vary, Severozápad, Czech Republic',
'PlaceCity' => 'Karlovy Vary',
'PlaceState' => 'Severozápad',
'PlaceCountry' => 'cz',
'InstanceID' => '',
'FileUID' => 'fsnveqqeusn692qo',
'FileRoot' => '/',
'FileName' => '2024/08/20240818_141105_44E61AED.jpg',
'Hash' => 'cc5d0f544e52b288d7c8460d2e1bb17fa66e6089',
'Width' => 2736,
'Height' => 3648,
'Portrait' => true,
'Merged' => false,
'CreatedAt' => '2024-12-02T14:25:38Z',
'UpdatedAt' => '2024-12-02T14:25:38Z',
'EditedAt' => '0001-01-01T00:00:00Z',
'CheckedAt' => '2024-12-02T14:36:45Z',
'Files' => nil
},
{
'ID' => '81',
'UID' => 'psnveqpl96gcfdzf',
'Type' => 'image',
'TypeSrc' => '',
'TakenAt' => '2024-08-18T14:11:04Z',
'TakenAtLocal' => '2024-08-18T16:11:04Z',
'TakenSrc' => 'meta',
'TimeZone' => 'Europe/Prague',
'Path' => '2024/08',
'Name' => '20240818_141104_E9949CD4',
'OriginalName' => 'PXL_20240818_141104633',
'Title' => 'Portrait / Karlovy Vary / 2024',
'Description' => '',
'Year' => 2024,
'Month' => 8,
'Day' => 18,
'Country' => 'cz',
'Stack' => 0,
'Favorite' => false,
'Private' => false,
'Iso' => 43,
'FocalLength' => 21,
'FNumber' => 2.2,
'Exposure' => '1/356',
'Faces' => 1,
'Quality' => 4,
'Resolution' => 10,
'Color' => 2,
'Scan' => false,
'Panorama' => false,
'CameraID' => 8,
'CameraSrc' => 'meta',
'CameraMake' => 'Google',
'CameraModel' => 'Pixel 7 Pro',
'LensID' => 11,
'LensMake' => 'Google',
'LensModel' => 'Pixel 7 Pro front camera 2.74mm f/2.2',
'Altitude' => 423,
'Lat' => 50.21,
'Lng' => 12.85,
'CellID' => 's2:47a09944f33c',
'PlaceID' => 'cz:ciNqTjWuq6NN',
'PlaceSrc' => 'meta',
'PlaceLabel' => 'Karlovy Vary, Severozápad, Czech Republic',
'PlaceCity' => 'Karlovy Vary',
'PlaceState' => 'Severozápad',
'PlaceCountry' => 'cz',
'InstanceID' => '',
'FileUID' => 'fsnveqp9xsl7onsv',
'FileRoot' => '/',
'FileName' => '2024/08/20240818_141104_E9949CD4.jpg',
'Hash' => 'd5dfadc56a0b63051dfe0b5dec55ff1d81f033b7',
'Width' => 2736,
'Height' => 3648,
'Portrait' => true,
'Merged' => false,
'CreatedAt' => '2024-12-02T14:25:37Z',
'UpdatedAt' => '2024-12-02T14:25:37Z',
'EditedAt' => '0001-01-01T00:00:00Z',
'CheckedAt' => '2024-12-02T14:36:45Z',
'Files' => nil
}
]
end
describe '#call' do
context 'with valid credentials' do
before do
stub_request(
:any,
"#{user.settings['photoprism_url']}/api/v1/photos?after=#{start_date}&before=#{end_date}&count=1000&public=true&q=&quality=3"
).with(
headers: {
'Accept' => 'application/json',
'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
'Authorization' => 'Bearer test_api_key',
'User-Agent' => 'Ruby'
}
).to_return(
status: 200,
body: mock_photo_response.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(
:any,
"#{user.settings['photoprism_url']}/api/v1/photos?after=#{start_date}&before=#{end_date}&count=1000&public=true&q=&quality=3&offset=1000"
).to_return(status: 200, body: [].to_json)
end
it 'returns photos within the specified date range' do
result = service.call
expect(result).to be_an(Array)
expect(result.first['Title']).to eq('Moment / Karlovy Vary / 2024')
end
end
context 'with missing credentials' do
let(:user) { create(:user, settings: {}) }
it 'raises error when Photoprism URL is missing' do
expect { service.call }.to raise_error(ArgumentError, 'Photoprism URL is missing')
end
it 'raises error when API key is missing' do
user.update(settings: { 'photoprism_url' => 'http://photoprism.local' })
expect { service.call }.to raise_error(ArgumentError, 'Photoprism API key is missing')
end
end
context 'when API returns an error' do
before do
stub_request(
:get,
"#{user.settings['photoprism_url']}/api/v1/photos?after=#{start_date}&before=#{end_date}&count=1000&public=true&q=&quality=3"
).to_return(status: 400, body: { status: 400, error: 'Unable to do that' }.to_json)
end
it 'logs the error' do
expect(Rails.logger).to \
receive(:error).with('Photoprism API returned 400: {"status":400,"error":"Unable to do that"}')
expect(Rails.logger).to \
receive(:debug).with(
"Photoprism API request params: #{{ q: '', public: true, quality: 3, after: start_date, count: 1000,
before: end_date }}"
)
service.call
end
end
context 'with pagination' do
let(:first_page) { [{ 'TakenAtLocal' => "#{start_date}T14:30:00Z" }] }
let(:second_page) { [{ 'TakenAtLocal' => "#{start_date}T14:30:00Z" }] }
let(:empty_page) { [] }
let(:common_headers) do
{
'Accept' => 'application/json',
'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
'Authorization' => 'Bearer test_api_key',
'User-Agent' => 'Ruby'
}
end
before do
# First page
stub_request(:any, "#{user.settings['photoprism_url']}/api/v1/photos")
.with(
headers: common_headers,
query: {
after: start_date,
before: end_date,
count: '1000',
public: 'true',
q: '',
quality: '3'
}
)
.to_return(status: 200, body: first_page.to_json)
# Second page
stub_request(:any, "#{user.settings['photoprism_url']}/api/v1/photos")
.with(
headers: common_headers,
query: {
after: start_date,
before: end_date,
count: '1000',
public: 'true',
q: '',
quality: '3',
offset: '1000'
}
)
.to_return(status: 200, body: second_page.to_json)
# Last page (empty)
stub_request(:any, "#{user.settings['photoprism_url']}/api/v1/photos")
.with(
headers: common_headers,
query: {
after: start_date,
before: end_date,
count: '1000',
public: 'true',
q: '',
quality: '3',
offset: '2000'
}
)
.to_return(status: 200, body: empty_page.to_json)
end
it 'fetches all pages until empty result' do
result = service.call
expect(result.size).to eq(2)
end
end
end
end

View file

@ -2,7 +2,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Immich::ImportParser do RSpec.describe Photos::ImportParser do
describe '#call' do describe '#call' do
subject(:service) { described_class.new(import, user.id).call } subject(:service) { described_class.new(import, user.id).call }

View file

@ -0,0 +1,147 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Photos::Search do
let(:user) { create(:user) }
let(:start_date) { '2024-01-01' }
let(:end_date) { '2024-03-01' }
let(:service) { described_class.new(user, start_date: start_date, end_date: end_date) }
describe '#call' do
context 'when user has no integrations configured' do
before do
allow(user).to receive(:immich_integration_configured?).and_return(false)
allow(user).to receive(:photoprism_integration_configured?).and_return(false)
end
it 'returns an empty array' do
expect(service.call).to eq([])
end
end
context 'when user has Immich integration configured' do
let(:immich_photo) { { 'type' => 'image', 'id' => '1' } }
let(:serialized_photo) { { id: '1', source: 'immich' } }
before do
allow(user).to receive(:immich_integration_configured?).and_return(true)
allow(user).to receive(:photoprism_integration_configured?).and_return(false)
allow_any_instance_of(Immich::RequestPhotos).to receive(:call)
.and_return([immich_photo])
allow_any_instance_of(Api::PhotoSerializer).to receive(:call)
.and_return(serialized_photo)
end
it 'fetches and transforms Immich photos' do
expect(service.call).to eq([serialized_photo])
end
end
context 'when user has Photoprism integration configured' do
let(:photoprism_photo) { { 'Type' => 'image', 'id' => '2' } }
let(:serialized_photo) { { id: '2', source: 'photoprism' } }
before do
allow(user).to receive(:immich_integration_configured?).and_return(false)
allow(user).to receive(:photoprism_integration_configured?).and_return(true)
allow_any_instance_of(Photoprism::RequestPhotos).to receive(:call)
.and_return([photoprism_photo])
allow_any_instance_of(Api::PhotoSerializer).to receive(:call)
.and_return(serialized_photo)
end
it 'fetches and transforms Photoprism photos' do
expect(service.call).to eq([serialized_photo])
end
end
context 'when user has both integrations configured' do
let(:immich_photo) { { 'type' => 'image', 'id' => '1' } }
let(:photoprism_photo) { { 'Type' => 'image', 'id' => '2' } }
let(:serialized_immich) do
{
id: '1',
latitude: nil,
longitude: nil,
localDateTime: nil,
originalFileName: nil,
city: nil,
state: nil,
country: nil,
type: 'image',
source: 'immich'
}
end
let(:serialized_photoprism) do
{
id: '2',
latitude: nil,
longitude: nil,
localDateTime: nil,
originalFileName: nil,
city: nil,
state: nil,
country: nil,
type: 'image',
source: 'photoprism'
}
end
before do
allow(user).to receive(:immich_integration_configured?).and_return(true)
allow(user).to receive(:photoprism_integration_configured?).and_return(true)
allow_any_instance_of(Immich::RequestPhotos).to receive(:call)
.and_return([immich_photo])
allow_any_instance_of(Photoprism::RequestPhotos).to receive(:call)
.and_return([photoprism_photo])
end
it 'fetches and transforms photos from both services' do
expect(service.call).to eq([serialized_immich, serialized_photoprism])
end
end
context 'when filtering out videos' do
let(:immich_photo) { { 'type' => 'video', 'id' => '1' } }
before do
allow(user).to receive(:immich_integration_configured?).and_return(true)
allow(user).to receive(:photoprism_integration_configured?).and_return(false)
allow_any_instance_of(Immich::RequestPhotos).to receive(:call)
.and_return([immich_photo])
end
it 'excludes video assets' do
expect(service.call).to eq([])
end
end
end
describe '#initialize' do
context 'with default parameters' do
let(:service_default) { described_class.new(user) }
it 'sets default start_date' do
expect(service_default.start_date).to eq('1970-01-01')
end
it 'sets default end_date to nil' do
expect(service_default.end_date).to be_nil
end
end
context 'with custom parameters' do
it 'sets custom dates' do
expect(service.start_date).to eq(start_date)
expect(service.end_date).to eq(end_date)
end
end
end
end

View file

@ -0,0 +1,77 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Photos::Thumbnail do
let(:user) { create(:user) }
let(:id) { 'photo123' }
describe '#call' do
subject { described_class.new(user, source, id).call }
context 'with immich source' do
let(:source) { 'immich' }
let(:api_key) { 'immich_key_123' }
let(:base_url) { 'https://photos.example.com' }
let(:expected_url) { "#{base_url}/api/assets/#{id}/thumbnail?size=preview" }
let(:expected_headers) do
{
'accept' => 'application/octet-stream',
'X-Api-Key' => api_key
}
end
before do
allow(user).to receive(:settings).and_return(
'immich_url' => base_url,
'immich_api_key' => api_key
)
end
it 'fetches thumbnail with correct parameters' do
expect(HTTParty).to receive(:get)
.with(expected_url, headers: expected_headers)
.and_return('thumbnail_data')
expect(subject).to eq('thumbnail_data')
end
end
context 'with photoprism source' do
let(:source) { 'photoprism' }
let(:base_url) { 'https://photoprism.example.com' }
let(:preview_token) { 'preview_token_123' }
let(:expected_url) { "#{base_url}/api/v1/t/#{id}/#{preview_token}/tile_500" }
let(:expected_headers) do
{
'accept' => 'application/octet-stream'
}
end
before do
allow(user).to receive(:settings).and_return(
'photoprism_url' => base_url
)
allow(Rails.cache).to receive(:read)
.with("#{Photoprism::CachePreviewToken::TOKEN_CACHE_KEY}_#{user.id}")
.and_return(preview_token)
end
it 'fetches thumbnail with correct parameters' do
expect(HTTParty).to receive(:get)
.with(expected_url, headers: expected_headers)
.and_return('thumbnail_data')
expect(subject).to eq('thumbnail_data')
end
end
context 'with unsupported source' do
let(:source) { 'unsupported' }
it 'raises an error' do
expect { subject }.to raise_error(RuntimeError, 'Unsupported source: unsupported')
end
end
end
end

View file

@ -3,7 +3,7 @@
require 'swagger_helper' require 'swagger_helper'
RSpec.describe 'Api::V1::PhotosController', type: :request do RSpec.describe 'Api::V1::PhotosController', type: :request do
let(:user) { create(:user, :with_immich_credentials) } let(:user) { create(:user, :with_immich_integration) }
let(:api_key) { user.api_key } let(:api_key) { user.api_key }
let(:start_date) { '2024-01-01' } let(:start_date) { '2024-01-01' }
let(:end_date) { '2024-01-02' } let(:end_date) { '2024-01-02' }
@ -103,59 +103,17 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do
type: :object, type: :object,
properties: { properties: {
id: { type: :string }, id: { type: :string },
deviceAssetId: { type: :string }, latitude: { type: :number, format: :float },
ownerId: { type: :string }, longitude: { type: :number, format: :float },
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' }, localDateTime: { type: :string, format: 'date-time' },
updatedAt: { type: :string, format: 'date-time' }, originalFileName: { type: :string },
isFavorite: { type: :boolean }, city: { type: :string },
isArchived: { type: :boolean }, state: { type: :string },
isTrashed: { type: :boolean }, country: { type: :string },
duration: { type: :string }, type: { type: :string },
exifInfo: { source: { type: :string }
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 required: %w[id latitude longitude localDateTime originalFileName city state country type source]
originalFileName originalMimeType thumbhash
fileCreatedAt fileModifiedAt localDateTime
updatedAt isFavorite isArchived isTrashed duration
exifInfo checksum isOffline hasMetadata duplicateId resized]
} }
run_test! do |response| run_test! do |response|
@ -172,61 +130,24 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do
produces 'application/json' produces 'application/json'
parameter name: :id, in: :path, type: :string, required: true parameter name: :id, in: :path, type: :string, required: true
parameter name: :api_key, in: :query, type: :string, required: true parameter name: :api_key, in: :query, type: :string, required: true
parameter name: :source, in: :query, type: :string, required: true
response '200', 'photo found' do response '200', 'photo found' do
schema type: :object, schema type: :object,
properties: { properties: {
id: { type: :string }, id: { type: :string },
deviceAssetId: { type: :string }, latitude: { type: :number, format: :float },
ownerId: { type: :string }, longitude: { type: :number, format: :float },
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' }, localDateTime: { type: :string, format: 'date-time' },
updatedAt: { type: :string, format: 'date-time' }, originalFileName: { type: :string },
isFavorite: { type: :boolean }, city: { type: :string },
isArchived: { type: :boolean }, state: { type: :string },
isTrashed: { type: :boolean }, country: { type: :string },
duration: { type: :string }, type: { type: :string },
exifInfo: { source: { type: :string }
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' } let(:id) { '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c' }
let(:source) { 'immich' }
run_test! do |response| run_test! do |response|
data = JSON.parse(response.body) data = JSON.parse(response.body)
@ -238,6 +159,7 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do
response '404', 'photo not found' do response '404', 'photo not found' do
let(:id) { 'nonexistent' } let(:id) { 'nonexistent' }
let(:api_key) { user.api_key } let(:api_key) { user.api_key }
let(:source) { 'immich' }
run_test! do |response| run_test! do |response|
data = JSON.parse(response.body) data = JSON.parse(response.body)

View file

@ -347,130 +347,38 @@ paths:
properties: properties:
id: id:
type: string type: string
deviceAssetId: latitude:
type: string type: number
ownerId: format: float
type: string longitude:
type: type: number
type: string format: float
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: localDateTime:
type: string type: string
format: date-time format: date-time
updatedAt: originalFileName:
type: string type: string
format: date-time city:
isFavorite:
type: boolean
isArchived:
type: boolean
isTrashed:
type: boolean
duration:
type: string type: string
exifInfo: state:
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 type: string
isOffline: country:
type: boolean type: string
hasMetadata: type:
type: boolean type: string
duplicateId: source:
type: string type: string
resized:
type: boolean
required: required:
- id - id
- deviceAssetId - latitude
- ownerId - longitude
- type
- originalPath
- originalFileName
- originalMimeType
- thumbhash
- fileCreatedAt
- fileModifiedAt
- localDateTime - localDateTime
- updatedAt - originalFileName
- isFavorite - city
- isArchived - state
- isTrashed - country
- duration - type
- exifInfo - source
- checksum
- isOffline
- hasMetadata
- duplicateId
- resized
"/api/v1/photos/{id}/thumbnail": "/api/v1/photos/{id}/thumbnail":
get: get:
summary: Retrieves a photo summary: Retrieves a photo
@ -487,6 +395,11 @@ paths:
required: true required: true
schema: schema:
type: string type: string
- name: source
in: query
required: true
schema:
type: string
responses: responses:
'200': '200':
description: photo found description: photo found
@ -497,107 +410,27 @@ paths:
properties: properties:
id: id:
type: string type: string
deviceAssetId: latitude:
type: string type: number
ownerId: format: float
type: string longitude:
type: type: number
type: string format: float
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: localDateTime:
type: string type: string
format: date-time format: date-time
updatedAt: originalFileName:
type: string type: string
format: date-time city:
isFavorite:
type: boolean
isArchived:
type: boolean
isTrashed:
type: boolean
duration:
type: string type: string
exifInfo: state:
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 type: string
isOffline: country:
type: boolean type: string
hasMetadata: type:
type: boolean type: string
duplicateId: source:
type: string type: string
resized:
type: boolean
'404': '404':
description: photo not found description: photo not found
"/api/v1/points": "/api/v1/points":