feat: enforce free tier limits (3 videos/batch, 720p max)
- config.py: add BATCHTUBE_PLAN env var, FREE_TIER_MAX_VIDEOS=3, FREE_TIER_MAX_RESOLUTION=720, PLAN_LIMITS map for all tiers - api_server.py: cap resolution to 720p on free plan, reject CSV batches > 3 videos with 403 + upgrade prompt - worker.py: truncate resolved video list to max_videos on free plan, emit tier_limit SSE event with upgrade message Pricing tiers (2026-03-07 approved): Free: 3 videos/batch, 720p | Creator: $19/mo | Pro: $39/mo Studio: $79/mo | Lifetime: $197
This commit is contained in:
@@ -118,6 +118,40 @@ async def create_job(req: CreateJobRequest):
|
||||
if not req.input_value.strip():
|
||||
raise HTTPException(status_code=400, detail="input_value is required")
|
||||
|
||||
|
||||
# ── Free Tier Enforcement ─────────────────────────────────────────────────
|
||||
plan = config.BATCHTUBE_PLAN
|
||||
limits = config.PLAN_LIMITS.get(plan, config.PLAN_LIMITS["free"])
|
||||
|
||||
# Cap resolution for free tier
|
||||
resolution = req.resolution
|
||||
max_res = limits.get("max_resolution")
|
||||
if max_res and max_res != "best":
|
||||
res_order = config.AVAILABLE_RESOLUTIONS # ["best", "4320", ..., "144"]
|
||||
# Lower index = higher quality; cap if requested res is higher than allowed
|
||||
def res_index(r):
|
||||
try:
|
||||
return res_order.index(r)
|
||||
except ValueError:
|
||||
return len(res_order)
|
||||
if res_index(resolution) < res_index(max_res):
|
||||
logger.info(f"Free tier: capping resolution from {resolution} to {max_res}")
|
||||
resolution = max_res
|
||||
else:
|
||||
resolution = req.resolution
|
||||
|
||||
# For CSV input: enforce max_videos limit upfront
|
||||
max_videos = limits.get("max_videos")
|
||||
if max_videos is not None and req.input_type == "csv":
|
||||
csv_lines = [ln.strip() for ln in req.input_value.strip().splitlines() if ln.strip()]
|
||||
if len(csv_lines) > max_videos:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Free tier is limited to {max_videos} videos per batch. "
|
||||
f"Upgrade to Creator or Pro for larger batches. "
|
||||
f"({len(csv_lines)} URLs submitted)"
|
||||
)
|
||||
|
||||
# Create the folder if it doesn't exist
|
||||
try:
|
||||
Path(output_folder).mkdir(parents=True, exist_ok=True)
|
||||
@@ -128,7 +162,7 @@ async def create_job(req: CreateJobRequest):
|
||||
job_id = db.create_job(
|
||||
output_folder=output_folder,
|
||||
selections=req.selections,
|
||||
resolution=req.resolution,
|
||||
resolution=resolution, # may be capped by free tier enforcement
|
||||
delay_min=req.delay_min,
|
||||
delay_max=req.delay_max,
|
||||
input_type=req.input_type,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
config.py - Default configuration for YouTube SIPOC Tool
|
||||
config.py - Default configuration for BatchTube
|
||||
"""
|
||||
import os
|
||||
|
||||
@@ -32,7 +32,40 @@ SUMMARY_MAX_TOKENS = 1024
|
||||
MAX_TITLE_LENGTH = 80 # characters - truncate long titles in filenames
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
DB_PATH = os.environ.get("BATCHTUBE_DB_PATH", os.path.join(os.path.dirname(__file__), "youtube_sipoc.db"))
|
||||
# Windows-safe: allow override via env var (avoids path issues in PyInstaller bundles)
|
||||
DB_PATH = os.environ.get(
|
||||
"BATCHTUBE_DB_PATH",
|
||||
os.path.join(os.path.dirname(__file__), "youtube_sipoc.db")
|
||||
)
|
||||
|
||||
# ── Artifact Types ────────────────────────────────────────────────────────────
|
||||
ARTIFACT_TYPES = ["title", "stats", "transcript", "summary", "thumbnail", "mp3", "mp4"]
|
||||
|
||||
# ── Licensing / Plan Tiers ────────────────────────────────────────────────────
|
||||
# Plan is set via environment variable BATCHTUBE_PLAN.
|
||||
# Valid values: free | creator | pro | studio | lifetime
|
||||
# Defaults to 'free' — license validation layer overrides this at runtime.
|
||||
BATCHTUBE_PLAN = os.environ.get("BATCHTUBE_PLAN", "free").lower()
|
||||
|
||||
# ── Free Tier Limits (updated 2026-03-07) ─────────────────────────────────────
|
||||
# Free tier: 3 videos/batch max (tightened from 5), 720p max resolution
|
||||
FREE_TIER_MAX_VIDEOS = 3 # max videos per batch job on free plan
|
||||
FREE_TIER_MAX_RESOLUTION = "720" # max resolution on free plan (was 1080p)
|
||||
|
||||
# ── Plan Limits Map ───────────────────────────────────────────────────────────
|
||||
# Maps plan name -> {max_videos, max_resolution}; None = unlimited
|
||||
PLAN_LIMITS = {
|
||||
"free": {"max_videos": FREE_TIER_MAX_VIDEOS, "max_resolution": FREE_TIER_MAX_RESOLUTION},
|
||||
"creator": {"max_videos": 100, "max_resolution": "1080"},
|
||||
"pro": {"max_videos": None, "max_resolution": "best"},
|
||||
"studio": {"max_videos": None, "max_resolution": "best"},
|
||||
"lifetime": {"max_videos": None, "max_resolution": "best"},
|
||||
}
|
||||
|
||||
# ── Pricing Reference (2026-03-07 approved) ───────────────────────────────────
|
||||
# Free: 3 videos/batch, 720p max
|
||||
# Creator: $19/mo or $149/yr — 100 videos/batch, 1080p
|
||||
# Pro: $39/mo or $299/yr — unlimited, 4K
|
||||
# Studio: $79/mo — 3 seats, API access
|
||||
# Lifetime: $197 one-time — unlimited, all features
|
||||
# AppSumo: $79 one-time — limited launch deal
|
||||
|
||||
@@ -171,6 +171,27 @@ def run_job(job_id: str):
|
||||
unique_videos.append(v)
|
||||
videos = unique_videos
|
||||
|
||||
# ── Free Tier: Enforce max videos per batch ───────────────────────────
|
||||
plan = config.BATCHTUBE_PLAN
|
||||
limits = config.PLAN_LIMITS.get(plan, config.PLAN_LIMITS["free"])
|
||||
max_videos = limits.get("max_videos")
|
||||
if max_videos is not None and len(videos) > max_videos:
|
||||
db.add_log(
|
||||
job_id,
|
||||
f"Free tier limit: truncating batch from {len(videos)} to {max_videos} videos. "
|
||||
f"Upgrade to Creator or Pro for larger batches.",
|
||||
level="WARN"
|
||||
)
|
||||
_emit(job_id, "tier_limit", {
|
||||
"job_id": job_id,
|
||||
"plan": plan,
|
||||
"requested": len(videos),
|
||||
"allowed": max_videos,
|
||||
"message": f"Free tier: batch limited to {max_videos} videos. Upgrade for more."
|
||||
})
|
||||
videos = videos[:max_videos]
|
||||
|
||||
|
||||
# ── Step 2: Register videos in DB ────────────────────────────────────
|
||||
for i, v in enumerate(videos):
|
||||
db.upsert_video(job_id, v["video_id"], v["url"], v.get("title", ""), i)
|
||||
|
||||
Reference in New Issue
Block a user