diff --git a/packages/server/src/immich_watcher_server/api/scheduled.py b/packages/server/src/immich_watcher_server/api/scheduled.py new file mode 100644 index 0000000..40950dd --- /dev/null +++ b/packages/server/src/immich_watcher_server/api/scheduled.py @@ -0,0 +1,152 @@ +"""Scheduled notification job API routes.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from ..auth.dependencies import get_current_user +from ..database.engine import get_session +from ..database.models import AlbumTracker, ScheduledJob, User + +router = APIRouter(prefix="/api/scheduled", tags=["scheduled"]) + + +class ScheduledJobCreate(BaseModel): + tracker_id: int + job_type: str # periodic_summary, scheduled_assets, memory + enabled: bool = True + times: str = "09:00" + interval_days: int = 1 + start_date: str = "2025-01-01" + album_mode: str = "per_album" + limit: int = 10 + favorite_only: bool = False + asset_type: str = "all" + min_rating: int = 0 + order_by: str = "random" + order: str = "descending" + min_date: str | None = None + max_date: str | None = None + message_template: str = "" + + +class ScheduledJobUpdate(BaseModel): + enabled: bool | None = None + times: str | None = None + interval_days: int | None = None + album_mode: str | None = None + limit: int | None = None + favorite_only: bool | None = None + asset_type: str | None = None + min_rating: int | None = None + order_by: str | None = None + order: str | None = None + min_date: str | None = None + max_date: str | None = None + message_template: str | None = None + + +@router.get("") +async def list_jobs( + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + """List all scheduled jobs for the current user's trackers.""" + trackers = await session.exec( + select(AlbumTracker).where(AlbumTracker.user_id == user.id) + ) + tracker_ids = [t.id for t in trackers.all()] + if not tracker_ids: + return [] + + result = await session.exec( + select(ScheduledJob).where(ScheduledJob.tracker_id.in_(tracker_ids)) + ) + return [_job_response(j) for j in result.all()] + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def create_job( + body: ScheduledJobCreate, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + """Create a scheduled notification job.""" + # Verify tracker ownership + tracker = await session.get(AlbumTracker, body.tracker_id) + if not tracker or tracker.user_id != user.id: + raise HTTPException(status_code=404, detail="Tracker not found") + + if body.job_type not in ("periodic_summary", "scheduled_assets", "memory"): + raise HTTPException(status_code=400, detail="Invalid job_type") + + job = ScheduledJob(**body.model_dump()) + session.add(job) + await session.commit() + await session.refresh(job) + return _job_response(job) + + +@router.put("/{job_id}") +async def update_job( + job_id: int, + body: ScheduledJobUpdate, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + """Update a scheduled job.""" + job = await _get_user_job(session, job_id, user.id) + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(job, field, value) + session.add(job) + await session.commit() + await session.refresh(job) + return _job_response(job) + + +@router.delete("/{job_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_job( + job_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + """Delete a scheduled job.""" + job = await _get_user_job(session, job_id, user.id) + await session.delete(job) + await session.commit() + + +def _job_response(j: ScheduledJob) -> dict: + return { + "id": j.id, + "tracker_id": j.tracker_id, + "job_type": j.job_type, + "enabled": j.enabled, + "times": j.times, + "interval_days": j.interval_days, + "start_date": j.start_date, + "album_mode": j.album_mode, + "limit": j.limit, + "favorite_only": j.favorite_only, + "asset_type": j.asset_type, + "min_rating": j.min_rating, + "order_by": j.order_by, + "order": j.order, + "min_date": j.min_date, + "max_date": j.max_date, + "message_template": j.message_template, + "created_at": j.created_at.isoformat(), + } + + +async def _get_user_job( + session: AsyncSession, job_id: int, user_id: int +) -> ScheduledJob: + job = await session.get(ScheduledJob, job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + tracker = await session.get(AlbumTracker, job.tracker_id) + if not tracker or tracker.user_id != user_id: + raise HTTPException(status_code=404, detail="Job not found") + return job diff --git a/packages/server/src/immich_watcher_server/api/trackers.py b/packages/server/src/immich_watcher_server/api/trackers.py index 832ed2a..5ece775 100644 --- a/packages/server/src/immich_watcher_server/api/trackers.py +++ b/packages/server/src/immich_watcher_server/api/trackers.py @@ -23,6 +23,14 @@ class TrackerCreate(BaseModel): enabled: bool = True quiet_hours_start: str | None = None quiet_hours_end: str | None = None + track_images: bool = True + track_videos: bool = True + notify_favorites_only: bool = False + include_people: bool = True + include_asset_details: bool = False + max_assets_to_show: int = 5 + assets_order_by: str = "none" + assets_order: str = "descending" class TrackerUpdate(BaseModel): @@ -35,6 +43,14 @@ class TrackerUpdate(BaseModel): enabled: bool | None = None quiet_hours_start: str | None = None quiet_hours_end: str | None = None + track_images: bool | None = None + track_videos: bool | None = None + notify_favorites_only: bool | None = None + include_people: bool | None = None + include_asset_details: bool | None = None + max_assets_to_show: int | None = None + assets_order_by: str | None = None + assets_order: str | None = None @router.get("") @@ -174,6 +190,14 @@ def _tracker_response(t: AlbumTracker) -> dict: "enabled": t.enabled, "quiet_hours_start": t.quiet_hours_start, "quiet_hours_end": t.quiet_hours_end, + "track_images": t.track_images, + "track_videos": t.track_videos, + "notify_favorites_only": t.notify_favorites_only, + "include_people": t.include_people, + "include_asset_details": t.include_asset_details, + "max_assets_to_show": t.max_assets_to_show, + "assets_order_by": t.assets_order_by, + "assets_order": t.assets_order, "created_at": t.created_at.isoformat(), } diff --git a/packages/server/src/immich_watcher_server/auth/routes.py b/packages/server/src/immich_watcher_server/auth/routes.py index 2d3897b..fbb8b3f 100644 --- a/packages/server/src/immich_watcher_server/auth/routes.py +++ b/packages/server/src/immich_watcher_server/auth/routes.py @@ -136,3 +136,5 @@ async def needs_setup(session: AsyncSession = Depends(get_session)): result = await session.exec(select(func.count()).select_from(User)) count = result.one() return {"needs_setup": count == 0} + + diff --git a/packages/server/src/immich_watcher_server/config.py b/packages/server/src/immich_watcher_server/config.py index 99c52b6..6047c5d 100644 --- a/packages/server/src/immich_watcher_server/config.py +++ b/packages/server/src/immich_watcher_server/config.py @@ -29,6 +29,7 @@ class Settings(BaseSettings): # Telegram webhook secret (used to validate incoming webhook requests) telegram_webhook_secret: str = "" + model_config = {"env_prefix": "IMMICH_WATCHER_"} @property diff --git a/packages/server/src/immich_watcher_server/database/__init__.py b/packages/server/src/immich_watcher_server/database/__init__.py index 0f2bb6a..7b6d840 100644 --- a/packages/server/src/immich_watcher_server/database/__init__.py +++ b/packages/server/src/immich_watcher_server/database/__init__.py @@ -8,6 +8,7 @@ from .models import ( ImmichServer, MessageTemplate, NotificationTarget, + ScheduledJob, User, ) diff --git a/packages/server/src/immich_watcher_server/database/models.py b/packages/server/src/immich_watcher_server/database/models.py index c072f69..618925c 100644 --- a/packages/server/src/immich_watcher_server/database/models.py +++ b/packages/server/src/immich_watcher_server/database/models.py @@ -58,6 +58,8 @@ class MessageTemplate(SQLModel, table=True): user_id: int = Field(foreign_key="user.id") name: str body: str = Field(default="") + body_ru: str = Field(default="") # Russian locale variant (empty = use body) + event_type: str = Field(default="") # "" = all events, or specific type is_default: bool = Field(default=False) created_at: datetime = Field(default_factory=_utcnow) @@ -82,6 +84,43 @@ class AlbumTracker(SQLModel, table=True): enabled: bool = Field(default=True) quiet_hours_start: str | None = None # "HH:MM" quiet_hours_end: str | None = None # "HH:MM" + # Enhanced filtering (matching HAOS blueprint) + track_images: bool = Field(default=True) + track_videos: bool = Field(default=True) + notify_favorites_only: bool = Field(default=False) + include_people: bool = Field(default=True) + include_asset_details: bool = Field(default=False) + max_assets_to_show: int = Field(default=5) + assets_order_by: str = Field(default="none") # none/date/rating/name/random + assets_order: str = Field(default="descending") + created_at: datetime = Field(default_factory=_utcnow) + + +class ScheduledJob(SQLModel, table=True): + """Scheduled notification job (periodic summary, scheduled assets, memory mode).""" + + __tablename__ = "scheduled_job" + + id: int | None = Field(default=None, primary_key=True) + tracker_id: int = Field(foreign_key="album_tracker.id") + job_type: str # "periodic_summary", "scheduled_assets", "memory" + enabled: bool = Field(default=True) + # Timing + times: str = Field(default="09:00") # "HH:MM, HH:MM" + interval_days: int = Field(default=1) # For periodic: 1=daily, 7=weekly + start_date: str = Field(default="2025-01-01") # For periodic interval anchor + # Asset fetching config (scheduled_assets + memory modes) + album_mode: str = Field(default="per_album") # per_album/combined/random + limit: int = Field(default=10) + favorite_only: bool = Field(default=False) + asset_type: str = Field(default="all") # all/photo/video + min_rating: int = Field(default=0) # 0=no filter, 1-5 + order_by: str = Field(default="random") + order: str = Field(default="descending") + min_date: str | None = None + max_date: str | None = None + # Template + message_template: str = Field(default="") # Custom Jinja2 template for this job created_at: datetime = Field(default_factory=_utcnow) diff --git a/packages/server/src/immich_watcher_server/main.py b/packages/server/src/immich_watcher_server/main.py index 7df9a61..afe3819 100644 --- a/packages/server/src/immich_watcher_server/main.py +++ b/packages/server/src/immich_watcher_server/main.py @@ -22,6 +22,7 @@ from .api.targets import router as targets_router from .api.users import router as users_router from .api.status import router as status_router from .api.sync import router as sync_router +from .api.scheduled import router as scheduled_router from .ai.telegram_webhook import router as telegram_ai_router logging.basicConfig( @@ -72,6 +73,7 @@ app.include_router(targets_router) app.include_router(users_router) app.include_router(status_router) app.include_router(sync_router) +app.include_router(scheduled_router) app.include_router(telegram_ai_router) # Serve frontend static files if available