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 = `
-

${photo.originalFileName}

Taken: ${new Date(photo.localDateTime).toLocaleString()}

-

Location: ${photo.exifInfo.city}, ${photo.exifInfo.state}, ${photo.exifInfo.country}

+

Location: ${photo.city}, ${photo.state}, ${photo.country}

+

Source: ${photo.source}

${photo.type === 'VIDEO' ? '🎥 Video' : '📷 Photo'}
`; 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":