This project is a web application to manage photos for a Samsung The Frame television. It consists of a Python FastAPI backend and a React/TypeScript frontend.
These instructions will get you a copy of the project up and running on your local machine for development and testing purposes.
You need to have uv installed. uv is an extremely fast Python package installer and resolver, written in Rust.
-
Clone the repository:
git clone https://github.com/slknijnenburg/framegallery.git cd framegallery -
Backend Setup (in
/framegallery):Navigate to the backend directory:
cd framegalleryCreate a virtual environment and install the dependencies:
uv venv uv sync
-
Frontend Setup (in
/ui):Navigate to the frontend directory from the root:
cd uiInstall the dependencies using your preferred package manager (e.g.,
npm,yarn, orpnpm):npm install
-
Run the backend server:
From the
/framegallerydirectory, activate the virtual environment and start the FastAPI server:uv run uvicorn --port 7999 --reload framegallery.main:app
The backend will be running at
http://127.0.0.1:7999. -
Run the frontend development server:
From the
/uidirectory, start the Vite development server:npm run dev
The frontend will be accessible at
http://localhost:3000and will proxy API requests to the backend.
You can pull and run the latest pre-built image directly from GitHub Container Registry:
# Pull the latest image
docker pull ghcr.io/slknijnenburg/framegallery:latest
# Create directories for data and images
mkdir -p ./images ./data
# Run the container
docker run -d --name framegallery \
-p 7999:7999 \
-v $(pwd)/images:/app/images \
-v $(pwd)/data:/app/data \
--env-file .env \
ghcr.io/slknijnenburg/framegallery:latestOr using Docker Compose:
# Update docker-compose.yml to use the pre-built image
docker compose up -dIf you prefer to build the image locally:
docker build -f Dockerfile -t framegallery:local .Then run it with:
docker run -it --rm -p 127.0.0.1:7999:7999 -v $(pwd)/images:/app/images -v $(pwd)/data:/app/data framegallery:localOn start-up, the app will import all images from the images directory and create a database in the data directory.
It will also generate thumbnails for display in the browser for each image, so you'll need to ensure that the images folder is writeable by the container.
Create a .env file with your configuration. See .env.dist for all available options:
# Samsung TV Configuration
tv_ip_address=192.168.1.100
tv_port=8002 # 8002 = TLS + token auth (default); 8001 = plain WebSockets. See "TV port" below
tv_client_name=FrameGallery # device name registered on the TV; keep stable to avoid re-pairing
# Application Settings
gallery_path="./images"
db_url="sqlite:///./data/framegallery.db"
log_level=INFO
# Log level for the WebSocket libraries (websockets/samsungtvws) driving the TV
# connection. Kept separate from log_level because they emit very noisy
# ping/pong/keepalive messages at DEBUG. Raise to DEBUG only to debug the TV link.
websocket_log_level=WARNING
slideshow_interval=180
filesystem_refresh_interval=600
# Upload-processor strategy for pushing images to the TV (applied at startup —
# restart to change). One of: single_async (default), sync_thread, batch_slideshow.
# Useful for A/B testing which mechanism your TV tolerates without crashing. See
# the "Upload processors" section below.
upload_processor=single_async
# Settle delay (seconds) between consecutive TV commands in the single-image
# processors (single_async/sync_thread). Guards against Art Mode crashing when the
# switch command is sent before the TV has finished digesting the upload. 0 disables.
tv_command_delay=5.0
# Uploads are normalized to fit this box (downscaled, re-encoded as JPEG) before
# being sent. Defaults fit the 32" Frame's 1080p panel; raise to 3840x2160 on 4K
# models. See "Upload normalization" below.
upload_max_width=1920
upload_max_height=1080
upload_jpeg_quality=90
# Keep a copy of the last N upload payloads in $DATA_PATH/upload_debug/ (0 = none).
upload_debug_keep=0
# Art-mode watchdog: periodically checks whether the TV is reachable and still in art
# mode. See the "Art-mode watchdog" section below.
art_mode_watchdog_enabled=true
art_mode_poll_interval=60
# Docker Volume Mount Paths (customize for your setup)
IMAGES_PATH=./images
DATA_PATH=./data
LOGS_PATH=./logs
# CORS Security Configuration (optional)
# CORS_ALLOW_ALL=true # Use permissive CORS (less secure, good for development/testing)
# CORS_ORIGINS=http://localhost:3000,http://your-domain.com # Custom allowed originsThe Docker setup now supports configurable volume mount paths via environment variables:
IMAGES_PATH: Path to your images directory (default:./images)DATA_PATH: Path to application data directory (default:./data)LOGS_PATH: Path to logs directory (default:./logs)
Example with custom paths:
# In your .env file
IMAGES_PATH=/home/user/photos
DATA_PATH=/home/user/framegallery-data
LOGS_PATH=/var/log/framegallery
# Then run normally
docker compose up -dLogs are written to two places, and both are capped so they cannot grow without bound:
$LOGS_PATH/framegallery.logis rotated by the application at midnight UTC, keeping 7 days of history (framegallery.log.YYYY-MM-DD).- The container's stdout is captured by Docker and capped by the
loggingoptions indocker-compose.yml(7 files of 20 MB).
Note that the Docker-side limits only apply when the container is recreated (docker compose up -d); docker compose restart reuses the existing container and its current log settings.
Running at log_level=DEBUG produces roughly 15k lines per day, the bulk of it HTTP client chatter from httpcore/urllib3. Set log_level=INFO to reduce this substantially; websocket_log_level remains a separate knob so the TV connection can still be debugged independently.
tv_port selects the whole transport, not just the port number. samsungtvws derives
everything from it:
8002 (default) |
8001 |
|
|---|---|---|
| WebSocket | wss:// |
ws:// |
| Auth | token in the URL | none |
| Pairing | one-time "Allow" prompt on the TV | not required, and skipped automatically |
| REST probe | https |
http |
Both ports are served by current Frame firmware — 8001 is not a legacy-only option, and
was confirmed working on a 2023 model (QE32LS03CBUXXN). The app detects that 8001 needs
no token and skips pairing automatically. Note that 8001 is unencrypted and
unauthenticated on your LAN.
Stay on the default unless you are deliberately investigating. 8001 exists here so the option is available and does not silently hang the reconnect loop; it is not a recommendation.
Background. Some Frames abort uploads by closing the WebSocket with status 1005:
sync_thread: TV op 'upload local:1234' failed: ('Invalid close opcode %r', 1005)
1005 is reserved by RFC 6455 and must never appear on the wire, so the TV is sending a malformed close frame. A short probe on one 2023 Frame saw 20 uploads on 8001 without a single 1005, against roughly a third failing that way on 8002 in the same period — which hinted the errors relate to the token-authenticated channel rather than to uploading itself.
That hint did not hold up. The full-day test was run on a 2023 Frame: a day on each
port, several hundred uploads each, under identical conditions. The failure rate was
statistically identical — roughly a third of uploads on both ports — and only the error
string changed: Invalid close opcode 1005 on 8002 becomes [Errno 32] Broken pipe on
8001. Both are the same event (the TV killing the art channel in response to the upload
request) surfacing differently through TLS and plain sockets. Switching ports does not
change upload reliability; see docs/crash-analysis.md for the full analysis.
In case changes were made to the database schema, migrations will need to be executed manually when running the updated container:
docker run -it --rm -v $(pwd)/images:/app/images -v $(pwd)/data:/app/data ghcr.io/slknijnenburg/framegallery:latest uv run alembic upgrade headlatest- Latest stable release from the main branchmain- Latest development build from the main branchv1.0.0- Specific version tags (when available)
The Frame's aspect ratio is 16:9. Images with these dimensions can be configured with any matte. Images with an aspect ratio of 3:2 (e.g. 1920x1280) can also be configured with a matte. When using "none" the image will be slightly cropped to 1920x1080. Images with an aspect ratio of 4:3 (e.g. 1920x1440) can also be configured with a matte. When using "none" the image will be cropped to 1920x1080
It actually seems you can select any matte style for any image, as long the slideshow mode is disabled.
The way images are pushed to the TV is pluggable, selected at startup with the upload_processor
setting (restart to apply).
This exists mainly as a diagnostic tool: some Frame TVs crash in Art Mode during uploads/photo
changes, and switching the mechanism helps isolate the cause.
upload_processor |
Behaviour |
|---|---|
single_async (default) |
Persistent async WebSocket. On each slideshow tick, uploads one image, activates it, and deletes the previously-active image. |
sync_thread |
Same one-image-at-a-time behaviour, but via the synchronous samsungtvws client run in a background thread, with idle-connection recycling, Wake-on-LAN before retry, and bounded retries. |
batch_slideshow |
Uploads a batch of images to the TV once and hands rotation to the TV's own slideshow; the app stops pushing an image every interval. |
If you experience TV crashes, try switching from single_async to sync_thread (or
batch_slideshow) and observe whether the crashes stop.
Related settings:
| Variable | Default | Applies to | Description |
|---|---|---|---|
tv_mac_address |
(unset) | sync_thread |
TV MAC address; when set, a Wake-on-LAN packet is sent before retrying a failed connection (arp -n <tv-ip> to find it). |
tv_keepalive_interval |
0 |
sync_thread |
When set (seconds), the TV connection is kept warm with a WebSocket ping whenever it has been idle that long, and the 30s idle-recycle is disabled — so every upload runs on a long-lived connection instead of a brand-new one. 0 disables the keepalive and keeps the recycle. Experimental: this tests whether a warm channel lowers the TV's upload-rejection rate (see docs/crash-analysis.md). |
tv_command_delay |
5.0 |
single_async, sync_thread |
Settle delay in seconds inserted between consecutive TV commands: after upload before select_image, and before deleting the previous image. The Frame needs a moment to finish digesting an upload before it reliably accepts the next command; issuing them back-to-back can crash Art Mode back to regular TV. Set to 0 for the original back-to-back behaviour. |
batch_size |
50 |
batch_slideshow |
How many images to upload to the TV in one batch. |
batch_rotation_minutes |
3 |
batch_slideshow |
The TV's own rotation interval, in whole minutes (the TV API only accepts minutes, so slideshow_interval does not apply in this mode). |
In batch_slideshow mode the app-driven slideshow loop and the TV auto-cleanup service are
both suppressed, since the TV owns rotation and the processor manages its own batch.
Every image is normalized before it is sent to the TV, whatever library it came from.
Anything larger than the upload_max_width x upload_max_height box is downscaled to fit (aspect ratio preserved, never upscaled) and re-encoded as JPEG at upload_jpeg_quality; non-JPEG formats (PNG/HEIC) are converted even when they already fit.
EXIF rotation is baked into the pixels so portrait phone photos display upright.
A JPEG that already fits is sent byte-for-byte untouched.
This matters because the TV ingests uploads slowly and payload size drives how long the upload takes to confirm: full-resolution phone photos (30+ MP, 10+ MB originals from e.g. Immich) reliably exceeded the confirmation window, failing the upload after the bytes were sent and stranding an orphaned image on the TV (see docs/crash-analysis.md).
The defaults target the 32" Frame's 1080p panel; on a 4K model (43" and up) set upload_max_width=3840 and upload_max_height=2160.
For diagnostics, upload_debug_keep=N keeps the last N exact payloads in $DATA_PATH/upload_debug/ so you can inspect precisely what the TV received; 0 (default) keeps none.
The single-image processors upload a photo, activate it, and delete the one it replaced,
so the TV should only ever hold one of our images. Every image uploaded is tracked in the
database (latest_tv_content_id, pending_tv_deletions) until the TV confirms it is gone:
- Tracking is persisted, so a restart does not abandon whichever image was live at the time. This was the largest source of leaks: each restart used to strand one image on the TV with nothing able to identify or delete it.
- A new image is recorded before it is activated, so a failed
select_imageor delete cannot leave it untracked. - Failed deletes stay queued and are retried on the next slideshow tick, in a single
batched
delete_listcall rather than one command per image. - Uploads are never retried.
upload()streams the whole image and only then waits for the TV's reply, so a timeout usually means the TV has the image but we never learned its id. Retrying re-sends it, turning one failed cycle into several untracked copies.
Anything that still slips through — including images left by older versions — is swept up by the TV Auto-cleanup service on the Settings page, which keeps only the most recent few files. Use Run Cleanup Now there to clear an existing backlog in one go.
The Frame leaves art mode for two very different reasons: somebody picks up the remote to
watch television, or the art system crashes and the TV falls back to regular TV (typically
Samsung TV Plus). Both look identical from the outside — get_artmode simply reports off.
The watchdog therefore does not try to guess which happened. It probes on an interval and:
- Pauses slideshow pushes while art mode is off. Uploads are invisible on regular TV, and issuing them is itself a common way to wedge the Frame. Pushing resumes automatically when art mode comes back — i.e. when you finish watching and switch back.
- Detects a wedged art channel and rebuilds the connection. It probes power state over
REST (
/api/v2/, a plain HTTP GET) separately from the art WebSocket. That is what distinguishes "the TV is fine but art mode has crashed" from "the TV is gone entirely" — a distinction ICMP cannot make, since a Frame in standby still answers ping.
| Health | Meaning |
|---|---|
art_on |
Reachable and displaying art. The normal state. |
tv_mode |
Reachable, art mode off. Someone is watching TV, or art mode crashed. |
standby |
Reachable but powered down. Normal overnight. |
art_unavailable |
REST answers but the art channel does not — the art system has wedged. |
unreachable |
No response at all: powered off, rebooting, or off the network. |
Art mode is only forced back on immediately after our own writes. After each upload/select/delete sequence the processor re-checks art mode; finding it off at that moment means we knocked the TV out of it, so restoring cannot be fighting a person who just reached for the remote. The periodic poll deliberately never restores art mode.
The trade-off: an art-mode crash that happens between writes is indistinguishable from someone watching TV, so the Frame will stay on regular TV until you switch it back. Making the poll restore art mode too would close that gap at the cost of overriding you every time you sit down to watch something.
| Variable | Default | Description |
|---|---|---|
art_mode_watchdog_enabled |
true |
Enable the watchdog. With it off, nothing observes art mode and /api/status reports the art-mode fields as unknown. |
art_mode_poll_interval |
60 |
Seconds between probes. |
The Settings page has a TV Watch Mode toggle for when you want to watch television and be certain the app will not interfere. While it is on:
- no images are pushed to the TV at all, and
- art mode is never restored — including immediately after our own writes, the one case where the app would otherwise be confident it should intervene.
It is checked before the art-mode state, so it does not depend on art-mode detection being accurate or up to date. Turn it off and the slideshow resumes on the next tick.
This is a runtime setting stored in the database (tv_watch_mode_enabled), not an
environment variable, so it can be toggled without a restart. It is also available at
GET/POST /api/config/tv_watch_mode_enabled.
The slideshow can draw photos from multiple libraries at once, managed on the Libraries page.
- Local Gallery — the always-present default library, backed by the
images/folder and the SQLite database. Its selection is controlled by the active filter (configured on the Filters page). - Immich — one or more external Immich servers.
Add one on the Libraries page by entering the server's base URL (e.g.
http://immich.local:2283) and an API key, testing the connection, and selecting one or more albums.
Each enabled library has a weight.
The slideshow picks a source with probability proportional to weight × (number of matching photos),
so with equal weights every photo across all libraries is equally likely (a true random pick over the union).
Increase a library's weight to make it appear more often relative to its size.
External photos are fetched on demand: when a photo is chosen, its bytes are downloaded from the source just-in-time and uploaded to the TV. Nothing is mirrored locally. If a library is unreachable, it is skipped for that tick and the slideshow continues with the other libraries.
Each library on the Libraries page shows a live status chip — its number of matching photos, 0 matching photos,
or Unavailable: … when the server can't be reached. A warning banner appears when no enabled library has any
photos, so the "nothing to display" condition is visible in the UI rather than only in the server logs.
Create an API key in Immich under Account Settings → API Keys. It needs read access to albums and assets plus the ability to download originals:
| Permission | Used for |
|---|---|
album.read |
Listing albums and reading album contents |
asset.read |
Reading asset metadata (dimensions, filename) |
asset.download |
Downloading the original photo to send to the TV |
server.about |
Reporting the Immich version on "Test connection" (optional) |
The minimal set is album.read, asset.read, asset.download. An unrestricted key also works. (Immich versions
older than ~v1.118 have all-or-nothing keys, so any valid key works there.)
The key is stored in the application's SQLite database (in the mounted data/ volume, alongside the TV auth token)
and is never returned by the API — treat the data/ directory as sensitive. Because it is stored, you don't
need to re-enter it to change a library's album selection: opening a saved library reloads its albums with the
stored key, and you only type a key when you want to rotate it.
Crop and matte editing are only available for local images; external photos are displayed as-is (16:9 photos without a matte, others with a shadowbox).
Recent Immich releases stopped embedding assets in GET /api/albums/{id}, so album contents are enumerated with
POST /api/search/metadata (paged), and the union across the selected albums is de-duplicated. A random asset is
chosen client-side rather than via Immich's /search/random, which has been unreliable across releases.