├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── marrie.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /dist/ 3 | *.pyc 4 | *.pyo 5 | /MANIFEST 6 | /marrie.egg-info 7 | /*.wpr 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009-2010, Rafael G. Martins 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | * Neither the name of the nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 20 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 23 | TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 24 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 25 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 26 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include MANIFEST.in 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | marrie 2 | ====== 3 | 4 | ``marrie`` is a simple podcast client that runs on the Command Line Interface. 5 | It is pretty simple and just does the basic tasks: fetch and play podcast 6 | chapters listed on a feed. 7 | 8 | 9 | Installation 10 | ------------ 11 | 12 | .. _`PyPI Package Index`: http://pypi.python.org/pypi 13 | .. _Gentoo: http://www.gentoo.org/ 14 | 15 | ``marrie`` is available on the `PyPI Package Index`_, and you can install it 16 | using ``pip``:: 17 | 18 | # pip install marrie 19 | 20 | If you are a Gentoo_ user, you can install it using your favorite package 21 | manager:: 22 | 23 | # emerge -av media-sound/marrie 24 | 25 | 26 | Configuration 27 | ------------- 28 | 29 | With ``marrie`` installed, you'll need a configuration file, with your list of 30 | feeds and the commands to be used to fetch and play the podcast chapters. 31 | 32 | To create it, just run the main script without arguments:: 33 | 34 | $ marrie 35 | 36 | This command will raise an error, but will create a sample configuration file 37 | for you at ``~/.marrie``. Edit it as follows. 38 | 39 | ``[config]`` section 40 | ~~~~~~~~~~~~~~~~~~~~ 41 | 42 | The main section of the configuration file. 43 | 44 | - ``fetch_command``: The command used to fetch the chapters. The default 45 | command will use ``wget``. If you want to change it, make sure that the 46 | variables ``%(file)s`` and ``%(url)s`` are correctly used on your command. 47 | - ``player_command``: The command used to play the chapters. The default 48 | command will use ``mplayer``. If you want to change it, make sure that the 49 | variable ``%(file)s`` is correctly used on your command. 50 | - ``media_dir``: The directory where the chapters will be stored. Defaults to 51 | ``~/podcasts``. A subfolder will be created for each feed. 52 | 53 | ``[podcast]`` section 54 | ~~~~~~~~~~~~~~~~~~~~~ 55 | 56 | The section with the URLs of your feeds. Each ``key=value`` pair represents 57 | a feed URL. The key is the identifier of the feed (it will be used later 58 | to choose the feed to be used by ``marrie``) and the value is the URL of 59 | the feed. :: 60 | 61 | [podcast] 62 | my_podcast = http://example.org/feed.rss 63 | 64 | You can add as many feeds as you want. 65 | 66 | 67 | Basic usage 68 | ----------- 69 | 70 | After having ``marrie`` installed and configured, you should syncronize your 71 | feeds:: 72 | 73 | $ marrie --sync 74 | 75 | If you want to sync a single feed, you just need to provide its identifier:: 76 | 77 | $ marrie --sync my_podcast 78 | 79 | ``marrie`` will not provide any auto-sync feature. It is simple! If you want 80 | to syncronize periodically, feel free to create a cron job on your system. 81 | 82 | With a fresh list of feeds in cache, you'll want to download a chapter. If 83 | you just want to get the latest chapter published in a feed, type:: 84 | 85 | $ marrie --get my_podcast 86 | 87 | If you want to download a specific chapter, you'll need to list all the 88 | available chapters first:: 89 | 90 | $ marrie --list my_podcast 91 | ... 92 | 93 | Remote files available for "my_podcast" (reverse sorted by date): 94 | 95 | 1: chapter_100.mp3 (Fri, 19 Dec 2014 03:00:00 +0000) 96 | 2: chapter_99.mp3 (Fri, 19 Dec 2014 02:00:00 +0000) 97 | 3: chapter_98.mp3 (Fri, 19 Dec 2014 01:00:00 +0000) 98 | ... 99 | 100 | If you want to download the ``chapter_99.mp3``, just type:: 101 | 102 | $ marrie --get my_podcast 2 103 | 104 | Where ``2`` is the numeric identifier of the wanted chapter. 105 | 106 | After the download, you'll want to play the chapter. To play the latest 107 | chapter fetched by ``marrie`` (that isn't exactly the latest chapter published 108 | on the feed, it is actually the latest chapter that was downloaded to your 109 | filesystem), just type:: 110 | 111 | $ marrie --play my_podcast 112 | 113 | To play a specific chapter (after download it, obviously), you'll need to list 114 | the chapters available on your disk:: 115 | 116 | $ marrie --list my_podcast 117 | Fetched files available for "nageral" (sorted by name): 118 | 119 | 1: chapter_1.mp3 120 | 2: chapter_2.mp3 121 | 3: chapter_2.mp3 122 | ... 123 | 124 | This is the same command used to list the available chapters to download. The 125 | output will contains both lists. 126 | 127 | If you want to play ``chapter_2.mp3``, just type:: 128 | 129 | $ marrie --play my_podcast 2 130 | 131 | Where ``2`` is the numeric identifier of the wanted chapter. 132 | 133 | If you are bored with the absence of new chapters of your favorite podcast, 134 | you can ask ``marrie`` to play a random old chapter for you. It will pick any 135 | of the chapters that were previously fetched and will play for you:: 136 | 137 | $ marrie --play-random my_podcast 138 | 139 | That's it. This is pretty much everything that ``marrie`` can do for you! 140 | 141 | 142 | Contributions 143 | ------------- 144 | 145 | You can send patches to my email address: 146 | 147 | rafael@rafaelmartins.eng.br . 148 | 149 | Patches should be created against the Git repository: 150 | 151 | https://github.com/rafaelmartins/marrie/ 152 | 153 | Any patch that heavily increases the complexity of the script will be rejected! 154 | 155 | -------------------------------------------------------------------------------- /marrie.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | marrie 5 | ~~~~~~ 6 | 7 | A simple podcast client that runs on the Command Line Interface. 8 | 9 | :copyright: (c) 2010-2013 by Rafael Goncalves Martins 10 | :license: BSD, see LICENSE for more details. 11 | """ 12 | 13 | __all__ = ['MarrieError', 'Config', 'Podcast', 'Cli', 'main'] 14 | __version__ = '0.4' 15 | 16 | import argparse 17 | import codecs 18 | import feedparser 19 | import json 20 | import os 21 | import posixpath 22 | import random 23 | import shutil 24 | import subprocess 25 | import sys 26 | from collections import OrderedDict 27 | from configparser import ConfigParser 28 | 29 | config_file = '''\ 30 | [config] 31 | 32 | # Fetch command to download the files. 33 | # 34 | # Examples: 35 | # wget --limit-rate=30k -c -O %(file)s %(url)s 36 | # curl --limit-rate 30K -C - -o %(file)s %(url)s 37 | fetch_command = wget -c -O "%(file)s" "%(url)s" 38 | 39 | # Player command to play the files 40 | # 41 | # Examples: 42 | # mpv %(file)s 43 | # mpg123 %(file)s 44 | player_command = mpv %(file)s 45 | 46 | # Media directory to store the files 47 | media_dir = ~/podcasts 48 | 49 | [podcast] 50 | 51 | # List of feeds of your podcasts, in the format: 52 | # podcast_id = http://example.org/rss/feed.xml 53 | 54 | ''' 55 | 56 | 57 | class MarrieError(Exception): 58 | pass 59 | 60 | 61 | class Config(object): 62 | 63 | _raw_options = ('fetch_command', 'player_command') 64 | _options = ('media_dir',) 65 | _expanduser = ('media_dir',) 66 | 67 | def __init__(self, my_file): 68 | my_file = os.path.expanduser(my_file) 69 | if not os.path.exists(my_file): 70 | with codecs.open(my_file, 'w', encoding='utf-8') as fp: 71 | fp.write(config_file) 72 | raise MarrieError( 73 | 'Missing config file: %s. It will be created for you.' % my_file) 74 | self._cp = ConfigParser() 75 | self._cp.read(my_file) 76 | for opt in (self._raw_options + self._options): 77 | if not self._cp.has_option('config', opt): 78 | raise MarrieError('Missing needed config option: config:%s' \ 79 | % opt) 80 | 81 | def __getattr__(self, attr): 82 | opt = None 83 | if attr in self._raw_options: 84 | opt = self._cp.get('config', attr, raw=True) 85 | elif attr in self._options: 86 | opt = self._cp.get('config', attr) 87 | elif attr == 'podcast': 88 | opt = OrderedDict(self._cp.items('podcast')) 89 | if opt is None: 90 | raise AttributeError(attr) 91 | if attr in self._expanduser and not isinstance(opt, dict): 92 | return os.path.expanduser(opt) 93 | return opt 94 | 95 | 96 | class Podcast(object): 97 | 98 | def __init__(self, config, pid): 99 | self.config = config 100 | if pid not in self.config.podcast: 101 | raise MarrieError('Invalid podcast ID: %s' % pid) 102 | self.pid = pid 103 | self.media_dir = os.path.join(self.config.media_dir, self.pid) 104 | if not os.path.exists(self.media_dir): 105 | os.makedirs(self.media_dir) 106 | self._cache_file = os.path.join(self.media_dir, '.cache') 107 | self._latest_file = os.path.join(self.media_dir, '.latest') 108 | 109 | ### Subprocess wrappers ### 110 | 111 | def _fetch(self, url): 112 | filepath = os.path.join(self.media_dir, posixpath.basename(url)) 113 | part_file = filepath + '.part' 114 | rv = subprocess.call(self.config.fetch_command % \ 115 | dict(url=url, file=part_file), shell=True) 116 | if rv != os.EX_OK: 117 | raise MarrieError('Failed to download the file (%s): %i' % \ 118 | (url, rv)) 119 | try: 120 | shutil.move(part_file, filepath) 121 | except Exception as err: 122 | raise MarrieError('Failed to save the file (%s): %s' % \ 123 | (filepath, str(err))) 124 | else: 125 | self.set_latest(filepath) 126 | 127 | def _play(self, filename): 128 | filepath = os.path.join(self.media_dir, os.path.basename(filename)) 129 | rv = subprocess.call(self.config.player_command % dict(file=filepath), 130 | shell=True) 131 | if rv != os.EX_OK: 132 | raise MarrieError('Failed to play the file (%s): %i' % \ 133 | (filepath, rv)) 134 | 135 | ### Internal helpers ### 136 | 137 | def _load_cache(self): 138 | try: 139 | with codecs.open(self._cache_file, encoding='utf-8') as fp: 140 | return json.load(fp) 141 | except Exception as err: 142 | raise MarrieError('Failed to load cache (%s): %s' % \ 143 | (self._cache_file, str(err))) 144 | 145 | def _convert_oldstyle_latest(self): 146 | old_latest = os.path.join(self.media_dir, 'LATEST') 147 | if os.path.exists(old_latest): 148 | try: 149 | with codecs.open(old_latest, encoding='utf-8') as fp: 150 | os.symlink(fp.read().strip(), self._latest_file) 151 | except Exception as err: 152 | raise MarrieError('Failed to convert old-style LATEST file ' \ 153 | 'to symlink: %s' % str(err)) 154 | else: 155 | os.unlink(old_latest) 156 | 157 | ### Action helpers ### 158 | 159 | def list_chapters(self): 160 | if os.path.exists(self._cache_file): 161 | return self._load_cache() 162 | return [] 163 | 164 | def list_fetched_chapters(self): 165 | chapters = os.listdir(self.media_dir) 166 | mylist = [] 167 | for chapter in chapters: 168 | if chapter not in ('.cache', '.latest') and \ 169 | not chapter.endswith('.part'): 170 | mylist.append(os.path.join(self.media_dir, chapter)) 171 | return mylist 172 | 173 | def sync(self): 174 | self._convert_oldstyle_latest() 175 | purl = self.config.podcast[self.pid] 176 | try: 177 | rss = feedparser.parse(purl) 178 | except Exception as err: 179 | raise MarrieError('Failed to parse the feed (%s): %s' % \ 180 | (purl, str(err))) 181 | chapters = [] 182 | for entry in rss.entries: 183 | published = entry.get('published') 184 | for link in entry.links: 185 | if link.rel == 'enclosure' and hasattr(link, 'type'): 186 | category = link.type.split('/')[0] 187 | if category in ('audio', 'video'): 188 | chapters.append((link.href, published)) 189 | try: 190 | with codecs.open(self._cache_file, 'w', encoding='utf-8') as fp: 191 | json.dump(chapters, fp) 192 | except Exception as err: 193 | raise MarrieError('Failed to save cache (%s): %s' % \ 194 | (self._cache_file, str(err))) 195 | 196 | def fetch(self, chapter_id): 197 | chapters = self.list_chapters() 198 | chapter_id = chapter_id - 1 199 | try: 200 | chapter = chapters[chapter_id] 201 | if not isinstance(chapter, str): 202 | chapter = chapter[0] 203 | except IndexError: 204 | raise MarrieError('Invalid chapter identifier.') 205 | else: 206 | self._fetch(chapter) 207 | 208 | def fetch_latest(self): 209 | chapters = self.list_chapters() 210 | if len(chapters) == 0: 211 | raise MarrieError('No chapters available.') 212 | chapter = chapters[0] 213 | if not isinstance(chapter, str): 214 | chapter = chapter[0] 215 | if os.path.exists(os.path.join(self.media_dir, 216 | posixpath.basename(chapter))): 217 | raise MarrieError('No newer podcast available.') 218 | self._fetch(chapter) 219 | 220 | def play(self, chapter_id): 221 | if isinstance(chapter_id, int): 222 | chapters = self.list_fetched_chapters() 223 | chapter_id = chapter_id - 1 224 | try: 225 | chapter = chapters[chapter_id] 226 | except IndexError: 227 | raise MarrieError('Invalid chapter identifier.') 228 | else: 229 | chapter = chapter_id 230 | self._play(chapter) 231 | 232 | def play_latest(self): 233 | self._play(self.get_latest()) 234 | 235 | def play_random(self): 236 | chapters = self.list_fetched_chapters() 237 | if not len(chapters): 238 | raise MarrieError('No chapters available.') 239 | self.play(random.choice(chapters)) 240 | 241 | def get_latest(self): 242 | if not os.path.exists(self._latest_file): 243 | raise MarrieError('No podcast file registered as latest.') 244 | latest_file = os.path.realpath(self._latest_file) 245 | if not os.path.exists(latest_file): 246 | raise MarrieError('Broken symlink: %s -> %s' % (self._latest_file, 247 | latest_file)) 248 | return latest_file 249 | 250 | def set_latest(self, url): 251 | try: 252 | if os.path.exists(self._latest_file): 253 | os.unlink(self._latest_file) 254 | os.symlink(posixpath.basename(url), self._latest_file) 255 | except Exception as err: 256 | raise MarrieError('Failed to create the .latest symlink: %s' % \ 257 | str(err)) 258 | 259 | 260 | class Cli(object): 261 | 262 | _required_pid = ('get', 'play', 'play_random') 263 | 264 | def __init__(self): 265 | self.parser = argparse.ArgumentParser( 266 | description=('A simple podcast client that runs on the Command ' 267 | 'Line Interface.')) 268 | self.parser.add_argument('podcast_id', nargs='?', metavar='PODCAST_ID', 269 | help='podcast identifier, from the ' 270 | 'configuration file') 271 | self.parser.add_argument('chapter_id', nargs='?', metavar='CHAPTER_ID', 272 | help='chapter identifier, local for ' 273 | '`--play\', remote for `--get\'. This ' 274 | 'identifier is variable and is available on ' 275 | '`--list CHAPTER_ID\'', type=int) 276 | self.parser.add_argument('-v', '--version', action='version', 277 | version='%%(prog)s %s' % __version__) 278 | self.parser.add_argument('--config-file', metavar='FILE', 279 | dest='config_file', help='configuration file ' 280 | 'to be used. It will override the default ' 281 | 'file `~/.marrie\'') 282 | self.parser.add_argument('--all', action='store_true', 283 | dest='all_podcasts', 284 | help='with --get, download all configured ' 285 | 'podcasts') 286 | self.group = self.parser.add_mutually_exclusive_group() 287 | self.group.add_argument('-s', '--sync', action='store_const', 288 | dest='callback', const=self.cmd_sync, 289 | help='syncronize the local cache of podcast ' 290 | 'chapters available for download, for a given ' 291 | 'PODCAST_ID, or for all available feeds') 292 | self.group.add_argument('-l', '--list', action='store_const', 293 | dest='callback', const=self.cmd_list, 294 | help='list all the feeds available or all the ' 295 | 'chapters available for a given PODCAST_ID') 296 | self.group.add_argument('-g', '--get', action='store_const', 297 | dest='callback', const=self.cmd_get, 298 | help='fetch the latest chapter available for ' 299 | 'a given PODCAST_ID, if no CHAPTER_ID is ' 300 | 'provided') 301 | self.group.add_argument('-p', '--play', action='store_const', 302 | dest='callback', const=self.cmd_play, 303 | help='play the latest chapter fetched for ' 304 | 'a given PODCAST_ID, if no CHAPTER_ID is ' 305 | 'provided') 306 | self.group.add_argument('-r', '--play-random', action='store_const', 307 | dest='callback', const=self.cmd_play_random, 308 | help='play a random chapter from the fetched ' 309 | 'for a given PODCAST_ID') 310 | 311 | def run(self): 312 | self.args = self.parser.parse_args() 313 | self.config = Config(self.args.config_file or '~/.marrie') 314 | callback = self.args.callback 315 | if callback is None: 316 | self.parser.print_help() 317 | return os.EX_USAGE 318 | if callback.__name__.lstrip('cmd_') in self._required_pid: 319 | if self.args.podcast_id is None and \ 320 | not self.args.all_podcasts: 321 | self.parser.error('one argument is required.') 322 | if self.args.podcast_id is not None: 323 | self.podcast = Podcast(self.config, self.args.podcast_id) 324 | return callback() 325 | 326 | ### Commands ### 327 | 328 | def cmd_sync(self): 329 | if self.args.podcast_id is not None: 330 | print('Syncronizing feed "%s".' % self.args.podcast_id) 331 | self.podcast.sync() 332 | return os.EX_OK 333 | for pid in self.config.podcast: 334 | print('Syncronizing feed "%s".' % pid) 335 | podcast = Podcast(self.config, pid) 336 | podcast.sync() 337 | 338 | def cmd_list(self): 339 | if self.args.podcast_id is None: 340 | print('Podcast feeds available:') 341 | print() 342 | for pid in self.config.podcast: 343 | print(' %s - %s' % (pid, self.config.podcast[pid])) 344 | print() 345 | return os.EX_OK 346 | else: 347 | print('Fetched files available for "%s" (sorted by name):' 348 | % self.args.podcast_id) 349 | print() 350 | count = 1 351 | for filepath in self.podcast.list_fetched_chapters(): 352 | print(' %i: %s' % (count, os.path.basename(filepath))) 353 | count += 1 354 | if count == 1: 355 | print(' **** No fetched files.') 356 | print() 357 | print('Remote files available for "%s" (reverse sorted by date):' 358 | % self.args.podcast_id) 359 | print() 360 | count = 1 361 | for url in self.podcast.list_chapters(): 362 | if isinstance(url, str): 363 | print(' %i: %s' % (count, posixpath.basename(url))) 364 | else: 365 | print(' %i: %s (%s)' % (count, 366 | posixpath.basename(url[0]), 367 | url[1])) 368 | count += 1 369 | if count == 1: 370 | print(' **** No remote files. Try running this script ' 371 | 'with `--sync\'') 372 | print() 373 | 374 | def cmd_get(self): 375 | if self.args.all_podcasts: 376 | pids = self.config.podcast 377 | else: 378 | pids = [self.args.podcast_id] 379 | err = None 380 | for pid in pids: 381 | try: 382 | podcast = Podcast(self.config, pid) 383 | if self.args.chapter_id is None: 384 | print('Fetching the latest chapter available for "%s"' % pid) 385 | print() 386 | podcast.fetch_latest() 387 | else: 388 | print('Fetching chapter "%i" for "%s"' % (self.args.chapter_id, pid)) 389 | print() 390 | podcast.fetch(self.args.chapter_id) 391 | except MarrieError as e: 392 | sys.stderr.write("%s\n" % str(e)) 393 | err = e 394 | if err: 395 | sys.exit(1) 396 | 397 | def cmd_play(self): 398 | if self.args.chapter_id is None: 399 | print('Playing the latest chapter fetched for "%s"' % 400 | self.args.podcast_id) 401 | print() 402 | self.podcast.play_latest() 403 | return os.EX_OK 404 | print('Playing chapter "%i" for "%s"' % (self.args.chapter_id, 405 | self.args.podcast_id)) 406 | print() 407 | self.podcast.play(self.args.chapter_id) 408 | 409 | def cmd_play_random(self): 410 | print('Playing a random chapter available for "%s"' % 411 | self.args.podcast_id) 412 | print() 413 | self.podcast.play_random() 414 | 415 | 416 | def main(): 417 | cli = Cli() 418 | try: 419 | return cli.run() 420 | except KeyboardInterrupt: 421 | print('Interrupted', file=sys.stderr) 422 | except MarrieError as err: 423 | print('error: %s' % err, file=sys.stderr) 424 | return 1 425 | 426 | if __name__ == '__main__': 427 | sys.exit(main()) 428 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | import os 5 | 6 | current_dir = os.path.dirname(os.path.abspath(__file__)) 7 | with open(os.path.join(current_dir, 'README.rst')) as fp: 8 | long_description = fp.read() 9 | 10 | setup( 11 | name='marrie', 12 | version='0.4', 13 | license='BSD', 14 | description=('A simple podcast client that runs on the Command Line ' 15 | 'Interface.'), 16 | long_description=long_description, 17 | author='Rafael Goncalves Martins', 18 | author_email='rafael@rafaelmartins.eng.br', 19 | url='https://github.com/rafaelmartins/marrie', 20 | py_modules=['marrie'], 21 | install_requires=['feedparser >= 5.1.3'], 22 | classifiers=[ 23 | 'Development Status :: 3 - Alpha', 24 | 'Environment :: Console', 25 | 'License :: OSI Approved :: BSD License', 26 | 'Operating System :: OS Independent', 27 | 'Programming Language :: Python :: 3.6', 28 | 'Programming Language :: Python :: 3.7', 29 | 'Topic :: Multimedia :: Sound/Audio', 30 | 'Topic :: Multimedia :: Video', 31 | ], 32 | entry_points={'console_scripts': ['marrie = marrie:main']}, 33 | ) 34 | --------------------------------------------------------------------------------