feat: shared album links

Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
Varun Raj committed 2025-01-17 22:21:17 +05:30
1 parent 1132a046f4
commit 34f32e4906
26 files changed
+667 -70

No files matched your search

+12 -2
View File
@@ -6,6 +6,12 @@ A unofficial immich client to provide better tools to organize and manage your i
[![Immich Power Tools](./screenshots/screenshot-1.png)](https://www.loom.com/embed/13aa90d8ab2e4acab0993bdc8703a750?sid=71498690-b745-473f-b239-a7bdbe6efc21)
### Support me 🙏
If you find this tool useful, please consider supporting me by buying me a coffee.
[![Buy me a coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/varunraj)
## 💭 Back story
Recently I've migrated my entire Google photos library to Immich, I was able to successfully migrate all my assets along with its albums to immich. But there were few things like people match that was lacking. I loved Immich UI on the whole but for organizing content I felt its quite restricted and I had to do a lot of things in bulk instead of opening each asset and doing it. Hence I built this tool (continuing to itereate) to make my life and any other Immich user's life easier.
@@ -51,7 +57,11 @@ Refer here for obtaining Immich API Key: https://immich.app/docs/features/comman
If you're using portainer, run the docker using `docker run` and add the power tools to the same network as immich.
```bash
# Run the power tools from docker
docker run -d --name immich_power_tools -p 8001:3000 --env-file .env ghcr.io/varun-raj/immich-power-tools:latest
# Add Power tools to the same network as immich
docker network connect immich_default immich_power_tools
```
@@ -90,7 +100,8 @@ bun run dev
- [x] Manage People
- [x] Smart Merge
- [x] Manage Albums
- [ ] Bulk Delete
- [x] Bulk Delete
- [x] Bulk Share
- [ ] Bulk Edit
- [ ] Filters
- [x] Potential Albums
@@ -127,7 +138,6 @@ Google Gemini 1.5 Flash model is used for parsing your search query in "Find" pa
> Code where Gemini is used: [src/helpers/gemini.helper.ts](./src/helpers/gemini.helper.ts)
## Contributing
Feel free to contribute to this project, I'm open to any suggestions and improvements. Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) for more details.
BIN
View File
Binary file not shown.
+2
View File
@@ -48,6 +48,7 @@
"cookie": "^0.6.0",
"date-fns": "^3.6.0",
"drizzle-orm": "^0.33.0",
"jsonwebtoken": "^9.0.2",
"leaflet": "^1.9.4",
"leaflet-defaulticon-compatibility": "0.1.2",
"lucide-react": "^0.428.0",
@@ -64,6 +65,7 @@
"react-hot-toast": "^2.5.1",
"react-leaflet": "^4.2.1",
"react-mentions": "^4.4.10",
"react-photo-album": "^3.0.2",
"react-query": "^3.39.3",
"recharts": "^2.12.7",
"remotion": "4.0.232",
+47 -40
View File
@@ -21,14 +21,16 @@ import { Checkbox } from '@/components/ui/checkbox'
interface AlbumPeopleProps {
album: IAlbum
onSelect: (personId: string) => void
readOnly?: boolean
}
interface IAlbumPerson {
id: string
name: string
numberOfPhotos: number
}
export default function AlbumPeople({ album, onSelect }: AlbumPeopleProps) {
export default function AlbumPeople({ album, onSelect, readOnly }: AlbumPeopleProps) {
const { exImmichUrl } = useConfig()
const router = useRouter()
const { query, pathname } = router
@@ -117,51 +119,56 @@ export default function AlbumPeople({ album, onSelect }: AlbumPeopleProps) {
{selectedPerson.name || "No Name"}
</Link>
</div>
<div className='flex items-center gap-2'>
<PersonMergeDropdown
person={{
...selectedPerson,
assetCount: selectedPerson.numberOfPhotos,
birthDate: null,
thumbnailPath: PERSON_THUBNAIL_PATH(selectedPerson.id),
isHidden: false,
updatedAt: new Date()
}}
triggerClassName='w-full flex-1'
onComplete={(mergedPerson) => {
router.push({
pathname,
query: {
...query,
faceId: mergedPerson.id
}
})
}} />
<Button disabled={hidingPerson} className="!py-0.5 !px-2 text-xs h-7 flex-1" variant="outline" onClick={() => handleHidePerson(selectedPerson.id)}>
Hide
</Button>
</div>
{!readOnly && (
<div className='flex items-center gap-2'>
<PersonMergeDropdown
person={{
...selectedPerson,
assetCount: selectedPerson.numberOfPhotos,
birthDate: null,
thumbnailPath: PERSON_THUBNAIL_PATH(selectedPerson.id),
isHidden: false,
updatedAt: new Date()
}}
triggerClassName='w-full flex-1'
onComplete={(mergedPerson) => {
router.push({
pathname,
query: {
...query,
faceId: mergedPerson.id
}
})
}} />
<Button disabled={hidingPerson} className="!py-0.5 !px-2 text-xs h-7 flex-1" variant="outline" onClick={() => handleHidePerson(selectedPerson.id)}>
Hide
</Button>
</div>
)}
</div>
)}
<div className="flex items-center gap-2 justify-between">
<p className="text-sm font-medium">People</p>
{!selectedPerson && (
<Button variant="outline" className="!py-0.5 !px-2 text-xs h-7" onClick={() => {
setSelectionMode(!selectionMode)
setSelectedPeople([])
}}>
{selectionMode ? "Cancel" : "Select"}
</Button>
)}
{selectedPeople.length > 0 && (
<div className='absolute mx-auto bottom-0 w-full py-2 bg-white darl:bg-black -mx-2 px-2'>
<Button variant="outline" className="!py-0.5 !px-2 text-xs h-7" onClick={handleHideSelectedPeople}>
Hide {selectedPeople.length} people
</Button>
</div>
{!readOnly && (
<>
{!selectedPerson && (
<Button variant="outline" className="!py-0.5 !px-2 text-xs h-7" onClick={() => {
setSelectionMode(!selectionMode)
setSelectedPeople([])
}}>
{selectionMode ? "Cancel" : "Select"}
</Button>
)}
{selectedPeople.length > 0 && (
<div className='absolute mx-auto bottom-0 w-full py-2 bg-white darl:bg-black -mx-2 px-2'>
<Button variant="outline" className="!py-0.5 !px-2 text-xs h-7" onClick={handleHideSelectedPeople}>
Hide {selectedPeople.length} people
</Button>
</div>
)}
</>
)}
</div>
<div className="flex flex-col overflow-x-hidden gap-2">
@@ -41,6 +41,7 @@ export default function PotentialAlbumsAssets() {
width: p.exifImageWidth as number,
height: p.exifImageHeight as number,
isSelected: selectedIds.includes(p.id),
orientation: 1,
tags: [
{
title: "Immich Link",
@@ -1,8 +1,11 @@
import { AlertDialog } from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Dialog, DialogTitle, DialogHeader, DialogContent } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { shareAlbums } from '@/handlers/api/album.handler';
import { generateShareLink } from '@/handlers/api/shareLink.handler';
import { IAlbum } from '@/types/album';
import React, { ForwardedRef, forwardRef, useImperativeHandle, useState } from 'react'
@@ -28,6 +31,10 @@ const AlbumShareDialog = forwardRef(({ }: IAlbumShareDialogProps, ref: Forwarded
const [generating, setGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [generated, setGenerated] = useState<boolean>(false);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [generatedLink, setGeneratedLink] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const handleGenerateShareLink = async () => {
setGenerating(true);
@@ -52,10 +59,32 @@ const AlbumShareDialog = forwardRef(({ }: IAlbumShareDialogProps, ref: Forwarded
});
}
const handleGenerateGlobalShareLink = async () => {
setLoading(true);
return generateShareLink({
albumIds: selectedAlbums.map((album) => album.id),
}).then(({ link }) => {
setGeneratedLink(link);
}).catch((err) => {
setErrorMessage(err.message);
}).finally(() => {
setLoading(false);
});
}
const handleAllowPropertyChange = (albumId: string, property: string, checked: boolean) => {
setSelectedAlbums((prevAlbums) => prevAlbums.map((album) => album.id === albumId ? { ...album, [property]: checked } : album));
}
const handleCopy = () => {
if (generatedLink) {
navigator.clipboard.writeText(generatedLink);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
}
}
useImperativeHandle(ref, () => ({
open: (selectedAlbums: IAlbum[]) => {
@@ -109,23 +138,51 @@ const AlbumShareDialog = forwardRef(({ }: IAlbumShareDialogProps, ref: Forwarded
))}
</ol>
{generatedLink && (
<div className="flex flex-col gap-2 items-center">
<p className="text-sm py-2 text-muted-foreground text-center">Share links all generated</p>
<div className="flex gap-2 w-full">
<Input value={generatedLink} readOnly />
<Button onClick={handleCopy} className='max-w-fit'>
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
</div>
)}
{generated ? (
<>
<p className="text-sm py-2 text-muted-foreground text-center">Share links all generated</p>
<p className="text-sm py-2 text-muted-foreground text-center">Share links all generated</p>
</>
) : (
<>
{generating ? <div className="flex justify-center gap-2">
<p className="text-sm py-2 text-muted-foreground">Generating share links...</p>
</div> : <div className="flex justify-center gap-2">
<Button onClick={handleGenerateShareLink} disabled={generating}>
Generate For {selectedAlbums.length} albums
</Button>
</div>}
</>
{generating ? <div className="flex justify-center gap-2">
<p className="text-sm py-2 text-muted-foreground">Generating share links...</p>
</div> : (
<div className="flex justify-center gap-2">
<AlertDialog
title="Generate Share Links"
description="Generate share links for the selected albums."
onConfirm={handleGenerateShareLink}
>
<Button disabled={generating}>
Generate For {selectedAlbums.length} albums
</Button>
</AlertDialog>
<AlertDialog
title="Generate a single share link"
description="Immich Power Tools will work like a proxy for all your albums using single share link."
onConfirm={handleGenerateGlobalShareLink}
>
<Button disabled={generating}>
Generate 1 Share Link
</Button>
</AlertDialog>
</div>
)}
</>
)}
</div>
</DialogContent>
</Dialog>
@@ -56,6 +56,7 @@ export default function MissingLocationAssets({ groupBy }: IProps) {
width: p.exifImageWidth as number,
height: p.exifImageHeight as number,
isSelected: selectedIds.includes(p.id),
orientation: 1,
tags: [
{
title: "Immich Link",
+15
View File
@@ -12,6 +12,8 @@ import { useConfig } from "@/contexts/ConfigContext";
import { queryClient } from "@/config/rQuery";
import { QueryClientProvider } from "react-query";
import { Toaster } from "react-hot-toast";
import { useRouter } from "next/router";
import PageLayout from "./PageLayout";
type RootLayoutProps = {
children: ReactNode;
@@ -20,6 +22,8 @@ type RootLayoutProps = {
export default function RootLayout({ children }: RootLayoutProps) {
const { immichURL, exImmichUrl } = useConfig();
const router = useRouter();
const { pathname } = router;
const [user, setUser] = useState<IUser | null>(null);
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState<{
@@ -84,6 +88,17 @@ export default function RootLayout({ children }: RootLayoutProps) {
</div>
);
if (pathname.startsWith("/s/")) {
return (
<QueryClientProvider client={queryClient}>
<div className="grid max-h-screen min-h-screen w-full">
<div className="flex flex-col">{children}</div>
<Toaster />
</div>
</QueryClientProvider>
)
}
if (!user) return <LoginForm onLogin={handleLoginCompletion} />;
return (
+2
View File
@@ -11,6 +11,7 @@ import { useConfig } from "@/contexts/ConfigContext";
import { useToast } from "../ui/use-toast";
import { Badge } from "../ui/badge";
import { Button } from "@/components/ui/button";
import ShareAssetsTrigger from "../shared/ShareAssetsTrigger";
interface IProps {
person: IPerson;
onRemove: (person: IPerson) => void;
@@ -108,6 +109,7 @@ export default function PersonItem({ person, onRemove }: IProps) {
}}>
Hide
</Button>
<ShareAssetsTrigger filters={{ personIds: [person.id] }} buttonProps={{ variant: "outline", className: "!py-0.5 !px-2 text-xs h-7" }} />
</div>
</div>
{!editMode ? (
+71
View File
@@ -0,0 +1,71 @@
import "yet-another-react-lightbox/styles.css";
import { IAsset } from '@/types/asset';
import React, { useMemo, useState } from 'react'
import Lightbox from 'yet-another-react-lightbox';
import { Gallery } from "react-grid-gallery";
import LazyGridImage from "../ui/lazy-grid-image";
import Download from "yet-another-react-lightbox/plugins/download";
interface AssetGridProps {
assets: IAsset[];
isInternal?: boolean;
selectable?: boolean;
}
export default function AssetGrid({ assets, isInternal = true, selectable = false }: AssetGridProps) {
const [index, setIndex] = useState(-1);
const slides = useMemo(() => {
return assets.map((asset) => ({
...asset,
orientation: 1,
src: asset.previewUrl as string,
type: (asset.type === "VIDEO" ? "video" : "image") as any,
sources:
asset.type === "VIDEO"
? [
{
src: asset.downloadUrl as string,
type: "video/mp4",
},
]
: undefined,
height: asset.exifImageHeight as number,
width: asset.exifImageWidth as number,
downloadUrl: asset.downloadUrl as string,
}));
}, [assets]);
console.log(slides);
const images = useMemo(() => {
return assets.map((p) => ({
...p,
src: p.url as string,
original: p.previewUrl as string,
width: p.exifImageWidth / 10 as number,
height: p.exifImageHeight / 10 as number,
orientation: 1
}));
}, [assets]);
return (
<div>
<Lightbox
slides={slides}
plugins={[Download]}
open={index >= 0}
index={index}
close={() => setIndex(-1)}
/>
<Gallery
images={images}
onClick={setIndex}
enableImageSelection={selectable}
thumbnailImageComponent={LazyGridImage}
/>
</div>
);
}
@@ -0,0 +1,69 @@
import React, { useState } from 'react'
import { Dialog, DialogTitle, DialogContent, DialogHeader, DialogTrigger, DialogDescription } from '../ui/dialog';
import { Button, ButtonProps } from '../ui/button';
import { ShareLinkFilters } from '@/types/shareLink';
import { generateShareLink } from '@/handlers/api/shareLink.handler';
import { Input } from '../ui/input';
import { Label } from '@radix-ui/react-label';
interface ShareAssetsTriggerProps {
filters: ShareLinkFilters
buttonProps?: ButtonProps
}
export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssetsTriggerProps) {
const [generatedLink, setGeneratedLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const handleGenerate = async () => {
setLoading(true);
return generateShareLink(filters).then(({ link }) => {
setGeneratedLink(link);
}).catch((err) => {
setErrorMessage(err.message);
}).finally(() => {
setLoading(false);
});
}
const handleCopy = () => {
if (generatedLink) {
navigator.clipboard.writeText(generatedLink);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
}
}
return (
<Dialog>
<DialogTrigger asChild>
<Button {...buttonProps}>Share</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Share Assets</DialogTitle>
</DialogHeader>
<DialogDescription>
Share your assets with your friends and family.
</DialogDescription>
{errorMessage && <div className="text-red-500">{errorMessage}</div>}
{generatedLink ? <div className="flex flex-col gap-2">
<Label className='text-sm'>Share Link</Label>
<Input readOnly type="text" value={generatedLink} />
<p className='text-xs text-muted-foreground'>
This is a stateless link, it will not work if you leave the page. Which means it cannot be expired.
</p>
<Button onClick={handleCopy}>{copied ? 'Copied' : 'Copy'}</Button>
</div> : (
<Button onClick={handleGenerate} disabled={loading}>Generate Share Link</Button>
)}
</DialogContent>
</Dialog>
)
}
+2
View File
@@ -13,6 +13,8 @@ export const ENV = {
VERSION: process.env.VERSION,
GEMINI_API_KEY: process.env.GEMINI_API_KEY as string,
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY as string,
IMMICH_SHARE_LINK_KEY: process.env.IMMICH_SHARE_LINK_KEY as string,
POWER_TOOLS_ENDPOINT_URL: process.env.POWER_TOOLS_ENDPOINT_URL as string,
};
+10
View File
@@ -21,8 +21,10 @@ export const LIST_POTENTIAL_ALBUMS_DATES_PATH = BASE_API_ENDPOINT + "/albums/pot
export const LIST_POTENTIAL_ALBUMS_ASSETS_PATH = BASE_API_ENDPOINT + "/albums/potential-albums-assets";
export const LIST_ALBUMS_PATH = BASE_API_ENDPOINT + "/albums/list";
export const ALBUM_INFO_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/info";
export const ALBUM_PEOPLE_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/people";
export const ALBUM_ASSETS_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/assets";
export const ALBUM_ASSETS_PATH_PUBLIC = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/public-assets";
export const CREATE_ALBUM_PATH = BASE_PROXY_ENDPOINT + "/albums";
export const ADD_ASSETS_ALBUMS_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/albums/" + id + "/assets";
export const SHARE_ALBUMS_PATH = BASE_API_ENDPOINT + "/albums/share";
@@ -38,6 +40,8 @@ export const ASSET_PREVIEW_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/
export const ASSET_VIDEO_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/video/" + id;
export const ASSET_GEO_HEATMAP_PATH = BASE_API_ENDPOINT + "/assets/geo-heatmap";
export const ASSET_SHARE_THUMBNAIL_PATH = (id: string, size: string = "thumbnail", token: string) => BASE_PROXY_ENDPOINT + "/asset/share-thumbnail/" + id + "?size=" + size + "&token=" + token;
// Location
export const SEARCH_PLACES_PATH = BASE_PROXY_ENDPOINT + "/search/places";
@@ -57,3 +61,9 @@ export const GET_PERSON_INFO = (personId: string) => BASE_API_ENDPOINT + "/peopl
export const REWIND_STATS = BASE_API_ENDPOINT + "/rewind/stats";
// Find
export const FIND_ASSETS = BASE_API_ENDPOINT + "/find/search";
// Share Link
export const SHARE_LINK_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token;
export const SHARE_LINK_GENERATE_PATH = BASE_API_ENDPOINT + "/share-link/generate";
export const SHARE_LINK_ASSETS_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token + "/assets";
+17
View File
@@ -0,0 +1,17 @@
import { SHARE_LINK_ASSETS_PATH, SHARE_LINK_GENERATE_PATH, SHARE_LINK_PATH } from "@/config/routes";
import { cleanUpAssets } from "@/helpers/asset.helper";
import API from "@/lib/api";
import { ShareLinkFilters } from "@/types/shareLink";
export const getShareLinkInfo = async (token: string) => {
return API.get(SHARE_LINK_PATH(token));
}
export const getShareLinkAssets = async (token: string) => {
return API.get(SHARE_LINK_ASSETS_PATH(token));
}
export const generateShareLink = async (filters: ShareLinkFilters) => {
return API.post(SHARE_LINK_GENERATE_PATH, filters);
}
+29 -3
View File
@@ -1,6 +1,4 @@
import { ASSET_PREVIEW_PATH, ASSET_THUMBNAIL_PATH, ASSET_VIDEO_PATH, PERSON_THUBNAIL_PATH } from "@/config/routes"
import { IPerson } from "@/types/person"
import { parseDate } from "./date.helper"
import { ASSET_PREVIEW_PATH, ASSET_SHARE_THUMBNAIL_PATH, ASSET_THUMBNAIL_PATH, ASSET_VIDEO_PATH, PERSON_THUBNAIL_PATH } from "@/config/routes"
import { IAsset } from "@/types/asset"
@@ -12,3 +10,31 @@ export const cleanUpAsset = (asset: IAsset): IAsset => {
videoURL: ASSET_VIDEO_PATH(asset.id),
}
}
export const cleanUpShareAsset = (asset: IAsset, token: string): IAsset => {
return {
...asset,
url: ASSET_SHARE_THUMBNAIL_PATH(asset.id, "thumbnail", token),
downloadUrl: ASSET_SHARE_THUMBNAIL_PATH(asset.id, "original", token),
previewUrl: ASSET_SHARE_THUMBNAIL_PATH(asset.id, "preview", token),
videoURL: ASSET_SHARE_THUMBNAIL_PATH(asset.id, "video", token),
}
}
export const cleanUpAssets = (assets: IAsset[]): IAsset[] => {
return assets.map(cleanUpAsset);
}
function isRotated90CW(orientation: number) {
return orientation === 5 || orientation === 6 || orientation === 90;
}
function isRotated270CW(orientation: number) {
return orientation === 7 || orientation === 8 || orientation === -90;
}
export function isFlipped(orientation?: string | null) {
const value = Number(orientation);
return value && (isRotated270CW(value) || isRotated90CW(value));
}
+10 -4
View File
@@ -1,7 +1,11 @@
import { ENV } from "@/config/environment"
import { IUser } from "@/types/user"
export const getUserHeaders = (user: IUser, otherHeaders?: {
export const getUserHeaders = (user: {
isUsingAPIKey?: boolean,
isUsingShareKey?: boolean,
accessToken?: string
}, otherHeaders?: {
'Content-Type': string
}) => {
let headers: {
@@ -11,11 +15,13 @@ export const getUserHeaders = (user: IUser, otherHeaders?: {
} = {
'Content-Type': 'application/json',
}
if (user.isUsingAPIKey) {
if (user.isUsingShareKey) {
headers['x-api-key'] = ENV.IMMICH_SHARE_LINK_KEY
} else if (user.isUsingAPIKey) {
headers['x-api-key'] = ENV.IMMICH_API_KEY
}
else {
} else {
headers['Authorization'] = `Bearer ${user.accessToken}`
}
return {...headers, ...otherHeaders}
}
+1 -3
View File
@@ -4,12 +4,10 @@ import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { NextApiResponse } from "next";
import { albums } from "@/schema/albums.schema";
import { count, desc, eq, min, max, sql, and } from "drizzle-orm";
import { count, eq, min, max, sql, and } from "drizzle-orm";
import { assets } from "@/schema/assets.schema";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { users } from "@/schema/users.schema";
import { assetFaces, exif, person } from "@/schema";
import { IAlbum } from "@/types/album";
export default async function handler(
req: NextApiRequest,
+44
View File
@@ -0,0 +1,44 @@
import { NextApiRequest } from "next";
import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { NextApiResponse } from "next";
import { albums } from "@/schema/albums.schema";
import { count, eq, min, max, sql, and } from "drizzle-orm";
import { assets } from "@/schema/assets.schema";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { assetFaces, exif, person } from "@/schema";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { id } = req.query as { id: string };
const dbAlbums = await db.selectDistinctOn([albums.id], {
id: albums.id,
albumName: albums.albumName,
createdAt: albums.createdAt,
updatedAt: albums.updatedAt,
albumThumbnailAssetId: albums.albumThumbnailAssetId,
assetCount: count(assets.id),
firstPhotoDate: min(exif.dateTimeOriginal),
lastPhotoDate: max(exif.dateTimeOriginal),
faceCount: count(sql<string>`DISTINCT ${person.id}`), // Ensure unique personId
})
.from(albums)
.leftJoin(albumsAssetsAssets, eq(albums.id, albumsAssetsAssets.albumsId))
.leftJoin(assets, eq(albumsAssetsAssets.assetsId, assets.id))
.leftJoin(exif, and(eq(assets.id, exif.assetId), eq(assets.isVisible, true)))
.leftJoin(assetFaces, eq(assets.id, assetFaces.assetId))
.leftJoin(person, and(eq(assetFaces.personId, person.id), eq(person.isHidden, false)))
.where(and(eq(albums.id, id)))
.groupBy(albums.id)
.limit(1);
if (dbAlbums.length === 0) {
return res.status(404).json({ error: "Album not found" });
}
res.status(200).json(dbAlbums[0]);
}
@@ -0,0 +1,63 @@
// pages/api/proxy.js
import { ENV } from '@/config/environment';
import { getUserHeaders } from '@/helpers/user.helper';
import { verify } from 'jsonwebtoken';
import { NextApiRequest, NextApiResponse } from 'next'
export const config = {
api: {
bodyParser: false,
},
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
return res.status(405).json({ message: 'Method Not Allowed' })
}
const { id, size, token } = req.query;
if (!token) {
return res.status(401).json({ message: 'Unauthorized' })
}
try {
verify(token as string, ENV.JWT_SECRET);
} catch (error) {
return res.status(401).json({ message: 'Token is invalid' })
}
let targetUrl = `${ENV.IMMICH_URL}/api/assets/${id}/thumbnail?size=${size || 'thumbnail'}`;
if (size === "original") {
targetUrl = `${ENV.IMMICH_URL}/api/assets/${id}/original`;
}
try {
// Forward the request to the target API
const response = await fetch(targetUrl, {
method: 'GET',
headers: getUserHeaders({ isUsingShareKey: true }, {
'Content-Type': 'application/octet-stream',
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error("Error fetching thumbnail " + error.message)
}
// Get the image data from the response
const imageBuffer = await response.arrayBuffer()
// Set the appropriate headers for the image response
res.setHeader('Content-Type', response.headers.get('Content-Type') || 'image/png')
res.setHeader('Content-Length', imageBuffer.byteLength)
// Send the image data
res.send(Buffer.from(imageBuffer))
} catch (error: any) {
res.redirect("https://placehold.co/400")
console.error('Error:', error)
}
}
@@ -20,7 +20,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const targetUrl = `${ENV.IMMICH_URL}/api/assets/${id}/thumbnail?size=${size || 'thumbnail'}`;
const user = await getCurrentUser(req);
if (!user) {
return res.status(403).json({ message: 'Unauthorized' })
}
+89
View File
@@ -0,0 +1,89 @@
import { db } from "@/config/db";
import { ENV } from "@/config/environment";
import { desc, gte, lte } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import { assetFaces, assets, exif } from "@/schema";
import { and } from "drizzle-orm";
import { sign, verify } from "jsonwebtoken";
import { NextApiResponse } from "next";
import { NextApiRequest } from "next";
import { albums } from "@/schema/albums.schema";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { cleanUpAsset, cleanUpAssets, cleanUpShareAsset, isFlipped } from "@/helpers/asset.helper";
import { IAsset } from "@/types/asset";
import { parseDate } from "@/helpers/date.helper";
interface ShareLinkFilters {
personIds: string[];
albumIds: string[];
startDate: string;
endDate: string;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { token } = req.query;
if (!token) {
return res.status(404).json({ message: "Token not found" });
}
const decoded = verify(token as string, ENV.JWT_SECRET);
if (!decoded) {
return res.status(401).json({ message: "Unauthorized" });
}
const newPreviewToken = sign({ token: token }, ENV.JWT_SECRET, {
expiresIn: "2m"
});
const { personIds, albumIds, startDate, endDate } = decoded as ShareLinkFilters;
const dbAssets = await db.select({
id: assets.id,
deviceId: assets.deviceId,
type: assets.type,
originalPath: assets.originalPath,
isFavorite: assets.isFavorite,
duration: assets.duration,
encodedVideoPath: assets.encodedVideoPath,
originalFileName: assets.originalFileName,
sidecarPath: assets.sidecarPath,
deletedAt: assets.deletedAt,
localDateTime: assets.localDateTime,
exifImageWidth: exif.exifImageWidth,
exifImageHeight: exif.exifImageHeight,
ownerId: assets.ownerId,
dateTimeOriginal: exif.dateTimeOriginal,
url: assets.id,
previewUrl: assets.id,
videoURL: assets.id,
orientation: exif.orientation,
}).from(assets)
.innerJoin(assetFaces, eq(assets.id, assetFaces.assetId))
.innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId))
.innerJoin(albums, eq(albumsAssetsAssets.albumsId, albums.id))
.innerJoin(exif, eq(exif.assetId, assets.id))
.where(and(
personIds?.length > 0 ? inArray(assetFaces.personId, personIds) : undefined,
albumIds?.length > 0 ? inArray(albums.id, albumIds) : undefined,
startDate ? gte(assets.createdAt, new Date(startDate)) : undefined,
endDate ? lte(assets.createdAt, new Date(endDate)) : undefined,
eq(assets.isArchived, false),
eq(assets.isVisible, true),
eq(assets.isOffline, false),
))
.orderBy(desc(assets.createdAt));
const cleanedAssets = dbAssets.map((asset) => {
return {
...asset,
localDateTime: new Date(asset.localDateTime),
duration: asset.duration ? parseInt(asset.duration) : 0,
exifImageWidth: (isFlipped(asset.orientation) ? asset.exifImageHeight : asset.exifImageWidth) ?? 0,
exifImageHeight: (isFlipped(asset.orientation) ? asset.exifImageWidth : asset.exifImageHeight) ?? 0,
dateTimeOriginal: new Date(asset.dateTimeOriginal || new Date()).toISOString(),
}
}).map((asset) => cleanUpShareAsset(asset, newPreviewToken as string));
return res.status(200).json(cleanedAssets);
}
+24
View File
@@ -0,0 +1,24 @@
import { ENV } from "@/config/environment";
import { SHARE_LINK_ASSETS_PATH } from "@/config/routes";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { sign } from "jsonwebtoken";
import { NextApiRequest, NextApiResponse } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const currentUser = await getCurrentUser(req);
const filters = req.body;
if (!currentUser) {
return res.status(401).json({ message: "Unauthorized" });
}
if (!ENV.IMMICH_SHARE_LINK_KEY) {
return res.status(401).json({ message: "Unauthorized" });
}
console.log(filters);
const token = sign(filters, ENV.JWT_SECRET);
return res.status(200).json({ link: `${ENV.POWER_TOOLS_ENDPOINT_URL}/s/${token}` });
}
+73
View File
@@ -0,0 +1,73 @@
import PageLayout from '@/components/layouts/PageLayout'
import Header from '@/components/shared/Header'
import Loader from '@/components/ui/loader'
import { useConfig } from '@/contexts/ConfigContext'
import { IAlbum } from '@/types/album'
import Link from 'next/link'
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import AlbumPeople from '@/components/albums/info/AlbumPeople'
import AlbumImages from '@/components/albums/info/AlbumImages'
import { Camera, ExternalLink, Users } from 'lucide-react'
import { humanizeNumber } from '@/helpers/string.helper'
import { getShareLinkInfo } from '@/handlers/api/shareLink.handler'
import AssetGrid from '@/components/shared/AssetGrid'
import { IAsset } from '@/types/asset'
export default function AlbumListPage() {
const router = useRouter()
const { token } = router.query as { token: string }
const [assets, setAssets] = useState<IAsset[]>([])
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const fetchAlbumInfo = async () => {
setLoading(true)
getShareLinkInfo(token)
.then(setAssets)
.catch((error) => {
setErrorMessage(error.message)
})
.finally(() => {
setLoading(false)
})
}
useEffect(() => {
fetchAlbumInfo()
}, [])
const renderContent = () => {
if (loading) {
return <Loader />
}
else if (errorMessage) {
return <div>{errorMessage}</div>
}
return (
<AssetGrid
assets={assets}
isInternal={false}
/>
)
}
return (
<PageLayout className="!p-0 !mb-0">
<Header
leftComponent={"Assets"}
rightComponent={
!loading && (
<div className="flex items-center gap-2">
<p className="flex items-center gap-1 text-sm text-gray-700 dark:text-gray-500">
{humanizeNumber(assets.length || 0)}
<Camera className="w-4 h-4" />
</p>
</div>
)
}
/>
{renderContent()}
</PageLayout>
)
}
+5
View File
@@ -88,3 +88,8 @@
}
}
#ReactGridGallery img {
max-width: none !important;
height: none !important;
}
+6 -6
View File
@@ -5,19 +5,19 @@ export interface IAsset {
type: string;
originalPath: string;
isFavorite: boolean;
duration: null;
encodedVideoPath: string;
duration: number | null;
encodedVideoPath: string | null;
originalFileName: string;
sidecarPath: null;
thumbhash: IAssetThumbhash;
deletedAt: null;
localDateTime: string;
thumbhash?: IAssetThumbhash;
localDateTime: string | Date;
exifImageWidth: number;
exifImageHeight: number;
url: string;
previewUrl: string;
videoURL?: string;
dateTimeOriginal: string;
orientation?: number | null | string;
downloadUrl?: string;
}
export interface IAssetThumbhash {
+6
View File
@@ -0,0 +1,6 @@
export interface ShareLinkFilters {
personIds?: string[];
albumIds?: string[];
startDate?: string;
endDate?: string;
}