diff --git a/MMM-MyScoreboard.js b/MMM-MyScoreboard.js index 03786a3..977542a 100644 --- a/MMM-MyScoreboard.js +++ b/MMM-MyScoreboard.js @@ -39,6 +39,7 @@ Module.register('MMM-MyScoreboard', { baseballDetailViewOverride: true, showScoreAnimation: false, showUpcomingGames: false, + liveTennisApiKey: '', // free key: https://livetennisapi.com/subscribe/free (needed for ATP/WTA/TENNIS) sports: [ { league: 'NHL', @@ -93,6 +94,11 @@ Module.register('MMM-MyScoreboard', { 'PWHL': { provider: 'PWHL', logoFormat: 'url' }, + // Tennis (Live Tennis API — requires config.liveTennisApiKey) + 'ATP': { provider: 'LiveTennisAPI', logoFormat: 'url' }, + 'WTA': { provider: 'LiveTennisAPI', logoFormat: 'url' }, + 'TENNIS': { provider: 'LiveTennisAPI', logoFormat: 'url' }, + // International Soccer 'ALL_SOCCER': { provider: 'Scorepanel', logoFormat: 'url', homeTeamFirst: true }, 'SOCCER_ON_TV': { provider: 'Scorepanel', logoFormat: 'url', homeTeamFirst: true }, @@ -1446,6 +1452,7 @@ Module.register('MMM-MyScoreboard', { label: thisLabel, gameDate: gameDate, whichDay: whichDay, + apiKey: self.config.liveTennisApiKey, hideBroadcasts: self.config.hideBroadcasts, skipChannels: self.config.skipChannels, showLocalBroadcasts: self.config.showLocalBroadcasts, diff --git a/README.md b/README.md index 75d815a..25fdf17 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,14 @@ Currently this module supports the following leagues. Use the bold uppercase sh **Note:** You can probably guess the team abbreviations based on the city, but team abbreviation code lists for the leagues above are later in this README. If you notice an error, open an issue and let me know. +### Tennis + +* `ATP` - ATP Tour (men's singles and doubles) +* `WTA` - WTA Tour (women's singles and doubles) +* `TENNIS` - All tours (ATP, WTA, Challenger, ITF, Juniors) + +Tennis is served by the [Live Tennis API](https://livetennisapi.com) and needs a free API key. See [Tennis (ATP / WTA / TENNIS)](#tennis-atp--wta--tennis) below for setup, the free-tier limits, and how player names are used in place of team codes. + ### Soccer Leagues & Competitions #### Most Popular @@ -1852,6 +1860,40 @@ Teams: ``` +### Tennis (ATP / WTA / TENNIS) + +Tennis (league codes `ATP`, `WTA`, and `TENNIS` for all tours) is served by the [Live Tennis API](https://livetennisapi.com). + +**Disclosure:** this provider and this section were written by the operator of the Live Tennis API, so treat it as vendor-authored. There is also a separate dedicated MagicMirror module, [MMM-LiveTennis](https://github.com/livetennisapi/MMM-LiveTennis); this provider is for people who want tennis inside their one unified MMM-MyScoreboard scoreboard rather than a second module — the two do not conflict. + +**API key (required).** Tennis needs a key, set once at the top level of the module config as `liveTennisApiKey`. A free key is self-serve at [livetennisapi.com/subscribe/free](https://livetennisapi.com/subscribe/free). + +**Free-tier limits — please read.** The free tier is **30 requests/minute and 100 requests/day**. This provider refreshes on a shared 15-minute cycle and spends **two calls per refresh** (live + upcoming) no matter how many tennis leagues you follow — about 96 calls/day, which fits inside the free 100/day. It is fine for a mirror; it is **not** enough for sustained fast polling, which needs a paid tier. The free tier serves **live and upcoming** matches, so the tennis rows show today's in-progress and scheduled matches; **completed/final results are part of the paid History product** and are not shown on a free key. + +**Following players instead of teams.** Tennis has no team codes, so the `teams` array is used for **player surnames** (matched case-insensitively against both players). Omit `teams` to show the whole tour's slate for the day. + +```js +{ + module: 'MMM-MyScoreboard', + position: 'top_left', + config: { + liveTennisApiKey: 'YOUR_FREE_KEY', + sports: [ + { + league: 'ATP', + teams: ['Alcaraz', 'Djokovic'], // player surnames; omit for the full slate + }, + { + league: 'WTA', + teams: ['Gauff', 'Swiatek'], + }, + ], + }, +}, +``` + +**What the score shows.** Completed sets won appear in the two score slots; the status line shows the set-by-set games (e.g. `6-4 3-6 2-1`), the current game points (e.g. `40-30`, or `TB 5-3` in a tiebreak), and a `BP` marker on break point. A `•` next to a player marks who is serving. + ## Logos You can add your own custom personal logos into the `logos_custom` folder, and they will not be disturbed when you update the module. [Specific guidance can be found here](https://github.com/dathbe/MMM-MyScoreboard/tree/4.7.2/logos_custom). (But if you have a logo that you think should be added for all users, please [share it by opening an issue](https://github.com/dathbe/MMM-MyScoreboard/issues).) diff --git a/node_helper.js b/node_helper.js index 3e371e8..65f3ebf 100644 --- a/node_helper.js +++ b/node_helper.js @@ -17,6 +17,7 @@ module.exports = NodeHelper.create({ this.providers.Scorepanel = require('./providers/ESPN_Scorepanel.js') this.providers.CPL = require('./providers/CPL.js') this.providers.PWHL = require('./providers/PWHL.js') + this.providers.LiveTennisAPI = require('./providers/LiveTennisAPI.js') this.localLogos = {} var fsTree = this.getDirectoryTree('./modules/MMM-MyScoreboard/logos') diff --git a/providers/LiveTennisAPI.js b/providers/LiveTennisAPI.js new file mode 100644 index 0000000..48ae63b --- /dev/null +++ b/providers/LiveTennisAPI.js @@ -0,0 +1,278 @@ +/* + + --------------------------------------------- + Provider for Live Tennis API Scoreboard Data + --------------------------------------------- + + Provides live and upcoming singles/doubles scores for + ATP, WTA (and, via the TENNIS league code, all tours) + + Vendor note: this provider is written by the operator of the Live Tennis + API (https://livetennisapi.com). A free key is self-serve at + https://livetennisapi.com/subscribe/free (30 requests/minute, 100/day). + The free tier serves LIVE and UPCOMING matches; completed results are part + of the paid History product, so this provider shows today's live and + upcoming matches only. + +*/ + +const Log = require('logger') +const moment = require('moment-timezone') + +const BASE_URL = 'https://api.livetennisapi.com/api/public/v1' + +module.exports = { + PROVIDER_NAME: 'LiveTennisAPI', + + /* + Free tier is 30 req/min and 100 req/day. Each poll spends two calls + (live + upcoming) regardless of how many tennis leagues are configured, + because one shared cache feeds them all. A 15-minute cadence is ~96 + calls/day, which stays inside the free 100/day. Do not lower this on a + free key; sustained fast polling needs a paid tier. + */ + POLL_FREQUENCY: 15 * 60 * 1000, + + apiKey: null, + matches: null, + dataOk: false, + dataPollStarted: false, + + getScores(payload, gameDate, callback) { + if (payload.apiKey && !this.apiKey) this.apiKey = payload.apiKey + + if (!this.apiKey) { + Log.error('[MMM-MyScoreboard] LiveTennisAPI: no API key set. Add `liveTennisApiKey` to your module config. Free key: https://livetennisapi.com/subscribe/free') + callback([], payload.index, false) + return + } + + if (!this.dataPollStarted) this.startDataPoll() + + const self = this + let waited = 0 + const waitForData = setInterval(function () { + if (self.matches != null) { + clearInterval(waitForData) + const result = self.formatScores(payload.league, payload.teams, gameDate) + callback(result.games, payload.index, result.noGamesToday) + } + else if ((waited += 500) >= 10000) { + clearInterval(waitForData) + callback([], payload.index, false) + } + }, 500) + }, + + startDataPoll() { + this.dataPollStarted = true + this.getData() + setInterval(() => this.getData(), this.POLL_FREQUENCY) + }, + + async fetchMatches(status) { + const res = await fetch(`${BASE_URL}/matches?status=${status}&limit=200`, { + headers: { 'X-API-Key': this.apiKey }, + }) + if (!res.ok) { + throw new Error(`HTTP ${res.status} - ${res.statusText} (status=${status})`) + } + const body = await res.json() + return (body && Array.isArray(body.data)) ? body.data : [] + }, + + async getData() { + try { + // `live` and `upcoming` are the FREE current-state picture; `completed` + // is a paid surface, so it is intentionally not requested here. + const [live, upcoming] = await Promise.all([ + this.fetchMatches('live'), + this.fetchMatches('upcoming'), + ]) + this.matches = live.concat(upcoming) + this.dataOk = true + Log.info(`[MMM-MyScoreboard] LiveTennisAPI matches fetched (${live.length} live, ${upcoming.length} upcoming)`) + } + catch (err) { + Log.error(`[MMM-MyScoreboard] Error fetching LiveTennisAPI matches: ${err}`) + this.dataOk = false + // Unblock any waiters; a transient failure must not mark the day as + // having no games (that would suppress requests for the rest of the day). + if (this.matches == null) this.matches = [] + } + }, + + leagueToTour(league) { + switch (league) { + case 'ATP': return 'atp' + case 'WTA': return 'wta' + default: return null // TENNIS (or anything else) = all tours + } + }, + + formatScores(league, teams, gameDate) { + const tour = this.leagueToTour(league) + const day = moment(gameDate).format('YYYY-MM-DD') + const today = moment().format('YYYY-MM-DD') + const games = [] + + for (let i = 0; i < this.matches.length; i++) { + const m = this.matches[i] + if (tour != null && m.tour !== tour) continue + + let include + if (m.status === 'live') { + // A live match is happening now, so it belongs on today's board only. + include = (day === today) + } + else { + // upcoming / completed / cancelled: match on the scheduled local date. + const when = m.scheduled_time + if (when) { + include = moment.utc(when).local().format('YYYY-MM-DD') === day + } + else { + include = (m.status === 'upcoming' && day === today) + } + } + if (!include) continue + + if (teams != null && teams.length > 0 && !this.matchHasTeam(m, teams)) continue + + games.push(this.formatGame(m)) + } + + return { games: games, noGamesToday: games.length === 0 && this.dataOk } + }, + + matchHasTeam(m, teams) { + const players = m.players || {} + const names = [players.p1 && players.p1.name, players.p2 && players.p2.name] + .filter(Boolean) + .map(n => n.toLowerCase()) + return teams.some((t) => { + const needle = String(t).toLowerCase() + return names.some(n => n.includes(needle)) + }) + }, + + formatGame(m) { + const players = m.players || {} + const p1 = players.p1 || {} + const p2 = players.p2 || {} + const score = m.score || {} + const sets = Array.isArray(score.sets) ? score.sets : [] + + let gameMode + if (m.status === 'live') gameMode = 1 + else if (m.status === 'completed') gameMode = 2 + else gameMode = 0 // upcoming / cancelled render as "future" (no score shown) + + const server = (score.server === 1 || score.server === 2) ? score.server : null + const serveP1 = (gameMode === 1 && server === 1) ? ' •' : '' + const serveP2 = (gameMode === 1 && server === 2) ? ' •' : '' + + return { + classes: [], + gameMode: gameMode, + hTeam: this.surname(p1.name) + serveP1, + vTeam: this.surname(p2.name) + serveP2, + hTeamLong: (p1.name || 'TBD') + serveP1, + vTeamLong: (p2.name || 'TBD') + serveP2, + hTeamLogoUrl: '', + vTeamLogoUrl: '', + hScore: gameMode === 0 ? '' : (sets.length > 0 ? sets[0] : 0), + vScore: gameMode === 0 ? '' : (sets.length > 1 ? sets[1] : 0), + status: this.buildStatus(m, score), + } + }, + + buildStatus(m, score) { + const lines = [] + const setStr = this.setScoreString(score) + + if (m.status === 'upcoming' || m.status === 'cancelled') { + if (m.event_status) lines.push(m.event_status) + else lines.push(m.scheduled_time ? this.formatTime(m.scheduled_time) : 'TBD') + if (m.round) lines.push(m.round) + return lines + } + + if (m.status === 'completed') { + lines.push(m.event_status ? m.event_status : 'Final') + if (setStr) lines.push(setStr) + return lines + } + + // live + if (setStr) lines.push(setStr) + const cur = this.currentGameString(score) + if (cur) lines.push(cur) + if (this.isBreakPoint(score)) lines.push('BP') + if (m.event_status === 'Interrupted') lines.push('Interrupted') + return lines + }, + + setScoreString(score) { + const g = score.games + if (!Array.isArray(g) || g.length < 2 || !Array.isArray(g[0]) || !Array.isArray(g[1])) { + return '' + } + const n = Math.max(g[0].length, g[1].length) + const parts = [] + for (let i = 0; i < n; i++) { + const a = g[0][i] != null ? g[0][i] : 0 + const b = g[1][i] != null ? g[1][i] : 0 + parts.push(`${a}-${b}`) + } + return parts.join(' ') + }, + + currentGameString(score) { + const p = score.points + if (!Array.isArray(p) || p.length < 2) return '' + if (p[0] == null || p[1] == null) return '' + return score.is_tiebreak ? `TB ${p[0]}-${p[1]}` : `${p[0]}-${p[1]}` + }, + + /* + Break point: the receiver is one point from winning the game. + True when the receiver is at AD, or at 40 while the server is at 0/15/30. + Never in a tiebreak, and never when server or points are unknown. + */ + isBreakPoint(score) { + if (score.is_tiebreak) return false + const server = score.server + if (server !== 1 && server !== 2) return false + const p = score.points + if (!Array.isArray(p) || p.length < 2) return false + const serverPts = p[server - 1] + const receiverPts = p[server === 1 ? 1 : 0] + if (serverPts == null || receiverPts == null) return false + if (receiverPts === 'AD') return true + if (receiverPts === '40' && (serverPts === '0' || serverPts === '15' || serverPts === '30')) return true + return false + }, + + formatTime(iso) { + let timeFormat = 'h:mm a' + if (typeof config !== 'undefined' && config.timeFormat === 24) { + timeFormat = 'H:mm' + } + return moment.utc(iso).local().format(timeFormat) + }, + + surname(name) { + if (!name) return 'TBD' + if (name.indexOf('/') > -1) { + // doubles team: keep each player's surname + return name.split('/').map(part => this.lastToken(part.trim())).join('/') + } + return this.lastToken(name) + }, + + lastToken(s) { + const parts = String(s).trim().split(/\s+/) + return parts[parts.length - 1] || s + }, +} diff --git a/tests/unit/providers/LiveTennisAPI.test.js b/tests/unit/providers/LiveTennisAPI.test.js new file mode 100644 index 0000000..8e03aab --- /dev/null +++ b/tests/unit/providers/LiveTennisAPI.test.js @@ -0,0 +1,167 @@ +'use strict' + +const { describe, it, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') +const path = require('node:path') +const moment = require('moment-timezone') + +const LiveTennisAPI = require(path.resolve(__dirname, '../../../providers/LiveTennisAPI.js')) +const { mockFetch } = require('../../helpers/mock-fetch') + +const LIVE_URL = 'https://api.livetennisapi.com/api/public/v1/matches?status=live&limit=200' +const UPCOMING_URL = 'https://api.livetennisapi.com/api/public/v1/matches?status=upcoming&limit=200' + +function liveMatch(overrides = {}) { + return { + id: 1, + tour: 'atp', + tournament: 'Test Open', + status: 'live', + event_status: null, + scheduled_time: null, + round: 'QF', + players: { + p1: { name: 'Carlos Alcaraz' }, + p2: { name: 'Novak Djokovic' }, + }, + score: { + sets: [1, 0], + games: [[6, 2], [4, 1]], + points: ['40', '30'], + server: 1, + is_tiebreak: false, + }, + ...overrides, + } +} + +describe('LiveTennisAPI provider', () => { + let mock + + beforeEach(() => { + mock = mockFetch() + LiveTennisAPI.apiKey = 'test-key' + LiveTennisAPI.matches = null + LiveTennisAPI.dataOk = false + LiveTennisAPI.dataPollStarted = false + }) + + afterEach(() => { + mock.restore() + }) + + describe('getData', () => { + it('combines live + upcoming and sets dataOk', async () => { + mock.route(LIVE_URL, { data: [liveMatch()] }) + mock.route(UPCOMING_URL, { data: [{ id: 2, tour: 'wta', status: 'upcoming' }] }) + await LiveTennisAPI.getData() + assert.equal(LiveTennisAPI.matches.length, 2) + assert.equal(LiveTennisAPI.dataOk, true) + }) + + it('on HTTP error leaves dataOk false and unblocks with empty matches', async () => { + mock.route(LIVE_URL, '', { status: 500 }) + mock.route(UPCOMING_URL, { data: [] }) + await LiveTennisAPI.getData() + assert.deepEqual(LiveTennisAPI.matches, []) + assert.equal(LiveTennisAPI.dataOk, false) + }) + + it('tolerates a missing data array', async () => { + mock.route(LIVE_URL, {}) + mock.route(UPCOMING_URL, {}) + await LiveTennisAPI.getData() + assert.deepEqual(LiveTennisAPI.matches, []) + assert.equal(LiveTennisAPI.dataOk, true) + }) + }) + + describe('formatScores', () => { + it('filters by tour (ATP excludes WTA)', () => { + LiveTennisAPI.matches = [liveMatch(), liveMatch({ id: 3, tour: 'wta' })] + LiveTennisAPI.dataOk = true + const res = LiveTennisAPI.formatScores('ATP', null, moment()) + assert.equal(res.games.length, 1) + assert.equal(res.noGamesToday, false) + }) + + it('TENNIS league returns all tours', () => { + LiveTennisAPI.matches = [liveMatch(), liveMatch({ id: 3, tour: 'wta' })] + LiveTennisAPI.dataOk = true + assert.equal(LiveTennisAPI.formatScores('TENNIS', null, moment()).games.length, 2) + }) + + it('filters by player surname (case-insensitive)', () => { + LiveTennisAPI.matches = [liveMatch()] + LiveTennisAPI.dataOk = true + assert.equal(LiveTennisAPI.formatScores('ATP', ['alcaraz'], moment()).games.length, 1) + assert.equal(LiveTennisAPI.formatScores('ATP', ['Nadal'], moment()).games.length, 0) + }) + + it('reports noGamesToday only when the fetch succeeded', () => { + LiveTennisAPI.matches = [] + LiveTennisAPI.dataOk = true + assert.equal(LiveTennisAPI.formatScores('ATP', null, moment()).noGamesToday, true) + LiveTennisAPI.dataOk = false + assert.equal(LiveTennisAPI.formatScores('ATP', null, moment()).noGamesToday, false) + }) + }) + + describe('formatGame', () => { + it('maps a live match to the module game object', () => { + const g = LiveTennisAPI.formatGame(liveMatch()) + assert.equal(g.gameMode, 1) + assert.equal(g.hScore, 1) + assert.equal(g.vScore, 0) + assert.equal(g.hTeam, 'Alcaraz •') // p1 serving + assert.equal(g.vTeam, 'Djokovic') + assert.deepEqual(g.status, ['6-4 2-1', '40-30']) + }) + + it('surfaces a break point on the status line', () => { + const g = LiveTennisAPI.formatGame(liveMatch({ + score: { sets: [0, 0], games: [[3], [4]], points: ['30', '40'], server: 1, is_tiebreak: false }, + })) + assert.ok(g.status.includes('BP')) + }) + + it('renders a tiebreak count and no break point', () => { + const g = LiveTennisAPI.formatGame(liveMatch({ + score: { sets: [1, 1], games: [[6, 6], [6, 6]], points: ['5', '3'], server: 2, is_tiebreak: true }, + })) + assert.deepEqual(g.status, ['6-6 6-6', 'TB 5-3']) + assert.equal(g.vTeam, 'Djokovic •') // p2 serving + }) + + it('an upcoming match is FUTURE with no score and a round line', () => { + const g = LiveTennisAPI.formatGame({ + id: 9, tour: 'atp', status: 'upcoming', round: 'SF', + scheduled_time: moment.utc('2026-01-01T15:00:00Z').toISOString(), + players: { p1: { name: 'Jannik Sinner' }, p2: { name: 'Taylor Fritz' } }, + score: null, + }) + assert.equal(g.gameMode, 0) + assert.equal(g.hScore, '') + assert.equal(g.status[g.status.length - 1], 'SF') + }) + }) + + describe('isBreakPoint', () => { + it('true when receiver holds AD', () => { + assert.equal(LiveTennisAPI.isBreakPoint({ server: 1, points: ['40', 'AD'], is_tiebreak: false }), true) + }) + it('true when receiver at 40 and server below 40', () => { + assert.equal(LiveTennisAPI.isBreakPoint({ server: 2, points: ['40', '30'], is_tiebreak: false }), true) + }) + it('false at deuce (40-40)', () => { + assert.equal(LiveTennisAPI.isBreakPoint({ server: 1, points: ['40', '40'], is_tiebreak: false }), false) + }) + it('false in a tiebreak', () => { + assert.equal(LiveTennisAPI.isBreakPoint({ server: 1, points: ['3', '6'], is_tiebreak: true }), false) + }) + it('false when server or points are unknown', () => { + assert.equal(LiveTennisAPI.isBreakPoint({ server: null, points: ['0', 'AD'] }), false) + assert.equal(LiveTennisAPI.isBreakPoint({ server: 1, points: ['40', null] }), false) + }) + }) +})