Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


backend/pricing.py (4059 bytes)

1 """What we charge, and why.
2 
3 Market as of 2026:
4 
5   Direct competitors (audiobook <-> ebook sync)
6     Voxlight          $29.99/year, but alignment runs on the user's own Mac
7     Spokt             freemium, metered sync minutes
8     Storyteller       free, self-hosted; you administer a server
9     syncabook         free CLI
10 
11   Adjacent (AI subtitling SaaS, priced for transcription)
12     Otter             $16.99/mo
13     Happy Scribe      $17/mo for 120 AI minutes, $89/mo for 6,000
14     Veed              $22/mo
15     Kapwing/Descript  $24/mo
16     Sonix             $10/hour pay-as-you-go
17 
18   Raw forced alignment as an API
19     ElevenLabs        $0.22 per hour of audio: $2.20 for a 10-hour book
20 
21 The adjacent tools price per *minute of transcription*, which does not transfer:
22 a 10-hour audiobook is 600 minutes, so it would cost ~$100 at Sonix's rate.
23 
24 What is free and what is paid is split by where the work is done. In the
25 visitor's browser a conversion costs this server nothing: it is free, without
26 limit, with each output. On this server's hardware (a large speech model on a
27 GPU: minutes and not hours, from any device) a book takes one credit. The code
28 is public, so what is sold is the use of these machines and nothing else.
29 
30 A book costs about $0.64 to convert on a rented GPU. Above about $5 a technical
31 buyer wraps the ElevenLabs API; below $3 the fixed card fee takes too much.
32 There is no unlimited plan: use comes in bursts (a backlog, then nothing), and
33 one heavy user of an unlimited plan costs more than the plan brings in.
34 
35   free              in the browser: no limit, each output
36   single book       $4.99
37   5-book pack       $16.99   ($3.40/book)
38   20-book pack      $39.00   ($1.95/book)
39 
40 Every number is overridable by env var; these are defaults, not decisions cast
41 in code. An operator who wants a recurring plan can add one with
42 SUBPLZ_WEB_PLANS_JSON ("recurring": true, "credits": null).
43 """
44 from __future__ import annotations
45 
46 import json
47 import os
48 from dataclasses import asdict, dataclass
49 
50 
51 
52 @dataclass(frozen=True)
53 class Plan:
54     id: str
55     name: str
56     # None for the subscription, which is not a credit pack.
57     credits: int | None
58     price_cents: int
59     currency: str = "usd"
60     recurring: bool = False
61     blurb: str = ""
62 
63     @property
64     def price_display(self) -> str:
65         return f"${self.price_cents / 100:,.2f}"
66 
67     @property
68     def per_book_cents(self) -> int | None:
69         if not self.credits:
70             return None
71         return round(self.price_cents / self.credits)
72 
73 
74 DEFAULT_PLANS: list[Plan] = [
75     Plan(
76         id="single",
77         name="One book",
78         credits=1,
79         price_cents=499,
80         blurb="One book on our server, from any device.",
81     ),
82     Plan(
83         id="pack5",
84         name="5 books",
85         credits=5,
86         price_cents=1699,
87         blurb="Credits never expire.",
88     ),
89     Plan(
90         id="pack20",
91         name="20 books",
92         credits=20,
93         price_cents=3900,
94         blurb="For working through a series.",
95     ),
96 ]
97 
98 
99 def plans() -> list[Plan]:
100     """The catalogue, overridable with SUBPLZ_WEB_PLANS_JSON."""
101     raw = os.environ.get("SUBPLZ_WEB_PLANS_JSON")
102     if not raw:
103         return DEFAULT_PLANS
104     try:
105         return [Plan(**item) for item in json.loads(raw)]
106     except (json.JSONDecodeError, TypeError) as exc:
107         raise RuntimeError(f"SUBPLZ_WEB_PLANS_JSON is not valid: {exc}") from exc
108 
109 
110 def get(plan_id: str) -> Plan | None:
111     return next((p for p in plans() if p.id == plan_id), None)
112 
113 
114 def as_dicts() -> list[dict]:
115     out = []
116     for p in plans():
117         d = asdict(p)
118         d["price_display"] = p.price_display
119         d["per_book_cents"] = p.per_book_cents
120         out.append(d)
121     return out
122 
123 
124 def free_tier_summary() -> str:
125     return "free in your browser, without limit"
126 
127 
128 # What each tier hands over, for the UI. Kinds match Artifact.kind. The tiers
129 # differ in where the work is done, not in what comes out.
130 _OUTPUTS = ["srt", "video_embedded", "video"]
131 TIER_OUTPUTS = {"free": _OUTPUTS, "cloud": _OUTPUTS}