Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions scripts/debug-dedupe-repro.js
Original file line number Diff line number Diff line change
@@ -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'));
})();

14 changes: 13 additions & 1 deletion src/main/channelManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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')) {
Expand All @@ -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 || '',
Expand All @@ -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
};
}
Expand Down
206 changes: 185 additions & 21 deletions src/main/football.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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
Expand Down Expand Up @@ -102,45 +135,168 @@ 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);
}

function getThrottleState() {
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;
Expand All @@ -158,19 +314,20 @@ 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');
if (!home || !away) continue;
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
});
Expand All @@ -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 };
Loading
Loading