diff --git a/AUTHORS b/AUTHORS
new file mode 100644
index 0000000..ced9df8
--- /dev/null
+++ b/AUTHORS
@@ -0,0 +1 @@
+Copyright 2010-2013 Facundo Batista
diff --git a/AYUDA.txt b/AYUDA.txt
new file mode 100644
index 0000000..fbf478c
--- /dev/null
+++ b/AYUDA.txt
@@ -0,0 +1,50 @@
+¡Bienvenido al Visualizador de contenidos del Canal Encuentro y otros!
+
+Este es un simple programa que permite buscar, descargar y ver contenido
+del Canal Encuentro y otros. Para más información de cómo instalar el
+programa y licencias, leer el archivo LEEME.txt.
+
+
+Cómo ejecutar el programa
+-------------------------
+
+Una vez que está instalado se puede ejecutar el programa escribiendo...
+
+ encuentro
+
+...desde la linea de comandos, aunque también se puede ejecutarlo de la
+siguiente manera estando parado en el directorio donde se descomprimió
+el tarball:
+
+ bin/encuentro
+
+Para ver no solo la salida estándar sino también el log del programa,
+ejecutar:
+
+ bin/encuentro -v
+
+
+Cómo usarlo
+-----------
+
+Lo primero que hay que hacer es actualizar la metadata de los programas.
+Para ello está el botón Actualizar en la barra superior, hacé click ahí,
+y luego de bajar la info tendrás centenares de programas para elegir.
+
+Para descargar cualquiera de ellos, tenés que seleccionarlo y hacer click
+en el botón "Descargar". Pero antes, como cada descarga es personal, hay
+que configurar el programa con el usuario y clave que saques en el sitio
+web del Canal Encuentro o del backend que corresponda (no todos necesitan
+autenticación)
+
+Entonces, en el programa hacé click en el botón Configurar de la barra de
+arriba. Ahí vas a ver algunas pestañas, con configuración general y
+específica para los distintos backends, incluso con la URL para ir a
+registrarte y obtener las credenciales).
+
+Listo! Ahora puedes descargar cualquier programa de cualquier canal!
+
+Una vez descargado, para verlos sólo necesitás seleccionarlo y hacer click
+en el botón de Play.
+
+¡Que los disfrutes!
diff --git a/COPYING b/COPYING
new file mode 100644
index 0000000..4432540
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,676 @@
+
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
+
diff --git a/LEEME.txt b/LEEME.txt
new file mode 100644
index 0000000..1d7790a
--- /dev/null
+++ b/LEEME.txt
@@ -0,0 +1,92 @@
+¡Bienvenido al Visualizador de contenidos del Canal Encuentro!
+
+Este es un simple programa que permite buscar, descargar y ver contenido del
+Canal Encuentro y otros. Para más información de cómo usar el programa, leer
+el archivo AYUDA.txt.
+
+Si querés conocer más sobre el proyecto, el mismo se gestiona desde aquí:
+
+ https://launchpad.net/encuentro
+
+
+*Nota importante*: Si tenés una versión anterior a la 0.7, el programa no te
+va a funcionar. Tenés que actualizar sí o sí la versión. Esto es porque
+Encuentro migró sus contenidos al portal Conectate, por lo que las versiones
+viejas no te van a funcionar correctamente. La buena noticia es que ahora
+podrás descargar no sólo contenido de Encuentro, sino también de Paka Paka,
+Educ.ar, y otros.
+
+
+Cómo ejecutarlo directamente
+----------------------------
+
+Tanto si hacés un branch del proyecto, como si abrís el tarball, el programa
+puede ejecutarse fácilmente sin instalarlo, haciendo:
+
+ bin/encuentro
+
+
+Cómo instalarlo
+---------------
+
+Es bastante sencillo, sólo tenés que hacer:
+
+ sudo python setup.py install
+
+Para que funcione correctamente, tenés que tener Python 2 instalado, y las
+siguientes dependencias (paquete y número mínimo de versión):
+
+ python 2.6.6
+ python-requests 2.2.1
+ python-defer 1.0.6
+ python-qt4 4.9.1
+ python-xdg 0.15
+ python-bs4 4.1.0
+ python-notify 0.1.1
+
+(este último, python-notify, no es realmente necesario, pero si está el
+programa notificará las descargas finalizadas)
+
+
+Cómo instalarlo en un virtualenv
+--------------------------------
+
+Si no tenés la menor idea de qué es un virtualenv y el detalle anterior para
+instalar te sirve podés omitir este punto.
+Si querés instalarlo en un virtualenv para colaborar en el proyecto o por
+cualquier otra causa, tenés que seguir los siguientes pasos:
+
+Es necesario hacer un branch del proyecto, o descargar el tarball,
+tener instalado python-qt4 y python-notify. Al generar el virtualenv tenés
+que utilizar la opción '--system-site-packages'.
+
+Crear el virtualenv:
+
+ virtualenv --system-site-packages path/to/encuentro_venv
+
+Activarlo:
+
+ source path/to/encuentro_venv/bin/activate
+
+Instalar las dependencias:
+
+ cd path/to/encuentro/code/trunk
+ pip install -r requirements_py2.txt
+
+Ejecutar Encuentro:
+
+ bin/encuentro
+
+
+Licencias
+---------
+
+Este programa no distribuye contenido de Canal Encuentro, sino que permite un
+mejor uso personal de esos contenidos. Por favor, referirse al sitio web de
+Conectate (http://conectate.gov.ar/) para saber qué se puede y qué no se
+puede hacer con los contenidos de ese sitio.
+
+El código de este programa está licenciado bajo la GNU GPL v3, podés encontrar
+esta licencia en el archivo COPYING o acá:
+
+ http://www.gnu.org/licenses/gpl.html
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..fb3bd41
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,11 @@
+include encuentro/ui/media/*
+include encuentro/logos/icon-*.png
+include LEEME.txt
+include AYUDA.txt
+include README.txt
+include COPYING
+include AUTHORS
+include encuentro.desktop
+include source_encuentro.py
+include version.txt
+include man/encuentro.1
diff --git a/README.txt b/README.txt
new file mode 100644
index 0000000..e38f204
--- /dev/null
+++ b/README.txt
@@ -0,0 +1,20 @@
+Welcome to the Canal Encuentro visualization program!
+
+This is a simple program to search, download and see the content of the Canal
+Encuentro and others.
+
+This program is strongly oriented to Spanish speaking people, as the content
+of Canal Encuentro and the other channels is only in Spanish... for further
+information please check the LEEME.txt file.
+
+Notes regarding licenses:
+
+- The content of the channels is not distributed at all, but downloaded
+ personally by the user, please check here to see the licenses about
+ that content in
+
+ http://conectate.gov.ar/
+
+- The code is licensed under the GNU GPL v3, see file COPYING here or...
+
+ http://www.gnu.org/licenses/gpl.html
diff --git a/RUNNING_TESTS.txt b/RUNNING_TESTS.txt
new file mode 100644
index 0000000..003be6c
--- /dev/null
+++ b/RUNNING_TESTS.txt
@@ -0,0 +1,41 @@
+How to run the tests in the project
+===================================
+
+Read this if you're a developer of the project and want to know how
+to run the tests.
+
+
+Dependencies
+------------
+
+We're in a mixed state of Py2 and Py3 right now, and virtualenv is not
+good for this, so or you install the dependencies in your system, or
+just use two virtualenvs.
+
+Of course, you'll need Python2 and Python3. Qt is needed only for Python2.
+
+Then, a bunch of dependencies automatically handled by pip:
+
+ pip install -r requirements_py2.txt
+ pip3 install -r requirements_py3.txt
+
+Again, do that with sudo in all your system, or separatedly in different
+virtualenvs.
+
+Finally, flake8 and pylint are needed for static code analysis.
+
+
+Running the tests
+-----------------
+
+If you have all installed in the system, just do:
+
+ ./test
+
+Otherwise just execute all what it does, but separately in different environments.
+
+
+Note: In Ubuntu Trusty using nosetests3 failed to me when having it
+configured with with-progressive option, this fixed that:
+
+ https://github.com/nose-devs/nose/pull/811/files
diff --git a/anuncio.txt b/anuncio.txt
new file mode 100644
index 0000000..330bd18
--- /dev/null
+++ b/anuncio.txt
@@ -0,0 +1,62 @@
+
+Encuentro 2.1
+
+Encuentro es un simple programa que permite buscar, descargar y ver contenido del Canal Encuentro, Paka Paka, BACUA, Educ.ar y otros.
+
+Notar que este programa no distribuye contenido directamente, sino que permite un mejor uso personal de esos contenidos. Por favor, referirse a los sitios web correspondientes para saber qué se puede y qué no se puede hacer con los contenidos de tales sitios.
+
+Página oficial:
+
+ http://encuentro.taniquetil.com.ar/
+
+
+La versión 2.1 trae los siguientes cambios con respecto a la versión anterior:
+
+ - Nuevo backend para 'Decime Quien Sos Vos'
+
+ - Se autentica correctamente en Conectate, lo que implica mejor calidad de videos descargados
+
+ - Evita explotar por la mala interacción entre PyQt y pynotify en algunos sistemas
+
+ - Se arregló la conservación del estado de ordenamiento de las columnas entre ejecuciones
+
+ - Es más robusto frente a problemas al guardar la configuración o metadata
+
+ - Tiene un mejor parseo para Bacua y otros backends; se mejoraron las herramientas en general
+
+ - No se loguea más algunos parámetros sensibles a nivel seguridad
+
+
+La forma más fácil de instalarlo, si tienen un Debian o Ubuntu, es usando este instalador:
+
+ http://launchpad.net/encuentro/trunk/2.1/+download/encuentro-2.1.deb
+
+
+También tenemos instalador para Windows:
+
+ http://launchpad.net/encuentro/trunk/2.1/+download/encuentro-2.1-setup.exe
+
+
+Instalarlo en Arch es sencillo ya que está disponible en AUR, por ejemplo se puede hacer:
+
+ yaourt -S encuentro
+
+
+También se puede instalar directamente desde PyPI:
+
+ sudo easy_install encuentro
+
+
+En cualquier otro caso, pueden usar el tarball para instalarlo:
+
+ http://launchpad.net/encuentro/trunk/2.1/+download/encuentro-2.1.tar.gz
+
+
+Si tienen Fedora o Red Hat, todavía no tenemos un instalador, pero lo pueden instalar o usar desde el tarball, como acabo de indicar.
+
+Nota: no hay más PPAs para estar automáticamente actualizado en Debian/Ubuntu, porque en esta oportunidad no pude hacer andar toda la maldita maquinaria.
+
+
+Recuerden revisar el archivo AYUDA.txt si tienen alguna duda de cómo usar el programa.
+
+¡Que lo disfruten!
diff --git a/bin/encuentro b/bin/encuentro
new file mode 100755
index 0000000..c133fa3
--- /dev/null
+++ b/bin/encuentro
@@ -0,0 +1,59 @@
+#!/usr/bin/env python
+
+# Copyright 2011-2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Script to run Encuentro."""
+
+import logging
+import sys
+import os
+
+# this will be replaced at install time
+INSTALLED_BASE_DIR = "@ INSTALLED_BASE_DIR @"
+
+# get the replaced-at-install-time name if exists, or the project one
+if os.path.exists(INSTALLED_BASE_DIR):
+ project_basedir = INSTALLED_BASE_DIR
+ sys._INSTALLED_BASE_DIR = INSTALLED_BASE_DIR
+else:
+ project_basedir = os.path.abspath(os.path.dirname(os.path.dirname(
+ os.path.realpath(sys.argv[0]))))
+
+if project_basedir not in sys.path:
+ sys.path.insert(0, project_basedir)
+ sys.path.insert(1, os.path.join(project_basedir, 'qtreactor'))
+
+from encuentro import main, multiplatform, logger
+
+# set up logging
+verbose = len(sys.argv) > 1 and sys.argv[1] == '-v'
+logger.set_up(verbose)
+log = logging.getLogger('encuentro.init')
+
+# first of all, show the versions
+print "Running Python %s on %r" % (sys.version_info, sys.platform)
+log.info("Running Python %s on %r", sys.version_info, sys.platform)
+version_file = multiplatform.get_path('version.txt')
+if os.path.exists(version_file):
+ version = open(version_file).read().strip()
+ print "Encuentro: v. %s" % (version,)
+else:
+ version = None
+ print "Encuentro: sin revno info"
+log.info("Encuentro version: %r", version)
+
+main.start(version)
diff --git a/encuentro.desktop b/encuentro.desktop
new file mode 100644
index 0000000..a3cce7d
--- /dev/null
+++ b/encuentro.desktop
@@ -0,0 +1,13 @@
+[Desktop Entry]
+Version=1.0
+Encoding=UTF-8
+Type=Application
+Name=Encuentro
+GenericName=Encuentro
+Comment=Simple program to download and see the content from Canal Encuentro y otros
+Comment[es]=Simple programa que permite buscar, descargar y ver contenido del Canal Encuentro and others
+Icon=@ INSTALLED_ICON @
+Exec=encuentro
+Terminal=false
+StartupNotify=false
+Categories=Application;Education;Network;
diff --git a/encuentro/__init__.py b/encuentro/__init__.py
new file mode 100644
index 0000000..d75cca6
--- /dev/null
+++ b/encuentro/__init__.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+#
+# Copyright 2011 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""The package."""
+
+import sys
+
+# special import before any other imports to configure GUI to use API 2
+import sip
+for name in "QDate QDateTime QString QTextStream QTime QUrl QVariant".split():
+ sip.setapi(name, 2) # API v2 FTW!
+
+
+IMPORT_MSG = u"""
+ERROR! Problema al importar %(module)r
+
+Probablemente falte instalar una dependencia. Se necesita tener instalado
+el paquete %(package)r versión %(version)s o superior.
+"""
+
+
+class NiceImporter(object):
+ """Show nicely successful and errored imports."""
+ def __init__(self, module, package, version):
+ self.module = module
+ self.package = package
+ self.version = version
+
+ def __enter__(self):
+ pass
+
+ def _get_version(self):
+ """Get the version of a module."""
+ mod = sys.modules[self.module]
+ for attr in ('version', '__version__', 'ver', 'PYQT_VERSION_STR'):
+ v = getattr(mod, attr, None)
+ if v is not None:
+ return v
+ return ""
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ if exc_type is None:
+ version = self._get_version()
+ print "Modulo %r importado ok, version %r" % (self.module, version)
+ else:
+ print IMPORT_MSG % dict(module=self.module, package=self.package,
+ version=self.version)
+
+ # consume the exception!
+ return True
+
+
+# test the packages
+with NiceImporter('xdg', 'python-xdg', '0.15'):
+ import xdg # NOQA
+with NiceImporter('requests', 'python-requests', '2.2.1'):
+ import requests # NOQA
+with NiceImporter('PyQt4.QtCore', 'PyQt4', '4.9.1'):
+ import PyQt4.QtCore # NOQA
+with NiceImporter('defer', 'python-defer', '1.0.6'):
+ import defer # NOQA
diff --git a/encuentro/config.py b/encuentro/config.py
new file mode 100644
index 0000000..437ff8c
--- /dev/null
+++ b/encuentro/config.py
@@ -0,0 +1,101 @@
+# -*- coding: UTF-8 -*-
+
+# Copyright 2013 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""The system configuration."""
+
+import logging
+import os
+import pickle
+
+from encuentro import utils
+
+
+logger = logging.getLogger('encuentro.config')
+
+# these are the configuration variables/parameters that on one hand should
+# be stored in a keyring (if available), and on the other hand must not be
+# logged by the system
+SECURITY_CONFIG = ['user', 'password']
+
+
+class _Config(dict):
+ """The configuration."""
+
+ SYSTEM = 'system'
+
+ def __init__(self):
+ self._fname = None
+ super(_Config, self).__init__()
+
+ def sanitized_config(self):
+ """Return a copied config, sanitized to log."""
+ safecfg = self.copy()
+ for secure in SECURITY_CONFIG:
+ if secure in safecfg:
+ safecfg[secure] = ''
+ return safecfg
+
+ def init(self, fname):
+ """Initialize and load config."""
+ self._fname = fname
+ if not os.path.exists(fname):
+ # default to an almost empty dict
+ self[self.SYSTEM] = {}
+ logger.debug("File not found, starting empty")
+ return
+
+ with open(fname, 'rb') as fh:
+ saved_dict = pickle.load(fh)
+ logger.debug("Loaded: %s", self.sanitized_config())
+ self.update(saved_dict)
+
+ # for compatibility, put the system container if not there
+ if self.SYSTEM not in self:
+ self[self.SYSTEM] = {}
+
+ def save(self):
+ """Save the config to disk."""
+ # we don't want to pickle this class, but the dict itself
+ raw_dict = self.copy()
+ logger.debug("Saving: %s", self.sanitized_config())
+ with utils.SafeSaver(self._fname) as fh:
+ pickle.dump(raw_dict, fh)
+
+
+class _Signal(object):
+ """Custom signals.
+
+ Decorate a function to be called when signal is emitted.
+ """
+
+ def __init__(self):
+ self.store = {}
+
+ def register(self, method):
+ """Register a method."""
+ self.store.setdefault(method.__name__, []).append(method)
+
+ def emit(self, name):
+ """Call the registered methods."""
+ meths = self.store.get(name, [])
+ for meth in meths:
+ meth()
+
+
+config = _Config()
+signal = _Signal()
diff --git a/encuentro/data.py b/encuentro/data.py
new file mode 100644
index 0000000..eadb3cf
--- /dev/null
+++ b/encuentro/data.py
@@ -0,0 +1,310 @@
+# Copyright 2013-2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Classes to interface to and persist the episodes data."""
+
+import cgi
+import logging
+import os
+import pickle
+
+from base64 import b64decode
+from unicodedata import normalize
+
+from encuentro import utils
+from encuentro.ui import dialogs
+
+logger = logging.getLogger('encuentro.data')
+
+
+class Status(object):
+ """Status constants."""
+ none = 'none'
+ waiting = 'waiting'
+ downloading = 'downloading'
+ downloaded = 'downloaded'
+
+
+_normalize_cache = {}
+
+
+def _search_normalizer(char):
+ """Normalize always to one char length."""
+ try:
+ return _normalize_cache[char]
+ except KeyError:
+ norm = normalize('NFKD', char).encode('ASCII', 'ignore').lower()
+ if not norm:
+ norm = '?'
+ _normalize_cache[char] = norm
+ return norm
+
+
+def prepare_to_filter(text):
+ """Prepare a text to filter.
+
+ It receives unicode, but return simple lowercase ascii.
+ """
+ return ''.join(_search_normalizer(c) for c in text)
+
+
+class EpisodeData(object):
+ """Episode data."""
+
+ # these is for the attributes to be here when unpickling old instances
+ image_url = None
+ downtype = None
+ image_data = None
+ subtitle = None
+
+ def __init__(self, channel, section, title, duration, description,
+ episode_id, url, image_url, state=None, progress=None,
+ filename=None, downtype=None, season=None,
+ image_data=None, subtitle=None):
+ self.channel = channel
+ self.section = section
+ self.season = None if season is None else cgi.escape(season)
+ self.title = cgi.escape(title)
+ self.duration = duration
+ self.description = description
+ self.subtitle = subtitle
+ self.episode_id = episode_id
+
+ # build a nice string to show in the GUI
+ if self.season:
+ self.composed_title = self.season + u": " + self.title
+ else:
+ self.composed_title = self.title
+
+ # urls are bytes!
+ self.url = str(url)
+ self.image_url = str(image_url)
+
+ # image data is encoded in base64
+ self.image_data = None if image_data is None else b64decode(image_data)
+
+ self.state = Status.none if state is None else state
+ self.progress = progress
+ self.filename = filename
+ self.to_filter = None
+ self.downtype = downtype
+
+ # cache the processed title
+ self._normalized_title = prepare_to_filter(self.composed_title)
+
+ @property
+ def normalized_title(self):
+ """Get the normalized title, if already have it, or calculate it.
+
+ This attribute may not be present because old pickled instances didn't
+ have it.
+ """
+ if not hasattr(self, '_normalized_title'):
+ self._normalized_title = prepare_to_filter(self.composed_title)
+ return self._normalized_title
+
+ def update(self, channel, section, title, duration, description,
+ episode_id, url, image_url, state=None, progress=None,
+ filename=None, downtype=None, season=None,
+ image_data=None, subtitle=None):
+ """Update the episode data."""
+ self.channel = channel
+ self.section = section
+ self.season = None if season is None else cgi.escape(season)
+ self.title = cgi.escape(title)
+ self.duration = duration
+ self.description = description
+ self.subtitle = subtitle
+ self.episode_id = episode_id
+
+ # build a nice string to show in the GUI
+ if self.season:
+ self.composed_title = self.season + u": " + self.title
+ else:
+ self.composed_title = self.title
+
+ # urls are bytes!
+ self.url = str(url)
+ self.image_url = str(image_url)
+
+ # image data is encoded in base64
+ self.image_data = None if image_data is None else b64decode(image_data)
+
+ self.state = Status.none if state is None else state
+ self.progress = progress
+ self.filename = filename
+ self.downtype = downtype
+
+ # cache the processed title, overwritting what may be old from the past
+ self._normalized_title = prepare_to_filter(self.composed_title)
+
+ def filter_params(self, text, only_downloaded):
+ """Return the filtering params.
+
+ If should filter, it will return (pos1, pos2) (both in None if it only
+ filters by only_downloaded). If it should not filter, will return None.
+ """
+ if only_downloaded and self.state != Status.downloaded:
+ # need downloaded ones, sorry
+ return
+
+ t = self.normalized_title
+ pos1 = t.find(text)
+ if pos1 == -1:
+ # need to match text, sorry
+ return
+
+ # return boundaries
+ pos2 = pos1 + len(text)
+ return (pos1, pos2)
+
+ def __str__(self):
+ args = (self.episode_id, self.state, self.channel,
+ self.section, self.title)
+ return "" % args
+
+
+class ProgramsData(object):
+ """Holder / interface for programs data."""
+
+ # more recent version of the in-disk data
+ last_programs_version = 2
+
+ def __init__(self, main_window, filename):
+ self.main_window = main_window
+ self.filename = filename
+ print "Using data file:", repr(filename)
+ logger.info("Using data file: %r", filename)
+
+ self.version = None
+ self.data = None
+ self.reset_config_from_migration = False
+ self.load()
+ self.migrate()
+ logger.info("Episodes metadata loaded (total %d)", len(self.data))
+
+ def merge(self, new_data, episodes_widget):
+ """Merge new data to current programs data."""
+ for d in new_data:
+ names = ['channel', 'section', 'title', 'duration',
+ 'description', 'episode_id', 'url', 'image_url',
+ 'downtype', 'season', 'image_data', 'subtitle']
+ values = dict((name, d.get(name)) for name in names)
+ episode_id = d['episode_id']
+
+ try:
+ ed = self.data[episode_id]
+ except KeyError:
+ ed = EpisodeData(**values)
+ episodes_widget.add_episode(ed)
+ self.data[episode_id] = ed
+ else:
+ ed.update(**values)
+ episodes_widget.update_episode(ed)
+ self.save()
+
+ def load(self):
+ """Load the data from the file."""
+ # if not file, all empty
+ if not os.path.exists(self.filename):
+ self.data = {}
+ self.version = self.last_programs_version
+ return
+
+ # get from the file
+ with open(self.filename, 'rb') as fh:
+ try:
+ loaded_programs_data = pickle.load(fh)
+ except Exception, err:
+ logger.warning("ERROR while opening the pickled data: %s", err)
+ self.data = {}
+ self.version = self.last_programs_version
+ return
+
+ # check pre-versioned data
+ if isinstance(loaded_programs_data, dict):
+ # pre-versioned data
+ self.version = 0
+ self.data = loaded_programs_data
+ else:
+ self.version, self.data = loaded_programs_data
+
+ def migrate(self):
+ """Migrate metadata if needed."""
+ if self.version == self.last_programs_version:
+ logger.info("Metadata is updated, nothing to migrate")
+ return
+
+ if self.version > self.last_programs_version:
+ raise ValueError("Data is newer than code! %s" % (self.version,))
+
+ # migrate
+ if self.version == 0:
+ logger.info("Migrating from version 0")
+ # actually, from 0, no migration is possible, we
+ # need to tell the user the ugly truth
+ dlg = dialogs.ForceUpgradeDialog()
+ should_quit = dlg.exec_()
+ if should_quit:
+ self.main_window.shutdown()
+ return
+ # if user accessed to go on, don't really need to migrate
+ # anything, as *all* the code is to support the new metadata
+ # version only, so just remove it and mark the usr/pass config
+ # to be removed
+ self.version = self.last_programs_version
+ self.reset_config_from_migration = True
+ self.data = {}
+ return
+
+ if self.version == 1:
+ logger.info("Migrating from version 1")
+ self.version = self.last_programs_version
+ for epis_id, episode in self.data.items():
+ episode.composed_title = episode.title
+ return
+
+ raise ValueError("Don't know how to migrate from %r" % (self.version,))
+
+ def __str__(self):
+ return "" % (self.version, len(self.data))
+
+ def __nonzero__(self):
+ return bool(self.data)
+
+ def __getitem__(self, pos):
+ return self.data[pos]
+
+ def __setitem__(self, pos, value):
+ self.data[pos] = value
+
+ def values(self):
+ """Return the iter values of the data."""
+ return self.data.itervalues()
+
+ def __len__(self):
+ """The length."""
+ return len(self.data)
+
+ def items(self):
+ """Return the iter items of the data."""
+ return self.data.iteritems()
+
+ def save(self):
+ """Save to disk."""
+ to_save = (self.last_programs_version, self.data)
+ with utils.SafeSaver(self.filename) as fh:
+ pickle.dump(to_save, fh)
diff --git a/encuentro/image.py b/encuentro/image.py
new file mode 100644
index 0000000..d1d187c
--- /dev/null
+++ b/encuentro/image.py
@@ -0,0 +1,73 @@
+# Copyright 2013-2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Get an image from web and cache it."""
+
+
+import logging
+import md5
+import os
+import glob
+
+from encuentro import multiplatform, utils
+
+logger = logging.getLogger('encuentro.image')
+
+
+class ImageGetter(object):
+ """Image downloader and cache object."""
+
+ def __init__(self, callback):
+ self.callback = callback
+ self.cache_dir = os.path.join(multiplatform.cache_dir,
+ 'encuentro.images')
+ if not os.path.exists(self.cache_dir):
+ os.makedirs(self.cache_dir)
+
+ def get_image(self, episode_id, url):
+ """Get an image and show it using the callback."""
+ logger.info("Loading image for episode %s: %r", episode_id, url)
+ file_name = md5.md5(url).hexdigest()
+ file_fullname = os.path.join(self.cache_dir, file_name)
+ img_search_result = glob.glob(file_fullname + '.*')
+ if len(img_search_result) > 0:
+ logger.debug("Image already available: %r", file_fullname)
+ self.callback(episode_id, file_fullname)
+ return
+
+ def _d_callback(data, episode_id, file_fullname):
+ """Cache the image and use the callback."""
+ content_type, img_data = data
+ content, extension = content_type.split('/')
+ if content != 'image':
+ logger.debug("The Content-Type header is not 'image'")
+ file_fullname = file_fullname + '.' + extension
+ logger.debug("Image downloaded for episode_id %s, "
+ "saving to %r, Content-Type= %s",
+ episode_id, file_fullname, content_type)
+ with utils.SafeSaver(file_fullname) as fh:
+ fh.write(img_data)
+ self.callback(episode_id, file_fullname)
+
+ def _d_errback(failure):
+ """Log the problem."""
+ logger.error("Problem getting image: type: %s error: %s",
+ failure.type, failure.value)
+
+ logger.debug("Need to download the image")
+ d = utils.download(url)
+ d.add_callback(_d_callback, episode_id, file_fullname)
+ d.add_errback(_d_errback)
diff --git a/encuentro/logger.py b/encuentro/logger.py
new file mode 100644
index 0000000..5d525cf
--- /dev/null
+++ b/encuentro/logger.py
@@ -0,0 +1,77 @@
+# Copyright 2011-2013 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Logging set up."""
+
+import logging
+import os
+import sys
+import traceback
+
+from logging.handlers import RotatingFileHandler
+
+import xdg.BaseDirectory
+
+
+class CustomRotatingFH(RotatingFileHandler):
+ """Rotating handler that starts a new file for every run."""
+
+ def __init__(self, *args, **kwargs):
+ RotatingFileHandler.__init__(self, *args, **kwargs)
+ self.doRollover()
+
+
+def exception_handler(exc_type, exc_value, tb):
+ """Handle an unhandled exception."""
+ exception = traceback.format_exception(exc_type, exc_value, tb)
+ msg = "".join(exception)
+ print >> sys.stderr, msg
+
+ # log
+ logger = logging.getLogger('encuentro')
+ logger.error("Unhandled exception!\n%s", msg)
+
+
+def get_filename():
+ """Return the log file name."""
+ return os.path.join(xdg.BaseDirectory.xdg_cache_home,
+ 'encuentro', 'encuentro.log')
+
+
+def set_up(verbose):
+ """Set up the logging."""
+
+ logfile = get_filename()
+ print "Saving logs to", repr(logfile)
+ logfolder = os.path.dirname(logfile)
+ if not os.path.exists(logfolder):
+ os.makedirs(logfolder)
+
+ logger = logging.getLogger('encuentro')
+ handler = CustomRotatingFH(logfile, maxBytes=1e6, backupCount=10)
+ logger.addHandler(handler)
+ formatter = logging.Formatter("%(asctime)s %(name)-22s "
+ "%(levelname)-8s %(message)s")
+ handler.setFormatter(formatter)
+ logger.setLevel(logging.DEBUG)
+
+ if verbose:
+ handler = logging.StreamHandler()
+ handler.setFormatter(formatter)
+ logger.addHandler(handler)
+
+ # hook the exception handler
+ sys.excepthook = exception_handler
diff --git a/encuentro/logos/icon-192.png b/encuentro/logos/icon-192.png
new file mode 100644
index 0000000..00f6e55
Binary files /dev/null and b/encuentro/logos/icon-192.png differ
diff --git a/encuentro/logos/icon-32.png b/encuentro/logos/icon-32.png
new file mode 100644
index 0000000..89e8aae
Binary files /dev/null and b/encuentro/logos/icon-32.png differ
diff --git a/encuentro/logos/logo.svg b/encuentro/logos/logo.svg
new file mode 100644
index 0000000..66a023e
--- /dev/null
+++ b/encuentro/logos/logo.svg
@@ -0,0 +1,258 @@
+
+
+
+
diff --git a/encuentro/main.py b/encuentro/main.py
new file mode 100644
index 0000000..d2ca7c8
--- /dev/null
+++ b/encuentro/main.py
@@ -0,0 +1,50 @@
+# Copyright 2013-2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Main entry point, and initialization of everything we can."""
+
+import logging
+import os
+import sys
+
+from encuentro import multiplatform
+from encuentro.config import config
+from encuentro.ui.main import MainUI
+
+# we put here EpisodeData only for legacy reasons: unpickle of old pickles
+# will try to load EpisodeData from this namespace
+from encuentro.data import EpisodeData # NOQA
+
+logger = logging.getLogger('encuentro.init')
+
+from PyQt4.QtGui import QApplication, QIcon
+
+
+def start(version):
+ """Rock and roll."""
+ # set up config
+ fname = os.path.join(multiplatform.config_dir, 'encuentro.conf')
+ print "Using configuration file:", repr(fname)
+ logger.info("Using configuration file: %r", fname)
+ config.init(fname)
+
+ # the order of the lines hereafter are very precise, don't mess with them
+ app = QApplication(sys.argv)
+ icon = QIcon(multiplatform.get_path("encuentro/logos/icon-192.png"))
+ app.setWindowIcon(icon)
+
+ MainUI(version, app.quit)
+ sys.exit(app.exec_())
diff --git a/encuentro/multiplatform.py b/encuentro/multiplatform.py
new file mode 100644
index 0000000..fe5d121
--- /dev/null
+++ b/encuentro/multiplatform.py
@@ -0,0 +1,98 @@
+# -*- coding: utf8 -*-
+
+# Copyright 2011-2013 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Multiplatform code."""
+
+import os
+import re
+import subprocess
+import sys
+import user
+
+
+_basedir = os.path.abspath(os.path.dirname(os.path.dirname(
+ os.path.realpath(sys.argv[0]))))
+
+# if the base directory was determined by setup.py, fix it
+# pylint: disable=W0212
+if hasattr(sys, '_INSTALLED_BASE_DIR'):
+ _basedir = sys._INSTALLED_BASE_DIR
+
+# if the base directory was mangled by PyInstaller, fix it
+_frozen = False
+if hasattr(sys, 'frozen'):
+ _basedir = sys._MEIPASS
+ _frozen = True
+# pylint: enable=W0212
+
+if sys.platform == 'win32':
+ # won't find this in linux; pylint: disable=F0401
+ from win32com.shell import shell, shellcon
+ config_dir = shell.SHGetFolderPath(0, shellcon.CSIDL_PROFILE, None, 0)
+ data_dir = shell.SHGetFolderPath(0, shellcon.CSIDL_LOCAL_APPDATA, None, 0)
+ cache_dir = data_dir
+ del shell, shellcon
+else:
+ from xdg import BaseDirectory
+ config_dir = BaseDirectory.xdg_config_home
+ data_dir = BaseDirectory.xdg_data_home
+ cache_dir = BaseDirectory.xdg_cache_home
+ del BaseDirectory
+
+
+def get_path(path):
+ """Build an usable path for media."""
+ parts = path.split("/")
+
+ # if frozen by PyInstaller, all stuff is in the same dir
+ if _frozen:
+ return os.path.join(_basedir, parts[-1])
+
+ # normal work
+ return os.path.join(_basedir, *parts)
+
+
+def sanitize(name):
+ """Sanitize the name according to the OS."""
+ if sys.platform == 'win32':
+ sanit = re.sub(u'[<>:"/|?*]', '', name)
+ else:
+ sanit = re.sub(u'/', '', name)
+ return sanit
+
+
+def get_download_dir():
+ """Get a the download dir for the system.
+
+ I hope this someday will be included in the xdg library :|
+ """
+ try:
+ cmd = ["xdg-user-dir", 'DOWNLOAD']
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
+ base = proc.communicate()[0].strip()
+ except OSError:
+ base = user.home
+ return os.path.join(base, 'encuentro')
+
+
+def open_file(fullpath):
+ """Open the file."""
+ if sys.platform == 'win32':
+ os.startfile(fullpath)
+ else:
+ subprocess.call(["/usr/bin/xdg-open", fullpath])
diff --git a/encuentro/network.py b/encuentro/network.py
new file mode 100644
index 0000000..32bfd8a
--- /dev/null
+++ b/encuentro/network.py
@@ -0,0 +1,478 @@
+# -*- coding: utf8 -*-
+
+# Copyright 2011-2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Some functions to deal with network and Encuentro site."""
+
+import logging
+import os
+import sys
+import time
+import urllib
+import urllib2
+
+from threading import Thread, Event
+from Queue import Queue, Empty
+
+import bs4
+import defer
+import requests
+
+if __name__ == '__main__':
+ # special import before any other imports to configure GUI to use API 2; we
+ # normally don't need to do this *here*, just a support for run
+ # this as a script, for testing/development purpuses
+ import sip
+ for n in "QDate QDateTime QString QTextStream QTime QUrl QVariant".split():
+ sip.setapi(n, 2) # API v2 FTW!
+
+from PyQt4 import QtNetwork, QtCore
+
+from encuentro import multiplatform
+from encuentro.config import config
+
+AUTH_URL = "http://registro.educ.ar/cuentas/ServicioLogin/index"
+CHUNK = 16 * 1024
+MB = 1024 ** 2
+
+BAD_LOGIN_TEXT = "Ingreso de usuario"
+
+DONE_TOKEN = "I positively assure that the download is finished (?)"
+
+logger = logging.getLogger('encuentro.network')
+
+
+class BadCredentialsError(Exception):
+ """Problems with user and/or password."""
+
+
+class EncuentroError(Exception):
+ """Generic problem working with the Encuentro web site."""
+ def __init__(self, message, original_exception=None):
+ self.orig_exc = original_exception
+ super(EncuentroError, self).__init__(message)
+
+
+class CancelledError(Exception):
+ """The download was cancelled."""
+
+
+class MiBrowser(Thread):
+ """Threaded browser to do the download."""
+
+ # we *are* calling parent's init; pylint: disable=W0231
+ def __init__(self, parent, authuser, authpass, url,
+ fname, output_queue, must_quit):
+ self.parent = parent
+ self.authinfo = authuser, authpass
+ self.url = url
+ self.fname = fname
+ self.output_queue = output_queue
+ self.must_quit = must_quit
+ super(MiBrowser, self).__init__()
+
+ def _get_download_content(self):
+ """Get the content handler to download."""
+ # log in
+ logger.debug("Browser download, authenticating")
+ usr, psw = self.authinfo
+ get_data = dict(
+ servicio=self.parent.service,
+ continuar=urllib.quote(self.url),
+ )
+ complete_auth_url = AUTH_URL + "?" + urllib.urlencode(get_data)
+ post_data = dict(
+ login_user_name=usr,
+ login_user_password=psw,
+ r=complete_auth_url,
+ )
+ sess = requests.Session()
+ sess.post(complete_auth_url, post_data)
+
+ # get page with useful link
+ logger.debug("Browser download, getting html")
+ html = sess.get(complete_auth_url).content
+ if BAD_LOGIN_TEXT in html:
+ logger.error("Wrong user or password sent")
+ raise BadCredentialsError()
+ logger.debug("Browser download, got html len %d", len(html))
+
+ # download from the new url
+ soup = bs4.BeautifulSoup(html)
+ new_url = soup.find(attrs={'class': 'descargas'}).find('a')['href']
+ logger.debug("Opening final url %r", new_url)
+ content = urllib2.urlopen(new_url)
+ try:
+ filesize = int(content.headers['content-length'])
+ except KeyError:
+ logger.debug("No content information")
+ else:
+ logger.debug("Got content! filesize: %d", filesize)
+ return content, filesize
+
+ # ok, we don't know what happened :(
+ logger.error("Unknown error while browsing Encuentro: %r", html)
+ raise EncuentroError("Unknown problem when getting download link")
+
+ def run(self):
+ """Do the heavy work."""
+ # open the url and send the content
+ logger.debug("Browser opening url %s", self.url)
+ try:
+ content, filesize = self._get_download_content()
+ except Exception as err:
+ self.output_queue.put(err)
+ return
+
+ aout = open(self.fname, "wb")
+ tot = 0
+ size_mb = filesize / (1024.0 ** 2)
+ while not self.must_quit.is_set():
+ r = content.read(CHUNK)
+ if r == "":
+ break
+ aout.write(r)
+ tot += len(r)
+ m = "%.1f%% (de %d MB)" % (tot * 100.0 / filesize, size_mb)
+ self.output_queue.put(m)
+ content.close()
+ self.output_queue.put(DONE_TOKEN)
+
+
+class DeferredQueue(Queue):
+ """A Queue with a deferred get."""
+
+ _call_period = 500
+
+ def deferred_get(self):
+ """Return a deferred that is triggered when data."""
+ d = defer.Deferred()
+ attempts = [None] * 6
+
+ def check():
+ """Check if we have data and transmit it."""
+ try:
+ data = self.get(block=False)
+ except Empty:
+ # no data, check again later, unless we had too many attempts
+ attempts.pop()
+ if attempts:
+ QtCore.QTimer.singleShot(self._call_period, check)
+ else:
+ # finish without data, for external loop to do checks
+ d.callback(None)
+ else:
+ # have some data, let's check if there's more
+ all_data = [data]
+ try:
+ while True:
+ all_data.append(self.get(block=False))
+ except Empty:
+ # we're done!
+ d.callback(all_data)
+
+ QtCore.QTimer.singleShot(self._call_period, check)
+ return d
+
+
+class BaseDownloader(object):
+ """Base episode downloader."""
+
+ def shutdown(self):
+ """Quit the download."""
+ return self._shutdown()
+
+ def cancel(self):
+ """Cancel a download."""
+ return self._cancel()
+
+ def _setup_target(self, channel, section, season, title, extension):
+ """Set up the target file to download."""
+ # build where to save it
+ downloaddir = config.get('downloaddir', '')
+ channel = multiplatform.sanitize(channel)
+ section = multiplatform.sanitize(section)
+ title = multiplatform.sanitize(title)
+
+ if season is not None:
+ season = multiplatform.sanitize(season)
+ fname = os.path.join(downloaddir, channel, section,
+ season, title + extension)
+ else:
+ fname = os.path.join(downloaddir, channel, section,
+ title + extension)
+
+ # if the directory doesn't exist, create it
+ dirsecc = os.path.dirname(fname)
+ if not os.path.exists(dirsecc):
+ os.makedirs(dirsecc)
+
+ tempf = fname + str(time.time())
+ return fname, tempf
+
+ def download(self, channel, section, season, title, url, cb_progress):
+ """Download an episode."""
+ return self._download(channel, section, season,
+ title, url, cb_progress)
+
+
+class AuthenticatedDownloader(BaseDownloader):
+ """Episode downloader for Conectar site."""
+
+ def __init__(self):
+ super(AuthenticatedDownloader, self).__init__()
+ self._prev_progress = None
+ self.browser_quit = set()
+ self.cancelled = False
+ logger.info("Conectar downloader inited")
+
+ def _shutdown(self):
+ """Quit the download."""
+ for bquit in self.browser_quit:
+ bquit.set()
+ logger.info("Conectar downloader shutdown finished")
+
+ def _cancel(self):
+ """Cancel a download."""
+ self.cancelled = True
+ logger.info("Conectar downloader cancelled")
+
+ @defer.inline_callbacks
+ def _download(self, canal, seccion, season, titulo, url, cb_progress):
+ """Download an episode to disk."""
+ self.cancelled = False
+
+ # levantamos el browser
+ qinput = DeferredQueue()
+ bquit = Event()
+ self.browser_quit.add(bquit)
+ authuser = config.get('user', '')
+ authpass = config.get('password', '')
+
+ # build where to save it
+ fname, tempf = self._setup_target(canal, seccion, season,
+ titulo, u".avi")
+ logger.debug("Downloading to temporal file %r", tempf)
+
+ logger.info("Download episode %r: browser started", url)
+ brow = MiBrowser(self, authuser, authpass, url, tempf, qinput, bquit)
+ brow.start()
+
+ # loop reading until finished
+ self._prev_progress = None
+
+ logger.info("Downloader started receiving bytes")
+ while True:
+ # get all data and just use the last item
+ payload = yield qinput.deferred_get()
+ if self.cancelled:
+ logger.debug("Cancelled! Quit browser, wait, and clean.")
+ bquit.set()
+ yield qinput.deferred_get()
+ if os.path.exists(tempf):
+ os.remove(tempf)
+ logger.debug("Cancelled! Cleaned up.")
+ raise CancelledError()
+
+ # special situations
+ if payload is None:
+ # no data, let's try again
+ continue
+ data = payload[-1]
+ if isinstance(data, Exception):
+ raise data
+ if data == DONE_TOKEN:
+ break
+
+ # actualizamos si hay algo nuevo
+ if data != self._prev_progress:
+ cb_progress(data)
+ self._prev_progress = data
+
+ # movemos al nombre correcto y terminamos
+ logger.info("Downloading done, renaming temp to %r", fname)
+ os.rename(tempf, fname)
+ self.browser_quit.remove(bquit)
+ defer.return_value(fname)
+
+
+class ConectarDownloader(AuthenticatedDownloader):
+ """Episode downloader for Conectar site."""
+ service = 'conectate'
+
+
+class EncuentroDownloader(AuthenticatedDownloader):
+ """Episode downloader for Conectar site."""
+ service = 'encuentro'
+
+
+class _GenericDownloader(BaseDownloader):
+ """Episode downloader for a generic site that works with urllib2."""
+
+ headers = {
+ 'User-Agent': 'Mozilla/5.0',
+ 'Accept': '*/*',
+ }
+ manager = QtNetwork.QNetworkAccessManager()
+ file_extension = None # to be overwritten by class child
+
+ def __init__(self):
+ super(_GenericDownloader, self).__init__()
+ self._prev_progress = None
+ self.downloader_deferred = None
+ logger.info("Generic downloader inited")
+
+ def _shutdown(self):
+ """Quit the download."""
+ logger.info("Generic downloader shutdown finished")
+
+ def _cancel(self):
+ """Cancel a download."""
+ if self.downloader_deferred is not None:
+ logger.info("Generic downloader cancelled")
+ exc = CancelledError("Cancelled by user")
+ self.downloader_deferred.errback(exc)
+
+ @defer.inline_callbacks
+ def _download(self, canal, seccion, season, titulo, url, cb_progress):
+ """Download an episode to disk."""
+ url = str(url)
+ logger.info("Download episode %r", url)
+
+ # build where to save it
+ fname, tempf = self._setup_target(canal, seccion,
+ season, titulo, self.file_extension)
+ logger.debug("Downloading to temporal file %r", tempf)
+ fh = open(tempf, "wb")
+
+ def report(dloaded, total):
+ """Report download."""
+ if total == -1:
+ m = "%d MB" % (dloaded // MB,)
+ else:
+ size_mb = total // MB
+ perc = dloaded * 100.0 / total
+ m = "%.1f%% (de %d MB)" % (perc, size_mb)
+ if m != self._prev_progress:
+ cb_progress(m)
+ self._prev_progress = m
+
+ def save():
+ """Save available bytes to disk."""
+ data = req.read(req.bytesAvailable())
+ fh.write(data)
+
+ request = QtNetwork.QNetworkRequest()
+ request.setUrl(QtCore.QUrl(url))
+ for hk, hv in self.headers.items():
+ request.setRawHeader(hk, hv)
+
+ def end_ok():
+ """Finish Ok politely the deferred."""
+ if not self.downloader_deferred.called:
+ self.downloader_deferred.callback(True)
+
+ def end_fail(exc):
+ """Finish in error politely the deferred."""
+ if not self.downloader_deferred.called:
+ self.downloader_deferred.errback(exc)
+
+ deferred = self.downloader_deferred = defer.Deferred()
+ req = self.manager.get(request)
+ req.downloadProgress.connect(report)
+ req.error.connect(end_fail)
+ req.readyRead.connect(save)
+ req.finished.connect(end_ok)
+
+ try:
+ yield deferred
+ except Exception as err:
+ logger.debug("Exception when waiting deferred: %s (request "
+ "finished? %s)", err, req.isFinished())
+ if not req.isFinished():
+ logger.debug("Aborting QNetworkReply")
+ req.abort()
+ raise
+ finally:
+ fh.close()
+
+ # rename to final name and end
+ logger.info("Downloading done, renaming temp to %r", fname)
+ os.rename(tempf, fname)
+ defer.return_value(fname)
+
+
+class GenericVideoDownloader(_GenericDownloader):
+ """Generic downloaded that saves video."""
+ file_extension = u".mp4"
+
+
+class GenericAudioDownloader(_GenericDownloader):
+ """Generic downloaded that saves audio."""
+ file_extension = u".mp3"
+
+
+# this is the entry point to get the downloaders for each type
+all_downloaders = {
+ 'encuentro': EncuentroDownloader,
+ 'conectar': ConectarDownloader,
+ 'generic': GenericVideoDownloader,
+ 'dqsv': GenericAudioDownloader,
+}
+
+
+if __name__ == "__main__":
+ h = logging.StreamHandler()
+ h.setLevel(logging.DEBUG)
+ logger.setLevel(logging.DEBUG)
+ logger.addHandler(h)
+
+ def show(avance):
+ """Show progress."""
+ print "Avance:", avance
+
+ # overwrite config for the test
+ config = dict(user="lxpdvtnvrqdoa@mailinator.com", # NOQA
+ password="descargas", downloaddir='.')
+
+ # the three versions to test
+ downloader = EncuentroDownloader()
+ _url = "http://www.encuentro.gob.ar/sitios/encuentro/"\
+ "Programas/ver?rec_id=120761"
+
+# downloader = ConectarDownloader()
+# _url = "http://www.conectate.gob.ar/sitios/conectate/"\
+# "busqueda/pakapaka?rec_id=103605"
+
+ app = QtCore.QCoreApplication(sys.argv)
+# downloader = GenericVideoDownloader()
+# _url = "http://backend.bacua.gob.ar/video.php?v=_f9d06f72"
+
+ @defer.inline_callbacks
+ def download():
+ """Download."""
+ try:
+ fname = yield downloader.download("test-ej-canal", "secc", "temp",
+ "tit", _url, show)
+ print "All done!", fname
+ except CancelledError:
+ print "--- cancelado!"
+ finally:
+ downloader.shutdown()
+ app.exit()
+ download()
+ sys.exit(app.exec_())
diff --git a/encuentro/notify.py b/encuentro/notify.py
new file mode 100644
index 0000000..f6c86ec
--- /dev/null
+++ b/encuentro/notify.py
@@ -0,0 +1,64 @@
+# -*- coding: utf8 -*-
+
+# Copyright 2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""The notification-to-the-desktop subsystem."""
+
+from encuentro.config import config
+
+
+_ERRMSG = u"""
+ERROR! Problema al importar 'pynotify' - No es "estrictamente necesario, pero
+si lo instala tendrá algunas notificaciones en el escritorio.
+"""
+
+
+class _Notifier(object):
+ """A notifier that defers the import as much as possible.
+
+ This is because importing 'pynotify' while PyQt is still starting causes
+ everything to segfault.
+ """
+ def __init__(self):
+ self._inited = False
+
+ def _init(self):
+ """Initialize everything."""
+ self._inited = True
+ try:
+ import pynotify
+ except ImportError:
+ print _ERRMSG
+ self._notify = lambda t, m: None
+ else:
+ pynotify.init("Encuentro")
+
+ def _f(title, message):
+ """The method that will really notify."""
+ if config.get('notification', True):
+ n = pynotify.Notification(title, message)
+ n.show()
+
+ self._notify = _f
+
+ def __call__(self, title, message):
+ if not self._inited:
+ self._init()
+ self._notify(title, message)
+
+
+notify = _Notifier()
diff --git a/encuentro/ui/__init__.py b/encuentro/ui/__init__.py
new file mode 100644
index 0000000..e13d914
--- /dev/null
+++ b/encuentro/ui/__init__.py
@@ -0,0 +1,17 @@
+# Copyright 2013 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""All classes to define the User Interface of the program."""
diff --git a/encuentro/ui/central_panel.py b/encuentro/ui/central_panel.py
new file mode 100644
index 0000000..5bbe7d5
--- /dev/null
+++ b/encuentro/ui/central_panel.py
@@ -0,0 +1,558 @@
+# -*- coding: UTF-8 -*-
+
+# Copyright 2013-2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Central panels in the main window, the content part of all the interface."""
+
+import logging
+import operator
+
+from PyQt4.QtGui import (
+ QAbstractItemView,
+ QAbstractTextDocumentLayout,
+ QApplication,
+ QColor,
+ QHBoxLayout,
+ QImage,
+ QLabel,
+ QMenu,
+ QPalette,
+ QPixmap,
+ QPushButton,
+ QStyle,
+ QStyleOptionViewItemV4,
+ QStyledItemDelegate,
+ QTextDocument,
+ QTextEdit,
+ QTreeWidgetItem,
+ QVBoxLayout,
+ QWidget,
+)
+from PyQt4.QtCore import Qt, QSize
+
+from encuentro import data, image
+from encuentro.config import config, signal
+from encuentro.data import Status
+from encuentro.ui import remembering
+from encuentro.ui.throbber import Throbber
+
+logger = logging.getLogger("encuentro.centralpanel")
+
+
+class DownloadsWidget(remembering.RememberingTreeWidget):
+ """The downloads queue."""
+
+ def __init__(self, episodes_widget):
+ self.episodes_widget = episodes_widget
+ super(DownloadsWidget, self).__init__('downloads')
+ signal.register(self.save_state)
+
+ _headers = (u"Descargando...", u"Estado")
+ self.setColumnCount(len(_headers))
+ self.setHeaderLabels(_headers)
+
+ self.queue = []
+ self.current = -1
+ self.downloading = False
+
+ # connect the signals
+ self.clicked.connect(self.on_signal_clicked)
+
+ def on_signal_clicked(self, _):
+ """The view was clicked."""
+ item = self.currentItem()
+ self.episodes_widget.show(item.episode_id)
+
+ def append(self, episode):
+ """Append an episode to the downloads list."""
+ # add to the list in the GUI
+ item = QTreeWidgetItem((episode.composed_title, u"Encolado"))
+ item.episode_id = episode.episode_id
+ self.queue.append((episode, item))
+ self.addTopLevelItem(item)
+ self.setCurrentItem(item)
+
+ # fix episode state
+ episode.state = Status.waiting
+
+ def prepare(self):
+ """Set up everything for next download."""
+ self.downloading = True
+ self.current += 1
+ episode, _ = self.queue[self.current]
+ episode.state = Status.downloading
+ return episode
+
+ def start(self):
+ """Download started."""
+ episode, item = self.queue[self.current]
+ item.setText(1, u"Comenzando")
+ episode.state = Status.downloading
+
+ def progress(self, progress):
+ """Advance the progress indicator."""
+ _, item = self.queue[self.current]
+ item.setText(1, u"Descargando: %s" % progress)
+
+ def end(self, error=None):
+ """Mark episode as downloaded."""
+ episode, item = self.queue[self.current]
+ if error is None:
+ # downloaded OK
+ gui_msg = u"Terminado ok"
+ end_state = Status.downloaded
+ else:
+ # something bad happened
+ gui_msg = unicode(error)
+ end_state = Status.none
+ item.setText(1, gui_msg)
+ item.setDisabled(True)
+ episode.state = end_state
+ self.episodes_widget.set_color(episode)
+ self.downloading = False
+
+ def cancel(self):
+ """The download is being cancelled."""
+ episode, item = self.queue[self.current]
+ item.setText(1, u"Cancelado")
+ episode.state = Status.none
+
+ def unqueue(self, episode):
+ """Remove the indicated episode from the queue."""
+ episode.state = Status.none
+
+ # search for the item, adjust the queue and remove it from the widget
+ for pos, (queued_episode, item) in enumerate(self.queue):
+ if queued_episode.episode_id == episode.episode_id:
+ break
+ else:
+ raise ValueError(
+ "Couldn't find episode to unqueue: " + str(episode))
+ del self.queue[pos]
+ self.takeTopLevelItem(pos)
+
+ # as we removed an item, the cursor goes to other, fix the rest of
+ # the interface
+ item = self.currentItem()
+ self.episodes_widget.show(item.episode_id)
+
+ def pending(self):
+ """Return the pending downloads quantity (including current)."""
+ # remaining after current one
+ q = len(self.queue) - self.current - 1
+ # if we're still downloading current one, add it to the count
+ if self.downloading:
+ q += 1
+ return q
+
+ def save_state(self):
+ """Save state for pending downloads."""
+ p = self.pending()
+ if p > 0:
+ pending_ids = [e.episode_id for e, _ in self.queue[-p:]]
+ else:
+ pending_ids = []
+ config[config.SYSTEM]['pending_ids'] = pending_ids
+
+ def load_pending(self):
+ """Queue the pending downloads."""
+ loaded_pending_ids = config[config.SYSTEM].get('pending_ids', [])
+
+ for episode_id in loaded_pending_ids:
+ main_window = self.episodes_widget.main_window
+ try:
+ episode = main_window.programs_data[episode_id]
+ except KeyError:
+ logger.debug("Tried to load pending %r, didn't find it",
+ episode_id)
+ else:
+ main_window.queue_download(episode)
+
+
+class HTMLDelegate(QStyledItemDelegate):
+ """Custom delegate so the QTreeWidget can do HTML.
+
+ This is an adaptation of a post here:
+
+ http://stackoverflow.com/questions/10924175/how-do-i-use-a-
+ qstyleditemdelegate-to-paint-only-the-background-without-coverin
+
+ We only need to do background highlighting, so probably this will be
+ trimmed as much as possible for performance reasons.
+
+ Also, we only do HTML for one column, the rest is delegated to parent.
+ """
+ def __init__(self, parent, html_column):
+ self._html_column = html_column
+ QStyledItemDelegate.__init__(self, parent)
+
+ def paint(self, painter, option, index):
+ """Render the delegate for the item."""
+ if index.column() != self._html_column:
+ return QStyledItemDelegate.paint(self, painter, option, index)
+
+ options = QStyleOptionViewItemV4(option)
+ self.initStyleOption(options, index)
+
+ if options.widget is None:
+ style = QApplication.style()
+ else:
+ style = options.widget.style()
+
+ doc = QTextDocument()
+ doc.setHtml(options.text)
+
+ options.text = ""
+ style.drawControl(QStyle.CE_ItemViewItem, options, painter)
+
+ ctx = QAbstractTextDocumentLayout.PaintContext()
+
+ textRect = style.subElementRect(QStyle.SE_ItemViewItemText, options)
+ painter.save()
+ painter.translate(textRect.topLeft())
+ painter.setClipRect(textRect.translated(-textRect.topLeft()))
+ doc.documentLayout().draw(painter, ctx)
+ painter.restore()
+
+ def sizeHint(self, option, index):
+ """Calculate the needed size."""
+ options = QStyleOptionViewItemV4(option)
+ self.initStyleOption(options, index)
+
+ doc = QTextDocument()
+ doc.setHtml(options.text)
+ doc.setTextWidth(options.rect.width())
+ return QSize(doc.idealWidth(), doc.size().height())
+
+
+class EpisodesWidget(remembering.RememberingTreeWidget):
+ """The list of episodes info."""
+
+ _row_getter = operator.attrgetter('channel', 'section',
+ 'composed_title', 'duration')
+ _title_column = 2
+
+ def __init__(self, main_window, episode_info):
+ self.main_window = main_window
+ self.episode_info = episode_info
+ super(EpisodesWidget, self).__init__('episodes')
+ self.setMinimumSize(600, 300)
+ self.setItemDelegate(HTMLDelegate(self, self._title_column))
+
+ _headers = (u"Canal", u"Sección", u"Título", u"Duración [min]")
+ self.setColumnCount(len(_headers))
+ self.setHeaderLabels(_headers)
+ header = self.header()
+ header.setStretchLastSection(False)
+ header.setResizeMode(2, header.Stretch)
+ episodes = list(self.main_window.programs_data.values())
+
+ self._item_map = {}
+ for e in episodes:
+ item = QTreeWidgetItem([unicode(v) for v in self._row_getter(e)])
+ item.episode_id = e.episode_id
+ item.setTextAlignment(3, Qt.AlignRight)
+ self._item_map[e.episode_id] = item
+ self.addTopLevelItem(item)
+ self.set_color(e)
+
+ # enable sorting
+ self.setSortingEnabled(True)
+ self.setSelectionMode(QAbstractItemView.ExtendedSelection)
+
+ # connect the signals
+ self.clicked.connect(self.on_signal_clicked)
+ self.setContextMenuPolicy(Qt.CustomContextMenu)
+ self.customContextMenuRequested.connect(self.on_right_button)
+ self.itemDoubleClicked.connect(self.on_double_click)
+ self.currentItemChanged.connect(self.on_change)
+
+ def show(self, episode_id):
+ """Show the row for the requested episode."""
+ item = self._item_map[episode_id]
+ self.setCurrentItem(item)
+ self._adjust_gui(episode_id)
+
+ def on_signal_clicked(self, _):
+ """The view was clicked."""
+ item = self.currentItem()
+ self._adjust_gui(item.episode_id)
+
+ def on_change(self, _):
+ """The view was clicked."""
+ item = self.currentItem()
+ self._adjust_gui(item.episode_id)
+
+ def on_double_click(self, item, col):
+ """Double click."""
+ episode = self.main_window.programs_data[item.episode_id]
+ logger.debug("Double click in %s", episode)
+ if episode.state == Status.downloaded:
+ self.main_window.play_episode(episode)
+ elif episode.state == Status.none:
+ if self.main_window.have_config():
+ self.main_window.queue_download(episode)
+ else:
+ logger.debug("Not starting download because no config.")
+ t = (u"No se puede arrancar una descarga porque la "
+ u"configuración está incompleta.")
+ self.main_window.show_message(u'Falta configuración', t)
+
+ def _adjust_gui(self, episode_id):
+ """Adjust the rest of the GUI for this episode."""
+ episode = self.main_window.programs_data[episode_id]
+ logger.debug("Showing episode: %s", episode)
+ self.episode_info.update(episode)
+ self.main_window.check_download_play_buttons()
+
+ def on_right_button(self, point):
+ """Right button was pressed, build a menu."""
+ item = self.currentItem()
+ self._adjust_gui(item.episode_id)
+ episode = self.main_window.programs_data[item.episode_id]
+ menu = QMenu()
+ mw = self.main_window
+ act_play = menu.addAction(u"&Reproducir",
+ lambda: mw.play_episode(episode))
+ act_cancel = menu.addAction(u"&Cancelar descarga",
+ lambda: mw.cancel_download(episode))
+ act_download = menu.addAction(u"&Descargar",
+ lambda: mw.queue_download(episode))
+
+ # set menu options according status
+ state = episode.state
+ if state == Status.downloaded:
+ act_play.setEnabled(True)
+ act_cancel.setEnabled(False)
+ act_download.setEnabled(False)
+ elif state == Status.downloading or state == Status.waiting:
+ act_play.setEnabled(False)
+ act_cancel.setEnabled(True)
+ act_download.setEnabled(False)
+ elif state == Status.none:
+ act_play.setEnabled(False)
+ act_cancel.setEnabled(False)
+ if self.main_window.have_config():
+ act_download.setEnabled(True)
+ else:
+ act_download.setEnabled(False)
+ menu.exec_(self.viewport().mapToGlobal(point))
+
+ def update_episode(self, episode):
+ """Update episode with new info"""
+ item = self._item_map[episode.episode_id]
+ for i, v in enumerate(self._row_getter(episode)):
+ item.setText(i, unicode(v))
+
+ def add_episode(self, episode):
+ """Update episode with new info"""
+ item = QTreeWidgetItem([unicode(v) for v in self._row_getter(episode)])
+ item.episode_id = episode.episode_id
+ item.setTextAlignment(3, Qt.AlignRight)
+ self._item_map[episode.episode_id] = item
+ self.set_color(episode)
+ self.addTopLevelItem(item)
+
+ def set_color(self, episode):
+ """Set the background color for an episode (if needed)."""
+ if episode.state == Status.downloaded:
+ color = QColor("light green")
+ else:
+ palette = self.palette()
+ color = palette.color(QPalette.AlternateBase)
+ item = self._item_map[episode.episode_id]
+ for i in xrange(item.columnCount()):
+ item.setBackgroundColor(i, color)
+
+ def set_filter(self, text, only_downloaded=False):
+ """Apply a filter to the episodes list."""
+ text = data.prepare_to_filter(text)
+ for episode_id, item in self._item_map.iteritems():
+ episode = self.main_window.programs_data[episode_id]
+
+ params = episode.filter_params(text, only_downloaded)
+ if params is None:
+ item.setHidden(True)
+ else:
+ item.setHidden(False)
+ pos1, pos2 = params
+ if pos1 is None:
+ # no highlighting
+ item.setText(self._title_column, episode.composed_title)
+ else:
+ # filtering by text, so highlight
+ t = episode.composed_title
+ ht = u''.join((t[:pos1],
+ '',
+ t[pos1:pos2], '', t[pos2:]))
+ item.setText(self._title_column, ht)
+
+ # clear the selection to get consistent behaviour (because otherwise
+ # something will keep selected when widening the filter, or nothing
+ # will be selected when the filter gets more restrict)
+ self.clearSelection()
+
+ # now nothing is selected, so clear the episode info
+ self.episode_info.clear()
+
+
+class EpisodeInfo(QWidget):
+ """Show the episode at the right."""
+ def __init__(self, main_window):
+ self.main_window = main_window
+ super(EpisodeInfo, self).__init__()
+
+ self.current_episode = None
+ layout = QVBoxLayout(self)
+
+ # a throbber, that we don't initially show
+ self.throbber = Throbber()
+ layout.addWidget(self.throbber)
+ self.throbber.hide()
+
+ # the image and its getter
+ self.image_episode = QLabel()
+ self.image_episode.hide()
+ layout.addWidget(self.image_episode, alignment=Qt.AlignCenter)
+ self.get_image = image.ImageGetter(self.image_episode_loaded).get_image
+
+ # text area
+ self.text_edit = QTextEdit(
+ u"Seleccionar un programa para ver aquí la info.")
+ self.text_edit.setReadOnly(True)
+ layout.addWidget(self.text_edit)
+
+ # the button
+ self.button = QPushButton()
+ self.button.connected = False
+ self.button.hide()
+ layout.addWidget(self.button)
+
+ def image_episode_loaded(self, episode_id, image_path):
+ """An image has arrived, show it only if the path is correct."""
+ # only set the image if the user still have the same episode selected
+ if self.current_episode != episode_id:
+ return
+
+ # load the image and show it
+ pixmap = QPixmap(image_path)
+ self.image_episode.setPixmap(pixmap)
+ self.image_episode.show()
+
+ # hide the throbber
+ self.throbber.hide()
+
+ def clear(self):
+ """Clear the episode info panel."""
+ self.throbber.hide()
+ self.image_episode.hide()
+ msg = u"Seleccionar un programa para ver aquí la info."
+ self.text_edit.setText(msg)
+ self.button.hide()
+
+ def update(self, episode):
+ """Update all the episode info."""
+ self.current_episode = episode.episode_id
+
+ # image
+ if episode.image_data is not None:
+ # have the image data already!!
+ qimg = QImage.fromData(episode.image_data)
+ pixmap = QPixmap.fromImage(qimg)
+ self.image_episode.setPixmap(pixmap)
+ self.image_episode.show()
+ elif episode.image_url is not None:
+ # this must be before the get_image call, as it may call
+ # immediately to image_episode_loaded (showing the image and
+ # hiding the throber)
+ self.image_episode.hide()
+ self.throbber.show()
+ # now do call the get_image
+ self.get_image(episode.episode_id,
+ episode.image_url.encode('utf-8'))
+
+ # all description
+ if episode.subtitle is None:
+ msg = u"
%s" % (
+ episode.composed_title, episode.subtitle, episode.description)
+ self.text_edit.setHtml(msg)
+
+ # action button
+ self.button.show()
+ if episode.state == data.Status.downloaded:
+ label = "Reproducir"
+ func = self.main_window.play_episode
+ enable = True
+ remove = False
+ elif episode.state == data.Status.downloading:
+ label = u"Cancelar descarga"
+ func = self.main_window.cancel_download
+ enable = True
+ remove = False
+ elif episode.state == data.Status.waiting:
+ label = u"Sacar de la cola"
+ func = self.main_window.unqueue_download
+ enable = True
+ remove = True
+ else:
+ label = u"Descargar"
+ func = self.main_window.download_episode
+ enable = bool(self.main_window.have_config())
+ remove = False
+
+ def _exec(func, episode, remove):
+ """Execute a function on the episode and update its info."""
+ func(episode)
+ if not remove:
+ self.update(episode)
+
+ # set button text, disconnect if should, and connect new func
+ self.button.setEnabled(enable)
+ self.button.setText(label)
+ if self.button.connected:
+ self.button.clicked.disconnect()
+ self.button.connected = True
+ self.button.clicked.connect(lambda: _exec(func, episode, remove))
+
+
+class BigPanel(QWidget):
+ """The big panel for the main interface with user."""
+
+ def __init__(self, main_window):
+ super(BigPanel, self).__init__()
+ self.main_window = main_window
+
+ layout = QHBoxLayout(self)
+
+ # get this before, as it be used when creating other sutff
+ episode_info = EpisodeInfo(main_window)
+ self.episodes = EpisodesWidget(main_window, episode_info)
+
+ # split on the right
+ right_split = remembering.RememberingSplitter(Qt.Vertical, 'right')
+ right_split.addWidget(episode_info)
+ self.downloads_widget = DownloadsWidget(self.episodes)
+ right_split.addWidget(self.downloads_widget)
+
+ # main split
+ main_split = remembering.RememberingSplitter(Qt.Horizontal, 'main')
+ main_split.addWidget(self.episodes)
+ main_split.addWidget(right_split)
+ layout.addWidget(main_split)
diff --git a/encuentro/ui/dialogs.py b/encuentro/ui/dialogs.py
new file mode 100644
index 0000000..68369b9
--- /dev/null
+++ b/encuentro/ui/dialogs.py
@@ -0,0 +1,99 @@
+# -*- coding: utf8 -*-
+
+# Copyright 2013 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Several dialogs."""
+
+from PyQt4.QtGui import (
+ QDialog,
+ QDialogButtonBox,
+ QLabel,
+ QPlainTextEdit,
+ QPushButton,
+ QVBoxLayout,
+)
+
+UPGRADE_TEXT = u"""
+Esta nueva versión del programa Encuentro sólo funciona con contenido
+actualizado, lo cual le permitirá trabajar con programas del canal
+Encuentro y de otros nuevos canales, pero deberá configurarlo
+nuevamente y perderá la posibilidad de ver directactamente los
+videos ya descargados (los cuales permanecerán en su disco).
+
+Haga click en Continuar y podrá ver el Wizard que lo ayudará a
+configurar nuevamente el programa.
+"""
+
+
+class ForceUpgradeDialog(QDialog):
+ """The dialog for a force upgrade."""
+ def __init__(self):
+ super(ForceUpgradeDialog, self).__init__()
+ vbox = QVBoxLayout(self)
+
+ self.setWindowTitle(u"El contenido debe actualizarse")
+
+ self.main_text = QLabel(UPGRADE_TEXT)
+ vbox.addWidget(self.main_text)
+
+ bbox = QDialogButtonBox()
+ bbox.addButton(QPushButton(u"Salir del programa"),
+ QDialogButtonBox.AcceptRole)
+ bbox.accepted.connect(self.accept)
+ bbox.addButton(QPushButton(u"Continuar"),
+ QDialogButtonBox.RejectRole)
+ bbox.rejected.connect(self.reject)
+ vbox.addWidget(bbox)
+ self.show()
+
+
+class UpdateDialog(QDialog):
+ """The dialog for update."""
+ def __init__(self):
+ super(UpdateDialog, self).__init__()
+ vbox = QVBoxLayout(self)
+ self.closed = False
+ self.setModal(True)
+ self.resize(500, 250)
+
+ vbox.addWidget(QLabel(u"Actualización de la lista de episodios:"))
+ self.text = QPlainTextEdit()
+ self.text.setReadOnly(True)
+ vbox.addWidget(self.text)
+
+ bbox = QDialogButtonBox(QDialogButtonBox.Cancel)
+ bbox.rejected.connect(self.reject)
+ vbox.addWidget(bbox)
+
+ def append(self, text):
+ """Append some text in the dialog."""
+ self.text.appendPlainText(text.strip())
+
+ def closeEvent(self, event):
+ """It was closed."""
+ self.closed = True
+
+
+if __name__ == '__main__':
+ import sys
+
+ from PyQt4.QtGui import QApplication
+ app = QApplication(sys.argv)
+
+ frame = UpdateDialog()
+ frame.show()
+ frame.exec_()
diff --git a/encuentro/ui/main.py b/encuentro/ui/main.py
new file mode 100644
index 0000000..be384f2
--- /dev/null
+++ b/encuentro/ui/main.py
@@ -0,0 +1,522 @@
+# -*- coding: utf-8 -*-
+
+# Copyright 2013-2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""The main window."""
+
+import logging
+import os
+import datetime as dt
+
+import defer
+
+from PyQt4.QtGui import (
+ QAction,
+ QCheckBox,
+ QLabel,
+ QLineEdit,
+ QMessageBox,
+ QSizePolicy,
+ QStyle,
+ QWidget,
+)
+
+from encuentro import multiplatform, data, update
+from encuentro.config import config, signal
+from encuentro.data import Status
+from encuentro.network import (
+ BadCredentialsError,
+ CancelledError,
+ EncuentroError,
+ all_downloaders,
+)
+from encuentro.notify import notify
+from encuentro.ui import (
+ central_panel,
+ preferences,
+ remembering,
+ systray,
+ wizard,
+)
+
+logger = logging.getLogger('encuentro.main')
+
+# tooltips for buttons enabled and disabled
+TTIP_PLAY_E = u'Reproducir el programa'
+TTIP_PLAY_D = (
+ u"Reproducir - El episodio debe estar descargado para poder verlo."
+)
+TTIP_DOWNLOAD_E = u'Descargar el programa de la web'
+TTIP_DOWNLOAD_D = (
+ u"Descargar - No se puede descargar si ya está descargado o falta "
+ u"alguna configuración en el programa."
+)
+
+ABOUT_TEXT = u"""
+
+Simple programa que permite buscar, descargar y ver
+contenido del canal Encuentro y otros.
+
+Versión %s
+
+Copyright 2010-2013 Facundo Batista
+
+
+ http://encuentro.taniquetil.com.ar
+
+
+"""
+
+
+class MainUI(remembering.RememberingMainWindow):
+ """Main UI."""
+
+ _programs_file = os.path.join(multiplatform.data_dir, 'encuentro.data')
+
+ def __init__(self, version, app_quit):
+ super(MainUI, self).__init__()
+ self.app_quit = app_quit
+ self.finished = False
+ self.version = version
+ self.setWindowTitle('Encuentro')
+
+ self.programs_data = data.ProgramsData(self, self._programs_file)
+ self._touch_config()
+
+ self.downloaders = {}
+ for downtype, dloader_class in all_downloaders.iteritems():
+ self.downloaders[downtype] = dloader_class()
+
+ # finish all gui stuff
+ self.big_panel = central_panel.BigPanel(self)
+ self.episodes_list = self.big_panel.episodes
+ self.episodes_download = self.big_panel.downloads_widget
+ self.setCentralWidget(self.big_panel)
+
+ # the setting of menubar should be almost in the end, because it may
+ # trigger the wizard, which needs big_panel and etc.
+ self.action_play = self.action_download = None
+ self.filter_line = self.filter_cbox = self.needsomething_alert = None
+ self._menubar()
+
+ systray.show(self)
+
+ if config.get('autorefresh'):
+ ue = update.UpdateEpisodes(self)
+ ue.background()
+ else:
+ # refresh data if never done before or if last
+ # update was 7 days ago
+ last_refresh = config.get('autorefresh_last_time')
+ if last_refresh is None or (
+ dt.datetime.now() - last_refresh > dt.timedelta(7)):
+ ue = update.UpdateEpisodes(self)
+ ue.background()
+
+ self.show()
+
+ self.episodes_download.load_pending()
+ logger.debug("Main UI started ok")
+
+ def _touch_config(self):
+ """Do some config processing."""
+ # log the config, but without user and pass
+ safecfg = config.sanitized_config()
+ logger.debug("Configuration loaded: %s", safecfg)
+
+ # we have a default for download dir
+ if not config.get('downloaddir'):
+ config['downloaddir'] = multiplatform.get_download_dir()
+
+ # maybe clean some config
+ if self.programs_data.reset_config_from_migration:
+ config['user'] = ''
+ config['password'] = ''
+ config.pop('cols_width', None)
+ config.pop('cols_order', None)
+ config.pop('selected_row', None)
+
+ def have_config(self):
+ """Return if some config is needed."""
+ return config.get('user') and config.get('password')
+
+ def have_metadata(self):
+ """Return if metadata is needed."""
+ return bool(self.programs_data)
+
+ def _menubar(self):
+ """Set up the menu bar."""
+ menubar = self.menuBar()
+
+ # applications menu
+ menu_appl = menubar.addMenu(u'&Aplicación')
+
+ icon = self.style().standardIcon(QStyle.SP_BrowserReload)
+ action_reload = QAction(icon, '&Refrescar', self)
+ action_reload.setShortcut('Ctrl+R')
+ action_reload.setToolTip(u'Recarga la lista de programas')
+ action_reload.triggered.connect(self.refresh_episodes)
+ menu_appl.addAction(action_reload)
+
+ icon = self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
+ action_preferences = QAction(icon, u'&Preferencias', self)
+ action_preferences.triggered.connect(self.open_preferences)
+ action_preferences.setToolTip(
+ u'Configurar distintos parámetros del programa')
+ menu_appl.addAction(action_preferences)
+
+ menu_appl.addSeparator()
+
+ icon = self.style().standardIcon(QStyle.SP_MessageBoxInformation)
+ _act = QAction(icon, '&Acerca de', self)
+ _act.triggered.connect(self.open_about_dialog)
+ _act.setToolTip(u'Muestra información de la aplicación')
+ menu_appl.addAction(_act)
+
+ icon = self.style().standardIcon(QStyle.SP_DialogCloseButton)
+ _act = QAction(icon, '&Salir', self)
+ _act.setShortcut('Ctrl+Q')
+ _act.setToolTip(u'Sale de la aplicación')
+ _act.triggered.connect(self.on_close)
+ menu_appl.addAction(_act)
+
+ # program menu
+ menu_prog = menubar.addMenu(u'&Programa')
+
+ icon = self.style().standardIcon(QStyle.SP_ArrowDown)
+ self.action_download = QAction(icon, '&Descargar', self)
+ self.action_download.setShortcut('Ctrl+D')
+ self.action_download.setEnabled(False)
+ self.action_download.setToolTip(TTIP_DOWNLOAD_D)
+ self.action_download.triggered.connect(self.download_episode)
+ menu_prog.addAction(self.action_download)
+
+ icon = self.style().standardIcon(QStyle.SP_MediaPlay)
+ self.action_play = QAction(icon, '&Reproducir', self)
+ self.action_play.setEnabled(False)
+ self.action_play.setToolTip(TTIP_PLAY_D)
+ self.action_play.triggered.connect(self.on_play_action)
+ menu_prog.addAction(self.action_play)
+
+ # toolbar for buttons
+ toolbar = self.addToolBar('main')
+ toolbar.addAction(self.action_download)
+ toolbar.addAction(self.action_play)
+ toolbar.addSeparator()
+ toolbar.addAction(action_reload)
+ toolbar.addAction(action_preferences)
+
+ # filter text and button, to the right
+ spacer = QWidget()
+ spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+ toolbar.addWidget(spacer)
+ toolbar.addWidget(QLabel(u"Filtro: "))
+ self.filter_line = QLineEdit()
+ self.filter_line.setMaximumWidth(150)
+ self.filter_line.textChanged.connect(self.on_filter_changed)
+ toolbar.addWidget(self.filter_line)
+ self.filter_cbox = QCheckBox(u"Sólo descargados")
+ self.filter_cbox.stateChanged.connect(self.on_filter_changed)
+ toolbar.addWidget(self.filter_cbox)
+
+ # if needed, a warning that stuff needs to be configured
+ icon = self.style().standardIcon(QStyle.SP_MessageBoxWarning)
+ m = u"Necesita configurar algo; haga click aquí para abrir el wizard"
+ self.needsomething_alert = QAction(icon, m, self)
+ self.needsomething_alert.triggered.connect(self._start_wizard)
+ toolbar.addAction(self.needsomething_alert)
+ if not config.get('nowizard'):
+ self._start_wizard()
+ self._review_need_something_indicator()
+
+ def _start_wizard(self, _=None):
+ """Start the wizard if needed."""
+ if not self.have_config() or not self.have_metadata():
+ dlg = wizard.WizardDialog(self)
+ dlg.exec_()
+ self._review_need_something_indicator()
+
+ def on_filter_changed(self, _):
+ """The filter text has changed, apply it in the episodes list."""
+ text = self.filter_line.text()
+ cbox = self.filter_cbox.checkState()
+ self.episodes_list.set_filter(text, cbox)
+
+ # after applying filter, nothing is selected, so check buttons
+ # (easiest way to clean them all)
+ self.check_download_play_buttons()
+
+ def _review_need_something_indicator(self):
+ """Hide/show/enable/disable different indicators if need sth."""
+ needsomething = bool(not self.have_config() or
+ not self.have_metadata())
+ self.needsomething_alert.setVisible(needsomething)
+
+ def shutdown(self):
+ """Stop everything and quit.
+
+ This shutdown con be called at any time, even on init, so we have
+ extra precautions about which attributes we have.
+ """
+ signal.emit('save_state')
+ config.save()
+ self.finished = True
+
+ programs_data = getattr(self, 'programs_data', None)
+ if programs_data is not None:
+ programs_data.save()
+
+ downloaders = getattr(self, 'downloaders', {})
+ for downloader in downloaders.itervalues():
+ downloader.shutdown()
+
+ # bye bye
+ self.app_quit()
+
+ def on_close(self, _):
+ """Close signal."""
+ if self._should_close():
+ self.shutdown()
+
+ def closeEvent(self, event):
+ """All is being closed."""
+ if self._should_close():
+ self.shutdown()
+ else:
+ event.ignore()
+
+ def _should_close(self):
+ """Still time to decide if want to close or not."""
+ logger.info("Attempt to close the program")
+ pending = self.episodes_download.pending()
+ if not pending:
+ # all fine, save all and quit
+ logger.info("Saving states and quitting")
+ return True
+ logger.debug("Still %d active downloads when trying to quit", pending)
+
+ # stuff pending
+ m = (u"Hay programas todavía en proceso de descarga!\n"
+ u"¿Seguro quiere salir del programa?")
+ QMB = QMessageBox
+ dlg = QMB(u"Guarda!", m, QMB.Question, QMB.Yes, QMB.No, QMB.NoButton)
+ opt = dlg.exec_()
+ if opt != QMB.Yes:
+ logger.info("Quit cancelled")
+ return False
+
+ # quit anyway, put all downloading and pending episodes to none
+ logger.info("Fixing episodes, saving state and exiting")
+ for program in self.programs_data.values():
+ state = program.state
+ if state == Status.waiting or state == Status.downloading:
+ program.state = Status.none
+ return True
+
+ def show_message(self, err_type, text):
+ """Show different messages to the user."""
+ if self.finished:
+ logger.debug("Ignoring message: %r", text)
+ return
+ logger.debug("Showing a message: %r", text)
+
+ # error text can be produced by windows, try to to sanitize it
+ if isinstance(text, str):
+ try:
+ text = text.decode("utf8")
+ except UnicodeDecodeError:
+ try:
+ text = text.decode("latin1")
+ except UnicodeDecodeError:
+ text = repr(text)
+
+ QMB = QMessageBox
+ dlg = QMB(u"Atención: " + err_type, text, QMB.Warning,
+ QMB.Ok, QMB.NoButton, QMB.NoButton)
+ dlg.exec_()
+
+ def refresh_episodes(self, _=None):
+ """Update and refresh episodes."""
+ ue = update.UpdateEpisodes(self)
+ ue.interactive()
+
+ def download_episode(self, _=None):
+ """Download the episode(s)."""
+ items = self.episodes_list.selectedItems()
+ for item in items:
+ episode = self.programs_data[item.episode_id]
+ self.queue_download(episode)
+
+ @defer.inline_callbacks
+ def queue_download(self, episode):
+ """User indicated to download something."""
+ logger.debug("Download requested of %s", episode)
+ if episode.state != Status.none:
+ logger.debug("Download denied, episode %s is not in downloadeable "
+ "state.", episode.episode_id)
+ return
+
+ # queue
+ self.episodes_download.append(episode)
+ self.adjust_episode_info(episode)
+ self.check_download_play_buttons()
+ if self.episodes_download.downloading:
+ return
+
+ logger.debug("Downloads: starting")
+ while self.episodes_download.pending():
+ episode = self.episodes_download.prepare()
+ try:
+ filename, episode = yield self._episode_download(episode)
+ except CancelledError:
+ logger.debug("Got a CancelledError!")
+ self.episodes_download.end(error=u"Cancelado")
+ except BadCredentialsError:
+ logger.debug("Bad credentials error!")
+ msg = (u"Error con las credenciales: hay que configurar "
+ u"usuario y clave correctos")
+ self.show_message('BadCredentialsError', msg)
+ self.episodes_download.end(error=msg)
+ except EncuentroError, e:
+ orig_exc = e.orig_exc
+ msg = "%s(%s)" % (orig_exc, e)
+ err_type = e.__class__.__name__
+ logger.exception("Custom Encuentro error: %s (%r)",
+ e, orig_exc)
+ notify(err_type, msg)
+ self.episodes_download.end(error=u"Error: " + msg)
+ except Exception, e:
+ logger.exception("Unknown download error: %s (%r)", e, e)
+ err_type = e.__class__.__name__
+ notify(err_type, str(e))
+ self.episodes_download.end(error=u"Error: " + str(e))
+ else:
+ logger.debug("Episode downloaded: %s", episode)
+ self.episodes_download.end()
+ episode.filename = filename
+
+ # check buttons
+ self.adjust_episode_info(episode)
+ self.check_download_play_buttons()
+
+ logger.debug("Downloads: finished")
+
+ @defer.inline_callbacks
+ def _episode_download(self, episode):
+ """Effectively download an episode."""
+ logger.debug("Effectively downloading episode %s", episode.episode_id)
+ self.episodes_download.start()
+
+ # download!
+ downloader = self.downloaders[episode.downtype]
+ season = getattr(episode, 'season', None) # wasn't always there
+ fname = yield downloader.download(
+ episode.channel, episode.section, season, episode.title,
+ episode.url, self.episodes_download.progress)
+ episode_name = u"%s - %s - %s" % (episode.channel, episode.section,
+ episode.composed_title)
+ notify(u"Descarga finalizada", episode_name)
+ defer.return_value((fname, episode))
+
+ def open_preferences(self, _=None):
+ """Open the preferences dialog."""
+ dlg = preferences.PreferencesDialog()
+ dlg.exec_()
+ # after dialog closes, config changed, so review indicators
+ self._review_need_something_indicator()
+ safecfg = config.sanitized_config()
+ logger.debug("Configuration changed: %s", safecfg)
+
+ def adjust_episode_info(self, episode):
+ """Adjust the episode info."""
+ self.episodes_list.episode_info.update(episode)
+
+ def check_download_play_buttons(self):
+ """Set both buttons state according to the selected episodes."""
+ items = self.episodes_list.selectedItems()
+
+ # 'play' button should be enabled if only one row is selected and
+ # its state is 'downloaded'
+ play_enabled = False
+ if len(items) == 1:
+ episode = self.programs_data[items[0].episode_id]
+ if episode.state == Status.downloaded:
+ play_enabled = True
+ self.action_play.setEnabled(play_enabled)
+ ttip = TTIP_PLAY_E if play_enabled else TTIP_PLAY_D
+ self.action_play.setToolTip(ttip)
+
+ # 'download' button should be enabled if at least one of the selected
+ # rows is in 'none' state, and if config is ok
+ download_enabled = False
+ if self.have_config():
+ for item in items:
+ episode = self.programs_data[item.episode_id]
+ if episode.state == Status.none:
+ download_enabled = True
+ break
+ ttip = TTIP_DOWNLOAD_E if download_enabled else TTIP_DOWNLOAD_D
+ self.action_download.setEnabled(download_enabled)
+ self.action_download.setToolTip(ttip)
+
+ def on_play_action(self, _=None):
+ """Play the selected episode."""
+ items = self.episodes_list.selectedItems()
+ if len(items) != 1:
+ raise ValueError("Wrong call to play_episode, with %d selections"
+ % len(items))
+ item = items[0]
+ episode = self.programs_data[item.episode_id]
+ self.play_episode(episode)
+
+ def play_episode(self, episode):
+ """Play an episode."""
+ downloaddir = config.get('downloaddir', '')
+ filename = os.path.join(downloaddir, episode.filename)
+
+ logger.info("Play requested of %s", episode)
+ if os.path.exists(filename):
+ # pass file:// url with absolute path
+ fullpath = 'file://' + os.path.abspath(filename)
+ logger.info("Playing %r", fullpath)
+ multiplatform.open_file(fullpath)
+ else:
+ logger.warning("Aborted playing, file not found: %r", filename)
+ msg = (u"No se encontró el archivo para reproducir: " +
+ repr(filename))
+ self.show_message('Error al reproducir', msg)
+ episode.state = Status.none
+ self.episodes_list.set_color(episode)
+
+ def cancel_download(self, episode):
+ """Cancel the downloading of an episode."""
+ logger.info("Cancelling download of %s", episode)
+ self.episodes_download.cancel()
+ downloader = self.downloaders[episode.downtype]
+ downloader.cancel()
+
+ def unqueue_download(self, episode):
+ """Remove the episode from the download queue."""
+ logger.info("Unqueueing %s", episode)
+ self.episodes_download.unqueue(episode)
+
+ def open_about_dialog(self):
+ """Show the about dialog."""
+ version = self.version if self.version else u"(?)"
+ title = "Encuentro v" + version
+ text = ABOUT_TEXT % (version,)
+ QMessageBox.about(self, title, text)
diff --git a/encuentro/ui/media/throbber.gif b/encuentro/ui/media/throbber.gif
new file mode 100644
index 0000000..1a8dfe4
Binary files /dev/null and b/encuentro/ui/media/throbber.gif differ
diff --git a/encuentro/ui/preferences.py b/encuentro/ui/preferences.py
new file mode 100644
index 0000000..c188347
--- /dev/null
+++ b/encuentro/ui/preferences.py
@@ -0,0 +1,206 @@
+# -*- coding: utf8 -*-
+
+# Copyright 2013 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""The preferences dialog."""
+
+import os
+import sys
+import logging
+
+from PyQt4.QtGui import (
+ QCheckBox,
+ QCompleter,
+ QDialog,
+ QDialogButtonBox,
+ QDirModel,
+ QFileDialog,
+ QGridLayout,
+ QLabel,
+ QLineEdit,
+ QPushButton,
+ QTabWidget,
+ QVBoxLayout,
+ QWidget,
+)
+from PyQt4.QtCore import Qt, QDir
+
+from encuentro.config import config
+
+logger = logging.getLogger('encuentro.preferences')
+
+URL_CONECTATE = (
+ "http://registro.educ.ar/cuentas/registro/index?servicio=conectate"
+)
+
+
+class GeneralPreferences(QWidget):
+ """The general preferences input."""
+ def __init__(self):
+ super(GeneralPreferences, self).__init__()
+ grid = QGridLayout(self)
+ grid.setSpacing(20)
+ grid.setColumnStretch(1, 10)
+
+ # directory auto completer
+ completer = QCompleter(self)
+ dirs = QDirModel(self)
+ dirs.setFilter(QDir.AllDirs | QDir.NoDotAndDotDot)
+ completer.setModel(dirs)
+ completer.setCaseSensitivity(Qt.CaseInsensitive)
+ completer.setCompletionMode(QCompleter.PopupCompletion)
+
+ l = QLabel(
+ u"Ingresá el directorio donde descargar los videos...")
+ l.setTextFormat(Qt.RichText)
+ grid.addWidget(l, 0, 0, 1, 2)
+
+ grid.addWidget(QLabel(u"Descargar en:"), 1, 0, 2, 1)
+ prv = config.get('downloaddir', '')
+ self.downloaddir_entry = QLineEdit(prv)
+ self.downloaddir_entry.setCompleter(completer)
+ self.downloaddir_entry.setPlaceholderText(u'Ingresá un directorio')
+ grid.addWidget(self.downloaddir_entry, 1, 1, 2, 2)
+
+ self.downloaddir_buttn = QPushButton(u"Elegir un directorio")
+ self.downloaddir_buttn.clicked.connect(self._choose_dir)
+ grid.addWidget(self.downloaddir_buttn, 2, 1, 3, 2)
+
+ self.autoreload_checkbox = QCheckBox(
+ u"Recargar automáticamente la lista de episodios al iniciar")
+ prv = config.get('autorefresh', False)
+ self.autoreload_checkbox.setChecked(prv)
+ grid.addWidget(self.autoreload_checkbox, 3, 0, 4, 2)
+
+ self.shownotifs_checkbox = QCheckBox(
+ u"Mostrar una notificación cuando termina cada descarga")
+ prv = config.get('notification', True)
+ self.shownotifs_checkbox.setChecked(prv)
+ grid.addWidget(self.shownotifs_checkbox, 4, 0, 5, 2)
+
+ def _choose_dir(self):
+ """Choose a directory using a dialog."""
+ resp = QFileDialog.getExistingDirectory(self, '',
+ os.path.expanduser("~"))
+ if resp:
+ self.downloaddir_entry.setText(resp)
+
+ def get_config(self):
+ """Return the config for this tab."""
+ d = {}
+ d['downloaddir'] = self.downloaddir_entry.text()
+ d['autorefresh'] = self.autoreload_checkbox.isChecked()
+ d['notification'] = self.shownotifs_checkbox.isChecked()
+ return d
+
+
+class ConectatePreferences(QWidget):
+ """The preferences for Conectate backend."""
+ def __init__(self):
+ super(ConectatePreferences, self).__init__()
+ grid = QGridLayout(self)
+ grid.setSpacing(20)
+ grid.setColumnStretch(1, 10)
+
+ l = QLabel(u"Ingresá tus datos del portal Conectate:")
+ l.setTextFormat(Qt.RichText)
+ grid.addWidget(l, 0, 0, 1, 2)
+
+ grid.addWidget(QLabel(u"Usuario:"), 1, 0, 2, 1)
+ prv = config.get('user', '')
+ self.user_entry = QLineEdit(prv)
+ self.user_entry.setPlaceholderText(u'Ingresá tu usuario de Conectate')
+ grid.addWidget(self.user_entry, 1, 1, 2, 2)
+
+ grid.addWidget(QLabel(u"Contraseña:"), 2, 0, 3, 1)
+ prv = config.get('password', '')
+ self.password_entry = QLineEdit(prv)
+ self.password_entry.setEchoMode(QLineEdit.Password)
+ self.password_entry.setPlaceholderText(
+ u'Ingresá tu contraseña de Conectate')
+ grid.addWidget(self.password_entry, 2, 1, 3, 2)
+
+ self.password_mask = QCheckBox(u'Mostrar contraseña')
+ self.password_mask.stateChanged.connect(self._toggle_password_mask)
+ grid.addWidget(self.password_mask, 3, 1, 4, 2)
+
+ l = QLabel(u'Si no tenés estos datos, '
+ u'registrate aquí'.format(URL_CONECTATE))
+ l.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
+ l.setTextFormat(Qt.RichText)
+ l.setOpenExternalLinks(True)
+ grid.addWidget(l, 4, 0, 5, 3)
+
+ def _toggle_password_mask(self):
+ """Toggle the password hiding."""
+ if self.password_mask.isChecked() is True:
+ self.password_entry.setEchoMode(QLineEdit.Normal)
+ else:
+ self.password_entry.setEchoMode(QLineEdit.Password)
+
+ def get_config(self):
+ """Return the config for this tab."""
+ d = {}
+ d['user'] = self.user_entry.text()
+ d['password'] = self.password_entry.text()
+ return d
+
+
+class PreferencesDialog(QDialog):
+ """The dialog for preferences."""
+ def __init__(self):
+ super(PreferencesDialog, self).__init__()
+ vbox = QVBoxLayout(self)
+
+ tabbed = QTabWidget()
+ self.gp = GeneralPreferences()
+ tabbed.addTab(self.gp, u"General")
+ self.cp = ConectatePreferences()
+ tabbed.addTab(self.cp, u"Conectate")
+ vbox.addWidget(tabbed)
+
+ bbox = QDialogButtonBox(QDialogButtonBox.Ok)
+ bbox.accepted.connect(self.accept)
+ bbox.accepted.connect(self._save)
+ vbox.addWidget(bbox)
+
+ def closeEvent(self, event):
+ """Save and close."""
+ self._save()
+ super(PreferencesDialog, self).closeEvent(event)
+
+ def _save(self):
+ """Just save."""
+ # get it from tabs
+ config.update(self.gp.get_config())
+ config.update(self.cp.get_config())
+ config.save()
+
+
+if __name__ == '__main__':
+
+ project_basedir = os.path.abspath(os.path.dirname(os.path.dirname(
+ os.path.realpath(sys.argv[0]))))
+ sys.path.insert(0, project_basedir)
+
+ from PyQt4.QtGui import QApplication
+ app = QApplication(sys.argv)
+
+ frame = PreferencesDialog()
+ frame.show()
+ frame.exec_()
+ frame.save_config()
diff --git a/encuentro/ui/remembering.py b/encuentro/ui/remembering.py
new file mode 100644
index 0000000..4cf1e2e
--- /dev/null
+++ b/encuentro/ui/remembering.py
@@ -0,0 +1,131 @@
+# -*- coding: UTF-8 -*-
+
+# Copyright 2013 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""The remembering widgets."""
+
+from PyQt4.QtCore import Qt
+from PyQt4.QtGui import (
+ QMainWindow,
+ QSplitter,
+ QTreeWidget,
+)
+
+from encuentro.config import config, signal
+
+SYSTEM = config.SYSTEM
+
+
+class RememberingMainWindow(QMainWindow):
+ """A MainWindow that remembers size and position."""
+
+ def __init__(self):
+ super(RememberingMainWindow, self).__init__()
+ signal.register(self.save_state)
+ self._name = self.__class__.__name__
+ self._initted = False
+
+ def showEvent(self, event):
+ """Know when it was shown, load config."""
+ if not self._initted:
+ self._initted = True
+ conf = config[SYSTEM].get(self._name, {})
+ prv_size = conf.get('size', (800, 600))
+ prv_pos = conf.get('pos', (300, 300))
+ self.resize(*prv_size)
+ self.move(*prv_pos)
+ super(RememberingMainWindow, self).showEvent(event)
+
+ def save_state(self):
+ """Save what to remember."""
+ qsize = self.size()
+ size = qsize.width(), qsize.height()
+ qpos = self.pos()
+ pos = qpos.x(), qpos.y()
+ to_save = dict(pos=pos, size=size)
+ config[SYSTEM][self._name] = to_save
+
+
+class RememberingSplitter(QSplitter):
+ """A Splitter that remembers position."""
+
+ def __init__(self, type_, name):
+ super(RememberingSplitter, self).__init__(type_)
+ signal.register(self.save_state)
+ cname = self.__class__.__name__
+ self._name = '-'.join((cname, name))
+ self._initted = False
+
+ def showEvent(self, event):
+ """Know when it was shown, load config."""
+ if not self._initted:
+ self._initted = True
+ sizes = config[SYSTEM].get(self._name)
+ if sizes is not None:
+ self.setSizes(sizes)
+ super(RememberingSplitter, self).showEvent(event)
+
+ def save_state(self):
+ """Save what to remember."""
+ sizes = self.sizes()
+ config[SYSTEM][self._name] = sizes
+
+
+class RememberingTreeWidget(QTreeWidget):
+ """A TreeWidget that remembers visual stuff."""
+
+ def __init__(self, name):
+ super(RememberingTreeWidget, self).__init__()
+ signal.register(self.save_state)
+ cname = self.__class__.__name__
+ self._name = '-'.join((cname, name))
+ self._initted = False
+
+ def showEvent(self, event):
+ """Know when it was shown, load config."""
+ if not self._initted:
+ self._initted = True
+ info = config[SYSTEM].get(self._name)
+ if info is not None:
+ cols_w = info['cols_w']
+ for i, w in enumerate(cols_w):
+ self.setColumnWidth(i, w)
+ s_enabled = info['s_enabled']
+ self.setSortingEnabled(s_enabled)
+ if s_enabled:
+ s_column = info['s_column']
+ s_order = info['s_order']
+ ordr = Qt.AscendingOrder if s_order else Qt.DescendingOrder
+ self.sortItems(s_column, ordr)
+
+ super(RememberingTreeWidget, self).showEvent(event)
+
+ def save_state(self):
+ """Save what to remember."""
+ cols_w = [self.columnWidth(i) for i in xrange(self.columnCount())]
+ s_enabled = self.isSortingEnabled()
+ s_column = self.sortColumn()
+ c = self.topLevelItemCount()
+ if c < 2: # less than two records, no point in sorting
+ s_order = True
+ else:
+ val_first = self.topLevelItem(0).text(s_column)
+ val_last = self.topLevelItem(c - 1).text(s_column)
+ s_order = val_first < val_last
+ info = dict(cols_w=cols_w, s_enabled=s_enabled,
+ s_column=s_column, s_order=s_order)
+ config[SYSTEM][self._name] = info
diff --git a/encuentro/ui/systray.py b/encuentro/ui/systray.py
new file mode 100644
index 0000000..1eaf6db
--- /dev/null
+++ b/encuentro/ui/systray.py
@@ -0,0 +1,102 @@
+# Copyright 2013-2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Show an icon in the systray."""
+
+import json
+import logging
+import subprocess
+
+from encuentro import multiplatform
+
+from PyQt4.QtGui import QSystemTrayIcon, QIcon, QMenu
+
+logger = logging.getLogger("encuentro.systray")
+
+
+def _should_fix():
+ """Tell if we should fix the Unity panel systray settings.
+
+ Return None if don't need, else return the current conf.
+ """
+ cmd = "gsettings get com.canonical.Unity.Panel systray-whitelist".split()
+ try:
+ out = subprocess.check_output(cmd)
+ except Exception, err:
+ # don't have gsettings, nothing to fix
+ etype = err.__class__.__name__
+ logger.debug("No gsettings, no systray conf to fix (got %r %s)",
+ etype, err)
+ return
+
+ try:
+ conf = map(str, json.loads(out.strip().replace("'", '"')))
+ except ValueError:
+ # don't understand the output, can't really fix it :/
+ logger.warning("Don't understand gsettings output: %r", out)
+ return
+
+ logger.info("gsettings conf: %r", conf)
+ if "all" in conf or "encuentro" in conf:
+ # we're ok!
+ return
+
+ # need to fix
+ return conf
+
+
+def _fix_unity_systray():
+ """Check settings."""
+ conf = _should_fix()
+ if conf is None:
+ return
+
+ conf.append("encuentro")
+ cmd = ["gsettings", "set", "com.canonical.Unity.Panel",
+ "systray-whitelist", str(conf)]
+ try:
+ out = subprocess.check_output(cmd)
+ except OSError, err:
+ logger.warning("Error trying to set the new conf: %s", err)
+ else:
+ logger.warning("New config set (result: %r)", out)
+
+
+def show(main_window):
+ """Show a system tray icon with a small icon."""
+ _fix_unity_systray()
+ icon = QIcon(multiplatform.get_path("encuentro/logos/icon-192.png"))
+ sti = QSystemTrayIcon(icon, main_window)
+ if not sti.isSystemTrayAvailable():
+ logger.warning("System tray not available.")
+ return
+
+ def showhide(_):
+ """Show or hide the main window."""
+ if main_window.isVisible():
+ main_window.hide()
+ else:
+ main_window.show()
+
+ _menu = QMenu(main_window)
+ _act = _menu.addAction("Mostrar/Ocultar")
+ _act.triggered.connect(showhide)
+ _act = _menu.addAction("Acerca de")
+ _act.triggered.connect(main_window.open_about_dialog)
+ _act = _menu.addAction("Salir")
+ _act.triggered.connect(main_window.on_close)
+ sti.setContextMenu(_menu)
+ sti.show()
diff --git a/encuentro/ui/throbber.py b/encuentro/ui/throbber.py
new file mode 100644
index 0000000..5fe1ecf
--- /dev/null
+++ b/encuentro/ui/throbber.py
@@ -0,0 +1,47 @@
+# -*- coding: UTF-8 -*-
+
+# Copyright 2013-2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""A throbber."""
+
+from PyQt4.QtGui import (
+ QLabel,
+ QMovie,
+)
+from PyQt4.QtCore import Qt
+
+from encuentro import multiplatform
+
+
+class Throbber(QLabel):
+ """A throbber."""
+ def __init__(self):
+ super(Throbber, self).__init__()
+ self.setAlignment(Qt.AlignCenter)
+ fname = multiplatform.get_path("encuentro/ui/media/throbber.gif")
+ self._movie = QMovie(fname)
+ self.setMovie(self._movie)
+
+ def hide(self):
+ """Overload to control the movie."""
+ self._movie.stop()
+ super(Throbber, self).hide()
+
+ def show(self):
+ """Overload to control the movie."""
+ self._movie.start()
+ super(Throbber, self).show()
diff --git a/encuentro/ui/wizard.py b/encuentro/ui/wizard.py
new file mode 100644
index 0000000..877fb28
--- /dev/null
+++ b/encuentro/ui/wizard.py
@@ -0,0 +1,191 @@
+# -*- coding: utf8 -*-
+# Copyright 2013 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""The wizard that guides the user for the initial setup."""
+
+import logging
+
+from PyQt4.QtGui import (
+ QCheckBox,
+ QDialog,
+ QDialogButtonBox,
+ QLabel,
+ QPushButton,
+ QVBoxLayout,
+)
+
+from encuentro.config import config
+
+logger = logging.getLogger('encuentro.wizard')
+
+TEXT_INIT = u"""
+Bienvenido al visor de contenido del Canal Encuentro y otros.
+Para poder usar el programa debe primero configurarlo!
+"""
+
+TEXT_EPISODES = u"""
+Primero tiene que actualizar la lista de episodios:
+puede actualizar la lista ahora desde esta misma ventana
+y en cualquier momento desde el menú del programa.
+"""
+
+TEXT_CONFIG = u"""
+Para poder descargar los programas de los distintos backends tiene que
+configurar algunos con usuario y clave; puede configurar el sistema
+ahora desde esta misma ventana o en cualquier momento desde el menú
+del programa.
+"""
+
+TEXT_HAPPY_END = u"""
+Felicitaciones, el programa está listo para usar :)
+"""
+
+TEXT_SAD_END = u"""
+¡Ya podes usar el programa!
+(aunque te falta actualizar y/o configurar algo)
+"""
+
+# The steps for the wizard
+# - the text to show to the user
+# - the method to decide if the step should be ignored ("self._ign_"...)
+# - the text for the action button
+# - the action when clicked (will compose method with "self._act_")
+STEPS = [
+ (TEXT_INIT, None, None, None),
+ (TEXT_EPISODES, "episode", u"Actualizar", "update"),
+ (TEXT_CONFIG, "config", u"Configurar", "configure"),
+ (None, None, None, None),
+]
+
+
+class WizardDialog(QDialog):
+ """The dialog for update."""
+ def __init__(self, main_window):
+ super(WizardDialog, self).__init__()
+ self.mw = main_window
+ vbox = QVBoxLayout(self)
+
+ # label and checkbox
+ self.main_text = QLabel(u"init text")
+ vbox.addWidget(self.main_text)
+ self.notthisagain = QCheckBox(u"No mostrar automáticamente esta ayuda")
+ nowizard = config.get('nowizard', False)
+ self.notthisagain.setCheckState(nowizard)
+ self.notthisagain.stateChanged.connect(self._notthisagain_toggled)
+ vbox.addWidget(self.notthisagain)
+
+ # buttons
+ bbox = QDialogButtonBox()
+ self.navbut_actn = QPushButton(u"init text")
+ bbox.addButton(self.navbut_actn, QDialogButtonBox.ActionRole)
+ self.navbut_prev = QPushButton(u"Anterior")
+ bbox.addButton(self.navbut_prev, QDialogButtonBox.ActionRole)
+ self.navbut_next = QPushButton(u"Siguiente")
+ bbox.addButton(self.navbut_next, QDialogButtonBox.ActionRole)
+ vbox.addWidget(bbox)
+
+ self.show()
+ self.step = 0
+ self._move(0)
+
+ def _notthisagain_toggled(self, state):
+ """The "not this again" checkbutton togled state."""
+ logger.info("Configuring 'nowizard' to %s", state)
+ config['nowizard'] = state
+
+ def _move(self, delta_step):
+ """The engine for the wizard steps."""
+ self.step += delta_step
+ logger.debug("Entering into step %d", self.step)
+ (text, ign_func, act_label, act_func) = STEPS[self.step]
+ # if this step should be ignored, just leave
+ if ign_func is not None:
+ m = getattr(self, "_ign_" + ign_func)
+ if m():
+ # keep going
+ return self._move(delta_step)
+
+ # adjust navigation buttons
+ if self.step == 0:
+ self.navbut_prev.setEnabled(False)
+ self.navbut_next.setText(u"Siguiente")
+ self.navbut_next.clicked.disconnect()
+ self.navbut_next.clicked.connect(lambda: self._move(1))
+ self.notthisagain.show()
+ elif self.step == len(STEPS) - 1:
+ self.navbut_prev.setEnabled(True)
+ self.navbut_prev.clicked.disconnect()
+ self.navbut_prev.clicked.connect(lambda: self._move(-1))
+ self.navbut_next.setText(u"Terminar")
+ self.navbut_next.clicked.disconnect()
+ self.navbut_next.clicked.connect(self.accept)
+ self.notthisagain.hide()
+ else:
+ self.navbut_prev.setEnabled(True)
+ self.navbut_prev.clicked.disconnect()
+ self.navbut_prev.clicked.connect(lambda: self._move(-1))
+ self.navbut_next.setText(u"Siguiente")
+ self.navbut_next.clicked.disconnect()
+ self.navbut_next.clicked.connect(lambda: self._move(1))
+ self.notthisagain.hide()
+
+ # adjust main text and action button
+ if self.step == len(STEPS) - 1:
+ if self.mw.have_metadata() and self.mw.have_config():
+ self.main_text.setText(TEXT_HAPPY_END)
+ else:
+ self.main_text.setText(TEXT_SAD_END)
+ else:
+ self.main_text.setText(text)
+
+ if act_label is None:
+ self.navbut_actn.hide()
+ else:
+ self.navbut_actn.show()
+ self.navbut_actn.setText(act_label)
+ method_to_call = getattr(self, "_act_" + act_func)
+ self.navbut_actn.clicked.disconnect()
+ self.navbut_actn.clicked.connect(method_to_call)
+
+ def _act_configure(self, _):
+ """Open the config dialog."""
+ self.mw.open_preferences()
+
+ def _act_update(self, *a):
+ """Open the update dialog."""
+ self.mw.refresh_episodes()
+
+ def _ign_episode(self):
+ """Tell if the episode step should be ignored."""
+ return self.mw.have_metadata()
+
+ def _ign_config(self):
+ """Tell if the configure step should be ignored."""
+ return self.mw.have_config()
+
+
+if __name__ == '__main__':
+ import sys
+
+ from PyQt4.QtGui import QApplication
+ app = QApplication(sys.argv)
+ app.have_metadata = lambda: False
+ app.have_config = lambda: False
+
+ frame = WizardDialog(app)
+ frame.show()
+ frame.exec_()
diff --git a/encuentro/update.py b/encuentro/update.py
new file mode 100644
index 0000000..0f0d1b7
--- /dev/null
+++ b/encuentro/update.py
@@ -0,0 +1,184 @@
+# -*- coding: utf8 -*-
+
+# Copyright 2011-2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Update the episodes metadata."""
+
+import bz2
+import json
+import logging
+from datetime import datetime
+from encuentro.config import config
+
+import defer
+
+from encuentro import utils
+from encuentro.ui import dialogs
+
+BACKENDS_URL = "http://www.taniquetil.com.ar/encuentro/backends-v05.list"
+
+logger = logging.getLogger('encuentro.update')
+
+
+class UpdateEpisodes(object):
+ """Update the episodes info."""
+
+ def __init__(self, main_window):
+ self.main_window = main_window
+
+ def background(self):
+ """Trigger an update in background."""
+ self._update()
+
+ def interactive(self):
+ """Update episodes interactively."""
+ dialog = dialogs.UpdateDialog()
+ dialog.show()
+ self._update(dialog)
+
+ @defer.inline_callbacks
+ def _update(self, dialog=None):
+ """Update the content from server.
+
+ If we have a dialog (interactive update), check frequently if
+ it was closed, so we stop working for that request.
+ """
+ if dialog:
+ tell_user = lambda *t: dialog.append(u" ".join(map(unicode, t)))
+ else:
+ tell_user = lambda *t: None
+
+ logger.info("Downloading backend list")
+ tell_user("Descargando la lista de backends...")
+ try:
+ _, backends_file = yield utils.download(BACKENDS_URL)
+ except Exception, e:
+ logger.error("Problem when downloading backends: %s", e)
+ tell_user("Hubo un PROBLEMA al bajar la lista de backends:", e)
+ return
+ if dialog and dialog.closed:
+ return
+ backends_list = [l.strip().split() for l in backends_file.split("\n")
+ if l and l[0] != '#']
+
+ backends = {}
+ for b_name, b_dloader, b_url in backends_list:
+ logger.info("Downloading backend metadata for %r", b_name)
+ tell_user("Descargando la lista de episodios para backend %r..." %
+ (b_name,))
+ try:
+ _, compressed = yield utils.download(b_url)
+ except Exception, e:
+ logger.error("Problem when downloading episodes: %s", e)
+ tell_user("Hubo un PROBLEMA al bajar los episodios: ", e)
+ return
+ if dialog and dialog.closed:
+ return
+
+ tell_user("Descomprimiendo el archivo....")
+ new_content = bz2.decompress(compressed)
+ logger.debug("Downloaded data decompressed ok")
+
+ content = json.loads(new_content)
+ for item in content:
+ item['downtype'] = b_dloader
+ backends[b_name] = content
+
+ if dialog and dialog.closed:
+ return
+ tell_user("Conciliando datos de diferentes backends")
+ logger.debug("Merging backends data")
+ new_data = self._merge(backends)
+
+ tell_user("Actualizando los datos internos....")
+ logger.debug("Updating internal metadata (%d)", len(new_data))
+ self.main_window.programs_data.merge(
+ new_data, self.main_window.big_panel.episodes)
+
+ config.update({'autorefresh_last_time': datetime.now()})
+ config.save()
+
+ tell_user(u"¡Todo terminado bien!")
+
+ if dialog:
+ dialog.accept()
+
+ def _merge(self, backends):
+ """Merge content from all backends.
+
+ This is for v03-05, with only 'encuentro' and 'conectar' data to be
+ really merged, other data just appended.
+ """
+ raw_encuentro_data = backends.pop('encuentro')
+ raw_conectar_data = backends.pop('conectar')
+ enc_data = dict((x['episode_id'], x) for x in raw_encuentro_data)
+ con_data = dict((x['episode_id'], x) for x in raw_conectar_data)
+ common = set(enc_data) & set(con_data)
+ logger.debug("Merging: encuentro=%d conectar=%d (common=%d)",
+ len(enc_data), len(con_data), len(common))
+
+ # what is in not common in both goes untouched
+ final_data = ([enc_data[epid] for epid in set(enc_data) - common] +
+ [con_data[epid] for epid in set(con_data) - common])
+
+ # what is common, we need to do the merge
+ for epid in common:
+ enc_ep = enc_data[epid]
+ con_ep = con_data[epid]
+
+ enc_desc = enc_ep['description']
+ con_desc = con_ep['description']
+ if enc_desc == con_desc:
+ description = enc_desc
+ elif enc_desc is None:
+ description = con_desc
+ elif con_desc is None:
+ description = enc_desc
+ else:
+ # not None, or they would have been the same, let's concat
+ # both, shorter first
+ if len(con_desc) < len(enc_desc):
+ description = con_desc + ' ' + enc_desc
+ else:
+ description = enc_desc + ' ' + con_desc
+
+ # if both are equal (None or not), it also works
+ if enc_ep['duration'] is None:
+ duration = con_ep['duration']
+ else:
+ duration = enc_ep['duration']
+ if enc_ep['image_url'] is None:
+ image_url = con_ep['image_url']
+ else:
+ image_url = enc_ep['image_url']
+ if enc_ep['season'] is None:
+ season = con_ep['season']
+ else:
+ season = enc_ep['season']
+
+ d = dict(episode_id=epid, description=description,
+ duration=duration, url=con_ep['url'],
+ channel=con_ep['channel'], title=con_ep['title'],
+ section=con_ep['section'], image_url=image_url,
+ downtype=con_ep['downtype'], season=season)
+ final_data.append(d)
+
+ logger.debug("Merging: appending other data: %s", backends.keys())
+ for data in backends.itervalues():
+ final_data.extend(data)
+ logger.debug("Merged, final: %d", len(final_data))
+ return final_data
diff --git a/encuentro/utils.py b/encuentro/utils.py
new file mode 100644
index 0000000..bd3584c
--- /dev/null
+++ b/encuentro/utils.py
@@ -0,0 +1,93 @@
+# Copyright 2013 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Some useful functions."""
+
+import defer
+import os
+
+from PyQt4 import QtNetwork, QtCore
+
+_qt_network_manager = QtNetwork.QNetworkAccessManager()
+
+
+class _Downloader(object):
+ """An asynch downloader that fires a deferred with data when done."""
+ def __init__(self, url):
+ self.deferred = defer.Deferred()
+ self.deferred._store_it_because_qt_needs_or_wont_work = self
+ request = QtNetwork.QNetworkRequest(QtCore.QUrl(url))
+
+ self.req = _qt_network_manager.get(request)
+ self.req.error.connect(self.deferred.errback)
+ self.req.finished.connect(self.end)
+
+ def end(self):
+ """Send data through the deferred, if wasn't fired before."""
+ img_data = self.req.read(self.req.bytesAvailable())
+ content_type = self.req.header(
+ QtNetwork.QNetworkRequest.ContentTypeHeader)
+ data = (content_type, img_data)
+ if not self.deferred.called:
+ self.deferred.callback(data)
+
+
+def download(url):
+ """Deferredly download an URL, non blocking."""
+ d = _Downloader(url)
+ return d.deferred
+
+
+class SafeSaver(object):
+ """A safe saver to disk.
+
+ It saves to a .tmp and moves into final destination, and other
+ considerations.
+ """
+
+ def __init__(self, fname):
+ self.fname = fname
+ self.tmp = fname + ".tmp"
+ self.fh = None
+
+ def __enter__(self):
+ self.fh = open(self.tmp, 'wb')
+ return self.fh
+
+ def __exit__(self, *exc_data):
+ self.fh.close()
+
+ # only move into final destination if all went ok
+ if exc_data == (None, None, None):
+ if os.path.exists(self.fname):
+ # in Windows we need to remove the old file first
+ os.remove(self.fname)
+ os.rename(self.tmp, self.fname)
+
+
+if __name__ == "__main__":
+ import sys
+ app = QtCore.QCoreApplication(sys.argv)
+ _url = "http://www.taniquetil.com.ar/facundo/imgs/felu-camagrande.jpg"
+
+ @defer.inline_callbacks
+ def _download():
+ """Download."""
+ deferred = download(_url)
+ data = yield deferred
+ print "All done!", len(data), type(data)
+ _download()
+ sys.exit(app.exec_())
diff --git a/man/encuentro.1 b/man/encuentro.1
new file mode 100644
index 0000000..028769c
--- /dev/null
+++ b/man/encuentro.1
@@ -0,0 +1,31 @@
+.TH ENCUENTRO 1
+.SH NAME
+encuentro \- busque, descargue, y vea el maravilloso contenido ofrecido
+por el Canal Encuentro, Paka Paka, BACUA, Educ.ar y otros.
+
+Note: This program is strongly oriented to Spanish speaking people, as
+the content of Canal Encuentro and the other channels is only in Spanish.
+
+
+.SH SYNOPSYS
+.B encuentro [-v]
+
+.SH DESCRIPTION
+
+Este es un simple programa que permite buscar, descargar y ver contenido de Encuentro y otros canales. Notar que este programa no distribuye contenido directamente, sino que permite un mejor uso personal de esos contenidos.
+
+Por favor, referirse a los sitios web correspondientes para saber qué se puede y qué no se puede hacer con los contenidos de tales sitios.
+
+Si se ejecuta con
+.B -v
+mostrará las lineas de log en stdout.
+
+.SH AUTHOR
+Facundo Batista
+
+.SH SEE ALSO
+La página del proyecto es http://encuentro.taniquetil.com.ar/
+
+El desarrollo está centralizado en https://launchpad.net/launcherposta
+
+
diff --git a/pasos_release.txt b/pasos_release.txt
new file mode 100644
index 0000000..7982c3e
--- /dev/null
+++ b/pasos_release.txt
@@ -0,0 +1,24 @@
+- ver que los tests pasen todo ok
+
+- hacer un release de la vN (tarball)
+
+- generar el .deb y subirlo a la pag de descargas
+
+- actualizar el ppa, esperar que entre, multiplicar a los distintos ubuntus
+
+- actualizar PyPI
+ - ./setup.py register
+ - ./setup.py sdist upload
+
+- pedir para los otros sistemas:
+ - arch
+ - windows
+ - fedora/redhat
+
+- actualizar y subir la página web
+
+- anunciar
+ - pyar
+ - post en el blog
+ - tuit
+
diff --git a/pylintrc b/pylintrc
new file mode 100644
index 0000000..26de44b
--- /dev/null
+++ b/pylintrc
@@ -0,0 +1,309 @@
+# lint Python modules using external checkers.
+#
+# This is the main checker controlling the other ones and the reports
+# generation. It is itself both a raw checker and an astng checker in order
+# to:
+# * handle message activation / deactivation at the module level
+# * handle some basic but necessary stats'data (number of classes, methods...)
+#
+[MASTER]
+
+# Specify a configuration file.
+#rcfile=
+
+# Python code to execute, usually for sys.path manipulation such as
+# pygtk.require().
+#init-hook=
+
+# Profiled execution.
+profile=no
+
+# Add to the black list. It should be a base name, not a
+# path. You may set this option multiple times.
+#ignore=
+
+# Pickle collected data for later comparisons.
+persistent=no
+
+# List of plugins (as comma separated values of python modules names) to load,
+# usually to register additional checkers.
+load-plugins=
+
+
+[MESSAGES CONTROL]
+
+# Enable only checker(s) with the given id(s). This option conflicts with the
+# disable-checker option
+#enable-checker=
+
+# Enable all checker(s) except those with the given id(s). This option
+# conflicts with the enable-checker option
+#disable-checker=
+
+# Enable the message(s) with the given id(s).
+#enable=
+
+# Disable the message(s) with the given id(s).
+# W0142: Used * or ** magic
+# W0613: Unused argument 'yyy'
+# E1103: Generator 'accept_share' has no 'addCallbacks' member (but some
+# types could not be inferred). Disabled because it always complains
+# when using the deferred returned by an inlineCallbacks
+# E1101: Disable 'member' control, it always complains about reactor.callLater
+# W0703: Catch "Exception"
+# C0103: Invalid name
+# E0203: Access to member 'x' before its definition line n
+disable=R,I,W0142,W0613,E1103,E1101,W0703,C0103,E0203,W1401
+
+
+[REPORTS]
+
+# Set the output format. Available formats are text, parseable, colorized, msvs
+# (visual studio) and html
+output-format=colorized
+
+# Include message's id in output
+include-ids=yes
+
+# Put messages in a separate file for each module / package specified on the
+# command line instead of printing them on stdout. Reports (if any) will be
+# written in a file name "pylint_global.[txt|html]".
+files-output=no
+
+# Tells whether to display a full report or only the messages
+reports=no
+
+# Python expression which should return a note less than 10 (10 is the highest
+# note). You have access to the variables errors warning, statement which
+# respectively contain the number of errors / warnings messages and the total
+# number of statements analyzed. This is used by the global evaluation report
+# (R0004).
+evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
+
+# Add a comment according to your evaluation note. This is used by the global
+# evaluation report (R0004).
+comment=no
+
+# Enable the report(s) with the given id(s).
+#enable-report=
+
+# Disable the report(s) with the given id(s).
+#disable-report=
+
+
+# try to find bugs in the code using type inference
+#
+[TYPECHECK]
+
+# Tells whether missing members accessed in mixin class should be ignored. A
+# mixin class is detected if its name ends with "mixin" (case insensitive).
+ignore-mixin-members=yes
+
+# List of classes names for which member attributes should not be checked
+# (useful for classes with attributes dynamically set).
+ignored-classes=
+
+# When zope mode is activated, add a predefined set of Zope acquired attributes
+# to generated-members.
+zope=no
+
+# List of members which are set dynamically and missed by pylint inference
+# system, and so shouldn't trigger E0201 when accessed.
+generated-members=REQUEST,acl_users,aq_parent
+
+
+# checks for
+# * unused variables / imports
+# * undefined variables
+# * redefinition of variable from builtins or from an outer scope
+# * use of variable before assignment
+#
+[VARIABLES]
+
+# Tells whether we should check for unused import in __init__ files.
+init-import=yes
+
+# A regular expression matching names used for dummy variables (i.e. not used).
+dummy-variables-rgx=_|dummy
+
+# List of additional names supposed to be defined in builtins. Remember that
+# you should avoid to define new builtins when possible.
+additional-builtins=
+
+
+# checks for :
+# * doc strings
+# * modules / classes / functions / methods / arguments / variables name
+# * number of arguments, local variables, branches, returns and statements in
+# functions, methods
+# * required module attributes
+# * dangerous default values as arguments
+# * redefinition of function / method / class
+# * uses of the global statement
+#
+[BASIC]
+
+# Required attributes for module, separated by a comma
+required-attributes=
+
+# Regular expression which should only match functions or classes name which do
+# not require a docstring
+no-docstring-rgx=(__.*__|setUp|tearDown)
+
+# Regular expression which should only match correct module names
+module-rgx=([a-z_][a-z0-9_]*)$
+
+# Regular expression which should only match correct module level names
+const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$
+
+# Regular expression which should only match correct class names
+class-rgx=[A-Z_][a-zA-Z0-9]+$
+
+# Regular expression which should only match correct function names
+function-rgx=[a-z_][a-z0-9_]{2,79}$
+
+# Regular expression which should only match correct method names
+method-rgx=([a-z_][a-z0-9_]{2,79}$|setUp|tearDown)
+
+# Regular expression which should only match correct instance attribute names
+attr-rgx=[a-z_][a-z0-9_]{1,50}$
+
+# Regular expression which should only match correct argument names
+argument-rgx=[a-z_][a-z0-9_]{1,30}$
+
+# Regular expression which should only match correct variable names
+variable-rgx=[a-z_][a-z0-9_]{0,30}$
+
+# Regular expression which should only match correct list comprehension /
+# generator expression variable names
+inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$
+
+# Good variable names which should always be accepted, separated by a comma
+good-names=f,logger,_
+
+# Bad variable names which should always be refused, separated by a comma
+bad-names=foo,bar,baz,toto,tutu,tata
+
+# List of builtins function names that should not be used, separated by a comma
+bad-functions=apply,input
+
+
+# checks for sign of poor/misdesign:
+# * number of methods, attributes, local variables...
+# * size, complexity of functions, methods
+#
+[DESIGN]
+
+# Maximum number of arguments for function / method
+max-args=5
+
+# Maximum number of locals for function / method body
+max-locals=15
+
+# Maximum number of return / yield for function / method body
+max-returns=6
+
+# Maximum number of branch for function / method body
+max-branchs=12
+
+# Maximum number of statements in function / method body
+max-statements=50
+
+# Maximum number of parents for a class (see R0901).
+max-parents=7
+
+# Maximum number of attributes for a class (see R0902).
+max-attributes=7
+
+# Minimum number of public methods for a class (see R0903).
+min-public-methods=2
+
+# Maximum number of public methods for a class (see R0904).
+max-public-methods=20
+
+
+# checks for :
+# * methods without self as first argument
+# * overridden methods signature
+# * access only to existent members via self
+# * attributes not defined in the __init__ method
+# * supported interfaces implementation
+# * unreachable code
+#
+[CLASSES]
+
+# List of interface methods to ignore, separated by a comma. This is used for
+# instance to not check methods defines in Zopes Interface base class.
+#ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by,providedBy
+
+# List of method names used to declare (i.e. assign) instance attributes.
+defining-attr-methods=__init__,__new__,setUp
+
+
+# checks for
+# * external modules dependencies
+# * relative / wildcard imports
+# * cyclic imports
+# * uses of deprecated modules
+#
+[IMPORTS]
+
+# Deprecated modules which should not be used, separated by a comma
+deprecated-modules=regsub,string,TERMIOS,Bastion,rexec
+
+# Create a graph of every (i.e. internal and external) dependencies in the
+# given file (report RP0402 must not be disabled)
+import-graph=
+
+# Create a graph of external dependencies in the given file (report RP0402 must
+# not be disabled)
+ext-import-graph=
+
+# Create a graph of internal dependencies in the given file (report RP0402 must
+# not be disabled)
+int-import-graph=
+
+
+# checks for :
+# * unauthorized constructions
+# * strict indentation
+# * line length
+# * use of <> instead of !=
+#
+[FORMAT]
+
+# Maximum number of characters on a single line.
+max-line-length=79
+
+# Maximum number of lines in a module
+max-module-lines=2000
+
+# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
+# tab).
+indent-string=' '
+
+
+# checks for similarities and duplicated code. This computation may be
+# memory / CPU intensive, so you should disable it if you experiments some
+# problems.
+#
+[SIMILARITIES]
+
+# Minimum lines number of a similarity.
+min-similarity-lines=4
+
+# Ignore comments when computing similarities.
+ignore-comments=yes
+
+# Ignore docstrings when computing similarities.
+ignore-docstrings=yes
+
+
+# checks for:
+# * warning notes in the code like FIXME, XXX
+# * PEP 263: source code with non ascii character but no encoding declaration
+#
+[MISCELLANEOUS]
+
+# List of note tags to take in consideration, separated by a comma.
+notes=FIXME,XXX,TODO,fixme,xxx,todo
diff --git a/requirements_py2.txt b/requirements_py2.txt
new file mode 100644
index 0000000..655641d
--- /dev/null
+++ b/requirements_py2.txt
@@ -0,0 +1,7 @@
+pyxdg>=0.15
+requests>=2.2.1
+defer>=1.0.4
+beautifulsoup4>=4.3.2
+nose>=1.3.1
+flake8==2.1.0
+pylint==1.2.0
diff --git a/requirements_py3.txt b/requirements_py3.txt
new file mode 100644
index 0000000..0e0f1d4
--- /dev/null
+++ b/requirements_py3.txt
@@ -0,0 +1,2 @@
+nose>=1.3.1
+yaswfp>=0.4
diff --git a/server/__init__.py b/server/__init__.py
new file mode 100644
index 0000000..d3bd3d2
--- /dev/null
+++ b/server/__init__.py
@@ -0,0 +1 @@
+"""Server code."""
diff --git a/server/backends-v01.list b/server/backends-v01.list
new file mode 100644
index 0000000..4d1f144
--- /dev/null
+++ b/server/backends-v01.list
@@ -0,0 +1,4 @@
+# list of files to download, with data for different backends
+# each line has the backend name and the URL with the data
+encuentro http://www.taniquetil.com.ar/encuentro/encuentro-v03.bz2
+conectar http://www.taniquetil.com.ar/encuentro/conectar-v03.bz2
diff --git a/server/backends-v02.list b/server/backends-v02.list
new file mode 100644
index 0000000..4d1f144
--- /dev/null
+++ b/server/backends-v02.list
@@ -0,0 +1,4 @@
+# list of files to download, with data for different backends
+# each line has the backend name and the URL with the data
+encuentro http://www.taniquetil.com.ar/encuentro/encuentro-v03.bz2
+conectar http://www.taniquetil.com.ar/encuentro/conectar-v03.bz2
diff --git a/server/backends-v03.list b/server/backends-v03.list
new file mode 100644
index 0000000..78dea9a
--- /dev/null
+++ b/server/backends-v03.list
@@ -0,0 +1,6 @@
+# list of files to download, with data for different backends
+# each line has the backend name, the downloader to use, and the URL
+# with the metadata file
+encuentro conectar http://www.taniquetil.com.ar/encuentro/encuentro-v03.bz2
+conectar conectar http://www.taniquetil.com.ar/encuentro/conectar-v03.bz2
+bacua generic http://www.taniquetil.com.ar/encuentro/bacua-v03.bz2
diff --git a/server/backends-v04.list b/server/backends-v04.list
new file mode 100644
index 0000000..bf7254e
--- /dev/null
+++ b/server/backends-v04.list
@@ -0,0 +1,6 @@
+# list of files to download, with data for different backends
+# each line has the backend name, the downloader to use, and the URL
+# with the metadata file
+encuentro encuentro http://www.taniquetil.com.ar/encuentro/encuentro-v04.bz2
+conectar conectar http://www.taniquetil.com.ar/encuentro/conectar-v04.bz2
+bacua generic http://www.taniquetil.com.ar/encuentro/bacua-v04.bz2
diff --git a/server/backends-v05.list b/server/backends-v05.list
new file mode 100644
index 0000000..c6bb78f
--- /dev/null
+++ b/server/backends-v05.list
@@ -0,0 +1,7 @@
+# list of files to download, with data for different backends
+# each line has the backend name, the downloader to use, and the URL
+# with the metadata file
+#encuentro encuentro http://www.taniquetil.com.ar/encuentro/encuentro-v05.bz2
+#conectar conectar http://www.taniquetil.com.ar/encuentro/conectar-v05.bz2
+#bacua generic http://www.taniquetil.com.ar/encuentro/bacua-v05.bz2
+dqsv dqsv http://www.taniquetil.com.ar/encuentro/dqsv-v05.bz2
diff --git a/server/get_bacua_episodes.py b/server/get_bacua_episodes.py
new file mode 100644
index 0000000..0177a00
--- /dev/null
+++ b/server/get_bacua_episodes.py
@@ -0,0 +1,129 @@
+# -*- coding: utf8 -*-
+
+# Copyright 2012-2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Main server process to get all info from BACUA web site."""
+
+import logging
+import re
+import sys
+import urllib2
+
+from bs4 import BeautifulSoup
+
+# we execute this script from inside the directory; pylint: disable=W0403
+import helpers
+import srv_logger
+
+PAGE_URL = (
+ "http://catalogo.bacua.gob.ar/"
+ "catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=%s"
+)
+BACKEND = "http://backend.bacua.gob.ar/video.php?v=_%s"
+IMG_URL = 'http://backend.bacua.gob.ar/img.php?idvideo=%s'
+
+DURACION_REG = re.compile('([^"]*)')
+
+logger = logging.getLogger("BACUA")
+
+
+def scrap_list_page(html):
+ """Scrap the list page."""
+ pagina = re.compile('
([^"]*)
')
+ m = pagina.search(html).group(1)
+ s = re.sub('<[^<]+?>', '', m)
+ t = re.compile('[0-9]+[0-9]')
+ h = t.search(s).group(0)
+ s = int(h) + 1
+ lista = []
+ for i in range(1, s):
+ lista.append(PAGE_URL % i)
+ return lista
+
+
+@helpers.retryable(logger)
+def get_list_pages():
+ """Get list of pages."""
+ logger.info("Getting list of pages")
+ response = urllib2.urlopen(PAGE_URL)
+ html = response.read()
+ lista = scrap_list_page(html)
+ logger.info(" got %d", len(lista))
+ return lista
+
+
+def scrap_page(html):
+ """Scrap the page."""
+ contents = []
+ sanitized = helpers.sanitize(html)
+ soup = BeautifulSoup(sanitized)
+ for i in soup.findAll("div", {"class": "video_muestra_catalogo"}):
+ for a_node in i.find_all("a"):
+ onclick = a_node.get("onclick", "")
+ if onclick.startswith("javascript:verVideo"):
+ break
+ else:
+ # video not really present for this program
+ continue
+
+ title = i.h4.contents[0].title().strip()
+ _sinop_cat = i.find("h5", {"class": "sinopsis_cat"}).contents
+ sinopsis = _sinop_cat[0] if _sinop_cat else u""
+ id_video = i.findAll("li")[1].a['href'].split("=")[1]
+ image_url = IMG_URL % (id_video,)
+ video_url = BACKEND % (id_video,)
+
+ d = {"duration": "?", "channel": "Bacua", "section": "Micro",
+ "description": sinopsis, "title": title, "url": video_url,
+ "episode_id": 'bacua_' + id_video, "image_url": image_url,
+ "season": None}
+ contents.append(d)
+ return contents
+
+
+@helpers.retryable(logger)
+def get_content(page_url):
+ """Get content from a page."""
+ logger.info("Getting info for page %r", page_url)
+ u = urllib2.urlopen(page_url)
+ html = u.read()
+ contents = scrap_page(html)
+ logger.info(" got %d contents", len(contents))
+ return contents
+
+
+def get_all_data():
+ """Get everything."""
+ all_programs = []
+ for page_url in get_list_pages():
+ contents = get_content(page_url)
+ for content in contents:
+ all_programs.append(content)
+ logger.info("Done! Total programs: %d", len(all_programs))
+ return all_programs
+
+
+def main():
+ """Entry Point."""
+ all_data = get_all_data()
+ helpers.save_file("bacua-v05", all_data)
+
+
+if __name__ == '__main__':
+ shy = len(sys.argv) > 1 and sys.argv[1] == '--shy'
+ srv_logger.setup_log(shy)
+ main()
diff --git a/server/get_conect_episodes.py b/server/get_conect_episodes.py
new file mode 100644
index 0000000..babd499
--- /dev/null
+++ b/server/get_conect_episodes.py
@@ -0,0 +1,207 @@
+# -*- coding: utf8 -*-
+
+# Copyright 2013-2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Main server process to get all info from Conectate web site."""
+
+import json
+import logging
+import sys
+import urllib
+import urllib2
+
+# we execute this script from inside the directory; pylint: disable=W0403
+import helpers
+import scrapers_conect
+import srv_logger
+
+
+# different channels from where read content
+# id, name
+CHANNELS = [
+ ('encuentro', u"Encuentro"),
+ ('pakapaka', u"Pakapaka"),
+ ('ronda', u"Ronda"),
+ ('educar', u"Educ.ar"),
+ ('conectar', u"Conectar Igualdad"),
+]
+
+# different emission types
+# id, name, divided in series or not
+TRANSMISSIONS = [
+ (1, u"Película", False),
+ (2, u"Especial", False),
+ (3, u"Serie", True),
+ (4, u"Micro", True),
+]
+
+URL_EPIS_BASE = (
+ "http://www.conectate.gob.ar/sitios/conectate/busqueda/"
+ "%(channel_id)s?rec_id=%(epis_id)s"
+)
+URL_IMAGE_BASE = (
+ "http://globalbackend.educ.ar/repositorio/Imagen/ver?image_id=%(img_id)s"
+)
+
+URL_SEARCH = (
+ "http://www.conectate.gob.ar/sitios/conectate/busqueda/%(channel_id)s"
+)
+
+logger = logging.getLogger("Conectate")
+
+episodes_cache = helpers.Cache("episodes_cache_conect.pickle")
+
+
+class EpisodeInfo(object):
+ """Data about an episode."""
+ def __init__(self, **kwargs):
+ self.__dict__.update(kwargs)
+
+
+@helpers.retryable(logger)
+def _search(channel_id, transm_id, offset):
+ """Search each page."""
+ params = {'offset': offset, 'limit': 20,
+ 'tipo_emision_id': transm_id, 'ajax': True}
+ data = "__params=" + urllib.quote(json.dumps(params))
+ url = URL_SEARCH % dict(channel_id=channel_id)
+ logger.debug("hitting url: %r (%r)", url, data)
+ u = urllib2.urlopen(url, data=data)
+ raw = u.read()
+ data = json.loads(raw)
+ results = data['ResultSet']['data']['result']
+ return results
+
+
+def do_search(channel_id, transm_id):
+ """Search the web site."""
+ logger.info("Searching channel=%s emission=%s", channel_id, transm_id)
+ all_items = []
+ offset = 0
+ while True:
+ logger.info(" offset: %d", offset)
+ items = _search(channel_id, transm_id, offset)
+ if not items:
+ # done, return collected info
+ logger.info(" done: %d", len(all_items))
+ break
+
+ # store and go for next page
+ all_items.extend(items)
+ offset += len(items)
+
+ # extract the relevant information
+ for item in all_items:
+ image = URL_IMAGE_BASE % dict(img_id=item['rec_medium_icon_image_id'])
+ description = item['rec_descripcion']
+ epis_id = item['rec_id']
+ epis_url = URL_EPIS_BASE % dict(channel_id=channel_id, epis_id=epis_id)
+ title = helpers.enhance_number(helpers.clean_html(item['rec_titulo']))
+ ep = EpisodeInfo(epis_id=epis_id, epis_url=epis_url, title=title,
+ description=description, image_url=image, season=None)
+ yield ep
+
+
+@helpers.retryable(logger)
+def get_from_series(url):
+ """Get the episodes from an url page."""
+ logger.info("Get from series: %r", url)
+ u = urllib2.urlopen(url)
+ page = u.read()
+ results = scrapers_conect.scrap_series(page)
+ logger.info(" %d", len(results))
+ return results
+
+
+@helpers.retryable(logger)
+def get_episode_info(url):
+ """Get the info from an episode."""
+ logger.info("Get episode info: %r", url)
+ try:
+ info = episodes_cache.get(url)
+ logger.info(" cached!")
+ except KeyError:
+ u = urllib2.urlopen(url)
+ page = u.read()
+ info = scrapers_conect.scrap_video(page)
+ episodes_cache.set(url, info)
+ logger.info(" ok")
+ return info
+
+
+def get_episodes():
+ """Yield episode info."""
+ for chan_id, chan_name in CHANNELS:
+ for transm_id, transm_name, transm_is_deep in TRANSMISSIONS:
+ results = do_search(chan_id, transm_id)
+ if transm_is_deep:
+ # series, need to get each
+ episodes = []
+ for master in results:
+ from_series = get_from_series(master.epis_url)
+
+ # get the first to retrieve duration to use in them all
+ duration = get_episode_info(from_series[0][2])
+
+ # build the new episodes, with some common info from master
+ for season, title, url in from_series:
+ epis_id = helpers.get_url_param(url, 'rec_id')
+ ep = EpisodeInfo(
+ epis_id=epis_id, epis_url=url, title=title,
+ description=master.description, season=season,
+ image_url=master.image_url, duration=duration)
+ episodes.append(ep)
+
+ results = episodes
+ logger.info("Series collected: %d", len(results))
+
+ # inform each
+ for episode in results:
+ duration = get_episode_info(episode.epis_url)
+ episode.duration = duration
+ yield chan_name, transm_name, episode
+
+
+def get_all_data():
+ """Collect all data from the servers."""
+ all_data = []
+ for i, (chan_name, transm_name, episode) in enumerate(get_episodes()):
+ info = {
+ 'channel': chan_name,
+ 'section': transm_name,
+ 'title': episode.title,
+ 'url': episode.epis_url,
+ 'episode_id': episode.epis_id,
+ 'image_url': episode.image_url,
+ 'description': episode.description,
+ 'duration': episode.duration,
+ 'season': episode.season,
+ }
+ all_data.append(info)
+ return all_data
+
+
+def main():
+ """Entry point."""
+ all_data = get_all_data()
+ helpers.save_file("conectar-v05", all_data)
+
+
+if __name__ == '__main__':
+ shy = len(sys.argv) > 1 and sys.argv[1] == '--shy'
+ srv_logger.setup_log(shy)
+ main()
diff --git a/server/get_dqsv_episodes.py b/server/get_dqsv_episodes.py
new file mode 100644
index 0000000..2806ef8
--- /dev/null
+++ b/server/get_dqsv_episodes.py
@@ -0,0 +1,215 @@
+# Copyright 2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Main server process to get all info from 'Decime quién sos vos' web site."""
+
+import difflib
+import io
+import logging
+import string
+import sys
+import unicodedata
+
+from base64 import b64encode
+from urllib import request
+
+import bs4
+
+# we execute this script from inside the directory; pylint: disable=W0403
+import helpers
+import scrapers_dqsv
+import srv_logger
+
+URL_MUSIC = "http://decimequiensosvos.com.ar/music/"
+URL_FLASH = "http://decimequiensosvos.com.ar/imagesPortfolio/"
+LETTERS = set(string.ascii_letters)
+
+logger = logging.getLogger("DQSV")
+
+cache = helpers.Cache("episodes_cache_dqsv.pickle")
+
+
+# special chapters that don't have a mp3 associated
+SPECIAL_NONMP3_CHAPTERS = [
+ 'Elecciones 2011', 'Especial Navidad', 'Epílogos', 'Elecciones 2013']
+
+# store published interviews, to detect re-editions
+REEDITIONS = {}
+
+# some SWFs have weird images ordering, so I manually curate them
+CUSTOM_ORDER = {
+ '02': [
+ "Diego Capusotto",
+ "Osvaldo Bayer",
+ "Víctor Hugo Morales",
+ "Rodolfo Livingston",
+ ],
+ '03': [
+ "Felipe Pigna",
+ "Héctor Negro",
+ "Hebe de Bonafini",
+ "Soledad Villamil",
+ "Pepe Soriano",
+ ],
+ '06': [
+ "Juan Sasturain",
+ "Jairo",
+ "Rogelio García Lupo",
+ "Estela Barnes de Carlotto",
+ "José Pablo Feinmann",
+ ],
+ '07': [
+ "Norman Briski",
+ "Martha Pelloni",
+ "Víctor Heredia",
+ "Raúl Rizzo",
+ ],
+}
+
+
+@helpers.retryable(logger)
+def hit(url, apply_cache):
+ """Get the info from an episode."""
+ if apply_cache:
+ logger.info("Hitting: %r", url)
+ try:
+ raw = cache.get(url)
+ logger.info(" cached!")
+ except KeyError:
+ u = request.urlopen(url)
+ raw = u.read()
+ cache.set(url, raw)
+ logger.info(" ok (len=%d)", len(raw))
+ else:
+ logger.info("Hitting uncached: %r", url)
+ u = request.urlopen(url)
+ raw = u.read()
+ logger.info(" ok (len=%d)", len(raw))
+ return raw
+
+
+def get_swfs():
+ """Retrieve all SWFs from site and parse them."""
+ soup = bs4.BeautifulSoup(hit(URL_FLASH, False))
+ links = [(x.text, x.text.split(".")) for x in soup.find_all('a')]
+ names = [n for n, p in links if p[0].isdigit() and p[1] == 'swf']
+
+ # cache all except the last one, as it changes in the same month
+ names = [(n, True) for n in names[:-1]] + [(names[-1], False)]
+ for name, cache in names:
+ basename = name[:-4]
+ url = URL_FLASH + name
+ raw = hit(url, cache)
+ custom_order = CUSTOM_ORDER.get(basename)
+ items = scrapers_dqsv.scrap(io.BytesIO(raw), custom_order)
+ for swf in items:
+ yield basename, swf
+
+
+def get_mp3s():
+ """Retrieve all mp3s names."""
+ soup = bs4.BeautifulSoup(hit(URL_MUSIC, False))
+ links = [x.text for x in soup.find_all('a')]
+ mp3s = [x for x in links if x[:6].isdigit() and x.endswith('.mp3')]
+ return mp3s
+
+
+def find_matching_mp3(all_mp3s, swf_date, swf_name):
+ """Find the best match for an mp3."""
+ if swf_name in SPECIAL_NONMP3_CHAPTERS:
+ return None
+
+ similars = set()
+ inidate = swf_date.strftime("%y%m%d")
+ _dec = unicodedata.normalize('NFKD', swf_name).encode('ASCII', 'ignore')
+ decomp_name = _dec.decode("ASCII").lower()
+ for part in decomp_name.lower().split():
+ maybe_fname = inidate + part
+ similars.update(difflib.get_close_matches(maybe_fname, all_mp3s))
+
+ if not similars:
+ raise ValueError("No similar to {!r}".format(swf_name))
+
+ if len(similars) == 1:
+ return similars.pop()
+
+ # too many similars, let's filter by the date
+ filtered = [x for x in similars if x.startswith(inidate)]
+ if not filtered:
+ filtered = [x for x in similars if x.startswith(inidate[:4])]
+ if len(filtered) == 1:
+ return filtered[0]
+
+ filtered = [x for x in filtered if not x.split(".")[0][-1].isdigit()]
+ if len(filtered) == 1:
+ return filtered[0]
+
+ raise ValueError("Too many similars, even after filtering: {}".format(
+ filtered))
+
+
+def get_all_data():
+ """Get everything."""
+ logger.info("GO")
+ all_programs = []
+ all_swfs = get_swfs()
+ all_mp3s = get_mp3s()
+ for swfbasename, swf in all_swfs:
+ try:
+ old_date = REEDITIONS[swf.name]
+ except KeyError:
+ # new interview, all fine
+ REEDITIONS[swf.name] = swf.date
+ else:
+ # repeated!
+ logger.debug("Ignoring episode for {!r} of {}, was already from {}"
+ .format(swf.name, swf.date, old_date))
+ continue
+
+ mp3 = find_matching_mp3(all_mp3s, swf.date, swf.name)
+ logger.debug("MP3 found for (%s) %r: %r", swf.date, swf.name, mp3)
+ if mp3 is None:
+ continue
+ episode_id = "dqsv_{}_{}".format(
+ swfbasename, "".join(x.lower() for x in swf.name if x in LETTERS))
+
+ all_programs.append({
+ "duration": "?",
+ "channel": "Decime quién sos vos",
+ "section": "Audio",
+ "description": swf.bio,
+ "title": swf.name,
+ "subtitle": swf.occup,
+ "url": URL_MUSIC + mp3,
+ "episode_id": episode_id,
+ "image_data": b64encode(swf.image).decode('utf8'),
+ "season": swf.date.strftime("%Y-%m-%d"),
+ })
+
+ logger.info("Done! Total programs: %d", len(all_programs))
+ return all_programs
+
+
+def main():
+ """Entry point."""
+ all_data = get_all_data()
+ helpers.save_file("dqsv-v05", all_data)
+
+
+if __name__ == '__main__':
+ shy = len(sys.argv) > 1 and sys.argv[1] == '--shy'
+ srv_logger.setup_log(shy)
+ main()
diff --git a/server/get_encuen_episodes.py b/server/get_encuen_episodes.py
new file mode 100644
index 0000000..eba9071
--- /dev/null
+++ b/server/get_encuen_episodes.py
@@ -0,0 +1,210 @@
+# -*- coding: utf8 -*-
+
+# Copyright 2013-2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Main server process to get all info from Encuentro web site."""
+
+import json
+import logging
+import sys
+import urllib
+import urllib2
+
+# we execute this script from inside the directory; pylint: disable=W0403
+import helpers
+import scrapers_encuen
+import srv_logger
+
+
+URL_LISTING = (
+ "http://www.encuentro.gov.ar/sitios/encuentro/"
+ "Programas/getEmisionesDeSitio"
+)
+URL_IMAGE_BASE = (
+ "http://globalbackend.educ.ar/repositorio/Imagen/ver?image_id=%(img_id)s"
+)
+
+URL_DETAILS = (
+ 'http://www.encuentro.gov.ar/sitios/encuentro/Programas/detalleCapitulo'
+)
+URL_EPIS_BASE = (
+ "http://www.encuentro.gov.ar/sitios/encuentro/programas/"
+ "ver?rec_id=%(epis_id)s"
+)
+
+POST_DETAILS = '__params=%7B%22rec_id%22%3A{}%2C%22ajax%22%3Atrue%7D'
+
+logger = logging.getLogger("Encuentro")
+
+episodes_cache = helpers.Cache("episodes_cache_encuen.pickle")
+
+
+class EpisodeInfo(object):
+ """Generic object to hold episode info."""
+ def __init__(self, **kwargs):
+ self.__dict__.update(kwargs)
+
+
+@helpers.retryable(logger)
+def get_download_availability(episode_id):
+ """Check if the episode is available for download."""
+ logger.info("Get availability: %s", episode_id)
+ try:
+ info = episodes_cache.get(episode_id)
+ logger.info(" cached!")
+ except KeyError:
+ post = POST_DETAILS.format(episode_id)
+ u = urllib2.urlopen(URL_DETAILS, data=post)
+ t = u.read()
+ data = json.loads(t)
+ data = data["ResultSet"]['data']['recurso']['tipo_funcional']['data']
+ real_id = data['descargable']['file_id']
+ info = real_id is not None
+ episodes_cache.set(episode_id, info)
+ logger.info(" ok, avail? %s", real_id is not None)
+ return info
+
+
+@helpers.retryable(logger)
+def get_episode_info(url):
+ """Get the info from an episode."""
+ logger.info("Get episode info: %r", url)
+ try:
+ info = episodes_cache.get(url)
+ logger.info(" cached!")
+ except KeyError:
+ u = urllib2.urlopen(url)
+ page = u.read()
+ info = scrapers_encuen.scrap_programa(page)
+ episodes_cache.set(url, info)
+ logger.info(" ok")
+ return info
+
+
+@helpers.retryable(logger)
+def get_listing_info(offset):
+ """Get the info from a listing."""
+ logger.info("Get listing from offset %d", offset)
+ params = {'offset': offset, 'limit': 20, 'ajax': True}
+ data = "__params=" + urllib.quote(json.dumps(params))
+ logger.debug("hitting url: %r (%r)", URL_LISTING, data)
+ u = urllib2.urlopen(URL_LISTING, data=data)
+ raw = u.read()
+ data = json.loads(raw)
+ data = data['ResultSet']['data']
+ if data:
+ results = data['result']
+ else:
+ results = []
+ return results
+
+
+def get_episodes():
+ """Yield episode info."""
+ offset = 0
+ all_items = []
+ while True:
+ logger.info("Get Episodes, listing")
+ episodes = get_listing_info(offset)
+ logger.info(" found %d", len(episodes))
+ if not episodes:
+ break
+
+ all_items.extend(episodes)
+ offset += len(episodes)
+
+ # extract the relevant information
+ for item in all_items:
+ image = URL_IMAGE_BASE % dict(img_id=item['rec_medium_icon_image_id'])
+ description = item['rec_descripcion']
+ epis_id = item['rec_id']
+ epis_url = URL_EPIS_BASE % dict(epis_id=epis_id)
+ title = helpers.enhance_number(item['rec_titulo'])
+
+ # get more info from the episode page
+ logger.info("Getting info for %r %r", title, epis_url)
+ duration, links_info = get_episode_info(epis_url)
+
+ if len(links_info) == 0:
+ if duration > 60:
+ section = u"Película"
+ elif duration < 10:
+ section = u"Micro"
+ else:
+ section = u"Especial"
+
+ ep = EpisodeInfo(section=section, epis_url=epis_url,
+ title=title, description=description,
+ image_url=image, duration=duration,
+ epis_id=epis_id, season=None)
+ yield ep
+ else:
+ section = u"Serie"
+ for season, title, url in links_info:
+ epis_id = helpers.get_url_param(url, 'rec_id')
+ ep = EpisodeInfo(section=section, epis_url=url, title=title,
+ description=description, image_url=image,
+ duration=duration, epis_id=epis_id,
+ season=season)
+ yield ep
+
+
+def get_all_data():
+ """Collect all data from the servers."""
+ all_data = []
+ collected = {}
+ for ep in get_episodes():
+ available = get_download_availability(ep.epis_id)
+ if not available:
+ continue
+ info = dict(channel=u"Encuentro", title=ep.title, url=ep.epis_url,
+ section=ep.section, description=ep.description,
+ duration=ep.duration, episode_id=ep.epis_id,
+ image_url=ep.image_url, season=ep.season)
+
+ # check if already collected, verifying all is ok
+ if ep.epis_id in collected:
+ previous = collected[ep.epis_id]
+ if previous == info:
+ continue
+ else:
+ raise ValueError("Bad repeated! %s and %s", previous, info)
+
+ # store
+ collected[ep.epis_id] = info
+ all_data.append(info)
+ return all_data
+
+
+def main():
+ """Entry point."""
+ all_data = get_all_data()
+ helpers.save_file("encuentro-v05", all_data)
+
+
+if __name__ == '__main__':
+ if len(sys.argv) > 1 and sys.argv[1] == '--shy':
+ shy = True
+ del sys.argv[1]
+ else:
+ shy = False
+ srv_logger.setup_log(shy)
+
+ if len(sys.argv) > 1:
+ print get_episode_info(int(sys.argv[1]))
+ else:
+ main()
diff --git a/server/helpers.py b/server/helpers.py
new file mode 100644
index 0000000..74c6f45
--- /dev/null
+++ b/server/helpers.py
@@ -0,0 +1,160 @@
+# -*- coding: utf8 -*-
+
+from __future__ import unicode_literals
+
+# Copyright 2012-2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""A couple of helpers for server stuff."""
+
+
+import bz2
+import pickle
+import cgi
+import json
+import os
+import re
+import time
+
+try:
+ import urlparse as parse
+except ImportError:
+ from urllib import parse
+
+
+def save_file(basename, data):
+ """Save file to disk, dumping the data."""
+ # encode and compress
+ info = json.dumps(data)
+ info = bz2.compress(info.encode('ascii'))
+
+ # dump it
+ tmpname = basename + ".tmp"
+ bz2name = basename + ".bz2"
+ with open(tmpname, "wb") as fh:
+ fh.write(info)
+ os.rename(tmpname, bz2name)
+
+
+def _weird_utf8_fixing(byteseq):
+ """Clean non-utf8 elements and decode."""
+ tmp = []
+ consume = 0
+ for i, c in enumerate(byteseq):
+ if consume:
+ consume -= 1
+ continue
+ ord_c = ord(c)
+ if ord_c <= 127: # 0... ....
+ tmp.append(c)
+ elif 192 <= ord_c <= 223: # 110. ....
+ n = byteseq[i + 1]
+ if 128 <= ord(n) <= 191:
+ # second byte ok
+ tmp.append(c)
+ tmp.append(n)
+ consume = 1
+ else:
+ ValueError("Unsupported fixing sequence.")
+ result = b"".join(tmp).decode("utf8")
+ return result
+
+
+def sanitize(html):
+ """Sanitize html."""
+ # try to decode in utf8, otherwise try in cp1252
+ try:
+ html.decode("utf8")
+ except UnicodeDecodeError:
+ try:
+ html = html.decode("cp1252")
+ except UnicodeDecodeError:
+ html = _weird_utf8_fixing(html)
+
+ # remove script stuff
+ html = re.sub(b"", b"", html, flags=re.S)
+ return html
+
+
+class Cache(object):
+ """An automatic caché in disk."""
+ def __init__(self, fname):
+ self.fname = fname
+ if os.path.exists(fname):
+ with open(fname, "rb") as fh:
+ self.db = pickle.load(fh)
+ else:
+ self.db = {}
+
+ def get(self, key):
+ """Return a value in the DB."""
+ return self.db[key]
+
+ def set(self, key, value):
+ """Set a value to the DB."""
+ self.db[key] = value
+ temp = self.fname + ".tmp"
+ with open(temp, "wb") as fh:
+ pickle.dump(self.db, fh)
+ os.rename(temp, self.fname)
+
+
+def retryable(logger):
+ """Decorator generator."""
+ def decorator(func):
+ """Decorator to retry functions."""
+ def _f(*args, **kwargs):
+ """Retryable function."""
+ for attempt in range(5, -1, -1): # if reaches 0: no more attempts
+ try:
+ res = func(*args, **kwargs)
+ except Exception as e:
+ if not attempt:
+ raise
+ logger.debug(" problem (retrying...): %s", e)
+ time.sleep(30)
+ else:
+ return res
+ return _f
+ return decorator
+
+
+def get_url_param(url, param):
+ """Get the value of the param in the url."""
+ return cgi.parse_qs(parse.urlparse(url).query)[param][0]
+
+
+def clean_html(text):
+ """Clean HTML structures from the text."""
+ text = re.sub("<.*?>", "", text)
+ text = text.replace(" ", "")
+ return text.strip()
+
+
+def enhance_number(text):
+ """Enhance the number of a title, if any."""
+ parts = text.split("-", 1)
+ if len(parts) != 2:
+ return text
+
+ maybe_number, rest = parts
+ try:
+ number = int(maybe_number)
+ except ValueError:
+ return text
+
+ text = "%02d. %s" % (number, rest.strip())
+ return text
diff --git a/server/scrapers_conect.py b/server/scrapers_conect.py
new file mode 100644
index 0000000..9ba10a9
--- /dev/null
+++ b/server/scrapers_conect.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+
+# Copyright 2012-2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Some scrapers."""
+
+import bs4
+
+# we execute this script from inside the directory; pylint: disable=W0403
+import helpers
+
+
+def scrap_series(html):
+ """Get useful info from the series list."""
+ soup = bs4.BeautifulSoup(helpers.sanitize(html))
+
+ episodes_list = soup.find('ul', id='listaEpisodios')
+ results = []
+ seasons = episodes_list.find_all('li', class_='temporada')
+ for season in seasons:
+ season_title_tag = season.find('a', class_='temporada-titulo')
+ if season_title_tag is None:
+ season_title = None
+ else:
+ season_title = helpers.clean_html(season_title_tag.text)
+
+ episodes = season.find_all('li')
+ for episode in episodes:
+ a_tag = episode.find('a')
+ link = a_tag['href']
+
+ # before getting the text, remove a posible span text
+ span_tag = a_tag.find('span')
+ if span_tag is not None:
+ span_tag.clear()
+ title = helpers.enhance_number(helpers.clean_html(a_tag.text))
+
+ # store it
+ results.append((season_title, title, link))
+ return results
+
+
+def scrap_video(html):
+ """Get useful info from the video page."""
+ soup = bs4.BeautifulSoup(helpers.sanitize(html))
+
+ item = soup.find('p', class_='duracion')
+ if item is not None:
+ parts = item.text.split()
+ duration = int(parts[1])
+ return duration
diff --git a/server/scrapers_dqsv.py b/server/scrapers_dqsv.py
new file mode 100755
index 0000000..7e266ec
--- /dev/null
+++ b/server/scrapers_dqsv.py
@@ -0,0 +1,208 @@
+#!/usr/bin/env python3
+
+# Copyright 2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Scrapers for the decimequiensosvos backend."""
+
+import datetime
+import sys
+
+from collections import namedtuple
+
+from yaswfp import swfparser
+
+Episode = namedtuple("Episode", "name occup bio image date")
+
+
+class _ConstantPoolExtractor(object):
+ """Get items from the constant pool."""
+ def __init__(self, constants, actions):
+ self.constants = constants
+ self.actions = actions
+
+ def get(self, *keys):
+ """Get the text after some key."""
+ values = {}
+ stack = []
+ for act in self.actions:
+ if act.name == 'ActionPush':
+ if act.Type == 7:
+ idx = act.Integer
+ elif act.Type == 8:
+ idx = act.Constant8
+ elif act.Type in (5, 6):
+ continue
+ else:
+ raise ValueError("Bad act type: " + repr(act))
+ try:
+ val = self.constants[idx]
+ except IndexError:
+ stack.append(None)
+ else:
+ if val.startswith('titulo') and val.endswith('1'):
+ # hard group break!!!
+ values = {}
+ stack = []
+ stack.append(val)
+ elif act.name in ('ActionSetVariable', 'ActionSetMember'):
+ if len(stack) == 2:
+ title, value = stack
+ if title in keys:
+ values[title] = value
+ if len(values) == len(keys):
+ return values
+ stack = []
+ else:
+ stack = []
+
+
+def _fix_date(date):
+ """Fix and improve the date info."""
+ datestr = date.split()[0]
+ if datestr.isupper():
+ return None
+
+ if "-" in datestr:
+ datestr = "/".join(x.split("-")[0] for x in datestr.split("/"))
+ dt = datetime.datetime.strptime(datestr, "%d/%m/%y")
+ date = dt.date()
+ return date
+
+
+def _fix_occup(occup):
+ """Fix and improve the occupation info."""
+ occup = occup.strip()
+ if not occup:
+ return ""
+ occup = occup[0].upper() + occup[1:]
+ if occup[-1] != ".":
+ occup = occup + "."
+
+ # assure all the letters after a period is in uppercase
+ pos_from = 0
+ while True:
+ try:
+ pos = occup.index(".", pos_from)
+ except ValueError:
+ break
+ pos_from = pos + 1
+ pos += 2 # second letter after the point
+ if pos < len(occup):
+ occup = occup[:pos] + occup[pos].upper() + occup[pos + 1:]
+
+ return occup
+
+
+def _fix_bio(bio):
+ """Fix and improve the bio info."""
+ bio = bio.strip()
+ return bio
+
+
+def _fix_name(name):
+ """Fix and improve the name info."""
+ name = name.replace(""", '"')
+ return name
+
+
+def scrap(fh, custom_order=None):
+ """Get useful info from a program."""
+ swf = swfparser.SWFParser(fh)
+
+ # get the images
+ base = None
+ images = []
+ for tag in swf.tags:
+ if tag.name == 'JPEGTables':
+ base = tag.JPEGData
+ elif tag.name == 'DefineBits':
+ images.append((tag.CharacterID, tag.JPEGData))
+ elif tag.name == 'DefineBitsJPEG2':
+ images.append((tag.CharacterID, tag.ImageData))
+ images = [base + x[1] for x in sorted(images, reverse=True)]
+
+ # get the last DefineSprite
+ defsprite = None
+ for tag in swf.tags:
+ if tag.name == 'DefineSprite':
+ defsprite = tag
+ assert tag is not None, "DefineSprite not found"
+
+ # get the actions
+ doaction = defsprite.ControlTags[0]
+ for act in doaction.Actions:
+ if act.name == 'ActionConstantPool':
+ break
+ else:
+ if len(images) < 3:
+ # not enough images and no constant pool: a non-programs swf!
+ return []
+
+ raise ValueError("No ActionConstantPool found!")
+
+ # do some magic to retrieve the texts
+ cpe = _ConstantPoolExtractor(act.ConstantPool, doaction.Actions)
+ i = 0
+ all_vals = []
+ while True:
+ i += 1
+ name = 'titulo%d1' % i
+ occup = 'titulo%d2' % i
+ bio = 'htmlText'
+ date = 'titulo%d3' % i
+ vals = cpe.get(name, occup, bio, date)
+ if vals is None:
+ break
+ all_vals.append((vals[name], vals[occup], vals[bio], vals[date]))
+
+ items = []
+ for i, (name, occup, bio, date) in enumerate(all_vals):
+ date = _fix_date(date)
+ if date is None:
+ continue
+ occup = _fix_occup(occup)
+ bio = _fix_bio(bio)
+ name = _fix_name(name)
+
+ # use the corresponding image, or through the custom order
+ if custom_order is None:
+ idx = i
+ else:
+ idx = custom_order.index(name)
+ image = images[idx]
+
+ ep = Episode(name=name, occup=occup, bio=bio, image=image, date=date)
+ items.append(ep)
+ return items
+
+
+if __name__ == '__main__':
+ if len(sys.argv) != 2:
+ print("Usage: scrapers_dqsv.py file.swf")
+ exit()
+
+ custom_order = None
+ #custom_order = [
+ # u"",
+ #]
+
+ with open(sys.argv[1], 'rb') as fh:
+ episodes = scrap(fh, custom_order)
+ for i, ep in enumerate(episodes):
+ print("Saving img {} for {}".format(i, ep.name))
+ with open("scraper-img-{}.jpeg".format(i), "wb") as fh:
+ fh.write(ep.image)
diff --git a/server/scrapers_encuen.py b/server/scrapers_encuen.py
new file mode 100644
index 0000000..cf33259
--- /dev/null
+++ b/server/scrapers_encuen.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+
+# Copyright 2012-2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Some scrapers."""
+
+import bs4
+
+# we execute this script from inside the directory; pylint: disable=W0403
+import helpers
+
+
+def scrap_programa(html):
+ """Get useful info from a program."""
+ soup = bs4.BeautifulSoup(helpers.sanitize(html))
+ episodes_list = soup.find('ul', id='listaEpisodios')
+ episodes_result = []
+ if episodes_list is not None:
+ season = episodes_list.find('h2')
+ season_title = helpers.clean_html(season.text)
+
+ episodes = episodes_list.find_all('li')
+ for episode in episodes[1:]: # first episode is html weirdy
+ a_tag = episode.find('a')
+ link = a_tag['href']
+ title = helpers.clean_html(a_tag.text)
+
+ # store it
+ episodes_result.append((season_title, title, link))
+
+ duration_result = None
+ # get only duration from the metadata body
+ metadata = soup.find('div', class_="cuerpoMetadata informacion")
+ if metadata is not None:
+ duration_tag = metadata.find('p', class_='duracion')
+ if duration_tag is not None:
+ duration_text = duration_tag.text.split()[1]
+ duration_result = int(duration_text)
+
+ return duration_result, episodes_result
diff --git a/server/srv_logger.py b/server/srv_logger.py
new file mode 100644
index 0000000..78f20a5
--- /dev/null
+++ b/server/srv_logger.py
@@ -0,0 +1,49 @@
+# -*- coding: utf8 -*-
+
+# Copyright 2013 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Set up the logs."""
+
+import logging
+import os
+import time
+
+LOG_DIR = "logs"
+
+
+def setup_log(shy):
+ """Log setup."""
+ _rootlogger = logging.getLogger("")
+ _rootlogger.setLevel(logging.DEBUG)
+ formatter = logging.Formatter('%(asctime)s %(levelname)7s '
+ '%(name)s: %(message)s')
+
+ # stdout
+ _handler = logging.StreamHandler()
+ level = logging.WARNING if shy else logging.DEBUG
+ _handler.setLevel(level)
+ _rootlogger.addHandler(_handler)
+ _handler.setFormatter(formatter)
+
+ # file
+ if not os.path.exists(LOG_DIR):
+ os.makedirs(LOG_DIR)
+ fname = time.strftime(os.path.join(LOG_DIR, "encserver-%Y%m.log"))
+ _handler = logging.FileHandler(fname)
+ _handler.setLevel(logging.DEBUG)
+ _rootlogger.addHandler(_handler)
+ _handler.setFormatter(formatter)
diff --git a/setup.py b/setup.py
new file mode 100755
index 0000000..23dcab2
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python
+
+# Copyright 2011-2013 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Build tar.gz for encuentro.
+
+Needed packages to run (using Debian/Ubuntu package names):
+
+ python 2.7
+ python-requests 0.12.1
+ python-defer 1.0.6
+ python-qt4 4.9.1
+ python-xdg 0.15
+ python-notify 0.1.1 # not really needed, but provides notifications
+ python-bs4 4.1.0
+"""
+
+import os
+import shutil
+
+from distutils.command.install import install
+from distutils.core import setup
+
+
+class CustomInstall(install):
+ """Custom installation class on package files.
+
+ It copies all the files into the "PREFIX/share/PROJECTNAME" dir.
+ """
+ def run(self):
+ """Run parent install, and then save the install dir in the script."""
+ install.run(self)
+
+ # fix installation path in the script(s)
+ for script in self.distribution.scripts:
+ script_path = os.path.join(self.install_scripts,
+ os.path.basename(script))
+ with open(script_path, 'rb') as fh:
+ content = fh.read()
+ content = content.replace('@ INSTALLED_BASE_DIR @',
+ self._custom_data_dir)
+ with open(script_path, 'wb') as fh:
+ fh.write(content)
+
+ # fix the icon path, and save the .desktop file where it should be
+ src_desktop = self.distribution.get_name() + '.desktop'
+ if not os.path.exists(self._custom_apps_dir):
+ os.makedirs(self._custom_apps_dir)
+ dst_desktop = os.path.join(self._custom_apps_dir, src_desktop)
+
+ with open(src_desktop, 'rb') as fh:
+ content = fh.read()
+ icon = os.path.join(self._custom_data_dir,
+ 'encuentro', 'logos', 'icon-32.png')
+ content = content.replace('@ INSTALLED_ICON @', icon)
+ with open(dst_desktop, 'wb') as fh:
+ fh.write(content)
+
+ # install apport file
+ if not os.path.exists(self._custom_apport_dir):
+ os.makedirs(self._custom_apport_dir)
+ shutil.copy("source_encuentro.py", self._custom_apport_dir)
+
+ # man directory
+ if not os.path.exists(self._custom_man_dir):
+ os.makedirs(self._custom_man_dir)
+ shutil.copy("man/encuentro.1", self._custom_man_dir)
+
+ # version file
+ shutil.copy("version.txt", self.install_lib)
+
+ def finalize_options(self):
+ """Alter the installation path."""
+ install.finalize_options(self)
+
+ # the data path is under 'prefix'
+ data_dir = os.path.join(self.prefix, "share",
+ self.distribution.get_name())
+ apps_dir = os.path.join(self.prefix, "share", "applications")
+ apport_dir = os.path.join(self.prefix, "share",
+ "apport", "package-hooks")
+ man_dir = os.path.join(self.prefix, "share", "man", "man1")
+
+ # if we have 'root', put the building path also under it (used normally
+ # by pbuilder)
+ if self.root is None:
+ build_dir = data_dir
+ else:
+ build_dir = os.path.join(self.root, data_dir[1:])
+ apps_dir = os.path.join(self.root, apps_dir[1:])
+ apport_dir = os.path.join(self.root, apport_dir[1:])
+ man_dir = os.path.join(self.root, man_dir[1:])
+
+ # change the lib install directory so all package files go inside here
+ self.install_lib = build_dir
+
+ # save this custom data dir to later change the scripts
+ self._custom_data_dir = data_dir
+ self._custom_apps_dir = apps_dir
+ self._custom_apport_dir = apport_dir
+ self._custom_man_dir = man_dir
+
+
+LONG_DESCRIPTION = (
+ 'Simple application that allows to search, download '
+ 'and see the content of the Encuentro channel.'
+)
+
+setup(
+ name='encuentro',
+ version=open('version.txt').read().strip(),
+ license='GPL-3',
+ author='Facundo Batista',
+ author_email='facundo@taniquetil.com.ar',
+ description='Search, download and see the wonderful Encuentro content.',
+ long_description=LONG_DESCRIPTION,
+ url='https://launchpad.net/encuentro',
+
+ packages=["encuentro", "encuentro.ui"],
+ package_data={
+ "encuentro": ["ui/media/*", "logos/icon-*.png"],
+ "": ["encuentro.desktop", "source_encuentro.py", "version.txt"],
+ },
+ scripts=["bin/encuentro"],
+
+ cmdclass={
+ 'install': CustomInstall,
+ },
+)
diff --git a/source_encuentro.py b/source_encuentro.py
new file mode 100644
index 0000000..996a4af
--- /dev/null
+++ b/source_encuentro.py
@@ -0,0 +1,33 @@
+
+# Copyright 2011-2013 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Configuration file for Apport."""
+
+from apport.hookutils import attach_related_packages, attach_file_if_exists
+
+from encuentro import logger
+
+
+def add_info(report):
+ """Add info to the report."""
+ # attach the log
+ fname = logger.get_filename()
+ attach_file_if_exists(report, fname, "EncuentroLog")
+
+ # info about dependencies
+ packages = ["python-requests", "python-defer", "python-qt4", "python-xdg"]
+ attach_related_packages(report, packages)
diff --git a/test b/test
new file mode 100755
index 0000000..fb3ea3c
--- /dev/null
+++ b/test
@@ -0,0 +1,10 @@
+#!/bin/bash
+#
+# Copyright 2014 Facundo Batista
+
+set -eu
+
+nosetests -v -s tests --exclude=test_dqsv_scrapers.py
+nosetests3 -v -s tests/test_dqsv_scrapers.py
+flake8 encuentro server
+pylint -d R,C,W,E -e C0111,C0112 -r n -f colorized --no-docstring-rgx="(__.*__|test_*)" encuentro server 2> /dev/null
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/ej-bacua-list_1.html b/tests/ej-bacua-list_1.html
new file mode 100644
index 0000000..a55dccd
--- /dev/null
+++ b/tests/ej-bacua-list_1.html
@@ -0,0 +1,449 @@
+
+
+
+
+ BACUA
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Al estilo de Las mil y una noches, la historia transcurre en la Argentina durante la crisis del 2001. Un viaje de 800 kilómetros desde Bariloche hasta el corazón de la meseta de Somuncurá. Un caciq ...
Hay lugares en el mundo que uno conoce muy bien antes de verlos inclusive por primera vez. Sobre todo cuando se han instalado en nuestra imaginación desde que somos chicos. "Dos Historias de Malvinas ...
Argentina tiene un alto índice de mortalidad por accidentes de tránsito. Por eso nace 2 SEGUNDOS, un programa de TV destinado a generar conciencia, educar y debatir sobre la seguridad vial en nuestr ...
Queda expresamente prohibida toda difusión, modificación, copia, transmisión, reproducción y/o distribución total o parcial de los contenidos audiovisuales del presente sitio web, en cualquier forma y/o por cualquier medio, sin la previa autorización escrita del Consejo Asesor del Sistema Argentino de Televisión Digital Terrestre actuante en la órbita del MINISTERIO DE PLANIFICACIÓN FEDERAL, INVERSIÓN PÚBLICA Y SERVICIOS.
Es una serie sobre energía en la que cuatro niños que recorren la Patagonia, buscan respuestas a los interrogantes que se hacen acerca del medio ambiente. Se puede apreciar el yacimiento minero de R ...
Matías sale de dar un exámen en su facultad contento de haber aprobado una materia. Mientras vuelve para su casa en bicicleta es atropellado por un coche. Mientras tanto, en otra parte, Sofía esper ...
Hace más de dos siglos, algunas familias de Santiago Del Estero y Orán partieron hacia el corazón del gran Chaco. Convivieron con el indio y se adaptaron a las duras condiciones. Campo Grande fue ...
Queda expresamente prohibida toda difusión, modificación, copia, transmisión, reproducción y/o distribución total o parcial de los contenidos audiovisuales del presente sitio web, en cualquier forma y/o por cualquier medio, sin la previa autorización escrita del Consejo Asesor del Sistema Argentino de Televisión Digital Terrestre actuante en la órbita del MINISTERIO DE PLANIFICACIÓN FEDERAL, INVERSIÓN PÚBLICA Y SERVICIOS.
Los Ludomatic, banda de música infantil exitosa en los años 80, se reúne luego de veinte años para ver que sus vidas no son como lo habían imaginado tiempo atrás. Toni, Becca, Marco, Lupe y Ren ...
Córdoba Castings es una empresa dedicada a realizar castings de toda clase, principalmente para otras provincias y el extranjero. Para Nelson, Sergio, Atilio, Ludmila y Pilar la empresa es sólo un a ...
Programa de ficción tragicómico donde cada emisión relata la travesía del capitán Logan y su ayudante el gato Mr. Ripley. El instructivo que lo devolverá a la tierra se encuentra mezclado con ci ...
Queda expresamente prohibida toda difusión, modificación, copia, transmisión, reproducción y/o distribución total o parcial de los contenidos audiovisuales del presente sitio web, en cualquier forma y/o por cualquier medio, sin la previa autorización escrita del Consejo Asesor del Sistema Argentino de Televisión Digital Terrestre actuante en la órbita del MINISTERIO DE PLANIFICACIÓN FEDERAL, INVERSIÓN PÚBLICA Y SERVICIOS.
Deporte argentino desarrolla la historia de diversos deportes en sus distintas facetas y alternativas. Un recorrido por cada disciplina deportiva, con reseñas de los hitos más importantes de cada un ...
Un joven embala cajas en un viejo galpón de la provincia de Buenos Aires. Nadie sabe bien qué sucede allí adentro. Nada sabe el Pollo del Mula, nada el Mula del Laucha, pero algo sí está claro, m ...
El ciclo consta de 13 capítulos que reproducen 13 diálogos entre distintas personalidades de la historia argentina. Comienza con el encuentro de San Martín y Bolívar en Guayaquil y finaliza con la ...
Queda expresamente prohibida toda difusión, modificación, copia, transmisión, reproducción y/o distribución total o parcial de los contenidos audiovisuales del presente sitio web, en cualquier forma y/o por cualquier medio, sin la previa autorización escrita del Consejo Asesor del Sistema Argentino de Televisión Digital Terrestre actuante en la órbita del MINISTERIO DE PLANIFICACIÓN FEDERAL, INVERSIÓN PÚBLICA Y SERVICIOS.
Hijos del río cuenta la historia de Don Jero, un pescador del Paraná que vive en la zona hace 50 años. Ahora, la contaminación y la creación de represas hacen peligrar su entorno y su vida en el ...
La Revolución Libertadora y la resistencia peronista, los años de Frondizi y de Onganía, los gobiernos radicales. Toda la historia argentina narrada desde un imponente archivo fotográfico, ilustra ...
Queda expresamente prohibida toda difusión, modificación, copia, transmisión, reproducción y/o distribución total o parcial de los contenidos audiovisuales del presente sitio web, en cualquier forma y/o por cualquier medio, sin la previa autorización escrita del Consejo Asesor del Sistema Argentino de Televisión Digital Terrestre actuante en la órbita del MINISTERIO DE PLANIFICACIÓN FEDERAL, INVERSIÓN PÚBLICA Y SERVICIOS.
Documental sobre el primer asentamiento español en territorio argentino, su reciente hallazgo arqueológico y la reconstrucción histórica de la expedición de Sebastián Gaboto.
El plan Nacional Igualdad Cultural, impulsado por el Ministerio de Planificación Federal, Inversión Publica y Servicios y la Secretaria de Cultura de Presidencia de la Nación, presentó el 14 de Ab ...
En los años 70 durante la última dictadura militar, funcionaron en el Nuevo Cuyo alrededor de 39 Centros Clandestinos y hubo más de 328 detenidos desaparecidos, pero nadie ha explicado aún cómo o ...
Una cámara sigue, buscando pasar inadvertida, los rastros dispersos de los lugares, actividades y personajes que trazan el mapa abierto, en construcción, de la vida santafesina. Una noche de chamam ...
Queda expresamente prohibida toda difusión, modificación, copia, transmisión, reproducción y/o distribución total o parcial de los contenidos audiovisuales del presente sitio web, en cualquier forma y/o por cualquier medio, sin la previa autorización escrita del Consejo Asesor del Sistema Argentino de Televisión Digital Terrestre actuante en la órbita del MINISTERIO DE PLANIFICACIÓN FEDERAL, INVERSIÓN PÚBLICA Y SERVICIOS.
Las máquinas y las herramientas fueron transformándose con el paso del tiempo. ¿En qué consistieron esos cambios? ¿Por qué necesitamos modificar su diseño y los materiales con que se fabrican? Recorremos los inventos más importantes y su impacto en el contexto histórico y económico en el que se produjeron, hitos que marcaron un antes y un después, y las características que hoy tienen nuestras máquinas y herramientas. Serie producida por Canal Encuentro y el Instituto Nacional de Educación Tecnológica (INET).
En este curso se enseñará a realizar caballetes, sillas, cajas y cajones, revestimientos y muebles laminados. Un conjunto de conocimientos para emplear en los hogares o para abrirse un nuevo camino en el plano laboral.
El 31 de enero de 1813, comenzó a sesionar la Asamblea General Constituyente, integrada por hombres con ideales de libertad, que promulgaron la soberanía y sentaron las bases para una nación independiente. Sus medidas abrieron el camino a la igualdad y a lo que comenzaba a ser la identidad argentina. Presenta Roberto Carnaghi.
Sinopsis: Martín Fierro, el personaje más popular
+de la literatura gauchesca argentina, permanece vigente en las calles,
+en el lenguaje cotidiano, en las protestas sociales, en nuestra memoria
+histórica. Un ciclo que explora los temas más representativos del libro,
+ los relaciona con los actores sociales de nuestro presente y trae de
+regreso al gaucho rebelde, cantor, víctima del poder de turno y filósofo
+ del desierto para que cuente sus historias inconclusas y revele sus
+secretos.
En la época en que nació la Reina
+ Victoria, en 1819, Inglaterra era una sociedad agraria. En el
+transcurso de unas cortas décadas, esa pequeña nación se transformaría
+en una superpotencia industrial, con un imperio que se extendía en el
+mundo. Esta serie es tanto la historia de esa extraordinaria época, como
+ un atractivo retrato de una reina que gobernó sobre una quinta parte de
+ la población mundial.
\ No newline at end of file
diff --git a/tests/images/swf_image_1br.jpeg b/tests/images/swf_image_1br.jpeg
new file mode 100644
index 0000000..94e5899
Binary files /dev/null and b/tests/images/swf_image_1br.jpeg differ
diff --git a/tests/images/swf_image_1cm.jpeg b/tests/images/swf_image_1cm.jpeg
new file mode 100644
index 0000000..b4fad0e
Binary files /dev/null and b/tests/images/swf_image_1cm.jpeg differ
diff --git a/tests/images/swf_image_1jm.jpeg b/tests/images/swf_image_1jm.jpeg
new file mode 100644
index 0000000..921fb6b
Binary files /dev/null and b/tests/images/swf_image_1jm.jpeg differ
diff --git a/tests/images/swf_image_1mg.jpeg b/tests/images/swf_image_1mg.jpeg
new file mode 100644
index 0000000..fef6ac5
Binary files /dev/null and b/tests/images/swf_image_1mg.jpeg differ
diff --git a/tests/images/swf_image_2ac.jpeg b/tests/images/swf_image_2ac.jpeg
new file mode 100644
index 0000000..18717ec
Binary files /dev/null and b/tests/images/swf_image_2ac.jpeg differ
diff --git a/tests/images/swf_image_2as.jpeg b/tests/images/swf_image_2as.jpeg
new file mode 100644
index 0000000..112d2bf
Binary files /dev/null and b/tests/images/swf_image_2as.jpeg differ
diff --git a/tests/images/swf_image_2cm.jpeg b/tests/images/swf_image_2cm.jpeg
new file mode 100644
index 0000000..b834c83
Binary files /dev/null and b/tests/images/swf_image_2cm.jpeg differ
diff --git a/tests/images/swf_image_2gc.jpeg b/tests/images/swf_image_2gc.jpeg
new file mode 100644
index 0000000..b904994
Binary files /dev/null and b/tests/images/swf_image_2gc.jpeg differ
diff --git a/tests/images/swf_image_2pl.jpeg b/tests/images/swf_image_2pl.jpeg
new file mode 100644
index 0000000..79f8706
Binary files /dev/null and b/tests/images/swf_image_2pl.jpeg differ
diff --git a/tests/images/swf_image_3dc.jpeg b/tests/images/swf_image_3dc.jpeg
new file mode 100644
index 0000000..11428ee
Binary files /dev/null and b/tests/images/swf_image_3dc.jpeg differ
diff --git a/tests/images/swf_image_3ob.jpeg b/tests/images/swf_image_3ob.jpeg
new file mode 100644
index 0000000..f60fc59
Binary files /dev/null and b/tests/images/swf_image_3ob.jpeg differ
diff --git a/tests/images/swf_image_3rl.jpeg b/tests/images/swf_image_3rl.jpeg
new file mode 100644
index 0000000..6b37100
Binary files /dev/null and b/tests/images/swf_image_3rl.jpeg differ
diff --git a/tests/images/swf_image_3vm.jpeg b/tests/images/swf_image_3vm.jpeg
new file mode 100644
index 0000000..20c5336
Binary files /dev/null and b/tests/images/swf_image_3vm.jpeg differ
diff --git a/tests/images/swf_image_4hl.jpeg b/tests/images/swf_image_4hl.jpeg
new file mode 100644
index 0000000..5f5d992
Binary files /dev/null and b/tests/images/swf_image_4hl.jpeg differ
diff --git a/tests/images/swf_image_4lb.jpeg b/tests/images/swf_image_4lb.jpeg
new file mode 100644
index 0000000..63a718a
Binary files /dev/null and b/tests/images/swf_image_4lb.jpeg differ
diff --git a/tests/images/swf_image_4lm.jpeg b/tests/images/swf_image_4lm.jpeg
new file mode 100644
index 0000000..364a60c
Binary files /dev/null and b/tests/images/swf_image_4lm.jpeg differ
diff --git a/tests/images/swf_image_4lr.jpeg b/tests/images/swf_image_4lr.jpeg
new file mode 100644
index 0000000..de19c50
Binary files /dev/null and b/tests/images/swf_image_4lr.jpeg differ
diff --git a/tests/images/swf_image_6lp.jpeg b/tests/images/swf_image_6lp.jpeg
new file mode 100644
index 0000000..a125eb8
Binary files /dev/null and b/tests/images/swf_image_6lp.jpeg differ
diff --git a/tests/images/swf_image_6mk.jpeg b/tests/images/swf_image_6mk.jpeg
new file mode 100644
index 0000000..b320e99
Binary files /dev/null and b/tests/images/swf_image_6mk.jpeg differ
diff --git a/tests/images/swf_image_6mw.jpeg b/tests/images/swf_image_6mw.jpeg
new file mode 100644
index 0000000..ed34408
Binary files /dev/null and b/tests/images/swf_image_6mw.jpeg differ
diff --git a/tests/images/swf_image_6ta.jpeg b/tests/images/swf_image_6ta.jpeg
new file mode 100644
index 0000000..dba7155
Binary files /dev/null and b/tests/images/swf_image_6ta.jpeg differ
diff --git a/tests/images/swf_image_7fp.jpeg b/tests/images/swf_image_7fp.jpeg
new file mode 100644
index 0000000..8a2b644
Binary files /dev/null and b/tests/images/swf_image_7fp.jpeg differ
diff --git a/tests/images/swf_image_7hb.jpeg b/tests/images/swf_image_7hb.jpeg
new file mode 100644
index 0000000..49d584d
Binary files /dev/null and b/tests/images/swf_image_7hb.jpeg differ
diff --git a/tests/images/swf_image_7hn.jpeg b/tests/images/swf_image_7hn.jpeg
new file mode 100644
index 0000000..5f592f6
Binary files /dev/null and b/tests/images/swf_image_7hn.jpeg differ
diff --git a/tests/images/swf_image_7ps.jpeg b/tests/images/swf_image_7ps.jpeg
new file mode 100644
index 0000000..b1c66b6
Binary files /dev/null and b/tests/images/swf_image_7ps.jpeg differ
diff --git a/tests/images/swf_image_7sv.jpeg b/tests/images/swf_image_7sv.jpeg
new file mode 100644
index 0000000..703a757
Binary files /dev/null and b/tests/images/swf_image_7sv.jpeg differ
diff --git a/tests/images/swf_image_8ec.jpeg b/tests/images/swf_image_8ec.jpeg
new file mode 100644
index 0000000..7379f73
Binary files /dev/null and b/tests/images/swf_image_8ec.jpeg differ
diff --git a/tests/images/swf_image_8jf.jpeg b/tests/images/swf_image_8jf.jpeg
new file mode 100644
index 0000000..656f501
Binary files /dev/null and b/tests/images/swf_image_8jf.jpeg differ
diff --git a/tests/images/swf_image_8jo.jpeg b/tests/images/swf_image_8jo.jpeg
new file mode 100644
index 0000000..da1337b
Binary files /dev/null and b/tests/images/swf_image_8jo.jpeg differ
diff --git a/tests/images/swf_image_8js.jpeg b/tests/images/swf_image_8js.jpeg
new file mode 100644
index 0000000..0456562
Binary files /dev/null and b/tests/images/swf_image_8js.jpeg differ
diff --git a/tests/images/swf_image_8rl.jpeg b/tests/images/swf_image_8rl.jpeg
new file mode 100644
index 0000000..b726224
Binary files /dev/null and b/tests/images/swf_image_8rl.jpeg differ
diff --git a/tests/test_bacua_scrapers.py b/tests/test_bacua_scrapers.py
new file mode 100644
index 0000000..2a9d99b
--- /dev/null
+++ b/tests/test_bacua_scrapers.py
@@ -0,0 +1,230 @@
+# -*- coding: utf-8 -*-
+
+# Copyright 2012 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Tests for the scrapers for Conectate backend."""
+
+import unittest
+
+from server import get_bacua_episodes
+
+
+_RES_LIST_1 = [
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=1',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=2',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=3',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=4',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=5',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=6',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=7',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=8',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=9',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=10',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=11',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=12',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=13',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=14',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=15',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=16',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=17',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=18',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=19',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=20',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=21',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=22',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=23',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=24',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=25',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=26',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=27',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=28',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=29',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=30',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=31',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=32',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=33',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=34',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=35',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=36',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=37',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=38',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=39',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=40',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=41',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=42',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=43',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=44',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=45',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=46',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=47',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=48',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=49',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=50',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=51',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=52',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=53',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=54',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=55',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=56',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=57',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=58',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=59',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=60',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=61',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=62',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=63',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=64',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=65',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=66',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=67',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=68',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=69',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=70',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=71',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=72',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=73',
+ 'http://catalogo.bacua.gob.ar/catalogo.php?buscador=&ordenamiento=title&idTematica=0&page=74',
+]
+
+
+_RES_PAGE_1 = [
+ dict(
+ title=u"Campo Grande",
+ description=u"Hace más de dos siglos, algunas familias de Santiago Del Estero y Orán partieron hacia el corazón del gran Chaco. Convivieron con el indio y se adaptaron a las duras condiciones. Campo Grande fue ...",
+ episode_id="bacua_6732813b",
+ duration="?",
+ url="http://backend.bacua.gob.ar/video.php?v=_6732813b",
+ section="Micro",
+ channel="Bacua",
+ image_url="http://backend.bacua.gob.ar/img.php?idvideo=6732813b",
+ season=None,
+ ),
+ dict(
+ title=u"Cañete Chiquiclips",
+ description=u"Chiquiclips son consejos sobre seguridad vial, primeros cuidados y salud cantados a los niños por el payaso Cañete.",
+ episode_id="bacua_c8fa30ec",
+ duration="?",
+ url="http://backend.bacua.gob.ar/video.php?v=_c8fa30ec",
+ section="Micro",
+ channel="Bacua",
+ image_url="http://backend.bacua.gob.ar/img.php?idvideo=c8fa30ec",
+ season=None,
+ ),
+]
+
+_RES_PAGE_2 = [
+ dict(
+ title=u"Corazon De Vinilo",
+ description=u"Los Ludomatic, banda de música infantil exitosa en los años 80, se reúne luego de veinte años para ver que sus vidas no son como lo habían imaginado tiempo atrás. Toni, Becca, Marco, Lupe y Ren ...",
+ episode_id="bacua_4e0d053f",
+ duration="?",
+ url="http://backend.bacua.gob.ar/video.php?v=_4e0d053f",
+ section="Micro",
+ channel="Bacua",
+ image_url="http://backend.bacua.gob.ar/img.php?idvideo=4e0d053f",
+ season=None,
+ ),
+ dict(
+ title=u"Cordoba Castings",
+ description=u"Córdoba Castings es una empresa dedicada a realizar castings de toda clase, principalmente para otras provincias y el extranjero. Para Nelson, Sergio, Atilio, Ludmila y Pilar la empresa es sólo un a ...",
+ episode_id="bacua_2877c648",
+ duration="?",
+ url="http://backend.bacua.gob.ar/video.php?v=_2877c648",
+ section="Micro",
+ channel="Bacua",
+ image_url="http://backend.bacua.gob.ar/img.php?idvideo=2877c648",
+ season=None,
+ ),
+]
+
+_RES_PAGE_3 = [
+ # no videos in this page :/
+]
+
+_RES_PAGE_4 = [
+ dict(
+ title=u"Hijos De La Montaña",
+ description=u"",
+ episode_id="bacua_b4fb3ef2",
+ duration="?",
+ url="http://backend.bacua.gob.ar/video.php?v=_b4fb3ef2",
+ section="Micro",
+ channel="Bacua",
+ image_url="http://backend.bacua.gob.ar/img.php?idvideo=b4fb3ef2",
+ season=None,
+ ),
+]
+
+_RES_PAGE_5 = [
+ dict(
+ title=u"Catupecu Machu",
+ description=u"El plan Nacional Igualdad Cultural, impulsado por el Ministerio de Planificación Federal, Inversión Publica y Servicios y la Secretaria de Cultura de Presidencia de la Nación, presentó el 14 de Ab ...",
+ episode_id="bacua_91480bfa",
+ duration="?",
+ url="http://backend.bacua.gob.ar/video.php?v=_91480bfa",
+ section="Micro",
+ channel="Bacua",
+ image_url="http://backend.bacua.gob.ar/img.php?idvideo=91480bfa",
+ season=None,
+ ),
+ dict(
+ title=u"Centros Clandestinos",
+ description=u"En los años 70 durante la última dictadura militar, funcionaron en el Nuevo Cuyo alrededor de 39 Centros Clandestinos y hubo más de 328 detenidos desaparecidos, pero nadie ha explicado aún cómo o ...",
+ episode_id="bacua_3cfa998a",
+ duration="?",
+ url="http://backend.bacua.gob.ar/video.php?v=_3cfa998a",
+ section="Micro",
+ channel="Bacua",
+ image_url="http://backend.bacua.gob.ar/img.php?idvideo=3cfa998a",
+ season=None,
+ ),
+]
+
+class ScrapersTestCase(unittest.TestCase):
+ """Tests for the scrapers."""
+
+ def test_example_list_1(self):
+ html = open("tests/ej-bacua-list_1.html").read()
+ res = get_bacua_episodes.scrap_list_page(html)
+ self.assertEqual(res, _RES_LIST_1)
+
+ def test_example_page_1(self):
+ html = open("tests/ej-bacua-page_1.html").read()
+ res = get_bacua_episodes.scrap_page(html)
+ self.assertEqual(res, _RES_PAGE_1)
+
+ def test_example_page_2(self):
+ html = open("tests/ej-bacua-page_2.html").read()
+ res = get_bacua_episodes.scrap_page(html)
+ self.assertEqual(res, _RES_PAGE_2)
+
+ def test_example_page_3(self):
+ html = open("tests/ej-bacua-page_3.html").read()
+ res = get_bacua_episodes.scrap_page(html)
+ self.assertEqual(res, _RES_PAGE_3)
+
+ def test_example_page_4(self):
+ html = open("tests/ej-bacua-page_4.html").read()
+ res = get_bacua_episodes.scrap_page(html)
+ self.assertEqual(res, _RES_PAGE_4)
+
+ def test_example_page_5(self):
+ html = open("tests/ej-bacua-page_5.html").read()
+ res = get_bacua_episodes.scrap_page(html)
+ #print "\n=== res", [sorted(x.items()) for x in res]
+ #print "=== RES", [sorted(x.items()) for x in _RES_PAGE_5]
+ self.assertEqual(res, _RES_PAGE_5)
diff --git a/tests/test_conect_scrapers.py b/tests/test_conect_scrapers.py
new file mode 100644
index 0000000..09062b4
--- /dev/null
+++ b/tests/test_conect_scrapers.py
@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+
+# Copyright 2012-2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Tests for the scrapers for Conectate backend."""
+
+import unittest
+
+from server import scrapers_conect
+
+_RES_SERIES_1 = [
+ (u"Máquinas y herramientas", u"01. Historia de las máquinas y las herramientas", "http://www.conectate.gob.ar/sitios/conectate/busqueda/encuentro?rec_id=121847"),
+ (u"Máquinas y herramientas", u"02. Diseño y uso de máquinas-herramientas", "http://www.conectate.gob.ar/sitios/conectate/busqueda/encuentro?rec_id=121848"),
+ (u"Máquinas y herramientas", u"03. Diseño y uso de herramientas de corte", "http://www.conectate.gob.ar/sitios/conectate/busqueda/encuentro?rec_id=121849"),
+ (u"Máquinas y herramientas", u"04. Herramientas de corte y máquinas-herramientas: nuevos paradigmas", "http://www.conectate.gob.ar/sitios/conectate/busqueda/encuentro?rec_id=121850"),
+]
+
+_RES_SERIES_2 = [
+ (u"Oficios Curso de Carpintería", u"01. Introducción a la carpintería", "http://www.conectate.gob.ar/sitios/conectate/busqueda/buscar?rec_id=103248"),
+ (u"Oficios Curso de Carpintería", u"02. Realización de caballetes", "http://www.conectate.gob.ar/sitios/conectate/busqueda/buscar?rec_id=103260"),
+ (u"Oficios Curso de Carpintería", u"03. Realización de sillas, parte 1", "http://www.conectate.gob.ar/sitios/conectate/busqueda/buscar?rec_id=103261"),
+ (u"Oficios Curso de Carpintería", u"04. Realización de sillas, parte 2", "http://www.conectate.gob.ar/sitios/conectate/busqueda/buscar?rec_id=103265"),
+ (u"Oficios Curso de Carpintería", u"05. Realización de cajas y cajones, parte 1", "http://www.conectate.gob.ar/sitios/conectate/busqueda/buscar?rec_id=103268"),
+ (u"Oficios Curso de Carpintería", u"06. Realización de cajas y cajones, parte 2", "http://www.conectate.gob.ar/sitios/conectate/busqueda/buscar?rec_id=103270"),
+ (u"Oficios Curso de Carpintería", u"07. Revestimientos, parte 1", "http://www.conectate.gob.ar/sitios/conectate/busqueda/buscar?rec_id=103275"),
+ (u"Oficios Curso de Carpintería", u"08. Revestimientos, parte 2", "http://www.conectate.gob.ar/sitios/conectate/busqueda/buscar?rec_id=103277"),
+ (u"Oficios Curso de Carpintería", u"09. Muebles laminados, parte 1", "http://www.conectate.gob.ar/sitios/conectate/busqueda/buscar?rec_id=103279"),
+ (u"Oficios Curso de Carpintería", u"10. Muebles laminados, parte 2", "http://www.conectate.gob.ar/sitios/conectate/busqueda/buscar?rec_id=103282"),
+]
+
+_RES_VIDEO_01 = 26
+
+class ScrapersTestCase(unittest.TestCase):
+ """Tests for the scrapers."""
+
+ def test_example_series_1(self):
+ html = open("tests/ej-conect-series_1.html").read()
+ res = scrapers_conect.scrap_series(html)
+ self.assertEqual(res, _RES_SERIES_1)
+
+ def test_example_series_2(self):
+ html = open("tests/ej-conect-series_2.html").read()
+ res = scrapers_conect.scrap_series(html)
+ self.assertEqual(res, _RES_SERIES_2)
+
+ def test_example_video_01(self):
+ html = open("tests/ej-conect-video_01.html").read()
+ res = scrapers_conect.scrap_video(html)
+ self.assertEqual(res, _RES_VIDEO_01)
diff --git a/tests/test_dqsv_scrapers.py b/tests/test_dqsv_scrapers.py
new file mode 100644
index 0000000..a8b2e35
--- /dev/null
+++ b/tests/test_dqsv_scrapers.py
@@ -0,0 +1,340 @@
+# -*- coding: utf-8 -*-
+
+# Copyright 2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Tests for the scrapers for decimequiensosvos backend."""
+
+import datetime
+import unittest
+
+from server import scrapers_dqsv
+
+_SHOULD_SWF_1 = [(
+ "Carlos Alberto “Nito” Mestre",
+ "Músico.",
+ "Su voz entonada, su estirpe de clásico, su hermandad musical con Charly García, su calidad compositiva, su buen gusto, su trayectoria, lo convierten en una figura emblemática del rock nacional. Fundador de Sui Generis y PorsuiGieco, luego el liderazgo en varios grupos, y su definitiva condición de solista proyectado a la escena latinoamericana, con afincamientos en Miami y en Buenos Aires, encuentran a Nito estabilizado, con plenitud artística y de vida. Revaloriza su pasado de juventud, es autocrítico con el alcoholismo que padeció, pero se reconforta por haber dado vuelta la página, “con silenciosa ética”.",
+ "swf_image_1cm.jpeg",
+ datetime.date(year=2013, month=9, day=1),
+ ), (
+ "Baltasar Garzón Real",
+ "Jurista español.",
+ "Fue el juez que ordenó detener al dictador Augusto Pinochet, acusado por la desaparición de españoles en Chile, así como también contra genocidas argentinos, entre ellos el represor Adolfo Scilingo. Dictó numerosos sumarios contra la ETA y varios casos resonantes sobre el tráfico de drogas. Este andaluz nacido en el pequeño pueblo de Jaén, reside temporalmente en Argentina; fue suspendido por el Poder Judicial Español acusado de prevaricato en la investigación de los crímenes del franquismo, una decisión condenada por los organismos de Derechos Humanos. Aquí preside el Centro Internacional para la Promoción de esos Derechos.",
+ "swf_image_1br.jpeg",
+ datetime.date(year=2013, month=9, day=15),
+ ), (
+ "Marián Farías Gómez",
+ "Percusionista, cantora.",
+ "Proveniente de una familia de músicos y artistas, desde muy joven fue la primera voz de Los Huanca Huá. Años más tarde se integró como solista de Ariel Ramírez y a fines de 1966 grabó su propio álbum . Continuó su carrera en España porque la dictadura cívico militar la obligó a exilarse por ser militante peronista y en ese país compartió escenarios con artistas de la talla de Armando Tejada Gómez; Alfredo Zitarroza; Chango Farías Gómez; Rafael Amor; Astor Piazzolla, entre tantos otros. En la actualidad, además de continuar con su canto, Marian es Directora Provincial de Patrimonio Cultural de la provincia de Buenos Aires.",
+ "swf_image_1mg.jpeg",
+ datetime.date(year=2013, month=9, day=22),
+ ), (
+ "Jorge Marrale",
+ "Actor.",
+ "Jorge Marrale transita por todas las posibilidades escénicas que le brinda su condición de actor, aunque se vuelca preferentemente hacia el teatro. Pero también es exitoso en ciclos de cuidada producción de TV, como el inolvidable personaje de psicolonalista en Vulnerables. El cine le dio la oportunidad de personificar a Perón, en Ay Juancito. Por su sólida actividad artística, recibió gran número de premios, como el Konex de Platino, el Martín Fierro, el Ace de Oro, entre otros. Simultáneamente y siempre imbuído de su gran compromiso social, co conduce, como secretario general, la Sociedad Argentina de Gestión de Actores Intérpretes.",
+ "swf_image_1jm.jpeg",
+ datetime.date(year=2013, month=9, day=29),
+ )]
+
+_SHOULD_SWF_2 = [(
+ "Pablo LLonto",
+ "Abogado y periodista.",
+ "Su vida pública y su ideario se desarrollan a través de dos caminos pavimentados con sólidas convicciones sobre la igualdad y la justicia: el periodismo y la abogacía. Hay dos causas emblemáticas en su derrotero: Papel Prensa y la adopción de bebés por parte de la directora del diario Clarín. Justamente “La Noble Ernestina” es uno de sus libros más representativos. Fue delegado gremial de ese diario, perseguido por la empresa al punto de no dejarlo entrar a la redacción. En la actualidad escribe en “Caras y Caretas”, “Un caño” y en diversas publicaciones del exterior. Y milita, como hace una treintena de años, en la lucha por derechos humanos.",
+ "swf_image_2pl.jpeg",
+ datetime.date(year=2014, month=3, day=2),
+ ), (
+ "Alberto Sava",
+ "Artista, docente y psicólogo social.",
+ "Fue el fundador del Frente de Artistas del Borda, un espacio de denuncia y cambio social, desde donde sembró y cultivó una innovadora concepción en el tratamiento de la salud mental. Su lucha por el fin del encierro y por la transformación del manicomio es permanente, con una propuesta innovadora al incorporar talleres de arte como una disciplina per se. Sava preside la “Asociación Civil Red Argentina de Arte y Salud Mental”, es autor del libro “Desde el mimo contemporáneo hasta el teatro participativo” y coautor de “Frente de Artistas del Borda, una experiencia desmanicomializadora”. Un tipo que va al frente.",
+ "swf_image_2as.jpeg",
+ datetime.date(year=2014, month=3, day=9),
+ ), (
+ "Carlos Mellino",
+ "Músico.",
+ "Con su voz potente, sus matices de tecladista y su inspiración de compositor, es el alma y vida de “Alma y vida”, banda que la historia del rock nacional y su fusión con el jazz le reserva la condición de fundadora, junto con otras pioneras. Es aquel creador del mítico 'Del gemido de un gorrión'. Y ya en las edades mayores, Carlos Mellino reaparece cada tanto, con sus compañeros de siempre, sus éxitos eternos, sus nuevas propuestas. Y él, todo un abuelo, traslada sus vivencias a obras artesanales en coproducción activa con sus seres queridos como “Hasta que llegue mi voz”, su nueva obra.",
+ "swf_image_2cm.jpeg",
+ datetime.date(year=2014, month=3, day=16),
+ ), (
+ "Ana Cacopardo",
+ "Periodista y realizadora audiovisual.",
+ "La imagen de Ana en los medios audiovisuales se proyecta como una rara avis, alejada de los estereotipos en danza. Sus programas, emparentados con el documentalismo, se disparan a partir de una agenda e investigación que vinculan lo subjetivo con lo presente y la memoria colectiva. Por su ciclo “Historias Debidas” (Encuentro), fue merecedora del premio Lola Mora 2013 y uno de sus muchos documentales, “Cartoneros de Villa Itatí”, que produjo junto con Eduardo Mignona, ganó en el Quinto Festival Internacional de Cine y Derechos Humanos.",
+ "swf_image_2ac.jpeg",
+ datetime.date(year=2014, month=3, day=23),
+ ), (
+ "Graciana Peñafort Colombi",
+ "Abogada.",
+ "Es la directora general de Asuntos Jurídicos de la subsecretaría de Coordinación Administrativa del ministerio de Defensa, pero cobró justa notoriedad cuando se plantó con sus argumentos tan sólidos como brillantes frente a la Corte Suprema de Justicia en defensa de la Ley de Medios Audiovisuales, de la que es coautora. Esta profesional sanjuanina es una de las principales responsables, por su enorme conocimiento, del surgimiento de nuevos espacios audiovisuales para las tantas palabras que esperan su lugar. Una voz imprescindible.",
+ "swf_image_2gc.jpeg",
+ datetime.date(year=2014, month=3, day=30),
+ )]
+
+_SHOULD_SWF_3 = [(
+ "Víctor Hugo Morales",
+ "Periodista, relator deportivo, conductor.",
+ "Nació en Uruguay donde vivió hasta los 16 años. Relató todos los mundiales, desde 1978 a 2006. Escribió varios libros sobre fútbol: “El Intruso”, “Un grito en el desierto”, “Jugados, crítica a la patria deportista” y “Hablemos de fútbol” –en coautoría con Roberto Perfumo-.",
+ "swf_image_3vm.jpeg",
+ datetime.date(year=2009, month=4, day=26),
+ ), (
+ "Osvaldo Bayer",
+ "Escritor, historiador, periodista, guionista.",
+ "Estudió historia en la Universidad de Hamburgo donde vivió entre 1952 y 1956. Es profesor honorario de la Cátedra Libre de Derechos Humanos de la Facultad de Filosofía y Letras de la Universidad de Buenos Aires. Muchas de sus obras fueron llevadas al cine, entre ellas “La Patagonia Rebelde”.",
+ "swf_image_3ob.jpeg",
+ datetime.date(year=2009, month=4, day=19),
+ ), (
+ "Rodolfo Livingston",
+ "Arquitecto.",
+ "Es el creador de la especialidad “Arquitectos de familia”, un sistema de diseño participativo que recibió varios premios internacionales. Escribió diez libros, con 38 reediciones. Fue director del Centro Cultural Recoleta en 1989. Fundó, junto con otros colegas, la Facultad de Arquitectura de la Universidad del Nordeste, en Chaco, 1956.",
+ "swf_image_3rl.jpeg",
+ datetime.date(year=2009, month=4, day=12),
+ ), (
+ "Diego Capusotto",
+ "Actor, humorista, conductor.",
+ "Nació en Castelar, el 21 de septiembre de 1961. Es fana de Racing y alguna vez soñó con ser jugador de fútbol. Entre tantas distinciones, se destaca el Martín Fierro que recibió en 2008 por el programa “Peter Capusotto y sus videos”.",
+ "swf_image_3dc.jpeg",
+ datetime.date(year=2009, month=4, day=5),
+ )]
+
+_SHOULD_SWF_4 = [(
+ "León Rozitchner",
+ "Filósofo, profesor.",
+ "Estudió Humanidades en la Sorbona de París, donde se graduó en 1952, con maestros como Maurice Merleau-Ponty y Claude Lévi Strauss. Con David Viñas, Oscar Masotta y Noé Jitrik trabajó en la revista Contorno. Tras el golpe militar del '76 se exilió en Venezuela donde dirigió el Instituto de Filosofía de la Praxis. Fue investigador del Conicet, experto de la UNESCO y profesor titular en la Carrera de Sociología de la UBA, entre las muchas actividades que desempeñó.",
+ "swf_image_4lr.jpeg",
+ datetime.date(year=2009, month=10, day=4),
+ ), (
+ "Luis Brandoni",
+ "Actor.",
+ "Quiso ser futbolista o cantor de orquesta de tango pero finalmente emprendió el camino de la actuación donde cosechó grandes éxitos en teatro, cine y televisión. Se desempeñó también como Secretario General de la Asociación Argentina de Actores, asesor presidencial en lo cultural durante el gobierno de Raúl Alfonsín, y diputado nacional por el radicalismo.",
+ "swf_image_4lb.jpeg",
+ datetime.date(year=2009, month=10, day=4),
+ ), (
+ "Héctor Larrea",
+ "Locutor, conductor.",
+ "Creador de un estilo de revista radial coloquial y maestro de radio, Héctor Larrea nació en Bragado, quiso ser jugador de fútbol pero se llevó de maravillas con las palabras, los sonidos, la música. En la década del 60 fue el presentador oficial de los shows de Sandro y de prestigiosas orquestas de tango (Pugliese en el Colón, el Polaco Goyeneche en el Opera), y en el 67 puso al aire Rapidísmo, el programa que se convirtió en un hito de la radiofonía argentina. Actualmente conduce “Una vuelta Nacional” por la Radio Pública.",
+ "swf_image_4hl.jpeg",
+ datetime.date(year=2009, month=10, day=18),
+ ), (
+ "Leonor Manso",
+ "Actriz y directora de teatro.",
+ "Participó en más de diez obras teatrales tanto nacionales como extranjeras y en una veintena de obras cinematográficas y televisivas. Fue integrante del elenco de la importante propuesta de Teatro Abierto. En este 2009 está representando la obra teatral “Ten piedad de mi” y dirige otra: “Antígonas”, en el Centro Cultural de la Cooperación.",
+ "swf_image_4lm.jpeg",
+ datetime.date(year=2009, month=10, day=25),
+ )]
+
+_SHOULD_SWF_6 = [(
+ "Mauricio Kartun",
+ "Dramaturgo, director y docente.",
+ "La dramaturgia expresada y entendida con absoluta convicción, claridad y versación en la voz y la rica trayectoria de Mauricio Kartún: autor, director, docente y todo bajo una mirada inteligente, apasionada y comprometida con la problemática social. Es el creador de la Carrera de Dramaturgia de la Escuela de Arte Dramático de la Ciudad de Buenos Aires; escribió alrededor de veinticinco piezas teatrales; sus obras merecieron las distinciones más destacadas y es atrapante tanto su sabiduría como su forma de transmitirla.",
+ "swf_image_6mk.jpeg",
+ datetime.date(year=2012, month=4, day=1),
+ ), (
+ "Taty Almeida",
+ "Madre de Plaza de Mayo.",
+ "Alejandro Almeida tenía 20 años cuando fue detenido y desaparecido en la noche del 17 de junio de 1975. Sólo después de muchos años su mamá, Taty, supo que su hijo militaba en el ERP. Desde entonces ella, en cuyo entorno familiar eran todos militares y antiperonistas, comenzó a romper lazos de antaño para crear otros: los de la lucha compartida con las Madres en la búsqueda de sus hijos desaparecidos. Taty se reconoce “parida” por su hijo Alejandro, a partir de él y sus circunstancias, nació una nueva Taty, integrante de la Asociación Madres de Plaza de Mayo, Línea Fundadora. En 2008 publicó un libro con los 24 poemas que encontró en la agenda de su hijo.",
+ "swf_image_6ta.jpeg",
+ datetime.date(year=2012, month=4, day=8),
+ ), (
+ "Mario Wainfeld",
+ "Periodista, abogado.",
+ "De abogado en ejercicio pleno, a periodista o, si se quiere: periodista pleno, profesión que gradualmente lo abrazó y así evolucionó su vida laboral. Tan es así que a sus 63 años la desarrolla en medios gráficos, radio y televisión. Siempre con la política como eje de su ideario. Ejerció también la docencia y se desempeñó en la función pública. En la actualidad conduce “Gente de a pie”, por Radio Nacional; es uno de los principales analistas políticos del diario 'Página 12' y en televisión es columnista del programa 'Duro de Domar'. Mario es una voz amable, reflexiva, respetuosa, respetable y sustantivamente creíble.",
+ "swf_image_6mw.jpeg",
+ datetime.date(year=2012, month=4, day=15),
+ ), (
+ "Ligia Piro",
+ "Cantante, actriz.",
+ "Estudió canto en el Conservatorio Nacional de Música López Buchardo pero también se formó como actriz con el maestro Agustín Alezzo. Su comienzo como cantante profesional tuvo anclaje en la bossa nova y en el jazz, género musical por la que fue distinguida con el premio Konex a la mejor solista en 2005. Su afinada y cálida voz y su excelencia interpretativa la conducen por senderos que amplían el repertorio. Así lo testimonia su último disco, Las flores buenas, un manojo de temas latinoamericanos. Hija de dos grandes artistas, Susana Rinaldi y Alfredo Piro, Ligia es un canto y encanto al buen canto.",
+ "swf_image_6lp.jpeg",
+ datetime.date(year=2012, month=4, day=29),
+ )]
+
+_SHOULD_SWF_7 = [(
+ "Pepe Soriano",
+ "Actor.",
+ "Nació el 25 de septiembre de 1929 en Buenos Aires, Argentina. Trabajó tanto en Argentina como en España. Debutó en el teatro Colón con “Sueño de una noche de verano” y entre sus trabajos se destacan la inolvidable “La Nona”, “El Loro Calabrés”, “Gris de Ausencia”, “Tute Cabrero”, entre tantas. Es Presidente de la Sociedad Argentina de Gestión de Actores Intérpretes.",
+ "swf_image_7ps.jpeg",
+ datetime.date(year=2009, month=5, day=31),
+ ), (
+ "Héctor Negro",
+ "Poeta.",
+ "Es titular de la Academia Nacional del Tango. Lleva publicados 13 libros de poesías, un cancionero y dos antologías. Sus letras fueron grabadas por varios intérpretes de tango.",
+ "swf_image_7hn.jpeg",
+ datetime.date(year=2009, month=5, day=24),
+ ), (
+ "Soledad Villamil",
+ "Actriz y cantante.",
+ "A los 15 años comenzó a estudiar teatro y en 2006 se lanzó como cantante. Está por editar su segundo disco como solista. Se destacó en numerosas obras de teatro y unitarios para la televisión.",
+ "swf_image_7sv.jpeg",
+ datetime.date(year=2009, month=5, day=17),
+ ), (
+ "Hebe de Bonafini",
+ "Presidenta de la Asociación Madres de Plaza de Mayo.",
+ "Una de las creadoras de la Asociación Madres de Plaza de Mayo, desde el 30 de abril de 1977. Presidenta de la Asociación Madres de Plaza de Mayo desde 1979 y continúa. Conduce la Cátedra Libre de Derechos Humanos de la escuela Superior de Periodismo y Comunicación Social de la Universidad de La Plata.",
+ "swf_image_7hb.jpeg",
+ datetime.date(year=2009, month=5, day=10),
+ ), (
+ "Felipe Pigna",
+ "Historiador.",
+ "Es profesor de historia egresado del profesorado Joaquín V. González y dirige el proyecto “Ver la historia” de la Universidad de Buenos Aires. Está al frente de la revista “Caras y Caretas”. Conductor de varias propuestas televisivas, editó una docena de libros, entre ellos la saga “Los mitos e la historia argentina”.",
+ "swf_image_7fp.jpeg",
+ datetime.date(year=2009, month=5, day=3),
+ )]
+
+_SHOULD_SWF_8 = [(
+ "Juan Sasturain",
+ "Escritor, periodista y guionista de historietas.",
+ "Creó y dirigió la revista Fierro. Es el conductor del programa de televisión “Ver Para Leer” (por emitirse, aún, en 2009); egresó de la carrera de Letras y ejerció la docencia desde la literatura. Cada lunes escribe la contratapa del diario Página/12. Entre sus tantos libros publicados se destacan “Manual de perdedores”, “Arena en los zapatos”, “Parecido S.A.”, “Los dedos de Walt Disney”, “Los sentidos del agua”, “Brooklin y medio” y “La lucha continúa”.",
+ "swf_image_8js.jpeg",
+ datetime.date(year=2009, month=8, day=2),
+ ), (
+ "Jairo",
+ "Cantor.",
+ "Es cordobés y comenzó a cantar desde muy pequeño en su colegio, “Pablo Pizzurno”, en Cruz del Eje, Córdoba; más tarde integró el grupo de rock The Twisters Boys. Actuó en varios programas televisivos donde se presentaba como Marito González hasta que viajó a Europa: en España grabó su primer disco y en Francia –donde vivió 16 años- vendió más de cinco millones. Consagrado como un artista internacional, decidió volver a su país en 1994.",
+ "swf_image_8jo.jpeg",
+ datetime.date(year=2009, month=8, day=9),
+ ), (
+ "Rogelio García Lupo",
+ "Periodista e historiador.",
+ "Nació en 1931 y ejerce la profesión de periodista desde los 21 años. Cofundó en Cuba, en 1959, la Agencia Internacional Prensa Latina junto a Gabriel García Márquez, Jorge Masetti y Rodolfo Walsh. Fue ampliamente galardonado y entre esos premios recibió el de la Fundación Nuevo Periodismo Iberoamericano. Lleva publicados una decena de libros y es considerado uno de los más grandes periodistas e historiadores de nuestro país.",
+ "swf_image_8rl.jpeg",
+ datetime.date(year=2009, month=8, day=16),
+ ), (
+ "Estela Barnes de Carlotto",
+ "Abuela de Plaza de Mayo.",
+ "Durante 28 años se dedicó a la docencia, tuvo 4 hijos, entre ellos a Laura, secuestrada, embarazada de tres meses y luego asesinada por la dictadura militar argentina en 1977. Desde entonces Estela de Carlotto rastrea a su nieto Guido.",
+ "swf_image_8ec.jpeg",
+ datetime.date(year=2009, month=8, day=23),
+ ), (
+ "José Pablo Feinmann",
+ "Filósofo, docente, escritor.",
+ "En 1973 fundó el Centro de Estudios del Pensamiento Latinoamericano, en el Departamento de Filosofía de la UBA. Escribe artículos en Página/12, dicta cursos de filosofía de masiva convocatoria, conduce los programas televisivos “Filosofía, aquí y ahora” por el Canal Encuentro y “Cine con texto”, emitido por Canal 7. Sus libros fueron traducidos a varios idiomas y muchos de ellos convertidos en guiones cinematográficos.",
+ "swf_image_8jf.jpeg",
+ datetime.date(year=2009, month=8, day=30),
+ )]
+
+
+class ScrapersTestCase(unittest.TestCase):
+ """Tests for the scrapers."""
+
+ def _check(self, result, should_have):
+ """Helper to check."""
+ self.assertEqual(len(result), len(should_have))
+ for res, should in zip(result, should_have):
+ self.assertEqual(res.name, should[0])
+ self.assertEqual(res.occup, should[1])
+ self.assertEqual(res.bio, should[2])
+ with open('tests/images/' + should[3], 'rb') as fh:
+ data = fh.read()
+ self.assertEqual(res.image, data)
+ self.assertEqual(res.date, should[4])
+
+ def test_example_series_1(self):
+ swf = open("tests/ej-dqsv-1.swf", 'rb')
+ result = scrapers_dqsv.scrap(swf)
+ self._check(result, _SHOULD_SWF_1)
+
+ def test_example_series_2(self):
+ swf = open("tests/ej-dqsv-2.swf", 'rb')
+ result = scrapers_dqsv.scrap(swf)
+ self._check(result, _SHOULD_SWF_2)
+
+ def test_example_series_3(self):
+ swf = open("tests/ej-dqsv-3.swf", 'rb')
+ custom_order = [
+ u"Diego Capusotto",
+ u"Osvaldo Bayer",
+ u"Víctor Hugo Morales",
+ u"Rodolfo Livingston",
+ ]
+ result = scrapers_dqsv.scrap(swf, custom_order)
+ self._check(result, _SHOULD_SWF_3)
+
+ def test_example_series_4(self):
+ swf = open("tests/ej-dqsv-4.swf", 'rb')
+ result = scrapers_dqsv.scrap(swf)
+ self._check(result, _SHOULD_SWF_4)
+
+ def test_example_series_5(self):
+ swf = open("tests/ej-dqsv-5.swf", 'rb')
+ result = scrapers_dqsv.scrap(swf)
+ self._check(result, [])
+
+ def test_example_series_6(self):
+ swf = open("tests/ej-dqsv-6.swf", 'rb')
+ result = scrapers_dqsv.scrap(swf)
+ self._check(result, _SHOULD_SWF_6)
+
+ def test_example_series_7(self):
+ swf = open("tests/ej-dqsv-7.swf", 'rb')
+ custom_order = [
+ u"Felipe Pigna",
+ u"Héctor Negro",
+ u"Hebe de Bonafini",
+ u"Soledad Villamil",
+ u"Pepe Soriano",
+ ]
+ result = scrapers_dqsv.scrap(swf, custom_order)
+ self._check(result, _SHOULD_SWF_7)
+
+ def test_example_series_8(self):
+ swf = open("tests/ej-dqsv-8.swf", 'rb')
+ custom_order = [
+ u"Juan Sasturain",
+ u"Jairo",
+ u"Rogelio García Lupo",
+ u"Estela Barnes de Carlotto",
+ u"José Pablo Feinmann",
+ ]
+ result = scrapers_dqsv.scrap(swf, custom_order)
+ self._check(result, _SHOULD_SWF_8)
+
+
+class HelpersTestCase(unittest.TestCase):
+ """Tests for the helping functions."""
+
+ def test_date_simple(self):
+ r = scrapers_dqsv._fix_date("12/05/09 the rest")
+ self.assertEqual(r, datetime.date(2009, 5, 12))
+
+ def test_date_double(self):
+ r = scrapers_dqsv._fix_date("19-26/05/09 the rest")
+ self.assertEqual(r, datetime.date(2009, 5, 19))
+
+ def test_date_invalid(self):
+ r = scrapers_dqsv._fix_date("INVALID")
+ self.assertEqual(r, None)
+
+ def test_occup_simple(self):
+ r = scrapers_dqsv._fix_occup("foo bar. ")
+ self.assertEqual(r, "Foo bar.")
+
+ def test_occup_empty(self):
+ r = scrapers_dqsv._fix_occup("")
+ self.assertEqual(r, "")
+
+ def test_occup_final_point(self):
+ r = scrapers_dqsv._fix_occup("Foo bar")
+ self.assertEqual(r, "Foo bar.")
+
+ def test_occup_middle_point(self):
+ r = scrapers_dqsv._fix_occup("Voz de radio. militante feminista")
+ self.assertEqual(r, "Voz de radio. Militante feminista.")
+
+ def test_bio_simple(self):
+ r = scrapers_dqsv._fix_bio(" Foo bar. ")
+ self.assertEqual(r, "Foo bar.")
+
+ def test_name_quote(self):
+ r = scrapers_dqsv._fix_name(u'Juan "Tata" Cedrón')
+ self.assertEqual(r, u'Juan "Tata" Cedrón')
diff --git a/tests/test_encuen_scrapers.py b/tests/test_encuen_scrapers.py
new file mode 100644
index 0000000..2881885
--- /dev/null
+++ b/tests/test_encuen_scrapers.py
@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+
+# Copyright 2012-2013 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Tests for the scrapers of Encuentro itself."""
+
+import unittest
+
+from server import scrapers_encuen
+
+
+_RES_PROGRAMA_1 = None, [
+ (u"¿Dónde está Fierro?", u"Guerras cantadas", "http://www.encuentro.gov.ar/sitios/encuentro/programas/ver?rec_id=117820"),
+ (u"¿Dónde está Fierro?", u"Me tendrán en su memoria para siempre, mis paisanos", "http://www.encuentro.gov.ar/sitios/encuentro/programas/ver?rec_id=117821"),
+ (u"¿Dónde está Fierro?", u"¿Quién es el gaucho?", "http://www.encuentro.gov.ar/sitios/encuentro/programas/ver?rec_id=117822"),
+ (u"¿Dónde está Fierro?", u"¿Dónde está Hernández?", "http://www.encuentro.gov.ar/sitios/encuentro/programas/ver?rec_id=117823"),
+ (u"¿Dónde está Fierro?", u"Los demasiados libros", "http://www.encuentro.gov.ar/sitios/encuentro/programas/ver?rec_id=117824"),
+ (u"¿Dónde está Fierro?", u"Ida y vuelta", "http://www.encuentro.gov.ar/sitios/encuentro/programas/ver?rec_id=117825"),
+ (u"¿Dónde está Fierro?", u"¿Poema épico nacional?", "http://www.encuentro.gov.ar/sitios/encuentro/programas/ver?rec_id=117826"),
+ (u"¿Dónde está Fierro?", u"Mucho más que una payada", "http://www.encuentro.gov.ar/sitios/encuentro/programas/ver?rec_id=117827"),
+ (u"¿Dónde está Fierro?", u"Fronteras", "http://www.encuentro.gov.ar/sitios/encuentro/programas/ver?rec_id=117828"),
+ (u"¿Dónde está Fierro?", u"Fierro en las artes plásticas", "http://www.encuentro.gov.ar/sitios/encuentro/programas/ver?rec_id=117829"),
+ (u"¿Dónde está Fierro?", u"Entre pantallas y escenarios", "http://www.encuentro.gov.ar/sitios/encuentro/programas/ver?rec_id=117830"),
+ (u"¿Dónde está Fierro?", u"Fierro en el cine y el teatro", "http://www.encuentro.gov.ar/sitios/encuentro/programas/ver?rec_id=117831"),
+ (u"¿Dónde está Fierro?", u"Fierro en la música", "http://www.encuentro.gov.ar/sitios/encuentro/programas/ver?rec_id=117832"),
+ (u"¿Dónde está Fierro?", u"Fierro en los argentinos", "http://www.encuentro.gov.ar/sitios/encuentro/programas/ver?rec_id=117833"),
+]
+
+_RES_PROGRAMA_2 = 60, []
+
+
+class ScrapersTestCase(unittest.TestCase):
+ """Tests for the scrapers."""
+
+ maxDiff = None
+
+ def test_example_programa_1(self):
+ html = open("tests/ej-encuen-programa_1.html").read()
+ res = scrapers_encuen.scrap_programa(html)
+ self.assertEqual(res, _RES_PROGRAMA_1)
+
+ def test_example_programa_2(self):
+ html = open("tests/ej-encuen-programa_2.html").read()
+ res = scrapers_encuen.scrap_programa(html)
+ self.assertEqual(res, _RES_PROGRAMA_2)
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
new file mode 100644
index 0000000..b604e10
--- /dev/null
+++ b/tests/test_helpers.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+
+# Copyright 2014 Facundo Batista
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://launchpad.net/encuentro
+
+"""Tests for the helper of scrapers."""
+
+import unittest
+
+from server import helpers
+
+
+class CleanHTMLTestCase(unittest.TestCase):
+ """Test for the HTML cleaning function."""
+
+ def test_remove_tags(self):
+ t = u"foo bar. \n Baz Bardo fruta."
+ r = helpers.clean_html(t)
+ self.assertEqual(r, u"foo bar. \nBaz Bardo fruta.")
+
+ def test_in_the_end_1(self):
+ t = u"Provincia de Salta.
"
+ r = helpers.clean_html(t)
+ self.assertEqual(r, u"Provincia de Salta.")
+
+ def test_in_the_end_2(self):
+ t = u"evento deportivo. "
+ r = helpers.clean_html(t)
+ self.assertEqual(r, u"evento deportivo.")
+
+ def test_tag_complex(self):
+ t = u'Alumnos y docente'
+ r = helpers.clean_html(t)
+ self.assertEqual(r, u"Alumnos y docente")
diff --git a/version.txt b/version.txt
new file mode 100644
index 0000000..879b416
--- /dev/null
+++ b/version.txt
@@ -0,0 +1 @@
+2.1
diff --git a/web/favicon.ico b/web/favicon.ico
new file mode 100644
index 0000000..f43c906
Binary files /dev/null and b/web/favicon.ico differ
diff --git a/web/imgs/screenshot1.png b/web/imgs/screenshot1.png
new file mode 100644
index 0000000..396cf91
Binary files /dev/null and b/web/imgs/screenshot1.png differ
diff --git a/web/imgs/screenshot2.png b/web/imgs/screenshot2.png
new file mode 100644
index 0000000..d383a98
Binary files /dev/null and b/web/imgs/screenshot2.png differ
diff --git a/web/imgs/ssth1.png b/web/imgs/ssth1.png
new file mode 100644
index 0000000..de856cb
Binary files /dev/null and b/web/imgs/ssth1.png differ
diff --git a/web/imgs/ssth2.png b/web/imgs/ssth2.png
new file mode 100644
index 0000000..72e48fc
Binary files /dev/null and b/web/imgs/ssth2.png differ
diff --git a/web/imgs/title.png b/web/imgs/title.png
new file mode 100644
index 0000000..950cbe1
Binary files /dev/null and b/web/imgs/title.png differ
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..7bf68c5
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,223 @@
+
+
+
+ Encuentro
+
+
+
+
+
+
+
+
+
+
+
+
Visualizador de contenidos del Canal Encuentro y otros
+
+
+
+
+
+ Busque, descargue, y vea el maravilloso contenido ofrecido por el Canal Encuentro, Paka Paka, BACUA, Educ.ar y otros.
+
+
+
+ Este es un simple programa que permite buscar, descargar y ver contenido de Encuentro y otros canales. Notar que este programa no distribuye contenido directamente, sino que permite un mejor uso personal de esos contenidos.
+
+
+
+ Por favor, referirse a los sitios web correspondientes para saber qué se puede y qué no se puede hacer con los contenidos de tales sitios.
+
+
+
+ Nota importante: Si tenés una versión anterior a la 2.0, el programa no te va a funcionar correctamente. Tenés que actualizar sí o sí la versión. Esto es porque Encuentro y Conectate reconfiguraron sus portales, por lo que las versiones viejas no te van a funcionar correctamente.
+
Si estás en Fedora o Red Hat, muy pronto vas a poder disfrutar de la versión en los repos, o el .rpm para descargar, mientras tanto usá el tarball...
+
+
Nota: no hay más PPAs para estar automáticamente actualizado en Debian/Ubuntu, porque en esta oportunidad no pude hacer andar toda la maldita maquinaria para generarlos.
+
+
También se puede instalar directamente desde PyPI (no sé cómo se maneja el tema de las dependencias, acá):
+