mishrabp commited on
Commit
dfbefb3
·
verified ·
1 Parent(s): 4a794f2

Upload folder using huggingface_hub

Browse files
src/fifa/backend/main.py CHANGED
@@ -159,44 +159,87 @@ def _fetch_knockout() -> dict:
159
  return buckets
160
 
161
 
162
- _ESPN_NEWS = "https://now.core.api.espn.com/v1/sports/news?sport=soccer&league=fifa.world&limit=50"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
 
164
 
165
- def _fetch_highlights() -> list[dict]:
 
 
 
166
  try:
167
- resp = requests.get(_ESPN_NEWS, timeout=15)
168
- resp.raise_for_status()
169
- headlines = resp.json().get("headlines", [])
 
 
 
 
170
  except Exception:
171
- return []
 
172
 
173
- seen: set = set()
174
- clips: list[dict] = []
175
- for article in headlines:
176
- for v in article.get("video", []):
177
- vid_id = v.get("id")
178
- if not vid_id or vid_id in seen:
179
- continue
180
- seen.add(vid_id)
181
- src = v.get("links", {}).get("source", {}).get("href", "")
182
- web = v.get("links", {}).get("web", {}).get("href", "")
183
- if not (src or web):
184
- continue
185
- thumb = v.get("thumbnail", "") or ""
186
- if not thumb and v.get("images"):
187
- thumb = v["images"][0].get("url", "")
188
- clips.append({
189
- "id": vid_id,
190
- "headline": v.get("headline", ""),
191
- "description": v.get("description", "") or v.get("caption", ""),
192
- "thumbnail": thumb,
193
- "video_url": src,
194
- "espn_url": web,
195
- "duration": v.get("duration", 0),
196
- })
197
- if len(clips) >= 4:
198
- return clips
199
- return clips
200
 
201
 
202
  @app.get("/api/highlights")
 
159
  return buckets
160
 
161
 
162
+ _ESPN_BASE_URL = "https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world"
163
+ _HL_CACHE: list = []
164
+ _HL_CACHE_TS: datetime | None = None
165
+ _HL_TTL_SECS = 1800 # 30 min — YouTube search is slow, don't hammer it
166
+
167
+
168
+ def _get_recent_completed(days_back: int = 7, limit: int = 4) -> list[dict]:
169
+ from datetime import timedelta, timezone
170
+ utc_now = datetime.now(timezone.utc)
171
+ matches: list[dict] = []
172
+ for delta in range(0, days_back + 1):
173
+ target = utc_now - timedelta(days=delta)
174
+ date_str = target.strftime("%Y%m%d")
175
+ try:
176
+ resp = requests.get(f"{_ESPN_BASE_URL}/scoreboard?dates={date_str}", timeout=15)
177
+ if resp.status_code != 200:
178
+ continue
179
+ for ev in resp.json().get("events", []):
180
+ comp = ev.get("competitions", [{}])[0]
181
+ status = comp.get("status", {}).get("type", {}).get("description", "").lower()
182
+ if not any(s in status for s in ["final", "ft", "ended", "full time"]):
183
+ continue
184
+ competitors = comp.get("competitors", [{}, {}])
185
+ home = next((c for c in competitors if c.get("homeAway") == "home"), competitors[0])
186
+ away = next((c for c in competitors if c.get("homeAway") == "away"), competitors[-1])
187
+ home_team = home.get("team", {})
188
+ away_team = away.get("team", {})
189
+ matches.append({
190
+ "home_team": home_team.get("displayName", ""),
191
+ "home_logo": (home_team.get("logos") or [{}])[0].get("href", ""),
192
+ "home_score": home.get("score", ""),
193
+ "away_team": away_team.get("displayName", ""),
194
+ "away_logo": (away_team.get("logos") or [{}])[0].get("href", ""),
195
+ "away_score": away.get("score", ""),
196
+ "date": ev.get("date", "")[:10],
197
+ "round": ev.get("name", ""),
198
+ })
199
+ if len(matches) >= limit:
200
+ return matches
201
+ except Exception:
202
+ continue
203
+ return matches
204
 
205
 
206
+ def _yt_search(home: str, away: str) -> tuple[str | None, str | None]:
207
+ """Return (youtube_url, video_id) via DDGS, None if not found."""
208
+ import re
209
+ query = f"FIFA World Cup 2026 {home} vs {away} highlights"
210
  try:
211
+ from ddgs import DDGS
212
+ with DDGS() as ddgs:
213
+ for r in ddgs.text(query, max_results=8):
214
+ url = r.get("href", "")
215
+ m = re.search(r"(?:v=|youtu\.be/)([a-zA-Z0-9_-]{11})", url)
216
+ if m:
217
+ return url, m.group(1)
218
  except Exception:
219
+ pass
220
+ return None, None
221
 
222
+
223
+ def _fetch_highlights() -> list[dict]:
224
+ global _HL_CACHE, _HL_CACHE_TS
225
+ now = datetime.now()
226
+ if _HL_CACHE and _HL_CACHE_TS and (now - _HL_CACHE_TS).total_seconds() < _HL_TTL_SECS:
227
+ return _HL_CACHE
228
+
229
+ matches = _get_recent_completed(days_back=7, limit=4)
230
+ results = []
231
+ for m in matches:
232
+ _, vid_id = _yt_search(m["home_team"], m["away_team"])
233
+ results.append({
234
+ **m,
235
+ "youtube_id": vid_id,
236
+ "youtube_url": f"https://www.youtube.com/watch?v={vid_id}" if vid_id else None,
237
+ "thumbnail": f"https://img.youtube.com/vi/{vid_id}/maxresdefault.jpg" if vid_id else None,
238
+ })
239
+
240
+ _HL_CACHE = results
241
+ _HL_CACHE_TS = now
242
+ return results
 
 
 
 
 
 
243
 
244
 
245
  @app.get("/api/highlights")
src/fifa/frontend/src/views/HighlightsView.vue CHANGED
@@ -5,7 +5,7 @@ import { getHighlights } from '../services/api.js'
5
  const loading = ref(true)
6
  const error = ref(null)
7
  const clips = ref([])
8
- const playing = ref(null) // id of the currently-expanded video
9
 
10
  async function load() {
11
  try {
@@ -18,19 +18,15 @@ async function load() {
18
  }
19
  }
20
 
21
- function fmtDuration(secs) {
22
- if (!secs) return ''
23
- const m = Math.floor(secs / 60)
24
- const s = String(secs % 60).padStart(2, '0')
25
- return `${m}:${s}`
26
  }
27
 
28
- function toggle(clip) {
29
- if (playing.value === clip.id) {
30
- playing.value = null
31
- } else {
32
- playing.value = clip.id
33
- }
34
  }
35
 
36
  onMounted(load)
@@ -40,12 +36,12 @@ onMounted(load)
40
  <div class="highlights-view">
41
  <div class="view-header">
42
  <h1 class="view-title">🎬 Match Highlights</h1>
43
- <span class="subtitle">Latest FIFA World Cup 2026 videos · via ESPN</span>
44
  </div>
45
 
46
  <div v-if="loading" class="loading-spinner">
47
  <div class="spinner"></div>
48
- <span>Loading highlights…</span>
49
  </div>
50
 
51
  <div v-else-if="error" class="empty-state">
@@ -55,61 +51,77 @@ onMounted(load)
55
 
56
  <div v-else-if="clips.length === 0" class="empty-state">
57
  <div class="empty-icon">🎬</div>
58
- <p>No highlight videos available right now.</p>
59
  </div>
60
 
61
  <div v-else class="tiles-grid">
62
  <div
63
  v-for="clip in clips"
64
- :key="clip.id"
65
  class="tile card"
66
- :class="{ expanded: playing === clip.id }"
67
  >
68
- <!-- Video player (shown when expanded) -->
69
- <div v-if="playing === clip.id" class="player-wrap">
70
- <video
71
- class="player"
72
- :src="clip.video_url"
73
- controls
74
- autoplay
75
- preload="metadata"
76
- @error="playing = null"
77
- ></video>
78
  <button class="close-btn" @click="playing = null">✕ Close</button>
79
  </div>
80
 
81
- <!-- Thumbnail (shown when not playing) -->
82
- <div v-else class="thumb-wrap" @click="toggle(clip)">
 
83
  <img
84
  v-if="clip.thumbnail"
85
  :src="clip.thumbnail"
86
- :alt="clip.headline"
87
  class="thumb"
 
88
  />
89
- <div v-else class="thumb-placeholder">🎬</div>
90
-
91
- <div class="play-overlay">
92
- <div class="play-btn">▶</div>
93
- </div>
94
 
95
- <div class="duration-badge" v-if="clip.duration">
96
- {{ fmtDuration(clip.duration) }}
 
97
  </div>
98
  </div>
99
 
100
- <!-- Caption -->
101
- <div class="caption">
102
- <p class="caption-title">{{ clip.headline }}</p>
103
- <p class="caption-desc" v-if="clip.description && clip.description !== clip.headline">
104
- {{ clip.description }}
105
- </p>
106
- <div class="caption-actions">
107
- <button v-if="playing !== clip.id && clip.video_url" class="btn-watch" @click="toggle(clip)">
108
- ▶ Watch
109
- </button>
110
- <a v-if="clip.espn_url" :href="clip.espn_url" target="_blank" rel="noopener" class="btn-espn">
111
- ESPN ↗
112
- </a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  </div>
114
  </div>
115
  </div>
@@ -124,7 +136,6 @@ onMounted(load)
124
  .view-title { font-size: 22px; font-weight: 700; }
125
  .subtitle { font-size: 13px; color: var(--text-muted); }
126
 
127
- /* 2×2 grid */
128
  .tiles-grid {
129
  display: grid;
130
  grid-template-columns: repeat(2, 1fr);
@@ -136,23 +147,19 @@ onMounted(load)
136
  overflow: hidden;
137
  display: flex;
138
  flex-direction: column;
139
- transition: transform 0.2s, box-shadow 0.2s;
140
- }
141
- .tile.expanded {
142
- grid-column: 1 / -1; /* full-width when playing */
143
  }
 
144
 
145
  /* Thumbnail */
146
  .thumb-wrap {
147
  position: relative;
148
- cursor: pointer;
149
  aspect-ratio: 16 / 9;
150
  overflow: hidden;
151
  background: #000;
 
152
  }
153
  .thumb {
154
- width: 100%;
155
- height: 100%;
156
  object-fit: cover;
157
  transition: transform 0.3s, opacity 0.3s;
158
  }
@@ -160,119 +167,98 @@ onMounted(load)
160
  .thumb-placeholder {
161
  width: 100%; height: 100%;
162
  display: flex; align-items: center; justify-content: center;
163
- font-size: 48px;
164
- background: var(--bg-secondary);
165
  }
166
 
167
  .play-overlay {
168
- position: absolute;
169
- inset: 0;
170
- display: flex;
171
- align-items: center;
172
- justify-content: center;
173
- background: rgba(0,0,0,0.25);
174
  transition: background 0.2s;
175
  }
176
- .thumb-wrap:hover .play-overlay { background: rgba(0,0,0,0.45); }
 
177
 
178
  .play-btn {
179
- width: 56px; height: 56px;
180
  border-radius: 50%;
181
- background: rgba(255, 215, 0, 0.9);
182
- color: #000;
183
- font-size: 20px;
184
  display: flex; align-items: center; justify-content: center;
185
- box-shadow: 0 4px 20px rgba(0,0,0,0.5);
186
- transition: transform 0.2s;
187
- padding-left: 4px; /* optical center for ▶ */
188
  }
189
- .thumb-wrap:hover .play-btn { transform: scale(1.1); }
190
-
191
- .duration-badge {
192
- position: absolute;
193
- bottom: 8px; right: 8px;
194
- background: rgba(0,0,0,0.75);
195
- color: #fff;
196
- font-size: 11px;
197
- font-weight: 600;
198
- padding: 2px 7px;
199
- border-radius: 4px;
200
  }
201
 
202
- /* Player */
203
  .player-wrap { position: relative; background: #000; }
204
- .player {
205
- width: 100%;
206
- aspect-ratio: 16 / 9;
207
- display: block;
208
  }
209
  .close-btn {
210
- position: absolute;
211
- top: 10px; right: 10px;
212
- background: rgba(0,0,0,0.7);
213
- color: #fff;
214
- border: none;
215
- border-radius: 6px;
216
- padding: 5px 12px;
217
- font-size: 13px;
218
- cursor: pointer;
219
  }
220
- .close-btn:hover { background: rgba(255,0,0,0.7); }
221
 
222
- /* Caption */
223
- .caption {
224
  padding: 12px 14px 14px;
225
- display: flex;
226
- flex-direction: column;
227
- gap: 6px;
228
- flex: 1;
229
  }
230
- .caption-title {
231
- font-size: 14px;
232
- font-weight: 600;
233
- line-height: 1.4;
234
- color: var(--text-primary);
235
  }
236
- .caption-desc {
237
- font-size: 12px;
238
- color: var(--text-muted);
239
- line-height: 1.4;
240
- display: -webkit-box;
241
- -webkit-line-clamp: 2;
242
- -webkit-box-orient: vertical;
243
- overflow: hidden;
244
  }
245
- .caption-actions {
246
- display: flex;
247
- gap: 8px;
248
- margin-top: 4px;
 
 
 
 
 
 
 
 
249
  }
 
 
 
 
 
 
 
 
250
  .btn-watch {
251
  display: inline-flex; align-items: center; gap: 5px;
252
- padding: 5px 14px;
253
- border-radius: 6px;
254
- background: rgba(255,215,0,0.15);
255
- border: 1px solid var(--accent-gold);
256
- color: var(--accent-gold);
257
- font-size: 13px;
258
- font-weight: 600;
259
- cursor: pointer;
260
- transition: background 0.2s;
261
  }
262
- .btn-watch:hover { background: rgba(255,215,0,0.28); }
263
-
264
- .btn-espn {
265
  display: inline-flex; align-items: center;
266
- padding: 5px 12px;
267
- border-radius: 6px;
268
- background: transparent;
269
- border: 1px solid var(--border);
270
- color: var(--text-muted);
271
- font-size: 12px;
272
- text-decoration: none;
273
  transition: border-color 0.2s, color 0.2s;
274
  }
275
- .btn-espn:hover { border-color: var(--accent-gold); color: var(--accent-gold); }
 
276
 
277
  @media (max-width: 640px) {
278
  .tiles-grid { grid-template-columns: 1fr; }
 
5
  const loading = ref(true)
6
  const error = ref(null)
7
  const clips = ref([])
8
+ const playing = ref(null)
9
 
10
  async function load() {
11
  try {
 
18
  }
19
  }
20
 
21
+ function scoreLabel(clip) {
22
+ const hs = clip.home_score, as = clip.away_score
23
+ if (hs === '' || hs == null) return 'vs'
24
+ return `${hs} – ${as}`
 
25
  }
26
 
27
+ function formatDate(d) {
28
+ if (!d) return ''
29
+ return new Date(d + 'T12:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
 
 
 
30
  }
31
 
32
  onMounted(load)
 
36
  <div class="highlights-view">
37
  <div class="view-header">
38
  <h1 class="view-title">🎬 Match Highlights</h1>
39
+ <span class="subtitle">Last {{ clips.length }} completed matches · YouTube</span>
40
  </div>
41
 
42
  <div v-if="loading" class="loading-spinner">
43
  <div class="spinner"></div>
44
+ <span>Finding highlight videos…</span>
45
  </div>
46
 
47
  <div v-else-if="error" class="empty-state">
 
51
 
52
  <div v-else-if="clips.length === 0" class="empty-state">
53
  <div class="empty-icon">🎬</div>
54
+ <p>No completed matches yet — check back after games finish.</p>
55
  </div>
56
 
57
  <div v-else class="tiles-grid">
58
  <div
59
  v-for="clip in clips"
60
+ :key="clip.home_team + clip.away_team"
61
  class="tile card"
62
+ :class="{ expanded: playing === clip.home_team }"
63
  >
64
+ <!-- YouTube iframe (when playing) -->
65
+ <div v-if="playing === clip.home_team && clip.youtube_id" class="player-wrap">
66
+ <iframe
67
+ class="yt-player"
68
+ :src="`https://www.youtube-nocookie.com/embed/${clip.youtube_id}?autoplay=1&rel=0`"
69
+ frameborder="0"
70
+ allow="autoplay; encrypted-media; fullscreen"
71
+ allowfullscreen
72
+ ></iframe>
 
73
  <button class="close-btn" @click="playing = null">✕ Close</button>
74
  </div>
75
 
76
+ <!-- Thumbnail (when not playing) -->
77
+ <div v-else class="thumb-wrap" @click="clip.youtube_id && (playing = clip.home_team)">
78
+ <!-- YouTube-generated thumbnail -->
79
  <img
80
  v-if="clip.thumbnail"
81
  :src="clip.thumbnail"
82
+ :alt="`${clip.home_team} vs ${clip.away_team}`"
83
  class="thumb"
84
+ @error="$event.target.src = `https://img.youtube.com/vi/${clip.youtube_id}/hqdefault.jpg`"
85
  />
86
+ <div v-else class="thumb-placeholder">⚽</div>
 
 
 
 
87
 
88
+ <div class="play-overlay" :class="{ 'no-video': !clip.youtube_id }">
89
+ <div class="play-btn" v-if="clip.youtube_id">▶</div>
90
+ <div class="no-video-msg" v-else>Video not available</div>
91
  </div>
92
  </div>
93
 
94
+ <!-- Match info bar -->
95
+ <div class="match-bar">
96
+ <div class="team-row">
97
+ <div class="team-info">
98
+ <img v-if="clip.home_logo" :src="clip.home_logo" :alt="clip.home_team" class="team-logo" />
99
+ <span class="team-name">{{ clip.home_team }}</span>
100
+ </div>
101
+ <span class="score-pill">{{ scoreLabel(clip) }}</span>
102
+ <div class="team-info team-info-right">
103
+ <span class="team-name">{{ clip.away_team }}</span>
104
+ <img v-if="clip.away_logo" :src="clip.away_logo" :alt="clip.away_team" class="team-logo" />
105
+ </div>
106
+ </div>
107
+ <div class="match-meta">
108
+ <span class="match-round">{{ clip.round }}</span>
109
+ <span class="match-date">{{ formatDate(clip.date) }}</span>
110
+ </div>
111
+ <div class="actions">
112
+ <button
113
+ v-if="clip.youtube_id"
114
+ class="btn-watch"
115
+ @click="playing = clip.home_team"
116
+ >▶ Watch Highlights</button>
117
+ <a
118
+ v-if="clip.youtube_url"
119
+ :href="clip.youtube_url"
120
+ target="_blank"
121
+ rel="noopener"
122
+ class="btn-yt"
123
+ >YouTube ↗</a>
124
+ <span v-if="!clip.youtube_id" class="no-vid-label">No highlight found</span>
125
  </div>
126
  </div>
127
  </div>
 
136
  .view-title { font-size: 22px; font-weight: 700; }
137
  .subtitle { font-size: 13px; color: var(--text-muted); }
138
 
 
139
  .tiles-grid {
140
  display: grid;
141
  grid-template-columns: repeat(2, 1fr);
 
147
  overflow: hidden;
148
  display: flex;
149
  flex-direction: column;
 
 
 
 
150
  }
151
+ .tile.expanded { grid-column: 1 / -1; }
152
 
153
  /* Thumbnail */
154
  .thumb-wrap {
155
  position: relative;
 
156
  aspect-ratio: 16 / 9;
157
  overflow: hidden;
158
  background: #000;
159
+ cursor: pointer;
160
  }
161
  .thumb {
162
+ width: 100%; height: 100%;
 
163
  object-fit: cover;
164
  transition: transform 0.3s, opacity 0.3s;
165
  }
 
167
  .thumb-placeholder {
168
  width: 100%; height: 100%;
169
  display: flex; align-items: center; justify-content: center;
170
+ font-size: 48px; background: var(--bg-secondary);
 
171
  }
172
 
173
  .play-overlay {
174
+ position: absolute; inset: 0;
175
+ display: flex; align-items: center; justify-content: center;
176
+ background: rgba(0,0,0,0.2);
 
 
 
177
  transition: background 0.2s;
178
  }
179
+ .thumb-wrap:hover .play-overlay { background: rgba(0,0,0,0.4); }
180
+ .play-overlay.no-video { cursor: default; }
181
 
182
  .play-btn {
183
+ width: 60px; height: 60px;
184
  border-radius: 50%;
185
+ background: rgba(255,0,0,0.88); /* YouTube red */
186
+ color: #fff;
187
+ font-size: 22px;
188
  display: flex; align-items: center; justify-content: center;
189
+ box-shadow: 0 4px 24px rgba(0,0,0,0.6);
190
+ transition: transform 0.2s, background 0.2s;
191
+ padding-left: 4px;
192
  }
193
+ .thumb-wrap:hover .play-btn { transform: scale(1.12); background: rgba(255,0,0,1); }
194
+ .no-video-msg {
195
+ background: rgba(0,0,0,0.6); color: var(--text-muted);
196
+ padding: 6px 14px; border-radius: 6px; font-size: 13px;
 
 
 
 
 
 
 
197
  }
198
 
199
+ /* YouTube player */
200
  .player-wrap { position: relative; background: #000; }
201
+ .yt-player {
202
+ width: 100%; aspect-ratio: 16 / 9; display: block; border: none;
 
 
203
  }
204
  .close-btn {
205
+ position: absolute; top: 10px; right: 10px;
206
+ background: rgba(0,0,0,0.7); color: #fff;
207
+ border: none; border-radius: 6px;
208
+ padding: 5px 12px; font-size: 13px; cursor: pointer;
 
 
 
 
 
209
  }
210
+ .close-btn:hover { background: rgba(200,0,0,0.8); }
211
 
212
+ /* Match info bar */
213
+ .match-bar {
214
  padding: 12px 14px 14px;
215
+ display: flex; flex-direction: column; gap: 8px;
 
 
 
216
  }
217
+
218
+ .team-row {
219
+ display: flex; align-items: center; gap: 8px;
 
 
220
  }
221
+ .team-info {
222
+ display: flex; align-items: center; gap: 6px; flex: 1;
 
 
 
 
 
 
223
  }
224
+ .team-info-right { justify-content: flex-end; }
225
+ .team-logo { width: 24px; height: 24px; object-fit: contain; flex-shrink: 0; }
226
+ .team-name { font-size: 13px; font-weight: 600; }
227
+ .score-pill {
228
+ flex-shrink: 0;
229
+ padding: 3px 12px;
230
+ border-radius: 20px;
231
+ background: rgba(255,215,0,0.12);
232
+ border: 1px solid rgba(255,215,0,0.3);
233
+ font-size: 15px; font-weight: 800;
234
+ color: var(--accent-gold);
235
+ letter-spacing: 1px;
236
  }
237
+
238
+ .match-meta {
239
+ display: flex; justify-content: space-between;
240
+ font-size: 11px; color: var(--text-muted);
241
+ }
242
+ .match-round { font-style: italic; }
243
+
244
+ .actions { display: flex; gap: 8px; flex-wrap: wrap; }
245
  .btn-watch {
246
  display: inline-flex; align-items: center; gap: 5px;
247
+ padding: 6px 14px; border-radius: 6px;
248
+ background: rgba(255,0,0,0.15); border: 1px solid rgba(255,0,0,0.5);
249
+ color: #ff4444; font-size: 13px; font-weight: 600;
250
+ cursor: pointer; transition: background 0.2s;
 
 
 
 
 
251
  }
252
+ .btn-watch:hover { background: rgba(255,0,0,0.28); }
253
+ .btn-yt {
 
254
  display: inline-flex; align-items: center;
255
+ padding: 6px 12px; border-radius: 6px;
256
+ background: transparent; border: 1px solid var(--border);
257
+ color: var(--text-muted); font-size: 12px; text-decoration: none;
 
 
 
 
258
  transition: border-color 0.2s, color 0.2s;
259
  }
260
+ .btn-yt:hover { border-color: #ff4444; color: #ff4444; }
261
+ .no-vid-label { font-size: 12px; color: var(--text-muted); align-self: center; }
262
 
263
  @media (max-width: 640px) {
264
  .tiles-grid { grid-template-columns: 1fr; }