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(/([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 @@