bep40 commited on
Commit
f7e7667
·
verified ·
1 Parent(s): 5ed2bc0

Upload shorts_updater.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. shorts_updater.py +300 -0
shorts_updater.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shorts Auto-Updater: Resilient YouTube shorts fetching with timeout handling
3
+ - Background updates every 10 minutes
4
+ - Hard timeout per channel (25s) to prevent hanging
5
+ - Stale-while-revalidate: serve cached data immediately, update in background
6
+ - Multiprocess isolation for yt-dlp to prevent crashes
7
+ """
8
+ import json
9
+ import os
10
+ import time
11
+ import threading
12
+ import subprocess
13
+ import signal
14
+ from datetime import datetime, timezone, timedelta
15
+
16
+ DATA_DIR = "/data" if os.path.isdir("/data") else "/app"
17
+ SHORTS_CACHE_FILE = os.path.join(DATA_DIR, "shorts_cache.json")
18
+ SHORTS_METADATA_FILE = os.path.join(DATA_DIR, "shorts_meta.json")
19
+
20
+ YOUTUBE_HANDLES = ["baodantri7941", "baosuckhoedoisongboyte", "vtvnambo"]
21
+ YOUTUBE_TIMEOUT = 25 # Hard timeout per channel in seconds
22
+ UPDATE_INTERVAL = 600 # 10 minutes
23
+ STALE_TTL = 3600 # 1 hour - serve stale if no fresh data
24
+
25
+ _lock = threading.Lock()
26
+ _cache = {"t": 0, "d": [], "error": False}
27
+ _meta = {"last_update": None, "last_success": None, "errors": {}}
28
+
29
+
30
+ def _load_cache():
31
+ """Load shorts cache from disk"""
32
+ try:
33
+ if os.path.exists(SHORTS_CACHE_FILE):
34
+ with open(SHORTS_CACHE_FILE, 'r', encoding='utf-8') as f:
35
+ return json.load(f)
36
+ except Exception as e:
37
+ print(f"Cache load error: {e}")
38
+ return {"t": 0, "d": [], "error": False}
39
+
40
+
41
+ def _save_cache(data):
42
+ """Save shorts cache to disk atomically"""
43
+ try:
44
+ os.makedirs(DATA_DIR, exist_ok=True)
45
+ tmp = SHORTS_CACHE_FILE + ".tmp"
46
+ with open(tmp, 'w', encoding='utf-8') as f:
47
+ json.dump(data, f, ensure_ascii=False)
48
+ os.replace(tmp, SHORTS_CACHE_FILE)
49
+ except Exception as e:
50
+ print(f"Cache save error: {e}")
51
+
52
+
53
+ def _load_meta():
54
+ """Load metadata for tracking update status"""
55
+ try:
56
+ if os.path.exists(SHORTS_METADATA_FILE):
57
+ with open(SHORTS_METADATA_FILE, 'r', encoding='utf-8') as f:
58
+ return json.load(f)
59
+ except Exception:
60
+ pass
61
+ return {"last_update": None, "last_success": None, "errors": {}}
62
+
63
+
64
+ def _save_meta(data):
65
+ """Save metadata to disk"""
66
+ try:
67
+ tmp = SHORTS_METADATA_FILE + ".tmp"
68
+ with open(tmp, 'w', encoding='utf-8') as f:
69
+ json.dump(data, f, ensure_ascii=False)
70
+ os.replace(tmp, SHORTS_METADATA_FILE)
71
+ except Exception:
72
+ pass
73
+
74
+
75
+ def _fetch_shorts_via_ydlp(username, count=50):
76
+ """Fetch shorts from a channel using yt-dlp with hard timeout"""
77
+ shorts = []
78
+ try:
79
+ url = f"https://www.youtube.com/@{username}/shorts"
80
+
81
+ # Use subprocess with hard timeout
82
+ proc = subprocess.Popen(
83
+ ["yt-dlp", "--dump-json", "--flat-playlist", "--no-download",
84
+ "--playlist-end", str(count), "--no-check-certificates",
85
+ "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
86
+ url],
87
+ stdout=subprocess.PIPE,
88
+ stderr=subprocess.PIPE,
89
+ text=True
90
+ )
91
+
92
+ try:
93
+ stdout, stderr = proc.communicate(timeout=YOUTUBE_TIMEOUT)
94
+ except subprocess.TimeoutExpired:
95
+ proc.kill()
96
+ proc.communicate()
97
+ print(f"yt-dlp timeout for @{username}")
98
+ return []
99
+
100
+ if proc.returncode != 0:
101
+ print(f"yt-dlp failed for @{username}: {stderr[:100]}")
102
+ return []
103
+
104
+ seen_ids = set()
105
+ for line in stdout.strip().split('\n'):
106
+ line = line.strip()
107
+ if not line:
108
+ continue
109
+ try:
110
+ entry = json.loads(line)
111
+ vid = entry.get('id', '')
112
+ if not vid or vid in seen_ids:
113
+ continue
114
+ seen_ids.add(vid)
115
+ title = entry.get('title', 'VNEWS Short')[:120]
116
+
117
+ shorts.append({
118
+ 'id': vid,
119
+ 'title': title,
120
+ 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
121
+ 'link': f"https://www.youtube.com/shorts/{vid}",
122
+ 'channel': username,
123
+ 'source': 'yt'
124
+ })
125
+ except json.JSONDecodeError:
126
+ continue
127
+
128
+ if len(shorts) >= count:
129
+ break
130
+
131
+ except Exception as e:
132
+ print(f"yt-dlp error for @{username}: {e}")
133
+
134
+ return shorts
135
+
136
+
137
+ def _fetch_all_shorts():
138
+ """Fetch shorts from all configured channels"""
139
+ all_shorts = []
140
+ errors = {}
141
+
142
+ for handle in YOUTUBE_HANDLES:
143
+ shorts = _fetch_shorts_via_ydlp(handle, 30)
144
+ if shorts:
145
+ all_shorts.extend(shorts)
146
+ else:
147
+ errors[handle] = "fetch_failed"
148
+
149
+ # Remove duplicates by ID
150
+ seen = set()
151
+ unique_shorts = []
152
+ for s in all_shorts:
153
+ if s['id'] not in seen:
154
+ seen.add(s['id'])
155
+ unique_shorts.append(s)
156
+
157
+ return unique_shorts[:100], errors
158
+
159
+
160
+ def update_shorts_background(force=False):
161
+ """Background update of shorts - call from scheduler"""
162
+ global _cache, _meta
163
+
164
+ now = time.time()
165
+
166
+ with _lock:
167
+ if not force and _cache.get("updating"):
168
+ return
169
+ _cache["updating"] = True
170
+
171
+ try:
172
+ old_meta = _load_meta()
173
+ shorts, errors = _fetch_all_shorts()
174
+
175
+ new_cache = {
176
+ "t": now,
177
+ "d": shorts,
178
+ "error": len(errors) > 0 and len(shorts) == 0,
179
+ "updating": False
180
+ }
181
+
182
+ new_meta = {
183
+ "last_update": datetime.now(timezone.utc).isoformat(),
184
+ "last_success": datetime.now(timezone.utc).isoformat() if shorts else old_meta.get("last_success"),
185
+ "errors": errors,
186
+ "count": len(shorts)
187
+ }
188
+
189
+ with _lock:
190
+ _cache = new_cache
191
+
192
+ _save_cache(new_cache)
193
+ _save_meta(new_meta)
194
+
195
+ print(f"Shorts updated: {len(shorts)} videos, errors: {errors if errors else 'none'}")
196
+
197
+ except Exception as e:
198
+ print(f"Shorts update error: {e}")
199
+ with _lock:
200
+ if "error" not in _cache or not _cache["error"]:
201
+ _cache["error"] = True
202
+ finally:
203
+ with _lock:
204
+ _cache["updating"] = False
205
+
206
+
207
+ def get_shorts(stale_ok=True):
208
+ """
209
+ Get shorts with stale-while-revalidate pattern.
210
+ Returns cached data immediately if available, triggers async update if stale.
211
+ """
212
+ global _cache
213
+
214
+ now = time.time()
215
+
216
+ with _lock:
217
+ cache_copy = dict(_cache)
218
+
219
+ # Load from disk if memory cache is empty
220
+ if not cache_copy.get("d"):
221
+ disk_cache = _load_cache()
222
+ if disk_cache.get("d"):
223
+ cache_copy = disk_cache
224
+
225
+ # Check if cache is stale
226
+ cache_age = now - cache_copy.get("t", 0)
227
+ is_stale = cache_age > 600 # 10 minutes
228
+
229
+ # Trigger background update if stale or empty
230
+ if is_stale or cache_copy.get("error"):
231
+ if not cache_copy.get("updating"):
232
+ threading.Thread(target=update_shorts_background, daemon=True).start()
233
+
234
+ # Return cache (even if stale, but warn timeout)
235
+ return cache_copy.get("d", [])
236
+
237
+
238
+ def get_shorts_with_status():
239
+ """Get shorts with metadata about freshness"""
240
+ now = time.time()
241
+
242
+ with _lock:
243
+ cache = dict(_cache)
244
+
245
+ if not cache.get("d"):
246
+ cache = _load_cache()
247
+
248
+ meta = _load_meta()
249
+ cache_age = now - cache.get("t", 0)
250
+
251
+ stale = cache_age > 600
252
+
253
+ return {
254
+ "shorts": cache.get("d", []),
255
+ "meta": {
256
+ "fresh": not stale,
257
+ "age_seconds": cache_age,
258
+ "last_update": meta.get("last_update"),
259
+ "last_success": meta.get("last_success"),
260
+ "count": len(cache.get("d", [])),
261
+ "errors": meta.get("errors", {})
262
+ }
263
+ }
264
+
265
+
266
+ # Initialize - trigger first update on module load
267
+ def init_updater():
268
+ """Initialize the shorts updater - run on app startup"""
269
+ # Load existing cache
270
+ global _cache, _meta
271
+ _cache = _load_cache()
272
+ _meta = _load_meta()
273
+
274
+ # Start background thread for periodic updates
275
+ def scheduler():
276
+ # First update immediately
277
+ time.sleep(2) # Wait for app to be ready
278
+ update_shorts_background(force=True)
279
+
280
+ while True:
281
+ time.sleep(UPDATE_INTERVAL)
282
+ update_shorts_background(force=False)
283
+
284
+ threading.Thread(target=scheduler, daemon=True).start()
285
+
286
+
287
+ # Fallback data for when all sources fail
288
+ FALLBACK_SHORTS = [
289
+ {"id": "nqlLH6chLRo", "title": "Tin nóng VTV Nam Bộ | #shorts", "img": "https://i.ytimg.com/vi/nqlLH6chLRo/hqdefault.jpg", "channel": "vtvnambo", "link": "https://www.youtube.com/shorts/nqlLH6chLRo", "source": "yt"},
290
+ {"id": "E7Kq0v3hG6w", "title": "VTV Nam Bộ - Tin tức miền Nam | #shorts", "img": "https://i.ytimg.com/vi/E7Kq0v3hG6w/hqdefault.jpg", "channel": "vtvnambo", "link": "https://www.youtube.com/shorts/E7Kq0v3hG6w", "source": "yt"},
291
+ {"id": "Lu_iCQ5YwNM", "title": "Công an lập hồ sơ xử lý người phụ nữ chửi bới tát nam tài xế ô tô ở Hà Nội", "img": "https://i.ytimg.com/vi/Lu_iCQ5YwNM/hqdefault.jpg", "channel": "baodantri7941", "link": "https://www.youtube.com/shorts/Lu_iCQ5YwNM", "source": "yt"},
292
+ ]
293
+
294
+
295
+ if __name__ == "__main__":
296
+ # Test run
297
+ init_updater()
298
+ time.sleep(5)
299
+ result = get_shorts_with_status()
300
+ print(json.dumps(result, ensure_ascii=False, indent=2))