Skip to content

Commit 5dcf002

Browse files
committed
Fix subscriptions silently skipping pre-seeded videos
A video whose ID was recorded in the global archive by seedArchive (or a prior subscription) but never actually downloaded was skipped forever by filterNewVideos. This bit scheduled/premiere videos: their ID appears in the channel listing before they go public, gets seeded as "skip", and the video is then dropped once it actually publishes. The scheduled checker now fetches real publish dates (single non-flat yt-dlp extraction) and only honors a seed-only archive entry when the video predates the subscription. Genuinely-new uploads that were pre-seeded are reconsidered (and the stale archive row cleared) so they download. Also skips not-yet-published premieres/lives, and parses yt-dlp output via a unit-separator template instead of the fragile group-every-3-lines logic.
1 parent 5fc7815 commit 5dcf002

1 file changed

Lines changed: 125 additions & 6 deletions

File tree

src/lib/server/services/subscription.service.ts

Lines changed: 125 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,12 @@ class SubscriptionService {
8585

8686
console.log(`[Subscriptions] Checking ${subscription.name}...`);
8787

88-
// Get latest videos from channel
89-
const videos = await this.getLatestVideos(subscription.url);
88+
// Get latest videos from channel, including real publish dates so we can
89+
// tell genuinely-new uploads apart from back-catalog / pre-seeded entries.
90+
const videos = await this.getLatestVideosWithDates(subscription.url);
9091

9192
// Filter out already downloaded videos
92-
const newVideos = await this.filterNewVideos(videos);
93+
const newVideos = await this.filterNewVideos(videos, subscription);
9394

9495
if (newVideos.length > 0 && subscription.autoDownload) {
9596
console.log(`[Subscriptions] Found ${newVideos.length} new videos for ${subscription.name}`);
@@ -136,6 +137,91 @@ class SubscriptionService {
136137
return this.fetchPlaylistEntries(url, { limit: SubscriptionService.CHECK_DEPTH });
137138
}
138139

140+
/**
141+
* Get latest videos with publish dates and live status (full extraction).
142+
*
143+
* The subscription checker needs the real upload timestamp of each video so it
144+
* can distinguish a genuinely-new upload from a back-catalog entry that was
145+
* pre-seeded into the archive. Flat-playlist mode does not return timestamps,
146+
* so this does a single full extraction limited to CHECK_DEPTH videos.
147+
*/
148+
private async getLatestVideosWithDates(url: string): Promise<any[]> {
149+
ytdlpService.validateUrl(url);
150+
151+
// Unit Separator — won't appear in titles, so a single delimited line per
152+
// video can be parsed without the fragile "group every 3 lines" assumption.
153+
const SEP = String.fromCharCode(31);
154+
155+
return new Promise((resolve, reject) => {
156+
const args = [
157+
'--no-download',
158+
'--print',
159+
`%(id)s${SEP}%(title)s${SEP}%(webpage_url)s${SEP}%(timestamp)s${SEP}%(live_status)s`,
160+
'--playlist-end',
161+
SubscriptionService.CHECK_DEPTH.toString(),
162+
url,
163+
];
164+
165+
const proc = spawn(ytdlpService.getPath(), args);
166+
let output = '';
167+
let error = '';
168+
let settled = false;
169+
170+
const timeout = setTimeout(() => {
171+
if (settled) return;
172+
settled = true;
173+
try { proc.kill('SIGKILL'); } catch {}
174+
reject(new Error('yt-dlp playlist fetch timed out'));
175+
}, 120000);
176+
177+
proc.stdout.on('data', (data) => {
178+
output += data.toString();
179+
});
180+
181+
proc.stderr.on('data', (data) => {
182+
error += data.toString();
183+
});
184+
185+
proc.on('error', (err) => {
186+
if (settled) return;
187+
settled = true;
188+
clearTimeout(timeout);
189+
reject(err);
190+
});
191+
192+
proc.on('close', (code) => {
193+
if (settled) return;
194+
settled = true;
195+
clearTimeout(timeout);
196+
197+
if (code !== 0) {
198+
reject(new Error(`yt-dlp failed: ${error}`));
199+
return;
200+
}
201+
202+
const videos = [];
203+
for (const line of output.split('\n')) {
204+
if (!line.trim()) continue;
205+
const parts = line.split(SEP);
206+
if (parts.length < 3) continue;
207+
208+
const [id, title, webpageUrl, tsRaw, liveStatus] = parts;
209+
const ts = tsRaw && tsRaw !== 'NA' ? parseInt(tsRaw, 10) : NaN;
210+
211+
videos.push({
212+
id,
213+
title,
214+
url: webpageUrl,
215+
uploadedAt: Number.isFinite(ts) ? new Date(ts * 1000) : null,
216+
liveStatus: liveStatus && liveStatus !== 'NA' ? liveStatus : null,
217+
});
218+
}
219+
220+
resolve(videos);
221+
});
222+
});
223+
}
224+
139225
/**
140226
* Fetch playlist entries from yt-dlp with optional limit and date filter
141227
*/
@@ -295,12 +381,30 @@ class SubscriptionService {
295381

296382
/**
297383
* Filter out already downloaded videos
298-
* Checks both the archive and pending/active downloads to prevent duplicates
384+
* Checks both the archive and pending/active downloads to prevent duplicates.
385+
*
386+
* When `subscription` is provided and the videos carry publish dates (the
387+
* scheduled-check path), the archive is no longer treated as an unconditional
388+
* skip: a video that was only *seeded* (archived without ever being downloaded)
389+
* is reconsidered if it was actually published after the subscription was
390+
* created. This heals the case where a scheduled/premiere video gets pre-seeded
391+
* into the global archive and is then silently skipped once it goes public.
299392
*/
300-
private async filterNewVideos(videos: any[]): Promise<any[]> {
393+
private async filterNewVideos(videos: any[], subscription?: any): Promise<any[]> {
301394
const newVideos = [];
395+
const subCreatedAt = subscription?.createdAt ? new Date(subscription.createdAt).getTime() : null;
396+
const now = Date.now();
302397

303398
for (const video of videos) {
399+
// Skip videos that aren't actually published yet (upcoming premieres /
400+
// in-progress livestreams, or a publish timestamp still in the future).
401+
if (video.liveStatus === 'is_upcoming' || video.liveStatus === 'is_live') {
402+
continue;
403+
}
404+
if (video.uploadedAt instanceof Date && video.uploadedAt.getTime() > now) {
405+
continue;
406+
}
407+
304408
const archived = await prisma.archive.findUnique({
305409
where: { videoId: video.id },
306410
});
@@ -320,7 +424,22 @@ class SubscriptionService {
320424
await prisma.download.delete({ where: { id: download.id } });
321425
}
322426
} else {
323-
continue;
427+
// Seed-only archive entry (recorded without ever being downloaded).
428+
// Only skip it if the video is genuinely older than the subscription;
429+
// a video published *after* we subscribed but pre-seeded (e.g. it was
430+
// a scheduled premiere when the channel was seeded) must not be lost.
431+
const publishedAfterSub =
432+
subCreatedAt != null &&
433+
video.uploadedAt instanceof Date &&
434+
video.uploadedAt.getTime() > subCreatedAt;
435+
436+
if (!publishedAfterSub) {
437+
continue;
438+
}
439+
440+
// Stale skip-entry for a genuinely new upload — clear it and treat
441+
// the video as new so it downloads (and re-archives properly).
442+
await prisma.archive.delete({ where: { videoId: video.id } }).catch(() => {});
324443
}
325444
}
326445

0 commit comments

Comments
 (0)