Clip Downloader
An iPhone Shortcut that saves Reels, TikToks and Shorts straight to your phone from the share sheet.
Build your own clip downloader for iPhone
Share a Reel, TikTok or YouTube Short to one Shortcut and it lands in your Photos a few seconds later. Your own private server does the work, on Google Cloud's free tier.
Time: About 45 minutes the first time. After that, it's one tap.
What you'll get
- A Save Clip Shortcut in your share sheet. Tap Share on a Reel, TikTok or Short, pick it, done.
- The video saved straight to Photos, in a format the Photos app actually plays, and dated today so it shows up in Recents
- Best quality for short clips, and a fast path for longer videos so the Shortcut never gives up waiting
- Your own private server doing the download, so no sketchy download sites full of pop-ups
- A secret only your Shortcut knows, so nobody else can use your server
- Files deleted from the cloud automatically after a day
- Free at personal volume, on Google Cloud's free tier
How it works
- You share a link to the Shortcut.
- The Shortcut sends it to your server on Google Cloud Run, along with your secret.
- The server downloads the video with yt-dlp, converts it only if Photos wouldn't play it, stamps today's date on it, and uploads it to a storage bucket.
- It sends back a link, and the Shortcut saves the video to Photos.
What you'll need before we start
- An iPhone with the Shortcuts app
- A Mac or PC with Terminal. You'll paste a handful of commands.
- A Google account and a payment card. At a few clips a week you stay inside Google Cloud's free tier, but Google asks for a card to open a Cloud account.
- For YouTube and Instagram: Chrome, and a spare account for each
Time: About 45 minutes the first time.
Set it up with Claude
This one has the most steps. If you'd like a guide by your side, copy this into Claude and answer its questions.
I'm building the Clip Downloader from cezarhere.com/clip-downloader:
an iPhone Shortcut that sends a Reel, TikTok or Short link to my own server
on Google Cloud Run, which downloads it with yt-dlp and saves it to Photos.
Guide me one step at a time, wait for me to say "done" before the next step,
and keep it short. Explain any Terminal command in one sentence before I
run it.
My computer: [Mac / Windows]. Where I am right now: [haven't started /
setting up Google Cloud / creating the bucket / deploying / testing with
curl / building the Shortcut / fixing a YouTube error].
Never ask me to paste my secret (AUTH_TOKEN), my cookies.txt,
cookies_instagram.txt or anything from them into this chat. If something fails, ask me for the "detail" text
from the error.Get the server files
Make a new folder on your computer called clip-downloader and save these files into it, with exactly these names. main.py is the whole server: it checks your secret, only accepts Instagram, TikTok and YouTube links, picks the best version that will finish in time, converts it only if Photos needs it, and uploads it. deploy.sh holds the deploy settings, so you never retype them.
import json
import os
import re
import time
import uuid
import subprocess
import tempfile
import shutil
from flask import Flask, request, jsonify
from google.cloud import storage
app = Flask(__name__)
# --- Config (set as environment variables on Cloud Run) ---
AUTH_TOKEN = os.environ.get("AUTH_TOKEN") # shared secret, required
BUCKET_NAME = os.environ.get("BUCKET_NAME") # GCS bucket name, required
# Videos longer than this (seconds) skip the slow best-quality conversion
# so the iPhone Shortcut never runs out of time.
MAX_BEST_QUALITY_SECONDS = float(os.environ.get("MAX_BEST_QUALITY_SECONDS", "95"))
storage_client = storage.Client()
def check_auth(req):
"""Reject anything that doesn't present the shared secret."""
header = req.headers.get("Authorization", "")
expected = f"Bearer {AUTH_TOKEN}"
return AUTH_TOKEN and header == expected
@app.route("/download", methods=["POST"])
def download():
if not check_auth(request):
return jsonify({"error": "unauthorized"}), 401
# Be forgiving about how the iPhone Shortcut sends the link: it may
# arrive as {"url": "..."}, a list, or buried in other text (e.g. a
# TikTok "check out this video https://..." share). Fall back to
# pulling the first http(s) link out of the raw body.
raw = request.get_data(as_text=True) or ""
data = request.get_json(silent=True)
url = ""
if isinstance(data, dict):
value = data.get("url", "")
if isinstance(value, list):
value = value[0] if value else ""
url = str(value).strip() if value is not None else ""
if not url:
match = re.search(r"https?://[^\s\"'<>\\]+", raw.replace("\\/", "/"))
if match:
url = match.group(0)
# Echo what we actually received on failure, so a misconfigured
# Shortcut shows its own mistake directly in "Show Result".
received = {
"content_type": request.content_type,
"body": raw[:300],
}
print(f"/download received: {received}", flush=True)
if not url:
return jsonify({
"error": "missing 'url' in request body",
"received": received,
}), 400
# Basic allow-list: only these platforms, since that's all this is for.
allowed_domains = ["instagram.com", "tiktok.com", "youtube.com", "youtu.be"]
if not any(d in url for d in allowed_domains):
return jsonify({
"error": "url domain not allowed",
"url": url[:200],
"received": received,
}), 400
work_dir = tempfile.mkdtemp(prefix="dl_")
try:
# Each site only gets its own cookies; other sites (e.g. TikTok)
# get none, since another site's cookies just confuse them. Both
# files are baked into the container image (see Dockerfile).
if "instagram.com" in url:
cookies_path = "/app/cookies_instagram.txt"
elif "youtube.com" in url or "youtu.be" in url:
cookies_path = "/app/cookies.txt"
else:
cookies_path = None
base = [
"yt-dlp",
"--no-playlist",
# Lets yt-dlp fetch its JS challenge-solver (needed for YouTube).
"--remote-components", "ejs:github",
]
if cookies_path and os.path.exists(cookies_path):
base += ["--cookies", cookies_path]
# Cloud Run's filesystem is read-only outside /tmp, but yt-dlp's
# JS solver and Deno need somewhere writable for their caches.
env = os.environ.copy()
env["XDG_CACHE_HOME"] = "/tmp/xdg-cache"
env["DENO_DIR"] = "/tmp/deno-cache"
os.makedirs(env["XDG_CACHE_HOME"], exist_ok=True)
os.makedirs(env["DENO_DIR"], exist_ok=True)
# Step 1: look the video up (no download yet) to learn its length.
t_start = time.time()
info = subprocess.run(
base + ["-J", url],
capture_output=True, text=True, timeout=120, env=env,
)
if info.returncode != 0:
return jsonify({
"error": "yt-dlp failed",
"detail": info.stderr[-2000:],
}), 502
info_path = os.path.join(work_dir, "info.json")
with open(info_path, "w") as f:
f.write(info.stdout)
try:
info_data = json.loads(info.stdout)
duration = float(info_data.get("duration") or 0)
# Log what versions the site offers (helps explain choices).
offered = [
f"{f.get('format_id')}:{f.get('vcodec') or '?'}:{f.get('height') or '-'}p"
for f in info_data.get("formats", [])
if f.get("vcodec") != "none"
]
print(f"formats offered: {' '.join(offered)[:900]}", flush=True)
except (ValueError, AttributeError):
duration = 0
# Step 2: pick the version. The iPhone Shortcut gives up after
# about a minute, and converting Instagram's best (VP9) version
# takes longer the longer the video is. So: best quality for short
# videos; for long ones, the site's ready-made H.264 version, which
# needs no conversion and finishes in seconds at any length.
if duration > MAX_BEST_QUALITY_SECONDS:
mode = "fast (ready-made H.264)"
choice = [
# Anything that ISN'T VP9/AV1 (Instagram's ready-made
# versions carry no codec label, hence the "?" which also
# accepts unlabelled formats). Falls back to anything.
"-f", "b[vcodec!*=?vp][vcodec!*=?av01]"
"/bv*[vcodec!*=?vp][vcodec!*=?av01]+ba/bv*+ba/b",
"-S", "res,vcodec:h264,acodec:aac",
]
else:
mode = "best quality"
# Highest resolution; among equal resolutions prefer H.264/AAC.
choice = ["-S", "res,vcodec:h264,acodec:aac"]
print(f"duration: {duration:.0f}s -> {mode}", flush=True)
out_dir = os.path.join(work_dir, "out")
os.makedirs(out_dir, exist_ok=True)
result = subprocess.run(
base + choice + [
"-N", "4", # download several fragments in parallel
"--merge-output-format", "mp4",
"-o", os.path.join(out_dir, "%(id)s.%(ext)s"),
"--load-info-json", info_path,
],
capture_output=True,
text=True,
timeout=280, # keep comfortably under Cloud Run's request timeout
env=env,
)
print(f"timing: yt-dlp {time.time() - t_start:.1f}s", flush=True)
if result.returncode != 0:
return jsonify({
"error": "yt-dlp failed",
"detail": result.stderr[-2000:], # tail, in case it's long
}), 502
# Find whatever file yt-dlp produced
files = os.listdir(out_dir)
if not files:
return jsonify({"error": "no output file produced"}), 500
local_path = os.path.join(out_dir, files[0])
# Guarantee Photos-compatible output. yt-dlp's own postprocessors
# (Merger, --recode-video) only run in specific circumstances —
# Merger only fires if two separate streams were downloaded, and
# --recode-video skips silently if the container already says
# mp4 even when the codec inside is wrong. So instead we check
# the actual codecs with ffprobe and re-encode with ffmpeg
# ourselves whenever needed, regardless of what yt-dlp did.
probe = subprocess.run(
[
"ffprobe", "-v", "error",
"-show_entries", "stream=codec_type,codec_name",
"-of", "json",
local_path,
],
capture_output=True,
text=True,
timeout=30,
)
# JSON output is unambiguous (the old CSV output put the fields
# in the opposite order from what was assumed, so the codecs were
# never recognized and every file got needlessly re-encoded).
codecs = {}
try:
for stream in json.loads(probe.stdout).get("streams", []):
codecs.setdefault(stream.get("codec_type"), stream.get("codec_name", ""))
except (ValueError, AttributeError):
pass
video_codec = codecs.get("video", "")
audio_codec = codecs.get("audio", "")
print(f"codecs: video={video_codec} audio={audio_codec}", flush=True)
# Always do a final ffmpeg pass, because downloaded files carry an
# empty creation date (1 Jan 1904). Photos files videos by that
# date, so they got buried at the very start of the library
# instead of showing up in Recents. Stamping "now" fixes that.
# When the codecs are already fine this is a stream copy (~1s);
# only an incompatible stream (e.g. Instagram's VP9) is re-encoded.
converted_path = os.path.join(work_dir, "video.mp4")
now_iso = time.strftime("%Y-%m-%dT%H:%M:%S.000000Z", time.gmtime())
# Only re-encode the stream that actually needs it. Copying
# an already-compatible stream is nearly instant, while
# re-encoding video is by far the slowest step.
video_args = (
["-c:v", "copy"] if video_codec == "h264"
else [
"-c:v", "libx264", "-preset", "ultrafast", "-crf", "26",
"-pix_fmt", "yuv420p", "-threads", "0",
]
)
audio_args = (
["-c:a", "copy"] if audio_codec == "aac"
else ["-c:a", "aac", "-b:a", "192k"]
)
t_convert = time.time()
convert = subprocess.run(
["ffmpeg", "-y", "-i", local_path]
+ video_args + audio_args
+ ["-metadata", f"creation_time={now_iso}",
"-movflags", "+faststart", converted_path],
capture_output=True,
text=True,
timeout=280,
)
print(f"timing: ffmpeg {time.time() - t_convert:.1f}s", flush=True)
if convert.returncode != 0:
return jsonify({
"error": "conversion failed",
"detail": convert.stderr[-2000:],
}), 502
local_path = converted_path
# Upload to GCS
upload_name = os.path.basename(local_path)
blob_name = f"downloads/{uuid.uuid4().hex}_{upload_name}"
bucket = storage_client.bucket(BUCKET_NAME)
blob = bucket.blob(blob_name)
blob.upload_from_filename(local_path)
# Cloud Run's default credentials can't sign URLs (no private key),
# so instead we make just this one file briefly public. A daily
# lifecycle rule on the bucket cleans these up automatically.
blob.make_public()
return jsonify({
"status": "ok",
"download_url": blob.public_url,
})
except subprocess.TimeoutExpired:
return jsonify({"error": "download timed out"}), 504
finally:
shutil.rmtree(work_dir, ignore_errors=True)
@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok"})
if __name__ == "__main__":
port = int(os.environ.get("PORT", 8080))
app.run(host="0.0.0.0", port=port)
FROM python:3.12-slim
# ffmpeg: needed by yt-dlp to merge/convert audio+video streams
# curl/unzip: needed to install Deno
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
curl \
unzip \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Deno: required by yt-dlp (since 2025.11.12) to run YouTube's
# obfuscated JS and extract playable video URLs.
RUN curl -fsSL https://deno.land/install.sh | sh
ENV DENO_INSTALL="/root/.deno"
ENV PATH="$DENO_INSTALL/bin:$PATH"
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -U -r requirements.txt
# Optional: cookies.txt (YouTube) and/or cookies_instagram.txt (Instagram),
# both Netscape-format cookie files exported from a browser logged into
# that site, placed alongside main.py before deploying. These let yt-dlp
# authenticate and avoid each platform's cloud-IP bot detection. They're
# kept as separate files because they're separate accounts/domains.
# main.py is guaranteed to exist, so this line never fails even if one or
# both cookie files are absent (Docker's documented pattern for optional
# files).
COPY main.py cookies.txt* cookies_instagram.txt* /app/
# Cloud Run sets $PORT; gunicorn binds to it
ENV PORT=8080
EXPOSE 8080
CMD exec gunicorn --bind :$PORT --workers 1 --threads 4 --timeout 290 main:app
flask==3.0.3
gunicorn==22.0.0
yt-dlp
google-cloud-storage==2.18.2
{
"rule": [
{
"action": { "type": "Delete" },
"condition": { "age": 1 }
}
]
}
#!/bin/bash
# Deploys your clip downloader with the right settings. Run: ./deploy.sh
# Works from anywhere: it always deploys the folder it's in.
#
# Fill in your own two values first. Keep this file private once it holds
# your secret: never share it or put it on GitHub.
SECRET="YOUR_SECRET"
BUCKET="YOUR_BUCKET"
cd "$(dirname "$0")" || exit 1
gcloud run deploy clip-downloader \
--source . \
--region us-central1 \
--allow-unauthenticated \
--memory 4Gi \
--cpu 4 \
--cpu-boost \
--concurrency 1 \
--timeout 300 \
--max-instances 2 \
--set-env-vars "AUTH_TOKEN=$SECRET,BUCKET_NAME=$BUCKET"
1. Set up Google Cloud
- Open the Google Cloud Console, create a new project (call it
clip-downloader) and link a billing account when it asks. - Install the Google Cloud CLI on your computer.
- In Terminal, log in, pick your project and switch on the services you need. Your project ID is shown next to the project name in the console.
gcloud auth login
gcloud config set project YOUR_PROJECT_ID
gcloud services enable run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com storage.googleapis.com2. Create the storage bucket
The bucket is where finished videos wait for your phone to pick them up. Bucket names are shared across all of Google, so pick something unique, like yourname-clips-2026.
gcloud storage buckets create gs://YOUR_BUCKET --location=us-central1 --no-uniform-bucket-level-accessThen tell it to delete everything older than a day. Run this from inside your clip-downloader folder, where lifecycle.json is:
gcloud storage buckets update gs://YOUR_BUCKET --lifecycle-file=lifecycle.json3. Let the server use the bucket
Your server runs as your project's default service account. Look up your project number, then give that account permission to upload:
gcloud projects describe YOUR_PROJECT_ID --format="value(projectNumber)"
gcloud storage buckets add-iam-policy-binding gs://YOUR_BUCKET --member=serviceAccount:YOUR_PROJECT_NUMBER-compute@developer.gserviceaccount.com --role=roles/storage.objectAdmin4. Make your secret
openssl rand -hex 32That long string is your secret. It's the only thing stopping strangers from using your server, so keep it somewhere private, like your password manager. No openssl? Any long random string from a password generator works.
5. Deploy the server
Open deploy.sh in a text editor and replace YOUR_SECRET and YOUR_BUCKET with your own two values. Then run it from Terminal:
bash ~/clip-downloader/deploy.shSwap in wherever your folder actually is. You can drag the file into Terminal to paste its path.
It deploys with 4 CPUs and one clip per server at a time. That matters: Instagram's best versions need converting, and on 1 CPU a longer Reel takes more than the minute your phone is willing to wait.
The first time, it asks to create a place to store the build. Say yes. After a few minutes it prints a Service URL like https://clip-downloader-123456789.us-central1.run.app. Copy it.
--allow-unauthenticated in deploy.sh only means Google won't demand a Google login from your phone. Your secret does the protecting instead.
6. Test it before touching your phone
Swap in your URL, your secret and a link to any clip you own:
curl -X POST https://YOUR_SERVICE_URL/download \
-H "Authorization: Bearer YOUR_SECRET" \
-H "Content-Type: application/json" \
-d '{"url": "PASTE_A_LINK_HERE"}'If it worked, you get back {"download_url": "https://storage.googleapis.com/...", "status": "ok"}. If you get "error": "yt-dlp failed", read the detail text. It usually names the problem, and the fixes are in Troubleshooting below.
7. Build the Shortcut
- Open Shortcuts, tap + and name it Save Clip.
- Tap the i at the bottom, turn on Show in Share Sheet, and set it to receive URLs and Text.
- Add Get Contents of URL. Set the URL to
https://YOUR_SERVICE_URL/download, then expand it:- Method: POST
- Headers:
Authorizationwith the valueBearer YOUR_SECRET(with the space after Bearer), andContent-Typewith the valueapplication/json - Request Body: JSON, with one Text field named
urlset to Shortcut Input
- Add Get Dictionary Value: get the value for
download_urlin Contents of URL. - Add another Get Contents of URL, with the Dictionary Value as its URL. This one downloads the video.
- Add Save to Photo Album and choose Recents.
- Optional: add Show Notification with "Saved".
Now open a Reel, TikTok or Short, tap Share, and pick Save Clip. The first time, iOS asks permission to connect to your server and to add to Photos. Allow both. A clip usually takes 5 to 30 seconds.
8. YouTube and Instagram: when they want a login
YouTube and Instagram block most cloud servers unless the request carries a real, logged-in session. YouTube says "Sign in to confirm you're not a bot". Instagram says "Login required", "Restricted Video", or just can't find the post. Each site gets its own cookie file, and the server only ever sends a site its own:
- In Chrome, log in with a spare account, not your main one: a Google account for YouTube, an Instagram account for Instagram.
- Install the Get cookies.txt LOCALLY extension from the Chrome Web Store.
- On youtube.com or instagram.com, use the extension to export the cookies.
- Rename the file to exactly
cookies.txtfor YouTube, orcookies_instagram.txtfor Instagram, and put it in yourclip-downloaderfolder. - Run
deploy.shagain. The Dockerfile picks up both files automatically. You can have one, both or neither.
Troubleshooting
"unauthorized". The secret in your Shortcut's Authorization header doesn't match exactly. It needs Bearer, one space, then the secret.
"url domain not allowed". Only instagram.com, tiktok.com, youtube.com and youtu.be links are accepted, on purpose.
"missing 'url' in request body". The error includes a received field showing exactly what your Shortcut sent. Usually the Request Body isn't JSON, or the field isn't named url.
"n challenge solving failed" or "The page needs to be reloaded". Run deploy.sh again. Every deploy installs the newest yt-dlp (it isn't pinned in requirements.txt), which usually fixes it. If not, check yt-dlp's EJS page for changes.
An error about public access. Your bucket is blocking public links. Run gcloud storage buckets update gs://YOUR_BUCKET --no-public-access-prevention and try again.
It times out. Videos over 95 seconds already skip the slow best-quality conversion and use the site's ready-made version, which finishes in seconds. If longer clips still time out, add MAX_BEST_QUALITY_SECONDS=60 to the --set-env-vars line in deploy.sh (after a comma) and redeploy. Check that you deployed with deploy.sh too: on 1 CPU, everything is slower.
Something else. Copy the detail text from the test in step 6, paste it into Claude and ask what it means. It's yt-dlp's own error message and it usually points straight at the fix.
Cost and privacy
- At a handful of clips a week you stay inside the free tier, even with 4 CPUs: you only pay for the seconds a clip is actually being processed. Set a budget alert of $1 under Billing → Budgets & alerts in the console, so nothing can surprise you.
- Each video sits at a random, unguessable public link until the bucket deletes it a day later. Don't use this for anything private.
deploy.shholds your secret once you've filled it in. Keep it private like the cookie files.- If your secret leaks, make a new one with
openssl rand -hex 32, put it indeploy.sh, deploy again, and update the header in your Shortcut.
If you build it, send me the first thing you saved. Tag @cezarhere.