Appearance
Historical snapshot archived 2026-09-25. This records an earlier review or plan, not current implementation or live ticket state. For current work, follow root AGENTS.md, the relevant BloxClips skill, and owning repository source/tests. Preserve approved decisions as evidence; verify their present authority before acting.
Metrics & View Tracking
How submitted videos get their view counts, likes, comments — and how that feeds payouts.
Overview
Two tracking systems coexist:
- Continuous Tracking Tick — polls ACCEPTED submissions on a cadence (12h/24h)
- Payout-Time Rescrape — fresh scrape when user requests payout
Both use the same scrapers and same budget clamp logic.
Continuous Tracking Tick
Scheduler
File: src/scheduler.ts:13-37
Interval: Every 30 minutes
Entry: runTrackingTick() from src/utils/tracking/runTrackingTick.ts
Query: Which Submissions Are Polled
typescript
prisma.submission.findMany({
where: {
status: 'ACCEPTED',
nextPollAt: { lte: now },
trackingStoppedAt: null,
frozenViewCount: null,
campaign: { active: true, viewsFrozen: false, isDeleted: false }
}
})Excluded:
status != ACCEPTEDtrackingStoppedAtset (30-day expiry, manual flag, freeze)frozenViewCountset (budget clamp)- Campaign inactive/frozen/deleted
Polling Cadence
Source: src/utils/tracking/pollScheduler.ts
Time Since acceptedAt | Interval |
|---|---|
| < 48 hours | 12 hours |
| ≥ 48 hours | 24 hours |
Formula:
typescript
computeNextPollAt(acceptedAt, now):
ageMs = now - acceptedAt
interval = ageMs < 48h ? 12h : 24h
return now + intervalScrapers by Platform
| Platform | Provider | Batch Size | Auth |
|---|---|---|---|
| YouTube | YouTube Data API v3 | 50 IDs/call | YOUTUBE_API_KEY |
| TikTok | Apify clockworks/tiktok-scraper | All due | APIFY_TOKEN |
Apify apify/instagram-scraper | All due | APIFY_TOKEN |
YouTube: getVideoDetails(ids[]) → returns Map<videoId, {views, likes, comments, title, creatorHandle, creatorAccountId, uploadDate}>
TikTok/IG: Apify actor run → dataset → matched by URL → returns {views, likes, comments, videoTitle, previewVideoUrl, previewImageUrl, creatorHandle, creatorAccountId, postedAt}
On Success (applySuccess)
typescript
// runTrackingTick.ts:61-119
1. computeScrapeBudgetClamp(submissionId, newViews)
2. If clamp.alreadyFrozen → skip (campaign frozen by earlier sub in same tick)
3. Transaction:
- Update submission:
* currentViews = clamp.finalCurrentViews (always actual views for graph)
* currentLikes, currentComments
* lastPolledAt = now
* nextPollAt = computeNextPollAt(acceptedAt, now)
* consecutiveScrapeFailures = 0
* frozenViewCount = clamp.frozenViewCount (if clamped)
* Populate videoTitle, creatorHandle, postedAt on first success only
- Create ViewSnapshot (viewCount, likes, comments, snapshotDate=now)
4. If clamp.didFreezeCampaign → markCampaignFrozen(campaignId, now)Key: currentViews always reflects actual platform views (for chart accuracy). frozenViewCount is the payable ceiling used for earnings/payouts.
On Failure (applyFailure)
typescript
// runTrackingTick.ts:121-137
1. consecutiveScrapeFailures++
2. If >= 3:
- status = FLAGGED
- trackingStoppedAt = now
- nextPollAt = null
- stats.flagged++
3. Else:
- nextPollAt = now + 24h (back off from warm-up cadence)Expiry Handling
In tick: Before scraping, filter expired:
typescript
isTrackingExpired(acceptedAt, trackingDurationDays, now):
expiryMs = acceptedAt + trackingDurationDays * 24h
return now >= expiryMsIf expired → trackingStoppedAt=now, nextPollAt=null, stats.expired++
Default trackingDurationDays: 30 (Campaign model default)
Payout-Time Rescrape
File: src/utils/payouts/rescrape.ts
Trigger: processPayoutRequest() after user clicks "Request Payout"
Differences from Continuous Tick
| Aspect | Continuous Tick | Payout Rescrape |
|---|---|---|
| Trigger | Scheduler (30 min) | User action (async) |
| Target | All due ACCEPTED | User's eligible submissions only |
| Frozen campaigns | Skipped | Skipped (uses last-known) |
| Tracking-stopped | Skipped | Skipped (uses last-known) |
| Frozen submissions | Skipped | Skipped (uses last-known) |
| Budget clamp | Applied per scrape | Applied per rescrape |
| Output | Updates submission + ViewSnapshot | Returns RescrapeOutcome for PayoutItem |
| Unavailable reasons | Not tracked | VIDEO_DELETED/PRIVATE/SCRAPE_FAILED on PayoutItem |
Rescrape Flow
typescript
// rescrape.ts:140-291
1. Partition: toScrape vs skip (frozen/stopped/frozenViewCount)
2. Group toScrape by platform
3. YouTube: batch getVideoDetails → applyScrapedViews
4. TikTok: batch scrapeTikTok → applyScrapedViews
5. Instagram: batch scrapeInstagram → applyScrapedViews
6. applyScrapedViews:
- computeScrapeBudgetClamp
- Transaction: update submission + ViewSnapshot
- If clamp froze campaign → markCampaignFrozen
7. Return RescrapeOutcome[] with viewsAtPayout, unavailableReason, didFreezeCampaignPayoutItem Construction
File: src/utils/payouts/processRequest.ts:130-171
typescript
for each submission:
ratePerK = customRate || campaign rate
cap = customViewCap || campaign viewCap
rawViews = manualViewCount ?? frozenViewCount ?? outcome.viewsAtPayout
payableViews = cap ? min(rawViews, cap) : rawViews
priorPaid = submission.paidViewsTotal
viewsCounted = max(0, payableViews - priorPaid)
grossAmount = (viewsCounted / 1000) * ratePerK
netAmount = applyClipperFee(grossAmount) // 7% fee
badges = ratioBadges + spikeBadgeThreshold Gate: In calculateRawEarnings (used by budget calc), if actual views < minViewsShorts/Long → earnings = 0. But PayoutItem still created — admin sees 0 net.
Budget Clamping (First-Come-First-Earned)
File: src/utils/campaignBudget.ts:130-225
Principle
Earlier submissions (by createdAt) get paid first. Later submissions may be clamped to 0 if budget exhausted.
Two Entry Points
- Continuous tick:
computeScrapeBudgetClamp(submissionId, newViews) - Payout rescrape: Same function called per submission
Algorithm
typescript
computeScrapeBudgetClamp(submissionId, newViews):
1. If campaign.viewsFrozen → alreadyFrozen=true
2. Siblings = all ACCEPTED submissions in campaign EXCEPT this one
3. spendExcludingThis = Σ(sibling.cappedViews/1000 * rate * burn)
4. remainingBudget = budgetLimit - spendExcludingThis
5. maxViewsByBudget = floor(remainingBudget * 1000 / (ratePerK * burn))
6. cappedNewViews = min(newViews, viewCap)
7. If cappedNewViews <= maxViewsByBudget → no clamp
8. Else → frozenViewCount = maxViewsByBudget, didFreezeCampaign=trueBurn multiplier: 1 / (1 - platformFeeRate) — default 0.10 → 1.111x
Source: src/utils/fees.ts:burnMultiplier
Campaign Freeze
Trigger: Any scrape/rescrape where didFreezeCampaign=true
Effects:
campaign.viewsFrozen=true,viewsFrozenAt=now,acceptingSubmissions=false- All submissions in campaign:
frozenViewCountset (per their clamp) - Scheduler tick skips campaign (
campaign.viewsFrozen=falsein where) - Payout rescrape skips campaign (uses last-known
frozenViewCount)
Re-open: checkAndCloseCampaign can re-open if budget % drops < 95% (e.g., video deleted, manual view reduction)
ViewSnapshot Table
Model: ViewSnapshot (prisma/schema.prisma:648-660)
Written by:
- Submission create (initial scrape) —
submissions.ts:515-527 - Tracking tick success —
runTrackingTick.ts:102-110 - Payout rescrape success —
rescrape.ts:107-115
Fields: submissionId, viewCount, likes, comments, snapshotDate, createdAt
Used by:
- Admin chart (
/api/admin/submissions/:id/snapshots) - Spike badge detection (
computeSpikeBadgeinbadges.ts) - Historical audit
Note: snapshotDate is DateTime (not Date) — supports 12h cadence.
Fraud Detection Badges
File: src/utils/payouts/badges.ts
Computed at: Payout rescrape time (processRequest.ts:150-156)
| Badge | Condition |
|---|---|
LOW_LIKE_RATIO | likes/views < threshold |
LOW_COMMENT_RATIO | comments/views < threshold |
VIEW_SPIKE | View velocity anomaly vs historical snapshots |
Stored: PayoutItem.badges (comma-separated string)
Displayed: Admin payout detail → badges column
Not used in continuous tick — only at payout time.
Platform Differences
| Aspect | YouTube | TikTok | |
|---|---|---|---|
| API | YouTube Data API v3 | Apify actor | Apify actor |
| Cost | Free (quota) | Per result | Per result |
| Batch | 50 IDs/call | All URLs/run | All URLs/run |
| Video type detection | isYouTubeShort (URL + duration) | Forced short | Forced short |
| Duration | Available | 0 (not provided) | 0 |
creatorAccountId | Channel ID | authorMeta.id | ownerId |
previewVideoUrl | No (embed URL built frontend) | Yes (Apify) | Yes (Apify) |
previewImageUrl | img.youtube.com/vi/{id}/hqdefault.jpg | coverUrl | displayUrl |
Retry / Failure Behavior
| Scenario | Continuous Tick | Payout Rescrape |
|---|---|---|
| API error (network, quota) | applyFailure → back off 24h, flag at 3 | SCRAPE_FAILED → unavailableReason, last-known views used |
| Video deleted | VIDEO_DELETED → flag at 3 | VIDEO_DELETED → viewsCounted=0 |
| Video private | VIDEO_PRIVATE → flag at 3 | VIDEO_PRIVATE → viewsCounted=0 |
| Apify run failed | FAILED → back off 24h | SCRAPE_FAILED |
| Rate limited | N/A (YouTube quota) | N/A |
Current Production vs. Planned
Current Production (Implemented)
- [x] 30-min scheduler tick
- [x] 12h/24h adaptive cadence
- [x] YouTube Data API + Apify (TikTok/IG)
- [x] Budget clamp (first-come-first-earned)
- [x] Campaign freeze at budget
- [x] 3-failure auto-flag
- [x] 30-day tracking expiry
- [x] Payout rescrape with delta math
- [x] Fraud badges at payout time
Planned / In Investigation (Not Implemented)
- [ ] Adaptive polling architecture (separate investigation)
- [ ] Real-time webhook-based updates (not polling)
- [ ] Per-submission tracking duration config
- [ ] Historical metrics retention policy
- [ ] Cross-platform view deduplication
Key Files Summary
| File | Purpose |
|---|---|
src/scheduler.ts | Scheduler entry point |
src/utils/tracking/runTrackingTick.ts | Main polling logic |
src/utils/tracking/pollScheduler.ts | Cadence math |
src/utils/campaignBudget.ts | Budget clamp, freeze, close |
src/utils/scrapers/apify.ts | TikTok/IG Apify wrappers |
src/utils/youtube.ts | YouTube Data API |
src/utils/payouts/rescrape.ts | Payout-time rescrape |
src/utils/payouts/processRequest.ts | Payout orchestrator |
src/utils/payouts/badges.ts | Fraud badges |
src/utils/calculateSubmissionEarnings.ts | Budget-aware earnings |