##// END OF EJS Templates
Merge pull request #12113 from jonashaag/patch-1...
Matthias Bussonnier -
r25461:d90ef36a merge
parent child Browse files
Show More
@@ -1,648 +1,654 b''
1 1 """Various display related classes.
2 2
3 3 Authors : MinRK, gregcaporaso, dannystaple
4 4 """
5 5 from html import escape as html_escape
6 6 from os.path import exists, isfile, splitext, abspath, join, isdir
7 7 from os import walk, sep, fsdecode
8 8
9 9 from IPython.core.display import DisplayObject, TextDisplayObject
10 10
11 11 __all__ = ['Audio', 'IFrame', 'YouTubeVideo', 'VimeoVideo', 'ScribdDocument',
12 12 'FileLink', 'FileLinks', 'Code']
13 13
14 14
15 15 class Audio(DisplayObject):
16 16 """Create an audio object.
17 17
18 18 When this object is returned by an input cell or passed to the
19 19 display function, it will result in Audio controls being displayed
20 20 in the frontend (only works in the notebook).
21 21
22 22 Parameters
23 23 ----------
24 24 data : numpy array, list, unicode, str or bytes
25 25 Can be one of
26 26
27 27 * Numpy 1d array containing the desired waveform (mono)
28 28 * Numpy 2d array containing waveforms for each channel.
29 29 Shape=(NCHAN, NSAMPLES). For the standard channel order, see
30 30 http://msdn.microsoft.com/en-us/library/windows/hardware/dn653308(v=vs.85).aspx
31 31 * List of float or integer representing the waveform (mono)
32 32 * String containing the filename
33 33 * Bytestring containing raw PCM data or
34 34 * URL pointing to a file on the web.
35 35
36 36 If the array option is used, the waveform will be normalized.
37 37
38 38 If a filename or url is used, the format support will be browser
39 39 dependent.
40 40 url : unicode
41 41 A URL to download the data from.
42 42 filename : unicode
43 43 Path to a local file to load the data from.
44 44 embed : boolean
45 45 Should the audio data be embedded using a data URI (True) or should
46 46 the original source be referenced. Set this to True if you want the
47 47 audio to playable later with no internet connection in the notebook.
48 48
49 49 Default is `True`, unless the keyword argument `url` is set, then
50 50 default value is `False`.
51 51 rate : integer
52 52 The sampling rate of the raw data.
53 53 Only required when data parameter is being used as an array
54 54 autoplay : bool
55 55 Set to True if the audio should immediately start playing.
56 56 Default is `False`.
57 57 normalize : bool
58 58 Whether audio should be normalized (rescaled) to the maximum possible
59 59 range. Default is `True`. When set to `False`, `data` must be between
60 60 -1 and 1 (inclusive), otherwise an error is raised.
61 61 Applies only when `data` is a list or array of samples; other types of
62 62 audio are never normalized.
63 63
64 64 Examples
65 65 --------
66 66 ::
67 67
68 68 # Generate a sound
69 69 import numpy as np
70 70 framerate = 44100
71 71 t = np.linspace(0,5,framerate*5)
72 72 data = np.sin(2*np.pi*220*t) + np.sin(2*np.pi*224*t)
73 73 Audio(data,rate=framerate)
74 74
75 75 # Can also do stereo or more channels
76 76 dataleft = np.sin(2*np.pi*220*t)
77 77 dataright = np.sin(2*np.pi*224*t)
78 78 Audio([dataleft, dataright],rate=framerate)
79 79
80 80 Audio("http://www.nch.com.au/acm/8k16bitpcm.wav") # From URL
81 81 Audio(url="http://www.w3schools.com/html/horse.ogg")
82 82
83 83 Audio('/path/to/sound.wav') # From file
84 84 Audio(filename='/path/to/sound.ogg')
85 85
86 86 Audio(b'RAW_WAV_DATA..) # From bytes
87 87 Audio(data=b'RAW_WAV_DATA..)
88 88
89 89 See Also
90 90 --------
91 91
92 92 See also the ``Audio`` widgets form the ``ipywidget`` package for more flexibility and options.
93 93
94 94 """
95 95 _read_flags = 'rb'
96 96
97 97 def __init__(self, data=None, filename=None, url=None, embed=None, rate=None, autoplay=False, normalize=True, *,
98 98 element_id=None):
99 99 if filename is None and url is None and data is None:
100 100 raise ValueError("No audio data found. Expecting filename, url, or data.")
101 101 if embed is False and url is None:
102 102 raise ValueError("No url found. Expecting url when embed=False")
103 103
104 104 if url is not None and embed is not True:
105 105 self.embed = False
106 106 else:
107 107 self.embed = True
108 108 self.autoplay = autoplay
109 109 self.element_id = element_id
110 110 super(Audio, self).__init__(data=data, url=url, filename=filename)
111 111
112 112 if self.data is not None and not isinstance(self.data, bytes):
113 113 if rate is None:
114 114 raise ValueError("rate must be specified when data is a numpy array or list of audio samples.")
115 115 self.data = Audio._make_wav(data, rate, normalize)
116 116
117 117 def reload(self):
118 118 """Reload the raw data from file or URL."""
119 119 import mimetypes
120 120 if self.embed:
121 121 super(Audio, self).reload()
122 122
123 123 if self.filename is not None:
124 124 self.mimetype = mimetypes.guess_type(self.filename)[0]
125 125 elif self.url is not None:
126 126 self.mimetype = mimetypes.guess_type(self.url)[0]
127 127 else:
128 128 self.mimetype = "audio/wav"
129 129
130 130 @staticmethod
131 131 def _make_wav(data, rate, normalize):
132 132 """ Transform a numpy array to a PCM bytestring """
133 import struct
134 133 from io import BytesIO
135 134 import wave
136 135
137 136 try:
138 137 scaled, nchan = Audio._validate_and_normalize_with_numpy(data, normalize)
139 138 except ImportError:
140 139 scaled, nchan = Audio._validate_and_normalize_without_numpy(data, normalize)
141 140
142 141 fp = BytesIO()
143 142 waveobj = wave.open(fp,mode='wb')
144 143 waveobj.setnchannels(nchan)
145 144 waveobj.setframerate(rate)
146 145 waveobj.setsampwidth(2)
147 146 waveobj.setcomptype('NONE','NONE')
148 waveobj.writeframes(b''.join([struct.pack('<h',x) for x in scaled]))
147 waveobj.writeframes(scaled)
149 148 val = fp.getvalue()
150 149 waveobj.close()
151 150
152 151 return val
153 152
154 153 @staticmethod
155 154 def _validate_and_normalize_with_numpy(data, normalize):
156 155 import numpy as np
157 156
158 157 data = np.array(data, dtype=float)
159 158 if len(data.shape) == 1:
160 159 nchan = 1
161 160 elif len(data.shape) == 2:
162 161 # In wave files,channels are interleaved. E.g.,
163 162 # "L1R1L2R2..." for stereo. See
164 163 # http://msdn.microsoft.com/en-us/library/windows/hardware/dn653308(v=vs.85).aspx
165 164 # for channel ordering
166 165 nchan = data.shape[0]
167 166 data = data.T.ravel()
168 167 else:
169 168 raise ValueError('Array audio input must be a 1D or 2D array')
170 169
171 170 max_abs_value = np.max(np.abs(data))
172 171 normalization_factor = Audio._get_normalization_factor(max_abs_value, normalize)
173 scaled = np.int16(data / normalization_factor * 32767).tolist()
174 return scaled, nchan
172 scaled = data / normalization_factor * 32767
173 return scaled.astype('<h').tostring(), nchan
175 174
176 175
177 176 @staticmethod
178 177 def _validate_and_normalize_without_numpy(data, normalize):
178 import array
179 import sys
180
181 data = array.array('f', data)
182
179 183 try:
180 184 max_abs_value = float(max([abs(x) for x in data]))
181 185 except TypeError:
182 186 raise TypeError('Only lists of mono audio are '
183 187 'supported if numpy is not installed')
184 188
185 189 normalization_factor = Audio._get_normalization_factor(max_abs_value, normalize)
186 scaled = [int(x / normalization_factor * 32767) for x in data]
190 scaled = array.array('h', [int(x / normalization_factor * 32767) for x in data])
191 if sys.byteorder == 'big':
192 scaled.byteswap()
187 193 nchan = 1
188 return scaled, nchan
194 return scaled.tobytes(), nchan
189 195
190 196 @staticmethod
191 197 def _get_normalization_factor(max_abs_value, normalize):
192 198 if not normalize and max_abs_value > 1:
193 199 raise ValueError('Audio data must be between -1 and 1 when normalize=False.')
194 200 return max_abs_value if normalize else 1
195 201
196 202 def _data_and_metadata(self):
197 203 """shortcut for returning metadata with url information, if defined"""
198 204 md = {}
199 205 if self.url:
200 206 md['url'] = self.url
201 207 if md:
202 208 return self.data, md
203 209 else:
204 210 return self.data
205 211
206 212 def _repr_html_(self):
207 213 src = """
208 214 <audio {element_id} controls="controls" {autoplay}>
209 215 <source src="{src}" type="{type}" />
210 216 Your browser does not support the audio element.
211 217 </audio>
212 218 """
213 219 return src.format(src=self.src_attr(), type=self.mimetype, autoplay=self.autoplay_attr(),
214 220 element_id=self.element_id_attr())
215 221
216 222 def src_attr(self):
217 223 import base64
218 224 if self.embed and (self.data is not None):
219 225 data = base64=base64.b64encode(self.data).decode('ascii')
220 226 return """data:{type};base64,{base64}""".format(type=self.mimetype,
221 227 base64=data)
222 228 elif self.url is not None:
223 229 return self.url
224 230 else:
225 231 return ""
226 232
227 233 def autoplay_attr(self):
228 234 if(self.autoplay):
229 235 return 'autoplay="autoplay"'
230 236 else:
231 237 return ''
232 238
233 239 def element_id_attr(self):
234 240 if (self.element_id):
235 241 return 'id="{element_id}"'.format(element_id=self.element_id)
236 242 else:
237 243 return ''
238 244
239 245 class IFrame(object):
240 246 """
241 247 Generic class to embed an iframe in an IPython notebook
242 248 """
243 249
244 250 iframe = """
245 251 <iframe
246 252 width="{width}"
247 253 height="{height}"
248 254 src="{src}{params}"
249 255 frameborder="0"
250 256 allowfullscreen
251 257 ></iframe>
252 258 """
253 259
254 260 def __init__(self, src, width, height, **kwargs):
255 261 self.src = src
256 262 self.width = width
257 263 self.height = height
258 264 self.params = kwargs
259 265
260 266 def _repr_html_(self):
261 267 """return the embed iframe"""
262 268 if self.params:
263 269 try:
264 270 from urllib.parse import urlencode # Py 3
265 271 except ImportError:
266 272 from urllib import urlencode
267 273 params = "?" + urlencode(self.params)
268 274 else:
269 275 params = ""
270 276 return self.iframe.format(src=self.src,
271 277 width=self.width,
272 278 height=self.height,
273 279 params=params)
274 280
275 281 class YouTubeVideo(IFrame):
276 282 """Class for embedding a YouTube Video in an IPython session, based on its video id.
277 283
278 284 e.g. to embed the video from https://www.youtube.com/watch?v=foo , you would
279 285 do::
280 286
281 287 vid = YouTubeVideo("foo")
282 288 display(vid)
283 289
284 290 To start from 30 seconds::
285 291
286 292 vid = YouTubeVideo("abc", start=30)
287 293 display(vid)
288 294
289 295 To calculate seconds from time as hours, minutes, seconds use
290 296 :class:`datetime.timedelta`::
291 297
292 298 start=int(timedelta(hours=1, minutes=46, seconds=40).total_seconds())
293 299
294 300 Other parameters can be provided as documented at
295 301 https://developers.google.com/youtube/player_parameters#Parameters
296 302
297 303 When converting the notebook using nbconvert, a jpeg representation of the video
298 304 will be inserted in the document.
299 305 """
300 306
301 307 def __init__(self, id, width=400, height=300, **kwargs):
302 308 self.id=id
303 309 src = "https://www.youtube.com/embed/{0}".format(id)
304 310 super(YouTubeVideo, self).__init__(src, width, height, **kwargs)
305 311
306 312 def _repr_jpeg_(self):
307 313 # Deferred import
308 314 from urllib.request import urlopen
309 315
310 316 try:
311 317 return urlopen("https://img.youtube.com/vi/{id}/hqdefault.jpg".format(id=self.id)).read()
312 318 except IOError:
313 319 return None
314 320
315 321 class VimeoVideo(IFrame):
316 322 """
317 323 Class for embedding a Vimeo video in an IPython session, based on its video id.
318 324 """
319 325
320 326 def __init__(self, id, width=400, height=300, **kwargs):
321 327 src="https://player.vimeo.com/video/{0}".format(id)
322 328 super(VimeoVideo, self).__init__(src, width, height, **kwargs)
323 329
324 330 class ScribdDocument(IFrame):
325 331 """
326 332 Class for embedding a Scribd document in an IPython session
327 333
328 334 Use the start_page params to specify a starting point in the document
329 335 Use the view_mode params to specify display type one off scroll | slideshow | book
330 336
331 337 e.g to Display Wes' foundational paper about PANDAS in book mode from page 3
332 338
333 339 ScribdDocument(71048089, width=800, height=400, start_page=3, view_mode="book")
334 340 """
335 341
336 342 def __init__(self, id, width=400, height=300, **kwargs):
337 343 src="https://www.scribd.com/embeds/{0}/content".format(id)
338 344 super(ScribdDocument, self).__init__(src, width, height, **kwargs)
339 345
340 346 class FileLink(object):
341 347 """Class for embedding a local file link in an IPython session, based on path
342 348
343 349 e.g. to embed a link that was generated in the IPython notebook as my/data.txt
344 350
345 351 you would do::
346 352
347 353 local_file = FileLink("my/data.txt")
348 354 display(local_file)
349 355
350 356 or in the HTML notebook, just::
351 357
352 358 FileLink("my/data.txt")
353 359 """
354 360
355 361 html_link_str = "<a href='%s' target='_blank'>%s</a>"
356 362
357 363 def __init__(self,
358 364 path,
359 365 url_prefix='',
360 366 result_html_prefix='',
361 367 result_html_suffix='<br>'):
362 368 """
363 369 Parameters
364 370 ----------
365 371 path : str
366 372 path to the file or directory that should be formatted
367 373 url_prefix : str
368 374 prefix to be prepended to all files to form a working link [default:
369 375 '']
370 376 result_html_prefix : str
371 377 text to append to beginning to link [default: '']
372 378 result_html_suffix : str
373 379 text to append at the end of link [default: '<br>']
374 380 """
375 381 if isdir(path):
376 382 raise ValueError("Cannot display a directory using FileLink. "
377 383 "Use FileLinks to display '%s'." % path)
378 384 self.path = fsdecode(path)
379 385 self.url_prefix = url_prefix
380 386 self.result_html_prefix = result_html_prefix
381 387 self.result_html_suffix = result_html_suffix
382 388
383 389 def _format_path(self):
384 390 fp = ''.join([self.url_prefix, html_escape(self.path)])
385 391 return ''.join([self.result_html_prefix,
386 392 self.html_link_str % \
387 393 (fp, html_escape(self.path, quote=False)),
388 394 self.result_html_suffix])
389 395
390 396 def _repr_html_(self):
391 397 """return html link to file
392 398 """
393 399 if not exists(self.path):
394 400 return ("Path (<tt>%s</tt>) doesn't exist. "
395 401 "It may still be in the process of "
396 402 "being generated, or you may have the "
397 403 "incorrect path." % self.path)
398 404
399 405 return self._format_path()
400 406
401 407 def __repr__(self):
402 408 """return absolute path to file
403 409 """
404 410 return abspath(self.path)
405 411
406 412 class FileLinks(FileLink):
407 413 """Class for embedding local file links in an IPython session, based on path
408 414
409 415 e.g. to embed links to files that were generated in the IPython notebook
410 416 under ``my/data``, you would do::
411 417
412 418 local_files = FileLinks("my/data")
413 419 display(local_files)
414 420
415 421 or in the HTML notebook, just::
416 422
417 423 FileLinks("my/data")
418 424 """
419 425 def __init__(self,
420 426 path,
421 427 url_prefix='',
422 428 included_suffixes=None,
423 429 result_html_prefix='',
424 430 result_html_suffix='<br>',
425 431 notebook_display_formatter=None,
426 432 terminal_display_formatter=None,
427 433 recursive=True):
428 434 """
429 435 See :class:`FileLink` for the ``path``, ``url_prefix``,
430 436 ``result_html_prefix`` and ``result_html_suffix`` parameters.
431 437
432 438 included_suffixes : list
433 439 Filename suffixes to include when formatting output [default: include
434 440 all files]
435 441
436 442 notebook_display_formatter : function
437 443 Used to format links for display in the notebook. See discussion of
438 444 formatter functions below.
439 445
440 446 terminal_display_formatter : function
441 447 Used to format links for display in the terminal. See discussion of
442 448 formatter functions below.
443 449
444 450 Formatter functions must be of the form::
445 451
446 452 f(dirname, fnames, included_suffixes)
447 453
448 454 dirname : str
449 455 The name of a directory
450 456 fnames : list
451 457 The files in that directory
452 458 included_suffixes : list
453 459 The file suffixes that should be included in the output (passing None
454 460 meansto include all suffixes in the output in the built-in formatters)
455 461 recursive : boolean
456 462 Whether to recurse into subdirectories. Default is True.
457 463
458 464 The function should return a list of lines that will be printed in the
459 465 notebook (if passing notebook_display_formatter) or the terminal (if
460 466 passing terminal_display_formatter). This function is iterated over for
461 467 each directory in self.path. Default formatters are in place, can be
462 468 passed here to support alternative formatting.
463 469
464 470 """
465 471 if isfile(path):
466 472 raise ValueError("Cannot display a file using FileLinks. "
467 473 "Use FileLink to display '%s'." % path)
468 474 self.included_suffixes = included_suffixes
469 475 # remove trailing slashes for more consistent output formatting
470 476 path = path.rstrip('/')
471 477
472 478 self.path = path
473 479 self.url_prefix = url_prefix
474 480 self.result_html_prefix = result_html_prefix
475 481 self.result_html_suffix = result_html_suffix
476 482
477 483 self.notebook_display_formatter = \
478 484 notebook_display_formatter or self._get_notebook_display_formatter()
479 485 self.terminal_display_formatter = \
480 486 terminal_display_formatter or self._get_terminal_display_formatter()
481 487
482 488 self.recursive = recursive
483 489
484 490 def _get_display_formatter(self,
485 491 dirname_output_format,
486 492 fname_output_format,
487 493 fp_format,
488 494 fp_cleaner=None):
489 495 """ generate built-in formatter function
490 496
491 497 this is used to define both the notebook and terminal built-in
492 498 formatters as they only differ by some wrapper text for each entry
493 499
494 500 dirname_output_format: string to use for formatting directory
495 501 names, dirname will be substituted for a single "%s" which
496 502 must appear in this string
497 503 fname_output_format: string to use for formatting file names,
498 504 if a single "%s" appears in the string, fname will be substituted
499 505 if two "%s" appear in the string, the path to fname will be
500 506 substituted for the first and fname will be substituted for the
501 507 second
502 508 fp_format: string to use for formatting filepaths, must contain
503 509 exactly two "%s" and the dirname will be substituted for the first
504 510 and fname will be substituted for the second
505 511 """
506 512 def f(dirname, fnames, included_suffixes=None):
507 513 result = []
508 514 # begin by figuring out which filenames, if any,
509 515 # are going to be displayed
510 516 display_fnames = []
511 517 for fname in fnames:
512 518 if (isfile(join(dirname,fname)) and
513 519 (included_suffixes is None or
514 520 splitext(fname)[1] in included_suffixes)):
515 521 display_fnames.append(fname)
516 522
517 523 if len(display_fnames) == 0:
518 524 # if there are no filenames to display, don't print anything
519 525 # (not even the directory name)
520 526 pass
521 527 else:
522 528 # otherwise print the formatted directory name followed by
523 529 # the formatted filenames
524 530 dirname_output_line = dirname_output_format % dirname
525 531 result.append(dirname_output_line)
526 532 for fname in display_fnames:
527 533 fp = fp_format % (dirname,fname)
528 534 if fp_cleaner is not None:
529 535 fp = fp_cleaner(fp)
530 536 try:
531 537 # output can include both a filepath and a filename...
532 538 fname_output_line = fname_output_format % (fp, fname)
533 539 except TypeError:
534 540 # ... or just a single filepath
535 541 fname_output_line = fname_output_format % fname
536 542 result.append(fname_output_line)
537 543 return result
538 544 return f
539 545
540 546 def _get_notebook_display_formatter(self,
541 547 spacer="&nbsp;&nbsp;"):
542 548 """ generate function to use for notebook formatting
543 549 """
544 550 dirname_output_format = \
545 551 self.result_html_prefix + "%s/" + self.result_html_suffix
546 552 fname_output_format = \
547 553 self.result_html_prefix + spacer + self.html_link_str + self.result_html_suffix
548 554 fp_format = self.url_prefix + '%s/%s'
549 555 if sep == "\\":
550 556 # Working on a platform where the path separator is "\", so
551 557 # must convert these to "/" for generating a URI
552 558 def fp_cleaner(fp):
553 559 # Replace all occurrences of backslash ("\") with a forward
554 560 # slash ("/") - this is necessary on windows when a path is
555 561 # provided as input, but we must link to a URI
556 562 return fp.replace('\\','/')
557 563 else:
558 564 fp_cleaner = None
559 565
560 566 return self._get_display_formatter(dirname_output_format,
561 567 fname_output_format,
562 568 fp_format,
563 569 fp_cleaner)
564 570
565 571 def _get_terminal_display_formatter(self,
566 572 spacer=" "):
567 573 """ generate function to use for terminal formatting
568 574 """
569 575 dirname_output_format = "%s/"
570 576 fname_output_format = spacer + "%s"
571 577 fp_format = '%s/%s'
572 578
573 579 return self._get_display_formatter(dirname_output_format,
574 580 fname_output_format,
575 581 fp_format)
576 582
577 583 def _format_path(self):
578 584 result_lines = []
579 585 if self.recursive:
580 586 walked_dir = list(walk(self.path))
581 587 else:
582 588 walked_dir = [next(walk(self.path))]
583 589 walked_dir.sort()
584 590 for dirname, subdirs, fnames in walked_dir:
585 591 result_lines += self.notebook_display_formatter(dirname, fnames, self.included_suffixes)
586 592 return '\n'.join(result_lines)
587 593
588 594 def __repr__(self):
589 595 """return newline-separated absolute paths
590 596 """
591 597 result_lines = []
592 598 if self.recursive:
593 599 walked_dir = list(walk(self.path))
594 600 else:
595 601 walked_dir = [next(walk(self.path))]
596 602 walked_dir.sort()
597 603 for dirname, subdirs, fnames in walked_dir:
598 604 result_lines += self.terminal_display_formatter(dirname, fnames, self.included_suffixes)
599 605 return '\n'.join(result_lines)
600 606
601 607
602 608 class Code(TextDisplayObject):
603 609 """Display syntax-highlighted source code.
604 610
605 611 This uses Pygments to highlight the code for HTML and Latex output.
606 612
607 613 Parameters
608 614 ----------
609 615 data : str
610 616 The code as a string
611 617 url : str
612 618 A URL to fetch the code from
613 619 filename : str
614 620 A local filename to load the code from
615 621 language : str
616 622 The short name of a Pygments lexer to use for highlighting.
617 623 If not specified, it will guess the lexer based on the filename
618 624 or the code. Available lexers: http://pygments.org/docs/lexers/
619 625 """
620 626 def __init__(self, data=None, url=None, filename=None, language=None):
621 627 self.language = language
622 628 super().__init__(data=data, url=url, filename=filename)
623 629
624 630 def _get_lexer(self):
625 631 if self.language:
626 632 from pygments.lexers import get_lexer_by_name
627 633 return get_lexer_by_name(self.language)
628 634 elif self.filename:
629 635 from pygments.lexers import get_lexer_for_filename
630 636 return get_lexer_for_filename(self.filename)
631 637 else:
632 638 from pygments.lexers import guess_lexer
633 639 return guess_lexer(self.data)
634 640
635 641 def __repr__(self):
636 642 return self.data
637 643
638 644 def _repr_html_(self):
639 645 from pygments import highlight
640 646 from pygments.formatters import HtmlFormatter
641 647 fmt = HtmlFormatter()
642 648 style = '<style>{}</style>'.format(fmt.get_style_defs('.output_html'))
643 649 return style + highlight(self.data, self._get_lexer(), fmt)
644 650
645 651 def _repr_latex_(self):
646 652 from pygments import highlight
647 653 from pygments.formatters import LatexFormatter
648 654 return highlight(self.data, self._get_lexer(), LatexFormatter())
General Comments 0
You need to be logged in to leave comments. Login now