diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb index df042494..88baf2d7 100644 --- a/app/controllers/api/v1/photos_controller.rb +++ b/app/controllers/api/v1/photos_controller.rb @@ -32,7 +32,6 @@ class Api::V1::PhotosController < ApiController status: :ok ) else - Rails.logger.error "Failed to fetch thumbnail: #{response.code} - #{response.body}" render json: { error: 'Failed to fetch thumbnail' }, status: response.code end end diff --git a/app/services/immich/import_geodata.rb b/app/services/immich/import_geodata.rb index 38c34c58..766643a7 100644 --- a/app/services/immich/import_geodata.rb +++ b/app/services/immich/import_geodata.rb @@ -3,16 +3,13 @@ class Immich::ImportGeodata attr_reader :user, :start_date, :end_date - def initialize(user, end_date:, start_date: '1970-01-01') + def initialize(user, start_date: '1970-01-01', end_date: nil) @user = user @start_date = start_date @end_date = end_date end def call - raise ArgumentError, 'Immich API key is missing' if immich_api_key.blank? - raise ArgumentError, 'Immich URL is missing' if user.settings['immich_url'].blank? - immich_data = retrieve_immich_data log_no_data and return if immich_data.empty? diff --git a/app/services/immich/request_photos.rb b/app/services/immich/request_photos.rb index 9073ad9e..f553207c 100644 --- a/app/services/immich/request_photos.rb +++ b/app/services/immich/request_photos.rb @@ -12,6 +12,9 @@ class Immich::RequestPhotos end def call + raise ArgumentError, 'Immich API key is missing' if immich_api_key.blank? + raise ArgumentError, 'Immich URL is missing' if user.settings['immich_url'].blank? + data = retrieve_immich_data time_framed_data(data) diff --git a/config/routes.rb b/config/routes.rb index dc7730b0..430ba885 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -79,7 +79,7 @@ Rails.application.routes.draw do resources :borders, only: :index end - resources :photos do + resources :photos, only: %i[index] do member do get 'thumbnail', constraints: { id: %r{[^/]+} } end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 41a5035d..2d4e654f 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -22,5 +22,14 @@ FactoryBot.define do trait :admin do admin { true } end + + trait :with_immich_credentials do + settings do + { + immich_url: 'https://immich.example.com', + immich_api_key: '1234567890' + } + end + end end end diff --git a/spec/requests/api/v1/photos_spec.rb b/spec/requests/api/v1/photos_spec.rb index dc45702c..e3f3d32c 100644 --- a/spec/requests/api/v1/photos_spec.rb +++ b/spec/requests/api/v1/photos_spec.rb @@ -1,11 +1,44 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe "Api::V1::Photos", type: :request do - describe "GET /index" do - it "returns http success" do - get "/api/v1/photos/index" - expect(response).to have_http_status(:success) +RSpec.describe 'Api::V1::Photos', type: :request do + describe 'GET /index' do + let(:user) { create(:user) } + + let(:photo_data) do + [ + { + 'id' => '123', + 'latitude' => 35.6762, + 'longitude' => 139.6503, + 'createdAt' => '2024-01-01T00:00:00.000Z', + 'type' => 'photo' + }, + { + 'id' => '456', + 'latitude' => 40.7128, + 'longitude' => -74.0060, + 'createdAt' => '2024-01-02T00:00:00.000Z', + 'type' => 'photo' + } + ] + end + + context 'when the request is successful' do + before do + allow_any_instance_of(Immich::RequestPhotos).to receive(:call).and_return(photo_data) + + get '/api/v1/photos', params: { api_key: user.api_key } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'returns photos data as JSON' do + expect(JSON.parse(response.body)).to eq(photo_data) + end end end - end diff --git a/spec/swagger/api/v1/photos_controller_spec.rb b/spec/swagger/api/v1/photos_controller_spec.rb new file mode 100644 index 00000000..eb3cb737 --- /dev/null +++ b/spec/swagger/api/v1/photos_controller_spec.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'Api::V1::PhotosController', type: :request do + let(:user) { create(:user, :with_immich_credentials) } + let(:api_key) { user.api_key } + let(:start_date) { '2024-01-01' } + let(:end_date) { '2024-01-02' } + let!(:immich_image) do + { + "id": '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c', + "deviceAssetId": 'IMG_9913.jpeg-1168914', + "ownerId": 'f579f328-c355-438c-a82c-fe3390bd5f08', + "deviceId": 'CLI', + "libraryId": nil, + "type": 'IMAGE', + "originalPath": 'upload/library/admin/2023/2023-06-08/IMG_9913.jpeg', + "originalFileName": 'IMG_9913.jpeg', + "originalMimeType": 'image/jpeg', + "thumbhash": '4RgONQaZqYaH93g3h3p3d6RfPPrG', + "fileCreatedAt": '2023-06-08T07:58:45.637Z', + "fileModifiedAt": '2023-06-08T09:58:45.000Z', + "localDateTime": '2024-01-01T09:58:45.637Z', + "updatedAt": '2024-08-24T18:20:47.965Z', + "isFavorite": false, + "isArchived": false, + "isTrashed": false, + "duration": '0:00:00.00000', + "exifInfo": { + "make": 'Apple', + "model": 'iPhone 12 Pro', + "exifImageWidth": 4032, + "exifImageHeight": 3024, + "fileSizeInByte": 1_168_914, + "orientation": '6', + "dateTimeOriginal": '2023-06-08T07:58:45.637Z', + "modifyDate": '2023-06-08T07:58:45.000Z', + "timeZone": 'Europe/Berlin', + "lensModel": 'iPhone 12 Pro back triple camera 4.2mm f/1.6', + "fNumber": 1.6, + "focalLength": 4.2, + "iso": 320, + "exposureTime": '1/60', + "latitude": 52.11, + "longitude": 13.22, + "city": 'Johannisthal', + "state": 'Berlin', + "country": 'Germany', + "description": '', + "projectionType": nil, + "rating": nil + }, + "livePhotoVideoId": nil, + "people": [], + "checksum": 'aL1edPVg4ZpEnS6xCRWNUY0pUS8=', + "isOffline": false, + "hasMetadata": true, + "duplicateId": '88a34bee-783d-46e4-aa52-33b75ffda375', + "resized": true + } + end + let(:immich_data) do + { + "albums": { + "total": 0, + "count": 0, + "items": [], + "facets": [] + }, + "assets": { + "total": 1000, + "count": 1000, + "items": [immich_image] + } + }.to_json + end + + before do + stub_request(:post, "#{user.settings['immich_url']}/api/search/metadata") + .to_return(status: 200, body: immich_data) + + stub_request(:get, "#{user.settings['immich_url']}/api/assets/7fe486e3-c3ba-4b54-bbf9-1281b39ed15c/thumbnail?size=preview") + .to_return(status: 200, body: immich_image.to_json, headers: {}) + + stub_request(:get, "#{user.settings['immich_url']}/api/assets/nonexistent/thumbnail?size=preview") + .to_return(status: 404, body: [].to_json, headers: {}) + end + + path '/api/v1/photos' do + get 'Lists photos' do + tags 'Photos' + produces 'application/json' + parameter name: :api_key, in: :query, type: :string, required: true + parameter name: :start_date, in: :query, type: :string, required: true, + description: 'Start date in ISO8601 format, e.g. 2024-01-01' + parameter name: :end_date, in: :query, type: :string, required: true, + description: 'End date in ISO8601 format, e.g. 2024-01-02' + + response '200', 'photos found' do + schema type: :array, + items: { + type: :object, + properties: { + id: { type: :string }, + deviceAssetId: { type: :string }, + ownerId: { type: :string }, + type: { type: :string }, + originalPath: { type: :string }, + originalFileName: { type: :string }, + originalMimeType: { type: :string }, + thumbhash: { type: :string }, + fileCreatedAt: { type: :string, format: 'date-time' }, + fileModifiedAt: { type: :string, format: 'date-time' }, + localDateTime: { type: :string, format: 'date-time' }, + updatedAt: { type: :string, format: 'date-time' }, + isFavorite: { type: :boolean }, + isArchived: { type: :boolean }, + isTrashed: { type: :boolean }, + duration: { type: :string }, + exifInfo: { + type: :object, + properties: { + make: { type: :string }, + model: { type: :string }, + exifImageWidth: { type: :integer }, + exifImageHeight: { type: :integer }, + fileSizeInByte: { type: :integer }, + orientation: { type: :string }, + dateTimeOriginal: { type: :string, format: 'date-time' }, + modifyDate: { type: :string, format: 'date-time' }, + timeZone: { type: :string }, + lensModel: { type: :string }, + fNumber: { type: :number, format: :float }, + focalLength: { type: :number, format: :float }, + iso: { type: :integer }, + exposureTime: { type: :string }, + latitude: { type: :number, format: :float }, + longitude: { type: :number, format: :float }, + city: { type: :string }, + state: { type: :string }, + country: { type: :string }, + description: { type: :string }, + projectionType: { type: %i[string null] }, + rating: { type: %i[integer null] } + } + }, + checksum: { type: :string }, + isOffline: { type: :boolean }, + hasMetadata: { type: :boolean }, + duplicateId: { type: :string }, + resized: { type: :boolean } + }, + required: %w[id deviceAssetId ownerId type originalPath + originalFileName originalMimeType thumbhash + fileCreatedAt fileModifiedAt localDateTime + updatedAt isFavorite isArchived isTrashed duration + exifInfo checksum isOffline hasMetadata duplicateId resized] + } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data).to be_an(Array) + end + end + end + end + + path '/api/v1/photos/{id}/thumbnail' do + get 'Retrieves a photo' do + tags 'Photos' + produces 'application/json' + parameter name: :id, in: :path, type: :string, required: true + parameter name: :api_key, in: :query, type: :string, required: true + + response '200', 'photo found' do + schema type: :object, + properties: { + id: { type: :string }, + deviceAssetId: { type: :string }, + ownerId: { type: :string }, + type: { type: :string }, + originalPath: { type: :string }, + originalFileName: { type: :string }, + originalMimeType: { type: :string }, + thumbhash: { type: :string }, + fileCreatedAt: { type: :string, format: 'date-time' }, + fileModifiedAt: { type: :string, format: 'date-time' }, + localDateTime: { type: :string, format: 'date-time' }, + updatedAt: { type: :string, format: 'date-time' }, + isFavorite: { type: :boolean }, + isArchived: { type: :boolean }, + isTrashed: { type: :boolean }, + duration: { type: :string }, + exifInfo: { + type: :object, + properties: { + make: { type: :string }, + model: { type: :string }, + exifImageWidth: { type: :integer }, + exifImageHeight: { type: :integer }, + fileSizeInByte: { type: :integer }, + orientation: { type: :string }, + dateTimeOriginal: { type: :string, format: 'date-time' }, + modifyDate: { type: :string, format: 'date-time' }, + timeZone: { type: :string }, + lensModel: { type: :string }, + fNumber: { type: :number, format: :float }, + focalLength: { type: :number, format: :float }, + iso: { type: :integer }, + exposureTime: { type: :string }, + latitude: { type: :number, format: :float }, + longitude: { type: :number, format: :float }, + city: { type: :string }, + state: { type: :string }, + country: { type: :string }, + description: { type: :string }, + projectionType: { type: %i[string null] }, + rating: { type: %i[integer null] } + } + }, + checksum: { type: :string }, + isOffline: { type: :boolean }, + hasMetadata: { type: :boolean }, + duplicateId: { type: :string }, + resized: { type: :boolean } + } + + let(:id) { '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c' } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data).to be_a(Hash) + expect(data['id']).to eq(id) + end + end + + response '404', 'photo not found' do + let(:id) { 'nonexistent' } + let(:api_key) { user.api_key } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['error']).to eq('Failed to fetch thumbnail') + end + end + end + end +end diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 77a58f8d..3ecfb855 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -312,6 +312,294 @@ paths: isorcv: '2024-02-03T13:00:03Z' isotst: '2024-02-03T13:00:03Z' disptst: '2024-02-03 13:00:03' + "/api/v1/photos": + get: + summary: Lists photos + tags: + - Photos + parameters: + - name: api_key + in: query + required: true + schema: + type: string + - name: start_date + in: query + required: true + description: Start date in ISO8601 format, e.g. 2024-01-01 + schema: + type: string + - name: end_date + in: query + required: true + description: End date in ISO8601 format, e.g. 2024-01-02 + schema: + type: string + responses: + '200': + description: photos found + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + deviceAssetId: + type: string + ownerId: + type: string + type: + type: string + originalPath: + type: string + originalFileName: + type: string + originalMimeType: + type: string + thumbhash: + type: string + fileCreatedAt: + type: string + format: date-time + fileModifiedAt: + type: string + format: date-time + localDateTime: + type: string + format: date-time + updatedAt: + type: string + format: date-time + isFavorite: + type: boolean + isArchived: + type: boolean + isTrashed: + type: boolean + duration: + type: string + exifInfo: + type: object + properties: + make: + type: string + model: + type: string + exifImageWidth: + type: integer + exifImageHeight: + type: integer + fileSizeInByte: + type: integer + orientation: + type: string + dateTimeOriginal: + type: string + format: date-time + modifyDate: + type: string + format: date-time + timeZone: + type: string + lensModel: + type: string + fNumber: + type: number + format: float + focalLength: + type: number + format: float + iso: + type: integer + exposureTime: + type: string + latitude: + type: number + format: float + longitude: + type: number + format: float + city: + type: string + state: + type: string + country: + type: string + description: + type: string + projectionType: + type: + - string + - 'null' + rating: + type: + - integer + - 'null' + checksum: + type: string + isOffline: + type: boolean + hasMetadata: + type: boolean + duplicateId: + type: string + resized: + type: boolean + required: + - id + - deviceAssetId + - ownerId + - type + - originalPath + - originalFileName + - originalMimeType + - thumbhash + - fileCreatedAt + - fileModifiedAt + - localDateTime + - updatedAt + - isFavorite + - isArchived + - isTrashed + - duration + - exifInfo + - checksum + - isOffline + - hasMetadata + - duplicateId + - resized + "/api/v1/photos/{id}/thumbnail": + get: + summary: Retrieves a photo + tags: + - Photos + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: api_key + in: query + required: true + schema: + type: string + responses: + '200': + description: photo found + content: + application/json: + schema: + type: object + properties: + id: + type: string + deviceAssetId: + type: string + ownerId: + type: string + type: + type: string + originalPath: + type: string + originalFileName: + type: string + originalMimeType: + type: string + thumbhash: + type: string + fileCreatedAt: + type: string + format: date-time + fileModifiedAt: + type: string + format: date-time + localDateTime: + type: string + format: date-time + updatedAt: + type: string + format: date-time + isFavorite: + type: boolean + isArchived: + type: boolean + isTrashed: + type: boolean + duration: + type: string + exifInfo: + type: object + properties: + make: + type: string + model: + type: string + exifImageWidth: + type: integer + exifImageHeight: + type: integer + fileSizeInByte: + type: integer + orientation: + type: string + dateTimeOriginal: + type: string + format: date-time + modifyDate: + type: string + format: date-time + timeZone: + type: string + lensModel: + type: string + fNumber: + type: number + format: float + focalLength: + type: number + format: float + iso: + type: integer + exposureTime: + type: string + latitude: + type: number + format: float + longitude: + type: number + format: float + city: + type: string + state: + type: string + country: + type: string + description: + type: string + projectionType: + type: + - string + - 'null' + rating: + type: + - integer + - 'null' + checksum: + type: string + isOffline: + type: boolean + hasMetadata: + type: boolean + duplicateId: + type: string + resized: + type: boolean + '404': + description: photo not found "/api/v1/points": get: summary: Retrieves all points