]> kaliko git repositories - python-musicpd.git/blob - musicpd.py
Add getvol command (closes #9)
[python-musicpd.git] / musicpd.py
1 # python-musicpd: Python MPD client library
2 # Copyright (C) 2012-2021  kaliko <kaliko@azylum.org>
3 # Copyright (C) 2019       Naglis Jonaitis <naglis@mailbox.org>
4 # Copyright (C) 2019       Bart Van Loon <bbb@bbbart.be>
5 # Copyright (C) 2008-2010  J. Alexander Treuman <jat@spatialrift.net>
6 #
7 # python-musicpd is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Lesser General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # python-musicpd is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Lesser General Public License for more details.
16 #
17 # You should have received a copy of the GNU Lesser General Public License
18 # along with python-musicpd.  If not, see <http://www.gnu.org/licenses/>.
19
20 # pylint: disable=missing-docstring
21
22 import socket
23 import os
24
25 from functools import wraps
26
27 HELLO_PREFIX = "OK MPD "
28 ERROR_PREFIX = "ACK "
29 SUCCESS = "OK"
30 NEXT = "list_OK"
31 VERSION = '0.6.0'
32 #: seconds before a tcp connection attempt times out
33 CONNECTION_TIMEOUT = 5
34
35
36 def iterator_wrapper(func):
37     """Decorator handling iterate option"""
38     @wraps(func)
39     def decorated_function(instance, *args, **kwargs):
40         generator = func(instance, *args, **kwargs)
41         if not instance.iterate:
42             return list(generator)
43         instance._iterating = True
44
45         def iterator(gen):
46             try:
47                 for item in gen:
48                     yield item
49             finally:
50                 instance._iterating = False
51         return iterator(generator)
52     return decorated_function
53
54
55 class MPDError(Exception):
56     pass
57
58
59 class ConnectionError(MPDError):
60     pass
61
62
63 class ProtocolError(MPDError):
64     pass
65
66
67 class CommandError(MPDError):
68     pass
69
70
71 class CommandListError(MPDError):
72     pass
73
74
75 class PendingCommandError(MPDError):
76     pass
77
78
79 class IteratingError(MPDError):
80     pass
81
82
83 class Range:
84
85     def __init__(self, tpl):
86         self.tpl = tpl
87         self._check()
88
89     def __str__(self):
90         if len(self.tpl) == 0:
91             return ':'
92         if len(self.tpl) == 1:
93             return '{0}:'.format(self.tpl[0])
94         return '{0[0]}:{0[1]}'.format(self.tpl)
95
96     def __repr__(self):
97         return 'Range({0})'.format(self.tpl)
98
99     def _check(self):
100         if not isinstance(self.tpl, tuple):
101             raise CommandError('Wrong type, provide a tuple')
102         if len(self.tpl) not in [0, 1, 2]:
103             raise CommandError('length not in [0, 1, 2]')
104         for index in self.tpl:
105             try:
106                 index = int(index)
107             except (TypeError, ValueError):
108                 raise CommandError('Not a tuple of int')
109
110
111 class _NotConnected:
112
113     def __getattr__(self, attr):
114         return self._dummy
115
116     def _dummy(*args):
117         raise ConnectionError("Not connected")
118
119
120 class MPDClient:
121     """MPDClient instance will look for ``MPD_HOST``/``MPD_PORT``/``XDG_RUNTIME_DIR`` environment
122     variables and set instance attribute ``host``, ``port`` and ``pwd``
123     accordingly. Regarding ``MPD_HOST`` format to expose password refer
124     MPD client manual :manpage:`mpc (1)`.
125
126     Then :py:obj:`musicpd.MPDClient.connect` will use ``host`` and ``port`` as defaults if not provided as args.
127
128     Cf. :py:obj:`musicpd.MPDClient.connect` for details.
129
130     >>> from os import environ
131     >>> environ['MPD_HOST'] = 'pass@mpdhost'
132     >>> cli = musicpd.MPDClient()
133     >>> cli.pwd == environ['MPD_HOST'].split('@')[0]
134     True
135     >>> cli.host == environ['MPD_HOST'].split('@')[1]
136     True
137     >>> cli.connect() # will use host/port as set in MPD_HOST/MPD_PORT
138
139     :ivar str host: host used with the current connection
140     :ivar str,int port: port used with the current connection
141     :ivar str pwd: password detected in ``MPD_HOST`` environment variable
142
143     .. warning:: Instance attribute host/port/pwd
144
145       While :py:attr:`musicpd.MPDClient().host` and
146       :py:attr:`musicpd.MPDClient().port` keep track of current connection
147       host and port, :py:attr:`musicpd.MPDClient().pwd` is set once with
148       password extracted from environment variable.
149       Calling :py:meth:`musicpd.MPDClient().password()` with a new password
150       won't update :py:attr:`musicpd.MPDClient().pwd` value.
151
152       Moreover, :py:attr:`musicpd.MPDClient().pwd` is only an helper attribute
153       exposing password extracted from ``MPD_HOST`` environment variable, it
154       will not be used as default value for the :py:meth:`password` method
155     """
156
157     def __init__(self):
158         self.iterate = False
159         self._reset()
160         self._commands = {
161             # Status Commands
162             "clearerror":         self._fetch_nothing,
163             "currentsong":        self._fetch_object,
164             "idle":               self._fetch_list,
165             #"noidle":             None,
166             "status":             self._fetch_object,
167             "stats":              self._fetch_object,
168             # Playback Option Commands
169             "consume":            self._fetch_nothing,
170             "crossfade":          self._fetch_nothing,
171             "mixrampdb":          self._fetch_nothing,
172             "mixrampdelay":       self._fetch_nothing,
173             "random":             self._fetch_nothing,
174             "repeat":             self._fetch_nothing,
175             "setvol":             self._fetch_nothing,
176             "getvol":             self._fetch_object,
177             "single":             self._fetch_nothing,
178             "replay_gain_mode":   self._fetch_nothing,
179             "replay_gain_status": self._fetch_item,
180             "volume":             self._fetch_nothing,
181             # Playback Control Commands
182             "next":               self._fetch_nothing,
183             "pause":              self._fetch_nothing,
184             "play":               self._fetch_nothing,
185             "playid":             self._fetch_nothing,
186             "previous":           self._fetch_nothing,
187             "seek":               self._fetch_nothing,
188             "seekid":             self._fetch_nothing,
189             "seekcur":            self._fetch_nothing,
190             "stop":               self._fetch_nothing,
191             # Queue Commands
192             "add":                self._fetch_nothing,
193             "addid":              self._fetch_item,
194             "clear":              self._fetch_nothing,
195             "delete":             self._fetch_nothing,
196             "deleteid":           self._fetch_nothing,
197             "move":               self._fetch_nothing,
198             "moveid":             self._fetch_nothing,
199             "playlist":           self._fetch_playlist,
200             "playlistfind":       self._fetch_songs,
201             "playlistid":         self._fetch_songs,
202             "playlistinfo":       self._fetch_songs,
203             "playlistsearch":     self._fetch_songs,
204             "plchanges":          self._fetch_songs,
205             "plchangesposid":     self._fetch_changes,
206             "prio":               self._fetch_nothing,
207             "prioid":             self._fetch_nothing,
208             "rangeid":            self._fetch_nothing,
209             "shuffle":            self._fetch_nothing,
210             "swap":               self._fetch_nothing,
211             "swapid":             self._fetch_nothing,
212             "addtagid":           self._fetch_nothing,
213             "cleartagid":         self._fetch_nothing,
214             # Stored Playlist Commands
215             "listplaylist":       self._fetch_list,
216             "listplaylistinfo":   self._fetch_songs,
217             "listplaylists":      self._fetch_playlists,
218             "load":               self._fetch_nothing,
219             "playlistadd":        self._fetch_nothing,
220             "playlistclear":      self._fetch_nothing,
221             "playlistdelete":     self._fetch_nothing,
222             "playlistmove":       self._fetch_nothing,
223             "rename":             self._fetch_nothing,
224             "rm":                 self._fetch_nothing,
225             "save":               self._fetch_nothing,
226             # Database Commands
227             "albumart":           self._fetch_composite,
228             "count":              self._fetch_object,
229             "getfingerprint":     self._fetch_object,
230             "find":               self._fetch_songs,
231             "findadd":            self._fetch_nothing,
232             "list":               self._fetch_list,
233             "listall":            self._fetch_database,
234             "listallinfo":        self._fetch_database,
235             "listfiles":          self._fetch_database,
236             "lsinfo":             self._fetch_database,
237             "readcomments":       self._fetch_object,
238             "readpicture":        self._fetch_composite,
239             "search":             self._fetch_songs,
240             "searchadd":          self._fetch_nothing,
241             "searchaddpl":        self._fetch_nothing,
242             "update":             self._fetch_item,
243             "rescan":             self._fetch_item,
244             # Mounts and neighbors
245             "mount":              self._fetch_nothing,
246             "unmount":            self._fetch_nothing,
247             "listmounts":         self._fetch_mounts,
248             "listneighbors":      self._fetch_neighbors,
249             # Sticker Commands
250             "sticker get":        self._fetch_item,
251             "sticker set":        self._fetch_nothing,
252             "sticker delete":     self._fetch_nothing,
253             "sticker list":       self._fetch_list,
254             "sticker find":       self._fetch_songs,
255             # Connection Commands
256             "close":              None,
257             "kill":               None,
258             "password":           self._fetch_nothing,
259             "ping":               self._fetch_nothing,
260             "binarylimit":        self._fetch_nothing,
261             "tagtypes":           self._fetch_list,
262             "tagtypes disable":   self._fetch_nothing,
263             "tagtypes enable":    self._fetch_nothing,
264             "tagtypes clear":     self._fetch_nothing,
265             "tagtypes all":       self._fetch_nothing,
266             # Partition Commands
267             "partition":          self._fetch_nothing,
268             "listpartitions":     self._fetch_list,
269             "newpartition":       self._fetch_nothing,
270             "delpartition":       self._fetch_nothing,
271             "moveoutput":         self._fetch_nothing,
272             # Audio Output Commands
273             "disableoutput":      self._fetch_nothing,
274             "enableoutput":       self._fetch_nothing,
275             "toggleoutput":       self._fetch_nothing,
276             "outputs":            self._fetch_outputs,
277             "outputset":          self._fetch_nothing,
278             # Reflection Commands
279             "config":             self._fetch_object,
280             "commands":           self._fetch_list,
281             "notcommands":        self._fetch_list,
282             "urlhandlers":        self._fetch_list,
283             "decoders":           self._fetch_plugins,
284             # Client to Client
285             "subscribe":          self._fetch_nothing,
286             "unsubscribe":        self._fetch_nothing,
287             "channels":           self._fetch_list,
288             "readmessages":       self._fetch_messages,
289             "sendmessage":        self._fetch_nothing,
290         }
291         self._get_envvars()
292
293     def _get_envvars(self):
294         """
295         Retrieve MPD env. var. to overrides "localhost:6600"
296             Use MPD_HOST/MPD_PORT if set
297             else use MPD_HOST=${XDG_RUNTIME_DIR:-/run/}/mpd/socket if file exists
298         """
299         self.host = 'localhost'
300         self.pwd = None
301         self.port = os.environ.get('MPD_PORT', '6600')
302         mpd_host_env = os.environ.get('MPD_HOST')
303         if mpd_host_env:
304             # If password is set:
305             # mpd_host_env = ['pass', 'host'] because MPD_HOST=pass@host
306             mpd_host_env = mpd_host_env.split('@')
307             mpd_host_env.reverse()
308             self.host = mpd_host_env[0]
309             if len(mpd_host_env) > 1 and mpd_host_env[1]:
310                 self.pwd = mpd_host_env[1]
311         else:
312             # Is socket there
313             xdg_runtime_dir = os.environ.get('XDG_RUNTIME_DIR', '/run')
314             rundir = os.path.join(xdg_runtime_dir, 'mpd/socket')
315             if os.path.exists(rundir):
316                 self.host = rundir
317
318     def __getattr__(self, attr):
319         if attr == 'send_noidle':  # have send_noidle to cancel idle as well as noidle
320             return self.noidle()
321         if attr.startswith("send_"):
322             command = attr.replace("send_", "", 1)
323             wrapper = self._send
324         elif attr.startswith("fetch_"):
325             command = attr.replace("fetch_", "", 1)
326             wrapper = self._fetch
327         else:
328             command = attr
329             wrapper = self._execute
330         if command not in self._commands:
331             command = command.replace("_", " ")
332             if command not in self._commands:
333                 raise AttributeError("'%s' object has no attribute '%s'" %
334                                      (self.__class__.__name__, attr))
335         return lambda *args: wrapper(command, args)
336
337     def _send(self, command, args):
338         if self._command_list is not None:
339             raise CommandListError("Cannot use send_%s in a command list" %
340                                    command.replace(" ", "_"))
341         self._write_command(command, args)
342         retval = self._commands[command]
343         if retval is not None:
344             self._pending.append(command)
345
346     def _fetch(self, command, args=None):
347         if self._command_list is not None:
348             raise CommandListError("Cannot use fetch_%s in a command list" %
349                                    command.replace(" ", "_"))
350         if self._iterating:
351             raise IteratingError("Cannot use fetch_%s while iterating" %
352                                  command.replace(" ", "_"))
353         if not self._pending:
354             raise PendingCommandError("No pending commands to fetch")
355         if self._pending[0] != command:
356             raise PendingCommandError("'%s' is not the currently "
357                                       "pending command" % command)
358         del self._pending[0]
359         retval = self._commands[command]
360         if callable(retval):
361             return retval()
362         return retval
363
364     def _execute(self, command, args):
365         if self._iterating:
366             raise IteratingError("Cannot execute '%s' while iterating" %
367                                  command)
368         if self._pending:
369             raise PendingCommandError(
370                 "Cannot execute '%s' with pending commands" % command)
371         retval = self._commands[command]
372         if self._command_list is not None:
373             if not callable(retval):
374                 raise CommandListError(
375                     "'%s' not allowed in command list" % command)
376             self._write_command(command, args)
377             self._command_list.append(retval)
378         else:
379             self._write_command(command, args)
380             if callable(retval):
381                 return retval()
382             return retval
383
384     def _write_line(self, line):
385         self._wfile.write("%s\n" % line)
386         self._wfile.flush()
387
388     def _write_command(self, command, args=None):
389         if args is None:
390             args = []
391         parts = [command]
392         for arg in args:
393             if isinstance(arg, tuple):
394                 parts.append('{0!s}'.format(Range(arg)))
395             else:
396                 parts.append('"%s"' % escape(str(arg)))
397         self._write_line(" ".join(parts))
398
399     def _read_binary(self, amount):
400         chunk = bytearray()
401         while amount > 0:
402             result = self._rbfile.read(amount)
403             if len(result) == 0:
404                 self.disconnect()
405                 raise ConnectionError("Connection lost while reading binary content")
406             chunk.extend(result)
407             amount -= len(result)
408         return bytes(chunk)
409
410     def _read_line(self, binary=False):
411         if binary:
412             line = self._rbfile.readline().decode('utf-8')
413         else:
414             line = self._rfile.readline()
415         if not line.endswith("\n"):
416             self.disconnect()
417             raise ConnectionError("Connection lost while reading line")
418         line = line.rstrip("\n")
419         if line.startswith(ERROR_PREFIX):
420             error = line[len(ERROR_PREFIX):].strip()
421             raise CommandError(error)
422         if self._command_list is not None:
423             if line == NEXT:
424                 return
425             if line == SUCCESS:
426                 raise ProtocolError("Got unexpected '%s'" % SUCCESS)
427         elif line == SUCCESS:
428             return
429         return line
430
431     def _read_pair(self, separator, binary=False):
432         line = self._read_line(binary=binary)
433         if line is None:
434             return
435         pair = line.split(separator, 1)
436         if len(pair) < 2:
437             raise ProtocolError("Could not parse pair: '%s'" % line)
438         return pair
439
440     def _read_pairs(self, separator=": ", binary=False):
441         pair = self._read_pair(separator, binary=binary)
442         while pair:
443             yield pair
444             pair = self._read_pair(separator, binary=binary)
445
446     def _read_list(self):
447         seen = None
448         for key, value in self._read_pairs():
449             if key != seen:
450                 if seen is not None:
451                     raise ProtocolError("Expected key '%s', got '%s'" %
452                                         (seen, key))
453                 seen = key
454             yield value
455
456     def _read_playlist(self):
457         for _, value in self._read_pairs(":"):
458             yield value
459
460     def _read_objects(self, delimiters=None):
461         obj = {}
462         if delimiters is None:
463             delimiters = []
464         for key, value in self._read_pairs():
465             key = key.lower()
466             if obj:
467                 if key in delimiters:
468                     yield obj
469                     obj = {}
470                 elif key in obj:
471                     if not isinstance(obj[key], list):
472                         obj[key] = [obj[key], value]
473                     else:
474                         obj[key].append(value)
475                     continue
476             obj[key] = value
477         if obj:
478             yield obj
479
480     def _read_command_list(self):
481         try:
482             for retval in self._command_list:
483                 yield retval()
484         finally:
485             self._command_list = None
486         self._fetch_nothing()
487
488     def _fetch_nothing(self):
489         line = self._read_line()
490         if line is not None:
491             raise ProtocolError("Got unexpected return value: '%s'" % line)
492
493     def _fetch_item(self):
494         pairs = list(self._read_pairs())
495         if len(pairs) != 1:
496             return
497         return pairs[0][1]
498
499     @iterator_wrapper
500     def _fetch_list(self):
501         return self._read_list()
502
503     @iterator_wrapper
504     def _fetch_playlist(self):
505         return self._read_playlist()
506
507     def _fetch_object(self):
508         objs = list(self._read_objects())
509         if not objs:
510             return {}
511         return objs[0]
512
513     @iterator_wrapper
514     def _fetch_objects(self, delimiters):
515         return self._read_objects(delimiters)
516
517     def _fetch_changes(self):
518         return self._fetch_objects(["cpos"])
519
520     def _fetch_songs(self):
521         return self._fetch_objects(["file"])
522
523     def _fetch_playlists(self):
524         return self._fetch_objects(["playlist"])
525
526     def _fetch_database(self):
527         return self._fetch_objects(["file", "directory", "playlist"])
528
529     def _fetch_outputs(self):
530         return self._fetch_objects(["outputid"])
531
532     def _fetch_plugins(self):
533         return self._fetch_objects(["plugin"])
534
535     def _fetch_messages(self):
536         return self._fetch_objects(["channel"])
537
538     def _fetch_mounts(self):
539         return self._fetch_objects(["mount"])
540
541     def _fetch_neighbors(self):
542         return self._fetch_objects(["neighbor"])
543
544     def _fetch_composite(self):
545         obj = {}
546         for key, value in self._read_pairs(binary=True):
547             key = key.lower()
548             obj[key] = value
549             if key == 'binary':
550                 break
551         if not obj:
552             # If the song file was recognized, but there is no picture, the
553             # response is successful, but is otherwise empty.
554             return obj
555         amount = int(obj['binary'])
556         try:
557             obj['data'] = self._read_binary(amount)
558         except IOError as err:
559             raise ConnectionError('Error reading binary content: %s' % err)
560         if len(obj['data']) != amount:
561             raise ConnectionError('Error reading binary content: '
562                       'Expects %sB, got %s' % (amount, len(obj['data'])))
563         # Fetches trailing new line
564         self._read_line(binary=True)
565         # Fetches SUCCESS code
566         self._read_line(binary=True)
567         return obj
568
569     @iterator_wrapper
570     def _fetch_command_list(self):
571         return self._read_command_list()
572
573     def _hello(self):
574         line = self._rfile.readline()
575         if not line.endswith("\n"):
576             raise ConnectionError("Connection lost while reading MPD hello")
577         line = line.rstrip("\n")
578         if not line.startswith(HELLO_PREFIX):
579             raise ProtocolError("Got invalid MPD hello: '%s'" % line)
580         self.mpd_version = line[len(HELLO_PREFIX):].strip()
581
582     def _reset(self):
583         self.mpd_version = None
584         self._iterating = False
585         self._pending = []
586         self._command_list = None
587         self._sock = None
588         self._rfile = _NotConnected()
589         self._rbfile = _NotConnected()
590         self._wfile = _NotConnected()
591
592     def _connect_unix(self, path):
593         if not hasattr(socket, "AF_UNIX"):
594             raise ConnectionError(
595                 "Unix domain sockets not supported on this platform")
596         sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
597         sock.connect(path)
598         return sock
599
600     def _connect_tcp(self, host, port):
601         try:
602             flags = socket.AI_ADDRCONFIG
603         except AttributeError:
604             flags = 0
605         err = None
606         for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC,
607                                       socket.SOCK_STREAM, socket.IPPROTO_TCP,
608                                       flags):
609             af, socktype, proto, _, sa = res
610             sock = None
611             try:
612                 sock = socket.socket(af, socktype, proto)
613                 sock.settimeout(CONNECTION_TIMEOUT)
614                 sock.connect(sa)
615                 sock.settimeout(None)
616                 return sock
617             except socket.error as socket_err:
618                 err = socket_err
619                 if sock is not None:
620                     sock.close()
621         if err is not None:
622             raise ConnectionError(str(err))
623         else:
624             raise ConnectionError("getaddrinfo returns an empty list")
625
626     def noidle(self):
627         # noidle's special case
628         if not self._pending or self._pending[0] != 'idle':
629             raise CommandError(
630                 'cannot send noidle if send_idle was not called')
631         del self._pending[0]
632         self._write_command("noidle")
633         return self._fetch_list()
634
635     def connect(self, host=None, port=None):
636         """Connects the MPD server
637
638         :param str host: hostname, IP or FQDN (defaults to `localhost` or socket, see below for details)
639         :param port: port number (defaults to 6600)
640         :type port: str or int
641
642         The connect method honors MPD_HOST/MPD_PORT environment variables.
643
644         .. note:: Default host/port
645
646           If host evaluate to :py:obj:`False`
647            * use ``MPD_HOST`` environment variable if set, extract password if present,
648            * else looks for a existing file in ``${XDG_RUNTIME_DIR:-/run/}/mpd/socket``
649            * else set host to ``localhost``
650
651           If port evaluate to :py:obj:`False`
652            * if ``MPD_PORT`` environment variable is set, use it for port
653            * else use ``6600``
654         """
655         if not host:
656             host = self.host
657         else:
658             self.host = host
659         if not port:
660             port = self.port
661         else:
662             self.port = port
663         if self._sock is not None:
664             raise ConnectionError("Already connected")
665         if host.startswith("/"):
666             self._sock = self._connect_unix(host)
667         else:
668             self._sock = self._connect_tcp(host, port)
669         self._rfile = self._sock.makefile("r", encoding='utf-8', errors='surrogateescape')
670         self._rbfile = self._sock.makefile("rb")
671         self._wfile = self._sock.makefile("w", encoding='utf-8')
672         try:
673             self._hello()
674         except:
675             self.disconnect()
676             raise
677
678     def disconnect(self):
679         """Closes the MPD connection.
680         The client closes the actual socket, it does not use the
681         'close' request from MPD protocol (as suggested in documentation).
682         """
683         if hasattr(self._rfile, 'close'):
684             self._rfile.close()
685         if hasattr(self._rbfile, 'close'):
686             self._rbfile.close()
687         if hasattr(self._wfile, 'close'):
688             self._wfile.close()
689         if hasattr(self._sock, 'close'):
690             self._sock.close()
691         self._reset()
692
693     def fileno(self):
694         if self._sock is None:
695             raise ConnectionError("Not connected")
696         return self._sock.fileno()
697
698     def command_list_ok_begin(self):
699         if self._command_list is not None:
700             raise CommandListError("Already in command list")
701         if self._iterating:
702             raise IteratingError("Cannot begin command list while iterating")
703         if self._pending:
704             raise PendingCommandError("Cannot begin command list "
705                                       "with pending commands")
706         self._write_command("command_list_ok_begin")
707         self._command_list = []
708
709     def command_list_end(self):
710         if self._command_list is None:
711             raise CommandListError("Not in command list")
712         if self._iterating:
713             raise IteratingError("Already iterating over a command list")
714         self._write_command("command_list_end")
715         return self._fetch_command_list()
716
717
718 def escape(text):
719     return text.replace("\\", "\\\\").replace('"', '\\"')
720
721 # vim: set expandtab shiftwidth=4 softtabstop=4 textwidth=79: