Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ THUMBNAIL_DIR=./data/thumbnails
TV_RESOLUTION=4K
PORTRAIT_HANDLING=blur
UNSPLASH_API_KEY=
RIJKSMUSEUM_API_KEY=
NASA_API_KEY=
REDDIT_USER_AGENT=sawsube/1.0 (local self-hosted)
HOST=0.0.0.0
Expand Down
1 change: 0 additions & 1 deletion backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ class Settings(BaseSettings):
TV_RESOLUTION: str = "4K" # 4K | 1080p
PORTRAIT_HANDLING: str = "blur" # blur | crop | skip
UNSPLASH_API_KEY: str = ""
RIJKSMUSEUM_API_KEY: str = ""
NASA_API_KEY: str = ""
PEXELS_API_KEY: str = ""
PIXABAY_API_KEY: str = ""
Expand Down
38 changes: 28 additions & 10 deletions backend/routers/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ async def send_to_tv(image_id: int, tv_id: int, display: bool = True,
await s.commit()
existing = (await s.execute(
select(TVImage).where(TVImage.tv_id == tv_id, TVImage.image_id == image_id, TVImage.is_on_tv.is_(True))
)).scalar_one_or_none()
)).scalars().first()
if existing and existing.remote_id:
ti = existing
else:
Expand All @@ -236,14 +236,17 @@ async def send_to_tv(image_id: int, tv_id: int, display: bool = True,
@router.delete("/{image_id}/tv/{tv_id}")
async def remove_from_tv(image_id: int, tv_id: int, s: AsyncSession = Depends(get_session)):
tv = await s.get(TV, tv_id)
ti = (await s.execute(
# re-sending after a remove can leave multiple rows per (tv, image)
rows = (await s.execute(
select(TVImage).where(TVImage.tv_id == tv_id, TVImage.image_id == image_id)
)).scalar_one_or_none()
if not tv or not ti:
)).scalars().all()
if not tv or not rows:
raise HTTPException(404)
if ti.remote_id:
await tv_manager.delete_image(tv, ti.remote_id)
ti.is_on_tv = False
for ti in rows:
if ti.is_on_tv and ti.remote_id:
if not await tv_manager.delete_image(tv, ti.remote_id):
raise HTTPException(503, "TV unreachable — try again when the TV is on")
ti.is_on_tv = False
await s.commit()
return {"ok": True}

Expand All @@ -256,15 +259,30 @@ async def list_on_tv(tv_id: int, s: AsyncSession = Depends(get_session)):
return rows


def _tv_thumb_cache_path(tv_id: int, remote_id: str) -> str:
safe = _SAFE_NAME_RE.sub("_", remote_id)
return os.path.join(settings.IMAGE_CACHE_DIR, "tv_thumbs", f"{tv_id}_{safe}.jpg")


@router.get("/tv/{tv_id}/thumbnail/{remote_id}")
async def tv_thumb(tv_id: int, remote_id: str, s: AsyncSession = Depends(get_session)):
tv = await s.get(TV, tv_id)
if not tv:
raise HTTPException(404)
cache_path = _tv_thumb_cache_path(tv_id, remote_id)
data = await tv_manager.get_thumbnail(tv, remote_id)
if not data:
raise HTTPException(404)
return Response(content=data, media_type="image/jpeg")
if data:
try:
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
with open(cache_path, "wb") as f:
f.write(data)
except OSError as e:
log.warning("tv thumb cache write failed %s: %s", cache_path, e)
return Response(content=data, media_type="image/jpeg")
# TV unreachable (asleep/off) — serve the last cached copy if we have one
if os.path.exists(cache_path):
return FileResponse(cache_path, media_type="image/jpeg")
raise HTTPException(404)


# ── Sync all library images → TV ────────────────────────────────────────────
Expand Down
29 changes: 14 additions & 15 deletions backend/routers/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,10 @@ async def scan(fid: int, s: AsyncSession = Depends(get_session)):

# ── Unsplash ───────────────────────────────────────────────────────────────
@router.get("/unsplash/search")
async def unsplash_search(q: str = Query(...), per_page: int = 20):
async def unsplash_search(q: str = Query(...), per_page: int = 20, page: int = 1):
if not settings.UNSPLASH_API_KEY:
raise HTTPException(503, "UNSPLASH_API_KEY not configured — add it to .env")
return await unsplash.search(q, per_page)
return await unsplash.search(q, per_page, page)


@router.post("/unsplash/import", response_model=ImageOut)
Expand Down Expand Up @@ -104,10 +104,8 @@ async def nasa_import():

# ── Rijksmuseum ────────────────────────────────────────────────────────────
@router.get("/rijksmuseum/search")
async def rijks_search(q: str = Query(...), per_page: int = 20):
if not settings.RIJKSMUSEUM_API_KEY:
raise HTTPException(503, "RIJKSMUSEUM_API_KEY not configured — add it to .env")
return await rijksmuseum.search(q, per_page)
async def rijks_search(q: str = Query(...), per_page: int = 20, page_token: str = ""):
return await rijksmuseum.search(q, per_page, page_token or None)


@router.post("/rijksmuseum/import", response_model=ImageOut)
Expand All @@ -128,10 +126,10 @@ async def rijks_import(payload: ImportPayload):

# ── Pexels ────────────────────────────────────────────────────────────────
@router.get("/pexels/search")
async def pexels_search(q: str = Query(...), per_page: int = 20):
async def pexels_search(q: str = Query(...), per_page: int = 20, page: int = 1):
if not settings.PEXELS_API_KEY:
raise HTTPException(503, "PEXELS_API_KEY not configured — add it to .env")
return await pexels.search(q, per_page)
return await pexels.search(q, per_page, page)


@router.post("/pexels/import", response_model=ImageOut)
Expand All @@ -153,10 +151,10 @@ async def pexels_import(payload: ImportPayload):

# ── Pixabay ──────────────────────────────────────────────────────────────
@router.get("/pixabay/search")
async def pixabay_search(q: str = Query(...), per_page: int = 20):
async def pixabay_search(q: str = Query(...), per_page: int = 20, page: int = 1):
if not settings.PIXABAY_API_KEY:
raise HTTPException(503, "PIXABAY_API_KEY not configured — add it to .env")
return await pixabay.search(q, per_page)
return await pixabay.search(q, per_page, page)


@router.post("/pixabay/import", response_model=ImageOut)
Expand All @@ -178,8 +176,8 @@ async def pixabay_import(payload: ImportPayload):

# ── Reddit ─────────────────────────────────────────────────────────────────
@router.get("/reddit/fetch")
async def reddit_fetch(sub: str = Query(...), sort: str = "top", t: str = "week", limit: int = 20):
return await reddit.fetch(sub, sort, t, limit)
async def reddit_fetch(sub: str = Query(...), sort: str = "top", t: str = "week", limit: int = 20, after: str = ""):
return await reddit.fetch(sub, sort, t, limit, after)


@router.post("/reddit/import", response_model=ImageOut)
Expand All @@ -197,8 +195,8 @@ async def reddit_import(payload: ImportPayload):

# ── Reddit Galleries ────────────────────────────────────────────────────────
@router.get("/reddit-gallery/fetch")
async def reddit_gallery_fetch(sub: str = Query(...), sort: str = "top", t: str = "week", limit: int = 25):
return await reddit_gallery.fetch(sub, sort, t, limit)
async def reddit_gallery_fetch(sub: str = Query(...), sort: str = "top", t: str = "week", limit: int = 25, after: str = ""):
return await reddit_gallery.fetch(sub, sort, t, limit, after)


@router.post("/reddit-gallery/import", response_model=ImageOut)
Expand All @@ -224,8 +222,9 @@ async def openverse_search(
license_type: str = "",
aspect_ratio: str = "wide",
size: str = "large",
page: int = 1,
):
return await openverse.search(q, page_size, category, license_type, aspect_ratio, size)
return await openverse.search(q, page_size, category, license_type, aspect_ratio, size, page)


@router.post("/openverse/import", response_model=ImageOut)
Expand Down
13 changes: 8 additions & 5 deletions backend/services/sources/nasa_apod.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
async def today() -> dict | None:
url = "https://api.nasa.gov/planetary/apod"
params = {"api_key": settings.NASA_API_KEY or "DEMO_KEY"}
async with httpx.AsyncClient(timeout=15.0) as c:
r = await c.get(url, params=params)
if r.status_code != 200:
return None
j = r.json()
try:
async with httpx.AsyncClient(timeout=15.0) as c:
r = await c.get(url, params=params)
if r.status_code != 200:
return None
j = r.json()
except httpx.HTTPError:
return None
if j.get("media_type") != "image":
return {"unsupported": True, "title": j.get("title"), "media_type": j.get("media_type")}
return {
Expand Down
8 changes: 6 additions & 2 deletions backend/services/sources/openverse.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,13 @@ async def search(
license_type: str = "",
aspect_ratio: str = "wide",
size: str = "large",
) -> list[dict]:
page: int = 1,
) -> dict:
page = max(1, int(page))
params: dict = {
"q": q,
"page_size": min(max(int(page_size), 1), 100),
"page": page,
"mature": "false",
"filter_dead": "true",
}
Expand Down Expand Up @@ -108,7 +111,8 @@ async def search(
"width": item.get("width"),
"height": item.get("height"),
})
return out
has_more = out and page < int(j.get("page_count") or 0)
return {"items": out, "next": str(page + 1) if has_more else None}


async def get(image_id: str) -> dict | None:
Expand Down
10 changes: 6 additions & 4 deletions backend/services/sources/pexels.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,17 @@ def _normalise(p: dict) -> dict:
}


async def search(query: str, per_page: int = 20) -> list[dict]:
async def search(query: str, per_page: int = 20, page: int = 1) -> dict:
if not settings.PEXELS_API_KEY:
return []
params = {"query": query, "per_page": min(int(per_page), 80), "orientation": "landscape"}
return {"items": [], "next": None}
page = max(1, int(page))
params = {"query": query, "per_page": min(int(per_page), 80), "page": page, "orientation": "landscape"}
async with httpx.AsyncClient(timeout=15.0) as c:
r = await c.get(f"{_BASE}/search", params=params, headers=_headers())
r.raise_for_status()
j = r.json()
return [_normalise(p) for p in j.get("photos", [])]
items = [_normalise(p) for p in j.get("photos", [])]
return {"items": items, "next": str(page + 1) if items and j.get("next_page") else None}


async def get(photo_id: str) -> dict | None:
Expand Down
14 changes: 10 additions & 4 deletions backend/services/sources/pixabay.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,23 +33,29 @@ def _normalise(p: dict) -> dict:
}


async def search(query: str, per_page: int = 20) -> list[dict]:
async def search(query: str, per_page: int = 20, page: int = 1) -> dict:
if not settings.PIXABAY_API_KEY:
return []
return {"items": [], "next": None}
page = max(1, int(page))
per_page = max(3, min(int(per_page), 200))
params = {
"key": settings.PIXABAY_API_KEY,
"q": query,
"image_type": "photo",
"orientation": "horizontal",
"safesearch": "true",
"per_page": max(3, min(int(per_page), 200)),
"per_page": per_page,
"page": page,
"order": "popular",
}
async with httpx.AsyncClient(timeout=15.0) as c:
r = await c.get(_BASE, params=params)
r.raise_for_status()
j = r.json()
return [_normalise(p) for p in j.get("hits", [])]
items = [_normalise(p) for p in j.get("hits", [])]
# totalHits is how many results the API will actually serve (max 500)
has_more = items and page * per_page < int(j.get("totalHits") or 0)
return {"items": items, "next": str(page + 1) if has_more else None}


async def get(photo_id: str) -> dict | None:
Expand Down
12 changes: 7 additions & 5 deletions backend/services/sources/reddit.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ def _fetch_reddit_json(url: str, params: dict, user_agent: str) -> dict:
return _json.loads(resp.read().decode())


async def fetch(sub: str, sort: str = "top", t: str = "week", limit: int = 20) -> list[dict]:
async def fetch(sub: str, sort: str = "top", t: str = "week", limit: int = 20, after: str = "") -> dict:
global _last_call
if not _SUB_RE.match(sub or ""):
return []
return {"items": [], "next": None}
if sort not in {"top", "hot", "new", "rising", "controversial"}:
sort = "top"
if t not in {"hour", "day", "week", "month", "year", "all"}:
Expand All @@ -42,17 +42,19 @@ async def fetch(sub: str, sort: str = "top", t: str = "week", limit: int = 20) -
await asyncio.sleep(delay)
url = f"https://www.reddit.com/r/{sub}/{sort}.json"
params = {"limit": limit, "t": t}
if after:
params["after"] = after
user_agent = settings.REDDIT_USER_AGENT
try:
j = await loop.run_in_executor(
None, _fetch_reddit_json, url, params, user_agent
)
except urllib.error.HTTPError as exc:
log.warning("Reddit fetch blocked (HTTP %s): %s", exc.code, url)
return []
return {"items": [], "next": None}
except Exception as exc:
log.warning("Reddit fetch error: %s", exc)
return []
return {"items": [], "next": None}
_last_call = loop.time()
out = []
for child in (j.get("data") or {}).get("children", []):
Expand All @@ -71,4 +73,4 @@ async def fetch(sub: str, sort: str = "top", t: str = "week", limit: int = 20) -
"html": "https://www.reddit.com" + d.get("permalink", ""),
"subreddit": d.get("subreddit"),
})
return out
return {"items": out, "next": (j.get("data") or {}).get("after")}
12 changes: 7 additions & 5 deletions backend/services/sources/reddit_gallery.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,11 @@ def _extract_gallery_images(post: dict) -> list[dict]:
return out


async def fetch(sub: str, sort: str = "top", t: str = "week", limit: int = 20) -> list[dict]:
async def fetch(sub: str, sort: str = "top", t: str = "week", limit: int = 20, after: str = "") -> dict:
"""Fetch gallery posts from a subreddit. Returns flat list of individual images."""
global _last_call
if not _SUB_RE.match(sub or ""):
return []
return {"items": [], "next": None}
if sort not in {"top", "hot", "new", "rising", "controversial"}:
sort = "top"
if t not in {"hour", "day", "week", "month", "year", "all"}:
Expand All @@ -104,17 +104,19 @@ async def fetch(sub: str, sort: str = "top", t: str = "week", limit: int = 20) -
await asyncio.sleep(delay)
url = f"https://www.reddit.com/r/{sub}/{sort}.json"
params = {"limit": post_limit, "t": t}
if after:
params["after"] = after
user_agent = settings.REDDIT_USER_AGENT
try:
j = await loop.run_in_executor(
None, _fetch_reddit_json, url, params, user_agent
)
except urllib.error.HTTPError as exc:
log.warning("Reddit gallery fetch blocked (HTTP %s): %s", exc.code, url)
return []
return {"items": [], "next": None}
except Exception as exc:
log.warning("Reddit gallery fetch error: %s", exc)
return []
return {"items": [], "next": None}
_last_call = loop.time()

out: list[dict] = []
Expand All @@ -125,4 +127,4 @@ async def fetch(sub: str, sort: str = "top", t: str = "week", limit: int = 20) -
continue
out.extend(_extract_gallery_images(d))

return out
return {"items": out, "next": (j.get("data") or {}).get("after")}
Loading