@@ -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