dumbtv
dumbtv가 ADB로 Fire TV에서 광고·추적·업데이트 알림을 제거, 루트·앱 변경 없이, 무료 오픈소스
동작 시 Tapo 카메라 녹화·선택한 클라우드·디스크에 저장·한 페이지에서 무료 오픈소스로 시청
Records your Tapo camera only on motion and keeps the videos where you choose: YouTube, OneDrive, Nextcloud, any cloud or your own disk. Watch them from one page. Free, open source, self-hosted.
보안Python · HTMLGitHub @LuigiElleBalotta온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Tapo C200 to YouTube: record only on motion (ONVIF + RTSP + ffmpeg), upload private videos with a daily playlist. No cloud subscription. Raspberry Pi, VPS, Docker.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다.
README 전문 보기 ↗🌐 English · Italiano
A self-hosted web app that records your TP-Link Tapo camera (tested on the Tapo C200) only when it detects motion and keeps the videos in the storage you choose: YouTube, your own OneDrive, Nextcloud or any WebDAV, any rclone cloud (Mega, S3, Dropbox...) or the disk of the machine that runs it. One page to watch everything, whichever storage it lives in. No cloud subscription, no firmware hacking.
The Tapo C200 can't send its recordings to your own storage by itself. tapo-uploader is a small
Python service that pulls the camera's RTSP stream with ffmpeg, listens for ONVIF motion
events, and saves the result where you decide. Upload destinations can be mixed and changed at any
time: the videos you saved earlier stay on your page, each marked with the storage it lives in. It
runs for one person from a single .env, or for a group of friends with accounts, each with their
own camera and storage. A Raspberry Pi, your own PC, a cheap VPS or a Docker container is enough.
Tapo C200 ──RTSP──▶ ffmpeg ──▶ 30 s before + 5 min after each motion ──▶ one video a day, with chapters
(home) ──ONVIF motion events──▶ ──▶ YouTube · OneDrive · WebDAV · rclone · disk
(a page to watch them all)
ffmpeg -c copy: close to zero CPU, so a Raspberry Pi is plenty.Dockerfile, and Render blueprints included.There are two modes. Both use the same camera, the same storage settings and the same .env.
Continuous (tapo_uploader.py) | Motion (tapo_motion.py) | |
|---|---|---|
| Records | Everything, in 6-hour segments | Only around motion: 30 s before, 5 min after |
| Uploads | Each segment as soon as it closes | One video a day, at a fixed time |
| Disk use | A few GB at most | Almost nothing (about one minute of buffer) |
| Needs | RTSP | RTSP + ONVIF events (port 2020) |
| YouTube quota (if YouTube is the storage) | 4 uploads/day | 1 upload/day |
tapo_uploader.py run starts two threads:
-c copy (the video is not re-encoded) and
writes YYYYMMDD_HHMMSS.mp4 files into recordings/. Cuts are aligned to the wall clock: with
6-hour segments they close at 00:00, 06:00, 12:00 and 18:00. If the stream drops, ffmpeg restarts.private videos) titled
Tapo YYYY-MM-DD HH:MM-HH:MM, adds them to the playlist Tapo YYYY-MM-DD (created if missing)
and deletes the file. If an upload fails the file stays and is retried.The camera can't upload on its own, so you need an always-on computer that can reach it: a Raspberry Pi or mini PC at home, a VPS, or a container host.
| You need | Notes |
|---|---|
| A Tapo camera with RTSP | Tested on the Tapo C200. You need the "Camera Account" (step 1). |
| An always-on computer | Linux recommended (VPS, Raspberry Pi). Python 3.10+ and ffmpeg. |
| Somewhere to keep the videos | A YouTube channel (a Google account), a OneDrive, a Nextcloud/WebDAV, an rclone cloud, or just the disk. |
| Router access (only if the server is not at home) | To open a port towards the camera. |
Disk: at 640×360 a 6-hour segment is about 0.4 GB. A few free GB are enough because files are deleted after upload. Bandwidth: about 1 MB per minute of video.
Other ONVIF cameras that expose RTSP may work, but only the Tapo C200 has been tested.
In the Tapo app open the camera, then Settings (gear) → Advanced → Camera Account. Create a
username and password just for the camera. They are not your Tapo/cloud account: using
those is the most common mistake and gives 401 Unauthorized.
Find the camera's IP address in the app (Device Info) or in your router, and ideally give it a fixed IP (DHCP reservation) in the router.
Set the video quality to High (Settings → Video Quality). It may default to low: in our case both streams were 640×360.
The stream address is:
rtsp://USER:PASSWORD@CAMERA_IP:554/stream1 (high quality)
rtsp://USER:PASSWORD@CAMERA_IP:554/stream2 (low quality)
If the password contains special characters (!, @, :) they must be URL-encoded. The script
does it for you; by hand, a letters-and-digits password is easier.
Important limit: the camera accepts one RTSP session per stream. While the service records
stream1, a second program openingstream1getsOperation not permitted. To experiment while the service runs, usestream2(CAMERA_STREAM=stream2) or stop the service first.
Only if you want YouTube as the storage. For OneDrive, Nextcloud/WebDAV, rclone or the disk skip to 3 and follow the storage setup guide.
The script uploads to your channel through the official YouTube Data API v3, so you need a small Google "project" that identifies the app. It is free.
tapo-uploader, Create.tapo-uploader, support email = yours.tapo-uploader. Create.client_secret.json in the project folder. It is a
secret: don't commit it (.gitignore excludes it).While the app stays in Testing, the access token expires after 7 days and the script stops uploading until you log in again. To avoid it, publish the app ("In production"). For personal use Google needs no verification, but the Publish app button stays disabled until Branding has:
Use your own pages, not this project's. Google checks that the home page and the privacy policy belong to the domain of whoever owns the app, and that they describe that app. The policy in this repository names its author as the person responsible and describes the author's own server, so it would be wrong on your app and could fail Google's review. Copy
index.htmlandprivacy.html(MIT licensed) and rewrite the privacy policy for your own use: who runs it, what is stored, and who can use it (just you, or other people on your server: then say so, and name the services involved). A link back to this project on your home page is appreciated but not required. Each person who runs the software needs their own Google app and their own pages.
You don't need a real website, only two static pages. The quickest way is GitHub Pages:
index.html (one line about what
the app does) and privacy.html (what it accesses, what is stored, how to revoke access). This
repository has both, ready to copy and adapt.main branch, folder /.https://YOURUSER.github.io/REPO-NAME/.YOURUSER.github.io. Save.Publish before creating the token (step 3). A token created while the app was in Testing can stay subject to the 7-day expiry even after publishing: to be safe, delete
token.jsonand runauthagain after publishing.
These steps work on any system (Windows, macOS, Linux). You need Python 3.10+ and ffmpeg in the
PATH (ffmpeg -version to check).
git clone https://github.com/LuigiElleBalotta/tapo-uploader.git
cd tapo-uploader
python -m venv .venv
. .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # then fill in CAMERA_USER, CAMERA_PASS, CAMERA_HOST
Choose where the videos go first (see Where the recordings go; to try it with no account at all use
STORAGE=local and LOCAL_ROOT=/some/folder in .env). If you use YouTube, put client_secret.json (step 2.3) in the
folder, then:
python tapo_uploader.py check # 1. does the camera answer? prints codec and resolution
python tapo_uploader.py auth # 2. Google login, once: creates token.json
python tapo_uploader.py test # 3. records 60 s and uploads them: checks everything
check must print codec_name=h264, the resolution and the audio. 401 Unauthorized means
wrong credentials; Operation not permitted means the stream is already in use (see above).auth prints an address: open it in a browser on the same computer, choose the Google
account of the channel and allow YouTube access. The browser shows "The authentication flow has
completed" and token.json appears. If, while in Testing, you see "Google hasn't verified this
app": Advanced → Go to tapo-uploader.test records 60 seconds, uploads them as a private video and creates the day's playlist.
Check that the video is on YouTube, then delete it from Studio.If the server has no browser, run auth on your PC and copy token.json to the server.
The program must stay on 24/7 and be able to reach the camera. The options, simplest first:
| Where | Rough cost | How it reaches the camera | Notes |
|---|---|---|---|
| Your own PC (Windows, macOS, Linux) | free | LAN | Fine if it stays on and awake. No port forwarding. See below. |
| Raspberry Pi / mini PC at home | one-off, about $15–50 | LAN | Recommended: always on, no port forwarding, no DDNS, no exposed camera. |
| VPS (as in this guide) | a few dollars a month | port forwarding limited to the VPS's fixed IP | Steps 4 and 5. This is the tested setup. |
| Render (Background Worker) | about $7/month, plus $0.25/GB of disk | port forwarding to Render's shared IPs | Docker image included, not tested. See below. |
| Render free (web service) | $0 | port forwarding to Render's shared IPs | Tested up to startup, not with the camera. Ephemeral disk: clips can be lost. See below. |
| Vercel | – | – | Not suitable. See below. |
Other container platforms (Fly.io, Railway) follow the same pattern as Render: the Dockerfile
works anywhere, only prices and networking change.
Yes, a personal computer works, with one condition: it must stay on, awake and on the same network as the camera, because it records only while the program runs. A laptop that sleeps in the evening will miss the motion. Nothing is opened on the router. A short version (the full, illustrated-by-commands guide is the PC install guide):
winget install Python.Python.3.12, winget install Gyan.FFmpeg,
winget install Git.Git. macOS: brew install python ffmpeg git. Linux: sudo apt install python3 python3-venv ffmpeg git.git clone https://github.com/LuigiElleBalotta/tapo-uploader.git, cd tapo-uploader, python -m venv .venv, activate it
(.venv\Scripts\Activate.ps1 on Windows, . .venv/bin/activate elsewhere) and pip install -r requirements.txt..env.example to .env and set CAMERA_HOST, CAMERA_USER, CAMERA_PASS. To keep the videos on the PC:
STORAGE=local and LOCAL_ROOT=C:\Tapo (or /home/you/Tapo). Other storages: see
Where the recordings go.python tapo_uploader.py check, then python tapo_motion.py run.With Docker Desktop you can skip Python and ffmpeg and use the published image.
Render can run an always-on process (a Background Worker) from a Docker image. The repository
contains Dockerfile and render.yaml. Things to know:
0.5c-512mb: 0.5 CPU and 512 MB) is listed at $7/month. Render's
free plan is for web services only, not workers. With ffmpeg in -c copy mode CPU is not the
limit; RAM was not measured on Render.render.yaml asks for one mounted at /data, and the script uses it through
DATA_DIR.token.json and client_secret.json are passed as
the environment variables YOUTUBE_TOKEN_JSON and YOUTUBE_CLIENT_SECRET_JSON (the files'
contents). On first start the script writes them to /data with mode 600; afterwards the
refreshed token stays on disk. The login (auth) is done once on your PC.Steps: connect the repository to Render (New → Blueprint), fill in the variables it asks for
(CAMERA_USER, CAMERA_PASS, CAMERA_HOST/CAMERA_PORT, ONVIF_HOST/ONVIF_PORT,
YOUTUBE_TOKEN_JSON, YOUTUBE_CLIENT_SECRET_JSON), open the router ports to Render's IPs and watch
the worker's logs. Render only asks for the variables when the Blueprint is created: later you
change them in the dashboard. For continuous recording instead of motion mode, uncomment
dockerCommand in render.yaml.
For people without a server. The VPS remains the main choice: this is a zero-cost alternative
with precise limits. Deploying as a web service on the free plan was tried on Render up to
startup (build, health endpoint on port 10000, ffmpeg start); recording from the camera
through Render was not verified, because it needs the router rules described below.
How it works: the program opens a small HTTP port (/health, active when Render sets PORT) and
a free monitoring service (UptimeRobot, cron-job.org) calls it every 5 minutes. Per
Render's documentation a free service sleeps after 15 minutes
without inbound traffic; outbound traffic doesn't count, so a call from outside is needed. The
750 free monthly hours are enough for one always-on service.
Steps (the ones actually done, from the dashboard):
Dockerfile by itself.python -c "import json;print(json.dumps(json.load(open('token.json')),separators=(',',':')))").
CAMERA_USER=...
CAMERA_PASS=...
CAMERA_HOST=your-ddns-name.example.com
CAMERA_PORT=external RTSP port
ONVIF_HOST=your-ddns-name.example.com
ONVIF_PORT=external ONVIF port
CAMERA_STREAM=stream1
DATA_DIR=/data
TZ=Europe/Rome
YOUTUBE_TOKEN_JSON=...
YOUTUBE_CLIENT_SECRET_JSON=...
health endpoint on port 10000 and ffmpeg start. The warning file_cache is only supported with oauth2client<4.0.0 is harmless.https://NAME.onrender.com) and, with the Connect →
Outbound button, the IP ranges to give to the router (in our case 74.220.51.0/24 and
74.220.59.0/24: yours depend on the region and may differ).https://NAME.onrender.com/health every
5 minutes.If you already run a VPS with this camera: the camera accepts one RTSP session per stream. If
the VPS and Render both record stream1, whichever connects second gets
Operation not permitted. Run a single service per stream, or set CAMERA_STREAM=stream2 on
Render.
The render.free.yaml Blueprint was not tried: opening a Blueprint with the default render.yaml
(the $7/month worker) makes Render ask for a payment card. The web service created by hand, as
above, did not.
Limits and risks, read them first:
| Limit | Consequence |
|---|---|
| The disk is wiped on every restart, deploy or spin-down | Clips of the day not yet uploaded are lost. Render may restart the service at any time. |
| If the service sleeps (monitor stopped, hours used up) | No recording until it wakes up (about a minute after the first request). |
| Free hours used up | All free services are suspended until next month. Fine with one service and the monitor, not with two. |
| Render may suspend free services with very high outbound traffic | Here it is a few MB a day, but rules can change. |
| The refreshed YouTube token is not persisted | The token comes from YOUTUBE_TOKEN_JSON; every restart starts from it. It works until it is revoked. |
| RAM and CPU of the free plan are not documented | Not measured: ffmpeg in -c copy is light, but not verified. |
| The external monitor is one more dependency | If the monitoring service changes its rules, the service sleeps. |
| Render's IPs are shared and not fixed | See below: it is the most serious risk. |
The most serious risk is the camera's security. Render's outbound IPs are shared by every service in the region and are not in the documentation: you find them in the service's dashboard, Connect → Outbound. You have two options, both with a price:
If the connection to the camera drops, the script retries by itself with growing delays: only that stretch is lost. If your home IP changes, the DDNS name (step 5.1) follows the update.
To get the same results as the VPS without these limits you need a persistent disk (paid, see above) or a Raspberry Pi at home.
The answer here is no, and it is about how the platform works, not about configuration.
Vercel can still serve a status page or an API that reads the state of the videos, with the recording running elsewhere.
Example for Ubuntu/Debian (a VPS, a Raspberry Pi, a mini PC). Replace USER with your Linux user.
sudo apt-get update
sudo apt-get install -y ffmpeg python3-venv # python3-venv is really needed on Ubuntu
mkdir -p ~/tapo-uploader && cd ~/tapo-uploader
# copy here: tapo_uploader.py, tapo_motion.py, camera_context.py, camera_worker.py, requirements.txt, .env, client_secret.json, token.json
chmod 600 .env client_secret.json token.json # only you can read them
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
The server's .env is the PC's one, with CAMERA_HOST and CAMERA_PORT pointing to the camera
as seen from the internet (step 5), not to the home IP.
So it starts at boot and restarts by itself if it stops (no cron needed).
sudo cp deploy/tapo-uploader.service /etc/systemd/system/
sudo nano /etc/systemd/system/tapo-uploader.service # set USER and the paths
sudo systemctl daemon-reload
sudo systemctl enable tapo-uploader
Don't start it yet: first step 5 and the check in step 6.
The file sets Environment=TZ=Europe/Rome: without it, a server on UTC cuts segments at 1 AM
Italian time and changes playlist at UTC midnight. Change the timezone as needed.
If the server is on the same network as the camera, skip this step and use the local IP. Otherwise the camera, which sits behind your home router, must be made reachable only from your server.
The Tapo app works away from home because the camera connects outbound to TP-Link's servers. That doesn't apply to RTSP: you need an open port on the router.
Your home's public IP usually changes. A free DDNS service gives a fixed name that follows the IP. With FreeDNS (https://freedns.afraid.org, free, no monthly confirmations):
A, pick a free domain and a hard-to-guess name (for
example cam- plus random characters). Destination: your current IP.? in the "Direct URL"
(they call it "hash").freedns, domain name = the full subdomain, hash =
the token. Apply: the status should say the update succeeded.Check that the name resolves to your IP: nslookup your-name.domain.com.
If the "WAN" IP shown by the router differs from the public IP you see on a site like
ifconfig.me, you are behind CGNAT and no port forwarding can work: use a VPN (Tailscale, WireGuard) between home and server, or run the script at home.
In the router: Port Forwarding → new rule.
| Field | Value |
|---|---|
| Protocol | TCP |
| WAN (external) port | a high port of your choice, e.g. 38554, not the standard 554 |
| LAN host | the camera's IP |
| LAN host port | 554 |
| Remote host IPs | your server's public IP (the same value as range start and end) |
The "Remote host IPs" field is what matters: it makes the camera unreachable from any other address on the internet. If your router doesn't allow it, consider a VPN.
Then in the server's .env:
CAMERA_HOST=your-name.domain.com
CAMERA_PORT=38554
bash -c '</dev/tcp/your-name.domain.com/38554' && echo PORT_OPEN
.venv/bin/python tapo_uploader.py check
From outside (for example your phone on 5G) the same port must show as closed.
cd ~/tapo-uploader
.venv/bin/python tapo_uploader.py test # records 60 s and uploads, from the server
sudo systemctl start tapo-uploader
systemctl status tapo-uploader # must be "active (running)"
journalctl -u tapo-uploader -f # live logs
ls -la recordings # the segment being recorded
Remember the one-session-per-stream limit: run check and test before starting the service,
or with CAMERA_STREAM=stream2.
The first automatic upload happens after the first segment closes (at the next 00, 06, 12 or 18
o'clock) and the file disappears from recordings/. On YouTube you find the private video and the
day's playlist. Private playlists don't show on your channel's public page: look in
YouTube → You → Playlists or in Studio → Content → Playlists.
An alternative to continuous recording (tapo_uploader.py): tapo_motion.py records only when
the camera detects motion and uploads a single video a day, for example at 21:00. Continuous
recording uploads 4 videos a day and keeps everything; here you keep only what happened.
ffmpeg ──▶ ring of 10 s segments (only the last minute) ──┐
camera ──ONVIF events──▶ motion? ──▶ clip: 30 s before + 5 min after ──▶ events/YYYYMMDD/
│
at 21:00: all the day's clips ──▶ one private video ──▶ files deleted
Rules:
MOTION_PRE_SECONDS (30 s) before to
MOTION_POST_SECONDS (5 min) after.MOTION_EXTEND_WITHIN seconds (30) of the window extends it by
another 5 minutes from that moment (for example motion at 4:50). The same if the motion is still
going on. Motion during the earlier part of the window is ignored.UPLOAD_AT (21:00) the day's clips are joined without re-encoding into one private video.
The title is Tapo 2026-10-26 events 07:12-20:55 (5) and the description lists the time of
every event (0:00 Motion at 07:12:03...): YouTube shows it as chapters. Then the files are
deleted. If a day had no events nothing is uploaded. An event that ends after 21:00 goes into the
next video.What you also need:
Detection turned on in the Tapo app (Settings → Detection & Alarms). Events depend on what
is on: IsMotion = motion detection, IsPeople = person detection. Choose with
MOTION_EVENTS (default: both).
ONVIF reachable from the server. Events go through the camera's port 2020. On the LAN nothing
is needed; remotely you need a second port-forwarding rule like the one in step 5.2: external
port 38020 (your choice) to CAMERA_IP:2020, TCP, only from the server's IP. In the
server's .env:
ONVIF_HOST=your-name.domain.com
ONVIF_PORT=38020
On some routers (ZTE H388X) the created rule only becomes active after pressing Apply a second
time: check from the server with bash -c '</dev/tcp/your-name.domain.com/38020'.
The service in place of the continuous one. The camera accepts one RTSP session per stream: stop one before starting the other.
sudo systemctl disable --now tapo-uploader
sudo cp deploy/tapo-motion.service /etc/systemd/system/ # set USER and the paths
sudo systemctl daemon-reload && sudo systemctl enable --now tapo-motion
journalctl -u tapo-motion -f
Commands:
python tapo_motion.py listen 60 # prints the motion events the camera sends (ONVIF test)
python tapo_motion.py run # records around motion, uploads once a day
python tapo_motion.py upload # joins and uploads the pending clips right now
Quick test with short times, from a PC on the LAN (use stream2 if the service holds
stream1):
CAMERA_STREAM=stream2 MOTION_POST_SECONDS=20 MOTION_EXTEND_WITHIN=10 UPLOAD_AT=00:00 \
python tapo_motion.py run
Walk in front of the camera: the log shows motion: recording..., then clip saved and, within a
minute, the upload of the day's video (UPLOAD_AT=00:00 makes it due immediately).
Good to know:
listen prints nothing: the camera only reports state changes.An optional mode for a group of trusted people (friends, family) on one server. Each person signs in, links
their own camera and their own YouTube channel, and sees only their own videos and camera status on a
personal page. Nothing changes for single-user setups: without MULTIUSER=1 the programs above work exactly as
before, with the same .env.
What it adds: a small web app (python -m tapo_web run) with a SQLite database, a supervisor that runs one
recorder per camera, a router guide for each friend (manual steps and a text to paste into an AI assistant),
a "Test connection" button, and an administrator page. Sign-in is configurable: local accounts, and/or
Nextcloud (its groups become roles). Passwords and tokens are stored encrypted.
Quota, read this first. All users share the Google project's YouTube quota (10,000 units a day by default). One full daily upload costs about 1,700 units, so one project serves about 5 or 6 users at one upload a day. Ways around it: ask Google for a higher quota, let some users upload every other day, or enable
ALLOW_OWN_GOOGLE_CLIENTso a user brings their own Google project. The admin page shows the quota used today.
tapo_web folder), then
python3 -m venv .venv && .venv/bin/pip install -r requirements-web.txt.https://YOUR-ADDRESS/youtube/callback. Put its JSON in YOUTUBE_CLIENT_SECRET_JSON.
The app must be In production (step 2.4 above); beyond 100 users Google requires verification. Other
people will now use your app: update the consent screen and the privacy policy page to say so..env.example and fill it in. Generate the two keys:
python -c "from tapo_web.crypto import Vault; print(Vault.generate_key())" for ENCRYPTION_KEY, and any long
random string for SECRET_KEY. Keep ENCRYPTION_KEY backed up: without it the stored passwords and tokens
cannot be read. Set PUBLIC_URL (HTTPS) and SERVER_PUBLIC_IP (the address friends will allow in their routers).python -m tapo_web create-admin, or set ADMIN_USERNAME and ADMIN_PASSWORD for the
first start.https://YOUR-ADDRESS/auth/nextcloud/callback, then set NEXTCLOUD_URL, NEXTCLOUD_CLIENT_ID and
NEXTCLOUD_CLIENT_SECRET. Roles come from groups: members of NEXTCLOUD_ADMIN_GROUP (default admin) are
administrators, members of NEXTCLOUD_USER_GROUP may use the service. If that group is not set, only
administrators can sign in. Roles are re-checked at every login: someone removed from the group is locked out at
their next login, and stays out until an administrator reactivates them.python -m tapo_web run (port PORT, default
8080), or as a service with deploy/tapo-web.service, or in Docker with
CMD ["python", "-u", "-m", "tapo_web", "run"]. Behind one reverse proxy set TRUSTED_PROXY_HOPS=1, otherwise every visitor looks like the proxy and the login attempt limits cannot tell them apart. It is not meant for Render's free plan (ephemeral disk).Every push to main publishes a multi-architecture image (amd64 and arm64, so also a Raspberry Pi) to GitHub
Container Registry: ghcr.io/luigiellebalotta/tapo-uploader:latest, plus :sha-XXXXXXX and, for releases, :1.2.3.
Nothing to clone or build:
docker run -d --name tapo-web --restart unless-stopped -p 127.0.0.1:8080:8080 --env-file tapo-web.env -v tapo-data:/data ghcr.io/luigiellebalotta/tapo-uploader:latest python -u -m tapo_web run
# update later: docker pull ghcr.io/luigiellebalotta/tapo-uploader:latest, then remove and run the container again
deploy/docker-compose.yml does the same with docker compose up -d (update with docker compose pull && docker compose up -d).
The image's default command is the single-user motion mode (tapo_motion.py run).
The friend signs in, then Connect YouTube (their own channel), then Add a camera (their dynamic DNS name,
the camera account from the Tapo app). The server assigns two random external ports and shows a router guide:
open those two ports only for SERVER_PUBLIC_IP. Test connection checks the port, the video stream and the
motion events; only then does recording start.
ALLOW_PRIVATE_HOSTS=1 only on a home network.MAX_CAMERAS_PER_USER), 20 in total (MAX_CAMERAS_TOTAL).UPLOAD_AT, then UPLOAD_STAGGER_MINUTES apart).RETENTION_DAYS (30). If someone's YouTube link is
missing or revoked, their clips are kept for 7 days and then discarded..env.example.By default the daily video goes to YouTube. You can send it somewhere else. A step-by-step guide to set up each destination (Google app, Azure app for OneDrive, Nextcloud app password, rclone) is on the storage setup page:
| Destination | STORAGE | What it needs |
|---|---|---|
| YouTube (default) | youtube | the Google setup above |
| WebDAV: Nextcloud, ownCloud, Synology... | webdav | WEBDAV_URL, WEBDAV_USER, WEBDAV_PASS (for Nextcloud with two-factor, an app password), optional WEBDAV_FOLDER |
| A folder on the machine running the program | local | LOCAL_ROOT |
| Anything rclone supports: Mega, S3-compatible storage (Backblaze B2, Wasabi, MinIO), Dropbox, OneDrive... | rclone | the rclone program, a remote created with rclone config, RCLONE_REMOTE (optional RCLONE_CONFIG, RCLONE_FOLDER) |
Set the variables in .env (they are all listed in .env.example) and restart. The videos are stored as
<folder>/Tapo YYYY-MM-DD/<title>.mp4, with the chapters in a .chapters.txt file next to each. A file is written
under a temporary name and renamed when it is complete, so an interrupted upload never looks finished, and the
clips stay on the machine until the upload succeeds. This applies to the motion mode (tapo_motion.py); the
continuous mode (tapo_uploader.py) always uses YouTube.
Mega works through rclone's mega backend. The author has not tested it: Mega offers no official API for
this, so read rclone's documentation for its limits before relying on it.
In multi-account mode the server chooses the default (STORAGE and the STORAGE_* variables) and every user
gets it without doing anything, even without signing in with Nextcloud. Destinations that live on the server
(WebDAV, folder, rclone) get one sub-folder per camera, so nobody can see another person's videos. With
ALLOW_OWN_STORAGE=1 each user can pick, on the Recordings page, YouTube, their own WebDAV or their own
OneDrive (STORAGE_ALLOWED narrows the choice; the address must be public and https). local and rclone are
server-only on purpose. For OneDrive each person registers their own free Microsoft app at
portal.azure.com (App registrations, supported account types "any organizational directory
and personal Microsoft accounts"), adds the web redirect PUBLIC_URL/storage/onedrive/callback, creates a client secret
and pastes the ID and the secret on the Recordings page (kept encrypted with their account); an administrator may
instead set MS_CLIENT_ID and MS_CLIENT_SECRET as a shared app for people who type nothing. The app can only use its own
folder (Files.ReadWrite.AppFolder), never the rest of their files. OneDrive is tested against a fake of Microsoft's
API, not yet against a real account. Keeping videos on the server: add local to STORAGE_ALLOWED and people can choose it on the Recordings page (no folder to pick:
STORAGE_LOCAL_ROOT/<user>/camera-<n>, default DATA_DIR/videos). Rotation is optional and off by default:
LOCAL_KEEP_DAYS (0 = never; each person may set their own), LOCAL_QUOTA_GB per person (0 = unlimited; an administrator
may set one per person on the Admin page, and the oldest videos go first when it is full) and DISK_MIN_FREE_GB (default 1:
below it every page warns and the oldest local videos are deleted). Videos in a cloud are never deleted by this; if a
cloud refuses an upload the clips wait on the server and are retried every ten minutes.
Whenever a user changes destination the previous one stays in their list and its
videos stay on the personal page, each marked with where it lives. local and rclone are
server-only on purpose: a path or an rclone configuration chosen by a user would give them access to the server
itself. If a saved choice stops being allowed, the server default is used and the page says so. Only YouTube
spends the shared quota. With a server-hosted destination the : plan the disk space
and who can read it.
Watching: in multi-account mode the personal page shows every video as a card with two actions. Watch here opens a player on the page (YouTube's embedded player, or the app itself streaming the file, with seeking, only to its owner). Open the video goes to the video on YouTube, or to the file in your own cloud (nothing for a local folder or rclone). YouTube does not play private videos (the default) inside another site, so for those the card only has Open the video; Watch here appears for public and unlisted videos and for every other destination. The audio is converted to AAC while recording and the video is copied as it comes: keep the camera on H.264, because H.265 files may not play in some browsers.
Clips that have not been uploaded yet (waiting for the daily upload) appear in a Waiting for upload group, marked Not uploaded yet, and can be watched with Watch here straight from the server, whatever the destination.
sudo systemctl status tapo-uploader # status
journalctl -u tapo-uploader -f # logs
sudo systemctl restart tapo-uploader # after changing .env or the script
sudo systemctl stop tapo-uploader # stop it
ls -la ~/tapo-uploader/recordings # segments waiting or being recorded
(Use tapo-motion instead of tapo-uploader in motion mode.)
.env file (from .env.example, never in the repository):
| Variable | Meaning |
|---|---|
CAMERA_USER, CAMERA_PASS | Camera account created in the Tapo app (not the cloud account). |
CAMERA_HOST, CAMERA_PORT | On the LAN the camera's IP and 554. Remotely the DDNS name and external port. |
CAMERA_STREAM | stream1 (high quality) or stream2 (low). |
SEGMENT_SECONDS | Length of each file, default 21600 (6 hours). |
RECORD_DIR | Folder of files waiting for upload, default recordings. |
YOUTUBE_CLIENT_SECRET, YOUTUBE_TOKEN | Names of the client_secret.json and token.json files. |
YOUTUBE_PRIVACY | private (default), unlisted or public. |
PLAYLIST_PREFIX | Prefix of titles and playlists (default Tapo). |
DATA_DIR | Folder for token, state and pending videos (default: the project folder). In a container it is the persistent disk, e.g. /data. |
YOUTUBE_TOKEN_JSON, YOUTUBE_CLIENT_SECRET_JSON | Contents of token.json and client_secret.json, for containers: if the files are missing they are created from these. |
Only for tapo_motion.py (defaults in brackets):
| Variable | Meaning |
|---|---|
MOTION_PRE_SECONDS (30) | Seconds recorded before the first motion. |
MOTION_POST_SECONDS (300) | Seconds recorded after the motion. |
MOTION_EXTEND_WITHIN (30) | Motion in the last N seconds of the window extends it. |
MOTION_EVENTS (IsMotion,IsPeople) | ONVIF events that count as motion. |
UPLOAD_AT (21:00) | Time after which the day's video is uploaded. |
BUFFER_SEGMENT_SECONDS (10) | Length of the ring's segments. |
BUFFER_DIR, EVENTS_DIR | Folders of the ring and of the clips (buffer, events). |
ONVIF_HOST, ONVIF_PORT (camera host, 2020) | Where to reach the ONVIF events. |
-segment_atclocktime 1 cuts at 00/06/12/18; the cut happens at
the next keyframe, so durations vary by a few seconds.-use_wallclock_as_timestamps 1 -fflags +genpts.movflags=+frag_keyframe+empty_moov+default_base_moof. A normal MP4 writes
its index (moov) only at the end: if ffmpeg is killed the file is unreadable. With
fragmentation each piece is already complete.state.json.concat (copy) into one clip; the day's clips are joined the
same way into the video that is uploaded.rtsp://***@...), because ffmpeg
prints the full URL in errors. ffmpeg only logs errors.Each upload costs about 1,650 units (1,600 the video, 50 the playlist insert) and the default daily quota is 10,000: about 6 videos a day.
| Segment length | Uploads per day | Within quota? |
|---|---|---|
| 6 hours (default) | 4 | yes |
| 4 hours | 6 | at the limit |
| 1 hour | 24 | no, you must ask Google for an increase |
| Motion mode | 1 | yes |
If the quota runs out the script writes it in the log and retries after an hour. The quota resets at midnight Pacific Time.
.env, client_secret.json, token.json. .gitignore excludes them.token.json gives access to your YouTube channel: mode 600. If lost, revoke access at
https://myaccount.google.com/permissions.| Symptom | Cause and fix |
|---|---|
401 Unauthorized | Wrong username or password of the camera account (not the Tapo cloud ones). |
Operation not permitted | Another session is already using that stream: stop the service or use stream2. |
| Timeout from the server | Missing port forwarding, server IP changed, home IP changed and DDNS not updated, or CGNAT. |
ensurepip is not available creating the venv | python3-venv is missing: sudo apt-get install python3-venv. |
invalid_grant on YouTube | Token expired or revoked (typical if the app was in Testing): run auth again and copy token.json. |
| The "Publish app" button is grey | Home page, privacy policy or authorized domain missing in Branding. |
moov atom not found on a file | Segment written by a version without fragmented MP4 and interrupted: unrecoverable, delete it. |
| Playlist with the wrong day or cuts at 1 AM | The process runs on UTC: TZ is missing in the service. |
| I can't see the playlist in my channel | It is private: look in YouTube → You → Playlists, not on the channel's public page. |
| No upload after hours | The segment is not closed yet: look at recordings/ and journalctl -u tapo-uploader. |
listen never prints anything | Detection is off in the Tapo app, or there is no motion: events arrive only on state changes. |
| ONVIF events: timeout or no answer | The ONVIF port is unreachable (rule to re-apply, server IP changed). Try bash -c '</dev/tcp/HOST/PORT'. |
| The camera answers only the first ONVIF request | Happens with long PullMessages or standard HTTP clients: the script uses 1 s requests over a plain socket. |
| Events always ignored | MOTION_EVENTS doesn't contain the event the camera sends (IsPeople instead of IsMotion, or vice versa). |
quotaExceeded | Too many uploads for the daily quota: lengthen the segments. |
Can a Tapo C200 save its videos to my own storage? Not by itself. The camera has no YouTube, OneDrive or Google Drive option. This project adds it from outside: a small service reads the camera's RTSP stream, records around motion and saves the result where you choose (YouTube through its official API, OneDrive, Nextcloud/WebDAV, an rclone cloud or a disk).
Can I use my own computer? Yes, if it stays on, awake and on the same network as the camera. See Your own PC.
Do I need to flash custom firmware or root the camera? No. The stock firmware already exposes RTSP and ONVIF once you create the Camera Account in the Tapo app. Nothing on the camera is modified.
Is this a free alternative to Tapo Care or a cloud subscription? It replaces the cloud storage part: footage goes to your own YouTube channel, which is free. You lose the app's cloud timeline, so keep Tapo Care if you rely on it. Running the service costs nothing on a Raspberry Pi you already own, and a few dollars a month on a VPS.
Will the videos be public?
No. They are uploaded as private and only your Google account can see them. You can change it to
unlisted or public with YOUTUBE_PRIVACY.
Does it record all day or only on motion?
Your choice. tapo_motion.py records only around motion detection (30 s before, 5 min after) and
uploads one video a day. tapo_uploader.py records continuously in segments.
How do I record a Tapo camera on motion only?
Use tapo_motion.py: it subscribes to the camera's ONVIF motion and person-detection events and
keeps a short ring buffer so the seconds before the trigger are included. See
Motion mode.
Does it work with other TP-Link Tapo cameras or other brands? Only the Tapo C200 has been tested. Any camera that offers RTSP and ONVIF event notifications has a good chance, with different stream paths or event names. Issues and pull requests with other models are welcome.
Will I hit the YouTube API quota? No. One upload costs about 1,650 of the 10,000 free daily units. Motion mode uses one upload a day; continuous mode with 6-hour segments uses four.
Do I need to expose my camera to the internet? Only if the server is not on your home network. At home (Raspberry Pi, mini PC) nothing is exposed. With a remote VPS you forward one or two ports restricted to the server's IP; see step 5 and Security.
Can it run on Docker, Render or a Raspberry Pi?
Yes to all three. There's a Dockerfile, Render blueprints and a systemd unit. See
Where to run it for the honest trade-offs of each.
Why not Home Assistant, Frigate or Scrypted? They are great and much bigger. This is a single small script for one job: get motion footage into YouTube without running a home-automation stack.
Issues and pull requests are welcome, especially: other camera models, router guides for port forwarding, a notification on failures, and a tested Render or Fly.io setup. If this saved you a subscription, a ⭐ helps other people find it.
MIT, see LICENSE.