deejayd-0.10.0/0000755000175000017500000000000011354730161011402 5ustar royroydeejayd-0.10.0/COPYING0000644000175000017500000004311011351210474012431 0ustar royroy GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc. 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Library General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. , 1 April 1989 Ty Coon, President of Vice deejayd-0.10.0/testdeejayd/0000755000175000017500000000000011354730161013707 5ustar royroydeejayd-0.10.0/testdeejayd/server.py0000644000175000017500000000612311351210475015567 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """ Tools to create a test server. """ import os, signal, os.path, sys, subprocess logfiles = ['/tmp/testdeejayd.log', '/tmp/testdeejayd-webui.log'] for logfile in logfiles: if os.path.isfile(logfile): os.unlink(logfile) class TestServer: """Implements a server ready for testing.""" def __init__(self, conf_file): self.conf_file = conf_file self.serverExecRelPath = 'scripts/testserver' self.srcpath = self.findSrcPath() def findSrcPath(self): # Get the server executable path, assuming it is names # scripts/testserver in a subdirectory of $PYTHONPATH absPath = '' notFound = True sysPathIterator = iter(sys.path) while notFound: absPath = sysPathIterator.next() serverScriptPath = os.path.join(absPath, self.serverExecRelPath) if os.path.exists(serverScriptPath): notFound = False if notFound: raise Exception('Cannot find server executable') return os.path.abspath(absPath) def start(self): serverExec = os.path.join(self.srcpath, self.serverExecRelPath) if not os.access(serverExec, os.X_OK): sys.exit("The test server executable '%s' is not executable."\ % serverExec) args = [serverExec, self.conf_file] env = {'PYTHONPATH': self.srcpath, "PATH": os.getenv('PATH'),\ 'LANG': os.getenv('LANG')} self.__serverProcess = subprocess.Popen(args = args, env = env, stderr = subprocess.PIPE, stdout = sys.stdout.fileno(), close_fds = True) firstLine = self.__serverProcess.stderr.readline() if not firstLine == 'ready\n': # Should not occur print firstLine self.stop() raise Exception('Reactor does not seem to be ready') def stop(self): # Send stop signal to reactor os.kill(self.__serverProcess.pid, signal.SIGINT) # Wait for the process to finish self.__serverProcess.wait() # vim: ts=4 sw=4 expandtab deejayd-0.10.0/testdeejayd/profiles/0000755000175000017500000000000011354730161015532 5ustar royroydeejayd-0.10.0/testdeejayd/profiles/default0000644000175000017500000000064311351210475017102 0ustar royroy[general] log = info activated_modes = playlist,panel,webradio,video,dvd fullscreen = no replaygain = yes media_backend = xine [net] enabled = yes port = 23344 bind_addresses = localhost [webui] enabled = yes port = 23380 bind_addresses = localhost refresh = 0 [database] db_type = sqlite [mediadb] filesystem_charset = utf-8 [panel] panel_tags = genre,artist,album [xine] audio_output = none video_output = none deejayd-0.10.0/testdeejayd/profiles/mysql0000644000175000017500000000101611351210475016616 0ustar royroy[general] log = info activated_modes = playlist,panel,webradio,video,dvd fullscreen = no replaygain = yes media_backend = xine [net] enabled = yes port = 23344 bind_addresses = localhost [webui] enabled = yes port = 23380 bind_addresses = localhost refresh = 0 [database] db_type = mysql db_name = deejayd_test db_user = deejayd_test db_password = deejayd_test db_host = localhost db_port = 3300 [mediadb] filesystem_charset = utf-8 [panel] panel_tags = genre,artist,album [xine] audio_output = none video_output = none deejayd-0.10.0/testdeejayd/profiles/gstreamer0000644000175000017500000000062711351210475017451 0ustar royroy[general] log = info activated_modes = playlist,panel,webradio fullscreen = no replaygain = yes media_backend = gstreamer [net] enabled = yes port = 23344 bind_addresses = localhost [webui] enabled = yes port = 23380 bind_addresses = localhost refresh = 0 [database] db_type = sqlite [mediadb] filesystem_charset = utf-8 [panel] panel_tags = genre,various_artist,album [gstreamer] audio_output = fake deejayd-0.10.0/testdeejayd/test_database.py0000644000175000017500000000667311351210475017076 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import os, unittest from testdeejayd import TestCaseWithData from deejayd.mediafilters import * import deejayd.database import deejayd.ui.config from deejayd.database.dbobjects import SQLizer class TestDatabase(TestCaseWithData): def setUp(self): super(TestDatabase, self).setUp() config = deejayd.ui.config.DeejaydConfig() config.set('database', 'db_type', 'sqlite') dbfilename = self.testdata.getRandomString() + '.db' self.dbfilename = os.path.join('/tmp', dbfilename) config.set('database', 'db_name', self.dbfilename) self.db = deejayd.database.init(config) self.sqlizer = SQLizer() def tearDown(self): self.db.close() os.unlink(self.dbfilename) def test_basicfilter_save_retrieve(self): """Test the saving and retrieval of a basic filter""" f_class = self.testdata.getRandomElement(BASIC_FILTERS) f = f_class(self.testdata.getRandomString(), self.testdata.getRandomString()) sqlf = self.sqlizer.translate(f) fid = sqlf.save(self.db) self.db.connection.commit() cursor = self.db.connection.cursor() retrieved_filter = self.db.get_filter(cursor, fid) self.failUnless(f.equals(retrieved_filter)) sqlf.tag = self.testdata.getRandomString() sqlf.save(self.db) self.db.connection.commit() retrieved_filter = self.db.get_filter(cursor, fid) self.failUnless(sqlf.equals(retrieved_filter)) cursor.close() def test_complex_filter_save_retrieve(self): """Test the saving and retrieval of a complex filter""" f = self.testdata.get_sample_filter() sqlf = self.sqlizer.translate(f) fid = sqlf.save(self.db) self.db.connection.commit() cursor = self.db.connection.cursor() retrieved_filter = self.db.get_filter(cursor, fid) self.assert_filter_matches_sample(retrieved_filter) cursor.close() def test_magic_medialist_save_retrieve(self): """Test the saving and retrieval of a magic medialist""" f = self.testdata.get_sample_filter() pl_name = self.testdata.getRandomString() pl_id = self.db.set_magic_medialist_filters(pl_name, [f]) # retrieve magic playlist retrieved_filters = self.db.get_magic_medialist_filters(pl_id) self.assertEqual(len(retrieved_filters), 1) self.failUnless(f.equals(retrieved_filters[0])) # resave the same playlist self.db.set_magic_medialist_filters(pl_name, retrieved_filters) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/testdeejayd/data.py0000644000175000017500000000273411351210475015176 0ustar royroy# -*- coding: utf-8 -*- # Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. songlibrary = [ {"media_id":1, 'filename': 'rock/Dolly - Dolly/01-dolly-je_n_veux_pas_rester_sage.ogg', 'artist': 'Dolly', 'album': 'Dolly', 'title': "Je n'veux pas rester sage"}, {"media_id":2, 'filename': 'rap/MC Solar/LTDLQ/04-mc_solaar-onzième_commandement.ogg', 'artist': 'MC Solar', 'album': 'Le tour de la question CD1', 'title': "Onzième commandement", 'track': '04/20'}, {"media_id":3, 'filename': 'dance/singles/Ultra Nate - Free.mp3', 'artist': 'Ultra Nate', 'title': "Free"} ] sample_genres = ('Country', 'Disco', 'Rock', 'Trance', 'R&B', 'Big beat', ) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/testdeejayd/test_library.py0000644000175000017500000003177411351210475016776 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """ Deejayd DB testing module """ import os,time from testdeejayd import TestCaseWithAudioData, TestCaseWithVideoData from deejayd import database, mediafilters, plugins from deejayd.database.queries import DatabaseQueries from deejayd.mediadb.library import AudioLibrary,VideoLibrary,NotFoundException from deejayd.mediadb import inotify # FIXME : Those imports should really go away one day from deejayd.player import xine from deejayd.ui.config import DeejaydConfig class _TestDeejayDBLibrary(object): def setUp(self): self.dbfilename = '/tmp/testdeejayddb-'+self.testdata.getRandomString() # init player config = DeejaydConfig() config.set('database', 'db_type', 'sqlite') config.set('database', 'db_name', self.dbfilename) config.set('xine','audio_output',"none") config.set('xine','video_output',"none") self.db = database.init(config) player = xine.XinePlayer(self.db, plugins.PluginManager(config), config) player.init_video_support() self.library = self.__class__.library_class(self.db, player, \ self.testdata.getRootDir()) self.library._update() self.do_update = True def tearDown(self): self.db.close() os.remove(self.dbfilename) def verifyMediaDBContent(self, testTag=True): time.sleep(0.2) if self.do_update: # First update mediadb self.library._update() else: time.sleep(1.5) self.assertRaises(NotFoundException, self.library.get_dir_content, self.testdata.getRandomString()) self.assertRaises(NotFoundException, self.library.get_file, self.testdata.getRandomString()) self.__verifyRoot(self.testdata.getRootDir(), testTag) def __verifyRoot(self, requested_root, testTag=True, inlink_path=None): for root, dirs, files in os.walk(requested_root): try: root = root.decode("utf-8", "strict").encode("utf-8") except UnicodeError: continue strip_root = self.testdata.stripRoot(root) new_dirs = [] for d in dirs: try: d = d.decode("utf-8", "strict").encode("utf-8") except UnicodeError: continue dir_path = os.path.join(root, d) if os.path.islink(dir_path): rel_path = os.path.join(strip_root, d) self.__verifyRoot(dir_path, testTag, rel_path) new_dirs.append(d) dirs = new_dirs new_files = [] for f in files: try: f = f.decode("utf-8", "strict").encode("utf-8") except UnicodeError: continue if f != "cover.jpg" and not f.endswith(".srt"): new_files.append(f) files = new_files try: contents = self.library.get_dir_content(strip_root) except NotFoundException: allContents = self.library.get_dir_content('') self.assert_(False, "'%s' is in directory tree but was not found in DB %s" %\ (root,str(allContents))) # First, verify directory list self.assertEqual(len(contents["dirs"]), len(dirs)) for dir in dirs: self.assert_(dir in contents["dirs"], "'%s' is in directory tree but was not found in DB %s in current root '%s'" % (dir,str(contents["dirs"]),root)) # then, verify file list self.assertEqual(len(contents["files"]), len(files)) db_files = [f["filename"] for f in contents["files"]] for file in files: (name,ext) = os.path.splitext(file) if ext.lower() in self.__class__.supported_ext: self.assert_(file in db_files, "'%s' is a file in directory tree but was not found in DB"\ % file) relPath = os.path.join(strip_root, file) if testTag: self.verifyTag(relPath, inlink_path) def verifyTag(self, filePath, inlink_path=None): try: inDBfile = self.library.get_file(filePath) except NotFoundException: self.assert_(False, "'%s' is a file in directory tree but was not found in DB"\ % filePath) else: inDBfile = inDBfile[0] if inlink_path: link_full_path = os.path.join(self.testdata.getRootDir(), inlink_path) abs_path = filePath[len(inlink_path)+1:] realFile = self.testdata.dirlinks[link_full_path].medias[abs_path] else: realFile = self.testdata.medias[filePath] return (inDBfile, realFile) for tag in ("title","artist","album","genre"): self.assert_(realFile[tag] == inDBfile[tag], "tag %s for %s different between DB and reality %s != %s" % \ (tag,realFile["filename"],realFile[tag],inDBfile[tag])) def testDirlinks(self): self.testdata.addDirLink() self.verifyMediaDBContent() self.testdata.moveDirLink() self.verifyMediaDBContent() self.testdata.removeDirLink() self.verifyMediaDBContent() class TestVideoLibrary(TestCaseWithVideoData, _TestDeejayDBLibrary): library_class = VideoLibrary supported_ext = (".mpg",".avi") def setUp(self): TestCaseWithVideoData.setUp(self) _TestDeejayDBLibrary.setUp(self) def tearDown(self): _TestDeejayDBLibrary.tearDown(self) TestCaseWithVideoData.tearDown(self) def testGetDir(self): """built directory detected by video library""" self.verifyMediaDBContent() def testAddSubdirectory(self): """Add a subdirectory in video library""" self.testdata.addSubdir() self.verifyMediaDBContent() def testAddMedia(self): """Add a media file in video library""" self.testdata.addMedia() self.verifyMediaDBContent() def testAddSubtitle(self): """Add a subtitle file in video library""" self.testdata.add_subtitle() self.verifyMediaDBContent() def testRemoveSubtitle(self): """Remove a subtitle file in video library""" self.testdata.remove_subtitle() self.verifyMediaDBContent() def verifyTag(self, filePath, inlink_path=None): (inDBfile, realFile) = _TestDeejayDBLibrary.verifyTag(self, filePath, inlink_path) for tag in ('length','videowidth','videoheight','external_subtitle'): self.assertEqual(str(realFile[tag]), str(inDBfile[tag])) class TestAudioLibrary(TestCaseWithAudioData, _TestDeejayDBLibrary): library_class = AudioLibrary supported_ext = (".ogg",".mp3",".mp4",".flac") def setUp(self): TestCaseWithAudioData.setUp(self) _TestDeejayDBLibrary.setUp(self) def tearDown(self): TestCaseWithAudioData.tearDown(self) _TestDeejayDBLibrary.tearDown(self) def testGetDir(self): """built directory detected by audio library""" self.verifyMediaDBContent() def testAddDirectory(self): """Add a directory in audio library""" self.testdata.addDir() self.verifyMediaDBContent() def testAddSubdirectory(self): """Add a subdirectory in audio library""" self.testdata.addSubdir() self.verifyMediaDBContent() def testAddSubSubdirectory(self): """Add a subsubdirectory in audio library""" self.testdata.addSubSubdir() self.verifyMediaDBContent() def testRenameDirectory(self): """Rename a directory in audio library""" self.testdata.renameDir() self.verifyMediaDBContent(False) def testRemoveDirectory(self): """Remove a directory in audio library""" self.testdata.removeDir() self.verifyMediaDBContent() def testAddMedia(self): """Add a media file in audio library""" self.testdata.addMedia() self.verifyMediaDBContent() def testRenameMedia(self): """Rename a media file in audio library""" self.testdata.renameMedia() self.verifyMediaDBContent(False) def testRemoveMedia(self): """Remove a media file in audio library""" self.testdata.removeMedia() self.verifyMediaDBContent() def testChangeTag(self): """Tag value change detected by audio library""" self.testdata.changeMediaTags() self.verifyMediaDBContent() def testAddCover(self): """Add cover in audio library""" self.testdata.add_cover() self.verifyMediaDBContent() def testRemoveCover(self): """Add cover in audio library""" self.testdata.remove_cover() self.verifyMediaDBContent() def testSearchFile(self): """Search a file in audio library""" filter = mediafilters.Contains("genre", self.testdata.getRandomString()) self.assertEqual([], self.library.search(filter)) searched_genre = self.testdata.getRandomGenre() filter = mediafilters.Contains("genre", searched_genre) matched_medias_uri = [] for path, media in self.testdata.medias.items(): if searched_genre == media.tags['genre']: matched_medias_uri.append(media.tags['uri']) found_items_uri = [x['uri'] for x in self.library.search(filter)] self.assertEqual(len(matched_medias_uri), len(found_items_uri)) found_items_uri.sort() matched_medias_uri.sort() self.assertEqual(found_items_uri, matched_medias_uri) def verifyTag(self,filePath, inlink_path=None): (inDBfile, realFile) = _TestDeejayDBLibrary.verifyTag(self, filePath, inlink_path) for tag in ("title","artist","album","genre"): self.assert_(realFile[tag] == inDBfile[tag], "tag %s for %s different between DB and reality %s != %s" % \ (tag,realFile["filename"],realFile[tag],inDBfile[tag])) class TestInotifySupport(TestCaseWithAudioData, _TestDeejayDBLibrary): library_class = AudioLibrary supported_ext = (".ogg",".mp3",".mp4",".flac") def setUp(self): TestCaseWithAudioData.setUp(self) _TestDeejayDBLibrary.setUp(self) self.do_update = False # start inotify thread self.watcher = inotify.get_watcher(self.db, self.library, None) self.watcher.start() time.sleep(5) def tearDown(self): self.watcher.close() _TestDeejayDBLibrary.tearDown(self) TestCaseWithAudioData.tearDown(self) def testAddMedia(self): """Inotify support : Add a media in audio library""" self.testdata.addMedia() self.verifyMediaDBContent() def testAddSubdirectory(self): """Inotify support : Add a subdirectory""" self.testdata.addSubdir() self.verifyMediaDBContent() def testAddSubSubdirectory(self): """Inotify support : Add a subsubdirectory""" self.testdata.addSubSubdir() self.verifyMediaDBContent() def testRenameDirectory(self): """Inotify support : Rename a directory""" self.testdata.renameDir() self.verifyMediaDBContent(False) def testRemoveDirectory(self): """Inotify support : Remove a directory""" self.testdata.removeDir() self.verifyMediaDBContent() def testChangeTag(self): """Inotify support : Tag value change detected""" self.testdata.changeMediaTags() self.verifyMediaDBContent() def verifyTag(self, filePath, inlink_path=None): (inDBfile, realFile) = _TestDeejayDBLibrary.verifyTag(self, filePath, inlink_path) for tag in ("title","artist","album","genre"): self.assert_(realFile[tag] == inDBfile[tag], "tag %s for %s different between DB and reality %s != %s" % \ (tag,realFile["filename"],realFile[tag],inDBfile[tag])) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/testdeejayd/test_core.py0000644000175000017500000000600311351210475016245 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import os from testdeejayd import TestCaseWithAudioAndVideoData from testdeejayd.coreinterface import InterfaceTests, InterfaceSubscribeTests from deejayd.core import DeejayDaemonCore from deejayd.ui.config import DeejaydConfig class TestCore(TestCaseWithAudioAndVideoData, InterfaceTests, InterfaceSubscribeTests): """Test the deejayd daemon core.""" def setUp(self): TestCaseWithAudioAndVideoData.setUp(self) config = DeejaydConfig() self.dbfilename = '/tmp/testdeejayddb-' +\ self.testdata.getRandomString() + '.db' config.set('general', 'activated_modes',\ 'playlist,panel,webradio,video,dvd') config.set('database', 'db_type', 'sqlite') config.set('database', 'db_name', self.dbfilename) config.set('mediadb','music_directory',self.test_audiodata.getRootDir()) config.set('mediadb','video_directory',self.test_videodata.getRootDir()) # player option config.set('general','media_backend',"xine") config.set('general','video_support',"yes") config.set('xine','audio_output',"none") config.set('xine','video_output',"none") self.deejayd = DeejayDaemonCore(config) self.deejayd.audio_library._update() self.deejayd.video_library._update() def tearDown(self): self.deejayd.close() os.unlink(self.dbfilename) TestCaseWithAudioAndVideoData.tearDown(self) def test_objanswer_mechanism(self): """Test the objanswer mechanism to disable DeejaydAnswer objects in returns parameters.""" known_mode = 'playlist' # objanswer mechanism on (default) ans = self.deejayd.set_mode(known_mode) self.failUnless(ans.get_contents()) ans = self.deejayd.get_status() self.assertEqual(ans.get_contents()['mode'], known_mode) # objanswer mechanism off ans = self.deejayd.set_mode(known_mode, objanswer=False) self.failUnless(ans == None) ans = self.deejayd.get_status(objanswer=False) self.assertEqual(ans['mode'], known_mode) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/testdeejayd/__init__.py0000644000175000017500000001455411351210475016027 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import sys, os, shutil import unittest from testdeejayd.databuilder import TestData, TestAudioCollection,\ TestVideoCollection from testdeejayd.server import TestServer from deejayd.ui.config import DeejaydConfig class DeejaydTest(unittest.TestCase): def setUp(self): from deejayd.ui.i18n import DeejaydTranslations t = DeejaydTranslations() t.install() class TestCaseWithData(DeejaydTest): def setUp(self): super(TestCaseWithData, self).setUp() self.testdata = TestData() def assert_filter_matches_sample(self, retrieved_filter): self.assertEqual(retrieved_filter.__class__.__name__, 'And') anded = retrieved_filter.filterlist self.assertEqual(anded[0].__class__.__name__, 'Contains') self.assertEqual(anded[0].tag, 'artist') self.assertEqual(anded[0].pattern, 'Britney') self.assertEqual(anded[1].__class__.__name__, 'Or') ored = anded[1].filterlist self.assertEqual(ored[0].__class__.__name__, 'Equals') self.assertEqual(ored[0].tag, 'genre') self.assertEqual(ored[0].pattern, 'Classical') self.assertEqual(ored[1].__class__.__name__, 'Equals') self.assertEqual(ored[1].tag, 'genre') self.assertEqual(ored[1].pattern, 'Disco') class _TestCaseWithMediaData(DeejaydTest): def setUp(self): super(_TestCaseWithMediaData, self).setUp() self.testdata = self.collection_class() self.testdata.buildLibraryDirectoryTree() def tearDown(self): self.testdata.cleanLibraryDirectoryTree() class TestCaseWithAudioData(_TestCaseWithMediaData): collection_class = TestAudioCollection class TestCaseWithVideoData(_TestCaseWithMediaData): collection_class = TestVideoCollection class TestCaseWithAudioAndVideoData(DeejaydTest): video_support = True def setUp(self): super(TestCaseWithAudioAndVideoData, self).setUp() self.testdata = TestData() # audio library self.test_audiodata = TestAudioCollection() self.test_audiodata.buildLibraryDirectoryTree() # video library self.test_videodata = TestVideoCollection() self.test_videodata.buildLibraryDirectoryTree() def tearDown(self): self.test_audiodata.cleanLibraryDirectoryTree() self.test_videodata.cleanLibraryDirectoryTree() class TestCaseWithServer(TestCaseWithAudioAndVideoData): profiles = "default" def setUp(self): super(TestCaseWithServer, self).setUp() # create custom configuration files current_dir = os.path.dirname(__file__) DeejaydConfig.custom_conf = os.path.join(current_dir,\ "profiles", self.profiles) config = DeejaydConfig() config.set('mediadb', 'music_directory',\ self.test_audiodata.getRootDir()) config.set('mediadb', 'video_directory',\ self.test_videodata.getRootDir()) self.dbfilename = None if config.get('database', 'db_type') == 'sqlite': self.dbfilename = '/tmp/testdeejayddb-' +\ self.testdata.getRandomString() + '.db' config.set('database', 'db_name', self.dbfilename) elif config.get('database', 'db_type') == 'mysql': import MySQLdb try: connection = MySQLdb.connect(\ db=config.get('database', 'db_name'),\ user=config.get('database', 'db_user'),\ passwd=config.get('database', 'db_password'),\ host=config.get('database', 'db_host'),\ port=config.getint('database', 'db_port'),\ use_unicode=True, charset="utf8") except MySQLdb.DatabaseError: print "Unable to connect to mysql db" sys.exit(1) cursor = connection.cursor() # drop all table from deejayd.database.schema import db_schema for table in db_schema: try: cursor.execute("DROP TABLE `%s`" % table.name) except MySQLdb.DatabaseError: pass # commit changes and close connection.commit() connection.close() self.tmp_dir = None if config.getboolean("webui","enabled"): # define a tmp directory self.tmp_dir = '/tmp/testdeejayd-tmpdir-'+\ self.testdata.getRandomString() config.set("webui", "tmp_dir", self.tmp_dir) # record config to be used by the test server self.conf = os.path.join(current_dir, "profiles", "current") fp = open(self.conf, "w") config.write(fp) fp.close() os.chmod(self.conf, 0644) # launch server self.testserver = TestServer(self.conf) self.testserver.start() # record port for clients self.serverPort = config.getint('net', 'port') self.webServerPort = config.getint('webui', 'port') # update video_support var self.video_support = config.get("general","media_backend")!="gstreamer" def tearDown(self): self.testserver.stop() if self.dbfilename is not None: # Clean up temporary db file os.unlink(self.dbfilename) if self.tmp_dir is not None: try: shutil.rmtree(self.tmp_dir) except (IOError, OSError): pass os.unlink(self.conf) super(TestCaseWithServer, self).tearDown() # vim: ts=4 sw=4 expandtab deejayd-0.10.0/testdeejayd/coreinterface.py0000644000175000017500000010115011351210475017066 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import time, random import threading from deejayd.interfaces import DeejaydError from deejayd.mediafilters import * class InterfaceTests: """Test the deejayd daemon core interface, this test suite is to be used for testing the core facade and the client library.""" def testSetMode(self): """Test setMode command""" # ask an unknown mode mode_name = self.testdata.getRandomString() ans = self.deejayd.set_mode(mode_name) self.assertRaises(DeejaydError, ans.get_contents) # ask a known mode known_mode = 'playlist' ans = self.deejayd.set_mode(known_mode) self.failUnless(ans.get_contents()) # Test if the mode has been set status = self.deejayd.get_status().get_contents() self.assertEqual(status['mode'], known_mode) def testGetMode(self): """Test getMode command""" known_keys = ("playlist", "panel", "dvd", "webradio", "video") ans = self.deejayd.get_mode() for k in known_keys: self.failUnless(k in ans.get_contents().keys()) self.failUnless(ans[k] in (True, False)) def testGetStats(self): """Test getStats command""" ans = self.deejayd.get_stats() for k in ("audio_library_update","songs","artists","albums"): self.failUnless(k in ans.keys()) def testPlaylistSaveRetrieve(self): """Save a playlist and try to retrieve it.""" pl = [] djplname = self.testdata.getRandomString() # Get current playlist djpl = self.deejayd.get_playlist() self.assertEqual(djpl.get().get_medias(), pl) ans = djpl.add_path(self.testdata.getRandomString()) self.assertRaises(DeejaydError, ans.get_contents) # Add songs to playlist howManySongs = 3 for songPath in self.test_audiodata.getRandomSongPaths(howManySongs): pl.append(self.test_audiodata.medias[songPath].tags["uri"]) ans = djpl.add_path(songPath) self.failUnless(ans.get_contents()) # Check for the playlist to be of appropriate length self.assertEqual(self.deejayd.get_status()['playlistlength'], howManySongs) ans = djpl.save(djplname) self.failUnless(ans.get_contents()) djpl_id = ans["playlist_id"] # Check for the saved playslit to be available retrievedPls = self.deejayd.get_playlist_list().get_medias() self.failUnless(djplname in [p["name"] for p in retrievedPls]) # Retrieve the saved playlist djpl = self.deejayd.get_recorded_playlist(djpl_id, djplname, 'static') retrievedPl = djpl.get().get_medias() for song_nb in range(len(pl)): self.assertEqual(pl[song_nb], retrievedPl[song_nb]['uri']) def testPlaylistActions(self): """Test actions on current playlist.""" djpl = self.deejayd.get_playlist() howManySongs = 4 for songPath in self.test_audiodata.getRandomSongPaths(howManySongs): ans = djpl.add_path(songPath) self.failUnless(ans.get_contents()) content = djpl.get().get_medias() song = self.testdata.getRandomElement(content) # shuffle the playlist ans = djpl.shuffle() self.failUnless(ans.get_contents()) # move this song ans = djpl.move([song["id"]], 1) self.failUnless(ans.get_contents()) # delete a song djpl.del_song(song["id"]).get_contents() content = djpl.get().get_medias() self.assertEqual(len(content), howManySongs-1) def testSavedStaticPlaylistActions(self): """Test action on saved static playlist""" djplname = self.testdata.getRandomString() djpl = self.deejayd.get_playlist() howManySongs = 3 # Add songs to playlist for songPath in self.test_audiodata.getRandomSongPaths(howManySongs): ans = djpl.add_path(songPath) self.failUnless(ans.get_contents()) # save playlist ans = djpl.save(djplname) self.failUnless(ans.get_contents()) djpl_id = ans["playlist_id"] # add songs in the saved playlist savedpl = self.deejayd.get_recorded_playlist(djpl_id, djplname,\ "static") for songPath in self.test_audiodata.getRandomSongPaths(howManySongs): # add twice the same song ans = savedpl.add_path(songPath) self.failUnless(ans.get_contents()) ans = savedpl.add_path(songPath) self.failUnless(ans.get_contents()) content = savedpl.get().get_medias() self.assertEqual(len(content), howManySongs*3) def testSavedMagicPlaylistActions(self): """Test action on saved magic playlist""" djplname = self.testdata.getRandomString() djpl_infos = self.deejayd.create_recorded_playlist(djplname,\ "magic").get_contents() djpl = self.deejayd.get_recorded_playlist(djpl_infos["pl_id"],\ djplname, "magic") filter = Equals('genre', self.test_audiodata.getRandomGenre()) rnd_filter = Equals('genre', self.testdata.getRandomString()) # add filter djpl.add_filter(filter).get_contents() # verify playlist ans = djpl.get() self.assertEqual(len(ans.get_filter()), 1) self.failUnless(len(ans.get_medias()) > 0) self.failUnless(filter.equals(ans.get_filter()[0])) # add random filter djpl.remove_filter(filter).get_contents() djpl.add_filter(rnd_filter).get_contents() # verify playlist ans = djpl.get() self.assertEqual(len(ans.get_filter()), 1) self.assertEqual(len(ans.get_medias()), 0) self.failUnless(rnd_filter.equals(ans.get_filter()[0])) # add filter and set property djpl.clear_filters().get_contents() djpl.add_filter(filter).get_contents() djpl.set_property("use-limit", "1").get_contents() djpl.set_property("limit-value", "1").get_contents() # verify playlist ans = djpl.get() self.assertEqual(len(ans.get_filter()), 1) self.assertEqual(len(ans.get_medias()), 1) self.failUnless(filter.equals(ans.get_filter()[0])) def testWebradioAddRetrieve(self): """Save a webradio and check it is in the list, then delete it.""" wr_list = self.deejayd.get_webradios() # try to set wrong source ans = wr_list.set_source(self.testdata.getRandomString()) self.assertRaises(DeejaydError, ans.get_contents) # set local source ans = wr_list.set_source('local') self.failUnless(ans.get_contents()) # local does not have categorie, raise DeejaydError if we try to get it ans = wr_list.get_source_categories('local') self.assertRaises(DeejaydError, ans.get_contents) # Test for bad URI and inexistant playlist for badURI in [[self.testdata.getRandomString(50)],\ ['http://' + self.testdata.getRandomString(50) + '.pls']]: ans = wr_list.add_webradio(self.testdata.getRandomString(), badURI) self.assertRaises(DeejaydError, ans.get_contents) testWrName = self.testdata.getRandomString() testWrUrls = [] for urlCount in range(self.testdata.getRandomInt(10)): testWrUrls.append('http://' + self.testdata.getRandomString(50)) ans = wr_list.add_webradio(testWrName, testWrUrls) self.failUnless(ans.get_contents()) wr_list = self.deejayd.get_webradios() wr_names = [wr['title'] for wr in wr_list.get().get_medias()] self.failUnless(testWrName in wr_names) retrievedWr = [wr for wr in wr_list.get().get_medias()\ if wr['title'] == testWrName][0] for url in testWrUrls: self.failUnless(url in retrievedWr['urls']) ans = wr_list.delete_webradio(51) self.assertRaises(DeejaydError, ans.get_contents) ans = wr_list.delete_webradio(retrievedWr['id']) self.failUnless(ans.get_contents()) wr_list = self.deejayd.get_webradios().get().get_medias() self.failIf(testWrName in [wr['title'] for wr in wr_list]) def testQueue(self): """Add songs to the queue, try to retrieve it, delete some songs in it, then clear it.""" q = self.deejayd.get_queue() myq = [] how_many_songs = 10 for song_path in self.test_audiodata.getRandomSongPaths(how_many_songs): myq.append(self.test_audiodata.medias[song_path].tags["uri"]) ans = q.add_path(song_path) self.failUnless(ans.get_contents()) ddq = q.get() ddq_uris = [song['uri'] for song in ddq.get_medias()] for song_uri in myq: self.failUnless(song_uri in ddq_uris) random.seed(time.time()) songs_to_delete = random.sample(myq, how_many_songs / 3) ans = q.del_songs([song['id'] for song in ddq.get_medias()\ if song['uri'] in songs_to_delete]) self.failUnless(ans.get_contents()) ddq = q.get() ddq_uris = [song['uri'] for song in ddq.get_medias()] for song_uri in myq: if song_uri in songs_to_delete: self.failIf(song_uri in ddq_uris) else: self.failUnless(song_uri in ddq_uris) ans = q.clear() self.failUnless(ans.get_contents()) ddq = q.get() self.assertEqual(ddq.get_medias(), []) def testPanel(self): """ Test panel source actions """ panel = self.deejayd.get_panel() # get panel tags tags = panel.get_panel_tags().get_contents() for tag in tags: self.failUnless(tag in ['genre', 'artist',\ 'various_artist', 'album']) # set filter bad_tag = self.testdata.getRandomString() random_str = self.testdata.getRandomString() ans = panel.set_panel_filters(bad_tag, [random_str]) # random tags self.assertRaises(DeejaydError, ans.get_contents) tag = self.testdata.getRandomElement(tags) panel.set_panel_filters(tag, [random_str]).get_contents() ans = panel.get() self.assertEqual([], ans.get_medias()) # remove bad filter panel.remove_panel_filters(tag).get_contents() ans = panel.get() self.failUnless(len(ans.get_medias()) > 0) # get correct value for a tag result = self.deejayd.mediadb_list(tag, None).get_contents() value = self.testdata.getRandomElement(result) panel.set_panel_filters(tag, [value]).get_contents() ans = panel.get() self.failUnless(len(ans.get_medias()) > 0) # clear tag panel.clear_panel_filters().get_contents() ans = panel.get() self.failUnless(len(ans.get_medias()) > 0) # set search ans = panel.set_search_filter(bad_tag, random_str) # random tags self.assertRaises(DeejaydError, ans.get_contents) search_tag = self.testdata.getRandomElement(['title', 'album',\ 'genre', 'artist']) panel.set_search_filter(search_tag, random_str).get_contents() ans = panel.get() self.assertEqual([], ans.get_medias()) # clear search panel.clear_search_filter().get_contents() ans = panel.get() self.failUnless(len(ans.get_medias()) > 0) # test sort result = panel.set_sorts([(bad_tag, 'ascending')]) self.assertRaises(DeejaydError, result.get_contents) tag = self.testdata.getRandomElement(['title', 'rating', 'genre']) media_list = [m[tag] for m in ans.get_medias()] media_list.sort() panel.set_sorts([(tag, 'ascending')]).get_contents() ans = panel.get() for idx, m in enumerate(ans.get_medias()): self.assertEqual(m[tag], media_list[idx]) # save a playlist and update active list djpl = self.deejayd.get_playlist() ans = self.deejayd.get_audio_dir() dir = self.testdata.getRandomElement(ans.get_directories()) djpl.add_paths([dir]).get_contents() test_pl_name = self.testdata.getRandomString() djpl.save(test_pl_name).get_contents() pl_list = self.deejayd.get_playlist_list().get_medias() if len(pl_list) != 1: raise DeejaydError("playlist not saved") # save a playlist and update active list bad_plid = self.testdata.getRandomInt(2000, 1000) ans = panel.set_active_list("playlist", bad_plid) self.assertRaises(DeejaydError, ans.get_contents) panel.set_active_list("playlist", pl_list[0]["id"]).get_contents() panel_list = panel.get_active_list().get_contents() self.assertEqual(panel_list["type"], "playlist") self.assertEqual(panel_list["value"], pl_list[0]["id"]) def testVideo(self): """ Test video source actions """ if self.video_support: video_obj = self.deejayd.get_video() # choose a wrong directory rand_dir = self.testdata.getRandomString() ans = video_obj.set(rand_dir, "directory") self.assertRaises(DeejaydError, ans.get_contents) # get contents of root dir and try to set video directory ans = self.deejayd.get_video_dir() dir = self.testdata.getRandomElement(ans.get_directories()) video_obj.set(dir, "directory").get_contents() # test videolist content video_list = video_obj.get().get_medias() ans = self.deejayd.get_video_dir(dir) self.assertEqual(len(video_list), len(ans.get_files())) # sort videolist content sort = [["rating", "ascending"]] video_obj.set_sorts(sort).get_contents() video_list = video_obj.get() self.assertEqual(video_list.get_sort(), sort) # set bad sort rnd_sort = [(self.testdata.getRandomString(), "ascending")] ans = video_obj.set_sorts(rnd_sort) self.assertRaises(DeejaydError, ans.get_contents) # search a wrong title rand = self.testdata.getRandomString() video_obj.set(rand, "search").get_contents() video_list = video_obj.get().get_medias() self.assertEqual(len(video_list), 0) else: try: video_obj = self.deejayd.get_video() except DeejaydError: # we test core pass else: ans = video_obj.get() self.assertRaises(DeejaydError, ans.get_medias) def testMediaRating(self): """Test media rating method""" # wrong media id random_id = self.testdata.getRandomInt(2000, 1000) ans = self.deejayd.set_media_rating([random_id], "2", "audio") self.assertRaises(DeejaydError, ans.get_contents) ans = self.deejayd.get_audio_dir() files = ans.get_files() file_ids = [f["media_id"] for f in files] # wrong rating ans = self.deejayd.set_media_rating(file_ids, "9", "audio") self.assertRaises(DeejaydError, ans.get_contents) # wrong library rand_lib = self.testdata.getRandomString() ans = self.deejayd.set_media_rating(file_ids, "2", rand_lib) self.assertRaises(DeejaydError, ans.get_contents) ans = self.deejayd.set_media_rating(file_ids, "4", "audio") self.failUnless(ans.get_contents()) ans = self.deejayd.get_audio_dir() files = ans.get_files() for f in files: self.assertEqual(4, int(f["rating"])) def testAudioLibrary(self): """ Test request on audio library (get_audio_dir, search)""" # try to get contents of an unknown directory rand_dir = self.testdata.getRandomString() ans = self.deejayd.get_audio_dir(rand_dir) self.assertRaises(DeejaydError, ans.get_contents) # get contents of root dir and try to get content of a directory ans = self.deejayd.get_audio_dir() dir = self.testdata.getRandomElement(ans.get_directories()) ans = self.deejayd.get_audio_dir(dir) song_files = ans.get_files() self.failUnless(len(song_files) > 0) # search an unknown terms text = self.testdata.getRandomString() ans = self.deejayd.audio_search(text) self.assertEqual(ans.get_medias(), []) # search a known terms file = self.testdata.getRandomElement(song_files) ans = self.deejayd.audio_search(file["title"], "title") self.failUnless(len(ans.get_medias()) > 0) def testVideoLibrary(self): """ Test request on video library """ if self.video_support: # try to get contents of an unknown directory rand_dir = self.testdata.getRandomString() ans = self.deejayd.get_video_dir(rand_dir) self.assertRaises(DeejaydError, ans.get_contents) # get contents of root dir and try to get content of a directory ans = self.deejayd.get_video_dir() dir = self.testdata.getRandomElement(ans.get_directories()) ans = self.deejayd.get_video_dir(dir) files = ans.get_files() self.failUnless(len(files) > 0) else: ans = self.deejayd.get_video_dir() self.assertRaises(DeejaydError, ans.get_contents) def testSetOption(self): """ Test set_option commands""" # unknown option opt = self.testdata.getRandomString() ans = self.deejayd.set_option("playlist", opt, 1) self.assertRaises(DeejaydError, ans.get_contents) ans = self.deejayd.set_option(opt, "repeat", True) self.assertRaises(DeejaydError, ans.get_contents) ans = self.deejayd.set_option("webradio", "playorder", "inorder") self.assertRaises(DeejaydError, ans.get_contents) # set playlist option ans = self.deejayd.set_option("playlist", "playorder", "random") ans.get_contents() status = self.deejayd.get_status().get_contents() self.assertEqual(status["playlistplayorder"], "random") # set video option if self.video_support: ans = self.deejayd.set_option("video", "repeat", True) ans.get_contents() status = self.deejayd.get_status().get_contents() self.assertEqual(status["videorepeat"], True) def testAudioPlayer(self): """ Test player commands (play, pause,...) for audio """ # try to set volume vol = 30 ans = self.deejayd.set_volume(vol) self.failUnless(ans.get_contents()) status = self.deejayd.get_status().get_contents() self.assertEqual(status["volume"], vol) # load songs in main playlist djpl = self.deejayd.get_playlist() ans = self.deejayd.get_audio_dir() dir = self.testdata.getRandomElement(ans.get_directories()) djpl.add_paths([dir]).get_contents() # play song self.deejayd.set_mode("playlist").get_contents() self.deejayd.play_toggle().get_contents() # verify status status = self.deejayd.get_status().get_contents() self.assertEqual(status["state"], "play") # pause self.deejayd.play_toggle().get_contents() # verify status status = self.deejayd.get_status().get_contents() self.assertEqual(status["state"], "pause") self.deejayd.play_toggle().get_contents() # next and previous self.deejayd.next().get_contents() self.deejayd.previous().get_contents() status = self.deejayd.get_status().get_contents() self.assertEqual(status["state"], "play") # seek command self.deejayd.seek(1).get_contents() # test get_current command cur = self.deejayd.get_current().get_medias() self.assertEqual(len(cur), 1) self.deejayd.stop().get_contents() def testVideoPlayer(self): """ Test player commands (play, pause,...) for video """ if self.video_support: video_obj = self.deejayd.get_video() # set video mode self.deejayd.set_mode("video").get_contents() # choose directory ans = self.deejayd.get_video_dir() dir = self.testdata.getRandomElement(ans.get_directories()) video_obj.set(dir, "directory").get_contents() # play video file self.deejayd.play_toggle().get_contents() # verify status status = self.deejayd.get_status().get_contents() self.assertEqual(status["state"], "play") # test set_player_option cmd self.deejayd.set_player_option("av_offset", 100).get_contents() self.deejayd.set_player_option("aspect_ratio","16:9").get_contents() # bad option name ans = self.deejayd.set_player_option(\ self.testdata.getRandomString(),0) self.assertRaises(DeejaydError, ans.get_contents) # bad option value ans = self.deejayd.set_player_option("av_offset",\ self.testdata.getRandomString()) self.assertRaises(DeejaydError, ans.get_contents) ans = self.deejayd.set_player_option("aspect_ratio",\ self.testdata.getRandomString()) self.assertRaises(DeejaydError, ans.get_contents) self.deejayd.stop().get_contents() def testDvd(self): """ Test dvd commands""" if self.video_support: status = self.deejayd.get_status().get_contents() dvd_id = status["dvd"] self.deejayd.dvd_reload().get_contents() status = self.deejayd.get_status().get_contents() self.assertEqual(status["dvd"], dvd_id + 1) else: ans = self.deejayd.dvd_reload() self.assertRaises(DeejaydError, ans.get_contents) def test_mediadb_list(self): """Test db queries for tags listing.""" tag = 'artist' filter = Or(Equals('genre', self.test_audiodata.getRandomGenre()), Equals('genre', self.test_audiodata.getRandomGenre())) expected_tag_list = [] for song_info in self.test_audiodata.medias.values(): matches = False for f in filter.filterlist: if song_info.tags['genre'] == f.pattern: matches = True if matches: if song_info.tags[tag] not in expected_tag_list: expected_tag_list.append(song_info.tags[tag]) result = self.deejayd.mediadb_list(tag, filter) for tagvalue in expected_tag_list: self.failUnless(tagvalue in result) class InterfaceSubscribeTests: """Test the subscription interface. This is for the core and the async client only.""" def test_subscription(self): """Checks that signals subscriptions get in and out.""" server_notification = threading.Event() sub_id = self.deejayd.subscribe('player.status', lambda x: server_notification.set()) self.failUnless((sub_id, 'player.status')\ in self.deejayd.get_subscriptions().items()) self.deejayd.unsubscribe(sub_id) self.failUnless((sub_id, 'player.status')\ not in self.deejayd.get_subscriptions().items()) def generic_sub_bcast_test(self, signal_name, trigger, trigger_args=()): """Checks that signal_name signal is broadcast when one of the trigger is involved.""" server_notification = threading.Event() sub_id = self.deejayd.subscribe(signal_name, lambda x: server_notification.set()) ans = trigger(*trigger_args) if ans: ans.get_contents() server_notification.wait(4) self.failUnless(server_notification.isSet(), '%s signal was not broadcasted by %s.'\ % (signal_name, trigger.__name__)) server_notification.clear() self.deejayd.unsubscribe(sub_id) def test_sub_broadcast_player_status(self): """Checks that player.status signals are broadcasted.""" trigger_list = ((self.deejayd.play_toggle, ()), (self.deejayd.set_option, ("playlist", 'repeat', 1)), (self.deejayd.set_option, ('panel', "playorder",\ "random")), (self.deejayd.set_volume, (51, )), (self.deejayd.seek, (5, )), ) for trig in trigger_list: self.generic_sub_bcast_test('player.status', trig[0], trig[1]) def test_sub_broadcast_player_current(self): """Checks that player.current signals are broadcasted.""" trigger_list = ((self.deejayd.next, ()), (self.deejayd.previous, ()) ) for trig in trigger_list: self.generic_sub_bcast_test('player.current', trig[0], trig[1]) def test_sub_broadcast_player_plupdate(self): """Checks that player.plupdate signals are broadcasted.""" djpl = self.deejayd.get_playlist() ans = self.deejayd.get_audio_dir() dir = self.testdata.getRandomElement(ans.get_directories()) trigger_list = ((djpl.add_paths, ([dir], )), (djpl.shuffle, ()), (djpl.clear, ()), ) for trig in trigger_list: self.generic_sub_bcast_test('player.plupdate', trig[0], trig[1]) def test_sub_broadcast_playlist_listupdate(self): """Checks that playlist.listupdate signals are broadcasted.""" djpl = self.deejayd.get_playlist() ans = self.deejayd.get_audio_dir() dir = self.testdata.getRandomElement(ans.get_directories()) djpl.add_paths([dir]).get_contents() test_pl_name = self.testdata.getRandomString() test_pl_name2 = self.testdata.getRandomString() self.generic_sub_bcast_test('playlist.listupdate', djpl.save, (test_pl_name, )) retrievedPls = self.deejayd.get_playlist_list().get_medias() for pls in retrievedPls: if pls["name"] == test_pl_name: djpl_id = pls["id"] break trigger_list = ( (self.deejayd.erase_playlist, ([djpl_id], )), (djpl.save, (test_pl_name2,)), ) for trig in trigger_list: self.generic_sub_bcast_test('playlist.listupdate', trig[0], trig[1]) def test_sub_broadcast_playlist_update(self): """Checks that playlist.update signals are broadcasted.""" ans = self.deejayd.get_audio_dir() dir = self.testdata.getRandomElement(ans.get_directories()) filter = Equals('genre', self.test_audiodata.getRandomGenre()) st_pl_name = self.testdata.getRandomString() mg_pl_name = self.testdata.getRandomString() st_pl_infos = self.deejayd.create_recorded_playlist(st_pl_name,\ 'static').get_contents() mg_pl_infos = self.deejayd.create_recorded_playlist(mg_pl_name,\ 'magic').get_contents() st_djpl = self.deejayd.get_recorded_playlist(st_pl_infos["pl_id"],\ st_pl_name, 'static') mg_djpl = self.deejayd.get_recorded_playlist(mg_pl_infos["pl_id"],\ mg_pl_name, 'magic') trigger_list = ( (st_djpl.add_path, (dir,)), (mg_djpl.add_filter, (filter,)), (mg_djpl.remove_filter, (filter,)), (mg_djpl.set_property, ('use-limit', '1')), ) for trig in trigger_list: self.generic_sub_bcast_test('playlist.update', trig[0], trig[1]) def test_sub_broadcast_panel_update(self): """Checks that panel.update signals are broadcasted.""" # first save a playlist djpl = self.deejayd.get_playlist() ans = self.deejayd.get_audio_dir() dir = self.testdata.getRandomElement(ans.get_directories()) djpl.add_paths([dir]).get_contents() test_pl_name = self.testdata.getRandomString() djpl.save(test_pl_name).get_contents() pl_list = self.deejayd.get_playlist_list().get_medias() if len(pl_list) != 1: raise DeejaydError("playlist not saved") djpn = self.deejayd.get_panel() trigger_list = ( (djpn.set_active_list, ("playlist", pl_list[0]["id"])), (djpn.set_active_list, ("panel", "0")), (djpn.set_panel_filters, ("genre", "zboub")), (djpn.clear_panel_filters, []), (djpn.set_sorts, ([("genre", "ascending")],)), ) for trig in trigger_list: self.generic_sub_bcast_test('panel.update', trig[0], trig[1]) def test_sub_broadcast_video_update(self): """Checks that video.update signals are broadcasted.""" if not self.video_support: return True djvideo = self.deejayd.get_video() ans = self.deejayd.get_video_dir() dir = self.testdata.getRandomElement(ans.get_directories()) trigger_list = ( (djvideo.set, (dir, "directory")), (djvideo.set_sorts, ([("title", "ascending")],)), ) for trig in trigger_list: self.generic_sub_bcast_test('video.update', trig[0], trig[1]) def test_sub_broadcast_webradio_listupdate(self): """Checks that webradio.listupdate signals are broadcasted.""" wr_list = self.deejayd.get_webradios() test_wr_name = self.testdata.getRandomString() test_wr_urls = 'http://' + self.testdata.getRandomString(50) self.generic_sub_bcast_test('webradio.listupdate', wr_list.add_webradio, (test_wr_name, test_wr_urls)) retrieved_wr = [wr for wr in wr_list.get().get_medias()\ if wr['title'] == test_wr_name + '-1'][0] self.generic_sub_bcast_test('webradio.listupdate', wr_list.delete_webradio, (retrieved_wr['id'], )) def test_sub_broadcast_queue_update(self): """Checks that queue.update signals are broadcasted.""" q = self.deejayd.get_queue() ans = self.deejayd.get_audio_dir() dir = self.testdata.getRandomElement(ans.get_directories()) self.generic_sub_bcast_test('queue.update', q.add_paths, ([dir], )) retrieved_song_id = [song['id'] for song in q.get().get_medias()] self.generic_sub_bcast_test('queue.update', q.del_songs, (retrieved_song_id, )) def test_sub_broadcast_dvd_update(self): """Checks that dvd.update signals are broadcasted.""" if not self.video_support: return True self.generic_sub_bcast_test('dvd.update', self.deejayd.dvd_reload, ()) def test_sub_broadcast_mode(self): """Checks that mode signals are broadcasted.""" self.generic_sub_bcast_test('mode', self.deejayd.set_mode, ('video', )) def test_sub_broadcast_mediadb_aupdate(self): """Checks that mediadb.aupdate signals are broadcasted.""" # This is tested only using inotify support self.generic_sub_bcast_test('mediadb.aupdate', self.test_audiodata.addMedia) def test_sub_broadcast_mediadb_vupdate(self): """Checks that mediadb.vupdate signals are broadcasted.""" if not self.video_support: return True # This is tested only using inotify support self.generic_sub_bcast_test('mediadb.vupdate', self.test_videodata.addMedia) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/testdeejayd/databuilder.py0000644000175000017500000004153511351210475016547 0ustar royroy# -*- coding: utf-8 -*- # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., """ This module generates the test data. """ import os, sys, shutil, urllib, random, time, string from deejayd.mediafilters import * from testdeejayd.data import sample_genres DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') class TestDataError: pass class TestData(object): def __init__(self): # Grab some sample data import testdeejayd.data self.sampleLibrary = testdeejayd.data.songlibrary def getRandomString(self, length = 5, charset = string.letters,\ special = False): random.seed(time.time()) rs = ''.join(random.sample(charset, length-1)) #if special: # rs += ''.join(random.sample(['é','è',"'","`","?","-","ç","à"], 2)) return rs def getRandomInt(self, maxValue = 10, minValue = 1): random.seed(time.time()) return random.randint(minValue, maxValue) def getRandomElement(self, list): random.seed(time.time()) return random.sample(list, 1).pop() def getBadCaracter(self): return "\xe0" def getRandomGenre(self): return self.getRandomElement(sample_genres) def getSampleParmDict(self, howMuch = 2): parmDict = {} for i in range(howMuch): parmDict['parmName' + str(i)] = 'parmValue' + str(i) return parmDict def get_sample_filter(self): filter = And(Contains('artist', 'Britney'), Or(Equals('genre', 'Classical'), Equals('genre', 'Disco') ) ) return filter class TestSong(TestData): supportedTag = ("tracknumber","title","genre","artist","album","date") def __init__(self): self.name = self.getRandomString(special = True) + self.ext self.tags = {} def build(self,path): filename = os.path.join(path, self.name) shutil.copy(self.testFile, filename) self.tags["filename"] = filename self.tags["uri"] = "file://%s" % urllib.quote(filename) self.setRandomTag() def remove(self): os.unlink(self.tags["filename"]) self.tags = None def rename(self): newName = self.getRandomString(special = True) + self.ext newPath = os.path.join(os.path.dirname(self.tags['filename']),\ newName) os.rename(self.tags['filename'],newPath) self.tags["filename"] = newPath self.name = newName def get_random_tag_value(self, tagname): if tagname == "date": value = str(self.getRandomInt(2010,1971)) elif tagname == "tracknumber": value = str(self.getRandomInt(15)) elif tagname == "genre": value = self.getRandomGenre() else: value = self.getRandomString(special=False) return value def setRandomTag(self): tagInfo = self._getTagObject() for tag in self.__class__.supportedTag: value = self.get_random_tag_value(tag) tagInfo[tag] = unicode(value) self.tags[tag] = value tagInfo.save() class TestVideo(TestSong): def __init__(self): self.testFile,self.ext = os.path.join(DATA_DIR,"video_test.avi"),".avi" super(TestVideo, self).__init__() # FIXME Shoudn't videowidth and videoheight be of type int? self.tags = {"length": 2, "videowidth": '640', "videoheight": '480',\ "external_subtitle": ""} def __getitem__(self,key): try: return self.tags[key] except KeyError: return None def set_subtitle(self, sub): if sub != "": self.tags["external_subtitle"] = "file://"+sub else: self.tags["external_subtitle"] = "" def setRandomTag(self):pass class TestMP3Song(TestSong): def __init__(self): self.testFile,self.ext = os.path.join(DATA_DIR, "mp3_test.mp3"), ".mp3" #self.name = self.getRandomString() + self.getBadCaracter() + self.ext self.name = self.getRandomString() + self.ext self.tags = {} def __getitem__(self,key): return key in self.tags and self.tags[key] or None def _getTagObject(self): from mutagen.easyid3 import EasyID3 return EasyID3(self.tags["filename"]) class TestMP4Song(TestSong): __translate = { "\xa9nam": "title", "\xa9alb": "album", "\xa9ART": "artist", "\xa9day": "date", "\xa9gen": "genre", } __tupletranslate = { "trkn": "tracknumber", } def __init__(self): self.testFile,self.ext = os.path.join(DATA_DIR, "mp4_test.mp4"), ".mp4" super(TestMP4Song, self).__init__() def __getitem__(self,key): return key in self.tags and self.tags[key] or None def setRandomTag(self): from mutagen.mp4 import MP4 tag_info = MP4(self.tags["filename"]) for tag, name in self.__translate.iteritems(): value = self.get_random_tag_value(name) tag_info[tag] = unicode(value) self.tags[name] = value for tag, name in self.__tupletranslate.iteritems(): cur = self.getRandomInt(15) value = (cur, 15) tag_info[tag] = [value] self.tags[name] = "%d/15" % cur tag_info.save() class TestOggSong(TestSong): def __init__(self): self.testFile,self.ext = os.path.join(DATA_DIR, "ogg_test.ogg"), ".ogg" super(TestOggSong, self).__init__() def __getitem__(self,key): return key in self.tags and self.tags[key] or None def _getTagObject(self): from mutagen.oggvorbis import OggVorbis return OggVorbis(self.tags["filename"]) class TestFlacSong(TestSong): def __init__(self): self.testFile,self.ext = os.path.join(DATA_DIR,"flac_test.flac"),".flac" super(TestFlacSong, self).__init__() def __getitem__(self,key): return key in self.tags and self.tags[key] or None def _getTagObject(self): from mutagen.flac import FLAC return FLAC(self.tags["filename"]) class _TestDir(TestData): def __init__(self): self.build = False self.name = self.getRandomString(special = True) self.root = None self.items = [] def addItem(self, item): if self.build: item.build(self.dirPath) self.items.append(item) def buildContent(self, destDir): self.dirPath = os.path.join(destDir,self.name) os.mkdir(self.dirPath) time.sleep(0.1) for item in self.items: item.build(self.dirPath) self.build = True self.root = destDir def rename(self): newName = self.getRandomString(special = True) os.rename(os.path.join(self.root,self.name),\ os.path.join(self.root,newName)) self.name = newName def remove(self): shutil.rmtree(self.dirPath) self.build = False class TestAudioDir(_TestDir): def __init__(self): super(TestAudioDir, self).__init__() self.cover = None def buildContent(self, destDir, with_cover = False): super(TestAudioDir, self).buildContent(destDir) if with_cover: self.add_cover() def add_cover(self): if not self.cover: cover_path = os.path.join(DATA_DIR, "cover.jpg") self.cover = os.path.join(self.dirPath, "cover.jpg") shutil.copy(cover_path, self.cover) def remove_cover(self): if self.cover: os.unlink(self.cover) self.cover = None class TestVideoDir(_TestDir): def __init__(self): super(TestVideoDir, self).__init__() self.has_sub = False def buildContent(self, destDir, with_subtitle = False): super(TestVideoDir, self).buildContent(destDir) if with_subtitle: self.add_subtitle() def add_subtitle(self): sub_path = os.path.join(DATA_DIR, "sub.srt") for item in self.items: if not item["external_subtitle"]: sub_name = os.path.basename(item["filename"]) (sub_name, ext) = os.path.splitext(sub_name) dest = os.path.join(self.dirPath, sub_name + ".srt") shutil.copy(sub_path, dest) item.set_subtitle(dest) self.has_sub = True def remove_subtitle(self): for item in self.items: if item["external_subtitle"]: os.unlink(item["external_subtitle"].replace("file://", "")) item.set_subtitle("") self.has_sub = False class TestProvidedMusicCollection(TestData): def __init__(self, musicDir): self.datadir = os.path.normpath(musicDir) self.songPaths = [] for root, dir, files in os.walk(self.datadir): for file in files: (name,ext) = os.path.splitext(file) if ext.lower() in ('.mp3','.ogg','.mp4','.flac'): self.songPaths.append(self.stripRoot(os.path.join(root, file))) def getRootDir(self): return self.datadir def get_song_paths(self): return self.songPaths def stripRoot(self, path): """Strips the root directory path turning the argument into a path relative to the music root directory.""" abs_path = os.path.abspath(path) rel_path = os.path.normpath(abs_path[len(self.getRootDir()):]) if rel_path != '.': rel_path = rel_path.strip("/") else: rel_path = '' return rel_path def getRandomSongPaths(self, howMuch = 1): """Returns the path of a random song in provided music""" random.seed(time.time()) return random.sample(self.get_song_paths(), howMuch) class _TestMediaCollection(TestProvidedMusicCollection): def __init__(self): self.dir_struct_written = False self.clean_library = True self.dirs = {} self.dirlinks = {} self.medias = {} self.supported_files_class = () def cleanLibraryDirectoryTree(self): if self.dir_struct_written and self.clean_library: shutil.rmtree(self.datadir) for dirlink in self.dirlinks.values(): shutil.rmtree(dirlink.datadir) def buildLibraryDirectoryTree(self, destDir = "/tmp"): # create test data directory in random subdirectory of destDir self.datadir = os.path.join(os.path.normpath(destDir), 'testdeejayd-media' + self.getRandomString()) if not os.path.exists(self.datadir): os.mkdir(self.datadir) else: sys.exit(\ 'Test data temporary directory exists, I do not want to mess your stuff.') # Add songs in the root directory for media_class in self.__class__.supported_files_class: media = media_class() media.build(self.datadir) self.medias[media.name] = media # Create several directories for i in range(self.getRandomInt(10,5)): self.addDir() # Add a subdirectory self.addSubdir() self.dir_struct_written = True def get_song_paths(self): song_paths = self.medias.keys() for dirlink in self.dirlinks.values(): for song_path in dirlink.get_song_paths(): song_paths.append(song_path) return song_paths def addMedia(self): dir = self.getRandomElement(self.dirs.values()) media_class=self.getRandomElement(self.__class__.supported_files_class) media = media_class() dir.addItem(media) self.medias[os.path.join(dir.name, media.name)] = media def renameMedia(self): mediaKey = self.getRandomElement(self.medias.keys()) media = self.medias[mediaKey] del self.medias[mediaKey] media.rename() new_path = os.path.join(os.path.dirname(mediaKey), media.name) self.medias[new_path] = media def removeMedia(self): mediaKeys = self.getRandomElement(self.medias.keys()) self.medias[mediaKeys].remove() del self.medias[mediaKeys] def addDir(self): dir = self.dir_class() for media_class in self.__class__.supported_files_class: media = media_class() self.medias[os.path.join(dir.name, media.name)] = media dir.addItem(media) dir.buildContent(self.datadir, random.choice((True, False))) self.dirs[dir.name] = dir def addSubdir(self): dir = self.getRandomElement(self.dirs.values()) subdir = self.dir_class() for media_class in self.__class__.supported_files_class: media = media_class() media_path = os.path.join(dir.name, subdir.name, media.name) self.medias[media_path] = media subdir.addItem(media) subdir_path = os.path.join(self.datadir, dir.name) subdir.buildContent(subdir_path) self.dirs[subdir_path] = dir def addSubSubdir(self): dir = self.getRandomElement(self.dirs.values()) subdir, subsubdir = self.dir_class(), self.dir_class() for media_class in self.__class__.supported_files_class: media = media_class() media_path = os.path.join(dir.name, subdir.name, subsubdir.name,\ media.name) self.medias[media_path] = media subsubdir.addItem(media) subdir_path = os.path.join(self.datadir, dir.name) subdir.buildContent(subdir_path) self.dirs[subdir_path] = dir subsubdir_path = os.path.join(self.datadir, dir.name, subdir.name) subsubdir.buildContent(subsubdir_path) self.dirs[subsubdir_path] = subdir def renameDir(self): dir = self.getRandomElement(self.dirs.values()) del self.dirs[dir.name] dir.rename() self.dirs[dir.name] = dir def removeDir(self): dir = self.getRandomElement(self.dirs.values()) dir.remove() del self.dirs[dir.name] def addDirLink(self): where = self.getRandomElement(self.dirs.values()) dirlink = self.__class__() linkname = self.getRandomString() linkpath = os.path.join(self.datadir, where.name, linkname) dirlink.buildLibraryDirectoryTree() self.dirlinks[linkpath] = dirlink os.symlink(dirlink.datadir, linkpath) def moveDirLink(self): dirlinkpath = self.getRandomElement(self.dirlinks.keys()) linkname = os.path.basename(dirlinkpath) new_location = self.getRandomElement([d for d in self.dirs.keys()\ if d != linkname]) new_location = os.path.join(self.getRootDir(), new_location, linkname) os.rename(dirlinkpath, new_location) dirlink = self.dirlinks[dirlinkpath] del self.dirlinks[dirlinkpath] self.dirlinks[new_location] = dirlink def removeDirLink(self): dirlinkpath, dirlink = self.getRandomElement(self.dirlinks.items()) os.unlink(dirlinkpath) dirlink.cleanLibraryDirectoryTree() del self.dirlinks[dirlinkpath] class TestAudioCollection( _TestMediaCollection): dir_class = TestAudioDir supported_files_class = (TestOggSong,TestMP3Song,TestMP4Song,TestFlacSong) def remove_cover(self): for dir in self.dirs.values(): if dir.cover: dir.remove_cover() return def add_cover(self): for dir in self.dirs.values(): if not dir.cover: dir.add_cover() return def changeMediaTags(self): media = self.getRandomElement(self.medias.values()) media.setRandomTag() class TestVideoCollection( _TestMediaCollection): dir_class = TestVideoDir supported_files_class = (TestVideo,) def remove_subtitle(self): for dir in self.dirs.values(): if dir.has_sub: dir.remove_subtitle() return def add_subtitle(self): for dir in self.dirs.values(): if not dir.has_sub: dir.add_subtitle() return # vim: ts=4 sw=4 expandtab deejayd-0.10.0/testdeejayd/test_jsonrpc.py0000644000175000017500000003011011351210475016767 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """Deejayd JSON-RPC protocol testing""" import re from testdeejayd import TestCaseWithAudioAndVideoData, TestCaseWithData from deejayd.mediafilters import * from deejayd.interfaces import DeejaydSignal from deejayd.net.client import _DeejayDaemon,\ DeejaydAnswer, DeejaydKeyValue, DeejaydList,\ DeejaydFileList, DeejaydMediaList,\ DeejaydStaticPlaylist, DeejaydError from deejayd.rpc.jsonbuilders import JSONRPCRequest, JSONRPCResponse,\ Get_json_filter, DeejaydJSONSignal def trim_json(json): return re.sub('(\s{2,})|(\\n)',' ', json) class TestJSONRPCBuilders(TestCaseWithData): def test_request_builder(self): """ test JSON-RPC request building """ cmd = JSONRPCRequest("m_name", ["args1", 2]) expected_answer = """{"params": ["args1", 2], "method": "m_name", "id": %d}""" % cmd.get_id() self.assertEqual(trim_json(cmd.to_json()), trim_json(expected_answer)) def test_response_builder(self): """ test JSON-RPC response building """ expected_answer = """{"id": 22, "result": {"answer": ["r1", "r2"], "type": "type1"}, "error": null}""" ans = JSONRPCResponse({"type": "type1", "answer": ["r1", "r2"]}, 22) self.assertEqual(trim_json(ans.to_json()), trim_json(expected_answer)) def test_filter_builder(self): """ test JSON-RPC mediafilter building """ filter = And(Equals("artist", "artist_name"),\ Or(Contains("genre", "Rock"), Higher("Rating", "4"))) expected_answer = """{"type": "complex", "id": "and", "value": [{"type": "basic", "id": "equals", "value": {"pattern": "artist_name", "tag": "artist"}}, {"type": "complex", "id": "or", "value": [{"type": "basic", "id": "contains", "value": {"pattern": "Rock", "tag": "genre"}}, {"type": "basic", "id": "higher", "value": {"pattern": "4", "tag": "Rating"}}]}]}""" f = Get_json_filter(filter) self.assertEqual(trim_json(f.to_json()), trim_json(expected_answer)) def test_signal_builder(self): """ test JSON-RPC Signal building """ signal = DeejaydSignal("signal_name", {"attr1": "value1", "attr2": 22}) expected_answer = """{"answer": {"name": "signal_name", "attrs": {"attr2": 22, "attr1": "value1"}}, "type": "signal"}""" s = DeejaydJSONSignal(signal) self.assertEqual(trim_json(s.to_json()), trim_json(expected_answer)) class TestAnswerParser(TestCaseWithData): """Test the Deejayd client library answer parser""" def setUp(self): super(TestAnswerParser, self).setUp() self.deejayd = _DeejayDaemon() def parse_answer(self, str): self.deejayd._build_answer(str) def test_answer_parser_wrongformat(self): """Test the client library parsing a wrong format answer""" wrong_answer = """{"id": 1, "result": {"answer": true, "type": "ack"}}""" ans = DeejaydAnswer() ans.set_id(1) self.deejayd.expected_answers_queue.put(ans) self.assertRaises(DeejaydError, self.parse_answer, wrong_answer) def test_answer_parser_wrongid(self): """Test the client library parsing an answer with wrong id""" wrongid_answer = """{"id": 1, "error": null, "result": {"answer": true, "type": "ack"}}""" ans = DeejaydAnswer() ans.set_id(2) self.deejayd.expected_answers_queue.put(ans) self.assertRaises(DeejaydError, self.parse_answer, wrongid_answer) def test_answer_parser_ack(self): """Test the client library parsing an ack answer""" ack_answer = """{"id": 1, "result": {"answer": true, "type": "ack"}, "error": null}""" ans = DeejaydAnswer() ans.set_id(1) self.deejayd.expected_answers_queue.put(ans) self.parse_answer(ack_answer) self.failUnless(ans.get_contents()) def test_answer_parser_keyvalue(self): """Test the client library parsing a key value answer""" how_much = 10 orig_key_value = {} for count in range(how_much): key = self.testdata.getRandomString() value = self.testdata.getRandomString() orig_key_value[key] = value key_value_answer = """{"id": 1, "error": null, "result": {"type": "dict", "answer": {%s}}}""" \ % ",".join([""" "%s": "%s" """ % (k,v) \ for k,v in orig_key_value.items()]) ans = DeejaydKeyValue() ans.set_id(1) self.deejayd.expected_answers_queue.put(ans) self.parse_answer(key_value_answer) retrieved_key_values = ans.get_contents() for key in orig_key_value.keys(): self.assertEqual(orig_key_value[key], retrieved_key_values[key]) def test_answer_parser_list(self): """Test the client library parsing a list answer.""" how_much = 10 orig_list = [] for count in range(how_much): item = self.testdata.getRandomString() orig_list.append(item) list_answer = """{"id": 1, "error": null, "result": {"type": "list", "answer": [%s]}}""" \ % ",".join([""" "%s" """ % v for v in orig_list]) ans = DeejaydList() ans.set_id(1) self.deejayd.expected_answers_queue.put(ans) self.parse_answer(list_answer) retrieved_list = ans.get_contents() for item in orig_list: self.failUnless(item in retrieved_list) def test_answer_parser_fileanddir_list(self): """Test the client library parsing a file/dir list answer""" def dict_to_json(input): return """{%s}""" % ",".join([""" "%s": "%s" """ % (k,v)\ for k,v in input.items()]) how_much = self.testdata.getRandomInt(50) orig_files, orig_dirs = [], [] for count in range(how_much): if self.testdata.getRandomElement(['file','directory']) == 'file': file = {} file['id'] = count how_much_parms = self.testdata.getRandomInt() for parm_count in range(how_much_parms): name = self.testdata.getRandomString() value = self.testdata.getRandomString() file[name] = value orig_files.append(file) else: dirname = self.testdata.getRandomString() orig_dirs.append(dirname) fandd_list_answer = """{"id": 1, "error": null, "result": {"type": "fileAndDirList", "answer": {"type": "song", "directories": [%(dirs)s], "files": [%(files)s], "root": ""}}}""" \ % { "dirs": ",".join([""" "%s" """ % v for v in orig_dirs]), "files": ",".join([""" %s """ % dict_to_json(v)\ for v in orig_files]), } ans = DeejaydFileList() ans.set_id(1) self.deejayd.expected_answers_queue.put(ans) self.parse_answer(fandd_list_answer) for file in orig_files: # Find corresponding file in retrieved files found = False for retrieved_file in ans.get_files(): if file['id'] == int(retrieved_file['id']): found = True break for key in file.keys(): self.failUnless(key in retrieved_file.keys()) self.assertEqual(unicode(file[key]), retrieved_file[key]) for dir in orig_dirs: self.failUnless(unicode(dir) in ans.get_directories()) def test_answer_parser_medialist(self): """Test the client library parsing a medialist answer""" def dict_to_json(input): return """{%s}""" % ",".join([""" "%s": "%s" """ % (k,v)\ for k,v in input.items()]) how_much = self.testdata.getRandomInt(50) orig_medias = [] for count in range(how_much): media = {} media['id'] = count how_much_parms = self.testdata.getRandomInt() for parm_count in range(how_much_parms): name = self.testdata.getRandomString() value = self.testdata.getRandomString() media[name] = value orig_medias.append(media) filter = And(Equals("artist", "artist_name"),\ Or(Contains("genre", "Rock"), Higher("Rating", "4"))) json_filter = Get_json_filter(filter) medialist_answer = """{"id": 1, "error": null, "result": {"type": "mediaList", "answer": {"media_type": "song", "medias": [%(medias)s], "filter": %(filter)s}}}""" \ % { "filter": json_filter.to_json(), "medias": ",".join([""" %s """ % dict_to_json(v)\ for v in orig_medias]), } ans = DeejaydMediaList() ans.set_id(1) self.deejayd.expected_answers_queue.put(ans) self.parse_answer(medialist_answer) for media in orig_medias: # Find corresponding media found = False for retrieved_media in ans.get_medias(): if media['id'] == int(retrieved_media['id']): found = True break for key in media.keys(): self.failUnless(key in retrieved_media.keys()) self.assertEqual(unicode(media[key]), retrieved_media[key]) self.failUnless(ans.is_magic()) self.failUnless(filter.equals(ans.get_filter())) def test_answer_parser_signal(self): """Parse a signal message""" sig_name = self.testdata.getRandomElement(DeejaydSignal.SIGNALS) raw_sig = """{"answer": {"name": "%s", "attrs": {"attr2": 22, "attr1": "value1"}}, "type": "signal"}""" % sig_name signal_answer = """{"id": null, "error": null, "result": %s}""" % trim_json(raw_sig) # We use a list here as a workaround of python nested scopes # limitation : 'sig = None' and then in sig_received() 'sig = signal' # would not work. sig = [] def sig_received(signal): sig.append(signal) self.deejayd.subscribe(sig_name, sig_received) self.parse_answer(signal_answer) self.assertEqual(sig.pop().get_name(), sig_name) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/testdeejayd/test_client.py0000644000175000017500000001505211351210475016577 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """Deejayd Client library testing""" import threading from testdeejayd import TestCaseWithServer from testdeejayd.server import TestServer from testdeejayd.coreinterface import InterfaceTests, InterfaceSubscribeTests from deejayd.net.client import DeejayDaemonSync, DeejayDaemonAsync, \ DeejayDaemonHTTP, DeejaydError, \ DeejaydWebradioList class TestHTTPClient(TestCaseWithServer): """Test the http client library""" def setUp(self): TestCaseWithServer.setUp(self) # Instanciate the server object of the client library self.deejayd = DeejayDaemonHTTP('localhost', self.webServerPort) def testPing(self): """Ping server with a http connection""" self.failUnless(self.deejayd.ping().get_contents()) class TestSyncClient(TestCaseWithServer, InterfaceTests): """Test the DeejaydClient library in synchroneous mode.""" def setUp(self): TestCaseWithServer.setUp(self) # Instanciate the server object of the client library self.deejayd = DeejayDaemonSync() self.deejayd.connect('localhost', self.serverPort) def tearDown(self): self.deejayd.disconnect() TestCaseWithServer.tearDown(self) def testPing(self): """Ping server""" self.failUnless(self.deejayd.ping().get_contents()) class TestAsyncClient(TestCaseWithServer, InterfaceSubscribeTests): """Test the DeejaydClient library in asynchroenous mode.""" def setUp(self): TestCaseWithServer.setUp(self) # Instanciate the server object of the client library self.deejayd = DeejayDaemonAsync() self.deejayd.connect('localhost', self.serverPort) # Prepare in case we need other clients self.clients = [self.deejayd] def tearDown(self): for client in self.clients: client.disconnect() TestCaseWithServer.tearDown(self) def get_another_client(self): client = DeejayDaemonAsync() self.clients.append(client) return client def test_ping(self): """Ping server asynchroneously""" ans = self.deejayd.ping() self.failUnless(ans.get_contents(), 'Server did not respond well to ping.') def test_answer_callback(self): """Ping server asynchroneously and check for the callback to be triggered""" cb_called = threading.Event() def tcb(answer): cb_called.set() ans = self.deejayd.ping() ans.add_callback(tcb) # some seconds should be enough for the callback to be called cb_called.wait(4) self.failUnless(cb_called.isSet(), 'Answer callback was not triggered.') def testPlaylistSaveRetrieve(self): """Test playlist commands asynchroneously and callback""" # Get current playlist and add callback self.pl = None cb_called = threading.Event() def tcb(answer): cb_called.set() self.pl = answer.get_medias() djpl = self.deejayd.get_playlist() djpl.get().add_callback(tcb) cb_called.wait(4) self.failUnless(cb_called.isSet(), 'Answer callback was not triggered.') self.assertEqual(self.pl, []) # Add songs to playlist and get status cb_called = threading.Event() self.should_stop = False self.status = None def tcb_status(answer): cb_called.set() self.status = answer.get_contents() self.should_stop = True def cb_update_status(answer): self.deejayd.get_status().add_callback(tcb_status) djpl.add_path(self.test_audiodata.getRandomSongPaths(1)[0]).\ add_callback(cb_update_status) while not self.should_stop: cb_called.wait(4) self.failUnless(cb_called.isSet(), 'Answer callback was not triggered.') self.assertEqual(self.status['playlistlength'], 1) def testCallbackProcess(self): """ Send three commands asynchroneously at the same time and check callback """ firstcb_called = threading.Event() secondcb_called = threading.Event() thirdcb_called = threading.Event() def first_cb(answer): firstcb_called.set() def second_cb(answer): secondcb_called.set() def third_cb(answer): thirdcb_called.set() self.deejayd.get_audio_dir("").add_callback(first_cb) self.deejayd.get_playlist_list().add_callback(second_cb) self.deejayd.ping().add_callback(third_cb) firstcb_called.wait(2) self.failUnless(firstcb_called.isSet(), \ '1st Answer callback was not triggered.') secondcb_called.wait(2) self.failUnless(secondcb_called.isSet(), \ '2nd Answer callback was not triggered.') thirdcb_called.wait(2) self.failUnless(thirdcb_called.isSet(), \ '3rd Answer callback was not triggered.') def test_two_clients(self): """Checks that it is possible to instanciate two clients in the same process.""" client2 = self.get_another_client() client2.connect('localhost', self.serverPort) self.failUnless(self.deejayd.ping().get_contents()) self.failUnless(client2.ping().get_contents()) def test_subscription_another_client(self): """Checks that a subscription is broadcasted to another client who did not orignate the event trigger.""" # Instanciate a second client that connects to the same server client2 = self.get_another_client() client2.connect('localhost', self.serverPort) self.generic_sub_bcast_test('mode', client2.set_mode, ('video', )) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/testdeejayd/data/0000755000175000017500000000000011354730161014620 5ustar royroydeejayd-0.10.0/testdeejayd/data/sub.srt0000644000175000017500000000000611351210475016135 0ustar royroyzboub deejayd-0.10.0/testdeejayd/data/mp4_test.mp40000644000175000017500000002427511351210475017011 0ustar royroyftypmp42mp42isom!Lmdat6libfaac 1.26.1 (Oct 27 2007) UNSTABLET/P`,$ a PD <#QT\sేP6hѷUZ:?fnhmQbA [m `?b-mh6DPD% 6f[h h ,7 `h" a@dZK3״5d[rR Ai?~gO{6oVқ SߗOؚ2*7G?2#Tỡb\Q/nok= C* '[< LSZš(ê_yRH}7Z]0tQ.Rp ,D @P* AP* $^]ʾEd2Aaz3ho}[f?WZ=d1پ 7Is夰Ϣr25;^{596z{νNsz6B^v7[r(3- N!H 'ы$^SVB3M'/,$ AP* APH+Kj㾗+8VUtJ HT&GGL=?g t9 ⓟc';k~~b!v/9sޏ:ʨ=-*l4$^E/ӊ5vqCd%EETy%E& RptcBNJURU{h"4D T`A ,3 PHF ! T$Q{}{75p$˪d)v:zUgL[I#_ʹoGGB:1r &?c`b4`gm^ҭrs(nΗ 2,rQF݉ڼ+0SVZ*B;lϘY5$5.} L8,5 `$ @H*2KRTJ˪E(=nߵwΚo>恗s Rx*~j7TQ꼕O+@=Z#\ 7J oJK:ByKGy5'?T8hNUV(./Q *fNpP5cLFI 昪dHYe+)/^,4C$$"0nWUjTR2}(GϽ&AWMk+EGai\_+h ü#!Ld@aV<pe͈l_օeJhIR0%4\\  El MZ v[t @)E /R)S,4 PPJ!BA&k70HqO.?^tnNaGr/v5eIW>J}/?tB`vYxLg9v%8tddުՓK^H-)Ax] ^qKJ@n$*.^T"@3("Rp0 apP# :eoRyՖ~ٽ_7hs-9bbJpYj34#ő-j (1hP: ac~D# Ba@,D APD BBP $!Lw)r7ZީHPQ ,̒w,dAA@&0.:eΫ%j^IK"ș ڨ؝:>P'H x]8FKyP|ngڿ),ȗe79m=? %x()1eǿETgacreJiu`ʫΘ(ZKoNEN^iyQuҪiH Eu8 ,$ B`T(B A@$A Z]7\淫3\E R N?k.3woTAwNӧ췌3WR}nz9uϳBE_\I̫ 03ot.yYڅgZv%$32k7mJd\XF=CxDZj"EHU(qs0PPJa" I"]{nqjɈEwI/%tկ]|Ϝ|oS%g\O;sOqL oE*a v]Ǝ(Ч.ΤRiFr"ueTA/`ND1^t\q=QZ"Q;EǘN\ ,4@fB@Ů%"IImZmQWYt& ?V9^0b#(~U'PFͨB,9),)( F`.T9B!{׈CUeIL,$ @X(  HH"X#}s9)*%bQ(Romq6,/ IrCgrÿ}>]o!+ǮiA i )!TmLfFձj!ؔhнգħz.f Vt!XTJK] ʜ*4Cв*l$PH("* L#]6ѩ\my/U*RC7>)|PjwRR)-7mJ,y:v^w2EZ1_ U3F@4N2DuDHN.V^fA`n&˪8*L# A@Hh DEzsԕz^gJkw**uy[,hоWk_@)/ j}6=m_U@zL^B7%Pz\w*⡱C /)AXpUF}M3ibOuQPU42!eQIEh\"W$&]* `U?"00H" ŭF~|yΥo58R B(koh^ڃ̺t}4+NO_cڮœlpf1t|4rWۉA^@B!<Nie3(BTʯ[t`%t1#6"D$@I@4$.DU@10PEKC,`T(S BD@J9Y")jx)xQQ[>%y#=V,)iyv|ОVY^HRdV[h5{R$j]|zWVeO,xh[WP4/λB/ ZH-"T ,J*tQ, NAB6 &ZT(B, @P# B#ǿMJ%>^<:%%΋Z+vOՄKAz}}ߴ1"ی^ @vʫ,ln4-|2 ԥuz߭h#)h Z*ցbh&F Gd H3H dUBY S($`H B Ig^>)Û`%/*@Py_U(@|{_lKܔTG>kGWe=̾qE` ToRv12PMM`0$Wj^)]{sR8Iy`BBgZF B `D uKFqH/6M, PPJ AA $r檫-񊹸J]UT_ NU<;%@;6y>;` mw{2eD^mяO{Ƀ j͇ĊWiu Q9yywJo1 Ҙ&dw5UOxުfTE)dFWBId?pUS*q'9%$o\f)BJZ$PA&WR"3~H)u_,4BP*@Uwo\ZqUW2yOtwjrwmGtH[K;y{(/s;ƹueLLJȬb_03sU@6#xkpޱsZ[KV/$ij>>vs$Ix#ӭwZ~Ɋ fZj[8G4;Դ*y)j7R,#PFKڀ:m Nss*Q ,d A&BJEOn=k犼IJTCKQnP_wpMПNm+NECgwgHwiډK#aX^kz8R,4Y{EA#Z6Cu/zĆP!+o$DL%xV k%zp,4PTH AP& BAN3~޾9mi9%*P<_wtxf;d~N7D14Nȴ;x +x?s_Ui%л^&uul`*RkRubExr҉ZuH~ ʶ %u/y$q]\H2, X,-hTiC ,$ E$)Vg}uNu^H2*(8kwr;Ow;A{ K{ݞ%Lɚ[O\ݕTh}3W7a6o},yZG *ڸpHʒ UB^XNXguNIe{ӑ@5Zȅ,d AB(P$ D0H&,\=tUyeKofWtmONžy;1DzP_|pQ8³F{V}JJz-}dVVLQ:‘N0RJ8(\ ,$C$ ‚ H95Uڭ{JL<7\h7~%_ڟwͿ7l=j??緟qwi쎌٤_d(|F6ʳ`lwZ=.Rfd{,Qa "% +9/vdTzXH q$"T%dI"QU ,5 `T(R !H*972VqK)KȤ,_O˗?雾{L&Y_SZK 2ί7AZB>%%#-HT./UmDtd}Ԥ4фdiB"k-JKeezҥINdnH-zBNB@D*KuB[@#,$ BP$ E@P#>KZ.Vux)DWeX@R+j rr:,4 `(R!H5럿+y6* L_Kz~Ş~;.2|ķw69D<\.w%xHwikpouR۲%u$rhh(joz-HRR:Ro;RJgjNUICQKLڐ&pw!TP(GB( H8 ,4`(2  IB楧>޼tzjd)2%u}ccr}p)rk=)_|w.n^&[Ub{<WnGN"M,HD}]!Pd$"2~X#:!Lcu<hMX"Qk¾"^p%%ȵƇ,bMuP.V0rFPK*n|(L$@PU BD$j>9Kww^57yy%L&EwcZg?Eǡ_o7ތ8:O{q:H^t?Mv)m>Ba3&x)GaD 4o5INPY$-1T+E\].,h,y$Z!ȉD`U4M1dбqd&LBT$ b#H& Zq+:ξ=Fy':(Dl{_վ7OG(hMG̎[&wQi)^r S ﵃twLEKƴDbsSx.WpF]9S$J]ܲA4KF%@$LA@T@y p,WL@H(B  L$% BAEk}qL]]JnJBathмHwg৛wbI,8E/Y z晥w |&A[9nQ-k+^rb|ŮFQDqf)T WT@N` \9XV* &JXFDȧXbPhG[~Ыꄼ_hG $[ .q%f X 9ӃHD|}QX -B~}$0 MK} G n{;3g>0}Ns`j-N* N}X6VB˽%"GE8!TlN3t%{ MJ05Kxjid L?=^㞟iX%1Kipi#Ymoovlmvhdáiái_@iodsOtrak\tkhdáiái@,mdia mdhdáiáiV"!hdlrsounminfsmhd$dinfdref url stblgstsdWmp4aV"3esds"@( stts+stsz,stscstco  ctts+=udta5meta!hdlrmdirappl|ilst:too2dataFAAC 1.26.1 (Oct 27 2007) UNSTABLEartidatazboubtitldatazboubfreedeejayd-0.10.0/testdeejayd/data/cover.jpg0000644000175000017500000000211611351210475016436 0ustar royroyJFIFHHExifMM*Created with The GIMPC  !"$"$C," ?deejayd-0.10.0/testdeejayd/data/flac_test.flac0000644000175000017500000021423711351210475017422 0ustar royroyfLaC" B reference libFLAC 1.2.1 20070917album=zboub albumtitle=zboub titleartist=zboub artist%REPLAYGAIN_REFERENCE_LOUDNESS=89.0 dBREPLAYGAIN_TRACK_GAIN=+52.18 dB REPLAYGAIN_TRACK_PEAK=0.00384521REPLAYGAIN_ALBUM_GAIN=+52.18 dB REPLAYGAIN_ALBUM_PEAK=0.00384521EYkDG c!D`yB\.յlF-<Bc Sh3lŐ0g-8f"H|a$ͱ &K a1%$fV 2/duI2DDbk#e BP\ L#A  PN$A i2DTBz.A21 `Y'B<ж!! (R{JD^Zxdx@y&Y(O j H JHJ+q "Xң 7"#݈{(m4U,0-8 -CxǤ`(A,2BF|FF /1JQO(*d hh!)#" " y@]EP_!DDI$ ,Y %tXh B' !G& $SX" 9WX'cټM٠1XA1/jRɯ@Mc&jHh- 5 6a A[ HbqeHVN0KF &$$,2 fBIS Dw/$+a3ZHCT" paDĩ~B%CA `HH BD,P٤DJ$GD)q&$o!(A)a%%/D[X`" F xE:1!3B"S(.>(Ak-Ѕ@^܄O#D!( ;=Ai1(P16i$2,VF"IqRX(XnI  `\G J3 $G  a 9!Olk&&!yV(lDQnD"yrR"؈4 (5\"DH'ZO|I18 !\x-Q,3LHD#R aN d(O !JDGDB CxNA& 02̳LB( ~ E,-4$*YA"׬@lH JXeGbIwD}"wHDeEh'Y@z;^E&ri M$"K(H""hK!(/ ZD*2-‘M(г30v$ KDE$ @TK! "DAku4a(~t |怍h4D $ h@@E0DF@PHp-PQ 4b HB΄$8bУ X#@A!":o87)1S"(@ xL-"dHĤ$BtD8$:"kDv djqzLNR$a I ɂ @N(QC# C,I?eHB!F&„&J,B~"& hE,)D$UU % b)""I" AF"3lL!Db7!$Z6D)34hD@!h @DHD|/N MƂU@#3Hir J LFD$ !Iq.s"KRBm&fAWFGEB).>-Ô$CuEe"!f! ?W b$!!B ˆRՄkU% p!{IR ANEDy1@)Z B.S'aPA j\AT5j  r T _B`DSrTm1h-H e-+ĩSٶ]7 @(0zN<(Bj$+" dB948D$Ȅ1 !$ cCM`"Pdj)1 ЀK1G2F)H@^! 0 "#Ce@$hH!;=!#F! R !B vMr$k\~De"$B0Dȋ"؂,HAEQ E$\t--Ċ8 5)(9 >A" 8@LB"G" $ FFgF,TmE2a NөwlCge47|O:IlȌD#[MD!phtmrEDTPD)$B 26cO :+B/2rz^ND)Dɱ~ʹ%2#H2kTJͫ[WIKʋnFԾ4ՑI+ո]d_v3$62eR"[Q]*.zdO.uH4T&"kgiLNZhɎLy}f a" CH- jŤ(%MhxQdȤ D`ɪ7XQA+BsH@Lh@a DG(vB%ej$GmС!#&%c %P!1EDŒ*&@)p O-h#A8@ JD` µ"-Q&$*`"  KqD"D.8A4GQD%&B-W!JV^DHR).AZLhi'f%B|)!8ȐKfL "3H@$!HCbtx'  H(V;U,d"8_1F#%QmA"Sy~JdZ"tV) mBH"*_ & [@!~F$#=#YHa '"Q,J)j1@ JD`Hh1BGJZZ(4!d|4BаA.p!vJ(KVd&(e$0C[2*cQŘVl/2$Pb EI$)&$xkeL"5R /^@)ehBHfB F##HS F= ' BD+)+X XA& 8Er\I~! ")\Z&>@D(Z$J@R`L!5-AZa`)=PDB^&`Q jyD" ȢQ$dV8!:Y5 0$D)Hf %Q$ZE1re"CBȋ#G! B{2hG@C2Mc#n@4YdJ@&Ae ZE= qcD53 F c- dТ,!Gĸ0Fc4J(TC!`J;0AL,DO #P"(x&[, Ψlpb_tԛYHL%*,"%bBkd"Dd,MN6dQш!*2++HB*H,@P H!D)BZ1 6( زQ$IiDBr0BHidKU )ؓ6CR$F !BL4O2!fPD6kg$q,5(DZ*H($, UF ,BC:=LAI.c&2Ȉ$"QhDA8Nd"v HD`Z| X!Th ! b2 &JCI % ĈU4$`"]CAƇIlN9!QߠPpF-(e0&^Bl SJD @EF =(-H6\! viT)"0$֦LDvYeDcH4 PfJ"#$hFlBĆYH6?RlB p=$YELTFA3v) {$DII 5D Va MieKfKH̋/b,&ēD2Hj W &A,?"B4/B"hcBIAY$(~! U|'22l8&`bDK DKDQ"hJ (&WH"^j#Vi,YqՏ& $ ȑ2 Cx' DhK !!pw mB-ćpYAy&$A 9Tp&JGPTV64H! Yb&J+KÁA.Z'4 %h, B0#ODݓ'4K6YC3"&D&!3"1A Q, (ZJ fPǤKl9"d<\0I '!A% FD(8H%ĩɉ 4(悮U4"DȜ C2!`D'/A`a,Aޅ&g$Zs C*/ AA2Ta^"$r+" aHQIK(3F %P?j\D4Be"JCA0 OP ! ,- i"Q& $yGoō ."a (13> ,țDa zA[ e! ,ɐ&RցT]|1dZdƐ) R0 . 2 .|uB)`X$"$-#, i&aCA--D4 H%,-)XCHn 4ZDEY u$2!d!DQj8M3BvNds[M4S*6"L+^K8Dp}OŋE4IA0Y,d /HC ੓! !%9Q oRwI%@  QDBO# R AcDŽl1 !-SʂQdHY m.f "LB6Gh!j}A=C$"' ,DB&BW&X"DD F2u8')f8j a7 *EhDxQA2}I oa"H9#T&ʜJ`HHRpA $21 X(r"!"sbE'dD%, h(0BQ `!Dќy)6M "$$P!X-XL$ț!(,R. O>^ FCpu˚)z6hIkEK% DdM{)'$,B!p)AMXɲQe Lbt"([Ƃ JOH f =8"(Ea"'uXfP,uoD$h h>'|8Di"g !1BPBYD# JPYtA"$"6 Q# ph*,@@q k4a"! Ac #Q[#F'?A$D ) - A?/ F&B4!!Ɠg$ R&`6Q FB Fgig_&XDF. ;!)"4hI爄%tPMfBD ,Ej! H$R1I k"N3R=4"# Š2* _`C\BURsJC )(! ChrJ2H5(4 -! omDXsk[ bD$GB52 A -a JHQdX<1{QBfN6_:DFYv&&HٙͥLfh[dHPt1d]n DXLw,bI+i܌"I:DFfd.4mm w%iQQ.G2ވ#+P<_RCM 2%&ތ?EdhDD՚jʈETDjhd&2]VDB߆Ft3FEfM+\.d% jiBxUǝwѥ'BlM~=2lʌ̹%$Dk6CZa ۂEY$2UBGNC*HFscxO(SraL\( hdDB aOb8$&$C`Qᑕ.PX$;BLԉ$Бi"CAd)  ԠQ!" F4 j |@ cQҘ[ aHbfd&0@HY>$I㷖D dqDo.]ؒR$"H@ 1H  DX阄OZȥԥHX`,B"BH -ޭ^#+aT0P|EFRHE!!hq$!pEdCF?@SfTf[&Ҝ#zI$``MzPGA^JL54Nȍ29"'A )oR^k=dŎ 3p$˥ŨDj@$`lSп!ZQwEa!%!$oZ] ^ul6uv!HNBkEnd$CteOˮ.!!YfYB,6[7bM4Y"LWI4NfZjEPD̮J#؄IfؚD4'ڦFU6V$%ziѓLwk2OnR_q&YNbOӥhAUlؙj!'̄d X!*)JD*c~d䷒R}m[)1 (3D"2XDB_Bk`"hHBB#$Up֌eitJ6wAÂ"J9 r8LQBP@ ii"B2 ^Ga@ĆFcY bT7pYp$Qb!I;H P"A1!I …BB9D1Hֵy A#K&ª*&aYK%ZKjȍB$13' lЗX&! 2B4HH@.L0(ȄIt 1A[F&Ř?%O"SaLKZQ alwB"YFECEz2Xy(2!Q]xK0ՠOɢS(Za IJ@AHBA) P`CFIh&K'´E &zLB+d+ =E$Ǖ 2Xב&"6,%CQ""& 2A/AlP]M K^P@U&$8.DQ%b (5%e&&'`(NEPB 3(äM !0d(R1`" (±$!G/AB83 Ze @E:E pqMA 4rJ,(YqT D+HB *NbgD"ia ܉\  !:RPdEn x"9 8H!E A AJAHO!cD, )D QpD#xBM$T,RȊ!#c$@$!"!B/  t") Kz !ߘBD4D&Q¡$ G&RH*"IĤk$i[+#[V"f}V˄d])2Y$2iR9$rbE/$gꍐi"" Z؈&<@HԘK"DA $YR)>ě8FS4ײԓAcb 6YCrB#NUBJ"SHnX,Bd@Aa@@!!C茤bD!#d@2DDa)CaQ,:"hH ,3+,AF FFC]"M* CHX+ĐLħЄq0!8!PAm,lB"E!RwB"%(b$ji#bQv-YA#7X$lHQevh!X g-2m YP#kHQ ̑%PYDT!6Ds0ICoC-ʛ/k- HL*bʆr&b4xr FiҲ|lrR"W4uDM%V:fo#b!|&~_dEC  !\~m/$`%dU %/A+ 7UAH8Qq` LI Fz2)HȐ, A LA(v#3 - 6>="7dbb%!S`HJaPơ "d.` ($;,AtD0 X,TW* A e)HCp(HBfB"%]I\fxD-#|a L0ݣW)bԉ g"Pp& x(@@% H@@Z^i"b!hi5 aQ $D1aR #Qc2fd&NZD̛K"tJKɫ|eNVd!2mC*$[R$id"2kd>:lQ/?}6oӚ)R_2$V'"""2|"S)6FbrRhJ$12TK}Lr&To{,zI=rMEeI51Q"Ɇٲ,{EjǴ Y] + "e * dEF$pJ \' ,OE98FX,c)'vCښCA" H I z&*S4 C0TD#Z4A65,G Jb$3 蹓D2(B&RpLw$X2PU#"{$LIj  M,F(!6WDBRdҼcDiHB!dɩ.mtmtlb#Nd܌ɐhΤ{YtC1E!X1%l^!?.nʓͳ E1uF{Bd*diY$#Td$hF#k%H7*d.ZK{vmd_H*3gF5o!&!8il&6J6/v"H)DAn11L@-)1hGLCh@YrD"b54H#(L!HoL8ȵD  [BjpG D–E$s`.,Lp1P@0MH_KXD,DD&"dIy B A"D@3kC0`3HX/P$NL\Rj|4HfM$ɺDA>6%^a.ݬx16S:O#ݽŵ/Vt+"{"OP*zliɲby F'Mn\B^)>x+D&[3SQeE%HiԆcM!#m&\4Vx=ΐ{U1a&{%"Ťb(R˓O$!*LEr!R]!,͈J4&4TTm]W!ELF!n!ltXHpG WD<"Cd ZS.̈́!"4Q6n!H sM 49 h "lY%)F #M2(!E$2@E !}ّgAި!2SHBDX @Y"hxhF?B|P6e qfY$rw b,&ZBBI*@$&@ PC(D e*LDƓO#. +-x]d fPfQNK"/!=A LH! FT7 NBɠNU!biL30fH#iF9M0Pd - BG!'*F,eCKDٌlb)l8"rHAbD TJh 9"IH B5ВDL1  boFl#ς1DAEaI`2P^L֚fAH+ DRo" LfAB& e9@ie&8$MКL8ZbBSΒ$!f-Hq  $Lr B8B`Ųm â"AC,C 2 AB¢ m $ɒBb /),yE Qtx… aB8DJ$1M"0$C 4$#>T0{'#(@6a EBTDB)$$A  T,X%Qc!ЖZ)QMI)@1\!"Y*HIXAdv 'DI`!ГЋtw0z$B Yb DFA'J +PKA.~I1IB`Rذ(& h . eRH{*'ŘIhley I)2$%"QdLlB 8A&{|W"$'}jtV8˿1H#!|b]!5_ ՓČJllR}BvF l5y7dYJgMQMWD!KZ2{LēT:]*3N2+ VĞ$.3HHf'T ;"&hb$heG&n]m;9l^ =2!LBJ6|NdklBd CB,#fYKh ~0FHF̓Y}! PEW $ł l!EXAxYDpBeIAaE0i#$h`$ P"*8L!-+N"!0$ hZA,9B#Fo9*ȇ(AI8N-I`jQDGH"J $F2"D"% 5HB$=I"9h0L$܄V;Gp9 (F BH  $5(TV @L/R7A1tdbJv@0F,)`h` "^$hHA;BI"A0$Th#\M$94Dh)4'Dف&!3 CC$jBF=BKB$F2" a*xCR5-#u1(P!di "4DXBb HVQBĐ Q(;dȴ=Q!ȢqUDHˉ1Lɮl  N E&,G `HX V KDOO! )!C$$5r2!lFZРa0W$CeuHQQi`Bb"hrq6 K'm:.4E$ØD"!":@H# SB bP;HYZ,a1,yFzQ(N@) 8QCpE0PY'6 ( dF(N6(Aj( D!O,"$KqEH 3̉G8Q:i-M ҈(^DB!x@!)Q A$PB!+<0(l +6$!11Z(,QWQaHIs%z2hV=B mSA S~R%(k30D'$ oE,`& Eh4932Bk%x@!aTDA[DBZ*+ ),Fm&J"g›p̃7XFt!#Dm2ȖLKR#@L$H""r#BgENj!Բiq3 02CPPC"If!l#H()##$DQD+ "Am d KyEdA (1DKf >I̊baɐ,YFW:Y23$ $[AG6!&D0(HM B!!"?_h8ƧIiyV42|4<4՚J\fI$ȄBDOYTuxȜYЄ)pؖ֒V>Ɖ=mijmN$Fb.3G,TGEM* ~c]UY dѓ'zz涙xˬhET=XXjb"7fOR2Nݫli#4Rigtֵr3;ĐX\E'3ϺeZwbDJ2nO:XԚDD"TΤTdrA-PQx9Od3JIUࠔ #KJ^ZHHAHRoӣ%dNd%#Lh Ш 91x@"Al%BLB$>lh'8=BI 281,D0YƄȄo$qJa7@PBCXNM#T4K,aр[p%%0C2$BZta"`aZB@!A,a&&(x"jD\L% F {5#aF6Mo6D"2B5@Q d D'F 2E*.T 3u (\c2"RhKhpD" lXD ׊?"F$BG!baq$[E x4X&*4@+%jMBTk4$4R  ?"D*PDF_"T!HA8!@Ȕ- hR)iH`,I#9vBE yZ!EPR$(r `/ A9%!;j@"A t (ƊH!XilBdԵT<^+lKi pœF dHD!H1FL"d-2 4@2@d5q`Z>k&eIo* #VԡHx|L)x!D r"b!4_grh5r1M"UJILa'hEs * 3(q2!(AQ3hraP$#A"p C % kLG3%f RC!Y IB`jSʠỴxKb8魙 \E)!m5&bW*m!Q+y%d[#"F0g {u=q J U100'T@Oăȓ2D"ho"oɬTG)!ThDsaC#h!806Pz1fDdf@Dؖ"'ڳ7E"S6LZ!*日zT7h]W)fI?֬G5X"ey"UJSo-X*36]j!7g4[B4"5FX&$ 5I ̯4ټ1j!!}g$#:,Zmب'˚ fV"j4ipeze~x_2K:[leMrŊI;1?5slI2HMdUy[.cXthxBF)`E!J Ƣ6;-$*h2aâ&s$@Ćh4T8CdEChB{ B/ f(  ?ab |%xN#L4g*n "DDH,Q 2FIN$n/_g؋Q)8"5 `P"F!l!jIcCPA~bHx<*W;e2bddN 0؞V$3 =abI/ ^&ɨ +"1AIJ\ 'n1 1|X1!ݪ>f&$SJ>C,$& ,EDԋHB̡ZQ2&pDh`"jP-Tl!An eJˀY Z,2.a0 iNJ[" (:$ H5Hb&I3eIGN6(F1d؁F0THAc .{4$(lR/< #|PIc l/D!A"/D´*膡~ #"pCI %BcC!fĂk+3MȍP~xYn^Ze" BB6i BH <lDz e})l!2S+«șPH,зBKFD!j  L/+L8 d (?zbr  !3$8EDzzyע̹3NJєRXfsdPlKY0#G2"{!  4=ё)趧ECF e@4 DN$ H!$e"!|y1y'06"^BJ @Lh-8%Y~Db҈gF dA,u9 :T"Jd!.T4AHh"@D8Jذ]D40 TȺAzH!a6ARB'т^dIx/B"P-MAQ"Xx. Cp"#z"r!)q)"B)" !'XC)" 3hX!D4m/ .P,*[4) fM,'Mdl-x@FB0, B,*MGt(HeIؘFT]4Q`D/r"B֠ CEb$%nP/DW"a"," @A (E$4*mdB1l 3@T N& 6 B d$c7(<$Laф Rd I͉)F 7!%DFČ8B14AdofJnC34|7 IRH:@X&ĊhSA!){.ƄAb1Dj##ż M(F|(ib B (DH8FT"C#"1b waD@$1 ,I&!pA KB'K,DbANX.hԂ BI , yv]d8=h0QMaLI @R-FÚUMD#%@A 1026Ќ GLŒSoAN s$]) 0%ZB@Hȃ$j "E&VzPbѓ7Ł a!$QCXRA#I[$5$Km.9b-4H;$ Fp!&\T8# TpHY"`!; S"@& ({(5!JZbh`Qj zB%HHD0D(A04PN$b/8BXE%I%LP"s8س,QbhDAm$rb(M>ňv0/D \ "HH .BHD@h @b4 BIjNur$+jOA 5"@YA2) Y HBc22LNxQ*!{JBF^!l% (%%=!MЉ?R#DHd %2A&30At.- ! #ɦ[XFȄ'x$Ld/pP_ 4!H?$6 aCᬋ4萐yxjzaX"PE0#R eiB$B.H[M&%!"anDiQyDEe؄ȧD\rE'Q2P!;L" bDšсRAB8$I' 1" Jע}HlA|' =HDd CFP`$j$-WR(YSD޵^`bLQiV7{ @΁hBGl4xT\|/ah.)t,FȤ ##A=S$(dĘAM#3.5LTZDA"F, H1@ ).byehxF M@B@k )X-"X(IJ"jkE γW`!C0$Rġ6B"(@\#X%ч"YlE[GQ`?Z87 'XM  HL ,y6F&5$riDľ'6OTȺE&uEIJ6"/U"KWjs&ڙ fL%~|)l+d%4H/32OmT:D"t'HOmjȄ%b2"_u4*$DC]"17yf&b[aS5e*ڒ$]ɿĂL^̛6S>d{Y^%3դ1 s,L#X}` #M{3Dh[k$c-3!"I+ (ɈHy5FB5(i$-B"0B"`BŰNE& $^Ċl/ю,!guFCjM( aBl$IAEUh4$u\$z5oQI1XNn*%Č'пfM I|d"s+ЙD2A!.MXG @p0"J.A#DH3X!dmkl0)*^b1('MBz (Vb E̒ @C07C,,`l@FA,LmH HBDX$x(|FIbHL @`@@QR j824XB@a^fM;<5h[mAE w)bD$PR!^ A2kF`L4a{an%FERxPTY LBeHIPI+Y!*'/@I$4MhCe?aB<*)C7v!uB˩:7R)vMHL+dDJjB'm3Hؐ٩H,|_i"_gSFڗy3Y̐JF$^aI o/ Heɴb ӈ,2՝vK 2D$O:d[˪4."2j'vЏm7eIh14涥bEH2y Ri[֥멢:3 y5:6DjOQHB+H nV`21-sFBċ\T;]I&Ib$N -(s.kM4(0FrZDJ$+<)v$m!$AhY,B 6& =! H1"E"ZrA"(B4TaK8BhDf \螑]yo"YS(6XV'HP4B I6! IL#D8#Q/$ґ~DENHRb1dUBL$BiKBI&QHb ԅ"Np_#pC!̽;Jr 4"ij^&@Z!xt$F&!|h@Qa jbBaU K*D$b!GW-#a&9-!$S!o" LKbY8 Dd&NxbB?`//RP&TBTZ~Z0b (&I(q*Dкe$~+,X#L 0 NXY&Ztsp`-wo~LhH2`N>6P$flZ W&+FFb@ "|(8! DIaHb'   #%EEHFIe!#dn1'`!Af2A PDBH"{ X @I '"DHFY%Wь1:D DD1@]I`C #B8HDfijfC2BeDg\("b[dX҅^ID"bBDw-D;'AZpEd2 # C&J! P(T.#CDh+Z")vj @HBm# A 9O{ABY9h&D D1.Y bhU":*"@DiYG@L #(DX$ 2Td#M\ '"GFE"ņئ^DFd2 bQ$"B!@# $jYa,&QB1H$HX A Lm6kY bհSHX4L|" B5ol#3$6QIDK#_RAe#Ϛ?&,zd/s$+n]b+"]{W+\}[+U5h-ؓ׍uW$"HE]SMtK,mjCXBGK7mT6_[xfje m9.T͢A"D̖d *-l*l!T<<˯-*=Ŝ C+LX5ƐC"_$B%!>HY=!X2""lhHB G=E@#HW!Le$2!$DБlL"! "rbHU/aID>D,dpGLpȋ" D 9@@!HDGB{H;"9bDK2"^(oGE2H HA D@Ba !WÔEV6!w ,3 ĊX4`d70C"#4"!I)2/hI  Z@ I &$S07$x]3c5<ËrS嫚0 >\=B  Y4'AeR"y k4PY [!^"OC5Esgb!e0Io B< E#0ȊJ*^1m 4/oBvh?eBaP G{zȃ!hBj("͚&Sj,Vk *nZFkdDa8[lb AHA.EL)i ^XOqB)] ! Qt2M,qWBS4#Bd\G=A#`2R@,lZ4NS4LIP ",%G ""#AP$A:q!Sg ;bAQhx)BI% Hs" B)$ Rf#ڠ qH[d"q!g<: \LuƄC6i> L!%m L"MA3l֭ CT(؄5FDB1IRPKT /g$Z"$qb%i ,BK^FOA$£ `""ȕ4H"Cy B]UmŦ⛦E82fJSn&q B0$EaBBȑX"!z7 ڂ/%@i  "؇L@7hRZ@MC@q= ƅ@\Ŋz ]Q`i]AYE Jh" ',E.&bkTbD Ë4a剘.2HX D8A"J C\MdLqrNEXE B9!iZYg $ddQpH% F`D'@`CeB!j"! 6'YQ$K6bq bZX"H0Q ?"!,HȈ]A3%$7!haD(DF1 b$R$ZA.X1()(Z_@\3)"u ?A$b h%AbB$ESx Y%h2N)lBhC,Q *AHCD- Vh D:Xr J٠K>Di<"kP,IV<o!@г $]N'PI2 kЫ?B U<V4(bKS ӨX$r7` mpˆƅD( ) "CBA0X"Bx<bqi 2L=Ŏ I' ebFc( TE(DA E̴h Z!ȒGCy* "iyeR"*)mb!lțd7*y6^d Z$&ՙ"+w"Yo#4륚͓K̈U ry[*$V*>oQqm3}-hntܩmVL|7H|f!*$HHTN0:nld͑C e$ܯ.U$fE} bB*WKf"ږ=Igl牖T#5mgF#dq5N1SD#E dDMLlRb$%cTCЖm7=Y2.<՛EٹH)#mb,L,LrS,y"Vxڶf}W#_Ɨy>k d"o:,KZKDW-jl՜3,i] 7lXIvlZIA$~$JFŴ4`sI 8$JbFe!ȀX( L$"A A-6%bxPp,6{eFfʈɣE8Q/+bT\R`4Д5!\)"Tp¤!M0GB<( (0jPa iD\O,Z$ς/ G(fi$EfL]!!&#BEDXc 1!RHvh@bD+.mFgTnɡBBzH D [, B1 EPHР1E#L b&j]%QBM {夐Lh8d1NƑ$,(hZ\P0TɂG2AdB4VX &H,T3D"J ldsG<&" (" I#$D $DA#,D׎!0o!@7%53  $x͌ P@) (-T0$!:2ЦMT  OE ECkPʳKd f`dL 9("Ă0A !!D';LK S$,^@j(~T&tstIiqߙ'ǎȗMl&N E%Kp B% dqi@"" jalRD ry0HFQ%F!$j Q 'AF%9 A*EI M p)4!#$h$ Ѭ!X) 6HظAġ BaZa"Q4$H>Y ]DݵcBLT""N&MbI"TI㖪s Ԗ,e,Dc& `*<.DТ"CSbML!"P,D`EJL,KuTD H̢Z\[4EYb !KD"D[Ġ&FQ%J(3H%`q̝m13&a)B!Ą DI(D \, WSvU3VfmBQx,FJIBE*JXDB?,U8Q R1B.ZPA B/]`Vg6S2@" X|N凒(j"cNKC$! D#J!IA $%$BeP"hjuq{Pb|Dސ#' R@<,DO.Aeb{#h)^Li|&үtIeC1@*bZ!HC'WO@hl&x!hz&B&B̓%%&$ T!ae$DHjAeg#zm-_x g2DQ*4JI,"Đ"+"&BEI F cd"l nВ=Dh1/ HȳZ'Fp3Qd$ I.ސ[Y]Ÿ`jB8 KTr;Ŕ\ns1ZϞPPs&\h(J$zЄE)%!g$D PD݆e=n{4 Vj(O(BX0^$Ȑ"DH"B/,b\IB<\jRbd`Tҋ$Ab@Q&±؉"mLD'L16D jyhD.!4DԝRhFoI21?DK̾k2m'ҺT$UFFٖ$$&؋$pHњx*4Ac˶$1R*tɪRVܭcF]"/DD²gr1&c jJr&F!ɶ2/䶒[fxS5ЭldI$ffFղJcxM|OFe/$dKm"%Mb"$L5ݎqjYfTEMgBS5~[X'DdYa YZfY[a&fLBj;bDΣ"6Fys%#21S&$wޤdIQ5B Uv/b%]7M2&YQ>zV[.NE4tnf`jQ5B1vLKFkIo!>#BAy@a@` "63P&"fEW-5#1f6#2CA$ Aa"FDKRQgbԄKh&X`M㻄`ܐc1!c=QN! LqE"VY D", D"'8Jih[#߱F&| !pYA"bI"Y?$r,!?B!F AFITN&VˤaD*mbKD,pH0#Bi>@s:Y$K4"`&(dZ)DYKA'K^n-7񳐉!Q"њKˤhH"F$YP-ʑ)AJ0B@RBhvY!1ăE(#@  $a#!%4 (:"# D I! DBbBؒ]QxOAH[*"T%E`%Si+ [Ktm*K,M,1 @ĕ~"1XNUPRDPs+F.jn$Y$$4J,H֘A-,ϔ@M(Co(Q"b(\!A$= )! CeAgI„Gf'<,pht6X2T/, DH0!H>(HR ;&DHRly2դ^x`*FShyx08]ʌE\ A"BD@@:0AB09BcA$`r4\HQ E}LU6 - T>0FC 14+$`W΢ $НA !G1 BEx:1>M/I_rXK̚,k5*$GHD:TbG'MYr뉦&d]6(YSe:2.E#UЗ,255Kz"II[Y.YED$o2R"BDa3MH#%mu٢2%LrnKuݷW7 `BBe6Hռ=#$A{kْN6mY<2MGd̹.樿ܬ6'Lɢzޙ3i ъ˾]bH;JVWd.dBI,F434DH$r Ƕ&5tDV!=L7!J7*jWnDI=+W%"]"X5h,@K"EH!($1AT$L"REF" 8F0Q' L Idq OiAP\2D[$hiBc/|Bޅ8Ah"QjY2"DHD!h!#?a~2d;ЇW#,ڢƛa/"YA\^A )^B[PiƽY[ U2aQ !Dr;A)A+8XFڑX҇ V#D_E(-24#$!'A ""@$DHrIe2I؆5 T DM A&$PBAdhDA,M#_Զ+cN(0˛$1>B AjE4 aBA 1k"'?DAt)f Hi,"1 J4"1&&A- ZƢ4$!0"-s" -I".A,G%:,5 e (B4gЛ2B1AoQWyFLN&ސzR4&':3\Eg!ލ͒ bMk4]MBIM&K4G22]'L<؁ն"bs]stE+*v24Mę:j&"_$Q:k/xbh&y WM&D\F#L&'FMdg&k4CT3b+Th%l$B4Emb>uUD߮'$$ZQ.%HuYQ#^K 2 \Idlљ,ԛ(@GĸXC"FX  $dI GB8OÙا萁 nQIjAY$ĈA %đabЈB` d-Bkf"@#'[ `QMDeB)lED BA+[!!AcEH"@`viVK6h!%iISA!Јb` Q-1Jlq&jU B/6 ,Bɭ7"uG T^ [ZdP t,TD.2BDq^ HF" !vB"ͅf$d A5E`BiB6 S~B3eB$B,XDΑ VQ A@@QqR&[8 p DK %dAR0I&i![ Bn0)-A&2XcDC^DS8A@,^N,b & {`hLF,dH   #LBY"dD X^ AH ƲaV# " Bh{@FLV1VD4$IB)D$p$ʄg"D h H]BZvF P2,;7%I A $0>!7ςd&p?j yl"G(H-@G\Z"F[]ɇ aC kAa( N*1K`I!f""~$@pH-Db-BqLl #pPnhd LDM,hDJ D ȐB&6- huzdm5$DܞW3*A6eKS! eIKͫI zM&M]Kڰ'.65BU5̎i,1~i'OZ$u,D਒Iq-eu֗ks&L3r\I R2nXijP$hI=m7BDoFƫ.꒻ʜ%[!-T'yYIcS ʨe$.eBfov޷$T4ѤLkBt2$Ȋm15R$EƐY OD_+b܆"DokQ5C{")"d<["(LHaP& 2 8)Ʉib0P |p \B-0Hd*, _  DȾ"$ݦXaN"F IyD4*!ţB$F =$!-y2Q' ]"2NHD(V -a[ )J6"~XВ af`\9DBZVh'IhHQ̜H$Wvf,VNRmѐ%bDIЄzeVċO6MפmVVD-K*VнlɦB!馱H_!.䓊BZ&:vvS[M)Z,V6L#t\DeIHmF6mD" ]e:Wl!*"-u+"F6+ϑ>DʕDD\ff˥!SG".D2].*~IْŹ2k4CaLNeLD-FB5dLġr\IdF#Kb. HhВ,MDleؤRHL2K[&JB>- yAo(B=HT% ` I4$Bei -ؘSMOr~(LB=Zjam0PYSF!1FZŬHX0#:Y#B" [!PȖ$1&g  CE$!B(Z3$ I0$h`:\fٜ-q-3UNYDR |EMP,& JeZX r-g@B& Q2lM "4!YScM 9%N_#PD\ !!hG&)R*6N,Lf 9we[aZo"BbYk!#)bV!H)5e\Kh=dL7EQaDB&"  -˜%L HBLЂ.Bm`@q^$I$ VX(A! z'LWQp "DV*IL!( L*A" pD$#q TQ옙$"bc6܁X&.L,A Q!rASdB8A#$ B-8H)7(D Fbⱗ#[2 @X)i@C2 !^,8Hea? ` Xl"D (k!@z"M"M,$Q'+ .8;7~) aDPX*(K$/! X*bDD+%r$`&*jBP5$ YZrBD bLFЄB9*%B3Y bY8AH)4!,O2(f@#b|XL,bThA_⠤.q Ɖ $T+PŠ  _BJG^n eD3)GìH@("p$Y ,AADAI$8P zjC#hF dAZa'm2N FlxBh*ieD(rċBIxFB10N(A))q 3B$&D0!1hH"em #+ZF=XA@X26LzD$1D4  hDY( D* EHH Y$ oJB<EmD J )1?Dbq^DJEYF" AG b M "E (BLK/dZx"f.BV8 QNԂ"ְ F& $"6dA&MY ($cj$f @"B"()Pl@E9FYLo08C+HA80.eV Hh- rq0"f04<@RHD]H2!Op2mتረفPG!c` -BIp@L28'6RJ0 Pa! ! 0˜xZ!o؈$DL$h*'zI,'+g`H `.4Ђ!,,q#l`#1fQ3b\VD d Ac(Ё&]D ~$JZLD]0/ܠLJh\/YV"B>DBɛBbre jP14Y HD㵼8c4lt&S B)A Ä!Q) ,eLǖkY,=yBF&b,@VH!uh$Gt NxnbX)d'=!hf J= ~YZ ,aM7"2;HJaB*B Q|$3Wj$dH6OH4 %lCȉG#$7Ϡ# (b4!a =y!4DȒSh83t%6:;L1| !TB XAbf#SM".AF,"PDZh0"0p x@ 6#5haG C"ku Z R C-# "6HA FMA=KCB2,aY<嬂1i[Q)D3e)",  JQw! &LHLiE"%uBdH@mh&C+lKHDD' #9c CB(B,GHrȺW0'!X/2BNȏH цSȵ! =Iz " Pad$EDxقa#7tjظ&gHɤȮdKb@VH[B/"MD!Rz@YUCo"L-Lk <-u ,D,BBLdH0I7"4 B-P&h6@q R@]+T DY WHPH-bBb`d3j,,hd|96 d* HEJB4D$hT!6LQ1((2 ^ATYe1xH 0VFDt"NR2 @hȎHb3HD1+JD$df(DP^%c&E aHo L&$Dm)-$R @&!!N$DňLb"BD-$ޓ*4݆#0D1$ MH (j$PB"1"XA,45C¦XVAO!+XFZDLi! L[5J-H,4$&XH)f l@ HJ"I,Č \!:pU.h&Z FzD4/+d@$Hb!L ETHl,!JkDQ!SD2T ȺBHJFP\gHpQ-Ր d$` !f9DBb$Mr&`Z8p&TY 2EaYCFDR",.t&rTe$#Ĥ46 CeMiDQ0DB)4h*,Zb H- EFPC(NԪHBkA RBBPA $J|[FC LT!t 4"G"GFצihX1XcaPDI]D,B YP`DA "Е^FDkAoPD^6MV8 ,AD*Х$' , bHPHѧɢ^; HA2΄xH/ cAf,Ivb TaI I" l2$"DRXB@$`V@DH,86dEj 䚍iO j$LBGJ&JD#Ĕ,Hx˺MY %QƶDu "<q,*gLU.h L)$r0`X"Vk0H-KD]N8̀Hȼ8h"DK LD˂Y=͔fGܙ ]@ M=& 6`(&L s"r$xe0 .nAA& ,SI1RЀ,@1(*E$DCQ2dF "M՗#.2F/XH24iЄ1PDqj8(FF0$UTb"20 r$$DKp"8xR)HK4b ,զ4^*Ƣ!2߷JdDI`*z8PbB@VLƷ$-MC @k+& ,S!H0peF)$ 0V¢dA0Hʑ !e" HkBD9$! `]`".bDB$TQːE,I!bKE9@G" (UbЈBO" ,^Y܀Hogf8#W SxTpBSb\A A& i!&! g I e&0 K$ĂL2֓I"#B72)Z,Dk7l6zNw{K=Ky[ժ[TJ}QD5΄uKrJdI 6O"DI=#ԇfMz2AbŁna ;*#)D8TZx bC<K@"&@-X% 2hI@qY]2""2#GB4xDL".0H{!!(Eh@*B!D!2B-DvքQ1{OSPxi F7$*V<1I(DRB#E"mS*!B" !"mS+Đ(˺)oD6&HxG#$#4XQ #P2Z ADCqpLT(/gi,0(A01E!@He6HŠfA# E`#0H (0B}&Z"a7 FBa@#<0Y} 7$ڤ* h1 "d~ۂ (B$̈QˆHI%4 H,I2QL0-5Dx$䞒'Ё0-~HH%Љ@ փBSNnT14H:} L  422 HB4@$ hAB4a!&Eoɍ2=G2$fqLD;H$K!3Ƞ$8! .HE HP$eDȻ0BEzȊ"K{D_E4B $O].TF lE(@`DQ4"GdXDg/iE,IXLU"DHӞHAU(5eq{H BJD L9!d"TdRE%HD8)f3RDɒrɮ„`#ԐA?#kx &CApא3AD=i@6)gA9Zq!N,$$bDCxn*lĂ !E T"%HAHb $!OA f NBa0ނ$pRˈlHA%2DMTBMp@XD2*Bbn1Fi&FAzzZ(Mɀ0TE",dB%@-4I<'(d$HL $HB6+l8'/1@FM5xx(2$,qb2i- L! %JĈ$dSFK 02Ѥ`B)Gn[!F!hJIAJNщ)8"BCyc,B)"3' Q Nr:*I,Cz3 (Ȳ(8Fr!Ld $B@B D\b;Di,2 Y$ѐ`?HY/ &r$#J&,"R *hd"r)5'&S&" NԐH3±@L2,$$A4l("JB"E|E&AF2EDi'"BscD&24YFDbUpGBH;AhңOD^,1!K""BKSBdDЋ:xY4ԊIFJAc!/~dOdThD!4(,B4+B$@FȬ6!% 8<âE0D!0!&fx,U$D- Rdc*QIDCPHE) HHQFF "{" 9-g^8Dv!2LD@L`%BDJT)-\3i^q^8 1T6I$шBXK"-A LBaq sx04H1Lְ0(A"-ͰlYDỶlZ!3,H0 HD B+F  TĄDDqy"EM٢Ō߮=S"$d0NRPL!R@xB! J!(2 !Пix& A9~ $ J"%=DIZ%/AN$ ؈@F QD= ”;ICM 4 @"֌ dBH\Tؒ!B e#F YDU$ h [Ӂ @^#bGC#E8$1DFdCHTNy"NGB6$r\Hc# J&+ J4O@Zd0HY4^b(! 阖|p4=$Ř"&GD" B2 @"D0bPR *@}aېl'gBDjM0I& ,I/H%$8#@Hb"}B8RpQ&bn /22* Lē$D% I@,h)D`Ty % D4F"XmZ RAB$hAd!!NQf$&r(TPq$ Ш {*pZa"X A¸0M:4f ,eІ@K@ A4"B'd$ JJ B )F "Y]g,sFS/I )2QHclDE%@! _(0XDĒ3aJ!A%hɴ&xHACEY BeTDl4E*GS (",hXL P 9EBA( `\z|!xF$mPlSBC@dH$ (% BB\HfC̉ց*4{TJ8Lx1dZ%α# ?$"iIA '$j"{;`N$BI""D$ ڨ"!i!4"S(OAĒ"ŴQl x"0 Ԃ `F.6LfiDW 2P F0KB R-96Y{&ry+I$"bB"B$!B(,KPIe>[1gIfӄ1 JhDURX2F,Im@&HdGb1dL& h@$ De#B-(na 9mcLp/!|BP!5BEAIB] HTKR$d:C&aB Qr" z^E$ׂ`#(L x&JY ?2BO#-Y,"Y 6@N>bQD"Q I ],DY1\K@D!O8P0ЌDt!3xI0JDH"z"Ђ@aHBE8!fL(!x#A" hcY #H!jehT4`B~H"SƂX"(АD)A!$%X @2hA0PQأ"'*EdũX9KHݰh% A6(+r%!$E ,,|hB 䀘6 0^: -gaPHf!&tdQΔCIH"QM!J(H %GdD$aD!6m ll'[ 3F!LEI "x$5a(CIBC!(!~bd,MyDi D+^EXRLAOl\JflI-H/ AhD+&N,Hc/$k,p_$_[@9Bx(BՈ$ 3!&QULɐM&K%ކqRL,I@Bh MLLv` ÉD jPfܼ^ѐ#GȅI2x )|9 (0 ȓh@A'h\Q <$izI-&kZinH$ĭ%"Q.2ˉ[DF 4d&Mv-ҲbrJe'Se)M3ؚRDBrd"ɓ$1:ȚlY bB#A;2XkP/Ith٘w-32|ȴ2l2?2'e@T@h%+" `$@@,Ed0^ġ k2֑I&h'ĖAb9HpXED#M$a1LB 3h0 F`> 1;C H1b[" I-'"ׅYQpF zRS,mB㈋iy2"0X.18P אLb&?6@CUxEJ!D ZZ\`HD t  ?+B)u&"G"ah""B`D!R!EBQ!YbY"C' , TT3Ўլ%lе, ziy ֪XSBhCA*1MR!@^ojr N$"h)LhFBaAy>xPc1@L綂!Hrg"P'/3a" p Bf (##’Œ9<p2U]!e!C)p_Uq|B T ""̄#E& E-oI.qEf3 A#( GMy }p'srJt2JJ ffffUUU]c4ccbZV5B&8^F""Rc6 j|n97EH7cš Ewxg8t^Z/gZJ?G)tuB%<~E*$V@..(F|#6)kШj^@$֛z~i*kŬ]yzX h) ÆaL <@V/C1!TO:BB@ (DJ*:4j顠GdpNR!aYr䤠d 8C::\< yͅҨqpI|gU4 p&81z@YI F<ڋR#I#Oy< 9@4-ͬE’r:kV ,9&7uӶin;ab:c*`c2lBf dGQ6s \ ٭r570s4J̖2LNrۄ?~FRmV"C?@f F=Y K!4g^ (A((t-ޏUv!ą׿r[ kF@QraP$-#\Iŵ[BiP HTF=Nke"#.LUM |.0몠b+OZfpG)WZȷrILu}j9(rU!罏6y5aD@ċ$F^k ]Z$D%zԀj=;_3.v;~]zrcy_?=m('NbX2b?d #BĒ#_5|lx|b̕B67 mJY W&qV6cZ1vW'TN)L޿rct<'y TÙtcݒѕHdWat~}jKyV8-~3BĜ9 F]/i* $Y@Jy[污R+"8U&ifnԑ3llPUaOnNsbLH-3 Ve v P@ħ $F]k Dxz⵸eէ~X#kȬ@/Medsf>{-kxds/+' [*;n@SK3_>^}BĭrV8^5t2 &l'(3&YM rINzA#I+,s*|,&  H _EMc_ZFtxt@Ļ(4D(*=:Wu||DvwI5(1dO?:mr;*Իweh6}`pBbn<~ZʎIG͠IͽV㘱v֓8CifMwc/s/>g^’/iC=ʑ@b4^m>a" Ho̫2NdL8f#m*gխX,' gwސ"wփ"}w-H9MӧjCHQJCz%ʫWOiB(TF8f#10ZSbM7/BEia`k722bulVIn8b?koAR9 UIiE< X P@j4^F=a !6n?OKj@2n *N(CUЋ%-άܭOUT{LH'@ق0؆+&Cf~43RNs1Q^$SFILʔRB2 TF}b.LKtR &V+^~t_e4Qﲰ: bD3G/yŔL.`)N cOls#h[ȓӐGu@r RF5”yYܜ#ԪX X)~gsLLi9>=+o!{ZLNtzMs̟y ٦v[ %B 2Fe7!0AX^y|+Fn1h=}*Wo!e)8N#ϥdU;f=5=%3!};(cg_hȑkl@b(؆b7y;Guq5RB9 0rw~5t^vn}l,buTWnU[Ohjy.Gz_<hn|IB"2F՜2wk;lZ_k#2-}%Fs+*뚺H1Pc∣ Ynaf3d^ }T1W]!ťJ@(|FMR*:K:d[%ߡ8 囵.AtSy@Diّ7`}ُ'셮"(탼8mGouB 2'RIIkZ^_l󎡿o[Aj iS/jԬf 8xF֋ !́S]noOX@v TF)'O*s2●#6fE[8zy1b_S~M223jj"DGS?#i݁Gm.1Պ %ͫSB(FBK*zf]yf2<fKpr趚hpM* o}[ I܈ ƾ00t*&n29f7.'D''_)ñ1i+E;@ZPFsw| [y߾J; -pbNtk n7ZW丱'//S+ aQs&܌tmBP(TF<˼tΔ+Lff3L?32E ${?~7$5mk!I Y'3; D8K@B.JF6!:eFޱC_>o2pgR)ا3=J){ݻ6(2o4N!opfe|/5 8vBB0F=PYlҲ#24“OcvԮdT2&eLX ݶ` w@kX鄎%np*EEa @:n Rr*" t*&cc-xgi1#rnvPb>DkqKh]5V@ְ!e,؄&IsfB"(+"ػ@B^MS.|h^rZ (kjS.\ *f`҂PJ(d[ܕO59@ߜ`jݬ yv{ ]7Gs*HtXwB+F*=/ zsN$#D&dR1f@t7*7%G98F&˹tgSB)>Dy^wSG+i).w@5FҺ"{Y/-B9l>9BU-+,89ȯ۵^ti-JL!Ǩ!1zcuO`)sEV5*C"B* yHhW?,aMS ,˺ooe5ĉqU<=[TДZq֣т)F±%sX([ i2Đ@1*"d_̯*kgHҨv{م*n~}~aWi+SDVA l-Ϣw.Ȣ[B1(؆jF{gtgwmoX /jݓ~t m IZ;l~2=w= Xn^pgzEX 3 Tc@2 &TFM?$| `-#vT2`%e-7c'սT8V GU9c@J(9jSrR!4s|4;o)kKGYT=j]4s/LKf^oV95/[bIŔvZ8'HmT8তBѢ*FQ૲>'fҭtXN4Nwa?:w߾*+H?Wg*uA-7%] 2Bɏ7T6h|E-{P}6ا)g@{R(\FeˋI"iߩ=Ƽ6ΑOx$2+ LZH(m+׀ׄAm\A]˜O0B  RF14~#*,;Lb|]lɋ%Y> GKJHaVC$6f(k\I+шC&}~RȭLLDM @*؆H䰍XJynpB|?HV`OmI0Ŀo<[G_(WцDEc*jV] A(. p5r*cWwIٞHBbr^hF |핆Ey^vsQq/8DbnDo*Iw ÛmÉP2&<˔Țw5'@>(ٓ|*''maǥ*+EPn(\I}Kw} rZN"NdP2>>KM,MP*zBʚR#[p_}_;´*2pWs iyOf\sy "t(sȲ3TBeCC6V$jjl譟+6ZG@&N^FF5֬Xw~Io[c|;3_"\eOs.Uŝ V箺GPp kp24JB5Fyׅ]*qSSҜ"})kɜ mB"OƧCS߿j vm`#+#+b) g̦^@:(F!sobP߬mT:%Ns LsW5AIeԡ:@G?zxY 3FBZ*F%NgP/ZCg#$H-% ꅐrzfq<4֡Vsb)T!UV@:b oDYZK.Tmfoz{;18F5 +s& LYD3^F0! Cθ"C}<ҙp*BGz7l7ҐJ &7e`.nPezA#D"jf E2XJXYhSB)F)"c,fy &$$+LIU3Csau7U$1DqF3/ݧWn,|<<ʲT&@(Fr"SN3z;2P~kƳ mG;H!j ׬YHRb![Bf9  :wwBBR؆Aq<++ 7սa~E<ժnK5ݪ>m6EDR>gbrʑ@2* 2Fs\+z]3+L2Wje8C'7|獢%TyO -e&ăU_Ϭf0ظ+vBjHm FsP~_aO߫eߥ~u:MUGvlLUGYJB)0Qݘc@peUPn,R@k6r[5Ɇ~-Fm!v>`[s$myت:!>|־P9E 5Ϯ8 BQ2,FHFdI~pb Uhz!$-u \߶ǀ&nmWTA[gVfhqUյCtNQv@jHЬTMVLY5Jۣlo1o>Lz7(pɦS :9ae]Jj÷E̻h7/A(B)PBR]%ƚG܊gGl_ URs]U`)Yl̓dT7E8]O"1I3IO2lm @q~Ei.~?8RR"S,KBy*ZH]{EfNfGqcL'*d[{V 1=EJJ!,\GU瑱B# Ҫ ӂ#w@H2(GFh6辒׹{)5Vv S +/YtqM`O#rIbSWJR(:ezwGoM?fWBBRF%̙M.%VmߍWt vV'.t7U t#u3B! \!rf.0u zi}8+%$kA@ RCxVsn˿A+Y:hbF%@ Vh%I{Sk[4G0"il3ۨuzy&&kR$wBI*Fe"W޼JHUWW z4,“ }$^*ZP`T_yIsCNnuuRƴ_'<[Q{iMS,@Ң F d"|L's0jw+1rz+/>+t5NW$dQ$RCEfդ9ʏ)|ʔ/dk\̫B0F ^!RV!">B/|kn3]~<~v-WJ4(b=<Ȯ1 K]hG4Ju.S2h~͟@ TFΩ܉<)HCu'"ϱ??_Вg[ύ$m mBhߐ8D]5ʘ5w? jYjT:J~BF [ҳOP?ӡj\oLۿO+y=]] \; L6?T?L#'RU=q3S Ws̡†FDdKdYS@32 RF]M#㔆|L{e|&كYmnּB!>i&XcU*+HENt.yRI v(V:/w뿗ߞoc\W$ 2E/I3bYLh$ 50( P 淪sۉ^uB (TFE^e_Ӱ-l/D1w3p鷬2A 9.} EӂWv:l&y2*2ȥPCdkYsR]3ȿD@F^?8R.Ĥw; F9xAN)`ʏR,R mu[n #]\x.s]å dtB-F'OB5Ks~QlmhUa!/`s]j+Tpgƈ9#PAPY ZVͲ@ZZ*!ereRлQ.Άzyj*PnU ]))wJK&h' vSGn@bGn97+aeB=Fߊg_nӐåֆ\$)fdX?bnt9e}6VeP5sDZD"Cbh]0j(yc[6x@+V,TD2^*(zkl<%w%c?5I7WռBa١V[x[$1,`B*,OcyJ.B*$T(kﵟL?Y_>ZhWF G,NģJH6*2Lp^t:xklSy"ojUnc.ԯj1 JBLM }9S"1YK*RrZiY|_"B!(D}F:{PJGb>܈J(*!̕znf@j_i_,r8B1t@Eveȅ.ȵ<}ӥIT3@@0ن V!Tis2RYNb Xָч;f &x]T,ΥI FҹբԠĥyٙ;?V,BsRFrRVo|d-96g;WՌ{bЖ cÄݨ#x%Y+n6}=+BzY h8sNFLe_B_j1{~AK` b4ppC,ёC)\͗$qЄC0F_y­@(F XELU,$ډDvZ}u{s4Qڃ'O* S!딞y,+&~1 :^KwKؓFBp^(BK!v,jON|3\ֿq#T_QhFy=6Jx0'G-JC j+y8_awb@ 2FvuL)UL=R7f۰ȍHu;aLRe=Gg5.3O"5dh _1Tg dM.P-A!B"(TF-C_q:Cy  *[ԍRhO1عGi,B3 P 8@1Eq4Yj6 '4 *)ʺϸBqQ ԷTcWIÕ)G(S+R4'2Y ,*: +4VSEYFd &7$@BnLMNϯ}6 _jYHIՇE:>ec~ dg仚s9%~'%g2/c@*z(2*NfS :1[U O8b+K:b5|`FIl洿U`ޛk!wl319&"`8aLⓒBɌZU6*dBdf;+2VWZوh<*[8:v}%(K_ouKcҼ/]oy5kfy6kaI)Ok_=@RF LõR/Ă8:Kb[ol(Vik84ɸWd#Š,!TiBeиo8mB+J,TDRUrNK} gЗ#G9yܙM;|&hQK K3@S2;T.-eY,7T.rUlJTM#/QS@A2:JiLR]Mn 7LY:9EtDt9mv͔G0 S)aЛ<֖3~dV@esdeT7DBk(o`~{%?zu , GkinprQ-,+b+QN7-:?sRew*f&ټFkUqY,:@)N^'_Χ I($B)y Z셕(g/W?踮#.o_e=;^~V(7{Msj KJ@avTbЬۄH03) ڿc`Dq#@FFzCzf9+; 7 7xɏ/gk6v4`G@(땕2Ep,JHPvi¯&812>Ba:H؆Y厲ZtHLQob"cFCS#t~U;nm4Wa21V"_i=v}yJb}9 @ T؆ q%˄W(V.Ui4'KiXO u&G#l%>Xϩ> "d䯉<;LIS(ǓtBHPF5UY 58drZrsnugCG㑁 dKަ[VP14Q3U G$lbR\%_|:@(=Zv)NS ^M_3r; U|?6"%g0߂&T1h 2Q Y_(S7ۿ?cyBB ن~HfkK%>8{lnS*m*]k^њG7~^6:Ρ6zunz8#}Y 2=d cSgU5yM\6 ? rdDnCy[^4*DR%(@Y(RUQLAA,gke̵qR Hh9Lr=fQsslOATHe!٦^B0"*F>}tm<=iS4&{S-`ɵ-&ImoPI^1+ :jipmԔUBnd)@b2* 2>o5.W4%#̳$HcP{'GtUN|9_LI2yL)ԴM#ιBPCޘsbJlRaw}~V;lBWf)^JMJg2֣2 ۹0f ?) pG@*vF^A6;@)T~>=YOc߽WUG)zn@3*UPK߈^sjS,܌⯿{[uLB,F]XnO+Q)ssJB:I )sIdDO72*cA/#٫\87 MkK2SG#+z|M!꾋!sS@ 0؆s/IAʥ0L+C~*Clܷe!1 8>&sA_so@pm4طbBt,Rqmr,3 [WBC&*FR&u5DLLo 'GC8Q[_+vNJȖxQt`hCFhI-ɉIQdqS@؆dcCvCSO r8 "KH4Vu tRjݞ}z!5Z;7ALKTvC2"w )Bf(؆z-LLv+4|#ȹjP߱]lei? A?K4E Z0_Te u#LW'(ɪݽA]M!@i REYw;rkR:"1[UɛGh2ftj-1IItmttt Lz͉ѕ2y.B*(=:ិpѵ0R?ÿym$zK}9ϟ;\uSP:_-}#ivMQ C].U`]7p+m@:,F*sl Z'a\Sd!v_-\Il# 4,_;tLH@vP\Afk3ܫ]B)B(f)TBT`d-vFqrQu.ϊ#x9D_7wVi@-z,%w*UY0:ru. @!01l˷Wܢ NR^Rb䏊 NJq!5o3RV /Al.2E)IMkEIb}{5>&HnqBѺR~Rmls>S=N.ryi;:Wkو*9%l>;h.mWs7ОBi)3<=idb@j2$TF {w#ACc9εukKﷹL<) V;N'5W2BY["x2~!L$jS?{s> CBV(;?w2|{ٙ$))'˰ΣZ[>U9!@e ^g 2aAS:) 4iuڊ"̽`پ>_iV@J"=FϚ\=eM omݛ+s1k20!'Yb2ª$aC䣹ivNVP>o@ROSQyu"*Y!~ iBL앲mr_-Įfd=qo\u[A6lafeEYrNi%N&wB * RFD_"zf҃W\lg9ȍZw2sm=m%j~u128B3z S(1φFK\_ݬ{9}Y9@As&S#+S<4դ=sboœg݊->mMY!kΪS&̝oEf `Ve{[K8s?B ̻Sɤ^w=$Lb۹bG:PFBt.JEc`ε\ѧՆZ#U,nf@k(YWi[!5TVʟ@v!չNΰvG2jҝ2JE &h~vdqSaRB (TI8̄dJb!O-8Z2qɲ}/9gPoB"^$o\>jevܴImq:wO֕WIx Kr$Kr:]Zy2\@Rʙ^.OOBb/=5K5&3bB .-3:6` ߝyK!{H"̍ 9SeꗺOBJ Rp[//`nC0 @VJT΂׺b™[ p+{m8GuB'E͋e6[A`,@Z*%QiU9(>&JHX=%7Ɩ ݳcZ3_?5ZyLt͏(FlqU꜒ 8fYu jZABN^FrI%/lf$ᴦ~\8AaT*d`"T*>yK\AK 9QQ&nTȳsG{YfTOW7D@:J1K xF[ZR}AI@q 1-Oo 7{+ueI4ybc{dlvn @{W e7UB z2؆,#0:A8%3SؕmTQmGF}ճe^ܺL4=lvB)>C)"@:~JKgFC r+q5}V+Zq#/ۖWXr&S^h@sY*' Ts΄vDiFi X yB 2F]ˋ*TO$0BÂbUV\]~ uYa.y-O,*WJV dN$|SKa@F^N3iÄItx2-!_26p=hcLDBթhcTc=74҅&#KĒiBw/$B@*cS@/jr[o}ߪt?+-H8YTw,!uK3r`ؘ"L3&f-iU%@2F K "}35kdv'/ OZiQLZx+*; Tl*R%ӑapGr4Jw-aNzѭB9j,F}sdMө}>8mu0E:~}U,Na#6h&Cgp;ffG |/)U>7C#@K(F\Hd,&Fn!BK,3>S]2lvPdR=X jF:!#܌3UB (F1S&NJT++W6K9HP~^53OUo@Ęd(Tt ytCɱtAQ-_xǸJ@"0;~{BgGH~+~U@2GjG+ ߺ!4nґf #Ws6X`n5"2ӯ0CvSFB*PlC(3zx*ʎխjfFf%2 |0Mʨ>)S57LP(Ap=VLheMO6hg/a䉘;@(Fgӳ_~bۺ6x=h2}>l.FLxIU6lW(jLuAu܋HTSXGc~F)oB2 fKE/w.%C9Z_Fs95W#wjP5$i|Ļ3ЪeiL~Q6"ؼ8iiNb@ *1{ǜ =E|NEU,W,ҀȼR-"Kޓ=X_fY~[>ɩI3#OB-Fy2{C|?9FVn0}YwfaK2`8K]PvFf3oDZbQb@$TFf-Cdc_T.ݰX4aE~Tmms{O=vk>Hg{"0]S\MB20FNVM;Qj>\ _L"s)]+Ui}Z܌Nuؓ5";DHB].%MU9Fdy9s*@,؆jOG8J, aF>ԱDy7  zq]*}K•]#_: ̟]"N:NqF^p2:ztBRx~P5q}c隒~8rd]i$GU'{ӌKes*Wͅ,Z&s<`4hp2NN-݌e:@PWOcѺ; +ud}KK˞`ϓHDI; y Q'eAd!ig8eS+xGroJJ^By 2F2&7w/.U<BRD{=0d|Ȍb`|^0C\xv-SnRkWIXyWxfUBlL*=cwQ~%@^0F}pd~Vq 'iMkpWO%;ܹ:dq#l\VpV2HSm)YD†N$NR"B:Jd緭0TSpTaHKcn+Ez#l{@:M2"/cnXYLN\.esѬce ZV^l@ 0~--/j-23߾w{: #![S]8IpAl⌊/¦p0S=Wr# YB21G. !*1l+j%ǿr3H&Bn4,_ MԏKBQZyDu~cϯ6'sW:]6f墛) hWt+u¸뮯 Qrzd0rf2wFj zwB F Ej-v3#հ! eY w/FFLyg`lNN Ɯ>Ƿ^ Gh|I5*'bDR [y跄bƀˊY=>o@$TFj*;V:~*s= GK?9<+ڵ:bn6šA=jΎ66`*XqJ BJk7vK=~BB 2. i)/DE] aH88܍\d2&#)&NRl<'7S| *\B(CԊ ՊAwوg꩞WRΫ%%[?P~uM_Jnev13DeHL<%æ CdRm->@jH؆ ]_6/zXG7&%Y? ":aLxGeeԵYG}T]2ϡ_&XM6⹛ZBڞTFFiٖmi͔'3jpg*A_ٿ㶴hR~On^Х(w6}5)nu4)Vo$-hϴLt\ Fp*@Iz0tFo!Z7ӤVp>7 l&9[zKU\nܚ_#fs޲8ՍX-v+@!\QBr~RFVFuì"grM=9uiAJz=}k_#Mm'at DD%!3g yVYB#8b!"@j RFȖ,/-̌ )7F'RK9go'~+9@Ψ+`H*}Af+7}5=EH5ĿB1RFbLfofW֧ ޿~ScV{rG=عҨ6H@RhZ?%lԔ &y)G̺8œ-yYk!*S| vMh&S.]Ug XO)JB;J( _ؐ0NOJk|)׉mf&_l,89[˃lTU[*uAr|൹=6]V2h^%(@J(2|1Uk I_sj+ڢ ,u+nP;GٟWVmլ&rR)!dn<9C*diZIBF]ZS/[P806Zl['RQkcB[! jFD#7&#n >J_kfu[ +ҹ}&TQX;4p1ؚ 4<ɐӧ_;B"$#N>gE!'d3f-(LS+.Voiê j  ?)d)L[i5D\ S)@$TF5S9̽1^dӱtcgjr\>UnFyD@*%8VKlyB<:zR[yB2 RF S{R2D!K-) '<.?CN2rvQ"}]L$2 `0Cm/gmz呸"% PJSE1h\@(TF0NxCHqn.ƀ.F3ۣ85M 5RxFHefLgM)MԑBC RF8DeBC*3̭_N(h;4ҋB 0݈xٗrS,3vA43jv&g Yݘτ@F2t7c6pmivb X`QiJ;eFc *JjOWW/ZWhi/L &!B Vb|lў7>z=;\cno7PTKəX΂uH^Tlo;t#5C`)ˀ0gS"@ RF(Q>N.oLHdSn"޻ F?qU6VU1RRF@oDξȁjxֺw!Yt %?s];3BR *F9ʒYBW"} ܤ$2U,^Gdlsf3kA[_EvE׃>y ZL܊v:z@(z S0g9mUX{jsɯ;sOf]:$53Ȉȹ~^>ZI&{Љ&/-js=&Ss.ZBR0F se*UDmqRFq h .')NLVnkk%Z/zɂ>pBZJ]Wt?۩kkI;9{ȼ"^*&HRskxOjءxȤTĖKU,6K@Qrk'%`윈g21@,Fm~>y Ry N,C+ =BuV"lVv-:: dr̆B ɢ]߇LF"ұ}#2=FӟMBz.$TFfgҧ2&.Tqzi_Wyˣ-= e>&UHTb#Ä=iCa f?juOۙS<-,@"v*#*]ҟÝJf̷%T+JP%Էíj$r?k0݌G2o2d.1o!@jN"O[WrkdhPD=* xƃ\]L|65L+f[i V"g 8J qϲqeB*˼9S_Omfz­_TjL˷&wck% i0PU8oNjoa7XLU RG I'6u=@N1F\аgo7jwMϰ\Hõ:r2,#klsS6D#S|p+N-@1FsFJۊEĝL 4O>t2MGxƞd-)X#s,cnҍO"=9SB0(F;gJpri˶T 9H_8R VFbn3_:O6|ٟq2emlϼH%l۸~uꩀQ (lѬkd_V νgRPB(J^Dc:p;Y},cZ/v8Tgv!,L9nkm7HG~TSVuxwYGߟ=@&PțΗ$Demvm_t3\Ud/M̹*70S݃JO)nhTRjDs6%,ȮIC(4kYu!n Bx(Fsx١=oCLldj4?@.=r2nw]u3^!`M:_L^2BajH؆Mv.v=2ʦ]pŅ3 p6j?jrj+aֻ;gXhDf#0H1e()z6y;kROU̟@J؆ȗiz>Da//ߜ{UۧL_vj #[7 Ӫj~&?NZn)2KLg$u܈JF[;B&*؆tYuLk!%z[yygFIxӤ 'KGruZP+u" [LiYV;̑ܦ[44Yr@ ,TF۾pWKN>!\G8]˽ UjYY.Nr겢>&dJlf|ai~P?QCKr/b؏tB2 2؈#KK*(Vq܁s|k1([mrvOm~O^}ۤ*Ƕ%ʻUO)&D_ K1$MYJR@(TF]S]w;W=>˜ߺqI<'Oa(U\5 Vꚝ[dK͉բ._o֙hCIdgBR؆%8،]LfEzG86q%^v !&dSbp<-ط8oiqkAJ,-8oNJH>1@H؆ -֞'tW`B6F9\%盝;")j.Vڊ}\_utYqhU9v V]ߜB(=קbO#oa:-&{+B)4M/T4 :l4S[2mזW(Jם*S6R2}c@iv R؆shHNM{n{z*RnJ&Nztj Nlo^Cy06 9c*rΖ)]HghyB:iR23vSR.r]#c*铱ԡ jxW(K6c0PiMىYeIzH/~fG7D.@Z2F%y xl66_LH<27a;Gj 5T~\ 6Edd~yD0.O1GNjCFBSF(5d/=Kx #.1Q.hd]Y?2k']^:uI[&c Ѳ vM)F~@L(Q uQ@)*FklUPA7~͞Gf黚/ I3`cWֽ}:okms?Gr]BG e|Ր SC( MR5Bb(F ]1z~x>;O"Dڛ$)<-9m̐w^E }YLIx0EjfDt/qrLv@ F̔]G9RX4/7۟o/]q@LuJKvo` XHQd,>9x*')C;.̢UB"A1b 9藂˹XgbX/r#alg[94߽%:BS+Mhn Ң+EYU,'>;1@Y,F\dkK(GCF[>,KO;ϣ>*dkH*+f#k plnP=%BJ^UqlO,Zqs?MdtmNjw5[[,d'n:@P1 y|4'UeԾq|@0i5r:r0/+=*.Ԏm^r"a^fвIs^VRO O!SoDݑGʶtHӆP@6HBp5Fi~|>RЦ۝,Id!X$ $'53حwU:vQUPty|56.Fz~X 9k^PD@$TF%;2=BRr#2Sʛ׏G37u?.AE5* I$'G[SHpY"iϷ%B *F8#vhls'dF/$obG^9dRCߍ>Tᭆلr?cq/R.Y.v wtt:.[RfPr7@ 2~Zg_]/,l0MG%\ wGK}w?VUX3VM>o|D2I2*:МBuׄ,boPΗ,ʑBYG>UnD:]QxySr4k L7QHbPZPF90Y"+do\9i<#@BR *F]wR~_r^g4 R4*5l;nqO^rΕZ[/2>ȫa ;-8lCIK>@J TFW,hF߹y)TL#| \+##="*y[<8urTc2:\; En1Rfp!ɥ/(@HpBbj$TFkVބf58՚6[4^ u7$ؒbHC_N10-ZkM򴡝ϰ!@S:H э\ԪJޏ0<{3DjiZnLw?đ]!iJI{+_o:ɕBv2؆ 3OVjSgwyMMqQl֪Wibaj{ \ ueQʤ'nk { !1gJ-9 7#|9@"F (_jk]翼ƵgVcVIj|G[8v0E%hN:֨K hT V=c=䰓u Λ+ea+5}]NyCS-O<$& H-Vn5=o}+c)Lg-oyE23%)BN ۡLN6{zy.YVb܇+4R8ZhB0P <џ1iWS>xY戙[c'ҝ@.o3Lĩ(N4 &@T63Z{8mW<5Qt;^ܱ$&[CG|>Bz(T%raWa.%eqkr?^uc.%\j62_#&azeQJ蒼Us7W/t7dӝݜ:͝@: lf8e;QprPF}m3d'1xV <ٓvJP"UX7` NPlmD݄ӑno sbykckBy2J؆kR_:୛w!3R}xw@ͣzH$(J"蟰#RD&pE"fCU d\Xp*q @RFW xU^l;;ʝ W'.H '˹pߨIoru DL-_&tLOҬ~ySBN^F(D5aEA$&qYWYSAV߷bR~X=>Ov9h12Դ3B,F1UXii,<:WHgM~I"麟=cc`j4H`h쁻ѣh9<>ZDA/'ɘ*B zz#@9HF-yurdDt@[6c6fF 1I4wܺryE)ܝu_NYJɢILrS"tod[H3SQe6EBZ :݉C:Jmy\'[7̲g[KY@fF$i8J^ߺ 0RhgF܊@.(C?iZn}?}M4m i孮cg_tk#d\a5 |Y tPpIƮ?Jc9B. RF G)NS_QO?n~< .AVeO w Z5zTM떗̗}2ۨO/ɏdΙ@6 TF~ )HoNJ.7z؉Ì@P 6Z]Ju/`I4HAqE8JE>ft,Չ#RtsʱcniYBJ؆~L-S<3:Dd)R: ZڐHtsX\բ]m~zD-H ~rvs52*ԟ{V2.L9LO @*"FͦrBfr$~dA$[DBZ>5)mR]5mm A_0#~!*R;Mir,\*B^U!r3;`wBIʓt,PٔdScFe \sm߄-O+@(TF~+ LFQ#Z~Lku9b?V58:@NM9 G)/zcM QGqL˒Q"GpRgP B&x~PpPfz_]b74osR*sYsʮM (dVIJBgyxlԚY FP ~"*hyi Nf_@: sSF*&EԴl9oV{ס]hڏ<Isa0=f01o[6tU T7C&L![/%B"2FW?VԬ!?MibdWL*f+mPcZэ %} 9KibBHHo @1N^F%ԥ#BrwI9gn9pǒ#cUoaL ? ,s3U}t-TgB>F%lPdDzt@n,F0q7[VҔLj L=3S?G?aTTӁ&0e1!Fh)iWZeǠVB(Ffڦ9ݧpٕqIJ Z;Ls۝Pv{Ę`JLd+aFT&VN̊.&0烦8~@9(F 9VŵfH]wPZJF<"QnB?<^1%7a~ 1तK֫( BɊe犐|B" &șX:FgBq*k:ッFK׬̺NkSwm]bs:PX^eZZϋ7a9$Sj$OZdHY*lI\&@N TFU@S-KJ7ϫ$:G9_k.5e-(P jEÑnG4D hZn:"E|gBC"$2D@SBIH;i2[i,hk}L+oR,`b6` _!Z}Ԭg0# u+~5uסQ@RY:g^R"Q[P 0`z#̺\2py G$֞nZDi%v>B8ߤ>)?bRR>ksB(syI)6 ~Kބl%R[G/3ӄdKm5n'nd/vUɦOdcslMFLBaVNVc)@*~^8 7KZ_t+,}_UIk[C %uT~mԧLt#%{u4܈Y6щRԬShTB"R؆(f|؏u|9e+TgZus G}-^b%(&0Trޙ7jW)&d:v9ߦ@CADyl){J;.%Ү{H_LIg5"ՇٳBv촔7ݵ mE@ՑMKFM"NG)'B")F$SWAEs9p潬 er7*O7~n2`{Xb&ّWC#ݎg[ 4b)Ӷ6lי@R$TFs6I"W"2.S5ä7VE(ĺĹ ٻ+vM9 1XYc "Jeb ;<^@(bdˑ֪CLf1@^PF? 7#I)s.Ovnvs=TrJG ^ЄJ"c8(y^F/xox^HBz^8d!0{=]< Ve2*'K}k\¼ DE^dI&bS1$ b#\խ,=җmfc@BRT*Zw(P8Jwxnh#cN0.Gm!HAM7jěQ͔mW#SJEcBb(؆TʟNd<U1[/P( w>E҃U~ wا]~Rɋ/WՓѮiG(9fHdN;קG.@*2FfhjQqp ~XvV(ul`} +wRUzrn yx19!tc\jQJ+s+KD7UijBiP؆ñ*Qq&OHFLݕ0eWaoa`!_ve]u١Ei B/#7 NS-͸Yᘢ@x(؆r@i䆴aX{31~٣L<aj. UUG)f޻JF6&K}TsR˥=_!NCB2&&AX745;4!(rF >N!fJ62\,@/%36O@2ɚ *8wVTF-mBɓU#QYTɩnGJW2 Dć89z1(MЃN_W4ԛqoB92*F5ֳw{y@~]k%M VQ|^Fff%r0M8 S8Vе2ЩrUy5!1nƅ r@b(TF))]NqAƨwn@Zk˪>Gk _S{,6<е]K.bJg=##$B (DSɎ.3xv9 Zz32Ql5na SgBLp^Pfh?iIY}qW3L0H}ʇR@>R؆Q VȘm<ٮ3>H G|Z|}B[nm`Li^Ck[ŴkMSBr* ?sG^ Gōw;f3·v%E*<݄DGTL? wC4'3(8*̗#2 EHf@ +,]fwK[_sG!q0l ڛn+"/U꟠ix""8,2U*D*s\o|_Bbz^P}c3f:\(Mӻy+Y/\8yhcߗYs-J%j[#ڍz ʐZ4.%C]DNF@?F [B&L\}@BJHDJ06v5wڹBZ|FBȎD{@k)!jK!I񭛖sՋ@R^ [g9U/B"ȹLdB.$TFgH1|3<{1Vi./;=wM_ cI}͋dJFRsw>93_!q<*j]!2(e8V@KZ*؆yދc^i*"d$})-vaUl=8IIl s#RNdǒ!Y8V J1GѭrB,s!A+OαgF=Ƴ2`Weo *D< ¦cj;uSȈ45Ym6u<@Z,tIN9`XP8X&.Z*Zf !f11,3v%"Ʈ;v|+ 23W&AoIӏ|BbJFݢӞd_EپGz$ V5e 1Ic%t[36`NNZcd%3@y2FPJRZ "N2rdt25ߊZ>PU%zJJ*Ey59;C9Z_B Ǘ&'i[@Ǭ̡K#H؍{&d<̎9(4>!ͩr]%\QR-aU  $$,Oo73^M<ԞDs"qc_vR EYB 2FCB֦K"3xH_oSH㷢5*X< \ֱ6Z6m`k"4Z6(YcUKwTkZ_@Jo˿D?$lI[jMRu')6J5wz]Yfd9}G56fDFlhK 0]*yfB*\>)66Cm1pͤUIH}>C-o &2L*(G+zwܜ!7P{HTs,FSf8- v@z^PF|x_TonMӯ=S m'Uig4.cy]y ϼh:9pC)}ݷMMVk[BJ~RF)Y-$K'leS{s6Tt篻(*U^R ZhzXq8Ew5i0wS! DDoӶ59@90F)~23xf@ygۮ}TN<2, L%\Bq‹PB'q8e"C$7r,HdHqDBI(J\oc]fyuGHWht(-} (-7uoe7Nt !j&`KLUW~),Rc)@RF"ջT ,N2}RǞ1&[M\}>px  6` ɩT8f"11pvNTm.'/5lhhBc&Vep[QQ{XP)Yݟ} B2nth(g5VNLYdL}Yo7Ĕӓe%dw+Y@,TF\,jO/.yCY;IrI*Ѵ0\* t4X6ͮOB@GN }.rT:ͳB+bJ^@>["WZDz-n⿳}i{)/,CtqHhG1 4)es١-vhżPB @$RF0]2NoV2S6/B5e2Wz-{ӽq6'/,HL>JҍF Ptҡ=XB*F1pv}vFȎ5,~G}9;n/wK8$:9`r5(Pu @>O{cY+J(Hrdo @jRF ؤ Dc.=M ȼƳ K '\GAN5Quw]К2\(*Rm OBhC5QBh,F=L$JB'dNHF]nUa8]R_-$4sSS؎7+Ϸ񲫹\teUt@z(NMbJf& {o!gMT?Y%$~-/8 ,+6t. >Q@`fJ^E#ezȳs9ۿ-kS~^HC%`O*t'Z\hkzd鶼!" n'3Kk%BRF1YaYFɦ7{y!voZ8ejg5b"\dlM)T8?oc ع"3j~Vl+@((L٩H e7'HN #̟3yꎗ eU`ty5o{B tKˁdg5Ԑ ) 32!dͷZumuBR*ن{%Ҽzu2Ľԏeg7w[aW *gu,s2htlI<5626b++jޟ|׻j[ d_@cN~FB-܅oYHYÞ?#zdGɖT:f٩HS7E*, .$ER3&cy1Ƿ>ߔOD5Br(F81K˟1ʖES5wܵkMWn=LPoF)YXQ f`2(ƕ@: Fx{3L-9Zw^sZWp0gBS ı;?~,)U êG7!AB*F=# kEŞ]cxIbS/;.SimIh(=̬' Br;澤D@(tFj{p8wξyxfiؕ#^f=Xy9rjڝ mpVظ|C9 _Î0lyp[xjB*RF ٭As9`_;78ʺ?lƳ,*~2؀*ŌeVYIԼr~#]MVqZdW ۵t>$/@R{5Zd}eaVoṴ=bm"yL}~"=ӪLu`͛E)8١^)DKKr.1FZBv(؆t)ܦpEY^Y/٩"hsFU),2 xno#zkwv"'K?:@b (JΗ5׭ Yu{lNҹ/j:cM(}l+cBVtF~` nѳ6UqǮM JlF|g< F@ *IslԆuTRr{Ubڥ&eyv<,@T[!E.` >߄Jh*7}(bvSE$BJ؆&pq]G}m'ujOJB= ,FTb5&C-ث(|7z{$@^J^AsHuCP,wSJ751RGR"z_Y{CW*ZPi~'=J+2[Wnr8MmOr,dB:~^8FyҖwdillb, ^AEb'Z4<:F*DfVu)Czj]yv!;)%p>2ӥ1@2F+9+]eoυ$Ӄx^B$TF?''^ &HHKoovQRa4Be ꌹ)PfzzdDFcڥ6MrdDA.@z-F>J*KO9+6ԎndLԤ(to䥒 76dHԱRT C1m}kQHȫ8B2*F49IwЬ5CR~ҿB!Ieiш>_Ѷ` onSbdd 2ӻYvg|g@2Fa|9KZdIB-FEђn{LNWH\۵1*7I#Kxf 6` ݺg#e!2 r#"ݼ+;iS?@p~^8c †~dHҜm-T^8NVZ];P9qrRT/y׮Ob$9Ze2fl확ΔSRqNB{*؆%}}iꓐV_@Otwq&{C$yǹjU ᕪڎcھ |ȞnYa-Ύ*ď Sh*@߇3@~^8SDn o-&2.>J\mӳ_&pjPG5+2HDY(S(E14ts'kB2/@hD7{uq] _>ۻ?n_m,%tB}[)cCSvXJVssgrd*ꌤ@(Xu3#fQZfDw$ЊSXM$%~!! S*EYP))ihE"*߇iI:ϓ7N}Ɍ?oBv(TFJaczl^y>}}$'UY*r2 8 X ֈh<3%o+lfĥs==@Jن 럒R ؏dX̦FLX0ey(q~?(mMs _2{HR:e3,|:^ac?3 ۲6BOmlp ROMX93=92E ;HZ3lel-kb̸V1m#,^@*.RFf]|!?s+&VD)h]6m`]o,Ľj`pISUu%Y ˗L(o 9>gfJjJ#lOدetRT$QB(dhD_yF3#G(P˞\;yR*Un,#Ӊ RGR/gU 䏶d @rn@RHFE>tɔϿ} ׅ8.>D0܎XKYٙ1" 7il6#x^N (M(KkcTD!9B>('7ez_͙~\Fgndes/;@#<~tN@)(ueת:?s4X ( $sYi1L*}͉t@~(AI"Ocg̼RxI8<]k98Vs\Zg_(GB:$TF2gϿ6Dim3n-X~K<@? ™J] 0m1{R2sxn{5$3cby3̉*&@k"(o/~co`lAAZ\C*:  $Xta!'eP;\6Cf)ok@j,F=ٌR7JJfTNR&s˞E vK^*IB]VꄴpF}3Vsiət/M:i!Bq2FS4.4&mC<H%K+a9ͨTfcDV,GZmC}d=L Y43ou><@"WZg}:*Z?d*w57f H90c`BͳT]籶̢כBlDuT"p\pB2yiC{F& u5$8e_"#?&D)\⦳Q8_D|]ӵӊS ,4k`wo@$RFh ?ZXָx*#5PΖB"*% (ҩN$d|+gO'/dKn3!06ww~U}=e"k55)|U2h=v@ {OeMZdlwDZ77yO5 l5?4XUO$LXYI%C*jB *(ɴl7T̝_&{X~ ’Rj66W,ҫ!?4zEU1yOF*zٱGĚ@!,FM:lTĆG kkKjHZGuшAHe=#G)OvtkakYI-^3:QigB.(ojǭxyO#%MyGGsWyu$O3 2Ƈ cZyGLU;r-JI߂h+"$|@  F.n~4СzGBXٖjSEae eƲ#GC+ BBBS"3b *#UBF0ن&໗ T4z,E֒L%CvN!/ڛ+Ѷ`4"!5)K0^< VOˡ_@*؆nC%utB3-^?^ǐ~SF,QhJ'ЯbljкgRe~nvsT|zG W/myGB~R؆ :?M ?V[oq}BVb23pKGI*{H[# aΖQHUf:Nw2=M@ z^PcL},I ^\KK2Vyߞ7]bgfoI:Zy.!4"2WE q~]q9ߑҦTB) sJfM朑S˚~H;vltӳz{̩5;XZ'gaٛ=,B,pJR^KŌqOZY@R%FL_L>tfj/ڱJy,:<)V_R\نJ @V-nVO% X7ͩG]01;e5MLNB) RFzaEi\Bλ=_(IGvAc ,)k u=(X/$PrkhЌT@N$TFb~{>f]`1uJ۫^mL@?ߣ@J!y ,gԂcA'jU,h Z哐f"8ܽ1@ry?lַ8Ni%K*LK$b#Qu~fCK373X\6NgTn@1R!Uw۲ۭ>2*GLj !PuvY=ߵ^'Sc-FBh w#(2+4‘ YH!LBhPF9# ܭ܉ 3}ԑ)ǴXj`+jv/6O yOjw$ю޻0b 6K;"Z@"vJ%;[q.O:Jtjld$Ӳ"a)ZC$% )1ix2@ RF g2/_-̮SIOsNUƒbvgy&N9/27]@OEm؊W E!:bj#B.2F*(庭2B T="x#}@̺tE0s^/ЮܗvP#UeZ 0fQR"RG@mhL.`DN@^-FO#W! IH؇A ^k(v};*m}P Irye=9ZWi+Fl M.vBQRF]8Y Z z:{K&K5<)XIf#k\vt깗<ܛG (WD hHlfхY(I Fc{'6 @ fRFt:_e"/?RF(U?LGZU};H}MC:-O?*wkߝ? AJ*awq~)BJ^kn]o9>`m-)c߽cvMgaw9$ DB1Ef?5bw+Bi6rNskŤs؎@bRp8ZwN`6B4YKEF1ٝv2HŸ>h`0ᦲ*mZ[/*KsaI2Y!tr3*B` 2Fo%5A`nA}D9,7QEN?pa}vضu5)ВHA8JCNQԻii}j^}.!@"O˫uf9$sv@ R0JKIC|foO.#Zo!ϟq.9C˟琮J]J% 9dpW)Vv :G1Pb){$xȫrV3Ȟh~ MVأ@NF^8p/j^VLsS2rµjՀ42}ڵ[v}`]th%eLzYHU?Ў@w3~^vw@*(F4:Fge.|"_n=is}HW`(a"rs谉OLdX PdҤC&l K)fBfJ؆=sd.&M-q $cl?v2 ١cx̾@vz^PF?ƪo[5$qgF#> >? gJb"r"U[tB%G=4PMaxx7A5AjaeB*0Mg;fvISНiܺr.˹?*o!S&ߵH󐎦}= |ݔVaWSM_t(tfH@Pf(F*?(DibÊD&su.'aw~Ug~@]`';OYrVgQx"2i(2~.gmϦ~1*B2(FgLGL? D0F9K iҎJgpZ+ꮐ))yq,_ ܨ0a,qіG)1 ru?B$"E@RRFׄÜ_LVF1\#eWU>Ν'MDWՙu,=^g@bOm]'z&m?O̞e}-ϰB ^84;}`J~˱HGB;Fצ#+b/KM3:zˇF+PHJ 'm>Rb7qnhu@r$TFgFEe=sNy ֓8ЖX23 DV;Sj+~O8YƜ?u ]#TY#w,QQ̒BZ RF Ws!}Mm6?ʸ3B`bݿ=2upRHY l92j,7:Kd#THQ*2@@(F W+Me6`=ꜚ kirgiRt9$MpF8C@_7,bcH޸aBi(TFM5 zFQS57'^;!>%ahT?O1*Ȑ B.C[L$'[JL9z9:9z,.2k؊Owhv@2 2F}?J3hF533͡eݲEɃG ޛ. .[+ن8+W+[ȝ_5*1{*1[q4[.B(or2K2Y|KmU{ވ!r_MIjp(yhFa&kJZ4MkUvq+!( stD{(lGa#@6ľi_'|*vs22oP,98= X@H| ` y 3xMH-VnFǤWv∦Óe`BB(F :=0~ۼ&j"PjTG~:ҿzG^0ª hndGXNjF9xSFTg:Fօ @$TFL4soDM`=4&K@ !UY)fdlQ`j8_?+ˎ .L +tZ &Ê߂<`M\&]B2~^8 >:S?c D G/jj),: ݶyW''WѼ '"`-$U"nnixH"4vf-Ⱥ@*F%Z/Z# )boǕ-~Js$7A#+A w;FLò!! >,B*jUԖy{1$2y3>̫)uqp>֥}{.Sd Ԑkn%3kN1}& CR<`|CC3qy@B*FG$e PBl۫ƿc_9)DZ>Qi8{Q=Jvs$#ׯIVyG=+,soB(Ffi3#F(7l'y}\fl]W氐HM܏5wB#m+S{d~D@*(%3aTbzܷI,B!O"$/Yr,j:k7c4~[jVgaTǟZMLȡ盞J찏.ZkA7"BJf>D~F+'39|?2b*0 D\$Z`KٵRoj^#$fB\JyzP͏I@~2L4߈IsHOoۙLjzי52,W89,?R3ؑAnœ)AI!I(0l;.Bj,TFA?M4`;XLPr0vx_R_k^F.oP Aܱb|E`۶J E?]Ԫě\֤3@N$TF {_KR2U/T|'+#[w=t(αow ( Us-ddܟLYS16yޭBi(FŅ椦!}7/ ad $z%RFv1M|ؓMrSL4j?Mtg/!dS38=ˊI)s9ùٻu@bF^ Ovդ2w7LuS/47cdߏGyP̿ `+yS)ShOwA5f], W[iOȏ8 BY=UKm'.tR<4/IJ l*H(Rej tnq!&A3lYk`OKOőM_ޤQϹ,RX4@B-F<ͻc䡬lˋ{KB]tg7+J#byKgV&FqSޝlkW^iLe^ }` ڒ5ÅSV+UVh"^yW _@2(D;?ֳCjlO1! =arߛv8)UW ֥){kJs37xye9"64hFB}.fdr2 3w#B o|r[H6!RăR׽JLU9PԠz1 Q% Է@J~^8 j}cm~(+bjjbΎ9D]=Y-'^>x/}}[Jss9MJ;|4q;EBTFԉϗԆFy/ ~{,ȸy14o[WiPZW:DYS! (,a- `*~[xhaXOf@QRFq΋?3p}[]_~im0PWqFHU]C™WM&*BVsha|_gsɑE$?>S|~fJ<&eL S[ uHnmyw 9)2QUGBjBW5,R/|A@Rfe"XFiPgsOw^z@niC2v]o 1(UL#"`Wn4fD$_pB8 R~НIca[+e}DXiyl{O:i^~ 㕇,XWU}36@ZPN@A hSSLAME3.9B$Z,`7UUUUUUUUUUUUUUUUUUULAME3.97UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU@Ĩ)UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUBľHUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUdeejayd-0.10.0/testdeejayd/data/ogg_test.ogg0000644000175000017500000035777411351210475017156 0ustar royroyOggSPJJJ{vorbisDqOggSPJJD"-vorbisXiph.Org libVorbis I 20050304vorbis)BCV1L ŀАU`$)fI)(yHI)0c1c1c 4d( Ij9g'r9iN8 Q9 &cnkn)% Y@H!RH!b!b!r!r * 2 L2餓N:騣:(B -JL1Vc]|s9s9s BCV BdB!R)r 2ȀАU GI˱$O,Q53ESTMUUUUu]Wvevuv}Y[}Y[؅]aaaa}}} 4d #9)"9d ")Ifjihm˲,˲ iiiiiiifYeYeYeYeYeYeYeYeYeYeYeYeY@h*@@qq$ER$r, Y@R,r4Gs4s@BDFHJLNP@OggS@*PJJl@!Ia_]Zqf^ZZ\D$o"PQ[2M_BX)hJ (!`Ӵexx]s-=;ٓxg]ﻯ]غOe|p 3>}u-,v6ڶmMͪ&)\edddddd8Cm"R -,)_cҨq{z{17"b~;m醴v}uzUΧf᝵vjNu?D2$lff]NֻiHPts8Hh'M&u= af;ݩu;誮+[4U+p&qT2n`&~l^ T{ke~s;?C:X1IIg40F Kl_|ض ]#d1 Heov)4}$GFCUMI&QP$XJ=,ޚ@k/y=lvqͷ=/|"-ߵ3wfũ"wfhwV1ycovM,_7y ]i3M}tuow9o2{yd"2Yg]sAs՜>U{T@Cpo1a,jsklx裁ʇ3@5LΟW4&Tao4LTbq(_rRE7Ů9@뼇gvY˝zΗ-n`u }?IϦ)"z,@%U14΂@]peOa _u0@z#֏mXiP& RbȜ>W~|u1}3-&{Ao.zvnƷAsN,?+:N?M;Yp'_ CG[ıȋ/𳯡 #&gWŒtɄ''9^\vwL @>r>NXӃG3=8溛253gqG)~#SDT͞ fzɬY/EToVBn(b $f]0ssaMdLf4)*@ Bp.' _KHPe|_[XY0׽ \Nl۶ $9T.l'? _VeHX|,>kzYWZ7_SNfޘ[_]=b$(t|i'ߠ*O 5n8O% '쩩RY&=y_Su>,ʢMәzJ>p{Y*/2?͞|}ft ГY\e<+9 ݻ ޹S0>?̇5Y%X-E6Yu'3p7fz `し=;k7q~iA3x7>?7=nֽ ݾ{|"Pez[ dmudq|20?`e|_7Vjfx7kV$QUՒE(T^7;t\}9Smr~{=g[giDڙ6o3u]3CRuΟ :k8:?q e{}/r}.*\}N2N:vZiufzF}Ԕ)0~M6Źƌ=s;w9s6?nB'U9 > Sܑ?JcsyQ͎%_;:Yxٛ)46f)ŗN!鹼0Ti45wᩚf,F6S,1 >׿4IfF}M ݜ j+!y~-3ӹ,f*5g̡f8S~[K0qVڟ.YV@ip7 q]DGJ]e|_Wq@/׭1H7읭o۶UB@YL`~#/ӿ⦖ s1-.uUJu~]:;?uz~h2q6^-9|mk "O\a֊k.4W+|?c4DJNq;Szgb&Ϧ$AŮSʚg>.I͌ϫ3aН3]eQUZ?;1?w@" dh8_bD]^*@QR]ex- ps6Fm4B)U&ɪ"Z?eÇ0K֟ ߻8.FJ?9?s-]wgW4ԹCГ9T39>q&YrPERwS|#쯐`6~O O7[6%4kWL7O]}>z7+(7LMo[*k"s7@457qqWyaC3 &3K_3O6( ag 6rhzwF$vhJ !+.eG^c٠@eoa_Ə{{ 6?ڶ5ժTWI`TUD3/Z#B"ϮzL>R_+[81^FGwW?9)\L!':j~: ,=quW,Rg_pD][Y95iSўbꜤ67x10?_8t~e`M陁S3I)$=Yg ֛ڹdͶv>ALϾx/{ SoCC'7T۹Ɯupv+@PinA'{IR>"ڒFi<e7\[ 06FhXCը0*>=~*`:򽯏I/)v,oޚ^2op}QbtgͬWl;99!wSp{gumӳ1ywa\y/ttC&w-gNvLrW>> ]y (38tOIj f~kdg܇nKO΂d<6~fl8T{ tE9-L8󥌙7>~1*Y{?~;]MN yu3Ιjhs^:Xf1&OggS@ZPJJ0c_Z[TNZWZMT[&!OEfY TQ U@Nmrcǎ9T4 v~Y, DRAUt " `. 8 e _T: H϶ְhDD"WU`⪝Ͻ<~yora-[LyyBQ|)r۾Գ<ݭ=kLDߧ|䇄~T Cfrz^zks]ʚez`F =އa9ÿކr8~Uv(wj*y 5Pn or \kWlޕ[[]oN=5{iNf/= YL6 ̐wwy9z>q1i͝YNUHOt"zuPd$ 㛋""B#Pe7 }oַff R~r[ɡ񇻛~=[c芛Z}#Tzm8UW]&?C}[g(v:ρzc<:#럡nϪgo]&"ܵ'T>I~=Jw2Ww>;/{sgy.f_[<͐?]9dȈ*[1~Φ0_A%l2Y{ϝM@Ƈ`9}8{+Lz^USҫ63$+QDZ~k i0QJZSh$0578NцJR gAOcX-O?KQ@ 0:?;twC@1Mxj䞖4Wl706y޷w.Y爳׹i93$(:>>?8㟙9tB<'Ťa?I/LϿyF{Nj横g>59$]{7t ;/jhH 0D{ʂOu~g/`;Fg맟瘋Mwcz4'mA9iЀ % H @ e|yiԶ֢ U*\f`TU$#W?qo!y8iY!?N{~ٮ˿).>?wB?]l3_Nз vT1}w&z6L3}ӮSp{*u$̷!sxw%PM]:h O':YLwԹtb?dfHͩܣloƾ{Wgz8ջ鷈ϙ9ۜn1 gɋܤ?SP//9 "SY$܆Ls$ {dOQp{W+yWCg0l e|^'p@}mtz$hVQJI" EFl#[>jH^e;ǗEnڋt||)mbÅ9\,*=7wN!L7CV%u/e&*iD\MkLyWלZҭ\9JD"QS03~M30]I(Ϗ1 eꧦڍI>d)n(9|ns<rlV-sb̴X6KjF'![e7@_K (} Ѭ64,T%0*'lZP䝞]1yChIw$}}^^Cq5Nv8!LB[ }uvv(-wxn3}NDFd(NES5KL{=df5}/to\Lٯ盙3 vRدjflT ]őliݙja)>gҟO`[u7_1L3߆7gGp\rc v )_-1xa@v/G%J|%(e|^'@ P`_GhkPZ% KJs>jv.Ӓzy=o$oFT) M7KSm6svٽCK|!'*B(Jߵ=k yTSLr?b3'SS@gDU[<ϒ=`o>b8rgcS{vF9`X ALoqT54=V}*JU ɫv>MOSq1ӝy훩ST&fow#LH (x_ȸt/?uǗv{ CT/X"-@p, ud 8(e|_Oa2~ HvZVUM٥LV rn-c3s˞ɡ@|Mwf}a~C熆Y; P~޼7eSf`p1=ѻ?g:ܣ|ޮӛ䁏ydJIu5ʦXרn_x2izOYfMDrսr:G]TʞbRfRf<+U{FaW3:;y;YKq3<.6 2F wR<6F?Cxcoxlpž\$+c91"!revhn,Mcu3;Dr~3;eww\yvs(VA6aȹ3Eg5_C5r&_sCEy{H~;?ğ!k9"Y:4(Tn/vi=%W+|?]u5["Op3zvݛ7YSW3?~3%j&ɹ]O5E95lCtVɌ;'[ZfokrԂ8o _Dz6[n`=@@$!YBDyF&e\ +jOF}m#jҘxU\Y3qχ~?׻2Y$z{G #uއ#S9Kd wٮ)4Fov'qٌw/'{7{thmU"IUf|gQ3}_ٜ_߲lm"=\]t1Lqjf>o8UE|8л5mYlwc"pvy9tj">3s?e_PEw򕦁|AwGTfרg:܅V%"]tOMihƧvWgf9{P36ԇz+&_YTrj́|k'd;ukv3U\7܌^~,/h~Y/j?#.j H@  `%@G^4h e|_Oa/> > a0 U* QUR>3 - /͡2u=/T>*R F/eL4s3]f;d1̡=JgOR&L.jj Ǭws4RVR!<[gL{7C*Y=c8cٵPfܟ3,7 fv_W05= < L3m鯭5ߙ^E-vЀ{[,kYXX^ lNB$FX(e:~ $p}79h J "FUEB()~vye_}V!in>&pw2vfUW4h=۝䌼4jKz?e7Ts349M3O?>Sdjy'1· U&?"|$s93v㕮(dscyOؿ7EI[^^ٟP]93ub|wrq|`V|ً?#b> Qo(/0p)-j0CC&e|_Oa_k{c PHv֪ h.@V]ྉ(r~D]>K><[5}~u7YRfT'# 6 >&xJ݌~T9?0|s9r~dNvBy>QܙyZ c|k]< {*Րs~mMk;y~?婛{;SO@5TWmfr UE1;{AErZ'3 l*1f ?9{_vLu.st  |2g{sզ0ÌuE TཪX*<߰}E e| Xp)u0p} VVUSe h 6}O?^Zz ԡ:$N|o{bT3 5Le@vۃ߰q/6Yv.^ rԽ1~rOq%g7P)`3cM߿OĎi#zCl:(B e|_)uo0@S`϶QՐT0Ar.(п.ŭk=dg, Yn򙷜6boiܝIUuM~raΓdʇԆәض>p-{ @^%̾+FLg4pÁ9TWJ&҇Tφ}5;&8.}uU$+p_~ gf&ٶ<=4LPPY8~ n@{3`۹w~,.>v~ƶEi @בb-[.B!KHPDe_Ɵky {HѶꭦ!Ui0YUD'CU=]Nl|,3ʂ vq^ ~딼Ӎ)TLfrsP9t =\>͙wbft8bfb'~fܬiנgaHJ&4W ÜMݯ?a]m㾖fn>ԯu㱱`"(u 1NfTCÉR4e;\.< 1fkZKZ 5)% od< <꼼1 ʇTБ{Y>3>r];f^SLdߧP|<3qeu^ݰOY0|,a8o&k&rMs tM2׾↩OF; yCq^wo~{F~= 3Rw%3˨7SPӛ3 y]S'7sJ5Ec1ðHn(61(׿La6!sp7n-M}X9lXIɿVJ'$IB@eOA/:~B `mX4J$@RU,T?_>8>?CkIgWk:[?j֠ q_GeK)esey9us\ؤ< /T11Yi\s$w <٪)R6ݕmb=sܷ75&4|ȢaVɹ'*}9iY97}úlo^68;T5+z /2Yt&Wڃ~t+vy_WkNwҧzzP4pX NPֺMbEgi@e|_P@ߗ6 NlG ӨT iW EʳC.M+/!Q,}sYnsKϗ>cg~ `vTaT2Q&2={Ν|7T^HN U?3T̼gSMeG_j`"<|qa>'9>Y9w'wY2psgr薧+`\pDMW1Oϧ&ꍛYoUvq3Si?|_wvd?]S"xzL[W߉w礓7CuyQ<4Mf䚂AAeO(2~\'_Fmmh[ӈ %QU!x 3Լy?'w?'\??o~;]/ zy?O͂m-}GQ+wr8$CO{̹@kӽ<749߼h3]MRS\Æ|, Voygλs EÛ HvJ00/9L^$M~,D$+qlS_;gOggS@PJJ'TbTVZdRZL^`R. s}afjLgdLcDE7]6;w_|/Õb{ja:ڴ*e,EIUeO(p2~\G>M 1Z[MR>T?,kwQ\N*wry2ٟ'۳޴Gϭx0~'Œp̎9OﹿN]hZߎQO>Ի󙼒9-w̹w\SR͞9PpeT?(:? { k]%{?fӜڂvT ] >6+ߟ}n>@]23;_|QI@)iD`e0ew(f2~\O $phGjԠ&R<3;쭬MU?.H|v|89^>r{{37Kx~>{ۧ Ɋ2Usy)fI՗|(d493w1gw\%v;LSIbX<K&G3rL:yDuj)ݛ,j7S~zU 7'߬*LL5j?kw\49" R<;]3G 8ޮQ86 5pϝz]|jeL  Cl¬)HV.!5|^ڻIf_K < }mBUUATU$#m'9w/#y9*c|z}h1$oNuã-`~Q{GFP&{bΗqyd}1,̯)zvɚ>{=;e}U]C2wEi4yn6z2@Yw&:zw2d뺴:;6S=ޮ>> rHg_a.> )_UwY fcyPoј| kC=)2Mc5D CFƠ@5|\ڻp2~\[Uo$W# US)UUWJ3-#O 3p_Z~!rٜ;Lʆ3*KtAmL׿,[}?i3k<ە}L@^n~jv{PA\\nOi2wMS߽'fq;gȻ)P2If= ,8/bd):)jN}+$uirR++c]21g ]u<;W̹Z0Ħ@! z%cPe; O"HmR5x ?vܸ./H.t^o4ˣg0z FX7߿Ⱦ+߮\5!:̡{*L׹NE{S%fͬpw\/t?3Lָ#. Et\Mۊ> ݙMc~r˿z PC"fgu˸'kU6d!uS+-:߼/j͸#a.H6Esܞ؇&kR[goq5z'O/ٿ>-{EvD`0@-#ct]UA jBi 5e78uz$樏U-T%E)UY}i>Jkg6^Կo]/{QӂU>ݢ m\z~{7曚X?}Oz*ag+@xmC5]?5林6ӱ{ݱ_YY5@%YAU|]&wC*vD5O!IQZe}Wpiw[~*?몚fE/ul^6gUSЍ*`b?wbq <P|!(S5|_ۧ\mښ0*033e񹭤SOӒaٜt7ue%!'ggO3;2??H4;]爪հgYTaffjh]x'3uڂwo?00Wf5CLL;yCcq׾Yf07<CЛ|41Cfհ3DD||k&`μذ~}O&!Qx3v1vuɢ= KX#H-/ee \_F}605P `vW8^Kdon&͗>s:b}{ڛ_מ❘zΊorr2IUv's֐a~}~}ggXK5w0\ ٶmXjHU ӑ3Oe{ǞNۯȯ[̔rR܄;~oS>ocI۞Ind6z~R7sat:_鹒=>u޳.5W#t"ݻ1r :}ӖEmF0^(:yog֏Sa΢;lYw1ӈzxVHk6 `29 {cʧj_pngfeM+ zy(h_e6"T_/(!ɴmI`Ke|_@mS`mhLT H a*(cas_?Yo'eiyNiA//-ӳSw}ڇ|Dk]؝^$NedHsOMz.zs"ϰkǧz͕h]*6_ 9>uvVw3ۋ ;_C&i1d g-L6Pͩo% Ϳݹ:{sz:]CL{-}p>gL27 C06}k@d(ً" 2` eOa/ڿ%P7lk}}md 5JJUJ2c_D׎@="E3ӳ)OghLbm#M)I#70\L@3ۨQׯ9Og8?`q7+AH31u9.aazrSfu&Eyŏ»OggS@PJJ{^eW\aLdZbV^f;ڊ7w7w>;NR} '7 \ `?K&H%{|TjE(;6W3e veRJh4beO`/? 8hEm[0SS =drz^Nf')fCC6 &&Q ul5{8f͵uZk7 O3mz63T˒մ|{h6 r?< @[ٙISr4Uec^H T-.leO/絾\_F9a*-\&FU d^{۲X~gra{r&>Rg0n|_H(/u)НC~dNsolxv:ù};kgJ.T4՜ϝI3IgMfWGL]=מݻgr &0+td'şD=]'礓ajp#ɜnr* I]SdAyr1+Ǜwozg)ץ\id[ƶ3``fvGr9H`5|]'pA>|? `oѶL]EJ `.S"ۘ2.[>.6ro!{\ӭw@g]3j+MoUe[K?>uiMD׿:\}*?;?tg<꼿!½my˧Nmgv^cP役 2_3v29jw7ӹ jxf֓S=4ۓW{&gr'99W}̠CQ) UW~4Qř's8TNqAiIjLܜpj9#uf1 y ̺pw/<0Y "@eՆ9]ur|࿱||s=i #YnwFqS&I%W+M4UIKu˷Te!RHԞ' F[SJ: $sY=ӝS 3Ƨ'.Ot9e{7Ldl>;6MUsj0ɝYSCKeL&p~&8ŵ;0E̢2+,:T6ҽw|N,ҧ/vz.Їj ;us};=}a(?5]yÛiٿyUdڕy)fw噁S;[pͬgf3cMɻTlwFU'g +fDs̙7}M/gvX5 At>"dn7Qb,lƖVBOB0e7$e|^oTo$m[ִ겒U_>+/bsYv'(}./]K>߿qWb>H]~a0݌;)gt:曞t01YTdԭ;u.s299gz^SP4~|ϴ=O5a{uZ5 M%W{㮂]W'fSsLZE^k>3Q8]YW3lE3mn~_;9 1 Ymȁ9&Ar ʋ"߂^, 0QpxPeo$~_0@W`mfZR^UE4䚥yJv8͡,_n6<NH^wGydCٟK}9M>S3;4;r)f&Iyzgj-'O V4h_~sԮ q~c w̞c1zuv_U%Мaǵ{1f֯h 4[usc;!_zmr3atuCL{R>ih{¹e;w E6R묌:ӜE*-FJS Rұ$Mew_ky;h[JfT$(> - Z}ZN+,KzϹϪzNǬ,O'd9&yZ`=a qFwց$@~(6*u {ؽkv҅vMdzͤCv;⥈\JEkwSOy`3d) iQy1ihW֩$LB(OqPB'9 ͽ=Tw !\eśٚΌT/BAw?H_w1]}S eO ~[S`m[42 hdȄ}:ןۏ!>w؟6E/&CF͜cs 9?|Ylꏿ4藡*sn'?UOfINxAWLtԮsZ_}skS4HiLe4.40[z>g! <=߫+SY+Ѯ<<$3<I^ O>_CQep~o>0}qrw汁7uݹ>*,g2b0 MزYu][vMސ"0a@KX.'MPe|]o+Eum\l[ߎih$^UūRJb۱df_oͽ\dͪry?y ἱ9g_o TC]b̓K5#ի~v ʿOMyv-Us .q̉=VթZ.Iѿ3({z`/0o$=>a\ 5siƦOsj/R?wS 3*让+?i\owOv+80 n@EPWA`:J:Jń: eO~߷4ۨh S U$AU|T/C$|\ߖ S|q9_.qO_ٓJPDua ]<ްMe-gN(0~ϩ֐edJSlfݛC=~_iq74m+C{W%[2yJTIoD{1qryQ^-v5EOggS@PJJVAV\_Ua]YP\[J[57[bꩤÏb;-&&jV.J \k}EIMI^xPPeo A{HmmEP&&|q[]¿ jq8]z\`@/[їfOgu:{yd*lQ0#qִi<\^7)k堊xˮ\@C͗z"K58~?o7lm۪IYE^t><13sߝ,%8in|ϲ6'%{SzNMN05<Os jU/z6Qa;L䷯kvvgKZY͇3ke&UueuUQs<.`L}%!vڝYGߩM>dN鞦R_S)^4xگ祪kapYx\_ˎ|Y6LuQh `ߣ/l@Ӑ;0Hڃ<e|]E_KH~ vvaaP("E\`^]J,v<~^/o5,+y'g sR<||tգ{37 y] ~vlۀ.f7ΨͮLPLgADldlrH'ʜL~s Xkڻ"h3 ? ųV[<~|&dH9cS?=)mUy5g!v쟤[S<ùnpRɆ!+ 0:6(HW (2<45|^ۧp8~?n H~ >ֈЪTeFUhKl7Ο6Mv^84z]us~RhǽPOE~DOn<owwmv&S.1Z13s{n:q"l})lƋo2)tIΙKx=G)'3]ws5'5سζl@Q3nnۙWǿŽ5?03bph12/f)Y#MBi64H Pe|]p@/׭\_FmmZB-(IUE56CrݾyR8]^f? TgoCq{sW('1fړ)[K+滮04+Ν{s6@FSq) N}Ssvg8{a@әYtzsW&5Lb #_4꼈~]uLdQ sn󒙩]/͵?G;a.򳍚|;Z 9:1٨e;Wll,.鬹9*<,r߭w.Ng0I.a̅9 H>]uZKe|oE_uOF}mZ۪ZXF29k&#ޛ|wo8<,j<%͎bLiH,b4tP^vfr'k&>t3mb6 peu'䚂]w{{n7{:j53idٟ,xqV̌'1I{ޚiܝ:L6TmWΞLy(d`_|kTB6:jje^ߙv>3v.Ϧ1;ojfqEIikÁLBE!@6¯){lL^ L5|]'p.Hp?M F;f*Y% @!U 3?_w.Pqywus~:7l*4<үc]zo楘|?Ke?E?fg }4K9g[FLir1|&ićwzqYسoE={` K(V' '{ϙBNvf Q[$zg):*7.DžߩmZ{4W_oM__3!:H (lb`Kb"rPgKc+`, PzeoaVz#@>hGYVUUFUr~K.=޳Ϣ9'7>-uP8'x䏗|ߐD?s9Ivsd7_gC}oݩ̹qqskhsS\ɑ ~͈{ w~Zgheb+EqT'JEa4}fxt{)?˭@:>]]p[+~L+S>1bwcp}mͬgOjtPoX~Mx`H717TC+U4,ߴuC[)-  T8 @eB |_K (}7#ۈQFU \6)5ic~f}#i{yXS珹3׫4Tk0:|3>0,f#?f_Or}I)߶ ]'a b w2C<$e)Ih̝Z/SM+T'Y]?W԰Zl}:tS}s<֮җ؟ه>b~q;5uks'2/q\!sfm; p;\th[. +~UѪ6;e|_ǯc/ǽ(=^o$#ED-BU&( %P\gvebr&_±6K~ܞw v5dmfc»<p9Z˵>pf0/m Lҧ!3HdO#J؞y&]{WqO 5pY&]w? I>W#w ?g_kONIR!aٞiHaO^0쁩O Tk7rϙGsva:=Ei216Qh@4q`HUFM(R@e|]O_ƯKt@קz#׼fJ2QU!g<HngEߛ~m-~i]^_T^o٣{*t󑈉vu_tv?*rv>jt]1l_ Q Ŝɚ 8aeM\k2gxf0or f^pnI\3"iWO|= tqnlIEmȼPК:k\Ks}OggS@JPJJ ~.WTI]b[cY^]^[X`pßasgjjNT.~vv{Wz0[ނI mJL8Qe a %e72 8Nhh[QRY$Uꗒsrn+]٣GiQoo/~aoΜGKWCL$^{]ߣ?tEPv~;wwK>Z\ӣ3Oob4{*_l1uc~p=_WlWHhP]IssT<UE Qen,NCO_7`1+A;!ɨ$dS($ gD2 SK֓ӗϸp>N3p NreOH~_0@קz#mFJU@`N|1;˸_^w-!Ϯz+}&?Y$9N_U3y{̼sXj CL|v2Oks;Mө}S_y7{3sȡg<|]Il\*NF!+9_qۂife}qSb){E[a8{DFA3d u҉[S*|e|_'p@@e|_o p? y[ kXR h(rVJv~~V~u}0_ar|>~[ ;6@3ƃ|9y֣=Qs*d tf*G Ч߹{(v`_:<츳f ֐H]TU͏Uf9 F'PXmct dnSŹ RB [?]o宑Y=t 0z^xQ?f) ?-SJ<0 (D[5Pe|2~ >cm۶j*T|TɎSof9痽Oc^Cyurӱ%@"ާDMoV&x2754uuV\|;? q{N*C3b\g@+}uMRМ5dO>7StLUI@>NkN^ ")>9MwólOX;]n2R?5@Yߡ0$5g6P mԏJ&$ (͗%`@ $,)I" "tB(Wg e7( O> 6?DoU$QUUA`&."sL*6$0rc{>[7!'lsx)"FدKs{;GYN}pvAǿr.d| Cgh:ϠUx5 awO]}O f|I^dٳJ9ۻF=BrHhb U:4shsᩬyz𽋚ɡ? מf0SjFkM5~+g)r⟤~άW)H' =7?COD q<e;@_K{4@W`hךFUT201MTMqs. x*/&{pOgr =3<Ͽ߲Zߝ|O}ϼ qɎ6ܟs6Ipww"2,@3'7=E':-ɐMWWy v'Uk6L5lv]ޕK>>`D_؏JEn'AR:ڶVkRxU WHQ/%՛WIN||..Q(Kz5/OW왎K(K4üt%=љص>ɻ:sӼgzs֗z'="w33W7tez_{MM[_U7FAYn;i 4oUaN.Nj.說|\L]{9vs51o 3ٿ2u`T}%@Mii,L_d ,FTHXe }7oVժ$0*,{~|?ޟ/+y"l<ՌBOBG\Cg=_v:| DF"rU;ws&uvg9YU/IfFFx- 4zP$y~ {Izz߮l=,>U/=y _laj}Gφ"7[ܢ/4}k3qqfXݐ=5}WvɓdڛO :!JSnW}4Lݾ/c]\\-$v,07P\c1Ry)G@)ToBX/Ai@ewa_uv5P$ z#6mJ`BP|2%g?7Np>c& [0k=_us5ɠy9ݲa/1S׮S  - 7ni_սYy3{R1+gkѻnzx O@'S]T2O, *[ ; I{4f6MvQ<է,\Sș8=YInxf(\{ysQV3 KͿ (``,"]*e7_$p} UfQTeYET=4Lި[͇.KGOcG^yPor 7i\׭͗gULn7 .l뮚v775& ~h Ȏ=z6+sz `9bͩΦ 4pcvfK9>NjL6:>Msg0!9*3=Uy2&jg0%2]IOoD?|,ehE4 p1Cr㙁g/bv7QWeo57Jl.~6߆l&O%oMnjN[Ot4lgh7YUNNQOggS@zPJJ ̥_S\[c[^]a][]3u{m&ۻ4 ?sNvTA2_;["l( $ɬb6MݡH>eiW)BF#rtL@ eܶOY~o hHެmDL $gЩAMUnfSjdZ+u/dRS*S|qYK;n꽧ΟY7QϷ3}z:+sh덊{O͆)TGg=@)!Y T|zs>+ #}RE{'!g6D!BVӳ6ڸ=?bvl2GIs"cՙ TONs@ _[:9]pX`&M?Azב:'= `Peo Jp?S@7mk^kjR-HεPf*^w0{+NxoŇyHv;?_%ޞ+gOϞa Zeʌ}nMy?lwW@y@/zjiNenlϯ,T>ޢTVbSs9ڿ/03יּbr{;ԯaqv[ :3MMi`#gos$O{3ë[<4$KFp]!Y;wjW3ӎ])m7y,XP0 , ?+RCA eO`/h~79Ѷ5Uu_!c}aȾXBy_g{/8,?$~8۶D/ _Cϥ3o];gﻶ/gD_voS5#v5;~F9L{Y$Ԇޓ{ͼz3Z1Cœ.i,g3q]:u$ODa2sz&MfQE⨺RM{0s?/ \ njk ͍z8e?- 7 OYW_MEK 2$*K>#f⿑;APeOa_Ư$p~7I۶mU#TR&&h b)*v~*_82ol.ӒX,D{{Uyt(Ȇ1SL3#UXԳsg4s˹S[j~u=C1'a!_a7k$Y0.79ub5;Qќ9kN쩪>5E֟d6|-e]1wlot(E5 Nat5?j,+ -.Pƥ`;P84{ǒD{X($ Ze\vG̸ڶ U&g*Q򎗔S]Ob~OܟawfSe̿+zDW>O̟kSLu8La$1bdl%񸘂}ufҝ gc5zöow7dggij{̒J4wNwI}UefλnlOPN6ΩsM=l?Rv^C6[yKz4¤6PM3pj.;;w9sj¾Ou[w8&! hj#sB0B& 0e|_;p@/ץ~B: S`mG 5*$ؠ"lP9 W_E3?o5;ׯ?ey>sk~;>/3K!|X}Z]AoTd:_Lי~AzgQ]L;T0`Y+:]}CS؇ğ9wQ56צ6Eq]K)*]Jɗ[Evmbߺ3i fvrQsT\OVWi׷iFff ?ܯo ( 5ef3z=7ţBJP %5e$Zk(eo`/}Jx`mG5Ue&"TL~|eq<߆^\쾙Z^eyg;+OEҙ9v[33K3EOwoP3LUtr%Wh_י=?Y09g*xOl~̽ 5'qi̼Mwvu&>\v `꓀w f2L~7g k& ]kh' DJE5P^vHjCe|_c_Ưk}#0HvtZQU0*ٓȍyY>yxm!՟IRΣǿe)3|?L9z͘DWb=5{jLwEIv~!G~]wZW.twQcPx q BE|^NG\|KQQ̩ͣ}<2L2v/(YOfwkZ{?M}f._(g:\3 _R'v+J}r&ZpMpj3wz?gJ[Xg5MV&/ Q)pe >% Hm0R$ hV ,Jvz>{`q,i})ݑ.>gtwכ' 0N8zS'&Cz)|S{NӝdWes֮>CggMM\`4'/'}P8~8ՔH:ȼuGLN.(\tY߭0=eQ=lo_@ S6V}}*3oZ01VKVBrH|v|)7脖RF XBr@eOHp2.]AL{;ZF5LUUtUə"N!޿iDܛ0𻧝۶l߾|,$eȧGq(y{Q>LU#tg6AMLndSջ~?i̕35 3)x])eL:?ɩ=%Ey2O r|瀂nfr[u @MCZ4Hxq`5E <G6 ew0@u \[޶fmшPe TQ8s}~9}XI/7-x]^~8,em]95U-}H7̏e%2#BI;>$1 Lf9q<e9~jtAx4]L/Pkek2'#Xd ՖĜ]]\sSgC'O63l6䑯Y*!fR"1o{ܽ&[ƐZ5OV#gPteUv ݨn&/`hs]`h !g@Mw$Ԩޟ-VG,ӕc$07J U;Y@7$Є<~ACuP$0e|e7\_F}Z ӐhPdIᤋ>f߰29zpQ|Ȍ~m2?;*} ]S}tżigr2IE>W~ߧqOfWf& EQwdAC]0ɩItR)FƪݧTSq9n*翙׷?ysz&iV%dlS8s.7}7[ޘl8b=)JzjalPCF e|uPr/ǵ~B:OFmMQӡJ+3 ɪ" j|ow|z:ՏyebyCRݛ6Xl=_eP7?KҢpt?y}9'yӂli2#_CETVnLT:g8'^sJAt)ԵrOfȝoVs ̦$>'Vɮwg/WM) g0+b^̿tu`Ws?n-,0Ԛgb].]@us\k~3]{LNʁ]x 8 !Ut5|]e4iOGmUS3 @WUr8$gᢼcjZcso&˥ļ:xS,S8|!mbOt仨#vVP9K󯌛= ۚ⩡9Tl (ո j==6͓w7N|`tmS?_~PM7STSj^ T1m I:brfO'}f.~ٜ^pi;OX[n*،A{, 肺Ms˕ ?*!f` `Y Ve7 ھ. 7cvmZU3QU4 /b~E y~srtNy^'_2ѲEɎ'n~R!\Ì:/5=$Ys.v]43;,8Oc7^=|<vO Yrs7f-x"ɰ@)U*\ # 4a]Ae|_ۧASrIW`FjJUf ]L˪7qNO[C.[M)cs/W9S<]!WSygxx5#jrO̥]͇yf9; 8e~*'O9܊_ȅ+QO3}vAtv>ϩ'gr(%7C3]T3ςt:U[ˎk=?$_*<Ǭ 7m7~;sk9vp0j Jy%FSˇͰLEF7Ae|_O ޷Fh*I GXs/%߽g>3{{[Mz?7 Ӄߜ쾕0"SGtgԍ?\,\ ts=reCڇ̳2Y@LzsyIݨh5/? gs8Gtq\PquXL"<Μ~V_o9=_ӌ5]4;*xH.OsԔ'?0Ǽd!7(ݯWe.  0$cP$ }UGukWe7 }ށ> m۶*+L*>'S/?)j~{w5_|7:2 +hSq7?uΥ̩}=aqE^حӵN?_})fzkt\G6*NykeM~3'ai?ۙ4䵏9<<6Z5R `T|h5=ߟ}=꡺ngcӸm Fno"|41F),;)ow<9>錖n_T5 _9Lʙ$STΧCŹi.Hʚ>ً or˯q*q oLaxdg0;gL&'Tops(uc^5>3 97jZP]N.SS:M%ܯ7khyr7ScŘ,jv Z5Y[,3xn@z&$& _ eM>](5|_^F9hXfjK&&gP\^-7;7|{'^^'865|>.j99E/;MԠӕf)vNR' z=,lrO=&&Eyzsm")Χ>fgNHK$q3X C&3_ ܽTTuJO۾QfΥ]&{2w4!2GOM߳U=SK5p ~Mo_? $9 8aFMՕt2o~?0~Ɓ\BBdc@tp0@*e|]'}Ko> ;V0UJ$0AU\ʾ)sﻧGOY. ;çs'FӭYG~\rT^j4Tלn8v|FʛȜc>kVegwd]|3iC 0vmwW6s34lN 34^g:';Sl6Sdu}me,S &/6:پɽ+/2L*͋5_"`&9ͦ(oz)`5- hYnώ$gsܲ07V2"[Y d&O AX?e|^x ѶfaaaA) Jir~~r{~>S͋:?_˞7&R[*v6Uf^|U 7Eywa`7qQIb[~Ǧ3AU09MSªC,A` $4; e|]7@khS`_6j̒&֜!َ_cv>\>wccЏ~E=sεo';['&{>go\SF.e$S44WvO7;ٙŲNvwq5~7NUe\%*':3RfM?M*r_'{~ū~6p $sϫww%=yC]]7Tk(zw*>V5M"Ou Cyj?Sʳ& Tg/α쏱=秽ggp "G t&dT!gL2} ]u( t6KHfe|_Wee|_ۧd7h6U2E2TE2P^1]t_ya;|oݡ߼pe~r rR6}m_@f~%gS|S<\$ݟ͜dv05wwnCԝ__|gcO잜zS,3EQ}pF5<1seۦUl=39r)߄}yzG|~ =WCCOީ3 Z``Ń0),ɟ8!e|28 uz$Yhôj* HRVįܵS#_e8R"xw>ii?%fXv䷚'M7iK>?9%}GpS?11aZ 6t=һK]qP 481[~ l#\y{.uk](;K .ӓd_9o|\],ݛDHΎ00W#zkhP`vݙ5v7J% %I*9(, B eoSC'h!) HlCCCU ɪ%!k$%៿?g+N9T?wv2v-<`a-Yd]=u{1+?ꅄq2CLԔ;5yma~Φ[4wɝ~_Sb$gޯ?g2)vtשּׁ|ZO6=Gi Y >L}Џs> h̖˭zJooUYəùN&:p1+youUP]Q桙Q_ASPgs3saz&(K:|@E֥,, 7@CN:Cqꢨ;]✾Y˞½8.~!? >1d70jb]ogsv_}}̠TKJv$^ Cp+e  y e|]7p@q- LŌH͎mTjFi0 L*(UU[LV>Nr(筕.1>|eٗvt ;X;f$:qm"c;ͩtS޹őEN~UJ啙ysC&˜*4O>|FlF=~@9==E弝3-ϾgR 4x{P5@̴3"YCIf'Yw\Ο:tkZ6vAͮLvl}l ,l㾛(~wd(W<:^#Uju忡=m>F@ p,qOggS@PJJ %*TUd]^Xba_RQYe|]Me|_Otz$׶mѪî 梲r ٨9x9Luʷ/xϗt4:]vUf;u3ֺ)O=45M[ux'?ԙ&4tfwEaJl;zDd>5ˮ"wyM~y"fxjunUs̚ޙww2=&!!+htu\p^06]7ls`ͳ Sd+߾2۟k7í5|p>7sa4yZt|hl xEi, F i(Xe|]Ǐ}-z#mVQ, "?8C%^r֡CnJ#M^؜Wu2_7,):3CK$yzi{ϡ 9Cِ4Sޛ}]VGp IXin>RSI7IVV {Im*]h?r2g\x4/G'q` rHXHs+'TUL<9 w$FsS0]9x"oh}OfK8 M2E"퀣@dwOt`e|u@/: p} 1jm*%HLFEBhE6/>mpbx1=/sbr/Ͼ.W[nC6g0ONӛ^_PWٰ\7LkgMRllg74Gz5t?'.|?odxBɡv7W1[i#оFٽT<ˈO~&(@d9@ɧNp1.\Hm` 7W0 4w27;uf0u_wpMoK @$ ~#tڣ%%HfĴ$e|]7@Œn@}HͶ6BMMU2Hv*-2?/OĚ*fYqu?;>ɬ|FOn=/|K.jdΙwL?ٮNj~qvyk'$gRuZ~sKV63*!M|G@%>/g8~qK${f{f0\}g:ɩ}g{M ^iSD]\BeDd ;5e|Op} ַڪIJR f}=|x^OrYڧO?_.k擣?{sTQǙ}0k&,ŶGyzI]|\=got\YO7zNϷS %zW7Y%^LBᾓRSX?RL0ٛ-W8([~bӇIDޕΙywoVn]SUjnyi&4; bQwW Tq6w?/hO b \pHnEj}}:*PD#1ـ,~5|$_׵}OGm}MLU% "JPu0_{;s/wk_Cf#r>,;>Eg|̰=|yɼ;99\Խ^!Y^jTS$?FY {quWDUuOLuc:hg\~Kg&ܾ/d-s'(fw!`gʼn8"9)ǿ~ֻqRŏh6?S餳YݞXϐa@_ 5~ŕٜm!GG+ "E_!`e|^;p@/~p4k464TjI`BPP2 ]">P;>ޤ#NfycEcZI^~on^nw?)Fk}~V>}&]%ЬuI.h`._.f{OOv9$9]7 Yv"\=sӆIWt0{7s.A j @UR0SL* 8_fu(raLm?ufϯϭ\O=!+86O#?F C0J+d@eo/ǵ p? ڶЪJU$*gaL费\['믗^_?ws?xf =GYUSjA:oaL$]3sWiJ=s3s迧ȿw: SM‡ayLaNך=}5:d+bEAʦe6h 'OHPm{Pi$ <=3OΎ~̦ S~i:O]̋}vxHϞ?Lv( :2Nu&(NU%w7޻Φ 8vw.]Ϝ.?c0DeJ%Am&Mѵ̗oqvM[t?{ NO~ [;:unMUd}Z?TN- m];ӻS=9s8P}HqxR5E&ͫɼL9S>1נrΗ z?cG*9\/'Gͅzam&k7$e߿SwCw=oUk_|};י+TRd]3vof_ 'f+0̽,+uAt`. qw(8e7"3~`oZ5LJ)B\~n6zQaB.2ߖy7?:wN7k:wOk:; ުw]-9ˁǑ)Ot4-3UOe5uʢ*2פC(C讇M͍ܜI6M_4yƲq]9T=**jv)گ/;MRW7c]<@@ CVP qo6?&'@qezs]>H)ze!S:޿!J3 3QR{d@|Mb՜>1sfn-Q5S9{2?>$}1ow4=Y .v>g:5$E7PPsXE 9E`T}p:e`?֔5_@K6uʏ$PJJWxb^GXYb\]Y]X_e\)a ~7l[۶BCKI0?3/I˸CPw?:.9CD4,Xd~W `*j~I<?;7_VЕo)op+CspfQZ~O͵1IٙWW< rRYI C%6nܿ7E"C6ߛ,衩{PD>(a Q?ygȇ=f*-uY"L!-7L9n1k[#F>Upa!aͦh4{qѨԻE a! sC8@e7 ާz#v[DUUTx(;\{Əy"z~:{>u.$w> tqfsUxʧ&&yG/2IՐO>.bI.$_Unt4zÉB.$՝ː9_(2Cu4* u'^OA蒺*1k~Ӡ$*>Vanl>]dm~1?aeG, h*u,e|__KtP5p~7kfZkFUEf0q?obC,uKʕMx-z[=d/MQcXnMKWMٝxH2?.73>_ST<~ FԒ̙[2zg3 fthx19Gg1~?ۂߡwtΜd?s(d빸jzο{ó9ԻWL՜Чj7btA2 |{ًo?\լ>= owoWx#pvhƷ.⊔o 1ޏ vགྷ,eo0@}` I8skFDU}j>ڽѽ>_M/ߋ?=ǁO2szQ4ltCOVnj5l|d1K697;o$* 歜q[ޝNr2$TiN̝$d-Y|Ps|S|4{<\94qV9uO|4CnΏ@{WEәϜ|@NjJ1ٴ@cr-3efTffj8L&U/Ì46jhdo^S* `%d,=e7" 2~^ڻ0@z#>۶Z S&g @T黖awY#qB,{b;>H~KVwN9՝s}]Sc/KX5$8鎺yyqs6}zifdq.Sz4=xf6svR l._=Emz:`1LX9tuO>>Ld0Y?c.?MQS Y4H]fhfغx7kOWv<(uj_Λlߕqÿ$z{B@ %FI e|_ǯ|S_F ַ: P I20*W&oJY]7 3T~rjuؚRt<:U rgMYQ/i²;)܇kW-m]&wS=u%7L~+8|^vb;lEgWP"l*3pC<5=r̎{rwV^+k(tFٙũ[ñ?׻$Ewrzi? ?O߼1#ҡ ["i& e_A Uo$اomTUBWJ3̗R;\^H?M>͡7`԰%Wo}f2f&}٭snfCӗ>J s9IvWa=O={;49yPLgۿq:Cs:J6|*~*NS\LL}`'I NOݍ;󠪁kjqie c:ĥ&" 943ezR3ϙ{+B9I6K[Suޏ)kgهf:ho7|}ôzߨ{l6`kca<)ƑlࠐqL eoa_׵}pd$p}7m6jU euy4S ~<~~2rwz~Wdޯ6lޏ;92ޘ^R;$906}uȓ{&{saG50z"U.gx :ϽɂusK|3*I/Ԥa4 =9EŦtNH*f*s&1}3=7ټn*[qczؙMUy@N9vpr<]SP6W F ws̞mkj7k I(Ϯ A/~w֨rө?`7 w1dΘ}3Ψ`J!ByѯoN Fc?'zE TLe|_I@_Uo$P,boX6TIY6E,jfjo{9ݖ@&~}].q?89 q5ͅM1nn;TufLZE:oݚil!vpU!|3(්@e|7~.aOFmtoVCTR;LKmJ!^?u<龴Y{o-5Z|{3X$Lϝ$9>pfn0pBnV(SȀ&hj)B pi=# e|_ۧ_)iOF}v QUD}t,qǕ^LJϷyoyef/l3?O}f-Ӈ9>GVHglT Kdc<ː4U5JΛ,CIforMި{;;^7=EUކ:o4G7jQ;0X=Mg0s:̝u*Q[$vz'kW'g>S9ťaӞ[vs哧c2 qpoC'fX!S#O;<5%n,, /*) V%Ke|^Ǐ@}i@5}$g뭵P)I؀x]^>ǡt;~isqg<3Kr>vEQo;bF*S>g䪛crΪ+gt`ȞsZyKL&Dw=Wn(wv׈&&g霤tBj3=sNw_;:=(k`c'U E>r*;Iems|f;:r^24d~uu HC~>gM~س~c3<c&N4Y"/x e|/׭ܯz#vmjVI`BP(EG@Za9t1k-__=8?|=ΙF뱻T7;\y6L(9Ӑ Nvv]#>byꚎԩ/73wwt׿q~{_[tN_#=U9.4Է+!9}PWmȁg13ŏl+OV5ɉr@&$f6O5$SI7d6<7_'̡8c3~i:^;bpxݹ8߫ϧ;0w7.tEOf ? nH - ,@`e|e_\@cVߎ0BRO /]kME~Fo8r;A7sSkw\>y>٧6kK:QM!FEҞb(r=/P =&'㸛#tv?\i(wMvgW'Ø$YtƧz&ivh-ٽko;=u}|ɓ]9U?dubڪf[]oT@ ϩs8w%U~ڙ9O ř 3U49`*ΨYme u`'N'1x?>FjE,]. @.BJ7#@e|ܦO"Ҿa Uo$NOfkU0e"! @4˙,v ml&Uh/MgeNP,3S㈓v+c;gdv7{0K\r 7,Lʽ+|1Cqcե=LNМY1'o|k[yb7B?72@x z1Aоew`/筿 h) 0!HȞwhi9wq% ϿzW?'$1LhcobjnS}gz纯3cfδyn8o,7api{ⰦH@>h`dWe#!CԓeOa.>h>lk۬ަZ)kR)>LDC,7wgw(Kծ*W.Gc='G).E~{ٟzk3"?!'gɪd2>O 9Ik4{tnI2)gl>3L>]$^9\ﺙSE'56̞avf&Fv5㖝5v\>>tq:ڇ 6S VREfu թC8EgY% tES20pUa=` `4B҅@"e_Ə[smj0 AT=>&V{MZgJ;SR_?ClJ{ضίTíz@Fʮɋ'et??k)PuS{g%WN?s-7?k+dޙ:Z34:TSo6]'ɓ=K6j>/VOl\ljJl5s>sHG?i7CWlΚΚ a&nNěݵE=3org{6|j& gfWT"/hҧ߮k*IaJt0 p0OggS@PJJ#iB]XTSVY^[]h`ZeO`/~8 S`_GGJXU >>?Ɨ%LX̢<;J4L8T6bkcHy7v '] yei5gL4W/1p|5O֞g`)S 眙xw!Ϟ`9gMh oQ/tQn>6ڳ9l'ka*qqYx2{;綯jr: { ztwv3P!l۝c'Hq zz7gs%-aq>WK`5|]_Ə{N>kYvT&I$xՊES驾^tzvo&z8}cHn]Us\f).1yxÙ߆g{DMtq]v7eBDL3|߱q/8OUCT 7CatLXkĺ;ά$ r5}>0u~9 s@J-}P(e|_ qx Q߶ֶaRRL$*^)$LmRIcF<|r)MKEs7n^C^?j|[vVt|=3~fbʟNy -op:{?tԜN ?nΟuv]{HG? AJjׂɌvkn֦;śUoÇ騲Y)XU-3sg|0 Ux#ǰ{t".Ȫrvr 9Cוu&IYԶLӑwYziJ"a>;nv+ HIJ=J#6e;8 ӗVz#6ZEU@g[g5xgCb9n˖Lw帻 ޞ7)g綯}ۧ8~H&Ϸ{?Zfs|NiR)3̒C9{n[__zNc4taNvtb3#e 1fm7CuX㦋~%Ur6NPm.GлP`)uΫL9[߾7rb=4Sůp}?L'vP^a>P.ȑP 5|_絿K$Uo$mUSU$&g Lh9->Tv;_;/ϫ7Sޔ_ey#B񹁁Gړ'.1}e=_jbbW[sAIfOpI[5]^9Ww֫&N U57z&7kwdNyK)5=wrM~3C6ҝr>&dMa8 {߃? fՋYuً0M] ~?fd m J0u]??8~az|2w,}e@Ȗ͝ux|~w* { :[@b o]W'zF˸ٿl73]pϙ6x38]}aGR?;C{*VSٱ/N3Γ\Tq$]l'3G 0)~͜[h]`3l\~"e'A՜X!Ib>t*%+Æ<'S/"H2e _u04y7:ZZTUR h(?>?x{to uqN-4=?D!e!9s,nɛ35KfʐwogN 놭;³dDLp8ӓj'n]cp~Eȹs@M]ޜSԳ)`Ӟϙ/?g3ZFMWn3v[?4w 0yۻ۵Ikdou`j߿C)NHJ'07bt D8LMf .|xP856xe7uko0@קz#m;hD2 VJYx9V~p}\lwW3SY_95ӽʪO2w=\Ϸ2!!;OA'[?;M@r>yޕy2]Y)]9 4QoKlf}of:< pڻsSW7Msg&~ٕ[3<LZ]O ~>3p6qkMޜwA,G iQ+s+Lo=<Һض} ݊G J1e|_Oa/?A27쳣Ѷ*H&^ ?1z~V}5olZ9GK߆yr!&?qoË*CUPgd S$3]ݓM9̮B}irkE/YwS](:m5 <#]VRL^3ywOUEܙ]j S?9=||EvWĚ~ݛ.)rfȓ|?f:;=0e[fdMM=;tXlڜ oQ$n蟾eR4 1SeI@Z."W%ھV59O,eO\k8HTo$}֏Z4x|M2z쏫-IGG[z首sLs?c]8?B _suMNw435ν{iNT"]LT34&юbϰ\WRM]&{vեS^k{ɎRN氻sSc7ž)xP*f'64{z0R;tks<9h(qk]3_)&I U CaW'Ah`ke|_o$X2~\0@קz#^hGӆ]QU< )?Z<~y }P,zqx뺙(^ \3Uӵߋ:{_;5hSKsJ]yoisѧ;)yy=Y=A⢆Yfd>-44Mgoj=mjSp =Nw?9U!93YwTS #rv'zӽMf^\  ݇i*>Ma g&9ga 48AnӋ@i۰{_ hOOggS@PJJ$YL]_]kSZeTea5e|_O`z#6FkmkV 2%j/Q-B vI;\E6s?,6G{~}z.Y\ܟQ䯉N~RUz{y؝dgfq?gnq"F=S燎>fVbS9nUv4rzOyPQ6BĮ&l&fi3SG {znc9y]CEgg?Nɧ)Ilݖ:Ϟ7ǝdȼ}Q֚F$95;Jc}ms>bc .BӖv90 $e|]'yop@W`m[ `TU*zr,!9;F<{z}(qg%9{L@TC{Æ8l(zYԴ?='1L3u̮󓳓9fvTLN.&dN̼scm̼3so,ilYEd0ESipqͿSǐ]3~1"73{XS~Q ,|oߘm rk9uSPP$p 9K`MH~yQ, % 5|]7pp2~^ڻ8? ѶFU$UEe0/[|r͡Y{Ù;y:avN5sv6\IԒ"'4Pxf/c=@dSlYJ4JMrtEn6IZ$ e|_q@ׯ?HZ6PcIZr?:;mJ"?s;0OLpZUQfUW,g`}5ٗ#cs 'Yhf|._͙~355c<矽'7gf|pLi,;ubSavGh:STi_Ls&>@oܴ&Gǻ*!`z?I)h1, BREvu@T5 e7R|_ƏkhS mh-BMU2 Ji!:b7]v1GqYLY}z3qZs[2w }n2^Q~,JiXzjԊ3oh_47 yU՚ݨCޝus5UץlC!U!GaoɱgK2]{ zrϛΤ0ϼpu}SL'] rFu>oR51@oe&0S LPȹl-c=Κ~ bhά;lDZ<`0MυZ(Uaffp @@eO To$ֶ1뵪A* ~lA_P{(vtKYn}=nޜZlsogaQsnj:kUfL~y֬-pI3Lsw Y@&{_ }:`"8hPI?XBeOHp2 *To$g[߶$WU) kT@Ɉ/1\OG$mU]c29v7-yIuM80NGOM3'339LɠiB%E$:'K.ftk?*7|{iȸ w-2ؓѝ7?hZy"=IgtA(|7LͼKAWAgfۻ>ٕ =$ο+"XǽK!89y + 舻S *RGm`=u#M<@e k(zH֪ kj0 (U@)ߧg;3-ݬkj- =qlԺtws=Ve/ sbFjv__N,>MʜǬ;%6ݛ2'zgo_sbX9:=R'qw5cm7נyo]?'g`>&O{׏r:JR~;J1h7Iݛ<]n_;&7g7oy3YN N59tzf|v5ÒLkR-:98_\;8 h:GoWS;6)\SCɋ9wisSuOjfwW9ݘB랉e/._@ M%U@OLqz藂ܳiog`z7&Xyl!{B)b] '5|^p8|-HmjVU206M\8Xyr>?M>/՘WmB\a*a'{u:0aTyL0=7;֐qY/Z@'ü^٬i\N [_-Ÿ]0n0dޙۍ@ gzkq 5fI#>9]P}dyt3=W#^we2Af-TW lEΛ;+6?ſh Se9fa6 5|]q8~?S`mV `x;ȹg̤='THZxQ>ޫ?7-<Sfo'{\];~>\LQozD{vn3t],b1/CdꆎI`7Pt[U5uE)䧨&3&4hD =' Tm=oPdw:w6wb5 lQ4L5el ͘Բ[i[ŹW[_Rr؀$bD(a e _Ɵky# ta_F}1,VUjH5/R8+n͞x_{>|>ʿg4<؎K3X9^vٚϖL*?$Y!\m$خsoEE} 6';_IG7?3kYoiWSw?SwSҸsƌw抡Ʉ/I.0 u3sϕ9GOwBrӝL.S=>YOs f.! fIg6Ћ0 \TwCz7egf:`,;`DdOc:rC&,]XM e$p2aOF} hk55J& P}_}S}_p7l|$דK7ynGDlPz5D*?j Co|Ur'P 5o 5UZ$*TM 4{~~G1V/o)?Ǽ'Zd}@Xb= 0-C)$sa2Le`e|_'&Uo$km*U20*@E$ǟܧGO=E'3?F4to?xwe4>S}pU3g~ܝDL'e8Npϡnў_T'$/}KJ<}>np 0t̮.M7IC4_xǝ9C9=9'ygާ;T0ۨIr/.>lq%y-2z&*`Y (lM.8ͬɲT*FP6!e7(y7dH>,4B5FUEr({ ΉQdL$&ě!"]gcMBWFĮ+x%MFu;\|i[ yKsY*+篸5䇘?}6=<$id'[_;_S]}fO|򢳪}\L_OG3ը+95g{:Z;&>M faf8 YٔsSTRbLsiAVܾ5*Ϩ1M 7@ ~HGЗ.Ɍa(})0`i 37 e7VHH֢k4TJf`xe/}'J/\~wS/ M$3)\^9!^&/dw!%nS:0SI~sa" L9O}j/m o8Yp79s(yԆ&{!s7*GEbS^R55}a]́^l]9S]ߞ\kګArh*OOs3Yckp&#Dr.5sj~-~_JUxRʧIh PD`@$0`e|_O&y \_FmmgQ03e"!d|az^Ppn0؟}G'sGK}[/7I>|k/]Q֩uT;C'3WW~8aswM_4YAGVw%%^>lť3t짳f=z~S-gNYI5iT&sﰇss_nJSO'?M'݂.7kfEm yD$,U 쮟ؿ>ma_1pƤ 0 ]]`IkfҴ OggS@.PJJ(ReZ[Y^V[WTVT5e;kH4fiCkJ*s}r_O}V/|Z^tq(O5v^R|?_&3=͘ms|<-|پ`x7Y(8etY=*:5oꮫ:w#S64UWgU*ZQ`)+NR&Os7Yٓ?Qc6wʛ߷>Jd Ɲ/ w;eYA 7T $ww~Pu9V6bqvHIJL)3l`ХSQU5eOa/ǵ}4x`m۶L$0ZU ϯym84& X,btk}X.a8H?z1ٵ ];Ϝْft*sZӛLM+cr8'05KS?V秿 ~-}JUl:=睈t+~Uf%[yIOmY(m1s|,]twnSWv+ܔ3px ;@~Q=fE~sגF}}Lm P {i:=D9!} (! B.,Oe|]o`:~ W`mki2I`TU*d6a8l6~h!=?<=d2*Co^$1gKy]s OY?Ýs25hN=`1s }}LVbA(49}qw`WݧS^*U=9}LuW<'gd&ǝwY8}E7a̭+^WgwOɷw 5M7]KbpV^c5|'JT09#DKmq@;x@톎I I( e|P/W`mЎ*%9SESC f0{V۷cɿqVp|^X=Lggo~k4@Q#Zۙ MFOm`oKs~.yYսdIvźq~9[?݋9eugO]4]]ŝ[F?&LvrD?$`ܤVsL>CiSsj2)PA77]ʦh{AK9e|_Ǐc_}ίz#0v6ڈ0$0YJ*y+zyǖc?_){>/5):eaxon`C:d^_Sꧩ72| qs:e2#|FKf%bקm$ɣ7t\gmoJܜއYJ;QߵxrYdsMA䇎I.`3/0}dT/pgwݾ* U󆚛/7,EGj_=  # N(Eeoa_$HmG[mCJ$*a$έ/2]7yZw]獜 חWϛ|kdWTFtV=w ,uPU&NsUoLΝ$}{7v FH:T2ZW)1sP烬Qo<R>O_ yωi0F0sjx-?{=YJuD23ݚڳ]ӹSQ5ngs(7L vb75+">@Fetu&}!_Bz<4 _xFw'g7; 3NH9T4j]fp w>92j}gӓd )>3Ʊ~K8ISy%d#,|6^ӚxDF eO/>dHo[P!h"WA[l/}Ǿ5w>xnEś[kZ*&Q|!yq])>?Wp2w9ɕ0l{ce)䇦1<}z%YL|#~_~3_cEco61r6{Lf2/3d9MfBW:gO5tTʟ6I ?|~爞[]- ?MKJ%9H;9WOϚQEdNmMj gk~sDڇbBvw?Cz:C33%+wΠԖ5փhEb~} 0R y.E<"b e|_oa_$Uo$G۶Z*EKUQERy36+~}_v˾D\x79릮eakwSIQfRL3MUDe6b EQ(ך]9UTJr9]=)w6P滳&gЅ,OWӪ]P(zaOj萨9@ds |os}PF<=ϴYʟtffCd_lbnk] 3g4ta}g[TbX;LY3PYM ^lŸ*`xewa_Əp} ۶mhXh*EPa[soia"חKat̞}z?/sgw~IN⽊>d0;;f M@{'05wNܧ7v>Rn.d5@ޭsz8Lם ;K}Ɛq:gA}w/J9W0ŬgM^wL,?fdR0;pz;I{>x—?k-,JfiH!hDmB R(5|]7p8~?0@S`kZV-LU))I`TU$#s˔E޹3]go^`yPk^S9-Oݨ7ͧE;i~:{NtE{n@ 8g&HL>N2Nt;MkΡy~3 ˃jO/}'lj0旫S#9o*}茖)Kj{f`t]#sb{ qVR$}?`}0U:+){ۍ+Ne0l^Ua4=Wѧ?7=Lh/$~-A4{@a޾*}Htm1-df<]>ޜƼIy1|Ap&#~κ E @p[^ ^678 PiHhC֢ S5/OE WV^~/s_۝ic? /:~C(k2|rxܵ*I9U4a& qy5UdL>vϦ _5 sYj`i ʆI?OAhwG3fwRT̀ejZ>1'#z )UvUdld~hj|U_H E7t14P O6Cq0^ LRbf5" bAQ "88e72/$> ַmR0*=Ho=KU=D*.{tqh=uמ>>]'zxw~}~SBR{ kT;Adȍ;̺ɣgڕ'5ODC? Ԉ?9qطa7NZU1>gf:tS: /Gi2i^5JD"Fw3|aK3醆[[N[؞Y{rkw:Sa&\p) ȐGA(0PBe|^ۧ@/z#>jm&U% gr[u4Txdz|?"HkĽysH3u$U\UJiNf9U}zo_]e?q.wNO|~UPϩb_zz䇭ID'sg6u87Ry^1Sl칙{=5}ww[Iz\'DpDl|7xyH,4҉3'14}ըy;4U-ܕ7rUwϧN7\D ؈rXy˻2<-s>ΓMU=o;w]s}h3&qvt_}A8Otz}}馁A3/4}ftV˾,(rd<$@ wN,i4I7Iw7_Enxe{ẞ@$%=tAKS4@6 AeO|/SV$p? ȧoGۨR@ ]ey~]<>H" L>:3{?3nQO'Pvu~s-h~> c|̞Bd|L}Wuc>|su臈o2싚hB7Qڵϙ989m\ ~9qoΆ^7Mηu|{f́~Gv3IohCXw5uɪLHr+ [kخG9tK5/xzp~+_粋>~//@Qf,`uPe|\Oe|>=@eFZ3UU(fpTUkI9t!aw^#j7 ˗6y+eyr#CFl5ҷ|Ep)aE̻U 8Ω"MQ5(?2u~p1u:Eůl؂i+GƓ9;=?d>lYvlx=c{rjgwӾ>0uz݂d漟)ù }fgýoY~Tq/9En+~ŀwOtن~߁uehj O(䉞/fBoqM5qe78 qoop@קz#>ڎm56JުJ@eV\W,zTZ[)N#菲ԟt-&wgJZp;9ItV|vdu hͼ={fUP3Þ%w— =i_j:Z+g\5)zf(94ULJf oz_w 3et=Ml ܓy-;Š$gO4 tfW2Vםլ#'OR0m}m l$yq塴9s!vlOUva hZIv<`K@ eo4~_p}7lm*I"&w.X9rg6ob~~Qe//[g,kRv[ϳV7wO6~Ǎ)~T;2SG)f8NY dl;~;_V}G$Y\Nod=\iM{81=y9>w 54^]\N&dEٟT'܄tmsry5U״'k*_[iN YihAQ7(8M2؀%95e|]'p@H{߶Z-LU Zd|t2"9,qϿ?ǧGoyt>޵(3; _[\}s VQ@oC0ZlCjES 37?M 楘Q%xEA <@iVE7I{r$̤엦yQ):Օ&f6=5EV8_k77^;g3Wu-ҞbO =~%p?0/,grs)gS>n7 |rT-[v1626! uE Ŷ e|eOaW V߶0R*HRA}1K$C'Xg޻l#ں{3kMv2v`okwUE;z|ZIl3'S4; 'ɩo򻃼l~KvhDOggS@PJJ$iPTdgO\ZWcdfb5|^˧8| S`ֶFUFI`2*B5Mc~_>~6}z?T_,^y9ˡӼ,jgc}sdA$R/[Lpg`q_vwI&N^fO滙^Q3dW5~iaCfMv}ň C [ܳ.'  eurNefEs'E&8w7sSL*S|Ȣ 5r7M/Ht\00cXp~0ꡛ|퉊 ,(H5 e|_u:Hm[kaRITk Krp^]J/ kg[/Ew^G,m_xY>k_ 1լfb4[||އ9S3M#n&rS5wۦ@s kd/=}>.SZju Ce ]zn͸ g7q+PCNN12Bo]~_ap[S%Mܟ\&be}^X7nt[w嚟}ܫ]Wԧ8Gfk}w̮YQ;rl]y3S({d4k?c?r,u@?76\$Ξ8Y3ٓ&aG54?ӵ3.]\$t7fQ5UkO{Ûyp2]0w&Izw%9C3TNu3g@te339S87 ,:;C4>%M?P h7U_Rd2ye֯_$p}7G۶fjLTB ~}V&vO5:_6C_~}]lfn;ݖMy#sێ!'kyA3/}.Ό!dٳٮO]4swΌ2uSή ԧsseA6v֚޻79tQyr<4}4qE \4׆6Ok ʼ3o4m6T+[@sΜs|L"fv(gN{꼞ݮ<9<Ԧqi9@kl[蟟~ULJS.nPYk u6wGF;]ݧ '*S:dR!+u]ӛh}%$IM51)ZJW}䦧xs'e9 OCðN> [}:7- Lku/ ̤wTHF"QĠe7 _ FJL"!繲E~2⺱;lXqy})p9֗>;~wC_/|B HC'w3Ug1QV.o?LtDU{aL]Ϝ~پYor80@gu~awLM&p묙 ]|Oߊi6({*pOqJtvǜ|؃h}?xmuoڲf-f>UP%0fMp֨/:ZО@FĈh e ]ǽ_цmT#TT<8M4B:'~|bщGo?8Eo/>`yߪOv}gxzs:9a'ag'RS{̋IX7}9Kf/+{48~}ںOeqvΉt*rIA;'3@,kY"U4on 2}ltẺ 74 bVPQЫ2`e7 _1~7lmhL#xU"Ph''~zmw\]{#3/kz)Rf݊,%Ļs莈#/[,pv6'}a΂-<6E]P'Nvx'?MA4\GPt g|0[yr̤蟩~0=z_Cs"94ߧsI՜;_i~f_{;f_P9쎻oL2>0ũ30|~w_w\nr>'%fM]kĹAEBrXl=/G!4@zV^B$=Q3"qo^;e|_oa_~Gpx7cm۶*EIxU! \.T'#ۏbo/;tқjN7p ^>Kϝgyʭ^HoEfw\Q}Jsw|bs'<3}moRHywPINe&cW#GgƖ=]]i~ցME_oaNxx@9yrΜ1Y2EèXT{橪Lrg?g.uz~|^՟mȁϿ5`ro k;_l@M!@ns#hjfvCU17I1iWJFie|7~?o (7kַmMCMUAd]h5-e>|t'_է6abn3/&=T]935qS5uMMWV2cUuMqŻm;bG>өO~gec^ɇ{;+3PP/_;4{(޶;gD9*`⩦CϸhT4$& PkcgbMmȬ-߰_(]doЂPB&8OggS@PJJ?Ûi]PY]TRegU[fe|_'p@>`z#>:|a PUXUA&cy:?/xg>.7'~P> Ӯg.\,v򯺪T||&@^~I:%M';h2Y9WO7WOR5OeB>}Ȟd@ϜVWi?tj:@3}`2g3x?0]\ܞ1\Ŵ1k sF'0μ3:r9w3;W9͋p~|L,Y "6Mu:@p@i:*-)ZaJJi!+ eOa_[}> ۶>FUSFUE4'%ũrvgĆO4#C?/??ܯEJ-|iᆰy|jٶBE=r~+jo:O*}Wܜ};.";O3wvR*9,{F2>QUBJbu>lu STyE# }hf 92#H[EBs3]yAfUU@e@w_Ns N!/a&b03o&Pr|[u)F%@Y_KR\]2M}ʭ 5e|_qhm"UTJUsUK׽L?e57Ӗxl/s~|a|Yxw♦/k)ꚞ߾Nu;*s (IםH9M1%gݪa=a&9Gx``s՞vmrC{8:9yբvW'O4=t?bYBSWEORs^NACCkػ CwL\ =Eom 1'``{Agc:{bn31#?n`(\` e|]7p@/}] HpmF[S5$0AU$#>3bg.5y-?#?o-׺vg&[E{>W~ؗ=̧kTI(=w4fc09K;%SNE)iQ?fVqjWsCsdb3>ۜ |{ ,WO3_ࠁwyJMSdз]xa@Ԑ3ɋspUneWw{w7鿾 k622.:`xHz>!!C: ȼTe|_7\ Uo$VV$&z]iˋ=w?Ѽh e>X.iߝ޾ӧٙub晝sqА3Fz4b ƛG}TMjb20؏yVי|D@3{PߕsOٮc4#&齗vNT`bR{5 08Q0MU0bfTؼ`C*t&*r ߺ}| wS]p5=MQ0쬹}W}Uvcm!iXŢQDLȍ "e $p?'0@z#>Ѷ jU`DtKfҗ%,4缷o[c#ϗs6u?_clgryntS/Os`:s7ggu˛5B3 2j43}3SzJ5Y4 C7^j ξ`̉-1{t)efn铻zPpIs|ΗgxO7;sC)s:f-3brwϟ6? Yز`u ޙ:fon}O0ecz[vؿrg-}3>lR ElERw.P,E^6$`)e\pm4Z U$ h,=럃 >9pop/ά^W)+~3yog4sO^BAzч޼zUtv9[y?V_QtQcuW9SY'e̞ӞTv?隣o9MfD;c/aɪS:F n$͡g׎4^Ϯm|sGKEL fe146xu' .5@&ˋ?|Yz&}M3@*  .e*;d6R@ewa_uZH~7죭Y*"!DZC{ӴX̏}8Mw7׷Ҽ}?_.ur<%79@*P 7צ2]O7'<{'ԹnwQW층 5U37Nͮ|_QLNU?3Ld{Tg>.{ RE=:mf sQo1US nӽrR~pWf̕5g:DMv~2b̞́f|觻qpuιvyb\Lj2f%3x=$"{ PGwѷ[C06`=UAP @K*e|ܖO M`OF9}۶m4"L]FUg |͜'2~<;R.߯dܗwgH7ATE.2y8v&#mN=WT_yH?4ۯ&,TU4?I0;j-?kfrWtw٦ηaꬪ۞.|ʞ?ئ8MfM=i{W{@w&= ݙtu~ggCN%+7w U{,*䩄:Tv ud۴v?%sy||.` PJRa0BYN1PLeۤ7l~X[լj `TU$!r:W9IMn]s(/>{֏{oU'{Nte8d /`P\ӟ]EŐ'w=Sg2nTq7 DU޺]r`3 T?A='X?f!h]0\9S{&!o;O+ayfGAHV;6[iuÃ?`eO`eoOF}lU &QU lb.>%gGҫglSeܩy-e?_lo> : m:k=/ Eafjbqqy*uϺΛ5lB&̧84NW2*}~/;3w 9,+'ՕM{:<*{I&۵(\筆\]lɏ=[yӽϭB`g'>/!Nn.LzgRn:"~ 7TV$SJ.w| L>ũh|p[SMvuvZ}1 ?$JݭZs.hՐ \χ[O.iWoMZ f}Rno9?.rG;d/pwӭt ]kyH[/19/5ukNM9ֽ6UUhx ?- j_C?gSAQSLїk0ݿQ ;P3OʳtgASOSL2zMvN MWMugߍ` И3=%= g:S 9 :J1$3@0{S 8oB .YlOYv0) 4p e u7[kFTd`H KGznyb塷ӕGth?*қ- o=& }ͮ9øg{HT}wg:Z${?ߙeffjNw[&1ߎO0u|vʹgw3٠{: IF}?UjW.v~tMe={v4p>4]H1|MVܼճUufXW:UgI LMRs.g>41 lǮOf4ekaݔ?xg9|@+~h9])2@D-w(e|_Oa_KH7mFDj$QU@*^{Ȕ,ߞfSۣ`M|a9~Jn;%ϲ:B/9t΃ضWWJz&}Wf6Pb2cd& <.Wmzn,z}:ya{^jw^Y9妧/1$>ΈQݜza~2:ޝu>ԗvoh~_WG;gCsz'jrS&i{zP]c{hr]=[2ag֪?+M,`-o4@J"A!jy@l,e7 yo hG;|hURFUBezP|f[ o\t辕%|">5;6}MpS'WJ 6LT*kio7wvj"b=9E =~&AW))pQ /]+PΚn8P&|8߂3r"{+.vS7/ უ7pqkto]m393O2)Uw{a,F +SI4IȘA`H`Jv@,TI&n ) ^%eOa/>!ɂHmۚUB54$WU(<]I꯷?y7Xg\tr\΍./?[ Cek!o>srk&޹Lgs>s]wt6.gSW1ߪ} G9w6: Bq}9Qr=3d }6{wHf#:} _z>p~㾯v+&HUR ?=Sws]p7ݫ4AdSߕ'S,Wq .AEPh5 1uH ! e|_0@AeK VU TUDABF\|]i2\~7xyO%2O.:y.n5|a8(޵깔hg/P[Ko`o'IN5K3PL;?[T{%qlf㞈=WMA2۹O]nW隻wvvQN)sOn*.To;+ck?&f]gg?wWoj>4; [\X|A*NA!c5e0 7*l @p eO ]e>m۬iHU&@WU N)(,s?V$ˣ{ҏp76+?9οۯϝبuMgTX ҽϯS6I=]0Y;I\z$kN꩟>$?҆&iLΒL^ش% :Hl_twl;Esfy'_)b1o >0j!AM3Cμwv+p\trN UӜ=w?| -Nj=4)#W (~p`6S:C5Fe_ƏkH~7l۶ 3uU)^4 9r{sc;|zȢ,v+O2+\腿K:SY0Ln2ȝ?ۑy[b+dNd3EEiyb6רy*z * +*rrs@OEj&3:9M^ޜfT_7G_gw(4[&+=c8g*!O6ߪ鏷cSU$痃uDHj1@i{PjfB&MmA[H5|_Əkh7F UU)U@e>iIndCu蓜^gU+hGd8/}?o#ܩCVvSCSXa5~ =0p43a~mT/zԞ/utA}{MwU ;t]fM<4ו6udJΪ&'{;Qa~~9nz'O{((sfoĹu3ZoLu{WcI kYE @!1 e| Ie;&`o۶hLt Zdyр-NlKݵۙ;ԇl9tzn;󳻳xJ/W7i}DM"|C:,IdC| $5'9}NAC=}zfn15w0[*|^oRoM==w=Ч]LAL&'y|drfjo\o|^7;73xأ>xwӧ 7ސ9E=@YqOrfˇ}TiPxO0E=8")OggS@PJJ ^RSnRSTTaWSU5|_7.߁`_gjIbAЃf$L3]9wo^zy:7ˡ6yImbg7XnDmM\ ~%r&"q]:9㻯ӷٯk$yNW Ut^SS5'{}U?WӰ瞭Us-O? _7찿H ֹ)ȝs?o{Wн+ }7>~\<_l~\l_1Nv~ ?3E0p#PMٙ`Ѕ ]"! 5|/}HM6 $UY>xv_UmLow+|~~]gM_wY| Fr Oq_3ko{Ҽmj֚=:=5?dMzOv:uQfɟT@ Lm{ӟ 0 UGNwrj;CMUGULxȩ}:ܙ*n9wvfyXޏߵ9}z6=R4@djpxU)}w\Y10]"$ e|ܦOs$p20@H]@zYjHƪD̷*nvb/ƥ4f+o==f;Ӆm>}, #~}_}i,3?~SC3rjnWuɩT٣d@L 7PW3LmrқsQƺGtq?͚?I-YZ9LkwǧG; y^9>~4Λ*|q3cPfOΐNPtzqq_7\1((E7w/'XX zCH(Fn8e7 K8#mh54dt$}.5gfCN{vQ]r!>?S>tOSzvtu'rYs T3稧iN*ɨX3SɌ[OdN"vM*kߴ1kΫg|~Ч^[QVC =eOa_Ək~ ڊZmV(eUT x~hG:yiN>COaɯ"|N/W+ *~w|&-_L"7nRs>^Me>yr+bVߝ4nz3L-{$glfVd_bT>_R>Ygߙxm;靵K;OCf?3I>2^6 p9;/>h0n 1CMV\.9_ڶ6P*HU!{>t_ч0/QoƧi2?}wuLMwg343~\@?Uf.H45SWb}]cV!k7$32Kus44 jNA -% AsJ`H"e7\_iHmچTF `T{ x|}V.^aMKO۰9k$Gc)#Fsd>g9|)#< >YWOt><7;_Z5:w|)ݝfI'0:7h}A9ۭ3J9wU9v6vQ* 7Jf"k$ٝtjh2ƒa4M^s8 ԔDu{ǭsÐ4"5ܟg;9*w @d:h4ӾMT!HKweO $~?4 p}7EkZ2 g~󾳍O 8+Ʊu8kKu.(r+~:Yaͭ}8vB͙0JONj[C}?&9{%z5#v%9;)sOW3ǽfy?ͫzԔ~aN l6'ɤM37ifݧy^l9%M'[d?'bX7q2՟+3 rY'c+xP@B^[gX\nד&1 %'2IoQx &T#RʒHe|_;D e7R|OF}mUPfT$Zx=DSGSOd?-7?1;<7[~qmd(ͦ6;kfni_"s7?ݵc+'qMϥa}T6tg!xG_~mָ[{>ϻ?7nĸQpWHM\(l*BOHi0^te|_7H}PbF|QkTM)eE.Zrmy8/>J_i{2x[bz2yU{gzW=7*ѝ33}5t! JLho^2_y/)wUNջ)Ȑ8HHstU7U_ 'w䟩; N{&.^liW1!>Ӡ3MC3 U^ц:Oʹ)Q+yPg4?f5m:#f>g64q `B-H$T0@S e|q\_Fm"5L*󰹗yg~ 㳟q=>cgǷɿi/DSN:E j+ 7_|m'?{Ϭ"k`+JgIqܻ)d.ܧJF+VhTD,ih.rf1eee'|r\"nùsv=2y:kьJ6 <5?7a&&iCJmN4C3dW vPt&= \#I^D.ʎ{f88OggS@NPJJ~'[fYOUX]`^]ZYe7 _k{# Uo$نEQ*I`H yxkfl{AE+,!N0}Z{]]l.9ڽzs:_s{fۋcɝOyuL]}lEM}*!|ݺi3ԷeO`/H73m}m[S6$QUgm0lnr_בo/zVw|&I?3 zTr"֝Ώ–% N59C|xޏt-.>G{㜸Z.fGŗSYO܅:ٻgigH \d>QUv4TNB8Ut;nz}3o5tsUpظ4idᦧIie@Mn,dprO2 dWwuze5=sl_r8oEO9H9w2' ;0ѺVEM$e|^ڻ@k:#@>fmXJe eUJNPx FOeypyn:3?9=zAE<g2ɑNrٰ=Mu6$wruم:w5OOv9d@A=A9=T]TOiwUi:&Nc~::m;l5YEOjgݟS| Qn&qzQx-MWe.!\bG  (5|_p2~\OaFZP !%H*vo83M^LQZSЧ_f7},7h|F9d{|@{+[oZ6v \ngR t0,CQbL|K,,"- eOa_K8~ jPUBH i~a-}S}gt˯%xf[F}݉7gg<8 y:㢟8"w:8nQo oH2MOS??2 SgMo~JLUٔ﷊:g?$b@{o&c8MvOʙ+m~o/җf4Yrp`Y9u5sfnf*>VM~~lPO4Ր 7󙂳|ឿZY-TtD`0NFD{btv' /-Щ!PeO\A \ Z}Zj(H"9@XL}ֳ*ނzlq,|wuzs9?=j&\}}Mv]˜N7\+OŖ=wkŐC:0n{ۦ\{;x珵⽚kmiɫÒ:L]碿gH@ yi{y^2xȝpS ̙ή4kg})7Ԩ9&w&䇪ɦLkˬO64f0Czykqq)v@l PTSO*0F^'mhSRPeoa/ǵ}JPHQkf*$QU$ Cnd > ڶf" 5TU+!l/_/}de3?͗ɡ"ۇ_߽Jp.{I*Cv3cgzێ_Svόv3zz*r*ߛu%:d> u<ܰ35Y4y&'qbIڇ}fw+F(̟TNV^ͤЏ75̩k}M(lͿzdj4Mwā![_Ωɮ .6L3.ZHA^G JHaФblon<4e72~\Oa OFmk*30*< {@=lujw/SoO~]jוP{:V>rNe˯ZI^_6'Ik}Dܽ1q]yY΢65/k2}וqj h1rN}b)luU;OAWBSvf?@Cs%(ACľS'V]@04)Wk㺙(6 < NC)NCMPZϹ+wCz$TX*4WP @ 5|]! \_Gm ՐJ$=(x ɗu|sR=b=ۇ;i{yEc8?W߫OMkDgO#qȝτҩ<瘧&N}qnr6|jr۫1n7ww\$a3PUT)k83j8CDiizq*yF:\^'dfB NU5\1YI3[Cm=L]P:)R$۽;wb޾h@ 4!HVE0 x OggS@~PJJ[i=]Z_Qe`\V_TSVe78 ھaP շm5 Ӻ&hP9h;K;8T;SڇO͌d8-oo20]{{Ը3)%=Ź*9M39Dt3Rh>P3lO%Jdgb3~;T'ė3q#Nfm}?5$vqH~+(s93놄j ok~U>pA1 ?5mwǾY}4Չg+y9XI 4}*͠) $?ZAN eOQALR߅ 8?M ۶",BU@WU!KI:cc;s3|aA}ON*'ICn%W4 99\l͞|! !,bgu7U%e?YB$YY ͿWg- ?>iT`e̞=ifGΖr񓹼3[/&A2Q( 6H0e|2>q@H6jPc >x⸷_>v8ύK\,%|?>mo2^8igo57 -%WMO4gO3 J{fSLӮYk-|W Sig|k^xL~;k|*O5{5V:_LӃ~9W. 0XQ%e2~^OF}mшP  UX"!9y9rLuݳ}T柕v'1///o\6?oď)9B\OvWI:ݝ]g\5+731= L#A:6;2K 7 S1*AL h6L{g|tVr֦pUwwˬ@W݀t_9tϱȡgo==0͚]Lt 4!1>8fa:BKhx,|)7F'Ep`eOI$_Ək0@HϤmFUS &QUL Ϫ*||Y&Le)")s#>r̋_yş퇿=3hM?3E3Tr~7wVƒ;V=ӧj|kԿ{Nw@yvՌf{&U[]C*["ژ{S+:rES]IoM m:d>]$Oޗ!z\E]zԎ>{&v^SQn" ]& qpfxfvBu3470טC8E/ $MuBtd ;)6ʲ5F')(ew/ǵ$p} jmP Iz 'xW0l}Bvdoozܚ/Xr\G$~%qt7!un51/ lԫ*ͤa2uj;>$f*$ӳ =˷Oj+s`F1[Őf g~04ub6sM_&=&}٨| ]z־ph;4T3CgeY<8SCn@ t5\O2p =P`VBE)#x4d4BU ~ ז7Y>1^H|G[nu*ar:fYf8mto5[Y2;ϾyDK23=|zئ7F]O\l $j>w*Mmn ?1i祘I<TN'ngvN7v3eʑzzmkokvqUaH35oqca{{5͎s~坻y 6^?)@Ġ 0e7 _~\8lhGTY]&WUA&kPwC/{?<+ך\<ų`Kv&&;GDO'}i/;ę_Xsg̚5șv@6p瓚LNVf; BQgO~gݓ'd?KsR̹^hqY?UwTŀjΟyِ["nNfc*sg]:+`(gM ~4h9Ӵ pMe|_ie|_'8?M ٶh[UU )tUɽdj(a%u)8.?,0vs/6.,"ѧg2C}` 6ngagνk y٠3ͮ|fLJ}gf懓~Q?onk^zWy (Ǿkʹ2kj տswj>4Qr9gGgL)Ygx C 9&qIaZ6謁a`3@v9dHɝ%Cc+pk^ |:.O9eಁExǸx]lF@B$ $$"l6]؊*0  e7_u0@2Uo$F#ڰPFUEr*ЧekKk~||U-N6QIwE]1;0NCsd4LMoýs`™ӪS]gy63>'S;;̧?yguH4&Кtf+wAtK]OnxiJw}_ʸfggڿ7˵63J/|v5"`wu /f]P=9^3^}A5U,z}%_븯`E#z2j` Rh I0M685|]>lR @"!CJN~w{c<}O؛9_gcc2$Gz<~s:Q@=COev([{)Sgf^8_XMs\scu~w.J"O;_~l/ޗI V'eg+mS\ !V1E7i5=9O{nyd'~}?ͽ(*Kc\~}'\ U̻LwLu\\ {0lYOKIRAxI$Z5hKAu$[ tOggS@PJJ.aO^X\aeVa_aUe|_kNh;GXͪ$H*>K|osnοx/d\!n7Y6Fs=5.>}l@+snvUf-2wz✆|g]wsC,&&d y|tܗdyߦzhAzQӪ,`^힄w흰3g{"yF7޸vvV]?idF$tfO'oNo0P僘5nA}~j.}z0qnf|w A/6 cҌ^/s)L5e|_Oaz#ۆVT &FUE4TeYO|~yeɊq3>~˟{f:^u4TU˽l=t^J>xsg¿jyQМï y2'ɦ$9lwl;hv%- tg6fλvw҇$[SViPu2}횢X{ OWz Mu/޻ΚpPP}'kJ ~nS?>8Nݖ~ctgWrsUTэ@e|yo0@H5Z U& AE4C`UaFUd6 MHuz'9]^4ukatVgB5mW3qMy3TzvNS2/P?eg?op=0s˕ t$3aL"r gihDvS 3T?lS$3UIwDdJyb:곟%yqQ_ j5]$.y#x䡀.蚷40]@pr n1atn \$U/c !E^=*NX䤤 A/00e _}JdS`_o6"TUU20*s/Qil:kZ7)[ɋehDO~}۷WcX;.&zf tsKy2M~ã03yK˝j`wr|~ ;mEDs3lj0+IMḙ5f0w ^V0._ MRim$f&e(z@ t!Xț\~;rswfv'md;Ϯ_xpj".;Cn _!sUbH5@"@ 5|_۷/ǵ$p? ͷ5f*5$30`~8[N;U_ϿiM>?8,g$qyEf"geı%DJzJfrHja(T>8lsn=UWESOjbꜤ&Yߤ\>'{AW&1f$qVs&]O< _31MRggoVOuo쳁[xh߆Y*d͜fk"}ϸ{+:2lm|f&w{ ֎ۃPH  8R)xBeo$cxK8H>Q߶F4TU2TD$]i:B:{Z4ӵx.}ϝU3Kqv l'?Q4Iq30뙶Lndk?dsϹ8Pn'"ԥ ҿkgJPjuE%S䕜{W54sd啻n.uivu%{0h4XIn]V삜 SLM!'YSy+69_9 v]ha0vq] hStqa%o@gEUԘSPD0@e|]o!e\@c_G۶mCMUi&WMUU>^$yS_YNb:\zcǙִt>O5.Ŵ[wUx!!*w_Gq*ڕg<,S|A]Mx3og,sq>_oqLWeWO:1!sTZ:Eø>pz~av٩&wq*v Uh&y=@9wA[0TZ$Gxk=&잙sU]u9$]7E~*clSP{y.HPakZ*%݀jQ%h2u>4JƐ(E+eOaޯHHm0UJQL" 33=S+Oola^Mgӳ$N1-i9/;@8\U ڮ&@;?DvTy^w=ՇbvҟiȜz7!6q7TsSԗ{Ùn7MTfS9ev3g̕9E^5Iհ4$qِ=7 vG]NfQ4I&b{̞>vgnnrv=5]8 FwpA>Q?8|س6n1%78V" P@e7]A3`mLTU2*AP$LVͩޜyLE8#ߞ1~xv(>s;~~i={;g/%27qdS>}]0/9I{E?dt~MgS+BݻW zq2;kG׮7O:uʸ n [Cغ{+MEgXXeMGZQ@ e|]Oa/ڿ{-fhDhU2%_Z!˅· .'G|Wy3챯L#s223.֑K&g#<}ٙOMA4Mvʛo][xEBUtv&C&\~7Opv3Ð<)rWN]_YpsCIۻ֞r*ѰKMLY~ymJ%! HDo%v(dQd!]=)e|_o`_ƏkdF|ԷVTUM/ͅz_f'}p(SqoqطRW_cwpÅ~vWW$x޾h#1;Prk"_x C gU}2`ojSgޮfrf.3.n)S8r9`8*yn.Þ>5;bNP0]PtIp>{L:;/=)Rז||H_8I=Yn[rxٟ)!& lkasw:We2GVgJC0oZϝ$椇ݨ᯿nY:ᇁVQ5o].WtVӧMQj/z^=4@*LDA5|]_u~ ѶVU%- H"Bk.F\g*!N~G_{O~}}eN{)sq ?uMe<ݺc6*ضG]~Cmy98C.pcDA3~6CPsHEyR6S(OٹV\fge%v}맛ϸ;.jLōk*ٮ"[#W[3V7~ H*>}rv TiswE} yaLװ_S_Ɵ]pj_p%P@+9%R/e|_'$p~:=em[P3L]ETkgMee2N-=iY{Tu&? I^yk7'{.yܡs Clz~f~{>H$fy&.ܛ~oS$fQfr[*RCOtIVJ.CS1?oc7):νsmm *יenxof5> Ud!#Giؚsh(q20`( )&KQ`e78 k8 Uo$G}>"BMV&>Px|%l\}tI#ww6Y֒xxlÏbwqٲ\\wƜOդz}~~jĕ*1& >0f3Nt{f ~m3= S*5əÓ닯6esqـld!>@ .l3P X'4Pe ]u_kh55L g%S(ʈE/^1|{{do&Cksf5!3M#Jώ?qMU4g ˓L=y>g1-d37ϝ5ˆ5 $}N3f~t6Iÿ |b&!lLo8??t~(:M}=P"ԩHZG n,1].Y0e|_O6~JTo$GZ6T.3*=lw5~^>D?{%ڛI6Oosr<۷j^s9s&v[7fNsCUeeΐlit'WZ =9=̙,4Py*a/:#x_NƉ18oApqg^}9!'ߎp|v 9u:췻'blOs:' xa \Ep4Y;ysļX($H@ xPec_ _F0ohZI*dPڶm-T#B՘T||8Eziwq͡Gw]?MÛ\ bU?s}xywc_ut%v2dvU>/5R~9驧ro ?9o3gTFuNT%>3YisNØ뛔`r.7? j{YlS3TMSs-w<>c6>|3=Yg{sO&odT?PqMm`ў ܀I$s;`)/FQn~il?@2fe]u~OG}mZPiAI"C`,wٞWstzkhq!=Ւ&:.4?v=:oޭkpzɁfOCt}yڐoJCRK#ή{ÞwdO%?3M< 6^IʞLtI{mW,;ɾu[OBEUm]˓ns[;EvqduYk[@&P9Y~=bv k\mb} /L4K1ɂgCrĭ"E`8` y(6m.H'Ke|_Dž~?=0@קz#@>۶m}TU(ewoPGϓU.t9>uܧܶKeٜ͆/\3|Ry>?Srr+Kֿ#N=/ta)继3U|[3SUUdU=A[z{͚T_NNu];C!=]gG\{@V>ܓߎ}3욤9df>=MD͞:yw[=wc&λ昇]lsLܰ>͙yrd|F½~8_^$U[^'yP9lL>*<=҅7FMQln@4];{[sg542|/`S WQ,4*'2tg}g{iP:M㴓pJ4lBvDT$qpe|]@/ץ~8HmD#F1 LPQE󫼑h/Nmܝ*ϯ-7i75Sʏ翗s[ PY̥IΚ(?WQO/ yzvf 7|96PC"*"^uhl893Ԝu rirjߛ4چ>]IQ1UAS3gV6[n6ax!i1L*Γ –U 'n4I 1OggS@PJJXXbdWWffTY_be|]/ǵpN̶FjR$IUT)MP>g˿yt2ٗEk|sc];yt "ρ9lud{gkuk,*:F7IGh۲j.bf{% xnԬPw=ӳiO:94;)$@[3_#=xt8߮y[ 1=w0l05Sɋ` /?q>1?d34MgMXw{4PѦ?^ll' `륀e|]ۧ Qׯpx ƶmZ۪$6kR3@#a$~wyO].h#]\53fvgJ~̹75=]O}qOdҥʪ4#uVgj?f5;+|덳Wn޻zqٓ;Z^nrQԣә]D.3lϯ@9$*w:ެN`a(_j6]Ń0=4yz>4+]{If6gMz^Ϋt RPܻt(Pjt<h#ɿ[3f1Y}k4ZKJ姈2s<ԙ 3$0k7aNP;{T\tuffX4כũ..]'󇽩sR1;Rtf;Y{}T{NAV'+aTUSI&;{W/%LUS7!OAʮ&!3+&g9)[އ?51E<ڝnL$ AES.#nMPl`JZ)` ewII/ھx6F4TisI4hyy ;gۗ~:im#7&;9oqhwԲIgFIלwyb=dC?9;ϩ!i}5de4M}]m񹦫e' І]Jxy&90zN꽎3b)rȢuI9N{kOs%PW_eX[IS@0/MMpw\ECIpzoW34cL}]?mcfqz$ؐ2>!5Ch5ؾNeOH_׵}H7}ZUIj xY=?p݈$wߖ#ۋoXCɳ?~۲G}Yr@|-`G+][Zφ;NSvI䟝}~m,_f#gzk63>B_W CƟlyf_ ]|0OwOB'~w?Y IMX&7ׇ[/x viʻeY$z wM`gblT&q>>5~g:sͨ9?DN C`0-C6=UvB̀LWxe\[{m **e߿|}+UoǣT2l8+Y{ZgRxvL0Lߪ[2 ?^{A ]YYYP[7|*ix E61b[3̠ԳYEI2) uUKT̜taMdɞʇ=Ctr1X7[Űp]<|~]Nr[̉˻!lD33$鍿{a߶,t))tjH/5uAoeF5LճG~e|]O|?:$Uo$ZLP,߅o09EtzL$󝤕ry;z1,_1cI4grSvcwS\Y :s"f$?]6,"$wv9)U$kW\91犜Ԟ{"h7P@Su$øXUSSOzf Sw=99/Hwi?[[Sgc%$f䐝dM7;yv\w_]#tiY5) %A^J 3?6|;p8tߗ\l[6jLpBP<^I7M}@}V~#&v3~cB2/UY@Gp1u2 wgan7gntveJsޣp 5nX9< dm U 0@Ѭ;?J, dY1x c $)Z.*P H B" e|_Ǎb/}\ SlmۆE$*)4;fceB}GO,}i{9}XNSNTf~lG%>!'Nj/ qk+*f][avml>s1.:T5vX}&|_[VH{aO*+ IOFe6$54@e|];@) :=3j J@RO@ߚ[u&gqhnv2??oyLt9ߋ r==pwre'ѝ,{HNbΞ!2SkU߫`UtvRq3-Cvv${^4qa̡|ڞ=yg=H{w [W=ű3e~A,gׁ4Y6>5tYsȜv[ Je̞|gkoFLwTd~ha> +`I., XeO` . psttmka&g GڳxePSGzǭ.E&9@N:ʤןQv;|0w99pT|N]y'Ns3yM+&QOiӚM̯̥֕V:Lfiz5K0ndNM)Y14 ШHcǭ􀸩#=$ӻ3|{vv}5lr{/(#to%OXT,![eO$p2~\'P@C{ iԤT"QU @z/zyܼOJ{g. w-[L8^sl5{a$ANC 4/OTdDض d5V<א ܁)gf>n;{|=Sytt4wpŁ1;=%3! Շsbƌ>${N1~aݛ5D,8zOí36L= ;p=PI $.qfuwԶ  rp %e|_o`]ƏK4@`_۶V VUietU@B&6O{V݊q鸑K|he~i/L)Й{ U=tΉ^~U!ʼofO&r:$)}eP9O_9vԉ>Ycx]>3K]H}&)D"(Ovol}v/1ų끳pLZB*{8m>808SEỎN|apx|O=/7k҂CS  w[SƿٻX[fBYdLFi@P44zgӃ Geo _z#ZJ)`TU$gȊ_2̯/廨q f{y'b\6wQث>fw.ކPOաn.{snq|53wr?^\Yd!἞跺4g2po/"l{vO9[30tڭY>{kI^Qyժ|m=ONh}숙E7f=G w0m#~/x̪ * f~p~'voξ33w1S࿳Cpwnj7 )J&?# ]QUcfe7_Ək4@F|v6UDC5Yx~A yxiK7ejq6ض|$q{v̌s8u}zɭ:K\3\4ך xe sjy\gG0S533?Kt0-buT; ] t>| UIVuA'gnuv}>P mSծ~cI5dFբi Ҡhx _Ĭ˳~êqO3_+Hh@TH90% {ng" eou $p~76LUY$ L*PZF=o< /΋]>x`>M^>ɇjҗ%='s~wk^im+Ӊ)f= 3t9v5`|ztOGU~zٔkU&ٙsY['W쌟xMs0Ps ?oז-l~Ok!9]ц9T+P+fнasp5)4v녺}O4u.1׳3.\`Q07vO`iQ_XZeO_p}7죭5 5 UJJFUū,_{(nӣ~Xr10V\ރx|v;sޑ|fMo本J_ȆL9ͅ esrP78 {g>(y ]?o,3CR~{):We]甊z鿿Bʁq~HgVՈ*ٚ8MÐ Ԟa3f31ߌwusM2S=U9`9WVrSEk}G)Q&ufp~l:brYߐL&E35v<w+,\d+ KM 9 (ۚafJ$QU0Ǧ#l? !fy%ۯe!%map|w!iԜ^.2ɤ ~o }o䙾xNMerU= EF͆:'"nuӽM-8OUA7].t3N~dޚC5[]I{@9B{"@Q䭙sZA*~\dXhygsɢtÿl& @bhdA`-nhj M5|]p/:~p? Ѷm+$&W.z6&X}|syvs85OݖCS-Q,W-w g"2 =L^?5[55gk6D|Мgvr<$r2aLf؉7}p, ,jT}\stO%NQ|9×.St{_T|!AHA9z{{bޖlA@ (6o  k0q68B@I"I H$ OggS@FPJJNX]e7$ecz#vDkPUUdAP|]K+7o|_j&ѦGٶ̕8,_x4yL9_A:N=U=g Ϭõlv@К, ̜lu1s.&6t1]83G\VV3Pplֳaž@rc.s9dh8/TEXUb W eCG In$BU% DT) ??O z~s:4,wyayvw59YR]( Nw49/>O|ݓ=Eڝc/>GR8IߑU]?#<+*y# 58uvyz>U-޳ALr>,I7j5dߚ aTibܛNiz]&i>Lm|Pg!9wn w:Mmߏԛ_3g+$fRc U9$ 0$OggS@GPJJ Ce,wnicP ABJH ި?+deejayd-0.10.0/testdeejayd/data/video_test.avi0000644000175000017500000137513211351210475017500 0ustar royroyRIFFRAVI LIST&hdrlavih8@ 3LISTtstrlstrh8vids3Z-strf((LIST^strlstrh8audsP@>strfP}@LIST,INFOISFT MEncoder dev-SVN-rUNKNOWN-4.1.2JUNK[= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][= MPlayer junk data! =][=LISTmovi01wbHTETC2I$eWaUYeUUiעjyg[mZֵk[4O9Sk ,6浭kZֶWhj>l]ZmkZֵjW{Cض2oֵkZ9Wwguln65kZֵ:f%nb#mkZֵkOo5nŎ4|ֵkZ5<;-bikZֵTnxvu[mZֵkYAn5ζ-6浭kZ֪s >%K4wl:J“G/d_xm$kZ5|iiQs-!_@|iyDFXVH,%5N*9FsIu֮kET[rhB:T$5&M5 GZb%8ouq K1ЃfˉHTETC2I$aםyzY{8]fصhZ,n8Նk=)Oe^XkUe]EjC7+©غFu5UţV1c)WMdMܵNʝA*2}A*3g&{&$#x1jae:d6ëʮms`K6uv^;60gZsY[FiZ5ll&sW5(&VlP{bYjDMޭQGT SQ`3oZ#HTETC2I$eǟ~~]Ydg6,⇰Wv`EmƵkHj&-mVkrե6 ҞfXXMiֵh:BM3|eFۉ4LFfnlᄡjdhD8کt3ڨn#r8krmn,L.F56ֵFcGS-#Mjv،!Nȉq* Q!ai$Ɓ5f9UZk=m] ir5/nHD۴"(NMpHTETC2I$i}(`9fE! ]2ֵ0Is /R)5qRkm(wfV噫Hc^5cfҘsfM;e+ӝ*5khuD-MJֆX[g5Lgrʩ*FڦK^R7H浭kZ69u\7)YWbmkZ֐ S'fPD2lCIֵhHTETC2I$iǞ~ zz 9kFEY7sqrImeX !sʕ8saIdKW^eI&y媂*pi.L=//Dgv! ⮁H rqlqSZ )hMFmRkZ5hPHf&RBǣXfZ5 ÏKŧ%kCVDe+BUi|.J(X5nIK!m#5Q8S#͢e ]MkHJWIeSlm,cMek[HTETC2I$e~(\~߂)&v) Vn#0-TֵcV2oBcJ5I&kZ+LM6V`or*#֩sFӆ>L4hk&k`Ru˚iFi5'5MkZֶ&RAǜ-!1ЭЮ)'\E=&<4k[))ꡥD75k[m$l,6Ӎ =/gltmL)k7s q#kZ5Vjb"]+eKl2lD;;|51SHTETC2I$e_ ~9&~jp(ƏpL0EZֵQE.҄%8vkHaѡq]q UZLb20e,ú4fؐUkIU oHS1%29+QkXZ0jXf㑿lZ a;G4!aTh5STL-Kk*)]c[wvfRmKfk[jIVZюdg[İr帒Iݹ\00dc0,(@@L@LXXXXXXh`hlllhhhhlllttttttllttL@G}[9eF3МA$,"V>o,y+\c !r%:= q;CvGԹX6X>&%][QxTgl:Q}6Bj.o$t[ bu\l6> ]>Ja;J򳋉\f08:gopePR珦T;^DLJۀL\oeY:+Gy&^rgvSJ1.J1>ͥc9& Z% @C=1ݡxe{|1S4T约{uRu;bxl oC̃1;7vY򗴡 ? ^}&}W0}M,6;ss(: dhpB\2aGCEntT"CmvT4Kn.E?.h$U͙E͕Bt +'*aNjjiN21&.( Tj!4;e=ø|ut}`Zگ1 Fu;钍Sqwm Wc}6Gw2kbcI"ѲLà47\]STJ&s鴰\l&yc|w&2sz8SoNMldEFK>%lHNҋgHdm?>Ξ˝>Qplv˜YTIŞU;nkJ$AgN:l,lM}>o F.oV;,3D`SgLtPĮJs98hpDs>~|ES: O..Rᱴ+eh5 O2tUL=1y6>Arq @}P|<*VMjh־?v>Tw*d+ J/+["rdCTҡ c=ӎǓa]£[6N~"A>3rmHP|p<3+|8ӣ,pttm^&*Fpћg6amXZ+`5FjgjC7kjiYFQeQ7ʖ+Ȉ6+4[6we2l1:# O0O.f "2홺JŔeszAӻ4ʜIQl٢ʑsYS\qOEPtX+S@]Z>./GG;Nj.uTVTMRV| ųSa~~pˣyaqgaoE"pfT%Trem*:op] icJ8`Jҝ3.4}`qQC."ϟr:6ܭGt~˃hG_򳮳p&xߛiEƃ+(<ݤ|,>d4+S;Ci2S&cQ*+ !r~"c#| aL4S +-ژfs!n:ej|E7O+(GG3B"<1ɲq1Z2Tl&Vޛ qЛ"QҾFT1eO C7h16Pu oUo'aB#Ke=<:n"9%oDP6 (*(ԗDyX-+D5zx|ac",>Md#ڲVhq"t96CE߉?-^O2o!QnKZ̉뻟.{ʃNjd2y _Kqv%s 6 Ly]2)-c`m܍2.4>MӓYT3ʈ|[o#P2<~drIpu\ZSAmY52eJA[SΎ\|٧U=JI;ܝ40Zi寢saN,TOᑙJi܉5 T7C\@cfeEthAEF L,u"SfdEzEJfT2'O 6[ACS #ųQ:^S5ܜyy2!]R"^ nT\ݗ Z|im.T^"ӴP|6/JTUvGd,п=dvJ O,[-0_EFiŌ#|&tJ|K\4u4ngyx+|/ȉ4yTCp tv6 뀚ec WzCF4\Лrнv44t4oB+] }6,R72=zhmpzPq=xT% 1;.4ͧj^.o 7>x]vJ72P&nlxՇ*:B".ueJdqѧaLЉ{-7&U9J:+'@tremBLv-zb*22dcb-iLsS|l fi<`XQE,^n!p=2ArtEٹH+;QQ9Qa>ұG|".hoeW"~+h4 mP2,ݙ>f2 tQṚ͝)K6|kfc9ThQ.mE1 ??2l mQq8ɛKhˆџRVҋ-IӋ,&ad2EtzlJ(N·A"f> 3DY:xo©ETe%:&nm68i:l|"ۉU8|EãmxFGc,9~FT k"FmM\E`_x2-nEQ mfG놋z:DY< Te&TYP i1\k'(C`\];t폜[iʎxlaCh'FN҇b`>.B' lT~_vΕl]áêws?>-:#}OL\q,D|J,4G,3鰈8|ng6\QcEn*?4.weJ|`tOruEeZy؄tWJϩæ/m4E#[ib8|7Q|XݏQ*, g^3 q@GͿfv $=އ@]}ꆕEPhD$",uynڋxNW_?m~(")˴.TOEs +ԡ䨎'A˲hEm%ldz)hhGhUy[C+4nu(4βU46]"" \OTE`6BO:,|2z5SڏN~J,n(DEL>8ꊴ4g|W med9Qf2"˴(OCWqSQD8|U1ў.PnT쎭3ZViϕL5vBϗ}mjpY3wA -ދYB%f8wwSzgqPdŎ:$苚 D\DEZzjlE΄OMQ8mChp̤^QcLcxKE/CvqvB/y+jd*\pg6'J6n\Y\&Fo$WlTjvoQgwvx2.(T{S7DGhaH\Z}g;ߜ?D4\ 'TE)TN, :fc!ͣFf. E%\O|vjRf\qK#)[m3KٳM=>9˃ej($slG^V:zShw5aUeSj`v>%sUk`mh O)G,C@aTig1̣46:jtJcbiN(\iFr';E]o#ґz>HG αҿh3Sx(tpў'fG :o5gI=P|Y; D󹏎gh\YuG;p84Zyå`](Ԉ(|d2c.,+.=N,ؙ4eX\Ym&cbƪi"~鏓5!˟?MX{g]jUU1˔O.m"+_EˣJvN "4ʏ+Nh6.cB- 3f֣)6][#sA3uDYg ",e;:2sg"]0x\YHOG3D\XHȹ4Ms bv~SEm VV'gӡόD>iE>QϦw?:v,4 \MR,{gZ2E4t:a<U3e"0J E \l\z@˅Őnm0/;)o rt[vӵiHigySg>;MAqi;ëȒNMOK* P;>+;Ù37R\#\v];:{Q?WW,GX)z YnhCŮ+DCe3DXE.+6'1s:pceڛn;: EuhmNq_2C㡔|E}Yjl쿥ӝo2v-R:cW&hjLx0n>\` @TEύC ӱLu)#Bh1,> FFC|Aּ#a3\X9ڇAm3b4u uo/1ˉjB?seLyHw iw>EX||E'&2앬֙(!ivή>6F TZu-=uu˟wxPSate [Q{ʇ>Id4gSe˄QPҳa5imu"؎Nvw2}ndWteFcb-v>1t5D.:3WT>}3!Pww"^v5EQEp݅_L&V6_4T2 :'lt:Ol.j}STk-6[|n'EM!âtJCe_ڳ鶎C)Ƌce˛/]QpcGi{Ɲ̸6 ʍ%;?ttP0e?Ghh|(h]*SN-v'M􈶁â,4vxzB~ SaSA1-{*,.Oqu2DX 24X|gD9~-&]M/C>^GT^d9^ӣZOgC蕶)tnkךhui/zC!vW;U 9Y3"9qQ|[n> xdz᝚5hm9YeV$-?i tΕkE F>vO t麹`9'w"|eEvٕZw7itUv;SyGcЏh?t8dG㨨 LENv"&vqxzf6!tY3N.`# 5C(G(AQI¡:&,SDceTwS.QimrLݞo&]ͼ&6v3iOӡgC4G]ZsůbWg/7x9pwo\COL׮/H3b.Q|s(uv3D(ʤaB!30(DEP%i2˦:R/vӋ~GiN>;.*Ch[geCͳ oS1V_s)L\x 2$tY.n&)qT(cF;g\}>ʩ D&d\m(t}A \_hgYeK̗?E.i>ws`:ȸȇ"BV."+, >7i3doKkt!D6s_[I5f˃߅G/V7G6͡u3'o4: T䉑ۼEG֟2åt:PiҴ΃t0 eLkf}U~g3AQ-p*z^2̔#Qx+ c+"xg6,3@v[\U9ԉ!M>8Dk:2WA'q:X.at2'mF!"RR-P7y8:nxmiz4z`;CEƌ-+͗NQ˦[lsN\C5CaEϖ<ޒhD1v2qcvfwYcqqT*\tsML t4ZXQuk2_@Z`)*î4YAhfqA ( Jvgohe˲.|gTh*jL&"sޛʦhX|-JɰG\ER(JM ?,3GxT㲍..rϠe 4M]=7ڞ"F`('t\3EMw"W>^>me M*>,AQDX3x~S,9PEJb"fz8̪)`[E]7:ҕ2O@v9Gjm>sho |YAڝ]Qo/T"yN3h6Nj ObtJU>ǵ&Ϣ,#\4osn:1.CgyYRXˣ> n* (l+&ʌ]Z;xѭ0CC/(:,Oڍhk85ջ7!d#?3Q`ju:DgKoiqT>q.D7oaޛI>訕)vEq;™thZrT:G6?Nű? D#N%+e4w& @>w z+&dNP3Pqcފx:WD.nMSEңd3G.hd%s*O3D1'@ Y@؟46~,Ý;8thx:,9pv- AU+oC N< YxQ .4[Cjzhj37ϢkC)??_gSSez"~6pdv(>:.)LN&סu-ݢ=58fҝ;CEBrk>a? Ƈx[ ]fWʈ4\>T L|6"u:퍅E}MBYw:c.7`l.cUԹ#p^cp2,;>L\0}2Of<ۙEdw87xҰڛM.vs19\D[Hxot蕷} Hصx6.<Ф.ѧ:fãk36xu2v4t[2+<|NA0EG(U3۟ qC\{V8psCEwB^]މoMɑ6;v}v ˗h賂G[xy~VZ^,=pqLa@s*_G17>eCiWQlCL6:㰈ʃl5> ̟)]fO#fs`y6f͆#]镠V~1L'@:;GmcMy:ϖ:,ў*3ŝˋ@ .z57JV"N' g/ͰS2FYXg/dtE4N7=dRȳN)CY>,> INa\6'vc+6o|ʟh Z/#쭡j%F̿*T?͈~U?;9䈋4@?>&LFXu4SHf'Al:,i6Eυx!Z0kC".:%n˳DDεuzc"IuNZLR,mO >n-GCL}Ӌ2~Qi64SY-M"LSEl*9PqNއdhMEfsb,4.-i1>rэi<\6+\` ſa(,iS٣/Ad4tg[P|FQfW6qJIʗRO}eL1a,Ӷ`L⨷ZVs>ʋi)sfaЯ7ҟiLJ*wLU4J͹E?|fe_KI>2ˌQAlRGԷx~cheʆ-&]*tc:Aa0ӕ(uQf>1ԕYgD\xzv4g>9=Pt\hgX wY:"@>%sC.;JйXRΝqfne EuInh}Zv=S|Xޓ TblŞ*hoN(tvih\*ѡfxYô m g⣕)l@Ȟi構4ŗYGH= vF"_#R:,*\d,JoTYru1mBdᲸmD#)4k).!c+Pib+FZW>ce}x5p#L^XGKS¥cM.ghWF\ʎ*fmt45=|;*-4h:1;D-5%34*KFVW lYD5у.jCh)+m&Ѭ*cʝ6SC6\;Es͗:3(nӣNѦL;2jc.mOAp5t(<:6mކoCpϴE~Zd+T[AJ:Cه:]Z[~\:[Sh| gaԦ\eФ8Յ2͵f=I=sjSw+:^ec3W)qa¥qb9iBu 01wbeXUUTfլ$!blֵk`HTETC2I$a}!~(^&5<хVV=[MV hVkAW5SlֶJSN ~9QkZ59$-Jg=[UZ@PMXbWbB Z.-f\,El[%){Z5RV@ݛUhZfisi&-C5dMcVֵUve0WYpYkhMmPARiMҥt ^nIqkŤ+B𳤳3|Tb 4 ]HTETC2I$a`~`(8b8V8:q66.XMcHfm1GB6%l֩/ܾ6b^a[58 1LڳuT$QƹgSkZF3Bs{ʄ#@ƵMhotZ^E$ ZVbF+ћ"N*YME!Evpd"͡T|ĈԐ溸 AkbֶsIս em J?TMTWP6ơ͔HTETC2I$iǟ~^~!IdK FD2LMZֶWYnf8'PF/YE+j+kתa>[ IvA;o5j\LHNm5kZ*,ˢ+$kZַ,sxUmTXQ57|j Ɨ#Q7*M kCvCMZ5HZ^J >q7#浭m=;E=}mkZjL0|I6OֵsZHTETC2I$i_~8'9&uq ][GHֶcVm(e 1'!c ˮY`KLjZ֎ =t!T,-ُR5ލ|Z$ eDܵ#[E?o j\mm洍l)aSq3*-&|I=jtوù.&鬝ّvS[(MS u5IR LhxȰ*Xk-{!@$rSάژe|lHdVm)6sZԶͱpj=JPwcS`7m6+,(آ6g5lֶv+[x2O, c4)UVmBGOI\X'1ZHTETC2I$]_~ (>.zl{ vUaATҥ+ C6Tֵaa[Dk\zKP[!^l{2FPk\][6cbl6D֍8᪁k$aYׯ uta_7"|9kW iSXж"IVV"ض" ɵ+3R:)e2S)=8\.--j]Gp* l5Un\;Jا9aHTETC2I$e((e')2G5.@Mev:tj(:EJYk\`'4Ǚ->)]+nEcoQ5gCc - J֩9VЇ,눡QHֲ-tLT铇5|5SZ&.rU}9x!6ތ#Y7*9EQ+Mpzjbk4Mkmن\" b6:pH:y9R.qFN ֹ+lEXӉ ZFkI|H(lLȰ6*FUk@کI(ryZlX\n#JRnٌe,Ry;2\"BUUmHTETC2I$e ~_~("9fjUyMWhVLֵj[ >k䠮7FU ZV\IK0kX4LyX1X$[oFŬ}pCi+њ*֪ש'(.)mk5.R]/q28rֵVIk4TmM.DMkJF'qϺfpm+ZhcF-\k ̐ո&k4DZ`! C "%l< =8DSf,p(k֠HTETC2I$az`8b8kF1˳XBFk)#bRH)QTFej!j*UcXڒ$#eɹ})ڷ@܊lfDW1ԟcЄ#U:&QZ*SXbkuԾq^"P|ٍKIT̬:/NFIvY5:.=e@ډZ5s[+C@qrR!-Ev״f$MDȭ^se:j+Tװ$O!Cb5+:ȓq+٪HTETC2I$e`( hsHMT6#Q%J5 W1r*T&kry6rGoQ#T-sMjSd- +w}nfѽ.k1hZhֵaajUqN>㕶i0v[n6jֵ ⇲:m8ڪ5b200dc+&#E Zz[ï9v[6s 4=eNh3u K[ 0T6=Sl;8\+Ů'K `lQ:E(V.,G#0t4xZ4"é`0eh l|T E"ѥc1Yfg*̛f:<\>]C"|eꙢv8\Ηalwύ>v:s˝~+XMSv4hp %wS x8eV:yrm~>iNr$ⅼg8OAi?2lkӟWN0eOUZ⿓V!2"9KٗyY,\t+=pcx(tE`Sf\6VRy1t:ULY])VDs = R|{U6]ٴfTN֘u}hg&g*'`l)e:j#jMjhiYi~i |ϝ6Z)J-At_ xT.N|M>qq(t2 daYSPj4,YAyev]̆o8*|M oT\|l&GNUB蕿?i><ev}hn|DGqtfAD:.cSh.V`9Xulmq>"xQ ^+M~|P@tעuu4xq+O6Pg |6JҘ}17c$fWU;,D?\?VzXҹ%Qt:.JA賀eTenOh\\\6zB;r>eox2:>/l}Q7rU&EّXsU83v켠5Ӕd3J#o#)\s?}!1c/EnnWt]m\m(c&*Am4h8ȴ>VS A3`J.fo4fh̥4N۬]1P_;Kø +ten"h:-Oeâg"8:ĵ.vWhva N, d2D "XCts\gAҳ2v6oGg\XL7Y]GcE\=&:,]zo8z0⡞,頷J01hs_du|H>n 2)<oݲv<; /u<>dg]XtlkÀ.߾7D 2#ƶ;H#>uE]f3FJN F4S㍋izҳh*"٥)En*z,"m\2;4iC)^ihxs}V|e)#oelkCR}VԪ\:Qa"ЧzoNCa:UsDoţxΔX#G@Ů9yzSi}i]7Aֽtch4٥Sa~ٷ\"۪S.TEؓ/ݏ4O㩴kHC$kZ&%XX=" ETw&cSGiZU0OfO@oz|fSZݟo -:#L̆"W0T6ܭaddPt0r?E؋Ctn23iYӕѣAYqL*'"F[e63󕟗F0MoApS* -xt05sa'4vy|[l<76C ."P';E|Ew&ttu癚O.ZEQ8:|q6?>QiϊJ.n@M6jV[IN*#\`>*m`9¢4Y*-ն+h*eE/> ?꣏y[>]kW\ᴡ0+\u2h A,0s!ml. < Q̘>5ȀqPg3Z|e9F+i[TXX.4첩 p7 /uT̬ڛ=".}*45њP|)Mt\gڛ(tei>ETe\9kKyQ##[~)wo"-iP;N':~4\oaqY =i#h Hd7xifMC.ni:)j:'@n:DLWT>9yUGUCɺ>AQ&.<;j7\\mͧCY*^.}W?2"ϫJVjոQpǙ':}>iE3%`*Iґ,4ϊΨJ'ChdQ*% ~de?mm[4X|7 u EHlȆu4Cŵ48f;m5!"  㥆'l|6" ˆ{7]WS:.db-,h`l(+ hm}*"fWΟ:,>3;IӗݳS*W6>.)-se&R Lf-6TFmWru#迡N>,f_{d4YNW9IqgN5L뙢:<:v[Jdsu"3E!|ZVYr4EŎꐸk>W%l 6+D6I٧saҒQ'C5ۑM۟ vcҊ>Vqj2f\zh4F@T; zq4>"Τbh+(pEB'fS/*%d4\7zGf"Ltlhve:ZV~eS?+*?4ϝsxAގ 4O|7jy 92 \.`;O?Ϛ%iAS/}=1[7GȦyeFp 6<.4Š*qrSM+'΍!qs>]}'f^;f`7iYS35jmvfT3o#'e?\a}̖NM *ocCC t\;П+Pd[.mAQ+>9<˕ZT_x6,{@);&>m2"pt&XEH\Xjdsg3a0,es*sѤEohx߯KcW8uw8u6}R:,(DǮTQU ,QJ`J5%pT"r4٨l`d3N,1zC2s:J.QA %ͧ4;zVNz7Ƹ~'賥lQ{Cl.pu6fi܉m dzf=Pd5(>+s芇st|4\2?*x7 |Eơqu\쨤>_F8.뇋|>}LNj@>:-ndE'cDh*R3-iL\<#ŝ:WFx0,|\C1At)}1!f\"qgelLP>Ϗi;nӥ[ˡ,4&(T嬞(U!s= H4",r&hQЀE \+ᩗ3YC >%aͩQVD6TYcgNZ b:.ZҒ^ 3c>$:Vj*ҝX(]."-WuCS\ӸgL?_'QM2;P6<>NqAֻR4x&ɼ]o+9Uwtvo'v\|x:M3z:;SNMkڏSL5̡:dYz@4E plir\[EϨOC*= 62,S gglf&Ѯ;sZ,sC>M+gx&(]5;J.+9B7u&S&u?kt\٩Jwhl|4CT\c`. D ¢qO"lKv豻xʕ"c4Ren"ɸ"bjUCBw9Ct4=U.fݡFc)N;fU*op]H #-OcâS"\ލVV\x6OtGQg, ˏ>ٹK:4/ZluB YoIèuqO*,ti @i) R4󝈋%⨱Njeh6;,Kˇ|åe!M͞ŏ)ӈKEeLw:wO=t\2~DT&'eQsF95V6eAr_.qvS'kFx46caalӆݚ"bҋ9؟6k6ϭ񎏭)|JK |v%d5>^s[4+ŲxH^P%SuK *u4Jj9Fʈ>Tëvz5\tݿ;֯%v_֝M?N|;)t\:FtY-p."5) +&O`\jQ\N΃oHk D[svx苟+MGeC A1l6}[a[M}74Ǚ2u~.zj'*f}aٙm ⧓T~lb=)1:,Qd 觢+AaqY2,Rne1>GŁTE#6]\>97ω|7θ;r٘|e3fګN:uj[VbGx_yD&i]עA;2Zaӧyq}BX'\YnouDxOw P_:iIʚv3GiKZd1Vhcmt@*- rM+hG˗2҃S\DNGE ;9PSGlw?K~|,ahS;۴+ҒDLb.ASJ~2\ahy\w5mB|Edžxğ9;olKYuA|W(asLEyx2LWZ׼S3!o\d7 KVGz i4Pb^'cc$q 5;o |l ?U[gXu#V!EԌ;g<ʏO56Evc}MOlD#M5~|7i.u:5QŮ.,} \`t6TrvژQ'ȍg-TݿA?(T3KeNNmOqEM a'eEEPâw"ҒCa~ZUhV:; yywhzV5GS;SA>vvFaҝA3T(ΎJfjx'EκO,v96Kc8tw 97s! P1]uY'+kNqGYuy[lҲΎXytҵ0y08v(:F,mF]2k46 ~sv6z5|63 4Ytjm6_5ÝFӋc^tp>L;=38TXǼ\!\$T\qe}x'pכc$.>;6CS1hmJ.4\ʂֹŮ\E4398:-1trG2:z: S7Tqr[c̃ѢPj3p.|7<hX|[P2qf'4tYNx U4+tw<.φ's$TyqOEO:=ՠ5̆SDwcSD?`*cAqӋFQDJ >zON$llCGιDfiYFXHt{7!~~WT厝: R1$YYCHt_hdI:=zhlMc:x. Y죡ٍn8LF'my".uR|EAPiEeEͧ;G8 3g6ӟGc% ww"£hLl]dYp59 ]6U6"w7hvr,:>2?h͏h% `꜀jMmې!>QVݧzIQa9uJ~bX|t;CGz|0|9B:FPF\Rgy[n6(v{ELFdH츝 gu oD"V%<"6|te.gRiuMӟ"EY㏋1htcNsHSZ=tt3S})Y G.W;㴼0ᩴ¨paZTuRFٿ\8u925"7&-Dt(֬ '51| N:L-zΒso+GTT?K2 %2^vů*wSOM-U[~r\:\2-4#ѣڣ>467r N.fF|v\ mhޜu!\Eh̓|=.t2")/|qEODXNS劃SxbپIf:_͓ͮF*2B繐ʷts(*v :zy;(|N)`%)OI,S)q;EEwC3>^_E?2Ec&.zuMy::-PrO\Ö́VTuKeL˃=l.1}t6TP:qm9FU2;x}~K66GŦ7}ޛyR l~yqnT+3ElIއx'Asd7V*=@͓5θ=,C;7ery:^>-]?oՅ+SvNn> HȹHa=M.#s'v4N &%oSw5+7hHSsd]y1n©>ϡfW3<9E;Ӛ|qҰu)kǝEZ蝡v\?zat~t2d2im&ʜXjotŲGmb٩BqdcbʄX;DTΩΧO|5>i*IB>&| _Zٖ> S=)qq3X mDx[3iU0o#JDGSzCh4y6Qf^ޟ4\ӠDY|'Ch̨Tym; QJn K_c@B{s7@.Xv/z@*-#<@} z0,FQ.2ޞ z2 +Låއ@LxôDCFqn\c.&*GEzY{|+JQ 8\* G|JT]2v0`..Sl`5Lҕs zfW?>\b.-pv6-[0n!Q/W>r^q;|<\z$?J. шӝc@T6g\tJ桰z5dD.Y{]_D7\xG;7xtp̕ӱ47llse~s J*f|rèDfsà?N: Ecc\\[P*"x.Mi{E]6UNs Pqsh6.AҸ۵[h :qp?˗;JmXw' L-Uåyqs0sb,5547\EϜ[O񱡭lyZgEƆC#QccܝN|E>rF+q?Ldèh%Jzlæ6"ښ", O,NGHE|⡚룏Yй[@:x-6hhY>&qpTT7YD,|x iٛ&1{sRVӲj.\R0ڳL) 0GS ndOd/I~헎ɺ;]mɁ>vSٝPD~J,d]GKΟ.J„TO0: 2N><[?u7DOXiAQ\ZRDSr|lb =p6ʕ5!BލN(\.,oD[#0p2rg4\; qsFY|mg}MLwDXʦŊ MӖt~yN>||nOiZL"m8\iXHܥ54N>_ttϚihcYM6oAC!V e.7+4Edٚ|YU)CH10Fj:t|2fĝDChJdN*p8Z ϵow!}sZ_L|x421tko*\f Z+Ep"ҝsJ畣ʁcz#}Cx=r:*FJ<9Cކ?cQFB9GV 2>Y6WNdtt5 >Xt5l =4Ccâ؝J3S\xe|))@*emh캿fGasocʢxg},?kA/Mv#FH.,cݢ-Omۻ[(s mX[ŸGF DZm'PuAƟCPD N\YCfT`e PV|4tr,<#F\Tx#'o&^ds.e<]N`nM5EpDX6'QF@LqS:R9Nv\OůKx蕳Ch4s>,?|u山s3H"f;ceԒX3Cx*x跤4U)Tv!QdP:ӳA.57R0Z~OfR#E>nҳOs>:gjH4vZ]>,O >Z9ѕg9wk%铟*u9)ET6VTEdt64SnS*M.Pj%b|REU! ;鑔ᶌ>|d-O?Q f2᭓M$tTDCFQsLjs :&%|a9u=IG:PAZL] KgASG:Q3MMA8SE!`FCxm=<*,+ku6\1qy|D\>'BҝjRyqrԃS5 |춏ߺ۪^*4?fe"{Etr*'pH4[(L.S՟JLnE\4Etv.qjѩ9\$eGz'θ~ʛjsVqҳhK R3yW\2u9/:y\<uArg :` >SYD8LOf OޔfI+/Oi܌5%;oO 7aEᷔ\4.D홗 {.NųΗyisuQBiқFiFCef 9X˫mfb52m,̋o/ޟghdatZD ytѪi[ˋKN⣴Hw,M hL.QgŖ M">VhJb5۰TkHVMtԟ>m].VZh*5ŝ4'A"T +PQ00dcBWBp@ @ @ @ @ 800dc*ћhUXIUe Q3 ݦlM-4ϜiY32ׯE ]dn:sP3,3@ nR/K0|z8k1o:هɗcFa;T=CAϜ<e2&mczfk'm٤~>4!˿5aTm tYsf9R͝9WUccGS)t]ndFw4lnkƋx1m>Bd,/C[uU󟠅9_k=ͪ|:mXݽ@nfЄ9+l{~8ҷIԧK)FoϪmۊ\Q;crqT٢-1T͇N,3\v̱TAz3aQ·g. ]y2 ;5R7YЂL{'hm+G+ɳ9o̔[,t8| 9j:~ރ\͖j|ٝar\r;G.4\vQ$j|Z;KFi.IXVl.h8:F̑Qmb`9)T+OEaś:GDE?rRyZ4s< +`;M#elgn=9[lPáqafdY@© EBiI0s".Kasy4|EYU\uBF:,3/l>$G#\ˡc hg'@؋c`#N[0V;;R *ų4]c\:gŕ4'cuŦӥJm_2|cEáO:Mˍ(鎦\6n|蕖‚&F8Q;Dؕv"-7?V<>¥e= T_Hk?O|AGpZYuex31mll~]s&٨9R|G&k# ARN>]vETcABQqqt 2\;bS\#Eɒt^;]r\o8TO]3.mޫaq]R#4~213 yrMl1]L5-Qex 9ҸDSf"4|}6\xvn>S٧1z`Y-tO۹ҳ本,#kht@>Sg*c`WZßJ)tYN˶߽DPS/<\7hfW>+GQ^'qp} U"vNѥЄXTYўM*Q2U[]-ՌޯGl NAvi_|v'0}R[>$TpB\{˱9 jTJs\&"LHUIY{\lˇ 3EeQ<}?C_v^Cd1ѭ8f%rLg1UNˉ\a 6yYrJ4Ydˏj:0wj}..ϒsIa3/F}T% o.Iscf]!E>8|EͨRQqѿ\.vVj4Gԉ;mGM>U>fio'D\c V^hb','՛2U̕RU29Ҧ}.otȞ~,"MsնH>Zs'LBo@~U?QP.`>yyeAmqَgȕ.<ύgC("E|Nu*jr;AhJt($!vyo 6SqT^aqW ps(,P菗--QV:Vj^E9&.lʢ8y..r:%c<|5'3p\LJfx_>GJ7C%vU4Ϣ.T`*>'aI5e+Sǟ {ss(Chh72k'SuһHOm\9ir2XnE:=ohЦ.0UP:3PiTj*lde4Y᚝xG*">S#3y);tJoCLp}C"h**4VvPiФ.cELT}D P|XC:C¥`}%oQ>"ń:Rkl^.we.g[l6p&,lm lårR*,J)p>njH+0] miΖu&L,x玤gh˜Z"S4IYiRNVVtv-@ MVˌQq pی]tvgg_SӜ|ȵ9:,?7|nd[LLXi]>v!|p\4>'g.iFc(~>z>S4N 7~e>ett]T/-#iʫ>aJ‡GFJ>ӋtRlimBtDs>\'Hv9ፇk#2.>Ya>,ta/SP0JcΆ`. DXeLsKiN憠 "' ,U7/ME;4&h:.Q~֏Μ>s+ ODbCDֽ*}gˇiέB{&latzeMxh)\ojC'`9t]"+WMpt\;6گykʧvA*gwjo/r>jn$fu&EoRo,haFl\;+ ѩB8Т,^Dvh).-wYu7S-t^;l,p|\1S&y/N'C dj|&:q`QZDYaԁ'rM4l-tMa } uEp2SCDk=4=2A\^Af"6\YhFhllKs%j#V+sc<'~P55bd^;,ɍyy՛Cڼai>'k2A$?6;6T )գιօosU2;\ϡ:1Bhap<q੟XM@D5Eϋ>=Phuc`ҵR'p(uD[_ݼ[3zl֛ 胬5wGYlh?:AQxdEOM)m*tVUM >7B0|[KOh \\|%)i9с \4\w `T\Tz΁٥iٓ*"-20Bh?۟Z[EJm,r4@ #tR^QjR+u3ѧ'|o[;Dt.*c$Fzav;[nߗ7==:vc!mX´ExƋx.7\Eș!rENjSh)C:!E̢pu4?".l;;м덍ɲtEfYSw&j*Qne!蓠t{9'Ó蝚mFYapqT; ښ]8u?T>R:s xhGiiYo.5')鏈ɢ-:ʋ8;@CEQ2z<~\d>9٩"QePSyZQssZjyŜVFr@|-X(hS.;8tD/A6M4Ve˸9uvڻ\ʗ7DPThPhaZC)|^cbʚkMo6T?ZeOqD4.j,l*Qb2'`M04\f*>*'Lsxų2e>\Sf"&†´\>Z.LI|xe}ʏżvhSJw(N:?8N鲡|XmHxfeLY:""΁ښcB)A<ٝ+0@D{&.Mcet4U[Wkf.\ԇ\E:T>칑;\*otAҽ5bwy ]Y=!vB(|s.uŐ`[FQfFm6<6,+*.V_rr1w$2?vgriO`c}+o!C<αV4|ͺvw铖X/Pe34gv柴of3HEO|S *oGL\NTQ}{\tq?qt:=CiE55suŮB)܂͕66GUq:EjۏIZC\GjrN4py" toC.at8 U0 ٫NLuΘETkmIpBOp5^}N}^gTyueNVSiLuTh:,:颳iuAj/>+us4sjs`&fPTYpGh ؆a"Uo ٚTCeV/4eE?>M7F;fD(􃷊EFB320<6@}1ӝ.t[js ( 4*v~RPhT~yM9ꃢfB R"7؏(2]VJ*,A~G4-E7'8E:|:&:+݄JY6<ӓ[PR4ݡ+ڒt͵WE)sX6qZ #W6tVy[`qG2rx QɁya\dt~j:)^JQj}k#ܔHΈ/iW4X٢| 0ؕ z+D[FOEU1hsd2SM\6?S+ ~sΦ8|١&M/hGOEdwh<_:?c+sL4lO;Q+ T)Ŧ-]K"sG$4kzEdr8"Dszɣôk2irω[!Jntk V OeZn|Ild5\ڝMpȼ)󊇆̼|2ψ8 s".B-@q!iXى.chl8vGRl\m+PH.+?i˟+$VRʬN,N+8Uxvm=tv%ތ:1q;?*P?Xw. >1L"viEtqRi:cc,c12,El눹bK8Ȱ#qkhGx#7d+߅\ݴ:Ub,+2̈:[zݻ]xi\M;,4KE>txᐶ\hJtX:Qc<<˕qfcMhQn 8L`n"C(3#SLY:22\\vGjBƁ+ \sI[mEjc"2dS #m4|X>>\v_;34@[?m"yF?xA(b49)=@  pG\Xaor*zQ u4} .T͝*\:$w⣡ K.<"ԛFuå#=4;S>â|T:%sXo8>Htwژg+;7c\&08|1mib>h:8D94K8Tcqt \TFU6j+t6jy\\NN3qʍ lYB^F\>\5)F|\T| fƷ~.nprmS&[Nm6L9ZS$.0[*'ceLݫ?˗:i.vaF¢53*#YvfP*H>.j'1hʧO'laJ-sEoAY>Cb-O_1:, * &9^!H-/ leևA\]'E,\z樳S@YIE7s>K,躞S%+c]1p|ڑ1lDP,ylCu(ꦉ%k.=R>yͼ]lLh0h|M:'qMfA˃?>Zm;QT;4Ȳi,Sm,åeOOMm)ȢVmϓ]~%1igffEH\XeG} M4e1soСhƇ64TN.(Lt'g5">]Z(8EhvݼPpvQ=T⡕-mB>\sDX'G)GT7T5 tٛ1N]6T`΂t|E@e.x~'ƟCJ̽qMgNQ"۫CG3զ-q6Bl;y^9ӵQ:;棶Ϙzxfe*J.MF4ŚJ> 8isZ!Nv9{TZ+zMJd?BʐkIo#T:%d?h; }G9O(FmEw;/mJ.4B;Tw ¢Me *rzMPYDYv?ͳV\EjJfCK* nWl_i҈͡( :  *]>](c-p69T'(xYy;C.tO/6գ_|D>i3lqS@hXGC4FMMFh."cÕ\fVlxTk.DHQ||6T٣NeCEڷ.1,PTuёfHVDПFxptPtA8e3p}6q:&گqQAsvbCJ.6*neM(i\s"t"V:.dqfi6cf *2X˲,T b׻M),Q)2>C_S N,MI.2kV?ƋX՗Xa$ƙ'y 11MADGY>€k;Eʌ8>~[\s$JjVPtDmuƖ;np;V6kTEFƷHgL;۟ 7TӶhQ*S:\hkƇRv}5 Ӌ1¡h tjMݧ]_3왓Sifˋs zhP|2sPl~cjȝ|IvPdrTR|(Ge3*\r.CON-S*QrIʘ:i5E|?C>.J.eI}¦ɀ] :,vdA#ޮ?xyXW?Ih4zE 4hdtO˜3xCʔ'W>ctN4X612-a˧+UP Py5J1Nh4:φ7,Bj>f .qc*cM`hm-sNl㩳."8}?ͦ?bl}$Y?'H25_])245â9g 44\J^*9ΉYF*:zi&i҆NզJ\)o J'g@hhBD[7 :,F6Yqe3ЌQuzCE xQlތHi<\js3o2~N{Pv MQ+\i<BG>"T(s4Xiu6hOLϗ:^lTEJd2Eq EYVl'E/ޙ~ǖM[Ŗ( ,YSmi,.oCgjfjV t3Ϊp\_e.ٳEϘNJCcv>,QԜ; fLسf[ Fle> |Iz-74H*O;E@eGT\2*yn:etNOeDX|z@vظev?9::-hxxY8lC%賶+MEMe4vۯ1;üc.wqo#%pe(Zc 2Vyˎ;8} v$"Ty/m3QW?m9'ˌsiص֥7H}5m P}CNjsKvTTS\>\2A ҹhM7cAoጉ\͋ !MC+ g\D00dc*@z?Gw(Ue cډˏvOhkwjdwsRߏ'ӣyE a?*P<-}NbΦ; \>Qs#3.9pa+|z7v;H;9[DT>a>62.T1 NnߙMTYٖaa:Qsl)1̆=Se4%N7Q̓n{` !v|⃠ǮΎ:.g?Uf3E42|}k4a|Z2'}sjne(:U9k l_{,T*MfgkzKV3Ss#T0D LXeh>j f:IQr8qkJfA'*W4l|*+#;P^EV3} T6Kx+Kd>4ifpRqlˆ;fzՅe;mdJ|MXm*Ub|-ף*n"٢k:  f}ک nII;<*7SPXr-cne6`7 P#oc6\N ųSpwڇiNm(8˃dM[TKǂ|LJtNGў:45\;: Fu4OvEc 0:~V53q6:]|.:m_Ceѣ,/bEW mCd}᳎t[e04ˁ6l:d\j@BtJ%6 .!&k.O+AE}= C"VNtW2"dIF"D ?ZݯVُH5frٔm?E s@ΫFh:v]*G;DrfRgT̳EΓJ-àǯas[jEPxIK.ŝF~uT٩ΝTޯox %MLeC.Fx8ʎiOWjdEñ||թѴ]{("V4 JNqb1Ifg._^\ gq:;io7}D[l.>>ڎ:>B.f\1{]3꼧?+Vqi9+?h.hC4!r.uS*VybvqEQ+A"EMERdg^|#Cge>V㰢 @T#ȶA[".#s@|[ ~uVXJDE\4N2XVF鱣ᴎь/81_h(Tx48\ >aP=-ʃ76DYT6}\>iN4Nn}"h2F2V-0\\ast|m7.Ш]^>(:+0Ȁ_v2jDl>`xϤ>w_Nz8ݞz~eS:tl-Ma]`<|Oò Q/,e>.rEGy;%,sjv99P!?nMRYT:ೇ(6Y>5WM}E;3* S{+Me%'tBqfSمGy+C㢒5w0>kxUͱ?Vly[iv|0aqmA,FtaS_.laQmF6\] /FƎ.qe$h0v.5?: EHUUN^6hv]^)dOn:SpfAeJ.6-&ˬKK*ivE΃JS46[w_Sf6"*xiƷ\=_xTJͦ;Uc3,ӣ({dfD}Ŷm kMV[2_}/ (D>}DRdYhF.QT(=PCvC]M}f>_QaV5˳ŕ &]2: VLt7a J[xά3PzWAOΣ*\0jPqRF":.Yt 4CPFOOE2ޙjm_8ʀ.EE94R/GoWk#M&kԚl[}\h ӧ*{rm(u^\K[6uA T ʠå;ziqMW48UekO&o9k=xNW74ʓ,DӪJd'=`=㩕ҵ) p6]iVeG9^G6]K "ʭFgd6K*,Jt6Xs,4jsA pxkm( \َQ3en¡;E*:xSqg*.Fɣ F;Gp4|hpl> yYJ~Nٗ 4H6vPvǟazfl.#h:>%;t`5Dvu*%TZ!q#t>"]P:m|<=WU3ף9rQyV/:P249Yh:,=CË G;h GdCNVԧ>´=nOVJfML(!Tm1Is!UѳNr[H2 rvs<l><\촦;DYP;@"-:М[?a0ld[}'dhRQs4̱ФJ# 8;+,qTQ.5EQ6F2z".aq*-U4i۲h"]O+&.L8eQ{3"_h# YJSy[g1OE h[I4>*5c`T ͛Ttgx(>c",4t6+Sk0v߹?oA99S۟34Mho+~.5ttҋ<>2:kji՟Gq-U1XƝ;FpQa:oXL*&!qMCEYiLہT>e{00E5e.44xN.B/h1jJka@nV,}~wh 9egK}e2]GԈkHuÇyDF\c2AP". BCax- 9\9TLd|XѦ2M.JmC :6ⷦ:"!RWSq;L;!DvAR8KXhuM ΟLӟ%bŚqqz0LEghzfx:h'("":>:.hx|JjmNGI7鹔IN&T&%a4QЂ,hhph6Tk̓4kO?:'mGʕ6l\j|#C RIƈ>+xpԦTqM(O:VhЇ7;q$LTq4((MWm3\wC# 4>[Tʦ "!+-~a5+趌ųy^[*!.*]|΋v=;.6+ž(T[g8| N6qg.^#O&S:mHh@ΧjR"\n|E7Oe".TE̥GͰ^.4 2s*9C"5;9iOEػ*"//C6ӰyfjrPzbQ]pUOPpB"M}b#Ĭ -y1sqe.-̃4Z@nTYښ ]]7L\oeKqs\]~VX jtCEc*?ix3̧F>,͢ދqN9ϡXt+} lOtN##>;I-/i6zD\ҟWxM٠ŧC.COmu*VlN1Ry uC nmCΉsJ*:<^we Ѣg[ja lC#Jt6q}u L*̸rX+GeZ3AZw~V[E'4ޟ<6 (T>po.A2qqv\DؕXhF;m-I-.]D4vb₥W6v^?o,MD & h2:QMX>CyE+x M+:qm7a4&7ŏaqhD\ ҇ |4X7pv2"Lz=ZeG% @iS t}\}.6T4ڇ»*OiXʢsσ;Içf aV:s;L"WcfUK3V!'*uϣC}~.{G-;. x7é&~)ч."֌hc-Q||i\ Aix6&ѣڙ[΁Nu(ChL05m+ >ǪVM=+:C'`N8O:rm9:"ټfa(QSiCGT|f:L@Z9Nu 'SAQCt^v>S;|*<+Ed?شEyAeR;jMp}7]QnHwEhfhD\ޚdESҔ\ڟR"\Ce^\ڙOL/G`,y:w1ԠGZ.e|]F˨|Eu.?뉏 Hœϣ6x6-p|>6lҪ%o}L4Lƫ _%cOu=]4IҋX.b0N.;*.}Nգ|)Seo7+x 隞&i^rufCw>4E<tb~,*j|EFTE:LML L@]0ٳ(6.Ϛ",N?AѾ:gs,7jq\30qtS` 2GCϬgi4L>G.ze]y>>r~h2㪇jE\8R ;cw|:9R9X~MӞ+M땎+i&2`\ɬ*W y!#szÙ#"۫CO.M;^v3x:-'ᓞnQtQLO6,s*S1Q@r45 @fjq8.9Ph)8\=E<|,#L3xOzsu4rá.'?;|66|:SNFˉ\SGKf2u׳ӛ,>2]^iy66 hQSHm8\?i4\} . .m*@rtiy|蓣L*G\yڎ-j  cO^V=YkEjIsV/A01kCo>sxO3Sl~v̾/R0ZQX&`=v|æuic+~A@vjx|NbBld@[H4.mD*CN.#Lw͞Cb=ê1N>#g 8ndEdv:& t4\8HQK|rɞ{sk_vUyaqTvJv;#'iϦzꔭDu1ӷfIr1yT?CEuf6OJBF>G6fߋL. 2BT_BW2G,yΤ4Xq 'QԆ;:"h g~B螹L|X:ʕNl:f#x ta*6j0DYjlev{E<5Q=T]T2r2TN(k|twhMqNH Ѧ:-9çeKŶX@;@B"ˏTET "sTfhݻlM-JJ,5cEMnC)<|u)jю_LVohASvθ%g q^Gj ?eSo 71Ō#>mpuS1S,LL*~.cldu3蝝cODhgѕO.~6QmGN@c ü$/; r 2'@Xǰ.&|.:fQjNth\2",h4Ԉi:ͽ<8@>.hQt,p?2龇H%gc%q6VyƳ0\ FQkhhi\.ݏlʢߏ'˰cPk,3AOkrltXGWobeq^DvDey\Sŭ9JP.-1>ͬhW{^- m'cۡ4!fyB[c;>:/ NmcTtk5*F~u96hRGE5>htg3sa@Eæ*8mEL|*ٴe*:3Qzc/<KEziO¢VٚGlꗋ;o2R0gD;* :Ŝ/A斕6xNz 6I>;iZ"*9tJ" l;x,4iݧ5N є\gEz%kՅjoDYO6e!.} xعJ;C Tlѭi:"fdV;y53H# ` NFEѢG\O' Gat;R"Ƌ?3ldڏKi nN]^CMQca|2nڇKx߮X=i酵8CY@ҍw!5xC7 yz"ɝp>:2Ε)ˉFyeyLA#3M>SiZJw_41ח76yҎim~,$HrJSE58mguDٜ5m5<~DhIU}|`>,;GjevTm.ͦN9ߪL2gE*0EĚbw?3'E(ƬGޚ~u>vhPY e6=*v@1ce`T7Xy]Pcb-wl݌v?i4g.L^xk83Co;~yDY"8rȋjz'S#bA4#:i[Mcg+ϗ Q6gdizۋeS;qnv'Lƴ]T Mʪf a;7S)5'(qh(2ʣLYݼ#龉/J}͡Յ̎'gShiXUtFeCx9XT};>/:gJ$s_2iRme6ijAd\΋ IspeGtOSAqTU)Nqьm d1QfL50-Azgj+ɭ XubveCS 2+Js3EnmG>-+ś@0ޚ!:D|-Nբ*ˇoDYuO!7iU͠.KlzۅM㣨tffhyEQ4Gӕryw:,cC\Ety-f>ʆTZ7QOKr'AgƘ\>TCYoҝN'MRy"憤pE;QQe>]ɏ9>UCœ ҝ|#YꋵiUL|xPdD:|\0jеu+::\ojFm#\7\5KEK,64WF|7hSuɼ7jz8l0Fo􍞶G0[G+\*n}4cTϕe2B"˪R~.f)E@MNXZU G.~.4ǝ-#vYF]̋;  Fi~O;E"#; +4Y:NFXF!Dj72d\P3b\t4.'gj<&e OY>jl} >4T4XhÝ7r/.#7e+>3, mGash.Eσf8 RPhΔTiNo.Mʃ. Myt.M8tw1XvK[KF (6R>w2t?iff>~5Y"EFO2fjlԟ;ha].O |*rC`nxCeXl˭GWcEI8u+4Mc,eTvs04U\:.,P\6FsaTtf?AQLYAMN0|SsˌEϦʁQf xb.TbvhOBFœ:OU=Pӱ. _p\Ey2nЧ"fTlQot1={Dsh1Fz|ʗV:$e*"ce>Dl:z2;lqtJK1ew<^fZ>WEA^-ʀqe };<>5>#j=Zy|Ӹ*S>TC놔YK!Ju6#KA\qzRlҳCiD00dcBWBp@ @ @ @ @ 800dc(@W'[~sSAJGcm͇l/a;yv۝8'OJuhݲ||l3J`f-Mc$0icjtnlLҴLl/:'IM s-Q ^_lu2YoDce FV0{ ieUd۶'˲QgC̗.,aᏔkxC(s͆v:w 2ZGՏM 5S:-]2DmqJ쩼 :,.]+ \>Y; iZ鏇>"բl2Kt?<.1,ZHS戸 ˁ:.Cƛ͕X y-C\4;Mtad"GWsIWʾv6z3ΟoU:n5 'm>qRRȱټm-27<[m/mԩg<*6u*Amu6eNv,u&ii-mܟ#wH~:- Eh0w%G;( &?f\E4Q.0u t[_Pq3"#]*l>;AmÏih,C+]1uJgj*3l4efiw-d%T TY]?ʔ:)ɑ̱G X;* `bϹv>;hj7vڌ:fo "y4Z3 T3~m.qG\σ٦:ECFZXiEfڂ[ŖfSlDm> E1e@2-ڛ0YQ!uƙS;7ÛFS)q]6ax s̘9֛- qyQ~)G*#\B?9kf[i:ѼFF\DciNϟyO:>./CPW>te-[T?qAcA6\kj0E> \Y`o "". +>hhʧV^LC~Ǯ'}/Xq0|sȖ{Wqߪ/΀ᱬGjãKΏݳ$Yu&vN 4nr|"rgҵͳKu3sa 6i5htT~0tg qQâW>TB;ch>"- .5D b1v}}>:r&Tvc ϧf&-VçJOtyz%7PF7TFlvEo,yO%qp',srĵK6;Sivۋs>FFJ57qiilJ)l.f\AСx) qGlaTfXe ˃`^hN1b|}6lrex03-havS<6TJƟ8a*EɢǛE0L9M: Ԉmh,3c,4(4[jD[JaSSh̹r 2&"WbsFhhRF\z«Kˌ"=͏4N^6hZ-\;%vjM"R:O DJLéX#:tiŖ`G:VTYʃz4t͋ldaR(:5WM)NvG+]t~sw LIfX+8LH5:ԩY$IEFhm M bxfs⍎xqpwj+|66U3.1?FQ`j[ɵiTcHjP[AS,5(AУJ6ڛP8.I>GtJ2EǤ`EH#(X>z-џLҢ ]Sk(0<GT;y]jVԙ*كM?]gĬFvT.b;xI%Dav];^׫ihdPҋ\Li[ChMb\*V&29P g\I<m&]߮gԝ 8|:Vd=ϑkC̀G\Xpg>cR!`F4-`e TJ+ӴځirMHنVApNmNŗz4-(JGi2)hq ai",J, 3h |A#ꈳ egf|) h8;:ouw\o}VLiᰈ50-c('slG>/2N>TQc2SR\+ȏgfm8,/̈́F^r>#۱L]ω\zjdɄѴZ*YZrX&]E=6BOK4J6+n zAlt:4S54E陠D\>Q/+j5ThhwL6,*Ҟ,)%jl Nh-TEWFqyo+qo +fc>-<:_M;\E2h"&<("+D[Fx-MC`;|Ǜ F1>DYCN:Z.l,\lfEc.cΙs*<vl{qҴYv-+?CS <*<3:=|GU%ex :oOt&8sy=a:r|wESE}(4#R..*m! \R:@rV7ap>l5T>;5ͬM/>˴ٶ6fg㪑|ŗ# Σ_!Qaa ` g}K6ٞVzC",>f[eG0.2e*T@n ^:AedS: +&EPNʎ%e-xj*> 6v3)8eF"xl蹵 !a>v^9i8RSNʡeG 5qh/.H03ŴHόoMq2 }sNR̦\B'.p53E,(THT\':pT:gtYQ@ =a[}E¦ɾr YFQe^<4Ew:QMѧk͕ෂ.g\h52.R+xxu(؝2;4^Qh|y\SDY5p&Q7(H$u h}i6&\y4Y~l#\)pʹ44=ŽDX;cE[,.] T..?JZo&uCoōKtuM̛ꭸ.*Z2[bv7:e, 'uJ:b&PEƤ0\W.%ڏ丶ߎ&atYFiOb8 Ot2L.* tW:.j+jD{,uԔޛ"%\xs"TX|Z;ѼaQ`52Ⳣ.4e:=QUHӝ)}Ϧ7uҹ΋>y;M1/"o8t72,7T4@?ʟ6\.mdV }".f4:VGǙHšyTA?4fX~dF]c/ɮ!z3g ;MNB跓EmGGyhӳE;:&H,TcePx;*AʹC>og-jx)2mHy[\5EG3"0 bjv6.ozAňC\:~.E,I:.My2ҝ]<qKEx+>Ŝ8Cd\fKCvөq\:.La7i2!>gJGeH;@>qeLCàq!ej,mtC[su1鍕(D7\,6xp>qs0Ny7k4SIs:ux%GSN,sOlEQwk2j:"(O\>&CeCEFsb*4Z_B'h6oɧunTp]y>?ϠR׆񦉽a8:?K+To"1 *h*"՚:ԈO#gpƦ9q:z"˴qNz.T=OLtLtYTGEqM%;iby3x/G蝑j~:$Nu/;cW Q̈lzt'4>TZU:bTŰV\-}9OtݝZtzYzj<5=4ECQ+~x~'K Cyk;>k_C#( +*:C(M\oJ~ʫoOs:LtN|chl 83Q pRm+>`\GPIhB'rqc[>vvڜ]o&Q4peM.}}6o6ڌTi\GDQ*|Q NҋqNXX2q}9u@|.'kє>ĮERFivE4"ts=1Ӌ*zӶˣFVz;Pbq+n 9Uw5Hظ:Y'YA;Y|wur٥V|=P+zea!;e]X5olk4*x\Ҫbt.yvF~%cLtC\٨NJo1}wDt΋@vu9рN=Ϡe:2OU4*vh tsɚ\jA렘#(B"ʇl|>"CzvLjj.%r]pWt\:1n>mCq'2.~,`>Lf/AeK:TƶRT06^c|\kFsx4:2"6dc هAD[G.2 e&;žh4ۼDwN[˗k?ʧ悎 ED> D&l.]fc(eӋR眬#ZlH踽2˃26+oZ]Sq蟄6|Z>My|˖:>*xT;4C-6ˎSmhEyNʔ>eN4:,;ɧr@0蹠ڌ=t\)Mqa&6sz3GL>Sx FҶVW\ \`btEp3ˑ٧k~< kF42:/mvmc)OMO)ע~G`ڴ:\/ERwcvhlʹn.w2|K[}rpD(ޘ.L%2 `š"N4.#Gö-.t{Ew4ߝ."mOvqі(zOm>-S;Q.4YQM/Vxv"AvޔݕdG$yl"wG᬴|Ghv"QdRSaà+sEՂALQiΧE걱gi}#Xs D\h>>hzf'VNϡg]ݍQVҙ֜鲠d-si\q@ ;Wi}n@fPv4ؕ,\:֚o .Kſ>]YϪ ^\2Vh3U;S*:RJ}2ihcvY5r~Qg.,:_#bǛaPj/T>V>1qԓEIFইT[exE$Cx9O7>/GQ;1ʁqhE=_鿃fe>n IуzvV`뚟X hH4#NƈCh4^އ'p*]3S+_|:SvO\>$Pȋ iDZXzFyY'̭6>٣6-w@ףE.\IDWػt\Qގ;Į/yc:l˕(\Z-T^o<_\f\˧OړP?-a x'AP8@17dJ@f~,|iro&Ǫku_Ibr^gh2S<iOu?igC|~Y}#6m.h,pRs*X._;-h \N`f: 0XAӳMzEâ,QC樸T:5+*c\)D\z|ϋ4M h bW(ʤ4ߪPuň"540u6as$hȨ.!l,Z6x5)Z?S">gE2h,5ʕ:F"uP:`F-r@4xtT>zWfNʉxLu_#toF2t;FXlǑ>)N,5zy;Ʒ/@L_>1?r]ZҐq6>;=t;6Qf8`)ކ҇,aT>!u8}6Bň " ‡8uh6:n9C:vEgD Eius} ˔Z56ō[."9PBs82jujJ5hCW)4*-v]F-\\'^Z-L|ʼ5twhFrBFNzn>i鸪wgCJSԴxlZ Pȋũ*,fDžJyg|}F6u>Xuo|cgj1C}qn4_4~㡷3Aqx+M.0 *)COsxF\ˣm<۰+*f[ƋM@tˢ+`vhsŷ54%J&ʜe?6Qv:XMmԋ,:Fb:n6[*#7tJQ[ %Ζt!Fm,`tK\:ӓXz,!j|}I+u3QT6G<٬@|F!\OcGdҬ|]靕OFp**:qT+;aeӚ xkʕmT:٨Ut,%tjyEƎxjsr.h)T\]˔T(,!ib,x3'jB%sq2d*]Dk*i.M̈: i]~폧>.4`S/B|Y>56mpssV"F2".*%O_سPEx> 7\k;AOL}.C 62,Y>Xu8|ZFRe&vnʘ.le&Q<ӆ趁\J? EȌӛ&>1 _Um#dyQ孶Š3"8uDY\)\`+CD]O7\wEar:L]:>gN\Ny|X@th`63@LJj/D\BxkMC6M8h045yYm5Qs*y2"!c9;>B"+yC戱ϊUeeԤE*>"4L'+oGNDYt6Sè!Ƨ:|ΗJugg4|Oυǯ/fFTv4|k?llv)Ӌf#pz"h噏+E0|:,5.s}4c@ّڹcґumsť;Nۮ eټ5@J.F WtLaqfCNZZԦh\\2xfB'etd\L'Śg &eʡ}+EKH>zld2όE|`2&豄zP`t\/mN|vp97<w)"Ѽ{hˣ>%N-vU?%~1BVГŚFuD.2J96& ӝ!䬧6Gi4]xuF-O@M6ZcvfZAu9XHO0RǓ*"ȝ N,0hE737Q:7t*Qr48;""t*lhkc6h5<"͡Џ;"eFEF'a"Mhȇêq+GEls:-lѕE;C󩝘4L^<[mSh5.TJ6.yz3|̒T24m笴6,VzsO!BrljCȓakX2"ԍNh|ABi:D\|7ћEi6eҙAˋ!> ˎ*,4 ѴEL]t2GZ梢*Vb-ϨУD@00dc'Ք_ Dg|GUu~6n:ac%Cbp9/;(_8"6G?4xoqMڍwh3fE|cEяzl۳F272:ݷ!:4`k4DEJ:'lq_)QFRl.V:\v#GSΖPOڌᰈV¢{CE*. :+8T:\'EKQvV(7,Fb.`}{M1:y=|M1I1rF΍s<Ru=̔ah8undz+㣺R+ޯ?-ލm΃dַTJ} .a+CEĨtSdV'ʕ01h|o/Lgk= 56KTk9DJ8>[hl{3M 3aAt>[$|i6b-lˣ. TvE"o@,xkCvV[)ImsEG@e8rS axw2>8mzjgkL"'a "+ЩavņYh) lCm*8*?=6F98c U9i\|ԉ\nUEFN-H9 σ(dEE&fNV7C-#ϣE-rYtqucfe<2G9՛˻*W59ꉢ)(lϔ.;3L_eiEG+ի5禃A yFhGS8=PYLwhQrmv6"EEEy͡ӕj64h)L|?TQf|XC4šu ]:V2;)֊˧ʁTGAӾex\un3,c.q[:?cŲ6OH\} >Wo:\ 9QjtC̏&Qˆ;Râ&V"Hl$CH0TZ geCk-\74hʋKhF6඙g @ >A,!Q=4OPm=*qbS@ΪuFu&oq'PTZ< ^C3KY:S,Η9z)\_c h:CP|Mi{8 |xGGT^%a%Ras$k6hfiCϳ(ĮRQSJR77JƑ &*54U}2<=:vͱ}imo:#6̪Eo SMr놷KŒue[43?KŖ#фєsN, У(TFP  Si0v'h0 (: Z=ѹ6vi< hj4*K5o̙GUp`>"\<꽚d(|amm*fնt(>5iqDяV:%A !S Dp+O\UĬAKEir&§Lt34a+*FYD3U wxkcN*4^it J\:\iP9hhJ2zt\s3G.iEaSJ0٨&Sif^dEh.Yg6CKdž͏0TXJã|^ {,0|26}>M(*ӶVj.hT\jm}3 E](P>gE㩕D\tU( ܏sELtYzꭕ7[4˫@5͟L+ʃKDwi>VIF:ɧxë}G\[Ƌ\ dһ7nc>}x} 9Y: #X x,JA$F b'i*2֧Rt4>Qҷ ͕'qҞ:\.>1>gM*lص\lsw2m-b\ >,?Q@JS> :rj&9K ReQ8\NɠvSiЏ;f>x:_Qe>A>&.j? DYQȲr0x\\ fqcZn`|fYj0D(CzNVi"afgc|Nq+d*S"C%;ax3`5uGټ*.ZX1gebM6uΝ *,)*6mFgOJSbta蝫JA&7jeIZ-f1>#,6:L+t,#HvkwitQ|AQhjIs*=pW0t2 8|\)*o#p9*z9]+TT^闗V:95۰Jy˚ L+N.xF9>QlJ1*-+h3Bh_NWFDL;:qe|_%6iQx "0R"ȹ妒ϋvx. sAf).Lҷ ~1ȳf",5Qpe:鴮4h'ɮcΗ6SllYjh* 뿮Ҷ6Skv2AtpNƗgݼ$:|Z>ҪF3`*oi#FS\8 R:DMtkBmCŶ>R4w[<ӋuB( ,M0<`&%q)ҋ.eHb.6|"G:,|_. CG4qztJلJCgOBKS)NEEy^0TTԟ6:I8}:<-btEC<\Q# `Yn]F,t .ʉYLipju W;g(ˣR.*!)\22qBᴥ CXh"ᴠtuER*z,lLNNy]4qJ\z@+N,ো L*ʎ\ iJC @LNT6 *9[|T : ?4݌ʦh2.vZ:Md#Evx誉Qs.:Sԣc4磿 XTc HQCf"㮊#Jv2NdY2c!.PF]"V(l>;}&+A>Z)<ZO˝{MC5롱cg`EOs0gQ 8.W?Lզ'@+DzP>P*, 4S21S`VǪ4(D<1PaчQl7ɚS0 ds˒sbiwwg,,t4N5 )O\vABVZr$1;Pu|[+AMq"T8edz*%Wφv/:Gjpʻc|:MMq?8u9 r24ӝæ>]lfiwz6@jSҰ5wi#o.gd' i <:]h*"jR"TۭхR+>Dfgs=>hir\>on|Qpu7чU<4):EG\[/A,E\6\6hx ͋0EXz uEgF|5Ty WMukO.%R#\ZDӽ>UFv$LȜAs@><K+g\fo&ҞqV֑⣺뜬\2&iF%*VK>ӴDXp5ݎr MTBfEα]Q:+!iJ@ %je6;GE61:Qpfu;?SC&ç,Ф =t4yNᑗy\}.cPh,ET#Cv.*qӋE6",sjQKj h-aRE dasQN,uA]eѢ=l\Pצ~̪M"vR'f@؋8TgYrc*}wS'RQQf~.jnêUz ߮L4()Vx>:4t[c}>hZehŇ/U>6qf8vκe }3}7zDXãJv>AseFSf}MCEMF6 \Eʉ[FPtYT>"Jʎh*ep#.v*+8\,@|8'SB΋hjQn:qq q+?$cLW6vhilw!Ax&^VYY:n2tk,: X2vEO/}-N9]qge4֧,FU0 dʃ30F ::GEŎÝ2EHȸdhћ܇Q{ wyht{*QQSYzFѢth4Nڏ˃C金фM}B}e*%pMO:'r"6,dE 6w9sSuZҘL:,5yr8\߁ڭ7[ѩIQY6SAC扝st^yYQ-Tm*kv[oM  4S:躡3Jnc^v TiL6b~.]>\J9<<4G׻eyt<Bvc4#04=m+5*_9[@62"^ cc*V})=;S#AX\J>_M_0..jE4:rMA^fET}SCJ‡Rx>:2)1t6v>Xp;],yi;t F7x:!sYܟswޔ1sAd 5"&F:: E7hv|x&R/P9(ˈaχAֽʃ2ggJώ'Hz*cɝ S3ydyvn Fj~iClX]v%mw8q :.A]6ņo |AQo:nֻ4.r? sGh +tލ;Yk"3CCXjx,uEL"">>n{ p6+F'sͧ (+Th:;VthEyk˕\>h".-PP^Ш?FM)SIJ)w40"+;\R敥M͚闣NzU[y4x.;- M 2zzU't\ڛ:^vS4"-rth&-M h2,suJ|Wl/h-phCDDD\u0DXԇ:%r2C.544|E/ӶڝW> !b,,Fv8ʶjeK?y45:4B1skm/nSFkmNҹ y"sv4\Ch2V<2oҙ'A3p:Za1sqY|hٗWViv7˜7ʉTCeA.MnNpşG<.2캣 iL4p` \Dg-'ul3>'jϼO:sҔhEƢEdM.,؇PJ-AgP :\.a*APâ.}MDDP΃MzDf3GԤJ (F>M34,hPcEϢڙA14ˍOE+CƳ4Y;C ŶCgBäygZmrVv6 ܦx?돆yqAz>SԣzN-M&S @EJwrW|;LĞOVx3MVLizS\X[ETS4J5<:cZOs A"j-O¢|3=!8i6l0tOYJfO.;šy^ sMGMŘ*;A . |Z":8TNת/zLr-vaP&VFzwQ6&p\42 FS\JS+'E6Ǐz}Fi,cU;C%g<)N y=pFK+)2`Q= s􀸰 )cE@;\E)ǝQo\Ba7*2aj >f>qgP< chjuO>0ŊSO>Gr+* T s*X4[10iMvx #! |1>h/ 苑: ӯ 蹤ہG\"S<:Qՙ2if74~xl¢'4+6".*a}mAt ldKa0狊bvwNQj}4u>k4"v ;1,o Y>qd^fq+]'eʪAӸw::<6G\g>m,F&V-.VmκhuM >ywiYeB?:w5:zW5)F=)w#:Ǣ=).f:Vw14gϴšWv._ʋE&T4W+GÄœҙF..0iNuQB|5!Z=2 Nˆ\f^ ӱqI:r}+y-p舃L.SRhSgAQ6$t[Qp;&QцzS&6 huN:," 6g%aˇ]ZvN6U4rJ.H֩]Tfl8t:DvHsG⹊ +hA>!Z d;A:;c [~mOT6 k)T=do+!f: ԔYʁ릣,B? U:.sIŷM蔪(:puh">ǖ iMsh˜k F4+ژT\h£?al>iZ1\vN>'q}Ir+BX|YRQ  |P=Dh;QqT>l& H4pth64?bo/GE4-3}N*v| 4ŝ)ٴg1×,:|8ی_򰩟+0|pT>fTICA.9jAPtXaLQ'g*: eE"N깹dz{D@E̪ 3\\<JT2o+}qż}(j>}* cZ|}*,: lE Fgf;0}" #D>V7Y h7ҋM jpQN,|!,4]cC}'M e}4[!ATfa$H?7ZV#M"CSڱ G_tȰV@coJEh!>Lݕ5[&(x3"wn_ .@̣1_y" ?ir #cf̸ٗu}69>?˶r2TU3/#nH/Is9\*(i:F@Σ*@"١DM6 q0Żse¼1o):+mFhtOnjnoA5ϻ*>AdZq\3e^WEDkfz%e * 0%iBTy5byŕXϥGٙ?LT.<(<qTEDNϏ+|:Cf)R\X]%ms|JPa6s&W<) ӳ8TΝsCDWp|0ZSRj,頏}O~.~<ɘP֗ΏOmgT!vT2pTE#J6d(#7lah.fۑD6>GFEW38fTNUYa*Mn6Aԁ\Yꦥ Bt|WJxT}Ot1RKŕeFFM뻶CG.V}95Twê|oxR̋aLl}OCE\8 +h'Jl3~OrbgZ m$|Jà|r>M.9pt"fn14oԎ:"/::8 hh2W )A3\e\U:+Lt"Q:m0y/T k;K}Jˣ 4CiZ"frM08=mddD[mD!¢,aͨFhn78Fc%f\E]):Õtbgafn&|le,YOe=*H*Yɕ^]s-ɅQ;mhiʊi\EQHi[ȲnFxdDYf2-NQeQ q`*:gHeoŠˑlgi>[VcςtZ Tޯ*n"k4GEĬeAMpmFeRς)gRm>wM6hTL눸ڟ5<:W2À9t"֚!2}2=5LNU~.e.Kl\+s p4\5ڼ*5 \ѡFN<6,P5t\:0\ap"vLr=|iΈ<3: FJ,ti:oRqy\l1zD/bE) JmmW`}jޫe9캏uqaT:P+d$|GjyʇCS."KB㠳WRl>iFc"cVX֓'4."˹'F:]39V6eRZ63NW[SgrTEQgG>kЌ?%XCUFIy^ެi,m)n=q'J2.4i+G͌T!fA;LˁUѼlҳaEM ;=!CVpC}ri/ 4EHTY .8JP( N4XT 1NcvFÜiw\b2fA]D7t R-~1~S㡗o_6WeA2\fP"DZ\\3>Z6D"+SVٕψF'HA@Q+T[*>;+Vi7u(NFӏa ќvU:k`STEOJxV2.?J5EmB.PxX>'J.fv%eQC#EOj.,q#m.'a>ZҜVEPŚTfR|7rY )2h.^G\S,XmJC.4>͌͢C>(H6/D3+zmtkve˗A4fi6-}Vl!+l3Iի9>-ٴ}#eEXƔ\U5 dN<=6Z'yNJ,f6fYIO[S!(bT;0 iŖ>Z0\S|ΆU;yFiX p.$@hh9\:0oF\-բc̗GsRɜҳoh-Ap:˕Ke% C(lâԄ'AQ1,Wc<'S;7>kM-ŧLYsjzmsEXeh ?(=GQ؁@%4|.PvwT4ZHeXR'eR"Y\[EpTL6%T_DXXpʼnPsCG@@Y:"|ۑ`\[|ҟmd!cL.xD[a@Du[D6ʱ9K}QseV2-5Y\iהrTXHdJx Л4[<IƹC&ͿԦ;,hu"уR4Y跃'p*hm;PE4Ϧ>Qq4^a?jJ,ht*2\"l`FH:̦j36|i%"#0|YyB-.qQzW!N?So)nѫMmL|MHD\65u".@:5ȉњrtBD*󹞞4\;Ѵ:Sh}6owgCnvyN&Lg@əD{sxD>jN.* J5á+ _2&}juG>")^ ) t  6.H4EvnYΆQSLnclGNoi4A|UG&"ԙP;:TME44`|},yg\UhZ=keO2|;-/c"j}:67G|>3ұeo$vڍ8u)8d^ Sn L+0|4F\| ,nnGh l);˥폎>Sjc>- `xNxcotOEM4E+FoXcf(xuibWTЛEhCEenzкuR~XQ.ͥԩgwNeSg;Imϔ9m3-~VN([iҔN&1)c8ȍs`H痫U孻Hݼ1T `EUAF4΃ChT\0JVl(`>-CJ̧Ep=TiFLcewTe*& ⨕: :FƼYx\<^1Ӳ LY:0\|f4<*me <\j`g"hQTfɴPj9`Lmly*.+U\D,"v:b;Ldʋ˼uX"r+*gi:mYf?vƝ:t>%me4iELGlOc&Ryx*&U,*?!JgmGZHkAZT6.B;KO=b?1ԍ~T*^EsD曫˸hЉ¢.pфRv~?zCEkPWf,\5(aRqpD؋3i2̭L.HoSh;T 2*EèSDYaZ }8<[H賶GPZwEȞd^ \ЉZћZpҵgzZ ?0TtB'sΦZv NtW&e.*T (yԧ6lP:aG*KוxAmCc䷝}. U.£MlZcZ dvB,qSx=TQLx\ML.8:zy.0⦖GP\x].CM@v;Ŗ@?1>㩏0 %[D. ڏ㗨8sўB",Pxez>I-~zGQTE0xSs'B6QqaMq†C-#O!˜ύ%WՌ܍-;7Ǜ8PIayB=kn Y*Mc@ɝ ǓLi 08t?;Leg3P:$EU8T˕f>˦>'NV>Y\fuW>ϋڛD6| ~ʅi8t4\4Z^0 mIŵ![J.(\4OʹQahlꑚo v6ijmCŝ膂LMnє2:^mfPX{E*OFTnI]Vh>]D:P)һ;=G1:\G6yyr.u(ehJuŖvXfWXͦLAVTlitƟS:%uLpq:oqq󴂪]|aDY*.L*eYꌱٗN 8_seTFUZƆF%boQT[P*m+ G2S٩M\Q>EMM4?O= b|̘iyH:v[ѸN[J5S,Bd̋ 4"VeV2he͗:2+" Nt7SC6IME{Q%8 eVټ9;uE^y-]V>\@:EJW X4TE~UtYu@Amk8UW7ueDewo,=E6+$lY9R,PD2rPO\fpBN陣N _4h_vMU2T\^Ǫ ;2, E[3 јO:,p؃AmC Ap> ݢϤh2T5f\9[5\4GZMf!oxt qhE>P0|qhb"I`VzSav<^47#rXGFDDY k3 å\Lh١<>icfʧ[IL m=pgt{A¢waL%vӲg2l44BcʬfOQԦM6\ ܦMyYT)5m@\,|N;D\6sYLU4rE4`2.[ʔGlZyҺ̹wj0ř;7jds;A@}3SKfT>vMStJmԆ0\cQe`Mqg=1F\%DY:\dLzASQt83N5 :WECQm)u2 ȵ(Nݢ:(0:r2T[Ŏ|կhPU 8|YdtZ{Sx?}wi^n|ER ̣L:33 x :^>l':fEAMgY&P o.٦?ɴx6ʚmN +.MH펍;AC鿉\ƚ'c>`LES >-Yp_h;HfhD|mt1]g*k2m4\X)2Jlүӳo:Gq:'S\ˣZ̖6,R,9D@TY7p#|~39yvm||Z.:=jnh:kY#J4뇎բ!0D˪hs-mv=.8u|sȸl1ͦ>CK[cVdWT\zcm|ZQ E4EiJ"<*ď˃[pnc),9~:-+N.Oʸ34[z@:1*,aqҝƌ(6oHv6,|EQt|\HLWx!Q`qU>Ezk3aV""6lcjzDY;Ov^n}FƧt6C%Ei/TmSѿ\^3Nb&V 2486ccφ5r:,MCX'RЌI:;MtX:b|N뵕f|#4i<"VMQ?M-4"aqch.T`t,U=Ǽ6GZC)lr4Fz'N.M(';g#o@w" m@3R4YjjS.N-;4_IFEx >ibtiYC"Ce<;-5RN'am3Ý>͂OF\N& \^DMHiޚ&\FGFʇggJƛ$^Ձp"Z=Tŝj@FуQu  ѬM* oޅ3ƋL* "GZ¨tzƹ p9^ndCh˰t8 Qæ|J!|X:Ar0"|;DQRoqfB'p4Z4Z%qQTGiN ڇFd=#ERuCeSmѤhQVEOEAʣJwED>O7ҙ:\?"۴hɦk|\Tt&|6hlQɮ}+}tʉҍexm*6ykdJi*.MOkHml\*,hs:>Ϧ86n1>.9Px>,btsGdaQs@.%s j+jBqf NP00dc'@>˫֗oUʺR«V`v<=X衾>"s%B;jfll,Oäg;eB; :+PVp/s)Χ\*. S@}s31کG,Ofu4EGڍ\eܳ*]l6'g>=1?mҲ2LQslC +\" *"iNb$GxT 2l"^@e:ƙyGKA΃bݘz0C,@qFitrŖl-VzPeGNų(<>iscd4~VY.l.QoӋLJ-z; AJ,oτG>5tp( N,oĮ@Ӌ9O9qs|@T͎s̈߀qcdұ|ۮYYDCmDx|X^ Xh'Sѡw^:x;P+*cmV5W /¨*ɭl}MҷL2uqD"*ˑZ/:]:T|DžŮ;򹓆UƗ|tS&x6۟!E: xJڛ4NΆAT>megU6hXtXT[myAo۴N>EŃ d Mv\>:>4[l46s/8 ٳ}'@)ONGh47Czz,tAh6aD !~t sqLS6ΖcT1˓oh } {M6=Shv`m@ϊQh\h>qr QұS !4a4Tt'N-ux?Bt%-pv0.dZکޝ-J29Uel2|CWGis[PdOط: l\{ۣFӋ'@m>,tq ZRҵ>zʩWe=sPB"tW̓*+||xT쨋(>]8ҝg7hxe>56󳲣C⨋*Ts՟,;M͎Gfݡ8Z+'Q=|E/;QlSEEͥ:VzItcbMxfW?r.>WT.SkNt]?NW7 joشʝ˟T6sb>*.RӉ%XˏGŮVjiŌO<^+t<ܦ?P6zm)avO<_JYB.z6^WFy[l 6+=u0t2v44i[6¼h'iXQ"KTs5hG#Q"ahD] 4NCL o>⚫- aT˕;VjՆVi4ϱma3ӾQHٚt*FEQ]\Zn>n8ћѧXf [Jɳ͑~)lqRoǀm"9+C`+l_ӢQhϹ+ՅLžky~"ѡ`SmGn$8>aa$`3}a .&=L,*QiF]1;t2L rR qq *"VU6'lgu鸈uCY0ui -=QQՀeZ^jp](S-ѪYz5͛|&t>cڟ+Vref&L:S`aZcD@WY^v=f:G&|bn|3>f̹s1;XRuTPQPk(s>4hvBo+W>xy^egr{Ru\8La: xliŌH2>< ]eQ2FAarDų*Je*"uYfmq\_\:ttZ"U6N؋+.Z-F|ʣvuo-56qv/z;.oLO>HuY2l.Z,^~[J-}FRftf+9T۽MiJ~SL㡞|xOi%bl%TFD#`; (^gHJɧOgE><"N.FEiIiūj52!o>YeЬlY2(NL7U!¦xˋcH8m+&7̪:s?)6CX0}3h" .#*kexphiL-el|?t隍 'PvZuAqp"ѣCҋg s*+ O+~?z:tCQ}?ų̄ vM]&==]rA;goUugYFX4#EwqqqYAֱeUm"ƆhX}".&g|`>Tw+A:QӱB:+D:i4EtXLEѠOCՠqEȴ 7geQPꇋoZ4l\TT\v|>1bHJ~U';9}d:M(FC)=;GڟCJâp.p:ATW%xQf]Y΋#qTD[eǟ( <SJuFTEx.(&5Ʀ] +FЎ} XmF ĬFsh}NMmE-Tlpuǂh-mx,^字F'J5,rl'Sh.LH-)Ťaqfv>+EImfzw3B2fK?'A.66i􎟱;Q\ˣ 똩w(F2WkGTCpG؋ :qkHfYho tS iL?TJ)k+(6fFD8}3a2x8|N:e |h(J2.SN,QcҁшmTm:0Pra=\^Tx,Ly^vQ`va2K[SegSGuC4Qhej3CΗfm\/ň.:fS+W=TCL5&&J3,tW;S|q#թL? Llz3jo:.A0D\[G(bkˢ.#D\:h|p&w- |~;G`UO}&W9SE}7̸xk}O@(^.AIER;QE\?SvB%hlT9EDO\6#EǙpjg<`R_X:F?Q+'@sp0):.44Lhѣ\?Eg4N>+ÏBeae7YƃHr$YЋdF ?\ѕrO&:|趕Tf$Xx4,S*:f6TÒ|i'*~Zgȍ|iuٷuyp-|M'h38)g}wDXҙf9\U%2F\Ku燙MڇGKų Ty᭩˗>gQbσAqR>Cō|{AӕWG649ꢣl ʚSfŜ&jq6fXm7Tx:i>)yE1Jv:Ί}88hx*"Ɔic Z(x4eQ5)EU66,h4>TasZ#=ltRa/M#B p DL:.<>gN@N*?ѠۇHP:Rl+*m"~Z*կ̣mD&DGTvuT': 4ȸ,Y5ѤA=>`?P"vsR\tGE#ݡ1,ee\E2syLuL\kgjwQl N<`GLYw0:0;lx,",ap8ܥtE͡Fa:n#ETB\wmgCVEOZ7}Ԋ\zwUFqf TP7G)R=dnNZ}7m_4I"\igUZߟ7V4].q:-UCfAc(cZ R\ŇQzr N,u"Б3a^=~N Jl>T2๎Y`bjtxPdjʢ=A0T>-ͧxSS>)BtNRE(E5v ]k;PoEʅXޒh8TtY }(!ظBшM23h^t >'hf2&6Qjâ slW.nq'uӷy{lq;SA:]skTʮ[';&6rSAsho6yqf]iQ[K5vi&f N̺8K;ptz=S2}զ4XN2vE'; :4{=`:,cˎxL:mpE.qQ\ Djwc`lM:qkL) EJ.lx(DUgCjL|k2M(c*heƳˣ>Qc+3bG|e AMA΃15APX|{/Ga|xte-UL:^//J©LtP|"]+LFlڧ٦ʕ9u}BIlwU?\6TEC&V^SiA\Tڙ=Z"ój~i w%z:ȋuR=Q8 xTFHhQB,pRS߮VTb..CE TXep'"C`Ea9O O5¬򋋧b,Eʪm,|"Zcpz^Nw:2s_RҷE^ϸz>+Z͗ҋ 6cQc@Ȫb8>'S@j1Ҳu8.4Z MT\2nq3nxĬC'ũfSW0Fp9+. vwJ4C[e#,#V[Dڙ :U9 V\xnQ<8Ge-j0^X.ti6D}&qi|1 hs?gHR,7Tm&[OEr؛?3Ѡv~(>6 h&bmUb?@yԏ+4`;Ch*QsBcazc`E͢˕(DJ8*zm+B0Vvh ƃ9Lqlf3JVȪ}"}]y:熆~Ձ}Ϧ͢*&kGpK,t[P?iX?ô?aeR>.:ҵ@"q`(u@-nF[e55loRdƶ}:m)2/h:?T۵:˃,p㞌lF\\pщ؍4)'OtdӴ}LEW3>>9rM1:fX\vm2vӵ|V;7S9ʄNG2u<2n%E7 Y:fr@iΎ[,+xgEL|2EgLb. 6v ͞qb4X.)tf\:͋ҳapl|3 Gi|}L_5M7O|8!9r;~}ao)sgG-z,wgK'q[C*,ES&h %*"Ζ‚leYh:@J, xU4;G(|[)pm;v`CFpE(ƣ bԣJw"q:"l64j4EH6w4hrI(ex\m j42I˃bx :" TK0;(&&C t4,zs+q0: F .&t`(w 4~zlр.;. ㈐q;NDžS#_T:4y FLہ2]ӛ3es跃Jl59v /+f]a)Q<ةDXE:5ȞLtYryŞ6Y4)\g MtND0"'x;̜Eϣ*4uF|Nidz @ȋ,eLƦ .0|F)\9t(f2,42"j!CmtF>2.TO\: 6eHY;CE|N#ɲEH+tXMM&lF-2*ZeKh2gE?}?#1m&iيD*Oyfjé7\ETQ?x۰(9qAqR\0eu>N= NϘ~e ;Yf P@X:&4>"熋7*,>jz͢V |Z4xgxfV'\W4g+:%ũzNVȕeaTq\ OE.s< LC^Vhb ̺xlсpR Xϡw?s!zm86O υ5 F+Yfcn*J"]6ڐvEg6 f:An'H蹠m6a6>YŨ};4ZxLϪke ̏:jSzi4Y7>, J,5&qqpH.t:69R=m+6u?Tۿe/򫎌J{[}([s*.zl<:_[DaT:W:TN6ԜQ*yCɮ3iQVSGh-uQ,JhFT*OE!m 4}F& 7R'R"&\t.gXJZ_ ?LDNU$ _s谚5LY}NG5LEaEʎ>"*73sE oMgžEԆY>m7Skc1r taϊԢ̘eP\{||X^QhGuQXMiQ-lD6re?Lqop(3Ep:%e*f\O6[k:Oc.L:f6cMMJmN+ Q.6&2z+"Ļi}^-}*1(FT%bEc4ghxPNʣ/Q+Qlj*?67 Mèx |tTI'S=ciLV^: Tuz 1)Lv2 6L/YK1aؙ> 0豥V L:qSC#ũӟl!Ejxa>,"YVeL'£Yt+n{.ѵa4w6}6zUfTUZ—?YM.?b3јGyYQ٠6Rt"PCњaԄD.&jwtZmItޏLu!}0\~|N٢R#XT\tӋjCrX}(e%J. HF\= rrHNj@ elzPM-VSm6.m/mUtQ:<N&U2.@"CMLLʟzwjPE˕LtQ[|IeDi\iϞO: qtT(dd:MEʤ".s!Z] nSebkr#>D<~ϗ8KgyceSh>qeia~,.#v"oSS&8سhfFi{SF)KF/UM*.ERQ ٩.72'Ie:mڬNyɮjMYN:8)VAԈVZR\d:\*Ԡh>P00dcBWBp@ @ @ @ @ 800dc(@^g㾩lr3s]vPʓ&逨:n(\훧˳)yE:(ltQϪBȏmsg9p03DGZ#|J: x2)8]3> zlǮl*m -A[hl4v\Eh0: d؞l6'݂ T;>T4[SŇgZ)NV t:rtF W2U󡎗J:*M.eAtE(. ԃ<4D㷦h٢dy˕/¦Gn8T Yh;V>Zkmv39~!gYRv~U26BsN [àcMd|E?kn%*GECg_2LqÛJ=?]6ge[.P|:AsR7lYۮ7^p6eP0Rlf0,oETg-m'Q>}*cE1v6FN;QQ0kGU'^5fGV>Ӳϕt\ݥwo͆RMLXTej||hr:`K-D:Hsᚏqj|xF",jC-q]ch4_CxOE4P|0t_]diIG֖y _x=O1|7~q>;T>ߴ * >.gt\*Qn#ChiDz!~^+T>I<[.~mr"%ۛEAgC+B5TEx0@>FD ,찀 53Gx*'H=ZmO4|)lXD#lT4g:Q?ε}+nmgPeP׃%s*+s8i+*m5P+~2 @sS΢-tM>2VǟMeҍe 9Ry SCh{lߴE5!WR?8X,6*˕zߎ+u'@X%k[a.wCYS'vis-E*>:# 8֟1(S+-pʁa47#qxN dc*S32arcptPTEѴlE AQiF*,.UQ)mgJ$J;Ʌ.T>T\QZc㎄KX΁ A^,Fo"yV'u:V͝1n2DNWl* F NAY>Rnåhd0xK,هлKQe拐;ЍY.xd2c8:&.jhfR+ H N&"P h|oT>˔\o t~g\E8,l*<}dȮyVĮӼ^'vlޯyiv[Lclûs wUzέUFE_bQ˲fYjatXgh<[,|yX>\Sq ꟬p{\e9j~6vfM:oƷ-2Bڛ\Shd 5 ,xN.=>LJ2PtE\\ \Vi0hvߞڊ3h71egCatm+.0 [Q|+EЈFٗ EiFCh:-2Oh4[bflYJ,12RdCCA.= ȿ|?;\aӯLP|<\5aap3A.4o&򋈕鐩Cw#q:#fL6ˇS#PFt~gDG+}W3IN41 2j&&q:b0mI7 tHt˪qօV)3D{fzi~}7xϥڛgYVLwGwOErFb 鑡v_@ȋ/Ne%;>0gM?*D:u dxD\:\! }Ӌ,hI٨tkcKݠz\\LxM3.C*,,~{YP.o)JE&tIιnsS;-zTQ|..S9bt>NR :Sirae4]s2eimU􌨴qq!pp .V;ES:2ʫ4q6oIm;'|p ( r"Ch(ۼEhNC qG)., Q`5LmUq}Tg4])eEHȸԤEhQ&Ta]66,>R-ω:A#\;GoOڇ>ڑ+NEŃ .'N-Gv{A"٢ D\)I\m@'œ|i"6/c\f,mb˦WӕjԪf"مɎ˔*qsz-́>-Fz4\>- I*vZh,ivh(DYT3nQa98k?uRi4흡:D8.8HbD\XOEQ^"`fEdYJF9Fz4>[u5>ii>2fԃs x_fWTʲtXTl\52C¤|[6y>W2*M;JOE͠8;E+jBqe4C5u::*":xo U3N.D. '=q6\56x+rt%pMc!lm3#H+jv7L|F}z} ¨4|:lHuPx7haF+_8֔nyNaS7ʀz>;=7y4ZDy4{R=;Qݢ-}1>r~xڏ;d |N] EiΎqń<CLˡvrс7t9~.gfWkL:<{Bnw63xouy*QA 47 |Xz4irQt©!l+Q'eMŴ٠>S\S6V3T6BNdZi珡kxNWt<\> a txe`;3"_Qނ + 2v(Ԋ,?>DͲvgӶsKΘ<4Y2C@* h+7yEP]ÇgVa,Lô;F-NThsѵ6pȝ2;DZ.T.:GE})*dl^gQ+OϐybKIF:drl|:#M;fx3:,.eiL"+'K\SS¢̨S.f\4 cj~u*vgh|gE .-*xPt[+S9W:%mFG ˄EEhP5P,|Le gΩ[蹳D>FY`adiNޏ+E"ˮq\mgiS,<"\>hv8sb.B+rmYt㭣Oh.h7 Ut("~.o- WZbMvWQtS:LgNts@"~|9k5QrfxBo¤Ś F>.J͞008EQr>DVm8e=gOɗhGqAm2 ݎ8{)Sf#ȱ,g0C`Joh#y*8u \Js>=P~!qgesDN "o>|ràv #DkaTU)9Xz2Pb&s.ꈸ&*h8{SoG:zʦK vnXm2Eޘ}6ތS?M"oAu :oM)`~ӝrhU'g{œhF.B0hj0Mǰ)\zyO\SE iC6˻};jh;PQa@mIō|7} l:|Tap:cΑ=S\X4 TXoMWi햨Kךnd&w2קsq"M;El\C{*1eZQg4;:'eYJ΅:⌢b X>oQB`iI"l&EB:E΋. Cb,} 78u/ՙJʏim/\۲V6WXG懋t?C\V'-3ӸM! gEJ͢i*,FRqpFiN֌0ҹA4z'DYyf2ɞ.C2"ü7hpuǹ:+f厍Qqp\i3—W~5ܕ}&R^}Q2fNs9[n蝏MͩheQ\j}<[ճ#5ʋvh{'S'˥sP\ 6e9i󋖚areQO\IVS\P|S(5ɢ^;_ >cx%p(7Ӳ@:Ig|&\\f;,e> Γ*'B!grM 'Fpю2Qph*!v Y~хEj}Mu)z vX0oF?5Kpk,|{Ν>\o.쩓U^˯Uqջ=rsx dp3e@?)m#A?95&|?ڀ,c\T^P9ɚHM TZvDr~3 #q՞Mq S)T;,t3KE x+SJvr>Tvs+˝úGY>0t[EAEQ;/3g[c25 1s\\|<6ң爲ypfh6!OFAh|>ʔ\.S"3 \X¤P%KeEz[[h mrI\l?:ਟC̐?R; oXstǺ|˗\+c4qFh>WODg4XG|ąX aak;PuWQFÝLcFJVmaGN +M̛vʉR r?<|2gx|l."۫F[ETd`!TNݽADXLm3.qp٣"3ď{t>$>!;0'ǟO8˕6}&8>Rr@T2v\yW.eSG6Ma.Fް9ã=sC0O,}a=zн/Vd[l|Ң_8U?aW)ϧBqզk1>:Qg4@]C͞2|m2 RFApAgLf>r +\u3SD5%VG:EmQ:%fx:w(oJ|L?4\q^~wGJ|ge4o5odJ ➞gbT,цW4e}9ҋSi uŝT;z1X_h\X0|dqe6=Q^qa`!Φ˔\>;A"9\NGءZjm2&칲y,Tft3T;AszzjJ,ҙ?df?;MJvxo*XYt $6uFFxM }ml4;opt/P|XtxLE8ˇje*Qej(ecSѣ:. a#b.pWQl3GY4%dwPJ1>Slgр}e (4+V3UgiJ,nG*g×`苚Bf =+˃*là7lth~V\f¤:sZZW0;,[!BmVh=1.exs3oyr>L60GQ6 g=>h2{zU;Jv*,uLIjOCm+]uB #+MEK!@LrkvƧG!FCb@ 7`T;?ߎШΧP#Zvhڔr_pQ@486'c(sD*6iVb=^6zxjiY?ɢs$xeK|NAVOHh fCLu[M:m)l\>&Q2L4*?+Mv>; 6+> h/8طٽ[L7Q c`#*\N5ָvm٠eaT2`6A7y~hejj2vюs@a4.Wx̚Dg*OTw\23(N@NM`D5LL.$*,j*0뜮0|qotr> ʆꎋ>243L){o6:qlyfxDt".> EpEpvT fl]L xkm1yჭtG3SgxQ1~=\wP؁ \T"gcit6'o!@xTXz@1qm,xJ,Bi-r?KRtʖ'L&}<{TL5g>G\ LlyұLԧ<@Eevs~0etEZ: 3ʠJ΢VӲ[Q<;}-A >DY@ɺCE'œAs;As(T:6u92ol tJ*eF.m*^\|YڌҨ[vЏh\fц:S<6xl TefW;M4,|żSMt٦\.E+SxPCc-~Kh)|Zɚ-Mb,  %s虓M].ʔ:VB.1p.1騿9\9qY4eCNaAtT \*rtE>.:2p>ZԔX]L#(فW&ф[y>R$qf*ȸKBԡU9R:,๡qhicNw29丵a:9W|8Fhx9pj#)tC-5bOKʓO$O߿P}(Cdm2#YSFERsiX %ʏ(2Iݼ?}RoCs VqH:`c q+-eܟt\᭹і&Qqv }њFE#bXzV84NU, *2Ӹf>reD\ya-c΢1qTH\5}e>o~Z~΋.]MOS,2{QwC4~"˄6Ұʟ ?uST馏\6ͱÞaqa;'"j|"'Š"<5ƥ\5-&iſ8S>"9ԦPdi_:v҆ʉ| LEzqӋ;6hGh{D\AN6k {Eb̜01wbNF,je0J7[4klg%]&1}Zqұ9IJxIDX/5@HTETC2I$a~8`hfQ'F2xh#f9Vm \ 4M5#Zƴ "qҔ6p$#bm3>$ga6@y&쵬#Z@ʸ s%$8kOjjȨ|ݙդB86ֵk@D۱:˘/jb;vcFvTE*PFEW5&1bcՌh*f-5$7SZ&k+Z*Bƪ1L5;M@ۍlYhHTETC2I$e z ȥ9J)MšƵk,FmM;PZ"VՃҞ]cN ]SMi6 hRpúۅAq8-,ۗ¬md%7Z4 d1*ҟkR浶l2Oem䬶W!j1V"Δ"Yk%BRMHhJKh M5)DkRTR/XqZj7lAc9 CBۇ5#F@HTETC2I$i߆(]~(*X&[G2L2jVm".{(2C&1u6PɚYjK6WkZmA#$8p5meq*V5Tҵi5 vr/xیklٯs\tiTxZc[6U/,<-M4 kZ5z0ICcf36j[E4B %&5kZֹy*tg$T\qsZ֕V_9ݑm$ زEȫfdHTETC2I$ez ~`! eӗ7ͪA8[7.̍\qSjRFㆎSBמ,[>^-oBrtֵ"X:YGYn.Դ-$XDydEMiޱSTFl,@eXPZ#dE٦fڳ|A: cԥl[CÛ6k[M}h PYV+l>γ.F*En()-[]P#X6vɆ!D#lɕ,R4.WTHTETC2I$e_~yZm]~(&Yn XZֵ2fl$vkZF_q'7kZִlω)l\2@&oֵi[ z66UZ2\kZд˻k\EoM,DE?sZn)Rms֖ѕt~ֵkZ@+3+mmkZֱjO gVxmZֵcb3I[NXXmF𕻬 "GV"l+[\.&5̤[wֵS[HTETC2I$e߁^ǚn~$2QXTF[mZֵkY[) c¬浭kZ֨~z@ !kTkZֵUR۫;TuHgS;v]ۊh@QuX0s\hՔr&҅>nFkV;ϭ |5kZKztG"Tjm kZֵF0qZ5kTdTALpI8B汯kZ*aXUe ѐkT֑aec *Տ٭kZHTETC2I$a_~"`8)U-$xdi P֭Y:EnUРn%Pw`kۡKbl[BjT8v,deF7.mcNg)k^u頙cHu8^kXЯܱ БK&쎪iަ'Fi)\cV%I|[HީSRV;Qđ0i 4 Vh`bP׈R9.{`r*H6ScnIMk[7530AkI,LSk HTETC2I$a_~~IbƏMoveqVpֵ+F%m MheD^xa-&GU-fJ ]^uepRkr>]eœ;IڳZ i`mNTy@⨭!BNʛhQ]LkZ6IFEɢDT0RIjqVkD-n!9MjխM&5:J^yRXqk12tEt[JڲGNt)THTETC2I$e_ ((!If檲󄔆s\iIZ͠3Tlp M0ƫ6V!mp]K`mkHָ$%6*0Sֲ&l.I)~_gU'&q:Re4S[s(2pzMVkZָRٶj VeXZZFQl%jmmL9cRd4f3:E*( EXҵJcj"htusdcŤRF)֩HTETC2I$e` (kjȺN9fMFZYkT:*BdHoHĶHѩi-fr#JZeO@ZO0mԉۑZcwDT2Dm FkcI6nji#1iٍjպ .#*D=Gm+۵ɂ00dc$)$UѹLwu:.4U1ˡ[uʜN,]f4r.:Z^LRпtoE ͦ3SF6 zV(\;3n`P ћ1 ,n+Dy:'Jt6dTAcAYTELN,SM@f~Cʌ:Lm<]m'TZQj#E6 (GhlEJw *(?} M3͕:ZUwn!pʦ9j2ܯL7leWұIhb4L`3#JƑ5b%h&]aeOYV{^j-CJ/x"4Ǵqkh2\|{XL?a4:cQ*5u49[{M)Ev|qpp|.Ivijg;#6aR1 ʈ"Z[ /)w6KYMRk<E8KUt)B3Ŕ#A3wt<[2 2:v>Tv`ȶ|#!\8'cEJvÍmŋwCJuZM'A>yOS"ocښYk +Jߙ).kOSBaz.T+oY*p! PٲKt9Jdv;/=m3cӫ% 3^2dHhtX:fS&.3Y٣Vζ>++>EƃemZ>-D@ȷ\6G@\Ϩ^vo/ib?gsW*$J.CFZ(WGHʙҶQSFưʍ7qLd4y\Th]COL_]x9ϵ{ eLL db0 '+,_yOF|՛@u`e oU2Qg5Zh--4}t̝ }8Ehj.'gjyvJ.dMK@0QO)2h5"vX\Tjӕ߳yQ>U3k:Nj.KPvč_|ilnnp˧Wra\Qi2pm 3GeI9j5cGD6*19*\"tUa?7}2-h8l}!p+shp|o_YlNf9 mFb&ҝÅPN-TZ-h8kh1 3Btt> 7>Dl>>̢tE֣1ԸYd\ɅNiTF?2uPTɶ>c ~Y Fi;n>F9T:"ۢv̓qSU}3s \\TsN4&Z':t"rBH yM `:FmdjDȋ"|~jn9Z|X]F:ގ,E1˗NVƴeꊢ/]]vcR C\ˢp|6 eVq5o7iENq}qeڛ4:hzc4-GXC"@eâ,Z'§b=Pe;Ĭ7jxӶvGg:-(7РT{Qf!,pW[DYˏg];@y;*y .j͢ XmV^\͇:-rcTtEу \E]:[h12aq#$Okv\)uXve(LԦH. C Ψ#N5}5pDJKf'cAVh6[7S蚬9m8J[6[M&mv44*QltPqqV\h*=yvpm)K>jg+m/\Ύ_eˠ v7T:'sF :,2hjKp.jAT݅˘+EZ.(~-+Qق . m/9Z"6o:Z@x>V QQW ,E Qz"v)D<揁xl:^*?-&ԉc%r vpF^g^6̖lLDCh."* QS.-LtN.1VӾT}+o꣙A2`Nb}4EySTElj:%;$1Ї,`i8QZ xШy{hh:.3P.NO{shF.*&"`ҋ,\>,6|">AŚ]t;[krEopLeQeFOJqr N:AS8X- poH5u;*M66gJ"iHG@X=`rEu6TPj&yRh:zivZg2Va[R"Z-R'd!DNivZxe|]FiNeK8t˔[m[(9E'@ z3SeEEPgEС,sQ`B,g:0 xO.l*lz^\E?<N h,Pe>SRRW)JDYƹCѢ9XSSNj4W9\iEҵ3 EC3ŷs<\{0놟(mqJ ҳKѬ;ѻ S-xE45eZҌ*f\`Q\-cv8uƋ8h{Qr02Yo6:D<]Gi-uTE6\:[NszeP*4ACOqMTEqѴQ60TnszN*6|׻K1cseP>rю>dfJz3@ bPE<4\]Ch}"Esa1:E1d>>DYYHxc'v? 0ËX\>"ՕhAQ_ x;ӏcu.;aCEĞ@ElEQ=rUjmuy3L.T26xc%l^xSN߰tehTYANh6fXϣ>qjmp*,2RYTJ΄r:,ls8T3Vm"Rȟ-m[pw Vi&;f}kA.YV?epnehAڊ;G*gJ zWFRKw0KD؋-p.dES;fS5D[" ^Ql@'H (ͩϥ"Um3Aaq DZCcEVjY[Ժ=bViO]1&H;t̷x=R Ɵd+\6J-Q6h5E- ht6ch q;Ǔ&C3D\U ŝH+!Po'iQX'\w%Jtc!d",BmIVpGÞlD5eQΧiARhbvaTϋjI})Ρ[y{9ǝtq~: htLA|\O)T6DY~@تZ.~Fh'jc,̃E|)g `DEj B3dNE̸Fm7b-P\W є3w$8tj0 :s-OB}gj59 f: >,w7ߎo'ʚLtӛ.c͗&aӥ^jeRg s@@ff-;Q:W"6<\ˣs&ghdJ|[x}"Ve# ._yt~h=:E~*ڨS|@eVUqwGC?D\m ȶ9d?6*٥ gHvO4E(g6e(ڙvtX|J)MdФiYj0l;Z(>:1yX'-|3m3Eq8|Jn*`7(4 \:9\L#h\YpLoȲjqb~ =Ֆm>)t؎I|"Y˦  c/"\l.LtC>ZsVS3M^iEquɬՉZ˔{6X7 GN\s.;7TiϢEϧ2ҸN>4N㭡R,U'p>f Jˏc(df/Y;'t:S¤Ύlp|O`:.ln٠ӱeφs)7eH-J} 6:9EvFtזԩNf T DcfLEéM G'h2 SuγϢh6u+m.Rqs!ux.*mM* iiN=w^à\>)xdhuDbLu RrjROt)1S4Aj+&cE)ZjϛE's`KKX4H(=9 t>2Iq+\tۼEs9XˀǎeFCGT^.e]cF̩XzJU%g*s,x *-U>=uqjq͔+&@ o*W5#CGYeehglO\Xm*+08cc/̦֔ї;< u0r,#.du7>pexyFx>c6FPlZF&NGc^N ]4NS r*7\C7?mX": hxgzFp]ttE]sE9qEcpୣ,eES(*>J*?oN1/qRaӮ4눸ʎ);+Ytud/:oDXhF"+eE:}?Nw!*C(~INSٍ?VltY)w3>ʍA-\j!]t>-2nN!^dz(¨8"TR:%\`dgDhw1pf:NtXEeDXR:4ZOۛL0SJ.S4.<\FRmhS Qw "-t .1m34 QsVe^qa!r=A:qsd}x VNWѣm#7XlH]>z.HmT8kڙ)J˾ΦVբu7uDXeGK&\-ݥ2蜪?i"2cbM;TC4\t'D[)mQ\=:&,Zt6LzN`uc՗FyEŅGTk;˧#Ccctʑ>S~gT|s.w13K;ecÂȹO} f4͗W΢ZS:fv.vO;/ner4㪍EQqlV`hcDr#2-tNu6jy%f})ʘ|#<tιɦt6\i>7Y6?tƲ:XƟOE͕ \\:9ƹQ ts?&t[D|6g;G+.cfpårh SSRr6Y~>8eMr̗}5\q򝨨p#6͞VLZ%;WL[SK F]l;3/CQr" u"vQcXqNdG,tڱh+bg:ZΧ/v(Yh x4E>o'o ͣ,XDwv|CM5ň2ƾix!} al|zQ[sljIPmڻ}Q^T cijcgOTAp;< Q@ɤeAe@j 9Y;R ه6.)j:?(z>+FT4\8}!qT@eFJIz`?*_t|i\0jQ>QDI+)5ƒ6-N=r'B?DY5p,R'r>.c9z&\cŜ}3SZ!\ڥq+MS[,xlUSDh6Zq$.C"\qsAG}Vun#+>|,KàtF4&>-/qVv顼EO3F_B-#eCD{Qg1:Rtz`|ֆyiܠ\mFdh©.QlaqhrWTԏYFoN2'LI"_fLgyi/C+hy\/ʚz,(Ƣ7ʆ:KhPCE*>Y祈\.͢-MCᛏOY^O2;CD~f+ [-LJ.hetGH2rY|v:CL؋.S#j.:QelgmbT+áFiJs2t}MN<SSI; ȶ j4QfWQ) AӶh-QgSexD苞'œN򝝨&eN,PE6-,M̎hthk49P?BmGnE})lɰm97C,q,ˎ\4ET:Vݶ\~u@LNhe/",Fm&eGĭ&=4GsA*+Gw) GqfAoU6[m++2C6YŞޛg:dytxS5 B] k;S&*Qg,thX:Fi7xjE.1˃Q f?\Rh6,q=q0 UIfŖh(-FE* )Fv4}3=M ilecE7jTJ.}MY\ NqeCE9*->Sw˃胩tXj(Q,`qAr|9-|mz:JYhMLc2qgi6TQOx:GYX1"]2"A&C) UGE͢4VBݩjP3D\4gVM>Fjm\5̩C}'ϕh)ttm?鳼."#gs!ڋ?@FԓP2'c2A^ L>gZQPE);JdZQqƃPE(tEv͚?47.-p7J:"3+?Bw>]I6vy4ڛ1e|TfYEz~t.WL#y2.>RFiEU@Fi>ZɗK *:,|#--bgZ0Y> phvT*YV}D1?FLS:~ůL,TAKQbs4p~ Hɵ7tvts|~jm `bTSa'N7J\=qE,cG66l;uJ>f2 B\V~l.,Lrtš5mץnt{JpZ4Xxf2!3`+ #cYF"4m5;]G\Ea)MF /OKXpxV>4X0:l7FYcwlG=3GmuٛC4Ee#ѳCfJ:H(݀eyqN':)Oō"7:r>zVN\7n|\%P|#i̝?w{*tfwƺlYCp d|"JKV۱O?+BnHAӺFCj9\3N:3=YJݞ] jâgX?EӰvV@?+=>""fj"݀}#J,^QsN=4L;ruac: FCq66;|h ~|e7YoNYcbsveG|mr˲9[B=".:CӋ)+Dڳ;4tC3x5L.u6JvVc)PgM{ӲԧV0à|]mFŮFu+"v £:qJv5s*GҹT豼fwiuB`qTi_c8Ubz%=gXl|,?eH*X={4FD+N%+T@}Aέ;ɮ[\Tv7v ȩLޗ(Fr+A qlL3¢CSo.Qr CGT4EїS.3z|& >p2d2:ʢ1ШFEeʜ{<|&Y E: Q2mvYiN 4һ)N.3Y>iT|Jq3}#p|o+Ǯ]tZ6Q+h?;ŃUN˗qgiuy2k'?ycg+P8C %s.˃}>|kF=GIf7Upjdu)6lO3b8uS81ʗ,}`1鐺5aO5`57c]/T F#}8]Hr|<5*/=h-.g4\"$uHC`A,2 aTPq2 S3`;Ӳ*R*:e^fWvwꃼ7ZȒu'( :.I*do.:j3KΈ68Dk.0{EQtEu/ ne8.zl><]6||sƇvRʶǛ)ǽ6|selyw/z}\ 5vyi̍).B&Vn|¶q[eSӟ."TXe !?)sQsU6pV5FfD\h0*,f2pDQ>DfBw6TGf*QҼYЃOA#., R;SDHu;;Bq_DasN\:+>;?E,h¨WD*B;7TxϏ#(km:t.=l^TQCӜy6$ȏitQ+>%d.zMS=oJ29\)=z0> &E8 4&c'cd gl8tZY+2rɖ:SElϧ(Ұ+O*,n uDneaNV!yإ:ˆa0+,zj5 hcB].} ]}`x(;DY}q86arx2!. +uF"!fxPd4\TX CAq+'L/SFx*JѡFh43B"cs *|ltD'YNTY,4#D`šޝb,-̽/LeTyv9XOo T44YnщYC+QݏgK)o<_Y4Qtwyi\i(å|YVYY o0t3KXeʼ' 2v9Ug,ȷW :] )Rd6Fp]j۩^~|:ZFU;;0v|Oƴ>FQb'PRF2Eԇ:,.2i豸L;:o@d02&ƋQtshl8Lx@B" } :S0i𸕙roSD\-Q38T66Qq8*d ]GLȸl7}bԋb%4j5sFvDҋAsqq4͗N^lƣ:,gS.bt.M hi+; :/fD2xN,"@"4Xp⏃=0t[g&G'JxkC7Yޝ1 ,z,ȮJvDvԋl6xiF" Ọg\ C\*")E.M;:/ ȹCkC暴[@hj4cER;Y:^#G4(fé0Ѯm3ќU$|X6n˳gEVg+AA-FQ?YhA7@ŐQ}.R!X bxtHheO O2GjeGqm=2*6=PdX6.a|C0lUr;IYȌv;(u=C!l4>*QrTt-A6?m2MM<#v(+..ûMt|l|^D\:?E`c"ɋxr)Yf.QrSžh,ɶ̹wŃ](!th ,mG2TvѭZtyV:S g>|⃡,ʬ;ꮮ*NTV>+jJw!df FچĮ0eF"j*碱:.F8\vC׆υ1@Ƥ]6hW͛M3<NtUʜm>Gas<\u g7hӑ*">uȹ-;:Yv{v#x1*l\UDaPy A3f(RAP2'sx>|1"ohE{J4E-x6zjN-ul4ʋjR"ƂThaqe~Vˇ2NLBQ!tg*|]+t x.DizU?ޘt6N.A`FDY8] 8QqV^hDDJoH cjkmFFVuSwzuť;:&6y:(EњC),ʪyMgEiZ'B>_Щhm omqZ* zgCxq|EE8ttw/tAm?gD$+b#pܹϡSeAwc*4CN\Ϗhhneע\E?MNjnʤNԦX:!"2}˟:Z*nxnCꕞrf"= Sp{_{3Ұ;o&:.zS.Qe 0Y="\(& MAd4|:v΀];NdJu Eڃ 蝑m7o<[A>Qqmb|<|Mv4EzHE4jN.4(CM Ztw3*.gåǼ¥ ':?qr:,),QCflZ+L1"c8ƶ.2JCګ|թWk=%+Ko&yQdƃFEZlJPT,hNC,:V<ye[ iTXs 4>^Eʎk=7gEjhuEL}3C3e6x;CŊ"teYHZ|DEi7HE54S_lӪS2h hq/構_2æ#"U!SwsEsLq)VTbx:.eQCԂ]4N\`cC47٨p@t\fZA?c&6X\?\c*hh>eWQ1EG>YũIzZVaӎ٫~UYSF2"l\盞DwJO yw&(u5"V70LLz*-rNT1?Pmѫc'ꓼ/|FtbZ,Ph>%sj>&llx+ʰAL:V:):4Y0L-hwޝc*=IŔ35&ɧdاHd*.d0 Nmm>|҇F4`͢$苐t'K9:0/Ĭa"cE:jN,.0n.OLtEO6T7n=|0tZ'ôs͍_=>j1O}Jv.JZ՚m:_PNO+J,eH#Pb+:Gx2٤QisʵCc=)lŦqU7QӵqQPhrhzJv^3.F}g>vC:BW?1pl>戶ju@SJ.<5:;c#F:.Sjzh>.sQb# N%ap4p50EŜԈDX`EE'Ƌ-.D}ɛTjdPҋ 2o*>@vh.媌cM #k?\tTKűheA1l&owb3hχ9Z|3G,0tH:fu5iNqV*2ӳ:-/c\g\O6#㴴~͗ӳJsOXINY>tY2> mtYY⨡;4m-Du>SD\ߴ6ݥC4\,}x:;9â:gҋgq􌮨.u\؝эʁmΔ*3o&'Bϟͮ0uToL&O~kl;.p2"(!~ճ4T$JL>Y1/t,\n&G v#E9Zt%"l|yJ.&ɰNT(:-|..E2Rm;.*'} "cKEyw6.qsh4iڶ9:-(t:cW9ܦFQqAC5at>,4 =~]C[vݒwk|̥>^:QqṋuB,4Zk|g>:5əxgJ6mZtm'6)ڍ#MւPy;R(:>|&yvUa":bgޝ.Vٝ[sl{A]׎IuYԘeţSȄe8\t*'a5Q_e1ӯKV4q2Λ0wȹDˠuٟ+i2c`zqNiIE,b,}v oŻ<{Q\1qt뒣?\˸Ƶ$GE{Y=r*ތH;]6N©ij2I9g<B!cm :رNnu7TE&8E-t9HOwʜkѧ:[ˎ-gK+̜8@u :&@]$^㊍qښ/ ^O OPm6@yN}3:aTXwih%|Y2ٮ.lԁ-#ݢO2éMG:.2m|#>X=tY49FA|c;c"moF?D56!t",*'uaq7ZLSƋ*qj7a.SccņbUEm894XJD\O2EQ9GyR^|76"V( 3"fj3< mm 4ˠfkqپM谭2z~>" )i\;eIQ`wxGjS1+0n0VDDi>q| ;rhL'Et̹WrPoGasxŪ37?\>FS{ eqgz*?*FNK8Z xIE茋4,g l-TNlbʌ BFč2>w69dr eOul|7P4m!*CNSHsCJZ{ tE#65::SDX6d Dmye4YMAԧd)oZ̕6_ v8*J.c=P;N5::g9XMGѬh0iz'C}d|a+.q :tc4Y_yN;;4la4`C*C.>NV1Tqp|*ۣ0NjoPDdso#΋ m/EC>"]<CLt:w0/GBR4Vx6G7땢˞_Ы?_-R#*t"U6lt"O2wy>[ tB":RqR2wLm᣷ԉXjjhf5>|_Lp#8 SNhʘsC<*\RCfN.;j |{ h'i/hjvL5ƌD܊O0t)H\aDDCX)DDy1ʉ\jjveV3$?TZ*tqg/huD[3!N|[ҟFS"TO>Y,qR-8:4OFNˡYgIty5+fUK'> FWT:`5;?Jm>yqnj?5#qF60E+CeD9~)ݛS!_ʗY2wQgA=2 ,tXUz!V[X񂢈TZmqFT͋'=J xhR$k\_~5)ͅ:-^̈ڐڜ4RqQjeG||MOʂTv.gZh :VvZfBEM񰦛SK 4m3>ԙPdfSZ 7Ex3ASDXlNԇʛF< `ԈךclA'ϧzrTJJd,*VJd\4f#|㡳(ʆV.:Zefb@289J.,tNU#ECNQsL4Dvyq+Ŗ2iE !cY6"VQpǭ+ELtLiagˢ01MExGNR0Uc*?w+dYʏ>Ш!e_FhoHhMϛ<l|/mszQmZˀ+hfڙS2,uQ`#c7g+EˉҝoVv\;>MO9МkuJg0 )Ӌ^rԚ:!P[ 8Aa: I@F.5.:|D>LmirgR"!sG FZvXA}NZ\>)>p*+As*0tEPDwT\xsJ.jwތS z*8QoݤR$j4Tk33K|8=0<:NVڙTyU?TNE,nTvFT6kcqPhiEEo 3aͥp0|M学aR&>AӸLZiCGKͳggi,N(2*, ,te 9ZHL00dcS+@RS)^_U39_X2Y>`@W;|Y,L.q1bt7ΨTat;C&qe0CD\p,u46D|񏼉hiXé^a^ڛ/mpuyI'I̓[E-ŷ2hv?7a30utt87yi:~aDX:W4@3өQQDJ|ޛ<΋t7zAl\eA0\}ney5.x;F?`?딭=ecCgc̠)y\dhp&Ӫ4FEQ8=4~XEQa.'GlJԝ4(ã(0\uӰ|9*/ɏsݼd՘ubrClTQϝ=nxu^fP.$+'C16վ fQ-x3BYFњ.ђ}# zas ҀF/ga%nx+ttM9c.mt!-Fz@YZ8foO|>\E?u5K)eėn+cb  5eqqQ7\/avGQNX;S63D졛Hcu pG}PQrҀqpGed66%m| >^6c vfoogv{ÎgVujҋnxv٩87+ٌ*.|9qU DX;Km *V7?Pm8U}PNj+iv]G3Vt@m3C3J0u3CƲ(|q[z NFo}/Gàw|~W!+<t<5&Eh;ǮVD[1˕vKU"VL{KG>Jz0,.RDYf\5Į0ۼ<|v(YvNO^w;̓J)sCf;+m[[M(̨֝gMeP8 #[VF5J:"%oDD\Sx=gCD\]dq%\Nqm7xAhxڱt7+.@|kmCgF*٢64e36j}eOKұ[U-HeE@.0oc3ۨ:^ a֖0ўT_\ϔf"=):4Eh(4<)+ȋ- hbvh-6l- *=GDqhy]2dN:ݫ`rt4s^PuRr2 8&k huDGRuSV${~|lP;ei^.]4.AsA>>qbDuO.V;*?4E_fDӾcgdzBKϦA" ,G.;*%` ;CDȩx^iLȹ g2"+H SK^0ȯ2λlzTf;.gHm> -qKFxl.gŁtCYnQjE]qd`l⥀u?Q c6j:+T(".X|D͝m\(:_n>GO7q9t0}^.1`2$VqfQDBW>?D5Uqw0*D{4LC̯ZJRYb,9ksgA=6cer93j 's&xh}\8:2H^Ns:f)̏(v58|7D蕍Urߝkhfҳ*:aq}ft8fNΤ]E:K`9A}VrFAٌ|vb- +K-ټ2,Q\R쪆 ,(E@zmpԽATuD\BncET#(:@}2,l"uSŖhOd3gD6\F-,ux.}X[di=GQ.5 : &!ql˫|èkҭlez3C.)gK:{.le h4E)#3kKF]/Qgh7\ceGYiu k4T"++Jtr;9SN,l?zaҕq0\7hC4?J&᱔ hf5S'A̛stZ~B||Eh )e.,671:7x6TE~,;E:+N~ZlF"h54i;fM@Η!6;vEjsΣՊXTyl"v:6o(&KQUmc苋i=7EtY\).i{1aԁx&# {ndxWs9Xϕ)[J:;)rldo; lGzޯXhGJaHEqrPExR"".q> aҸ>?G>'P2(t\:wY-FV叙vgl 0JeShPmq4:hD;?}3kVwDk(8*أN hQruî !ҏ O-#EE]SD[Cx4aF|l5:R~:r3Gfnɫg}HsD[Cʉ|َζ:Q`]7P4x>,4Uaԁ1p cV2!ޯmF Iü̋F?o>xX\X6fHLh㡉&ԃZ,Q+!JDPM]zS ive-W`pFx&ʕ} |sh/\p2v=R<} D4p(l7S`*Qv44356L\42NltEycg:"4i.mW\p2-u;]5M76K}n;.{;Q3>w6S h~e"eخQ2iយ"-HˌEO'Bϡ|긚uظ.vvrs6}/c) =#1-NӋy*F3:,PjmH,Jf>:cŌlGeÜ چSaj;;i=!ӵæSEqcUG>6 -̀`ȝgp"ՄR*8VMπxl7ba3"gq2S8oDX:2l;L9aWB,mex]Dhv]h9*Oc\qoCէ*? t#*=~unIx>Q;pS8VrLh١𸕶2&\cǟN:rE168 h-CE.{HӇJvq_|  f||k$xj7܋]nx\|u  9Z u >vRe :M-kON:4Z l> ݣJGɒ:X:[*dTa6 ꝇ*Sތ:5 ك;ĬegseCݎOق8GX;Gr-axLt T12D'z 'C2tAWKΣ3^v:jWuoylNiv4c"l;czkN, mfwi05ϴ!U~|(it\}3JBй95U/:RJ\6qTnѮj˵6\qϧ>Rиw-:7EN4\iXSV/g:vS,SXNN:T4[+S4YE;}EYd6e#]NyeJ-txtMhZ'fhRPq=P*wh Z(Sv!M.iNL2rJ=sh٨vs+^a:,;o8Ps ,nҋYx7Tv5aS2Ӌ:\tL'-v7y7xR2mZnޯEzWP:]av* +z9()>]el]JFiP>;I4s~M ReW'i7|z<R6=Z˗1.MB.oO^?ES j<'E`vZ4$kDx.:\Xϕ l;?ê#t-^*%@*?wҍr.2tL.-M"̣㡎:uP}ya#l+~%v%_4]:G*Apkzm`n.Z=z;ĬqыGuyw~\spҵS+eX;yڞ=Ua]4lӵF|ӯ9c@b5>D5sw}χyL{Ca>-+(.;>[*&a3asӯSeAӷ`YeeىB3*@LEM U57mJ⨜DXV}s,ui>6xoLˁXC4z} RWhkvι}ҌO g!9z%O8kG'%S>odnݼN|zF&;>\Mr"ktKGnJ-3Ҫ6Չ_Ʀ|qqfz6teh쨞dF2#ر4`ǎpQ?BLYjoi3Ù+~C:3/m췪їeRGN۩pu<6}Έ,(d#<;jN&a3ct9r8*˅E;*TψKVeF]r566W:-<Dv};tZڱLTàyj$5\ Y.:=Aci8S͔[|Z_vv{qv\9m8F>UdY4=8O2_vʡB;4EφGo^^jܤ4y8՘g'>NA[͉҇0Vi̋QfTG΁:Jh)o٥ W:EĮC&ҹ,lJᲜɛ1Wc:"oGv\;"tvS/liv C./,"v4;h?K4:g*9tvOhO¦Xup(d\?SSCbbW)Ex @S/D*;晶'na26|hl͞]ԨTkX|:n|eѾY:\m7"֛E?\J>z+4Ed{]O>냬lPzfEözm\lzgCEW0zBGJ=*=JN\>\sVseWF;\.N1>'Kˎ_\E'.mG4\ ˅@p4,*Hh\kf0j*qs,:l"> #sZ'Cp+.`G ,uCp:V{he64;#8 EtmD\B+ZcB9yQC*mKu¢2 yqO( ̷Teyu9ZFvc$dӡMӕ2:㦤4Xǵ?\(9[u:nYqFQmӹJlhm"yuM2 :ĎqJ1l:v3Z9xi*hA(ͥt582l>4|*ڂb,tWJ,heGlpcϡmATEuJ"ȚGx tZj`.,eR˰ 6z:?:_TE 8ȋ.:D:%aطٞFoMR;]͗8ca'*=R, Mhay|=1ӕ͙(ꈶxe1gf>T6c'+S/#DX;S,f+ҞG\Љ:&_ҨǁfAyǎ-eLq;mgh >w bl\YLʜ\fuF2PQTYr>$ո6¦犜YPek]On|\qm l!TNVd+蕟ߴQD:Y\Vy鱱)iOEj|) MeG< 3be3G5ˢOsfGhMNū^]: Gʋ 2P:-deeȎ^p{A>,d29-%NhmN}2\sAJ˦6iNSSp5u^~RDuD> \f6Rc㎜ZYϫ:w_Z:; e EȦ3-<4V. :w[ Z6ˍ?q׎vŃv:xc`*:Rx:#\T}ZL.V42.mϢEC1SArkBaL\ss|qg~ih<>9:-t26-l&D[aF6meEЏ%E0FM2 ,ਨB'cxaTĮo1tji*2Ds7Mv[ŕE*1Ӳm:(ɀY2F~)wO1ap<؎yeu>FAȧROE,J5;|NZpCVo.~QS[.7F'gR J~]8D+A8+tg]1_W|a9*$"HM ,!41Qm `\j.蝍DφNelLi2n5>z3dYScWx^gSƋ Ep@\F|T㥃fQp-"VOCY?,4Y2szFхE(WށѢV2liW'\E)L8MUAve\z̦w3@ ŌtPrٌyӣ}Qi^[D0ZƇŝhBIl뎂UD\#^:YNXlsHD̸W6T m̃4x27U;x |>`n*V6h c@!:'+hOE|1ސ&V"ЌއK\> ) G>.s&Tϖrh6vYO x:7 팠ChxjI6FO.,NŞd3f*f Jat\feEMH2m htسσMq72E+/M3s˺=dIaQfgmg+hlty-:a1߿}fP Ӷ:gz#S:Zݢ*S83JvZPj4y\.Dl>4c BIxI~vԡt\PVD٨/4k~|6C1p*0, mt\9Rb$L6 gKnwqJ46@00dcBWBp@ @ @ @ @ 800dc+ @OVz~wciT2fAMXt?) S|.ߥn2"s!-ue-qM3R1kcΆ ̳|٢3u?Lym .EYۼmG[rE,AP2"086k.\.,-"lG/T4yPȋ!P IV{gZ u DeíRv폡l'( SusZ3J.Һ9mBQdE:S+;w!LLL3%a.O%'PtLJ^-o#Ŏ,=SIA4Tn]\DTҞIɺ6Lw!ywT̖TʦM:.xxG鰸Kl:=S;o~BEY8tI|aj3*~ݗ'Qs>~?cϴ ux$H}h՛#.-]hTg1^NSɎYel:  pa2"=!vL: eDYڞ2<[ 24뫛4hš蝻B?x'E(MFh+z'enzq2.w}t5݌nduk9z+xyh{re+8eCd^qa٨%\!J. =SwcoQs4hC\9^ 2⶘>r&\S#bGh6MRAP`7F2W%;M 8u "\r>>t3>?*w29lʊSs;3:v gn*aPTGys}?+΀ Ug[z26> :~.ϕ,3;ӳ[ss2MqȍHt|@[VYpω>Y2NWŹlx}b,FfUm=Qxf^t;nFq[S?TI:WQ<nj˻0R M3}FѢfrVz@..|BC%QSBTUf:V\:u/:"F*qs`siQr5wT*J,Ӳvʛ +\i[%GI7.gE盡T R 5>2A`n%q(~rX\|dyhT[4/hv&!o [SJϧBWcϔ|*(!yŦS)8%Ku-I3iyd2lQcc')0+4@Xo鱲EvUaZdo:۷jώkbQMN"eFuӛDֿ*;Qg5#&؎ֳ%zٛL7YuT:%cBa i= 㽕8+ |Dsa,}7n\\7_uKŇ.Q>y7SM)o#K͇ӫt[/:LY"Q\#OE5JmCY_62ӷhC5Jvszei\HyN5J"QVb,>ʑKbD[J'i|}XϗQjLNӀn9=;GČ>8{O5NjPsf'zq%|c#gŊ+SwSmxAZfT+U}6Y?:>_;E^e9r*9CY4:UZ.^ׯGdc`蹕>.d4m6:.sVN61;1zD4ez]ޚ?+uvn2P !΢|:E]t'„E +TE.n\Ea|(ҋ!:-+v7F #9Y'm@E͠u6Ls6]'s6ό7cbVh.6"M%N;4f\\ݎEWN{(jpam÷b:١**qnTYԙ.|k9^``9FfZ.̜h{\Q4,eG>?2ع?8: ,uڅ(>ltz>uL\a̋XxeƘtvgU>&쨋GkGc Ѿkn|q2x?e@Fr(rLe6-M+_8czIC|}u[R *c\"D0r>}NFm`ldσt&iMp:-g u".5+EX4 D[(AD苓|WUJt:<*dXkm\#'s3.E\\\s)N:CLqZf*48w+L>Pt]{XC > ~.Nѡ?3FGdXHfN,Kښ<\6wm3gY'yjߕy΀|eqC8@}j.-R|2B6,}(eD0zPtEpir=ų43@DY*; -vG6\v|c"m=OE/A2CC4Xu",L] ePaaz.j06tEO:Z-vQפcE9o,ٖba (Ǜ QaurfE0|EQӪ"Phlˎlj éexЀ\ڔcvOCeVY1Q:yZ.cjD\X6C?}? YTYRSթý[6}?h>^xe=gŪ'f2!HTF[l(d o;MQ$n>lJ̈Z̯fJ8~ ,2O\7ât\ع>qePJv( ç+?Cz+'©9\7]ጪ3 V~El>6}ǴQC}W-ay\7;6ΑUed8B0 h3SBQdCR"xbWj(uS`eĮCCEeFꋟlLЇ]C9 ] g4|E?qR c Pg N"ڛN,i*n.?EghGʞ)F]?鹋)|\^|Y\?:'xsM8㼗3J3á4ݥ;4HU<(tT&O52\5:Rt<[P|EƣGQ"ՍQtZ@2Km7yk>&iBY:goUq~}̆/͗w|u ~fŐM!鎐tͥbO" h{J3ӵxd,QJӵͶ~dԳڎ>:K2j謁,u<0T<9.GME͚3#J} 6viϤiنZz4jFf02Qd\GOé7\1Rޑ;mM%jxU6TqyE9eL95pA`^?QR=sMNtiΈNHЏFh:v3@bҚtJDCYqgO:B>d7D0Cvo,tj-埾_vF69THsdrtYn:e١+i|Gj0ha0ʍ+:Zx_J㺦v^t`|QOW"ߡu_Ohy0"F+0tk4h.8٨t9yY2uNh5GESi #Z;6Y;}Gt k6[G;t&*:}ҋ;EtNn}.AeDXteJ,oOMMDatM?4΍s40Dlq7T7_2r.,a4#y"_mv1şQt*,dtFco9XڳyF:dbR~0a2>şy|/"8u2wCNêH&4M9B|W-\pVj!j-ou",˒t/:"ìo1&-`q+P!ZdwO\LuZۏ0: ҋ;`Dhͧ`U:OɗSc|6T,l+6_~㠨f|*GE7SNETG]TH\z @N,t_uR+eF ~ЊJ}4J>Ԡhm zljPm6DwS[SzkV1hy&D|>>*VҟF(.%^=C>"u,^|Q݌OHJƇ?S5 Z7y~TQFdʶSL2[-<2E|Mg\CӋ4*: kfCaQUC`>=_D5 h[~MCw)>YM9P)9\@|fhP &hzN@J)ӸDs(lϋ;[jYhKVKOzz:Odm m)vcC8Csw|ݫd;ƵV na;fSQ:"iv!.0Oڱ h-uYPb***tEuA.@o> |dtoͅNhh&vEWv|ͷaQc%[:õj}deÈ'+iSaWbG'`gAY¨Pqta˻cV-iphͩ۸?ў)QNJ,u./,|g.)?CccaZs4ҫxhʶ/LW|:gF]f2s:MOrR_.g#3SjuyY8ELh`)Ph\M2HnϢ܉L&J|xqͺ|θ& =΀tF"o+wy3̌|A3f"S}Ji:fGpS5FaT©j}mgͺw;Sq"Ib":ΎUDYRDmEPȴCŖ,ȋ ebv:gDA"=)dcEr"ŃCh˕ߣdGp;Cm#Xۻ9{O͎Xi.2.x =CD\d6E "+P5y^<؝sNWU}=åՁ,DFUv;?#X;EG Mo.w3AᙼJ߼c ҕtaN(:Zv-Or&|\,wr%?4Nq\ƎT 4w \sF\[C yNq:9Yh(D?QRm{vuDk>h4eEq(αr:=c!qoJ1mwuUM'4;{;9T'IHtqv"e7UH¼:#:o; ?Nˑ>T\?٨9\3z8U1¢:s)눵Em;[9>JlSeXw4Eƥ>OϴP:QgӬD.! TrhzJ,Ŕ3@js 6 "BSgꖻ?Y4#НdQ]Xi)3M͎\YʇU:S*P]^9@86Ƃ#բ,ejP2"*ˁ*4.gFOX#u%[ooᕼhq)趛&:QqS޳-QN,;$WwlE*'vaRaTF|!*:]4+T tf绉yfҝ:Oӕ֎&dbLue.=g@poO¦t.]ZQ%ltr=: TPuxIy4U ҰNӯˏEl2ŜwS/i0\Z{D\QC̏'o٥;(tN'SuMI+SdeW/:g;.iX\E뉞4a2m7zeF@؋WTZtʍ \#@ E̩2 tAxlU\˄4[SN>)9>z %>A %>S+,w*eB%AoeZ3FmvhfJcF"}4NUD͡f5P,]f2]swTif땞ltY}xx}VkCDSS.W6ca,eaE1z9J˦s|"|EJurk19RKjv?GL&fΆ}1G#*bvq2<39rk"!qt*.?s&g6'r8tU2B"am2ʈ DUYᨯqLLdA&Kk |?UH/Xuj)."T^&xux(>"79dEq%+Q/7|w :BIy/ ^BF3P8Z1"̻**jy"3gz-VNQQ aLβ-92"2 \eCѢ*TMN&qkxsͨ&+ Oͨ>Tg@LYy:<.w)JJ.xz`h6FSz|qscݡ }mʓNڰ|9N6hBQr <4};= Jg,5ʍ"Fir|hat>h6Sj?at>;=.4OŅ<#EJtX[E(苙~&DX?ˈ]Ƈ:cg?eE[ȍd *롰eMXǼmNA-s .#n'ʖ9cEcv<ŏ>cJt:Câ &=TaTzN#ѡ-4߼?m+ ] ҴEQއi̯p;xjj@lL4ki8i|ҋ?>MAAϵ?:-u=:6:"h+Lèƴ֮cg̱hx0;˧@>-Q ރqS1t\BYsgBRY=Qp=?Tt4Y U) ԦT]q+Th/C>v?P",-[Ge:S6Dѫ:_T/GMcMCkFБӹ̉£E;E>4\' [.Q)D؋zevtθ)tj#*"6@|2`\+x$T[u=3'i'*OW\}*J'Tv=n&4XmDxN,G3ƙFJ/ݥ;!Ǒ(qqKsDh'ſʕ(TkNáFo8|Y갍aA%S[/̺kΉy sBYw3uEt5Q;gn'DVu2v}M>Tvɑsu(tpJᴣ /\އCQ x6|ӕp"'ʍE|u;jl:'S.~,j3 Tԉڙ.m.%է>s\4,t=QB,>SԄ64~>qmz cA75ˋN'y+E.̨e+ufN N@dv4ll~,~#aW1ҭĬNk;>#ڴ}puyH`tZj#-I6- n";2Fhn&8 }hwcS@ѡI zJ' Fl<2q;EPCDGô1Ӯ'CŃC'.i0̮J V/4ٻ+t:Ӳ"<*o#gcAMHEt[POPgFzs+D[êTr|Zuեt|6Jj%si;euϚ#+fMt\T1@<ص'K=W J0-w\3ΎޯX||F]9xkUrhFkLL;.TgCQʈ1>_Zʩxlh¢F63l* fsA&C6,mm(~SᏏka6,'!ȟB}Rl;ED3gdcCgiv<|k>mF|#1l4eKߊwwk??x͢ɴ6l˗h˜T|Y!; ;>T6-as|^3>=9ʈåjR:[yqevKY;Q~'˔tFqe&""}>|X_j[@Z aV͎Ct5hBGA906'FZ.3F# [4m F .ҹu=7[6^;Sh!T{-MCZ :L#4OΪ5ta4iǭظ;C+o9z|"\i"͢l!O\ˢT/^vycb|JmFD]m>9ՇȞd)arPNC4*VP|a:F1|*purEI?5lcj.gL̋.3c7tϹ<4vv,Iӑu œ5 3A˕Ŵ͔TZ ;h,cv>5)¹yt2D;#rv9Oz]ϋhHyNΣSSҋ"Ӌ٢W)Fs38yt7@׎Qs|-r:-θTt yl ݞ{o(wUiŖ9zo $[hCg*|V*V0%m[lJhS.?.9¥nldl.Vmv>"LK}J>,Nv pȸC.SB5Vιӝq;,h>rJl22JeL:V ;?gBϕJhJ-͏*}3kvd`R31"w4n>Ŕl& : >x3͍ 2$J.SAԣfN4A.jtQT7l=xshѧh~mAE"y4Etߴnn,$vh:QljzGv6&R)RMuD*sT(dZ1>i>9RUeL/ŞcU3Ggڠ4Sۉ\|fSBeIGZro,ëmԹNph6ʲ N>+.TU6Ƌ.ИOo SD>+1t|;*.?S(]w00dc, w]o;sy<:!R+,Ddn;OiQ1K36:JR(bxd哠Ӝۛ/ϐrFmQ9@~ctYps?e)M?¨Aَ;MnvTj2HtH 8\8LapC36Zʌ|g8]NvgN-D sDd3cǬt>mN-tN.g ?'xUMw,8>%mQFQ*4;@˺ju?S*qkT::%kJ΁>-^\yf|[PE +n>SX(u|ne6x<}֠u#iku6\&^7 4DL*j zʼtҲ|T*h_zl5# Sx٬l#E%oYc7K#>%-Q6ǽVCS'mPoݎg&+6>nV!;oj3}aD3Ɏ>r__]> FlYkFcKoH+c:wc >4[m#ўr:60ێgfh Mp蹴t1͜\W%q#goC`"84xqjx䎨2ϫl2ZɇJ,9A7ʈJ/Jlڛ@Ψ:c:ƕn|}GPcOQe3)eN,4'h}qrTv2Jᴤ5)ˡ3П9_`AVWՌ)#N9J_#êix y|O-8p|E;Wψ4N۾z á] }VQ9LACD[Ab8Pf\(4i\TsgeG;2c~:"l.k"ٞ &c$r+btjo,YQݱi[H2iN贞8?|:]N4g +ϟd|Z'1㰘2,\,GdgVflqja-cA"Q4>hk˛*82>Vo>ZL0Mƶ-٥E \#%9L:%`eGtkhV uX˦ xֱ;A}oչ=/VGilY*seͥ:SݧTJ+ )6'hK]WgxV"FeP6T*m+l"D\?.,\?hh4J \5Pӹښ:"չR!gNJ&٤U.&vYf`h T Ur\s53dVBl{NVS d "JgZ=[LECo'.e2beEj(4+(N:m Qc>L83:^cVgMqo"Bn67l}zM9<[ DI1\5Ȱ0Tn:m]ԗa}2 Kp.KtCG>̣a~m> z Vҋ-+52leC0 1u̪m.BZP}VwgPA.}aҵ"vZ\[Eq cgJ[Cc F3M̪lʃ7P\\6_D|88t>zeJ,6h/ϖGv['LKNmM^2rDc+gjNqŦ6oNa/t7ki^tmX cy4TGWqehC}5̈h~m6x*kZ:gx7ތl߫T s%v&$mx\twڷ)>g{T鴌3(s*W]dXzhe!ih9עi}ԏph0U4M>jM AN ^\RF|"_nT83Am6t5A6qClk .v=x4I˴",ڍq\ &"/s`fghh8 qxXSQ cPL^QaRP7icYT2s",۪9rl>$ӕ(k>oVkG:ncAYTJTڅUn(uhf>\n1ry4[uM)ϐ{~ep ,}ltAoTDZ䏣 o Q:LzS2IFV\B2i:~FZOwӋSh˧ȹ\顚.Lj'tX q/MN(u".DC+3JN.gY^qD[3yCGCoWmGS\k#%E:(a0M[T3Geh@Rrhe4>l>h Y>q,|tTJ#1\І Xʋy)R:ZZca3jHieSQl¼2gxzJ02'T2eej]MeM擬mrqx:.R"L;h3S 4r xLa |n٤qg)ӅhϢd<`ȸ}55%qC`ʄ4(}2;&LB>'I҇m6_!W~Hfl(Ne NXVr{NUj[*qDDɩD@Qӹt"tER ~FʇK72Tqk-2.I;nǁEuxtEÅUbA҇D542Έсe&..Fz2,u&MV\ǎwlE95QISiٰ?ӟQ:Ru)͋ToM'DYp#L.EèY]M4xE"W(F&h6sۇh04bV"u=ޔqRhDOŎ|c`+t5ѧlAʄ m"]>'D\_nmZv{G* "ZӋ4Htylʆ^o|ET>q4g6<unw4v=qxO&,53S+P6cx쎀Eebޟ[0tM,[Qk J5#Xhatahev>?/}kzzvT۫:z^23)S\4ܝ&1VF7>%ʫyxɗŪY)س#jJ.Q.iYoge_xm!s3MT uq>g1qOF\7vwhFqb$7T\Z?ȽAӱFS7X~ڙ"hE"1v2* 4A3Dk͉*R9Ryk>nM CŦX"r|EÔ LcK-..o;U9CJ|l>;>ӇWʍj`j4;ӋugEc.Tu|#Zcpf\|cl'ĢJ^ uu w6kڟ7QsO2%RtlŚH}DX9Qh9v\\\a@ ј|bٶsmSsu99q<,>DAkګվv^fq6>XClp#XtZ2C)&HhC"\}Cyplb(M8꧙=~9Q|ϕK]T'D[<2ϧdddYnO'aˎR|+N,DRS>MrF]=4KYsh&-svC㖹GLvuP>"vh%9D&V_UgCz٢Z>M*r>/3(.^CE?3C/o6RNƋgžɢhuu2[f/G7]!.㿗*$s쨋W6x h/G:4"mV~j +F}A'DZãpTEq6^H踍d/"W@҆fht1Ouv%r,Sw8/S;&[QEH ; 苍uF09\áN;;-ӷcV*X*v5;.ŇXtJ"4ē 5dL:/"|ka՜.Qo v)Ri)d1qJ .3"uiUqu.:E:"Xx'M/\EH8DeK[y[|]ht*fN.-ЎT>W J=f\C449ѣ tjHNvM˛.uASPdg.TR}7?SS:")TN:O;d S'řo/nn݋!'-٨ @.,.F"653F> l6%m}MSuD|7UQ:GC*+puDeǞa8%-Y_=?TQ+\Bq«1t.o?7Ocp|bg'FS.RE1qsEl&yQC8wSͣXêTG贻)eP +S8gpJ\UN:dULtmh4hyeAΑ76a ᕩTFvuEqL*,mFE4"-~y`ɾ:zqgv\ιq˙"?SNGT4cQK3Q-OZsdŜNeN;'*i|qf.D|*'`lm$\?F:'i)hG2R2O*Μ\-L|[#'h,NV6Q<f2=ar~:?*BN":.,l(dED}ޛ(l/U^RѠ'Kwat[A~WUi#!fWiyɧӳS bA"3C]j6V/٦eqk)Ңo .2.s`93tW/C\;Y- 4`l*wP}F,z2xcqgfcic`7)A`7"tPȎWW]%6담+,}'ĭ>)(ټƼ:I\~u̴NW r̙f?eTQ6<=.hFiE}źs}{Us7:=uoS:r.<N3uK,nEKΙ2"ccWRQ`Dͥi*yŜQsh-l\gT_1Rqh?QE vw|ueoVz\QPMEmTy|.,vWT;<ҝ[V~U(DD 6'bB$u[4;DtEBChvd /u4Ez S ˧.ˆѬa4Nj>Ə;nbitwhw>hD\:QQЉETJ}[faxk;C'Y?5R>mv# \>o cgH7#- p2.ʿyu.Wn|d]{S'*M7P+ȧ`72U|w`>ٗi#pYu`e,u9bcG]6DZ"wҥPntf>?IhDXO }1I9YƟgyPͿS6׷%.zcNqQ'FEqѢ|tj/xh/c6"8C\]>vhD\u2"DPFjeɶv8#&Nj޽D~'Uhfy4=. d])ˁgQFw\J.Mfsn-љ>w}*Gc//at2rΈFT=Ht qN&W%o7=8Uȋ9A>,S~Y_ʣ< \ρ&o6T9GoS9~]C.V1D}(v|Z\* X~l[F<|)IGC5\.zuW;9JQ*VG>C̏+oXKyΥ>NN4":+h%`q48ULƸu%^co [!i\&(Sdl[.\}ojs.uZC>\J }u%;)yD$D\[g*̨D\ϴ7PvWSENWhM>cDv;AuVâ,sf4oTˤiY:]kwBcCgR0s(35hdOuRGN˙IvDgʃϣ<;OARڰ\7-6g)r6wa>2~|f|>LzZvh1=daIE6>"1g i_D\ \W.}ϗ. hu ȳ)'5",-SDWZ1,5:EEO4EǮ㣇jtE3\Ƒ(vC .+mFWBT\p@Ş:*cb9A"Gӻ|mA68E=L+Q+8Z褐>v3CvLtVټR~3+F>J;(vPT͋élF11ٖm6}\o6iۛ/E:Cg"ς8ǂv1> &ցr˗x>,zuV6W'{FXib$urldٴkNt6D:]U3\f2A"ם46leJ#[N3Ff=tS:ny#.hhA:<\yoQNtSM\;Jv)éu;xϴXqOzSTY̹F#1.g\J_:lt{Q4l238CtA?uu EceoJNJ &߸x#\S0]3)9+::NPx|y;3R:Lu":\/Kŗ; ӃlSgKtXLMZ=ӯrzGN^>rSDR.N\CZVWDYjM.fC,':Nt3F â-W]18..<:^,놋MM9+JtUE1ivTNMcfuVug> LoGu4+fh+F&˥;iW3wS?TucfafaD횁ohԉY}U| \4b+`Dh<>V>#qm٫V&[DzsHNokmuCrneΘ!ir;:Q:>V:w p΂OF6yќ|va3'\_(ͳ.B.bM>5t2RSntEE¢ydEq2顳8}sEG:VvSYU]^dh(:S(P+hڛ@|nmXe*Ҙ|Ε;NޯN5:si,D`*Q`é4Dp\1tzV}"ՏwxNdt*:;je2N-;S.c. Js['){y$c '.S.0S&EqTn;R6.]NT~UIqlm"sJώ&TRXQmN6-pSvXNι# 6';GCt:#*O/3?ٳkvz0FE ?D\u)FˎDVj9\俕&|a֘~Ue i1}[OB1cL⃩7T"ʥiѴW]_Vht)&TX0s OX 9R5 yg\'P.0 xfTyA0?xJ9Br\۱ӻj'H9qrL8d4; CMb`$íφ5͞^¨*pa藝(A;1n~8b^!<goYΫGl"$>:2;s1vvuL26[Ƌs D~.P2G]?a,TR|X> ]Je9Q/U}.\w|Ab,u 3D]~ЭX<ثGS+PeQ{NK3PN.%`U6a>+ŊhIM*:tte137is- tasCS٣:*QgciE皼X;SQ T3E:=@v|n 8П-;+ |EK<ϋ1T'GLvo˳h6-wL} 'W]d!}pJ㳙O,٠\;M=q6tʟf>X*VPQk*e?2yG..:FxN.uS*ǝUh|h=#1M+Mʩ̋hlc-s&e˳i+Fp6`9ҸU!SAG8'T:,|7hQT.Yʯy2٥|.h3HU@Ҋ]PGsxkHXLW.dXԡ8&Lit| }B%s<."ϗHt;4ph:a6ş0|TYSBfrGY4{ b~Hس芇 ldgZθ`NVLT%4.^8\۳J|ϕl ClSN67|r?c"GGS3CmmD|q:DyJ}M3b;Cc#M3Ɠ+(tY0 %`e \}b,,珝ha|lq^5u*_@Qd\2JvMA<|NoJ~2s xXrES}=qrcT@}6hWTNz) C/Dpr"xD=O Xgá1B:\:su#4heC7x;4lOoo 6Εϊ 3cYm;^̊NҜ6dY{'68:V>hu"W)L+TFdvjX{>~.R3:O皢wٰGlx{M"#s:*ĮB~e \hR00dcBWBp@ @ @ @ @ 800dc* o׿T9'U(iݵ;GnwD|~ӛ4XQ #lE[~"v C4;ѶG |<~ 1)<]cL;K6sZ.8}y6|.+uQ,xĵtڝk'gu@Y|" p`*8Co`O=xaQr'C}Obt6Y䴧wGa@5њ.pmdž-&V_M}Ű4I,i]W&5>j`Eq9SFT=h+Tn9̈E"*-^1>bGQD\oކZRt|,lD樱hhQzy;4_}2Iѣ k+knJU7tő]X b:"Tڇ9԰7eΙp͘1[/Lȵh3D`[}Llس/3Fll."%C$dr A[l~Jڍ[f6o. ;h4N&u6>EZQJϢWoQo̹rvGT(AvD`o Lek ҌrTlj~V.ҩ?0hu7Xhew\v"8> <7TeLع.W.Q͕E6HS͍'GCGM}%gMxEܼ:nu_ `ȒT]Pd6m:-VˬcF{h\>!4[t474MZ=˿[. >q6l iWapl5^nY~6>b%s/Fis."P c.J3 :eΔB) ͞ e˴v;-abFT~b-=BdD.lو+yY\<E >sqzߛ(ULusYho:%kx4Y8qX(yӎY .]vZAN,|:5Pi_쨟ό+*d.|"j>Ŝru&'+M)s.邡Xè**6u֑̓x_`*duj_u.A4qv)} L:vJ>Ft6Fz!>ož7qReoЌE,ȴ2ᶋnǑCg* .3hCp Jb "m3H͞:1]a5(:"ʯ4Zz|O\\JB{>jo)-ӥ :,K⌢,ſ1Arh67mVFi>ȳgvhq,N3t|E8L7Mcyt,|5Eq6PTYSGAE6N˃%szej}lf]!l v~WAʚV~\!㱒>,`:Vat&b'D3ncmuf+G+="؝!7Yapv\>i6;# WLV]4'kQUt4YG6GnlYv|k-Vl3uĭvm]>}8ῌ6V>deժr˝װTiU}12ɕ\ X::mOLӕT~c"Sd\:B+8>h63|2/2ӸN"rj3S<.v^we.%lGfZc,'Chl:,F 66,J~I5AfN,ސ2C: vvI1+:pم%hf\ R"t]g@':69I)o7\ds%QϥK6. &\˵ۑ$~6kTӷ\Hɛkǁ G:j"749ٶNF]c㸪 v86yc+9P£)GD7ERh"BcZ6㧕*]rDgH1gCh7ȰP2|X7Q~?JW7ʂE,hl17ō~VL7~|E giq|E46%sh#=6jkqzVLN췦ѢW@CP/B.`2J-o`t\.P4X|qEfR'b,>UhFhX|GiNEut g6"=PzPO8 ԜYQWY:"٠ge: g/:l^qn0%>?4W6mt͌uHuǕOgUGb$4X:/7LE‹M#iiT0:-Ъ3˨.r¦|?jaL\R;CPuwk _vyE+èōUEk+ >"scv`;@eRk#.8Z >vHMOґ;ե?ў%d4rGCvg,Pٖ*ZkeϦ|\24E:a >*ʔY^=tPN1sd)[oFLr I6ç0|Te SC*"A^AD\m2\,f4uMkQnkDXh]H|Eڶ7ieC*'.6/ 2\?Ҋˤ2+5 PtkMt*W63Km˃JVz>j 2Gr}Z!ʛվdKiKz"ii4!SgB>>!D\٣D-XADYp-fas6,˸.katcvasvS4]f)(:,]0rUa̧T"SӳGz"s͋>h& :,Rg.]Ʈͧ>JE +]9Y: dQ3C*> .6=P4lf ;^Dg+m @*\tEͩz44\^?6H<\Q.41;GvTG3Ś E+*q\NG>Z,e@a;F. Ӌ;Jt_nͣ豝scݥe yɮ%:FJ ʗy.-*V .Pv %t`}WFqAtWHo+̦t>+D4Ɔ>'jcƛJ:'cE4x\Jۛ ~.u1Mœs]u6T2"$\mld( 'F]viӃy]xEnjhASF"w>3.|Fv9^&&;T.d0N-X1itY~tg3G",:v6Wg(`AXUV\FSŕmJD\>";b|23єbN&{9͘!c,LyҰqU UT0aeDCFă>0yŕRDJ⩇g9 2=[Ch5**n0|{/ >ȝ;b<'qT8LVEbv> o;Tݼ@g6HLY|N/3sHdha.0;URgHaeE'Actki]QиS!qu6CEPG\I1}jTyk*ӥ\ڲ6HhkCmx{]p3.liQ؋a`j6hϣA'}(U^EQPh>Z3Hdr ~zbi)b).|Y8DY\TET[CEu⥉۰ﷃ̳)OP#cg.\ƃ.9 9tt}eRáw 4 ~4tX C᭿:S:6[%b/rUC\u>Y}|R|6cSLCɕv̝qpٽ^ϼ Ӌ)TKEs~KMV).0vYE+LehamigF։Vi~xT^2hTQs[J”2-[2l\g">+̺QJ;-2E^.mJSD\ԏ $s6-OgDrfGew)E9JiNO.~"C~ѕ;C3-܏+LJS+."EyTE#w0}"H˳JW1;0 .'٨9%㦔ń:sA T͏>z$ڲKkxTOoN*m i5sޯwnLnil|q.Nߋ#[;ADc5|mŻ©YޔEعM2m.w SJ7YgϿd>=#cK]B,ZvO F^|g?n2N] g'.QeZ/ xi٢P 6\Z\ST+|L;TU.qd\T`O`TQNVv0KZۉK.о= I'Fa7Mv1Xt4.-~ufhp,~鐗6MQDJ+EpAJvvhh٢V0Le^6;:-5)(@!8y*>,.\mFZ.u6o6Lx\sjGB<\M**S D퍇DtYé茪--*'CSDY¥ڛ4:_vPZ~h#E"ٞa1N<炼N:m*;0W^v&L|thΜk.Q"unl􈹳Q^hPd |y:W ϔ@;xt59>$rSj Ft՗鷧ұLKҪmO2 ] T \UlDXGFQC紭:=AojcԨS'R5jm*e]pdZ|u*ndiPlE`77ٰT[&5SizD7U٥s.]C"C^tAd#&օ$Ȇ.VwA_Sãƻ W}LTEl6m8;=:? 3A+tvuş{3(Q,~l}\l~c͗,WҵL}9Dgw>d?ˋS }8Mn> O .,Q>.4?ݩ\f#F\ⶆ:\vCii:o'ͦ(2+ԃђefр,MXhͧcjn7wKJ|G>m:I-\TZ'KSeJ(Ȫ-gf홌uO7ˤ Hy՝==̂zEMYwL7ֿ6}M|ex~hh:I&[ t刷.LdSQ i;NVyˡ'.-tq9G՟?GV<+ANtJS;͢5XBm.Oň>bGyQQj:ұDoF x T8TI•+;|.5{6LeH\zΔ\eU.tgOˇCdqsqtZE֨.s(鼦Ht&vî8`*g5N:4Y6>.|.\t˦Q_uI9G/Q.0lq+Ge\;rϗMWΈYяG*qj~CXLWT0i[ntz0:Nqd;\+>>)wyycQtg^8ziT(|*,j4r_VGE5R鴕,vlmN:>Wi`g/^2-ODc͡GƋpW㏃seYH_yӸvddG[P~EY5?rYu1>@CtNL*at~\x*~ OO)}==LYΦt6驝+ڄAS(&oq8eD\cXhSEO4Na~ttG,3K-wTUS\QMˋ\Yc)DF͏Zըe><[n~m(qPO5otYϥ?cI&8tGN,lZ kעYtD.˔[;QdR&Jw2U ʛ4|:냧̎"!ʈvi;N< Ӫ-Z;΋D\~m.ʉ> h8\Sieͱҝ7qcmC &VP*i;]1S8L٢E2@o&TTrbi*,9bwtU +8熆nl>kji|=lcRqmw#\VA_T4\gdOj Y+;P 1:Gl&\*>"˕^NtMpc@JV?Y6"1>6.|0&'i+CPŘ.RGixm6땣~:B̛jf8:۟}S>hǝN>*2OYO9Nq鳏)RqeCuyPVg,wl<*v0SPԍk29M d>f G'Eκw-.jvj5;156+(ȋw:CdT6,7(y4X+u9ˋm+ |Y˧ϡޙof;AkM|!6oVtx2 sSZh㿣Aҳ>㩴". EFÝH:=1GN4vhh:ƪMz~%ggB<YDs#':K<\XAG\i_oRdj&2F?yf23Km;l|.3uJv*u39jVaSMvTvlJ7V\Cwg`nV-B_|v>(]4:\T`{s[C]>},h ;wWi)ȋ՞mgD5$&ʎΏTcfC՝bB.^8>tMj~d0gV>8DsיF+>vhFS7>.Rib-v1B5ʛ*{v(|C[z,4ǀSmH닋vcVEpn6v0.5[J-LuL8Ls!Pu u:]rJO D$㣇V:;&G.#n2;As(蹨JPTX@CdK>|2sNs>4m/VfŲy1åѬ h2 |~X$C]`l8hapѤ>!C6xu(|"556>:Fp`t4HVZ7랕#T= OΝ,^;t~]âlʣQt,ES4a<6pD|#sP-pRgAC!JN34|U?64T?͢ڍ#'h32a;Ƌ80ПExsC ܝccȏ8SNGt]DT.qg3C\GL\"DX=CrRbDӡ۫jh|COFvq #\ƍcSzuF4ҡhp@4?;}pD~f;.t¢-f܆碴?ҋn4-3,lʣS)٣+atWl/Kh668]@r.CDo ;mPT2 QCaΉoOMyEeo8)Ms+ Ɲ*%BQZ8:z(k].T[D;SGh. :, Y>)LE:??e]/:#YEZ]VVM/dYxh*.F}r*v\h6yNL)iY<5C hC}σ"7Tra1x\m+R4XD~+6N="CFqD-s鐿_L:6VuDz@蝕 G$\l.EQ+cb!8 iGE,NkDQTʌ>-c uŁE meeh;*,3DE2”i\^;*"h3'H21rk8t;jlm0Sgx}sЧm\Q,C TYw>Ll2&wjSdƸv\"ʁqeb`هQ=qOjr6'ol2d/[;a1߼٧s/.F"eɕFw>9V?4QfxSiѴYއ@fމâ,6WD\vhwƛF[J|q2aVW 4h# ,Ѕ24GU4E˃U+CMo/=<:. Ok9RLY2=2dͰl-gKxχʡ4n6?SChl ll6UN;yPz=66CQ&e @xqj78aQsu'xyzq8鏧^e2.Mgi]hTNJh,QPP٥Z DK,ٗ|::v\ "Sjxo~PKhDCx*gJ>g sєuJVzɧk 8t.21´hZsx&8tČM#3%u1G}Q IFu='7 æ$ငb KI,2LO>,neMۇn(uʞt :#29O+jV;G+wvyn٦֢ [e0t8g@6'FR\LP"D[41ʇsRJϦL`*vYtF"Qah@O͉TEmK#Ƌ-kDzZ*PcT~jxL}SE+o?:< c3OGP]y6>V ␌ȺSl&^qPҲkitχVeo㍏`suOJ&OfiE"aN3͋XHr g@t6GD-rt7TZ+S}ua>NT+aP'34PWqx|zb͎SD N"'U#v%K.q@3*Q`qjf|CFgT؉U2l87ˏY[l|So|Yf>Uq˧>2kF8xc9\^1}i׼\ѕ#-eLHϣ杍,~"ׯEu] eq juœv\\xnb9:ڟ*\>>\CK\kf6:Fv].d˕2Wn>9ָ*F+QNuk }ȞtE<锨`74:xLZʵ͹$m&9[}ΦG곣͌FlSPAUCq61aRO>64َ> X:,41:4YʧAT%mNãNR-#EmAіAGhLU/AꝮ:0>V|.9zVqF6h56vNYLˁyZMmOhT]rczgE=_c4rVa+aӥQӋ]2]:Pe9Rm:c,@JTYct s$ ORZ7f>yDXE \6D<ٸVMh fF ;,@TwYgW|;4~m At2pj2v!xؤ"T+ dCiLk Qq9yqo9AOݬ'Fcc(fDk.CX^/+_:Wt\cT+GKcp<#Vdn|sx+)+ZeYQ>xs:tx}6 v˂>c到44k5Ȱo)QW!q+DQ4' DEǕrG͠H;#qQMq6_@q˦%i+~(* w6:6"v. EexleD5:ŵ 6,l|C*p~] HTX.-xUAT`|N.SA4^tY἗pTg}GtGxl3Cu]Fүx|å:1CէNfՅUҽ(꽕XV]Z?+8DWDg!f\jR8&"TMɐ@;LGTps[M՞S^L+Ǎetɓ,cC .h9Yf"QiS&JU਋ GyZuG zl|i,Zar;ь>-F§Yo0|6tot46r$鋋d6i`p̠EF:CE#0| 4=Je٩hN,m3D0}8aqqtjFQe旇.JMmi XNQWMHL=FKŠނ)~Sh|aV3 ݠʛbNׯTpJ;0=w9M18{* e͇9R(tEáUi[ji58;o#>m=Ϩe =htCvq8ȵ".`5".zRgir; Y*#4}}Dj8}()2lEȷ*+j "Zj44Y*,Aqd*Q7yEm BxK' fO7压;0| 5΢,P&ΣCᎀPhfXfT~t|r܍";D*m;Ody\:4sCEѡ\J).ʔYh54:qpE tEXB1~+P/&w0f"_h0g ]:vvu!GTB =E苑x4d]N£IChPݨqQd> Hۮ]ĬT\<*9OF|S$ny:11q@Nqsjz &AhO9`5>gEƦT2),0:,'E у..0/gt[%C`3uUӾfO̍]Q=c=G@jfjExQD\ LpV5"t h qpt\F9P뙱f)\ˡ"Ry5M,L}]v.=T#͏Uqk;RBi攦oݼEx|dommCQg*26̿ŝtW6~`LX~רd&N3*".gt?Gs vVѷ5KGEF]\4ԃm acS3EEÊCL\u7]¡U8kjGqg=\XU'G&K9R>=a"0tPr_czEw("'X;;BrC N08L1˙" jP8I",գJl> mч:N.hoљpa2w.jd2,9~Vc>ˌ,A]Nh|!~`trϔY` hNvhej)Tww͋o uN9OQv5yaqltԏ56hSl&S.'q)4XU7Q; L>-%+X臄-pCTvl>t2Yf*۝}cF\uXٿGycv~q!G̟ .vQf;*AC)|.P0Dő"T`X\ZGTmoڭf]YF 9pu6.FN}S:å 4ˆ5rH'haqNV-Nʩ1s46T66qeU;#)z;Afe^/.h2,L{avsD${͢M3o 4YoxyM&Wo:=>' G%qt6 tr<ɇEhdaGeX6Nu?[6h֚"iG2p>\a"©fӋBBQnefTvifof|R4=Տ%tGp;I+7.x㟘lTwόѢ-RSMV Cn)3"Uo"8,ce<~M?Rގs.&v;DQ b:cֹxYޯ?ll;DIpu.,>ӱNC8d"ٴ |y'yX|Cfn'gf:2lj}uu̧Y#eJOls@EJ^ sRͮ7+Gc`6U 2vϏuFYU c,;j;M8ދ8g;%݂݃F;iYm[n*,OgYv9s:Xcu%Rz8O[>>Zv<ɣZ@ɞ+9.>Gx>fPTXg}Qh3ֿYKo0LZjEDZo7DX1^T5V|f5}8]M lP:6)'Ѡ  QQۏϡGe,2ze7w7hcMxp>̜l6}.lW>6ඏ Uчў;Vʦm͠~Mm4iv1':,5)NXJ,Cg2,j8ZS( Q+fy-tr\B&FJlvoN.3f[Ka9JeAj}۩*P6c+֣UgYuh :C2M;%DPM{E?Jg/>TyX|+VQN͗Dw\: dt\?˲MϪdkdGхQ6%=m-r4tn)P}",TN8e428%Bitǂ̎;~dzq<e;'>9O(:ӲT".Et:;v>Qoc.; t**ie*s|NJxC-^(]g.Wig;m?,N$JëC;̠tE-%m;562'fqQ|ʔZc~n*.u˻FUi!,>uӼ{/僤~v~(m OQK6I4ZveЏC,CM. 2U4NV]9F\kTSTiX:/ouqtexqԃFzvh KE/Ac.w>PNT4[~q*vݣyTJN.&cDeQ_H<>6nâΌ4HD[-̃@; sE'P*'m@hN'f}vhe]HxǸ}fCj4Qe*ˢCO"$[Dٟ33Y9Pҹje>="U.gMڟqu(uoEX^4]V9,m&z9rGz|m:PujiqӕtT.ę 56ЁX;@TaU<5.>(ctžcaҺ>'ES|nGx'(|EJqqӸ;PT0{6hq2:vMGhh42fEj.q+yBvac* w!si6sCH4e>am\ wupG)C}9X:ԹqtwX7\ڴwC˳OZZZ'=>\Q7D_3kL6t]9y gZ8|M+ch{˲h;q4z\JuEH؁T>-SGrT)64hp+:|NՕ6>[MOq0'eꝈV+*pH]M,hCAԧ9,|Ĭn:ovx*H:,oF|vLTEΆ أiZ֭2ʮtmtˬoqD=9uGME +ECtgd1+<3zΧe'bvJu-WqqNˈN>GhtEfaŷ_ceZΚ(Rjez8h,q> +}90x0:Ůʡxs+PLe>\Htfg5+*-Cmu#̃\>:ж>-_ϴe2Sa!"ʗ9|[SԪ.j}cJ:>#uo"0|X9stY5CJ,Go,'-J*jl9 zgz/P:9wʇiY7Iwܝ S dη\O۪\J XEG\ztt?myc˝cԇ9mOHãxC..dV䧝FmF. l>v aSg8|0|[. 7ä0]!}X4ECCEf:0rxYa3yԃC Yg:Uʛs͊7P >&+;6=g/n>"JކHLHaYේR1 :)ݢ>'hw|)9\Gx3A| SÝ3]Fzjg,n2]. pvݎۛ"@HmJ ?oN'EPBϕ̼zl]S?;A o: ".G:x3D*61΢OPTr8E3zڰl\N U"Pɞ{Cک62;]DGq箌2VQț'dFP|k6^:vbކ@JnpCMY͏9P52sB?p=鷕5i[;:-[<pMLy^_A1ht;:GiBYtцgsP"t4.]xe L:-ar IgXaro6 ?vA2`?NL ٲ}ӋmR"vz>Ogq]c:jaމVn(|Yw4'5hcerX+RWP}6 Cl\xQ:B\A^:K25TA둛c;#8[s|ڹ,{_BEGŏFm6H:Mqp>hiqj|{ eJ>oRG Ɠm8\A6(p鴬J o4S)g4lџk:GƵhq06oHx\鏣!7w2lY^ERt= ]4S PfCF"Seh NC: 3ot|g\[ptQG`j9YmisSهE0挡/l8 pSĬ iEG q&\'T>EAӵB|4OdeͶVe >\|IFaJ\?.A;BQ ;|m%Y=`5>M9\ѤHVcEB՟*P<쟨O ҳe9p..Qt#J>+G:~2:r䛟5ɭ \苔u&CNFҋo@FFc|/Lh-&yX؜M,*:.:o^Chnzo#W:WD*?XHl[yviN lk#A;Omgތ&+ʔX5fn{9ӋA펙rQ0eOSyٲLzFmoxbI0fks#йń(ҝ +#N4E9k+Cm}1Y'dpWX]3\Ԋxʋ,jvV>U2*S"-^"h}+><>W)x*'a됋8|[ >-cf yNdr!C.vN "-i,ph"QzSQw:Z#ڙO :mm4ehRisў,fJ3o&;}xϤV;'+}sf<~dVHeLon|땃vSԂ,(Mo 6qpAEwɱ\h;J-bEl:(rm q+"MϗxSEs5#9?f*8qQg̏QʂkI\M ft6cޞ4Yh&GEƢJc(|٩%p5) Dٰ貭C>tɁeƝi~~)daM0 I'B'ŵ2Q9c&s)A^=INgڌ,}x<0Tq1FT6ў)]k lwxXpRG4;NXʇuE뇊SV6iri>$tS>Ps挮~nۯc`u< Fxvhp9MtYЈE}F߼7EM`vFP|[A]¢˴O>:92.t$)IapBFsMEGNVPɡ00dc, @_:sOuݽ t17#nc$՟8V]9vlVV~".#vzg u?b?R#di<;_۹w孲˧]/Y㩝J/3rѻgc|tY6ێ>/ih:èC53B孎f24vC:%vwTB(ތ@T>ˉ[.it,ǔ8õ&l|7A"23mGSsއ.|a~FǮa}-`̤-:R, \4XCfp7(h4uTN̋!L*WY`/hLꦧ VUNț1R be1DulYvF4۱?O*wSg2 I#[u|&dJpT4:llKeňlڇq164.Iʭ0cޫ=s%=""b܋_kXkUh/ͳjwBwˍ˙h|áe.;+fvD;ERTݲocE5~($Xǃɒd9W: yZOt\ h-FYEͳFG%mS~N*aq: ۱:,$?|kG=>8twZwRvuwU^nӔvKX|O|؎jg!Qc2}B&t|}pl\f">ūcmꕲs  c.:ʩk}-jсܟ#/ tX["?ql)l**?(., eJj<2)uG+Οfog#XGd<>K Q7Ηh2o56c?v*+f0٥J£[ErE Yh0|fjqs0 2ԃ2  pE")Tf*1b΃.<0T+!9Z [M6N1a1Tx|A~e[hkQx2VQ:YCK 7\?\tmo&:\(;2=[*hLNן{f["8e>w>6ЎHE+:|(h7S:V_i7sH*!\>ˍ+9j.4dNѷ6Ui^ƎVeH MMgz !ETwCNTtS4`\De8Lň Asκ6m8%jaҝuSN,mFCaȾ}ϕ.nǼ.^Ee-9Di 1ʆ 4qm+ MH9jl#ƽL]aTA"Ƶ>hFxiA \ I0dX2,kSDퟎ, FJ@tt,aT>H_\E賷c`}%ru7jMӟҋ+ʈ|:'3苞?)S%Q2ϧ_aavQz" x;1Jzr DYNT͐܍EןZ^d>oʫՅ&xsIO̗gqseQKEOk 69iP*~,qRDP؋Byj'*82FT"71mƘY(Fx`LY *" q}X5B}\H qfۭj,Tx`]A!.0\٨a|Eaiu& x46fo iJݠ0|ϊ>h,GB+ sa4mE 1oY#\)θG3q膶29RR3B" :W&hngMuʣ'D~>jNʢ)bٞ+xӗ~$̩L٥|GD[~x㣶nXq=E8u )XN>Z2jQ2rfv2~V6m;?y|p:ScϞ+ EZ,FS 2e~m#Vd}5NjDBcbEm(4Pt[CEn}Tqq^KmEѠ6e0U? 6Qr?!EƋzPl_fcQ iПxo: m@s T./x!su{J#<:SEF⥘C+enlmz޳ɠ8>w:.is*;Jw?#"\mu4& eoN6m &6Fx:kza8OgSSUiEτʕfz3Jٸ;O|'eXiI4.ai\95" hll<GxpnQs-4ELd\bvX\NE6<}7hVhuBxx胬9ԃ*Y6;,}tYL4ĵjCkm;lP2Ǎ>>r9Ʋ #N.;@:: N,9qhVfc`Vzw0l]-v4~h2]M>3Ř|xihRI;Y:?\1JTiQ@n LE:q|[AAQ?sjG|g?T2w4iWGt#֪iE"~އ Aˈ:46?xɢAb ;XȣC5\voyUe6}ڋBsōUϏÙMQs|Wd:4?*C4tY86#q'$U;J-ޜçy)}2EΡnNz;b2xEàcyN=aShkjP%/:4YЉn苍I[N|2747 *L:1tCqt>^6CC >:)R>Tx^&RtuPkG3Ԍ<ζ"уfcˠu(t-xJ.}Nfjv|iNm SbOe6~tٞouy }uiO]2͚,Ne|]Q=.R`ZrtN5!SԃGDAuނb|.۟} *S>E-trF?G Baҳ|c!$v- #grP:.T1sAʡ#7sMMʈ&C5hJdj%puHoCA珏2lp;OvfɺL \/);'AZ|\:]@Ty8𸋋qsChAݦBAzEEOe.P(珦֮vNqpT0\JRݷ?Bۚ^gP-@!sr#˶x)VO~u͏yEӰt:>蕶:i`*]ҟ9e/H+NFW oWGGqu:9P6W>|3EV٦hxa0b×Rb'Ï)m 4n9P\ŰtI>v|;]yq2cM);<:V+fqSJiQਈ\Fc%ge Q9b"+du)CûS\q6EľD s+.%b#з0|U7jNuUA>Mrl&:<|1JWyrt=g쨋O9=T>RNw>ite2mVq}Z9qNi>u}4}oυԩTcʎԘ\:jE ,=(m":asꕚ0*rʗ\ښ~Ӯͣ\ YǬ*u>(ȋ;]6E-U)T.10T,EsD\ϨϕQN"Nt΋;#~B~\tdsӾc۟Tf48] :-c* ϖ?)̄2J~+*t) s2ǨT31TҊT7mWU:TӟcNg61Pt\Q? KZS0|;N+PeH\`CyPfW-:.qqTeS@! ;鎈O(4qT}17M&<>4] qybEt:"gY<Ɨfe@YO:8|Zg\ld6&[xB,lK΋\oGm+[Dþ{ћU,GL|Yvxh8fyYfz?tZ'4^Z>%k3xī:QU[O~18]^ǣ(SP>D}5ҹ"8 pwfTvOa4>{,U6b*yY|6LCEC+S.q]v̙wƋS1DxMJDY5så:Qc¢˘:ڝ떋O>Dlu DY'@N _EE(ϡShTe.Nsu>6Qh:tϋ-sDה\CxZcGD]q[: O~~d!3:lU<:6 Nll|M3J1PKvMkR⯟Jw õb;vj*hwg*<|1a]B*~Wؼ!?ҧ9ts ȵy՝[g_yte >?2>DZc_8ft\wMX^ N+2+W6?=".4?{A }OeOq-js eY'c:>QѤȹ̇i盉T;c)Te;V46Ä|Şm(R~iɽƃGSɧfC4f-cE(E? +.=~'q_;CD#s|މTYG>.tRe>yugRzك>*D[|ޫ|/å y٠KU6}վ˭~9h١s}1J->}.:t+(}V.S#hl+r/WӶ-u"ӋIҢ @lKBPnit`GCG4*ӝõ Th32?ݼ-aT}>+ÉUEJ \m,Z3ێ*>ݼCẸ(tQaԣH>vYX˘ڼwijsӸuaq<|>3(!*߼xoQtq{SC*V}HTPLj*W`xS߸>qlnjlG:*t6"Ǵκ."eC}q{Tf šd;3cX]2T*"kL: :.#\7SqxkJ;lz4Qt5fRKRyXD\[CpϜ3J,e.xD&MTE;Fy;pʇjcƝyV\?]4|1:qqunxTXGqPW7_G2f\îGTڇ@TkˊΆl8T]ҸCjhӃJ1nlNˍlCESDk44|Eo+ ;<#e鑞45# 㩅\No3<MeǝH6iNǶŨ::Qr4tZ#S;ot1𛟧;VDJ ϡ⩕ECe1eEȸi*m_P51: e7} +gz N,eA9h};+xCayE̩UC\ͥntrRi؋wxA\GU4NEld%;C&JQhs-;a놑r_k;6ʢ+Tvh uMcEڝZzW ?yTxx6* )gӝrd.j}ڛy3m8ZFx]eVzğ;2zu3ҖDP(:DUu7e̞\mgꢥBZ8؝&"Ρ}9\ﹱK;t\hcAGҸ:|'A_D((*;Ӌᕢ\T/GѨds7N>Ҿ.۟G?Hųs) Aꔞ: .ʈũՙR7֦ASnDRtQf0v~tXE1Nk\F,W/V3H&k~'Rv.ꇋĮ2nzfE|:Af&QbD[x4MUp:]W]l*t s+ӹMtr?̡;N*, 2?oTa6ssn7zq^FtNJ P݅l,,}6]r- vѢƎҝo6];EgfC>I>QlARtR>'qqGQl:OWYʆ?ǍsumGxmz/;ASi&IweA,n;Į hE{v==4^12x"'RM#eh#wvU6rl[ |:?u.@6V~mdMc-z 4xh>"ǖ9k跧U1S)rZ>驕:+2wX5} MK}3eod ڜ!23n>"q7AkSu;!R})R3J;Eʛ/uʌlpfizV>)ç:S_.Em2t* Q͜T4yX:mM Ů7M<Ұꦣ*F\i2:|Y8|MChU:Qf"ef&xє>mLP*Ad:ԣ4ظ]l&6ڞH2'sB"ݞcJ"?Iltp XB'mtiEE(IB7 ea<>fS0*l=;sNG):vL6Ꮳiuf6 m΅.Wm49ȟ;o3at)?Ip?ָQ# tYRZwڑ$XGʇ¢,z3h)Vqy=MqlkjvZvGg-Er!BJtGe]4sA6-AJ?Y *;( ŖYj'"|ryR Qp^;(dDRҸqRN΃UX v9r NNWf땚J.48 cF'r?,[DXâf>:QdO;'}*W*,''mkǟD6:Ragͣ٩˫N2"{E/>uFڔ;3FS ͦ9RJegKGt\e\땶NlsyNY8trSf F1t`e \W>aTjX&>zk$"TqPrn|HX2'ґ eg,N,lސdU2Cjat.5!jrᵰ`e7jJwҌ:.a4wQŜ2*.QE\-N.get.Qs;lPsIm4Nzhhg ldT>S)D>rw:d~'ce3 SDOF+m1JJCv6O]YȮw2A^ҝG U xk ⸋ (kr̡/8b,v:h4h0tʈie8ʋ~N99J\:-Z.§lt&D`.Cϗmώ3ة,CDӝ̸a_Jc"6h6w6;E +PO۴vr/(1Cf~E4ƪ fh7n;?ңG95&/22xw1ɛiɮ6T-J;u xQ󷢚6+U6DS\oysnriˣ U`贬E4} :F7]7mKX^N|{P.CNW)SEAS*<ұ4JxHc5}"V6,;Zs,/ΝTj4, PH3YT:B"-9;Cυ9}6w8taGLweF\\l: 8\iSټ̋"}豑;wM :,(E:!')y|êvNQWa߱mV*]>qeE+ךNMmY?!O-NK gݎMT*,f'PH; 0_EHqUD6U+ *Sp`PQJ0B+NN@49mgؒb}sM+YRNCui(3IacY(9묁Xfݼn)7kf:  qcUh*Q-Y*L^- lt E3ʫp]+Z(}J0ʹyFR+rIܪgpJ ĝV*jSi#k׈&s֥aFH8YZAGHHTETC2I$e} 8Y&VTϗ+VgU^؞Y&֖A$k,>Ml@ Vk֭zq61!-G-)ksy7§4˃n')/fD`ȒFwEMե$b, 6&y}Q`@4ρ"'e̢S|ֵk[b<%TKeVmk X%Sgy]m`U4T:Ã_NZ̠֑l%vh[J'*$*[,#¨lZxn/- bHTETC2I$eǟ`~8Yev!&5F HnrTK5J'ɵd J㙵k4Ȥu W"]ZJ,mm.k0ָYDш[3 X8OyH^)IJ̶Wpȋ&iZƨ r҅a"9mV-Sn.s)p8U #=f]Qϋs} s QCasͤTJvLR)tG,ع9Ă:M JƃGvkqBUG*"sa*rC̈\qYe4JQ .5S YCʋXÇ鶌}4͏#a"tfIqbCpʚVԕw:\T/Nx;gEks~?eGRhx0c֛+C*'nX1#En<꼵M!-\ǁ:Wom2b E|R1Ync%>.gCYh/zJҸ %Qfb< amMFt[}F(ȮSk-:-24 =weiJ>v1ˤTV`*z}3End΃`}/[SgѬjfb (t lFt\8m+)͢o4CEj 1 hhiX NW50t'eѣcSe<^T$Ҳsj王9Ne0 k;z`/6.xE_S'}ͭm],ҋtv١sŷi)n9hmIٜHaVuhӻ2GT63hy[6-V!/LlO seSo#)d caåmMh#ETm6^:: riӜt p;`5Ni~CdERg[cN3W7||qog 6yE +,3qcC"Q=,}C+P:4:~v7.:%lN˶-^0ـV ml2\#tm:Ihsv<.JJ5D0<\UA>ZʨMJ€]+C?ݽ)6par(ͬδg4@|`̶DUi6o.͚e>L;.э zŻ8} +=2:,m)Ԉk MʇѥU0-ЁDY72@8.,4sfby1>vr\UN.J(]w\oM5EySUey`>l>cMt_C^ym?WDe=7eB ػK8.ƌ t5P UKsMZ.FU#6}NhCŲ2Ǽӛ ~\mm^],@RL:ӝZ26쮨;M%b4[FR><]6y:,Ќ6kx>L0 (0m cE3yv"ѡ5EP8űZ`F Ah7 JQF7A:-qeP+4ZS40ta.,gMtZ"(*Y¼* >~?<~\|:mtmF:aW:X |!rV#,u\]5&Bgh%keeSM\hb=tL+aU WT;1w}6:M>RfQlY@|e4C!Q`YVşj|>R>1tEze@e lg.: įsQ^@l*66 J,}+hu̍ Ÿ >DE "ތ9l2 Ҵ>,fjSSS?J(ӹFjCAeFq '\atЬl\_:pɚmѨ)cۛC|Jc]p:,h[?) EF2"4 ˧Fx2w>zl&%s͏baբ,F.s:84$[e4z4t)&ԋLY) R%a=!e>7TJ,qqqjCD*:?'h}VZ-ivE4ګw2rfi=c41:ScEtxR"MviCe)?[h4m&ddZEJ,EpmۑrHg^,šŢw :6%d/A΢d:?|h;*u+EY:W}8: 4'.z)FhGDeEQ`3⥢FX.g:[DYʗM.TLۮX+@QEh9*cazW)C(;VT:%q0\Ne!@P\Jɺ]M#mj+ m".6L3D>QTWEg\-IӲq8u`?;DIeEȓ44YT <&@Su@#9Z; im;J;O Nh FlKXjoH\¼^eü4ДXC"N|6s ha5#Ag^ĭU31;L>-CQn}1Jw9+&t)"w0u Zr C赤Y, +0?E)уN.3NS!qqBEEMHEEP+6W.2-)=NCd T\Q qQmzglO>P- eӲkkHR#JD\Ф=b'BAxD*!@3D\MO\ʁ<Ƴ,Jt򉒢4B*=i\uPc! LN4^ fz*:)Ӹm'&+-Aql7>f ä.E:vtJ˰Rt2v:BfXԉS憤JhCeSwjeO.-Ĭ,MZA:jK8tE D_-ἂW5"W* N!ac#+! 4;Ey2ʈGyE!Ӌ sgzurL%h: ѕ-pthޜK>Vt?UG5bOyc ])չ-'ꏧrgԜ\oFPE0-%E6R"&>V9sc%sRs·UX"7>e^IN#$d>Jv6Ep0*JC"-#'fTjF4عHc^?x}>2Ryz6QtX>/EM/4Y>cF܏pRqe隑2^vDqhV>wFEι8{;<۪"֊> .j֍1ռ˖V2q_6v:-.cl8 ,<"28Zj;zI^WKh:sk>\>Kã8M\R(y>vDYB^hdYuDEE:YSFV:SMqєXLt8Ƿs= 2W|VMp9H )qA=qhU2u豏V`t.*tahk)ɢAEXnzҦOHbYσ6̦w^;ϛCFrM(:CE>c\h .V6U66V=r\xGN:(j TZ'e A2,HrKNEH}2E-Xu50\hjsş8?A7)(@Yk0EN!ek >'95},ikQѬva2  uJՅHTcg室Wyz^x?{a4FJSLXqSz4o,D\"iٛ˵bݩ2^ɽq'dMŒ*E<3Xh 6EOќU6`jS֨C0ѬO-: YTЏtGxw\"!W= tE+ƙe2cX>BL"AP( :B[=gΓy}=sӣT\+A Fx(Hy+9*zm+A,Qhz4viYS|tJh1n [5vZP\4|".R;<36SNjeP8*O5H`qk2鸈As9܍:tKLUF4\ q_xuj tN D5hEPLEC`ͳTΕПI;0h@|Z۴\E3Q.s 2=iޯ[)莩SV]>tdNmY$zEAֿb-ekLڎ9ڼ'&Tw2V0uQzWpi:LqepFo":D;CYD<`lto3?2笪G1pq3hJ%;Ŵ#)Q]W=2Mgg H5A>hdk"(u4lY1ӛq)s7<^8\E",: SERoz.D!L,{i]z?K;3-Pz! u:pQ)4ͅo:s.X2su=b6_n@'Pnʹi>L.J9'IqsR,Ldžd6T]ʛ]U| u*`PNP4fOmJt2,@`EtVt6ЩΑcgj|Ғ)N.WydGCNH(\PvvWʚҋP:oMੲ+!r 8OEtCM|kԡӕhσص>hh:4>>xJiŖsb lNu[F`DY:Tw");Esht )tM=H\p;ѧMN:l3Eƅqw}>@ȕ>G2*ܧd\:f̎4rEߘC&4!ruhNm< gS=;40[,,G=J:ZMMA3ҸTo3`Z t,%~&$2e"0+eîs~QeC蝖h 4ZOA~?@t=ѧW:ӹ"YU1䊃f`ÇƏ@y\ic",j..Gh3@ h7H;;R靫sh<͋t.HCkPȓUDmz0 `8Y|id=22TsFƝ]QϋQ|Z:O(ORQrY9Y}4|¡,S\!Vtc%oEqhjţtY.\1e{mx\ҰS0J*1+ 2P8 >2X ݥk9QzCʼnNiMpm7gұA&vB <[}H "N4Zf$x.!ҝTffcdb+:ч!ŝf-̏*YT^>'j{E EiXfSC7\OIMiag`@yE!@mHi(l+|&(]g(+fYtO5رaTpDx(>+L"Ls%q<;USP:+;:_d'N-(sdm kڐi )t0f6[2P}e-X .&J2o; xl) lu"E#c Ӳ9(6ICŪyt*vwTجth}˒:J>Qq15!Ӷ^.qrVNZQmQz.8MPsDr2mOCtn>]aG) "|ȗCAkO->.QsEpaq<\ƍ)qz.6cd*Cl[qR:62 B|\hQ#Y+U"-]Qåq^Fqo-ůzR @FV|̩ >farEU9ˋoێ4\24Tf:D\<R".YEy~/,3}("9;b|7J"e1H!@A]/#3btM3nh69p;.TZ x劇*k\'d7X窚v=!I%ʓDZ2>V|YQҾNhMGQ&Ҵ;S>L2~ }ᲴW:kxAҲHEN,D?j~a: Xw.\uEƢԚ̕Qig:YWq&<|>>>Pv3.7\#",*8O#ŃMΝ2˔\;Dct6zQ<&lYj4#>s:+3va "ϴ L1S]B'6.ԧ5)鷌.-vXɓN,546UCY:]>tGo#'cQ*dU\h5'd&D*VLA>fEϕX Vo,ˏixHs:/ !2- ;PJ-[CiTPRG<]:"`*vHŃQf-C$k+=EXCTiy"5 .Lf0q; J=".4`¨wk=tfIQ`”:Qg6OGG\aRv}hDY8zמ,(d}(y[}.hD3MϥDVEdRs@tt,3;\fGU'+}'ҋMhtX+-Zh\[juz3zO0k;Kb.@YU44*,HbtsCG[vsڟ=KPCB "T^:M+\,=Qi h`;+L@rwr΍q=pꙢ-Z'EQphmC\ Lt{K-(*Z*_?mQ'b*"aV4v;Fp|k4Qӕ42lEES:]C(b hC .00dcv'!Dr=Was>{ =>vaѧ£?1t^۟7𨲣7Y."~cp6U70|5gs'r8l"?4-W5Rfh,śth Ŗt q\>;T&TEt\ꦜ[uCj}|QAaUhni2g_UODLfSc#H`@][ j#îm5qLu<͋GAZrt\Y>]gh%pXDō |\]"sK8Z@*gjHi֘&8Ң,&mx;.0+\i0\sӔB6kg0:fظǨyF; 9΢%eFMsB4Zhl-tX˧.9gxw ʷU\œ"Mfїlޟfѕ#gqh1 ѣ٤E.+zfuƆT=+#>m)՜.D|*|.Ř'ۏDY;`L+XuS}/S[,T86n?W4M4E T:#܆mOF56 c,:, L"684Xl0t+kf du"u6Ac9:09YeAoeEO:|J2Le.,d,Dkn *\t~FhTXc-Lسz0",ml!pv hm-%Ņ#L˂ͦ®AAVQё44\jR tYj54voLt;蟵t][hj qLa>a^:D0#Cyq!E<'AsPPqu2VWl<Xت:<4pAұ(ڈ w j¦5ik Yp2n2.*f)T7 Ffm1,KbP*Q~O++D8|Bg g"yOO1nӪkU:fdeA* Tk,*|Ed:%io+ji6pyYњ y> 6VhT1Tgmd2 Gi¢؁H|9t`\<:\ +e6QuӋ?6YmKE*yʍL:dI!QO\O-Pbk'm]٪HS-7W:t&-vMK-mmeLEo8,tO+9PTde#*~HhD.,x=R:ü2BHhFhҲݩʹ0y-Ye\'0ldX6Y[.Cn9Aov.C M ]7wSN D(eAulu Fi15Tg=9S4 ;>hD^֗;&CPUg2 upKlN8|aͻюOFadH6uH0u3ôע5P:nifj .f\ e"VWLDQ\y>] -|mE6/,ɧa|]Yce4..jӈ٠|>hTX- 2 6,̳%MݖGPjJ,.;B >,s6asYoiQV>1'E |t0t FXD|^ųJΦj3˛EݠDZ E`( 6JT\^ʊ8}6u+"w=+Ct]U2訋Y)JYH.'Jw";TYHzDYf )S z7H,%x : h:Vw:4ϼNTU55RSͥe3CŢ.SC"ΐɥo.c8h3f>sir U:qP="V?aZC.,/Bdy]hR*24< fCCXޝg %?xfTECя ƝԆ>vQ.EtW6la"u}(6hQѕ)qT}M Mnl;vS5eJ2W H\;"c/pҰph4xM*DC#踺5%D\S*G>͵33A(:cg]8E̝Inl>Nݚ-/'D\:f[5T:vNlz.qP}L!k#Eԑsvi6 :gAsQqqC)Ejatef2;ÉE(x`7]^4V(˄{,+2鰘~Oaar㧥cwÊmQOؓ>.֘T`qhj.hF!i+tԌ8QS iIvxiY:gQb*xTyr|9Xj<i,v& 26uFAP¢M+E1lNMpO"h4FNCZT-"&qԙdsmCbw1; liYqЏjgèVӋf]>:"ΆoaE.mH$Cqi.s":7æ"g/i٥;\M때G˻\NbhEG1ˍ`ĝ,;,UMJ\f5IvJ֔Y hr*CE*w+->|phHs`} !F1+Mǃ*;@SEeҐNqp>&HQJ4E/ٽ,v8:/5t4z$4DYPOl.QиΒuaQ>t)ι]C;ULt E}==<ᐉʗMmMɷOМYF,QӋǼ3GصOT :c 㠜= (=sgNMiS(CE \9sqP\L}xuz4aqf^)[F'aC¥ cT@z-QFs\hit93:fMlhH|;K,Q`ɥg\-S 56Re\oeJvNWbϦJ;D*?>:&ʡ;eh<8Ts4IN2SiN<E(ѰŖ>"© :*Ў8苍͎ZDXh42q; EHl\#G,E q|\ώapC# timv}6x&:QqoDѦּ陨iy1&C;3x;6?]3\ME '}7X莫xxٳqЦQ8S)X1ojYGSJeD\6M6N.:'ET4<*su6,O,55:r:r23A, D苟4HE HYl"QfpM/D[m@tzҐ˧x0m.3 Et GY>QQ<OBDҷBqemu>,#5:,0 .pM D5h-ŪǪ+jn2.Vuzdѣ%|u^/߫uѨ'+/GqZ4rSBqqz/I򭌎ԉ؉鸸Nڏgf㈲.X8OHДYލYEL41Ѣ }mণy(ԗhj/"hgՋuyEF\"XhG9r Jv+ <)IatqR2s Ҵwm G4;hD|C:REl}62BՇC!w5=4|a\XMyNݧ'>Y; .ӓYlf?˂θj59ç+WGZ,QO:|xs"yJý hTje2Z"tmFͤuꙅHg[IZ H{O\GjlMPuo>sSŞJ,P"JCJS\:8L>,ǵ)dz'Įd}[6ƃS,Nɲ6 )ʈC }d]ɕ?Ra4\/8QFC.˵M\ 9Yc`"g("5C>axdb8HiYEwEâ,nژC-b:jcF5"5ʏ<`ǎGW  izeEϣ f\Ɲ+äůyO0t".ҋ;p9V1d6gXzAReO"w Q$֣s'(EXLw^dlB jC8ڝ LV!|_cSuS}σ4Sw6;4¢t\t4!4 [MoF3.j*L<@MgzP,aásiih|\Yn.9ݥQnmʎd#:''3t Xs?cϧsWMJd +N,%l#a vy>XxlKt4{F$#D |NU (J4EzK,)1cSphBJSABe6tctYvih:#ӆ&ky2h|tx\qf;Hd# ڇ l|*EMo=+;5%레6chl *.厢-e\5f>&O 2,8(t5 DYz0N3-7nNk7*ãB~x1xGO5h=2)Qy \ l| ˃bɰ>*w%k+Qs?KhoP>zw;*U4q62i]"t;=a^Qc?eg|4FYjjlMo+:q4LOD:1s8ȷ3A1w1<+t9h=Q8(-d쟡ʈǏ+ɢnTO[;Sѩk,gvzlOpP*E4F\:eqatˆq6VgMaG#woe(SͳUh[o*Qo8&12bv*9"M ;A,RXI>+\*fJOw=+sRuhUؖ&MZʬuIUfJ3H2( P=6/)E VtP3mF9d~M("8>"QF+:]8?> 8?˓5AНaD֚e43gC^՞Y=i+:jDYj>f.DG kyvTtNl:O;*ҼsMc :W%y~. OJzMQʙ=J7YѣA靡է:L"񭺠.œi\zPd[.E"tl"MOSG9jG叫9~5(|pLGņ)J4QZ\ЗiysϞyFfEǧԣtEeLj8 %:1;_}4Eʹ a^Vm[Ř2\aFY:JwB8(ȫ3b+68| FdD7)N U(t* %gCZ4;]7&r,;F&DX>)T7G~;"Q,$.F:'DYS"2lDi?TCԩŮт"]e:WЏ`XTc>G< pRIqLNj&qQ2M 1'SaJG,E?Y̋˷:s2A(cn>vbD[х<HmaqEB.kӸT6T+Ftϝ|~j^,s!ãm8ٺ k.20; 3)$m xi,عϔE!;,ǮX;Ps^6L>d[Oko e'\( gLZ~Dkwu ˆU#.4wERq08eg憣SD}rUNV6m7zCōQtӋ-+H>,*uFEPT'+"Q qbCI^h5q*]n"sDa2cg*X؋"Qᎁ󳸸:8#Y_*XiG2ppi2-+ʢTg\6]˗vl &`*b,43Ѩ eeMƣ-Rsy1s<\iȵ /f>5*'r6Ts͈ xZ.§g6,5ŻxsΤ8DFjGN :^ξ.3dwtӮ eϤަx4M֯>\Rg>E5*h6'e]Us2Wt~x,.sSP͈ze >wg;0}5>dt'+d&QlgVaA6QdL}32-<|3ƃ=dTRSaS ơuH: 00dcBWBp@ @ @ @ @ 800dc&"Wsws|tNA*GŰx7hlrz; gڇC48aʫә~PL4 =|%tζj͆yEә+lJQ[<>+0\>f.0狌IERxPmFIE>vs͆]6kFf4E5譗EʆhNCϣz3Q@Qpi rXhS%ƵM6 -;.:Ef¥d.DX2*>v$i.-_F8*\=.(]2VZB́r‡ iZz&̝F-oCto}V72YD*Qg٭mM`^';oڙ>SEʃw6F<ҵ?M)|"m>aCKDEh^"ٕMXɼC\TgG4E6 C6踿´[4F̃l}*'ayDY:$:GQbQGZ4Y󮯜dD{M2xfeyJ:p k\0Ł:l*S^"ʼN@:%fIhxN/Xl:%cPU?ӋXl&@. { ؔ5u}B(u'\L?+4>BTv\e:ͺPFlt.ݎT,3 X>vUaԨ˴gK^)αx hXeNCXTigA\xln*gWz7[vvYsk"t2QoFY+Kų<LٗH4t)C'5XG i|->x +ԈzLŜa:S:RF-ݢWj;.0ŲsgeJ.Vh˖t}£D Vc'NLQmυoL|*]&'KSo-yYJ4q2% Xivj[yϤje.~菢ӝ(7|&ޝW4t"**h ey7.Ν|Jr4MüB.,|2x\@f?CqQYp2ƜEųA qAt2*, mw>3> :w)͢qQe1h3Aqa FUD\ap:"sX> +aPf'dCb,P)2J.ECaaTl ϓ/*G[&c.+IcldƟ[N"9]˫ʭJ[$Tvce i3NxF͠ylQqJҰf+-M*,iӤ0:Ůͥ6[܉ Y2ƶh57+ڛCa^'[Vc#ظ>9T|J'AS4tiYf14\\"T`rѡ]PE rq1sxj+@D[dl P màiaT{n 6 z"猬:4 .h:vEDDE̔ &ߺ/x|zʁԑpE@vpu.Tb.i vy4IFUW(~'oE\2Q"aQnNΨx#OT'I;!sx7Z㤛xO#*rtLt*.3#F4N|L>Xt6Y t?ifSl`>/KʛuMmm3. VmIN 42,53DjP> yZD[K|:|3DEzSzH..63KTaL8h"6 b١D\f<헴tiJˇMo:'p;? eF+oviDepR,ڛ:w3Fr(U Eǥk&d|xqlEԇQD ϔ>8hL."ϖ4f\,t+<6?u%yt4X0a9kQi[SS[c@T2v̹rW^UTSIy)2"MX$)ÃqRҍ j(thpfb,Sw⣕ )݅DXpg;&Rf('tbGa8ފE5EFTv )tXښ Rte%A;EwT*"ŚEJ.~%s24\..QC9,CxD 2sE4JNJHG*\>xDJ.m\ uW!]2E 4x }> 4JCg;;5# xg- ltE}rS?Ɲ/c2@κ5;jB? =ȶ"h}qoF6\.*,(*'ZDzR0Vˈ E.QN˗*n: ވoss4poO=;JvTq>lh8} Uqr4r2`. 8o8p9EZZPlX'P>F8t4OҋGF]#hDJçJ0)J6 y>oL\̚CD炃h+4Q;~52˙ Y;m7@Ht>0g̟\,*4pŸAsvž4>qp:mIB'jn: 9r~E-oB̃4ݡPb]ޞlz&\g4;MsSGZs`CCE0tE5pƅb! m eA}[5O v5$:2Fi|tƆE0:+WSE}$:,a >gC[P*5J1ʇE(.z.S">?E8Ao)|}QoqjGEjA4E4X;Ýt飙ϜX4iůE4-Dy*,E}@E&xsiGQӫQ T_4f+v6qIШih>kZ[1_ m%LOs D\ 6VLU \ڧlӕUYvOC[<^⥼ofM |,@V8Rk D:4\ncSQee8~ซ4UwFiNPcn:oCeCQvd?xz;D 'CE6PtE".47)juS.qd|43sؑ!lu(It+Q>Q",D!IV4jkӯOW.fD;W|u 7y/:gDž:v)Hg))Yal}DXxo qy^667[4${ONC1?*;>VT΋SB94,r+zg˝;*j5Ag<&j{麤|sJ8Ϧwyڟh:i\M0M)y"M;*àdD}բ~lXP:">O;>'J+2T4L' (te](;aq=0<)ӀuQژ*"Bςc4;ݩoNqZN=TDɕô'MTls凍e}ʙ_*-i}8H8t*WS&zk)'XiYuG7TpkΥ;DjaPX+RQpq>0iYm ?X ;Vq̉+Ԩcfc u=:1}>vt|8Eނ!â-fhz4.siU צ¨&3EjJ.*Xm9R>%bQqev>¨Ұ"C+M;D3X~DXh#4q: [Oj53.;nb4Cje2 5-+RL͕;TYKi;H3jUykeaqwJ#C0%ÍFT4 Tڙ5E.XSho8=2`th¤vNV.e7TDA9:v'S,plbdl姡6ht?T_IbEZ E4htmV4hz4[(DiNލ6rDK2,Q&'g((G0xfWj* OYSOZW>huRœL:"ϥ!,e;} p* ЏGXʏ})Ϟ>-vUo}̪9rcm>duig+mtK>at)k\1=hhgChv(+.Vy4*5;6m4 :ڝRNL |΋妚riPDV:S"©C&ҝFHxefcݣG:Ⱦ9gi] >'g'AeCB6xSzf:q0""'8U%vtSf6٨AQY;aɲSM)qi9u7eJѕ݉z3Quûޜ.4q~EGÊiiYLlN!󏚥f+iXM?Tg*>Ȓ(Jf2 :\k$e fu[O;ڞ +dyx9.S:ai6QZ0t\eQ.]24΋CM4!_sQtGUf>G8z-iZ$gڟ#Ik:zjyYR%5ҍ GD:DYqt<q郢`im0+D׆gg w\hjm̝͘h (@/8GAb,ReC=8nsQT;IƤ 8XP*ŕ)48t̕ך N\pS̔S  S輴Zd}2;:?c8Yl.~U<'U9rm1FN>ms2GJYyGu|9ΩZ=:Imi,lzv XuL:cv0tBuRCW5ta6˔*}㋋ң1fE뚆A](t;JЦf旅Ym^AM5_o=;g]3}:xDZ1áqҋmm)iE)i})㡢&ʋ2sS˾yeB[%\oDQr}#K\c`~GN.Ү"$i-p6NW)1&{SJ|vq˔\zEP>`FV"msK2͈q^\6Ĭ2yx2 INMOjoN'7avqd|/ʟX:veQ;4C#E{Ц\ctM:Ȯ<\CkFX0zx"`pn*N]>Wk]U2(xSh:qs@b.3q>"uDE%:.9FT`GŶ%R蕩s-ӆF"OQhc*~,|:Y 9j J >W2u>h+FSEi*2{LEq+U<; F"ЎhmNt|=F´:S'Q3]{m?>9c܃ЍNգ2(t<[f4CYeC} eJ-TΝ@f}U@ T+D(sQR} m:M :,*c,`.!mJ`3؍sX43EseU" Xa4ـ\ݥ2- v/l.f0xfr(s.ʋg\\'SSx1wPF`!㔳rShFhk:Qc Lu76%`C&p7y{Ta : q[A邡Nc#LQ=,ӲAqYô&TEQx6c$B4lmoEá*w(A񴭌^j4eq:,Qslgp;7:TX?>c'jm h>5T&7HMg0qTquCEy=6uɩG\q1'J,0 )b;*I&u{S: 6 QS:. Cӯ*yP|]Naxf,P PSˋBp;6{+.&MuBNz)-mSA¢˰="tFyZC6iLW鏆]B/̝Niq1PdxHR; 4q) g F64Ԉ˻ўm &:s:Mv:z|Fj~D Ji4\'ѩ "eyi4g9#f˗/.FQ1.,.m\Ҙt.C,he8ټdm)Dkon|[z ]<|6T;kA<:o+2龓aRp jjğ);\AB"Xz-[EKɲEE E2,tGOF\l"`4jG:S~x cseEhs6);DY.T:Vtpf=q:fQ+1+8}".U1ͣ{jYTaTxŦ5"ō`6]("%d{y"qciOf.ĬeKET#'qtxk+zQr?ň'lmqheTra<\pMG6.*:2,QQSBɴENqj2X=qapң`iqTvt5!TZ04uyޚ+ş HhI蹎D!5< 5qQx‡JY¬M@SN,2 iCM+ ;ѩ h>xC3bɴorJLyq; <4 tLq@\kF+-E;:mmIX20yeEq'qϘrh˕Y'DN O-O/h0MN6zQpsxv!Cڂ(b)lˌwgW ytCZ+42h<<|J518TxkhhAr.1bFNV_DLtYRo;jSgX'T.2C[ltw#EP~uEG#hsiy܋ "Qg*0:1FOL6,ʣп;\1xߟʊ;\t T0'KNɴ;>gEYPCig?WXhShQĬth2hk-見E;Klۜ EMlA˫e=1r<ŮaCf1L>~Z՞22XD3=;4\:'Q[YqԤtWʡ608DY CECCeQeAM4iY7jJvuJY;.MGeLs@>0|\hQO=l+h~+އR";q00dc'#@Jy攺ї+]\BDϳt]r<7ʍlx,rΎ\YlE2xcH4-IqaSh`..Iq+, ;m# N., Uc.A3p*]My<\\>Qik[GʏxֹxW2 ~v*.̡r+-钡'n.I̸D.}7ԆTZL>hpGzc,Q v\+6TX5:?r˔/\עρSJYzj CQƄv.r2:p:z͓z\ecH¥lf\^2xJg&wa ͎CŝH79V7T<}9lˉx Fc¥h4=v|d5(FT>2rfV>V`;(klj6iŰ %;StuaT ԹM\Jvrѩ>sj:2C ϣH961\N%Yҙ3;/hhf[e99Z*do*9t\P, /DY 8m+%_i7 jlt8Nq+d(K8dh:<\U0ұˣgèpA-P!\d5)VUQ}__/cȔiIPs~&#)Y!;ʏX꽅Z5SfEMm.Jվ\7sQze,SA_QSj~h鼵!ΧsKxm+vEͨP\̋?4v)x:.ir'v`Ԓٚ. ͞.A2p| 6z.1)ETyNMFsC0QL2&<|4wliYq4 tAĮ.؍uhs~:>L:s,up'MQzRz>F v6PM.+"`0榀\h:5+",@暓֚MŌez=2E'VAfL!aֲ|^ ͔cJ_r|=Λ.vgTmD..!\] ˦p"f:M:Vv9\. qiő4r P2&hMڋhfh:x쨋&fV(|U|SONfJVƋYqĨtٔ]UL㰘?/A}=.;O N&"t3H$ SʕrPOS+)Qf]3@TvD,p\J )6Aʆ|\ͿTpұ@@>3qFxAq̕+?Rrs0u[aQ;feFxlمFx5 Okf6ҘiݪC<>2fgk,&,.Qq007ѾhSwW;bWFQwTC p0l&=[4EQ",Ш 2.F;/L|EFqnH\sͰS2"AgLl\hɴZosQhчRT1d\o(u7eCDX0m?.Vv9R_ tU>\Fäwq{R9pX|zz'CwY0\%J|a8qʠ} hhƼ̈́'>P|F t8Fays3":qtM.cσQENj}kJAgapo8C`TEež.|鎣 )h4JӅqsfіgE1;4>Ze@\[4Fx6,S4xSam󎈶GiRpuMt>ڣ\wh&|-p+*(TfƻTƸUXU`^mg|mPf,j0.XsL#JϦl\)1+ޟSv[Zh3铗ݤtlNd:V cS<|*tv->%ޟ- yPDY?.lN1:,KնʪL븽 ̛,: fW2=ZudRhEshtN5#s"Мk/H*Km?64>;?6w\S ",yf\llq)s ȷ'.,щXh,; DD [ r{QT--dkڍ!-ԇP\T;;IŚ q>\Tțkv^6IQGPtYȆ:415p5(:,G&ئ8E`|(*fEB wad]tع8L `@H'A:.dFFE`AS-@;=^\"n:mq&dN˕Zrk-N<.,Ewjs2*r.I?gE@rL\?EU.3C*f>o, #h|\h.+iEQᔩ\oR7ql5:Bv_>J"2̗+ՏuHMKWgh4H\Wl~#qӠ>>>~ڰYT6T}^v2'Cc|OEpTFa:-,]SLq t|7\\>v}h:W<.1;eD(2ړhjEB'e*G@|YښUEJnyq;,¥qF8Ed >Gݠ۟Ghc"'F"D1+SQQGq| l*,C4y蹕ONɚVbFY6=ᚚ -+P4,RzW7>,:t ]oD;vatW-(>ShJU>cS(`:r\vtmh>O&"4T*;=>cc|>§bꕈ(di:iBl\CW v \:/O6\DLw9qh㠢tFF1ʧӕd-<٥zwx:3h:Ӌtըw\ Ԉ2]mc-Nj4K\6C8r Cg,ePTeaSTNH4 eTm> ~.MZ,G,Ѣ,Ѥ:,ˉj(T"8|Yҋ \Mas;M8\eJzDs&.[>"z˗9>-h.;%`Ѳ:qShwD[+QO":rfc%FS/t4\> M?^rֽ 7RVvެl3Һ2Nw3xqT:tZèxu)b0hpEFcFYN:+:(F;  Mpʐ>v^:~W>6s:.M,h\yQsK@M2&ln;f)(:/$~x ztuL1`uSNÊ̄P37 ^4Wi.})܂Φ+4.hM'sqCN٠hF y &ѡF\[h|>w7T?\EYtuiNʣgi>P3΃\GPh@! L>sQW]M'9K93,ȕ5)9x]2)ZE6ޜâ,-6ڧ\0ӽWp;ZKG0n9w kSW>.ecHА٨qs@D\ʣL\gF'E⼱GQdx:?4p}t",n*?4eAӷ6 F]8E|@𪸴*vz=THHaQgh4N(ᡥsCq hy+w(~E,Rc#%pt"#3 ?|\>x̩~GEqYYS2YIՉ:V%qo Fvm XNEMϫˏFx #5édlΩWT7\ꕿ>c'2w ( + MaM\^xRx@Snӕ)fG2.CRx£>zvGB0\{CGGe0:|(u)8ˡl)1:^|C0zm)i.(F}t)苇El _%D"V9dz;-GE1v C,4t;S`ȣ:V>QD<^sOQ+4߁͋7<œ>Go"tX.4S[u75 ^feR5fIDw:h(]4.7o3JZp|(ٞ_v:'B2jG2>GJd~o88|gmT:#f]YQs7+.MM 5E zEoCi\IN\v͂iu*LŝAsju*1ӳO(ԏP݇Sx榔\æ;>hDFCi m N:.>:.L;eGIXE(GyӶ:'s>:_+gT0v>SyŇ[R+i}8S@tݡn(|vn*%EH|X ryx |NVoj"]8O}5W,Chݣ\WDZn>:.DX أyMgӕ*<Ԉxp'Eƃc΄XˊyCNVw~#hz>vWM6he-&B'sg;{苕}4>2"CNuSu.9*|}=)hjyUs h¡st) `Y8mW:wnXLfbZjP."oڐa">vHzcۇBM0鼶{tl;к*;k4ӸYo=KyOKh։ڇIv#6o?W_G%>_m/:+qՉT ga˄B7f]ZU24f+Gh0\6,.9rfCAҳQ˃|ϋ|yy?yOb,;gjugli6V6;R?ϴ#踩44|"th4*zڇʡ4TK۾.Cb<5Vu¦r?zC6'ML59mˇYUWc,eOP"|cO/:m;*:xθRq'L?V>24Zn'YQeKLŔʎA .<ͰlFfm7 ThuڡQ4t*>o3ҹjJ,EhTy#Ӯ,B8wP\ ao'(sT\ ";EcF}F Fy6MGthH;o4"PN@ь\~<0\Fg0|Nˮ6.Qg 5{ECv,URqlM+GiŏDh7YPeiLWTg.SyŪ$tZ>x3B'$T5N\:tYt]]!dG:v'#S+j6U:D\ϵ%>9}<MrGl)?Ye$:|DXv뢚:6- ɱ6SL[R܊Hʋ4;hxƛYa%=Ԕy:nó@tA>'aG Qvz-R}h2.Rc`;;@QLҾ4x.%guȹ*">=dud\:S6^>Te3t눳E;J-]?bs+?DYoDeSSSlH_2 "Ey4qƓC7\ʢ٢HسtvohwjxQ\g CNVi::S@}3r.T5ʙ,|;Tڍmǝ⫑\:,(bYϗMh?,u:vE & +4EgѠ6,M\|Jh`#g>>VYAqgJAZF[AQѕ:ˆTY[QG=)JeњaM1Y;QB}2Z=6V SqPSxO klUV}b5&u.+-fnh>--c2mr̓dC`PtiyE+asZSgYx6υ Qg\2EFt.VO7eEt.a2:e˵0;N-aAgeM28 |.G.4J|EruUgACQap62˜[yOzƍ;f\D%U,EKO:'SD5|qꤢ.utnB7/o6 CFV돊TҺ݇P >܉hVR{4BȒO`nNGCŊChsvo44Jɫ]0""K4Nv7m/+LF'KZNjIQmps7GSGh265F-7h~)%p :xԧK!w4DAx4vXxMLOTCP8K\iY+4? ,xyiE5cKh|p4N>,AfasJS(T\:T. n|m%×.\M>qefq7ʋ}Z9P*V(.`'EHEOd͈2鴎Df'cQd+cte0pdkL"N3k-)XOr4u4jjͷd6GfӢS4ҙ9˕690}9ˋ3TiV2F]7:¢&Pu|+Dk|QtJ[ӈ4LXfi2 DEZm+4>i*.lR4D[AF:Li˝H^,0.zC*O;1Y'4YӬP>@3$a4􈹼2{Y2=:'c0RُeGxx*ю+ js*ySJ,+鑡hQyG+|3o)O\)_gPR,NG˚nW0rŢ=!˧T+*,xcl".'6D<[l4+Mֹ+qz*!W!FdEPʋˇvM3JVԁvT*.C!c(؉Qʇjg\ҝ1.>t4iŶ9djz=ƣ\6vLmԦɌ\g[Ro"*:pR|6,\4Yp<5hx*_T禧iœm!ӷS-u2|Re˼9Έ!qjclJZ&\,qQci Ŵ;G8Eߪjm(K<NKNkc&&POYm鏀zkM6$",*o)gԂTaT-s.ϲq`Z1CaqsϓřWLԇ̈́R=TtEQE? L&v2'4!, G.56q AOEhj)D[y41R6D: ӹ L:;Dա6hXyŮS>rM.+aNDtb\Zz_:TrТS;Bp+!2SLܦԉ5į]߹:-($*EjPm#"3qЈ'h\caQgt٨Ɵ"3 M)|fg\ՠ|p4lM բ ُntYI5}LJTYGeHαS|ePTU(V,AO ;J7(étYX6Z*]a/)e3™} bP՟FuC\CQ @4E^L:d{+uaM: Շux<.VN-oݢw*o &p鳐%%~cIqX'CFҋ6y}N+0[nſݦۢmӰrK5x=>50S,qlMhTY=sp6jʄ3JÄdLZ]O9 z ,Agx~SQS1|DYvW#ӫS"ˣʃ쑅wh}rhvmxex'.%kFh|AgਅRC Oi՚|+8u3H0E\Ҳjr&GA @tR4ztǮ]e#!\W|4#CXJ;lұt}^KvU?y']=8T{LM"D~8K~S5N;MҲ҃+YYg,$EjF6Z.P*E͇C9 j-̳ZV§h6v J4Je!No:Scl>'+JG4:,B4\7>u@豳*N/ ehm̓56r\8oݡQm}6xgf6|.DX%>}6Gwj:o lOgP\x 3Q[e hѿL>%egh8\Yf8Ӳp0.&O/ʜkmw:!'ipt~nŊ`3lE_TQ)gTYĬᰈjm_FivuHn*ma>_(yqӛ$E 1.?w"HՠQ>ʋ)|k*_XRVύg>4eh  xq4iO63J#3737o|jҋelv0j +;9×AlgEi}D6eA>QXhh|7k:n-,.!F{'Sc[3A2S$m|Fn}k.Qq;u袬42gw-M.O1ҳ/C7/>Ot_zXG]ZcdPQs* x$;J3 &m犏}+-Љ]&F?>M U r^RA|24 +PwAcJNVvjev,U4a FXT\;5g]V 8`,ycC,ga1xh0}.#b} 6 942gkc:w:w3Ҍ&ܡ}HRq]Y:hV."|8xt͖l NZ|E|Oe s'cepT|D|-yݛ0QVfRUIgWg\BϧE'XҨD20|*Fǎ\>g)G:G\4h~#ze\EtR*t>CULp%pٴJcdGRM|pj,S ^aP``BOşΪ9Ч*}h;vA>Qqq7hm sƔ6l:.5S*ZwTL[6;'ZZ,ћSSYxCUo h061&A a", |*:~CF_L/+Dbp*,tZ#;`1dɆ/ʆm(mH>]34K76ڹ?ڶz:*Ӧ8; 'N.eQZ8u;"+JG)C¶ ŀl£ϠY4Z~:E=:qguO'K 9Q;ù?-|}6}v(fFqS#Et"`6, 6!;Q DA(F'_!+K=6ZmYq"tY[Qk2^tm;s:[:9ݪUc-.~pg<[Kxh+ȶW2xeOm)M"4a+r0U3;,QLD.Tx62qtfo. %:Bf}cB~K-ͧN1ä|[-GP.VT¤|HŇyXggãza FJN٘zwkx~r,U1g|POGSV:fO(:Mzg"ڊ݇Cqū||?P.QqtAs2ޏ:4 sb,Sz-lERm.e*=q6ovxٚn}>Q+];ژMZio*,y|}g_>YSM*aRGaji4Xjs|3 icw \i;E=1ˀ3U&v K4}qsͰvCSATsh|Gs2S EԂ)٥bv4O.,!ʕ^5usOH\zS%c`v]Pamio'EuRϤӱLl0tδebShV2;ȋ(m\Y.f\EN>> dU b梣R&2A˗}е4I֍?mP:q-diJ͵Һ݌:fjs7?NmC3#ZRW N,mFYcU4@?)Ki4OMgd[eEL>>(dzESQ\F6㈆>C9XfJN2Je'bmNH\\ф^QqLE4tg+^vi挸Gs|~4qG _ats4poM/;UfyڛAq!NFtv,2xfϰuO\asxH0}+cOx..eNvs*#U*WLe.6>+Tݧʝ\Ӫ*Wˆqoo%[zoEM'`F8J6jUt-*n7erU"Li.vʦAG-;R&l|Jս!IY5N/xn.%cU3A;1elmqb3<\ގ`y.>417Zv\ɞhE>g$~ sUF,k.ҁҋ 6>VMiu yXr5vU(iʉYʐ#Q-vPA-@jOTE*2W*F PTIaՉxe;Gb͛L|w Pji5keJ" -bm iGeL8 "ty,B'HcFeN,5DO\/g gzAT@'pCjFTbVKNS:*D[w'SCNφ2;1^hTY74vyMό7:#Eϩ#(c 4#j.S3fJɫQ>"R>,SJ",c1>;;z;CuE%s3Z p*՛m\?8"Dս??ljx;MΈ> ?>"Ms9vo$x#>Siat[hvTFT6>o6uy~snyȳ D*Pi [FruQ:zßpـe t5~F  \h|CJvB,|IvO Qd^q`O)Ocf"FSSb,T|Y2L{j4Z'r, d >#ψ.>Vjm1/:"P=8ŕ?Abڂ"W(J.=6>| ҋc;v#onRGU|K1_U+;S.4Ǔ.EF8ŌzQ`!u4ixcɘ/Z9Tљs)tÖe\x*,蜨s$Ոf(hj%P|uΜwyk.m!V|ݠ'OgZ<˗* ƌ6]3<<&a\>>pֿ",c+O\UkVu[:S31٠lc}hZ~tQpOc/`(~WJƼwQS04&V:é X2Af~"=CN2VYDN.u@7ޯ}0p|?%`2vЂ!+"'fƵhdv2Ml T;:r:cyٗ|M\]*YO|]IfQ;u1i/\ڏKN92s)A%Z%znZw4匩[GoA/NYϓ>_XۛAV"ƙ/Ei,ݴPDy*)QkΦ2.~db& Ҹ/xsq:Lxi ٴ~<*s.۳EҌTkah9;y\5O0FkU|}68ɉ6PQbt\Tbkc9zWo6l=ˇ9:,X׈hΡ¢6T9ч:qq7Շɞhq'ޜ)DY5ۼ Yټ;Au>2_?MΆvC۠F@7HSi1\1P%ѱkQ9̇?"V ʦʙ")R".gh7}+Cl tB%;Ҙ\lp!+'C}+:Q'ř"Ph6 ۴01QL?VԧШ0¢ƣ4YPӶ6A(8u.*iErx';mMO gR1;:y@]*HB"oFhB"E.-Lfu,+@菈*.Zu)'…E7?5CDXR?m\v7˖sGx@,=;jnYxD%k0ZP;xx.1 cp-jNDvqc1N7h#¾VtsETQ;4 Jo3xC}⏃TB|ٌ*2T>n1;?.vΌTеeyr*N2jtTEG⡭p;\:o.&Ujț\uCҟMQbش>, h6zs%mw,dD":mCxƝdžUʢhdËf @D[<(ʆҝ]:T:24atDXCT SX7 &^x:w@OI2"u.,s̉iθ̡Kf=r>,7_T#3rOt`豱M\ٽCLQj`Dl;.frҮGUr>qdBe C4J;D苉EOzhzmJh*d@ 'rӾtO=)h8\EC:Qqtj}㡢1- 8N>>Qc͡ETtw)Ӌ?Cj@iYwDo#ۇu^sO G na0ҳӀ+Gs'QIQ:r%So+yEhc,t569\h28|^ttBr]6N~%cGCӼV`1͢:9Lwd58(kW|'?G,t=53ڍ?#'ƥ83NϧކEPӳTaŰmF`;=Zn+;..џGu.O}ᎎD[s'cixOY})8TE9х\XC: TFaS̘8IF6*8rh1gꈳϕBel]P|vW6Cű \p? 9ZHƏbeŃ6D5u4Z^qޙbΈ >"guERX:Vم˗hGqt6v-&Nt~_'A9CtvѮ~. ʊꕏ7c>M;gYsAʍs[>{EgOer}t"2j1(#`;*sf])œHsIijW6muL5F6|\Nx_2d5MC8-+0f'Ѡ*:4SOoφ>6͢>=tԈk,ѨNp\:Wᡢʄ;iZ!Q4z5Sq4hᫌ),t\ /H mw2c''l}̺Q;BXt*72,FRCɭd?:>B n\tKvh tnag&m"G\>-(& ҷv:33b֟=3caCBC@U!@x+َtJ̎ez,>Lqjt wVP]\6S} Sj'oM|El˦L;PLͦP7Dcaт[$X*i6zlt9I>ofIeTzɵ4etJ:Cci 68T6J?e#Bϑ}4Q̪h3N4N,P: {Įm.˔ƲU6:6]vf/bЩ8u(ҟtf4i%2:u O0WFz2̌"1H6-}6?e@*t i/}7h4tE>b,0 Nṃ27u`ˢW6\DeDZӛ =JˮssgL2a'i&k!8MJb4VYJmUbQs>EIƏ:5J K'K.w>\4;EpfJ`diN#ȱG2n*`Џ-g<ʆN[Gûb*'s"eP2e2a$&,ycu6,zX>@+'a]\hh0h|5yz3Nz,˦Ԫ5g}}˔[u3UDgV/w:Fii\>6>vZw&FaF|tŽМD*|YE62]xMTֹe}b Jhs \)!..h|ʂ6F\GN˧U(˒zSjBq"%wv͡L񈳥hRn.}CXw20٣V^To㱲tY%Tz5(\`0RDXp00dc)$O@ 5gKvNq#IYfv]#4Yv\.>\݈͍⨌.UZַ"GC+蹏MA3.fo؞9-l4:u ظGf6S:"ټ1j4@m( "KdE.ʇǾV/QFl~&YqsE:ϦcM7VǴݏh+Kh[\}1MC@i338L7o1hSIښQ6 8tH?qš9XU2./ >.8LaW4?Ds4GdpN¼S\g(2ݢ.R>>m+N \̪Gv9kxjO#{7TMvjϵ6llWLNl!CE,";A>v\BCt'E:qꃎ?0lq^;F*]@ bmFst+};vIG.m=<#)dF=<c8 ";K:W)L")C28{B>W4ec<:"F.VRݻJU^f!K{_A2\joX/~R^MUWƐmmFWT* {#De=p(~y3uK#JVGSэn:,>0nl@sxg:?s!"ښ]O-z*-pZh3N,u g*AMɓ7/x(ʃcڍoD:F FE9È4X؋9l0<5-rه㏗uhyy՗)CN+UyCʫGy>-,7hl>r.|:jĞˈ:C͕/fckADXSٚ8 pl"^`6gxΊ*Qs*g\)Mz)"t4`:dIwXTz;٧+WۈL4M}a>:kv[Y#>  ;e*0Qq.pwT.vP$,J&`ptJޜ1;92ٳFոDP"ٟUˣHdNl@9EiE3"ft\2hf(7A-Hʙ;fcH37Z?>n*P'|:ZjmFTOUui7}:TsD+6(k$Ni7ll"-~̭}(+M24hFjK8\vw4%_Bt0|[C-ho6ճ?;SALtM6(Fꜵ4ZZ,wg:haQ6}^2o2V2CCYmOk,"]4eg]k˕?ה*lai>qW t[ *U\[42FT.vZF\j,d.'d:i1ˑ6s;@*ƝFu39FTCPT)۟*(hQ-GP/vcE¨ʄ)sy]ۄF?>Ez*n\z>.%gv> /Gv5m %us`i\gN,gY\<8wzqZ-VcσMQfhu:̜\P:FE>2tvX>D*"ϋCF:::2N`>}: iЪs,lm7Z/ʙ|z#P+o#&m{޳Ri؍Zl2h01gf6V%qiwXJ)Ƒ#i[`Jݮɫi$z n uN;hm"*Vv\4Naf\>уsG.oIœ̡:A"ivhdIE 犕iT. idE$4c*š Ŗt"T\mLt 2.j8=h"4-Pm.lڝ@vr<|,n?fFS?No #cj` > h0E2qտ7sٴO]BJ'G(:1ެƼUQ3z:1҇zGҹT͠p;G:V4;zVrzv\N|>dtqFYJd;gRvЩ\U9\Tttb,@J\.#X:cd}סeG\kjak6j Ҷz0:-1R kCoc!PP11nJr>Wkfiim/7\;@hht\?EFJiW Ul\OˏhʋfRS:(qhg2>Ec!u6006j{NmU ۶tg#YPPH6&g RDC\,z. UҞd}3%g1(>mGCa0:" 2-Sxju]Az.3xt̸3Jv F܆mE&CgiρNȏѳCh=W t0pTl suŮXlܪfqh:wz . s |vf:,n'Nš'B] ukX:?CF"GϗNԏh) 2lQaT˥# 4Ls0n\"=͑xf‡h?:GEhV #VCBU 6.K딮x?us +RFxziEň顷J͌EÄo6ZEV\hlQg}_qRo/ *Qe a+Qt2QfiLT[4?уK")GҳAl FTX}N>*auæ<ǹSc)أӋRp4EpzF|\\tX}7T΃;QM96J7Q6j*:&tXVT#W+r\F:)tBfH;o]9\xA^;gNZEREDL",cM}"-7x@Ӱ:DxH0:.lhϖ}4EDŽ̹t*mg贾'Dj t*hhf hc""G g厧:?,V42T'f6(iYT:45cjԵV4ݣJԣSCx)v.")m\*! +,R+z3g|qҩQm ioTu;s3A4v̀]O|.TIXo?J.S@f.lju>QEj5.aЙT\a<:=<\\ğ.Yg *v*cZ ͢\UrhP;Tql:-16\2"\]'E&4h*":,9phvr΋.*.Rc<?:3]S*%uҬ\B):[3;.'tHEa\*-_zʕNj1RB\>M"0VFH,Y_UJΙ'żT\^tsN"4\Ҵ~|=&5..sωeD(tE鍕Du(e55?3gڊ򋍣hˊp1llB:NcUg;vgGP;|f^Qq[A.7:Z-T*=T2>z,}:L|%sw2]ōhChe ]ECudt 1}!DYʕ/syƴe< MgoNhws9\  ED\B)D]c*U0|XE/7\\Jڱ-?N}EۙH ?:;?qS\oxXJv E*zNMg@eSx26 :VKe㴱P?}NǣhCcaVSLi3ɼX*,4~~Mr۶\O]=^M;,.SCtW\E|w>fp0(kqsAQ}D\sPPÚcac-?:,7y4YÚvѷ2O:+FQqpچLȹP*"%e<\\9\!S\LkLF:"*:TAD)PTa>dAVm4s.H4Eff1s+™^T:0R\C\O:S`;=~x4E%?yffʇftqG͡ (㌋.02qT)-X4htg3R3\ΆjͩpEʦ,ӋgEw>Qcϣ̣.<7eXccMJ.|8xڛ,>fō <%w|;uuc 𸗞2|&.YOJR\\jc>,hTm&Xh6Tv2㼭yc0 +2ſ6YFiNJ:. +4ڶwqgӳFQG~Ec>:ӮehZ@mC`'G? p7w̺I,KBiً(gdZ4Eef~:;] ?zl.܋IjQpЦTnQpáAQ.fA>˷>eN+ R,٢.@L:d/TSQzqbin:k M .dڋLЋO*W6v>sX̼:<`e(DQJc+ipq>d`et[]%ND;A;/sIIxxr?玉Y>V)|@}M7YƝtQeWt;4 ȸq3gGl;44ReĬ'|s͔3.(>[eS62#(h I>Z[h.}gcg:93IPLQ98Mק̨oS;r;'7y}% XrtE@i[u4PwҡC;mX9q3)E\=4E\T :%gf\J~)T\Ghd3G-Tӧicf~el&GhFjs:SO_CB+5'h/\^ȩM=gz0+eqR`ΫN̨i}rMUȀ֛.FXh4Ns@\\:O5ovJtBea*V,;= 66gɳedFAEO!Qs!>fy蹻vL:DL}@G}OAWҕ:-:;uY6o8iA řpJQYIң6>3Ŷ'BQcELX/KJ3q>,jPhϥr+|uϱ4Xv:}.2 ,N˔p:PZ,jsLN-;?~* ߷sCT7 `2'rEg;ޞj,Ј J΀Ue"v)3AfkӨ$*".+eF`j8-GNETjhBc7i?i48;."S|8Jš 2e'L/M(|ci9\VT6Zމ̽vt-U\Plv6h>v/W>W;zullS'Sf\20P>W!3ˡ\^-t1zcG\Zc1 ɳ.fSOkuq1 xOMW:̆\#C\^4Y[Ιz"ʚ8N>x }۴rJwu%>T:vJm2R桔+o\] H u)d>:/-8؋2,-*z.~BXʋ 1qeëEhNe>e+ƙ>"̽yR :kիZJ-!6AQ3C΃xNtCXF7o@*B"T2j|9Z㡰̟:-sA`)LaBbx+*"SM3ϕ̨GAS4-oF:QkO+u>1w*Q*Xb} [4ECI"8G.^x"hS) G6|7TsJ.0+q:VZE qGj,aNzz~v ƀ\J~ZxxcBfT{{RK;Vh>gTڳҰJLq?jE;j|K%ͭEjCjm;BXڬɑ:ʟ60̗K*;]GL_C5#/;i} #\y fСr1]>c!5)swh*qc*}5>Y@^,Uj`zh\\rEl򊕶չ͵2ZRrΎ &cd.\]ֺnӋ|{KH:-htv'OE>cPEzYtKV{ 56zuDY2#Y>+: &x#*,y}tR1+:t* ~5GCG>V|LrtNF#2kOfS4`#ɍ3D[(] NEJuDZ8N#o.*Z&c7KE];|Jv7Ew2DYh5>٠D\juF\[T\TNs҇Ɖ5t)sh|[3wP&3D?v&j3cNT\Oi_h+C._ڣ6]?)hDZh6Cϣ Tt췋e)Ct|N;mq|2Jۡt3Èqk-F2PD:N..=pxTnL:mIN֑%o9\0M/;'XU2'f0s,Ƌw?xK.|_=幓ꆞ\Yg";o͑\玨n>WG}0g7ga>|*g_Jlų?}G""tcm+ ꡄÜyw˘%^GxRtZRוtLdXTr #JW<6l:nɟqcy~6:NO~|[1#XH?F1Mi=O2tZ|N^Qi=0WfWZ>>k!r!< h5K[8tOFUSxУP*3Ef8MR :-]wL" 6ށTIw0&}4}ROи["ԁ;mFxC 4%;2cc,K*ӝ'>vݙpCr>\qy7zw4}2-b2e.xJ.|2h(MYmeeAoOuPFW/P5qv%a4Yt9`5ƗjigX ,t9qMei,|YȰٴ#hU\zR4h2lÇD 3D\mGobql] ACOx\6:0NBg(N6U7D >.%P4u~FM|rh;c|9[ijefsam]?_XTcUift\ϢޙچNT:QgJ4W w<:LDNs.XtXdۯq՚üxwjmatL;4Mm֪uc"?=Έ5ȵfDdd:vN:nGćG\o?ts!M(ask[Fs`LXWMh\(Ƽ(*+1 TNxGR*00dcBWBp@ @ @ @ @ 800dc,%=mv\(}Z Zxqnt}aM36qAQ؟Sɢw?<\m߲.V}Ro ӧc|;^AJŁ&-uhϘ ћChP[|*\Y.j|C(l>^wfG敚!ͣ* %eS<A'g1dE.9P* и[˫q||Z-8O4hݸ)!Ά:gxFqwXt"*.Ɛb5K6]+󩃽1_tz=?Sw:4#Ӷ2,@ɳ 4ͥ.H2tm'RTG9[uMF ʶSBRO]nlE;Og!EPxҌda툐t\' 1kd1'MqD2?dwr2Q7A5E#J*R˔Y\>jwrC.E m&)Q(ktU2L,z4uRr-Pv8tGD;UR-CJn*Dk}.ny}ahm"s9C?Z fg\3h6_Gw;Gn8:j%aQu5Z5X",¤y6S-*L˜lr|}K6-Y|a2F'dŅSS6r\n^Aw>A"\dY>M0 "|06pz)Y[c(Fֶp;Ju1vd4J9qh}gN5F˫i&*zFcd5y5f|(: a /+A86xBAeD y~F8f =Ed(U6+-0 åA $궔.Es5 m+҇S9\EztmͦD>v5: `F[eFx+> wo'6f:J5>>M9d>0:,t SfOi>D+2*E\BҰe) |͍}6e [Emetǿv &f@苛Ggt5$ߎj@陈eFJAEfz6xT\٩e FE +}<[3S`64J,vS4 ٪W^Sex ʚQm l苒i2NV1.+f',"b3~>&q:^5|YSAcW)C^=pN^m5q*.#Wp5eȳz DY Ec喕ͻM=.wjmA;ͼ*ҟT[\opd56X|5Qe-᷀Y KI0|tEO(a q0t@hTH\YWW4Nq2CQDv.wZw}-ՙU"C:=w˫wp|~9qugջ2VuRkq 9++tͺ 39Z8!*ĭ`l0gt2΅yeNqա\X٣GJƃ6 U{HY 8y;c;KXOf$3:.2R|r^\Q=|lt=܍/ 񠙟+X >S_6GY<Bpu:,*;F١TEqҴ6h3=GE>yYY,\5Ŝip6sY6c1[<ôӈ1j5> #R'e I%ځ+f*]vhԡӋE>,8{Ñ!ݘ|`dEFt6h.͜Yei6)٧+WѧaY}W|g\h2,:"މ3"o׎A8>,0)ࣦYæt:9TГER 1Qg7u|E4"9Ygm6}0 xNIJ2.+P0| !Ұv,d;YFTI]ilRӶ:_3γG+gyZCgB\@rgUm?T[^1PKJ iTXGȟsJ.,d[6>E‰\,i:Kˇ:>ĬFgr>Ap< BSQҳM̈k.?١D[˴3DAp-;l6x4XN̪#I۳&k EdH>V{C&6SxML̪\O>GŎt7xxlqcuvnD]g/v? 8pCl*.DP]0YdoGF']7h b6zHȹ لM[*f#Jv2Vd[Q7ֻ<>l=UǛgEx2)>-,|F&M+44f}FlD<\2?K;Ϟ蝃|VmL\\G]RQf'pq: >\2Ehv=m8L;@t^ҋ- F>gFz!SuPg+abe<>8T](\e0REB+"".R NL<چS."h`>ƙqTTYRferFS'GuGɚAӹ* }2p|l:0UVVƵҙA2, 4&n:Zr%-MF\hj)۟&qtY:}7 æn26?H?LBb;Ls1>ldqaGpPE`>,. >-OG.TEh~:h=7!sukC)EsoD?:.8k7tşNd<j7S*uABf}*w.'gfο]aݥEDS\LEGES48|ƣ4ij􎧜SDu! hʋcfʃ)č,p6z:sEu0%W0~NcD#.s GИUc*BgRS4Ev}Cv05!ni"ʛY[jmď\}34·)R2m>x~#Φ6G1h\Yr6;)d4\ʏs o눰h| oczGiC*"\0pOEju`x'ERR^..:0LŃBo٥}3C6I%e#V2]SL\sެY w"l]*a~/KzL~f A="ԧˆEg>\(|MY03(ʱd27UKMzVPS4NR- E4lEG;hl:mCws2W"l;=%d\h}4q´ ڋzS0|]`5E5 G vzs46|pj8j+3\2;~wۮUj7ޞQiGMN.q.a(tAcl9r<\as3hAk4ul*2M7n^83NzUޭyi WNT6) C=-qRBGo8kQvj-âv츉x SiE(MшGV* +Fm;U?-`DsPV0ä%ri=Qo+Tcsfl.Wͦ@hԾ| ҹRŀ};4h4h yvZ+h9ge2(cmv *uaS|,tJˏX:j$Qg>U{M|tEϛNv*qX\Z]:vWML\\0 (8E4©84;8᎘ؕ82rQǯFxiU;,qS."ßnl3G\q9O>u7>=.)@~n7@0펔씩{;40w\&69L..ҙ|$jG`"JӖFh4^GAښӕ eӜy23R'`PS&ʗ>qd\\~\9:eKϻxSd5ON ENW?yEˏAvv]SLJWBu'Q^)Q ?\y7cNBő4踹hJ;sC",-ݢB|,Q8>blnݡY7CJJ_9) ;4<3g.#fٿce8Ƴv N|n-!y [:QK<l>VW<=]Ζ>Ne^O|^ƌLe:&VŠXvW zGGCJZ/#g&ҺqC!Pv>Aqqу"jw*P/]S2~|y֡Q+Sϔ@J:Qp|vsQq/C .zlEoDq6faPTv Vl}?n(Ǽ-Uڈұo)M}+U}XQL{aЁgvx}65Ů6%(TZm INh/:(:-yUeß4O Ys<1*&.:v6E٣^1>lӸm**6GZlؗRF6-^.hgpF @J*upgF:wV۟llSaEJD\ jePL T\0f]:tar&ĭw&VeR,t<l.V¥Sguo=o՚gyz4>6N1`.`z:K3v[RS m5E8qRNJF~8VE@ȳeQyq,AxmQt0h^?.F=ufL.__\jmڜ:"߬C4b.# ^cU q*]bM؄~F#*GN/3Rՙd?d5Nj;*X:W}w$Tg=}}[MfXm HQұ OM:-rMȋAq4uTy؉A*ë<1 L"K<]L8y9չJv٥wU^Zό&pOeK2C#cm/Rvy'35%4LC>##͹J?\OeK6Qxqуh .^o߲u\~[.g6n]{9BecQ=똨κxo7xh o][34FptXjK=˃amǥYZKxrt|u9;`*|h:ؼΆa]L:%heoWݤ:Qcv\er(DKN{g6g=R!H(6f>"y۞oN9PT+Ps&0ʌ]M)5|",'+iB}mCHtPeE6+qkb܏m7:.mԂ΁qo"1tKonQ{@B^|EJ6&VzQɗ;xH8 hu8E"Ǐ..􀳣<\geuTXIhSŞ쩹A|FS*.c lznWC"T\َS4xϟ0Ӻ+Q ^h4Vlc-o,OTp2hf5ݗmN"Ƙ&gNEA>r:,84U,eSEqywC4E[9ק+^8D߯<>µ8A뀸ü>:k'`@>"Cq1NW:#R9#(-)Rg؛gSRL>dhElʢ[ʋhC.nysB'#m+|{އ 'N˛3D[Sv|)\!FFEic;7: ll|O0E7ˏgJ3R"J4\Tf4.q`C1|O\_C!\>:ʜå[|h[ [EäpQ|+zxpVxQK˚;+< 37\ԧh> QXY ~=' qa>1n++\}6<5"cQazu%\qqa;GM+9pie;->ȴ4s.-Q^]꙰eh􃦫̛U?x~ tXT'ʕ;Ja<=MҌ|5 A,j5<pLt:qqQepPu:~oEzFiJÚmN0[eC5ڍt*kEyE626AfSa;Q f&ДXy̆huEiq#uy:3Jʚ, x&2^\xceǾ*WJ.KGf=}i>G;|Z0\Q.[?|2b3㪐`2^|2Sfqҙ:̃ŌsiLȋچQ趀kjE#n*' \<5⿉î")Q]ԩp/Q#螰d^$"%saԋٷC մj~;SáXX |Jf<*_ vU\sfTA|N,u>*76>yxFLUG:)SD[uꕞ,xSv?r)x>-s-;GˣlJm ғ<:'c E^Ltvljq\"<ڛ/8\J?ݏejFm>ipV'Bшgaa3,:e|GhAکXFBNpMٹN63d'Siՙf.8\E,q\g2-} hC7E* >;P|ZèOKY )8=#˲ Xŕf\EI;GTҋwh*c3sŠxtz(pY:s`˓f&\.yhmQara .=2M̚eu'Fhؕ2}f zCc]^8\22.dwTJ­H^l.:Zf~ʦTK2^ehmhhgh>D:ca,lot͋:/be1+VIۛETۮ_m|C?;ǹ 勒Di9Y6^ ViGP]WT .LthPKE"S85P6N|`lpM:mݤe)GO7yNeC7J+M8cS\c|.mH)ڳtҷeK/R.YglꦄEù=Ι\6J Y<>?GZTʠh!PftqP"ٍE;KjT@u2פtk^ åCLV#G C9<>k@cgۇD.:*XmJˮ=]\h}Oন:WO,ښTKxR'}+u,M+'P2nr5;!Ѧ%4pM6cz}%hNG:cX +?Ӹθ3PJvCIgx Z;虷<[<*c`NW"ê9Zg*w8+'),,&蝯SN +\|>kE˕bsSG3eզpTEa!o7۴؍?᭐˧uGŠҰFp>fq?AcQ1ΩEvEn1<8M'2cGJƄe,j~B}r#iTESOA:V̸囼EJ4a,QESEPMEh0HӶqǫB;;<= ?\fѴ\;26=1xd;.EEB= :OyJ#,};r,NeK> zu1j66hh TVvHKTg<C,L*T* jxmkj'U!2MM f>Ne>Gc%m v@|m }6C)suς:嶏j끫S~MtـCEYQ0 Ʋˉ+èЦcg3yX: +fhj3R!+3b- hh4N猦68S@vG mFʑ&L`ŝHerg|&=M, ˋ45CuD iY5)Βd]7wQm ei]?qvCEAXT̿!>W^r<wLDYF"'2w .Vk%a}O2)IBm)mp2DEt%4玔\>v4]cis#*+jS5s-;f?J5<; "( ,aRJl:..@Ml&XQ(00dcZ-&@Wf v: ֎Y.b}doU%$}{/aZ/:vFʄqpփ0lov1VJwC=^zln$><"I۪9F.Ce4A͹+*V[sDMmi۩^V PS6:,z095teM;aph&ӈiVvvhbǑD5`~v.gѬ|X`O h˽9G6m6:5wjlF!v9s+M;5ซ.Gxhhct!o^Vq>gJ}-qDM$J@ #,Q>j6S<΍m@c +"!,0FUΛAː苚0N™G*,n>MVeIS3n.sӠ:o7DҒ>~+d};9 0Ilu D7ُٝG,/ôEC9L#.]όW7=J%fͣNؓL"Oq+.Q.1;*͗f6#\QnY(*Rh`l ŗ)tH:-vÉ[٩#*s_itSmi|t{8DaϕD[<Ι ;'AZsuc;l[a\rgh\3k-sn漢c2ʏQxh|¥dˈ&DL\؋Jjs6\sgU (ꋱ논jFvefi*@,`;Lq>$V*E6\ykmDs<|z>M*g_d6Y)el[7O*;~ZY3E_[>xۈh'ͭS2qq|˦8[nvZ."ŢO;Gޙ:%gcdlJ#hh@YFxoMIb,2x}; JD5tA?.L3XT~nu7e@ۙEK1;F(ehC* fӤШ$Q#*SrW?hsޯ';?<-2qA򹕨`ŏfi:tJDŽM\h uWٳ@]Z.l>\Î^ҕŠu4ozC; jd<.W4z.g'Qlnu@D/JYjΘQsOPjNW(d#ﱋٴDb^!V13Oc(Ņů&9Pڙ |%-kF;]VoOLJ~Ȳʃ*TgWʕFJ+n}n:x"aS'cA \]}(N,E**YQsT}6+-S(-;=5qtEqKx52wq_1cϚqh>/Qm-ҾZch,7taP%a͎Tſ TYaD[^>#XYt`!. ,6C+4\QrчW' P|= \Ɨ*Spمf  Gw#NV~a;n|ӆT6vPbv.D\ʑ <$S<3K?v>Ƌd7R 9AC|͵a5<дC {&mq%\-ptҰtEg;|2Fug9&GDZe.+6d>~*"h}dEV0[6v_泠b%;E;&fgһQaC611I)i3 ҙNWj=r;I'>j.PhikT7ޫ">i 9/SA*5YP}6(L.~@;Gm:^,`5.۸܌DH}He\< â,6*"VMiYځi,>,̕FD.1 x t6ahf>R|Z bٚdI>bʗ lv#/R|ȍV+un">h:5-_H7ir3n<}3|=bU٧M,ex5ҫG'MeCǝ28؋Pg<'+cu;TQ/b6~*|~·!>Wf_-xSnk/xNdrsfr04\ԣ1alлU%:,r̭,}/Oh۝o*c`kòʎN+ :3 q.ttYPSeDk;Ak0g2,C΃.Шz5QpAt"DX(.6:iC.422)Qq;"$䋪PĠ ^Xy4eT,2Sڜ:"QRYUKƻt;Lugnzϙ1`**G,Tn&-+?x~13SKêѳ@ֽzΎ}gc)Y?2b&[VnU62Ѵ :]Q~VϫʮxKӫ.M`}2|[m#C D|Yãm;nvP.f4G YCleqde6j4E@#9.j0)#S^Q" DYl A./t:?9e*a,ސʦ̕bڄM)mYx!rו:vccETWf;5>\,FSy{Lp|2oEVv8A3hfitfM+=bPZGMQm͕@tYVWlԏL-`"t 鴻44Y:)'Ŗi\tp(`lSƽ=}v.~E}<͡:)3J0ՠP.f*-ov)YLhqi;'e)aJG;v}.N,#Ǫ̈xj}'hOO/u:RLL[ҊruAmϝJټ0Fҋp;MP"ʽPu¢.QIQm. 2.fCbXqK`ҍd.hMMMGڍnTb= `ѕငAOœMqPTcE5dYqpqP- ֋7ʕL XivTvC\!JzQ"Tm3"ŸR MOŹ1> n"*SRSLJT;Lm^oҔ};ao+h*!:.qJP>TiE ~6q1lJ%5Tv>Ň|(_2"n]OEш9Bf3DY`#Ňp\i,CLqæaMN:B"Q~WińJZIO򰏙BQY#ρmy>'>28M;VڰZms * +`yu &p*9cA*u#3bzeǟ!0eD}0 XjGQpnuʩ3ӬsC.:=\.M9=]K.4TS/Qzes(ѕ!ݢGX4i}mFJw6Q8\?4M:_F4YLV*.,Ӳ~:'Z H1&V늺~)p~OY)^,o(w2" qAgJ-φhԁQaqe˿ۜt" J.P:" |",2|e<嬭P'oݛF\t4Vseӳ]ԙzn*ceg5۾|#`/҇QWYdZނ2|0SQNʃEZTfTlH%o}75rk|v-T7TmT:W4gE :MɊ⇞ͅ+7hNrϋKeMϱrs {8|^h6.;ѡtoΔ"˃52ȱJGAc<2JT캞g]nڟ|t8ʔ㺢U.*pNS6Uk,ۼEƆ>.gEB-aZTLWJǛ|W'4OpzttZw:VUsyVcϼV\SygM~>SD ҲMh7q"'dG|;=|t78E:/,lXx sEzg\X?SS>JtSa3֤n>}t0y1;M˕1vz0* X[)#s&yQb/N* +юER@fxQ:FMh# V+?ѣ\;/ˡcmF0aSi\G]'-dvHȳ.>YvpF:JJN~^BtN.'.h."b:SieEWhs)g>2Ӝ\NPȯ?CHYƏcf:'sQ|xj yf12̃*Ĭ˳Q:w\Cz,4M\VDYeS:SGe;Z\:ϟ+:;?z,TyFy1 NNՇFg@ 𘋛ӴQ̸6W7z#/мDiJ Pn> huh=é(ƦR:BNl6:X8ޖyզ?O Lҝr CtEXo Ұћ2VcωY2 xtt'mHV=?l\Lt\E9 "S>"*0Gg_ROSmttrDXs(tĘC}&|okKN`'-w \bjS}_:V |r{1vs#ϋ‘}xm!c)Y~o缮M|{G}Y֨d4i[4}zE.zȚ4(!zQg7;ͳuëe~* lU,#Ncs;G;ӯG+GR'c2cH>^'xezSi;=ޔchpx;4> Vr9^1~f\ 1Yww:6C,tX1aT5{΋gQ:9 *Q#mfWŲU"ZNjsN*6l[ўsg=MVwYz:E}5gg>  ϸVC~q6ʊvOn۽X|hŇR"µ6Sc^%p6'ςU.>,C!Ռ8#>Y#?h.VsavdMtB61"Yd=:.'3*]rckDwH3"4byr4>RLx,*c &*_4N\YSHDck+ux4H=6AD6]: ,[o>=ꈲWznzDj׫1{9.&Cc 1kѳ.'i3}qLzVf\I[&K49M]o9y̗UwGWՈK'܉?+bi-yNWeJf>y/H~/kۛC5 Qp*|#1aԀtMZm/scAҴٖ@פ4a qR>V? h0wH8|j1Ɓ'ջ,|J f6MrdpҝTYR򝲠͡Ų p5M1^S]s0~ThVU1_F̆} uLc&>m5 fhQtb,~ڔ\N*3hl l} T~vx2E{јN|4Xe.q":U}b^ feyd?6hc4N''iREl(2'6G5,`C0\Of`)nKa]_,qҺϮfKOc*oqӕ3o2d2M[Q<.D8ɴpQw^ro }NDMq.ve9x ̓ʛPc|s t:NW=63>:>4Ni[T|0şqP̳#"qq>:* Q">:Iʆs;Jg؝|G}6SKS4\Ԉ*f L>}e:&h'q Y;(y~qfU7z;J"lJJyŌzt5D5ϔgЮHq^:c86.uB=E%KZwnN`G8oXj}+Ƅ X"6FќG8vh1àY866}Uim!ZFk*n!e%&2:"lzTyͤ8N P.FXŔ>eFfqo9vocn|D[?(٢blSai-EZ |'O:+cd9:S̉6ju..Ekэ|'ss:5Lz>"MNrۮͦGnCώGu<\U3D\\\ˠe+qG_*u٠dh>,#,U J-ce2qN>z]UϽ7hqqfz,|H:nk3wEp ޛl*,}7[AѳGYJtQ.m+Ý 5W>E2t6Dlϗ&Yy֙:?$dމ3* CTe6l|4N "KgR,3Q^I7-9OAkˢ:DYn|C"V[V>[3B\/xԤQ>"xؙSDX;Nr qP!vNjo)r6} ʉsQHB%ldR- T[>'h+c#h"yW9U{D2o7o 66vMN*JɰZ<[+]̃HEZa&͕~e*.1+ȭ<"6IEpƨNVfD :-|ǼE4XvLM2̈ DkM"}'yJ-h P;b}Qbl|M7&ՠm?̍v _j ѵ8xk~ב(ΒTٮuf膸x'uS҇z؝NJJ,*ZFE"&'sg?Ӫ5O?`p^ub;w1>tzצccq?Cf\򝄜e1f{J-s0(vrch8}4eDYϼN;Ms)>mJ&gESQGtˎOeM N* (r-Lᨈ dJy᭐*F}wk6E?\|3iaF^DgfD'hK # hލHF"ca72|T:-`Efxbæ!V͒S]ftq1N_%{֕fQg Q{dzOv~xʜ>#QfÓ X˛͢ FVqnlF NlOh츥}R#A}V/Th`\'^^PpF>'s/=7C9Ѧx~*g!v1*7¢& \|Re0TȄ2v ˊ}3LNƆ'+2""s :o7{lT㝩-| z.:ZiGvS7i1ç2٢fZ "Ql}!N>Ac0go.ʊvm&o˶Qgd||',/od҆N^V쨝OSX2:o RfwT)gT˓Fs=LD/^P.Qm8ʶx,t֢Cf1>S!ע-.>#\Jե^m*V6x3^"a'[N#ju p3hMωY [~ f-TGљ~sDZЇ+4\rϕX|+ 'Pr<q:ʗBqpu wu:$\h1ҋWyW6$]>siqg¦'mu #"StNSE&,j1mIY#D\*Qo4tuvQ:gvN|\h>hD[q^2,a.:nL0ҷ( P$LϢQǪ odv~Q]2UW44Ͷ\*)^:DrqcmA4|͕ӷ;DQ&ӝNN߇ʩ/E1й3;A86Vr^Lɹ7ίHGH@+c#\XϦoʕFmL{֩N.TY}N¡(©J7g+(:>6r L4*v MdYW+8.U*Xʶt<E<As9S;H.5"-h|t "ɑ>_TOrG 7>Ge\g.ݗE@Tx;C\s*o*yg(E.mg<?\6θ̩?Si kVxT-Z.Sqm[4CI;ygèu!qg:iEγpc#<\mMgC. G2AҲaApٴ7i\f>l.i`c^)KCXmޱl*Mtjs7T Q)3.4΅ըfUfW%6hJl2GXSQ.,aʎGe3I|56\EjJeMLh.o(ќ/QgMqRMcmǙ*2}Om鲄I;OEèlM_pSOVڋA)܎jC42s(Es+-(QꆝE'I᭙e}!qgntye]~(T㴹ScTSoD*+M$s8T엛G!~̂y]'˳iHTp;GPalC~\ڰ79G2ja**isx춞hlKh ͷ:sy؟qv#na|uh,0M>Um8CĬi\.r6UV`00dcBWBp@ @ @ @ @ 800dc],'@OݫY+ ]]ZkݕE&Wo89,\{OE,ݲڙ{T6ɲirm1۟i?6wm:T ,Je:N T5: сq˾Klp9 ..5r yDA1@O[Lu saQg\l5^tÄDYڌ>4E1>"ڎNaи'P> c LиN|)ƣvzndJm Eág]0 #T| 0v:%s_A1ʍghxghs :)4ŞY9\0P& \62erLl&aPұGعJc ]̓Vެ鴵>1h#?v+Xݣb%P͍JR>xFs$e'S;?6h3.z!'+J3@ !;mublQ )öO&fFjnv"V>#[}c{*gp>6oσ*oL"4Yt;JUlÛQ:"Lvcb&;㡳4[|gTZ 4!>bu__QϹRv''Aca֎L>uKAPj!9[F0j6EoCv~Xe|+4 rY'km+jʆ\X: MH;aDX2V@2,!m)i[eGX8\itʇN6nFtjī19)x.G=QaV>TfӹafMΑ-XLק>sdBs|[M^FpL{LKŢ՝8PD5Ҍ 4Ϡv:qlpQLs,4qgHPm }DvtET;KlYB.'mC͇ˏT.,2ü:±whn<Jhmx ێlNDY[HE6}6xmQfê~qz, Fh*"Ai0 cp_hN>?4 u[ 8  ki,4\)E4W7ދ|*} 2Pؕtj}ӼW:p} 濪RO1)34F}T\ݩSnx θi7BR9Ų܌ .w3ų#+eFrVslxm o2zx8E9:-(]En`ˆ" N> hc!v[ӨC+,<<\Zz.r2>ťbVχƛ:,t9?DYVx'n՘wapl]I2#̜`DZ?Ez2.!S>LJCs5:Bn CcrtE3A-C3#?_hϽZ;Jh4~m1Qeu1ҋ\2ώx%F[ mՠCW,QaعՍTE:;ĬSCA4*'QzW3A8.6ǰe"gJ8 #yrpgv0wgDo.^3l:rwRGJTFQ*+}]2Mv>oyuⅼ:-?)^4]2sKƶW0|&@*Tms"*aE}tFyZ2~cMu̼2!Sui3 ƛM\?[n=&Jʏ p1c.74M.:.;>[t 7ֿhC4\Ja8. 3}&Ot=ٞ=plYarqo;)ApSY`8m5("^,M}LЈhCqQ\E%PxZ}L$|ptChЦvO6mFgJp iy.Ϟo 2*D0QN8Mr(6i[Vʙ{z}mϙwXSuOyfhnvoMQu7id+ CSwX]6%ZS3L]uE DZxaN*%lQҳQmƷ<:Mj_;l|Sh6]Vngq":ctXڎԆ- & _AH \6n(etb~ڜ]]5ETG(^.mL FTz4`ʔ mN-EC*لCTC<٥y*z#"ᴦTZ'aH3JW*S:vC@ɻ)\N|/A,|V.2r)Ӡ :ns:-(yjAe|ը̮*^:Mm(6Ӡ'z)FmQ;vu712,!.-an'ϻiG-`^͢戴˛:=j:42?^?=vIm>MnlDp#IXfq˕ų4<# 'v¥aQH|Eԡ,t~١%o eCE£ƓF5.U1_D4oK]xD0}_DI\T<è~E*]̋mATvV|dQƇPb4`iYgG`'@EtθEۇQcLعF^:V3Sj hw٠NSi҉ _!x+AiYe~\e+eKH;Ea#6"̧z7tyvPD/z<+qq+JVJr6qST.C.;v2Ncj:7V\ǟ :w< <Л烬>gM؋\T%sK<2,t2E`R"*]KlT6;-L aO 1hI𘪇7BԢ&O7{j/hƟ43C [@SѼx0ZTDӫ&|;VӝQx}E\c|y0u49Z4X(|~\tu(z=6&Pl;DXA0g\ Onu5Voj|D(ϳSaԈl΢+pExas2|5 sUN"msnIiX:,i8:YS6QώC`u6F%s:444XzEdchnӼ49[;Ń:Sh6M:%qL2vE\;?EQq-he-16 JNqf⣶yS:B\u6Rv#Ӷ`G>u4s=⚇ ,4OUŨNf:,mT* *cem뀺6hroE(=Xm/r`sg0í=jTJ|y7d%gxe/NVRF: RQnVg\SJ TT5 M‚.Aa~V`?>sρ1`:ڝ^P,LrQDΦ\rhLi3isZvi5:|cQRFTM:!$NGQRfſ.c#(\ 8&,:G4Y珏m1N6gcn_-~^\g"tFU93."}mD\ԏ}㏃G'@ɛcc4{W].`a- +*o,4>jhž.34\QGƋS?PvJ*B5 3&c" S#l,\̸Nv%vl ZC2 ;t1L`wPy,o: q>;u6xh*YHd";S 7TXv!uH\\Q\ \ q GT6ֵL^su<ӨȞ3L ]Y~ce3,iʮ:B\ϼZ"9p'v7ظ0؋#˺2OWJFWN Fo3jfa8\Q1W>j6=2?;Μ:-* B""f@Tlm?TzhjlcXJ9beJ6_UϹrqp+i􎝢4/gwʈVhc< 4o+E51YE;&:)=MitNtކSv^pƍ |ELj.qa3LaQ3=0>|1ѧgBFy wˀNEYgSeӟ,3:okⰪ, h :vrFT?4Er"bvWAf2H超)Z].mE uFŜF.=bdwv]GxC u⡗K?(Lop+7dlWbԹtEj!-w>? f񴽯fDt͔C#ܦuD[uhHf2h?@N=i ^q~% [&Q.'ō:,`M#\TӋe 1æo! s(gx(J2w¢=P麰`#(e}9uME4mʦ}ES6kẢa+cV*;ۜ>SFr3evunH4+Rhᣳhf?^ʖe EigAڛQōuy}@9kt:-:] F.Q|Nl|1L1|X:Sffa8cϑPblh]TOH杚8|O0 /cuQ梦cCh ECGN٢})( EQT>;4U4XpjEϘeQMm7 .h8D\|B+8l>.^8kl<ͣU<`TƏo t= hxvCexwy:L;}-jeU]OoӓL۫K}Aށ[C#g.Q^4qpͨ} uQ.v éN9\ʫS?KZe2lvxf;0|sȔ ~IO,UW[7+vLL>w>Gf|åcPeiY)"QoСT[pVx;fGDYa -PjS,v q"|EzcPE3O ڞV3DXD3 DgàqdTɐq&b mWN35'Oq㩒3av>ghD{OG<钃M׏\EJ»*GouB9ɹL뫜5H8ğ>zV3Td|FHF,>R(R:Ezl~+E <0t4PŮ"ÎeNMC-tee::vty4Xz6(o历kz";UUAqsMGg6GxEGɴ\?ɗ69rݼye8Xe ŦP9iō}Ϭ[귅]fl>,?QӋҋTfJx**.Xһ*ZZ&XjSŧם7Lҋ/ˡDXŐ)-YҸM+C5f:|-Y =sur,}靼4S,'(|66lXh0Ŧ:v\2k28]7nF6Ŗ_մ[(h$:.,VTrLE eGY΃o)7˜ Gl.uBcwlF\ԓT x:.zc:ڇ-輺>x\YcNIΝT.S"p w˗PxvE6Yȓv>mt[قTrQqf;G q8^!ۼW;?yptEPOâqiXE7Z"TQZ蕈fyF 4SEiZϠNjSaMhH蹥SLt7T1oZvM7l6t5)&\T5s qVFq1TTAώb Ү:q4vTC>UC;MM=veq[)heҸ˧;NV S*IdYD.+6VM|>O=C)֘6.!PhԸvQsKrNZSގ} Mv)uJ-O7:e.%se N+?6\\!Nm;U#"6"bv̱0ʢ;C¡eym򡗅7xݞMw?G˛!fIwR>,$ELc:"هb(2d3ERˇDM2:Z΋-PNxf4Z.iнW)k?&z~9se/O_YCʛ,tN6ꃥoxȡɞ,c}: g}N*ԛ546E|(Q9ᳮtY(v6Ü˙ʖ:9Z/C y;EQڝF<\>]U+O 7/^.KY5)<ڶa5bq #iC](>+ɡ@+hxРFgqy}6\ѷ;աMɧ i2m([h9{ü\u 0iE?c~2xCOF?Nn'A󰀪|"8\RSt\.Fi{<^e?U:?EDz=1.Am Ӵʋ?>, yX$c$s2Q >R055d:i NѢl sNA?M0>+,)F 1Tld"vCG>[!s:Ree>fqg#DC<7˾s :-nOυ?x G7Elt2^lsE28N\\@,UK2,(>*"Ș3GJҋW9|ET`e:EޝP:.[6As D`ŏҧQF:51|sHer#o7hBǞ>!қ-p:Vn~AMcQ;FWh<56*,Pe͖:VrDI΋J}\:VWOqi*-p|Qʗzf\GTw +>3 >j4nteE*vcD\6"8nƺ,ɗ͏4N\U=Ƨ?84z3<L}v2c9KʁDè vS cѳ![̘tل@l4El5|>TqG3ͲKju|#Pt3,6_#JdE}h\sTi)Xlj;X64or:_M ,}1P\{C*`Y-a}Cn EuGjΜPH(K?IFҞ%E:VD[m ?SmGjA|U,[vINTfAӳEFi 4#O6c٠QLS35@,Ӣ".Y͢= xD퍧,cғYRNrt;9rgˈn'C<ȇZ|\GE! f+Ohm#٧#j2grqDmϏ\>#}ceU*<6sϦQ`5F F||<50M hJ(kn,rcĽ\m8gϟ#@M Ѣ:?KtF?T?"0\zGDS9YMO+\A㼧[4eX癚GF Eqk띓hH  tBѡԇA̧*- CJv3;ŗate6QԤ\ٽYEᏅEs]LN"TtvT͎˔@{TI*N, |>ըOj7Ts;㳯H:vi^Z*v/*Bt|s7ih>`r3 >h-H+EodmڌP0xkL.-vȕ͸E* Ȧg t83E㥗+=Rh<Q0RCFNV-i!v:-7<A&T;Sh>.3R 2Ll]4 ˢOvP LTfҹ KOU3<>x:,m >hڊ044YYlPtYT{G疋9*ap;<3fނi'e+fQ+ `n;h gYgB&YtU3<+(\,2&Lu3oφjJ,Q#Ŵ Ԫ6ˆ˞fYSI4i{*9TDX_>dPb:e>"w bVWT>0ۏcy\&@ɑf+4yYU)I00dc)'/qC9\ :XکԺc@t⢺lgBQ.'p`H'.-6ϔːxokeq * mKƝc)AѢVwM +,:Eo.c/aQ30j lft1p|c 1lw?jaEô<g+8*'S0-3֊i/jt7b Eฌ]Guo*,*UMNt4NqSDpt1pf=OS2W?t[Eyڝϙr9q}0:vEYtʣ蕮P`wL&FcJaTZå8 aFY&ٴ.j:)GfZ+U*0+TX>8U^V 4[{R]N6)B<tR}9۶6T,(ʔ&cCe1D mĮ| .ـkO626=ͥ`Z!>yrtv1賀,* :AtC[0.\>(ȝ.s"zyS.aP\Nm6D4g)1Ʒ΋IE:uVa|j|f^0\h0:!ToAFσl MGl"J.hO9+08>,E,*V^S豞.5QrQ-4h:WY[E1ݡNVYQu|OD5giX4W9%slmm8> ^BEvRiN%Nޙ]nzl$m5 l/^o8-:*;~"$7BE1LJMg΃RqsjApIL`@Lm.SgWx莇FxMU4c;J;pw/2"ʝ*QN)&tyauʎ å6\6jgW4s:-0wb7\l/npt5֖>goO0 EAZm2v4Au\D\cA03d/yʡȪv8 ~E+cӋg`d!+'D7B,8m[4V;wzyſ O6`ӛKOlٞͽ?evzD9(G[L׋yCp77GC ƋU1vmW6o.觤l\>\*&Pń7 ?:eNjҶx!YSb"dZLXLf7oMYC^8XwfTg2\~W2x\:PqQX_(֡PˎE~}dXl|E1HŖrǐ`(]tO\EUݧ*ەᴎkKۮ]h-э;i`hߊ\ e Avs`j"T:*ψFT."\0QeaQQx2T9roxһͳUC[%Tj\]t xFUTh ڱ5- XʋarD;C*%eSO4N˾: FiLk3}6y8B87һ<"r툓*eCeϟi:uZR6huDs?+V c2"'>R8BbHF}Kt!cb0g5FpC(:Gŝi:qetLmJ3X0@ b%g}- |{-C!HR@3N.S>@|YctT}C<5܊Nwmwɠ^TPtED98JoX^j0铻y]8W筒薘geOEVnmTklqg=P"MQ`*ʟ|n2OW QJƛ6Z;Eg*6<ѼR>]΃`dwrgK[oEDX[˖|`: "ҍMϖH Қ+R㨨Ƒb<}yypT7Ewu2m lJ.m Ivqez// ZFTEN3 (L N.SBҲ)M;*H4IEe%YB.sa4ݼ<`ьiG%đ%f\3 T;GYNzmfG|UzGNUuaw!_:W[s(#IuE6P8ԃ.,4Y`,LGDdJg:gИt\*>d1dqĮ;<'V2F5JԝC >ght5ljUM6|qZaS.[3ʱhįmUQXf4áVY"p vhs;H3]]VG`n4X:'e2tCB"s!qsâ;mtx;A40,%Q<#qgjA\u,x4Ws%C#J e2C?5Qc*gZ\:5IL?͡٢V~yvR| @2ĬAwwވy?~+PЃpz3Gü6.CNMV73._>OGЏY&t ԉT"y%sBE>!v>k|1uFA*ݻhjpu˕ITȺV՛I\p|S-rKx}Gя mIx2ÈW;PC" سʆs "Qkǝz>DYR8rώ\ W! w}:9X?ECDvD8 )#̩Z,PD2(Qtl:8.W"qі2&?wχ@D[hQ whNRꋟ5Y}fҝǁٞ"[C..4Xs4Ah<,D5N-44ٚ02Qs6So!?4|j:'cyz~k8.VƋj0mN|*,DgNVY3 C%-ceCg˙T<lҶeLӹCu:"6Kc`˔pACvƏE&-sSvQ;Ef4n>]xQh:,luB,|v^'өs:iM;CE*o \TDZê|9rqBbڇ|\_FB"T6ߺ"Mh|ylO\m3oލH.5az>O;뜭wV&) n.r2v(8h:W5!EmE=(w!Ly7tCc"}",;DD~VtP;X2CcgFŕ˴Z SER >>r@S;,>Ƹah>͗oll|1}RN,0xT3Cn"2/e }ƣoF6FŃѩ'D[KYbr)!fC@8؅F೨x1fAAqFw¢q0xB~sfTqE+Nj2f9ϣ|.IolXgg\ _+B,u6͢-CM /h`*.+љKt1h;9ӋeO^>-d֡y-Е|,]guqg|A3̑\QsJ,'.]FP;m:fhx"ɬ;΍6c`TZ>,SN.llٍ}N;WKE>>ʆk 7t.VFi**5P$4kf]lEl;cc?͕DŽOIv66,Yaq,oLGiyY?ǻ]&{ngT+ɧ"<2vI4pҊټQ99|\l>9A񈰯MZQ!u4EM D\4)nt|)xa?ѡ!>X-ut0dG:Vv]R}؜ewbWQތʎdcQI GSwq4f2 }訉EV604ȰiW1nRl`XJsVlA.-]ãwCe(x8;TTXxğҸ2>"gE.0[|GhQef2 +Pӱ4N1gwjs㏢cŃ >#5!9%|łrJ-ٵY?b̹'ҝY:" #7ž^ `|.u0V?+ 'D\Ϡ*\iN,g:.;shۮ!aSh}7͠CW}3U1U.]:d(qUs\2.'?TiD[ϐٓˎ'*GVt:=c` ;˔.qcl{6U2ߟgQ56T#ie\x2K-6)Hl+fC[)i*΁ԃ1ͽ3@\0IQgSjmNvZQæ&-9z}B%le2mMh=E}=)hDF}5ҋN,lSQ؋@>Gm]2QLx%0 ї(dYK8nW:? b.axѡAfc|.|O7R'qR(kuuKF<}2fLAVnqmA͑5S+j_'vmwC`6D5K[,lSSm*,H؉i4Mo42t\y:ƄU%;QCjP+EAW N8"]RLqɲ*>.dW1+:<}6xZ,'S+iZ*ll"v <+^V!j:6hJN-[QhceS8/CN>m>,TEspjzeJ.SAtxg.a4:3hiyG6V;4U4E͔eyvU!cdt?2\zVç &SkކĬMF73:\klPcvٸ6|f.TODm[efmj}W'k]u/O KUkZw!¢DX|eb; + wNe˹S; g>tD]2FDk'Kovly|\lfL+->12T0TtzC |Ac߰ys*geL]2|&ZG(TҰdr;b:te9&\ݑ,|lGS"ɏ9lsˢݡk~9 /9ގ(}mq2͟K6l]È *2Qs}DAk0:ajhZ¤?+T]? s:3E̯kS>Q\Q)~.(tu(ʍiFo;YKEwzҳy~izLόYv3CG/£y=Q !e>Y:.Eit$hJdXuSEOXG@TYNKJp/1r}`r**Nc!ZXxHtY٩EGZ_ tJ਽.T0apeJ,G9ë62L}Iӷhv7@N>c8[}7w8.*xQz]S IN.Y5::(Loy|4h (D*FTyu |bEPZ,tRʈс as3E<.SM6'je^:K1g5(*EU\|6ꖋXGj~)C@Ȝ*Qag͙eG)q> ;PSҋTp|7,A?+.3K\銖˶ӑ"Liz6hx%r͠t )Y];> ,ehf'xvHو\T uEˉoDqoE\>@|agnh'qF46&DXE8\4[C5nt\> 7vhvY@~+Tiʦ6Xhj4Ϧ*-#ʎ8\Eͥʛm3sĭELDt|YLJ"NVdrʼnZt&gCs :,iX8t#L\vcIECΎ]vcm+LNKf+1yڵT>>ruKSDX"|Z8bu!řvx|;4`;&}st ԓNʧ`REԍ3|vǑh94t438෢\MF:<1q4OVQеMvYԫ|7 l*-|WUFVjFsZ@reJ-éf)œ644P*WLk|'Mu+mHCσq s3OvatYʡ$gklERDGӴYT򡗧4>e6-o!7E fNt1уcI~m9˝;1lSjAsPeS8>VL 棢CSedJT.Nr6,a\#-dFggJfCSɆS6ɅIm눳0|Tط?>>\>8f>ʃuO)"CvqsѴ#ωkgBi͞;Ddav뉉EKxlѢ53SNV4#EĬn"#*~w:3h+AQ:zCiJNO3ygSb" E4[*8vte:\u&jiI4E1rm]>MehdΛHhNCPPL.#j`!OD*Xh#m9z^ѦJOCNh|aIƳ7 ʼnƝs50UϽ[sDEA7Q踍9pI>Ti2|:5ϖl"(>ߴB&Lz~ٟx隐m s5y$*?M.̳eMLD>.5 X+ ZP}!PƛUt}ϛ{ŕ=N66 Q+LQSigu$?KD|XTpTB,4hi.vhE١Nr4N(`Iqwx{OfAw 's/j* :"c 3J= G3l~\g3eeO6/of8I\?"f\CEF@͢LĬ.)_H3yOk-Df+-o+&zCŝT#E̓G\FtO:mtDdzw\>V%A5iTb%lOj}PKEwSi3À] Ɔ CuɭDԏxt`lNӕ|1zRpJ4qlm<45$CѨѕ;EQ+`T=rPvЋArγͨ.dhG :,M)іT[o |zeFY6lz\ɤX^&1.u(dx UhqW̛CNZ_2&tEL)> 6M lŵGCfBW2ʑ#.t\Faib4ZP\^%q5ͦQ8.i#4rhxNVWڞ !.Ggkpii5 yT;JMCNf˦i٢G Ck0t?w<[ tvTg8 .TetqzQr'yN::sAqu3E.,ԣY?Գit鲩=W¨₡qr?7 Zvσ+l73Qv'gv Iz,6w6\Lrl2W:%6eCl>Z]o nEUCKh2L؟EP40\cxER]-Xu>r w3+oFE9?#EFTm)8>jOJeRUDk,ƴO>Z3G1>qg"hͧe|Cg@|ZxJ|hˋX4CY8uCG;?aޑXTj7å }C*lzb>۴ar+EO;*j4YdQSSPDY88ifyXb/GSaӋ>]4i[lWjm1&}66ӥD&h41fTNzuOxwE2ͦ/VhSv)JeD>&\&*6nʝe#IJӗ).>.3R<.y`s :Y ;<.W4pu'o2\)700dcBWBp@ @ @ @ @ 800dc+(~cx}NDFus~> l)tdScᲟє}1mD1Qݳ!sf%nqA۫Jx:hlpf2==N|x"\j ^G'QJ,х˖e-|1hY1F|xN U#\g4Rt;4:ao/\šdAQJ|6?3M2gx~DCJ8}Ŭ"LXwtWArf l4.htEJ-Oct'G8=2f}D@crͺʇkLyY[+B7GV3R8{m .2qޘW&9l-wYwʡ4#G"\ ,mmD.t홴TvRDͶJ9H{zŐcqm\%lPt;ңvg+;;D3xeSF+)˔ gg>pQ6jl.fE9 bllm3`/JټSxk  Чz: ;aQ﷍C"7xV\.5[κt:DZL:qpE;odXh>"*ZCeN,Jds;D.tEtZm+ :: w㩠Z2|\m43ʏ=pи̜:cǮr|92NŮ^yjIfFӴĚ5UPu-f}GS3ԹZrlʐ#˶Q>kw[N3˱fge/Fmw3|,T7m%l5GY:¢2){U;Bb"Ip3*" T(}\eEPtTNوio-+`sڛj42?N.caXQlW!YjE@>8)L*qmTnlݡ44XL!qtT NFi+_M3@Y;*߾RF >QgcaFx:wtY١򡃨[NI+SxʕۉҰTN^ jhVF%am?)_2&ov\ȆirLSV\tqmgˎ>z"˫: 7V=aGwV!-ύk |NZ\Yp"u(чEe`rje8u&r&f"gE3f{#addC>d̹ qQ6 @΋>s I3D[6".a͠b%+,$F';;p#OyEƠ2 -åC?6PaPp:<Ŏ=u'ԘurpLX#þ7^TE@oSKTŮtzQgVͧCղ߫D*.vFA|4JŘ3+EEGP:ĬM'Ilc/Dt#+ K-nrTLOX0鶛E ̧d!iER"Ʀt꽫K7U孾o:tE ѽ1>4\'+==\6F[<,a{DNm傢ٍVҋv*EN.6+&YlګVzl\)5Е-?tt*A+äSWv{˕`SSD5E#7Mk:qgtMhliYQu9YLσm?jZ^w9&4.]1(iӵxhC\KuA zގŪğ64L@ȇmm?tu# tEGf }<>IT`Cg:M*V̲BDY<QN8FxD\:4E#AŶڐHGeZ'=Q>:" >,+76?&P fr&t1iŝ.҅Gm UtEǡ \[=$t\B>qf4Tb$Eowèe4ENJҮ;pOʼnӞVє{;40:,/Sch8}m]>Δ\Np+;}9jM#ўqdV:*,qd)y kn>rsGl8>VzMio.Nj1~wes@P؋XKN,RLLXg Ұv7XhcQrFv|^Bu"w6#afT34 #͏ ]VL m8Y:*vE(**`h5>Jf^Ays$fcQ4v 0*۬U8]>|5 l|C,v!\hH]:-P9*WDdk:u>oQvRt^&ɉ=ߟLEǥC[' ])QjI݈8G I>֡Te't[hm3}>:Gُh 4|zM3} Fm/e:DS4rwytQ2 iۙM5mL}6T.-:ja2YP}sZꣳC<[:x3Jv(|YhR!j.S)΃\lE}&7v2.^:8yo2QsCGhsz]]>ݼmtR ]_ɳ;ѽٞ{iRŮf\: 6ؕ3T;M'>3D\W" *.Bkf "6i>CE͞=nZVg1sGxEd\]-Qsht=TrT.騨 ц#g?@gzœі);!w6پJ狱֧KմzHlGzn)=k0|GG,=:];2cʈ.>}EEeift\AS@Ŋ3T@.\t0T|cCxDY34OE;=3p>.JaU }":ʛ<q%}8AXFWK&uW|X5Ri3IJSfդӋ?MoXM+mX .v%q*QgvErTe+>Z v>.Ksn~: ; |_[~Ϧl\9hC顉ѳ\M靻K٤"z ٫3[mwez~TЛ3֊ѣ /W:Ը:l;T\ک YY!JjuC#N|OϐWqӪEW4~~4Tgjv„y+E@#rP=s'GN~ȴΗTˋ7hU3A*dX +DOGh7.Qd5oLۏ9uc v;qDjNhsOBMao4j0xhD2QtKEeF pXkIJ0\7Q}N.|t:S",mt߈[eڝvYsKhUh4I|5"JШqy;^f&2Phq6 :ukΓAq3tLCoI >Xxlq>qsL)-YlCn8Ύϴ?ժQ|jٻpIt44YHO"6.ʈ3;>t%E˓|6(hЎ:pec) gʜnu5٦_Lw<fDcAmg*w!qfXOэtqQ SE6S&@g:4yU- EZ9pp:14i4<.T}.:cV|0\͛A4]p\GE}=:O26x|ɤGEàF' N-7?D@\×MA~VT6TCTh*[ѥ?TQj"qbKU%F]Ĝupgy ..ETU苋aQX57|EJokOciG6ˆԤJ磴i¤;;"4ҟ5φtCB|uAzC\f,||j 606~Mm¡>:oq5Ik%=i]Ѣ q8*oY?o.3V s'ALQʃHHCIviJwhfڥEu{P4ZmއCK 4DǼk {ςt0)ɶzK*v~osʬK3N>P~lGΛ.W}639ޝc9 ܥ4<֖=FF.=4$qq=em`1爦-?:-٠> 4Tvit<[A(=ASeGPZ@:o&]k&QσE+x-;aS?Wt1:̞}mK5+ǡ--LmmVfc?=j iݻQ?kѠ3ۧpQƢ۷yxթWVw(T*:nsjfO"J1<l\I>3A~5 CL{D 2Λ3je07;L=P=GTJ7wh'-͓fiSOzctjE.h9Zҋ}\)˓Ȼ;vT\B)VSϡ[|tTYF,:EaF(ʤq18ƒ:-yӡ|w8E.~cҳXu4rʟUY>jsoxy6qje7%R_&^\Ϛ"Ɓ]s&m1kfx?GߺV'-_H^n}A?6sCxho&>;a Pyڱ=dxY./)NV;.m"ĭ>yxu3=0EPuS!Eux9>d,QQ84i4Nqu \ҫM tehY.2<[G9<:D| ظuM.eJaul+V4NuK6i.-t9fbPn 6롆V;*dZJ'\ΉyEjR'sME5*(sp+0; :;Aoxz*ZW hL ;eH+.kOφd,6Gkeq߻rͲ͈:oxxl vᲢvW4rw鲩AVZ'i}gmtE6o*+>vUB-gT>V<1DlS)档tq&?] V}'ښVO3,='C.^"6R#ȐXX29΂Yg=1.}Mt2E2v  MvT|E!ޑ?l: M\+9C"ë<6ʉ+\Z~V hfxCEa3 Iw/8GZ?H&5Z,m(w31窌>4\pCP/Ŗ˦x>~,vQ\vSᎩ˓[4~lQjSJ|3>iχ+\|2e~Sp9 y͗gsD5ʏ#>QbodϩqdtW@|STfdf;Dhc~L>+oM\jdXxCJ6%сk>>L@\Ps8r6Wn"<[ 2 ?F"VYhBePDZP|tb\~".2NwJTF\hU3'Ǫ'm.Z6E8,DDڊ9r{OӋ wKF5 fjwEԪ*uGPev.3ƴQLB4\|1u6q5>>, +)Q nyPxz^,|5 }|qhh>V2Zg٣h:|goMeN^Ͼt.{]r ,|19cҟh1t^˗uL~5? Tݼ:W eM i.Ca4hF CX>VTF>,ޚkߎi VG.Ū=Eoi\: 5\YiʆNŗ$^"X:V(bУCJH睓NtNCc(ͪћ95.BtOif*/oV٣h-:Bz)Q'=Guσ"Ga28VFʈ+jۮUBYiWgűJ}R:.R vLBc;O hm=EQE׺)[iI6ZNyTOhpdEDׇ+åuAf|\\7'RiyE͛&J9x; :JktVef6JiM+Ϣ-8\O\\5xM7:]J ON: iJsiE96TѢv2;Gv3y 4h2 hq;a86Ln\IŕXNewxoHMLZ5(}p敡tEji ):vpc`t6ef='Nw((Zấ:-..c#,sɵ  ٧-S˳m> JErlG'c!mXl, dj}Х44(}$˵Ox}v: *2Gl*?7739h ]\㚎eRrcqm&?!HDpUMͦvN1]V:,ѣ?:4XD\U(CndLNũxyθ3隊HQxjlcf{<ⷅ?peQPٸcJ 7DTn:3)ؕ |Y|NyXLƆf/qr\o}6[ó.j T*c AÁaRUJTr2SeΑʁwhTiiU(Fp: XLs[N|[LiՎ2L<.1FkNQ ,j3GaqXЦT<6"4Oth~A-C Cfe m3?@T*(U fq+4k\>s`v."YuUxIQb >\qFLiXT5t : èҶN;7Nū㣷g>9tE3Jlf^p5%JT#-QqR :%beiWH:'(00dc+)@|͸BGE.1;K̜n7.PU8;cJ~T<-ql̺en0g|2R/`"mϓ!fA#bm̡,ط:L3a~UKEC[}6.' ;u8Cφ`5caҋ3/IifY4FEzeh>17BFYٍRMFyNoOtټ;g9X\7a0*#Xw9P6~>V6*;|JԉMzLs@"GN G@ɕLʈ|jm,wtcū+<~ښJEIJJ|LøwVuΕ_qS6T>mw12Β;G*Umfnݽ:9:Į+J*c)̛$u6uD[s✼.AA,Em  lu}"Qg1ӢmAi~q0F6m3Je @\kf%* ir]ҳ+CaTpCo˦= kdh&8SG"ˀAgK,*hm%E6h8`hgYN,43NgϨޚ'bѕ%shJyO). t\@}Yʝ`v%hNXap*8t˛.WHDEƂ2*Z1ԦZ47zV~YDh 68t]Pc"؟ikF16"·R:k)eusxR_}él}_ǝ5s~XW;0t72?:w[MJҲ8D"q#E.*paꕖ|[uE+5AJvf2 [je)٤ #i[e@١lu Qex$,u㼝SxX ʛ#Em'98۝&5,dHqnϨuT8x.eE\gCVϊHiXAU6R}1`XL\m'eV4\*VTV5H|cC/\QEDqrV+dF5W0W"Du[70>7_G>4[h>\>?3xFovy)tX/sP6-iWh*"D4yO(T \<]Bӣ/!s6բvo8g˂)Z%yGU;Vos"rfX؝:(TZZ"rt7hw>e,3mFX3 t{4v0LMiNSu^ 69n<9Ί]6i_?>G7`>dw4ezU4CN.eǟ xh J6Y3m ^m*9QӋ5Hi΋롇Jʦg$YgL..llGT5 ʉd>mա3BUJ<|<[5>t 6Tvievj`/+Ѕ t:(tfLԜYT51A9^: yQ2.lMjeP V\Y'J=Ⱦe`l7q6~Əs#-M6>>_͊>}:x}+.˔ggҋCnn<߬e(*c( gNW%َGN"Ϣ6\kâ>Y-W᩼ftX›DU]b{}TKuC~gȱFH4\ϩC|};£*8ҋ3FUEۏn֖3FnyM92v]6x,@\IN8:Hi\jjlC-E ?hu)68Yn0}?A0vl 0qs+\j@јN@q8]Mp˫eqstA?(eX٦N|𸻐t:[(xt]L6kgzUFEҴ<ڵ{5<[n|R;h:,VvNTd]QD:dJӡILL۬MՉ?Fua}? +CGӱ̗Di[":2{RB0tYG)\*v;G{2Ptz6Y(-.lwcrC%e: "wJ0EƢ'󖲵s9Oq 58ER苙TH 3Q#uel4Qfh}8=9vW0p>CEڎυȜ7CsRke2]qy( x_& N]ndOCs7Iŋm)m#h1& CLmT/c\:oڛ7g*4_DZ#/S.f>ltOs AW:AOb}?ihnm1{ՠˌ%TpE.]rJGtwhGũj8畴aszQ4YiYfSeAåez!$ tNЁ4+@WnmUg^sQ+m&)O3}C:+Q Yv ŝ2PǛ gCM'RҋPŕ54C|t[h2H>TeŴ T3zbgFCq>>f:~,>.RtU(\s`.-LFol<]mq"-љ#[ʶSDe_9rĬp!WA~r#gE yM\^UPtOsyJͧ٥.TޏBn#%q*4bņRelEm+]Ƌ4| c:"' .sȃJm'[~U-Q7Gd^&S+:Bqmh6t2o+cx29ؙ# 6"۬2};/%FEmCE>X\Q8\>;o *p}C:;.Qkjii-T "J4|gh.=-c9>3m3? |5TET(k-ϢcP v`زMnUʲMEWA1\xHDk4X44~EJ̸u4áw7¢ݏ:#Y\`t\\E2.W:{y]˧,Z٤> )s4%kҞgvӨApCQTjBNj: 4Cdf t&if6P,SiHiX'QZGL;e[,dW-(j:ApOJpT\]M8?2."xh @VF֏냢2^ev&2-St)%QQQ&qg٥b'PvEV|*ٚ!|Ȕ:7И'ŏ*d*fſx):?ho#x_禎yNNWTE+OIOXB!NWfE/)ǮQjO,fgrâ+ jf;e2⯧Ҕ>\Nn]S~0W \ɒ蝑f 7[]':cGpX4Ѣ=qR HFE̩HJc\eb*qpecc˃z4EM)"|BNW1[*mYe;~.?ՠSuQ٧+zίZ~爧7#V}NB!pR(F`RxLbVJ b0|\]P|YyI j;DYh}M +G*vWQP>W\6BC}GR.  dX֢iΛC[tt(FbbcFSg](,\f+TR/N2C\J2o}äۡS;)A6Ɏyy&8*3S`C҃ WE ;.%rd6On}2VcN6w*tϱzë(l4kWMb$msSL*?.#vx.Dqd;O.,Rhi\U(˚ Sͬ:IEnzigf=tGL)]Z,mGI`ˎ<taфǰ̓vqNUe<=RhB|Fh-ix>p._\ CWdE\[qMm73CR&tcKN:&i7sTwѢ.wYu^M>teؕs膞.éYA||Y;*T\ ^#, ҍl[ʩӛwT `:%k/xslZV8<|[l?}ns&EMh2,3B- A,i yZ8}:K5h4&x :4K+Efˤ̝pFm̢GU>.υJ!g6w4qx E(x#Şt--aLi8l"1Q݇ RPujA|tNڋ4*"ɇDE5(;SALME#]5œ': +Q.Ǹʦ 2򔕴uջK]3egteN *g؍uGT{#+(V. Kw9:&?Ti{>2}mc-]hQ1[JkeIi\})thha' H]6V6x76wŝ.aꜚ :`؝c6#&f)܆'I\;0@.j L>,z0zna0e9GN*/EG̈́Ř<ˊiar(C<;P>%gR/ILPTY8sGJqҋoC(6ne,4iu:S`ز: UYyz꣝ 6<Sy atVE}hhX:g9Z>gS.4\-zduHbQ?L۫:tewuSgH&+  Qi'#3xi2[L<߷>PeL˃w"ЌEFZtE9Emk69TeZѨdt92'}9D.mbeӋ?}Mذ^v:\qt\tIˋ0T?%:,p7EByɬ*WW(W.D\utֻKNPNas>y{|>.ꊨ?KYQ;-,WZ GCr١Ez拶itڛSQeF\mfI?ySTC)ϋ*(~-cN.fV:a\b&;gj~:(OLeHD?eAi܏gGSp藳O+0 O(E46NQO 6w8LuΦh\Fj |8')\(OWǴ[3 O3KELӴ2Ec&㠄%.SG5FFˬSIv{b: %+ȧ~S[S#Lr!:8g|^LR =3.&j71q+ʷT䄼/kCաKh7Ru*AQ;f%q+;*3i6_ 8-Rfè2l\ؕU=fky6x|4\u.|qu6O|~CF;V&GLh,tOIM;&. 8>NJza*ꋇQS<ܕvzLO=kcs(]LH:wOäҰ˕3k6.lET:f\CZQoF\E|i mrxϼ-+o;E3*M8H߲6EB%<"fmLsifiQߴTx:UޅjHlM$Lt7įtKVL~P|Wz\s=Lwdڱ'u!j:z qs*]1\\wޙP=g;ɳf x)y\M"xM𨵧v+]]O,FM|Yԋ.hO f,ͥ;[iۏgl>.%~tHȸуANP{ t=x㩠;'Q';y]B ?4R:,Fqjfv>*zٚR"̸{@ȝ:LSCy\M66^2'bm tݲl ʟ7ZlhglC\'he>">),F, *tSyEz7ҵttapǴE7Qkҹӳŝhk)(>*:WvU,YqCZQV.n}Ys&^,Yh4.-lo6Th|jxyNs Jv ڮ.=G>?-heXȳσ "-CAJ-NX4iZ;}򩖸4Q-R2 ̣tqs+yN 3NWq "*SmxEԠNMs+Ɲ`?N 2"c˂ef3ACO>Y;̇Q|B1 ga<[m\6}C`vf6e˩ѥcy 鹓  J6₣|D7yEå񑦅ƹ dq./kRNݼ~\1s:.mBz%r5̃-+Nd&V "E֩wo0}_M̉*qrJ8&&eˈ.g4p,&ns-(lhs״N r/((> a1t6xm va+v>~CTzSp0zgDwÚvGS|.Tpih|LtN<"(h"8:, OuJF[%ZLͧL|N.Vښ>UgiN4\~F1̣z g.vo'?4J,Q 4.9pQ+w>oEz6)S;fڐ\+-OEu8M>\2"!#+DNgeE*M8:?U ʐ}z#?\p:ς#ڙ[DazVQBY )ؤt UoӋ>R<}+;;PZ7BxW;f Qa):-sN<RW^q~>9ڞ:;hhRm2=]TNqʛex:,Tg>A N3SA).#˴q\B SvFg\<iO\6KNlc3K X]6JڞҞ:d"v^G)0D e[>QQpn*hk0Ƴڛ.:񭺦rfz>蕙wKiᏜvi4_i1Gk˻lT]v4X:cEQ,Bg"O~y;ZZ,9HRZtCu TgrnR}-)ڭd'<[3eB?&":(w> ;sF4\:O/*,j@.W-&řYP>s4};4і\/;`&*Qf)!@@CŷU i@}hd2x}Ek'*t[m Lw;=t?.DE: '_B'Ő<)]7hcyÒhϝgr bϪI#؉L)q|m<<5+qAT}EFKR*ʦ_ف&A|X;Of:J;OlzWbk/BS;MkV\uE&ҵn7?AQPX"*j-rF4x:*꛴\aБA7cef?扰d,|3XvQhU+gjSšvU[iOqb'S*vHicE2 @CY\F 'E2lApxy:?CS+΋ӊ;Mͩ_\yT>iڥϏQ̜\Yҵgx)a\ETM-6R胦΂;CcbJhp[u3o 1'vJma4ʙ⩲eCCl::򱎡)<ͷːɶ-m;._XOm4O3c06NNdz^6Eb:|rUGhaҍ3Sytxk  2-m4>'Fcr Lt_.iyhJvN)ri|x\;E2m3B`;-|C\`>"hT5@nHAsC4T̳Œԃ<Ü劐rwh\p #6\wqUIU+>SQʈŚ ~_L,x|8|l>^Fh[&mkt}J}$gQU6tDrE]<6âxUԈKiâ-˖!@n]̃<\ꕱ,[ZSi]Wc"C5VY@>v08Jw(eyQ]IYq;&M&ukNO,.w)Ch i٧xj|l͋c(Ns)Qr's螸\X[+xBVsK1xN (\@C+ωggi6˒0͓MZɎE)D\+~e:43SMsai(.4Z 6 |E?wi}o·m!%(T|5yNi;igKT;J1>/8m,>.ĺ$|֬.iV8CI}s@L*VB>F8|k\f`zeQӈ00dcBWBp@ @ @ @ @ 800dc**@^y{3h]͗uҔ|P}}ŗ&AZގ]Q/6~Yh D8 [sK=+o<à",Ff9V`Y;* +;#uj)v<ʋ*G,An EO:hH5g:*zy fy]mpsڛZss]>W\/Esњxҹ.]}4]q7_˕E,|͢} Wxej)إ.0ڴ:)G|J;o1:b$dIkQqcf'KrԊXxM2-NOK{Gh*c +Ye4Xp7? :ҿB~jtFm`e]hI!&fи^:~<:6h2 sceJw1^'Elm1p6Vǚ*N *FqP؍9[l*M( 3x;#6 LzFh21rp|O`NɱuߏhZ~Ț|Y3E+x|ڌ!~w_6.eAF, G {H~<ޝ"@`ȳ!R:f=1n|]P=iY |aΜ\SӞ|Wzd VY4u 8L> lcjn0:Rlt+ z F\8[b wsGe[yzC]A-.`۷[Ubp6L ,1ELdJXp9WThڌ %hgڭ\cj."eχQm,чiZZ&|QRo3Jиk:+h~e4>=eyo3*G3'qQP=؍*]ޑY t8 H㨜msG0s.Aꕿ<#;wzf9w K) 'Q4X];CEAƆZEGA+4ME9K3iZmeGYc#E&1PʛƾY,n-јZ͢vEV͞`Du iv'J%mV{uѼE"N'ʬϸ $߁Z2! h.fY :-7r>\etPl+3Qn3.ãO܊+*QoL$iE;u*"ljjOQ␩6CN*U*2)t`thmJ,4o"C´HYeSildGpU\tbE8֍(\N`9Y> h'>ow2\ӱx}i]Q+15ψn`L|WRZfWL]xc>c̓*6DG맶ژal>mtg }6&|Wӡf1yQjoLF&1G`uП NP#"AFǕ& ,=qMeF@P:QqXAbviDLZqqk> p1~~-:rK1 6;sJ:bD:4:1vn*MzQ<5~:>r`;|ʕ.5E:t6!軐i,TDȌm:Qq\|v@TѳOXUtZҏY̓fJ@%5ϟ+:T3N-Qt:tU:w^N,t.2yR=6xCaRf+Sh" +D:*~, "."ށR"ϡэ;- ȳ"V>jϴ}mM:aKpAv+6Xt[tχEЦEډ̎2vuUO.}Y#[20dusF<[~u9 T:-lWT-J,ɕF!S?Ͽ$s6|DZlɪʛҋD ?=LuW)Fkn>?*Kڙ 0I\ML?Pٰ,L#(2S?;hw=;F46 y,e6gCYҌzmg>V ӋMQgt菙pC*+X8\\;8\\B4۞A.nڏE]6xeDY Metz >J%_*Sb̭95q]Rw˘Jf,;=#՗ޥKݧ+%gҰ71]Un/s+tըdfeT1ˆDwZFi2=%EM*r)t62Qa]f9.TW)ty0.*s靟YM\h";Ig }^xYtoq]i;fj e diŗ1\4EDYGEADN4So"+1ˈk>My;M}ElsC3Q&Z3PEPs|\`7$ ҋBsTt[q4eO͇J-)Mm=MF<'!(Vg=tqpȋ\yi\XY-,CZFn1tYfD1hDw}x64X1TCCX F\`ٕBjSThcf:KfHa2v]хJe;ˉyHtiFlm3f uj~QMBq:V&-4>mN9pdZŞMȨx@N5T<["6/J.72CIʄETU $ѬS:3DwH8jD\j41b-C3+(Eޝq63ұi+D\hxɢ>5̮lQ'nJ۫Vd:<8̤@.$":I"Sg_қœ!6%ffs5J**Y⡀6T*,hS6cd\5Viq4f2LφM~.QmaR τDQiY[Qzt';fAQP;G@p<1LECEb9 qéCi>%N:'FJ<"-`ˣ'Q+]!:- >"9*"hh|ʕ&mjv[NaS;PN,&a(,hDSP,geQx"iN6Qk}Q29mTϕ;*񬲦cZq<# >,!^TT7>NlOdF~>@eTyTehO!1pn,zގ2L."G]TШcuL!^zsLCf:8E:?bl0`kCqa>.#ʜ;9TyҬمG!$pϗ-)H*3grS'RTv2QͩO8@JBm ܄`DƆʁ8Mx(\>]E^ ݌.AuDXV+4mF~^w6ԠhAttE[*ӮQa"E\.<)4I-r30ơ6y:ȋ;=_tk vMz6>\{‰>/.-f|(o6ftomwOĬ*H-3l >*9Ph򹴆@A KNmM*V\Z6͔r+S'G>~~L+>]CUE64S;*hÉrntEԠ|ZP\\Z$7yϧٲʎEK鴡'R,/Q+`p Ŝ擨u4'\C\X:LtZѣb4MvMv1gE/<&jN]>\ozt7 v=G>,cc"4G ;EqDE7Fâ,2,haqr苑v6:fńo^ɻo?-e KypG6ώYڏy.)g)9ϟ.Ty#8s&Ě$DY+1y7s;me< 6ltqQxmOh*?S)ھ˛&.2iWrM`m\c,NU 4<(vKXxl"v&˄R2[Cf\CNx3hz,?zjR|PN7 cQRcw_Xh}9ʒe;aSK4;ju?D=eˣ9J-Na:EapI":QDR9\)e4ZҮcU:Q4Ez1y2ǦN-~KZǍ\ztv~%KzU6𚬿Nۭq_VTyݦzDX‡/Hq*Stk e6QR^|uxd}>!l.,MáQkƚJ>])9S\#3W+4FQ`J `*M4F lCLt.wouSS凞XlmOkt]r)-ݻIshh}ziE "-8""v |@[H!Ӫ*c@R"oKPeX:c!qkCfyxqRem(+'o m}Sj%ۧ*t/lc6j8 e1:->?W5ؕ7IK<Ծ^TCW>SjOŦ^y0z?-x\egcfaR6/(tJCK eUs5Eh0(n|ϋJhHY:>0y;MV=R2x!Qly{e/FT>E-*>zGNɀBSx4AP N,mHx'1 "'N۫ѝC#P2-V:foJ }'E<ϧ+}M3Ţ>~Fhcah.q>aju95}'U"/Q'cj TĚ٢]̝v 6缎TGLˏ^ea:2:,0Q6s|ЌEh,- DxtD@iXCsu(TJ:0:,Yi[)@DXo'.M¥g#*Ōu͇x~(u(ƴ4X>ls>f0mc\\xLڟ2_Tj͍/iiv<.G(藞(DEl̶_S¥f؝eѴYhc樰h9n"%e-.qz}cvfh >SÁز|vy2|v\ES>`>.m:EoFjz0tE|1lCGZ0:4qpxjMmD4=x* 8.H3ɬX\9ڙڣ*t_mDsiN T>:Nl[a^?'U1^E'fJ,v+f>u??jcl4g`v7".N zlfK(WCjN.MMG/ߝ :lX6f\N3C6>ZDY#gEgj;K.\vLJ6eʼnr92Zx鎍}v\z j3Í>PAau?21c bX>D5gWD Qkd6}Ş~>Ƽ\åht]fc}u+cl4ZxqD<66N=igqåzdd:=o(u-zReB%й]wiѾrx干 k%nHwӋTuP.J ȋ.MA>K´;ҝ͗OmgI^5u;Ezqag4\\6\qbnC."LlXIJTMhq0d1Ԧeb9˃*kʀ4DYa1>8|Ӳ5>\QdYHd[PCoxxlӳ>_pd (>.@+C\ڇ,t>Vލ3h!k4 dAlʢ*Z,n>;  l`>[)M2sPC>,&Gr$>TZ St?|o?s3uϞ).72 oAMlgM'ڐgJw1ek+>Y'8L6VV+ 1vW}QpӮ"(ƋҪc^8LQR*Yu\4[y[| 5;y;&CW6ػ1e[N)\m696sxe͏\ca <ERLӕͥvb.Mq>ˆ$}\ptZ")t\1ќ\g2 |hvS,ňB"υTvc`>vZˡ+} :;:'aS#+N,R.ADkLu4#NdeG\ 6vyylY2f7Wk 8M 5ɲ:]^RGM rFnڌ.tsTٶT ăT2򕞺F];͆&ঊ)Z.mIviƄrTEiޚ'eywZzW T$iU4VkM,xŖѧ:~TEӧf.8:&Svm.d6,G6yEͪyŮfAll.2ǣ;I>.;5ΏJݕNjoDXmEM̆7D.vJ4Wbԋļ9caEΧ;) >|So1r펔\'̨|9>24vz:rk,XRl(퐥.SbGxpBrmfQv2WTh|GU)٨*mFt* .D\:@M cO'Ki,}>(Vp5M lNNyh:ڔч^xERso58:Vjc-L"fl.Ū4Kg/s |s3*_^nˊhh@;Eenbt1(S sEkщ5,cOhu.йZn3"om.}ԂE͕MN|6o7U`SA'Q\R&m$qQvzmN}"Ƃ]8*Yメ!Sh:-|D0 b0YTiShSb m!3PPoiN7+}K4]W:,O3Q|ro3E|zQر!ϔx剓r:%cT:T:3*vM'?qŃW?4SN.D Ӣv5: e D:YiVcTsG§g;Kթ 2D[J!_՟#eolPl&\BCE±}L9k)Fau*niTz7åmx"D\TѶ5 Q\E\#AFϔl:~V-ˉ؆eywZ/ liN:vVK´Y:ri4\aS**mg\T6<\YjeN-С:"9P9q6'q.};Nu?T.#vf);4/#v_]?Ke{PT4x٥eGiL5Ee b7m:EPk6#k4X]6~Agx<,C5?ez +z(TT~v"|tt0G :Z£é|}CL|h5Zc+4~\(йXAGS!fKeJĺ*Wl`\43]q9"~-ΤUȹEŞ|qtY>#;'zAT'C4[eB&! Foh::#LTYpLj5 rU7yU=Qcg>\t;.)S*.s\xR*xyE1>puO˜5飰lezCt..xl+gceh'y]f1.9ѬfsZvvҹt#2訝= .uC+DBSBh}6%DEڇHcGR4)eKر-uS+SjЌqz.S>5u6~V@x.3E#Cp1;EU4ƤDY]--3Hݨ:h/RLӰèqb,Sjg>+)h Yi:s`]\X*qmQ n@'/Jvݼ9NoƋ:6j;@|XWt~.=3L"@:VyYTT9g1f͋]d2"f٧h'ǖh+Ya鼬ç43P*PT)Q93B]Rwyn|C?G3D|*2>mik  EON:6ee.Sp2"-F& 00dcu+*v^άmX}=sNR-%laf[?F?C :q8>CNg>o(}H'F,*?76 K7b/ν>6vn=p4hi:"SLS&e,-g8.'˴E~P<YtȔƉ[swyv9ӟV;3mLŞ#thq£C`Ҹ<>)ʉu4rTg M" Io r/¢,*]Mӕ#m-Po6iPq;>Ҹ Hѭ~Hv>OsxU^mƷ Aކ ws>ЁшCvZlh`gE 6ykJeA^mpf'i\|i2渰L -~Y-whvX綸I).y.Qyҵ.O3ӫ|~ߺq$님n'/ W f_6ju3|dVtg-9mj6}Ä͍́IFY6a26檙fJi:%eX >̝@c1^"CGw9FTg=dJDo,TV>=ͱ.' yyk+ .P;+j}φju`Egm\x 1(wFEo8jftE3 w2~+_h̆Yꈰdޚqu^fe|Vũd,ʛ7T, 2qd@rD_ñ@=xTUL3FŌmAJ\|h FipnьofpN1rcamvj<}HSqw2q/D#sD-U[#yfjn7¤DYn^V&´~|EN>Ŗ*|v*2guGX FiV¡nA0t0苙&8s^|~Q t[ sD4>XiLop9v\n&ʜ3Q A\|2gXH͟H,9lef||E0+u/K}BemPhP(m)v*4ESC>s苈^+"|Ӳ&lAGQrW>f:W# ŌN6,j54>T?2Vf-EvFL:~ZG vVV2yL[qvD}X(m Ddg}.߸Tʙ{qCc>g sk9^%xhw]]~4,$gY|iuuDk?gT62G+"Χ>:v|im]4VOFvpvCf<.R,||^]\n7˗ι;=ۛU#'e7̹7TSDf|xˈ+^/BvrJ,!t:sE} ұ:;D9v8 Byr~TcA>). `f4Z;(r-Pl+]U#|hx(A.^0 >4=FҵE^4\ʫwʡ^-ډʉG45{4RderuZZK&Cf%;ON¨2'geAeAb%-reS/Txgs&^S3ՠS0t<guPqf[ϋjmv5SM.!rJe,g}j N uJaar)Ӌ1iOL͗.vMVp &fYh~Nf=ZMecc.j@?Nc%G:vMLT[ .?7?βRD|~m>-4 ҰΪ|YQ.NYT\åjuC8" t7g@cՇx+r&s53!vsyOQ>Y+&E2/v?O-\{\F'D:L:CŰv >>:`=2 TT2ſ 6\ĥ,JӰPD\[a3nNJ}6X2fV0vZ<GGoԓeGa+:}/h P۲NlqlһN#,puǡG換Epu5AҹCE| Fڟ*]KVLj@:"9p<Y]9pB1B2P?+l^mQje%?Nk}8ѣRLF.h<`hquI{p]WCi9vmM רiҟ,xash`#8tiucw~ǟ6ҼXKNߝtdSCpˣAdOgӋå3 ‹\oҋ<};9Plf:DEeqxxNGJ,v2arh,DVD]J?ztCXSّ)QD85T"rN2;vSt z2&Ei=s<QɜXiPηCVZ\AO4DI}?cKv|aoG游/{kl ٹ+M ]m+ ,ZLp*rԌE|8DҔ.qcSa[)ލ-P}vr0vmCYe4Y0eFC"V3JЧ\ŵ!EFN)٣rNX\ZZvx+u@T&>;.. |X(NEѓEO'i.uMC6iE]o̘jV~fcOkj&lW0YQ9l"O [ 2}Y3.rj`U5b0Ojufϸ%/Whap<}6m] uygEE YMf?UjFy&t7y2E.pT..h lЙdŒ.#c̣.L`rJ`4oER].R0a3 h,qds]`6b-Z .Ӌmaci=r2.4XFO: ۞h6:SJYc`МLtOYP|oXi]D"^:;s%3mv_#,|Ǒ+%ikT/XW|^".:j!:mJS&Tyv埤Q?.\q:$4~QpNS8s,GѬwٮ.G .i~e|Sl[ 6h( TseZFsd NShg gclUV:紬5 )f>ӵx8.*\4EB"z2&cc@}>a 9qp9#D#)gʉX, w?v>M;:cgq苆Q`rqmY<唨C),ȋҞ'boE%q3@至@Au:Ņ\3F닛s!6`h{PEjApJ<QRv53GjNVhvm}'\u8XTa^&"2ҷ:.jRd\ԈcV"iqGCm͟&tLi\w7};N"[Nڛj@qŷ|TzNnh3sdE-\R FYiU3>T4vʑ?G&ҳgT8VQ؃L>Qcv2:;"èD] ;ʣK8h;EtXx ;f:]9\CCbw :ѷ7!\u#-~:,mOߎyM"zچJv$O<5LtR4RC)Qt\:'P=54{N.gSF q>3A4}/jrFs6G!,e10s+\F<2bW?ޙZ,¡E/hh<\ཎGhe.8-i:NQ"-L\h44jCS4Y6=?;>}Z4SΗ+|S|sv5ʤwx Z ŪфDȟR . ;LD.1rtx 2qgf5L(pu1_Cyuo L"6saFG icLhqcu eKWTE̯FtJl|",tEU=< ރ; af}7[WNꛟ81OU3LN!h8|s`us(COFV:wht\<ҋax&'l0}#\:VccG|;Mٌ"E1t4Ml;CtY?+ t~53`ūnA!e&?\l7n>~Liv*hWN|̷W ~0|(厓4tCBB'wҕPTDN-cL< ѡRiV:gvtw͌']?A}ҝF}hhNo¢5:ELr-˄NЁ4ås<5)f+E0ȶ!œN"^.ˈ> ʈOS{]efgy7yN^iו+Z.Ic%o3TEȹK⧎\+PSrLbu01/@ɚQiBl~*Ysvш'+Vf3C(df%QˆméOHv| fI[#~Ued iӏ*c"ћGhoeQZ #ЋJ*!f=ޚ!j%l:⍗OMi&(mo,'>\øttһ7>o(1|Nz>vU] {/|1=mSO)Y )t*_6GfpGKw(l2o+2lr x6:J+qq;Xgٙ:E; dIC6=ʁG}o^lϓ}(ii&\\qXJS͓cV\F\e?vpi3vvygVPklx'ux3*L&-\G,0t5d|赌"O@}Cr¥ME+NT`Ѯ?:.aϕjlкї̘|l:FĿӢ6fL{Q9zMXlއwY>+"~.|8أ b;qkCMMEydN[eP 6^*-dɳL:,4vM>sB%q4rѭuxd>CϦPs*Fh4\ݡqagҙzqV{}mrVyF;[.dX#\\oU2Z99X<\d&gtϧ+eH[>=g@.8[A1BȌrc+g*berǯʑq:ʘ󡺛QgEzVa2Qfm7d4TMr> 4N;>-&A@rs [0,st)|6J; qggYp1qxN]Eks"v.\WNhͱ<+Le)̉YmC$sÅh8ќuEȴS]ḎѠ]"OXZj>QYU!/U:w8Ws%>9Qtih6[Ӌ"G}Ma: yݨrնȹx˦80\gNf՟qlˑU6/vN<ɳMR&fT0 ojhFgO?:,/N˪ԁh>.xʦ9p(1>`h\f2\6 O8SSw%Qm`\AcA sӀ֞] T%j;}VTCY6Ҍ^#[*nu-fFsy5ڦfWzOmRojs~>gTEüVni]ϕ w:V\?J,eM<-FcQ])El.u;-pҵ>cb,QNDӸ"*6*eS`2g6T"Dh4#N0E)i }϶Otdt3GA>"0r4苋JhqPvTr~geh1eEòuˈqE2:XEYʧ̩W98x;2(j NA ˛*,.)>^6F\WHҲ*]XHfV dq. l:U0u *V#ADYL;" Yj5JQq~2W:\dQփaI;>9\LM)?JmEj}QN5[>O a?gNێ/jFhˢSas(LeC\52FQ;>reXSLN;?R".0v>Qb68à q +F'gJtLtq+.:oq\56%bVC.ql؋&$nQk?r,|h{ fj/DY(.SJaXzg>V,Xqs'Tcb¡d:V1E'yϛˢ9d!olQ'vʦjD\>S>.' lw[Jvw sh3`b, *G8ș:?^~D[;agTkD9ʙC&4vEˢVX|50U&"Χ\GR"S59fQ)s:=P)L|&Pц*wSt.V?Ռ;* (d3{¢h x|?ĬC6dC*Ńtva1s.(Љ\CAd?,l*D1}HqtGIYvP?=OYN=r{Ͳ+D|AN0thpb]Qqj|0s渻v Ӿ}=n;^W2]߾\\Fw'[Qv:6/@zQ]p 鍆5'bn7hu yҲOi賡qeZhJ> ۈxm&ètg/ts揀HQs (\u8řzIф.o32LU)G\7U4ܛ \EFt#etѨˈW}(@T˛JMkdTܞ3̜1Tpfj @|&̡V>M i:o]`SmW}2ꖉʃTg\d)R[KM-qR#5b1M?,!MdoWjb,MRq# zzo3bP:^Qc<+:2㣳OXSHl|Sn7W}Nl R(J5pئ s3 |sXuMގ> ӭ9ET\J ж SW;1ȈZeSP2. B!tc\Nge|̐&CٺG}OAâ-ө<7sݤ _CxFU .ޛT2:rOKJv4Uণ[|ѡ;. #ehϏZXW>QeCq)\cj%Z#t\LTh댫d]QAҍdGˎyS͡.*} \ãԙdֹͪ2͜~+eat̸uN1h4Jq6Ŋe'Ltt\TW~N1dYhoC3<jڙr vesl,$i)yAs8 ȝ(|Z2lOOtš\і0sBhhp|9oE,NZ|Ń>Z.Ű>:uâ"2OP蝌|$-8:W6TQKwi[vTt>\slx*seJ-Dx|lF~.D[eeYL|TI/xe}D5#lчR[Q6}HK]Sly#n}ECe.:*o3ek!ͨxA'M'cyxE!cPz3egW*L0iJ.eAED|l2P<_T6QdUbv_*Ybnꊥ*0JRQrFxe;t= \Yfx劑Qgt\2^Te FqڃzE{swtrQL&V Գfٰ ZPLq[xN-I>?+?6Tu86.RWTWdr4\2v2%q^QPUňKY:7+f-2yeLE<-'JlzN©FD\ZQŘ<*Qf.>gZPWK 0:m}ӨRv4Шڛjh*k=phcvn "T4Qm,~8h2"",j Eq#,/|XSS5 :jǗi:A-gٲM>L&>Ȗ}t mʒ-k0mÄEwWP%U?OpNݲ;;U?V#}&,>O) BUfT&JNjm-Pmy~\YR;˖OVf\^paBԩpT+u.>1˕ǓS.8Мcu,h7aQ;;v<MuD~⩅ E{AӸSj/iҵBLZNǼ:3ngE,ft*VP T^l=ScGU6|dIWԣMi!TyX\:Z-?SL"r2Y~.7(ff/hhmz+?G~S{S)ڛDm;5|'g lٞ^&c+h%7Zf2VN+hQ. 0s<f:0_u#s' ˬa'['*o W~ieRQ8؃nI.Advx'g8U]:mã(2?9dŎf`UQl+Hh_!gWQ:ƠsHkj|9o.2 +# cef>.L.v[mʨ,ɮ-a"hQ er1OhoF&fQCEZViX dtgv2!,O,gC%q>\;ϕ^ٜLt&C*hm>5ϣIwg.cb۵1k=6]jyeV`%3Se4Yyh.L˒#[w.m+8rMeňڐҳsckexi\jF'84JY-b?Gi*Z%C,3$*š2݉u؀̃L{qL6ڽ[!߁ 踿וdBzw731WTR&߭`o># |OTZt(}6|ϏdQo6w QC>9LGS<>'T\tp#9&nNQtYen)WԂjh2i:Z/S|*2Ue,.ǁI3_hߨpr1=giq=2Zq W'Mh#OEf.qv-"G&ߘB(/;F3wå3dE<]=:S~2'q3C꣙2g ?K>'m鴭ҋP(sDcΧ#M&V6}&o?v6sQ(46c;͍[=V,t1+Vm h6iŝJ>J|<[#uDO%d.@/h\_ ƙ/ޟmhoh\=YS7L+hl̬̩L\:0YD+'Aej}Y+}r1p@,U",>Ew\:T@e/ew#DߥPI*S >[%$gC1O͖}X '{yCEwo'd7. ;Y_>DDDsP87DEϩ*,!8jq+iXJɲnƹ ϸ ߩ9D>wy_5#w>C¥p>?8s94ZDGNjNss5EcJ=B}^G7NVZ 9e1ƋC6:"h0}ʧ۳zl"J0.mXe^:1qFh-IiE0Xh щڋt5Au02Ql.Hių.@4SiDYIը=zi*ﲢ5sVfʩW|ɉ.ii¤#>lyLXv8ˆ>Fl~Ak*h 6,tͻStz^>} /buyOLq]<6}nH1v:ǃ*qr6*RW}/o sjfT˧MqPf}9XABs66v1ԝ[ڒx#Y &tY|*;9FZ.hm6:\2Sg 6R"} Ҵ`ѹH QgtIfSu%;#8Yj5>{> Fx6Y8\E"LqQ bJ%K|Zf @I$S+}̜v>W0cVx4}QU=1x3է6(DNYx{9=z}FYJ,˚?4DsasN\pBעV?v|=xu\=nAiK8͠sҹ]Qʦ|PH| k|a>*Z4?0l;O<[C E*FA2FUg}ULCC[0iv;-J 24_EM̘vo?\%2ϣ \2^sGϣ*]-Σ v|p'J"۝ZQS6Z~AӖiSNWJvrZ㰪>6.-O\4]!q28u2Um"|\3N#1PRu68'<*>u6mh#qSŇxJi4z5@vTxvT)>S| f+eIEÕNlPHgҳ U钩&aJ]6p;-;fVwôT*o4XO 4.ʁ \\`S@>" &͡t:z-Qc=*SAtn}YwhE9:4\EoMm l+.>DY["Ǿꈸَ3J.M8} yET1Rmǰfx hvx hpʉ|V#hN.C ",L]>fgS)Y->Tp;ytJc@k N.Qp^h蹖A8w e1""۫|)"9a̅0t{ǎ*7GquY?CJ5ENxȕ".M;ڛx4N\h>GYftâ.Ox;FDXqt\x2'dwDQX} M8NfcxERͣMD=ϴʤT;+5JN?-GEueŻӄP$+:ԖrCuΔZꡳ:=R"DwmGeMb,;.?yK 苄跡(hEFbq܆znJf\FCŒtM6> ED86vv|*T4qp1Eg8 EjEƊ- 4|0}9r'r3;@ ;Yg̀k;,uQp諪qEW:q26fE6jFQdQHx4i3њ+OQh;q"(Ԕ!VZ:P蝶yO=BX.z Qif#[l3A.6~MoINg϶Ylm,l@J*0GT:MHi`fbPh3Q3Y44ʎ6^6ЅNT@S\f\ɕ8.-2M A1:M#1ˇهѢg*/ڞAr |<;Nèڊhp|Y.= M q s;򣩕.n#Y#>,=鶄*-_Cfaqw>'j&; tVkq=|M:R2}E9hgC"w:.ύ_C&c|Eú3>|d:{TݞnM{n|T~K:21]ύzø;7ˁs2Ebm|A@j8NV)JIQb?N,GE!Ĭz!Ц>oV{Mj&I+Lφ?F/PeoF :qd?4\}p^"4zqA,%tұ2C΍_i.lt [N[2pl3#nֆ]bcMY$*,7cˈ[Lemz< EƆTarݵ4Xl.\"qF~dU.~Z 7X+=8js0ẅ́ŽdNxVQr2hWJ"Yft4;ν!Gv:0yhi6g:|<v46 K#GJC'c/GFrR\ER8s.sHh-MF촭Lq|\ٗ6}o+Ƈx\]Yh6,c̈́j^5~5nRɵ:7@TH:BbO;",lis6Ph+X|e"ٚ9ܾe;tX Tt1teRsoU# "j8}Sd#9p뒧i2~se[uA46iw0E3CG E:.m++:b9̑:v./t??PM<\EƗ*,8 â,Ci pfm!x>@.oCEDE22;I:p><6ˈcΟwCDYg>oH#͞ 9R-B*d-;DdEQK6Xʢhl4Y~@C 3lЍ8:6f!˦ 1P՝qDld hvK#۩ N9G4]scd*мڴC4cr2靮ˎ>'+Sը?rL2/D|i]}S%p_SL8_#<"~c،gJ]>ykO\!}MW5(,"<!&qrf>Mf k/QeB|'Fi>,s!su'*.X>WŎ9QOCdCXUbT*A91q_6Z;8f:Fx|im?cO^ca^SP1j"MEp(NF(2R4qs>>teό"w#qP;©\Ln.;UCd*AvSUSqڜ|iEHxUF6ai,a*u]Env=Fk)RLuuENJMs:W4|N| Q>+c#-+mH'ce;hm \|ٞ.OϚ]gYkҦ:pN[ʨOE !M ʦ>sDT\TTqPʝ CNǾtYר">4y4XIxa:g)d@LN[A̋4VdL$j@DY8u(&+xFcn:TsiG8eQP:!9q:Hf9tVh>m6JmS/A*>SV:FsGv~>MШѢ*8t(v\{&%4A60U鎖2M #:7֍X^+NjVYeCF6"՚!ԄJ!9ڌ%rw~W7tOtdj3φ;'j2H~OU%4lleQ$~>if}tE2 z|YFL&0CȝEi6"\"bEL'etx\Y<0wr^F p|1hfM:.q 苘gce\EEj,TNc!ݎʼne]|,t6lr \T桵E>puVWi8|м9M-l̖ ;jZVo8uItiiތC8%4h@56`͸BŪ?):SgwE9QݳG@"W 쩵ō5SYTvæ.;4gLi?VFSvSvx6.%mAQr:Q'dV<(U3Nvq?A>U_CjwxS;QŴhVN> D,quZ]3p+> ̈Ϟ >QaTuDG2'ca s'+-m.PbdZlmFW'V􎺹ʎgt=m6ueQٰwx.Ejm4v&>AxT2Qj۵Y^ԅNl.*V_}\*LLZvAq Ş9.S.2Mqw+~=,l#}3Az#8Nk: gz1|3A-vU=̹ ;:4y fV{z.C#ƻyq2hPsc ljm usOm7a{G9 a\T>EsT&J. zQg\./a!t`$4AcR%Nd냣Ϝ\;[LlG^8kutoK D@8S/ewI ,i.|p!*U.gCe]aҹN*iښ>Z6uӝCE2{hs4ҋh :?34N:w*hpEiv𦈸ræ"T"fCgg.'|qʧŧi2p O8.e4Ex b4CYϕ*۸huJmj"7sjt<جL4.Vz)!t(2+М;Pz:Nsi[s"T~ޟrpG[A|1cw#ՅiF3\3gs1f4viM67U9p U2'M@ 5F\OųG}y[J.QRӅ\EU;0^t3t-TFepQ:x'9[CDoThy5\*AtAԸM;.d(LP",;2XTd\**:rw\7nDaT7{pgN'\|̺3J:n5Ϣ#egs|X>Ț(#qPCO.s8tRlJA=蹴BSmd4v~:Q>2S9~L蕻7>65(T6M?aڂdB iu1򆸷u$:gJxhS鎈c| +< =sfgFgD:=gSQ Xg4߸0^at6Vvm,f`PmS66'ig-;'ǩOOH]3F":,, :-7SC+4 j-2[.Tn'Ҷbtzݨm7oNԼo$xTo.AQs!82+4YCz}<.*"|X9t :'k7> 9.*ʐ랹ދ(:GSEwFm6Ȟqiv,ThΎh3gDZ4"Ǜmghɶ54pws.le3?qVo[+6;]:5lx h&UGKŝB|chxFm2o6Vx*xeRY:n"C'hy6\;ʼn{:))>:T\h)(Gd|\,M+*ŔA(e|-Q`}]㪈-~q`Lw{lcZv>ʇ©ٞJt[2,gDbwhT|g+tJdʕA̕Ubt?x8ɔ|ES t&OQO>m} c?C,苐j*%pEfJjl#˛\'˾C 6::dK[(}(ԇO5>zrԊ{Xhڍ 5ҴmiX:zhEЦ\7!Yꌾ,[;+Ύii&x:)ҝL\6h:h>NucvUpPh|%ˋw"CvsʞEMF쟨2 +U͚$w 56%Q#-Ҥ .zپxtOX!'mMYD苍 r/3OvT4ZCM*p.\|ʔY/2|2>F-~m#>664)R=YHF V]z.4JVwjQ1ӝfN]ҹkМ*\D,KśH\#TDHv]%eL[lci4+uEhQ2w"(A%69rYs*cqOP 3Z7âC̓|Y\="#lFeKIEq0gxf)Q<CR, ilDzԂ:?=1uEu9!ΈtNIhn",iE z4E.>mH}66,yҴXtpՓٴJͥٗ&->QC2Sgy\[̱RKL Ci\)GiꖕT6mşᎦV ҋu iQxq1aʋ]r3uKYۏ2cCR_âV4|4Zqdjl>vezY `GjMqp>>UFO eSftl@Ds(&fEơgʖ:w gTt(T6e:wqsABhQ%Ee~liN©蓏Qh|Һ6q칔oN.<MU1iM qNUtQqlf\f4IcVctk]΂ =TFp٩yiƤ6TF0xMuN-U :!j S@|.})PBfY{KM5FEC,oqy00dcT-,@z 1>w:ov&醵?Js9t[GsNO̾|v:+}FNهQьE fTp&fűrtga>"ᔣSsJ٠zΈۙƃQmaE;8Ct0u}qGzu8LTsh"c"\5v\b g7̳Ҵadiھfw%TG}׾tw|._5܁F< 'fh~8q0ڔ,i>Ǔθ|ߍM6ټ՛Qё7iJ"3+`6ty[LW5釽9Gl+X7uN,&1ktiX#Qz8p9PJ*<RormiL}T{T/>[(uxHiP=um?6hhyC爡aQbåkTeOYQ2B 3w豳L;4>t&Q'*-dߊW Aeb;-j͘ڌ*]XXTvmor||sj3Sa`r,8]F :-҉:2" 9 2f}+<#Ec2Fe=x6N qAh?&l+<( Q~-7xngE^9k6%nAy:UۃzԷ煎E۝ OTIPu2Bfߴo;J~6x2,t/xebxxw[Gg qf#˲ؕlD,Xjm㌖2@e O6wRһ /wxyٕ||[dp \9QlrYh-Ө2A+oWG&K`h՘A双e?0:5؆\;Q'>5M|6|.%`liW}OPy9F;>EgeOɊV5Ƨ|3ҲkUD:7qRј 8&}Zm^T0pZ? M>o#Z4!gKEHdY7l:ZSr2LrNL=P>)tT:MԸ^WaGn*"e=C"k-ﲼ?o-&3GՎ2;NV8!T0yXePtyy>+UEatB16VσÝjgs>gfaFj3pI?>^txu+;g3MZ,աG;4AV=hd 蜙GS8du1#zY/7CR4:^|jHʙ3ɑD[ʄy?rm.-s&mVǴ6vyV=X(;;}ujt*.n"#)q-.X4.W>+yE[ :aaQBhj0-[ӟ#7|YĊ|q%a3fˇ~DA3|6f ST\XL ;CFQDa'.VTJ|6D1'N,|[iyeKhvl9Y9ޚ)8i7} w.CK9_ yr-6_uDO)Q;3 Ҹ"M)/˴vҳEt8<5mOFԘ!WJ|Gŗ5, 3qx|[YZ݈)K)Y6 t"W1rǏ:,|'q|C`,[S0@&sDupo苳n;4ATFS6åI?Bb.3S%q.?9Z>p\TXJ/"jT>lBXD顳i[2Q֩Y˖~_ͧ{Xwx~I1V5ùp;N,}xt:+Q"\8F7Q"\4놃'G*QjliٶSG;JDXj4 U6"ʊOl*3wDZ/iTc#RZ.feC*'eY: l'+:>2GF\\ w1pE0?E.GC`!r.hb3~dhN /w~@|$eT뙓-)3ΖcJ,TjdVbO]Ɏ/*fcGJY4-rxrOx-[ef[N.|.m+ xeSfӋN$žvD`˱ߵ^õ#[sEѝq;*^*r,GP>RKN޴1UOg)MF aXLx x2G\^ykOeB{fYAS'LɔOZPf:Qg>]@[3P|.E>jx;}Gh.*4\f"Ty:2\ǀ(tI¢A4h

SSM~[pQBQעq8ZQJ Ri<|{m20˷aq+l|7,.;aresasgGOljm]4Ի櫟#4l^l.JЬҋ=\J͌6%atX2DJdU,"t".t`+^sT']47#Dݠ>s&-m?N.:K(aTs?Rpj47>" EʣqmC˄<\DTUIE)i]CȐȳxP?EQ\#\NJJVrK5sR#\N 8CRj":[Vl(Ngi踃Ojl.=Ď0צ> <s zsR.-GZgQ9\lNʧ-U>#YIՈYd74>uKo*LtKOmT \T\*5k2c݌۳L˓لeˠ "vPˡ95>t4S*!;o6ʕ;rQ:Lؼ>,;Q^Eϵ vˆmOqs G-Ȕ; ژ9EF""7e*Qr鴰Eڟ6Fw>btcir`+f9f)1Tc+DY =RdrwjRhZ+TA9Ux/s NE uo.Ӻ Z4j5J9,*E}<4f'A6n?_7XtJcp n*QY84+ElnrH_Xqxge4*躹6jgEJJNW:4y0p"Ǝ";J.eA5(*qRDGN5q: KD>P"ѮJ0l4EƋ(><0;EEWn66`#dk;.vo_\<."ϦL-6&o+zn&1΋鎕Om7>Os%\iCft\:Р.w?6SY⩩|}>'Dα3떝P6.:"㏧_}>lEm0SSwQ:p\MY>'2OeSx]j/йZzc܏|E6ϕ"},vmGYF%E\J2\ ŮxЩCV6F]n<{hVX qGm3QȳϐYyG:-+9T6_\\CDXzFJV")ӝ=+:Lp.( 9LUk?6w"1J򋌪rNqd5(#tXPjR".82̗dʩ 7pP4Ν$w˔%ENCEnyoOl[jv#GAҰe2659sQOTYʎ(dKYύ!FjDY F4x"ҟ07<ȺCo6h#.m+!QЪ[=KqC̔o{(9gê@iŜTjuweI Kc<9އPˇڎn",´jZǸ3nOu^>SmmiN[ 2gF>1q>ɇ4525\Z4^ A\Pl?L輧IQl.N^WyDTuE/ |2GN~eJڳȸQyg\<< Qw(?l."C C.3t˧|2" q| 6;X\ro)X!DctVu emٍ2ʃ2rː2MZWkgeVwK՞[ڣ?*:8-9V;*;L;iˬGXr?0|2"FkfYmLJ.c^̺ߏ~˝+$ Ϥ.Z0eQ,? 6\N>+3ZFhJ2b46?fʩ6*d;^euVcGӪ<\N82ӱ:Ԩaʇl-:-eO[\S&7h6,art\:0O\PhDc4J" l'Yxhh#[-Fҋ8BMLLjzu:|JDʘfutC,uj(\kuE/)2pq̭F6& SXSVI|7c7xhGkN|xfdZ BdA:Zv9zgwy'ɐYrњzq:.Q-P%ޒMMص5:~S©OMψⓞA>'phn$%(kR{i4iU>,*?Ӫ'l6>Qaiy|6zt0ȸj+8g# s E qgZQql=Z-u?:N,:efk:h>G sB\2;:,P8TAi qEtNvK4*x'8t1GUv$^(NEmzx:,*>چD]iKX:h2-C8}3Ǒ%c.fo[mFoW>)Set:<-)\uR#\c:Q4;J-t6o-;<} 9echCQr.uJ,jS*-;H賛F\Nh4n=zS*q`艧U1P.ʋ43md:.Ap4op|X2.ʃ fTw貅!T؟5-Yٿv\,"j9ʇhiYmMG:ywyع>rJWzwޮ!#Ю2ZC"lwҬ+|.GTmmCCe4VQ`c'S2缮m6l6*r>{Gl`4S]7Po2DuSId:* Uz(Sfasx**>fŘL&ϗ},3L|;=)H> ਋iv#41rgڌ}1pg\!VhvN:cs%^Ǫ%cxll>Ex3Sahn:Tc}:?6M.wn6T#<2g&"rꓘ8DJM3 )+\{ +s`HDS-|Zno.??h.%dcQέ|j:J_f:qqSZS8Lԧ}t]@\)2\cuEŊc(UM# 7]\cav))gGǐjo)m h"WtƋ.J2SlYˏ2MQuD.+zCa*EE4q!2g]?X0w j?$px?AXbxy4 ұ [7mL:$<53#fq]*W͕=nA>l Ά4SQxq=S z6ڋ\r +|N^l;^ZBV2V|V'@uVO۫ʆ1٥F>h?.y=M%2ڇ扴4EƢ<6U+=r5:,)^_EgFWPhDK|:zv}*6(=Ah.$uJMQaَ<}ui\~:MQbR1j>hc:th&UQwGT%vm(Fp|=Pn\\j6ҝ/o34EƧEJcž'JD82VUt05>E~Pp!Fe&4}53P5ax8X}FEíp2"c¿>Et".;PaOSGB?˜YGP#eڇLwЉ>JpڇF"]/DgOKYN>LO 4~ӋdEiEGac\0SM\4Nt<.SNrCDXS~ڭճs鴻g Mٕ͛sW)vi"}QvtF #['ldڌ6qayXL vci[ ,yNoFg*R'p:-  OL|2ءW֔bY(uEm28|DY./5>uX[珃}l8q!. SÅS]H㩳2xuxf+<d:(^ >>Xh4uqjSz uRFTLtA C6ҘCNVZ2%;1$zGj."ۏ6+CrdCcfOqG1  h۴m(I#g*>ݟLy'j툕:Dї:ϵ/VbW/ .44H<* Ήѣ) ,9X/Ltߛ'T*5; f%f)6yZ`Grbyi+FSޭDu(Z}VPLp}!HB&S~WJ,Ө|)B6,F.[hL|E6>^%Gcfyhoˈݜ:劏<ˤݼo@]̳3;FoOy wI)`ȆAO|tq6Tz(/7/*8t݀~C̙|u!aTI:%LXُ6PE2l*y2,BE3~ZlslҠt{LhM2ŏ1X0lZ|:m.^(ɮZ=^Gա M̟sNۀm h\gw[ap2|Rr| R<|Y 1(T,vi+eQ~.RˌâôO1xB| xJ,6jPoJѴ*v>(F2,ҍL09iixlio<2͗ r=FўvLOθ>i_B}Ru9FT[ۙ*CvNL:*=π]cm._2m$>xO>lO)؇C+L|EtEHl><6e.}Q>iQVsti>| Fh;>ca;E͖epxeNM2"dv=ESeSc5|Ox5)-)\ |+Dlk'P}1#JH ɍu qjh>,p;Rriu>ǵl3X:=!ӕ 6*1:Xu*McK>00dcBWBp@ @ @ @ @ 800dcc*@̷N:e'ϥBw%2AJ+Sߍ0~\o>Nl γpD[ls]ViJ,L7UxT[fb- [fV˃g:SaE2Gчd~SE4N)Xj8;.e:qm. \YZ3~>g2 B-rz׫;K_m(!/ux:A~kM|ɢSo4Xt p|FG ,lӋ4:~ nF{nse?.O8=,sws%kjlv\tY%; =>~,LtAZ."i3g2?َΗ z@؏d> *G[sas[juPXT4`KC4E Į,;͹qp*\˃ߝjogJ1>"zNVDG mMsYjmqa9tFvUYe \: RM  Se0"鞞i\<֕lGWc Ň  0lcwsDg221s#0]9%XNVfxn>^g[7dlhr4ye9۸uIЪ+kmTMogSAIWELj7SJh;* Ll @3,ET>eų%Cvt~%:al[.ʘQ<ؓGgetgK[g8 ϕ>4Z E5>1Qqp ڰF}4~uZ|qҙ3<~hEbbVPlDxLu6mz@8鎔\m8uiԈ 9q L\jAEW8P49X/3ZjzT~*TD+:d;;A9γַcnwx .J1Sm0谂;50Dr4 j49;ҼYcuAv˛J՗Em\&21M0t5 "ͰsԱ}6:Zp|7'Sf\cZ,J<|[Ý+b>ѣk.fNc1l|. C}"v~vh3Dϗ.ҲDObw8 OX|óygI5ψviUxtYQKG!U@H:-!",AG<f:K[uJʧai<+6z0|yYG"@. FX猁Fލ~+JjLf/Vh*lEAa¢W:CVSJ4:plJeh:/3WV26Aa*b. M&D^*;1oWc`5N\cw"OY 6C`7;Gic6t|QdlJj6xT\a:9ѣnd2E]>T 6MҲw#iNEΦd9 qn뗙25LO2&;Vv>io?cbF ,k}#b/3ڮ*fC[dm+h4#edcSiie6jmAeϕ.d8j|,8FUáNsxȟ\\1je,hV16hjMY~Z5>!lyǜYOC b3BrC,ϕ joǝAh"5ƆN-u97+\6٬Cġ9YQZ:zg̕FZn}_ 苎bᱱ5qyfMT4XʏYlFhk`*`>Tn:=CjFZ sp!ڌƒ.AMȕAOMtN@j.ez|)N,ڔhe:?hp2ӳG 2,6cC3 .v5q\6?RT✢"+,"xv#3˴2'ϫD)PdZ|m6~{WwfTֿj' nSNRB[鵳CeINk-\?SD\ʆv@E]5iJӼQcΌm??թ$L^{\~[7_Xhgq.\iؤ3;V~TJNJE0]a(u2V0mt<[I{çEjAcyݛ Jes*]EKDY2<Ǒ(~tL2m:g[gh0v7|64\)qsoH/MDD]HiX9ᙥnf> ڂ#4hY̌9Ԉz,ޙ sM(~i\gTmdL2gYWB o"fڱL9{8 ̺:v #Jb|&.4ENt\ -I6ڎ2+CSx2.eFiŜmLu6n+=@5A?B,'"tq`..udk6jhTο&VԕVݢXTh㔩9sqR\)z8;JfdyMqeUmC uDZQb$ӲЏB`!FN>Sj2iT7ml$tEJh#O+/E 7ڎd,Cc#8:} rfS "˂"V eTqf-#4|\LeS." J}* qSU"V]AUDYThtZeańuiYb̈qڜ+?/AGSJ 8 m).MVv%Ӵ4qbN>v!h3h:V\Hf '*s~]v6GExP>"@8>9p D4hD\ʣJ-u1<44{G*b⩨ eӃ9XԊd\WǏSh@ʋL|hmx􀨋c#+6i؎T+ĮvʞW]>vg>a c t[QBg#RP<ۥM ..+28e>]&o;2sCv2 dpqU2EStE;؋\`a5)Sq1sA|\0jF"7HFQiLԛEGh>G?y'#hXLkYӥ9$TJBϘR?:4tQr \EG-tro!愢$,hKGK..8u vѡ0n"9qOMѬmDvʸy5T>[">N}CEŭ,Ll$L=1 b^5XU# *%b8-訕Je:(sɬ0 iZ6~5yD6o'3lu"YOʧzDh<:L^hhQ+y~Cw(6GO4"vZ#4T6"\ƙrqoGҰzq?1IM|O;\j21"üc|y5e<M<>"j$DaN]44N Сq+cMɥ8DD,T?\HhEEYQYux: u^fN9q;R_$Weu֨!G Gez~4Az:-͙Lu8njҴ6p>mpmFJφ>s=0̥(;-~l!Pth};" ϔYj=6 L.,.rN^!36 '@*SaXڛ8E|{ yŎSrtY 7JDR6 m㢩eOuG3ӷJiuFStL;10 euy+ĵ̪+*Q3yQ;2:T4JGDئqqOA.,MN|8NGU#xnɃD-!щݏO4ųb,=x"PAb`ʦX1DYQe,574Elof6!+TS82豠0^s%4stGTʭƑ@SBAqvr XA-CfSv l.%R-plsS g:\6,/hmiItz?vJmE9['V @6Gr$s"'rv4 7U(ˣ.%qh:,L(# 5roé§qi:cٽ:,u!Ρqs'谨DX<МX>SrhC0Q|EWb.ezsуqO˨3w.n)r.3jeEG蕅..>=p&5i|6\6}&̨  @C03n 4~}V7m.xRo+ˤ[,/Sx!q F ;\6kgivˢ>" \:eI<xF<ݴeE򰩴8 jeDvhs-x& 2?cw7l,F,,t6 06,3S+PO<:-~y| ./Hd|E˖-3bƦj${Lo2GUP1DӶAkQqOړu$l\egCI_уl!pBjZ^xe<וkR ^0kz*;y\Nfz?z7jt>1X#cum.ǁs)@_n:m)QlkD!^ )DV Fq\ԣi*v?LHGVCE2p/EsiatTe rX4>,y2n|*Ye)dtD",jn"w0}3n |)5/Ll<i>,鏂BΟm va \ޖO0o٬* yZ LNw_UNM'h;1{0.Z-SV!-ϑUbUGtŝ4c+עvWc=t(\ff6ԏ; ·σe#+Ljf>y,u=4Y:.ALt?ZM2o;͔ Jϡm#5D6՚)F,r8財g^7%ŷX.-) f cJrˆ\6nPNx!#F*2Mv6mJvuҎӲ*,ҷE2G\Xu 4XNS[G*şSqlކSJ-t&D;j xGR,gȁ/":֕/9\9f}C *2s}g+y#<[Tʆh|^;EFTiяf|EqG6|mmZgJʎhL(ˇZvqSJvϕ 28]z.z"<2gjit†?ZxiXiyn"bc+F)Q}'*t-BjoFU$yN۝0o4EmQSzQEFqLh}9>'g \<",jaH+9euEd>} 4ŕH @E0'+L\~๞XθLت"G6,hLMD57P蕖_Eͧ;uQJ-NyJrhʎkCў DYe)x鱴Q>D.DhC TE `FT7lB4a.u lNU4>}2# ʼn˝9`EjtEΨ|K>rHӲғeqs oXt]I>%cwsGhlQ*]OPҸ}5 `qw5WSAȈ.+uO\nD\aG~˪-Y:dQq.$7NuM42yi:2hkm7Ψ4عN}0苑:Vlꡓxo/t,t4Z&DPhʎ<<8 u4v2Ӌ\6dMaQ+c?NufsN;c`"=6V.) u*x\.(\ iOM3۟Tv>\:qN:\.gEΪ3SӨ\IW)QSĮAr,v} | ȰS.V(̉S"-PE͗QiNL>juKdG6%\KOeI3`9;&U <:e.SθB4\Vm,tͲB4Ǵk#jMt5ڭ;i iNݩ8Hci\E}$ނ^نz42W!C'3,ԉ$Nrd6z<,s4W:Λ)Usv\>e8TbG:(8u .s(lPg)POχTmsSNLyf6pu:9k:hTY;QYC$mU\7:L> drs\m·dhDqZŷTkoi5oXM!sFj.FePiYRS6ڌi릊pgn|7Zش/>uOun2v) tϹumCœwQӢϘr$Ȳm(JP؋ *Q`οKvsT]Ŗ>쪚Vzˑ.FTeEͫWݫ1񣽮Q=S\5ٻ}Og)P~+l|9t\:QhT_J/ʂ6Ӻymi'Ś" YRc'+f:#Ƴ'']돀 aQ`LiPxڔ;ŹӱJ\TYx'g}> hѸ|.¥;Fݜ[>?Tmvey0hD7i<;48 Km(҆Eɘ>yWO$,*'C/xo鱢eL'yH̃KmJ,hxeGE}EJ,:\a-Rll6hO4M)ctseS[͖UiڂEAqQZDqQT'EιMT۰e徕ٍ2%mЧZTsR2GYQ6"L2ڹ[ҙMIs4Zx \;4jS@ emFhkHEe懌\M_h;/Q%No'DYf3hY@ 7DX]?TE?ʓCDaӣtʙTYN\QPG0ӖM͔>Jh͢,3AVƮ^6͗N&Sp"qTl ςMTmF* dw:;4;A2Mj#.;OlyY~˕U4~\5-RD:&Nj^އ+XJAJxVj."iv-n⡧q4YKo3.Zw70TZ.xVyL>, 3zS(g)J.2=0^ӝ+Ţ=ԏD\eGC40s'xhCJ,C3c\.qcEQ[Bc.%h"~|?Mz.qQ:WDeQz%S6G[3Z)Fa6XD+>SEB|S.|TzسWL}\pc:_ggY:?|,. `2Xa'\"~:敎>}WZٍ\Ɵ=#;+y:͛]R+y`xjjIrzlZTcGKy>H6W6Ž ܎T!f"w`l|âiv+&FE4t4T-L: [it9Q;PŴ| :lEm)?ykD򭪕uQ ]:-zx!Zvh n ktdJ-L/TmM^٩*(ʉjeJ!q?2j'/x] lc9tE)RjCiňCu6T:3gq"ˇԣK} idx1p01wb00dc0,01wb;00dcK+00dcdwB00dcw*00dcv*00dc.B00dcx(00dc'00dc2B00dc|&00dcXD'00dclB00dc\l(01wb00dc$)00dcB00dc*00dc S+00dch$B00dc$+00dcP,00dc}B00dc}*00dcB00dc z,00dc,00dcB01wb00dc&00dcV8v'00dc_B00dc`&00dc'00dcB00dc=)00dcB)00dcB00dc8,00dc.Z-00dcF\B00dc\],00dc)00dcвB00dc+00dc+00dc B00dc *00dc5u+00dc@aB00dca6-00dcȎT-00dc$B00dcnc*deejayd-0.10.0/TODO0000644000175000017500000000223011351210474012064 0ustar royroy* ReplayGain support on videos if possible. * Work on webui from use cases (albumplayback, etc). * Signals to the webui exploiting the fact that XmlHttpRequest does not have a timeout. * Devscripts dir * Build testing library once and for all tests for speedups. * Interlude functionality : save state, play a media, retrieve state. This is different from the Queue because it interupts the currently played media and restores playback after. * Directory and subdirs mode, for people who like browsing their filesystem. * Replace the proprietary protocol with the DBus protocol. The fact is that we probably do not want to make the the use of the DBus daemon mandatory, as it does not listen to the network by default, and configuring it to do so would probably be a security risk for the desktop user. Therefore, what is needed is either an access to libdbus low level message marshalling functions, or to rely on a pure python DBus implementation. Such implementation exists in Ruby: http://trac.luon.net/ruby-dbus/browser/trunk/lib/dbus/bus.rb * Do not segfault when there is no xserver and video mode is activated. * Build a new webui with GWT deejayd-0.10.0/pytyxi/0000755000175000017500000000000011354730161012750 5ustar royroydeejayd-0.10.0/pytyxi/_xinelib.py0000644000175000017500000002652511354570476015140 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import sys, ctypes try: _xinelib = ctypes.cdll.LoadLibrary('libxine.so.1') except (ImportError, OSError), e: raise ImportError, e # void xine_get_version (int *major, int *minor, int *sub) _xinelib.xine_get_version.argstype = (ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int),) # int xine_check_version (int major, int minor, int sub) _xinelib.xine_check_version.argtypes = (ctypes.c_int, ctypes.c_int, ctypes.c_int) _xinelib.xine_check_version.restype = ctypes.c_int # char *xine_get_file_extensions (xine_t *self) _xinelib.xine_get_file_extensions.argtypes = (ctypes.c_void_p, ) _xinelib.xine_get_file_extensions.restype = ctypes.c_char_p # char *const *xine_list_input_plugins(xine_t *self) _xinelib.xine_list_input_plugins.argtypes = (ctypes.c_void_p, ) _xinelib.xine_list_input_plugins.restype = ctypes.POINTER(ctypes.c_char_p) # xine_t *xine_new (void) _xinelib.xine_new.restype = ctypes.c_void_p # void xine_config_load (xine_t *self, const char *cfg_filename) _xinelib.xine_config_load.argstype = (ctypes.c_void_p, ctypes.c_char_p) # const char *xine_get_homedir(void) _xinelib.xine_get_homedir.restype = ctypes.c_char_p # void xine_init (xine_t *self) _xinelib.xine_init.argstype = (ctypes.c_void_p, ) # void xine_exit (xine_t *self) _xinelib.xine_exit.argstype = (ctypes.c_void_p, ) # void xine_engine_set_param(xine_t *self, int param, int value) _xinelib.xine_engine_set_param.argstype = (ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ) # xine_audio_port_t *xine_open_audio_driver (xine_t *self, const char *id, # void *data) _xinelib.xine_open_audio_driver.argstype = (ctypes.c_void_p, ctypes.c_char_p, ctypes.c_void_p, ) _xinelib.xine_open_audio_driver.restype = ctypes.c_void_p # xine_video_port_t *xine_open_video_driver (xine_t *self, const char *id, # int visual, void *data) _xinelib.xine_open_video_driver.argstype = (ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int, ctypes.c_void_p, ) _xinelib.xine_open_video_driver.restype = ctypes.c_void_p # void xine_close_audio_driver (xine_t *self, xine_audio_port_t *driver) _xinelib.xine_close_audio_driver.argstype = (ctypes.c_void_p, ctypes.c_void_p, ) # void xine_close_video_driver (xine_t *self, xine_video_port_t *driver) _xinelib.xine_close_video_driver.argstype = (ctypes.c_void_p, ctypes.c_void_p, ) # int xine_port_send_gui_data (xine_video_port_t *vo, # int type, void *data) _xinelib.xine_port_send_gui_data.argstype = (ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p, ) _xinelib.xine_port_send_gui_data.restype = ctypes.c_int # xine_stream_t *xine_stream_new (xine_t *self, # xine_audio_port_t *ao, xine_video_port_t *vo) _xinelib.xine_stream_new.argstype = (ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p,) _xinelib.xine_stream_new.restype = ctypes.c_void_p # int xine_open (xine_stream_t *stream, const char *mrl) _xinelib.xine_open.argstype = (ctypes.c_void_p, ctypes.c_char_p, ) _xinelib.xine_open.restype = ctypes.c_int # int xine_play (xine_stream_t *stream, int start_pos, int start_time) _xinelib.xine_play.argstype = (ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ) _xinelib.xine_play.restype = ctypes.c_int # void xine_stop (xine_stream_t *stream) _xinelib.xine_stop.argstype = (ctypes.c_void_p, ) _xinelib.xine_usec_sleep.argtypes = (ctypes.c_int, ) # void xine_close (xine_stream_t *stream) _xinelib.xine_close.argstype = (ctypes.c_void_p, ) # void xine_dispose (xine_stream_t *stream) _xinelib.xine_dispose.argstype = (ctypes.c_void_p, ) class x11_visual_t(ctypes.Structure): _fields_ = ( ('display', ctypes.c_void_p), ('screen', ctypes.c_int), ('d', ctypes.c_ulong), # Drawable ('user_data', ctypes.c_void_p), ('dest_size_cb', ctypes.c_void_p), ('frame_output_cb', ctypes.c_void_p), ('lock_display', ctypes.c_void_p), ('unlock_display', ctypes.c_void_p), ) # dest size callback xine_dest_size_cb = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_double, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_double)) # frame output callback xine_frame_output_cb = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_double, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_double), ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int)) class xine_event_t(ctypes.Structure): _fields_ = ( ('type', ctypes.c_int), ('stream', ctypes.c_void_p), ('data', ctypes.c_void_p), ('data_length', ctypes.c_int), ) class xine_ui_message_data_t(ctypes.Structure): _fields_ = ( ('compatibility_num_buttons', ctypes.c_int), ('compatibility_str_len', ctypes.c_int), ('compatibility_str', 256 * ctypes.c_char), ('type', ctypes.c_int), ('explanation', ctypes.c_int), ('num_parameters', ctypes.c_int), ('parameters', ctypes.c_int), ('messages', ctypes.c_char), ) # event listener callback type xine_event_listener_cb_t = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(xine_event_t)) # void xine_event_create_listener_thread(xine_event_queue_t *queue, # xine_event_listener_cb_t callback, # void *user_data) _xinelib.xine_event_create_listener_thread.argtypes = (ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p) # xine_event_queue_t *xine_event_new_queue(xine_stream_t *stream) _xinelib.xine_event_new_queue.argtypes = (ctypes.c_void_p, ) _xinelib.xine_event_new_queue.restype = ctypes.c_void_p # void xine_event_dispose_queue(xine_event_queue_t *queue) _xinelib.xine_event_dispose_queue.argtypes = (ctypes.c_void_p, ) # void xine_set_param (xine_stream_t *stream, int param, int value) _xinelib.xine_set_param.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_int) # int xine_get_param (xine_stream_t *stream, int param) _xinelib.xine_get_param.argtypes = (ctypes.c_void_p, ctypes.c_int) _xinelib.xine_get_param.restype = ctypes.c_int # char *xine_get_meta_info(xine_stream_t *stream, int info) _xinelib.xine_get_meta_info.argtypes = (ctypes.c_void_p, ctypes.c_int) _xinelib.xine_get_meta_info.restype = ctypes.c_char_p # int xine_get_stream_info(xine_stream_t *stream, int info) _xinelib.xine_get_stream_info.argtypes = (ctypes.c_void_p, ctypes.c_int) _xinelib.xine_get_stream_info.restype = ctypes.c_int # int xine_get_status (xine_stream_t *stream) _xinelib.xine_get_status.argtypes = (ctypes.c_void_p, ) _xinelib.xine_get_status.restype = ctypes.c_int # int xine_get_pos_length (xine_stream_t *stream, int *pos_stream, # int *pos_time, int *length_time) _xinelib.xine_get_pos_length.argtypes = (ctypes.c_void_p, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int)) # int xine_get_status (xine_stream_t *stream) _xinelib.xine_get_status.argtypes = (ctypes.c_void_p, ) _xinelib.xine_get_status.restype = ctypes.c_int # int xine_get_audio_lang(xine_stream_t *stream, int channel, char *lang) _xinelib.xine_get_audio_lang.restype = ctypes.c_int _xinelib.xine_get_audio_lang.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_char_p) # int xine_get_spu_lang(xine_stream_t *stream, int channel, char *lang) _xinelib.xine_get_spu_lang.restype = ctypes.c_int _xinelib.xine_get_spu_lang.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_char_p) # xine_osd_t *xine_osd_new(xine_stream_t *self, int x, int y, # int width, int height) _xinelib.xine_osd_new.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int) _xinelib.xine_osd_new.restype = ctypes.c_void_p # void xine_osd_free(xine_osd_t *self) _xinelib.xine_osd_free.argtypes = (ctypes.c_void_p, ) # uint32_t xine_osd_get_capabilities(xine_osd_t *self) _xinelib.xine_osd_get_capabilities.restype = ctypes.c_int _xinelib.xine_osd_get_capabilities.argtypes = (ctypes.c_void_p, ) # void xine_osd_set_text_palette(xine_osd_t *self,int palette_number, # int color_base ) _xinelib.xine_osd_set_text_palette.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_int) # int xine_osd_set_font(xine_osd_t *self, const char *fontname, int size) _xinelib.xine_osd_set_font.restype = ctypes.c_int _xinelib.xine_osd_set_font.argtypes = (ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int) # void xine_osd_set_position(xine_osd_t *self, int x, int y) _xinelib.xine_osd_set_position.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_int) # void xine_osd_draw_text(xine_osd_t *self, int x1, int y1, char *text, # int color_base) _xinelib.xine_osd_draw_text.argtypes = (ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_char_p, ctypes.c_int) # void xine_osd_show(xine_osd_t *self, int64_t vpts) _xinelib.xine_osd_show.argtypes = (ctypes.c_void_p, ctypes.c_int) # void xine_osd_show_unscaled (xine_osd_t *self, int64_t vpts) _xinelib.xine_osd_show_unscaled.argtypes = (ctypes.c_void_p, ctypes.c_int) # void xine_osd_hide(xine_osd_t *self, int64_t vpts) _xinelib.xine_osd_hide.argtypes = (ctypes.c_void_p, ctypes.c_int) # void xine_osd_clear(xine_osd_t *self) _xinelib.xine_osd_clear.argtypes = (ctypes.c_void_p, ) # copy functions from the library module = sys.modules[__name__] for name in dir(_xinelib): if name.startswith('xine_'): setattr(module, name, getattr(_xinelib, name)) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/pytyxi/xine.py0000644000175000017500000006053511354570476014311 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # This work is based on the perl Video::Xine API. # http://search.cpan.org/~stephen/Video-Xine/ import ctypes, os import locale import _xinelib as xinelib import x11 class XineError(Exception): pass class Osd(object): XINE_TEXT_PALETTE_SIZE = 11 XINE_OSD_TEXT1 = (0 * XINE_TEXT_PALETTE_SIZE) XINE_OSD_TEXT2 = (1 * XINE_TEXT_PALETTE_SIZE) XINE_OSD_TEXT3 = (2 * XINE_TEXT_PALETTE_SIZE) XINE_OSD_TEXT4 = (3 * XINE_TEXT_PALETTE_SIZE) XINE_OSD_TEXT5 = (4 * XINE_TEXT_PALETTE_SIZE) XINE_OSD_TEXT6 = (5 * XINE_TEXT_PALETTE_SIZE) XINE_OSD_TEXT7 = (6 * XINE_TEXT_PALETTE_SIZE) XINE_OSD_TEXT8 = (7 * XINE_TEXT_PALETTE_SIZE) XINE_OSD_TEXT9 = (8 * XINE_TEXT_PALETTE_SIZE) XINE_OSD_TEXT10 = (9 * XINE_TEXT_PALETTE_SIZE) # white text, black border, transparent background XINE_TEXTPALETTE_WHITE_BLACK_TRANSPARENT = 0 # white text, noborder, transparent background XINE_TEXTPALETTE_WHITE_NONE_TRANSPARENT = 1 # white text, no border, translucid background XINE_TEXTPALETTE_WHITE_NONE_TRANSLUCID = 2 # yellow text, black border, transparent background XINE_TEXTPALETTE_YELLOW_BLACK_TRANSPARENT = 3 XINE_OSD_CAP_FREETYPE2 = 0x0001 XINE_OSD_CAP_UNSCALED = 0x0002 def __init__(self, stream, width, height): self.__osd_p = xinelib.xine_osd_new(stream.stream_p(), 0, 0, width, height) self.color_base = Osd.XINE_OSD_TEXT1 self.__last_text = None def set_font(self, font_name, font_size): xinelib.xine_osd_set_font(self.__osd_p, font_name, font_size) def set_text_palette(self, palette_number, color_base): self.color_base = color_base xinelib.xine_osd_set_text_palette(self.__osd_p, palette_number, color_base) def is_unscaled(self): unscaled = xinelib.xine_osd_get_capabilities(self.__osd_p)\ & Osd.XINE_OSD_CAP_UNSCALED return unscaled and True or False def clear(self): xinelib.xine_osd_clear(self.__osd_p) def draw_text(self, posx, posy, text): xinelib.xine_osd_draw_text(self.__osd_p, 0, 0, text, self.color_base) xinelib.xine_osd_set_position(self.__osd_p, posx, posy) self.__last_text = text def show(self): if self.is_unscaled: xinelib.xine_osd_show_unscaled(self.__osd_p, 0) else: xinelib.xine_osd_show(self.__osd_p, 0) def hide(self, text): if text == self.__last_text: xinelib.xine_osd_hide(self.__osd_p, 0) def close(self): self.clear() xinelib.xine_osd_free(self.__osd_p) self.__osd_p = None class Event(object): XINE_EVENT_UI_PLAYBACK_FINISHED = 1 XINE_EVENT_UI_CHANNELS_CHANGED = 2 XINE_EVENT_UI_SET_TITLE = 3 XINE_EVENT_UI_MESSAGE = 4 XINE_EVENT_FRAME_FORMAT_CHANGE = 5 XINE_EVENT_AUDIO_LEVEL = 6 XINE_EVENT_QUIT = 7 XINE_EVENT_PROGRESS = 8 def __init__(self, type, contents): self.type = type if self.type == Event.XINE_EVENT_UI_MESSAGE: self.data = ctypes.cast(contents.data, ctypes.POINTER(xinelib.xine_ui_message_data_t)) else: self.data = None def message(self): if not self.data: return None msg = self.data.contents if msg.type != XinePlayer.XINE_MSG_NO_ERROR: if msg.explanation: message_txt = ctypes.string_at(ctypes.addressof(msg)\ + msg.explanation) message_parameters = [] param_address = ctypes.addressof(msg) + msg.parameters for param_index in range(0, msg.num_parameters): message_par = ctypes.string_at(param_address) param_address += len(message_par) + 1 # Skip '\0' message_parameters.append(message_par) message_params = ' '.join(message_parameters) message = "%s %s" % (message_txt, message_params) else: raise XineError(msg.type) else: message = None return message.decode(locale.getpreferredencoding()) class EventQueue(object): def __init__(self, stream): self.__callbacks = [] self.__event_queue_p = xinelib.xine_event_new_queue(stream.stream_p()) def add_callback(self, callback, user_data=None): cb = xinelib.xine_event_listener_cb_t(self.__get_cb(callback)) self.__callbacks.append(cb) xinelib.xine_event_create_listener_thread(self.__event_queue_p, cb, user_data) def __get_cb(self, callback): def c_cb(user_data, event): callback(user_data, Event(event.contents.type, event.contents)) return c_cb def close(self): xinelib.xine_event_dispose_queue(self.__event_queue_p) self.__callbacks = [] class AudioDriver(object): def __init__(self, xine, id=None, data=None): self.__xine_p = xine.xine_p() self.__driver_p = xinelib.xine_open_audio_driver(self.__xine_p, id, data) if not self.__driver_p: raise XineError('Could not open audio driver') def driver_p(self): return self.__driver_p def destroy(self): xinelib.xine_close_audio_driver(self.__xine_p, self.__driver_p) self.__driver_p = None class VideoDriver(object): XINE_VISUAL_TYPE_NONE = 0 XINE_VISUAL_TYPE_X11 = 1 XINE_VISUAL_TYPE_X11_2 = 10 XINE_VISUAL_TYPE_AA = 2 XINE_VISUAL_TYPE_FB = 3 XINE_VISUAL_TYPE_GTK = 4 XINE_VISUAL_TYPE_DFB = 5 XINE_VISUAL_TYPE_PM = 6 XINE_VISUAL_TYPE_DIRECTX = 7 XINE_VISUAL_TYPE_CACA = 8 XINE_VISUAL_TYPE_MACOSX = 9 XINE_VISUAL_TYPE_XCB = 11 def __init__(self, xine, id=None, display_id=':0.0', fullscreen=False): self.__xine_p = xine.xine_p() try: visual = self.__make_x11_visual(display_id, fullscreen) except x11.X11Error: raise XineError('Could not initialize Xine on %s', display_id) self.__driver_p = xinelib.xine_open_video_driver(self.__xine_p, id, VideoDriver.XINE_VISUAL_TYPE_X11, ctypes.cast(ctypes.byref(visual), ctypes.c_void_p)) if not self.__driver_p: raise XineError('Could not open video driver') def driver_p(self): return self.__driver_p def __make_x11_visual(self, display_id, fullscreen=False): self.display = x11.X11Display(display_id) if fullscreen: self.window = self.display.do_create_window(fullscreen=True) else: self.window = self.display.do_create_window(320, 200) # Those callbacks are required to be kept in this tuple in order to # be safe from the garbage collector. self.__x11_callbacks = ( xinelib.xine_dest_size_cb(self.__dest_size_cb), xinelib.xine_frame_output_cb(self.__frame_output_cb), ) vis = xinelib.x11_visual_t() vis.display = self.display.display_p() vis.screen = self.display.get_default_screen_number() vis.d = self.window.window_p() vis.frame_output_cb = ctypes.cast(self.__x11_callbacks[1], ctypes.c_void_p) vis.dest_size_cb = ctypes.cast(self.__x11_callbacks[0], ctypes.c_void_p) return vis def __frame_output_cb(self, user_data, video_width, video_height, video_pixel_aspect, dest_x, dest_y, dest_width, dest_height, dest_pixel_aspect, win_x, win_y): dest_x[0] = 0 dest_y[0] = 0 win_x[0] = self.window.video_area_info['win_x'] win_y[0] = self.window.video_area_info['win_y'] self.__dest_size_cb(user_data, video_width, video_height, video_pixel_aspect, dest_width, dest_height, dest_pixel_aspect) def __dest_size_cb(self, user_data, video_width, video_height, video_pixel_aspect, dest_width, dest_height, dest_pixel_aspect): dest_width[0] = self.window.video_area_info['width'] dest_height[0] = self.window.video_area_info['height'] dest_pixel_aspect[0] = self.window.video_area_info['aspect'] XINE_GUI_SEND_DRAWABLE_CHANGED = 2 XINE_GUI_SEND_EXPOSE_EVENT = 3 XINE_GUI_SEND_TRANSLATE_GUI_TO_VIDEO = 4 XINE_GUI_SEND_VIDEOWIN_VISIBLE = 5 XINE_GUI_SEND_SELECT_VISUAL = 8 XINE_GUI_SEND_WILL_DESTROY_DRAWABLE = 9 def send_gui_data(self, type, data=None): xinelib.xine_port_send_gui_data(self.__driver_p, ctypes.c_int(type), ctypes.cast(data, ctypes.c_void_p)) def destroy(self): xinelib.xine_close_video_driver(self.__xine_p, self.__driver_p) self.__x11_callbacks = None self.__driver = None self.window.close() self.window = None self.display.destroy() self.display = None class Stream(object): XINE_SPEED_PAUSE = 0 XINE_SPEED_SLOW_4 = 1 XINE_SPEED_SLOW_2 = 2 XINE_SPEED_NORMAL = 4 XINE_SPEED_FAST_2 = 8 XINE_SPEED_FAST_4 = 16 def __init__(self, xine, audio_port=None, video_port=None): self.__xine = xine self.__event_queue = None self.__software_mixer = False self.__audio_port = audio_port or AudioDriver(xine) self.__video_port = video_port if self.__video_port: video_driver_p = self.__video_port.driver_p() else: video_driver_p = None self.__stream_p = xinelib.xine_stream_new(self.__xine.xine_p(), self.__audio_port.driver_p(), video_driver_p) if self.__xine.has_gapless(): self.set_param(Stream.XINE_PARAM_EARLY_FINISHED_EVENT, 1) if self.__video_port: self.__video_port.send_gui_data(\ VideoDriver.XINE_GUI_SEND_DRAWABLE_CHANGED, self.__video_port.window.window_p()) self.__video_port.send_gui_data(\ VideoDriver.XINE_GUI_SEND_VIDEOWIN_VISIBLE, 1) else: self.set_param(Stream.XINE_PARAM_IGNORE_VIDEO, 1) self.set_param(Stream.XINE_PARAM_IGNORE_SPU, 1) self.__osd = None def has_video(self): if self.__video_port: return True else: return False def stream_p(self): return self.__stream_p def add_event_callback(self, callback): if not self.__event_queue: self.__event_queue = EventQueue(self) self.__event_queue.add_callback(callback) def open(self, mrl): if not xinelib.xine_open(self.__stream_p, mrl): raise XineError('Could not open %s' % mrl) def play(self, start_pos=0, start_time=0): if not xinelib.xine_play(self.__stream_p, ctypes.c_int(start_pos), ctypes.c_int(start_time)): raise XineError('Could not play stream') else: self.set_dpms(False) def stop(self): self.set_dpms(False) xinelib.xine_stop(self.__stream_p) def get_pos_length(self): _pos_stream = ctypes.c_int() _pos_time = ctypes.c_int() _length_time = ctypes.c_int() result = xinelib.xine_get_pos_length(self.__stream_p, ctypes.byref(_pos_stream), ctypes.byref(_pos_time), ctypes.byref(_length_time)) if result: return _pos_stream.value, _pos_time.value, _length_time.value else: return 0, 0, 0 def get_pos(self): # Workaround for problems when you seek too quickly i = 0 while i < 4: pos_s, pos_t, length = self.get_pos_length() if int(pos_t) > 0: break xinelib.xine_usec_sleep(100000) i += 1 return int(pos_t / 1000) def get_length(self): pos_s, pos_t, length = self.get_pos_length() return length / 1000 XINE_STATUS_IDLE = 0 XINE_STATUS_STOP = 1 XINE_STATUS_PLAY = 2 XINE_STATUS_QUIT = 3 def get_status(self): return xinelib.xine_get_status(self.__stream_p) def set_fullscreen(self, fullscreen): # FIXME : This does not work yet. raise NotImplementedError if not self.__video_port: raise XineError('Stream is audio only') self.__video_port.window.set_fullscreen(fullscreen) self.__video_port.send_gui_data(\ VideoDriver.XINE_GUI_SEND_DRAWABLE_CHANGED, self.__video_port.window.window_p()) XINE_PARAM_SPEED = 1 # see below XINE_PARAM_AV_OFFSET = 2 # unit: 1/90000 sec XINE_PARAM_AUDIO_CHANNEL_LOGICAL = 3 # 1 => auto, -2 => off XINE_PARAM_SPU_CHANNEL = 4 XINE_PARAM_VIDEO_CHANNEL = 5 XINE_PARAM_AUDIO_VOLUME = 6 # 0..100 XINE_PARAM_AUDIO_MUTE = 7 # 1=>mute, 0=>unmute XINE_PARAM_AUDIO_COMPR_LEVEL = 8 # <100=>off, % compress otherw XINE_PARAM_AUDIO_AMP_LEVEL = 9 # 0..200, 100=>100% (default) XINE_PARAM_AUDIO_REPORT_LEVEL = 10 # 1=>send events, 0=> don't XINE_PARAM_VERBOSITY = 11 # control console output XINE_PARAM_SPU_OFFSET = 12 # unit: 1/90000 sec XINE_PARAM_IGNORE_VIDEO = 13 # disable video decoding XINE_PARAM_IGNORE_AUDIO = 14 # disable audio decoding XINE_PARAM_IGNORE_SPU = 15 # disable spu decoding XINE_PARAM_BROADCASTER_PORT = 16 # 0: disable, x: server port XINE_PARAM_METRONOM_PREBUFFER = 17 # unit: 1/90000 sec XINE_PARAM_EQ_30HZ = 18 # equalizer gains -100..100 XINE_PARAM_EQ_60HZ = 19 # equalizer gains -100..100 XINE_PARAM_EQ_125HZ = 20 # equalizer gains -100..100 XINE_PARAM_EQ_250HZ = 21 # equalizer gains -100..100 XINE_PARAM_EQ_500HZ = 22 # equalizer gains -100..100 XINE_PARAM_EQ_1000HZ = 23 # equalizer gains -100..100 XINE_PARAM_EQ_2000HZ = 24 # equalizer gains -100..100 XINE_PARAM_EQ_4000HZ = 25 # equalizer gains -100..100 XINE_PARAM_EQ_8000HZ = 26 # equalizer gains -100..100 XINE_PARAM_EQ_16000HZ = 27 # equalizer gains -100..100 XINE_PARAM_AUDIO_CLOSE_DEVICE = 28 # force closing audio device XINE_PARAM_AUDIO_AMP_MUTE = 29 # 1=>mute, 0=>unmute XINE_PARAM_FINE_SPEED = 30 # 1.000.000 => normal speed XINE_PARAM_EARLY_FINISHED_EVENT = 31 # send event when demux finish XINE_PARAM_GAPLESS_SWITCH = 32 # next stream only gapless swi XINE_PARAM_DELAY_FINISHED_EVENT = 33 # 1/10sec,0=>disable,-1=>f XINE_PARAM_VO_DEINTERLACE = 0x01000000 # bool XINE_PARAM_VO_ASPECT_RATIO = 0x01000001 # see below XINE_PARAM_VO_HUE = 0x01000002 # 0..65535 XINE_PARAM_VO_SATURATION = 0x01000003 # 0..65535 XINE_PARAM_VO_CONTRAST = 0x01000004 # 0..65535 XINE_PARAM_VO_BRIGHTNESS = 0x01000005 # 0..65535 XINE_PARAM_VO_ZOOM_X = 0x01000008 # percent XINE_PARAM_VO_ZOOM_Y = 0x0100000d # percent XINE_PARAM_VO_PAN_SCAN = 0x01000009 # bool XINE_PARAM_VO_TVMODE = 0x0100000a # ??? XINE_PARAM_VO_WINDOW_WIDTH = 0x0100000f # readonly XINE_PARAM_VO_WINDOW_HEIGHT = 0x01000010 # readonly XINE_PARAM_VO_CROP_LEFT = 0x01000020 # crop frame pixels XINE_PARAM_VO_CROP_RIGHT = 0x01000021 # crop frame pixels XINE_PARAM_VO_CROP_TOP = 0x01000022 # crop frame pixels XINE_PARAM_VO_CROP_BOTTOM = 0x01000023 # crop frame pixels XINE_VO_ZOOM_STEP = 100 XINE_VO_ZOOM_MAX = 400 XINE_VO_ZOOM_MIN = -85 XINE_VO_ASPECT_AUTO = 0 XINE_VO_ASPECT_SQUARE = 1 # 1:1 XINE_VO_ASPECT_4_3 = 2 # 4:3 XINE_VO_ASPECT_ANAMORPHIC = 3 # 16:9 XINE_VO_ASPECT_DVB = 4 # 2.11:1 XINE_VO_ASPECT_NUM_RATIOS = 5 def set_param(self, param, value): xinelib.xine_set_param(self.__stream_p, param, value) def get_param(self, param): return xinelib.xine_get_param(self.__stream_p, param) def set_software_mixer(self, software_mixer): self.__software_mixer = software_mixer def set_volume(self, volume): param = Stream.XINE_PARAM_AUDIO_VOLUME if self.__software_mixer: param = Stream.XINE_PARAM_AUDIO_AMP_LEVEL self.set_param(param, volume) XINE_META_INFO_TITLE = 0 XINE_META_INFO_COMMENT = 1 XINE_META_INFO_ARTIST = 2 XINE_META_INFO_GENRE = 3 XINE_META_INFO_ALBUM = 4 XINE_META_INFO_YEAR = 5 XINE_META_INFO_VIDEOCODEC = 6 XINE_META_INFO_AUDIOCODEC = 7 XINE_META_INFO_SYSTEMLAYER = 8 XINE_META_INFO_INPUT_PLUGIN = 9 def get_meta_info(self, info): return xinelib.xine_get_meta_info(self.__stream_p, info) XINE_STREAM_INFO_BITRATE = 0 XINE_STREAM_INFO_SEEKABLE = 1 XINE_STREAM_INFO_VIDEO_WIDTH = 2 XINE_STREAM_INFO_VIDEO_HEIGHT = 3 XINE_STREAM_INFO_VIDEO_RATIO = 4 def get_stream_info(self, info): return xinelib.xine_get_stream_info(self.__stream_p, info) XINE_LANG_MAX = 256 def get_audio_lang(self, channel): _lang = ctypes.create_string_buffer(Stream.XINE_LANG_MAX) result = xinelib.xine_get_audio_lang(self.__stream_p, channel, _lang) if not result: _lang.raw = 'unknown' return _lang.value def get_spu_lang(self, channel): _lang = ctypes.create_string_buffer(Stream.XINE_LANG_MAX) result = xinelib.xine_get_spu_lang(self.__stream_p, channel, _lang) if not result: _lang.raw = 'unknown' return _lang.value def set_dpms(self, activated): if self.__video_port: self.__video_port.display.set_dpms(activated) def osd_new(self, font_size): if not self.__video_port: raise XineError('This Stream is audio only.') # Does this need locking the video area? self.__osd = Osd(self, self.__video_port.window.video_area_info['width'], self.__video_port.window.video_area_info['height']) self.__osd.set_font('sans', font_size) self.__osd.set_text_palette(\ Osd.XINE_TEXTPALETTE_WHITE_BLACK_TRANSPARENT, Osd.XINE_OSD_TEXT1) return self.__osd def close(self): self.stop() xinelib.xine_close(self.__stream_p) def destroy(self): self.close() self.set_param(Stream.XINE_PARAM_AUDIO_CLOSE_DEVICE, 1) if self.__event_queue: self.__event_queue.close() self.__event_queue = None if self.__osd: self.__osd.close() self.__osd = None xinelib.xine_dispose(self.__stream_p) self.__stream_p = None self.__audio_port.destroy() self.__audio_port = None if self.__video_port: self.__video_port.destroy() self.__video_port = None class XinePlayer(object): XINE_MSG_NO_ERROR = 0 # (messages to UI) XINE_MSG_GENERAL_WARNING = 1 # (warning message) XINE_MSG_UNKNOWN_HOST = 2 # (host name) XINE_MSG_UNKNOWN_DEVICE = 3 # (device name) XINE_MSG_NETWORK_UNREACHABLE = 4 # none XINE_MSG_CONNECTION_REFUSED = 5 # (host name) XINE_MSG_FILE_NOT_FOUND = 6 # (file name or mrl) XINE_MSG_READ_ERROR = 7 # (device/file/mrl) XINE_MSG_LIBRARY_LOAD_ERROR = 8 # (library/decoder) XINE_MSG_ENCRYPTED_SOURCE = 9 # none XINE_MSG_SECURITY = 10 # (security message) XINE_MSG_AUDIO_OUT_UNAVAILABLE = 11 # none XINE_MSG_PERMISSION_ERROR = 12 # (file name or mrl) XINE_MSG_FILE_EMPTY = 13 # file is empty def __init__(self, config_file_path=None): self.__xine = xinelib.xine_new() if not self.__xine: raise XineError('Error during Xine instance initialisation') if config_file_path: xinelib.xine_config_load(self.__xine, config_file_path) else: # load default config file default = os.path.join(xinelib.xine_get_homedir(),".xine/config") xinelib.xine_config_load(self.__xine, default) xinelib.xine_init(self.__xine) def xine_p(self): return self.__xine def get_version(self): major = ctypes.c_int() minor = ctypes.c_int() sub = ctypes.c_int() xinelib.xine_get_version(ctypes.byref(major), ctypes.byref(minor), ctypes.byref(sub)) return (major.value, minor.value, sub.value) def has_gapless(self): return xinelib.xine_check_version(1, 1, 1) == 1 def get_supported_extensions(self): return xinelib.xine_get_file_extensions(self.__xine).split() def list_input_plugins(self): plugins = [] for plugin in xinelib.xine_list_input_plugins(self.__xine): if not plugin: break plugins.append(plugin.lower()) return plugins def destroy(self): xinelib.xine_exit(self.__xine) self.__xine = None XINE_ENGINE_PARAM_VERBOSITY_NONE = 0 XINE_ENGINE_PARAM_VERBOSITY_LOG = 1 def set_param(self, param, value): xinelib.xine_engine_set_param(self.__xine, param, value) def stream_new(self, audio_port=None, video_port=None, video=False): if video and not video_port: video_port = VideoDriver(self) return Stream(self, audio_port, video_port) if __name__ == '__main__': import sys, os, time x = XinePlayer(os.path.expanduser('~/.xine/config')) print 'Xine %d.%d.%d ' % x.get_version() x.set_param(XinePlayer.XINE_ENGINE_PARAM_VERBOSITY_LOG, 1) vd = VideoDriver(x, fullscreen=False) s = x.stream_new(video_port=vd) def print_message(data, event): if event.type == Event.XINE_EVENT_UI_MESSAGE: try: print "Xine error message : %s" % event.message() except XineError, e: print "Xine exception message : %s " % e s.add_event_callback(print_message) file_path = sys.argv[1] if not file_path.startswith('/') and not file_path.startswith('http://'): file_path = os.path.join(os.getcwd(), file_path) if file_path.startswith('/'): file_path = 'file:/' + file_path s.open(file_path) s.play() time.sleep(15) s.stop() s.destroy() x.destroy() # vim: ts=4 sw=4 expandtab deejayd-0.10.0/pytyxi/_libX11.py0000644000175000017500000002317111351210475014523 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import sys import ctypes try: _libX11 = ctypes.cdll.LoadLibrary('libX11.so.6') except (ImportError, OSError), e: raise ImportError, e # int XInitThreads() _libX11.XInitThreads.restype = ctypes.c_int # Display *XOpenDisplay(char *display_name) _libX11.XOpenDisplay.restype = ctypes.c_void_p _libX11.XOpenDisplay.argtypes = (ctypes.c_char_p, ) # int XDefaultScreen(Display *display) _libX11.XDefaultScreen.restype = ctypes.c_int _libX11.XDefaultScreen.argtypes = (ctypes.c_void_p, ) # int XDisplayWidth(Display *display, int screen_number); _libX11.XDisplayWidth.restype = ctypes.c_int _libX11.XDisplayWidth.argtypes = (ctypes.c_void_p, ctypes.c_int) # int XDisplayHeight(Display *display, int screen_number); _libX11.XDisplayHeight.restype = ctypes.c_int _libX11.XDisplayHeight.argtypes = (ctypes.c_void_p, ctypes.c_int) # int XDisplayWidthMM(Display *display, int screen_number); _libX11.XDisplayWidthMM.restype = ctypes.c_int _libX11.XDisplayWidthMM.argtypes = (ctypes.c_void_p, ctypes.c_int) # int XDisplayHeightMM(Display *display, int screen_number); _libX11.XDisplayHeightMM.restype = ctypes.c_int _libX11.XDisplayHeightMM.argtypes = (ctypes.c_void_p, ctypes.c_int) # void XCloseDisplay(Display *display) _libX11.XCloseDisplay.argtypes = (ctypes.c_void_p, ) # void XLockDisplay(Display *display) _libX11.XLockDisplay.argtypes = (ctypes.c_void_p, ) # void XUnlockDisplay(Display *display) _libX11.XUnlockDisplay.argtypes = (ctypes.c_void_p, ) # Window XDefaultRootWindow(Display *display) _libX11.XDefaultRootWindow.restype = ctypes.c_ulong _libX11.XDefaultRootWindow.argtypes = (ctypes.c_void_p, ) # Window XCreateSimpleWindow(Display *display, Window parent, int x, int y, # uint width, uint height, uint border_width, # ulong border, ulong background) _libX11.XCreateSimpleWindow.restype = ctypes.c_ulong _libX11.XCreateSimpleWindow.argtypes = (ctypes.c_void_p, ctypes.c_ulong, ctypes.c_int, ctypes.c_int, ctypes.c_uint, ctypes.c_uint, ctypes.c_uint, ctypes.c_ulong, ctypes.c_ulong) # void XMapRaised(Display *display, Window w) _libX11.XMapRaised.argtypes = (ctypes.c_void_p, ctypes.c_ulong) # void XUnmapWindow(Display *display, Window w) _libX11.XUnmapWindow.argtypes = (ctypes.c_void_p, ctypes.c_ulong) # void XSelectInput(Display *display, Window w, long event_mask) _libX11.XSelectInput.argtypes = (ctypes.c_void_p, ctypes.c_ulong, ctypes.c_long) # int XGetGeometry(Display *display, Drawable d, Window *root_return, # int *x_return, int *y_return, uint *width_return, # uint *height_return, uint *border_width_return, # uint *depth_return) _libX11.XGetGeometry.restype = ctypes.c_int _libX11.XGetGeometry.argtypes = (ctypes.c_void_p, ctypes.c_ulong, ctypes.POINTER(ctypes.c_ulong), ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_uint), ctypes.POINTER(ctypes.c_uint), ctypes.POINTER(ctypes.c_uint), ctypes.POINTER(ctypes.c_uint)) # void XSync(Display *display, Bool discard) _libX11.XSync.argtypes = (ctypes.c_void_p, ctypes.c_int) # void XDestroyWindow(Display *display, Window w) _libX11.XDestroyWindow.argtypes = (ctypes.c_void_p, ctypes.c_ulong) class XColor(ctypes.Structure): _fields_ = ( ('pixel', ctypes.c_ulong), ('red', ctypes.c_ushort), ('green', ctypes.c_ushort), ('blue', ctypes.c_ushort), ('flags', ctypes.c_char), ('pad', ctypes.c_char), ) class MWMHints(ctypes.Structure): MWM_HINTS_DECORATIONS = (1L << 1) PROP_MWM_HINTS_ELEMENTS = 5 _fields_ = ( ('flags', ctypes.c_uint), ('functions', ctypes.c_uint), ('decorations', ctypes.c_uint), ('input_mode', ctypes.c_int), ('status', ctypes.c_uint), ) # Atom XInternAtom(Display *display,char *atom_name, Bool only_if_exists) _libX11.XInternAtom.restype = ctypes.c_ulong _libX11.XInternAtom.argtypes = (ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int) # XChangeProperty(Display *display, Window w, Atom property, Atom type, # int format, int mode, uchar *data, int nelements) _libX11.XChangeProperty.argtypes = (ctypes.c_void_p, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_int, ctypes.c_int, ctypes.c_char_p, ctypes.c_int) class PropertyModes: PropModeReplace = 0 PropModePrepend = 1 PropModeAppend = 2 class XSetWindowAttributes(ctypes.Structure): _fields_ = ( ('background_pixmap', ctypes.c_ulong), ('background_pixel', ctypes.c_ulong), ('border_pixmap', ctypes.c_ulong), ('border_pixel', ctypes.c_ulong), ('bit_gravity', ctypes.c_int), ('win_gravity', ctypes.c_int), ('backing_store', ctypes.c_int), ('backing_planes', ctypes.c_ulong), ('backing_pixel', ctypes.c_ulong), ('save_under', ctypes.c_int), ('event_mask', ctypes.c_long), ('do_not_propagate_mask', ctypes.c_long), ('override_redirect', ctypes.c_int), ('colormap', ctypes.c_ulong), ('cursor', ctypes.c_ulong), ) # XChangeWindowAttributes(Display *display, Window w, ulong valuemask, # XSetWindowAttributes *attributes) _libX11.XChangeWindowAttributes.argtypes = (ctypes.c_void_p, ctypes.c_ulong, ctypes.c_ulong, ctypes.POINTER(XSetWindowAttributes)) CWOverrideRedirect = (1L<<9) # Colormap XDefaultColormap(Display *display, int screen) _libX11.XDefaultColormap.restype = ctypes.c_ulong _libX11.XDefaultColormap.argtypes = (ctypes.c_void_p, ctypes.c_int) # int XAllocNamedColor(Display *display, Colormap cmap, char *color_name, # XColor *screen_def_return, XColor *exact_def_return) _libX11.XAllocNamedColor.restype = ctypes.c_int _libX11.XAllocNamedColor.argtypes = (ctypes.c_void_p, ctypes.c_ulong, ctypes.c_char_p, ctypes.POINTER(XColor), ctypes.POINTER(XColor)) # XDefineCursor(Display *display, Window w, Cursor cursor) _libX11.XDefineCursor.argtypes = (ctypes.c_void_p, ctypes.c_ulong, ctypes.c_ulong) # XUndefineCursor(Display *display, Window w) _libX11.XUndefineCursor.argtypes = (ctypes.c_void_p, ctypes.c_ulong) # Cursor XCreatePixmapCursor(Display *display, Pixmap source, Pixmap mask, # XColor *foreground_color, # XColor *background_color, uint x, uint y) _libX11.XCreatePixmapCursor.restype = ctypes.c_ulong _libX11.XCreatePixmapCursor.argtypes = (ctypes.c_void_p, ctypes.c_ulong, ctypes.c_ulong, ctypes.POINTER(XColor), ctypes.POINTER(XColor), ctypes.c_uint, ctypes.c_uint) # XFreeCursor(Display *display, Cursor cursor) _libX11.XFreeCursor.argtypes = (ctypes.c_void_p, ctypes.c_ulong) # Colormap XDefaultColormap(Display *display, int screen) _libX11.XDefaultColormap.restype = ctypes.c_ulong _libX11.XDefaultColormap.argtypes = (ctypes.c_void_p, ctypes.c_int) # int XAllocNamedColor(Display *display, Colormap cmap, char *color_name, # XColor *screen_def_return, XColor *exact_def_return) _libX11.XAllocNamedColor.restype = ctypes.c_int _libX11.XAllocNamedColor.argtypes = (ctypes.c_void_p, ctypes.c_ulong, ctypes.c_char_p, ctypes.POINTER(XColor), ctypes.POINTER(XColor)) # Pixmap XCreateBitmapFromData(Display *display, Drawable d, char *data, # uint width, uint height) _libX11.XCreateBitmapFromData.restype = ctypes.c_ulong _libX11.XCreateBitmapFromData.argtypes = (ctypes.c_void_p, ctypes.c_ulong, ctypes.c_char_p, ctypes.c_uint, ctypes.c_uint) # void XFreePixmap(Display *display, Pixmap p) _libX11.XFreePixmap.argtypes = (ctypes.c_void_p, ctypes.c_ulong) # void XFreeColors(Display *display, Colormap colormap, ulong pixels[], # int npixels, ulong planes)) _libX11.XFreeColors.argtypes = (ctypes.c_void_p, ctypes.c_ulong, ctypes.POINTER(ctypes.c_ulong), ctypes.c_int, ctypes.c_ulong) # XResizeWindow(Display *display, Window w, uint width, uint height) _libX11.XResizeWindow.argtypes = (ctypes.c_void_p, ctypes.c_ulong, ctypes.c_uint, ctypes.c_uint) # copy functions from the library module = sys.modules[__name__] for name in dir(_libX11): if name.startswith('X'): setattr(module, name, getattr(_libX11, name)) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/pytyxi/x11.py0000644000175000017500000002503311351210475013734 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # # This work is based on the perl X11::FullScreen API. # http://search.cpan.org/~stephen/X11-FullScreen-0.03/ import ctypes import _libX11 as libX11 import _libXext as libXext class X11Error(Exception): pass class X11Event: NoEventMask = 0L KeyPressMask = (1L<<0) KeyReleaseMask = (1L<<1) ButtonPressMask = (1L<<2) ButtonReleaseMask = (1L<<3) EnterWindowMask = (1L<<4) LeaveWindowMask = (1L<<5) PointerMotionMask = (1L<<6) PointerMotionHintMask = (1L<<7) Button1MotionMask = (1L<<8) Button2MotionMask = (1L<<9) Button3MotionMask = (1L<<10) Button4MotionMask = (1L<<11) Button5MotionMask = (1L<<12) ButtonMotionMask = (1L<<13) KeymapStateMask = (1L<<14) ExposureMask = (1L<<15) VisibilityChangeMask = (1L<<16) StructureNotifyMask = (1L<<17) ResizeRedirectMask = (1L<<18) SubstructureNotifyMask = (1L<<19) SubstructureRedirectMask = (1L<<20) FocusChangeMask = (1L<<21) PropertyChangeMask = (1L<<22) ColormapChangeMask = (1L<<23) OwnerGrabButtonMask = (1L<<24) class X11Window(object): def __init__(self, display, width=None, height=None, fullscreen=False): self.__display = display self.__display_p = self.__display.display_p() libX11.XLockDisplay(self.__display_p) if fullscreen: self.__always_fulscreen = True self.__fullscreen = True self.width = self.__display.get_width() self.height = self.__display.get_height() elif width and height: self.__always_fulscreen = False self.__fullscreen = False self.width = width self.height = height elif not width and not height and not fullscreen: raise X11Error('A window is either fullscreen or has dimensions.') self.__window_p = libX11.XCreateSimpleWindow(self.__display_p, libX11.XDefaultRootWindow(self.__display_p), 0, 0, self.width, self.height, 0, 0, 0) self.__hide_cursor() if self.__fullscreen: self.__remove_decorations() libX11.XSelectInput(self.__display_p, self.__window_p, (X11Event.ExposureMask |\ X11Event.ButtonPressMask |\ X11Event.KeyPressMask |\ X11Event.ButtonMotionMask |\ X11Event.StructureNotifyMask |\ X11Event.PropertyChangeMask |\ X11Event.PointerMotionMask)) libX11.XMapRaised(self.__display_p, self.__window_p) libX11.XSync(self.__display_p, False) libX11.XUnlockDisplay(self.__display_p) self.video_area_info = {} self.update_video_area_info() def window_p(self): return self.__window_p def __remove_decorations(self): mwmhints = libX11.MWMHints() mwmhints.decorations = 0 mwmhints.flags = libX11.MWMHints.MWM_HINTS_DECORATIONS data = ctypes.cast(ctypes.byref(mwmhints), ctypes.c_char_p) xa_no_border = libX11.XInternAtom(self.__display_p, "_MOTIF_WM_HINTS", False) libX11.XChangeProperty(self.__display_p, self.__window_p, xa_no_border, xa_no_border, 32, libX11.PropertyModes.PropModeReplace, data, libX11.MWMHints.PROP_MWM_HINTS_ELEMENTS) def __ignore_wm(self): attr = libX11.XSetWindowAttributes() attr.override_redirect = True libX11.XChangeWindowAttributes(self.__display_p, self.__window_p, libX11.CWOverrideRedirect) def __hide_cursor(self): black = libX11.XColor() dummy = libX11.XColor() bitmap_struct = ctypes.c_char * 8 bm_no_data = bitmap_struct('0', '0', '0', '0', '0', '0', '0', '0') cmap = libX11.XDefaultColormap(self.__display_p, self.__display.get_default_screen_number()) libX11.XAllocNamedColor(self.__display_p, cmap, "black", ctypes.byref(black), ctypes.byref(dummy)) bm_no = libX11.XCreateBitmapFromData(self.__display_p, self.__window_p, bm_no_data, 1, 1) no_ptr = libX11.XCreatePixmapCursor(self.__display_p, bm_no, bm_no, ctypes.byref(black), ctypes.byref(black), 0, 0) libX11.XDefineCursor(self.__display_p, self.__window_p, no_ptr) libX11.XFreeCursor(self.__display_p, no_ptr) if bm_no != None: libX11.XFreePixmap(self.__display_p, bm_no) pixel = ctypes.c_ulong(black.pixel) libX11.XFreeColors(self.__display_p, cmap, ctypes.byref(pixel), 1, 0) def get_geometry(self): root = ctypes.c_ulong() x = ctypes.c_int() y = ctypes.c_int() width =ctypes. c_uint() height = ctypes.c_uint() border_width = ctypes.c_uint() depth = ctypes.c_uint() libX11.XGetGeometry(self.__display_p, self.__window_p, ctypes.byref(root), ctypes.byref(x), ctypes.byref(y), ctypes.byref(width), ctypes.byref(height), ctypes.byref(border_width), ctypes.byref(depth)) return x.value, y.value, width.value, height.value def update_video_area_info(self): # FIXME : This should be called when the window is resized. x, y, w, h = self.get_geometry() self.video_area_info['win_x'] = x self.video_area_info['win_y'] = y self.video_area_info['width'] = w self.video_area_info['height'] = h self.video_area_info['aspect'] = self.__display.get_pixel_aspect() def set_fullscreen(self, fson=True): if fson == self.__fulscreen or self.__always_fulscreen: return libX11.XLockDisplay(self.__display_p) if fson: new_width = self.__display.get_width() new_height = self.__display.get_height() self.__remove_decorations() self.__fulscreen = True else: new_width = self.width new_height = self.height self.__fulscreen = False libX11.XResizeWindow(self.__display_p, self.__window_p, new_width, new_height) libX11.XUnlockDisplay(self.__display_p) self.update_video_area_info() def show(self, do_show=True): libX11.XLockDisplay(self.__display_p) if do_show: libX11.XMapRaised(self.__display_p, self.__window_p) else: libX11.XUnmapWindow(self.__display_p, self.__window_p) libX11.XUnlockDisplay(self.__display_p) def close(self): libX11.XLockDisplay(self.__display_p) libX11.XUnmapWindow(self.__display_p, self.__window_p) libX11.XDestroyWindow(self.__display_p, self.__window_p) libX11.XUnlockDisplay(self.__display_p) self.video_area_info = None class X11Display(object): def __init__(self, id=':0.0'): self.id = id if not libX11.XInitThreads(): raise X11Error('Could not init threads') self.__display_p = libX11.XOpenDisplay(self.id) if not self.__display_p: raise X11Error('Could not open display %s' % self.id) self.__dpms_orig = self.is_dpms_on() def display_p(self): return self.__display_p def get_default_screen_number(self): return libX11.XDefaultScreen(self.__display_p) def get_width(self, screen_number=None): if not screen_number: screen_number = self.get_default_screen_number() return libX11.XDisplayWidth(self.__display_p, screen_number) def get_height(self, screen_number=None): if not screen_number: screen_number = self.get_default_screen_number() return libX11.XDisplayHeight(self.__display_p, screen_number) def get_pixel_aspect(self, screen_number=None): if not screen_number: screen_number = self.get_default_screen_number() res_h = float(self.get_width(screen_number) * 1000 /\ libX11.XDisplayWidthMM(self.__display_p, screen_number)) res_v = float(self.get_height(screen_number) * 1000 /\ libX11.XDisplayHeightMM(self.__display_p, screen_number)) return res_v / res_h def is_dpms_on(self): dummy = ctypes.c_int() if libXext.DPMSQueryExtension(self.__display_p, ctypes.byref(dummy), ctypes.byref(dummy)): return True else: return False def set_dpms(self, activated): if self.is_dpms_on(): if activated: libXext.DPMSEnable(self.__display_p) else: libXext.DPMSDisable(self.__display_p) def do_create_window(self, width=None, height=None, fullscreen=False): return X11Window(self, width, height, fullscreen) def destroy(self): self.set_dpms(self.__dpms_orig) libX11.XCloseDisplay(self.__display_p) if __name__ == '__main__': import time d = X11Display() w = d.do_create_window(320, 200) time.sleep(5) w.close() d.destroy() # vim: ts=4 sw=4 expandtab deejayd-0.10.0/pytyxi/__init__.py0000644000175000017500000000160711351210475015063 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # vim: ts=4 sw=4 expandtab deejayd-0.10.0/pytyxi/_libXext.py0000644000175000017500000000345111351210475015101 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import sys import ctypes try: _libXext = ctypes.cdll.LoadLibrary('libXext.so.6') except (ImportError, OSError), e: raise ImportError, e # Bool DPMSQueryExtension (Display *display, int *event_base, int *error_base) _libXext.DPMSQueryExtension.argtypes = (ctypes.c_void_p, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int)) _libXext.DPMSQueryExtension.restype = ctypes.c_int # Status DPMSEnable (Display *display ) _libXext.DPMSEnable.argtypes = (ctypes.c_void_p, ) _libXext.DPMSEnable.restype = ctypes.c_int # Status DPMSDisable (Display *display ) _libXext.DPMSDisable.argtypes = (ctypes.c_void_p, ) _libXext.DPMSDisable.restype = ctypes.c_int # copy functions from the library module = sys.modules[__name__] for name in dir(_libXext): if name.startswith('DPMS'): setattr(module, name, getattr(_libXext, name)) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/0000755000175000017500000000000011354730161013007 5ustar royroydeejayd-0.10.0/deejayd/plugins/0000755000175000017500000000000011354730161014470 5ustar royroydeejayd-0.10.0/deejayd/plugins/lastfm.py0000644000175000017500000002205111354571226016335 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import urllib, urllib2, time, ConfigParser import threading, Queue try: from hashlib import md5 except ImportError: # python < 2.5 from md5 import md5 from zope.interface import implements from deejayd.interfaces import DeejaydError from deejayd.ui import log from deejayd.plugins import IPlayerPlugin, PluginError from deejayd.utils import str_encode class AudioScrobblerFatalError(DeejaydError): pass class AudioScrobblerError(DeejaydError): def __init__(self, msg): self.message = _("AudioScrobbler Error: %s") % str_encode(msg) class AudioScrobblerPlugin: implements(IPlayerPlugin) NAME="audioscrobbler" AUDIOSCROBBLER_URL = "post.audioscrobbler.com" AUDIOSCROBBLER_PROTOCOL_VERSION = "1.2.1" AUDIOSCROBBLER_CLIENT="tst" AUDIOSCROBBLER_VERSION="1.0" AUDIOSCROBBLER_WAIT_INTERVAL=30 def __init__(self, config): self.enabled = True self.authenticated = False self.last_request = None self.session_id = None self.nowplaying_url = None self.submit_url = None self.should_stop = threading.Event() self.queue = Queue.Queue() try: self.__auth_details = { "login": config.get("lastfm", "login"), "password": config.get("lastfm", "password"), } except (ConfigParser.NoSectionError, ConfigParser.NoOptionError): raise PluginError(_("Lastfm configuration has not been set.")) # start thread self.thread = threading.Thread(target=self.run) self.thread.setDaemon(True) # exit if only this thread is left self.thread.start() def on_media_played(self, media): if self.enabled and media["type"] == "song": self.queue.put((media, self.__get_stamp(media))) def close(self): self.should_stop.set() def __open_request(self, request): try: url_handle = urllib2.urlopen(request) except urllib2.HTTPError, error: err_msg = _("Unable to connect to server: %s - %s") code = str_encode(error.code, errors="ignore") msg = str_encode(error.msg, errors="ignore") raise AudioScrobblerError(err_msg % (code, msg)) except urllib2.URLError, error: args = getattr(error.reason, 'args', None) code = '000' message = str(error) if args is not None: if len(args) == 1: message = error.reason.args[0] elif len(args) == 2: code = str(error.reason.args[0]) message = error.reason.args[1] code = str_encode(code, errors="ignore") message = str_encode(message, errors="ignore") err_msg = _("Unable to connect to server: %s - %s") raise AudioScrobblerError(err_msg % (code, message)) self.last_request = int(time.time()) return url_handle def auth(self): if self.authenticated: return True timestamp = int(time.time()) token = md5(self.__auth_details["password"]+str(timestamp)).hexdigest() p = { "hs": "true", "u": self.__auth_details["login"], "c": self.AUDIOSCROBBLER_CLIENT, "v": self.AUDIOSCROBBLER_VERSION, "p": self.AUDIOSCROBBLER_PROTOCOL_VERSION, "t": str(timestamp), "a": token, } plist = [(k, urllib.quote_plus(v.encode('utf8'))) for k, v in p.items()] authparams = urllib.urlencode(plist) url = 'http://%s/?%s' % (self.AUDIOSCROBBLER_URL, authparams) req = urllib2.Request(url) url_handle = self.__open_request(req) # check answer response = url_handle.read().rstrip().split("\n") if len(response) == 0: raise AudioScrobblerError(_('Got nothing back from the server')) status = response.pop(0) if status == "OK": self.session_id = response.pop(0) self.nowplaying_url = response.pop(0) self.submit_url = response.pop(0) self.authenticated = True elif status == "UPTODATE": self.authenticated = True elif status == "BADAUTH": raise AudioScrobblerFatalError(_("Bad login/password")) elif status == "BADTIME": raise AudioScrobblerFatalError(_("Bad time")) elif status == "BANNED": raise AudioScrobblerFatalError(_("Application has be banned")) elif status.startswith("FAILED"): raise AudioScrobblerError(_("Failed to handshake: %s") % status) else: raise AudioScrobblerFatalError(_("Unknown status %s") % status) def submit_track(self, queue): data = {"s": self.session_id} for i, (track, stamp) in enumerate(queue): data["a[%d]" % i] = track["artist"].encode('utf-8') data["t[%d]" % i] = track["title"].encode('utf-8') data["l[%d]" % i] = track["length"].encode('utf-8') data["b[%d]" % i] = track['album'].encode('utf-8') data["m[%d]" % i] = "".encode('utf-8') data["i[%d]" % i] = stamp data["o[%d]" % i] = "P".encode('utf-8') data["n[%d]" % i] = "".encode('utf-8') data["r[%d]" % i] = "".encode('utf-8') postdata = urllib.urlencode(data) req = urllib2.Request(url=self.submit_url, data=postdata) url_handle = self.__open_request(req) response = url_handle.read().rstrip().split("\n") if len(response) == 0: raise AudioScrobblerError(_('Got nothing back from the server')) status = response.pop(0) if status == "OK": return True if status == "BADSESSION": raise AudioScrobblerFatalError(_("Bad session ID")) elif status.startswith("FAILED"): raise AudioScrobblerError(_("Failed to submit songs: %s") % status) else: raise AudioScrobblerError(_("Unknown status %s") % status) def run(self): while not self.should_stop.isSet(): try: self.auth() except AudioScrobblerFatalError, ex: # fatal error, disable plugin log.err(_("Fatal error in audioscrobbler: %s") % ex) log.err(_("Disable audioscrobbler plugin")) self.enabled = False break except AudioScrobblerError, ex: # log error and try later log.err(str(ex)) else: tracks = [] if not self.queue.empty(): if (int(time.time()) - self.last_request) > 3600: log.info(_("Last FM : force reauthentification")) self.authenticated = False continue for i in range(1,10): if self.queue.empty(): break tracks.append(self.queue.get()) if len(tracks): submission_failures = 0 while submission_failures < 3: try: self.submit_track(tracks) except AudioScrobblerError, err: log.err(_("Unable to submit songs: %s") % err) # wait 10 sec before retry self.should_stop.wait(10) submission_failures += 1 except AudioScrobblerFatalError, err: log.err(_("Fatal error in submission process: %s")\ % err) log.err(_("Force lastfm reauthentification")) submission_failures = 3 break else: break if submission_failures == 3: self.authenticated = False for track in tracks: self.queue.put(track) self.should_stop.wait(self.AUDIOSCROBBLER_WAIT_INTERVAL) def __get_stamp(self, track): stamp = str(int(time.time())--int(int(track['length'])/2)) return stamp.encode("utf-8") # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/plugins/__init__.py0000644000175000017500000000517111351210475016603 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import os, glob, ConfigParser from zope.interface import Interface, Attribute from deejayd.interfaces import DeejaydError class PluginError(DeejaydError): pass class PluginManager(object): def __init__(self, config): try: self.enabled_plugins = config.getlist("general", "enabled_plugins") except (ConfigParser.NoSectionError, ConfigParser.NoOptionError): self.enabled_plugins = [] def get_plugins(self, interface): plugins = [] base = os.path.dirname(__file__) base_import = "deejayd.plugins" modules = [os.path.basename(f[:-3]) \ for f in glob.glob(os.path.join(base, "[!_]*.py"))] for m in modules: mod = __import__(base_import+"."+m, {}, {}, base) for cls_name in dir(mod): cls = getattr(mod, cls_name) try: if interface.implementedBy(cls) \ and cls.NAME in self.enabled_plugins: plugins.append(cls) except: continue return plugins class IWebradioPlugin(Interface): NAME = Attribute("Name of the plugin") HAS_CATEGORIE = Attribute("Set to true if this module support categorie") def get_categories(self): """ return list of categories supported by this plugin raise an exception if no categorie has been supported""" def get_streams(self, categorie = None): """ return list of streams for a given categories """ class IPlayerPlugin(Interface): NAME = Attribute("Name of the plugin") def on_media_played(self, media): """ Call when a track has been played """ def close(self): """ Call when we closed the player """ # vim: ts=4 sw=4 expandtabdeejayd-0.10.0/deejayd/plugins/shoutcast.py0000644000175000017500000000720511351210475017061 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import urllib from zope.interface import implements from deejayd.xmlobject import ET from deejayd.plugins import PluginError, IWebradioPlugin # Without arg return genre list # with genre= return station list SHOUTCAST_URL_QUERY = "http://www.shoutcast.com/sbin/newxml.phtml" # Use id= to select stream SHOUTCAST_URL_STREAM = "http://www.shoutcast.com" SHOUTCAST_TUNEIN_BASE = "/sbin/tunein-station.pls" class ShoutcastPlugin(object): implements(IWebradioPlugin) NAME="shoutcast" HAS_CATEGORIE = True def __init__(self): self.__tunein = {} self.__categories = None self.__streams = {} def get_xml_page(self, url): try: page_handle = urllib.urlopen(url) xml_page = page_handle.read() except: raise PluginError(_("Unable to connect to shoutcast website")) # try to parse result try: root = ET.fromstring(xml_page) except ET.XMLSyntaxError: raise PluginError(_("Unable to parse shoutcast website page: %s")\ %xml_page) except: raise PluginError(_("Unable to read result from shoutcast website")) finally: page_handle.close() return root def get_categories(self): if self.__categories is None: root = self.get_xml_page(SHOUTCAST_URL_QUERY) if root is None: return [] self.__categories = [] for genre_elt in root.getchildren(): if "name" in genre_elt.attrib: self.__categories.append(genre_elt.attrib["name"]) return self.__categories def get_streams(self, categorie): if categorie == "": return [] if categorie not in self.__streams.keys(): # get streams for this cat root = self.get_xml_page(SHOUTCAST_URL_QUERY+"?genre="+categorie) if root is None: return [] self.__streams[categorie] = [] for station in root.getchildren(): if station.tag == "tunein": self.__tunein[categorie] = station.attrib["base"] elif station.tag == "station": tunein = categorie in self.__tunein.keys() and \ self.__tunein[categorie] or SHOUTCAST_TUNEIN_BASE self.__streams[categorie].append({ "title": station.attrib["name"], "type": "webradio", "url": SHOUTCAST_URL_STREAM + tunein + "?id="\ + station.attrib["id"], "url-type": "pls", "uri": "", }) return self.__streams[categorie] # vim: ts=4 sw=4 expandtabdeejayd-0.10.0/deejayd/interfaces.py0000644000175000017500000003554011351210475015511 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import locale class DeejaydError(Exception): """General purpose error structure.""" # Handle unicode messages, what Exceptions cannot. See Python issue at # http://bugs.python.org/issue2517 def __str__(self): if type(self.message) is unicode: return str(self.message.encode(locale.getpreferredencoding())) else: return str(self.message) def __unicode__(self): if type(self.message) is unicode: return self.message else: return unicode(self.message) class DeejaydAnswer(object): """General purpose core answer container.""" def __init__(self): self.contents = None self.error = False def set_error(self, msg): self.contents = msg self.error = True def get_contents(self): if self.error: raise DeejaydError(self.contents) return self.contents class DeejaydKeyValue(DeejaydAnswer): """Dictionnary answer.""" def __getitem__(self, name): self.get_contents() return self.contents[name] def keys(self): self.get_contents() return self.contents.keys() def items(self): self.get_contents() return self.contents.items() class DeejaydList(DeejaydAnswer): """List answer.""" def __len__(self): self.get_contents() return len(self.contents) def __iter__(self): self.get_contents() return self.contents.__iter__() class DeejaydFileList(DeejaydAnswer): """File list answer.""" def __init__(self): DeejaydAnswer.__init__(self) self.root_dir = "" self.files = [] self.directories = [] def set_rootdir(self, dir): self.root_dir = dir def add_file(self, file): self.files.append(file) def add_dir(self, dir): self.directories.append(dir) def set_files(self, files): self.files = files def set_directories(self, dirs): self.directories = dirs def get_files(self): self.get_contents() return self.files def get_directories(self): self.get_contents() return self.directories class DeejaydMediaList(DeejaydAnswer): """Media list answer.""" def __init__(self): DeejaydAnswer.__init__(self) self.medias = [] self.filter = None self.sort = None self.media_type = None def add_media(self, media): self.medias.append(media) def get_medias(self): self.get_contents() return self.medias def set_medias(self, medias): self.medias = medias def is_magic(self): self.get_contents() return self.filter != None def set_media_type(self, media_type): self.media_type = media_type def get_media_type(self): self.get_contents() return self.media_type def set_filter(self, filter): self.filter = filter def get_filter(self): self.get_contents() return self.filter def set_sort(self, sort): self.sort = sort def get_sort(self): self.get_contents() return self.sort class DeejaydDvdInfo(DeejaydAnswer): """Dvd information answer.""" def __init__(self): DeejaydAnswer.__init__(self) self.dvd_content = {} def set_dvd_content(self, infos): self.dvd_content.update(infos) def add_track(self, track): if "track" not in self.dvd_content.keys(): self.dvd_content['track'] = [] self.dvd_content['track'].append(track) def set_tracks(self, tracks): self.dvd_content['track'] = tracks def get_dvd_contents(self): self.get_contents() if "track" not in self.dvd_content.keys(): self.dvd_content['track'] = [] return self.dvd_content class DeejaydStaticPlaylist(object): """ Static playlist object """ type = "static" def get(self, first=0, length=-1): raise NotImplementedError def add_path(self, path): return self.add_paths([path]) def add_paths(self, paths): raise NotImplementedError def add_song(self, song_id): return self.add_songs([song_id]) def add_songs(self, song_ids): raise NotImplementedError class DeejaydMagicPlaylist(object): """ Magic playlist object """ type = "magic" def get(self, first=0, length=-1): raise NotImplementedError def add_filter(self, filter): raise NotImplementedError def remove_filter(self, filter): raise NotImplementedError def clear_filters(self): raise NotImplementedError def get_properties(self): raise NotImplementedError def set_property(self, key, value): raise NotImplementedError class DeejaydWebradioList(object): """Webradio list management.""" def get(self, first = 0, length = None): raise NotImplementedError def get_available_sources(self): raise NotImplementedError def get_source_categories(self, source_name): raise NotImplementedError def set_source(self, source_name): raise NotImplementedError def set_source_categorie(self, categorie): raise NotImplementedError def add_webradio(self, name, urls): raise NotImplementedError def delete_webradio(self, wr_id): return self.delete_webradios([wr_id]) def delete_webradios(self, wr_ids): raise NotImplementedError def clear(self): raise NotImplementedError class DeejaydQueue(object): """Queue management.""" def get(self, first = 0, length = None): raise NotImplementedError def add_path(self, path, pos = None): return self.add_paths([path], pos) def add_paths(self, paths, pos = None): raise NotImplementedError def add_song(self, song_id, pos = None): return self.add_songs([song_id], pos) def add_songs(self, song_ids, pos = None): raise NotImplementedError def load_playlist(self, pl_id, pos = None): return self.load_playlists([pl_id], pos) def load_playlists(self, pl_ids, pos = None): raise NotImplementedError def move(self, ids, new_pos): raise NotImplementedError def clear(self): raise NotImplementedError def del_song(self, id): return self.del_songs([id]) def del_songs(self, ids): raise NotImplementedError class DeejaydPanel(object): def get(self, first = 0, length = None): raise NotImplementedError def get_active_list(self): raise NotImplementedError def get_panel_tags(self): raise NotImplementedError def set_active_list(self, type, pl_id=""): raise NotImplementedError def set_panel_filters(self, tag, values): raise NotImplementedError def remove_panel_filters(self, tag): raise NotImplementedError def clear_panel_filters(self): raise NotImplementedError def set_search_filter(self, tag, value): raise NotImplementedError def clear_search_filter(self): raise NotImplementedError def set_sorts(self, sort): raise NotImplementedError class DeejaydPlaylistMode(object): def get(self, first = 0, length = None): raise NotImplementedError def save(self, name): raise NotImplementedError def add_path(self, path, pos = None): return self.add_paths([path], pos) def add_paths(self, paths, pos = None): raise NotImplementedError def add_song(self, song_id, pos = None): return self.add_songs([song_id], pos) def add_songs(self, song_ids, pos = None): raise NotImplementedError def load(self, pl_id, pos = None): return self.loads([pl_id], pos) def loads(self, pl_ids, pos = None): raise NotImplementedError def move(self, ids, new_pos): raise NotImplementedError def shuffle(self): raise NotImplementedError def clear(self): raise NotImplementedError def del_song(self, id): return self.del_songs([id]) def del_songs(self, ids): raise NotImplementedError class DeejaydVideo(object): """Video management.""" def get(self, first = 0, length = None): raise NotImplementedError def set(self, value, type = "directory"): raise NotImplementedError def set_sorts(self, sorts): raise NotImplementedError class DeejaydSignal(object): SIGNALS = ('player.status', # Player status change (play/pause/stop/ # random/repeat/volume/manseek) 'player.current', # Currently played song change 'player.plupdate', # The current playlist has changed 'playlist.listupdate', # The stored playlist list has changed # (either a saved playlist has been saved # or deleted). 'playlist.update', # A recorded playlist (static or magic) # has changed # set id of playlist as attribute 'webradio.listupdate', 'panel.update', 'queue.update', 'video.update', 'dvd.update', 'mode', # Mode change 'mediadb.aupdate', # Media library audio update 'mediadb.vupdate', # Media library video update 'mediadb.mupdate', # a media has been updated # set id and type of media as attribute # set type of update as attribute ) def __init__(self, name=None, attrs = {}): self.name = name self.attrs = attrs def set_name(self, name): self.name = name def get_name(self): return self.name def get_attrs(self): return self.attrs def get_attr(self, key): return self.attrs[key] def set_attr(self, key, value): self.attrs[key] = value class DeejaydCore(object): """Abstract class for a deejayd core.""" def __init__(self): self._clear_subscriptions() def ping(self): raise NotImplementedError def play_toggle(self): raise NotImplementedError def stop(self): raise NotImplementedError def previous(self): raise NotImplementedError def next(self): raise NotImplementedError def seek(self, pos, relative = False): raise NotImplementedError def get_current(self): raise NotImplementedError def go_to(self, id, id_type = None, source = None): raise NotImplementedError def set_volume(self, volume_value): raise NotImplementedError def set_option(self, source, option_name, option_value): raise NotImplementedError def set_mode(self, mode_name): raise NotImplementedError def get_mode(self): raise NotImplementedError def set_player_option(self, option_name, option_value): raise NotImplementedError def get_status(self): raise NotImplementedError def get_stats(self): raise NotImplementedError def update_audio_library(self, force = False, sync = False): raise NotImplementedError def update_video_library(self, force = False, sync = False): raise NotImplementedError def create_recorded_playlist(self, name, type): raise NotImplementedError def get_recorded_playlist(self, pl_id): raise NotImplementedError def erase_playlist(self, pl_ids): raise NotImplementedError def get_playlist_list(self): raise NotImplementedError def get_playlist(self): raise NotImplementedError def get_webradios(self): raise NotImplementedError def get_queue(self): raise NotImplementedError def get_panel(self): raise NotImplementedError def get_video(self): raise NotImplementedError def set_media_rating(self, media_ids, rating, type = "audio"): raise NotImplementedError def get_audio_dir(self, dir=None): raise NotImplementedError def get_audio_cover(self, media_id): raise NotImplementedError def audio_search(self, search_txt, type = 'all'): raise NotImplementedError def get_video_dir(self, dir=None): raise NotImplementedError def dvd_reload(self): raise NotImplementedError def mediadb_list(self, taglist, filter): raise NotImplementedError def get_dvd_content(self): raise NotImplementedError def __get_next_sub_id(self): sub_id = self.__sub_id_counter self.__sub_id_counter = self.__sub_id_counter + 1 return sub_id def subscribe(self, signal_name, callback): """Subscribe to a signal with a callback. Returns an id.""" if signal_name not in DeejaydSignal.SIGNALS: return DeejaydError('Unknown signal provided for subscription.') sub_id = self.__get_next_sub_id() self.__sig_subscriptions[sub_id] = (signal_name, callback) return sub_id def unsubscribe(self, sub_id): """Unsubscribe using the provied id.""" try: del self.__sig_subscriptions[sub_id] except IndexError: raise DeejaydError('Unknown subscription id') def get_subscriptions(self): """Get the list of currently subcribed signals for this instance.""" return dict([(sub_id, sub[0]) for (sub_id, sub)\ in self.__sig_subscriptions.items()]) def _clear_subscriptions(self): self.__sig_subscriptions = {} self.__sub_id_counter = 0 def _dispatch_signal(self, signal): for cb in [sub[1] for sub in self.__sig_subscriptions.values()\ if sub[0] == signal.get_name()]: cb(signal) def _dispatch_signame(self, signal_name, attrs = {}): self._dispatch_signal(DeejaydSignal(signal_name, attrs)) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/webui/0000755000175000017500000000000011354730161014122 5ustar royroydeejayd-0.10.0/deejayd/webui/jsonrpc.py0000644000175000017500000001075011351210475016153 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """A generic resource for publishing objects via JSON-RPC. Requires simplejson; can be downloaded from http://cheeseshop.python.org/pypi/simplejson """ from __future__ import nested_scopes # System Imports import urlparse # Sibling Imports from twisted.web import resource, server from twisted.internet import defer, protocol, reactor from twisted.web import http from deejayd.ui import log from deejayd.rpc import Fault from deejayd.rpc.jsonparsers import loads_request from deejayd.rpc.jsonbuilders import JSONRPCResponse from deejayd.rpc import protocol as deejayd_protocol class Handler: """Handle a JSON-RPC request and store the state for a request in progress. Override the run() method and return result using self.result, a Deferred. We require this class since we're not using threads, so we can't encapsulate state in a running function if we're going to have to wait for results. For example, lets say we want to authenticate against twisted.cred, run a LDAP query and then pass its result to a database query, all as a result of a single JSON-RPC command. We'd use a Handler instance to store the state of the running command. """ def __init__(self, resource, *args): self.resource = resource # the JSON-RPC resource we are connected to self.result = defer.Deferred() self.run(*args) def run(self, *args): # event driven equivalent of 'raise UnimplementedError' self.result.errback(NotImplementedError("Implement run() in subclasses")) class JSONRPC(resource.Resource, deejayd_protocol.DeejaydHttpJSONRPC): """A resource that implements JSON-RPC. Methods published can return JSON-RPC serializable results, Faults, Binary, Boolean, DateTime, Deferreds, or Handler instances. By default methods beginning with 'jsonrpc_' are published. Sub-handlers for prefixed methods (e.g., system.listMethods) can be added with putSubHandler. By default, prefixes are separated with a '.'. Override self.separator to change this. """ # Error codes for Twisted, if they conflict with yours then # modify them at runtime. NOT_FOUND = 8001 FAILURE = 8002 isLeaf = 1 def __init__(self, deejayd): super(JSONRPC, self).__init__() self.subHandlers = {} self.deejayd_core = deejayd def render(self, request): request.content.seek(0, 0) # Unmarshal the JSON-RPC data content = request.content.read() try: parsed = loads_request(content) args, functionPath = parsed['params'], parsed["method"] function = self._getFunction(functionPath) except Fault, f: try: id = parsed["id"] except: id = None self._cbRender(f, request, id) else: request.setHeader("content-type", "text/json") defer.maybeDeferred(function, *args).addErrback( self._ebRender, parsed["id"] ).addCallback( self._cbRender, request, parsed["id"] ) return server.NOT_DONE_YET def _cbRender(self, result, request, id): if isinstance(result, Handler): result = result.result # build json answer ans = JSONRPCResponse(result, id).to_json() request.setHeader("content-length", str(len(ans))) request.write(ans) request.finish() def _ebRender(self, failure, id): if isinstance(failure.value, Fault): return failure.value log.err(failure) return Fault(self.FAILURE, "error") __all__ = ["JSONRPC", "Handler"] # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/webui/mobile.py0000644000175000017500000003036611351210475015751 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. #from deejayd.utils import str_encode class IphoneBrowser(object): def is_true(self, user_agent): if user_agent.lower().find("iphone") != -1: return True return False def header(self): return """ """ class WebkitBrowser(object): def is_true(self, user_agent): if user_agent.lower().find("applewebkit") != -1: return True return False def header(self): return """ """ class DefaultBrowser(object): def is_true(self, user_agent): return True def header(self): return "" browsers = [IphoneBrowser(), WebkitBrowser(), DefaultBrowser()] def build_template(deejayd, user_agent): global browsers for bw in browsers: if bw.is_true(user_agent): browser = bw break # build modelist mode_list = deejayd.get_mode().get_contents() av_modes = [k for (k, v) in mode_list.items() if int(v) == 1] mode_title = { "playlist": _("Playlist Mode"), "panel": _("Panel Mode"), "video": _("Video Mode"), "webradio": _("Webradio Mode"), "dvd": _("DVD Mode"), } modes = [] button = """

%(title)s
""" for m in av_modes: modes.append(button % {"mode": m, "title": mode_title[m]}) tpl = """ Deejayd Webui %(header)s
%(current-mode)s
%(no-playing-media)s
%(refresh)s
""" % { "header": browser.header(), "now-playing": _("Now Playing"), "no-playing-media": _("No Playing Media"), "mode-list": _("Mode List"), "current-mode": _("Current Mode"), "modelist-content": "\n".join(modes), "close": _("Close"), "refresh": _("Refresh"), # options "in-order": _("In Order"), "random": _("Random"), "wrandom": _("Weighted Random"), "one-media": _("One Media"), "repeat": _("Repeat"), "save-options": _("Save Options"), "play-order": _("Play Order"), # js localisation "loading": _("Loading..."), "load-files": _("Load Files"), "audio-library": _("Audio Library"), "video-library": _("Video Library"), "search": _("Search"), "pls-mode": _("Playlist Mode"), "video-mode": _("Video Mode"), "wb-mode": _("Webradio Mode"), "panel-mode": _("Panel Mode"), "dvd-mode": _("DVD Mode"), "wb-name": _("Webradio Name"), "wb-url": _("Webradio URL"), "add": _("Add"), "wb-add": _("Add a Webradio"), "all": _("All"), "various": _("Various Artist"), "unknown": _("Unknown"), "genre": _("Genre"), "artist": _("Artist"), "album": _("Album"), } return str(tpl.encode('utf-8')) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/webui/__init__.py0000644000175000017500000001145511351210475016237 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import os,shutil from twisted.web import static,server from twisted.web.resource import Resource from deejayd.interfaces import DeejaydError from deejayd.ui import log # jsonrpc import from deejayd.webui.jsonrpc import JSONRPC from deejayd.rpc.protocol import build_protocol, set_web_subhandler # xul parts from deejayd.webui.xul import build as xul_build # mobile parts from deejayd.webui import mobile class DeejaydWebError(DeejaydError): pass class DeejaydMainHandler(Resource): def getChild(self, name, request): if name == '': return self return Resource.getChild(self,name,request) def render_GET(self, request): user_agent = request.getHeader("user-agent"); root = request.prepath[-1] != '' and request.path + '/' or request.path if user_agent.lower().find("mobile") != -1: # redirect to specific mobile interface request.redirect(root + 'm/') return 'redirected' else: # default xul interface request.redirect(root + 'xul/') return 'redirected' class DeejaydXulHandler(Resource): def __init__(self, config): Resource.__init__(self) self.__config = config def getChild(self, name, request): if name == '': return self return Resource.getChild(self,name,request) def render_GET(self, request): request.setHeader("Content-Type", "application/vnd.mozilla.xul+xml") return xul_build(self.__config) class DeejaydMobileHandler(Resource): def __init__(self, deejayd, config): Resource.__init__(self) self.__deejayd = deejayd self.__config = config def getChild(self, name, request): if name == '': return self return Resource.getChild(self,name,request) def render_GET(self, request): # Trailing slash is required for js script paths in the mobile webui, # therefore we need to add it if it is missing, by issuing a redirect # to the web browser. if request.prepath[-1] != '': request.redirect(request.path + '/') return 'redirected' user_agent = request.getHeader("user-agent"); return mobile.build_template(self.__deejayd, user_agent) class SiteWithCustomLogging(server.Site): def _openLogFile(self, path): self.log_file = log.LogFile(path, False) self.log_file.set_reopen_signal(callback=self.__reopen_cb) return self.log_file.fd def __reopen_cb(self, signal, frame): self.log_file.reopen() # Change the logfile fd from HTTPFactory internals. self.logFile = self.log_file.fd def init(deejayd_core, config, webui_logfile, htdocs_dir): # create tmp directory tmp_dir = config.get("webui","tmp_dir") if os.path.isdir(tmp_dir): try: shutil.rmtree(tmp_dir) except (IOError, OSError): raise DeejaydWebError(_("Unable to remove tmp directory %s") % \ tmp_dir) try: os.mkdir(tmp_dir) except IOError: raise DeejaydWebError(_("Unable to create tmp directory %s") % tmp_dir) if not os.path.isdir(htdocs_dir): raise DeejaydWebError(_("Htdocs directory %s does not exists") % \ htdocs_dir) # main handler main_handler = DeejaydMainHandler() # json-rpc handler rpc_handler = JSONRPC(deejayd_core) rpc_handler = build_protocol(deejayd_core, rpc_handler) rpc_handler = set_web_subhandler(deejayd_core, tmp_dir, rpc_handler) main_handler.putChild("rpc",rpc_handler) # statics url main_handler.putChild("tmp",static.File(tmp_dir)) main_handler.putChild("static",static.File(htdocs_dir)) # xul part xul_handler = DeejaydXulHandler(config) main_handler.putChild("xul", xul_handler) # mobile part mobile_handler = DeejaydMobileHandler(deejayd_core, config) main_handler.putChild("m", mobile_handler) return SiteWithCustomLogging(main_handler, logPath=webui_logfile) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/webui/xul.py0000644000175000017500000000660111351210475015305 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from deejayd import __version__ as VERSION def build(config): return """ %(install)s %(clickHere)s """ % { "refresh": config.get('webui','refresh'), "version": VERSION, "install": _("You need to install a firefox extension in order to use the deejayd-webui XUL client. Please note that if you run a flavour of GNU/Linux, it should be available from your package manager.").\ encode("utf-8"), "upgrade": _("You need to upgrade the firefox extension.").\ encode("utf-8"), "clickHere": _("Install the deejayd-webui extension").encode("utf-8"), "error": _("ERROR : Host is not allowed to use the firefox extension.").\ encode("utf-8"), } # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/rpc/0000755000175000017500000000000011354730161013573 5ustar royroydeejayd-0.10.0/deejayd/rpc/protocol.py0000644000175000017500000011650511351210475016014 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import os, re from deejayd import __version__ from deejayd.interfaces import DeejaydError from deejayd.mediafilters import * from deejayd.rpc import Fault, INTERNAL_ERROR, INVALID_METHOD_PARAMS,\ DEEJAYD_PROTOCOL_VERSION from deejayd.rpc.jsonbuilders import Get_json_filter from deejayd.rpc.jsonparsers import Parse_json_filter from deejayd.rpc.jsonrpc import JSONRPC, addIntrospection from deejayd.rpc.rdfbuilder import modes as rdf_modes def returns_answer(type, params = None): def returns_answer_instance(func): def returns_answer_func(*__args, **__kw): # verify arguments if params: for idx, p in enumerate(params): try: arg = __args[idx+1] except IndexError: if p["req"] is True: raise Fault(INVALID_METHOD_PARAMS,\ _("Param %s is required") % p["name"]) break else: if p['type'] == "int": try: int(arg) except (ValueError,TypeError): raise Fault(INVALID_METHOD_PARAMS,\ _("Param %s is not an int") % p["name"]) elif p['type'] in ("bool", "list", "dict"): types = {"bool": bool, "dict": dict, "list": list} if not isinstance(arg, types[p['type']]): raise Fault(INVALID_METHOD_PARAMS,\ _("Param %s has wrong type") % p["name"]) elif p['type'] == "int-list": if not isinstance(arg, list): raise Fault(INVALID_METHOD_PARAMS,\ _("Param %s is not a list") % p["name"]) try: map(int, arg) except (ValueError,TypeError): raise Fault(INVALID_METHOD_PARAMS,\ _("Param %s is not an int-list") % p["name"]) try: res = func(*__args, **__kw) except DeejaydError, txt: raise Fault(INTERNAL_ERROR, str(txt)) if type == "ack": res = True return {"type": type, "answer": res} returns_answer_func.__name__ = func.__name__ returns_answer_func.__doc__ = func.__doc__ returns_answer_func.answer_type = type returns_answer_func.params = params # build help p_desc = "\nNo arguments" if params: p_desc = "\nArguments : \n%s" %\ "\n".join([" * name '%s', type '%s', required '%s'" %\ (p["name"], p["type"], p["req"]) for p in params]) returns_answer_func.help = """ Description : %(description)s Answer type : %(answer)s %(p_desc)s""" % {\ "description": func.__doc__,\ "answer": type,\ "p_desc": p_desc,\ } # build signature p_dict = { "list": "array", "int-list": "array", "dict": "object", "string": "string", "int": "number", "bool": "boolean", "filter": "object", "sort": "array", } if params: signature = [] opt_params = [p_dict[p["type"]] for p in params if not p["req"]] for i in range(len(opt_params)+1): s = ["object"] s.extend([p_dict[p["type"]] for p in params if p["req"]]) s.extend(opt_params[:i]) signature.append(s) else: signature = [["object"]] returns_answer_func.signature = signature return returns_answer_func return returns_answer_instance class _DeejaydJSONRPC(JSONRPC): def __init__(self, deejayd = None): super(_DeejaydJSONRPC, self).__init__() self.deejayd_core = deejayd class _DeejaydMainJSONRPC(_DeejaydJSONRPC): @returns_answer('ack') def jsonrpc_ping(self): """Does nothing, just replies with an acknowledgement that the command was received""" return None @returns_answer('ack', params=[{"name":"mode","type":"string","req":True}]) def jsonrpc_setmode(self, mode): """Change the player mode. Possible values are : * playlist : to manage and listen songs in playlist * panel : to manage and listen songs in panel mode (like itunes) * video : to manage and wath video file * dvd : to wath dvd * webradio : to manage and listen webradios""" self.deejayd_core.set_mode(mode, objanswer=False) @returns_answer('dict') def jsonrpc_availablemodes(self): """For each available source, shows if it is activated or not. The answer consists in : * playlist : _bool_ true or false * panel : _bool_ true or false * webradio : _bool_ true or false (media backend has to be abble to read url streams) * video : _bool_ true or false (needs video dependencies, X display and needs to be activated in configuration) * dvd : _bool_ true or false (media backend has to be able to read dvd)""" return dict(self.deejayd_core.get_mode(objanswer=False)) @returns_answer('dict') def jsonrpc_status(self): """Return status of deejayd. Given informations are : * playlist : _int_ id of the current playlist * playlistlength : _int_ length of the current playlist * playlisttimelength : _int_ time length of the current playlist * playlistrepeat : _bool_ false (not activated) or true (activated) * playlistplayorder : inorder | random | onemedia | random-weighted * webradio : _int_ id of the current webradio list * webradiolength : _int_ number of recorded webradio * webradiosource : _str_ current source for webradio streams * webradiosourcecat : _str_ current categorie for webradio * queue : _int_ id of the current queue * queuelength : _int_ length of the current queue * queuetimelength : _int_ time length of the current queue * queueplayorder : _str_ inorder | random * video : _int_ id of the current video list * videolength : _int_ length of the current video list * videotimelength : _int_ time length of the current video list * videorepeat : _bool_ false (not activated) or true (activated) * videoplayorder : inorder | random | onemedia | random-weighted * dvd : _int_ id of the current dvd * dvdlength : _int_ number of tracks on the current dvd * volume : `[0-100]` current volume value * state : [play-pause-stop] the current state of the player * current : _int_:_int_:_str_ current media pos : current media file id : playing source name * time : _int_:_int_ position:length of the current media file * mode : [playlist-webradio-video] the current mode * audio_updating_db : _int_ show when a audio library update is in progress * audio_updating_error : _string_ error message that apppears when the audio library update has failed * video_updating_db : _int_ show when a video library update is in progress * video_updating_error : _string_ error message that apppears when the video library update has failed""" return dict(self.deejayd_core.get_status(objanswer = False)) @returns_answer('dict') def jsonrpc_stats(self): """Return statistical informations : * audio_library_update : UNIX time of the last audio library update * video_library_update : UNIX time of the last video library update * videos : number of videos known by the database * songs : number of songs known by the database * artists : number of artists in the database * albums : number of albums in the database""" return dict(self.deejayd_core.get_stats(objanswer = False)) @returns_answer('ack', [{"name":"source", "type":"string", "req":True},\ {"name":"option_name", "type":"string","req":True},\ {"name":"option_value","type":"string","req":True}]) def jsonrpc_setOption(self, source, option_name, option_value): """Set player options "name" to "value" for mode "source", Available options are : * playorder (_str_: inorder, onemedia, random or random-weighted) * repeat (_bool_: True or False)""" self.deejayd_core.set_option(source, option_name,\ option_value, objanswer=False) @returns_answer('ack', params=[\ {"name":"ids", "type":"int-list", "req": True},\ {"name": "value", "type": "int", "req": True},\ {"name": "type", "type": "string", "req": False}]) def jsonrpc_setRating(self, ids, value, type = "audio"): """Set rating of media file with ids equal to media_id for library 'type' """ self.deejayd_core.set_media_rating(ids, value, type, objanswer=False) class DeejaydTcpJSONRPC(_DeejaydMainJSONRPC): @returns_answer('ack') def jsonrpc_close(self): """Close the connection with the server""" return None class DeejaydHttpJSONRPC(_DeejaydMainJSONRPC): @returns_answer('dict') def jsonrpc_serverInfo(self): """Return deejayd server informations : * server_version : deejayd server version * protocol_version : protocol version""" return { "server_version": __version__, "protocol_version": DEEJAYD_PROTOCOL_VERSION } # # Player commands # class DeejaydPlayerJSONRPC(_DeejaydJSONRPC): @returns_answer('ack') def jsonrpc_playToggle(self): """Toggle play/pause.""" self.deejayd_core.play_toggle(objanswer=False) @returns_answer('ack') def jsonrpc_stop(self): """Stop playing.""" self.deejayd_core.stop(objanswer=False) @returns_answer('ack') def jsonrpc_previous(self): """Go to previous song or webradio.""" self.deejayd_core.previous(objanswer=False) @returns_answer('ack') def jsonrpc_next(self): """Go to next song or webradio.""" self.deejayd_core.next(objanswer=False) @returns_answer('ack', params=[{"name":"pos", "type":"int", "req":True},\ {"name":"relative", "type":"bool", "req":False}]) def jsonrpc_seek(self, pos, relative = False): """Seeks to the position "pos" (in seconds) of the current media set relative argument to true to set new pos in relative way""" self.deejayd_core.seek(pos, relative, objanswer=False) @returns_answer('mediaList') def jsonrpc_current(self): """Return informations on the current song, webradio or video info.""" medias = self.deejayd_core.get_current(objanswer=False) media_type = len(medias) == 1 and medias[0]["type"] or None return { "media_type": media_type, "medias": medias, "filter": None, "sort": None, } @returns_answer('ack',params=[\ {"name":"id", "type":"string", "req":True},\ {"name":"id_type","type":"string", "req":False},\ {"name":"source","type":"string","req":False}]) def jsonrpc_goto(self, id, id_type = "id", source = None): """Begin playing at media file with id "id" or toggle play/pause.""" if not re.compile("^\w{1,}|\w{1,}\.\w{1,}$").search(id): raise Fault(INVALID_METHOD_PARAMS, _("Wrong id parameter")) self.deejayd_core.go_to(id, id_type, source, objanswer=False) @returns_answer('ack', params=[{"name":"volume", "type":"int", "req":True}]) def jsonrpc_setVolume(self, volume): """Set volume to "volume". The volume range is 0-100.""" self.deejayd_core.set_volume(volume, objanswer=False) @returns_answer('ack', params=[\ {"name":"option_name", "type":"string", "req":True},\ {"name":"option_value", "type":"string", "req":True}]) def jsonrpc_setPlayerOption(self, option_name, option_value): """Set player option for the current media. Possible options are : * zoom : set zoom (video only), min=-85, max=400 * audio_lang : select audio channel (video only) * sub_lang : select subtitle channel (video only) * av_offset : set audio/video offset (video only) * sub_offset : set subtitle/video offset (video only) * aspect_ratio : set video aspect ratio (video only), available values are : * auto * 1:1 * 16:9 * 4:3 * 2.11:1 (for DVB)""" self.deejayd_core.set_player_option(option_name, option_value,\ objanswer=False) # # media library # class _DeejaydLibraryJSONRPC(_DeejaydJSONRPC): type = "" @returns_answer('dict', params=[{"name":"force","type":"bool","req":False}]) def jsonrpc_update(self, force = False): """Update the library. * 'type'_updating_db : the id of this task. It appears in the status until the update are completed.""" func = getattr(self.deejayd_core, "update_%s_library"%self.type) return dict(func(force=force, objanswer=False)) @returns_answer('fileAndDirList',\ params=[{"name":"directory","type":"string","req":False}]) def jsonrpc_getDir(self, directory = ""): """List the files of the directory supplied as argument.""" func = getattr(self.deejayd_core, "get_%s_dir"%self.type) root_dir, dirs, files = func(directory, objanswer=False) type = self.type == "audio" and "song" or self.type return { "type": type, "files": files, "directories": dirs, "root": root_dir, } @returns_answer('mediaList',\ params=[{"name":"pattern", "type":"string", "req":True},\ {"name":"type","type":"string","req":True}]) def jsonrpc_search(self, pattern, type): """Search files in library where "type" contains "pattern" content.""" func = getattr(self.deejayd_core, "%s_search"%self.type) medias = func(pattern, type, objanswer=False) type = self.type == "audio" and "song" or self.type return {"media_type": type, "medias": medias} class DeejaydAudioLibraryJSONRPC(_DeejaydLibraryJSONRPC): type = "audio" @returns_answer('list',\ params=[{"name":"tag", "type":"string", "req":True},\ {"name":"filter","type":"filter","req":False}]) def jsonrpc_taglist(self, tag, filter = None): """List all the possible values for a tag according to the optional filter argument.""" if filter is not None: filter = Parse_json_filter(filter) tag_list = self.deejayd_core.mediadb_list(tag, filter, objanswer=False) return list(tag_list) class DeejaydVideoLibraryJSONRPC(_DeejaydLibraryJSONRPC): type = "video" # # generic class for modes # class _DeejaydModeJSONRPC(_DeejaydJSONRPC): def __init__(self, deejayd): super(_DeejaydModeJSONRPC, self).__init__(deejayd) self.source = getattr(self.deejayd_core, "get_%s"%self.source_name)() @returns_answer('mediaList',\ params=[{"name":"first", "type":"int", "req":False},\ {"name":"length","type":"int","req":False}]) def jsonrpc_get(self, first=0, length=-1): """Return the content of this mode.""" res = self.source.get(first, length, objanswer=False) if isinstance(res, tuple): medias, filter, sort = res else: medias, filter, sort = res, None, None json_filter = filter is not None and \ Get_json_filter(filter).dump() or None return { "media_type": self.media_type, "medias": medias, "filter": json_filter, "sort": sort, } # # Dvd commands # class DeejaydDvdModeJSONRPC(_DeejaydJSONRPC): @returns_answer('dvdInfo') def jsonrpc_get(self): """Get the content of the current dvd.""" return self.deejayd_core.get_dvd_content(objanswer=False) @returns_answer('ack') def jsonrpc_reload(self): """Load the content of the dvd player.""" self.deejayd_core.dvd_reload(objanswer=False) # # video command # class DeejaydVideoModeJSONRPC(_DeejaydModeJSONRPC): media_type = "video" source_name = "video" @returns_answer('ack',\ params=[{"name":"value", "type":"string", "req":True},\ {"name":"type","type":"string","req":False}]) def jsonrpc_set(self, value, type="directory"): """Set content of video mode""" self.source.set(value, type, objanswer=False) @returns_answer('ack', params=[{"name":"sort", "type":"sort", "req":True}]) def jsonrpc_sort(self, sort): """Sort active medialist in video mode""" self.source.set_sorts(sort, objanswer=False) # # playlist mode command # class DeejaydPanelModeJSONRPC(_DeejaydModeJSONRPC): media_type = "song" source_name = "panel" @returns_answer('list') def jsonrpc_tags(self): """Return tag list used in panel mode.""" tags = self.source.get_panel_tags(objanswer=False) return list(tags) @returns_answer('dict') def jsonrpc_activeList(self): """Return active list in panel mode * type : 'playlist' if playlist is choosen as active medialist 'panel' if panel navigation is active * value : if 'playlist' is selected, return used playlist id""" return dict(self.source.get_active_list(objanswer=False)) @returns_answer('ack', [{"name":"type", "type":"string", "req":True},\ {"name":"value", "type":"string", "req":False}]) def jsonrpc_setActiveList(self, type, value = ""): """Set the active list in panel mode""" self.source.set_active_list(type, value, objanswer=False) @returns_answer('ack', [{"name":"tag", "type":"string", "req":True},\ {"name":"values", "type":"list", "req":True}]) def jsonrpc_setFilter(self, tag, values): """Set a filter for panel mode""" self.source.set_panel_filters(tag, values, objanswer=False) @returns_answer('ack', [{"name":"tag", "type":"string", "req":True},]) def jsonrpc_removeFilter(self, tag): """Remove a filter for panel mode""" self.source.remove_panel_filters(tag, objanswer=False) @returns_answer('ack') def jsonrpc_clearFilter(self): """Clear filters for panel mode""" self.source.clear_panel_filters(objanswer=False) @returns_answer('ack', [{"name":"tag", "type":"string", "req":True},\ {"name":"value", "type":"string", "req":True}]) def jsonrpc_setSearch(self, tag, value): """Set search filter in panel mode""" self.source.set_search_filter(tag, value, objanswer=False) @returns_answer('ack') def jsonrpc_clearSearch(self): """Clear search filter in panel mode""" self.source.clear_search_filter(objanswer=False) @returns_answer('ack') def jsonrpc_clearAll(self): """Clear search filter and panel filters""" self.source.clear_search_filter(objanswer=False) self.source.clear_panel_filters(objanswer=False) @returns_answer('ack', [{"name":"sort","type":"sort","req":True},]) def jsonrpc_setSort(self, sort): """Sort active medialist in panel mode""" self.source.set_sorts(sort, objanswer=False) # # playlist mode command # class DeejaydPlaylistModeJSONRPC(_DeejaydModeJSONRPC): media_type = "song" source_name = "playlist" @returns_answer('ack') def jsonrpc_clear(self): """Clear the current playlist.""" self.source.clear(objanswer=False) @returns_answer('ack') def jsonrpc_shuffle(self): """Shuffle the current playlist.""" self.source.shuffle(objanswer=False) @returns_answer('dict',\ params=[{"name":"pls_name", "type":"string", "req":True}]) def jsonrpc_save(self, pls_name): """Save the current playlist to "pls_name" in the database. * playlist_id : id of the recorded playlist""" return dict(self.source.save(pls_name, objanswer=False)) @returns_answer('ack', params=[\ {"name":"pl_ids", "type":"int-list", "req":True}, {"name":"pos", "type":"int", "req":False}]) def jsonrpc_loads(self, pl_ids, pos = None): """Load playlists passed as arguments "name" at the position "pos".""" self.source.loads(pl_ids, pos, objanswer=False) @returns_answer('ack', params=[\ {"name":"paths", "type":"list", "req":True}, {"name":"pos", "type":"int", "req":False}]) def jsonrpc_addPath(self, paths, pos = None): """Load files or directories passed as arguments ("paths") at the position "pos" in the current playlist.""" self.source.add_paths(paths, pos,objanswer=False) @returns_answer('ack', params=[\ {"name":"ids", "type":"int-list", "req":True}, {"name":"pos", "type":"int", "req":False}]) def jsonrpc_addIds(self, ids, pos = None): """Load files with id passed as arguments ("ids") at the position "pos" in the current playlist.""" self.source.add_songs(ids, pos, objanswer=False) @returns_answer('ack', params=[{"name":"ids","type":"int-list","req":True}]) def jsonrpc_remove(self, ids): """Remove songs with ids passed as argument ("ids") from the current playlist""" self.source.del_songs(ids, objanswer=False) @returns_answer('ack', params=[\ {"name":"ids", "type":"int-list", "req":True}, {"name":"pos", "type":"int", "req":True}]) def jsonrpc_move(self, ids, pos): """Move songs with id in "ids" to position "pos".""" self.source.move(ids, pos, objanswer=False) # # webradios commands # class DeejaydWebradioModeJSONRPC(_DeejaydModeJSONRPC): media_type = "webradio" source_name = "webradios" @returns_answer('dict') def jsonrpc_getAvailableSources(self): """Return list of available sources for webradio mode as source_name: has_categories""" return self.source.get_available_sources(objanswer=False) @returns_answer('list',\ params=[{"name":"source_name","type":"string","req":True}]) def jsonrpc_getSourceCategories(self, source_name): """Return list of categories for webradio source 'source_name'""" return self.source.get_source_categories(source_name, objanswer=False) @returns_answer('ack', \ params=[{"name":"source_name","type":"string","req":True}]) def jsonrpc_setSource(self, source_name): """Set current source to 'source_name'""" self.source.set_source(source_name, objanswer=False) @returns_answer('ack', \ params=[{"name":"categorie","type":"string","req":True}]) def jsonrpc_setSourceCategorie(self, categorie): """Set categorie to 'categorie' for current source""" self.source.set_source_categorie(categorie, objanswer=False) @returns_answer('ack') def jsonrpc_localClear(self): """Remove all recorded webradios from the 'local' source.""" self.source.clear(objanswer=False) @returns_answer('ack', params=[{"name":"ids","type":"int-list","req":True}]) def jsonrpc_localRemove(self, ids): """Remove webradios with id in "ids" from the 'local' source.""" self.source.delete_webradios(ids, objanswer=False) @returns_answer('ack', params=[{"name":"name","type":"string","req":True},\ {"name":"url", "type":"list", "req":True}]) def jsonrpc_localAdd(self, name, urls): """Add a webradio in 'local' source. Its name is "name" and the url of the webradio is "url". You can pass a playlist for "url" argument (.pls and .m3u format are supported).""" self.source.add_webradio(name, urls, objanswer=False) # # queue commands # class DeejaydQueueJSONRPC(_DeejaydModeJSONRPC): media_type = "song" source_name = "queue" @returns_answer('ack', params=[\ {"name":"paths", "type":"list", "req":True}, {"name":"pos", "type":"int", "req":False}]) def jsonrpc_addPath(self, paths, pos = None): """Load files or directories passed as arguments ("paths") at the position "pos" in the queue.""" self.source.add_paths(paths, pos,objanswer=False) @returns_answer('ack', params=[\ {"name":"ids", "type":"int-list", "req":True}, {"name":"pos", "type":"int", "req":False}]) def jsonrpc_addIds(self, ids, pos = None): """Load files with id passed as arguments ("ids") at the position "pos" in the queue.""" self.source.add_songs(ids, pos, objanswer=False) @returns_answer('ack', params=[\ {"name":"pl_ids", "type":"int-list", "req":True}, {"name":"pos", "type":"int", "req":False}]) def jsonrpc_loads(self, pl_ids, pos = None): """Load playlists passed as arguments "name" at the position "pos" in the queue.""" self.source.load_playlists(pl_ids, pos, objanswer=False) @returns_answer('ack', params=[{"name":"ids","type":"int-list","req":True}]) def jsonrpc_remove(self, ids): """Remove songs with ids passed as argument ("ids") from the queue""" self.source.del_songs(ids, objanswer=False) @returns_answer('ack', params=[\ {"name":"ids", "type":"int-list", "req":True}, {"name":"pos", "type":"int", "req":True}]) def jsonrpc_move(self, ids, pos): """Move songs with id in "ids" to position "pos".""" self.source.move(ids, pos, objanswer=False) @returns_answer('ack') def jsonrpc_clear(self): """Clear the queue.""" self.source.clear(objanswer=False) # # recorded playlist # class DeejaydRecordedPlaylistJSONRPC(_DeejaydJSONRPC): @returns_answer('mediaList') def jsonrpc_list(self): """Return the list of recorded playlists.""" pls = self.deejayd_core.get_playlist_list(objanswer=False) return { "media_type": 'playlist', "medias": pls, "filter": None, "sort": None, } @returns_answer('dict', [{"name":"name", "type":"string", "req":True},\ {"name":"type","type":"string","req":True}]) def jsonrpc_create(self, name, type): """Create recorded playlist. The answer consist on * pl_id : id of the created playlist * name : name of the created playlist * type : type of the created playlist""" pl_infos = self.deejayd_core.create_recorded_playlist(\ name, type, objanswer=False) return dict(pl_infos) @returns_answer('ack', [{"name":"pl_ids", "type":"int-list", "req":True}]) def jsonrpc_erase(self, pl_ids): """Erase recorded playlists passed as arguments.""" self.deejayd_core.erase_playlist(pl_ids, objanswer=False) @returns_answer('mediaList', [{"name":"pl_id", "type":"int", "req":True},\ {"name":"first", "type":"int", "req":False},\ {"name":"length","type":"int","req":False}]) def jsonrpc_get(self, pl_id, first = 0, length = -1): """Return the content of a recorded playlist.""" pls = self.deejayd_core.get_recorded_playlist(pl_id) if pls.type == "static": songs = pls.get(first, length, objanswer=False) filter, sort = None, None elif pls.type == "magic": songs, filter, sort = pls.get(first, length, objanswer=False) json_filter = filter is not None and \ Get_json_filter(filter).dump() or None return { "media_type": 'song', "medias": songs, "filter": json_filter, "sort": sort, } @returns_answer('ack', params=[{"name":"pl_id", "type":"int", "req":True},\ {"name":"values", "type":"list", "req":True},\ {"name":"type","type":"string","req":False}]) def jsonrpc_staticAdd(self, pl_id, values, type = "path"): """Add songs in a recorded static playlist. Argument 'type' has to be 'path' (default) or 'id'""" if type not in ("id", "path"): raise Fault(INVALID_METHOD_PARAMS,\ _("Param 'type' has a wrong value")) pls = self.deejayd_core.get_recorded_playlist(pl_id) if pls.type == "magic": raise Fault(INVALID_METHOD_PARAMS,\ _("Selected playlist is not static.")) if type == "id": try: values = map(int, values) except (TypeError, ValueError): raise Fault(INVALID_METHOD_PARAMS,\ _("values arg must be integer")) pls.add_songs(values, objanswer=False) else: pls.add_paths(values, objanswer=False) @returns_answer('ack', params=[{"name":"pl_id", "type":"int", "req":True},\ {"name":"filter","type":"filter","req":True}]) def jsonrpc_magicAddFilter(self, pl_id, filter): """Add a filter in recorded magic playlist.""" pls = self.deejayd_core.get_recorded_playlist(pl_id) if pls.type != "magic": raise Fault(INVALID_METHOD_PARAMS,\ _("Selected playlist is not magic.")) if filter is not None: filter = Parse_json_filter(filter) pls.add_filter(filter, objanswer=False) @returns_answer('ack', params=[{"name":"pl_id", "type":"int", "req":True},\ {"name":"filter","type":"filter","req":True}]) def jsonrpc_magicRemoveFilter(self, pl_id, filter): """Remove a filter from recorded magic playlist.""" pls = self.deejayd_core.get_recorded_playlist(pl_id) if pls.type != "magic": raise Fault(INVALID_METHOD_PARAMS,\ _("Selected playlist is not magic.")) if filter is not None: filter = Parse_json_filter(filter) pls.remove_filter(filter, objanswer=False) @returns_answer('ack', params=[{"name":"pl_id", "type":"int", "req":True}]) def jsonrpc_magicClearFilter(self, pl_id): """Remove all filter from recorded magic playlist.""" pls = self.deejayd_core.get_recorded_playlist(pl_id) if pls.type != "magic": raise Fault(INVALID_METHOD_PARAMS,\ _("Selected playlist is not magic.")) pls.clear_filters(objanswer=False) @returns_answer('dict', [{"name":"pl_id", "type":"int", "req":True}]) def jsonrpc_magicGetProperties(self, pl_id): """Get properties of a magic playlist * use-or-filter: if equal to 1, use "Or" filter instead of "And" (0 or 1) * use-limit: limit or not number of songs in the playlist (0 or 1) * limit-value: number of songs for this playlist (integer) * limit-sort-value: when limit is active sort playlist with this tag * limit-sort-direction: sort direction for limit (ascending or descending)""" pls = self.deejayd_core.get_recorded_playlist(pl_id) if pls.type != "magic": raise Fault(INVALID_METHOD_PARAMS,\ _("Selected playlist is not magic.")) return dict(pls.get_properties(objanswer=False)) @returns_answer('ack', [{"name":"pl_id", "type":"int", "req":True},\ {"name":"key", "type":"string","req":True},\ {"name":"value", "type":"string", "req":True}]) def jsonrpc_magicSetProperty(self, pl_id, key, value): """Set a property for a magic playlist.""" pls = self.deejayd_core.get_recorded_playlist(pl_id) if pls.type != "magic": raise Fault(INVALID_METHOD_PARAMS,\ _("Selected playlist is not magic.")) pls.set_property(key, value, objanswer=False) def build_protocol(deejayd, main): # add introspection addIntrospection(main) # add common deejayd subhandler sub_handlers = { "player": DeejaydPlayerJSONRPC, "audiolib": DeejaydAudioLibraryJSONRPC, "videolib": DeejaydVideoLibraryJSONRPC, "recpls": DeejaydRecordedPlaylistJSONRPC, } for key in sub_handlers: main.putSubHandler(key, sub_handlers[key](deejayd)) # add mode deejayd subhandler mode_handlers = { "panel": DeejaydPanelModeJSONRPC, "webradio": DeejaydWebradioModeJSONRPC, "video": DeejaydVideoModeJSONRPC, "playlist": DeejaydPlaylistModeJSONRPC, "dvd": DeejaydDvdModeJSONRPC, "queue": DeejaydQueueJSONRPC, } for key in mode_handlers: try: mode = mode_handlers[key](deejayd) except DeejaydError: # this mode is not activated pass else: main.putSubHandler(key, mode) return main ############################################################################# ## part specific for jsonrpc over a TCP connecion : signals ############################################################################# class DeejaydSignalJSONRPC(_DeejaydJSONRPC): def __init__(self, deejayd, connector): super(DeejaydSignalJSONRPC, self).__init__(deejayd) self.connector = connector @returns_answer('ack', [{"name":"signal", "type":"string", "req":True},\ {"name":"value", "type":"bool", "req":True}]) def jsonrpc_setSubscription(self, signal, value): """Set subscribtion to "signal" signal notifications to "value" which should be 0 or 1.""" if value is False: self.connector.set_not_signaled(signal) elif value is True: self.connector.set_signaled(signal) def set_signal_subhandler(deejayd, protocol): protocol.putSubHandler("signal", DeejaydSignalJSONRPC(deejayd, protocol)) return protocol ############################################################################# ## part specific for jsonrpc over a http connecion ############################################################################# class DeejaydWebJSONRPC(_DeejaydJSONRPC): def __init__(self, deejayd, tmp_dir): super(DeejaydWebJSONRPC, self).__init__(deejayd) self._tmp_dir = tmp_dir def __find_ids(self, pattern): ids = [] for file in os.listdir(self._tmp_dir): if re.compile("^%s-[0-9]+" % pattern).search(file): t = file.split("-")[1] # id.ext t = t.split(".") try : ids.append(int(t[0])) except ValueError: pass return ids @returns_answer('dict', params=[{"name":"mid","type":"int","req":True}]) def jsonrpc_writecover(self, mid): """ Record requested cover in the temp directory """ try: cover = self.deejayd_core.get_audio_cover(mid,objanswer=False) except (TypeError, DeejaydError, KeyError): return {"cover": None} cover_ids = self.__find_ids("cover") ext = cover["mime"] == "image/jpeg" and "jpg" or "png" filename = "cover-%s.%s" % (str(cover["id"]), ext) if cover["id"] not in cover_ids: file_path = os.path.join(self._tmp_dir,filename) fd = open(file_path, "w") fd.write(cover["cover"]) fd.close() os.chmod(file_path,0644) # erase unused cover files for id in cover_ids: try: os.unlink(os.path.join(self._tmp_dir,\ "cover-%s.jpg" % id)) os.unlink(os.path.join(self._tmp_dir,\ "cover-%s.png" % id)) except OSError: pass return {"cover": os.path.join('tmp', filename), "mime": cover["mime"]} @returns_answer('dict', params=[{"name":"mode","type":"string","req":True}]) def jsonrpc_buildSourceRDF(self, mode): """ Build rdf file with current medialist of the specified mode return dict with specific informations (like a description)""" try: rdf = rdf_modes[mode](self.deejayd_core, self._tmp_dir) except KeyError: raise Fault(INVALID_METHOD_PARAMS,_("mode %s is not known") % mode) return rdf.update() @returns_answer('dict',[{"name":"updated_tag","type":"string","req":False}]) def jsonrpc_buildPanel(self, updated_tag = None): """ Build panel list """ panel = self.deejayd_core.get_panel() medias, filters, sort = panel.get(objanswer=False) try: filter_list = filters.filterlist except (TypeError, AttributeError): filter_list = [] answer = {"panels": {}} panel_filter = And() # find search filter for ft in filter_list: if ft.type == "basic" and ft.get_name() == "contains": panel_filter.combine(ft) answer["search"] = Get_json_filter(ft).dump() break elif ft.type == "complex" and ft.get_name() == "or": panel_filter.combine(ft) answer["search"] = Get_json_filter(ft).dump() break # find panel filter list for ft in filter_list: if ft.type == "complex" and ft.get_name() == "and": filter_list = ft break tag_list = panel.get_panel_tags(objanswer=False) try: idx = tag_list.index(updated_tag) except ValueError: pass else: tag_list = tag_list[idx+1:] for t in panel.get_panel_tags(objanswer=False): selected = [] for ft in filter_list: # OR filter try: tag = ft[0].tag except (IndexError, TypeError): # bad filter continue if tag == t: selected = [t_ft.pattern for t_ft in ft] tag_filter = ft break if t in tag_list: list = self.deejayd_core.mediadb_list(t,\ panel_filter, objanswer=False) items = [{"name": _("All"), "value":"__all__", \ "class":"list-all", "sel":str(selected==[]).lower()}] if t == "various_artist" and "__various__" in list: items.append({"name": _("Various Artist"),\ "value":"__various__",\ "class":"list-unknown",\ "sel":str("__various__" in selected).lower()}) items.extend([{"name": l,"value":l,\ "sel":str(l in selected).lower(), "class":""}\ for l in list if l != "" and l != "__various__"]) if "" in list: items.append({"name": _("Unknown"), "value":"",\ "class":"list-unknown",\ "sel":str("" in selected).lower()}) answer["panels"][t] = items # add filter for next panel if len(selected) > 0: panel_filter.combine(tag_filter) return answer def set_web_subhandler(deejayd, tmp_dir, main): main.putSubHandler("web", DeejaydWebJSONRPC(deejayd, tmp_dir)) return main # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/rpc/rdfbuilder.py0000644000175000017500000002510611351210475016271 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import os,re from deejayd.xmlobject import DeejaydXMLObject, ET from deejayd.utils import * class RdfBuilder(DeejaydXMLObject): def __init__(self, source_name): self.rdf_nsp = "{http://www.w3.org/1999/02/22-rdf-syntax-ns#}" self.file_nsp = "{http://%s/rdf#}" % source_name self.xmlroot = ET.Element(self.rdf_nsp+"RDF") def build_seq(self, url, parent = None): if parent is None: parent = self.xmlroot seq = ET.SubElement(parent, self.rdf_nsp+"Seq") seq.attrib[self.rdf_nsp+"about"] = self._to_xml_string(url) return seq def build_li(self, parent, url = None): li = ET.SubElement(parent, self.rdf_nsp+"li") if url: li.attrib[self.rdf_nsp+"about"] = self._to_xml_string(url) return li def build_item_desc(self, parms, parent = None, url = None): if parent is None: parent = self.xmlroot desc = ET.SubElement(parent, self.rdf_nsp+"Description") if url: desc.attrib[self.rdf_nsp+"about"] = self._to_xml_string(url) for p in parms.keys(): if p in ("time","length"): if parms[p]: value = format_time(int(parms[p])) else: value = self._to_xml_string(0) elif p == "external_subtitle": value = parms[p] == "" and _("No") or _("Yes") elif p == "rating": rating = u'\u266a' * int(parms[p]) value = self._to_xml_string(rating) else: try: value = self._to_xml_string(parms[p]) except TypeError: continue node = ET.SubElement(desc, self.file_nsp+"%s"\ % self._to_xml_string(p)) node.text = value return desc def set_resource(self, elt, url): elt.attrib[self.rdf_nsp+"resource"] = self._to_xml_string(url) def to_xml(self): return '' + "\n" + \ ET.tostring(self.xmlroot) class _DeejaydSourceRdf(DeejaydXMLObject): name = "unknown" locale_strings = ("%d Song", "%d Songs") def __init__(self, deejayd, rdf_dir): self._deejayd = deejayd self._rdf_dir = rdf_dir def update(self): current_id = self._get_current_id() try: status = self._deejayd.get_status(objanswer=False) new_id = status[self.__class__.name] except KeyError: return {"desc": ""}# this mode is not active new_id = int(new_id) % 10000 # get media list obj = getattr(self._deejayd, self.__class__.get_list_func)() res = obj.get(objanswer=False) if isinstance(res, tuple): medias, filter, sort = res else: medias, filter, sort = res, None, None if current_id != new_id: self._build_rdf_file(medias, new_id) # build description single, plural = self.__class__.locale_strings len = status[self.__class__.name + "length"] desc = ngettext(single,plural,int(len))%int(len) try: time = int(status[self.__class__.name + "timelength"]) except KeyError: pass else: if time > 0: desc += " (%s)" % format_time_long(time) return {"desc": desc, "sort": sort} def _build_rdf_file(self, media_list, new_id): # build xml rdf_builder = RdfBuilder(self.__class__.name) seq = rdf_builder.build_seq("http://%s/all-content" % self.name) for media in media_list: li = rdf_builder.build_li(seq) rdf_builder.build_item_desc(media, li,\ "http://%s/%s" % (self.name, media["id"])) self._save_rdf(rdf_builder, new_id) def _save_rdf(self, rdf_builder, new_id, name = None): name = name or self.__class__.name # first clean rdf dir for file in os.listdir(self._rdf_dir): path = os.path.join(self._rdf_dir,file) if os.path.isfile(path) and file.startswith(name+"-"): os.unlink(path) filename = "%s-%d.rdf" % (name, new_id); file_path = os.path.join(self._rdf_dir,filename) fd = open(file_path, "w") fd.write(rdf_builder.to_xml()) fd.close() os.chmod(file_path,0644) def _get_current_id(self): ids = [] for file in os.listdir(self._rdf_dir): if re.compile("^"+self.name+"-[0-9]+\.rdf$").search(file): t = file.split("-")[1] # id.rdf t = t.split(".") try : ids.append(int(t[0])) except ValueError: pass if ids == []: return 0 else: return max(ids) class DeejaydPlaylistRdf(_DeejaydSourceRdf): name = "playlist" get_list_func = "get_playlist" class DeejaydPanelRdf(_DeejaydSourceRdf): name = "panel" get_list_func = "get_panel" class DeejaydQueueRdf(_DeejaydSourceRdf): name = "queue" get_list_func = "get_queue" class DeejaydWebradioRdf(_DeejaydSourceRdf): name = "webradio" locale_strings = ("%d Webradio", "%d Webradios") get_list_func = "get_webradios" def _build_rdf_file(self, wb_list, new_id): rdf_builder = RdfBuilder(self.__class__.name) seq = rdf_builder.build_seq("http://webradio/all-content") for wb in wb_list: if wb["url-type"] == "urls": urls = " | ".join(wb["urls"]) else: urls = wb["url"] wb_item = rdf_builder.build_li(seq) rdf_builder.build_item_desc({"title": wb["title"], "url": urls},\ wb_item, url = "http://webradio/%s" % str(wb["id"])) self._save_rdf(rdf_builder, new_id) class DeejaydVideoRdf(_DeejaydSourceRdf): name = "video" locale_strings = ("%d Video", "%d Videos") get_list_func = "get_video" class DeejaydVideoDirRdf(_DeejaydSourceRdf): name = "videodir" def update(self): try: stats = self._deejayd.get_stats() new_id = stats["video_library_update"] except KeyError: return {"id": None}# this mode is not active current_id = self._get_current_id() new_id = int(new_id) % 10000 if current_id != new_id: self._build_rdf_file(new_id) return {"id": new_id} def _build_rdf_file(self,new_id): rdf_builder = RdfBuilder(self.__class__.name) seq = rdf_builder.build_seq("http://videodir/all-content") self._build_dir_list(rdf_builder, seq, "") self._save_rdf(rdf_builder, new_id) def _build_dir_list(self, rdf_builder, seq_elt, dir, id = "1"): dir_elt = rdf_builder.build_li(seq_elt) dir_url = "http://videodir/%s" % os.path.join("root", dir) title = dir == "" and _("Root Directory") or os.path.basename(dir) rdf_builder.build_item_desc({"title": title}, url = dir_url) subdirs = self._deejayd.get_video_dir(dir).get_directories() if subdirs == []: rdf_builder.set_resource(dir_elt, dir_url) else: subdir_list = rdf_builder.build_seq(dir_url, parent = dir_elt) for idx, d in enumerate(subdirs): new_id = id + "/%d" % (idx+1,) self._build_dir_list(rdf_builder, subdir_list,\ os.path.join(dir, d), new_id) class DeejaydDvdRdf(_DeejaydSourceRdf): name = "dvd" locale_strings = ("%d Track", "%d Tracks") def update(self): current_id = self._get_current_id() try: status = self._deejayd.get_status(objanswer=False) new_id = status[self.__class__.name] except KeyError: return {"desc": ""}# this mode is not active dvd_content = self._deejayd.get_dvd_content(objanswer=False) new_id = int(new_id) % 10000 if current_id != new_id: self._build_rdf_file(dvd_content, new_id) # build description single, plural = self.__class__.locale_strings len = status[self.__class__.name + "length"] desc = ngettext(single,plural,int(len))%int(len) return {"desc": desc, "title": dvd_content["title"],\ "longest_track": dvd_content["longest_track"]} def _build_rdf_file(self, dvd_content, new_id): rdf_builder = RdfBuilder(self.__class__.name) # dvd structure seq = rdf_builder.build_seq("http://dvd/all-content") for track in dvd_content["track"]: track_li = rdf_builder.build_li(seq) track_url = "http://dvd/%s" % track["ix"] track_struct = rdf_builder.build_seq(track_url,track_li) track_data = {"title": _("Title %s") % track["ix"],\ "id": track["ix"],\ "length": track["length"]} rdf_builder.build_item_desc(track_data,None,track_url) for chapter in track["chapter"]: chapter_url = track_url + "/%s" % chapter["ix"] chapter_li = rdf_builder.build_li(track_struct) rdf_builder.set_resource(chapter_li,chapter_url) chapter_data = {"title": _("Chapter %s") % chapter["ix"],\ "id": chapter["ix"],\ "length": chapter["length"]} rdf_builder.build_item_desc(chapter_data,None,chapter_url) self._save_rdf(rdf_builder,new_id) ngettext("%d Song", "%d Songs", 0) ngettext("%d Video", "%d Videos", 0) ngettext("%d Webradio", "%d Webradios", 0) ngettext("%d Track", "%d Tracks", 0) modes = { "playlist": DeejaydPlaylistRdf, "queue": DeejaydQueueRdf, "panel": DeejaydPanelRdf, "webradio": DeejaydWebradioRdf, "video": DeejaydVideoRdf, "dvd": DeejaydDvdRdf, "videodir": DeejaydVideoDirRdf, } # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/rpc/jsonrpc.py0000644000175000017500000001244511351210475015627 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from twisted.python import reflect from deejayd import rpc as jsonrpclib class JSONRPC(object): separator = '.' def __init__(self): self.subHandlers = {} def putSubHandler(self, prefix, handler): self.subHandlers[prefix] = handler def getSubHandler(self, prefix): return self.subHandlers.get(prefix, None) def getSubHandlerPrefixes(self): return self.subHandlers.keys() def _getFunction(self, functionPath): """Given a string, return a function, or raise jsonrpclib.Fault. This returned function will be called, and should return the result of the call, a Deferred, or a Fault instance. Override in subclasses if you want your own policy. The default policy is that given functionPath 'foo', return the method at self.jsonrpc_foo, i.e. getattr(self, "jsonrpc_" + functionPath). If functionPath contains self.separator, the sub-handler for the initial prefix is used to search for the remaining path. """ if functionPath.find(self.separator) != -1: prefix, functionPath = functionPath.split(self.separator, 1) handler = self.getSubHandler(prefix) if handler is None: raise jsonrpclib.Fault(jsonrpclib.METHOD_NOT_FOUND, "no such sub-handler %s" % prefix) return handler._getFunction(functionPath) f = getattr(self, "jsonrpc_%s" % functionPath, None) if not f: raise jsonrpclib.Fault(jsonrpclib.METHOD_NOT_FOUND, "function %s not found" % functionPath) elif not callable(f): raise jsonrpclib.Fault(jsonrpclib.METHOD_NOT_CALLABLE, "function %s not callable" % functionPath) else: return f def _listFunctions(self): """Return a list of the names of all jsonrpc methods.""" return reflect.prefixedMethodNames(self.__class__, 'jsonrpc_') class JSONRPCIntrospection(JSONRPC): """Implement a JSON-RPC Introspection API. By default, the methodHelp method returns the 'help' method attribute, if it exists, otherwise the __doc__ method attribute, if it exists, otherwise the empty string. To enable the methodSignature method, add a 'signature' method attribute containing a list of lists. See methodSignature's documentation for the format. Note the type strings should be JSON-RPC types, not Python types. """ def __init__(self, parent): """Implement Introspection support for a JSONRPC server. @param parent: the JSONRPC server to add Introspection support to. """ JSONRPC.__init__(self) self._jsonrpc_parent = parent def jsonrpc_listMethods(self): """Return a list of the method names implemented by this server.""" functions = [] todo = [(self._jsonrpc_parent, '')] while todo: obj, prefix = todo.pop(0) functions.extend([ prefix + name for name in obj._listFunctions() ]) todo.extend([ (obj.getSubHandler(name), prefix + name + obj.separator) for name in obj.getSubHandlerPrefixes() ]) return functions jsonrpc_listMethods.signature = [['array']] def jsonrpc_methodHelp(self, method): """ Return a documentation string describing the use of the given method. """ method = self._jsonrpc_parent._getFunction(method) return (getattr(method, 'help', None) or getattr(method, '__doc__', None) or '') jsonrpc_methodHelp.signature = [['string', 'string']] def jsonrpc_methodSignature(self, method): """Return a list of type signatures. Each type signature is a list of the form [rtype, type1, type2, ...] where rtype is the return type and typeN is the type of the Nth argument. If no signature information is available, the empty string is returned. """ method = self._jsonrpc_parent._getFunction(method) return getattr(method, 'signature', None) or '' jsonrpc_methodSignature.signature = [['array', 'string'], ['string', 'string']] def addIntrospection(jsonrpc): """Add Introspection support to an JSONRPC server. @param jsonrpc: The jsonrpc server to add Introspection support to. """ jsonrpc.putSubHandler('system', JSONRPCIntrospection(jsonrpc)) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/rpc/jsonparsers.py0000644000175000017500000000460411351210475016520 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. try: import json # python 2.6 except ImportError: # if python < 2.6, require simplejson import simplejson as json from deejayd.mediafilters import * from deejayd.rpc import * def loads_request(string, **kws): err = Fault(NOT_WELLFORMED_ERROR, "Bad json-rpc request") try: unmarshalled = json.loads(string, **kws) except ValueError: raise err if (isinstance(unmarshalled, dict)): for key in ("method", "params", "id"): if key not in unmarshalled: raise err return unmarshalled raise err def loads_response(string, **kws): err = Fault(NOT_WELLFORMED_ERROR, "Bad json-rpc response") try: ans = json.loads(string, **kws) except ValueError: raise err for key in ("error", "result", "id"): if key not in ans: raise err return ans def Parse_json_filter(json_filter): try: name = json_filter["id"] type = json_filter["type"] if type == "basic": filter_class = NAME2BASIC[name] filter = filter_class(json_filter["value"]["tag"], \ json_filter["value"]["pattern"]) elif type == "complex": filter = NAME2COMPLEX[name]() for f in json_filter["value"]: filter.combine(Parse_json_filter(f)) else: raise TypeError except (KeyError, TypeError): raise Fault(NOT_WELLFORMED_ERROR,\ "Bad filter argument for this json-rpc request") return filter # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/rpc/__init__.py0000644000175000017500000000273011351210475015704 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from deejayd.interfaces import DeejaydError # Deejayd protocol version number DEEJAYD_PROTOCOL_VERSION = 4 # from specification # http://groups.google.com/group/json-rpc/web/json-rpc-1-2-proposal NOT_WELLFORMED_ERROR = -32700 INVALID_JSONRPC = -32600 METHOD_NOT_FOUND = -32601 INVALID_METHOD_PARAMS = -32602 INTERNAL_ERROR = -32603 METHOD_NOT_CALLABLE = -32604 class Fault(DeejaydError): """Indicates an JSON-RPC fault package.""" def __init__(self, code, message): super(Fault, self).__init__() self.code = code self.message = message # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/rpc/jsonbuilders.py0000644000175000017500000001037211351210475016651 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import time from datetime import datetime try: import json # python 2.6 except ImportError: # if python < 2.6, require simplejson import simplejson as json from deejayd.rpc import * class JSONRPCEncoder(json.JSONEncoder): """ Provide custom serializers for JSON-RPC. """ def default(self, obj): if isinstance(obj, datetime): return obj.strftime("%Y%m%dT%H:%M:%S") raise TypeError("%r is not JSON serializable" % (obj,)) class _DeejaydJSON: def dump(self): return self._build_obj() def to_json(self): return json.dumps(self._build_obj(), cls=JSONRPCEncoder) def to_pretty_json(self): s = json.dumps(self._build_obj(), sort_keys=True, indent=4) return '\n'.join([l.rstrip() for l in s.splitlines()]) class JSONRPCRequest(_DeejaydJSON): """ Build JSON-RPC Request """ def __init__(self, method_name, params, notification = False, id = None): self.method = method_name self.params = params # use timestamp as id if no id has been given self.id = None if not notification: self.id = id or int(time.time()) def _build_obj(self): return {"method": self.method, "params": self.params, "id": self.id} def get_id(self): return self.id class JSONRPCResponse(_DeejaydJSON): """ Build JSON-RPC Response """ def __init__(self, result, id): self.id = id self.result = result def _build_obj(self): result, error = self.result, None if isinstance(self.result, Fault): error = {"code": self.result.code, "message": str(self.result)} result = None return {"result": result, "error": error, "id": self.id} def to_json(self): try: return json.dumps(self._build_obj(), cls=JSONRPCEncoder) except TypeError, ex: error = {"code": NOT_WELLFORMED_ERROR, "message": str(ex)} obj = {"result": None, "error": error, "id": self.id} return json.dumps(obj) # # JSON filter serializer # class JSONFilter(_DeejaydJSON): def __init__(self, filter): self.filter = filter def _get_value(self): raise NotImplementedError def _build_obj(self): return { "type": self.type, "id": self.filter.get_identifier(), "value": self._get_value(), } class JSONBasicFilter(JSONFilter): type = "basic" def _get_value(self): return {"tag": self.filter.tag, "pattern": self.filter.pattern} class JSONComplexFilter(JSONFilter): type = "complex" def _get_value(self): return [Get_json_filter(f).dump() for f in self.filter.filterlist] def Get_json_filter(filter): if filter is None: return None if filter.type == 'basic': json_filter_class = JSONBasicFilter elif filter.type == 'complex': json_filter_class = JSONComplexFilter return json_filter_class(filter) # # JSON signal serializer # class DeejaydJSONSignal(_DeejaydJSON): def __init__(self, signal): self.name = signal is not None and signal.get_name() or "" self.attrs = signal is not None and signal.get_attrs() or {} def set_name(self, name): self.name = name def _build_obj(self): return {"type": "signal",\ "answer": {"name": self.name, "attrs": self.attrs}} # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/sources/0000755000175000017500000000000011354730161014472 5ustar royroydeejayd-0.10.0/deejayd/sources/_playorder.py0000644000175000017500000001253111351210475017204 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import random class Order(object): # Not called directly, but the default implementation of # next_explicit and next_implicit both just call this. def next(self, medialist, current): raise NotImplementedError # Not called directly, but the default implementation of # previous_explicit calls this. Right now there is no such thing # as previous_implicit. def previous(self, medialist, current): raise NotImplementedError # Not called directly, but the default implementation of # set_explicit calls this. Right now there is no such thing as # set_implicit. def set(self, medialist, media_id): return medialist.get_item(media_id) # Called when the user presses a "Next" button. def next_explicit(self, medialist, current): return self.next(medialist, current) # Called when a media ends passively, e.g. it plays through. def next_implicit(self, medialist, current): return self.next(medialist, current) # Called when the user presses a "Previous" button. def previous_explicit(self, medialist, current): return self.previous(medialist, current) # Called when the user manually selects a media # If desired the play order can override that def set_explicit(self, medialist, media_id): return self.set(medialist, media_id) def reset(self, medialist): pass class OrderInOrder(Order): name = "inorder" def next(self, medialist, current): if current is None: return medialist.get_item_first() else: next = medialist.next(current) if next is None and medialist.repeat: next = medialist.get_item_first() return next def previous(self, medialist, current): if len(medialist) == 0: return None elif current is None: return medialist.get_item_last() else: previous = medialist.previous(current) if previous is None and medialist.repeat: previous = medialist.get_item_last() return previous class OrderRemembered(Order): # Shared class for all the shuffle modes that keep a memory # of their previously played medias. def __init__(self): self._played = [] def next(self, medialist, current): if current is not None: self._played.append(current["id"]) def previous(self, medialist, current): try: id = self._played.pop() except IndexError: return None else: return medialist.get_item(id) def set(self, medialist, media_id): self._played.append(media_id) return medialist.get_item(media_id) def reset(self, medialist): self._played = [] class OrderShuffle(OrderRemembered): name = "random" def next(self, medialist, current): super(OrderShuffle, self).next(medialist, current) played = set(self._played) medias = set([m["id"] for m in medialist.get()]) remaining = medias.difference(played) if remaining: return medialist.get_item(random.choice(list(remaining))) elif medialist.repeat and not medialist.is_empty(): del(self._played[:]) return medialist.get_item(random.choice(medias)) else: del(self._played[:]) return None class OrderWeighted(OrderRemembered): name = "random-weighted" def next(self, medialist, current): super(OrderWeighted, self).next(medialist, current) played = set(self._played) remaining = [(m["id"],int(m["rating"])) \ for m in medialist.get() if m["id"] not in played] max_score = sum([r for (id,r) in remaining]) choice = int(random.random() * max_score) current = 0 for id, rating in remaining: current += rating if current >= choice: return medialist.get_item(id) if medialist.repeat and not medialist.is_empty(): del(self._played[:]) return medialist.get_item_first() else: del(self._played[:]) return None class OrderOneMedia(OrderInOrder): name = "onemedia" def next_implicit(self, medialist, current): if medialist.repeat: return current else: return None orders = { "onemedia": OrderOneMedia(), "inorder": OrderInOrder(), "random": OrderShuffle(), "random-weighted": OrderWeighted(), } # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/sources/_base.py0000644000175000017500000002361711351210475016124 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import os, locale import cPickle as pickle from twisted.internet import reactor from deejayd import mediafilters from deejayd.interfaces import DeejaydError from deejayd.component import SignalingComponent from deejayd.mediadb.library import NotFoundException from deejayd.sources._medialist import * from deejayd.sources._playorder import orders class SourceError(DeejaydError): pass class _BaseSource(SignalingComponent): name = "unknown" _default_state = {"id": 1} def __init__(self, db): super(_BaseSource, self).__init__() self.db = db self._current = None self._playorder = orders["inorder"] def _load_state(self): state = self._default_state.copy() recorded_state = self.db.get_state("%s_state" % self.name) if recorded_state is not None: state = pickle.loads(recorded_state.encode("utf-8")) return state def get_recorded_id(self): return self._state["id"] def get_content(self, start = 0, stop = None): return self._media_list.get(start, stop) def get_current(self): return self._current def go_to(self, id, type = "id"): if type == "pos": try: id = self._media_list._order[id] except IndexError: return None self._current = self._playorder.set_explicit(self._media_list, id) return self._current def delete(self, ids): if not self._media_list.delete(ids): raise SourceError(_("Unable to delete selected ids")) self._media_list.reload_item_pos(self._current) def clear(self): self._media_list.clear() self._playorder.reset(self._media_list) def next(self, explicit = True): if explicit: self._current = self._playorder.next_explicit(self._media_list,\ self._current) else: self._current = self._playorder.next_implicit(self._media_list,\ self._current) return self._current def previous(self): self._current = self._playorder.previous_explicit(self._media_list,\ self._current) return self._current def get_status(self): return [ (self.name, self._media_list.list_id), (self.name+"length", len(self._media_list)), (self.name+"timelength", self._media_list.time_length) ] def set_option(self, name, value): raise NotImplementedError def close(self): self._state["id"] = self._media_list.list_id states = [ (pickle.dumps(self._state), "%s_state" % self.name) ] self.db.set_state(states) class _BaseLibrarySource(_BaseSource): available_playorder = ("inorder", "random", "onemedia","random-weighted") has_repeat = True _default_state = {"id": 1, "playorder": "inorder", "repeat": False} source_signal = '' def __init__(self, db, library): super(_BaseLibrarySource, self).__init__(db) self._state = self._load_state() if self.medialist_type == "sorted": self._media_list = SortedMediaList(self.get_recorded_id() + 1) elif self.medialist_type == "unsorted": self._media_list = UnsortedMediaList(self.get_recorded_id() + 1) self.library = library if self.has_repeat: self._media_list.repeat = self._state["repeat"] self._playorder = orders[self._state["playorder"]] def _get_playlist_content(self, pl_id): try: pl_id, name, type = self.db.is_medialist_exists(pl_id) if type == "static": return self.db.get_static_medialist(pl_id,\ infos=self.library.media_attr) elif type == "magic": properties = dict(self.db.get_magic_medialist_properties(pl_id)) if properties["use-or-filter"] == "1": filter = mediafilters.Or() else: filter = mediafilters.And() sorts = mediafilters.DEFAULT_AUDIO_SORT if properties["use-limit"] == "1": sorts = [(properties["limit-sort-value"],\ properties["limit-sort-direction"])] + sorts limit = int(properties["limit-value"]) else: limit = None filter.filterlist = self.db.get_magic_medialist_filters(pl_id) return self.library.search(filter, sorts, limit) except TypeError: raise SourceError(_("Playlist %s does not exist.") % str(pl_id)) def set_option(self, name, value): if name == "playorder": try: self._playorder = orders[value] except KeyError: raise SourceError(_("Unable to set %s order, not supported") % value) else: self._state["playorder"] = value elif name == "repeat" and self.has_repeat: if not isinstance(value, bool): raise SourceError(_("Option value has to be a boolean")) self._media_list.repeat = value self._state["repeat"] = value else: raise NotImplementedError def get_status(self): status = super(_BaseLibrarySource, self).get_status() status.append((self.name+"playorder", self._playorder.name)) if self.has_repeat: status.append((self.name+"repeat", self._media_list.repeat)) return status def close(self): super(_BaseLibrarySource, self).close() self.db.set_static_medialist(self.base_medialist,self._media_list.get()) def cb_library_changes(self, signal): file_id = signal.get_attr("id") getattr(self, "_%s_media" % signal.get_attr("type"))(file_id) def _add_media(self, media_id): pass def _update_media(self, media_id): try: media = self.library.get_file_withids([media_id]) except NotFoundException: return if self._media_list.update_media(media[0]): self.dispatch_signame(self.source_signal) def _remove_media(self, media_id): if self._media_list.remove_media(media_id): self.dispatch_signame(self.source_signal) class _BaseSortedLibSource(_BaseLibrarySource): medialist_type = "sorted" def set_sorts(self, sorts): for (tag, direction) in sorts: if tag not in self.sort_tags: raise SourceError(_("Tag '%s' not supported for sort") % tag) if direction not in ('ascending', 'descending'): raise SourceError(_("Bad sort direction for source")) self._sorts = sorts self._media_list.sort(self._sorts + self.default_sorts) self.dispatch_signame(self.source_signal) class _BaseAudioLibSource(_BaseLibrarySource): base_medialist = '' medialist_type = "unsorted" def __init__(self, db, audio_library): super(_BaseAudioLibSource, self).__init__(db, audio_library) # load saved try: ml_id = self.db.get_medialist_id(self.base_medialist, 'static') except ValueError: # medialist does not exist pass else: self._media_list.set(self._get_playlist_content(ml_id)) def add_song(self, song_ids, pos = None): try: medias = self.library.get_file_withids(song_ids) except NotFoundException: raise SourceError(_("One of these ids %s not found") % \ ",".join(map(str, song_ids))) self._media_list.add_media(medias, pos) if pos: self._media_list.reload_item_pos(self._current) self.dispatch_signame(self.source_signal) def add_path(self, paths, pos = None): medias = [] for path in paths: try: medias.extend(self.library.get_all_files(path)) except NotFoundException: try: medias.extend(self.library.get_file(path)) except NotFoundException: raise SourceError(_("%s not found") % path) self._media_list.add_media(medias, pos) if pos: self._media_list.reload_item_pos(self._current) self.dispatch_signame(self.source_signal) def load_playlist(self, pl_ids, pos = None): medias = [] for id in pl_ids: medias.extend(self._get_playlist_content(id)) self._media_list.add_media(medias, pos) if pos: self._media_list.reload_item_pos(self._current) self.dispatch_signame(self.source_signal) def move(self, ids, new_pos): if not self._media_list.move(ids, new_pos): raise SourceError(_("Unable to move selected medias")) self._media_list.reload_item_pos(self._current) self.dispatch_signame(self.source_signal) def delete(self, ids): super(_BaseAudioLibSource, self).delete(ids) self.dispatch_signame(self.source_signal) def clear(self): self._current = None self._media_list.clear() self.dispatch_signame(self.__class__.source_signal) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/sources/queue.py0000644000175000017500000000356511351210475016177 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import random from deejayd.sources._base import _BaseAudioLibSource, SourceError class QueueSource(_BaseAudioLibSource): base_medialist = "__djqueue__" name = "queue" source_signal = 'queue.update' available_playorder = ("inorder", "random") has_repeat = False def go_to(self, nb, type = "id"): self._current = super(QueueSource, self).go_to(nb, type) if self._current != None: self._media_list.delete([self._current["id"],]) self.dispatch_signame(self.source_signal) return self._current def next(self, explicit = True): self._current = None super(QueueSource, self).next(explicit) if self._current != None: self._media_list.delete([self._current["id"],]) self.dispatch_signame(self.source_signal) return self._current def previous(self,rd,rpt): # Have to be never called raise NotImplementedError def reset(self): self._current = None # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/sources/playlist.py0000644000175000017500000000304711351210475016707 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from deejayd.sources._base import _BaseAudioLibSource class PlaylistSource(_BaseAudioLibSource): SUBSCRIPTIONS = { "mediadb.mupdate": "cb_library_changes", } base_medialist = "__djcurrent__" name = "playlist" source_signal = 'player.plupdate' def shuffle(self): self._media_list.shuffle(self._current) if self._current: self._current["pos"] = 0 self.dispatch_signame(self.__class__.source_signal) def save(self, playlist_name): id = self.db.set_static_medialist(playlist_name, self._media_list.get()) self.dispatch_signame('playlist.listupdate') return {"playlist_id": id} # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/sources/__init__.py0000644000175000017500000001552511351210475016611 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import os from deejayd.component import SignalingComponent from deejayd.ui import log from deejayd.player import PlayerError from deejayd.sources._base import SourceError class UnknownSourceException: pass def format_rsp(func): def format_rsp_func(*__args, **__kw): (media, source) = func(*__args, **__kw) if media is not None: media["source"] = source return media return format_rsp_func class SourceFactory(SignalingComponent): sources_list = ("playlist","queue","webradio","video","dvd","panel") def __init__(self,player,db,audio_library,video_library,plg_mnt,config): SignalingComponent.__init__(self) self.current = "" self.db = db activated_sources = config.getlist('general', "activated_modes") self.sources_obj = {} from deejayd.sources import queue self.sources_obj["queue"] = queue.QueueSource(db, audio_library) # playlist if "playlist" in activated_sources: from deejayd.sources import playlist self.sources_obj["playlist"] = playlist.PlaylistSource(db,\ audio_library) else: log.info(_("Playlist support disabled")) # panel if "panel" in activated_sources: from deejayd.sources import panel self.sources_obj["panel"] = panel.PanelSource(db, audio_library,\ config) else: log.info(_("Panel support disabled")) # Webradio if "webradio" in activated_sources and player.is_supported_uri("http"): from deejayd.sources import webradio self.sources_obj["webradio"] = webradio.WebradioSource(db, plg_mnt) else: log.info(_("Webradio support disabled")) # init video support in player if "video" in activated_sources or "dvd" in activated_sources: try: player.init_video_support() except PlayerError: # Critical error, we have to quit deejayd msg = _('Cannot initialise video support, either disable video and dvd mode or check your player video support.') log.err(msg, fatal = True) except NotImplementedError: # player not supported video playback, quit deejayd msg = _("player '%s' don't support video playback, either disable video and dvd mode or change your player to have video support.") log.err(msg % player.name, fatal = True) # Video if "video" in activated_sources: from deejayd.sources import video self.sources_obj["video"] = video.VideoSource(db, video_library) else: log.info(_("Video support disabled")) # dvd if "dvd" in activated_sources and player.is_supported_uri("dvd"): from deejayd.sources import dvd try: self.sources_obj["dvd"] = dvd.DvdSource(db,config) except dvd.DvdError, ex: log.err(_("Unable to init dvd support : %s") % str(ex)) else: log.info(_("DVD support disabled")) # restore recorded source source = db.get_state("source") try: self.set_source(source) except UnknownSourceException: log.err(_("Unable to set recorded source %s") % str(source)) self.set_source(self.get_available_sources()[0]) player.set_source(self) player.load_state() def set_option(self, source, name, value): try: self.sources_obj[source].set_option(name, value) except KeyError: raise UnknownSourceException except NotImplementedError: raise SourceError(_("option %s not supported for this mode")) self.dispatch_signame('player.status') def get_source(self,s): if s not in self.sources_obj.keys(): raise UnknownSourceException return self.sources_obj[s] def set_source(self,s): if s not in self.sources_obj.keys(): raise UnknownSourceException self.current = s self.dispatch_signame('mode') return True def get_status(self): status = [("mode",self.current)] for k in self.sources_obj.keys(): status.extend(self.sources_obj[k].get_status()) return status def get_all_sources(self): return [m for m in self.sources_list if m != "queue"] def get_available_sources(self): modes = self.sources_obj.keys() modes.remove("queue") return modes def is_available(self, mode): return mode in self.sources_obj.keys() def close(self): self.db.set_state([(self.current,"source")]) for k in self.sources_obj.keys(): self.sources_obj[k].close() # # Functions called from the player # @format_rsp def get(self, nb = None, type = "id", source_name = None): src = source_name or self.current return (self.sources_obj[src].go_to(nb,type), src) @format_rsp def get_current(self): queue_media = self.sources_obj["queue"].get_current() or \ self.sources_obj["queue"].next() if queue_media: return (queue_media, "queue") current = self.sources_obj[self.current].get_current() or \ self.sources_obj[self.current].next(explicit = False) return (current, self.current) @format_rsp def next(self, explicit = True): queue_media = self.sources_obj["queue"].next(explicit) if queue_media: return (queue_media, "queue") return (self.sources_obj[self.current].next(explicit),self.current) @format_rsp def previous(self): return (self.sources_obj[self.current].previous(),self.current) def queue_reset(self): self.sources_obj["queue"].reset() def init(player,db,audio_library,video_library,pl_mngt,config): source = SourceFactory(player,db,audio_library,video_library,pl_mngt,config) return source # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/sources/_medialist.py0000644000175000017500000001632711351210475017165 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import random __all__ = ["SimpleMediaList", "SortedMediaList", "UnsortedMediaList"] class SimpleMediaList(object): def __init__(self, list_id = 0): self._media_id = 0 self._order, self._content = [], {} self.repeat = False self.list_id = list_id self.time_length = 0 def __len__(self): return len(self._order) def _set_media_id(self): self._media_id += 1 return self._media_id def _set_media_ans(self, id, pos = -1): try: ans = self._content[id] except KeyError: return None ans.update({"id": id, "pos": pos}) return ans def get(self, start = 0, stop = None): stop = stop or len(self._order) return map(self._set_media_ans, self._order[start:stop],\ range(start, stop)) def get_item(self, id): try: return self._set_media_ans(id, self._order.index(id)) except ValueError: return None def get_item_first(self): if self._order: return self._set_media_ans(self._order[0], 0) return None def get_item_last(self): if self._order: return self._set_media_ans(self._order[-1], len(self._order)-1) return None def get_ids(self): return self._order def reload_item_pos(self, media): try: pos = self._order.index(media["id"]) except TypeError: return except ValueError: pos = -1 media["pos"] = pos def set(self, medias): self._order, self._content = [], {} self.time_length = 0 self.add_media(medias) def add_media(self, medias, first_pos = None): first_pos = first_pos or len(self._order) for index, m in enumerate(medias): id = self._set_media_id() self._order.insert(first_pos + index, id) self._content[id] = m # update medialist time length try: length = int(m["length"]) except (ValueError, KeyError, TypeError): continue self.time_length += length self.list_id += 1 def clear(self): self._order, self._content = [], {} self.time_length = 0 self.list_id += 1 def delete(self, ids, type = "id"): if type == "pos": ids = [self._order[p] for p in ids] missing_ids = [id for id in ids if id not in self._order] if missing_ids: return False for id in ids: self._order.remove(id) # update time length try: length = int(self._content[id]["length"]) except (ValueError, KeyError, TypeError): continue else: self.time_length -= length del self._content[id] self.list_id += 1 return True def next(self, media): try: idx = self._order.index(media["id"]) id = self._order[idx+1] except ValueError: # id not found, try to find by media_id id = None if media: for list_id, m in self._content.items(): if m["media_id"] == media["media_id"]: idx = self._order.index(m["id"]) try: id = self._order[idx+1] except IndexError: # end of medialist return None break if id is None: return None except (IndexError, KeyError, TypeError): return None return self._set_media_ans(id, idx+1) def previous(self, media): try: idx = self._order.index(media["id"]) id = idx and self._order[idx-1] or None except (ValueError, KeyError): return None return self._set_media_ans(id, idx-1) def find_id(self, media_id): for id, m in self._content.items(): if m["media_id"] == media_id: return id raise ValueError class _MediaList(SimpleMediaList): # # library changes action # def remove_media(self, media_id): ans = False for key, media in self._content.items(): if media["media_id"] == media_id: self._order.remove(key) del self._content[key] ans = True try: length = int(media["length"]) except (ValueError, KeyError, TypeError): continue self.time_length -= length if ans: self.list_id += 1 return ans def update_media(self, new_media): ans = False for key, m in self._content.items(): if m["media_id"] == new_media["media_id"]: self._content[key] = new_media ans = True if ans: self.list_id += 1 return ans class UnsortedMediaList(_MediaList): def move(self, ids, new_pos, type = "id"): if type == "pos": ids = [self._order[p] for p in ids] missing_ids = [id for id in ids if id not in self._order] if missing_ids: return False s_list = [id for id in self._order[:new_pos] if id not in ids] e_list = [id for id in self._order[new_pos:] if id not in ids] self._order = s_list + ids + e_list self.list_id += 1 return True def shuffle(self, current = None): if not self._order: return random.shuffle(self._order) if current and current["id"]: try: self._order.remove(current["id"]) except ValueError: pass else: self._order = [current["id"]] + self._order self.list_id += 1 class SortedMediaList(_MediaList): def __init__(self, list_id = 0): super(SortedMediaList, self).__init__(list_id) # # sort actions # def __compare_tag(self, id1, id2, tag, direction): m1 = self._content[id1] m2 = self._content[id2] if m1[tag] < m2[tag]: return direction == "ascending" and -1 or 1 elif m1[tag] == m2[tag]: return 0 else: return direction == "ascending" and 1 or -1 def sort(self, sorts): def compare(id1, id2): for (tag, direction) in sorts: result = self.__compare_tag(id1, id2, tag, direction) if result != 0: return result return 0 self._order.sort(cmp=compare) self.list_id += 1 # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/sources/video.py0000644000175000017500000000576211351210475016162 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import os from deejayd.mediadb.library import NotFoundException from deejayd.sources._base import _BaseSortedLibSource, SourceError from deejayd import mediafilters class VideoSource(_BaseSortedLibSource): SUBSCRIPTIONS = { "mediadb.mupdate": "cb_library_changes", } name = "video" base_medialist = "__videocurrent__" source_signal = 'video.update' sort_tags = ('title','rating','length') default_sorts = mediafilters.DEFAULT_VIDEO_SORT def __init__(self, db, library): super(VideoSource, self).__init__(db, library) # load saved try: ml_id = self.db.get_medialist_id(self.base_medialist, 'static') except ValueError: # medialist does not exist self._sorts = [] else: self._sorts = list(self.db.get_magic_medialist_sorts(ml_id)) or [] self._media_list.set(self._get_playlist_content(ml_id)) self.set_sorts(self._sorts) def set(self, type, value): need_sort = False if type == "directory": try: video_list = self.library.get_all_files(value) except NotFoundException: raise SourceError(_("Directory %s not found") % value) need_sort = True elif type == "search": sorts = self._sorts + self.__class__.default_sorts video_list = self.library.search(\ mediafilters.Contains("title",value), sorts) else: raise SourceError(_("type %s not supported") % type) self._media_list.set(video_list) if need_sort: self._media_list.sort(self._sorts + self.default_sorts) self.dispatch_signame(self.source_signal) def get_content(self, start = 0, stop = None): return self._media_list.get(start, stop), None, self._sorts def close(self): super(VideoSource, self).close() # save panel sorts try: ml_id = self.db.get_medialist_id(self.base_medialist, 'static') except ValueError: # medialist does not exist pass else: self.db.set_magic_medialist_sorts(ml_id, self._sorts) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/sources/panel.py0000644000175000017500000002232011351210475016140 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from deejayd.mediafilters import * from deejayd.sources._base import _BaseSortedLibSource, SourceError from deejayd.ui import log class PanelSource(_BaseSortedLibSource): SUBSCRIPTIONS = { "playlist.update": "cb_playlist_update", "playlist.listupdate": "cb_playlist_listupdate", "mediadb.mupdate": "cb_library_changes", } base_medialist = "__panelcurrent__" name = "panel" source_signal = 'panel.update' supported_panel_tags = [\ ['genre','artist','album'],\ ['genre','various_artist','album'],\ ['artist','album'],\ ['various_artist','album'],\ ] contains_tags = ('genre','artist','album','title','all') sort_tags = ('genre','artist','album','title','rating','tracknumber') default_sorts = DEFAULT_AUDIO_SORT _default_state = {"id": 1, "playorder": "inorder", "repeat": False,\ "panel-type": "panel", "panel-value": "0"} def __init__(self, db, library, config): super(PanelSource, self).__init__(db, library) # get panel tags self.__panel_tags = config.getlist("panel", "panel_tags") if self.__panel_tags not in self.supported_panel_tags: log.err(_("You choose wrong panel tags, fallback to default")) self.__panel_tags = ['genre','artist','album'] # get recorded panel medialist filter = And() try: ml_id = self.db.get_medialist_id(self.base_medialist, 'magic') except ValueError: # medialist does not exist self._sorts = [] else: # get filters filter.filterlist = self.db.get_magic_medialist_filters(ml_id) # get recorded sorts self._sorts = list(self.db.get_magic_medialist_sorts(ml_id)) or [] self.__filters_to_parms(filter) # custom attributes self.__update_active_list(self._state["panel-type"],\ self._state["panel-value"]) def __filters_to_parms(self, filter = None): # filter as AND(Search, Panel) with # * Panel : And(OR(genre=value1, genre=value2), OR(artist=value1)...) # * Search : OR(tag1 CONTAINS value, ) self.__search, self.__panel, self.__filter = None, {}, filter if filter != None: for ft in filter.filterlist: if ft.get_name() == "and": # panel for panel_ft in ft.filterlist: tag = panel_ft.filterlist[0].tag if tag in self.__panel_tags: self.__panel[tag] = panel_ft elif ft.get_name() == "or" or ft.type == "basic": # search self.__search = ft def __update_panel_filters(self): # rebuild filter self.__filter = And() if self.__search: self.__filter.filterlist.append(self.__search) panel_filter = And() panel_filter.filterlist = self.__panel.values() self.__filter.filterlist.append(panel_filter) if self._state["panel-type"] == "panel": sorts = self._sorts + self.__class__.default_sorts medias = self.library.search(self.__filter, sorts) self._media_list.set(medias) self.__update_current() self.dispatch_signame(self.__class__.source_signal) def __update_active_list(self, type, pl_id, raise_ex = False): need_sort, sorts = False, self._sorts + self.__class__.default_sorts if type == "playlist": try: medias = self._get_playlist_content(pl_id) except SourceError: # playlist does not exist, set to panel if raise_ex: raise SourceError(_("Playlist with id %s not found")\ % str(pl_id)) self._state["panel-type"] = "panel"; medias = self.library.search(self.__filter, sorts) else: need_sort = True elif type == "panel": medias = self.library.search(self.__filter, sorts) else: raise TypeError self._media_list.set(medias) if need_sort: self._media_list.sort(self._sorts) def __update_current(self): if self._current and self._current["id"] != -1: # update current id media_id = self._current["media_id"] try: self._current["id"] = self._media_list.find_id(media_id) except ValueError: self._current["id"] = -1 def get_panel_tags(self): return self.__panel_tags def set_panel_filters(self, tag, values): if tag not in self.__panel_tags: raise SourceError(_("Tag '%s' not supported") % tag) if not values: self.remove_panel_filters(tag) return filter = Or() for value in values: filter.combine(Equals(tag, value)) if tag in self.__panel and self.__panel[tag].equals(filter): return # do not need update self.__panel[tag] = filter # remove filter for panels at the right of this tag for tg in reversed(self.get_panel_tags()): if tg == tag: break try: del self.__panel[tg] except KeyError: pass self.__update_panel_filters() def remove_panel_filters(self, tag): try: del self.__panel[tag] except KeyError: pass self.__update_panel_filters() def clear_panel_filters(self): self.__panel = {} self.__update_panel_filters() def set_search_filter(self, tag, value): if tag not in self.__class__.contains_tags: raise SourceError(_("Tag '%s' not supported") % tag) if tag == "all": new_filter = Or() for tg in ('title','genre','artist','album'): new_filter.combine(Contains(tg, value)) else: new_filter = Contains(tag, value) self.__search = new_filter self.__panel = {} # remove old panel filter self.__update_panel_filters() def clear_search_filter(self): if self.__search: self.__search = None self.__update_panel_filters() def get_active_list(self): return {"type": self._state["panel-type"],\ "value": self._state["panel-value"]} def set_active_list(self, type, pl_id): if type == self._state["panel-type"]\ and pl_id == self._state["panel-value"]: return # we do not need to update panel self.__update_active_list(type, pl_id, raise_ex = True) self._state["panel-type"] = type self._state["panel-value"] = pl_id self.__update_current() self.dispatch_signame(self.__class__.source_signal) def get_content(self, start = 0, stop = None): if self._state["panel-type"] == "panel": return self._media_list.get(start, stop),self.__filter,self._sorts elif self._state["panel-type"] == "playlist": return self._media_list.get(start, stop), None, self._sorts def close(self): super(PanelSource, self).close() # save panel filters filter_list = self.__filter.filterlist ml_id = self.db.set_magic_medialist_filters(self.base_medialist,\ filter_list) # save panel sorts self.db.set_magic_medialist_sorts(ml_id, self._sorts) # # callback for deejayd signal # def cb_playlist_update(self, signal): pl_id = int(signal.get_attr('pl_id')) if self._state["panel-type"] == "playlist"\ and pl_id == int(self._state["panel-value"]): self.__update_active_list("playlist", pl_id, raise_ex = True) self.dispatch_signame(self.__class__.source_signal) def cb_playlist_listupdate(self, signal): if self._state["panel-type"] == "playlist": pl_id = int(self._state["panel-value"]) list = [int(id) \ for (id, pl, type) in self.db.get_medialist_list() if not \ pl.startswith("__") or not pl.endswith("__")] if pl_id not in list: # fall back to panel self.__update_active_list("panel", "", raise_ex = True) self._state["panel-type"] = "panel" self._state["panel-value"] = "" self.dispatch_signame(self.__class__.source_signal) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/sources/dvd.py0000644000175000017500000001223311351210475015620 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from deejayd.component import SignalingComponent from deejayd.player._base import PlayerError from deejayd.player.xine import DvdParser class DvdError(Exception): pass class DvdSource(SignalingComponent): name = "dvd" def __init__(self, db, config): super(DvdSource, self).__init__() try: self.parser = DvdParser() except PlayerError, ex: # dvd parser does not work raise DvdError(ex) self.db = db try: self.current_id = int(self.db.get_state("dvdid")) except TypeError: # init dvdid self.current_id = 1 self.dvd_info = None self.selected_track = None def get_content(self): if not self.dvd_info: return {'title': "DVD NOT LOADED", 'longest_track': "0", \ 'track': []} return self.dvd_info def load(self): # Reinit var and update id self.dvd_info = None self.selected_track = None self.current_id +=1 try: self.dvd_info = self.parser.get_dvd_info() except PlayerError, err: raise DvdError("Unable to load the dvd : %s " % err) # select the default track of the dvd self.select_track() self.dispatch_signame('dvd.update') def select_track(self,nb = None,alang_idx = None, slang_idx = None): if not self.dvd_info: return if not nb: nb = self.dvd_info['longest_track'] for track in self.dvd_info['track']: if int(track['ix']) == int(nb): self.selected_track = track self.selected_track["selected_chapter"] = -1 break def select_chapter(self,track = None, chapter = 1, alang = None, \ slang = None): self.selected_track = self.select_track(track,alang,slang) self.selected_track["selected_chapter"] = chapter def get_current(self): if not self.dvd_info or not self.selected_track: return None # Construct item info for player id = str(self.selected_track["ix"]) pos = int(self.selected_track["ix"]) if self.selected_track["selected_chapter"] != -1: id += ".%d" % self.selected_track["selected_chapter"] pos = self.selected_track["selected_chapter"] uri = "dvd://%d" % self.selected_track["ix"] return {"title": self.dvd_info["title"], "type": "video", \ "uri": uri, "chapter":self.selected_track["selected_chapter"],\ "length": self.selected_track["length"],\ "id": id, "pos": pos,\ "audio": self.selected_track["audio"],\ "subtitle": self.selected_track["subp"]} def go_to(self,id,type = "track"): if type in ("track", "id"): self.select_track(id) elif type == "chapter": self.select_chapter(None,id) elif type == "dvd_id": ids = id.split('.') self.select_track(int(ids[0])) if len(ids) > 1 and self.selected_track: self.selected_track["selected_chapter"] = int(ids[1]) return self.get_current() def next(self, explicit = True): if not self.dvd_info or not self.selected_track: return None if self.selected_track["selected_chapter"] != -1: # go to next chapter self.selected_track["selected_chapter"] += 1 else: # go to next title current_title = self.selected_track["ix"] self.select_track(current_title+1) return self.get_current() def previous(self): if not self.dvd_info or not self.selected_track: return None if self.selected_track["selected_chapter"] != -1: # go to previous chapter self.selected_track["selected_chapter"] -= 1 else: # go to previous title current_title = self.selected_track["ix"] self.select_track(current_title - 1) return self.get_current() def get_status(self): length = 0 if self.dvd_info: length = len(self.dvd_info) status = [ (self.name, self.current_id), (self.name+"length",length) ] return status def close(self): states = [(str(self.current_id),"dvdid")] self.db.set_state(states) self.parser.close() # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/sources/webradio.py0000644000175000017500000001473011351210475016643 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from zope.interface import implements from twisted.internet import threads from deejayd.sources._base import _BaseSource, SimpleMediaList, SourceError from deejayd.plugins import IWebradioPlugin from deejayd.utils import get_uris_from_pls, get_uris_from_m3u class WbLocalSource(object): implements(IWebradioPlugin) NAME = "local" HAS_CATEGORIE = False def __init__(self, db): self.db = db self.__load() def __load(self): self.__streams = {} for (id, name, url) in self.db.get_webradios(): if id not in self.__streams.keys(): self.__streams[id] = {"wb_id": id, "title": name,\ "urls": [url], "url-type": "urls", "uri": "",\ "url-index": 0, "type": "webradio"} else: self.__streams[id]["urls"].append(url) def add(self, urls, name): needed_urls = [] for url in urls: if url.lower().startswith("http://"): try: if url.lower().endswith(".pls"): needed_urls.extend(get_uris_from_pls(url)) elif url.lower().endswith(".m3u"): needed_urls.extend(get_uris_from_m3u(url)) else: needed_urls.append(url) except IOError: raise SourceError(_("Given url %s is not supported") % url) else: raise SourceError(_("Given url %s is not supported") % url) # save webradio self.db.add_webradio(name, needed_urls) self.__load() def delete(self, webradio_ids): self.db.remove_webradios(webradio_ids) self.__load() def clear(self): self.db.clear_webradios() self.__streams = {} def get_categories(self): raise SourceError(_("Categories not supported for this source")) def get_streams(self, categorie = None): return self.__streams.values() class WebradioSource(_BaseSource): name = "webradio" _default_state = {"id": 1, "source": "local", "source-cat": ""} def __init__(self, db, plugin_manager): _BaseSource.__init__(self, db) self._state = self._load_state() self.wb_sources = {} # get plugins for plugin in plugin_manager.get_plugins(IWebradioPlugin): self.wb_sources[plugin.NAME] = plugin() # get local source from database self.wb_sources["local"] = WbLocalSource(self.db) # load current list self._media_list = SimpleMediaList(self.get_recorded_id() + 1) try: self.__source = self.wb_sources[self._state["source"]] except KeyError: # recorded source not found, fallback to default self.__source = self.wb_sources["local"] # defer to thread init to avoid long delay # when we try to connect to shoutcast for example def load(): self._media_list.set(\ self.__source.get_streams(self._state["source-cat"])) self.defered = threads.deferToThread(load) def get_available_sources(self): return [(s.NAME, s.HAS_CATEGORIE) for s in self.wb_sources.values()] def set_source(self, source): if self._state["source"] != source: try: self.__source = self.wb_sources[source] except KeyError: raise SourceError(_("Webradio source %s not supported")%source) self._media_list.set(self.__source.get_streams(\ self._state["source-cat"])) self._state["source"] = source self.dispatch_signame('webradio.listupdate') def get_source_categories(self, source_name): try: source = self.wb_sources[source_name] except KeyError: raise SourceError(_("Webradio source %s not supported")%source_name) if not source.HAS_CATEGORIE: raise SourceError(_("Categorie not supported for source %s")\ % source_name) return source.get_categories() def set_source_categorie(self, categorie): if not self.__source.HAS_CATEGORIE: raise SourceError(_("Categorie not supported for source %s")\ % categorie) if self._state["source-cat"] != categorie: self._media_list.set(self.__source.get_streams(categorie)) self._state["source-cat"] = categorie self.dispatch_signame('webradio.listupdate') def add(self, urls, name): self.wb_sources["local"].add(urls, name) self.__reload_local_source() return True def delete(self, ids): webradio_ids = [] for id in ids: wb = self._media_list.get_item(id) if wb is None: raise SourceError(_("Webradio with id %s not found")%str(id)) webradio_ids.append(wb["wb_id"]) self.wb_sources["local"].delete(webradio_ids) self.__reload_local_source() return True def clear(self): self.wb_sources["local"].clear() self.__reload_local_source() return True def get_status(self): return [ ("webradio", self._media_list.list_id), ("webradiolength", len(self._media_list)), ("webradiosource", self._state["source"]), ("webradiosourcecat", self._state["source-cat"]), ] def __reload_local_source(self): if self.__source.NAME == "local": # reload the current medialist self._media_list.set(self.__source.get_streams()) self.dispatch_signame('webradio.listupdate') # vim: ts=4 sw=4 expandtabdeejayd-0.10.0/deejayd/core.py0000644000175000017500000006761211351210475014323 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import deejayd.interfaces from deejayd.interfaces import DeejaydError,\ DeejaydAnswer,\ DeejaydKeyValue, DeejaydList,\ DeejaydFileList,\ DeejaydMediaList, DeejaydDvdInfo from deejayd.ui.config import DeejaydConfig from deejayd import mediafilters, player, sources, mediadb, database, plugins # Exception imports import deejayd.sources import deejayd.mediadb.library def require_mode(mode_name): def require_mode_instance(func): def require_mode_func(self, *__args, **__kw): if self.sources.is_available(mode_name): return func(self, *__args, **__kw) else: raise DeejaydError(_("mode %s is not activated.") % mode_name) require_mode_func.__name__ = func.__name__ return require_mode_func return require_mode_instance # This decorator is used for the core interface to provide : # - A simple way of converting python types to deejayd answers as required by # the defined network interface. This is to ensure that the core API stays # the same as the client library API. This way, code written as a deejayd # client can become a full media player for free by swaping # deejayd.net.DeejaydClient and deejayd.core.DeejaydCore . # - A simple way to make this optionnal using an optionnal objanswer argument. # Please don't use objanswer=False if you think that what you write may one # day be used as a deejayd network client. This was originally provided for # use in the deejayd.net.commandsXML module. def returns_deejaydanswer(answer_class): def returns_deejaydanswer_instance(func): def interface_clean_func(*__args, **__kw): if __kw.has_key('objanswer'): objanswer = __kw['objanswer'] del __kw['objanswer'] else: objanswer = True if objanswer: ans = answer_class() try: res = func(*__args, **__kw) except DeejaydError, txt: ans.set_error(txt) else: if res == None: ans.contents = True elif answer_class == DeejaydMediaList: if isinstance(res, tuple): ans.set_filter(res[1]) ans.set_sort(res[2]) res = res[0] ans.set_medias(res) elif answer_class == DeejaydFileList: root_dir, dirs, files = res if root_dir != None: ans.set_rootdir(root_dir) ans.set_files(files) ans.set_directories(dirs) elif answer_class == DeejaydDvdInfo: ans.set_dvd_content(res) else: ans.contents = res return ans else: return func(*__args, **__kw) interface_clean_func.__name__ = func.__name__ return interface_clean_func return returns_deejaydanswer_instance class DeejaydStaticPlaylist(deejayd.interfaces.DeejaydStaticPlaylist): def __init__(self, deejaydcore, pl_id, name): self.deejaydcore = deejaydcore self.db, self.library = deejaydcore.db, deejaydcore.audio_library self.name = name self.pl_id = pl_id @returns_deejaydanswer(DeejaydMediaList) def get(self, first=0, length=-1): songs = self.db.get_static_medialist(self.pl_id,\ infos = self.library.media_attr) last = length == -1 and len(songs) or int(first) + int(length) return songs[int(first):last] @returns_deejaydanswer(DeejaydAnswer) def add_paths(self, paths): ids = [] for path in paths: try: medias = self.library.get_all_files(path) except deejayd.mediadb.library.NotFoundException: try: medias = self.library.get_file(path) except deejayd.mediadb.library.NotFoundException: raise DeejaydError(_('Path %s not found in library') % path) for m in medias: ids.append(m["media_id"]) self.add_songs(ids) @returns_deejaydanswer(DeejaydAnswer) def add_songs(self, song_ids): self.db.add_to_static_medialist(self.pl_id, song_ids) self.db.connection.commit() self.deejaydcore._dispatch_signame('playlist.update',\ {"pl_id": self.pl_id}) class DeejaydMagicPlaylist(deejayd.interfaces.DeejaydMagicPlaylist): """ Magic playlist object """ def __init__(self, deejaydcore, pl_id, name): self.deejaydcore = deejaydcore self.db, self.library = deejaydcore.db, deejaydcore.audio_library self.name = name self.pl_id = pl_id @returns_deejaydanswer(DeejaydMediaList) def get(self, first=0, length=-1): properties = dict(self.db.get_magic_medialist_properties(self.pl_id)) if properties["use-or-filter"] == "1": filter = mediafilters.Or() else: filter = mediafilters.And() if properties["use-limit"] == "1": sort = [(properties["limit-sort-value"],\ properties["limit-sort-direction"])] limit = int(properties["limit-value"]) else: sort, limit = [], None filter.filterlist = self.db.get_magic_medialist_filters(self.pl_id) songs = self.library.search(filter, sort, limit) last = length == -1 and len(songs) or int(first) + int(length) return (songs[int(first):last], filter, None) @returns_deejaydanswer(DeejaydAnswer) def add_filter(self, filter): if filter.type != "basic": raise DeejaydError(\ _("Only basic filters are allowed for magic playlist")) self.db.add_magic_medialist_filters(self.pl_id, [filter]) self.deejaydcore._dispatch_signame('playlist.update',\ {"pl_id": self.pl_id}) @returns_deejaydanswer(DeejaydAnswer) def remove_filter(self, filter): record_filters = self.db.get_magic_medialist_filters(self.pl_id) new_filters = [] for record_filter in record_filters: if not filter.equals(record_filter): new_filters.append(record_filter) self.db.set_magic_medialist_filters(self.name, new_filters) self.deejaydcore._dispatch_signame('playlist.update',\ {"pl_id": self.pl_id}) @returns_deejaydanswer(DeejaydAnswer) def clear_filters(self): self.db.set_magic_medialist_filters(self.name, []) self.deejaydcore._dispatch_signame('playlist.update',\ {"pl_id": self.pl_id}) @returns_deejaydanswer(DeejaydKeyValue) def get_properties(self): return dict(self.db.get_magic_medialist_properties(self.pl_id)) @returns_deejaydanswer(DeejaydAnswer) def set_property(self, key, value): self.db.set_magic_medialist_property(self.pl_id, key, value) self.deejaydcore._dispatch_signame('playlist.update',\ {"pl_id": self.pl_id}) class DeejaydWebradioList(deejayd.interfaces.DeejaydWebradioList): def __init__(self, deejaydcore): self.deejaydcore = deejaydcore self.source = self.deejaydcore.sources.get_source('webradio') @returns_deejaydanswer(DeejaydMediaList) def get(self, first = 0, length = -1): wrs = self.source.get_content() last = length == -1 and len(wrs) or int(first) + int(length) return wrs[int(first):last] @returns_deejaydanswer(DeejaydKeyValue) def get_available_sources(self): return dict(self.source.get_available_sources()) @returns_deejaydanswer(DeejaydList) def get_source_categories(self, source_name): return self.source.get_source_categories(source_name) @returns_deejaydanswer(DeejaydAnswer) def set_source(self, source_name): self.source.set_source(source_name) @returns_deejaydanswer(DeejaydAnswer) def set_source_categorie(self, categorie): self.source.set_source_categorie(categorie) @returns_deejaydanswer(DeejaydAnswer) def add_webradio(self, name, urls): self.source.add(urls, name) @returns_deejaydanswer(DeejaydAnswer) def delete_webradios(self, wr_ids): ids = map(int, wr_ids) self.source.delete(ids) @returns_deejaydanswer(DeejaydAnswer) def clear(self): self.source.clear() class DeejaydQueue(deejayd.interfaces.DeejaydQueue): def __init__(self, deejaydcore): self.deejaydcore = deejaydcore self.source = self.deejaydcore.sources.get_source('queue') @returns_deejaydanswer(DeejaydMediaList) def get(self, first = 0, length = -1): songs = self.source.get_content() last = length == -1 and len(songs) or int(first) + int(length) return songs[int(first):last] @returns_deejaydanswer(DeejaydAnswer) def add_songs(self, song_ids, pos = None): p = pos and int(pos) or None try: self.source.add_song(song_ids, pos = p) except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) @returns_deejaydanswer(DeejaydAnswer) def add_paths(self, paths, pos = None): p = pos and int(pos) or None try: self.source.add_path(paths, p) except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) @returns_deejaydanswer(DeejaydAnswer) def load_playlists(self, pl_ids, pos=None): pos = pos and int(pos) or None try: self.source.load_playlist(pl_ids, pos) except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) @returns_deejaydanswer(DeejaydAnswer) def move(self, ids, new_pos): ids = [int(id) for id in ids] try: self.source.move(ids, new_pos) except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) @returns_deejaydanswer(DeejaydAnswer) def clear(self): self.source.clear() @returns_deejaydanswer(DeejaydAnswer) def del_songs(self, ids): ids = [int(id) for id in ids] try: self.source.delete(ids) except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) class DeejaydPlaylistMode(deejayd.interfaces.DeejaydPlaylistMode): """Audio playlist mode.""" def __init__(self, deejaydcore): self.deejaydcore = deejaydcore self.source = self.deejaydcore.sources.get_source("playlist") @returns_deejaydanswer(DeejaydMediaList) def get(self, first=0, length=-1): songs = self.source.get_content() last = length == -1 and len(songs) or int(first) + int(length) return songs[int(first):last] @returns_deejaydanswer(DeejaydKeyValue) def save(self, name): if name == "": raise DeejaydError(_("Set a playlist name")) return self.source.save(name) @returns_deejaydanswer(DeejaydAnswer) def add_paths(self, paths, pos=None): p = pos and int(pos) or None try: self.source.add_path(paths, pos = p) except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) @returns_deejaydanswer(DeejaydAnswer) def add_songs(self, song_ids, pos=None): p = pos and int(pos) or None try: self.source.add_song(song_ids, pos = p) except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) @returns_deejaydanswer(DeejaydAnswer) def loads(self, pl_ids, pos=None): pos = pos and int(pos) or None try: self.source.load_playlist(pl_ids, pos) except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) @returns_deejaydanswer(DeejaydAnswer) def move(self, ids, new_pos): ids = [int(id) for id in ids] try: self.source.move(ids, int(new_pos)) except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) @returns_deejaydanswer(DeejaydAnswer) def shuffle(self): try: self.source.shuffle() except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) @returns_deejaydanswer(DeejaydAnswer) def clear(self): try: self.source.clear() except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) @returns_deejaydanswer(DeejaydAnswer) def del_songs(self, ids): ids = [int(id) for id in ids] try: self.source.delete(ids) except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) class DeejaydPanel(deejayd.interfaces.DeejaydPanel): def __init__(self, deejaydcore): self.deejaydcore = deejaydcore self.source = self.deejaydcore.sources.get_source("panel") @returns_deejaydanswer(DeejaydMediaList) def get(self, first=0, length=-1): songs, filters, sort = self.source.get_content() last = length == -1 and len(songs) or int(first) + int(length) return (songs[int(first):last], filters, sort) @returns_deejaydanswer(DeejaydList) def get_panel_tags(self): return self.source.get_panel_tags() @returns_deejaydanswer(DeejaydKeyValue) def get_active_list(self): return self.source.get_active_list() @returns_deejaydanswer(DeejaydAnswer) def set_active_list(self, type, pl_id=""): try: self.source.set_active_list(type, pl_id) except TypeError: raise DeejaydError(_("Not supported type")) except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) @returns_deejaydanswer(DeejaydAnswer) def set_panel_filters(self, tag, values): try: self.source.set_panel_filters(tag, values) except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) @returns_deejaydanswer(DeejaydAnswer) def remove_panel_filters(self, tag): try: self.source.remove_panel_filters(tag) except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) @returns_deejaydanswer(DeejaydAnswer) def clear_panel_filters(self): self.source.clear_panel_filters() @returns_deejaydanswer(DeejaydAnswer) def set_search_filter(self, tag, value): try: self.source.set_search_filter(tag, value) except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) @returns_deejaydanswer(DeejaydAnswer) def clear_search_filter(self): self.source.clear_search_filter() @returns_deejaydanswer(DeejaydAnswer) def set_sorts(self, sorts): try: self.source.set_sorts(sorts) except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) class DeejaydVideo(deejayd.interfaces.DeejaydVideo): """Video mode.""" def __init__(self, deejaydcore): self.deejaydcore = deejaydcore self.source = self.deejaydcore.sources.get_source('video') @returns_deejaydanswer(DeejaydMediaList) def get(self, first = 0, length = -1): videos, filters, sort = self.source.get_content() last = length == -1 and len(videos) or int(first) + int(length) return (videos[int(first):last], filters, sort) @returns_deejaydanswer(DeejaydAnswer) def set(self, value, type = "directory"): try: self.source.set(type, value) except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) @returns_deejaydanswer(DeejaydAnswer) def set_sorts(self, sorts): try: self.source.set_sorts(sorts) except deejayd.sources._base.SourceError, ex: raise DeejaydError(str(ex)) class DeejayDaemonCore(deejayd.interfaces.DeejaydCore): def __init__(self, config=None): deejayd.interfaces.DeejaydCore.__init__(self) if not config: config = DeejaydConfig() self.db = database.init(config) self.plugin_manager = plugins.PluginManager(config) self.player = player.init(self.db, self.plugin_manager, config) self.player.register_dispatcher(self) self.audio_library,self.video_library, self.watcher = \ mediadb.init(self.db, self.player,config) self.audio_library.register_dispatcher(self) if self.video_library: self.video_library.register_dispatcher(self) self.sources = sources.init(self.player, self.db, self.audio_library, self.video_library, self.plugin_manager, config) self.sources.register_dispatcher(self) for source in self.sources.sources_obj.values(): source.register_dispatcher(self) if not self.db.structure_created: self.update_audio_library(objanswer=False) if self.video_library: self.update_video_library(objanswer=False) # start inotify thread when we are sure that all init stuff are ok if self.watcher: self.watcher.start() def close(self): for obj in (self.watcher,self.player,self.sources,self.audio_library,\ self.video_library,self.db): if obj != None: obj.close() @returns_deejaydanswer(DeejaydAnswer) def play_toggle(self): if self.player.get_state() == player._base.PLAYER_PLAY: current_media = self.player.get_playing() if current_media['type'] == 'webradio': # There is no point in pausing radio streams. try: self.player.stop() except player.PlayerError, err: raise DeejaydError(err) else: self.player.pause() else: try: self.player.play() except player.PlayerError, err: raise DeejaydError(err) @returns_deejaydanswer(DeejaydAnswer) def stop(self): try: self.player.stop() except player.PlayerError, err: raise DeejaydError(err) @returns_deejaydanswer(DeejaydAnswer) def previous(self): try: self.player.previous() except player.PlayerError, err: raise DeejaydError(err) @returns_deejaydanswer(DeejaydAnswer) def next(self): try: self.player.next() except player.PlayerError, err: raise DeejaydError(err) @returns_deejaydanswer(DeejaydAnswer) def seek(self, pos, relative = False): self.player.set_position(int(pos), relative) @returns_deejaydanswer(DeejaydMediaList) def get_current(self): medias = [] current = self.player.get_playing() if current != None: medias.append(current) return medias @require_mode("playlist") def get_playlist(self): return DeejaydPlaylistMode(self) @require_mode("panel") def get_panel(self): return DeejaydPanel(self) @require_mode("webradio") def get_webradios(self): return DeejaydWebradioList(self) @require_mode("video") def get_video(self): return DeejaydVideo(self) def get_queue(self): return DeejaydQueue(self) @returns_deejaydanswer(DeejaydAnswer) def go_to(self, id, id_type = "id", source = None): if id_type not in ("dvd_id","track","chapter","id","pos"): raise DeejaydError(_("Bad value for id_type parm")) if id_type != "dvd_id": try: id = int(id) except ValueError: raise DeejaydError(_("Bad value for id parm")) try: self.player.go_to(id, id_type, source) except player.PlayerError, err: raise DeejaydError(err) @returns_deejaydanswer(DeejaydAnswer) def set_volume(self, volume_value): self.player.set_volume(int(volume_value)) @returns_deejaydanswer(DeejaydAnswer) def set_option(self, source, option_name, option_value): try: self.sources.set_option(source, option_name, option_value) except sources.UnknownSourceException: raise DeejaydError(_('Mode %s not supported') % source) except sources._base.SourceError, ex: raise DeejaydError(str(ex)) @returns_deejaydanswer(DeejaydAnswer) def set_mode(self, mode_name): try: self.sources.set_source(mode_name) except sources.UnknownSourceException: raise DeejaydError(_('Mode %s not supported') % mode_name) @returns_deejaydanswer(DeejaydKeyValue) def get_mode(self): av_sources = self.sources.get_available_sources() modes = {} for s in self.sources.get_all_sources(): modes[s] = s in av_sources return modes @returns_deejaydanswer(DeejaydAnswer) def set_player_option(self, name, value): if name != "aspect_ratio": try: value = int(value) except (ValueError,TypeError): raise DeejaydError(_("Param value is not an int")) try: self.player.set_option(name, value) except KeyError: raise DeejaydError(_("Option %s does not exist") % name) except NotImplementedError: raise DeejaydError(_("Option %s is not supported for this backend")\ % name) except player.PlayerError, err: raise DeejaydError(err) @returns_deejaydanswer(DeejaydKeyValue) def get_status(self): status = self.player.get_status() status.extend(self.sources.get_status()) status.extend(self.audio_library.get_status()) if self.video_library: status.extend(self.video_library.get_status()) return dict(status) @returns_deejaydanswer(DeejaydKeyValue) def get_stats(self): ans = self.db.get_stats() return dict(ans) @returns_deejaydanswer(DeejaydKeyValue) def update_audio_library(self, force = False, sync = False): return {'audio_updating_db': self.audio_library.update(force, sync)} @require_mode("video") @returns_deejaydanswer(DeejaydKeyValue) def update_video_library(self, force = False, sync = False): if not self.video_library: raise DeejaydError(_("Video mode disabled")) return {'video_updating_db': self.video_library.update(force, sync)} @returns_deejaydanswer(DeejaydKeyValue) def create_recorded_playlist(self, name, type): if name == "": raise DeejaydError(_("Set a playlist name")) # first search if this pls already exist try: self.db.get_medialist_id(name, type) except ValueError: pass else: # pls already exists raise DeejaydError(_("This playlist already exists")) if type == "static": pl_id = self.db.set_static_medialist(name, []) elif type == "magic": pl_id = self.db.set_magic_medialist_filters(name, []) pl = DeejaydMagicPlaylist(self, pl_id, name) # set default properties for this playlist default = { "use-or-filter": "0", "use-limit": "0", "limit-value": "50", "limit-sort-value": "title", "limit-sort-direction": "ascending" } for (k, v) in default.items(): pl.set_property(k, v) self._dispatch_signame('playlist.listupdate') return {"pl_id": pl_id, "name": name, "type": type} def get_recorded_playlist(self, id, name = "", type = "static"): try: pl_id, name, type = self.db.is_medialist_exists(id) except TypeError: raise DeejaydError(_("Playlist with id %s not found.") % str(id)) if type == "static": return DeejaydStaticPlaylist(self, pl_id, name) elif type == "magic": return DeejaydMagicPlaylist(self, pl_id, name) @returns_deejaydanswer(DeejaydAnswer) def erase_playlist(self, ids): for id in ids: self.db.delete_medialist(id) self._dispatch_signame('playlist.listupdate') @returns_deejaydanswer(DeejaydMediaList) def get_playlist_list(self): return [{"name": pl, "id":id, "type":type}\ for (id, pl, type) in self.db.get_medialist_list() if not \ pl.startswith("__") or not pl.endswith("__")] @returns_deejaydanswer(DeejaydAnswer) def set_media_rating(self, media_ids, rating, type = "audio"): if int(rating) not in range(0, 5): raise DeejaydError(_("Bad rating value")) try: library = getattr(self, type+"_library") except AttributeError: raise DeejaydError(_('Type %s is not supported') % (type,)) for id in media_ids: try: library.set_file_info(int(id), "rating", rating) except TypeError: raise DeejaydError(_("%s library not activated") % type) except deejayd.mediadb.library.NotFoundException: raise DeejaydError(_("File with id %s not found") % str(id)) @returns_deejaydanswer(DeejaydFileList) def get_audio_dir(self,dir = None): if dir == None: dir = "" try: contents = self.audio_library.get_dir_content(dir) except deejayd.mediadb.library.NotFoundException: raise DeejaydError(_('Directory %s not found in database') % dir) return dir, contents['dirs'], contents['files'] @returns_deejaydanswer(DeejaydKeyValue) def get_audio_cover(self,media_id): try: cover = self.audio_library.get_cover(media_id) except deejayd.mediadb.library.NotFoundException: raise DeejaydError(_('Cover not found')) return cover @returns_deejaydanswer(DeejaydMediaList) def audio_search(self, pattern, type = 'all'): if type not in ('all','title','genre','filename','artist','album'): raise DeejaydError(_('Type %s is not supported') % (type,)) if type == "all": filter = mediafilters.Or() for tag in ('title','genre','artist','album'): filter.combine(mediafilters.Contains(tag, pattern)) else: filter = mediafilters.Contains(type, pattern) songs = self.audio_library.search(filter,\ mediafilters.DEFAULT_AUDIO_SORT) return songs @require_mode("video") @returns_deejaydanswer(DeejaydFileList) def get_video_dir(self,dir = None): if not self.video_library: raise DeejaydError(_("Video mode disabled")) if dir == None: dir = "" try: contents = self.video_library.get_dir_content(dir) except deejayd.mediadb.library.NotFoundException: raise DeejaydError(_('Directory %s not found in database') % dir) return dir, contents['dirs'], contents['files'] @require_mode("dvd") @returns_deejaydanswer(DeejaydAnswer) def dvd_reload(self): try: self.sources.get_source("dvd").load() except sources.dvd.DvdError, msg: raise DeejaydError('%s' % msg) @require_mode("dvd") @returns_deejaydanswer(DeejaydDvdInfo) def get_dvd_content(self): return self.sources.get_source("dvd").get_content() @returns_deejaydanswer(DeejaydList) def mediadb_list(self, tag, filter): return [x[0] for x in self.db.list_tags(tag, filter)] # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/mediadb/0000755000175000017500000000000011354730161014374 5ustar royroydeejayd-0.10.0/deejayd/mediadb/inotify.py0000644000175000017500000002300011351210475016420 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import os, threading, traceback, Queue from deejayd.ui import log from deejayd.utils import str_encode import pyinotify ############################################################################# ##### Events Watcher ############################################################################# def log_event(func): def log_event_func(self, event): path = os.path.join(event.path.decode("utf-8"),\ event.name.decode("utf-8")) try: event.maskname except AttributeError: event.maskname = event.event_name log.info(_("Inotify event %s: %s") % (event.maskname, path)) func(self,event) return log_event_func class InotifyWatcher(pyinotify.ProcessEvent): def __init__(self, library, queue): self.__library = library self.__queue = queue @log_event def process_IN_CREATE(self, event): self.__queue.put(("create", self.__library, event)) @log_event def process_IN_DELETE(self, event): self.__queue.put(("delete", self.__library, event)) @log_event def process_IN_MOVED_FROM(self, event): self.__queue.put(("move_from", self.__library, event)) @log_event def process_IN_MOVED_TO(self, event): self.__queue.put(("move_to", self.__library, event)) @log_event def process_IN_CLOSE_WRITE(self, event): self.__queue.put(("close_write", self.__library, event)) def process_IN_IGNORED(self, event): # This is said to be useless in the documentation, and is # effectively useless for us except for adding extreme verbosity # to the tests. Farewell, IN_IGNORED! pass class _LibraryWatcher(threading.Thread): def __init__(self, db, queue): threading.Thread.__init__(self) self.should_stop = threading.Event() self.__db = db self.__queue = queue self.__created_files = [] self.__need_update = False self.__record_changes = [] def run(self): while not self.should_stop.isSet(): try: type, library, event = self.__queue.get(True, 0.1) except Queue.Empty: continue try: changes = self.__execute(type,library,event) if changes: self.__record_changes.extend(changes) self.__need_update = True except Exception, ex: path = str_encode(os.path.join(event.path, event.name), errors='replace') log.err(_("Inotify problem for '%s', see traceback") % path) log.err("------------------Traceback lines--------------------") log.err(traceback.format_exc()) log.err("-----------------------------------------------------") if self.__need_update and self.__queue.empty(): # record changes library.inotify_record_changes(self.__record_changes) self.__record_changes = [] self.__need_update = False self.__db.close() def __occured_on_dirlink(self, library, event): if not event.name: return False file_path = os.path.join(event.path, event.name) if os.path.exists(file_path): return os.path.islink(file_path) and os.path.isdir(file_path) else: # File seems to have been deleted, so we lookup for a dirlink # in the library. return file_path in library.get_root_paths() def __execute(self, type, library, event): # first be sure that path are correct try: path = library._encode(event.path) name = library._encode(event.name) except UnicodeError: # skip this event return False if type == "create": if self.__occured_on_dirlink(library, event): return library.add_directory(path, name, True) elif not self.is_on_dir(event): self.__created_files.append((path, name)) elif type == "delete": if self.__occured_on_dirlink(library, event): return library.remove_directory(path, name, True) elif not self.is_on_dir(event): return library.remove_file(path, name) elif type == "move_from": if not self.is_on_dir(event): return library.remove_file(path, name) else: return library.remove_directory(path, name) elif type == "move_to": if not self.is_on_dir(event): return library.add_file(path, name) else: return library.add_directory(path, name) elif type == "close_write": if (path, name) in self.__created_files: del self.__created_files[\ self.__created_files.index((path, name))] return library.add_file(path, name) else: return library.update_file(path, name) return False def close(self): self.should_stop.set() threading.Thread.join(self) class LibraryWatcher(_LibraryWatcher): def is_on_dir(self, event): return event.dir class LibraryWatcherOLD(_LibraryWatcher): def is_on_dir(self, event): return event.is_dir ############################################################################# class _DeejaydInotify(threading.Thread): def __init__(self, db, audio_library, video_library): threading.Thread.__init__(self) self.should_stop = threading.Event() self.__audio_library = audio_library self.__video_library = video_library self.__db = db self.__queue = Queue.Queue(1000) self.__wm = pyinotify.WatchManager() self.__watched_dirs = {} self.EVENT_MASK = self.watched_events_mask() def is_watched (self, dir_path): return dir_path in self.__watched_dirs.keys() def watch_dir(self, dir_path, library): if self.is_watched(dir_path): raise ValueError('dir %s is already watched' % dir_path) wdd = self.__wm.add_watch(dir_path, self.EVENT_MASK, proc_fun=InotifyWatcher(library,self.__queue), rec=True, auto_add=True) self.__watched_dirs[dir_path] = wdd def stop_watching_dir(self, dir_path): if self.is_watched(dir_path): wdd = self.__watched_dirs[dir_path] del self.__watched_dirs[dir_path] self.__wm.rm_watch(wdd[dir_path], rec=True) def run(self): notifier = self.notifier(self.__wm) for library in (self.__audio_library, self.__video_library): if library: library.watcher = self for dir_path in library.get_root_paths(): self.watch_dir(dir_path, library) # start library watcher thread lib_watcher = self.watcher(self.__db, self.__queue) lib_watcher.start() while not self.should_stop.isSet(): # process the queue of events as explained above notifier.process_events() if notifier.check_events(): # read notified events and enqeue them notifier.read_events() lib_watcher.close() notifier.stop() def close(self): self.should_stop.set() threading.Thread.join(self) class DeejaydInotify(_DeejaydInotify): def watched_events_mask(self): return pyinotify.IN_DELETE |\ pyinotify.IN_CREATE |\ pyinotify.IN_MOVED_FROM |\ pyinotify.IN_MOVED_TO |\ pyinotify.IN_CLOSE_WRITE def watcher(self, db, queue): return LibraryWatcher(db, queue) def notifier(self, watch_manager): return pyinotify.Notifier(watch_manager, timeout=1000) class DeejaydInotifyOLD(_DeejaydInotify): def watched_events_mask(self): return pyinotify.EventsCodes.IN_DELETE |\ pyinotify.EventsCodes.IN_CREATE |\ pyinotify.EventsCodes.IN_MOVED_FROM |\ pyinotify.EventsCodes.IN_MOVED_TO |\ pyinotify.EventsCodes.IN_CLOSE_WRITE def watcher(self, db, queue): return LibraryWatcherOLD(db, queue) def notifier(self, watch_manager): return pyinotify.Notifier(watch_manager) def get_watcher(db, audio_library, video_library): try: pyinotify_version = map(int, pyinotify.__version__.split('.')) except AttributeError: pyinotify_version = [0, 7] if pyinotify_version >= [0, 8]: return DeejaydInotify(db, audio_library, video_library) else: return DeejaydInotifyOLD(db, audio_library, video_library) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/mediadb/__init__.py0000644000175000017500000000400011351210475016475 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from ConfigParser import NoOptionError from deejayd.ui import log from deejayd.mediadb import library try: from deejayd.mediadb import inotify except ImportError: inotify = False def init(db, player, config): audio_library,video_library,lib_watcher = None, None, None fc = config.get("mediadb","filesystem_charset") audio_dir = config.get("mediadb","music_directory") try: audio_library = library.AudioLibrary(db, player, audio_dir, fc) except library.NotFoundException,msg: log.err(_("Unable to init audio library : %s") % msg, fatal = True) activated_sources = config.getlist('general', "activated_modes") if "video" in activated_sources: video_dir = config.get('mediadb', 'video_directory') try: video_library = library.VideoLibrary(db,player,video_dir,fc) except library.NotFoundException,msg: log.err(_("Unable to init video library : %s") % msg, fatal=True) if inotify: lib_watcher = inotify.get_watcher(db, audio_library, video_library) else: log.info(_("Inotify support disabled")) return audio_library,video_library,lib_watcher # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/mediadb/library.py0000644000175000017500000007245611351210475016426 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # -*- coding: utf-8 -*- import os, sys, threading, traceback, base64, locale, hashlib from twisted.internet import threads, reactor import kaa.metadata from deejayd.interfaces import DeejaydError from deejayd.component import SignalingComponent from deejayd.mediadb import formats from deejayd.utils import quote_uri, str_encode from deejayd import database, mediafilters from deejayd.ui import log class NotFoundException(DeejaydError):pass class NotSupportedFormat(DeejaydError):pass ########################################################################## ########################################################################## def inotify_action(func): def inotify_action_func(*__args, **__kw): self = __args[0] try: name = self._encode(__args[1]) path = self._encode(__args[2]) except UnicodeError: return return func(*__args, **__kw) return inotify_action_func class _Library(SignalingComponent): common_attr = ("filename","uri","type","title","length") persistent_attr = ("rating","skipcount","playcount","lastplayed") type = None def __init__(self, db_connection, player, path, fs_charset="utf-8"): SignalingComponent.__init__(self) # init Parms self.media_attr = [] for i in self.__class__.common_attr: self.media_attr.append(i) for j in self.__class__.custom_attr: self.media_attr.append(j) for k in self.__class__.persistent_attr: self.media_attr.append(k) self._fs_charset = fs_charset self._update_id = 0 self._update_end = True self._update_error = None self._changes_cb = {} self._changes_cb_id = 0 self._path = os.path.abspath(path) # test library path if not os.path.isdir(self._path): msg = _("Unable to find directory %s") % self._encode(self._path) raise NotFoundException(msg) # Connection to the database self.db_con = db_connection # init a mutex self.mutex = threading.Lock() # build supported extension list self.ext_dict = formats.get_extensions(player, self.type) self.watcher = None def _encode(self, data): return str_encode(data, self._fs_charset) def _build_supported_extension(self, player): raise NotImplementedError def set_file_info(self, file_id, key, value, allow_create = False): ans = self.db_con.set_media_infos(file_id, {key: value}, allow_create) if not ans: raise NotFoundException self.dispatch_signame('mediadb.mupdate',\ attrs = {"type": "update", "id": file_id}) self.db_con.connection.commit() def get_dir_content(self, dir): dir = os.path.join(self._path, dir).rstrip("/") files_rsp = self.db_con.get_dir_content(dir,\ infos = self.media_attr, type = self.type) dirs_rsp = self.db_con.get_dir_list(dir, self.type) if len(files_rsp) == 0 and len(dirs_rsp) == 0 and dir != self._path: # nothing found for this directory raise NotFoundException dirs = [] for dir_id, dir_path in dirs_rsp: root, d = os.path.split(dir_path.rstrip("/")) if d != "" and root == self._encode(dir): dirs.append(d) return {'files': files_rsp, 'dirs': dirs} def get_dir_files(self,dir): dir = os.path.join(self._path, dir).rstrip("/") files_rsp = self.db_con.get_dir_content(dir,\ infos = self.media_attr, type = self.type) if len(files_rsp) == 0 and dir != self._path: raise NotFoundException return files_rsp def get_all_files(self,dir): dir = os.path.join(self._path, dir).rstrip("/") files_rsp = self.db_con.get_alldir_files(dir,\ infos = self.media_attr, type = self.type) if len(files_rsp) == 0 and dir != self._path: raise NotFoundException return files_rsp def get_file(self,file): file = os.path.join(self._path, file) d, f = os.path.split(file) files_rsp = self.db_con.get_file(d, f,\ infos = self.media_attr, type = self.type) if len(files_rsp) == 0: # this file is not found raise NotFoundException return files_rsp def get_file_withids(self,file_ids): files_rsp = self.db_con.get_file_withids(file_ids,\ infos = self.media_attr, type = self.type) if len(files_rsp) != len(file_ids): raise NotFoundException return files_rsp def search(self, filter, ords = [], limit = None): ft = mediafilters.And() ft.combine(mediafilters.Equals("type", self.__class__.search_type)) if filter is not None: ft.combine(filter) return self.db_con.search(ft, infos = self.media_attr, orders=ords,\ limit = limit) def get_root_path(self): return self._path def get_root_paths(self): root_paths = [self.get_root_path()] for id, dirlink_record in self.db_con.get_all_dirlinks('', self.type): dirlink = os.path.join(self.get_root_path(), dirlink_record) root_paths.append(dirlink) return root_paths def get_status(self): status = [] if not self._update_end: status.append((self.type+"_updating_db",self._update_id)) if self._update_error: status.append((self.type+"_updating_error",self._update_error)) self._update_error = None return status def close(self): pass # # Update process # def update(self, force = False, sync = False): if self._update_end: self._update_id += 1 if sync: # synchrone update self._update(force) self._update_end = True else: # asynchrone update self.defered = threads.deferToThread(self._update, force) self.defered.pause() # Add callback functions succ = lambda *x: self.end_update() self.defered.addCallback(succ) # Add errback functions def error_handler(failure,db_class): # Log the exception to debug pb later failure.printTraceback() db_class.end_update(False) return False self.defered.addErrback(error_handler,self) self.defered.unpause() self.dispatch_signame(self.update_signal_name) return self._update_id return 0 def is_in_root(self, path, root=None): """Checks if a directory is physically in the supplied root (the library root by default).""" if not root: root = self.get_root_path() real_root = os.path.realpath(root) real_path = os.path.realpath(path) head = real_path old_head = None while head != old_head: if head == real_root: return True old_head = head head, tail = os.path.split(head) return False def is_in_a_root(self, path, roots): """Checks if a directory is physically in one of the supplied roots.""" for root in roots: if self.is_in_root(path, root): return True return False def _update_dir(self, dir, force = False, dispatch_signal = True): # dirname/filename : (id, lastmodified) library_files = dict([(os.path.join(it[1],it[3]), (it[2],it[4]))\ for it in self.db_con.get_all_files(dir,self.type)]) # name : id library_dirs = dict([(item[1],item[0]) for item \ in self.db_con.get_all_dirs(dir,self.type)]) # name library_dirlinks = [item[1] for item\ in self.db_con.get_all_dirlinks(dir, self.type)] changes = self.walk_directory(dir or self.get_root_path(), library_dirs, library_files, library_dirlinks, force=force, dispatch_signal=dispatch_signal) # Remove unexistent files and directories from library for (id, lastmodified) in library_files.values(): self.db_con.remove_file(id) if dispatch_signal: reactor.callFromThread(self.dispatch_signame,\ 'mediadb.mupdate', attrs = {"type": "remove", "id": id}) changes.append((id, "remove")) for id in library_dirs.values(): self.db_con.remove_dir(id) for dirlinkname in library_dirlinks: self.db_con.remove_dirlink(dirlinkname, self.type) if self.watcher: self.watcher.stop_watching_dir(dirlinkname) return changes def _update(self, force = False): self._update_end = False try: # compare keys recorded in the database with needed key # if there is a difference, force update keys = self.db_con.get_media_keys(self.search_type) # remove cover because it is not used try: keys.remove(("cover",)) except ValueError: pass if len(keys) > 0 and len(keys) != len(self.media_attr): log.msg(\ _("%s library has to be updated, this can take a while.")%\ (self.type,)) force = True self._update_dir('', force=force) self.mutex.acquire() self.db_con.erase_empty_dir(self.type) self.db_con.update_stats(self.type) # commit changes self.db_con.connection.commit() self.mutex.release() finally: # close the connection self.db_con.close() def walk_directory(self, walk_root, library_dirs, library_files, library_dirlinks, force = False, forbidden_roots=None, dispatch_signal=True): """Walk a directory for files to update. Called recursively to carefully handle symlinks.""" if not forbidden_roots: forbidden_roots = [self.get_root_path()] changes = [] for root, dirs, files in os.walk(walk_root): try: root = self._encode(root) except UnicodeError: # skip this directory continue try: dir_id = library_dirs[root] except KeyError: dir_id = self.db_con.insert_dir(root, self.type) else: del library_dirs[root] # search symlinks for dir in dirs: try: dir = self._encode(dir) except UnicodeError: # skip this directory continue # Walk only symlinks that aren't in library root or in one of # the additional known root paths which consist in already # crawled and out-of-main-root directories # (i.e. other symlinks). dir_path = os.path.join(root, dir) if os.path.islink(dir_path): if not self.is_in_a_root(dir_path, forbidden_roots): forbidden_roots.append(os.path.realpath(dir_path)) if dir_path in library_dirlinks: library_dirlinks.remove(dir_path) else: self.db_con.insert_dirlink(dir_path, self.type) if self.watcher: self.watcher.watch_dir(dir_path, self) changes.extend(self.walk_directory(dir_path, library_dirs, library_files, library_dirlinks, force, forbidden_roots, dispatch_signal)) # else update files changes.extend(self.update_files(root, dir_id, files, library_files, force, dispatch_signal)) return changes def end_update(self, result = True): self._update_end = True if result: log.msg(_("The %s library has been updated") % self.type) else: msg = _("Unable to update the %s library. See log.") % self.type log.err(msg) self._update_error = msg return True def update_files(self, root, dir_id, files, library_files, force = False, dispatch_signal=True): changes = [] for file in files: try: file = self._encode(file) except UnicodeError: # skip this file continue file_path = os.path.join(root, file) try: fid, lastmodified = library_files[file_path] need_update = force or os.stat(file_path).st_mtime>lastmodified changes_type = "update" except KeyError: need_update, fid = True, None changes_type = "add" else: del library_files[file_path] if need_update: file_info = self._get_file_info(file_path) if file_info is not None: # file supported fid = self.set_media(dir_id, file_path, file_info, fid) if fid: self.set_extra_infos(root, file, fid) if need_update and fid: if dispatch_signal: reactor.callFromThread(self.dispatch_signame,\ 'mediadb.mupdate', attrs = {"type": changes_type, "id": fid}) changes.append((fid, changes_type)) return changes def set_media(self, dir_id, file_path, file_info, file_id): if file_info is None: return file_id # not supported lastmodified = os.stat(file_path).st_mtime if file_id: # do not update persistent attribute for attr in self.__class__.persistent_attr: del file_info[attr] fid = file_id self.db_con.update_file(fid, lastmodified) else: filename = os.path.basename(file_path) fid = self.db_con.insert_file(dir_id, filename, lastmodified) self.db_con.set_media_infos(fid, file_info) return fid def set_extra_infos(self, dir, file, file_id): pass def _get_file_info(self, file_path): (base, ext) = os.path.splitext(file_path) # try to get infos from this file try: file_info = self.ext_dict[ext.lower()]().parse(file_path) except (TypeError, KeyError): log.info(_("File %s not supported") % file_path) return None except Exception, ex: log.err(_("Unable to get infos from %s, see traceback")%file_path) log.err("------------------Traceback lines--------------------") log.err(self._encode(traceback.format_exc())) log.err("-----------------------------------------------------") return None return file_info ####################################################################### ## Inotify actions ####################################################################### @inotify_action def add_file(self, path, file): file_path = os.path.join(path, file) file_info = self._get_file_info(file_path) if not file_info: return self._inotify_add_info(path, file) try: dir_id, file_id = self.db_con.is_file_exist(path, file, self.type) except TypeError: dir_id = self.db_con.is_dir_exist(path, self.type) or\ self.db_con.insert_dir(path, self.type) file_id = None fid = self.set_media(dir_id, file_path, file_info, file_id) if fid: self.set_extra_infos(path, file, fid) self._add_missing_dir(os.path.dirname(path)) return [(fid, "add")] return None @inotify_action def update_file(self, path, name): try: dir_id, file_id = self.db_con.is_file_exist(path, name, self.type) except TypeError: return self._inotify_update_info(path, name) else: file_path = os.path.join(path, name) file_info = self._get_file_info(file_path) if not file_info: return self._inotify_update_info(path, name) self.set_media(dir_id, file_path, file_info, file_id) return [(file_id, "update")] @inotify_action def remove_file(self, path, name): file = self.db_con.is_file_exist(path, name, self.type) if file: dir_id, file_id = file self.db_con.remove_file(file_id) self._remove_empty_dir(path) return [(file_id, "remove")] else: return self._inotify_remove_info(path, name) @inotify_action def add_directory(self, path, name, dirlink=False): dir_path = os.path.join(path, name) if dirlink: self.db_con.insert_dirlink(dir_path, self.type) self.watcher.watch_dir(dir_path, self) changes = self._update_dir(dir_path.rstrip("/"), dispatch_signal=False) self._add_missing_dir(os.path.dirname(dir_path)) self._remove_empty_dir(path) return changes @inotify_action def remove_directory(self, path, name, dirlink=False): dir_path = os.path.join(path, name) dir_id = self.db_con.is_dir_exist(dir_path, self.type) if not dir_id: return None if dirlink: self.db_con.remove_dirlink(dir_path, self.type) self.watcher.stop_watching_dir(dir_path) ids = self.db_con.remove_recursive_dir(dir_path) self._remove_empty_dir(path) return [(id, "remove") for id in ids] def inotify_record_changes(self, fids): self.mutex.acquire() self.db_con.update_stats(self.type) self.db_con.connection.commit() for fid, signal_type in fids: reactor.callFromThread(self.dispatch_signame, 'mediadb.mupdate',\ attrs = {"type": signal_type, "id": fid}) reactor.callFromThread(self.dispatch_signame, self.update_signal_name) self.mutex.release() def _remove_empty_dir(self, path): while path != "": if len(self.db_con.get_all_files(path, self.type)) > 0: break dir_id = self.db_con.is_dir_exist(path, self.type) if dir_id: self.db_con.remove_dir(dir_id) path = os.path.dirname(path) def _add_missing_dir(self, path): """ add missing dir in the mediadb """ while path != "": dir_id = self.db_con.is_dir_exist(path, self.type) if dir_id: break self.db_con.insert_dir(path, self.type) path = os.path.dirname(path) class AudioLibrary(_Library): type = "audio" search_type = "song" update_signal_name = 'mediadb.aupdate' custom_attr = ("artist","album","genre","tracknumber","date","bitrate",\ "replaygain_track_gain","replaygain_track_peak",\ "various_artist","discnumber") cover_name = ("cover.jpg", "folder.jpg", ".folder.jpg",\ "cover.png", "folder.png", ".folder.png") def get_cover(self, file_id): try: (cover_id, mime, image) = self.db_con.get_file_cover(file_id) except TypeError: raise NotFoundException return {"mime": mime, "cover": base64.b64decode(image), "id": cover_id} def __extract_cover(self, cover_path): if os.path.getsize(cover_path) > 512*1024: return None # file too large (> 512k) # parse video file with kaa cover_infos = kaa.metadata.parse(cover_path) if cover_infos is None: raise TypeError(_("cover %s not supported by kaa parser") % \ cover_path) # get mime type of this picture mime_type = cover_infos["mime"] if unicode(mime_type) not in (u"image/jpeg", u"image/png"): log.info(_("cover %s : wrong mime type") % cover_path) return None try: fd = open(cover_path) except Exception, ex: log.info(_("Unable to open cover file %s") % cover_path) return None rs = fd.read() fd.close() return mime_type, base64.b64encode(rs) def __find_cover(self, dir): cover = None for name in self.cover_name: cover_path = os.path.join(dir, name) if os.path.isfile(cover_path): try: (cover, lmod) = self.db_con.is_cover_exist(cover_path) except TypeError: try: mime, image = self.__extract_cover(cover_path) except TypeError: return None cover = self.db_con.add_cover(cover_path, mime, image) else: if int(lmod)lastmodified changes_type = "update" except KeyError: need_update, fid = True, None changes_type = "add" else: del library_files[file_path] if need_update: file_info = self._get_file_info(file_path) if file_info is not None: # file supported fid = self.set_media(dir_id,file_path,file_info,fid,cover) elif fid and cover: self.__update_cover(fid, cover) if need_update and fid: if dispatch_signal: reactor.callFromThread(self.dispatch_signame, 'mediadb.mupdate',\ attrs = {"type": changes_type, "id": fid}) changes.append((fid, changes_type)) return changes def set_media(self, dir_id, file_path, file_info, file_id, cover = None): if file_info is not None and "cover" in file_info: # find a cover in the file image = base64.b64encode(file_info["cover"]["data"]) mime = file_info["cover"]["mime"] # use hash to identify cover in the db and avoid duplication img_hash = self.__get_digest(image) try: (cover, lmod) = self.db_con.is_cover_exist(img_hash) except TypeError: cover = self.db_con.add_cover(img_hash, mime, image) file_info["cover"] = cover elif cover: # use the cover available in this directory file_info["cover"] = cover fid = super(AudioLibrary, self).set_media(dir_id, file_path, \ file_info, file_id) # update compilation tag if necessary if fid and "album" in file_info.keys() and file_info["album"] != '': self.db_con.set_variousartist_tag(fid, file_info) return fid # # custom inotify actions # @inotify_action def add_file(self, path, file): file_path = os.path.join(path, file) file_info = self._get_file_info(file_path) if not file_info and file in self.cover_name: # it is a cover files = self.db_con.get_dircontent_id(path, self.type) if len(files) > 0: file_path = os.path.join(path, file) try: mime, image = self.__extract_cover(file_path) except TypeError: # image not supported return False if image: cover = self.db_con.add_cover(file_path, mime, image) changes = [] for (id,) in files: if self.__update_cover(id, cover): changes.append((id, "update")) return changes return None try: dir_id, file_id = self.db_con.is_file_exist(path, file, self.type) except TypeError: dir_id = self.db_con.is_dir_exist(path, self.type) or\ self.db_con.insert_dir(path, self.type) file_id = None fid = self.set_media(dir_id, file_path, file_info, file_id) if fid: self._add_missing_dir(os.path.dirname(path)) return [(fid, "add")] return None def _inotify_remove_info(self, path, file): rs = self.db_con.is_cover_exist(os.path.join(path, file)) try: (cover, lmod) = rs except TypeError: return None ids = self.db_con.search_id("cover", cover) for (id,) in ids: self.db_con.set_media_infos(id, {"cover": ""}) self.db_con.remove_cover(cover) return [(id, "update") for (id,) in ids] def _inotify_update_info(self, path, file): file_path = os.path.join(path, file) rs = self.db_con.is_cover_exist(file_path) try: (cover, lmod) = rs except TypeError: return None image = self.__extract_cover(file_path) if image: self.db_con.update_cover(cover, image) return [] ########################################################### class VideoLibrary(_Library): type = "video" search_type = "video" update_signal_name = 'mediadb.vupdate' custom_attr = ("videoheight", "videowidth","external_subtitle",\ "audio_channels", "subtitle_channels") subtitle_ext = (".srt",) def set_extra_infos(self, dir, file, file_id): file_path = os.path.join(dir, file) (base_path,ext) = os.path.splitext(file_path) sub = "" for ext_type in self.subtitle_ext: if os.path.isfile(os.path.join(base_path + ext_type)): sub = quote_uri(base_path + ext_type) break try: (recorded_sub,) = self.db_con.get_file_info(file_id,\ "external_subtitle") except TypeError: recorded_sub = None if recorded_sub != sub: self.db_con.set_media_infos(file_id,{"external_subtitle": sub}) # # custom inotify actions # def _inotify_add_info(self, path, file): (base_file, ext) = os.path.splitext(file) if ext in self.subtitle_ext: for video_ext in self.ext_dict.keys(): try: (dir_id,fid,) = self.db_con.is_file_exist(path,\ base_file+video_ext, self.type) except TypeError: pass else: uri = quote_uri(os.path.join(path, file)) self.db_con.set_media_infos(fid, {"external_subtitle": uri}) return [(fid, "update")] return None def _inotify_remove_info(self, path, file): (base_file, ext) = os.path.splitext(file) if ext in self.subtitle_ext: ids = self.db_con.search_id("external_subtitle",\ quote_uri(os.path.join(path, file))) for (id,) in ids: self.db_con.set_media_infos(id, {"external_subtitle": ""}) return [(id, "update") for (id,) in ids] return None def _inotify_update_info(self, path, file): return None ########################################################### # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/mediadb/formats/0000755000175000017500000000000011354730161016047 5ustar royroydeejayd-0.10.0/deejayd/mediadb/formats/audio/0000755000175000017500000000000011354730161017150 5ustar royroydeejayd-0.10.0/deejayd/mediadb/formats/audio/ogg.py0000644000175000017500000000304411351210475020275 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from deejayd.mediadb.formats._base import _AudioFile extensions = [".ogg"] try: from mutagen.oggvorbis import OggVorbis except ImportError: extensions = [] class OggFile(_AudioFile): _tagclass_ = OggVorbis def get_cover(self, tag_info): return None # disable for now # not work correctly #if 'coverarttype' in tag_info.keys() and\ # int(tag_info['coverarttype'][0])==3: # try: # return {"data": tag_info['coverart'][0],\ # "mime": tag_info['coverartmime'][0]} # except KeyError: # return None #return None object = OggFile # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/mediadb/formats/audio/mp4.py0000644000175000017500000000475611351210475020234 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from deejayd.mediadb.formats._base import _AudioFile extensions = ['.mp4', '.m4a'] try: from mutagen.mp4 import MP4 except ImportError: extensions = [] class Mp4File(_AudioFile): __translate = { "\xa9nam": "title", "\xa9alb": "album", "\xa9ART": "artist", "\xa9day": "date", "\xa9gen": "genre", "----:com.apple.iTunes:replaygain_track_gain": "replaygain_track_gain", "----:com.apple.iTunes:replaygain_track_peak": "replaygain_track_peak", } __tupletranslate = { "trkn": "tracknumber", "disk": "discnumber", } def parse(self, file): infos = _AudioFile.parse(self, file) mp4_info = MP4(file) infos["bitrate"] = int(mp4_info.info.bitrate) infos["length"] = int(mp4_info.info.length) for tag, name in self.__translate.iteritems(): try: infos[name] = mp4_info[tag][0] except: infos[name] = ''; for tag, name in self.__tupletranslate.iteritems(): try: cur, total = mp4_info[tag][0] if total: self[name] = "%02d/%02d" % (cur, total) else: infos[name] = "%02d" % cur except: infos[name] = ''; # extract cover try: cover = mp4_info["covr"][0] except (KeyError, ValueError): pass else: mime = "image/jpeg" if cover.format == cover.FORMAT_PNG: mime = "image/png" infos["cover"] = {"mime": mime, "data": cover} infos["various_artist"] = infos["artist"] return infos object = Mp4File # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/mediadb/formats/audio/flac.py0000644000175000017500000000250111351210475020423 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from deejayd.mediadb.formats._base import _AudioFile extensions = [".flac"] try: from mutagen.flac import FLAC except ImportError: extensions = [] class FlacFile(_AudioFile): _tagclass_ = FLAC def get_cover(self, tag_info): for picture in tag_info.pictures: if picture.type == 3: # album front cover return {"data": picture.data, "mime": picture.mime} return None object = FlacFile # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/mediadb/formats/audio/mp3.py0000644000175000017500000000743711351210475020232 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import os from deejayd.mediadb.formats._base import _AudioFile extensions = [".mp3",".mp2",".aac"] try: from mutagen.mp3 import MP3 except ImportError: extensions = [] class Mp3File(_AudioFile): IDS = { "TIT2": "title", "TPE1": "artist", "TALB": "album", } replaygain_process = False def parse(self, file): infos = _AudioFile.parse(self, file) mp3_info = MP3(file) infos.update([ ("title", ""), ("artist", ""), ("various_artist", ""), ("album", ""), ("tracknumber", ""), ("discnumber", ""), ("date", ""), ("genre", ""), ("replaygain_track_gain", ""), ("replaygain_track_peak", ""), ("bitrate", int(mp3_info.info.bitrate)), ("length", int(mp3_info.info.length)), ]) tag = mp3_info.tags if not tag: infos["title"] = os.path.split(file)[1] return infos for frame in tag.values(): if frame.FrameID == "TXXX": if frame.desc in ("replaygain_track_peak",\ "replaygain_track_gain"): # Some versions of Foobar2000 write broken Replay Gain # tags in this format. infos[frame.desc] = frame.text[0] self.replaygain_process = True else: continue elif frame.FrameID == "RVA2": # replaygain self.__process_rg(frame, infos) continue elif frame.FrameID == "TCON": # genre infos["genre"] = frame.genres[0] continue elif frame.FrameID == "TDRC": # date list = [stamp.text for stamp in frame.text] infos["date"] = list[0] continue elif frame.FrameID == "TRCK": # tracknumber infos["tracknumber"] = self._format_number(frame.text[0]) elif frame.FrameID == "TPOS": # discnumber infos["discnumber"] = self._format_number(frame.text[0]) elif frame.FrameID in self.IDS.keys(): infos[self.IDS[frame.FrameID]] = frame.text[0] elif frame.FrameID == "APIC": # picture if frame.type == 3: # album front cover infos["cover"] = {"data": frame.data, "mime": frame.mime} else: continue infos["various_artist"] = infos["artist"] return infos def __process_rg(self, frame, infos): if frame.channel == 1: if frame.desc == "album": return # not supported elif frame.desc == "track" or not self.replaygain_process: infos["replaygain_track_gain"] = "%+f dB" % frame.gain infos["replaygain_track_peak"] = str(frame.peak) self.replaygain_process = True object = Mp3File # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/mediadb/formats/audio/__init__.py0000644000175000017500000000160611351210475021262 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/mediadb/formats/video/0000755000175000017500000000000011354730161017155 5ustar royroydeejayd-0.10.0/deejayd/mediadb/formats/video/__init__.py0000644000175000017500000000160611351210475021267 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/mediadb/formats/video/default.py0000644000175000017500000000244311351210475021154 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import os, datetime from deejayd.mediadb.formats._base import _VideoFile extensions = (".avi", ".asf", ".wmv", ".ogm", ".mkv", ".mp4", ".mov", ".m4v") class VideoFile(_VideoFile): mime_type = (u"video/x-msvideo", u"video/x-ms-asf", u"video/x-ms-wmv",\ u"video/x-ogg", u"video/x-theora",u"application/ogg",\ u"video/x-matroska", u'video/quicktime', u'video/mp4') object = VideoFile # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/mediadb/formats/_base.py0000644000175000017500000000750611351210475017500 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import os from deejayd.utils import quote_uri import kaa.metadata class _MediaFile(object): type = "unknown" def parse(self, file_path): return { "filename": os.path.basename(file_path), "uri": quote_uri(file_path), "type": self.type, "rating": "2", # [0-4] "lastplayed": "0", "skipcount": "0", "playcount": "0", } class _AudioFile(_MediaFile): _tagclass_ = None type = "song" supported_tag = ("tracknumber","title","genre","artist","album",\ "discnumber","date","replaygain_track_gain",\ "replaygain_track_peak") def _format_number(self, nb): numbers = nb.split("/") try: numbers = ["%02d" % int(num) for num in numbers] except (TypeError, ValueError): return nb return "/".join(numbers) def parse(self, file): infos = _MediaFile.parse(self, file) if self._tagclass_: tag_info = self._tagclass_(file) for i in ("bitrate", "length"): try: infos[i] = int(getattr(tag_info.info, i)) except AttributeError: infos[i] = 0 for t in self.supported_tag: try: info = tag_info[t][0] except: info = '' if t in ("tracknumber", "discnumber"): info = self._format_number(info) infos[t] = info # get front cover album if available infos["various_artist"] = infos["artist"] cover = self.get_cover(tag_info) if cover: infos["cover"] = cover return infos class _VideoFile(_MediaFile): type = "video" mime_type = None def _format_title(self, f): (filename,ext) = os.path.splitext(f) title = filename.replace(".", " ") title = title.replace("_", " ") return title.title() def parse(self, file): infos = _MediaFile.parse(self, file) infos.update({ "audio_channels": "0", "subtitle_channels": "0", "length": "0", "videoheight": "0", "videowidth": "0", }) (path,filename) = os.path.split(file) infos["title"] = self._format_title(filename) # parse video file with kaa kaa_infos = kaa.metadata.parse(file) if kaa_infos is None: raise TypeError(_("Video media %s not supported by kaa parser") \ % file) if len(kaa_infos["video"]) == 0: raise TypeError(_("This file is not a video")) infos["length"] = int(kaa_infos["length"]) infos["videowidth"] = kaa_infos["video"][0]["width"] infos["videoheight"] = kaa_infos["video"][0]["height"] infos["audio_channels"] = len(kaa_infos["audio"]) infos["subtitle_channels"] = len(kaa_infos["subtitles"]) return infos # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/mediadb/formats/__init__.py0000644000175000017500000000267711351210475020172 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import os, glob def get_extensions(player, type = "audio"): base = os.path.join(os.path.dirname(__file__), type) base_import = "deejayd.mediadb.formats.%s" % type ext_dict = {} modules = [os.path.basename(f[:-3]) \ for f in glob.glob(os.path.join(base, "[!_]*.py"))] for m in modules: mod = __import__(base_import+"."+m, {}, {}, base) filetype_class = mod.object for ext in mod.extensions: if player.is_supported_format(ext): ext_dict[ext] = filetype_class return ext_dict # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/net/0000755000175000017500000000000011354730161013575 5ustar royroydeejayd-0.10.0/deejayd/net/client.py0000644000175000017500000010577711351210475015444 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """The Deejayd python client library""" import socket, asyncore, threading import sys,time from Queue import Queue, Empty import deejayd.interfaces from deejayd.interfaces import DeejaydError, DeejaydKeyValue, DeejaydSignal from deejayd.rpc import Fault, DEEJAYD_PROTOCOL_VERSION from deejayd.rpc.jsonbuilders import JSONRPCRequest, Get_json_filter from deejayd.rpc.jsonparsers import loads_response, Parse_json_filter MSG_DELIMITER = 'ENDJSON\n' MAX_BANNER_LENGTH = 50 class DeejaydAnswer(deejayd.interfaces.DeejaydAnswer): def __init__(self, server = None): deejayd.interfaces.DeejaydAnswer.__init__(self) self.answer_received = threading.Event() self.callbacks = [] self.server = server self.id = None def set_id(self, id): self.id = id def get_id(self): return self.id def wait(self): self.answer_received.wait() def _received(self, contents): self.contents = contents self.answer_received.set() self._run_callbacks() def set_error(self, msg): deejayd.interfaces.DeejaydAnswer.set_error(self, msg) self.answer_received.set() self._run_callbacks() def get_contents(self): self.wait() return deejayd.interfaces.DeejaydAnswer.get_contents(self) def add_callback(self, cb): if self.answer_received.isSet(): cb(self) else: self.callbacks.append(cb) def _run_callbacks(self): self.wait() for cb in self.callbacks: cb(self) class DeejaydKeyValue(DeejaydAnswer, deejayd.interfaces.DeejaydKeyValue): def __init__(self, server=None): DeejaydAnswer.__init__(self, server) class DeejaydList(DeejaydAnswer, deejayd.interfaces.DeejaydList): def __init__(self, server=None): DeejaydAnswer.__init__(self, server) class DeejaydFileList(DeejaydAnswer, deejayd.interfaces.DeejaydFileList): def __init__(self, server = None): deejayd.interfaces.DeejaydFileList.__init__(self) DeejaydAnswer.__init__(self, server) class DeejaydMediaList(DeejaydAnswer, deejayd.interfaces.DeejaydMediaList): def __init__(self, server = None): deejayd.interfaces.DeejaydMediaList.__init__(self) DeejaydAnswer.__init__(self, server) class DeejaydDvdInfo(DeejaydAnswer, deejayd.interfaces.DeejaydDvdInfo): def __init__(self, server = None): deejayd.interfaces.DeejaydDvdInfo.__init__(self) DeejaydAnswer.__init__(self, server) class DeejaydStaticPlaylist(deejayd.interfaces.DeejaydStaticPlaylist): def __init__(self, server, pl_id, name): self.server = server self.__pl_id = pl_id self.__name = name def get(self, first=0, length=-1): params = [self.__pl_id, first] if length != -1: params.append(length) cmd = JSONRPCRequest('recpls.get', params) ans = DeejaydMediaList(self.server) return self.server._send_command(cmd, ans) def __add(self, values, type): cmd = JSONRPCRequest('recpls.staticAdd', [self.__pl_id, values, type]) return self.server._send_command(cmd) def add_songs(self, song_ids): return self.__add(song_ids, "id") def add_paths(self, paths): return self.__add(paths, "path") class DeejaydMagicPlaylist(deejayd.interfaces.DeejaydMagicPlaylist): def __init__(self, server, pl_id, name): self.server = server self.__pl_id = pl_id self.__name = name def get(self, first=0, length=-1): params = [self.__pl_id, first] if length != -1: params.append(length) cmd = JSONRPCRequest('recpls.get', params) ans = DeejaydMediaList(self.server) return self.server._send_command(cmd, ans) def add_filter(self, filter): jfilter = Get_json_filter(filter).dump() cmd = JSONRPCRequest('recpls.magicAddFilter', [self.__pl_id, jfilter]) return self.server._send_command(cmd) def remove_filter(self, filter): jfilter = Get_json_filter(filter).dump() cmd = JSONRPCRequest('recpls.magicRemoveFilter',\ [self.__pl_id, jfilter]) return self.server._send_command(cmd) def clear_filters(self): cmd = JSONRPCRequest('recpls.magicClearFilter', [self.__pl_id]) return self.server._send_command(cmd) def get_properties(self): cmd = JSONRPCRequest('recpls.magicGetProperties', [self.__pl_id]) return self.server._send_command(cmd, DeejaydKeyValue()) def set_property(self, key, value): cmd = JSONRPCRequest('recpls.magicSetProperty',\ [self.__pl_id, key, value]) return self.server._send_command(cmd) class DeejaydWebradioList(deejayd.interfaces.DeejaydWebradioList): def __init__(self, server): self.server = server def get(self, first = 0, length = None): params = [first] if length != None: params.append(length) cmd = JSONRPCRequest('webradio.get', params) ans = DeejaydMediaList(self) return self.server._send_command(cmd, ans) def get_available_sources(self): cmd = JSONRPCRequest('webradio.getAvailableSources', []) return self.server._send_command(cmd, DeejaydKeyValue()) def get_source_categories(self, source_name): cmd = JSONRPCRequest('webradio.getSourceCategories', [source_name]) return self.server._send_command(cmd, DeejaydList()) def set_source(self, source_name): cmd = JSONRPCRequest('webradio.setSource', [source_name]) return self.server._send_command(cmd) def set_source_categorie(self, categorie): cmd = JSONRPCRequest('webradio.setSourceCategorie', [categorie]) return self.server._send_command(cmd) def add_webradio(self, name, url): cmd = JSONRPCRequest('webradio.localAdd', [name, url]) return self.server._send_command(cmd) def delete_webradios(self, wr_ids): cmd = JSONRPCRequest('webradio.localRemove', [wr_ids]) return self.server._send_command(cmd) def clear(self): cmd = JSONRPCRequest('webradio.localClear', []) return self.server._send_command(cmd) class DeejaydQueue(deejayd.interfaces.DeejaydQueue): def __init__(self, server): self.server = server def get(self, first = 0, length = None): params = [first] if length != None: params.append(length) cmd = JSONRPCRequest('queue.get', params) ans = DeejaydMediaList(self) return self.server._send_command(cmd, ans) def add_songs(self, song_ids, pos = None): params = [song_ids] if pos != None: params.append(pos) cmd = JSONRPCRequest('queue.addIds', params) return self.server._send_command(cmd) def add_paths(self, paths, pos = None): params = [paths] if pos != None: params.append(pos) cmd = JSONRPCRequest('queue.addPath', params) return self.server._send_command(cmd) def load_playlists(self, pl_ids, pos = None): params = [pl_ids] if pos != None: params.append(pos) cmd = JSONRPCRequest('queue.loads', params) return self.server._send_command(cmd) def clear(self): cmd = JSONRPCRequest('queue.clear', []) return self.server._send_command(cmd) def move(self, ids, new_pos): cmd = JSONRPCRequest('queue.move', [ids, new_pos]) return self.server._send_command(cmd) def del_songs(self, ids): cmd = JSONRPCRequest('queue.remove', [ids]) return self.server._send_command(cmd) class DeejaydPlaylistMode(deejayd.interfaces.DeejaydPlaylistMode): def __init__(self, server): self.server = server def get(self, first = 0, length = None): params = [first] if length != None: params.append(length) cmd = JSONRPCRequest('playlist.get', params) ans = DeejaydMediaList(self) return self.server._send_command(cmd, ans) def save(self, name): cmd = JSONRPCRequest('playlist.save', [name]) return self.server._send_command(cmd, DeejaydKeyValue()) def add_songs(self, song_ids, pos = None): params = [song_ids] if pos != None: params.append(pos) cmd = JSONRPCRequest('playlist.addIds', params) return self.server._send_command(cmd) def add_paths(self, paths, pos = None): params = [paths] if pos != None: params.append(pos) cmd = JSONRPCRequest('playlist.addPath', params) return self.server._send_command(cmd) def loads(self, pl_ids, pos = None): params = [pl_ids] if pos != None: params.append(pos) cmd = JSONRPCRequest('playlist.loads', params) return self.server._send_command(cmd) def move(self, ids, new_pos): cmd = JSONRPCRequest('playlist.move', [ids, new_pos]) return self.server._send_command(cmd) def shuffle(self): cmd = JSONRPCRequest('playlist.shuffle', []) return self.server._send_command(cmd) def clear(self): cmd = JSONRPCRequest('playlist.clear', []) return self.server._send_command(cmd) def del_songs(self, ids): cmd = JSONRPCRequest('playlist.remove', [ids]) return self.server._send_command(cmd) class DeejaydPanel(deejayd.interfaces.DeejaydPanel): def __init__(self, server): self.server = server def get(self, first = 0, length = None): params = [first] if length != None: params.append(length) cmd = JSONRPCRequest('panel.get', params) ans = DeejaydMediaList(self) return self.server._send_command(cmd, ans) def get_panel_tags(self): cmd = JSONRPCRequest('panel.tags', []) return self.server._send_command(cmd, DeejaydList()) def get_active_list(self): cmd = JSONRPCRequest('panel.activeList', []) return self.server._send_command(cmd, DeejaydKeyValue()) def set_active_list(self, type, pl_id=""): cmd = JSONRPCRequest('panel.setActiveList', [type, pl_id]) return self.server._send_command(cmd) def set_panel_filters(self, tag, values): if not isinstance(values, list): values = [values] cmd = JSONRPCRequest('panel.setFilter', [tag, values]) return self.server._send_command(cmd) def remove_panel_filters(self, tag): cmd = JSONRPCRequest('panel.removeFilter', [tag]) return self.server._send_command(cmd) def clear_panel_filters(self): cmd = JSONRPCRequest('panel.clearFilter', []) return self.server._send_command(cmd) def set_search_filter(self, tag, value): cmd = JSONRPCRequest('panel.setSearch', [tag, value]) return self.server._send_command(cmd) def clear_search_filter(self): cmd = JSONRPCRequest('panel.clearSearch', []) return self.server._send_command(cmd) def set_sorts(self, sort): cmd = JSONRPCRequest('panel.setSort', [sort]) return self.server._send_command(cmd) class DeejaydVideo(deejayd.interfaces.DeejaydVideo): def __init__(self, server): self.server = server def get(self, first = 0, length = None): params = [first] if length != None: params.append(length) cmd = JSONRPCRequest('video.get', params) ans = DeejaydMediaList(self) return self.server._send_command(cmd, ans) def set(self, value, type = "directory"): cmd = JSONRPCRequest('video.set', [value, type]) return self.server._send_command(cmd) def set_sorts(self, sort): cmd = JSONRPCRequest('video.sort', [sort]) return self.server._send_command(cmd) class ConnectError(deejayd.interfaces.DeejaydError): pass class _DeejayDaemon(deejayd.interfaces.DeejaydCore): """Abstract class for a deejay daemon client.""" def __init__(self): deejayd.interfaces.DeejaydCore.__init__(self) self.expected_answers_queue = Queue() self.connected = False def connect(self, host, port, ignore_version=False): raise NotImplementedError def is_connected(self): return self.connected def disconnect(self): raise NotImplementedError def _version_from_banner(self, banner_line): tokenized_banner = banner_line.split(' ') try: version = tokenized_banner[2] except IndexError: raise ValueError else: numerical_version = map(int, version.split('.')) try: if tokenized_banner[3] == 'protocol': protocol_version = tokenized_banner[4] else: raise IndexError except IndexError: # Assume protocol is first one protocol_version = 1 else: protocol_version = int(protocol_version) return numerical_version, protocol_version def _version_is_supported(self, versions): numerical_version = versions[0] protocol_version = versions[1] return protocol_version == DEEJAYD_PROTOCOL_VERSION def _send_simple_command(self, cmd_name): cmd = JSONRPCRequest(cmd_name, []) return self._send_command(cmd) def get_recorded_playlist(self, pl_id): return DeejaydStaticPlaylist(self, pl_id) def get_playlist(self): return DeejaydPlaylistMode(self) def get_panel(self): return DeejaydPanel(self) def get_webradios(self): return DeejaydWebradioList(self) def get_video(self): return DeejaydVideo(self) def get_queue(self): return DeejaydQueue(self) def ping(self): return self._send_simple_command('ping') def set_mode(self, mode): cmd = JSONRPCRequest('setmode', [mode]) return self._send_command(cmd) def get_mode(self): cmd = JSONRPCRequest('availablemodes', []) return self._send_command(cmd, DeejaydKeyValue()) def set_option(self, source, option_name, option_value): cmd = JSONRPCRequest('setOption', [source, option_name, option_value]) return self._send_command(cmd) def get_status(self): cmd = JSONRPCRequest('status', []) return self._send_command(cmd, DeejaydKeyValue()) def get_stats(self): cmd = JSONRPCRequest('stats', []) return self._send_command(cmd, DeejaydKeyValue()) def play_toggle(self): return self._send_simple_command('player.playToggle') def stop(self): return self._send_simple_command('player.stop') def previous(self): return self._send_simple_command('player.previous') def next(self): return self._send_simple_command('player.next') def seek(self, pos, relative = False): cmd = JSONRPCRequest('player.seek', [pos, relative]) return self._send_command(cmd) def get_current(self): cmd = JSONRPCRequest('player.current', []) return self._send_command(cmd,DeejaydMediaList()) def go_to(self, id, id_type = "id", source = None): params = [id, id_type] if source: params.append(source) cmd = JSONRPCRequest('player.goto', params) return self._send_command(cmd) def set_volume(self, volume): cmd = JSONRPCRequest('player.setVolume', [volume]) return self._send_command(cmd) def set_player_option(self, name, value): cmd = JSONRPCRequest('player.setPlayerOption', [name, value]) return self._send_command(cmd) def update_audio_library(self, force = False): cmd = JSONRPCRequest('audiolib.update', [force]) return self._send_command(cmd, DeejaydKeyValue()) def update_video_library(self, force = False): cmd = JSONRPCRequest('videolib.update', [force]) return self._send_command(cmd, DeejaydKeyValue()) def get_audio_dir(self, dir = ""): ans = DeejaydFileList(self) cmd = JSONRPCRequest('audiolib.getDir', [dir]) return self._send_command(cmd, ans) def audio_search(self, pattern, type = 'all'): ans = DeejaydMediaList(self) cmd = JSONRPCRequest('audiolib.search', [pattern, type]) return self._send_command(cmd, ans) def mediadb_list(self, tag, filter): params = [tag] if filter is not None: params.append(Get_json_filter(filter).dump()) ans = DeejaydList(self) cmd = JSONRPCRequest('audiolib.taglist', params) return self._send_command(cmd, ans) def get_video_dir(self, dir = ""): ans = DeejaydFileList(self) cmd = JSONRPCRequest('videolib.getDir', [dir]) return self._send_command(cmd, ans) def create_recorded_playlist(self, name, type): cmd = JSONRPCRequest('recpls.create', [name, type]) return self._send_command(cmd, DeejaydKeyValue()) def get_recorded_playlist(self, pl_id, name, type): if type == "static": return DeejaydStaticPlaylist(self, pl_id, name) elif type == "magic": return DeejaydMagicPlaylist(self, pl_id, name) def erase_playlist(self, pl_ids): cmd = JSONRPCRequest('recpls.erase', [pl_ids]) return self._send_command(cmd) def get_playlist_list(self): cmd = JSONRPCRequest('recpls.list', []) return self._send_command(cmd,DeejaydMediaList()) def set_media_rating(self, media_ids, rating, type = "audio"): cmd = JSONRPCRequest('setRating', [media_ids, rating, type]) return self._send_command(cmd) def dvd_reload(self): return self._send_simple_command('dvd.reload') def get_dvd_content(self): cmd = JSONRPCRequest('dvd.get', []) ans = DeejaydDvdInfo(self) return self._send_command(cmd, ans) def _build_answer(self, msg): try: msg = loads_response(msg) except Fault, f: raise DeejaydError("JSONRPC error - %s - %s" % (f.code, f.message)) else: if msg["id"] is None: # it is a notification result = msg["result"]["answer"] type = msg["result"]["type"] if type == 'signal': signal = DeejaydSignal(result["name"], result["attrs"]) return self._dispatch_signal(signal) else: expected_answer = self.expected_answers_queue.get() if expected_answer.id != msg["id"]: raise DeejaydError("Bad id for JSON server answer") if msg["error"] is not None: # an error is returned expected_answer.set_error("Deejayd Server Error - %s - %s"\ % (msg["error"]["code"], msg["error"]["message"])) else: result = msg["result"]["answer"] type = msg["result"]["type"] if type == 'fileAndDirList': expected_answer.set_rootdir(result["root"]) expected_answer.set_files(result["files"]) expected_answer.set_directories(result["directories"]) elif type == 'mediaList': expected_answer.set_medias(result["medias"]) expected_answer.set_media_type(result["media_type"]) if "filter" in result and result["filter"] is not None: expected_answer.set_filter(\ Parse_json_filter(result["filter"])) if "sort" in result: expected_answer.set_sort(result["sort"]) elif type == 'dvdInfo': expected_answer.set_dvd_content(result) expected_answer._received(result) class DeejayDaemonSync(_DeejayDaemon): """Synchroneous deejayd client library.""" def __init__(self): _DeejayDaemon.__init__(self) self.socket_to_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.__timeout = 2 self.socket_to_server.settimeout(self.__timeout) self.next_msg = "" def connect(self, host, port, ignore_version=False): if self.connected: self.disconnect() self.host = host self.port = port try: self.socket_to_server.connect((self.host, self.port)) except socket.timeout, msg: # reset connection self._reset_socket() raise ConnectError('Connection timeout') except socket.error, msg: raise ConnectError('Connection with server failed : %s' % msg) self.socket_to_server.settimeout(None) socketFile = self.socket_to_server.makefile() # Catch version banner_line = socketFile.readline() self.connected = True if not banner_line.startswith("OK DEEJAYD")\ or len(banner_line) > MAX_BANNER_LENGTH: self.disconnect() raise ConnectError('Connection with server failed') try: versions = self._version_from_banner(banner_line) except ValueError: self.disconnect() raise ConnectError('Initial version dialog with server failed') if not ignore_version and not self._version_is_supported(versions): self.disconnect() raise ConnectError('This server version protocol is not handled by this client version') def _send_command(self, cmd, expected_answer = None): # Set a default answer by default if expected_answer == None: expected_answer = DeejaydAnswer(self) expected_answer.set_id(cmd.get_id()) self.expected_answers_queue.put(expected_answer) self._sendmsg(cmd.to_json()) rawmsg = self._readmsg() self._build_answer(rawmsg) return expected_answer def disconnect(self): if not self.connected: return self.socket_to_server.settimeout(self.__timeout) try: self._send_simple_command('close').get_contents() except socket.timeout: pass self._reset_socket() self.connected = False self.host = None self.port = None def _reset_socket(self): self.socket_to_server.close() # New Socket setup self.socket_to_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket_to_server.settimeout(self.__timeout) def _sendmsg(self, buf): self.socket_to_server.send(buf + MSG_DELIMITER) def _readmsg(self): msg_chunks = '' msg_chunk = '' def split_msg(msg,index): return (msg[0:index], msg[index+len(MSG_DELIMITER):len(msg)]) while 1: try: index = self.next_msg.index(MSG_DELIMITER) except ValueError: pass else: (rs,self.next_msg) = split_msg(self.next_msg, index) break msg_chunk = self.socket_to_server.recv(4096) # socket.recv returns an empty string if the socket is closed, so # catch this. if msg_chunk == '': raise socket.error() msg_chunks += msg_chunk try: index = msg_chunks.index(MSG_DELIMITER) except ValueError: pass else: (rs,self.next_msg) = split_msg(msg_chunks, index) break return rs # No subscription for the sync client def subscribe(self, signal_name, callback): raise NotImplementedError def unsubscribe(self, sub_id): raise NotImplementedError class _DeejaydSocket(asyncore.dispatcher): ac_in_buffer_size = 256 ac_out_buffer_size = 256 def __init__(self, socket_map, deejayd, ignore_version=False): asyncore.dispatcher.__init__(self, map=socket_map) self.deejayd = deejayd self.ignore_version = ignore_version self.create_socket(socket.AF_INET, socket.SOCK_STREAM) self.socket_die_callback = [] self.__connect_callback = [] self.state = "disconnected" self.msg_chunks = "" self.next_msg = "" def add_connect_callback(self, callback): self.__connect_callback.append(callback) def add_socket_die_callback(self, callback): self.socket_die_callback.append(callback) def __error_callbacks(self, msg): for cb in self.socket_die_callback: cb(msg) def handle_error(self): t, v, tb = sys.exc_info() assert tb # Must have a traceback if self.state != "json_protocol": # error appears when we try to connect for cb in self.__connect_callback: cb(False,str(v)) def handle_connect(self): self.state = "connected" def handle_close(self): if self.state == "json_protocol": self.__error_callbacks('disconnected') self.state = "disconnected" self.close() def handle_read(self): def split_msg(msg,index): return (msg[0:index], msg[index+len(MSG_DELIMITER):len(msg)]) if self.state == "connected": # Catch banner until first newline char banner_line = '' newchar = '' while newchar != '\n' and len(banner_line) < MAX_BANNER_LENGTH: banner_line += newchar newchar = self.recv(1) if banner_line.startswith("OK DEEJAYD"): try: versions = self.deejayd._version_from_banner(banner_line) except ValueError: for cb in self.__connect_callback: cb(False, 'Initial version dialog with server failed') if self.ignore_version\ or self.deejayd._version_is_supported(versions): self.state = 'json_protocol' # now we are sure to be connected for cb in self.__connect_callback: cb(True,"") else: for cb in self.__connect_callback: cb(False,\ 'This server version protocol is not handled by this client version') elif self.state == "json_protocol": msg_chunk = self.recv(256) # socket.recv returns an empty string if the socket is closed, so # catch this. if msg_chunk == '': self.close() self.msg_chunks += msg_chunk try: index = self.msg_chunks.index(MSG_DELIMITER) except ValueError: return else: (rawmsg,self.next_msg) = split_msg(self.msg_chunks, index) self.msg_chunks = "" self.answer_received(rawmsg) while self.next_msg != '': try: index = self.next_msg.index(MSG_DELIMITER) except ValueError: self.msg_chunks = self.next_msg self.next_msg = "" break else: (rawmsg,self.next_msg) = split_msg(self.next_msg, index) self.answer_received(rawmsg) elif self.state == "disconnected": # This should not happen raise AttributeError def answer_received(self,rawmsg): try: self.deejayd._build_answer(rawmsg) except DeejaydError: self.__error_callbacks("Unable to parse server answer : %s" %rawmsg) self.close() def handle_write(self): cmd = self.deejayd.command_queue.get() self.send(cmd.to_json()+MSG_DELIMITER) def writable(self): if self.state != "json_protocol": return False return not self.deejayd.command_queue.empty() class _DeejaydSocketThread: def __init__(self): self.__th = None self.socket_map = {} # Thanks # http://mail.python.org/pipermail/python-list/2004-November/290798.html def __asyncore_loop(self): self.__stop_asyncore_loop = False while self.socket_map and not self.__stop_asyncore_loop: asyncore.loop(timeout=1, map=self.socket_map, count=1) def stop(self): self.__stop_asyncore_loop = True self.__th.join() def start(self): self.__th = threading.Thread(target=self.__asyncore_loop) self.__th.start() class DeejayDaemonAsync(_DeejayDaemon): """Completely aynchroneous deejayd client library.""" # There is only one socket thread for all the async client instances socket_thread = _DeejaydSocketThread() def __init__(self): _DeejayDaemon.__init__(self) #socket.setdefaulttimeout(5) self.command_queue = Queue() self.__con_cb = [] self.__err_cb = [] self.socket_to_server = None def __create_socket(self, ignore_version=False): self.socket_to_server = _DeejaydSocket(self.socket_thread.socket_map, self, ignore_version) self.socket_to_server.add_socket_die_callback(\ self.__make_answers_obsolete) self.socket_to_server.add_socket_die_callback(self.__disconnect) self.socket_to_server.add_connect_callback(self.__connect_callback) def __disconnect(self, msg): # socket die, so thread do not need to be stopped # just reset the socket self.connected = False del self.socket_to_server self.socket_to_server = None # execute error callback for cb in self.__err_cb: cb(msg) def __make_answers_obsolete(self, msg): msg = "Could not obtain answer from server : " + msg try: while True: self.expected_answers_queue.get_nowait().set_error(msg) except Empty: # There is no more answer there, so no need to set any more errors. pass def __connect_callback(self, rs, msg = ""): self.connected = rs if not rs: # error happens, reset socket del self.socket_to_server self.socket_to_server = None for cb in self.__con_cb: cb(rs, msg) def add_connect_callback(self,cb): self.__con_cb.append(cb) def add_error_callback(self,cb): self.__err_cb.append(cb) def connect(self, host, port, ignore_version=False): if self.connected: self.disconnect() self.__create_socket(ignore_version) try: self.socket_to_server.connect((host, port)) except socket.connecterror, msg: raise ConnectError('Connection error %s' % str(msg)) if len(self.socket_thread.socket_map) < 2: # This is the first client to ask for a connection self.socket_thread.start() def disconnect(self): if self.socket_to_server != None: self.__stop_thread() self.connected = False # clear subscriptions self._clear_subscriptions() def __stop_thread(self): # terminate socket thread self.socket_to_server.close() if len(self.socket_thread.socket_map) < 1: # This is the last client to disconnect self.socket_thread.stop() del self.socket_to_server self.socket_to_server = None def _send_command(self, cmd, expected_answer = None): # Set a default answer by default if expected_answer == None: expected_answer = DeejaydAnswer(self) expected_answer.set_id(cmd.get_id()) self.expected_answers_queue.put(expected_answer) self.command_queue.put(cmd) return expected_answer def subscribe(self, signal_name, callback): if signal_name not in self.get_subscriptions().values(): cmd = JSONRPCRequest('signal.setSubscription', [signal_name, True]) ans = self._send_command(cmd) # Subscription are sync because there should be a way to tell the # client that subscription failed. ans.get_contents() return _DeejayDaemon.subscribe(self, signal_name, callback) def unsubscribe(self, sub_id): try: signal_name = self.get_subscriptions()[sub_id] except KeyError: # DeejaydError will be raised at local unsubscription pass _DeejayDaemon.unsubscribe(self, sub_id) # Remote unsubscribe if last local subscription laid off if signal_name not in self.get_subscriptions().values(): cmd = JSONRPCRequest('signal.setSubscription', [signal_name, False]) ans = self._send_command(cmd) # As subscription, unsubscription is sync. ans.get_contents() # # HTTP client # import httplib class DeejayDaemonHTTP(_DeejayDaemon): """HTTP deejayd client library.""" def __init__(self, host, port = 6880): _DeejayDaemon.__init__(self) self.host = host self.port = port self.connection = httplib.HTTPConnection(self.host, self.port) self.hdrs = { "Content-Type": "text/json", "Accept": "text/json", "User-Agent": "Deejayd Client Library", } def test_compatibility(self): # get server informations cmd = JSONRPCRequest('serverInfo', []) ans = self._send_command(cmd, DeejaydKeyValue()) versions = (ans["server_version"], ans["protocol_version"]) return self._version_is_supported(versions) def _send_command(self, cmd, expected_answer = None): # Set a default answer by default if expected_answer == None: expected_answer = DeejaydAnswer(self) expected_answer.set_id(cmd.get_id()) self.expected_answers_queue.put(expected_answer) # send http request try: self.connection.request("POST", "/rpc/", cmd.to_json(), self.hdrs) except Exception, ex: raise DeejaydError("Unable to send request : %s" % str(ex)) # get answer response = self.connection.getresponse() if response.status != 200: raise DeejaydError("Server return error code %d - %s" %\ (response.status, response.reason)) rawmsg = response.read() self._build_answer(rawmsg) return expected_answer # No subscription for the http client def subscribe(self, signal_name, callback): raise NotImplementedError def unsubscribe(self, sub_id): raise NotImplementedError # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/net/protocol.py0000644000175000017500000001467011351210475016016 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import sys, traceback from twisted.application import service, internet from twisted.internet import protocol, reactor from twisted.internet.error import ConnectionDone from twisted.protocols.basic import LineReceiver from deejayd import __version__ from deejayd.interfaces import DeejaydSignal from deejayd.mediafilters import * from deejayd.ui import log from deejayd.utils import str_encode from deejayd.rpc import Fault, DEEJAYD_PROTOCOL_VERSION from deejayd.rpc.jsonparsers import loads_request from deejayd.rpc.jsonbuilders import JSONRPCResponse, DeejaydJSONSignal from deejayd.rpc import protocol as deejayd_protocol class DeejaydProtocol(LineReceiver, deejayd_protocol.DeejaydTcpJSONRPC): NOT_FOUND = 8001 FAILURE = 8002 delimiter = 'ENDJSON\n' def __init__(self, deejayd_core, protocol_manager): super(DeejaydProtocol, self).__init__() self.MAX_LENGTH = 40960 self.subHandlers = {} self.deejayd_core = deejayd_core self.manager = protocol_manager def connectionMade(self): self.send_buffer("OK DEEJAYD %s protocol %d\n" % (__version__, DEEJAYD_PROTOCOL_VERSION, )) def connectionLost(self, reason=ConnectionDone): self.manager.close_signals(self) def lineReceived(self, line): line = line.strip("\r") # DEBUG Informations log.debug(line) need_to_close = False try: parsed = loads_request(line) args, functionPath = parsed['params'], parsed["method"] function = self._getFunction(functionPath) if parsed["method"] == "close": # close the connection after send answer need_to_close = True except Fault, f: try: id = parsed["id"] except: id = None ans = JSONRPCResponse(f, id) else: try: result = function(*args) except Exception, ex: if not isinstance(ex, Fault): log.err(str_encode(traceback.format_exc())) result = Fault(self.FAILURE, _("error, see deejayd log")) else: result = ex ans = JSONRPCResponse(result, parsed["id"]) self.send_buffer(ans.to_json()+self.delimiter) if need_to_close: self.transport.loseConnection() def send_buffer(self, buf): if isinstance(buf, unicode): buf = buf.encode("utf-8") self.transport.write(buf) log.debug(buf) def lineLengthExceeded(self, line): log.err(_("Request too long, close the connection")) self.transport.loseConnection() def set_signaled(self, signal_name): self.manager.set_signaled(self, signal_name) def set_not_signaled(self, signal_name): self.manager.set_not_signaled(self, signal_name) class DeejaydFactory(protocol.ServerFactory): protocol = DeejaydProtocol obj_supplied = False def __init__(self, deejayd_core): self.deejayd_core = deejayd_core self.signaled_clients = dict([(signame, []) for signame\ in DeejaydSignal.SIGNALS]) self.core_sub_ids = {} def startFactory(self): log.info(_("Net Protocol activated")) def buildProtocol(self, addr): p = self.protocol(self.deejayd_core, self) p = deejayd_protocol.build_protocol(self.deejayd_core, p) # set specific signal subhandler p = deejayd_protocol.set_signal_subhandler(self.deejayd_core, p) p.factory = self return p def clientConnectionLost(self, connector, reason): for signal_name in DeejaydSignal.SIGNALS: self.set_not_signaled(connector, signal_name) def set_signaled(self, connector, signal_name): client_list = self.signaled_clients[signal_name] if len(client_list) < 1: # First subscription for this signal, so subscribe sub_id = self.deejayd_core.subscribe(signal_name, self.sig_bcast_to_clients) self.core_sub_ids[signal_name] = sub_id self.signaled_clients[signal_name].append(connector) def set_not_signaled(self, connector, signal_name): client_list = self.signaled_clients[signal_name] if connector in client_list: client_list.remove(connector) if len(client_list) < 1: # No more clients for this signal, we can unsubscribe self.deejayd_core.unsubscribe(self.core_sub_ids[signal_name]) def close_signals(self, connector): for signal_name in self.signaled_clients.keys(): if len(self.signaled_clients[signal_name]) > 0: self.set_not_signaled(connector, signal_name) def sig_bcast_to_clients(self, signal): interested_clients = self.signaled_clients[signal.get_name()] if len(interested_clients) > 0: j_sig = DeejaydJSONSignal(signal) ans = JSONRPCResponse(j_sig.dump(), None).to_json() for client in interested_clients: # http://twistedmatrix.com/pipermail/twisted-python/2007-August/015905.html # says : "Don't call reactor methods from any thread except the # one which is running the reactor. This will have # unpredictable results and generally be broken." # This is the "why" of this weird call instead of a simple # client.send_buffer(xml_sig.to_xml()). reactor.callFromThread(client.send_buffer, ans+client.delimiter) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/net/__init__.py0000644000175000017500000000160611351210475015707 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/xmlobject.py0000644000175000017500000000541111351210475015347 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # need python-lxml try: from lxml import etree as ET except ImportError: import sys msg = "Unable to import module to build/parse xml, please install lxml" sys.exit(_(msg)) class DeejaydXMLObject(object): def __is_xml_char(self, char): # FIXME: This function should probably be more complicated than this. return char not in '\x19' def _to_xml_string(self, s): xml_string = '' if isinstance(s, int) or isinstance(s, float) or isinstance(s, long): xml_string = "%d" % (s,) elif isinstance(s, str): xml_string = "%s" % (s.decode('utf-8')) elif isinstance(s, unicode): rs = s.encode("utf-8") xml_string = "%s" % (rs.decode('utf-8')) else: raise TypeError filtered_xml_string = [] for string_char in xml_string: if self.__is_xml_char(string_char): filtered_xml_string.append(string_char) return ''.join(filtered_xml_string) def _indent(self,elem, level=0): indent_char = " " i = "\n" + level*indent_char if len(elem): if not elem.text or not elem.text.strip(): elem.text = i + indent_char for e in elem: self._indent(e, level+1) if not e.tail or not e.tail.strip(): e.tail = i + indent_char if not e.tail or not e.tail.strip(): e.tail = i else: if level and (not elem.tail or not elem.tail.strip()): elem.tail = i def to_string(self): return ET.tostring(self.xmlroot) def to_xml(self): return '' + self.to_string() def to_pretty_xml(self): self._indent(self.xmlroot) return '' + "\n" +\ self.to_string() + "\n" # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/__init__.py0000644000175000017500000000163611354572752015140 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. __version__ = "0.10.0" # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/mediafilters.py0000644000175000017500000001062311351210475016031 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. __all__ = ( 'BASIC_FILTERS', 'NAME2BASIC', 'Equals', 'NotEquals', 'Contains', 'NotContains', 'Regexi', 'Higher', 'Lower', 'COMPLEX_FILTERS', 'NAME2COMPLEX', 'And', 'Or', "DEFAULT_AUDIO_SORT", "DEFAULT_VIDEO_SORT" ) class MediaFilter(object): def get_identifier(self): return self.__class__.__name__.lower() def get_name(self): return self.__class__.__name__.lower() def __str__(self): return NotImplementedError class BasicFilter(MediaFilter): type = 'basic' def __init__(self, tag, pattern): super(BasicFilter, self).__init__() self.tag = tag self.pattern = pattern def equals(self, filter): if filter.type == 'basic' and filter.get_name() == self.get_name(): return filter.tag == self.tag and filter.pattern == self.pattern return False def __str__(self): return self.repr_str % (self.tag, self.pattern) class Equals(BasicFilter): repr_str = "(%s == '%s')" class NotEquals(BasicFilter): repr_str = "(%s != '%s')" class Contains(BasicFilter): repr_str = "(%s == '%%%s%%')" class NotContains(BasicFilter): repr_str = "(%s != '%%%s%%')" class Regexi(BasicFilter): repr_str = "(%s ~ /%s/)" class Higher(BasicFilter): repr_str = "(%s >= %s)" class Lower(BasicFilter): repr_str = "(%s <= %s)" BASIC_FILTERS = ( Equals, NotEquals, Contains, NotContains, Regexi, Higher, Lower, ) NAME2BASIC = dict([(x(None, None).get_identifier(), x) for x in BASIC_FILTERS]) class ComplexFilter(MediaFilter): type = 'complex' def __init__(*__args, **__kwargs): self = __args[0] super(ComplexFilter, self).__init__() self.filterlist = [] for filter in __args[1:]: self.combine(filter) def combine(self, filter): self.filterlist.append(filter) def equals(self, filter): if filter.type == 'complex' and len(filter) == len(self.filterlist): for ft in self.filterlist: if not filter.find_filter(ft): return False return True return False def find_filter_by_tag(self, tag): return [ft for ft in self.filterlist\ if ft.type == 'basic' and ft.tag == tag] def find_filter_by_name(self, name): return [ft for ft in self.filterlist if ft.get_name() == name] def find_filter(self, filter): return [ft for ft in self.filterlist if ft.equals(filter)] def remove_filter(self, filter): filters = self.find_filter(filter) if not filters: raise ValueError for ft in filters: self.filterlist.remove(ft) def __iter__(self): return iter(self.filterlist) def __getitem__(self, idx): return self.filterlist[idx] def __len__(self): return len(self.filterlist) def __str__(self): filters_str = map(str, self.filterlist) return "(%s)" % self.repr_joiner.join(filters_str) class And(ComplexFilter): repr_joiner = " && " class Or(ComplexFilter): repr_joiner = " || " COMPLEX_FILTERS = ( And, Or, ) NAME2COMPLEX = dict([(x().get_identifier(), x) for x in COMPLEX_FILTERS]) DEFAULT_AUDIO_SORT = [("album", "ascending"), ("discnumber", "ascending"),\ ("tracknumber", "ascending")] DEFAULT_VIDEO_SORT = [("title", "ascending")] # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/ui/0000755000175000017500000000000011354730161013424 5ustar royroydeejayd-0.10.0/deejayd/ui/defaults.conf0000644000175000017500000000563211351210475016106 0ustar royroy[general] # log level # possible value are : # * error : just log error # * info : be more verbose # * debug : log all messages log = error # Modes enabled in deejayd. Available modes are : # playlist, panel, webradio, video, dvd activated_modes = playlist,panel,webradio # fullscreen mode fullscreen = yes # replaygain support replaygain = yes # media_backend : choose the media backend for deejayd # possible values are : auto (whichever could be loaded, first trying with # xine), xine or gstreamer # Caution : gstreamer backend does not support video playback media_backend = auto # enabled_plugins: a list of plugins to activate separated by ',' # available plugins are : # - shoutcast: to navigate and play shoutcast webradio # - audioscrobbler : to activate lastfm audioscrobbler # you need to set your lastfm login/password in lastfm # section below enabled_plugins = shoutcast [net] enabled = yes port = 6800 # Addresses to bind to, a list of ip addresses separated by ',' or 'all'. bind_addresses = localhost [webui] enabled = no port = 6880 # Addresses to bind to, a list of ip addresses separated by ',' or 'all'. bind_addresses = localhost # temp directory where deejayd save rdf files for webui tmp_dir = /tmp/deejayd_webui # Number of seconds between auto-refreshes of ui. # set to 0 if you don't want to refresh automatically the webui. refresh = 0 [database] # _db_type_ possible values are : 'mysql', 'sqlite' db_type = sqlite # _db_name_ or path to database file if using sqlite db_name = /var/lib/deejayd/deejayd.db # _db_user_ : not used with sqlite #db_user = zboub # _db_password_ : not used with sqlite #db_password = 'unbreakable_password' # _db_host_ : set to empty string for localhost. Not used with sqlite #db_host = localhost # _db_port_ : set to empty string for default. Not used with sqlite #db_port = 3300 [mediadb] music_directory = /var/lib/deejayd/music video_directory = /var/lib/deejayd/video filesystem_charset = utf-8 [panel] # panel_tags : set panel tags for panel mode # supported lists are # * genre,artist,album (default) # * artist,album # * genre,various_artist,album # * various_artist,album # various_artist is equivalent to artist, except compilation albums are # grouped inside "Various Artist" label panel_tags = genre,artist,album [gstreamer] # Audio Ouput # Possible values are : auto, alsa, oss, esd... audio_output = auto # Alsa Options # valid only for alsa output #alsa_card = hw:2 [xine] # Audio Ouput # Possible values are : auto,alsa, oss... audio_output = auto # Software Mixer Use # set to true to use software mixer instead of hardware software_mixer = false # Video Ouput # Possible values are : auto,Xv,xshm .. video_output = auto # Video Display video_display= :0.0 # osd support osd_support = no osd_font_size = 32 #[lastfm] # lastfm section #login = yourlastfmlogin #password = yourmd5lastfmpassword # md5(lastfmpassword) deejayd-0.10.0/deejayd/ui/i18n.py0000644000175000017500000000267111351210475014561 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import __builtin__ import gettext class DeejaydTranslations(gettext.GNUTranslations): def __init__(self, *args, **kwargs): self._catalog = {} self.plural = lambda n: n > 1 gettext.GNUTranslations.__init__(self, *args, **kwargs) def install(self, unicode = True): if unicode: __builtin__.__dict__["_"] = self.ugettext __builtin__.__dict__["ngettext"] = self.ungettext else: __builtin__.__dict__["_"] = self.gettext __builtin__.__dict__["ngettext"] = self.ngettext # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/ui/config.py0000644000175000017500000000444011351210475015243 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import ConfigParser, os, sys, string class DeejaydConfig: custom_conf = None __global_conf = '/etc/deejayd.conf' __user_conf = '~/.deejayd.conf' __config = None def __init__(self): if DeejaydConfig.__config == None: DeejaydConfig.__config = ConfigParser.SafeConfigParser() default_config_path = os.path.abspath(os.path.dirname(__file__)) DeejaydConfig.__config.readfp(open(default_config_path\ + '/defaults.conf')) conf_files = [DeejaydConfig.__global_conf,\ os.path.expanduser(DeejaydConfig.__user_conf)] if DeejaydConfig.custom_conf: conf_files.append(DeejaydConfig.custom_conf) DeejaydConfig.__config.read(conf_files) def __getattr__(self, name): return getattr(DeejaydConfig.__config, name) def set(self, section, variable, value): self.__config.set(section, variable, value) def getlist(self, section, variable): list_items = self.__config.get(section, variable).split(',') return map(string.strip, list_items) def get_bind_addresses(self, service = 'net'): bind_addresses = self.getlist(service, 'bind_addresses') if 'all' in bind_addresses: return [''] else: return bind_addresses def write(self, fp): self.__config.write(fp) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/ui/__init__.py0000644000175000017500000000160511351210475015535 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/ui/log.py0000644000175000017500000000546211351210475014564 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import signal, sys, locale from twisted.python import log from deejayd.ui.config import DeejaydConfig ERROR = 0 INFO = 1 DEBUG = 2 level = DeejaydConfig().get("general","log") log_level = {"error": ERROR, "info": INFO, \ "debug": DEBUG}[level] class LogFile: def __init__(self, path, autosignal=True): self.path = path self.open() if autosignal: self.set_reopen_signal() def open(self): try: self.fd = open(self.path, 'a') except IOError: sys.exit("Unable to open the log file %s" % self.path) def reopen(self): self.fd.close() self.open() def __reopen_cb(self, signal, frame): self.reopen() def set_reopen_signal(self, sig=signal.SIGHUP, callback=None): if not callback: callback = self.__reopen_cb signal.signal(sig, callback) class SignaledFileLogObserver(log.FileLogObserver): def __init__(self, path): self.log_file = LogFile(path, False) self.log_file.open() self.__observe_log_file() self.log_file.set_reopen_signal(callback=self.__reopen_cb) def __observe_log_file(self): log.FileLogObserver.__init__(self, self.log_file.fd) def __reopen_cb(self, signal, frame): self.stop() self.log_file.reopen() self.__observe_log_file() self.start() def __log(log_msg): try: log.msg(log_msg.encode(locale.getpreferredencoding())) except UnicodeError: # perharps prefered encoding not correctly set, force to UTF-8 log.msg(log_msg.encode('utf-8')) def err(err, fatal = False): msg = _("ERROR - %s") % err __log(msg) if fatal: sys.exit(err) def msg(msg): __log(msg) def info(msg): if log_level >= INFO: msg = _("INFO - %s") % msg __log(msg) def debug(msg): if log_level >= DEBUG: msg = _("DEBUG - %s") % msg __log(msg) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/component.py0000644000175000017500000000306311351210475015363 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. class SignalingComponent(object): SUBSCRIPTIONS = {} def __init__(self): self.__dispatcher = None def register_dispatcher(self, dispatcher): self.__dispatcher = dispatcher # set internal subscription for signame in self.SUBSCRIPTIONS.keys(): self.__dispatcher.subscribe(signame,\ getattr(self, self.SUBSCRIPTIONS[signame])) def dispatch_signal(self, signal): if self.__dispatcher: self.__dispatcher._dispatch_signal(signal) def dispatch_signame(self, signal_name, attrs = {}): if self.__dispatcher: self.__dispatcher._dispatch_signame(signal_name, attrs) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/player/0000755000175000017500000000000011354730161014303 5ustar royroydeejayd-0.10.0/deejayd/player/xine.py0000644000175000017500000004764011354570476015646 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import os from os import path import kaa.metadata from twisted.internet import reactor from pytyxi import xine from deejayd.player import PlayerError from deejayd.player._base import * from deejayd.ui import log class XinePlayer(UnknownPlayer): name = "xine" supported_extensions = None xine_plugins = None def __init__(self, db, plugin_manager, config): UnknownPlayer.__init__(self, db, plugin_manager, config) self.__xine_options = { "video": self.config.get("xine", "video_output"), "display" : self.config.get("xine", "video_display"), "osd_support" : self.config.getboolean("xine", "osd_support"), "osd_font_size" : self.config.getint("xine", "osd_font_size"), "software_mixer": self.config.getboolean("xine", "software_mixer"), } self.__video_aspects = { "auto": xine.Stream.XINE_VO_ASPECT_AUTO, "1:1": xine.Stream.XINE_VO_ASPECT_SQUARE, "4:3": xine.Stream.XINE_VO_ASPECT_4_3, "16:9": xine.Stream.XINE_VO_ASPECT_ANAMORPHIC, "2.11:1": xine.Stream.XINE_VO_ASPECT_DVB, } self.__default_aspect_ratio = "auto" # init main instance try: self.__xine = xine.XinePlayer() except xine.XineError: raise PlayerError(_("Unable to init a xine instance")) # init vars self.__supports_gapless = self.__xine.has_gapless() self.__audio_volume = 100 self.__video_volume = 100 self.__window = None self.__stream = None self.__osd = None def start_play(self): super(XinePlayer, self).start_play() if not self._media_file: return # format correctly the uri uri = self._media_file["uri"].encode("utf-8") # For dvd chapter if "chapter" in self._media_file.keys() and \ self._media_file["chapter"] != -1: uri += ".%d" % self._media_file["chapter"] # load subtitle if "external_subtitle" in self._media_file and \ self._media_file["external_subtitle"].startswith("file://"): # external subtitle uri += "#subtitle:%s" \ % self._media_file["external_subtitle"].encode("utf-8") self._media_file["subtitle"] = [{"lang": "none", "ix": -2},\ {"lang": "auto", "ix": -1},\ {"lang": "external", "ix":0}] elif "subtitle_channels" in self._media_file.keys() and\ int(self._media_file["subtitle_channels"]) > 0: self._media_file["subtitle"] = [{"lang": "none", "ix": -2},\ {"lang": "auto", "ix": -1}] for i in range(int(self._media_file["subtitle_channels"])): self._media_file["subtitle"].append(\ {"lang": _("Sub channel %d") % (i+1,), "ix": i}) # audio channels if "audio_channels" in self._media_file.keys() and \ int(self._media_file["audio_channels"]) > 1: audio_channels = [{"lang":"none","ix":-2},{"lang":"auto","ix":-1}] for i in range(int(self._media_file["audio_channels"])): audio_channels.append(\ {"lang": _("Audio channel %d") % (i+1,), "ix": i}) self._media_file["audio"] = audio_channels needs_video = self.current_is_video() if self.__stream: stream_should_change = (needs_video and\ not self.__stream.has_video())\ or (not needs_video and self.__stream.has_video()) else: stream_should_change = True if stream_should_change: self._create_stream(needs_video) def open_uri(uri): try: self.__stream.open(uri) self.__stream.play(0, 0) except xine.XineError: msg = _("Unable to play file %s") % uri log.err(msg) raise PlayerError(msg) if self._media_file["type"] == "webradio": while True: try: open_uri(self._media_file["uri"]) except PlayerError, ex: if self._media_file["url-index"] < \ len(self._media_file["urls"])-1: self._media_file["url-index"] += 1 self._media_file["uri"] = \ self._media_file["urls"]\ [self._media_file["url-index"]].encode("utf-8") else: raise ex else: break else: try: open_uri(uri) except PlayerError, ex: self._destroy_stream() raise ex if self.__window: self.__window.show(self.current_is_video()) # init video information if needs_video: self._media_file["av_offset"] = 0 self._media_file["zoom"] = 100 if "audio" in self._media_file: self._media_file["audio_idx"] = self.__stream.get_param(\ xine.Stream.XINE_PARAM_AUDIO_CHANNEL_LOGICAL) else: self._player_set_alang(-1) # auto audio channel if "subtitle" in self._media_file: self._media_file["sub_offset"] = 0 self._media_file["subtitle_idx"] = self.__stream.get_param(\ xine.Stream.XINE_PARAM_SPU_CHANNEL) else: self._player_set_slang(-1) # auto subtitle channel try: del self._media_file["sub_offset"] except KeyError: pass # set video aspect ration to default value self.set_aspectratio(self.__default_aspect_ratio) def _change_file(self, new_file, gapless = False): sig = self.get_state() == PLAYER_STOP and True or False if self._media_file == None\ or new_file == None\ or self._media_file["type"] != new_file["type"]: self._destroy_stream() gapless = False self._media_file = new_file if gapless and self.__supports_gapless: self.__stream.set_param(xine.Stream.XINE_PARAM_GAPLESS_SWITCH, 1) self.start_play() if gapless and self.__supports_gapless: self.__stream.set_param(xine.Stream.XINE_PARAM_GAPLESS_SWITCH, 0) # replaygain reset self.set_volume(self.get_volume(), sig=False) if sig: self.dispatch_signame('player.status') self.dispatch_signame('player.current') def pause(self): if self.get_state() == PLAYER_PAUSE: self.__stream.set_param(xine.Stream.XINE_PARAM_SPEED, xine.Stream.XINE_SPEED_NORMAL) elif self.get_state() == PLAYER_PLAY: self.__stream.set_param(xine.Stream.XINE_PARAM_SPEED, xine.Stream.XINE_SPEED_PAUSE) else: return self.dispatch_signame('player.status') def stop(self): if self.get_state() != PLAYER_STOP: self._source.queue_reset() self._change_file(None) self.dispatch_signame('player.status') def set_zoom(self, zoom): if zoom > xine.Stream.XINE_VO_ZOOM_MAX\ or zoom < xine.Stream.XINE_VO_ZOOM_MIN: raise PlayerError(_("Zoom value not accepted")) self.__stream.set_param(xine.Stream.XINE_PARAM_VO_ZOOM_X, zoom) self.__stream.set_param(xine.Stream.XINE_PARAM_VO_ZOOM_Y, zoom) self._media_file["zoom"] = zoom self._osd_set(_("Zoom: %d percent") % zoom) def set_aspectratio(self, aspect_ratio): try: asp = self.__video_aspects[aspect_ratio] except KeyError: raise PlayerError(_("Video aspect ration %s is not known.")\ % aspect_ratio) self.__default_aspect_ratio = aspect_ratio self._media_file["aspect_ratio"] = self.__default_aspect_ratio if self.__stream.has_video(): self.__stream.set_param(xine.Stream.XINE_PARAM_VO_ASPECT_RATIO, asp) def set_avoffset(self, offset): self.__stream.set_param(xine.Stream.XINE_PARAM_AV_OFFSET, offset * 90) self._media_file["av_offset"] = offset self._osd_set(_("Audio/Video offset: %d ms") % offset) def set_suboffset(self, offset): if "subtitle" in self._media_file.keys(): self.__stream.set_param(xine.Stream.XINE_PARAM_SPU_OFFSET, offset * 90) self._media_file["sub_offset"] = offset self._osd_set(_("Subtitle offset: %d ms") % offset) def _player_set_alang(self,lang_idx): self.__stream.set_param(xine.Stream.XINE_PARAM_AUDIO_CHANNEL_LOGICAL, lang_idx) def _player_set_slang(self,lang_idx): self.__stream.set_param(xine.Stream.XINE_PARAM_SPU_CHANNEL, lang_idx) def _player_get_alang(self): return self.__stream.get_param(xine.Stream.\ XINE_PARAM_AUDIO_CHANNEL_LOGICAL) def _player_get_slang(self): return self.__stream.get_param(xine.Stream.XINE_PARAM_SPU_CHANNEL) def get_volume(self): if self.current_is_video(): return self.__video_volume else: return self.__audio_volume def set_volume(self, vol, sig = True): new_volume = min(100, int(vol)) if self.current_is_video(): self.__video_volume = new_volume else: self.__audio_volume = new_volume # replaygain support vol = self.get_volume() if self._replaygain and self._media_file is not None: try: scale = self._media_file.replay_gain() except AttributeError: pass # replaygain not supported else: vol = max(0.0, min(4.0, float(vol)/100.0 * scale)) vol = min(100, int(vol * 100)) if self.__stream: self.__stream.set_volume(vol) if sig: self._osd_set("Volume: %d" % self.get_volume()) self.dispatch_signame('player.status') def get_position(self): if not self.__stream: return 0 return self.__stream.get_pos() def _set_position(self,pos): pos = int(pos * 1000) state = self.get_state() if state == PLAYER_PAUSE: self.__stream.play(0, pos) self.__stream.set_param(xine.Stream.XINE_PARAM_SPEED, xine.Stream.XINE_SPEED_PAUSE) elif state == PLAYER_PLAY: self.__stream.play(0, pos) self.dispatch_signame('player.status') def get_state(self): if not self.__stream: return PLAYER_STOP status = self.__stream.get_status() if status == xine.Stream.XINE_STATUS_PLAY: if self.__stream.get_param(xine.Stream.XINE_PARAM_SPEED)\ == xine.Stream.XINE_SPEED_NORMAL: return PLAYER_PLAY return PLAYER_PAUSE return PLAYER_STOP def is_supported_uri(self,uri_type): if self.xine_plugins == None: self.xine_plugins = self.__xine.list_input_plugins() return uri_type in self.xine_plugins def is_supported_format(self,format): if self.supported_extensions == None: self.supported_extensions = self.__xine.get_supported_extensions() return format.strip(".") in self.supported_extensions def current_is_video(self): return self._media_file is not None\ and self._media_file['type'] == 'video' def close(self): UnknownPlayer.close(self) self.__xine.destroy() def _create_stream(self, has_video = True): if self.__stream != None: self._destroy_stream() # open audio driver driver_name = self.config.get("xine", "audio_output") try: audio_port = xine.AudioDriver(self.__xine, driver_name) except xine.xineError: raise PlayerError(_("Unable to open audio driver")) # open video driver if has_video and self._video_support\ and self.__xine_options["video"] != "none": try: video_port = xine.VideoDriver(self.__xine, self.__xine_options["video"], self.__xine_options["display"], self._fullscreen) except xine.XineError: msg = _("Unable to open video driver") log.err(msg) raise PlayerError(msg) else: self.__window = video_port.window else: video_port = None # create stream self.__stream = self.__xine.stream_new(audio_port, video_port) self.__stream.set_software_mixer(self.__xine_options["software_mixer"]) if video_port and self.__xine_options["osd_support"]: self.__osd = self.__stream.osd_new(\ self.__xine_options["osd_font_size"]) # add event listener self.__stream.add_event_callback(self._event_callback) # restore volume self.__stream.set_volume(self.get_volume()) def _destroy_stream(self): if self.__stream: self.__stream.destroy() self.__stream = None self.__window = None self.__osd = None def _osd_set(self, text): if not self.__osd: return self.__osd.clear() self.__osd.draw_text(60, 20, text.encode("utf-8")) self.__osd.show() reactor.callLater(2, self._osd_hide, text) def _osd_hide(self, text): if self.__osd: self.__osd.hide(text) # # callbacks # def _eof(self): if self._media_file: if self._media_file["type"] == "webradio": # an error happened, try the next url if self._media_file["url-index"] \ < len(self._media_file["urls"])-1: self._media_file["url-index"] += 1 self._media_file["uri"] = \ self._media_file["urls"]\ [self._media_file["url-index"]].encode("utf-8") self.start_play() return False else: try: self._media_file.played() except AttributeError: pass for plugin in self.plugins: plugin.on_media_played(self._media_file) new_file = self._source.next(explicit = False) try: self._change_file(new_file, gapless = True) except PlayerError: pass return False def _update_metadata(self): if not self._media_file or self._media_file["type"] != "webradio": return False # update webradio song info meta = [ (xine.Stream.XINE_META_INFO_TITLE, 'song-title'), (xine.Stream.XINE_META_INFO_ARTIST, 'song-artist'), (xine.Stream.XINE_META_INFO_ALBUM, 'song-album'), ] for info, name in meta: text = self.__stream.get_meta_info(info) if not text: continue text = text.decode('UTF-8', 'replace') if name not in self._media_file.keys() or\ self._media_file[name] != text: self._media_file[name] = text self.dispatch_signame('player.current') return False # this callback is not called in the main reactor thread # so we have to use callFromThread function instead of callLater # see |http://twistedmatrix.com/documents/current/api/ # |twisted.internet.interfaces.IReactorThreads.callFromThread.html def _event_callback(self, user_data, event): if event.type == xine.Event.XINE_EVENT_UI_PLAYBACK_FINISHED: log.info("Xine event : playback finished") reactor.callFromThread(self._eof) elif event.type == xine.Event.XINE_EVENT_UI_SET_TITLE: log.info("Xine event : set title") reactor.callFromThread(self._update_metadata) elif event.type == xine.Event.XINE_EVENT_UI_MESSAGE: log.info("Xine event : message") try: message = event.message() except xine.XineError, errornum: message = _("Xine error %s") % errornum if message is not None: reactor.callFromThread(log.err, message) return True class DvdParser: DEVICE = "/dev/dvd" def __init__(self): try: self.__xine = xine.XinePlayer() except xine.XineError: raise PlayerError(_("Unable to init a xine instance")) self.__mine_stream = self.__xine.stream_new(None, None) def get_dvd_info(self): kaa_infos = kaa.metadata.parse(self.DEVICE) if kaa_infos is None: raise PlayerError(_("Unable to identify dvd device")) dvd_info = {"title": kaa_infos["label"], 'track': []} longest_track = {"ix": 0, "length": 0} for idx, t in enumerate(kaa_infos['tracks']): try: self.__mine_stream.open("dvd://%d" % (idx+1,)) except xine.XineError, ex: raise PlayerError, ex track = {"ix": idx+1, "length": int(t['length']), "chapter": []} if track['length'] > longest_track["length"]: longest_track = track # get audio channels info channels_number = len(t['audio']) audio_channels = [{"lang":"none","ix":-2},{"lang":"auto","ix":-1}] for ch in range(0,channels_number): lang = self.__mine_stream.get_audio_lang(ch) audio_channels.append({'ix':ch, "lang":lang.encode("utf-8")}) track["audio"] = audio_channels # get subtitles channels info channels_number = len(t['subtitles']) sub_channels = [{"lang":"none","ix":-2},{"lang":"auto","ix":-1}] for ch in range(0,channels_number): lang = self.__mine_stream.get_spu_lang(ch) sub_channels.append({'ix':ch, "lang":lang.encode("utf-8")}) track["subp"] = sub_channels # chapters for c_i,chapter in enumerate(kaa_infos['tracks'][idx]['chapters']): track["chapter"].append({ "ix": c_i+1,\ 'length': int(chapter["pos"]) }) if c_i > 0: track["chapter"][c_i-1]['length'] = int(chapter["pos"]) - \ track["chapter"][c_i-1]['length'] if c_i == len(kaa_infos['tracks'][idx]['chapters']) - 1: track["chapter"][c_i]['length'] = track["length"] - \ int(track["chapter"][c_i]['length']) dvd_info['track'].append(track) dvd_info['longest_track'] = longest_track["ix"] return dvd_info def close(self): self.__mine_stream.destroy() self.__xine.destroy() # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/player/gstreamer.py0000644000175000017500000002164311351210475016652 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import time import pygst pygst.require('0.10') import gobject import gst import gst.interfaces from ConfigParser import NoOptionError from deejayd.player import PlayerError from deejayd.player._base import * from deejayd.ui import log class GstreamerPlayer(UnknownPlayer): name = 'gstreamer' def __init__(self, db, plugin_manager, config): UnknownPlayer.__init__(self, db, plugin_manager, config) self.__volume = 100 # Open a Audio pipeline pipeline = self.config.get("gstreamer", "audio_output") if pipeline in ("gconf", "auto"): pipeline += "audiosink" else: pipeline += "sink" try: audio_sink = gst.parse_launch(pipeline) except gobject.GError, err: raise PlayerError(_("No audio sink found for Gstreamer : %s"%err)) # More audio-sink option if pipeline == "alsasink": try: alsa_card = self.config.get("gstreamer", "alsa_card") except NoOptionError: pass else: audio_sink.set_property('device',alsa_card) self.bin = gst.element_factory_make('playbin') self.bin.set_property('video-sink', None) self.bin.set_property('audio-sink',audio_sink) self.bin.set_property("auto-flush-bus",True) bus = self.bin.get_bus() bus.add_signal_watch() bus.connect('message', self.on_message) def init_video_support(self): raise NotImplementedError def on_message(self, bus, message): if message.type == gst.MESSAGE_EOS: if self._media_file["type"] == "webradio": # an error happened, try the next url if self._media_file["url-index"] \ < len(self._media_file["urls"])-1: self._media_file["url-index"] += 1 self._media_file["uri"] = \ self._media_file["urls"]\ [self._media_file["url-index"]].encode("utf-8") self.start_play() return False elif self._media_file: try: self._media_file.played() except AttributeError: pass for plugin in self.plugins: plugin.on_media_played(self._media_file) self._change_file(self._source.next(explicit = False)) elif message.type == gst.MESSAGE_TAG: self._update_metadata(message.parse_tag()) elif message.type == gst.MESSAGE_ERROR: err, debug = message.parse_error() err = str(err).decode("utf8", 'replace') log.err("Gstreamer : " + err) return True def start_play(self): super(GstreamerPlayer, self).start_play() if not self._media_file: return def open_uri(uri): self.bin.set_property('uri',uri) state_ret = self.bin.set_state(gst.STATE_PLAYING) timeout = 4 state = None while state_ret == gst.STATE_CHANGE_ASYNC and timeout > 0: state_ret,state,pending_state = self.bin.get_state(1 * gst.SECOND) timeout -= 1 if state_ret != gst.STATE_CHANGE_SUCCESS: msg = _("Unable to play file %s") % uri raise PlayerError(msg) if self._media_file["type"] == "webradio": while True: try: open_uri(self._media_file["uri"]) except PlayerError, ex: if self._media_file["url-index"] < \ len(self._media_file["urls"])-1: self._media_file["url-index"] += 1 self._media_file["uri"] = \ self._media_file["urls"]\ [self._media_file["url-index"]].encode("utf-8") else: raise ex else: break else: try: open_uri(self._media_file["uri"]) except PlayerError, ex: self._destroy_stream() raise ex def pause(self): if self.get_state() == PLAYER_PLAY: self.bin.set_state(gst.STATE_PAUSED) elif self.get_state() == PLAYER_PAUSE: self.bin.set_state(gst.STATE_PLAYING) else: return self.dispatch_signame('player.status') def stop(self,widget = None, event = None): self._media_file = None self.bin.set_state(gst.STATE_NULL) # FIXME : try to remove this one day ... self._source.queue_reset() self.dispatch_signame('player.status') def _change_file(self,new_file): sig = self.get_state() == PLAYER_STOP and True or False self.stop() self._media_file = new_file self.start_play() # replaygain reset self.set_volume(self.__volume, sig=False) if sig: self.dispatch_signame('player.status') self.dispatch_signame('player.current') def get_volume(self): return self.__volume def set_volume(self, vol, sig=True): self.__volume = min(100, int(vol)) v = float(self.__volume)/100 # replaygain support if self._replaygain and self._media_file is not None: try: scale = self._media_file.replay_gain() except AttributeError: pass # replaygain not supported else: v = max(0.0, min(4.0, v * scale)) v = float(min(100, int(v * 100)))/100 self.bin.set_property('volume', v) if sig: self.dispatch_signame('player.status') def get_position(self): if gst.STATE_NULL != self.__get_gst_state() and \ self.bin.get_property('uri'): try: p = self.bin.query_position(gst.FORMAT_TIME)[0] except gst.QueryError: return 0 p //= gst.SECOND return p return 0 def _set_position(self,pos): if gst.STATE_NULL != self.__get_gst_state() and \ self.bin.get_property('uri'): pos = max(0, int(pos)) gst_time = pos * gst.SECOND event = gst.event_new_seek( 1.0, gst.FORMAT_TIME, gst.SEEK_FLAG_FLUSH | \ gst.SEEK_FLAG_ACCURATE, gst.SEEK_TYPE_SET, gst_time, \ gst.SEEK_TYPE_NONE, 0) self.bin.send_event(event) self.dispatch_signame('player.status') def _update_metadata(self, tags): if not self._media_file or self._media_file["type"] != "webradio": return for k in tags.keys(): value = str(tags[k]).strip() if not value: continue if k in ("emphasis", "mode", "layer"): continue elif isinstance(value, basestring): if k in ("title", "album", "artist"): name = "song-" + k if name not in self._media_file.keys() or\ self._media_file[name] != value: self._media_file[name] = value self.dispatch_signame('player.current') def get_state(self): gst_state = self.__get_gst_state() if gst_state == gst.STATE_PLAYING: return PLAYER_PLAY elif gst_state == gst.STATE_PAUSED: return PLAYER_PAUSE else: return PLAYER_STOP def __get_gst_state(self): changestatus,state,_state = self.bin.get_state() return state def is_supported_uri(self,uri_type): if uri_type == "dvd": return False return gst.element_make_from_uri(gst.URI_SRC,uri_type+"://", '') \ is not None def is_supported_format(self,format): formats = { ".mp3": ("mad",), ".mp2": ("mad",), ".ogg": ("ogg", "vorbis"), ".mp4": ("faad",), ".flac": ("flac",), } if format in formats.keys(): for plugin in formats[format]: if gst.registry_get_default().find_plugin(plugin) == None: return False return True return False # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/player/_base.py0000644000175000017500000002216511351210475015732 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import os,subprocess from deejayd.component import SignalingComponent from deejayd.plugins import PluginError, IPlayerPlugin from deejayd.player import PlayerError from deejayd.ui import log from deejayd.utils import get_uris_from_pls, get_uris_from_m3u PLAYER_PLAY = "play" PLAYER_PAUSE = "pause" PLAYER_STOP = "stop" class UnknownPlayer(SignalingComponent): def __init__(self, db, plugin_manager, config): SignalingComponent.__init__(self) self.config = config self.db = db # Init plugins self.plugins = [] plugins_cls = plugin_manager.get_plugins(IPlayerPlugin) for plugin in plugins_cls: try: self.plugins.append(plugin(config)) except PluginError, err: log.err(_("Unable to init %s plugin: %s")%(plugin.NAME, err)) # Initialise var self._video_support = False self._source = None self._media_file = None self._replaygain = config.getboolean("general","replaygain") def load_state(self): # Restore volume self.set_volume(float(self.db.get_state("volume"))) # Restore current media media_pos = int(self.db.get_state("current")) source = self.db.get_state("current_source") if media_pos != -1 and source not in ("queue", "none", 'webradio'): self._media_file = self._source.get(media_pos, "pos", source) # Update state state = self.db.get_state("state") if state != PLAYER_STOP and source != 'webradio': try: self.play() except PlayerError: # There is an issue restoring the playing state on this file # so pause for now. self.stop() else: if self._media_file and self._media_file["source"] != "queue": self.set_position(int(self.db.get_state("current_pos"))) if state == PLAYER_PAUSE: self.pause() def init_video_support(self): self._video_support = True self._fullscreen = self.config.getboolean("general", "fullscreen") def set_source(self,source): self._source = source def play(self): if self.get_state() == PLAYER_STOP: file = self._media_file or self._source.get_current() self._change_file(file) elif self.get_state() in (PLAYER_PAUSE, PLAYER_PLAY): self.pause() def start_play(self): if not self._media_file: return if self._media_file["type"] == "webradio": if self._media_file["url-type"] == "pls": try: urls = get_uris_from_pls(self._media_file["url"]) except IOError: raise PlayerError(_("Unable to get pls for webradio %s")\ % self._media_file["title"]) if not len(urls): # we don't succeed to extract uri raise PlayerError(\ _("Unable to extract uri from pls playlist")) self._media_file.update({"urls": urls, "url-index": 0, \ "url-type": "urls"}) idx = self._media_file["url-index"] self._media_file["uri"] = \ self._media_file["urls"][idx].encode("utf-8") def pause(self): raise NotImplementedError def stop(self): raise NotImplementedError def next(self): if self.get_state() != PLAYER_STOP: try: self._media_file.skip() except AttributeError: pass self._change_file(self._source.next()) def previous(self): self._change_file(self._source.previous()) def go_to(self,nb,type,source = None): self._change_file(self._source.get(nb,type,source)) def get_volume(self): raise NotImplementedError def set_volume(self,v): raise NotImplementedError def get_position(self): raise NotImplementedError def set_position(self, pos, relative = False): if relative and self.get_state()!="stop"\ and self._media_file["type"]!="webradio": cur_pos = self.get_position() pos = int(pos) + cur_pos self._set_position(pos) def _set_position(self, pos): raise NotImplementedError def get_state(self): raise NotImplementedError def set_option(self, name, value): if not self._media_file or self._media_file["type"] != "video" or\ self.get_state() == PLAYER_STOP: return options = { "audio_lang": self.set_alang, "sub_lang": self.set_slang, "av_offset": self.set_avoffset, "sub_offset": self.set_suboffset, "zoom": self.set_zoom, "aspect_ratio": self.set_aspectratio, } options[name](value) def set_zoom(self, zoom): raise NotImplementedError def set_avoffset(self, offset): raise NotImplementedError def set_suboffset(self, offset): raise NotImplementedError def set_aspectratio(self, aspect): raise NotImplementedError def set_alang(self,lang_idx): try: audio_tracks = self._media_file["audio"] except KeyError: raise PlayerError(_("Current media hasn't multiple audio channel")) else: if lang_idx in (-2,-1): # disable/auto audio channel self._player_set_alang(lang_idx) self._media_file["audio_idx"] = self._player_get_alang() return found = False for track in audio_tracks: if track['ix'] == lang_idx: # audio track exists self._player_set_alang(lang_idx) found = True break if not found: raise PlayerError(_("Audio channel %d not found") % lang_idx) self._media_file["audio_idx"] = self._player_get_alang() def set_slang(self,lang_idx): try: sub_tracks = self._media_file["subtitle"] except KeyError: raise PlayerError(_("Current media hasn't multiple sub channel")) else: if lang_idx in (-2,-1): # disable/auto subtitle channel self._player_set_slang(lang_idx) self._media_file["subtitle_idx"] = self._player_get_slang() return found = False for track in sub_tracks: if track['ix'] == lang_idx: # audio track exists self._player_set_slang(lang_idx) found = True break if not found: raise PlayerError(_("Sub channel %d not found") % lang_idx) self._media_file["subtitle_idx"] = self._player_get_slang() def get_playing(self): return self.get_state() != PLAYER_STOP and self._media_file or None def is_playing(self): return self.get_state() != PLAYER_STOP def get_status(self): status = [("state",self.get_state()),("volume",self.get_volume())] if self._media_file: status.append(("current",\ "%d:%s:%s" % (self._media_file["pos"],\ str(self._media_file["id"]), self._media_file["source"]))) if self.get_state() != PLAYER_STOP: position = self.get_position() if "length" not in self._media_file.keys() or \ self._media_file["length"] == 0: length = position else: length = int(self._media_file["length"]) status.extend([ ("time","%d:%d" % (position, length)) ]) return status def close(self): # close plugins for plugin in self.plugins: plugin.close() # save state current = self._media_file or {"pos": "-1", "source": "none"} states = [ (str(self.get_volume()), "volume"), (current["pos"], "current"), (current["source"], "current_source"), (str(self.get_position()), "current_pos"), (self.get_state(), "state"), ] self.db.set_state(states) # stop player if necessary if self.get_state() != PLAYER_STOP: self.stop() # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/player/__init__.py0000644000175000017500000000454111351210475016416 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from deejayd.interfaces import DeejaydError from deejayd.ui import log AVAILABLE_BACKENDS = ('xine', 'gstreamer', ) class PlayerError(DeejaydError):pass def init(db, plugin_manager, config): media_backend = config.get("general","media_backend") if media_backend == "auto": backend_it = iter(AVAILABLE_BACKENDS) media_backend = None try: while not media_backend: backend = backend_it.next() try: __import__('.'.join(('deejayd', 'player', backend, ))) except ImportError: # Do nothing, simply ignore pass else: media_backend = backend config.set('general', 'mediabackend', backend) log.msg(_("Autodetected %s backend." % backend)) except StopIteration: log.err(_("Could not find suitable media backend."), fatal=True) if media_backend == "gstreamer": from deejayd.player import gstreamer try: player = gstreamer.GstreamerPlayer(db, plugin_manager, config) except PlayerError, err: log.err(str(err), fatal=True) elif media_backend == "xine": from deejayd.player import xine try: player = xine.XinePlayer(db, plugin_manager, config) except PlayerError, err: log.err(str(err), fatal=True) else: log.err(_("Invalid media backend"), fatal=True) return player # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/utils.py0000644000175000017500000000632511351210475014525 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import urllib from deejayd.ui import log def quote_uri(path): if type(path) is unicode: path = path.encode('utf-8') return "file://%s" % urllib.quote(path) def str_encode(data, charset = 'utf-8', errors='strict'): if type(data) is unicode: return data try: rs = data.decode(charset, errors) except UnicodeError: log.err(_("%s string has wrong characters, skip it") %\ data.decode(charset, "ignore").encode("utf-8","ignore")) raise UnicodeError return unicode(rs) def format_time(time): """Turn a time value in seconds into hh:mm:ss or mm:ss.""" if time >= 3600: # 1 hour # time, in hours:minutes:seconds return "%d:%02d:%02d" % (time // 3600, (time % 3600) // 60, time % 60) else: # time, in minutes:seconds return "%d:%02d" % (time // 60, time % 60) def format_time_long(time): """Turn a time value in seconds into x hours, x minutes, etc.""" if time < 1: return _("No time information") cutoffs = [ (60, "%d seconds", "%d second"), (60, "%d minutes", "%d minute"), (24, "%d hours", "%d hour"), (365, "%d days", "%d day"), (None, "%d years", "%d year"), ] time_str = [] for divisor, plural, single in cutoffs: if time < 1: break if divisor is None: time, unit = 0, time else: time, unit = divmod(time, divisor) if unit: time_str.append(ngettext(single, plural, unit) % unit) time_str.reverse() if len(time_str) > 2: time_str.pop() return ", ".join(time_str) ngettext("%d second", "%d seconds", 1) ngettext("%d minute", "%d minutes", 1) ngettext("%d hour", "%d hours", 1) ngettext("%d day", "%d days", 1) ngettext("%d year", "%d years", 1) def get_playlist_file_lines(URL): pls_handle = urllib.urlopen(URL) playlist = pls_handle.read() return playlist.splitlines() def get_uris_from_pls(URL): uris = [] lines = get_playlist_file_lines(URL) for line in lines: if line.lower().startswith("file") and line.find("=")!=-1: uris.append(line[line.find("=")+1:].strip()) return uris def get_uris_from_m3u(URL): uris = [] lines = get_playlist_file_lines(URL) for line in lines: if not line.startswith("#") and line.strip()!="": uris.append(line.strip()) return uris # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/database/0000755000175000017500000000000011354730161014553 5ustar royroydeejayd-0.10.0/deejayd/database/schema.py0000644000175000017500000001321011351210475016360 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. class Table(object): """Declare a table in a database schema.""" def __init__(self, name, key=[]): self.name = name self.columns = [] self.indices = [] self.key = key if isinstance(key, basestring): self.key = [key] def __getitem__(self, objs): self.columns = [o for o in objs if isinstance(o, Column)] self.indices = [o for o in objs if isinstance(o, Index)] return self class Column(object): """Declare a table column in a database schema.""" def __init__(self, name, type='text', size=None, auto_increment=False): self.name = name self.type = type self.size = size self.auto_increment = auto_increment class Index(object): """Declare an index for a database schema.""" def __init__(self, columns, unique = True): self.unique = unique self.columns = columns db_schema_version=13 db_schema = [ Table('library_dir', key='id')[ Column('id', auto_increment=True), Column('name'), Column('lib_type'), # audio or video Column('type'), # directory or dirlink Index(('name', 'lib_type', 'type'))], Table('library', key='id')[ Column('id', auto_increment=True), Column('directory', type='int'), Column('name'), Column('lastmodified', type='int'), Index(('name', 'directory')), Index(('directory',), unique = False)], Table('media_info', key=('id','ikey'))[ Column('id', type="int"), Column('ikey'), Column('value')], Table('cover', key='id')[ Column('id', auto_increment=True), # path to the cover file or # hash of the picture for cover inside audio file Column('source'), Column('mime_type'), # mime type of the cover, ex image/jpeg Column('lmod', type="int"), # last modified Column('image', type="blob"), Index(('source',))], Table('medialist', key='id')[ Column('id', auto_increment=True), Column('name'), Column('type'), # magic or static Index(('name','type'))], Table('medialist_libraryitem', key=('position'))[ Column('position', auto_increment=True), Column('medialist_id', type='int'), Column('libraryitem_id', type='int')], Table('medialist_property', key=('medialist_id','ikey'))[ Column('medialist_id', type='int'), Column('ikey'), Column('value')], Table('medialist_sorts', key=('position'))[ Column('position', auto_increment=True), Column('medialist_id', type='int'), Column('tag'), Column('direction')], Table('medialist_filters', key=('medialist_id', 'filter_id'))[ Column('medialist_id', type='int'), Column('filter_id', type='int')], Table('filters', key=('filter_id'))[ Column('filter_id', auto_increment=True), Column('type')], # complex or basic Table('filters_basicfilters', key=('filter_id'))[ Column('filter_id', type='int'), Column('tag'), # criterion Column('operator'), # equal, not equal, regex, regexi, etc. Column('pattern')], # matched value Table('filters_complexfilters', key=('filter_id'))[ Column('filter_id', type='int'), Column('combinator')], # AND, OR, XOR Table('filters_complexfilters_subfilters', key=('complexfilter_id', 'filter_id'))[ Column('complexfilter_id', type='int'), Column('filter_id', type='int')], Table('webradio', key='id')[ Column('id', auto_increment=True), Column('name'), Index(('name',), unique = False)], Table('webradio_entries', key='id')[ Column('id', auto_increment=True), Column('url'), Column('webradio_id', type='int'), Index(('url', 'webradio_id'))], Table('stats', key='name')[ Column('name'), Column('value', type='int')], Table('variables', key='name')[ Column('name'), Column('value')], ] db_init_cmds = [ # stats "INSERT INTO stats VALUES('video_library_update',0);", "INSERT INTO stats VALUES('audio_library_update',0);", "INSERT INTO stats VALUES('songs',0);", "INSERT INTO stats VALUES('videos',0);", "INSERT INTO stats VALUES('artists',0);", "INSERT INTO stats VALUES('albums',0);", "INSERT INTO stats VALUES('genres',0);", # variables "INSERT INTO variables VALUES('volume','0');", "INSERT INTO variables VALUES('current','-1');", "INSERT INTO variables VALUES('current_source','none');", "INSERT INTO variables VALUES('current_pos','0');", "INSERT INTO variables VALUES('state','stop');", "INSERT INTO variables VALUES('source','playlist');", "INSERT INTO variables VALUES('database_version','13');", ] # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/database/dbobjects.py0000644000175000017500000001545111351210475017070 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import re from deejayd import mediafilters from deejayd.database.querybuilders import EditRecordQuery class _DBObject(object): def __init__(self): self.id = None def save(self, db): raise NotImplementedError class NoneFilter(_DBObject): CLASS_TABLE = 'filters' PRIMARY_KEY = 'filter_id' TYPE = 'none' def __init__(self, none_filter = None): super(NoneFilter, self).__init__() self.id = None def restrict(self, query): pass def save(self, db): cursor = db.connection.cursor() query = EditRecordQuery(self.CLASS_TABLE) query.add_value('type', self.TYPE) if self.id: query.set_update_id(self.PRIMARY_KEY, self.id) cursor.execute(query.to_sql(), query.get_args()) if not self.id: self.id = db.connection.get_last_insert_id(cursor) cursor.close() return self.id class _BasicFilter(mediafilters.BasicFilter, NoneFilter): TABLE = 'filters_basicfilters' TYPE = 'basic' def __init__(self, basic_filter): NoneFilter.__init__(self) mediafilters.BasicFilter.__init__(self, basic_filter.tag, basic_filter.pattern) def restrict(self, query): query.join_on_tag(self.tag) where_str, arg = self._match_tag() query.append_where(where_str, (arg,)) def _match_tag(self, match_value): raise NotImplementedError def save(self, db): if not self.id: new = True else: new = False super(_BasicFilter, self).save(db) cursor = db.connection.cursor() query = EditRecordQuery(self.TABLE) if new: query.add_value(self.PRIMARY_KEY, self.id) else: query.set_update_id(self.PRIMARY_KEY, self.id) query.add_value('tag', self.tag) query.add_value('operator', self.get_name()) query.add_value('pattern', self.pattern) cursor.execute(query.to_sql(), query.get_args()) cursor.close() return self.id class Equals(mediafilters.Equals, _BasicFilter): def _match_tag(self): return "(%s.value = " % (self.tag,) + "%s)", self.pattern class NotEquals(mediafilters.NotEquals, _BasicFilter): def _match_tag(self): return "(%s.value != " % (self.tag,) + "%s)", self.pattern class Contains(mediafilters.Contains, _BasicFilter): def _match_tag(self): return "(%s.value LIKE " % (self.tag,) + "%s)", "%%"+self.pattern+"%%" class NotContains(mediafilters.NotContains, _BasicFilter): def _match_tag(self): return "(%s.value NOT LIKE " % (self.tag,)+"%s)", "%%"+self.pattern+"%%" class Higher(mediafilters.Higher, _BasicFilter): def _match_tag(self): return "(%s.value >= " % (self.tag,)+"%s)", self.pattern class Lower(mediafilters.Lower, _BasicFilter): def _match_tag(self): return "(%s.value <= " % (self.tag,)+"%s)", self.pattern class Regexi(mediafilters.Regexi, _BasicFilter): # FIXME : to implement some day pass class _ComplexFilter(mediafilters.ComplexFilter, NoneFilter): TABLE = 'filters_complexfilters' TYPE = 'complex' def __init__(self, complex_filter): self.sqlfilterlist = map(SQLizer().translate, complex_filter.filterlist) arglist = [self] + self.sqlfilterlist mediafilters.ComplexFilter.__init__(*arglist) def restrict(self, query): if self.filterlist: where_str, args = self._build_wheres(query) query.append_where(where_str, args) def _build_wheres(self, query): if not self.filterlist: return "(1)", [] wheres, wheres_args = [], [] for filter in self.filterlist: if filter.type == "basic": query.join_on_tag(filter.tag) where_query, arg = filter._match_tag() wheres.append(where_query) wheres_args.append(arg) else: # complex filter where_query, args = filter._build_wheres(query) wheres.append(where_query) wheres_args.extend(args) return "(%s)" % (self.combinator.join(wheres),), wheres_args def save(self, db): if not self.id: new = True else: new = False super(_ComplexFilter, self).save(db) cursor = db.connection.cursor() query = EditRecordQuery(_ComplexFilter.TABLE) if new: query.add_value(self.PRIMARY_KEY, self.id) else: query.set_update_id(self.PRIMARY_KEY, self.id) query.add_value('combinator', self.get_name()) cursor.execute(query.to_sql(), query.get_args()) for subfilter in self.sqlfilterlist: subfilter_id = subfilter.save(db) query = EditRecordQuery('filters_complexfilters_subfilters') query.add_value('complexfilter_id', self.id) query.add_value('filter_id', subfilter_id) cursor.execute(query.to_sql(), query.get_args()) cursor.close() return self.id class And(mediafilters.And, _ComplexFilter): combinator = ' AND ' class Or(mediafilters.Or, _ComplexFilter): combinator = ' OR ' class SQLizer(object): translations = { mediafilters.Equals : Equals, mediafilters.NotEquals : NotEquals, mediafilters.Contains : Contains, mediafilters.NotContains : NotContains, mediafilters.Regexi : Regexi, mediafilters.Higher : Higher, mediafilters.Lower : Lower, mediafilters.And : And, mediafilters.Or : Or, } def translate(self, object): if object == None: return NoneFilter(object) object_class = SQLizer.translations[object.__class__] return object_class(object) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/database/upgrade/0000755000175000017500000000000011354730161016202 5ustar royroydeejayd-0.10.0/deejayd/database/upgrade/db_7.py0000644000175000017500000000216411351210475017370 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. def upgrade(cursor, backend, config): sql = [ "INSERT INTO variables VALUES('qrandom','0');", "UPDATE variables SET value = '7' WHERE name = 'database_version';", ] for s in sql: cursor.execute(s) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/database/upgrade/db_6.py0000644000175000017500000000255011351210475017366 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. def upgrade(cursor, backend, config): sql = [ "DELETE FROM variables WHERE name = 'currentPos';", "INSERT INTO variables VALUES('current','-1');", "INSERT INTO variables VALUES('current_source','none');", "INSERT INTO variables VALUES('current_pos','0');", "INSERT INTO variables VALUES('state','stop');", "UPDATE variables SET value = '6' WHERE name = 'database_version';", ] for s in sql: cursor.execute(s) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/database/upgrade/db_12.py0000644000175000017500000000263611351210475017450 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from deejayd.database import schema def upgrade(cursor, backend, config): # drop wrong table medialist_sorts and create the new one cursor.execute("DROP TABLE medialist_sorts") for table in schema.db_schema: if table.name == "medialist_sorts": for stmt in backend.to_sql(table): cursor.execute(stmt) break # update db version sql = [ "UPDATE variables SET value = '12' WHERE name = 'database_version';", ] for s in sql: cursor.execute(s) # vim ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/database/upgrade/db_13.py0000644000175000017500000000515311351210475017446 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from deejayd.database import schema def upgrade(cursor, backend, config): # get recorded webradio cursor.execute("SELECT * FROM webradio") webradios = cursor.fetchall() # drop old webradio table and create new one cursor.execute("DROP TABLE webradio") for table in schema.db_schema: if table.name in ("webradio", "webradio_entries"): for stmt in backend.to_sql(table): cursor.execute(stmt) # record webradio in new table for wid, name, url in webradios: cursor.execute("SELECT id from webradio WHERE name=%s", (name[:-2],)) try: (id,) = cursor.fetchone() except (TypeError, ValueError): cursor.execute("INSERT INTO webradio (name)VALUES(%s)",\ (name[:-2],)) id = cursor.lastrowid cursor.execute("INSERT INTO webradio_entries \ (webradio_id,url)VALUES(%s,%s)", (id, url)) # remove useless state entries query = "DELETE FROM variables WHERE name = %s" cursor.executemany(query, [ ("playlist-playorder",), ("video-playorder",), ("panel-playorder",), ("queue-playorder",), ("playlist-repeat",), ("video-repeat",), ("panel-repeat",), ("queueid",), ("playlistid",), ("panelid",), ("webradioid",), ("videoid",), ("dvdid",), ("panel-type",), ("panel-value",), ]) # update db version sql = [ "UPDATE variables SET value = '13' WHERE name = 'database_version';", ] for s in sql: cursor.execute(s) # vim ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/database/upgrade/db_8.py0000644000175000017500000002201311351210475017364 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import os from deejayd.utils import quote_uri from deejayd.database import schema def format_tracknumber(tckn): numbers = tckn.split("/") try: numbers = ["%02d" % int(num) for num in numbers] except (TypeError, ValueError): return tckn return "/".join(numbers) def upgrade(cursor, backend, config): # get audio/video library cursor.execute("SELECT * FROM audio_library;") audio_library = cursor.fetchall() cursor.execute("SELECT * FROM video_library;") video_library = cursor.fetchall() # get medialist cursor.execute("SELECT DISTINCT name FROM medialist ORDER BY name") medialist = {} for (name,) in cursor.fetchall(): if name == "__videocurrent__": query = "SELECT l.dir, l.filename\ FROM medialist p JOIN video_library l \ ON p.media_id = l.id WHERE p.name = %s ORDER BY p.position" else: query = "SELECT l.dir, l.filename\ FROM medialist p JOIN audio_library l\ ON p.media_id = l.id WHERE p.name = %s ORDER BY p.position" cursor.execute(query, (name,)) medialist[name] = cursor.fetchall() # erase old table cursor.execute("DROP TABLE audio_library") cursor.execute("DROP TABLE video_library") cursor.execute("DROP TABLE medialist") # create new table/indexes new_tables = ( "library_dir", "library", "media_info", "cover", "medialist", "medialist_libraryitem", "medialist_filters", "filters", "filters_basicfilters", "filters_complexfilters", "filters_complexfilters_subfilters", ) for table in schema.db_schema: if table.name in new_tables: for stmt in backend.to_sql(table): cursor.execute(stmt) for query in backend.custom_queries: cursor.execute(query) # set library directory audio_dirs, video_dirs = {}, {} query = "INSERT INTO library_dir (name,lib_type,type)VALUES(%s,%s,%s)" path = config.get("mediadb","music_directory") cursor.execute(query, (path.rstrip("/"), "audio", "directory")) # root audio_dirs[path.rstrip("/")] = cursor.lastrowid for id,dir,fn,type,tit,art,alb,gn,tk,date,len,bt,rpg,rpp in audio_library: if type != "file": cursor.execute(query, (os.path.join(path,dir,fn), "audio", type)) audio_dirs[os.path.join(path,dir,fn).rstrip("/")] = cursor.lastrowid path = config.get("mediadb","video_directory") cursor.execute(query, (path.rstrip("/"), "video", "directory")) # root video_dirs[path.rstrip("/")] = cursor.lastrowid for id,dir,fn,type,tit,width,height,sub,len in video_library: if type != "file": cursor.execute(query, (os.path.join(path,dir,fn), "video", type)) video_dirs[os.path.join(path,dir,fn).rstrip("/")] = cursor.lastrowid # set library files library_files = {} query = "INSERT INTO library (directory,name,lastmodified)VALUES(%s,%s,%s)" for id,dir,fn,type,tit,art,alb,gn,tk,date,len,bt,rpg,rpp in audio_library: path = config.get("mediadb","music_directory") if type == "file": try: dir_id = audio_dirs[os.path.join(path,dir).rstrip("/")] except KeyError: continue file_path = os.path.join(path,dir,fn) if not os.path.isfile(file_path): continue cursor.execute(query, (dir_id,fn,os.stat(file_path).st_mtime)) file_id = cursor.lastrowid infos = { "type": "song", "filename": fn, "uri": quote_uri(file_path), "rating": "2", "lastplayed": "0", "skipcount": "0", "playcount": "0", "compilation": "0", "tracknumber": format_tracknumber(tk), "title": tit, "genre": gn, "artist": art, "album": alb, "date": date, "replaygain_track_gain":rpg, "replaygain_track_peak":rpp, "bitrate": bt, "length": len, "cover": "", } entries = [(file_id, k, v) for k, v in infos.items()] cursor.executemany("INSERT INTO media_info\ (id,ikey,value)VALUES(%s,%s,%s)", entries) library_files[os.path.join(path,dir,fn)] = file_id for id,dir,fn,type,tit,width,height,sub,len in video_library: path = config.get("mediadb","video_directory") if type == "file": try: dir_id = video_dirs[os.path.join(path,dir).rstrip("/")] except KeyError: continue file_path = os.path.join(path,dir,fn) if not os.path.isfile(file_path): continue cursor.execute(query, (dir_id,fn,os.stat(file_path).st_mtime)) file_id = cursor.lastrowid infos = { "type": "video", "filename": fn, "uri": quote_uri(file_path), "rating": "2", "lastplayed": "0", "skipcount": "0", "playcount": "0", "title": tit, "length": len, "videowidth": width, "videoheight": height, "external_subtitle": sub, } entries = [(file_id, k, v) for k, v in infos.items()] cursor.executemany("INSERT INTO media_info\ (id,ikey,value)VALUES(%s,%s,%s)", entries) library_files[os.path.join(path,dir,fn)] = file_id # set compilation tag query = "SELECT DISTINCT value FROM media_info WHERE ikey='album'" cursor.execute(query) for (album,) in cursor.fetchall(): if album == '': continue # do not set compilation tag query = "SELECT COUNT(DISTINCT m.value) FROM media_info m \ JOIN media_info m2 ON m.id = m2.id\ WHERE m.ikey='artist' AND m2.ikey='album'\ AND m2.value=%s" cursor.execute(query, (album,)) (value,) = cursor.fetchone() if int(value) > 1: cursor.execute("SELECT id FROM media_info\ WHERE ikey='album' AND value=%s", (album,)) cursor.executemany("UPDATE media_info SET value = '1'\ WHERE ikey='compilation' AND id = %s",cursor.fetchall()) # set medialist for pl_name, items in medialist.items(): cursor.execute("INSERT INTO medialist (name,type)VALUES(%s,%s)",\ (pl_name, "static")) pl_id = cursor.lastrowid path = config.get("mediadb","music_directory") if pl_name == "__videocurrent__": path = config.get("mediadb","video_directory") for item in items: try: file_id = library_files[os.path.join(path,item[0],item[1])] except KeyError: continue cursor.execute("INSERT INTO medialist_libraryitem\ (medialist_id,libraryitem_id)VALUES(%s,%s)", (pl_id, file_id)) # other updates sql = [ "DELETE FROM variables WHERE name='repeat'", "DELETE FROM variables WHERE name='songlistid'", "DELETE FROM variables WHERE name='repeat'", "DELETE FROM variables WHERE name='random'", "DELETE FROM variables WHERE name='qrandom'", "INSERT INTO variables VALUES('playlist-playorder','inorder');", "INSERT INTO variables VALUES('panel-playorder','inorder');", "INSERT INTO variables VALUES('video-playorder','inorder');", "INSERT INTO variables VALUES('queue-playorder','inorder');", "INSERT INTO variables VALUES('playlist-repeat','0');", "INSERT INTO variables VALUES('panel-repeat','0');", "INSERT INTO variables VALUES('video-repeat','0');", "INSERT INTO variables VALUES('panelid','1');", "INSERT INTO variables VALUES('panel-type','panel');", "INSERT INTO variables VALUES('panel-value','');", "UPDATE variables SET value = '8' WHERE name = 'database_version';", ] for s in sql: cursor.execute(s) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/database/upgrade/db_11.py0000644000175000017500000000253411351210475017444 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from deejayd.database import schema def upgrade(cursor, backend, config): # create new table medialist_property for table in schema.db_schema: if table.name == "medialist_property": for stmt in backend.to_sql(table): cursor.execute(stmt) break # update db version sql = [ "UPDATE variables SET value = '11' WHERE name = 'database_version';", ] for s in sql: cursor.execute(s) # vim ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/database/upgrade/__init__.py0000644000175000017500000000160611351210475020314 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/database/upgrade/db_10.py0000644000175000017500000000420211351210475017435 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from deejayd.database import schema def upgrade(cursor, backend, config): # create new table medialist_sorts for table in schema.db_schema: if table.name == "medialist_sorts": for stmt in backend.to_sql(table): cursor.execute(stmt) break # remove compilation tag and add various_artist tag query = "SELECT id FROM media_info WHERE ikey='compilation' and value='1'" cursor.execute(query) cursor.executemany("INSERT INTO media_info (id,ikey,value)\ VALUES(%s,%s,%s)", [(id,"various_artist","__various__")\ for (id,) in cursor.fetchall()]) query = "SELECT m.id, m.value FROM media_info m\ JOIN media_info m1 ON m.id = m1.id\ WHERE m1.ikey='compilation' and m1.value='0' and m.ikey='artist'" cursor.execute(query) cursor.executemany("INSERT INTO media_info (id,ikey,value)\ VALUES(%s,%s,%s)", [(id,"various_artist",artist)\ for (id,artist) in cursor.fetchall()]) query = "DELETE FROM media_info WHERE ikey='compilation'" cursor.execute(query) # update db version sql = [ "UPDATE variables SET value = '10' WHERE name = 'database_version';", ] for s in sql: cursor.execute(s) # vim ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/database/upgrade/db_9.py0000644000175000017500000000322511351210475017371 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from deejayd.database import schema def upgrade(cursor, backend, config): # get covers cursor.execute("SELECT * FROM cover;") covers = cursor.fetchall() # drop old table cursor.execute("DROP TABLE cover") # create new table for table in schema.db_schema: if table.name != "cover": continue for stmt in backend.to_sql(table): cursor.execute(stmt) query = "INSERT INTO cover (id,source,mime_type,lmod,image)\ VALUES(%s,%s,%s,%s,%s)" for (id, source, lmod, img) in covers: mime = "image/jpeg" cursor.execute(query, (id, source, mime, lmod, img)) sql = [ "UPDATE variables SET value = '9' WHERE name = 'database_version';", ] for s in sql: cursor.execute(s) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/database/queries.py0000644000175000017500000007017711351210475016614 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import os, sys, time, base64 from deejayd.mediafilters import * from deejayd.ui import log from deejayd.database.querybuilders import * from deejayd.database.dbobjects import SQLizer ############################################################################ class MediaFile(dict): def __init__(self, db, file_id): self.db = db self["media_id"] = file_id def set_info(self, key, value): self.set_infos({key: value}) def set_infos(self, infos): self.db.set_media_infos(self["media_id"], infos) self.db.connection.commit() self.update(infos) def played(self): played = int(self["playcount"]) + 1 timestamp = int(time.time()) self.set_infos({"playcount":str(played), "lastplayed":str(timestamp)}) def skip(self): skip = int(self["skipcount"]) + 1 self.set_info("skipcount", str(skip)) def get_cover(self): if self["type"] != "song": raise AttributeError try: (id, mime, cover) = self.db.get_file_cover(self["media_id"]) except TypeError: raise AttributeError return {"cover": base64.b64decode(cover), "id":id, "mime": mime} def replay_gain(self): """Return the recommended Replay Gain scale factor.""" try: db = float(self["replaygain_track_gain"].split()[0]) peak = self["replaygain_track_peak"] and\ float(self["replaygain_track_peak"]) or 1.0 except (KeyError, ValueError, IndexError): return 1.0 else: scale = 10.**(db / 20) if scale * peak > 1: scale = 1.0 / peak # don't clip return min(15, scale) ############################################################################ class DatabaseQueries(object): structure_created = False def __init__(self, connection): self.connection = connection self.sqlizer = SQLizer() # # MediaDB requests # @query_decorator("lastid") def insert_file(self, cursor, dir, filename, lastmodified): query = "INSERT INTO library \ (directory,name,lastmodified)VALUES(%s, %s, %s)" cursor.execute(query, (dir, filename, lastmodified)) @query_decorator("none") def update_file(self, cursor, id, lastmodified): query = "UPDATE library SET lastmodified = %s WHERE id = %s" cursor.execute(query, (lastmodified, id)) @query_decorator("rowcount") def set_media_infos(self, cursor, file_id, infos, allow_create = True): if allow_create: query = "REPLACE INTO media_info (id,ikey,value)VALUES(%s,%s,%s)" entries = [(file_id, k, v) for k, v in infos.items()] else: query = "UPDATE media_info SET value=%s WHERE id=%s and ikey=%s" entries = [(v, file_id, k) for k, v in infos.items()] cursor.executemany(query, entries) @query_decorator("none") def remove_file(self, cursor, id): queries = [ "DELETE FROM library WHERE id = %s", "DELETE FROM media_info WHERE id = %s", "DELETE FROM medialist_libraryitem WHERE libraryitem_id = %s", ] for q in queries: cursor.execute(q, (id,)) @query_decorator("fetchone") def is_file_exist(self, cursor, dirname, filename, type = "audio"): query = "SELECT d.id, l.id \ FROM library l JOIN library_dir d ON d.id=l.directory\ WHERE l.name = %s AND d.name = %s AND d.lib_type = %s" cursor.execute(query,(filename, dirname, type)) @query_decorator("lastid") def insert_dir(self, cursor, new_dir, type="audio"): query = "INSERT INTO library_dir (name,type,lib_type)VALUES(%s,%s,%s)" cursor.execute(query, (new_dir, 'directory', type)) @query_decorator("none") def remove_dir(self, cursor, id): cursor.execute("SELECT id FROM library WHERE directory = %s", (id,)) for (id,) in cursor.fetchall(): self.remove_file(id) cursor.execute("DELETE FROM library_dir WHERE id = %s", (id,)) @query_decorator("none") def remove_recursive_dir(self, cursor, dir, type="audio"): files = self.get_all_files(dir, type) for file in files: self.remove_file(file[2]) cursor.execute("DELETE FROM library_dir\ WHERE name LIKE %s AND lib_type = %s", (dir+u"%%", type)) return [f[2] for f in files] @query_decorator("custom") def is_dir_exist(self, cursor, dirname, type): cursor.execute("SELECT id FROM library_dir\ WHERE name=%s AND lib_type=%s", (dirname, type)) rs = cursor.fetchone() return rs and rs[0] @query_decorator("none") def insert_dirlink(self, cursor, new_dirlink, type="audio"): query = "INSERT INTO library_dir (name,type,lib_type)VALUES(%s,%s,%s)" cursor.execute(query, (new_dirlink, "dirlink", type)) @query_decorator("none") def remove_dirlink(self, cursor, dirlink, type="audio"): query = "DELETE FROM library_dir\ WHERE name = %s AND\ type = 'dirlink' AND\ lib_type = %s" cursor.execute(query, (dirlink, type)) @query_decorator("fetchall") def get_dir_list(self, cursor, dir, t = "audio"): query = "SELECT DISTINCT id, name FROM library_dir\ WHERE name LIKE %s AND\ lib_type = %s AND\ type = 'directory'\ ORDER BY name" term = dir == unicode("") and u"%%" or dir+unicode("/%%") cursor.execute(query, (term, t)) @query_decorator("fetchone") def get_file_info(self, cursor, file_id, info_type): query = "SELECT value FROM media_info WHERE id = %s AND ikey = %s" cursor.execute(query, (file_id, info_type)) @query_decorator("fetchall") def get_all_files(self, cursor, dir, type = "audio"): query = "SELECT DISTINCT d.id, d.name, l.id, l.name, l.lastmodified\ FROM library l JOIN library_dir d ON d.id=l.directory\ WHERE d.name LIKE %s AND d.lib_type = %s ORDER BY d.name,l.name" cursor.execute(query,(dir+u"%%", type)) @query_decorator("fetchall") def get_all_dirs(self, cursor, dir, type = "audio"): query = "SELECT DISTINCT id,name FROM library_dir\ WHERE name LIKE %s AND type='directory' AND lib_type = %s\ ORDER BY name" cursor.execute(query,(dir+u"%%", type)) @query_decorator("fetchall") def get_all_dirlinks(self, cursor, dirlink, type = 'audio'): query = "SELECT DISTINCT id,name FROM library_dir\ WHERE name LIKE %s AND type='dirlink' AND lib_type = %s\ ORDER BY name" cursor.execute(query,(dirlink+u"%%", type)) def _medialist_answer(self, answer, infos = []): files = [] for m in answer: current = MediaFile(self, m[0]) for index, attr in enumerate(infos): current[attr] = m[index+1] files.append(current) return files def _build_media_query(self, infos_list): selectquery = "i.id" joinquery = "" for index, key in enumerate(infos_list): selectquery += ",i%d.value" % index joinquery += " LEFT OUTER JOIN media_info i%d ON i%d.id=i.id AND\ i%d.ikey='%s'" % (index, index, index, key) return selectquery, joinquery @query_decorator("medialist") def get_dir_content(self, cursor, dir, infos = [], type = "audio"): selectquery, joinquery = self._build_media_query(infos) query = "SELECT DISTINCT "+ selectquery +\ " FROM library l JOIN library_dir d ON d.id=l.directory\ LEFT OUTER JOIN media_info i ON i.id=l.id"\ + joinquery+\ " WHERE d.name = %s AND d.lib_type = %s ORDER BY d.name,l.name" cursor.execute(query,(dir, type)) @query_decorator("medialist") def get_file(self, cursor, dir, file, infos = [], type = "audio"): selectquery, joinquery = self._build_media_query(infos) query = "SELECT DISTINCT "+ selectquery +\ " FROM library l JOIN library_dir d ON d.id=l.directory\ JOIN media_info i ON i.id=l.id"\ + joinquery+\ " WHERE d.name = %s AND l.name = %s AND d.lib_type = %s" cursor.execute(query, (dir, file, type)) @query_decorator("medialist") def get_file_withids(self, cursor, file_ids, infos=[], type="audio"): selectquery, joinquery = self._build_media_query(infos) query = "SELECT DISTINCT "+ selectquery +\ " FROM library l JOIN library_dir d ON d.id=l.directory\ JOIN media_info i ON i.id=l.id"\ + joinquery+\ " WHERE l.id IN (%s) AND d.lib_type = '%s'" % \ (",".join(map(str,file_ids)), type) cursor.execute(query) @query_decorator("medialist") def get_alldir_files(self, cursor, dir, infos = [], type = "audio"): selectquery, joinquery = self._build_media_query(infos) query = "SELECT DISTINCT "+ selectquery +\ " FROM library l JOIN library_dir d ON d.id=l.directory\ JOIN media_info i ON i.id=l.id"\ + joinquery+\ " WHERE d.name LIKE %s AND d.lib_type = %s ORDER BY d.name,l.name" cursor.execute(query,(dir+u"%%", type)) @query_decorator("fetchall") def get_dircontent_id(self, cursor, dir, type): query = "SELECT l.id\ FROM library l JOIN library_dir d ON l.directory = d.id\ WHERE d.lib_type = %s AND d.name = %s" cursor.execute(query,(type, dir)) @query_decorator("fetchall") def search_id(self, cursor, key, value): query = "SELECT DISTINCT id FROM media_info WHERE ikey=%s AND value=%s" cursor.execute(query,(key, value)) @query_decorator("medialist") def search(self, cursor, filter, infos = [], orders = [], limit = None): filter = self.sqlizer.translate(filter) query = MediaSelectQuery() query.select_id() for tag in infos: query.select_tag(tag) filter.restrict(query) for (tag, direction) in orders: query.order_by_tag(tag, direction == "descending") query.set_limit(limit) cursor.execute(query.to_sql(), query.get_args()) @query_decorator("fetchall") def list_tags(self, cursor, tag, filter): filter = self.sqlizer.translate(filter) query = MediaSelectQuery() query.select_tag(tag) filter.restrict(query) query.order_by_tag(tag) cursor.execute(query.to_sql(), query.get_args()) @query_decorator("fetchall") def get_media_keys(self, cursor, type): query = "SELECT DISTINCT m.ikey FROM media_info m\ JOIN media_info m2 ON m.id = m2.id\ WHERE m2.ikey='type' AND m2.value=%s" cursor.execute(query, (type,)) # # Post update action # @query_decorator("none") def set_variousartist_tag(self, cursor, fid, file_info): query = "SELECT DISTINCT m.id,m.value,m3.value FROM media_info m\ JOIN media_info m2 ON m.id = m2.id\ JOIN media_info m3 ON m.id = m3.id\ WHERE m.ikey='various_artist' AND m2.ikey='album'\ AND m2.value=%s AND m3.ikey='artist'" cursor.execute(query, (file_info["album"],)) try: (id, various_artist, artist) = cursor.fetchone() except TypeError: # first song of this album return else: need_update = False if various_artist == "__various__": need_update, ids = True, [(fid,)] elif artist != file_info["artist"]: need_update = True cursor.execute("SELECT id FROM media_info\ WHERE ikey='album' AND value=%s", (file_info["album"],)) ids = cursor.fetchall() if need_update: cursor.executemany("UPDATE media_info SET value = '__various__'\ WHERE ikey='various_artist' AND id = %s", ids) @query_decorator("none") def erase_empty_dir(self, cursor, type = "audio"): cursor.execute("SELECT DISTINCT name FROM library_dir\ WHERE lib_type=%s", (type,)) for (dirname,) in cursor.fetchall(): rs = self.get_all_files(dirname, type) if len(rs) == 0: # remove directory cursor.execute("DELETE FROM library_dir WHERE name = %s",\ (dirname,)) @query_decorator("none") def update_stats(self, cursor, type = "audio"): # record mediadb stats query = "UPDATE stats SET value = \ (SELECT COUNT(DISTINCT m.value) FROM media_info m JOIN media_info m2\ ON m.id = m2.id WHERE m.ikey=%s AND m2.ikey='type' AND m2.value=%s)\ WHERE name = %s" if type == "audio": values = [("uri", "song", "songs"), ("artist", "song", "artists"), ("genre", "song", "genres"), ("album", "song", "albums")] elif type == "video": values = [("uri", "video", "videos")] cursor.executemany(query, values) # update last updated stats cursor.execute("UPDATE stats SET value = %s WHERE name = %s",\ (time.time(),type+"_library_update")) # # cover requests # @query_decorator("fetchone") def get_file_cover(self, cursor, file_id, source = False): var = source and "source" or "image" query = "SELECT c.id, c.mime_type, c." + var +\ " FROM media_info m JOIN cover c\ ON m.ikey = 'cover' AND m.value = c.id\ WHERE m.id = %s" cursor.execute(query, (file_id,)) @query_decorator("fetchone") def is_cover_exist(self, cursor, source): query = "SELECT id,lmod FROM cover WHERE source=%s" cursor.execute(query, (source,)) @query_decorator("lastid") def add_cover(self, cursor, source, mime, image): query = "INSERT INTO cover (source,mime_type,lmod,image)\ VALUES(%s,%s,%s,%s)" cursor.execute(query, (source, mime, time.time(), image)) @query_decorator("none") def update_cover(self, cursor, id, mime, new_image): query = "UPDATE cover SET mime_type = %s, lmod = %s, image = %s\ WHERE id=%s" cursor.execute(query, (mime, time.time(), new_image, id)) @query_decorator("none") def remove_cover(self, cursor, id): query = "DELETE FROM cover WHERE id=%s" cursor.execute(query, (id,)) @query_decorator("none") def remove_unused_cover(self, cursor): query = "DELETE FROM cover WHERE id NOT IN \ (SELECT DISTINCT value FROM media_info WHERE ikey = 'cover')" cursor.execute(query) # # common medialist requests # @query_decorator("fetchall") def get_medialist_list(self, cursor): query = SimpleSelect('medialist') query.select_column('id', 'name', 'type') query.order_by('name') cursor.execute(query.to_sql(), query.get_args()) @query_decorator("custom") def get_medialist_id(self, cursor, pl_name, pl_type = 'static'): query = SimpleSelect('medialist') query.select_column('id') query.append_where("name = %s", (pl_name, )) query.append_where("type = %s", (pl_type, )) cursor.execute(query.to_sql(), query.get_args()) ans = cursor.fetchone() if ans is None: raise ValueError return ans[0] @query_decorator("fetchone") def is_medialist_exists(self, cursor, pl_id): query = SimpleSelect('medialist') query.select_column('id', 'name', 'type') query.append_where("id = %s", (pl_id, )) cursor.execute(query.to_sql(), query.get_args()) @query_decorator("none") def delete_medialist(self, cursor, ml_id): try: ml_id, name, type = self.is_medialist_exists(ml_id) except TypeError: return if type == "static": query = "DELETE FROM medialist_libraryitem WHERE medialist_id = %s" cursor.execute(query, (ml_id,)) elif type == "magic": for (filter_id,) in self.__get_medialist_filterids(cursor, ml_id): self.delete_filter(cursor, filter_id) cursor.execute(\ "DELETE FROM medialist_filters WHERE medialist_id=%s", (ml_id,)) # delete medialist properties cursor.execute(\ "DELETE FROM medialist_property WHERE medialist_id=%s", (ml_id,)) # delete medialist sort cursor.execute(\ "DELETE FROM medialist_sorts WHERE medialist_id=%s", (ml_id,)) cursor.execute("DELETE FROM medialist WHERE id = %s", (ml_id,)) self.connection.commit() def get_filter(self, cursor, id): try: filter_type = self.__get_filter_type(cursor, id) except ValueError: return None if filter_type == 'basic': return self.__get_basic_filter(cursor, id) elif filter_type == 'complex': return self.__get_complex_filter(cursor, id) def delete_filter(self, cursor, filter_id): try: filter_type = self.__get_filter_type(cursor, filter_id) except ValueError: return None if filter_type == 'basic': cursor.execute("DELETE FROM filters_basicfilters\ WHERE filter_id = %s", (filter_id,)) elif filter_type == 'complex': # get filters id associated with this filter query = SimpleSelect('filters_complexfilters_subfilters') query.select_column('filter_id') query.append_where("complexfilter_id = %s", (filter_id, )) cursor.execute(query.to_sql(), query.get_args()) for (id,) in cursor.fetchall(): self.delete_filter(cursor, id) cursor.execute("DELETE FROM filters_complexfilters_subfilters \ WHERE complexfilter_id = %s AND filter_id = %s",\ (filter_id, id)) cursor.execute("DELETE FROM filters_complexfilters \ WHERE filter_id = %s",(filter_id,)) cursor.execute("DELETE FROM filters WHERE filter_id = %s",(filter_id,)) def __get_filter_type(self, cursor, filter_id): query = SimpleSelect('filters') query.select_column('type') query.append_where("filter_id = %s", (filter_id, )) cursor.execute(query.to_sql(), query.get_args()) record = cursor.fetchone() if not record: raise ValueError return record[0] def __get_basic_filter(self, cursor, id): query = SimpleSelect('filters_basicfilters') query.select_column('tag', 'operator', 'pattern') query.append_where("filter_id = %s", (id, )) cursor.execute(query.to_sql(), query.get_args()) record = cursor.fetchone() if record: bfilter_class = NAME2BASIC[record[1]] f = bfilter_class(record[0], record[2]) return f def __get_complex_filter(self, cursor, id): query = SimpleSelect('filters_complexfilters') query.select_column('combinator') query.append_where("filter_id = %s", (id, )) cursor.execute(query.to_sql(), query.get_args()) record = cursor.fetchone() if record: cfilter_class = NAME2COMPLEX[record[0]] query = SimpleSelect('filters_complexfilters_subfilters') query.select_column('filter_id') query.append_where("complexfilter_id = %s", (id, )) cursor.execute(query.to_sql(), query.get_args()) sf_records = cursor.fetchall() filterlist = [] for sf_record in sf_records: sf_id = sf_record[0] filterlist.append(self.get_filter(cursor, sf_id)) cfilter = cfilter_class(*filterlist) return cfilter def __get_medialist_filterids(self, cursor, ml_id): query = SimpleSelect('medialist_filters') query.select_column('filter_id') query.append_where("medialist_id = %s", (ml_id, )) cursor.execute(query.to_sql(), query.get_args()) return cursor.fetchall() def __add_medialist_filters(self, cursor, pl_id, filters): filter_ids = [] for filter in filters: filter_id = self.sqlizer.translate(filter).save(self) if filter_id: filter_ids.append((pl_id, filter_id)) cursor.executemany("INSERT INTO medialist_filters\ (medialist_id,filter_id)VALUES(%s,%s)", filter_ids) @query_decorator("custom") def get_magic_medialist_filters(self, cursor, ml_id): rs = self.__get_medialist_filterids(cursor, ml_id) if not rs: return [] filters = [] for (filter_id,) in rs: filter = self.get_filter(cursor, filter_id) if filter: filters.append(filter) return filters @query_decorator("custom") def set_magic_medialist_filters(self, cursor, pl_name, filters): slt_query = "SELECT id FROM medialist WHERE name=%s AND type = 'magic'" cursor.execute(slt_query, (pl_name,)) rs = cursor.fetchone() if not rs: query = "INSERT INTO medialist (name,type)VALUES(%s,'magic')" cursor.execute(query, (pl_name,)) id = self.connection.get_last_insert_id(cursor) else: (id,) = rs for (filter_id,) in self.__get_medialist_filterids(cursor, id): self.delete_filter(cursor, filter_id) cursor.execute(\ "DELETE FROM medialist_filters WHERE medialist_id=%s", (id,)) self.__add_medialist_filters(cursor, id, filters) self.connection.commit() return id @query_decorator("none") def add_magic_medialist_filters(self, cursor, pl_id, filters): self.__add_medialist_filters(cursor, pl_id, filters) self.connection.commit() @query_decorator("fetchall") def get_magic_medialist_sorts(self, cursor, ml_id): query = "SELECT tag,direction FROM medialist_sorts\ WHERE medialist_id = %s" cursor.execute(query, (ml_id,)) @query_decorator("none") def set_magic_medialist_sorts(self, cursor, ml_id, sorts): # first, delete all previous sort for this medialist cursor.execute("DELETE FROM medialist_sorts WHERE medialist_id=%s",\ (ml_id,)) cursor.executemany("INSERT INTO medialist_sorts\ (medialist_id,tag,direction)VALUES(%s,%s,%s)",\ [ (ml_id, tag, direction) for (tag, direction) in sorts]) self.connection.commit() @query_decorator("fetchall") def get_magic_medialist_properties(self, cursor, ml_id): query = "SELECT ikey,value FROM medialist_property\ WHERE medialist_id = %s" cursor.execute(query, (ml_id,)) @query_decorator("none") def set_magic_medialist_property(self, cursor, ml_id, key, value): cursor.execute("REPLACE INTO medialist_property\ (medialist_id,ikey,value)VALUES(%s,%s,%s)", (ml_id, key, value)) self.connection.commit() ###################################### ###### Static medialist queries ###### ###################################### @query_decorator("none") def add_to_static_medialist(self, cursor, ml_id, media_ids): query = "INSERT INTO medialist_libraryitem\ (medialist_id, libraryitem_id) VALUES(%s,%s)" cursor.executemany(query, [(ml_id, mid) for mid in media_ids]) @query_decorator("medialist") def get_static_medialist(self, cursor, ml_id, infos = []): selectquery, joinquery = self._build_media_query(infos) query = "SELECT DISTINCT "+ selectquery + ", mi.position " +\ " FROM medialist m JOIN medialist_libraryitem mi\ ON m.id = mi.medialist_id\ JOIN media_info i ON i.id=mi.libraryitem_id"\ + joinquery+\ " WHERE m.id = %s AND m.type = 'static' ORDER BY mi.position" cursor.execute(query,(ml_id,)) @query_decorator("custom") def set_static_medialist(self, cursor, name, content): slt_query = "SELECT id FROM medialist WHERE name=%s AND type = 'static'" cursor.execute(slt_query, (name,)) rs = cursor.fetchone() if not rs: query = "INSERT INTO medialist (name,type)VALUES(%s,'static')" cursor.execute(query, (name,)) id = self.connection.get_last_insert_id(cursor) else: (id,) = rs cursor.execute(\ "DELETE FROM medialist_libraryitem WHERE medialist_id = %s",(id,)) values = [(id, s["media_id"]) for s in content] query = "INSERT INTO medialist_libraryitem(medialist_id,libraryitem_id)\ VALUES(%s,%s)" cursor.executemany(query,values) self.connection.commit() # return id of the playlist return id # # Webradio requests # @query_decorator("fetchall") def get_webradios(self, cursor): cursor.execute("SELECT DISTINCT w.id, w.name, e.url\ FROM webradio w INNER JOIN webradio_entries e \ ON w.id = e.webradio_id\ ORDER BY w.id, e.id") @query_decorator("none") def add_webradio(self, cursor, name, urls): query = "INSERT INTO webradio(name)VALUES(%s)" cursor.execute(query, (name,)) wid = self.connection.get_last_insert_id(cursor) query = "INSERT INTO webradio_entries(url, webradio_id)VALUES(%s,%s)" cursor.executemany(query, [(url,wid) for url in urls]) self.connection.commit() @query_decorator("none") def remove_webradios(self, cursor, wids): wids = [(wid,) for wid in wids] cursor.executemany("DELETE FROM webradio WHERE id = %s" , wids) cursor.executemany("DELETE FROM webradio_entries\ WHERE webradio_id = %s", wids) self.connection.commit() @query_decorator("none") def remove_url_from_webradio(self, cursor, wid, url_id): cursor.execute("DELETE FROM webradio_entries\ WHERE webradio_id = %s AND id = %s", (wid, url_id)) self.connection.commit() @query_decorator("none") def add_url_for_webradio(self, cursor, wid, url): cursor.execute("INSERT INTO webradio_entries (url, webradio_id)\ VALUES(%s,%s)", (url, wid)) self.connection.commit() @query_decorator("none") def clear_webradios(self, cursor): cursor.execute("DELETE FROM webradio") cursor.execute("DELETE FROM webradio_entries") self.connection.commit() # # Stat requests # @query_decorator("fetchall") def get_stats(self, cursor): cursor.execute("SELECT * FROM stats") # # State requests # @query_decorator("none") def set_state(self, cursor, values): cursor.executemany("REPLACE INTO variables (value,name)VALUES(%s,%s)",\ values) self.connection.commit() @query_decorator("custom") def get_state(self, cursor, type): cursor.execute("SELECT value FROM variables WHERE name = %s",(type,)) try: (rs,) = cursor.fetchone() return rs except (ValueError, TypeError): return None def close(self): self.connection.close() # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/database/backends/0000755000175000017500000000000011354730161016325 5ustar royroydeejayd-0.10.0/deejayd/database/backends/mysql.py0000644000175000017500000001245111351210475020045 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import time from threading import local from deejayd.ui import log import MySQLdb as mysql # We want version (1, 2, 1, 'final', 2) or later. We can't just use # lexicographic ordering in this check because then (1, 2, 1, 'gamma') # inadvertently passes the version test. version = mysql.version_info if (version < (1,2,1) or (version[:3] == (1, 2, 1) and (len(version) < 5 or version[3] != 'final' or version[4] < 2))): raise ImportError, "MySQLdb-1.2.1p2 or newer is required; you have %s" % mysql.__version__ DatabaseError = mysql.DatabaseError class DatabaseWrapper(local): __slots__ = "globalcommit" def __init__(self, db_name, db_user, db_password, db_host, db_port): self.connection = None self.last_commit = 0 self.db_name = db_name self.db_user = db_user self.db_password = db_password self.db_host = db_host self.db_port = db_port def _valid_connection(self): if self.connection is not None: try: self.connection.ping() try: globalcommit = self.globalcommit except AttributeError: globalcommit = 0 if self.last_commit < globalcommit: self.connection.commit() self.connection.close() self.connection = None self.last_commit = globalcommit else: return True except DatabaseError: self.connection.close() self.connection = None return False def cursor(self): if not self._valid_connection(): try: self.connection = mysql.connect(db=self.db_name,\ user=self.db_user, passwd=self.db_password,\ host=self.db_host, port=self.db_port, charset="utf8",\ use_unicode=True) except DatabaseError, err: error = _("Could not connect to MySQL server %s." % err) log.err(error, fatal = True) cursor = self.connection.cursor() return cursor def commit(self): if self.connection is not None: self.connection.commit() timestamp = time.time() self.globalcommit = timestamp self.lastcommit = timestamp def rollback(self): if self.connection is not None: try: self.connection.rollback() except mysql.NotSupportedError: pass def get_last_insert_id(self, cursor): return cursor.lastrowid def close(self): if self.connection is not None: self.connection.close() self.connection = None def to_sql(table): def __collist(table, columns): cols = [] limit = 333 / len(columns) if limit > 255: limit = 255 for c in columns: name = '`%s`' % c table_col = filter((lambda x: x.name == c), table.columns) if len(table_col) == 1 and table_col[0].type.lower() == 'text': name += '(%s)' % limit # For non-text columns, we simply throw away the extra bytes. # That could certainly be optimized better, but for now let's KISS. cols.append(name) return ','.join(cols) sql = ['CREATE TABLE %s (' % table.name] coldefs = [] for column in table.columns: ctype = column.type if ctype == "blob": ctype = "mediumblob" if column.auto_increment: ctype = 'INT UNSIGNED NOT NULL AUTO_INCREMENT' # Override the column type, as a text field cannot # use auto_increment. column.type = 'int' coldefs.append(' `%s` %s' % (column.name, ctype)) if len(table.key) > 0: coldefs.append(' PRIMARY KEY (%s)' % __collist(table, table.key)) sql.append(',\n'.join(coldefs) + '\n) ENGINE=InnoDB') #sql.append(',\n'.join(coldefs) + '\n)') yield '\n'.join(sql) for index in table.indices: unique = index.unique and "UNIQUE" or "" yield 'CREATE %s INDEX %s_%s_idx ON %s (%s);' % (unique,table.name, '_'.join(index.columns), table.name, __collist(table, index.columns)) custom_queries = [ "CREATE UNIQUE INDEX id_key_value_idx ON media_info\ (id, ikey(64), value(64));", "CREATE INDEX key_value_idx ON media_info\ (ikey(64), value(64));", ] # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/database/backends/_base.py0000644000175000017500000000246211351210475017752 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. class DatabaseError(Exception): pass class DatabaseWrapper: def cursor(self): raise NotImplementedError def commit(self): raise NotImplementedError def rollback(self): raise NotImplementedError def close(self): raise NotImplementedError def get_last_insert_id(self): raise NotImplementedError def to_sql(self, table): raise NotImplementedError # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/database/backends/__init__.py0000644000175000017500000000160611351210475020437 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/database/backends/sqlite.py0000644000175000017500000001275111351210475020204 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from os import path from threading import local from deejayd.ui import log try: from sqlite3 import dbapi2 as sqlite # python 2.5 except ImportError: from pysqlite2 import dbapi2 as sqlite # Check pysqlite version pysqlite_min_version = [2, 2] pysqlite_version = map(int, sqlite.version.split('.')) if pysqlite_version < pysqlite_min_version: sqlite_error=_('This program requires pysqlite version %s or later.')\ % '.'.join(map(str, pysqlite_min_version)) log.err(sqlite_error, fatal = True) LOCK_TIMEOUT = 600 DatabaseError = sqlite.DatabaseError def str_encode(data): if isinstance(data, unicode): return data.encode("utf-8") return data class DatabaseWrapper(local): def __init__(self, db_file): self._file = db_file self.connection = None def cursor(self): if self.connection is None: try: self.connection = sqlite.connect(self._file,\ timeout=LOCK_TIMEOUT) except sqlite.Error: error = _("Could not connect to sqlite database %s.")%self._file log.err(error, fatal = True) # configure connection sqlite.register_adapter(str,str_encode) return self.connection.cursor(factory = SQLiteCursorWrapper) def commit(self): if self.connection is not None: self.connection.commit() def rollback(self): if self.connection is not None: self.connection.rollback() def get_last_insert_id(self, cursor): return cursor.lastrowid def close(self): if self.connection is not None: self.connection.close() self.connection = None class SQLiteCursorWrapper(sqlite.Cursor): def execute(self, query, params=()): query = self.convert_query(query, len(params)) return sqlite.Cursor.execute(self, query, params) def executemany(self, query, param_list): if len(param_list) == 0: return query = self.convert_query(query, len(param_list[0])) return sqlite.Cursor.executemany(self, query, param_list) def convert_query(self, query, num_params): return query % tuple("?" * num_params) def to_sql(table): sql = ["CREATE TABLE %s (" % table.name] coldefs = [] for column in table.columns: ctype = column.type.lower() if column.auto_increment: ctype = "integer PRIMARY KEY" elif len(table.key) == 1 and column.name in table.key: ctype += " PRIMARY KEY" elif ctype == "int": ctype = "integer" coldefs.append(" %s %s" % (column.name, ctype)) if len(table.key) > 1: coldefs.append(" UNIQUE (%s)" % ','.join(table.key)) sql.append(',\n'.join(coldefs) + '\n);') yield '\n'.join(sql) for index in table.indices: unique = index.unique and "UNIQUE" or "" yield "CREATE %s INDEX %s_%s_idx ON %s (%s);" % (unique,table.name, '_'.join(index.columns), table.name, ','.join(index.columns)) custom_queries = [ # custom indexes "CREATE INDEX id_key_value_1x ON media_info(id,ikey,value COLLATE BINARY);", "CREATE INDEX id_key_value_2x ON media_info(id,ikey,value COLLATE NOCASE);", "CREATE INDEX key_value_1x ON media_info (ikey, value COLLATE BINARY);", "CREATE INDEX key_value_2x ON media_info (ikey, value COLLATE NOCASE);", # extract from ANALYZE request "ANALYZE;", "INSERT INTO sqlite_stat1 VALUES('cover', 'cover_source_idx','208 1');", "INSERT INTO sqlite_stat1 VALUES('stats',\ 'sqlite_autoindex_stats_1','7 1');", "INSERT INTO sqlite_stat1 VALUES('variables',\ 'sqlite_autoindex_variables_1','18 1');", "INSERT INTO sqlite_stat1 VALUES('media_info',\ 'key_value_2x','70538 3713 6');", "INSERT INTO sqlite_stat1 VALUES('media_info',\ 'key_value_1x','70538 3713 6');", "INSERT INTO sqlite_stat1 VALUES('media_info',\ 'id_key_value_2x','70538 16 1 1');", "INSERT INTO sqlite_stat1 VALUES('media_info',\ 'id_key_value_1x','70538 16 1 1');", "INSERT INTO sqlite_stat1 VALUES('media_info',\ 'sqlite_autoindex_media_info_1','70538 16 1');", "INSERT INTO sqlite_stat1 VALUES('media_info',\ 'sqlite_autoindex_media_info_1','70538 16 1');", "INSERT INTO sqlite_stat1 VALUES('library',\ 'library_directory_idx','4421 18');", "INSERT INTO sqlite_stat1 VALUES('library',\ 'library_name_directory_idx','4421 2 1');", "INSERT INTO sqlite_stat1 VALUES('library_dir',\ 'library_dir_name_lib_type_idx','377 2 1');", ] # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/database/__init__.py0000644000175000017500000000707711351210475016675 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from os import path from ConfigParser import NoOptionError from deejayd.ui import log from deejayd.database.queries import DatabaseQueries from deejayd.database import schema DatabaseError = None def init(config): global DatabaseError db_type = config.get("database","db_type") db_name = config.get("database","db_name") try: backend = __import__('deejayd.database.backends.%s' % db_type,\ {}, {}, ['']) except ImportError, ex: log.err(_(\ "You chose a database which is not supported. see config file. %s")\ % str(ex), fatal = True) DatabaseError = backend.DatabaseError if db_type == "sqlite": connection = backend.DatabaseWrapper(db_name) elif db_type == "mysql": db_user = config.get("database","db_user") db_password = config.get("database","db_password") try: db_host = config.get("database","db_host") except NoOptionError: db_host = "" try: db_port = config.getint("database","db_port") except (NoOptionError, ValueError): db_port = 3306 connection = backend.DatabaseWrapper(db_name, db_user, db_password,\ db_host, db_port) # verify database version cursor = connection.cursor() try: cursor.execute("SELECT value FROM variables\ WHERE name = 'database_version'") (db_version,) = cursor.fetchone() db_version = int(db_version) except DatabaseError: # initailise db for table in schema.db_schema: for stmt in backend.to_sql(table): cursor.execute(stmt) for query in backend.custom_queries: cursor.execute(query) log.info(_("Database structure successfully created.")) for query in schema.db_init_cmds: cursor.execute(query) log.info(_("Initial entries correctly inserted.")) DatabaseQueries.structure_created = True connection.commit() else: if schema.db_schema_version > db_version: log.info(_("The database structure needs to be updated...")) base = path.dirname(__file__) base_import = "deejayd.database.upgrade" i = db_version+1 while i < schema.db_schema_version+1: db_file = "db_%d" % i try: up = __import__(base_import+"."+db_file, {}, {}, base) except ImportError: err = _("Unable to upgrade database, have to quit") log.err(err, True) up.upgrade(cursor, backend, config) i += 1 connection.commit() cursor.close() return DatabaseQueries(connection) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/deejayd/database/querybuilders.py0000644000175000017500000001320311351210475020021 0ustar royroy# Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. class _DBQuery(object): def __init__(self, table_name): self.table_name = table_name def get_args(self): raise NotImplementedError def to_sql(self): return str(self) class SimpleSelect(_DBQuery): def __init__(self, table_name): super(SimpleSelect, self).__init__(table_name) self.selects = [] self.orders = [] self.wheres, self.wheres_args = [], [] def select_column(self, *__args, **__kw): for col in __args: self.selects.append("%s.%s" % (self.table_name, col)) def order_by(self, column, desc = False): self.orders.append("%s.%s" % (self.table_name, column)) def append_where(self, where_query, args): self.wheres.append(where_query) self.wheres_args.extend(args) def get_args(self): return self.wheres_args def __str__(self): return "SELECT DISTINCT %s FROM %s WHERE %s"\ % ( ', '.join(self.selects), self.table_name, ' AND '.join(self.wheres) or 1, ) class MediaSelectQuery(SimpleSelect): def __init__(self): super(MediaSelectQuery, self).__init__('library') self.joins = [] self.limit = None self.__joined_tags = [] self.id = False def select_id(self): self.id = True def select_column(self, column_name, table_name=None): if not table_name: table_name = self.table_name self.selects.append("%s.%s" % (table_name, column_name)) def select_tag(self, tagname): self.select_column('value', tagname) self.join_on_tag(tagname) def order_by_tag(self, tagname, desc = False): order = "%s.value" % tagname if desc: order = "%s DESC" % order self.orders.append(order) self.join_on_tag(tagname) def join_on_tag(self, tagname): if tagname not in self.__joined_tags: self.__joined_tags.append(tagname) j_st = "JOIN media_info %(tag)s ON %(tag)s.id = library.id\ AND %(tag)s.ikey = '%(tag)s'"\ % { 'tag' : tagname } self.joins.append(j_st) def set_limit(self, limit): self.limit = limit def __str__(self): orders, limit = None, None if len(self.orders) >= 1: orders = 'ORDER BY ' + ', '.join(self.orders) if self.limit is not None: limit = "LIMIT %s" % str(self.limit) return "SELECT DISTINCT %s %s FROM %s %s WHERE %s %s %s"\ % (self.id and 'library.id,' or '', ', '.join(self.selects), self.table_name, ' '.join(self.joins), ' AND '.join(self.wheres) or 1, orders or '', limit or '') class EditRecordQuery(_DBQuery): def __init__(self, table_name): super(EditRecordQuery, self).__init__(table_name) self.dbvalues = {} self.update_id = None def add_value(self, column_name, column_value): self.dbvalues[column_name] = column_value def set_update_id(self, key, id): self.update_key = key self.update_id = id def get_args(self): args = self.dbvalues.values() if self.update_id: args.append(self.update_id) return args def __str__(self): if self.update_id: sets_st = ["%s = %%s" % x for x in self.dbvalues.keys()] query = "UPDATE %s SET %s WHERE %s"\ % ( self.table_name, ', '.join(sets_st), "%s.%s = %%s" % (self.table_name, self.update_key), ) else: query = "INSERT INTO %s(%s) VALUES(%s)"\ % ( self.table_name, ', '.join(self.dbvalues.keys()), ', '.join(["%s" for x in self.get_args()]), ) return query def query_decorator(answer_type): def query_decorator_instance(func): def query_func(self, *__args, **__kw): cursor = self.connection.cursor() rs = func(self, cursor, *__args, **__kw) if answer_type == "lastid": rs = self.connection.get_last_insert_id(cursor) elif answer_type == "rowcount": rs = cursor.rowcount elif answer_type == "fetchall": rs = list(cursor.fetchall()) elif answer_type == "fetchone": rs = cursor.fetchone() elif answer_type == "medialist": rs = self._medialist_answer(cursor.fetchall(),__kw['infos']) cursor.close() return rs return query_func return query_decorator_instance # vim: ts=4 sw=4 expandtab deejayd-0.10.0/man/0000755000175000017500000000000011354730161012155 5ustar royroydeejayd-0.10.0/man/deejayd.conf.5.xml0000644000175000017500000003665711351210475015412 0ustar royroy %deejaydent; Alexandre"> Rossi"> June, 30th 2008"> 5"> alexandre.rossi@gmail.com"> DEEJAYD.CONF"> Debian"> GNU"> GPL"> ]>
&dhemail;
2008 &dhusername; &dhdate;
&dhucpackage; &dhsection; &dhpackage; Site-wide configuration file for deejayd, a media player daemon. DESCRIPTION The file /etc/deejayd.conf is an INI like file which configures deejayd. The format looks like this : [sectionname] variable = value othervariable = othervalue [othersection] foo = bar general section This section defines the general section configuration values for all of deejayd components. log Log level. Possible values are error to log only errors, info to be more verbose and debug to log all messages. activated_modes Modes enabled. Available modes are : playlist for audio playlists, panel for audio panel mode like rhythmbox, webradio for streamed webradios, video for video directories playback, dvd for DVD playback. As an example : activated_modes = playlist, panel, webradio, video fullscreen Fullscreen video. This is mainly a debugging configuration value and you want this to be set to yes if you use the video mode. Set this to no if you want to be able to control deejayd using some graphical client on the same display. replaygain Replaygain support, yes or no. media_backend The chosen media backend. Possible values are : auto for whichever works, trying first with Xine, xine for the Xine backend, gstreamer for the GStreamer backend. enabled_plugins a list of plugins to activate separated by ','. Available plugins are : shoutcast to navigate and play shoutcast webradio audioscrobbler to activate lastfm audioscrobbler net section This section defines the net section configuration values. This defines all that relates to the TCP XML messaging interface to deejayd. enabled Remote control using XML messages, yes or no. port The numerical TCP port number to listen on for XML messages, yes or no. bind_addresses The hostname(s), the IP address(es) of the network ipv4 interface to listen on for XML messages, or all to listen on all available interfaces. bind_addresses = localhost, 192.168.51.1, 10.2.3.4 webui section This section defines the webui section configuration values. This defines all that relates to the HTTP web user interface to deejayd. enabled Remote control using the HTTP web ui, yes or no. port The numerical TCP port number to listen on for HTTP request. yes or no. You can then access the web ui by pointing your browser to http://deejayd-host:port/. bind_addresses The hostname(s), the IP address(es) of the network ipv4 interface to listen on for HTTP requests, or all to listen on all available interfaces. bind_addresses = 192.168.51.2, 10.0.0.3 tmp_dir This is a temporary directory used by the deejayd webui for caching. It should probably be located in /tmp. The only use case which would involve changing this would be for running multiple deejayd daemons running on the same host (with both webuis enabled). refresh Automatic refreshing time for the webui (in seconds). This is the delay after which your browser will be asked to reload the web ui state (song position, current song, etc). Set to 0 to disable this automatic reloading. database section This section defines the database section configuration values. This defines all that relates to the internal media database used by deejayd. db_type Database backend used, among sqlite and mysql. db_name (SQLite only) The full path to the sqlite database file. db_user (MySQL only) The username to use to connect to the database. db_password (MySQL only) The password to use to connect to the database. db_host (MySQL only) The host to try to connect to to reach the database. db_port (MySQL only) The port to try to connect to to reach the database. Usually 3306. mediadb section This section defines the mediadb section configuration values. This defines all that relates to the relationship between you media files and deejayd and its database. Symlinked directories in both audio and video locations will be followed and watched if inotify is supported. music_directory The full path to the directory holding you music files. video_directory The full path to the directory holding you video files. filesystem_charset The character set to use to decode your filenames. This should probably be autodetected. panel section This section defines the panel section configuration values. Those options configure the behaviour of the panel mode. panel_tags Music tags that are avilable for filtering in the panel mode. Available possibilities are : genre,artist,album which is the default, artist,album, genre,various_artist,album, various_artist,album. various_artist here is the same as artist, except compilation albums are grouped into a special "Various Artists" label. As an example : panel_tags = genre,artist,album gstreamer section This section defines the gstreamer section configuration values. Those options are specific to the GStreamer backend. audio_output The output device to use. This is usually one among auto, alsa, oss, esd, etc. alsa_card Optionnaly, the alsa card to output to, e.g. hw:2. This is useful if you have more than one soundcard. xine section This section defines the xine section configuration values. Those options are specific to the Xine backend. The Xine backend may be customised even more using de Xine configuration file used by deejayd. This is usually ~/.xine/config, ~ being to home directory of the user running deejayd. This should change to be /etc. audio_output The output device to use. This is usually one among auto, alsa, oss, etc. software_mixer Set to true to use xine software mixer yes or no. video_output The video driver to use. This is usually one among auto, xv, x, etc. video_display The X display to play the video to. Usually :0.0. osd_support On-screen display enabled yes or no. osd_font_size The On-screen display font size, an integer. SEE ALSO &deejayd;. AUTHOR This manual page was written by &dhusername; &dhemail; for the &debian; system (but may be used by others). Permission is granted to copy, distribute and/or modify this document under the terms of the &gnu; General Public License, Version 2 any later version published by the Free Software Foundation. On Debian systems, the complete text of the GNU General Public License can be found in /usr/share/common-licenses/GPL.
deejayd-0.10.0/man/djc.1.xml0000644000175000017500000000752111351210475013601 0ustar royroy %deejaydent; Alexandre"> Rossi"> February, 3rd 2008"> 1"> alexandre.rossi@gmail.com"> DEEJAYD"> Debian"> GNU"> GPL"> ]>
&dhemail;
2008 &dhusername; &dhdate;
&dhucpackage; &dhsection; &dhpackage; A command line client to the the Deejayd media player daemon. &dhpackage; DESCRIPTION This manual page documents briefly the &dhpackage;. This manual page was written for the &debian; distribution because the original program does not have a manual page. OPTIONS These programs follow the usual &gnu; command line syntax, with long options starting with two dashes (`-'). A summary of options is included below. For a complete description, see the switch. Show summary of options. SEE ALSO &deejayd;. AUTHOR This manual page was written by &dhusername; &dhemail; for the &debian; system (but may be used by others). Permission is granted to copy, distribute and/or modify this document under the terms of the &gnu; General Public License, Version 2 any later version published by the Free Software Foundation. On Debian systems, the complete text of the GNU General Public License can be found in /usr/share/common-licenses/GPL.
deejayd-0.10.0/man/deejayd.1.xml0000644000175000017500000001003311351210475014436 0ustar royroy %deejaydent; Alexandre"> Rossi"> February, 3rd 2008"> 1"> alexandre.rossi@gmail.com"> DEEJAYD"> Debian"> GNU"> GPL"> ]>
&dhemail;
2008 &dhusername; &dhdate;
&dhucpackage; &dhsection; &dhpackage; A media player daemon. &dhpackage; DESCRIPTION This manual page documents briefly the &dhpackage; command. This manual page was written for the &debian; distribution because the original program does not have a manual page. OPTIONS These programs follow the usual &gnu; command line syntax, with long options starting with two dashes (`-'). A summary of options is included below. For a complete description, see the switch. Show summary of options. Show version of program. SEE ALSO &deejayd-conf;, &djc;. AUTHOR This manual page was written by &dhusername; &dhemail; for the &debian; system (but may be used by others). Permission is granted to copy, distribute and/or modify this document under the terms of the &gnu; General Public License, Version 2 any later version published by the Free Software Foundation. On Debian systems, the complete text of the GNU General Public License can be found in /usr/share/common-licenses/GPL.
deejayd-0.10.0/man/deejayd.ent0000644000175000017500000000074611351210475014277 0ustar royroy deejayd 1 " > deejayd.conf 5 " > djc 1 " > deejayd-0.10.0/MANIFEST.in0000644000175000017500000000105311351210474013134 0ustar royroyinclude MANIFEST.in include doc/* include COPYING include README include NEWS include TODO include prepare.sh include docs.py include deejayd/ui/defaults.conf recursive-include deejayd/webui/mobile/templates *thtml include gentoo/* include gentoo/files/* recursive-include data/htdocs * include po/*po include po/POTFILES.in include po/update.py include po/deejayd.pot include tests.py include scripts/testserver include scripts/deejayd_rgscan recursive-include testdeejayd * include data/logo/* recursive-include extensions/deejayd-webui * include man/* deejayd-0.10.0/NEWS0000644000175000017500000000762511354572607012124 0ustar royroy New features and significant updates in version... 0.10.0 (2010-03-31) -------------------- * Improve webradio mode * Add shoutcast webradio support (disabled by default) * Add lastfm audioscrobbler support (disabled by default) * Update firefox extension to work with 3.6 version * Several bug fixes 0.9.0 (2009-09-21) -------------------- * Replace XML protocol by a JSON-RPC protocol * Rewrite XUL/Mobile webui to use new JSON-RPC protocol * Xine : Add a new player option 'aspect_ratio" to change video aspect ratio * Improve mobile webui * Use kaa-metradata instead of hachoir-metadata/lsdvd to get video infos * Xine : handle separate volume levels for audio and video media tracks 0.8.3 (2009-07-05) -------------------- * update extension to work with firefox 3.5 * several bug fixes 0.8.2 (2009-05-24) -------------------- * fix a important bug in library 0.8.1 (2009-05-19) -------------------- * add cdnumber info for audio media in database * support multiple audio/video channels in video files * fixes in xul webui 0.8.0 (2009-04-27) -------------------- * update database schema to be more evolutive * add new mode : navigation panel (like rhythmbox panel mode) * implement mediafilters to filter audio/video library * add rating support * add new playorder mode * weigthed random * one media * set xine binding as an independant module (pytyxi) * add a html interface optimized for mobile device (http://host:port/m/) * add full support of cover album * set xul webui as an firefox extension * add intelligent playlist support * add support of pyinotify-0.8 0.7.2 (2008-05-15) -------------------- * xine: add osd support (disabled by default) * xine: add zoom option * many fix in signal infrastructure * fix in sql requests 0.7.1 (2008-04-16) -------------------- * add queue random option * use XUL notification to display messages * fix dvd playback * minor fix in client library 0.7.0 (2008-03-28) -------------------- * database : add mysql backend support * add new player options : Audio/Video offset and Subtitle offset * add flac support * add replaygain support (track profile only) * add script to process an audio library and record replaygain track gain/peak in songs * log reopen on SIGHUP * inotify watches out of root directory symlink * save full status on exit and restore it on startup 0.6.3 (2008-02-10) -------------------- * xine : disable DPMS while playing videos 0.6.2 (2008-02-03) -------------------- * fix in xine backend * correctly hide cursor * fix xine event callback * fix in mediadb (skip file with bad caracter) 0.6.1 (2008-01-28) -------------------- * fix important bug in xine backend * fix symlinks support in audio/video library 0.6.0 (2008-01-26) -------------------- * rewrite xine backend to use ctypes * xine : add gapless support * improve video mode * add signaling support * add inotify support to update audio/video library * improve webui performance * add i18n support (only french translation is available for now) * A lot of cleanups and bugfixes 0.5.0 (2007-12-26) ------------------ * xine backend : close stream when no media has been played * integrate webui in deejayd. Ajaxdj is useless now * support all commands in library client and djc * A lot of cleanups and bugfixes 0.4.1 (2007-11-11) ------------------ * Fix bugs in mediadb and video source * Fix documentation generation and update it 0.4.0 (2007-11-04) ------------------ * Add dvd support * Add a python library client * Add a command line client : djc * Improve performance and memory usage with the use of celementtree module * A lot of cleanups and bugfixes 0.2.0 (2007-06-24) ------------------ * Add a song queue * Add video support * Add xine backend * Rewrite library * A lot of cleanups and bugfixes 0.1.0 (2007-02-28) ------------------ * First release !! deejayd-0.10.0/po/0000755000175000017500000000000011354730161012020 5ustar royroydeejayd-0.10.0/po/fr.po0000644000175000017500000007552111354576217013013 0ustar royroy# French translation of deejayd. # Copyright (C) 2008-2009 Mickaël Royer # This file is distributed under the same license as the deejayd package. # # msgid "" msgstr "" "Project-Id-Version: deejayd 0.9.0\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2010-03-31 09:34+0200\n" "PO-Revision-Date: 2008-04-12 23:40+0200\n" "Last-Translator: Mickaël Royer \n" "Language-Team: French\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n>1;\n" #: ../deejayd/core.py:40 #, python-format msgid "mode %s is not activated." msgstr "Le mode %s n'est pas activé" #: ../deejayd/core.py:124 #, python-format msgid "Path %s not found in library" msgstr "Le chemin %s n'a pas été trouvé dans la librairie" #: ../deejayd/core.py:167 msgid "Only basic filters are allowed for magic playlist" msgstr "" "Seul les filtres basiques sont autorisés pour les listes de lecture " "intelligentes" #: ../deejayd/core.py:310 ../deejayd/core.py:634 msgid "Set a playlist name" msgstr "Indiquer un nom de playlist" #: ../deejayd/core.py:385 msgid "Not supported type" msgstr "type non supporté" #: ../deejayd/core.py:554 msgid "Bad value for id_type parm" msgstr "Mauvaise valeur pour le paramètre id_type" #: ../deejayd/core.py:558 msgid "Bad value for id parm" msgstr "Mauvaise valeur pour le paramètre id" #: ../deejayd/core.py:572 ../deejayd/core.py:580 #, python-format msgid "Mode %s not supported" msgstr "Mode %s non supporté" #: ../deejayd/core.py:595 msgid "Param value is not an int" msgstr "L'argument 'value' n'est pas un chiffre entier" #: ../deejayd/core.py:599 #, python-format msgid "Option %s does not exist" msgstr "L'option %s n'existe pas" #: ../deejayd/core.py:601 #, python-format msgid "Option %s is not supported for this backend" msgstr "L'option %s n'est pas supporté pour ce backend" #: ../deejayd/core.py:628 ../deejayd/core.py:730 msgid "Video mode disabled" msgstr "Mode vidéo désactivé" #. pls already exists #: ../deejayd/core.py:639 msgid "This playlist already exists" msgstr "Cette liste de lecture existe déjà" #: ../deejayd/core.py:662 #, python-format msgid "Playlist with id %s not found." msgstr "La liste de lecture avec l'identifiant %s n'a pas été trouvée" #: ../deejayd/core.py:683 msgid "Bad rating value" msgstr "Valeur érronée pour la notation" #: ../deejayd/core.py:687 ../deejayd/core.py:714 #, python-format msgid "Type %s is not supported" msgstr "Le type %s n'est pas supporté" #: ../deejayd/core.py:691 #, python-format msgid "%s library not activated" msgstr "la librairie %s n'est pas activé" #: ../deejayd/core.py:693 #, python-format msgid "File with id %s not found" msgstr "Le fichier avec l'identifiant %s n'a pas été trouvé" #: ../deejayd/core.py:700 ../deejayd/core.py:735 #, python-format msgid "Directory %s not found in database" msgstr "Le répertoire %s n'a pas été trouvé dans la base de donnée" #: ../deejayd/core.py:708 msgid "Cover not found" msgstr "Pochette d'album non trouvée" #: ../deejayd/utils.py:31 #, python-format msgid "%s string has wrong characters, skip it" msgstr "%s a des caractères invalides, il est écarté" #: ../deejayd/utils.py:47 msgid "No time information" msgstr "Pas d'information sur la durée" #: ../deejayd/utils.py:65 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "%d seconde" msgstr[1] "%d secondes" #: ../deejayd/utils.py:66 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "%d minute" msgstr[1] "%d minutes" #: ../deejayd/utils.py:67 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "%d heure" msgstr[1] "%d heures" #: ../deejayd/utils.py:68 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "%d jour" msgstr[1] "%d jours" #: ../deejayd/utils.py:69 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "%d année" msgstr[1] "%d années" #: ../deejayd/database/__init__.py:36 #, python-format msgid "You chose a database which is not supported. see config file. %s" msgstr "" "Vous avez choisi une base de donnée non supportée. Vérifier votre fichier de " "configuration. %s" #: ../deejayd/database/__init__.py:68 msgid "Database structure successfully created." msgstr "La structure de la base de donnée a été crée avec succès" #: ../deejayd/database/__init__.py:71 msgid "Initial entries correctly inserted." msgstr "Les entrées initiales ont été correctement insérées" #: ../deejayd/database/__init__.py:77 msgid "The database structure needs to be updated..." msgstr "La structure de la base de donnée doit être mise à jour..." #: ../deejayd/database/__init__.py:86 msgid "Unable to upgrade database, have to quit" msgstr "Impossible de mettre à jour la base de donnée." #: ../deejayd/database/backends/sqlite.py:30 #, python-format msgid "This program requires pysqlite version %s or later." msgstr "Ce program nécessite une version de pysqlite >= %s." #: ../deejayd/database/backends/sqlite.py:54 #, python-format msgid "Could not connect to sqlite database %s." msgstr "Impossible de se connecter à la base de donnée sqlite %s" #: ../deejayd/database/backends/mysql.py:72 #, python-format msgid "Could not connect to MySQL server %s." msgstr "Impossible de se connecter à la base de donnée mysql %s" #: ../deejayd/mediadb/__init__.py:37 #, python-format msgid "Unable to init audio library : %s" msgstr "Impossible d'initialiser la library audio : %s" #: ../deejayd/mediadb/__init__.py:44 #, python-format msgid "Unable to init video library : %s" msgstr "Impossible d'initialiser la librairie vidéo : %s" #: ../deejayd/mediadb/__init__.py:48 msgid "Inotify support disabled" msgstr "Support d'inotify désactivé" #: ../deejayd/mediadb/library.py:76 #, python-format msgid "Unable to find directory %s" msgstr "Impossible de trouver le répertoire %s" #: ../deejayd/mediadb/library.py:280 #, python-format msgid "%s library has to be updated, this can take a while." msgstr "La librairie %s doit être mise à jour, cela peut prendre du temps" #: ../deejayd/mediadb/library.py:348 #, python-format msgid "The %s library has been updated" msgstr "La librairie %s a été mise à jour" #: ../deejayd/mediadb/library.py:350 #, python-format msgid "Unable to update the %s library. See log." msgstr "Impossible de mettre à jour la library %s. Voir les logs" #: ../deejayd/mediadb/library.py:407 #, python-format msgid "File %s not supported" msgstr "Le fichier %s n'est pas supporté" #: ../deejayd/mediadb/library.py:410 #, python-format msgid "Unable to get infos from %s, see traceback" msgstr "" "Impossible d'obtenir les données du fichier %s, voir l'exception pour plus " "de détail" #: ../deejayd/mediadb/library.py:541 #, python-format msgid "cover %s not supported by kaa parser" msgstr "Pochette pour %s non supportée" #: ../deejayd/mediadb/library.py:546 #, python-format msgid "cover %s : wrong mime type" msgstr "Pochette pour %s : type mime non supporté" #: ../deejayd/mediadb/library.py:551 #, python-format msgid "Unable to open cover file %s" msgstr "Impossible d'ouvrir la pochette d'album pour %s" #: ../deejayd/mediadb/inotify.py:36 #, python-format msgid "Inotify event %s: %s" msgstr "Evennement inotify %s : %s" #: ../deejayd/mediadb/inotify.py:98 #, python-format msgid "Inotify problem for '%s', see traceback" msgstr "Problème inotify pour '%s', voir l'exception pour plus de détail" #: ../deejayd/net/protocol.py:77 msgid "error, see deejayd log" msgstr "erreur, voir les logs de deejayd" #: ../deejayd/net/protocol.py:93 msgid "Request too long, close the connection" msgstr "Requête trop longue, cloture de la connection" #: ../deejayd/net/protocol.py:114 msgid "Net Protocol activated" msgstr "Protocol réseau activé" #: ../deejayd/player/__init__.py:45 #, python-format msgid "Autodetected %s backend." msgstr "%s backend a été détecté automatiquement." #: ../deejayd/player/__init__.py:47 msgid "Could not find suitable media backend." msgstr "Impossible de trouver un backend fonctionnel" #: ../deejayd/player/__init__.py:61 msgid "Invalid media backend" msgstr "Player invalide" #: ../deejayd/player/_base.py:43 #, python-format msgid "Unable to init %s plugin: %s" msgstr "Impossible d'initialiser le plugin %s : %s" #: ../deejayd/player/_base.py:96 #, python-format msgid "Unable to get pls for webradio %s" msgstr "Impossible d'obtenir le fichier .pls pour la webradio %s" #. we don't succeed to extract uri #: ../deejayd/player/_base.py:100 msgid "Unable to extract uri from pls playlist" msgstr "Impossible d'extraire les urls de la playlist" #: ../deejayd/player/_base.py:177 msgid "Current media hasn't multiple audio channel" msgstr "Le média courant n'a pas plusieurs canaux audio" #: ../deejayd/player/_base.py:190 #, python-format msgid "Audio channel %d not found" msgstr "Canal audio %d non trouvé" #: ../deejayd/player/_base.py:196 msgid "Current media hasn't multiple sub channel" msgstr "Le média courant n'a pas plusieurs canaux de sous-titre" #: ../deejayd/player/_base.py:209 #, python-format msgid "Sub channel %d not found" msgstr "Canal des sous-titres %d non trouvé" #: ../deejayd/player/xine.py:57 ../deejayd/player/xine.py:458 msgid "Unable to init a xine instance" msgstr "Impossible d'initialiser une instance de xine" #: ../deejayd/player/xine.py:94 #, python-format msgid "Sub channel %d" msgstr "Canal des sous-titres %d" #: ../deejayd/player/xine.py:101 #, python-format msgid "Audio channel %d" msgstr "Canal audio %d" #: ../deejayd/player/xine.py:120 ../deejayd/player/gstreamer.py:109 #, python-format msgid "Unable to play file %s" msgstr "Impossible de lire le fichier %s" #: ../deejayd/player/xine.py:207 msgid "Zoom value not accepted" msgstr "valeur de zoom non accepté" #: ../deejayd/player/xine.py:211 #, python-format msgid "Zoom: %d percent" msgstr "Zoom : %d pourcent" #: ../deejayd/player/xine.py:216 #, python-format msgid "Video aspect ration %s is not known." msgstr "L'aspect vidéo %s n'est pas connu" #: ../deejayd/player/xine.py:226 #, python-format msgid "Audio/Video offset: %d ms" msgstr "Décalage Audio/Vidéo : %s ms" #: ../deejayd/player/xine.py:233 #, python-format msgid "Subtitle offset: %d ms" msgstr "Décalage des sous-titre : %d ms" #: ../deejayd/player/xine.py:330 msgid "Unable to open audio driver" msgstr "Impossible d'ouvrir le driver audio" #: ../deejayd/player/xine.py:341 msgid "Unable to open video driver" msgstr "Impossible d'ouvrir le driver vidéo" #: ../deejayd/player/xine.py:445 #, python-format msgid "Xine error %s" msgstr "Erreur Xine %s" #: ../deejayd/player/xine.py:464 msgid "Unable to identify dvd device" msgstr "Impossible d'identifier le dvd" #: ../deejayd/player/gstreamer.py:47 #, python-format msgid "No audio sink found for Gstreamer : %s" msgstr "Aucun driver audio trouvé pour gstreamer" #: ../deejayd/sources/__init__.py:56 msgid "Playlist support disabled" msgstr "Mode playlist désactivé" #: ../deejayd/sources/__init__.py:64 msgid "Panel support disabled" msgstr "Mode Navigateur à panneau désactivé" #: ../deejayd/sources/__init__.py:71 msgid "Webradio support disabled" msgstr "Mode webradio désactivé" #. Critical error, we have to quit deejayd #: ../deejayd/sources/__init__.py:78 msgid "" "Cannot initialise video support, either disable video and dvd mode or check " "your player video support." msgstr "Impossible d'initialiser le support vidéo" #. player not supported video playback, quit deejayd #: ../deejayd/sources/__init__.py:82 #, python-format msgid "" "player '%s' don't support video playback, either disable video and dvd mode " "or change your player to have video support." msgstr "Le backend '%s' ne supporte pas la lecture vidéo" #: ../deejayd/sources/__init__.py:90 msgid "Video support disabled" msgstr "Mode vidéo désactivé" #: ../deejayd/sources/__init__.py:97 #, python-format msgid "Unable to init dvd support : %s" msgstr "Impossible d'initialiser le support des DVD : %s" #: ../deejayd/sources/__init__.py:99 msgid "DVD support disabled" msgstr "Le support des DVD est désactivé" #: ../deejayd/sources/__init__.py:105 #, python-format msgid "Unable to set recorded source %s" msgstr "Impossible de choisir la source enregistrée %s" #: ../deejayd/sources/__init__.py:116 #, python-format msgid "option %s not supported for this mode" msgstr "L'option %s n'est pas supporté pour ce mode" #: ../deejayd/sources/_base.py:69 msgid "Unable to delete selected ids" msgstr "Impossible d'effacer les ids des fichiers sélectionnés" #: ../deejayd/sources/_base.py:147 #, python-format msgid "Playlist %s does not exist." msgstr "La liste de lecture %s n'existe pas." #: ../deejayd/sources/_base.py:153 #, python-format msgid "Unable to set %s order, not supported" msgstr "Impossible de choisir l'ordre de lecture %s, non supporté" #: ../deejayd/sources/_base.py:159 msgid "Option value has to be a boolean" msgstr "La valeur de l'option doit être un booléen" #: ../deejayd/sources/_base.py:201 #, python-format msgid "Tag '%s' not supported for sort" msgstr "Le tag '%s' n'est pas supporté pour le tri" #: ../deejayd/sources/_base.py:203 msgid "Bad sort direction for source" msgstr "Mauvaise direction pour le tri pour cette source" #: ../deejayd/sources/_base.py:225 #, python-format msgid "One of these ids %s not found" msgstr "Un de ces identifiants '%s' n'a pas été trouvé" #: ../deejayd/sources/_base.py:238 #, python-format msgid "%s not found" msgstr "%s non trouvé" #: ../deejayd/sources/_base.py:253 msgid "Unable to move selected medias" msgstr "Impossible de déplacer les fichiers sélectionés" #: ../deejayd/sources/panel.py:50 msgid "You choose wrong panel tags, fallback to default" msgstr "Vous avez choisi les mauvais tags, retourne aux tags par défaut" #: ../deejayd/sources/panel.py:106 #, python-format msgid "Playlist with id %s not found" msgstr "La liste de lecture avec l'identifiant %s n'a pas été trouvée" #: ../deejayd/sources/panel.py:132 ../deejayd/sources/panel.py:164 #, python-format msgid "Tag '%s' not supported" msgstr "Tag '%s' non supporté" #: ../deejayd/sources/video.py:50 #, python-format msgid "Directory %s not found" msgstr "Le répertoire %s n'a pas été trouvé" #: ../deejayd/sources/video.py:57 #, python-format msgid "type %s not supported" msgstr "Le type %s n'est pas supporté" #: ../deejayd/sources/webradio.py:57 ../deejayd/sources/webradio.py:59 #, python-format msgid "Given url %s is not supported" msgstr "L'URL %s n'est pas supportée" #: ../deejayd/sources/webradio.py:74 msgid "Categories not supported for this source" msgstr "Les catégories ne sont pas supportées pour cette source" #: ../deejayd/sources/webradio.py:116 ../deejayd/sources/webradio.py:125 #, python-format msgid "Webradio source %s not supported" msgstr "La source de radio web %s n'est pas supportée" #: ../deejayd/sources/webradio.py:127 ../deejayd/sources/webradio.py:134 #, python-format msgid "Categorie not supported for source %s" msgstr "Les catégories ne sont pas supportées pour la source %s" #: ../deejayd/sources/webradio.py:151 #, python-format msgid "Webradio with id %s not found" msgstr "La radio web avec l'identifiant %d n'a pas été trouvée" #: ../deejayd/ui/log.py:86 #, python-format msgid "ERROR - %s" msgstr "ERREUR - %s" #: ../deejayd/ui/log.py:95 #, python-format msgid "INFO - %s" msgstr "INFO - %s" #: ../deejayd/ui/log.py:100 #, python-format msgid "DEBUG - %s" msgstr "DEBUG - %s" #: ../deejayd/rpc/protocol.py:44 #, python-format msgid "Param %s is required" msgstr "L'argument %s est requis" #: ../deejayd/rpc/protocol.py:51 #, python-format msgid "Param %s is not an int" msgstr "L'argument %s n'est pas un chiffre entier" #: ../deejayd/rpc/protocol.py:56 #, python-format msgid "Param %s has wrong type" msgstr "L'argument %s n'est pas du bon type" #: ../deejayd/rpc/protocol.py:60 #, python-format msgid "Param %s is not a list" msgstr "L'argument %s n'est pas une liste" #: ../deejayd/rpc/protocol.py:64 #, python-format msgid "Param %s is not an int-list" msgstr "L'argument %s n'est pas une liste d'entier" #: ../deejayd/rpc/protocol.py:286 msgid "Wrong id parameter" msgstr "Mauvais argument id" #: ../deejayd/rpc/protocol.py:704 msgid "Param 'type' has a wrong value" msgstr "L'argument 'type' a une valeur fausse" #: ../deejayd/rpc/protocol.py:708 msgid "Selected playlist is not static." msgstr "La liste de lecture sélectionnée n'est pas de type statique" #: ../deejayd/rpc/protocol.py:713 msgid "values arg must be integer" msgstr "L'argument values doit être un entier" #: ../deejayd/rpc/protocol.py:725 ../deejayd/rpc/protocol.py:737 #: ../deejayd/rpc/protocol.py:748 ../deejayd/rpc/protocol.py:762 #: ../deejayd/rpc/protocol.py:773 msgid "Selected playlist is not magic." msgstr "La liste de lecture sélectionnée n'est pas de type intelligente." #: ../deejayd/rpc/protocol.py:882 #, python-format msgid "mode %s is not known" msgstr "Le mode %s n'est pas connu" #: ../deejayd/rpc/protocol.py:936 ../deejayd/webui/mobile.py:300 msgid "All" msgstr "Tous" #: ../deejayd/rpc/protocol.py:939 ../deejayd/webui/mobile.py:301 msgid "Various Artist" msgstr "Artiste Divers" #: ../deejayd/rpc/protocol.py:947 ../deejayd/webui/mobile.py:302 msgid "Unknown" msgstr "Inconnu" #: ../deejayd/rpc/rdfbuilder.py:57 msgid "No" msgstr "Non" #: ../deejayd/rpc/rdfbuilder.py:57 msgid "Yes" msgstr "Oui" #: ../deejayd/rpc/rdfbuilder.py:216 msgid "Root Directory" msgstr "Répertoire Racine" #: ../deejayd/rpc/rdfbuilder.py:262 #, python-format msgid "Title %s" msgstr "Titre %s" #: ../deejayd/rpc/rdfbuilder.py:272 #, python-format msgid "Chapter %s" msgstr "Chapitre %s" #: ../deejayd/rpc/rdfbuilder.py:280 #, python-format msgid "%d Song" msgid_plural "%d Songs" msgstr[0] "%d Chanson" msgstr[1] "%d Chansons" #: ../deejayd/rpc/rdfbuilder.py:281 #, python-format msgid "%d Video" msgid_plural "%d Videos" msgstr[0] "%d Vidéo" msgstr[1] "%d Vidéos" #: ../deejayd/rpc/rdfbuilder.py:282 #, python-format msgid "%d Webradio" msgid_plural "%d Webradios" msgstr[0] "%d Radio Web" msgstr[1] "%d Radios Web" #: ../deejayd/rpc/rdfbuilder.py:283 #, python-format msgid "%d Track" msgid_plural "%d Tracks" msgstr[0] "%d Titre" msgstr[1] "%d Titres" #: ../deejayd/webui/__init__.py:113 #, python-format msgid "Unable to remove tmp directory %s" msgstr "Impossible de supprimer le répertoire temporaire %s" #: ../deejayd/webui/__init__.py:117 #, python-format msgid "Unable to create tmp directory %s" msgstr "Impossible de créer le répertoire temporaire %s" #: ../deejayd/webui/__init__.py:120 #, python-format msgid "Htdocs directory %s does not exists" msgstr "Le répertoire %s n'existe pas" #: ../deejayd/webui/xul.py:79 msgid "" "You need to install a firefox extension in order to use the deejayd-webui " "XUL client. Please note that if you run a flavour of GNU/Linux, it should be " "available from your package manager." msgstr "" "Vous devez installer une extension firefox pour utiliser l'interface XUL de " "deejayd. Prière de noter que si vous utiliser une distribution GNU/Linux, il " "est possible que cette extension soit disponible par votre manageur de " "paquet." #: ../deejayd/webui/xul.py:81 msgid "You need to upgrade the firefox extension." msgstr "Il est nécessaire de mettre à jour l'extension firefox" #: ../deejayd/webui/xul.py:83 msgid "Install the deejayd-webui extension" msgstr "Installer l'extension firefox 'deejayd-webui'" #: ../deejayd/webui/xul.py:84 msgid "ERROR : Host is not allowed to use the firefox extension." msgstr "Erreur : l'hôte n'est pas autorisé à utiliser l'extension firefox" #: ../deejayd/webui/mobile.py:70 ../deejayd/webui/mobile.py:291 msgid "Playlist Mode" msgstr "Mode Playlist" #: ../deejayd/webui/mobile.py:71 ../deejayd/webui/mobile.py:294 msgid "Panel Mode" msgstr "Navigateur à Panneau" #: ../deejayd/webui/mobile.py:72 ../deejayd/webui/mobile.py:292 msgid "Video Mode" msgstr "Mode Vidéo" #: ../deejayd/webui/mobile.py:73 ../deejayd/webui/mobile.py:293 msgid "Webradio Mode" msgstr "Radio Web" #: ../deejayd/webui/mobile.py:74 ../deejayd/webui/mobile.py:295 msgid "DVD Mode" msgstr "Mode Dvd" #: ../deejayd/webui/mobile.py:270 msgid "Now Playing" msgstr "En cours" #: ../deejayd/webui/mobile.py:271 msgid "No Playing Media" msgstr "Pas de lecture en cours" #: ../deejayd/webui/mobile.py:272 msgid "Mode List" msgstr "Liste des modes" #: ../deejayd/webui/mobile.py:273 msgid "Current Mode" msgstr "Mode courant" #: ../deejayd/webui/mobile.py:275 msgid "Close" msgstr "Fermer" #: ../deejayd/webui/mobile.py:276 msgid "Refresh" msgstr "Rafraichir" #. options #: ../deejayd/webui/mobile.py:278 msgid "In Order" msgstr "Dans l'ordre" #: ../deejayd/webui/mobile.py:279 msgid "Random" msgstr "Aléatoire" #: ../deejayd/webui/mobile.py:280 msgid "Weighted Random" msgstr "Pondéré en Mélanger" #: ../deejayd/webui/mobile.py:281 msgid "One Media" msgstr "Un Média" #: ../deejayd/webui/mobile.py:282 msgid "Repeat" msgstr "Répéter" #: ../deejayd/webui/mobile.py:283 msgid "Save Options" msgstr "Sauver les options" #: ../deejayd/webui/mobile.py:284 msgid "Play Order" msgstr "Ordre de lecture" #. js localisation #: ../deejayd/webui/mobile.py:286 msgid "Loading..." msgstr "Chargement..." #: ../deejayd/webui/mobile.py:287 msgid "Load Files" msgstr "Charger les fichiers" #: ../deejayd/webui/mobile.py:288 msgid "Audio Library" msgstr "Librarie Audio" #: ../deejayd/webui/mobile.py:289 msgid "Video Library" msgstr "Librarie Vidéo" #: ../deejayd/webui/mobile.py:290 msgid "Search" msgstr "Rechercher" #: ../deejayd/webui/mobile.py:296 msgid "Webradio Name" msgstr "Nom de la Radio Web" #: ../deejayd/webui/mobile.py:297 msgid "Webradio URL" msgstr "Url de la Radio Web" #: ../deejayd/webui/mobile.py:298 msgid "Add" msgstr "Ajouter" #: ../deejayd/webui/mobile.py:299 msgid "Add a Webradio" msgstr "Ajouter une radio web" #: ../deejayd/webui/mobile.py:303 msgid "Genre" msgstr "Genre" #: ../deejayd/webui/mobile.py:304 msgid "Artist" msgstr "Artiste" #: ../deejayd/webui/mobile.py:305 msgid "Album" msgstr "Album" #. The help option must be changed to comply with i18n. #: ../scripts/deejayd:61 msgid "Show this help message and exit." msgstr "Afficher ce message d'aide et quitter." #: ../scripts/deejayd:64 msgid "The uid to run as" msgstr "L'UID avec lequel sera lancé deejayd" #: ../scripts/deejayd:66 msgid "" "The gid to run as (the first one), and the supplementary gids separated by " "commas." msgstr "" "Le GID avec lequel sera lancé deejayd (le premier), suivi par les GIDs " "supplémentaires séparés par des virgules." #: ../scripts/deejayd:68 msgid "No daemonize deejayd" msgstr "Ne pas lancer en démon" #: ../scripts/deejayd:70 ../scripts/deejayd:74 msgid "Specify the log file" msgstr "Spécifier le fichier de log" #: ../scripts/deejayd:72 msgid "Specify the log file for the webui" msgstr "Spécifier le fichier de log pour l'interface web" #: ../scripts/deejayd:76 msgid "Specify a custom conf file" msgstr "Spécifier un fichier de configuration à prendre en compte" #: ../scripts/deejayd:78 msgid "Kill the actual deejayd process" msgstr "Tuer le processus actuel de deejayd" #: ../scripts/deejayd:81 msgid "Log more debug informations" msgstr "Enregistre plus d'information pour le debuggage" #: ../scripts/deejayd:91 msgid "The config file does not exist." msgstr "Le fichier de configuration n'existe pas" #: ../scripts/deejayd:121 #, python-format msgid "Pidfile %s contains non-numeric value" msgstr "Le fichier pour le PID %s contient une valeur non numérique" #: ../scripts/deejayd:127 #, python-format msgid "Unable to stop deejayd : %s, are you sure it running ?" msgstr "Impossible d'arrêter deejayd : %s, Etes vous sûr qu'il est lancé ?" #: ../scripts/deejayd:130 msgid "no PidFile found, are you sure deejayd running ?" msgstr "" "Aucun fichier pour le PID trouvé, Etes vous sûr que deejayd est lancé ?" #: ../scripts/deejayd:137 #, python-format msgid "Unable to remove pid file : %s" msgstr "Impossible de supprimer leier pour le PID : %s" #: ../scripts/deejayd:162 msgid "Unable to change gid of the process" msgstr "Impossible de modifier le GID du process" #: ../scripts/deejayd:170 msgid "Unable to change uid of the process" msgstr "Impossible de modifier l'UID du process" #: ../scripts/deejayd:225 msgid "Unable to launch deejayd core, see traceback for more details" msgstr "Impossible de lancer deejayd, voir les logs pour plus de détails" #: ../scripts/deejayd:249 msgid "Webui does not seem to be installed, disabling." msgstr "L'interface web ne semble pas installé, désactivé." #: ../scripts/deejayd:280 msgid "No service has been activated" msgstr "Aucun service n'a été activé" #~ msgid "Webradio info could not be retrieved" #~ msgstr "Les infos sur la radio web n'ont pas pu être récupérées" #~ msgid "lsdvd not found, can't extract dvd info" #~ msgstr "lsdvd non trouvé, impossible d'extraire les informations du dvd" #~ msgid "error in lsdvd command" #~ msgstr "Erreur dans la commande lsdvd" #~ msgid "Unknown command : %s" #~ msgstr "Commande inconnue : %s" #~ msgid "Arg %s (%s) is not a string" #~ msgstr "L'argument %s (%s) n'est pas une chaîne de caractère" #~ msgid "Arg %s (%s) is not in the possible list" #~ msgstr "L'argument %s (%s) n'est pas dans la liste des possibilités" #~ msgid "Arg %s (%s) not match to the regular exp (%s)" #~ msgstr "L'argument %s (%s) ne correspond pas à l'expression régulière %s" #~ msgid "Arg %s is mising" #~ msgstr "L'argument %s est manquant" #~ msgid "Set value arg to choose a playlist" #~ msgstr "Indiquer l'argument 'value' pour choisir une liste de lecture" #~ msgid "You have to enter an action." #~ msgstr "Vous devez entrer une action" #~ msgid "Command %s not found" #~ msgstr "La commande %s n'a pas été trouvée" #~ msgid "Command send with invalid method" #~ msgstr "La commande a été envoyée avec une méthode invalide" #~ msgid "Bad argument : %s" #~ msgstr "Argument %s erroné" #~ msgid "Mobile Web UI disabled because genshi seems absent." #~ msgstr "" #~ "L'interface web pour les mobiles a été désactivée car genshi semble absent" #~ msgid "bad 'magic_pls_infos' arg" #~ msgstr "Mauvais argument 'magic_pls_infos'" #~ msgid "basic filter not found" #~ msgstr "Le filtre basique n'a pas été trouvé" #~ msgid "infos argument needed for magic playlist" #~ msgstr "" #~ "L'argument 'infos' est nécessaire pour les listes de lecture intelligentes" #~ msgid "Not a magic playlist" #~ msgstr "Non une playlist intelligente" #~ msgid "The audio library has been updated" #~ msgstr "La librairie audio a été mise à jour" #~ msgid "The video library has been updated" #~ msgstr "La librairie vidéo a été mise à jour" #~ msgid "The magic playlist has been updated" #~ msgstr "La liste de lecture intelligente a été mise à jour" #~ msgid "Current playlist has been saved" #~ msgstr "La playlist a été sauvegardée" #~ msgid "Unable to get key %s value for current" #~ msgstr "" #~ "Impossible d'obtenir la valeur de la clé %s pour le média en lecture" #~ msgid "DVD Title : %s" #~ msgstr "Titre du DVD : %s" #~ msgid "Longest Track : %s" #~ msgstr "Piste la plus longue : %s" #~ msgid "Files has been loaded to the playlist" #~ msgstr "Les fichiers ont été chargés dans la liste de lecture" #~ msgid "Video" #~ msgstr "Vidéo" #~ msgid "Navigation Panel" #~ msgstr "Navigateur à panneaux" #~ msgid "Webradio" #~ msgstr "Radio Web" #~ msgid "Dvd Playback" #~ msgstr "Lecture Dvd" #~ msgid "library: emit %s change for file %d" #~ msgstr "librairie : emission du signal %s pour le fichier %d" #~ msgid "No video sink found for Gstreamer" #~ msgstr "Aucun driver vidéo trouvé pour gstreamer" #~ msgid "Are you sure ?" #~ msgstr "Etes vous sûr ?" #~ msgid "It misses a parameter !" #~ msgstr "Il manque un paramètre" #~ msgid "Do you want to replace this playlist ?" #~ msgstr "Voulez vous replacer cette liste de lecture ?" #~ msgid "Song with id %d not found" #~ msgstr "La chanson avec l'identifiant %d n'a pas été trouvée" #~ msgid "Unable to load pls in a saved pls." #~ msgstr "Impossible de charger une playlist dans une playlist sauvegardée." #~ msgid "Playlist %s does not have a song of id %d" #~ msgstr "La liste de lecture %s n'a pas de chanson avec l'identifiant %d" #~ msgid "The database structure has been updated" #~ msgstr "La structure de la base de donnée a été mise à jour" #~ msgid "Unable to execute database request '%s': %s" #~ msgstr "Impossible d'éxécuter la requête '%s': %s" #~ msgid "Try Mysql reconnection" #~ msgstr "Tentative de reconnection à la base mysql" #~ msgid "Remove" #~ msgstr "Supprimer" #~ msgid "Play" #~ msgstr "Jouer" #~ msgid "Playlist" #~ msgstr "Liste de lecture" #~ msgid "Title" #~ msgstr "Titre" #~ msgid "Time" #~ msgstr "Temps" #~ msgid "Bitrate" #~ msgstr "Bitrate" #~ msgid "Ok" #~ msgstr "Ok" #~ msgid "Cancel" #~ msgstr "Annuler" #~ msgid "Go to current song" #~ msgstr "Aller à la chanson courante" #~ msgid "Dvd" #~ msgstr "Dvd" #~ msgid "Show debug zone" #~ msgstr "Voir la zone de débuggage" #~ msgid "Advanced Option" #~ msgstr "Options avancées" #~ msgid "Audio/Video Offset:" #~ msgstr "Décalage Audio/Vidéo :" #~ msgid "Subtitle Offset:" #~ msgstr "Décalage des sous-titre :" #~ msgid "Load" #~ msgstr "Charger" #~ msgid "Load in the queue" #~ msgstr "Charger dans la file des chansons" #~ msgid "Add to playlist" #~ msgstr "Ajouter à la liste de lecture" #~ msgid "Directory" #~ msgstr "Répertoire" #~ msgid "Save" #~ msgstr "Sauver" #~ msgid "Shuffle" #~ msgstr "Mélanger" #~ msgid "URL (.pls and .m3u are supported)" #~ msgstr "URL (.pls et .m3u sont supportées)" #~ msgid "Name" #~ msgstr "Nom" #~ msgid "URL" #~ msgstr "URL" #~ msgid "Song Queue" #~ msgstr "File des chansons" #~ msgid "Reload" #~ msgstr "Recharger" #~ msgid "Video Informations" #~ msgstr "Informations sur la vidéo" #~ msgid "Length" #~ msgstr "Durée" #~ msgid "Width" #~ msgstr "Largeur" #~ msgid "Height" #~ msgstr "Hauteur" #~ msgid "Subtitle" #~ msgstr "Sous-titre" #~ msgid "Webradio support not available." #~ msgstr "Le support des radios web n'est pas disponible." #~ msgid "update" #~ msgstr "Mettre à jour" #~ msgid "Unable to change subtitle channel" #~ msgstr "Impossible de modifier le canal des sous-titres" #~ msgid "Dvd mode disabled" #~ msgstr "Mode DVD désactivé" #~ msgid "Database structure not found" #~ msgstr "La structure de la base de donnée n'a pas été trouvée" #~ msgid "You have to choose a music directory" #~ msgstr "Vous devez choisir un répertoire contenant votre musique" #~ msgid "Supplied video directory not found. Video support disabled." #~ msgstr "" #~ "Le répertoire vidéo proposé n'existe pas. Le support de la vidéo est " #~ "désactivé" #~ msgid "Webradio support disabled for the choosen backend" #~ msgstr "Le support des radios web est désactivé" #~ msgid "You do not choose a database. Verify your config file." #~ msgstr "" #~ "Vous n'avez pas choisi de base de donnée. Vérifier votre fichier de " #~ "configuration" #~ msgid "Unable to play this file : %s" #~ msgstr "Impossible de lire le fichier %s" #~ msgid "Xine initialisation failed" #~ msgstr "L'initialisation de xine a échoué" deejayd-0.10.0/po/POTFILES.in0000644000175000017500000000151311351210475013573 0ustar royroydeejayd/interfaces.py deejayd/core.py deejayd/utils.py deejayd/database/__init__.py deejayd/database/backends/_base.py deejayd/database/backends/sqlite.py deejayd/database/backends/mysql.py deejayd/mediadb/__init__.py deejayd/mediadb/library.py deejayd/mediadb/inotify.py deejayd/net/protocol.py deejayd/player/__init__.py deejayd/player/_base.py deejayd/player/xine.py deejayd/player/gstreamer.py deejayd/sources/__init__.py deejayd/sources/_base.py deejayd/sources/_medialist.py deejayd/sources/_playorder.py deejayd/sources/dvd.py deejayd/sources/panel.py deejayd/sources/playlist.py deejayd/sources/queue.py deejayd/sources/video.py deejayd/sources/webradio.py deejayd/ui/log.py deejayd/rpc/protocol.py deejayd/rpc/jsonrpc.py deejayd/rpc/rdfbuilder.py deejayd/webui/__init__.py deejayd/webui/xul.py deejayd/webui/mobile.py scripts/deejayd deejayd-0.10.0/po/update.py0000755000175000017500000000447211351210475013664 0ustar royroy#!/usr/bin/env python # Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import glob,os from distutils.dep_util import newer from distutils.spawn import spawn def update_translation(po_dir, po_package, po): # Update runtime translations os.chdir(po_dir) spawn(["intltool-update", "--dist", "--gettext-package", po_package, os.path.basename(po[:-3])]) def update_template(po_dir, po_package): os.chdir(po_dir) # We force here the python language for gettext strings extraction because # xgettext (which is called by intltool-update) reverts to C for # templates, as they do not have a .py extension, and this does not work # and translatable strings are not extracted. os.environ['XGETTEXT_ARGS'] = "-L Python" spawn(["intltool-update", "--pot", "--gettext-package", po_package]) def update_po(): po_package = "deejayd" po_dir = os.path.abspath("po") pot_file = os.path.join(po_dir, po_package + ".pot") po_files = glob.glob(os.path.join(po_dir, "*.po")) infilename = os.path.join(po_dir, "POTFILES.in") infiles = file(infilename).read().splitlines() oldpath = os.getcwd() need_tpl_update = False for filename in infiles: if newer(filename, pot_file): need_tpl_update = True if need_tpl_update: update_template(po_dir, po_package) for po in po_files: update_translation(po_dir, po_package, po) os.chdir(oldpath) if __name__ == "__main__": update_po() # vim: ts=4 sw=4 expandtab deejayd-0.10.0/po/deejayd.pot0000644000175000017500000004344211354575423014170 0ustar royroy# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2010-03-31 09:34+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" #: ../deejayd/core.py:40 #, python-format msgid "mode %s is not activated." msgstr "" #: ../deejayd/core.py:124 #, python-format msgid "Path %s not found in library" msgstr "" #: ../deejayd/core.py:167 msgid "Only basic filters are allowed for magic playlist" msgstr "" #: ../deejayd/core.py:310 ../deejayd/core.py:634 msgid "Set a playlist name" msgstr "" #: ../deejayd/core.py:385 msgid "Not supported type" msgstr "" #: ../deejayd/core.py:554 msgid "Bad value for id_type parm" msgstr "" #: ../deejayd/core.py:558 msgid "Bad value for id parm" msgstr "" #: ../deejayd/core.py:572 ../deejayd/core.py:580 #, python-format msgid "Mode %s not supported" msgstr "" #: ../deejayd/core.py:595 msgid "Param value is not an int" msgstr "" #: ../deejayd/core.py:599 #, python-format msgid "Option %s does not exist" msgstr "" #: ../deejayd/core.py:601 #, python-format msgid "Option %s is not supported for this backend" msgstr "" #: ../deejayd/core.py:628 ../deejayd/core.py:730 msgid "Video mode disabled" msgstr "" #. pls already exists #: ../deejayd/core.py:639 msgid "This playlist already exists" msgstr "" #: ../deejayd/core.py:662 #, python-format msgid "Playlist with id %s not found." msgstr "" #: ../deejayd/core.py:683 msgid "Bad rating value" msgstr "" #: ../deejayd/core.py:687 ../deejayd/core.py:714 #, python-format msgid "Type %s is not supported" msgstr "" #: ../deejayd/core.py:691 #, python-format msgid "%s library not activated" msgstr "" #: ../deejayd/core.py:693 #, python-format msgid "File with id %s not found" msgstr "" #: ../deejayd/core.py:700 ../deejayd/core.py:735 #, python-format msgid "Directory %s not found in database" msgstr "" #: ../deejayd/core.py:708 msgid "Cover not found" msgstr "" #: ../deejayd/utils.py:31 #, python-format msgid "%s string has wrong characters, skip it" msgstr "" #: ../deejayd/utils.py:47 msgid "No time information" msgstr "" #: ../deejayd/utils.py:65 #, python-format msgid "%d second" msgid_plural "%d seconds" msgstr[0] "" msgstr[1] "" #: ../deejayd/utils.py:66 #, python-format msgid "%d minute" msgid_plural "%d minutes" msgstr[0] "" msgstr[1] "" #: ../deejayd/utils.py:67 #, python-format msgid "%d hour" msgid_plural "%d hours" msgstr[0] "" msgstr[1] "" #: ../deejayd/utils.py:68 #, python-format msgid "%d day" msgid_plural "%d days" msgstr[0] "" msgstr[1] "" #: ../deejayd/utils.py:69 #, python-format msgid "%d year" msgid_plural "%d years" msgstr[0] "" msgstr[1] "" #: ../deejayd/database/__init__.py:36 #, python-format msgid "You chose a database which is not supported. see config file. %s" msgstr "" #: ../deejayd/database/__init__.py:68 msgid "Database structure successfully created." msgstr "" #: ../deejayd/database/__init__.py:71 msgid "Initial entries correctly inserted." msgstr "" #: ../deejayd/database/__init__.py:77 msgid "The database structure needs to be updated..." msgstr "" #: ../deejayd/database/__init__.py:86 msgid "Unable to upgrade database, have to quit" msgstr "" #: ../deejayd/database/backends/sqlite.py:30 #, python-format msgid "This program requires pysqlite version %s or later." msgstr "" #: ../deejayd/database/backends/sqlite.py:54 #, python-format msgid "Could not connect to sqlite database %s." msgstr "" #: ../deejayd/database/backends/mysql.py:72 #, python-format msgid "Could not connect to MySQL server %s." msgstr "" #: ../deejayd/mediadb/__init__.py:37 #, python-format msgid "Unable to init audio library : %s" msgstr "" #: ../deejayd/mediadb/__init__.py:44 #, python-format msgid "Unable to init video library : %s" msgstr "" #: ../deejayd/mediadb/__init__.py:48 msgid "Inotify support disabled" msgstr "" #: ../deejayd/mediadb/library.py:76 #, python-format msgid "Unable to find directory %s" msgstr "" #: ../deejayd/mediadb/library.py:280 #, python-format msgid "%s library has to be updated, this can take a while." msgstr "" #: ../deejayd/mediadb/library.py:348 #, python-format msgid "The %s library has been updated" msgstr "" #: ../deejayd/mediadb/library.py:350 #, python-format msgid "Unable to update the %s library. See log." msgstr "" #: ../deejayd/mediadb/library.py:407 #, python-format msgid "File %s not supported" msgstr "" #: ../deejayd/mediadb/library.py:410 #, python-format msgid "Unable to get infos from %s, see traceback" msgstr "" #: ../deejayd/mediadb/library.py:541 #, python-format msgid "cover %s not supported by kaa parser" msgstr "" #: ../deejayd/mediadb/library.py:546 #, python-format msgid "cover %s : wrong mime type" msgstr "" #: ../deejayd/mediadb/library.py:551 #, python-format msgid "Unable to open cover file %s" msgstr "" #: ../deejayd/mediadb/inotify.py:36 #, python-format msgid "Inotify event %s: %s" msgstr "" #: ../deejayd/mediadb/inotify.py:98 #, python-format msgid "Inotify problem for '%s', see traceback" msgstr "" #: ../deejayd/net/protocol.py:77 msgid "error, see deejayd log" msgstr "" #: ../deejayd/net/protocol.py:93 msgid "Request too long, close the connection" msgstr "" #: ../deejayd/net/protocol.py:114 msgid "Net Protocol activated" msgstr "" #: ../deejayd/player/__init__.py:45 #, python-format msgid "Autodetected %s backend." msgstr "" #: ../deejayd/player/__init__.py:47 msgid "Could not find suitable media backend." msgstr "" #: ../deejayd/player/__init__.py:61 msgid "Invalid media backend" msgstr "" #: ../deejayd/player/_base.py:43 #, python-format msgid "Unable to init %s plugin: %s" msgstr "" #: ../deejayd/player/_base.py:96 #, python-format msgid "Unable to get pls for webradio %s" msgstr "" #. we don't succeed to extract uri #: ../deejayd/player/_base.py:100 msgid "Unable to extract uri from pls playlist" msgstr "" #: ../deejayd/player/_base.py:177 msgid "Current media hasn't multiple audio channel" msgstr "" #: ../deejayd/player/_base.py:190 #, python-format msgid "Audio channel %d not found" msgstr "" #: ../deejayd/player/_base.py:196 msgid "Current media hasn't multiple sub channel" msgstr "" #: ../deejayd/player/_base.py:209 #, python-format msgid "Sub channel %d not found" msgstr "" #: ../deejayd/player/xine.py:57 ../deejayd/player/xine.py:458 msgid "Unable to init a xine instance" msgstr "" #: ../deejayd/player/xine.py:94 #, python-format msgid "Sub channel %d" msgstr "" #: ../deejayd/player/xine.py:101 #, python-format msgid "Audio channel %d" msgstr "" #: ../deejayd/player/xine.py:120 ../deejayd/player/gstreamer.py:109 #, python-format msgid "Unable to play file %s" msgstr "" #: ../deejayd/player/xine.py:207 msgid "Zoom value not accepted" msgstr "" #: ../deejayd/player/xine.py:211 #, python-format msgid "Zoom: %d percent" msgstr "" #: ../deejayd/player/xine.py:216 #, python-format msgid "Video aspect ration %s is not known." msgstr "" #: ../deejayd/player/xine.py:226 #, python-format msgid "Audio/Video offset: %d ms" msgstr "" #: ../deejayd/player/xine.py:233 #, python-format msgid "Subtitle offset: %d ms" msgstr "" #: ../deejayd/player/xine.py:330 msgid "Unable to open audio driver" msgstr "" #: ../deejayd/player/xine.py:341 msgid "Unable to open video driver" msgstr "" #: ../deejayd/player/xine.py:445 #, python-format msgid "Xine error %s" msgstr "" #: ../deejayd/player/xine.py:464 msgid "Unable to identify dvd device" msgstr "" #: ../deejayd/player/gstreamer.py:47 #, python-format msgid "No audio sink found for Gstreamer : %s" msgstr "" #: ../deejayd/sources/__init__.py:56 msgid "Playlist support disabled" msgstr "" #: ../deejayd/sources/__init__.py:64 msgid "Panel support disabled" msgstr "" #: ../deejayd/sources/__init__.py:71 msgid "Webradio support disabled" msgstr "" #. Critical error, we have to quit deejayd #: ../deejayd/sources/__init__.py:78 msgid "" "Cannot initialise video support, either disable video and dvd mode or check " "your player video support." msgstr "" #. player not supported video playback, quit deejayd #: ../deejayd/sources/__init__.py:82 #, python-format msgid "" "player '%s' don't support video playback, either disable video and dvd mode " "or change your player to have video support." msgstr "" #: ../deejayd/sources/__init__.py:90 msgid "Video support disabled" msgstr "" #: ../deejayd/sources/__init__.py:97 #, python-format msgid "Unable to init dvd support : %s" msgstr "" #: ../deejayd/sources/__init__.py:99 msgid "DVD support disabled" msgstr "" #: ../deejayd/sources/__init__.py:105 #, python-format msgid "Unable to set recorded source %s" msgstr "" #: ../deejayd/sources/__init__.py:116 #, python-format msgid "option %s not supported for this mode" msgstr "" #: ../deejayd/sources/_base.py:69 msgid "Unable to delete selected ids" msgstr "" #: ../deejayd/sources/_base.py:147 #, python-format msgid "Playlist %s does not exist." msgstr "" #: ../deejayd/sources/_base.py:153 #, python-format msgid "Unable to set %s order, not supported" msgstr "" #: ../deejayd/sources/_base.py:159 msgid "Option value has to be a boolean" msgstr "" #: ../deejayd/sources/_base.py:201 #, python-format msgid "Tag '%s' not supported for sort" msgstr "" #: ../deejayd/sources/_base.py:203 msgid "Bad sort direction for source" msgstr "" #: ../deejayd/sources/_base.py:225 #, python-format msgid "One of these ids %s not found" msgstr "" #: ../deejayd/sources/_base.py:238 #, python-format msgid "%s not found" msgstr "" #: ../deejayd/sources/_base.py:253 msgid "Unable to move selected medias" msgstr "" #: ../deejayd/sources/panel.py:50 msgid "You choose wrong panel tags, fallback to default" msgstr "" #: ../deejayd/sources/panel.py:106 #, python-format msgid "Playlist with id %s not found" msgstr "" #: ../deejayd/sources/panel.py:132 ../deejayd/sources/panel.py:164 #, python-format msgid "Tag '%s' not supported" msgstr "" #: ../deejayd/sources/video.py:50 #, python-format msgid "Directory %s not found" msgstr "" #: ../deejayd/sources/video.py:57 #, python-format msgid "type %s not supported" msgstr "" #: ../deejayd/sources/webradio.py:57 ../deejayd/sources/webradio.py:59 #, python-format msgid "Given url %s is not supported" msgstr "" #: ../deejayd/sources/webradio.py:74 msgid "Categories not supported for this source" msgstr "" #: ../deejayd/sources/webradio.py:116 ../deejayd/sources/webradio.py:125 #, python-format msgid "Webradio source %s not supported" msgstr "" #: ../deejayd/sources/webradio.py:127 ../deejayd/sources/webradio.py:134 #, python-format msgid "Categorie not supported for source %s" msgstr "" #: ../deejayd/sources/webradio.py:151 #, python-format msgid "Webradio with id %s not found" msgstr "" #: ../deejayd/ui/log.py:86 #, python-format msgid "ERROR - %s" msgstr "" #: ../deejayd/ui/log.py:95 #, python-format msgid "INFO - %s" msgstr "" #: ../deejayd/ui/log.py:100 #, python-format msgid "DEBUG - %s" msgstr "" #: ../deejayd/rpc/protocol.py:44 #, python-format msgid "Param %s is required" msgstr "" #: ../deejayd/rpc/protocol.py:51 #, python-format msgid "Param %s is not an int" msgstr "" #: ../deejayd/rpc/protocol.py:56 #, python-format msgid "Param %s has wrong type" msgstr "" #: ../deejayd/rpc/protocol.py:60 #, python-format msgid "Param %s is not a list" msgstr "" #: ../deejayd/rpc/protocol.py:64 #, python-format msgid "Param %s is not an int-list" msgstr "" #: ../deejayd/rpc/protocol.py:286 msgid "Wrong id parameter" msgstr "" #: ../deejayd/rpc/protocol.py:704 msgid "Param 'type' has a wrong value" msgstr "" #: ../deejayd/rpc/protocol.py:708 msgid "Selected playlist is not static." msgstr "" #: ../deejayd/rpc/protocol.py:713 msgid "values arg must be integer" msgstr "" #: ../deejayd/rpc/protocol.py:725 ../deejayd/rpc/protocol.py:737 #: ../deejayd/rpc/protocol.py:748 ../deejayd/rpc/protocol.py:762 #: ../deejayd/rpc/protocol.py:773 msgid "Selected playlist is not magic." msgstr "" #: ../deejayd/rpc/protocol.py:882 #, python-format msgid "mode %s is not known" msgstr "" #: ../deejayd/rpc/protocol.py:936 ../deejayd/webui/mobile.py:300 msgid "All" msgstr "" #: ../deejayd/rpc/protocol.py:939 ../deejayd/webui/mobile.py:301 msgid "Various Artist" msgstr "" #: ../deejayd/rpc/protocol.py:947 ../deejayd/webui/mobile.py:302 msgid "Unknown" msgstr "" #: ../deejayd/rpc/rdfbuilder.py:57 msgid "No" msgstr "" #: ../deejayd/rpc/rdfbuilder.py:57 msgid "Yes" msgstr "" #: ../deejayd/rpc/rdfbuilder.py:216 msgid "Root Directory" msgstr "" #: ../deejayd/rpc/rdfbuilder.py:262 #, python-format msgid "Title %s" msgstr "" #: ../deejayd/rpc/rdfbuilder.py:272 #, python-format msgid "Chapter %s" msgstr "" #: ../deejayd/rpc/rdfbuilder.py:280 #, python-format msgid "%d Song" msgid_plural "%d Songs" msgstr[0] "" msgstr[1] "" #: ../deejayd/rpc/rdfbuilder.py:281 #, python-format msgid "%d Video" msgid_plural "%d Videos" msgstr[0] "" msgstr[1] "" #: ../deejayd/rpc/rdfbuilder.py:282 #, python-format msgid "%d Webradio" msgid_plural "%d Webradios" msgstr[0] "" msgstr[1] "" #: ../deejayd/rpc/rdfbuilder.py:283 #, python-format msgid "%d Track" msgid_plural "%d Tracks" msgstr[0] "" msgstr[1] "" #: ../deejayd/webui/__init__.py:113 #, python-format msgid "Unable to remove tmp directory %s" msgstr "" #: ../deejayd/webui/__init__.py:117 #, python-format msgid "Unable to create tmp directory %s" msgstr "" #: ../deejayd/webui/__init__.py:120 #, python-format msgid "Htdocs directory %s does not exists" msgstr "" #: ../deejayd/webui/xul.py:79 msgid "" "You need to install a firefox extension in order to use the deejayd-webui " "XUL client. Please note that if you run a flavour of GNU/Linux, it should be " "available from your package manager." msgstr "" #: ../deejayd/webui/xul.py:81 msgid "You need to upgrade the firefox extension." msgstr "" #: ../deejayd/webui/xul.py:83 msgid "Install the deejayd-webui extension" msgstr "" #: ../deejayd/webui/xul.py:84 msgid "ERROR : Host is not allowed to use the firefox extension." msgstr "" #: ../deejayd/webui/mobile.py:70 ../deejayd/webui/mobile.py:291 msgid "Playlist Mode" msgstr "" #: ../deejayd/webui/mobile.py:71 ../deejayd/webui/mobile.py:294 msgid "Panel Mode" msgstr "" #: ../deejayd/webui/mobile.py:72 ../deejayd/webui/mobile.py:292 msgid "Video Mode" msgstr "" #: ../deejayd/webui/mobile.py:73 ../deejayd/webui/mobile.py:293 msgid "Webradio Mode" msgstr "" #: ../deejayd/webui/mobile.py:74 ../deejayd/webui/mobile.py:295 msgid "DVD Mode" msgstr "" #: ../deejayd/webui/mobile.py:270 msgid "Now Playing" msgstr "" #: ../deejayd/webui/mobile.py:271 msgid "No Playing Media" msgstr "" #: ../deejayd/webui/mobile.py:272 msgid "Mode List" msgstr "" #: ../deejayd/webui/mobile.py:273 msgid "Current Mode" msgstr "" #: ../deejayd/webui/mobile.py:275 msgid "Close" msgstr "" #: ../deejayd/webui/mobile.py:276 msgid "Refresh" msgstr "" #. options #: ../deejayd/webui/mobile.py:278 msgid "In Order" msgstr "" #: ../deejayd/webui/mobile.py:279 msgid "Random" msgstr "" #: ../deejayd/webui/mobile.py:280 msgid "Weighted Random" msgstr "" #: ../deejayd/webui/mobile.py:281 msgid "One Media" msgstr "" #: ../deejayd/webui/mobile.py:282 msgid "Repeat" msgstr "" #: ../deejayd/webui/mobile.py:283 msgid "Save Options" msgstr "" #: ../deejayd/webui/mobile.py:284 msgid "Play Order" msgstr "" #. js localisation #: ../deejayd/webui/mobile.py:286 msgid "Loading..." msgstr "" #: ../deejayd/webui/mobile.py:287 msgid "Load Files" msgstr "" #: ../deejayd/webui/mobile.py:288 msgid "Audio Library" msgstr "" #: ../deejayd/webui/mobile.py:289 msgid "Video Library" msgstr "" #: ../deejayd/webui/mobile.py:290 msgid "Search" msgstr "" #: ../deejayd/webui/mobile.py:296 msgid "Webradio Name" msgstr "" #: ../deejayd/webui/mobile.py:297 msgid "Webradio URL" msgstr "" #: ../deejayd/webui/mobile.py:298 msgid "Add" msgstr "" #: ../deejayd/webui/mobile.py:299 msgid "Add a Webradio" msgstr "" #: ../deejayd/webui/mobile.py:303 msgid "Genre" msgstr "" #: ../deejayd/webui/mobile.py:304 msgid "Artist" msgstr "" #: ../deejayd/webui/mobile.py:305 msgid "Album" msgstr "" #. The help option must be changed to comply with i18n. #: ../scripts/deejayd:61 msgid "Show this help message and exit." msgstr "" #: ../scripts/deejayd:64 msgid "The uid to run as" msgstr "" #: ../scripts/deejayd:66 msgid "" "The gid to run as (the first one), and the supplementary gids separated by " "commas." msgstr "" #: ../scripts/deejayd:68 msgid "No daemonize deejayd" msgstr "" #: ../scripts/deejayd:70 ../scripts/deejayd:74 msgid "Specify the log file" msgstr "" #: ../scripts/deejayd:72 msgid "Specify the log file for the webui" msgstr "" #: ../scripts/deejayd:76 msgid "Specify a custom conf file" msgstr "" #: ../scripts/deejayd:78 msgid "Kill the actual deejayd process" msgstr "" #: ../scripts/deejayd:81 msgid "Log more debug informations" msgstr "" #: ../scripts/deejayd:91 msgid "The config file does not exist." msgstr "" #: ../scripts/deejayd:121 #, python-format msgid "Pidfile %s contains non-numeric value" msgstr "" #: ../scripts/deejayd:127 #, python-format msgid "Unable to stop deejayd : %s, are you sure it running ?" msgstr "" #: ../scripts/deejayd:130 msgid "no PidFile found, are you sure deejayd running ?" msgstr "" #: ../scripts/deejayd:137 #, python-format msgid "Unable to remove pid file : %s" msgstr "" #: ../scripts/deejayd:162 msgid "Unable to change gid of the process" msgstr "" #: ../scripts/deejayd:170 msgid "Unable to change uid of the process" msgstr "" #: ../scripts/deejayd:225 msgid "Unable to launch deejayd core, see traceback for more details" msgstr "" #: ../scripts/deejayd:249 msgid "Webui does not seem to be installed, disabling." msgstr "" #: ../scripts/deejayd:280 msgid "No service has been activated" msgstr "" deejayd-0.10.0/scripts/0000755000175000017500000000000011354730161013071 5ustar royroydeejayd-0.10.0/scripts/deejayd0000755000175000017500000002263211351210475014427 0ustar royroy#!/usr/bin/env python # Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """ This is the script used to launch deejayd """ import errno, random, sys, os, pwd, grp, traceback from optparse import OptionParser from deejayd.ui.config import DeejaydConfig INSTALL_BIN_DIR = os.path.dirname(__file__) binsuffix = os.path.basename(INSTALL_BIN_DIR) INSTALL_MODE = 'source' if binsuffix == 'bin': # Deejayd is installed, and not in source tree INSTALL_MODE = 'installed' INSTALL_PREFIX = INSTALL_BIN_DIR[:-len(binsuffix)] # init translation ###################################################### import gettext from deejayd.ui.i18n import DeejaydTranslations if INSTALL_MODE == 'source': LOCALES_PATH = os.path.join(INSTALL_BIN_DIR, '..', 'build', 'mo') else: LOCALES_PATH = os.path.join(INSTALL_PREFIX, 'share', 'locale') try: t = gettext.translation("deejayd", LOCALES_PATH, class_=DeejaydTranslations) except IOError: t = DeejaydTranslations() t.install() ############ Parse Option ############################ ###################################################### usage = "usage: %prog [options]" parser = OptionParser(usage=usage) # List options parser.set_defaults(pidfile="deejayd.pid",kill = False,daemon = True) # The help option must be changed to comply with i18n. parser.get_option('-h').help = _("Show this help message and exit.") parser.add_option("-u","--uid",dest="uid",type="string",\ help=_("The uid to run as")) parser.add_option("-g","--gid",dest="gid",type="string",\ help=_("The gid to run as (the first one), and the supplementary gids separated by commas.")) parser.add_option("-n","--nodaemon",action="store_false",dest="daemon",\ help=_("No daemonize deejayd")) parser.add_option("-l","--log-file",type="string",dest="logfile",\ metavar="FILE", help=_("Specify the log file")) parser.add_option("-w","--webui-log",type="string",dest="webui_logfile",\ metavar="FILE", help=_("Specify the log file for the webui")) parser.add_option("-p","--pid-file",type="string",dest="pidfile",\ metavar="FILE", help=_("Specify the log file")) parser.add_option("-c","--conf-file",type="string",dest="conffile",\ metavar="FILE", help=_("Specify a custom conf file")) parser.add_option("-k","--kill",action="store_true",dest="kill",\ help=_("Kill the actual deejayd process")) parser.add_option("-d", "--debug", action="store_true", dest="debug", help=_("Log more debug informations")) (options, args) = parser.parse_args() ###################################################### # add custom config parms if options.conffile: if os.path.isfile(options.conffile): DeejaydConfig.custom_conf = options.conffile else: sys.exit(_("The config file does not exist.")) if options.debug: DeejaydConfig().set('general', 'log', 'debug') ###################################################### def daemonize(): # See http://www.erlenstar.demon.co.uk/unix/faq_toc.html#TOC16 if os.fork(): # launch child and... os._exit(0) # kill off parent os.setsid() if os.fork(): # launch child and... os._exit(0) # kill off parent again. os.umask(077) null=os.open('/dev/null', os.O_RDWR) for i in range(3): try: os.dup2(null, i) except OSError, e: if e.errno != errno.EBADF: raise os.close(null) def killDeejayd(pidfile): if os.path.exists(pidfile): try: pid = int(open(pidfile).read()) except ValueError: sys.exit(_('Pidfile %s contains non-numeric value') % pidfile) try: os.kill(pid, 15) removePID(pidfile) except OSError, err: sys.exit(\ _('Unable to stop deejayd : %s, are you sure it running ?')\ % (err,)) else: sys.exit(_('no PidFile found, are you sure deejayd running ?')) def removePID(pidfile): try: os.unlink(pidfile) except OSError, e: if e.errno == errno.EACCES or e.errno == errno.EPERM: sys.exit(_("Unable to remove pid file : %s") % (e,)) def setEnv(options): # Init random generator random.seed() if options.daemon: daemonize() # Store the pid removePID(options.pidfile) open(options.pidfile,'wb').write(str(os.getpid())) if options.gid: gids = options.gid.split(',') ngids = [] for gid in gids: try: ngid = int(gid) except ValueError: ngid = grp.getgrnam(gid)[2] ngids.append(ngid) try: os.setgroups(ngids) os.setgid(ngids[0]) except OSError: sys.exit(_("Unable to change gid of the process")) if options.uid: try: uid = int(options.uid) except ValueError: uid = pwd.getpwnam(options.uid)[2] try: os.setuid(uid) except OSError: sys.exit(_("Unable to change uid of the process")) def startLog(options): from twisted.python import log import deejayd.ui.log log_file_name = None if options.logfile: log_file_name = options.logfile elif options.daemon: log_file_name = 'deejayd.log' if log_file_name: flo = deejayd.ui.log.SignaledFileLogObserver(log_file_name) log.startLoggingWithObserver(flo.emit) else: log.startLogging(sys.stdout) # Start if __name__ == "__main__": if options.kill: killDeejayd(options.pidfile) sys.exit() ################### # install reactor ################## config = DeejaydConfig() media_backend = config.get("general", "media_backend") if media_backend == "gstreamer": # Install glib2 reactor from twisted.internet import glib2reactor glib2reactor.install() from twisted.internet import reactor # use twisted loop for kaa import kaa kaa.main.select_notifier('twisted') ############################################################### setEnv(options) startLog(options) from twisted.internet.error import CannotListenError from twisted.python import log # start core try: from deejayd.core import DeejayDaemonCore deejayd_core = DeejayDaemonCore(config) except Exception, ex: from deejayd.utils import str_encode log.err(\ _("Unable to launch deejayd core, see traceback for more details")) log.msg(str_encode(traceback.format_exc())) sys.exit(1) service = False # net service if config.getboolean("net","enabled"): service = True from deejayd.net.protocol import DeejaydFactory factory = DeejaydFactory(deejayd_core) port = config.getint("net", "port") for bind_address in config.get_bind_addresses('net'): try: reactor.listenTCP(port, factory, interface=bind_address) except CannotListenError, err: deejayd_core.close() log.err(str(err)) sys.exit(1) # webui service if config.getboolean("webui","enabled"): try: from deejayd import webui except ImportError: log.err(_("Webui does not seem to be installed, disabling.")) config.set("webui", "enabled", False) else: service = True htdocs_dir = None if INSTALL_MODE == 'source': htdocs_dir = os.path.abspath(os.path.join(INSTALL_BIN_DIR, '..', 'data', 'htdocs')) elif INSTALL_MODE == 'installed': htdocs_dir = os.path.abspath(os.path.join(INSTALL_PREFIX, 'share', 'deejayd', 'htdocs')) try: site = webui.init(deejayd_core, config, options.webui_logfile, htdocs_dir) except webui.DeejaydWebError, err: deejayd_core.close() log.err(err) sys.exit(1) port = config.getint("webui","port") for bind_address in config.get_bind_addresses('webui'): try: reactor.listenTCP(port, site, interface=bind_address) except CannotListenError, err: deejayd_core.close() log.err(str(err)) sys.exit(1) # launch reactor if not service: deejayd_core.close() log.err(_("No service has been activated")) sys.exit(1) reactor.addSystemEventTrigger('after','shutdown',deejayd_core.close) reactor.run() # vim: ts=4 sw=4 expandtab deejayd-0.10.0/scripts/deejayd_rgscan0000755000175000017500000002612211351210475015762 0ustar royroy#!/usr/bin/env python # Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """ This is the script used to scan audio file and process replaygain. It depends on python-mutagen >= 1.9 (for new module mutagen.mp4). It also depends on the following executables : - aacgain for both mp4/m4a and mp3 support - mp3gain for mp3 support - vorbisgain for ogg support - metaflac for flac support. """ import os,subprocess,time import logging as log from mutagen import id3, oggvorbis, mp4, flac ############ Parse Option ############################ ###################################################### from optparse import OptionParser usage = "usage: %prog [options] directory" parser = OptionParser(usage=usage) # List options parser.set_defaults(\ verbose=False,\ timestamp_file=None,\ log_file=None,\ force=False) parser.add_option("-t","--timestamp-file",type="string",dest="timestamp_file",\ metavar="FILE", help="Specify the timestamp file") parser.add_option("-l","--log-file",type="string",dest="log_file",\ metavar="FILE", help="Specify the log file") parser.add_option("-f","--force",action="store_true",dest="force",\ help="force to scan all files") parser.add_option("-v","--verbose",action="store_true",dest="verbose",\ help="no output") (options, args) = parser.parse_args() ###################################################### # init log log_level = options.verbose and log.DEBUG or log.WARNING log_format = '%(levelname)s - %(message)s' if options.log_file: log.basicConfig(level=log_level,format=log_format,filename=options.log_file) else: log.basicConfig(level=log_level,format=log_format) class CmdError(Exception): pass class _SongProcess: commands = None command = None options = "\"%s\"" def __init__(self, filename = None): self._filename = filename self.command = self.get_best_command() def cmd_exists(self, command=None): if not command: if self.command: command = self.command else: return False path = os.getenv('PATH') if not path: return False for p in path.split(':'): if os.path.isfile(os.path.join(p, command)): return True return False def get_best_command(self): if not self.commands: return self.command else: for command in self.commands: if self.cmd_exists(command): return command return None def has_rg_tag(self): raise NotImplementedError def process(self): log.debug("Process file : %s" % os.path.basename(self._filename)) cmd = " ".join([self.command, self.options % self._filename]) null = open("/dev/null") process = subprocess.Popen(cmd, shell=True,\ stdin=None, stdout=subprocess.PIPE, stderr=null) process.wait() return process.stdout.read() class MP3Process(_SongProcess): extensions = [".mp3"] commands = ("aacgain", "mp3gain") options = "-o -s s \"%s\"" def has_rg_tag(self): try: tag = id3.ID3(self._filename) except id3.error: return False for frame in tag.values(): if frame.FrameID == "RVA2" and frame.desc == "track": return True return False def process(self): rs = _SongProcess.process(self) try: infos = rs.split("\n")[1] infos = infos.split("\t") # File MP3gain dBgain MaxAmplitude Maxglobal_gain Minglobal_gain t_gain = float(infos[2]) # peak = MaxAmplitud / 32768.0 t_peak = float(infos[3]) / 32768.0 except (ValueError, TypeError, IndexError): log.error("Unable to extract information from %s" % self._filename) log.error("Infos for this file are : %s" % str(infos)) return # record infos in the song try: tag = id3.ID3(self._filename) except id3.error: log.info("File %s has no id3 header. Creating one."\ % os.path.basename(self._filename)) tag = id3.ID3() else: # erase replaygain tag for k in ["normalize", "track"]: try: del(tag["RVA2:"+k]) except KeyError: pass #for k in ["track_peak", "track_gain", "album_peak", "album_gain"]: # Delete Foobar droppings. #try: del(tag["TXXX:replaygain_" + k]) #except KeyError: pass try: rg_tag = id3.RVA2(desc="track", channel=1, gain=t_gain, peak=t_peak) tag.add(rg_tag) tag.save(self._filename) except: log.error("Unable to record id3 tag for file %s" % self._filename) class MP4Process(_SongProcess): extensions = [".mp4", ".m4a"] command = "aacgain" options = "-s r \"%s\"" def has_rg_tag(self): try: info = mp4.MP4(self._filename) except mp4.error: return False return "----:com.apple.iTunes:replaygain_track_gain" in info.keys() and\ "----:com.apple.iTunes:replaygain_track_peak" in info.keys() class OggProcess(_SongProcess): extensions = [".ogg"] command = "vorbisgain" def has_rg_tag(self): try: info = oggvorbis.OggVorbis(self._filename) except oggvorbis.error: return False return "replaygain_track_gain" in info.keys() and\ "replaygain_track_peak" in info.keys() class FlacProcess(_SongProcess): extensions = [".flac"] command = "metaflac" options = "--add-replay-gain \"%s\"" def has_rg_tag(self): try: info = flac.FLAC(self._filename) except flac.error: return False return "replaygain_track_gain" in info.keys() and\ "replaygain_track_peak" in info.keys() class TimeStamp: __fn = None def __init__(self, filename): if filename: self.__fn = os.path.abspath(filename) def read(self): ts = 0 if self.__fn and os.path.isfile(self.__fn): try: ts = int(open(self.__fn).read()) except ValueError: log.warning('ts file %s contains non-numeric value' % self.__fn) except OSError: log.warning("Unable to read ts file %s" % self.__fn) return ts def write(self): if self.__fn is None: return if os.path.isfile(self.__fn): try: os.unlink(self.__fn) except OSError, e: if e.errno == errno.EACCES or e.errno == errno.EPERM: log.warning("Unable to remove ts file : %s" % (e,)) return try: open(self.__fn,'wb').write(str(int(time.time()+1))) except OSError, e: if e.errno == errno.EACCES or e.errno == errno.EPERM: log.warning("Unable to write ts file : %s" % (e,)) class Library: extensions = {} def __init__(self, directory, options): self.__root = directory self.__options = options self.__ts = TimeStamp(options.timestamp_file) for cls in (MP3Process, MP4Process, OggProcess, FlacProcess): if cls().cmd_exists(): for ext in cls.extensions: self.extensions[ext] = cls else: if cls.commands: apps = " or ".join(cls.commands) else: apps = cls.command log.info("%s support disabled, missing apps %s" %\ (",".join(cls.extensions), apps)) def is_in_root(self, path, root=None): """Checks if a directory is physically in the supplied root (the library root by default).""" if not root: root = self.__root real_root = os.path.realpath(root) real_path = os.path.realpath(path) head = real_path old_head = None while head != old_head: if head == real_root: return True old_head = head head, tail = os.path.split(head) return False def is_in_a_root(self, path, roots): """Checks if a directory is physically in one of the supplied roots.""" for root in roots: if self.is_in_root(path, root): return True return False def scan(self): self.walk_directory(self.__root, self.__ts.read()) self.__ts.write() def walk_directory(self, walk_root, ts, forbidden_roots=None): if not forbidden_roots: forbidden_roots = [self.__root] for (root, dirs, files) in os.walk(walk_root): for dir in dirs: dir_path = os.path.join(root, dir) if os.path.islink(dir_path): if not self.is_in_a_root(dir_path, forbidden_roots): forbidden_roots.append(dir_path) self.walk_directory(dir_path, ts, forbidden_roots) for file in files: file_path = os.path.join(root,file) (dummy, ext) = os.path.splitext(file) ext = ext.lower() try: file_object = self.extensions[ext](file_path) except KeyError: # format not supported continue if not self.__options.force and \ os.stat(file_path).st_mtime >= ts: if file_object.has_rg_tag(): log.info("file %s has already rg tag. skipped" % file) continue try: file_object.process() except CmdError, e: log.error("Unable to analyse file %s : %s"\ % (file_path, e)) elif self.__options.force: try: file_object.process() except CmdError, e: log.error("Unable to analyse file %s : %s"\ % (file_path, e)) # Start if __name__ == "__main__": try: directory = args[0] except IndexError: sys.exit("You have to enter a directory name") directory = os.path.abspath(directory) if not os.path.isdir(directory): sys.exit("Directory %s not found" % directory) library = Library(directory, options) library.scan() # vim: ts=4 sw=4 expandtab deejayd-0.10.0/scripts/djc0000755000175000017500000005600211351210475013560 0ustar royroy#!/usr/bin/env python # Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import sys from optparse import OptionParser import socket from deejayd.net.client import DeejayDaemonSync,DeejaydError,\ DeejaydStaticPlaylist,\ DeejaydWebradioList,DeejaydPlaylistMode,\ DeejaydQueue,ConnectError class DjcError(Exception): pass class AvailableCommands: def __init__(self, server): self.server = server def ping(self, args): """Ping server using the protocol""" self.server.ping().get_contents() def playtoggle(self, args): """Toggle play/pause""" self.server.play_toggle().get_contents() def stop(self, args): """Stop player""" self.server.stop().get_contents() def previous(self, args): """Previous element in player""" self.server.previous().get_contents() def next(self, args): """Next element in player""" self.server.next().get_contents() def seek(self, args): """Set the position in the stream to argument value""" try: pos = int(args[0]) except (ValueError, IndexError): raise DjcError('Bad command line') self.server.seek(pos).get_contents() def current(self, args): """Return the current playing media (if it exists)""" cur = self.server.get_current().get_medias() if len(cur) == 0: print "No current media" else: media = cur[0] for (k, v) in media.items(): print "%s: %s" % (k, v) VOLUME_STEP = 10 def volume(self, args): """Set volume to argument value. If preceeded with 'up'|'down', (in|dea)crease volume with value. 'up'|'down' only also works.""" try: if args[0] in ['up', 'down']: volume = self.server.get_status()['volume'] try: possible_delta = int(args[1]) except IndexError: possible_delta = AvailableCommands.VOLUME_STEP if args[0] == 'up': volume += possible_delta elif args[0] == 'down': volume -= possible_delta else: volume = int(args[0]) self.server.set_volume(volume).get_contents() except (ValueError, IndexError): raise DjcError('Bad command line') def set_option(self, args): """Set option to argument value for a given source. ex: djc set_option playlist playorder random ex: djc set_option playlist repeat 0 (or 1)""" try: source = args[0] option_name = args[1] option_value = args[2] except (ValueError, IndexError): raise DjcError('Bad command line') if option_name == 'repeat': try: option_value = bool(int(option_value)) except ValueError: raise DjcError('for repeat option, value has to be 0 or 1') self.server.set_option(source,option_name,option_value).get_contents() def set_mode(self, args): """Set active mode to argument value. ex: djc set_mode webradio""" try: mode = args[0] except IndexError: raise DjcError('You need to choose a mode') else: self.server.set_mode(mode).get_contents() def player_option(self, args): """set player option ex: djc player_option option_name option_value option_name can be : sub_offset, av_offset, audio_lang, sub_lang""" try: opt_name = args[0] opt_value = args[1] except IndexError: raise DjcError('Usage: djc player_option option_name option_value') else: self.server.set_player_option(opt_name, opt_value).get_contents() def status(self, args): """Get full deejayd status""" for (key, value) in self.server.get_status().items(): print key, ':', value def stats(self, args): """Get audio/video library stats""" for (key, value) in self.server.get_stats().items(): print key, ':', value def audio_update(self, args): """Update audio library""" force = False try: force_value = args[0] if force_value.lower() == "force": force = True except IndexError: pass for (key, value) in self.server.update_audio_library(force).items(): print key, ':', value def get_audio_dir(self, args): """Get the content of an directory in audio library. ex: djc get_audio_dir "dirname" if no dirname has been entered, return root directory content""" try: dirname = args[0] except IndexError: dirname = "" rsp = self.server.get_audio_dir(dirname) for dir in rsp.get_directories(): print "directory:", dir for file in rsp.get_files(): print "file:", file["filename"] def audio_search(self, args): """Search in audio library ex: djc audio_search type search_txt type has to be in ('all','artist','album','genre','title')""" try: type = args[0] except IndexError: raise DjcError('You need to enter a search type') try: search_txt = args[1] except IndexError: raise DjcError('You need to enter a search text') rsp = self.server.audio_search(search_txt, type) for m in rsp.get_medias(): print m["media_id"], "|" ,m["filename"] # Video commands def video_update(self, args): """Update video library""" force = False try: force_value = args[0] if force_value.lower() == "force": force = True except IndexError: pass for (key, value) in self.server.update_video_library(force).items(): print key, ':', value def get_video_dir(self, args): """Get the content of an directory in video library. ex: djc get_video_dir "dirname" if no dirname has been entered, return root directory content""" try: dirname = args[0] except IndexError: dirname = "" rsp = self.server.get_video_dir(dirname) for dir in rsp.get_directories(): print "directory:", dir for file in rsp.get_files(): print "file:", file["filename"] def video_info(self, args): """ Get video list's content """ contents = self.server.get_video().get() if contents.get_sort() is not None: print "############ Sort ############" sorts = contents.get_sort() for s in sorts: print " * %s - %s" % s print "############ Video List ############" for media in contents.get_medias(): print media["pos"], '|', media["title"], '|',\ media["videowidth"], '|',\ media["videoheight"], '|', media["id"] def set_video(self, args): """Update the video list ex: djc set_video "type" "value" type must be : * directory to set content of dir "value" as video list * search to set result of search "value" as video list""" try: type = args[0] except IndexError: raise DjcError('You have to choose a type') try: value = args[1] except IndexError: raise DjcError('You have to enter a value') self.server.get_video().set(value, type).get_contents() # Queue commands def queue_info(self, args): """ Get queue's content """ contents = DeejaydQueue(self.server).get() for media in contents.get_medias(): print media["pos"], '|', media["title"], '|',\ media["artist"], '|',\ media["album"], '|', media["id"] def queue_clear(self, args): """ clear queue """ DeejaydQueue(self.server).clear().get_contents() def queue_add(self, args): """ add audio dirs/files to queue ex: djc queue_add path1,path2 """ try: item = args[0] except IndexError: raise DjcError('You have to enter dirs/files') else: items = item.split(",") DeejaydQueue(self.server).add_paths(items).get_contents() def queue_loadpls(self, args): """ load playlist in the queue ex: djc queue_loadpls playlist_id1,playlist_id2""" try: playlist = args[0] except IndexError: raise DjcError('You have to enter playlist names') pl_ids = playlist.split(",") queue_obj = DeejaydQueue(self.server) queue_obj.load_playlists(pl_ids).get_contents() def queue_remove(self, args): """ remove songs from queue ex: djc queue_remove song_id1,song_id2 """ try: item = args[0] except IndexError: raise DjcError('Enter song_id please') else: ids = item.split(",") try: ids = [int(id) for id in ids] except ValueError: raise DjcError("song_id has to be an integer") DeejaydQueue(self.server).del_songs(ids).get_contents() # Recorded Playlist commands def recorded_playlist_info(self, args): """ get content of a recorded playlist usage : djc recorded_playlist_info pl_id""" try: pl_id = int(args[0]) except (IndexError, TypeError): raise DjcError('You have to enter a playlist id') pls_list = self.server.get_playlist_list() pls_infos = None for pls in pls_list.get_medias(): if int(pls["id"]) == pl_id: pls_infos = pls break if pls_infos is None: raise DjcError('Playlist with id %s not found' % str(pl_id)) pls = self.server.get_recorded_playlist(pls_infos["id"],\ pls_infos["name"], pls_infos["type"]) ans = pls.get() if pls.type == 'magic': print "############ Properties ############" props = pls.get_properties().get_contents() for k, v in props.items(): print "%s: %s" % (k, v) if ans.get_filter() is not None: print "############ Filters ############" for ft in ans.get_filter(): print str(ft) print "############ Songs ############" for media in pls.get().get_medias(): print media["title"], '|', media["artist"], '|', media["album"] def playlist_erase(self, args): """ Erase a recorded playlist usage : djc playlist_erase pl_id""" try: pl_id = args[0] except IndexError: raise DjcError('You have to enter a playlist name') self.server.erase_playlist([pl_id]).get_contents() def playlist_list(self, args): """ Return list of recorded playlist """ contents = self.server.get_playlist_list() for media in contents.get_medias(): print "%d : %s (%s)" % (int(media["id"]) ,media["name"],\ media["type"]) # Playlist Mode commands def playlist_save(self, args): """ Save current playlist usage : djc playlist_save pl_name""" try: pl_name = args[0] except IndexError: raise DjcError('You have to enter a playlist name') ans = DeejaydPlaylistMode(self.server).save(pl_name) print "The current playlist has been saved with id %s" %\ ans["playlist_id"] def playlist_info(self, args): """ Get current playlist's content ex: djc playlist_info""" for media in DeejaydPlaylistMode(self.server).get().get_medias(): print media["pos"], '|', media["title"], '|', media["artist"], '|',\ media["album"], '|', media["id"] def playlist_clear(self, args): """ clear current playlist ex: djc playlist_clear if no name has been entered, clear the current pl""" DeejaydPlaylistMode(self.server).clear().get_contents() def playlist_shuffle(self, args): """ shuffle current playlist ex: djc playlist_shuffle""" DeejaydPlaylistMode(self.server).shuffle().get_contents() def playlist_add(self, args): """ add dirs/files to current playlist ex: djc playlist_add path1,path2""" try: item = args[0] except IndexError: raise DjcError('You have to enter dirs/files') items = item.split(",") DeejaydPlaylistMode(self.server).add_paths(items).get_contents() def playlist_load(self, args): """ load playlist in the current playlist ex: djc playlist_load playlist_id1,playlist_id2""" try: playlist = args[0] except IndexError: raise DjcError('You have to enter playlist names') pl_ids = playlist.split(",") DeejaydPlaylistMode(self.server).loads(pl_ids).get_contents() def playlist_remove(self, args): """ remove songs from current playlist ex: djc playlist_remove song_id1,song_id2""" try: item = args[0] except IndexError: raise DjcError('Enter song_id please') ids = item.split(",") try: ids = [int(id) for id in ids] except ValueError: raise DjcError("song_id has to be an integer") DeejaydPlaylistMode(self.server).del_songs(ids).get_contents() # Panel commands def panel_info(self, args): """ Get current panel's content ex: djc panel_info""" panel = self.server.get_panel() ans = panel.get() if ans.get_filter() is not None: print "############ Filter ############" search, panels = None, [] for ft in ans.get_filter(): if ft.type == "basic" and ft.get_name() == "contains": search = "%s contains '%s'" % (ft.tag, ft.pattern) elif ft.type == "complex" and ft.get_name() == "or": search = "%s equals to '%s'" % ("all", ft[0].pattern) elif ft.type == "complex" and ft.get_name() == "and": for panel_ft in ft: tag = panel_ft[0].tag values = [value_ft.pattern for value_ft in panel_ft] panels.append({"tag":tag, "values": ",".join(values)}) if search is not None: print " * search filter : %s" % search for pn in panels: print " * panel filter : %s in (%s)"%(pn["tag"], pn["values"]) if ans.get_sort() is not None: print "############ Sort ############" sorts = ans.get_sort() for s in sorts: print " * %s - %s" % s print "############ Song List ############" for media in ans.get_medias(): print media["pos"], '|', media["title"], '|', media["artist"], '|',\ media["album"], '|', media["id"] def panel_tags(self, args): """ List tags used in panel mode ex: djc panel_tags""" panel = self.server.get_panel() tag_list = panel.get_panel_tags().get_contents() print "############ Tag List ############" print " | ".join(tag_list) def panel_get_active(self, args): """ Return panel active list ex: djc panel_get_active""" panel = self.server.get_panel() active = panel.get_active_list().get_contents() value = "panel" if active["type"] == "playlist": value = "playlist '%s'" % active["value"] print "Active panel list : %s" % value def panel_set_active(self, args): """ Set panel active list ex: djc panel_set_active panel ex: djc panel_set_active playlist pls_name""" panel = self.server.get_panel() try: type = args[0] if type not in ('playlist', 'panel'): raise DjcError('Bad command line') except IndexError: raise DjcError('Bad command line') value = "0" if type == 'playlist': try: value = int(args[1]) except (IndexError, ValueError, TypeError): raise DjcError('Bad command line') panel.set_active_list(type, value).get_contents() def panel_set_filter(self, args): """ Set a panel filter ex: djc panel_set_filter genre rock ex: djc panel_set_filter artist artist1,artist2""" panel = self.server.get_panel() try: tag = args[0] values = args[1].split(",") except IndexError: raise DjcError('Bad command line') panel.set_panel_filters(tag, values).get_contents() def panel_remove_filter(self, args): """ Remove a panel filter ex: djc panel_remove_filter artist""" panel = self.server.get_panel() try: tag = args[0] except IndexError: raise DjcError('Bad command line') panel.remove_panel_filters(tag).get_contents() def panel_clear_filter(self, args): """ Clear panel filters ex: djc panel_clear_filter""" panel = self.server.get_panel() panel.clear_panel_filters().get_contents() def panel_set_search(self, args): """ Set search filter ex: djc panel_set_search genre rock ex: djc panel_set_search artist ben""" panel = self.server.get_panel() try: tag = args[0] value = args[1] except IndexError: raise DjcError('Bad command line') panel.set_search_filter(tag, value).get_contents() def panel_clear_search(self, args): """ Clear panel search filter ex: djc panel_clear_search""" panel = self.server.get_panel() panel.clear_search_filter().get_contents() def panel_sort(self, args): """ Sort Panel Medialist ex: djc panel_sort artist descending ex: djc panel_sort title ascending""" panel = self.server.get_panel() try: tag = args[0] direction = args[1] except IndexError: raise DjcError('Bad command line') panel.set_sorts([(tag, direction)]).get_contents() # Webradio commands def webradio_list(self, args): """ List webradios """ wrs = DeejaydWebradioList(self.server).get() for wr in wrs.get_medias(): if wr["url-type"] == "urls": url = " | ".join(wr["urls"]) else: url = wr["url"] print wr["title"],"|",url,"|",wr["id"] def webradio_setSource(self, args): """ Set the current source for webradio mode """ try: source = args[0] except IndexError: raise DjcError('Bad command line') wr = DeejaydWebradioList(self.server) wr.set_source(source).get_contents() def webradio_localAdd(self, args): """Add a webradio in the local source.ex: djc add_webradio wr_name urls. Url has been separated by ",".""" try: wr_name = args[0] wr_urls = args[1] except IndexError: raise DjcError('Bad command line') wr = DeejaydWebradioList(self.server) wr.add_webradio(wr_name,wr_url.split(",")).get_contents() def webradio_localClear(self, args): """Erase all webradios from the local source""" DeejaydWebradioList(self.server).clear().get_contents() def webradio_localDelete(self, args): """Erase webradio(s) from the local source ex: djc webradio_erase wr_id1,wr_id2""" try: item = args[0] except IndexError: raise DjcError('Enter webradio_id please') else: ids = item.split(",") try: ids = [int(id) for id in ids] except ValueError: raise DjcError("webradio_id has to be an integer") wr = DeejaydWebradioList(self.server) wr.delete_webradios(ids).get_contents() # dvd commands def dvd_reload(self, args): """Reload the dvd""" ans = self.server.dvd_reload() ans.get_contents() def dvd_get_content(self, args): """Get contents of the current dvd""" ans = self.server.get_dvd_content() dvd_content = ans.get_dvd_contents() print """ DVD content title : %s longest_track : %s """ % (dvd_content["title"],dvd_content["longest_track"]) for t in dvd_content["track"]: print """ track : %s (%s) """ % (t['ix'], t['length']) def get_cmds(): import types cmds = [] for cmd_name in dir(AvailableCommands): if cmd_name[0] != '_': cmd = getattr(AvailableCommands, cmd_name) if isinstance(cmd, types.UnboundMethodType): cmds.append(' : '.join([cmd_name, cmd.__doc__])) return cmds cmd_sep = "\n * " cmds = cmd_sep.join(get_cmds()) usage = """usage: %prog [options] COMMAND [COMMAND_OPTIONS] where COMMAND may be :""" + cmd_sep + cmds parser = OptionParser(usage=usage) parser.add_option("", "--host", action="store", type="string", dest="host", default="localhost", help="Hostname or ip address on which deejayd listens. Default is localhost.") parser.add_option("", "--port", action="store", type="int", dest="port", default=6800, help="Port on which deejayd listens. Default is 6800.") (options, args) = parser.parse_args() def fail_cmdline(): parser.print_help() sys.exit("Bad command line.") if __name__ == '__main__': deejayd = DeejayDaemonSync() if len(args) < 1: fail_cmdline() command = args[0] cmds = AvailableCommands(deejayd) if command in dir(cmds): try: deejayd.connect(options.host, options.port) except ConnectError, msg: print msg else: try: getattr(AvailableCommands, command)(cmds, args[1:]) except DeejaydError, msg: print "Deejayd Error:", msg except DjcError, msg: print "Djc Error:", msg except socket.error: print "Error: the server closes the socket" try: deejayd.disconnect() except socket.error: pass else: fail_cmdline() # vim: ts=4 sw=4 expandtab deejayd-0.10.0/scripts/testserver0000755000175000017500000000743111351210475015230 0ustar royroy#!/usr/bin/env python # Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. """ This is a deejayd test server executable """ import sys, os, traceback from twisted.python import log import twisted.internet.error # argv[1] should be configuration files if len(sys.argv) != 2: sys.exit('Incorrect invocation') log.startLogging(open('/tmp/testdeejayd.log', 'a')) from deejayd.ui.config import DeejaydConfig conf_file = sys.argv[1] if os.path.isfile(conf_file): DeejaydConfig.custom_conf = conf_file else: os.write(2, 'stopped\n') sys.exit() config = DeejaydConfig() # init translation from deejayd.ui.i18n import DeejaydTranslations t = DeejaydTranslations() t.install() # instanciate reactor media_backend = config.get("general", "media_backend") if media_backend == "gstreamer": # Install glib2 reactor from twisted.internet import glib2reactor glib2reactor.install() from twisted.internet import reactor # use twisted loop for kaa import kaa kaa.main.select_notifier('twisted') # start core from deejayd.core import DeejayDaemonCore try: deejayd_core = DeejayDaemonCore(config) deejayd_core.update_audio_library(sync = True, objanswer = False) activated_sources = config.getlist('general', "activated_modes") if "video" in activated_sources: # video library activated deejayd_core.update_video_library(sync = True, objanswer = False) except Exception, ex: try: deejayd_core.close() except: pass from deejayd.utils import str_encode err = "Unable to launch deejayd core, see traceback for more details" log.msg(err) log.msg(str_encode(traceback.format_exc())) os.write(2, 'stopped\n') sys.exit() # net protocol if config.getboolean("net","enabled"): from deejayd.net.protocol import DeejaydFactory factory = DeejaydFactory(deejayd_core) try: reactor.listenTCP(config.getint("net","port"), factory) except twisted.internet.error.CannotListenError: # This is to avoid locking the test suite when the server used in the # previous test has not stopped listenning. deejayd_core.close() os.write(2, 'stopped\n') sys.exit() # http protocol if config.getboolean("webui","enabled"): from deejayd import webui htdocs_dir = os.path.abspath(os.path.join(os.path.dirname(__file__),\ '..', 'data', 'htdocs')) site = webui.init(deejayd_core, config, '/tmp/testdeejayd-webui.log', htdocs_dir) try: reactor.listenTCP(config.getint("webui","port"), site) except twisted.internet.error.CannotListenError: # This is to avoid locking the test suite when the server used in the # previous test has not stopped listenning. deejayd_core.close() os.write(2, 'stopped\n') sys.exit() def printReady(): os.write(2, 'ready\n') reactor.addSystemEventTrigger('after', 'startup', printReady) reactor.addSystemEventTrigger('after', 'shutdown', deejayd_core.close) reactor.run() # vim: ts=4 sw=4 expandtab deejayd-0.10.0/PKG-INFO0000644000175000017500000000044711354730161012504 0ustar royroyMetadata-Version: 1.0 Name: deejayd Version: 0.10.0 Summary: Network controllable media player daemon Home-page: http://mroy31.dyndns.org/~roy/projects/deejayd Author: Mikael Royer, Alexandre Rossi Author-email: mickael.royer@gmail.com License: GNU GPL v2 Description: UNKNOWN Platform: UNKNOWN deejayd-0.10.0/setup.py0000755000175000017500000002017311351210475013120 0ustar royroy#!/usr/bin/env python # Deejayd, a media player daemon # Copyright (C) 2007-2009 Mickael Royer # Alexandre Rossi # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import glob,os from distutils.command.build import build as distutils_build from distutils.command.clean import clean as distutils_clean from distutils.core import setup,Command from distutils.errors import DistutilsFileError from distutils.dep_util import newer from distutils.dir_util import remove_tree from distutils.spawn import find_executable from zipfile import ZipFile import deejayd def force_unlink(path): try: os.unlink(path) except OSError: pass def force_rmdir(path): try: os.rmdir(path) except OSError: pass class build_extension(Command): ext_directory = None extension = None def initialize_options(self): pass def finalize_options(self): self.ext_directory = "extensions" self.extension = "deejayd-webui" self.ext_dir = os.path.join(self.ext_directory, self.extension) self.ext_path = "%s.xpi" % self.ext_dir def run(self): data_files = self.distribution.data_files # first remove old zip file self.clean() ext_file = ZipFile(self.ext_path, 'w') for root, dirs, files in os.walk(self.ext_dir): for f in files: path = os.path.join(root, f) ext_file.write(path, path[len(self.ext_dir):]) ext_file.close() target_path = os.path.join('share', 'deejayd', 'htdocs') data_files.append((target_path, (self.ext_path, ), )) def clean(self): force_unlink(self.ext_path) class build_manpages(Command): manpages = None db2mans = [ # debian "/usr/share/sgml/docbook/stylesheet/xsl/nwalsh/manpages/docbook.xsl", # gentoo "/usr/share/sgml/docbook/xsl-stylesheets/manpages/docbook.xsl", ] mandir = "man/" executable = find_executable('xsltproc') def initialize_options(self): pass def finalize_options(self): self.manpages = glob.glob(os.path.join(self.mandir, "*.xml")) def __get_manpage(self, xmlmanpage): return xmlmanpage[:-4] # remove '.xml' at the end def run(self): data_files = self.distribution.data_files db2man = None for path in self.__class__.db2mans: if os.path.exists(path): db2man = path continue for xmlmanpage in self.manpages: manpage = self.__get_manpage(xmlmanpage) if newer(xmlmanpage, manpage): cmd = (self.executable, "--nonet", "-o", self.mandir, db2man, xmlmanpage) self.spawn(cmd) targetpath = os.path.join("share", "man","man%s" % manpage[-1]) data_files.append((targetpath, (manpage, ), )) def clean(self): for xmlmanpage in self.manpages: force_unlink(self.__get_manpage(xmlmanpage)) class build_i18n(Command): user_options = [] po_package = None po_directory = None po_files = None executable = find_executable('msgfmt') def initialize_options(self): pass def finalize_options(self): self.po_directory = "po" self.po_package = "deejayd" self.po_files = glob.glob(os.path.join(self.po_directory, "*.po")) self.mo_dir = os.path.join('build', 'mo') def run(self): data_files = self.distribution.data_files for po_file in self.po_files: lang = os.path.basename(po_file[:-3]) mo_dir = os.path.join(self.mo_dir, lang, "LC_MESSAGES") mo_file = os.path.join(mo_dir, "%s.mo" % self.po_package) if not os.path.exists(mo_dir): os.makedirs(mo_dir) cmd = (self.executable, po_file, "-o", mo_file) self.spawn(cmd) targetpath = os.path.join("share/locale", lang, "LC_MESSAGES") data_files.append((targetpath, (mo_file,))) def clean(self): if os.path.isdir(self.mo_dir): remove_tree(self.mo_dir) class deejayd_build(distutils_build): def __has_manpages(self, command): has_db2man = False for path in build_manpages.db2mans: if os.path.exists(path): has_db2man = True return self.distribution.cmdclass.has_key("build_manpages")\ and has_db2man and build_manpages.executable != None def __has_i18n(self, command): return self.distribution.cmdclass.has_key("build_i18n")\ and build_i18n.executable != None def __has_extension(self, command): return self.distribution.cmdclass.has_key("build_extension") def finalize_options(self): distutils_build.finalize_options(self) self.sub_commands.append(("build_i18n", self.__has_i18n)) self.sub_commands.append(("build_manpages", self.__has_manpages)) self.sub_commands.append(("build_extension", self.__has_extension)) def clean(self): for subcommand_name in self.get_sub_commands(): subcommand = self.get_finalized_command(subcommand_name) if hasattr(subcommand, 'clean'): subcommand.clean() class deejayd_clean(distutils_clean): def run(self): distutils_clean.run(self) for cmd in self.distribution.command_obj.values(): if hasattr(cmd, 'clean'): cmd.clean() force_unlink('MANIFEST') force_rmdir('build') # # data files # def get_data_files(walking_root, dest_dir): data_files = [] for root, dirs, files in os.walk(walking_root): paths = [os.path.join(root, f) for f in files] root = root.replace(walking_root, '').strip('/') dest_path = os.path.join(dest_dir, root) data_files.append((dest_path, paths)) return data_files def build_data_files_list(): data = [ ('share/doc/deejayd', ("doc/deejayd_rpc_protocol", )), ('share/doc/deejayd', ("README", "NEWS", )), ('share/doc/deejayd', ["scripts/deejayd_rgscan"]), ] # htdocs data.extend(get_data_files('data/htdocs', 'share/deejayd/htdocs')) return data if __name__ == "__main__": setup( name="deejayd", version=deejayd.__version__, url="http://mroy31.dyndns.org/~roy/projects/deejayd", description="Network controllable media player daemon", author="Mikael Royer, Alexandre Rossi", author_email="mickael.royer@gmail.com", license="GNU GPL v2", scripts=["scripts/deejayd","scripts/djc"], packages=["deejayd","deejayd.net","deejayd.mediadb",\ "deejayd.mediadb.formats", "deejayd.mediadb.formats.audio",\ "deejayd.mediadb.formats.video","deejayd.player",\ "deejayd.sources","deejayd.ui","deejayd.rpc",\ "deejayd.database","deejayd.database.upgrade",\ "deejayd.database.backends","deejayd.plugins",\ "deejayd.webui","pytyxi"], package_data={'deejayd.ui': ['defaults.conf'],}, data_files= build_data_files_list(), cmdclass={"build": deejayd_build, "build_i18n": build_i18n, "build_extension": build_extension, "build_manpages": build_manpages, "clean" : deejayd_clean, } ) # vim: ts=4 sw=4 expandtab deejayd-0.10.0/prepare.sh0000755000175000017500000000112311351210475013372 0ustar royroy#!/bin/bash SRCDIR=$(pwd)/$(dirname $0) makeexec (){ chmod +x $SRCDIR/$1 } makeexec scripts/testserver makeexec tests.py makeexec debian/rules if [ -d $SRCDIR/maemosrc ]; then makeexec maemosrc/debian/rules cd $SRCDIR/maemosrc/deejayd/ ln -sf ../../deejayd/__init__.py . ln -sf ../../deejayd/interfaces.py . ln -sf ../../deejayd/mediafilters.py . cd $SRCDIR/maemosrc/deejayd/rpc ln -sf ../../../deejayd/rpc/jsonbuilders.py . ln -sf ../../../deejayd/rpc/jsonparsers.py . cd $SRCDIR/maemosrc/deejayd/net ln -sf ../../../deejayd/net/client.py . fi deejayd-0.10.0/gentoo/0000755000175000017500000000000011354730161012675 5ustar royroydeejayd-0.10.0/gentoo/deejayd-0.10.0.ebuild0000644000175000017500000000477611354574706016233 0ustar royroy# Copyright 1999-2008 Gentoo Foundation # Distributed under the terms of the GNU General Public License v2 NEED_PYTHON=2.5 inherit distutils DESCRIPTION="deejayd is a media player daemon based on twisted." HOMEPAGE="http://mroy31.dyndns.org/~roy/projects/deejayd" SRC_URI="http://mroy31.dyndns.org/~roy/archives/deejayd/${P}.tar.gz" LICENSE="GPL-2" SLOT="0" KEYWORDS="~x86 ~amd64" IUSE="sqlite mysql webradio xine gstreamer webui inotify logrotate man" DEPEND=" man? ( >=app-text/docbook-xsl-stylesheets-1.73 >=dev-libs/libxslt-1.1.24 )" RDEPEND=" >=dev-python/twisted-2.0.0 sqlite? ( || ( >=dev-lang/python-2.5.0 >=dev-python/pysqlite-2.2 ) ) mysql? ( >=dev-python/mysql-python-1.2.1 ) || ( >=dev-lang/python-2.5.0 >=dev-python/celementtree-1.0.2 ) || ( >=dev-lang/python-2.6.0 >=dev-python/simplejson-2.0.9 ) >=media-libs/mutagen-1.9 >=dev-python/kaa-metadata-1.1 >=dev-python/lxml-1.3.0 logrotate? ( app-admin/logrotate ) webui? ( >=dev-python/twisted-web-0.6.0 ) inotify? ( >=dev-python/pyinotify-0.6.0 ) gstreamer? ( >=dev-python/pygobject-2.14 >=media-libs/gstreamer-0.10.2 >=media-libs/gst-plugins-base-0.10.2 >=media-libs/gst-plugins-good-0.10.2 >=dev-python/gst-python-0.10.2 >=media-plugins/gst-plugins-meta-0.10-r1 webradio? ( >=media-plugins/gst-plugins-gnomevfs-0.10.2 ) ) xine? ( || ( >=dev-lang/python-2.5.0 >=dev-python/ctypes-1.0.0 ) >=x11-libs/libX11-1.0.0 >=x11-libs/libXext-1.0.0 >=media-libs/xine-lib-1.1.0 )" pkg_setup() { enewuser deejayd '' '' "/var/lib/deejayd" audio,cdrom || die "problem adding user deejayd" # also change homedir and groups if the user has existed before usermod -d "/var/lib/deejayd" -G audio,cdrom deejayd } src_install() { ${python} setup.py install --root=${D} --no-compile "$@" || die # Pid File dodir /var/run/deejayd fowners deejayd:audio /var/run/deejayd fperms 750 /var/run/deejayd keepdir /var/run/deejayd # Conf insinto /etc newins deejayd/ui/defaults.conf deejayd.conf # conf.d newconfd "${FILESDIR}/deejayd.confd" deejayd fperms 600 /etc/conf.d/deejayd # init.d newinitd "${FILESDIR}/deejayd.init" deejayd diropts -m0755 -o deejayd -g audio dodir /var/lib/deejayd/music keepdir /var/lib/deejayd/music dodir /var/lib/deejayd/video keepdir /var/lib/deejayd/video # Log dodir /var/log/deejayd keepdir /var/log/deejayd # Logrotate support if use logrotate ; then insinto /etc/logrotate.d newins "${FILESDIR}/deejayd.logrotate" deejayd fi } deejayd-0.10.0/gentoo/files/0000755000175000017500000000000011354730161013777 5ustar royroydeejayd-0.10.0/gentoo/files/deejayd.init0000755000175000017500000000243311354574755016315 0ustar royroy#!/sbin/runscript # Copyright 1999-2004 Gentoo Foundation # Distributed under the terms of the GNU General Public License v2 name=deejayd pidfile=/var/run/${name}/${name}.pid daemon_path=/usr/bin/${name} conf_file=/etc/${name}.conf logdir=/var/log/${name} log_file=${logdir}/${name}.log webuilog_file=${logdir}/${name}-webui.log opts="reload" depend() { need localmount use netmount } check() { if ! [ -x ${daemon_path} ]; then eerror "Deejayd application not found." return 1 fi if ! [ -f ${conf_file} ]; then eerror "Configuration file /etc/deejayd.conf does not exist." return 1 fi return 0 } start() { check || return 1 ebegin "Starting deejayd" start-stop-daemon --start --exec ${daemon_path} \ --pidfile ${pidfile} \ -- -l ${log_file} \ -w ${webuilog_file} \ -p ${pidfile} ${DEEJAYD_OPTS} eend $? "Failed to start deejayd" } stop() { ebegin "Stopping deejayd" start-stop-daemon --stop --exec ${daemon_path} \ --pidfile ${pidfile} eend $? "Failed to stop deejayd" } reload() { check || return 1 service_started "deejayd" || return ebegin "Reload deejayd" start-stop-daemon --user ${name} --stop --oknodo --signal 1 --quiet \ --pidfile ${pidfile} --name ${name} eend $? "Failed to reload deejayd" } deejayd-0.10.0/gentoo/files/deejayd.confd0000644000175000017500000000022611351210475016415 0ustar royroy# /etc/conf.d/deejayd: config file for /etc/init.d/deejayd # see deejayd --help # for valid cmdline options DEEJAYD_OPTS="-u deejayd -g audio,cdrom" deejayd-0.10.0/gentoo/files/deejayd.logrotate0000644000175000017500000000021511351210475017322 0ustar royroy/var/log/deejayd/*log { missingok notifempty postrotate /etc/init.d/deejayd reload > /dev/null 2>&1 || true endscript } deejayd-0.10.0/extensions/0000755000175000017500000000000011354730161013601 5ustar royroydeejayd-0.10.0/extensions/deejayd-webui/0000755000175000017500000000000011354730161016317 5ustar royroydeejayd-0.10.0/extensions/deejayd-webui/chrome.manifest0000644000175000017500000000047711351210475021332 0ustar royroycontent deejayd-webui chrome/content/ skin deejayd-webui classic/1.0 chrome/skin/classic/ overlay chrome://browser/content/browser.xul chrome://deejayd-webui/content/overlay.xul locale deejayd-webui en-US chrome/locale/en-US/ locale deejayd-webui fr-FR chrome/locale/fr-FR/ deejayd-0.10.0/extensions/deejayd-webui/chrome/0000755000175000017500000000000011354730161017574 5ustar royroydeejayd-0.10.0/extensions/deejayd-webui/chrome/locale/0000755000175000017500000000000011354730161021033 5ustar royroydeejayd-0.10.0/extensions/deejayd-webui/chrome/locale/en-US/0000755000175000017500000000000011354730161021762 5ustar royroydeejayd-0.10.0/extensions/deejayd-webui/chrome/locale/en-US/deejayd-webui.properties0000644000175000017500000000167611351210475026626 0ustar royroyaddHostConfirm=Host %S is not allowed to use webui extensions, Do you want to allow it ? prefError=Unable to load preference, can't init webui prefError2=No server allowed for this extension unknownHost=Host not known, can not send command confirm=Are you sure ? missParm=It misses a parameter ! replacePls=Do you want to replace this playlist ? newStaticPls=New Playlist newMagicPls=New Smart Playlist enterPlsName=Enter playlist name magicPlsFilters=Filters edition for %S playlist # playlist dialog title=Title artist=Artist album=Album genre=Genre rating=Rating remove=Remove equals=Equals notequals=Not Equals contains=Contains notcontains=Not Contains higher=>= lower=<= edit=Edit remove=Remove newStaticPls=New Playlist newMagicPls=New Smart Playlist plsSave=The playlist has been saved audioUpdate=Audio library has been updated videoUpdate=Video library has been updated webradioLocal=Recorded Webradios webradioShoutcast=Shoutcast Webradios deejayd-0.10.0/extensions/deejayd-webui/chrome/locale/en-US/deejayd-webui.dtd0000644000175000017500000000543311351210475025200 0ustar royroy deejayd-0.10.0/extensions/deejayd-webui/chrome/locale/fr-FR/0000755000175000017500000000000011354730161021747 5ustar royroydeejayd-0.10.0/extensions/deejayd-webui/chrome/locale/fr-FR/deejayd-webui.properties0000644000175000017500000000207611351210475026606 0ustar royroyaddHostConfirm=L'hôte %S n'est pas autorisé à utiliser l'extension 'deejayd-webui', souhaitez vous l'autoriser ? prefError=Impossible de charger les préférences, l'initialisation a échoué prefError2=Aucun serveur n'est autorisé pour cette extension unknownHost=L'hôte n'est pas connu, impossible d'envoyer la commande confirm=Etes vous sûr ? missParm=Il manque un argument ! replacePls=Souhaitez vous remplacer cette playlist ? enterPlsName=Entrez le nom de la liste de lecture magicPlsFilters=Edition des règles pour la liste de lecture %S # playlist dialog title=Titre artist=Artiste album=Album genre=Genre rating=Note equals=Egal à notequals=N'est pas égal à contains=Contient notcontains=Ne Contient Pas higher=>= lower=<= edit=Editer remove=Supprimer newStaticPls=Nouvelle Liste de Lecture newMagicPls=Nouvelle Liste de Lecture Intelligente plsSave=La liste de lecture a été sauvegardée audioUpdate=La librarie audio a été mise à jour videoUpdate=La librarie vidéo a été mise à jour webradioLocal=Radios Web Enregistrées webradioShoutcast=Shoutcastdeejayd-0.10.0/extensions/deejayd-webui/chrome/locale/fr-FR/deejayd-webui.dtd0000644000175000017500000000620111351210475025157 0ustar royroy deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/0000755000175000017500000000000011354730161020540 5ustar royroydeejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/0000755000175000017500000000000011354730161022161 5ustar royroydeejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/main.css0000644000175000017500000001150311351210475023615 0ustar royroy/* * An example if you want to style the tree */ /* treechildren::-moz-tree-row(selected) { background-color: #ddd; } treechildren::-moz-tree-row(focus,selected) { background-color: #aaa; } treechildren::-moz-tree-row(odd) { background-color: #eee; } treechildren::-moz-tree-row(odd,selected) { background-color: #ddd; } treechildren::-moz-tree-row(focus,odd,selected) { background-color: #aaa; } */ /* * General parms */ #main{margin: 10px;} caption{font-weight: bold;} /* * Icons */ .collapsed{list-style-image: url("./images/collapsed.png");} .expanded{list-style-image: url("./images/expanded.png");} .audio-item{list-style-image: url("./images/audio.png");} .directory-item{list-style-image: url("./images/folder.png");} .video-item{list-style-image: url("./images/video.png");} .playlist-item{list-style-image: url("./images/playlist.png");} .static-playlist-item{list-style-image: url("./images/playlist.png");} .magic-playlist-item{list-style-image: url("./images/magic-playlist.png");} .webradio-item{list-style-image: url("./images/webradio.png");} .dvd-item{list-style-image: url("./images/dvd.png");} .add-action{list-style-image: url("./images/add.png");} .remove-action{list-style-image: url("./images/remove.png");} .play-action{list-style-image: url("./images/play-low.png");} .search-action{list-style-image: url("./images/search.png");} .edit-action{list-style-image: url("./images/edit.png");} .shuffle-button{list-style-image: url("./images/update.png");} .save-button{list-style-image: url("./images/save.png");} .clear-button{list-style-image: url("./images/clear.png");} .cancel-button{list-style-image: url("./images/cancel.png");} .fullscreen-button{list-style-image: url("./images/fullscreen.png");} .update-button{list-style-image: url("./images/update.png");} .subtitle-button{list-style-image: url("./images/subtitle.png");} #random-button{list-style-image: url("./images/shuffle.png");} #repeat-button{list-style-image: url("./images/repeat.png");} .goCurSong-button{list-style-image: url("./images/go.png");} .home-menu{list-style-image: url("./images/home.png");} .search-menu{list-style-image: url("./images/search.png");} .playlist-menu{list-style-image: url("./images/playlist.png");} /* * Example to change the style of a checked button */ button[checked="true"] { font-weight: bold; } toolbarbutton[checked="true"] { font-weight: bold; } /* * Playlist */ treechildren::-moz-tree-row(dragged) { border-top: 2px solid #333; } /* * video directoey */ treechildren::-moz-tree-image(folder) { list-style-image: url("./images/tree-folder.png"); padding-right: 6px; } treechildren::-moz-tree-image(videodir-select) { list-style-image: url("./images/go.png"); } /* * Command */ #command-panel{ margin: 5px 10px 2px 20px; padding: 5px; } #seekbar-button{max-height: 26px !important; } #volseek-toolbar{margin: 5px 0px 5px 0px;} /* * Message */ #message-box{ padding: 3px; height:20px;} .error{ background-color: #EF9398; border: 1px solid #DC5757; } .confirmation{ background-color: #A6EF7B; border: 1px solid #76C83F; } /* * Player */ .play-button {list-style-image: url("./images/play.png");} .pause-button {list-style-image: url("./images/pause.png");} .stop-button {list-style-image: url("./images/stop.png");} .previous-button {list-style-image: url("./images/previous.png");} .next-button {list-style-image: url("./images/next.png");} #current-media{margin: 10px 5px 0px 5px;} #media-info .cursong {margin: 0px;} #current-artist,#current-url,#current-album{ font-size: 11px; padding-left: 10px; } #current-artist,#current-url{font-weight: bold;} #current-album{font-style: italic;} #seekbar{margin-top: 5px;} treechildren::-moz-tree-row(play) {background-color: #ccc;} treechildren::-moz-tree-image(play) { list-style-image: url("./images/tree-play.png"); } treechildren::-moz-tree-row(pause) {background-color: #ccc;} treechildren::-moz-tree-image(pause) { list-style-image: url("./images/tree-pause.png"); } /* * Search */ #search-box{height: 11px;} /* * Playlist */ #navigation-path {margin: 1px 10px 1px 10px;} #navigation-path toolbarbutton{ font-size: 10px; max-width:60px; margin: 2px; background-color: #ddd; border-left: 1px solid #ddd; border-right: 1px solid #ddd; } /* * Queue */ #queue-panel{ height:30px; min-height:30px; max-height:30px; } /* * Panel */ #panel-select-box .listitem-title { font-weight: bold; } .list-all, .list-unknown { font-weight: bold; } treechildren::-moz-tree-image(static-playlist-item) { list-style-image: url("./images/playlist.png"); } treechildren::-moz-tree-image(magic-playlist-item) { list-style-image: url("./images/magic-playlist.png"); } .playlist-new {list-style-image: url("./images/playlist-new.png");} #panel-pls-list treechildren::-moz-tree-row(dragged) { border-top: 0px solid #333; background-color: #ded; } deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/0000755000175000017500000000000011354730161023426 5ustar royroydeejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/playlist.png0000644000175000017500000000051511351210475025774 0ustar royroyPNG  IHDRabKGD pHYs  tIME8`&BIDAT8˭=n0H[!!l"A!QsL$^Xj,{|3m1 Z;騬8xt/5ǒ4MW(w=ιURW/P&AH৞"?S-FIENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/edit.png0000644000175000017500000000305511351210475025062 0ustar royroyPNG  IHDRw=bKGDtIME G:&IDATH[l0=x^DZC*P2`"@[Tz(T\$jo"0I gT6I!6 z9h-;O|מb9\s ׬ [=<k:/f~_z<"Ѡ}5VrϦڸzsh}vps:?e@94gVpE:aAɚXloя_tN-t΍Kd*`kaچiöy OjNpl\A AOLKS]M5D!\?rD7n1.XWgG3෣)E 8G`")@UG LրOP2qD %΋v\ؖA,g g 8 sVyOL^'F)u *T*2J!`qm˲Jy}oeϻ.a*Y~g`ˠD~f-[Ӡ>B|"Z 7޾Wmqϯm.8./ӳ5Oz{m&~"K^rE (! l9j>]/٨'yś۶#/Zx3XFzЙs%7fk6l~垯g(4q'9Ξ롒,GbM p- (&3ىrs;55fEΠ=D2P4\ք#Ũe䤊Tb<8`Яtwwr@[ۮYMuoa[B~H Ø^k;vu~WVqʺ:;! c/lX-iiCG~woY6vo2T[  @9|~%'F)6G7xkW(|f FQzN\[xdL{7b tmӦiE,ԻMYD(%L&H:re9m jC&Eͅ-Bp>"M36wUm$+DIo" S' <u#F#ĚC1"T#$r T9zw(rT ;zN_Sb]z p=zࡿSIRT*!>~,щ|Lf1._{Oen@ҳw_=4x@+wzRMo?7 VC ̝7osvoddoy P$cQLUMjd?>2?$"=xYInOHycjm%xBV=SyW[!Ʒ[IENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/audio.png0000644000175000017500000000064311351210475025236 0ustar royroyPNG  IHDRabKGD pHYs B(xtIME 69$G0IDAT8c`0b,5g`Z@H3^2_  ھvzg&l3~# ,l*ÿ@&0000t7^:  Ld?xpa\xIt@V, ˳0000dZH2w |k'ӧ:5- 3U~^u~ne{O2s2ga```aȰf33;'k.[ɿzOg`a{p[wo^<}C>''L"b,* \axCWy9uo)JIENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/next.png0000644000175000017500000000214411351210475025111 0ustar royroyPNG  IHDRĴl;sBIT|dtEXtSoftwarewww.inkscape.org<IDAT8U_L[e{ޖK(0`dIIQaas2Lid9, LX4٢f3-B4ƹQqkKooi{? F|7o_9s"+I+"""s9#3s}gw89 fIbfG}uuU"%HecUUڇ6흞DD_^bnںEMiG.3ga]U@m6}`TU; **+~~㻛Ws<3R!e2tvv/(`GGWԌoDi)TU1&'3Eɥ9|! upڃTVV-!1D_NYN;(Jw(2vOU]\[[X7(-E4|nfh^oLk?|d,QryZ+jA"dTIfaٓ"R'lY? Bs$p9em ri5'&sdIENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/fullscreen.png0000644000175000017500000000121211351210475026270 0ustar royroyPNG  IHDRabKGD pHYs  tIME 5wߚ5tEXtComment(c) 2004 Jakub Steiner Created with The GIMPًoIDAT8˝;hQ5i%Hq *v*_P*AppH`APVQtPq -%-EjMSMLuؼrsϹȉEo4g Xm֜XԵ%'ýHp".qSK$~QM :yCυoePO9/hYk>H0=@{g<wdvBV))` vpAg\ƭ!IDAT8}jAϝ]V+lVR.eER`u)"~@6A$F+[j72,\ ϙKggg<"3CD(dYH nUUmu"奢(`0f7ALj~fnc>cv¶ MSLEǽpqc()","~BBE~/}q~~{^- " "(ZkUUfat]^( 5$ѳٌ1}ZZlxڱ֮vVBGb ֘s](hK41|_!1+loc<J0Oh!_bcepSj6A#H9Y8+ 9...pt`5zM1UG<5đJ*koKP"[EL鱠#>~ze4ʲ$A$㮳iPd| +ޝŏ"N4wt`` a,W0 M|t`ˆj.L#!m<~2[9X!-^6!PUbxdunBHDhiDưd2yjq0G{T|b`(fR(!4Mc~^~{@951Vҙ,ޭQv0LnMtv(IENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/volume-zero.png0000644000175000017500000000150711351210475026421 0ustar royroyPNG  IHDRĴl;sBIT|dtEXtSoftwarewww.inkscape.org<IDAT8kTW?KMf6fLSR]T+lWEXAlh *n/I,EJ'@10&3{{yygx{9{=2{)͂/_{ \V{1ON._6{ q;~lW\.Y^fsQ#"$ϿuvUW8wry|Dolz/>zl'!ι;vT0Xmc7tkM[qf͵fn} ZP0i-ךk~9 YpvwDN W 4.zSf Qa9$(1vWFww8R*HXAԽo\\*XZzZOErB{vPڳ>dZ=dwh1sΛ>z}C5vaGyv?z<_.Cʱ@& ?ܿpk-BT(#J\^:riA7xyeɶ?0kZInk%C$1psss M8D]dl w^WK!mcj?(ɕJD #0,4 "mT;~+r)I:`}|3;KIENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/pause.png0000644000175000017500000000121711351210475025250 0ustar royroyPNG  IHDRĴl;bKGD pHYs B(xtIME !IDAT8UˊA=+ݩ+71,2 nD$A0?B%HRq.TĝUus={ ^{08K!Dr|r\JyGK)}ws!'Qcf%Zbx:=%|BiJh9tzVӦ֚9v-f@t;M:fGw̯ 55C$I\*i0MS@Ǽ82&"a1oSˢiT0oA~bEe\*2) sx\h*EQ%Yht)QS#cv A,ͬ_RΊvrjP*(jzKw:V} kCNjZxV(A"`0?Yvm`4٭EW'B=yytt񘭵?>_+v̍jc*!GQdh cLZ*(8 %-cۚ!!IENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/cancel.png0000644000175000017500000000146411351210475025364 0ustar royroyPNG  IHDRabKGD pHYs  tIME - kIDAT8mOSasm%@+xij 6X\:8iL q1P$(Ji;&_M{Ua==1DUv \Be3?k.]vͭhRJiY|x23^(X5*FN><}j&1}QPȑ|8v728emZ:M W,JgZg&xccw6ǪG Df8޻w$_r<֍TD"̻w Ծ>Ԗu~ ڷEJJ.hϟ( %BaՑppq]Qpz<8;wu@ y%%}]pzXb1mc6 ;Rww(8vԅB*,e_)D V[Z޺e=gutǃ=<혹Ʒoȕt)冚a ,6\Uxqx4рԝ:iբ)+@[VJ$(~MOnޤP^\$k$oF4|~?%r^S'[AW*gX-kSH c:`C6"Orbizi~inU|^L= 1mv?x.ElBZVO$֧lvƆ(,nƭ"zvWiEay3MR׆IENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/add.png0000644000175000017500000000050311351210475024660 0ustar royroyPNG  IHDRabKGDC pHYs B(xtIME DxIDAT8͑``rҺ`6[;tqDdZ &g7Z,Vi1v`9=0+n/\ϵUR>.009xrbD["[-$mx\9XwV2@@PiAkeRjt&Rȿ&ROXNOӅ43kgO=TrK wIENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/clear.png0000644000175000017500000000140511351210475025220 0ustar royroyPNG  IHDRasBIT|dtEXtSoftwarewww.inkscape.org<IDAT8KhgLHBHը+& ҂JKvэ" ]Rƅ`Ku 1-i411qFIe?O+Ec!pQG`s8Ʋ @4߯mڰ1zt^`qET*-XHs~SIՙJV|Rt}#ה]K뙋Lɧʕ7M/JDڧNWrUsK;=rT%'_A*vitsGWvjUC3F%њdS}k9$j&]toeQP5~Pν_ ` )RI(r;͵:.iB `dA϶Ր wj2[ԯVI8RwU{#e`g!^̌#^UVJo5RLZ[P">WXPFDJOz'yMwqcLL˥ep)DX6 EЃ18o #z)u=<p ݘܺy%oLe‘ߤYQ{/'e52 CC}Y‘wҀ1t =v咆?jӣkr ;8TR-S>L8xK~C(IENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/dvd.png0000644000175000017500000000164311351210475024713 0ustar royroyPNG  IHDRabKGD pHYs B(xtIME55*})K0IDAT8mO\ew8g@R(e. Mim5֔Ru+[(ӈؤlبEع@$,`(0snJb˳xGpↆ/}@L_/NcD۷?xղfz^|21M_~y^.,,R)\\\ rXVCٙcN;v/﹑#@aH)i4"u'mV,hCÃǷ.//K9PP?G5Bt03yyҧw|v7Uq]]7hnn!NkkDJu&ՄO-..n@γJR sͬ/޸αR^&Oygt$3MS)0M! /<#}b1 8a@N?S)E躎mH)?4Lq<! @H`hxJ)R:h;ץRꙮo]{[Ty>t: Оjgwog_$=D"vvwCRrJոi躎eZU/TZS!RŽ{ԏhBi =cXG+"@J ]7Fv)%GGGdYO޿;:P,XMێd2q0 ,L&#_g4Bhs$;;;Kڕi˲'''W ʼne߰_Rt !Dd9 QnJ:IENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/expanded.png0000644000175000017500000000125311351210475025723 0ustar royroyPNG  IHDRasBIT|dtEXtSoftwarewww.inkscape.org<=IDAT8MhAߙk`C"I"/*AU*mك١E&MADEt>S8zVO޼)c>. Q51Qw&zgO/Dk00(nCȽ{[a~5鈴@bLQ= &$dY[AOXZloxz.Ҽ6?_h8|E7sJ*hIENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/playlist-new.png0000644000175000017500000000073511351210475026567 0ustar royroyPNG  IHDRabKGDO4ױ pHYs  tIME&Ӷ)jIDAT8˭OK[QsV$}%[\f qYE޲w#ĂM7*RJRJIZEbwDž&Tz63 s3R.kZ\go˟oϾdRIxS/Oh缠'+⮶23l6 ! "=c~!"f L&jǷ[SW{jky`1*F_^^o: L8 n4Y:OPEi@дޛzƦ织'Z_YD)]Cu4V)W9u]wuݸ8{"bDii|h/V~)-[adg(D)6Ьj_QZ| vuR)1`!e7("ĕN'Vx6i,'Ig۶FB߻ncJKc96LH?շ IENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/previous.png0000644000175000017500000000203011351210475026001 0ustar royroyPNG  IHDRĴl;sBIT|dtEXtSoftwarewww.inkscape.org<IDAT8ML\u} o%"8E364u2P?CXHMh覆 avcXe&.l]čABhlBu λy3c+JVE "@#oD"rSYD4݆(@gXUU-r?jjH{ &dȲ\Px__# 6?0eҺ޷V |,R6J,~G\m<U|*/4H_f$pQZ{WwCϞ;4z^x} ze/re9iZ3n=nޘÝdb?/83qI%,HX,ܹ{MV@w "F$Q"J1>6App̈h J!R=kn;l6TUcdxQTUf57c 3\@PUUr>_9-MA2-M|TUH3ӳ˕UAL! WEsD6#mxd"ɻ3]O$7>Uv{(,,KerH&|5D--Vڅ_Oy^ve90o# \. QI2!IENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/play-low.png0000644000175000017500000000122411351210475025675 0ustar royroyPNG  IHDRabKGD pHYs  tIME $!٭^!IDAT8˝Kk<2$M&1ibZI0#JHRlgt'XB\H/r . BMXlJФ3M23.Ħb)wus9@wx`]: z`0,>0LDz/nZϵZMB4>7ԤʥOSL4-kuhpf.^x06 Îx""@0Sx}LQ*̝d t 6x=>褃bxuhEFo͎0,kf,BoZ;5e>Y& x' FeѲ9;+5Numy}--jxC8OLm|;Ύ?4}1 k6n{fLxm :M[Ů6)n彔GzoƄ?*kV)M4ے U2Mw}7caMn !F(ŸdšPɏ%IJ`f(1}.YOv7mlzm^LfAQ A'lk*sSiYX ۶8fE`i6[>4 20;Bp2 ! @DQ~ِe*##GL̀2HB@ BdY5g[cZ 6o*HWj, $A.=7<}{v E \UUUA%d<|D"$]ֲӆ!ର( =Bki#cMݡ={`y _*Ggԓ,Yr;:^"~?X8'({7Dcc ٢HX @`jirva?8;R*MIENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/search.png0000644000175000017500000000164711351210475025407 0ustar royroyPNG  IHDRabKGD pHYs B(xtIMElx4IDAT8˥[hTwgn646>|P4B5M5TZ4DThk&/}/Hh@[d޷=9%Z 3Ïo>W~7ߊ*qBfyxE |ͲQE&NgRg2{v[c ;֦[aHR,(ĥ\6J>iTw=6DrQcJK6 hDčآŃCro::毫<MqIʥ"3d"rIP\fU[,X6n0y}V34ݑApw־D$I v(x/7br]Hė4ʀ+m$yZ$QO&ژ LhbN80@D<ޙw2>s79(" q0ȭXt}rL&zee` DQJx+V5`rGArPpylxo#Eԉ} 6.@o{`rO^Z&`lZ[[[TCz ;fhOIENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/home.png0000644000175000017500000000163011351210475025062 0ustar royroyPNG  IHDRĴl;bKGD pHYs B(xtIME #oUO%IDAT8˥oE:m@]Dm!U4@(4R/ *j-YU"_S`TH' :R/=B9A5B\ ]MDHݝ7yoVp69~9 mޚczǶ<ѾM~-v׸8'a< ѝ;xmqQuAֽ{ d]󇁋 0BgmJ)/EjNG?'.0tF.biǢm`1\ǡGl~^<Ω6WRm+="uѶل]dp'}##J&iZy03]|:d!Hǁn6Af/y ļuRrusxFLEڄо3LΟò@k eYdealrt~ho300]n>,0;O٥gg.-͝[O?dO7׿E>BG>vJ:Й3oĥ`|i˓ A1FoZ0ȿ~p5~hH$0M00MTnߛl؈H)IU QC)R c<dK;(r4[4QOE+M6ֈI&A)&b{RTm-" |0F=JEet(B)җ'KMwP! ڝ lؚX<%خaOG?ifV(\&?IENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/confirm.png0000644000175000017500000000412711351210475025573 0ustar royroyPNG  IHDR;0gAMAOX2tEXtSoftwareAdobe ImageReadyqe<IDATxb?@b!E}b?;77Og 22eg<$,b$^3~~y]n>A &F ߼`^<ۦO#d&@;wOg3X31cbx_6fL L1;ҙ y/sj.\fNrf4|yΏˆ eV<L j : Oc8whmS뱩 {fN׏ Dߌ oeD`;es>=+]@aX;'3S~`c8{4 `bQƊ, VΞS@<}9:bE_SM[b *0gض򦾸0~RFÂ]XTa녿 , LȮa+e&#qgh ^ 2Vo[i >{lapBFF(fbFLĐj "'hi řgDa⿿Չi0f`xHAy B3hP`a& ܐ?Ar/?32abUgg>olg 0̄_X| L{WWo~>yWL#N0}_$e^}f353 | u~!? Ԕe'0\} v9_!i?K ) ( c/AefA$(oA1|_ ,c1 : (F`h`+ O.}fn0b@%% lP7^ϰ/` 69A>aO? ` V#2[ \}HP3C)+$f`v7B&Zrā yؙ>t47}勧q hObx b8C+C%+'^0d0@`BS6.0n17gw1} _3FhϾ?T Q`C,1+`| nx5 @pOn 0 LdBh`J_~:w0- VE ~ ō<0xEGS~gf`]&Y`itB9p?Ý aA@YQٽ BGL@L(9`س~1 "h̓>``aUH0A &fHng8Œ >}`pO.>(d>(˙_3Il4\KAǏ?T} 2 [n`apnĿ|b"@X[ 3]w*?&`\h fXKA`!&P`Z}N6'g@8\Esk?{R`,n_~A,`b8/2t^`x|4nKnf>@meW,tVv9=[i%m&N.UP f1<{ >\a1###;(w N@UI|db;8ko^1/;s+; 5Pq'>@aT1&bC8x,9@X~|xډ;| fZ +=8P}'bb `[)IENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/save.png0000644000175000017500000000161711351210475025075 0ustar royroyPNG  IHDRabKGDC pHYs  tIME 7*ؿtEXtCommentMenu-sized icon ========== (c) 2003 Jakub 'jimmac' Steiner, http://jimmac.musichall.cz created with the GIMP, http://www.gimp.orggGIDAT8˝MHTaL1͑QlQQ4&E.B(,V-*ETR( "((j["DETjܛN al6<=_'?[ظ3 T[&Ÿ?ͻϝ 4wt퍭Uk:#km'-Q7˺; <}۝;ƨf(ce#+s >b$y,%_FDDdsZzD/Hϫ/.wY%<r$qDxXBAA(W'n۱wp=/x|vl_PPP#wҘX̌M4єXi^"2"0e1.XͻǪ Q_ͭ{h\Î,4M9H*|!mkص)i8(m41MCRO-hm aPYYޚ <۶qy!<]s?D30DQ,@.x]:R,Fs4_;3L"bmIENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/go.png0000644000175000017500000000125511351210475024542 0ustar royroyPNG  IHDRasBIT|dtEXtSoftwarewww.inkscape.org<?IDAT8MHQ{?Ѣ`,(HD-EBhcPFZM\Ti""3b)aJ滷ijc wsk-KUߪp=_KјN5UVlј._,TS{]"x BTS{U8b('ͩhNbY($ycMhd)Ցz4YߢXKsß;l[sGHB!_l\U'~1kL#0 xZKu]!?=fN]YQ]D.ۃFH2 MAd3ô2zHɦsyf)r8P'cd BMNҋL)H#R (A8>0A5 sγ ^8 Sq%Ml)`(Huݺ(F?s|H*ɶ^ʽ NeGX3c4g[s{;rd2c{f|;]6œ#S&1)6,Fo&' dfzs AsIENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/warning.png0000644000175000017500000000261611351210475025604 0ustar royroyPNG  IHDR;0gAMAOX2tEXtSoftwareAdobe ImageReadyqe< IDATxb?@bb @,je굇|bZ b@1XcsF]_o  ]+]3I1 H ןOIe`{@ȾpӉ@D[~zV.V}W_0 t^2蛸1r_ఁX(lt@D ßW 3?1EV2\3# ʌ _o>\r  IYEe5 2 &$|}=hT `_&2{3BVgwO6G |Ou>|Ov .> N\k/^|sIb*~1k8(?P׷ .) ϟھ>[ f4G߁ h r2ˁp ?K[`߂|/yi:y#@Qƿ1rAЊstㅸ FlĎzϼ^,yo3002"%? .A.^}p=?j`bfca#LEyP< |ϞrA 0zc4b~:?ɞ4/?8EcfZ7[AY Px,߮.e((uUfy/%0UK 2pO3>P")ÃG? @pLԜ̧gغ#M- މw!¿G^uso 7s@fzk2}# dH GUR "BL >$h?Ñ5 b1! "/P<{ǜ?} 8  'F˿3H2|_  w7\w(ՃGvg_`<_džK\a$X3s f` x>fdd4誔*{g$-{[@bP1+Aiht0 jo_q04`]0 F֑J9IENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/error.png0000644000175000017500000000163511351210475025270 0ustar royroyPNG  IHDRĴl;bKGD pHYs  tIME 4/`h*IDAT8Ք?hg?'$4ihLBzdҩK@ 2dOJ:kB$[B!!$N,d[ήtrqUwYtY ,xY^o[g*\98ðkeVcիpm~: ܘ)xy(.R?ݽ Ʊ3)n/^Ro"*h -lϣn7~M\z}98@E T3*3a 8< rLzmtAUD1A0sE:L4w#M$btU5*X07-a3nNlp,Cׯի:Dӎ㌯yƶO Xd13Q٤>[2b(;m9PEm?mLEjV*F?0.P nn!ol5ff2ɠw @ETn:MU U$~ ^wggideNƕb$NHap*<tlg4N1n:M譨cP][c׻3Gm dZ66qRRT[,!=ǥCV;-1ފq{QNoy ^އU6@w 49sw!30+Ʉ)` ;f?Rh@c2֡3߫C=%.:/ɧIENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/loading.gif0000644000175000017500000000562411351210475025537 0ustar royroyGIF89aQQQƹdddGGG)))zzzlllrrr}}}===ɶ! NETSCAPE2.0! ,'dihlnӌ,&1cUف`7<@xK T^# RP IS$˅cɈl+T%. (50|(,_'I'U'-4(|k%#]k#?)k_6J0>#뿽""!! ,'dihl.,vN2d7$ g x@,ҠlFՠ|hEN*0.L.D$P, f_*  a%5.(f0Y',%r'(,$-&-("Y###*aߦJީJ70}ذ. H!! ,'dihl뾮,5#؜Da7 'Px4^*2@md4̀t$Q ?*u% /( J&:-u%(u$.#,(^-(~dx"~4$Xd6יU^/#~Ű#U H! ,'dihlnQ,6#A؟`7 :@"} "|x"00s Hq*OpK`5+qV%/( e6&L-q% (q$.#,(\.)btK"C#(\6ܔ@϶/# ! ,'dihl뾮,60؟`7l  S>B802*簻dg08R,1b8x !*8+2^$ /(d6%:-2$(2#." *( . |*xx(f"CSF *5#56"T6MF0QH"B! ,'dihl뾮,6cUٟ`7E>j# "BK! ,'dihl뾮,6cUٟ`7E>j g?N@{.5%2/'J(+2$(2\-"@'/w)S o(C  7|$]AT"]6@>#6T HPD! ,'dihl뾮,6cUٟ`7E>j g?N@{.5%2/'J(+2$Ki$.#o6( .&  {)S > ](w "|# xT 0 ""@#2YŎ*,! ,'dihl뾮,6cUٟ`7E>j g?N@{.5%2/'J(+2$Ki$.wj@ (]. 4~)pH$ {  #yM( \ FT," @#{0ST HD! ,'dihl뾮,6cUٟ`7E>j g?N@{.5%2/'J(+ wj2i$O,Mj@ (].S~)uoH% e#y'\ι`@_Tw"#pT#!! ,'dihl뾮,6cUٟ`7E>jf|4,^# R 4YƳ]& p g2m* &2. (50(,2p$O,_'/ -zo({|H% D#$B;deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/tree-pause.png0000644000175000017500000000072011351210475026203 0ustar royroyPNG  IHDRabKGD pHYs  tIME  +J`D]IDAT8͒KnA *(؊8@r HHp(;DgH0ia/J\~?_Gڻ=;-"#:kΟ=y/Y C>f_ywnyfWfps_L-{>}`t]7#7Svw)@T0sEDkv(?$д;AT@v7Zq4mC"!yt0FU_,n>&C *fO,e`n; ﻩ_dQxh 6N1-F$j\BB|9Q*Ώ몞l7D"ic#)HsJtV:mJ8IENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/tree-play.png0000644000175000017500000000122411351210475026033 0ustar royroyPNG  IHDRabKGD pHYs  tIME $!٭^!IDAT8˝Kk<2$M&1ibZI0#JHRlgt'XB\H/r . BMXlJФ3M23.Ħb)wus9@wx`]: z`0,>0LDz/nZϵZMB4>7ԤʥOSL4-kuhpf.^x06 Îx""@0Sx}LQ*̝d t 6x=>褃bxuhEFo͎0,kf,BoZ;5e>Y& x' Fܹ c ҏr@adaԂow;{90\~p×}!+w{84{6bQg)bc8p4@}b# 043;0İiL.,˭B P,4ϟ"SY>0y{@=|fA? ? 30s -'D?_V̝=Ȗ R2c8zG}ՙ |\l`.T``b+Va`5ES03^ &@LHeb-o-,p$ t4  d(5l09XݫJaze5l />?_2}p7KߥTXQJ30`׷rvO~ &o9 d4tZ?70##+{mշVf`a`bbP 2iuVNabu*0<~}&@߾` ??yEXyE #V}g VZ _?$$}-=a'?+6WcUO~1Lra`Jg$fda@|on^o?A>FfwU o=k0m/O3|}01``d.^Y_VVvv ?0jͰ`@aV  & DvbwVf&v?1=3"\j l@U?~&>&P]YX{ z+, " ᆮ> ?|``bfMA3 ӗϞ3s01 00Z v*wfQ`i.ͰJ fgdx] 1Y5?pA ٷ/HJ ' ̜ xX7@?.@,o] l]0q@2? "ETv4׉oX}l2M @OXX]=}AV-/`bC,4zTVzJIAZ l<@v, &HGwqۧĹQ |Ff]^3|̗dH 4`hXxkv`o?1hP^I@P~5xf^=OT?xSϷX<3WpKxQ~~QrkgBGoC3XU_Vge4(fP?J3;#+go՜RvS @a@U ,<:,b2j [ lV V/$^=^`}齹m2P-Dn6(];L=; 2`xeL2{~3O(PAY?p0"fdDiW׷ӟ^rf&+PW(}f17R!@e" )*^&~}~{ͽ-NPfJHPKhWd{26/ؠU@:A?zHJ &h ;$`|]IENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/folder.png0000644000175000017500000000125011351210475025403 0ustar royroyPNG  IHDRĴl;bKGDC pHYs B(xtIME &m^5IDAT8ՕkAԦZ+车'QgxU)D/~IQO"Xų7 Z"i{ڴMd!M~xp7;oޝ(<Ӹ9X091vg52w) ?nGk~uPz.dFovAc`Z[kXœoRL&S԰ ;a>Z]AJ8I.q o=|sh,,ki qMjkGoUJekW[xee(^ccW#SC7u  DA5(a{ͯ%W{2Cp29;Pt`i E׀m1;v&\pDܦKz ?a/Mtд4-f>;-R-7 m:9hblw;ssZ,HqtUU*}"P:i14"2+@/6OIENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/subtitle.png0000644000175000017500000000051511351210475025766 0ustar royroyPNG  IHDRabKGD pHYs  tIME8`&BIDAT8˭=n0H[!!l"A!QsL$^Xj,{|3m1 Z;騬8xt/5ǒ4MW(w=ιURW/P&AH৞"p-f)7",3x_3ԁ1|tSSRƣqwp5Xb55 -$b]ӨPB--ZRVS6Rʋ.EuQXKM &&-BҨh xs#idNfo̝UV4sK7 ~w<m]/od2&WU(nj_xr&oj0**{0xmo{Wp^I`*M5ͽ59?xފDQxò^~WC5ޖ枳zKK+F{_oU5ww@m%/IDܺwhW4-< ooV\(--sP8VZz[ʩu-=yH"1 PUUMUU5*6ٹYTk-%!T3*2&JLML^ l5"@}&l[Nͧ~F*`OnG5Fd*c%8}`` "'Dd \ZE`9O3J#XkE,n qi9_|hFQBij<w J.DUɪΩ@M duC^ IENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/update.png0000644000175000017500000000162011351210475025413 0ustar royroyPNG  IHDRasBIT|dtEXtSoftwarewww.inkscape.org<"IDAT8mRMlTe={3m[ 3јF7`XDa M ta1L%47!«qВF;̴Yp=''\bfHe4jՄ'¸.ؖp|t&E@O*}]gQ(V02v|g'-lzW6ÌĈdǂЉ  3EW Ŋ 5>.W f43=gOk40ĭCձž _M^l8l?3]X1/23ǕgƧg^hڷuS( wD-HU/<A2e^ۏ^,}?,az  ,Hc$UgnSui&vn{'v>Eq4Akw_?߼''k'W%߼[0j>AX-/?ԩT=eYcJ奎Go@W!'$_ԓSln\{&?3 DV%r=?JeFmXv@x\qqah=Ѿ2;}З|9r-k5tT2>hMA҄ǡ̖MD, z=*];[x 8wyIuI1MN<-I7, #?[FՕD(̗ˣRmcer-0{ W1oR ]!PՑl.UvjeIENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/images/tree-folder.png0000644000175000017500000000076211351210475026347 0ustar royroyPNG  IHDRabKGDC pHYs B(xtIME 9 pPIDAT8˥MRQc*@ZA ,b lf]P3*DeIPlF"( {Gfq ͗/Q.6S2Bԝ{3x^wZ=>Lxx3}Z=B)pS @0y.?iv2̥gn:RHaf'us1RC}X?<5_8 &3 \ _KM`e "(DZah}H,C+ \t]D#1Y~uڜ>{_w"a/_8hyʛ,‡ʧIENDB`deejayd-0.10.0/extensions/deejayd-webui/chrome/skin/classic/prefs.css0000644000175000017500000000010211351210475024001 0ustar royroy .option-desc { font-style: italic; padding-left: 10px; } deejayd-0.10.0/extensions/deejayd-webui/chrome/content/0000755000175000017500000000000011354730161021246 5ustar royroydeejayd-0.10.0/extensions/deejayd-webui/chrome/content/overlay.xul0000644000175000017500000000101711351210475023456 0ustar royroy