MythTV master
channelgroup.cpp
Go to the documentation of this file.
1// c++
2#include <algorithm>
3
4// mythtv
7
8#include "channelgroup.h"
9#include "channelutil.h"
10#include "sourceutil.h"
11
12#define LOC QString("Channel Group: ")
13
14inline bool lt_group(const ChannelGroupItem &a, const ChannelGroupItem &b)
15{
16 return QString::localeAwareCompare(a.m_name, b.m_name) < 0;
17}
18
19bool ChannelGroup::ToggleChannel(uint chanid, int changrpid, bool delete_chan)
20{
21 // Check if it already exists for that chanid...
23 query.prepare(
24 "SELECT channelgroup.id "
25 "FROM channelgroup "
26 "WHERE channelgroup.chanid = :CHANID AND "
27 "channelgroup.grpid = :GRPID "
28 "LIMIT 1");
29 query.bindValue(":CHANID", chanid);
30 query.bindValue(":GRPID", changrpid);
31
32 if (!query.exec())
33 {
34 MythDB::DBError("ChannelGroup::ToggleChannel", query);
35 return false;
36 }
37 if (query.next() && delete_chan)
38 {
39 // We have a record...Remove it to toggle...
40 QString id = query.value(0).toString();
41 query.prepare("DELETE FROM channelgroup WHERE id = :CHANID");
42 query.bindValue(":CHANID", id);
43 if (!query.exec())
44 MythDB::DBError("ChannelGroup::ToggleChannel -- delete", query);
45 LOG(VB_GENERAL, LOG_INFO, LOC +
46 QString("Removing channel %1 from group %2.").arg(id).arg(changrpid));
47 }
48 else if (query.size() == 0)
49 {
50 // We have no record...Add one to toggle...
51 query.prepare("INSERT INTO channelgroup (chanid,grpid) "
52 "VALUES (:CHANID, :GRPID)");
53 query.bindValue(":CHANID", chanid);
54 query.bindValue(":GRPID", changrpid);
55 if (!query.exec())
56 MythDB::DBError("ChannelGroup::ToggleChannel -- insert", query);
57 LOG(VB_GENERAL, LOG_INFO, LOC +
58 QString("Adding channel %1 to group %2.")
59 .arg(chanid).arg(changrpid));
60 }
61 else
62 {
63 LOG(VB_GENERAL, LOG_INFO, LOC +
64 QString("Channel %1 already present in group %2.")
65 .arg(chanid).arg(changrpid));
66 }
67
68 return true;
69}
70
71bool ChannelGroup::AddChannel(uint chanid, int changrpid)
72{
73 // Make sure the channel group exists
75 query.prepare(
76 "SELECT grpid, name FROM channelgroupnames "
77 "WHERE grpid = :GRPID");
78 query.bindValue(":GRPID", changrpid);
79
80 if (!query.exec())
81 {
82 MythDB::DBError("ChannelGroup::AddChannel", query);
83 return false;
84 }
85 if (query.size() == 0)
86 {
87 LOG(VB_GENERAL, LOG_INFO, LOC +
88 QString("AddChannel failed to find channel group %1.").arg(changrpid));
89 return false;
90 }
91
92 query.first();
93 QString groupName = query.value(1).toString();
94
95 // Make sure the channel exists and is visible
96 query.prepare(
97 "SELECT chanid, name FROM channel "
98 "WHERE chanid = :CHANID "
99 "AND visible > 0 "
100 "AND deleted IS NULL");
101 query.bindValue(":CHANID", chanid);
102
103 if (!query.exec())
104 {
105 MythDB::DBError("ChannelGroup::AddChannel", query);
106 return false;
107 }
108 if (query.size() == 0)
109 {
110 LOG(VB_GENERAL, LOG_INFO, LOC +
111 QString("AddChannel failed to find channel %1.").arg(chanid));
112 return false;
113 }
114
115 query.first();
116 QString chanName = query.value(1).toString();
117
118 // Check if it already exists for that chanid...
119 query.prepare(
120 "SELECT channelgroup.id "
121 "FROM channelgroup "
122 "WHERE channelgroup.chanid = :CHANID AND "
123 "channelgroup.grpid = :GRPID "
124 "LIMIT 1");
125 query.bindValue(":CHANID", chanid);
126 query.bindValue(":GRPID", changrpid);
127
128 if (!query.exec())
129 {
130 MythDB::DBError("ChannelGroup::AddChannel", query);
131 return false;
132 }
133 if (query.size() == 0)
134 {
135 // We have no record...Add one to toggle...
136 query.prepare("INSERT INTO channelgroup (chanid,grpid) "
137 "VALUES (:CHANID, :GRPID)");
138 query.bindValue(":CHANID", chanid);
139 query.bindValue(":GRPID", changrpid);
140 if (!query.exec())
141 MythDB::DBError("ChannelGroup::AddChannel -- insert", query);
142 LOG(VB_GENERAL, LOG_INFO, LOC +
143 QString("Adding channel %1 to group %2.")
144 .arg(chanName, groupName));
145 }
146
147 return true;
148}
149
150bool ChannelGroup::DeleteChannel(uint chanid, int changrpid)
151{
152 // Check if it already exists for that chanid...
154 query.prepare(
155 "SELECT channelgroup.id "
156 "FROM channelgroup "
157 "WHERE channelgroup.chanid = :CHANID AND "
158 "channelgroup.grpid = :GRPID "
159 "LIMIT 1");
160 query.bindValue(":CHANID", chanid);
161 query.bindValue(":GRPID", changrpid);
162
163 if (!query.exec())
164 {
165 MythDB::DBError("ChannelGroup::DeleteChannel", query);
166 return false;
167 }
168 if (query.next())
169 {
170 // We have a record...Remove it to toggle...
171 QString id = query.value(0).toString();
172 query.prepare("DELETE FROM channelgroup WHERE id = :CHANID");
173 query.bindValue(":CHANID", id);
174 if (!query.exec())
175 MythDB::DBError("ChannelGroup::DeleteChannel -- delete", query);
176 LOG(VB_GENERAL, LOG_INFO, LOC +
177 QString("Removing channel with id=%1.").arg(id));
178 }
179
180 return true;
181}
182
183// Create a list of all channel groups that are manually configured
184//
185// The channel groups are returned in the following order:
186// - Favorites
187// - Manually created channel groups, sorted by name.
188//
190{
191 ChannelGroupList list;
192 QString qstr;
194
195 // Always the Favorites with group id 1
196 if (includeEmpty)
197 {
198 qstr = "SELECT grpid, name FROM channelgroupnames"
199 " WHERE grpid = 1";
200 }
201 else
202 {
203 qstr = "SELECT DISTINCT t1.grpid, name FROM channelgroupnames t1, channelgroup t2"
204 " WHERE t1.grpid = t2.grpid"
205 " AND t1.grpid = 1";
206 }
207 query.prepare(qstr);
208 if (!query.exec())
209 MythDB::DBError("ChannelGroup::GetChannelGroups Favorites", query);
210 else
211 {
212 if (query.next())
213 {
214 ChannelGroupItem group(query.value(0).toUInt(),
215 query.value(1).toString());
216 list.push_back(group);
217 }
218 }
219
220 // Then the channel groups that are manually created
221 if (includeEmpty)
222 {
223 qstr = "SELECT grpid, name FROM channelgroupnames"
224 " WHERE name NOT IN (SELECT name FROM videosource)"
225 " AND name <> 'Priority' "
226 " AND grpid <> 1"
227 " ORDER BY NAME";
228 }
229 else
230 {
231 qstr = "SELECT DISTINCT t1.grpid, name FROM channelgroupnames t1, channelgroup t2"
232 " WHERE t1.grpid = t2.grpid"
233 " AND name NOT IN (SELECT name FROM videosource)"
234 " AND name <> 'Priority' "
235 " AND t1.grpid <> 1"
236 " ORDER BY NAME";
237 }
238 query.prepare(qstr);
239 if (!query.exec())
240 MythDB::DBError("ChannelGroup::GetChannelGroups manual", query);
241 else
242 {
243 while (query.next())
244 {
245 ChannelGroupItem group(query.value(0).toUInt(),
246 query.value(1).toString());
247 list.push_back(group);
248 }
249 }
250
251 return list;
252}
253
254// Create a list of all channel groups that are automatically created
255//
256// The channel groups are returned in the following order:
257// - Priority channels
258// - Channel groups created automatically from videosources, sorted by name.
259//
261{
262 ChannelGroupList list;
263 QString qstr;
265
266 // The Priority channel group if it exists
267 if (includeEmpty)
268 {
269 qstr = "SELECT grpid, name FROM channelgroupnames"
270 " WHERE name = 'Priority'";
271 }
272 else
273 {
274 qstr = "SELECT DISTINCT t1.grpid, name FROM channelgroupnames t1, channelgroup t2"
275 " WHERE t1.grpid = t2.grpid "
276 " AND name = 'Priority'";
277 }
278 query.prepare(qstr);
279 if (!query.exec())
280 MythDB::DBError("ChannelGroup::GetChannelGroups Priority", query);
281 else
282 {
283 if (query.next())
284 {
285 ChannelGroupItem group(query.value(0).toUInt(),
286 query.value(1).toString());
287 list.push_back(group);
288 }
289 }
290
291 // The channel groups that are automatically created from videosources
292 if (includeEmpty)
293 {
294 qstr = "SELECT grpid, name FROM channelgroupnames"
295 " WHERE name IN (SELECT name FROM videosource)"
296 " ORDER BY NAME";
297 }
298 else
299 {
300 qstr = "SELECT DISTINCT t1.grpid, name FROM channelgroupnames t1, channelgroup t2"
301 " WHERE t1.grpid = t2.grpid"
302 " AND name IN (SELECT name FROM videosource)"
303 " ORDER BY NAME";
304 }
305 query.prepare(qstr);
306 if (!query.exec())
307 MythDB::DBError("ChannelGroup::GetChannelGroups videosources", query);
308 else
309 {
310 while (query.next())
311 {
312 ChannelGroupItem group(query.value(0).toUInt(),
313 query.value(1).toString());
314 list.push_back(group);
315 }
316 }
317 return list;
318}
319
320// Create a list of all channel groups
321//
322// The channel groups are returned in the following order:
323// - Favorites
324// - Manually created channel groups, sorted by name.
325// - Priority channels
326// - Channel groups created automatically from videosources, sorted by name.
327//
329{
330 ChannelGroupList list = GetManualChannelGroups(includeEmpty);
331 ChannelGroupList more = GetAutomaticChannelGroups(includeEmpty);
332 list.insert(list.end(), more.begin(), more.end());
333 return list;
334}
335
336// Cycle through the available channel groups.
337// At the end return -1 to select "All Channels".
339{
340 // If no groups return -1 for "All Channels"
341 if (sorted.empty())
342 return -1;
343
344 // If grpid is "All Channels" (-1), then return the first grpid
345 if (grpid == -1)
346 return sorted[0].m_grpId;
347
348 auto it = std::find(sorted.cbegin(), sorted.cend(), grpid);
349
350 // If grpid is not in the list, return -1 for "All Channels"
351 if (it == sorted.end())
352 return -1;
353
354 ++it;
355
356 // If we reached the end, the next option is "All Channels" (-1)
357 if (it == sorted.end())
358 return -1;
359
360 return it->m_grpId;
361}
362
363bool ChannelGroup::InChannelGroupList(const ChannelGroupList &groupList, int grpid)
364{
365 auto it = std::find(groupList.cbegin(), groupList.cend(), grpid);
366 return it != groupList.end();
367}
368
370{
371 return !InChannelGroupList(groupList, grpid);
372}
373
375{
376 // All Channels
377 if (grpid == -1)
378 return tr("All Channels");
379
380 // No group
381 if (grpid == 0)
382 return "";
383
385 query.prepare("SELECT name FROM channelgroupnames WHERE grpid = :GROUPID");
386 query.bindValue(":GROUPID", grpid);
387
388 if (!query.exec())
389 MythDB::DBError("ChannelGroup::GetChannelGroups", query);
390 else if (query.next())
391 return query.value(0).toString();
392
393 return "";
394}
395
396int ChannelGroup::GetChannelGroupId(const QString& changroupname)
397{
398 // All Channels
399 if (changroupname == "All Channels")
400 return -1;
401
403
404 query.prepare("SELECT grpid FROM channelgroupnames "
405 "WHERE name = :GROUPNAME");
406 query.bindValue(":GROUPNAME", changroupname);
407
408 if (!query.exec())
409 MythDB::DBError("ChannelGroup::GetChannelGroups", query);
410 else if (query.next())
411 return query.value(0).toUInt();
412
413 return 0;
414}
415
416int ChannelGroup::AddChannelGroup(const QString &groupName)
417{
418 int groupId = ChannelGroup::GetChannelGroupId(groupName);
419 if (groupId == 0)
420 {
421 LOG(VB_GENERAL, LOG_INFO, QString("Add channelgroup %1").arg(groupName));
422
424 query.prepare("INSERT INTO channelgroupnames (name) VALUE (:NEWNAME);");
425 query.bindValue(":NEWNAME", groupName);
426
427 if (!query.exec())
428 MythDB::DBError("AddChannelGroup", query);
429 groupId = query.lastInsertId().toInt();
430 }
431 return groupId;
432}
433
434bool ChannelGroup::RemoveChannelGroup(const QString &groupName)
435{
436 int groupId = ChannelGroup::GetChannelGroupId(groupName);
437 if (groupId > 0)
438 {
439 // Yes, channelgroup does exist. Remove all existing channels.
440 LOG(VB_GENERAL, LOG_INFO, QString("Remove channels of channelgroup %1").arg(groupName));
441
443 query.prepare("DELETE FROM channelgroup WHERE grpid = :GRPID;");
444 query.bindValue(":GRPID", groupId);
445
446 if (!query.exec())
447 MythDB::DBError("RemoveChannelGroup 1", query);
448
449 // And also the channelgroupname
450 LOG(VB_GENERAL, LOG_INFO, QString("Remove channelgroup %1").arg(groupName));
451 query.prepare("DELETE FROM channelgroupnames WHERE grpid = :GRPID;");
452 query.bindValue(":GRPID", groupId);
453
454 if (!query.exec())
455 MythDB::DBError("RemoveChannelGroup 2", query);
456 }
457 else
458 {
459 LOG(VB_GENERAL, LOG_DEBUG, QString("Channelgroup %1 not found").arg(groupName));
460 return false;
461 }
462 return true;
463}
464
465bool ChannelGroup::UpdateChannelGroup(const QString & oldName, const QString & newName)
466{
467 // Check if new name already exists
468 int groupId = ChannelGroup::GetChannelGroupId(newName);
469 if (groupId > 0)
470 return false;
471
473 QString qstr = "UPDATE channelgroupnames set name = :NEWNAME "
474 " WHERE name = :OLDNAME ;";
475 query.prepare(qstr);
476 query.bindValue(":NEWNAME", newName);
477 query.bindValue(":OLDNAME", oldName);
478
479 if (!query.exec())
480 {
481 MythDB::DBError("ChannelGroup::UpdateChannelGroup fail", query);
482 return false;
483 }
484 return true;
485}
486
487// UpdateChannelGroups
488//
489// Create and maintain a channel group for each connected video source
490// with the name of the video source.
491// Create and maintain a channel group Priority for
492// all channels that have a recording priority bigger than 0.
493//
495{
496 QMap<int, QString> allSources;
497 QMap<int, QString> connectedSources;
498 QMap<int, QString> disconnectedSources;
499
500 LOG(VB_GENERAL, LOG_INFO, QString("Running UpdateChannelGroups"));
501
502 // Get list of all video sources
503 {
505 query.prepare("SELECT sourceid,name FROM videosource;");
506 if (query.exec())
507 {
508 while (query.next())
509 allSources[query.value(0).toInt()] = query.value(1).toString();
510 }
511 else
512 {
513 MythDB::DBError("UpdateChannelGroups videosource 1", query);
514 }
515 }
516
517 // Split all video sources into a list of video sources that are connected to one
518 // or more capture cards and a list of video sources that are not connected.
519 for (auto it = allSources.cbegin(); it != allSources.cend(); ++it)
520 {
521 uint sourceId = it.key();
522 int count = SourceUtil::GetConnectionCount(sourceId);
523 if (count > 0)
524 connectedSources[sourceId] = allSources[sourceId];
525 else
526 disconnectedSources[sourceId] = allSources[sourceId];
527 }
528
529 // If there is only one connected video source then we do not need a special
530 // channel group for that video source; it is then the same as "All Channels".
531 QMap<int, QString> removeSources = disconnectedSources;
532 if (connectedSources.size() == 1)
533 {
534 auto it = connectedSources.cbegin();
535 uint sourceid = it.key();
536 removeSources[sourceid] = *it;
537 }
538
539 // Remove channelgroup channels and the channelgroupname for disconnected video sources.
540 for (const auto &sourceName : std::as_const(removeSources))
541 {
542 RemoveChannelGroup(sourceName);
543 }
544
545 // Remove all channels that do not exist anymore or that are not visible.
546 // This is done only for the automatic channel groups.
547 {
550 for (const auto &chgrp : list)
551 {
552 query.prepare(
553 "DELETE from channelgroup WHERE grpid = :GRPID"
554 " AND chanid NOT IN "
555 " (SELECT chanid FROM channel WHERE deleted IS NULL AND visible > 0)");
556 query.bindValue(":GRPID", chgrp.m_grpId);
557 if (!query.exec())
558 {
559 MythDB::DBError("ChannelGroup::UpdateChannelGroups", query);
560 return;
561 }
562 if (query.numRowsAffected() > 0)
563 {
564 LOG(VB_GENERAL, LOG_INFO, QString("Removed %1 channels from channelgroup %2")
565 .arg(query.numRowsAffected()).arg(chgrp.m_name));
566 }
567 }
568 }
569
570 // Create a channel group for each connected video source only if there is
571 // more than one video source configured with a capture card.
572 if (connectedSources.size() > 1)
573 {
574 // Add channelgroupname entry if it does not exist yet
575 for (const auto &sourceName : std::as_const(connectedSources))
576 {
577 AddChannelGroup(sourceName);
578 }
579
580 // Add all visible channels to the channelgroups
581 for (auto it = connectedSources.cbegin(); it != connectedSources.cend(); ++it)
582 {
583 uint sourceId = it.key();
584 QString sourceName = connectedSources[sourceId];
585 int groupId = ChannelGroup::GetChannelGroupId(sourceName);
586
587 if (groupId > 0)
588 {
589 LOG(VB_GENERAL, LOG_INFO, QString("Update channelgroup %1").arg(sourceName));
591 query.prepare(
592 "SELECT chanid FROM channel "
593 "WHERE sourceid = :SOURCEID "
594 "AND deleted IS NULL "
595 "AND visible > 0 ");
596 query.bindValue(":SOURCEID", sourceId);
597 if (!query.exec())
598 {
599 MythDB::DBError("ChannelGroup::UpdateChannelGroups", query);
600 return;
601 }
602 while (query.next())
603 {
604 uint chanId = query.value(0).toUInt();
605 ChannelGroup::AddChannel(chanId, groupId);
606 }
607 }
608 }
609 }
610
611 // Channelgroup Priority
612
613 // Find the number of priority channels in all connected video sources
614 uint numPrioChannels = 0;
615 for (auto it = connectedSources.cbegin(); it != connectedSources.cend(); ++it)
616 {
617 uint sourceId = it.key();
619 query.prepare(
620 "SELECT count(*) FROM channel "
621 "WHERE sourceid = :SOURCEID "
622 "AND deleted IS NULL "
623 "AND visible > 0 "
624 "AND recpriority > 0");
625 query.bindValue(":SOURCEID", sourceId);
626 if (!query.exec())
627 {
628 MythDB::DBError("UpdateChannelGroups Priority select channels", query);
629 return;
630 }
631 if (query.next())
632 {
633 numPrioChannels += query.value(0).toUInt();
634 }
635 }
636 LOG(VB_GENERAL, LOG_INFO, QString("Found %1 priority channels").arg(numPrioChannels));
637
638 if (numPrioChannels > 0)
639 {
640 // Add channel group for Priority channels
641 QString groupName = "Priority";
642 AddChannelGroup(groupName);
643 LOG(VB_GENERAL, LOG_INFO, QString("Update channelgroup %1").arg(groupName));
644
645 // Update all channels in channel group Priority
646 int groupId = ChannelGroup::GetChannelGroupId(groupName);
647 if (groupId > 0)
648 {
649 // Remove all channels from channelgroup Priority that do not have priority anymore.
651 query.prepare(
652 "DELETE from channelgroup WHERE grpid = :GRPID "
653 " AND chanid NOT IN "
654 " (SELECT chanid FROM channel "
655 " WHERE deleted IS NULL "
656 " AND visible > 0 "
657 " AND recpriority > 0)");
658 query.bindValue(":GRPID", groupId);
659 if (!query.exec())
660 {
661 MythDB::DBError("ChannelGroup::UpdateChannelGroups", query);
662 return;
663 }
664 if (query.numRowsAffected() > 0)
665 {
666 LOG(VB_GENERAL, LOG_INFO, QString("Removed %1 channels from channelgroup Priority")
667 .arg(query.numRowsAffected()));
668 }
669
670 // Add all channels from all connected video groups if they have priority
671 for (auto it = connectedSources.cbegin(); it != connectedSources.cend(); ++it)
672 {
673 uint sourceId = it.key();
674 query.prepare(
675 "SELECT chanid FROM channel "
676 "WHERE sourceid = :SOURCEID "
677 "AND deleted IS NULL "
678 "AND visible > 0 "
679 "AND recpriority > 0");
680 query.bindValue(":SOURCEID", sourceId);
681 if (!query.exec())
682 {
683 MythDB::DBError("UpdateChannelGroups Priority select channels", query);
684 return;
685 }
686 while (query.next())
687 {
688 uint chanId = query.value(0).toUInt();
689 ChannelGroup::AddChannel(chanId, groupId);
690 }
691 }
692 }
693 }
694 else
695 {
696 // No priority channels in connected video sources, so no Priority channel group
697 RemoveChannelGroup("Priority");
698 }
699}
#define LOC
bool lt_group(const ChannelGroupItem &a, const ChannelGroupItem &b)
std::vector< ChannelGroupItem > ChannelGroupList
Definition: channelgroup.h:31
static bool NotInChannelGroupList(const ChannelGroupList &groupList, int grpid)
static bool InChannelGroupList(const ChannelGroupList &groupList, int grpid)
static ChannelGroupList GetAutomaticChannelGroups(bool includeEmpty=true)
static bool RemoveChannelGroup(const QString &groupName)
static QString GetChannelGroupName(int grpid)
static void UpdateChannelGroups(void)
static bool AddChannel(uint chanid, int changrpid)
static int GetNextChannelGroup(const ChannelGroupList &sorted, int grpid)
static ChannelGroupList GetChannelGroups(bool includeEmpty=true)
static bool DeleteChannel(uint chanid, int changrpid)
static bool ToggleChannel(uint chanid, int changrpid, bool delete_chan)
static ChannelGroupList GetManualChannelGroups(bool includeEmpty=true)
static int GetChannelGroupId(const QString &changroupname)
static int AddChannelGroup(const QString &groupName)
static bool UpdateChannelGroup(const QString &oldName, const QString &newName)
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:837
bool first(void)
Wrap QSqlQuery::first() so we can display the query results.
Definition: mythdbcon.cpp:822
QVariant value(int i) const
Definition: mythdbcon.h:204
int size(void) const
Definition: mythdbcon.h:214
int numRowsAffected() const
Definition: mythdbcon.h:217
bool exec(void)
Wrap QSqlQuery::exec() so we can display SQL.
Definition: mythdbcon.cpp:618
void bindValue(const QString &placeholder, const QVariant &val)
Add a single binding.
Definition: mythdbcon.cpp:888
QVariant lastInsertId()
Return the id of the last inserted row.
Definition: mythdbcon.cpp:935
bool next(void)
Wrap QSqlQuery::next() so we can display the query results.
Definition: mythdbcon.cpp:812
static MSqlQueryInfo InitCon(ConnectionReuse _reuse=kNormalConnection)
Only use this in combination with MSqlQuery constructor.
Definition: mythdbcon.cpp:550
static void DBError(const QString &where, const MSqlQuery &query)
Definition: mythdb.cpp:226
static uint GetConnectionCount(uint sourceid)
Definition: sourceutil.cpp:230
static pid_list_t::iterator find(const PIDInfoMap &map, pid_list_t &list, pid_list_t::iterator begin, pid_list_t::iterator end, bool find_open)
unsigned int uint
Definition: freesurround.h:24
#define LOG(_MASK_, _LEVEL_, _QSTRING_)
Definition: mythlogging.h:39