diff --git a/.app_version b/.app_version
index 503a21de..1cf0537c 100644
--- a/.app_version
+++ b/.app_version
@@ -1 +1 @@
-0.18.2
+0.19.0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6c1cd353..23150af8 100644
--- a/CHANGELOG.md
+++ b/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
diff --git a/README.md b/README.md
index 9b88431a..16878306 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb
index 88baf2d7..5eee82c0 100644
--- a/app/controllers/api/v1/photos_controller.rb
+++ b/app/controllers/api/v1/photos_controller.rb
@@ -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
diff --git a/app/controllers/api/v1/settings_controller.rb b/app/controllers/api/v1/settings_controller.rb
index 660f88e0..f87d9df7 100644
--- a/app/controllers/api/v1/settings_controller.rb
+++ b/app/controllers/api/v1/settings_controller.rb
@@ -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
diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb
index 8442bb94..243189cf 100644
--- a/app/controllers/settings_controller.rb
+++ b/app/controllers/settings_controller.rb
@@ -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
diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js
index 20e058ee..338444cf 100644
--- a/app/javascript/controllers/maps_controller.js
+++ b/app/javascript/controllers/maps_controller.js
@@ -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 {
${photo.originalFileName}
Taken: ${new Date(photo.localDateTime).toLocaleString()}
Location: ${photo.exifInfo.city}, ${photo.exifInfo.state}, ${photo.exifInfo.country}
- ${photo.type === 'VIDEO' ? '🎥 Video' : '📷 Photo'}
+ ${photo.type === 'video' ? '🎥 Video' : '📷 Photo'}
`;
marker.bindPopup(popupContent);
diff --git a/app/javascript/controllers/trips_controller.js b/app/javascript/controllers/trips_controller.js
index 00a2b497..497fe5e3 100644
--- a/app/javascript/controllers/trips_controller.js
+++ b/app/javascript/controllers/trips_controller.js
@@ -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;
}
diff --git a/app/javascript/maps/helpers.js b/app/javascript/maps/helpers.js
index 9103f122..7fdcdbca 100644
--- a/app/javascript/maps/helpers.js
+++ b/app/javascript/maps/helpers.js
@@ -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 = `
`;
diff --git a/app/jobs/enqueue_background_job.rb b/app/jobs/enqueue_background_job.rb
index aa5cdccf..61e103c3 100644
--- a/app/jobs/enqueue_background_job.rb
+++ b/app/jobs/enqueue_background_job.rb
@@ -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
diff --git a/app/jobs/import/photoprism_geodata_job.rb b/app/jobs/import/photoprism_geodata_job.rb
new file mode 100644
index 00000000..7aa2d27e
--- /dev/null
+++ b/app/jobs/import/photoprism_geodata_job.rb
@@ -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
diff --git a/app/models/import.rb b/app/models/import.rb
index c6e5a8a6..067baf12 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -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!
diff --git a/app/models/user.rb b/app/models/user.rb
index a102d0b5..53adfa2d 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -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
diff --git a/app/serializers/api/photo_serializer.rb b/app/serializers/api/photo_serializer.rb
new file mode 100644
index 00000000..5e3ce9a5
--- /dev/null
+++ b/app/serializers/api/photo_serializer.rb
@@ -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
diff --git a/app/services/immich/import_geodata.rb b/app/services/immich/import_geodata.rb
index 766643a7..469761d6 100644
--- a/app/services/immich/import_geodata.rb
+++ b/app/services/immich/import_geodata.rb
@@ -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)
diff --git a/app/services/immich/request_photos.rb b/app/services/immich/request_photos.rb
index 0f1eabc7..034a6452 100644
--- a/app/services/immich/request_photos.rb
+++ b/app/services/immich/request_photos.rb
@@ -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
diff --git a/app/services/imports/create.rb b/app/services/imports/create.rb
index 4ce3e7c2..7c34cc1f 100644
--- a/app/services/imports/create.rb
+++ b/app/services/imports/create.rb
@@ -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
diff --git a/app/services/photoprism/cache_preview_token.rb b/app/services/photoprism/cache_preview_token.rb
new file mode 100644
index 00000000..da16166c
--- /dev/null
+++ b/app/services/photoprism/cache_preview_token.rb
@@ -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
diff --git a/app/services/photoprism/import_geodata.rb b/app/services/photoprism/import_geodata.rb
new file mode 100644
index 00000000..182681e6
--- /dev/null
+++ b/app/services/photoprism/import_geodata.rb
@@ -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
diff --git a/app/services/photoprism/request_photos.rb b/app/services/photoprism/request_photos.rb
new file mode 100644
index 00000000..276e7e5c
--- /dev/null
+++ b/app/services/photoprism/request_photos.rb
@@ -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
diff --git a/app/services/immich/import_parser.rb b/app/services/photos/import_parser.rb
similarity index 97%
rename from app/services/immich/import_parser.rb
rename to app/services/photos/import_parser.rb
index b0a2a38c..97b9c9d4 100644
--- a/app/services/immich/import_parser.rb
+++ b/app/services/photos/import_parser.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Immich::ImportParser
+class Photos::ImportParser
include Imports::Broadcaster
attr_reader :import, :json, :user_id
diff --git a/app/services/photos/search.rb b/app/services/photos/search.rb
new file mode 100644
index 00000000..20046268
--- /dev/null
+++ b/app/services/photos/search.rb
@@ -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
diff --git a/app/services/photos/thumbnail.rb b/app/services/photos/thumbnail.rb
new file mode 100644
index 00000000..6bdb7fd5
--- /dev/null
+++ b/app/services/photos/thumbnail.rb
@@ -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
diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb
index 32ab69ff..b3f8cbfc 100644
--- a/app/views/imports/index.html.erb
+++ b/app/views/imports/index.html.erb
@@ -10,6 +10,11 @@
<% else %>
Import Immich data
<% 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 %>
+ Import Photoprism data
+ <% end %>
diff --git a/app/views/settings/_navigation.html.erb b/app/views/settings/_navigation.html.erb
index 7232cc1c..b0b20437 100644
--- a/app/views/settings/_navigation.html.erb
+++ b/app/views/settings/_navigation.html.erb
@@ -1,5 +1,5 @@
- <%= 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)}" %>
diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb
index eb8e605f..613cfe73 100644
--- a/app/views/settings/index.html.erb
+++ b/app/views/settings/index.html.erb
@@ -5,7 +5,7 @@
-
Edit your Dawarich settings!
+ Edit your Integrations settings!
<%= form_for :settings, url: settings_path, method: :patch, data: { turbo_method: :patch, turbo: false } do |f| %>
<%= 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' %>
+
+
+ <%= 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' %>
+
+
+ <%= f.label :photoprism_api_key %>
+ <%= f.text_field :photoprism_api_key, value: current_user.settings['photoprism_api_key'], class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
+
+
<%= f.submit "Update", class: "btn btn-primary" %>
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index 2d4e654f..f3fe9d7b 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -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
diff --git a/spec/models/import_spec.rb b/spec/models/import_spec.rb
index 3ac6130d..2e04a91d 100644
--- a/spec/models/import_spec.rb
+++ b/spec/models/import_spec.rb
@@ -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
diff --git a/spec/requests/api/v1/photos_spec.rb b/spec/requests/api/v1/photos_spec.rb
index d15a5342..8c8811b6 100644
--- a/spec/requests/api/v1/photos_spec.rb
+++ b/spec/requests/api/v1/photos_spec.rb
@@ -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
diff --git a/spec/serializers/api/photo_serializer_spec.rb b/spec/serializers/api/photo_serializer_spec.rb
new file mode 100644
index 00000000..3dad077a
--- /dev/null
+++ b/spec/serializers/api/photo_serializer_spec.rb
@@ -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
diff --git a/spec/services/immich/request_photos_spec.rb b/spec/services/immich/request_photos_spec.rb
index 255e6cd0..041ed4fd 100644
--- a/spec/services/immich/request_photos_spec.rb
+++ b/spec/services/immich/request_photos_spec.rb
@@ -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
diff --git a/spec/services/imports/create_spec.rb b/spec/services/imports/create_spec.rb
new file mode 100644
index 00000000..d35e1898
--- /dev/null
+++ b/spec/services/imports/create_spec.rb
@@ -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
diff --git a/spec/services/photoprism/cache_preview_token_spec.rb b/spec/services/photoprism/cache_preview_token_spec.rb
new file mode 100644
index 00000000..298aee98
--- /dev/null
+++ b/spec/services/photoprism/cache_preview_token_spec.rb
@@ -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
diff --git a/spec/services/photoprism/import_geodata_spec.rb b/spec/services/photoprism/import_geodata_spec.rb
new file mode 100644
index 00000000..341348fc
--- /dev/null
+++ b/spec/services/photoprism/import_geodata_spec.rb
@@ -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
diff --git a/spec/services/photoprism/request_photos_spec.rb b/spec/services/photoprism/request_photos_spec.rb
new file mode 100644
index 00000000..a4461151
--- /dev/null
+++ b/spec/services/photoprism/request_photos_spec.rb
@@ -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
diff --git a/spec/services/immich/import_parser_spec.rb b/spec/services/photos/import_parser_spec.rb
similarity index 97%
rename from spec/services/immich/import_parser_spec.rb
rename to spec/services/photos/import_parser_spec.rb
index cefa4dc6..33460398 100644
--- a/spec/services/immich/import_parser_spec.rb
+++ b/spec/services/photos/import_parser_spec.rb
@@ -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 }
diff --git a/spec/services/photos/search_spec.rb b/spec/services/photos/search_spec.rb
new file mode 100644
index 00000000..0ce34613
--- /dev/null
+++ b/spec/services/photos/search_spec.rb
@@ -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
diff --git a/spec/services/photos/thumbnail_spec.rb b/spec/services/photos/thumbnail_spec.rb
new file mode 100644
index 00000000..c687e370
--- /dev/null
+++ b/spec/services/photos/thumbnail_spec.rb
@@ -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
diff --git a/spec/swagger/api/v1/photos_controller_spec.rb b/spec/swagger/api/v1/photos_controller_spec.rb
index eb3cb737..eef5d9a5 100644
--- a/spec/swagger/api/v1/photos_controller_spec.rb
+++ b/spec/swagger/api/v1/photos_controller_spec.rb
@@ -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)
diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml
index 3ecfb855..6657aebf 100644
--- a/swagger/v1/swagger.yaml
+++ b/swagger/v1/swagger.yaml
@@ -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":