Skip to content

Commit

Permalink
Merge pull request #445 from Freika/feature/immich-photos-integration
Browse files Browse the repository at this point in the history
Add immich photos to the map
  • Loading branch information
Freika authored Nov 26, 2024
2 parents 4efc5c4 + 3ee5654 commit 45cbaf4
Show file tree
Hide file tree
Showing 14 changed files with 1,094 additions and 130 deletions.
2 changes: 1 addition & 1 deletion .app_version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.16.9
0.17.0
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,31 @@ 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.17.0 - 2024-11-26

## The Immich Photos release

With this release, Dawarich can now show photos from your Immich instance on the map.

To enable this feature, you need to provide your Immich instance URL and API key in the Settings page. Then you need to enable "Photos" layer on the map (top right corner).

An important note to add here is that photos are heavy and hence generate a lot of traffic. The response from Immich for specific dates is being cached in Redis for 1 day, and that may lead to Redis taking a lot more space than previously. But since the cache is being expired after 24 hours, you'll get your space back pretty soon.

The other thing worth mentioning is how Dawarich gets data from Immich. It goes like this:

1. When you click on the "Photos" layer, Dawarich will make a request to `GET /api/v1/photos` endpoint to get photos for the selected timeframe.
2. This endpoint will make a request to `POST /search/metadata` endpoint of your Immich instance to get photos for the selected timeframe.
3. The response from Immich is being cached in Redis for 1 day.
4. Dawarich's frontend will make a request to `GET /api/v1/photos/:id/thumbnail.jpg` endpoint to get photo thumbnail from Immich. The number of requests to this endpoint will depend on how many photos you have in the selected timeframe.
5. For each photo, Dawarich's frontend will make a request to `GET /api/v1/photos/:id/thumbnail.jpg` endpoint to get photo thumbnail from Immich. This thumbnail request is also cached in Redis for 1 day.


### Added

- If you have provided your Immich instance URL and API key, the map will now show photos from your Immich instance when Photos layer is enabled.
- `GET /api/v1/photos` endpoint added to get photos from Immich.
- `GET /api/v1/photos/:id/thumbnail.jpg` endpoint added to get photo thumbnail from Immich.

# 0.16.9 - 2024-11-24

### Changed
Expand Down
50 changes: 50 additions & 0 deletions app/assets/stylesheets/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,53 @@
.leaflet-settings-panel button:hover {
background-color: #0056b3;
}

.photo-marker {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 50%;
}

.photo-marker img {
border-radius: 50%;
width: 48px;
height: 48px;
}

.leaflet-loading-control {
padding: 5px;
border-radius: 4px;
box-shadow: 0 1px 5px rgba(0,0,0,0.2);
margin: 10px;
width: 32px;
height: 32px;
background: white;
}

.loading-spinner {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
color: gray;
}

.loading-spinner::before {
content: '🔵';
font-size: 18px;
animation: spinner 1s linear infinite;
}

.loading-spinner.done::before {
content: '✅';
animation: none;
}

@keyframes spinner {
to {
transform: rotate(360deg);
}
}
38 changes: 38 additions & 0 deletions app/controllers/api/v1/photos_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true

class Api::V1::PhotosController < ApiController
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' }
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

if response.success?
send_data(
response.body,
type: 'image/jpeg',
disposition: 'inline',
status: :ok
)
else
render json: { error: 'Failed to fetch thumbnail' }, status: response.code
end
end
end
142 changes: 141 additions & 1 deletion app/javascript/controllers/maps_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export default class extends Controller {
this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map);
this.fogOverlay = L.layerGroup(); // Initialize fog layer
this.areasLayer = L.layerGroup(); // Initialize areas layer
this.photoMarkers = L.layerGroup();

this.setupScratchLayer(this.countryCodesMap);

if (!this.settingsButtonAdded) {
Expand All @@ -77,7 +79,8 @@ export default class extends Controller {
Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay,
"Scratch map": this.scratchLayer,
Areas: this.areasLayer // Add the areas layer to the controls
Areas: this.areasLayer,
Photos: this.photoMarkers
};

L.control
Expand Down Expand Up @@ -133,6 +136,13 @@ export default class extends Controller {
if (e.name === 'Areas') {
this.map.addControl(this.drawControl);
}
if (e.name === 'Photos') {
// Extract dates from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const startDate = urlParams.get('start_at')?.split('T')[0] || new Date().toISOString().split('T')[0];
const endDate = urlParams.get('end_at')?.split('T')[0] || new Date().toISOString().split('T')[0];
this.fetchAndDisplayPhotos(startDate, endDate);
}
});

this.map.on('overlayremove', (e) => {
Expand Down Expand Up @@ -771,4 +781,134 @@ export default class extends Controller {
this.map.removeControl(this.layerControl);
this.layerControl = L.control.layers(this.baseMaps(), layerControl).addTo(this.map);
}

async fetchAndDisplayPhotos(startDate, endDate, retryCount = 0) {
const MAX_RETRIES = 3;
const RETRY_DELAY = 3000; // 3 seconds

// Create loading control
const LoadingControl = L.Control.extend({
onAdd: (map) => {
const container = L.DomUtil.create('div', 'leaflet-loading-control');
container.innerHTML = '<div class="loading-spinner"></div>';
return container;
}
});

const loadingControl = new LoadingControl({ position: 'topleft' });
this.map.addControl(loadingControl);

try {
const params = new URLSearchParams({
api_key: this.apiKey,
start_date: startDate,
end_date: endDate
});

const response = await fetch(`/api/v1/photos?${params}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const photos = await response.json();
this.photoMarkers.clearLayers();

// Create a promise for each photo to track when it's fully loaded
const photoLoadPromises = photos.map(photo => {
return new Promise((resolve) => {
const img = new Image();
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.apiKey}`;

img.onload = () => {
this.createPhotoMarker(photo);
resolve();
};

img.onerror = () => {
console.error(`Failed to load photo ${photo.id}`);
resolve(); // Resolve anyway to not block other photos
};

img.src = thumbnailUrl;
});
});

// Wait for all photos to be loaded and rendered
await Promise.all(photoLoadPromises);

if (!this.map.hasLayer(this.photoMarkers)) {
this.photoMarkers.addTo(this.map);
}

// Show checkmark for 1 second before removing
const loadingSpinner = document.querySelector('.loading-spinner');
loadingSpinner.classList.add('done');

await new Promise(resolve => setTimeout(resolve, 1000));

} catch (error) {
console.error('Error fetching photos:', error);
showFlashMessage('error', 'Failed to fetch photos');

if (retryCount < MAX_RETRIES) {
console.log(`Retrying in ${RETRY_DELAY/1000} seconds... (Attempt ${retryCount + 1}/${MAX_RETRIES})`);
setTimeout(() => {
this.fetchAndDisplayPhotos(startDate, endDate, retryCount + 1);
}, RETRY_DELAY);
} else {
showFlashMessage('error', 'Failed to fetch photos after multiple attempts');
}
} finally {
// Remove loading control after the delay
this.map.removeControl(loadingControl);
}
}

createPhotoMarker(photo) {
if (!photo.exifInfo?.latitude || !photo.exifInfo?.longitude) return;

const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.apiKey}`;

const icon = L.divIcon({
className: 'photo-marker',
html: `<img src="${thumbnailUrl}" style="width: 48px; height: 48px;">`,
iconSize: [48, 48]
});

const marker = L.marker(
[photo.exifInfo.latitude, photo.exifInfo.longitude],
{ icon }
);

const startOfDay = new Date(photo.localDateTime);
startOfDay.setHours(0, 0, 0, 0);

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 = `${this.userSettings.immich_url}/search?query=${encodedQuery}`;
const popupContent = `
<div class="max-w-xs">
<a href="${immich_photo_link}" target="_blank" onmouseover="this.firstElementChild.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.3)';"
onmouseout="this.firstElementChild.style.boxShadow = '';">
<img src="${thumbnailUrl}"
class="w-8 h-8 mb-2 rounded"
style="transition: box-shadow 0.3s ease;"
alt="${photo.originalFileName}">
</a>
<h3 class="font-bold">${photo.originalFileName}</h3>
<p>Taken: ${new Date(photo.localDateTime).toLocaleString()}</p>
<p>Location: ${photo.exifInfo.city}, ${photo.exifInfo.state}, ${photo.exifInfo.country}</p>
${photo.type === 'VIDEO' ? '🎥 Video' : '📷 Photo'}
</div>
`;
marker.bindPopup(popupContent);

this.photoMarkers.addLayer(marker);
}
}
64 changes: 7 additions & 57 deletions app/services/immich/import_geodata.rb
Original file line number Diff line number Diff line change
@@ -1,24 +1,19 @@
# frozen_string_literal: true

class Immich::ImportGeodata
attr_reader :user, :immich_api_base_url, :immich_api_key
attr_reader :user, :start_date, :end_date

def initialize(user)
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_key = user.settings['immich_api_key']
@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?

write_raw_data(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)
Expand All @@ -27,53 +22,14 @@ def call

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
page = 1
data = []
max_pages = 1000 # Prevent infinite loop

while page <= max_pages
Rails.logger.debug "Retrieving next page: #{page}"
body = request_body(page)
response = JSON.parse(HTTParty.post(immich_api_base_url, headers: headers, body: body).body)

items = response.dig('assets', 'items')
Rails.logger.debug "#{items.size} items found"

break if items.empty?

data << items

Rails.logger.debug "next_page: #{response.dig('assets', 'nextPage')}"

page += 1

Rails.logger.debug "#{data.flatten.size} data size"
end

data.flatten
end

def request_body(page)
{
createdAfter: '1970-01-01',
size: 1000,
page: page,
order: 'asc',
withExif: true
}
Immich::RequestPhotos.new(user, start_date:, end_date:).call
end

def parse_immich_data(immich_data)
Expand Down Expand Up @@ -101,13 +57,7 @@ def extract_geodata(asset)
end

def log_no_data
Rails.logger.debug 'No data found'
end

def write_raw_data(immich_data)
File.open("tmp/imports/immich_raw_data_#{Time.current}_#{user.email}.json", 'w') do |file|
file.write(immich_data.to_json)
end
Rails.logger.info 'No data found'
end

def create_import_failed_notification(import_name)
Expand Down
Loading

0 comments on commit 45cbaf4

Please sign in to comment.