Files
haos-hacs-immich-album-watcher/packages/server/src/immich_watcher_server/api/servers.py
alexei.dolgolyov 381de98c40
Some checks failed
Validate / Hassfest (push) Has been cancelled
Comprehensive review fixes: security, performance, code quality, and UI polish
Backend: Fix CORS wildcard+credentials, add secret key warning, remove raw
API keys from sync endpoint, fix N+1 queries in watcher/sync, fix
AttributeError on event_types, delete dead scheduled.py/templates.py,
add limit cap on history, re-validate server on URL/key update, apply
tracking/template config IDs in update_target.

HA Integration: Replace datetime.now() with dt_util.now(), fix notification
queue to only remove successfully sent items, use album UUID for entity
unique IDs, add shared links dirty flag and users cache hourly refresh,
deduplicate _is_quiet_hours, add HTTP timeouts, cache albums in config
flow, change iot_class to local_polling.

Frontend: Make i18n reactive via $state (remove window.location.reload),
add Modal transitions/a11y/Escape key, create ConfirmModal replacing all
confirm() calls, add error handling to all pages, replace Unicode nav
icons with MDI SVGs, add card hover effects, dashboard stat icons, global
focus-visible styles, form slide transitions, mobile responsive bottom
nav, fix password error color, add ~20 i18n keys (EN/RU).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:34:31 +03:00

192 lines
6.1 KiB
Python

"""Immich server management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
import aiohttp
from immich_watcher_core.immich_client import ImmichClient
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import ImmichServer, User
router = APIRouter(prefix="/api/servers", tags=["servers"])
class ServerCreate(BaseModel):
name: str = "Immich"
url: str
api_key: str
class ServerUpdate(BaseModel):
name: str | None = None
url: str | None = None
api_key: str | None = None
class ServerResponse(BaseModel):
id: int
name: str
url: str
created_at: str
@router.get("")
async def list_servers(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all Immich servers for the current user."""
result = await session.exec(
select(ImmichServer).where(ImmichServer.user_id == user.id)
)
servers = result.all()
return [
{"id": s.id, "name": s.name, "url": s.url, "created_at": s.created_at.isoformat()}
for s in servers
]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_server(
body: ServerCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Add a new Immich server (validates connection)."""
# Validate connection
async with aiohttp.ClientSession() as http_session:
client = ImmichClient(http_session, body.url, body.api_key)
if not await client.ping():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot connect to Immich server at {body.url}",
)
# Fetch external domain
external_domain = await client.get_server_config()
server = ImmichServer(
user_id=user.id,
name=body.name,
url=body.url,
api_key=body.api_key,
external_domain=external_domain,
)
session.add(server)
await session.commit()
await session.refresh(server)
return {"id": server.id, "name": server.name, "url": server.url}
@router.get("/{server_id}")
async def get_server(
server_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Get a specific Immich server."""
server = await _get_user_server(session, server_id, user.id)
return {"id": server.id, "name": server.name, "url": server.url, "created_at": server.created_at.isoformat()}
@router.put("/{server_id}")
async def update_server(
server_id: int,
body: ServerUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update an Immich server."""
server = await _get_user_server(session, server_id, user.id)
if body.name is not None:
server.name = body.name
url_changed = body.url is not None and body.url != server.url
key_changed = body.api_key is not None and body.api_key != server.api_key
if body.url is not None:
server.url = body.url
if body.api_key is not None:
server.api_key = body.api_key
# Re-validate and refresh external_domain when URL or API key changes
if url_changed or key_changed:
try:
async with aiohttp.ClientSession() as http_session:
client = ImmichClient(http_session, server.url, server.api_key)
if not await client.ping():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot connect to Immich server at {server.url}",
)
server.external_domain = await client.get_server_config()
except aiohttp.ClientError as err:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Connection error: {err}",
)
session.add(server)
await session.commit()
await session.refresh(server)
return {"id": server.id, "name": server.name, "url": server.url}
@router.delete("/{server_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_server(
server_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete an Immich server."""
server = await _get_user_server(session, server_id, user.id)
await session.delete(server)
await session.commit()
@router.get("/{server_id}/ping")
async def ping_server(
server_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Check if an Immich server is reachable."""
server = await _get_user_server(session, server_id, user.id)
async with aiohttp.ClientSession() as http_session:
client = ImmichClient(http_session, server.url, server.api_key)
ok = await client.ping()
return {"online": ok}
@router.get("/{server_id}/albums")
async def list_albums(
server_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Fetch albums from an Immich server."""
server = await _get_user_server(session, server_id, user.id)
async with aiohttp.ClientSession() as http_session:
client = ImmichClient(http_session, server.url, server.api_key)
albums = await client.get_albums()
return [
{
"id": a.get("id"),
"albumName": a.get("albumName"),
"assetCount": a.get("assetCount", 0),
"shared": a.get("shared", False),
"updatedAt": a.get("updatedAt", ""),
}
for a in albums
]
async def _get_user_server(
session: AsyncSession, server_id: int, user_id: int
) -> ImmichServer:
"""Get a server owned by the user, or raise 404."""
server = await session.get(ImmichServer, server_id)
if not server or server.user_id != user_id:
raise HTTPException(status_code=404, detail="Server not found")
return server