mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
Merge pull request #475 from Freika/feature/photoprism-integration
Photoprism integration
This commit is contained in:
commit
c2605ed805
40 changed files with 1649 additions and 408 deletions
|
|
@ -1 +1 @@
|
|||
0.18.2
|
||||
0.19.0
|
||||
|
|
|
|||
29
CHANGELOG.md
29
CHANGELOG.md
|
|
@ -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/)
|
||||
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
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -99,6 +99,10 @@ Simply install one of the supported apps on your device and configure it to send
|
|||
### 📊 Statistics
|
||||
- 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 from various sources:
|
||||
- Google Maps Timeline
|
||||
|
|
|
|||
|
|
@ -1,38 +1,52 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::PhotosController < ApiController
|
||||
before_action :check_integration_configured, only: %i[index thumbnail]
|
||||
before_action :check_source, only: %i[thumbnail]
|
||||
|
||||
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' }
|
||||
Photos::Search.new(current_api_user, start_date: params[:start_date], end_date: params[:end_date]).call
|
||||
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
|
||||
response = fetch_cached_thumbnail(params[:source])
|
||||
handle_thumbnail_response(response)
|
||||
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?
|
||||
send_data(
|
||||
response.body,
|
||||
type: 'image/jpeg',
|
||||
disposition: 'inline',
|
||||
status: :ok
|
||||
)
|
||||
send_data(response.body, type: 'image/jpeg', disposition: 'inline', status: :ok)
|
||||
else
|
||||
render json: { error: 'Failed to fetch thumbnail' }, status: response.code
|
||||
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
|
||||
|
|
|
|||
|
|
@ -12,16 +12,11 @@ class Api::V1::SettingsController < ApiController
|
|||
settings_params.each { |key, value| current_api_user.settings[key] = value }
|
||||
|
||||
if current_api_user.save
|
||||
render json: {
|
||||
message: 'Settings updated',
|
||||
settings: current_api_user.settings,
|
||||
status: 'success'
|
||||
}, status: :ok
|
||||
render json: { message: 'Settings updated', settings: current_api_user.settings, status: 'success' },
|
||||
status: :ok
|
||||
else
|
||||
render json: {
|
||||
message: 'Something went wrong',
|
||||
errors: current_api_user.errors.full_messages
|
||||
}, status: :unprocessable_entity
|
||||
render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages },
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -31,7 +26,8 @@ class Api::V1::SettingsController < ApiController
|
|||
params.require(:settings).permit(
|
||||
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
|
||||
: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
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class SettingsController < ApplicationController
|
|||
params.require(:settings).permit(
|
||||
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
|
||||
: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
|
||||
|
|
|
|||
|
|
@ -137,10 +137,13 @@ export default class extends Controller {
|
|||
this.map.addControl(this.drawControl);
|
||||
}
|
||||
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(
|
||||
'error',
|
||||
'Immich integration is not configured. Please check your settings.'
|
||||
'Photos integration is not configured. Please check your integrations settings.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -836,7 +839,7 @@ export default class extends Controller {
|
|||
<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'}
|
||||
${photo.type === 'video' ? '🎥 Video' : '📷 Photo'}
|
||||
</div>
|
||||
`;
|
||||
marker.bindPopup(popupContent);
|
||||
|
|
|
|||
|
|
@ -80,10 +80,10 @@ export default class extends Controller {
|
|||
this.map.on('overlayadd', (e) => {
|
||||
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(
|
||||
'error',
|
||||
'Immich integration is not configured. Please check your settings.'
|
||||
'Photos integration is not configured. Please check your integrations settings.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa
|
|||
|
||||
const response = await fetch(`/api/v1/photos?${params}`);
|
||||
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();
|
||||
|
|
@ -171,10 +171,10 @@ export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDa
|
|||
const photoLoadPromises = photos.map(photo => {
|
||||
return new Promise((resolve) => {
|
||||
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 = () => {
|
||||
createPhotoMarker(photo, userSettings.immich_url, photoMarkers, apiKey);
|
||||
createPhotoMarker(photo, userSettings, photoMarkers, apiKey);
|
||||
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) {
|
||||
if (!photo.exifInfo?.latitude || !photo.exifInfo?.longitude) return;
|
||||
const endOfDay = new Date(photo.localDateTime);
|
||||
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({
|
||||
className: 'photo-marker',
|
||||
|
|
@ -229,25 +262,16 @@ export function createPhotoMarker(photo, immichUrl, photoMarkers,apiKey) {
|
|||
});
|
||||
|
||||
const marker = L.marker(
|
||||
[photo.exifInfo.latitude, photo.exifInfo.longitude],
|
||||
[photo.latitude, photo.longitude],
|
||||
{ icon }
|
||||
);
|
||||
|
||||
const startOfDay = new Date(photo.localDateTime);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const photo_link = getPhotoLink(photo, userSettings);
|
||||
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 = `
|
||||
<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 = '';">
|
||||
<img src="${thumbnailUrl}"
|
||||
class="mb-2 rounded"
|
||||
|
|
@ -256,7 +280,8 @@ export function createPhotoMarker(photo, immichUrl, photoMarkers,apiKey) {
|
|||
</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>
|
||||
<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'}
|
||||
</div>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ class EnqueueBackgroundJob < ApplicationJob
|
|||
case job_name
|
||||
when 'start_immich_import'
|
||||
Import::ImmichGeodataJob.perform_later(user_id)
|
||||
when 'start_photoprism_import'
|
||||
Import::PhotoprismGeodataJob.perform_later(user_id)
|
||||
when 'start_reverse_geocoding', 'continue_reverse_geocoding'
|
||||
Jobs::Create.new(job_name, user_id).call
|
||||
else
|
||||
|
|
|
|||
12
app/jobs/import/photoprism_geodata_job.rb
Normal file
12
app/jobs/import/photoprism_geodata_job.rb
Normal 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
|
||||
|
|
@ -10,7 +10,7 @@ class Import < ApplicationRecord
|
|||
|
||||
enum :source, {
|
||||
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!
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class User < ApplicationRecord
|
|||
has_many :trips, dependent: :destroy
|
||||
|
||||
after_create :create_api_key
|
||||
before_save :strip_trailing_slashes
|
||||
|
||||
def countries_visited
|
||||
stats.pluck(:toponyms).flatten.map { _1['country'] }.uniq.compact
|
||||
|
|
@ -53,6 +54,14 @@ class User < ApplicationRecord
|
|||
tracked_points.select(:id).where.not(geodata: {}).count
|
||||
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
|
||||
|
||||
def create_api_key
|
||||
|
|
@ -60,4 +69,9 @@ class User < ApplicationRecord
|
|||
|
||||
save
|
||||
end
|
||||
|
||||
def strip_trailing_slashes
|
||||
settings['immich_url']&.gsub!(%r{/+\z}, '')
|
||||
settings['photoprism_url']&.gsub!(%r{/+\z}, '')
|
||||
end
|
||||
end
|
||||
|
|
|
|||
63
app/serializers/api/photo_serializer.rb
Normal file
63
app/serializers/api/photo_serializer.rb
Normal 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
|
||||
|
|
@ -57,7 +57,7 @@ class Immich::ImportGeodata
|
|||
end
|
||||
|
||||
def log_no_data
|
||||
Rails.logger.info 'No data found'
|
||||
Rails.logger.info 'No geodata found for Immich'
|
||||
end
|
||||
|
||||
def create_import_failed_notification(import_name)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ class Immich::RequestPhotos
|
|||
|
||||
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_base_url = URI.parse("#{user.settings['immich_url']}/api/search/metadata")
|
||||
@immich_api_key = user.settings['immich_api_key']
|
||||
@start_date = start_date
|
||||
@end_date = end_date
|
||||
|
|
|
|||
|
|
@ -24,12 +24,12 @@ class Imports::Create
|
|||
def parser(source)
|
||||
# Bad classes naming by the way, they are not parsers, they are point creators
|
||||
case source
|
||||
when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser
|
||||
when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser
|
||||
when 'owntracks' then OwnTracks::ExportParser
|
||||
when 'gpx' then Gpx::TrackParser
|
||||
when 'immich_api' then Immich::ImportParser
|
||||
when 'geojson' then Geojson::ImportParser
|
||||
when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser
|
||||
when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser
|
||||
when 'owntracks' then OwnTracks::ExportParser
|
||||
when 'gpx' then Gpx::TrackParser
|
||||
when 'geojson' then Geojson::ImportParser
|
||||
when 'immich_api', 'photoprism_api' then Photos::ImportParser
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
16
app/services/photoprism/cache_preview_token.rb
Normal file
16
app/services/photoprism/cache_preview_token.rb
Normal 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
|
||||
86
app/services/photoprism/import_geodata.rb
Normal file
86
app/services/photoprism/import_geodata.rb
Normal 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
|
||||
101
app/services/photoprism/request_photos.rb
Normal file
101
app/services/photoprism/request_photos.rb
Normal 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
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Immich::ImportParser
|
||||
class Photos::ImportParser
|
||||
include Imports::Broadcaster
|
||||
|
||||
attr_reader :import, :json, :user_id
|
||||
45
app/services/photos/search.rb
Normal file
45
app/services/photos/search.rb
Normal 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
|
||||
51
app/services/photos/thumbnail.rb
Normal file
51
app/services/photos/thumbnail.rb
Normal 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
|
||||
|
|
@ -10,6 +10,11 @@
|
|||
<% 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>
|
||||
<% 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 id="imports" class="min-w-full">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<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? %>
|
||||
<%= 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)}" %>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
<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">
|
||||
<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| %>
|
||||
<div class="form-control my-2">
|
||||
<%= f.label :immich_url %>
|
||||
|
|
@ -15,6 +15,16 @@
|
|||
<%= f.label :immich_api_key %>
|
||||
<%= f.text_field :immich_api_key, value: current_user.settings['immich_api_key'], class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
|
||||
</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">
|
||||
<%= f.submit "Update", class: "btn btn-primary" %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ FactoryBot.define do
|
|||
admin { true }
|
||||
end
|
||||
|
||||
trait :with_immich_credentials do
|
||||
trait :with_immich_integration do
|
||||
settings do
|
||||
{
|
||||
immich_url: 'https://immich.example.com',
|
||||
|
|
@ -31,5 +31,14 @@ FactoryBot.define do
|
|||
}
|
||||
end
|
||||
end
|
||||
|
||||
trait :with_photoprism_integration do
|
||||
settings do
|
||||
{
|
||||
photoprism_url: 'https://photoprism.example.com',
|
||||
photoprism_api_key: '1234567890'
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ RSpec.describe Import, type: :model do
|
|||
google_phone_takeout: 3,
|
||||
gpx: 4,
|
||||
immich_api: 5,
|
||||
geojson: 6
|
||||
geojson: 6,
|
||||
photoprism_api: 7
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,40 +4,68 @@ require 'rails_helper'
|
|||
|
||||
RSpec.describe 'Api::V1::Photos', type: :request 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
|
||||
[
|
||||
{
|
||||
'id' => '123',
|
||||
'latitude' => 35.6762,
|
||||
'longitude' => 139.6503,
|
||||
'localDateTime' => '2024-01-01T00:00:00.000Z',
|
||||
'type' => 'photo'
|
||||
},
|
||||
{
|
||||
'id' => '456',
|
||||
'latitude' => 40.7128,
|
||||
'longitude' => -74.0060,
|
||||
'localDateTime' => '2024-01-02T00:00:00.000Z',
|
||||
'type' => 'photo'
|
||||
}
|
||||
]
|
||||
let(:photo_data) do
|
||||
[
|
||||
{
|
||||
'id' => 1,
|
||||
'latitude' => 35.6762,
|
||||
'longitude' => 139.6503,
|
||||
'localDateTime' => '2024-01-01T00:00:00.000Z',
|
||||
'originalFileName' => 'photo1.jpg',
|
||||
'city' => 'Tokyo',
|
||||
'state' => 'Tokyo',
|
||||
'country' => 'Japan',
|
||||
'type' => 'photo',
|
||||
'source' => 'photoprism'
|
||||
},
|
||||
{
|
||||
'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
|
||||
|
||||
context 'when the request is successful' do
|
||||
context 'when the integration is not configured' do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
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 }
|
||||
get '/api/v1/photos', params: { api_key: user.api_key, source: 'immich' }
|
||||
end
|
||||
|
||||
it 'returns http success' do
|
||||
expect(response).to have_http_status(:success)
|
||||
it 'returns http unauthorized' do
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns photos data as JSON' do
|
||||
expect(JSON.parse(response.body)).to eq(photo_data)
|
||||
it 'returns an error message' do
|
||||
expect(JSON.parse(response.body)).to eq({ 'error' => 'Immich integration not configured' })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
160
spec/serializers/api/photo_serializer_spec.rb
Normal file
160
spec/serializers/api/photo_serializer_spec.rb
Normal 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
|
||||
|
|
@ -9,7 +9,7 @@ RSpec.describe Immich::RequestPhotos do
|
|||
let(:user) do
|
||||
create(:user, settings: { 'immich_url' => 'http://immich.app', 'immich_api_key' => '123456' })
|
||||
end
|
||||
let(:immich_data) do
|
||||
let(:mock_immich_data) do
|
||||
{
|
||||
"albums": {
|
||||
"total": 0,
|
||||
|
|
@ -134,11 +134,11 @@ RSpec.describe Immich::RequestPhotos do
|
|||
stub_request(
|
||||
:any,
|
||||
'http://immich.app/api/search/metadata'
|
||||
).to_return(status: 200, body: immich_data, headers: {})
|
||||
).to_return(status: 200, body: mock_immich_data, headers: {})
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
105
spec/services/imports/create_spec.rb
Normal file
105
spec/services/imports/create_spec.rb
Normal 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
|
||||
19
spec/services/photoprism/cache_preview_token_spec.rb
Normal file
19
spec/services/photoprism/cache_preview_token_spec.rb
Normal 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
|
||||
177
spec/services/photoprism/import_geodata_spec.rb
Normal file
177
spec/services/photoprism/import_geodata_spec.rb
Normal 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
|
||||
287
spec/services/photoprism/request_photos_spec.rb
Normal file
287
spec/services/photoprism/request_photos_spec.rb
Normal 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
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Immich::ImportParser do
|
||||
RSpec.describe Photos::ImportParser do
|
||||
describe '#call' do
|
||||
subject(:service) { described_class.new(import, user.id).call }
|
||||
|
||||
147
spec/services/photos/search_spec.rb
Normal file
147
spec/services/photos/search_spec.rb
Normal 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
|
||||
77
spec/services/photos/thumbnail_spec.rb
Normal file
77
spec/services/photos/thumbnail_spec.rb
Normal 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
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
require 'swagger_helper'
|
||||
|
||||
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(:start_date) { '2024-01-01' }
|
||||
let(:end_date) { '2024-01-02' }
|
||||
|
|
@ -103,59 +103,17 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do
|
|||
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' },
|
||||
latitude: { type: :number, format: :float },
|
||||
longitude: { type: :number, format: :float },
|
||||
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 }
|
||||
originalFileName: { type: :string },
|
||||
city: { type: :string },
|
||||
state: { type: :string },
|
||||
country: { type: :string },
|
||||
type: { type: :string },
|
||||
source: { type: :string }
|
||||
},
|
||||
required: %w[id deviceAssetId ownerId type originalPath
|
||||
originalFileName originalMimeType thumbhash
|
||||
fileCreatedAt fileModifiedAt localDateTime
|
||||
updatedAt isFavorite isArchived isTrashed duration
|
||||
exifInfo checksum isOffline hasMetadata duplicateId resized]
|
||||
required: %w[id latitude longitude localDateTime originalFileName city state country type source]
|
||||
}
|
||||
|
||||
run_test! do |response|
|
||||
|
|
@ -172,61 +130,24 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do
|
|||
produces 'application/json'
|
||||
parameter name: :id, in: :path, 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
|
||||
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' },
|
||||
latitude: { type: :number, format: :float },
|
||||
longitude: { type: :number, format: :float },
|
||||
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 }
|
||||
originalFileName: { type: :string },
|
||||
city: { type: :string },
|
||||
state: { type: :string },
|
||||
country: { type: :string },
|
||||
type: { type: :string },
|
||||
source: { type: :string }
|
||||
}
|
||||
|
||||
let(:id) { '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c' }
|
||||
let(:source) { 'immich' }
|
||||
|
||||
run_test! do |response|
|
||||
data = JSON.parse(response.body)
|
||||
|
|
@ -238,6 +159,7 @@ RSpec.describe 'Api::V1::PhotosController', type: :request do
|
|||
response '404', 'photo not found' do
|
||||
let(:id) { 'nonexistent' }
|
||||
let(:api_key) { user.api_key }
|
||||
let(:source) { 'immich' }
|
||||
|
||||
run_test! do |response|
|
||||
data = JSON.parse(response.body)
|
||||
|
|
|
|||
|
|
@ -347,130 +347,38 @@ paths:
|
|||
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
|
||||
latitude:
|
||||
type: number
|
||||
format: float
|
||||
longitude:
|
||||
type: number
|
||||
format: float
|
||||
localDateTime:
|
||||
type: string
|
||||
format: date-time
|
||||
updatedAt:
|
||||
originalFileName:
|
||||
type: string
|
||||
format: date-time
|
||||
isFavorite:
|
||||
type: boolean
|
||||
isArchived:
|
||||
type: boolean
|
||||
isTrashed:
|
||||
type: boolean
|
||||
duration:
|
||||
city:
|
||||
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:
|
||||
state:
|
||||
type: string
|
||||
isOffline:
|
||||
type: boolean
|
||||
hasMetadata:
|
||||
type: boolean
|
||||
duplicateId:
|
||||
country:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
resized:
|
||||
type: boolean
|
||||
required:
|
||||
- id
|
||||
- deviceAssetId
|
||||
- ownerId
|
||||
- type
|
||||
- originalPath
|
||||
- originalFileName
|
||||
- originalMimeType
|
||||
- thumbhash
|
||||
- fileCreatedAt
|
||||
- fileModifiedAt
|
||||
- latitude
|
||||
- longitude
|
||||
- localDateTime
|
||||
- updatedAt
|
||||
- isFavorite
|
||||
- isArchived
|
||||
- isTrashed
|
||||
- duration
|
||||
- exifInfo
|
||||
- checksum
|
||||
- isOffline
|
||||
- hasMetadata
|
||||
- duplicateId
|
||||
- resized
|
||||
- originalFileName
|
||||
- city
|
||||
- state
|
||||
- country
|
||||
- type
|
||||
- source
|
||||
"/api/v1/photos/{id}/thumbnail":
|
||||
get:
|
||||
summary: Retrieves a photo
|
||||
|
|
@ -487,6 +395,11 @@ paths:
|
|||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: source
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: photo found
|
||||
|
|
@ -497,107 +410,27 @@ paths:
|
|||
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
|
||||
latitude:
|
||||
type: number
|
||||
format: float
|
||||
longitude:
|
||||
type: number
|
||||
format: float
|
||||
localDateTime:
|
||||
type: string
|
||||
format: date-time
|
||||
updatedAt:
|
||||
originalFileName:
|
||||
type: string
|
||||
format: date-time
|
||||
isFavorite:
|
||||
type: boolean
|
||||
isArchived:
|
||||
type: boolean
|
||||
isTrashed:
|
||||
type: boolean
|
||||
duration:
|
||||
city:
|
||||
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:
|
||||
state:
|
||||
type: string
|
||||
isOffline:
|
||||
type: boolean
|
||||
hasMetadata:
|
||||
type: boolean
|
||||
duplicateId:
|
||||
country:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
resized:
|
||||
type: boolean
|
||||
'404':
|
||||
description: photo not found
|
||||
"/api/v1/points":
|
||||
|
|
|
|||
Loading…
Reference in a new issue