pax_global_header00006660000000000000000000000064125676570000014522gustar00rootroot0000000000000052 comment=18f007bd98d3e184b28c0712b9d5fc38023819b8 mopidy-podcast-1.1.2/000077500000000000000000000000001256765700000144575ustar00rootroot00000000000000mopidy-podcast-1.1.2/.coveragerc000066400000000000000000000002011256765700000165710ustar00rootroot00000000000000[report] omit = */pyshared/* */python?.?/* */site-packages/nose/* */mopidy/* */cachetools/* */uritools/* mopidy-podcast-1.1.2/.gitignore000066400000000000000000000000661256765700000164510ustar00rootroot00000000000000*.egg-info *.pyc *.swp .coverage MANIFEST build/ dist/mopidy-podcast-1.1.2/.travis.yml000066400000000000000000000006461256765700000165760ustar00rootroot00000000000000language: python python: - "2.7_with_system_site_packages" install: - wget -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add - - sudo wget -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list - sudo apt-get update || true - sudo apt-get install mopidy - pip install . - pip install coverage coveralls script: - nosetests --with-coverage --cover-package=mopidy_podcast after_success: - coveralls mopidy-podcast-1.1.2/CHANGES.rst000066400000000000000000000022561256765700000162660ustar00rootroot000000000000001.1.2 2015-08-27 ---------------- - Pass ``episodes`` as ``list`` to ``Podcast.copy()``. 1.1.1 2015-03-25 ---------------- - Prepare for Mopidy v1.0 exact search API. 1.1.0 2014-11-22 ---------------- - Improve `podcast` URI scheme. - Report podcasts as albums when browsing. - Update dependencies. - Update unit tests. 1.0.0 2014-05-24 ---------------- - Move RSS parsing to ``FeedsDirectory``. - Support for additional podcast/episode properties. - Add `search_results` config value. - Add `uri_schemes` property to ``PodcastDirectory``. - Add `uri` property to ``Podcast`` and ``Episode``. - Support for ``. - Convert ``Podcast.Image`` and ``Episode.Enclosure`` to Mopidy model types. 0.4.0 2014-04-11 ---------------- - ``PodcastDirectory`` and models API changes. - Performance and stability improvements. - Configuration cleanup. 0.3.0 2014-03-14 ---------------- - Complete rewrite to integrate podcast directory extensions. 0.2.0 2014-02-07 ---------------- - Improve handling of iTunes tags. - Improve performance by removing feedparser. - Support searching for podcasts and episodes. 0.1.0 2014-02-01 ---------------- - Initial release. mopidy-podcast-1.1.2/LICENSE000066400000000000000000000236761256765700000155020ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS mopidy-podcast-1.1.2/MANIFEST.in000066400000000000000000000002611256765700000162140ustar00rootroot00000000000000include .coveragerc include .travis.yml include CHANGES.rst include LICENSE include MANIFEST.in include README.rst include mopidy_podcast/ext.conf recursive-include tests *.py mopidy-podcast-1.1.2/README.rst000066400000000000000000000036551256765700000161570ustar00rootroot00000000000000Mopidy-Podcast ======================================================================== Mopidy-Podcast is a Mopidy_ extension for searching and browsing podcasts. Installation ------------------------------------------------------------------------ Mopidy-Podcast can be installed using pip_ by running:: pip install Mopidy-Podcast Project Resources ------------------------------------------------------------------------ .. image:: http://img.shields.io/pypi/v/Mopidy-Podcast.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-Podcast/ :alt: Latest PyPI version .. image:: http://img.shields.io/pypi/dm/Mopidy-Podcast.svg?style=flat :target: https://pypi.python.org/pypi/Mopidy-Podcast/ :alt: Number of PyPI downloads .. image:: http://img.shields.io/travis/tkem/mopidy-podcast/master.svg?style=flat :target: https://travis-ci.org/tkem/mopidy-podcast/ :alt: Travis CI build status .. image:: http://img.shields.io/coveralls/tkem/mopidy-podcast/master.svg?style=flat :target: https://coveralls.io/r/tkem/mopidy-podcast/ :alt: Test coverage .. image:: https://readthedocs.org/projects/mopidy-podcast/badge/?version=latest&style=flat :target: https://readthedocs.org/projects/mopidy-podcast/?badge=latest :alt: Documentation Status - `Documentation`_ - `Issue Tracker`_ - `Source Code`_ - `Change Log`_ License ------------------------------------------------------------------------ Copyright (c) 2014, 2015 Thomas Kemmer. Licensed under the `Apache License, Version 2.0`_. .. _Mopidy: http://www.mopidy.com/ .. _pip: https://pip.pypa.io/en/latest/ .. _Documentation: http://mopidy-podcast.readthedocs.org/en/latest/ .. _Issue Tracker: https://github.com/tkem/mopidy-podcast/issues/ .. _Source Code: https://github.com/tkem/mopidy-podcast .. _Change Log: https://github.com/tkem/mopidy-podcast/blob/master/CHANGES.rst .. _Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 mopidy-podcast-1.1.2/docs/000077500000000000000000000000001256765700000154075ustar00rootroot00000000000000mopidy-podcast-1.1.2/docs/.gitignore000066400000000000000000000000071256765700000173740ustar00rootroot00000000000000_build mopidy-podcast-1.1.2/docs/Makefile000066400000000000000000000127341256765700000170560ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Mopidy-Podcast.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Mopidy-Podcast.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/Mopidy-Podcast" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Mopidy-Podcast" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." mopidy-podcast-1.1.2/docs/api/000077500000000000000000000000001256765700000161605ustar00rootroot00000000000000mopidy-podcast-1.1.2/docs/api/directory.rst000066400000000000000000000103321256765700000207150ustar00rootroot00000000000000Mopidy-Podcast Directory Provider ======================================================================== .. module:: mopidy_podcast.directory A :class:`PodcastDirectory` provides access to collections (also termed directories in Mopidy), podcasts and episodes, possibly via an external directory service. Each :class:`PodcastDirectory` instance manages its own private namespace of URI references, all starting with an absolute path and optionally containg a query string and fragment identifier. The URI reference :const:`/` specifies the *root* of a podcast directory, and any two podcast directories may use the same URI reference, e.g. :const:`/Music?topPodcasts`, for different resources. A :class:`PodcastDirectory` may also register one or more URI schemes via the :attr:`uri_schemes` attribute. For example, the *feeds* directory bundled with Mopidy-Podcast already registers the :const:`file`, :const:`ftp`, :const:`http` and :const:`https` schemes, assuming URIs with these schemes point to podcast RSS feeds. By returning an absolute URI with one of these schemes, a podcast directory actually delegates retrieving and parsing the respective resource to the *feeds* directory. .. autoclass:: PodcastDirectory .. autoattribute:: name Subclasses must override this attribute with a string starting with an ASCII character and consisting solely of ASCII characters, digits and hyphens (:const:`-`). .. autoattribute:: root_name Subclasses must override this attribute if they implement the :func:`browse` method. .. autoattribute:: uri_schemes Subclasses that provide support for additional URI schemes must implement the :func:`get` method for the specified schemes, and must also support absolute URIs in their :func:`browse` and :func:`search` methods. Note that the :const:`file`, :const:`ftp`, :const:`http` and :const:`https` schemes are already handled by the *feeds* directory implementation. .. automethod:: get `uri` is an absolute URI corresponding to one of the configured :attr:`uri_schemes`. If a subclass does not override :attr:`uri_schemes`, this method need not be implemented. :param string uri: podcast URI :rtype: :class:`mopidy_podcast.models.Podcast` .. automethod:: browse `uri` may be either a URI reference starting with :const:`/`, or an absolute URI with one of the configured :attr:`uri_schemes`. `limit` specifies the maximum number of objects to return, or :const:`None` if no such limit is given. Returns a list of :class:`mopidy_podcast.models.Ref` objects for the directories, podcasts and episodes at the given `uri`. :param string uri: browse URI :param int limit: browse limit :rtype: :class:`mopidy_podcast.models.Ref` iterable .. automethod:: search `uri` may be either a URI reference starting with :const:`/`, or an absolute URI with one of the configured :attr:`uri_schemes`. `terms` is a list of strings specifying search terms. `attr` may be an attribute name which must be matched, or :const:`None` if any attribute may match `terms`. `type`, if given, specifies the type of items to search for, and must be either :attr:`mopidy_podcast.models.Ref.PODCAST` or :attr:`mopidy_podcast.models.Ref.EPISODE`. `limit` specifies the maximum number of objects to return, or :const:`None` if no such limit is given. Returns a list of :class:`mopidy_podcast.models.Ref` objects for the matching podcasts and episodes found at the given `uri`. :param string uri: browse URI :param terms: search terms :type terms: list of strings :param string attr: search attribute :param string type: search result type :param int limit: search limit :rtype: :class:`mopidy_podcast.models.Ref` iterable .. automethod:: refresh This method is called right after :func:`__init__` and should be used to perform potentially time-consuming initialization, such as retrieving data from a Web site. This method may also be called periodically as a request to update any locally cached data. :param string uri: refresh URI mopidy-podcast-1.1.2/docs/api/index.rst000066400000000000000000000011171256765700000200210ustar00rootroot00000000000000Mopidy-Podcast API reference ======================================================================== Mopidy-Podcast extensions that wish to provide alternate podcast directory services need to subclass :class:`mopidy_podcast.directory.PodcastDirectory` and install and configure it with a Mopidy extension. Directory subclasses need to be added to Mopidy's registry with key :const:`podcast:directory`, e.g.:: class MyPodcastExtension(ext.Extension): def setup(self, registry): registry.add('podcast:directory', MyPodcastDirectory) .. toctree:: models directory mopidy-podcast-1.1.2/docs/api/models.rst000066400000000000000000000070231256765700000201770ustar00rootroot00000000000000Mopidy-Podcast Data Models ======================================================================== Mopidy-Podcast extends Mopidy's `data model`_ by providing additional domain-specific types. These immutable data models are used for all data transfer between Mopidy-Podcast extensions and the Mopidy-Podcast core module. The intricacies of converting from/to Mopidy's native data models are left to the Mopidy-Podcast module, so extension developers can work solely with domain objects. These models are based on Apple's -- rather informal -- `podcast specification`_, which in turn is based on `RSS 2.0`_. .. module:: mopidy_podcast.models .. autoclass:: Podcast :members: :param uri: podcast URI :type uri: string :param title: podcast title :type title: string :param link: Web site URI :type link: string :param copyright: copyright notice :type copyright: string :param language: ISO two-letter language code :type language: string :param pubdate: publication date and time :type pubdate: :class:`datetime.datetime` :param author: author name :type author: string :param block: prevent the podcast from appearing :type block: boolean :param category: main category :type category: string :param image: podcast image :type image: :class:`Image` :param explicit: whether the podcast contains explicit material :type explicit: boolean :param complete: whether the podcast is complete :type complete: boolean :param newfeedurl: new feed location :type newfeedurl: string :param subtitle: short description :type subtitle: string :param summary: long description :type summary: string :param episodes: podcast episodes :type episodes: list of :class:`Episode` .. autoclass:: Episode :members: :param uri: episode URI :type uri: string :param title: episode title :type title: string :param guid: globally unique identifier :type guid: string :param pubdate: publication date and time :type pubdate: :class:`datetime.datetime` :param author: author name :type author: string :param block: prevent the episode from appearing :type block: boolean :param image: episode image :type image: :class:`Image` :param duration: episode duration :type duration: :class:`datetime.timedelta` :param explicit: whether the podcast contains explicit material :type explicit: boolean :param order: override default ordering :type order: integer :param subtitle: short description :type subtitle: string :param summary: long description :type summary: string :param enclosure: media object :type summary: :class:`Enclosure` .. autoclass:: Image :members: :param uri: image URI :type uri: string :param title: image title :type name: string or :const:`None` :param width: image width in pixels :type width: integer or :const:`None` :param height: image height in pixels :type height: integer or :const:`None` .. autoclass:: Enclosure :members: :param uri: enclosure URI :type uri: string :param length: enclosure file size in bytes :type length: integer :param type: enclosure MIME type :type type: string .. autoclass:: Ref :members: :param uri: object URI :type uri: string :param name: object name :type name: string :param type: object type :type type: string .. _data model: http://docs.mopidy.com/en/latest/api/models/ .. _podcast specification: https://www.apple.com/itunes/podcasts/specs.html .. _RSS 2.0: http://cyber.law.harvard.edu/rss/rss.html mopidy-podcast-1.1.2/docs/conf.py000066400000000000000000000234501256765700000167120ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Mopidy-Podcast documentation build configuration file, created by # sphinx-quickstart on Fri Mar 21 06:11:33 2014. # # This file is execfile()d with the current directory set to its containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import sys, os, re # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. #sys.path.insert(0, os.path.abspath('.')) # -- Workarounds to have autodoc generate API docs ---------------------------- sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../')) class Mock(object): def __init__(self, *args, **kwargs): pass def __call__(self, *args, **kwargs): return Mock() def __or__(self, other): return Mock() @classmethod def __getattr__(self, name): if name in ('__file__', '__path__'): return '/dev/null' elif name == 'get_system_config_dirs': # glib.get_system_config_dirs() return tuple elif name == 'get_user_config_dir': # glib.get_user_config_dir() return str elif (name[0] == name[0].upper() # gst.interfaces.MIXER_TRACK_* and not name.startswith('MIXER_TRACK_') # gst.PadTemplate and not name.startswith('PadTemplate') # dbus.String() and not name == 'String'): return type(name, (), {}) else: return Mock() MOCK_MODULES = [ 'cherrypy', 'dbus', 'dbus.mainloop', 'dbus.mainloop.glib', 'dbus.service', 'glib', 'gobject', 'gst', 'pygst', 'pykka', 'pykka.actor', 'pykka.future', 'pykka.registry', 'pylast', 'ws4py', 'ws4py.messaging', 'ws4py.server', 'ws4py.server.cherrypyserver', 'ws4py.websocket', ] for mod_name in MOCK_MODULES: sys.modules[mod_name] = Mock() # -- Custom Sphinx object types ----------------------------------------------- def setup(app): from sphinx.ext.autodoc import cut_lines #app.connect(b'autodoc-process-docstring', cut_lines(4, what=['module'])) app.add_object_type( b'confval', 'confval', objname='configuration value', indextemplate='pair: %s; configuration value') def get_version(filename): content = open(filename).read() metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", content)) return metadata['version'] # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. #source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = u'Mopidy-Podcast' copyright = u'2014, Thomas Kemmer' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = get_version('../mopidy_podcast/__init__.py') # The full version, including alpha/beta/rc tags. release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. #language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['_build'] # The reST default role (used for this markup: `text`) to use for all documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). #add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'default' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. #html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". #html_title = None # A shorter title for the navigation bar. Default is the same as html_title. #html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. #html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. #html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. #html_domain_indices = True # If false, no index is generated. #html_use_index = True # If true, the index is split into individual pages for each letter. #html_split_index = False # If true, links to the reST sources are added to the pages. #html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. #html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. #html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'Mopidy-Podcastdoc' # -- Options for LaTeX output -------------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', # Additional stuff for the LaTeX preamble. #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ('index', 'Mopidy-Podcast.tex', u'Mopidy-Podcast Documentation', u'Thomas Kemmer', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. #latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # If true, show page references after internal links. #latex_show_pagerefs = False # If true, show URL addresses after external links. #latex_show_urls = False # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'mopidy-podcast', u'Mopidy-Podcast Documentation', [u'Thomas Kemmer'], 1) ] # If true, show URL addresses after external links. #man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'Mopidy-Podcast', u'Mopidy-Podcast Documentation', u'Thomas Kemmer', 'Mopidy-Podcast', 'Mopidy extension for searching and streaming podcasts.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. #texinfo_appendices = [] # If false, no module index is generated. #texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' mopidy-podcast-1.1.2/docs/config.rst000066400000000000000000000056141256765700000174140ustar00rootroot00000000000000Configuration ======================================================================== This section describes the configuration values that affect Mopidy-Podcast's core functions and the bundled :ref:`feeds ` directory provider. For configuring external :ref:`extensions `, please refer to their respective documentation. General Configuration Values ------------------------------------------------------------------------ .. confval:: podcast/browse_limit The maximum number of browse results and podcast episodes to show. .. confval:: podcast/search_limit The maximum number of search results to show. .. confval:: podcast/search_details Whether to return fully detailed search results. If set to ``off`` (the default), only a podcast's or episode's name and URI will appear in search results, similar to what is shown when browsing. If set to ``on``, search results will also include meta information such as author name, track counts and lengths, publication dates and images, if available. However, this will slow down searching tremendously, so if you enable this you might consider decreasing :confval:`podcast/search_limit`. .. confval:: podcast/update_interval The directory update interval in seconds, i.e. how often locally stored information should be refreshed. .. _feeds: Feeds Directory Configuration Values ------------------------------------------------------------------------ This section lists configuration values specific to the *feeds* podcast directory provider that is bundled with Mopidy-Podcast. If you do not plan to use the *feeds* directory, these can be safely ignored. .. confval:: podcast/feeds A list of podcast RSS feed URLs to subscribe to. Individual URLs must be seperated by either newlines or commas, with newlines preferred. To subscribe to some podcasts from NPR_'s highly recommended `All Songs Considered`_ program:: feeds = http://www.npr.org/rss/podcast.php?id=510019 http://www.npr.org/rss/podcast.php?id=510253 http://www.npr.org/rss/podcast.php?id=510306 .. confval:: podcast/feeds_root_name The directory name shown for browsing subscribed feeds. .. confval:: podcast/feeds_cache_size The maximum number of podcast RSS feeds that should be cached. .. confval:: podcast/feeds_cache_ttl The feeds cache *time to live*, i.e. the number of seconds after which a cached feed expires and needs to be reloaded. .. confval:: podcast/feeds_timeout The HTTP request timeout when retrieving RSS feeds, in seconds. Default Configuration ------------------------------------------------------------------------ For reference, this is the default configuration shipped with Mopidy-Podcast release |release|: .. literalinclude:: ../mopidy_podcast/ext.conf :language: ini .. _NPR: http://www.npr.org/ .. _All Songs Considered: http://www.npr.org/blogs/allsongs/ mopidy-podcast-1.1.2/docs/extensions.rst000066400000000000000000000015511256765700000203420ustar00rootroot00000000000000.. _extensions: Mopidy-Podcast Extensions ======================================================================== Here you can find a list of external packages that extend Mopidy-Podcast with additional functionality. Mopidy-Podcast-iTunes ------------------------------------------------------------------------ https://github.com/tkem/mopidy-podcast-itunes Mopidy-Podcast-iTunes is a Mopidy-Podcast extension for searching and browsing podcasts on the `Apple iTunes Store`_. Mopidy-Podcast-gpodder.net ------------------------------------------------------------------------ https://github.com/tkem/mopidy-podcast-gpodder Mopidy-Podcast-gpodder.net is a Mopidy-Podcast extension for searching and browsing podcasts using the `gpodder.net`_ Web service. .. _Apple iTunes Store: https://itunes.apple.com/genre/podcasts/id26 .. _gpodder.net: http://gpodder.net mopidy-podcast-1.1.2/docs/index.rst000066400000000000000000000032451256765700000172540ustar00rootroot00000000000000Mopidy-Podcast ======================================================================== Mopidy-Podcast is a Mopidy_ extension for searching and browsing podcasts. Mopidy-Podcast extends Mopidy's browsing and searching capabilities to the podcasting domain by integrating podcasts and their episodes with Mopidy's native `data model`_. More specifically, podcasts are mapped to *albums*, while individual podcast episodes are shown as *tracks* in Mopidy. Podcast and episode metadata is retained and converted to Mopidy's native types where applicable. An episode's audio stream is then played using Mopidy's streaming_ extension. To use Mopidy-Podcast, you first have to configure how to find and access podcasts: - If you already have some favorite podcasts published as RSS feeds, you can subscribe to them by adding their feed URLs to :confval:`podcast/feeds`. RSS feeds will get updated on a regular basis, so you can always browse and search for the latest episodes. - You can also install one of several -- well, actually two, at the time -- :ref:`extensions`, which let you access external podcast directory services such as the `Apple iTunes Store`_. Note that both methods can be combined, i.e. you can configure a list of your favorite RSS feeds for regular -- and potentially faster -- access, while also installing one or more extensions for exploring. .. toctree:: :maxdepth: 2 install config extensions api/index license .. _Mopidy: http://www.mopidy.com/ .. _data model: http://docs.mopidy.com/en/latest/api/models/ .. _streaming: http://docs.mopidy.com/en/latest/ext/stream/ .. _Apple iTunes Store: https://itunes.apple.com/genre/podcasts/id26 mopidy-podcast-1.1.2/docs/install.rst000066400000000000000000000003311256765700000176040ustar00rootroot00000000000000Installation ======================================================================== Mopidy-Podcast can be installed using pip_ by running:: pip install Mopidy-Podcast .. _pip: https://pip.pypa.io/en/latest/ mopidy-podcast-1.1.2/docs/license.rst000066400000000000000000000012341256765700000175630ustar00rootroot00000000000000License ======================================================================== Mopidy-Podcast is Copyright (c) 2014 Thomas Kemmer. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this software except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. mopidy-podcast-1.1.2/docs/requirements.txt000066400000000000000000000000421256765700000206670ustar00rootroot00000000000000Sphinx >= 1.0 Mopidy >= 0.18 glib mopidy-podcast-1.1.2/mopidy_podcast/000077500000000000000000000000001256765700000174755ustar00rootroot00000000000000mopidy-podcast-1.1.2/mopidy_podcast/__init__.py000066400000000000000000000026771256765700000216220ustar00rootroot00000000000000from __future__ import unicode_literals import os from mopidy import config, ext __version__ = '1.1.2' class Extension(ext.Extension): dist_name = 'Mopidy-Podcast' ext_name = 'podcast' version = __version__ def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') return config.read(conf_file) def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema['browse_limit'] = config.Integer(optional=True, minimum=1) schema['search_limit'] = config.Integer(optional=True, minimum=1) schema['search_details'] = config.Boolean() schema['update_interval'] = config.Integer(minimum=60) # feeds directory provider config schema['feeds'] = config.List(optional=True) schema['feeds_root_name'] = config.String() schema['feeds_cache_size'] = config.Integer(minimum=1) schema['feeds_cache_ttl'] = config.Integer(minimum=1) schema['feeds_timeout'] = config.Integer(optional=True, minimum=1) # no longer used/needed schema['root_name'] = config.Deprecated() return schema def setup(self, registry): from .backend import PodcastBackend from .feeds import FeedsDirectory registry.add('backend', PodcastBackend) registry.add('podcast:directory', FeedsDirectory) PodcastBackend.directories = registry['podcast:directory'] mopidy-podcast-1.1.2/mopidy_podcast/backend.py000066400000000000000000000037001256765700000214360ustar00rootroot00000000000000from __future__ import unicode_literals import logging import pykka import threading from mopidy import backend from . import Extension from .controller import PodcastDirectoryController from .library import PodcastLibraryProvider from .playback import PodcastPlaybackProvider logger = logging.getLogger(__name__) class PodcastBackend(pykka.ThreadingActor, backend.Backend): directories = [] @property def uri_schemes(self): schemes = ['podcast'] for cls in self.directories: schemes.extend('podcast+' + scheme for scheme in cls.uri_schemes) return schemes def __init__(self, config, audio): super(PodcastBackend, self).__init__() directories = [cls(config) for cls in self.directories] logger.info('Starting %s directories: %s', Extension.dist_name, ', '.join(d.__class__.__name__ for d in directories)) self.directory = PodcastDirectoryController(directories) self.library = PodcastLibraryProvider(config, backend=self) self.playback = PodcastPlaybackProvider(audio=audio, backend=self) self.lock = threading.RLock() interval = config[Extension.ext_name]['update_interval'] def update(): logger.info('Refreshing %s directories', Extension.dist_name) self.directory.refresh(async=True) self.timer = self._timer(interval, update) update() def on_stop(self): with self.lock: self.timer.cancel() logger.info('Stopping %s directories', Extension.dist_name) self.directory.stop() logger.debug('Stoppped %s directories', Extension.dist_name) def _timer(self, interval, func): if not interval or not func: return None with self.lock: if self.actor_stopped.is_set(): return None timer = threading.Timer(interval, func) timer.start() return timer mopidy-podcast-1.1.2/mopidy_podcast/controller.py000066400000000000000000000103301256765700000222270ustar00rootroot00000000000000from __future__ import unicode_literals import logging import pykka import uritools from .models import Ref logger = logging.getLogger(__name__) def transform(base, model): ref = base.transform(model.uri, strict=True) if ref.scheme != base.scheme: return model.copy(uri='podcast+' + ref.geturi()) else: return model.copy(uri=ref.geturi()) class PodcastDirectoryActor(pykka.ThreadingActor): def __init__(self, directory): super(PodcastDirectoryActor, self).__init__() self.directory = directory def get(self, uri): return self.directory.get(uri) def browse(self, uri, limit=None): return self.directory.browse(uri, limit) def search(self, uri, terms, attr=None, type=None, limit=None): return self.directory.search(uri, terms, attr, type, limit) def refresh(self, uri=None): return self.directory.refresh(uri) def on_start(self): logger.debug('Starting %s', self.directory) def on_stop(self): logger.debug('Stopping %s', self.directory) class PodcastDirectoryController(object): root_uri = 'podcast:' def __init__(self, directories): self.root_directories = [] self._base_uris = {} self._proxies = {} self._schemes = {} for d in directories: uri = 'podcast://%s/' % d.name if d.root_name: name = d.root_name self.root_directories.append(Ref.directory(uri=uri, name=name)) self._base_uris[d.name] = uritools.urisplit(uri) proxy = PodcastDirectoryActor.start(d).proxy() self._proxies[d.name] = proxy self._schemes.update(dict.fromkeys(d.uri_schemes, proxy)) def get(self, uri): uribase, uriref, proxy = self._lookup(uri) podcast = proxy.get(uriref).get() # FIXME: episode w/o uri shouldn't be possible... episodes = [transform(uribase, e) for e in podcast.episodes if e.uri] return podcast.copy(uri=uri, episodes=episodes) def browse(self, uri, limit=None): if uri and uri != self.root_uri: uribase, uriref, proxy = self._lookup(uri) refs = proxy.browse(uriref, limit).get() or [] return [transform(uribase, ref) for ref in refs] else: return self.root_directories def search(self, uri, terms, attr=None, type=None, limit=None): if uri and uri != self.root_uri: uribase, uriref, proxy = self._lookup(uri) refs = proxy.search(uriref, terms, attr, type, limit).get() or [] return [transform(uribase, ref) for ref in refs] else: return self._search(terms, attr, type, limit) def refresh(self, uri=None, async=False): if uri and uri != self.root_uri: _, uriref, proxy = self._lookup(uri) futures = [proxy.refresh(uriref)] else: futures = [p.refresh() for p in self._proxies.values()] if not async: pykka.get_all(futures) def stop(self): for proxy in self._proxies.values(): proxy.actor_ref.stop() def _lookup(self, uri): parts = uritools.urisplit(uri) if parts.scheme == 'podcast': ref = uritools.uriunsplit((None, None) + parts[2:]) proxy = self._proxies[parts.authority] else: scheme = parts.scheme.partition('+')[2] ref = uritools.uriunsplit((scheme,) + parts[1:]) proxy = self._schemes[scheme] return (parts, ref, proxy) def _search(self, terms, attr=None, type=None, limit=None): results = [] for name, proxy in self._proxies.items(): results.append((name, proxy.search('/', terms, attr, type, limit))) # merge results, filter duplicates by uri merged = {} for name, future in results: try: refs = future.get() or [] base = self._base_uris[name] results = [transform(base, ref) for ref in refs] merged.update((ref.uri, ref) for ref in results) except Exception as e: logger.error('Searching "%s" failed: %s', name, e) return merged.values()[:limit] # arbitrary selection mopidy-podcast-1.1.2/mopidy_podcast/directory.py000066400000000000000000000016211256765700000220530ustar00rootroot00000000000000from __future__ import unicode_literals class PodcastDirectory(object): """Podcast directory provider.""" name = None """Name of the podcast directory implementation.""" root_name = None """Name of the root directory for browsing.""" uri_schemes = [] """List of URI schemes the directory can handle.""" def __init__(self, config): pass def get(self, uri): """Return a podcast for the given `uri`.""" raise NotImplementedError def browse(self, uri, limit=None): """Browse directories, podcasts and episodes at the given `uri`.""" raise NotImplementedError def search(self, uri, terms, attr=None, type=None, limit=None): """Search podcasts and episodes at the given `uri` for `terms`.""" raise NotImplementedError def refresh(self, uri=None): """Refresh the podcast directory.""" pass mopidy-podcast-1.1.2/mopidy_podcast/ext.conf000066400000000000000000000013341256765700000211450ustar00rootroot00000000000000[podcast] enabled = true # maximum number of browse results browse_limit = 100 # maximum number of search results search_limit = 20 # whether to return fully detailed search results; if set to "off", # only name and URI will be returned for increased performance search_details = off # directory update interval in seconds update_interval = 86400 # an optional list of podcast RSS feed URLs to subscribe to; URLs need # to be seperated by commas or newlines feeds = # user-friendly name for browsing subscribed RSS feeds feeds_root_name = Subscribed Feeds # number of RSS feeds to cache feeds_cache_size = 32 # feeds cache time-to-live in seconds feeds_cache_ttl = 3600 # HTTP request timeout in seconds feeds_timeout = 10 mopidy-podcast-1.1.2/mopidy_podcast/feeds.py000066400000000000000000000131241256765700000211360ustar00rootroot00000000000000from __future__ import unicode_literals import cachetools import collections import contextlib import datetime import itertools import logging import operator import uritools import urllib2 from . import Extension from .directory import PodcastDirectory from .models import Episode, Ref from .rssfeed import parse_rss _PODCAST_SEARCH_ATTRS = ('title', 'author', 'category', 'subtitle', 'summary') _EPISODE_SEARCH_ATTRS = ('title', 'author', 'subtitle', 'summary') logger = logging.getLogger(__name__) def _feeds_cache(feeds_cache_size=None, feeds_cache_ttl=None, **kwargs): """Cache factory""" if feeds_cache_size is None: return None # mainly for testing/debugging elif feeds_cache_ttl is None: return cachetools.LRUCache(feeds_cache_size) else: return cachetools.TTLCache(feeds_cache_size, feeds_cache_ttl) def _pubdate(model): return model.pubdate or datetime.datetime.min class FeedsDirectory(PodcastDirectory): IndexEntry = collections.namedtuple('Entry', 'ref index pubdate') name = 'feeds' uri_schemes = ['file', 'ftp', 'http', 'https'] def __init__(self, config): super(FeedsDirectory, self).__init__(config) self._config = ext_config = config[Extension.ext_name] self._cache = _feeds_cache(**ext_config) self._podcasts = [] self._episodes = [] self.root_name = ext_config['feeds_root_name'] # for browsing @cachetools.cachedmethod(operator.attrgetter('_cache')) def get(self, uri): uri, _ = uritools.uridefrag(uri) # remove fragment, if any timeout = self._config['feeds_timeout'] with contextlib.closing(urllib2.urlopen(uri, timeout=timeout)) as src: return parse_rss(src) # FIXME: check Content-Type header? def browse(self, uri, limit=None): if not uri or uri == '/': return [e.ref for e in self._podcasts] else: return self._browse_episodes(uri, limit) def search(self, uri, terms, attr=None, type=None, limit=None): if not uri or uri == '/': return self._search_index(terms, attr, type, limit) elif type is None or type == Ref.EPISODE: return self._search_episodes(uri, terms, attr, limit) else: return None def refresh(self, uri=None): podcasts = {p.ref.uri: p for p in self._podcasts} episodes = {e.ref.uri: e for e in self._episodes} self._cache.clear() for feedurl in self._config['feeds']: try: podcast = self.get(feedurl) except Exception as e: logger.error('Error loading podcast %s: %s', feedurl, e) continue # keep existing entry try: p = self._index_podcast(podcast) for episode in podcast.episodes: e = self._index_episode(episode, p.index) episodes[e.ref.uri] = e podcasts[p.ref.uri] = p except Exception as e: logger.error('Error indexing podcast %s: %s', feedurl, e) self._podcasts = sorted(podcasts.values(), key=_pubdate, reverse=True) self._episodes = sorted(episodes.values(), key=_pubdate, reverse=True) def _browse_episodes(self, uri, limit=None): refs = [] for e in self.get(uri).episodes: if limit and len(refs) >= limit: break if not e.uri: # TODO: filter by media type? continue refs.append(Ref.episode(uri=e.uri, name=e.title)) return refs def _search_episodes(self, uri, terms, attr=None, limit=None): if attr is None: getter = operator.attrgetter(*_EPISODE_SEARCH_ATTRS) elif hasattr(Episode, attr): getter = operator.attrgetter(attr) else: return None terms = map(unicode.lower, terms) refs = [] for e in self.get(uri).episodes: if limit and len(refs) >= limit: break if not e.uri: # TODO: filter by media type? continue if all(term in unicode(getter(e) or '').lower() for term in terms): refs.append(Ref.episode(uri=e.uri, name=e.title)) return refs def _search_index(self, terms, attr=None, type=None, limit=None): if type is None: entries = itertools.chain(self._podcasts, self._episodes) elif type == Ref.PODCAST: entries = iter(self._podcasts) elif type == Ref.EPISODE: entries = iter(self._episodes) else: return None terms = map(unicode.lower, terms) refs = [] for e in entries: if limit and len(refs) >= limit: break if all(term in e.index.get(attr, '') for term in terms): refs.append(e.ref) return refs def _index_podcast(self, podcast): ref = Ref.podcast(uri=podcast.uri, name=podcast.title) index = {} for name in _PODCAST_SEARCH_ATTRS: if getattr(podcast, name): index[name] = getattr(podcast, name).lower() index[None] = '\n'.join(index.values()) return self.IndexEntry(ref, index, podcast.pubdate) def _index_episode(self, episode, defaults): ref = Ref.episode(uri=episode.uri, name=episode.title) index = defaults.copy() for name in _EPISODE_SEARCH_ATTRS: if getattr(episode, name): index[name] = getattr(episode, name).lower() index[None] = '\n'.join(index.values()) return self.IndexEntry(ref, index, episode.pubdate) mopidy-podcast-1.1.2/mopidy_podcast/library.py000066400000000000000000000155241256765700000215220ustar00rootroot00000000000000from __future__ import unicode_literals import logging import operator import uritools from mopidy import backend from mopidy.models import Album, Artist, Track, SearchResult from . import Extension from .models import Ref from .query import Query _QUERY_MAPPING = { 'album': ('title', Ref.PODCAST), 'albumartist': ('author', Ref.PODCAST), 'artist': ('author', Ref.EPISODE), 'comment': ('subtitle', Ref.EPISODE), 'date': ('pubdate', None), 'genre': ('category', Ref.PODCAST), 'track_name': ('title', Ref.EPISODE), 'any': (None, None) } logger = logging.getLogger(__name__) class PodcastLibraryProvider(backend.LibraryProvider): root_directory = Ref.directory(uri='podcast:', name='Podcasts') def __init__(self, config, backend): super(PodcastLibraryProvider, self).__init__(backend) self._config = config[Extension.ext_name] self._lookup = {} def lookup(self, uri): try: return [self._lookup[uri]] except KeyError: logger.debug('Podcast lookup cache miss: %s', uri) try: base, fragment = uritools.uridefrag(uri) if fragment: self._lookup = {t.uri: t for t in self._tracks(base)} return [self._lookup[uri]] else: tracks = self._tracks(base, self._config['browse_limit']) self._lookup = {t.uri: t for t in tracks} return tracks except Exception as e: logger.error('Podcast lookup failed for %s: %s', uri, e) return [] def browse(self, uri): try: if not uri: return [self.root_directory] elif uri == self.root_directory.uri: return self.backend.directory.root_directories else: return self._browse(uri, self._config['browse_limit']) except Exception as e: logger.error('Browsing podcasts failed for %s: %s', uri, e) return None def find_exact(self, query=None, uris=None): if not query: return None try: q = Query(query, exact=True) return self._search(q, uris, self._config['search_limit']) except Exception as e: logger.error('Finding podcasts failed: %s', e) return None def search(self, query=None, uris=None, exact=False): if exact: return self.find_exact(query, uris) if not query: return None try: q = Query(query, exact=False) return self._search(q, uris, self._config['search_limit']) except Exception as e: logger.error('Searching podcasts failed: %s', e) return None def _browse(self, uri, limit=None): refs = [] for ref in self.backend.directory.browse(uri, limit): if ref.type == Ref.PODCAST: refs.append(Ref.album(uri=ref.uri, name=ref.name)) elif ref.type == Ref.EPISODE: refs.append(Ref.track(uri=ref.uri, name=ref.name)) else: refs.append(ref) return refs def _search(self, query, uris=None, limit=None): # only single search attribute supported if len(query) != 1 or query.keys()[0] not in _QUERY_MAPPING: return None attr, type = _QUERY_MAPPING[query.keys()[0]] terms = [v for values in query.values() for v in values] logger.debug('Searching %s.%s for %r in %r', type, attr, terms, uris) # merge results for multiple search uris results = [] directory = self.backend.directory for uri in (uris or [directory.root_uri]): nleft = limit - len(results) if limit else None results.extend(directory.search(uri, terms, attr, type, nleft)) # convert refs to albums and tracks if query.exact or self._config['search_details']: albums, tracks = self._load_search_results(results) else: albums, tracks = self._search_results(results) # filter results for exact queries if query.exact: albums = filter(query.match_album, albums) tracks = filter(query.match_track, tracks) return SearchResult(albums=albums, tracks=tracks) def _load_search_results(self, refs): directory = self.backend.directory albums = [] tracks = [] # sort by uri to improve lookup cache performance for ref in sorted(refs, key=operator.attrgetter('uri')): try: if ref.type == Ref.PODCAST: albums.append(self._album(directory.get(ref.uri))) elif ref.type == Ref.EPISODE: tracks.extend(self.lookup(ref.uri)) else: logger.warn('Invalid podcast search result: %r', ref) except Exception as e: logger.warn('Skipping search result %s: %s', ref.uri, e) return (albums, tracks) def _search_results(self, refs): albums = [] tracks = [] for ref in refs: if ref.type == Ref.PODCAST: albums.append(Album(uri=ref.uri, name=ref.name)) elif ref.type == Ref.EPISODE: tracks.append(Track(uri=ref.uri, name=ref.name)) else: logger.warn('Invalid podcast search result: %r', ref) return (albums, tracks) def _tracks(self, uri, limit=None): podcast = self.backend.directory.get(uri) album = self._album(podcast) tracks = [] for index, episode in enumerate(podcast.episodes[:limit], start=1): kwargs = { 'uri': episode.uri, 'name': episode.title, 'artists': album.artists, # default 'album': album, 'genre': podcast.category, 'comment': episode.subtitle, 'track_no': index } if episode.author: kwargs['artists'] = [Artist(name=episode.author)] if episode.pubdate: kwargs['date'] = episode.pubdate.date().isoformat() if episode.duration: kwargs['length'] = int(episode.duration.total_seconds() * 1000) if episode.subtitle: kwargs['comment'] = episode.subtitle tracks.append(Track(**kwargs)) return tracks def _album(self, podcast): kwargs = { 'uri': podcast.uri, 'name': podcast.title, 'num_tracks': len(podcast.episodes) } if podcast.author: kwargs['artists'] = [Artist(name=podcast.author)] if podcast.pubdate: kwargs['date'] = podcast.pubdate.date().isoformat() if podcast.image and podcast.image.uri: kwargs['images'] = [podcast.image.uri] return Album(**kwargs) mopidy-podcast-1.1.2/mopidy_podcast/models.py000066400000000000000000000110411256765700000213270ustar00rootroot00000000000000from __future__ import unicode_literals import mopidy.models class Image(mopidy.models.ImmutableObject): """Mopidy model type to represent a podcast's image.""" uri = None """The image's URI.""" title = None """The image's title.""" width = None """The image's width in pixels.""" height = None """The image's height in pixels.""" class Enclosure(mopidy.models.ImmutableObject): """Mopidy model type to represent an episode's media object.""" uri = None """The URI of the media object.""" length = None """The enclosure's file size in bytes.""" type = None """The MIME type of the enclosure, e.g. :const:`audio/mpeg`.""" class Podcast(mopidy.models.ImmutableObject): """Mopidy model type to represent a podcast.""" uri = None """The podcast URI. For podcasts distributed as RSS feeds, the podcast URI is the URL from which the RSS feed can be retrieved. To distinguish between podcast and episode URIs, the podcast URI *MUST NOT* contain a fragment identifier. """ title = None """The podcast's title.""" link = None """The URL of the HTML website corresponding to the podcast.""" copyright = None """The podcast's copyright notice.""" language = None """The podcast's ISO two-letter language code.""" pubdate = None """The podcast's publication date and time as an instance of :class:`datetime.datetime`. """ author = None """The podcast's author's name.""" block = None """Prevent a podcast from appearing in the directory.""" category = None """The main category of the podcast.""" image = None """An image to be displayed with the podcast as an instance of :class:`Image`. """ explicit = None """Indicates whether the podcast contains explicit material.""" complete = None """Indicates completion of the podcast.""" newfeedurl = None """Used to inform of new feed URL location.""" subtitle = None """A short description of the podcast.""" summary = None """A description of the podcast, up to 4000 characters long.""" episodes = tuple() """The podcast's episodes as a read-only :class:`tuple` of :class:`Episode` instances. """ def __init__(self, *args, **kwargs): self.__dict__['episodes'] = tuple( kwargs.pop('episodes', None) or [] ) super(Podcast, self).__init__(*args, **kwargs) class Episode(mopidy.models.ImmutableObject): """Mopidy model type to represent a podcast episode.""" uri = None """The episode URI. If the episode contains an enclosure, the episode URI *MUST* consist of the associated podcast URI with the enclosure URL appended as a fragment identifier. """ title = None """The episode's title.""" guid = None """A string that uniquely identifies the episode.""" pubdate = None """The episode's publication date and time as an instance of :class:`datetime.datetime`. """ author = None """The episode's author's name.""" block = None """Prevent an episode from appearing in the directory.""" image = None """An image to be displayed with the episode as an instance of :class:`Image`. """ duration = None """The episode's duration as an instance of :class:`datetime.timedelta`. """ explicit = None """Indicates whether the episode contains explicit material.""" order = None """Overrides the default ordering of episodes.""" subtitle = None """A short description of the episode.""" summary = None """A description of the episode, up to 4000 characters long.""" enclosure = None """The media object, e.g. the audio stream, attached to the episode as an instance of :class:`Enclosure`. """ class Ref(mopidy.models.Ref): """Extends :class:`mopidy.models.Ref` to provide factory methods and type constants for :class:`Podcast` and :class:`Episode`. """ PODCAST = 'podcast' """Constant used for comparison with the :attr:`type` field.""" EPISODE = 'episode' """Constant used for comparison with the :attr:`type` field.""" @classmethod def podcast(cls, **kwargs): """Create a :class:`Ref` with :attr:`type` :attr:`PODCAST`.""" kwargs['type'] = Ref.PODCAST return cls(**kwargs) @classmethod def episode(cls, **kwargs): """Create a :class:`Ref` with :attr:`type` :attr:`EPISODE`.""" kwargs['type'] = Ref.EPISODE return cls(**kwargs) mopidy-podcast-1.1.2/mopidy_podcast/playback.py000066400000000000000000000005031256765700000216330ustar00rootroot00000000000000from __future__ import unicode_literals import uritools from mopidy import backend class PodcastPlaybackProvider(backend.PlaybackProvider): def change_track(self, track): track = track.copy(uri=uritools.uridefrag(track.uri).fragment) return super(PodcastPlaybackProvider, self).change_track(track) mopidy-podcast-1.1.2/mopidy_podcast/query.py000066400000000000000000000143131256765700000212160ustar00rootroot00000000000000from __future__ import unicode_literals import collections from mopidy.models import Artist, Album, Track QUERY_FIELDS = { 'uri', 'track_name', 'album', 'artist', 'composer', 'performer', 'albumartist', 'genre', 'date', 'comment', 'track_no', 'any' } DEFAULT_FILTERS = dict.fromkeys(QUERY_FIELDS, lambda q, v: False) TRACK_FILTERS = [ dict( DEFAULT_FILTERS, uri=lambda q, track: bool( track.uri and q in track.uri.lower() ), track_name=lambda q, track: bool( track.name and q in track.name.lower() ), album=lambda q, track: bool( track.album and track.album.name and q in track.album.name.lower() ), artist=lambda q, track: any( a.name and q in a.name.lower() for a in track.artists ), composer=lambda q, track: any( a.name and q in a.name.lower() for a in track.composers ), performer=lambda q, track: any( a.name and q in a.name.lower() for a in track.performers ), albumartist=lambda q, track: track.album and any( a.name and q in a.name.lower() for a in track.album.artists ), genre=lambda q, track: bool( track.genre and q in track.genre.lower() ), date=lambda q, track: bool( track.date and track.date.startswith(q) ), comment=lambda q, track: bool( track.comment and q in track.comment.lower(), ), track_no=lambda q, track: q.isdigit() and int(q) == track.track_no ), dict( DEFAULT_FILTERS, uri=lambda q, track: q == track.uri, track_name=lambda q, track: q == track.name, album=lambda q, track: track.album and q == track.album.name, artist=lambda q, track: any( q == a.name for a in track.artists ), composer=lambda q, track: any( q == a.name for a in track.composers ), performer=lambda q, track: any( q == a.name for a in track.performers ), albumartist=lambda q, track: track.album and any( q == a.name for a in track.album.artists ), genre=lambda q, track: q == track.genre, date=lambda q, track: q == track.date, comment=lambda q, track: q == track.comment, track_no=lambda q, track: q.isdigit() and int(q) == track.track_no ) ] ALBUM_FILTERS = [ dict( DEFAULT_FILTERS, uri=lambda q, album: bool(album.uri and q in album.uri.lower()), album=lambda q, album: bool(album.name and q in album.name.lower()), artist=lambda q, album: any( a.name and q in a.name.lower() for a in album.artists ), albumartist=lambda q, album: any( a.name and q in a.name.lower() for a in album.artists ), date=lambda q, album: bool( album.date and album.date.startswith(q) ) ), dict( DEFAULT_FILTERS, uri=lambda q, album: q == album.uri, album=lambda q, album: q == album.name, artist=lambda q, album: any( q == a.name for a in album.artists ), albumartist=lambda q, album: any( q == a.name for a in album.artists ), date=lambda q, album: q == album.date ) ] ARTIST_FILTERS = [ dict( DEFAULT_FILTERS, uri=lambda q, artist: bool(artist.uri and q in artist.uri.lower()), artist=lambda q, artist: bool(artist.name and q in artist.name.lower()) ), dict( DEFAULT_FILTERS, uri=lambda q, artist: q == artist.uri, artist=lambda q, artist: q == artist.name ) ] class Query(collections.Mapping): _track_filter = None _album_filter = None _artist_filter = None def __init__(self, terms, exact=False): if not terms: raise LookupError('Empty query not allowed') self.terms = {} self.exact = exact for field, values in terms.iteritems(): if field not in QUERY_FIELDS: raise LookupError('Invalid query field %r' % field) if isinstance(values, basestring): values = [values] if not (values and all(values)): raise LookupError('Missing query value for %r' % field) if exact: self.terms[field] = values else: self.terms[field] = [v.lower() for v in values] def __getitem__(self, key): return self.terms[key] def __iter__(self): return iter(self.terms) def __len__(self): return len(self.terms) def match(self, model): if isinstance(model, Track): return self.match_track(model) elif isinstance(model, Album): return self.match_album(model) elif isinstance(model, Artist): return self.match_artist(model) else: raise TypeError('Invalid model type: %s' % type(model)) def match_artist(self, artist): if not self._artist_filter: self._artist_filter = self._filter(ARTIST_FILTERS[self.exact]) return self._artist_filter(artist) def match_album(self, album): if not self._album_filter: self._album_filter = self._filter(ALBUM_FILTERS[self.exact]) return self._album_filter(album) def match_track(self, track): if not self._track_filter: self._track_filter = self._filter(TRACK_FILTERS[self.exact]) return self._track_filter(track) def _filter(self, filtermap): from functools import partial filters = [] for field, values in self.terms.iteritems(): filters.extend(partial(filtermap[field], v) for v in values) def func(model): return all(f(model) for f in filters) return func # setup 'any' filters def _any_filter(filtermap): filters = filtermap.values() def any_filter(q, v): return any(f(q, v) for f in filters) return any_filter for i in (False, True): TRACK_FILTERS[i]['any'] = _any_filter(TRACK_FILTERS[i]) ALBUM_FILTERS[i]['any'] = _any_filter(ALBUM_FILTERS[i]) ARTIST_FILTERS[i]['any'] = _any_filter(ARTIST_FILTERS[i]) mopidy-podcast-1.1.2/mopidy_podcast/rssfeed.py000066400000000000000000000105411256765700000215030ustar00rootroot00000000000000from __future__ import unicode_literals import datetime import email.utils import logging import re import xml.etree.ElementTree from .models import Podcast, Episode, Image, Enclosure DURATION_RE = re.compile(r""" (?: (?:(?P\d+):)? (?P\d+): )? (?P\d+) """, flags=re.VERBOSE) NAMESPACES = { 'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd' } logger = logging.getLogger(__name__) def to_bool(e): return e.text.strip().lower() == 'yes' if e.text else None def to_int(e): return int(e.text) if e.text and e.text.isdigit() else None def to_datetime(e): try: timestamp = email.utils.mktime_tz(email.utils.parsedate_tz(e.text)) except AttributeError: return None except TypeError: return None return datetime.datetime.utcfromtimestamp(timestamp) def to_timedelta(e): try: groups = DURATION_RE.match(e.text).groupdict('0') except AttributeError: return None except TypeError: return None return datetime.timedelta(**{k: int(v) for k, v in groups.items()}) def to_category(e): return e.get('text') def to_image(e): kwargs = {} # handle both RSS and itunes images kwargs['uri'] = e.get('href', gettag(e, 'url')) kwargs['title'] = gettag(e, 'title') for name in ('width', 'height'): kwargs[name] = gettag(e, name, to_int) return Image(**kwargs) def to_enclosure(e): uri = e.get('url') type = e.get('type') length = int(e.get('length')) if e.get('length', '').isdigit() else None return Enclosure(uri=uri, type=type, length=length) def to_episode(e, feedurl): kwargs = { 'title': gettag(e, 'title'), 'guid': gettag(e, 'guid'), 'pubdate': gettag(e, 'pubDate', to_datetime), 'author': gettag(e, 'itunes:author'), 'block': gettag(e, 'itunes:block', to_bool), 'image': gettag(e, 'itunes:image', to_image), 'duration': gettag(e, 'itunes:duration', to_timedelta), 'explicit': gettag(e, 'itunes:explicit', to_bool), # TODO: "clean" 'order': gettag(e, 'itunes:order', to_int), 'subtitle': gettag(e, 'itunes:subtitle'), 'summary': gettag(e, 'itunes:summary'), 'enclosure': gettag(e, 'enclosure', to_enclosure) } # FIXME: belongs in library if not kwargs['summary']: kwargs['summary'] = gettag(e, 'description') if not kwargs['guid'] and kwargs['enclosure']: kwargs['guid'] = kwargs['enclosure'].uri if kwargs['enclosure'] and kwargs['enclosure'].uri: kwargs['uri'] = feedurl + '#' + kwargs['enclosure'].uri return Episode(**kwargs) def gettag(etree, tag, convert=None, namespaces=NAMESPACES): e = etree.find(tag, namespaces=namespaces) if e is None: return None elif convert: return convert(e) elif e.text: return e.text.strip() else: return None def episodekey(model): return model.pubdate or datetime.datetime.min def parse_rss(source): uri = source.geturl() channel = xml.etree.ElementTree.parse(source).find('channel') if channel is None: raise TypeError('Not an RSS feed') kwargs = {'uri': uri} for name in ('title', 'link', 'copyright', 'language'): kwargs[name] = gettag(channel, name) for name in ('author', 'subtitle', 'summary'): kwargs[name] = gettag(channel, 'itunes:' + name) for name in ('block', 'complete', 'explicit'): # TODO: clean kwargs[name] = gettag(channel, 'itunes:' + name, to_bool) kwargs['pubdate'] = gettag(channel, 'pubDate', to_datetime) kwargs['category'] = gettag(channel, 'itunes:category', to_category) kwargs['newfeedurl'] = gettag(channel, 'itunes:new-feed-url') kwargs['image'] = gettag(channel, 'image', to_image) # FIXME: belongs in library? if not kwargs['summary']: kwargs['summary'] = gettag(channel, 'description') if not kwargs['image']: # TBD: prefer iTunes image over RSS image? kwargs['image'] = gettag(channel, 'itunes:image', to_image) episodes = [] for item in channel.iter(tag='item'): try: episodes.append(to_episode(item, uri)) except Exception as e: logger.warn('Skipping episode for %s: %s', uri, e) # TODO: itunes:order, configurable... kwargs['episodes'] = sorted(episodes, key=episodekey, reverse=True) return Podcast(**kwargs) mopidy-podcast-1.1.2/setup.cfg000066400000000000000000000002271256765700000163010ustar00rootroot00000000000000[flake8] exclude = .git,docs [build_sphinx] source-dir = docs/ build-dir = docs/_build all_files = 1 [upload_sphinx] upload-dir = docs/_build/html mopidy-podcast-1.1.2/setup.py000066400000000000000000000026571256765700000162030ustar00rootroot00000000000000from __future__ import unicode_literals import re from setuptools import setup, find_packages def get_version(filename): content = open(filename).read() metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", content)) return metadata['version'] setup( name='Mopidy-Podcast', version=get_version('mopidy_podcast/__init__.py'), url='https://github.com/tkem/mopidy-podcast', license='Apache License, Version 2.0', author='Thomas Kemmer', author_email='tkemmer@computer.org', description='Mopidy extension for searching and browsing podcasts', long_description=open('README.rst').read(), packages=find_packages(exclude=['tests', 'tests.*']), zip_safe=False, include_package_data=True, install_requires=[ 'setuptools', 'Mopidy >= 0.19', 'Pykka >= 1.1.0', 'cachetools >= 0.7.0', 'uritools >= 0.8.0' ], test_suite='nose.collector', tests_require=[ 'nose', 'mock >= 1.0', ], entry_points={ 'mopidy.ext': [ 'podcast = mopidy_podcast:Extension', ], }, classifiers=[ 'Environment :: No Input/Output (Daemon)', 'Intended Audience :: End Users/Desktop', 'License :: OSI Approved :: Apache Software License', 'Operating System :: OS Independent', 'Programming Language :: Python :: 2', 'Topic :: Multimedia :: Sound/Audio :: Players', ], ) mopidy-podcast-1.1.2/tests/000077500000000000000000000000001256765700000156215ustar00rootroot00000000000000mopidy-podcast-1.1.2/tests/__init__.py000066400000000000000000000003361256765700000177340ustar00rootroot00000000000000from __future__ import unicode_literals def datapath(*name): import os path = os.path.dirname(__file__) path = os.path.join(path, 'data') path = os.path.abspath(path) return os.path.join(path, *name) mopidy-podcast-1.1.2/tests/data/000077500000000000000000000000001256765700000165325ustar00rootroot00000000000000mopidy-podcast-1.1.2/tests/data/example.xml000066400000000000000000000066241256765700000207170ustar00rootroot00000000000000 All About Everything http://www.example.com/podcasts/everything/index.html en-us ℗ & © 2014 John Doe & Family A show about everything John Doe All About Everything is a show about everything. Each week we dive into any subject known to man and talk about it as much as we can. Look for our podcast in the Podcasts app or in the iTunes Store All About Everything is a show about everything. Each week we dive into any subject known to man and talk about it as much as we can. Look for our podcast in the Podcasts app or in the iTunes Store John Doe john.doe@example.com Shake Shake Shake Your Spices John Doe A short primer on table spices salt and pepper shakers, comparing and contrasting pour rates, construction materials, and overall aesthetics. Come and join the party!]]> http://example.com/podcasts/archive/aae20140615.m4a Wed, 15 Jun 2014 19:00:00 GMT 7:04 Socket Wrench Shootout Jane Doe Comparing socket wrenches is fun! This week we talk about metric vs. Old English socket wrenches. Which one is better? Do you really need both? Get all of your answers here. http://example.com/podcasts/archive/aae20140608.mp3 Wed, 8 Jun 2014 19:00:00 GMT 4:34 Red,Whine, & Blue Various Red + Blue != Purple This week we talk about surviving in a Red state if you are a Blue person. Or vice versa. http://example.com/podcasts/archive/aae20140601.mp3 Wed, 1 Jun 2014 19:00:00 GMT 3:59 mopidy-podcast-1.1.2/tests/test_directory.py000066400000000000000000000034331256765700000212410ustar00rootroot00000000000000from __future__ import unicode_literals import unittest import pykka from mopidy_podcast.directory import PodcastDirectory from mopidy_podcast.controller import PodcastDirectoryController from mopidy_podcast.models import Ref class TestDirectory(PodcastDirectory): name = 'test' root_name = 'Test Directory' refs = [Ref.podcast(uri='/foo', name='bar')] def __init__(self, config): super(TestDirectory, self).__init__(config) def browse(self, uri, limit=None): return self.refs def search(self, uri, terms, attr=None, type=None, limit=None): return self.refs def refresh(self, uri=None): self.refs = [Ref.podcast(uri='/foo', name='baz')] class DirectoryTest(unittest.TestCase): def setUp(self): self.directory = PodcastDirectoryController([TestDirectory({})]) def tearDown(self): pykka.ActorRegistry.stop_all() def test_browse(self): self.assertItemsEqual( self.directory.browse(None), [Ref.directory(uri='podcast://test/', name='Test Directory')] ) self.assertItemsEqual( self.directory.browse('podcast://test/'), [Ref.podcast(uri='podcast://test/foo', name='bar')] ) def test_search(self): self.assertItemsEqual( self.directory.search(None, []), [Ref.podcast(uri='podcast://test/foo', name='bar')] ) self.assertItemsEqual( self.directory.search('podcast://test/foo', []), [Ref.podcast(uri='podcast://test/foo', name='bar')] ) def test_refresh(self): self.directory.refresh(), self.assertItemsEqual( self.directory.browse('podcast://test/'), [Ref.podcast(uri='podcast://test/foo', name='baz')] ) mopidy-podcast-1.1.2/tests/test_extension.py000066400000000000000000000012421256765700000212450ustar00rootroot00000000000000from __future__ import unicode_literals import unittest from mopidy_podcast import Extension class ExtensionTest(unittest.TestCase): def test_get_default_config(self): ext = Extension() config = ext.get_default_config() self.assertIn('[podcast]', config) self.assertIn('enabled = true', config) def test_get_config_schema(self): ext = Extension() schema = ext.get_config_schema() self.assertIn('root_name', schema) self.assertIn('browse_limit', schema) self.assertIn('search_limit', schema) self.assertIn('search_details', schema) self.assertIn('update_interval', schema) mopidy-podcast-1.1.2/tests/test_feeds.py000066400000000000000000000075351256765700000203320ustar00rootroot00000000000000from __future__ import unicode_literals import unittest from mopidy_podcast.feeds import FeedsDirectory from . import datapath BASE_URL = 'http://example.com/podcasts/everything/' LINK_URL = 'http://www.example.com/podcasts/everything/index.html' IMAGE_URL = BASE_URL + 'AllAboutEverything.jpg' EPISODE1_MEDIA_URL = BASE_URL + 'AllAboutEverythingEpisode1.mp3' EPISODE1_IMAGE_URL = BASE_URL + 'AllAboutEverything/Episode3.jpg' # sic! EPISODE2_MEDIA_URL = BASE_URL + 'AllAboutEverythingEpisode2.mp3' EPISODE2_IMAGE_URL = BASE_URL + 'AllAboutEverything/Episode2.jpg' EPISODE3_MEDIA_URL = BASE_URL + 'AllAboutEverythingEpisode3.m4a' EPISODE3_IMAGE_URL = BASE_URL + 'AllAboutEverything/Episode1.jpg' # sic! URI_PREFIX = BASE_URL + 'AllAboutEverything' class FeedsTest(unittest.TestCase): def setUp(self): self.feeds = FeedsDirectory({ 'podcast': { 'feeds': [], 'feeds_root_name': 'Feeds', 'feeds_cache_size': 1, 'feeds_cache_ttl': 0, 'feeds_timeout': None } }) def test_parse(self): feedurl = 'file://' + datapath('example.xml') podcast = self.feeds.get(feedurl) self.assertEqual(podcast.uri, feedurl) self.assertEqual(podcast.title, 'All About Everything') self.assertEqual(podcast.link, LINK_URL) self.assertRegexpMatches(podcast.summary, '^All About Everything') self.assertEqual(podcast.language, 'en-us') self.assertRegexpMatches(podcast.copyright, 'John Doe & Family') self.assertEqual(podcast.pubdate, None) self.assertEqual(podcast.image.uri, IMAGE_URL) self.assertEqual(podcast.author, 'John Doe') self.assertEqual(podcast.complete, None) self.assertEqual(podcast.explicit, None) self.assertEqual(podcast.subtitle, 'A show about everything') self.assertEqual(podcast.category, 'Technology') self.assertEqual(len(podcast.episodes), 3) episode3, episode2, episode1 = podcast.episodes # episode 1 self.assertEqual(episode1.uri, podcast.uri + '#' + EPISODE1_MEDIA_URL) self.assertEqual(episode1.title, 'Red,Whine, & Blue') self.assertEqual(episode1.author, 'Various') self.assertEqual(str(episode1.pubdate), '2014-06-01 19:00:00') self.assertEqual(str(episode1.duration), '0:03:59') self.assertEqual(episode1.enclosure.uri, EPISODE1_MEDIA_URL) self.assertEqual(episode1.image.uri, EPISODE1_IMAGE_URL) self.assertRegexpMatches(episode1.subtitle, '^Red \+ Blue') self.assertRegexpMatches(episode1.summary, '^This week') # episode 2 self.assertEqual(episode2.uri, podcast.uri + '#' + EPISODE2_MEDIA_URL) self.assertEqual(episode2.title, 'Socket Wrench Shootout') self.assertEqual(episode2.author, 'Jane Doe') self.assertEqual(str(episode2.pubdate), '2014-06-08 19:00:00') self.assertEqual(str(episode2.duration), '0:04:34') self.assertEqual(episode2.enclosure.uri, EPISODE2_MEDIA_URL) self.assertEqual(episode2.image.uri, EPISODE2_IMAGE_URL) self.assertRegexpMatches(episode2.subtitle, '^Comparing socket') self.assertRegexpMatches(episode2.summary, '^This week') # episode 3 self.assertEqual(episode3.uri, podcast.uri + '#' + EPISODE3_MEDIA_URL) self.assertEqual(episode3.title, 'Shake Shake Shake Your Spices') self.assertEqual(episode3.author, 'John Doe') self.assertEqual(str(episode3.pubdate), '2014-06-15 19:00:00') self.assertEqual(str(episode3.duration), '0:07:04') self.assertEqual(episode3.enclosure.uri, EPISODE3_MEDIA_URL) self.assertEqual(episode3.image.uri, EPISODE3_IMAGE_URL) self.assertRegexpMatches(episode3.summary, '^This week') self.assertRegexpMatches(episode3.subtitle, '^A short primer') mopidy-podcast-1.1.2/tests/test_library.py000066400000000000000000000016241256765700000207010ustar00rootroot00000000000000from __future__ import unicode_literals import unittest import pykka from mopidy_podcast.backend import PodcastBackend from mopidy import core class LibraryTest(unittest.TestCase): config = { 'podcast': { 'root_name': '', 'browse_limit': None, 'search_limit': None, 'update_interval': 86400 } } def setUp(self): self.backend = PodcastBackend.start(self.config, None).proxy() self.library = core.Core(backends=[self.backend]).library def tearDown(self): pykka.ActorRegistry.stop_all() def test_search_artist(self): self.library.search(artist=['foo']) # TODO: write tests def test_search_album(self): self.library.search(album=['foo']) # TODO: write tests def test_search_date(self): self.library.search(date=['2014-02-01']) # TODO: write tests mopidy-podcast-1.1.2/tests/test_query.py000066400000000000000000000147311256765700000204050ustar00rootroot00000000000000from __future__ import unicode_literals import unittest from mopidy.models import Artist, Album, Track, Ref from mopidy_podcast.query import Query def anycase(*strings): return [s.upper() for s in strings] + [s.lower() for s in strings] class QueryTest(unittest.TestCase): def assertQueryMatches(self, model, **kwargs): query = Query(kwargs, exact=False) self.assertTrue(query.match(model)) def assertNotQueryMatches(self, model, **kwargs): query = Query(kwargs, exact=False) self.assertFalse(query.match(model)) def assertQueryMatchesExact(self, model, **kwargs): query = Query(kwargs, exact=True) self.assertTrue(query.match(model)) def assertNotQueryMatchesExact(self, model, **kwargs): query = Query(kwargs, exact=True) self.assertFalse(query.match(model)) def test_create_query(self): for exact in (True, False): q1 = Query(dict(any='foo'), exact) self.assertEqual(q1.exact, exact) self.assertEqual(len(q1), 1) self.assertItemsEqual(q1, ['any']) self.assertEqual(len(q1['any']), 1) self.assertEqual(q1['any'][0], 'foo') self.assertNotEqual(q1['any'][0], 'bar') q2 = Query(dict(any=['foo', 'bar'], artist='x'), exact) self.assertEqual(q2.exact, exact) self.assertEqual(len(q2), 2) self.assertItemsEqual(q2, ['any', 'artist']) self.assertEqual(len(q2['any']), 2) self.assertEqual(len(q2['artist']), 1) self.assertEqual(q2['any'][0], 'foo') self.assertEqual(q2['any'][1], 'bar') self.assertEqual(q2['artist'][0], 'x') def test_query_errors(self): for exact in (True, False): with self.assertRaises(LookupError): Query(None, exact) with self.assertRaises(LookupError): Query({}, exact) with self.assertRaises(LookupError): Query({'artist': None}, exact) with self.assertRaises(LookupError): Query({'artist': ''}, exact) with self.assertRaises(LookupError): Query({'artist': []}, exact) with self.assertRaises(LookupError): Query({'artist': ['']}, exact) with self.assertRaises(LookupError): Query({'any': None}, exact) with self.assertRaises(LookupError): Query({'any': ''}, exact) with self.assertRaises(LookupError): Query({'any': []}, exact) with self.assertRaises(LookupError): Query({'any': ['']}, exact) with self.assertRaises(LookupError): Query({'foo': 'bar'}, exact) with self.assertRaises(TypeError): q = Query(dict(any='foo'), exact) q.match(Ref(name='foo')) def test_match_artist(self): artist = Artist(name='foo') for name in anycase('f', 'o', 'fo', 'oo', 'foo'): self.assertQueryMatches(artist, any=name) self.assertQueryMatches(artist, artist=name) self.assertQueryMatchesExact(artist, any='foo') self.assertQueryMatchesExact(artist, artist='foo') self.assertNotQueryMatches(artist, any='none') self.assertNotQueryMatches(artist, artist='none') self.assertNotQueryMatchesExact(artist, any='none') self.assertNotQueryMatchesExact(artist, artist='none') def test_match_album(self): artist = Artist(name='foo') album = Album(name='bar', artists=[artist]) for name in anycase('f', 'o', 'fo', 'oo', 'foo'): self.assertQueryMatches(album, any=name) self.assertQueryMatches(album, artist=name) self.assertQueryMatches(album, albumartist=name) for name in anycase('b', 'a', 'ba', 'ar', 'bar'): self.assertQueryMatches(album, any=name) self.assertQueryMatches(album, album=name) self.assertQueryMatchesExact(album, any='foo') self.assertQueryMatchesExact(album, artist='foo') self.assertQueryMatchesExact(album, albumartist='foo') self.assertQueryMatchesExact(album, any='bar') self.assertQueryMatchesExact(album, album='bar') self.assertNotQueryMatches(album, any='none') self.assertNotQueryMatches(album, artist='bar') self.assertNotQueryMatches(album, album='foo') self.assertNotQueryMatchesExact(album, any='none') self.assertNotQueryMatchesExact(album, artist='bar') self.assertNotQueryMatchesExact(album, album='foo') def test_match_track(self): artist = Artist(name='foo') album = Album(name='bar', artists=[Artist(name='v/a')]) track = Track(name='zyx', album=album, artists=[artist]) for name in anycase('f', 'o', 'fo', 'oo', 'foo'): self.assertQueryMatches(track, any=name) self.assertQueryMatches(track, artist=name) for name in anycase('b', 'a', 'ba', 'ar', 'bar'): self.assertQueryMatches(track, any=name) self.assertQueryMatches(track, album=name) for name in anycase('v', '/', 'v/', '/a', 'v/a'): self.assertQueryMatches(track, any=name) self.assertQueryMatches(track, albumartist=name) for name in anycase('z', 'y', 'zy', 'yx', 'zyx'): self.assertQueryMatches(track, any=name) self.assertQueryMatches(track, track_name=name) self.assertQueryMatchesExact(track, any='foo') self.assertQueryMatchesExact(track, artist='foo') self.assertQueryMatchesExact(track, any='bar') self.assertQueryMatchesExact(track, album='bar') self.assertQueryMatchesExact(track, any='v/a') self.assertQueryMatchesExact(track, albumartist='v/a') self.assertQueryMatchesExact(track, any='zyx') self.assertQueryMatchesExact(track, track_name='zyx') self.assertNotQueryMatches(track, any='none') self.assertNotQueryMatches(track, artist='bar') self.assertNotQueryMatches(track, album='foo') self.assertNotQueryMatches(track, albumartist='zyx') self.assertNotQueryMatches(track, track_name='v/a') self.assertNotQueryMatchesExact(track, any='none') self.assertNotQueryMatchesExact(track, artist='bar') self.assertNotQueryMatchesExact(track, album='foo') self.assertNotQueryMatchesExact(track, albumartist='zyx') self.assertNotQueryMatchesExact(track, track_name='v/a') mopidy-podcast-1.1.2/tests/test_rssfeed.py000066400000000000000000000056611256765700000206750ustar00rootroot00000000000000from __future__ import unicode_literals import unittest import xml.etree.ElementTree from mopidy_podcast.rssfeed import * # noqa def element(_=None, tag='', **kwargs): e = xml.etree.ElementTree.Element(tag, attrib=kwargs) e.text = _ return e def tree(tag, _=None, **kwargs): e = xml.etree.ElementTree.Element('root') e.append(element(_, tag=tag, **kwargs)) return e class RSSFeedTest(unittest.TestCase): def test_bool(self): self.assertFalse(to_bool(element())) self.assertFalse(to_bool(element(''))) self.assertFalse(to_bool(element('foo'))) self.assertFalse(to_bool(element('no'))) self.assertTrue(to_bool(element('yes'))) self.assertTrue(to_bool(element('YES'))) def test_int(self): self.assertEqual(None, to_int(element())) self.assertEqual(None, to_int(element(''))) self.assertEqual(None, to_int(element('foo'))) self.assertEqual(0, to_int(element('0'))) self.assertEqual(42, to_int(element('42'))) def test_datetime(self): self.assertEqual(None, to_datetime(element())) self.assertEqual(None, to_datetime(element(''))) self.assertEqual(None, to_datetime(element('foo'))) self.assertEqual( '2014-06-01 19:00:00', str(to_datetime(element('Wed, 1 Jun 2014 19:00:00 GMT'))) ) def test_timedelta(self): self.assertEqual(None, to_timedelta(element())) self.assertEqual(None, to_timedelta(element(''))) self.assertEqual(None, to_timedelta(element('foo'))) self.assertEqual('0:03:59', str(to_timedelta(element('239')))) self.assertEqual('0:03:59', str(to_timedelta(element('239.0')))) self.assertEqual('0:03:59', str(to_timedelta(element('3:59')))) self.assertEqual('0:03:59', str(to_timedelta(element('3:59.0')))) self.assertEqual('0:03:59', str(to_timedelta(element('03:59')))) self.assertEqual('0:03:59', str(to_timedelta(element('03:59.0')))) self.assertEqual('0:03:59', str(to_timedelta(element('0:03:59')))) self.assertEqual('0:03:59', str(to_timedelta(element('0:03:59.0')))) def test_category(self): self.assertEqual(None, to_category(element())) self.assertEqual(None, to_category(element(''))) self.assertEqual(None, to_category(element('foo'))) self.assertEqual('Music', to_category(element(text='Music'))) def test_gettag(self): self.assertEqual(None, gettag(tree('bar'), 'foo')) self.assertEqual(None, gettag(tree('foo'), 'foo')) self.assertEqual(None, gettag(tree('foo', None), 'foo')) self.assertEqual(None, gettag(tree('foo', ''), 'foo')) self.assertEqual('bar', gettag(tree('foo', 'bar'), 'foo')) self.assertEqual('bar', gettag(tree('foo', ' bar '), 'foo')) self.assertEqual('42', gettag(tree('foo', '42'), 'foo')) self.assertEqual(42, gettag(tree('foo', '42'), 'foo', to_int))