diff --git a/scripts/debug-dedupe-repro.js b/scripts/debug-dedupe-repro.js new file mode 100644 index 0000000..5e88a2c --- /dev/null +++ b/scripts/debug-dedupe-repro.js @@ -0,0 +1,40 @@ +const { createEspnProvider } = require('E:/OwnProject/ControllerCutie/src/main/football'); +const { createFootballMonitor } = require('E:/OwnProject/ControllerCutie/src/main/footballMonitor'); +const { namesMatch } = require('E:/OwnProject/ControllerCutie/src/main/footballManager'); +const store = { + _d: {}, + get(k, d) { return k in this._d ? this._d[k] : d; }, + set(k, v) { this._d[k] = v; } +}; +// Simulate the STALE duplicate the user had cached under old spelling/id +store._d['footballFixtures'] = [{ + id: 'espn:bayern-munich-vs-borussia-dortmund@na', + kickoffUtc: new Date(Date.now() + 3600000).toISOString(), + status: 'live', minute: "45+3'", + homeTeam: { id: '', name: 'FC Bayern München', crest: '' }, + awayTeam: { id: '', name: 'Borussia Dortmund', crest: '' }, + competition: { code: '', name: '' }, score: { home: 0, away: 0 }, pseudo: true +}]; +const espn = createEspnProvider({ + fetchFn: (u) => fetch(u, { headers: { 'User-Agent': 'Mozilla/5.0' } }), + onError: (e, s) => console.log('ESPN-ERR', s || '', e.message) +}); +const mon = createFootballMonitor({ + store, + favoriteTeams: [{ id: '', name: 'Bayern Munich', crest: '', competitionCode: 'BL1' }], + pinnedFixtures: [], leagues: ['PL', 'PD', 'BL1', 'CL'], reminderMinutes: 15, liveBoost: true, + espnProvider: espn, fdProvider: null, paceMs: 300, + onFixtureEvent: (e) => console.log('EVENT:', e.type, e.homeName, e.awayName), + onStatusUpdate: () => {}, + onError: (e) => console.log('ERR:', e.message) +}); +(async () => { + await mon.checkSweep(); + const fx = store.get('footballFixtures', []); + const sc = fx.filter(f => f.competition.code === 'GER-SC' || + (f.homeTeam.name + f.awayTeam.name).toLowerCase().includes('bayern')); + console.log('supercup/bayern entries:', sc.length); + sc.forEach(f => console.log(' -', f.id, '|', f.competition.code, '|', f.status, f.minute || '')); + console.log('watched(FC Bayern München spelling):', namesMatch('FC Bayern München', 'Bayern Munich')); +})(); + diff --git a/src/main/channelManager.js b/src/main/channelManager.js index 3d4a42b..e76396f 100644 --- a/src/main/channelManager.js +++ b/src/main/channelManager.js @@ -3,6 +3,8 @@ * Handles channel store management, normalization, and Google Takeout CSV import. */ +const { sanitizeString } = require('./youtube'); + /** * Normalizes a channel object to standard schema. */ @@ -25,6 +27,7 @@ function normalizeChannel(rawChannel) { if (!title) { title = handle ? handle.replace(/^@/, '') : id || 'Streamer'; } + title = sanitizeString(title); // If url is missing or not an http URL, construct it if (!url || !url.startsWith('http')) { @@ -39,6 +42,15 @@ function normalizeChannel(rawChannel) { } } + let currentStream = rawChannel.currentStream || null; + if (currentStream && typeof currentStream === 'object') { + currentStream = { + ...currentStream, + title: sanitizeString(currentStream.title), + channelTitle: sanitizeString(currentStream.channelTitle || title) + }; + } + return { id: id || handle || url, handle: handle || '', @@ -47,7 +59,7 @@ function normalizeChannel(rawChannel) { avatar: rawChannel.avatar || '', enabled: rawChannel.enabled !== false, isLive: Boolean(rawChannel.isLive), - currentStream: rawChannel.currentStream || null, + currentStream, lastLiveVideoId: rawChannel.lastLiveVideoId || null }; } diff --git a/src/main/football.js b/src/main/football.js index f17ff66..caf3106 100644 --- a/src/main/football.js +++ b/src/main/football.js @@ -7,7 +7,7 @@ const { normalizeFixture } = require('./footballManager'); const FD_BASE = 'https://api.football-data.org/v4'; -const ESPN_BASE = 'https://site.api.espn.com/apis/site/v2/sports/soccer'; +const ESPN_BASE = 'https://site.web.api.espn.com/apis/site/v2/sports/soccer'; // Single registry: fd.org competition code is canonical, ESPN slug travels with it. const LEAGUES = [ @@ -25,12 +25,45 @@ const LEAGUES = [ { code: 'BSA', name: 'Brasileirão Série A', espnSlug: 'bra.1' } ]; +/** + * ESPN-only cup competitions for the big-5 European countries (Phase 1): + * these have no fd.org free-tier competition, so their fixtures come purely + * from ESPN scoreboards. Slugs verified live 2026-08-22. + */ +const EXTRA_LEAGUES = [ + { code: 'ENG-FA', name: 'FA Cup', espnSlug: 'eng.fa', source: 'espn' }, + { code: 'ENG-LC', name: 'Carabao Cup', espnSlug: 'eng.league_cup', source: 'espn' }, + { code: 'ESP-CDR', name: 'Copa del Rey', espnSlug: 'esp.copa_del_rey', source: 'espn' }, + { code: 'ITA-CI', name: 'Coppa Italia', espnSlug: 'ita.coppa_italia', source: 'espn' }, + { code: 'GER-PK', name: 'DFB-Pokal', espnSlug: 'ger.dfb_pokal', source: 'espn' }, + { code: 'GER-SC', name: 'DFL-Supercup', espnSlug: 'ger.super_cup', source: 'espn' }, + { code: 'FRA-TC', name: 'Trophée des Champions', espnSlug: 'fra.super_cup', source: 'espn' } +]; + function espnSlugFor(code) { - const league = LEAGUES.find(l => l.code === code); + const league = [...LEAGUES, ...EXTRA_LEAGUES].find(l => l.code === code); return league ? league.espnSlug : null; } +function codeForEspnSlug(slug) { + if (!slug) return ''; + const league = [...LEAGUES, ...EXTRA_LEAGUES].find(l => l.espnSlug === slug); + return league ? league.code : ''; +} + +function getTeamCrest(teamObj) { + if (!teamObj) return ''; + if (teamObj.logo) return teamObj.logo; + if (Array.isArray(teamObj.logos) && teamObj.logos.length > 0 && teamObj.logos[0].href) { + return teamObj.logos[0].href; + } + return ''; +} + /** + * @deprecated football-data.org provider — no longer wired in production + * (main.js uses ESPN as the sole source). Kept for potential future use. + * * Primary provider — football-data.org v4. * Throttling follows the documented policy (docs.football-data.org/general/v4/policies.html): * free plan = 10 requests/minute. We combine a polite fixed pacing gap with @@ -102,25 +135,43 @@ function createFdProvider({ return res; } - async function fetchSchedule(leagueCode, dateFrom, dateTo) { - const url = `${FD_BASE}/competitions/${encodeURIComponent(leagueCode)}/matches` + - `?dateFrom=${encodeURIComponent(dateFrom)}&dateTo=${encodeURIComponent(dateTo)}`; - const opts = { headers: { 'X-Auth-Token': apiKey } }; - - let res = await requestOnce(url, opts); + async function apiGet(url) { + let res = await requestOnce(url, { headers: { 'X-Auth-Token': apiKey } }); // Honor the reset window on throttling responses, then retry once if (!res.ok && res.status === 429 && maxRetries > 0) { const reset = readHeader(res && res.headers, 'x-requestcounter-reset'); await sleepFn(((reset != null ? reset : 60) + 1) * 1000); - res = await requestOnce(url, opts); + res = await requestOnce(url, optsHeaders()); } if (!res.ok) { const body = await res.json().catch(() => ({})); - throw new Error(`football-data.org ${leagueCode} failed with HTTP ${res.status}: ${body.message || ''}`); + throw new Error(`football-data.org request failed with HTTP ${res.status}: ${body.message || ''} (${url})`); } - const data = await res.json(); + return res.json(); + } + + function optsHeaders() { + return { headers: { 'X-Auth-Token': apiKey } }; + } + + async function fetchSchedule(leagueCode, dateFrom, dateTo) { + const url = `${FD_BASE}/competitions/${encodeURIComponent(leagueCode)}/matches` + + `?dateFrom=${encodeURIComponent(dateFrom)}&dateTo=${encodeURIComponent(dateTo)}`; + const data = await apiGet(url); + return (data.matches || []).map(normalizeFixture).filter(Boolean); + } + + /** + * All fixtures of a specific team across ALL its competitions (league, + * cups, Europe) within the date window — used so Favorite Teams surface + * matches even outside the enabled league filters. + */ + async function fetchTeamFixtures(teamId, dateFrom, dateTo) { + const url = `${FD_BASE}/teams/${encodeURIComponent(teamId)}/matches` + + `?dateFrom=${encodeURIComponent(dateFrom)}&dateTo=${encodeURIComponent(dateTo)}`; + const data = await apiGet(url); return (data.matches || []).map(normalizeFixture).filter(Boolean); } @@ -128,19 +179,124 @@ function createFdProvider({ return { lastAvailable, lastResetSeconds }; } - return { name: 'football-data.org', fetchSchedule, getThrottleState }; + return { name: 'football-data.org', fetchSchedule, fetchTeamFixtures, getThrottleState }; +} + +function espnStatusFromState(state) { + if (state === 'post') return 'finished'; + if (state === 'in') return 'live'; + return 'scheduled'; +} + +function formatEspnMinute(status) { + if (!status) return null; + const type = status.type || {}; + if (type.state !== 'in') return null; + const short = type.shortDetail ? String(type.shortDetail).trim() : ''; + if (short && (short.includes("'") || short === 'HT' || short === 'HALFTIME')) { + return short; + } + const clock = status.displayClock; + if (clock != null && clock !== '') { + const clkStr = String(clock).trim(); + if (clkStr === '0' || clkStr === '0.0') return "1'"; + return clkStr.includes("'") ? clkStr : `${clkStr}'`; + } + return short || 'LIVE'; } /** * Live Boost provider — ESPN public scoreboard (keyless, unofficial). - * Returns flat live-state records; matching to fixtures happens in the monitor - * via normalized club names (ESPN ids are never stored). */ -function createEspnProvider({ fetchFn, onError = () => {} } = {}) { +function createEspnProvider({ baseUrl = ESPN_BASE, fetchFn, onError = () => {} } = {}) { if (typeof fetchFn !== 'function') { throw new Error('fetchFn is required'); } + function eventToFixture(event, code, leagueName) { + const status = event.status || {}; + const statusType = status.type || {}; + const competitions = event.competitions || []; + const comp = competitions[0] || {}; + const competitors = comp.competitors || []; + const home = competitors.find(c => c.homeAway === 'home'); + const away = competitors.find(c => c.homeAway === 'away'); + if (!home || !away) return null; + const resolvedCode = code || (event.league && codeForEspnSlug(event.league.slug)) || ''; + return { + id: `espn:${event.id}`, + kickoffUtc: event.date || null, + status: espnStatusFromState(statusType.state), + minute: formatEspnMinute(status), + homeTeam: { id: '', name: home.team && home.team.displayName || '', crest: getTeamCrest(home.team) }, + awayTeam: { id: '', name: away.team && away.team.displayName || '', crest: getTeamCrest(away.team) }, + competition: { code: resolvedCode, name: leagueName || '' }, + score: { + home: home.score != null ? Number(home.score) : null, + away: away.score != null ? Number(away.score) : null + } + }; + } + + async function fetchJsonOk(url) { + let res; + try { + res = await fetchFn(url); + } catch (err) { + throw new Error(`ESPN network error for ${url}: ${err.message}`); + } + if (!res.ok) { + throw new Error(`ESPN request failed with HTTP ${res.status} (${url})`); + } + return res.json(); + } + + /** + * Phase 1: full fixture list for an EXTRA cup competition within a date + * range (scoreboard supports YYYYMMDD-YYYYMMDD ranges). + */ + async function fetchFixtures(code, dateFrom, dateTo) { + const slug = espnSlugFor(code); + if (!slug) return []; + const compact = (d) => String(d).replace(/-/g, ''); + const url = `${baseUrl}/${slug}/scoreboard?dates=${compact(dateFrom)}-${compact(dateTo)}`; + const data = await fetchJsonOk(url); + const leagueName = (data.leagues && data.leagues[0] && data.leagues[0].name) || ''; + return (data.events || []) + .map(e => eventToFixture(e, code, leagueName)) + .filter(Boolean); + } + + /** + * Phase 2: a specific team's fixtures via its own schedule endpoint + * (?fixture=true unlocks the upcoming-fixture list). Covers ANY competition + * the team plays — cups, super cups — independent of league filters. + */ + async function fetchTeamSchedule(slug, teamId) { + const url = `${baseUrl}/${slug}/teams/${encodeURIComponent(teamId)}/schedule?fixture=true`; + const data = await fetchJsonOk(url); + const leagueSlug = (data.league && data.league.slug) || slug; + const leagueCode = codeForEspnSlug(leagueSlug); + const leagueName = (data.league && data.league.name) || (data.season && data.season.displayName) || ''; + return (data.events || []) + .map(e => eventToFixture(e, leagueCode, leagueName)) + .filter(Boolean); + } + + /** + * Phase 2: resolve ESPN team ids by listing a league's teams. + */ + async function fetchTeamDirectory(slug) { + const url = `${baseUrl}/${slug}/teams`; + const data = await fetchJsonOk(url); + const teamsNode = data.sports && data.sports[0] && data.sports[0].leagues && + data.sports[0].leagues[0] && data.sports[0].leagues[0].teams || []; + return teamsNode + .map(t => t.team) + .filter(Boolean) + .map(t => ({ id: String(t.id), name: t.displayName || t.name || '', espnSlug: slug })); + } + async function fetchLeagueState(slug, code) { const url = `${ESPN_BASE}/${slug}/scoreboard`; let res; @@ -158,7 +314,7 @@ function createEspnProvider({ fetchFn, onError = () => {} } = {}) { const events = data.events || []; const states = []; for (const event of events) { - const status = (event.status && event.status.type) || {}; + const statusType = (event.status && event.status.type) || {}; const competitors = ((event.competitions && event.competitions[0]) || {}).competitors || []; const home = competitors.find(c => c.homeAway === 'home'); const away = competitors.find(c => c.homeAway === 'away'); @@ -166,11 +322,12 @@ function createEspnProvider({ fetchFn, onError = () => {} } = {}) { states.push({ eventId: event.id, kickoffUtc: event.date || null, + competitionCode: code || '', homeName: home.team && home.team.displayName || '', awayName: away.team && away.team.displayName || '', - state: status.state || 'pre', - completed: Boolean(status.completed), - minute: event.status.displayClock || null, + state: statusType.state || 'pre', + completed: Boolean(statusType.completed), + minute: (event.status && event.status.displayClock) || null, scoreHome: home.score != null ? Number(home.score) : null, scoreAway: away.score != null ? Number(away.score) : null }); @@ -188,7 +345,14 @@ function createEspnProvider({ fetchFn, onError = () => {} } = {}) { return all; } - return { name: 'espn-live-boost', fetchLiveState }; + return { + name: 'espn-live-boost', + fetchLiveState, + fetchFixtures, + fetchSchedule: fetchFixtures, + fetchTeamSchedule, + fetchTeamDirectory + }; } -module.exports = { LEAGUES, createFdProvider, createEspnProvider }; +module.exports = { LEAGUES, EXTRA_LEAGUES, createFdProvider, createEspnProvider }; diff --git a/src/main/footballManager.js b/src/main/footballManager.js index 1f3d773..1c0c016 100644 --- a/src/main/footballManager.js +++ b/src/main/footballManager.js @@ -39,11 +39,13 @@ function normalizeFixture(raw) { if (raw.status === 'CANCELLED') return null; const fullTime = (raw.score && raw.score.fullTime) || {}; + const scoreHome = raw.score ? (raw.score.home != null ? raw.score.home : (fullTime.home != null ? fullTime.home : null)) : null; + const scoreAway = raw.score ? (raw.score.away != null ? raw.score.away : (fullTime.away != null ? fullTime.away : null)) : null; return { id: String(raw.id), - kickoffUtc: raw.utcDate || null, - status: STATUS_MAP[raw.status] || 'scheduled', + kickoffUtc: raw.kickoffUtc || raw.utcDate || null, + status: STATUS_MAP[raw.status] || (['scheduled', 'live', 'finished'].includes(raw.status) ? raw.status : 'scheduled'), minute: raw.minute != null ? String(raw.minute) : null, homeTeam: normalizeTeam(raw.homeTeam) || { id: '', name: 'Unknown', crest: '' }, awayTeam: normalizeTeam(raw.awayTeam) || { id: '', name: 'Unknown', crest: '' }, @@ -52,8 +54,8 @@ function normalizeFixture(raw) { name: (raw.competition && raw.competition.name) || '' }, score: { - home: fullTime.home != null ? fullTime.home : null, - away: fullTime.away != null ? fullTime.away : null + home: scoreHome, + away: scoreAway } }; } @@ -124,28 +126,133 @@ function togglePinnedFixture(list = [], fixture) { return next; } +/** + * Repairs/enriches a list of pinned fixtures: if any pinned fixture is missing kickoffUtc + * or has stale status, look it up in the schedule list by id or club identity and merge properties. + */ +function repairPinnedFixtures(pinnedList = [], scheduleList = []) { + if (!Array.isArray(pinnedList)) return []; + const schedule = Array.isArray(scheduleList) ? scheduleList : []; + return pinnedList.map(pin => { + if (!pin) return pin; + const match = schedule.find(f => + f && (f.id === pin.id || (namesMatch(f.homeTeam && f.homeTeam.name, pin.homeTeam && pin.homeTeam.name) && namesMatch(f.awayTeam && f.awayTeam.name, pin.awayTeam && pin.awayTeam.name))) + ); + if (match) { + return { + ...pin, + kickoffUtc: pin.kickoffUtc || match.kickoffUtc || null, + status: match.status || pin.status || 'scheduled', + minute: match.minute != null ? match.minute : pin.minute, + score: { + home: match.score && match.score.home != null ? match.score.home : (pin.score ? pin.score.home : null), + away: match.score && match.score.away != null ? match.score.away : (pin.score ? pin.score.away : null) + } + }; + } + return pin; + }); +} + function normalizeClubName(name) { return String(name || '').toLowerCase().replace(/\s+fc$|\s+cf$|\s+afc$/, '').trim(); } +// Unicode-fold a club name: lowercase, strip diacritics (ü→u), ß→ss, +// drop punctuation, collapse spaces. Output is the canonical comparison form. +function foldName(name) { + return String(name || '') + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/ß/g, 'ss') + .replace(/[.'`´]/g, '') + .replace(/&/g, 'and') + .replace(/-/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .replace(/\s+fc$|\s+cf$|\s+afc$/, ''); +} + +/** + * Alias groups: provider name variants that refer to the same club + * (fd.org / bundled list vs ESPN spellings), compared in folded form. + * Groups are deliberately conservative — different clubs sharing a city + * token (Barcelona vs RCD Espanyol de Barcelona) stay in separate groups. + */ +const CLUB_ALIAS_GROUPS = [ + ['bayern munich', 'bayern munchen', 'fc bayern munchen', 'fc bayern', 'bayern'], + ['real madrid', 'real madrid cf'], + ['espanyol', 'rcd espanyol', 'espanyol de barcelona', 'rcd espanyol de barcelona'], + ['athletic club', 'athletic bilbao'], + ['wolverhampton wanderers', 'wolves'], + ['internazionale', 'inter milan', 'inter'], + ['paris saint germain', 'psg'], + ['bayer leverkusen', 'bayer 04 leverkusen'], + ['atletico madrid', 'club atletico de madrid'], + ['real betis', 'real betis balompie'], + ['real sociedad', 'real sociedad de futbol'], + ['celta vigo', 'rc celta de vigo', 'rc celta'], + ['deportivo alaves', 'alaves'], + ['rayo vallecano', 'rayo vallecano de madrid'], + ['sevilla', 'sevilla fc'], + ['valencia', 'valencia cf'], + ['villarreal', 'villarreal cf'], + ['elche', 'elche cf'], + ['levante', 'levante ud'], + ['getafe', 'getafe cf'], + ['osasuna', 'ca osasuna'], + ['mallorca', 'rcd mallorca'], + ['manchester united', 'man utd', 'manchester utd'], + ['manchester city', 'man city'], + ['tottenham hotspur', 'tottenham'], + ['newcastle united', 'newcastle'], + ['west ham united', 'west ham'], + ['brighton and hove albion', 'brighton'], + ['leeds united', 'leeds'] +]; + +const CLUB_ALIAS_LOOKUP = (() => { + const map = new Map(); + for (const group of CLUB_ALIAS_GROUPS) { + const members = group.map(foldName); + const groupId = members[0]; + for (const m of members) map.set(m, groupId); + } + return map; +})(); + +/** + * Cross-provider club identity: true when two spelled variants refer to the + * same club. Exact-fold equality OR membership in the same alias group. + */ +function namesMatch(a, b) { + if (!a || !b) return false; + const fa = foldName(a); + const fb = foldName(b); + if (!fa || !fb) return false; + if (fa === fb) return true; + const ga = CLUB_ALIAS_LOOKUP.get(fa); + if (!ga) return false; + return ga === CLUB_ALIAS_LOOKUP.get(fb); +} + /** * Watched Fixture rule: a Favorite Team is playing OR the fixture is pinned. - * Matches favorites by provider team id OR normalized club name so keyless - * big-clubs favorites work before any API data exists. + * Matches favorites by provider team id OR cross-provider club-name identity + * (namesMatch) so keyless big-clubs favorites work before any API data exists. */ function isWatchedFixture(fixture, favoriteTeams = [], pinnedFixtures = []) { if (!fixture || !fixture.id) return false; if (pinnedFixtures.some(f => f.id === fixture.id)) return true; - const sideNames = [normalizeClubName(fixture.homeTeam && fixture.homeTeam.name), - normalizeClubName(fixture.awayTeam && fixture.awayTeam.name)]; + const sideNames = [fixture.homeTeam && fixture.homeTeam.name, fixture.awayTeam && fixture.awayTeam.name]; const sideIds = [fixture.homeTeam && fixture.homeTeam.id, fixture.awayTeam && fixture.awayTeam.id]; return favoriteTeams.some(t => { const favId = t.id != null ? String(t.id) : ''; if (favId && sideIds.includes(favId)) return true; - const favName = normalizeClubName(t.name); - return favName !== '' && sideNames.includes(favName); + return sideNames.some(n => namesMatch(n, t.name)); }); } @@ -238,11 +345,14 @@ module.exports = { addFavoriteTeam, removeFavoriteTeam, togglePinnedFixture, + repairPinnedFixtures, isWatchedFixture, filterScheduleFixtures, isBigMatch, monogram, normalizeClubName, + namesMatch, + foldName, loadBigClubs, searchBigClubs }; diff --git a/src/main/footballMonitor.js b/src/main/footballMonitor.js index ff8b99f..4bfda39 100644 --- a/src/main/footballMonitor.js +++ b/src/main/footballMonitor.js @@ -8,8 +8,16 @@ const { isWatchedFixture, - normalizeClubName + namesMatch, + foldName } = require('./footballManager'); +const { EXTRA_LEAGUES } = require('./football'); + +function eventKey(homeName, awayName, kickoffUtc) { + const day = kickoffUtc ? new Date(kickoffUtc).toISOString().slice(0, 10) : 'na'; + const slug = (n) => foldName(n || '').replace(/\s+/g, '-') || 'unknown'; + return `${slug(homeName)}-vs-${slug(awayName)}@${day}`; +} const FIXTURES_KEY = 'footballFixtures'; const EVENT_LOG_KEY = 'matchEventLog'; @@ -29,6 +37,7 @@ function createFootballMonitor({ leagues = ['PL', 'PD', 'BL1', 'CL'], reminderMinutes = 15, liveBoost = true, + cupsEnabled = true, espnProvider = null, fdProvider = null, nowFn = () => Date.now(), @@ -37,7 +46,7 @@ function createFootballMonitor({ onError = () => {}, watchIntervalMs = 75 * 1000, sweepIntervalMs = 6 * 60 * 60 * 1000, - paceMs = 7000 + paceMs = 250 } = {}) { if (!store) throw new Error('store instance is required for FootballMonitor'); @@ -102,13 +111,22 @@ function createFootballMonitor({ const prev = lastStatuses[target.id] || target.cachedStatus || null; const cur = statuses.get(target.id) || target.status; + // Dead-man switch: a fixture still marked live long past kickoff with + // no source reporting is over — force finished so it can't stick at + // "45'+3" forever (user-visible stale-live bug). + const effectiveCur = + cur === 'live' && !statuses.has(target.id) && + target.kickoffMs != null && now > target.kickoffMs + 3 * 60 * 60 * 1000 + ? 'finished' + : cur; + const fired = firedLog[target.id] || {}; // Reminder inside [kickoff - reminderMinutes, kickoff) const kickoffMs = target.kickoffMs; if ( !fired.reminder && - cur === 'scheduled' && + effectiveCur === 'scheduled' && kickoffMs != null && now >= kickoffMs - reminderMinutes * 60 * 1000 && now < kickoffMs @@ -116,16 +134,16 @@ function createFootballMonitor({ emit('reminder', target); } - if (!fired.kickoff && prev === 'scheduled' && cur === 'live') { + if (!fired.kickoff && prev === 'scheduled' && effectiveCur === 'live') { emit('kickoff', target); } - if (!fired.fulltime && cur === 'finished' && prev !== 'finished') { + if (!fired.fulltime && effectiveCur === 'finished' && prev !== 'finished') { emit('fulltime', target, { home: target.scoreHome, away: target.scoreAway }); } - lastStatuses[target.id] = cur; - persistStatus(target, cur); + lastStatuses[target.id] = effectiveCur; + persistStatus(target, effectiveCur); } } @@ -159,14 +177,19 @@ function createFootballMonitor({ const cached = store.get(FIXTURES_KEY, []); const watchedCached = cached.filter(f => - (isWatchedFixture(f, favoriteTeams, pinnedFixtures) || - pinnedFixtures.some(p => p.id === f.id)) && - (() => { - // Watcher only polls inside active match windows (plan §2.3) - if (!f.kickoffUtc) return true; - const k = Date.parse(f.kickoffUtc); - return now >= k - windowStartMs && now <= k + windowEndMs; - })() + ( + (isWatchedFixture(f, favoriteTeams, pinnedFixtures) || + pinnedFixtures.some(p => p.id === f.id)) && + (() => { + // Watcher only polls inside active match windows (plan §2.3)… + if (!f.kickoffUtc) return true; + const k = Date.parse(f.kickoffUtc); + return now >= k - windowStartMs && now <= k + windowEndMs; + })() + ) || + // …but a fixture the cache believes is LIVE stays watched past the + // window so the dead-man switch can retire it. + f.status === 'live' ); const targets = new Map(); @@ -184,41 +207,83 @@ function createFootballMonitor({ }); } + for (const p of pinnedFixtures) { + if (p && p.id && !targets.has(p.id)) { + const kMs = p.kickoffUtc ? Date.parse(p.kickoffUtc) : null; + if (!kMs || (now >= kMs - windowStartMs && now <= kMs + windowEndMs) || p.status === 'live') { + targets.set(p.id, { + id: p.id, + homeName: p.homeTeam ? p.homeTeam.name : '', + awayName: p.awayTeam ? p.awayTeam.name : '', + competitionCode: p.competition ? p.competition.code : '', + kickoffUtc: p.kickoffUtc, + kickoffMs: kMs, + status: p.status || 'scheduled', + cachedStatus: p.status || 'scheduled', + fromCache: false + }); + } + } + } + let statuses = new Map(); if (espnProvider && liveBoost) { + // Poll enabled leagues PLUS any cup/extra competition that has a watched + // fixture near its window — otherwise live cup matches never update. + const fdCodeSet = new Set(leagues); + const watchedExtraCodes = [...new Set( + [...targets.values()] + .map(t => t.competitionCode) + .filter(c => c && !fdCodeSet.has(c)) + )]; try { - const states = await espnProvider.fetchLiveState(leagues); + const states = await espnProvider.fetchLiveState([...fdCodeSet, ...watchedExtraCodes]); for (const s of states) { - const homeKey = normalizeClubName(s.homeName); - const awayKey = normalizeClubName(s.awayName); + const st = statusFromEspnState(s.state); + + // Update any cached fixture in FIXTURES_KEY so all games in schedule receive live scores/minutes + const currentFixtures = store.get(FIXTURES_KEY, []); + const cachedMatch = currentFixtures.find(f => + namesMatch(f.homeTeam && f.homeTeam.name, s.homeName) && + namesMatch(f.awayTeam && f.awayTeam.name, s.awayName) + ); + if (cachedMatch) { + cachedMatch.status = st; + cachedMatch.minute = s.minute; + cachedMatch.score = { + home: s.scoreHome != null ? s.scoreHome : (cachedMatch.score ? cachedMatch.score.home : null), + away: s.scoreAway != null ? s.scoreAway : (cachedMatch.score ? cachedMatch.score.away : null) + }; + store.set(FIXTURES_KEY, currentFixtures); + } - // Match against cached watched fixtures by club name... + // Match against watched/pinned targets... const match = [...targets.values()].find(t => - normalizeClubName(t.homeName) === homeKey && normalizeClubName(t.awayName) === awayKey + namesMatch(t.homeName, s.homeName) && namesMatch(t.awayName, s.awayName) ); if (match) { - match.status = statusFromEspnState(s.state); + match.status = st; match.minute = s.minute; match.scoreHome = s.scoreHome; match.scoreAway = s.scoreAway; statuses.set(match.id, match.status); } else if ( favoriteTeams.some(t => - normalizeClubName(t.name) === homeKey || normalizeClubName(t.name) === awayKey + namesMatch(t.name, s.homeName) || namesMatch(t.name, s.awayName) ) ) { // ...or surface favorite matches not yet in cache (keyless-friendly) - const pseudoId = `espn:${homeKey}-${awayKey}`; + const pseudoId = `espn:${eventKey(s.homeName, s.awayName, s.kickoffUtc)}`; const target = { id: pseudoId, homeName: s.homeName, awayName: s.awayName, - competitionCode: '', + competitionCode: s.competitionCode || '', kickoffUtc: s.kickoffUtc || null, kickoffMs: s.kickoffUtc ? Date.parse(s.kickoffUtc) : null, - status: statusFromEspnState(s.state), + status: st, cachedStatus: null, fromCache: false, minute: s.minute, @@ -227,6 +292,7 @@ function createFootballMonitor({ }; targets.set(pseudoId, target); statuses.set(pseudoId, target.status); + upsertPseudoFixture(target); } } } catch (err) { @@ -244,6 +310,36 @@ function createFootballMonitor({ return getStatus(); } + /** + * Keyless favorites: write ESPN-discovered favorite matches into the + * fixtures cache so the Matches tab shows them even though their + * competition was never swept. Pruned naturally by sweep cutoff. + */ + function upsertPseudoFixture(target) { + const fixtures = store.get(FIXTURES_KEY, []); + const idx = fixtures.findIndex(f => f.id === target.id); + const entry = { + id: target.id, + kickoffUtc: target.kickoffUtc, + status: target.status, + minute: target.minute != null ? target.minute : null, + homeTeam: { id: '', name: target.homeName, crest: '' }, + awayTeam: { id: '', name: target.awayName, crest: '' }, + competition: { code: target.competitionCode || '', name: target.competitionCode || 'LIVE' }, + score: { + home: target.scoreHome != null ? target.scoreHome : null, + away: target.scoreAway != null ? target.scoreAway : null + }, + pseudo: true + }; + if (idx >= 0) { + fixtures[idx] = { ...fixtures[idx], ...entry }; + } else { + fixtures.push(entry); + } + store.set(FIXTURES_KEY, fixtures); + } + async function fdFallback(watchedCached, targets) { if (!fdProvider) return new Map(); const statuses = new Map(); @@ -258,8 +354,7 @@ function createFootballMonitor({ const fixtures = await fdProvider.fetchSchedule(code, from, to); for (const f of fixtures) { const match = [...targets.values()].find(t => - normalizeClubName(t.homeName) === normalizeClubName(f.homeTeam.name) && - normalizeClubName(t.awayName) === normalizeClubName(f.awayTeam.name) + namesMatch(t.homeName, f.homeTeam.name) && namesMatch(t.awayName, f.awayTeam.name) ) || (targets.has(f.id) ? targets.get(f.id) : null); if (match) { match.status = f.status; @@ -280,35 +375,155 @@ function createFootballMonitor({ return statuses; } - /** Sweeper tick: refresh next-8-days schedules for enabled leagues (fd.org). */ + function sameFixture(a, b) { + if (!a || !b) return false; + const sameSides = + namesMatch(a.homeTeam && a.homeTeam.name, b.homeTeam && b.homeTeam.name) && + namesMatch(a.awayTeam && a.awayTeam.name, b.awayTeam && b.awayTeam.name); + const sameDay = a.kickoffUtc && b.kickoffUtc && + new Date(a.kickoffUtc).toDateString() === new Date(b.kickoffUtc).toDateString(); + return sameSides && (sameDay || !a.kickoffUtc || !b.kickoffUtc); + } + + /** + * Merge a swept fixture into the map. If an existing entry refers to the + * same real-world match (different id), prefer the MORE INFORMATIVE one: + * a canonical fixture (real competition code, non-pseudo) replaces a + * pseudo/stale entry; otherwise the existing entry wins. + */ + function mergeFixture(map, f) { + let dupId = null; + for (const [id, existing] of map) { + const eh = existing.homeTeam && existing.homeTeam.name; + const ea = existing.awayTeam && existing.awayTeam.name; + const fh = f.homeTeam && f.homeTeam.name; + const fa = f.awayTeam && f.awayTeam.name; + const sameRealMatch = + id === f.id || + sameFixture(existing, f) || + // Pseudo entries may lack a trustworthy kickoff/orientation — club + // identity alone (either side order) retires them once a canonical + // fixture arrives. + (existing.pseudo && + ((namesMatch(eh, fh) && namesMatch(ea, fa)) || + (namesMatch(eh, fa) && namesMatch(ea, fh)))); + if (sameRealMatch) { dupId = id; break; } + } + if (dupId == null) { + map.set(f.id, f); + return; + } + if (dupId === f.id) return; + const dup = map.get(dupId); + // Prefer the more informative entry: a fixture carrying a real + // competition code replaces one without it; otherwise keep existing. + if (!dup.competition || !dup.competition.code ? Boolean(f.competition && f.competition.code) : false) { + map.delete(dupId); + map.set(f.id, f); + } + } + + /** Sweeper tick: refresh schedules — ESPN leagues, ESPN big-5 cups, + * and per-team ESPN schedules for resolved favorites. */ async function checkSweep() { lastSweep = Date.now(); - if (!fdProvider) { - onError(new Error('checkSweep skipped: no fd.org provider configured')); - return getStatus(); - } const dayMs = 24 * 60 * 60 * 1000; const from = new Date(nowFn() - dayMs).toISOString().slice(0, 10); const to = new Date(nowFn() + 7 * dayMs).toISOString().slice(0, 10); const merged = new Map(store.get(FIXTURES_KEY, []).map(f => [f.id, f])); - for (let i = 0; i < leagues.length; i++) { - const code = leagues[i]; - try { - const fixtures = await fdProvider.fetchSchedule(code, from, to); - for (const f of fixtures) merged.set(f.id, f); - } catch (err) { - err.message = `football-data.org ${code} sweep failed: ${err.message}`; - onError(err, code); + if (espnProvider) { + // 1. Regular leagues (PL, PD, BL1, SA, FL1, CL, etc.) + if (typeof espnProvider.fetchSchedule === 'function') { + for (let i = 0; i < leagues.length; i++) { + const code = leagues[i]; + try { + const fixtures = await espnProvider.fetchSchedule(code, from, to); + for (const f of fixtures) { + mergeFixture(merged, f); + } + } catch (err) { + err.message = `ESPN ${code} sweep failed: ${err.message}`; + onError(err, code); + } + if (paceMs > 0 && i < leagues.length - 1) { + await new Promise(res => setTimeout(res, paceMs)); + } + } } - if (paceMs > 0 && i < leagues.length - 1) { - await new Promise(res => setTimeout(res, paceMs)); + + // 2. ESPN-only cup competitions (big-5) if enabled + if (cupsEnabled) { + for (let i = 0; i < EXTRA_LEAGUES.length; i++) { + try { + const cupFixtures = await espnProvider.fetchFixtures(EXTRA_LEAGUES[i].code, from, to); + for (const f of cupFixtures) { + mergeFixture(merged, f); + } + } catch (err) { + err.message = `ESPN ${EXTRA_LEAGUES[i].code} sweep failed: ${err.message}`; + onError(err, EXTRA_LEAGUES[i].code); + } + if (paceMs > 0 && i < EXTRA_LEAGUES.length - 1) { + await new Promise(res => setTimeout(res, paceMs)); + } + } + } + + // 3. Per-team schedules for favorites resolved with an ESPN identity + const espnFavorites = favoriteTeams.filter(t => t.espnSlug && t.espnTeamId); + for (let i = 0; i < espnFavorites.length; i++) { + const fav = espnFavorites[i]; + try { + const teamFixtures = await espnProvider.fetchTeamSchedule(fav.espnSlug, fav.espnTeamId); + for (const f of teamFixtures) { + mergeFixture(merged, { ...f, viaFavorite: fav.name }); + } + } catch (err) { + err.message = `ESPN team schedule for ${fav.name} (${fav.espnSlug}/${fav.espnTeamId}) failed: ${err.message}`; + onError(err, fav.espnSlug); + } + if (paceMs > 0 && i < espnFavorites.length - 1) { + await new Promise(res => setTimeout(res, paceMs)); + } + } + } + + if (fdProvider) { + for (let i = 0; i < leagues.length; i++) { + const code = leagues[i]; + try { + const fixtures = await fdProvider.fetchSchedule(code, from, to); + for (const f of fixtures) merged.set(f.id, f); + } catch (err) { + err.message = `football-data.org ${code} sweep failed: ${err.message}`; + onError(err, code); + } + if (paceMs > 0 && i < leagues.length - 1) { + await new Promise(res => setTimeout(res, paceMs)); + } + } + + // Favorite teams: fetch their fixtures across ALL competitions so + // matches outside enabled leagues still surface (plan Q10). + const keyedFavorites = favoriteTeams.filter(t => t.id && /^\d+$/.test(String(t.id))); + for (let i = 0; i < keyedFavorites.length; i++) { + try { + const teamFixtures = await fdProvider.fetchTeamFixtures(keyedFavorites[i].id, from, to); + for (const f of teamFixtures) merged.set(f.id, f); + } catch (err) { + err.message = `football-data.org team ${keyedFavorites[i].id} fixtures failed: ${err.message}`; + onError(err, keyedFavorites[i].id); + } + if (paceMs > 0 && i < keyedFavorites.length - 1) { + await new Promise(res => setTimeout(res, paceMs)); + } } } const all = [...merged.values()]; const cutoff = nowFn() - 2 * dayMs; - store.set(FIXTURES_KEY, all.filter(f => !f.kickoffUtc || Date.parse(f.kickoffUtc) > cutoff)); + store.set(FIXTURES_KEY, all.filter(f => Boolean(f.kickoffUtc) && Date.parse(f.kickoffUtc) > cutoff)); onStatusUpdate(getStatus()); return getStatus(); } @@ -330,6 +545,10 @@ function createFootballMonitor({ function start() { stop(); + const cached = store.get(FIXTURES_KEY, []); + if (!cached || cached.length === 0 || !lastSweep) { + Promise.resolve(checkSweep()).catch(err => onError(err)); + } watchTimer = setInterval(() => { Promise.resolve(checkWatch()).catch(err => onError(err)); }, watchIntervalMs); diff --git a/src/main/main.js b/src/main/main.js index 5a0b0f0..05964e5 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -11,15 +11,18 @@ const { migrateChannelsStore } = require('./channelManager'); const { createLiveMonitor, HISTORY_KEY, MAX_HISTORY } = require('./liveMonitor'); -const { LEAGUES, createFdProvider, createEspnProvider } = require('./football'); +const { LEAGUES, createEspnProvider } = require('./football'); const { loadBigClubs, searchBigClubs, addFavoriteTeam, removeFavoriteTeam, togglePinnedFixture, + repairPinnedFixtures, normalizeFixture, - isBigMatch + isBigMatch, + isWatchedFixture, + normalizeClubName } = require('./footballManager'); const { createFootballMonitor, FIXTURES_KEY } = require('./footballMonitor'); require('dotenv').config(); @@ -30,6 +33,8 @@ app.commandLine.appendSwitch('disable-software-rasterizer'); app.commandLine.appendSwitch('js-flags', '--max-old-space-size=64'); const store = new Store({ + clearInvalidConfig: true, + deserialize: (text) => JSON.parse(typeof text === 'string' ? text.replace(/^\uFEFF/, '') : text), defaults: { apiKey: process.env.YOUTUBE_API_KEY || '', channels: [], @@ -46,6 +51,8 @@ const store = new Store({ footballFavoriteTeams: [], footballPinnedFixtures: [], footballLeagues: ['PL', 'PD', 'BL1', 'CL'], + footballAllLeagues: true, + footballCupsEnabled: true, footballLiveBoost: true, matchReminderMinutes: 15, matchEventLog: {}, @@ -57,6 +64,11 @@ const store = new Store({ const initialChannels = migrateChannelsStore(store.store); if (initialChannels.length > 0 && (!store.get('channels') || store.get('channels').length === 0)) { store.set('channels', initialChannels); +} else { + const existingChannels = store.get('channels', []); + if (Array.isArray(existingChannels) && existingChannels.length > 0) { + store.set('channels', existingChannels.map(normalizeChannel)); + } } let tray = null; @@ -81,18 +93,30 @@ function buildFootballMonitor() { const logFootballError = (err) => { console.error('[FootballMonitor]', err && err.stack ? err.stack : err); }; - const apiKey = store.get('footballApiKey', ''); - const fdProvider = apiKey ? createFdProvider({ apiKey, fetchFn: (url, opts) => fetch(url, opts) }) : null; + // football-data.org deprecated: ESPN is now the sole data source + // (schedules via scoreboards/extras + per-team schedules for resolved favorites). + const fdProvider = null; const espnProvider = createEspnProvider({ fetchFn: (url) => fetch(url), onError: logFootballError }); + const cachedFixtures = store.get(FIXTURES_KEY, []); + if (Array.isArray(cachedFixtures)) { + const cleaned = cachedFixtures.filter(f => f && f.kickoffUtc); + if (cleaned.length !== cachedFixtures.length) { + store.set(FIXTURES_KEY, cleaned); + } + } + footballMonitor = createFootballMonitor({ store, favoriteTeams: store.get('footballFavoriteTeams', []), pinnedFixtures: store.get('footballPinnedFixtures', []), - leagues: store.get('footballLeagues', ['PL', 'PD', 'BL1', 'CL']), + leagues: store.get('footballAllLeagues', true) + ? LEAGUES.map(l => l.code) + : store.get('footballLeagues', ['PL', 'PD', 'BL1', 'CL']), + cupsEnabled: store.get('footballCupsEnabled', true), reminderMinutes: store.get('matchReminderMinutes', 15), liveBoost: store.get('footballLiveBoost', true), espnProvider, @@ -299,7 +323,6 @@ ipcMain.handle('save-settings', async (_, settings) => { const oldApiKey = store.get('apiKey'); const oldChannelId = store.get('channelId'); const oldChannelUrl = store.get('channelUrl'); - const oldFootballApiKey = store.get('footballApiKey'); if (settings.channelUrl && (settings.channelUrl !== oldChannelUrl || !settings.channelId)) { try { @@ -330,12 +353,6 @@ ipcMain.handle('save-settings', async (_, settings) => { liveMonitor.checkNow(false); } - if (settings.footballApiKey !== undefined && settings.footballApiKey !== oldFootballApiKey) { - startFootballIfEnabled(); - if (footballMonitor) Promise.resolve(footballMonitor.checkSweep()).catch(err => - console.error('[Football] sweep after key change failed:', err.stack || err)); - } - return { success: true, channelId: settings.channelId }; }); @@ -553,12 +570,35 @@ ipcMain.on('minimize-settings', () => { if (win) win.minimize(); }); -ipcMain.on('click-stream', (_, targetVideoId) => { +ipcMain.on('click-stream', (_, targetVideoId, popupType) => { + let videoId = targetVideoId; + let type = popupType; + + if (typeof targetVideoId === 'object' && targetVideoId !== null) { + videoId = targetVideoId.videoId; + type = targetVideoId.type || type; + } + + if (type === 'football' || videoId === 'football') { + const settingsWin = windowManager.createSettingsWindow(); + if (settingsWin && settingsWin.webContents) { + if (settingsWin.webContents.isLoading()) { + settingsWin.webContents.once('did-finish-load', () => { + windowManager.broadcastToSettings('select-tab', 'football'); + }); + } else { + windowManager.broadcastToSettings('select-tab', 'football'); + } + } + windowManager.animatePopupOut(); + return; + } + const status = liveMonitor.getStatus(); - const videoId = targetVideoId || status.streamData?.videoId; + const id = videoId || status.streamData?.videoId; - if (videoId && videoId !== 'live') { - const url = `https://www.youtube.com/watch?v=${videoId}`; + if (id && id !== 'live') { + const url = `https://www.youtube.com/watch?v=${id}`; if (store.get('openInBrowser', true)) { shell.openExternal(url); } else { @@ -592,48 +632,44 @@ function broadcastFootballStatus() { } } -ipcMain.handle('get-football-state', guardFootball(() => ({ - success: true, - enabled: true, - apiKey: store.get('footballApiKey', ''), - favoriteTeams: store.get('footballFavoriteTeams', []), - pinnedFixtures: store.get('footballPinnedFixtures', []), - leagues: store.get('footballLeagues', []), - liveBoost: store.get('footballLiveBoost', true), - reminderMinutes: store.get('matchReminderMinutes', 15), - status: footballMonitor ? footballMonitor.getStatus() : null -}))); - -ipcMain.handle('get-football-schedule', guardFootball(() => ({ - success: true, - fixtures: store.get(FIXTURES_KEY, []).map(f => ({ - ...f, - bigMatch: isBigMatch(f) - })) -}))); - -ipcMain.handle('search-football-teams', guardFootball(async (_, { query = '', competitionCode = '' } = {}) => { - const apiKey = store.get('footballApiKey', ''); - const q = String(query || '').trim().toLowerCase(); - - // Keyed: league-first live search over the API team list - if (apiKey && competitionCode) { - try { - const url = `https://api.football-data.org/v4/competitions/${encodeURIComponent(competitionCode)}/teams`; - const res = await fetch(url, { headers: { 'X-Auth-Token': apiKey } }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - const teams = (data.teams || []) - .map(t => ({ id: String(t.id), name: t.name, crest: t.crest || '', competitionCode })) - .filter(t => !q || t.name.toLowerCase().includes(q)) - .slice(0, 30); - return { success: true, source: 'api', teams }; - } catch (err) { - console.error('[Football] API team search failed, falling back to bundled list:', err.stack || err); - } +ipcMain.handle('get-football-state', guardFootball(() => { + const pins = store.get('footballPinnedFixtures', []); + const schedule = store.get(FIXTURES_KEY, []); + const repairedPins = repairPinnedFixtures(pins, schedule); + if (JSON.stringify(pins) !== JSON.stringify(repairedPins)) { + store.set('footballPinnedFixtures', repairedPins); } + return { + success: true, + enabled: true, + apiKey: store.get('footballApiKey', ''), + favoriteTeams: store.get('footballFavoriteTeams', []), + pinnedFixtures: repairedPins, + leagues: store.get('footballLeagues', []), + allLeagues: store.get('footballAllLeagues', true), + cupsEnabled: store.get('footballCupsEnabled', true), + liveBoost: store.get('footballLiveBoost', true), + reminderMinutes: store.get('matchReminderMinutes', 15), + status: footballMonitor ? footballMonitor.getStatus() : null + }; +})); + +ipcMain.handle('get-football-schedule', guardFootball(() => { + const favorites = store.get('footballFavoriteTeams', []); + const pins = store.get('footballPinnedFixtures', []); + return { + success: true, + fixtures: store.get(FIXTURES_KEY, []).map(f => ({ + ...f, + bigMatch: isBigMatch(f), + watched: isWatchedFixture(f, favorites, pins) + })) + }; +})); - // Keyless fallback: bundled big-clubs list +ipcMain.handle('search-football-teams', guardFootball(async (_, { query = '', competitionCode = '' } = {}) => { + // football-data.org deprecated — team search runs over the bundled + // big-clubs list; ESPN per-team schedules cover fixtures after favoriting. return { success: true, source: 'bundled', @@ -641,8 +677,48 @@ ipcMain.handle('search-football-teams', guardFootball(async (_, { query = '', co }; })); -ipcMain.handle('add-favorite-team', guardFootball((_, team) => { - const updated = addFavoriteTeam(store.get('footballFavoriteTeams', []), team); +// Phase 2: resolve a favorite team's ESPN identity (slug + team id) by +// scanning the big-5 league directories once each, cached in-memory. +let espnDirectoryCache = {}; +function getEspnProvider() { + return createEspnProvider({ + fetchFn: (url) => fetch(url), + onError: (err) => console.error('[ESPN]', err && err.stack ? err.stack : err) + }); +} + +async function resolveEspnIdentity(name) { + const provider = getEspnProvider(); + const slugs = ['ger.1', 'eng.1', 'esp.1', 'ita.1', 'fra.1']; + for (const slug of slugs) { + try { + if (!espnDirectoryCache[slug]) { + espnDirectoryCache[slug] = await provider.fetchTeamDirectory(slug); + } + const hit = espnDirectoryCache[slug].find(t => + normalizeClubName(t.name) === normalizeClubName(name) + ); + if (hit) return { espnSlug: hit.espnSlug, espnTeamId: hit.id }; + } catch (err) { + console.error(`[Football] ESPN directory ${slug} failed:`, err.stack || err); + } + } + return null; +} + +ipcMain.handle('add-favorite-team', guardFootball(async (_, team) => { + let updated = addFavoriteTeam(store.get('footballFavoriteTeams', []), team); + // Best-effort: attach ESPN identity so per-team schedules cover cup matches too + try { + const identity = await resolveEspnIdentity(team.name || (updated[updated.length - 1] || {}).name); + if (identity) { + updated = updated.map(t => + normalizeClubName(t.name) === normalizeClubName(team.name || '') ? { ...t, ...identity } : t + ); + } + } catch (err) { + console.error('[Football] ESPN identity resolution failed:', err.stack || err); + } store.set('footballFavoriteTeams', updated); startFootballIfEnabled(); return { success: true, favoriteTeams: updated }; @@ -664,12 +740,28 @@ ipcMain.handle('toggle-pin-fixture', guardFootball((_, fixture) => { return { success: true, pinnedFixtures: updated }; })); -ipcMain.handle('set-football-leagues', guardFootball((_, leagues) => { +ipcMain.handle('set-football-leagues', guardFootball((_, payload) => { + // Accepts { all, leagues, cupsEnabled } or a legacy bare array of league codes + const isArray = Array.isArray(payload); + const all = isArray ? undefined : payload.all; + const leagues = isArray ? payload : (payload.leagues || []); + const cupsEnabled = isArray ? undefined : payload.cupsEnabled; if (!Array.isArray(leagues)) throw new Error('leagues must be an array'); const valid = leagues.filter(c => LEAGUES.some(l => l.code === c)); store.set('footballLeagues', valid); + if (all !== undefined) { + store.set('footballAllLeagues', all === true); + } + if (cupsEnabled !== undefined) { + store.set('footballCupsEnabled', cupsEnabled === true); + } startFootballIfEnabled(); - return { success: true, leagues: valid }; + return { + success: true, + allLeagues: store.get('footballAllLeagues', true), + leagues: valid, + cupsEnabled: store.get('footballCupsEnabled', true) + }; })); ipcMain.handle('set-football-enabled', (_, enabled) => { diff --git a/src/main/preload-popup.js b/src/main/preload-popup.js index 3700cbb..5a39db5 100644 --- a/src/main/preload-popup.js +++ b/src/main/preload-popup.js @@ -2,6 +2,6 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('popupApi', { onStreamData: (callback) => ipcRenderer.on('stream-data', (_event, data) => callback(data)), - sendClickStream: (videoId) => ipcRenderer.send('click-stream', videoId), + sendClickStream: (videoId, type) => ipcRenderer.send('click-stream', videoId, type), sendClosePopup: () => ipcRenderer.send('close-popup'), }); \ No newline at end of file diff --git a/src/main/preload.js b/src/main/preload.js index 69a7976..9a2bba0 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -37,4 +37,5 @@ contextBridge.exposeInMainWorld('api', { checkFootballNow: () => ipcRenderer.invoke('check-football-now'), testFootballNotification: () => ipcRenderer.invoke('test-football-notification'), onFootballStatusUpdated: (callback) => ipcRenderer.on('football-status-updated', (_event, data) => callback(data)), + onSelectTab: (callback) => ipcRenderer.on('select-tab', (_event, tabName) => callback(tabName)), }); \ No newline at end of file diff --git a/src/main/youtube.js b/src/main/youtube.js index 4657179..003c000 100644 --- a/src/main/youtube.js +++ b/src/main/youtube.js @@ -8,6 +8,66 @@ const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 5, keepAliveMs const YOUTUBE_API_BASE = 'https://www.googleapis.com/youtube/v3'; +const WIN1252_MAP = { + '\u20AC': 0x80, '\u201A': 0x82, '\u0192': 0x83, '\u201E': 0x84, '\u2026': 0x85, + '\u2020': 0x86, '\u2021': 0x87, '\u02C6': 0x88, '\u2030': 0x89, '\u0160': 0x8A, + '\u2039': 0x8B, '\u0152': 0x8C, '\u017D': 0x8E, '\u2018': 0x91, '\u2019': 0x92, + '\u201C': 0x93, '\u201D': 0x94, '\u2022': 0x95, '\u2013': 0x96, '\u2014': 0x97, + '\u02DC': 0x98, '\u2122': 0x99, '\u0161': 0x9A, '\u203A': 0x9B, '\u0153': 0x9C, + '\u017E': 0x9E, '\u0178': 0x9F +}; + +function fixMojibake(str) { + if (!str || typeof str !== 'string') return str; + if (!/[\u0080-\u00FF\u0152\u0153\u0160\u0161\u017D\u017E\u0178\u0192\u02C6\u02DC\u2013\u2014\u2018\u2019\u201A\u201C\u201D\u201E\u2020\u2021\u2022\u2026\u2030\u2039\u203A\u20AC\u2122]/.test(str)) { + return str; + } + try { + const bytes = []; + for (let i = 0; i < str.length; i++) { + const ch = str[i]; + const code = ch.charCodeAt(0); + if (code < 128) { + bytes.push(code); + } else if (WIN1252_MAP[ch] !== undefined) { + bytes.push(WIN1252_MAP[ch]); + } else if (code <= 255) { + bytes.push(code); + } else { + return str; + } + } + const buf = Buffer.from(bytes); + const decoded = buf.toString('utf8'); + if (!decoded.includes('\uFFFD')) return decoded; + } catch (e) { + // Return original on decode failure + } + return str; +} + +function decodeHtmlEntities(str) { + if (!str || typeof str !== 'string') return str; + return str + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/ /g, ' ') + .replace(/&#(\d+);/g, (_, dec) => { + try { return String.fromCodePoint(parseInt(dec, 10)); } catch (e) { return _; } + }) + .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => { + try { return String.fromCodePoint(parseInt(hex, 16)); } catch (e) { return _; } + }); +} + +function sanitizeString(str) { + if (!str || typeof str !== 'string') return str; + return fixMojibake(decodeHtmlEntities(str)); +} + async function checkLiveStatus(apiKey, channelId) { if (!apiKey || !channelId) { throw new Error('API key and channel ID are required'); @@ -35,8 +95,8 @@ async function checkLiveStatus(apiKey, channelId) { return { isLive: true, videoId: video.id.videoId, - title: video.snippet.title, - channelTitle: video.snippet.channelTitle, + title: sanitizeString(video.snippet.title), + channelTitle: sanitizeString(video.snippet.channelTitle), thumbnail: video.snippet.thumbnails.high?.url || video.snippet.thumbnails.default?.url, publishedAt: video.snippet.publishedAt }; @@ -183,13 +243,13 @@ async function checkLiveStatusFree(channelIdentifier) { let title = 'Live Stream'; const titleMatch = html.match(/([^<]+)<\/title>/); if (titleMatch) { - title = titleMatch[1].replace(/ - YouTube$/, '').trim(); + title = sanitizeString(titleMatch[1].replace(/ - YouTube$/, '').trim()); } let channelTitle = trimmed; const authorMatch = html.match(/ { const activeTheme = data.theme || localStorage.getItem('appTheme') || 'pink'; document.documentElement.setAttribute('data-theme', activeTheme); + currentNotificationType = data ? data.type : null; if (data.type === 'football') { renderFootballPopup(data); @@ -85,7 +87,7 @@ popup.addEventListener('click', (e) => { if (e.target.closest('#popupCloseBtn') || e.target.closest('.popup-close-btn')) { return; } - window.popupApi.sendClickStream(currentVideoId); + window.popupApi.sendClickStream(currentVideoId, currentNotificationType); }); document.addEventListener('keydown', (e) => { diff --git a/src/renderer/settings/index.html b/src/renderer/settings/index.html index c5dac5f..2a71ea0 100644 --- a/src/renderer/settings/index.html +++ b/src/renderer/settings/index.html @@ -226,6 +226,7 @@

📅 Fixture Schedule (Next
+
@@ -233,6 +234,26 @@

📅 Fixture Schedule (Next

+
+ + +
+
+ 📅 + Today · Sun, Aug 23 + 0 matches +
+ +
+ + +
Loading fixtures...
@@ -262,14 +283,8 @@

📌 Pinned Fixtures

-

🔑 football-data.org API Key

-
- - - Favorites work without a key. A free key unlocks the full fixture schedule. Get one at football-data.org/client/register. -
- - +

📡 Data Source

+ Football fixtures and live scores come from ESPN automatically — no API key needed. Team search uses the built-in top-clubs list.
diff --git a/src/renderer/settings/renderer.js b/src/renderer/settings/renderer.js index 46cc0d1..8bfc9c2 100644 --- a/src/renderer/settings/renderer.js +++ b/src/renderer/settings/renderer.js @@ -1021,11 +1021,14 @@ const SVG_ICONS = { const LEAGUE_LABELS = { PL: 'EPL', PD: 'La Liga', SA: 'Serie A', BL1: 'Bundesliga', FL1: 'Ligue 1', CL: 'UCL', WC: 'World Cup', EC: 'Euro', ELC: 'Championship', DED: 'Eredivisie', - PPL: 'Primeira', BSA: 'Brasileirão' + PPL: 'Primeira', BSA: 'Brasileirão', + 'ENG-FA': 'FA Cup', 'ENG-LC': 'Carabao Cup', 'ESP-CDR': 'Copa del Rey', + 'ITA-CI': 'Coppa Italia', 'GER-PK': 'DFB-Pokal', 'GER-SC': 'Supercup', 'FRA-TC': 'Trophée des Champions' }; +const FD_LEAGUE_CODES = ['PL', 'PD', 'SA', 'BL1', 'FL1', 'CL', 'WC', 'EC', 'ELC', 'DED', 'PPL', 'BSA']; let footballState = { - apiKey: '', favoriteTeams: [], pinnedFixtures: [], leagues: [], liveBoost: true, reminderMinutes: 15 + apiKey: '', favoriteTeams: [], pinnedFixtures: [], leagues: [], allMode: true, cupsEnabled: true, liveBoost: true, reminderMinutes: 15 }; let fixturesCache = []; let activeFixtureFilter = 'all'; @@ -1042,12 +1045,49 @@ const teamSearchResultsEl = document.getElementById('teamSearchResults'); const teamSearchHelpEl = document.getElementById('teamSearchHelp'); const favoriteTeamsListEl = document.getElementById('favoriteTeamsList'); const pinnedFixturesListEl = document.getElementById('pinnedFixturesList'); -const footballApiKeyInput = document.getElementById('footballApiKeyInput'); -const saveFootballKeyBtn = document.getElementById('saveFootballKeyBtn'); -const footballKeyResult = document.getElementById('footballKeyResult'); const reminderMinutesInput = document.getElementById('reminderMinutesInput'); const liveBoostCheckbox = document.getElementById('liveBoostCheckbox'); +// Centered Date Navigator Elements +const fbDateNav = document.getElementById('fbDateNav'); +const fbPrevDayBtn = document.getElementById('fbPrevDayBtn'); +const fbNextDayBtn = document.getElementById('fbNextDayBtn'); +const fbDateCenter = document.getElementById('fbDateCenter'); +const fbDateTitle = document.getElementById('fbDateTitle'); +const fbDateBadge = document.getElementById('fbDateBadge'); +const fbTodayQuickBtn = document.getElementById('fbTodayQuickBtn'); + +let selectedDateOffset = 0; // 0 = Today, -1 = Yesterday, +1 = Tomorrow, etc. + +function getOffsetDate(offsetDays = 0) { + const d = new Date(); + d.setDate(d.getDate() + offsetDays); + return d; +} + +function formatDateNavTitle(date, offsetDays) { + const weekday = date.toLocaleDateString(undefined, { weekday: 'short' }); + const monthDay = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + if (offsetDays === 0) { + return `Today · ${weekday}, ${monthDay}`; + } else if (offsetDays === 1) { + return `Tomorrow · ${weekday}, ${monthDay}`; + } else if (offsetDays === -1) { + return `Yesterday · ${weekday}, ${monthDay}`; + } + return `${weekday}, ${monthDay}`; +} + +function changeDateOffset(delta) { + selectedDateOffset += delta; + renderFixtures(); +} + +function resetToToday() { + selectedDateOffset = 0; + renderFixtures(); +} + function applyFootballVisibility(enabled) { const btn = document.querySelector('.tab-btn[data-tab="football"]'); if (btn) btn.style.display = enabled ? '' : 'none'; @@ -1065,22 +1105,21 @@ async function loadFootballTab() { apiKey: state.apiKey || '', favoriteTeams: state.favoriteTeams || [], pinnedFixtures: state.pinnedFixtures || [], - leagues: state.leagues && state.leagues.length ? state.leagues : ['PL', 'PD', 'BL1', 'CL'], + leagues: state.leagues || [], + allMode: state.allLeagues !== false, + cupsEnabled: state.cupsEnabled !== false, liveBoost: state.liveBoost !== false, reminderMinutes: state.reminderMinutes || 15 }; - footballApiKeyInput.value = footballState.apiKey; reminderMinutesInput.value = footballState.reminderMinutes; liveBoostCheckbox.checked = footballState.liveBoost; - teamSearchHelpEl.textContent = footballState.apiKey - ? 'API key active — searching bundled top clubs.' - : 'No API key: searching bundled top clubs. Add a free key below for full schedules.'; + teamSearchHelpEl.textContent = 'Searching bundled top clubs — fixtures come from ESPN after favoriting.'; const sched = await window.api.getFootballSchedule(); fixturesCache = (sched && sched.fixtures) || []; - renderLeagueChips(leagueChipsEl, footballState.leagues, toggleLeague); - renderLeagueChips(favLeagueChipsEl, [favSearchLeague], setFavSearchLeague); + renderLeagueChips(leagueChipsEl, footballState.leagues, footballState.cupsEnabled, footballState.allMode); + renderFavLeagueChips(favLeagueChipsEl, favSearchLeague); renderFavoriteTeams(); renderPinnedFixtures(); renderFixtures(); @@ -1091,39 +1130,83 @@ async function loadFootballTab() { } } -function renderLeagueChips(container, activeCodes, onToggle) { - container.innerHTML = Object.keys(LEAGUE_LABELS).map(code => ` - +function renderLeagueChips(container, activeCodes, cupsEnabled, allMode) { + // "All" is an exclusive mode: only All is bold; individual chips are bold + // only after leaving All mode by clicking one of them. + const chips = [ + { code: '__ALL__', label: 'All', active: !!allMode }, + ...FD_LEAGUE_CODES.map(code => ({ code, label: LEAGUE_LABELS[code], active: !allMode && activeCodes.includes(code) })), + { code: '__CUPS__', label: 'Other Cup', active: !!cupsEnabled } + ]; + container.innerHTML = chips.map(c => ` + `).join(''); container.querySelectorAll('.league-chip').forEach(chip => { - chip.addEventListener('click', () => onToggle(chip.dataset.league)); + chip.addEventListener('click', () => { + const code = chip.dataset.league; + if (code === '__ALL__') selectAllLeagues(); + else if (code === '__CUPS__') toggleCups(); + else toggleLeague(code); + }); }); } -async function toggleLeague(code) { - const next = footballState.leagues.includes(code) - ? footballState.leagues.filter(c => c !== code) - : [...footballState.leagues, code]; - if (next.length === 0) return; - footballState.leagues = next; +async function saveLeagues() { + // Optimistic UI: chips reflect the new state immediately, even if the IPC + // call is slow or fails (error only logged; state re-syncs on next load). + renderLeagueChips(leagueChipsEl, footballState.leagues, footballState.cupsEnabled, footballState.allMode); + renderFixtures(); try { - await window.api.setFootballLeagues(next); + await window.api.setFootballLeagues({ + all: footballState.allMode, + leagues: footballState.leagues, + cupsEnabled: footballState.cupsEnabled + }); } catch (error) { console.error('Failed to save leagues:', error); } - renderLeagueChips(leagueChipsEl, next, toggleLeague); - renderFixtures(); +} + +function selectAllLeagues() { + footballState.allMode = true; + saveLeagues(); +} + +async function toggleCups() { + footballState.cupsEnabled = !footballState.cupsEnabled; + await saveLeagues(); +} + +async function toggleLeague(code) { + // Clicking a league chip exits All mode and selects that chip; clicking a + // selected chip deselects it (multi-select across clicks). + footballState.allMode = false; + if (footballState.leagues.includes(code)) { + footballState.leagues = footballState.leagues.filter(c => c !== code); + } else { + footballState.leagues = [...footballState.leagues, code]; + } + await saveLeagues(); +} + +function renderFavLeagueChips(container, activeCode) { + container.innerHTML = FD_LEAGUE_CODES.map(code => ` + + `).join(''); + container.querySelectorAll('.league-chip').forEach(chip => { + chip.addEventListener('click', () => setFavSearchLeague(chip.dataset.league)); + }); } function setFavSearchLeague(code) { favSearchLeague = code; - renderLeagueChips(favLeagueChipsEl, [code], setFavSearchLeague); + renderFavLeagueChips(favLeagueChipsEl, code); if (teamSearchInput.value.trim()) runTeamSearch(); } function updateFootballBanner() { - if (!footballState.apiKey && fixturesCache.length === 0) { - footballBannerEl.innerHTML = `${SVG_ICONS.key}Add your free key from football-data.org to unlock the full fixture schedule. Favoriting teams already works without one!`; + if (fixturesCache.length === 0) { + footballBannerEl.innerHTML = `${SVG_ICONS.key}Fixtures load automatically from ESPN — favorite teams above (⭐ Favorites) and press 🔄 Refresh.`; footballBannerEl.style.display = 'flex'; } else { footballBannerEl.style.display = 'none'; @@ -1171,64 +1254,99 @@ function monogramHtml(name) { } function renderFixtures() { - const now = Date.now(); + try { + renderFixturesInner(); + } catch (error) { + console.error('Failed to render fixtures:', error); + fixturesListEl.innerHTML = '
Failed to render fixtures — check the console.
'; + } +} + +function renderFixturesInner() { + const targetDate = getOffsetDate(selectedDateOffset); + const targetDateString = targetDate.toDateString(); + let list = fixturesCache.filter(f => { - if (activeFixtureFilter === 'favorites') return isFavoriteSide(f); + if (activeFixtureFilter === 'favorites') return f.watched || isFavoriteSide(f); if (activeFixtureFilter === 'live') return f.status === 'live'; if (activeFixtureFilter === 'finished') return f.status === 'finished'; return true; }); - // Favorites' and pinned fixtures always shown; others must pass league filter (Q10) + // All mode shows everything; otherwise watched fixtures (favorite team + // playing or pinned, matched server-side via cross-provider club identity) + // always pass and other fixtures must match an enabled league chip (Q10). list = list.filter(f => - isFavoriteSide(f) || isPinned(f) || footballState.leagues.includes(f.competition.code) + footballState.allMode || + f.watched || + isPinned(f) || + footballState.leagues.includes(f.competition.code) ); - if (list.length === 0) { - fixturesListEl.innerHTML = '
No fixtures yet. Add your free API key and hit Refresh!
'; + // Filter fixtures specifically for the selected date + const dayFixtures = list + .filter(f => f.kickoffUtc && new Date(f.kickoffUtc).toDateString() === targetDateString) + .sort((a, b) => Date.parse(a.kickoffUtc || 0) - Date.parse(b.kickoffUtc || 0)); + + // Update date navigator bar + if (fbDateNav) { + fbDateNav.classList.toggle('is-today', selectedDateOffset === 0); + } + if (fbDateTitle) { + fbDateTitle.textContent = formatDateNavTitle(targetDate, selectedDateOffset); + } + if (fbDateBadge) { + fbDateBadge.textContent = `${dayFixtures.length} match${dayFixtures.length === 1 ? '' : 'es'}`; + } + if (fbTodayQuickBtn) { + fbTodayQuickBtn.style.display = selectedDateOffset !== 0 ? 'inline-block' : 'none'; + } + + if (dayFixtures.length === 0) { + fixturesListEl.innerHTML = ` +
+ No matches scheduled for ${escapeHtml(formatDateNavTitle(targetDate, selectedDateOffset))}. + ${selectedDateOffset !== 0 ? '
' : ''} +
+ `; + const jumpBtn = document.getElementById('jumpTodayEmptyBtn'); + if (jumpBtn) { + jumpBtn.addEventListener('click', resetToToday); + } return; } - const byDay = new Map(); - list - .sort((a, b) => Date.parse(a.kickoffUtc || 0) - Date.parse(b.kickoffUtc || 0)) - .forEach(f => { - const d = new Date(f.kickoffUtc); - const key = d.toDateString(); - if (!byDay.has(key)) byDay.set(key, []); - byDay.get(key).push(f); - }); + let html = dayFixtures.map(f => { + const live = f.status === 'live'; + const finished = f.status === 'finished'; + + const homeHtml = (live || finished) + ? `${escapeHtml(f.homeTeam.name)} ${crestHtml(f.homeTeam)} ${f.score.home ?? 0}` + : `${escapeHtml(f.homeTeam.name)} ${crestHtml(f.homeTeam)}`; + const awayHtml = (live || finished) + ? `${f.score.away ?? 0} ${crestHtml(f.awayTeam)} ${escapeHtml(f.awayTeam.name)}` + : `${crestHtml(f.awayTeam)} ${escapeHtml(f.awayTeam.name)}`; + + const center = finished + ? `${f.score.home ?? '-'} : ${f.score.away ?? '-'}` + : live + ? `${escapeHtml(f.minute || 'LIVE')}` + : `${new Date(f.kickoffUtc).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; + return ` +
+
+ ${homeHtml} + ${center} + ${awayHtml} +
+ ${f.bigMatch ? SVG_ICONS.flame : ''} + ${(LEAGUE_LABELS[f.competition.code] || f.competition.code) ? `${escapeHtml(LEAGUE_LABELS[f.competition.code] || f.competition.code)}` : ''} + +
`; + }).join(''); - let html = ''; - for (const [dayKey, dayFixtures] of byDay) { - const date = new Date(dayKey); - const label = date.toDateString() === new Date().toDateString() ? 'Today' : - date.toDateString() === new Date(now + 86400000).toDateString() ? 'Tomorrow' : - date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); - html += `
${label}
`; - html += dayFixtures.map(f => { - const live = f.status === 'live'; - const finished = f.status === 'finished'; - const center = finished - ? `${f.score.home ?? '-'} : ${f.score.away ?? '-'}` - : live - ? `${escapeHtml(f.minute || 'LIVE')}` - : `${new Date(f.kickoffUtc).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; - return ` -
-
- ${escapeHtml(f.homeTeam.name)} ${crestHtml(f.homeTeam)} - ${center} - ${crestHtml(f.awayTeam)} ${escapeHtml(f.awayTeam.name)} -
- ${f.bigMatch ? SVG_ICONS.flame : ''} - ${escapeHtml(LEAGUE_LABELS[f.competition.code] || f.competition.code)} - -
`; - }).join(''); - } fixturesListEl.innerHTML = html; wireCrestFallbacks(fixturesListEl); @@ -1256,16 +1374,44 @@ function renderPinnedFixtures() { pinnedFixturesListEl.innerHTML = '
No pinned fixtures yet. Star a match in the 📅 Matches tab!
'; return; } - pinnedFixturesListEl.innerHTML = footballState.pinnedFixtures.map(f => ` -
+ pinnedFixturesListEl.innerHTML = footballState.pinnedFixtures.map(pf => { + const cached = fixturesCache.find(c => + c.id === pf.id || + (c.homeTeam && pf.homeTeam && c.homeTeam.name.toLowerCase() === pf.homeTeam.name.toLowerCase() && + c.awayTeam && pf.awayTeam && c.awayTeam.name.toLowerCase() === pf.awayTeam.name.toLowerCase()) + ); + const kickoff = pf.kickoffUtc || (cached && cached.kickoffUtc); + const status = (cached && cached.status) || pf.status || 'scheduled'; + const minute = (cached && cached.minute != null) ? cached.minute : pf.minute; + const score = (cached && cached.score) ? cached.score : (pf.score || { home: null, away: null }); + + const live = status === 'live'; + const finished = status === 'finished'; + + const homeHtml = (live || finished) + ? `${escapeHtml(pf.homeTeam.name)} ${crestHtml(pf.homeTeam)} ${score.home ?? 0}` + : `${escapeHtml(pf.homeTeam.name)} ${crestHtml(pf.homeTeam)}`; + const awayHtml = (live || finished) + ? `${score.away ?? 0} ${crestHtml(pf.awayTeam)} ${escapeHtml(pf.awayTeam.name)}` + : `${crestHtml(pf.awayTeam)} ${escapeHtml(pf.awayTeam.name)}`; + + const centerHtml = live + ? `${escapeHtml(minute || 'LIVE')}` + : finished + ? `${score.home ?? '-'} : ${score.away ?? '-'}` + : `${kickoff ? new Date(kickoff).toLocaleString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' }) : 'TBD'}`; + + return ` +
- ${escapeHtml(f.homeTeam.name)} - ${new Date(f.kickoffUtc).toLocaleString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' })} - ${escapeHtml(f.awayTeam.name)} + ${homeHtml} + ${centerHtml} + ${awayHtml}
- -
- `).join(''); + +
`; + }).join(''); + pinnedFixturesListEl.querySelectorAll('[data-unpin-id]').forEach(btn => { btn.addEventListener('click', async () => { const fixture = footballState.pinnedFixtures.find(f => f.id === btn.dataset.unpinId); @@ -1391,26 +1537,6 @@ document.querySelectorAll('.fixture-pill').forEach(pill => { }); }); -saveFootballKeyBtn.addEventListener('click', async () => { - const key = footballApiKeyInput.value.trim(); - const showFbResult = (msg) => { - if (footballKeyResult) { - footballKeyResult.textContent = msg; - footballKeyResult.style.display = 'block'; - } - }; - try { - await window.api.saveSettings({ footballApiKey: key }); - footballState.apiKey = key; - teamSearchHelpEl.textContent = key - ? 'API key active — full schedule unlocked.' - : 'No API key: searching bundled top clubs.'; - showFbResult('✅ Football API key saved! Hit 🔄 Refresh in the Matches tab.'); - } catch (error) { - showFbResult(`❌ Failed to save key: ${error.message}`); - } -}); - reminderMinutesInput.addEventListener('change', async () => { const minutes = Math.min(120, Math.max(5, parseInt(reminderMinutesInput.value) || 15)); reminderMinutesInput.value = minutes; @@ -1431,12 +1557,64 @@ liveBoostCheckbox.addEventListener('change', async () => { if (typeof window.api.onFootballStatusUpdated === 'function') { window.api.onFootballStatusUpdated(() => { - // Light refresh when the monitor pushes new state while tab is open - if (document.getElementById('tab-football').classList.contains('active')) { - window.api.getFootballSchedule().then(sched => { - fixturesCache = (sched && sched.fixtures) || []; - renderFixtures(); - }).catch(err => console.error('Football schedule refresh failed:', err)); + // Always keep the cache warm; rendering a hidden list is harmless. + window.api.getFootballSchedule().then(sched => { + fixturesCache = (sched && sched.fixtures) || []; + renderFixtures(); + renderPinnedFixtures(); + }).catch(err => console.error('Football schedule refresh failed:', err)); + }); +} + +if (fbPrevDayBtn) { + fbPrevDayBtn.addEventListener('click', () => changeDateOffset(-1)); +} + +if (fbNextDayBtn) { + fbNextDayBtn.addEventListener('click', () => changeDateOffset(1)); +} + +if (fbDateCenter) { + fbDateCenter.addEventListener('click', () => resetToToday()); + fbDateCenter.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + resetToToday(); + } + }); +} + +if (fbTodayQuickBtn) { + fbTodayQuickBtn.addEventListener('click', (e) => { + e.stopPropagation(); + resetToToday(); + }); +} + +// Keyboard shortcuts (Left/Right arrow) when on the Matches subtab +document.addEventListener('keydown', (e) => { + const activeTabBtn = document.querySelector('.tab-btn.active'); + const activeTab = activeTabBtn ? activeTabBtn.dataset.tab : null; + const activeSubtabBtn = document.querySelector('#tab-football .sub-tab-btn.active'); + const activeSubtab = activeSubtabBtn ? activeSubtabBtn.dataset.subtab : null; + + if (activeTab !== 'football' || activeSubtab !== 'fb-matches') return; + if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return; + + if (e.key === 'ArrowLeft') { + e.preventDefault(); + changeDateOffset(-1); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + changeDateOffset(1); + } +}); + +if (window.api && typeof window.api.onSelectTab === 'function') { + window.api.onSelectTab((tabName) => { + const targetBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`); + if (targetBtn) { + targetBtn.click(); } }); } diff --git a/src/renderer/settings/styles.css b/src/renderer/settings/styles.css index 8ee003f..a7b6a46 100644 --- a/src/renderer/settings/styles.css +++ b/src/renderer/settings/styles.css @@ -1596,11 +1596,351 @@ body { border-color: transparent; } +.fb-date-nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 12px; + margin-bottom: 8px; + padding: 8px 12px; + background: var(--header-bg); + border: 1.5px solid var(--header-border); + border-radius: 14px; + user-select: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.2); + transition: all 200ms ease; +} + +.fb-date-nav:hover { + border-color: var(--input-focus-border); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.10); +} + +.fb-nav-btn { + width: 34px; + height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + border: 1.5px solid var(--header-border); + background: var(--section-bg); + color: var(--title-color); + cursor: pointer; + flex-shrink: 0; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.08); + transition: all 180ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.fb-nav-btn:hover { + background: var(--btn-primary-bg); + color: var(--btn-primary-text); + border-color: transparent; + transform: scale(1.08); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); +} + +.fb-nav-btn:active { + transform: scale(0.92); +} + +.fb-nav-btn:focus-visible { + outline: 2px solid var(--input-focus-border); + outline-offset: 2px; +} + +.fb-date-center { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + flex: 1; + min-width: 0; + cursor: pointer; + padding: 6px 14px; + background: var(--section-bg); + border: 1px solid var(--section-border); + font-size: 13px; + font-weight: 700; + color: var(--title-color); + margin: 0; +} + +.guide-card-subtitle { + font-size: 11.5px; + color: var(--text-muted); + margin-bottom: 10px; + line-height: 1.4; +} + +.guide-free-badge { + display: inline-block; + background: #e8f5e9; + color: #2e7d32; + font-size: 11px; + font-weight: 600; + padding: 3px 8px; + border-radius: 6px; + margin-bottom: 8px; + border: 1px solid #c8e6c9; +} + +.guide-steps { + display: flex; + flex-direction: column; + gap: 8px; +} + +.guide-step { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 12px; + color: var(--text-label); + line-height: 1.4; +} + +.step-badge { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--control-btn-bg); + color: var(--title-color); + font-size: 10px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 1px; + border: 1px solid var(--section-border); +} + +.step-desc { + flex: 1; +} + +.step-desc code { + background: var(--input-bg); + padding: 1px 4px; + border-radius: 4px; + font-size: 11px; + color: var(--text-accent); + border: 1px solid var(--section-border); +} + +.guide-tips-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.guide-tip-item { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 11.5px; + color: var(--text-label); + line-height: 1.4; + background: var(--input-bg); + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--section-border); +} + +.guide-tip-item code { + background: var(--section-bg); + padding: 1px 4px; + border-radius: 4px; + font-size: 11px; + color: var(--text-accent); + border: 1px solid var(--section-border); +} + +.tip-icon { + font-size: 15px; + line-height: 1; +} +/* ================= Football Tab (plan §3.1) ================= */ + +.league-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.league-chip { + cursor: pointer; + border: 1px solid var(--pill-border); + background: var(--pill-bg); + color: var(--pill-text); + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + transition: background-color 200ms ease, color 200ms ease, border-color 200ms ease; +} + +.league-chip:hover { + border-color: var(--input-focus-border); +} + +.league-chip.active { + background: var(--pill-active-bg); + color: var(--pill-active-text); + border-color: transparent; +} + +.fb-date-nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 12px; + margin-bottom: 8px; + padding: 8px 12px; + background: var(--header-bg); + border: 1.5px solid var(--header-border); + border-radius: 14px; + user-select: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.2); + transition: all 200ms ease; +} + +.fb-date-nav:hover { + border-color: var(--input-focus-border); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.10); +} + +.fb-nav-btn { + width: 34px; + height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + border: 1.5px solid var(--header-border); + background: var(--section-bg); + color: var(--title-color); + cursor: pointer; + flex-shrink: 0; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.08); + transition: all 180ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.fb-nav-btn:hover { + background: var(--btn-primary-bg); + color: var(--btn-primary-text); + border-color: transparent; + transform: scale(1.08); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); +} + +.fb-nav-btn:active { + transform: scale(0.92); +} + +.fb-nav-btn:focus-visible { + outline: 2px solid var(--input-focus-border); + outline-offset: 2px; +} + +.fb-date-center { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + flex: 1; + min-width: 0; + cursor: pointer; + padding: 6px 14px; + background: var(--section-bg); + border: 1px solid var(--section-border); + border-radius: 999px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); + transition: all 180ms ease; +} + +.fb-date-center:hover { + border-color: var(--input-focus-border); + transform: translateY(-1px); + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.08); +} + +.fb-date-center:active { + transform: scale(0.98); +} + +.fb-date-center:focus-visible { + outline: 2px solid var(--input-focus-border); + outline-offset: 1px; +} + +.fb-date-primary { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + justify-content: center; +} + +.fb-date-icon { + font-size: 15px; +} + +.fb-date-title { + font-size: 13.5px; + font-weight: 800; + color: var(--title-color); + letter-spacing: -0.2px; + transition: color 180ms ease; +} + +.fb-date-badge { + font-size: 11px; + font-weight: 700; + padding: 2px 8px; + border-radius: 999px; + background: var(--subtab-bg); + border: 1px solid var(--header-border); + color: var(--subtitle-color); + white-space: nowrap; + transition: all 180ms ease; +} + +.fb-today-quick-btn { + font-size: 11px; + font-weight: 700; + padding: 3px 10px; + border-radius: 999px; + background: var(--btn-primary-bg); + color: var(--btn-primary-text); + border: none; + cursor: pointer; + white-space: nowrap; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18); + animation: pulse-soft 2s infinite ease-in-out; + transition: opacity 180ms ease, transform 150ms ease; +} + +.fb-today-quick-btn:hover { + transform: scale(1.06); +} + +.fb-today-quick-btn:active { + transform: scale(0.95); +} + +@keyframes pulse-soft { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.85; } +} + .fixtures-list { display: flex; flex-direction: column; gap: 8px; - margin-top: 10px; + margin-top: 6px; } .fb-date-header { @@ -1682,6 +2022,14 @@ body { text-align: center; } +.score-val { + font-family: 'Consolas', 'Courier New', monospace; + font-weight: 700; + font-size: 14px; + color: var(--fb-live-accent); + padding: 0 4px; +} + .comp-chip { font-size: 10px; font-weight: 700; @@ -1848,3 +2196,27 @@ body { } .history-badge svg { width: 11px; height: 11px; } + +/* Chips/pills separation + borderless filter pills */ +.chips-divider { + border-top: 1px solid var(--section-border); + opacity: 0.6; +} + +.fixture-pill { + border: none; + background: transparent; + color: var(--text-muted); + font-weight: 600; + letter-spacing: 0.3px; +} + +.fixture-pill:hover { + color: var(--text-accent); + background: var(--fb-pitch-soft); +} + +.fixture-pill.active { + background: var(--pill-active-bg); + color: var(--pill-active-text); +} diff --git a/tests/clickStreamAndFocusFix.test.js b/tests/clickStreamAndFocusFix.test.js index 18014e7..17a15bb 100644 --- a/tests/clickStreamAndFocusFix.test.js +++ b/tests/clickStreamAndFocusFix.test.js @@ -37,4 +37,33 @@ describe('IPC Click Stream Routing & Focus Tests', () => { assert.equal(getControllerImageClassList(false, 'https://i.ytimg.com/thumb.jpg'), 'controller-image has-img'); assert.equal(getControllerImageClassList(false, null), 'controller-image'); }); + it('should route football notifications to app football tab instead of youtube URL', () => { + function routeClickNotification(targetVideoId, popupType) { + let videoId = targetVideoId; + let type = popupType; + + if (typeof targetVideoId === 'object' && targetVideoId !== null) { + videoId = targetVideoId.videoId; + type = targetVideoId.type || type; + } + + if (type === 'football' || videoId === 'football') { + return { action: 'open_tab', tab: 'football' }; + } + + return { action: 'open_youtube', url: `https://www.youtube.com/watch?v=${videoId}` }; + } + + // Football notification click (targetVideoId: null, popupType: 'football') + const result1 = routeClickNotification(null, 'football'); + assert.deepEqual(result1, { action: 'open_tab', tab: 'football' }); + + // Football notification click with object format ({ type: 'football' }) + const result2 = routeClickNotification({ type: 'football' }); + assert.deepEqual(result2, { action: 'open_tab', tab: 'football' }); + + // Regular YouTube stream click + const result3 = routeClickNotification('stream123', null); + assert.deepEqual(result3, { action: 'open_youtube', url: 'https://www.youtube.com/watch?v=stream123' }); + }); }); diff --git a/tests/football.test.js b/tests/football.test.js index bfa554d..d1bfc6d 100644 --- a/tests/football.test.js +++ b/tests/football.test.js @@ -2,6 +2,7 @@ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); const { LEAGUES, + EXTRA_LEAGUES, createFdProvider, createEspnProvider } = require('../src/main/football'); @@ -10,6 +11,23 @@ function headersLike(map) { return { get: (name) => map[String(name).toLowerCase()] ?? null }; } +function espnEventJson(id, state, opts = {}) { + return { + id, + date: opts.date || '2026-08-22T18:30Z', + status: { + displayClock: opts.minute || null, + type: { state, completed: state === 'post' } + }, + competitions: [{ + competitors: [ + { homeAway: 'home', score: opts.home != null ? String(opts.home) : null, team: { displayName: opts.homeName || 'Bayern Munich' } }, + { homeAway: 'away', score: opts.away != null ? String(opts.away) : null, team: { displayName: opts.awayName || 'Borussia Dortmund' } } + ] + }] + }; +} + describe('Football Providers Tests', () => { describe('League registry', () => { it('covers all 12 free competitions with espn slugs', () => { @@ -68,6 +86,103 @@ describe('Football Providers Tests', () => { assert.equal(calls, 2); assert.ok(delays.some(d => d >= 60000), 'default bounded backoff of ~60s applied when no reset header'); }); + }); + + describe('ESPN extra competitions (big-5 cups, Phase 1)', () => { + it('registers the verified big-5 cup slugs without colliding with fd.org codes', () => { + const fdCodes = new Set(LEAGUES.map(l => l.code)); + const expected = [ + ['ENG-FA', 'eng.fa'], + ['ENG-LC', 'eng.league_cup'], + ['ESP-CDR', 'esp.copa_del_rey'], + ['ITA-CI', 'ita.coppa_italia'], + ['GER-PK', 'ger.dfb_pokal'], + ['GER-SC', 'ger.super_cup'], + ['FRA-TC', 'fra.super_cup'] + ]; + assert.ok(EXTRA_LEAGUES.length >= expected.length); + for (const [code, slug] of expected) { + const entry = EXTRA_LEAGUES.find(l => l.code === code); + assert.ok(entry, `missing extra league ${code}`); + assert.equal(entry.espnSlug, slug); + assert.equal(entry.source, 'espn'); + assert.ok(!fdCodes.has(code), `${code} must not collide with fd.org codes`); + } + }); + + it('fetchFixtures maps scoreboard events (incl. scheduled) to fixture shape', async () => { + let captured; + const fetchFn = async (url) => { + captured = url; + return { + ok: true, status: 200, + json: async () => ({ + leagues: [{ name: 'German Supercup' }], + events: [ + espnEventJson('e1', 'pre', { date: '2026-08-22T18:30Z' }), + espnEventJson('e2', 'in', { minute: "12'", home: 1, away: 0 }) + ] + }) + }; + }; + const provider = createEspnProvider({ fetchFn }); + const fixtures = await provider.fetchFixtures('GER-SC', '2026-08-21', '2026-08-28'); + + assert.ok(captured.includes('soccer/ger.super_cup/scoreboard')); + assert.ok(captured.includes('dates=20260821-20260828'), `expected ESPN date range in ${captured}`); + assert.equal(fixtures.length, 2); + const scheduled = fixtures[0]; + assert.equal(scheduled.status, 'scheduled'); + assert.equal(scheduled.homeTeam.name, 'Bayern Munich'); + assert.equal(scheduled.competition.code, 'GER-SC'); + assert.equal(fixtures[1].status, 'live'); + assert.equal(fixtures[1].score.home, 1); + }); + + it('fetchTeamSchedule uses fixture=true and maps team fixtures across cups', async () => { + let captured; + const fetchFn = async (url) => { + captured = url; + return { + ok: true, status: 200, + json: async () => ({ + team: { displayName: 'Bayern Munich' }, + events: [espnEventJson('supercup-e1', 'in', { minute: "34'", home: 0, away: 1 })] + }) + }; + }; + const provider = createEspnProvider({ fetchFn }); + const fixtures = await provider.fetchTeamSchedule('ger.1', '132'); + + assert.ok(captured.includes('/teams/132/schedule?fixture=true'), `unexpected URL ${captured}`); + assert.equal(fixtures.length, 1); + assert.equal(fixtures[0].status, 'live'); + assert.equal(fixtures[0].awayTeam.name, 'Borussia Dortmund'); + }); + + it('fetchTeamDirectory resolves ESPN team ids from the big-5 league teams list', async () => { + let captured; + const fetchFn = async (url) => { + captured = url; + return { + ok: true, status: 200, + json: async () => ({ + sports: [{ leagues: [{ teams: [ + { team: { id: '598', displayName: '1. FC Union Berlin' } }, + { team: { id: '132', displayName: 'Bayern Munich' } } + ] }] }] + }) + }; + }; + const provider = createEspnProvider({ fetchFn }); + const directory = await provider.fetchTeamDirectory('ger.1'); + + assert.ok(captured.includes('soccer/ger.1/teams')); + assert.deepEqual(directory, [ + { id: '598', name: '1. FC Union Berlin', espnSlug: 'ger.1' }, + { id: '132', name: 'Bayern Munich', espnSlug: 'ger.1' } + ]); + }); it('requires an api key at construction', () => { assert.throws(() => createFdProvider({ fetchFn: async () => ({}) })); @@ -135,6 +250,38 @@ describe('Football Providers Tests', () => { const provider = createFdProvider({ apiKey: 'k', fetchFn, sleepFn: () => Promise.resolve() }); await assert.rejects(() => provider.fetchSchedule('PL', 'a', 'b'), /403/); }); + + it('fetches a favorite team fixtures across all its competitions', async () => { + let captured; + const fetchFn = async (url, opts) => { + captured = { url, opts }; + return { + ok: true, status: 200, + headers: null, + json: async () => ({ + matches: [{ + id: 900001, + utcDate: '2026-08-22T19:00:00Z', + status: 'IN_PLAY', + minute: "34'", + homeTeam: { id: 529, name: 'Barcelona', crest: '' }, + awayTeam: { id: 5013, name: 'Chelsea', crest: '' }, + competition: { code: 'CL', name: 'Champions League' }, + score: { fullTime: { home: 1, away: 0 } } + }] + }) + }; + }; + const provider = createFdProvider({ apiKey: 'k', fetchFn, sleepFn: () => Promise.resolve() }); + const fixtures = await provider.fetchTeamFixtures('529', '2026-08-21', '2026-08-28'); + + assert.ok(captured.url.includes('/v4/teams/529/matches')); + assert.ok(captured.url.includes('dateFrom=2026-08-21')); + assert.equal(captured.opts.headers['X-Auth-Token'], 'k'); + assert.equal(fixtures.length, 1); + assert.equal(fixtures[0].status, 'live'); + assert.equal(fixtures[0].competition.code, 'CL'); + }); }); describe('ESPN Live Boost provider', () => { diff --git a/tests/footballManager.test.js b/tests/footballManager.test.js index 64b0acf..7c58730 100644 --- a/tests/footballManager.test.js +++ b/tests/footballManager.test.js @@ -10,6 +10,7 @@ const { filterScheduleFixtures, isBigMatch, monogram, + namesMatch, loadBigClubs, searchBigClubs } = require('../src/main/footballManager'); @@ -176,6 +177,55 @@ describe('Football Manager Tests', () => { }); }); + describe('Cross-provider club name matching', () => { + it('matches the same club spelled differently across providers', () => { + const positives = [ + ['FC Bayern München', 'Bayern Munich'], + ['Bayern München', 'Bayern Munich'], + ['Real Madrid CF', 'Real Madrid'], + ['RCD Espanyol de Barcelona', 'Espanyol'], + ['Espanyol de Barcelona', 'RCD Espanyol'], + ['Athletic Club', 'Athletic Bilbao'], + ['Wolverhampton Wanderers', 'Wolves'], + ['Internazionale', 'Inter Milan'], + ['Paris Saint-Germain', 'PSG'], + ['Bayer 04 Leverkusen', 'Bayer Leverkusen'] + ]; + for (const [a, b] of positives) { + assert.equal(namesMatch(a, b), true, `expected match: ${a} ~ ${b}`); + } + }); + + it('never matches different clubs that share city tokens', () => { + const negatives = [ + ['FC Barcelona', 'RCD Espanyol de Barcelona'], + ['FC Barcelona', 'Espanyol'], + ['Real Madrid', 'Atlético Madrid'], + ['Arsenal', 'Chelsea'], + ['Bayern Munich', 'Borussia Dortmund'] + ]; + for (const [a, b] of negatives) { + assert.equal(namesMatch(a, b), false, `expected NO match: ${a} !~ ${b}`); + } + }); + + it('is symmetric and null-safe', () => { + assert.equal(namesMatch('Espanyol', 'RCD Espanyol de Barcelona'), true); + assert.equal(namesMatch('', ''), false); + assert.equal(namesMatch(null, 'Bayern Munich'), false); + }); + + it('watched-fixture rule works across provider spellings', () => { + const fdStyle = normalizeFixture({ + id: 1, utcDate: '2026-08-22T16:30:00Z', status: 'IN_PLAY', + homeTeam: { name: 'FC Bayern München' }, awayTeam: { name: 'Borussia Dortmund' }, + competition: { code: 'GER-SC' } + }); + const keylessFav = [{ id: '', name: 'Bayern Munich', crest: '', competitionCode: 'BL1' }]; + assert.equal(isWatchedFixture(fdStyle, keylessFav, []), true); + }); + }); + describe('Schedule filtering & Big Match', () => { const mk = (id, code, home, away) => ({ id, kickoffUtc: '2026-08-22T16:30:00Z', status: 'scheduled', diff --git a/tests/footballMonitor.test.js b/tests/footballMonitor.test.js index f97f5e7..4298607 100644 --- a/tests/footballMonitor.test.js +++ b/tests/footballMonitor.test.js @@ -134,4 +134,249 @@ describe('Football Monitor Tests', () => { await mon.checkWatch(); // must not throw assert.ok(true); }); + + it('injects keyless favorite matches from ESPN into the schedule cache (cross-competition)', async () => { + const store = memoryStore(); // no cached fixtures at all + const espn = { + fetchLiveState: async () => [ + { eventId: 'ucl1', kickoffUtc: new Date(KICKOFF).toISOString(), homeName: 'Barcelona', awayName: 'Bayern Munich', state: 'in', completed: false, minute: "34'", scoreHome: 1, scoreAway: 0 } + ] + }; + const mon = createFootballMonitor({ + store, + favoriteTeams: [ + { id: '', name: 'Barcelona', crest: '', competitionCode: 'PD' }, + { id: '', name: 'Bayern Munich', crest: '', competitionCode: 'BL1' } + ], + pinnedFixtures: [], leagues: ['BL1'], reminderMinutes: 15, liveBoost: true, + espnProvider: espn, fdProvider: null, + nowFn: () => KICKOFF + 30 * 60000, + onFixtureEvent: () => {}, + onError: () => {} + }); + + await mon.checkWatch(); + const cache = store.get('footballFixtures', []); + assert.equal(cache.length, 1); + assert.equal(cache[0].homeTeam.name, 'Barcelona'); + assert.equal(cache[0].awayTeam.name, 'Bayern Munich'); + assert.equal(cache[0].status, 'live'); + assert.equal(cache[0].competition.code, ''); // slug unknown without registry hit — acceptable v1 + assert.equal(cache[0].score.home, 1); + }); + + it('sweep merges favorite-team fixtures fetched per team id', async () => { + const store = memoryStore({ footballFavoriteTeams: [{ id: '529', name: 'Barcelona', crest: '', competitionCode: 'PD' }] }); + let calls = []; + const fd = { + fetchSchedule: async (code) => { calls.push(`league:${code}`); return []; }, + fetchTeamFixtures: async (teamId) => { calls.push(`team:${teamId}`); return [makeFixture('tfx', { status: 'scheduled' })]; } + }; + const mon = createFootballMonitor({ + store, + favoriteTeams: [{ id: '529', name: 'Barcelona', crest: '', competitionCode: 'PD' }], + pinnedFixtures: [], leagues: ['PL'], reminderMinutes: 15, liveBoost: false, + espnProvider: null, fdProvider: fd, + nowFn: () => KICKOFF, paceMs: 0, + onFixtureEvent: () => {}, onError: () => {} + }); + await mon.checkSweep(); + assert.ok(calls.includes('league:PL')); + assert.ok(calls.includes('team:529')); + const cache = store.get('footballFixtures', []); + assert.equal(cache.length, 1); + assert.equal(cache[0].id, 'tfx'); + }); + + it('sweep pulls big-5 cup fixtures from ESPN extras (Phase 1)', async () => { + const store = memoryStore(); + const espn = { + fetchLiveState: async () => [], + fetchFixtures: async (code) => { + assert.equal(code, 'GER-SC'); + return [{ + id: 'espn:sc1', + kickoffUtc: new Date(KICKOFF).toISOString(), + status: 'scheduled', + minute: null, + homeTeam: { id: '', name: 'Bayern Munich', crest: '' }, + awayTeam: { id: '', name: 'Borussia Dortmund', crest: '' }, + competition: { code: 'GER-SC', name: 'German Supercup' }, + score: { home: null, away: null } + }]; + } + }; + const mon = createFootballMonitor({ + store, + favoriteTeams: [], pinnedFixtures: [], leagues: ['PL'], reminderMinutes: 15, liveBoost: true, + espnProvider: espn, fdProvider: null, + nowFn: () => KICKOFF, paceMs: 0, + onFixtureEvent: () => {}, onError: () => {} + }); + await mon.checkSweep(); + const cache = store.get('footballFixtures', []); + assert.equal(cache.length, 1); + assert.equal(cache[0].competition.code, 'GER-SC'); + }); + + it('cupsEnabled=false skips the extras sweep entirely', async () => { + const store = memoryStore(); + let called = 0; + const espn = { + fetchLiveState: async () => [], + fetchFixtures: async () => { called++; return []; } + }; + const mon = createFootballMonitor({ + store, + favoriteTeams: [], pinnedFixtures: [], leagues: ['PL'], reminderMinutes: 15, liveBoost: true, + cupsEnabled: false, + espnProvider: espn, fdProvider: null, + nowFn: () => Date.now(), paceMs: 0, + onFixtureEvent: () => {}, onError: () => {} + }); + await mon.checkSweep(); + assert.equal(called, 0); + }); + + it('watcher polls watched cup competition scoreboards too (live cup matches update)', async () => { + const store = memoryStore({ + footballFixtures: [makeFixture('cupfx', { + competition: { code: 'GER-SC', name: 'German Supercup' }, + status: 'scheduled', + kickoffUtc: new Date(Date.now() + 10 * 60000).toISOString(), + homeTeam: { id: '', name: 'Bayern Munich', crest: '' }, + awayTeam: { id: '', name: 'Borussia Dortmund', crest: '' } + })] + }); + const requestedCodes = []; + const espn = { + fetchLiveState: async (codes) => { + requestedCodes.push(...codes); + return [{ eventId: 'x', kickoffUtc: new Date(Date.now() + 11 * 60000).toISOString(), competitionCode: 'GER-SC', homeName: 'Bayern Munich', awayName: 'Borussia Dortmund', state: 'in', completed: false, minute: "5'", scoreHome: 0, scoreAway: 0 }]; + } + }; + const mon = createFootballMonitor({ + store, + favoriteTeams: [{ id: '', name: 'Bayern Munich', crest: '', competitionCode: 'BL1' }], + pinnedFixtures: [], leagues: ['PL'], reminderMinutes: 15, liveBoost: true, + espnProvider: espn, fdProvider: null, + nowFn: () => Date.now(), paceMs: 0, + onFixtureEvent: (e) => events.push(e.type), + onError: () => {} + }); + const events = []; + await mon.checkWatch(); + assert.ok(requestedCodes.includes('GER-SC'), `watcher must request watched cup codes, got ${JSON.stringify(requestedCodes)}`); + assert.deepEqual(events, ['kickoff']); + }); + + it('forces a stuck live fixture to finished when its source goes silent past kickoff+3h', async () => { + const store = memoryStore({ + footballFixtures: [makeFixture('stuck1', { + status: 'live', + minute: "45+3'", + homeTeam: { id: '', name: 'Bayern Munich', crest: '' }, + awayTeam: { id: '', name: 'Borussia Dortmund', crest: '' }, + competition: { code: 'GER-SC', name: 'German Supercup' } + })] + }); + const events = []; + const mon = createFootballMonitor({ + store, + favoriteTeams: [{ id: '', name: 'Bayern Munich', crest: '', competitionCode: 'BL1' }], + pinnedFixtures: [], leagues: ['PL'], reminderMinutes: 15, liveBoost: true, + espnProvider: { fetchLiveState: async () => [] }, // silent source + fdProvider: null, + nowFn: () => KICKOFF + 4 * 60 * 60 * 1000, // 4h after kickoff + paceMs: 0, + onFixtureEvent: (e) => events.push(e.type), + onError: () => {} + }); + await mon.checkWatch(); + assert.deepEqual(events, ['fulltime']); + assert.equal(store.get('footballFixtures', [])[0].status, 'finished'); + }); + + it('sweep REPLACES stale pseudo duplicates with the canonical cup fixture', async () => { + const staleKickoff = new Date(KICKOFF).toISOString(); + const store = memoryStore({ + footballFixtures: [{ + id: 'espn:bayern-munich-vs-borussia-dortmund@na', + kickoffUtc: staleKickoff, + status: 'live', + minute: "45+3'", + homeTeam: { id: '', name: 'Bayern Munich', crest: '' }, + awayTeam: { id: '', name: 'Borussia Dortmund', crest: '' }, + competition: { code: '', name: '' }, + score: { home: 0, away: 0 }, + pseudo: true + }] + }); + const espn = { + fetchLiveState: async () => [], + fetchFixtures: async (code) => code === 'GER-SC' ? [{ + id: 'espn:401873679', + kickoffUtc: staleKickoff, + status: 'live', + minute: "76'", + homeTeam: { id: '', name: 'Bayern Munich', crest: '' }, + awayTeam: { id: '', name: 'Borussia Dortmund', crest: '' }, + competition: { code: 'GER-SC', name: 'German Supercup' }, + score: { home: 0, away: 1 } + }] : [] + }; + const mon = createFootballMonitor({ + store, + favoriteTeams: [], pinnedFixtures: [], leagues: ['PL'], reminderMinutes: 15, liveBoost: true, + espnProvider: espn, fdProvider: null, + nowFn: () => KICKOFF, paceMs: 0, + onFixtureEvent: () => {}, onError: () => {} + }); + await mon.checkSweep(); + const cache = store.get('footballFixtures', []); + assert.equal(cache.length, 1); + assert.equal(cache[0].id, 'espn:401873679'); + assert.equal(cache[0].minute, "76'"); + }); + + it('sweep merges per-team ESPN schedules for resolved favorites and skips cross-source duplicates', async () => { + const store = memoryStore({ + footballFixtures: [makeFixture('fd-dup', {})] + }); + const espn = { + fetchLiveState: async () => [], + fetchFixtures: async () => [], + fetchTeamSchedule: async (slug, teamId) => { + assert.equal(slug, 'ger.1'); + assert.equal(teamId, '132'); + // Same real-world match already cached from fd.org (fd-dup), plus a cup match + return [ + { + id: 'espn:dup1', kickoffUtc: new Date(KICKOFF).toISOString(), status: 'scheduled', minute: null, + homeTeam: { id: '', name: 'Arsenal', crest: '' }, awayTeam: { id: '', name: 'Chelsea', crest: '' }, + competition: { code: '', name: '2026 German Bundesliga' }, score: { home: null, away: null } + }, + { + id: 'espn:new1', kickoffUtc: new Date(KICKOFF + 3600000).toISOString(), status: 'scheduled', minute: null, + homeTeam: { id: '', name: 'Bayern Munich', crest: '' }, awayTeam: { id: '', name: 'Borussia Dortmund', crest: '' }, + competition: { code: '', name: '2026 German SuperCup' }, score: { home: null, away: null } + } + ]; + } + }; + const mon = createFootballMonitor({ + store, + favoriteTeams: [{ id: '132', name: 'Bayern Munich', crest: '', espnSlug: 'ger.1', espnTeamId: '132' }], + pinnedFixtures: [], leagues: ['PL'], reminderMinutes: 15, liveBoost: true, + espnProvider: espn, fdProvider: null, + nowFn: () => KICKOFF, paceMs: 0, + onFixtureEvent: () => {}, onError: () => {} + }); + await mon.checkSweep(); + const cache = store.get('footballFixtures', []); + const ids = cache.map(f => f.id); + assert.ok(ids.includes('fd-dup'), 'existing fd fixture preserved'); + assert.ok(ids.includes('espn:new1'), 'cup match added via team schedule'); + assert.ok(!ids.includes('espn:dup1'), 'duplicate of cached fixture skipped'); + }); }); diff --git a/tests/freeYoutube.test.js b/tests/freeYoutube.test.js index 6f96e1a..352317d 100644 --- a/tests/freeYoutube.test.js +++ b/tests/freeYoutube.test.js @@ -146,5 +146,29 @@ describe('YouTube Free (0-Quota) Live Check Engine Tests', () => { } ); }); + + it('should correctly decode HTML entities and fix Mojibake symbols in title and channelTitle', async () => { + const mockHtml = ` + + + + lofi hip hop radio & beats <3'n'study - YouTube + + + + + {"isLiveNow":true,"isLive":true} + + `; + + mock.method(axios, 'get', async () => { + return { data: mockHtml }; + }); + + const result = await checkLiveStatusFree('@lofigirl'); + assert.equal(result.isLive, true); + assert.equal(result.title, 'lofi hip hop radio 📚 & beats 📚'); + assert.equal(result.channelTitle, 'Lofi Girl & Friends'); + }); }); }); diff --git a/tests/liveScoresAndPinnedFormatting.test.js b/tests/liveScoresAndPinnedFormatting.test.js new file mode 100644 index 0000000..765ceae --- /dev/null +++ b/tests/liveScoresAndPinnedFormatting.test.js @@ -0,0 +1,137 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { createFootballMonitor, FIXTURES_KEY } = require('../src/main/footballMonitor'); +const { repairPinnedFixtures } = require('../src/main/footballManager'); + +function memoryStore(initial = {}) { + const data = { ...initial }; + return { + get: (key, def) => (key in data ? data[key] : def), + set: (key, val) => { data[key] = val; }, + _data: data + }; +} + +const KICKOFF = Date.parse('2026-08-26T19:00:00Z'); + +describe('Live Scores & Pinned Formatting Tests (TDD)', () => { + describe('Slice 1: checkWatch updates ALL live games in cache', () => { + it('should update minute and score for unwatched fixtures in cache during checkWatch', async () => { + const store = memoryStore({ + [FIXTURES_KEY]: [ + { + id: 'espn:101', + kickoffUtc: new Date(KICKOFF).toISOString(), + status: 'scheduled', + minute: null, + homeTeam: { id: '', name: 'Unwatched Home', crest: '' }, + awayTeam: { id: '', name: 'Unwatched Away', crest: '' }, + competition: { code: 'PL', name: 'Premier League' }, + score: { home: null, away: null } + } + ] + }); + + const espn = { + fetchLiveState: async (codes) => { + return [ + { + eventId: '101', + kickoffUtc: new Date(KICKOFF).toISOString(), + competitionCode: 'PL', + homeName: 'Unwatched Home', + awayName: 'Unwatched Away', + state: 'in', + completed: false, + minute: "35'", + scoreHome: 2, + scoreAway: 1 + } + ]; + } + }; + + const mon = createFootballMonitor({ + store, + favoriteTeams: [], + pinnedFixtures: [], + leagues: ['PL'], + reminderMinutes: 15, + liveBoost: true, + espnProvider: espn, + fdProvider: null, + nowFn: () => KICKOFF + 30 * 60 * 1000, + paceMs: 0, + onFixtureEvent: () => {}, + onError: () => {} + }); + + await mon.checkWatch(); + + const cache = store.get(FIXTURES_KEY, []); + assert.equal(cache.length, 1); + assert.equal(cache[0].status, 'live'); + assert.equal(cache[0].minute, "35'"); + assert.equal(cache[0].score.home, 2); + assert.equal(cache[0].score.away, 1); + }); + }); + + describe('Slice 2: Auto-repair stale pinned fixtures missing kickoffUtc', () => { + it('should enrich stale pinned fixtures lacking kickoffUtc from schedule cache', () => { + const stalePins = [ + { + id: 'espn:101', + kickoffUtc: null, // stale stored value + status: 'scheduled', + homeTeam: { id: '', name: 'Arsenal', crest: '' }, + awayTeam: { id: '', name: 'Chelsea', crest: '' }, + competition: { code: 'PL', name: 'Premier League' }, + score: { home: null, away: null } + } + ]; + + const schedule = [ + { + id: 'espn:101', + kickoffUtc: '2026-08-26T21:00:00.000Z', + status: 'scheduled', + homeTeam: { id: '', name: 'Arsenal', crest: '' }, + awayTeam: { id: '', name: 'Chelsea', crest: '' }, + competition: { code: 'PL', name: 'Premier League' }, + score: { home: null, away: null } + } + ]; + + const repaired = repairPinnedFixtures(stalePins, schedule); + assert.equal(repaired.length, 1); + assert.equal(repaired[0].kickoffUtc, '2026-08-26T21:00:00.000Z', 'kickoffUtc must be repaired from schedule'); + }); + }); + + describe('Slice 3: Live Pinned Match Formatting', () => { + it('should format live pinned fixture with home team, home score, live minute, away score, away team', () => { + const livePin = { + id: 'espn:102', + kickoffUtc: new Date(KICKOFF).toISOString(), + status: 'live', + minute: "45'", + homeTeam: { id: '', name: 'Arsenal', crest: '' }, + awayTeam: { id: '', name: 'Chelsea', crest: '' }, + competition: { code: 'PL', name: 'Premier League' }, + score: { home: 2, away: 1 } + }; + + // Mock formatting helper reproducing the renderer output structure + const formatPinnedLiveRow = (f) => { + const homeHtml = `${f.homeTeam.name} [logo] ${f.score.home ?? 0}`; + const centerHtml = `${f.minute || 'LIVE'}`; + const awayHtml = `${f.score.away ?? 0} [logo2] ${f.awayTeam.name}`; + return `${homeHtml} | ${centerHtml} | ${awayHtml}`; + }; + + const row = formatPinnedLiveRow(livePin); + assert.equal(row, "Arsenal [logo] 2 | 45' | 1 [logo2] Chelsea"); + }); + }); +}); diff --git a/tests/pinnedFixtureBug.test.js b/tests/pinnedFixtureBug.test.js new file mode 100644 index 0000000..dee7c0c --- /dev/null +++ b/tests/pinnedFixtureBug.test.js @@ -0,0 +1,57 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { normalizeFixture, togglePinnedFixture, isWatchedFixture } = require('../src/main/footballManager'); + +describe('Pinned Fixture Normalization Bug', () => { + it('should preserve kickoffUtc when normalizing an already-normalized or ESPN fixture', () => { + const fixture = { + id: 'espn:12345', + kickoffUtc: '2026-08-26T19:00:00.000Z', + status: 'scheduled', + minute: null, + homeTeam: { id: '', name: 'Arsenal', crest: '' }, + awayTeam: { id: '', name: 'Chelsea', crest: '' }, + competition: { code: 'PL', name: 'Premier League' }, + score: { home: null, away: null } + }; + + const normalized = normalizeFixture(fixture); + assert.equal(normalized.kickoffUtc, '2026-08-26T19:00:00.000Z', 'kickoffUtc must not be wiped to null'); + assert.equal(normalized.status, 'scheduled', 'status must be preserved'); + }); + + it('should preserve live status and score when normalizing an already-normalized fixture', () => { + const fixture = { + id: 'espn:12345', + kickoffUtc: '2026-08-26T19:00:00.000Z', + status: 'live', + minute: '45', + homeTeam: { id: '', name: 'Arsenal', crest: '' }, + awayTeam: { id: '', name: 'Chelsea', crest: '' }, + competition: { code: 'PL', name: 'Premier League' }, + score: { home: 2, away: 1 } + }; + + const normalized = normalizeFixture(fixture); + assert.equal(normalized.kickoffUtc, '2026-08-26T19:00:00.000Z'); + assert.equal(normalized.status, 'live'); + assert.equal(normalized.score.home, 2); + assert.equal(normalized.score.away, 1); + }); + + it('should preserve kickoffUtc when togglePinnedFixture is called with a fixture', () => { + const fixture = { + id: 'espn:12345', + kickoffUtc: '2026-08-26T19:00:00.000Z', + status: 'scheduled', + homeTeam: { id: '', name: 'Arsenal', crest: '' }, + awayTeam: { id: '', name: 'Chelsea', crest: '' }, + competition: { code: 'PL', name: 'Premier League' }, + score: { home: null, away: null } + }; + + const pinnedList = togglePinnedFixture([], fixture); + assert.equal(pinnedList.length, 1); + assert.equal(pinnedList[0].kickoffUtc, '2026-08-26T19:00:00.000Z', 'pinned fixture kickoffUtc must not be null'); + }); +});