391 changes: 391 additions & 0 deletions mythtv/programs/mythfrontend/services/mythfrontendservice.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,391 @@
// Qt
#include <QKeyEvent>
#include <QGlobalStatic>
#include <QCoreApplication>

// MythTV
#include "mythlogging.h"
#include "mythcorecontext.h"
#include "mythversion.h"
#include "keybindings.h"
#include "mythmainwindow.h"
#include "mythuistatetracker.h"
#include "recordinginfo.h"
#include "tv_actions.h"
#include "tv_play.h"
#include "mythuihelper.h"
#include "videoutils.h"
#include "videometadatalistmanager.h"
#include "services/mythfrontendservice.h"
#include "libmythbase/http/mythhttpmetaservice.h"

// Std
#include <algorithm>

#define LOC QString("FrontendServices: ")
using ActionDescs = QHash<QString,QStringList>;

/*! \class FrontendActions
*
* A private, static singleton that performs thread safe initialisation of the
* available actions when first required.
*/
class FrontendActions
{
public:
const QStringList& Actions() { return m_actions; }
const QStringList& SelectActions() { return m_selectActions; }
const QStringList& ValueActions() { return m_valueActions; }
const ActionDescs& Descriptions() { return m_actionDescriptions; }

FrontendActions()
{
// Build action and action descriptions
if (auto * bindings = new KeyBindings(gCoreContext->GetHostName()))
{
QStringList contexts = bindings->GetContexts();
contexts.sort();
for (const QString & context : qAsConst(contexts))
{
m_actionDescriptions[context].clear();
QStringList ctxactions = bindings->GetActions(context);
ctxactions.sort();
m_actions << ctxactions;
for (const QString & actions : qAsConst(ctxactions))
{
QString desc = actions + "," + bindings->GetActionDescription(context, actions);
m_actionDescriptions[context].append(desc);
}
}
delete bindings;
}
m_actions.removeDuplicates();
m_actions.sort();

// Build actions that have an implicit value (e.g. SELECTSUBTITLE_0)
for (const auto & action : m_actions)
if (action.startsWith("select", Qt::CaseInsensitive) && action.contains("_"))
m_selectActions.append(action);

// Hardcoded list of value actions
m_valueActions = QStringList{ ACTION_HANDLEMEDIA, ACTION_SETVOLUME,
ACTION_SETAUDIOSYNC, ACTION_SETBRIGHTNESS,
ACTION_SETCONTRAST, ACTION_SETCOLOUR,
ACTION_SETHUE, ACTION_JUMPCHAPTER,
ACTION_SWITCHTITLE, ACTION_SWITCHANGLE,
ACTION_SEEKABSOLUTE };
}

private:
QStringList m_actions;
QStringList m_selectActions;
QStringList m_valueActions;
QHash<QString,QStringList> m_actionDescriptions;
};

Q_GLOBAL_STATIC(FrontendActions, s_actions)

// This will be initialised in a thread safe manner on first use
Q_GLOBAL_STATIC_WITH_ARGS(MythHTTPMetaService, s_service,
(FRONTEND_HANDLE, MythFrontendService::staticMetaObject, std::bind(&MythFrontendService::RegisterCustomTypes)))

void MythFrontendService::RegisterCustomTypes()
{
qRegisterMetaType<FrontendStatus*>("FrontendStatus");
qRegisterMetaType<FrontendActionList*>("FrontendActionList");
}

MythFrontendService::MythFrontendService()
: MythHTTPService(s_service)
{
}

FrontendStatus* MythFrontendService::GetStatus()
{
QVariantMap state;
MythUIStateTracker::GetFreshState(state);
FrontendStatus* result = new FrontendStatus(gCoreContext->GetHostName(), GetMythSourceVersion(), state);
return result;
}

bool MythFrontendService::SendKey(const QString& Key)
{
if (Key.isEmpty() || !HasMythMainWindow())
return false;

int keycode = 0;
bool valid = false;
QString key = Key.toLower();

// We could statically initialise this mapping of the Qt::Key enum but clients
// should really just use actions...
QMetaEnum meta = QMetaEnum::fromType<Qt::Key>();
for (int i = 0; i < meta.keyCount(); i++)
{
if (QByteArray(meta.key(i)).mid(4).toLower() == key)
{
keycode = meta.value(i);
valid = true;
break;
}
}

if (!valid && key.size() == 1)
{
keycode = key.toLatin1()[0] & 0x7f;
valid = true;
}

if (!valid)
{
LOG(VB_GENERAL, LOG_WARNING, LOC + QString("Unknown key: '%1'").arg(Key));
return false;
}

MythMainWindow::ResetScreensaver();
auto mainwindow = GetMythMainWindow();
auto event1 = new QKeyEvent(QEvent::KeyPress, keycode, Qt::NoModifier, "");
QCoreApplication::postEvent(mainwindow, event1);
auto event2 = new QKeyEvent(QEvent::KeyRelease, keycode, Qt::NoModifier, "");
QCoreApplication::postEvent(mainwindow, event2);
LOG(VB_HTTP, LOG_INFO, LOC + QString("Sent key: '%1'").arg(Key));
return true;
}

bool MythFrontendService::SendMessage(const QString& Message, uint Timeout)
{
if (Message.isEmpty())
return false;
QStringList data(QString::number(std::clamp(Timeout, 0U, 1000U)));
qApp->postEvent(GetMythMainWindow(), new MythEvent(MythEvent::MythUserMessage, Message, data));
return true;
}

/*! \brief Send a notification to the frontend.
*
* \note Complete use of this method (i.e. using all available parameters) will
* probably not work as expected as Qt's invoke calls are limited to 10 parameters
* (one of which is the return type).
*/
bool MythFrontendService::SendNotification(bool Error, const QString& Type,
const QString& Message, const QString& Origin,
const QString& Description, const QString& Image,
const QString& Extra, const QString& ProgressText,
float Progress, int Timeout,
bool Fullscreen, uint Visibility,
uint Priority)
{
if (Message.isEmpty() || !GetNotificationCenter())
return false;

ShowNotification(Error ? MythNotification::Error : MythNotification::TypeFromString(Type),
Message, Origin.isNull() ? tr("FrontendServices") : Origin,
Description, Image, Extra, ProgressText, Progress, Timeout,
Fullscreen, Visibility, static_cast<MythNotification::Priority>(Priority));
return true;
}

FrontendActionList* MythFrontendService::GetActionList(const QString& Context)
{
QVariantMap result;
QHashIterator<QString,QStringList> contexts(s_actions->Descriptions());
while (contexts.hasNext())
{
contexts.next();
if (!Context.isEmpty() && contexts.key() != Context)
continue;

// TODO can we keep the context data with QMap<QString, QStringList>?
QStringList actions = contexts.value();
for (const QString & action : qAsConst(actions))
{
QStringList split = action.split(",");
if (split.size() == 2)
result.insert(split[0], split[1]);
}
}
return new FrontendActionList(result);
}

QStringList MythFrontendService::GetContextList()
{
return s_actions->Descriptions().keys();
}

bool MythFrontendService::SendAction(const QString& Action, const QString& Value, uint Width, uint Height)
{
if (!HasMythMainWindow() || !IsValidAction(Action))
return false;

if (!Value.isEmpty() && s_actions->ValueActions().contains(Action))
{
MythMainWindow::ResetScreensaver();
auto * me = new MythEvent(Action, QStringList(Value));
qApp->postEvent(GetMythMainWindow(), me);
return true;
}

if (ACTION_SCREENSHOT == Action)
{
if (!Width || !Height)
{
LOG(VB_GENERAL, LOG_ERR, LOC + "Invalid screenshot parameters.");
return false;
}

QStringList args;
args << QString::number(Width) << QString::number(Height);
auto* me = new MythEvent(Action, args);
qApp->postEvent(GetMythMainWindow(), me);
return true;
}

MythMainWindow::ResetScreensaver();
qApp->postEvent(GetMythMainWindow(), new QKeyEvent(QEvent::KeyPress, 0, Qt::NoModifier, Action));
return true;
}

bool MythFrontendService::IsValidAction(const QString& Action)
{
if (s_actions->Actions().contains(Action) || s_actions->SelectActions().contains(Action))
return true;
LOG(VB_GENERAL, LOG_ERR, LOC + QString("Action '%1'' is invalid.").arg(Action));
return false;
}

bool MythFrontendService::PlayRecording(int RecordedId, int ChanId, const QDateTime& StartTime)
{
QDateTime starttime = StartTime;

if ((RecordedId <= 0) && (ChanId <= 0 || !StartTime.isValid()))
{
LOG(VB_GENERAL, LOG_INFO, LOC + "Recorded ID or Channel ID and StartTime appears invalid.");
return false;
}

if (RecordedId > 0)
{
RecordingInfo recInfo = RecordingInfo(static_cast<uint>(RecordedId));
ChanId = static_cast<int>(recInfo.GetChanID());
starttime = recInfo.GetRecordingStartTime();
}

// Note: This is inconsistent with PlayVideo behaviour - which checks for
// a current instance of TV and returns false if something is already playing.
if (GetMythUI()->GetCurrentLocation().toLower() == "playback")
{
QString message = QString("NETWORK_CONTROL STOP");
MythEvent me(message);
gCoreContext->dispatch(me);

QElapsedTimer timer;
timer.start();
while (!timer.hasExpired(10000) && (GetMythUI()->GetCurrentLocation().toLower() == "playback"))
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}

if (GetMythUI()->GetCurrentLocation().toLower() != "playbackbox")
{
GetMythMainWindow()->JumpTo("TV Recording Playback");
QElapsedTimer timer;
timer.start();
while (!timer.hasExpired(10000) && (GetMythUI()->GetCurrentLocation().toLower() != "playbackbox"))
std::this_thread::sleep_for(std::chrono::milliseconds(10));

timer.start();
while (!timer.hasExpired(10000) && (!MythMainWindow::IsTopScreenInitialized()))
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}

if (GetMythUI()->GetCurrentLocation().toLower() == "playbackbox")
{
LOG(VB_GENERAL, LOG_INFO, LOC + QString("PlayRecording, ChanID: %1 StartTime: %2")
.arg(ChanId).arg(starttime.toString(Qt::ISODate)));

QString message = QString("NETWORK_CONTROL PLAY PROGRAM %1 %2 %3")
.arg(ChanId).arg(starttime.toString("yyyyMMddhhmmss")).arg("12345");

MythEvent me(message);
gCoreContext->dispatch(me);
return true;
}

return false;
}

bool MythFrontendService::PlayVideo(const QString& Id, bool UseBookmark)
{
if (TV::IsTVRunning())
{
LOG(VB_GENERAL, LOG_WARNING, LOC + QString("Ignoring PlayVideo request - frontend is busy."));
return false;
}

bool ok = false;
uint id = Id.toUInt(&ok);
if (!ok)
{
LOG(VB_GENERAL, LOG_WARNING, LOC + QString("Invalid video Id."));
return false;
}

auto metadata = VideoMetadataListManager::loadOneFromDatabase(id);
if (!metadata)
{
LOG(VB_GENERAL, LOG_WARNING, LOC + QString("Didn't find any video metadata."));
return false;
}

if (metadata->GetHost().isEmpty())
{
LOG(VB_GENERAL, LOG_WARNING, LOC + QString("No host for video."));
return false;
}

QString mrl = generate_file_url("Videos", metadata->GetHost(), metadata->GetFilename());
LOG(VB_GENERAL, LOG_INFO, LOC + QString("PlayVideo ID: %1 UseBookmark: %2 URL: '%3'")
.arg(id).arg(UseBookmark).arg(mrl));

QStringList args;
args << mrl << metadata->GetPlot() << metadata->GetTitle()
<< metadata->GetSubtitle() << metadata->GetDirector()
<< QString::number(metadata->GetSeason())
<< QString::number(metadata->GetEpisode())
<< metadata->GetInetRef() << QString::number(metadata->GetLength())
<< QString::number(metadata->GetYear())
<< QString::number(metadata->GetID())
<< QString::number(static_cast<int>(UseBookmark));

auto * me = new MythEvent(ACTION_HANDLEMEDIA, args);
qApp->postEvent(GetMythMainWindow(), me);
return true;
}

FrontendStatus::FrontendStatus(const QString& Name, const QString& Version, const QVariantMap& State)
: m_name(Name),
m_version(Version),
m_state(State)
{
if (m_state.contains("chaptertimes") && m_state["chaptertimes"].type() == QVariant::List)
{
m_chapterTimes = m_state["chaptertimes"].toList();
m_state.remove("chaptertimes");
}

if (m_state.contains("subtitletracks") && m_state["subtitletracks"].type() == QVariant::Map)
{
m_subtitleTracks = m_state["subtitletracks"].toMap();
m_state.remove("subtitletracks");
}

if (m_state.contains("audiotracks") && m_state["audiotracks"].type() == QVariant::Map)
{
m_audioTracks = m_state["audiotracks"].toMap();
m_state.remove("audiotracks");
}
}

FrontendActionList::FrontendActionList(const QVariantMap& List)
: m_actionList(List)
{
}
85 changes: 85 additions & 0 deletions mythtv/programs/mythfrontend/services/mythfrontendservice.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#ifndef MYTHFRONTENDSERVICE_H
#define MYTHFRONTENDSERVICE_H

// MythTV
#include "libmythbase/http/mythhttpservice.h"

#define FRONTEND_SERVICE QString("/Frontend/")
#define FRONTEND_HANDLE QString("Frontend")

class FrontendStatus : public QObject
{
Q_OBJECT
Q_CLASSINFO("Version", "1.1")
Q_CLASSINFO("State", "type=QString")
Q_CLASSINFO("ChapterTimes", "type=QString;name=Chapter")
Q_CLASSINFO("SubtitleTracks", "type=QString;name=Track")
Q_CLASSINFO("AudioTracks", "type=QString;name=Track")
SERVICE_PROPERTY(QString, Name, name)
SERVICE_PROPERTY(QString, Version, version)
SERVICE_PROPERTY(QVariantMap, State, state)
SERVICE_PROPERTY(QVariantList, ChapterTimes, chapterTimes)
SERVICE_PROPERTY(QVariantMap, SubtitleTracks, subtitleTracks)
SERVICE_PROPERTY(QVariantMap, AudioTracks, audioTracks)

public:
FrontendStatus(const QString& Name, const QString& Version, const QVariantMap& State);
};

Q_DECLARE_METATYPE(FrontendStatus*)

class FrontendActionList : public QObject
{
Q_OBJECT
Q_CLASSINFO("Version", "1.0")
Q_CLASSINFO("ActionList", "type=QString;name=Action")
SERVICE_PROPERTY(QVariantMap, ActionList, actionList)

public:
FrontendActionList(const QVariantMap& List);
};

Q_DECLARE_METATYPE(FrontendActionList*)

class MythFrontendService : public MythHTTPService
{
Q_OBJECT
Q_CLASSINFO("Version", "1.0")
Q_CLASSINFO("SendAction", "methods=POST")
Q_CLASSINFO("SendKey", "methods=POST")
Q_CLASSINFO("PlayVideo", "methods=POST")
Q_CLASSINFO("PlayRecording", "methods=POST")
Q_CLASSINFO("SendMessage", "methods=POST")
Q_CLASSINFO("SendNotification", "methods=POST")
Q_CLASSINFO("GetContextList", "name=StringList") // Consistency with old code

public slots:
bool SendAction (const QString& Action, const QString& Value, uint Width, uint Height);
bool SendKey (const QString& Key);
FrontendActionList* GetActionList(const QString& Context);
QStringList GetContextList ();
FrontendStatus* GetStatus ();
bool PlayVideo (const QString& Id, bool UseBookmark);
bool PlayRecording (int RecordedId, int ChanId, const QDateTime& StartTime);
bool SendMessage (const QString& Message, uint Timeout);
bool SendNotification(bool Error, const QString& Type,
const QString& Message, const QString& Origin,
const QString& Description, const QString& Image,
const QString& Extra, const QString& ProgressText,
float Progress, int Timeout,
bool Fullscreen, uint Visibility,
uint Priority);

public:
MythFrontendService();
~MythFrontendService() override = default;
static void RegisterCustomTypes();

protected:
static bool IsValidAction(const QString& Action);

private:
Q_DISABLE_COPY(MythFrontendService)
};

#endif