Skip to content

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:

  1. Continuous Tracking Tick — polls ACCEPTED submissions on a cadence (12h/24h)
  2. 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 != ACCEPTED
  • trackingStoppedAt set (30-day expiry, manual flag, freeze)
  • frozenViewCount set (budget clamp)
  • Campaign inactive/frozen/deleted

Polling Cadence ​

Source: src/utils/tracking/pollScheduler.ts

Time Since acceptedAtInterval
< 48 hours12 hours
≥ 48 hours24 hours

Formula:

typescript
computeNextPollAt(acceptedAt, now):
  ageMs = now - acceptedAt
  interval = ageMs < 48h ? 12h : 24h
  return now + interval

Scrapers by Platform ​

PlatformProviderBatch SizeAuth
YouTubeYouTube Data API v350 IDs/callYOUTUBE_API_KEY
TikTokApify clockworks/tiktok-scraperAll dueAPIFY_TOKEN
InstagramApify apify/instagram-scraperAll dueAPIFY_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 >= expiryMs

If 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 ​

AspectContinuous TickPayout Rescrape
TriggerScheduler (30 min)User action (async)
TargetAll due ACCEPTEDUser's eligible submissions only
Frozen campaignsSkippedSkipped (uses last-known)
Tracking-stoppedSkippedSkipped (uses last-known)
Frozen submissionsSkippedSkipped (uses last-known)
Budget clampApplied per scrapeApplied per rescrape
OutputUpdates submission + ViewSnapshotReturns RescrapeOutcome for PayoutItem
Unavailable reasonsNot trackedVIDEO_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, didFreezeCampaign

PayoutItem 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 + spikeBadge

Threshold 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 ​

  1. Continuous tick: computeScrapeBudgetClamp(submissionId, newViews)
  2. 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=true

Burn 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: frozenViewCount set (per their clamp)
  • Scheduler tick skips campaign (campaign.viewsFrozen=false in 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:

  1. Submission create (initial scrape) — submissions.ts:515-527
  2. Tracking tick success — runTrackingTick.ts:102-110
  3. 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 (computeSpikeBadge in badges.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)

BadgeCondition
LOW_LIKE_RATIOlikes/views < threshold
LOW_COMMENT_RATIOcomments/views < threshold
VIEW_SPIKEView 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 ​

AspectYouTubeTikTokInstagram
APIYouTube Data API v3Apify actorApify actor
CostFree (quota)Per resultPer result
Batch50 IDs/callAll URLs/runAll URLs/run
Video type detectionisYouTubeShort (URL + duration)Forced shortForced short
DurationAvailable0 (not provided)0
creatorAccountIdChannel IDauthorMeta.idownerId
previewVideoUrlNo (embed URL built frontend)Yes (Apify)Yes (Apify)
previewImageUrlimg.youtube.com/vi/{id}/hqdefault.jpgcoverUrldisplayUrl

Retry / Failure Behavior ​

ScenarioContinuous TickPayout Rescrape
API error (network, quota)applyFailure → back off 24h, flag at 3SCRAPE_FAILED → unavailableReason, last-known views used
Video deletedVIDEO_DELETED → flag at 3VIDEO_DELETED → viewsCounted=0
Video privateVIDEO_PRIVATE → flag at 3VIDEO_PRIVATE → viewsCounted=0
Apify run failedFAILED → back off 24hSCRAPE_FAILED
Rate limitedN/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 ​

FilePurpose
src/scheduler.tsScheduler entry point
src/utils/tracking/runTrackingTick.tsMain polling logic
src/utils/tracking/pollScheduler.tsCadence math
src/utils/campaignBudget.tsBudget clamp, freeze, close
src/utils/scrapers/apify.tsTikTok/IG Apify wrappers
src/utils/youtube.tsYouTube Data API
src/utils/payouts/rescrape.tsPayout-time rescrape
src/utils/payouts/processRequest.tsPayout orchestrator
src/utils/payouts/badges.tsFraud badges
src/utils/calculateSubmissionEarnings.tsBudget-aware earnings