Implement the import of geodata from Immich to Dawarich

This commit is contained in:
Eugene Burmakin 2024-08-21 18:40:54 +02:00
parent e0438e01d1
commit 7652dcce76
29 changed files with 429 additions and 30 deletions

View file

@ -1 +1 @@
0.10.0 0.11.0

View file

@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/) The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/). and this project adheres to [Semantic Versioning](http://semver.org/).
## [0.11.0] — 2024-08-21
### Added
- A user can now trigger the import of their geodata from Immich to Dawarich by clicking the "Import Immich data" button in the Imports page.
- A user can now provide a url and an api key for their Immich instance and then trigger the import of their geodata from Immich to Dawarich. This can be done in the Settings page.
### Changed
- Table columns on the Exports page were reordered to make it more user-friendly.
- Exports are now being named with this pattern: "export_from_dd.mm.yyyy_to_dd.mm.yyyy.json" where "dd.mm.yyyy" is the date range of the export.
- Notification about any error now will include the stacktrace.
## [0.10.0] — 2024-08-20 ## [0.10.0] — 2024-08-20
### Added ### Added

View file

@ -9,6 +9,7 @@ gem 'chartkick'
gem 'data_migrate' gem 'data_migrate'
gem 'devise' gem 'devise'
gem 'geocoder' gem 'geocoder'
gem 'httparty'
gem 'importmap-rails' gem 'importmap-rails'
gem 'kaminari' gem 'kaminari'
gem 'lograge' gem 'lograge'

View file

@ -138,6 +138,10 @@ GEM
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
hashdiff (1.1.0) hashdiff (1.1.0)
httparty (0.22.0)
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.14.5) i18n (1.14.5)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
importmap-rails (2.0.1) importmap-rails (2.0.1)
@ -182,6 +186,8 @@ GEM
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.25.1) minitest (5.25.1)
msgpack (1.7.2) msgpack (1.7.2)
multi_xml (0.7.1)
bigdecimal (~> 3.1)
mutex_m (0.2.0) mutex_m (0.2.0)
net-imap (0.4.12) net-imap (0.4.12)
date date
@ -425,6 +431,7 @@ DEPENDENCIES
ffaker ffaker
foreman foreman
geocoder geocoder
httparty
importmap-rails importmap-rails
kaminari kaminari
lograge lograge

File diff suppressed because one or more lines are too long

View file

@ -9,7 +9,7 @@ class ExportsController < ApplicationController
end end
def create def create
export_name = "#{params[:start_at].to_date}_#{params[:end_at].to_date}" export_name = "export_from_#{params[:start_at].to_date}_to_#{params[:end_at].to_date}"
export = current_user.exports.create(name: export_name, status: :created) export = current_user.exports.create(name: export_name, status: :created)
ExportJob.perform_later(export.id, params[:start_at], params[:end_at]) ExportJob.perform_later(export.id, params[:start_at], params[:end_at])

View file

@ -9,7 +9,7 @@ class Settings::BackgroundJobsController < ApplicationController
end end
def create def create
EnqueueReverseGeocodingJob.perform_later(params[:job_name], current_user.id) EnqueueBackgroundJob.perform_later(params[:job_name], current_user.id)
flash.now[:notice] = 'Job was successfully created.' flash.now[:notice] = 'Job was successfully created.'

View file

@ -3,8 +3,7 @@
class SettingsController < ApplicationController class SettingsController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
def index def index; end
end
def update def update
current_user.update(settings: settings_params) current_user.update(settings: settings_params)
@ -31,7 +30,8 @@ class SettingsController < ApplicationController
def settings_params def settings_params
params.require(:settings).permit( params.require(:settings).permit(
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters, :meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity :time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
:immich_url, :immich_api_key
) )
end end
end end

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
class EnqueueBackgroundJob < ApplicationJob
queue_as :reverse_geocoding
def perform(job_name, user_id)
case job_name
when 'start_immich_import'
ImportImmichGeodataJob.perform_later(user_id)
when 'start_reverse_geocoding', 'continue_reverse_geocoding'
Jobs::Create.new(job_name, user_id).call
else
raise ArgumentError, "Unknown job name: #{job_name}"
end
end
end

View file

@ -1,9 +0,0 @@
# frozen_string_literal: true
class EnqueueReverseGeocodingJob < ApplicationJob
queue_as :reverse_geocoding
def perform(job_name, user_id)
Jobs::Create.new(job_name, user_id).call
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class ImportImmichGeodataJob < ApplicationJob
queue_as :imports
def perform(user_id)
user = User.find(user_id)
Immich::ImportGeodata.new(user).call
end
end

View file

@ -26,19 +26,21 @@ class ImportJob < ApplicationJob
user:, user:,
kind: :error, kind: :error,
title: 'Import failed', title: 'Import failed',
content: "Import \"#{import.name}\" failed: #{e.message}" content: "Import \"#{import.name}\" failed: #{e.message}, stacktrace: #{e.backtrace.join("\n")}"
).call ).call
end end
private private
def parser(source) def parser(source)
# Bad classes naming by the way, they are not parsers, they are point creators
case source case source
when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser when 'google_semantic_history' then GoogleMaps::SemanticHistoryParser
when 'google_records' then GoogleMaps::RecordsParser when 'google_records' then GoogleMaps::RecordsParser
when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutParser
when 'owntracks' then OwnTracks::ExportParser when 'owntracks' then OwnTracks::ExportParser
when 'gpx' then Gpx::TrackParser when 'gpx' then Gpx::TrackParser
when 'immich_api' then Immich::ImportParser
end end
end end
end end

View file

@ -8,5 +8,8 @@ class Import < ApplicationRecord
include ImportUploader::Attachment(:raw) include ImportUploader::Attachment(:raw)
enum source: { google_semantic_history: 0, owntracks: 1, google_records: 2, google_phone_takeout: 3, gpx: 4 } enum source: {
google_semantic_history: 0, owntracks: 1, google_records: 2,
google_phone_takeout: 3, gpx: 4, immich_api: 5
}
end end

View file

@ -29,7 +29,12 @@ class CreateStats
Notifications::Create.new(user:, kind: :info, title: 'Stats updated', content: 'Stats updated').call Notifications::Create.new(user:, kind: :info, title: 'Stats updated', content: 'Stats updated').call
rescue StandardError => e rescue StandardError => e
Notifications::Create.new(user:, kind: :error, title: 'Stats update failed', content: e.message).call Notifications::Create.new(
user:,
kind: :error,
title: 'Stats update failed',
content: "#{e.message}, stacktrace: #{e.backtrace.join("\n")}"
).call
end end
end end

View file

@ -37,7 +37,7 @@ class Exports::Create
user:, user:,
kind: :error, kind: :error,
title: 'Export failed', title: 'Export failed',
content: "Export \"#{export.name}\" failed: #{e.message}" content: "Export \"#{export.name}\" failed: #{e.message}, stacktrace: #{e.backtrace.join("\n")}"
).call ).call
export.update!(status: :failed) export.update!(status: :failed)

View file

@ -0,0 +1,102 @@
# frozen_string_literal: true
class Immich::ImportGeodata
attr_reader :user, :immich_api_base_url, :immich_api_key
def initialize(user)
@user = user
@immich_api_base_url = "#{user.settings['immich_url']}/api"
@immich_api_key = user.settings['immich_api_key']
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
immich_data_json = parse_immich_data(immich_data)
file_name = file_name(immich_data_json)
import = user.imports.find_or_initialize_by(name: file_name, source: :immich_api)
create_import_failed_notification and return unless import.new_record?
import.raw_data = immich_data_json
import.save!
ImportJob.perform_later(user.id, import.id)
end
private
def headers
{
'x-api-key' => immich_api_key,
'accept' => 'application/json'
}
end
def retrieve_immich_data
(1..12).flat_map do |month_number|
(1..31).map do |day|
url = "#{immich_api_base_url}/assets/memory-lane?day=#{day}&month=#{month_number}"
JSON.parse(HTTParty.get(url, headers:).body)
end
end
end
def valid?(asset)
asset.dig('exifInfo', 'latitude') &&
asset.dig('exifInfo', 'longitude') &&
asset.dig('exifInfo', 'dateTimeOriginal')
end
def parse_immich_data(immich_data)
geodata = []
immich_data.each do |memory_lane|
log_no_data and next if memory_lane_invalid?(memory_lane)
assets = extract_assets(memory_lane)
assets.each { |asset| geodata << extract_geodata(asset) if valid?(asset) }
end
geodata.sort_by { |data| data[:timestamp] }
end
def memory_lane_invalid?(memory_lane)
memory_lane.is_a?(Hash) && memory_lane['statusCode'] == 404
end
def extract_assets(memory_lane)
memory_lane.flat_map { |lane| lane['assets'] }.compact
end
def extract_geodata(asset)
{
latitude: asset.dig('exifInfo', 'latitude'),
longitude: asset.dig('exifInfo', 'longitude'),
timestamp: Time.zone.parse(asset.dig('exifInfo', 'dateTimeOriginal')).to_i
}
end
def log_no_data
Rails.logger.debug 'No data found'
end
def create_import_failed_notification
Notifications::Create.new(
user:,
kind: :info,
title: 'Import was not created',
content: 'Import with the same name already exists. If you want to proceed, delete the existing import and try again.'
).call
end
def file_name(immich_data_json)
from = Time.zone.at(immich_data_json.first[:timestamp]).to_date
to = Time.zone.at(immich_data_json.last[:timestamp]).to_date
"immich-geodata-#{user.email}-from-#{from}-to-#{to}.json"
end
end

View file

@ -0,0 +1,40 @@
# frozen_string_literal: true
class Immich::ImportParser
attr_reader :import, :json, :user_id
def initialize(import, user_id)
@import = import
@json = import.raw_data
@user_id = user_id
end
def call
json.each { |point| create_point(point) }
end
def create_point(point)
return 0 if point['latitude'].blank? || point['longitude'].blank? || point['timestamp'].blank?
return 0 if point_exists?(point, point['timestamp'])
Point.create(
latitude: point['latitude'].to_d,
longitude: point['longitude'].to_d,
timestamp: point['timestamp'],
raw_data: point,
import_id: import.id,
user_id:
)
1
end
def point_exists?(point, timestamp)
Point.exists?(
latitude: point['latitude'].to_d,
longitude: point['longitude'].to_d,
timestamp:,
user_id:
)
end
end

View file

@ -28,15 +28,16 @@
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Created at</th>
<th>Status</th> <th>Status</th>
<th>Actions</th> <th>Actions</th>
<th>Created at</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<% @exports.each do |export| %> <% @exports.each do |export| %>
<tr> <tr>
<td><%= export.name %></td> <td><%= export.name %></td>
<td><%= export.created_at.strftime('%Y-%m-%d %H:%M:%S') %></td>
<td><%= export.status %></td> <td><%= export.status %></td>
<td> <td>
<% if export.completed? %> <% if export.completed? %>
@ -44,7 +45,6 @@
<% end %> <% end %>
<%= link_to 'Delete', export, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %> <%= link_to 'Delete', export, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %>
</td> </td>
<td><%= export.created_at.strftime('%Y-%m-%d %H:%M:%S') %></td>
</tr> </tr>
<% end %> <% end %>
</tbody> </tbody>

View file

@ -1,9 +1,15 @@
<% content_for :title, 'Imports' %> <% content_for :title, 'Imports' %>
<div class="w-full"> <div class="w-full">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center mb-3">
<h1 class="font-bold text-4xl">Imports</h1> <h1 class="font-bold text-4xl">Imports</h1>
<%= link_to "New import", new_import_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %> <%= link_to "New import", new_import_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
<% if current_user.settings['immich_url'] && current_user.settings['immich_api_key'] %>
<%= link_to 'Import Immich data', settings_background_jobs_path(job_name: 'start_immich_import'), method: :post, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %>
<% else %>
<a href='' class="rounded-lg py-3 px-5 bg-blue-900 text-gray block font-medium tooltip cursor-not-allowed" data-tip="You need to provide your Immich instance data in the Settings">Import Immich data</a>
<% end %>
</div> </div>
<div id="imports" class="min-w-full"> <div id="imports" class="min-w-full">

View file

@ -164,6 +164,14 @@
<% end %> <% end %>
<%= f.number_field :route_opacity, value: current_user.settings['route_opacity'], class: "input input-bordered" %> <%= f.number_field :route_opacity, value: current_user.settings['route_opacity'], class: "input input-bordered" %>
</div> </div>
<div class="form-control my-2">
<%= f.label :immich_url %>
<%= f.text_field :immich_url, value: current_user.settings['immich_url'], class: "input input-bordered", placeholder: 'http://192.168.0.1:2283' %>
</div>
<div class="form-control my-2">
<%= f.label :immich_api_key %>
<%= f.text_field :immich_api_key, value: current_user.settings['immich_api_key'], class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
</div>
<div class="form-control my-2"> <div class="form-control my-2">
<%= f.submit "Update", class: "btn btn-primary" %> <%= f.submit "Update", class: "btn btn-primary" %>
</div> </div>

View file

@ -50,7 +50,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="text-lg font-black <%= 'underline decoration-dotted' if visit.pending? %>"> <div class="text-lg font-black <%= 'underline decoration-dotted' if visit.pending? %>">
<%= visit.area.name %> <%= visit&.area&.name %>
</div> </div>
<div> <div>
<%= "#{visit.started_at.strftime('%H:%M')} - #{visit.ended_at.strftime('%H:%M')}" %> <%= "#{visit.started_at.strftime('%H:%M')} - #{visit.ended_at.strftime('%H:%M')}" %>

12
spec/fixtures/files/immich/geodata.json vendored Normal file
View file

@ -0,0 +1,12 @@
[
{
"latitude": 59.0000,
"longitude": 30.0000,
"timestamp": 978296400
},
{
"latitude": 55.0001,
"longitude": 37.0001,
"timestamp": 978296400
}
]

View file

@ -0,0 +1,24 @@
[
[
{
"assets": [
{
"exifInfo": {
"dateTimeOriginal": "2022-12-31T23:17:06.170Z",
"latitude": 52.0000,
"longitude": 13.0000
}
},
{
"exifInfo": {
"dateTimeOriginal": "2022-12-31T23:21:53.140Z",
"latitude": 52.0000,
"longitude": 13.0000
}
}
],
"title": "1 year ago",
"yearsAgo": 1
}
]
]

View file

@ -2,13 +2,13 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe EnqueueReverseGeocodingJob, type: :job do RSpec.describe EnqueueBackgroundJob, type: :job do
let(:job_name) { 'start_reverse_geocoding' } let(:job_name) { 'start_reverse_geocoding' }
let(:user_id) { 1 } let(:user_id) { 1 }
it 'calls job creation service' do it 'calls job creation service' do
expect(Jobs::Create).to receive(:new).with(job_name, user_id).and_return(double(call: nil)) expect(Jobs::Create).to receive(:new).with(job_name, user_id).and_return(double(call: nil))
EnqueueReverseGeocodingJob.perform_now(job_name, user_id) EnqueueBackgroundJob.perform_now(job_name, user_id)
end end
end end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ImportImmichGeodataJob, type: :job do
describe '#perform' do
let(:user) { create(:user) }
it 'calls Immich::ImportGeodata' do
expect_any_instance_of(Immich::ImportGeodata).to receive(:call)
ImportImmichGeodataJob.perform_now(user.id)
end
end
end

View file

@ -1,3 +1,5 @@
# frozen_string_literal: true
require 'rails_helper' require 'rails_helper'
RSpec.describe Import, type: :model do RSpec.describe Import, type: :model do
@ -13,7 +15,8 @@ RSpec.describe Import, type: :model do
owntracks: 1, owntracks: 1,
google_records: 2, google_records: 2,
google_phone_takeout: 3, google_phone_takeout: 3,
gpx: 4 gpx: 4,
immich_api: 5
) )
end end
end end

View file

@ -46,7 +46,7 @@ RSpec.describe '/settings/background_jobs', type: :request do
it 'enqueues a new job' do it 'enqueues a new job' do
expect do expect do
post settings_background_jobs_url, params: post settings_background_jobs_url, params:
end.to have_enqueued_job(EnqueueReverseGeocodingJob) end.to have_enqueued_job(EnqueueBackgroundJob)
end end
it 'redirects to the created settings_background_job' do it 'redirects to the created settings_background_job' do

View file

@ -0,0 +1,92 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Immich::ImportGeodata do
describe '#call' do
subject(:service) { described_class.new(user).call }
let(:user) do
create(:user, settings: { 'immich_url' => 'http://immich.app', 'immich_api_key' => '123456' })
end
let(:immich_data) do
[
{
"assets": [
{
"exifInfo": {
"dateTimeOriginal": '2022-12-31T23:17:06.170Z',
"latitude": 52.0000,
"longitude": 13.0000
}
},
{
"exifInfo": {
"dateTimeOriginal": '2022-12-31T23:21:53.140Z',
"latitude": 52.0000,
"longitude": 13.0000
}
}
],
"title": '1 year ago',
"yearsAgo": 1
}
].to_json
end
context 'when user has immich_url and immich_api_key' do
before do
stub_request(
:any,
%r{http://immich\.app/api/assets/memory-lane\?day=(1[0-9]|2[0-9]|3[01]|[1-9])&month=(1[0-2]|[1-9])}
).to_return(status: 200, body: immich_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
context 'when user has no immich_url' do
before do
user.settings['immich_url'] = nil
user.save
end
it 'raises ArgumentError' do
expect { service }.to raise_error(ArgumentError, 'Immich URL is missing')
end
end
context 'when user has no immich_api_key' do
before do
user.settings['immich_api_key'] = nil
user.save
end
it 'raises ArgumentError' do
expect { service }.to raise_error(ArgumentError, 'Immich API key is missing')
end
end
end
end

View file

@ -0,0 +1,48 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Immich::ImportParser do
describe '#call' do
subject(:service) { described_class.new(import, user.id).call }
let(:user) do
create(:user, settings: { 'immich_url' => 'http://immich.app', 'immich_api_key' => '123456' })
end
let(:immich_data) do
JSON.parse(File.read(Rails.root.join('spec/fixtures/files/immich/geodata.json')))
end
let(:import) { create(:import, user:, raw_data: immich_data) }
context 'when there are no points' do
it 'creates new points' do
expect { service }.to change { Point.count }.by(2)
end
it 'creates points with correct attributes' do
service
expect(Point.first.latitude.to_f).to eq(59.0000)
expect(Point.first.longitude.to_f).to eq(30.0000)
expect(Point.first.timestamp).to eq(978_296_400)
expect(Point.first.import_id).to eq(import.id)
expect(Point.second.latitude.to_f).to eq(55.0001)
expect(Point.second.longitude.to_f).to eq(37.0001)
expect(Point.second.timestamp).to eq(978_296_400)
expect(Point.second.import_id).to eq(import.id)
end
end
context 'when there are points with the same coordinates' do
let!(:existing_point) do
create(:point, latitude: 59.0000, longitude: 30.0000, timestamp: 978_296_400, user:)
end
it 'creates only new points' do
expect { service }.to change { Point.count }.by(1)
end
end
end
end