Article generation from YouTube
TubeReader takes a YouTube URL and returns a formatted article in under 30 seconds. PHP handles accounts, billing, and generation. React renders the dashboard and the public explore grid. A Flask service on Railway fetches transcripts. A limited demo is at tubereader.practicalsoftware.com.
Generation options
The generate screen is where the user picks tone, length, language, point of view, structure, font, theme, and visibility before the model runs. Paid accounts can save those choices as named presets and attach a preset to a playlist or channel watcher. Custom titles go through a separate moderation call so a public article does not store a title that fails a general-audience check.
Credits scale with the article length target, then add a surcharge from video duration. Free accounts stop at 20 minutes. Paid accounts stop at 180. Music videos, official audio, ASMR, and similar clips are rejected from YouTube category and title heuristics, without a second model call. Videos under two minutes are rejected as well.
Hourly playlist and channel monitors
Paid users can watch a playlist or a channel. On create, the
current video ids are stored as a baseline so existing uploads are
not generated in bulk. An n8n schedule trigger runs once an hour
and POSTs to the scripts in the cron folder. Those endpoints are
not a public API. They require a shared secret in a header, compared
with hash_equals, and they cap each run at 50 monitors
and 30 new articles.
POST /php/cron/hourly-playlist.php
X-Job-Key: <secret>
{
"maxFeeds": 50,
"maxArticles": 30
}
The same n8n workflow calls a channel watcher in that folder. A third script backfills plain-text search indexes for course packs. A unique video id per watcher stops two overlapping hours from writing the same article twice. Watchers skip free-plan accounts.
Dashboard and publishing
After login, the dashboard lists articles the user owns. Explore queries only rows marked public, with the mature flag off. Unlisted articles stay off that grid and are reachable by link. Private articles require the owner session. Course packs group articles into an ordered set, with MySQL full-text search over stored plain text rather than the HTML file.
Copy helpers return HTML, Markdown, or a YouTube description draft. Description chapters use caption timestamps from the transcript service when those cues exist. The first description draft costs one credit. PDF export allowlists thumbnail hosts so the renderer cannot fetch an arbitrary URL.
Billing and access control
Stripe Checkout starts from a server-resolved product key. Amounts never come from the client. Checkout success does not grant credits by itself. A signed webhook writes the event, then fulfillment runs. Duplicate deliveries are ignored through a unique event id. Subscriptions reset plan credits on each paid invoice. One-time packs add a separate top-up balance that renewals do not clear. Usage burns plan credits first, then top-ups.
function consumeCredits($pdo, $userId, $amount) {
$pdo->beginTransaction();
$stmt = $pdo->prepare(
'SELECT usage_count, usage_limit, pack_credits
FROM users WHERE user_id = ? FOR UPDATE'
);
$stmt->execute([$userId]);
$row = $stmt->fetch();
$planLeft = max(0, $row['usage_limit'] - $row['usage_count']);
if ($planLeft + $row['pack_credits'] < $amount) {
$pdo->rollBack();
throw new RuntimeException('Insufficient credits');
}
$fromPlan = min($amount, $planLeft);
$fromPack = $amount - $fromPlan;
$pdo->prepare(
'UPDATE users
SET usage_count = usage_count + ?,
pack_credits = pack_credits - ?
WHERE user_id = ?'
)->execute([$fromPlan, $fromPack, $userId]);
$pdo->commit();
return ['from_plan' => $fromPlan, 'from_pack' => $fromPack];
}
That is the spend path, not a production filename. The row lock keeps two generate requests from racing the same balance. If the model call fails after the debit, the same split is written back so a failed article does not keep the credits.
Registration checks reCAPTCHA and uses MailCheck to reject disposable inboxes, at about 95% detection accuracy. Passwords are hashed. Session tokens are random bytes stored as a SHA-256 hash, in an HttpOnly Secure SameSite Lax cookie. Logins, generation, and password resets are rate limited. Mutation requests must come from an allowlisted origin. An admin view, not linked from the public navbar, lists billing rows, model cost, and error logs.