diff --git a/.gitignore b/.gitignore index af85d96c0..25731cc2a 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ mobile/ios/fastlane/report.xml vite.config.js.timestamp-* .pnpm-store +.devcontainer/library +.devcontainer/.env* diff --git a/i18n/en.json b/i18n/en.json index 6b70a366a..afedf0081 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1076,10 +1076,7 @@ "gcast_enabled": "Google Cast", "gcast_enabled_description": "This feature loads external resources from Google in order to work.", "general": "General", - "geolocation_instruction_all_have_location": "All assets for this date already have location data. Try showing all assets or select a different date", "geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map", - "geolocation_instruction_no_date": "Select a date to manage location data for photos and videos from that day", - "geolocation_instruction_no_photos": "No photos or videos found for this date. Select a different date to show them", "get_help": "Get Help", "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", "getting_started": "Getting Started", @@ -1850,10 +1847,8 @@ "shift_to_permanent_delete": "press ⇧ to permanently delete asset", "show_album_options": "Show album options", "show_albums": "Show albums", - "show_all_assets": "Show all assets", "show_all_people": "Show all people", "show_and_hide_people": "Show & hide people", - "show_assets_without_location": "Show assets without location", "show_file_location": "Show file location", "show_gallery": "Show gallery", "show_hidden_people": "Show hidden people", @@ -2038,7 +2033,6 @@ "use_biometric": "Use biometric", "use_current_connection": "use current connection", "use_custom_date_range": "Use custom date range instead", - "use_this_location": "Click to use location", "user": "User", "user_has_been_deleted": "This user has been deleted.", "user_id": "User ID", diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 2d142e3d6..70ac076c9 100644 Binary files a/mobile/openapi/lib/api/timeline_api.dart and b/mobile/openapi/lib/api/timeline_api.dart differ diff --git a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart index 886b353f6..58032b7c5 100644 Binary files a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart and b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 5a7886253..5a847fc83 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8895,6 +8895,15 @@ "$ref": "#/components/schemas/AssetVisibility" } }, + { + "name": "withCoordinates", + "required": false, + "in": "query", + "description": "Include location data in the response", + "schema": { + "type": "boolean" + } + }, { "name": "withPartners", "required": false, @@ -9040,6 +9049,15 @@ "$ref": "#/components/schemas/AssetVisibility" } }, + { + "name": "withCoordinates", + "required": false, + "in": "query", + "description": "Include location data in the response", + "schema": { + "type": "boolean" + } + }, { "name": "withPartners", "required": false, @@ -17066,6 +17084,14 @@ }, "type": "array" }, + "latitude": { + "description": "Array of latitude coordinates extracted from EXIF GPS data", + "items": { + "nullable": true, + "type": "number" + }, + "type": "array" + }, "livePhotoVideoId": { "description": "Array of live photo video asset IDs (null for non-live photos)", "items": { @@ -17081,6 +17107,14 @@ }, "type": "array" }, + "longitude": { + "description": "Array of longitude coordinates extracted from EXIF GPS data", + "items": { + "nullable": true, + "type": "number" + }, + "type": "array" + }, "ownerId": { "description": "Array of owner IDs for each asset", "items": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index a74afd733..18f70f9ab 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1561,10 +1561,14 @@ export type TimeBucketAssetResponseDto = { isImage: boolean[]; /** Array indicating whether each asset is in the trash */ isTrashed: boolean[]; + /** Array of latitude coordinates extracted from EXIF GPS data */ + latitude?: (number | null)[]; /** Array of live photo video asset IDs (null for non-live photos) */ livePhotoVideoId: (string | null)[]; /** Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective. */ localOffsetHours: number[]; + /** Array of longitude coordinates extracted from EXIF GPS data */ + longitude?: (number | null)[]; /** Array of owner IDs for each asset */ ownerId: string[]; /** Array of projection types for 360° content (e.g., "EQUIRECTANGULAR", "CUBEFACE", "CYLINDRICAL") */ @@ -4293,7 +4297,7 @@ export function tagAssets({ id, bulkIdsDto }: { /** * This endpoint requires the `asset.read` permission. */ -export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withPartners, withStacked }: { +export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: { albumId?: string; isFavorite?: boolean; isTrashed?: boolean; @@ -4305,6 +4309,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers timeBucket: string; userId?: string; visibility?: AssetVisibility; + withCoordinates?: boolean; withPartners?: boolean; withStacked?: boolean; }, opts?: Oazapfts.RequestOpts) { @@ -4323,6 +4328,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers timeBucket, userId, visibility, + withCoordinates, withPartners, withStacked }))}`, { @@ -4332,7 +4338,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers /** * This endpoint requires the `asset.read` permission. */ -export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withPartners, withStacked }: { +export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: { albumId?: string; isFavorite?: boolean; isTrashed?: boolean; @@ -4343,6 +4349,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per tagId?: string; userId?: string; visibility?: AssetVisibility; + withCoordinates?: boolean; withPartners?: boolean; withStacked?: boolean; }, opts?: Oazapfts.RequestOpts) { @@ -4360,6 +4367,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per tagId, userId, visibility, + withCoordinates, withPartners, withStacked }))}`, { diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index 449cec320..58772da00 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -53,6 +53,12 @@ export class TimeBucketDto { description: 'Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)', }) visibility?: AssetVisibility; + + @ValidateBoolean({ + optional: true, + description: 'Include location data in the response', + }) + withCoordinates?: boolean; } export class TimeBucketAssetDto extends TimeBucketDto { @@ -185,6 +191,22 @@ export class TimeBucketAssetResponseDto { description: 'Array of country names extracted from EXIF GPS data', }) country!: (string | null)[]; + + @ApiProperty({ + type: 'array', + required: false, + items: { type: 'number', nullable: true }, + description: 'Array of latitude coordinates extracted from EXIF GPS data', + }) + latitude!: number[]; + + @ApiProperty({ + type: 'array', + required: false, + items: { type: 'number', nullable: true }, + description: 'Array of longitude coordinates extracted from EXIF GPS data', + }) + longitude!: number[]; } export class TimeBucketsResponseDto { diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index ae595e35a..5c3bd8996 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -60,6 +60,7 @@ interface AssetBuilderOptions { status?: AssetStatus; assetType?: AssetType; visibility?: AssetVisibility; + withCoordinates?: boolean; } export interface TimeBucketOptions extends AssetBuilderOptions { @@ -628,6 +629,7 @@ export class AssetRepository { ) .as('ratio'), ]) + .$if(!!options.withCoordinates, (qb) => qb.select(['asset_exif.latitude', 'asset_exif.longitude'])) .where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null) .$if(options.visibility == undefined, withDefaultVisibility) .$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!)) @@ -701,6 +703,12 @@ export class AssetRepository { eb.fn.coalesce(eb.fn('array_agg', ['status']), sql.lit('{}')).as('status'), eb.fn.coalesce(eb.fn('array_agg', ['thumbhash']), sql.lit('{}')).as('thumbhash'), ]) + .$if(!!options.withCoordinates, (qb) => + qb.select((eb) => [ + eb.fn.coalesce(eb.fn('array_agg', ['latitude']), sql.lit('{}')).as('latitude'), + eb.fn.coalesce(eb.fn('array_agg', ['longitude']), sql.lit('{}')).as('longitude'), + ]), + ) .$if(!!options.withStacked, (qb) => qb.select((eb) => eb.fn.coalesce(eb.fn('json_agg', ['stack']), sql.lit('[]')).as('stack')), ), diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index 6fa4f738d..9cf50d135 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -1,6 +1,7 @@ - -
-
-
- - -
- -
- - -
- -
- - -
- -
- -
-
-
diff --git a/web/src/lib/components/utilities-page/geolocation/geolocation.svelte b/web/src/lib/components/utilities-page/geolocation/geolocation.svelte deleted file mode 100644 index 0efde6df7..000000000 --- a/web/src/lib/components/utilities-page/geolocation/geolocation.svelte +++ /dev/null @@ -1,104 +0,0 @@ - - -
-
- { - if (asset.exifInfo?.latitude && asset.exifInfo?.longitude) { - onLocation({ latitude: asset.exifInfo?.latitude, longitude: asset.exifInfo?.longitude }); - } else { - onSelectAsset(asset); - } - }} - onSelect={() => onSelectAsset(asset)} - onMouseEvent={() => onMouseEvent(asset)} - selected={assetInteraction.hasSelectedAsset(asset.id)} - selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} - thumbnailSize={boxWidth} - readonly={hasGps} - /> - - {#if hasGps} -
- {$t('gps')} -
- {:else} -
- {$t('gps_missing')} -
- {/if} -
-
- - {asset.originalFileName} - -
-
-

- {new Date(asset.localDateTime).toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - })} -

-

- {new Date(asset.localDateTime).toLocaleTimeString(undefined, { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - timeZone: 'UTC', - })} -

- {#if hasGps} -

- {asset.exifInfo?.country} -

-

- {asset.exifInfo?.city} -

- {/if} -
-
diff --git a/web/src/lib/managers/timeline-manager/month-group.svelte.ts b/web/src/lib/managers/timeline-manager/month-group.svelte.ts index 03d138f68..e40697290 100644 --- a/web/src/lib/managers/timeline-manager/month-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/month-group.svelte.ts @@ -187,6 +187,11 @@ export class MonthGroup { thumbhash: bucketAssets.thumbhash[i], people: null, // People are not included in the bucket assets }; + + if (bucketAssets.latitude?.[i] && bucketAssets.longitude?.[i]) { + timelineAsset.latitude = bucketAssets.latitude?.[i]; + timelineAsset.longitude = bucketAssets.longitude?.[i]; + } this.addTimelineAsset(timelineAsset, addContext); } diff --git a/web/src/lib/managers/timeline-manager/types.ts b/web/src/lib/managers/timeline-manager/types.ts index 18ee0426f..fea62084b 100644 --- a/web/src/lib/managers/timeline-manager/types.ts +++ b/web/src/lib/managers/timeline-manager/types.ts @@ -31,6 +31,8 @@ export type TimelineAsset = { city: string | null; country: string | null; people: string[] | null; + latitude?: number | null; + longitude?: number | null; }; export type AssetOperation = (asset: TimelineAsset) => { remove: boolean }; diff --git a/web/src/lib/utils/date-time.spec.ts b/web/src/lib/utils/date-time.spec.ts index bca57863a..d96bef45d 100644 --- a/web/src/lib/utils/date-time.spec.ts +++ b/web/src/lib/utils/date-time.spec.ts @@ -1,5 +1,5 @@ import { writable } from 'svelte/store'; -import { buildDateRangeFromYearMonthAndDay, getAlbumDateRange, timeToSeconds } from './date-time'; +import { getAlbumDateRange, timeToSeconds } from './date-time'; describe('converting time to seconds', () => { it('parses hh:mm:ss correctly', () => { @@ -75,24 +75,3 @@ describe('getAlbumDate', () => { expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00+05:00' })).toEqual('Jan 1, 2021'); }); }); - -describe('buildDateRangeFromYearMonthAndDay', () => { - it('should build correct date range for a specific day', () => { - const result = buildDateRangeFromYearMonthAndDay(2023, 1, 8); - - expect(result.from).toContain('2023-01-08T00:00:00'); - expect(result.to).toContain('2023-01-09T00:00:00'); - }); - - it('should build correct date range for a month', () => { - const result = buildDateRangeFromYearMonthAndDay(2023, 2); - expect(result.from).toContain('2023-02-01T00:00:00'); - expect(result.to).toContain('2023-03-01T00:00:00'); - }); - - it('should build correct date range for a year', () => { - const result = buildDateRangeFromYearMonthAndDay(2023); - expect(result.from).toContain('2023-01-01T00:00:00'); - expect(result.to).toContain('2024-01-01T00:00:00'); - }); -}); diff --git a/web/src/lib/utils/date-time.ts b/web/src/lib/utils/date-time.ts index bf87d041c..8a50df9cf 100644 --- a/web/src/lib/utils/date-time.ts +++ b/web/src/lib/utils/date-time.ts @@ -85,33 +85,3 @@ export const getAlbumDateRange = (album: { startDate?: string; endDate?: string */ export const asLocalTimeISO = (date: DateTime) => (date.setZone('utc', { keepLocalTime: true }) as DateTime).toISO(); - -/** - * Creates a date range for filtering assets based on year, month, and day parameters - */ -export const buildDateRangeFromYearMonthAndDay = (year: number, month?: number, day?: number) => { - const baseDate = DateTime.fromObject({ - year, - month: month || 1, - day: day || 1, - }); - - let from: DateTime; - let to: DateTime; - - if (day) { - from = baseDate.startOf('day'); - to = baseDate.plus({ days: 1 }).startOf('day'); - } else if (month) { - from = baseDate.startOf('month'); - to = baseDate.plus({ months: 1 }).startOf('month'); - } else { - from = baseDate.startOf('year'); - to = baseDate.plus({ years: 1 }).startOf('year'); - } - - return { - from: from.toISO() || undefined, - to: to.toISO() || undefined, - }; -}; diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index a1147b708..6a0f12c20 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -190,6 +190,8 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): city: city || null, country: country || null, people, + latitude: assetResponse.exifInfo?.latitude || null, + longitude: assetResponse.exifInfo?.longitude || null, }; }; diff --git a/web/src/routes/(user)/utilities/geolocation/+page.svelte b/web/src/routes/(user)/utilities/geolocation/+page.svelte index 48e94d750..ab0a59f3e 100644 --- a/web/src/routes/(user)/utilities/geolocation/+page.svelte +++ b/web/src/routes/(user)/utilities/geolocation/+page.svelte @@ -1,26 +1,21 @@ @@ -224,9 +139,7 @@ {#snippet buttons()}
- {#if filteredAssets.length > 0} - - {/if} +
-
- {#if isLoading}
{/if} - {#if filteredAssets && filteredAssets.length > 0} -
- {#each filteredAssets as asset (asset.id)} - handleSelectAssets(asset)} - onMouseEvent={(asset) => assetMouseEventHandler(asset)} - onLocation={(selected) => { - location = selected; - locationUpdated = true; - setTimeout(() => { - locationUpdated = false; - }, 1000); - }} - /> - {/each} -
- {:else} -
- {#if partialDate == null} - - {:else if showOnlyAssetsWithoutLocation && filteredAssets.length === 0 && assets.length > 0} - + + {#snippet customLayout(asset: TimelineAsset)} + {#if hasGps(asset)} +
+ {asset.city || $t('gps')} +
{:else} - +
+ {$t('gps_missing')} +
{/if} -
- {/if} + {/snippet} + {#snippet empty()} + {}} /> + {/snippet} +
diff --git a/web/src/routes/(user)/utilities/geolocation/+page.ts b/web/src/routes/(user)/utilities/geolocation/+page.ts index f5c227a7e..1ada22a23 100644 --- a/web/src/routes/(user)/utilities/geolocation/+page.ts +++ b/web/src/routes/(user)/utilities/geolocation/+page.ts @@ -1,15 +1,12 @@ import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; -import { getQueryValue } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; export const load = (async ({ url }) => { await authenticate(url); - const partialDate = getQueryValue('date'); const $t = await getFormatter(); return { - partialDate, meta: { title: $t('manage_geolocation'), }, diff --git a/web/src/routes/(user)/utilities/geolocation/photos/[photoId]/+page.ts b/web/src/routes/(user)/utilities/geolocation/photos/[photoId]/+page.ts new file mode 100644 index 000000000..17fd84097 --- /dev/null +++ b/web/src/routes/(user)/utilities/geolocation/photos/[photoId]/+page.ts @@ -0,0 +1,8 @@ +import { AppRoute } from '$lib/constants'; +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load = (({ params }) => { + const photoId = params.photoId; + return redirect(302, `${AppRoute.PHOTOS}/${photoId}`); +}) satisfies PageLoad;