/* * svdrp.c: Simple Video Disk Recorder Protocol * * See the main source file 'vdr.c' for copyright information and * how to reach the author. * * The "Simple Video Disk Recorder Protocol" (SVDRP) was inspired * by the "Simple Mail Transfer Protocol" (SMTP) and is fully ASCII * text based. Therefore you can simply 'telnet' to your VDR port * and interact with the Video Disk Recorder - or write a full featured * graphical interface that sits on top of an SVDRP connection. * * $Id: svdrp.c 5.7 2023/02/16 17:20:09 kls Exp $ */ #include "svdrp.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include "channels.h" #include "config.h" #include "device.h" #include "eitscan.h" #include "keys.h" #include "menu.h" #include "plugin.h" #include "recording.h" #include "remote.h" #include "skins.h" #include "timers.h" #include "videodir.h" static bool DumpSVDRPDataTransfer = false; #define dbgsvdrp(a...) if (DumpSVDRPDataTransfer) fprintf(stderr, a) static int SVDRPTcpPort = 0; static int SVDRPUdpPort = 0; enum eSvdrpFetchFlags { sffNone = 0b00000000, sffConn = 0b00000001, sffPing = 0b00000010, sffTimers = 0b00000100, }; // --- cIpAddress ------------------------------------------------------------ class cIpAddress { private: cString address; int port; cString connection; public: cIpAddress(void); cIpAddress(const char *Address, int Port); const char *Address(void) const { return address; } int Port(void) const { return port; } void Set(const char *Address, int Port); void Set(const sockaddr *SockAddr); const char *Connection(void) const { return connection; } }; cIpAddress::cIpAddress(void) { Set(INADDR_ANY, 0); } cIpAddress::cIpAddress(const char *Address, int Port) { Set(Address, Port); } void cIpAddress::Set(const char *Address, int Port) { address = Address; port = Port; connection = cString::sprintf("%s:%d", *address, port); } void cIpAddress::Set(const sockaddr *SockAddr) { const sockaddr_in *Addr = (sockaddr_in *)SockAddr; Set(inet_ntoa(Addr->sin_addr), ntohs(Addr->sin_port)); } // --- cSocket --------------------------------------------------------------- #define MAXUDPBUF 1024 class cSocket { private: int port; bool tcp; int sock; cIpAddress lastIpAddress; public: cSocket(int Port, bool Tcp); ~cSocket(); bool Listen(void); bool Connect(const char *Address); void Close(void); int Port(void) const { return port; } int Socket(void) const { return sock; } static bool SendDgram(const char *Dgram, int Port); int Accept(void); cString Discover(void); const cIpAddress *LastIpAddress(void) const { return &lastIpAddress; } }; cSocket::cSocket(int Port, bool Tcp) { port = Port; tcp = Tcp; sock = -1; } cSocket::~cSocket() { Close(); } void cSocket::Close(void) { if (sock >= 0) { close(sock); sock = -1; } } bool cSocket::Listen(void) { if (sock < 0) { isyslog("SVDRP %s opening port %d/%s", Setup.SVDRPHostName, port, tcp ? "tcp" : "udp"); // create socket: sock = tcp ? socket(PF_INET, SOCK_STREAM, IPPROTO_IP) : socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP); if (sock < 0) { LOG_ERROR; return false; } // allow it to always reuse the same port: int ReUseAddr = 1; setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &ReUseAddr, sizeof(ReUseAddr)); // configure port and ip: sockaddr_in Addr; memset(&Addr, 0, sizeof(Addr)); Addr.sin_family = AF_INET; Addr.sin_port = htons(port); Addr.sin_addr.s_addr = SVDRPhosts.LocalhostOnly() ? htonl(INADDR_LOOPBACK) : htonl(INADDR_ANY); if (bind(sock, (sockaddr *)&Addr, sizeof(Addr)) < 0) { LOG_ERROR; Close(); return false; } // make it non-blocking: int Flags = fcntl(sock, F_GETFL, 0); if (Flags < 0) { LOG_ERROR; return false; } Flags |= O_NONBLOCK; if (fcntl(sock, F_SETFL, Flags) < 0) { LOG_ERROR; return false; } if (tcp) { // listen to the socket: if (listen(sock, 1) < 0) { LOG_ERROR; return false; } } isyslog("SVDRP %s listening on port %d/%s", Setup.SVDRPHostName, port, tcp ? "tcp" : "udp"); } return true; } bool cSocket::Connect(const char *Address) { if (sock < 0 && tcp) { // create socket: sock = socket(PF_INET, SOCK_STREAM, IPPROTO_IP); if (sock < 0) { LOG_ERROR; return false; } // configure port and ip: sockaddr_in Addr; memset(&Addr, 0, sizeof(Addr)); Addr.sin_family = AF_INET; Addr.sin_port = htons(port); Addr.sin_addr.s_addr = inet_addr(Address); if (connect(sock, (sockaddr *)&Addr, sizeof(Addr)) < 0) { LOG_ERROR; Close(); return false; } // make it non-blocking: int Flags = fcntl(sock, F_GETFL, 0); if (Flags < 0) { LOG_ERROR; return false; } Flags |= O_NONBLOCK; if (fcntl(sock, F_SETFL, Flags) < 0) { LOG_ERROR; return false; } dbgsvdrp("> %s:%d server connection established\n", Address, port); isyslog("SVDRP %s > %s:%d server connection established", Setup.SVDRPHostName, Address, port); return true; } return false; } bool cSocket::SendDgram(const char *Dgram, int Port) { // Create a socket: int Socket = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP); if (Socket < 0) { LOG_ERROR; return false; } // Enable broadcast: int One = 1; if (setsockopt(Socket, SOL_SOCKET, SO_BROADCAST, &One, sizeof(One)) < 0) { LOG_ERROR; close(Socket); return false; } // Configure port and ip: sockaddr_in Addr; memset(&Addr, 0, sizeof(Addr)); Addr.sin_family = AF_INET; Addr.sin_addr.s_addr = htonl(INADDR_BROADCAST); Addr.sin_port = htons(Port); // Send datagram: dbgsvdrp("> %s:%d %s\n", inet_ntoa(Addr.sin_addr), Port, Dgram); dsyslog("SVDRP %s > %s:%d send dgram '%s'", Setup.SVDRPHostName, inet_ntoa(Addr.sin_addr), Port, Dgram); int Length = strlen(Dgram); int Sent = sendto(Socket, Dgram, Length, 0, (sockaddr *)&Addr, sizeof(Addr)); if (Sent < 0) LOG_ERROR; close(Socket); return Sent == Length; } int cSocket::Accept(void) { if (sock >= 0 && tcp) { sockaddr_in Addr; uint Size = sizeof(Addr); int NewSock = accept(sock, (sockaddr *)&Addr, &Size); if (NewSock >= 0) { bool Accepted = SVDRPhosts.Acceptable(Addr.sin_addr.s_addr); if (!Accepted) { const char *s = "Access denied!\n"; if (write(NewSock, s, strlen(s)) < 0) LOG_ERROR; close(NewSock); NewSock = -1; } lastIpAddress.Set((sockaddr *)&Addr); dbgsvdrp("< %s client connection %s\n", lastIpAddress.Connection(), Accepted ? "accepted" : "DENIED"); isyslog("SVDRP %s < %s client connection %s", Setup.SVDRPHostName, lastIpAddress.Connection(), Accepted ? "accepted" : "DENIED"); } else if (FATALERRNO) LOG_ERROR; return NewSock; } return -1; } cString cSocket::Discover(void) { if (sock >= 0 && !tcp) { char buf[MAXUDPBUF]; sockaddr_in Addr; uint Size = sizeof(Addr); int NumBytes = recvfrom(sock, buf, sizeof(buf), 0, (sockaddr *)&Addr, &Size); if (NumBytes >= 0) { buf[NumBytes] = 0; lastIpAddress.Set((sockaddr *)&Addr); if (!SVDRPhosts.Acceptable(Addr.sin_addr.s_addr)) { dsyslog("SVDRP %s < %s discovery ignored (%s)", Setup.SVDRPHostName, lastIpAddress.Connection(), buf); return NULL; } if (!startswith(buf, "SVDRP:discover")) { dsyslog("SVDRP %s < %s discovery unrecognized (%s)", Setup.SVDRPHostName, lastIpAddress.Connection(), buf); return NULL; } if (strcmp(strgetval(buf, "name", ':'), Setup.SVDRPHostName) != 0) { // ignore our own broadcast dbgsvdrp("< %s discovery received (%s)\n", lastIpAddress.Connection(), buf); isyslog("SVDRP %s < %s discovery received (%s)", Setup.SVDRPHostName, lastIpAddress.Connection(), buf); return buf; } } else if (FATALERRNO) LOG_ERROR; } return NULL; } // --- cSVDRPClient ---------------------------------------------------------- class cSVDRPClient { private: cIpAddress serverIpAddress; cSocket socket; cString serverName; int length; char *input; int timeout; cTimeMs pingTime; cFile file; int fetchFlags; bool connected; bool Send(const char *Command); void Close(void); public: cSVDRPClient(const char *Address, int Port, const char *ServerName, int Timeout); ~cSVDRPClient(); const char *ServerName(void) const { return serverName; } const char *Connection(void) const { return serverIpAddress.Connection(); } bool HasAddress(const char *Address, int Port) const; bool Process(cStringList *Response = NULL); bool Execute(const char *Command, cStringList *Response = NULL); bool Connected(void) const { return connected; } void SetFetchFlag(int Flag); bool HasFetchFlag(int Flag); bool GetRemoteTimers(cStringList &Response); }; static cPoller SVDRPClientPoller; cSVDRPClient::cSVDRPClient(const char *Address, int Port, const char *ServerName, int Timeout) :serverIpAddress(Address, Port) ,socket(Port, true) { serverName = ServerName; length = BUFSIZ; input = MALLOC(char, length); timeout = Timeout * 1000 * 9 / 10; // ping after 90% of timeout pingTime.Set(timeout); fetchFlags = sffNone; connected = false; if (socket.Connect(Address)) { if (file.Open(socket.Socket())) { SVDRPClientPoller.Add(file, false); dsyslog("SVDRP %s > %s client created for '%s'", Setup.SVDRPHostName, serverIpAddress.Connection(), *serverName); return; } } esyslog("SVDRP %s > %s ERROR: failed to create client for '%s'", Setup.SVDRPHostName, serverIpAddress.Connection(), *serverName); } cSVDRPClient::~cSVDRPClient() { Close(); free(input); dsyslog("SVDRP %s > %s client destroyed for '%s'", Setup.SVDRPHostName, serverIpAddress.Connection(), *serverName); } void cSVDRPClient::Close(void) { if (file.IsOpen()) { SVDRPClientPoller.Del(file, false); file.Close(); socket.Close(); } } bool cSVDRPClient::HasAddress(const char *Address, int Port) const { return strcmp(serverIpAddress.Address(), Address) == 0 && serverIpAddress.Port() == Port; } bool cSVDRPClient::Send(const char *Command) { pingTime.Set(timeout); dbgsvdrp("> C %s: %s\n", *serverName, Command); if (safe_write(file, Command, strlen(Command) + 1) < 0) { LOG_ERROR; return false; } return true; } bool cSVDRPClient::Process(cStringList *Response) { if (file.IsOpen()) { int numChars = 0; #define SVDRPResonseTimeout 5000 // ms cTimeMs Timeout(SVDRPResonseTimeout); for (;;) { if (file.Ready(false)) { unsigned char c; int r = safe_read(file, &c, 1); if (r > 0) { if (c == '\n' || c == 0x00) { // strip trailing whitespace: while (numChars > 0 && strchr(" \t\r\n", input[numChars - 1])) input[--numChars] = 0; // make sure the string is terminated: input[numChars] = 0; dbgsvdrp("< C %s: %s\n", *serverName, input); if (Response) Response->Append(strdup(input)); else { switch (atoi(input)) { case 220: if (numChars > 4) { char *n = input + 4; if (char *t = strchr(n, ' ')) { *t = 0; if (strcmp(n, serverName) != 0) { serverName = n; dsyslog("SVDRP %s < %s remote server name is '%s'", Setup.SVDRPHostName, serverIpAddress.Connection(), *serverName); } SetFetchFlag(sffConn | sffTimers); connected = true; } } break; case 221: dsyslog("SVDRP %s < %s remote server closed connection to '%s'", Setup.SVDRPHostName, serverIpAddress.Connection(), *serverName); connected = false; Close(); break; } } if (numChars >= 4 && input[3] != '-') // no more lines will follow break; numChars = 0; } else { if (numChars >= length - 1) { int NewLength = length + BUFSIZ; if (char *NewBuffer = (char *)realloc(input, NewLength)) { length = NewLength; input = NewBuffer; } else { esyslog("SVDRP %s < %s ERROR: out of memory", Setup.SVDRPHostName, serverIpAddress.Connection()); Close(); break; } } input[numChars++] = c; input[numChars] = 0; } Timeout.Set(SVDRPResonseTimeout); } else if (r <= 0) { isyslog("SVDRP %s < %s lost connection to remote server '%s'", Setup.SVDRPHostName, serverIpAddress.Connection(), *serverName); Close(); return false; } } else if (Timeout.TimedOut()) { esyslog("SVDRP %s < %s timeout while waiting for response from '%s'", Setup.SVDRPHostName, serverIpAddress.Connection(), *serverName); return false; } else if (!Response && numChars == 0) break; // we read all or nothing! } if (pingTime.TimedOut()) SetFetchFlag(sffPing); } return file.IsOpen(); } bool cSVDRPClient::Execute(const char *Command, cStringList *Response) { cStringList Dummy; if (Response) Response->Clear(); else Response = &Dummy; return Send(Command) && Process(Response); } void cSVDRPClient::SetFetchFlag(int Flags) { fetchFlags |= Flags; } bool cSVDRPClient::HasFetchFlag(int Flag) { bool Result = (fetchFlags & Flag); fetchFlags &= ~Flag; return Result; } bool cSVDRPClient::GetRemoteTimers(cStringList &Response) { if (Execute("LSTT ID", &Response)) { for (int i = 0; i < Response.Size(); i++) { char *s = Response[i]; int Code = SVDRPCode(s); if (Code == 250) strshift(s, 4); else if (Code == 550) Response.Clear(); else { esyslog("ERROR: %s: %s", ServerName(), s); return false; } } Response.SortNumerically(); return true; } return false; } // --- cSVDRPServerParams ---------------------------------------------------- class cSVDRPServerParams { private: cString name; int port; cString vdrversion; cString apiversion; int timeout; cString host; cString error; public: cSVDRPServerParams(const char *Params); const char *Name(void) const { return name; } const int Port(void) const { return port; } const char *VdrVersion(void) const { return vdrversion; } const char *ApiVersion(void) const { return apiversion; } const int Timeout(void) const { return timeout; } const char *Host(void) const { return host; } bool Ok(void) const { return !*error; } const char *Error(void) const { return error; } }; cSVDRPServerParams::cSVDRPServerParams(const char *Params) { if (Params && *Params) { name = strgetval(Params, "name", ':'); if (*name) { cString p = strgetval(Params, "port", ':'); if (*p) { port = atoi(p); vdrversion = strgetval(Params, "vdrversion", ':'); if (*vdrversion) { apiversion = strgetval(Params, "apiversion", ':'); if (*apiversion) { cString t = strgetval(Params, "timeout", ':'); if (*t) { timeout = atoi(t); if (timeout > 10) { // don't let it get too small host = strgetval(Params, "host", ':'); // no error if missing - this parameter is optional! } else error = "invalid timeout"; } else error = "missing server timeout"; } else error = "missing server apiversion"; } else error = "missing server vdrversion"; } else error = "missing server port"; } else error = "missing server name"; } else error = "missing server parameters"; } // --- cSVDRPClientHandler --------------------------------------------------- cStateKey StateKeySVDRPRemoteTimersPoll(true); class cSVDRPClientHandler : public cThread { private: cMutex mutex; int tcpPort; cSocket udpSocket; cVector clientConnections; void SendDiscover(void); void HandleClientConnection(void); void ProcessConnections(void); cSVDRPClient *GetClientForServer(const char *ServerName); protected: virtual void Action(void); public: cSVDRPClientHandler(int TcpPort, int UdpPort); virtual ~cSVDRPClientHandler(); void AddClient(cSVDRPServerParams &ServerParams, const char *IpAddress); bool Execute(const char *ServerName, const char *Command, cStringList *Response = NULL); bool GetServerNames(cStringList *ServerNames); bool TriggerFetchingTimers(const char *ServerName); }; static cSVDRPClientHandler *SVDRPClientHandler = NULL; cSVDRPClientHandler::cSVDRPClientHandler(int TcpPort, int UdpPort) :cThread("SVDRP client handler", true) ,udpSocket(UdpPort, false) { tcpPort = TcpPort; } cSVDRPClientHandler::~cSVDRPClientHandler() { Cancel(3); for (int i = 0; i < clientConnections.Size(); i++) delete clientConnections[i]; } cSVDRPClient *cSVDRPClientHandler::GetClientForServer(const char *ServerName) { for (int i = 0; i < clientConnections.Size(); i++) { if (strcmp(clientConnections[i]->ServerName(), ServerName) == 0) return clientConnections[i]; } return NULL; } void cSVDRPClientHandler::SendDiscover(void) { cString Dgram = cString::sprintf("SVDRP:discover name:%s port:%d vdrversion:%d apiversion:%d timeout:%d%s", Setup.SVDRPHostName, tcpPort, VDRVERSNUM, APIVERSNUM, Setup.SVDRPTimeout, (Setup.SVDRPPeering == spmOnly && *Setup.SVDRPDefaultHost) ? *cString::sprintf(" host:%s", Setup.SVDRPDefaultHost) : ""); udpSocket.SendDgram(Dgram, udpSocket.Port()); } void cSVDRPClientHandler::ProcessConnections(void) { cString PollTimersCmd; if (cTimers::GetTimersRead(StateKeySVDRPRemoteTimersPoll, 100)) { PollTimersCmd = cString::sprintf("POLL %s TIMERS", Setup.SVDRPHostName); StateKeySVDRPRemoteTimersPoll.Remove(); } else if (StateKeySVDRPRemoteTimersPoll.TimedOut()) return; // try again next time for (int i = 0; i < clientConnections.Size(); i++) { cSVDRPClient *Client = clientConnections[i]; if (Client->Process()) { if (Client->HasFetchFlag(sffConn)) Client->Execute(cString::sprintf("CONN name:%s port:%d vdrversion:%d apiversion:%d timeout:%d", Setup.SVDRPHostName, SVDRPTcpPort, VDRVERSNUM, APIVERSNUM, Setup.SVDRPTimeout)); if (Client->HasFetchFlag(sffPing)) Client->Execute("PING"); if (Client->HasFetchFlag(sffTimers)) { cStringList RemoteTimers; if (Client->GetRemoteTimers(RemoteTimers)) { if (cTimers *Timers = cTimers::GetTimersWrite(StateKeySVDRPRemoteTimersPoll, 100)) { bool TimersModified = Timers->StoreRemoteTimers(Client->ServerName(), &RemoteTimers); StateKeySVDRPRemoteTimersPoll.Remove(TimersModified); } else Client->SetFetchFlag(sffTimers); // try again next time } } if (*PollTimersCmd) { if (!Client->Execute(PollTimersCmd)) esyslog("ERROR: can't send '%s' to '%s'", *PollTimersCmd, Client->ServerName()); } } else { cTimers *Timers = cTimers::GetTimersWrite(StateKeySVDRPRemoteTimersPoll); bool TimersModified = Timers->StoreRemoteTimers(Client->ServerName(), NULL); StateKeySVDRPRemoteTimersPoll.Remove(TimersModified); delete Client; clientConnections.Remove(i); i--; } } } void cSVDRPClientHandler::AddClient(cSVDRPServerParams &ServerParams, const char *IpAddress) { cMutexLock MutexLock(&mutex); for (int i = 0; i < clientConnections.Size(); i++) { if (clientConnections[i]->HasAddress(IpAddress, ServerParams.Port())) return; } if (Setup.SVDRPPeering == spmOnly && strcmp(ServerParams.Name(), Setup.SVDRPDefaultHost) != 0) return; // we only want to peer with the default host, but this isn't the default host if (ServerParams.Host() && strcmp(ServerParams.Host(), Setup.SVDRPHostName) != 0) return; // the remote VDR requests a specific host, but it's not us clientConnections.Append(new cSVDRPClient(IpAddress, ServerParams.Port(), ServerParams.Name(), ServerParams.Timeout())); } void cSVDRPClientHandler::HandleClientConnection(void) { cString NewDiscover = udpSocket.Discover(); if (*NewDiscover) { cSVDRPServerParams ServerParams(NewDiscover); if (ServerParams.Ok()) AddClient(ServerParams, udpSocket.LastIpAddress()->Address()); else esyslog("SVDRP %s < %s ERROR: %s", Setup.SVDRPHostName, udpSocket.LastIpAddress()->Connection(), ServerParams.Error()); } } void cSVDRPClientHandler::Action(void) { if (udpSocket.Listen()) { SVDRPClientPoller.Add(udpSocket.Socket(), false); SendDiscover(); while (Running()) { SVDRPClientPoller.Poll(1000); cMutexLock MutexLock(&mutex); HandleClientConnection(); ProcessConnections(); } SVDRPClientPoller.Del(udpSocket.Socket(), false); udpSocket.Close(); } } bool cSVDRPClientHandler::Execute(const char *ServerName, const char *Command, cStringList *Response) { cMutexLock MutexLock(&mutex); if (cSVDRPClient *Client = GetClientForServer(ServerName)) return Client->Execute(Command, Response); return false; } bool cSVDRPClientHandler::GetServerNames(cStringList *ServerNames) { cMutexLock MutexLock(&mutex); ServerNames->Clear(); for (int i = 0; i < clientConnections.Size(); i++) { cSVDRPClient *Client = clientConnections[i]; if (Client->Connected()) ServerNames->Append(strdup(Client->ServerName())); } return ServerNames->Size() > 0; } bool cSVDRPClientHandler::TriggerFetchingTimers(const char *ServerName) { cMutexLock MutexLock(&mutex); if (cSVDRPClient *Client = GetClientForServer(ServerName)) { Client->SetFetchFlag(sffTimers); return true; } return false; } // --- cPUTEhandler ---------------------------------------------------------- class cPUTEhandler { private: FILE *f; int status; const char *message; public: cPUTEhandler(void); ~cPUTEhandler(); bool Process(const char *s); int Status(void) { return status; } const char *Message(void) { return message; } }; cPUTEhandler::cPUTEhandler(void) { if ((f = tmpfile()) != NULL) { status = 354; message = "Enter EPG data, end with \".\" on a line by itself"; } else { LOG_ERROR; status = 554; message = "Error while opening temporary file"; } } cPUTEhandler::~cPUTEhandler() { if (f) fclose(f); } bool cPUTEhandler::Process(const char *s) { if (f) { if (strcmp(s, ".") != 0) { fputs(s, f); fputc('\n', f); return true; } else { rewind(f); if (cSchedules::Read(f)) { cSchedules::Cleanup(true); status = 250; message = "EPG data processed"; } else { status = 451; message = "Error while processing EPG data"; } fclose(f); f = NULL; } } return false; } // --- cSVDRPServer ---------------------------------------------------------- #define MAXHELPTOPIC 10 #define EITDISABLETIME 10 // seconds until EIT processing is enabled again after a CLRE command // adjust the help for CLRE accordingly if changing this! const char *HelpPages[] = { "CHAN [ + | - | | | ]\n" " Switch channel up, down or to the given channel number, name or id.\n" " Without option (or after successfully switching to the channel)\n" " it returns the current channel number and name.", "CLRE [ | | ]\n" " Clear the EPG list of the given channel number, name or id.\n" " Without option it clears the entire EPG list.\n" " After a CLRE command, no further EPG processing is done for 10\n" " seconds, so that data sent with subsequent PUTE commands doesn't\n" " interfere with data from the broadcasters.", "CONN name: port: vdrversion: apiversion: timeout:\n" " Used by peer-to-peer connections between VDRs to tell the other VDR\n" " to establish a connection to this VDR. The name is the SVDRP host name\n" " of this VDR, which may differ from its DNS name.", "DELC | \n" " Delete the channel with the given number or channel id.", "DELR \n" " Delete the recording with the given id. Before a recording can be\n" " deleted, an LSTR command should have been executed in order to retrieve\n" " the recording ids. The ids are unique and don't change while this\n" " instance of VDR is running.\n" " CAUTION: THERE IS NO CONFIRMATION PROMPT WHEN DELETING A\n" " RECORDING - BE SURE YOU KNOW WHAT YOU ARE DOING!", "DELT \n" " Delete the timer with the given id. If this timer is currently recording,\n" " the recording will be stopped without any warning.", "EDIT \n" " Edit the recording with the given id. Before a recording can be\n" " edited, an LSTR command should have been executed in order to retrieve\n" " the recording ids.", "GRAB [ [ ] ]\n" " Grab the current frame and save it to the given file. Images can\n" " be stored as JPEG or PNM, depending on the given file name extension.\n" " The quality of the grabbed image can be in the range 0..100, where 100\n" " (the default) means \"best\" (only applies to JPEG). The size parameters\n" " define the size of the resulting image (default is full screen).\n" " If the file name is just an extension (.jpg, .jpeg or .pnm) the image\n" " data will be sent to the SVDRP connection encoded in base64. The same\n" " happens if '-' (a minus sign) is given as file name, in which case the\n" " image format defaults to JPEG.", "HELP [ ]\n" " The HELP command gives help info.", "HITK [ ... ]\n" " Hit the given remote control key. Without option a list of all\n" " valid key names is given. If more than one key is given, they are\n" " entered into the remote control queue in the given sequence. There\n" " can be up to 31 keys.", "LSTC [ :ids ] [ :groups | | | ]\n" " List channels. Without option, all channels are listed. Otherwise\n" " only the given channel is listed. If a name is given, all channels\n" " containing the given string as part of their name are listed.\n" " If ':groups' is given, all channels are listed including group\n" " separators. The channel number of a group separator is always 0.\n" " With ':ids' the channel ids are listed following the channel numbers.\n" " The special number 0 can be given to list the current channel.", "LSTD\n" " List all available devices. Each device is listed with its name and\n" " whether it is currently the primary device ('P') or it implements a\n" " decoder ('D') and can be used as output device.", "LSTE [ ] [ now | next | at