MythTV master
recordingextender.cpp
Go to the documentation of this file.
1/*
2 * Class RecordingExtender
3 *
4 * Copyright (c) David Hampton 2021
5 *
6 * Based on the ideas in the standalone Myth Recording PHP code from
7 * Derek Battams <derek@battams.ca>.
8 *
9 * This program is free software; you can redistribute it and/or modify
10 * it under the terms of the GNU General Public License as published by
11 * the Free Software Foundation; either version 2 of the License, or
12 * (at your option) any later version.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
22 */
23#include <thread>
24
25// Qt
26#include <QFile>
27#include <QJsonArray>
28#include <QJsonObject>
29#include <QUrlQuery>
30
31// MythTV
35#include "libmythbase/mythdb.h"
39
40// MythBackend
41#include "recordingextender.h"
42#include "scheduler.h"
43
44#define LOC QString("RecExt: ")
45
47static constexpr int64_t kLookBackTime { 3LL * 60 * 60 };
48static constexpr int64_t kLookForwardTime { 1LL * 60 * 60 };
49
50static constexpr std::chrono::minutes kExtensionTime {10};
51static constexpr int kExtensionTimeInSec {
52 (duration_cast<std::chrono::seconds>(kExtensionTime).count()) };
53static const QRegularExpression kVersusPattern {R"(\s(at|@|vs\.?)\s)"};
54static const QRegularExpression kSentencePattern {R"(:|\.+\s)"};
55
61static inline bool ValidRecordingStatus(RecStatus::Type recstatus)
62{
63 return (recstatus == RecStatus::Recording ||
64 recstatus == RecStatus::Tuning ||
65 recstatus == RecStatus::WillRecord ||
66 recstatus == RecStatus::Pending);
67}
68
75{
76 LOG(VB_GENERAL, LOG_DEBUG, LOC +
77 QString("setInfoUrl(%1)").arg(url.url()));
78 m_infoUrl = std::move(url);
79}
80
87{
88 LOG(VB_GENERAL, LOG_DEBUG, LOC +
89 QString("setGameUrl(%1)").arg(url.url()));
90 m_gameUrl = std::move(url);
91}
92
102bool ActiveGame::teamsMatch(const QStringList& names, const QStringList& abbrevs) const
103{
104 // Exact name matches
105 if ((m_team1Normalized == names[0]) &&
106 (m_team2Normalized == names[1]))
107 return true;
108 if ((m_team1Normalized == names[1]) &&
109 (m_team2Normalized == names[0]))
110 return true;
111
112 // One name or the other is shortened
113 if (((m_team1Normalized.contains(names[0])) ||
114 (names[0].contains(m_team1Normalized))) &&
115 ((m_team2Normalized.contains(names[1])) ||
116 names[1].contains(m_team2Normalized)))
117 return true;
118 if (((m_team1Normalized.contains(names[1])) ||
119 (names[1].contains(m_team1Normalized))) &&
120 ((m_team2Normalized.contains(names[0])) ||
121 names[0].contains(m_team2Normalized)))
122 return true;
123
124 // Check abbrevs
125 if ((m_team1 == abbrevs[0]) && (m_team2 == abbrevs[1]))
126 return true;
127 return ((m_team1 == abbrevs[1]) && (m_team2 == abbrevs[0]));
128}
129
133
138bool RecExtDataPage::timeIsClose(const QDateTime& eventStart)
139{
140 QDateTime now = getNow();
141 QDateTime past = now.addSecs(-kLookBackTime);
142 QDateTime future = now.addSecs( kLookForwardTime);
143#if 0
144 LOG(VB_GENERAL, LOG_DEBUG, LOC + QString("past: %1.").arg(past.toString(Qt::ISODate)));
145 LOG(VB_GENERAL, LOG_DEBUG, LOC + QString("eventStart: %1.").arg(eventStart.toString(Qt::ISODate)));
146 LOG(VB_GENERAL, LOG_DEBUG, LOC + QString("future: %1.").arg(future.toString(Qt::ISODate)));
147 LOG(VB_GENERAL, LOG_DEBUG, LOC + QString("result is %1.")
148 .arg(((past < eventStart) && (eventStart < future)) ? "true" : "false"));
149#endif
150 return ((past < eventStart) && (eventStart < future));
151}
152
160QJsonObject RecExtDataPage::walkJsonPath(QJsonObject& object, const QStringList& path)
161{
162 static QRegularExpression re { R"((\w+)\[(\d+)\])" };
163 QRegularExpressionMatch match;
164
165 for (const QString& step : path)
166 {
167 if (step.contains(re, &match))
168 {
169 QString name = match.captured(1);
170 int index = match.captured(2).toInt();
171 if (!object.contains(name) || !object[name].isArray())
172 {
173 LOG(VB_GENERAL, LOG_ERR, LOC +
174 QString("Invalid json at %1 in path %2 (not an array)")
175 .arg(name, path.join('/')));
176 return {};
177 }
178 QJsonArray array = object[name].toArray();
179 if ((array.size() < index) || !array[index].isObject())
180 {
181 LOG(VB_GENERAL, LOG_ERR, LOC +
182 QString("Invalid json at %1[%2] in path %3 (invalid array)")
183 .arg(name).arg(index).arg(path.join('/')));
184 return {};
185 }
186 object = array[index].toObject();
187 }
188 else
189 {
190 if (!object.contains(step) || !object[step].isObject())
191 {
192 LOG(VB_GENERAL, LOG_ERR, LOC +
193 QString("Invalid json at %1 in path %2 (not an object)")
194 .arg(step, path.join('/')));
195 return {};
196 }
197 object = object[step].toObject();
198 }
199 }
200 return object;
201}
202
211bool RecExtDataPage::getJsonInt(const QJsonObject& _object, QStringList& path, int& value)
212{
213 if (path.empty())
214 return false;
215 QString key = path.takeLast();
216 QJsonObject object = _object;
217 if (!path.empty())
218 object = walkJsonPath(object, path);
219 if (object.isEmpty() || !object.contains(key) || !object[key].isDouble())
220 {
221 LOG(VB_GENERAL, LOG_DEBUG, LOC +
222 QString("invalid key: %1.").arg(path.join('/')));
223 return false;
224 }
225 value = object[key].toDouble();
226 return true;
227}
228
237bool RecExtDataPage::getJsonInt(const QJsonObject& object, const QString& key, int& value)
238{
239 QStringList list = key.split('/');
240 return getJsonInt(object, list, value);
241}
242
249bool RecExtDataPage::getJsonString(const QJsonObject& _object, QStringList& path, QString& value)
250{
251 if (path.empty())
252 return false;
253 QString key = path.takeLast();
254 QJsonObject object = _object;
255 if (!path.empty())
256 object = walkJsonPath(object, path);
257 if (object.isEmpty() || !object.contains(key) || !object[key].isString())
258 {
259 LOG(VB_GENERAL, LOG_DEBUG, LOC +
260 QString("invalid key: %1.").arg(path.join('/')));
261 return false;
262 }
263 value = object[key].toString();
264 return true;
265}
266
275bool RecExtDataPage::getJsonString(const QJsonObject& object, const QString& key, QString& value)
276{
277 QStringList list = key.split('/');
278 return getJsonString(object, list, value);
279}
280
287bool RecExtDataPage::getJsonObject(const QJsonObject& _object, QStringList& path, QJsonObject& value)
288{
289 if (path.empty())
290 return false;
291 QString key = path.takeLast();
292 QJsonObject object = _object;
293 if (!path.empty())
294 object = walkJsonPath(object, path);
295 if (object.isEmpty() || !object.contains(key) || !object[key].isObject())
296 {
297 LOG(VB_GENERAL, LOG_DEBUG, LOC +
298 QString("invalid key: %1.").arg(path.join('/')));
299 return false;
300 }
301 value = object[key].toObject();
302 return true;
303}
304
313bool RecExtDataPage::getJsonObject(const QJsonObject& object, const QString& key, QJsonObject& value)
314{
315 QStringList list = key.split('/');
316 return getJsonObject(object, list, value);
317}
318
325bool RecExtDataPage::getJsonArray(const QJsonObject& object, const QString& key, QJsonArray& value)
326{
327 if (!object.contains(key) || !object[key].isArray())
328 return false;
329 value = object[key].toArray();
330 return true;
331}
332
334
336QHash<QString,QJsonDocument> RecExtDataSource::s_downloadedJson {};
337
340{
341 s_downloadedJson.clear();
342}
343
351static QString normalizeString(const QString& s)
352{
353 QString result;
354
355 QString norm = s.normalized(QString::NormalizationForm_D);
356 for (QChar c : std::as_const(norm))
357 {
358 switch (c.category())
359 {
360 case QChar::Mark_NonSpacing:
361 case QChar::Mark_SpacingCombining:
362 case QChar::Mark_Enclosing:
363 continue;
364 default:
365 result += c;
366 }
367 }
368
369 // Possibly needed? Haven't seen a team name with a German eszett
370 // to know how they are handled by the api providers.
371 //result = result.replace("ß","ss");
372 return result.simplified();
373}
374
378
379// The URL to get the names of all sports:
380// https://sports.core.api.espn.com/v2/sports/
381//
382// The list as of 2026-05-12 is this:
383//
384// australian-football
385// baseball
386// basketball
387// cricket
388// field-hockey
389// football
390// golf
391// hockey
392// lacrosse
393// mma
394// racing
395// rugby
396// rugby-league
397// soccer
398// tennis
399// volleyball
400// water-polo
401//
402// The URL to get the names of all the leagues in a given sport:
403// https://site.api.espn.com/apis/site/v2/leagues/dropdown?sport=${sport}&limit=100
404//
405// The URL to get the names of all the teams in a league:
406// http://site.api.espn.com/apis/site/v2/sports/${sport}/${league}/teams
407
408// The URL to retrieve schedules and scores.
409// http://site.api.espn.com/apis/site/v2/sports/${sport}/${league}/scoreboard
410// http://site.api.espn.com/apis/site/v2/sports/${sport}/${league}/scoreboard?dates=20180901
411// http://sports.core.api.espn.com/v2/sports/${sport}/leagues/${league}/events/${eventId}/competitions/${eventId}/status
412//
413// Mens College Basketball (Group 50)
414//
415// All teams:
416// http://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/teams?groups=50&limit=500
417//
418// This only shows teams in the top 25:
419// http://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard?date=20220126
420//
421// This shows all the scheduled games.
422// http://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard?date=20220126&groups=50&limit=500
423
424static const QString espnInfoUrlFmt {"http://site.api.espn.com/apis/site/v2/sports/%1/%2/scoreboard"};
425static const QString espnGameUrlFmt {"http://sports.core.api.espn.com/v2/sports/%1/leagues/%2/events/%3/competitions/%3/status"};
426
428const QList<RecExtEspnDataPage::GameStatus> RecExtEspnDataPage::kFinalStatuses {
429 FINAL, FORFEIT, CANCELLED, POSTPONED, SUSPENDED,
430 FORFEIT_HOME_TEAM, FORFEIT_AWAY_TEAM, ABANDONED, FULL_TIME,
431 PLAY_COMPLETE, OFFICIAL_EVENT_SHORTENED, RETIRED,
432 BYE, ESPNVOID, FINAL_SCORE_AFTER_EXTRA_TIME, FINAL_SCORE_AFTER_GOLDEN_GOAL,
433 FINAL_SCORE_AFTER_PENALTIES, END_EXTRA_TIME, FINAL_SCORE_ABANDONED,
434};
435
445{
446 LOG(VB_GENERAL, LOG_DEBUG, LOC +
447 QString("Looking for match of %1/%2")
448 .arg(game.getTeam1(), game.getTeam2()));
449
450 QJsonObject json = m_doc.object();
451 if (json.isEmpty())
452 return false;
453 if (!json.contains("events") || !json["events"].isArray())
454 {
455 LOG(VB_GENERAL, LOG_INFO, LOC +
456 QString("malformed json document, step %1.").arg(1));
457 return false;
458 }
459
460 // Process the games
461 QJsonArray eventArray = json["events"].toArray();
462 for (const auto& eventValue : std::as_const(eventArray))
463 {
464 // Process info at the game level
465 if (!eventValue.isObject())
466 {
467 LOG(VB_GENERAL, LOG_INFO, LOC +
468 QString("malformed json document, step %1.").arg(2));
469 continue;
470 }
471 QJsonObject event = eventValue.toObject();
472
473 // Top level info for a game
474 QString idStr {};
475 QString dateStr {};
476 QString gameTitle {};
477 QString gameShortTitle {};
478 if (!getJsonString(event, "id", idStr) ||
479 !getJsonString(event, "date", dateStr) ||
480 !getJsonString(event, "name", gameTitle) ||
481 !getJsonString(event, "shortName", gameShortTitle))
482 {
483 LOG(VB_GENERAL, LOG_INFO, LOC +
484 QString("malformed json document, step %1.").arg(3));
485 continue;
486 }
487 QStringList teamNames = gameTitle.split(kVersusPattern);
488 QStringList teamAbbrevs = gameShortTitle.split(kVersusPattern);
489 if ((teamNames.size() != 2) || (teamAbbrevs.size() != 2))
490 {
491 LOG(VB_GENERAL, LOG_INFO, LOC +
492 QString("malformed json document, step %1.").arg(4));
493 continue;
494 }
495 RecordingExtender::nameCleanup(game.getInfo(), teamNames[0], teamNames[1]);
496 if (!game.teamsMatch(teamNames, teamAbbrevs))
497 {
498 LOG(VB_GENERAL, LOG_DEBUG, LOC +
499 QString("Found %1 at %2 (%3 @ %4). Teams don't match.")
500 .arg(teamNames[0], teamNames[1],
501 teamAbbrevs[0], teamAbbrevs[1]));
502 continue;
503 }
504 QDateTime startTime = QDateTime::fromString(dateStr, Qt::ISODate);
505 if (!timeIsClose(startTime))
506 {
507 LOG(VB_GENERAL, LOG_INFO, LOC +
508 QString("Found '%1 vs %2' starting time %3 wrong")
509 .arg(game.getTeam1(), game.getTeam2(),
510 game.getStartTimeAsString()));
511 continue;
512 }
513
514 // Found everthing we need.
515 game.setAbbrevs(teamAbbrevs);
516 game.setStartTime(startTime);
517 game.setGameUrl(getSource()->makeGameUrl(game, idStr));
518
519 LOG(VB_GENERAL, LOG_DEBUG, LOC +
520 QString("Match: %1 at %2 (%3 @ %4), start %5.")
521 .arg(game.getTeam1(), game.getTeam2(),
522 game.getAbbrev1(), game.getAbbrev2(),
523 game.getStartTimeAsString()));
524 return true;
525 }
526 return false;
527}
528
541{
542 LOG(VB_GENERAL, LOG_DEBUG, LOC +
543 QString("Parsing game score for %1/%2")
544 .arg(game.getTeam1(), game.getTeam2()));
545
546 QJsonObject json = m_doc.object();
547 if (json.isEmpty())
548 return {};
549
550 int period {-1};
551 QString typeId;
552 QString detail;
553 QString description;
554 if (!getJsonInt(json, "period", period) ||
555 !getJsonString(json, "type/id", typeId) ||
556 !getJsonString(json, "type/description", description) ||
557 !getJsonString(json, "type/detail", detail))
558 {
559 LOG(VB_GENERAL, LOG_INFO, LOC +
560 QString("malformed json document, step %1.").arg(5));
561 return {};
562 }
563 auto stateId = static_cast<GameStatus>(typeId.toInt());
564 bool gameOver = kFinalStatuses.contains(stateId);
565
566 GameState state(game, period, gameOver);
567 QString extra;
568 if ((description == detail) || (description == "In Progress"))
569 extra = detail;
570 else
571 extra = QString ("%1: %2").arg(description, detail);
572 state.setTextState(extra);
573 LOG(VB_GENERAL, LOG_INFO, LOC +
574 QString("%1 at %2 (%3 @ %4), %5.")
575 .arg(game.getTeam1(), game.getTeam2(),
576 game.getAbbrev1(), game.getAbbrev2(), extra));
577 return state;
578}
579
589RecExtEspnDataSource::loadPage(const ActiveGame& game, const QUrl& _url)
590{
591 QString url = _url.url();
592
593 // Return cached document
594 if (s_downloadedJson.contains(url))
595 {
596 LOG(VB_GENERAL, LOG_DEBUG, LOC +
597 QString("Using cached document for %1.").arg(url));
598 return newPage(s_downloadedJson[url]);
599 }
600
601 QByteArray data;
602 bool ok {false};
603 QString scheme = _url.scheme();
604 if (scheme == QStringLiteral(u"file"))
605 {
606 QFile file(_url.path(QUrl::FullyDecoded));
607 ok = file.open(QIODevice::ReadOnly);
608 if (ok)
609 data = file.readAll();
610 }
611 else if ((scheme == QStringLiteral(u"http")) ||
612 (scheme == QStringLiteral(u"https")))
613 {
614 ok = GetMythDownloadManager()->download(url, &data);
615 }
616 if (!ok)
617 {
618 LOG(VB_GENERAL, LOG_INFO, LOC +
619 QString("\"%1\" couldn't download %2.")
620 .arg(game.getTitle(), url));
621 return nullptr;
622 }
623
624 QJsonParseError error {};
625 QJsonDocument doc = QJsonDocument::fromJson(data, &error);
626 if (error.error != QJsonParseError::NoError)
627 {
628 LOG(VB_GENERAL, LOG_ERR, LOC +
629 QString("Error parsing %1 at offset %2: %3")
630 .arg(url).arg(error.offset).arg(error.errorString()));
631 return nullptr;
632 }
633
634 QJsonObject json = doc.object();
635 if (json.contains("code") && json["code"].isDouble() &&
636 json.contains("detail") && json["detail"].isString())
637 {
638 LOG(VB_GENERAL, LOG_INFO, LOC +
639 QString("error downloading json document, code %1, detail %2.")
640 .arg(json["code"].toInt()).arg(json["detail"].toString()));
641 return nullptr;
642 }
643 s_downloadedJson[url] = doc;
644 return newPage(doc);
645}
646
653QUrl RecExtEspnDataSource::makeInfoUrl (const SportInfo& info, const QDateTime& dt)
654{
655 QUrl url {QString(espnInfoUrlFmt).arg(info.sport, info.league)};
656 QUrlQuery query;
657 query.addQueryItem("limit", "500");
658 // Add this to get all games, otherwise only top-25 games are returned.
659 if (info.league.endsWith("college-basketball"))
660 query.addQueryItem("group", "50");
661 if (dt.isValid())
662 query.addQueryItem("dates", dt.toString("yyyyMMdd"));
663 url.setQuery(query);
664 return url;
665}
666
673QUrl RecExtEspnDataSource::makeGameUrl(const ActiveGame& game, const QString& str)
674{
675 SportInfo info = game.getInfo();
676 QUrl gameUrl = QUrl(espnGameUrlFmt.arg(info.sport, info.league, str));
677 return gameUrl;
678}
679
695{
696 // Find game with today's date (in UTC)
697 // Is the starting time close to now?
698 QDateTime now = MythDate::current();
699 game.setInfoUrl(makeInfoUrl(info, now));
700 RecExtDataPage* page = loadPage(game, game.getInfoUrl());
701 if (!page)
702 {
703 LOG(VB_GENERAL, LOG_INFO, LOC +
704 QString("Couldn't load %1").arg(game.getInfoUrl().url()));
705 return {};
706 }
707 if (page->findGameInfo(game))
708 {
709 LOG(VB_GENERAL, LOG_INFO, LOC +
710 QString("Found game '%1 vs %2' at %3")
711 .arg(game.getTeam1(), game.getTeam2(), game.getStartTimeAsString()));
712 return game.getInfoUrl();
713 }
714
715 // Find game with yesterdays's date (in UTC)
716 // Handles evening games that start after 00:00 UTC. E.G. an 8pm EST football game.
717 // Is the starting time close to now?
718 game.setInfoUrl(makeInfoUrl(info, now.addDays(-1)));
719 page = loadPage(game, game.getInfoUrl());
720 if (!page)
721 {
722 LOG(VB_GENERAL, LOG_INFO, LOC +
723 QString("Couldn't load %1").arg(game.getInfoUrl().url()));
724 return {};
725 }
726 if (page->findGameInfo(game))
727 {
728 LOG(VB_GENERAL, LOG_INFO, LOC +
729 QString("Found game '%1 vs %2' at %3")
730 .arg(game.getTeam1(), game.getTeam2(), game.getStartTimeAsString()));
731 return game.getInfoUrl();
732 }
733
734 // Find game with tomorrow's date (in UTC)
735 // E.G. Handles
736 // Is the starting time close to now?
737 game.setInfoUrl(makeInfoUrl(info, now.addDays(1)));
738 page = loadPage(game, game.getInfoUrl());
739 if (!page)
740 {
741 LOG(VB_GENERAL, LOG_INFO, LOC +
742 QString("Couldn't load %1").arg(game.getInfoUrl().url()));
743 return {};
744 }
745 if (page->findGameInfo(game))
746 {
747 LOG(VB_GENERAL, LOG_INFO, LOC +
748 QString("Found game '%1 vs %2' at %3")
749 .arg(game.getTeam1(), game.getTeam2(), game.getStartTimeAsString()));
750 return game.getInfoUrl();
751 }
752 return {};
753}
754
758
759// The MLB API is free for individual, non-commercial use. See
760// http://gdx.mlb.com/components/copyright.txt
761//
762// Working queryable version of the API:
763// https://beta-statsapi.mlb.com/docs/
764//
765// For schedule information:
766// https://statsapi.mlb.com/api/v1/schedule?sportId=1&date=2021-09-18
767// https://statsapi.mlb.com/api/v1/schedule?sportId=1&startDate=2021-09-18&endDate=2021-09-20"
768//
769// For game information:
770// https://statsapi.mlb.com/api/v1.1/game/{game_pk}/feed/live
771// where the game_pk comes from the schedule data.
772//
773// You can request extra data be returned by asking for "hydrations"
774// (additional data) to be added to the response. The list of
775// available hydrations for any API can be retrieved by adding
776// "hydrate=hydrations" to the URL. For example:
777// https://statsapi.mlb.com/api/v1/schedule?sportId=1&hydrate=hydrations&date=2021-09-18"
778// https://statsapi.mlb.com/api/v1/schedule?sportId=1&hydrate=team&startDate=2021-09-18&endDate=2021-09-20"
779
786bool RecExtMlbDataPage::parseGameObject(const QJsonObject& gameObject,
787 ActiveGame& game)
788{
789 QString dateStr {};
790 QString gameLink {};
791 QStringList teamNames {"", ""};
792 QStringList teamAbbrevs { "", ""};
793 if (!getJsonString(gameObject, "gameDate", dateStr) ||
794 !getJsonString(gameObject, "link", gameLink) ||
795 !getJsonString(gameObject, "teams/home/team/name", teamNames[0]) ||
796 !getJsonString(gameObject, "teams/away/team/name", teamNames[1]) ||
797 !getJsonString(gameObject, "teams/home/team/abbreviation", teamAbbrevs[0]) ||
798 !getJsonString(gameObject, "teams/away/team/abbreviation", teamAbbrevs[1]))
799 {
800 LOG(VB_GENERAL, LOG_INFO, LOC +
801 QString("malformed json document, step %1.").arg(1));
802 return false;
803 }
804
805 RecordingExtender::nameCleanup(game.getInfo(), teamNames[0], teamNames[1]);
806 bool success = game.teamsMatch(teamNames, teamAbbrevs);
807 LOG(VB_GENERAL, LOG_DEBUG, LOC +
808 QString("Found: %1 at %2 (%3 @ %4), starting %5. (%6)")
809 .arg(teamNames[0], teamNames[1],
810 teamAbbrevs[0], teamAbbrevs[1], dateStr,
811 success ? "Success" : "Teams don't match"));
812 if (!success)
813 return false;
814
815 // Found everthing we need.
816 game.setAbbrevs(teamAbbrevs);
817 game.setGameUrl(getSource()->makeGameUrl(game, gameLink));
819 return true;
820}
821
831{
832 LOG(VB_GENERAL, LOG_DEBUG, LOC +
833 QString("Looking for match of %1/%2")
834 .arg(game.getTeam1(), game.getTeam2()));
835
836 QJsonObject json = m_doc.object();
837 if (json.isEmpty())
838 return false;
839
840 QJsonArray datesArray;
841 if (!getJsonArray(json, "dates", datesArray))
842 {
843 LOG(VB_GENERAL, LOG_INFO, LOC +
844 QString("malformed json document, step %1.").arg(1));
845 return false;
846 }
847
848 // Process each of the three dates
849 for (const auto& dateValue : std::as_const(datesArray))
850 {
851 if (!dateValue.isObject())
852 {
853 LOG(VB_GENERAL, LOG_INFO, LOC +
854 QString("malformed json document, step %1.").arg(2));
855 continue;
856 }
857 QJsonObject dateObject = dateValue.toObject();
858
859 QJsonArray gamesArray;
860 if (!getJsonArray(dateObject, "games", gamesArray))
861 {
862 LOG(VB_GENERAL, LOG_INFO, LOC +
863 QString("malformed json document, step %1.").arg(3));
864 continue;
865 }
866
867 // Process each game on a given date
868 for (const auto& gameValue : std::as_const(gamesArray))
869 {
870 if (!gameValue.isObject())
871 {
872 LOG(VB_GENERAL, LOG_INFO, LOC +
873 QString("malformed json document, step %1.").arg(4));
874 continue;
875 }
876 QJsonObject gameObject = gameValue.toObject();
877
878 if (!parseGameObject(gameObject, game))
879 continue;
880 bool match = timeIsClose(game.getStartTime());
881 LOG(VB_GENERAL, LOG_INFO, LOC +
882 QString("Found '%1 vs %2' starting %3 (%4)")
883 .arg(game.getTeam1(), game.getTeam2(), game.getStartTimeAsString(),
884 match ? "match" : "keep looking"));
885 if (!match)
886 continue;
887 return true;
888 }
889 }
890 return false;
891}
892
900{
901 LOG(VB_GENERAL, LOG_DEBUG, LOC +
902 QString("Parsing game score for %1/%2")
903 .arg(game.getTeam1(), game.getTeam2()));
904
905 QJsonObject json = m_doc.object();
906 if (json.isEmpty())
907 return {};
908
909 int period {-1};
910 QString abstractGameState;
911 QString detailedGameState;
912 QString inningState;
913 QString inningOrdinal;
914 if (!getJsonString(json, "gameData/status/abstractGameState", abstractGameState) ||
915 !getJsonString(json, "gameData/status/detailedState", detailedGameState))
916 {
917 LOG(VB_GENERAL, LOG_INFO, LOC +
918 QString("malformed json document (%d)").arg(1));
919 return {};
920 }
921
922 if (detailedGameState != "Scheduled")
923 {
924 if (!getJsonInt( json, "liveData/linescore/currentInning", period) ||
925 !getJsonString(json, "liveData/linescore/currentInningOrdinal", inningOrdinal) ||
926 !getJsonString(json, "liveData/linescore/inningState", inningState))
927 {
928 LOG(VB_GENERAL, LOG_INFO, LOC +
929 QString("malformed json document (%d)").arg(2));
930 return {};
931 }
932 }
933
934 bool gameOver = (abstractGameState == "Final") ||
935 detailedGameState.contains("Suspended");
936 GameState state = GameState(game.getTeam1(), game.getTeam2(),
937 game.getAbbrev1(), game.getAbbrev2(),
938 period, gameOver);
939 QString extra;
940 if (gameOver)
941 extra = "game over";
942 else if (detailedGameState == "In Progress")
943 extra = QString("%1 %2").arg(inningState, inningOrdinal);
944 else
945 extra = detailedGameState;
946 state.setTextState(extra);
947 LOG(VB_GENERAL, LOG_DEBUG, LOC +
948 QString("%1 at %2 (%3 @ %4), %5.")
949 .arg(game.getTeam1(), game.getTeam2(),
950 game.getAbbrev1(), game.getAbbrev2(), extra));
951 return state;
952}
953
963RecExtMlbDataSource::loadPage(const ActiveGame& game, const QUrl& _url)
964{
965 QString url = _url.url();
966
967 // Return cached document
968 if (s_downloadedJson.contains(url))
969 {
970 LOG(VB_GENERAL, LOG_DEBUG, LOC +
971 QString("Using cached document for %1.").arg(url));
972 return newPage(s_downloadedJson[url]);
973 }
974
975 QByteArray data;
976 bool ok {false};
977 QString scheme = _url.scheme();
978 if (scheme == QStringLiteral(u"file"))
979 {
980 QFile file(_url.path(QUrl::FullyDecoded));
981 ok = file.open(QIODevice::ReadOnly);
982 if (ok)
983 data = file.readAll();
984 }
985 else if ((scheme == QStringLiteral(u"http")) ||
986 (scheme == QStringLiteral(u"https")))
987 {
988 ok = GetMythDownloadManager()->download(url, &data);
989 }
990 if (!ok)
991 {
992 LOG(VB_GENERAL, LOG_INFO, LOC +
993 QString("\"%1\" couldn't download %2.")
994 .arg(game.getTitle(), url));
995 return nullptr;
996 }
997
998 QJsonParseError error {};
999 QJsonDocument doc = QJsonDocument::fromJson(data, &error);
1000 if (error.error != QJsonParseError::NoError)
1001 {
1002 LOG(VB_GENERAL, LOG_ERR, LOC +
1003 QString("Error parsing %1 at offset %2: %3")
1004 .arg(url).arg(error.offset).arg(error.errorString()));
1005 return nullptr;
1006 }
1007 s_downloadedJson[url] = doc;
1008 return newPage(doc);
1009}
1010
1018 const QDateTime& dt)
1019{
1020 if (!dt.isValid())
1021 return {};
1022
1023 QDateTime yesterday = dt.addDays(-1);
1024 QDateTime tomorrow = dt.addDays(+1);
1025 QUrl url {"https://statsapi.mlb.com/api/v1/schedule"};
1026 QUrlQuery query;
1027 query.addQueryItem("sportId", "1");
1028 query.addQueryItem("hydrate", "team");
1029 query.addQueryItem("startDate", QString("%1").arg(yesterday.toString("yyyy-MM-dd")));
1030 query.addQueryItem("endDate", QString("%1").arg(tomorrow.toString("yyyy-MM-dd")));
1031 url.setQuery(query);
1032 return url;
1033}
1034
1041QUrl RecExtMlbDataSource::makeGameUrl (const ActiveGame& game, const QString& str)
1042{
1043 QUrl gameUrl = game.getInfoUrl();
1044 gameUrl.setPath(str);
1045 gameUrl.setQuery(QString());
1046 return gameUrl;
1047}
1048
1062{
1063 // Find game with today's date (in UTC)
1064 // Is the starting time close to now?
1065 QDateTime now = MythDate::current();
1066 game.setInfoUrl(makeInfoUrl(info, now));
1067 RecExtDataPage* page = loadPage(game, game.getInfoUrl());
1068 if (!page)
1069 {
1070 LOG(VB_GENERAL, LOG_INFO, LOC +
1071 QString("Couldn't load %1").arg(game.getInfoUrl().url()));
1072 return {};
1073 }
1074 LOG(VB_GENERAL, LOG_INFO, LOC +
1075 QString("Loaded page %1").arg(game.getInfoUrl().url()));
1076 if (page->findGameInfo(game))
1077 return game.getGameUrl();
1078
1079 return {};
1080}
1081
1085
1088
1090{
1092}
1093
1105{
1108 {
1109 LOG(VB_GENERAL, LOG_DEBUG, LOC +
1110 QString("Recording of %1 at %2 not marked for auto extend.")
1112 return;
1113 }
1114 LOG(VB_GENERAL, LOG_DEBUG, LOC +
1115 QString("Adding %1 at %2 to new recordings list.")
1117
1118 QMutexLocker lock(&s_createLock);
1119 if (!s_singleton)
1120 {
1122 s_singleton->m_scheduler = scheduler;
1123 s_singleton->start();
1124 s_singleton->moveToThread(s_singleton->qthread());
1125 }
1127};
1128
1134{
1135 QMutexLocker lock(&m_newRecordingsLock);
1136 m_newRecordings.append(recordedID);
1137}
1138
1148{
1149 switch (type)
1150 {
1151 default:
1152 return nullptr;
1154 return new RecExtEspnDataSource(this);
1156 return new RecExtMlbDataSource(this);
1157 }
1158}
1159
1179bool RecordingExtender::findKnownSport(const QString& _title,
1181 SportInfoList& infoList) const
1182{
1183 static const QRegularExpression year {R"(\d{4})"};
1184 QRegularExpressionMatch match;
1185 QString title = _title;
1186 if (title.contains(year, &match))
1187 {
1188 bool ok {false};
1189 int matchYear = match.captured().toInt(&ok);
1190 int thisYear = m_forcedYearforTesting
1192 : QDateTime::currentDateTimeUtc().date().year();
1193 // FIFA Qualifiers can be in the year before the tournament.
1194 if (!ok || ((matchYear != thisYear) && (matchYear != thisYear+1)))
1195 return false;
1196 title = title.remove(match.capturedStart(), match.capturedLength());
1197 }
1198 title = title.simplified();
1199 LOG(VB_GENERAL, LOG_DEBUG, LOC +
1200 QString("Looking for %1 title '%2")
1201 .arg(toString(type), title));
1202
1204 query.prepare(
1205 "SELECT sl.title, api.provider, api.key1, api.key2" \
1206 " FROM sportslisting sl " \
1207 " INNER JOIN sportsapi api ON sl.api = api.id" \
1208 " WHERE api.provider = :PROVIDER AND :TITLE REGEXP sl.title");
1209 query.bindValue(":PROVIDER", static_cast<uint8_t>(type));
1210 query.bindValue(":TITLE", title);
1211 if (!query.exec())
1212 {
1213 MythDB::DBError("sportsapi() -- findKnownSport", query);
1214 return false;
1215 }
1216 while (query.next())
1217 {
1219
1220 info.showTitle = query.value(0).toString();
1221 info.dataProvider = type;
1222 info.sport = query.value(2).toString();
1223 info.league = query.value(3).toString();
1224 infoList.append(info);
1225
1226 LOG(VB_GENERAL, LOG_DEBUG, LOC +
1227 QString("Info: '%1' matches '%2' '%3' '%4' '%5'")
1228 .arg(title, toString(info.dataProvider), info.showTitle,
1229 info.sport, info.league));
1230 }
1231 return !infoList.isEmpty();
1232}
1233
1236{
1238}
1239
1240// Parse a single string. First split it into parts on a semi-colon or
1241// 'period space', and then selectively check those parts for the
1242// pattern "A vs B".
1243static bool parseProgramString (const QString& string,
1244#if QT_VERSION < QT_VERSION_CHECK(6,0,0)
1245 int limit,
1246#else
1247 qsizetype limit,
1248#endif
1249 QString& team1, QString& team2)
1250{
1251 QString lString = string;
1252 QStringList parts = lString.replace("vs.", "vs").split(kSentencePattern);
1253 for (int i = 0; i < std::min(limit,parts.size()); i++)
1254 {
1255 QStringList words = parts[i].split(kVersusPattern);
1256 if (words.size() == 2)
1257 {
1258 team1 = words[0].simplified();
1259 team2 = words[1].simplified();
1260 return true;
1261 }
1262 }
1263 return false;
1264}
1265
1274bool RecordingExtender::parseProgramInfo (const QString& subtitle, const QString& description,
1275 QString& team1, QString& team2)
1276{
1277 if (parseProgramString(subtitle, 2, team1, team2))
1278 return true;
1279 if (parseProgramString(description, 1, team1, team2))
1280 return true;
1281 LOG(VB_GENERAL, LOG_DEBUG, LOC +
1282 QString("can't find team names in subtitle or description '%1'").arg(description));
1283 return false;
1284}
1285
1293{
1294 if (rr->m_parentRecID)
1295 return QString("%1->%2").arg(rr->m_parentRecID).arg(rr->m_recordID);
1296 return QString::number(rr->m_recordID);
1297}
1298
1309{
1310 name = normalizeString(name);
1311 name = name.simplified();
1312
1313 LOG(VB_GENERAL, LOG_DEBUG, LOC + QString("Start: %1").arg(name));
1314
1315 // Ask the database for all the applicable cleanups
1317 query.prepare(
1318 "SELECT sc.name, sc.pattern, sc.nth, sc.replacement" \
1319 " FROM sportscleanup sc " \
1320 " WHERE (provider=0 or provider=:PROVIDER) " \
1321 " AND (key1='all' or key1=:SPORT) " \
1322 " AND (:NAME REGEXP pattern)" \
1323 " ORDER BY sc.weight");
1324 query.bindValue(":PROVIDER", static_cast<uint8_t>(info.dataProvider));
1325 query.bindValue(":SPORT", info.sport);
1326 query.bindValue(":NAME", name);
1327 if (!query.exec())
1328 {
1329 MythDB::DBError("sportscleanup() -- main query", query);
1330 return;
1331 }
1332
1333 // Now apply each cleanup.
1334 while (query.next())
1335 {
1336 QString patternName = query.value(0).toString();
1337 QString patternStr = query.value(1).toString();
1338 int patternField = query.value(2).toInt();
1339 QString replacement = query.value(3).toString();
1340
1341 QString original = name;
1342 QString tag {"no match"};
1343 QRegularExpressionMatch match;
1344 // Should always be true....
1345 if (name.contains(QRegularExpression(patternStr), &match) &&
1346 match.hasMatch())
1347 {
1348 QString capturedText = match.captured(patternField);
1349 name = name.replace(match.capturedStart(patternField),
1350 match.capturedLength(patternField),
1351 replacement);
1352 name = name.simplified();
1353 if (name.isEmpty())
1354 {
1355 name = original;
1356 tag = "fail";
1357 }
1358 else
1359 {
1360 tag = QString("matched '%1'").arg(capturedText);
1361 }
1362 }
1363 LOG(VB_GENERAL, LOG_DEBUG, LOC +
1364 QString("pattern '%1', %2, now '%3'")
1365 .arg(patternName, tag, name));
1366 }
1367}
1368
1376void RecordingExtender::nameCleanup(const SportInfo& info, QString& name1, QString& name2)
1377{
1378 nameCleanup(info, name1);
1379 if (!name2.isEmpty())
1380 nameCleanup(info, name2);
1381}
1382
1394 const RecordingInfo *ri, RecordingRule *rr, ActiveGame const& game)
1395{
1396 LOG(VB_GENERAL, LOG_INFO, LOC +
1397 QString("Recording %1 rule %2 for '%3 @ %4' has finished. Stop recording.")
1398 .arg(ri->GetRecordingID())
1399 .arg(ruleIdAsString(rr), game.getTeam1(), game.getTeam2()));
1400
1401 MythEvent me(QString("STOP_RECORDING %1 %2")
1402 .arg(ri->GetChanID())
1405}
1406
1416 const RecordingInfo *ri, RecordingRule *rr, const ActiveGame& game)
1417{
1418 LOG(VB_GENERAL, LOG_DEBUG, LOC +
1419 QString("Recording %1 rule %2 for '%3 @ %4' scheduled to end soon. Extending recording.")
1420 .arg(ri->GetRecordingID())
1421 .arg(ruleIdAsString(rr), game.getTeam1(), game.getTeam2()));
1422
1423 // Create an override to make it easy to clean up later.
1424 if (rr->m_type != kOverrideRecord)
1425 {
1426 rr->MakeOverride();
1427 rr->m_type = kOverrideRecord;
1428 }
1429 static const QString ae {"(Auto Extend)"};
1430 rr->m_subtitle = rr->m_subtitle.startsWith(ae)
1431 ? rr->m_subtitle
1432 : ae + ' ' + rr->m_subtitle;
1433
1434 // Update the recording end time. The m_endOffset field is the
1435 // one that is used by the scheduler for timing. The others are
1436 // only updated for consistency.
1437 rr->m_endOffset += kExtensionTime.count();
1438 QDateTime oldDt = ri->GetRecordingEndTime();
1439 QDateTime newDt = oldDt.addSecs(kExtensionTimeInSec);
1440 rr->m_enddate = newDt.date();
1441 rr->m_endtime = newDt.time();
1442
1443 // Update the RecordingRule and Save/Apply it.
1444 if (!rr->Save(true))
1445 {
1446 // Oops. Maybe the backend crashed and there's an old override
1447 // recording already in the table?
1448 LOG(VB_GENERAL, LOG_ERR, LOC +
1449 QString("Recording %1, couldn't save override rule for '%2 @ %3'.")
1450 .arg(ri->GetRecordingID()).arg(game.getTeam1(), game.getTeam2()));
1451 return;
1452 }
1453
1454 // Debugging
1455 bool exists = m_overrideRules.contains(rr->m_recordID);
1456 LOG(VB_GENERAL, LOG_INFO, LOC +
1457 QString("Recording %1, %2 override rule %3 for '%4 @ %5' ending %6 -> %7.")
1458 .arg(ri->GetRecordingID())
1459 .arg(exists ? "updated" : "created",
1460 ruleIdAsString(rr), game.getTeam1(), game.getTeam2(),
1461 oldDt.toString(Qt::ISODate), newDt.toString(Qt::ISODate)));
1462
1463 // Remember the new rule number for later cleanup.
1464 if (!exists)
1465 m_overrideRules.append(rr->m_recordID);
1466}
1467
1476 const RecordingInfo *ri, RecordingRule *rr, const ActiveGame& game)
1477{
1478 LOG(VB_GENERAL, LOG_DEBUG, LOC +
1479 QString("Recording %1 rule %2 for '%3 @ %4' ends %5.")
1480 .arg(ri->GetRecordingID())
1481 .arg(ruleIdAsString(rr), game.getTeam1(), game.getTeam2(),
1482 ri->GetRecordingEndTime().toString(Qt::ISODate)));
1483}
1484
1489{
1490 m_overrideRules.clear();
1491}
1492
1497{
1498 while (true)
1499 {
1500 QMutexLocker locker (&m_newRecordingsLock);
1501 if (m_newRecordings.isEmpty())
1502 break;
1503 int recordedID = m_newRecordings.takeFirst();
1504 locker.unlock();
1505
1506 // Have to get this from the scheduler, otherwise we never see
1507 // the actual recording state.
1508 // WE OWN THIS POINTER.
1509 RecordingInfo *ri = m_scheduler->GetRecording(recordedID);
1510 if (nullptr == ri)
1511 {
1512 LOG(VB_GENERAL, LOG_INFO, LOC +
1513 QString("Couldn't get recording %1 from scheduler")
1514 .arg(recordedID));
1515 continue;
1516 }
1517
1519 {
1520 LOG(VB_GENERAL, LOG_INFO, LOC +
1521 QString("Invalid status for '%1 : %2', status %3.")
1522 .arg(ri->GetTitle(), ri->GetSubtitle(),
1524 delete ri;
1525 continue;
1526 }
1527 RecordingRule *rr = ri->GetRecordingRule(); // owned by ri
1528
1529 SportInfoList infoList;
1530 if (!findKnownSport(ri->GetTitle(), rr->m_autoExtend, infoList))
1531 {
1532 LOG(VB_GENERAL, LOG_INFO, LOC +
1533 QString("Unknown sport '%1' for provider %2")
1534 .arg(ri->GetTitle(), toString(rr->m_autoExtend)));
1535 delete ri;
1536 continue;
1537 }
1538
1539 auto* source = createDataSource(rr->m_autoExtend);
1540 if (!source)
1541 {
1542 LOG(VB_GENERAL, LOG_INFO, LOC +
1543 QString("unable to create data source of type %1.")
1544 .arg(toString(rr->m_autoExtend)));
1545 delete ri;
1546 continue;
1547 }
1548
1549 // Build the game data structure
1550 ActiveGame game(recordedID, ri->GetTitle());
1551 QString team1;
1552 QString team2;
1554 team1, team2))
1555 {
1556 LOG(VB_GENERAL, LOG_INFO, LOC +
1557 QString("Unable to find '%1 : %2', provider %3")
1558 .arg(ri->GetTitle(), ri->GetSubtitle(),
1559 toString(rr->m_autoExtend)));
1560 delete source;
1561 delete ri;
1562 continue;
1563 }
1564 game.setTeams(team1, team2);
1565
1566 // Now try each of the returned sport APIs
1567 bool found {false};
1568 for (auto it = infoList.begin(); !found && it != infoList.end(); it++)
1569 {
1570 SportInfo info = *it;
1571 game.setInfo(info);
1572 nameCleanup(info, team1, team2);
1573 game.setTeamsNorm(team1, team2);
1574
1575 source->findInfoUrl(game, info);
1576 if (game.getGameUrl().isEmpty())
1577 {
1578 LOG(VB_GENERAL, LOG_INFO, LOC +
1579 QString("unable to find data page for recording '%1 : %2'.")
1580 .arg(ri->GetTitle(), ri->GetSubtitle()));
1581 continue;
1582 }
1583 found = true;
1584 }
1585
1586 if (found)
1587 m_activeGames.append(game);
1588 delete source;
1589 delete ri;
1590 }
1591}
1592
1598{
1599 for (auto it = m_activeGames.begin(); it != m_activeGames.end(); )
1600 {
1601 ActiveGame game = *it;
1602 // Have to get this from the scheduler, otherwise we never see
1603 // the change from original to override recording rule.
1604 // WE OWN THIS POINTER.
1606 if (nullptr == _ri)
1607 {
1608 LOG(VB_GENERAL, LOG_INFO, LOC +
1609 QString("Couldn't get recording %1 from scheduler")
1610 .arg(game.getRecordedId()));
1611 it = m_activeGames.erase(it);
1612 continue;
1613 }
1614
1615 // Simplify memory management
1616 auto ri = std::make_unique<RecordingInfo>(*_ri);
1617 delete _ri;
1618
1619 if (!ValidRecordingStatus(ri->GetRecordingStatus()))
1620 {
1621 LOG(VB_GENERAL, LOG_INFO, LOC +
1622 QString("Invalid status for '%1 : %2', status %3.")
1623 .arg(ri->GetTitle(), ri->GetSubtitle(),
1624 RecStatus::toString(ri->GetRecordingStatus())));
1625 it = m_activeGames.erase(it);
1626 continue;
1627 }
1628
1629 RecordingRule *rr = ri->GetRecordingRule(); // owned by ri
1630 auto* source = createDataSource(rr->m_autoExtend);
1631 if (nullptr == source)
1632 {
1633 LOG(VB_GENERAL, LOG_INFO, LOC +
1634 QString("Couldn't create source of type %1")
1635 .arg(toString(rr->m_autoExtend)));
1636 it++;
1637 delete source;
1638 continue;
1639 }
1640 auto* page = source->loadPage(game, game.getGameUrl());
1641 if (nullptr == page)
1642 {
1643 LOG(VB_GENERAL, LOG_INFO, LOC +
1644 QString("Couldn't load source %1, teams %2 and %3, url %4")
1645 .arg(toString(rr->m_autoExtend), game.getTeam1(), game.getTeam2(),
1646 game.getGameUrl().url()));
1647 it++;
1648 delete source;
1649 continue;
1650 }
1651 auto gameState = page->findGameScore(game);
1652 if (!gameState.isValid())
1653 {
1654 LOG(VB_GENERAL, LOG_INFO, LOC +
1655 QString("Game state for source %1, teams %2 and %3 is invalid")
1656 .arg(toString(rr->m_autoExtend), game.getTeam1(), game.getTeam2()));
1657 it++;
1658 delete source;
1659 continue;
1660 }
1661 if (gameState.isFinished())
1662 {
1663 finishRecording(ri.get(), rr, game);
1664 it = m_activeGames.erase(it);
1665 delete source;
1666 continue;
1667 }
1668 if (ri->GetScheduledEndTime() <
1670 {
1671 extendRecording(ri.get(), rr, game);
1672 it++;
1673 delete source;
1674 continue;
1675 }
1676 unchangedRecording(ri.get(), rr, game);
1677 it++;
1678 delete source;
1679 }
1680}
1681
1685{
1686 QMutexLocker lock1(&s_createLock);
1687 QMutexLocker lock2(&m_newRecordingsLock);
1688
1689 if (m_newRecordings.empty() && m_activeGames.empty())
1690 {
1691 m_running = false;
1692 s_singleton = nullptr;
1693 LOG(VB_GENERAL, LOG_DEBUG, LOC +
1694 QString("Nothing left to do. Exiting."));
1695 return;
1696 }
1697
1698 LOG(VB_GENERAL, LOG_DEBUG, LOC +
1699 QString("%1 new recordings, %2 active recordings, %3 overrides.")
1700 .arg(m_newRecordings.size()).arg(m_activeGames.size())
1701 .arg(m_overrideRules.size()));
1702}
1703
1706{
1707 RunProlog();
1708
1709 while (m_running)
1710 {
1711 std::this_thread::sleep_for(kExtensionTime);
1712
1716
1717 checkDone();
1718 }
1719
1721
1722 RunEpilog();
1723 quit();
1724}
void setTeams(QString team1, QString team2)
QString m_team1Normalized
void setGameUrl(QUrl url)
Set the game status information URL.
void setTeamsNorm(QString team1, QString team2)
QUrl getGameUrl() const
QDateTime getStartTime() const
QString getTitle() const
void setInfo(const SportInfo &info)
QString getTeam2() const
void setAbbrevs(QStringList abbrevs)
QString getAbbrev2() const
QString getStartTimeAsString() const
bool teamsMatch(const QStringList &names, const QStringList &abbrevs) const
Do the supplied team names/abbrevs match this game.
QString m_team2Normalized
int getRecordedId() const
QString getTeam1() const
void setInfoUrl(QUrl url)
Set the game scheduling information URL.
QString getAbbrev1() const
void setStartTime(const QDateTime &time)
SportInfo getInfo() const
QUrl getInfoUrl() const
void setTextState(QString text)
QSqlQuery wrapper that fetches a DB connection from the connection pool.
Definition: mythdbcon.h:128
bool prepare(const QString &query)
QSqlQuery::prepare() is not thread safe in Qt <= 3.3.2.
Definition: mythdbcon.cpp:838
QVariant value(int i) const
Definition: mythdbcon.h:204
bool exec(void)
Wrap QSqlQuery::exec() so we can display SQL.
Definition: mythdbcon.cpp:619
void bindValue(const QString &placeholder, const QVariant &val)
Add a single binding.
Definition: mythdbcon.cpp:889
bool next(void)
Wrap QSqlQuery::next() so we can display the query results.
Definition: mythdbcon.cpp:813
static MSqlQueryInfo InitCon(ConnectionReuse _reuse=kNormalConnection)
Only use this in combination with MSqlQuery constructor.
Definition: mythdbcon.cpp:551
void RunProlog(void)
Sets up a thread, call this if you reimplement run().
Definition: mthread.cpp:180
void start(QThread::Priority p=QThread::InheritPriority)
Tell MThread to start running the thread in the near future.
Definition: mthread.cpp:267
void quit(void)
calls exit(0)
Definition: mthread.cpp:279
void RunEpilog(void)
Cleans up a thread's resources, call this if you reimplement run().
Definition: mthread.cpp:193
QThread * qthread(void)
Returns the thread, this will always return the same pointer no matter how often you restart the thre...
Definition: mthread.cpp:217
void dispatch(const MythEvent &event)
static void DBError(const QString &where, const MSqlQuery &query)
Definition: mythdb.cpp:225
bool download(const QString &url, const QString &dest, bool reload=false)
Downloads a URL to a file in blocking mode.
This class is used as a container for messages.
Definition: mythevent.h:17
uint GetChanID(void) const
This is the unique key used in the database to locate tuning information.
Definition: programinfo.h:380
uint GetRecordingID(void) const
Definition: programinfo.h:457
QString GetDescription(void) const
Definition: programinfo.h:372
QString GetTitle(void) const
Definition: programinfo.h:368
QDateTime GetRecordingStartTime(void) const
Approximate time the recording started.
Definition: programinfo.h:412
QDateTime GetScheduledStartTime(void) const
The scheduled start time of program.
Definition: programinfo.h:398
RecStatus::Type GetRecordingStatus(void) const
Definition: programinfo.h:458
QDateTime GetRecordingEndTime(void) const
Approximate time the recording should have ended, did end, or is intended to end.
Definition: programinfo.h:420
QString GetSubtitle(void) const
Definition: programinfo.h:370
virtual QDateTime getNow()
Get the current time. Overridden by the testing code.
virtual bool timeIsClose(const QDateTime &eventStart)
Base Classes ///.
static bool getJsonObject(const QJsonObject &object, QStringList &path, QJsonObject &value)
Retrieve a specific object from another json object.
static bool getJsonArray(const QJsonObject &object, const QString &key, QJsonArray &value)
Retrieve the specified array from a json object.
static QJsonObject walkJsonPath(QJsonObject &object, const QStringList &path)
Iterate through a json object and return the specified object.
static bool getJsonInt(const QJsonObject &object, QStringList &path, int &value)
Retrieve the specified integer from a json object.
virtual bool findGameInfo(ActiveGame &game)=0
static bool getJsonString(const QJsonObject &object, QStringList &path, QString &value)
Retrieve the specified string from a json object.
QJsonDocument m_doc
RecExtDataSource * getSource()
static void clearCache()
Clear the downloaded document cache.
static QHash< QString, QJsonDocument > s_downloadedJson
A cache of downloaded documents.
GameState findGameScore(ActiveGame &game) override
Parse the previously downloaded data page for a given game.
static const QList< GameStatus > kFinalStatuses
A list of the ESPN status that mean the game is over.
bool findGameInfo(ActiveGame &game) override
Parse a previously downloaded data page for a given sport.
RecExtDataPage * loadPage(const ActiveGame &game, const QUrl &_url) override
Download the data page for a game, and do some minimal validation.
QUrl findInfoUrl(ActiveGame &game, SportInfo &info) override
Find the right URL for a specific recording.
RecExtDataPage * newPage(const QJsonDocument &doc) override
QUrl makeGameUrl(const ActiveGame &game, const QString &str) override
Create a URL for one specific game in the ESPN API that is built from the various known bits of data ...
QUrl makeInfoUrl(const SportInfo &info, const QDateTime &dt) override
Create a URL for the ESPN API that is built from the various known bits of data accumulated so far.
GameState findGameScore(ActiveGame &game) override
Parse the previously downloaded data page for a given game.
bool parseGameObject(const QJsonObject &gameObject, ActiveGame &game)
MLB ///.
bool findGameInfo(ActiveGame &game) override
Parse a previously downloaded data page for a given sport.
QUrl findInfoUrl(ActiveGame &game, SportInfo &info) override
Find the right URL for a specific recording.
QUrl makeInfoUrl(const SportInfo &info, const QDateTime &dt) override
Create a URL for the MLB API that is built from the various known bits of data accumulated so far.
RecExtDataPage * newPage(const QJsonDocument &doc) override
QUrl makeGameUrl(const ActiveGame &game, const QString &str) override
Create a URL for one specific game in the MLB API that is built from the various known bits of data a...
RecExtDataPage * loadPage(const ActiveGame &game, const QUrl &_url) override
Download the data page for a game, and do some minimal validation.
static QString toString(RecStatus::Type recstatus, uint id)
Converts "recstatus" into a short (unreadable) string.
virtual RecExtDataSource * createDataSource(AutoExtendType type)
Create a RecExtDataSource object for the specified service.
static void clearDownloadedInfo()
Clear all downloaded info.
void processNewRecordings()
Process the list of newly started sports recordings.
QMutex m_newRecordingsLock
New recordings are added by the scheduler process and removed by this process.
uint m_forcedYearforTesting
Testing data.
bool findKnownSport(const QString &_title, AutoExtendType type, SportInfoList &info) const
Retrieve the db record for a sporting event on a specific provider.
bool m_running
Whether the RecordingExtender process is running.
void addNewRecording(int recordedID)
Add an item to the list of new recordings.
static QString ruleIdAsString(const RecordingRule *rr)
Quick helper function for printing recording rule numbers.
void expireOverrides()
Delete the list of the override rules that have been created by this instance of RecordingExtender.
static void unchangedRecording(const RecordingInfo *ri, RecordingRule *rr, const ActiveGame &game)
Log that this recording hasn't changed.
static void nameCleanup(const SportInfo &info, QString &name1, QString &name2)
Clean up two team names for comparison against the ESPN API.
QList< int > m_overrideRules
Recordings that have had an override rule creates.
static void create(Scheduler *scheduler, RecordingInfo &ri)
Create an instance of the RecordingExtender if necessary, and add this recording to the list of new r...
void processActiveRecordings()
Process the currently active sports recordings.
Scheduler * m_scheduler
Pointer to the scheduler.
static QMutex s_createLock
Interlock the scheduler thread crating this process, and this process determining whether it should c...
static bool parseProgramInfo(const QString &subtitle, const QString &description, QString &team1, QString &team2)
Parse a RecordingInfo to find the team names.
QList< int > m_newRecordings
Newly started recordings to process.
void run(void) override
The main execution loop for the Recording Extender.
QList< ActiveGame > m_activeGames
Currently ongoing games to track.
static void finishRecording(const RecordingInfo *ri, RecordingRule *rr, const ActiveGame &game)
Stop the current recording early.
void extendRecording(const RecordingInfo *ri, RecordingRule *rr, const ActiveGame &game)
Extend the current recording by XX minutes.
void checkDone()
Is there any remaining work? Check for both newly created recording and for active recordings.
static RecordingExtender * s_singleton
The single instance of a RecordingExtender.
Holds information on a TV Program one might wish to record.
Definition: recordinginfo.h:36
RecordingRule * GetRecordingRule(void)
Returns the "record" field, creating it if necessary.
Internal representation of a recording rule, mirrors the record table.
Definition: recordingrule.h:30
RecordingType m_type
int m_recordID
Unique Recording Rule ID.
Definition: recordingrule.h:71
bool MakeOverride(void)
bool Save(bool sendSig=true)
QString m_subtitle
Definition: recordingrule.h:80
AutoExtendType m_autoExtend
QMap< QString, ProgramInfo * > GetRecording(void) const override
Definition: scheduler.cpp:1795
MythCoreContext * gCoreContext
This global variable contains the MythCoreContext instance for the app.
MythDownloadManager * GetMythDownloadManager(void)
Gets the pointer to the MythDownloadManager singleton.
#define LOG(_MASK_, _LEVEL_, _QSTRING_)
Definition: mythlogging.h:39
QString toString(const QDateTime &raw_dt, uint format)
Returns formatted string representing the time.
Definition: mythdate.cpp:93
@ ISODate
Default UTC.
Definition: mythdate.h:17
QDateTime fromString(const QString &dtstr)
Converts kFilename && kISODate formats to QDateTime.
Definition: mythdate.cpp:39
QDateTime current(bool stripped)
Returns current Date and Time in UTC.
Definition: mythdate.cpp:15
dictionary info
Definition: azlyrics.py:7
def error(message)
Definition: smolt.py:409
bool exists(str path)
Definition: xbmcvfs.py:51
#define LOC
static const QString espnInfoUrlFmt
ESPN ///.
static bool ValidRecordingStatus(RecStatus::Type recstatus)
Does this recording status indicate that the recording is still ongoing.
static constexpr int kExtensionTimeInSec
static QString normalizeString(const QString &s)
Remove all diacritical marks, etc., etc., from a string leaving just the base characters.
static const QString espnGameUrlFmt
static const QRegularExpression kSentencePattern
static constexpr std::chrono::minutes kExtensionTime
static bool parseProgramString(const QString &string, qsizetype limit, QString &team1, QString &team2)
static constexpr int64_t kLookForwardTime
static constexpr int64_t kLookBackTime
Does the specified time fall within -3/+1 hour from now?
static const QRegularExpression kVersusPattern
QList< SportInfo > SportInfoList
AutoExtendType
@ kOverrideRecord